From ecc97d1254149052c2f47c1814956ac661957688 Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Fri, 13 Sep 2024 16:08:06 -0400 Subject: [PATCH] feat: tag cleanup job --- mobile/openapi/README.md | 3 + mobile/openapi/lib/api.dart | 2 + mobile/openapi/lib/api/jobs_api.dart | 39 ++++++++ mobile/openapi/lib/api_client.dart | 4 + mobile/openapi/lib/api_helper.dart | 3 + mobile/openapi/lib/model/job_create_dto.dart | 98 +++++++++++++++++++ mobile/openapi/lib/model/manual_job_name.dart | 88 +++++++++++++++++ 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, 476 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 36b2c7bbf4613..16f293f81a6d3 100644 --- a/mobile/openapi/README.md +++ b/mobile/openapi/README.md @@ -124,6 +124,7 @@ Class | Method | HTTP request | Description *FileReportsApi* | [**fixAuditFiles**](doc//FileReportsApi.md#fixauditfiles) | **POST** /reports/fix | *FileReportsApi* | [**getAuditFiles**](doc//FileReportsApi.md#getauditfiles) | **GET** /reports | *FileReportsApi* | [**getFileChecksums**](doc//FileReportsApi.md#getfilechecksums) | **POST** /reports/checksum | +*JobsApi* | [**createJob**](doc//JobsApi.md#createjob) | **POST** /jobs | *JobsApi* | [**getAllJobsStatus**](doc//JobsApi.md#getalljobsstatus) | **GET** /jobs | *JobsApi* | [**sendJobCommand**](doc//JobsApi.md#sendjobcommand) | **PUT** /jobs/{id} | *LibrariesApi* | [**createLibrary**](doc//LibrariesApi.md#createlibrary) | **POST** /libraries | @@ -330,6 +331,7 @@ Class | Method | HTTP request | Description - [JobCommand](doc//JobCommand.md) - [JobCommandDto](doc//JobCommandDto.md) - [JobCountsDto](doc//JobCountsDto.md) + - [JobCreateDto](doc//JobCreateDto.md) - [JobName](doc//JobName.md) - [JobSettingsDto](doc//JobSettingsDto.md) - [JobStatusDto](doc//JobStatusDto.md) @@ -341,6 +343,7 @@ Class | Method | HTTP request | Description - [LoginCredentialDto](doc//LoginCredentialDto.md) - [LoginResponseDto](doc//LoginResponseDto.md) - [LogoutResponseDto](doc//LogoutResponseDto.md) + - [ManualJobName](doc//ManualJobName.md) - [MapMarkerResponseDto](doc//MapMarkerResponseDto.md) - [MapReverseGeocodeResponseDto](doc//MapReverseGeocodeResponseDto.md) - [MapTheme](doc//MapTheme.md) diff --git a/mobile/openapi/lib/api.dart b/mobile/openapi/lib/api.dart index 091e900145ab3..915c70f08eb26 100644 --- a/mobile/openapi/lib/api.dart +++ b/mobile/openapi/lib/api.dart @@ -144,6 +144,7 @@ part 'model/image_format.dart'; part 'model/job_command.dart'; part 'model/job_command_dto.dart'; part 'model/job_counts_dto.dart'; +part 'model/job_create_dto.dart'; part 'model/job_name.dart'; part 'model/job_settings_dto.dart'; part 'model/job_status_dto.dart'; @@ -155,6 +156,7 @@ part 'model/log_level.dart'; part 'model/login_credential_dto.dart'; part 'model/login_response_dto.dart'; part 'model/logout_response_dto.dart'; +part 'model/manual_job_name.dart'; part 'model/map_marker_response_dto.dart'; part 'model/map_reverse_geocode_response_dto.dart'; part 'model/map_theme.dart'; diff --git a/mobile/openapi/lib/api/jobs_api.dart b/mobile/openapi/lib/api/jobs_api.dart index 5f9501d126f8e..78afc15c93580 100644 --- a/mobile/openapi/lib/api/jobs_api.dart +++ b/mobile/openapi/lib/api/jobs_api.dart @@ -16,6 +16,45 @@ class JobsApi { final ApiClient apiClient; + /// Performs an HTTP 'POST /jobs' operation and returns the [Response]. + /// Parameters: + /// + /// * [JobCreateDto] jobCreateDto (required): + Future createJobWithHttpInfo(JobCreateDto jobCreateDto,) async { + // ignore: prefer_const_declarations + final path = r'/jobs'; + + // ignore: prefer_final_locals + Object? postBody = jobCreateDto; + + final queryParams = []; + final headerParams = {}; + final formParams = {}; + + const contentTypes = ['application/json']; + + + return apiClient.invokeAPI( + path, + 'POST', + queryParams, + postBody, + headerParams, + formParams, + contentTypes.isEmpty ? null : contentTypes.first, + ); + } + + /// Parameters: + /// + /// * [JobCreateDto] jobCreateDto (required): + Future createJob(JobCreateDto jobCreateDto,) async { + final response = await createJobWithHttpInfo(jobCreateDto,); + if (response.statusCode >= HttpStatus.badRequest) { + throw ApiException(response.statusCode, await _decodeBodyBytes(response)); + } + } + /// Performs an HTTP 'GET /jobs' operation and returns the [Response]. Future getAllJobsStatusWithHttpInfo() async { // ignore: prefer_const_declarations diff --git a/mobile/openapi/lib/api_client.dart b/mobile/openapi/lib/api_client.dart index 9ec00aecc87aa..6a40de730c002 100644 --- a/mobile/openapi/lib/api_client.dart +++ b/mobile/openapi/lib/api_client.dart @@ -343,6 +343,8 @@ class ApiClient { return JobCommandDto.fromJson(value); case 'JobCountsDto': return JobCountsDto.fromJson(value); + case 'JobCreateDto': + return JobCreateDto.fromJson(value); case 'JobName': return JobNameTypeTransformer().decode(value); case 'JobSettingsDto': @@ -365,6 +367,8 @@ class ApiClient { return LoginResponseDto.fromJson(value); case 'LogoutResponseDto': return LogoutResponseDto.fromJson(value); + case 'ManualJobName': + return ManualJobNameTypeTransformer().decode(value); case 'MapMarkerResponseDto': return MapMarkerResponseDto.fromJson(value); case 'MapReverseGeocodeResponseDto': diff --git a/mobile/openapi/lib/api_helper.dart b/mobile/openapi/lib/api_helper.dart index 8dcef880f59a4..0f3cc41097276 100644 --- a/mobile/openapi/lib/api_helper.dart +++ b/mobile/openapi/lib/api_helper.dart @@ -97,6 +97,9 @@ String parameterToString(dynamic value) { if (value is LogLevel) { return LogLevelTypeTransformer().encode(value).toString(); } + if (value is ManualJobName) { + return ManualJobNameTypeTransformer().encode(value).toString(); + } if (value is MapTheme) { return MapThemeTypeTransformer().encode(value).toString(); } 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 0000000000000..a4734791bbced --- /dev/null +++ b/mobile/openapi/lib/model/job_create_dto.dart @@ -0,0 +1,98 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.18 + +// ignore_for_file: unused_element, unused_import +// ignore_for_file: always_put_required_named_parameters_first +// ignore_for_file: constant_identifier_names +// ignore_for_file: lines_longer_than_80_chars + +part of openapi.api; + +class JobCreateDto { + /// Returns a new [JobCreateDto] instance. + JobCreateDto({ + required this.name, + }); + + ManualJobName name; + + @override + bool operator ==(Object other) => identical(this, other) || other is JobCreateDto && + other.name == name; + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (name.hashCode); + + @override + String toString() => 'JobCreateDto[name=$name]'; + + Map toJson() { + final json = {}; + json[r'name'] = this.name; + return json; + } + + /// Returns a new [JobCreateDto] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static JobCreateDto? fromJson(dynamic value) { + if (value is Map) { + final json = value.cast(); + + return JobCreateDto( + name: ManualJobName.fromJson(json[r'name'])!, + ); + } + return null; + } + + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; + if (json is List && json.isNotEmpty) { + for (final row in json) { + final value = JobCreateDto.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } + + static Map mapFromJson(dynamic json) { + final map = {}; + if (json is Map && json.isNotEmpty) { + json = json.cast(); // ignore: parameter_assignments + for (final entry in json.entries) { + final value = JobCreateDto.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of JobCreateDto-objects as value to a dart map + static Map> mapListFromJson(dynamic json, {bool growable = false,}) { + final map = >{}; + if (json is Map && json.isNotEmpty) { + // ignore: parameter_assignments + json = json.cast(); + for (final entry in json.entries) { + map[entry.key] = JobCreateDto.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + 'name', + }; +} + 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 0000000000000..7e8d9d51b2bab --- /dev/null +++ b/mobile/openapi/lib/model/manual_job_name.dart @@ -0,0 +1,88 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.18 + +// ignore_for_file: unused_element, unused_import +// ignore_for_file: always_put_required_named_parameters_first +// ignore_for_file: constant_identifier_names +// ignore_for_file: lines_longer_than_80_chars + +part of openapi.api; + + +class ManualJobName { + /// Instantiate a new enum with the provided [value]. + const ManualJobName._(this.value); + + /// The underlying value of this enum member. + final String value; + + @override + String toString() => value; + + String toJson() => value; + + static const personCleanup = ManualJobName._(r'person-cleanup'); + static const tagCleanup = ManualJobName._(r'tag-cleanup'); + static const userCleanup = ManualJobName._(r'user-cleanup'); + + /// List of all possible values in this [enum][ManualJobName]. + static const values = [ + personCleanup, + tagCleanup, + userCleanup, + ]; + + static ManualJobName? fromJson(dynamic value) => ManualJobNameTypeTransformer().decode(value); + + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; + if (json is List && json.isNotEmpty) { + for (final row in json) { + final value = ManualJobName.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } +} + +/// Transformation class that can [encode] an instance of [ManualJobName] to String, +/// and [decode] dynamic data back to [ManualJobName]. +class ManualJobNameTypeTransformer { + factory ManualJobNameTypeTransformer() => _instance ??= const ManualJobNameTypeTransformer._(); + + const ManualJobNameTypeTransformer._(); + + String encode(ManualJobName data) => data.value; + + /// Decodes a [dynamic value][data] to a ManualJobName. + /// + /// If [allowNull] is true and the [dynamic value][data] cannot be decoded successfully, + /// then null is returned. However, if [allowNull] is false and the [dynamic value][data] + /// cannot be decoded successfully, then an [UnimplementedError] is thrown. + /// + /// The [allowNull] is very handy when an API changes and a new enum value is added or removed, + /// and users are still using an old app with the old code. + ManualJobName? decode(dynamic data, {bool allowNull = true}) { + if (data != null) { + switch (data) { + case r'person-cleanup': return ManualJobName.personCleanup; + case r'tag-cleanup': return ManualJobName.tagCleanup; + case r'user-cleanup': return ManualJobName.userCleanup; + default: + if (!allowNull) { + throw ArgumentError('Unknown enum value to decode: $data'); + } + } + } + return null; + } + + /// Singleton [ManualJobNameTypeTransformer] instance. + static ManualJobNameTypeTransformer? _instance; +} + diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index b4ec4505b9e2d..af79815563c70 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 9350bd5604507..da57313692dc2 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 2aa5920fab7b8..7da19e207fce0 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 { + return this.service.create(dto); + } + @Put(':id') @Authenticated({ admin: true }) sendJobCommand(@Param() { id }: JobIdParamDto, @Body() dto: JobCommandDto): Promise { diff --git a/server/src/dtos/job.dto.ts b/server/src/dtos/job.dto.ts index b7d8cf59bf55a..895f710b7a782 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 32254854e4c5a..d76d97371ce48 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 a0533fa63f9c0..d0a15bfa5dc00 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 aca9c223d552b..16a34d6ac4960 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; upsertAssetIds(items: AssetTagItem[]): Promise; + deleteEmptyTags(): Promise; } diff --git a/server/src/repositories/job.repository.ts b/server/src/repositories/job.repository.ts index f64e5175e5127..2981fa4bddcd8 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.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 9389aeb13b4e3..1a5415b8dbb08 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, - ) {} + @InjectRepository(TagEntity) private tree: TreeRepository, + @Inject(ILoggerRepository) private logger: ILoggerRepository, + ) { + this.logger.setContext(TagRepository.name); + } get(id: string): Promise { 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(); + 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): Promise { 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 aa61ccf3cb229..03a6edf126e3a 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 { + await this.jobRepository.queue(asJobItem(dto)); + } + async handleCommand(queueName: QueueName, dto: JobCommandDto): Promise { 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 025400cc9bde3..df4b072d56400 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 97b0ef1be6843..cc6d64f749d20 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 a3fc0e77e0312..acc2b59f6d686 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 => { 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 d3e022a75933c..7c71fe8aeaed7 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 e27cc54d52156..33f622451255d 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": "{label} 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": "{user}'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 dcd6630a01c56..16c2541e61b53 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')); + } + };
+ (isOpen = true)}> +
+ + {$t('admin.create_job')} +
+
@@ -46,3 +83,24 @@ + +{#if isOpen} + +
+
+ +
+
+
+{/if}