2023-06-01 06:32:51 -04:00
|
|
|
import { getQueueToken } from '@nestjs/bullmq';
|
2024-04-17 03:00:31 +05:30
|
|
|
import { Inject, Injectable } from '@nestjs/common';
|
2024-10-31 13:42:58 -04:00
|
|
|
import { ModuleRef, Reflector } from '@nestjs/core';
|
2023-10-31 21:19:12 +01:00
|
|
|
import { SchedulerRegistry } from '@nestjs/schedule';
|
2024-10-31 13:42:58 -04:00
|
|
|
import { JobsOptions, Queue, Worker } from 'bullmq';
|
|
|
|
import { ClassConstructor } from 'class-transformer';
|
2024-02-02 04:18:00 +01:00
|
|
|
import { setTimeout } from 'node:timers/promises';
|
2024-10-31 13:42:58 -04:00
|
|
|
import { JobConfig } from 'src/decorators';
|
|
|
|
import { MetadataKey } from 'src/enum';
|
2024-10-17 10:50:54 -04:00
|
|
|
import { IConfigRepository } from 'src/interfaces/config.interface';
|
2024-10-31 13:42:58 -04:00
|
|
|
import { IEventRepository } from 'src/interfaces/event.interface';
|
2024-03-20 22:15:09 -05:00
|
|
|
import {
|
2024-10-18 13:51:34 -06:00
|
|
|
IEntityJob,
|
2024-03-20 22:15:09 -05:00
|
|
|
IJobRepository,
|
|
|
|
JobCounts,
|
|
|
|
JobItem,
|
|
|
|
JobName,
|
2024-10-31 13:42:58 -04:00
|
|
|
JobOf,
|
|
|
|
JobStatus,
|
2024-03-20 22:15:09 -05:00
|
|
|
QueueCleanType,
|
|
|
|
QueueName,
|
|
|
|
QueueStatus,
|
2024-03-21 12:59:49 +01:00
|
|
|
} from 'src/interfaces/job.interface';
|
2024-04-17 03:00:31 +05:30
|
|
|
import { ILoggerRepository } from 'src/interfaces/logger.interface';
|
2024-10-31 13:42:58 -04:00
|
|
|
import { getKeyByValue, getMethodNames, ImmichStartupError } from 'src/utils/misc';
|
2024-03-20 22:15:09 -05:00
|
|
|
|
2024-10-31 13:42:58 -04:00
|
|
|
type JobMapItem = {
|
|
|
|
jobName: JobName;
|
|
|
|
queueName: QueueName;
|
|
|
|
handler: (job: JobOf<any>) => Promise<JobStatus>;
|
|
|
|
label: string;
|
2024-03-20 22:15:09 -05:00
|
|
|
};
|
2023-01-21 11:11:55 -05:00
|
|
|
|
2023-05-26 08:52:52 -04:00
|
|
|
@Injectable()
|
2023-01-21 11:11:55 -05:00
|
|
|
export class JobRepository implements IJobRepository {
|
2023-06-01 06:32:51 -04:00
|
|
|
private workers: Partial<Record<QueueName, Worker>> = {};
|
2024-10-31 13:42:58 -04:00
|
|
|
private handlers: Partial<Record<JobName, JobMapItem>> = {};
|
2023-06-01 06:32:51 -04:00
|
|
|
|
2023-10-31 21:19:12 +01:00
|
|
|
constructor(
|
2024-10-31 13:42:58 -04:00
|
|
|
private moduleRef: ModuleRef,
|
|
|
|
private schedulerRegistry: SchedulerRegistry,
|
2024-10-17 10:50:54 -04:00
|
|
|
@Inject(IConfigRepository) private configRepository: IConfigRepository,
|
2024-10-31 13:42:58 -04:00
|
|
|
@Inject(IEventRepository) private eventRepository: IEventRepository,
|
2024-04-17 03:00:31 +05:30
|
|
|
@Inject(ILoggerRepository) private logger: ILoggerRepository,
|
|
|
|
) {
|
|
|
|
this.logger.setContext(JobRepository.name);
|
|
|
|
}
|
2023-01-21 23:13:36 -05:00
|
|
|
|
2024-10-31 13:42:58 -04:00
|
|
|
setup({ services }: { services: ClassConstructor<unknown>[] }) {
|
|
|
|
const reflector = this.moduleRef.get(Reflector, { strict: false });
|
|
|
|
|
|
|
|
// discovery
|
|
|
|
for (const Service of services) {
|
|
|
|
const instance = this.moduleRef.get<any>(Service);
|
|
|
|
for (const methodName of getMethodNames(instance)) {
|
|
|
|
const handler = instance[methodName];
|
|
|
|
const config = reflector.get<JobConfig>(MetadataKey.JOB_CONFIG, handler);
|
|
|
|
if (!config) {
|
|
|
|
continue;
|
|
|
|
}
|
|
|
|
|
|
|
|
const { name: jobName, queue: queueName } = config;
|
|
|
|
const label = `${Service.name}.${handler.name}`;
|
|
|
|
|
|
|
|
// one handler per job
|
|
|
|
if (this.handlers[jobName]) {
|
|
|
|
const jobKey = getKeyByValue(JobName, jobName);
|
|
|
|
const errorMessage = `Failed to add job handler for ${label}`;
|
|
|
|
this.logger.error(
|
|
|
|
`${errorMessage}. JobName.${jobKey} is already handled by ${this.handlers[jobName].label}.`,
|
|
|
|
);
|
|
|
|
throw new ImmichStartupError(errorMessage);
|
|
|
|
}
|
|
|
|
|
|
|
|
this.handlers[jobName] = {
|
|
|
|
label,
|
|
|
|
jobName,
|
|
|
|
queueName,
|
|
|
|
handler: handler.bind(instance),
|
|
|
|
};
|
|
|
|
|
|
|
|
this.logger.verbose(`Added job handler: ${jobName} => ${label}`);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
// no missing handlers
|
|
|
|
for (const [jobKey, jobName] of Object.entries(JobName)) {
|
|
|
|
const item = this.handlers[jobName];
|
|
|
|
if (!item) {
|
|
|
|
const errorMessage = `Failed to find job handler for Job.${jobKey} ("${jobName}")`;
|
|
|
|
this.logger.error(
|
|
|
|
`${errorMessage}. Make sure to add the @OnJob({ name: JobName.${jobKey}, queue: QueueName.XYZ }) decorator for the new job.`,
|
|
|
|
);
|
|
|
|
throw new ImmichStartupError(errorMessage);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
startWorkers() {
|
2024-10-17 10:50:54 -04:00
|
|
|
const { bull } = this.configRepository.getEnv();
|
2024-10-31 13:42:58 -04:00
|
|
|
for (const queueName of Object.values(QueueName)) {
|
|
|
|
this.logger.debug(`Starting worker for queue: ${queueName}`);
|
|
|
|
this.workers[queueName] = new Worker(
|
|
|
|
queueName,
|
|
|
|
(job) => this.eventRepository.emit('job.start', queueName, job as JobItem),
|
|
|
|
{ ...bull.config, concurrency: 1 },
|
|
|
|
);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
async run({ name, data }: JobItem) {
|
|
|
|
const item = this.handlers[name as JobName];
|
|
|
|
if (!item) {
|
|
|
|
this.logger.warn(`Skipping unknown job: "${name}"`);
|
|
|
|
return JobStatus.SKIPPED;
|
|
|
|
}
|
|
|
|
|
|
|
|
return item.handler(data);
|
2023-06-01 06:32:51 -04:00
|
|
|
}
|
|
|
|
|
|
|
|
setConcurrency(queueName: QueueName, concurrency: number) {
|
|
|
|
const worker = this.workers[queueName];
|
|
|
|
if (!worker) {
|
|
|
|
this.logger.warn(`Unable to set queue concurrency, worker not found: '${queueName}'`);
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
|
|
|
worker.concurrency = concurrency;
|
|
|
|
}
|
|
|
|
|
2023-04-01 22:46:07 +02:00
|
|
|
async getQueueStatus(name: QueueName): Promise<QueueStatus> {
|
2023-05-26 08:52:52 -04:00
|
|
|
const queue = this.getQueue(name);
|
2023-04-01 22:46:07 +02:00
|
|
|
|
|
|
|
return {
|
|
|
|
isActive: !!(await queue.getActiveCount()),
|
|
|
|
isPaused: await queue.isPaused(),
|
|
|
|
};
|
2023-01-21 23:13:36 -05:00
|
|
|
}
|
|
|
|
|
2023-03-20 11:55:28 -04:00
|
|
|
pause(name: QueueName) {
|
2023-05-26 08:52:52 -04:00
|
|
|
return this.getQueue(name).pause();
|
2023-03-20 11:55:28 -04:00
|
|
|
}
|
|
|
|
|
2023-03-28 14:25:22 -04:00
|
|
|
resume(name: QueueName) {
|
2023-05-26 08:52:52 -04:00
|
|
|
return this.getQueue(name).resume();
|
2023-03-28 14:25:22 -04:00
|
|
|
}
|
|
|
|
|
2023-01-21 23:13:36 -05:00
|
|
|
empty(name: QueueName) {
|
2023-06-01 06:32:51 -04:00
|
|
|
return this.getQueue(name).drain();
|
2023-01-21 23:13:36 -05:00
|
|
|
}
|
|
|
|
|
2023-12-05 10:07:20 +08:00
|
|
|
clear(name: QueueName, type: QueueCleanType) {
|
|
|
|
return this.getQueue(name).clean(0, 1000, type);
|
|
|
|
}
|
|
|
|
|
2023-01-21 23:13:36 -05:00
|
|
|
getJobCounts(name: QueueName): Promise<JobCounts> {
|
2023-06-01 06:32:51 -04:00
|
|
|
return this.getQueue(name).getJobCounts(
|
|
|
|
'active',
|
|
|
|
'completed',
|
|
|
|
'failed',
|
|
|
|
'delayed',
|
|
|
|
'waiting',
|
|
|
|
'paused',
|
|
|
|
) as unknown as Promise<JobCounts>;
|
2023-01-21 23:13:36 -05:00
|
|
|
}
|
2023-01-21 11:11:55 -05:00
|
|
|
|
2024-10-31 13:42:58 -04:00
|
|
|
private getQueueName(name: JobName) {
|
|
|
|
return (this.handlers[name] as JobMapItem).queueName;
|
|
|
|
}
|
|
|
|
|
2024-01-01 15:45:42 -05:00
|
|
|
async queueAll(items: JobItem[]): Promise<void> {
|
2024-02-02 04:18:00 +01:00
|
|
|
if (items.length === 0) {
|
2024-01-01 15:45:42 -05:00
|
|
|
return;
|
|
|
|
}
|
2023-01-21 23:13:36 -05:00
|
|
|
|
2024-01-18 00:08:48 -05:00
|
|
|
const promises = [];
|
|
|
|
const itemsByQueue = {} as Record<string, (JobItem & { data: any; options: JobsOptions | undefined })[]>;
|
|
|
|
for (const item of items) {
|
2024-10-31 13:42:58 -04:00
|
|
|
const queueName = this.getQueueName(item.name);
|
2024-01-18 00:08:48 -05:00
|
|
|
const job = {
|
2024-01-01 15:45:42 -05:00
|
|
|
name: item.name,
|
2024-01-18 00:08:48 -05:00
|
|
|
data: item.data || {},
|
2024-01-01 15:45:42 -05:00
|
|
|
options: this.getJobOptions(item) || undefined,
|
2024-01-18 00:08:48 -05:00
|
|
|
} as JobItem & { data: any; options: JobsOptions | undefined };
|
|
|
|
|
|
|
|
if (job.options?.jobId) {
|
|
|
|
// need to use add() instead of addBulk() for jobId deduplication
|
|
|
|
promises.push(this.getQueue(queueName).add(item.name, item.data, job.options));
|
|
|
|
} else {
|
|
|
|
itemsByQueue[queueName] = itemsByQueue[queueName] || [];
|
|
|
|
itemsByQueue[queueName].push(job);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
for (const [queueName, jobs] of Object.entries(itemsByQueue)) {
|
|
|
|
const queue = this.getQueue(queueName as QueueName);
|
|
|
|
promises.push(queue.addBulk(jobs));
|
2024-01-01 15:45:42 -05:00
|
|
|
}
|
2024-01-18 00:08:48 -05:00
|
|
|
|
|
|
|
await Promise.all(promises);
|
2024-01-01 15:45:42 -05:00
|
|
|
}
|
|
|
|
|
|
|
|
async queue(item: JobItem): Promise<void> {
|
2024-01-18 00:08:48 -05:00
|
|
|
return this.queueAll([item]);
|
|
|
|
}
|
|
|
|
|
|
|
|
async waitForQueueCompletion(...queues: QueueName[]): Promise<void> {
|
|
|
|
let activeQueue: QueueStatus | undefined;
|
|
|
|
do {
|
|
|
|
const statuses = await Promise.all(queues.map((name) => this.getQueueStatus(name)));
|
|
|
|
activeQueue = statuses.find((status) => status.isActive);
|
|
|
|
} while (activeQueue);
|
|
|
|
{
|
|
|
|
this.logger.verbose(`Waiting for ${activeQueue} queue to stop...`);
|
|
|
|
await setTimeout(1000);
|
|
|
|
}
|
2023-05-26 08:52:52 -04:00
|
|
|
}
|
feat(server): xmp sidecar metadata (#2466)
* initial commit for XMP sidecar support
* Added support for 'missing' metadata files to include those without sidecar files, now detects sidecar files in the filesystem for media already ingested but the sidecar was created afterwards
* didn't mean to commit default log level during testing
* new sidecar logic for video metadata as well
* Added xml mimetype for sidecars only
* don't need capture group for this regex
* wrong default value reverted
* simplified the move here - keep it in the same try catch since the outcome is to move the media back anyway
* simplified setter logic
Co-authored-by: Jason Rasmussen <jrasm91@gmail.com>
* simplified logic per suggestions
* sidecar is now its own queue with a discover and sync, updated UI for the new job queueing
* queue a sidecar job for every asset based on discovery or sync, though the logic is almost identical aside from linking the sidecar
* now queue sidecar jobs for each assset, though logic is mostly the same between discovery and sync
* simplified logic of filename extraction and asset instantiation
* not sure how that got deleted..
* updated code per suggestions and comments in the PR
* stat was not being used, removed the variable set
* better type checking, using in-scope variables for exif getter instead of passing in every time
* removed commented out test
* ran and resolved all lints, formats, checks, and tests
* resolved suggested change in PR
* made getExifProperty more dynamic with multiple possible args for fallbacks, fixed typo, used generic in function for better type checking
* better error handling and moving files back to positions on move or save failure
* regenerated api
* format fixes
* Added XMP documentation
* documentation typo
* Merged in main
* missed merge conflict
* more changes due to a merge
* Resolving conflicts
* added icon for sidecar jobs
---------
Co-authored-by: Jason Rasmussen <jrasm91@gmail.com>
Co-authored-by: Alex Tran <alex.tran1502@gmail.com>
2023-05-24 21:59:30 -04:00
|
|
|
|
2023-06-01 06:32:51 -04:00
|
|
|
private getJobOptions(item: JobItem): JobsOptions | null {
|
2023-05-26 08:52:52 -04:00
|
|
|
switch (item.name) {
|
2024-10-18 13:51:34 -06:00
|
|
|
case JobName.NOTIFY_ALBUM_UPDATE: {
|
|
|
|
return { jobId: item.data.id, delay: item.data?.delay };
|
|
|
|
}
|
2024-02-02 04:18:00 +01:00
|
|
|
case JobName.STORAGE_TEMPLATE_MIGRATION_SINGLE: {
|
2023-10-11 04:14:44 +02:00
|
|
|
return { jobId: item.data.id };
|
2024-02-02 04:18:00 +01:00
|
|
|
}
|
|
|
|
case JobName.GENERATE_PERSON_THUMBNAIL: {
|
2023-05-26 08:52:52 -04:00
|
|
|
return { priority: 1 };
|
2024-02-02 04:18:00 +01:00
|
|
|
}
|
|
|
|
case JobName.QUEUE_FACIAL_RECOGNITION: {
|
2024-01-18 00:08:48 -05:00
|
|
|
return { jobId: JobName.QUEUE_FACIAL_RECOGNITION };
|
2024-02-02 04:18:00 +01:00
|
|
|
}
|
|
|
|
default: {
|
2023-05-26 08:52:52 -04:00
|
|
|
return null;
|
2024-02-02 04:18:00 +01:00
|
|
|
}
|
2023-01-21 11:11:55 -05:00
|
|
|
}
|
|
|
|
}
|
2023-05-26 08:52:52 -04:00
|
|
|
|
2023-09-20 13:16:33 +02:00
|
|
|
private getQueue(queue: QueueName): Queue {
|
2024-10-31 13:42:58 -04:00
|
|
|
return this.moduleRef.get<Queue>(getQueueToken(queue), { strict: false });
|
2023-05-26 08:52:52 -04:00
|
|
|
}
|
2024-10-18 13:51:34 -06:00
|
|
|
|
|
|
|
public async removeJob(jobId: string, name: JobName): Promise<IEntityJob | undefined> {
|
2024-10-31 13:42:58 -04:00
|
|
|
const existingJob = await this.getQueue(this.getQueueName(name)).getJob(jobId);
|
2024-10-18 13:51:34 -06:00
|
|
|
if (!existingJob) {
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
try {
|
|
|
|
await existingJob.remove();
|
|
|
|
} catch (error: any) {
|
|
|
|
if (error.message?.includes('Missing key for job')) {
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
throw error;
|
|
|
|
}
|
|
|
|
return existingJob.data;
|
|
|
|
}
|
2023-01-21 11:11:55 -05:00
|
|
|
}
|