1
0
mirror of https://github.com/immich-app/immich.git synced 2025-03-30 23:04:40 +02:00

feat: tag cleanup job (#12654)

This commit is contained in:
Jason Rasmussen 2024-09-16 16:49:12 -04:00 committed by GitHub
parent 4a1ff6abce
commit b74b20824a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
23 changed files with 476 additions and 10 deletions

View File

@ -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)

View File

@ -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';

View File

@ -16,6 +16,45 @@ class JobsApi {
final ApiClient apiClient;
/// Performs an HTTP 'POST /jobs' operation and returns the [Response].
/// Parameters:
///
/// * [JobCreateDto] jobCreateDto (required):
Future<Response> createJobWithHttpInfo(JobCreateDto jobCreateDto,) async {
// ignore: prefer_const_declarations
final path = r'/jobs';
// ignore: prefer_final_locals
Object? postBody = jobCreateDto;
final queryParams = <QueryParam>[];
final headerParams = <String, String>{};
final formParams = <String, String>{};
const contentTypes = <String>['application/json'];
return apiClient.invokeAPI(
path,
'POST',
queryParams,
postBody,
headerParams,
formParams,
contentTypes.isEmpty ? null : contentTypes.first,
);
}
/// Parameters:
///
/// * [JobCreateDto] jobCreateDto (required):
Future<void> 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<Response> getAllJobsStatusWithHttpInfo() async {
// ignore: prefer_const_declarations

View File

@ -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':

View File

@ -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();
}

View File

@ -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<String, dynamic> toJson() {
final json = <String, dynamic>{};
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<String, dynamic>();
return JobCreateDto(
name: ManualJobName.fromJson(json[r'name'])!,
);
}
return null;
}
static List<JobCreateDto> listFromJson(dynamic json, {bool growable = false,}) {
final result = <JobCreateDto>[];
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<String, JobCreateDto> mapFromJson(dynamic json) {
final map = <String, JobCreateDto>{};
if (json is Map && json.isNotEmpty) {
json = json.cast<String, dynamic>(); // 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<String, List<JobCreateDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
final map = <String, List<JobCreateDto>>{};
if (json is Map && json.isNotEmpty) {
// ignore: parameter_assignments
json = json.cast<String, dynamic>();
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 = <String>{
'name',
};
}

View File

@ -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 = <ManualJobName>[
personCleanup,
tagCleanup,
userCleanup,
];
static ManualJobName? fromJson(dynamic value) => ManualJobNameTypeTransformer().decode(value);
static List<ManualJobName> listFromJson(dynamic json, {bool growable = false,}) {
final result = <ManualJobName>[];
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;
}

View File

@ -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": {

View File

@ -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",

View File

@ -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> {

View File

@ -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;

View File

@ -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',
}

View File

@ -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 }

View File

@ -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>;
}

View File

@ -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,

View File

@ -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 } });

View File

@ -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}`);

View File

@ -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(),
});
}

View File

@ -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) {

View File

@ -17,5 +17,6 @@ export const newTagRepositoryMock = (): Mocked<ITagRepository> => {
addAssetIds: vitest.fn(),
removeAssetIds: vitest.fn(),
upsertAssetIds: vitest.fn(),
deleteEmptyTags: vitest.fn(),
};
};

View File

@ -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"
>

View File

@ -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.",

View File

@ -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}