1
0
mirror of https://github.com/immich-app/immich.git synced 2025-01-13 15:35:15 +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 239 additions and 10 deletions

BIN
mobile/openapi/README.md generated

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

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}