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}