From 99505f987ed1628d121db7e5d430ed29f12d774c Mon Sep 17 00:00:00 2001 From: Ujjwal Goel <8354378+ujjwal123123@users.noreply.github.com> Date: Sun, 23 Nov 2025 19:04:43 -0800 Subject: [PATCH 01/87] fix: use npm instead of pnpm and fix `check:all` (#24101) * fix: use npm instead of pnpm and fix `check:all` * fix: remove `--` from pnpm commands * Remove `check:all` from the documentation section --- docs/docs/developer/pr-checklist.md | 8 ++++---- web/package.json | 10 +++++----- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/docs/docs/developer/pr-checklist.md b/docs/docs/developer/pr-checklist.md index f855e854c4..e68567bc8f 100644 --- a/docs/docs/developer/pr-checklist.md +++ b/docs/docs/developer/pr-checklist.md @@ -14,15 +14,15 @@ When contributing code through a pull request, please check the following: - [ ] `pnpm run check:typescript` (check typescript) - [ ] `pnpm test` (unit tests) +:::tip AIO +Run all web checks with `pnpm run check:all` +::: + ## Documentation - [ ] `pnpm run format` (formatting via Prettier) - [ ] Update the `_redirects` file if you have renamed a page or removed it from the documentation. -:::tip AIO -Run all web checks with `pnpm run check:all` -::: - ## Server Checks - [ ] `pnpm run lint` (linting via ESLint) diff --git a/web/package.json b/web/package.json index 1afe94ac07..d157c9fde0 100644 --- a/web/package.json +++ b/web/package.json @@ -11,13 +11,13 @@ "preview": "vite preview", "check:svelte": "svelte-check --no-tsconfig --fail-on-warnings", "check:typescript": "tsc --noEmit", - "check:watch": "npm run check:svelte -- --watch", - "check:code": "npm run format && npm run lint:p && npm run check:svelte && npm run check:typescript", - "check:all": "npm run check:code && npm run test:cov", + "check:watch": "pnpm run check:svelte --watch", + "check:code": "pnpm run format && pnpm run lint && pnpm run check:svelte && pnpm run check:typescript", + "check:all": "pnpm run check:code && pnpm run test:cov", "lint": "eslint . --max-warnings 0 --concurrency 4", - "lint:fix": "npm run lint -- --fix", + "lint:fix": "pnpm run lint --fix", "format": "prettier --check .", - "format:fix": "prettier --write . && npm run format:i18n", + "format:fix": "prettier --write . && pnpm run format:i18n", "format:i18n": "pnpm dlx sort-json ../i18n/*.json", "test": "vitest --run", "test:cov": "vitest --coverage", From 57be3ff8c71161f54d935fbbf791ddddeb2e8f10 Mon Sep 17 00:00:00 2001 From: Daniel Dietzler <36593685+danieldietzler@users.noreply.github.com> Date: Mon, 24 Nov 2025 13:52:36 +0100 Subject: [PATCH 02/87] fix: add users to album (#24133) --- .../components/album-page/albums-list.svelte | 17 +++++------------ 1 file changed, 5 insertions(+), 12 deletions(-) diff --git a/web/src/lib/components/album-page/albums-list.svelte b/web/src/lib/components/album-page/albums-list.svelte index bb826110b7..0671422c28 100644 --- a/web/src/lib/components/album-page/albums-list.svelte +++ b/web/src/lib/components/album-page/albums-list.svelte @@ -134,8 +134,6 @@ let albumGroupOption: string = $state(AlbumGroupBy.None); - let albumToShare: AlbumResponseDto | null = $state(null); - let contextMenuPosition: ContextMenuPosition = $state({ x: 0, y: 0 }); let selectedAlbum: AlbumResponseDto | undefined = $state(); let isOpen = $state(false); @@ -231,7 +229,7 @@ const result = await modalManager.show(AlbumShareModal, { album: selectedAlbum }); switch (result?.action) { case 'sharedUsers': { - await handleAddUsers(result.data); + await handleAddUsers(selectedAlbum, result.data); break; } @@ -300,22 +298,17 @@ updateRecentAlbumInfo(album); }; - const handleAddUsers = async (albumUsers: AlbumUserAddDto[]) => { - if (!albumToShare) { - return; - } + const handleAddUsers = async (album: AlbumResponseDto, albumUsers: AlbumUserAddDto[]) => { try { - const album = await addUsersToAlbum({ - id: albumToShare.id, + const updatedAlbum = await addUsersToAlbum({ + id: album.id, addUsersDto: { albumUsers, }, }); - updateAlbumInfo(album); + updateAlbumInfo(updatedAlbum); } catch (error) { handleError(error, $t('errors.unable_to_add_album_users')); - } finally { - albumToShare = null; } }; From aecf064ec9d29dc7389e35aa49d7950cde8fcf93 Mon Sep 17 00:00:00 2001 From: Greg Lutostanski Date: Mon, 24 Nov 2025 09:34:21 -0600 Subject: [PATCH 03/87] fix(server): sanitize DB_URL for pg_dumpall to remove unknown query params (#23333) Co-authored-by: Greg Lutostanski Co-authored-by: Alex --- docs/docs/install/environment-variables.md | 2 +- server/src/services/backup.service.spec.ts | 31 ++++++++++++++++++++++ server/src/services/backup.service.ts | 12 +++++++-- 3 files changed, 42 insertions(+), 3 deletions(-) diff --git a/docs/docs/install/environment-variables.md b/docs/docs/install/environment-variables.md index 55c226d507..8863a13ee7 100644 --- a/docs/docs/install/environment-variables.md +++ b/docs/docs/install/environment-variables.md @@ -93,7 +93,7 @@ Information on the current workers can be found [here](/administration/jobs-work All `DB_` variables must be provided to all Immich workers, including `api` and `microservices`. `DB_URL` must be in the format `postgresql://immichdbusername:immichdbpassword@postgreshost:postgresport/immichdatabasename`. -You can require SSL by adding `?sslmode=require` to the end of the `DB_URL` string, or require SSL and skip certificate verification by adding `?sslmode=require&sslmode=no-verify`. +You can require SSL by adding `?sslmode=require` to the end of the `DB_URL` string, or require SSL and skip certificate verification by adding `?sslmode=require&uselibpqcompat=true`. This allows both immich and `pg_dumpall` (the utility used for database backups) to [properly connect](https://github.com/brianc/node-postgres/tree/master/packages/pg-connection-string#tcp-connections) to your database. When `DB_URL` is defined, the `DB_HOSTNAME`, `DB_PORT`, `DB_USERNAME`, `DB_PASSWORD` and `DB_DATABASE_NAME` database variables are ignored. diff --git a/server/src/services/backup.service.spec.ts b/server/src/services/backup.service.spec.ts index 8aa20aa868..9e25fbaf2e 100644 --- a/server/src/services/backup.service.spec.ts +++ b/server/src/services/backup.service.spec.ts @@ -153,6 +153,37 @@ describe(BackupService.name, () => { mocks.storage.createWriteStream.mockReturnValue(new PassThrough()); }); + it('should sanitize DB_URL (remove uselibpqcompat) before calling pg_dumpall', async () => { + // create a service instance with a URL connection that includes libpqcompat + const dbUrl = 'postgresql://postgres:pwd@host:5432/immich?sslmode=require&uselibpqcompat=true'; + const configMock = { + getEnv: () => ({ database: { config: { connectionType: 'url', url: dbUrl }, skipMigrations: false } }), + getWorker: () => ImmichWorker.Api, + isDev: () => false, + } as unknown as any; + + ({ sut, mocks } = newTestService(BackupService, { config: configMock })); + + mocks.storage.readdir.mockResolvedValue([]); + mocks.process.spawn.mockReturnValue(mockSpawn(0, 'data', '')); + mocks.storage.rename.mockResolvedValue(); + mocks.storage.unlink.mockResolvedValue(); + mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.backupEnabled); + mocks.storage.createWriteStream.mockReturnValue(new PassThrough()); + mocks.database.getPostgresVersion.mockResolvedValue('14.10'); + + await sut.handleBackupDatabase(); + + expect(mocks.process.spawn).toHaveBeenCalled(); + const call = mocks.process.spawn.mock.calls[0]; + const args = call[1] as string[]; + // ['--dbname', '', '--clean', '--if-exists'] + expect(args[0]).toBe('--dbname'); + const passedUrl = args[1]; + expect(passedUrl).not.toContain('uselibpqcompat'); + expect(passedUrl).toContain('sslmode=require'); + }); + it('should run a database backup successfully', async () => { const result = await sut.handleBackupDatabase(); expect(result).toBe(JobStatus.Success); diff --git a/server/src/services/backup.service.ts b/server/src/services/backup.service.ts index 6f8cc0e34a..3731b1810f 100644 --- a/server/src/services/backup.service.ts +++ b/server/src/services/backup.service.ts @@ -81,8 +81,16 @@ export class BackupService extends BaseService { const isUrlConnection = config.connectionType === 'url'; + let connectionUrl: string = isUrlConnection ? config.url : ''; + if (URL.canParse(connectionUrl)) { + // remove known bad url parameters for pg_dumpall + const url = new URL(connectionUrl); + url.searchParams.delete('uselibpqcompat'); + connectionUrl = url.toString(); + } + const databaseParams = isUrlConnection - ? ['--dbname', config.url] + ? ['--dbname', connectionUrl] : [ '--username', config.username, @@ -118,7 +126,7 @@ export class BackupService extends BaseService { { env: { PATH: process.env.PATH, - PGPASSWORD: isUrlConnection ? new URL(config.url).password : config.password, + PGPASSWORD: isUrlConnection ? new URL(connectionUrl).password : config.password, }, }, ); From 24e5dabb516ad3373949997a3245125e12dbc5ae Mon Sep 17 00:00:00 2001 From: shenlong <139912620+shenlong-tanwen@users.noreply.github.com> Date: Mon, 24 Nov 2025 21:19:27 +0530 Subject: [PATCH 04/87] fix: use proper updatedAt value in local assets (#24137) * fix: incorrect updatedAt value in local assets * add test --------- Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com> --- .../domain/services/local_sync.service.dart | 4 +- mobile/lib/utils/migration.dart | 36 +++++++++++++++- .../services/local_sync_service_test.dart | 41 ++++++++++++++----- 3 files changed, 68 insertions(+), 13 deletions(-) diff --git a/mobile/lib/domain/services/local_sync.service.dart b/mobile/lib/domain/services/local_sync.service.dart index 5cbae9c5a1..04eaf04694 100644 --- a/mobile/lib/domain/services/local_sync.service.dart +++ b/mobile/lib/domain/services/local_sync.service.dart @@ -363,14 +363,14 @@ extension on Iterable { } } -extension on PlatformAsset { +extension PlatformToLocalAsset on PlatformAsset { LocalAsset toLocalAsset() => LocalAsset( id: id, name: name, checksum: null, type: AssetType.values.elementAtOrNull(type) ?? AssetType.other, createdAt: tryFromSecondsSinceEpoch(createdAt, isUtc: true) ?? DateTime.timestamp(), - updatedAt: tryFromSecondsSinceEpoch(createdAt, isUtc: true) ?? DateTime.timestamp(), + updatedAt: tryFromSecondsSinceEpoch(updatedAt, isUtc: true) ?? DateTime.timestamp(), width: width, height: height, durationInSeconds: durationInSeconds, diff --git a/mobile/lib/utils/migration.dart b/mobile/lib/utils/migration.dart index b0d7ea6013..552c9e356a 100644 --- a/mobile/lib/utils/migration.dart +++ b/mobile/lib/utils/migration.dart @@ -22,14 +22,16 @@ import 'package:immich_mobile/infrastructure/entities/store.entity.drift.dart'; import 'package:immich_mobile/infrastructure/entities/user.entity.dart'; import 'package:immich_mobile/infrastructure/repositories/db.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/sync_stream.repository.dart'; +import 'package:immich_mobile/platform/native_sync_api.g.dart'; import 'package:immich_mobile/services/app_settings.service.dart'; +import 'package:immich_mobile/utils/datetime_helpers.dart'; import 'package:immich_mobile/utils/debug_print.dart'; import 'package:immich_mobile/utils/diff.dart'; import 'package:isar/isar.dart'; // ignore: import_rule_photo_manager import 'package:photo_manager/photo_manager.dart'; -const int targetVersion = 18; +const int targetVersion = 19; Future migrateDatabaseIfNeeded(Isar db, Drift drift) async { final hasVersion = Store.tryGet(StoreKey.version) != null; @@ -78,6 +80,12 @@ Future migrateDatabaseIfNeeded(Isar db, Drift drift) async { await Store.put(StoreKey.shouldResetSync, true); } + if (version < 19 && Store.isBetaTimelineEnabled) { + if (!await _populateUpdatedAtTime(drift)) { + return; + } + } + if (targetVersion >= 12) { await Store.put(StoreKey.version, targetVersion); return; @@ -221,6 +229,32 @@ Future _migrateDeviceAsset(Isar db) async { }); } +Future _populateUpdatedAtTime(Drift db) async { + try { + final nativeApi = NativeSyncApi(); + final albums = await nativeApi.getAlbums(); + for (final album in albums) { + final assets = await nativeApi.getAssetsForAlbum(album.id); + await db.batch((batch) async { + for (final asset in assets) { + batch.update( + db.localAssetEntity, + LocalAssetEntityCompanion( + updatedAt: Value(tryFromSecondsSinceEpoch(asset.updatedAt, isUtc: true) ?? DateTime.timestamp()), + ), + where: (t) => t.id.equals(asset.id), + ); + } + }); + } + + return true; + } catch (error) { + dPrint(() => "[MIGRATION] Error while populating updatedAt time: $error"); + return false; + } +} + Future migrateDeviceAssetToSqlite(Isar db, Drift drift) async { try { final isarDeviceAssets = await db.deviceAssetEntitys.where().findAll(); diff --git a/mobile/test/domain/services/local_sync_service_test.dart b/mobile/test/domain/services/local_sync_service_test.dart index 2f236971e0..92ab01c7e0 100644 --- a/mobile/test/domain/services/local_sync_service_test.dart +++ b/mobile/test/domain/services/local_sync_service_test.dart @@ -54,12 +54,7 @@ void main() { when(() => mockNativeSyncApi.shouldFullSync()).thenAnswer((_) async => false); when(() => mockNativeSyncApi.getMediaChanges()).thenAnswer( - (_) async => SyncDelta( - hasChanges: false, - updates: const [], - deletes: const [], - assetAlbums: const {}, - ), + (_) async => SyncDelta(hasChanges: false, updates: const [], deletes: const [], assetAlbums: const {}), ); when(() => mockNativeSyncApi.getTrashedAssets()).thenAnswer((_) async => {}); when(() => mockTrashedLocalAssetRepository.processTrashSnapshot(any())).thenAnswer((_) async {}); @@ -144,13 +139,19 @@ void main() { }); final localAssetToTrash = LocalAssetStub.image2.copyWith(id: 'local-trash', checksum: 'checksum-trash'); - when(() => mockTrashedLocalAssetRepository.getToTrash()).thenAnswer((_) async => {'album-a': [localAssetToTrash]}); + when(() => mockTrashedLocalAssetRepository.getToTrash()).thenAnswer( + (_) async => { + 'album-a': [localAssetToTrash], + }, + ); final assetEntity = MockAssetEntity(); when(() => assetEntity.getMediaUrl()).thenAnswer((_) async => 'content://local-trash'); when(() => mockStorageRepository.getAssetEntityForAsset(localAssetToTrash)).thenAnswer((_) async => assetEntity); - await sut.processTrashedAssets({'album-a': [platformAsset]}); + await sut.processTrashedAssets({ + 'album-a': [platformAsset], + }); verify(() => mockTrashedLocalAssetRepository.processTrashSnapshot(any())).called(1); verify(() => mockTrashedLocalAssetRepository.getToTrash()).called(1); @@ -159,8 +160,7 @@ void main() { verify(() => mockTrashedLocalAssetRepository.applyRestoredAssets(restoredIds)).called(1); verify(() => mockStorageRepository.getAssetEntityForAsset(localAssetToTrash)).called(1); - final moveArgs = - verify(() => mockLocalFilesManager.moveToTrash(captureAny())).captured.single as List; + final moveArgs = verify(() => mockLocalFilesManager.moveToTrash(captureAny())).captured.single as List; expect(moveArgs, ['content://local-trash']); final trashArgs = verify(() => mockTrashedLocalAssetRepository.trashLocalAsset(captureAny())).captured.single @@ -187,4 +187,25 @@ void main() { verifyNever(() => mockTrashedLocalAssetRepository.trashLocalAsset(any())); }); }); + + group('LocalSyncService - PlatformAsset conversion', () { + test('toLocalAsset uses correct updatedAt timestamp', () { + final platformAsset = PlatformAsset( + id: 'test-id', + name: 'test.jpg', + type: AssetType.image.index, + durationInSeconds: 0, + orientation: 0, + isFavorite: false, + createdAt: 1700000000, + updatedAt: 1732000000, + ); + + final localAsset = platformAsset.toLocalAsset(); + + expect(localAsset.createdAt.millisecondsSinceEpoch ~/ 1000, 1700000000); + expect(localAsset.updatedAt.millisecondsSinceEpoch ~/ 1000, 1732000000); + expect(localAsset.updatedAt, isNot(localAsset.createdAt)); + }); + }); } From 0498f6cb9df6addd846908b69d67c9bc982f5b9b Mon Sep 17 00:00:00 2001 From: Daniel Dietzler <36593685+danieldietzler@users.noreply.github.com> Date: Mon, 24 Nov 2025 17:14:24 +0100 Subject: [PATCH 05/87] fix: albums page reactivity loops (#24046) --- .../components/album-page/albums-list.svelte | 85 +++++++------------ 1 file changed, 33 insertions(+), 52 deletions(-) diff --git a/web/src/lib/components/album-page/albums-list.svelte b/web/src/lib/components/album-page/albums-list.svelte index 0671422c28..e4b588af8f 100644 --- a/web/src/lib/components/album-page/albums-list.svelte +++ b/web/src/lib/components/album-page/albums-list.svelte @@ -33,7 +33,6 @@ import { groupBy } from 'lodash-es'; import { onMount, type Snippet } from 'svelte'; import { t } from 'svelte-i18n'; - import { run } from 'svelte/legacy'; interface Props { ownedAlbums?: AlbumResponseDto[]; @@ -128,63 +127,45 @@ }, }; - let albums: AlbumResponseDto[] = $state([]); - let filteredAlbums: AlbumResponseDto[] = $state([]); - let groupedAlbums: AlbumGroup[] = $state([]); + let albums = $derived.by(() => { + switch (userSettings.filter) { + case AlbumFilter.Owned: { + return ownedAlbums; + } + case AlbumFilter.Shared: { + return sharedAlbums; + } + default: { + const nonOwnedAlbums = sharedAlbums.filter((album) => album.ownerId !== $user.id); + return nonOwnedAlbums.length > 0 ? ownedAlbums.concat(nonOwnedAlbums) : ownedAlbums; + } + } + }); + const normalizedSearchQuery = $derived(normalizeSearchString(searchQuery)); + let filteredAlbums = $derived( + normalizedSearchQuery + ? albums.filter(({ albumName }) => normalizeSearchString(albumName).includes(normalizedSearchQuery)) + : albums, + ); - let albumGroupOption: string = $state(AlbumGroupBy.None); + let albumGroupOption = $derived(getSelectedAlbumGroupOption(userSettings)); + let groupedAlbums = $derived.by(() => { + const groupFunc = groupOptions[albumGroupOption] ?? groupOptions[AlbumGroupBy.None]; + const groupedAlbums = groupFunc(stringToSortOrder(userSettings.groupOrder), filteredAlbums); + + return groupedAlbums.map((group) => ({ + id: group.id, + name: group.name, + albums: sortAlbums(group.albums, { sortBy: userSettings.sortBy, orderBy: userSettings.sortOrder }), + })); + }); let contextMenuPosition: ContextMenuPosition = $state({ x: 0, y: 0 }); let selectedAlbum: AlbumResponseDto | undefined = $state(); let isOpen = $state(false); - // Step 1: Filter between Owned and Shared albums, or both. - run(() => { - switch (userSettings.filter) { - case AlbumFilter.Owned: { - albums = ownedAlbums; - break; - } - case AlbumFilter.Shared: { - albums = sharedAlbums; - break; - } - default: { - const userId = $user.id; - const nonOwnedAlbums = sharedAlbums.filter((album) => album.ownerId !== userId); - albums = nonOwnedAlbums.length > 0 ? ownedAlbums.concat(nonOwnedAlbums) : ownedAlbums; - } - } - }); - - // Step 2: Filter using the given search query. - run(() => { - if (searchQuery) { - const searchAlbumNormalized = normalizeSearchString(searchQuery); - - filteredAlbums = albums.filter((album) => { - return normalizeSearchString(album.albumName).includes(searchAlbumNormalized); - }); - } else { - filteredAlbums = albums; - } - }); - - // Step 3: Group albums. - run(() => { - albumGroupOption = getSelectedAlbumGroupOption(userSettings); - const groupFunc = groupOptions[albumGroupOption] ?? groupOptions[AlbumGroupBy.None]; - groupedAlbums = groupFunc(stringToSortOrder(userSettings.groupOrder), filteredAlbums); - }); - - // Step 4: Sort albums amongst each group. - run(() => { - groupedAlbums = groupedAlbums.map((group) => ({ - id: group.id, - name: group.name, - albums: sortAlbums(group.albums, { sortBy: userSettings.sortBy, orderBy: userSettings.sortOrder }), - })); - + // TODO get rid of this + $effect(() => { albumGroupIds = groupedAlbums.map(({ id }) => id); }); From c860809aa101760ad06e686474b764e2f38008e0 Mon Sep 17 00:00:00 2001 From: shenlong <139912620+shenlong-tanwen@users.noreply.github.com> Date: Mon, 24 Nov 2025 21:53:17 +0530 Subject: [PATCH 06/87] fix: getAspectRatio fallback to db width and height (#24131) fix: getExif fallback to db width and height Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com> --- mobile/lib/domain/services/asset.service.dart | 14 ++ .../domain/services/asset.service_test.dart | 165 ++++++++++++++++++ .../test/infrastructure/repository.mock.dart | 3 + mobile/test/test_utils.dart | 40 +++++ 4 files changed, 222 insertions(+) create mode 100644 mobile/test/domain/services/asset.service_test.dart diff --git a/mobile/lib/domain/services/asset.service.dart b/mobile/lib/domain/services/asset.service.dart index 33661105e4..3d8fddc9b7 100644 --- a/mobile/lib/domain/services/asset.service.dart +++ b/mobile/lib/domain/services/asset.service.dart @@ -75,6 +75,20 @@ class AssetService { isFlipped = false; } + if (width == null || height == null) { + if (asset.hasRemote) { + final id = asset is LocalAsset ? asset.remoteId! : (asset as RemoteAsset).id; + final remoteAsset = await _remoteAssetRepository.get(id); + width = remoteAsset?.width?.toDouble(); + height = remoteAsset?.height?.toDouble(); + } else { + final id = asset is LocalAsset ? asset.id : (asset as RemoteAsset).localId!; + final localAsset = await _localAssetRepository.get(id); + width = localAsset?.width?.toDouble(); + height = localAsset?.height?.toDouble(); + } + } + final orientedWidth = isFlipped ? height : width; final orientedHeight = isFlipped ? width : height; if (orientedWidth != null && orientedHeight != null && orientedHeight > 0) { diff --git a/mobile/test/domain/services/asset.service_test.dart b/mobile/test/domain/services/asset.service_test.dart new file mode 100644 index 0000000000..5e7179ffa6 --- /dev/null +++ b/mobile/test/domain/services/asset.service_test.dart @@ -0,0 +1,165 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:immich_mobile/domain/models/exif.model.dart'; +import 'package:immich_mobile/domain/services/asset.service.dart'; +import 'package:mocktail/mocktail.dart'; + +import '../../infrastructure/repository.mock.dart'; +import '../../test_utils.dart'; + +void main() { + late AssetService sut; + late MockRemoteAssetRepository mockRemoteAssetRepository; + late MockDriftLocalAssetRepository mockLocalAssetRepository; + + setUp(() { + mockRemoteAssetRepository = MockRemoteAssetRepository(); + mockLocalAssetRepository = MockDriftLocalAssetRepository(); + sut = AssetService( + remoteAssetRepository: mockRemoteAssetRepository, + localAssetRepository: mockLocalAssetRepository, + ); + }); + + group('getAspectRatio', () { + test('flips dimensions on Android for 90° and 270° orientations', () async { + debugDefaultTargetPlatformOverride = TargetPlatform.android; + addTearDown(() => debugDefaultTargetPlatformOverride = null); + + for (final orientation in [90, 270]) { + final localAsset = TestUtils.createLocalAsset( + id: 'local-$orientation', + width: 1920, + height: 1080, + orientation: orientation, + ); + + final result = await sut.getAspectRatio(localAsset); + + expect(result, 1080 / 1920, reason: 'Orientation $orientation should flip on Android'); + } + }); + + test('does not flip dimensions on iOS regardless of orientation', () async { + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + addTearDown(() => debugDefaultTargetPlatformOverride = null); + + for (final orientation in [0, 90, 270]) { + final localAsset = TestUtils.createLocalAsset( + id: 'local-$orientation', + width: 1920, + height: 1080, + orientation: orientation, + ); + + final result = await sut.getAspectRatio(localAsset); + + expect(result, 1920 / 1080, reason: 'iOS should never flip dimensions'); + } + }); + + test('fetches dimensions from remote repository when missing from asset', () async { + final remoteAsset = TestUtils.createRemoteAsset(id: 'remote-1', width: null, height: null); + + final exif = const ExifInfo(orientation: '1'); + + final fetchedAsset = TestUtils.createRemoteAsset(id: 'remote-1', width: 1920, height: 1080); + + when(() => mockRemoteAssetRepository.getExif('remote-1')).thenAnswer((_) async => exif); + when(() => mockRemoteAssetRepository.get('remote-1')).thenAnswer((_) async => fetchedAsset); + + final result = await sut.getAspectRatio(remoteAsset); + + expect(result, 1920 / 1080); + verify(() => mockRemoteAssetRepository.get('remote-1')).called(1); + }); + + test('fetches dimensions from local repository when missing from local asset', () async { + final localAsset = TestUtils.createLocalAsset(id: 'local-1', width: null, height: null, orientation: 0); + + final fetchedAsset = TestUtils.createLocalAsset(id: 'local-1', width: 1920, height: 1080, orientation: 0); + + when(() => mockLocalAssetRepository.get('local-1')).thenAnswer((_) async => fetchedAsset); + + final result = await sut.getAspectRatio(localAsset); + + expect(result, 1920 / 1080); + verify(() => mockLocalAssetRepository.get('local-1')).called(1); + }); + + test('returns 1.0 when dimensions are still unavailable after fetching', () async { + final remoteAsset = TestUtils.createRemoteAsset(id: 'remote-1', width: null, height: null); + + final exif = const ExifInfo(orientation: '1'); + + when(() => mockRemoteAssetRepository.getExif('remote-1')).thenAnswer((_) async => exif); + when(() => mockRemoteAssetRepository.get('remote-1')).thenAnswer((_) async => null); + + final result = await sut.getAspectRatio(remoteAsset); + + expect(result, 1.0); + }); + + test('returns 1.0 when height is zero', () async { + final remoteAsset = TestUtils.createRemoteAsset(id: 'remote-1', width: 1920, height: 0); + + final exif = const ExifInfo(orientation: '1'); + + when(() => mockRemoteAssetRepository.getExif('remote-1')).thenAnswer((_) async => exif); + + final result = await sut.getAspectRatio(remoteAsset); + + expect(result, 1.0); + }); + + test('handles local asset with remoteId and uses exif from remote', () async { + final localAsset = TestUtils.createLocalAsset( + id: 'local-1', + remoteId: 'remote-1', + width: 1920, + height: 1080, + orientation: 0, + ); + + final exif = const ExifInfo(orientation: '6'); + + when(() => mockRemoteAssetRepository.getExif('remote-1')).thenAnswer((_) async => exif); + + final result = await sut.getAspectRatio(localAsset); + + expect(result, 1080 / 1920); + }); + + test('handles various flipped EXIF orientations correctly', () async { + final flippedOrientations = ['5', '6', '7', '8', '90', '-90']; + + for (final orientation in flippedOrientations) { + final remoteAsset = TestUtils.createRemoteAsset(id: 'remote-$orientation', width: 1920, height: 1080); + + final exif = ExifInfo(orientation: orientation); + + when(() => mockRemoteAssetRepository.getExif('remote-$orientation')).thenAnswer((_) async => exif); + + final result = await sut.getAspectRatio(remoteAsset); + + expect(result, 1080 / 1920, reason: 'Orientation $orientation should flip dimensions'); + } + }); + + test('handles various non-flipped EXIF orientations correctly', () async { + final nonFlippedOrientations = ['1', '2', '3', '4']; + + for (final orientation in nonFlippedOrientations) { + final remoteAsset = TestUtils.createRemoteAsset(id: 'remote-$orientation', width: 1920, height: 1080); + + final exif = ExifInfo(orientation: orientation); + + when(() => mockRemoteAssetRepository.getExif('remote-$orientation')).thenAnswer((_) async => exif); + + final result = await sut.getAspectRatio(remoteAsset); + + expect(result, 1920 / 1080, reason: 'Orientation $orientation should NOT flip dimensions'); + } + }); + }); +} diff --git a/mobile/test/infrastructure/repository.mock.dart b/mobile/test/infrastructure/repository.mock.dart index becfafe33d..aac384c29e 100644 --- a/mobile/test/infrastructure/repository.mock.dart +++ b/mobile/test/infrastructure/repository.mock.dart @@ -4,6 +4,7 @@ import 'package:immich_mobile/infrastructure/repositories/local_album.repository import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/log.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/remote_album.repository.dart'; +import 'package:immich_mobile/infrastructure/repositories/remote_asset.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/storage.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/store.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/sync_api.repository.dart'; @@ -35,6 +36,8 @@ class MockLocalAssetRepository extends Mock implements DriftLocalAssetRepository class MockDriftLocalAssetRepository extends Mock implements DriftLocalAssetRepository {} +class MockRemoteAssetRepository extends Mock implements RemoteAssetRepository {} + class MockTrashedLocalAssetRepository extends Mock implements DriftTrashedLocalAssetRepository {} class MockStorageRepository extends Mock implements StorageRepository {} diff --git a/mobile/test/test_utils.dart b/mobile/test/test_utils.dart index 9b59773d3b..498607e3d2 100644 --- a/mobile/test/test_utils.dart +++ b/mobile/test/test_utils.dart @@ -5,6 +5,7 @@ import 'package:easy_localization/easy_localization.dart'; import 'package:fake_async/fake_async.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/domain/models/asset/base_asset.model.dart' as domain; import 'package:immich_mobile/entities/album.entity.dart'; import 'package:immich_mobile/entities/android_device_asset.entity.dart'; import 'package:immich_mobile/entities/asset.entity.dart'; @@ -116,4 +117,43 @@ abstract final class TestUtils { } return result; } + + static domain.RemoteAsset createRemoteAsset({required String id, int? width, int? height, String? ownerId}) { + return domain.RemoteAsset( + id: id, + checksum: 'checksum1', + ownerId: ownerId ?? 'owner1', + name: 'test.jpg', + type: domain.AssetType.image, + createdAt: DateTime(2024, 1, 1), + updatedAt: DateTime(2024, 1, 1), + durationInSeconds: 0, + isFavorite: false, + width: width, + height: height, + ); + } + + static domain.LocalAsset createLocalAsset({ + required String id, + String? remoteId, + int? width, + int? height, + int orientation = 0, + }) { + return domain.LocalAsset( + id: id, + remoteId: remoteId, + checksum: 'checksum1', + name: 'test.jpg', + type: domain.AssetType.image, + createdAt: DateTime(2024, 1, 1), + updatedAt: DateTime(2024, 1, 1), + durationInSeconds: 0, + isFavorite: false, + width: width, + height: height, + orientation: orientation, + ); + } } From 75d23fe135d0e92a32802866e5b0c977b9967a55 Mon Sep 17 00:00:00 2001 From: Snowknight26 Date: Mon, 24 Nov 2025 10:24:02 -0600 Subject: [PATCH 07/87] fix(web): fix support & feedback modal wrapping (#24018) * fix(web): fix support & feedback modal wrapping * Fix reference --- .../lib/modals/HelpAndFeedbackModal.svelte | 97 ++++++------------- 1 file changed, 30 insertions(+), 67 deletions(-) diff --git a/web/src/lib/modals/HelpAndFeedbackModal.svelte b/web/src/lib/modals/HelpAndFeedbackModal.svelte index f25f7d1704..8b73978672 100644 --- a/web/src/lib/modals/HelpAndFeedbackModal.svelte +++ b/web/src/lib/modals/HelpAndFeedbackModal.svelte @@ -2,7 +2,7 @@ import { type ServerAboutResponseDto } from '@immich/sdk'; import { Icon, Modal, ModalBody } from '@immich/ui'; import { mdiBugOutline, mdiFaceAgent, mdiGit, mdiGithub, mdiInformationOutline } from '@mdi/js'; - import { siDiscord } from 'simple-icons'; + import { type SimpleIcon, siDiscord } from 'simple-icons'; import { t } from 'svelte-i18n'; interface Props { @@ -13,94 +13,57 @@ let { onClose, info }: Props = $props(); +{#snippet link(url: string, icon: string | SimpleIcon, text: string)} + +{/snippet} +

{$t('official_immich_resources')}

-
- +
+ {@render link( + `https://docs.${info.version}.archive.immich.app/overview/introduction`, + mdiInformationOutline, + $t('documentation'), + )} - + {@render link('https://github.com/immich-app/immich/', mdiGithub, $t('source'))} - + {@render link('https://discord.immich.app', siDiscord, $t('discord'))} - + {@render link( + 'https://github.com/immich-app/immich/issues/new/choose', + mdiBugOutline, + $t('bugs_and_feature_requests'), + )}
{#if info.thirdPartyBugFeatureUrl || info.thirdPartySourceUrl || info.thirdPartyDocumentationUrl || info.thirdPartySupportUrl}

{$t('third_party_resources')}

{$t('support_third_party_description')}

-
+
{#if info.thirdPartyDocumentationUrl} - + {@render link(info.thirdPartyDocumentationUrl, mdiInformationOutline, $t('documentation'))} {/if} {#if info.thirdPartySourceUrl} - + {@render link(info.thirdPartySourceUrl, mdiGit, $t('source'))} {/if} {#if info.thirdPartySupportUrl} - + {@render link(info.thirdPartySupportUrl, mdiFaceAgent, $t('support'))} {/if} {#if info.thirdPartyBugFeatureUrl} - + {@render link(info.thirdPartyBugFeatureUrl, mdiBugOutline, $t('bugs_and_feature_requests'))} {/if}
{/if} From d6b39a464d3c03658849dd1d09c348ef8c652bf6 Mon Sep 17 00:00:00 2001 From: Min Idzelis Date: Mon, 24 Nov 2025 11:26:52 -0500 Subject: [PATCH 08/87] feat: improve performance: don't sort timeline buckets from server (#24032) --- .../group-insertion-cache.svelte.ts | 13 ++++++++----- .../internal/load-support.svelte.ts | 2 +- .../managers/timeline-manager/month-group.svelte.ts | 5 ++++- 3 files changed, 13 insertions(+), 7 deletions(-) diff --git a/web/src/lib/managers/timeline-manager/group-insertion-cache.svelte.ts b/web/src/lib/managers/timeline-manager/group-insertion-cache.svelte.ts index aa4bae8919..566b11b8b7 100644 --- a/web/src/lib/managers/timeline-manager/group-insertion-cache.svelte.ts +++ b/web/src/lib/managers/timeline-manager/group-insertion-cache.svelte.ts @@ -1,6 +1,5 @@ import { setDifference, type TimelineDate } from '$lib/utils/timeline-util'; import { AssetOrder } from '@immich/sdk'; -import { SvelteSet } from 'svelte/reactivity'; import type { DayGroup } from './day-group.svelte'; import type { MonthGroup } from './month-group.svelte'; import type { TimelineAsset } from './types'; @@ -10,8 +9,10 @@ export class GroupInsertionCache { [year: number]: { [month: number]: { [day: number]: DayGroup } }; } = {}; unprocessedAssets: TimelineAsset[] = []; - changedDayGroups = new SvelteSet(); - newDayGroups = new SvelteSet(); + // eslint-disable-next-line svelte/prefer-svelte-reactivity + changedDayGroups = new Set(); + // eslint-disable-next-line svelte/prefer-svelte-reactivity + newDayGroups = new Set(); getDayGroup({ year, month, day }: TimelineDate): DayGroup | undefined { return this.#lookupCache[year]?.[month]?.[day]; @@ -32,7 +33,8 @@ export class GroupInsertionCache { } get updatedBuckets() { - const updated = new SvelteSet(); + // eslint-disable-next-line svelte/prefer-svelte-reactivity + const updated = new Set(); for (const group of this.changedDayGroups) { updated.add(group.monthGroup); } @@ -40,7 +42,8 @@ export class GroupInsertionCache { } get bucketsWithNewDayGroups() { - const updated = new SvelteSet(); + // eslint-disable-next-line svelte/prefer-svelte-reactivity + const updated = new Set(); for (const group of this.newDayGroups) { updated.add(group.monthGroup); } diff --git a/web/src/lib/managers/timeline-manager/internal/load-support.svelte.ts b/web/src/lib/managers/timeline-manager/internal/load-support.svelte.ts index ec50e3d75e..859e818583 100644 --- a/web/src/lib/managers/timeline-manager/internal/load-support.svelte.ts +++ b/web/src/lib/managers/timeline-manager/internal/load-support.svelte.ts @@ -46,7 +46,7 @@ export async function loadFromTimeBuckets( } } - const unprocessedAssets = monthGroup.addAssets(bucketResponse); + const unprocessedAssets = monthGroup.addAssets(bucketResponse, true); if (unprocessedAssets.length > 0) { console.error( `Warning: getTimeBucket API returning assets not in requested month: ${monthGroup.yearMonth.month}, ${JSON.stringify( diff --git a/web/src/lib/managers/timeline-manager/month-group.svelte.ts b/web/src/lib/managers/timeline-manager/month-group.svelte.ts index bef512c226..1d9e1bbaa7 100644 --- a/web/src/lib/managers/timeline-manager/month-group.svelte.ts +++ b/web/src/lib/managers/timeline-manager/month-group.svelte.ts @@ -153,7 +153,7 @@ export class MonthGroup { }; } - addAssets(bucketAssets: TimeBucketAssetResponseDto) { + addAssets(bucketAssets: TimeBucketAssetResponseDto, preSorted: boolean) { const addContext = new GroupInsertionCache(); for (let i = 0; i < bucketAssets.id.length; i++) { const { localDateTime, fileCreatedAt } = getTimes( @@ -194,6 +194,9 @@ export class MonthGroup { } this.addTimelineAsset(timelineAsset, addContext); } + if (preSorted) { + return addContext.unprocessedAssets; + } for (const group of addContext.existingDayGroups) { group.sortAssets(this.#sortOrder); From 8b7b9ee3948e19fe005938e984658ced5bdd0e5b Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 24 Nov 2025 17:27:46 +0100 Subject: [PATCH 09/87] chore(deps): update dependency esbuild to ^0.25.0 [security] (#23903) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- plugins/package-lock.json | 332 +++++++++++++++++++++++--------------- plugins/package.json | 2 +- pnpm-lock.yaml | 4 +- 3 files changed, 208 insertions(+), 130 deletions(-) diff --git a/plugins/package-lock.json b/plugins/package-lock.json index 3b0f0b34cb..231e298970 100644 --- a/plugins/package-lock.json +++ b/plugins/package-lock.json @@ -1,385 +1,459 @@ { - "name": "js-pdk-template", + "name": "plugins", "version": "1.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "js-pdk-template", + "name": "plugins", "version": "1.0.0", - "license": "BSD-3-Clause", + "license": "AGPL-3.0", "devDependencies": { "@extism/js-pdk": "^1.0.1", - "esbuild": "^0.19.6", + "esbuild": "^0.25.0", "typescript": "^5.3.2" } }, "node_modules/@esbuild/aix-ppc64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.19.12.tgz", - "integrity": "sha512-bmoCYyWdEL3wDQIVbcyzRyeKLgk2WtWLTWz1ZIAZF/EGbNOwSA6ew3PftJ1PqMiOOGu0OyFMzG53L0zqIpPeNA==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", "cpu": [ "ppc64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "aix" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/android-arm": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.19.12.tgz", - "integrity": "sha512-qg/Lj1mu3CdQlDEEiWrlC4eaPZ1KztwGJ9B6J+/6G+/4ewxJg7gqj8eVYWvao1bXrqGiW2rsBZFSX3q2lcW05w==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", "cpu": [ "arm" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "android" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/android-arm64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.19.12.tgz", - "integrity": "sha512-P0UVNGIienjZv3f5zq0DP3Nt2IE/3plFzuaS96vihvD0Hd6H/q4WXUGpCxD/E8YrSXfNyRPbpTq+T8ZQioSuPA==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", "cpu": [ "arm64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "android" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/android-x64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.19.12.tgz", - "integrity": "sha512-3k7ZoUW6Q6YqhdhIaq/WZ7HwBpnFBlW905Fa4s4qWJyiNOgT1dOqDiVAQFwBH7gBRZr17gLrlFCRzF6jFh7Kew==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", "cpu": [ "x64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "android" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/darwin-arm64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.19.12.tgz", - "integrity": "sha512-B6IeSgZgtEzGC42jsI+YYu9Z3HKRxp8ZT3cqhvliEHovq8HSX2YX8lNocDn79gCKJXOSaEot9MVYky7AKjCs8g==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", "cpu": [ "arm64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "darwin" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/darwin-x64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.19.12.tgz", - "integrity": "sha512-hKoVkKzFiToTgn+41qGhsUJXFlIjxI/jSYeZf3ugemDYZldIXIxhvwN6erJGlX4t5h417iFuheZ7l+YVn05N3A==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", "cpu": [ "x64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "darwin" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/freebsd-arm64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.19.12.tgz", - "integrity": "sha512-4aRvFIXmwAcDBw9AueDQ2YnGmz5L6obe5kmPT8Vd+/+x/JMVKCgdcRwH6APrbpNXsPz+K653Qg8HB/oXvXVukA==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", "cpu": [ "arm64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "freebsd" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/freebsd-x64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.19.12.tgz", - "integrity": "sha512-EYoXZ4d8xtBoVN7CEwWY2IN4ho76xjYXqSXMNccFSx2lgqOG/1TBPW0yPx1bJZk94qu3tX0fycJeeQsKovA8gg==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", "cpu": [ "x64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "freebsd" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/linux-arm": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.19.12.tgz", - "integrity": "sha512-J5jPms//KhSNv+LO1S1TX1UWp1ucM6N6XuL6ITdKWElCu8wXP72l9MM0zDTzzeikVyqFE6U8YAV9/tFyj0ti+w==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", "cpu": [ "arm" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/linux-arm64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.19.12.tgz", - "integrity": "sha512-EoTjyYyLuVPfdPLsGVVVC8a0p1BFFvtpQDB/YLEhaXyf/5bczaGeN15QkR+O4S5LeJ92Tqotve7i1jn35qwvdA==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", "cpu": [ "arm64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/linux-ia32": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.19.12.tgz", - "integrity": "sha512-Thsa42rrP1+UIGaWz47uydHSBOgTUnwBwNq59khgIwktK6x60Hivfbux9iNR0eHCHzOLjLMLfUMLCypBkZXMHA==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", "cpu": [ "ia32" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/linux-loong64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.19.12.tgz", - "integrity": "sha512-LiXdXA0s3IqRRjm6rV6XaWATScKAXjI4R4LoDlvO7+yQqFdlr1Bax62sRwkVvRIrwXxvtYEHHI4dm50jAXkuAA==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", "cpu": [ "loong64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/linux-mips64el": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.19.12.tgz", - "integrity": "sha512-fEnAuj5VGTanfJ07ff0gOA6IPsvrVHLVb6Lyd1g2/ed67oU1eFzL0r9WL7ZzscD+/N6i3dWumGE1Un4f7Amf+w==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", "cpu": [ "mips64el" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/linux-ppc64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.19.12.tgz", - "integrity": "sha512-nYJA2/QPimDQOh1rKWedNOe3Gfc8PabU7HT3iXWtNUbRzXS9+vgB0Fjaqr//XNbd82mCxHzik2qotuI89cfixg==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", "cpu": [ "ppc64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/linux-riscv64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.19.12.tgz", - "integrity": "sha512-2MueBrlPQCw5dVJJpQdUYgeqIzDQgw3QtiAHUC4RBz9FXPrskyyU3VI1hw7C0BSKB9OduwSJ79FTCqtGMWqJHg==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", "cpu": [ "riscv64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/linux-s390x": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.19.12.tgz", - "integrity": "sha512-+Pil1Nv3Umes4m3AZKqA2anfhJiVmNCYkPchwFJNEJN5QxmTs1uzyy4TvmDrCRNT2ApwSari7ZIgrPeUx4UZDg==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", "cpu": [ "s390x" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/linux-x64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.19.12.tgz", - "integrity": "sha512-B71g1QpxfwBvNrfyJdVDexenDIt1CiDN1TIXLbhOw0KhJzE78KIFGX6OJ9MrtC0oOqMWf+0xop4qEU8JrJTwCg==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", "cpu": [ "x64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, - "node_modules/@esbuild/netbsd-x64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.19.12.tgz", - "integrity": "sha512-3ltjQ7n1owJgFbuC61Oj++XhtzmymoCihNFgT84UAmJnxJfm4sYCiSLTXZtE00VWYpPMYc+ZQmB6xbSdVh0JWA==", + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", "cpu": [ - "x64" + "arm64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "netbsd" ], "engines": { - "node": ">=12" + "node": ">=18" } }, - "node_modules/@esbuild/openbsd-x64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.19.12.tgz", - "integrity": "sha512-RbrfTB9SWsr0kWmb9srfF+L933uMDdu9BIzdA7os2t0TXhCRjrQyCeOt6wVxr79CKD4c+p+YhCj31HBkYcXebw==", + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", "cpu": [ "x64" ], "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", "optional": true, "os": [ "openbsd" ], "engines": { - "node": ">=12" + "node": ">=18" } }, - "node_modules/@esbuild/sunos-x64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.19.12.tgz", - "integrity": "sha512-HKjJwRrW8uWtCQnQOz9qcU3mUZhTUQvi56Q8DPTLLB+DawoiQdjsYq+j+D3s9I8VFtDr+F9CjgXKKC4ss89IeA==", + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", "cpu": [ "x64" ], "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", "optional": true, "os": [ "sunos" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/win32-arm64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.19.12.tgz", - "integrity": "sha512-URgtR1dJnmGvX864pn1B2YUYNzjmXkuJOIqG2HdU62MVS4EHpU2946OZoTMnRUHklGtJdJZ33QfzdjGACXhn1A==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", "cpu": [ "arm64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "win32" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/win32-ia32": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.19.12.tgz", - "integrity": "sha512-+ZOE6pUkMOJfmxmBZElNOx72NKpIa/HFOMGzu8fqzQJ5kgf6aTGrcJaFsNiVMH4JKpMipyK+7k0n2UXN7a8YKQ==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", "cpu": [ "ia32" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "win32" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/win32-x64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.19.12.tgz", - "integrity": "sha512-T1QyPSDCyMXaO3pzBkF96E8xMkiRYbUEZADd29SyPGabqxMViNoii+NcK7eWJAEoU6RZyEm5lVSIjTmcdoB9HA==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", "cpu": [ "x64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "win32" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@extism/js-pdk": { @@ -389,41 +463,45 @@ "dev": true }, "node_modules/esbuild": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.19.12.tgz", - "integrity": "sha512-aARqgq8roFBj054KvQr5f1sFu0D65G+miZRCuJyJ0G13Zwx7vRar5Zhn2tkQNzIXcBrNVsv/8stehpj+GAjgbg==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", "dev": true, "hasInstallScript": true, + "license": "MIT", "bin": { "esbuild": "bin/esbuild" }, "engines": { - "node": ">=12" + "node": ">=18" }, "optionalDependencies": { - "@esbuild/aix-ppc64": "0.19.12", - "@esbuild/android-arm": "0.19.12", - "@esbuild/android-arm64": "0.19.12", - "@esbuild/android-x64": "0.19.12", - "@esbuild/darwin-arm64": "0.19.12", - "@esbuild/darwin-x64": "0.19.12", - "@esbuild/freebsd-arm64": "0.19.12", - "@esbuild/freebsd-x64": "0.19.12", - "@esbuild/linux-arm": "0.19.12", - "@esbuild/linux-arm64": "0.19.12", - "@esbuild/linux-ia32": "0.19.12", - "@esbuild/linux-loong64": "0.19.12", - "@esbuild/linux-mips64el": "0.19.12", - "@esbuild/linux-ppc64": "0.19.12", - "@esbuild/linux-riscv64": "0.19.12", - "@esbuild/linux-s390x": "0.19.12", - "@esbuild/linux-x64": "0.19.12", - "@esbuild/netbsd-x64": "0.19.12", - "@esbuild/openbsd-x64": "0.19.12", - "@esbuild/sunos-x64": "0.19.12", - "@esbuild/win32-arm64": "0.19.12", - "@esbuild/win32-ia32": "0.19.12", - "@esbuild/win32-x64": "0.19.12" + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" } }, "node_modules/typescript": { diff --git a/plugins/package.json b/plugins/package.json index ab6b2f8435..024b7bc8a9 100644 --- a/plugins/package.json +++ b/plugins/package.json @@ -13,7 +13,7 @@ "license": "AGPL-3.0", "devDependencies": { "@extism/js-pdk": "^1.0.1", - "esbuild": "^0.19.6", + "esbuild": "^0.25.0", "typescript": "^5.3.2" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1228fcd55f..c7b254c1c6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -311,8 +311,8 @@ importers: specifier: ^1.0.1 version: 1.1.1 esbuild: - specifier: ^0.19.6 - version: 0.19.12 + specifier: ^0.25.0 + version: 0.25.12 typescript: specifier: ^5.3.2 version: 5.9.3 From c1198b99b7c3d925e2365852c0fcf2d2e9d90a85 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 24 Nov 2025 17:28:18 +0100 Subject: [PATCH 10/87] chore(deps): update dependency js-yaml to v4.1.1 [security] (#23901) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- pnpm-lock.yaml | 30 +++++++++++++++++++----------- 1 file changed, 19 insertions(+), 11 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c7b254c1c6..acdc2045d0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -456,7 +456,7 @@ importers: version: 5.10.0 js-yaml: specifier: ^4.1.0 - version: 4.1.0 + version: 4.1.1 jsonwebtoken: specifier: ^9.0.2 version: 9.0.2 @@ -7705,14 +7705,18 @@ packages: js-tokens@9.0.1: resolution: {integrity: sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==} - js-yaml@3.14.1: - resolution: {integrity: sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==} + js-yaml@3.14.2: + resolution: {integrity: sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==} hasBin: true js-yaml@4.1.0: resolution: {integrity: sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==} hasBin: true + js-yaml@4.1.1: + resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==} + hasBin: true + jsdom@20.0.3: resolution: {integrity: sha512-SYhBvTh89tTfCD/CRdSOm13mOBa42iTaTyfyEWBdKcGdPxPtLFBXuHR8XHb33YNYaP+lLbmSvBTsnoesCNJEsQ==} engines: {node: '>=14'} @@ -13553,7 +13557,7 @@ snapshots: '@types/react-router-config': 5.0.11 combine-promises: 1.2.0 fs-extra: 11.3.2 - js-yaml: 4.1.0 + js-yaml: 4.1.1 lodash: 4.17.21 react: 18.3.1 react-dom: 18.3.1(react@18.3.1) @@ -14009,7 +14013,7 @@ snapshots: '@docusaurus/utils-common': 3.9.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) fs-extra: 11.3.2 joi: 17.13.3 - js-yaml: 4.1.0 + js-yaml: 4.1.1 lodash: 4.17.21 tslib: 2.8.1 transitivePeerDependencies: @@ -14034,7 +14038,7 @@ snapshots: globby: 11.1.0 gray-matter: 4.0.3 jiti: 1.21.7 - js-yaml: 4.1.0 + js-yaml: 4.1.1 lodash: 4.17.21 micromatch: 4.0.8 p-queue: 6.6.2 @@ -14240,7 +14244,7 @@ snapshots: globals: 14.0.0 ignore: 5.3.2 import-fresh: 3.3.1 - js-yaml: 4.1.0 + js-yaml: 4.1.1 minimatch: 3.1.2 strip-json-comments: 3.1.1 transitivePeerDependencies: @@ -17965,7 +17969,7 @@ snapshots: cosmiconfig@8.3.6(typescript@5.8.3): dependencies: import-fresh: 3.3.1 - js-yaml: 4.1.0 + js-yaml: 4.1.1 parse-json: 5.2.0 path-type: 4.0.0 optionalDependencies: @@ -17974,7 +17978,7 @@ snapshots: cosmiconfig@8.3.6(typescript@5.9.3): dependencies: import-fresh: 3.3.1 - js-yaml: 4.1.0 + js-yaml: 4.1.1 parse-json: 5.2.0 path-type: 4.0.0 optionalDependencies: @@ -19425,7 +19429,7 @@ snapshots: gray-matter@4.0.3: dependencies: - js-yaml: 3.14.1 + js-yaml: 3.14.2 kind-of: 6.0.3 section-matter: 1.0.0 strip-bom-string: 1.0.0 @@ -20133,7 +20137,7 @@ snapshots: js-tokens@9.0.1: {} - js-yaml@3.14.1: + js-yaml@3.14.2: dependencies: argparse: 1.0.10 esprima: 4.0.1 @@ -20142,6 +20146,10 @@ snapshots: dependencies: argparse: 2.0.1 + js-yaml@4.1.1: + dependencies: + argparse: 2.0.1 + jsdom@20.0.3(canvas@2.11.2): dependencies: abab: 2.0.6 From 78553a0258131e7531f66e802b319244d97c73d4 Mon Sep 17 00:00:00 2001 From: fabianbees Date: Mon, 24 Nov 2025 17:30:15 +0100 Subject: [PATCH 11/87] feat: separate camera and lens info in detail panel (#23670) Co-authored-by: Daniel Dietzler --- .../asset-viewer/detail-panel.svelte | 55 ++++++++++--------- 1 file changed, 30 insertions(+), 25 deletions(-) diff --git a/web/src/lib/components/asset-viewer/detail-panel.svelte b/web/src/lib/components/asset-viewer/detail-panel.svelte index 2ee4496830..60913ff47b 100644 --- a/web/src/lib/components/asset-viewer/detail-panel.svelte +++ b/web/src/lib/components/asset-viewer/detail-panel.svelte @@ -23,6 +23,7 @@ import { Icon, IconButton, LoadingSpinner, modalManager } from '@immich/ui'; import { mdiCalendar, + mdiCamera, mdiCameraIris, mdiClose, mdiEye, @@ -372,9 +373,9 @@
- {#if asset.exifInfo?.make || asset.exifInfo?.model || asset.exifInfo?.fNumber} + {#if asset.exifInfo?.make || asset.exifInfo?.model || asset.exifInfo?.exposureTime || asset.exifInfo?.iso}
-
+
{#if asset.exifInfo?.make || asset.exifInfo?.model} @@ -395,20 +396,34 @@

{/if} +
+ {#if asset.exifInfo.exposureTime} +

{`${asset.exifInfo.exposureTime} s`}

+ {/if} + + {#if asset.exifInfo.iso} +

{`ISO ${asset.exifInfo.iso}`}

+ {/if} +
+
+
+ {/if} + + {#if asset.exifInfo?.lensModel || asset.exifInfo?.fNumber || asset.exifInfo?.focalLength} +
+
+ +
{#if asset.exifInfo?.lensModel} - +

+ + {asset.exifInfo.lensModel} + +

{/if}
@@ -416,19 +431,9 @@

ƒ/{asset.exifInfo.fNumber.toLocaleString($locale)}

{/if} - {#if asset.exifInfo.exposureTime} -

{`${asset.exifInfo.exposureTime} s`}

- {/if} - {#if asset.exifInfo.focalLength}

{`${asset.exifInfo.focalLength.toLocaleString($locale)} mm`}

{/if} - - {#if asset.exifInfo.iso} -

- {`ISO ${asset.exifInfo.iso}`} -

- {/if}
From 7694b342ed41adc56212b85c50c65d2992b57f0c Mon Sep 17 00:00:00 2001 From: Min Idzelis Date: Mon, 24 Nov 2025 18:09:46 -0500 Subject: [PATCH 12/87] refactor(web): Extract asset grid layout component from TimelineDateGroup and split into AssetLayout and Month components (#23338) * refactor(web): Extract asset grid layout component from TimelineDateGroup and split into AssetLayout and Month components * chore: cleanup --------- Co-authored-by: Daniel Dietzler <36593685+danieldietzler@users.noreply.github.com> --- .../components/timeline/AssetLayout.svelte | 66 +++++ web/src/lib/components/timeline/Month.svelte | 115 ++++++++ .../lib/components/timeline/Timeline.svelte | 97 +++++-- .../timeline/TimelineDateGroup.svelte | 246 ------------------ .../(user)/utilities/geolocation/+page.svelte | 2 +- 5 files changed, 262 insertions(+), 264 deletions(-) create mode 100644 web/src/lib/components/timeline/AssetLayout.svelte create mode 100644 web/src/lib/components/timeline/Month.svelte delete mode 100644 web/src/lib/components/timeline/TimelineDateGroup.svelte diff --git a/web/src/lib/components/timeline/AssetLayout.svelte b/web/src/lib/components/timeline/AssetLayout.svelte new file mode 100644 index 0000000000..1d3300ca71 --- /dev/null +++ b/web/src/lib/components/timeline/AssetLayout.svelte @@ -0,0 +1,66 @@ + + + +
+ {#each filterIntersecting(viewerAssets) as viewerAsset (viewerAsset.id)} + {@const position = viewerAsset.position!} + {@const asset = viewerAsset.asset!} + + +
+ {@render thumbnail({ asset, position })} + {@render customThumbnailLayout?.(asset)} +
+ {/each} +
+ + diff --git a/web/src/lib/components/timeline/Month.svelte b/web/src/lib/components/timeline/Month.svelte new file mode 100644 index 0000000000..f7ffb58c43 --- /dev/null +++ b/web/src/lib/components/timeline/Month.svelte @@ -0,0 +1,115 @@ + + +{#each filterIntersecting(monthGroup.dayGroups) as dayGroup, groupIndex (dayGroup.day)} + {@const absoluteWidth = dayGroup.left} + {@const isDayGroupSelected = assetInteraction.selectedGroup.has(dayGroup.groupTitle)} + +
(hoveredDayGroup = dayGroup.groupTitle)} + onmouseleave={() => (hoveredDayGroup = null)} + > + +
+ {#if !singleSelect} +
onDayGroupSelect(dayGroup, assetsSnapshot(dayGroup.getAssets()))} + onkeydown={() => onDayGroupSelect(dayGroup, assetsSnapshot(dayGroup.getAssets()))} + > + {#if isDayGroupSelected} + + {:else} + + {/if} +
+ {/if} + + + {dayGroup.groupTitle} + +
+ + + {#snippet thumbnail({ asset, position })} + {@render thumbnailWithGroup({ asset, position, dayGroup, groupIndex })} + {/snippet} + +
+{/each} + + diff --git a/web/src/lib/components/timeline/Timeline.svelte b/web/src/lib/components/timeline/Timeline.svelte index 0a209fcde3..d2873eca70 100644 --- a/web/src/lib/components/timeline/Timeline.svelte +++ b/web/src/lib/components/timeline/Timeline.svelte @@ -2,6 +2,8 @@ import { afterNavigate, beforeNavigate } from '$app/navigation'; import { page } from '$app/state'; import { resizeObserver, type OnResizeCallback } from '$lib/actions/resize-observer'; + import Thumbnail from '$lib/components/assets/thumbnail/thumbnail.svelte'; + import Month from '$lib/components/timeline/Month.svelte'; import Scrubber from '$lib/components/timeline/Scrubber.svelte'; import TimelineAssetViewer from '$lib/components/timeline/TimelineAssetViewer.svelte'; import TimelineKeyboardActions from '$lib/components/timeline/actions/TimelineKeyboardActions.svelte'; @@ -19,13 +21,12 @@ import { assetViewingStore } from '$lib/stores/asset-viewing.store'; import { isSelectingAllAssets } from '$lib/stores/assets-store.svelte'; import { mobileDevice } from '$lib/stores/mobile-device.svelte'; - import { isAssetViewerRoute } from '$lib/utils/navigation'; + import { isAssetViewerRoute, navigate } from '$lib/utils/navigation'; import { getTimes, type ScrubberListener } from '$lib/utils/timeline-util'; import { type AlbumResponseDto, type PersonResponseDto } from '@immich/sdk'; import { DateTime } from 'luxon'; import { onDestroy, onMount, type Snippet } from 'svelte'; import type { UpdatePayload } from 'vite'; - import TimelineDateGroup from './TimelineDateGroup.svelte'; interface Props { isSelectionMode?: boolean; @@ -54,7 +55,7 @@ onEscape?: () => void; children?: Snippet; empty?: Snippet; - customLayout?: Snippet<[TimelineAsset]>; + customThumbnailLayout?: Snippet<[TimelineAsset]>; onThumbnailClick?: ( asset: TimelineAsset, timelineManager: TimelineManager, @@ -86,7 +87,7 @@ onEscape = () => {}, children, empty, - customLayout, + customThumbnailLayout, onThumbnailClick, }: Props = $props(); @@ -398,7 +399,8 @@ lastAssetMouseEvent = asset; }; - const handleGroupSelect = (timelineManager: TimelineManager, group: string, assets: TimelineAsset[]) => { + const handleGroupSelect = (dayGroup: DayGroup, assets: TimelineAsset[]) => { + const group = dayGroup.groupTitle; if (assetInteraction.selectedGroup.has(group)) { assetInteraction.removeGroupFromMultiselectGroup(group); for (const asset of assets) { @@ -418,7 +420,7 @@ } }; - const handleSelectAssets = async (asset: TimelineAsset) => { + const onSelectAssets = async (asset: TimelineAsset) => { if (!asset) { return; } @@ -540,6 +542,40 @@ void timelineManager.loadMonthGroup({ year: localDateTime.year, month: localDateTime.month }); } }); + + const assetSelectHandler = ( + timelineManager: TimelineManager, + asset: TimelineAsset, + assetsInDayGroup: TimelineAsset[], + groupTitle: string, + ) => { + void onSelectAssets(asset); + + // Check if all assets are selected in a group to toggle the group selection's icon + let selectedAssetsInGroupCount = assetsInDayGroup.filter(({ id }) => assetInteraction.hasSelectedAsset(id)).length; + + // if all assets are selected in a group, add the group to selected group + if (selectedAssetsInGroupCount === assetsInDayGroup.length) { + assetInteraction.addGroupToMultiselectGroup(groupTitle); + } else { + assetInteraction.removeGroupFromMultiselectGroup(groupTitle); + } + + isSelectingAllAssets.set(timelineManager.assetCount === assetInteraction.selectedAssets.length); + }; + + const _onClick = ( + timelineManager: TimelineManager, + assets: TimelineAsset[], + groupTitle: string, + asset: TimelineAsset, + ) => { + if (isSelectionMode || assetInteraction.selectionActive) { + assetSelectHandler(timelineManager, asset, assets, groupTitle); + return; + } + void navigate({ targetRoute: 'current', assetId: asset.id }); + }; @@ -649,20 +685,47 @@ style:transform={`translate3d(0,${absoluteHeight}px,0)`} style:width="100%" > - handleGroupSelect(timelineManager, title, assets)} - onSelectAssetCandidates={handleSelectAssetCandidates} - onSelectAssets={handleSelectAssets} - {customLayout} - {onThumbnailClick} - /> + manager={timelineManager} + onDayGroupSelect={handleGroupSelect} + > + {#snippet thumbnail({ asset, position, dayGroup, groupIndex })} + {@const isAssetSelectionCandidate = assetInteraction.hasSelectionCandidate(asset.id)} + {@const isAssetSelected = + assetInteraction.hasSelectedAsset(asset.id) || timelineManager.albumAssets.has(asset.id)} + {@const isAssetDisabled = timelineManager.albumAssets.has(asset.id)} + { + if (typeof onThumbnailClick === 'function') { + onThumbnailClick(asset, timelineManager, dayGroup, _onClick); + } else { + _onClick(timelineManager, dayGroup.getAssets(), dayGroup.groupTitle, asset); + } + }} + onSelect={() => { + if (isSelectionMode || assetInteraction.selectionActive) { + assetSelectHandler(timelineManager, asset, dayGroup.getAssets(), dayGroup.groupTitle); + return; + } + void onSelectAssets(asset); + }} + onMouseEvent={() => handleSelectAssetCandidates(asset)} + selected={isAssetSelected} + selectionCandidate={isAssetSelectionCandidate} + disabled={isAssetDisabled} + thumbnailWidth={position.width} + thumbnailHeight={position.height} + /> + {/snippet} + {/if} {/each} diff --git a/web/src/lib/components/timeline/TimelineDateGroup.svelte b/web/src/lib/components/timeline/TimelineDateGroup.svelte deleted file mode 100644 index c662c16e72..0000000000 --- a/web/src/lib/components/timeline/TimelineDateGroup.svelte +++ /dev/null @@ -1,246 +0,0 @@ - - -{#each filterIntersecting(monthGroup.dayGroups) as dayGroup, groupIndex (dayGroup.day)} - {@const absoluteWidth = dayGroup.left} - - -
{ - isMouseOverGroup = true; - assetMouseEventHandler(dayGroup.groupTitle, null); - }} - onmouseleave={() => { - isMouseOverGroup = false; - assetMouseEventHandler(dayGroup.groupTitle, null); - }} - > - -
- {#if !singleSelect} -
handleSelectGroup(dayGroup.groupTitle, assetsSnapshot(dayGroup.getAssets()))} - onkeydown={() => handleSelectGroup(dayGroup.groupTitle, assetsSnapshot(dayGroup.getAssets()))} - > - {#if assetInteraction.selectedGroup.has(dayGroup.groupTitle)} - - {:else} - - {/if} -
- {/if} - - - {dayGroup.groupTitle} - -
- - -
- {#each filterIntersecting(dayGroup.viewerAssets) as viewerAsset (viewerAsset.id)} - {@const position = viewerAsset.position!} - {@const asset = viewerAsset.asset!} - - - -
- { - if (typeof onThumbnailClick === 'function') { - onThumbnailClick(asset, timelineManager, dayGroup, _onClick); - } else { - _onClick(timelineManager, dayGroup.getAssets(), dayGroup.groupTitle, asset); - } - }} - onSelect={(asset) => assetSelectHandler(timelineManager, asset, dayGroup.getAssets(), dayGroup.groupTitle)} - onMouseEvent={() => assetMouseEventHandler(dayGroup.groupTitle, assetSnapshot(asset))} - selected={assetInteraction.hasSelectedAsset(asset.id) || - dayGroup.monthGroup.timelineManager.albumAssets.has(asset.id)} - selectionCandidate={assetInteraction.hasSelectionCandidate(asset.id)} - disabled={dayGroup.monthGroup.timelineManager.albumAssets.has(asset.id)} - thumbnailWidth={position.width} - thumbnailHeight={position.height} - /> - {#if customLayout} - {@render customLayout(asset)} - {/if} -
- - {/each} -
-
-{/each} - - diff --git a/web/src/routes/(user)/utilities/geolocation/+page.svelte b/web/src/routes/(user)/utilities/geolocation/+page.svelte index 4bd1a29fe5..89615062d4 100644 --- a/web/src/routes/(user)/utilities/geolocation/+page.svelte +++ b/web/src/routes/(user)/utilities/geolocation/+page.svelte @@ -196,7 +196,7 @@ withStacked onThumbnailClick={handleThumbnailClick} > - {#snippet customLayout(asset: TimelineAsset)} + {#snippet customThumbnailLayout(asset: TimelineAsset)} {#if hasGps(asset)}
{asset.city || $t('gps')} From 8755cd59fda693cb792e7be092f2e60e0e244a2b Mon Sep 17 00:00:00 2001 From: Daniel Dietzler <36593685+danieldietzler@users.noreply.github.com> Date: Tue, 25 Nov 2025 00:57:46 +0100 Subject: [PATCH 13/87] chore: refactor svelte reactivity (#24072) --- e2e/src/mock-network/timeline-network.ts | 44 ++++++++++++------- .../asset-viewer/activity-viewer.svelte | 8 +--- .../asset-viewer/album-list-item.svelte | 6 +-- .../asset-viewer/asset-viewer.svelte | 10 ++--- .../asset-viewer/photo-viewer.svelte | 1 - .../asset-viewer/video-native-viewer.svelte | 12 ++--- .../asset-viewer/video-remote-viewer.svelte | 1 - .../components/places-page/places-list.svelte | 37 +++++----------- .../shared-components/change-location.svelte | 5 +-- .../context-menu/context-menu.svelte | 39 ++++++++-------- .../search-bar/search-camera-section.svelte | 7 +-- .../search-bar/search-location-section.svelte | 18 +++----- web/src/lib/stores/ocr.svelte.ts | 8 ++-- web/src/routes/(user)/+layout.svelte | 12 +++-- .../[[assetId=id]]/+page.svelte | 7 ++- web/src/routes/+layout.svelte | 4 +- web/src/routes/auth/onboarding/+page.svelte | 14 +++--- 17 files changed, 105 insertions(+), 128 deletions(-) diff --git a/e2e/src/mock-network/timeline-network.ts b/e2e/src/mock-network/timeline-network.ts index 012defe4ab..59bce71dd8 100644 --- a/e2e/src/mock-network/timeline-network.ts +++ b/e2e/src/mock-network/timeline-network.ts @@ -62,50 +62,60 @@ export const setupTimelineMockApiRoutes = async ( return route.continue(); }); - await context.route('**/api/assets/**', async (route, request) => { + await context.route('**/api/assets/*', async (route, request) => { + const url = new URL(request.url()); + const pathname = url.pathname; + const assetId = basename(pathname); + const asset = getAsset(timelineRestData, assetId); + return route.fulfill({ + status: 200, + contentType: 'application/json', + json: asset, + }); + }); + + await context.route('**/api/assets/*/ocr', async (route) => { + return route.fulfill({ status: 200, contentType: 'application/json', json: [] }); + }); + + await context.route('**/api/assets/*/thumbnail?size=*', async (route, request) => { const pattern = /\/api\/assets\/(?[^/]+)\/thumbnail\?size=(?preview|thumbnail)/; const match = request.url().match(pattern); - if (!match) { - const url = new URL(request.url()); - const pathname = url.pathname; - const assetId = basename(pathname); - const asset = getAsset(timelineRestData, assetId); - return route.fulfill({ - status: 200, - contentType: 'application/json', - json: asset, - }); + if (!match?.groups) { + throw new Error(`Invalid URL for thumbnail endpoint: ${request.url()}`); } - if (match.groups?.size === 'preview') { + + if (match.groups.size === 'preview') { if (!route.request().serviceWorker()) { return route.continue(); } - const asset = getAsset(timelineRestData, match.groups?.assetId); + const asset = getAsset(timelineRestData, match.groups.assetId); return route.fulfill({ status: 200, headers: { 'content-type': 'image/jpeg', ETag: 'abc123', 'Cache-Control': 'public, max-age=3600' }, body: await randomPreview( - match.groups?.assetId, + match.groups.assetId, (asset?.exifInfo?.exifImageWidth ?? 0) / (asset?.exifInfo?.exifImageHeight ?? 1), ), }); } - if (match.groups?.size === 'thumbnail') { + if (match.groups.size === 'thumbnail') { if (!route.request().serviceWorker()) { return route.continue(); } - const asset = getAsset(timelineRestData, match.groups?.assetId); + const asset = getAsset(timelineRestData, match.groups.assetId); return route.fulfill({ status: 200, headers: { 'content-type': 'image/jpeg' }, body: await randomThumbnail( - match.groups?.assetId, + match.groups.assetId, (asset?.exifInfo?.exifImageWidth ?? 0) / (asset?.exifInfo?.exifImageHeight ?? 1), ), }); } return route.continue(); }); + await context.route('**/api/albums/**', async (route, request) => { const pattern = /\/api\/albums\/(?[^/?]+)/; const match = request.url().match(pattern); diff --git a/web/src/lib/components/asset-viewer/activity-viewer.svelte b/web/src/lib/components/asset-viewer/activity-viewer.svelte index 73b311769a..d688b2e9dd 100644 --- a/web/src/lib/components/asset-viewer/activity-viewer.svelte +++ b/web/src/lib/components/asset-viewer/activity-viewer.svelte @@ -52,7 +52,7 @@ let innerHeight: number = $state(0); let activityHeight: number = $state(0); let chatHeight: number = $state(0); - let divHeight: number = $state(0); + let divHeight = $derived(innerHeight - activityHeight); let previousAssetId: string | undefined = $state(assetId); let message = $state(''); let isSendingMessage = $state(false); @@ -96,11 +96,7 @@ } isSendingMessage = false; }; - $effect(() => { - if (innerHeight && activityHeight) { - divHeight = innerHeight - activityHeight; - } - }); + $effect(() => { if (assetId && previousAssetId != assetId) { previousAssetId = assetId; diff --git a/web/src/lib/components/asset-viewer/album-list-item.svelte b/web/src/lib/components/asset-viewer/album-list-item.svelte index 21b9b385d8..da0df21839 100644 --- a/web/src/lib/components/asset-viewer/album-list-item.svelte +++ b/web/src/lib/components/asset-viewer/album-list-item.svelte @@ -35,15 +35,13 @@ }); }; - let albumNameArray: string[] = $state(['', '', '']); - // This part of the code is responsible for splitting album name into 3 parts where part 2 is the search query // It is used to highlight the search query in the album name - $effect(() => { + const albumNameArray: string[] = $derived.by(() => { let { albumName } = album; let findIndex = normalizeSearchString(albumName).indexOf(normalizeSearchString(searchQuery)); let findLength = searchQuery.length; - albumNameArray = [ + return [ albumName.slice(0, findIndex), albumName.slice(findIndex, findIndex + findLength), albumName.slice(findIndex + findLength), diff --git a/web/src/lib/components/asset-viewer/asset-viewer.svelte b/web/src/lib/components/asset-viewer/asset-viewer.svelte index 0af27e8373..4e23206659 100644 --- a/web/src/lib/components/asset-viewer/asset-viewer.svelte +++ b/web/src/lib/components/asset-viewer/asset-viewer.svelte @@ -395,13 +395,11 @@ } }); - let currentAssetId = $derived(asset.id); + // primarily, this is reactive on `asset` $effect(() => { - if (currentAssetId) { - untrack(() => handlePromiseError(handleGetAllAlbums())); - ocrManager.clear(); - handlePromiseError(ocrManager.getAssetOcr(currentAssetId)); - } + handlePromiseError(handleGetAllAlbums()); + ocrManager.clear(); + handlePromiseError(ocrManager.getAssetOcr(asset.id)); }); diff --git a/web/src/lib/components/asset-viewer/photo-viewer.svelte b/web/src/lib/components/asset-viewer/photo-viewer.svelte index 261f194d34..2607f6de79 100644 --- a/web/src/lib/components/asset-viewer/photo-viewer.svelte +++ b/web/src/lib/components/asset-viewer/photo-viewer.svelte @@ -171,7 +171,6 @@ $effect(() => { if (assetFileUrl) { - // this can't be in an async context with $effect void cast(assetFileUrl); } }); diff --git a/web/src/lib/components/asset-viewer/video-native-viewer.svelte b/web/src/lib/components/asset-viewer/video-native-viewer.svelte index 92c467bc1e..a25789a76c 100644 --- a/web/src/lib/components/asset-viewer/video-native-viewer.svelte +++ b/web/src/lib/components/asset-viewer/video-native-viewer.svelte @@ -43,7 +43,9 @@ let videoPlayer: HTMLVideoElement | undefined = $state(); let isLoading = $state(true); - let assetFileUrl = $state(''); + let assetFileUrl = $derived( + playOriginalVideo ? getAssetOriginalUrl({ id: assetId, cacheKey }) : getAssetPlaybackUrl({ id: assetId, cacheKey }), + ); let isScrubbing = $state(false); let showVideo = $state(false); @@ -53,11 +55,9 @@ }); $effect(() => { - assetFileUrl = playOriginalVideo - ? getAssetOriginalUrl({ id: assetId, cacheKey }) - : getAssetPlaybackUrl({ id: assetId, cacheKey }); - if (videoPlayer) { - videoPlayer.load(); + // reactive on `assetFileUrl` changes + if (assetFileUrl) { + videoPlayer?.load(); } }); diff --git a/web/src/lib/components/asset-viewer/video-remote-viewer.svelte b/web/src/lib/components/asset-viewer/video-remote-viewer.svelte index 392028c49f..94a7e748c5 100644 --- a/web/src/lib/components/asset-viewer/video-remote-viewer.svelte +++ b/web/src/lib/components/asset-viewer/video-remote-viewer.svelte @@ -35,7 +35,6 @@ $effect(() => { if (assetFileUrl) { - // this can't be in an async context with $effect void cast(assetFileUrl); } }); diff --git a/web/src/lib/components/places-page/places-list.svelte b/web/src/lib/components/places-page/places-list.svelte index 3da4772e0c..bcb90cb18e 100644 --- a/web/src/lib/components/places-page/places-list.svelte +++ b/web/src/lib/components/places-page/places-list.svelte @@ -9,7 +9,6 @@ import { type PlacesGroup, getSelectedPlacesGroupOption } from '$lib/utils/places-utils'; import { Icon } from '@immich/ui'; import { t } from 'svelte-i18n'; - import { run } from 'svelte/legacy'; interface Props { places?: AssetResponseDto[]; @@ -70,39 +69,27 @@ }, }; - let filteredPlaces: AssetResponseDto[] = $state([]); - let groupedPlaces: PlacesGroup[] = $state([]); + const filteredPlaces = $derived.by(() => { + const searchQueryNormalized = normalizeSearchString(searchQuery); + return searchQueryNormalized + ? places.filter((place) => normalizeSearchString(place.exifInfo?.city ?? '').includes(searchQueryNormalized)) + : places; + }); - let placesGroupOption: string = $state(PlacesGroupBy.None); - - let hasPlaces = $derived(places.length > 0); - - // Step 1: Filter using the given search query. - run(() => { - if (searchQuery) { - const searchQueryNormalized = normalizeSearchString(searchQuery); - - filteredPlaces = places.filter((place) => { - return normalizeSearchString(place.exifInfo?.city ?? '').includes(searchQueryNormalized); - }); - } else { - filteredPlaces = places; - } + const placesGroupOption: string = $derived(getSelectedPlacesGroupOption(userSettings)); + const groupingFunction = $derived(groupOptions[placesGroupOption] ?? groupOptions[PlacesGroupBy.None]); + const groupedPlaces: PlacesGroup[] = $derived(groupingFunction(filteredPlaces)); + $effect(() => { searchResultCount = filteredPlaces.length; }); - // Step 2: Group places. - run(() => { - placesGroupOption = getSelectedPlacesGroupOption(userSettings); - const groupFunc = groupOptions[placesGroupOption] ?? groupOptions[PlacesGroupBy.None]; - groupedPlaces = groupFunc(filteredPlaces); - + $effect(() => { placesGroupIds = groupedPlaces.map(({ id }) => id); }); -{#if hasPlaces} +{#if places.length > 0} {#if placesGroupOption === PlacesGroupBy.None} diff --git a/web/src/lib/components/shared-components/change-location.svelte b/web/src/lib/components/shared-components/change-location.svelte index 23fd00190e..0102e34977 100644 --- a/web/src/lib/components/shared-components/change-location.svelte +++ b/web/src/lib/components/shared-components/change-location.svelte @@ -27,7 +27,7 @@ let { asset = undefined, point: initialPoint, onClose }: Props = $props(); let places: PlacesResponseDto[] = $state([]); - let suggestedPlaces: PlacesResponseDto[] = $state([]); + let suggestedPlaces: PlacesResponseDto[] = $derived(places.slice(0, 5)); let searchWord: string = $state(''); let latestSearchTimeout: number; let showLoadingSpinner = $state(false); @@ -52,9 +52,6 @@ }); $effect(() => { - if (places) { - suggestedPlaces = places.slice(0, 5); - } if (searchWord === '') { suggestedPlaces = []; } diff --git a/web/src/lib/components/shared-components/context-menu/context-menu.svelte b/web/src/lib/components/shared-components/context-menu/context-menu.svelte index f63cbd0621..7bf9ba58b3 100644 --- a/web/src/lib/components/shared-components/context-menu/context-menu.svelte +++ b/web/src/lib/components/shared-components/context-menu/context-menu.svelte @@ -33,37 +33,36 @@ children, }: Props = $props(); - let left: number = $state(0); - let top: number = $state(0); + const swap = (direction: string) => (direction === 'left' ? 'right' : 'left'); + + const layoutDirection = $derived(languageManager.rtl ? swap(direction) : direction); + const position = $derived.by(() => { + if (!menuElement) { + return { left: 0, top: 0 }; + } + + const rect = menuElement.getBoundingClientRect(); + const directionWidth = layoutDirection === 'left' ? rect.width : 0; + const menuHeight = Math.min(menuElement.clientHeight, height) || 0; + + const left = Math.max(8, Math.min(window.innerWidth - rect.width, x - directionWidth)); + const top = Math.max(8, Math.min(window.innerHeight - menuHeight, y)); + + return { left, top }; + }); // We need to bind clientHeight since the bounding box may return a height // of zero when starting the 'slide' animation. let height: number = $state(0); let isTransitioned = $state(false); - - $effect(() => { - if (menuElement) { - let layoutDirection = direction; - if (languageManager.rtl) { - layoutDirection = direction === 'left' ? 'right' : 'left'; - } - - const rect = menuElement.getBoundingClientRect(); - const directionWidth = layoutDirection === 'left' ? rect.width : 0; - const menuHeight = Math.min(menuElement.clientHeight, height) || 0; - - left = Math.max(8, Math.min(window.innerWidth - rect.width, x - directionWidth)); - top = Math.max(8, Math.min(window.innerHeight - menuHeight, y)); - } - });
{ diff --git a/web/src/lib/components/shared-components/search-bar/search-camera-section.svelte b/web/src/lib/components/shared-components/search-bar/search-camera-section.svelte index 1d451eacc4..ac158aa8a3 100644 --- a/web/src/lib/components/shared-components/search-bar/search-camera-section.svelte +++ b/web/src/lib/components/shared-components/search-bar/search-camera-section.svelte @@ -64,10 +64,11 @@ } } - let makeFilter = $derived(filters.make); - let modelFilter = $derived(filters.model); - let lensModelFilter = $derived(filters.lensModel); + const makeFilter = $derived(filters.make); + const modelFilter = $derived(filters.model); + const lensModelFilter = $derived(filters.lensModel); + // TODO replace by async $derived, at the latest when it's in stable https://svelte.dev/docs/svelte/await-expressions $effect(() => { handlePromiseError(updateMakes()); }); diff --git a/web/src/lib/components/shared-components/search-bar/search-location-section.svelte b/web/src/lib/components/shared-components/search-bar/search-location-section.svelte index cba5e105af..37a4a3ca9b 100644 --- a/web/src/lib/components/shared-components/search-bar/search-location-section.svelte +++ b/web/src/lib/components/shared-components/search-bar/search-location-section.svelte @@ -7,11 +7,10 @@
diff --git a/web/src/lib/stores/ocr.svelte.ts b/web/src/lib/stores/ocr.svelte.ts index 4922f630ec..f9862b1edc 100644 --- a/web/src/lib/stores/ocr.svelte.ts +++ b/web/src/lib/stores/ocr.svelte.ts @@ -19,21 +19,23 @@ export type OcrBoundingBox = { class OcrManager { #data = $state([]); showOverlay = $state(false); - hasOcrData = $state(false); + #hasOcrData = $derived(this.#data.length > 0); get data() { return this.#data; } + get hasOcrData() { + return this.#hasOcrData; + } + async getAssetOcr(id: string) { this.#data = await getAssetOcr({ id }); - this.hasOcrData = this.#data.length > 0; } clear() { this.#data = []; this.showOverlay = false; - this.hasOcrData = false; } toggleOcrBoundingBox() { diff --git a/web/src/routes/(user)/+layout.svelte b/web/src/routes/(user)/+layout.svelte index ea10c45444..e6e349fe91 100644 --- a/web/src/routes/(user)/+layout.svelte +++ b/web/src/routes/(user)/+layout.svelte @@ -1,8 +1,6 @@ diff --git a/web/src/routes/(user)/search/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/search/[[photos=photos]]/[[assetId=id]]/+page.svelte index 97964344ef..b58210187b 100644 --- a/web/src/routes/(user)/search/[[photos=photos]]/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/search/[[photos=photos]]/[[assetId=id]]/+page.svelte @@ -44,7 +44,7 @@ } from '@immich/sdk'; import { Icon, IconButton, LoadingSpinner } from '@immich/ui'; import { mdiArrowLeft, mdiDotsVertical, mdiImageOffOutline, mdiPlus, mdiSelectAll } from '@mdi/js'; - import { tick } from 'svelte'; + import { tick, untrack } from 'svelte'; import { t } from 'svelte-i18n'; let { isViewing: showAssetViewer } = assetViewingStore; @@ -71,11 +71,10 @@ let terms = $derived(searchQuery ? JSON.parse(searchQuery) : {}); $effect(() => { + // we want this to *only* be reactive on `terms` // eslint-disable-next-line @typescript-eslint/no-unused-expressions terms; - setTimeout(() => { - handlePromiseError(onSearchQueryUpdate()); - }); + untrack(() => handlePromiseError(onSearchQueryUpdate())); }); const onEscape = () => { diff --git a/web/src/routes/+layout.svelte b/web/src/routes/+layout.svelte index 80d4da1252..379b6b00d6 100644 --- a/web/src/routes/+layout.svelte +++ b/web/src/routes/+layout.svelte @@ -22,7 +22,6 @@ import { modalManager, setTranslations } from '@immich/ui'; import { onMount, type Snippet } from 'svelte'; import { t } from 'svelte-i18n'; - import { run } from 'svelte/legacy'; import '../app.css'; interface Props { @@ -69,7 +68,8 @@ afterNavigate(() => { showNavigationLoadingBar = false; }); - run(() => { + + $effect.pre(() => { if ($user || page.url.pathname.startsWith(AppRoute.MAINTENANCE)) { openWebsocketConnection(); } else { diff --git a/web/src/routes/auth/onboarding/+page.svelte b/web/src/routes/auth/onboarding/+page.svelte index 44cd97637a..ed4235104c 100644 --- a/web/src/routes/auth/onboarding/+page.svelte +++ b/web/src/routes/auth/onboarding/+page.svelte @@ -1,6 +1,6 @@
- +
diff --git a/web/src/routes/admin/users/+page.svelte b/web/src/routes/admin/users/+page.svelte index 307fbe1ba4..85cff25e97 100644 --- a/web/src/routes/admin/users/+page.svelte +++ b/web/src/routes/admin/users/+page.svelte @@ -6,7 +6,7 @@ import { getUserAdminActions, getUserAdminsActions } from '$lib/services/user-admin.service'; import { locale } from '$lib/stores/preferences.store'; import { getByteUnitString } from '$lib/utils/byte-units'; - import { searchUsersAdmin, type UserAdminResponseDto } from '@immich/sdk'; + import { type UserAdminResponseDto } from '@immich/sdk'; import { HStack, Icon } from '@immich/ui'; import { mdiInfinity } from '@mdi/js'; import { t } from 'svelte-i18n'; @@ -18,24 +18,20 @@ let { data }: Props = $props(); - let allUsers: UserAdminResponseDto[] = $derived(data.allUsers); + let allUsers: UserAdminResponseDto[] = $state(data.allUsers); - const refresh = async () => { - allUsers = await searchUsersAdmin({ withDeleted: true }); - }; - - const onUserAdminDeleted = ({ id: userId }: { id: string }) => { - const user = allUsers.find(({ id }) => id === userId); - if (user) { - allUsers = allUsers.filter((user) => user.id !== userId); + const onUpdate = (user: UserAdminResponseDto) => { + const index = allUsers.findIndex(({ id }) => id === user.id); + if (index !== -1) { + allUsers[index] = user; } }; - const UserAdminsActions = $derived(getUserAdminsActions($t)); - - const onUpdate = async () => { - await refresh(); + const onUserAdminDeleted = ({ id: userId }: { id: string }) => { + allUsers = allUsers.filter(({ id }) => id !== userId); }; + + const { Create } = $derived(getUserAdminsActions($t)); {#snippet buttons()} - + {/snippet}
@@ -69,7 +65,7 @@ {#each allUsers as user (user.id)} - {@const UserAdminActions = getUserAdminActions($t, user)} + {@const { View, ContextMenu } = getUserAdminActions($t, user)} - - + + {/each} From 66ae07ee39ea12f7eca23bded287c8c2a7f1da3f Mon Sep 17 00:00:00 2001 From: Alex Date: Tue, 25 Nov 2025 06:58:27 -0600 Subject: [PATCH 21/87] fix: don't get OCR data in shared link (#24152) --- web/src/lib/components/asset-viewer/asset-viewer.svelte | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/web/src/lib/components/asset-viewer/asset-viewer.svelte b/web/src/lib/components/asset-viewer/asset-viewer.svelte index 4e23206659..7570278e51 100644 --- a/web/src/lib/components/asset-viewer/asset-viewer.svelte +++ b/web/src/lib/components/asset-viewer/asset-viewer.svelte @@ -399,7 +399,9 @@ $effect(() => { handlePromiseError(handleGetAllAlbums()); ocrManager.clear(); - handlePromiseError(ocrManager.getAssetOcr(asset.id)); + if (!sharedLink) { + handlePromiseError(ocrManager.getAssetOcr(asset.id)); + } }); From 104fa09f69b6b8c8265953673485cc658beba6d7 Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Tue, 25 Nov 2025 08:19:40 -0500 Subject: [PATCH 22/87] feat: queues (#24142) --- e2e/src/utils.ts | 6 +- mobile/openapi/README.md | 17 +- mobile/openapi/lib/api.dart | 11 +- mobile/openapi/lib/api/deprecated_api.dart | 109 ++++ mobile/openapi/lib/api/jobs_api.dart | 8 +- mobile/openapi/lib/api/queues_api.dart | 308 +++++++++++ mobile/openapi/lib/api_client.dart | 20 +- mobile/openapi/lib/api_helper.dart | 6 + mobile/openapi/lib/model/job_name.dart | 244 ++++++++ mobile/openapi/lib/model/permission.dart | 18 + .../openapi/lib/model/queue_delete_dto.dart | 109 ++++ .../lib/model/queue_job_response_dto.dart | 132 +++++ .../openapi/lib/model/queue_job_status.dart | 97 ++++ .../openapi/lib/model/queue_response_dto.dart | 38 +- .../lib/model/queue_response_legacy_dto.dart | 107 ++++ ..._dto.dart => queue_status_legacy_dto.dart} | 38 +- .../openapi/lib/model/queue_update_dto.dart | 108 ++++ ...o.dart => queues_response_legacy_dto.dart} | 106 ++-- open-api/immich-openapi-specs.json | 519 +++++++++++++++++- open-api/typescript-sdk/src/fetch-client.ts | 204 ++++++- server/src/constants.ts | 2 + server/src/controllers/index.ts | 2 + server/src/controllers/job.controller.ts | 21 +- server/src/controllers/queue.controller.ts | 85 +++ server/src/dtos/queue-legacy.dto.ts | 89 +++ server/src/dtos/queue.dto.ts | 109 ++-- server/src/enum.ts | 22 + server/src/repositories/config.repository.ts | 2 +- server/src/repositories/job.repository.ts | 41 +- server/src/services/queue.service.spec.ts | 132 +++-- server/src/services/queue.service.ts | 74 ++- server/src/types.ts | 5 - .../test/repositories/job.repository.mock.ts | 4 +- server/test/small.factory.ts | 12 + web/src/lib/components/jobs/JobTile.svelte | 4 +- web/src/lib/components/jobs/JobsPanel.svelte | 4 +- web/src/routes/admin/jobs-status/+page.svelte | 10 +- 37 files changed, 2487 insertions(+), 336 deletions(-) create mode 100644 mobile/openapi/lib/api/queues_api.dart create mode 100644 mobile/openapi/lib/model/job_name.dart create mode 100644 mobile/openapi/lib/model/queue_delete_dto.dart create mode 100644 mobile/openapi/lib/model/queue_job_response_dto.dart create mode 100644 mobile/openapi/lib/model/queue_job_status.dart create mode 100644 mobile/openapi/lib/model/queue_response_legacy_dto.dart rename mobile/openapi/lib/model/{queue_status_dto.dart => queue_status_legacy_dto.dart} (63%) create mode 100644 mobile/openapi/lib/model/queue_update_dto.dart rename mobile/openapi/lib/model/{queues_response_dto.dart => queues_response_legacy_dto.dart} (55%) create mode 100644 server/src/controllers/queue.controller.ts create mode 100644 server/src/dtos/queue-legacy.dto.ts diff --git a/e2e/src/utils.ts b/e2e/src/utils.ts index f045ea2efd..15bb112cd8 100644 --- a/e2e/src/utils.ts +++ b/e2e/src/utils.ts @@ -12,7 +12,7 @@ import { PersonCreateDto, QueueCommandDto, QueueName, - QueuesResponseDto, + QueuesResponseLegacyDto, SharedLinkCreateDto, UpdateLibraryDto, UserAdminCreateDto, @@ -564,13 +564,13 @@ export const utils = { await updateConfig({ systemConfigDto: defaultConfig }, { headers: asBearerAuth(accessToken) }); }, - isQueueEmpty: async (accessToken: string, queue: keyof QueuesResponseDto) => { + isQueueEmpty: async (accessToken: string, queue: keyof QueuesResponseLegacyDto) => { const queues = await getQueuesLegacy({ headers: asBearerAuth(accessToken) }); const jobCounts = queues[queue].jobCounts; return !jobCounts.active && !jobCounts.waiting; }, - waitForQueueFinish: (accessToken: string, queue: keyof QueuesResponseDto, ms?: number) => { + waitForQueueFinish: (accessToken: string, queue: keyof QueuesResponseLegacyDto, ms?: number) => { // eslint-disable-next-line no-async-promise-executor return new Promise(async (resolve, reject) => { const timeout = setTimeout(() => reject(new Error('Timed out waiting for queue to empty')), ms || 10_000); diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index 52fbbc8e7b..268c4849c5 100644 --- a/mobile/openapi/README.md +++ b/mobile/openapi/README.md @@ -137,8 +137,10 @@ Class | Method | HTTP request | Description *DeprecatedApi* | [**getAllUserAssetsByDeviceId**](doc//DeprecatedApi.md#getalluserassetsbydeviceid) | **GET** /assets/device/{deviceId} | Retrieve assets by device ID *DeprecatedApi* | [**getDeltaSync**](doc//DeprecatedApi.md#getdeltasync) | **POST** /sync/delta-sync | Get delta sync for user *DeprecatedApi* | [**getFullSyncForUser**](doc//DeprecatedApi.md#getfullsyncforuser) | **POST** /sync/full-sync | Get full sync for user +*DeprecatedApi* | [**getQueuesLegacy**](doc//DeprecatedApi.md#getqueueslegacy) | **GET** /jobs | Retrieve queue counts and status *DeprecatedApi* | [**getRandom**](doc//DeprecatedApi.md#getrandom) | **GET** /assets/random | Get random assets *DeprecatedApi* | [**replaceAsset**](doc//DeprecatedApi.md#replaceasset) | **PUT** /assets/{id}/original | Replace asset +*DeprecatedApi* | [**runQueueCommandLegacy**](doc//DeprecatedApi.md#runqueuecommandlegacy) | **PUT** /jobs/{name} | Run jobs *DownloadApi* | [**downloadArchive**](doc//DownloadApi.md#downloadarchive) | **POST** /download/archive | Download asset archive *DownloadApi* | [**getDownloadInfo**](doc//DownloadApi.md#getdownloadinfo) | **POST** /download/info | Retrieve download information *DuplicatesApi* | [**deleteDuplicate**](doc//DuplicatesApi.md#deleteduplicate) | **DELETE** /duplicates/{id} | Delete a duplicate @@ -198,6 +200,11 @@ Class | Method | HTTP request | Description *PeopleApi* | [**updatePerson**](doc//PeopleApi.md#updateperson) | **PUT** /people/{id} | Update person *PluginsApi* | [**getPlugin**](doc//PluginsApi.md#getplugin) | **GET** /plugins/{id} | Retrieve a plugin *PluginsApi* | [**getPlugins**](doc//PluginsApi.md#getplugins) | **GET** /plugins | List all plugins +*QueuesApi* | [**emptyQueue**](doc//QueuesApi.md#emptyqueue) | **DELETE** /queues/{name}/jobs | Empty a queue +*QueuesApi* | [**getQueue**](doc//QueuesApi.md#getqueue) | **GET** /queues/{name} | Retrieve a queue +*QueuesApi* | [**getQueueJobs**](doc//QueuesApi.md#getqueuejobs) | **GET** /queues/{name}/jobs | Retrieve queue jobs +*QueuesApi* | [**getQueues**](doc//QueuesApi.md#getqueues) | **GET** /queues | List all queues +*QueuesApi* | [**updateQueue**](doc//QueuesApi.md#updatequeue) | **PUT** /queues/{name} | Update a queue *SearchApi* | [**getAssetsByCity**](doc//SearchApi.md#getassetsbycity) | **GET** /search/cities | Retrieve assets by city *SearchApi* | [**getExploreData**](doc//SearchApi.md#getexploredata) | **GET** /search/explore | Retrieve explore data *SearchApi* | [**getSearchSuggestions**](doc//SearchApi.md#getsearchsuggestions) | **GET** /search/suggestions | Retrieve search suggestions @@ -396,6 +403,7 @@ Class | Method | HTTP request | Description - [FoldersUpdate](doc//FoldersUpdate.md) - [ImageFormat](doc//ImageFormat.md) - [JobCreateDto](doc//JobCreateDto.md) + - [JobName](doc//JobName.md) - [JobSettingsDto](doc//JobSettingsDto.md) - [LibraryResponseDto](doc//LibraryResponseDto.md) - [LibraryStatsResponseDto](doc//LibraryStatsResponseDto.md) @@ -465,11 +473,16 @@ Class | Method | HTTP request | Description - [PurchaseUpdate](doc//PurchaseUpdate.md) - [QueueCommand](doc//QueueCommand.md) - [QueueCommandDto](doc//QueueCommandDto.md) + - [QueueDeleteDto](doc//QueueDeleteDto.md) + - [QueueJobResponseDto](doc//QueueJobResponseDto.md) + - [QueueJobStatus](doc//QueueJobStatus.md) - [QueueName](doc//QueueName.md) - [QueueResponseDto](doc//QueueResponseDto.md) + - [QueueResponseLegacyDto](doc//QueueResponseLegacyDto.md) - [QueueStatisticsDto](doc//QueueStatisticsDto.md) - - [QueueStatusDto](doc//QueueStatusDto.md) - - [QueuesResponseDto](doc//QueuesResponseDto.md) + - [QueueStatusLegacyDto](doc//QueueStatusLegacyDto.md) + - [QueueUpdateDto](doc//QueueUpdateDto.md) + - [QueuesResponseLegacyDto](doc//QueuesResponseLegacyDto.md) - [RandomSearchDto](doc//RandomSearchDto.md) - [RatingsResponse](doc//RatingsResponse.md) - [RatingsUpdate](doc//RatingsUpdate.md) diff --git a/mobile/openapi/lib/api.dart b/mobile/openapi/lib/api.dart index a47d9ddf92..21730074aa 100644 --- a/mobile/openapi/lib/api.dart +++ b/mobile/openapi/lib/api.dart @@ -50,6 +50,7 @@ part 'api/notifications_admin_api.dart'; part 'api/partners_api.dart'; part 'api/people_api.dart'; part 'api/plugins_api.dart'; +part 'api/queues_api.dart'; part 'api/search_api.dart'; part 'api/server_api.dart'; part 'api/sessions_api.dart'; @@ -154,6 +155,7 @@ part 'model/folders_response.dart'; part 'model/folders_update.dart'; part 'model/image_format.dart'; part 'model/job_create_dto.dart'; +part 'model/job_name.dart'; part 'model/job_settings_dto.dart'; part 'model/library_response_dto.dart'; part 'model/library_stats_response_dto.dart'; @@ -223,11 +225,16 @@ part 'model/purchase_response.dart'; part 'model/purchase_update.dart'; part 'model/queue_command.dart'; part 'model/queue_command_dto.dart'; +part 'model/queue_delete_dto.dart'; +part 'model/queue_job_response_dto.dart'; +part 'model/queue_job_status.dart'; part 'model/queue_name.dart'; part 'model/queue_response_dto.dart'; +part 'model/queue_response_legacy_dto.dart'; part 'model/queue_statistics_dto.dart'; -part 'model/queue_status_dto.dart'; -part 'model/queues_response_dto.dart'; +part 'model/queue_status_legacy_dto.dart'; +part 'model/queue_update_dto.dart'; +part 'model/queues_response_legacy_dto.dart'; part 'model/random_search_dto.dart'; part 'model/ratings_response.dart'; part 'model/ratings_update.dart'; diff --git a/mobile/openapi/lib/api/deprecated_api.dart b/mobile/openapi/lib/api/deprecated_api.dart index aaf7c074b9..d0d92d804d 100644 --- a/mobile/openapi/lib/api/deprecated_api.dart +++ b/mobile/openapi/lib/api/deprecated_api.dart @@ -248,6 +248,54 @@ class DeprecatedApi { return null; } + /// Retrieve queue counts and status + /// + /// Retrieve the counts of the current queue, as well as the current status. + /// + /// Note: This method returns the HTTP [Response]. + Future getQueuesLegacyWithHttpInfo() async { + // ignore: prefer_const_declarations + final apiPath = r'/jobs'; + + // ignore: prefer_final_locals + Object? postBody; + + final queryParams = []; + final headerParams = {}; + final formParams = {}; + + const contentTypes = []; + + + return apiClient.invokeAPI( + apiPath, + 'GET', + queryParams, + postBody, + headerParams, + formParams, + contentTypes.isEmpty ? null : contentTypes.first, + ); + } + + /// Retrieve queue counts and status + /// + /// Retrieve the counts of the current queue, as well as the current status. + Future getQueuesLegacy() async { + final response = await getQueuesLegacyWithHttpInfo(); + if (response.statusCode >= HttpStatus.badRequest) { + throw ApiException(response.statusCode, await _decodeBodyBytes(response)); + } + // When a remote server returns no body with a status of 204, we shall not decode it. + // At the time of writing this, `dart:convert` will throw an "Unexpected end of input" + // FormatException when trying to decode an empty string. + if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) { + return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'QueuesResponseLegacyDto',) as QueuesResponseLegacyDto; + + } + return null; + } + /// Get random assets /// /// Retrieve a specified number of random assets for the authenticated user. @@ -444,4 +492,65 @@ class DeprecatedApi { } return null; } + + /// Run jobs + /// + /// Queue all assets for a specific job type. Defaults to only queueing assets that have not yet been processed, but the force command can be used to re-process all assets. + /// + /// Note: This method returns the HTTP [Response]. + /// + /// Parameters: + /// + /// * [QueueName] name (required): + /// + /// * [QueueCommandDto] queueCommandDto (required): + Future runQueueCommandLegacyWithHttpInfo(QueueName name, QueueCommandDto queueCommandDto,) async { + // ignore: prefer_const_declarations + final apiPath = r'/jobs/{name}' + .replaceAll('{name}', name.toString()); + + // ignore: prefer_final_locals + Object? postBody = queueCommandDto; + + final queryParams = []; + final headerParams = {}; + final formParams = {}; + + const contentTypes = ['application/json']; + + + return apiClient.invokeAPI( + apiPath, + 'PUT', + queryParams, + postBody, + headerParams, + formParams, + contentTypes.isEmpty ? null : contentTypes.first, + ); + } + + /// Run jobs + /// + /// Queue all assets for a specific job type. Defaults to only queueing assets that have not yet been processed, but the force command can be used to re-process all assets. + /// + /// Parameters: + /// + /// * [QueueName] name (required): + /// + /// * [QueueCommandDto] queueCommandDto (required): + Future runQueueCommandLegacy(QueueName name, QueueCommandDto queueCommandDto,) async { + final response = await runQueueCommandLegacyWithHttpInfo(name, queueCommandDto,); + if (response.statusCode >= HttpStatus.badRequest) { + throw ApiException(response.statusCode, await _decodeBodyBytes(response)); + } + // When a remote server returns no body with a status of 204, we shall not decode it. + // At the time of writing this, `dart:convert` will throw an "Unexpected end of input" + // FormatException when trying to decode an empty string. + if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) { + return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'QueueResponseLegacyDto',) as QueueResponseLegacyDto; + + } + return null; + } } diff --git a/mobile/openapi/lib/api/jobs_api.dart b/mobile/openapi/lib/api/jobs_api.dart index 906dce6d37..9dda59a883 100644 --- a/mobile/openapi/lib/api/jobs_api.dart +++ b/mobile/openapi/lib/api/jobs_api.dart @@ -97,7 +97,7 @@ class JobsApi { /// Retrieve queue counts and status /// /// Retrieve the counts of the current queue, as well as the current status. - Future getQueuesLegacy() async { + Future getQueuesLegacy() async { final response = await getQueuesLegacyWithHttpInfo(); if (response.statusCode >= HttpStatus.badRequest) { throw ApiException(response.statusCode, await _decodeBodyBytes(response)); @@ -106,7 +106,7 @@ class JobsApi { // At the time of writing this, `dart:convert` will throw an "Unexpected end of input" // FormatException when trying to decode an empty string. if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) { - return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'QueuesResponseDto',) as QueuesResponseDto; + return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'QueuesResponseLegacyDto',) as QueuesResponseLegacyDto; } return null; @@ -158,7 +158,7 @@ class JobsApi { /// * [QueueName] name (required): /// /// * [QueueCommandDto] queueCommandDto (required): - Future runQueueCommandLegacy(QueueName name, QueueCommandDto queueCommandDto,) async { + Future runQueueCommandLegacy(QueueName name, QueueCommandDto queueCommandDto,) async { final response = await runQueueCommandLegacyWithHttpInfo(name, queueCommandDto,); if (response.statusCode >= HttpStatus.badRequest) { throw ApiException(response.statusCode, await _decodeBodyBytes(response)); @@ -167,7 +167,7 @@ class JobsApi { // At the time of writing this, `dart:convert` will throw an "Unexpected end of input" // FormatException when trying to decode an empty string. if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) { - return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'QueueResponseDto',) as QueueResponseDto; + return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'QueueResponseLegacyDto',) as QueueResponseLegacyDto; } return null; diff --git a/mobile/openapi/lib/api/queues_api.dart b/mobile/openapi/lib/api/queues_api.dart new file mode 100644 index 0000000000..50575ed706 --- /dev/null +++ b/mobile/openapi/lib/api/queues_api.dart @@ -0,0 +1,308 @@ +// +// 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 QueuesApi { + QueuesApi([ApiClient? apiClient]) : apiClient = apiClient ?? defaultApiClient; + + final ApiClient apiClient; + + /// Empty a queue + /// + /// Removes all jobs from the specified queue. + /// + /// Note: This method returns the HTTP [Response]. + /// + /// Parameters: + /// + /// * [QueueName] name (required): + /// + /// * [QueueDeleteDto] queueDeleteDto (required): + Future emptyQueueWithHttpInfo(QueueName name, QueueDeleteDto queueDeleteDto,) async { + // ignore: prefer_const_declarations + final apiPath = r'/queues/{name}/jobs' + .replaceAll('{name}', name.toString()); + + // ignore: prefer_final_locals + Object? postBody = queueDeleteDto; + + final queryParams = []; + final headerParams = {}; + final formParams = {}; + + const contentTypes = ['application/json']; + + + return apiClient.invokeAPI( + apiPath, + 'DELETE', + queryParams, + postBody, + headerParams, + formParams, + contentTypes.isEmpty ? null : contentTypes.first, + ); + } + + /// Empty a queue + /// + /// Removes all jobs from the specified queue. + /// + /// Parameters: + /// + /// * [QueueName] name (required): + /// + /// * [QueueDeleteDto] queueDeleteDto (required): + Future emptyQueue(QueueName name, QueueDeleteDto queueDeleteDto,) async { + final response = await emptyQueueWithHttpInfo(name, queueDeleteDto,); + if (response.statusCode >= HttpStatus.badRequest) { + throw ApiException(response.statusCode, await _decodeBodyBytes(response)); + } + } + + /// Retrieve a queue + /// + /// Retrieves a specific queue by its name. + /// + /// Note: This method returns the HTTP [Response]. + /// + /// Parameters: + /// + /// * [QueueName] name (required): + Future getQueueWithHttpInfo(QueueName name,) async { + // ignore: prefer_const_declarations + final apiPath = r'/queues/{name}' + .replaceAll('{name}', name.toString()); + + // ignore: prefer_final_locals + Object? postBody; + + final queryParams = []; + final headerParams = {}; + final formParams = {}; + + const contentTypes = []; + + + return apiClient.invokeAPI( + apiPath, + 'GET', + queryParams, + postBody, + headerParams, + formParams, + contentTypes.isEmpty ? null : contentTypes.first, + ); + } + + /// Retrieve a queue + /// + /// Retrieves a specific queue by its name. + /// + /// Parameters: + /// + /// * [QueueName] name (required): + Future getQueue(QueueName name,) async { + final response = await getQueueWithHttpInfo(name,); + if (response.statusCode >= HttpStatus.badRequest) { + throw ApiException(response.statusCode, await _decodeBodyBytes(response)); + } + // When a remote server returns no body with a status of 204, we shall not decode it. + // At the time of writing this, `dart:convert` will throw an "Unexpected end of input" + // FormatException when trying to decode an empty string. + if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) { + return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'QueueResponseDto',) as QueueResponseDto; + + } + return null; + } + + /// Retrieve queue jobs + /// + /// Retrieves a list of queue jobs from the specified queue. + /// + /// Note: This method returns the HTTP [Response]. + /// + /// Parameters: + /// + /// * [QueueName] name (required): + /// + /// * [List] status: + Future getQueueJobsWithHttpInfo(QueueName name, { List? status, }) async { + // ignore: prefer_const_declarations + final apiPath = r'/queues/{name}/jobs' + .replaceAll('{name}', name.toString()); + + // ignore: prefer_final_locals + Object? postBody; + + final queryParams = []; + final headerParams = {}; + final formParams = {}; + + if (status != null) { + queryParams.addAll(_queryParams('multi', 'status', status)); + } + + const contentTypes = []; + + + return apiClient.invokeAPI( + apiPath, + 'GET', + queryParams, + postBody, + headerParams, + formParams, + contentTypes.isEmpty ? null : contentTypes.first, + ); + } + + /// Retrieve queue jobs + /// + /// Retrieves a list of queue jobs from the specified queue. + /// + /// Parameters: + /// + /// * [QueueName] name (required): + /// + /// * [List] status: + Future?> getQueueJobs(QueueName name, { List? status, }) async { + final response = await getQueueJobsWithHttpInfo(name, status: status, ); + if (response.statusCode >= HttpStatus.badRequest) { + throw ApiException(response.statusCode, await _decodeBodyBytes(response)); + } + // When a remote server returns no body with a status of 204, we shall not decode it. + // At the time of writing this, `dart:convert` will throw an "Unexpected end of input" + // FormatException when trying to decode an empty string. + if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) { + final responseBody = await _decodeBodyBytes(response); + return (await apiClient.deserializeAsync(responseBody, 'List') as List) + .cast() + .toList(growable: false); + + } + return null; + } + + /// List all queues + /// + /// Retrieves a list of queues. + /// + /// Note: This method returns the HTTP [Response]. + Future getQueuesWithHttpInfo() async { + // ignore: prefer_const_declarations + final apiPath = r'/queues'; + + // ignore: prefer_final_locals + Object? postBody; + + final queryParams = []; + final headerParams = {}; + final formParams = {}; + + const contentTypes = []; + + + return apiClient.invokeAPI( + apiPath, + 'GET', + queryParams, + postBody, + headerParams, + formParams, + contentTypes.isEmpty ? null : contentTypes.first, + ); + } + + /// List all queues + /// + /// Retrieves a list of queues. + Future?> getQueues() async { + final response = await getQueuesWithHttpInfo(); + if (response.statusCode >= HttpStatus.badRequest) { + throw ApiException(response.statusCode, await _decodeBodyBytes(response)); + } + // When a remote server returns no body with a status of 204, we shall not decode it. + // At the time of writing this, `dart:convert` will throw an "Unexpected end of input" + // FormatException when trying to decode an empty string. + if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) { + final responseBody = await _decodeBodyBytes(response); + return (await apiClient.deserializeAsync(responseBody, 'List') as List) + .cast() + .toList(growable: false); + + } + return null; + } + + /// Update a queue + /// + /// Change the paused status of a specific queue. + /// + /// Note: This method returns the HTTP [Response]. + /// + /// Parameters: + /// + /// * [QueueName] name (required): + /// + /// * [QueueUpdateDto] queueUpdateDto (required): + Future updateQueueWithHttpInfo(QueueName name, QueueUpdateDto queueUpdateDto,) async { + // ignore: prefer_const_declarations + final apiPath = r'/queues/{name}' + .replaceAll('{name}', name.toString()); + + // ignore: prefer_final_locals + Object? postBody = queueUpdateDto; + + final queryParams = []; + final headerParams = {}; + final formParams = {}; + + const contentTypes = ['application/json']; + + + return apiClient.invokeAPI( + apiPath, + 'PUT', + queryParams, + postBody, + headerParams, + formParams, + contentTypes.isEmpty ? null : contentTypes.first, + ); + } + + /// Update a queue + /// + /// Change the paused status of a specific queue. + /// + /// Parameters: + /// + /// * [QueueName] name (required): + /// + /// * [QueueUpdateDto] queueUpdateDto (required): + Future updateQueue(QueueName name, QueueUpdateDto queueUpdateDto,) async { + final response = await updateQueueWithHttpInfo(name, queueUpdateDto,); + if (response.statusCode >= HttpStatus.badRequest) { + throw ApiException(response.statusCode, await _decodeBodyBytes(response)); + } + // When a remote server returns no body with a status of 204, we shall not decode it. + // At the time of writing this, `dart:convert` will throw an "Unexpected end of input" + // FormatException when trying to decode an empty string. + if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) { + return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'QueueResponseDto',) as QueueResponseDto; + + } + return null; + } +} diff --git a/mobile/openapi/lib/api_client.dart b/mobile/openapi/lib/api_client.dart index c0dcf542ef..041be67015 100644 --- a/mobile/openapi/lib/api_client.dart +++ b/mobile/openapi/lib/api_client.dart @@ -358,6 +358,8 @@ class ApiClient { return ImageFormatTypeTransformer().decode(value); case 'JobCreateDto': return JobCreateDto.fromJson(value); + case 'JobName': + return JobNameTypeTransformer().decode(value); case 'JobSettingsDto': return JobSettingsDto.fromJson(value); case 'LibraryResponseDto': @@ -496,16 +498,26 @@ class ApiClient { return QueueCommandTypeTransformer().decode(value); case 'QueueCommandDto': return QueueCommandDto.fromJson(value); + case 'QueueDeleteDto': + return QueueDeleteDto.fromJson(value); + case 'QueueJobResponseDto': + return QueueJobResponseDto.fromJson(value); + case 'QueueJobStatus': + return QueueJobStatusTypeTransformer().decode(value); case 'QueueName': return QueueNameTypeTransformer().decode(value); case 'QueueResponseDto': return QueueResponseDto.fromJson(value); + case 'QueueResponseLegacyDto': + return QueueResponseLegacyDto.fromJson(value); case 'QueueStatisticsDto': return QueueStatisticsDto.fromJson(value); - case 'QueueStatusDto': - return QueueStatusDto.fromJson(value); - case 'QueuesResponseDto': - return QueuesResponseDto.fromJson(value); + case 'QueueStatusLegacyDto': + return QueueStatusLegacyDto.fromJson(value); + case 'QueueUpdateDto': + return QueueUpdateDto.fromJson(value); + case 'QueuesResponseLegacyDto': + return QueuesResponseLegacyDto.fromJson(value); case 'RandomSearchDto': return RandomSearchDto.fromJson(value); case 'RatingsResponse': diff --git a/mobile/openapi/lib/api_helper.dart b/mobile/openapi/lib/api_helper.dart index e6d39d5eb7..2c97eeb314 100644 --- a/mobile/openapi/lib/api_helper.dart +++ b/mobile/openapi/lib/api_helper.dart @@ -94,6 +94,9 @@ String parameterToString(dynamic value) { if (value is ImageFormat) { return ImageFormatTypeTransformer().encode(value).toString(); } + if (value is JobName) { + return JobNameTypeTransformer().encode(value).toString(); + } if (value is LogLevel) { return LogLevelTypeTransformer().encode(value).toString(); } @@ -133,6 +136,9 @@ String parameterToString(dynamic value) { if (value is QueueCommand) { return QueueCommandTypeTransformer().encode(value).toString(); } + if (value is QueueJobStatus) { + return QueueJobStatusTypeTransformer().encode(value).toString(); + } if (value is QueueName) { return QueueNameTypeTransformer().encode(value).toString(); } diff --git a/mobile/openapi/lib/model/job_name.dart b/mobile/openapi/lib/model/job_name.dart new file mode 100644 index 0000000000..038a17a8e6 --- /dev/null +++ b/mobile/openapi/lib/model/job_name.dart @@ -0,0 +1,244 @@ +// +// 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 JobName { + /// Instantiate a new enum with the provided [value]. + const JobName._(this.value); + + /// The underlying value of this enum member. + final String value; + + @override + String toString() => value; + + String toJson() => value; + + static const assetDelete = JobName._(r'AssetDelete'); + static const assetDeleteCheck = JobName._(r'AssetDeleteCheck'); + static const assetDetectFacesQueueAll = JobName._(r'AssetDetectFacesQueueAll'); + static const assetDetectFaces = JobName._(r'AssetDetectFaces'); + static const assetDetectDuplicatesQueueAll = JobName._(r'AssetDetectDuplicatesQueueAll'); + static const assetDetectDuplicates = JobName._(r'AssetDetectDuplicates'); + static const assetEncodeVideoQueueAll = JobName._(r'AssetEncodeVideoQueueAll'); + static const assetEncodeVideo = JobName._(r'AssetEncodeVideo'); + static const assetEmptyTrash = JobName._(r'AssetEmptyTrash'); + static const assetExtractMetadataQueueAll = JobName._(r'AssetExtractMetadataQueueAll'); + static const assetExtractMetadata = JobName._(r'AssetExtractMetadata'); + static const assetFileMigration = JobName._(r'AssetFileMigration'); + static const assetGenerateThumbnailsQueueAll = JobName._(r'AssetGenerateThumbnailsQueueAll'); + static const assetGenerateThumbnails = JobName._(r'AssetGenerateThumbnails'); + static const auditLogCleanup = JobName._(r'AuditLogCleanup'); + static const auditTableCleanup = JobName._(r'AuditTableCleanup'); + static const databaseBackup = JobName._(r'DatabaseBackup'); + static const facialRecognitionQueueAll = JobName._(r'FacialRecognitionQueueAll'); + static const facialRecognition = JobName._(r'FacialRecognition'); + static const fileDelete = JobName._(r'FileDelete'); + static const fileMigrationQueueAll = JobName._(r'FileMigrationQueueAll'); + static const libraryDeleteCheck = JobName._(r'LibraryDeleteCheck'); + static const libraryDelete = JobName._(r'LibraryDelete'); + static const libraryRemoveAsset = JobName._(r'LibraryRemoveAsset'); + static const libraryScanAssetsQueueAll = JobName._(r'LibraryScanAssetsQueueAll'); + static const librarySyncAssets = JobName._(r'LibrarySyncAssets'); + static const librarySyncFilesQueueAll = JobName._(r'LibrarySyncFilesQueueAll'); + static const librarySyncFiles = JobName._(r'LibrarySyncFiles'); + static const libraryScanQueueAll = JobName._(r'LibraryScanQueueAll'); + static const memoryCleanup = JobName._(r'MemoryCleanup'); + static const memoryGenerate = JobName._(r'MemoryGenerate'); + static const notificationsCleanup = JobName._(r'NotificationsCleanup'); + static const notifyUserSignup = JobName._(r'NotifyUserSignup'); + static const notifyAlbumInvite = JobName._(r'NotifyAlbumInvite'); + static const notifyAlbumUpdate = JobName._(r'NotifyAlbumUpdate'); + static const userDelete = JobName._(r'UserDelete'); + static const userDeleteCheck = JobName._(r'UserDeleteCheck'); + static const userSyncUsage = JobName._(r'UserSyncUsage'); + static const personCleanup = JobName._(r'PersonCleanup'); + static const personFileMigration = JobName._(r'PersonFileMigration'); + static const personGenerateThumbnail = JobName._(r'PersonGenerateThumbnail'); + static const sessionCleanup = JobName._(r'SessionCleanup'); + static const sendMail = JobName._(r'SendMail'); + static const sidecarQueueAll = JobName._(r'SidecarQueueAll'); + static const sidecarCheck = JobName._(r'SidecarCheck'); + static const sidecarWrite = JobName._(r'SidecarWrite'); + static const smartSearchQueueAll = JobName._(r'SmartSearchQueueAll'); + static const smartSearch = JobName._(r'SmartSearch'); + static const storageTemplateMigration = JobName._(r'StorageTemplateMigration'); + static const storageTemplateMigrationSingle = JobName._(r'StorageTemplateMigrationSingle'); + static const tagCleanup = JobName._(r'TagCleanup'); + static const versionCheck = JobName._(r'VersionCheck'); + static const ocrQueueAll = JobName._(r'OcrQueueAll'); + static const ocr = JobName._(r'Ocr'); + static const workflowRun = JobName._(r'WorkflowRun'); + + /// List of all possible values in this [enum][JobName]. + static const values = [ + assetDelete, + assetDeleteCheck, + assetDetectFacesQueueAll, + assetDetectFaces, + assetDetectDuplicatesQueueAll, + assetDetectDuplicates, + assetEncodeVideoQueueAll, + assetEncodeVideo, + assetEmptyTrash, + assetExtractMetadataQueueAll, + assetExtractMetadata, + assetFileMigration, + assetGenerateThumbnailsQueueAll, + assetGenerateThumbnails, + auditLogCleanup, + auditTableCleanup, + databaseBackup, + facialRecognitionQueueAll, + facialRecognition, + fileDelete, + fileMigrationQueueAll, + libraryDeleteCheck, + libraryDelete, + libraryRemoveAsset, + libraryScanAssetsQueueAll, + librarySyncAssets, + librarySyncFilesQueueAll, + librarySyncFiles, + libraryScanQueueAll, + memoryCleanup, + memoryGenerate, + notificationsCleanup, + notifyUserSignup, + notifyAlbumInvite, + notifyAlbumUpdate, + userDelete, + userDeleteCheck, + userSyncUsage, + personCleanup, + personFileMigration, + personGenerateThumbnail, + sessionCleanup, + sendMail, + sidecarQueueAll, + sidecarCheck, + sidecarWrite, + smartSearchQueueAll, + smartSearch, + storageTemplateMigration, + storageTemplateMigrationSingle, + tagCleanup, + versionCheck, + ocrQueueAll, + ocr, + workflowRun, + ]; + + static JobName? fromJson(dynamic value) => JobNameTypeTransformer().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 = JobName.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } +} + +/// Transformation class that can [encode] an instance of [JobName] to String, +/// and [decode] dynamic data back to [JobName]. +class JobNameTypeTransformer { + factory JobNameTypeTransformer() => _instance ??= const JobNameTypeTransformer._(); + + const JobNameTypeTransformer._(); + + String encode(JobName data) => data.value; + + /// Decodes a [dynamic value][data] to a JobName. + /// + /// 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. + JobName? decode(dynamic data, {bool allowNull = true}) { + if (data != null) { + switch (data) { + case r'AssetDelete': return JobName.assetDelete; + case r'AssetDeleteCheck': return JobName.assetDeleteCheck; + case r'AssetDetectFacesQueueAll': return JobName.assetDetectFacesQueueAll; + case r'AssetDetectFaces': return JobName.assetDetectFaces; + case r'AssetDetectDuplicatesQueueAll': return JobName.assetDetectDuplicatesQueueAll; + case r'AssetDetectDuplicates': return JobName.assetDetectDuplicates; + case r'AssetEncodeVideoQueueAll': return JobName.assetEncodeVideoQueueAll; + case r'AssetEncodeVideo': return JobName.assetEncodeVideo; + case r'AssetEmptyTrash': return JobName.assetEmptyTrash; + case r'AssetExtractMetadataQueueAll': return JobName.assetExtractMetadataQueueAll; + case r'AssetExtractMetadata': return JobName.assetExtractMetadata; + case r'AssetFileMigration': return JobName.assetFileMigration; + case r'AssetGenerateThumbnailsQueueAll': return JobName.assetGenerateThumbnailsQueueAll; + case r'AssetGenerateThumbnails': return JobName.assetGenerateThumbnails; + case r'AuditLogCleanup': return JobName.auditLogCleanup; + case r'AuditTableCleanup': return JobName.auditTableCleanup; + case r'DatabaseBackup': return JobName.databaseBackup; + case r'FacialRecognitionQueueAll': return JobName.facialRecognitionQueueAll; + case r'FacialRecognition': return JobName.facialRecognition; + case r'FileDelete': return JobName.fileDelete; + case r'FileMigrationQueueAll': return JobName.fileMigrationQueueAll; + case r'LibraryDeleteCheck': return JobName.libraryDeleteCheck; + case r'LibraryDelete': return JobName.libraryDelete; + case r'LibraryRemoveAsset': return JobName.libraryRemoveAsset; + case r'LibraryScanAssetsQueueAll': return JobName.libraryScanAssetsQueueAll; + case r'LibrarySyncAssets': return JobName.librarySyncAssets; + case r'LibrarySyncFilesQueueAll': return JobName.librarySyncFilesQueueAll; + case r'LibrarySyncFiles': return JobName.librarySyncFiles; + case r'LibraryScanQueueAll': return JobName.libraryScanQueueAll; + case r'MemoryCleanup': return JobName.memoryCleanup; + case r'MemoryGenerate': return JobName.memoryGenerate; + case r'NotificationsCleanup': return JobName.notificationsCleanup; + case r'NotifyUserSignup': return JobName.notifyUserSignup; + case r'NotifyAlbumInvite': return JobName.notifyAlbumInvite; + case r'NotifyAlbumUpdate': return JobName.notifyAlbumUpdate; + case r'UserDelete': return JobName.userDelete; + case r'UserDeleteCheck': return JobName.userDeleteCheck; + case r'UserSyncUsage': return JobName.userSyncUsage; + case r'PersonCleanup': return JobName.personCleanup; + case r'PersonFileMigration': return JobName.personFileMigration; + case r'PersonGenerateThumbnail': return JobName.personGenerateThumbnail; + case r'SessionCleanup': return JobName.sessionCleanup; + case r'SendMail': return JobName.sendMail; + case r'SidecarQueueAll': return JobName.sidecarQueueAll; + case r'SidecarCheck': return JobName.sidecarCheck; + case r'SidecarWrite': return JobName.sidecarWrite; + case r'SmartSearchQueueAll': return JobName.smartSearchQueueAll; + case r'SmartSearch': return JobName.smartSearch; + case r'StorageTemplateMigration': return JobName.storageTemplateMigration; + case r'StorageTemplateMigrationSingle': return JobName.storageTemplateMigrationSingle; + case r'TagCleanup': return JobName.tagCleanup; + case r'VersionCheck': return JobName.versionCheck; + case r'OcrQueueAll': return JobName.ocrQueueAll; + case r'Ocr': return JobName.ocr; + case r'WorkflowRun': return JobName.workflowRun; + default: + if (!allowNull) { + throw ArgumentError('Unknown enum value to decode: $data'); + } + } + } + return null; + } + + /// Singleton [JobNameTypeTransformer] instance. + static JobNameTypeTransformer? _instance; +} + diff --git a/mobile/openapi/lib/model/permission.dart b/mobile/openapi/lib/model/permission.dart index 0a2f0d1791..3b9a3964b6 100644 --- a/mobile/openapi/lib/model/permission.dart +++ b/mobile/openapi/lib/model/permission.dart @@ -152,6 +152,12 @@ class Permission { static const userProfileImagePeriodRead = Permission._(r'userProfileImage.read'); static const userProfileImagePeriodUpdate = Permission._(r'userProfileImage.update'); static const userProfileImagePeriodDelete = Permission._(r'userProfileImage.delete'); + static const queuePeriodRead = Permission._(r'queue.read'); + static const queuePeriodUpdate = Permission._(r'queue.update'); + static const queueJobPeriodCreate = Permission._(r'queueJob.create'); + static const queueJobPeriodRead = Permission._(r'queueJob.read'); + static const queueJobPeriodUpdate = Permission._(r'queueJob.update'); + static const queueJobPeriodDelete = Permission._(r'queueJob.delete'); static const workflowPeriodCreate = Permission._(r'workflow.create'); static const workflowPeriodRead = Permission._(r'workflow.read'); static const workflowPeriodUpdate = Permission._(r'workflow.update'); @@ -294,6 +300,12 @@ class Permission { userProfileImagePeriodRead, userProfileImagePeriodUpdate, userProfileImagePeriodDelete, + queuePeriodRead, + queuePeriodUpdate, + queueJobPeriodCreate, + queueJobPeriodRead, + queueJobPeriodUpdate, + queueJobPeriodDelete, workflowPeriodCreate, workflowPeriodRead, workflowPeriodUpdate, @@ -471,6 +483,12 @@ class PermissionTypeTransformer { case r'userProfileImage.read': return Permission.userProfileImagePeriodRead; case r'userProfileImage.update': return Permission.userProfileImagePeriodUpdate; case r'userProfileImage.delete': return Permission.userProfileImagePeriodDelete; + case r'queue.read': return Permission.queuePeriodRead; + case r'queue.update': return Permission.queuePeriodUpdate; + case r'queueJob.create': return Permission.queueJobPeriodCreate; + case r'queueJob.read': return Permission.queueJobPeriodRead; + case r'queueJob.update': return Permission.queueJobPeriodUpdate; + case r'queueJob.delete': return Permission.queueJobPeriodDelete; case r'workflow.create': return Permission.workflowPeriodCreate; case r'workflow.read': return Permission.workflowPeriodRead; case r'workflow.update': return Permission.workflowPeriodUpdate; diff --git a/mobile/openapi/lib/model/queue_delete_dto.dart b/mobile/openapi/lib/model/queue_delete_dto.dart new file mode 100644 index 0000000000..d319238f92 --- /dev/null +++ b/mobile/openapi/lib/model/queue_delete_dto.dart @@ -0,0 +1,109 @@ +// +// 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 QueueDeleteDto { + /// Returns a new [QueueDeleteDto] instance. + QueueDeleteDto({ + this.failed, + }); + + /// If true, will also remove failed jobs from the queue. + /// + /// Please note: This property should have been non-nullable! Since the specification file + /// does not include a default value (using the "default:" property), however, the generated + /// source code must fall back to having a nullable type. + /// Consider adding a "default:" property in the specification file to hide this note. + /// + bool? failed; + + @override + bool operator ==(Object other) => identical(this, other) || other is QueueDeleteDto && + other.failed == failed; + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (failed == null ? 0 : failed!.hashCode); + + @override + String toString() => 'QueueDeleteDto[failed=$failed]'; + + Map toJson() { + final json = {}; + if (this.failed != null) { + json[r'failed'] = this.failed; + } else { + // json[r'failed'] = null; + } + return json; + } + + /// Returns a new [QueueDeleteDto] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static QueueDeleteDto? fromJson(dynamic value) { + upgradeDto(value, "QueueDeleteDto"); + if (value is Map) { + final json = value.cast(); + + return QueueDeleteDto( + failed: mapValueOfType(json, r'failed'), + ); + } + 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 = QueueDeleteDto.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 = QueueDeleteDto.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of QueueDeleteDto-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] = QueueDeleteDto.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + }; +} + diff --git a/mobile/openapi/lib/model/queue_job_response_dto.dart b/mobile/openapi/lib/model/queue_job_response_dto.dart new file mode 100644 index 0000000000..1bfaa56195 --- /dev/null +++ b/mobile/openapi/lib/model/queue_job_response_dto.dart @@ -0,0 +1,132 @@ +// +// 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 QueueJobResponseDto { + /// Returns a new [QueueJobResponseDto] instance. + QueueJobResponseDto({ + required this.data, + this.id, + required this.name, + required this.timestamp, + }); + + Object data; + + /// + /// Please note: This property should have been non-nullable! Since the specification file + /// does not include a default value (using the "default:" property), however, the generated + /// source code must fall back to having a nullable type. + /// Consider adding a "default:" property in the specification file to hide this note. + /// + String? id; + + JobName name; + + int timestamp; + + @override + bool operator ==(Object other) => identical(this, other) || other is QueueJobResponseDto && + other.data == data && + other.id == id && + other.name == name && + other.timestamp == timestamp; + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (data.hashCode) + + (id == null ? 0 : id!.hashCode) + + (name.hashCode) + + (timestamp.hashCode); + + @override + String toString() => 'QueueJobResponseDto[data=$data, id=$id, name=$name, timestamp=$timestamp]'; + + Map toJson() { + final json = {}; + json[r'data'] = this.data; + if (this.id != null) { + json[r'id'] = this.id; + } else { + // json[r'id'] = null; + } + json[r'name'] = this.name; + json[r'timestamp'] = this.timestamp; + return json; + } + + /// Returns a new [QueueJobResponseDto] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static QueueJobResponseDto? fromJson(dynamic value) { + upgradeDto(value, "QueueJobResponseDto"); + if (value is Map) { + final json = value.cast(); + + return QueueJobResponseDto( + data: mapValueOfType(json, r'data')!, + id: mapValueOfType(json, r'id'), + name: JobName.fromJson(json[r'name'])!, + timestamp: mapValueOfType(json, r'timestamp')!, + ); + } + 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 = QueueJobResponseDto.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 = QueueJobResponseDto.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of QueueJobResponseDto-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] = QueueJobResponseDto.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + 'data', + 'name', + 'timestamp', + }; +} + diff --git a/mobile/openapi/lib/model/queue_job_status.dart b/mobile/openapi/lib/model/queue_job_status.dart new file mode 100644 index 0000000000..03a1371cc5 --- /dev/null +++ b/mobile/openapi/lib/model/queue_job_status.dart @@ -0,0 +1,97 @@ +// +// 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 QueueJobStatus { + /// Instantiate a new enum with the provided [value]. + const QueueJobStatus._(this.value); + + /// The underlying value of this enum member. + final String value; + + @override + String toString() => value; + + String toJson() => value; + + static const active = QueueJobStatus._(r'active'); + static const failed = QueueJobStatus._(r'failed'); + static const completed = QueueJobStatus._(r'completed'); + static const delayed = QueueJobStatus._(r'delayed'); + static const waiting = QueueJobStatus._(r'waiting'); + static const paused = QueueJobStatus._(r'paused'); + + /// List of all possible values in this [enum][QueueJobStatus]. + static const values = [ + active, + failed, + completed, + delayed, + waiting, + paused, + ]; + + static QueueJobStatus? fromJson(dynamic value) => QueueJobStatusTypeTransformer().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 = QueueJobStatus.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } +} + +/// Transformation class that can [encode] an instance of [QueueJobStatus] to String, +/// and [decode] dynamic data back to [QueueJobStatus]. +class QueueJobStatusTypeTransformer { + factory QueueJobStatusTypeTransformer() => _instance ??= const QueueJobStatusTypeTransformer._(); + + const QueueJobStatusTypeTransformer._(); + + String encode(QueueJobStatus data) => data.value; + + /// Decodes a [dynamic value][data] to a QueueJobStatus. + /// + /// 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. + QueueJobStatus? decode(dynamic data, {bool allowNull = true}) { + if (data != null) { + switch (data) { + case r'active': return QueueJobStatus.active; + case r'failed': return QueueJobStatus.failed; + case r'completed': return QueueJobStatus.completed; + case r'delayed': return QueueJobStatus.delayed; + case r'waiting': return QueueJobStatus.waiting; + case r'paused': return QueueJobStatus.paused; + default: + if (!allowNull) { + throw ArgumentError('Unknown enum value to decode: $data'); + } + } + } + return null; + } + + /// Singleton [QueueJobStatusTypeTransformer] instance. + static QueueJobStatusTypeTransformer? _instance; +} + diff --git a/mobile/openapi/lib/model/queue_response_dto.dart b/mobile/openapi/lib/model/queue_response_dto.dart index b20449f721..c5d4ed8e3d 100644 --- a/mobile/openapi/lib/model/queue_response_dto.dart +++ b/mobile/openapi/lib/model/queue_response_dto.dart @@ -13,32 +13,38 @@ part of openapi.api; class QueueResponseDto { /// Returns a new [QueueResponseDto] instance. QueueResponseDto({ - required this.jobCounts, - required this.queueStatus, + required this.isPaused, + required this.name, + required this.statistics, }); - QueueStatisticsDto jobCounts; + bool isPaused; - QueueStatusDto queueStatus; + QueueName name; + + QueueStatisticsDto statistics; @override bool operator ==(Object other) => identical(this, other) || other is QueueResponseDto && - other.jobCounts == jobCounts && - other.queueStatus == queueStatus; + other.isPaused == isPaused && + other.name == name && + other.statistics == statistics; @override int get hashCode => // ignore: unnecessary_parenthesis - (jobCounts.hashCode) + - (queueStatus.hashCode); + (isPaused.hashCode) + + (name.hashCode) + + (statistics.hashCode); @override - String toString() => 'QueueResponseDto[jobCounts=$jobCounts, queueStatus=$queueStatus]'; + String toString() => 'QueueResponseDto[isPaused=$isPaused, name=$name, statistics=$statistics]'; Map toJson() { final json = {}; - json[r'jobCounts'] = this.jobCounts; - json[r'queueStatus'] = this.queueStatus; + json[r'isPaused'] = this.isPaused; + json[r'name'] = this.name; + json[r'statistics'] = this.statistics; return json; } @@ -51,8 +57,9 @@ class QueueResponseDto { final json = value.cast(); return QueueResponseDto( - jobCounts: QueueStatisticsDto.fromJson(json[r'jobCounts'])!, - queueStatus: QueueStatusDto.fromJson(json[r'queueStatus'])!, + isPaused: mapValueOfType(json, r'isPaused')!, + name: QueueName.fromJson(json[r'name'])!, + statistics: QueueStatisticsDto.fromJson(json[r'statistics'])!, ); } return null; @@ -100,8 +107,9 @@ class QueueResponseDto { /// The list of required keys that must be present in a JSON. static const requiredKeys = { - 'jobCounts', - 'queueStatus', + 'isPaused', + 'name', + 'statistics', }; } diff --git a/mobile/openapi/lib/model/queue_response_legacy_dto.dart b/mobile/openapi/lib/model/queue_response_legacy_dto.dart new file mode 100644 index 0000000000..214b0b31f6 --- /dev/null +++ b/mobile/openapi/lib/model/queue_response_legacy_dto.dart @@ -0,0 +1,107 @@ +// +// 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 QueueResponseLegacyDto { + /// Returns a new [QueueResponseLegacyDto] instance. + QueueResponseLegacyDto({ + required this.jobCounts, + required this.queueStatus, + }); + + QueueStatisticsDto jobCounts; + + QueueStatusLegacyDto queueStatus; + + @override + bool operator ==(Object other) => identical(this, other) || other is QueueResponseLegacyDto && + other.jobCounts == jobCounts && + other.queueStatus == queueStatus; + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (jobCounts.hashCode) + + (queueStatus.hashCode); + + @override + String toString() => 'QueueResponseLegacyDto[jobCounts=$jobCounts, queueStatus=$queueStatus]'; + + Map toJson() { + final json = {}; + json[r'jobCounts'] = this.jobCounts; + json[r'queueStatus'] = this.queueStatus; + return json; + } + + /// Returns a new [QueueResponseLegacyDto] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static QueueResponseLegacyDto? fromJson(dynamic value) { + upgradeDto(value, "QueueResponseLegacyDto"); + if (value is Map) { + final json = value.cast(); + + return QueueResponseLegacyDto( + jobCounts: QueueStatisticsDto.fromJson(json[r'jobCounts'])!, + queueStatus: QueueStatusLegacyDto.fromJson(json[r'queueStatus'])!, + ); + } + 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 = QueueResponseLegacyDto.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 = QueueResponseLegacyDto.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of QueueResponseLegacyDto-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] = QueueResponseLegacyDto.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + 'jobCounts', + 'queueStatus', + }; +} + diff --git a/mobile/openapi/lib/model/queue_status_dto.dart b/mobile/openapi/lib/model/queue_status_legacy_dto.dart similarity index 63% rename from mobile/openapi/lib/model/queue_status_dto.dart rename to mobile/openapi/lib/model/queue_status_legacy_dto.dart index 77591affe2..88c4eac340 100644 --- a/mobile/openapi/lib/model/queue_status_dto.dart +++ b/mobile/openapi/lib/model/queue_status_legacy_dto.dart @@ -10,9 +10,9 @@ part of openapi.api; -class QueueStatusDto { - /// Returns a new [QueueStatusDto] instance. - QueueStatusDto({ +class QueueStatusLegacyDto { + /// Returns a new [QueueStatusLegacyDto] instance. + QueueStatusLegacyDto({ required this.isActive, required this.isPaused, }); @@ -22,7 +22,7 @@ class QueueStatusDto { bool isPaused; @override - bool operator ==(Object other) => identical(this, other) || other is QueueStatusDto && + bool operator ==(Object other) => identical(this, other) || other is QueueStatusLegacyDto && other.isActive == isActive && other.isPaused == isPaused; @@ -33,7 +33,7 @@ class QueueStatusDto { (isPaused.hashCode); @override - String toString() => 'QueueStatusDto[isActive=$isActive, isPaused=$isPaused]'; + String toString() => 'QueueStatusLegacyDto[isActive=$isActive, isPaused=$isPaused]'; Map toJson() { final json = {}; @@ -42,15 +42,15 @@ class QueueStatusDto { return json; } - /// Returns a new [QueueStatusDto] instance and imports its values from + /// Returns a new [QueueStatusLegacyDto] instance and imports its values from /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods - static QueueStatusDto? fromJson(dynamic value) { - upgradeDto(value, "QueueStatusDto"); + static QueueStatusLegacyDto? fromJson(dynamic value) { + upgradeDto(value, "QueueStatusLegacyDto"); if (value is Map) { final json = value.cast(); - return QueueStatusDto( + return QueueStatusLegacyDto( isActive: mapValueOfType(json, r'isActive')!, isPaused: mapValueOfType(json, r'isPaused')!, ); @@ -58,11 +58,11 @@ class QueueStatusDto { return null; } - static List listFromJson(dynamic json, {bool growable = false,}) { - final result = []; + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; if (json is List && json.isNotEmpty) { for (final row in json) { - final value = QueueStatusDto.fromJson(row); + final value = QueueStatusLegacyDto.fromJson(row); if (value != null) { result.add(value); } @@ -71,12 +71,12 @@ class QueueStatusDto { return result.toList(growable: growable); } - static Map mapFromJson(dynamic json) { - final map = {}; + 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 = QueueStatusDto.fromJson(entry.value); + final value = QueueStatusLegacyDto.fromJson(entry.value); if (value != null) { map[entry.key] = value; } @@ -85,14 +85,14 @@ class QueueStatusDto { return map; } - // maps a json object with a list of QueueStatusDto-objects as value to a dart map - static Map> mapListFromJson(dynamic json, {bool growable = false,}) { - final map = >{}; + // maps a json object with a list of QueueStatusLegacyDto-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] = QueueStatusDto.listFromJson(entry.value, growable: growable,); + map[entry.key] = QueueStatusLegacyDto.listFromJson(entry.value, growable: growable,); } } return map; diff --git a/mobile/openapi/lib/model/queue_update_dto.dart b/mobile/openapi/lib/model/queue_update_dto.dart new file mode 100644 index 0000000000..ce89e51878 --- /dev/null +++ b/mobile/openapi/lib/model/queue_update_dto.dart @@ -0,0 +1,108 @@ +// +// 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 QueueUpdateDto { + /// Returns a new [QueueUpdateDto] instance. + QueueUpdateDto({ + this.isPaused, + }); + + /// + /// Please note: This property should have been non-nullable! Since the specification file + /// does not include a default value (using the "default:" property), however, the generated + /// source code must fall back to having a nullable type. + /// Consider adding a "default:" property in the specification file to hide this note. + /// + bool? isPaused; + + @override + bool operator ==(Object other) => identical(this, other) || other is QueueUpdateDto && + other.isPaused == isPaused; + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (isPaused == null ? 0 : isPaused!.hashCode); + + @override + String toString() => 'QueueUpdateDto[isPaused=$isPaused]'; + + Map toJson() { + final json = {}; + if (this.isPaused != null) { + json[r'isPaused'] = this.isPaused; + } else { + // json[r'isPaused'] = null; + } + return json; + } + + /// Returns a new [QueueUpdateDto] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static QueueUpdateDto? fromJson(dynamic value) { + upgradeDto(value, "QueueUpdateDto"); + if (value is Map) { + final json = value.cast(); + + return QueueUpdateDto( + isPaused: mapValueOfType(json, r'isPaused'), + ); + } + 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 = QueueUpdateDto.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 = QueueUpdateDto.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of QueueUpdateDto-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] = QueueUpdateDto.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + }; +} + diff --git a/mobile/openapi/lib/model/queues_response_dto.dart b/mobile/openapi/lib/model/queues_response_legacy_dto.dart similarity index 55% rename from mobile/openapi/lib/model/queues_response_dto.dart rename to mobile/openapi/lib/model/queues_response_legacy_dto.dart index be40a56fb1..4aab6d863b 100644 --- a/mobile/openapi/lib/model/queues_response_dto.dart +++ b/mobile/openapi/lib/model/queues_response_legacy_dto.dart @@ -10,9 +10,9 @@ part of openapi.api; -class QueuesResponseDto { - /// Returns a new [QueuesResponseDto] instance. - QueuesResponseDto({ +class QueuesResponseLegacyDto { + /// Returns a new [QueuesResponseLegacyDto] instance. + QueuesResponseLegacyDto({ required this.backgroundTask, required this.backupDatabase, required this.duplicateDetection, @@ -32,42 +32,42 @@ class QueuesResponseDto { required this.workflow, }); - QueueResponseDto backgroundTask; + QueueResponseLegacyDto backgroundTask; - QueueResponseDto backupDatabase; + QueueResponseLegacyDto backupDatabase; - QueueResponseDto duplicateDetection; + QueueResponseLegacyDto duplicateDetection; - QueueResponseDto faceDetection; + QueueResponseLegacyDto faceDetection; - QueueResponseDto facialRecognition; + QueueResponseLegacyDto facialRecognition; - QueueResponseDto library_; + QueueResponseLegacyDto library_; - QueueResponseDto metadataExtraction; + QueueResponseLegacyDto metadataExtraction; - QueueResponseDto migration; + QueueResponseLegacyDto migration; - QueueResponseDto notifications; + QueueResponseLegacyDto notifications; - QueueResponseDto ocr; + QueueResponseLegacyDto ocr; - QueueResponseDto search; + QueueResponseLegacyDto search; - QueueResponseDto sidecar; + QueueResponseLegacyDto sidecar; - QueueResponseDto smartSearch; + QueueResponseLegacyDto smartSearch; - QueueResponseDto storageTemplateMigration; + QueueResponseLegacyDto storageTemplateMigration; - QueueResponseDto thumbnailGeneration; + QueueResponseLegacyDto thumbnailGeneration; - QueueResponseDto videoConversion; + QueueResponseLegacyDto videoConversion; - QueueResponseDto workflow; + QueueResponseLegacyDto workflow; @override - bool operator ==(Object other) => identical(this, other) || other is QueuesResponseDto && + bool operator ==(Object other) => identical(this, other) || other is QueuesResponseLegacyDto && other.backgroundTask == backgroundTask && other.backupDatabase == backupDatabase && other.duplicateDetection == duplicateDetection && @@ -108,7 +108,7 @@ class QueuesResponseDto { (workflow.hashCode); @override - String toString() => 'QueuesResponseDto[backgroundTask=$backgroundTask, backupDatabase=$backupDatabase, duplicateDetection=$duplicateDetection, faceDetection=$faceDetection, facialRecognition=$facialRecognition, library_=$library_, metadataExtraction=$metadataExtraction, migration=$migration, notifications=$notifications, ocr=$ocr, search=$search, sidecar=$sidecar, smartSearch=$smartSearch, storageTemplateMigration=$storageTemplateMigration, thumbnailGeneration=$thumbnailGeneration, videoConversion=$videoConversion, workflow=$workflow]'; + String toString() => 'QueuesResponseLegacyDto[backgroundTask=$backgroundTask, backupDatabase=$backupDatabase, duplicateDetection=$duplicateDetection, faceDetection=$faceDetection, facialRecognition=$facialRecognition, library_=$library_, metadataExtraction=$metadataExtraction, migration=$migration, notifications=$notifications, ocr=$ocr, search=$search, sidecar=$sidecar, smartSearch=$smartSearch, storageTemplateMigration=$storageTemplateMigration, thumbnailGeneration=$thumbnailGeneration, videoConversion=$videoConversion, workflow=$workflow]'; Map toJson() { final json = {}; @@ -132,42 +132,42 @@ class QueuesResponseDto { return json; } - /// Returns a new [QueuesResponseDto] instance and imports its values from + /// Returns a new [QueuesResponseLegacyDto] instance and imports its values from /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods - static QueuesResponseDto? fromJson(dynamic value) { - upgradeDto(value, "QueuesResponseDto"); + static QueuesResponseLegacyDto? fromJson(dynamic value) { + upgradeDto(value, "QueuesResponseLegacyDto"); if (value is Map) { final json = value.cast(); - return QueuesResponseDto( - backgroundTask: QueueResponseDto.fromJson(json[r'backgroundTask'])!, - backupDatabase: QueueResponseDto.fromJson(json[r'backupDatabase'])!, - duplicateDetection: QueueResponseDto.fromJson(json[r'duplicateDetection'])!, - faceDetection: QueueResponseDto.fromJson(json[r'faceDetection'])!, - facialRecognition: QueueResponseDto.fromJson(json[r'facialRecognition'])!, - library_: QueueResponseDto.fromJson(json[r'library'])!, - metadataExtraction: QueueResponseDto.fromJson(json[r'metadataExtraction'])!, - migration: QueueResponseDto.fromJson(json[r'migration'])!, - notifications: QueueResponseDto.fromJson(json[r'notifications'])!, - ocr: QueueResponseDto.fromJson(json[r'ocr'])!, - search: QueueResponseDto.fromJson(json[r'search'])!, - sidecar: QueueResponseDto.fromJson(json[r'sidecar'])!, - smartSearch: QueueResponseDto.fromJson(json[r'smartSearch'])!, - storageTemplateMigration: QueueResponseDto.fromJson(json[r'storageTemplateMigration'])!, - thumbnailGeneration: QueueResponseDto.fromJson(json[r'thumbnailGeneration'])!, - videoConversion: QueueResponseDto.fromJson(json[r'videoConversion'])!, - workflow: QueueResponseDto.fromJson(json[r'workflow'])!, + return QueuesResponseLegacyDto( + backgroundTask: QueueResponseLegacyDto.fromJson(json[r'backgroundTask'])!, + backupDatabase: QueueResponseLegacyDto.fromJson(json[r'backupDatabase'])!, + duplicateDetection: QueueResponseLegacyDto.fromJson(json[r'duplicateDetection'])!, + faceDetection: QueueResponseLegacyDto.fromJson(json[r'faceDetection'])!, + facialRecognition: QueueResponseLegacyDto.fromJson(json[r'facialRecognition'])!, + library_: QueueResponseLegacyDto.fromJson(json[r'library'])!, + metadataExtraction: QueueResponseLegacyDto.fromJson(json[r'metadataExtraction'])!, + migration: QueueResponseLegacyDto.fromJson(json[r'migration'])!, + notifications: QueueResponseLegacyDto.fromJson(json[r'notifications'])!, + ocr: QueueResponseLegacyDto.fromJson(json[r'ocr'])!, + search: QueueResponseLegacyDto.fromJson(json[r'search'])!, + sidecar: QueueResponseLegacyDto.fromJson(json[r'sidecar'])!, + smartSearch: QueueResponseLegacyDto.fromJson(json[r'smartSearch'])!, + storageTemplateMigration: QueueResponseLegacyDto.fromJson(json[r'storageTemplateMigration'])!, + thumbnailGeneration: QueueResponseLegacyDto.fromJson(json[r'thumbnailGeneration'])!, + videoConversion: QueueResponseLegacyDto.fromJson(json[r'videoConversion'])!, + workflow: QueueResponseLegacyDto.fromJson(json[r'workflow'])!, ); } return null; } - static List listFromJson(dynamic json, {bool growable = false,}) { - final result = []; + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; if (json is List && json.isNotEmpty) { for (final row in json) { - final value = QueuesResponseDto.fromJson(row); + final value = QueuesResponseLegacyDto.fromJson(row); if (value != null) { result.add(value); } @@ -176,12 +176,12 @@ class QueuesResponseDto { return result.toList(growable: growable); } - static Map mapFromJson(dynamic json) { - final map = {}; + 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 = QueuesResponseDto.fromJson(entry.value); + final value = QueuesResponseLegacyDto.fromJson(entry.value); if (value != null) { map[entry.key] = value; } @@ -190,14 +190,14 @@ class QueuesResponseDto { return map; } - // maps a json object with a list of QueuesResponseDto-objects as value to a dart map - static Map> mapListFromJson(dynamic json, {bool growable = false,}) { - final map = >{}; + // maps a json object with a list of QueuesResponseLegacyDto-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] = QueuesResponseDto.listFromJson(entry.value, growable: growable,); + map[entry.key] = QueuesResponseLegacyDto.listFromJson(entry.value, growable: growable,); } } return map; diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index ffaad85906..68af1438cd 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -4929,6 +4929,7 @@ }, "/jobs": { "get": { + "deprecated": true, "description": "Retrieve the counts of the current queue, as well as the current status.", "operationId": "getQueuesLegacy", "parameters": [], @@ -4937,7 +4938,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/QueuesResponseDto" + "$ref": "#/components/schemas/QueuesResponseLegacyDto" } } }, @@ -4957,7 +4958,8 @@ ], "summary": "Retrieve queue counts and status", "tags": [ - "Jobs" + "Jobs", + "Deprecated" ], "x-immich-admin-only": true, "x-immich-history": [ @@ -4972,10 +4974,14 @@ { "version": "v2", "state": "Stable" + }, + { + "version": "v2.4.0", + "state": "Deprecated" } ], "x-immich-permission": "job.read", - "x-immich-state": "Stable" + "x-immich-state": "Deprecated" }, "post": { "description": "Run a specific job. Most jobs are queued automatically, but this endpoint allows for manual creation of a handful of jobs, including various cleanup tasks, as well as creating a new database backup.", @@ -5032,6 +5038,7 @@ }, "/jobs/{name}": { "put": { + "deprecated": true, "description": "Queue all assets for a specific job type. Defaults to only queueing assets that have not yet been processed, but the force command can be used to re-process all assets.", "operationId": "runQueueCommandLegacy", "parameters": [ @@ -5059,7 +5066,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/QueueResponseDto" + "$ref": "#/components/schemas/QueueResponseLegacyDto" } } }, @@ -5079,7 +5086,8 @@ ], "summary": "Run jobs", "tags": [ - "Jobs" + "Jobs", + "Deprecated" ], "x-immich-admin-only": true, "x-immich-history": [ @@ -5094,10 +5102,14 @@ { "version": "v2", "state": "Stable" + }, + { + "version": "v2.4.0", + "state": "Deprecated" } ], "x-immich-permission": "job.create", - "x-immich-state": "Stable" + "x-immich-state": "Deprecated" } }, "/libraries": { @@ -8064,6 +8076,303 @@ "x-immich-state": "Alpha" } }, + "/queues": { + "get": { + "description": "Retrieves a list of queues.", + "operationId": "getQueues", + "parameters": [], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "items": { + "$ref": "#/components/schemas/QueueResponseDto" + }, + "type": "array" + } + } + }, + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "summary": "List all queues", + "tags": [ + "Queues" + ], + "x-immich-admin-only": true, + "x-immich-history": [ + { + "version": "v2.4.0", + "state": "Added" + }, + { + "version": "v2.4.0", + "state": "Alpha" + } + ], + "x-immich-permission": "queue.read", + "x-immich-state": "Alpha" + } + }, + "/queues/{name}": { + "get": { + "description": "Retrieves a specific queue by its name.", + "operationId": "getQueue", + "parameters": [ + { + "name": "name", + "required": true, + "in": "path", + "schema": { + "$ref": "#/components/schemas/QueueName" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/QueueResponseDto" + } + } + }, + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "summary": "Retrieve a queue", + "tags": [ + "Queues" + ], + "x-immich-admin-only": true, + "x-immich-history": [ + { + "version": "v2.4.0", + "state": "Added" + }, + { + "version": "v2.4.0", + "state": "Alpha" + } + ], + "x-immich-permission": "queue.read", + "x-immich-state": "Alpha" + }, + "put": { + "description": "Change the paused status of a specific queue.", + "operationId": "updateQueue", + "parameters": [ + { + "name": "name", + "required": true, + "in": "path", + "schema": { + "$ref": "#/components/schemas/QueueName" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/QueueUpdateDto" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/QueueResponseDto" + } + } + }, + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "summary": "Update a queue", + "tags": [ + "Queues" + ], + "x-immich-admin-only": true, + "x-immich-history": [ + { + "version": "v2.4.0", + "state": "Added" + }, + { + "version": "v2.4.0", + "state": "Alpha" + } + ], + "x-immich-permission": "queue.update", + "x-immich-state": "Alpha" + } + }, + "/queues/{name}/jobs": { + "delete": { + "description": "Removes all jobs from the specified queue.", + "operationId": "emptyQueue", + "parameters": [ + { + "name": "name", + "required": true, + "in": "path", + "schema": { + "$ref": "#/components/schemas/QueueName" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/QueueDeleteDto" + } + } + }, + "required": true + }, + "responses": { + "204": { + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "summary": "Empty a queue", + "tags": [ + "Queues" + ], + "x-immich-admin-only": true, + "x-immich-history": [ + { + "version": "v2.4.0", + "state": "Added" + }, + { + "version": "v2.4.0", + "state": "Alpha" + } + ], + "x-immich-permission": "queueJob.delete", + "x-immich-state": "Alpha" + }, + "get": { + "description": "Retrieves a list of queue jobs from the specified queue.", + "operationId": "getQueueJobs", + "parameters": [ + { + "name": "name", + "required": true, + "in": "path", + "schema": { + "$ref": "#/components/schemas/QueueName" + } + }, + { + "name": "status", + "required": false, + "in": "query", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/QueueJobStatus" + } + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "items": { + "$ref": "#/components/schemas/QueueJobResponseDto" + }, + "type": "array" + } + } + }, + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "summary": "Retrieve queue jobs", + "tags": [ + "Queues" + ], + "x-immich-admin-only": true, + "x-immich-history": [ + { + "version": "v2.4.0", + "state": "Added" + }, + { + "version": "v2.4.0", + "state": "Alpha" + } + ], + "x-immich-permission": "queueJob.read", + "x-immich-state": "Alpha" + } + }, "/search/cities": { "get": { "description": "Retrieve a list of assets with each asset belonging to a different city. This endpoint is used on the places pages to show a single thumbnail for each city the user has assets in.", @@ -14043,6 +14352,10 @@ "name": "Plugins", "description": "A plugin is an installed module that makes filters and actions available for the workflow feature." }, + { + "name": "Queues", + "description": "Queues and background jobs are used for processing tasks asynchronously. Queues can be paused and resumed as needed." + }, { "name": "Search", "description": "Endpoints related to searching assets via text, smart search, optical character recognition (OCR), and other filters like person, album, and other metadata. Search endpoints usually support pagination and sorting." @@ -16291,6 +16604,66 @@ ], "type": "object" }, + "JobName": { + "enum": [ + "AssetDelete", + "AssetDeleteCheck", + "AssetDetectFacesQueueAll", + "AssetDetectFaces", + "AssetDetectDuplicatesQueueAll", + "AssetDetectDuplicates", + "AssetEncodeVideoQueueAll", + "AssetEncodeVideo", + "AssetEmptyTrash", + "AssetExtractMetadataQueueAll", + "AssetExtractMetadata", + "AssetFileMigration", + "AssetGenerateThumbnailsQueueAll", + "AssetGenerateThumbnails", + "AuditLogCleanup", + "AuditTableCleanup", + "DatabaseBackup", + "FacialRecognitionQueueAll", + "FacialRecognition", + "FileDelete", + "FileMigrationQueueAll", + "LibraryDeleteCheck", + "LibraryDelete", + "LibraryRemoveAsset", + "LibraryScanAssetsQueueAll", + "LibrarySyncAssets", + "LibrarySyncFilesQueueAll", + "LibrarySyncFiles", + "LibraryScanQueueAll", + "MemoryCleanup", + "MemoryGenerate", + "NotificationsCleanup", + "NotifyUserSignup", + "NotifyAlbumInvite", + "NotifyAlbumUpdate", + "UserDelete", + "UserDeleteCheck", + "UserSyncUsage", + "PersonCleanup", + "PersonFileMigration", + "PersonGenerateThumbnail", + "SessionCleanup", + "SendMail", + "SidecarQueueAll", + "SidecarCheck", + "SidecarWrite", + "SmartSearchQueueAll", + "SmartSearch", + "StorageTemplateMigration", + "StorageTemplateMigrationSingle", + "TagCleanup", + "VersionCheck", + "OcrQueueAll", + "Ocr", + "WorkflowRun" + ], + "type": "string" + }, "JobSettingsDto": { "properties": { "concurrency": { @@ -17583,6 +17956,12 @@ "userProfileImage.read", "userProfileImage.update", "userProfileImage.delete", + "queue.read", + "queue.update", + "queueJob.create", + "queueJob.read", + "queueJob.update", + "queueJob.delete", "workflow.create", "workflow.read", "workflow.update", @@ -18083,6 +18462,63 @@ ], "type": "object" }, + "QueueDeleteDto": { + "properties": { + "failed": { + "description": "If true, will also remove failed jobs from the queue.", + "type": "boolean", + "x-immich-history": [ + { + "version": "v2.4.0", + "state": "Added" + }, + { + "version": "v2.4.0", + "state": "Alpha" + } + ], + "x-immich-state": "Alpha" + } + }, + "type": "object" + }, + "QueueJobResponseDto": { + "properties": { + "data": { + "type": "object" + }, + "id": { + "type": "string" + }, + "name": { + "allOf": [ + { + "$ref": "#/components/schemas/JobName" + } + ] + }, + "timestamp": { + "type": "integer" + } + }, + "required": [ + "data", + "name", + "timestamp" + ], + "type": "object" + }, + "QueueJobStatus": { + "enum": [ + "active", + "failed", + "completed", + "delayed", + "waiting", + "paused" + ], + "type": "string" + }, "QueueName": { "enum": [ "thumbnailGeneration", @@ -18106,12 +18542,35 @@ "type": "string" }, "QueueResponseDto": { + "properties": { + "isPaused": { + "type": "boolean" + }, + "name": { + "allOf": [ + { + "$ref": "#/components/schemas/QueueName" + } + ] + }, + "statistics": { + "$ref": "#/components/schemas/QueueStatisticsDto" + } + }, + "required": [ + "isPaused", + "name", + "statistics" + ], + "type": "object" + }, + "QueueResponseLegacyDto": { "properties": { "jobCounts": { "$ref": "#/components/schemas/QueueStatisticsDto" }, "queueStatus": { - "$ref": "#/components/schemas/QueueStatusDto" + "$ref": "#/components/schemas/QueueStatusLegacyDto" } }, "required": [ @@ -18151,7 +18610,7 @@ ], "type": "object" }, - "QueueStatusDto": { + "QueueStatusLegacyDto": { "properties": { "isActive": { "type": "boolean" @@ -18166,58 +18625,66 @@ ], "type": "object" }, - "QueuesResponseDto": { + "QueueUpdateDto": { + "properties": { + "isPaused": { + "type": "boolean" + } + }, + "type": "object" + }, + "QueuesResponseLegacyDto": { "properties": { "backgroundTask": { - "$ref": "#/components/schemas/QueueResponseDto" + "$ref": "#/components/schemas/QueueResponseLegacyDto" }, "backupDatabase": { - "$ref": "#/components/schemas/QueueResponseDto" + "$ref": "#/components/schemas/QueueResponseLegacyDto" }, "duplicateDetection": { - "$ref": "#/components/schemas/QueueResponseDto" + "$ref": "#/components/schemas/QueueResponseLegacyDto" }, "faceDetection": { - "$ref": "#/components/schemas/QueueResponseDto" + "$ref": "#/components/schemas/QueueResponseLegacyDto" }, "facialRecognition": { - "$ref": "#/components/schemas/QueueResponseDto" + "$ref": "#/components/schemas/QueueResponseLegacyDto" }, "library": { - "$ref": "#/components/schemas/QueueResponseDto" + "$ref": "#/components/schemas/QueueResponseLegacyDto" }, "metadataExtraction": { - "$ref": "#/components/schemas/QueueResponseDto" + "$ref": "#/components/schemas/QueueResponseLegacyDto" }, "migration": { - "$ref": "#/components/schemas/QueueResponseDto" + "$ref": "#/components/schemas/QueueResponseLegacyDto" }, "notifications": { - "$ref": "#/components/schemas/QueueResponseDto" + "$ref": "#/components/schemas/QueueResponseLegacyDto" }, "ocr": { - "$ref": "#/components/schemas/QueueResponseDto" + "$ref": "#/components/schemas/QueueResponseLegacyDto" }, "search": { - "$ref": "#/components/schemas/QueueResponseDto" + "$ref": "#/components/schemas/QueueResponseLegacyDto" }, "sidecar": { - "$ref": "#/components/schemas/QueueResponseDto" + "$ref": "#/components/schemas/QueueResponseLegacyDto" }, "smartSearch": { - "$ref": "#/components/schemas/QueueResponseDto" + "$ref": "#/components/schemas/QueueResponseLegacyDto" }, "storageTemplateMigration": { - "$ref": "#/components/schemas/QueueResponseDto" + "$ref": "#/components/schemas/QueueResponseLegacyDto" }, "thumbnailGeneration": { - "$ref": "#/components/schemas/QueueResponseDto" + "$ref": "#/components/schemas/QueueResponseLegacyDto" }, "videoConversion": { - "$ref": "#/components/schemas/QueueResponseDto" + "$ref": "#/components/schemas/QueueResponseLegacyDto" }, "workflow": { - "$ref": "#/components/schemas/QueueResponseDto" + "$ref": "#/components/schemas/QueueResponseLegacyDto" } }, "required": [ diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index 0de0e7696b..bbcc2311b6 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -716,32 +716,32 @@ export type QueueStatisticsDto = { paused: number; waiting: number; }; -export type QueueStatusDto = { +export type QueueStatusLegacyDto = { isActive: boolean; isPaused: boolean; }; -export type QueueResponseDto = { +export type QueueResponseLegacyDto = { jobCounts: QueueStatisticsDto; - queueStatus: QueueStatusDto; + queueStatus: QueueStatusLegacyDto; }; -export type QueuesResponseDto = { - backgroundTask: QueueResponseDto; - backupDatabase: QueueResponseDto; - duplicateDetection: QueueResponseDto; - faceDetection: QueueResponseDto; - facialRecognition: QueueResponseDto; - library: QueueResponseDto; - metadataExtraction: QueueResponseDto; - migration: QueueResponseDto; - notifications: QueueResponseDto; - ocr: QueueResponseDto; - search: QueueResponseDto; - sidecar: QueueResponseDto; - smartSearch: QueueResponseDto; - storageTemplateMigration: QueueResponseDto; - thumbnailGeneration: QueueResponseDto; - videoConversion: QueueResponseDto; - workflow: QueueResponseDto; +export type QueuesResponseLegacyDto = { + backgroundTask: QueueResponseLegacyDto; + backupDatabase: QueueResponseLegacyDto; + duplicateDetection: QueueResponseLegacyDto; + faceDetection: QueueResponseLegacyDto; + facialRecognition: QueueResponseLegacyDto; + library: QueueResponseLegacyDto; + metadataExtraction: QueueResponseLegacyDto; + migration: QueueResponseLegacyDto; + notifications: QueueResponseLegacyDto; + ocr: QueueResponseLegacyDto; + search: QueueResponseLegacyDto; + sidecar: QueueResponseLegacyDto; + smartSearch: QueueResponseLegacyDto; + storageTemplateMigration: QueueResponseLegacyDto; + thumbnailGeneration: QueueResponseLegacyDto; + videoConversion: QueueResponseLegacyDto; + workflow: QueueResponseLegacyDto; }; export type JobCreateDto = { name: ManualJobName; @@ -966,6 +966,24 @@ export type PluginResponseDto = { updatedAt: string; version: string; }; +export type QueueResponseDto = { + isPaused: boolean; + name: QueueName; + statistics: QueueStatisticsDto; +}; +export type QueueUpdateDto = { + isPaused?: boolean; +}; +export type QueueDeleteDto = { + /** If true, will also remove failed jobs from the queue. */ + failed?: boolean; +}; +export type QueueJobResponseDto = { + data: object; + id?: string; + name: JobName; + timestamp: number; +}; export type SearchExploreItem = { data: AssetResponseDto; value: string; @@ -2925,7 +2943,7 @@ export function reassignFacesById({ id, faceDto }: { export function getQueuesLegacy(opts?: Oazapfts.RequestOpts) { return oazapfts.ok(oazapfts.fetchJson<{ status: 200; - data: QueuesResponseDto; + data: QueuesResponseLegacyDto; }>("/jobs", { ...opts })); @@ -2951,7 +2969,7 @@ export function runQueueCommandLegacy({ name, queueCommandDto }: { }, opts?: Oazapfts.RequestOpts) { return oazapfts.ok(oazapfts.fetchJson<{ status: 200; - data: QueueResponseDto; + data: QueueResponseLegacyDto; }>(`/jobs/${encodeURIComponent(name)}`, oazapfts.json({ ...opts, method: "PUT", @@ -3651,6 +3669,75 @@ export function getPlugin({ id }: { ...opts })); } +/** + * List all queues + */ +export function getQueues(opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchJson<{ + status: 200; + data: QueueResponseDto[]; + }>("/queues", { + ...opts + })); +} +/** + * Retrieve a queue + */ +export function getQueue({ name }: { + name: QueueName; +}, opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchJson<{ + status: 200; + data: QueueResponseDto; + }>(`/queues/${encodeURIComponent(name)}`, { + ...opts + })); +} +/** + * Update a queue + */ +export function updateQueue({ name, queueUpdateDto }: { + name: QueueName; + queueUpdateDto: QueueUpdateDto; +}, opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchJson<{ + status: 200; + data: QueueResponseDto; + }>(`/queues/${encodeURIComponent(name)}`, oazapfts.json({ + ...opts, + method: "PUT", + body: queueUpdateDto + }))); +} +/** + * Empty a queue + */ +export function emptyQueue({ name, queueDeleteDto }: { + name: QueueName; + queueDeleteDto: QueueDeleteDto; +}, opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchText(`/queues/${encodeURIComponent(name)}/jobs`, oazapfts.json({ + ...opts, + method: "DELETE", + body: queueDeleteDto + }))); +} +/** + * Retrieve queue jobs + */ +export function getQueueJobs({ name, status }: { + name: QueueName; + status?: QueueJobStatus[]; +}, opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchJson<{ + status: 200; + data: QueueJobResponseDto[]; + }>(`/queues/${encodeURIComponent(name)}/jobs${QS.query(QS.explode({ + status + }))}`, { + ...opts + })); +} /** * Retrieve assets by city */ @@ -5241,6 +5328,12 @@ export enum Permission { UserProfileImageRead = "userProfileImage.read", UserProfileImageUpdate = "userProfileImage.update", UserProfileImageDelete = "userProfileImage.delete", + QueueRead = "queue.read", + QueueUpdate = "queue.update", + QueueJobCreate = "queueJob.create", + QueueJobRead = "queueJob.read", + QueueJobUpdate = "queueJob.update", + QueueJobDelete = "queueJob.delete", WorkflowCreate = "workflow.create", WorkflowRead = "workflow.read", WorkflowUpdate = "workflow.update", @@ -5330,6 +5423,71 @@ export enum PluginContext { Album = "album", Person = "person" } +export enum QueueJobStatus { + Active = "active", + Failed = "failed", + Completed = "completed", + Delayed = "delayed", + Waiting = "waiting", + Paused = "paused" +} +export enum JobName { + AssetDelete = "AssetDelete", + AssetDeleteCheck = "AssetDeleteCheck", + AssetDetectFacesQueueAll = "AssetDetectFacesQueueAll", + AssetDetectFaces = "AssetDetectFaces", + AssetDetectDuplicatesQueueAll = "AssetDetectDuplicatesQueueAll", + AssetDetectDuplicates = "AssetDetectDuplicates", + AssetEncodeVideoQueueAll = "AssetEncodeVideoQueueAll", + AssetEncodeVideo = "AssetEncodeVideo", + AssetEmptyTrash = "AssetEmptyTrash", + AssetExtractMetadataQueueAll = "AssetExtractMetadataQueueAll", + AssetExtractMetadata = "AssetExtractMetadata", + AssetFileMigration = "AssetFileMigration", + AssetGenerateThumbnailsQueueAll = "AssetGenerateThumbnailsQueueAll", + AssetGenerateThumbnails = "AssetGenerateThumbnails", + AuditLogCleanup = "AuditLogCleanup", + AuditTableCleanup = "AuditTableCleanup", + DatabaseBackup = "DatabaseBackup", + FacialRecognitionQueueAll = "FacialRecognitionQueueAll", + FacialRecognition = "FacialRecognition", + FileDelete = "FileDelete", + FileMigrationQueueAll = "FileMigrationQueueAll", + LibraryDeleteCheck = "LibraryDeleteCheck", + LibraryDelete = "LibraryDelete", + LibraryRemoveAsset = "LibraryRemoveAsset", + LibraryScanAssetsQueueAll = "LibraryScanAssetsQueueAll", + LibrarySyncAssets = "LibrarySyncAssets", + LibrarySyncFilesQueueAll = "LibrarySyncFilesQueueAll", + LibrarySyncFiles = "LibrarySyncFiles", + LibraryScanQueueAll = "LibraryScanQueueAll", + MemoryCleanup = "MemoryCleanup", + MemoryGenerate = "MemoryGenerate", + NotificationsCleanup = "NotificationsCleanup", + NotifyUserSignup = "NotifyUserSignup", + NotifyAlbumInvite = "NotifyAlbumInvite", + NotifyAlbumUpdate = "NotifyAlbumUpdate", + UserDelete = "UserDelete", + UserDeleteCheck = "UserDeleteCheck", + UserSyncUsage = "UserSyncUsage", + PersonCleanup = "PersonCleanup", + PersonFileMigration = "PersonFileMigration", + PersonGenerateThumbnail = "PersonGenerateThumbnail", + SessionCleanup = "SessionCleanup", + SendMail = "SendMail", + SidecarQueueAll = "SidecarQueueAll", + SidecarCheck = "SidecarCheck", + SidecarWrite = "SidecarWrite", + SmartSearchQueueAll = "SmartSearchQueueAll", + SmartSearch = "SmartSearch", + StorageTemplateMigration = "StorageTemplateMigration", + StorageTemplateMigrationSingle = "StorageTemplateMigrationSingle", + TagCleanup = "TagCleanup", + VersionCheck = "VersionCheck", + OcrQueueAll = "OcrQueueAll", + Ocr = "Ocr", + WorkflowRun = "WorkflowRun" +} export enum SearchSuggestionType { Country = "country", State = "state", diff --git a/server/src/constants.ts b/server/src/constants.ts index 68534c00e9..33f8e3b4c5 100644 --- a/server/src/constants.ts +++ b/server/src/constants.ts @@ -163,6 +163,8 @@ export const endpointTags: Record = { 'A person is a collection of faces, which can be favorited and named. A person can also be merged into another person. People are automatically created via the face recognition job.', [ApiTag.Plugins]: 'A plugin is an installed module that makes filters and actions available for the workflow feature.', + [ApiTag.Queues]: + 'Queues and background jobs are used for processing tasks asynchronously. Queues can be paused and resumed as needed.', [ApiTag.Search]: 'Endpoints related to searching assets via text, smart search, optical character recognition (OCR), and other filters like person, album, and other metadata. Search endpoints usually support pagination and sorting.', [ApiTag.Server]: diff --git a/server/src/controllers/index.ts b/server/src/controllers/index.ts index d5811de48c..6ba3d38a73 100644 --- a/server/src/controllers/index.ts +++ b/server/src/controllers/index.ts @@ -20,6 +20,7 @@ import { OAuthController } from 'src/controllers/oauth.controller'; import { PartnerController } from 'src/controllers/partner.controller'; import { PersonController } from 'src/controllers/person.controller'; import { PluginController } from 'src/controllers/plugin.controller'; +import { QueueController } from 'src/controllers/queue.controller'; import { SearchController } from 'src/controllers/search.controller'; import { ServerController } from 'src/controllers/server.controller'; import { SessionController } from 'src/controllers/session.controller'; @@ -59,6 +60,7 @@ export const controllers = [ PartnerController, PersonController, PluginController, + QueueController, SearchController, ServerController, SessionController, diff --git a/server/src/controllers/job.controller.ts b/server/src/controllers/job.controller.ts index 977f1e0f1e..783d5a3133 100644 --- a/server/src/controllers/job.controller.ts +++ b/server/src/controllers/job.controller.ts @@ -1,10 +1,12 @@ import { Body, Controller, Get, HttpCode, HttpStatus, Param, Post, Put } from '@nestjs/common'; import { ApiTags } from '@nestjs/swagger'; import { Endpoint, HistoryBuilder } from 'src/decorators'; +import { AuthDto } from 'src/dtos/auth.dto'; import { JobCreateDto } from 'src/dtos/job.dto'; -import { QueueCommandDto, QueueNameParamDto, QueueResponseDto, QueuesResponseDto } from 'src/dtos/queue.dto'; +import { QueueResponseLegacyDto, QueuesResponseLegacyDto } from 'src/dtos/queue-legacy.dto'; +import { QueueCommandDto, QueueNameParamDto } from 'src/dtos/queue.dto'; import { ApiTag, Permission } from 'src/enum'; -import { Authenticated } from 'src/middleware/auth.guard'; +import { Auth, Authenticated } from 'src/middleware/auth.guard'; import { JobService } from 'src/services/job.service'; import { QueueService } from 'src/services/queue.service'; @@ -21,10 +23,10 @@ export class JobController { @Endpoint({ summary: 'Retrieve queue counts and status', description: 'Retrieve the counts of the current queue, as well as the current status.', - history: new HistoryBuilder().added('v1').beta('v1').stable('v2'), + history: new HistoryBuilder().added('v1').beta('v1').stable('v2').deprecated('v2.4.0'), }) - getQueuesLegacy(): Promise { - return this.queueService.getAll(); + getQueuesLegacy(@Auth() auth: AuthDto): Promise { + return this.queueService.getAllLegacy(auth); } @Post() @@ -46,9 +48,12 @@ export class JobController { summary: 'Run jobs', description: 'Queue all assets for a specific job type. Defaults to only queueing assets that have not yet been processed, but the force command can be used to re-process all assets.', - history: new HistoryBuilder().added('v1').beta('v1').stable('v2'), + history: new HistoryBuilder().added('v1').beta('v1').stable('v2').deprecated('v2.4.0'), }) - runQueueCommandLegacy(@Param() { name }: QueueNameParamDto, @Body() dto: QueueCommandDto): Promise { - return this.queueService.runCommand(name, dto); + runQueueCommandLegacy( + @Param() { name }: QueueNameParamDto, + @Body() dto: QueueCommandDto, + ): Promise { + return this.queueService.runCommandLegacy(name, dto); } } diff --git a/server/src/controllers/queue.controller.ts b/server/src/controllers/queue.controller.ts new file mode 100644 index 0000000000..1d8d918c5f --- /dev/null +++ b/server/src/controllers/queue.controller.ts @@ -0,0 +1,85 @@ +import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Param, Put, Query } from '@nestjs/common'; +import { ApiTags } from '@nestjs/swagger'; +import { Endpoint, HistoryBuilder } from 'src/decorators'; +import { AuthDto } from 'src/dtos/auth.dto'; +import { + QueueDeleteDto, + QueueJobResponseDto, + QueueJobSearchDto, + QueueNameParamDto, + QueueResponseDto, + QueueUpdateDto, +} from 'src/dtos/queue.dto'; +import { ApiTag, Permission } from 'src/enum'; +import { Auth, Authenticated } from 'src/middleware/auth.guard'; +import { QueueService } from 'src/services/queue.service'; + +@ApiTags(ApiTag.Queues) +@Controller('queues') +export class QueueController { + constructor(private service: QueueService) {} + + @Get() + @Authenticated({ permission: Permission.QueueRead, admin: true }) + @Endpoint({ + summary: 'List all queues', + description: 'Retrieves a list of queues.', + history: new HistoryBuilder().added('v2.4.0').alpha('v2.4.0'), + }) + getQueues(@Auth() auth: AuthDto): Promise { + return this.service.getAll(auth); + } + + @Get(':name') + @Authenticated({ permission: Permission.QueueRead, admin: true }) + @Endpoint({ + summary: 'Retrieve a queue', + description: 'Retrieves a specific queue by its name.', + history: new HistoryBuilder().added('v2.4.0').alpha('v2.4.0'), + }) + getQueue(@Auth() auth: AuthDto, @Param() { name }: QueueNameParamDto): Promise { + return this.service.get(auth, name); + } + + @Put(':name') + @Authenticated({ permission: Permission.QueueUpdate, admin: true }) + @Endpoint({ + summary: 'Update a queue', + description: 'Change the paused status of a specific queue.', + history: new HistoryBuilder().added('v2.4.0').alpha('v2.4.0'), + }) + updateQueue( + @Auth() auth: AuthDto, + @Param() { name }: QueueNameParamDto, + @Body() dto: QueueUpdateDto, + ): Promise { + return this.service.update(auth, name, dto); + } + + @Get(':name/jobs') + @Authenticated({ permission: Permission.QueueJobRead, admin: true }) + @Endpoint({ + summary: 'Retrieve queue jobs', + description: 'Retrieves a list of queue jobs from the specified queue.', + history: new HistoryBuilder().added('v2.4.0').alpha('v2.4.0'), + }) + getQueueJobs( + @Auth() auth: AuthDto, + @Param() { name }: QueueNameParamDto, + @Query() dto: QueueJobSearchDto, + ): Promise { + return this.service.searchJobs(auth, name, dto); + } + + @Delete(':name/jobs') + @Authenticated({ permission: Permission.QueueJobDelete, admin: true }) + @HttpCode(HttpStatus.NO_CONTENT) + @Endpoint({ + summary: 'Empty a queue', + description: 'Removes all jobs from the specified queue.', + history: new HistoryBuilder().added('v2.4.0').alpha('v2.4.0'), + }) + emptyQueue(@Auth() auth: AuthDto, @Param() { name }: QueueNameParamDto, @Body() dto: QueueDeleteDto): Promise { + return this.service.emptyQueue(auth, name, dto); + } +} diff --git a/server/src/dtos/queue-legacy.dto.ts b/server/src/dtos/queue-legacy.dto.ts new file mode 100644 index 0000000000..79155e3f74 --- /dev/null +++ b/server/src/dtos/queue-legacy.dto.ts @@ -0,0 +1,89 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { QueueResponseDto, QueueStatisticsDto } from 'src/dtos/queue.dto'; +import { QueueName } from 'src/enum'; + +export class QueueStatusLegacyDto { + isActive!: boolean; + isPaused!: boolean; +} + +export class QueueResponseLegacyDto { + @ApiProperty({ type: QueueStatusLegacyDto }) + queueStatus!: QueueStatusLegacyDto; + + @ApiProperty({ type: QueueStatisticsDto }) + jobCounts!: QueueStatisticsDto; +} + +export class QueuesResponseLegacyDto implements Record { + @ApiProperty({ type: QueueResponseLegacyDto }) + [QueueName.ThumbnailGeneration]!: QueueResponseLegacyDto; + + @ApiProperty({ type: QueueResponseLegacyDto }) + [QueueName.MetadataExtraction]!: QueueResponseLegacyDto; + + @ApiProperty({ type: QueueResponseLegacyDto }) + [QueueName.VideoConversion]!: QueueResponseLegacyDto; + + @ApiProperty({ type: QueueResponseLegacyDto }) + [QueueName.SmartSearch]!: QueueResponseLegacyDto; + + @ApiProperty({ type: QueueResponseLegacyDto }) + [QueueName.StorageTemplateMigration]!: QueueResponseLegacyDto; + + @ApiProperty({ type: QueueResponseLegacyDto }) + [QueueName.Migration]!: QueueResponseLegacyDto; + + @ApiProperty({ type: QueueResponseLegacyDto }) + [QueueName.BackgroundTask]!: QueueResponseLegacyDto; + + @ApiProperty({ type: QueueResponseLegacyDto }) + [QueueName.Search]!: QueueResponseLegacyDto; + + @ApiProperty({ type: QueueResponseLegacyDto }) + [QueueName.DuplicateDetection]!: QueueResponseLegacyDto; + + @ApiProperty({ type: QueueResponseLegacyDto }) + [QueueName.FaceDetection]!: QueueResponseLegacyDto; + + @ApiProperty({ type: QueueResponseLegacyDto }) + [QueueName.FacialRecognition]!: QueueResponseLegacyDto; + + @ApiProperty({ type: QueueResponseLegacyDto }) + [QueueName.Sidecar]!: QueueResponseLegacyDto; + + @ApiProperty({ type: QueueResponseLegacyDto }) + [QueueName.Library]!: QueueResponseLegacyDto; + + @ApiProperty({ type: QueueResponseLegacyDto }) + [QueueName.Notification]!: QueueResponseLegacyDto; + + @ApiProperty({ type: QueueResponseLegacyDto }) + [QueueName.BackupDatabase]!: QueueResponseLegacyDto; + + @ApiProperty({ type: QueueResponseLegacyDto }) + [QueueName.Ocr]!: QueueResponseLegacyDto; + + @ApiProperty({ type: QueueResponseLegacyDto }) + [QueueName.Workflow]!: QueueResponseLegacyDto; +} + +export const mapQueueLegacy = (response: QueueResponseDto): QueueResponseLegacyDto => { + return { + queueStatus: { + isPaused: response.isPaused, + isActive: response.statistics.active > 0, + }, + jobCounts: response.statistics, + }; +}; + +export const mapQueuesLegacy = (responses: QueueResponseDto[]): QueuesResponseLegacyDto => { + const legacy = new QueuesResponseLegacyDto(); + + for (const response of responses) { + legacy[response.name] = mapQueueLegacy(response); + } + + return legacy; +}; diff --git a/server/src/dtos/queue.dto.ts b/server/src/dtos/queue.dto.ts index df00c5cfc2..38a4a4ac6b 100644 --- a/server/src/dtos/queue.dto.ts +++ b/server/src/dtos/queue.dto.ts @@ -1,5 +1,6 @@ import { ApiProperty } from '@nestjs/swagger'; -import { QueueCommand, QueueName } from 'src/enum'; +import { HistoryBuilder, Property } from 'src/decorators'; +import { JobName, QueueCommand, QueueJobStatus, QueueName } from 'src/enum'; import { ValidateBoolean, ValidateEnum } from 'src/validation'; export class QueueNameParamDto { @@ -15,6 +16,46 @@ export class QueueCommandDto { force?: boolean; // TODO: this uses undefined as a third state, which should be refactored to be more explicit } +export class QueueUpdateDto { + @ValidateBoolean({ optional: true }) + isPaused?: boolean; +} + +export class QueueDeleteDto { + @ValidateBoolean({ optional: true }) + @Property({ + description: 'If true, will also remove failed jobs from the queue.', + history: new HistoryBuilder().added('v2.4.0').alpha('v2.4.0'), + }) + failed?: boolean; +} + +export class QueueJobSearchDto { + @ValidateEnum({ enum: QueueJobStatus, name: 'QueueJobStatus', optional: true, each: true }) + status?: QueueJobStatus[]; +} +export class QueueJobResponseDto { + id?: string; + + @ValidateEnum({ enum: JobName, name: 'JobName' }) + name!: JobName; + + data!: object; + + @ApiProperty({ type: 'integer' }) + timestamp!: number; +} + +export class QueueResponseDto { + @ValidateEnum({ enum: QueueName, name: 'QueueName' }) + name!: QueueName; + + @ValidateBoolean() + isPaused!: boolean; + + statistics!: QueueStatisticsDto; +} + export class QueueStatisticsDto { @ApiProperty({ type: 'integer' }) active!: number; @@ -29,69 +70,3 @@ export class QueueStatisticsDto { @ApiProperty({ type: 'integer' }) paused!: number; } - -export class QueueStatusDto { - isActive!: boolean; - isPaused!: boolean; -} - -export class QueueResponseDto { - @ApiProperty({ type: QueueStatisticsDto }) - jobCounts!: QueueStatisticsDto; - - @ApiProperty({ type: QueueStatusDto }) - queueStatus!: QueueStatusDto; -} - -export class QueuesResponseDto implements Record { - @ApiProperty({ type: QueueResponseDto }) - [QueueName.ThumbnailGeneration]!: QueueResponseDto; - - @ApiProperty({ type: QueueResponseDto }) - [QueueName.MetadataExtraction]!: QueueResponseDto; - - @ApiProperty({ type: QueueResponseDto }) - [QueueName.VideoConversion]!: QueueResponseDto; - - @ApiProperty({ type: QueueResponseDto }) - [QueueName.SmartSearch]!: QueueResponseDto; - - @ApiProperty({ type: QueueResponseDto }) - [QueueName.StorageTemplateMigration]!: QueueResponseDto; - - @ApiProperty({ type: QueueResponseDto }) - [QueueName.Migration]!: QueueResponseDto; - - @ApiProperty({ type: QueueResponseDto }) - [QueueName.BackgroundTask]!: QueueResponseDto; - - @ApiProperty({ type: QueueResponseDto }) - [QueueName.Search]!: QueueResponseDto; - - @ApiProperty({ type: QueueResponseDto }) - [QueueName.DuplicateDetection]!: QueueResponseDto; - - @ApiProperty({ type: QueueResponseDto }) - [QueueName.FaceDetection]!: QueueResponseDto; - - @ApiProperty({ type: QueueResponseDto }) - [QueueName.FacialRecognition]!: QueueResponseDto; - - @ApiProperty({ type: QueueResponseDto }) - [QueueName.Sidecar]!: QueueResponseDto; - - @ApiProperty({ type: QueueResponseDto }) - [QueueName.Library]!: QueueResponseDto; - - @ApiProperty({ type: QueueResponseDto }) - [QueueName.Notification]!: QueueResponseDto; - - @ApiProperty({ type: QueueResponseDto }) - [QueueName.BackupDatabase]!: QueueResponseDto; - - @ApiProperty({ type: QueueResponseDto }) - [QueueName.Ocr]!: QueueResponseDto; - - @ApiProperty({ type: QueueResponseDto }) - [QueueName.Workflow]!: QueueResponseDto; -} diff --git a/server/src/enum.ts b/server/src/enum.ts index d397f9d2ae..87ff282f31 100644 --- a/server/src/enum.ts +++ b/server/src/enum.ts @@ -248,6 +248,14 @@ export enum Permission { UserProfileImageUpdate = 'userProfileImage.update', UserProfileImageDelete = 'userProfileImage.delete', + QueueRead = 'queue.read', + QueueUpdate = 'queue.update', + + QueueJobCreate = 'queueJob.create', + QueueJobRead = 'queueJob.read', + QueueJobUpdate = 'queueJob.update', + QueueJobDelete = 'queueJob.delete', + WorkflowCreate = 'workflow.create', WorkflowRead = 'workflow.read', WorkflowUpdate = 'workflow.update', @@ -543,6 +551,15 @@ export enum QueueName { Workflow = 'workflow', } +export enum QueueJobStatus { + Active = 'active', + Failed = 'failed', + Complete = 'completed', + Delayed = 'delayed', + Waiting = 'waiting', + Paused = 'paused', +} + export enum JobName { AssetDelete = 'AssetDelete', AssetDeleteCheck = 'AssetDeleteCheck', @@ -624,9 +641,13 @@ export enum JobName { export enum QueueCommand { Start = 'start', + /** @deprecated Use `updateQueue` instead */ Pause = 'pause', + /** @deprecated Use `updateQueue` instead */ Resume = 'resume', + /** @deprecated Use `emptyQueue` instead */ Empty = 'empty', + /** @deprecated Use `emptyQueue` instead */ ClearFailed = 'clear-failed', } @@ -823,6 +844,7 @@ export enum ApiTag { Partners = 'Partners', People = 'People', Plugins = 'Plugins', + Queues = 'Queues', Search = 'Search', Server = 'Server', Sessions = 'Sessions', diff --git a/server/src/repositories/config.repository.ts b/server/src/repositories/config.repository.ts index 05d4bd2ac3..60ec021b3b 100644 --- a/server/src/repositories/config.repository.ts +++ b/server/src/repositories/config.repository.ts @@ -249,7 +249,7 @@ const getEnv = (): EnvData => { prefix: 'immich_bull', connection: { ...redisConfig }, defaultJobOptions: { - attempts: 3, + attempts: 1, removeOnComplete: true, removeOnFail: false, }, diff --git a/server/src/repositories/job.repository.ts b/server/src/repositories/job.repository.ts index cf2799a4cf..b12accb68e 100644 --- a/server/src/repositories/job.repository.ts +++ b/server/src/repositories/job.repository.ts @@ -5,11 +5,12 @@ import { JobsOptions, Queue, Worker } from 'bullmq'; import { ClassConstructor } from 'class-transformer'; import { setTimeout } from 'node:timers/promises'; import { JobConfig } from 'src/decorators'; -import { JobName, JobStatus, MetadataKey, QueueCleanType, QueueName } from 'src/enum'; +import { QueueJobResponseDto, QueueJobSearchDto } from 'src/dtos/queue.dto'; +import { JobName, JobStatus, MetadataKey, QueueCleanType, QueueJobStatus, QueueName } from 'src/enum'; import { ConfigRepository } from 'src/repositories/config.repository'; import { EventRepository } from 'src/repositories/event.repository'; import { LoggingRepository } from 'src/repositories/logging.repository'; -import { JobCounts, JobItem, JobOf, QueueStatus } from 'src/types'; +import { JobCounts, JobItem, JobOf } from 'src/types'; import { getKeyByValue, getMethodNames, ImmichStartupError } from 'src/utils/misc'; type JobMapItem = { @@ -115,13 +116,14 @@ export class JobRepository { worker.concurrency = concurrency; } - async getQueueStatus(name: QueueName): Promise { + async isActive(name: QueueName): Promise { const queue = this.getQueue(name); + const count = await queue.getActiveCount(); + return count > 0; + } - return { - isActive: !!(await queue.getActiveCount()), - isPaused: await queue.isPaused(), - }; + async isPaused(name: QueueName): Promise { + return this.getQueue(name).isPaused(); } pause(name: QueueName) { @@ -192,17 +194,28 @@ export class JobRepository { } async waitForQueueCompletion(...queues: QueueName[]): Promise { - let activeQueue: QueueStatus | undefined; - do { - const statuses = await Promise.all(queues.map((name) => this.getQueueStatus(name))); - activeQueue = statuses.find((status) => status.isActive); - } while (activeQueue); - { - this.logger.verbose(`Waiting for ${activeQueue} queue to stop...`); + const getPending = async () => { + const results = await Promise.all(queues.map(async (name) => ({ pending: await this.isActive(name), name }))); + return results.filter(({ pending }) => pending).map(({ name }) => name); + }; + + let pending = await getPending(); + + while (pending.length > 0) { + this.logger.verbose(`Waiting for ${pending[0]} queue to stop...`); await setTimeout(1000); + pending = await getPending(); } } + async searchJobs(name: QueueName, dto: QueueJobSearchDto): Promise { + const jobs = await this.getQueue(name).getJobs(dto.status ?? Object.values(QueueJobStatus), 0, 1000); + return jobs.map((job) => { + const { id, name, timestamp, data } = job; + return { id, name: name as JobName, timestamp, data }; + }); + } + private getJobOptions(item: JobItem): JobsOptions | null { switch (item.name) { case JobName.NotifyAlbumUpdate: { diff --git a/server/src/services/queue.service.spec.ts b/server/src/services/queue.service.spec.ts index 5dce9476e2..f5cf20413e 100644 --- a/server/src/services/queue.service.spec.ts +++ b/server/src/services/queue.service.spec.ts @@ -2,6 +2,7 @@ import { BadRequestException } from '@nestjs/common'; import { defaults, SystemConfig } from 'src/config'; import { ImmichWorker, JobName, QueueCommand, QueueName } from 'src/enum'; import { QueueService } from 'src/services/queue.service'; +import { factory } from 'test/small.factory'; import { newTestService, ServiceMocks } from 'test/utils'; describe(QueueService.name, () => { @@ -52,80 +53,64 @@ describe(QueueService.name, () => { describe('getAllJobStatus', () => { it('should get all job statuses', async () => { - mocks.job.getJobCounts.mockResolvedValue({ - active: 1, - completed: 1, - failed: 1, - delayed: 1, - waiting: 1, - paused: 1, - }); - mocks.job.getQueueStatus.mockResolvedValue({ - isActive: true, - isPaused: true, - }); + const stats = factory.queueStatistics({ active: 1 }); + const expected = { jobCounts: stats, queueStatus: { isActive: true, isPaused: true } }; - const expectedJobStatus = { - jobCounts: { - active: 1, - completed: 1, - delayed: 1, - failed: 1, - waiting: 1, - paused: 1, - }, - queueStatus: { - isActive: true, - isPaused: true, - }, - }; + mocks.job.getJobCounts.mockResolvedValue(stats); + mocks.job.isPaused.mockResolvedValue(true); - await expect(sut.getAll()).resolves.toEqual({ - [QueueName.BackgroundTask]: expectedJobStatus, - [QueueName.DuplicateDetection]: expectedJobStatus, - [QueueName.SmartSearch]: expectedJobStatus, - [QueueName.MetadataExtraction]: expectedJobStatus, - [QueueName.Search]: expectedJobStatus, - [QueueName.StorageTemplateMigration]: expectedJobStatus, - [QueueName.Migration]: expectedJobStatus, - [QueueName.ThumbnailGeneration]: expectedJobStatus, - [QueueName.VideoConversion]: expectedJobStatus, - [QueueName.FaceDetection]: expectedJobStatus, - [QueueName.FacialRecognition]: expectedJobStatus, - [QueueName.Sidecar]: expectedJobStatus, - [QueueName.Library]: expectedJobStatus, - [QueueName.Notification]: expectedJobStatus, - [QueueName.BackupDatabase]: expectedJobStatus, - [QueueName.Ocr]: expectedJobStatus, - [QueueName.Workflow]: expectedJobStatus, + await expect(sut.getAllLegacy(factory.auth())).resolves.toEqual({ + [QueueName.BackgroundTask]: expected, + [QueueName.DuplicateDetection]: expected, + [QueueName.SmartSearch]: expected, + [QueueName.MetadataExtraction]: expected, + [QueueName.Search]: expected, + [QueueName.StorageTemplateMigration]: expected, + [QueueName.Migration]: expected, + [QueueName.ThumbnailGeneration]: expected, + [QueueName.VideoConversion]: expected, + [QueueName.FaceDetection]: expected, + [QueueName.FacialRecognition]: expected, + [QueueName.Sidecar]: expected, + [QueueName.Library]: expected, + [QueueName.Notification]: expected, + [QueueName.BackupDatabase]: expected, + [QueueName.Ocr]: expected, + [QueueName.Workflow]: expected, }); }); }); describe('handleCommand', () => { it('should handle a pause command', async () => { - await sut.runCommand(QueueName.MetadataExtraction, { command: QueueCommand.Pause, force: false }); + mocks.job.getJobCounts.mockResolvedValue(factory.queueStatistics()); + + await sut.runCommandLegacy(QueueName.MetadataExtraction, { command: QueueCommand.Pause, force: false }); expect(mocks.job.pause).toHaveBeenCalledWith(QueueName.MetadataExtraction); }); it('should handle a resume command', async () => { - await sut.runCommand(QueueName.MetadataExtraction, { command: QueueCommand.Resume, force: false }); + mocks.job.getJobCounts.mockResolvedValue(factory.queueStatistics()); + + await sut.runCommandLegacy(QueueName.MetadataExtraction, { command: QueueCommand.Resume, force: false }); expect(mocks.job.resume).toHaveBeenCalledWith(QueueName.MetadataExtraction); }); it('should handle an empty command', async () => { - await sut.runCommand(QueueName.MetadataExtraction, { command: QueueCommand.Empty, force: false }); + mocks.job.getJobCounts.mockResolvedValue(factory.queueStatistics()); + + await sut.runCommandLegacy(QueueName.MetadataExtraction, { command: QueueCommand.Empty, force: false }); expect(mocks.job.empty).toHaveBeenCalledWith(QueueName.MetadataExtraction); }); it('should not start a job that is already running', async () => { - mocks.job.getQueueStatus.mockResolvedValue({ isActive: true, isPaused: false }); + mocks.job.isActive.mockResolvedValue(true); await expect( - sut.runCommand(QueueName.VideoConversion, { command: QueueCommand.Start, force: false }), + sut.runCommandLegacy(QueueName.VideoConversion, { command: QueueCommand.Start, force: false }), ).rejects.toBeInstanceOf(BadRequestException); expect(mocks.job.queue).not.toHaveBeenCalled(); @@ -133,33 +118,37 @@ describe(QueueService.name, () => { }); it('should handle a start video conversion command', async () => { - mocks.job.getQueueStatus.mockResolvedValue({ isActive: false, isPaused: false }); + mocks.job.isActive.mockResolvedValue(false); + mocks.job.getJobCounts.mockResolvedValue(factory.queueStatistics()); - await sut.runCommand(QueueName.VideoConversion, { command: QueueCommand.Start, force: false }); + await sut.runCommandLegacy(QueueName.VideoConversion, { command: QueueCommand.Start, force: false }); expect(mocks.job.queue).toHaveBeenCalledWith({ name: JobName.AssetEncodeVideoQueueAll, data: { force: false } }); }); it('should handle a start storage template migration command', async () => { - mocks.job.getQueueStatus.mockResolvedValue({ isActive: false, isPaused: false }); + mocks.job.isActive.mockResolvedValue(false); + mocks.job.getJobCounts.mockResolvedValue(factory.queueStatistics()); - await sut.runCommand(QueueName.StorageTemplateMigration, { command: QueueCommand.Start, force: false }); + await sut.runCommandLegacy(QueueName.StorageTemplateMigration, { command: QueueCommand.Start, force: false }); expect(mocks.job.queue).toHaveBeenCalledWith({ name: JobName.StorageTemplateMigration }); }); it('should handle a start smart search command', async () => { - mocks.job.getQueueStatus.mockResolvedValue({ isActive: false, isPaused: false }); + mocks.job.isActive.mockResolvedValue(false); + mocks.job.getJobCounts.mockResolvedValue(factory.queueStatistics()); - await sut.runCommand(QueueName.SmartSearch, { command: QueueCommand.Start, force: false }); + await sut.runCommandLegacy(QueueName.SmartSearch, { command: QueueCommand.Start, force: false }); expect(mocks.job.queue).toHaveBeenCalledWith({ name: JobName.SmartSearchQueueAll, data: { force: false } }); }); it('should handle a start metadata extraction command', async () => { - mocks.job.getQueueStatus.mockResolvedValue({ isActive: false, isPaused: false }); + mocks.job.isActive.mockResolvedValue(false); + mocks.job.getJobCounts.mockResolvedValue(factory.queueStatistics()); - await sut.runCommand(QueueName.MetadataExtraction, { command: QueueCommand.Start, force: false }); + await sut.runCommandLegacy(QueueName.MetadataExtraction, { command: QueueCommand.Start, force: false }); expect(mocks.job.queue).toHaveBeenCalledWith({ name: JobName.AssetExtractMetadataQueueAll, @@ -168,17 +157,19 @@ describe(QueueService.name, () => { }); it('should handle a start sidecar command', async () => { - mocks.job.getQueueStatus.mockResolvedValue({ isActive: false, isPaused: false }); + mocks.job.isActive.mockResolvedValue(false); + mocks.job.getJobCounts.mockResolvedValue(factory.queueStatistics()); - await sut.runCommand(QueueName.Sidecar, { command: QueueCommand.Start, force: false }); + await sut.runCommandLegacy(QueueName.Sidecar, { command: QueueCommand.Start, force: false }); expect(mocks.job.queue).toHaveBeenCalledWith({ name: JobName.SidecarQueueAll, data: { force: false } }); }); it('should handle a start thumbnail generation command', async () => { - mocks.job.getQueueStatus.mockResolvedValue({ isActive: false, isPaused: false }); + mocks.job.isActive.mockResolvedValue(false); + mocks.job.getJobCounts.mockResolvedValue(factory.queueStatistics()); - await sut.runCommand(QueueName.ThumbnailGeneration, { command: QueueCommand.Start, force: false }); + await sut.runCommandLegacy(QueueName.ThumbnailGeneration, { command: QueueCommand.Start, force: false }); expect(mocks.job.queue).toHaveBeenCalledWith({ name: JobName.AssetGenerateThumbnailsQueueAll, @@ -187,34 +178,37 @@ describe(QueueService.name, () => { }); it('should handle a start face detection command', async () => { - mocks.job.getQueueStatus.mockResolvedValue({ isActive: false, isPaused: false }); + mocks.job.isActive.mockResolvedValue(false); + mocks.job.getJobCounts.mockResolvedValue(factory.queueStatistics()); - await sut.runCommand(QueueName.FaceDetection, { command: QueueCommand.Start, force: false }); + await sut.runCommandLegacy(QueueName.FaceDetection, { command: QueueCommand.Start, force: false }); expect(mocks.job.queue).toHaveBeenCalledWith({ name: JobName.AssetDetectFacesQueueAll, data: { force: false } }); }); it('should handle a start facial recognition command', async () => { - mocks.job.getQueueStatus.mockResolvedValue({ isActive: false, isPaused: false }); + mocks.job.isActive.mockResolvedValue(false); + mocks.job.getJobCounts.mockResolvedValue(factory.queueStatistics()); - await sut.runCommand(QueueName.FacialRecognition, { command: QueueCommand.Start, force: false }); + await sut.runCommandLegacy(QueueName.FacialRecognition, { command: QueueCommand.Start, force: false }); expect(mocks.job.queue).toHaveBeenCalledWith({ name: JobName.FacialRecognitionQueueAll, data: { force: false } }); }); it('should handle a start backup database command', async () => { - mocks.job.getQueueStatus.mockResolvedValue({ isActive: false, isPaused: false }); + mocks.job.isActive.mockResolvedValue(false); + mocks.job.getJobCounts.mockResolvedValue(factory.queueStatistics()); - await sut.runCommand(QueueName.BackupDatabase, { command: QueueCommand.Start, force: false }); + await sut.runCommandLegacy(QueueName.BackupDatabase, { command: QueueCommand.Start, force: false }); expect(mocks.job.queue).toHaveBeenCalledWith({ name: JobName.DatabaseBackup, data: { force: false } }); }); it('should throw a bad request when an invalid queue is used', async () => { - mocks.job.getQueueStatus.mockResolvedValue({ isActive: false, isPaused: false }); + mocks.job.isActive.mockResolvedValue(false); await expect( - sut.runCommand(QueueName.BackgroundTask, { command: QueueCommand.Start, force: false }), + sut.runCommandLegacy(QueueName.BackgroundTask, { command: QueueCommand.Start, force: false }), ).rejects.toBeInstanceOf(BadRequestException); expect(mocks.job.queue).not.toHaveBeenCalled(); diff --git a/server/src/services/queue.service.ts b/server/src/services/queue.service.ts index bea665e8fd..cdfa2ad2ed 100644 --- a/server/src/services/queue.service.ts +++ b/server/src/services/queue.service.ts @@ -2,7 +2,21 @@ import { BadRequestException, Injectable } from '@nestjs/common'; import { ClassConstructor } from 'class-transformer'; import { SystemConfig } from 'src/config'; import { OnEvent } from 'src/decorators'; -import { QueueCommandDto, QueueResponseDto, QueuesResponseDto } from 'src/dtos/queue.dto'; +import { AuthDto } from 'src/dtos/auth.dto'; +import { + mapQueueLegacy, + mapQueuesLegacy, + QueueResponseLegacyDto, + QueuesResponseLegacyDto, +} from 'src/dtos/queue-legacy.dto'; +import { + QueueCommandDto, + QueueDeleteDto, + QueueJobResponseDto, + QueueJobSearchDto, + QueueResponseDto, + QueueUpdateDto, +} from 'src/dtos/queue.dto'; import { BootstrapEventPriority, CronJob, @@ -86,7 +100,7 @@ export class QueueService extends BaseService { this.services = services; } - async runCommand(name: QueueName, dto: QueueCommandDto): Promise { + async runCommandLegacy(name: QueueName, dto: QueueCommandDto): Promise { this.logger.debug(`Handling command: queue=${name},command=${dto.command},force=${dto.force}`); switch (dto.command) { @@ -117,28 +131,60 @@ export class QueueService extends BaseService { } } + const response = await this.getByName(name); + + return mapQueueLegacy(response); + } + + async getAll(_auth: AuthDto): Promise { + return Promise.all(Object.values(QueueName).map((name) => this.getByName(name))); + } + + async getAllLegacy(auth: AuthDto): Promise { + const responses = await this.getAll(auth); + return mapQueuesLegacy(responses); + } + + get(auth: AuthDto, name: QueueName): Promise { return this.getByName(name); } - async getAll(): Promise { - const response = new QueuesResponseDto(); - for (const name of Object.values(QueueName)) { - response[name] = await this.getByName(name); + async update(auth: AuthDto, name: QueueName, dto: QueueUpdateDto): Promise { + if (dto.isPaused === true) { + if (name === QueueName.BackgroundTask) { + throw new BadRequestException(`The BackgroundTask queue cannot be paused`); + } + await this.jobRepository.pause(name); } - return response; + + if (dto.isPaused === false) { + await this.jobRepository.resume(name); + } + + return this.getByName(name); } - async getByName(name: QueueName): Promise { - const [jobCounts, queueStatus] = await Promise.all([ - this.jobRepository.getJobCounts(name), - this.jobRepository.getQueueStatus(name), - ]); + searchJobs(auth: AuthDto, name: QueueName, dto: QueueJobSearchDto): Promise { + return this.jobRepository.searchJobs(name, dto); + } - return { jobCounts, queueStatus }; + async emptyQueue(auth: AuthDto, name: QueueName, dto: QueueDeleteDto) { + await this.jobRepository.empty(name); + if (dto.failed) { + await this.jobRepository.clear(name, QueueCleanType.Failed); + } + } + + private async getByName(name: QueueName): Promise { + const [statistics, isPaused] = await Promise.all([ + this.jobRepository.getJobCounts(name), + this.jobRepository.isPaused(name), + ]); + return { name, isPaused, statistics }; } private async start(name: QueueName, { force }: QueueCommandDto): Promise { - const { isActive } = await this.jobRepository.getQueueStatus(name); + const isActive = await this.jobRepository.isActive(name); if (isActive) { throw new BadRequestException(`Job is already running`); } diff --git a/server/src/types.ts b/server/src/types.ts index dd3d25a7cb..848d19177d 100644 --- a/server/src/types.ts +++ b/server/src/types.ts @@ -291,11 +291,6 @@ export interface JobCounts { paused: number; } -export interface QueueStatus { - isActive: boolean; - isPaused: boolean; -} - export type JobItem = // Audit | { name: JobName.AuditTableCleanup; data?: IBaseJob } diff --git a/server/test/repositories/job.repository.mock.ts b/server/test/repositories/job.repository.mock.ts index f0f4fdda00..4fc5460c8a 100644 --- a/server/test/repositories/job.repository.mock.ts +++ b/server/test/repositories/job.repository.mock.ts @@ -11,9 +11,11 @@ export const newJobRepositoryMock = (): Mocked Promise.resolve()), queueAll: vitest.fn().mockImplementation(() => Promise.resolve()), - getQueueStatus: vitest.fn(), + isActive: vitest.fn(), + isPaused: vitest.fn(), getJobCounts: vitest.fn(), clear: vitest.fn(), waitForQueueCompletion: vitest.fn(), diff --git a/server/test/small.factory.ts b/server/test/small.factory.ts index ea0df585ea..a0de947b2b 100644 --- a/server/test/small.factory.ts +++ b/server/test/small.factory.ts @@ -14,6 +14,7 @@ import { } from 'src/database'; import { MapAsset } from 'src/dtos/asset-response.dto'; import { AuthDto } from 'src/dtos/auth.dto'; +import { QueueStatisticsDto } from 'src/dtos/queue.dto'; import { AssetStatus, AssetType, AssetVisibility, MemoryType, Permission, UserMetadataKey, UserStatus } from 'src/enum'; import { OnThisDayData, UserMetadataItem } from 'src/types'; import { v4, v7 } from 'uuid'; @@ -139,6 +140,16 @@ const sessionFactory = (session: Partial = {}) => ({ ...session, }); +const queueStatisticsFactory = (dto?: Partial) => ({ + active: 0, + completed: 0, + failed: 0, + delayed: 0, + waiting: 0, + paused: 0, + ...dto, +}); + const stackFactory = () => ({ id: newUuid(), ownerId: newUuid(), @@ -353,6 +364,7 @@ export const factory = { library: libraryFactory, memory: memoryFactory, partner: partnerFactory, + queueStatistics: queueStatisticsFactory, session: sessionFactory, stack: stackFactory, user: userFactory, diff --git a/web/src/lib/components/jobs/JobTile.svelte b/web/src/lib/components/jobs/JobTile.svelte index 64a6db5b7f..8bdd7c169a 100644 --- a/web/src/lib/components/jobs/JobTile.svelte +++ b/web/src/lib/components/jobs/JobTile.svelte @@ -1,7 +1,7 @@ {#if action.$if?.() ?? true} - + onAction(action)} /> {/if} diff --git a/web/src/lib/components/HeaderButton.svelte b/web/src/lib/components/HeaderButton.svelte index 9021d2d1cb..c4189c06c0 100644 --- a/web/src/lib/components/HeaderButton.svelte +++ b/web/src/lib/components/HeaderButton.svelte @@ -1,18 +1,17 @@ {#if action.$if?.() ?? true} - {/if} diff --git a/web/src/lib/components/TableButton.svelte b/web/src/lib/components/TableButton.svelte index 4bd82e4dd9..e2aead4b22 100644 --- a/web/src/lib/components/TableButton.svelte +++ b/web/src/lib/components/TableButton.svelte @@ -1,16 +1,14 @@ {#if action.$if?.() ?? true} - + onAction(action)} /> {/if} diff --git a/web/src/lib/components/album-page/album-shared-link.svelte b/web/src/lib/components/album-page/album-shared-link.svelte index 1b6db6ff69..a9e3471241 100644 --- a/web/src/lib/components/album-page/album-shared-link.svelte +++ b/web/src/lib/components/album-page/album-shared-link.svelte @@ -32,7 +32,7 @@ .filter(Boolean) .join(' • '); - const SharedLinkActions = $derived(getSharedLinkActions($t, sharedLink)); + const { ViewQrCode, Copy } = $derived(getSharedLinkActions($t, sharedLink));
@@ -41,7 +41,7 @@ {getShareProperties()}
- - + +
diff --git a/web/src/lib/components/layouts/AdminPageLayout.svelte b/web/src/lib/components/layouts/AdminPageLayout.svelte index a74a6aee35..45d21c9139 100644 --- a/web/src/lib/components/layouts/AdminPageLayout.svelte +++ b/web/src/lib/components/layouts/AdminPageLayout.svelte @@ -4,16 +4,16 @@ import NavigationBar from '$lib/components/shared-components/navigation-bar/navigation-bar.svelte'; import AdminSidebar from '$lib/sidebars/AdminSidebar.svelte'; import { sidebarStore } from '$lib/stores/sidebar.svelte'; - import { AppShell, AppShellHeader, AppShellSidebar, Scrollable } from '@immich/ui'; + import { AppShell, AppShellHeader, AppShellSidebar, Scrollable, type BreadcrumbItem } from '@immich/ui'; import type { Snippet } from 'svelte'; type Props = { - title: string; + breadcrumbs: BreadcrumbItem[]; buttons?: Snippet; children?: Snippet; }; - let { title, buttons, children }: Props = $props(); + let { breadcrumbs, buttons, children }: Props = $props(); @@ -24,7 +24,7 @@ - + {@render children?.()} diff --git a/web/src/lib/components/layouts/TitleLayout.svelte b/web/src/lib/components/layouts/TitleLayout.svelte index 1beab45586..2d867bab2f 100644 --- a/web/src/lib/components/layouts/TitleLayout.svelte +++ b/web/src/lib/components/layouts/TitleLayout.svelte @@ -1,26 +1,20 @@
-
-
{title}
- {#if description} - {description} - {/if} -
+ {@render buttons?.()}
{@render children?.()} diff --git a/web/src/lib/components/sharedlinks-page/shared-link-card.svelte b/web/src/lib/components/sharedlinks-page/shared-link-card.svelte index b2c6cf296d..6de97e94f9 100644 --- a/web/src/lib/components/sharedlinks-page/shared-link-card.svelte +++ b/web/src/lib/components/sharedlinks-page/shared-link-card.svelte @@ -6,6 +6,7 @@ import { getSharedLinkActions } from '$lib/services/shared-link.service'; import { locale } from '$lib/stores/preferences.store'; import { SharedLinkType, type SharedLinkResponseDto } from '@immich/sdk'; + import { ContextMenuButton, MenuItemType } from '@immich/ui'; import { DateTime, type ToRelativeUnit } from 'luxon'; import { t } from 'svelte-i18n'; @@ -31,7 +32,7 @@ } }; - const SharedLinkActions = $derived(getSharedLinkActions($t, sharedLink)); + const { Edit, Copy, Delete } = $derived(getSharedLinkActions($t, sharedLink));
- +
diff --git a/web/src/lib/services/library.service.ts b/web/src/lib/services/library.service.ts index 415d6dae42..93cf836c82 100644 --- a/web/src/lib/services/library.service.ts +++ b/web/src/lib/services/library.service.ts @@ -7,7 +7,6 @@ import LibraryFolderAddModal from '$lib/modals/LibraryFolderAddModal.svelte'; import LibraryFolderEditModal from '$lib/modals/LibraryFolderEditModal.svelte'; import LibraryRenameModal from '$lib/modals/LibraryRenameModal.svelte'; import LibraryUserPickerModal from '$lib/modals/LibraryUserPickerModal.svelte'; -import type { ActionItem } from '$lib/types'; import { handleError } from '$lib/utils/handle-error'; import { getFormatter } from '$lib/utils/i18n'; import { @@ -20,7 +19,7 @@ import { updateLibrary, type LibraryResponseDto, } from '@immich/sdk'; -import { modalManager, toastManager } from '@immich/ui'; +import { modalManager, toastManager, type ActionItem } from '@immich/ui'; import { mdiPencilOutline, mdiPlusBoxOutline, mdiSync, mdiTrashCanOutline } from '@mdi/js'; import type { MessageFormatter } from 'svelte-i18n'; @@ -28,13 +27,13 @@ export const getLibrariesActions = ($t: MessageFormatter) => { const ScanAll: ActionItem = { title: $t('scan_all_libraries'), icon: mdiSync, - onSelect: () => void handleScanAllLibraries(), + onAction: () => void handleScanAllLibraries(), }; const Create: ActionItem = { title: $t('create_library'), icon: mdiPlusBoxOutline, - onSelect: () => void handleCreateLibrary(), + onAction: () => void handleCreateLibrary(), }; return { ScanAll, Create }; @@ -44,32 +43,32 @@ export const getLibraryActions = ($t: MessageFormatter, library: LibraryResponse const Rename: ActionItem = { icon: mdiPencilOutline, title: $t('rename'), - onSelect: () => void modalManager.show(LibraryRenameModal, { library }), + onAction: () => void modalManager.show(LibraryRenameModal, { library }), }; const Delete: ActionItem = { icon: mdiTrashCanOutline, title: $t('delete'), color: 'danger', - onSelect: () => void handleDeleteLibrary(library), + onAction: () => void handleDeleteLibrary(library), }; const AddFolder: ActionItem = { icon: mdiPlusBoxOutline, title: $t('add'), - onSelect: () => void modalManager.show(LibraryFolderAddModal, { library }), + onAction: () => void modalManager.show(LibraryFolderAddModal, { library }), }; const AddExclusionPattern: ActionItem = { icon: mdiPlusBoxOutline, title: $t('add'), - onSelect: () => void modalManager.show(LibraryExclusionPatternAddModal, { library }), + onAction: () => void modalManager.show(LibraryExclusionPatternAddModal, { library }), }; const Scan: ActionItem = { icon: mdiSync, title: $t('scan_library'), - onSelect: () => void handleScanLibrary(library), + onAction: () => void handleScanLibrary(library), }; return { Rename, Delete, AddFolder, AddExclusionPattern, Scan }; @@ -79,13 +78,13 @@ export const getLibraryFolderActions = ($t: MessageFormatter, library: LibraryRe const Edit: ActionItem = { icon: mdiPencilOutline, title: $t('edit'), - onSelect: () => void modalManager.show(LibraryFolderEditModal, { folder, library }), + onAction: () => void modalManager.show(LibraryFolderEditModal, { folder, library }), }; const Delete: ActionItem = { icon: mdiTrashCanOutline, title: $t('delete'), - onSelect: () => void handleDeleteLibraryFolder(library, folder), + onAction: () => void handleDeleteLibraryFolder(library, folder), }; return { Edit, Delete }; @@ -99,13 +98,13 @@ export const getLibraryExclusionPatternActions = ( const Edit: ActionItem = { icon: mdiPencilOutline, title: $t('edit'), - onSelect: () => void modalManager.show(LibraryExclusionPatternEditModal, { exclusionPattern, library }), + onAction: () => void modalManager.show(LibraryExclusionPatternEditModal, { exclusionPattern, library }), }; const Delete: ActionItem = { icon: mdiTrashCanOutline, title: $t('delete'), - onSelect: () => void handleDeleteExclusionPattern(library, exclusionPattern), + onAction: () => void handleDeleteExclusionPattern(library, exclusionPattern), }; return { Edit, Delete }; diff --git a/web/src/lib/services/shared-link.service.ts b/web/src/lib/services/shared-link.service.ts index 3ce6f4222d..ea7f158db8 100644 --- a/web/src/lib/services/shared-link.service.ts +++ b/web/src/lib/services/shared-link.service.ts @@ -16,48 +16,37 @@ import { type SharedLinkEditDto, type SharedLinkResponseDto, } from '@immich/sdk'; -import { MenuItemType, menuManager, modalManager, toastManager, type MenuItem } from '@immich/ui'; -import { mdiCircleEditOutline, mdiContentCopy, mdiDelete, mdiDotsVertical, mdiQrcode } from '@mdi/js'; +import { modalManager, toastManager, type ActionItem } from '@immich/ui'; +import { mdiCircleEditOutline, mdiContentCopy, mdiDelete, mdiQrcode } from '@mdi/js'; import type { MessageFormatter } from 'svelte-i18n'; export const getSharedLinkActions = ($t: MessageFormatter, sharedLink: SharedLinkResponseDto) => { - const Edit: MenuItem = { + const Edit: ActionItem = { title: $t('edit_link'), icon: mdiCircleEditOutline, - onSelect: () => void goto(`${AppRoute.SHARED_LINKS}/${sharedLink.id}`), + onAction: () => void goto(`${AppRoute.SHARED_LINKS}/${sharedLink.id}`), }; - const Delete: MenuItem = { + const Delete: ActionItem = { title: $t('delete_link'), icon: mdiDelete, color: 'danger', - onSelect: () => void handleDeleteSharedLink(sharedLink), + onAction: () => void handleDeleteSharedLink(sharedLink), }; - const Copy: MenuItem = { + const Copy: ActionItem = { title: $t('copy_link'), icon: mdiContentCopy, - onSelect: () => void copyToClipboard(asUrl(sharedLink)), + onAction: () => void copyToClipboard(asUrl(sharedLink)), }; - const ViewQrCode: MenuItem = { + const ViewQrCode: ActionItem = { title: $t('view_qr_code'), icon: mdiQrcode, - onSelect: () => void handleShowSharedLinkQrCode(sharedLink), + onAction: () => void handleShowSharedLinkQrCode(sharedLink), }; - const ContextMenu: MenuItem = { - title: $t('shared_link_options'), - icon: mdiDotsVertical, - onSelect: ({ event }) => - void menuManager.show({ - target: event.currentTarget as HTMLElement, - position: 'top-right', - items: [Edit, Copy, MenuItemType.Divider, Delete], - }), - }; - - return { Edit, Delete, Copy, ViewQrCode, ContextMenu }; + return { Edit, Delete, Copy, ViewQrCode }; }; const asUrl = (sharedLink: SharedLinkResponseDto) => { diff --git a/web/src/lib/services/system-config.service.ts b/web/src/lib/services/system-config.service.ts index b555c425ef..62034886b9 100644 --- a/web/src/lib/services/system-config.service.ts +++ b/web/src/lib/services/system-config.service.ts @@ -1,12 +1,11 @@ import { downloadManager } from '$lib/managers/download-manager.svelte'; import { eventManager } from '$lib/managers/event-manager.svelte'; -import type { ActionItem } from '$lib/types'; import { copyToClipboard } from '$lib/utils'; import { downloadBlob } from '$lib/utils/asset-utils'; import { handleError } from '$lib/utils/handle-error'; import { getFormatter } from '$lib/utils/i18n'; import { getConfig, updateConfig, type ServerFeaturesDto, type SystemConfigDto } from '@immich/sdk'; -import { toastManager } from '@immich/ui'; +import { toastManager, type ActionItem } from '@immich/ui'; import { mdiContentCopy, mdiDownload, mdiUpload } from '@mdi/js'; import { isEqual } from 'lodash-es'; import type { MessageFormatter } from 'svelte-i18n'; @@ -19,20 +18,20 @@ export const getSystemConfigActions = ( const CopyToClipboard: ActionItem = { title: $t('copy_to_clipboard'), icon: mdiContentCopy, - onSelect: () => void handleCopyToClipboard(config), + onAction: () => void handleCopyToClipboard(config), }; const Download: ActionItem = { title: $t('export_as_json'), icon: mdiDownload, - onSelect: () => handleDownloadConfig(config), + onAction: () => handleDownloadConfig(config), }; const Upload: ActionItem = { title: $t('import_from_json'), icon: mdiUpload, $if: () => !featureFlags.configFile, - onSelect: () => handleUploadConfig(), + onAction: () => handleUploadConfig(), }; return { CopyToClipboard, Download, Upload }; diff --git a/web/src/lib/services/user-admin.service.ts b/web/src/lib/services/user-admin.service.ts index 93b8800b11..b8a4c648c1 100644 --- a/web/src/lib/services/user-admin.service.ts +++ b/web/src/lib/services/user-admin.service.ts @@ -1,13 +1,11 @@ import { goto } from '$app/navigation'; import { eventManager } from '$lib/managers/event-manager.svelte'; -import { serverConfigManager } from '$lib/managers/server-config-manager.svelte'; import PasswordResetSuccessModal from '$lib/modals/PasswordResetSuccessModal.svelte'; import UserCreateModal from '$lib/modals/UserCreateModal.svelte'; import UserDeleteConfirmModal from '$lib/modals/UserDeleteConfirmModal.svelte'; import UserEditModal from '$lib/modals/UserEditModal.svelte'; import UserRestoreConfirmModal from '$lib/modals/UserRestoreConfirmModal.svelte'; import { user as authUser } from '$lib/stores/user.store'; -import type { ActionItem } from '$lib/types'; import { handleError } from '$lib/utils/handle-error'; import { getFormatter } from '$lib/utils/i18n'; import { @@ -21,45 +19,33 @@ import { type UserAdminResponseDto, type UserAdminUpdateDto, } from '@immich/sdk'; -import { MenuItemType, menuManager, modalManager, toastManager } from '@immich/ui'; +import { modalManager, toastManager, type ActionItem } from '@immich/ui'; import { mdiDeleteRestore, - mdiDotsVertical, - mdiEyeOutline, mdiLockReset, mdiLockSmart, mdiPencilOutline, mdiPlusBoxOutline, mdiTrashCanOutline, } from '@mdi/js'; -import { DateTime } from 'luxon'; import type { MessageFormatter } from 'svelte-i18n'; import { get } from 'svelte/store'; -const getDeleteDate = (deletedAt: string): Date => - DateTime.fromISO(deletedAt).plus({ days: serverConfigManager.value.userDeleteDelay }).toJSDate(); - export const getUserAdminsActions = ($t: MessageFormatter) => { const Create: ActionItem = { title: $t('create_user'), icon: mdiPlusBoxOutline, - onSelect: () => void modalManager.show(UserCreateModal, {}), + onAction: () => void modalManager.show(UserCreateModal, {}), }; return { Create }; }; export const getUserAdminActions = ($t: MessageFormatter, user: UserAdminResponseDto) => { - const View: ActionItem = { - icon: mdiEyeOutline, - title: $t('view'), - onSelect: () => void goto(`/admin/users/${user.id}`), - }; - const Update: ActionItem = { icon: mdiPencilOutline, title: $t('edit'), - onSelect: () => void modalManager.show(UserEditModal, { user }), + onAction: () => void modalManager.show(UserEditModal, { user }), }; const Delete: ActionItem = { @@ -67,7 +53,7 @@ export const getUserAdminActions = ($t: MessageFormatter, user: UserAdminRespons title: $t('delete'), color: 'danger', $if: () => get(authUser).id !== user.id && !user.deletedAt, - onSelect: () => void modalManager.show(UserDeleteConfirmModal, { user }), + onAction: () => void modalManager.show(UserDeleteConfirmModal, { user }), }; const Restore: ActionItem = { @@ -75,47 +61,23 @@ export const getUserAdminActions = ($t: MessageFormatter, user: UserAdminRespons title: $t('restore'), color: 'primary', $if: () => !!user.deletedAt && user.status === UserStatus.Deleted, - onSelect: () => void modalManager.show(UserRestoreConfirmModal, { user }), - props: { - title: $t('admin.user_restore_scheduled_removal', { - values: { date: getDeleteDate(user.deletedAt!) }, - }), - }, + onAction: () => void modalManager.show(UserRestoreConfirmModal, { user }), }; const ResetPassword: ActionItem = { icon: mdiLockReset, title: $t('reset_password'), $if: () => get(authUser).id !== user.id, - onSelect: () => void handleResetPasswordUserAdmin(user), + onAction: () => void handleResetPasswordUserAdmin(user), }; const ResetPinCode: ActionItem = { icon: mdiLockSmart, title: $t('reset_pin_code'), - onSelect: () => void handleResetPinCodeUserAdmin(user), + onAction: () => void handleResetPinCodeUserAdmin(user), }; - const ContextMenu: ActionItem = { - icon: mdiDotsVertical, - title: $t('actions'), - onSelect: ({ event }) => - void menuManager.show({ - target: event.currentTarget as HTMLElement, - position: 'top-right', - items: [ - View, - Update, - ResetPassword, - ResetPinCode, - get(authUser).id === user.id ? undefined : MenuItemType.Divider, - Restore, - Delete, - ].filter(Boolean), - }), - }; - - return { View, Update, Delete, Restore, ResetPassword, ResetPinCode, ContextMenu }; + return { Update, Delete, Restore, ResetPassword, ResetPinCode }; }; export const handleCreateUserAdmin = async (dto: UserAdminCreateDto) => { @@ -172,6 +134,10 @@ export const handleRestoreUserAdmin = async (user: UserAdminResponseDto) => { } }; +export const handleNavigateUserAdmin = async (user: UserAdminResponseDto) => { + await goto(`/admin/users/${user.id}`); +}; + // TODO move password reset server-side const generatePassword = (length: number = 16) => { let generatedPassword = ''; diff --git a/web/src/lib/types.ts b/web/src/lib/types.ts index 4e6e8a45f4..d95e7b7cf2 100644 --- a/web/src/lib/types.ts +++ b/web/src/lib/types.ts @@ -1,8 +1,4 @@ import type { ServerVersionResponseDto } from '@immich/sdk'; -import type { MenuItem } from '@immich/ui'; -import type { HTMLAttributes } from 'svelte/elements'; - -export type ActionItem = MenuItem & { props?: Omit, 'color'> }; export interface ReleaseEvent { isAvailable: boolean; diff --git a/web/src/routes/admin/jobs-status/+page.svelte b/web/src/routes/admin/jobs-status/+page.svelte index 808a5b57ca..6a3195f447 100644 --- a/web/src/routes/admin/jobs-status/+page.svelte +++ b/web/src/routes/admin/jobs-status/+page.svelte @@ -58,7 +58,7 @@ }); - + {#snippet buttons()} {#if pausedJobs.length > 0} diff --git a/web/src/routes/admin/library-management/+page.svelte b/web/src/routes/admin/library-management/+page.svelte index 37153d5003..6aa2b3007a 100644 --- a/web/src/routes/admin/library-management/+page.svelte +++ b/web/src/routes/admin/library-management/+page.svelte @@ -58,7 +58,7 @@ onLibraryDelete={handleDeleteLibrary} /> - + {#snippet buttons()}
{#if libraries.length > 0} diff --git a/web/src/routes/admin/library-management/[id]/+page.svelte b/web/src/routes/admin/library-management/[id]/+page.svelte index c6fffbbd95..32367e78a8 100644 --- a/web/src/routes/admin/library-management/[id]/+page.svelte +++ b/web/src/routes/admin/library-management/[id]/+page.svelte @@ -39,7 +39,12 @@ onLibraryDelete={({ id }) => id === library.id && goto(AppRoute.ADMIN_LIBRARY_MANAGEMENT)} /> - + {#snippet buttons()}
diff --git a/web/src/routes/admin/server-status/+page.svelte b/web/src/routes/admin/server-status/+page.svelte index e33a792322..31b193d952 100644 --- a/web/src/routes/admin/server-status/+page.svelte +++ b/web/src/routes/admin/server-status/+page.svelte @@ -24,7 +24,7 @@ }); - +
diff --git a/web/src/routes/admin/system-settings/+page.svelte b/web/src/routes/admin/system-settings/+page.svelte index 7fb7559be7..71035b90ea 100644 --- a/web/src/routes/admin/system-settings/+page.svelte +++ b/web/src/routes/admin/system-settings/+page.svelte @@ -215,7 +215,7 @@ ); - + {#snippet buttons()}