From b74b20824a1c0aa238a08e59a327307526016ad3 Mon Sep 17 00:00:00 2001 From: Jason Rasmussen <jason@rasm.me> Date: Mon, 16 Sep 2024 16:49:12 -0400 Subject: [PATCH] feat: tag cleanup job (#12654) --- mobile/openapi/README.md | Bin 31759 -> 31916 bytes mobile/openapi/lib/api.dart | Bin 11346 -> 11415 bytes mobile/openapi/lib/api/jobs_api.dart | Bin 3553 -> 4649 bytes mobile/openapi/lib/api_client.dart | Bin 29270 -> 29439 bytes mobile/openapi/lib/api_helper.dart | Bin 6474 -> 6578 bytes mobile/openapi/lib/model/job_create_dto.dart | Bin 0 -> 2700 bytes mobile/openapi/lib/model/manual_job_name.dart | Bin 0 -> 2828 bytes open-api/immich-openapi-specs.json | 52 +++++++++++++++ open-api/typescript-sdk/src/fetch-client.ts | 17 +++++ server/src/controllers/job.controller.ts | 10 ++- server/src/dtos/job.dto.ts | 7 ++ server/src/enum.ts | 6 ++ server/src/interfaces/job.interface.ts | 6 ++ server/src/interfaces/tag.interface.ts | 1 + server/src/repositories/job.repository.ts | 3 + server/src/repositories/tag.repository.ts | 39 ++++++++++- server/src/services/job.service.ts | 28 +++++++- server/src/services/microservices.service.ts | 3 + server/src/services/tag.service.ts | 6 ++ .../test/repositories/tag.repository.mock.ts | 1 + .../shared-components/combobox.svelte | 2 +- web/src/lib/i18n/en.json | 6 ++ web/src/routes/admin/jobs-status/+page.svelte | 62 +++++++++++++++++- 23 files changed, 239 insertions(+), 10 deletions(-) create mode 100644 mobile/openapi/lib/model/job_create_dto.dart create mode 100644 mobile/openapi/lib/model/manual_job_name.dart diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index 36b2c7bbf46132a6b91e74ee8e7ec4f148f07d40..16f293f81a6d38b717cad2de8c446aaec7ac0829 100644 GIT binary patch delta 119 zcmeDG!MNrp;|2v;&g7!h#FA96{G`bSvLZqdURHjRrb3N^mR5j&@Z=a-nav8a8yzH_ w!75!!@?$kp@{{%TfqEc9n-9gZG0EqZCguQz{1S6hp$dExk;FFdjSrFm0NA@KP5=M^ delta 24 gcmZ4Uld=B?;|2xU&DpXW9X7kfeP`U9n&2)20F3<#Z~y=R diff --git a/mobile/openapi/lib/api.dart b/mobile/openapi/lib/api.dart index 091e900145ab3366b14f70a91d3eb52b1baac2e8..915c70f08eb2654a9016f254bdea978c0d0391e4 100644 GIT binary patch delta 42 xcmcZ<F+Fm_GU3TgVseukgjqHNnT$MnrHMK5S@}uvd5O7`3k5|tn~O?|0{~b=4#NNd delta 17 ZcmbOpc`0JUGU3f9geNm@t`n6M2LMX<2OIzZ diff --git a/mobile/openapi/lib/api/jobs_api.dart b/mobile/openapi/lib/api/jobs_api.dart index 5f9501d126f8e5c0e38f9e3d86c62745ac53f1fe..78afc15c93580aed4c8f5c471498cd115904f348 100644 GIT binary patch delta 255 zcmaDTy;5bv4o2nx|KQ2H7!4<fvnz=>7o{eaq`H*k$0}syCqa0Vx3MehB!eZq@{__d zOENr4N(wyl((*Nce7FuI4H6vwlT(<iCtu)TX9eq<e3#=36VRG^=E;*&xs-z8nvkpk zTB@K?lv-GtS(KWhX~m_W;8t2vT9j&2mY<norvP^VE=%RPHRa)oP+W=dNg?-)$t>*B Kn|Cm3@&EuFHd}xI delta 11 ScmZ3f@=$ui4#rJvnmhm<)C3>^ diff --git a/mobile/openapi/lib/api_client.dart b/mobile/openapi/lib/api_client.dart index 9ec00aecc87aadceb5e04fb3e5baac28bdbf126e..6a40de730c0021246c84e3a71a737f9eddbd061e 100644 GIT binary patch delta 58 zcmccigz^7V#tlwplPAW=GJEAGP2L!zx!K-q2{TJxX=2V~doyWX-$W1-DCd`$n>x8r LU4QdK%TfyfzvC9p delta 19 bcmezWl=0dV#tlwpoBPZbGH>p&Dz*RsW$y_H diff --git a/mobile/openapi/lib/api_helper.dart b/mobile/openapi/lib/api_helper.dart index 8dcef880f59a46e42a8fd528c39af79f2311f9d9..0f3cc4109727640f5626bedf02916b69215c80b5 100644 GIT binary patch delta 32 lcmX?Qw8?nGWNwzc(!`v}eQaWEzKLMwTQ0ZF6S;Tt0szm)3~B%X delta 12 UcmdmFe9CCUWbVzsxwrEI04E6rtpET3 diff --git a/mobile/openapi/lib/model/job_create_dto.dart b/mobile/openapi/lib/model/job_create_dto.dart new file mode 100644 index 0000000000000000000000000000000000000000..a4734791bbcede135306ddf59d38eaa8939062d6 GIT binary patch literal 2700 zcmbVOU2oeq6n*!vxB-S(0aUr$Q<2PWi^dt6H8#*@4})O{v_x5KWl|%l8b<2>zI#bg zkybAaYQVNc-OqE*B{i9hCKGu3*ZuOvAM?fh<J<fBHQc=aFpuGS35(@D+%2!)-~4@s zW@Py$XWCAGOMZPdqFb?*O7pZ(IxR%SFQJi*;d#m{zT?uy;a==&rR_-%R_)orq;+Fc z&HrhHMt8|}_**lL|CVcm!L>Q<o+)W8lQtDOCKQF>+PRyP$toeaNh>9{Xl6?$(^oIk ztYq4ZMwrfmDnQj-vRWkgy&8?OiWvj9YV%HWX8E;M@Gt_HVDlfjZL~DNK=M7T`ZjA2 zuB?nFXxx7ikvBkS6>KR?LjA_Le!Rep0C!9_tU|8^t^+-E%Wu?It~K_FCL5(Hs#G(p zG)$+_a`TyISidc~zJTcrJRTXVB1#d5C*QvLD-i$b>ipal<^s5aFzV^tLR#2z3neq< zJC$?H=3F~HkPIZxxG_v0h(=_l<VF~07Ewb<aP}SdTl-|SkXx|IUqr5(aUa7KMVS6V zzw2>}SpB;<k0C$c1Ch-z!mUvf{XOJ`kgS5wxCB%9&idha<*EZ`vC?CTK3=0~$B;MB zj&)~63r8aC*=hhLbMV7pKv)A`S=Dd@g;u*Z&dM#JfC@Aile$)StX{_;n+ho>e)qyS zUG)I3H7`)oZW?rxp(spBa!GMGtjJQFqf+HWfL|DB^0mwFz-qzsbp_<Gl*m;@qQPRq zI=}!^XOd*h*zeGdJk4JDG&O_#5tijY1p4Jr{|RB^Y9s18VlK{TVCaLP>F?-%TXqPm zj-9sS4+0D8%V-7_F5QIip>;|V!-Jc!tyX)ssZioju*&fG7<%`eYi^p#lAZ>ES8HDr z0Y~8oH%brZ=T10@FbiepyV@Q?Jj4<QIcmeIJ#HY*^znH>+cPm8AovIp7Tf|tpgb~n zhQo}Y^uy6EMCB(e&pp3APY-8+bpUSbWfip)1&A&oT)8N2E?VSKGMyp@IKht9C-Dzu zdN|OkKeB@mVQ%|SW4<Smc9cv#6aObyoay#-yM*o_O7SefBae<Yzn2YI!&Q9<yd`ue zxCxyQ8@`*T`Ex-MXVYWj2RtG`EARM)A85~V{$7KW_z5!6wHp*{8E<LJaR@1Yr0j() zafxPyj>kUwFT4qQv^yE@xoAr72own?@`M6NHN$b4kufcw)WUOT5o7dKaw5&oXyutQ zc#fpn{i-+TKU56%G$gi<enOOVkoANUccb_?lyRpfFNVQHW15}_CU9T!j_>YG1*K*1 zFl0z=gHqUVz(dDiT9Q12+fT~{z3jA%yOXGU&H0mjyHj`e;GpnFtb6ad8sUENFP*<& AvH$=8 literal 0 HcmV?d00001 diff --git a/mobile/openapi/lib/model/manual_job_name.dart b/mobile/openapi/lib/model/manual_job_name.dart new file mode 100644 index 0000000000000000000000000000000000000000..7e8d9d51b2bab01e7ab4058c07c5a84c5daa3ab0 GIT binary patch literal 2828 zcmai0ZI9YU5dO}un3JlUPa>x6r-+o0Q;%zj60T~v4;4bzVy|JF^{%_SK7^wC@15DT z4fb&a39!BMKF>Vk(P%Il!R_Pn;q4!@#q96f<?Igb=bvXoxO;%b!xFwc+|BR*xk53L z{FpK0CcnnNyc^K1*eY#!l53OXLh><Gs<J#wxa0*_ZrI$5qSVHn)nIbZ4mK$(ml*zE zB@C)7R^V^R4E{TAEE+fVyn3pYbxgTLWav=jf*Y@H&pMMrahpi3wkYOyOeODrPSPDS zb}&G94s;H><cgId#_#*VV30~?Eqr0BV)B#TEHEVaJ^&bD`t#shFvo#`;(Oq#Dqt_% z4&07IY4j7umBH$X$%?OI)bjy%?ZrvtcEZM9=mwnz>r1pysf-(W5NZqllmaB#;Hcom zh8wb)3&kXSb*7c{W<KgCZVZNiBK4uu;Uc<$$%kOni?+v~tXBO)EU^=59ear#(du`} zu_h%<UgTn~gT&i3h+Jbz038#KZ7=EL@daf>n7O1)=cnp*IX?^Q`^%(+Qd=uFl85{) z2o<WjqW-N{oo@TCb)xs3u$cL89l;MP^aRi;&(J?QrW@{0GN1=*-+<ah$K(`pqYK}W z?0{2&JqRG7-gP9GhmtQ1Qx<2j;3m3>GoI><M<<}FJy0Qfk%Po%_kCCYVw;EXeWSI6 zt<if%6`MfLq~*is8$SjO^bEI^bZ2UU$x&5A4iVV{#IEnen>RrBv9Js6W<}`^jX^Z2 z4MYLb=$5)eqQ~>bX&8w<0^5?Iu7@7^4-o`~s|lz|%2Rs)A%U1>SzEH82k`7OX#T^U z;jS{O(Q=w{ikc#g+j4pT&x2=T%Mr8<!fC=%1ZqJVa*#3w2tMlA8t__pW=T2Cdk>D_ zbUn*MZ<HzIm(c7rG$_fKW3XZAH<Iodh|h6a4?n)QIZK^3haUiqpqRArPN$P|6xTD4 zj2C$B3>&^CS`$<#Qq%!IP`aMa=pyiFOQUyw4lEM%s;Q2L!Fol)Ybp@yrbvW}?Fzpz z#||K8==}mQvnKpHQz74D*@@%N8^wMq?O?+pbS48^r75>IM<WLsdcp(hD-T7o8W6>^ z82;3Ij=Vjjq3OByvv=8dZTkli1mA?ZM}^xt>}#IQjL{|}xx?{M-BQp->IH;x9sn0< zc7Q!)gBEZ9ngi}s+_6}6tkTJ54D*M{;+WxD#4$I#z#Ww}6(M<IN0cxecZNW+t}HDz zG^`~WE6d}4r3W-J6lDhx)4J?Y({XBbLuG9<k*%neEt3&Pks!Pv2#YH{-G$Rr+DMt@ z@M7PM>t1$aiaU!Tup%)`ZR2$3y<*_FMqX%lcHS%MCtK&JpYfa_agVWr&4<cx)fVYA zU6OEw3%E7gir(-%UZU$q^-UpK!dppbGLBV%{uq9uqK3DC#>WLpC2FSlkTb)Se8u)l e?vOCMMAjn;L~Tu)1c!6mw5KpV($;%o=>GvG6{sZu literal 0 HcmV?d00001 diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index b4ec4505b9..af79815563 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -2561,6 +2561,39 @@ "tags": [ "Jobs" ] + }, + "post": { + "operationId": "createJob", + "parameters": [], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/JobCreateDto" + } + } + }, + "required": true + }, + "responses": { + "201": { + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "tags": [ + "Jobs" + ] } }, "/jobs/{id}": { @@ -9269,6 +9302,17 @@ ], "type": "object" }, + "JobCreateDto": { + "properties": { + "name": { + "$ref": "#/components/schemas/ManualJobName" + } + }, + "required": [ + "name" + ], + "type": "object" + }, "JobName": { "enum": [ "thumbnailGeneration", @@ -9511,6 +9555,14 @@ ], "type": "object" }, + "ManualJobName": { + "enum": [ + "person-cleanup", + "tag-cleanup", + "user-cleanup" + ], + "type": "string" + }, "MapMarkerResponseDto": { "properties": { "city": { diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index 9350bd5604..da57313692 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -548,6 +548,9 @@ export type AllJobStatusResponseDto = { thumbnailGeneration: JobStatusDto; videoConversion: JobStatusDto; }; +export type JobCreateDto = { + name: ManualJobName; +}; export type JobCommandDto = { command: JobCommand; force: boolean; @@ -1941,6 +1944,15 @@ export function getAllJobsStatus(opts?: Oazapfts.RequestOpts) { ...opts })); } +export function createJob({ jobCreateDto }: { + jobCreateDto: JobCreateDto; +}, opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchText("/jobs", oazapfts.json({ + ...opts, + method: "POST", + body: jobCreateDto + }))); +} export function sendJobCommand({ id, jobCommandDto }: { id: JobName; jobCommandDto: JobCommandDto; @@ -3364,6 +3376,11 @@ export enum EntityType { Asset = "ASSET", Album = "ALBUM" } +export enum ManualJobName { + PersonCleanup = "person-cleanup", + TagCleanup = "tag-cleanup", + UserCleanup = "user-cleanup" +} export enum JobName { ThumbnailGeneration = "thumbnailGeneration", MetadataExtraction = "metadataExtraction", diff --git a/server/src/controllers/job.controller.ts b/server/src/controllers/job.controller.ts index 2aa5920fab..7da19e207f 100644 --- a/server/src/controllers/job.controller.ts +++ b/server/src/controllers/job.controller.ts @@ -1,6 +1,6 @@ -import { Body, Controller, Get, Param, Put } from '@nestjs/common'; +import { Body, Controller, Get, Param, Post, Put } from '@nestjs/common'; import { ApiTags } from '@nestjs/swagger'; -import { AllJobStatusResponseDto, JobCommandDto, JobIdParamDto, JobStatusDto } from 'src/dtos/job.dto'; +import { AllJobStatusResponseDto, JobCommandDto, JobCreateDto, JobIdParamDto, JobStatusDto } from 'src/dtos/job.dto'; import { Authenticated } from 'src/middleware/auth.guard'; import { JobService } from 'src/services/job.service'; @@ -15,6 +15,12 @@ export class JobController { return this.service.getAllJobsStatus(); } + @Post() + @Authenticated({ admin: true }) + createJob(@Body() dto: JobCreateDto): Promise<void> { + return this.service.create(dto); + } + @Put(':id') @Authenticated({ admin: true }) sendJobCommand(@Param() { id }: JobIdParamDto, @Body() dto: JobCommandDto): Promise<JobStatusDto> { diff --git a/server/src/dtos/job.dto.ts b/server/src/dtos/job.dto.ts index b7d8cf59bf..895f710b7a 100644 --- a/server/src/dtos/job.dto.ts +++ b/server/src/dtos/job.dto.ts @@ -1,5 +1,6 @@ import { ApiProperty } from '@nestjs/swagger'; import { IsEnum, IsNotEmpty } from 'class-validator'; +import { ManualJobName } from 'src/enum'; import { JobCommand, QueueName } from 'src/interfaces/job.interface'; import { ValidateBoolean } from 'src/validation'; @@ -20,6 +21,12 @@ export class JobCommandDto { force!: boolean; } +export class JobCreateDto { + @IsEnum(ManualJobName) + @ApiProperty({ type: 'string', enum: ManualJobName, enumName: 'ManualJobName' }) + name!: ManualJobName; +} + export class JobCountsDto { @ApiProperty({ type: 'integer' }) active!: number; diff --git a/server/src/enum.ts b/server/src/enum.ts index 32254854e4..d76d97371c 100644 --- a/server/src/enum.ts +++ b/server/src/enum.ts @@ -186,3 +186,9 @@ export enum SourceType { MACHINE_LEARNING = 'machine-learning', EXIF = 'exif', } + +export enum ManualJobName { + PERSON_CLEANUP = 'person-cleanup', + TAG_CLEANUP = 'tag-cleanup', + USER_CLEANUP = 'user-cleanup', +} diff --git a/server/src/interfaces/job.interface.ts b/server/src/interfaces/job.interface.ts index a0533fa63f..d0a15bfa5d 100644 --- a/server/src/interfaces/job.interface.ts +++ b/server/src/interfaces/job.interface.ts @@ -60,6 +60,9 @@ export enum JobName { STORAGE_TEMPLATE_MIGRATION = 'storage-template-migration', STORAGE_TEMPLATE_MIGRATION_SINGLE = 'storage-template-migration-single', + // tags + TAG_CLEANUP = 'tag-cleanup', + // migration QUEUE_MIGRATION = 'queue-migration', MIGRATE_ASSET = 'migrate-asset', @@ -262,6 +265,9 @@ export type JobItem = | { name: JobName.CLEAN_OLD_AUDIT_LOGS; data?: IBaseJob } | { name: JobName.CLEAN_OLD_SESSION_TOKENS; data?: IBaseJob } + // Tags + | { name: JobName.TAG_CLEANUP; data?: IBaseJob } + // Asset Deletion | { name: JobName.PERSON_CLEANUP; data?: IBaseJob } | { name: JobName.ASSET_DELETION; data: IAssetDeleteJob } diff --git a/server/src/interfaces/tag.interface.ts b/server/src/interfaces/tag.interface.ts index aca9c223d5..16a34d6ac4 100644 --- a/server/src/interfaces/tag.interface.ts +++ b/server/src/interfaces/tag.interface.ts @@ -17,4 +17,5 @@ export interface ITagRepository extends IBulkAsset { upsertAssetTags({ assetId, tagIds }: { assetId: string; tagIds: string[] }): Promise<void>; upsertAssetIds(items: AssetTagItem[]): Promise<AssetTagItem[]>; + deleteEmptyTags(): Promise<void>; } diff --git a/server/src/repositories/job.repository.ts b/server/src/repositories/job.repository.ts index f64e5175e5..2981fa4bdd 100644 --- a/server/src/repositories/job.repository.ts +++ b/server/src/repositories/job.repository.ts @@ -41,6 +41,9 @@ export const JOBS_TO_QUEUE: Record<JobName, QueueName> = { [JobName.GENERATE_THUMBHASH]: QueueName.THUMBNAIL_GENERATION, [JobName.GENERATE_PERSON_THUMBNAIL]: QueueName.THUMBNAIL_GENERATION, + // tags + [JobName.TAG_CLEANUP]: QueueName.BACKGROUND_TASK, + // metadata [JobName.QUEUE_METADATA_EXTRACTION]: QueueName.METADATA_EXTRACTION, [JobName.METADATA_EXTRACTION]: QueueName.METADATA_EXTRACTION, diff --git a/server/src/repositories/tag.repository.ts b/server/src/repositories/tag.repository.ts index 9389aeb13b..1a5415b8db 100644 --- a/server/src/repositories/tag.repository.ts +++ b/server/src/repositories/tag.repository.ts @@ -1,10 +1,11 @@ -import { Injectable } from '@nestjs/common'; +import { Inject, Injectable } from '@nestjs/common'; import { InjectDataSource, InjectRepository } from '@nestjs/typeorm'; import { Chunked, ChunkedSet, DummyValue, GenerateSql } from 'src/decorators'; import { TagEntity } from 'src/entities/tag.entity'; +import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { AssetTagItem, ITagRepository } from 'src/interfaces/tag.interface'; import { Instrumentation } from 'src/utils/instrumentation'; -import { DataSource, In, Repository } from 'typeorm'; +import { DataSource, In, Repository, TreeRepository } from 'typeorm'; @Instrumentation() @Injectable() @@ -12,7 +13,11 @@ export class TagRepository implements ITagRepository { constructor( @InjectDataSource() private dataSource: DataSource, @InjectRepository(TagEntity) private repository: Repository<TagEntity>, - ) {} + @InjectRepository(TagEntity) private tree: TreeRepository<TagEntity>, + @Inject(ILoggerRepository) private logger: ILoggerRepository, + ) { + this.logger.setContext(TagRepository.name); + } get(id: string): Promise<TagEntity | null> { return this.repository.findOne({ where: { id } }); @@ -174,6 +179,34 @@ export class TagRepository implements ITagRepository { }); } + async deleteEmptyTags() { + await this.dataSource.transaction(async (manager) => { + const ids = new Set<string>(); + const tags = await manager.find(TagEntity); + for (const tag of tags) { + const count = await manager + .createQueryBuilder('assets', 'asset') + .innerJoin( + 'asset.tags', + 'asset_tags', + 'asset_tags.id IN (SELECT id_descendant FROM tags_closure WHERE id_ancestor = :tagId)', + { tagId: tag.id }, + ) + .getCount(); + + if (count === 0) { + this.logger.debug(`Found empty tag: ${tag.id} - ${tag.value}`); + ids.add(tag.id); + } + } + + if (ids.size > 0) { + await manager.delete(TagEntity, { id: In([...ids]) }); + this.logger.log(`Deleted ${ids.size} empty tags`); + } + }); + } + private async save(partial: Partial<TagEntity>): Promise<TagEntity> { const { id } = await this.repository.save(partial); return this.repository.findOneOrFail({ where: { id } }); diff --git a/server/src/services/job.service.ts b/server/src/services/job.service.ts index aa61ccf3cb..03a6edf126 100644 --- a/server/src/services/job.service.ts +++ b/server/src/services/job.service.ts @@ -2,8 +2,8 @@ import { BadRequestException, Inject, Injectable } from '@nestjs/common'; import { snakeCase } from 'lodash'; import { SystemConfigCore } from 'src/cores/system-config.core'; import { mapAsset } from 'src/dtos/asset-response.dto'; -import { AllJobStatusResponseDto, JobCommandDto, JobStatusDto } from 'src/dtos/job.dto'; -import { AssetType } from 'src/enum'; +import { AllJobStatusResponseDto, JobCommandDto, JobCreateDto, JobStatusDto } from 'src/dtos/job.dto'; +import { AssetType, ManualJobName } from 'src/enum'; import { IAssetRepository } from 'src/interfaces/asset.interface'; import { ClientEvent, IEventRepository } from 'src/interfaces/event.interface'; import { @@ -22,6 +22,26 @@ import { IMetricRepository } from 'src/interfaces/metric.interface'; import { IPersonRepository } from 'src/interfaces/person.interface'; import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; +const asJobItem = (dto: JobCreateDto): JobItem => { + switch (dto.name) { + case ManualJobName.TAG_CLEANUP: { + return { name: JobName.TAG_CLEANUP }; + } + + case ManualJobName.PERSON_CLEANUP: { + return { name: JobName.PERSON_CLEANUP }; + } + + case ManualJobName.USER_CLEANUP: { + return { name: JobName.USER_DELETE_CHECK }; + } + + default: { + throw new BadRequestException('Invalid job name'); + } + } +}; + @Injectable() export class JobService { private configCore: SystemConfigCore; @@ -39,6 +59,10 @@ export class JobService { this.configCore = SystemConfigCore.create(systemMetadataRepository, logger); } + async create(dto: JobCreateDto): Promise<void> { + await this.jobRepository.queue(asJobItem(dto)); + } + async handleCommand(queueName: QueueName, dto: JobCommandDto): Promise<JobStatusDto> { this.logger.debug(`Handling command: queue=${queueName},force=${dto.force}`); diff --git a/server/src/services/microservices.service.ts b/server/src/services/microservices.service.ts index 025400cc9b..df4b072d56 100644 --- a/server/src/services/microservices.service.ts +++ b/server/src/services/microservices.service.ts @@ -15,6 +15,7 @@ import { SessionService } from 'src/services/session.service'; import { SmartInfoService } from 'src/services/smart-info.service'; import { StorageTemplateService } from 'src/services/storage-template.service'; import { StorageService } from 'src/services/storage.service'; +import { TagService } from 'src/services/tag.service'; import { UserService } from 'src/services/user.service'; import { VersionService } from 'src/services/version.service'; import { otelShutdown } from 'src/utils/instrumentation'; @@ -34,6 +35,7 @@ export class MicroservicesService { private sessionService: SessionService, private storageTemplateService: StorageTemplateService, private storageService: StorageService, + private tagService: TagService, private userService: UserService, private duplicateService: DuplicateService, private versionService: VersionService, @@ -93,6 +95,7 @@ export class MicroservicesService { [JobName.NOTIFY_ALBUM_INVITE]: (data) => this.notificationService.handleAlbumInvite(data), [JobName.NOTIFY_ALBUM_UPDATE]: (data) => this.notificationService.handleAlbumUpdate(data), [JobName.NOTIFY_SIGNUP]: (data) => this.notificationService.handleUserSignup(data), + [JobName.TAG_CLEANUP]: () => this.tagService.handleTagCleanup(), [JobName.VERSION_CHECK]: () => this.versionService.handleVersionCheck(), }); } diff --git a/server/src/services/tag.service.ts b/server/src/services/tag.service.ts index 97b0ef1be6..cc6d64f749 100644 --- a/server/src/services/tag.service.ts +++ b/server/src/services/tag.service.ts @@ -14,6 +14,7 @@ import { TagEntity } from 'src/entities/tag.entity'; import { Permission } from 'src/enum'; import { IAccessRepository } from 'src/interfaces/access.interface'; import { IEventRepository } from 'src/interfaces/event.interface'; +import { JobStatus } from 'src/interfaces/job.interface'; import { AssetTagItem, ITagRepository } from 'src/interfaces/tag.interface'; import { checkAccess, requireAccess } from 'src/utils/access'; import { addAssets, removeAssets } from 'src/utils/asset.util'; @@ -138,6 +139,11 @@ export class TagService { return results; } + async handleTagCleanup() { + await this.repository.deleteEmptyTags(); + return JobStatus.SUCCESS; + } + private async findOrFail(id: string) { const tag = await this.repository.get(id); if (!tag) { diff --git a/server/test/repositories/tag.repository.mock.ts b/server/test/repositories/tag.repository.mock.ts index a3fc0e77e0..acc2b59f6d 100644 --- a/server/test/repositories/tag.repository.mock.ts +++ b/server/test/repositories/tag.repository.mock.ts @@ -17,5 +17,6 @@ export const newTagRepositoryMock = (): Mocked<ITagRepository> => { addAssetIds: vitest.fn(), removeAssetIds: vitest.fn(), upsertAssetIds: vitest.fn(), + deleteEmptyTags: vitest.fn(), }; }; diff --git a/web/src/lib/components/shared-components/combobox.svelte b/web/src/lib/components/shared-components/combobox.svelte index d3e022a759..7c71fe8aea 100644 --- a/web/src/lib/components/shared-components/combobox.svelte +++ b/web/src/lib/components/shared-components/combobox.svelte @@ -220,7 +220,7 @@ role="listbox" id={listboxId} transition:fly={{ duration: 250 }} - class="absolute text-left text-sm w-full max-h-64 overflow-y-auto bg-white dark:bg-gray-800 border-t-0 border-gray-300 dark:border-gray-900 rounded-b-xl z-10" + class="absolute text-left text-sm w-full max-h-64 overflow-y-auto bg-white dark:bg-gray-800 border-t-0 border-gray-300 dark:border-gray-900 rounded-b-xl z-[10000]" class:border={isOpen} tabindex="-1" > diff --git a/web/src/lib/i18n/en.json b/web/src/lib/i18n/en.json index f880dab347..a788666050 100644 --- a/web/src/lib/i18n/en.json +++ b/web/src/lib/i18n/en.json @@ -41,6 +41,7 @@ "confirm_email_below": "To confirm, type \"{email}\" below", "confirm_reprocess_all_faces": "Are you sure you want to reprocess all faces? This will also clear named people.", "confirm_user_password_reset": "Are you sure you want to reset {user}'s password?", + "create_job": "Create job", "disable_login": "Disable login", "duplicate_detection_job_description": "Run machine learning on assets to detect similar images. Relies on Smart Search", "exclusion_pattern_description": "Exclusion patterns lets you ignore files and folders when scanning your library. This is useful if you have folders that contain files you don't want to import, such as RAW files.", @@ -68,6 +69,7 @@ "image_thumbnail_resolution": "Thumbnail resolution", "image_thumbnail_resolution_description": "Used when viewing groups of photos (main timeline, album view, etc.). Higher resolutions can preserve more detail but take longer to encode, have larger file sizes, and can reduce app responsiveness.", "job_concurrency": "{job} concurrency", + "job_created": "Job created", "job_not_concurrency_safe": "This job is not concurrency-safe.", "job_settings": "Job Settings", "job_settings_description": "Manage job concurrency", @@ -196,6 +198,7 @@ "password_settings": "Password Login", "password_settings_description": "Manage password login settings", "paths_validated_successfully": "All paths validated successfully", + "person_cleanup_job": "Person cleanup", "quota_size_gib": "Quota Size (GiB)", "refreshing_all_libraries": "Refreshing all libraries", "registration": "Admin Registration", @@ -209,6 +212,7 @@ "reset_settings_to_recent_saved": "Reset settings to the recent saved settings", "scanning_library_for_changed_files": "Scanning library for changed files", "scanning_library_for_new_files": "Scanning library for new files", + "search_jobs": "Search jobs...", "send_welcome_email": "Send welcome email", "server_external_domain_settings": "External domain", "server_external_domain_settings_description": "Domain for public shared links, including http(s)://", @@ -236,6 +240,7 @@ "storage_template_settings_description": "Manage the folder structure and file name of the upload asset", "storage_template_user_label": "<code>{label}</code> is the user's Storage Label", "system_settings": "System Settings", + "tag_cleanup_job": "Tag cleanup", "theme_custom_css_settings": "Custom CSS", "theme_custom_css_settings_description": "Cascading Style Sheets allow the design of Immich to be customized.", "theme_settings": "Theme Settings", @@ -309,6 +314,7 @@ "trash_settings_description": "Manage trash settings", "untracked_files": "Untracked Files", "untracked_files_description": "These files are not tracked by the application. They can be the results of failed moves, interrupted uploads, or left behind due to a bug", + "user_cleanup_job": "User cleanup", "user_delete_delay": "<b>{user}</b>'s account and assets will be scheduled for permanent deletion in {delay, plural, one {# day} other {# days}}.", "user_delete_delay_settings": "Delete delay", "user_delete_delay_settings_description": "Number of days after removal to permanently delete a user's account and assets. The user deletion job runs at midnight to check for users that are ready for deletion. Changes to this setting will be evaluated at the next execution.", diff --git a/web/src/routes/admin/jobs-status/+page.svelte b/web/src/routes/admin/jobs-status/+page.svelte index dcd6630a01..16c2541e61 100644 --- a/web/src/routes/admin/jobs-status/+page.svelte +++ b/web/src/routes/admin/jobs-status/+page.svelte @@ -3,10 +3,17 @@ import LinkButton from '$lib/components/elements/buttons/link-button.svelte'; import Icon from '$lib/components/elements/icon.svelte'; import UserPageLayout from '$lib/components/layouts/user-page-layout.svelte'; + import Combobox, { type ComboBoxOption } from '$lib/components/shared-components/combobox.svelte'; + import ConfirmDialog from '$lib/components/shared-components/dialog/confirm-dialog.svelte'; + import { + notificationController, + NotificationType, + } from '$lib/components/shared-components/notification/notification'; import { AppRoute } from '$lib/constants'; import { asyncTimeout } from '$lib/utils'; - import { getAllJobsStatus, type AllJobStatusResponseDto } from '@immich/sdk'; - import { mdiCog } from '@mdi/js'; + import { handleError } from '$lib/utils/handle-error'; + import { createJob, getAllJobsStatus, ManualJobName, type AllJobStatusResponseDto } from '@immich/sdk'; + import { mdiCog, mdiPlus } from '@mdi/js'; import { onDestroy, onMount } from 'svelte'; import { t } from 'svelte-i18n'; import type { PageData } from './$types'; @@ -16,6 +23,8 @@ let jobs: AllJobStatusResponseDto; let running = true; + let isOpen = false; + let selectedJob: ComboBoxOption | undefined = undefined; onMount(async () => { while (running) { @@ -27,10 +36,38 @@ onDestroy(() => { running = false; }); + + const options = [ + { title: $t('admin.person_cleanup_job'), value: ManualJobName.PersonCleanup }, + { title: $t('admin.tag_cleanup_job'), value: ManualJobName.TagCleanup }, + { title: $t('admin.user_cleanup_job'), value: ManualJobName.UserCleanup }, + ].map(({ value, title }) => ({ id: value, label: title, value })); + + const handleCancel = () => (isOpen = false); + + const handleCreate = async () => { + if (!selectedJob) { + return; + } + + try { + await createJob({ jobCreateDto: { name: selectedJob.value as ManualJobName } }); + notificationController.show({ message: $t('admin.job_created'), type: NotificationType.Info }); + handleCancel(); + } catch (error) { + handleError(error, $t('errors.unable_to_submit_job')); + } + }; </script> <UserPageLayout title={data.meta.title} admin> <div class="flex justify-end" slot="buttons"> + <LinkButton on:click={() => (isOpen = true)}> + <div class="flex place-items-center gap-2 text-sm"> + <Icon path={mdiPlus} size="18" /> + {$t('admin.create_job')} + </div> + </LinkButton> <LinkButton href="{AppRoute.ADMIN_SETTINGS}?isOpen=job"> <div class="flex place-items-center gap-2 text-sm"> <Icon path={mdiCog} size="18" /> @@ -46,3 +83,24 @@ </section> </section> </UserPageLayout> + +{#if isOpen} + <ConfirmDialog + confirmColor="primary" + title={$t('admin.create_job')} + disabled={!selectedJob} + onConfirm={handleCreate} + onCancel={handleCancel} + > + <form on:submit|preventDefault={handleCreate} autocomplete="off" id="create-tag-form" slot="prompt" class="w-full"> + <div class="flex flex-col gap-1 text-left"> + <Combobox + bind:selectedOption={selectedJob} + label={$t('jobs')} + {options} + placeholder={$t('admin.search_jobs')} + /> + </div> + </form> + </ConfirmDialog> +{/if}