From 788b435f9b6fbfea2029918f3c7783ef94a7ae8e Mon Sep 17 00:00:00 2001 From: Alex Date: Thu, 26 Jan 2023 22:50:22 -0600 Subject: [PATCH] feat(web/server): Add options to rerun job on all assets (#1422) --- mobile/openapi/doc/JobCommandDto.md | Bin 431 -> 469 bytes mobile/openapi/lib/model/job_command_dto.dart | Bin 3309 -> 3649 bytes mobile/openapi/test/job_command_dto_test.dart | Bin 565 -> 680 bytes .../src/api-v1/asset/asset-repository.ts | 18 +++ .../src/api-v1/asset/asset.service.spec.ts | 2 + .../src/api-v1/job/dto/job-command.dto.ts | 6 +- .../immich/src/api-v1/job/job.controller.ts | 8 +- .../apps/immich/src/api-v1/job/job.service.ts | 43 ++++-- .../schedule-tasks/schedule-tasks.service.ts | 90 +------------ .../apps/immich/src/utils/file-name.util.ts | 5 + .../metadata-extraction.processor.ts | 4 +- .../processors/video-transcode.processor.ts | 15 +-- server/immich-openapi-specs.json | 6 +- web/src/api/open-api/api.ts | 6 + web/src/app.css | 4 + .../admin-page/jobs/job-tile.svelte | 122 +++++++++++------- .../admin-page/jobs/jobs-panel.svelte | 70 +++++++--- 17 files changed, 217 insertions(+), 182 deletions(-) create mode 100644 server/apps/immich/src/utils/file-name.util.ts diff --git a/mobile/openapi/doc/JobCommandDto.md b/mobile/openapi/doc/JobCommandDto.md index 4e87fde8e894b9a9f0e4b5e3902ecca528d0460c..68cbee51a82ff3ae674d6fdf54a6c437dfc2593d 100644 GIT binary patch delta 43 ycmZ3_e3f|v7o)tER%TvuPH9T2V@{4^adB!%v6hxXje?d|Qht7pmeyou#x(#p3k}c! delta 11 Scmcc0yq|#^6AQu;b4pYF(?Ti>Qf)wfw$lKa ducLs)g3V{xq}k*Y6fmvU0s5@gnyZ$J3jj#feFFdh delta 44 zcmX>o^Hy@h6voLr8098^V630Kh$(5Z8nf%d(s3?FT$jW2@09}0) Ab^rhX delta 11 ScmZ3%x|L;vHq+#JOk4mM0t0~n diff --git a/server/apps/immich/src/api-v1/asset/asset-repository.ts b/server/apps/immich/src/api-v1/asset/asset-repository.ts index af02ff6ab0..891c0c708f 100644 --- a/server/apps/immich/src/api-v1/asset/asset-repository.ts +++ b/server/apps/immich/src/api-v1/asset/asset-repository.ts @@ -29,6 +29,8 @@ export interface IAssetRepository { livePhotoAssetEntity?: AssetEntity, ): Promise; update(userId: string, asset: AssetEntity, dto: UpdateAssetDto): Promise; + getAll(): Promise; + getAllVideos(): Promise; getAllByUserId(userId: string, dto: AssetSearchDto): Promise; getAllByDeviceId(userId: string, deviceId: string): Promise; getById(assetId: string): Promise; @@ -61,6 +63,22 @@ export class AssetRepository implements IAssetRepository { @Inject(ITagRepository) private _tagRepository: ITagRepository, ) {} + async getAllVideos(): Promise { + return await this.assetRepository.find({ + where: { type: AssetType.VIDEO }, + }); + } + + async getAll(): Promise { + return await this.assetRepository.find({ + where: { isVisible: true }, + relations: { + exifInfo: true, + smartInfo: true, + }, + }); + } + async getAssetWithNoSmartInfo(): Promise { return await this.assetRepository .createQueryBuilder('asset') diff --git a/server/apps/immich/src/api-v1/asset/asset.service.spec.ts b/server/apps/immich/src/api-v1/asset/asset.service.spec.ts index 44f84e3556..7dfb31cbec 100644 --- a/server/apps/immich/src/api-v1/asset/asset.service.spec.ts +++ b/server/apps/immich/src/api-v1/asset/asset.service.spec.ts @@ -123,6 +123,8 @@ describe('AssetService', () => { assetRepositoryMock = { create: jest.fn(), update: jest.fn(), + getAll: jest.fn(), + getAllVideos: jest.fn(), getAllByUserId: jest.fn(), getAllByDeviceId: jest.fn(), getAssetCountByTimeBucket: jest.fn(), diff --git a/server/apps/immich/src/api-v1/job/dto/job-command.dto.ts b/server/apps/immich/src/api-v1/job/dto/job-command.dto.ts index f63f0fa517..5984404184 100644 --- a/server/apps/immich/src/api-v1/job/dto/job-command.dto.ts +++ b/server/apps/immich/src/api-v1/job/dto/job-command.dto.ts @@ -1,5 +1,5 @@ import { ApiProperty } from '@nestjs/swagger'; -import { IsIn, IsNotEmpty } from 'class-validator'; +import { IsBoolean, IsIn, IsNotEmpty, IsOptional } from 'class-validator'; export class JobCommandDto { @IsNotEmpty() @@ -9,4 +9,8 @@ export class JobCommandDto { enumName: 'JobCommand', }) command!: string; + + @IsOptional() + @IsBoolean() + includeAllAssets!: boolean; } diff --git a/server/apps/immich/src/api-v1/job/job.controller.ts b/server/apps/immich/src/api-v1/job/job.controller.ts index 5dcb3e7a0c..a6228ddcc1 100644 --- a/server/apps/immich/src/api-v1/job/job.controller.ts +++ b/server/apps/immich/src/api-v1/job/job.controller.ts @@ -21,12 +21,12 @@ export class JobController { @Put('/:jobId') async sendJobCommand( @Param(ValidationPipe) params: GetJobDto, - @Body(ValidationPipe) body: JobCommandDto, + @Body(ValidationPipe) dto: JobCommandDto, ): Promise { - if (body.command === 'start') { - return await this.jobService.start(params.jobId); + if (dto.command === 'start') { + return await this.jobService.start(params.jobId, dto.includeAllAssets); } - if (body.command === 'stop') { + if (dto.command === 'stop') { return await this.jobService.stop(params.jobId); } return 0; diff --git a/server/apps/immich/src/api-v1/job/job.service.ts b/server/apps/immich/src/api-v1/job/job.service.ts index ea45f7aca8..ca31d3562e 100644 --- a/server/apps/immich/src/api-v1/job/job.service.ts +++ b/server/apps/immich/src/api-v1/job/job.service.ts @@ -5,7 +5,7 @@ import { IAssetRepository } from '../asset/asset-repository'; import { AssetType } from '@app/infra'; import { JobId } from './dto/get-job.dto'; import { MACHINE_LEARNING_ENABLED } from '@app/common'; - +import { getFileNameWithoutExtension } from '../../utils/file-name.util'; const jobIds = Object.values(JobId) as JobId[]; @Injectable() @@ -19,8 +19,8 @@ export class JobService { } } - start(jobId: JobId): Promise { - return this.run(this.asQueueName(jobId)); + start(jobId: JobId, includeAllAssets: boolean): Promise { + return this.run(this.asQueueName(jobId), includeAllAssets); } async stop(jobId: JobId): Promise { @@ -36,7 +36,7 @@ export class JobService { return response; } - private async run(name: QueueName): Promise { + private async run(name: QueueName, includeAllAssets: boolean): Promise { const isActive = await this.jobRepository.isActive(name); if (isActive) { throw new BadRequestException(`Job is already running`); @@ -44,7 +44,9 @@ export class JobService { switch (name) { case QueueName.VIDEO_CONVERSION: { - const assets = await this._assetRepository.getAssetWithNoEncodedVideo(); + const assets = includeAllAssets + ? await this._assetRepository.getAllVideos() + : await this._assetRepository.getAssetWithNoEncodedVideo(); for (const asset of assets) { await this.jobRepository.add({ name: JobName.VIDEO_CONVERSION, data: { asset } }); } @@ -61,7 +63,10 @@ export class JobService { throw new BadRequestException('Machine learning is not enabled.'); } - const assets = await this._assetRepository.getAssetWithNoSmartInfo(); + const assets = includeAllAssets + ? await this._assetRepository.getAll() + : await this._assetRepository.getAssetWithNoSmartInfo(); + for (const asset of assets) { await this.jobRepository.add({ name: JobName.IMAGE_TAGGING, data: { asset } }); await this.jobRepository.add({ name: JobName.OBJECT_DETECTION, data: { asset } }); @@ -70,19 +75,37 @@ export class JobService { } case QueueName.METADATA_EXTRACTION: { - const assets = await this._assetRepository.getAssetWithNoEXIF(); + const assets = includeAllAssets + ? await this._assetRepository.getAll() + : await this._assetRepository.getAssetWithNoEXIF(); + for (const asset of assets) { if (asset.type === AssetType.VIDEO) { - await this.jobRepository.add({ name: JobName.EXTRACT_VIDEO_METADATA, data: { asset, fileName: asset.id } }); + await this.jobRepository.add({ + name: JobName.EXTRACT_VIDEO_METADATA, + data: { + asset, + fileName: asset.exifInfo?.imageName ?? getFileNameWithoutExtension(asset.originalPath), + }, + }); } else { - await this.jobRepository.add({ name: JobName.EXIF_EXTRACTION, data: { asset, fileName: asset.id } }); + await this.jobRepository.add({ + name: JobName.EXIF_EXTRACTION, + data: { + asset, + fileName: asset.exifInfo?.imageName ?? getFileNameWithoutExtension(asset.originalPath), + }, + }); } } return assets.length; } case QueueName.THUMBNAIL_GENERATION: { - const assets = await this._assetRepository.getAssetWithNoThumbnail(); + const assets = includeAllAssets + ? await this._assetRepository.getAll() + : await this._assetRepository.getAssetWithNoThumbnail(); + for (const asset of assets) { await this.jobRepository.add({ name: JobName.GENERATE_JPEG_THUMBNAIL, data: { asset } }); } diff --git a/server/apps/immich/src/modules/schedule-tasks/schedule-tasks.service.ts b/server/apps/immich/src/modules/schedule-tasks/schedule-tasks.service.ts index e77163bf02..55b7ce6864 100644 --- a/server/apps/immich/src/modules/schedule-tasks/schedule-tasks.service.ts +++ b/server/apps/immich/src/modules/schedule-tasks/schedule-tasks.service.ts @@ -1,9 +1,8 @@ -import { Inject, Injectable, Logger } from '@nestjs/common'; +import { Inject, Injectable } from '@nestjs/common'; import { Cron, CronExpression } from '@nestjs/schedule'; import { InjectRepository } from '@nestjs/typeorm'; import { IsNull, Not, Repository } from 'typeorm'; -import { AssetEntity, AssetType, ExifEntity, UserEntity } from '@app/infra'; -import { ConfigService } from '@nestjs/config'; +import { UserEntity } from '@app/infra'; import { userUtils } from '@app/common'; import { IJobRepository, JobName } from '@app/domain'; @@ -13,93 +12,8 @@ export class ScheduleTasksService { @InjectRepository(UserEntity) private userRepository: Repository, - @InjectRepository(AssetEntity) - private assetRepository: Repository, - - @InjectRepository(ExifEntity) - private exifRepository: Repository, - @Inject(IJobRepository) private jobRepository: IJobRepository, - - private configService: ConfigService, ) {} - - @Cron(CronExpression.EVERY_DAY_AT_MIDNIGHT) - async webpConversion() { - const assets = await this.assetRepository.find({ - where: { - webpPath: '', - }, - }); - - if (assets.length == 0) { - Logger.log('All assets has webp file - aborting task', 'CronjobWebpGenerator'); - return; - } - - for (const asset of assets) { - await this.jobRepository.add({ name: JobName.GENERATE_WEBP_THUMBNAIL, data: { asset } }); - } - } - - @Cron(CronExpression.EVERY_DAY_AT_1AM) - async videoConversion() { - const assets = await this.assetRepository.find({ - where: { - type: AssetType.VIDEO, - mimeType: 'video/quicktime', - encodedVideoPath: '', - }, - order: { - createdAt: 'DESC', - }, - }); - - for (const asset of assets) { - await this.jobRepository.add({ name: JobName.VIDEO_CONVERSION, data: { asset } }); - } - } - - @Cron(CronExpression.EVERY_DAY_AT_2AM) - async reverseGeocoding() { - const isGeocodingEnabled = this.configService.get('DISABLE_REVERSE_GEOCODING') !== 'true'; - - if (isGeocodingEnabled) { - const exifInfo = await this.exifRepository.find({ - where: { - city: IsNull(), - longitude: Not(IsNull()), - latitude: Not(IsNull()), - }, - }); - - for (const exif of exifInfo) { - await this.jobRepository.add({ - name: JobName.REVERSE_GEOCODING, - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - data: { exifId: exif.id, latitude: exif.latitude!, longitude: exif.longitude! }, - }); - } - } - } - - @Cron(CronExpression.EVERY_DAY_AT_3AM) - async extractExif() { - const exifAssets = await this.assetRepository - .createQueryBuilder('asset') - .leftJoinAndSelect('asset.exifInfo', 'ei') - .where('ei."assetId" IS NULL') - .getMany(); - - for (const asset of exifAssets) { - if (asset.type === AssetType.VIDEO) { - await this.jobRepository.add({ name: JobName.EXTRACT_VIDEO_METADATA, data: { asset, fileName: asset.id } }); - } else { - await this.jobRepository.add({ name: JobName.EXIF_EXTRACTION, data: { asset, fileName: asset.id } }); - } - } - } - @Cron(CronExpression.EVERY_DAY_AT_11PM) async deleteUserAndRelatedAssets() { const usersToDelete = await this.userRepository.find({ withDeleted: true, where: { deletedAt: Not(IsNull()) } }); diff --git a/server/apps/immich/src/utils/file-name.util.ts b/server/apps/immich/src/utils/file-name.util.ts new file mode 100644 index 0000000000..b575ae5f0b --- /dev/null +++ b/server/apps/immich/src/utils/file-name.util.ts @@ -0,0 +1,5 @@ +import { basename, extname } from 'node:path'; + +export function getFileNameWithoutExtension(path: string): string { + return basename(path, extname(path)); +} diff --git a/server/apps/microservices/src/processors/metadata-extraction.processor.ts b/server/apps/microservices/src/processors/metadata-extraction.processor.ts index 7e6e9dadd7..36f9ca3cb6 100644 --- a/server/apps/microservices/src/processors/metadata-extraction.processor.ts +++ b/server/apps/microservices/src/processors/metadata-extraction.processor.ts @@ -216,7 +216,7 @@ export class MetadataExtractionProcessor { } } - await this.exifRepository.save(newExif); + await this.exifRepository.upsert(newExif, { conflictPaths: ['assetId'] }); } catch (error: any) { this.logger.error(`Error extracting EXIF ${error}`, error?.stack); } @@ -327,7 +327,7 @@ export class MetadataExtractionProcessor { } } - await this.exifRepository.save(newExif); + await this.exifRepository.upsert(newExif, { conflictPaths: ['assetId'] }); await this.assetRepository.update({ id: asset.id }, { duration: durationString, createdAt: createdAt }); } catch (err) { // do nothing diff --git a/server/apps/microservices/src/processors/video-transcode.processor.ts b/server/apps/microservices/src/processors/video-transcode.processor.ts index 52557e62fe..34ea3e297a 100644 --- a/server/apps/microservices/src/processors/video-transcode.processor.ts +++ b/server/apps/microservices/src/processors/video-transcode.processor.ts @@ -11,6 +11,7 @@ import { Repository } from 'typeorm'; @Processor(QueueName.VIDEO_CONVERSION) export class VideoTranscodeProcessor { + readonly logger = new Logger(VideoTranscodeProcessor.name); constructor( @InjectRepository(AssetEntity) private assetRepository: Repository, @@ -20,7 +21,6 @@ export class VideoTranscodeProcessor { @Process({ name: JobName.VIDEO_CONVERSION, concurrency: 2 }) async videoConversion(job: Job) { const { asset } = job.data; - const basePath = APP_UPLOAD_LOCATION; const encodedVideoPath = `${basePath}/${asset.userId}/encoded-video`; @@ -30,17 +30,14 @@ export class VideoTranscodeProcessor { const savedEncodedPath = `${encodedVideoPath}/${asset.id}.mp4`; - if (!asset.encodedVideoPath) { - // Put the processing into its own async function to prevent the job exist right away - await this.runVideoEncode(asset, savedEncodedPath); - } + await this.runVideoEncode(asset, savedEncodedPath); } async runFFProbePipeline(asset: AssetEntity): Promise { return new Promise((resolve, reject) => { ffmpeg.ffprobe(asset.originalPath, (err, data) => { if (err || !data) { - Logger.error(`Cannot probe video ${err}`, 'mp4Conversion'); + this.logger.error(`Cannot probe video ${err}`, 'runFFProbePipeline'); reject(err); } @@ -88,14 +85,14 @@ export class VideoTranscodeProcessor { ]) .output(savedEncodedPath) .on('start', () => { - Logger.log('Start Converting Video', 'mp4Conversion'); + this.logger.log('Start Converting Video'); }) .on('error', (error) => { - Logger.error(`Cannot Convert Video ${error}`, 'mp4Conversion'); + this.logger.error(`Cannot Convert Video ${error}`); reject(); }) .on('end', async () => { - Logger.log(`Converting Success ${asset.id}`, 'mp4Conversion'); + this.logger.log(`Converting Success ${asset.id}`); await this.assetRepository.update({ id: asset.id }, { encodedVideoPath: savedEncodedPath }); resolve(); }) diff --git a/server/immich-openapi-specs.json b/server/immich-openapi-specs.json index eedc18f5c2..1641c9bf8b 100644 --- a/server/immich-openapi-specs.json +++ b/server/immich-openapi-specs.json @@ -4538,10 +4538,14 @@ "properties": { "command": { "$ref": "#/components/schemas/JobCommand" + }, + "includeAllAssets": { + "type": "boolean" } }, "required": [ - "command" + "command", + "includeAllAssets" ] } } diff --git a/web/src/api/open-api/api.ts b/web/src/api/open-api/api.ts index f7333f306d..a128f4f51e 100644 --- a/web/src/api/open-api/api.ts +++ b/web/src/api/open-api/api.ts @@ -1203,6 +1203,12 @@ export interface JobCommandDto { * @memberof JobCommandDto */ 'command': JobCommand; + /** + * + * @type {boolean} + * @memberof JobCommandDto + */ + 'includeAllAssets': boolean; } /** * diff --git a/web/src/app.css b/web/src/app.css index a683e10174..9324af9ff7 100644 --- a/web/src/app.css +++ b/web/src/app.css @@ -101,4 +101,8 @@ input:focus-visible { display: none; scrollbar-width: none; } + + .job-play-button { + @apply h-full flex flex-col place-items-center place-content-center px-8 text-gray-600 transition-all hover:bg-immich-primary hover:text-white dark:text-gray-200 dark:hover:bg-immich-dark-primary text-sm dark:hover:text-black w-[120px] gap-2; + } } diff --git a/web/src/lib/components/admin-page/jobs/job-tile.svelte b/web/src/lib/components/admin-page/jobs/job-tile.svelte index 134ad611fe..9ea6839d15 100644 --- a/web/src/lib/components/admin-page/jobs/job-tile.svelte +++ b/web/src/lib/components/admin-page/jobs/job-tile.svelte @@ -1,76 +1,102 @@ -
-
-

- {title.toUpperCase()} -

-

{subtitle}

-

- -

- - - - - - - - - - - - - - - - -
StatusActiveWaiting
- {#if jobCounts} - {jobCounts.active > 0 || jobCounts.waiting > 0 ? 'Active' : 'Idle'} - {:else} - - {/if} - +
+
+
+
+ {title.toUpperCase()} +
+ + {#if subtitle.length > 0} +
{subtitle}
+ {/if} +
+ +
+
+

Active

+

{#if jobCounts.active !== undefined} {jobCounts.active} {:else} {/if} -

+

+ + +
+

{#if jobCounts.waiting !== undefined} {jobCounts.waiting} {:else} {/if} -

+

+

Waiting

+
+
+ -
- + {/if} + + {#if !isRunning} + {#if showOptions} + + {:else} - {buttonTitle} + {/if} - + {/if}
diff --git a/web/src/lib/components/admin-page/jobs/jobs-panel.svelte b/web/src/lib/components/admin-page/jobs/jobs-panel.svelte index 901a11a0ee..815cfe6fe9 100644 --- a/web/src/lib/components/admin-page/jobs/jobs-panel.svelte +++ b/web/src/lib/components/admin-page/jobs/jobs-panel.svelte @@ -18,20 +18,28 @@ onMount(async () => { await load(); - timer = setInterval(async () => await load(), 5_000); + timer = setInterval(async () => await load(), 1_000); }); onDestroy(() => { clearInterval(timer); }); - const run = async (jobId: JobId, jobName: string, emptyMessage: string) => { + const run = async ( + jobId: JobId, + jobName: string, + emptyMessage: string, + includeAllAssets: boolean + ) => { try { - const { data } = await api.jobApi.sendJobCommand(jobId, { command: JobCommand.Start }); + const { data } = await api.jobApi.sendJobCommand(jobId, { + command: JobCommand.Start, + includeAllAssets + }); if (data) { notificationController.show({ - message: `Started ${jobName}`, + message: includeAllAssets ? `Started ${jobName} for all assets` : `Started ${jobName}`, type: NotificationType.Info }); } else { @@ -43,53 +51,77 @@ }; -
+
{#if jobs} - run(JobId.ThumbnailGeneration, 'thumbnail generation', 'No missing thumbnails found')} + subtitle={'Regenerate JPEG and WebP thumbnails'} + on:click={(e) => { + const { includeAllAssets } = e.detail; + + run( + JobId.ThumbnailGeneration, + 'thumbnail generation', + 'No missing thumbnails found', + includeAllAssets + ); + }} jobCounts={jobs[JobId.ThumbnailGeneration]} /> run(JobId.MetadataExtraction, 'extract EXIF', 'No missing EXIF found')} + title={'EXTRACT METADATA'} + subtitle={'Extract metadata information i.e. GPS, resolution...etc'} + on:click={(e) => { + const { includeAllAssets } = e.detail; + run(JobId.MetadataExtraction, 'extract EXIF', 'No missing EXIF found', includeAllAssets); + }} jobCounts={jobs[JobId.MetadataExtraction]} /> - run(JobId.MachineLearning, 'object detection', 'No missing object detection found')} + on:click={(e) => { + const { includeAllAssets } = e.detail; + + run( + JobId.MachineLearning, + 'object detection', + 'No missing object detection found', + includeAllAssets + ); + }} jobCounts={jobs[JobId.MachineLearning]} > - Note that some assets may not have any objects detected, this is normal. + Note that some assets may not have any objects detected + subtitle={'Transcode videos not in the desired format'} + on:click={(e) => { + const { includeAllAssets } = e.detail; run( JobId.VideoConversion, 'video conversion', - 'No videos without an encoded version found' - )} + 'No videos without an encoded version found', + includeAllAssets + ); + }} jobCounts={jobs[JobId.VideoConversion]} /> run( JobId.StorageTemplateMigration, 'storage template migration', - 'All files have been migrated to the new storage template' + 'All files have been migrated to the new storage template', + false )} jobCounts={jobs[JobId.StorageTemplateMigration]} >