0
Fork 0
mirror of https://github.com/immich-app/immich.git synced 2025-03-11 02:23:09 -05:00

feat(server): library cleanup from ui (#16226)

* feat(server,web): scan all libraries from frontend

* feat(server,web): scan all libraries from frontend

* Add button text
This commit is contained in:
Jonathan Jogenfors 2025-03-03 04:29:02 +01:00 committed by GitHub
parent 8885e3105e
commit 869839f642
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 37 additions and 24 deletions

View file

@ -68,7 +68,7 @@ In rare cases, the library watcher can hang, preventing Immich from starting up.
### Nightly job ### Nightly job
There is an automatic scan job that is scheduled to run once a day. This job also cleans up any libraries stuck in deletion. There is an automatic scan job that is scheduled to run once a day. This job also cleans up any libraries stuck in deletion. It is possible to trigger the cleanup by clicking "Scan all libraries" in the library managment page.
## Usage ## Usage

View file

@ -96,7 +96,7 @@
"library_scanning_enable_description": "Enable periodic library scanning", "library_scanning_enable_description": "Enable periodic library scanning",
"library_settings": "External Library", "library_settings": "External Library",
"library_settings_description": "Manage external library settings", "library_settings_description": "Manage external library settings",
"library_tasks_description": "Perform library tasks", "library_tasks_description": "Scan external libraries for new and/or changed assets",
"library_watching_enable_description": "Watch external libraries for file changes", "library_watching_enable_description": "Watch external libraries for file changes",
"library_watching_settings": "Library watching (EXPERIMENTAL)", "library_watching_settings": "Library watching (EXPERIMENTAL)",
"library_watching_settings_description": "Automatically watch for changed files", "library_watching_settings_description": "Automatically watch for changed files",
@ -336,6 +336,7 @@
"untracked_files": "Untracked Files", "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", "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_cleanup_job": "User cleanup",
"cleanup": "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": "<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": "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.", "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.",
@ -1114,6 +1115,7 @@
"say_something": "Say something", "say_something": "Say something",
"scan_all_libraries": "Scan All Libraries", "scan_all_libraries": "Scan All Libraries",
"scan_library": "Scan", "scan_library": "Scan",
"rescan": "Rescan",
"scan_settings": "Scan Settings", "scan_settings": "Scan Settings",
"scanning_for_album": "Scanning for album...", "scanning_for_album": "Scanning for album...",
"search": "Search", "search": "Search",

View file

@ -473,7 +473,7 @@ export enum JobName {
LIBRARY_SYNC_FILE = 'library-sync-file', LIBRARY_SYNC_FILE = 'library-sync-file',
LIBRARY_SYNC_ASSET = 'library-sync-asset', LIBRARY_SYNC_ASSET = 'library-sync-asset',
LIBRARY_DELETE = 'library-delete', LIBRARY_DELETE = 'library-delete',
LIBRARY_QUEUE_SYNC_ALL = 'library-queue-sync-all', LIBRARY_QUEUE_SCAN_ALL = 'library-queue-scan-all',
LIBRARY_QUEUE_CLEANUP = 'library-queue-cleanup', LIBRARY_QUEUE_CLEANUP = 'library-queue-cleanup',
// cleanup // cleanup

View file

@ -170,7 +170,7 @@ export class JobService extends BaseService {
} }
case QueueName.LIBRARY: { case QueueName.LIBRARY: {
return this.jobRepository.queue({ name: JobName.LIBRARY_QUEUE_SYNC_ALL, data: { force } }); return this.jobRepository.queue({ name: JobName.LIBRARY_QUEUE_SCAN_ALL, data: { force } });
} }
case QueueName.BACKUP_DATABASE: { case QueueName.BACKUP_DATABASE: {

View file

@ -1079,7 +1079,7 @@ describe(LibraryService.name, () => {
it('should queue the refresh job', async () => { it('should queue the refresh job', async () => {
mocks.library.getAll.mockResolvedValue([libraryStub.externalLibrary1]); mocks.library.getAll.mockResolvedValue([libraryStub.externalLibrary1]);
await expect(sut.handleQueueSyncAll()).resolves.toBe(JobStatus.SUCCESS); await expect(sut.handleQueueScanAll()).resolves.toBe(JobStatus.SUCCESS);
expect(mocks.job.queue.mock.calls).toEqual([ expect(mocks.job.queue.mock.calls).toEqual([
[ [

View file

@ -47,7 +47,7 @@ export class LibraryService extends BaseService {
name: 'libraryScan', name: 'libraryScan',
expression: scan.cronExpression, expression: scan.cronExpression,
onTick: () => onTick: () =>
handlePromiseError(this.jobRepository.queue({ name: JobName.LIBRARY_QUEUE_SYNC_ALL }), this.logger), handlePromiseError(this.jobRepository.queue({ name: JobName.LIBRARY_QUEUE_SCAN_ALL }), this.logger),
start: scan.enabled, start: scan.enabled,
}); });
} }
@ -210,11 +210,17 @@ export class LibraryService extends BaseService {
@OnJob({ name: JobName.LIBRARY_QUEUE_CLEANUP, queue: QueueName.LIBRARY }) @OnJob({ name: JobName.LIBRARY_QUEUE_CLEANUP, queue: QueueName.LIBRARY })
async handleQueueCleanup(): Promise<JobStatus> { async handleQueueCleanup(): Promise<JobStatus> {
this.logger.debug('Cleaning up any pending library deletions'); this.logger.log('Checking for any libraries pending deletion...');
const pendingDeletion = await this.libraryRepository.getAllDeleted(); const pendingDeletions = await this.libraryRepository.getAllDeleted();
await this.jobRepository.queueAll( if (pendingDeletions.length > 0) {
pendingDeletion.map((libraryToDelete) => ({ name: JobName.LIBRARY_DELETE, data: { id: libraryToDelete.id } })), const libraryString = pendingDeletions.length === 1 ? 'library' : 'libraries';
); this.logger.log(`Found ${pendingDeletions.length} ${libraryString} pending deletion, cleaning up...`);
await this.jobRepository.queueAll(
pendingDeletions.map((libraryToDelete) => ({ name: JobName.LIBRARY_DELETE, data: { id: libraryToDelete.id } })),
);
}
return JobStatus.SUCCESS; return JobStatus.SUCCESS;
} }
@ -442,9 +448,13 @@ export class LibraryService extends BaseService {
await this.jobRepository.queue({ name: JobName.LIBRARY_QUEUE_SYNC_ASSETS, data: { id } }); await this.jobRepository.queue({ name: JobName.LIBRARY_QUEUE_SYNC_ASSETS, data: { id } });
} }
@OnJob({ name: JobName.LIBRARY_QUEUE_SYNC_ALL, queue: QueueName.LIBRARY }) async queueScanAll() {
async handleQueueSyncAll(): Promise<JobStatus> { await this.jobRepository.queue({ name: JobName.LIBRARY_QUEUE_SCAN_ALL, data: {} });
this.logger.debug(`Refreshing all external libraries`); }
@OnJob({ name: JobName.LIBRARY_QUEUE_SCAN_ALL, queue: QueueName.LIBRARY })
async handleQueueScanAll(): Promise<JobStatus> {
this.logger.log(`Refreshing all external libraries`);
await this.jobRepository.queue({ name: JobName.LIBRARY_QUEUE_CLEANUP, data: {} }); await this.jobRepository.queue({ name: JobName.LIBRARY_QUEUE_CLEANUP, data: {} });

View file

@ -351,7 +351,7 @@ export type JobItem =
| { name: JobName.LIBRARY_QUEUE_SYNC_ASSETS; data: IEntityJob } | { name: JobName.LIBRARY_QUEUE_SYNC_ASSETS; data: IEntityJob }
| { name: JobName.LIBRARY_SYNC_ASSET; data: ILibraryAssetJob } | { name: JobName.LIBRARY_SYNC_ASSET; data: ILibraryAssetJob }
| { name: JobName.LIBRARY_DELETE; data: IEntityJob } | { name: JobName.LIBRARY_DELETE; data: IEntityJob }
| { name: JobName.LIBRARY_QUEUE_SYNC_ALL; data?: IBaseJob } | { name: JobName.LIBRARY_QUEUE_SCAN_ALL; data?: IBaseJob }
| { name: JobName.LIBRARY_QUEUE_CLEANUP; data: IBaseJob } | { name: JobName.LIBRARY_QUEUE_CLEANUP; data: IBaseJob }
// Notification // Notification

View file

@ -185,7 +185,7 @@
{#if !disabled && !multipleButtons && isIdle} {#if !disabled && !multipleButtons && isIdle}
<JobTileButton color="light-gray" onClick={() => onCommand({ command: JobCommand.Start, force: false })}> <JobTileButton color="light-gray" onClick={() => onCommand({ command: JobCommand.Start, force: false })}>
<Icon path={mdiPlay} size="48" /> <Icon path={mdiPlay} size="48" />
{$t('start').toUpperCase()} {missingText}
</JobTileButton> </JobTileButton>
{/if} {/if}
</div> </div>

View file

@ -79,8 +79,7 @@
icon: mdiLibraryShelves, icon: mdiLibraryShelves,
title: $getJobName(JobName.Library), title: $getJobName(JobName.Library),
subtitle: $t('admin.library_tasks_description'), subtitle: $t('admin.library_tasks_description'),
allText: $t('all'), missingText: $t('rescan'),
missingText: $t('refresh'),
}, },
[JobName.Sidecar]: { [JobName.Sidecar]: {
title: $getJobName(JobName.Sidecar), title: $getJobName(JobName.Sidecar),
@ -135,14 +134,14 @@
[JobName.StorageTemplateMigration]: { [JobName.StorageTemplateMigration]: {
icon: mdiFolderMove, icon: mdiFolderMove,
title: $getJobName(JobName.StorageTemplateMigration), title: $getJobName(JobName.StorageTemplateMigration),
missingText: $t('missing'), missingText: $t('start'),
description: StorageMigrationDescription, description: StorageMigrationDescription,
}, },
[JobName.Migration]: { [JobName.Migration]: {
icon: mdiFolderMove, icon: mdiFolderMove,
title: $getJobName(JobName.Migration), title: $getJobName(JobName.Migration),
subtitle: $t('admin.migration_job_description'), subtitle: $t('admin.migration_job_description'),
missingText: $t('missing'), missingText: $t('start'),
}, },
}; };

View file

@ -146,7 +146,7 @@ export const getJobName = derived(t, ($t) => {
[JobName.Migration]: $t('admin.migration_job'), [JobName.Migration]: $t('admin.migration_job'),
[JobName.BackgroundTask]: $t('admin.background_task_job'), [JobName.BackgroundTask]: $t('admin.background_task_job'),
[JobName.Search]: $t('search'), [JobName.Search]: $t('search'),
[JobName.Library]: $t('library'), [JobName.Library]: $t('external_libraries'),
[JobName.Notifications]: $t('notifications'), [JobName.Notifications]: $t('notifications'),
[JobName.BackupDatabase]: $t('admin.backup_database'), [JobName.BackupDatabase]: $t('admin.backup_database'),
}; };

View file

@ -22,7 +22,10 @@
getAllLibraries, getAllLibraries,
getLibraryStatistics, getLibraryStatistics,
getUserAdmin, getUserAdmin,
JobCommand,
JobName,
scanLibrary, scanLibrary,
sendJobCommand,
updateLibrary, updateLibrary,
type LibraryResponseDto, type LibraryResponseDto,
type LibraryStatsResponseDto, type LibraryStatsResponseDto,
@ -151,9 +154,8 @@
const handleScanAll = async () => { const handleScanAll = async () => {
try { try {
for (const library of libraries) { await sendJobCommand({ id: JobName.Library, jobCommandDto: { command: JobCommand.Start } });
await scanLibrary({ id: library.id });
}
notificationController.show({ notificationController.show({
message: $t('admin.refreshing_all_libraries'), message: $t('admin.refreshing_all_libraries'),
type: NotificationType.Info, type: NotificationType.Info,