From 8a481e2ea14b134d1165e768b2618f49b1e0dbd1 Mon Sep 17 00:00:00 2001 From: bo0tzz Date: Fri, 24 Jan 2025 16:08:01 +0100 Subject: [PATCH 001/395] docs: add FAQ about app update approval (#15599) --- docs/docs/FAQ.mdx | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/docs/FAQ.mdx b/docs/docs/FAQ.mdx index c605c564cd..4cd3717e84 100644 --- a/docs/docs/FAQ.mdx +++ b/docs/docs/FAQ.mdx @@ -62,6 +62,10 @@ Instead of these experimental features, we recommend using the URL switching fea We are not actively developing these features and will not be able to provide support, but welcome contributions to improve them. Please discuss any large PRs with our dev team to ensure your time is not wasted. +### Why isn't the mobile app updated yet? + +The app stores can take a few days to approve new builds of the app. If you're impatient, android APKs can be downloaded from the GitHub releases. + --- ## Assets From 19740a35603156262087c090588c0d9dce89e8ad Mon Sep 17 00:00:00 2001 From: Alex Date: Fri, 24 Jan 2025 09:18:26 -0600 Subject: [PATCH 002/395] fix(web): neon artifacts (#15582) --- web/src/app.css | 2 +- web/src/lib/components/layouts/AuthPageLayout.svelte | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/web/src/app.css b/web/src/app.css index 7a547d3504..1127b60624 100644 --- a/web/src/app.css +++ b/web/src/app.css @@ -15,7 +15,7 @@ /* dark */ --immich-dark-primary: 172 203 250; - --immich-dark-bg: 0 0 0; + --immich-dark-bg: 10 10 10; --immich-dark-fg: 229 231 235; --immich-dark-gray: 33 33 33; --immich-dark-error: 211 47 47; diff --git a/web/src/lib/components/layouts/AuthPageLayout.svelte b/web/src/lib/components/layouts/AuthPageLayout.svelte index f4532902c2..51186f83b2 100644 --- a/web/src/lib/components/layouts/AuthPageLayout.svelte +++ b/web/src/lib/components/layouts/AuthPageLayout.svelte @@ -1,7 +1,7 @@
-
- Immich logo -
+
+ Immich logo +
From 6c95eb22b72754a5ad5c5d1c95acf66c8fc80a80 Mon Sep 17 00:00:00 2001 From: Alex Date: Fri, 24 Jan 2025 11:27:33 -0600 Subject: [PATCH 005/395] fix(mobile): full refresh doesn't get albums (#15560) --- mobile/lib/pages/photos/photos.page.dart | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/mobile/lib/pages/photos/photos.page.dart b/mobile/lib/pages/photos/photos.page.dart index 9e15b0193e..845de40ee7 100644 --- a/mobile/lib/pages/photos/photos.page.dart +++ b/mobile/lib/pages/photos/photos.page.dart @@ -83,11 +83,18 @@ class PhotosPage extends HookConsumerWidget { Future refreshAssets() async { final fullRefresh = refreshCount.value > 0; - await ref.read(assetProvider.notifier).getAllAsset(clear: fullRefresh); + if (fullRefresh) { + Future.wait([ + ref.read(assetProvider.notifier).getAllAsset(clear: true), + ref.read(albumProvider.notifier).refreshRemoteAlbums(), + ]); + // refresh was forced: user requested another refresh within 2 seconds refreshCount.value = 0; } else { + await ref.read(assetProvider.notifier).getAllAsset(clear: false); + refreshCount.value++; // set counter back to 0 if user does not request refresh again Timer(const Duration(seconds: 4), () => refreshCount.value = 0); From 61bc24d7eacca0ae5f54b6ceb9f13ad93f7ff41e Mon Sep 17 00:00:00 2001 From: Alex Date: Fri, 24 Jan 2025 11:28:00 -0600 Subject: [PATCH 006/395] chore(mobile): post release task (#15581) --- mobile/ios/Runner.xcodeproj/project.pbxproj | 12 ++++++------ mobile/ios/Runner/Info.plist | 4 ++-- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/mobile/ios/Runner.xcodeproj/project.pbxproj b/mobile/ios/Runner.xcodeproj/project.pbxproj index 10133cc330..d7d24a9fa9 100644 --- a/mobile/ios/Runner.xcodeproj/project.pbxproj +++ b/mobile/ios/Runner.xcodeproj/project.pbxproj @@ -541,7 +541,7 @@ CODE_SIGN_ENTITLEMENTS = Runner/RunnerProfile.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 187; + CURRENT_PROJECT_VERSION = 189; CUSTOM_GROUP_ID = group.app.immich.share; DEVELOPMENT_TEAM = 2F67MQ8R79; ENABLE_BITCODE = NO; @@ -685,7 +685,7 @@ CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 187; + CURRENT_PROJECT_VERSION = 189; CUSTOM_GROUP_ID = group.app.immich.share; DEVELOPMENT_TEAM = 2F67MQ8R79; ENABLE_BITCODE = NO; @@ -715,7 +715,7 @@ CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 187; + CURRENT_PROJECT_VERSION = 189; CUSTOM_GROUP_ID = group.app.immich.share; DEVELOPMENT_TEAM = 2F67MQ8R79; ENABLE_BITCODE = NO; @@ -748,7 +748,7 @@ CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; + CURRENT_PROJECT_VERSION = 189; CUSTOM_GROUP_ID = group.app.immich.share; DEVELOPMENT_TEAM = 2F67MQ8R79; ENABLE_USER_SCRIPT_SANDBOXING = YES; @@ -791,7 +791,7 @@ CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; + CURRENT_PROJECT_VERSION = 189; CUSTOM_GROUP_ID = group.app.immich.share; DEVELOPMENT_TEAM = 2F67MQ8R79; ENABLE_USER_SCRIPT_SANDBOXING = YES; @@ -831,7 +831,7 @@ CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; + CURRENT_PROJECT_VERSION = 189; CUSTOM_GROUP_ID = group.app.immich.share; DEVELOPMENT_TEAM = 2F67MQ8R79; ENABLE_USER_SCRIPT_SANDBOXING = YES; diff --git a/mobile/ios/Runner/Info.plist b/mobile/ios/Runner/Info.plist index be5ec5d9d7..a3b34a9bcd 100644 --- a/mobile/ios/Runner/Info.plist +++ b/mobile/ios/Runner/Info.plist @@ -78,7 +78,7 @@ CFBundlePackageType APPL CFBundleShortVersionString - 1.124.0 + 1.125.1 CFBundleSignature ???? CFBundleURLTypes @@ -93,7 +93,7 @@ CFBundleVersion - 187 + 189 FLTEnableImpeller ITSAppUsesNonExemptEncryption From ec7ab209f3a9c56fb9a32c02496a384187f23d86 Mon Sep 17 00:00:00 2001 From: Alex Date: Fri, 24 Jan 2025 11:38:59 -0600 Subject: [PATCH 007/395] fix(server): link live photos (#15612) * fix(server): link live photos * chore: sql * formatting --- server/src/repositories/asset.repository.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/src/repositories/asset.repository.ts b/server/src/repositories/asset.repository.ts index 9c506197d6..6bb253d183 100644 --- a/server/src/repositories/asset.repository.ts +++ b/server/src/repositories/asset.repository.ts @@ -430,9 +430,9 @@ export class AssetRepository implements IAssetRepository { findLivePhotoMatch(options: LivePhotoSearchOptions): Promise { const { ownerId, otherAssetId, livePhotoCID, type } = options; - return this.db .selectFrom('assets') + .select('assets.id') .innerJoin('exif', 'assets.id', 'exif.assetId') .where('id', '!=', asUuid(otherAssetId)) .where('ownerId', '=', asUuid(ownerId)) From ede9c99adbf1ee11bee565b6086ea6a9479b2c79 Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Fri, 24 Jan 2025 12:39:06 -0500 Subject: [PATCH 008/395] fix: demo login page (#15616) --- web/src/routes/auth/login/+page.svelte | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/web/src/routes/auth/login/+page.svelte b/web/src/routes/auth/login/+page.svelte index e60cd5f145..c3d01b3c56 100644 --- a/web/src/routes/auth/login/+page.svelte +++ b/web/src/routes/auth/login/+page.svelte @@ -116,11 +116,11 @@ {/if} - + - + From a6ace5151c11e6d85293188c30c08e54601b3879 Mon Sep 17 00:00:00 2001 From: Mert <101130780+mertalev@users.noreply.github.com> Date: Fri, 24 Jan 2025 12:42:39 -0500 Subject: [PATCH 009/395] fix(server): no exif metadata in the deduplication utility (#15585) add exif to `getDuplicates` --- server/src/queries/asset.repository.sql | 23 ++++++++++++++------ server/src/repositories/asset.repository.ts | 24 +++++++++++++++------ 2 files changed, 33 insertions(+), 14 deletions(-) diff --git a/server/src/queries/asset.repository.sql b/server/src/queries/asset.repository.sql index 67f9f39c84..948f7dd114 100644 --- a/server/src/queries/asset.repository.sql +++ b/server/src/queries/asset.repository.sql @@ -306,17 +306,26 @@ order by with "duplicates" as ( select - "duplicateId", - jsonb_agg("assets") as "assets" + "assets"."duplicateId", + jsonb_agg("asset") as "assets" from "assets" + left join lateral ( + select + "assets".*, + "exif" as "exifInfo" + from + "exif" + where + "exif"."assetId" = "assets"."id" + ) as "asset" on true where - "ownerId" = $1::uuid - and "duplicateId" is not null - and "deletedAt" is null - and "isVisible" = $2 + "assets"."ownerId" = $1::uuid + and "assets"."duplicateId" is not null + and "assets"."deletedAt" is null + and "assets"."isVisible" = $2 group by - "duplicateId" + "assets"."duplicateId" ), "unique" as ( select diff --git a/server/src/repositories/asset.repository.ts b/server/src/repositories/asset.repository.ts index 6bb253d183..b39781209e 100644 --- a/server/src/repositories/asset.repository.ts +++ b/server/src/repositories/asset.repository.ts @@ -677,13 +677,23 @@ export class AssetRepository implements IAssetRepository { .with('duplicates', (qb) => qb .selectFrom('assets') - .select('duplicateId') - .select((eb) => eb.fn('jsonb_agg', [eb.table('assets')]).as('assets')) - .where('ownerId', '=', asUuid(userId)) - .where('duplicateId', 'is not', null) - .where('deletedAt', 'is', null) - .where('isVisible', '=', true) - .groupBy('duplicateId'), + .leftJoinLateral( + (qb) => + qb + .selectFrom('exif') + .selectAll('assets') + .select((eb) => eb.table('exif').as('exifInfo')) + .whereRef('exif.assetId', '=', 'assets.id') + .as('asset'), + (join) => join.onTrue(), + ) + .select('assets.duplicateId') + .select((eb) => eb.fn('jsonb_agg', [eb.table('asset')]).as('assets')) + .where('assets.ownerId', '=', asUuid(userId)) + .where('assets.duplicateId', 'is not', null) + .where('assets.deletedAt', 'is', null) + .where('assets.isVisible', '=', true) + .groupBy('assets.duplicateId'), ) .with('unique', (qb) => qb From c0210bd6c08f5dedb57c27c023792ab2dd8d2add Mon Sep 17 00:00:00 2001 From: Matthew Momjian <50788000+mmomjian@users.noreply.github.com> Date: Fri, 24 Jan 2025 12:47:01 -0500 Subject: [PATCH 010/395] fix(mobile): translation (no /api, experimental features) (#15600) * initial /api removal * translations /api * experimental features * japanese url update --------- Co-authored-by: Alex --- docs/docs/install/script.md | 2 +- docs/docs/partials/_mobile-app-login.md | 2 +- install.sh | 2 +- mobile/assets/i18n/ar-JO.json | 6 +++--- mobile/assets/i18n/ca-CA.json | 2 +- mobile/assets/i18n/cs-CZ.json | 2 +- mobile/assets/i18n/da-DK.json | 2 +- mobile/assets/i18n/de-DE.json | 2 +- mobile/assets/i18n/el-GR.json | 2 +- mobile/assets/i18n/en-US.json | 10 +++++----- mobile/assets/i18n/es-ES.json | 2 +- mobile/assets/i18n/es-MX.json | 2 +- mobile/assets/i18n/es-PE.json | 2 +- mobile/assets/i18n/es-US.json | 2 +- mobile/assets/i18n/fi-FI.json | 2 +- mobile/assets/i18n/fr-CA.json | 2 +- mobile/assets/i18n/fr-FR.json | 2 +- mobile/assets/i18n/he-IL.json | 6 +++--- mobile/assets/i18n/hi-IN.json | 2 +- mobile/assets/i18n/hu-HU.json | 4 ++-- mobile/assets/i18n/id-ID.json | 2 +- mobile/assets/i18n/it-IT.json | 2 +- mobile/assets/i18n/ja-JP.json | 4 ++-- mobile/assets/i18n/ko-KR.json | 2 +- mobile/assets/i18n/lt-LT.json | 2 +- mobile/assets/i18n/lv-LV.json | 2 +- mobile/assets/i18n/mn-MN.json | 2 +- mobile/assets/i18n/nb-NO.json | 2 +- mobile/assets/i18n/nl-NL.json | 2 +- mobile/assets/i18n/pl-PL.json | 2 +- mobile/assets/i18n/pt-BR.json | 2 +- mobile/assets/i18n/pt-PT.json | 2 +- mobile/assets/i18n/ro-RO.json | 2 +- mobile/assets/i18n/ru-RU.json | 2 +- mobile/assets/i18n/sk-SK.json | 2 +- mobile/assets/i18n/sl-SI.json | 2 +- mobile/assets/i18n/sr-Cyrl.json | 2 +- mobile/assets/i18n/sr-Latn.json | 2 +- mobile/assets/i18n/sv-FI.json | 2 +- mobile/assets/i18n/sv-SE.json | 2 +- mobile/assets/i18n/th-TH.json | 2 +- mobile/assets/i18n/tr-TR.json | 2 +- mobile/assets/i18n/uk-UA.json | 2 +- mobile/assets/i18n/vi-VN.json | 2 +- mobile/assets/i18n/zh-CN.json | 4 ++-- mobile/assets/i18n/zh-Hans.json | 4 ++-- mobile/assets/i18n/zh-TW.json | 4 ++-- .../networking_settings/local_network_preference.dart | 4 ++-- 48 files changed, 62 insertions(+), 62 deletions(-) diff --git a/docs/docs/install/script.md b/docs/docs/install/script.md index a515f2b628..93d1fb166c 100644 --- a/docs/docs/install/script.md +++ b/docs/docs/install/script.md @@ -27,7 +27,7 @@ The script will perform the following actions: 1. Download [docker-compose.yml](https://github.com/immich-app/immich/releases/latest/download/docker-compose.yml), and the [.env](https://github.com/immich-app/immich/releases/latest/download/example.env) file from the main branch of the [repository](https://github.com/immich-app/immich). 2. Start the containers. -The web application will be available at `http://:2283`, and the server URL for the mobile app will be `http://:2283/api` +The web application and mobile app will be available at `http://:2283` The directory which is used to store the library files is `./immich-app` relative to the current directory. diff --git a/docs/docs/partials/_mobile-app-login.md b/docs/docs/partials/_mobile-app-login.md index bfd15ac5d0..3dc8f30933 100644 --- a/docs/docs/partials/_mobile-app-login.md +++ b/docs/docs/partials/_mobile-app-login.md @@ -1,3 +1,3 @@ -Login to the mobile app with the server endpoint URL at `http://:2283/api` +Login to the mobile app with the server endpoint URL at `http://:2283` diff --git a/install.sh b/install.sh index e9c65b3283..d6569f736a 100755 --- a/install.sh +++ b/install.sh @@ -53,7 +53,7 @@ show_friendly_message() { ip_address=$(hostname -I | awk '{print $1}') cat < Date: Fri, 24 Jan 2025 18:47:54 +0100 Subject: [PATCH 011/395] fix(mobile): deletion of single assets (#15597) fix: set asset in currentassetprovider on image load Co-authored-by: Alex --- mobile/lib/pages/common/gallery_viewer.page.dart | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/mobile/lib/pages/common/gallery_viewer.page.dart b/mobile/lib/pages/common/gallery_viewer.page.dart index 7e47c1d087..f51be027f5 100644 --- a/mobile/lib/pages/common/gallery_viewer.page.dart +++ b/mobile/lib/pages/common/gallery_viewer.page.dart @@ -262,6 +262,11 @@ class GalleryViewerPage extends HookConsumerWidget { PhotoViewGalleryPageOptions buildAsset(BuildContext context, int index) { var newAsset = loadAsset(index); + + WidgetsBinding.instance.addPostFrameCallback((_) { + ref.read(currentAssetProvider.notifier).set(newAsset); + }); + final stackId = newAsset.stackId; if (stackId != null && currentIndex.value == index) { final stackElements = From 9d8072b994982d9389da703969f3786331c7c1ab Mon Sep 17 00:00:00 2001 From: Alex Date: Fri, 24 Jan 2025 11:54:53 -0600 Subject: [PATCH 012/395] fix(server): failed to get albums with archived assets (#15611) * fix(mobile): failed to get albums with archived assets * sql --- server/src/queries/album.repository.sql | 3 +-- server/src/repositories/album.repository.ts | 1 - 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/server/src/queries/album.repository.sql b/server/src/queries/album.repository.sql index 8f17146633..48dc4dda4e 100644 --- a/server/src/queries/album.repository.sql +++ b/server/src/queries/album.repository.sql @@ -98,7 +98,6 @@ select where "albums_assets_assets"."albumsId" = "albums"."id" and "assets"."deletedAt" is null - and "assets"."isArchived" = $1 order by "assets"."fileCreatedAt" desc ) as "asset" @@ -106,7 +105,7 @@ select from "albums" where - "albums"."id" = $2 + "albums"."id" = $1 and "albums"."deletedAt" is null -- AlbumRepository.getByAssetId diff --git a/server/src/repositories/album.repository.ts b/server/src/repositories/album.repository.ts index e32a53e82d..d3b696169b 100644 --- a/server/src/repositories/album.repository.ts +++ b/server/src/repositories/album.repository.ts @@ -63,7 +63,6 @@ const withAssets = (eb: ExpressionBuilder) => { .innerJoin('albums_assets_assets', 'albums_assets_assets.assetsId', 'assets.id') .whereRef('albums_assets_assets.albumsId', '=', 'albums.id') .where('assets.deletedAt', 'is', null) - .where('assets.isArchived', '=', false) .orderBy('assets.fileCreatedAt', 'desc') .as('asset'), ) From d4a9eed4a180e6a17f6c5e107175f8db4f995a12 Mon Sep 17 00:00:00 2001 From: Alex Date: Fri, 24 Jan 2025 12:11:22 -0600 Subject: [PATCH 013/395] fix(server): migration mentions public schema (#15622) --- server/src/migrations/1734574016301-AddTimeBucketIndices.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/server/src/migrations/1734574016301-AddTimeBucketIndices.ts b/server/src/migrations/1734574016301-AddTimeBucketIndices.ts index 71e085ee18..2162a713fc 100644 --- a/server/src/migrations/1734574016301-AddTimeBucketIndices.ts +++ b/server/src/migrations/1734574016301-AddTimeBucketIndices.ts @@ -3,10 +3,10 @@ import { MigrationInterface, QueryRunner } from 'typeorm'; export class AddTimeBucketIndices1734574016301 implements MigrationInterface { public async up(queryRunner: QueryRunner): Promise { await queryRunner.query( - `CREATE INDEX idx_local_date_time_month ON public.assets ((date_trunc('MONTH', "localDateTime" at time zone 'UTC') at time zone 'UTC'))`, + `CREATE INDEX idx_local_date_time_month ON assets ((date_trunc('MONTH', "localDateTime" at time zone 'UTC') at time zone 'UTC'))`, ); await queryRunner.query( - `CREATE INDEX idx_local_date_time ON public.assets ((("localDateTime" at time zone 'UTC')::date))`, + `CREATE INDEX idx_local_date_time ON assets ((("localDateTime" at time zone 'UTC')::date))`, ); await queryRunner.query(`DROP INDEX "IDX_day_of_month"`); await queryRunner.query(`DROP INDEX "IDX_month"`); From f5a3d7ba232d77ab55b7202deb15404395ef7422 Mon Sep 17 00:00:00 2001 From: Alex Date: Fri, 24 Jan 2025 12:47:29 -0600 Subject: [PATCH 014/395] fix(mobile): failed to load ga/gl locale (#15623) --- localizely.yml | 4 +- mobile/assets/i18n/ga.json | 673 +++++++++++++++++++++++++++++++++++++ mobile/assets/i18n/gl.json | 673 +++++++++++++++++++++++++++++++++++++ 3 files changed, 1348 insertions(+), 2 deletions(-) create mode 100644 mobile/assets/i18n/ga.json create mode 100644 mobile/assets/i18n/gl.json diff --git a/localizely.yml b/localizely.yml index 2a9b95464f..0b8309ea6d 100644 --- a/localizely.yml +++ b/localizely.yml @@ -97,8 +97,8 @@ download: - file: mobile/assets/i18n/id-ID.json locale_code: id-ID - file: mobile/assets/i18n/gl.json - locale_code: gl-ES + locale_code: gl - file: mobile/assets/i18n/ga.json - locale_code: ga-IE + locale_code: ga - file: mobile/assets/i18n/tr-TR.json locale_code: tr-TR diff --git a/mobile/assets/i18n/ga.json b/mobile/assets/i18n/ga.json new file mode 100644 index 0000000000..9450b4b44f --- /dev/null +++ b/mobile/assets/i18n/ga.json @@ -0,0 +1,673 @@ +{ + "action_common_back": "Back", + "action_common_cancel": "Cancel", + "action_common_clear": "Clear", + "action_common_confirm": "Confirm", + "action_common_save": "Save", + "action_common_select": "Select", + "action_common_update": "Update", + "add_a_name": "Add a name", + "add_endpoint": "Add endpoint", + "add_to_album_bottom_sheet_added": "Added to {album}", + "add_to_album_bottom_sheet_already_exists": "Already in {album}", + "advanced_settings_log_level_title": "Log level: {}", + "advanced_settings_prefer_remote_subtitle": "Some devices are painfully slow to load thumbnails from assets on the device. Activate this setting to load remote images instead.", + "advanced_settings_prefer_remote_title": "Prefer remote images", + "advanced_settings_proxy_headers_subtitle": "Define proxy headers Immich should send with each network request", + "advanced_settings_proxy_headers_title": "Proxy Headers", + "advanced_settings_self_signed_ssl_subtitle": "Skips SSL certificate verification for the server endpoint. Required for self-signed certificates.", + "advanced_settings_self_signed_ssl_title": "Allow self-signed SSL certificates (EXPERIMENTAL)", + "advanced_settings_tile_subtitle": "Advanced user's settings", + "advanced_settings_tile_title": "Advanced", + "advanced_settings_troubleshooting_subtitle": "Enable additional features for troubleshooting", + "advanced_settings_troubleshooting_title": "Troubleshooting", + "album_info_card_backup_album_excluded": "EXCLUDED", + "album_info_card_backup_album_included": "INCLUDED", + "albums": "Albums", + "album_thumbnail_card_item": "1 item", + "album_thumbnail_card_items": "{} items", + "album_thumbnail_card_shared": " · Shared", + "album_thumbnail_owned": "Owned", + "album_thumbnail_shared_by": "Shared by {}", + "album_viewer_appbar_delete_confirm": "Are you sure you want to delete this album from your account?", + "album_viewer_appbar_share_delete": "Delete album", + "album_viewer_appbar_share_err_delete": "Failed to delete album", + "album_viewer_appbar_share_err_leave": "Failed to leave album", + "album_viewer_appbar_share_err_remove": "There are problems in removing assets from album", + "album_viewer_appbar_share_err_title": "Failed to change album title", + "album_viewer_appbar_share_leave": "Leave album", + "album_viewer_appbar_share_remove": "Remove from album", + "album_viewer_appbar_share_to": "Share To", + "album_viewer_page_share_add_users": "Add users", + "all": "All", + "all_people_page_title": "People", + "all_videos_page_title": "Videos", + "app_bar_signout_dialog_content": "Are you sure you want to sign out?", + "app_bar_signout_dialog_ok": "Yes", + "app_bar_signout_dialog_title": "Sign out", + "archived": "Archived", + "archive_page_no_archived_assets": "No archived assets found", + "archive_page_title": "Archive ({})", + "asset_action_delete_err_read_only": "Cannot delete read only asset(s), skipping", + "asset_action_share_err_offline": "Cannot fetch offline asset(s), skipping", + "asset_list_group_by_sub_title": "Group by", + "asset_list_layout_settings_dynamic_layout_title": "Dynamic layout", + "asset_list_layout_settings_group_automatically": "Automatic", + "asset_list_layout_settings_group_by": "Group assets by", + "asset_list_layout_settings_group_by_month": "Month", + "asset_list_layout_settings_group_by_month_day": "Month + day", + "asset_list_layout_sub_title": "Layout", + "asset_list_settings_subtitle": "Photo grid layout settings", + "asset_list_settings_title": "Photo Grid", + "asset_restored_successfully": "Asset restored successfully", + "assets_deleted_permanently": "{} asset(s) deleted permanently", + "assets_deleted_permanently_from_server": "{} asset(s) deleted permanently from the Immich server", + "assets_removed_permanently_from_device": "{} asset(s) removed permanently from your device", + "assets_restored_successfully": "{} asset(s) restored successfully", + "assets_trashed": "{} asset(s) trashed", + "assets_trashed_from_server": "{} asset(s) trashed from the Immich server", + "asset_viewer_settings_subtitle": "Manage your gallery viewer settings", + "asset_viewer_settings_title": "Asset Viewer", + "automatic_endpoint_switching_subtitle": "Connect locally over designated Wi-Fi when available and use alternative connections elsewhere", + "automatic_endpoint_switching_title": "Automatic URL switching", + "background_location_permission": "Background location permission", + "background_location_permission_content": "In order to switch networks when running in the background, Immich must *always* have precise location access so the app can read the Wi-Fi network's name", + "backup_album_selection_page_albums_device": "Albums on device ({})", + "backup_album_selection_page_albums_tap": "Tap to include, double tap to exclude", + "backup_album_selection_page_assets_scatter": "Assets can scatter across multiple albums. Thus, albums can be included or excluded during the backup process.", + "backup_album_selection_page_select_albums": "Select albums", + "backup_album_selection_page_selection_info": "Selection Info", + "backup_album_selection_page_total_assets": "Total unique assets", + "backup_all": "All", + "backup_background_service_backup_failed_message": "Failed to backup assets. Retrying…", + "backup_background_service_connection_failed_message": "Failed to connect to the server. Retrying…", + "backup_background_service_current_upload_notification": "Uploading {}", + "backup_background_service_default_notification": "Checking for new assets…", + "backup_background_service_error_title": "Backup error", + "backup_background_service_in_progress_notification": "Backing up your assets…", + "backup_background_service_upload_failure_notification": "Failed to upload {}", + "backup_controller_page_albums": "Backup Albums", + "backup_controller_page_background_app_refresh_disabled_content": "Enable background app refresh in Settings > General > Background App Refresh in order to use background backup.", + "backup_controller_page_background_app_refresh_disabled_title": "Background app refresh disabled", + "backup_controller_page_background_app_refresh_enable_button_text": "Go to settings", + "backup_controller_page_background_battery_info_link": "Show me how", + "backup_controller_page_background_battery_info_message": "For the best background backup experience, please disable any battery optimizations restricting background activity for Immich.\n\nSince this is device-specific, please lookup the required information for your device manufacturer.", + "backup_controller_page_background_battery_info_ok": "OK", + "backup_controller_page_background_battery_info_title": "Battery optimizations", + "backup_controller_page_background_charging": "Only while charging", + "backup_controller_page_background_configure_error": "Failed to configure the background service", + "backup_controller_page_background_delay": "Delay new assets backup: {}", + "backup_controller_page_background_description": "Turn on the background service to automatically backup any new assets without needing to open the app", + "backup_controller_page_background_is_off": "Automatic background backup is off", + "backup_controller_page_background_is_on": "Automatic background backup is on", + "backup_controller_page_background_turn_off": "Turn off background service", + "backup_controller_page_background_turn_on": "Turn on background service", + "backup_controller_page_background_wifi": "Only on WiFi", + "backup_controller_page_backup": "Backup", + "backup_controller_page_backup_selected": "Selected: ", + "backup_controller_page_backup_sub": "Backed up photos and videos", + "backup_controller_page_cancel": "Cancel", + "backup_controller_page_created": "Created on: {}", + "backup_controller_page_desc_backup": "Turn on foreground backup to automatically upload new assets to the server when opening the app.", + "backup_controller_page_excluded": "Excluded: ", + "backup_controller_page_failed": "Failed ({})", + "backup_controller_page_filename": "File name: {} [{}]", + "backup_controller_page_id": "ID: {}", + "backup_controller_page_info": "Backup Information", + "backup_controller_page_none_selected": "None selected", + "backup_controller_page_remainder": "Remainder", + "backup_controller_page_remainder_sub": "Remaining photos and videos to back up from selection", + "backup_controller_page_select": "Select", + "backup_controller_page_server_storage": "Server Storage", + "backup_controller_page_start_backup": "Start Backup", + "backup_controller_page_status_off": "Automatic foreground backup is off", + "backup_controller_page_status_on": "Automatic foreground backup is on", + "backup_controller_page_storage_format": "{} of {} used", + "backup_controller_page_to_backup": "Albums to be backed up", + "backup_controller_page_total": "Total", + "backup_controller_page_total_sub": "All unique photos and videos from selected albums", + "backup_controller_page_turn_off": "Turn off foreground backup", + "backup_controller_page_turn_on": "Turn on foreground backup", + "backup_controller_page_uploading_file_info": "Uploading file info", + "backup_err_only_album": "Cannot remove the only album", + "backup_info_card_assets": "assets", + "backup_manual_cancelled": "Cancelled", + "backup_manual_failed": "Failed", + "backup_manual_in_progress": "Upload already in progress. Try after sometime", + "backup_manual_success": "Success", + "backup_manual_title": "Upload status", + "backup_options_page_title": "Backup options", + "backup_setting_subtitle": "Manage background and foreground upload settings", + "cache_settings_album_thumbnails": "Library page thumbnails ({} assets)", + "cache_settings_clear_cache_button": "Clear cache", + "cache_settings_clear_cache_button_title": "Clears the app's cache. This will significantly impact the app's performance until the cache has rebuilt.", + "cache_settings_duplicated_assets_clear_button": "CLEAR", + "cache_settings_duplicated_assets_subtitle": "Photos and videos that are black listed by the app", + "cache_settings_duplicated_assets_title": "Duplicated Assets ({})", + "cache_settings_image_cache_size": "Image cache size ({} assets)", + "cache_settings_statistics_album": "Library thumbnails", + "cache_settings_statistics_assets": "{} assets ({})", + "cache_settings_statistics_full": "Full images", + "cache_settings_statistics_shared": "Shared album thumbnails", + "cache_settings_statistics_thumbnail": "Thumbnails", + "cache_settings_statistics_title": "Cache usage", + "cache_settings_subtitle": "Control the caching behaviour of the Immich mobile application", + "cache_settings_thumbnail_size": "Thumbnail cache size ({} assets)", + "cache_settings_tile_subtitle": "Control the local storage behaviour", + "cache_settings_tile_title": "Local Storage", + "cache_settings_title": "Caching Settings", + "cancel": "Cancel", + "canceled": "Canceled", + "change_display_order": "Change display order", + "change_password_form_confirm_password": "Confirm Password", + "change_password_form_description": "Hi {name},\n\nThis is either the first time you are signing into the system or a request has been made to change your password. Please enter the new password below.", + "change_password_form_new_password": "New Password", + "change_password_form_password_mismatch": "Passwords do not match", + "change_password_form_reenter_new_password": "Re-enter New Password", + "check_corrupt_asset_backup": "Check for corrupt asset backups", + "check_corrupt_asset_backup_button": "Perform check", + "check_corrupt_asset_backup_description": "Run this check only over Wi-Fi and once all assets have been backed-up. The procedure might take a few minutes.", + "client_cert_dialog_msg_confirm": "OK", + "client_cert_enter_password": "Enter Password", + "client_cert_import": "Import", + "client_cert_import_success_msg": "Client certificate is imported", + "client_cert_invalid_msg": "Invalid certificate file or wrong password", + "client_cert_remove": "Remove", + "client_cert_remove_msg": "Client certificate is removed", + "client_cert_subtitle": "Supports PKCS12 (.p12, .pfx) format only. Certificate Import/Remove is available only before login", + "client_cert_title": "SSL Client Certificate (EXPERIMENTAL)", + "common_add_to_album": "Add to album", + "common_change_password": "Change Password", + "common_create_new_album": "Create new album", + "common_server_error": "Please check your network connection, make sure the server is reachable and app/server versions are compatible.", + "common_shared": "Shared", + "completed": "Completed", + "contextual_search": "Sunrise on the beach", + "control_bottom_app_bar_add_to_album": "Add to album", + "control_bottom_app_bar_album_info": "{} items", + "control_bottom_app_bar_album_info_shared": "{} items · Shared", + "control_bottom_app_bar_archive": "Archive", + "control_bottom_app_bar_create_new_album": "Create new album", + "control_bottom_app_bar_delete": "Delete", + "control_bottom_app_bar_delete_from_immich": "Delete from Immich", + "control_bottom_app_bar_delete_from_local": "Delete from device", + "control_bottom_app_bar_download": "Download", + "control_bottom_app_bar_edit": "Edit", + "control_bottom_app_bar_edit_location": "Edit Location", + "control_bottom_app_bar_edit_time": "Edit Date & Time", + "control_bottom_app_bar_favorite": "Favorite", + "control_bottom_app_bar_share": "Share", + "control_bottom_app_bar_share_to": "Share To", + "control_bottom_app_bar_stack": "Stack", + "control_bottom_app_bar_trash_from_immich": "Move to Trash", + "control_bottom_app_bar_unarchive": "Unarchive", + "control_bottom_app_bar_unfavorite": "Unfavorite", + "control_bottom_app_bar_upload": "Upload", + "create_album": "Create album", + "create_album_page_untitled": "Untitled", + "create_new": "CREATE NEW", + "create_shared_album_page_create": "Create", + "create_shared_album_page_share": "Share", + "create_shared_album_page_share_add_assets": "ADD ASSETS", + "create_shared_album_page_share_select_photos": "Select Photos", + "crop": "Crop", + "curated_location_page_title": "Places", + "curated_object_page_title": "Things", + "current_server_address": "Current server address", + "daily_title_text_date": "E, MMM dd", + "daily_title_text_date_year": "E, MMM dd, yyyy", + "date_format": "E, LLL d, y • h:mm a", + "delete_dialog_alert": "These items will be permanently deleted from Immich and from your device", + "delete_dialog_alert_local": "These items will be permanently removed from your device but still be available on the Immich server", + "delete_dialog_alert_local_non_backed_up": "Some of the items aren't backed up to Immich and will be permanently removed from your device", + "delete_dialog_alert_remote": "These items will be permanently deleted from the Immich server", + "delete_dialog_cancel": "Cancel", + "delete_dialog_ok": "Delete", + "delete_dialog_ok_force": "Delete Anyway", + "delete_dialog_title": "Delete Permanently", + "delete_local_dialog_ok_backed_up_only": "Delete Backed Up Only", + "delete_local_dialog_ok_force": "Delete Anyway", + "delete_shared_link_dialog_content": "Are you sure you want to delete this shared link?", + "delete_shared_link_dialog_title": "Delete Shared Link", + "description_input_hint_text": "Add description...", + "description_input_submit_error": "Error updating description, check the log for more details", + "download_canceled": "Download canceled", + "download_complete": "Download complete", + "download_enqueue": "Download enqueued", + "download_error": "Download Error", + "download_failed": "Download failed", + "download_filename": "file: {}", + "download_finished": "Download finished", + "downloading": "Downloading...", + "downloading_media": "Downloading media", + "download_notfound": "Download not found", + "download_paused": "Download paused", + "download_started": "Download started", + "download_sucess": "Download success", + "download_sucess_android": "The media has been downloaded to DCIM/Immich", + "download_waiting_to_retry": "Waiting to retry", + "edit_date_time_dialog_date_time": "Date and Time", + "edit_date_time_dialog_timezone": "Timezone", + "edit_image_title": "Edit", + "edit_location_dialog_title": "Location", + "end_date": "End date", + "enqueued": "Enqueued", + "enter_wifi_name": "Enter WiFi name", + "error_change_sort_album": "Failed to change album sort order", + "error_saving_image": "Error: {}", + "exif_bottom_sheet_description": "Add Description...", + "exif_bottom_sheet_details": "DETAILS", + "exif_bottom_sheet_location": "LOCATION", + "exif_bottom_sheet_location_add": "Add a location", + "exif_bottom_sheet_people": "PEOPLE", + "exif_bottom_sheet_person_add_person": "Add name", + "experimental_settings_new_asset_list_subtitle": "Work in progress", + "experimental_settings_new_asset_list_title": "Enable experimental photo grid", + "experimental_settings_subtitle": "Use at your own risk!", + "experimental_settings_title": "Experimental", + "external_network": "External network", + "external_network_sheet_info": "When not on the preferred WiFi network, the app will connect to the server through the first of the below URLs it can reach, starting from top to bottom", + "failed": "Failed", + "favorites": "Favorites", + "favorites_page_no_favorites": "No favorite assets found", + "favorites_page_title": "Favorites", + "filename_search": "File name or extension", + "filter": "Filter", + "get_wifiname_error": "Could not get Wi-Fi name. Make sure you have granted the necessary permissions and are connected to a Wi-Fi network", + "grant_permission": "Grant permission", + "haptic_feedback_switch": "Enable haptic feedback", + "haptic_feedback_title": "Haptic Feedback", + "header_settings_add_header_tip": "Add Header", + "header_settings_field_validator_msg": "Value cannot be empty", + "header_settings_header_name_input": "Header name", + "header_settings_header_value_input": "Header value", + "header_settings_page_title": "Proxy Headers (EXPERIMENTAL)", + "headers_settings_tile_subtitle": "Define proxy headers the app should send with each network request", + "headers_settings_tile_title": "Custom proxy headers", + "home_page_add_to_album_conflicts": "Added {added} assets to album {album}. {failed} assets are already in the album.", + "home_page_add_to_album_err_local": "Can not add local assets to albums yet, skipping", + "home_page_add_to_album_success": "Added {added} assets to album {album}.", + "home_page_album_err_partner": "Can not add partner assets to an album yet, skipping", + "home_page_archive_err_local": "Can not archive local assets yet, skipping", + "home_page_archive_err_partner": "Can not archive partner assets, skipping", + "home_page_building_timeline": "Building the timeline", + "home_page_delete_err_partner": "Can not delete partner assets, skipping", + "home_page_delete_remote_err_local": "Local assets in delete remote selection, skipping", + "home_page_favorite_err_local": "Can not favorite local assets yet, skipping", + "home_page_favorite_err_partner": "Can not favorite partner assets yet, skipping", + "home_page_first_time_notice": "If this is your first time using the app, please make sure to choose a backup album(s) so that the timeline can populate photos and videos in the album(s).", + "home_page_share_err_local": "Can not share local assets via link, skipping", + "home_page_upload_err_limit": "Can only upload a maximum of 30 assets at a time, skipping", + "ignore_icloud_photos": "Ignore iCloud photos", + "ignore_icloud_photos_description": "Photos that are stored on iCloud will not be uploaded to the Immich server", + "image_saved_successfully": "Image saved", + "image_viewer_page_state_provider_download_error": "Download Error", + "image_viewer_page_state_provider_download_started": "Download Started", + "image_viewer_page_state_provider_download_success": "Download Success", + "image_viewer_page_state_provider_share_error": "Share Error", + "invalid_date": "Invalid date", + "invalid_date_format": "Invalid date format", + "library": "Library", + "library_page_albums": "Albums", + "library_page_archive": "Archive", + "library_page_device_albums": "Albums on Device", + "library_page_favorites": "Favorites", + "library_page_new_album": "New album", + "library_page_sharing": "Sharing", + "library_page_sort_asset_count": "Number of assets", + "library_page_sort_created": "Created date", + "library_page_sort_last_modified": "Last modified", + "library_page_sort_most_oldest_photo": "Oldest photo", + "library_page_sort_most_recent_photo": "Most recent photo", + "library_page_sort_title": "Album title", + "local_network": "Local network", + "local_network_sheet_info": "The app will connect to the server through this URL when using the specified Wi-Fi network", + "location_permission": "Location permission", + "location_permission_content": "In order to use the auto-switching feature, Immich needs precise location permission so it can read the current WiFi network's name", + "location_picker_choose_on_map": "Choose on map", + "location_picker_latitude": "Latitude", + "location_picker_latitude_error": "Enter a valid latitude", + "location_picker_latitude_hint": "Enter your latitude here", + "location_picker_longitude": "Longitude", + "location_picker_longitude_error": "Enter a valid longitude", + "location_picker_longitude_hint": "Enter your longitude here", + "login_disabled": "Login has been disabled", + "login_form_api_exception": "API exception. Please check the server URL and try again.", + "login_form_back_button_text": "Back", + "login_form_button_text": "Login", + "login_form_email_hint": "youremail@email.com", + "login_form_endpoint_hint": "http://your-server-ip:port", + "login_form_endpoint_url": "Server Endpoint URL", + "login_form_err_http": "Please specify http:// or https://", + "login_form_err_invalid_email": "Invalid Email", + "login_form_err_invalid_url": "Invalid URL", + "login_form_err_leading_whitespace": "Leading whitespace", + "login_form_err_trailing_whitespace": "Trailing whitespace", + "login_form_failed_get_oauth_server_config": "Error logging using OAuth, check server URL", + "login_form_failed_get_oauth_server_disable": "OAuth feature is not available on this server", + "login_form_failed_login": "Error logging you in, check server URL, email and password", + "login_form_handshake_exception": "There was an Handshake Exception with the server. Enable self-signed certificate support in the settings if you are using a self-signed certificate.", + "login_form_label_email": "Email", + "login_form_label_password": "Password", + "login_form_next_button": "Next", + "login_form_password_hint": "password", + "login_form_save_login": "Stay logged in", + "login_form_server_empty": "Enter a server URL.", + "login_form_server_error": "Could not connect to server.", + "login_password_changed_error": "There was an error updating your password", + "login_password_changed_success": "Password updated successfully", + "map_assets_in_bound": "{} photo", + "map_assets_in_bounds": "{} photos", + "map_cannot_get_user_location": "Cannot get user's location", + "map_location_dialog_cancel": "Cancel", + "map_location_dialog_yes": "Yes", + "map_location_picker_page_use_location": "Use this location", + "map_location_service_disabled_content": "Location service needs to be enabled to display assets from your current location. Do you want to enable it now?", + "map_location_service_disabled_title": "Location Service disabled", + "map_no_assets_in_bounds": "No photos in this area", + "map_no_location_permission_content": "Location permission is needed to display assets from your current location. Do you want to allow it now?", + "map_no_location_permission_title": "Location Permission denied", + "map_settings_dark_mode": "Dark mode", + "map_settings_date_range_option_all": "All", + "map_settings_date_range_option_day": "Past 24 hours", + "map_settings_date_range_option_days": "Past {} days", + "map_settings_date_range_option_year": "Past year", + "map_settings_date_range_option_years": "Past {} years", + "map_settings_dialog_cancel": "Cancel", + "map_settings_dialog_save": "Save", + "map_settings_dialog_title": "Map Settings", + "map_settings_include_show_archived": "Include Archived", + "map_settings_include_show_partners": "Include Partners", + "map_settings_only_relative_range": "Date range", + "map_settings_only_show_favorites": "Show Favorite Only", + "map_settings_theme_settings": "Map Theme", + "map_zoom_to_see_photos": "Zoom out to see photos", + "memories_all_caught_up": "All caught up", + "memories_check_back_tomorrow": "Check back tomorrow for more memories", + "memories_start_over": "Start Over", + "memories_swipe_to_close": "Swipe up to close", + "memories_year_ago": "A year ago", + "memories_years_ago": "{} years ago", + "monthly_title_text_date_format": "MMMM y", + "motion_photos_page_title": "Motion Photos", + "multiselect_grid_edit_date_time_err_read_only": "Cannot edit date of read only asset(s), skipping", + "multiselect_grid_edit_gps_err_read_only": "Cannot edit location of read only asset(s), skipping", + "my_albums": "My albums", + "networking_settings": "Networking", + "networking_subtitle": "Manage the server endpoint settings", + "no_assets_to_show": "No assets to show", + "no_name": "No name", + "notification_permission_dialog_cancel": "Cancel", + "notification_permission_dialog_content": "To enable notifications, go to Settings and select allow.", + "notification_permission_dialog_settings": "Settings", + "notification_permission_list_tile_content": "Grant permission to enable notifications.", + "notification_permission_list_tile_enable_button": "Enable Notifications", + "notification_permission_list_tile_title": "Notification Permission", + "not_selected": "Not selected", + "on_this_device": "On this device", + "partner_list_user_photos": "{user}'s photos", + "partner_list_view_all": "View all", + "partner_page_add_partner": "Add partner", + "partner_page_empty_message": "Your photos are not yet shared with any partner.", + "partner_page_no_more_users": "No more users to add", + "partner_page_partner_add_failed": "Failed to add partner", + "partner_page_select_partner": "Select partner", + "partner_page_shared_to_title": "Shared to", + "partner_page_stop_sharing_content": "{} will no longer be able to access your photos.", + "partner_page_stop_sharing_title": "Stop sharing your photos?", + "partner_page_title": "Partner", + "partners": "Partners", + "paused": "Paused", + "people": "People", + "permission_onboarding_back": "Back", + "permission_onboarding_continue_anyway": "Continue anyway", + "permission_onboarding_get_started": "Get started", + "permission_onboarding_go_to_settings": "Go to settings", + "permission_onboarding_grant_permission": "Grant permission", + "permission_onboarding_log_out": "Log out", + "permission_onboarding_permission_denied": "Permission denied. To use Immich, grant photo and video permissions in Settings.", + "permission_onboarding_permission_granted": "Permission granted! You are all set.", + "permission_onboarding_permission_limited": "Permission limited. To let Immich backup and manage your entire gallery collection, grant photo and video permissions in Settings.", + "permission_onboarding_request": "Immich requires permission to view your photos and videos.", + "places": "Places", + "preferences_settings_subtitle": "Manage the app's preferences", + "preferences_settings_title": "Preferences", + "profile_drawer_app_logs": "Logs", + "profile_drawer_client_out_of_date_major": "Mobile App is out of date. Please update to the latest major version.", + "profile_drawer_client_out_of_date_minor": "Mobile App is out of date. Please update to the latest minor version.", + "profile_drawer_client_server_up_to_date": "Client and Server are up-to-date", + "profile_drawer_documentation": "Documentation", + "profile_drawer_github": "GitHub", + "profile_drawer_server_out_of_date_major": "Server is out of date. Please update to the latest major version.", + "profile_drawer_server_out_of_date_minor": "Server is out of date. Please update to the latest minor version.", + "profile_drawer_settings": "Settings", + "profile_drawer_sign_out": "Sign Out", + "profile_drawer_trash": "Trash", + "recently_added": "Recently added", + "recently_added_page_title": "Recently Added", + "save": "Save", + "save_to_gallery": "Save to gallery", + "scaffold_body_error_occurred": "Error occurred", + "search_albums": "Search albums", + "search_bar_hint": "Search your photos", + "search_filter_apply": "Apply filter", + "search_filter_camera": "Camera", + "search_filter_camera_make": "Make", + "search_filter_camera_model": "Model", + "search_filter_camera_title": "Select camera type", + "search_filter_date": "Date", + "search_filter_date_interval": "{start} to {end}", + "search_filter_date_title": "Select a date range", + "search_filter_display_option_archive": "Archive", + "search_filter_display_option_favorite": "Favorite", + "search_filter_display_option_not_in_album": "Not in album", + "search_filter_display_options": "Display Options", + "search_filter_display_options_title": "Display options", + "search_filter_location": "Location", + "search_filter_location_city": "City", + "search_filter_location_country": "Country", + "search_filter_location_state": "State", + "search_filter_location_title": "Select location", + "search_filter_media_type": "Media Type", + "search_filter_media_type_all": "All", + "search_filter_media_type_image": "Image", + "search_filter_media_type_title": "Select media type", + "search_filter_media_type_video": "Video", + "search_filter_people": "People", + "search_filter_people_title": "Select people", + "search_page_categories": "Categories", + "search_page_favorites": "Favorites", + "search_page_motion_photos": "Motion Photos", + "search_page_no_objects": "No Objects Info Available", + "search_page_no_places": "No Places Info Available", + "search_page_people": "People", + "search_page_person_add_name_dialog_cancel": "Cancel", + "search_page_person_add_name_dialog_hint": "Name", + "search_page_person_add_name_dialog_save": "Save", + "search_page_person_add_name_dialog_title": "Add a name", + "search_page_person_add_name_subtitle": "Find them fast by name with search", + "search_page_person_add_name_title": "Add a name", + "search_page_person_edit_name": "Edit name", + "search_page_places": "Places", + "search_page_recently_added": "Recently added", + "search_page_screenshots": "Screenshots", + "search_page_search_photos_videos": "Search for your photos and videos", + "search_page_selfies": "Selfies", + "search_page_things": "Things", + "search_page_videos": "Videos", + "search_page_view_all_button": "View all", + "search_page_your_activity": "Your activity", + "search_page_your_map": "Your Map", + "search_result_page_new_search_hint": "New Search", + "search_suggestion_list_smart_search_hint_1": "Smart search is enabled by default, to search for metadata use the syntax ", + "search_suggestion_list_smart_search_hint_2": "m:your-search-term", + "select_additional_user_for_sharing_page_suggestions": "Suggestions", + "select_user_for_sharing_page_err_album": "Failed to create album", + "select_user_for_sharing_page_share_suggestions": "Suggestions", + "server_endpoint": "Server Endpoint", + "server_info_box_app_version": "App Version", + "server_info_box_latest_release": "Latest Version", + "server_info_box_server_url": "Server URL", + "server_info_box_server_version": "Server Version", + "setting_image_viewer_help": "The detail viewer loads the small thumbnail first, then loads the medium-size preview (if enabled), finally loads the original (if enabled).", + "setting_image_viewer_original_subtitle": "Enable to load the original full-resolution image (large!). Disable to reduce data usage (both network and on device cache).", + "setting_image_viewer_original_title": "Load original image", + "setting_image_viewer_preview_subtitle": "Enable to load a medium-resolution image. Disable to either directly load the original or only use the thumbnail.", + "setting_image_viewer_preview_title": "Load preview image", + "setting_image_viewer_title": "Images", + "setting_languages_apply": "Apply", + "setting_languages_subtitle": "Change the app's language", + "setting_languages_title": "Languages", + "setting_notifications_notify_failures_grace_period": "Notify background backup failures: {}", + "setting_notifications_notify_hours": "{} hours", + "setting_notifications_notify_immediately": "immediately", + "setting_notifications_notify_minutes": "{} minutes", + "setting_notifications_notify_never": "never", + "setting_notifications_notify_seconds": "{} seconds", + "setting_notifications_single_progress_subtitle": "Detailed upload progress information per asset", + "setting_notifications_single_progress_title": "Show background backup detail progress", + "setting_notifications_subtitle": "Adjust your notification preferences", + "setting_notifications_title": "Notifications", + "setting_notifications_total_progress_subtitle": "Overall upload progress (done/total assets)", + "setting_notifications_total_progress_title": "Show background backup total progress", + "setting_pages_app_bar_settings": "Settings", + "settings_require_restart": "Please restart Immich to apply this setting", + "setting_video_viewer_looping_subtitle": "Enable to automatically loop a video in the detail viewer.", + "setting_video_viewer_looping_title": "Looping", + "setting_video_viewer_original_video_subtitle": "When streaming a video from the server, play the original even when a transcode is available. May lead to buffering. Videos available locally are played in original quality regardless of this setting.", + "setting_video_viewer_original_video_title": "Force original video", + "setting_video_viewer_title": "Videos", + "share_add": "Add", + "share_add_photos": "Add photos", + "share_add_title": "Add a title", + "share_assets_selected": "{} selected", + "share_create_album": "Create album", + "shared_album_activities_input_disable": "Comment is disabled", + "shared_album_activities_input_hint": "Say something", + "shared_album_activity_remove_content": "Do you want to delete this activity?", + "shared_album_activity_remove_title": "Delete Activity", + "shared_album_activity_setting_subtitle": "Let others respond", + "shared_album_activity_setting_title": "Comments & likes", + "shared_album_section_people_action_error": "Error leaving/removing from album", + "shared_album_section_people_action_leave": "Remove user from album", + "shared_album_section_people_action_remove_user": "Remove user from album", + "shared_album_section_people_owner_label": "Owner", + "shared_album_section_people_title": "PEOPLE", + "share_dialog_preparing": "Preparing...", + "shared_intent_upload_button_progress_text": "{} / {} Uploaded", + "shared_link_app_bar_title": "Shared Links", + "shared_link_clipboard_copied_massage": "Copied to clipboard", + "shared_link_clipboard_text": "Link: {}\nPassword: {}", + "shared_link_create_app_bar_title": "Create link to share", + "shared_link_create_error": "Error while creating shared link", + "shared_link_create_info": "Let anyone with the link see the selected photo(s)", + "shared_link_create_submit_button": "Create link", + "shared_link_edit_allow_download": "Allow public user to download", + "shared_link_edit_allow_upload": "Allow public user to upload", + "shared_link_edit_app_bar_title": "Edit link", + "shared_link_edit_change_expiry": "Change expiration time", + "shared_link_edit_description": "Description", + "shared_link_edit_description_hint": "Enter the share description", + "shared_link_edit_expire_after": "Expire after", + "shared_link_edit_expire_after_option_day": "1 day", + "shared_link_edit_expire_after_option_days": "{} days", + "shared_link_edit_expire_after_option_hour": "1 hour", + "shared_link_edit_expire_after_option_hours": "{} hours", + "shared_link_edit_expire_after_option_minute": "1 minute", + "shared_link_edit_expire_after_option_minutes": "{} minutes", + "shared_link_edit_expire_after_option_months": "{} months", + "shared_link_edit_expire_after_option_never": "Never", + "shared_link_edit_expire_after_option_year": "{} year", + "shared_link_edit_password": "Password", + "shared_link_edit_password_hint": "Enter the share password", + "shared_link_edit_show_meta": "Show metadata", + "shared_link_edit_submit_button": "Update link", + "shared_link_empty": "You don't have any shared links", + "shared_link_error_server_url_fetch": "Cannot fetch the server url", + "shared_link_expired": "Expired", + "shared_link_expires_day": "Expires in {} day", + "shared_link_expires_days": "Expires in {} days", + "shared_link_expires_hour": "Expires in {} hour", + "shared_link_expires_hours": "Expires in {} hours", + "shared_link_expires_minute": "Expires in {} minute", + "shared_link_expires_minutes": "Expires in {} minutes", + "shared_link_expires_never": "Expires ∞", + "shared_link_expires_second": "Expires in {} second", + "shared_link_expires_seconds": "Expires in {} seconds", + "shared_link_individual_shared": "Individual shared", + "shared_link_info_chip_download": "Download", + "shared_link_info_chip_metadata": "EXIF", + "shared_link_info_chip_upload": "Upload", + "shared_link_manage_links": "Manage Shared links", + "shared_link_public_album": "Public album", + "shared_links": "Shared links", + "share_done": "Done", + "shared_with_me": "Shared with me", + "share_invite": "Invite to album", + "sharing_page_album": "Shared albums", + "sharing_page_description": "Create shared albums to share photos and videos with people in your network.", + "sharing_page_empty_list": "EMPTY LIST", + "sharing_silver_appbar_create_shared_album": "New shared album", + "sharing_silver_appbar_shared_links": "Shared links", + "sharing_silver_appbar_share_partner": "Share with partner", + "start_date": "Start date", + "sync": "Sync", + "sync_albums": "Sync albums", + "sync_albums_manual_subtitle": "Sync all uploaded videos and photos to the selected backup albums", + "sync_upload_album_setting_subtitle": "Create and upload your photos and videos to the selected albums on Immich", + "tab_controller_nav_library": "Library", + "tab_controller_nav_photos": "Photos", + "tab_controller_nav_search": "Search", + "tab_controller_nav_sharing": "Sharing", + "theme_setting_asset_list_storage_indicator_title": "Show storage indicator on asset tiles", + "theme_setting_asset_list_tiles_per_row_title": "Number of assets per row ({})", + "theme_setting_colorful_interface_subtitle": "Apply primary color to background surfaces.", + "theme_setting_colorful_interface_title": "Colorful interface", + "theme_setting_dark_mode_switch": "Dark mode", + "theme_setting_image_viewer_quality_subtitle": "Adjust the quality of the detail image viewer", + "theme_setting_image_viewer_quality_title": "Image viewer quality", + "theme_setting_primary_color_subtitle": "Pick a color for primary actions and accents.", + "theme_setting_primary_color_title": "Primary color", + "theme_setting_system_primary_color_title": "Use system color", + "theme_setting_system_theme_switch": "Automatic (Follow system setting)", + "theme_setting_theme_subtitle": "Choose the app's theme setting", + "theme_setting_theme_title": "Theme", + "theme_setting_three_stage_loading_subtitle": "Three-stage loading might increase the loading performance but causes significantly higher network load", + "theme_setting_three_stage_loading_title": "Enable three-stage loading", + "translated_text_options": "Options", + "trash": "Trash", + "trash_emptied": "Emptied trash", + "trash_page_delete": "Delete", + "trash_page_delete_all": "Delete All", + "trash_page_empty_trash_btn": "Empty trash", + "trash_page_empty_trash_dialog_content": "Do you want to empty your trashed assets? These items will be permanently removed from Immich", + "trash_page_empty_trash_dialog_ok": "Ok", + "trash_page_info": "Trashed items will be permanently deleted after {} days", + "trash_page_no_assets": "No trashed assets", + "trash_page_restore": "Restore", + "trash_page_restore_all": "Restore All", + "trash_page_select_assets_btn": "Select assets", + "trash_page_select_btn": "Select", + "trash_page_title": "Trash ({})", + "upload": "Upload", + "upload_dialog_cancel": "Cancel", + "upload_dialog_info": "Do you want to backup the selected Asset(s) to the server?", + "upload_dialog_ok": "Upload", + "upload_dialog_title": "Upload Asset", + "uploading": "Uploading", + "upload_to_immich": "Upload to Immich ({})", + "use_current_connection": "use current connection", + "validate_endpoint_error": "Please enter a valid URL", + "version_announcement_overlay_ack": "Acknowledge", + "version_announcement_overlay_release_notes": "release notes", + "version_announcement_overlay_text_1": "Hi friend, there is a new release of", + "version_announcement_overlay_text_2": "please take your time to visit the ", + "version_announcement_overlay_text_3": " and ensure your docker-compose and .env setup is up-to-date to prevent any misconfigurations, especially if you use WatchTower or any mechanism that handles updating your server application automatically.", + "version_announcement_overlay_title": "New Server Version Available \uD83C\uDF89", + "videos": "Videos", + "viewer_remove_from_stack": "Remove from Stack", + "viewer_stack_use_as_main_asset": "Use as Main Asset", + "viewer_unstack": "Un-Stack", + "wifi_name": "WiFi Name", + "your_wifi_name": "Your WiFi name" +} diff --git a/mobile/assets/i18n/gl.json b/mobile/assets/i18n/gl.json new file mode 100644 index 0000000000..9450b4b44f --- /dev/null +++ b/mobile/assets/i18n/gl.json @@ -0,0 +1,673 @@ +{ + "action_common_back": "Back", + "action_common_cancel": "Cancel", + "action_common_clear": "Clear", + "action_common_confirm": "Confirm", + "action_common_save": "Save", + "action_common_select": "Select", + "action_common_update": "Update", + "add_a_name": "Add a name", + "add_endpoint": "Add endpoint", + "add_to_album_bottom_sheet_added": "Added to {album}", + "add_to_album_bottom_sheet_already_exists": "Already in {album}", + "advanced_settings_log_level_title": "Log level: {}", + "advanced_settings_prefer_remote_subtitle": "Some devices are painfully slow to load thumbnails from assets on the device. Activate this setting to load remote images instead.", + "advanced_settings_prefer_remote_title": "Prefer remote images", + "advanced_settings_proxy_headers_subtitle": "Define proxy headers Immich should send with each network request", + "advanced_settings_proxy_headers_title": "Proxy Headers", + "advanced_settings_self_signed_ssl_subtitle": "Skips SSL certificate verification for the server endpoint. Required for self-signed certificates.", + "advanced_settings_self_signed_ssl_title": "Allow self-signed SSL certificates (EXPERIMENTAL)", + "advanced_settings_tile_subtitle": "Advanced user's settings", + "advanced_settings_tile_title": "Advanced", + "advanced_settings_troubleshooting_subtitle": "Enable additional features for troubleshooting", + "advanced_settings_troubleshooting_title": "Troubleshooting", + "album_info_card_backup_album_excluded": "EXCLUDED", + "album_info_card_backup_album_included": "INCLUDED", + "albums": "Albums", + "album_thumbnail_card_item": "1 item", + "album_thumbnail_card_items": "{} items", + "album_thumbnail_card_shared": " · Shared", + "album_thumbnail_owned": "Owned", + "album_thumbnail_shared_by": "Shared by {}", + "album_viewer_appbar_delete_confirm": "Are you sure you want to delete this album from your account?", + "album_viewer_appbar_share_delete": "Delete album", + "album_viewer_appbar_share_err_delete": "Failed to delete album", + "album_viewer_appbar_share_err_leave": "Failed to leave album", + "album_viewer_appbar_share_err_remove": "There are problems in removing assets from album", + "album_viewer_appbar_share_err_title": "Failed to change album title", + "album_viewer_appbar_share_leave": "Leave album", + "album_viewer_appbar_share_remove": "Remove from album", + "album_viewer_appbar_share_to": "Share To", + "album_viewer_page_share_add_users": "Add users", + "all": "All", + "all_people_page_title": "People", + "all_videos_page_title": "Videos", + "app_bar_signout_dialog_content": "Are you sure you want to sign out?", + "app_bar_signout_dialog_ok": "Yes", + "app_bar_signout_dialog_title": "Sign out", + "archived": "Archived", + "archive_page_no_archived_assets": "No archived assets found", + "archive_page_title": "Archive ({})", + "asset_action_delete_err_read_only": "Cannot delete read only asset(s), skipping", + "asset_action_share_err_offline": "Cannot fetch offline asset(s), skipping", + "asset_list_group_by_sub_title": "Group by", + "asset_list_layout_settings_dynamic_layout_title": "Dynamic layout", + "asset_list_layout_settings_group_automatically": "Automatic", + "asset_list_layout_settings_group_by": "Group assets by", + "asset_list_layout_settings_group_by_month": "Month", + "asset_list_layout_settings_group_by_month_day": "Month + day", + "asset_list_layout_sub_title": "Layout", + "asset_list_settings_subtitle": "Photo grid layout settings", + "asset_list_settings_title": "Photo Grid", + "asset_restored_successfully": "Asset restored successfully", + "assets_deleted_permanently": "{} asset(s) deleted permanently", + "assets_deleted_permanently_from_server": "{} asset(s) deleted permanently from the Immich server", + "assets_removed_permanently_from_device": "{} asset(s) removed permanently from your device", + "assets_restored_successfully": "{} asset(s) restored successfully", + "assets_trashed": "{} asset(s) trashed", + "assets_trashed_from_server": "{} asset(s) trashed from the Immich server", + "asset_viewer_settings_subtitle": "Manage your gallery viewer settings", + "asset_viewer_settings_title": "Asset Viewer", + "automatic_endpoint_switching_subtitle": "Connect locally over designated Wi-Fi when available and use alternative connections elsewhere", + "automatic_endpoint_switching_title": "Automatic URL switching", + "background_location_permission": "Background location permission", + "background_location_permission_content": "In order to switch networks when running in the background, Immich must *always* have precise location access so the app can read the Wi-Fi network's name", + "backup_album_selection_page_albums_device": "Albums on device ({})", + "backup_album_selection_page_albums_tap": "Tap to include, double tap to exclude", + "backup_album_selection_page_assets_scatter": "Assets can scatter across multiple albums. Thus, albums can be included or excluded during the backup process.", + "backup_album_selection_page_select_albums": "Select albums", + "backup_album_selection_page_selection_info": "Selection Info", + "backup_album_selection_page_total_assets": "Total unique assets", + "backup_all": "All", + "backup_background_service_backup_failed_message": "Failed to backup assets. Retrying…", + "backup_background_service_connection_failed_message": "Failed to connect to the server. Retrying…", + "backup_background_service_current_upload_notification": "Uploading {}", + "backup_background_service_default_notification": "Checking for new assets…", + "backup_background_service_error_title": "Backup error", + "backup_background_service_in_progress_notification": "Backing up your assets…", + "backup_background_service_upload_failure_notification": "Failed to upload {}", + "backup_controller_page_albums": "Backup Albums", + "backup_controller_page_background_app_refresh_disabled_content": "Enable background app refresh in Settings > General > Background App Refresh in order to use background backup.", + "backup_controller_page_background_app_refresh_disabled_title": "Background app refresh disabled", + "backup_controller_page_background_app_refresh_enable_button_text": "Go to settings", + "backup_controller_page_background_battery_info_link": "Show me how", + "backup_controller_page_background_battery_info_message": "For the best background backup experience, please disable any battery optimizations restricting background activity for Immich.\n\nSince this is device-specific, please lookup the required information for your device manufacturer.", + "backup_controller_page_background_battery_info_ok": "OK", + "backup_controller_page_background_battery_info_title": "Battery optimizations", + "backup_controller_page_background_charging": "Only while charging", + "backup_controller_page_background_configure_error": "Failed to configure the background service", + "backup_controller_page_background_delay": "Delay new assets backup: {}", + "backup_controller_page_background_description": "Turn on the background service to automatically backup any new assets without needing to open the app", + "backup_controller_page_background_is_off": "Automatic background backup is off", + "backup_controller_page_background_is_on": "Automatic background backup is on", + "backup_controller_page_background_turn_off": "Turn off background service", + "backup_controller_page_background_turn_on": "Turn on background service", + "backup_controller_page_background_wifi": "Only on WiFi", + "backup_controller_page_backup": "Backup", + "backup_controller_page_backup_selected": "Selected: ", + "backup_controller_page_backup_sub": "Backed up photos and videos", + "backup_controller_page_cancel": "Cancel", + "backup_controller_page_created": "Created on: {}", + "backup_controller_page_desc_backup": "Turn on foreground backup to automatically upload new assets to the server when opening the app.", + "backup_controller_page_excluded": "Excluded: ", + "backup_controller_page_failed": "Failed ({})", + "backup_controller_page_filename": "File name: {} [{}]", + "backup_controller_page_id": "ID: {}", + "backup_controller_page_info": "Backup Information", + "backup_controller_page_none_selected": "None selected", + "backup_controller_page_remainder": "Remainder", + "backup_controller_page_remainder_sub": "Remaining photos and videos to back up from selection", + "backup_controller_page_select": "Select", + "backup_controller_page_server_storage": "Server Storage", + "backup_controller_page_start_backup": "Start Backup", + "backup_controller_page_status_off": "Automatic foreground backup is off", + "backup_controller_page_status_on": "Automatic foreground backup is on", + "backup_controller_page_storage_format": "{} of {} used", + "backup_controller_page_to_backup": "Albums to be backed up", + "backup_controller_page_total": "Total", + "backup_controller_page_total_sub": "All unique photos and videos from selected albums", + "backup_controller_page_turn_off": "Turn off foreground backup", + "backup_controller_page_turn_on": "Turn on foreground backup", + "backup_controller_page_uploading_file_info": "Uploading file info", + "backup_err_only_album": "Cannot remove the only album", + "backup_info_card_assets": "assets", + "backup_manual_cancelled": "Cancelled", + "backup_manual_failed": "Failed", + "backup_manual_in_progress": "Upload already in progress. Try after sometime", + "backup_manual_success": "Success", + "backup_manual_title": "Upload status", + "backup_options_page_title": "Backup options", + "backup_setting_subtitle": "Manage background and foreground upload settings", + "cache_settings_album_thumbnails": "Library page thumbnails ({} assets)", + "cache_settings_clear_cache_button": "Clear cache", + "cache_settings_clear_cache_button_title": "Clears the app's cache. This will significantly impact the app's performance until the cache has rebuilt.", + "cache_settings_duplicated_assets_clear_button": "CLEAR", + "cache_settings_duplicated_assets_subtitle": "Photos and videos that are black listed by the app", + "cache_settings_duplicated_assets_title": "Duplicated Assets ({})", + "cache_settings_image_cache_size": "Image cache size ({} assets)", + "cache_settings_statistics_album": "Library thumbnails", + "cache_settings_statistics_assets": "{} assets ({})", + "cache_settings_statistics_full": "Full images", + "cache_settings_statistics_shared": "Shared album thumbnails", + "cache_settings_statistics_thumbnail": "Thumbnails", + "cache_settings_statistics_title": "Cache usage", + "cache_settings_subtitle": "Control the caching behaviour of the Immich mobile application", + "cache_settings_thumbnail_size": "Thumbnail cache size ({} assets)", + "cache_settings_tile_subtitle": "Control the local storage behaviour", + "cache_settings_tile_title": "Local Storage", + "cache_settings_title": "Caching Settings", + "cancel": "Cancel", + "canceled": "Canceled", + "change_display_order": "Change display order", + "change_password_form_confirm_password": "Confirm Password", + "change_password_form_description": "Hi {name},\n\nThis is either the first time you are signing into the system or a request has been made to change your password. Please enter the new password below.", + "change_password_form_new_password": "New Password", + "change_password_form_password_mismatch": "Passwords do not match", + "change_password_form_reenter_new_password": "Re-enter New Password", + "check_corrupt_asset_backup": "Check for corrupt asset backups", + "check_corrupt_asset_backup_button": "Perform check", + "check_corrupt_asset_backup_description": "Run this check only over Wi-Fi and once all assets have been backed-up. The procedure might take a few minutes.", + "client_cert_dialog_msg_confirm": "OK", + "client_cert_enter_password": "Enter Password", + "client_cert_import": "Import", + "client_cert_import_success_msg": "Client certificate is imported", + "client_cert_invalid_msg": "Invalid certificate file or wrong password", + "client_cert_remove": "Remove", + "client_cert_remove_msg": "Client certificate is removed", + "client_cert_subtitle": "Supports PKCS12 (.p12, .pfx) format only. Certificate Import/Remove is available only before login", + "client_cert_title": "SSL Client Certificate (EXPERIMENTAL)", + "common_add_to_album": "Add to album", + "common_change_password": "Change Password", + "common_create_new_album": "Create new album", + "common_server_error": "Please check your network connection, make sure the server is reachable and app/server versions are compatible.", + "common_shared": "Shared", + "completed": "Completed", + "contextual_search": "Sunrise on the beach", + "control_bottom_app_bar_add_to_album": "Add to album", + "control_bottom_app_bar_album_info": "{} items", + "control_bottom_app_bar_album_info_shared": "{} items · Shared", + "control_bottom_app_bar_archive": "Archive", + "control_bottom_app_bar_create_new_album": "Create new album", + "control_bottom_app_bar_delete": "Delete", + "control_bottom_app_bar_delete_from_immich": "Delete from Immich", + "control_bottom_app_bar_delete_from_local": "Delete from device", + "control_bottom_app_bar_download": "Download", + "control_bottom_app_bar_edit": "Edit", + "control_bottom_app_bar_edit_location": "Edit Location", + "control_bottom_app_bar_edit_time": "Edit Date & Time", + "control_bottom_app_bar_favorite": "Favorite", + "control_bottom_app_bar_share": "Share", + "control_bottom_app_bar_share_to": "Share To", + "control_bottom_app_bar_stack": "Stack", + "control_bottom_app_bar_trash_from_immich": "Move to Trash", + "control_bottom_app_bar_unarchive": "Unarchive", + "control_bottom_app_bar_unfavorite": "Unfavorite", + "control_bottom_app_bar_upload": "Upload", + "create_album": "Create album", + "create_album_page_untitled": "Untitled", + "create_new": "CREATE NEW", + "create_shared_album_page_create": "Create", + "create_shared_album_page_share": "Share", + "create_shared_album_page_share_add_assets": "ADD ASSETS", + "create_shared_album_page_share_select_photos": "Select Photos", + "crop": "Crop", + "curated_location_page_title": "Places", + "curated_object_page_title": "Things", + "current_server_address": "Current server address", + "daily_title_text_date": "E, MMM dd", + "daily_title_text_date_year": "E, MMM dd, yyyy", + "date_format": "E, LLL d, y • h:mm a", + "delete_dialog_alert": "These items will be permanently deleted from Immich and from your device", + "delete_dialog_alert_local": "These items will be permanently removed from your device but still be available on the Immich server", + "delete_dialog_alert_local_non_backed_up": "Some of the items aren't backed up to Immich and will be permanently removed from your device", + "delete_dialog_alert_remote": "These items will be permanently deleted from the Immich server", + "delete_dialog_cancel": "Cancel", + "delete_dialog_ok": "Delete", + "delete_dialog_ok_force": "Delete Anyway", + "delete_dialog_title": "Delete Permanently", + "delete_local_dialog_ok_backed_up_only": "Delete Backed Up Only", + "delete_local_dialog_ok_force": "Delete Anyway", + "delete_shared_link_dialog_content": "Are you sure you want to delete this shared link?", + "delete_shared_link_dialog_title": "Delete Shared Link", + "description_input_hint_text": "Add description...", + "description_input_submit_error": "Error updating description, check the log for more details", + "download_canceled": "Download canceled", + "download_complete": "Download complete", + "download_enqueue": "Download enqueued", + "download_error": "Download Error", + "download_failed": "Download failed", + "download_filename": "file: {}", + "download_finished": "Download finished", + "downloading": "Downloading...", + "downloading_media": "Downloading media", + "download_notfound": "Download not found", + "download_paused": "Download paused", + "download_started": "Download started", + "download_sucess": "Download success", + "download_sucess_android": "The media has been downloaded to DCIM/Immich", + "download_waiting_to_retry": "Waiting to retry", + "edit_date_time_dialog_date_time": "Date and Time", + "edit_date_time_dialog_timezone": "Timezone", + "edit_image_title": "Edit", + "edit_location_dialog_title": "Location", + "end_date": "End date", + "enqueued": "Enqueued", + "enter_wifi_name": "Enter WiFi name", + "error_change_sort_album": "Failed to change album sort order", + "error_saving_image": "Error: {}", + "exif_bottom_sheet_description": "Add Description...", + "exif_bottom_sheet_details": "DETAILS", + "exif_bottom_sheet_location": "LOCATION", + "exif_bottom_sheet_location_add": "Add a location", + "exif_bottom_sheet_people": "PEOPLE", + "exif_bottom_sheet_person_add_person": "Add name", + "experimental_settings_new_asset_list_subtitle": "Work in progress", + "experimental_settings_new_asset_list_title": "Enable experimental photo grid", + "experimental_settings_subtitle": "Use at your own risk!", + "experimental_settings_title": "Experimental", + "external_network": "External network", + "external_network_sheet_info": "When not on the preferred WiFi network, the app will connect to the server through the first of the below URLs it can reach, starting from top to bottom", + "failed": "Failed", + "favorites": "Favorites", + "favorites_page_no_favorites": "No favorite assets found", + "favorites_page_title": "Favorites", + "filename_search": "File name or extension", + "filter": "Filter", + "get_wifiname_error": "Could not get Wi-Fi name. Make sure you have granted the necessary permissions and are connected to a Wi-Fi network", + "grant_permission": "Grant permission", + "haptic_feedback_switch": "Enable haptic feedback", + "haptic_feedback_title": "Haptic Feedback", + "header_settings_add_header_tip": "Add Header", + "header_settings_field_validator_msg": "Value cannot be empty", + "header_settings_header_name_input": "Header name", + "header_settings_header_value_input": "Header value", + "header_settings_page_title": "Proxy Headers (EXPERIMENTAL)", + "headers_settings_tile_subtitle": "Define proxy headers the app should send with each network request", + "headers_settings_tile_title": "Custom proxy headers", + "home_page_add_to_album_conflicts": "Added {added} assets to album {album}. {failed} assets are already in the album.", + "home_page_add_to_album_err_local": "Can not add local assets to albums yet, skipping", + "home_page_add_to_album_success": "Added {added} assets to album {album}.", + "home_page_album_err_partner": "Can not add partner assets to an album yet, skipping", + "home_page_archive_err_local": "Can not archive local assets yet, skipping", + "home_page_archive_err_partner": "Can not archive partner assets, skipping", + "home_page_building_timeline": "Building the timeline", + "home_page_delete_err_partner": "Can not delete partner assets, skipping", + "home_page_delete_remote_err_local": "Local assets in delete remote selection, skipping", + "home_page_favorite_err_local": "Can not favorite local assets yet, skipping", + "home_page_favorite_err_partner": "Can not favorite partner assets yet, skipping", + "home_page_first_time_notice": "If this is your first time using the app, please make sure to choose a backup album(s) so that the timeline can populate photos and videos in the album(s).", + "home_page_share_err_local": "Can not share local assets via link, skipping", + "home_page_upload_err_limit": "Can only upload a maximum of 30 assets at a time, skipping", + "ignore_icloud_photos": "Ignore iCloud photos", + "ignore_icloud_photos_description": "Photos that are stored on iCloud will not be uploaded to the Immich server", + "image_saved_successfully": "Image saved", + "image_viewer_page_state_provider_download_error": "Download Error", + "image_viewer_page_state_provider_download_started": "Download Started", + "image_viewer_page_state_provider_download_success": "Download Success", + "image_viewer_page_state_provider_share_error": "Share Error", + "invalid_date": "Invalid date", + "invalid_date_format": "Invalid date format", + "library": "Library", + "library_page_albums": "Albums", + "library_page_archive": "Archive", + "library_page_device_albums": "Albums on Device", + "library_page_favorites": "Favorites", + "library_page_new_album": "New album", + "library_page_sharing": "Sharing", + "library_page_sort_asset_count": "Number of assets", + "library_page_sort_created": "Created date", + "library_page_sort_last_modified": "Last modified", + "library_page_sort_most_oldest_photo": "Oldest photo", + "library_page_sort_most_recent_photo": "Most recent photo", + "library_page_sort_title": "Album title", + "local_network": "Local network", + "local_network_sheet_info": "The app will connect to the server through this URL when using the specified Wi-Fi network", + "location_permission": "Location permission", + "location_permission_content": "In order to use the auto-switching feature, Immich needs precise location permission so it can read the current WiFi network's name", + "location_picker_choose_on_map": "Choose on map", + "location_picker_latitude": "Latitude", + "location_picker_latitude_error": "Enter a valid latitude", + "location_picker_latitude_hint": "Enter your latitude here", + "location_picker_longitude": "Longitude", + "location_picker_longitude_error": "Enter a valid longitude", + "location_picker_longitude_hint": "Enter your longitude here", + "login_disabled": "Login has been disabled", + "login_form_api_exception": "API exception. Please check the server URL and try again.", + "login_form_back_button_text": "Back", + "login_form_button_text": "Login", + "login_form_email_hint": "youremail@email.com", + "login_form_endpoint_hint": "http://your-server-ip:port", + "login_form_endpoint_url": "Server Endpoint URL", + "login_form_err_http": "Please specify http:// or https://", + "login_form_err_invalid_email": "Invalid Email", + "login_form_err_invalid_url": "Invalid URL", + "login_form_err_leading_whitespace": "Leading whitespace", + "login_form_err_trailing_whitespace": "Trailing whitespace", + "login_form_failed_get_oauth_server_config": "Error logging using OAuth, check server URL", + "login_form_failed_get_oauth_server_disable": "OAuth feature is not available on this server", + "login_form_failed_login": "Error logging you in, check server URL, email and password", + "login_form_handshake_exception": "There was an Handshake Exception with the server. Enable self-signed certificate support in the settings if you are using a self-signed certificate.", + "login_form_label_email": "Email", + "login_form_label_password": "Password", + "login_form_next_button": "Next", + "login_form_password_hint": "password", + "login_form_save_login": "Stay logged in", + "login_form_server_empty": "Enter a server URL.", + "login_form_server_error": "Could not connect to server.", + "login_password_changed_error": "There was an error updating your password", + "login_password_changed_success": "Password updated successfully", + "map_assets_in_bound": "{} photo", + "map_assets_in_bounds": "{} photos", + "map_cannot_get_user_location": "Cannot get user's location", + "map_location_dialog_cancel": "Cancel", + "map_location_dialog_yes": "Yes", + "map_location_picker_page_use_location": "Use this location", + "map_location_service_disabled_content": "Location service needs to be enabled to display assets from your current location. Do you want to enable it now?", + "map_location_service_disabled_title": "Location Service disabled", + "map_no_assets_in_bounds": "No photos in this area", + "map_no_location_permission_content": "Location permission is needed to display assets from your current location. Do you want to allow it now?", + "map_no_location_permission_title": "Location Permission denied", + "map_settings_dark_mode": "Dark mode", + "map_settings_date_range_option_all": "All", + "map_settings_date_range_option_day": "Past 24 hours", + "map_settings_date_range_option_days": "Past {} days", + "map_settings_date_range_option_year": "Past year", + "map_settings_date_range_option_years": "Past {} years", + "map_settings_dialog_cancel": "Cancel", + "map_settings_dialog_save": "Save", + "map_settings_dialog_title": "Map Settings", + "map_settings_include_show_archived": "Include Archived", + "map_settings_include_show_partners": "Include Partners", + "map_settings_only_relative_range": "Date range", + "map_settings_only_show_favorites": "Show Favorite Only", + "map_settings_theme_settings": "Map Theme", + "map_zoom_to_see_photos": "Zoom out to see photos", + "memories_all_caught_up": "All caught up", + "memories_check_back_tomorrow": "Check back tomorrow for more memories", + "memories_start_over": "Start Over", + "memories_swipe_to_close": "Swipe up to close", + "memories_year_ago": "A year ago", + "memories_years_ago": "{} years ago", + "monthly_title_text_date_format": "MMMM y", + "motion_photos_page_title": "Motion Photos", + "multiselect_grid_edit_date_time_err_read_only": "Cannot edit date of read only asset(s), skipping", + "multiselect_grid_edit_gps_err_read_only": "Cannot edit location of read only asset(s), skipping", + "my_albums": "My albums", + "networking_settings": "Networking", + "networking_subtitle": "Manage the server endpoint settings", + "no_assets_to_show": "No assets to show", + "no_name": "No name", + "notification_permission_dialog_cancel": "Cancel", + "notification_permission_dialog_content": "To enable notifications, go to Settings and select allow.", + "notification_permission_dialog_settings": "Settings", + "notification_permission_list_tile_content": "Grant permission to enable notifications.", + "notification_permission_list_tile_enable_button": "Enable Notifications", + "notification_permission_list_tile_title": "Notification Permission", + "not_selected": "Not selected", + "on_this_device": "On this device", + "partner_list_user_photos": "{user}'s photos", + "partner_list_view_all": "View all", + "partner_page_add_partner": "Add partner", + "partner_page_empty_message": "Your photos are not yet shared with any partner.", + "partner_page_no_more_users": "No more users to add", + "partner_page_partner_add_failed": "Failed to add partner", + "partner_page_select_partner": "Select partner", + "partner_page_shared_to_title": "Shared to", + "partner_page_stop_sharing_content": "{} will no longer be able to access your photos.", + "partner_page_stop_sharing_title": "Stop sharing your photos?", + "partner_page_title": "Partner", + "partners": "Partners", + "paused": "Paused", + "people": "People", + "permission_onboarding_back": "Back", + "permission_onboarding_continue_anyway": "Continue anyway", + "permission_onboarding_get_started": "Get started", + "permission_onboarding_go_to_settings": "Go to settings", + "permission_onboarding_grant_permission": "Grant permission", + "permission_onboarding_log_out": "Log out", + "permission_onboarding_permission_denied": "Permission denied. To use Immich, grant photo and video permissions in Settings.", + "permission_onboarding_permission_granted": "Permission granted! You are all set.", + "permission_onboarding_permission_limited": "Permission limited. To let Immich backup and manage your entire gallery collection, grant photo and video permissions in Settings.", + "permission_onboarding_request": "Immich requires permission to view your photos and videos.", + "places": "Places", + "preferences_settings_subtitle": "Manage the app's preferences", + "preferences_settings_title": "Preferences", + "profile_drawer_app_logs": "Logs", + "profile_drawer_client_out_of_date_major": "Mobile App is out of date. Please update to the latest major version.", + "profile_drawer_client_out_of_date_minor": "Mobile App is out of date. Please update to the latest minor version.", + "profile_drawer_client_server_up_to_date": "Client and Server are up-to-date", + "profile_drawer_documentation": "Documentation", + "profile_drawer_github": "GitHub", + "profile_drawer_server_out_of_date_major": "Server is out of date. Please update to the latest major version.", + "profile_drawer_server_out_of_date_minor": "Server is out of date. Please update to the latest minor version.", + "profile_drawer_settings": "Settings", + "profile_drawer_sign_out": "Sign Out", + "profile_drawer_trash": "Trash", + "recently_added": "Recently added", + "recently_added_page_title": "Recently Added", + "save": "Save", + "save_to_gallery": "Save to gallery", + "scaffold_body_error_occurred": "Error occurred", + "search_albums": "Search albums", + "search_bar_hint": "Search your photos", + "search_filter_apply": "Apply filter", + "search_filter_camera": "Camera", + "search_filter_camera_make": "Make", + "search_filter_camera_model": "Model", + "search_filter_camera_title": "Select camera type", + "search_filter_date": "Date", + "search_filter_date_interval": "{start} to {end}", + "search_filter_date_title": "Select a date range", + "search_filter_display_option_archive": "Archive", + "search_filter_display_option_favorite": "Favorite", + "search_filter_display_option_not_in_album": "Not in album", + "search_filter_display_options": "Display Options", + "search_filter_display_options_title": "Display options", + "search_filter_location": "Location", + "search_filter_location_city": "City", + "search_filter_location_country": "Country", + "search_filter_location_state": "State", + "search_filter_location_title": "Select location", + "search_filter_media_type": "Media Type", + "search_filter_media_type_all": "All", + "search_filter_media_type_image": "Image", + "search_filter_media_type_title": "Select media type", + "search_filter_media_type_video": "Video", + "search_filter_people": "People", + "search_filter_people_title": "Select people", + "search_page_categories": "Categories", + "search_page_favorites": "Favorites", + "search_page_motion_photos": "Motion Photos", + "search_page_no_objects": "No Objects Info Available", + "search_page_no_places": "No Places Info Available", + "search_page_people": "People", + "search_page_person_add_name_dialog_cancel": "Cancel", + "search_page_person_add_name_dialog_hint": "Name", + "search_page_person_add_name_dialog_save": "Save", + "search_page_person_add_name_dialog_title": "Add a name", + "search_page_person_add_name_subtitle": "Find them fast by name with search", + "search_page_person_add_name_title": "Add a name", + "search_page_person_edit_name": "Edit name", + "search_page_places": "Places", + "search_page_recently_added": "Recently added", + "search_page_screenshots": "Screenshots", + "search_page_search_photos_videos": "Search for your photos and videos", + "search_page_selfies": "Selfies", + "search_page_things": "Things", + "search_page_videos": "Videos", + "search_page_view_all_button": "View all", + "search_page_your_activity": "Your activity", + "search_page_your_map": "Your Map", + "search_result_page_new_search_hint": "New Search", + "search_suggestion_list_smart_search_hint_1": "Smart search is enabled by default, to search for metadata use the syntax ", + "search_suggestion_list_smart_search_hint_2": "m:your-search-term", + "select_additional_user_for_sharing_page_suggestions": "Suggestions", + "select_user_for_sharing_page_err_album": "Failed to create album", + "select_user_for_sharing_page_share_suggestions": "Suggestions", + "server_endpoint": "Server Endpoint", + "server_info_box_app_version": "App Version", + "server_info_box_latest_release": "Latest Version", + "server_info_box_server_url": "Server URL", + "server_info_box_server_version": "Server Version", + "setting_image_viewer_help": "The detail viewer loads the small thumbnail first, then loads the medium-size preview (if enabled), finally loads the original (if enabled).", + "setting_image_viewer_original_subtitle": "Enable to load the original full-resolution image (large!). Disable to reduce data usage (both network and on device cache).", + "setting_image_viewer_original_title": "Load original image", + "setting_image_viewer_preview_subtitle": "Enable to load a medium-resolution image. Disable to either directly load the original or only use the thumbnail.", + "setting_image_viewer_preview_title": "Load preview image", + "setting_image_viewer_title": "Images", + "setting_languages_apply": "Apply", + "setting_languages_subtitle": "Change the app's language", + "setting_languages_title": "Languages", + "setting_notifications_notify_failures_grace_period": "Notify background backup failures: {}", + "setting_notifications_notify_hours": "{} hours", + "setting_notifications_notify_immediately": "immediately", + "setting_notifications_notify_minutes": "{} minutes", + "setting_notifications_notify_never": "never", + "setting_notifications_notify_seconds": "{} seconds", + "setting_notifications_single_progress_subtitle": "Detailed upload progress information per asset", + "setting_notifications_single_progress_title": "Show background backup detail progress", + "setting_notifications_subtitle": "Adjust your notification preferences", + "setting_notifications_title": "Notifications", + "setting_notifications_total_progress_subtitle": "Overall upload progress (done/total assets)", + "setting_notifications_total_progress_title": "Show background backup total progress", + "setting_pages_app_bar_settings": "Settings", + "settings_require_restart": "Please restart Immich to apply this setting", + "setting_video_viewer_looping_subtitle": "Enable to automatically loop a video in the detail viewer.", + "setting_video_viewer_looping_title": "Looping", + "setting_video_viewer_original_video_subtitle": "When streaming a video from the server, play the original even when a transcode is available. May lead to buffering. Videos available locally are played in original quality regardless of this setting.", + "setting_video_viewer_original_video_title": "Force original video", + "setting_video_viewer_title": "Videos", + "share_add": "Add", + "share_add_photos": "Add photos", + "share_add_title": "Add a title", + "share_assets_selected": "{} selected", + "share_create_album": "Create album", + "shared_album_activities_input_disable": "Comment is disabled", + "shared_album_activities_input_hint": "Say something", + "shared_album_activity_remove_content": "Do you want to delete this activity?", + "shared_album_activity_remove_title": "Delete Activity", + "shared_album_activity_setting_subtitle": "Let others respond", + "shared_album_activity_setting_title": "Comments & likes", + "shared_album_section_people_action_error": "Error leaving/removing from album", + "shared_album_section_people_action_leave": "Remove user from album", + "shared_album_section_people_action_remove_user": "Remove user from album", + "shared_album_section_people_owner_label": "Owner", + "shared_album_section_people_title": "PEOPLE", + "share_dialog_preparing": "Preparing...", + "shared_intent_upload_button_progress_text": "{} / {} Uploaded", + "shared_link_app_bar_title": "Shared Links", + "shared_link_clipboard_copied_massage": "Copied to clipboard", + "shared_link_clipboard_text": "Link: {}\nPassword: {}", + "shared_link_create_app_bar_title": "Create link to share", + "shared_link_create_error": "Error while creating shared link", + "shared_link_create_info": "Let anyone with the link see the selected photo(s)", + "shared_link_create_submit_button": "Create link", + "shared_link_edit_allow_download": "Allow public user to download", + "shared_link_edit_allow_upload": "Allow public user to upload", + "shared_link_edit_app_bar_title": "Edit link", + "shared_link_edit_change_expiry": "Change expiration time", + "shared_link_edit_description": "Description", + "shared_link_edit_description_hint": "Enter the share description", + "shared_link_edit_expire_after": "Expire after", + "shared_link_edit_expire_after_option_day": "1 day", + "shared_link_edit_expire_after_option_days": "{} days", + "shared_link_edit_expire_after_option_hour": "1 hour", + "shared_link_edit_expire_after_option_hours": "{} hours", + "shared_link_edit_expire_after_option_minute": "1 minute", + "shared_link_edit_expire_after_option_minutes": "{} minutes", + "shared_link_edit_expire_after_option_months": "{} months", + "shared_link_edit_expire_after_option_never": "Never", + "shared_link_edit_expire_after_option_year": "{} year", + "shared_link_edit_password": "Password", + "shared_link_edit_password_hint": "Enter the share password", + "shared_link_edit_show_meta": "Show metadata", + "shared_link_edit_submit_button": "Update link", + "shared_link_empty": "You don't have any shared links", + "shared_link_error_server_url_fetch": "Cannot fetch the server url", + "shared_link_expired": "Expired", + "shared_link_expires_day": "Expires in {} day", + "shared_link_expires_days": "Expires in {} days", + "shared_link_expires_hour": "Expires in {} hour", + "shared_link_expires_hours": "Expires in {} hours", + "shared_link_expires_minute": "Expires in {} minute", + "shared_link_expires_minutes": "Expires in {} minutes", + "shared_link_expires_never": "Expires ∞", + "shared_link_expires_second": "Expires in {} second", + "shared_link_expires_seconds": "Expires in {} seconds", + "shared_link_individual_shared": "Individual shared", + "shared_link_info_chip_download": "Download", + "shared_link_info_chip_metadata": "EXIF", + "shared_link_info_chip_upload": "Upload", + "shared_link_manage_links": "Manage Shared links", + "shared_link_public_album": "Public album", + "shared_links": "Shared links", + "share_done": "Done", + "shared_with_me": "Shared with me", + "share_invite": "Invite to album", + "sharing_page_album": "Shared albums", + "sharing_page_description": "Create shared albums to share photos and videos with people in your network.", + "sharing_page_empty_list": "EMPTY LIST", + "sharing_silver_appbar_create_shared_album": "New shared album", + "sharing_silver_appbar_shared_links": "Shared links", + "sharing_silver_appbar_share_partner": "Share with partner", + "start_date": "Start date", + "sync": "Sync", + "sync_albums": "Sync albums", + "sync_albums_manual_subtitle": "Sync all uploaded videos and photos to the selected backup albums", + "sync_upload_album_setting_subtitle": "Create and upload your photos and videos to the selected albums on Immich", + "tab_controller_nav_library": "Library", + "tab_controller_nav_photos": "Photos", + "tab_controller_nav_search": "Search", + "tab_controller_nav_sharing": "Sharing", + "theme_setting_asset_list_storage_indicator_title": "Show storage indicator on asset tiles", + "theme_setting_asset_list_tiles_per_row_title": "Number of assets per row ({})", + "theme_setting_colorful_interface_subtitle": "Apply primary color to background surfaces.", + "theme_setting_colorful_interface_title": "Colorful interface", + "theme_setting_dark_mode_switch": "Dark mode", + "theme_setting_image_viewer_quality_subtitle": "Adjust the quality of the detail image viewer", + "theme_setting_image_viewer_quality_title": "Image viewer quality", + "theme_setting_primary_color_subtitle": "Pick a color for primary actions and accents.", + "theme_setting_primary_color_title": "Primary color", + "theme_setting_system_primary_color_title": "Use system color", + "theme_setting_system_theme_switch": "Automatic (Follow system setting)", + "theme_setting_theme_subtitle": "Choose the app's theme setting", + "theme_setting_theme_title": "Theme", + "theme_setting_three_stage_loading_subtitle": "Three-stage loading might increase the loading performance but causes significantly higher network load", + "theme_setting_three_stage_loading_title": "Enable three-stage loading", + "translated_text_options": "Options", + "trash": "Trash", + "trash_emptied": "Emptied trash", + "trash_page_delete": "Delete", + "trash_page_delete_all": "Delete All", + "trash_page_empty_trash_btn": "Empty trash", + "trash_page_empty_trash_dialog_content": "Do you want to empty your trashed assets? These items will be permanently removed from Immich", + "trash_page_empty_trash_dialog_ok": "Ok", + "trash_page_info": "Trashed items will be permanently deleted after {} days", + "trash_page_no_assets": "No trashed assets", + "trash_page_restore": "Restore", + "trash_page_restore_all": "Restore All", + "trash_page_select_assets_btn": "Select assets", + "trash_page_select_btn": "Select", + "trash_page_title": "Trash ({})", + "upload": "Upload", + "upload_dialog_cancel": "Cancel", + "upload_dialog_info": "Do you want to backup the selected Asset(s) to the server?", + "upload_dialog_ok": "Upload", + "upload_dialog_title": "Upload Asset", + "uploading": "Uploading", + "upload_to_immich": "Upload to Immich ({})", + "use_current_connection": "use current connection", + "validate_endpoint_error": "Please enter a valid URL", + "version_announcement_overlay_ack": "Acknowledge", + "version_announcement_overlay_release_notes": "release notes", + "version_announcement_overlay_text_1": "Hi friend, there is a new release of", + "version_announcement_overlay_text_2": "please take your time to visit the ", + "version_announcement_overlay_text_3": " and ensure your docker-compose and .env setup is up-to-date to prevent any misconfigurations, especially if you use WatchTower or any mechanism that handles updating your server application automatically.", + "version_announcement_overlay_title": "New Server Version Available \uD83C\uDF89", + "videos": "Videos", + "viewer_remove_from_stack": "Remove from Stack", + "viewer_stack_use_as_main_asset": "Use as Main Asset", + "viewer_unstack": "Un-Stack", + "wifi_name": "WiFi Name", + "your_wifi_name": "Your WiFi name" +} From ba01b40e7c3ff520c00463e7630d82b2ae517b16 Mon Sep 17 00:00:00 2001 From: Mert <101130780+mertalev@users.noreply.github.com> Date: Fri, 24 Jan 2025 14:01:55 -0500 Subject: [PATCH 015/395] fix(server): `sslmode` not working (#15587) * parse db url before passing it to the driver * don't be lazy * simplify * simplify * add tests * update sql sync script * update mock * remove unused import * remove unused imports --- server/src/app.module.ts | 16 +++- server/src/bin/sync-sql.ts | 4 +- .../repositories/config.repository.spec.ts | 78 +++++++++++++++++-- server/src/repositories/config.repository.ts | 75 +++++++++++------- server/src/services/database.service.spec.ts | 22 ++++-- .../repositories/config.repository.mock.ts | 9 +-- 6 files changed, 154 insertions(+), 50 deletions(-) diff --git a/server/src/app.module.ts b/server/src/app.module.ts index cd19972206..0096cc6c26 100644 --- a/server/src/app.module.ts +++ b/server/src/app.module.ts @@ -3,9 +3,11 @@ import { Inject, Module, OnModuleDestroy, OnModuleInit, ValidationPipe } from '@ import { APP_FILTER, APP_GUARD, APP_INTERCEPTOR, APP_PIPE, ModuleRef } from '@nestjs/core'; import { ScheduleModule, SchedulerRegistry } from '@nestjs/schedule'; import { TypeOrmModule } from '@nestjs/typeorm'; +import { PostgresJSDialect } from 'kysely-postgres-js'; import { ClsModule } from 'nestjs-cls'; import { KyselyModule } from 'nestjs-kysely'; import { OpenTelemetryModule } from 'nestjs-otel'; +import postgres from 'postgres'; import { commands } from 'src/commands'; import { IWorker } from 'src/constants'; import { controllers } from 'src/controllers'; @@ -57,7 +59,19 @@ const imports = [ }, }), TypeOrmModule.forFeature(entities), - KyselyModule.forRoot(database.config.kysely), + KyselyModule.forRoot({ + dialect: new PostgresJSDialect({ postgres: postgres(database.config.kysely) }), + log(event) { + if (event.level === 'error') { + console.error('Query failed :', { + durationMs: event.queryDurationMillis, + error: event.error, + sql: event.query.sql, + params: event.query.parameters, + }); + } + }, + }), ]; class BaseModule implements OnModuleInit, OnModuleDestroy { diff --git a/server/src/bin/sync-sql.ts b/server/src/bin/sync-sql.ts index c25e1c8a90..e0d578d58f 100644 --- a/server/src/bin/sync-sql.ts +++ b/server/src/bin/sync-sql.ts @@ -4,10 +4,12 @@ import { Reflector } from '@nestjs/core'; import { SchedulerRegistry } from '@nestjs/schedule'; import { Test } from '@nestjs/testing'; import { TypeOrmModule } from '@nestjs/typeorm'; +import { PostgresJSDialect } from 'kysely-postgres-js'; import { KyselyModule } from 'nestjs-kysely'; import { OpenTelemetryModule } from 'nestjs-otel'; import { mkdir, rm, writeFile } from 'node:fs/promises'; import { join } from 'node:path'; +import postgres from 'postgres'; import { format } from 'sql-formatter'; import { GENERATE_SQL_KEY, GenerateSqlQueries } from 'src/decorators'; import { entities } from 'src/entities'; @@ -84,7 +86,7 @@ class SqlGenerator { const moduleFixture = await Test.createTestingModule({ imports: [ KyselyModule.forRoot({ - ...database.config.kysely, + dialect: new PostgresJSDialect({ postgres: postgres(database.config.kysely) }), log: (event) => { if (event.level === 'query') { this.sqlLogger.logQuery(event.query.sql); diff --git a/server/src/repositories/config.repository.spec.ts b/server/src/repositories/config.repository.spec.ts index 19068ddc5d..2b5343f7ba 100644 --- a/server/src/repositories/config.repository.spec.ts +++ b/server/src/repositories/config.repository.spec.ts @@ -1,4 +1,3 @@ -import { PostgresJSDialect } from 'kysely-postgres-js'; import { ImmichTelemetry } from 'src/enum'; import { clearEnvCache, ConfigRepository } from 'src/repositories/config.repository'; @@ -81,10 +80,13 @@ describe('getEnv', () => { const { database } = getEnv(); expect(database).toEqual({ config: { - kysely: { - dialect: expect.any(PostgresJSDialect), - log: expect.any(Function), - }, + kysely: expect.objectContaining({ + host: 'database', + port: 5432, + database: 'immich', + username: 'postgres', + password: 'postgres', + }), typeorm: expect.objectContaining({ type: 'postgres', host: 'database', @@ -104,6 +106,72 @@ describe('getEnv', () => { const { database } = getEnv(); expect(database).toMatchObject({ skipMigrations: true }); }); + + it('should use DB_URL', () => { + process.env.DB_URL = 'postgres://postgres1:postgres2@database1:54320/immich'; + const { database } = getEnv(); + expect(database.config.kysely).toMatchObject({ + host: 'database1', + password: 'postgres2', + user: 'postgres1', + port: 54_320, + database: 'immich', + }); + }); + + it('should handle sslmode=require', () => { + process.env.DB_URL = 'postgres://postgres1:postgres2@database1:54320/immich?sslmode=require'; + + const { database } = getEnv(); + + expect(database.config.kysely).toMatchObject({ ssl: {} }); + }); + + it('should handle sslmode=prefer', () => { + process.env.DB_URL = 'postgres://postgres1:postgres2@database1:54320/immich?sslmode=prefer'; + + const { database } = getEnv(); + + expect(database.config.kysely).toMatchObject({ ssl: {} }); + }); + + it('should handle sslmode=verify-ca', () => { + process.env.DB_URL = 'postgres://postgres1:postgres2@database1:54320/immich?sslmode=verify-ca'; + + const { database } = getEnv(); + + expect(database.config.kysely).toMatchObject({ ssl: {} }); + }); + + it('should handle sslmode=verify-full', () => { + process.env.DB_URL = 'postgres://postgres1:postgres2@database1:54320/immich?sslmode=verify-full'; + + const { database } = getEnv(); + + expect(database.config.kysely).toMatchObject({ ssl: {} }); + }); + + it('should handle sslmode=no-verify', () => { + process.env.DB_URL = 'postgres://postgres1:postgres2@database1:54320/immich?sslmode=no-verify'; + + const { database } = getEnv(); + + expect(database.config.kysely).toMatchObject({ ssl: { rejectUnauthorized: false } }); + }); + + it('should handle ssl=true', () => { + process.env.DB_URL = 'postgres://postgres1:postgres2@database1:54320/immich?ssl=true'; + + const { database } = getEnv(); + + expect(database.config.kysely).toMatchObject({ ssl: true }); + }); + + it('should reject invalid ssl', () => { + process.env.DB_URL = 'postgres://postgres1:postgres2@database1:54320/immich?ssl=invalid'; + + expect(() => getEnv()).toThrowError('Invalid ssl option: invalid'); + }); }); describe('redis', () => { diff --git a/server/src/repositories/config.repository.ts b/server/src/repositories/config.repository.ts index d78e473da2..a2af1b61b3 100644 --- a/server/src/repositories/config.repository.ts +++ b/server/src/repositories/config.repository.ts @@ -5,12 +5,11 @@ import { plainToInstance } from 'class-transformer'; import { validateSync } from 'class-validator'; import { Request, Response } from 'express'; import { RedisOptions } from 'ioredis'; -import { KyselyConfig } from 'kysely'; -import { PostgresJSDialect } from 'kysely-postgres-js'; import { CLS_ID, ClsModuleOptions } from 'nestjs-cls'; import { OpenTelemetryModuleOptions } from 'nestjs-otel/lib/interfaces'; import { join, resolve } from 'node:path'; -import postgres, { Notice } from 'postgres'; +import { parse } from 'pg-connection-string'; +import { Notice } from 'postgres'; import { citiesFile, excludePaths, IWorker } from 'src/constants'; import { Telemetry } from 'src/decorators'; import { EnvDto } from 'src/dtos/env.dto'; @@ -20,6 +19,20 @@ import { QueueName } from 'src/interfaces/job.interface'; import { setDifference } from 'src/utils/set'; import { PostgresConnectionOptions } from 'typeorm/driver/postgres/PostgresConnectionOptions.js'; +type Ssl = 'require' | 'allow' | 'prefer' | 'verify-full' | boolean | object; +type PostgresConnectionConfig = { + host?: string; + password?: string; + user?: string; + port?: number; + database?: string; + client_encoding?: string; + ssl?: Ssl; + application_name?: string; + fallback_application_name?: string; + options?: string; +}; + export interface EnvData { host?: string; port: number; @@ -53,7 +66,7 @@ export interface EnvData { }; database: { - config: { typeorm: PostgresConnectionOptions & DatabaseConnectionParams; kysely: KyselyConfig }; + config: { typeorm: PostgresConnectionOptions & DatabaseConnectionParams; kysely: PostgresConnectionConfig }; skipMigrations: boolean; vectorExtension: VectorExtension; }; @@ -124,6 +137,9 @@ const asSet = (value: string | undefined, defaults: T[]) => { return new Set(values.length === 0 ? defaults : (values as T[])); }; +const isValidSsl = (ssl?: string | boolean | object): ssl is Ssl => + typeof ssl !== 'string' || ssl === 'require' || ssl === 'allow' || ssl === 'prefer' || ssl === 'verify-full'; + const getEnv = (): EnvData => { const dto = plainToInstance(EnvDto, process.env); const errors = validateSync(dto); @@ -185,6 +201,31 @@ const getEnv = (): EnvData => { } } + const parts = { + connectionType: 'parts', + host: dto.DB_HOSTNAME || 'database', + port: dto.DB_PORT || 5432, + username: dto.DB_USERNAME || 'postgres', + password: dto.DB_PASSWORD || 'postgres', + database: dto.DB_DATABASE_NAME || 'immich', + } as const; + + let parsedOptions: PostgresConnectionConfig = parts; + if (dto.DB_URL) { + const parsed = parse(dto.DB_URL); + if (!isValidSsl(parsed.ssl)) { + throw new Error(`Invalid ssl option: ${parsed.ssl}`); + } + + parsedOptions = { + ...parsed, + ssl: parsed.ssl, + host: parsed.host ?? undefined, + port: parsed.port ? Number(parsed.port) : undefined, + database: parsed.database ?? undefined, + }; + } + const driverOptions = { onnotice: (notice: Notice) => { if (notice['severity'] !== 'NOTICE') { @@ -206,17 +247,9 @@ const getEnv = (): EnvData => { serialize: (value: number) => value.toString(), }, }, + ...parsedOptions, }; - const parts = { - connectionType: 'parts', - host: dto.DB_HOSTNAME || 'database', - port: dto.DB_PORT || 5432, - username: dto.DB_USERNAME || 'postgres', - password: dto.DB_PASSWORD || 'postgres', - database: dto.DB_DATABASE_NAME || 'immich', - } as const; - return { host: dto.IMMICH_HOST, port: dto.IMMICH_PORT || 2283, @@ -282,21 +315,7 @@ const getEnv = (): EnvData => { parseInt8: true, ...(databaseUrl ? { connectionType: 'url', url: databaseUrl } : parts), }, - kysely: { - dialect: new PostgresJSDialect({ - postgres: databaseUrl ? postgres(databaseUrl, driverOptions) : postgres({ ...parts, ...driverOptions }), - }), - log(event) { - if (event.level === 'error') { - console.error('Query failed :', { - durationMs: event.queryDurationMillis, - error: event.error, - sql: event.query.sql, - params: event.query.parameters, - }); - } - }, - }, + kysely: driverOptions, }, skipMigrations: dto.DB_SKIP_MIGRATIONS ?? false, diff --git a/server/src/services/database.service.spec.ts b/server/src/services/database.service.spec.ts index 477cb6931f..edd2f9dc62 100644 --- a/server/src/services/database.service.spec.ts +++ b/server/src/services/database.service.spec.ts @@ -1,4 +1,3 @@ -import { PostgresJSDialect } from 'kysely-postgres-js'; import { DatabaseExtension, EXTENSION_NAMES, @@ -62,8 +61,11 @@ describe(DatabaseService.name, () => { database: { config: { kysely: { - dialect: expect.any(PostgresJSDialect), - log: ['error'], + host: 'database', + port: 5432, + user: 'postgres', + password: 'postgres', + database: 'immich', }, typeorm: { connectionType: 'parts', @@ -298,8 +300,11 @@ describe(DatabaseService.name, () => { database: { config: { kysely: { - dialect: expect.any(PostgresJSDialect), - log: ['error'], + host: 'database', + port: 5432, + user: 'postgres', + password: 'postgres', + database: 'immich', }, typeorm: { connectionType: 'parts', @@ -328,8 +333,11 @@ describe(DatabaseService.name, () => { database: { config: { kysely: { - dialect: expect.any(PostgresJSDialect), - log: ['error'], + host: 'database', + port: 5432, + user: 'postgres', + password: 'postgres', + database: 'immich', }, typeorm: { connectionType: 'parts', diff --git a/server/test/repositories/config.repository.mock.ts b/server/test/repositories/config.repository.mock.ts index ab8731ea4d..2b195ae8c9 100644 --- a/server/test/repositories/config.repository.mock.ts +++ b/server/test/repositories/config.repository.mock.ts @@ -1,5 +1,3 @@ -import { PostgresJSDialect } from 'kysely-postgres-js'; -import postgres from 'postgres'; import { ImmichEnvironment, ImmichWorker } from 'src/enum'; import { DatabaseExtension } from 'src/interfaces/database.interface'; import { EnvData } from 'src/repositories/config.repository'; @@ -24,12 +22,7 @@ const envData: EnvData = { database: { config: { - kysely: { - dialect: new PostgresJSDialect({ - postgres: postgres({ database: 'immich', host: 'database', port: 5432 }), - }), - log: ['error'], - }, + kysely: { database: 'immich', host: 'database', port: 5432 }, typeorm: { connectionType: 'parts', database: 'immich', From 9871a04d54288b464d54033ce6ca5d58a1137e6a Mon Sep 17 00:00:00 2001 From: github-actions <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 24 Jan 2025 19:09:06 +0000 Subject: [PATCH 016/395] chore: version v1.125.2 --- cli/package-lock.json | 6 +++--- cli/package.json | 2 +- docs/static/archived-versions.json | 4 ++++ e2e/package-lock.json | 8 ++++---- e2e/package.json | 2 +- machine-learning/pyproject.toml | 2 +- mobile/android/fastlane/Fastfile | 4 ++-- mobile/ios/fastlane/Fastfile | 2 +- mobile/openapi/README.md | 2 +- mobile/pubspec.yaml | 2 +- open-api/immich-openapi-specs.json | 2 +- open-api/typescript-sdk/package-lock.json | 4 ++-- open-api/typescript-sdk/package.json | 2 +- open-api/typescript-sdk/src/fetch-client.ts | 2 +- server/package-lock.json | 4 ++-- server/package.json | 2 +- web/package-lock.json | 6 +++--- web/package.json | 2 +- 18 files changed, 31 insertions(+), 27 deletions(-) diff --git a/cli/package-lock.json b/cli/package-lock.json index f7364aaa21..75d01d7596 100644 --- a/cli/package-lock.json +++ b/cli/package-lock.json @@ -1,12 +1,12 @@ { "name": "@immich/cli", - "version": "2.2.42", + "version": "2.2.43", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@immich/cli", - "version": "2.2.42", + "version": "2.2.43", "license": "GNU Affero General Public License version 3", "dependencies": { "fast-glob": "^3.3.2", @@ -52,7 +52,7 @@ }, "../open-api/typescript-sdk": { "name": "@immich/sdk", - "version": "1.125.1", + "version": "1.125.2", "dev": true, "license": "GNU Affero General Public License version 3", "dependencies": { diff --git a/cli/package.json b/cli/package.json index b995d852de..810e9c4b3b 100644 --- a/cli/package.json +++ b/cli/package.json @@ -1,6 +1,6 @@ { "name": "@immich/cli", - "version": "2.2.42", + "version": "2.2.43", "description": "Command Line Interface (CLI) for Immich", "type": "module", "exports": "./dist/index.js", diff --git a/docs/static/archived-versions.json b/docs/static/archived-versions.json index 01975e79c1..6a33867146 100644 --- a/docs/static/archived-versions.json +++ b/docs/static/archived-versions.json @@ -1,4 +1,8 @@ [ + { + "label": "v1.125.2", + "url": "https://v1.125.2.archive.immich.app" + }, { "label": "v1.125.1", "url": "https://v1.125.1.archive.immich.app" diff --git a/e2e/package-lock.json b/e2e/package-lock.json index 31b79ce44b..2e03f9f4b5 100644 --- a/e2e/package-lock.json +++ b/e2e/package-lock.json @@ -1,12 +1,12 @@ { "name": "immich-e2e", - "version": "1.125.1", + "version": "1.125.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "immich-e2e", - "version": "1.125.1", + "version": "1.125.2", "license": "GNU Affero General Public License version 3", "devDependencies": { "@eslint/eslintrc": "^3.1.0", @@ -45,7 +45,7 @@ }, "../cli": { "name": "@immich/cli", - "version": "2.2.42", + "version": "2.2.43", "dev": true, "license": "GNU Affero General Public License version 3", "dependencies": { @@ -92,7 +92,7 @@ }, "../open-api/typescript-sdk": { "name": "@immich/sdk", - "version": "1.125.1", + "version": "1.125.2", "dev": true, "license": "GNU Affero General Public License version 3", "dependencies": { diff --git a/e2e/package.json b/e2e/package.json index 73b4a2dc29..35be7954f3 100644 --- a/e2e/package.json +++ b/e2e/package.json @@ -1,6 +1,6 @@ { "name": "immich-e2e", - "version": "1.125.1", + "version": "1.125.2", "description": "", "main": "index.js", "type": "module", diff --git a/machine-learning/pyproject.toml b/machine-learning/pyproject.toml index a89916dc68..15a23d1c95 100644 --- a/machine-learning/pyproject.toml +++ b/machine-learning/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "machine-learning" -version = "1.125.1" +version = "1.125.2" description = "" authors = ["Hau Tran "] readme = "README.md" diff --git a/mobile/android/fastlane/Fastfile b/mobile/android/fastlane/Fastfile index eec5f8bc88..be26d489dd 100644 --- a/mobile/android/fastlane/Fastfile +++ b/mobile/android/fastlane/Fastfile @@ -35,8 +35,8 @@ platform :android do task: 'bundle', build_type: 'Release', properties: { - "android.injected.version.code" => 177, - "android.injected.version.name" => "1.125.1", + "android.injected.version.code" => 178, + "android.injected.version.name" => "1.125.2", } ) upload_to_play_store(skip_upload_apk: true, skip_upload_images: true, skip_upload_screenshots: true, aab: '../build/app/outputs/bundle/release/app-release.aab') diff --git a/mobile/ios/fastlane/Fastfile b/mobile/ios/fastlane/Fastfile index c88974a9e5..9c2284a9f1 100644 --- a/mobile/ios/fastlane/Fastfile +++ b/mobile/ios/fastlane/Fastfile @@ -19,7 +19,7 @@ platform :ios do desc "iOS Release" lane :release do increment_version_number( - version_number: "1.125.1" + version_number: "1.125.2" ) increment_build_number( build_number: latest_testflight_build_number + 1, diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index f239026c0a..f14203b55d 100644 --- a/mobile/openapi/README.md +++ b/mobile/openapi/README.md @@ -3,7 +3,7 @@ Immich API This Dart package is automatically generated by the [OpenAPI Generator](https://openapi-generator.tech) project: -- API version: 1.125.1 +- API version: 1.125.2 - Generator version: 7.8.0 - Build package: org.openapitools.codegen.languages.DartClientCodegen diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml index 140ec7291d..d4b39e5cf0 100644 --- a/mobile/pubspec.yaml +++ b/mobile/pubspec.yaml @@ -2,7 +2,7 @@ name: immich_mobile description: Immich - selfhosted backup media file on mobile phone publish_to: 'none' -version: 1.125.1+177 +version: 1.125.2+178 environment: sdk: '>=3.3.0 <4.0.0' diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index 7ce4e0e300..5aac20c1c0 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -7454,7 +7454,7 @@ "info": { "title": "Immich", "description": "Immich API", - "version": "1.125.1", + "version": "1.125.2", "contact": {} }, "tags": [], diff --git a/open-api/typescript-sdk/package-lock.json b/open-api/typescript-sdk/package-lock.json index 45410e78a0..d77e997d4b 100644 --- a/open-api/typescript-sdk/package-lock.json +++ b/open-api/typescript-sdk/package-lock.json @@ -1,12 +1,12 @@ { "name": "@immich/sdk", - "version": "1.125.1", + "version": "1.125.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@immich/sdk", - "version": "1.125.1", + "version": "1.125.2", "license": "GNU Affero General Public License version 3", "dependencies": { "@oazapfts/runtime": "^1.0.2" diff --git a/open-api/typescript-sdk/package.json b/open-api/typescript-sdk/package.json index 5d94e0e70d..e36eaa5733 100644 --- a/open-api/typescript-sdk/package.json +++ b/open-api/typescript-sdk/package.json @@ -1,6 +1,6 @@ { "name": "@immich/sdk", - "version": "1.125.1", + "version": "1.125.2", "description": "Auto-generated TypeScript SDK for the Immich API", "type": "module", "main": "./build/index.js", diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index 7a4c0cd7ee..ce521bf847 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -1,6 +1,6 @@ /** * Immich - * 1.125.1 + * 1.125.2 * DO NOT MODIFY - This file has been generated using oazapfts. * See https://www.npmjs.com/package/oazapfts */ diff --git a/server/package-lock.json b/server/package-lock.json index 248792918d..18f066e7eb 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -1,12 +1,12 @@ { "name": "immich", - "version": "1.125.1", + "version": "1.125.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "immich", - "version": "1.125.1", + "version": "1.125.2", "license": "GNU Affero General Public License version 3", "dependencies": { "@nestjs/bullmq": "^11.0.0", diff --git a/server/package.json b/server/package.json index 7f93d4e503..3ccd2db179 100644 --- a/server/package.json +++ b/server/package.json @@ -1,6 +1,6 @@ { "name": "immich", - "version": "1.125.1", + "version": "1.125.2", "description": "", "author": "", "private": true, diff --git a/web/package-lock.json b/web/package-lock.json index 34366ca368..18d4413a06 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -1,12 +1,12 @@ { "name": "immich-web", - "version": "1.125.1", + "version": "1.125.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "immich-web", - "version": "1.125.1", + "version": "1.125.2", "license": "GNU Affero General Public License version 3", "dependencies": { "@formatjs/icu-messageformat-parser": "^2.9.8", @@ -75,7 +75,7 @@ }, "../open-api/typescript-sdk": { "name": "@immich/sdk", - "version": "1.125.1", + "version": "1.125.2", "license": "GNU Affero General Public License version 3", "dependencies": { "@oazapfts/runtime": "^1.0.2" diff --git a/web/package.json b/web/package.json index 1402c0b868..7daaadf3ff 100644 --- a/web/package.json +++ b/web/package.json @@ -1,6 +1,6 @@ { "name": "immich-web", - "version": "1.125.1", + "version": "1.125.2", "license": "GNU Affero General Public License version 3", "scripts": { "dev": "vite dev --host 0.0.0.0 --port 3000", From 72fa31f9e9bf43af1cbedfa79a6dd5c2ed0563ad Mon Sep 17 00:00:00 2001 From: Mert <101130780+mertalev@users.noreply.github.com> Date: Fri, 24 Jan 2025 20:01:24 -0500 Subject: [PATCH 017/395] fix(server): changing vector dim size (#15630) --- server/src/repositories/search.repository.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/src/repositories/search.repository.ts b/server/src/repositories/search.repository.ts index fb59157c80..a309f76e01 100644 --- a/server/src/repositories/search.repository.ts +++ b/server/src/repositories/search.repository.ts @@ -292,7 +292,7 @@ export class SearchRepository implements ISearchRepository { await sql`truncate ${sql.table('smart_search')}`.execute(trx); await trx.schema .alterTable('smart_search') - .alterColumn('embedding', (col) => col.setDataType(sql.lit(`vector(${dimSize})`))) + .alterColumn('embedding', (col) => col.setDataType(sql.raw(`vector(${dimSize})`))) .execute(); await sql`reindex index clip_index`.execute(trx); }); From 10e518db427ddcffae27832c43043678587fe3a7 Mon Sep 17 00:00:00 2001 From: Jonathan Jogenfors Date: Sat, 25 Jan 2025 04:45:55 +0100 Subject: [PATCH 018/395] chore(server): print stack in case of worker error (#15632) feat: show error stack --- server/src/main.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/src/main.ts b/server/src/main.ts index 3097eee69b..95b35c6915 100644 --- a/server/src/main.ts +++ b/server/src/main.ts @@ -13,7 +13,7 @@ if (immichApp) { let apiProcess: ChildProcess | undefined; const onError = (name: string, error: Error) => { - console.error(`${name} worker error: ${error}`); + console.error(`${name} worker error: ${error}, stack: ${error.stack}`); }; const onExit = (name: string, exitCode: number | null) => { From 39697cd9737980f41dc79f7b205a36f8fb112627 Mon Sep 17 00:00:00 2001 From: jdicioccio Date: Sat, 25 Jan 2025 00:26:52 -1000 Subject: [PATCH 019/395] fix: increase upload timeout (#15588) Fix upload timeout issue Fix an issue where when uploading a large file, the upload would consistently abort after 30 minutes. I changed this timeout from 30 minutes to 1 day. Maybe that's excessive, or maybe the timeout isn't even needed, but the current 30 minute timeout definitely seems way too short. --- server/src/workers/api.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/src/workers/api.ts b/server/src/workers/api.ts index d6dc7233d1..ddf6e50aa2 100644 --- a/server/src/workers/api.ts +++ b/server/src/workers/api.ts @@ -62,7 +62,7 @@ async function bootstrap() { app.use(app.get(ApiService).ssr(excludePaths)); const server = await (host ? app.listen(port, host) : app.listen(port)); - server.requestTimeout = 30 * 60 * 1000; + server.requestTimeout = 24 * 60 * 60 * 1000; logger.log(`Immich Server is listening on ${await app.getUrl()} [v${serverVersion}] [${environment}] `); } From 79592701dd2abb2f913ab23aa023e49b9b4dcc87 Mon Sep 17 00:00:00 2001 From: Regenxyz <92148448+idkwhyiusethisname@users.noreply.github.com> Date: Sat, 25 Jan 2025 17:30:53 +0700 Subject: [PATCH 020/395] chore: fix typos in Thai Language Readme (#15637) Update README_th_TH.md Fixing weird Thai Translate --- readme_i18n/README_th_TH.md | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/readme_i18n/README_th_TH.md b/readme_i18n/README_th_TH.md index 5a73251652..7735c3c854 100644 --- a/readme_i18n/README_th_TH.md +++ b/readme_i18n/README_th_TH.md @@ -40,12 +40,12 @@ Tiếng Việt

-## ข้อจำกัดความรับผิดชอบ +## ข้อควรระวัง -- ⚠️ โพรเจกต์นี้กำลังอยู่ระหว่างการพัฒนา**ที่มีการเปลี่ยนแปลงบ่อยมาก** -- ⚠️ คาดว่าจะมีข้อผิดพลาดและการเปลี่ยนแปลงที่ส่งผลเสีย -- ⚠️ **ห้ามใช้แอปนี้เป็นวิธีการเดียวในการจัดเก็บภาพถ่ายและวิดีโอของคุณ** -- ⚠️ ปฏิบัติตามแผนการสำรองข้อมูลแบบ [3-2-1](https://www.backblaze.com/blog/the-3-2-1-backup-strategy/) สำหรับภาพถ่ายและวิดีโอที่สำคัญของคุณอยู่เสมอ! +- ⚠️ โพรเจกต์นี้กำลังอยู่ระหว่างการพัฒนา**มีการเปลี่ยนแปลงบ่อยมาก** +- ⚠️ อาจจะเกิดข้อผิดพลาดและการเปลี่ยนแปลงที่ส่งผลเสีย +- ⚠️ **ห้ามใช้ระบบนี้เป็นวิธีการเดียวในการจัดเก็บภาพถ่ายและวิดีโอของคุณ** +- ⚠️ ปฏิบัติตามแผนการสำรองข้อมูลแบบ [3-2-1](https://www.backblaze.com/blog/the-3-2-1-backup-strategy/) สำหรับภาพถ่ายและวิดีโอที่สำคัญของคุณอยู่เสมอ > [!NOTE] > คุณสามารถหาคู่มือหลัก รวมถึงคู่มือการติดตั้ง ได้ที่ https://immich.app/ @@ -79,15 +79,15 @@ | :----------------------------------------- | ------ | ------ | | อัปโหลดและดูวิดีโอและภาพถ่าย | ใช่ | ใช่ | | การสำรองข้อมูลอัตโนมัติเมื่อเปิดแอป | ใช่ | N/A | -| ป้องกันการซ้ำซ้อนของไฟล์ | ใช่ | ใช่ | +| ป้องกันการซ้ำของไฟล์ | ใช่ | ใช่ | | เลือกอัลบั้มสำหรับสำรองข้อมูล | ใช่ | N/A | | ดาวน์โหลดภาพถ่ายและวิดีโอไปยังอุปกรณ์ | ใช่ | ใช่ | | รองรับผู้ใช้หลายคน | ใช่ | ใช่ | | อัลบั้มและอัลบั้มแชร์ | ใช่ | ใช่ | | แถบเลื่อนแบบลากได้ | ใช่ | ใช่ | | รองรับรูปแบบไฟล์ RAW | ใช่ | ใช่ | -| ดูข้อมูลเมตา (EXIF, แผนที่) | ใช่ | ใช่ | -| ค้นหาจากข้อมูลเมตา วัตถุ ใบหน้า และ CLIP | ใช่ | ใช่ | +| ดูข้อมูลเมตาดาต้า (EXIF, แผนที่) | ใช่ | ใช่ | +| ค้นหาจากข้อมูลเมตาดาต้า วัตถุ ใบหน้า และ CLIP | ใช่ | ใช่ | | ฟังก์ชันการจัดการผู้ดูแลระบบ | ไม่ใช่ | ใช่ | | การสำรองข้อมูลพื้นหลัง | ใช่ | N/A | | การเลื่อนแบบเสมือน | ใช่ | ใช่ | @@ -100,7 +100,7 @@ | การจัดเก็บและรายการโปรด | ใช่ | ใช่ | | แผนที่ทั่วโลก | ใช่ | ใช่ | | การแชร์กับคู่หู | ใช่ | ใช่ | -| การจดจำใบหน้าและการจัดกลุ่ม | ใช่ | ใช่ | +| ระบบจดจำใบหน้าและการจัดกลุ่ม | ใช่ | ใช่ | | ความทรงจำ (x ปีที่แล้ว) | ใช่ | ใช่ | | รองรับแบบออฟไลน์ | ใช่ | ไม่ใช่ | | แกลเลอรีแบบอ่านอย่างเดียว | ใช่ | ใช่ | @@ -108,13 +108,13 @@ ## การแปลภาษา -อ่านเพิ่มเติมเกี่ยวกับการแปลภาษา [ที่นี่](https://immich.app/docs/developer/translations) +อ่านเพิ่มเติมเกี่ยวกับการแปล [ที่นี่](https://immich.app/docs/developer/translations) สถานะการแปล -## กิจกรรมของคลังเก็บข้อมูล +## กิจกรรมของ Repository ![กิจกรรม](https://repobeats.axiom.co/api/embed/9e86d9dc3ddd137161f2f6d2e758d7863b1789cb.svg "ภาพการวิเคราะห์ของ Repobeats") From 947c053c159cdfa6a520587b606740a7193d86fc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Robert=20Sch=C3=BCtz?= Date: Sat, 25 Jan 2025 02:38:00 -0800 Subject: [PATCH 021/395] chore(server): add DB_URL supports Unix sockets unit test (#15629) * test(server): DB_URL supports Unix sockets * chore: format --------- Co-authored-by: Alex Tran --- .../repositories/config.repository.spec.ts | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/server/src/repositories/config.repository.spec.ts b/server/src/repositories/config.repository.spec.ts index 2b5343f7ba..888d5c33ec 100644 --- a/server/src/repositories/config.repository.spec.ts +++ b/server/src/repositories/config.repository.spec.ts @@ -172,6 +172,28 @@ describe('getEnv', () => { expect(() => getEnv()).toThrowError('Invalid ssl option: invalid'); }); + + it('should handle socket: URLs', () => { + process.env.DB_URL = 'socket:/run/postgresql?db=database1'; + + const { database } = getEnv(); + + expect(database.config.kysely).toMatchObject({ + host: '/run/postgresql', + database: 'database1', + }); + }); + + it('should handle sockets in postgres: URLs', () => { + process.env.DB_URL = 'postgres:///database2?host=/path/to/socket'; + + const { database } = getEnv(); + + expect(database.config.kysely).toMatchObject({ + host: '/path/to/socket', + database: 'database2', + }); + }); }); describe('redis', () => { From d12b1c907d68a9f9e6e4d459f6367c05f5872f03 Mon Sep 17 00:00:00 2001 From: Alex Date: Sat, 25 Jan 2025 11:58:07 -0600 Subject: [PATCH 022/395] fix(server): bulk update location (#15642) --- server/src/services/asset.service.spec.ts | 28 +++++++++++++++++++++++ server/src/services/asset.service.ts | 9 +++++++- 2 files changed, 36 insertions(+), 1 deletion(-) diff --git a/server/src/services/asset.service.spec.ts b/server/src/services/asset.service.spec.ts index bf36c181fc..8ff846d39d 100755 --- a/server/src/services/asset.service.spec.ts +++ b/server/src/services/asset.service.spec.ts @@ -416,6 +416,34 @@ describe(AssetService.name, () => { await sut.updateAll(authStub.admin, { ids: ['asset-1', 'asset-2'], isArchived: true }); expect(assetMock.updateAll).toHaveBeenCalledWith(['asset-1', 'asset-2'], { isArchived: true }); }); + + it('should not update Assets table if no relevant fields are provided', async () => { + accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1'])); + await sut.updateAll(authStub.admin, { + ids: ['asset-1'], + latitude: 0, + longitude: 0, + isArchived: undefined, + isFavorite: undefined, + duplicateId: undefined, + rating: undefined, + }); + expect(assetMock.updateAll).not.toHaveBeenCalled(); + }); + + it('should update Assets table if isArchived field is provided', async () => { + accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1'])); + await sut.updateAll(authStub.admin, { + ids: ['asset-1'], + latitude: 0, + longitude: 0, + isArchived: undefined, + isFavorite: false, + duplicateId: undefined, + rating: undefined, + }); + expect(assetMock.updateAll).toHaveBeenCalled(); + }); }); describe('deleteAll', () => { diff --git a/server/src/services/asset.service.ts b/server/src/services/asset.service.ts index 3913c0ce4c..99ddbb29cc 100644 --- a/server/src/services/asset.service.ts +++ b/server/src/services/asset.service.ts @@ -142,7 +142,14 @@ export class AssetService extends BaseService { await this.updateMetadata({ id, dateTimeOriginal, latitude, longitude }); } - await this.assetRepository.updateAll(ids, options); + if ( + options.isArchived != undefined || + options.isFavorite != undefined || + options.duplicateId != undefined || + options.rating != undefined + ) { + await this.assetRepository.updateAll(ids, options); + } } @OnJob({ name: JobName.ASSET_DELETION_CHECK, queue: QueueName.BACKGROUND_TASK }) From 19f2f888eea7802ddb0c75f2c75ea65cf91106fc Mon Sep 17 00:00:00 2001 From: Gagan Yadav Date: Sun, 26 Jan 2025 01:06:49 +0530 Subject: [PATCH 023/395] fix(mobile): improve timezone picker (#15615) - Fix missing timezones - Remove the UTC prefix from timezone display text to align with web app - Remove unnecessary layout builder - Created a custom `DropdownSearchMenu` widget Co-authored-by: Alex --- mobile/assets/i18n/en-US.json | 1 + .../lib/widgets/common/date_time_picker.dart | 143 +++++++-------- .../widgets/common/dropdown_search_menu.dart | 169 ++++++++++++++++++ 3 files changed, 239 insertions(+), 74 deletions(-) create mode 100644 mobile/lib/widgets/common/dropdown_search_menu.dart diff --git a/mobile/assets/i18n/en-US.json b/mobile/assets/i18n/en-US.json index 9450b4b44f..194871e18d 100644 --- a/mobile/assets/i18n/en-US.json +++ b/mobile/assets/i18n/en-US.json @@ -248,6 +248,7 @@ "download_waiting_to_retry": "Waiting to retry", "edit_date_time_dialog_date_time": "Date and Time", "edit_date_time_dialog_timezone": "Timezone", + "edit_date_time_dialog_search_timezone": "Search timezone...", "edit_image_title": "Edit", "edit_location_dialog_title": "Location", "end_date": "End date", diff --git a/mobile/lib/widgets/common/date_time_picker.dart b/mobile/lib/widgets/common/date_time_picker.dart index d90ee40e47..4e4e24e18c 100644 --- a/mobile/lib/widgets/common/date_time_picker.dart +++ b/mobile/lib/widgets/common/date_time_picker.dart @@ -4,6 +4,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/duration_extensions.dart'; +import 'package:immich_mobile/widgets/common/dropdown_search_menu.dart'; import 'package:timezone/timezone.dart' as tz; import 'package:timezone/timezone.dart'; @@ -24,7 +25,7 @@ Future showDateTimePicker({ } String _getFormattedOffset(int offsetInMilli, tz.Location location) { - return "${location.name} (UTC${Duration(milliseconds: offsetInMilli).formatAsOffset()})"; + return "${location.name} (${Duration(milliseconds: offsetInMilli).formatAsOffset()})"; } class _DateTimePicker extends HookWidget { @@ -73,7 +74,6 @@ class _DateTimePicker extends HookWidget { // returns a list of location along with it's offset in duration List<_TimeZoneOffset> getAllTimeZones() { return tz.timeZoneDatabase.locations.values - .where((l) => !l.currentTimeZone.abbreviation.contains("0")) .map(_TimeZoneOffset.fromLocation) .sorted() .toList(); @@ -133,83 +133,78 @@ class _DateTimePicker extends HookWidget { context.pop(dtWithOffset); } - return LayoutBuilder( - builder: (context, constraint) => AlertDialog( - contentPadding: - const EdgeInsets.symmetric(vertical: 32, horizontal: 18), - actions: [ - TextButton( - onPressed: () => context.pop(), - child: Text( - "action_common_cancel", - style: context.textTheme.bodyMedium?.copyWith( - fontWeight: FontWeight.w600, - color: context.colorScheme.error, + return AlertDialog( + contentPadding: const EdgeInsets.symmetric(vertical: 32, horizontal: 18), + actions: [ + TextButton( + onPressed: () => context.pop(), + child: Text( + "action_common_cancel", + style: context.textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.w600, + color: context.colorScheme.error, + ), + ).tr(), + ), + TextButton( + onPressed: popWithDateTime, + child: Text( + "action_common_update", + style: context.textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.w600, + color: context.primaryColor, + ), + ).tr(), + ), + ], + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + "edit_date_time_dialog_date_time", + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + ), + ).tr(), + const SizedBox(height: 32), + ListTile( + tileColor: context.colorScheme.surfaceContainerHighest, + shape: ShapeBorder.lerp( + RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10), ), + RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10), + ), + 1, + ), + trailing: Icon( + Icons.edit_outlined, + size: 18, + color: context.primaryColor, + ), + title: Text( + DateFormat("dd-MM-yyyy hh:mm a").format(date.value), + style: context.textTheme.bodyMedium, ).tr(), + onTap: pickDate, ), - TextButton( - onPressed: popWithDateTime, - child: Text( - "action_common_update", - style: context.textTheme.bodyMedium?.copyWith( - fontWeight: FontWeight.w600, - color: context.primaryColor, - ), - ).tr(), + const SizedBox(height: 24), + DropdownSearchMenu( + trailingIcon: Icon( + Icons.arrow_drop_down, + color: context.primaryColor, + ), + hintText: "edit_date_time_dialog_timezone".tr(), + label: const Text('edit_date_time_dialog_timezone').tr(), + textStyle: context.textTheme.bodyMedium, + onSelected: (value) => tzOffset.value = value, + initialSelection: tzOffset.value, + dropdownMenuEntries: menuEntries, ), ], - content: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Text( - "edit_date_time_dialog_date_time", - style: TextStyle( - fontSize: 16, - fontWeight: FontWeight.w600, - ), - ).tr(), - const SizedBox(height: 32), - ListTile( - tileColor: context.colorScheme.surfaceContainerHighest, - shape: ShapeBorder.lerp( - RoundedRectangleBorder( - borderRadius: BorderRadius.circular(10), - ), - RoundedRectangleBorder( - borderRadius: BorderRadius.circular(10), - ), - 1, - ), - trailing: Icon( - Icons.edit_outlined, - size: 18, - color: context.primaryColor, - ), - title: Text( - DateFormat("dd-MM-yyyy hh:mm a").format(date.value), - style: context.textTheme.bodyMedium, - ).tr(), - onTap: pickDate, - ), - const SizedBox(height: 24), - DropdownMenu( - width: 275, - menuHeight: 300, - trailingIcon: Icon( - Icons.arrow_drop_down, - color: context.primaryColor, - ), - hintText: "edit_date_time_dialog_timezone".tr(), - label: const Text('edit_date_time_dialog_timezone').tr(), - textStyle: context.textTheme.bodyMedium, - onSelected: (value) => tzOffset.value = value!, - initialSelection: tzOffset.value, - dropdownMenuEntries: menuEntries, - ), - ], - ), ), ); } diff --git a/mobile/lib/widgets/common/dropdown_search_menu.dart b/mobile/lib/widgets/common/dropdown_search_menu.dart new file mode 100644 index 0000000000..2fd5539b01 --- /dev/null +++ b/mobile/lib/widgets/common/dropdown_search_menu.dart @@ -0,0 +1,169 @@ +import 'package:collection/collection.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/scheduler.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:immich_mobile/extensions/build_context_extensions.dart'; + +class DropdownSearchMenu extends HookWidget { + const DropdownSearchMenu({ + super.key, + required this.dropdownMenuEntries, + this.initialSelection, + this.onSelected, + this.trailingIcon, + this.hintText, + this.label, + this.textStyle, + this.menuConstraints, + }); + + final List> dropdownMenuEntries; + final T? initialSelection; + final ValueChanged? onSelected; + final Widget? trailingIcon; + final String? hintText; + final Widget? label; + final TextStyle? textStyle; + final BoxConstraints? menuConstraints; + + @override + Widget build(BuildContext context) { + final selectedItem = useState?>( + dropdownMenuEntries + .firstWhereOrNull((item) => item.value == initialSelection), + ); + final showTimeZoneDropdown = useState(false); + + final effectiveConstraints = menuConstraints ?? + const BoxConstraints( + minWidth: 280, + maxWidth: 280, + minHeight: 0, + maxHeight: 280, + ); + + final inputDecoration = InputDecoration( + contentPadding: const EdgeInsets.fromLTRB(12, 4, 12, 4), + border: const OutlineInputBorder(), + suffixIcon: trailingIcon, + label: label, + hintText: hintText, + ).applyDefaults(context.themeData.inputDecorationTheme); + + if (!showTimeZoneDropdown.value) { + return ConstrainedBox( + constraints: effectiveConstraints, + child: GestureDetector( + onTap: () => showTimeZoneDropdown.value = true, + child: InputDecorator( + decoration: inputDecoration, + child: selectedItem.value != null + ? Text( + selectedItem.value!.label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: textStyle, + ) + : null, + ), + ), + ); + } + + return ConstrainedBox( + constraints: effectiveConstraints, + child: Autocomplete>( + displayStringForOption: (option) => option.label, + optionsBuilder: (textEditingValue) { + return dropdownMenuEntries.where( + (item) => item.label + .toLowerCase() + .trim() + .contains(textEditingValue.text.toLowerCase().trim()), + ); + }, + onSelected: (option) { + selectedItem.value = option; + showTimeZoneDropdown.value = false; + onSelected?.call(option.value); + }, + fieldViewBuilder: (context, textEditingController, focusNode, _) { + return TextField( + autofocus: true, + focusNode: focusNode, + controller: textEditingController, + decoration: inputDecoration.copyWith( + hintText: "edit_date_time_dialog_search_timezone".tr(), + ), + maxLines: 1, + style: context.textTheme.bodyMedium, + expands: false, + onTapOutside: (event) { + showTimeZoneDropdown.value = false; + focusNode.unfocus(); + }, + onSubmitted: (_) { + showTimeZoneDropdown.value = false; + }, + ); + }, + optionsViewBuilder: (context, onSelected, options) { + // This widget is a copy of the default implementation. + // We have only changed the `constraints` parameter. + return Align( + alignment: Alignment.topLeft, + child: ConstrainedBox( + constraints: effectiveConstraints, + child: Material( + elevation: 4.0, + child: ListView.builder( + padding: EdgeInsets.zero, + shrinkWrap: true, + itemCount: options.length, + itemBuilder: (BuildContext context, int index) { + final option = options.elementAt(index); + return InkWell( + onTap: () => onSelected(option), + child: Builder( + builder: (BuildContext context) { + final bool highlight = + AutocompleteHighlightedOption.of(context) == + index; + if (highlight) { + SchedulerBinding.instance.addPostFrameCallback( + (Duration timeStamp) { + Scrollable.ensureVisible( + context, + alignment: 0.5, + ); + }, + debugLabel: 'AutocompleteOptions.ensureVisible', + ); + } + return Container( + color: highlight + ? Theme.of(context) + .colorScheme + .onSurface + .withOpacity(0.12) + : null, + padding: const EdgeInsets.all(16.0), + child: Text( + option.label, + style: textStyle, + ), + ); + }, + ), + ); + }, + ), + ), + ), + ); + }, + ), + ); + } +} From 64b92cb24c6eae5c92e125e459f995329c013916 Mon Sep 17 00:00:00 2001 From: Carsten Otto Date: Sat, 25 Jan 2025 20:50:37 +0100 Subject: [PATCH 024/395] fix(server): do not reset fileCreatedDate (#15650) When marking an offline asset as online again, do not reset the fileCreatedAt value. This value contains the "true" date, copied from exif.dateTimeOriginal. If we overwrite this value, we'd need to run the metadata extraction job again. Instead, we just leave the old (and correct) value in place. fixes #15640 --- server/src/services/library.service.spec.ts | 22 +++++++++++++++++++-- server/src/services/library.service.ts | 1 - 2 files changed, 20 insertions(+), 3 deletions(-) diff --git a/server/src/services/library.service.spec.ts b/server/src/services/library.service.spec.ts index 5f81d92ec2..9f60e35dcc 100644 --- a/server/src/services/library.service.spec.ts +++ b/server/src/services/library.service.spec.ts @@ -337,12 +337,31 @@ describe(LibraryService.name, () => { expect(assetMock.updateAll).toHaveBeenCalledWith([assetStub.trashedOffline.id], { deletedAt: null, - fileCreatedAt: assetStub.trashedOffline.fileModifiedAt, fileModifiedAt: assetStub.trashedOffline.fileModifiedAt, isOffline: false, originalFileName: 'path.jpg', }); }); + + it('should not touch fileCreatedAt when un-trashing an asset previously marked as offline', async () => { + const mockAssetJob: ILibraryAssetJob = { + id: assetStub.external.id, + importPaths: ['/'], + exclusionPatterns: [], + }; + + assetMock.getById.mockResolvedValue(assetStub.trashedOffline); + storageMock.stat.mockResolvedValue({ mtime: assetStub.trashedOffline.fileModifiedAt } as Stats); + + await expect(sut.handleSyncAsset(mockAssetJob)).resolves.toBe(JobStatus.SUCCESS); + + expect(assetMock.updateAll).toHaveBeenCalledWith( + [assetStub.trashedOffline.id], + expect.not.objectContaining({ + fileCreatedAt: expect.anything(), + }), + ); + }); }); it('should update file when mtime has changed', async () => { @@ -360,7 +379,6 @@ describe(LibraryService.name, () => { expect(assetMock.updateAll).toHaveBeenCalledWith([assetStub.external.id], { fileModifiedAt: newMTime, - fileCreatedAt: newMTime, isOffline: false, originalFileName: 'photo.jpg', deletedAt: null, diff --git a/server/src/services/library.service.ts b/server/src/services/library.service.ts index dca1dec9e2..daccf01dce 100644 --- a/server/src/services/library.service.ts +++ b/server/src/services/library.service.ts @@ -511,7 +511,6 @@ export class LibraryService extends BaseService { await this.assetRepository.updateAll([asset.id], { isOffline: false, deletedAt: null, - fileCreatedAt: mtime, fileModifiedAt: mtime, originalFileName: parse(asset.originalPath).base, }); From 4f725b95e1df57e17637e7a5e280c50f95fc6f6e Mon Sep 17 00:00:00 2001 From: Carsten Otto Date: Sat, 25 Jan 2025 23:45:13 +0100 Subject: [PATCH 025/395] fix(server): do not count deleted assets for album summary (#15668) fixes #15645 fixes #15646 --- e2e/src/api/specs/album.e2e-spec.ts | 20 ++++++++++++++++++++ server/src/queries/album.repository.sql | 1 + server/src/repositories/album.repository.ts | 1 + 3 files changed, 22 insertions(+) diff --git a/e2e/src/api/specs/album.e2e-spec.ts b/e2e/src/api/specs/album.e2e-spec.ts index 5b40234e8d..1d142ac468 100644 --- a/e2e/src/api/specs/album.e2e-spec.ts +++ b/e2e/src/api/specs/album.e2e-spec.ts @@ -362,6 +362,26 @@ describe('/albums', () => { shared: true, }); }); + + it('should not count trashed assets', async () => { + await utils.deleteAssets(user1.accessToken, [user1Asset2.id]); + + const { status, body } = await request(app) + .get(`/albums/${user2Albums[0].id}?withoutAssets=true`) + .set('Authorization', `Bearer ${user1.accessToken}`); + + expect(status).toBe(200); + expect(body).toEqual({ + ...user2Albums[0], + assets: [], + assetCount: 1, + lastModifiedAssetTimestamp: expect.any(String), + endDate: expect.any(String), + startDate: expect.any(String), + albumUsers: expect.any(Array), + shared: true, + }); + }); }); describe('GET /albums/statistics', () => { diff --git a/server/src/queries/album.repository.sql b/server/src/queries/album.repository.sql index 48dc4dda4e..b982ea2cff 100644 --- a/server/src/queries/album.repository.sql +++ b/server/src/queries/album.repository.sql @@ -210,6 +210,7 @@ from left join "assets" on "assets"."id" = "album_assets"."assetsId" where "albums"."id" in ($1) + and "assets"."deletedAt" is null group by "albums"."id" diff --git a/server/src/repositories/album.repository.ts b/server/src/repositories/album.repository.ts index d3b696169b..c6e01b532e 100644 --- a/server/src/repositories/album.repository.ts +++ b/server/src/repositories/album.repository.ts @@ -126,6 +126,7 @@ export class AlbumRepository implements IAlbumRepository { .select((eb) => eb.fn.max('assets.fileCreatedAt').as('endDate')) .select((eb) => eb.fn.count('assets.id').as('assetCount')) .where('albums.id', 'in', ids) + .where('assets.deletedAt', 'is', null) .groupBy('albums.id') .execute(); From 05a446c259a3c1658934c85abdad9574fbdb2cf3 Mon Sep 17 00:00:00 2001 From: Mert <101130780+mertalev@users.noreply.github.com> Date: Sat, 25 Jan 2025 23:37:19 -0500 Subject: [PATCH 026/395] fix(server): avoid duplicate rows in album queries (#15670) * avoid duplicate rows * left join, handle null vs. undefined * update sql --- e2e/src/api/specs/album.e2e-spec.ts | 5 +- server/src/interfaces/album.interface.ts | 4 +- server/src/queries/album.repository.sql | 131 ++++++++------------ server/src/repositories/album.repository.ts | 69 ++++++----- server/src/services/album.service.spec.ts | 8 +- server/src/services/album.service.ts | 16 +-- 6 files changed, 103 insertions(+), 130 deletions(-) diff --git a/e2e/src/api/specs/album.e2e-spec.ts b/e2e/src/api/specs/album.e2e-spec.ts index 1d142ac468..5c087d1269 100644 --- a/e2e/src/api/specs/album.e2e-spec.ts +++ b/e2e/src/api/specs/album.e2e-spec.ts @@ -52,7 +52,10 @@ describe('/albums', () => { user1Albums = await Promise.all([ utils.createAlbum(user1.accessToken, { albumName: user1SharedEditorUser, - albumUsers: [{ userId: user2.userId, role: AlbumUserRole.Editor }], + albumUsers: [ + { userId: admin.userId, role: AlbumUserRole.Editor }, + { userId: user2.userId, role: AlbumUserRole.Editor }, + ], assetIds: [user1Asset1.id], }), utils.createAlbum(user1.accessToken, { diff --git a/server/src/interfaces/album.interface.ts b/server/src/interfaces/album.interface.ts index 7af1bd97e1..36a6d8a1d2 100644 --- a/server/src/interfaces/album.interface.ts +++ b/server/src/interfaces/album.interface.ts @@ -9,8 +9,8 @@ export const IAlbumRepository = 'IAlbumRepository'; export interface AlbumAssetCount { albumId: string; assetCount: number; - startDate: Date | undefined; - endDate: Date | undefined; + startDate: Date | null; + endDate: Date | null; } export interface AlbumInfoOptions { diff --git a/server/src/queries/album.repository.sql b/server/src/queries/album.repository.sql index b982ea2cff..217b0ce77b 100644 --- a/server/src/queries/album.repository.sql +++ b/server/src/queries/album.repository.sql @@ -90,7 +90,7 @@ select ( select "assets".*, - to_json("exif") as "exifInfo" + "exif" as "exifInfo" from "assets" inner join "exif" on "assets"."id" = "exif"."assetId" @@ -180,19 +180,20 @@ select ) as "albumUsers" from "albums" - left join "albums_assets_assets" as "album_assets" on "album_assets"."albumsId" = "albums"."id" - left join "albums_shared_users_users" as "album_users" on "album_users"."albumsId" = "albums"."id" + inner join "albums_assets_assets" as "album_assets" on "album_assets"."albumsId" = "albums"."id" where ( - ( - "albums"."ownerId" = $1 - and "album_assets"."assetsId" = $2 - ) - or ( - "album_users"."usersId" = $3 - and "album_assets"."assetsId" = $4 + "albums"."ownerId" = $1 + or exists ( + select + from + "albums_shared_users_users" as "album_users" + where + "album_users"."albumsId" = "albums"."id" + and "album_users"."usersId" = $2 ) ) + and "album_assets"."assetsId" = $3 and "albums"."deletedAt" is null order by "albums"."createdAt" desc, @@ -200,10 +201,10 @@ order by -- AlbumRepository.getMetadataForIds select - "albums"."id", + "albums"."id" as "albumId", min("assets"."fileCreatedAt") as "startDate", max("assets"."fileCreatedAt") as "endDate", - count("assets"."id") as "assetCount" + count("assets"."id")::int as "assetCount" from "albums" left join "albums_assets_assets" as "album_assets" on "album_assets"."albumsId" = "albums"."id" @@ -306,8 +307,8 @@ order by "albums"."createdAt" desc -- AlbumRepository.getShared -select distinct - on ("albums"."createdAt") "albums".*, +select + "albums".*, ( select coalesce(json_agg(agg), '[]') @@ -390,15 +391,26 @@ select distinct ) as "sharedLinks" from "albums" - left join "albums_shared_users_users" as "shared_albums" on "shared_albums"."albumsId" = "albums"."id" - left join "shared_links" on "shared_links"."albumId" = "albums"."id" where ( - "shared_albums"."usersId" = $1 - or "shared_links"."userId" = $2 - or ( - "albums"."ownerId" = $3 - and "shared_albums"."usersId" is not null + exists ( + select + from + "albums_shared_users_users" as "album_users" + where + "album_users"."albumsId" = "albums"."id" + and ( + "albums"."ownerId" = $1 + or "album_users"."usersId" = $2 + ) + ) + or exists ( + select + from + "shared_links" + where + "shared_links"."albumId" = "albums"."id" + and "shared_links"."userId" = $3 ) ) and "albums"."deletedAt" is null @@ -406,48 +418,8 @@ order by "albums"."createdAt" desc -- AlbumRepository.getNotShared -select distinct - on ("albums"."createdAt") "albums".*, - ( - select - coalesce(json_agg(agg), '[]') - from - ( - select - "album_users".*, - ( - select - to_json(obj) - from - ( - select - "id", - "email", - "createdAt", - "profileImagePath", - "isAdmin", - "shouldChangePassword", - "deletedAt", - "oauthId", - "updatedAt", - "storageLabel", - "name", - "quotaSizeInBytes", - "quotaUsageInBytes", - "status", - "profileChangedAt" - from - "users" - where - "users"."id" = "album_users"."usersId" - ) as obj - ) as "user" - from - "albums_shared_users_users" as "album_users" - where - "album_users"."albumsId" = "albums"."id" - ) as agg - ) as "albumUsers", +select + "albums".*, ( select to_json(obj) @@ -474,29 +446,26 @@ select distinct where "users"."id" = "albums"."ownerId" ) as obj - ) as "owner", - ( - select - coalesce(json_agg(agg), '[]') - from - ( - select - * - from - "shared_links" - where - "shared_links"."albumId" = "albums"."id" - ) as agg - ) as "sharedLinks" + ) as "owner" from "albums" - left join "albums_shared_users_users" as "shared_albums" on "shared_albums"."albumsId" = "albums"."id" - left join "shared_links" on "shared_links"."albumId" = "albums"."id" where "albums"."ownerId" = $1 - and "shared_albums"."usersId" is null - and "shared_links"."userId" is null and "albums"."deletedAt" is null + and not exists ( + select + from + "albums_shared_users_users" as "album_users" + where + "album_users"."albumsId" = "albums"."id" + ) + and not exists ( + select + from + "shared_links" + where + "shared_links"."albumId" = "albums"."id" + ) order by "albums"."createdAt" desc diff --git a/server/src/repositories/album.repository.ts b/server/src/repositories/album.repository.ts index c6e01b532e..d63fd2ed4f 100644 --- a/server/src/repositories/album.repository.ts +++ b/server/src/repositories/album.repository.ts @@ -59,7 +59,7 @@ const withAssets = (eb: ExpressionBuilder) => { .selectFrom('assets') .selectAll('assets') .innerJoin('exif', 'assets.id', 'exif.assetId') - .select((eb) => eb.fn.toJson('exif').as('exifInfo')) + .select((eb) => eb.table('exif').as('exifInfo')) .innerJoin('albums_assets_assets', 'albums_assets_assets.assetsId', 'assets.id') .whereRef('albums_assets_assets.albumsId', '=', 'albums.id') .where('assets.deletedAt', 'is', null) @@ -93,14 +93,19 @@ export class AlbumRepository implements IAlbumRepository { return this.db .selectFrom('albums') .selectAll('albums') - .leftJoin('albums_assets_assets as album_assets', 'album_assets.albumsId', 'albums.id') - .leftJoin('albums_shared_users_users as album_users', 'album_users.albumsId', 'albums.id') + .innerJoin('albums_assets_assets as album_assets', 'album_assets.albumsId', 'albums.id') .where((eb) => eb.or([ - eb.and([eb('albums.ownerId', '=', ownerId), eb('album_assets.assetsId', '=', assetId)]), - eb.and([eb('album_users.usersId', '=', ownerId), eb('album_assets.assetsId', '=', assetId)]), + eb('albums.ownerId', '=', ownerId), + eb.exists( + eb + .selectFrom('albums_shared_users_users as album_users') + .whereRef('album_users.albumsId', '=', 'albums.id') + .where('album_users.usersId', '=', ownerId), + ), ]), ) + .where('album_assets.assetsId', '=', assetId) .where('albums.deletedAt', 'is', null) .orderBy('albums.createdAt', 'desc') .select(withOwner) @@ -117,25 +122,18 @@ export class AlbumRepository implements IAlbumRepository { return []; } - const metadatas = await this.db + return this.db .selectFrom('albums') .leftJoin('albums_assets_assets as album_assets', 'album_assets.albumsId', 'albums.id') .leftJoin('assets', 'assets.id', 'album_assets.assetsId') - .select('albums.id') + .select('albums.id as albumId') .select((eb) => eb.fn.min('assets.fileCreatedAt').as('startDate')) .select((eb) => eb.fn.max('assets.fileCreatedAt').as('endDate')) - .select((eb) => eb.fn.count('assets.id').as('assetCount')) + .select((eb) => sql`${eb.fn.count('assets.id')}::int`.as('assetCount')) .where('albums.id', 'in', ids) .where('assets.deletedAt', 'is', null) .groupBy('albums.id') .execute(); - - return metadatas.map((metadatas) => ({ - albumId: metadatas.id, - assetCount: Number(metadatas.assetCount), - startDate: metadatas.startDate ? new Date(metadatas.startDate) : undefined, - endDate: metadatas.endDate ? new Date(metadatas.endDate) : undefined, - })); } @GenerateSql({ params: [DummyValue.UUID] }) @@ -160,14 +158,20 @@ export class AlbumRepository implements IAlbumRepository { return this.db .selectFrom('albums') .selectAll('albums') - .distinctOn('albums.createdAt') - .leftJoin('albums_shared_users_users as shared_albums', 'shared_albums.albumsId', 'albums.id') - .leftJoin('shared_links', 'shared_links.albumId', 'albums.id') .where((eb) => eb.or([ - eb('shared_albums.usersId', '=', ownerId), - eb('shared_links.userId', '=', ownerId), - eb.and([eb('albums.ownerId', '=', ownerId), eb('shared_albums.usersId', 'is not', null)]), + eb.exists( + eb + .selectFrom('albums_shared_users_users as album_users') + .whereRef('album_users.albumsId', '=', 'albums.id') + .where((eb) => eb.or([eb('albums.ownerId', '=', ownerId), eb('album_users.usersId', '=', ownerId)])), + ), + eb.exists( + eb + .selectFrom('shared_links') + .whereRef('shared_links.albumId', '=', 'albums.id') + .where('shared_links.userId', '=', ownerId), + ), ]), ) .where('albums.deletedAt', 'is', null) @@ -186,16 +190,21 @@ export class AlbumRepository implements IAlbumRepository { return this.db .selectFrom('albums') .selectAll('albums') - .distinctOn('albums.createdAt') - .leftJoin('albums_shared_users_users as shared_albums', 'shared_albums.albumsId', 'albums.id') - .leftJoin('shared_links', 'shared_links.albumId', 'albums.id') .where('albums.ownerId', '=', ownerId) - .where('shared_albums.usersId', 'is', null) - .where('shared_links.userId', 'is', null) .where('albums.deletedAt', 'is', null) - .select(withAlbumUsers) + .where((eb) => + eb.not( + eb.exists( + eb + .selectFrom('albums_shared_users_users as album_users') + .whereRef('album_users.albumsId', '=', 'albums.id'), + ), + ), + ) + .where((eb) => + eb.not(eb.exists(eb.selectFrom('shared_links').whereRef('shared_links.albumId', '=', 'albums.id'))), + ) .select(withOwner) - .select(withSharedLink) .orderBy('albums.createdAt', 'desc') .execute() as unknown as Promise; } @@ -282,7 +291,6 @@ export class AlbumRepository implements IAlbumRepository { .selectAll() .where('id', '=', newAlbum.id) .select(withOwner) - .select(withSharedLink) .select(withAssets) .select(withAlbumUsers) .executeTakeFirst() as unknown as Promise; @@ -292,7 +300,7 @@ export class AlbumRepository implements IAlbumRepository { update(id: string, album: Updateable): Promise { return this.db .updateTable('albums') - .set({ ...album, updatedAt: new Date() }) + .set(album) .where('id', '=', id) .returningAll('albums') .returning(withOwner) @@ -335,7 +343,6 @@ export class AlbumRepository implements IAlbumRepository { .select('album_assets.assetsId') .orderBy('assets.fileCreatedAt', 'desc') .limit(1), - updatedAt: new Date(), })) .where((eb) => eb.or([ diff --git a/server/src/services/album.service.spec.ts b/server/src/services/album.service.spec.ts index fe732843b6..942615b0d9 100644 --- a/server/src/services/album.service.spec.ts +++ b/server/src/services/album.service.spec.ts @@ -52,8 +52,8 @@ describe(AlbumService.name, () => { it('gets list of albums for auth user', async () => { albumMock.getOwned.mockResolvedValue([albumStub.empty, albumStub.sharedWithUser]); albumMock.getMetadataForIds.mockResolvedValue([ - { albumId: albumStub.empty.id, assetCount: 0, startDate: undefined, endDate: undefined }, - { albumId: albumStub.sharedWithUser.id, assetCount: 0, startDate: undefined, endDate: undefined }, + { albumId: albumStub.empty.id, assetCount: 0, startDate: null, endDate: null }, + { albumId: albumStub.sharedWithUser.id, assetCount: 0, startDate: null, endDate: null }, ]); const result = await sut.getAll(authStub.admin, {}); @@ -82,7 +82,7 @@ describe(AlbumService.name, () => { it('gets list of albums that are shared', async () => { albumMock.getShared.mockResolvedValue([albumStub.sharedWithUser]); albumMock.getMetadataForIds.mockResolvedValue([ - { albumId: albumStub.sharedWithUser.id, assetCount: 0, startDate: undefined, endDate: undefined }, + { albumId: albumStub.sharedWithUser.id, assetCount: 0, startDate: null, endDate: null }, ]); const result = await sut.getAll(authStub.admin, { shared: true }); @@ -94,7 +94,7 @@ describe(AlbumService.name, () => { it('gets list of albums that are NOT shared', async () => { albumMock.getNotShared.mockResolvedValue([albumStub.empty]); albumMock.getMetadataForIds.mockResolvedValue([ - { albumId: albumStub.empty.id, assetCount: 0, startDate: undefined, endDate: undefined }, + { albumId: albumStub.empty.id, assetCount: 0, startDate: null, endDate: null }, ]); const result = await sut.getAll(authStub.admin, { shared: false }); diff --git a/server/src/services/album.service.ts b/server/src/services/album.service.ts index efc71c4c8d..0b6c646801 100644 --- a/server/src/services/album.service.ts +++ b/server/src/services/album.service.ts @@ -55,13 +55,7 @@ export class AlbumService extends BaseService { const results = await this.albumRepository.getMetadataForIds(albums.map((album) => album.id)); const albumMetadata: Record = {}; for (const metadata of results) { - const { albumId, assetCount, startDate, endDate } = metadata; - albumMetadata[albumId] = { - albumId, - assetCount, - startDate, - endDate, - }; + albumMetadata[metadata.albumId] = metadata; } return Promise.all( @@ -70,8 +64,8 @@ export class AlbumService extends BaseService { return { ...mapAlbumWithoutAssets(album), sharedLinks: undefined, - startDate: albumMetadata[album.id].startDate, - endDate: albumMetadata[album.id].endDate, + startDate: albumMetadata[album.id].startDate ?? undefined, + endDate: albumMetadata[album.id].endDate ?? undefined, assetCount: albumMetadata[album.id].assetCount, lastModifiedAssetTimestamp: lastModifiedAsset?.updatedAt, }; @@ -89,8 +83,8 @@ export class AlbumService extends BaseService { return { ...mapAlbum(album, withAssets, auth), - startDate: albumMetadataForIds.startDate, - endDate: albumMetadataForIds.endDate, + startDate: albumMetadataForIds.startDate ?? undefined, + endDate: albumMetadataForIds.endDate ?? undefined, assetCount: albumMetadataForIds.assetCount, lastModifiedAssetTimestamp: lastModifiedAsset?.updatedAt, }; From 7bbffccf7601f0159629eb90a53db0e199a578df Mon Sep 17 00:00:00 2001 From: Alex Date: Sun, 26 Jan 2025 07:06:26 -0600 Subject: [PATCH 027/395] fix(web): neon overflow on mobile screen (#15676) --- web/src/lib/components/layouts/AuthPageLayout.svelte | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/web/src/lib/components/layouts/AuthPageLayout.svelte b/web/src/lib/components/layouts/AuthPageLayout.svelte index 19108956ff..7eae5d0847 100644 --- a/web/src/lib/components/layouts/AuthPageLayout.svelte +++ b/web/src/lib/components/layouts/AuthPageLayout.svelte @@ -11,7 +11,11 @@
- Immich logo + Immich logo
From f780a56e24b6d15ed14366f5ec942eff42c5babb Mon Sep 17 00:00:00 2001 From: Damiano Ferrari <34270884+ferraridamiano@users.noreply.github.com> Date: Sun, 26 Jan 2025 14:51:46 +0100 Subject: [PATCH 028/395] fix(mobile): Misaligned text icon in circle avatar (#15683) style(mobile): Use `DefaultTextStyle` for the text icon in `CircleAvatar` --- mobile/lib/widgets/common/user_circle_avatar.dart | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/mobile/lib/widgets/common/user_circle_avatar.dart b/mobile/lib/widgets/common/user_circle_avatar.dart index 50da009676..f90da6097b 100644 --- a/mobile/lib/widgets/common/user_circle_avatar.dart +++ b/mobile/lib/widgets/common/user_circle_avatar.dart @@ -28,8 +28,7 @@ class UserCircleAvatar extends ConsumerWidget { final profileImageUrl = '${Store.get(StoreKey.serverEndpoint)}/users/${user.id}/profile-image?d=${Random().nextInt(1024)}'; - final textIcon = Text( - user.name[0].toUpperCase(), + final textIcon = DefaultTextStyle( style: TextStyle( fontWeight: FontWeight.bold, fontSize: 12, @@ -37,6 +36,7 @@ class UserCircleAvatar extends ConsumerWidget { ? Colors.black : Colors.white, ), + child: Text(user.name[0].toUpperCase()), ); return CircleAvatar( backgroundColor: user.avatarColor.toColor(), From 206412267ac4abbd0617e11dd74fc843f1c4a026 Mon Sep 17 00:00:00 2001 From: sudbrack Date: Sun, 26 Jan 2025 08:06:18 -0600 Subject: [PATCH 029/395] fix(server): /search/random API returns same assets every call (#15682) * Fix for server searchRandom function not returning random results * Fix lint --- server/src/queries/search.repository.sql | 4 ++-- server/src/repositories/search.repository.ts | 10 ++++++++-- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/server/src/queries/search.repository.sql b/server/src/queries/search.repository.sql index 2d5da4d381..72e8a6941d 100644 --- a/server/src/queries/search.repository.sql +++ b/server/src/queries/search.repository.sql @@ -36,7 +36,7 @@ offset and "assets"."deletedAt" is null and "assets"."id" < $6 order by - "assets"."id" + random() limit $7 ) @@ -56,7 +56,7 @@ union all and "assets"."deletedAt" is null and "assets"."id" > $13 order by - "assets"."id" + random() limit $14 ) diff --git a/server/src/repositories/search.repository.ts b/server/src/repositories/search.repository.ts index a309f76e01..76b6653e3d 100644 --- a/server/src/repositories/search.repository.ts +++ b/server/src/repositories/search.repository.ts @@ -72,8 +72,14 @@ export class SearchRepository implements ISearchRepository { async searchRandom(size: number, options: AssetSearchOptions): Promise { const uuid = randomUUID(); const builder = searchAssetBuilder(this.db, options); - const lessThan = builder.where('assets.id', '<', uuid).orderBy('assets.id').limit(size); - const greaterThan = builder.where('assets.id', '>', uuid).orderBy('assets.id').limit(size); + const lessThan = builder + .where('assets.id', '<', uuid) + .orderBy(sql`random()`) + .limit(size); + const greaterThan = builder + .where('assets.id', '>', uuid) + .orderBy(sql`random()`) + .limit(size); const { rows } = await sql`${lessThan} union all ${greaterThan} limit ${size}`.execute(this.db); return rows as any as AssetEntity[]; } From 72a55c13b60036adccd9ea108276ca9a4bc37ade Mon Sep 17 00:00:00 2001 From: github-actions <41898282+github-actions[bot]@users.noreply.github.com> Date: Sun, 26 Jan 2025 14:14:48 +0000 Subject: [PATCH 030/395] chore: version v1.125.3 --- cli/package-lock.json | 6 +++--- cli/package.json | 2 +- docs/static/archived-versions.json | 4 ++++ e2e/package-lock.json | 8 ++++---- e2e/package.json | 2 +- machine-learning/pyproject.toml | 2 +- mobile/android/fastlane/Fastfile | 4 ++-- mobile/ios/fastlane/Fastfile | 2 +- mobile/openapi/README.md | 2 +- mobile/pubspec.yaml | 2 +- open-api/immich-openapi-specs.json | 2 +- open-api/typescript-sdk/package-lock.json | 4 ++-- open-api/typescript-sdk/package.json | 2 +- open-api/typescript-sdk/src/fetch-client.ts | 2 +- server/package-lock.json | 4 ++-- server/package.json | 2 +- web/package-lock.json | 6 +++--- web/package.json | 2 +- 18 files changed, 31 insertions(+), 27 deletions(-) diff --git a/cli/package-lock.json b/cli/package-lock.json index 75d01d7596..6fcbbcc5c9 100644 --- a/cli/package-lock.json +++ b/cli/package-lock.json @@ -1,12 +1,12 @@ { "name": "@immich/cli", - "version": "2.2.43", + "version": "2.2.44", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@immich/cli", - "version": "2.2.43", + "version": "2.2.44", "license": "GNU Affero General Public License version 3", "dependencies": { "fast-glob": "^3.3.2", @@ -52,7 +52,7 @@ }, "../open-api/typescript-sdk": { "name": "@immich/sdk", - "version": "1.125.2", + "version": "1.125.3", "dev": true, "license": "GNU Affero General Public License version 3", "dependencies": { diff --git a/cli/package.json b/cli/package.json index 810e9c4b3b..e90f48a2e2 100644 --- a/cli/package.json +++ b/cli/package.json @@ -1,6 +1,6 @@ { "name": "@immich/cli", - "version": "2.2.43", + "version": "2.2.44", "description": "Command Line Interface (CLI) for Immich", "type": "module", "exports": "./dist/index.js", diff --git a/docs/static/archived-versions.json b/docs/static/archived-versions.json index 6a33867146..422ce6a8fb 100644 --- a/docs/static/archived-versions.json +++ b/docs/static/archived-versions.json @@ -1,4 +1,8 @@ [ + { + "label": "v1.125.3", + "url": "https://v1.125.3.archive.immich.app" + }, { "label": "v1.125.2", "url": "https://v1.125.2.archive.immich.app" diff --git a/e2e/package-lock.json b/e2e/package-lock.json index 2e03f9f4b5..726dc58adc 100644 --- a/e2e/package-lock.json +++ b/e2e/package-lock.json @@ -1,12 +1,12 @@ { "name": "immich-e2e", - "version": "1.125.2", + "version": "1.125.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "immich-e2e", - "version": "1.125.2", + "version": "1.125.3", "license": "GNU Affero General Public License version 3", "devDependencies": { "@eslint/eslintrc": "^3.1.0", @@ -45,7 +45,7 @@ }, "../cli": { "name": "@immich/cli", - "version": "2.2.43", + "version": "2.2.44", "dev": true, "license": "GNU Affero General Public License version 3", "dependencies": { @@ -92,7 +92,7 @@ }, "../open-api/typescript-sdk": { "name": "@immich/sdk", - "version": "1.125.2", + "version": "1.125.3", "dev": true, "license": "GNU Affero General Public License version 3", "dependencies": { diff --git a/e2e/package.json b/e2e/package.json index 35be7954f3..ad19e4d059 100644 --- a/e2e/package.json +++ b/e2e/package.json @@ -1,6 +1,6 @@ { "name": "immich-e2e", - "version": "1.125.2", + "version": "1.125.3", "description": "", "main": "index.js", "type": "module", diff --git a/machine-learning/pyproject.toml b/machine-learning/pyproject.toml index 15a23d1c95..9c0600bc1f 100644 --- a/machine-learning/pyproject.toml +++ b/machine-learning/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "machine-learning" -version = "1.125.2" +version = "1.125.3" description = "" authors = ["Hau Tran "] readme = "README.md" diff --git a/mobile/android/fastlane/Fastfile b/mobile/android/fastlane/Fastfile index be26d489dd..824fdd9a97 100644 --- a/mobile/android/fastlane/Fastfile +++ b/mobile/android/fastlane/Fastfile @@ -35,8 +35,8 @@ platform :android do task: 'bundle', build_type: 'Release', properties: { - "android.injected.version.code" => 178, - "android.injected.version.name" => "1.125.2", + "android.injected.version.code" => 179, + "android.injected.version.name" => "1.125.3", } ) upload_to_play_store(skip_upload_apk: true, skip_upload_images: true, skip_upload_screenshots: true, aab: '../build/app/outputs/bundle/release/app-release.aab') diff --git a/mobile/ios/fastlane/Fastfile b/mobile/ios/fastlane/Fastfile index 9c2284a9f1..4c86002f5c 100644 --- a/mobile/ios/fastlane/Fastfile +++ b/mobile/ios/fastlane/Fastfile @@ -19,7 +19,7 @@ platform :ios do desc "iOS Release" lane :release do increment_version_number( - version_number: "1.125.2" + version_number: "1.125.3" ) increment_build_number( build_number: latest_testflight_build_number + 1, diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index f14203b55d..e0a88fcb43 100644 --- a/mobile/openapi/README.md +++ b/mobile/openapi/README.md @@ -3,7 +3,7 @@ Immich API This Dart package is automatically generated by the [OpenAPI Generator](https://openapi-generator.tech) project: -- API version: 1.125.2 +- API version: 1.125.3 - Generator version: 7.8.0 - Build package: org.openapitools.codegen.languages.DartClientCodegen diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml index d4b39e5cf0..29bbc86247 100644 --- a/mobile/pubspec.yaml +++ b/mobile/pubspec.yaml @@ -2,7 +2,7 @@ name: immich_mobile description: Immich - selfhosted backup media file on mobile phone publish_to: 'none' -version: 1.125.2+178 +version: 1.125.3+179 environment: sdk: '>=3.3.0 <4.0.0' diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index 5aac20c1c0..815dc7452d 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -7454,7 +7454,7 @@ "info": { "title": "Immich", "description": "Immich API", - "version": "1.125.2", + "version": "1.125.3", "contact": {} }, "tags": [], diff --git a/open-api/typescript-sdk/package-lock.json b/open-api/typescript-sdk/package-lock.json index d77e997d4b..3ec67cee5f 100644 --- a/open-api/typescript-sdk/package-lock.json +++ b/open-api/typescript-sdk/package-lock.json @@ -1,12 +1,12 @@ { "name": "@immich/sdk", - "version": "1.125.2", + "version": "1.125.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@immich/sdk", - "version": "1.125.2", + "version": "1.125.3", "license": "GNU Affero General Public License version 3", "dependencies": { "@oazapfts/runtime": "^1.0.2" diff --git a/open-api/typescript-sdk/package.json b/open-api/typescript-sdk/package.json index e36eaa5733..7e1b6d6699 100644 --- a/open-api/typescript-sdk/package.json +++ b/open-api/typescript-sdk/package.json @@ -1,6 +1,6 @@ { "name": "@immich/sdk", - "version": "1.125.2", + "version": "1.125.3", "description": "Auto-generated TypeScript SDK for the Immich API", "type": "module", "main": "./build/index.js", diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index ce521bf847..3cfa15268f 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -1,6 +1,6 @@ /** * Immich - * 1.125.2 + * 1.125.3 * DO NOT MODIFY - This file has been generated using oazapfts. * See https://www.npmjs.com/package/oazapfts */ diff --git a/server/package-lock.json b/server/package-lock.json index 18f066e7eb..10c21a8c04 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -1,12 +1,12 @@ { "name": "immich", - "version": "1.125.2", + "version": "1.125.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "immich", - "version": "1.125.2", + "version": "1.125.3", "license": "GNU Affero General Public License version 3", "dependencies": { "@nestjs/bullmq": "^11.0.0", diff --git a/server/package.json b/server/package.json index 3ccd2db179..5356b4c11b 100644 --- a/server/package.json +++ b/server/package.json @@ -1,6 +1,6 @@ { "name": "immich", - "version": "1.125.2", + "version": "1.125.3", "description": "", "author": "", "private": true, diff --git a/web/package-lock.json b/web/package-lock.json index 18d4413a06..491ac70e85 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -1,12 +1,12 @@ { "name": "immich-web", - "version": "1.125.2", + "version": "1.125.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "immich-web", - "version": "1.125.2", + "version": "1.125.3", "license": "GNU Affero General Public License version 3", "dependencies": { "@formatjs/icu-messageformat-parser": "^2.9.8", @@ -75,7 +75,7 @@ }, "../open-api/typescript-sdk": { "name": "@immich/sdk", - "version": "1.125.2", + "version": "1.125.3", "license": "GNU Affero General Public License version 3", "dependencies": { "@oazapfts/runtime": "^1.0.2" diff --git a/web/package.json b/web/package.json index 7daaadf3ff..861301ea0a 100644 --- a/web/package.json +++ b/web/package.json @@ -1,6 +1,6 @@ { "name": "immich-web", - "version": "1.125.2", + "version": "1.125.3", "license": "GNU Affero General Public License version 3", "scripts": { "dev": "vite dev --host 0.0.0.0 --port 3000", From e864811a85aa27893ae2c3536d402509ccf51ca6 Mon Sep 17 00:00:00 2001 From: Carsten Otto Date: Sun, 26 Jan 2025 22:07:22 +0100 Subject: [PATCH 031/395] fix(web): sort folders (#15691) fixes #13145 --- web/src/lib/components/shared-components/tree/tree-items.svelte | 2 +- web/src/lib/stores/folders.svelte.ts | 1 - .../folders/[[photos=photos]]/[[assetId=id]]/+page.svelte | 2 +- .../(user)/folders/[[photos=photos]]/[[assetId=id]]/+page.ts | 2 +- 4 files changed, 3 insertions(+), 4 deletions(-) diff --git a/web/src/lib/components/shared-components/tree/tree-items.svelte b/web/src/lib/components/shared-components/tree/tree-items.svelte index c6db9fec8d..3724ced6c9 100644 --- a/web/src/lib/components/shared-components/tree/tree-items.svelte +++ b/web/src/lib/components/shared-components/tree/tree-items.svelte @@ -15,7 +15,7 @@
    - {#each Object.entries(items) as [path, tree]} + {#each Object.entries(items).sort() as [path, tree]} {@const value = normalizeTreePath(`${parent}/${path}`)} {@const key = value + getColor(value)} {#key key} diff --git a/web/src/lib/stores/folders.svelte.ts b/web/src/lib/stores/folders.svelte.ts index f3b237f8a2..fb59687a38 100644 --- a/web/src/lib/stores/folders.svelte.ts +++ b/web/src/lib/stores/folders.svelte.ts @@ -24,7 +24,6 @@ class FoldersStore { const uniquePaths = await getUniqueOriginalPaths(); this.uniquePaths.push(...uniquePaths); - this.uniquePaths.sort(); } bustAssetCache() { diff --git a/web/src/routes/(user)/folders/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/folders/[[photos=photos]]/[[assetId=id]]/+page.svelte index 87cd2434d6..8ff2a35981 100644 --- a/web/src/routes/(user)/folders/[[photos=photos]]/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/folders/[[photos=photos]]/[[assetId=id]]/+page.svelte @@ -44,7 +44,7 @@ let pathSegments = $derived(data.path ? data.path.split('/') : []); let tree = $derived(buildTree(foldersStore.uniquePaths)); let currentPath = $derived($page.url.searchParams.get(QueryParameter.PATH) || ''); - let currentTreeItems = $derived(currentPath ? data.currentFolders : Object.keys(tree)); + let currentTreeItems = $derived(currentPath ? data.currentFolders : Object.keys(tree).sort()); const assetInteraction = new AssetInteraction(); diff --git a/web/src/routes/(user)/folders/[[photos=photos]]/[[assetId=id]]/+page.ts b/web/src/routes/(user)/folders/[[photos=photos]]/[[assetId=id]]/+page.ts index 0d23ba32df..d00ba238ef 100644 --- a/web/src/routes/(user)/folders/[[photos=photos]]/[[assetId=id]]/+page.ts +++ b/web/src/routes/(user)/folders/[[photos=photos]]/[[assetId=id]]/+page.ts @@ -34,7 +34,7 @@ export const load = (async ({ params, url }) => { return { asset, path, - currentFolders: Object.keys(tree || {}), + currentFolders: Object.keys(tree || {}).sort(), pathAssets, meta: { title: $t('folders'), From 8dab5d37980a115fbc14dbf3486c18a07722883d Mon Sep 17 00:00:00 2001 From: Alex Date: Sun, 26 Jan 2025 15:09:15 -0600 Subject: [PATCH 032/395] chore(mobile): post release task (#15662) --- mobile/ios/Runner.xcodeproj/project.pbxproj | 12 ++++++------ mobile/ios/Runner/Info.plist | 4 ++-- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/mobile/ios/Runner.xcodeproj/project.pbxproj b/mobile/ios/Runner.xcodeproj/project.pbxproj index d7d24a9fa9..89c18ba5b7 100644 --- a/mobile/ios/Runner.xcodeproj/project.pbxproj +++ b/mobile/ios/Runner.xcodeproj/project.pbxproj @@ -541,7 +541,7 @@ CODE_SIGN_ENTITLEMENTS = Runner/RunnerProfile.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 189; + CURRENT_PROJECT_VERSION = 190; CUSTOM_GROUP_ID = group.app.immich.share; DEVELOPMENT_TEAM = 2F67MQ8R79; ENABLE_BITCODE = NO; @@ -685,7 +685,7 @@ CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 189; + CURRENT_PROJECT_VERSION = 190; CUSTOM_GROUP_ID = group.app.immich.share; DEVELOPMENT_TEAM = 2F67MQ8R79; ENABLE_BITCODE = NO; @@ -715,7 +715,7 @@ CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 189; + CURRENT_PROJECT_VERSION = 190; CUSTOM_GROUP_ID = group.app.immich.share; DEVELOPMENT_TEAM = 2F67MQ8R79; ENABLE_BITCODE = NO; @@ -748,7 +748,7 @@ CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 189; + CURRENT_PROJECT_VERSION = 190; CUSTOM_GROUP_ID = group.app.immich.share; DEVELOPMENT_TEAM = 2F67MQ8R79; ENABLE_USER_SCRIPT_SANDBOXING = YES; @@ -791,7 +791,7 @@ CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 189; + CURRENT_PROJECT_VERSION = 190; CUSTOM_GROUP_ID = group.app.immich.share; DEVELOPMENT_TEAM = 2F67MQ8R79; ENABLE_USER_SCRIPT_SANDBOXING = YES; @@ -831,7 +831,7 @@ CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 189; + CURRENT_PROJECT_VERSION = 190; CUSTOM_GROUP_ID = group.app.immich.share; DEVELOPMENT_TEAM = 2F67MQ8R79; ENABLE_USER_SCRIPT_SANDBOXING = YES; diff --git a/mobile/ios/Runner/Info.plist b/mobile/ios/Runner/Info.plist index a3b34a9bcd..a2688775dc 100644 --- a/mobile/ios/Runner/Info.plist +++ b/mobile/ios/Runner/Info.plist @@ -78,7 +78,7 @@ CFBundlePackageType APPL CFBundleShortVersionString - 1.125.1 + 1.125.2 CFBundleSignature ???? CFBundleURLTypes @@ -93,7 +93,7 @@ CFBundleVersion - 189 + 190 FLTEnableImpeller ITSAppUsesNonExemptEncryption From f6cbc9db06c0783d09f154f66e12d041032fff62 Mon Sep 17 00:00:00 2001 From: Alex Date: Sun, 26 Jan 2025 21:18:34 -0600 Subject: [PATCH 033/395] fix(server): cannot render album page when all assets of an album are in trash (#15690) * fix(server): cannot render album page when all assets of an album are in trash * inner join * add e2e test * check empty albums too * render add to album button on empty album * lint * count 0 if undefined * fix album card test --------- Co-authored-by: mertalev <101130780+mertalev@users.noreply.github.com> --- e2e/src/api/specs/album.e2e-spec.ts | 129 ++++++++++++-------- server/src/queries/album.repository.sql | 4 +- server/src/repositories/album.repository.ts | 4 +- server/src/services/album.service.ts | 12 +- 4 files changed, 91 insertions(+), 58 deletions(-) diff --git a/e2e/src/api/specs/album.e2e-spec.ts b/e2e/src/api/specs/album.e2e-spec.ts index 5c087d1269..cede49f469 100644 --- a/e2e/src/api/specs/album.e2e-spec.ts +++ b/e2e/src/api/specs/album.e2e-spec.ts @@ -22,82 +22,92 @@ const user1NotShared = 'user1NotShared'; const user2SharedUser = 'user2SharedUser'; const user2SharedLink = 'user2SharedLink'; const user2NotShared = 'user2NotShared'; +const user4DeletedAsset = 'user4DeletedAsset'; +const user4Empty = 'user4Empty'; describe('/albums', () => { let admin: LoginResponseDto; let user1: LoginResponseDto; let user1Asset1: AssetMediaResponseDto; let user1Asset2: AssetMediaResponseDto; + let user4Asset1: AssetMediaResponseDto; let user1Albums: AlbumResponseDto[]; let user2: LoginResponseDto; let user2Albums: AlbumResponseDto[]; + let deletedAssetAlbum: AlbumResponseDto; let user3: LoginResponseDto; // deleted + let user4: LoginResponseDto; beforeAll(async () => { await utils.resetDatabase(); admin = await utils.adminSetup(); - [user1, user2, user3] = await Promise.all([ + [user1, user2, user3, user4] = await Promise.all([ utils.userSetup(admin.accessToken, createUserDto.user1), utils.userSetup(admin.accessToken, createUserDto.user2), utils.userSetup(admin.accessToken, createUserDto.user3), + utils.userSetup(admin.accessToken, createUserDto.user4), ]); - [user1Asset1, user1Asset2] = await Promise.all([ + [user1Asset1, user1Asset2, user4Asset1] = await Promise.all([ utils.createAsset(user1.accessToken, { isFavorite: true }), utils.createAsset(user1.accessToken), + utils.createAsset(user1.accessToken), ]); - user1Albums = await Promise.all([ - utils.createAlbum(user1.accessToken, { - albumName: user1SharedEditorUser, - albumUsers: [ - { userId: admin.userId, role: AlbumUserRole.Editor }, - { userId: user2.userId, role: AlbumUserRole.Editor }, - ], - assetIds: [user1Asset1.id], - }), - utils.createAlbum(user1.accessToken, { - albumName: user1SharedLink, - assetIds: [user1Asset1.id], - }), - utils.createAlbum(user1.accessToken, { - albumName: user1NotShared, - assetIds: [user1Asset1.id, user1Asset2.id], - }), - utils.createAlbum(user1.accessToken, { - albumName: user1SharedViewerUser, - albumUsers: [{ userId: user2.userId, role: AlbumUserRole.Viewer }], - assetIds: [user1Asset1.id], + [user1Albums, user2Albums, deletedAssetAlbum] = await Promise.all([ + Promise.all([ + utils.createAlbum(user1.accessToken, { + albumName: user1SharedEditorUser, + albumUsers: [ + { userId: admin.userId, role: AlbumUserRole.Editor }, + { userId: user2.userId, role: AlbumUserRole.Editor }, + ], + assetIds: [user1Asset1.id], + }), + utils.createAlbum(user1.accessToken, { + albumName: user1SharedLink, + assetIds: [user1Asset1.id], + }), + utils.createAlbum(user1.accessToken, { + albumName: user1NotShared, + assetIds: [user1Asset1.id, user1Asset2.id], + }), + utils.createAlbum(user1.accessToken, { + albumName: user1SharedViewerUser, + albumUsers: [{ userId: user2.userId, role: AlbumUserRole.Viewer }], + assetIds: [user1Asset1.id], + }), + ]), + Promise.all([ + utils.createAlbum(user2.accessToken, { + albumName: user2SharedUser, + albumUsers: [ + { userId: user1.userId, role: AlbumUserRole.Editor }, + { userId: user3.userId, role: AlbumUserRole.Editor }, + ], + }), + utils.createAlbum(user2.accessToken, { albumName: user2SharedLink }), + utils.createAlbum(user2.accessToken, { albumName: user2NotShared }), + ]), + utils.createAlbum(user4.accessToken, { albumName: user4DeletedAsset }), + utils.createAlbum(user4.accessToken, { albumName: user4Empty }), + utils.createAlbum(user3.accessToken, { + albumName: 'Deleted', + albumUsers: [{ userId: user1.userId, role: AlbumUserRole.Editor }], }), ]); - user2Albums = await Promise.all([ - utils.createAlbum(user2.accessToken, { - albumName: user2SharedUser, - albumUsers: [ - { userId: user1.userId, role: AlbumUserRole.Editor }, - { userId: user3.userId, role: AlbumUserRole.Editor }, - ], - }), - utils.createAlbum(user2.accessToken, { albumName: user2SharedLink }), - utils.createAlbum(user2.accessToken, { albumName: user2NotShared }), - ]); - - await utils.createAlbum(user3.accessToken, { - albumName: 'Deleted', - albumUsers: [{ userId: user1.userId, role: AlbumUserRole.Editor }], - }); - - await addAssetsToAlbum( - { id: user2Albums[0].id, bulkIdsDto: { ids: [user1Asset1.id, user1Asset2.id] } }, - { headers: asBearerAuth(user1.accessToken) }, - ); - - user2Albums[0] = await getAlbumInfo({ id: user2Albums[0].id }, { headers: asBearerAuth(user2.accessToken) }); - await Promise.all([ + addAssetsToAlbum( + { id: user2Albums[0].id, bulkIdsDto: { ids: [user1Asset1.id, user1Asset2.id] } }, + { headers: asBearerAuth(user1.accessToken) }, + ), + addAssetsToAlbum( + { id: deletedAssetAlbum.id, bulkIdsDto: { ids: [user4Asset1.id] } }, + { headers: asBearerAuth(user4.accessToken) }, + ), // add shared link to user1SharedLink album utils.createSharedLink(user1.accessToken, { type: SharedLinkType.Album, @@ -110,7 +120,11 @@ describe('/albums', () => { }), ]); - await deleteUserAdmin({ id: user3.userId, userAdminDeleteDto: {} }, { headers: asBearerAuth(admin.accessToken) }); + [user2Albums[0]] = await Promise.all([ + getAlbumInfo({ id: user2Albums[0].id }, { headers: asBearerAuth(user2.accessToken) }), + deleteUserAdmin({ id: user3.userId, userAdminDeleteDto: {} }, { headers: asBearerAuth(admin.accessToken) }), + utils.deleteAssets(user1.accessToken, [user4Asset1.id]), + ]); }); describe('GET /albums', () => { @@ -287,6 +301,25 @@ describe('/albums', () => { expect(status).toBe(200); expect(body).toHaveLength(5); }); + + it('should return empty albums and albums where all assets are deleted', async () => { + const { status, body } = await request(app).get('/albums').set('Authorization', `Bearer ${user4.accessToken}`); + expect(status).toBe(200); + expect(body).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + ownerId: user4.userId, + albumName: user4DeletedAsset, + shared: false, + }), + expect.objectContaining({ + ownerId: user4.userId, + albumName: user4Empty, + shared: false, + }), + ]), + ); + }); }); describe('GET /albums/:id', () => { diff --git a/server/src/queries/album.repository.sql b/server/src/queries/album.repository.sql index 217b0ce77b..08ea078f73 100644 --- a/server/src/queries/album.repository.sql +++ b/server/src/queries/album.repository.sql @@ -207,8 +207,8 @@ select count("assets"."id")::int as "assetCount" from "albums" - left join "albums_assets_assets" as "album_assets" on "album_assets"."albumsId" = "albums"."id" - left join "assets" on "assets"."id" = "album_assets"."assetsId" + inner join "albums_assets_assets" as "album_assets" on "album_assets"."albumsId" = "albums"."id" + inner join "assets" on "assets"."id" = "album_assets"."assetsId" where "albums"."id" in ($1) and "assets"."deletedAt" is null diff --git a/server/src/repositories/album.repository.ts b/server/src/repositories/album.repository.ts index d63fd2ed4f..6c81395a58 100644 --- a/server/src/repositories/album.repository.ts +++ b/server/src/repositories/album.repository.ts @@ -124,8 +124,8 @@ export class AlbumRepository implements IAlbumRepository { return this.db .selectFrom('albums') - .leftJoin('albums_assets_assets as album_assets', 'album_assets.albumsId', 'albums.id') - .leftJoin('assets', 'assets.id', 'album_assets.assetsId') + .innerJoin('albums_assets_assets as album_assets', 'album_assets.albumsId', 'albums.id') + .innerJoin('assets', 'assets.id', 'album_assets.assetsId') .select('albums.id as albumId') .select((eb) => eb.fn.min('assets.fileCreatedAt').as('startDate')) .select((eb) => eb.fn.max('assets.fileCreatedAt').as('endDate')) diff --git a/server/src/services/album.service.ts b/server/src/services/album.service.ts index 0b6c646801..0286b387c3 100644 --- a/server/src/services/album.service.ts +++ b/server/src/services/album.service.ts @@ -64,9 +64,9 @@ export class AlbumService extends BaseService { return { ...mapAlbumWithoutAssets(album), sharedLinks: undefined, - startDate: albumMetadata[album.id].startDate ?? undefined, - endDate: albumMetadata[album.id].endDate ?? undefined, - assetCount: albumMetadata[album.id].assetCount, + startDate: albumMetadata[album.id]?.startDate ?? undefined, + endDate: albumMetadata[album.id]?.endDate ?? undefined, + assetCount: albumMetadata[album.id]?.assetCount ?? 0, lastModifiedAssetTimestamp: lastModifiedAsset?.updatedAt, }; }), @@ -83,9 +83,9 @@ export class AlbumService extends BaseService { return { ...mapAlbum(album, withAssets, auth), - startDate: albumMetadataForIds.startDate ?? undefined, - endDate: albumMetadataForIds.endDate ?? undefined, - assetCount: albumMetadataForIds.assetCount, + startDate: albumMetadataForIds?.startDate ?? undefined, + endDate: albumMetadataForIds?.endDate ?? undefined, + assetCount: albumMetadataForIds?.assetCount ?? 0, lastModifiedAssetTimestamp: lastModifiedAsset?.updatedAt, }; } From e5794e6cfcb97c120e1bed981c8110ff83f685a1 Mon Sep 17 00:00:00 2001 From: github-actions <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 27 Jan 2025 18:44:12 +0000 Subject: [PATCH 034/395] chore: version v1.125.4 --- cli/package-lock.json | 6 +++--- cli/package.json | 2 +- docs/static/archived-versions.json | 4 ++++ e2e/package-lock.json | 8 ++++---- e2e/package.json | 2 +- machine-learning/pyproject.toml | 2 +- mobile/android/fastlane/Fastfile | 4 ++-- mobile/ios/fastlane/Fastfile | 2 +- mobile/openapi/README.md | 2 +- mobile/pubspec.yaml | 2 +- open-api/immich-openapi-specs.json | 2 +- open-api/typescript-sdk/package-lock.json | 4 ++-- open-api/typescript-sdk/package.json | 2 +- open-api/typescript-sdk/src/fetch-client.ts | 2 +- server/package-lock.json | 4 ++-- server/package.json | 2 +- web/package-lock.json | 6 +++--- web/package.json | 2 +- 18 files changed, 31 insertions(+), 27 deletions(-) diff --git a/cli/package-lock.json b/cli/package-lock.json index 6fcbbcc5c9..75e20968d6 100644 --- a/cli/package-lock.json +++ b/cli/package-lock.json @@ -1,12 +1,12 @@ { "name": "@immich/cli", - "version": "2.2.44", + "version": "2.2.45", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@immich/cli", - "version": "2.2.44", + "version": "2.2.45", "license": "GNU Affero General Public License version 3", "dependencies": { "fast-glob": "^3.3.2", @@ -52,7 +52,7 @@ }, "../open-api/typescript-sdk": { "name": "@immich/sdk", - "version": "1.125.3", + "version": "1.125.4", "dev": true, "license": "GNU Affero General Public License version 3", "dependencies": { diff --git a/cli/package.json b/cli/package.json index e90f48a2e2..cb966cd431 100644 --- a/cli/package.json +++ b/cli/package.json @@ -1,6 +1,6 @@ { "name": "@immich/cli", - "version": "2.2.44", + "version": "2.2.45", "description": "Command Line Interface (CLI) for Immich", "type": "module", "exports": "./dist/index.js", diff --git a/docs/static/archived-versions.json b/docs/static/archived-versions.json index 422ce6a8fb..13927be527 100644 --- a/docs/static/archived-versions.json +++ b/docs/static/archived-versions.json @@ -1,4 +1,8 @@ [ + { + "label": "v1.125.4", + "url": "https://v1.125.4.archive.immich.app" + }, { "label": "v1.125.3", "url": "https://v1.125.3.archive.immich.app" diff --git a/e2e/package-lock.json b/e2e/package-lock.json index 726dc58adc..6beef479f2 100644 --- a/e2e/package-lock.json +++ b/e2e/package-lock.json @@ -1,12 +1,12 @@ { "name": "immich-e2e", - "version": "1.125.3", + "version": "1.125.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "immich-e2e", - "version": "1.125.3", + "version": "1.125.4", "license": "GNU Affero General Public License version 3", "devDependencies": { "@eslint/eslintrc": "^3.1.0", @@ -45,7 +45,7 @@ }, "../cli": { "name": "@immich/cli", - "version": "2.2.44", + "version": "2.2.45", "dev": true, "license": "GNU Affero General Public License version 3", "dependencies": { @@ -92,7 +92,7 @@ }, "../open-api/typescript-sdk": { "name": "@immich/sdk", - "version": "1.125.3", + "version": "1.125.4", "dev": true, "license": "GNU Affero General Public License version 3", "dependencies": { diff --git a/e2e/package.json b/e2e/package.json index ad19e4d059..9e14d540c9 100644 --- a/e2e/package.json +++ b/e2e/package.json @@ -1,6 +1,6 @@ { "name": "immich-e2e", - "version": "1.125.3", + "version": "1.125.4", "description": "", "main": "index.js", "type": "module", diff --git a/machine-learning/pyproject.toml b/machine-learning/pyproject.toml index 9c0600bc1f..96e23e829c 100644 --- a/machine-learning/pyproject.toml +++ b/machine-learning/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "machine-learning" -version = "1.125.3" +version = "1.125.4" description = "" authors = ["Hau Tran "] readme = "README.md" diff --git a/mobile/android/fastlane/Fastfile b/mobile/android/fastlane/Fastfile index 824fdd9a97..f19b8a8be2 100644 --- a/mobile/android/fastlane/Fastfile +++ b/mobile/android/fastlane/Fastfile @@ -35,8 +35,8 @@ platform :android do task: 'bundle', build_type: 'Release', properties: { - "android.injected.version.code" => 179, - "android.injected.version.name" => "1.125.3", + "android.injected.version.code" => 180, + "android.injected.version.name" => "1.125.4", } ) upload_to_play_store(skip_upload_apk: true, skip_upload_images: true, skip_upload_screenshots: true, aab: '../build/app/outputs/bundle/release/app-release.aab') diff --git a/mobile/ios/fastlane/Fastfile b/mobile/ios/fastlane/Fastfile index 4c86002f5c..aa53e7def4 100644 --- a/mobile/ios/fastlane/Fastfile +++ b/mobile/ios/fastlane/Fastfile @@ -19,7 +19,7 @@ platform :ios do desc "iOS Release" lane :release do increment_version_number( - version_number: "1.125.3" + version_number: "1.125.4" ) increment_build_number( build_number: latest_testflight_build_number + 1, diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index e0a88fcb43..d5e3889b4e 100644 --- a/mobile/openapi/README.md +++ b/mobile/openapi/README.md @@ -3,7 +3,7 @@ Immich API This Dart package is automatically generated by the [OpenAPI Generator](https://openapi-generator.tech) project: -- API version: 1.125.3 +- API version: 1.125.4 - Generator version: 7.8.0 - Build package: org.openapitools.codegen.languages.DartClientCodegen diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml index 29bbc86247..3b05d6257b 100644 --- a/mobile/pubspec.yaml +++ b/mobile/pubspec.yaml @@ -2,7 +2,7 @@ name: immich_mobile description: Immich - selfhosted backup media file on mobile phone publish_to: 'none' -version: 1.125.3+179 +version: 1.125.4+180 environment: sdk: '>=3.3.0 <4.0.0' diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index 815dc7452d..80eebddf96 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -7454,7 +7454,7 @@ "info": { "title": "Immich", "description": "Immich API", - "version": "1.125.3", + "version": "1.125.4", "contact": {} }, "tags": [], diff --git a/open-api/typescript-sdk/package-lock.json b/open-api/typescript-sdk/package-lock.json index 3ec67cee5f..50a886bfbf 100644 --- a/open-api/typescript-sdk/package-lock.json +++ b/open-api/typescript-sdk/package-lock.json @@ -1,12 +1,12 @@ { "name": "@immich/sdk", - "version": "1.125.3", + "version": "1.125.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@immich/sdk", - "version": "1.125.3", + "version": "1.125.4", "license": "GNU Affero General Public License version 3", "dependencies": { "@oazapfts/runtime": "^1.0.2" diff --git a/open-api/typescript-sdk/package.json b/open-api/typescript-sdk/package.json index 7e1b6d6699..b04f9fd255 100644 --- a/open-api/typescript-sdk/package.json +++ b/open-api/typescript-sdk/package.json @@ -1,6 +1,6 @@ { "name": "@immich/sdk", - "version": "1.125.3", + "version": "1.125.4", "description": "Auto-generated TypeScript SDK for the Immich API", "type": "module", "main": "./build/index.js", diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index 3cfa15268f..c466d71e87 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -1,6 +1,6 @@ /** * Immich - * 1.125.3 + * 1.125.4 * DO NOT MODIFY - This file has been generated using oazapfts. * See https://www.npmjs.com/package/oazapfts */ diff --git a/server/package-lock.json b/server/package-lock.json index 10c21a8c04..f348344ae3 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -1,12 +1,12 @@ { "name": "immich", - "version": "1.125.3", + "version": "1.125.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "immich", - "version": "1.125.3", + "version": "1.125.4", "license": "GNU Affero General Public License version 3", "dependencies": { "@nestjs/bullmq": "^11.0.0", diff --git a/server/package.json b/server/package.json index 5356b4c11b..5fbe97caab 100644 --- a/server/package.json +++ b/server/package.json @@ -1,6 +1,6 @@ { "name": "immich", - "version": "1.125.3", + "version": "1.125.4", "description": "", "author": "", "private": true, diff --git a/web/package-lock.json b/web/package-lock.json index 491ac70e85..fc360850f2 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -1,12 +1,12 @@ { "name": "immich-web", - "version": "1.125.3", + "version": "1.125.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "immich-web", - "version": "1.125.3", + "version": "1.125.4", "license": "GNU Affero General Public License version 3", "dependencies": { "@formatjs/icu-messageformat-parser": "^2.9.8", @@ -75,7 +75,7 @@ }, "../open-api/typescript-sdk": { "name": "@immich/sdk", - "version": "1.125.3", + "version": "1.125.4", "license": "GNU Affero General Public License version 3", "dependencies": { "@oazapfts/runtime": "^1.0.2" diff --git a/web/package.json b/web/package.json index 861301ea0a..3a9d50653f 100644 --- a/web/package.json +++ b/web/package.json @@ -1,6 +1,6 @@ { "name": "immich-web", - "version": "1.125.3", + "version": "1.125.4", "license": "GNU Affero General Public License version 3", "scripts": { "dev": "vite dev --host 0.0.0.0 --port 3000", From 0fe62298e1f28bfa970e183a306b42d4036feafa Mon Sep 17 00:00:00 2001 From: Alex Date: Mon, 27 Jan 2025 13:53:59 -0600 Subject: [PATCH 035/395] fix(server): duplicate detection (#15727) --- server/src/entities/asset.entity.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/src/entities/asset.entity.ts b/server/src/entities/asset.entity.ts index b7d3e7d4ab..e9dbe67a2f 100644 --- a/server/src/entities/asset.entity.ts +++ b/server/src/entities/asset.entity.ts @@ -193,7 +193,7 @@ export function withExifInner(qb: SelectQueryBuilder) { export function withSmartSearch(qb: SelectQueryBuilder) { return qb .leftJoin('smart_search', 'assets.id', 'smart_search.assetId') - .select(sql`smart_search.embedding`.as('embedding')); + .select((eb) => eb.fn.toJson(eb.table('smart_search')).as('smartSearch')); } export function withFaces(eb: ExpressionBuilder) { From c139e05170dc039addd6c6894179208f48fe62d9 Mon Sep 17 00:00:00 2001 From: Alex Date: Mon, 27 Jan 2025 14:02:23 -0600 Subject: [PATCH 036/395] fix(mobile): locale option causes the datetime filter error out (#15704) --- mobile/lib/pages/search/search.page.dart | 1 - 1 file changed, 1 deletion(-) diff --git a/mobile/lib/pages/search/search.page.dart b/mobile/lib/pages/search/search.page.dart index 32e73f5c24..88cc56a145 100644 --- a/mobile/lib/pages/search/search.page.dart +++ b/mobile/lib/pages/search/search.page.dart @@ -277,7 +277,6 @@ class SearchPage extends HookConsumerWidget { fieldEndHintText: 'end_date'.tr(), initialEntryMode: DatePickerEntryMode.calendar, keyboardType: TextInputType.text, - locale: context.locale, ); if (date == null) { From 64d926581ffb933c45f6220961c56bb592205521 Mon Sep 17 00:00:00 2001 From: github-actions <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 27 Jan 2025 20:04:50 +0000 Subject: [PATCH 037/395] chore: version v1.125.5 --- cli/package-lock.json | 6 +++--- cli/package.json | 2 +- docs/static/archived-versions.json | 4 ++++ e2e/package-lock.json | 8 ++++---- e2e/package.json | 2 +- machine-learning/pyproject.toml | 2 +- mobile/android/fastlane/Fastfile | 4 ++-- mobile/ios/fastlane/Fastfile | 2 +- mobile/openapi/README.md | 2 +- mobile/pubspec.yaml | 2 +- open-api/immich-openapi-specs.json | 2 +- open-api/typescript-sdk/package-lock.json | 4 ++-- open-api/typescript-sdk/package.json | 2 +- open-api/typescript-sdk/src/fetch-client.ts | 2 +- server/package-lock.json | 4 ++-- server/package.json | 2 +- web/package-lock.json | 6 +++--- web/package.json | 2 +- 18 files changed, 31 insertions(+), 27 deletions(-) diff --git a/cli/package-lock.json b/cli/package-lock.json index 75e20968d6..9044ab7171 100644 --- a/cli/package-lock.json +++ b/cli/package-lock.json @@ -1,12 +1,12 @@ { "name": "@immich/cli", - "version": "2.2.45", + "version": "2.2.46", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@immich/cli", - "version": "2.2.45", + "version": "2.2.46", "license": "GNU Affero General Public License version 3", "dependencies": { "fast-glob": "^3.3.2", @@ -52,7 +52,7 @@ }, "../open-api/typescript-sdk": { "name": "@immich/sdk", - "version": "1.125.4", + "version": "1.125.5", "dev": true, "license": "GNU Affero General Public License version 3", "dependencies": { diff --git a/cli/package.json b/cli/package.json index cb966cd431..24da3c87ac 100644 --- a/cli/package.json +++ b/cli/package.json @@ -1,6 +1,6 @@ { "name": "@immich/cli", - "version": "2.2.45", + "version": "2.2.46", "description": "Command Line Interface (CLI) for Immich", "type": "module", "exports": "./dist/index.js", diff --git a/docs/static/archived-versions.json b/docs/static/archived-versions.json index 13927be527..177f445f53 100644 --- a/docs/static/archived-versions.json +++ b/docs/static/archived-versions.json @@ -1,4 +1,8 @@ [ + { + "label": "v1.125.5", + "url": "https://v1.125.5.archive.immich.app" + }, { "label": "v1.125.4", "url": "https://v1.125.4.archive.immich.app" diff --git a/e2e/package-lock.json b/e2e/package-lock.json index 6beef479f2..5fd97d8f98 100644 --- a/e2e/package-lock.json +++ b/e2e/package-lock.json @@ -1,12 +1,12 @@ { "name": "immich-e2e", - "version": "1.125.4", + "version": "1.125.5", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "immich-e2e", - "version": "1.125.4", + "version": "1.125.5", "license": "GNU Affero General Public License version 3", "devDependencies": { "@eslint/eslintrc": "^3.1.0", @@ -45,7 +45,7 @@ }, "../cli": { "name": "@immich/cli", - "version": "2.2.45", + "version": "2.2.46", "dev": true, "license": "GNU Affero General Public License version 3", "dependencies": { @@ -92,7 +92,7 @@ }, "../open-api/typescript-sdk": { "name": "@immich/sdk", - "version": "1.125.4", + "version": "1.125.5", "dev": true, "license": "GNU Affero General Public License version 3", "dependencies": { diff --git a/e2e/package.json b/e2e/package.json index 9e14d540c9..a65545ddb3 100644 --- a/e2e/package.json +++ b/e2e/package.json @@ -1,6 +1,6 @@ { "name": "immich-e2e", - "version": "1.125.4", + "version": "1.125.5", "description": "", "main": "index.js", "type": "module", diff --git a/machine-learning/pyproject.toml b/machine-learning/pyproject.toml index 96e23e829c..14be0f1947 100644 --- a/machine-learning/pyproject.toml +++ b/machine-learning/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "machine-learning" -version = "1.125.4" +version = "1.125.5" description = "" authors = ["Hau Tran "] readme = "README.md" diff --git a/mobile/android/fastlane/Fastfile b/mobile/android/fastlane/Fastfile index f19b8a8be2..c943ca8f20 100644 --- a/mobile/android/fastlane/Fastfile +++ b/mobile/android/fastlane/Fastfile @@ -35,8 +35,8 @@ platform :android do task: 'bundle', build_type: 'Release', properties: { - "android.injected.version.code" => 180, - "android.injected.version.name" => "1.125.4", + "android.injected.version.code" => 181, + "android.injected.version.name" => "1.125.5", } ) upload_to_play_store(skip_upload_apk: true, skip_upload_images: true, skip_upload_screenshots: true, aab: '../build/app/outputs/bundle/release/app-release.aab') diff --git a/mobile/ios/fastlane/Fastfile b/mobile/ios/fastlane/Fastfile index aa53e7def4..82694c9835 100644 --- a/mobile/ios/fastlane/Fastfile +++ b/mobile/ios/fastlane/Fastfile @@ -19,7 +19,7 @@ platform :ios do desc "iOS Release" lane :release do increment_version_number( - version_number: "1.125.4" + version_number: "1.125.5" ) increment_build_number( build_number: latest_testflight_build_number + 1, diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index d5e3889b4e..d448416a18 100644 --- a/mobile/openapi/README.md +++ b/mobile/openapi/README.md @@ -3,7 +3,7 @@ Immich API This Dart package is automatically generated by the [OpenAPI Generator](https://openapi-generator.tech) project: -- API version: 1.125.4 +- API version: 1.125.5 - Generator version: 7.8.0 - Build package: org.openapitools.codegen.languages.DartClientCodegen diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml index 3b05d6257b..3bd660dac2 100644 --- a/mobile/pubspec.yaml +++ b/mobile/pubspec.yaml @@ -2,7 +2,7 @@ name: immich_mobile description: Immich - selfhosted backup media file on mobile phone publish_to: 'none' -version: 1.125.4+180 +version: 1.125.5+181 environment: sdk: '>=3.3.0 <4.0.0' diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index 80eebddf96..30fa7316ec 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -7454,7 +7454,7 @@ "info": { "title": "Immich", "description": "Immich API", - "version": "1.125.4", + "version": "1.125.5", "contact": {} }, "tags": [], diff --git a/open-api/typescript-sdk/package-lock.json b/open-api/typescript-sdk/package-lock.json index 50a886bfbf..8ceb407798 100644 --- a/open-api/typescript-sdk/package-lock.json +++ b/open-api/typescript-sdk/package-lock.json @@ -1,12 +1,12 @@ { "name": "@immich/sdk", - "version": "1.125.4", + "version": "1.125.5", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@immich/sdk", - "version": "1.125.4", + "version": "1.125.5", "license": "GNU Affero General Public License version 3", "dependencies": { "@oazapfts/runtime": "^1.0.2" diff --git a/open-api/typescript-sdk/package.json b/open-api/typescript-sdk/package.json index b04f9fd255..88f35e2bfe 100644 --- a/open-api/typescript-sdk/package.json +++ b/open-api/typescript-sdk/package.json @@ -1,6 +1,6 @@ { "name": "@immich/sdk", - "version": "1.125.4", + "version": "1.125.5", "description": "Auto-generated TypeScript SDK for the Immich API", "type": "module", "main": "./build/index.js", diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index c466d71e87..93c72a477b 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -1,6 +1,6 @@ /** * Immich - * 1.125.4 + * 1.125.5 * DO NOT MODIFY - This file has been generated using oazapfts. * See https://www.npmjs.com/package/oazapfts */ diff --git a/server/package-lock.json b/server/package-lock.json index f348344ae3..9a3587fa3a 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -1,12 +1,12 @@ { "name": "immich", - "version": "1.125.4", + "version": "1.125.5", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "immich", - "version": "1.125.4", + "version": "1.125.5", "license": "GNU Affero General Public License version 3", "dependencies": { "@nestjs/bullmq": "^11.0.0", diff --git a/server/package.json b/server/package.json index 5fbe97caab..f9b55a1ac4 100644 --- a/server/package.json +++ b/server/package.json @@ -1,6 +1,6 @@ { "name": "immich", - "version": "1.125.4", + "version": "1.125.5", "description": "", "author": "", "private": true, diff --git a/web/package-lock.json b/web/package-lock.json index fc360850f2..d57126bb80 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -1,12 +1,12 @@ { "name": "immich-web", - "version": "1.125.4", + "version": "1.125.5", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "immich-web", - "version": "1.125.4", + "version": "1.125.5", "license": "GNU Affero General Public License version 3", "dependencies": { "@formatjs/icu-messageformat-parser": "^2.9.8", @@ -75,7 +75,7 @@ }, "../open-api/typescript-sdk": { "name": "@immich/sdk", - "version": "1.125.4", + "version": "1.125.5", "license": "GNU Affero General Public License version 3", "dependencies": { "@oazapfts/runtime": "^1.0.2" diff --git a/web/package.json b/web/package.json index 3a9d50653f..9e9a6bf7e2 100644 --- a/web/package.json +++ b/web/package.json @@ -1,6 +1,6 @@ { "name": "immich-web", - "version": "1.125.4", + "version": "1.125.5", "license": "GNU Affero General Public License version 3", "scripts": { "dev": "vite dev --host 0.0.0.0 --port 3000", From 92412ca2f70bd117b44b5c8c6d19a68d3ed8e03e Mon Sep 17 00:00:00 2001 From: Mert <101130780+mertalev@users.noreply.github.com> Date: Mon, 27 Jan 2025 17:20:18 -0500 Subject: [PATCH 038/395] fix(server): person thumbnail generation always being queued (#15734) * fix person thumbnail generation always being queued * fix thumbhash comparison * fix mock --- server/src/repositories/asset.repository.ts | 1 - server/src/repositories/person.repository.ts | 3 +-- server/src/services/media.service.ts | 2 +- server/test/repositories/media.repository.mock.ts | 2 +- 4 files changed, 3 insertions(+), 5 deletions(-) diff --git a/server/src/repositories/asset.repository.ts b/server/src/repositories/asset.repository.ts index b39781209e..1f9f8f997f 100644 --- a/server/src/repositories/asset.repository.ts +++ b/server/src/repositories/asset.repository.ts @@ -495,7 +495,6 @@ export class AssetRepository implements IAssetRepository { .$if(property === WithoutProperty.THUMBNAIL, (qb) => qb .innerJoin('asset_job_status as job_status', 'assetId', 'assets.id') - .select(withFiles) .where('assets.isVisible', '=', true) .where((eb) => eb.or([ diff --git a/server/src/repositories/person.repository.ts b/server/src/repositories/person.repository.ts index 45183f39d6..7c2512aa26 100644 --- a/server/src/repositories/person.repository.ts +++ b/server/src/repositories/person.repository.ts @@ -100,7 +100,6 @@ export class PersonRepository implements IPersonRepository { .$if(!!options.personId, (qb) => qb.where('asset_faces.personId', '=', options.personId!)) .$if(!!options.sourceType, (qb) => qb.where('asset_faces.sourceType', '=', options.sourceType!)) .$if(!!options.assetId, (qb) => qb.where('asset_faces.assetId', '=', options.assetId!)) - .$if(!!options.assetId, (qb) => qb.where('asset_faces.assetId', '=', options.assetId!)) .stream() as AsyncIterableIterator; } @@ -109,7 +108,7 @@ export class PersonRepository implements IPersonRepository { .selectFrom('person') .selectAll('person') .$if(!!options.ownerId, (qb) => qb.where('person.ownerId', '=', options.ownerId!)) - .$if(!!options.thumbnailPath, (qb) => qb.where('person.thumbnailPath', '=', options.thumbnailPath!)) + .$if(options.thumbnailPath !== undefined, (qb) => qb.where('person.thumbnailPath', '=', options.thumbnailPath!)) .$if(options.faceAssetId === null, (qb) => qb.where('person.faceAssetId', 'is', null)) .$if(!!options.faceAssetId, (qb) => qb.where('person.faceAssetId', '=', options.faceAssetId!)) .$if(options.isHidden !== undefined, (qb) => qb.where('person.isHidden', '=', options.isHidden!)) diff --git a/server/src/services/media.service.ts b/server/src/services/media.service.ts index 5555a937f8..c22d124b63 100644 --- a/server/src/services/media.service.ts +++ b/server/src/services/media.service.ts @@ -194,7 +194,7 @@ export class MediaService extends BaseService { await Promise.all(pathsToDelete.map((path) => this.storageRepository.unlink(path))); } - if (asset.thumbhash != generated.thumbhash) { + if (!asset.thumbhash || Buffer.compare(asset.thumbhash, generated.thumbhash) !== 0) { await this.assetRepository.update({ id: asset.id, thumbhash: generated.thumbhash }); } diff --git a/server/test/repositories/media.repository.mock.ts b/server/test/repositories/media.repository.mock.ts index 1e909dcae3..238066ad9e 100644 --- a/server/test/repositories/media.repository.mock.ts +++ b/server/test/repositories/media.repository.mock.ts @@ -4,7 +4,7 @@ import { Mocked, vitest } from 'vitest'; export const newMediaRepositoryMock = (): Mocked => { return { generateThumbnail: vitest.fn().mockImplementation(() => Promise.resolve()), - generateThumbhash: vitest.fn().mockImplementation(() => Promise.resolve()), + generateThumbhash: vitest.fn().mockResolvedValue(Buffer.from('')), decodeImage: vitest.fn().mockResolvedValue({ data: Buffer.from(''), info: {} }), extract: vitest.fn().mockResolvedValue(false), probe: vitest.fn(), From f44669447fc2e84202b4eaeefa642b0c81de10a2 Mon Sep 17 00:00:00 2001 From: github-actions <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 28 Jan 2025 02:58:27 +0000 Subject: [PATCH 039/395] chore: version v1.125.6 --- cli/package-lock.json | 6 +++--- cli/package.json | 2 +- docs/static/archived-versions.json | 4 ++++ e2e/package-lock.json | 8 ++++---- e2e/package.json | 2 +- machine-learning/pyproject.toml | 2 +- mobile/android/fastlane/Fastfile | 4 ++-- mobile/ios/fastlane/Fastfile | 2 +- mobile/openapi/README.md | 2 +- mobile/pubspec.yaml | 2 +- open-api/immich-openapi-specs.json | 2 +- open-api/typescript-sdk/package-lock.json | 4 ++-- open-api/typescript-sdk/package.json | 2 +- open-api/typescript-sdk/src/fetch-client.ts | 2 +- server/package-lock.json | 4 ++-- server/package.json | 2 +- web/package-lock.json | 6 +++--- web/package.json | 2 +- 18 files changed, 31 insertions(+), 27 deletions(-) diff --git a/cli/package-lock.json b/cli/package-lock.json index 9044ab7171..4e956fdfde 100644 --- a/cli/package-lock.json +++ b/cli/package-lock.json @@ -1,12 +1,12 @@ { "name": "@immich/cli", - "version": "2.2.46", + "version": "2.2.47", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@immich/cli", - "version": "2.2.46", + "version": "2.2.47", "license": "GNU Affero General Public License version 3", "dependencies": { "fast-glob": "^3.3.2", @@ -52,7 +52,7 @@ }, "../open-api/typescript-sdk": { "name": "@immich/sdk", - "version": "1.125.5", + "version": "1.125.6", "dev": true, "license": "GNU Affero General Public License version 3", "dependencies": { diff --git a/cli/package.json b/cli/package.json index 24da3c87ac..f356c7fbe7 100644 --- a/cli/package.json +++ b/cli/package.json @@ -1,6 +1,6 @@ { "name": "@immich/cli", - "version": "2.2.46", + "version": "2.2.47", "description": "Command Line Interface (CLI) for Immich", "type": "module", "exports": "./dist/index.js", diff --git a/docs/static/archived-versions.json b/docs/static/archived-versions.json index 177f445f53..829935d60b 100644 --- a/docs/static/archived-versions.json +++ b/docs/static/archived-versions.json @@ -1,4 +1,8 @@ [ + { + "label": "v1.125.6", + "url": "https://v1.125.6.archive.immich.app" + }, { "label": "v1.125.5", "url": "https://v1.125.5.archive.immich.app" diff --git a/e2e/package-lock.json b/e2e/package-lock.json index 5fd97d8f98..76314d99cc 100644 --- a/e2e/package-lock.json +++ b/e2e/package-lock.json @@ -1,12 +1,12 @@ { "name": "immich-e2e", - "version": "1.125.5", + "version": "1.125.6", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "immich-e2e", - "version": "1.125.5", + "version": "1.125.6", "license": "GNU Affero General Public License version 3", "devDependencies": { "@eslint/eslintrc": "^3.1.0", @@ -45,7 +45,7 @@ }, "../cli": { "name": "@immich/cli", - "version": "2.2.46", + "version": "2.2.47", "dev": true, "license": "GNU Affero General Public License version 3", "dependencies": { @@ -92,7 +92,7 @@ }, "../open-api/typescript-sdk": { "name": "@immich/sdk", - "version": "1.125.5", + "version": "1.125.6", "dev": true, "license": "GNU Affero General Public License version 3", "dependencies": { diff --git a/e2e/package.json b/e2e/package.json index a65545ddb3..87193b55a0 100644 --- a/e2e/package.json +++ b/e2e/package.json @@ -1,6 +1,6 @@ { "name": "immich-e2e", - "version": "1.125.5", + "version": "1.125.6", "description": "", "main": "index.js", "type": "module", diff --git a/machine-learning/pyproject.toml b/machine-learning/pyproject.toml index 14be0f1947..c644caac71 100644 --- a/machine-learning/pyproject.toml +++ b/machine-learning/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "machine-learning" -version = "1.125.5" +version = "1.125.6" description = "" authors = ["Hau Tran "] readme = "README.md" diff --git a/mobile/android/fastlane/Fastfile b/mobile/android/fastlane/Fastfile index c943ca8f20..b1dcfcb938 100644 --- a/mobile/android/fastlane/Fastfile +++ b/mobile/android/fastlane/Fastfile @@ -35,8 +35,8 @@ platform :android do task: 'bundle', build_type: 'Release', properties: { - "android.injected.version.code" => 181, - "android.injected.version.name" => "1.125.5", + "android.injected.version.code" => 182, + "android.injected.version.name" => "1.125.6", } ) upload_to_play_store(skip_upload_apk: true, skip_upload_images: true, skip_upload_screenshots: true, aab: '../build/app/outputs/bundle/release/app-release.aab') diff --git a/mobile/ios/fastlane/Fastfile b/mobile/ios/fastlane/Fastfile index 82694c9835..52f6bc0fbe 100644 --- a/mobile/ios/fastlane/Fastfile +++ b/mobile/ios/fastlane/Fastfile @@ -19,7 +19,7 @@ platform :ios do desc "iOS Release" lane :release do increment_version_number( - version_number: "1.125.5" + version_number: "1.125.6" ) increment_build_number( build_number: latest_testflight_build_number + 1, diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index d448416a18..01ced65598 100644 --- a/mobile/openapi/README.md +++ b/mobile/openapi/README.md @@ -3,7 +3,7 @@ Immich API This Dart package is automatically generated by the [OpenAPI Generator](https://openapi-generator.tech) project: -- API version: 1.125.5 +- API version: 1.125.6 - Generator version: 7.8.0 - Build package: org.openapitools.codegen.languages.DartClientCodegen diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml index 3bd660dac2..9741ddfa3e 100644 --- a/mobile/pubspec.yaml +++ b/mobile/pubspec.yaml @@ -2,7 +2,7 @@ name: immich_mobile description: Immich - selfhosted backup media file on mobile phone publish_to: 'none' -version: 1.125.5+181 +version: 1.125.6+182 environment: sdk: '>=3.3.0 <4.0.0' diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index 30fa7316ec..fc62b58290 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -7454,7 +7454,7 @@ "info": { "title": "Immich", "description": "Immich API", - "version": "1.125.5", + "version": "1.125.6", "contact": {} }, "tags": [], diff --git a/open-api/typescript-sdk/package-lock.json b/open-api/typescript-sdk/package-lock.json index 8ceb407798..62fe913e70 100644 --- a/open-api/typescript-sdk/package-lock.json +++ b/open-api/typescript-sdk/package-lock.json @@ -1,12 +1,12 @@ { "name": "@immich/sdk", - "version": "1.125.5", + "version": "1.125.6", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@immich/sdk", - "version": "1.125.5", + "version": "1.125.6", "license": "GNU Affero General Public License version 3", "dependencies": { "@oazapfts/runtime": "^1.0.2" diff --git a/open-api/typescript-sdk/package.json b/open-api/typescript-sdk/package.json index 88f35e2bfe..81352dc721 100644 --- a/open-api/typescript-sdk/package.json +++ b/open-api/typescript-sdk/package.json @@ -1,6 +1,6 @@ { "name": "@immich/sdk", - "version": "1.125.5", + "version": "1.125.6", "description": "Auto-generated TypeScript SDK for the Immich API", "type": "module", "main": "./build/index.js", diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index 93c72a477b..088e30f9d8 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -1,6 +1,6 @@ /** * Immich - * 1.125.5 + * 1.125.6 * DO NOT MODIFY - This file has been generated using oazapfts. * See https://www.npmjs.com/package/oazapfts */ diff --git a/server/package-lock.json b/server/package-lock.json index 9a3587fa3a..ab9c5f91a0 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -1,12 +1,12 @@ { "name": "immich", - "version": "1.125.5", + "version": "1.125.6", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "immich", - "version": "1.125.5", + "version": "1.125.6", "license": "GNU Affero General Public License version 3", "dependencies": { "@nestjs/bullmq": "^11.0.0", diff --git a/server/package.json b/server/package.json index f9b55a1ac4..974256c8e3 100644 --- a/server/package.json +++ b/server/package.json @@ -1,6 +1,6 @@ { "name": "immich", - "version": "1.125.5", + "version": "1.125.6", "description": "", "author": "", "private": true, diff --git a/web/package-lock.json b/web/package-lock.json index d57126bb80..c445a58b97 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -1,12 +1,12 @@ { "name": "immich-web", - "version": "1.125.5", + "version": "1.125.6", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "immich-web", - "version": "1.125.5", + "version": "1.125.6", "license": "GNU Affero General Public License version 3", "dependencies": { "@formatjs/icu-messageformat-parser": "^2.9.8", @@ -75,7 +75,7 @@ }, "../open-api/typescript-sdk": { "name": "@immich/sdk", - "version": "1.125.5", + "version": "1.125.6", "license": "GNU Affero General Public License version 3", "dependencies": { "@oazapfts/runtime": "^1.0.2" diff --git a/web/package.json b/web/package.json index 9e9a6bf7e2..de60c94887 100644 --- a/web/package.json +++ b/web/package.json @@ -1,6 +1,6 @@ { "name": "immich-web", - "version": "1.125.5", + "version": "1.125.6", "license": "GNU Affero General Public License version 3", "scripts": { "dev": "vite dev --host 0.0.0.0 --port 3000", From fe1e09e51f565b0e521862b5f3e18a8b5b1f588a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20K=C3=BCndig?= Date: Tue, 28 Jan 2025 04:54:29 +0100 Subject: [PATCH 040/395] fix(server): Allow negative rating (for rejected images) (#15699) Allow negative rating (for rejected images) --- e2e/src/api/specs/asset.e2e-spec.ts | 14 ++++++++++++++ .../openapi/lib/model/asset_bulk_update_dto.dart | 2 +- mobile/openapi/lib/model/update_asset_dto.dart | 2 +- open-api/immich-openapi-specs.json | 4 ++-- server/src/dtos/asset.dto.ts | 2 +- server/src/services/metadata.service.spec.ts | 11 +++++++++++ server/src/services/metadata.service.ts | 2 +- 7 files changed, 31 insertions(+), 6 deletions(-) diff --git a/e2e/src/api/specs/asset.e2e-spec.ts b/e2e/src/api/specs/asset.e2e-spec.ts index 32cbdd6df8..1b644454aa 100644 --- a/e2e/src/api/specs/asset.e2e-spec.ts +++ b/e2e/src/api/specs/asset.e2e-spec.ts @@ -701,6 +701,20 @@ describe('/asset', () => { expect(status).toEqual(200); }); + it('should set the negative rating', async () => { + const { status, body } = await request(app) + .put(`/assets/${user1Assets[0].id}`) + .set('Authorization', `Bearer ${user1.accessToken}`) + .send({ rating: -1 }); + expect(body).toMatchObject({ + id: user1Assets[0].id, + exifInfo: expect.objectContaining({ + rating: -1, + }), + }); + expect(status).toEqual(200); + }); + it('should reject invalid rating', async () => { for (const test of [{ rating: 7 }, { rating: 3.5 }, { rating: null }]) { const { status, body } = await request(app) diff --git a/mobile/openapi/lib/model/asset_bulk_update_dto.dart b/mobile/openapi/lib/model/asset_bulk_update_dto.dart index da23d2f09d..0b5a2c30d9 100644 --- a/mobile/openapi/lib/model/asset_bulk_update_dto.dart +++ b/mobile/openapi/lib/model/asset_bulk_update_dto.dart @@ -67,7 +67,7 @@ class AssetBulkUpdateDto { /// num? longitude; - /// Minimum value: 0 + /// Minimum value: -1 /// Maximum value: 5 /// /// Please note: This property should have been non-nullable! Since the specification file diff --git a/mobile/openapi/lib/model/update_asset_dto.dart b/mobile/openapi/lib/model/update_asset_dto.dart index 9ebce5fd92..c6ae6d8e07 100644 --- a/mobile/openapi/lib/model/update_asset_dto.dart +++ b/mobile/openapi/lib/model/update_asset_dto.dart @@ -73,7 +73,7 @@ class UpdateAssetDto { /// num? longitude; - /// Minimum value: 0 + /// Minimum value: -1 /// Maximum value: 5 /// /// Please note: This property should have been non-nullable! Since the specification file diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index fc62b58290..3067b25449 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -7951,7 +7951,7 @@ }, "rating": { "maximum": 5, - "minimum": 0, + "minimum": -1, "type": "number" } }, @@ -12780,7 +12780,7 @@ }, "rating": { "maximum": 5, - "minimum": 0, + "minimum": -1, "type": "number" } }, diff --git a/server/src/dtos/asset.dto.ts b/server/src/dtos/asset.dto.ts index 42d6d7d745..8aa63f2f69 100644 --- a/server/src/dtos/asset.dto.ts +++ b/server/src/dtos/asset.dto.ts @@ -52,7 +52,7 @@ export class UpdateAssetBase { @Optional() @IsInt() @Max(5) - @Min(0) + @Min(-1) rating?: number; } diff --git a/server/src/services/metadata.service.spec.ts b/server/src/services/metadata.service.spec.ts index 8cc6e014d2..99ca1e7ed3 100644 --- a/server/src/services/metadata.service.spec.ts +++ b/server/src/services/metadata.service.spec.ts @@ -1162,6 +1162,17 @@ describe(MetadataService.name, () => { }), ); }); + it('should handle valid negative rating value', async () => { + assetMock.getByIds.mockResolvedValue([assetStub.image]); + mockReadTags({ Rating: -1 }); + + await sut.handleMetadataExtraction({ id: assetStub.image.id }); + expect(assetMock.upsertExif).toHaveBeenCalledWith( + expect.objectContaining({ + rating: -1, + }), + ); + }); }); describe('handleQueueSidecar', () => { diff --git a/server/src/services/metadata.service.ts b/server/src/services/metadata.service.ts index d5b7e6e4e4..db3af9fca0 100644 --- a/server/src/services/metadata.service.ts +++ b/server/src/services/metadata.service.ts @@ -204,7 +204,7 @@ export class MetadataService extends BaseService { // comments description: String(exifTags.ImageDescription || exifTags.Description || '').trim(), profileDescription: exifTags.ProfileDescription || null, - rating: validateRange(exifTags.Rating, 0, 5), + rating: validateRange(exifTags.Rating, -1, 5), // grouping livePhotoCID: (exifTags.ContentIdentifier || exifTags.MediaGroupUUID) ?? null, From 92dff839d091ac837cca903fb521fd0dae4ee22d Mon Sep 17 00:00:00 2001 From: RiggiG <44820045+RiggiG@users.noreply.github.com> Date: Mon, 27 Jan 2025 22:54:56 -0500 Subject: [PATCH 041/395] fix(web): do not throw error when hash fails (#15740) change: do not throw error when hash fails --- web/src/lib/utils/file-uploader.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/web/src/lib/utils/file-uploader.ts b/web/src/lib/utils/file-uploader.ts index 407501e622..04d4d78810 100644 --- a/web/src/lib/utils/file-uploader.ts +++ b/web/src/lib/utils/file-uploader.ts @@ -157,7 +157,6 @@ async function fileUploader( } } catch (error) { console.error(`Error calculating sha1 file=${assetFile.name})`, error); - throw error; } } From 08db77db231420849ef5f4c082d705838d04e19a Mon Sep 17 00:00:00 2001 From: PastLeo Date: Tue, 28 Jan 2025 23:09:40 +0800 Subject: [PATCH 042/395] =?UTF-8?q?feat:=20resolution=20selection=20and=20?= =?UTF-8?q?default=20preview=20playback=20for=20360=C2=B0=20panorama=20vid?= =?UTF-8?q?eos=20(#15747)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * original/preview switching in photo-sphere-viewer 1. default to preview in photo-sphere-viewer video mode 2. install and integrate @photo-sphere-viewer/settings-plugin & @photo-sphere-viewer/resolution-plugin * fix lint errors --- web/package-lock.json | 21 ++++++++++ web/package.json | 2 + .../asset-viewer/image-panorama-viewer.svelte | 2 +- .../photo-sphere-viewer-adapter.svelte | 42 +++++++++++++++---- .../asset-viewer/video-panorama-viewer.svelte | 10 ++++- 5 files changed, 66 insertions(+), 11 deletions(-) diff --git a/web/package-lock.json b/web/package-lock.json index c445a58b97..b6454c2caa 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -16,6 +16,8 @@ "@mdi/js": "^7.4.47", "@photo-sphere-viewer/core": "^5.11.5", "@photo-sphere-viewer/equirectangular-video-adapter": "^5.11.5", + "@photo-sphere-viewer/resolution-plugin": "^5.11.5", + "@photo-sphere-viewer/settings-plugin": "^5.11.5", "@photo-sphere-viewer/video-plugin": "^5.11.5", "@zoom-image/svelte": "^0.3.0", "dom-to-image": "^2.6.0", @@ -1669,6 +1671,25 @@ "@photo-sphere-viewer/video-plugin": "5.11.5" } }, + "node_modules/@photo-sphere-viewer/resolution-plugin": { + "version": "5.11.5", + "resolved": "https://registry.npmjs.org/@photo-sphere-viewer/resolution-plugin/-/resolution-plugin-5.11.5.tgz", + "integrity": "sha512-Dbvp5bBtozD3IWt1Q0wORVaZBcB1bV9xUeoOS9A7F7b3EkQ2pkC5/jot/1AyM4wtU5wJ63NWHskQ1d7m6WWazQ==", + "license": "MIT", + "peerDependencies": { + "@photo-sphere-viewer/core": "5.11.5", + "@photo-sphere-viewer/settings-plugin": "5.11.5" + } + }, + "node_modules/@photo-sphere-viewer/settings-plugin": { + "version": "5.11.5", + "resolved": "https://registry.npmjs.org/@photo-sphere-viewer/settings-plugin/-/settings-plugin-5.11.5.tgz", + "integrity": "sha512-ZgYaWjiBMhsoRH5ddW3h+v4J4LPmofsT7BBRq5UCssWw2Fsrvv7mFFRi4UbZ1qzeKmvNUOr8BaFQgX1ZLvUWfQ==", + "license": "MIT", + "peerDependencies": { + "@photo-sphere-viewer/core": "5.11.5" + } + }, "node_modules/@photo-sphere-viewer/video-plugin": { "version": "5.11.5", "resolved": "https://registry.npmjs.org/@photo-sphere-viewer/video-plugin/-/video-plugin-5.11.5.tgz", diff --git a/web/package.json b/web/package.json index de60c94887..9c9bfc680c 100644 --- a/web/package.json +++ b/web/package.json @@ -72,6 +72,8 @@ "@mdi/js": "^7.4.47", "@photo-sphere-viewer/core": "^5.11.5", "@photo-sphere-viewer/equirectangular-video-adapter": "^5.11.5", + "@photo-sphere-viewer/resolution-plugin": "^5.11.5", + "@photo-sphere-viewer/settings-plugin": "^5.11.5", "@photo-sphere-viewer/video-plugin": "^5.11.5", "@zoom-image/svelte": "^0.3.0", "dom-to-image": "^2.6.0", diff --git a/web/src/lib/components/asset-viewer/image-panorama-viewer.svelte b/web/src/lib/components/asset-viewer/image-panorama-viewer.svelte index 6da8cc33d3..7b9fd85b4a 100644 --- a/web/src/lib/components/asset-viewer/image-panorama-viewer.svelte +++ b/web/src/lib/components/asset-viewer/image-panorama-viewer.svelte @@ -24,7 +24,7 @@ {:then [data, { default: PhotoSphereViewer }]} {:catch} {$t('errors.failed_to_load_asset')} diff --git a/web/src/lib/components/asset-viewer/photo-sphere-viewer-adapter.svelte b/web/src/lib/components/asset-viewer/photo-sphere-viewer-adapter.svelte index 0c8f76a01e..517e630dc9 100644 --- a/web/src/lib/components/asset-viewer/photo-sphere-viewer-adapter.svelte +++ b/web/src/lib/components/asset-viewer/photo-sphere-viewer-adapter.svelte @@ -7,18 +7,21 @@ type AdapterConstructor, type PluginConstructor, } from '@photo-sphere-viewer/core'; + import { SettingsPlugin } from '@photo-sphere-viewer/settings-plugin'; + import { ResolutionPlugin } from '@photo-sphere-viewer/resolution-plugin'; import '@photo-sphere-viewer/core/index.css'; + import '@photo-sphere-viewer/settings-plugin/index.css'; import { onDestroy, onMount } from 'svelte'; interface Props { panorama: string | { source: string }; - originalImageUrl?: string; + originalPanorama?: string | { source: string }; adapter?: AdapterConstructor | [AdapterConstructor, unknown]; plugins?: (PluginConstructor | [PluginConstructor, unknown])[]; navbar?: boolean; } - let { panorama, originalImageUrl, adapter = EquirectangularAdapter, plugins = [], navbar = false }: Props = $props(); + let { panorama, originalPanorama, adapter = EquirectangularAdapter, plugins = [], navbar = false }: Props = $props(); let container: HTMLDivElement | undefined = $state(); let viewer: Viewer; @@ -30,9 +33,33 @@ viewer = new Viewer({ adapter, - plugins, + plugins: [ + SettingsPlugin, + [ + ResolutionPlugin, + { + defaultResolution: $alwaysLoadOriginalFile && originalPanorama ? 'original' : 'default', + resolutions: [ + { + id: 'default', + label: 'Default', + panorama, + }, + ...(originalPanorama + ? [ + { + id: 'original', + label: 'Original', + panorama: originalPanorama, + }, + ] + : []), + ], + }, + ], + ...plugins, + ], container, - panorama, touchmoveTwoFingers: false, mousewheelCtrlKey: false, navbar, @@ -40,15 +67,14 @@ maxFov: 120, fisheye: false, }); + const resolutionPlugin = viewer.getPlugin(ResolutionPlugin) as ResolutionPlugin; - if (originalImageUrl && !$alwaysLoadOriginalFile) { + if (originalPanorama && !$alwaysLoadOriginalFile) { const zoomHandler = ({ zoomLevel }: events.ZoomUpdatedEvent) => { // zoomLevel range: [0, 100] if (Math.round(zoomLevel) >= 75) { // Replace the preview with the original - viewer.setPanorama(originalImageUrl, { showLoader: false, speed: 150 }).catch(() => { - viewer.setPanorama(panorama, { showLoader: false, speed: 0 }).catch(() => {}); - }); + void resolutionPlugin.setResolution('original'); viewer.removeEventListener(events.ZoomUpdatedEvent.type, zoomHandler); } }; diff --git a/web/src/lib/components/asset-viewer/video-panorama-viewer.svelte b/web/src/lib/components/asset-viewer/video-panorama-viewer.svelte index 73315d661e..a205ffce3c 100644 --- a/web/src/lib/components/asset-viewer/video-panorama-viewer.svelte +++ b/web/src/lib/components/asset-viewer/video-panorama-viewer.svelte @@ -1,5 +1,5 @@ - {getDateRange(startDate, endDate)} + {getAlbumDateRange(album)} {$t('items_count', { values: { count: album.assetCount } })} diff --git a/web/src/lib/utils/date-time.spec.ts b/web/src/lib/utils/date-time.spec.ts index cbb08418c0..90db980e2a 100644 --- a/web/src/lib/utils/date-time.spec.ts +++ b/web/src/lib/utils/date-time.spec.ts @@ -1,4 +1,5 @@ -import { timeToSeconds } from './date-time'; +import { writable } from 'svelte/store'; +import { getAlbumDateRange, timeToSeconds } from './date-time'; describe('converting time to seconds', () => { it('parses hh:mm:ss correctly', () => { @@ -21,3 +22,30 @@ describe('converting time to seconds', () => { expect(timeToSeconds('01:02:03.456.123456')).toBeCloseTo(3723.456); }); }); + +describe('getAlbumDate', () => { + beforeAll(() => { + process.env.TZ = 'UTC'; + + vitest.mock('$lib/stores/preferences.store', () => ({ + locale: writable('en'), + })); + }); + + it('should work with only a start date', () => { + expect(getAlbumDateRange({ startDate: '2021-01-01T00:00:00Z' })).toEqual('Jan 1, 2021'); + }); + + it('should work with a start and end date', () => { + expect( + getAlbumDateRange({ + startDate: '2021-01-01T00:00:00Z', + endDate: '2021-01-05T00:00:00Z', + }), + ).toEqual('Jan 1, 2021 - Jan 5, 2021'); + }); + + it('should work with the new date format', () => { + expect(getAlbumDateRange({ startDate: '2021-01-01T00:00:00+05:00' })).toEqual('Jan 1, 2021'); + }); +}); diff --git a/web/src/lib/utils/date-time.ts b/web/src/lib/utils/date-time.ts index d5482f153f..ba22503c70 100644 --- a/web/src/lib/utils/date-time.ts +++ b/web/src/lib/utils/date-time.ts @@ -1,3 +1,4 @@ +import { dateFormats } from '$lib/constants'; import { locale } from '$lib/stores/preferences.store'; import { DateTime, Duration } from 'luxon'; import { get } from 'svelte/store'; @@ -51,3 +52,28 @@ export const getShortDateRange = (startDate: string | Date, endDate: string | Da return `${startDateLocalized} - ${endDateLocalized}`; } }; + +const formatDate = (date?: string) => { + if (!date) { + return; + } + + // without timezone + const localDate = date.replace(/Z$/, '').replace(/\+.+$/, ''); + return localDate ? new Date(localDate).toLocaleDateString(get(locale), dateFormats.album) : undefined; +}; + +export const getAlbumDateRange = (album: { startDate?: string; endDate?: string }) => { + const start = formatDate(album.startDate); + const end = formatDate(album.endDate); + + if (start && end && start !== end) { + return `${start} - ${end}`; + } + + if (start) { + return start; + } + + return ''; +}; From 4fccc09fc128e88c0485691b6b544b1a4d027ada Mon Sep 17 00:00:00 2001 From: Felix Eckhofer Date: Fri, 31 Jan 2025 03:34:12 +0100 Subject: [PATCH 059/395] chore: fix typo in libraries.md (#15800) Fix typo in libraries.md --- docs/docs/features/libraries.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/docs/features/libraries.md b/docs/docs/features/libraries.md index 6a1dba9eba..796337f37c 100644 --- a/docs/docs/features/libraries.md +++ b/docs/docs/features/libraries.md @@ -58,7 +58,7 @@ If your photos are on a network drive, automatic file watching likely won't work #### Troubleshooting -If you encounter an `ENOSPC` error, you need to increase your file watcher limit. In sysctl, this key is called `fs.inotify.max_user_watched` and has a default value of 8192. Increase this number to a suitable value greater than the number of files you will be watching. Note that Immich has to watch all files in your import paths including any ignored files. +If you encounter an `ENOSPC` error, you need to increase your file watcher limit. In sysctl, this key is called `fs.inotify.max_user_watches` and has a default value of 8192. Increase this number to a suitable value greater than the number of files you will be watching. Note that Immich has to watch all files in your import paths including any ignored files. ``` ERROR [LibraryService] Library watcher for library c69faf55-f96d-4aa0-b83b-2d80cbc27d98 encountered error: Error: ENOSPC: System limit for number of file watchers reached, watch '/media/photo.jpg' From 098bab7c9b8162438a74fdbd8329c1b8501a14c3 Mon Sep 17 00:00:00 2001 From: Alex Date: Thu, 30 Jan 2025 21:12:57 -0600 Subject: [PATCH 060/395] fix(mobile): search page issues (#15804) * fix: don't repeat search * fix: show snackbar for no result * fix: do not search on empty filter * chore: syling --- mobile/assets/i18n/en-US.json | 2 + .../lib/interfaces/person_api.interface.dart | 81 ++++++++++++++++++- .../models/search/search_filter.model.dart | 17 ++++ mobile/lib/pages/search/search.page.dart | 57 +++++++++++-- .../search/paginated_search.provider.dart | 12 ++- mobile/lib/services/search.service.dart | 2 +- .../search/search_filter/people_picker.dart | 2 +- 7 files changed, 159 insertions(+), 14 deletions(-) diff --git a/mobile/assets/i18n/en-US.json b/mobile/assets/i18n/en-US.json index c14aa6d748..61c4621346 100644 --- a/mobile/assets/i18n/en-US.json +++ b/mobile/assets/i18n/en-US.json @@ -1,4 +1,6 @@ { + "search_no_result": "No results found, try a different search term or combination", + "search_no_more_result": "No more results", "action_common_back": "Back", "action_common_cancel": "Cancel", "action_common_clear": "Clear", diff --git a/mobile/lib/interfaces/person_api.interface.dart b/mobile/lib/interfaces/person_api.interface.dart index b2fa28df8c..9d127ad765 100644 --- a/mobile/lib/interfaces/person_api.interface.dart +++ b/mobile/lib/interfaces/person_api.interface.dart @@ -1,3 +1,6 @@ +// ignore_for_file: public_member_api_docs, sort_constructors_first +import 'dart:convert'; + abstract interface class IPersonApiRepository { Future> getAll(); Future update(String id, {String? name}); @@ -6,10 +9,10 @@ abstract interface class IPersonApiRepository { class Person { Person({ required this.id, + this.birthDate, required this.isHidden, required this.name, required this.thumbnailPath, - this.birthDate, this.updatedAt, }); @@ -19,4 +22,80 @@ class Person { final String name; final String thumbnailPath; final DateTime? updatedAt; + + @override + String toString() { + return 'Person(id: $id, birthDate: $birthDate, isHidden: $isHidden, name: $name, thumbnailPath: $thumbnailPath, updatedAt: $updatedAt)'; + } + + Person copyWith({ + String? id, + DateTime? birthDate, + bool? isHidden, + String? name, + String? thumbnailPath, + DateTime? updatedAt, + }) { + return Person( + id: id ?? this.id, + birthDate: birthDate ?? this.birthDate, + isHidden: isHidden ?? this.isHidden, + name: name ?? this.name, + thumbnailPath: thumbnailPath ?? this.thumbnailPath, + updatedAt: updatedAt ?? this.updatedAt, + ); + } + + Map toMap() { + return { + 'id': id, + 'birthDate': birthDate?.millisecondsSinceEpoch, + 'isHidden': isHidden, + 'name': name, + 'thumbnailPath': thumbnailPath, + 'updatedAt': updatedAt?.millisecondsSinceEpoch, + }; + } + + factory Person.fromMap(Map map) { + return Person( + id: map['id'] as String, + birthDate: map['birthDate'] != null + ? DateTime.fromMillisecondsSinceEpoch(map['birthDate'] as int) + : null, + isHidden: map['isHidden'] as bool, + name: map['name'] as String, + thumbnailPath: map['thumbnailPath'] as String, + updatedAt: map['updatedAt'] != null + ? DateTime.fromMillisecondsSinceEpoch(map['updatedAt'] as int) + : null, + ); + } + + String toJson() => json.encode(toMap()); + + factory Person.fromJson(String source) => + Person.fromMap(json.decode(source) as Map); + + @override + bool operator ==(covariant Person other) { + if (identical(this, other)) return true; + + return other.id == id && + other.birthDate == birthDate && + other.isHidden == isHidden && + other.name == name && + other.thumbnailPath == thumbnailPath && + other.updatedAt == updatedAt; + } + + @override + int get hashCode { + return id.hashCode ^ + birthDate.hashCode ^ + isHidden.hashCode ^ + name.hashCode ^ + thumbnailPath.hashCode ^ + updatedAt.hashCode; + } } diff --git a/mobile/lib/models/search/search_filter.model.dart b/mobile/lib/models/search/search_filter.model.dart index 297a819b6a..0df64b6924 100644 --- a/mobile/lib/models/search/search_filter.model.dart +++ b/mobile/lib/models/search/search_filter.model.dart @@ -255,6 +255,23 @@ class SearchFilter { required this.mediaType, }); + bool get isEmpty { + return (context == null || (context != null && context!.isEmpty)) && + (filename == null || (filename!.isEmpty)) && + people.isEmpty && + location.country == null && + location.state == null && + location.city == null && + camera.make == null && + camera.model == null && + date.takenBefore == null && + date.takenAfter == null && + display.isNotInAlbum == false && + display.isArchive == false && + display.isFavorite == false && + mediaType == AssetType.other; + } + SearchFilter copyWith({ String? context, String? filename, diff --git a/mobile/lib/pages/search/search.page.dart b/mobile/lib/pages/search/search.page.dart index 88cc56a145..385da9dbcb 100644 --- a/mobile/lib/pages/search/search.page.dart +++ b/mobile/lib/pages/search/search.page.dart @@ -49,7 +49,7 @@ class SearchPage extends HookConsumerWidget { ), ); - final previousFilter = useState(filter.value); + final previousFilter = useState(null); final peopleCurrentFilterWidget = useState(null); final dateRangeCurrentFilterWidget = useState(null); @@ -60,19 +60,55 @@ class SearchPage extends HookConsumerWidget { final isSearching = useState(false); + SnackBar searchInfoSnackBar(String message) { + return SnackBar( + content: Text( + message, + style: context.textTheme.labelLarge, + ), + showCloseIcon: true, + behavior: SnackBarBehavior.fixed, + closeIconColor: context.colorScheme.onSurface, + ); + } + search() async { - if (prefilter == null && filter.value == previousFilter.value) return; + if (filter.value.isEmpty) { + return; + } + + if (prefilter == null && filter.value == previousFilter.value) { + return; + } isSearching.value = true; ref.watch(paginatedSearchProvider.notifier).clear(); - await ref.watch(paginatedSearchProvider.notifier).search(filter.value); + final hasResult = await ref + .watch(paginatedSearchProvider.notifier) + .search(filter.value); + + if (!hasResult) { + context.showSnackBar( + searchInfoSnackBar('search_no_result'.tr()), + ); + } + previousFilter.value = filter.value; isSearching.value = false; } loadMoreSearchResult() async { isSearching.value = true; - await ref.watch(paginatedSearchProvider.notifier).search(filter.value); + final hasResult = await ref + .watch(paginatedSearchProvider.notifier) + .search(filter.value); + + if (!hasResult) { + context.showSnackBar( + searchInfoSnackBar('search_no_more_result'.tr()), + ); + } + isSearching.value = false; } @@ -596,10 +632,15 @@ class SearchPage extends HookConsumerWidget { ), ), ), - SearchResultGrid( - onScrollEnd: loadMoreSearchResult, - isSearching: isSearching.value, - ), + if (isSearching.value) + const Expanded( + child: Center(child: CircularProgressIndicator.adaptive()), + ) + else + SearchResultGrid( + onScrollEnd: loadMoreSearchResult, + isSearching: isSearching.value, + ), ], ), ); diff --git a/mobile/lib/providers/search/paginated_search.provider.dart b/mobile/lib/providers/search/paginated_search.provider.dart index 270f1148e8..60264947b2 100644 --- a/mobile/lib/providers/search/paginated_search.provider.dart +++ b/mobile/lib/providers/search/paginated_search.provider.dart @@ -19,17 +19,23 @@ class PaginatedSearchNotifier extends StateNotifier { PaginatedSearchNotifier(this._searchService) : super(SearchResult(assets: [], nextPage: 1)); - search(SearchFilter filter) async { - if (state.nextPage == null) return; + Future search(SearchFilter filter) async { + if (state.nextPage == null) { + return false; + } final result = await _searchService.search(filter, state.nextPage!); - if (result == null) return; + if (result == null) { + return false; + } state = SearchResult( assets: [...state.assets, ...result.assets], nextPage: result.nextPage, ); + + return true; } clear() { diff --git a/mobile/lib/services/search.service.dart b/mobile/lib/services/search.service.dart index ba46848cdd..fe8f7393c2 100644 --- a/mobile/lib/services/search.service.dart +++ b/mobile/lib/services/search.service.dart @@ -101,7 +101,7 @@ class SearchService { ); } - if (response == null) { + if (response == null || response.assets.items.isEmpty) { return null; } diff --git a/mobile/lib/widgets/search/search_filter/people_picker.dart b/mobile/lib/widgets/search/search_filter/people_picker.dart index 9cc74bf939..04f9538875 100644 --- a/mobile/lib/widgets/search/search_filter/people_picker.dart +++ b/mobile/lib/widgets/search/search_filter/people_picker.dart @@ -20,7 +20,7 @@ class PeoplePicker extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final formFocus = useFocusNode(); - final imageSize = 75.0; + final imageSize = 60.0; final searchQuery = useState(''); final people = ref.watch(getAllPeopleProvider); final headers = ApiService.getRequestHeaders(); From 1b141d5ca9d68b7224f6c85d8dbdf3012cfc1c56 Mon Sep 17 00:00:00 2001 From: David Wolff Date: Fri, 31 Jan 2025 16:06:45 +0100 Subject: [PATCH 061/395] refactor(server): filter assets by people using a subquery instead of a cte (#15768) --- server/src/entities/asset.entity.ts | 31 ++++++++++----------- server/src/repositories/asset.repository.ts | 13 ++++----- 2 files changed, 20 insertions(+), 24 deletions(-) diff --git a/server/src/entities/asset.entity.ts b/server/src/entities/asset.entity.ts index e9dbe67a2f..879c2c5169 100644 --- a/server/src/entities/asset.entity.ts +++ b/server/src/entities/asset.entity.ts @@ -238,24 +238,20 @@ export function withFacesAndPeople(eb: ExpressionBuilder) { .as('faces'); } -/** Adds a `has_people` CTE that can be inner joined on to filter out assets */ -export function hasPeopleCte(db: Kysely, personIds: string[]) { - return db.with('has_people', (qb) => - qb - .selectFrom('asset_faces') - .select('assetId') - .where('personId', '=', anyUuid(personIds!)) - .groupBy('assetId') - .having((eb) => eb.fn.count('personId').distinct(), '=', personIds.length), +export function hasPeople(qb: SelectQueryBuilder, personIds: string[]) { + return qb.innerJoin( + (eb) => + eb + .selectFrom('asset_faces') + .select('assetId') + .where('personId', '=', anyUuid(personIds!)) + .groupBy('assetId') + .having((eb) => eb.fn.count('personId').distinct(), '=', personIds.length) + .as('has_people'), + (join) => join.onRef('has_people.assetId', '=', 'assets.id'), ); } -export function hasPeople(db: Kysely, personIds?: string[]) { - return personIds && personIds.length > 0 - ? hasPeopleCte(db, personIds).selectFrom('assets').innerJoin('has_people', 'has_people.assetId', 'assets.id') - : db.selectFrom('assets'); -} - export function withOwner(eb: ExpressionBuilder) { return jsonObjectFrom(eb.selectFrom('users').selectAll().whereRef('users.id', '=', 'assets.ownerId')).as('owner'); } @@ -326,8 +322,11 @@ const joinDeduplicationPlugin = new DeduplicateJoinsPlugin(); export function searchAssetBuilder(kysely: Kysely, options: AssetSearchBuilderOptions) { options.isArchived ??= options.withArchived ? undefined : false; options.withDeleted ||= !!(options.trashedAfter || options.trashedBefore); - return hasPeople(kysely.withPlugin(joinDeduplicationPlugin), options.personIds) + return kysely + .withPlugin(joinDeduplicationPlugin) + .selectFrom('assets') .selectAll('assets') + .$if(!!options.personIds && options.personIds.length > 0, (qb) => hasPeople(qb, options.personIds!)) .$if(!!options.createdBefore, (qb) => qb.where('assets.createdAt', '<=', options.createdBefore!)) .$if(!!options.createdAfter, (qb) => qb.where('assets.createdAt', '>=', options.createdAfter!)) .$if(!!options.updatedBefore, (qb) => qb.where('assets.updatedAt', '<=', options.updatedBefore!)) diff --git a/server/src/repositories/asset.repository.ts b/server/src/repositories/asset.repository.ts index 1f9f8f997f..b306b1a694 100644 --- a/server/src/repositories/asset.repository.ts +++ b/server/src/repositories/asset.repository.ts @@ -8,7 +8,6 @@ import { Chunked, ChunkedArray, DummyValue, GenerateSql } from 'src/decorators'; import { AssetEntity, hasPeople, - hasPeopleCte, searchAssetBuilder, truncatedDate, withAlbums, @@ -576,7 +575,7 @@ export class AssetRepository implements IAssetRepository { @GenerateSql({ params: [{ size: TimeBucketSize.MONTH }] }) async getTimeBuckets(options: TimeBucketOptions): Promise { return ( - ((options.personId ? hasPeopleCte(this.db, [options.personId]) : this.db) as Kysely) + this.db .with('assets', (qb) => qb .selectFrom('assets') @@ -589,11 +588,7 @@ export class AssetRepository implements IAssetRepository { .innerJoin('albums_assets_assets', 'assets.id', 'albums_assets_assets.assetsId') .where('albums_assets_assets.albumsId', '=', asUuid(options.albumId!)), ) - .$if(!!options.personId, (qb) => - qb.innerJoin(sql.table('has_people').as('has_people'), (join) => - join.onRef(sql`has_people."assetId"`, '=', 'assets.id'), - ), - ) + .$if(!!options.personId, (qb) => hasPeople(qb, [options.personId!])) .$if(!!options.withStacked, (qb) => qb .leftJoin('asset_stack', (join) => @@ -628,10 +623,12 @@ export class AssetRepository implements IAssetRepository { @GenerateSql({ params: [DummyValue.TIME_BUCKET, { size: TimeBucketSize.MONTH, withStacked: true }] }) async getTimeBucket(timeBucket: string, options: TimeBucketOptions): Promise { - return hasPeople(this.db, options.personId ? [options.personId] : undefined) + return this.db + .selectFrom('assets') .selectAll('assets') .$call(withExif) .$if(!!options.albumId, (qb) => withAlbums(qb, { albumId: options.albumId })) + .$if(!!options.personId, (qb) => hasPeople(qb, [options.personId!])) .$if(!!options.userIds, (qb) => qb.where('assets.ownerId', '=', anyUuid(options.userIds!))) .$if(options.isArchived !== undefined, (qb) => qb.where('assets.isArchived', '=', options.isArchived!)) .$if(options.isFavorite !== undefined, (qb) => qb.where('assets.isFavorite', '=', options.isFavorite!)) From 221e1976330a02d974d38a95e4865b50d0c75d62 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mangat=20Singh=20Toor=20=7C=20=E0=A8=AE=E0=A9=B0=E0=A8=97?= =?UTF-8?q?=E0=A8=A4=20=E0=A8=B8=E0=A8=BF=E0=A9=B0=E0=A8=98=20=E0=A8=A4?= =?UTF-8?q?=E0=A9=82=E0=A8=B0?= Date: Fri, 31 Jan 2025 07:24:53 -0800 Subject: [PATCH 062/395] fix(mobile): retain edited title when album updates (#15806) * fix(album-viewer): retain edited title when album updates ensure `AlbumViewerEditableTitle` keeps user input while editing, even when the album updates from another provider. fall back to `albumName` only when not in edit mode. * linting --------- Co-authored-by: Alex --- .../lib/widgets/album/album_viewer_editable_title.dart | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/mobile/lib/widgets/album/album_viewer_editable_title.dart b/mobile/lib/widgets/album/album_viewer_editable_title.dart index 7547dff932..72fdfe070d 100644 --- a/mobile/lib/widgets/album/album_viewer_editable_title.dart +++ b/mobile/lib/widgets/album/album_viewer_editable_title.dart @@ -16,7 +16,14 @@ class AlbumViewerEditableTitle extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final titleTextEditController = useTextEditingController(text: albumName); + final albumViewerState = ref.watch(albumViewerProvider); + + final titleTextEditController = useTextEditingController( + text: albumViewerState.isEditAlbum && + albumViewerState.editTitleText.isNotEmpty + ? albumViewerState.editTitleText + : albumName, + ); void onFocusModeChange() { if (!titleFocusNode.hasFocus && titleTextEditController.text.isEmpty) { From 9ac95d6845825d0d188eb9fb7b2971df3695ee92 Mon Sep 17 00:00:00 2001 From: David Wolff Date: Fri, 31 Jan 2025 22:37:22 +0100 Subject: [PATCH 063/395] feat: add searching by tags (#15395) * feat: add searching by tags * fix: fix merge --------- Co-authored-by: Alex --- .../lib/model/metadata_search_dto.dart | 11 ++- .../openapi/lib/model/random_search_dto.dart | 11 ++- .../openapi/lib/model/smart_search_dto.dart | 11 ++- open-api/immich-openapi-specs.json | 21 +++++ open-api/typescript-sdk/src/fetch-client.ts | 3 + server/src/dtos/search.dto.ts | 3 + server/src/entities/asset.entity.ts | 16 ++++ server/src/interfaces/search.interface.ts | 10 ++- .../search-bar/search-filter-modal.svelte | 8 ++ .../search-bar/search-tags-section.svelte | 80 +++++++++++++++++++ .../[[assetId=id]]/+page.svelte | 18 +++++ 11 files changed, 187 insertions(+), 5 deletions(-) create mode 100644 web/src/lib/components/shared-components/search-bar/search-tags-section.svelte diff --git a/mobile/openapi/lib/model/metadata_search_dto.dart b/mobile/openapi/lib/model/metadata_search_dto.dart index 654883b38a..5f9e3f8e15 100644 --- a/mobile/openapi/lib/model/metadata_search_dto.dart +++ b/mobile/openapi/lib/model/metadata_search_dto.dart @@ -41,6 +41,7 @@ class MetadataSearchDto { this.previewPath, this.size, this.state, + this.tagIds = const [], this.takenAfter, this.takenBefore, this.thumbnailPath, @@ -235,6 +236,8 @@ class MetadataSearchDto { String? state; + List tagIds; + /// /// 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 @@ -363,6 +366,7 @@ class MetadataSearchDto { other.previewPath == previewPath && other.size == size && other.state == state && + _deepEquality.equals(other.tagIds, tagIds) && other.takenAfter == takenAfter && other.takenBefore == takenBefore && other.thumbnailPath == thumbnailPath && @@ -408,6 +412,7 @@ class MetadataSearchDto { (previewPath == null ? 0 : previewPath!.hashCode) + (size == null ? 0 : size!.hashCode) + (state == null ? 0 : state!.hashCode) + + (tagIds.hashCode) + (takenAfter == null ? 0 : takenAfter!.hashCode) + (takenBefore == null ? 0 : takenBefore!.hashCode) + (thumbnailPath == null ? 0 : thumbnailPath!.hashCode) + @@ -423,7 +428,7 @@ class MetadataSearchDto { (withStacked == null ? 0 : withStacked!.hashCode); @override - String toString() => 'MetadataSearchDto[checksum=$checksum, city=$city, country=$country, createdAfter=$createdAfter, createdBefore=$createdBefore, deviceAssetId=$deviceAssetId, deviceId=$deviceId, encodedVideoPath=$encodedVideoPath, id=$id, isArchived=$isArchived, isEncoded=$isEncoded, isFavorite=$isFavorite, isMotion=$isMotion, isNotInAlbum=$isNotInAlbum, isOffline=$isOffline, isVisible=$isVisible, lensModel=$lensModel, libraryId=$libraryId, make=$make, model=$model, order=$order, originalFileName=$originalFileName, originalPath=$originalPath, page=$page, personIds=$personIds, previewPath=$previewPath, size=$size, state=$state, takenAfter=$takenAfter, takenBefore=$takenBefore, thumbnailPath=$thumbnailPath, trashedAfter=$trashedAfter, trashedBefore=$trashedBefore, type=$type, updatedAfter=$updatedAfter, updatedBefore=$updatedBefore, withArchived=$withArchived, withDeleted=$withDeleted, withExif=$withExif, withPeople=$withPeople, withStacked=$withStacked]'; + String toString() => 'MetadataSearchDto[checksum=$checksum, city=$city, country=$country, createdAfter=$createdAfter, createdBefore=$createdBefore, deviceAssetId=$deviceAssetId, deviceId=$deviceId, encodedVideoPath=$encodedVideoPath, id=$id, isArchived=$isArchived, isEncoded=$isEncoded, isFavorite=$isFavorite, isMotion=$isMotion, isNotInAlbum=$isNotInAlbum, isOffline=$isOffline, isVisible=$isVisible, lensModel=$lensModel, libraryId=$libraryId, make=$make, model=$model, order=$order, originalFileName=$originalFileName, originalPath=$originalPath, page=$page, personIds=$personIds, previewPath=$previewPath, size=$size, state=$state, tagIds=$tagIds, takenAfter=$takenAfter, takenBefore=$takenBefore, thumbnailPath=$thumbnailPath, trashedAfter=$trashedAfter, trashedBefore=$trashedBefore, type=$type, updatedAfter=$updatedAfter, updatedBefore=$updatedBefore, withArchived=$withArchived, withDeleted=$withDeleted, withExif=$withExif, withPeople=$withPeople, withStacked=$withStacked]'; Map toJson() { final json = {}; @@ -559,6 +564,7 @@ class MetadataSearchDto { } else { // json[r'state'] = null; } + json[r'tagIds'] = this.tagIds; if (this.takenAfter != null) { json[r'takenAfter'] = this.takenAfter!.toUtc().toIso8601String(); } else { @@ -662,6 +668,9 @@ class MetadataSearchDto { previewPath: mapValueOfType(json, r'previewPath'), size: num.parse('${json[r'size']}'), state: mapValueOfType(json, r'state'), + tagIds: json[r'tagIds'] is Iterable + ? (json[r'tagIds'] as Iterable).cast().toList(growable: false) + : const [], takenAfter: mapDateTime(json, r'takenAfter', r''), takenBefore: mapDateTime(json, r'takenBefore', r''), thumbnailPath: mapValueOfType(json, r'thumbnailPath'), diff --git a/mobile/openapi/lib/model/random_search_dto.dart b/mobile/openapi/lib/model/random_search_dto.dart index 3fcab05bbb..c63d7e82f6 100644 --- a/mobile/openapi/lib/model/random_search_dto.dart +++ b/mobile/openapi/lib/model/random_search_dto.dart @@ -32,6 +32,7 @@ class RandomSearchDto { this.personIds = const [], this.size, this.state, + this.tagIds = const [], this.takenAfter, this.takenBefore, this.trashedAfter, @@ -158,6 +159,8 @@ class RandomSearchDto { String? state; + List tagIds; + /// /// 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 @@ -269,6 +272,7 @@ class RandomSearchDto { _deepEquality.equals(other.personIds, personIds) && other.size == size && other.state == state && + _deepEquality.equals(other.tagIds, tagIds) && other.takenAfter == takenAfter && other.takenBefore == takenBefore && other.trashedAfter == trashedAfter && @@ -304,6 +308,7 @@ class RandomSearchDto { (personIds.hashCode) + (size == null ? 0 : size!.hashCode) + (state == null ? 0 : state!.hashCode) + + (tagIds.hashCode) + (takenAfter == null ? 0 : takenAfter!.hashCode) + (takenBefore == null ? 0 : takenBefore!.hashCode) + (trashedAfter == null ? 0 : trashedAfter!.hashCode) + @@ -318,7 +323,7 @@ class RandomSearchDto { (withStacked == null ? 0 : withStacked!.hashCode); @override - String toString() => 'RandomSearchDto[city=$city, country=$country, createdAfter=$createdAfter, createdBefore=$createdBefore, deviceId=$deviceId, isArchived=$isArchived, isEncoded=$isEncoded, isFavorite=$isFavorite, isMotion=$isMotion, isNotInAlbum=$isNotInAlbum, isOffline=$isOffline, isVisible=$isVisible, lensModel=$lensModel, libraryId=$libraryId, make=$make, model=$model, personIds=$personIds, size=$size, state=$state, takenAfter=$takenAfter, takenBefore=$takenBefore, trashedAfter=$trashedAfter, trashedBefore=$trashedBefore, type=$type, updatedAfter=$updatedAfter, updatedBefore=$updatedBefore, withArchived=$withArchived, withDeleted=$withDeleted, withExif=$withExif, withPeople=$withPeople, withStacked=$withStacked]'; + String toString() => 'RandomSearchDto[city=$city, country=$country, createdAfter=$createdAfter, createdBefore=$createdBefore, deviceId=$deviceId, isArchived=$isArchived, isEncoded=$isEncoded, isFavorite=$isFavorite, isMotion=$isMotion, isNotInAlbum=$isNotInAlbum, isOffline=$isOffline, isVisible=$isVisible, lensModel=$lensModel, libraryId=$libraryId, make=$make, model=$model, personIds=$personIds, size=$size, state=$state, tagIds=$tagIds, takenAfter=$takenAfter, takenBefore=$takenBefore, trashedAfter=$trashedAfter, trashedBefore=$trashedBefore, type=$type, updatedAfter=$updatedAfter, updatedBefore=$updatedBefore, withArchived=$withArchived, withDeleted=$withDeleted, withExif=$withExif, withPeople=$withPeople, withStacked=$withStacked]'; Map toJson() { final json = {}; @@ -413,6 +418,7 @@ class RandomSearchDto { } else { // json[r'state'] = null; } + json[r'tagIds'] = this.tagIds; if (this.takenAfter != null) { json[r'takenAfter'] = this.takenAfter!.toUtc().toIso8601String(); } else { @@ -502,6 +508,9 @@ class RandomSearchDto { : const [], size: num.parse('${json[r'size']}'), state: mapValueOfType(json, r'state'), + tagIds: json[r'tagIds'] is Iterable + ? (json[r'tagIds'] as Iterable).cast().toList(growable: false) + : const [], takenAfter: mapDateTime(json, r'takenAfter', r''), takenBefore: mapDateTime(json, r'takenBefore', r''), trashedAfter: mapDateTime(json, r'trashedAfter', r''), diff --git a/mobile/openapi/lib/model/smart_search_dto.dart b/mobile/openapi/lib/model/smart_search_dto.dart index 4e1408cafa..c81e1519b4 100644 --- a/mobile/openapi/lib/model/smart_search_dto.dart +++ b/mobile/openapi/lib/model/smart_search_dto.dart @@ -34,6 +34,7 @@ class SmartSearchDto { required this.query, this.size, this.state, + this.tagIds = const [], this.takenAfter, this.takenBefore, this.trashedAfter, @@ -169,6 +170,8 @@ class SmartSearchDto { String? state; + List tagIds; + /// /// 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 @@ -266,6 +269,7 @@ class SmartSearchDto { other.query == query && other.size == size && other.state == state && + _deepEquality.equals(other.tagIds, tagIds) && other.takenAfter == takenAfter && other.takenBefore == takenBefore && other.trashedAfter == trashedAfter && @@ -301,6 +305,7 @@ class SmartSearchDto { (query.hashCode) + (size == null ? 0 : size!.hashCode) + (state == null ? 0 : state!.hashCode) + + (tagIds.hashCode) + (takenAfter == null ? 0 : takenAfter!.hashCode) + (takenBefore == null ? 0 : takenBefore!.hashCode) + (trashedAfter == null ? 0 : trashedAfter!.hashCode) + @@ -313,7 +318,7 @@ class SmartSearchDto { (withExif == null ? 0 : withExif!.hashCode); @override - String toString() => 'SmartSearchDto[city=$city, country=$country, createdAfter=$createdAfter, createdBefore=$createdBefore, deviceId=$deviceId, isArchived=$isArchived, isEncoded=$isEncoded, isFavorite=$isFavorite, isMotion=$isMotion, isNotInAlbum=$isNotInAlbum, isOffline=$isOffline, isVisible=$isVisible, lensModel=$lensModel, libraryId=$libraryId, make=$make, model=$model, page=$page, personIds=$personIds, query=$query, size=$size, state=$state, takenAfter=$takenAfter, takenBefore=$takenBefore, trashedAfter=$trashedAfter, trashedBefore=$trashedBefore, type=$type, updatedAfter=$updatedAfter, updatedBefore=$updatedBefore, withArchived=$withArchived, withDeleted=$withDeleted, withExif=$withExif]'; + String toString() => 'SmartSearchDto[city=$city, country=$country, createdAfter=$createdAfter, createdBefore=$createdBefore, deviceId=$deviceId, isArchived=$isArchived, isEncoded=$isEncoded, isFavorite=$isFavorite, isMotion=$isMotion, isNotInAlbum=$isNotInAlbum, isOffline=$isOffline, isVisible=$isVisible, lensModel=$lensModel, libraryId=$libraryId, make=$make, model=$model, page=$page, personIds=$personIds, query=$query, size=$size, state=$state, tagIds=$tagIds, takenAfter=$takenAfter, takenBefore=$takenBefore, trashedAfter=$trashedAfter, trashedBefore=$trashedBefore, type=$type, updatedAfter=$updatedAfter, updatedBefore=$updatedBefore, withArchived=$withArchived, withDeleted=$withDeleted, withExif=$withExif]'; Map toJson() { final json = {}; @@ -414,6 +419,7 @@ class SmartSearchDto { } else { // json[r'state'] = null; } + json[r'tagIds'] = this.tagIds; if (this.takenAfter != null) { json[r'takenAfter'] = this.takenAfter!.toUtc().toIso8601String(); } else { @@ -495,6 +501,9 @@ class SmartSearchDto { query: mapValueOfType(json, r'query')!, size: num.parse('${json[r'size']}'), state: mapValueOfType(json, r'state'), + tagIds: json[r'tagIds'] is Iterable + ? (json[r'tagIds'] as Iterable).cast().toList(growable: false) + : const [], takenAfter: mapDateTime(json, r'takenAfter', r''), takenBefore: mapDateTime(json, r'takenBefore', r''), trashedAfter: mapDateTime(json, r'trashedAfter', r''), diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index 0bb00103ba..090a3267d4 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -10036,6 +10036,13 @@ "nullable": true, "type": "string" }, + "tagIds": { + "items": { + "format": "uuid", + "type": "string" + }, + "type": "array" + }, "takenAfter": { "format": "date-time", "type": "string" @@ -10649,6 +10656,13 @@ "nullable": true, "type": "string" }, + "tagIds": { + "items": { + "format": "uuid", + "type": "string" + }, + "type": "array" + }, "takenAfter": { "format": "date-time", "type": "string" @@ -11564,6 +11578,13 @@ "nullable": true, "type": "string" }, + "tagIds": { + "items": { + "format": "uuid", + "type": "string" + }, + "type": "array" + }, "takenAfter": { "format": "date-time", "type": "string" diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index 0c6ed43249..bbd41c3ecb 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -792,6 +792,7 @@ export type MetadataSearchDto = { previewPath?: string; size?: number; state?: string | null; + tagIds?: string[]; takenAfter?: string; takenBefore?: string; thumbnailPath?: string; @@ -858,6 +859,7 @@ export type RandomSearchDto = { personIds?: string[]; size?: number; state?: string | null; + tagIds?: string[]; takenAfter?: string; takenBefore?: string; trashedAfter?: string; @@ -893,6 +895,7 @@ export type SmartSearchDto = { query: string; size?: number; state?: string | null; + tagIds?: string[]; takenAfter?: string; takenBefore?: string; trashedAfter?: string; diff --git a/server/src/dtos/search.dto.ts b/server/src/dtos/search.dto.ts index f3f45af44d..9dabfff25f 100644 --- a/server/src/dtos/search.dto.ts +++ b/server/src/dtos/search.dto.ts @@ -111,6 +111,9 @@ class BaseSearchDto { @ValidateUUID({ each: true, optional: true }) personIds?: string[]; + + @ValidateUUID({ each: true, optional: true }) + tagIds?: string[]; } export class RandomSearchDto extends BaseSearchDto { diff --git a/server/src/entities/asset.entity.ts b/server/src/entities/asset.entity.ts index 879c2c5169..605fbb0456 100644 --- a/server/src/entities/asset.entity.ts +++ b/server/src/entities/asset.entity.ts @@ -252,6 +252,21 @@ export function hasPeople(qb: SelectQueryBuilder, personIds: ); } +export function hasTags(qb: SelectQueryBuilder, tagIds: string[]) { + return qb.innerJoin( + (eb) => + eb + .selectFrom('tag_asset') + .select('assetsId') + .innerJoin('tags_closure', 'tag_asset.tagsId', 'tags_closure.id_descendant') + .where('tags_closure.id_ancestor', '=', anyUuid(tagIds)) + .groupBy('assetsId') + .having((eb) => eb.fn.count('tags_closure.id_ancestor').distinct(), '>=', tagIds.length) + .as('has_tags'), + (join) => join.onRef('has_tags.assetsId', '=', 'assets.id'), + ); +} + export function withOwner(eb: ExpressionBuilder) { return jsonObjectFrom(eb.selectFrom('users').selectAll().whereRef('users.id', '=', 'assets.ownerId')).as('owner'); } @@ -326,6 +341,7 @@ export function searchAssetBuilder(kysely: Kysely, options: AssetSearchBuild .withPlugin(joinDeduplicationPlugin) .selectFrom('assets') .selectAll('assets') + .$if(!!options.tagIds && options.tagIds.length > 0, (qb) => hasTags(qb, options.tagIds!)) .$if(!!options.personIds && options.personIds.length > 0, (qb) => hasPeople(qb, options.personIds!)) .$if(!!options.createdBefore, (qb) => qb.where('assets.createdAt', '<=', options.createdBefore!)) .$if(!!options.createdAfter, (qb) => qb.where('assets.createdAt', '>=', options.createdAfter!)) diff --git a/server/src/interfaces/search.interface.ts b/server/src/interfaces/search.interface.ts index bb76ff7b1f..e6f9acbd21 100644 --- a/server/src/interfaces/search.interface.ts +++ b/server/src/interfaces/search.interface.ts @@ -112,6 +112,10 @@ export interface SearchPeopleOptions { personIds?: string[]; } +export interface SearchTagOptions { + tagIds?: string[]; +} + export interface SearchOrderOptions { orderDirection?: 'asc' | 'desc'; } @@ -128,7 +132,8 @@ type BaseAssetSearchOptions = SearchDateOptions & SearchPathOptions & SearchStatusOptions & SearchUserIdOptions & - SearchPeopleOptions; + SearchPeopleOptions & + SearchTagOptions; export type AssetSearchOptions = BaseAssetSearchOptions & SearchRelationOptions; @@ -142,7 +147,8 @@ export type SmartSearchOptions = SearchDateOptions & SearchOneToOneRelationOptions & SearchStatusOptions & SearchUserIdOptions & - SearchPeopleOptions; + SearchPeopleOptions & + SearchTagOptions; export interface FaceEmbeddingSearch extends SearchEmbeddingOptions { hasPerson?: boolean; diff --git a/web/src/lib/components/shared-components/search-bar/search-filter-modal.svelte b/web/src/lib/components/shared-components/search-bar/search-filter-modal.svelte index c367d001f2..7653ad3413 100644 --- a/web/src/lib/components/shared-components/search-bar/search-filter-modal.svelte +++ b/web/src/lib/components/shared-components/search-bar/search-filter-modal.svelte @@ -8,6 +8,7 @@ query: string; queryType: 'smart' | 'metadata'; personIds: SvelteSet; + tagIds: SvelteSet; location: SearchLocationFilter; camera: SearchCameraFilter; date: SearchDateFilter; @@ -20,6 +21,7 @@ import { Button } from '@immich/ui'; import { AssetTypeEnum, type SmartSearchDto, type MetadataSearchDto } from '@immich/sdk'; import SearchPeopleSection from './search-people-section.svelte'; + import SearchTagsSection from './search-tags-section.svelte'; import SearchLocationSection from './search-location-section.svelte'; import SearchCameraSection, { type SearchCameraFilter } from './search-camera-section.svelte'; import SearchDateSection from './search-date-section.svelte'; @@ -54,6 +56,7 @@ query: 'query' in searchQuery ? searchQuery.query : searchQuery.originalFileName || '', queryType: 'query' in searchQuery ? 'smart' : 'metadata', personIds: new SvelteSet('personIds' in searchQuery ? searchQuery.personIds : []), + tagIds: new SvelteSet('tagIds' in searchQuery ? searchQuery.tagIds : []), location: { country: withNullAsUndefined(searchQuery.country), state: withNullAsUndefined(searchQuery.state), @@ -85,6 +88,7 @@ query: '', queryType: 'smart', personIds: new SvelteSet(), + tagIds: new SvelteSet(), location: {}, camera: {}, date: {}, @@ -117,6 +121,7 @@ isFavorite: filter.display.isFavorite || undefined, isNotInAlbum: filter.display.isNotInAlbum || undefined, personIds: filter.personIds.size > 0 ? [...filter.personIds] : undefined, + tagIds: filter.tagIds.size > 0 ? [...filter.tagIds] : undefined, type, }; @@ -143,6 +148,9 @@ + + + diff --git a/web/src/lib/components/shared-components/search-bar/search-tags-section.svelte b/web/src/lib/components/shared-components/search-bar/search-tags-section.svelte new file mode 100644 index 0000000000..6071da1460 --- /dev/null +++ b/web/src/lib/components/shared-components/search-bar/search-tags-section.svelte @@ -0,0 +1,80 @@ + + +{#if $preferences?.tags?.enabled} +
    +
    +
    + ({ id: tag.id, label: tag.value, value: tag.id }))} + bind:selectedOption + placeholder={$t('search_tags')} + /> +
    +
    + +
    + {#each selectedTags as tagId (tagId)} + {@const tag = tagMap[tagId]} + {#if tag} +
    + +

    + {tag.value} +

    +
    + + +
    + {/if} + {/each} +
    +
    +{/if} 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 97d0cacdce..c416226c41 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 @@ -29,6 +29,7 @@ type SmartSearchDto, type MetadataSearchDto, type AlbumResponseDto, + getTagById, } from '@immich/sdk'; import { mdiArrowLeft, mdiDotsVertical, mdiImageOffOutline, mdiPlus, mdiSelectAll } from '@mdi/js'; import type { Viewport } from '$lib/stores/assets.store'; @@ -194,6 +195,7 @@ model: $t('camera_model'), lensModel: $t('lens_model'), personIds: $t('people'), + tagIds: $t('tags'), originalFileName: $t('file_name'), }; return keyMap[key] || key; @@ -215,6 +217,18 @@ return personNames.join(', '); } + async function getTagNames(tagIds: string[]) { + const tagNames = await Promise.all( + tagIds.map(async (tagId) => { + const tag = await getTagById({ id: tagId }); + + return tag.value; + }), + ); + + return tagNames.join(', '); + } + const triggerAssetUpdate = () => (searchResultAssets = searchResultAssets); const onAddToAlbum = (assetIds: string[]) => { @@ -299,6 +313,10 @@ {#await getPersonName(value) then personName} {personName} {/await} + {:else if key === 'tagIds' && Array.isArray(value)} + {#await getTagNames(value) then tagNames} + {tagNames} + {/await} {:else if value === null || value === ''} {$t('unknown')} {:else} From 2b41b5efe159921bef8ebb4928eed592c5f93923 Mon Sep 17 00:00:00 2001 From: Zack Pollard Date: Sat, 1 Feb 2025 23:26:23 +0000 Subject: [PATCH 064/395] feat: merch links (#15843) --- docs/docusaurus.config.js | 4 ++-- docs/src/pages/index.tsx | 7 +++++++ 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/docs/docusaurus.config.js b/docs/docusaurus.config.js index 16d654b46b..7166611a2e 100644 --- a/docs/docusaurus.config.js +++ b/docs/docusaurus.config.js @@ -110,9 +110,9 @@ const config = { label: 'API', }, { - to: '/blog', + href: 'https://immich.store', position: 'right', - label: 'Blog', + label: 'Merch', }, { href: 'https://github.com/immich-app/immich', diff --git a/docs/src/pages/index.tsx b/docs/src/pages/index.tsx index b3cf10b810..8ea8e1220d 100644 --- a/docs/src/pages/index.tsx +++ b/docs/src/pages/index.tsx @@ -50,6 +50,13 @@ function HomepageHeader() { > Demo + + + Buy Merch +
From 3f18acdb1ad90a887cd6c4a518ec7eb0a65276c1 Mon Sep 17 00:00:00 2001 From: Nicholas Flamy <30300649+NicholasFlamy@users.noreply.github.com> Date: Sun, 2 Feb 2025 13:07:39 -0500 Subject: [PATCH 065/395] docs: TrueNAS: add danger message to external libraries (#15857) Add danger message to external libraries in truenas.md (Format fix included) --- docs/docs/install/truenas.md | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/docs/docs/install/truenas.md b/docs/docs/install/truenas.md index c56eaf21b2..049af1250e 100644 --- a/docs/docs/install/truenas.md +++ b/docs/docs/install/truenas.md @@ -41,7 +41,7 @@ className="border rounded-xl" :::info Permissions The **pgData** dataset must be owned by the user `netdata` (UID 999) for postgres to start. The other datasets must be owned by the user `root` (UID 0) or a group that includes the user `root` (UID 0) for immich to have the necessary permissions. -If the **library** dataset uses ACL it must have [ACL mode](https://www.truenas.com/docs/core/coretutorials/storage/pools/permissions/#access-control-lists) set to `Passthrough` if you plan on using a [storage template](/docs/administration/storage-template.mdx) and the dataset is configured for network sharing (its ACL type is set to `SMB/NFSv4`). When the template is applied and files need to be moved from **upload** to **library**, immich performs `chmod` internally and needs to be allowed to execute the command. [More info.](https://github.com/immich-app/immich/pull/13017) +If the **library** dataset uses ACL it must have [ACL mode](https://www.truenas.com/docs/core/coretutorials/storage/pools/permissions/#access-control-lists) set to `Passthrough` if you plan on using a [storage template](/docs/administration/storage-template.mdx) and the dataset is configured for network sharing (its ACL type is set to `SMB/NFSv4`). When the template is applied and files need to be moved from **upload** to **library**, Immich performs `chmod` internally and needs to be allowed to execute the command. [More info.](https://github.com/immich-app/immich/pull/13017) ::: ## Installing the Immich Application @@ -160,6 +160,10 @@ The image above has example values. ### Additional Storage [(External Libraries)](/docs/features/libraries) +:::danger Advanced Users Only +This feature should only be used by advanced users. If this is your first time installing Immich, then DO NOT mount an external library until you have a working setup. Also, your mount path MUST be something unique and should NOT be your library or upload location or a Linux directory like `/lib`. The picture below shows a valid example. +::: + You may configure [External Libraries](/docs/features/libraries) by mounting them using **Additional Storage**. -The **Mount Path** is the loaction you will need to copy and paste into the External Library settings within Immich. +The **Mount Path** is the location you will need to copy and paste into the External Library settings within Immich. The **Host Path** is the location on the TrueNAS SCALE server where your external library is located. From a808a840c824f53363b7b8f9124e427d5e1fb5c4 Mon Sep 17 00:00:00 2001 From: Matthew Momjian <50788000+mmomjian@users.noreply.github.com> Date: Sun, 2 Feb 2025 15:43:14 -0500 Subject: [PATCH 066/395] fix(mobile): title of custom proxy headers (#15859) fix title --- mobile/assets/i18n/en-US.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/mobile/assets/i18n/en-US.json b/mobile/assets/i18n/en-US.json index 61c4621346..410c88e57b 100644 --- a/mobile/assets/i18n/en-US.json +++ b/mobile/assets/i18n/en-US.json @@ -284,9 +284,9 @@ "header_settings_field_validator_msg": "Value cannot be empty", "header_settings_header_name_input": "Header name", "header_settings_header_value_input": "Header value", - "header_settings_page_title": "Proxy Headers (EXPERIMENTAL)", + "header_settings_page_title": "Proxy Headers", "headers_settings_tile_subtitle": "Define proxy headers the app should send with each network request", - "headers_settings_tile_title": "Custom proxy headers", + "headers_settings_tile_title": "Custom proxy headers (EXPERIMENTAL)", "home_page_add_to_album_conflicts": "Added {added} assets to album {album}. {failed} assets are already in the album.", "home_page_add_to_album_err_local": "Can not add local assets to albums yet, skipping", "home_page_add_to_album_success": "Added {added} assets to album {album}.", From 4efacfbb91ac429fcf598b321256fc398fc05b77 Mon Sep 17 00:00:00 2001 From: Alex Date: Sun, 2 Feb 2025 15:18:13 -0600 Subject: [PATCH 067/395] feat: search by description (#15818) * feat: search by description * wip: mobile * wip: mobile ui * wip: mobile search logic * feat: using f_unaccent * icon to fit with text search --- i18n/en.json | 4 +- mobile/assets/i18n/en-US.json | 4 + mobile/lib/constants/enums.dart | 6 + .../models/search/search_filter.model.dart | 9 +- mobile/lib/pages/search/search.page.dart | 159 +++++++++++++++--- mobile/lib/services/search.service.dart | 4 + .../lib/widgets/forms/login/login_form.dart | 2 +- .../lib/model/metadata_search_dto.dart | 19 ++- open-api/immich-openapi-specs.json | 3 + open-api/typescript-sdk/src/fetch-client.ts | 1 + server/src/dtos/search.dto.ts | 5 + server/src/entities/asset.entity.ts | 5 + server/src/interfaces/search.interface.ts | 1 + .../search-bar/search-filter-modal.svelte | 3 +- .../search-bar/search-text-section.svelte | 22 ++- .../[[assetId=id]]/+page.svelte | 1 + 16 files changed, 217 insertions(+), 31 deletions(-) diff --git a/i18n/en.json b/i18n/en.json index ad48a96991..217c62b76f 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -1,4 +1,6 @@ { + "search_by_description_example": "Hiking day in Sapa", + "search_by_description": "Search by description", "about": "About", "account": "Account", "account_settings": "Account Settings", @@ -1350,4 +1352,4 @@ "yes": "Yes", "you_dont_have_any_shared_links": "You don't have any shared links", "zoom_image": "Zoom Image" -} \ No newline at end of file +} diff --git a/mobile/assets/i18n/en-US.json b/mobile/assets/i18n/en-US.json index 410c88e57b..32061d720f 100644 --- a/mobile/assets/i18n/en-US.json +++ b/mobile/assets/i18n/en-US.json @@ -1,5 +1,9 @@ { + "search_filter_contextual": "Search by context", + "search_filter_filename": "Search by file name", + "search_filter_description": "Search by description", "search_no_result": "No results found, try a different search term or combination", + "description_search": "Hiking day in Sapa", "search_no_more_result": "No more results", "action_common_back": "Back", "action_common_cancel": "Cancel", diff --git a/mobile/lib/constants/enums.dart b/mobile/lib/constants/enums.dart index a9b5107426..3a3bf9959a 100644 --- a/mobile/lib/constants/enums.dart +++ b/mobile/lib/constants/enums.dart @@ -2,3 +2,9 @@ enum SortOrder { asc, desc, } + +enum TextSearchType { + context, + filename, + description, +} diff --git a/mobile/lib/models/search/search_filter.model.dart b/mobile/lib/models/search/search_filter.model.dart index 0df64b6924..87e7b24e34 100644 --- a/mobile/lib/models/search/search_filter.model.dart +++ b/mobile/lib/models/search/search_filter.model.dart @@ -235,6 +235,7 @@ class SearchDisplayFilters { class SearchFilter { String? context; String? filename; + String? description; Set people; SearchLocationFilter location; SearchCameraFilter camera; @@ -247,6 +248,7 @@ class SearchFilter { SearchFilter({ this.context, this.filename, + this.description, required this.people, required this.location, required this.camera, @@ -258,6 +260,7 @@ class SearchFilter { bool get isEmpty { return (context == null || (context != null && context!.isEmpty)) && (filename == null || (filename!.isEmpty)) && + (description == null || (description!.isEmpty)) && people.isEmpty && location.country == null && location.state == null && @@ -275,6 +278,7 @@ class SearchFilter { SearchFilter copyWith({ String? context, String? filename, + String? description, Set? people, SearchLocationFilter? location, SearchCameraFilter? camera, @@ -285,6 +289,7 @@ class SearchFilter { return SearchFilter( context: context ?? this.context, filename: filename ?? this.filename, + description: description ?? this.description, people: people ?? this.people, location: location ?? this.location, camera: camera ?? this.camera, @@ -296,7 +301,7 @@ class SearchFilter { @override String toString() { - return 'SearchFilter(context: $context, filename: $filename, people: $people, location: $location, camera: $camera, date: $date, display: $display, mediaType: $mediaType)'; + return 'SearchFilter(context: $context, filename: $filename, description: $description, people: $people, location: $location, camera: $camera, date: $date, display: $display, mediaType: $mediaType)'; } @override @@ -305,6 +310,7 @@ class SearchFilter { return other.context == context && other.filename == filename && + other.description == description && other.people == people && other.location == location && other.camera == camera && @@ -317,6 +323,7 @@ class SearchFilter { int get hashCode { return context.hashCode ^ filename.hashCode ^ + description.hashCode ^ people.hashCode ^ location.hashCode ^ camera.hashCode ^ diff --git a/mobile/lib/pages/search/search.page.dart b/mobile/lib/pages/search/search.page.dart index 385da9dbcb..fcae1fb586 100644 --- a/mobile/lib/pages/search/search.page.dart +++ b/mobile/lib/pages/search/search.page.dart @@ -5,6 +5,7 @@ import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/constants/enums.dart'; import 'package:immich_mobile/entities/asset.entity.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/theme_extensions.dart'; @@ -31,7 +32,8 @@ class SearchPage extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final isContextualSearch = useState(true); + final textSearchType = useState(TextSearchType.context); + final searchHintText = useState('contextual_search'.tr()); final textSearchController = useTextEditingController(); final filter = useState( SearchFilter( @@ -478,37 +480,148 @@ class SearchPage extends HookConsumerWidget { } handleTextSubmitted(String value) { - if (isContextualSearch.value) { - filter.value = filter.value.copyWith( - filename: '', - context: value, - ); - } else { - filter.value = filter.value.copyWith( - filename: value, - context: '', - ); + switch (textSearchType.value) { + case TextSearchType.context: + filter.value = filter.value.copyWith( + filename: '', + context: value, + description: '', + ); + + break; + case TextSearchType.filename: + filter.value = filter.value.copyWith( + filename: value, + context: '', + description: '', + ); + + break; + case TextSearchType.description: + filter.value = filter.value.copyWith( + filename: '', + context: '', + description: value, + ); + break; } search(); } + IconData getSearchPrefixIcon() { + switch (textSearchType.value) { + case TextSearchType.context: + return Icons.image_search_rounded; + case TextSearchType.filename: + return Icons.abc_rounded; + case TextSearchType.description: + return Icons.text_snippet_outlined; + default: + return Icons.search_rounded; + } + } + return Scaffold( resizeToAvoidBottomInset: true, appBar: AppBar( automaticallyImplyLeading: true, actions: [ Padding( - padding: const EdgeInsets.only(right: 14.0), - child: IconButton( - key: const Key('contextual_search_button'), - icon: isContextualSearch.value - ? const Icon(Icons.abc_rounded) - : const Icon(Icons.image_search_rounded), - onPressed: () { - isContextualSearch.value = !isContextualSearch.value; - textSearchController.clear(); + padding: const EdgeInsets.only(right: 16.0), + child: MenuAnchor( + style: MenuStyle( + elevation: const WidgetStatePropertyAll(1), + shape: WidgetStateProperty.all( + RoundedRectangleBorder( + borderRadius: BorderRadius.circular(24), + ), + ), + padding: const WidgetStatePropertyAll( + EdgeInsets.all(4), + ), + ), + builder: ( + BuildContext context, + MenuController controller, + Widget? child, + ) { + return IconButton( + onPressed: () { + if (controller.isOpen) { + controller.close(); + } else { + controller.open(); + } + }, + icon: const Icon(Icons.more_vert_rounded), + tooltip: 'Show text search menu', + ); }, + menuChildren: [ + MenuItemButton( + child: ListTile( + leading: const Icon(Icons.image_search_rounded), + title: Text( + 'search_filter_contextual'.tr(), + style: context.textTheme.bodyLarge?.copyWith( + fontWeight: FontWeight.w500, + color: textSearchType.value == TextSearchType.context + ? context.colorScheme.primary + : null, + ), + ), + selectedColor: context.colorScheme.primary, + selected: textSearchType.value == TextSearchType.context, + ), + onPressed: () { + textSearchType.value = TextSearchType.context; + searchHintText.value = 'contextual_search'.tr(); + }, + ), + MenuItemButton( + child: ListTile( + leading: const Icon(Icons.abc_rounded), + title: Text( + 'search_filter_filename'.tr(), + style: context.textTheme.bodyLarge?.copyWith( + fontWeight: FontWeight.w500, + color: textSearchType.value == TextSearchType.filename + ? context.colorScheme.primary + : null, + ), + ), + selectedColor: context.colorScheme.primary, + selected: textSearchType.value == TextSearchType.filename, + ), + onPressed: () { + textSearchType.value = TextSearchType.filename; + searchHintText.value = 'filename_search'.tr(); + }, + ), + MenuItemButton( + child: ListTile( + leading: const Icon(Icons.text_snippet_outlined), + title: Text( + 'search_filter_description'.tr(), + style: context.textTheme.bodyLarge?.copyWith( + fontWeight: FontWeight.w500, + color: + textSearchType.value == TextSearchType.description + ? context.colorScheme.primary + : null, + ), + ), + selectedColor: context.colorScheme.primary, + selected: + textSearchType.value == TextSearchType.description, + ), + onPressed: () { + textSearchType.value = TextSearchType.description; + searchHintText.value = 'description_search'.tr(); + }, + ), + ], ), ), ], @@ -539,12 +652,10 @@ class SearchPage extends HookConsumerWidget { prefixIcon: prefilter != null ? null : Icon( - Icons.search_rounded, + getSearchPrefixIcon(), color: context.colorScheme.primary, ), - hintText: isContextualSearch.value - ? 'contextual_search'.tr() - : 'filename_search'.tr(), + hintText: searchHintText.value, hintStyle: context.textTheme.bodyLarge?.copyWith( color: context.themeData.colorScheme.onSurfaceSecondary, ), diff --git a/mobile/lib/services/search.service.dart b/mobile/lib/services/search.service.dart index fe8f7393c2..4c6c80abf3 100644 --- a/mobile/lib/services/search.service.dart +++ b/mobile/lib/services/search.service.dart @@ -84,6 +84,10 @@ class SearchService { ? filter.filename : null, country: filter.location.country, + description: + filter.description != null && filter.description!.isNotEmpty + ? filter.description + : null, state: filter.location.state, city: filter.location.city, make: filter.camera.make, diff --git a/mobile/lib/widgets/forms/login/login_form.dart b/mobile/lib/widgets/forms/login/login_form.dart index 30b6a74bb1..86ef9a8bb0 100644 --- a/mobile/lib/widgets/forms/login/login_form.dart +++ b/mobile/lib/widgets/forms/login/login_form.dart @@ -168,7 +168,7 @@ class LoginForm extends HookConsumerWidget { populateTestLoginInfo1() { emailController.text = 'testuser@email.com'; passwordController.text = 'password'; - serverEndpointController.text = 'http://10.1.15.216:3000/api'; + serverEndpointController.text = 'http://10.1.15.216:2283/api'; } login() async { diff --git a/mobile/openapi/lib/model/metadata_search_dto.dart b/mobile/openapi/lib/model/metadata_search_dto.dart index 5f9e3f8e15..3a3c141442 100644 --- a/mobile/openapi/lib/model/metadata_search_dto.dart +++ b/mobile/openapi/lib/model/metadata_search_dto.dart @@ -18,6 +18,7 @@ class MetadataSearchDto { this.country, this.createdAfter, this.createdBefore, + this.description, this.deviceAssetId, this.deviceId, this.encodedVideoPath, @@ -85,6 +86,14 @@ class MetadataSearchDto { /// DateTime? createdBefore; + /// + /// 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? description; + /// /// 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 @@ -343,6 +352,7 @@ class MetadataSearchDto { other.country == country && other.createdAfter == createdAfter && other.createdBefore == createdBefore && + other.description == description && other.deviceAssetId == deviceAssetId && other.deviceId == deviceId && other.encodedVideoPath == encodedVideoPath && @@ -389,6 +399,7 @@ class MetadataSearchDto { (country == null ? 0 : country!.hashCode) + (createdAfter == null ? 0 : createdAfter!.hashCode) + (createdBefore == null ? 0 : createdBefore!.hashCode) + + (description == null ? 0 : description!.hashCode) + (deviceAssetId == null ? 0 : deviceAssetId!.hashCode) + (deviceId == null ? 0 : deviceId!.hashCode) + (encodedVideoPath == null ? 0 : encodedVideoPath!.hashCode) + @@ -428,7 +439,7 @@ class MetadataSearchDto { (withStacked == null ? 0 : withStacked!.hashCode); @override - String toString() => 'MetadataSearchDto[checksum=$checksum, city=$city, country=$country, createdAfter=$createdAfter, createdBefore=$createdBefore, deviceAssetId=$deviceAssetId, deviceId=$deviceId, encodedVideoPath=$encodedVideoPath, id=$id, isArchived=$isArchived, isEncoded=$isEncoded, isFavorite=$isFavorite, isMotion=$isMotion, isNotInAlbum=$isNotInAlbum, isOffline=$isOffline, isVisible=$isVisible, lensModel=$lensModel, libraryId=$libraryId, make=$make, model=$model, order=$order, originalFileName=$originalFileName, originalPath=$originalPath, page=$page, personIds=$personIds, previewPath=$previewPath, size=$size, state=$state, tagIds=$tagIds, takenAfter=$takenAfter, takenBefore=$takenBefore, thumbnailPath=$thumbnailPath, trashedAfter=$trashedAfter, trashedBefore=$trashedBefore, type=$type, updatedAfter=$updatedAfter, updatedBefore=$updatedBefore, withArchived=$withArchived, withDeleted=$withDeleted, withExif=$withExif, withPeople=$withPeople, withStacked=$withStacked]'; + String toString() => 'MetadataSearchDto[checksum=$checksum, city=$city, country=$country, createdAfter=$createdAfter, createdBefore=$createdBefore, description=$description, deviceAssetId=$deviceAssetId, deviceId=$deviceId, encodedVideoPath=$encodedVideoPath, id=$id, isArchived=$isArchived, isEncoded=$isEncoded, isFavorite=$isFavorite, isMotion=$isMotion, isNotInAlbum=$isNotInAlbum, isOffline=$isOffline, isVisible=$isVisible, lensModel=$lensModel, libraryId=$libraryId, make=$make, model=$model, order=$order, originalFileName=$originalFileName, originalPath=$originalPath, page=$page, personIds=$personIds, previewPath=$previewPath, size=$size, state=$state, tagIds=$tagIds, takenAfter=$takenAfter, takenBefore=$takenBefore, thumbnailPath=$thumbnailPath, trashedAfter=$trashedAfter, trashedBefore=$trashedBefore, type=$type, updatedAfter=$updatedAfter, updatedBefore=$updatedBefore, withArchived=$withArchived, withDeleted=$withDeleted, withExif=$withExif, withPeople=$withPeople, withStacked=$withStacked]'; Map toJson() { final json = {}; @@ -457,6 +468,11 @@ class MetadataSearchDto { } else { // json[r'createdBefore'] = null; } + if (this.description != null) { + json[r'description'] = this.description; + } else { + // json[r'description'] = null; + } if (this.deviceAssetId != null) { json[r'deviceAssetId'] = this.deviceAssetId; } else { @@ -643,6 +659,7 @@ class MetadataSearchDto { country: mapValueOfType(json, r'country'), createdAfter: mapDateTime(json, r'createdAfter', r''), createdBefore: mapDateTime(json, r'createdBefore', r''), + description: mapValueOfType(json, r'description'), deviceAssetId: mapValueOfType(json, r'deviceAssetId'), deviceId: mapValueOfType(json, r'deviceId'), encodedVideoPath: mapValueOfType(json, r'encodedVideoPath'), diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index 090a3267d4..85dc55aed8 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -9949,6 +9949,9 @@ "format": "date-time", "type": "string" }, + "description": { + "type": "string" + }, "deviceAssetId": { "type": "string" }, diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index bbd41c3ecb..32cca2dbce 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -769,6 +769,7 @@ export type MetadataSearchDto = { country?: string | null; createdAfter?: string; createdBefore?: string; + description?: string; deviceAssetId?: string; deviceId?: string; encodedVideoPath?: string; diff --git a/server/src/dtos/search.dto.ts b/server/src/dtos/search.dto.ts index 9dabfff25f..6cf34debef 100644 --- a/server/src/dtos/search.dto.ts +++ b/server/src/dtos/search.dto.ts @@ -133,6 +133,11 @@ export class MetadataSearchDto extends RandomSearchDto { @Optional() deviceAssetId?: string; + @IsString() + @IsNotEmpty() + @Optional() + description?: string; + @IsString() @IsNotEmpty() @Optional() diff --git a/server/src/entities/asset.entity.ts b/server/src/entities/asset.entity.ts index 605fbb0456..594dd17785 100644 --- a/server/src/entities/asset.entity.ts +++ b/server/src/entities/asset.entity.ts @@ -396,6 +396,11 @@ export function searchAssetBuilder(kysely: Kysely, options: AssetSearchBuild sql`'%' || f_unaccent(${options.originalFileName}) || '%'`, ), ) + .$if(!!options.description, (qb) => + qb + .innerJoin('exif', 'assets.id', 'exif.assetId') + .where(sql`f_unaccent(exif.description)`, 'ilike', sql`'%' || f_unaccent(${options.description}) || '%'`), + ) .$if(!!options.type, (qb) => qb.where('assets.type', '=', options.type!)) .$if(options.isFavorite !== undefined, (qb) => qb.where('assets.isFavorite', '=', options.isFavorite!)) .$if(options.isOffline !== undefined, (qb) => qb.where('assets.isOffline', '=', options.isOffline!)) diff --git a/server/src/interfaces/search.interface.ts b/server/src/interfaces/search.interface.ts index e6f9acbd21..b9ae7b7194 100644 --- a/server/src/interfaces/search.interface.ts +++ b/server/src/interfaces/search.interface.ts @@ -101,6 +101,7 @@ export interface SearchExifOptions { make?: string | null; model?: string | null; state?: string | null; + description?: string | null; } export interface SearchEmbeddingOptions { diff --git a/web/src/lib/components/shared-components/search-bar/search-filter-modal.svelte b/web/src/lib/components/shared-components/search-bar/search-filter-modal.svelte index 7653ad3413..8170010332 100644 --- a/web/src/lib/components/shared-components/search-bar/search-filter-modal.svelte +++ b/web/src/lib/components/shared-components/search-bar/search-filter-modal.svelte @@ -6,7 +6,7 @@ export type SearchFilter = { query: string; - queryType: 'smart' | 'metadata'; + queryType: 'smart' | 'metadata' | 'description'; personIds: SvelteSet; tagIds: SvelteSet; location: SearchLocationFilter; @@ -110,6 +110,7 @@ let payload: SmartSearchDto | MetadataSearchDto = { query: filter.queryType === 'smart' ? query : undefined, originalFileName: filter.queryType === 'metadata' ? query : undefined, + description: filter.queryType === 'description' ? query : undefined, country: filter.location.country, state: filter.location.state, city: filter.location.city, diff --git a/web/src/lib/components/shared-components/search-bar/search-text-section.svelte b/web/src/lib/components/shared-components/search-bar/search-text-section.svelte index 2f118e6567..085e43b065 100644 --- a/web/src/lib/components/shared-components/search-bar/search-text-section.svelte +++ b/web/src/lib/components/shared-components/search-bar/search-text-section.svelte @@ -4,7 +4,7 @@ interface Props { query: string | undefined; - queryType?: 'smart' | 'metadata'; + queryType?: 'smart' | 'metadata' | 'description'; } let { query = $bindable(), queryType = $bindable('smart') }: Props = $props(); @@ -21,6 +21,13 @@ bind:group={queryType} value="metadata" /> +
@@ -34,7 +41,7 @@ placeholder={$t('sunrise_on_the_beach')} bind:value={query} /> -{:else} +{:else if queryType === 'metadata'} +{:else if queryType === 'description'} + + {/if} 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 c416226c41..5bb1ecce03 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 @@ -197,6 +197,7 @@ personIds: $t('people'), tagIds: $t('tags'), originalFileName: $t('file_name'), + description: $t('description'), }; return keyMap[key] || key; } From 96a6cc20b7f197ef94fc8fd027c6bc9db718c9d2 Mon Sep 17 00:00:00 2001 From: Damiano Ferrari <34270884+ferraridamiano@users.noreply.github.com> Date: Sun, 2 Feb 2025 22:46:46 +0100 Subject: [PATCH 068/395] refactor(mobile): Use `switch` expression when possible (#15852) refactor: Use `switch` expression when possible Co-authored-by: Alex --- mobile/lib/entities/asset.entity.dart | 20 +-- mobile/lib/entities/store.entity.dart | 27 ++-- mobile/lib/entities/user.entity.dart | 75 ++++------- mobile/lib/pages/common/app_log.page.dart | 37 ++---- mobile/lib/pages/common/download_panel.dart | 30 ++--- mobile/lib/pages/editing/crop.page.dart | 30 ++--- .../permission_onboarding.page.dart | 27 ++-- mobile/lib/repositories/album.repository.dart | 31 ++--- mobile/lib/repositories/asset.repository.dart | 125 ++++++++---------- .../lib/repositories/backup.repository.dart | 12 +- mobile/lib/repositories/user.repository.dart | 11 +- mobile/lib/services/backup.service.dart | 18 +-- mobile/lib/utils/storage_indicator.dart | 15 +-- mobile/lib/widgets/common/immich_toast.dart | 48 +++---- mobile/lib/widgets/photo_view/photo_view.dart | 24 ++-- .../src/utils/photo_view_utils.dart | 23 ++-- .../networking_settings.dart | 40 +++--- 17 files changed, 219 insertions(+), 374 deletions(-) diff --git a/mobile/lib/entities/asset.entity.dart b/mobile/lib/entities/asset.entity.dart index 4bec35970a..f6b10a3ab5 100644 --- a/mobile/lib/entities/asset.entity.dart +++ b/mobile/lib/entities/asset.entity.dart @@ -545,19 +545,13 @@ enum AssetType { } extension AssetTypeEnumHelper on AssetTypeEnum { - AssetType toAssetType() { - switch (this) { - case AssetTypeEnum.IMAGE: - return AssetType.image; - case AssetTypeEnum.VIDEO: - return AssetType.video; - case AssetTypeEnum.AUDIO: - return AssetType.audio; - case AssetTypeEnum.OTHER: - return AssetType.other; - } - throw Exception(); - } + AssetType toAssetType() => switch (this) { + AssetTypeEnum.IMAGE => AssetType.image, + AssetTypeEnum.VIDEO => AssetType.video, + AssetTypeEnum.AUDIO => AssetType.audio, + AssetTypeEnum.OTHER => AssetType.other, + _ => throw Exception(), + }; } /// Describes where the information of this asset came from: diff --git a/mobile/lib/entities/store.entity.dart b/mobile/lib/entities/store.entity.dart index a7f2db78d0..a6ebe77c4f 100644 --- a/mobile/lib/entities/store.entity.dart +++ b/mobile/lib/entities/store.entity.dart @@ -96,25 +96,16 @@ class StoreValue { int? intValue; String? strValue; - T? _extract(StoreKey key) { - switch (key.type) { - case const (int): - return intValue as T?; - case const (bool): - return intValue == null ? null : (intValue! == 1) as T; - case const (DateTime): - return intValue == null + T? _extract(StoreKey key) => switch (key.type) { + const (int) => intValue as T?, + const (bool) => intValue == null ? null : (intValue! == 1) as T, + const (DateTime) => intValue == null ? null - : DateTime.fromMicrosecondsSinceEpoch(intValue!) as T; - case const (String): - return strValue as T?; - default: - if (key.fromDb != null) { - return key.fromDb!.call(Store._db, intValue!); - } - } - throw TypeError(); - } + : DateTime.fromMicrosecondsSinceEpoch(intValue!) as T, + const (String) => strValue as T?, + _ when key.fromDb != null => key.fromDb!.call(Store._db, intValue!), + _ => throw TypeError(), + }; static Future _of(T? value, StoreKey key) async { int? i; diff --git a/mobile/lib/entities/user.entity.dart b/mobile/lib/entities/user.entity.dart index 55a19fe496..8fa6e83874 100644 --- a/mobile/lib/entities/user.entity.dart +++ b/mobile/lib/entities/user.entity.dart @@ -149,56 +149,33 @@ enum AvatarColorEnum { } extension AvatarColorEnumHelper on UserAvatarColor { - AvatarColorEnum toAvatarColor() { - switch (this) { - case UserAvatarColor.primary: - return AvatarColorEnum.primary; - case UserAvatarColor.pink: - return AvatarColorEnum.pink; - case UserAvatarColor.red: - return AvatarColorEnum.red; - case UserAvatarColor.yellow: - return AvatarColorEnum.yellow; - case UserAvatarColor.blue: - return AvatarColorEnum.blue; - case UserAvatarColor.green: - return AvatarColorEnum.green; - case UserAvatarColor.purple: - return AvatarColorEnum.purple; - case UserAvatarColor.orange: - return AvatarColorEnum.orange; - case UserAvatarColor.gray: - return AvatarColorEnum.gray; - case UserAvatarColor.amber: - return AvatarColorEnum.amber; - } - return AvatarColorEnum.primary; - } + AvatarColorEnum toAvatarColor() => switch (this) { + UserAvatarColor.primary => AvatarColorEnum.primary, + UserAvatarColor.pink => AvatarColorEnum.pink, + UserAvatarColor.red => AvatarColorEnum.red, + UserAvatarColor.yellow => AvatarColorEnum.yellow, + UserAvatarColor.blue => AvatarColorEnum.blue, + UserAvatarColor.green => AvatarColorEnum.green, + UserAvatarColor.purple => AvatarColorEnum.purple, + UserAvatarColor.orange => AvatarColorEnum.orange, + UserAvatarColor.gray => AvatarColorEnum.gray, + UserAvatarColor.amber => AvatarColorEnum.amber, + _ => AvatarColorEnum.primary, + }; } extension AvatarColorToColorHelper on AvatarColorEnum { - Color toColor([bool isDarkTheme = false]) { - switch (this) { - case AvatarColorEnum.primary: - return isDarkTheme ? const Color(0xFFABCBFA) : const Color(0xFF4250AF); - case AvatarColorEnum.pink: - return const Color.fromARGB(255, 244, 114, 182); - case AvatarColorEnum.red: - return const Color.fromARGB(255, 239, 68, 68); - case AvatarColorEnum.yellow: - return const Color.fromARGB(255, 234, 179, 8); - case AvatarColorEnum.blue: - return const Color.fromARGB(255, 59, 130, 246); - case AvatarColorEnum.green: - return const Color.fromARGB(255, 22, 163, 74); - case AvatarColorEnum.purple: - return const Color.fromARGB(255, 147, 51, 234); - case AvatarColorEnum.orange: - return const Color.fromARGB(255, 234, 88, 12); - case AvatarColorEnum.gray: - return const Color.fromARGB(255, 75, 85, 99); - case AvatarColorEnum.amber: - return const Color.fromARGB(255, 217, 119, 6); - } - } + Color toColor([bool isDarkTheme = false]) => switch (this) { + AvatarColorEnum.primary => + isDarkTheme ? const Color(0xFFABCBFA) : const Color(0xFF4250AF), + AvatarColorEnum.pink => const Color.fromARGB(255, 244, 114, 182), + AvatarColorEnum.red => const Color.fromARGB(255, 239, 68, 68), + AvatarColorEnum.yellow => const Color.fromARGB(255, 234, 179, 8), + AvatarColorEnum.blue => const Color.fromARGB(255, 59, 130, 246), + AvatarColorEnum.green => const Color.fromARGB(255, 22, 163, 74), + AvatarColorEnum.purple => const Color.fromARGB(255, 147, 51, 234), + AvatarColorEnum.orange => const Color.fromARGB(255, 234, 88, 12), + AvatarColorEnum.gray => const Color.fromARGB(255, 75, 85, 99), + AvatarColorEnum.amber => const Color.fromARGB(255, 217, 119, 6), + }; } diff --git a/mobile/lib/pages/common/app_log.page.dart b/mobile/lib/pages/common/app_log.page.dart index fd718ee37d..226d380a28 100644 --- a/mobile/lib/pages/common/app_log.page.dart +++ b/mobile/lib/pages/common/app_log.page.dart @@ -36,32 +36,19 @@ class AppLogPage extends HookConsumerWidget { ); } - Widget buildLeadingIcon(LogLevel level) { - switch (level) { - case LogLevel.INFO: - return colorStatusIndicator(context.primaryColor); - case LogLevel.SEVERE: - return colorStatusIndicator(Colors.redAccent); + Widget buildLeadingIcon(LogLevel level) => switch (level) { + LogLevel.INFO => colorStatusIndicator(context.primaryColor), + LogLevel.SEVERE => colorStatusIndicator(Colors.redAccent), + LogLevel.WARNING => colorStatusIndicator(Colors.orangeAccent), + _ => colorStatusIndicator(Colors.grey), + }; - case LogLevel.WARNING: - return colorStatusIndicator(Colors.orangeAccent); - default: - return colorStatusIndicator(Colors.grey); - } - } - - getTileColor(LogLevel level) { - switch (level) { - case LogLevel.INFO: - return Colors.transparent; - case LogLevel.SEVERE: - return Colors.redAccent.withOpacity(0.25); - case LogLevel.WARNING: - return Colors.orangeAccent.withOpacity(0.25); - default: - return context.primaryColor.withOpacity(0.1); - } - } + Color getTileColor(LogLevel level) => switch (level) { + LogLevel.INFO => Colors.transparent, + LogLevel.SEVERE => Colors.redAccent.withOpacity(0.25), + LogLevel.WARNING => Colors.orangeAccent.withOpacity(0.25), + _ => context.primaryColor.withOpacity(0.1), + }; return Scaffold( appBar: AppBar( diff --git a/mobile/lib/pages/common/download_panel.dart b/mobile/lib/pages/common/download_panel.dart index 4421e337e9..5cc6e5b8d6 100644 --- a/mobile/lib/pages/common/download_panel.dart +++ b/mobile/lib/pages/common/download_panel.dart @@ -74,26 +74,16 @@ class DownloadTaskTile extends StatelessWidget { Widget build(BuildContext context) { final progressPercent = (progress * 100).round(); - getStatusText() { - switch (status) { - case TaskStatus.running: - return 'downloading'.tr(); - case TaskStatus.complete: - return 'download_complete'.tr(); - case TaskStatus.failed: - return 'download_failed'.tr(); - case TaskStatus.canceled: - return 'download_canceled'.tr(); - case TaskStatus.paused: - return 'download_paused'.tr(); - case TaskStatus.enqueued: - return 'download_enqueue'.tr(); - case TaskStatus.notFound: - return 'download_notfound'.tr(); - case TaskStatus.waitingToRetry: - return 'download_waiting_to_retry'.tr(); - } - } + String getStatusText() => switch (status) { + TaskStatus.running => 'downloading'.tr(), + TaskStatus.complete => 'download_complete'.tr(), + TaskStatus.failed => 'download_failed'.tr(), + TaskStatus.canceled => 'download_canceled'.tr(), + TaskStatus.paused => 'download_paused'.tr(), + TaskStatus.enqueued => 'download_enqueue'.tr(), + TaskStatus.notFound => 'download_notfound'.tr(), + TaskStatus.waitingToRetry => 'download_waiting_to_retry'.tr(), + }; return SizedBox( key: const ValueKey('download_progress'), diff --git a/mobile/lib/pages/editing/crop.page.dart b/mobile/lib/pages/editing/crop.page.dart index dc467f5740..f7f459c770 100644 --- a/mobile/lib/pages/editing/crop.page.dart +++ b/mobile/lib/pages/editing/crop.page.dart @@ -174,33 +174,19 @@ class _AspectRatioButton extends StatelessWidget { @override Widget build(BuildContext context) { - IconData iconData; - switch (label) { - case 'Free': - iconData = Icons.crop_free_rounded; - break; - case '1:1': - iconData = Icons.crop_square_rounded; - break; - case '16:9': - iconData = Icons.crop_16_9_rounded; - break; - case '3:2': - iconData = Icons.crop_3_2_rounded; - break; - case '7:5': - iconData = Icons.crop_7_5_rounded; - break; - default: - iconData = Icons.crop_free_rounded; - } - return Column( mainAxisSize: MainAxisSize.min, children: [ IconButton( icon: Icon( - iconData, + switch (label) { + 'Free' => Icons.crop_free_rounded, + '1:1' => Icons.crop_square_rounded, + '16:9' => Icons.crop_16_9_rounded, + '3:2' => Icons.crop_3_2_rounded, + '7:5' => Icons.crop_7_5_rounded, + _ => Icons.crop_free_rounded, + }, color: aspectRatio.value == ratio ? context.primaryColor : context.themeData.iconTheme.color, diff --git a/mobile/lib/pages/onboarding/permission_onboarding.page.dart b/mobile/lib/pages/onboarding/permission_onboarding.page.dart index e5408f2297..dc9923303b 100644 --- a/mobile/lib/pages/onboarding/permission_onboarding.page.dart +++ b/mobile/lib/pages/onboarding/permission_onboarding.page.dart @@ -136,23 +136,16 @@ class PermissionOnboardingPage extends HookConsumerWidget { ); } - final Widget child; - switch (permission) { - case PermissionStatus.limited: - child = buildPermissionLimited(); - break; - case PermissionStatus.denied: - child = buildRequestPermission(); - break; - case PermissionStatus.granted: - case PermissionStatus.provisional: - child = buildPermissionGranted(); - break; - case PermissionStatus.restricted: - case PermissionStatus.permanentlyDenied: - child = buildPermissionDenied(); - break; - } + final Widget child = switch (permission) { + PermissionStatus.limited => buildPermissionLimited(), + PermissionStatus.denied => buildRequestPermission(), + PermissionStatus.granted || + PermissionStatus.provisional => + buildPermissionGranted(), + PermissionStatus.restricted || + PermissionStatus.permanentlyDenied => + buildPermissionDenied() + }; return Scaffold( body: SafeArea( diff --git a/mobile/lib/repositories/album.repository.dart b/mobile/lib/repositories/album.repository.dart index adf83b33d4..7f4beee3bb 100644 --- a/mobile/lib/repositories/album.repository.dart +++ b/mobile/lib/repositories/album.repository.dart @@ -18,15 +18,11 @@ class AlbumRepository extends DatabaseRepository implements IAlbumRepository { @override Future count({bool? local}) { final baseQuery = db.albums.where(); - final QueryBuilder query; - switch (local) { - case null: - query = baseQuery.noOp(); - case true: - query = baseQuery.localIdIsNotNull(); - case false: - query = baseQuery.remoteIdIsNotNull(); - } + final QueryBuilder query = switch (local) { + null => baseQuery.noOp(), + true => baseQuery.localIdIsNotNull(), + false => baseQuery.remoteIdIsNotNull(), + }; return query.count(); } @@ -91,15 +87,11 @@ class AlbumRepository extends DatabaseRepository implements IAlbumRepository { if (ownerId != null) { filterQuery = filterQuery.owner((q) => q.isarIdEqualTo(ownerId)); } - final QueryBuilder query; - switch (sortBy) { - case null: - query = filterQuery.noOp(); - case AlbumSort.remoteId: - query = filterQuery.sortByRemoteId(); - case AlbumSort.localId: - query = filterQuery.sortByLocalId(); - } + final QueryBuilder query = switch (sortBy) { + null => filterQuery.noOp(), + AlbumSort.remoteId => filterQuery.sortByRemoteId(), + AlbumSort.localId => filterQuery.sortByLocalId(), + }; return query.findAll(); } @@ -150,14 +142,11 @@ class AlbumRepository extends DatabaseRepository implements IAlbumRepository { query = query.owner( (q) => q.not().isarIdEqualTo(Store.get(StoreKey.currentUser).isarId), ); - break; case QuickFilterMode.myAlbums: query = query.owner( (q) => q.isarIdEqualTo(Store.get(StoreKey.currentUser).isarId), ); - break; case QuickFilterMode.all: - default: break; } diff --git a/mobile/lib/repositories/asset.repository.dart b/mobile/lib/repositories/asset.repository.dart index eaaafd3045..36e976a1ab 100644 --- a/mobile/lib/repositories/asset.repository.dart +++ b/mobile/lib/repositories/asset.repository.dart @@ -38,27 +38,20 @@ class AssetRepository extends DatabaseRepository implements IAssetRepository { query = query.ownerIdEqualTo(ownerId); } - switch (state) { - case null: - break; - case AssetState.local: - query = query.remoteIdIsNull(); - case AssetState.remote: - query = query.localIdIsNull(); - case AssetState.merged: - query = query.localIdIsNotNull().remoteIdIsNotNull(); + if (state != null) { + query = switch (state) { + AssetState.local => query.remoteIdIsNull(), + AssetState.remote => query.localIdIsNull(), + AssetState.merged => query.localIdIsNotNull().remoteIdIsNotNull(), + }; } - final QueryBuilder sortedQuery; - - switch (sortBy) { - case null: - sortedQuery = query.noOp(); - case AssetSort.checksum: - sortedQuery = query.sortByChecksum(); - case AssetSort.ownerIdChecksum: - sortedQuery = query.sortByOwnerId().thenByChecksum(); - } + final QueryBuilder sortedQuery = + switch (sortBy) { + null => query.noOp(), + AssetSort.checksum => query.sortByChecksum(), + AssetSort.ownerIdChecksum => query.sortByOwnerId().thenByChecksum(), + }; return sortedQuery.findAll(); } @@ -84,16 +77,12 @@ class AssetRepository extends DatabaseRepository implements IAssetRepository { AssetState? state, ) { final query = db.assets.remote(ids).filter(); - switch (state) { - case null: - return query.noOp(); - case AssetState.local: - return query.remoteIdIsNull(); - case AssetState.remote: - return query.localIdIsNull(); - case AssetState.merged: - return query.localIdIsNotEmpty().remoteIdIsNotNull(); - } + return switch (state) { + null => query.noOp(), + AssetState.local => query.remoteIdIsNull(), + AssetState.remote => query.localIdIsNull(), + AssetState.merged => query.localIdIsNotEmpty().remoteIdIsNotNull(), + }; } @override @@ -104,39 +93,32 @@ class AssetRepository extends DatabaseRepository implements IAssetRepository { int? limit, }) { final baseQuery = db.assets.where(); - final QueryBuilder filteredQuery; - switch (state) { - case null: - filteredQuery = baseQuery.ownerIdEqualToAnyChecksum(ownerId).noOp(); - case AssetState.local: - filteredQuery = baseQuery - .remoteIdIsNull() - .filter() - .localIdIsNotNull() - .ownerIdEqualTo(ownerId); - case AssetState.remote: - filteredQuery = baseQuery - .localIdIsNull() - .filter() - .remoteIdIsNotNull() - .ownerIdEqualTo(ownerId); - case AssetState.merged: - filteredQuery = baseQuery - .ownerIdEqualToAnyChecksum(ownerId) - .filter() - .remoteIdIsNotNull() - .localIdIsNotNull(); - } + final QueryBuilder filteredQuery = + switch (state) { + null => baseQuery.ownerIdEqualToAnyChecksum(ownerId).noOp(), + AssetState.local => baseQuery + .remoteIdIsNull() + .filter() + .localIdIsNotNull() + .ownerIdEqualTo(ownerId), + AssetState.remote => baseQuery + .localIdIsNull() + .filter() + .remoteIdIsNotNull() + .ownerIdEqualTo(ownerId), + AssetState.merged => baseQuery + .ownerIdEqualToAnyChecksum(ownerId) + .filter() + .remoteIdIsNotNull() + .localIdIsNotNull(), + }; - final QueryBuilder query; - switch (sortBy) { - case null: - query = filteredQuery.noOp(); - case AssetSort.checksum: - query = filteredQuery.sortByChecksum(); - case AssetSort.ownerIdChecksum: - query = filteredQuery.sortByOwnerId().thenByChecksum(); - } + final QueryBuilder query = switch (sortBy) { + null => filteredQuery.noOp(), + AssetSort.checksum => filteredQuery.sortByChecksum(), + AssetSort.ownerIdChecksum => + filteredQuery.sortByOwnerId().thenByChecksum(), + }; return limit == null ? query.findAll() : query.limit(limit).findAll(); } @@ -155,17 +137,16 @@ class AssetRepository extends DatabaseRepository implements IAssetRepository { int limit = 100, }) { final baseQuery = db.assets.where(); - final QueryBuilder query; - switch (state) { - case null: - query = baseQuery.noOp(); - case AssetState.local: - query = baseQuery.remoteIdIsNull().filter().localIdIsNotNull(); - case AssetState.remote: - query = baseQuery.localIdIsNull().filter().remoteIdIsNotNull(); - case AssetState.merged: - query = baseQuery.localIdIsNotNull().filter().remoteIdIsNotNull(); - } + final QueryBuilder query = + switch (state) { + null => baseQuery.noOp(), + AssetState.local => + baseQuery.remoteIdIsNull().filter().localIdIsNotNull(), + AssetState.remote => + baseQuery.localIdIsNull().filter().remoteIdIsNotNull(), + AssetState.merged => + baseQuery.localIdIsNotNull().filter().remoteIdIsNotNull(), + }; return _getMatchesImpl(query, ownerId, assets, limit); } diff --git a/mobile/lib/repositories/backup.repository.dart b/mobile/lib/repositories/backup.repository.dart index 61997ff23a..ed3a9c27e4 100644 --- a/mobile/lib/repositories/backup.repository.dart +++ b/mobile/lib/repositories/backup.repository.dart @@ -14,13 +14,11 @@ class BackupRepository extends DatabaseRepository implements IBackupRepository { @override Future> getAll({BackupAlbumSort? sort}) { final baseQuery = db.backupAlbums.where(); - final QueryBuilder query; - switch (sort) { - case null: - query = baseQuery.noOp(); - case BackupAlbumSort.id: - query = baseQuery.sortById(); - } + final QueryBuilder query = + switch (sort) { + null => baseQuery.noOp(), + BackupAlbumSort.id => baseQuery.sortById(), + }; return query.findAll(); } diff --git a/mobile/lib/repositories/user.repository.dart b/mobile/lib/repositories/user.repository.dart index fb4df84fe7..bc9325c9e9 100644 --- a/mobile/lib/repositories/user.repository.dart +++ b/mobile/lib/repositories/user.repository.dart @@ -25,13 +25,10 @@ class UserRepository extends DatabaseRepository implements IUserRepository { final int userId = Store.get(StoreKey.currentUser).isarId; final QueryBuilder afterWhere = self ? baseQuery.noOp() : baseQuery.isarIdNotEqualTo(userId); - final QueryBuilder query; - switch (sortBy) { - case null: - query = afterWhere.noOp(); - case UserSort.id: - query = afterWhere.sortById(); - } + final QueryBuilder query = switch (sortBy) { + null => afterWhere.noOp(), + UserSort.id => afterWhere.sortById(), + }; return query.findAll(); } diff --git a/mobile/lib/services/backup.service.dart b/mobile/lib/services/backup.service.dart index 7bce1047e2..802a98571e 100644 --- a/mobile/lib/services/backup.service.dart +++ b/mobile/lib/services/backup.service.dart @@ -519,18 +519,12 @@ class BackupService { return responseBody.containsKey('id') ? responseBody['id'] : null; } - String _getAssetType(AssetType assetType) { - switch (assetType) { - case AssetType.audio: - return "AUDIO"; - case AssetType.image: - return "IMAGE"; - case AssetType.video: - return "VIDEO"; - case AssetType.other: - return "OTHER"; - } - } + String _getAssetType(AssetType assetType) => switch (assetType) { + AssetType.audio => "AUDIO", + AssetType.image => "IMAGE", + AssetType.video => "VIDEO", + AssetType.other => "OTHER", + }; } class MultipartRequest extends http.MultipartRequest { diff --git a/mobile/lib/utils/storage_indicator.dart b/mobile/lib/utils/storage_indicator.dart index 4764c45385..a7dad063ca 100644 --- a/mobile/lib/utils/storage_indicator.dart +++ b/mobile/lib/utils/storage_indicator.dart @@ -2,13 +2,8 @@ import 'package:flutter/material.dart'; import 'package:immich_mobile/entities/asset.entity.dart'; /// Returns the suitable [IconData] to represent an [Asset]s storage location -IconData storageIcon(Asset asset) { - switch (asset.storage) { - case AssetState.local: - return Icons.cloud_off_outlined; - case AssetState.remote: - return Icons.cloud_outlined; - case AssetState.merged: - return Icons.cloud_done_outlined; - } -} +IconData storageIcon(Asset asset) => switch (asset.storage) { + AssetState.local => Icons.cloud_off_outlined, + AssetState.remote => Icons.cloud_outlined, + AssetState.merged => Icons.cloud_done_outlined, + }; diff --git a/mobile/lib/widgets/common/immich_toast.dart b/mobile/lib/widgets/common/immich_toast.dart index d33f6c4caf..b0f1306aba 100644 --- a/mobile/lib/widgets/common/immich_toast.dart +++ b/mobile/lib/widgets/common/immich_toast.dart @@ -15,36 +15,26 @@ class ImmichToast { final fToast = FToast(); fToast.init(context); - Color getColor(ToastType type, BuildContext context) { - switch (type) { - case ToastType.info: - return context.primaryColor; - case ToastType.success: - return const Color.fromARGB(255, 78, 140, 124); - case ToastType.error: - return const Color.fromARGB(255, 220, 48, 85); - } - } + Color getColor(ToastType type, BuildContext context) => switch (type) { + ToastType.info => context.primaryColor, + ToastType.success => const Color.fromARGB(255, 78, 140, 124), + ToastType.error => const Color.fromARGB(255, 220, 48, 85), + }; - Icon getIcon(ToastType type) { - switch (type) { - case ToastType.info: - return Icon( - Icons.info_outline_rounded, - color: context.primaryColor, - ); - case ToastType.success: - return const Icon( - Icons.check_circle_rounded, - color: Color.fromARGB(255, 78, 140, 124), - ); - case ToastType.error: - return const Icon( - Icons.error_outline_rounded, - color: Color.fromARGB(255, 240, 162, 156), - ); - } - } + Icon getIcon(ToastType type) => switch (type) { + ToastType.info => Icon( + Icons.info_outline_rounded, + color: context.primaryColor, + ), + ToastType.success => const Icon( + Icons.check_circle_rounded, + color: Color.fromARGB(255, 78, 140, 124), + ), + ToastType.error => const Icon( + Icons.error_outline_rounded, + color: Color.fromARGB(255, 240, 162, 156), + ), + }; fToast.showToast( child: Container( diff --git a/mobile/lib/widgets/photo_view/photo_view.dart b/mobile/lib/widgets/photo_view/photo_view.dart index 7f72750afe..f72d1e298f 100644 --- a/mobile/lib/widgets/photo_view/photo_view.dart +++ b/mobile/lib/widgets/photo_view/photo_view.dart @@ -590,21 +590,15 @@ class _PhotoViewState extends State } /// The default [ScaleStateCycle] -PhotoViewScaleState defaultScaleStateCycle(PhotoViewScaleState actual) { - switch (actual) { - case PhotoViewScaleState.initial: - return PhotoViewScaleState.covering; - case PhotoViewScaleState.covering: - return PhotoViewScaleState.originalSize; - case PhotoViewScaleState.originalSize: - return PhotoViewScaleState.initial; - case PhotoViewScaleState.zoomedIn: - case PhotoViewScaleState.zoomedOut: - return PhotoViewScaleState.initial; - default: - return PhotoViewScaleState.initial; - } -} +PhotoViewScaleState defaultScaleStateCycle(PhotoViewScaleState actual) => + switch (actual) { + PhotoViewScaleState.initial => PhotoViewScaleState.covering, + PhotoViewScaleState.covering => PhotoViewScaleState.originalSize, + PhotoViewScaleState.originalSize => PhotoViewScaleState.initial, + PhotoViewScaleState.zoomedIn || + PhotoViewScaleState.zoomedOut => + PhotoViewScaleState.initial, + }; /// A type definition for a [Function] that receives the actual [PhotoViewScaleState] and returns the next one /// It is used internally to walk in the "doubletap gesture cycle". diff --git a/mobile/lib/widgets/photo_view/src/utils/photo_view_utils.dart b/mobile/lib/widgets/photo_view/src/utils/photo_view_utils.dart index d91e9f51dd..9c632df3bf 100644 --- a/mobile/lib/widgets/photo_view/src/utils/photo_view_utils.dart +++ b/mobile/lib/widgets/photo_view/src/utils/photo_view_utils.dart @@ -9,25 +9,20 @@ double getScaleForScaleState( PhotoViewScaleState scaleState, ScaleBoundaries scaleBoundaries, ) { - switch (scaleState) { - case PhotoViewScaleState.initial: - case PhotoViewScaleState.zoomedIn: - case PhotoViewScaleState.zoomedOut: - return _clampSize(scaleBoundaries.initialScale, scaleBoundaries); - case PhotoViewScaleState.covering: - return _clampSize( + return switch (scaleState) { + PhotoViewScaleState.initial || + PhotoViewScaleState.zoomedIn || + PhotoViewScaleState.zoomedOut => + _clampSize(scaleBoundaries.initialScale, scaleBoundaries), + PhotoViewScaleState.covering => _clampSize( _scaleForCovering( scaleBoundaries.outerSize, scaleBoundaries.childSize, ), scaleBoundaries, - ); - case PhotoViewScaleState.originalSize: - return _clampSize(1.0, scaleBoundaries); - // Will never be reached - default: - return 0; - } + ), + PhotoViewScaleState.originalSize => _clampSize(1.0, scaleBoundaries), + }; } /// Internal class to wraps custom scale boundaries (min, max and initial) diff --git a/mobile/lib/widgets/settings/networking_settings/networking_settings.dart b/mobile/lib/widgets/settings/networking_settings/networking_settings.dart index 59d05fd4cf..d241792d95 100644 --- a/mobile/lib/widgets/settings/networking_settings/networking_settings.dart +++ b/mobile/lib/widgets/settings/networking_settings/networking_settings.dart @@ -220,23 +220,20 @@ class NetworkStatusIcon extends StatelessWidget { ); } - Widget _buildIcon(BuildContext context) { - switch (status) { - case AuxCheckStatus.loading: - return Padding( - padding: const EdgeInsets.only(left: 4.0), - child: SizedBox( - width: 18, - height: 18, - child: CircularProgressIndicator( - color: context.primaryColor, - strokeWidth: 2, - key: const ValueKey('loading'), + Widget _buildIcon(BuildContext context) => switch (status) { + AuxCheckStatus.loading => Padding( + padding: const EdgeInsets.only(left: 4.0), + child: SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator( + color: context.primaryColor, + strokeWidth: 2, + key: const ValueKey('loading'), + ), ), ), - ); - case AuxCheckStatus.valid: - return enabled + AuxCheckStatus.valid => enabled ? const Icon( Icons.check_circle_rounded, color: Colors.green, @@ -246,9 +243,8 @@ class NetworkStatusIcon extends StatelessWidget { Icons.check_circle_rounded, color: context.colorScheme.onSurface.withAlpha(100), key: const ValueKey('success'), - ); - case AuxCheckStatus.error: - return enabled + ), + AuxCheckStatus.error => enabled ? const Icon( Icons.error_rounded, color: Colors.red, @@ -258,9 +254,7 @@ class NetworkStatusIcon extends StatelessWidget { Icons.error_rounded, color: Colors.grey, key: ValueKey('error'), - ); - default: - return const Icon(Icons.circle_outlined, key: ValueKey('unknown')); - } - } + ), + _ => const Icon(Icons.circle_outlined, key: ValueKey('unknown')), + }; } From 882163f545422faa833203d562ef08c2337b923c Mon Sep 17 00:00:00 2001 From: bo0tzz Date: Sun, 2 Feb 2025 23:45:58 +0100 Subject: [PATCH 069/395] chore: build metadata for ML container (#15831) * chore: build metadata for ML container * fix: build_image_url --- machine-learning/Dockerfile | 16 ++++++++++++++++ machine-learning/start.sh | 2 ++ 2 files changed, 18 insertions(+) diff --git a/machine-learning/Dockerfile b/machine-learning/Dockerfile index df2b5b95fe..7dc206ab55 100644 --- a/machine-learning/Dockerfile +++ b/machine-learning/Dockerfile @@ -106,6 +106,22 @@ COPY --from=builder /opt/venv /opt/venv COPY ann/ann.py /usr/src/ann/ann.py COPY start.sh log_conf.json gunicorn_conf.py ./ COPY app . + +ARG BUILD_ID +ARG BUILD_IMAGE +ARG BUILD_SOURCE_REF +ARG BUILD_SOURCE_COMMIT + +ENV IMMICH_BUILD=${BUILD_ID} +ENV IMMICH_BUILD_URL=https://github.com/immich-app/immich/actions/runs/${BUILD_ID} +ENV IMMICH_BUILD_IMAGE=${BUILD_IMAGE} +ENV IMMICH_BUILD_IMAGE_URL=https://github.com/immich-app/immich/pkgs/container/immich-machine-learning +ENV IMMICH_REPOSITORY=immich-app/immich +ENV IMMICH_REPOSITORY_URL=https://github.com/immich-app/immich +ENV IMMICH_SOURCE_REF=${BUILD_SOURCE_REF} +ENV IMMICH_SOURCE_COMMIT=${BUILD_SOURCE_COMMIT} +ENV IMMICH_SOURCE_URL=https://github.com/immich-app/immich/commit/${BUILD_SOURCE_COMMIT} + ENTRYPOINT ["tini", "--"] CMD ["./start.sh"] diff --git a/machine-learning/start.sh b/machine-learning/start.sh index 552cca1f5e..d2f5b94dc3 100755 --- a/machine-learning/start.sh +++ b/machine-learning/start.sh @@ -1,5 +1,7 @@ #!/usr/bin/env sh +echo "Initializing Immich ML $IMMICH_SOURCE_REF" + lib_path="/usr/lib/$(arch)-linux-gnu/libmimalloc.so.2" # mimalloc seems to increase memory usage dramatically with openvino, need to investigate if ! [ "$DEVICE" = "openvino" ]; then From 52c9fbea5ffebb9ab934dd25c4fb711d81683047 Mon Sep 17 00:00:00 2001 From: Matthew Momjian <50788000+mmomjian@users.noreply.github.com> Date: Sun, 2 Feb 2025 23:55:47 -0500 Subject: [PATCH 070/395] fix(docs): query DB by ID (#15863) * db query for id * format * backticks * Update database-queries.md --- docs/docs/guides/database-queries.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/docs/guides/database-queries.md b/docs/docs/guides/database-queries.md index e71fa21c8b..2017689984 100644 --- a/docs/docs/guides/database-queries.md +++ b/docs/docs/guides/database-queries.md @@ -27,6 +27,10 @@ SELECT * FROM "assets" WHERE "originalPath" = 'upload/library/admin/2023/2023-09 SELECT * FROM "assets" WHERE "originalPath" LIKE 'upload/library/admin/2023/%'; ``` +```sql title="Find by ID" +SELECT * FROM "assets" WHERE "id" = '9f94e60f-65b6-47b7-ae44-a4df7b57f0e9'; +``` + :::note You can calculate the checksum for a particular file by using the command `sha1sum `. ::: From e8d05e78adbe489caa3741e54da22f33bfb852ea Mon Sep 17 00:00:00 2001 From: Stark <66514398+OkayStark@users.noreply.github.com> Date: Mon, 3 Feb 2025 23:06:25 +0530 Subject: [PATCH 071/395] feat(web): Updated Onboarding page (#15880) Updated Onboarding page the "previous" button on the Storage Template page now points to privacy instead of theme --- .../onboarding-page/onboarding-storage-template.svelte | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/src/lib/components/onboarding-page/onboarding-storage-template.svelte b/web/src/lib/components/onboarding-page/onboarding-storage-template.svelte index b692a6f2de..4b53ca2673 100644 --- a/web/src/lib/components/onboarding-page/onboarding-storage-template.svelte +++ b/web/src/lib/components/onboarding-page/onboarding-storage-template.svelte @@ -54,7 +54,7 @@
From ef245ea2d2891a0726741776eb9863dd2b99d87d Mon Sep 17 00:00:00 2001 From: Damiano Ferrari <34270884+ferraridamiano@users.noreply.github.com> Date: Mon, 3 Feb 2025 20:49:55 +0100 Subject: [PATCH 072/395] feat(mobile): Use `NavigationRail` when the screen is in landscape mode (#15885) --- .../lib/pages/common/tab_controller.page.dart | 148 +++++++++++------- 1 file changed, 90 insertions(+), 58 deletions(-) diff --git a/mobile/lib/pages/common/tab_controller.page.dart b/mobile/lib/pages/common/tab_controller.page.dart index 1ba9650056..a418e8d2f0 100644 --- a/mobile/lib/pages/common/tab_controller.page.dart +++ b/mobile/lib/pages/common/tab_controller.page.dart @@ -20,6 +20,8 @@ class TabControllerPage extends HookConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { final isRefreshingAssets = ref.watch(assetProvider); final isRefreshingRemoteAlbums = ref.watch(isRefreshingRemoteAlbumProvider); + final isScreenLandscape = + MediaQuery.orientationOf(context) == Orientation.landscape; Widget buildIcon({required Widget icon, required bool isProcessing}) { if (!isProcessing) return icon; @@ -45,7 +47,7 @@ class TabControllerPage extends HookConsumerWidget { ); } - onNavigationSelected(TabsRouter router, int index) { + void onNavigationSelected(TabsRouter router, int index) { // On Photos page menu tapped if (router.activeIndex == 0 && index == 0) { scrollToTopNotifierProvider.scrollToTop(); @@ -61,62 +63,82 @@ class TabControllerPage extends HookConsumerWidget { ref.read(tabProvider.notifier).state = TabEnum.values[index]; } - bottomNavigationBar(TabsRouter tabsRouter) { + final navigationDestinations = [ + NavigationDestination( + label: 'tab_controller_nav_photos'.tr(), + icon: const Icon( + Icons.photo_library_outlined, + ), + selectedIcon: buildIcon( + isProcessing: isRefreshingAssets, + icon: Icon( + Icons.photo_library, + color: context.primaryColor, + ), + ), + ), + NavigationDestination( + label: 'tab_controller_nav_search'.tr(), + icon: const Icon( + Icons.search_rounded, + ), + selectedIcon: Icon( + Icons.search, + color: context.primaryColor, + ), + ), + NavigationDestination( + label: 'albums'.tr(), + icon: const Icon( + Icons.photo_album_outlined, + ), + selectedIcon: buildIcon( + isProcessing: isRefreshingRemoteAlbums, + icon: Icon( + Icons.photo_album_rounded, + color: context.primaryColor, + ), + ), + ), + NavigationDestination( + label: 'library'.tr(), + icon: const Icon( + Icons.space_dashboard_outlined, + ), + selectedIcon: buildIcon( + isProcessing: isRefreshingAssets, + icon: Icon( + Icons.space_dashboard_rounded, + color: context.primaryColor, + ), + ), + ), + ]; + + Widget bottomNavigationBar(TabsRouter tabsRouter) { return NavigationBar( selectedIndex: tabsRouter.activeIndex, onDestinationSelected: (index) => onNavigationSelected(tabsRouter, index), - destinations: [ - NavigationDestination( - label: 'tab_controller_nav_photos'.tr(), - icon: const Icon( - Icons.photo_library_outlined, - ), - selectedIcon: buildIcon( - isProcessing: isRefreshingAssets, - icon: Icon( - Icons.photo_library, - color: context.primaryColor, + destinations: navigationDestinations, + ); + } + + Widget navigationRail(TabsRouter tabsRouter) { + return NavigationRail( + destinations: navigationDestinations + .map( + (e) => NavigationRailDestination( + icon: e.icon, + label: Text(e.label), ), - ), - ), - NavigationDestination( - label: 'tab_controller_nav_search'.tr(), - icon: const Icon( - Icons.search_rounded, - ), - selectedIcon: Icon( - Icons.search, - color: context.primaryColor, - ), - ), - NavigationDestination( - label: 'albums'.tr(), - icon: const Icon( - Icons.photo_album_outlined, - ), - selectedIcon: buildIcon( - isProcessing: isRefreshingRemoteAlbums, - icon: Icon( - Icons.photo_album_rounded, - color: context.primaryColor, - ), - ), - ), - NavigationDestination( - label: 'library'.tr(), - icon: const Icon( - Icons.space_dashboard_outlined, - ), - selectedIcon: buildIcon( - isProcessing: isRefreshingAssets, - icon: Icon( - Icons.space_dashboard_rounded, - color: context.primaryColor, - ), - ), - ), - ], + ) + .toList(), + onDestinationSelected: (index) => + onNavigationSelected(tabsRouter, index), + selectedIndex: tabsRouter.activeIndex, + labelType: NavigationRailLabelType.all, + groupAlignment: 0.0, ); } @@ -135,17 +157,27 @@ class TabControllerPage extends HookConsumerWidget { ), builder: (context, child) { final tabsRouter = AutoTabsRouter.of(context); + final heroedChild = HeroControllerScope( + controller: HeroController(), + child: child, + ); return PopScope( canPop: tabsRouter.activeIndex == 0, onPopInvokedWithResult: (didPop, _) => !didPop ? tabsRouter.setActiveIndex(0) : null, child: Scaffold( - body: HeroControllerScope( - controller: HeroController(), - child: child, - ), - bottomNavigationBar: - multiselectEnabled ? null : bottomNavigationBar(tabsRouter), + body: isScreenLandscape + ? Row( + children: [ + navigationRail(tabsRouter), + const VerticalDivider(), + Expanded(child: heroedChild), + ], + ) + : heroedChild, + bottomNavigationBar: multiselectEnabled || isScreenLandscape + ? null + : bottomNavigationBar(tabsRouter), ), ); }, From aac029d92b788e0bbaee2926258e299263d3e873 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Ventura?= Date: Mon, 3 Feb 2025 20:01:05 +0000 Subject: [PATCH 073/395] feat(web): merge suggestion modal: focus on Yes button by default. (#15827) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(web): merge suggestion modal: focus on Yes button by default. * refactor(web): merge suggestion modal: use Button from @immich/ui. --------- Co-authored-by: André Ventura --- .../faces-page/merge-suggestion-modal.svelte | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/web/src/lib/components/faces-page/merge-suggestion-modal.svelte b/web/src/lib/components/faces-page/merge-suggestion-modal.svelte index a4ac76f198..eaf9ab64e9 100644 --- a/web/src/lib/components/faces-page/merge-suggestion-modal.svelte +++ b/web/src/lib/components/faces-page/merge-suggestion-modal.svelte @@ -1,11 +1,12 @@ @@ -113,7 +119,9 @@
{#snippet stickyBottom()} - - + + {/snippet} From 47f6181d42e3612663c46dd8e2f3ee5b4502de34 Mon Sep 17 00:00:00 2001 From: Meesam Date: Tue, 4 Feb 2025 02:00:39 +0530 Subject: [PATCH 074/395] fix(mobile): improved the visibility of backup cloud icon on lighter images (#15886) * fix(mobile): improved the visibility of backup cloud icon on lighter images * refactor(mobile): add 'const' keyword to Offset constructor for improved performance --- mobile/lib/widgets/asset_grid/thumbnail_image.dart | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/mobile/lib/widgets/asset_grid/thumbnail_image.dart b/mobile/lib/widgets/asset_grid/thumbnail_image.dart index 35013bb595..81932e2b94 100644 --- a/mobile/lib/widgets/asset_grid/thumbnail_image.dart +++ b/mobile/lib/widgets/asset_grid/thumbnail_image.dart @@ -204,6 +204,13 @@ class ThumbnailImage extends ConsumerWidget { storageIcon(asset), color: Colors.white.withOpacity(.8), size: 16, + shadows: [ + Shadow( + blurRadius: 5.0, + color: Colors.black.withOpacity(0.6), + offset: const Offset(0.0, 0.0), + ), + ], ), ), if (asset.isFavorite) From 06f077bac25dcf4870ac03fb46840182bd38fbbf Mon Sep 17 00:00:00 2001 From: Alex Date: Mon, 3 Feb 2025 16:29:41 -0600 Subject: [PATCH 075/395] fix(server): memory lane assets order (#15882) * fix(server): memory lane assets order * fix: sql * pr feedback * sql --- server/src/queries/asset.repository.sql | 2 ++ server/src/repositories/asset.repository.ts | 1 + 2 files changed, 3 insertions(+) diff --git a/server/src/queries/asset.repository.sql b/server/src/queries/asset.repository.sql index 948f7dd114..437e1e173c 100644 --- a/server/src/queries/asset.repository.sql +++ b/server/src/queries/asset.repository.sql @@ -47,6 +47,8 @@ with and "asset_files"."type" = $6 ) and "assets"."deletedAt" is null + order by + (assets."localDateTime" at time zone 'UTC')::date desc limit $7 ) as "a" on true diff --git a/server/src/repositories/asset.repository.ts b/server/src/repositories/asset.repository.ts index b306b1a694..1f52b9c71a 100644 --- a/server/src/repositories/asset.repository.ts +++ b/server/src/repositories/asset.repository.ts @@ -121,6 +121,7 @@ export class AssetRepository implements IAssetRepository { ), ) .where('assets.deletedAt', 'is', null) + .orderBy(sql`(assets."localDateTime" at time zone 'UTC')::date`, 'desc') .limit(20) .as('a'), (join) => join.onTrue(), From 9358b4dc7e04b928eadfc0561342ffc5064a3be7 Mon Sep 17 00:00:00 2001 From: jtkmckenna <124043374+jtkmckenna@users.noreply.github.com> Date: Mon, 3 Feb 2025 23:41:42 +0100 Subject: [PATCH 076/395] fix: bash install.sh script for mac os (#15874) fix: bash script for mac os Fix the displayed IP address in bash script if hostname fails to return a string Co-authored-by: Joseph McKenna --- install.sh | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/install.sh b/install.sh index d6569f736a..ccefe4e894 100755 --- a/install.sh +++ b/install.sh @@ -51,6 +51,10 @@ start_docker_compose() { show_friendly_message() { local ip_address ip_address=$(hostname -I | awk '{print $1}') + # If length of ip_address is 0, then we are on a Mac + if [ ${#ip_address} -eq 0 ]; then + ip_address=$(ipconfig getifaddr en0) + fi cat < Date: Tue, 4 Feb 2025 09:43:23 +1100 Subject: [PATCH 077/395] fix(mobile): #15182 Video memories no longer play (#15210) * Update current asset to play video. * Updated location of currentAssetProvider update per feedback. * Added a playbackDelayFactor to the video viewer to resolve an issue in memories. Also adjusted the scale of the memory preview image to match the ratio of the video. This still appears to jump because the video preview doesn't seem to be the first frame for some reason :\ * add video indicator --------- Co-authored-by: Tom graham Co-authored-by: Alex --- .../common/native_video_viewer.page.dart | 12 +++++++--- mobile/lib/pages/photos/memory.page.dart | 23 +++++++++++++++++++ mobile/lib/widgets/memories/memory_card.dart | 3 ++- mobile/lib/widgets/memories/memory_lane.dart | 9 ++++++++ 4 files changed, 43 insertions(+), 4 deletions(-) diff --git a/mobile/lib/pages/common/native_video_viewer.page.dart b/mobile/lib/pages/common/native_video_viewer.page.dart index ad9d53b1bb..8ab0da1b44 100644 --- a/mobile/lib/pages/common/native_video_viewer.page.dart +++ b/mobile/lib/pages/common/native_video_viewer.page.dart @@ -26,6 +26,7 @@ import 'package:wakelock_plus/wakelock_plus.dart'; class NativeVideoViewerPage extends HookConsumerWidget { final Asset asset; final bool showControls; + final int playbackDelayFactor; final Widget image; const NativeVideoViewerPage({ @@ -33,6 +34,7 @@ class NativeVideoViewerPage extends HookConsumerWidget { required this.asset, required this.image, this.showControls = true, + this.playbackDelayFactor = 1, }); @override @@ -317,12 +319,16 @@ class NativeVideoViewerPage extends HookConsumerWidget { } // Delay the video playback to avoid a stutter in the swipe animation + // Note, in some circumstances a longer delay is needed (eg: memories), + // the playbackDelayFactor can be used for this + // This delay seems like a hacky way to resolve underlying bugs in video + // playback, but other resolutions failed thus far Timer( Platform.isIOS - ? const Duration(milliseconds: 300) + ? Duration(milliseconds: 300 * playbackDelayFactor) : imageToVideo - ? const Duration(milliseconds: 200) - : const Duration(milliseconds: 400), () { + ? Duration(milliseconds: 200 * playbackDelayFactor) + : Duration(milliseconds: 400 * playbackDelayFactor), () { if (!context.mounted) { return; } diff --git a/mobile/lib/pages/photos/memory.page.dart b/mobile/lib/pages/photos/memory.page.dart index 74a94ed6ee..b082b7484f 100644 --- a/mobile/lib/pages/photos/memory.page.dart +++ b/mobile/lib/pages/photos/memory.page.dart @@ -5,6 +5,8 @@ import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/entities/asset.entity.dart'; import 'package:immich_mobile/models/memories/memory.model.dart'; +import 'package:immich_mobile/providers/asset_viewer/current_asset.provider.dart'; +import 'package:immich_mobile/providers/asset_viewer/video_player_value_provider.dart'; import 'package:immich_mobile/providers/haptic_feedback.provider.dart'; import 'package:immich_mobile/widgets/common/immich_image.dart'; import 'package:immich_mobile/widgets/memories/memory_bottom_info.dart'; @@ -13,6 +15,8 @@ import 'package:immich_mobile/widgets/memories/memory_epilogue.dart'; import 'package:immich_mobile/widgets/memories/memory_progress_indicator.dart'; @RoutePage() + +/// Expects [currentAssetProvider] to be set before navigating to this page class MemoryPage extends HookConsumerWidget { final List memories; final int memoryIndex; @@ -32,6 +36,7 @@ class MemoryPage extends HookConsumerWidget { "${currentAssetPage.value + 1}|${currentMemory.value.assets.length}", ); const bgColor = Colors.black; + final currentAsset = useState(null); /// The list of all of the asset page controllers final memoryAssetPageControllers = @@ -135,6 +140,14 @@ class MemoryPage extends HookConsumerWidget { ref.read(hapticFeedbackProvider.notifier).selectionClick(); currentAssetPage.value = otherIndex; updateProgressText(); + + final asset = currentMemory.value.assets[otherIndex]; + currentAsset.value = asset; + ref.read(currentAssetProvider.notifier).set(asset); + if (asset.isVideo || asset.isMotionPhoto) { + ref.read(videoPlaybackValueProvider.notifier).reset(); + } + // Wait for page change animation to finish await Future.delayed(const Duration(milliseconds: 400)); // And then precache the next asset @@ -274,6 +287,16 @@ class MemoryPage extends HookConsumerWidget { ), ), ), + if (currentAsset.value != null && + currentAsset.value!.isVideo) + Positioned( + bottom: 24, + right: 32, + child: Icon( + Icons.videocam_outlined, + color: Colors.grey[200], + ), + ), ], ), ), diff --git a/mobile/lib/widgets/memories/memory_card.dart b/mobile/lib/widgets/memories/memory_card.dart index 4954d0bfcc..b63a310b32 100644 --- a/mobile/lib/widgets/memories/memory_card.dart +++ b/mobile/lib/widgets/memories/memory_card.dart @@ -75,11 +75,12 @@ class MemoryCard extends StatelessWidget { key: ValueKey(asset.id), asset: asset, showControls: false, + playbackDelayFactor: 2, image: ImmichImage( asset, width: context.width, height: context.height, - fit: fit, + fit: BoxFit.contain, ), ), ), diff --git a/mobile/lib/widgets/memories/memory_lane.dart b/mobile/lib/widgets/memories/memory_lane.dart index 41e9cc628e..d5b46dab51 100644 --- a/mobile/lib/widgets/memories/memory_lane.dart +++ b/mobile/lib/widgets/memories/memory_lane.dart @@ -4,6 +4,8 @@ import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/models/memories/memory.model.dart'; import 'package:immich_mobile/widgets/asset_grid/thumbnail_placeholder.dart'; +import 'package:immich_mobile/providers/asset_viewer/current_asset.provider.dart'; +import 'package:immich_mobile/providers/asset_viewer/video_player_value_provider.dart'; import 'package:immich_mobile/providers/memory.provider.dart'; import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/providers/haptic_feedback.provider.dart'; @@ -33,6 +35,13 @@ class MemoryLane extends HookConsumerWidget { ), onTap: (memoryIndex) { ref.read(hapticFeedbackProvider.notifier).heavyImpact(); + if (memories[memoryIndex].assets.isNotEmpty) { + final asset = memories[memoryIndex].assets[0]; + ref.read(currentAssetProvider.notifier).set(asset); + if (asset.isVideo || asset.isMotionPhoto) { + ref.read(videoPlaybackValueProvider.notifier).reset(); + } + } context.pushRoute( MemoryRoute( memories: memories, From 7ec361075372954942cd79c61a98857ac7636efc Mon Sep 17 00:00:00 2001 From: Arno <46051866+arnolicious@users.noreply.github.com> Date: Tue, 4 Feb 2025 09:52:17 +0100 Subject: [PATCH 078/395] feat: Mark people as favorite (#14866) * feat: added ability to mark people as favorite, which get sorted to the front of the people list * feat(server): added unit test for favorite people * feat(server): refactored for better readability * fixed person service unit tests * fixed open-api and sql checks * fixed bad codegen and removed unnecessary type assertion again * chore: clean up --------- Co-authored-by: Alex Co-authored-by: Jason Rasmussen --- e2e/src/api/specs/person.e2e-spec.ts | 39 ++++++++++++++++- .../openapi/lib/model/people_update_item.dart | 19 +++++++- .../openapi/lib/model/person_create_dto.dart | 19 +++++++- .../lib/model/person_response_dto.dart | 20 ++++++++- .../openapi/lib/model/person_update_dto.dart | 19 +++++++- .../model/person_with_faces_response_dto.dart | 20 ++++++++- open-api/immich-openapi-specs.json | 17 ++++++++ open-api/typescript-sdk/src/fetch-client.ts | 7 +++ server/src/db.d.ts | 17 +++++--- server/src/dtos/person.dto.ts | 6 +++ server/src/entities/person.entity.ts | 3 ++ .../1734879118272-AddIsFavoritePerson.ts | 14 ++++++ server/src/repositories/person.repository.ts | 1 + server/src/services/person.service.spec.ts | 43 +++++++++++++++++++ server/src/services/person.service.ts | 13 +++++- server/test/fixtures/person.stub.ts | 24 +++++++++++ .../components/faces-page/people-card.svelte | 31 +++++++++++-- web/src/routes/(user)/explore/+page.svelte | 9 +++- web/src/routes/(user)/people/+page.svelte | 20 +++++++++ .../[[assetId=id]]/+page.svelte | 42 ++++++++++++++---- 20 files changed, 355 insertions(+), 28 deletions(-) create mode 100644 server/src/migrations/1734879118272-AddIsFavoritePerson.ts diff --git a/e2e/src/api/specs/person.e2e-spec.ts b/e2e/src/api/specs/person.e2e-spec.ts index bb838bbae3..f3e0530696 100644 --- a/e2e/src/api/specs/person.e2e-spec.ts +++ b/e2e/src/api/specs/person.e2e-spec.ts @@ -1,7 +1,7 @@ -import { LoginResponseDto, PersonResponseDto } from '@immich/sdk'; +import { getPerson, LoginResponseDto, PersonResponseDto } from '@immich/sdk'; import { uuidDto } from 'src/fixtures'; import { errorDto } from 'src/responses'; -import { app, utils } from 'src/utils'; +import { app, asBearerAuth, utils } from 'src/utils'; import request from 'supertest'; import { beforeAll, beforeEach, describe, expect, it } from 'vitest'; @@ -203,6 +203,22 @@ describe('/people', () => { birthDate: '1990-01-01T00:00:00.000Z', }); }); + + it('should create a favorite person', async () => { + const { status, body } = await request(app) + .post(`/people`) + .set('Authorization', `Bearer ${admin.accessToken}`) + .send({ + name: 'New Favorite Person', + isFavorite: true, + }); + expect(status).toBe(201); + expect(body).toMatchObject({ + id: expect.any(String), + name: 'New Favorite Person', + isFavorite: true, + }); + }); }); describe('PUT /people/:id', () => { @@ -216,6 +232,7 @@ describe('/people', () => { { key: 'name', type: 'string' }, { key: 'featureFaceAssetId', type: 'string' }, { key: 'isHidden', type: 'boolean value' }, + { key: 'isFavorite', type: 'boolean value' }, ]) { it(`should not allow null ${key}`, async () => { const { status, body } = await request(app) @@ -255,6 +272,24 @@ describe('/people', () => { expect(status).toBe(200); expect(body).toMatchObject({ birthDate: null }); }); + + it('should mark a person as favorite', async () => { + const person = await utils.createPerson(admin.accessToken, { + name: 'visible_person', + }); + + expect(person.isFavorite).toBe(false); + + const { status, body } = await request(app) + .put(`/people/${person.id}`) + .set('Authorization', `Bearer ${admin.accessToken}`) + .send({ isFavorite: true }); + expect(status).toBe(200); + expect(body).toMatchObject({ isFavorite: true }); + + const person2 = await getPerson({ id: person.id }, { headers: asBearerAuth(admin.accessToken) }); + expect(person2).toMatchObject({ id: person.id, isFavorite: true }); + }); }); describe('POST /people/:id/merge', () => { diff --git a/mobile/openapi/lib/model/people_update_item.dart b/mobile/openapi/lib/model/people_update_item.dart index 042e4fa36f..6f8e312959 100644 --- a/mobile/openapi/lib/model/people_update_item.dart +++ b/mobile/openapi/lib/model/people_update_item.dart @@ -16,6 +16,7 @@ class PeopleUpdateItem { this.birthDate, this.featureFaceAssetId, required this.id, + this.isFavorite, this.isHidden, this.name, }); @@ -35,6 +36,14 @@ class PeopleUpdateItem { /// Person id. String id; + /// + /// 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? isFavorite; + /// Person visibility /// /// Please note: This property should have been non-nullable! Since the specification file @@ -58,6 +67,7 @@ class PeopleUpdateItem { other.birthDate == birthDate && other.featureFaceAssetId == featureFaceAssetId && other.id == id && + other.isFavorite == isFavorite && other.isHidden == isHidden && other.name == name; @@ -67,11 +77,12 @@ class PeopleUpdateItem { (birthDate == null ? 0 : birthDate!.hashCode) + (featureFaceAssetId == null ? 0 : featureFaceAssetId!.hashCode) + (id.hashCode) + + (isFavorite == null ? 0 : isFavorite!.hashCode) + (isHidden == null ? 0 : isHidden!.hashCode) + (name == null ? 0 : name!.hashCode); @override - String toString() => 'PeopleUpdateItem[birthDate=$birthDate, featureFaceAssetId=$featureFaceAssetId, id=$id, isHidden=$isHidden, name=$name]'; + String toString() => 'PeopleUpdateItem[birthDate=$birthDate, featureFaceAssetId=$featureFaceAssetId, id=$id, isFavorite=$isFavorite, isHidden=$isHidden, name=$name]'; Map toJson() { final json = {}; @@ -86,6 +97,11 @@ class PeopleUpdateItem { // json[r'featureFaceAssetId'] = null; } json[r'id'] = this.id; + if (this.isFavorite != null) { + json[r'isFavorite'] = this.isFavorite; + } else { + // json[r'isFavorite'] = null; + } if (this.isHidden != null) { json[r'isHidden'] = this.isHidden; } else { @@ -111,6 +127,7 @@ class PeopleUpdateItem { birthDate: mapDateTime(json, r'birthDate', r''), featureFaceAssetId: mapValueOfType(json, r'featureFaceAssetId'), id: mapValueOfType(json, r'id')!, + isFavorite: mapValueOfType(json, r'isFavorite'), isHidden: mapValueOfType(json, r'isHidden'), name: mapValueOfType(json, r'name'), ); diff --git a/mobile/openapi/lib/model/person_create_dto.dart b/mobile/openapi/lib/model/person_create_dto.dart index 36bd6dfee9..bc1d67c240 100644 --- a/mobile/openapi/lib/model/person_create_dto.dart +++ b/mobile/openapi/lib/model/person_create_dto.dart @@ -14,6 +14,7 @@ class PersonCreateDto { /// Returns a new [PersonCreateDto] instance. PersonCreateDto({ this.birthDate, + this.isFavorite, this.isHidden, this.name, }); @@ -21,6 +22,14 @@ class PersonCreateDto { /// Person date of birth. Note: the mobile app cannot currently set the birth date to null. DateTime? birthDate; + /// + /// 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? isFavorite; + /// Person visibility /// /// Please note: This property should have been non-nullable! Since the specification file @@ -42,6 +51,7 @@ class PersonCreateDto { @override bool operator ==(Object other) => identical(this, other) || other is PersonCreateDto && other.birthDate == birthDate && + other.isFavorite == isFavorite && other.isHidden == isHidden && other.name == name; @@ -49,11 +59,12 @@ class PersonCreateDto { int get hashCode => // ignore: unnecessary_parenthesis (birthDate == null ? 0 : birthDate!.hashCode) + + (isFavorite == null ? 0 : isFavorite!.hashCode) + (isHidden == null ? 0 : isHidden!.hashCode) + (name == null ? 0 : name!.hashCode); @override - String toString() => 'PersonCreateDto[birthDate=$birthDate, isHidden=$isHidden, name=$name]'; + String toString() => 'PersonCreateDto[birthDate=$birthDate, isFavorite=$isFavorite, isHidden=$isHidden, name=$name]'; Map toJson() { final json = {}; @@ -62,6 +73,11 @@ class PersonCreateDto { } else { // json[r'birthDate'] = null; } + if (this.isFavorite != null) { + json[r'isFavorite'] = this.isFavorite; + } else { + // json[r'isFavorite'] = null; + } if (this.isHidden != null) { json[r'isHidden'] = this.isHidden; } else { @@ -85,6 +101,7 @@ class PersonCreateDto { return PersonCreateDto( birthDate: mapDateTime(json, r'birthDate', r''), + isFavorite: mapValueOfType(json, r'isFavorite'), isHidden: mapValueOfType(json, r'isHidden'), name: mapValueOfType(json, r'name'), ); diff --git a/mobile/openapi/lib/model/person_response_dto.dart b/mobile/openapi/lib/model/person_response_dto.dart index 0b36fcde3b..1884459928 100644 --- a/mobile/openapi/lib/model/person_response_dto.dart +++ b/mobile/openapi/lib/model/person_response_dto.dart @@ -15,6 +15,7 @@ class PersonResponseDto { PersonResponseDto({ required this.birthDate, required this.id, + this.isFavorite, required this.isHidden, required this.name, required this.thumbnailPath, @@ -25,6 +26,15 @@ class PersonResponseDto { String id; + /// This property was added in v1.126.0 + /// + /// 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? isFavorite; + bool isHidden; String name; @@ -44,6 +54,7 @@ class PersonResponseDto { bool operator ==(Object other) => identical(this, other) || other is PersonResponseDto && other.birthDate == birthDate && other.id == id && + other.isFavorite == isFavorite && other.isHidden == isHidden && other.name == name && other.thumbnailPath == thumbnailPath && @@ -54,13 +65,14 @@ class PersonResponseDto { // ignore: unnecessary_parenthesis (birthDate == null ? 0 : birthDate!.hashCode) + (id.hashCode) + + (isFavorite == null ? 0 : isFavorite!.hashCode) + (isHidden.hashCode) + (name.hashCode) + (thumbnailPath.hashCode) + (updatedAt == null ? 0 : updatedAt!.hashCode); @override - String toString() => 'PersonResponseDto[birthDate=$birthDate, id=$id, isHidden=$isHidden, name=$name, thumbnailPath=$thumbnailPath, updatedAt=$updatedAt]'; + String toString() => 'PersonResponseDto[birthDate=$birthDate, id=$id, isFavorite=$isFavorite, isHidden=$isHidden, name=$name, thumbnailPath=$thumbnailPath, updatedAt=$updatedAt]'; Map toJson() { final json = {}; @@ -70,6 +82,11 @@ class PersonResponseDto { // json[r'birthDate'] = null; } json[r'id'] = this.id; + if (this.isFavorite != null) { + json[r'isFavorite'] = this.isFavorite; + } else { + // json[r'isFavorite'] = null; + } json[r'isHidden'] = this.isHidden; json[r'name'] = this.name; json[r'thumbnailPath'] = this.thumbnailPath; @@ -92,6 +109,7 @@ class PersonResponseDto { return PersonResponseDto( birthDate: mapDateTime(json, r'birthDate', r''), id: mapValueOfType(json, r'id')!, + isFavorite: mapValueOfType(json, r'isFavorite'), isHidden: mapValueOfType(json, r'isHidden')!, name: mapValueOfType(json, r'name')!, thumbnailPath: mapValueOfType(json, r'thumbnailPath')!, diff --git a/mobile/openapi/lib/model/person_update_dto.dart b/mobile/openapi/lib/model/person_update_dto.dart index 51a7ea25d0..cf0688a27f 100644 --- a/mobile/openapi/lib/model/person_update_dto.dart +++ b/mobile/openapi/lib/model/person_update_dto.dart @@ -15,6 +15,7 @@ class PersonUpdateDto { PersonUpdateDto({ this.birthDate, this.featureFaceAssetId, + this.isFavorite, this.isHidden, this.name, }); @@ -31,6 +32,14 @@ class PersonUpdateDto { /// String? featureFaceAssetId; + /// + /// 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? isFavorite; + /// Person visibility /// /// Please note: This property should have been non-nullable! Since the specification file @@ -53,6 +62,7 @@ class PersonUpdateDto { bool operator ==(Object other) => identical(this, other) || other is PersonUpdateDto && other.birthDate == birthDate && other.featureFaceAssetId == featureFaceAssetId && + other.isFavorite == isFavorite && other.isHidden == isHidden && other.name == name; @@ -61,11 +71,12 @@ class PersonUpdateDto { // ignore: unnecessary_parenthesis (birthDate == null ? 0 : birthDate!.hashCode) + (featureFaceAssetId == null ? 0 : featureFaceAssetId!.hashCode) + + (isFavorite == null ? 0 : isFavorite!.hashCode) + (isHidden == null ? 0 : isHidden!.hashCode) + (name == null ? 0 : name!.hashCode); @override - String toString() => 'PersonUpdateDto[birthDate=$birthDate, featureFaceAssetId=$featureFaceAssetId, isHidden=$isHidden, name=$name]'; + String toString() => 'PersonUpdateDto[birthDate=$birthDate, featureFaceAssetId=$featureFaceAssetId, isFavorite=$isFavorite, isHidden=$isHidden, name=$name]'; Map toJson() { final json = {}; @@ -79,6 +90,11 @@ class PersonUpdateDto { } else { // json[r'featureFaceAssetId'] = null; } + if (this.isFavorite != null) { + json[r'isFavorite'] = this.isFavorite; + } else { + // json[r'isFavorite'] = null; + } if (this.isHidden != null) { json[r'isHidden'] = this.isHidden; } else { @@ -103,6 +119,7 @@ class PersonUpdateDto { return PersonUpdateDto( birthDate: mapDateTime(json, r'birthDate', r''), featureFaceAssetId: mapValueOfType(json, r'featureFaceAssetId'), + isFavorite: mapValueOfType(json, r'isFavorite'), isHidden: mapValueOfType(json, r'isHidden'), name: mapValueOfType(json, r'name'), ); diff --git a/mobile/openapi/lib/model/person_with_faces_response_dto.dart b/mobile/openapi/lib/model/person_with_faces_response_dto.dart index b14bad7895..7d61db11f3 100644 --- a/mobile/openapi/lib/model/person_with_faces_response_dto.dart +++ b/mobile/openapi/lib/model/person_with_faces_response_dto.dart @@ -16,6 +16,7 @@ class PersonWithFacesResponseDto { required this.birthDate, this.faces = const [], required this.id, + this.isFavorite, required this.isHidden, required this.name, required this.thumbnailPath, @@ -28,6 +29,15 @@ class PersonWithFacesResponseDto { String id; + /// This property was added in v1.126.0 + /// + /// 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? isFavorite; + bool isHidden; String name; @@ -48,6 +58,7 @@ class PersonWithFacesResponseDto { other.birthDate == birthDate && _deepEquality.equals(other.faces, faces) && other.id == id && + other.isFavorite == isFavorite && other.isHidden == isHidden && other.name == name && other.thumbnailPath == thumbnailPath && @@ -59,13 +70,14 @@ class PersonWithFacesResponseDto { (birthDate == null ? 0 : birthDate!.hashCode) + (faces.hashCode) + (id.hashCode) + + (isFavorite == null ? 0 : isFavorite!.hashCode) + (isHidden.hashCode) + (name.hashCode) + (thumbnailPath.hashCode) + (updatedAt == null ? 0 : updatedAt!.hashCode); @override - String toString() => 'PersonWithFacesResponseDto[birthDate=$birthDate, faces=$faces, id=$id, isHidden=$isHidden, name=$name, thumbnailPath=$thumbnailPath, updatedAt=$updatedAt]'; + String toString() => 'PersonWithFacesResponseDto[birthDate=$birthDate, faces=$faces, id=$id, isFavorite=$isFavorite, isHidden=$isHidden, name=$name, thumbnailPath=$thumbnailPath, updatedAt=$updatedAt]'; Map toJson() { final json = {}; @@ -76,6 +88,11 @@ class PersonWithFacesResponseDto { } json[r'faces'] = this.faces; json[r'id'] = this.id; + if (this.isFavorite != null) { + json[r'isFavorite'] = this.isFavorite; + } else { + // json[r'isFavorite'] = null; + } json[r'isHidden'] = this.isHidden; json[r'name'] = this.name; json[r'thumbnailPath'] = this.thumbnailPath; @@ -99,6 +116,7 @@ class PersonWithFacesResponseDto { birthDate: mapDateTime(json, r'birthDate', r''), faces: AssetFaceWithoutPersonResponseDto.listFromJson(json[r'faces']), id: mapValueOfType(json, r'id')!, + isFavorite: mapValueOfType(json, r'isFavorite'), isHidden: mapValueOfType(json, r'isHidden')!, name: mapValueOfType(json, r'name')!, thumbnailPath: mapValueOfType(json, r'thumbnailPath')!, diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index 85dc55aed8..f1ef466df4 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -10294,6 +10294,9 @@ "description": "Person id.", "type": "string" }, + "isFavorite": { + "type": "boolean" + }, "isHidden": { "description": "Person visibility", "type": "boolean" @@ -10399,6 +10402,9 @@ "nullable": true, "type": "string" }, + "isFavorite": { + "type": "boolean" + }, "isHidden": { "description": "Person visibility", "type": "boolean" @@ -10420,6 +10426,10 @@ "id": { "type": "string" }, + "isFavorite": { + "description": "This property was added in v1.126.0", + "type": "boolean" + }, "isHidden": { "type": "boolean" }, @@ -10467,6 +10477,9 @@ "description": "Asset is used to get the feature face thumbnail.", "type": "string" }, + "isFavorite": { + "type": "boolean" + }, "isHidden": { "description": "Person visibility", "type": "boolean" @@ -10494,6 +10507,10 @@ "id": { "type": "string" }, + "isFavorite": { + "description": "This property was added in v1.126.0", + "type": "boolean" + }, "isHidden": { "type": "boolean" }, diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index 32cca2dbce..6704a83cc7 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -215,6 +215,8 @@ export type PersonWithFacesResponseDto = { birthDate: string | null; faces: AssetFaceWithoutPersonResponseDto[]; id: string; + /** This property was added in v1.126.0 */ + isFavorite?: boolean; isHidden: boolean; name: string; thumbnailPath: string; @@ -492,6 +494,8 @@ export type DuplicateResponseDto = { export type PersonResponseDto = { birthDate: string | null; id: string; + /** This property was added in v1.126.0 */ + isFavorite?: boolean; isHidden: boolean; name: string; thumbnailPath: string; @@ -689,6 +693,7 @@ export type PersonCreateDto = { /** Person date of birth. Note: the mobile app cannot currently set the birth date to null. */ birthDate?: string | null; + isFavorite?: boolean; /** Person visibility */ isHidden?: boolean; /** Person name. */ @@ -702,6 +707,7 @@ export type PeopleUpdateItem = { featureFaceAssetId?: string; /** Person id. */ id: string; + isFavorite?: boolean; /** Person visibility */ isHidden?: boolean; /** Person name. */ @@ -716,6 +722,7 @@ export type PersonUpdateDto = { birthDate?: string | null; /** Asset is used to get the feature face thumbnail. */ featureFaceAssetId?: string; + isFavorite?: boolean; /** Person visibility */ isHidden?: boolean; /** Person name. */ diff --git a/server/src/db.d.ts b/server/src/db.d.ts index 6242914bee..16f73c53e7 100644 --- a/server/src/db.d.ts +++ b/server/src/db.d.ts @@ -279,6 +279,7 @@ export interface Person { createdAt: Generated; faceAssetId: string | null; id: Generated; + isFavorite: Generated; isHidden: Generated; name: Generated; ownerId: string; @@ -327,11 +328,6 @@ export interface SocketIoAttachments { payload: Buffer | null; } -export interface SystemConfig { - key: string; - value: string | null; -} - export interface SystemMetadata { key: string; value: Json; @@ -357,6 +353,15 @@ export interface TagsClosure { id_descendant: string; } +export interface TypeormMetadata { + database: string | null; + name: string | null; + schema: string | null; + table: string | null; + type: string; + value: string | null; +} + export interface UserMetadata { key: string; userId: string; @@ -431,11 +436,11 @@ export interface DB { shared_links: SharedLinks; smart_search: SmartSearch; socket_io_attachments: SocketIoAttachments; - system_config: SystemConfig; system_metadata: SystemMetadata; tag_asset: TagAsset; tags: Tags; tags_closure: TagsClosure; + typeorm_metadata: TypeormMetadata; user_metadata: UserMetadata; users: Users; "vectors.pg_vector_index_stat": VectorsPgVectorIndexStat; diff --git a/server/src/dtos/person.dto.ts b/server/src/dtos/person.dto.ts index 047ef600b8..8bf041be37 100644 --- a/server/src/dtos/person.dto.ts +++ b/server/src/dtos/person.dto.ts @@ -32,6 +32,9 @@ export class PersonCreateDto { */ @ValidateBoolean({ optional: true }) isHidden?: boolean; + + @ValidateBoolean({ optional: true }) + isFavorite?: boolean; } export class PersonUpdateDto extends PersonCreateDto { @@ -97,6 +100,8 @@ export class PersonResponseDto { isHidden!: boolean; @PropertyLifecycle({ addedAt: 'v1.107.0' }) updatedAt?: Date; + @PropertyLifecycle({ addedAt: 'v1.126.0' }) + isFavorite?: boolean; } export class PersonWithFacesResponseDto extends PersonResponseDto { @@ -170,6 +175,7 @@ export function mapPerson(person: PersonEntity): PersonResponseDto { birthDate: person.birthDate, thumbnailPath: person.thumbnailPath, isHidden: person.isHidden, + isFavorite: person.isFavorite, updatedAt: person.updatedAt, }; } diff --git a/server/src/entities/person.entity.ts b/server/src/entities/person.entity.ts index 5efbcbfa0b..8cf416b766 100644 --- a/server/src/entities/person.entity.ts +++ b/server/src/entities/person.entity.ts @@ -49,4 +49,7 @@ export class PersonEntity { @Column({ default: false }) isHidden!: boolean; + + @Column({ default: false }) + isFavorite!: boolean; } diff --git a/server/src/migrations/1734879118272-AddIsFavoritePerson.ts b/server/src/migrations/1734879118272-AddIsFavoritePerson.ts new file mode 100644 index 0000000000..6f7640f96f --- /dev/null +++ b/server/src/migrations/1734879118272-AddIsFavoritePerson.ts @@ -0,0 +1,14 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class AddIsFavoritePerson1734879118272 implements MigrationInterface { + name = 'AddIsFavoritePerson1734879118272' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "person" ADD "isFavorite" boolean NOT NULL DEFAULT false`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "person" DROP COLUMN "isFavorite"`); + } + +} diff --git a/server/src/repositories/person.repository.ts b/server/src/repositories/person.repository.ts index 7c2512aa26..73fb8313d2 100644 --- a/server/src/repositories/person.repository.ts +++ b/server/src/repositories/person.repository.ts @@ -132,6 +132,7 @@ export class PersonRepository implements IPersonRepository { ) .where('person.ownerId', '=', userId) .orderBy('person.isHidden', 'asc') + .orderBy('person.isFavorite', 'desc') .having((eb) => eb.or([ eb('person.name', '!=', ''), diff --git a/server/src/services/person.service.spec.ts b/server/src/services/person.service.spec.ts index dc9d7a9329..5407821fab 100644 --- a/server/src/services/person.service.spec.ts +++ b/server/src/services/person.service.spec.ts @@ -30,6 +30,7 @@ const responseDto: PersonResponseDto = { thumbnailPath: '/path/to/thumbnail.jpg', isHidden: false, updatedAt: expect.any(Date), + isFavorite: false, }; const statistics = { assets: 3 }; @@ -116,6 +117,7 @@ describe(PersonService.name, () => { birthDate: null, thumbnailPath: '/path/to/thumbnail.jpg', isHidden: true, + isFavorite: false, updatedAt: expect.any(Date), }, ], @@ -125,6 +127,35 @@ describe(PersonService.name, () => { withHidden: true, }); }); + + it('should get all visible people and favorites should be first in the array', async () => { + personMock.getAllForUser.mockResolvedValue({ + items: [personStub.isFavorite, personStub.withName], + hasNextPage: false, + }); + personMock.getNumberOfPeople.mockResolvedValue({ total: 2, hidden: 1 }); + await expect(sut.getAll(authStub.admin, { withHidden: false, page: 1, size: 10 })).resolves.toEqual({ + hasNextPage: false, + total: 2, + hidden: 1, + people: [ + { + id: 'person-4', + name: personStub.isFavorite.name, + birthDate: null, + thumbnailPath: '/path/to/thumbnail.jpg', + isHidden: false, + isFavorite: true, + updatedAt: expect.any(Date), + }, + responseDto, + ], + }); + expect(personMock.getAllForUser).toHaveBeenCalledWith({ skip: 0, take: 10 }, authStub.admin.user.id, { + minimumFaceCount: 3, + withHidden: false, + }); + }); }); describe('getById', () => { @@ -227,6 +258,7 @@ describe(PersonService.name, () => { birthDate: '1976-06-30', thumbnailPath: '/path/to/thumbnail.jpg', isHidden: false, + isFavorite: false, updatedAt: expect.any(Date), }); expect(personMock.update).toHaveBeenCalledWith({ id: 'person-1', birthDate: '1976-06-30' }); @@ -245,6 +277,16 @@ describe(PersonService.name, () => { expect(accessMock.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['person-1'])); }); + it('should update a person favorite status', async () => { + personMock.update.mockResolvedValue(personStub.withName); + accessMock.person.checkOwnerAccess.mockResolvedValue(new Set(['person-1'])); + + await expect(sut.update(authStub.admin, 'person-1', { isFavorite: true })).resolves.toEqual(responseDto); + + expect(personMock.update).toHaveBeenCalledWith({ id: 'person-1', isFavorite: true }); + expect(accessMock.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['person-1'])); + }); + it("should update a person's thumbnailPath", async () => { personMock.update.mockResolvedValue(personStub.withName); personMock.getFacesByIds.mockResolvedValue([faceStub.face1]); @@ -375,6 +417,7 @@ describe(PersonService.name, () => { ).resolves.toEqual({ birthDate: personStub.noName.birthDate, isHidden: personStub.noName.isHidden, + isFavorite: personStub.noName.isFavorite, id: personStub.noName.id, name: personStub.noName.name, thumbnailPath: personStub.noName.thumbnailPath, diff --git a/server/src/services/person.service.ts b/server/src/services/person.service.ts index bcc65cfad3..2f4a6bb0d1 100644 --- a/server/src/services/person.service.ts +++ b/server/src/services/person.service.ts @@ -184,13 +184,14 @@ export class PersonService extends BaseService { name: dto.name, birthDate: dto.birthDate, isHidden: dto.isHidden, + isFavorite: dto.isFavorite, }); } async update(auth: AuthDto, id: string, dto: PersonUpdateDto): Promise { await this.requireAccess({ auth, permission: Permission.PERSON_UPDATE, ids: [id] }); - const { name, birthDate, isHidden, featureFaceAssetId: assetId } = dto; + const { name, birthDate, isHidden, featureFaceAssetId: assetId, isFavorite } = dto; // TODO: set by faceId directly let faceId: string | undefined = undefined; if (assetId) { @@ -203,7 +204,14 @@ export class PersonService extends BaseService { faceId = face.id; } - const person = await this.personRepository.update({ id, faceAssetId: faceId, name, birthDate, isHidden }); + const person = await this.personRepository.update({ + id, + faceAssetId: faceId, + name, + birthDate, + isHidden, + isFavorite, + }); if (assetId) { await this.jobRepository.queue({ name: JobName.GENERATE_PERSON_THUMBNAIL, data: { id } }); @@ -221,6 +229,7 @@ export class PersonService extends BaseService { name: person.name, birthDate: person.birthDate, featureFaceAssetId: person.featureFaceAssetId, + isFavorite: person.isFavorite, }); results.push({ id: person.id, success: true }); } catch (error: Error | any) { diff --git a/server/test/fixtures/person.stub.ts b/server/test/fixtures/person.stub.ts index 544894b31e..ecd5b0dbea 100644 --- a/server/test/fixtures/person.stub.ts +++ b/server/test/fixtures/person.stub.ts @@ -15,6 +15,7 @@ export const personStub = { faceAssetId: null, faceAsset: null, isHidden: false, + isFavorite: false, }), hidden: Object.freeze({ id: 'person-1', @@ -29,6 +30,7 @@ export const personStub = { faceAssetId: null, faceAsset: null, isHidden: true, + isFavorite: false, }), withName: Object.freeze({ id: 'person-1', @@ -43,6 +45,7 @@ export const personStub = { faceAssetId: 'assetFaceId', faceAsset: null, isHidden: false, + isFavorite: false, }), withBirthDate: Object.freeze({ id: 'person-1', @@ -57,6 +60,7 @@ export const personStub = { faceAssetId: null, faceAsset: null, isHidden: false, + isFavorite: false, }), noThumbnail: Object.freeze({ id: 'person-1', @@ -71,6 +75,7 @@ export const personStub = { faceAssetId: null, faceAsset: null, isHidden: false, + isFavorite: false, }), newThumbnail: Object.freeze({ id: 'person-1', @@ -85,6 +90,7 @@ export const personStub = { faceAssetId: 'asset-id', faceAsset: null, isHidden: false, + isFavorite: false, }), primaryPerson: Object.freeze({ id: 'person-1', @@ -99,6 +105,7 @@ export const personStub = { faceAssetId: null, faceAsset: null, isHidden: false, + isFavorite: false, }), mergePerson: Object.freeze({ id: 'person-2', @@ -113,6 +120,7 @@ export const personStub = { faceAssetId: null, faceAsset: null, isHidden: false, + isFavorite: false, }), randomPerson: Object.freeze({ id: 'person-3', @@ -127,5 +135,21 @@ export const personStub = { faceAssetId: null, faceAsset: null, isHidden: false, + isFavorite: false, + }), + isFavorite: Object.freeze({ + id: 'person-4', + createdAt: new Date('2021-01-01'), + updatedAt: new Date('2021-01-01'), + ownerId: userStub.admin.id, + owner: userStub.admin, + name: 'Person 1', + birthDate: null, + thumbnailPath: '/path/to/thumbnail.jpg', + faces: [], + faceAssetId: 'assetFaceId', + faceAsset: null, + isHidden: false, + isFavorite: true, }), }; diff --git a/web/src/lib/components/faces-page/people-card.svelte b/web/src/lib/components/faces-page/people-card.svelte index a83d1180f9..494dd94666 100644 --- a/web/src/lib/components/faces-page/people-card.svelte +++ b/web/src/lib/components/faces-page/people-card.svelte @@ -1,4 +1,7 @@ @@ -51,6 +64,11 @@ title={person.name} widthStyle="100%" /> + {#if person.isFavorite} +
+ +
+ {/if}
{#if person.name} + {/if} diff --git a/web/src/routes/(user)/explore/+page.svelte b/web/src/routes/(user)/explore/+page.svelte index fef6a29b85..40a02f7425 100644 --- a/web/src/routes/(user)/explore/+page.svelte +++ b/web/src/routes/(user)/explore/+page.svelte @@ -11,6 +11,8 @@ import { onMount } from 'svelte'; import { websocketEvents } from '$lib/stores/websocket'; import SingleGridRow from '$lib/components/shared-components/single-grid-row.svelte'; + import Icon from '$lib/components/elements/icon.svelte'; + import { mdiHeart } from '@mdi/js'; interface Props { data: PageData; @@ -53,7 +55,7 @@ {#snippet children({ itemCount })} {#each people.slice(0, itemCount) as person (person.id)} - + + {#if person.isFavorite} +
+ +
+ {/if}

{person.name}

{/each} diff --git a/web/src/routes/(user)/people/+page.svelte b/web/src/routes/(user)/people/+page.svelte index cdfd0045c6..f3ea5d8638 100644 --- a/web/src/routes/(user)/people/+page.svelte +++ b/web/src/routes/(user)/people/+page.svelte @@ -222,6 +222,25 @@ } }; + const handleToggleFavorite = async (detail: PersonResponseDto) => { + try { + const updatedPerson = await updatePerson({ + id: detail.id, + personUpdateDto: { isFavorite: !detail.isFavorite }, + }); + + const index = people.findIndex((person) => person.id === detail.id); + people[index] = updatedPerson; + + notificationController.show({ + message: updatedPerson.isFavorite ? $t('added_to_favorites') : $t('removed_from_favorites'), + type: NotificationType.Info, + }); + } catch (error) { + handleError(error, $t('errors.unable_to_add_remove_favorites', { values: { favorite: detail.isFavorite } })); + } + }; + const handleMergePeople = async (detail: PersonResponseDto) => { await goto( `${AppRoute.PEOPLE}/${detail.id}?${QueryParameter.ACTION}=${ActionQueryParameterValue.MERGE}&${QueryParameter.PREVIOUS_ROUTE}=${AppRoute.PEOPLE}`, @@ -413,6 +432,7 @@ onSetBirthDate={() => handleSetBirthDate(person)} onMergePeople={() => handleMergePeople(person)} onHidePerson={() => handleHidePerson(person)} + onToggleFavorite={() => handleToggleFavorite(person)} /> {/snippet} diff --git a/web/src/routes/(user)/people/[personId]/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/people/[personId]/[[photos=photos]]/[[assetId=id]]/+page.svelte index 00a5284452..edaf33487c 100644 --- a/web/src/routes/(user)/people/[personId]/[[photos=photos]]/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/people/[personId]/[[photos=photos]]/[[assetId=id]]/+page.svelte @@ -1,6 +1,8 @@ -
+
+
+
+{/if} + +
+ {#if !isCollapsed} +
+ {#each places as item} + {@const city = item.exifInfo?.city} + +
+ {city} +
+ + {city} + +
+ {/each} +
+ {/if} +
diff --git a/web/src/lib/components/places-page/places-controls.svelte b/web/src/lib/components/places-page/places-controls.svelte new file mode 100644 index 0000000000..12f6171528 --- /dev/null +++ b/web/src/lib/components/places-page/places-controls.svelte @@ -0,0 +1,93 @@ + + + + + + + ({ + title: placesGroupByNames[id], + icon: groupIcon, + disabled: isDisabled(), + })} +/> + +{#if getSelectedPlacesGroupOption($placesViewSettings) !== PlacesGroupBy.None} + + + + +{/if} diff --git a/web/src/lib/components/places-page/places-list.svelte b/web/src/lib/components/places-page/places-list.svelte new file mode 100644 index 0000000000..27eea3c5a8 --- /dev/null +++ b/web/src/lib/components/places-page/places-list.svelte @@ -0,0 +1,121 @@ + + +{#if hasPlaces} + + {#if placesGroupOption === PlacesGroupBy.None} + + {:else} + {#each groupedPlaces as placeGroup (placeGroup.id)} + + {/each} + {/if} +{:else} +
+
+ +

{$t('no_places')}

+
+
+{/if} diff --git a/web/src/lib/stores/preferences.store.ts b/web/src/lib/stores/preferences.store.ts index 1ec0853dc0..818800755c 100644 --- a/web/src/lib/stores/preferences.store.ts +++ b/web/src/lib/stores/preferences.store.ts @@ -101,6 +101,14 @@ export interface AlbumViewSettings { }; } +export interface PlacesViewSettings { + groupBy: string; + collapsedGroups: { + // Grouping Option => Array + [group: string]: string[]; + }; +} + export interface SidebarSettings { people: boolean; sharing: boolean; @@ -147,6 +155,16 @@ export const albumViewSettings = persisted('album-view-settin collapsedGroups: {}, }); +export enum PlacesGroupBy { + None = 'None', + Country = 'Country', +} + +export const placesViewSettings = persisted('places-view-settings', { + groupBy: PlacesGroupBy.None, + collapsedGroups: {}, +}); + export const showDeleteModal = persisted('delete-confirm-dialog', true, {}); export const alwaysLoadOriginalFile = persisted('always-load-original-file', false, {}); diff --git a/web/src/lib/utils/places-utils.ts b/web/src/lib/utils/places-utils.ts new file mode 100644 index 0000000000..625f42d147 --- /dev/null +++ b/web/src/lib/utils/places-utils.ts @@ -0,0 +1,95 @@ +import { PlacesGroupBy, placesViewSettings, type PlacesViewSettings } from '$lib/stores/preferences.store'; +import { type AssetResponseDto } from '@immich/sdk'; +import { get } from 'svelte/store'; + +/** + * -------------- + * Places Grouping + * -------------- + */ +export interface PlacesGroup { + id: string; + name: string; + places: AssetResponseDto[]; +} + +export interface PlacesGroupOptionMetadata { + id: PlacesGroupBy; + isDisabled: () => boolean; +} + +export const groupOptionsMetadata: PlacesGroupOptionMetadata[] = [ + { + id: PlacesGroupBy.None, + isDisabled: () => false, + }, + { + id: PlacesGroupBy.Country, + isDisabled: () => false, + }, +]; + +export const findGroupOptionMetadata = (groupBy: string) => { + // Default is no grouping + const defaultGroupOption = groupOptionsMetadata[0]; + return groupOptionsMetadata.find(({ id }) => groupBy === id) ?? defaultGroupOption; +}; + +export const getSelectedPlacesGroupOption = (settings: PlacesViewSettings) => { + const defaultGroupOption = PlacesGroupBy.None; + const albumGroupOption = settings.groupBy ?? defaultGroupOption; + + if (findGroupOptionMetadata(albumGroupOption).isDisabled()) { + return defaultGroupOption; + } + return albumGroupOption; +}; + +/** + * ---------------------------- + * Places Groups Collapse/Expand + * ---------------------------- + */ +const getCollapsedPlacesGroups = (settings: PlacesViewSettings) => { + settings.collapsedGroups ??= {}; + const { collapsedGroups, groupBy } = settings; + collapsedGroups[groupBy] ??= []; + return collapsedGroups[groupBy]; +}; + +export const isPlacesGroupCollapsed = (settings: PlacesViewSettings, groupId: string) => { + if (settings.groupBy === PlacesGroupBy.None) { + return false; + } + return getCollapsedPlacesGroups(settings).includes(groupId); +}; + +export const togglePlacesGroupCollapsing = (groupId: string) => { + const settings = get(placesViewSettings); + if (settings.groupBy === PlacesGroupBy.None) { + return; + } + const collapsedGroups = getCollapsedPlacesGroups(settings); + const groupIndex = collapsedGroups.indexOf(groupId); + if (groupIndex === -1) { + // Collapse + collapsedGroups.push(groupId); + } else { + // Expand + collapsedGroups.splice(groupIndex, 1); + } + placesViewSettings.set(settings); +}; + +export const collapseAllPlacesGroups = (groupIds: string[]) => { + placesViewSettings.update((settings) => { + const collapsedGroups = getCollapsedPlacesGroups(settings); + collapsedGroups.length = 0; + collapsedGroups.push(...groupIds); + return settings; + }); +}; + +export const expandAllPlacesGroups = () => { + collapseAllPlacesGroups([]); +}; diff --git a/web/src/routes/(user)/places/+page.svelte b/web/src/routes/(user)/places/+page.svelte index 1808755482..636f28013f 100644 --- a/web/src/routes/(user)/places/+page.svelte +++ b/web/src/routes/(user)/places/+page.svelte @@ -1,13 +1,12 @@ - - {#if hasPlaces} -
- {#each places as item (item.id)} - {@const city = item.exifInfo.city} - -
- {city} -
- - {city} - -
- {/each} + + {#snippet buttons()} +
+
- {:else} -
-
- -

{$t('no_places')}

-
-
- {/if} + {/snippet} + +
From ef6c2bf54713050d5c155f8964315b1816d2d817 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Thu, 6 Feb 2025 15:59:29 -0500 Subject: [PATCH 094/395] chore(deps): update base-image to v20250204 (major) (#15931) chore(deps): update base-image to v20250204 Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- server/Dockerfile | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/server/Dockerfile b/server/Dockerfile index 20728b7693..0de8c96e22 100644 --- a/server/Dockerfile +++ b/server/Dockerfile @@ -1,5 +1,5 @@ # dev build -FROM ghcr.io/immich-app/base-server-dev:20250128@sha256:6427b6feb98defc654e595b3b0b4faba99989b8120b6f9e796368daac793c05d AS dev +FROM ghcr.io/immich-app/base-server-dev:20250204@sha256:8b203f19f4d5cf4619b60ee5f50d6d4b5ea3745747f5e5170d1b7404ebeb0792 AS dev RUN apt-get install --no-install-recommends -yqq tini WORKDIR /usr/src/app @@ -42,7 +42,7 @@ RUN npm run build # prod build -FROM ghcr.io/immich-app/base-server-prod:20250128@sha256:3c8595675a725a76169aa30fea8c7f512cbc57970d5006f25c4e96525fc8d347 +FROM ghcr.io/immich-app/base-server-prod:20250204@sha256:2af3da713d5ab3ccca23b216b747557ea6016117e72deac101e35069ccaf9b5e WORKDIR /usr/src/app ENV NODE_ENV=production \ From 252d3f5f2ccc1b982e7e55c2579e77097870558c Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Thu, 6 Feb 2025 15:59:47 -0500 Subject: [PATCH 095/395] chore(deps): update grafana/grafana docker tag to v11.5.0 (#15930) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- docker/docker-compose.prod.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/docker-compose.prod.yml b/docker/docker-compose.prod.yml index d0209ed285..66a84e693b 100644 --- a/docker/docker-compose.prod.yml +++ b/docker/docker-compose.prod.yml @@ -103,7 +103,7 @@ services: command: ['./run.sh', '-disable-reporting'] ports: - 3000:3000 - image: grafana/grafana:11.4.0-ubuntu@sha256:afccec22ba0e4815cca1d2bf3836e414322390dc78d77f1851976ffa8d61051c + image: grafana/grafana:11.5.0-ubuntu@sha256:3c9e2b202eb933a22da5f2b5a22c98a665493f603b452263d9d6f242a87f60d7 volumes: - grafana-data:/var/lib/grafana From c4531fc4d372318d7ef1fb01e362b7a23b2af1ea Mon Sep 17 00:00:00 2001 From: Nicholas Flamy <30300649+NicholasFlamy@users.noreply.github.com> Date: Thu, 6 Feb 2025 16:00:52 -0500 Subject: [PATCH 096/395] fix(docs): show version selection dropdown on mobile (#15894) change-className-and-add-css-to-show-versions-on-mobile --- docs/src/components/version-switcher.tsx | 2 +- docs/src/css/custom.css | 5 +++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/docs/src/components/version-switcher.tsx b/docs/src/components/version-switcher.tsx index 56fcd4f569..5f7e1c807f 100644 --- a/docs/src/components/version-switcher.tsx +++ b/docs/src/components/version-switcher.tsx @@ -44,7 +44,7 @@ export default function VersionSwitcher(): JSX.Element { return ( versions.length > 0 && ( ({ diff --git a/docs/src/css/custom.css b/docs/src/css/custom.css index dc3ff4e9ef..fd3f199ce8 100644 --- a/docs/src/css/custom.css +++ b/docs/src/css/custom.css @@ -75,6 +75,11 @@ div[class^='announcementBar_'] { font-weight: 500; } +/* workaround for version switcher PR 15894 */ +div[class*='navbar__items'] > li:has(a[class*='version-switcher-34ab39']) { + display: none; +} + code { font-weight: 600; } From 2e5007adef106e55d7368726e9ec381449ddfe39 Mon Sep 17 00:00:00 2001 From: Mert <101130780+mertalev@users.noreply.github.com> Date: Fri, 7 Feb 2025 14:44:22 +0300 Subject: [PATCH 097/395] docs: soften wording for openvino igpu (#15941) --- docs/docs/features/ml-hardware-acceleration.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/docs/features/ml-hardware-acceleration.md b/docs/docs/features/ml-hardware-acceleration.md index fdf6149ed9..c71c503ace 100644 --- a/docs/docs/features/ml-hardware-acceleration.md +++ b/docs/docs/features/ml-hardware-acceleration.md @@ -11,7 +11,7 @@ You do not need to redo any machine learning jobs after enabling hardware accele - ARM NN (Mali) - CUDA (NVIDIA GPUs with [compute capability](https://developer.nvidia.com/cuda-gpus) 5.2 or higher) -- OpenVINO (Intel discrete GPUs such as Iris Xe and Arc) +- OpenVINO (Intel GPUs such as Iris Xe and Arc) ## Limitations @@ -43,8 +43,9 @@ You do not need to redo any machine learning jobs after enabling hardware accele #### OpenVINO -- The server must have a discrete GPU, i.e. Iris Xe or Arc. Expect issues when attempting to use integrated graphics. +- Integrated GPUs are more likely to experience issues than discrete GPUs, especially for older processors or servers with low RAM. - Ensure the server's kernel version is new enough to use the device for hardware accceleration. +- Expect higher RAM usage when using OpenVINO compared to CPU processing. ## Setup From 23014c263b7ded7828c721fd36bfdcf3160fa847 Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Fri, 7 Feb 2025 10:06:58 -0500 Subject: [PATCH 098/395] feat(api): set person color (#15937) --- e2e/src/api/specs/person.e2e-spec.ts | 19 +++++++++++++++++ .../openapi/lib/model/people_update_item.dart | 13 +++++++++++- .../openapi/lib/model/person_create_dto.dart | 13 +++++++++++- .../lib/model/person_response_dto.dart | 20 +++++++++++++++++- .../openapi/lib/model/person_update_dto.dart | 13 +++++++++++- .../model/person_with_faces_response_dto.dart | 20 +++++++++++++++++- open-api/immich-openapi-specs.json | 21 ++++++++++++++++++- open-api/typescript-sdk/src/fetch-client.ts | 7 +++++++ server/src/db.d.ts | 1 + server/src/dtos/person.dto.ts | 16 +++++++++++++- server/src/dtos/tag.dto.ts | 8 +++---- server/src/entities/person.entity.ts | 3 +++ .../1738889177573-AddPersonColor.ts | 14 +++++++++++++ server/src/services/person.service.spec.ts | 4 ++-- server/src/services/person.service.ts | 12 +++++++---- server/src/services/search.service.spec.ts | 2 ++ server/src/services/search.service.ts | 7 ++++--- server/src/validation.ts | 10 +++++++++ 18 files changed, 182 insertions(+), 21 deletions(-) create mode 100644 server/src/migrations/1738889177573-AddPersonColor.ts diff --git a/e2e/src/api/specs/person.e2e-spec.ts b/e2e/src/api/specs/person.e2e-spec.ts index f3e0530696..1a589da1f7 100644 --- a/e2e/src/api/specs/person.e2e-spec.ts +++ b/e2e/src/api/specs/person.e2e-spec.ts @@ -195,6 +195,7 @@ describe('/people', () => { .send({ name: 'New Person', birthDate: '1990-01-01', + color: '#333', }); expect(status).toBe(201); expect(body).toMatchObject({ @@ -273,6 +274,24 @@ describe('/people', () => { expect(body).toMatchObject({ birthDate: null }); }); + it('should set a color', async () => { + const { status, body } = await request(app) + .put(`/people/${visiblePerson.id}`) + .set('Authorization', `Bearer ${admin.accessToken}`) + .send({ color: '#555' }); + expect(status).toBe(200); + expect(body).toMatchObject({ color: '#555' }); + }); + + it('should clear a color', async () => { + const { status, body } = await request(app) + .put(`/people/${visiblePerson.id}`) + .set('Authorization', `Bearer ${admin.accessToken}`) + .send({ color: null }); + expect(status).toBe(200); + expect(body.color).toBeUndefined(); + }); + it('should mark a person as favorite', async () => { const person = await utils.createPerson(admin.accessToken, { name: 'visible_person', diff --git a/mobile/openapi/lib/model/people_update_item.dart b/mobile/openapi/lib/model/people_update_item.dart index 6f8e312959..ce324b859e 100644 --- a/mobile/openapi/lib/model/people_update_item.dart +++ b/mobile/openapi/lib/model/people_update_item.dart @@ -14,6 +14,7 @@ class PeopleUpdateItem { /// Returns a new [PeopleUpdateItem] instance. PeopleUpdateItem({ this.birthDate, + this.color, this.featureFaceAssetId, required this.id, this.isFavorite, @@ -24,6 +25,8 @@ class PeopleUpdateItem { /// Person date of birth. Note: the mobile app cannot currently set the birth date to null. DateTime? birthDate; + String? color; + /// Asset is used to get the feature face thumbnail. /// /// Please note: This property should have been non-nullable! Since the specification file @@ -65,6 +68,7 @@ class PeopleUpdateItem { @override bool operator ==(Object other) => identical(this, other) || other is PeopleUpdateItem && other.birthDate == birthDate && + other.color == color && other.featureFaceAssetId == featureFaceAssetId && other.id == id && other.isFavorite == isFavorite && @@ -75,6 +79,7 @@ class PeopleUpdateItem { int get hashCode => // ignore: unnecessary_parenthesis (birthDate == null ? 0 : birthDate!.hashCode) + + (color == null ? 0 : color!.hashCode) + (featureFaceAssetId == null ? 0 : featureFaceAssetId!.hashCode) + (id.hashCode) + (isFavorite == null ? 0 : isFavorite!.hashCode) + @@ -82,7 +87,7 @@ class PeopleUpdateItem { (name == null ? 0 : name!.hashCode); @override - String toString() => 'PeopleUpdateItem[birthDate=$birthDate, featureFaceAssetId=$featureFaceAssetId, id=$id, isFavorite=$isFavorite, isHidden=$isHidden, name=$name]'; + String toString() => 'PeopleUpdateItem[birthDate=$birthDate, color=$color, featureFaceAssetId=$featureFaceAssetId, id=$id, isFavorite=$isFavorite, isHidden=$isHidden, name=$name]'; Map toJson() { final json = {}; @@ -91,6 +96,11 @@ class PeopleUpdateItem { } else { // json[r'birthDate'] = null; } + if (this.color != null) { + json[r'color'] = this.color; + } else { + // json[r'color'] = null; + } if (this.featureFaceAssetId != null) { json[r'featureFaceAssetId'] = this.featureFaceAssetId; } else { @@ -125,6 +135,7 @@ class PeopleUpdateItem { return PeopleUpdateItem( birthDate: mapDateTime(json, r'birthDate', r''), + color: mapValueOfType(json, r'color'), featureFaceAssetId: mapValueOfType(json, r'featureFaceAssetId'), id: mapValueOfType(json, r'id')!, isFavorite: mapValueOfType(json, r'isFavorite'), diff --git a/mobile/openapi/lib/model/person_create_dto.dart b/mobile/openapi/lib/model/person_create_dto.dart index bc1d67c240..87b426eaed 100644 --- a/mobile/openapi/lib/model/person_create_dto.dart +++ b/mobile/openapi/lib/model/person_create_dto.dart @@ -14,6 +14,7 @@ class PersonCreateDto { /// Returns a new [PersonCreateDto] instance. PersonCreateDto({ this.birthDate, + this.color, this.isFavorite, this.isHidden, this.name, @@ -22,6 +23,8 @@ class PersonCreateDto { /// Person date of birth. Note: the mobile app cannot currently set the birth date to null. DateTime? birthDate; + String? color; + /// /// 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 @@ -51,6 +54,7 @@ class PersonCreateDto { @override bool operator ==(Object other) => identical(this, other) || other is PersonCreateDto && other.birthDate == birthDate && + other.color == color && other.isFavorite == isFavorite && other.isHidden == isHidden && other.name == name; @@ -59,12 +63,13 @@ class PersonCreateDto { int get hashCode => // ignore: unnecessary_parenthesis (birthDate == null ? 0 : birthDate!.hashCode) + + (color == null ? 0 : color!.hashCode) + (isFavorite == null ? 0 : isFavorite!.hashCode) + (isHidden == null ? 0 : isHidden!.hashCode) + (name == null ? 0 : name!.hashCode); @override - String toString() => 'PersonCreateDto[birthDate=$birthDate, isFavorite=$isFavorite, isHidden=$isHidden, name=$name]'; + String toString() => 'PersonCreateDto[birthDate=$birthDate, color=$color, isFavorite=$isFavorite, isHidden=$isHidden, name=$name]'; Map toJson() { final json = {}; @@ -73,6 +78,11 @@ class PersonCreateDto { } else { // json[r'birthDate'] = null; } + if (this.color != null) { + json[r'color'] = this.color; + } else { + // json[r'color'] = null; + } if (this.isFavorite != null) { json[r'isFavorite'] = this.isFavorite; } else { @@ -101,6 +111,7 @@ class PersonCreateDto { return PersonCreateDto( birthDate: mapDateTime(json, r'birthDate', r''), + color: mapValueOfType(json, r'color'), isFavorite: mapValueOfType(json, r'isFavorite'), isHidden: mapValueOfType(json, r'isHidden'), name: mapValueOfType(json, r'name'), diff --git a/mobile/openapi/lib/model/person_response_dto.dart b/mobile/openapi/lib/model/person_response_dto.dart index 1884459928..c9ebb14c72 100644 --- a/mobile/openapi/lib/model/person_response_dto.dart +++ b/mobile/openapi/lib/model/person_response_dto.dart @@ -14,6 +14,7 @@ class PersonResponseDto { /// Returns a new [PersonResponseDto] instance. PersonResponseDto({ required this.birthDate, + this.color, required this.id, this.isFavorite, required this.isHidden, @@ -24,6 +25,15 @@ class PersonResponseDto { DateTime? birthDate; + /// This property was added in v1.126.0 + /// + /// 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? color; + String id; /// This property was added in v1.126.0 @@ -53,6 +63,7 @@ class PersonResponseDto { @override bool operator ==(Object other) => identical(this, other) || other is PersonResponseDto && other.birthDate == birthDate && + other.color == color && other.id == id && other.isFavorite == isFavorite && other.isHidden == isHidden && @@ -64,6 +75,7 @@ class PersonResponseDto { int get hashCode => // ignore: unnecessary_parenthesis (birthDate == null ? 0 : birthDate!.hashCode) + + (color == null ? 0 : color!.hashCode) + (id.hashCode) + (isFavorite == null ? 0 : isFavorite!.hashCode) + (isHidden.hashCode) + @@ -72,7 +84,7 @@ class PersonResponseDto { (updatedAt == null ? 0 : updatedAt!.hashCode); @override - String toString() => 'PersonResponseDto[birthDate=$birthDate, id=$id, isFavorite=$isFavorite, isHidden=$isHidden, name=$name, thumbnailPath=$thumbnailPath, updatedAt=$updatedAt]'; + String toString() => 'PersonResponseDto[birthDate=$birthDate, color=$color, id=$id, isFavorite=$isFavorite, isHidden=$isHidden, name=$name, thumbnailPath=$thumbnailPath, updatedAt=$updatedAt]'; Map toJson() { final json = {}; @@ -80,6 +92,11 @@ class PersonResponseDto { json[r'birthDate'] = _dateFormatter.format(this.birthDate!.toUtc()); } else { // json[r'birthDate'] = null; + } + if (this.color != null) { + json[r'color'] = this.color; + } else { + // json[r'color'] = null; } json[r'id'] = this.id; if (this.isFavorite != null) { @@ -108,6 +125,7 @@ class PersonResponseDto { return PersonResponseDto( birthDate: mapDateTime(json, r'birthDate', r''), + color: mapValueOfType(json, r'color'), id: mapValueOfType(json, r'id')!, isFavorite: mapValueOfType(json, r'isFavorite'), isHidden: mapValueOfType(json, r'isHidden')!, diff --git a/mobile/openapi/lib/model/person_update_dto.dart b/mobile/openapi/lib/model/person_update_dto.dart index cf0688a27f..6736b4e177 100644 --- a/mobile/openapi/lib/model/person_update_dto.dart +++ b/mobile/openapi/lib/model/person_update_dto.dart @@ -14,6 +14,7 @@ class PersonUpdateDto { /// Returns a new [PersonUpdateDto] instance. PersonUpdateDto({ this.birthDate, + this.color, this.featureFaceAssetId, this.isFavorite, this.isHidden, @@ -23,6 +24,8 @@ class PersonUpdateDto { /// Person date of birth. Note: the mobile app cannot currently set the birth date to null. DateTime? birthDate; + String? color; + /// Asset is used to get the feature face thumbnail. /// /// Please note: This property should have been non-nullable! Since the specification file @@ -61,6 +64,7 @@ class PersonUpdateDto { @override bool operator ==(Object other) => identical(this, other) || other is PersonUpdateDto && other.birthDate == birthDate && + other.color == color && other.featureFaceAssetId == featureFaceAssetId && other.isFavorite == isFavorite && other.isHidden == isHidden && @@ -70,13 +74,14 @@ class PersonUpdateDto { int get hashCode => // ignore: unnecessary_parenthesis (birthDate == null ? 0 : birthDate!.hashCode) + + (color == null ? 0 : color!.hashCode) + (featureFaceAssetId == null ? 0 : featureFaceAssetId!.hashCode) + (isFavorite == null ? 0 : isFavorite!.hashCode) + (isHidden == null ? 0 : isHidden!.hashCode) + (name == null ? 0 : name!.hashCode); @override - String toString() => 'PersonUpdateDto[birthDate=$birthDate, featureFaceAssetId=$featureFaceAssetId, isFavorite=$isFavorite, isHidden=$isHidden, name=$name]'; + String toString() => 'PersonUpdateDto[birthDate=$birthDate, color=$color, featureFaceAssetId=$featureFaceAssetId, isFavorite=$isFavorite, isHidden=$isHidden, name=$name]'; Map toJson() { final json = {}; @@ -85,6 +90,11 @@ class PersonUpdateDto { } else { // json[r'birthDate'] = null; } + if (this.color != null) { + json[r'color'] = this.color; + } else { + // json[r'color'] = null; + } if (this.featureFaceAssetId != null) { json[r'featureFaceAssetId'] = this.featureFaceAssetId; } else { @@ -118,6 +128,7 @@ class PersonUpdateDto { return PersonUpdateDto( birthDate: mapDateTime(json, r'birthDate', r''), + color: mapValueOfType(json, r'color'), featureFaceAssetId: mapValueOfType(json, r'featureFaceAssetId'), isFavorite: mapValueOfType(json, r'isFavorite'), isHidden: mapValueOfType(json, r'isHidden'), diff --git a/mobile/openapi/lib/model/person_with_faces_response_dto.dart b/mobile/openapi/lib/model/person_with_faces_response_dto.dart index 7d61db11f3..0bd38b0870 100644 --- a/mobile/openapi/lib/model/person_with_faces_response_dto.dart +++ b/mobile/openapi/lib/model/person_with_faces_response_dto.dart @@ -14,6 +14,7 @@ class PersonWithFacesResponseDto { /// Returns a new [PersonWithFacesResponseDto] instance. PersonWithFacesResponseDto({ required this.birthDate, + this.color, this.faces = const [], required this.id, this.isFavorite, @@ -25,6 +26,15 @@ class PersonWithFacesResponseDto { DateTime? birthDate; + /// This property was added in v1.126.0 + /// + /// 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? color; + List faces; String id; @@ -56,6 +66,7 @@ class PersonWithFacesResponseDto { @override bool operator ==(Object other) => identical(this, other) || other is PersonWithFacesResponseDto && other.birthDate == birthDate && + other.color == color && _deepEquality.equals(other.faces, faces) && other.id == id && other.isFavorite == isFavorite && @@ -68,6 +79,7 @@ class PersonWithFacesResponseDto { int get hashCode => // ignore: unnecessary_parenthesis (birthDate == null ? 0 : birthDate!.hashCode) + + (color == null ? 0 : color!.hashCode) + (faces.hashCode) + (id.hashCode) + (isFavorite == null ? 0 : isFavorite!.hashCode) + @@ -77,7 +89,7 @@ class PersonWithFacesResponseDto { (updatedAt == null ? 0 : updatedAt!.hashCode); @override - String toString() => 'PersonWithFacesResponseDto[birthDate=$birthDate, faces=$faces, id=$id, isFavorite=$isFavorite, isHidden=$isHidden, name=$name, thumbnailPath=$thumbnailPath, updatedAt=$updatedAt]'; + String toString() => 'PersonWithFacesResponseDto[birthDate=$birthDate, color=$color, faces=$faces, id=$id, isFavorite=$isFavorite, isHidden=$isHidden, name=$name, thumbnailPath=$thumbnailPath, updatedAt=$updatedAt]'; Map toJson() { final json = {}; @@ -85,6 +97,11 @@ class PersonWithFacesResponseDto { json[r'birthDate'] = _dateFormatter.format(this.birthDate!.toUtc()); } else { // json[r'birthDate'] = null; + } + if (this.color != null) { + json[r'color'] = this.color; + } else { + // json[r'color'] = null; } json[r'faces'] = this.faces; json[r'id'] = this.id; @@ -114,6 +131,7 @@ class PersonWithFacesResponseDto { return PersonWithFacesResponseDto( birthDate: mapDateTime(json, r'birthDate', r''), + color: mapValueOfType(json, r'color'), faces: AssetFaceWithoutPersonResponseDto.listFromJson(json[r'faces']), id: mapValueOfType(json, r'id')!, isFavorite: mapValueOfType(json, r'isFavorite'), diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index f1ef466df4..94ef49f12e 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -10286,6 +10286,10 @@ "nullable": true, "type": "string" }, + "color": { + "nullable": true, + "type": "string" + }, "featureFaceAssetId": { "description": "Asset is used to get the feature face thumbnail.", "type": "string" @@ -10402,6 +10406,10 @@ "nullable": true, "type": "string" }, + "color": { + "nullable": true, + "type": "string" + }, "isFavorite": { "type": "boolean" }, @@ -10423,6 +10431,10 @@ "nullable": true, "type": "string" }, + "color": { + "description": "This property was added in v1.126.0", + "type": "string" + }, "id": { "type": "string" }, @@ -10473,6 +10485,10 @@ "nullable": true, "type": "string" }, + "color": { + "nullable": true, + "type": "string" + }, "featureFaceAssetId": { "description": "Asset is used to get the feature face thumbnail.", "type": "string" @@ -10498,6 +10514,10 @@ "nullable": true, "type": "string" }, + "color": { + "description": "This property was added in v1.126.0", + "type": "string" + }, "faces": { "items": { "$ref": "#/components/schemas/AssetFaceWithoutPersonResponseDto" @@ -12611,7 +12631,6 @@ "properties": { "color": { "nullable": true, - "pattern": "^#?([0-9A-F]{3}|[0-9A-F]{4}|[0-9A-F]{6}|[0-9A-F]{8})$", "type": "string" } }, diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index 6704a83cc7..46ce207883 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -213,6 +213,8 @@ export type AssetFaceWithoutPersonResponseDto = { }; export type PersonWithFacesResponseDto = { birthDate: string | null; + /** This property was added in v1.126.0 */ + color?: string; faces: AssetFaceWithoutPersonResponseDto[]; id: string; /** This property was added in v1.126.0 */ @@ -493,6 +495,8 @@ export type DuplicateResponseDto = { }; export type PersonResponseDto = { birthDate: string | null; + /** This property was added in v1.126.0 */ + color?: string; id: string; /** This property was added in v1.126.0 */ isFavorite?: boolean; @@ -693,6 +697,7 @@ export type PersonCreateDto = { /** Person date of birth. Note: the mobile app cannot currently set the birth date to null. */ birthDate?: string | null; + color?: string | null; isFavorite?: boolean; /** Person visibility */ isHidden?: boolean; @@ -703,6 +708,7 @@ export type PeopleUpdateItem = { /** Person date of birth. Note: the mobile app cannot currently set the birth date to null. */ birthDate?: string | null; + color?: string | null; /** Asset is used to get the feature face thumbnail. */ featureFaceAssetId?: string; /** Person id. */ @@ -720,6 +726,7 @@ export type PersonUpdateDto = { /** Person date of birth. Note: the mobile app cannot currently set the birth date to null. */ birthDate?: string | null; + color?: string | null; /** Asset is used to get the feature face thumbnail. */ featureFaceAssetId?: string; isFavorite?: boolean; diff --git a/server/src/db.d.ts b/server/src/db.d.ts index 16f73c53e7..2bffe2ba5f 100644 --- a/server/src/db.d.ts +++ b/server/src/db.d.ts @@ -276,6 +276,7 @@ export interface Partners { export interface Person { birthDate: Timestamp | null; + color: string | null; createdAt: Generated; faceAssetId: string | null; id: Generated; diff --git a/server/src/dtos/person.dto.ts b/server/src/dtos/person.dto.ts index 8bf041be37..ca705154a2 100644 --- a/server/src/dtos/person.dto.ts +++ b/server/src/dtos/person.dto.ts @@ -7,7 +7,14 @@ import { AuthDto } from 'src/dtos/auth.dto'; import { AssetFaceEntity } from 'src/entities/asset-face.entity'; import { PersonEntity } from 'src/entities/person.entity'; import { SourceType } from 'src/enum'; -import { IsDateStringFormat, MaxDateString, Optional, ValidateBoolean, ValidateUUID } from 'src/validation'; +import { + IsDateStringFormat, + MaxDateString, + Optional, + ValidateBoolean, + ValidateHexColor, + ValidateUUID, +} from 'src/validation'; export class PersonCreateDto { /** @@ -35,6 +42,10 @@ export class PersonCreateDto { @ValidateBoolean({ optional: true }) isFavorite?: boolean; + + @Optional({ emptyToNull: true, nullable: true }) + @ValidateHexColor() + color?: string | null; } export class PersonUpdateDto extends PersonCreateDto { @@ -102,6 +113,8 @@ export class PersonResponseDto { updatedAt?: Date; @PropertyLifecycle({ addedAt: 'v1.126.0' }) isFavorite?: boolean; + @PropertyLifecycle({ addedAt: 'v1.126.0' }) + color?: string; } export class PersonWithFacesResponseDto extends PersonResponseDto { @@ -176,6 +189,7 @@ export function mapPerson(person: PersonEntity): PersonResponseDto { thumbnailPath: person.thumbnailPath, isHidden: person.isHidden, isFavorite: person.isFavorite, + color: person.color ?? undefined, updatedAt: person.updatedAt, }; } diff --git a/server/src/dtos/tag.dto.ts b/server/src/dtos/tag.dto.ts index cff11962d7..17200a8874 100644 --- a/server/src/dtos/tag.dto.ts +++ b/server/src/dtos/tag.dto.ts @@ -1,8 +1,7 @@ import { ApiProperty } from '@nestjs/swagger'; -import { Transform } from 'class-transformer'; import { IsHexColor, IsNotEmpty, IsString } from 'class-validator'; import { TagEntity } from 'src/entities/tag.entity'; -import { Optional, ValidateUUID } from 'src/validation'; +import { Optional, ValidateHexColor, ValidateUUID } from 'src/validation'; export class TagCreateDto { @IsString() @@ -18,9 +17,8 @@ export class TagCreateDto { } export class TagUpdateDto { - @Optional({ nullable: true, emptyToNull: true }) - @IsHexColor() - @Transform(({ value }) => (typeof value === 'string' && value[0] !== '#' ? `#${value}` : value)) + @Optional({ emptyToNull: true, nullable: true }) + @ValidateHexColor() color?: string | null; } diff --git a/server/src/entities/person.entity.ts b/server/src/entities/person.entity.ts index 8cf416b766..3785e1985e 100644 --- a/server/src/entities/person.entity.ts +++ b/server/src/entities/person.entity.ts @@ -52,4 +52,7 @@ export class PersonEntity { @Column({ default: false }) isFavorite!: boolean; + + @Column({ type: 'varchar', nullable: true, default: null }) + color?: string | null; } diff --git a/server/src/migrations/1738889177573-AddPersonColor.ts b/server/src/migrations/1738889177573-AddPersonColor.ts new file mode 100644 index 0000000000..ebdc86f52d --- /dev/null +++ b/server/src/migrations/1738889177573-AddPersonColor.ts @@ -0,0 +1,14 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class AddPersonColor1738889177573 implements MigrationInterface { + name = 'AddPersonColor1738889177573' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "person" ADD "color" character varying`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "person" DROP COLUMN "color"`); + } + +} diff --git a/server/src/services/person.service.spec.ts b/server/src/services/person.service.spec.ts index 5407821fab..1cd1b34ec4 100644 --- a/server/src/services/person.service.spec.ts +++ b/server/src/services/person.service.spec.ts @@ -355,7 +355,7 @@ describe(PersonService.name, () => { sut.reassignFaces(authStub.admin, personStub.noName.id, { data: [{ personId: personStub.withName.id, assetId: assetStub.image.id }], }), - ).resolves.toEqual([personStub.noName]); + ).resolves.toBeDefined(); expect(jobMock.queueAll).toHaveBeenCalledWith([ { @@ -448,7 +448,7 @@ describe(PersonService.name, () => { it('should create a new person', async () => { personMock.create.mockResolvedValue(personStub.primaryPerson); - await expect(sut.create(authStub.admin, {})).resolves.toBe(personStub.primaryPerson); + await expect(sut.create(authStub.admin, {})).resolves.toBeDefined(); expect(personMock.create).toHaveBeenCalledWith({ ownerId: authStub.admin.user.id }); }); diff --git a/server/src/services/person.service.ts b/server/src/services/person.service.ts index 2f4a6bb0d1..116d2ec6c8 100644 --- a/server/src/services/person.service.ts +++ b/server/src/services/person.service.ts @@ -104,7 +104,7 @@ export class PersonService extends BaseService { await this.personRepository.reassignFace(face.id, personId); } - result.push(person); + result.push(mapPerson(person)); } if (changeFeaturePhoto.length > 0) { // Remove duplicates @@ -178,20 +178,23 @@ export class PersonService extends BaseService { }); } - create(auth: AuthDto, dto: PersonCreateDto): Promise { - return this.personRepository.create({ + async create(auth: AuthDto, dto: PersonCreateDto): Promise { + const person = await this.personRepository.create({ ownerId: auth.user.id, name: dto.name, birthDate: dto.birthDate, isHidden: dto.isHidden, isFavorite: dto.isFavorite, + color: dto.color, }); + + return mapPerson(person); } async update(auth: AuthDto, id: string, dto: PersonUpdateDto): Promise { await this.requireAccess({ auth, permission: Permission.PERSON_UPDATE, ids: [id] }); - const { name, birthDate, isHidden, featureFaceAssetId: assetId, isFavorite } = dto; + const { name, birthDate, isHidden, featureFaceAssetId: assetId, isFavorite, color } = dto; // TODO: set by faceId directly let faceId: string | undefined = undefined; if (assetId) { @@ -211,6 +214,7 @@ export class PersonService extends BaseService { birthDate, isHidden, isFavorite, + color, }); if (assetId) { diff --git a/server/src/services/search.service.spec.ts b/server/src/services/search.service.spec.ts index 5c59e24b21..9f16ddf82d 100644 --- a/server/src/services/search.service.spec.ts +++ b/server/src/services/search.service.spec.ts @@ -31,6 +31,8 @@ describe(SearchService.name, () => { it('should pass options to search', async () => { const { name } = personStub.withName; + personMock.getByName.mockResolvedValue([]); + await sut.searchPerson(authStub.user1, { name, withHidden: false }); expect(personMock.getByName).toHaveBeenCalledWith(authStub.user1.user.id, name, { withHidden: false }); diff --git a/server/src/services/search.service.ts b/server/src/services/search.service.ts index b833d0184c..b74d3d3cba 100644 --- a/server/src/services/search.service.ts +++ b/server/src/services/search.service.ts @@ -1,8 +1,9 @@ import { BadRequestException, Injectable } from '@nestjs/common'; import { AssetMapOptions, AssetResponseDto, mapAsset } from 'src/dtos/asset-response.dto'; import { AuthDto } from 'src/dtos/auth.dto'; -import { PersonResponseDto } from 'src/dtos/person.dto'; +import { mapPerson, PersonResponseDto } from 'src/dtos/person.dto'; import { + mapPlaces, MetadataSearchDto, PlacesResponseDto, RandomSearchDto, @@ -12,7 +13,6 @@ import { SearchSuggestionRequestDto, SearchSuggestionType, SmartSearchDto, - mapPlaces, } from 'src/dtos/search.dto'; import { AssetEntity } from 'src/entities/asset.entity'; import { AssetOrder } from 'src/enum'; @@ -24,7 +24,8 @@ import { isSmartSearchEnabled } from 'src/utils/misc'; @Injectable() export class SearchService extends BaseService { async searchPerson(auth: AuthDto, dto: SearchPeopleDto): Promise { - return this.personRepository.getByName(auth.user.id, dto.name, { withHidden: dto.withHidden }); + const people = await this.personRepository.getByName(auth.user.id, dto.name, { withHidden: dto.withHidden }); + return people.map((person) => mapPerson(person)); } async searchPlaces(dto: SearchPlacesDto): Promise { diff --git a/server/src/validation.ts b/server/src/validation.ts index 177e439919..29e402826d 100644 --- a/server/src/validation.ts +++ b/server/src/validation.ts @@ -12,6 +12,7 @@ import { IsArray, IsBoolean, IsDate, + IsHexColor, IsNotEmpty, IsOptional, IsString, @@ -97,6 +98,15 @@ export function Optional({ nullable, emptyToNull, ...validationOptions }: Option return applyDecorators(...decorators); } +export const ValidateHexColor = () => { + const decorators = [ + IsHexColor(), + Transform(({ value }) => (typeof value === 'string' && value[0] !== '#' ? `#${value}` : value)), + ]; + + return applyDecorators(...decorators); +}; + type UUIDOptions = { optional?: boolean; each?: boolean; nullable?: boolean }; export const ValidateUUID = (options?: UUIDOptions) => { const { optional, each, nullable } = { optional: false, each: false, nullable: false, ...options }; From c5360e78c53d81f5e0cc6ce016647d804a35047b Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Fri, 7 Feb 2025 13:05:15 -0500 Subject: [PATCH 099/395] feat(web): shared link filters (#15948) --- i18n/en.json | 3 + mobile/openapi/README.md | 2 + mobile/openapi/lib/api.dart | 2 + mobile/openapi/lib/api_client.dart | 4 + .../lib/model/shared_links_response.dart | 107 +++++++++++++++ .../lib/model/shared_links_update.dart | 125 ++++++++++++++++++ .../model/user_preferences_response_dto.dart | 10 +- .../model/user_preferences_update_dto.dart | 19 ++- open-api/immich-openapi-specs.json | 35 +++++ open-api/typescript-sdk/src/fetch-client.ts | 10 ++ server/src/dtos/user-preferences.dto.ts | 19 +++ server/src/entities/user-metadata.entity.ts | 8 ++ .../lib/components/elements/group-tab.svelte | 5 +- .../side-bar/side-bar.svelte | 5 + .../actions/shared-link-edit.svelte | 11 +- .../sharedlinks-page/shared-link-card.svelte | 8 +- .../feature-settings.svelte | 20 +++ web/src/lib/constants.ts | 2 +- .../shared-links/[[id=id]]/+page.svelte | 119 +++++++++++++++++ .../(user)/shared-links/[[id=id]]/+page.ts | 14 ++ .../(user)/sharing/sharedlinks/+page.svelte | 89 ------------- .../(user)/sharing/sharedlinks/+page.ts | 15 +-- 22 files changed, 520 insertions(+), 112 deletions(-) create mode 100644 mobile/openapi/lib/model/shared_links_response.dart create mode 100644 mobile/openapi/lib/model/shared_links_update.dart create mode 100644 web/src/routes/(user)/shared-links/[[id=id]]/+page.svelte create mode 100644 web/src/routes/(user)/shared-links/[[id=id]]/+page.ts delete mode 100644 web/src/routes/(user)/sharing/sharedlinks/+page.svelte diff --git a/i18n/en.json b/i18n/en.json index 55fa27c981..72559d4502 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -436,6 +436,7 @@ "back_close_deselect": "Back, close, or deselect", "backward": "Backward", "birthdate_saved": "Date of birth saved successfully", + "show_shared_links": "Show shared links", "birthdate_set_description": "Date of birth is used to calculate the age of this person at the time of a photo.", "blurred_background": "Blurred background", "bugs_and_feature_requests": "Bugs & Feature Requests", @@ -804,6 +805,7 @@ "include_shared_albums": "Include shared albums", "include_shared_partner_assets": "Include shared partner assets", "individual_share": "Individual share", + "individual_shares": "Individual shares", "info": "Info", "interval": { "day_at_onepm": "Every day at 1pm", @@ -1172,6 +1174,7 @@ "shared_from_partner": "Photos from {partner}", "shared_link_options": "Shared link options", "shared_links": "Shared links", + "shared_links_description": "Share photos and videos with a link", "shared_photos_and_videos_count": "{assetCount, plural, other {# shared photos & videos.}}", "shared_with_partner": "Shared with {partner}", "sharing": "Sharing", diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index 5e0558db18..8a2b6b886a 100644 --- a/mobile/openapi/README.md +++ b/mobile/openapi/README.md @@ -408,6 +408,8 @@ Class | Method | HTTP request | Description - [SharedLinkEditDto](doc//SharedLinkEditDto.md) - [SharedLinkResponseDto](doc//SharedLinkResponseDto.md) - [SharedLinkType](doc//SharedLinkType.md) + - [SharedLinksResponse](doc//SharedLinksResponse.md) + - [SharedLinksUpdate](doc//SharedLinksUpdate.md) - [SignUpDto](doc//SignUpDto.md) - [SmartSearchDto](doc//SmartSearchDto.md) - [SourceType](doc//SourceType.md) diff --git a/mobile/openapi/lib/api.dart b/mobile/openapi/lib/api.dart index 73eb02d89e..3455cdb4fd 100644 --- a/mobile/openapi/lib/api.dart +++ b/mobile/openapi/lib/api.dart @@ -221,6 +221,8 @@ part 'model/shared_link_create_dto.dart'; part 'model/shared_link_edit_dto.dart'; part 'model/shared_link_response_dto.dart'; part 'model/shared_link_type.dart'; +part 'model/shared_links_response.dart'; +part 'model/shared_links_update.dart'; part 'model/sign_up_dto.dart'; part 'model/smart_search_dto.dart'; part 'model/source_type.dart'; diff --git a/mobile/openapi/lib/api_client.dart b/mobile/openapi/lib/api_client.dart index a6f8d551da..3721652b8b 100644 --- a/mobile/openapi/lib/api_client.dart +++ b/mobile/openapi/lib/api_client.dart @@ -496,6 +496,10 @@ class ApiClient { return SharedLinkResponseDto.fromJson(value); case 'SharedLinkType': return SharedLinkTypeTypeTransformer().decode(value); + case 'SharedLinksResponse': + return SharedLinksResponse.fromJson(value); + case 'SharedLinksUpdate': + return SharedLinksUpdate.fromJson(value); case 'SignUpDto': return SignUpDto.fromJson(value); case 'SmartSearchDto': diff --git a/mobile/openapi/lib/model/shared_links_response.dart b/mobile/openapi/lib/model/shared_links_response.dart new file mode 100644 index 0000000000..80875e6174 --- /dev/null +++ b/mobile/openapi/lib/model/shared_links_response.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 SharedLinksResponse { + /// Returns a new [SharedLinksResponse] instance. + SharedLinksResponse({ + this.enabled = true, + this.sidebarWeb = false, + }); + + bool enabled; + + bool sidebarWeb; + + @override + bool operator ==(Object other) => identical(this, other) || other is SharedLinksResponse && + other.enabled == enabled && + other.sidebarWeb == sidebarWeb; + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (enabled.hashCode) + + (sidebarWeb.hashCode); + + @override + String toString() => 'SharedLinksResponse[enabled=$enabled, sidebarWeb=$sidebarWeb]'; + + Map toJson() { + final json = {}; + json[r'enabled'] = this.enabled; + json[r'sidebarWeb'] = this.sidebarWeb; + return json; + } + + /// Returns a new [SharedLinksResponse] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static SharedLinksResponse? fromJson(dynamic value) { + upgradeDto(value, "SharedLinksResponse"); + if (value is Map) { + final json = value.cast(); + + return SharedLinksResponse( + enabled: mapValueOfType(json, r'enabled')!, + sidebarWeb: mapValueOfType(json, r'sidebarWeb')!, + ); + } + 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 = SharedLinksResponse.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 = SharedLinksResponse.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of SharedLinksResponse-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] = SharedLinksResponse.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + 'enabled', + 'sidebarWeb', + }; +} + diff --git a/mobile/openapi/lib/model/shared_links_update.dart b/mobile/openapi/lib/model/shared_links_update.dart new file mode 100644 index 0000000000..5d9eda3001 --- /dev/null +++ b/mobile/openapi/lib/model/shared_links_update.dart @@ -0,0 +1,125 @@ +// +// 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 SharedLinksUpdate { + /// Returns a new [SharedLinksUpdate] instance. + SharedLinksUpdate({ + this.enabled, + this.sidebarWeb, + }); + + /// + /// 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? enabled; + + /// + /// 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? sidebarWeb; + + @override + bool operator ==(Object other) => identical(this, other) || other is SharedLinksUpdate && + other.enabled == enabled && + other.sidebarWeb == sidebarWeb; + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (enabled == null ? 0 : enabled!.hashCode) + + (sidebarWeb == null ? 0 : sidebarWeb!.hashCode); + + @override + String toString() => 'SharedLinksUpdate[enabled=$enabled, sidebarWeb=$sidebarWeb]'; + + Map toJson() { + final json = {}; + if (this.enabled != null) { + json[r'enabled'] = this.enabled; + } else { + // json[r'enabled'] = null; + } + if (this.sidebarWeb != null) { + json[r'sidebarWeb'] = this.sidebarWeb; + } else { + // json[r'sidebarWeb'] = null; + } + return json; + } + + /// Returns a new [SharedLinksUpdate] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static SharedLinksUpdate? fromJson(dynamic value) { + upgradeDto(value, "SharedLinksUpdate"); + if (value is Map) { + final json = value.cast(); + + return SharedLinksUpdate( + enabled: mapValueOfType(json, r'enabled'), + sidebarWeb: mapValueOfType(json, r'sidebarWeb'), + ); + } + 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 = SharedLinksUpdate.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 = SharedLinksUpdate.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of SharedLinksUpdate-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] = SharedLinksUpdate.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/user_preferences_response_dto.dart b/mobile/openapi/lib/model/user_preferences_response_dto.dart index 23d9ea84ec..b244284eb0 100644 --- a/mobile/openapi/lib/model/user_preferences_response_dto.dart +++ b/mobile/openapi/lib/model/user_preferences_response_dto.dart @@ -21,6 +21,7 @@ class UserPreferencesResponseDto { required this.people, required this.purchase, required this.ratings, + required this.sharedLinks, required this.tags, }); @@ -40,6 +41,8 @@ class UserPreferencesResponseDto { RatingsResponse ratings; + SharedLinksResponse sharedLinks; + TagsResponse tags; @override @@ -52,6 +55,7 @@ class UserPreferencesResponseDto { other.people == people && other.purchase == purchase && other.ratings == ratings && + other.sharedLinks == sharedLinks && other.tags == tags; @override @@ -65,10 +69,11 @@ class UserPreferencesResponseDto { (people.hashCode) + (purchase.hashCode) + (ratings.hashCode) + + (sharedLinks.hashCode) + (tags.hashCode); @override - String toString() => 'UserPreferencesResponseDto[avatar=$avatar, download=$download, emailNotifications=$emailNotifications, folders=$folders, memories=$memories, people=$people, purchase=$purchase, ratings=$ratings, tags=$tags]'; + String toString() => 'UserPreferencesResponseDto[avatar=$avatar, download=$download, emailNotifications=$emailNotifications, folders=$folders, memories=$memories, people=$people, purchase=$purchase, ratings=$ratings, sharedLinks=$sharedLinks, tags=$tags]'; Map toJson() { final json = {}; @@ -80,6 +85,7 @@ class UserPreferencesResponseDto { json[r'people'] = this.people; json[r'purchase'] = this.purchase; json[r'ratings'] = this.ratings; + json[r'sharedLinks'] = this.sharedLinks; json[r'tags'] = this.tags; return json; } @@ -101,6 +107,7 @@ class UserPreferencesResponseDto { people: PeopleResponse.fromJson(json[r'people'])!, purchase: PurchaseResponse.fromJson(json[r'purchase'])!, ratings: RatingsResponse.fromJson(json[r'ratings'])!, + sharedLinks: SharedLinksResponse.fromJson(json[r'sharedLinks'])!, tags: TagsResponse.fromJson(json[r'tags'])!, ); } @@ -157,6 +164,7 @@ class UserPreferencesResponseDto { 'people', 'purchase', 'ratings', + 'sharedLinks', 'tags', }; } diff --git a/mobile/openapi/lib/model/user_preferences_update_dto.dart b/mobile/openapi/lib/model/user_preferences_update_dto.dart index 208dbf6860..3e420df119 100644 --- a/mobile/openapi/lib/model/user_preferences_update_dto.dart +++ b/mobile/openapi/lib/model/user_preferences_update_dto.dart @@ -21,6 +21,7 @@ class UserPreferencesUpdateDto { this.people, this.purchase, this.ratings, + this.sharedLinks, this.tags, }); @@ -88,6 +89,14 @@ class UserPreferencesUpdateDto { /// RatingsUpdate? ratings; + /// + /// 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. + /// + SharedLinksUpdate? sharedLinks; + /// /// 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 @@ -106,6 +115,7 @@ class UserPreferencesUpdateDto { other.people == people && other.purchase == purchase && other.ratings == ratings && + other.sharedLinks == sharedLinks && other.tags == tags; @override @@ -119,10 +129,11 @@ class UserPreferencesUpdateDto { (people == null ? 0 : people!.hashCode) + (purchase == null ? 0 : purchase!.hashCode) + (ratings == null ? 0 : ratings!.hashCode) + + (sharedLinks == null ? 0 : sharedLinks!.hashCode) + (tags == null ? 0 : tags!.hashCode); @override - String toString() => 'UserPreferencesUpdateDto[avatar=$avatar, download=$download, emailNotifications=$emailNotifications, folders=$folders, memories=$memories, people=$people, purchase=$purchase, ratings=$ratings, tags=$tags]'; + String toString() => 'UserPreferencesUpdateDto[avatar=$avatar, download=$download, emailNotifications=$emailNotifications, folders=$folders, memories=$memories, people=$people, purchase=$purchase, ratings=$ratings, sharedLinks=$sharedLinks, tags=$tags]'; Map toJson() { final json = {}; @@ -166,6 +177,11 @@ class UserPreferencesUpdateDto { } else { // json[r'ratings'] = null; } + if (this.sharedLinks != null) { + json[r'sharedLinks'] = this.sharedLinks; + } else { + // json[r'sharedLinks'] = null; + } if (this.tags != null) { json[r'tags'] = this.tags; } else { @@ -191,6 +207,7 @@ class UserPreferencesUpdateDto { people: PeopleUpdate.fromJson(json[r'people']), purchase: PurchaseUpdate.fromJson(json[r'purchase']), ratings: RatingsUpdate.fromJson(json[r'ratings']), + sharedLinks: SharedLinksUpdate.fromJson(json[r'sharedLinks']), tags: TagsUpdate.fromJson(json[r'tags']), ); } diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index 94ef49f12e..bee8dfcac8 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -11514,6 +11514,34 @@ ], "type": "string" }, + "SharedLinksResponse": { + "properties": { + "enabled": { + "default": true, + "type": "boolean" + }, + "sidebarWeb": { + "default": false, + "type": "boolean" + } + }, + "required": [ + "enabled", + "sidebarWeb" + ], + "type": "object" + }, + "SharedLinksUpdate": { + "properties": { + "enabled": { + "type": "boolean" + }, + "sidebarWeb": { + "type": "boolean" + } + }, + "type": "object" + }, "SignUpDto": { "properties": { "email": { @@ -13160,6 +13188,9 @@ "ratings": { "$ref": "#/components/schemas/RatingsResponse" }, + "sharedLinks": { + "$ref": "#/components/schemas/SharedLinksResponse" + }, "tags": { "$ref": "#/components/schemas/TagsResponse" } @@ -13173,6 +13204,7 @@ "people", "purchase", "ratings", + "sharedLinks", "tags" ], "type": "object" @@ -13203,6 +13235,9 @@ "ratings": { "$ref": "#/components/schemas/RatingsUpdate" }, + "sharedLinks": { + "$ref": "#/components/schemas/SharedLinksUpdate" + }, "tags": { "$ref": "#/components/schemas/TagsUpdate" } diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index 46ce207883..d5fea1ed79 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -113,6 +113,10 @@ export type PurchaseResponse = { export type RatingsResponse = { enabled: boolean; }; +export type SharedLinksResponse = { + enabled: boolean; + sidebarWeb: boolean; +}; export type TagsResponse = { enabled: boolean; sidebarWeb: boolean; @@ -126,6 +130,7 @@ export type UserPreferencesResponseDto = { people: PeopleResponse; purchase: PurchaseResponse; ratings: RatingsResponse; + sharedLinks: SharedLinksResponse; tags: TagsResponse; }; export type AvatarUpdate = { @@ -158,6 +163,10 @@ export type PurchaseUpdate = { export type RatingsUpdate = { enabled?: boolean; }; +export type SharedLinksUpdate = { + enabled?: boolean; + sidebarWeb?: boolean; +}; export type TagsUpdate = { enabled?: boolean; sidebarWeb?: boolean; @@ -171,6 +180,7 @@ export type UserPreferencesUpdateDto = { people?: PeopleUpdate; purchase?: PurchaseUpdate; ratings?: RatingsUpdate; + sharedLinks?: SharedLinksUpdate; tags?: TagsUpdate; }; export type AlbumUserResponseDto = { diff --git a/server/src/dtos/user-preferences.dto.ts b/server/src/dtos/user-preferences.dto.ts index 8de7021eaf..5a393a2d71 100644 --- a/server/src/dtos/user-preferences.dto.ts +++ b/server/src/dtos/user-preferences.dto.ts @@ -38,6 +38,14 @@ class PeopleUpdate { sidebarWeb?: boolean; } +class SharedLinksUpdate { + @ValidateBoolean({ optional: true }) + enabled?: boolean; + + @ValidateBoolean({ optional: true }) + sidebarWeb?: boolean; +} + class TagsUpdate { @ValidateBoolean({ optional: true }) enabled?: boolean; @@ -98,6 +106,11 @@ export class UserPreferencesUpdateDto { @Type(() => RatingsUpdate) ratings?: RatingsUpdate; + @Optional() + @ValidateNested() + @Type(() => SharedLinksUpdate) + sharedLinks?: SharedLinksUpdate; + @Optional() @ValidateNested() @Type(() => TagsUpdate) @@ -152,6 +165,11 @@ class TagsResponse { sidebarWeb: boolean = true; } +class SharedLinksResponse { + enabled: boolean = true; + sidebarWeb: boolean = false; +} + class EmailNotificationsResponse { enabled!: boolean; albumInvite!: boolean; @@ -175,6 +193,7 @@ export class UserPreferencesResponseDto implements UserPreferences { memories!: MemoriesResponse; people!: PeopleResponse; ratings!: RatingsResponse; + sharedLinks!: SharedLinksResponse; tags!: TagsResponse; avatar!: AvatarResponse; emailNotifications!: EmailNotificationsResponse; diff --git a/server/src/entities/user-metadata.entity.ts b/server/src/entities/user-metadata.entity.ts index 2c901426c3..65c187883a 100644 --- a/server/src/entities/user-metadata.entity.ts +++ b/server/src/entities/user-metadata.entity.ts @@ -34,6 +34,10 @@ export interface UserPreferences { ratings: { enabled: boolean; }; + sharedLinks: { + enabled: boolean; + sidebarWeb: boolean; + }; tags: { enabled: boolean; sidebarWeb: boolean; @@ -74,6 +78,10 @@ export const getDefaultPreferences = (user: { email: string }): UserPreferences enabled: true, sidebarWeb: false, }, + sharedLinks: { + enabled: true, + sidebarWeb: false, + }, ratings: { enabled: false, }, diff --git a/web/src/lib/components/elements/group-tab.svelte b/web/src/lib/components/elements/group-tab.svelte index 021d5ca96f..950c26f3f5 100644 --- a/web/src/lib/components/elements/group-tab.svelte +++ b/web/src/lib/components/elements/group-tab.svelte @@ -3,12 +3,13 @@ interface Props { filters: string[]; + labels?: string[]; selected: string; label: string; onSelect: (selected: string) => void; } - let { filters, selected, label, onSelect }: Props = $props(); + let { filters, selected, label, labels, onSelect }: Props = $props(); const id = `group-tab-${generateId()}`; @@ -32,7 +33,7 @@ for="{id}-{index}" class="flex h-full cursor-pointer items-center px-4 text-sm hover:bg-gray-300 group-first-of-type:rounded-s-2xl group-last-of-type:rounded-e-2xl peer-checked:bg-gray-300 dark:hover:bg-gray-800 peer-checked:dark:bg-gray-700" > - {filter} + {labels?.[index] ?? filter}
{/each} diff --git a/web/src/lib/components/shared-components/side-bar/side-bar.svelte b/web/src/lib/components/shared-components/side-bar/side-bar.svelte index 9c49b971ba..5493495fd3 100644 --- a/web/src/lib/components/shared-components/side-bar/side-bar.svelte +++ b/web/src/lib/components/shared-components/side-bar/side-bar.svelte @@ -21,6 +21,7 @@ mdiToolboxOutline, mdiFolderOutline, mdiTagMultipleOutline, + mdiLink, } from '@mdi/js'; import SideBarSection from './side-bar-section.svelte'; import SideBarLink from './side-bar-link.svelte'; @@ -72,6 +73,10 @@ /> {/if} + {#if $preferences.sharedLinks.enabled && $preferences.sharedLinks.sidebarWeb} + + {/if} + + import { goto } from '$app/navigation'; import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte'; import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte'; + import { AppRoute } from '$lib/constants'; + import type { SharedLinkResponseDto } from '@immich/sdk'; import { mdiCircleEditOutline } from '@mdi/js'; import { t } from 'svelte-i18n'; interface Props { menuItem?: boolean; - onEdit: () => void; + sharedLink: SharedLinkResponseDto; } - let { menuItem = false, onEdit }: Props = $props(); + let { sharedLink, menuItem = false }: Props = $props(); + + const onEdit = async () => { + await goto(`${AppRoute.SHARED_LINKS}/${sharedLink.id}`); + }; {#if menuItem} 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 70f6247533..15a4468c0e 100644 --- a/web/src/lib/components/sharedlinks-page/shared-link-card.svelte +++ b/web/src/lib/components/sharedlinks-page/shared-link-card.svelte @@ -15,10 +15,9 @@ interface Props { link: SharedLinkResponseDto; onDelete: () => void; - onEdit: () => void; } - let { link, onDelete, onEdit }: Props = $props(); + let { link, onDelete }: Props = $props(); let now = DateTime.now(); let expiresAt = $derived(link.expiresAt ? DateTime.fromISO(link.expiresAt) : undefined); @@ -95,10 +94,9 @@
-
@@ -112,7 +110,7 @@ padding="3" hideContent > - + diff --git a/web/src/lib/components/user-settings-page/feature-settings.svelte b/web/src/lib/components/user-settings-page/feature-settings.svelte index 9a60f83647..c3868070c3 100644 --- a/web/src/lib/components/user-settings-page/feature-settings.svelte +++ b/web/src/lib/components/user-settings-page/feature-settings.svelte @@ -27,6 +27,10 @@ // Ratings let ratingsEnabled = $state($preferences?.ratings?.enabled ?? false); + // Shared links + let sharedLinksEnabled = $state($preferences?.sharedLinks?.enabled ?? true); + let sharedLinkSidebar = $state($preferences?.sharedLinks?.sidebarWeb ?? false); + // Tags let tagsEnabled = $state($preferences?.tags?.enabled ?? false); let tagsSidebar = $state($preferences?.tags?.sidebarWeb ?? false); @@ -39,6 +43,7 @@ memories: { enabled: memoriesEnabled }, people: { enabled: peopleEnabled, sidebarWeb: peopleSidebar }, ratings: { enabled: ratingsEnabled }, + sharedLinks: { enabled: sharedLinksEnabled, sidebarWeb: sharedLinkSidebar }, tags: { enabled: tagsEnabled, sidebarWeb: tagsSidebar }, }, }); @@ -104,6 +109,21 @@
+ +
+ +
+ {#if sharedLinksEnabled} +
+ +
+ {/if} +
+
diff --git a/web/src/lib/constants.ts b/web/src/lib/constants.ts index b7ea2cfb52..db04efa5db 100644 --- a/web/src/lib/constants.ts +++ b/web/src/lib/constants.ts @@ -30,7 +30,7 @@ export enum AppRoute { EXPLORE = '/explore', SHARE = '/share', SHARING = '/sharing', - SHARED_LINKS = '/sharing/sharedlinks', + SHARED_LINKS = '/shared-links', SEARCH = '/search', MAP = '/map', USER_SETTINGS = '/user-settings', diff --git a/web/src/routes/(user)/shared-links/[[id=id]]/+page.svelte b/web/src/routes/(user)/shared-links/[[id=id]]/+page.svelte new file mode 100644 index 0000000000..436f3b47de --- /dev/null +++ b/web/src/routes/(user)/shared-links/[[id=id]]/+page.svelte @@ -0,0 +1,119 @@ + + + + {#snippet buttons()} + + {/snippet} + +
+ {#if sharedLinks.length === 0} +
+

{$t('you_dont_have_any_shared_links')}

+
+ {:else} +
+ {#each filteredSharedLinks as link (link.id)} + handleDeleteLink(link.id)} /> + {/each} +
+ {/if} + + {#if sharedLink} + + {/if} +
+
diff --git a/web/src/routes/(user)/shared-links/[[id=id]]/+page.ts b/web/src/routes/(user)/shared-links/[[id=id]]/+page.ts new file mode 100644 index 0000000000..920e5bdba4 --- /dev/null +++ b/web/src/routes/(user)/shared-links/[[id=id]]/+page.ts @@ -0,0 +1,14 @@ +import { authenticate } from '$lib/utils/auth'; +import { getFormatter } from '$lib/utils/i18n'; +import type { PageLoad } from './$types'; + +export const load = (async () => { + await authenticate(); + const $t = await getFormatter(); + + return { + meta: { + title: $t('shared_links'), + }, + }; +}) satisfies PageLoad; diff --git a/web/src/routes/(user)/sharing/sharedlinks/+page.svelte b/web/src/routes/(user)/sharing/sharedlinks/+page.svelte deleted file mode 100644 index b7d4da2941..0000000000 --- a/web/src/routes/(user)/sharing/sharedlinks/+page.svelte +++ /dev/null @@ -1,89 +0,0 @@ - - - goto(backUrl)}> - {#snippet leading()} - {$t('shared_links')} - {/snippet} - - -
-
-

{$t('manage_shared_links')}

-
- {#if sharedLinks.length === 0} -
-

{$t('you_dont_have_any_shared_links')}

-
- {:else} -
- {#each sharedLinks as link (link.id)} - handleDeleteLink(link.id)} onEdit={() => (editSharedLink = link)} /> - {/each} -
- {/if} -
- -{#if editSharedLink} - -{/if} diff --git a/web/src/routes/(user)/sharing/sharedlinks/+page.ts b/web/src/routes/(user)/sharing/sharedlinks/+page.ts index 920e5bdba4..59530fd83f 100644 --- a/web/src/routes/(user)/sharing/sharedlinks/+page.ts +++ b/web/src/routes/(user)/sharing/sharedlinks/+page.ts @@ -1,14 +1,7 @@ -import { authenticate } from '$lib/utils/auth'; -import { getFormatter } from '$lib/utils/i18n'; +import { AppRoute } from '$lib/constants'; +import { redirect } from '@sveltejs/kit'; import type { PageLoad } from './$types'; -export const load = (async () => { - await authenticate(); - const $t = await getFormatter(); - - return { - meta: { - title: $t('shared_links'), - }, - }; +export const load = (() => { + redirect(307, AppRoute.SHARED_LINKS); }) satisfies PageLoad; From 61b8eb85b571c547c52960f3dfe4aa70c906c9dc Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Fri, 7 Feb 2025 16:38:20 -0500 Subject: [PATCH 100/395] feat: view album shared links (#15943) --- e2e/src/api/specs/shared-link.e2e-spec.ts | 24 ++++++++ mobile/openapi/lib/api/shared_links_api.dart | 16 ++++- open-api/immich-openapi-specs.json | 12 +++- open-api/typescript-sdk/src/fetch-client.ts | 8 ++- .../src/controllers/shared-link.controller.ts | 5 +- server/src/dtos/shared-link.dto.ts | 5 ++ .../src/interfaces/shared-link.interface.ts | 7 ++- .../repositories/shared-link.repository.ts | 5 +- .../src/services/shared-link.service.spec.ts | 4 +- server/src/services/shared-link.service.ts | 7 ++- .../album-page/album-shared-link.svelte | 40 +++++++++++++ .../album-page/user-selection-modal.svelte | 60 ++++++++----------- .../shared-links/[[id=id]]/+page.svelte | 2 +- 13 files changed, 144 insertions(+), 51 deletions(-) create mode 100644 web/src/lib/components/album-page/album-shared-link.svelte diff --git a/e2e/src/api/specs/shared-link.e2e-spec.ts b/e2e/src/api/specs/shared-link.e2e-spec.ts index 5da6765d20..3918429e4e 100644 --- a/e2e/src/api/specs/shared-link.e2e-spec.ts +++ b/e2e/src/api/specs/shared-link.e2e-spec.ts @@ -150,6 +150,30 @@ describe('/shared-links', () => { ); }); + it('should filter on albumId', async () => { + const { status, body } = await request(app) + .get(`/shared-links?albumId=${album.id}`) + .set('Authorization', `Bearer ${user1.accessToken}`); + + expect(status).toBe(200); + expect(body).toHaveLength(2); + expect(body).toEqual( + expect.arrayContaining([ + expect.objectContaining({ id: linkWithAlbum.id }), + expect.objectContaining({ id: linkWithPassword.id }), + ]), + ); + }); + + it('should find 0 albums', async () => { + const { status, body } = await request(app) + .get(`/shared-links?albumId=${uuidDto.notFound}`) + .set('Authorization', `Bearer ${user1.accessToken}`); + + expect(status).toBe(200); + expect(body).toHaveLength(0); + }); + it('should not get shared links created by other users', async () => { const { status, body } = await request(app) .get('/shared-links') diff --git a/mobile/openapi/lib/api/shared_links_api.dart b/mobile/openapi/lib/api/shared_links_api.dart index 12e0224999..a6b2978fe2 100644 --- a/mobile/openapi/lib/api/shared_links_api.dart +++ b/mobile/openapi/lib/api/shared_links_api.dart @@ -127,7 +127,10 @@ class SharedLinksApi { } /// Performs an HTTP 'GET /shared-links' operation and returns the [Response]. - Future getAllSharedLinksWithHttpInfo() async { + /// Parameters: + /// + /// * [String] albumId: + Future getAllSharedLinksWithHttpInfo({ String? albumId, }) async { // ignore: prefer_const_declarations final path = r'/shared-links'; @@ -138,6 +141,10 @@ class SharedLinksApi { final headerParams = {}; final formParams = {}; + if (albumId != null) { + queryParams.addAll(_queryParams('', 'albumId', albumId)); + } + const contentTypes = []; @@ -152,8 +159,11 @@ class SharedLinksApi { ); } - Future?> getAllSharedLinks() async { - final response = await getAllSharedLinksWithHttpInfo(); + /// Parameters: + /// + /// * [String] albumId: + Future?> getAllSharedLinks({ String? albumId, }) async { + final response = await getAllSharedLinksWithHttpInfo( albumId: albumId, ); if (response.statusCode >= HttpStatus.badRequest) { throw ApiException(response.statusCode, await _decodeBodyBytes(response)); } diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index bee8dfcac8..0d3b808a80 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -5230,7 +5230,17 @@ "/shared-links": { "get": { "operationId": "getAllSharedLinks", - "parameters": [], + "parameters": [ + { + "name": "albumId", + "required": false, + "in": "query", + "schema": { + "format": "uuid", + "type": "string" + } + } + ], "responses": { "200": { "content": { diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index d5fea1ed79..a1560d897d 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -2762,11 +2762,15 @@ export function deleteSession({ id }: { method: "DELETE" })); } -export function getAllSharedLinks(opts?: Oazapfts.RequestOpts) { +export function getAllSharedLinks({ albumId }: { + albumId?: string; +}, opts?: Oazapfts.RequestOpts) { return oazapfts.ok(oazapfts.fetchJson<{ status: 200; data: SharedLinkResponseDto[]; - }>("/shared-links", { + }>(`/shared-links${QS.query(QS.explode({ + albumId + }))}`, { ...opts })); } diff --git a/server/src/controllers/shared-link.controller.ts b/server/src/controllers/shared-link.controller.ts index 59f81068d8..ca978f03da 100644 --- a/server/src/controllers/shared-link.controller.ts +++ b/server/src/controllers/shared-link.controller.ts @@ -9,6 +9,7 @@ import { SharedLinkEditDto, SharedLinkPasswordDto, SharedLinkResponseDto, + SharedLinkSearchDto, } from 'src/dtos/shared-link.dto'; import { ImmichCookie, Permission } from 'src/enum'; import { Auth, Authenticated, GetLoginDetails } from 'src/middleware/auth.guard'; @@ -24,8 +25,8 @@ export class SharedLinkController { @Get() @Authenticated({ permission: Permission.SHARED_LINK_READ }) - getAllSharedLinks(@Auth() auth: AuthDto): Promise { - return this.service.getAll(auth); + getAllSharedLinks(@Auth() auth: AuthDto, @Query() dto: SharedLinkSearchDto): Promise { + return this.service.getAll(auth, dto); } @Get('me') diff --git a/server/src/dtos/shared-link.dto.ts b/server/src/dtos/shared-link.dto.ts index b97791db58..e3f8c72e19 100644 --- a/server/src/dtos/shared-link.dto.ts +++ b/server/src/dtos/shared-link.dto.ts @@ -7,6 +7,11 @@ import { SharedLinkEntity } from 'src/entities/shared-link.entity'; import { SharedLinkType } from 'src/enum'; import { Optional, ValidateBoolean, ValidateDate, ValidateUUID } from 'src/validation'; +export class SharedLinkSearchDto { + @ValidateUUID({ optional: true }) + albumId?: string; +} + export class SharedLinkCreateDto { @IsEnum(SharedLinkType) @ApiProperty({ enum: SharedLinkType, enumName: 'SharedLinkType' }) diff --git a/server/src/interfaces/shared-link.interface.ts b/server/src/interfaces/shared-link.interface.ts index 25b7237f00..c030ceb736 100644 --- a/server/src/interfaces/shared-link.interface.ts +++ b/server/src/interfaces/shared-link.interface.ts @@ -4,8 +4,13 @@ import { SharedLinkEntity } from 'src/entities/shared-link.entity'; export const ISharedLinkRepository = 'ISharedLinkRepository'; +export type SharedLinkSearchOptions = { + userId: string; + albumId?: string; +}; + export interface ISharedLinkRepository { - getAll(userId: string): Promise; + getAll(options: SharedLinkSearchOptions): Promise; get(userId: string, id: string): Promise; getByKey(key: Buffer): Promise; create(entity: Insertable & { assetIds?: string[] }): Promise; diff --git a/server/src/repositories/shared-link.repository.ts b/server/src/repositories/shared-link.repository.ts index 3ffae4f067..8e2e6976a5 100644 --- a/server/src/repositories/shared-link.repository.ts +++ b/server/src/repositories/shared-link.repository.ts @@ -7,7 +7,7 @@ import { DB, SharedLinks } from 'src/db'; import { DummyValue, GenerateSql } from 'src/decorators'; import { SharedLinkEntity } from 'src/entities/shared-link.entity'; import { SharedLinkType } from 'src/enum'; -import { ISharedLinkRepository } from 'src/interfaces/shared-link.interface'; +import { ISharedLinkRepository, SharedLinkSearchOptions } from 'src/interfaces/shared-link.interface'; @Injectable() export class SharedLinkRepository implements ISharedLinkRepository { @@ -93,7 +93,7 @@ export class SharedLinkRepository implements ISharedLinkRepository { } @GenerateSql({ params: [DummyValue.UUID] }) - getAll(userId: string): Promise { + getAll({ userId, albumId }: SharedLinkSearchOptions): Promise { return this.db .selectFrom('shared_links') .selectAll('shared_links') @@ -149,6 +149,7 @@ export class SharedLinkRepository implements ISharedLinkRepository { ) .select((eb) => eb.fn.toJson('album').as('album')) .where((eb) => eb.or([eb('shared_links.type', '=', SharedLinkType.INDIVIDUAL), eb('album.id', 'is not', null)])) + .$if(!!albumId, (eb) => eb.where('shared_links.albumId', '=', albumId!)) .orderBy('shared_links.createdAt', 'desc') .distinctOn(['shared_links.createdAt']) .execute() as unknown as Promise; diff --git a/server/src/services/shared-link.service.spec.ts b/server/src/services/shared-link.service.spec.ts index 2d673eb7ca..0e29012876 100644 --- a/server/src/services/shared-link.service.spec.ts +++ b/server/src/services/shared-link.service.spec.ts @@ -29,11 +29,11 @@ describe(SharedLinkService.name, () => { describe('getAll', () => { it('should return all shared links for a user', async () => { sharedLinkMock.getAll.mockResolvedValue([sharedLinkStub.expired, sharedLinkStub.valid]); - await expect(sut.getAll(authStub.user1)).resolves.toEqual([ + await expect(sut.getAll(authStub.user1, {})).resolves.toEqual([ sharedLinkResponseStub.expired, sharedLinkResponseStub.valid, ]); - expect(sharedLinkMock.getAll).toHaveBeenCalledWith(authStub.user1.user.id); + expect(sharedLinkMock.getAll).toHaveBeenCalledWith({ userId: authStub.user1.user.id }); }); }); diff --git a/server/src/services/shared-link.service.ts b/server/src/services/shared-link.service.ts index a015bbe3f3..74595bb9a2 100644 --- a/server/src/services/shared-link.service.ts +++ b/server/src/services/shared-link.service.ts @@ -9,6 +9,7 @@ import { SharedLinkEditDto, SharedLinkPasswordDto, SharedLinkResponseDto, + SharedLinkSearchDto, } from 'src/dtos/shared-link.dto'; import { SharedLinkEntity } from 'src/entities/shared-link.entity'; import { Permission, SharedLinkType } from 'src/enum'; @@ -17,8 +18,10 @@ import { getExternalDomain, OpenGraphTags } from 'src/utils/misc'; @Injectable() export class SharedLinkService extends BaseService { - async getAll(auth: AuthDto): Promise { - return this.sharedLinkRepository.getAll(auth.user.id).then((links) => links.map((link) => mapSharedLink(link))); + async getAll(auth: AuthDto, { albumId }: SharedLinkSearchDto): Promise { + return this.sharedLinkRepository + .getAll({ userId: auth.user.id, albumId }) + .then((links) => links.map((link) => mapSharedLink(link))); } async getMine(auth: AuthDto, dto: SharedLinkPasswordDto): Promise { diff --git a/web/src/lib/components/album-page/album-shared-link.svelte b/web/src/lib/components/album-page/album-shared-link.svelte new file mode 100644 index 0000000000..55c08c4d12 --- /dev/null +++ b/web/src/lib/components/album-page/album-shared-link.svelte @@ -0,0 +1,40 @@ + + +
+
+ {sharedLink.description || album.albumName} + {[ + DateTime.fromISO(sharedLink.createdAt).toLocaleString( + { + month: 'long', + day: 'numeric', + year: 'numeric', + }, + { locale: $locale }, + ), + sharedLink.allowUpload && $t('upload'), + sharedLink.allowDownload && $t('download'), + sharedLink.showMetadata && $t('exif'), + sharedLink.password && $t('password'), + ] + .filter(Boolean) + .join(' • ')} +
+ +
diff --git a/web/src/lib/components/album-page/user-selection-modal.svelte b/web/src/lib/components/album-page/user-selection-modal.svelte index 85155866f9..96e3a1672e 100644 --- a/web/src/lib/components/album-page/user-selection-modal.svelte +++ b/web/src/lib/components/album-page/user-selection-modal.svelte @@ -1,4 +1,5 @@ - + {#if Object.keys(selectedUsers).length > 0}
-

{$t('selected').toUpperCase()}

+

{$t('selected')}

{#each Object.values(selectedUsers) as { user }} {#key user.id} @@ -117,7 +113,7 @@
{#if users.length > 0 && users.length !== Object.keys(selectedUsers).length} -

{$t('suggestions').toUpperCase()}

+ {$t('users')}
{#each users as user} @@ -144,9 +140,9 @@ {#if users.length > 0}
{/if} -
+
-
- + +
+ {$t('shared_links')} + {$t('view_all')} +
- {#if sharedLinks.length} - - -

{$t('view_links')}

-
- {/if} -
+ + {#each sharedLinks as sharedLink} + + {/each} + + + + diff --git a/web/src/routes/(user)/shared-links/[[id=id]]/+page.svelte b/web/src/routes/(user)/shared-links/[[id=id]]/+page.svelte index 436f3b47de..49f9f32b5b 100644 --- a/web/src/routes/(user)/shared-links/[[id=id]]/+page.svelte +++ b/web/src/routes/(user)/shared-links/[[id=id]]/+page.svelte @@ -27,7 +27,7 @@ let sharedLink = $derived(sharedLinks.find(({ id }) => id === page.params.id)); const refresh = async () => { - sharedLinks = await getAllSharedLinks(); + sharedLinks = await getAllSharedLinks({}); }; onMount(async () => { From 03948a69e25a6273f79c62ad600c51df957c6b39 Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Fri, 7 Feb 2025 17:26:49 -0500 Subject: [PATCH 101/395] refactor: system metadata repository (#15954) --- server/src/cores/storage.core.ts | 3 +-- server/src/interfaces/system-metadata.interface.ts | 10 ---------- server/src/repositories/index.ts | 3 +-- server/src/repositories/map.repository.ts | 6 +++--- server/src/repositories/system-metadata.repository.ts | 3 +-- server/src/services/asset.service.spec.ts | 2 +- server/src/services/auth.service.spec.ts | 3 +-- server/src/services/backup.service.spec.ts | 3 +-- server/src/services/base.service.ts | 4 ++-- server/src/services/cli.service.spec.ts | 2 +- server/src/services/duplicate.service.spec.ts | 3 +-- server/src/services/media.service.spec.ts | 3 +-- server/src/services/metadata.service.spec.ts | 9 +++++++-- server/src/services/notification.service.spec.ts | 3 +-- server/src/services/person.service.spec.ts | 3 +-- server/src/services/server.service.spec.ts | 2 +- server/src/services/smart-info.service.spec.ts | 3 +-- server/src/services/storage-template.service.spec.ts | 2 +- server/src/services/storage.service.spec.ts | 3 +-- server/src/services/system-config.service.spec.ts | 3 +-- server/src/services/system-metadata.service.spec.ts | 2 +- server/src/services/user.service.spec.ts | 2 +- server/src/services/version.service.spec.ts | 9 +++++++-- server/src/types.ts | 2 ++ server/src/utils/config.ts | 3 +-- .../repositories/system-metadata.repository.mock.ts | 2 +- server/test/utils.ts | 4 +++- 27 files changed, 44 insertions(+), 53 deletions(-) delete mode 100644 server/src/interfaces/system-metadata.interface.ts diff --git a/server/src/cores/storage.core.ts b/server/src/cores/storage.core.ts index 50b07981a6..0dc225b90c 100644 --- a/server/src/cores/storage.core.ts +++ b/server/src/cores/storage.core.ts @@ -9,8 +9,7 @@ import { ICryptoRepository } from 'src/interfaces/crypto.interface'; import { IMoveRepository } from 'src/interfaces/move.interface'; import { IPersonRepository } from 'src/interfaces/person.interface'; import { IStorageRepository } from 'src/interfaces/storage.interface'; -import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; -import { IConfigRepository, ILoggingRepository } from 'src/types'; +import { IConfigRepository, ILoggingRepository, ISystemMetadataRepository } from 'src/types'; import { getAssetFiles } from 'src/utils/asset.util'; import { getConfig } from 'src/utils/config'; diff --git a/server/src/interfaces/system-metadata.interface.ts b/server/src/interfaces/system-metadata.interface.ts deleted file mode 100644 index fd83d33ee9..0000000000 --- a/server/src/interfaces/system-metadata.interface.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { SystemMetadata } from 'src/entities/system-metadata.entity'; - -export const ISystemMetadataRepository = 'ISystemMetadataRepository'; - -export interface ISystemMetadataRepository { - get(key: T): Promise; - set(key: T, value: SystemMetadata[T]): Promise; - delete(key: T): Promise; - readFile(filename: string): Promise; -} diff --git a/server/src/repositories/index.ts b/server/src/repositories/index.ts index b54c69e117..d6eddc4ccf 100644 --- a/server/src/repositories/index.ts +++ b/server/src/repositories/index.ts @@ -15,7 +15,6 @@ import { ISessionRepository } from 'src/interfaces/session.interface'; import { ISharedLinkRepository } from 'src/interfaces/shared-link.interface'; import { IStackRepository } from 'src/interfaces/stack.interface'; import { IStorageRepository } from 'src/interfaces/storage.interface'; -import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; import { ITagRepository } from 'src/interfaces/tag.interface'; import { IUserRepository } from 'src/interfaces/user.interface'; import { AccessRepository } from 'src/repositories/access.repository'; @@ -74,6 +73,7 @@ export const repositories = [ NotificationRepository, OAuthRepository, ServerInfoRepository, + SystemMetadataRepository, TelemetryRepository, TrashRepository, ViewRepository, @@ -98,7 +98,6 @@ export const providers = [ { provide: ISharedLinkRepository, useClass: SharedLinkRepository }, { provide: IStackRepository, useClass: StackRepository }, { provide: IStorageRepository, useClass: StorageRepository }, - { provide: ISystemMetadataRepository, useClass: SystemMetadataRepository }, { provide: ITagRepository, useClass: TagRepository }, { provide: IUserRepository, useClass: UserRepository }, ]; diff --git a/server/src/repositories/map.repository.ts b/server/src/repositories/map.repository.ts index fcfa74a5d0..d813ff29f2 100644 --- a/server/src/repositories/map.repository.ts +++ b/server/src/repositories/map.repository.ts @@ -1,4 +1,4 @@ -import { Inject, Injectable } from '@nestjs/common'; +import { Injectable } from '@nestjs/common'; import { getName } from 'i18n-iso-countries'; import { Expression, Kysely, sql, SqlBool } from 'kysely'; import { InjectKysely } from 'nestjs-kysely'; @@ -11,9 +11,9 @@ import { DB, GeodataPlaces, NaturalearthCountries } from 'src/db'; import { DummyValue, GenerateSql } from 'src/decorators'; import { NaturalEarthCountriesTempEntity } from 'src/entities/natural-earth-countries.entity'; import { LogLevel, SystemMetadataKey } from 'src/enum'; -import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; import { ConfigRepository } from 'src/repositories/config.repository'; import { LoggingRepository } from 'src/repositories/logging.repository'; +import { SystemMetadataRepository } from 'src/repositories/system-metadata.repository'; export interface MapMarkerSearchOptions { isArchived?: boolean; @@ -48,7 +48,7 @@ interface MapDB extends DB { export class MapRepository { constructor( private configRepository: ConfigRepository, - @Inject(ISystemMetadataRepository) private metadataRepository: ISystemMetadataRepository, + private metadataRepository: SystemMetadataRepository, private logger: LoggingRepository, @InjectKysely() private db: Kysely, ) { diff --git a/server/src/repositories/system-metadata.repository.ts b/server/src/repositories/system-metadata.repository.ts index 7cd4d715e2..a110b9bc44 100644 --- a/server/src/repositories/system-metadata.repository.ts +++ b/server/src/repositories/system-metadata.repository.ts @@ -5,12 +5,11 @@ import { readFile } from 'node:fs/promises'; import { DB, SystemMetadata as DbSystemMetadata } from 'src/db'; import { GenerateSql } from 'src/decorators'; import { SystemMetadata } from 'src/entities/system-metadata.entity'; -import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; type Upsert = Insertable; @Injectable() -export class SystemMetadataRepository implements ISystemMetadataRepository { +export class SystemMetadataRepository { constructor(@InjectKysely() private db: Kysely) {} @GenerateSql({ params: ['metadata_key'] }) diff --git a/server/src/services/asset.service.spec.ts b/server/src/services/asset.service.spec.ts index 8ff846d39d..f1e537a3d8 100755 --- a/server/src/services/asset.service.spec.ts +++ b/server/src/services/asset.service.spec.ts @@ -9,9 +9,9 @@ import { IEventRepository } from 'src/interfaces/event.interface'; import { IJobRepository, JobName, JobStatus } from 'src/interfaces/job.interface'; import { IPartnerRepository } from 'src/interfaces/partner.interface'; import { IStackRepository } from 'src/interfaces/stack.interface'; -import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; import { IUserRepository } from 'src/interfaces/user.interface'; import { AssetService } from 'src/services/asset.service'; +import { ISystemMetadataRepository } from 'src/types'; import { assetStub } from 'test/fixtures/asset.stub'; import { authStub } from 'test/fixtures/auth.stub'; import { faceStub } from 'test/fixtures/face.stub'; diff --git a/server/src/services/auth.service.spec.ts b/server/src/services/auth.service.spec.ts index 780d802922..dc9f2162f4 100644 --- a/server/src/services/auth.service.spec.ts +++ b/server/src/services/auth.service.spec.ts @@ -7,10 +7,9 @@ import { ICryptoRepository } from 'src/interfaces/crypto.interface'; import { IEventRepository } from 'src/interfaces/event.interface'; import { ISessionRepository } from 'src/interfaces/session.interface'; import { ISharedLinkRepository } from 'src/interfaces/shared-link.interface'; -import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; import { IUserRepository } from 'src/interfaces/user.interface'; import { AuthService } from 'src/services/auth.service'; -import { IApiKeyRepository, IOAuthRepository } from 'src/types'; +import { IApiKeyRepository, IOAuthRepository, ISystemMetadataRepository } from 'src/types'; import { keyStub } from 'test/fixtures/api-key.stub'; import { authStub } from 'test/fixtures/auth.stub'; import { sessionStub } from 'test/fixtures/session.stub'; diff --git a/server/src/services/backup.service.spec.ts b/server/src/services/backup.service.spec.ts index 33d77a59aa..015a51a39c 100644 --- a/server/src/services/backup.service.spec.ts +++ b/server/src/services/backup.service.spec.ts @@ -6,9 +6,8 @@ import { IDatabaseRepository } from 'src/interfaces/database.interface'; import { JobStatus } from 'src/interfaces/job.interface'; import { IProcessRepository } from 'src/interfaces/process.interface'; import { IStorageRepository } from 'src/interfaces/storage.interface'; -import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; import { BackupService } from 'src/services/backup.service'; -import { IConfigRepository, ICronRepository } from 'src/types'; +import { IConfigRepository, ICronRepository, ISystemMetadataRepository } from 'src/types'; import { systemConfigStub } from 'test/fixtures/system-config.stub'; import { mockSpawn, newTestService } from 'test/utils'; import { describe, Mocked } from 'vitest'; diff --git a/server/src/services/base.service.ts b/server/src/services/base.service.ts index 865a16a9da..acceadd0aa 100644 --- a/server/src/services/base.service.ts +++ b/server/src/services/base.service.ts @@ -23,7 +23,6 @@ import { ISessionRepository } from 'src/interfaces/session.interface'; import { ISharedLinkRepository } from 'src/interfaces/shared-link.interface'; import { IStackRepository } from 'src/interfaces/stack.interface'; import { IStorageRepository } from 'src/interfaces/storage.interface'; -import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; import { ITagRepository } from 'src/interfaces/tag.interface'; import { IUserRepository } from 'src/interfaces/user.interface'; import { AccessRepository } from 'src/repositories/access.repository'; @@ -41,6 +40,7 @@ import { MetadataRepository } from 'src/repositories/metadata.repository'; import { NotificationRepository } from 'src/repositories/notification.repository'; import { OAuthRepository } from 'src/repositories/oauth.repository'; import { ServerInfoRepository } from 'src/repositories/server-info.repository'; +import { SystemMetadataRepository } from 'src/repositories/system-metadata.repository'; import { TelemetryRepository } from 'src/repositories/telemetry.repository'; import { TrashRepository } from 'src/repositories/trash.repository'; import { VersionHistoryRepository } from 'src/repositories/version-history.repository'; @@ -84,7 +84,7 @@ export class BaseService { @Inject(ISharedLinkRepository) protected sharedLinkRepository: ISharedLinkRepository, @Inject(IStackRepository) protected stackRepository: IStackRepository, @Inject(IStorageRepository) protected storageRepository: IStorageRepository, - @Inject(ISystemMetadataRepository) protected systemMetadataRepository: ISystemMetadataRepository, + protected systemMetadataRepository: SystemMetadataRepository, @Inject(ITagRepository) protected tagRepository: ITagRepository, protected telemetryRepository: TelemetryRepository, protected trashRepository: TrashRepository, diff --git a/server/src/services/cli.service.spec.ts b/server/src/services/cli.service.spec.ts index 149b030e50..987af3a287 100644 --- a/server/src/services/cli.service.spec.ts +++ b/server/src/services/cli.service.spec.ts @@ -1,6 +1,6 @@ -import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; import { IUserRepository } from 'src/interfaces/user.interface'; import { CliService } from 'src/services/cli.service'; +import { ISystemMetadataRepository } from 'src/types'; import { userStub } from 'test/fixtures/user.stub'; import { newTestService } from 'test/utils'; import { Mocked, describe, it } from 'vitest'; diff --git a/server/src/services/duplicate.service.spec.ts b/server/src/services/duplicate.service.spec.ts index 3c332edbe9..6fdb9c2b5c 100644 --- a/server/src/services/duplicate.service.spec.ts +++ b/server/src/services/duplicate.service.spec.ts @@ -1,10 +1,9 @@ import { IAssetRepository, WithoutProperty } from 'src/interfaces/asset.interface'; import { IJobRepository, JobName, JobStatus } from 'src/interfaces/job.interface'; import { ISearchRepository } from 'src/interfaces/search.interface'; -import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; import { DuplicateService } from 'src/services/duplicate.service'; import { SearchService } from 'src/services/search.service'; -import { ILoggingRepository } from 'src/types'; +import { ILoggingRepository, ISystemMetadataRepository } from 'src/types'; import { assetStub } from 'test/fixtures/asset.stub'; import { authStub } from 'test/fixtures/auth.stub'; import { newTestService } from 'test/utils'; diff --git a/server/src/services/media.service.spec.ts b/server/src/services/media.service.spec.ts index 9ce6c8edb9..9844aa7f0f 100644 --- a/server/src/services/media.service.spec.ts +++ b/server/src/services/media.service.spec.ts @@ -18,9 +18,8 @@ import { IJobRepository, JobCounts, JobName, JobStatus } from 'src/interfaces/jo import { IMoveRepository } from 'src/interfaces/move.interface'; import { IPersonRepository } from 'src/interfaces/person.interface'; import { IStorageRepository } from 'src/interfaces/storage.interface'; -import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; import { MediaService } from 'src/services/media.service'; -import { ILoggingRepository, IMediaRepository, RawImageInfo } from 'src/types'; +import { ILoggingRepository, IMediaRepository, ISystemMetadataRepository, RawImageInfo } from 'src/types'; import { assetStub } from 'test/fixtures/asset.stub'; import { faceStub } from 'test/fixtures/face.stub'; import { probeStub } from 'test/fixtures/media.stub'; diff --git a/server/src/services/metadata.service.spec.ts b/server/src/services/metadata.service.spec.ts index ffc3c171dc..6384c17a42 100644 --- a/server/src/services/metadata.service.spec.ts +++ b/server/src/services/metadata.service.spec.ts @@ -12,12 +12,17 @@ import { IEventRepository } from 'src/interfaces/event.interface'; import { IJobRepository, JobName, JobStatus } from 'src/interfaces/job.interface'; import { IPersonRepository } from 'src/interfaces/person.interface'; import { IStorageRepository } from 'src/interfaces/storage.interface'; -import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; import { ITagRepository } from 'src/interfaces/tag.interface'; import { IUserRepository } from 'src/interfaces/user.interface'; import { ImmichTags } from 'src/repositories/metadata.repository'; import { MetadataService } from 'src/services/metadata.service'; -import { IConfigRepository, IMapRepository, IMediaRepository, IMetadataRepository } from 'src/types'; +import { + IConfigRepository, + IMapRepository, + IMediaRepository, + IMetadataRepository, + ISystemMetadataRepository, +} from 'src/types'; import { assetStub } from 'test/fixtures/asset.stub'; import { fileStub } from 'test/fixtures/file.stub'; import { probeStub } from 'test/fixtures/media.stub'; diff --git a/server/src/services/notification.service.spec.ts b/server/src/services/notification.service.spec.ts index 671cae0774..41e9e35ff8 100644 --- a/server/src/services/notification.service.spec.ts +++ b/server/src/services/notification.service.spec.ts @@ -8,11 +8,10 @@ import { IAlbumRepository } from 'src/interfaces/album.interface'; import { IAssetRepository } from 'src/interfaces/asset.interface'; import { IEventRepository } from 'src/interfaces/event.interface'; import { IJobRepository, INotifyAlbumUpdateJob, JobName, JobStatus } from 'src/interfaces/job.interface'; -import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; import { IUserRepository } from 'src/interfaces/user.interface'; import { EmailTemplate } from 'src/repositories/notification.repository'; import { NotificationService } from 'src/services/notification.service'; -import { INotificationRepository } from 'src/types'; +import { INotificationRepository, ISystemMetadataRepository } from 'src/types'; import { albumStub } from 'test/fixtures/album.stub'; import { assetStub } from 'test/fixtures/asset.stub'; import { userStub } from 'test/fixtures/user.stub'; diff --git a/server/src/services/person.service.spec.ts b/server/src/services/person.service.spec.ts index 1cd1b34ec4..65cd8815f8 100644 --- a/server/src/services/person.service.spec.ts +++ b/server/src/services/person.service.spec.ts @@ -10,9 +10,8 @@ import { DetectedFaces, IMachineLearningRepository } from 'src/interfaces/machin import { IPersonRepository } from 'src/interfaces/person.interface'; import { FaceSearchResult, ISearchRepository } from 'src/interfaces/search.interface'; import { IStorageRepository } from 'src/interfaces/storage.interface'; -import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; import { PersonService } from 'src/services/person.service'; -import { IMediaRepository } from 'src/types'; +import { IMediaRepository, ISystemMetadataRepository } from 'src/types'; import { ImmichFileResponse } from 'src/utils/file'; import { assetStub } from 'test/fixtures/asset.stub'; import { authStub } from 'test/fixtures/auth.stub'; diff --git a/server/src/services/server.service.spec.ts b/server/src/services/server.service.spec.ts index 3f7fafcebf..4a405629c9 100644 --- a/server/src/services/server.service.spec.ts +++ b/server/src/services/server.service.spec.ts @@ -1,8 +1,8 @@ import { SystemMetadataKey } from 'src/enum'; import { IStorageRepository } from 'src/interfaces/storage.interface'; -import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; import { IUserRepository } from 'src/interfaces/user.interface'; import { ServerService } from 'src/services/server.service'; +import { ISystemMetadataRepository } from 'src/types'; import { newTestService } from 'test/utils'; import { Mocked } from 'vitest'; diff --git a/server/src/services/smart-info.service.spec.ts b/server/src/services/smart-info.service.spec.ts index ff0dcc3160..1b985ab421 100644 --- a/server/src/services/smart-info.service.spec.ts +++ b/server/src/services/smart-info.service.spec.ts @@ -5,9 +5,8 @@ import { IDatabaseRepository } from 'src/interfaces/database.interface'; import { IJobRepository, JobName, JobStatus } from 'src/interfaces/job.interface'; import { IMachineLearningRepository } from 'src/interfaces/machine-learning.interface'; import { ISearchRepository } from 'src/interfaces/search.interface'; -import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; import { SmartInfoService } from 'src/services/smart-info.service'; -import { IConfigRepository } from 'src/types'; +import { IConfigRepository, ISystemMetadataRepository } from 'src/types'; import { getCLIPModelInfo } from 'src/utils/misc'; import { assetStub } from 'test/fixtures/asset.stub'; import { systemConfigStub } from 'test/fixtures/system-config.stub'; diff --git a/server/src/services/storage-template.service.spec.ts b/server/src/services/storage-template.service.spec.ts index 46ec4f53e1..467456d5aa 100644 --- a/server/src/services/storage-template.service.spec.ts +++ b/server/src/services/storage-template.service.spec.ts @@ -8,9 +8,9 @@ import { ICryptoRepository } from 'src/interfaces/crypto.interface'; import { JobStatus } from 'src/interfaces/job.interface'; import { IMoveRepository } from 'src/interfaces/move.interface'; import { IStorageRepository } from 'src/interfaces/storage.interface'; -import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; import { IUserRepository } from 'src/interfaces/user.interface'; import { StorageTemplateService } from 'src/services/storage-template.service'; +import { ISystemMetadataRepository } from 'src/types'; import { albumStub } from 'test/fixtures/album.stub'; import { assetStub } from 'test/fixtures/asset.stub'; import { userStub } from 'test/fixtures/user.stub'; diff --git a/server/src/services/storage.service.spec.ts b/server/src/services/storage.service.spec.ts index 3a5bf3bad9..d92fd09e53 100644 --- a/server/src/services/storage.service.spec.ts +++ b/server/src/services/storage.service.spec.ts @@ -1,8 +1,7 @@ import { SystemMetadataKey } from 'src/enum'; import { IStorageRepository } from 'src/interfaces/storage.interface'; -import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; import { StorageService } from 'src/services/storage.service'; -import { IConfigRepository, ILoggingRepository } from 'src/types'; +import { IConfigRepository, ILoggingRepository, ISystemMetadataRepository } from 'src/types'; import { ImmichStartupError } from 'src/utils/misc'; import { mockEnvData } from 'test/repositories/config.repository.mock'; import { newTestService } from 'test/utils'; diff --git a/server/src/services/system-config.service.spec.ts b/server/src/services/system-config.service.spec.ts index 02166cdeb8..537ef21056 100644 --- a/server/src/services/system-config.service.spec.ts +++ b/server/src/services/system-config.service.spec.ts @@ -14,9 +14,8 @@ import { } from 'src/enum'; import { IEventRepository } from 'src/interfaces/event.interface'; import { QueueName } from 'src/interfaces/job.interface'; -import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; import { SystemConfigService } from 'src/services/system-config.service'; -import { DeepPartial, IConfigRepository, ILoggingRepository } from 'src/types'; +import { DeepPartial, IConfigRepository, ILoggingRepository, ISystemMetadataRepository } from 'src/types'; import { mockEnvData } from 'test/repositories/config.repository.mock'; import { newTestService } from 'test/utils'; import { Mocked } from 'vitest'; diff --git a/server/src/services/system-metadata.service.spec.ts b/server/src/services/system-metadata.service.spec.ts index 3dc2f0a6bb..071626d593 100644 --- a/server/src/services/system-metadata.service.spec.ts +++ b/server/src/services/system-metadata.service.spec.ts @@ -1,6 +1,6 @@ import { SystemMetadataKey } from 'src/enum'; -import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; import { SystemMetadataService } from 'src/services/system-metadata.service'; +import { ISystemMetadataRepository } from 'src/types'; import { newTestService } from 'test/utils'; import { Mocked } from 'vitest'; diff --git a/server/src/services/user.service.spec.ts b/server/src/services/user.service.spec.ts index cb7c2f08ad..9ed941c36c 100644 --- a/server/src/services/user.service.spec.ts +++ b/server/src/services/user.service.spec.ts @@ -4,9 +4,9 @@ import { CacheControl, UserMetadataKey } from 'src/enum'; import { IAlbumRepository } from 'src/interfaces/album.interface'; import { IJobRepository, JobName } from 'src/interfaces/job.interface'; import { IStorageRepository } from 'src/interfaces/storage.interface'; -import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; import { IUserRepository } from 'src/interfaces/user.interface'; import { UserService } from 'src/services/user.service'; +import { ISystemMetadataRepository } from 'src/types'; import { ImmichFileResponse } from 'src/utils/file'; import { authStub } from 'test/fixtures/auth.stub'; import { systemConfigStub } from 'test/fixtures/system-config.stub'; diff --git a/server/src/services/version.service.spec.ts b/server/src/services/version.service.spec.ts index 406d3c1439..1fe55afc45 100644 --- a/server/src/services/version.service.spec.ts +++ b/server/src/services/version.service.spec.ts @@ -4,9 +4,14 @@ import { serverVersion } from 'src/constants'; import { ImmichEnvironment, SystemMetadataKey } from 'src/enum'; import { IEventRepository } from 'src/interfaces/event.interface'; import { IJobRepository, JobName, JobStatus } from 'src/interfaces/job.interface'; -import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; import { VersionService } from 'src/services/version.service'; -import { IConfigRepository, ILoggingRepository, IServerInfoRepository, IVersionHistoryRepository } from 'src/types'; +import { + IConfigRepository, + ILoggingRepository, + IServerInfoRepository, + ISystemMetadataRepository, + IVersionHistoryRepository, +} from 'src/types'; import { mockEnvData } from 'test/repositories/config.repository.mock'; import { newTestService } from 'test/utils'; import { Mocked } from 'vitest'; diff --git a/server/src/types.ts b/server/src/types.ts index 9928669136..c74dac5753 100644 --- a/server/src/types.ts +++ b/server/src/types.ts @@ -15,6 +15,7 @@ import { MetadataRepository } from 'src/repositories/metadata.repository'; import { NotificationRepository } from 'src/repositories/notification.repository'; import { OAuthRepository } from 'src/repositories/oauth.repository'; import { ServerInfoRepository } from 'src/repositories/server-info.repository'; +import { SystemMetadataRepository } from 'src/repositories/system-metadata.repository'; import { MetricGroupRepository, TelemetryRepository } from 'src/repositories/telemetry.repository'; import { TrashRepository } from 'src/repositories/trash.repository'; import { VersionHistoryRepository } from 'src/repositories/version-history.repository'; @@ -59,6 +60,7 @@ export type IMetricGroupRepository = RepositoryInterface; export type INotificationRepository = RepositoryInterface; export type IOAuthRepository = RepositoryInterface; export type IServerInfoRepository = RepositoryInterface; +export type ISystemMetadataRepository = RepositoryInterface; export type ITelemetryRepository = RepositoryInterface; export type ITrashRepository = RepositoryInterface; export type IViewRepository = RepositoryInterface; diff --git a/server/src/utils/config.ts b/server/src/utils/config.ts index cd28c63618..64a0a37c86 100644 --- a/server/src/utils/config.ts +++ b/server/src/utils/config.ts @@ -7,8 +7,7 @@ import { SystemConfig, defaults } from 'src/config'; import { SystemConfigDto } from 'src/dtos/system-config.dto'; import { SystemMetadataKey } from 'src/enum'; import { DatabaseLock } from 'src/interfaces/database.interface'; -import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; -import { DeepPartial, IConfigRepository, ILoggingRepository } from 'src/types'; +import { DeepPartial, IConfigRepository, ILoggingRepository, ISystemMetadataRepository } from 'src/types'; import { getKeysDeep, unsetDeep } from 'src/utils/misc'; export type SystemConfigValidator = (config: SystemConfig, newConfig: SystemConfig) => void | Promise; diff --git a/server/test/repositories/system-metadata.repository.mock.ts b/server/test/repositories/system-metadata.repository.mock.ts index 793dd4c1c0..b96b525697 100644 --- a/server/test/repositories/system-metadata.repository.mock.ts +++ b/server/test/repositories/system-metadata.repository.mock.ts @@ -1,4 +1,4 @@ -import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; +import { ISystemMetadataRepository } from 'src/types'; import { clearConfigCache } from 'src/utils/config'; import { Mocked, vitest } from 'vitest'; diff --git a/server/test/utils.ts b/server/test/utils.ts index 94377ca18c..02032d114b 100644 --- a/server/test/utils.ts +++ b/server/test/utils.ts @@ -16,6 +16,7 @@ import { MetadataRepository } from 'src/repositories/metadata.repository'; import { NotificationRepository } from 'src/repositories/notification.repository'; import { OAuthRepository } from 'src/repositories/oauth.repository'; import { ServerInfoRepository } from 'src/repositories/server-info.repository'; +import { SystemMetadataRepository } from 'src/repositories/system-metadata.repository'; import { TelemetryRepository } from 'src/repositories/telemetry.repository'; import { TrashRepository } from 'src/repositories/trash.repository'; import { VersionHistoryRepository } from 'src/repositories/version-history.repository'; @@ -36,6 +37,7 @@ import { INotificationRepository, IOAuthRepository, IServerInfoRepository, + ISystemMetadataRepository, ITrashRepository, IVersionHistoryRepository, IViewRepository, @@ -170,7 +172,7 @@ export const newTestService = ( sharedLinkMock, stackMock, storageMock, - systemMock, + systemMock as ISystemMetadataRepository as SystemMetadataRepository, tagMock, telemetryMock as unknown as TelemetryRepository, trashMock as ITrashRepository as TrashRepository, From d7d4d22fe0340f3213c358c95c8d912e92a01b8b Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Fri, 7 Feb 2025 18:04:04 -0500 Subject: [PATCH 102/395] refactor: process repository (#15956) --- server/src/interfaces/process.interface.ts | 25 ------------------- server/src/repositories/index.ts | 3 +-- server/src/repositories/process.repository.ts | 6 ++--- server/src/services/backup.service.spec.ts | 3 +-- server/src/services/base.service.ts | 4 +-- server/src/types.ts | 2 ++ .../repositories/process.repository.mock.ts | 2 +- server/test/utils.ts | 4 ++- 8 files changed, 12 insertions(+), 37 deletions(-) delete mode 100644 server/src/interfaces/process.interface.ts diff --git a/server/src/interfaces/process.interface.ts b/server/src/interfaces/process.interface.ts deleted file mode 100644 index 14a8c1ff33..0000000000 --- a/server/src/interfaces/process.interface.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { ChildProcessWithoutNullStreams, SpawnOptionsWithoutStdio } from 'node:child_process'; -import { Readable } from 'node:stream'; - -export interface ImmichReadStream { - stream: Readable; - type?: string; - length?: number; -} - -export interface ImmichZipStream extends ImmichReadStream { - addFile: (inputPath: string, filename: string) => void; - finalize: () => Promise; -} - -export interface DiskUsage { - available: number; - free: number; - total: number; -} - -export const IProcessRepository = 'IProcessRepository'; - -export interface IProcessRepository { - spawn(command: string, args?: readonly string[], options?: SpawnOptionsWithoutStdio): ChildProcessWithoutNullStreams; -} diff --git a/server/src/repositories/index.ts b/server/src/repositories/index.ts index d6eddc4ccf..0c3cf2cd77 100644 --- a/server/src/repositories/index.ts +++ b/server/src/repositories/index.ts @@ -9,7 +9,6 @@ import { IMachineLearningRepository } from 'src/interfaces/machine-learning.inte import { IMoveRepository } from 'src/interfaces/move.interface'; import { IPartnerRepository } from 'src/interfaces/partner.interface'; import { IPersonRepository } from 'src/interfaces/person.interface'; -import { IProcessRepository } from 'src/interfaces/process.interface'; import { ISearchRepository } from 'src/interfaces/search.interface'; import { ISessionRepository } from 'src/interfaces/session.interface'; import { ISharedLinkRepository } from 'src/interfaces/shared-link.interface'; @@ -72,6 +71,7 @@ export const repositories = [ MetadataRepository, NotificationRepository, OAuthRepository, + ProcessRepository, ServerInfoRepository, SystemMetadataRepository, TelemetryRepository, @@ -92,7 +92,6 @@ export const providers = [ { provide: IMoveRepository, useClass: MoveRepository }, { provide: IPartnerRepository, useClass: PartnerRepository }, { provide: IPersonRepository, useClass: PersonRepository }, - { provide: IProcessRepository, useClass: ProcessRepository }, { provide: ISearchRepository, useClass: SearchRepository }, { provide: ISessionRepository, useClass: SessionRepository }, { provide: ISharedLinkRepository, useClass: SharedLinkRepository }, diff --git a/server/src/repositories/process.repository.ts b/server/src/repositories/process.repository.ts index bd533129f9..d065554b0c 100644 --- a/server/src/repositories/process.repository.ts +++ b/server/src/repositories/process.repository.ts @@ -1,13 +1,11 @@ import { Injectable } from '@nestjs/common'; import { ChildProcessWithoutNullStreams, spawn, SpawnOptionsWithoutStdio } from 'node:child_process'; -import { IProcessRepository } from 'src/interfaces/process.interface'; import { LoggingRepository } from 'src/repositories/logging.repository'; -import { StorageRepository } from 'src/repositories/storage.repository'; @Injectable() -export class ProcessRepository implements IProcessRepository { +export class ProcessRepository { constructor(private logger: LoggingRepository) { - this.logger.setContext(StorageRepository.name); + this.logger.setContext(ProcessRepository.name); } spawn(command: string, args: readonly string[], options?: SpawnOptionsWithoutStdio): ChildProcessWithoutNullStreams { diff --git a/server/src/services/backup.service.spec.ts b/server/src/services/backup.service.spec.ts index 015a51a39c..7b8454f61e 100644 --- a/server/src/services/backup.service.spec.ts +++ b/server/src/services/backup.service.spec.ts @@ -4,10 +4,9 @@ import { StorageCore } from 'src/cores/storage.core'; import { ImmichWorker, StorageFolder } from 'src/enum'; import { IDatabaseRepository } from 'src/interfaces/database.interface'; import { JobStatus } from 'src/interfaces/job.interface'; -import { IProcessRepository } from 'src/interfaces/process.interface'; import { IStorageRepository } from 'src/interfaces/storage.interface'; import { BackupService } from 'src/services/backup.service'; -import { IConfigRepository, ICronRepository, ISystemMetadataRepository } from 'src/types'; +import { IConfigRepository, ICronRepository, IProcessRepository, ISystemMetadataRepository } from 'src/types'; import { systemConfigStub } from 'test/fixtures/system-config.stub'; import { mockSpawn, newTestService } from 'test/utils'; import { describe, Mocked } from 'vitest'; diff --git a/server/src/services/base.service.ts b/server/src/services/base.service.ts index acceadd0aa..15e374a411 100644 --- a/server/src/services/base.service.ts +++ b/server/src/services/base.service.ts @@ -17,7 +17,6 @@ import { IMachineLearningRepository } from 'src/interfaces/machine-learning.inte import { IMoveRepository } from 'src/interfaces/move.interface'; import { IPartnerRepository } from 'src/interfaces/partner.interface'; import { IPersonRepository } from 'src/interfaces/person.interface'; -import { IProcessRepository } from 'src/interfaces/process.interface'; import { ISearchRepository } from 'src/interfaces/search.interface'; import { ISessionRepository } from 'src/interfaces/session.interface'; import { ISharedLinkRepository } from 'src/interfaces/shared-link.interface'; @@ -39,6 +38,7 @@ import { MemoryRepository } from 'src/repositories/memory.repository'; import { MetadataRepository } from 'src/repositories/metadata.repository'; import { NotificationRepository } from 'src/repositories/notification.repository'; import { OAuthRepository } from 'src/repositories/oauth.repository'; +import { ProcessRepository } from 'src/repositories/process.repository'; import { ServerInfoRepository } from 'src/repositories/server-info.repository'; import { SystemMetadataRepository } from 'src/repositories/system-metadata.repository'; import { TelemetryRepository } from 'src/repositories/telemetry.repository'; @@ -77,7 +77,7 @@ export class BaseService { protected oauthRepository: OAuthRepository, @Inject(IPartnerRepository) protected partnerRepository: IPartnerRepository, @Inject(IPersonRepository) protected personRepository: IPersonRepository, - @Inject(IProcessRepository) protected processRepository: IProcessRepository, + protected processRepository: ProcessRepository, @Inject(ISearchRepository) protected searchRepository: ISearchRepository, protected serverInfoRepository: ServerInfoRepository, @Inject(ISessionRepository) protected sessionRepository: ISessionRepository, diff --git a/server/src/types.ts b/server/src/types.ts index c74dac5753..471a928a98 100644 --- a/server/src/types.ts +++ b/server/src/types.ts @@ -14,6 +14,7 @@ import { MemoryRepository } from 'src/repositories/memory.repository'; import { MetadataRepository } from 'src/repositories/metadata.repository'; import { NotificationRepository } from 'src/repositories/notification.repository'; import { OAuthRepository } from 'src/repositories/oauth.repository'; +import { ProcessRepository } from 'src/repositories/process.repository'; import { ServerInfoRepository } from 'src/repositories/server-info.repository'; import { SystemMetadataRepository } from 'src/repositories/system-metadata.repository'; import { MetricGroupRepository, TelemetryRepository } from 'src/repositories/telemetry.repository'; @@ -59,6 +60,7 @@ export type IMetadataRepository = RepositoryInterface; export type IMetricGroupRepository = RepositoryInterface; export type INotificationRepository = RepositoryInterface; export type IOAuthRepository = RepositoryInterface; +export type IProcessRepository = RepositoryInterface; export type IServerInfoRepository = RepositoryInterface; export type ISystemMetadataRepository = RepositoryInterface; export type ITelemetryRepository = RepositoryInterface; diff --git a/server/test/repositories/process.repository.mock.ts b/server/test/repositories/process.repository.mock.ts index 9a3c5a30b6..0ef1b0fdb1 100644 --- a/server/test/repositories/process.repository.mock.ts +++ b/server/test/repositories/process.repository.mock.ts @@ -1,4 +1,4 @@ -import { IProcessRepository } from 'src/interfaces/process.interface'; +import { IProcessRepository } from 'src/types'; import { Mocked, vitest } from 'vitest'; export const newProcessRepositoryMock = (): Mocked => { diff --git a/server/test/utils.ts b/server/test/utils.ts index 02032d114b..0a3c614159 100644 --- a/server/test/utils.ts +++ b/server/test/utils.ts @@ -15,6 +15,7 @@ import { MemoryRepository } from 'src/repositories/memory.repository'; import { MetadataRepository } from 'src/repositories/metadata.repository'; import { NotificationRepository } from 'src/repositories/notification.repository'; import { OAuthRepository } from 'src/repositories/oauth.repository'; +import { ProcessRepository } from 'src/repositories/process.repository'; import { ServerInfoRepository } from 'src/repositories/server-info.repository'; import { SystemMetadataRepository } from 'src/repositories/system-metadata.repository'; import { TelemetryRepository } from 'src/repositories/telemetry.repository'; @@ -36,6 +37,7 @@ import { IMetadataRepository, INotificationRepository, IOAuthRepository, + IProcessRepository, IServerInfoRepository, ISystemMetadataRepository, ITrashRepository, @@ -165,7 +167,7 @@ export const newTestService = ( oauthMock as IOAuthRepository as OAuthRepository, partnerMock, personMock, - processMock, + processMock as IProcessRepository as ProcessRepository, searchMock, serverInfoMock as IServerInfoRepository as ServerInfoRepository, sessionMock, From 758449e9f07b84298fae407a9199ddfff8981ef9 Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Fri, 7 Feb 2025 18:16:40 -0500 Subject: [PATCH 103/395] refactor: session repository (#15957) --- server/src/dtos/session.dto.ts | 4 +-- server/src/interfaces/session.interface.ts | 17 --------- server/src/repositories/index.ts | 3 +- server/src/repositories/session.repository.ts | 35 ++++++++----------- server/src/services/auth.service.spec.ts | 11 +++--- server/src/services/auth.service.ts | 3 +- server/src/services/base.service.ts | 4 +-- server/src/services/session.service.spec.ts | 8 ++--- server/src/types.ts | 4 +++ .../repositories/session.repository.mock.ts | 2 +- server/test/utils.ts | 4 ++- 11 files changed, 38 insertions(+), 57 deletions(-) delete mode 100644 server/src/interfaces/session.interface.ts diff --git a/server/src/dtos/session.dto.ts b/server/src/dtos/session.dto.ts index d96d7819ad..dab1bf62b5 100644 --- a/server/src/dtos/session.dto.ts +++ b/server/src/dtos/session.dto.ts @@ -1,4 +1,4 @@ -import { SessionEntity } from 'src/entities/session.entity'; +import { SessionItem } from 'src/types'; export class SessionResponseDto { id!: string; @@ -9,7 +9,7 @@ export class SessionResponseDto { deviceOS!: string; } -export const mapSession = (entity: SessionEntity, currentId?: string): SessionResponseDto => ({ +export const mapSession = (entity: SessionItem, currentId?: string): SessionResponseDto => ({ id: entity.id, createdAt: entity.createdAt.toISOString(), updatedAt: entity.updatedAt.toISOString(), diff --git a/server/src/interfaces/session.interface.ts b/server/src/interfaces/session.interface.ts deleted file mode 100644 index 8d695fbfc2..0000000000 --- a/server/src/interfaces/session.interface.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { Insertable, Updateable } from 'kysely'; -import { Sessions } from 'src/db'; -import { SessionEntity } from 'src/entities/session.entity'; - -export const ISessionRepository = 'ISessionRepository'; - -type E = SessionEntity; -export type SessionSearchOptions = { updatedBefore: Date }; - -export interface ISessionRepository { - search(options: SessionSearchOptions): Promise; - create(dto: Insertable): Promise; - update(id: string, dto: Updateable): Promise; - delete(id: string): Promise; - getByToken(token: string): Promise; - getByUserId(userId: string): Promise; -} diff --git a/server/src/repositories/index.ts b/server/src/repositories/index.ts index 0c3cf2cd77..cb870bd339 100644 --- a/server/src/repositories/index.ts +++ b/server/src/repositories/index.ts @@ -10,7 +10,6 @@ import { IMoveRepository } from 'src/interfaces/move.interface'; import { IPartnerRepository } from 'src/interfaces/partner.interface'; import { IPersonRepository } from 'src/interfaces/person.interface'; import { ISearchRepository } from 'src/interfaces/search.interface'; -import { ISessionRepository } from 'src/interfaces/session.interface'; import { ISharedLinkRepository } from 'src/interfaces/shared-link.interface'; import { IStackRepository } from 'src/interfaces/stack.interface'; import { IStorageRepository } from 'src/interfaces/storage.interface'; @@ -72,6 +71,7 @@ export const repositories = [ NotificationRepository, OAuthRepository, ProcessRepository, + SessionRepository, ServerInfoRepository, SystemMetadataRepository, TelemetryRepository, @@ -93,7 +93,6 @@ export const providers = [ { provide: IPartnerRepository, useClass: PartnerRepository }, { provide: IPersonRepository, useClass: PersonRepository }, { provide: ISearchRepository, useClass: SearchRepository }, - { provide: ISessionRepository, useClass: SessionRepository }, { provide: ISharedLinkRepository, useClass: SharedLinkRepository }, { provide: IStackRepository, useClass: StackRepository }, { provide: IStorageRepository, useClass: StorageRepository }, diff --git a/server/src/repositories/session.repository.ts b/server/src/repositories/session.repository.ts index 3e6c897721..3e490bdc84 100644 --- a/server/src/repositories/session.repository.ts +++ b/server/src/repositories/session.repository.ts @@ -3,36 +3,37 @@ import { Insertable, Kysely, Updateable } from 'kysely'; import { InjectKysely } from 'nestjs-kysely'; import { DB, Sessions } from 'src/db'; import { DummyValue, GenerateSql } from 'src/decorators'; -import { SessionEntity, withUser } from 'src/entities/session.entity'; -import { ISessionRepository, SessionSearchOptions } from 'src/interfaces/session.interface'; +import { withUser } from 'src/entities/session.entity'; import { asUuid } from 'src/utils/database'; +export type SessionSearchOptions = { updatedBefore: Date }; + @Injectable() -export class SessionRepository implements ISessionRepository { +export class SessionRepository { constructor(@InjectKysely() private db: Kysely) {} @GenerateSql({ params: [{ updatedBefore: DummyValue.DATE }] }) - search(options: SessionSearchOptions): Promise { + search(options: SessionSearchOptions) { return this.db .selectFrom('sessions') .selectAll() .where('sessions.updatedAt', '<=', options.updatedBefore) - .execute() as Promise; + .execute(); } @GenerateSql({ params: [DummyValue.STRING] }) - getByToken(token: string): Promise { + getByToken(token: string) { return this.db .selectFrom('sessions') .innerJoinLateral(withUser, (join) => join.onTrue()) .selectAll('sessions') .select((eb) => eb.fn.toJson('user').as('user')) .where('sessions.token', '=', token) - .executeTakeFirst() as Promise; + .executeTakeFirst(); } @GenerateSql({ params: [DummyValue.UUID] }) - getByUserId(userId: string): Promise { + getByUserId(userId: string) { return this.db .selectFrom('sessions') .innerJoinLateral(withUser, (join) => join.onTrue()) @@ -41,30 +42,24 @@ export class SessionRepository implements ISessionRepository { .where('sessions.userId', '=', userId) .orderBy('sessions.updatedAt', 'desc') .orderBy('sessions.createdAt', 'desc') - .execute() as unknown as Promise; + .execute(); } - async create(dto: Insertable): Promise { - const { id, token, userId, createdAt, updatedAt, deviceType, deviceOS } = await this.db - .insertInto('sessions') - .values(dto) - .returningAll() - .executeTakeFirstOrThrow(); - - return { id, token, userId, createdAt, updatedAt, deviceType, deviceOS } as SessionEntity; + create(dto: Insertable) { + return this.db.insertInto('sessions').values(dto).returningAll().executeTakeFirstOrThrow(); } - update(id: string, dto: Updateable): Promise { + update(id: string, dto: Updateable) { return this.db .updateTable('sessions') .set(dto) .where('sessions.id', '=', asUuid(id)) .returningAll() - .executeTakeFirstOrThrow() as Promise; + .executeTakeFirstOrThrow(); } @GenerateSql({ params: [DummyValue.UUID] }) - async delete(id: string): Promise { + async delete(id: string) { await this.db.deleteFrom('sessions').where('id', '=', asUuid(id)).execute(); } } diff --git a/server/src/services/auth.service.spec.ts b/server/src/services/auth.service.spec.ts index dc9f2162f4..e3b418d350 100644 --- a/server/src/services/auth.service.spec.ts +++ b/server/src/services/auth.service.spec.ts @@ -5,11 +5,10 @@ import { UserEntity } from 'src/entities/user.entity'; import { AuthType, Permission } from 'src/enum'; import { ICryptoRepository } from 'src/interfaces/crypto.interface'; import { IEventRepository } from 'src/interfaces/event.interface'; -import { ISessionRepository } from 'src/interfaces/session.interface'; import { ISharedLinkRepository } from 'src/interfaces/shared-link.interface'; import { IUserRepository } from 'src/interfaces/user.interface'; import { AuthService } from 'src/services/auth.service'; -import { IApiKeyRepository, IOAuthRepository, ISystemMetadataRepository } from 'src/types'; +import { IApiKeyRepository, IOAuthRepository, ISessionRepository, ISystemMetadataRepository } from 'src/types'; import { keyStub } from 'test/fixtures/api-key.stub'; import { authStub } from 'test/fixtures/auth.stub'; import { sessionStub } from 'test/fixtures/session.stub'; @@ -257,7 +256,7 @@ describe('AuthService', () => { it('should validate using authorization header', async () => { userMock.get.mockResolvedValue(userStub.user1); - sessionMock.getByToken.mockResolvedValue(sessionStub.valid); + sessionMock.getByToken.mockResolvedValue(sessionStub.valid as any); await expect( sut.authenticate({ headers: { authorization: 'Bearer auth_token' }, @@ -362,7 +361,7 @@ describe('AuthService', () => { }); it('should return an auth dto', async () => { - sessionMock.getByToken.mockResolvedValue(sessionStub.valid); + sessionMock.getByToken.mockResolvedValue(sessionStub.valid as any); await expect( sut.authenticate({ headers: { cookie: 'immich_access_token=auth_token' }, @@ -376,7 +375,7 @@ describe('AuthService', () => { }); it('should throw if admin route and not an admin', async () => { - sessionMock.getByToken.mockResolvedValue(sessionStub.valid); + sessionMock.getByToken.mockResolvedValue(sessionStub.valid as any); await expect( sut.authenticate({ headers: { cookie: 'immich_access_token=auth_token' }, @@ -387,7 +386,7 @@ describe('AuthService', () => { }); it('should update when access time exceeds an hour', async () => { - sessionMock.getByToken.mockResolvedValue(sessionStub.inactive); + sessionMock.getByToken.mockResolvedValue(sessionStub.inactive as any); sessionMock.update.mockResolvedValue(sessionStub.valid); await expect( sut.authenticate({ diff --git a/server/src/services/auth.service.ts b/server/src/services/auth.service.ts index f46eb93111..f4c6c6249e 100644 --- a/server/src/services/auth.service.ts +++ b/server/src/services/auth.service.ts @@ -17,6 +17,7 @@ import { mapLoginResponse, } from 'src/dtos/auth.dto'; import { UserAdminResponseDto, mapUserAdmin } from 'src/dtos/user.dto'; +import { SessionEntity } from 'src/entities/session.entity'; import { UserEntity } from 'src/entities/user.entity'; import { AuthType, ImmichCookie, ImmichHeader, ImmichQuery, Permission } from 'src/enum'; import { OAuthProfile } from 'src/repositories/oauth.repository'; @@ -338,7 +339,7 @@ export class AuthService extends BaseService { await this.sessionRepository.update(session.id, { id: session.id, updatedAt: new Date() }); } - return { user: session.user, session }; + return { user: session.user as unknown as UserEntity, session: session as unknown as SessionEntity }; } throw new UnauthorizedException('Invalid user token'); diff --git a/server/src/services/base.service.ts b/server/src/services/base.service.ts index 15e374a411..8a690b752e 100644 --- a/server/src/services/base.service.ts +++ b/server/src/services/base.service.ts @@ -18,7 +18,6 @@ import { IMoveRepository } from 'src/interfaces/move.interface'; import { IPartnerRepository } from 'src/interfaces/partner.interface'; import { IPersonRepository } from 'src/interfaces/person.interface'; import { ISearchRepository } from 'src/interfaces/search.interface'; -import { ISessionRepository } from 'src/interfaces/session.interface'; import { ISharedLinkRepository } from 'src/interfaces/shared-link.interface'; import { IStackRepository } from 'src/interfaces/stack.interface'; import { IStorageRepository } from 'src/interfaces/storage.interface'; @@ -40,6 +39,7 @@ import { NotificationRepository } from 'src/repositories/notification.repository import { OAuthRepository } from 'src/repositories/oauth.repository'; import { ProcessRepository } from 'src/repositories/process.repository'; import { ServerInfoRepository } from 'src/repositories/server-info.repository'; +import { SessionRepository } from 'src/repositories/session.repository'; import { SystemMetadataRepository } from 'src/repositories/system-metadata.repository'; import { TelemetryRepository } from 'src/repositories/telemetry.repository'; import { TrashRepository } from 'src/repositories/trash.repository'; @@ -80,7 +80,7 @@ export class BaseService { protected processRepository: ProcessRepository, @Inject(ISearchRepository) protected searchRepository: ISearchRepository, protected serverInfoRepository: ServerInfoRepository, - @Inject(ISessionRepository) protected sessionRepository: ISessionRepository, + protected sessionRepository: SessionRepository, @Inject(ISharedLinkRepository) protected sharedLinkRepository: ISharedLinkRepository, @Inject(IStackRepository) protected stackRepository: IStackRepository, @Inject(IStorageRepository) protected storageRepository: IStorageRepository, diff --git a/server/src/services/session.service.spec.ts b/server/src/services/session.service.spec.ts index 49d1227712..8d989db5df 100644 --- a/server/src/services/session.service.spec.ts +++ b/server/src/services/session.service.spec.ts @@ -1,7 +1,6 @@ -import { UserEntity } from 'src/entities/user.entity'; import { JobStatus } from 'src/interfaces/job.interface'; -import { ISessionRepository } from 'src/interfaces/session.interface'; import { SessionService } from 'src/services/session.service'; +import { ISessionRepository } from 'src/types'; import { authStub } from 'test/fixtures/auth.stub'; import { sessionStub } from 'test/fixtures/session.stub'; import { IAccessRepositoryMock } from 'test/repositories/access.repository.mock'; @@ -38,7 +37,6 @@ describe('SessionService', () => { deviceType: '', id: '123', token: '420', - user: {} as UserEntity, userId: '42', }, ]); @@ -50,7 +48,7 @@ describe('SessionService', () => { describe('getAll', () => { it('should get the devices', async () => { - sessionMock.getByUserId.mockResolvedValue([sessionStub.valid, sessionStub.inactive]); + sessionMock.getByUserId.mockResolvedValue([sessionStub.valid as any, sessionStub.inactive]); await expect(sut.getAll(authStub.user1)).resolves.toEqual([ { createdAt: '2021-01-01T00:00:00.000Z', @@ -76,7 +74,7 @@ describe('SessionService', () => { describe('logoutDevices', () => { it('should logout all devices', async () => { - sessionMock.getByUserId.mockResolvedValue([sessionStub.inactive, sessionStub.valid]); + sessionMock.getByUserId.mockResolvedValue([sessionStub.inactive, sessionStub.valid] as any[]); await sut.deleteAll(authStub.user1); diff --git a/server/src/types.ts b/server/src/types.ts index 471a928a98..8e8e329b8b 100644 --- a/server/src/types.ts +++ b/server/src/types.ts @@ -16,6 +16,7 @@ import { NotificationRepository } from 'src/repositories/notification.repository import { OAuthRepository } from 'src/repositories/oauth.repository'; import { ProcessRepository } from 'src/repositories/process.repository'; import { ServerInfoRepository } from 'src/repositories/server-info.repository'; +import { SessionRepository } from 'src/repositories/session.repository'; import { SystemMetadataRepository } from 'src/repositories/system-metadata.repository'; import { MetricGroupRepository, TelemetryRepository } from 'src/repositories/telemetry.repository'; import { TrashRepository } from 'src/repositories/trash.repository'; @@ -61,6 +62,7 @@ export type IMetricGroupRepository = RepositoryInterface; export type INotificationRepository = RepositoryInterface; export type IOAuthRepository = RepositoryInterface; export type IProcessRepository = RepositoryInterface; +export type ISessionRepository = RepositoryInterface; export type IServerInfoRepository = RepositoryInterface; export type ISystemMetadataRepository = RepositoryInterface; export type ITelemetryRepository = RepositoryInterface; @@ -81,6 +83,8 @@ export type MemoryItem = | Awaited> | Awaited>[0]; +export type SessionItem = Awaited>[0]; + export interface CropOptions { top: number; left: number; diff --git a/server/test/repositories/session.repository.mock.ts b/server/test/repositories/session.repository.mock.ts index e24d4c87dd..41fa1640a2 100644 --- a/server/test/repositories/session.repository.mock.ts +++ b/server/test/repositories/session.repository.mock.ts @@ -1,4 +1,4 @@ -import { ISessionRepository } from 'src/interfaces/session.interface'; +import { ISessionRepository } from 'src/types'; import { Mocked, vitest } from 'vitest'; export const newSessionRepositoryMock = (): Mocked => { diff --git a/server/test/utils.ts b/server/test/utils.ts index 0a3c614159..34af8877b1 100644 --- a/server/test/utils.ts +++ b/server/test/utils.ts @@ -17,6 +17,7 @@ import { NotificationRepository } from 'src/repositories/notification.repository import { OAuthRepository } from 'src/repositories/oauth.repository'; import { ProcessRepository } from 'src/repositories/process.repository'; import { ServerInfoRepository } from 'src/repositories/server-info.repository'; +import { SessionRepository } from 'src/repositories/session.repository'; import { SystemMetadataRepository } from 'src/repositories/system-metadata.repository'; import { TelemetryRepository } from 'src/repositories/telemetry.repository'; import { TrashRepository } from 'src/repositories/trash.repository'; @@ -39,6 +40,7 @@ import { IOAuthRepository, IProcessRepository, IServerInfoRepository, + ISessionRepository, ISystemMetadataRepository, ITrashRepository, IVersionHistoryRepository, @@ -170,7 +172,7 @@ export const newTestService = ( processMock as IProcessRepository as ProcessRepository, searchMock, serverInfoMock as IServerInfoRepository as ServerInfoRepository, - sessionMock, + sessionMock as ISessionRepository as SessionRepository, sharedLinkMock, stackMock, storageMock, From fb21950ad8a05cf700568fd7ea76bfeb481886ef Mon Sep 17 00:00:00 2001 From: Alex Date: Fri, 7 Feb 2025 19:53:12 -0600 Subject: [PATCH 104/395] chore(web): shared links style tweaks (#15960) --- .../album-page/user-selection-modal.svelte | 20 ++++++++++--------- .../shared-links/[[id=id]]/+page.svelte | 2 +- 2 files changed, 12 insertions(+), 10 deletions(-) diff --git a/web/src/lib/components/album-page/user-selection-modal.svelte b/web/src/lib/components/album-page/user-selection-modal.svelte index 96e3a1672e..acbced70a0 100644 --- a/web/src/lib/components/album-page/user-selection-modal.svelte +++ b/web/src/lib/components/album-page/user-selection-modal.svelte @@ -154,16 +154,18 @@
-
- {$t('shared_links')} - {$t('view_all')} -
+ {#if sharedLinks.length > 0} +
+ {$t('shared_links')} + {$t('view_all')} +
- - {#each sharedLinks as sharedLink} - - {/each} - + + {#each sharedLinks as sharedLink} + + {/each} + + {/if}
diff --git a/web/src/routes/(user)/shared-links/[[id=id]]/+page.svelte b/web/src/routes/(user)/shared-links/[[id=id]]/+page.svelte index 49f9f32b5b..6fd1f17ecc 100644 --- a/web/src/routes/(user)/shared-links/[[id=id]]/+page.svelte +++ b/web/src/routes/(user)/shared-links/[[id=id]]/+page.svelte @@ -97,7 +97,7 @@
{/snippet} -
+
{#if sharedLinks.length === 0}
Date: Sat, 8 Feb 2025 17:01:28 -0500 Subject: [PATCH 105/395] fix(server): validate oauth profile has a sub (#15967) --- server/src/repositories/oauth.repository.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/server/src/repositories/oauth.repository.ts b/server/src/repositories/oauth.repository.ts index 85263cd647..29e6ffbb52 100644 --- a/server/src/repositories/oauth.repository.ts +++ b/server/src/repositories/oauth.repository.ts @@ -43,7 +43,12 @@ export class OAuthRepository { const params = client.callbackParams(url); try { const tokens = await client.callback(redirectUrl, params, { state: params.state }); - return await client.userinfo(tokens.access_token || ''); + const profile = await client.userinfo(tokens.access_token || ''); + if (!profile.sub) { + throw new Error('Unexpected profile response, no `sub`'); + } + + return profile; } catch (error: Error | any) { if (error.message.includes('unexpected JWT alg received')) { this.logger.warn( From 64f0333306dca4e140c0a895d6faac3196bf43a2 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Sun, 9 Feb 2025 07:00:37 -0500 Subject: [PATCH 106/395] chore(deps): update grafana/grafana docker tag to v11.5.1 (#15963) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- docker/docker-compose.prod.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/docker-compose.prod.yml b/docker/docker-compose.prod.yml index 66a84e693b..1c31977f3c 100644 --- a/docker/docker-compose.prod.yml +++ b/docker/docker-compose.prod.yml @@ -103,7 +103,7 @@ services: command: ['./run.sh', '-disable-reporting'] ports: - 3000:3000 - image: grafana/grafana:11.5.0-ubuntu@sha256:3c9e2b202eb933a22da5f2b5a22c98a665493f603b452263d9d6f242a87f60d7 + image: grafana/grafana:11.5.1-ubuntu@sha256:9a4ab78cec1a2ec7d1ca5dfd5aacec6412706a1bc9e971fc7184e2f6696a63f5 volumes: - grafana-data:/var/lib/grafana From feba590de7a7c13e3c7c5cc3f7a1646401c0b6a3 Mon Sep 17 00:00:00 2001 From: github-actions <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 10 Feb 2025 16:10:06 +0000 Subject: [PATCH 107/395] chore: version v1.126.0 --- cli/package-lock.json | 6 +++--- cli/package.json | 2 +- docs/static/archived-versions.json | 4 ++++ e2e/package-lock.json | 8 ++++---- e2e/package.json | 2 +- machine-learning/pyproject.toml | 2 +- mobile/android/fastlane/Fastfile | 4 ++-- mobile/ios/fastlane/Fastfile | 2 +- mobile/openapi/README.md | 2 +- mobile/pubspec.yaml | 2 +- open-api/immich-openapi-specs.json | 2 +- open-api/typescript-sdk/package-lock.json | 4 ++-- open-api/typescript-sdk/package.json | 2 +- open-api/typescript-sdk/src/fetch-client.ts | 2 +- server/package-lock.json | 4 ++-- server/package.json | 2 +- web/package-lock.json | 6 +++--- web/package.json | 2 +- 18 files changed, 31 insertions(+), 27 deletions(-) diff --git a/cli/package-lock.json b/cli/package-lock.json index 63e6c0d639..ce1d3addb2 100644 --- a/cli/package-lock.json +++ b/cli/package-lock.json @@ -1,12 +1,12 @@ { "name": "@immich/cli", - "version": "2.2.48", + "version": "2.2.49", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@immich/cli", - "version": "2.2.48", + "version": "2.2.49", "license": "GNU Affero General Public License version 3", "dependencies": { "fast-glob": "^3.3.2", @@ -52,7 +52,7 @@ }, "../open-api/typescript-sdk": { "name": "@immich/sdk", - "version": "1.125.7", + "version": "1.126.0", "dev": true, "license": "GNU Affero General Public License version 3", "dependencies": { diff --git a/cli/package.json b/cli/package.json index 58d8201f26..76a09ea2d8 100644 --- a/cli/package.json +++ b/cli/package.json @@ -1,6 +1,6 @@ { "name": "@immich/cli", - "version": "2.2.48", + "version": "2.2.49", "description": "Command Line Interface (CLI) for Immich", "type": "module", "exports": "./dist/index.js", diff --git a/docs/static/archived-versions.json b/docs/static/archived-versions.json index 76f787c1bd..b55a7ef0f6 100644 --- a/docs/static/archived-versions.json +++ b/docs/static/archived-versions.json @@ -1,4 +1,8 @@ [ + { + "label": "v1.126.0", + "url": "https://v1.126.0.archive.immich.app" + }, { "label": "v1.125.7", "url": "https://v1.125.7.archive.immich.app" diff --git a/e2e/package-lock.json b/e2e/package-lock.json index 6a4e37ed41..81554a319f 100644 --- a/e2e/package-lock.json +++ b/e2e/package-lock.json @@ -1,12 +1,12 @@ { "name": "immich-e2e", - "version": "1.125.7", + "version": "1.126.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "immich-e2e", - "version": "1.125.7", + "version": "1.126.0", "license": "GNU Affero General Public License version 3", "devDependencies": { "@eslint/eslintrc": "^3.1.0", @@ -45,7 +45,7 @@ }, "../cli": { "name": "@immich/cli", - "version": "2.2.48", + "version": "2.2.49", "dev": true, "license": "GNU Affero General Public License version 3", "dependencies": { @@ -92,7 +92,7 @@ }, "../open-api/typescript-sdk": { "name": "@immich/sdk", - "version": "1.125.7", + "version": "1.126.0", "dev": true, "license": "GNU Affero General Public License version 3", "dependencies": { diff --git a/e2e/package.json b/e2e/package.json index 8e7c434c3e..e2644ebfc5 100644 --- a/e2e/package.json +++ b/e2e/package.json @@ -1,6 +1,6 @@ { "name": "immich-e2e", - "version": "1.125.7", + "version": "1.126.0", "description": "", "main": "index.js", "type": "module", diff --git a/machine-learning/pyproject.toml b/machine-learning/pyproject.toml index f446a31245..c87956d4df 100644 --- a/machine-learning/pyproject.toml +++ b/machine-learning/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "machine-learning" -version = "1.125.7" +version = "1.126.0" description = "" authors = ["Hau Tran "] readme = "README.md" diff --git a/mobile/android/fastlane/Fastfile b/mobile/android/fastlane/Fastfile index 42ddb722da..729f279ee1 100644 --- a/mobile/android/fastlane/Fastfile +++ b/mobile/android/fastlane/Fastfile @@ -35,8 +35,8 @@ platform :android do task: 'bundle', build_type: 'Release', properties: { - "android.injected.version.code" => 182, - "android.injected.version.name" => "1.125.7", + "android.injected.version.code" => 183, + "android.injected.version.name" => "1.126.0", } ) upload_to_play_store(skip_upload_apk: true, skip_upload_images: true, skip_upload_screenshots: true, aab: '../build/app/outputs/bundle/release/app-release.aab') diff --git a/mobile/ios/fastlane/Fastfile b/mobile/ios/fastlane/Fastfile index edffa3e16b..5c90ec0482 100644 --- a/mobile/ios/fastlane/Fastfile +++ b/mobile/ios/fastlane/Fastfile @@ -19,7 +19,7 @@ platform :ios do desc "iOS Release" lane :release do increment_version_number( - version_number: "1.125.7" + version_number: "1.126.0" ) increment_build_number( build_number: latest_testflight_build_number + 1, diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index 8a2b6b886a..bcae401ce5 100644 --- a/mobile/openapi/README.md +++ b/mobile/openapi/README.md @@ -3,7 +3,7 @@ Immich API This Dart package is automatically generated by the [OpenAPI Generator](https://openapi-generator.tech) project: -- API version: 1.125.7 +- API version: 1.126.0 - Generator version: 7.8.0 - Build package: org.openapitools.codegen.languages.DartClientCodegen diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml index 162ad08571..18d515b92b 100644 --- a/mobile/pubspec.yaml +++ b/mobile/pubspec.yaml @@ -2,7 +2,7 @@ name: immich_mobile description: Immich - selfhosted backup media file on mobile phone publish_to: 'none' -version: 1.125.7+182 +version: 1.126.0+183 environment: sdk: '>=3.3.0 <4.0.0' diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index 0d3b808a80..28e64dc6d4 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -7468,7 +7468,7 @@ "info": { "title": "Immich", "description": "Immich API", - "version": "1.125.7", + "version": "1.126.0", "contact": {} }, "tags": [], diff --git a/open-api/typescript-sdk/package-lock.json b/open-api/typescript-sdk/package-lock.json index 732240615a..1ff92584d6 100644 --- a/open-api/typescript-sdk/package-lock.json +++ b/open-api/typescript-sdk/package-lock.json @@ -1,12 +1,12 @@ { "name": "@immich/sdk", - "version": "1.125.7", + "version": "1.126.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@immich/sdk", - "version": "1.125.7", + "version": "1.126.0", "license": "GNU Affero General Public License version 3", "dependencies": { "@oazapfts/runtime": "^1.0.2" diff --git a/open-api/typescript-sdk/package.json b/open-api/typescript-sdk/package.json index bbfba0c213..c3523ef01c 100644 --- a/open-api/typescript-sdk/package.json +++ b/open-api/typescript-sdk/package.json @@ -1,6 +1,6 @@ { "name": "@immich/sdk", - "version": "1.125.7", + "version": "1.126.0", "description": "Auto-generated TypeScript SDK for the Immich API", "type": "module", "main": "./build/index.js", diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index a1560d897d..2f7ff04e2e 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -1,6 +1,6 @@ /** * Immich - * 1.125.7 + * 1.126.0 * DO NOT MODIFY - This file has been generated using oazapfts. * See https://www.npmjs.com/package/oazapfts */ diff --git a/server/package-lock.json b/server/package-lock.json index 07f96d43a3..4619f8fe67 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -1,12 +1,12 @@ { "name": "immich", - "version": "1.125.7", + "version": "1.126.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "immich", - "version": "1.125.7", + "version": "1.126.0", "license": "GNU Affero General Public License version 3", "dependencies": { "@nestjs/bullmq": "^11.0.1", diff --git a/server/package.json b/server/package.json index 860f9e31b7..5121713576 100644 --- a/server/package.json +++ b/server/package.json @@ -1,6 +1,6 @@ { "name": "immich", - "version": "1.125.7", + "version": "1.126.0", "description": "", "author": "", "private": true, diff --git a/web/package-lock.json b/web/package-lock.json index 029b389499..90b5d95cce 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -1,12 +1,12 @@ { "name": "immich-web", - "version": "1.125.7", + "version": "1.126.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "immich-web", - "version": "1.125.7", + "version": "1.126.0", "license": "GNU Affero General Public License version 3", "dependencies": { "@formatjs/icu-messageformat-parser": "^2.9.8", @@ -77,7 +77,7 @@ }, "../open-api/typescript-sdk": { "name": "@immich/sdk", - "version": "1.125.7", + "version": "1.126.0", "license": "GNU Affero General Public License version 3", "dependencies": { "@oazapfts/runtime": "^1.0.2" diff --git a/web/package.json b/web/package.json index be05066d22..9fb94af628 100644 --- a/web/package.json +++ b/web/package.json @@ -1,6 +1,6 @@ { "name": "immich-web", - "version": "1.125.7", + "version": "1.126.0", "license": "GNU Affero General Public License version 3", "scripts": { "dev": "vite dev --host 0.0.0.0 --port 3000", From 4cad23aaa37af9f356aac9ba9c5833dd8bdf1b78 Mon Sep 17 00:00:00 2001 From: Nicholas Flamy <30300649+NicholasFlamy@users.noreply.github.com> Date: Mon, 10 Feb 2025 11:46:47 -0500 Subject: [PATCH 108/395] docs: add-hash #15860 follow-up (#15988) add-hash --- docs/src/components/version-switcher.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/src/components/version-switcher.tsx b/docs/src/components/version-switcher.tsx index 5f7e1c807f..c02ad444c8 100644 --- a/docs/src/components/version-switcher.tsx +++ b/docs/src/components/version-switcher.tsx @@ -49,7 +49,7 @@ export default function VersionSwitcher(): JSX.Element { mobile={windowSize === 'mobile'} items={versions.map(({ label, url }) => ({ label, - to: url + location.pathname, + to: url + location.pathname + location.hash, target: '_self', }))} /> From fe4c49c8e3d86f46bc8753796c826d855d6686cf Mon Sep 17 00:00:00 2001 From: Parsa Poorshikhian Date: Mon, 10 Feb 2025 20:17:53 +0330 Subject: [PATCH 109/395] chore: update of the persian translation (#15972) * chore: update of the persian translation * chore: update of the persian translation * chore: update of the persian translation * chore: update of the persian translation --- i18n/fa.json | 874 +++++++++++++++++++++++++-------------------------- 1 file changed, 437 insertions(+), 437 deletions(-) diff --git a/i18n/fa.json b/i18n/fa.json index 1e40996f15..4417786169 100644 --- a/i18n/fa.json +++ b/i18n/fa.json @@ -312,157 +312,157 @@ "admin_password": "رمز عبور مدیر", "administration": "مدیریت", "advanced": "پیشرفته", - "album_added": "", + "album_added": "آلبوم اضافه شد", "album_added_notification_setting_description": "", - "album_cover_updated": "", - "album_info_updated": "", - "album_name": "", - "album_options": "", - "album_updated": "", + "album_cover_updated": "جلد آلبوم به‌روزرسانی شد", + "album_info_updated": "اطلاعات آلبوم به‌روزرسانی شد", + "album_name": "نام آلبوم", + "album_options": "گزینه‌های آلبوم", + "album_updated": "آلبوم به‌روزرسانی شد", "album_updated_setting_description": "", - "albums": "", + "albums": "آلبوم‌ها", "albums_count": "", - "all": "", - "all_people": "", - "allow_dark_mode": "", - "allow_edits": "", - "api_key": "", - "api_keys": "", - "app_settings": "", - "appears_in": "", - "archive": "", + "all": "همه", + "all_people": "همه افراد", + "allow_dark_mode": "اجازه دادن به حالت تاریک", + "allow_edits": "اجازه ویرایش", + "api_key": "کلید API", + "api_keys": "کلیدهای API", + "app_settings": "تنظیمات برنامه", + "appears_in": "ظاهر می‌شود در", + "archive": "بایگانی", "archive_or_unarchive_photo": "", - "archive_size": "", + "archive_size": "اندازه بایگانی", "archive_size_description": "", - "asset_offline": "", - "assets": "", - "authorized_devices": "", - "back": "", - "backward": "", - "blurred_background": "", + "asset_offline": "محتوا آفلاین", + "assets": "محتواها", + "authorized_devices": "دستگاه‌های مجاز", + "back": "بازگشت", + "backward": "عقب", + "blurred_background": "پس‌زمینه محو", "bulk_delete_duplicates_confirmation": "", "bulk_keep_duplicates_confirmation": "", "bulk_trash_duplicates_confirmation": "", - "camera": "", - "camera_brand": "", - "camera_model": "", - "cancel": "", - "cancel_search": "", - "cannot_merge_people": "", - "cannot_update_the_description": "", - "change_date": "", - "change_expiration_time": "", - "change_location": "", - "change_name": "", - "change_name_successfully": "", - "change_password": "", - "change_your_password": "", + "camera": "دوربین", + "camera_brand": "برند دوربین", + "camera_model": "مدل دوربین", + "cancel": "لغو", + "cancel_search": "لغو جستجو", + "cannot_merge_people": "نمی‌توان افراد را ادغام کرد", + "cannot_update_the_description": "نمی‌توان توضیحات را به‌روزرسانی کرد", + "change_date": "تغییر تاریخ", + "change_expiration_time": "تغییر زمان انقضا", + "change_location": "تغییر مکان", + "change_name": "تغییر نام", + "change_name_successfully": "نام با موفقیت تغییر یافت", + "change_password": "تغییر رمز عبور", + "change_your_password": "رمز عبور خود را تغییر دهید", "changed_visibility_successfully": "", - "check_all": "", - "check_logs": "", + "check_all": "انتخاب همه", + "check_logs": "بررسی لاگ‌ها", "choose_matching_people_to_merge": "", - "city": "", - "clear": "", - "clear_all": "", - "clear_message": "", - "clear_value": "", - "close": "", - "collapse_all": "", - "color_theme": "", - "comment_options": "", - "comments_are_disabled": "", - "confirm": "", - "confirm_admin_password": "", + "city": "شهر", + "clear": "پاک کردن", + "clear_all": "پاک کردن همه", + "clear_message": "پاک کردن پیام", + "clear_value": "پاک کردن مقدار", + "close": "بستن", + "collapse_all": "جمع کردن همه", + "color_theme": "تم رنگ", + "comment_options": "گزینه‌های نظر", + "comments_are_disabled": "نظرات غیرفعال هستند", + "confirm": "تأیید", + "confirm_admin_password": "تأیید رمز عبور مدیر", "confirm_delete_shared_link": "", - "confirm_password": "", - "contain": "", - "context": "", - "continue": "", - "copied_image_to_clipboard": "", - "copied_to_clipboard": "", - "copy_error": "", - "copy_file_path": "", - "copy_image": "", - "copy_link": "", - "copy_link_to_clipboard": "", - "copy_password": "", - "copy_to_clipboard": "", - "country": "", - "cover": "", - "covers": "", - "create": "", - "create_album": "", - "create_library": "", - "create_link": "", - "create_link_to_share": "", - "create_new_person": "", - "create_new_user": "", - "create_user": "", - "created": "", - "current_device": "", + "confirm_password": "تأیید رمز عبور", + "contain": "شامل", + "context": "زمینه", + "continue": "ادامه", + "copied_image_to_clipboard": "تصویر به کلیپ‌بورد کپی شد.", + "copied_to_clipboard": "به کلیپ‌بورد کپی شد!", + "copy_error": "خطا در کپی", + "copy_file_path": "کپی مسیر فایل", + "copy_image": "کپی تصویر", + "copy_link": "کپی لینک", + "copy_link_to_clipboard": "کپی لینک به کلیپ‌بورد", + "copy_password": "کپی رمز عبور", + "copy_to_clipboard": "کپی به کلیپ‌بورد", + "country": "کشور", + "cover": "جلد", + "covers": "جلدها", + "create": "ایجاد", + "create_album": "ایجاد آلبوم", + "create_library": "ایجاد کتابخانه", + "create_link": "ایجاد لینک", + "create_link_to_share": "ایجاد لینک برای اشتراک‌گذاری", + "create_new_person": "ایجاد فرد جدید", + "create_new_user": "ایجاد کاربر جدید", + "create_user": "ایجاد کاربر", + "created": "ایجاد شد", + "current_device": "دستگاه فعلی", "custom_locale": "", "custom_locale_description": "", - "dark": "", - "date_after": "", - "date_and_time": "", - "date_before": "", - "date_range": "", - "day": "", - "deduplicate_all": "", + "dark": "تاریک", + "date_after": "تاریخ پس از", + "date_and_time": "تاریخ و زمان", + "date_before": "تاریخ قبل از", + "date_range": "بازه زمانی", + "day": "روز", + "deduplicate_all": "حذف تکراری‌ها به صورت کامل", "default_locale": "", "default_locale_description": "", - "delete": "", - "delete_album": "", + "delete": "حذف", + "delete_album": "حذف آلبوم", "delete_api_key_prompt": "", "delete_duplicates_confirmation": "", - "delete_key": "", - "delete_library": "", - "delete_link": "", - "delete_shared_link": "", - "delete_user": "", - "deleted_shared_link": "", - "description": "", - "details": "", - "direction": "", - "disabled": "", - "disallow_edits": "", - "discover": "", - "dismiss_all_errors": "", - "dismiss_error": "", - "display_options": "", - "display_order": "", - "display_original_photos": "", + "delete_key": "حذف کلید", + "delete_library": "حذف کتابخانه", + "delete_link": "حذف لینک", + "delete_shared_link": "حذف لینک اشتراکی", + "delete_user": "حذف کاربر", + "deleted_shared_link": "لینک اشتراکی حذف شد", + "description": "توضیحات", + "details": "جزئیات", + "direction": "جهت", + "disabled": "غیرفعال", + "disallow_edits": "عدم اجازه ویرایش", + "discover": "کشف کردن", + "dismiss_all_errors": "رد تمام خطاها", + "dismiss_error": "رد خطا", + "display_options": "گزینه‌های نمایش", + "display_order": "ترتیب نمایش", + "display_original_photos": "نمایش عکس‌های اصلی", "display_original_photos_setting_description": "", - "done": "", - "download": "", - "download_settings": "", - "download_settings_description": "", - "downloading": "", - "duplicates": "", + "done": "انجام شد", + "download": "دانلود", + "download_settings": "تنظیمات دانلود", + "download_settings_description": "مدیریت تنظیمات مرتبط با دانلود محتوا", + "downloading": "در حال دانلود", + "duplicates": "تکراری‌ها", "duplicates_description": "", - "duration": "", - "edit_album": "", - "edit_avatar": "", - "edit_date": "", - "edit_date_and_time": "", - "edit_exclusion_pattern": "", - "edit_faces": "", + "duration": "مدت زمان", + "edit_album": "ویرایش آلبوم", + "edit_avatar": "ویرایش آواتار", + "edit_date": "ویرایش تاریخ", + "edit_date_and_time": "ویرایش تاریخ و زمان", + "edit_exclusion_pattern": "ویرایش الگوی استثناء", + "edit_faces": "ویرایش چهره‌ها", "edit_import_path": "", "edit_import_paths": "", - "edit_key": "", - "edit_link": "", - "edit_location": "", - "edit_name": "", - "edit_people": "", - "edit_title": "", - "edit_user": "", - "edited": "", - "editor": "", - "email": "", - "empty_trash": "", - "end_date": "", - "error": "", - "error_loading_image": "", + "edit_key": "ویرایش کلید", + "edit_link": "ویرایش لینک", + "edit_location": "ویرایش مکان", + "edit_name": "ویرایش نام", + "edit_people": "ویرایش افراد", + "edit_title": "ویرایش عنوان", + "edit_user": "ویرایش کاربر", + "edited": "ویرایش شد", + "editor": "ویرایشگر", + "email": "ایمیل", + "empty_trash": "خالی کردن سطل زباله", + "end_date": "تاریخ پایان", + "error": "خطا", + "error_loading_image": "خطا در بارگذاری تصویر", "errors": { "exclusion_pattern_already_exists": "", "import_path_already_exists": "", @@ -530,400 +530,400 @@ "unable_to_update_timeline_display_status": "", "unable_to_update_user": "" }, - "exit_slideshow": "", - "expand_all": "", - "expire_after": "", - "expired": "", - "explore": "", - "export": "", - "export_as_json": "", - "extension": "", - "external": "", - "external_libraries": "", - "favorite": "", + "exit_slideshow": "خروج از نمایش اسلاید", + "expand_all": "باز کردن همه", + "expire_after": "منقضی شدن بعد از", + "expired": "منقضی شده", + "explore": "کاوش کردن", + "export": "صادر کردن", + "export_as_json": "صادر کردن به‌صورت JSON", + "extension": "پسوند", + "external": "خارجی", + "external_libraries": "کتابخانه‌های خارجی", + "favorite": "علاقه‌مندی", "favorite_or_unfavorite_photo": "", - "favorites": "", + "favorites": "علاقه‌مندی‌ها", "feature_photo_updated": "", - "file_name": "", - "file_name_or_extension": "", - "filename": "", - "filetype": "", - "filter_people": "", + "file_name": "نام فایل", + "file_name_or_extension": "نام فایل یا پسوند", + "filename": "نام فایل", + "filetype": "نوع فایل", + "filter_people": "فیلتر افراد", "find_them_fast": "", - "fix_incorrect_match": "", - "forward": "", - "general": "", - "get_help": "", - "getting_started": "", - "go_back": "", - "go_to_search": "", - "group_albums_by": "", - "has_quota": "", - "hide_gallery": "", - "hide_password": "", - "hide_person": "", - "host": "", - "hour": "", - "image": "", - "immich_logo": "", - "immich_web_interface": "", - "import_from_json": "", - "import_path": "", + "fix_incorrect_match": "رفع تطابق نادرست", + "forward": "جلو", + "general": "عمومی", + "get_help": "دریافت کمک", + "getting_started": "شروع به کار", + "go_back": "بازگشت", + "go_to_search": "رفتن به جستجو", + "group_albums_by": "گروه‌بندی آلبوم‌ها براساس...", + "has_quota": "دارای سهمیه", + "hide_gallery": "پنهان کردن گالری", + "hide_password": "پنهان کردن رمز عبور", + "hide_person": "پنهان کردن فرد", + "host": "میزبان", + "hour": "ساعت", + "image": "تصویر", + "immich_logo": "لوگوی Immich", + "immich_web_interface": "رابط وب Immich", + "import_from_json": "وارد کردن از JSON", + "import_path": "مسیر وارد کردن", "in_albums": "", - "in_archive": "", - "include_archived": "", - "include_shared_albums": "", + "in_archive": "در بایگانی", + "include_archived": "شامل بایگانی شده‌ها", + "include_shared_albums": "شامل آلبوم‌های اشتراکی", "include_shared_partner_assets": "", - "individual_share": "", - "info": "", + "individual_share": "اشتراک فردی", + "info": "اطلاعات", "interval": { "day_at_onepm": "", "hours": "", "night_at_midnight": "", "night_at_twoam": "" }, - "invite_people": "", - "invite_to_album": "", - "jobs": "", - "keep": "", - "keep_all": "", - "keyboard_shortcuts": "", - "language": "", - "language_setting_description": "", - "last_seen": "", - "leave": "", - "let_others_respond": "", - "level": "", - "library": "", - "library_options": "", - "light": "", - "link_options": "", - "link_to_oauth": "", - "linked_oauth_account": "", - "list": "", - "loading": "", - "loading_search_results_failed": "", - "log_out": "", - "log_out_all_devices": "", - "login_has_been_disabled": "", - "look": "", - "loop_videos": "", + "invite_people": "دعوت افراد", + "invite_to_album": "دعوت به آلبوم", + "jobs": "وظایف", + "keep": "نگه داشتن", + "keep_all": "نگه داشتن همه", + "keyboard_shortcuts": "میانبرهای صفحه‌کلید", + "language": "زبان", + "language_setting_description": "انتخاب زبان دلخواه شما", + "last_seen": "آخرین مشاهده", + "leave": "ترک کردن", + "let_others_respond": "اجازه به دیگران برای پاسخ‌گویی", + "level": "سطح", + "library": "کتابخانه", + "library_options": "گزینه‌های کتابخانه", + "light": "روشن", + "link_options": "گزینه‌های لینک", + "link_to_oauth": "اتصال به OAuth", + "linked_oauth_account": "حساب OAuth متصل شده", + "list": "لیست", + "loading": "در حال بارگذاری", + "loading_search_results_failed": "بارگذاری نتایج جستجو ناموفق بود", + "log_out": "خروج از سیستم", + "log_out_all_devices": "خروج از همه دستگاه‌ها", + "login_has_been_disabled": "ورود غیرفعال شده است.", + "look": "نگاه کردن", + "loop_videos": "پخش مداوم ویدئوها", "loop_videos_description": "", - "make": "", - "manage_shared_links": "", + "make": "ساختن", + "manage_shared_links": "مدیریت لینک‌های اشتراکی", "manage_sharing_with_partners": "", - "manage_the_app_settings": "", - "manage_your_account": "", - "manage_your_api_keys": "", - "manage_your_devices": "", - "manage_your_oauth_connection": "", - "map": "", + "manage_the_app_settings": "مدیریت تنظیمات برنامه", + "manage_your_account": "مدیریت حساب کاربری شما", + "manage_your_api_keys": "مدیریت کلیدهای API شما", + "manage_your_devices": "مدیریت دستگاه‌های متصل", + "manage_your_oauth_connection": "مدیریت اتصال OAuth شما", + "map": "نقشه", "map_marker_with_image": "", - "map_settings": "", - "matches": "", - "media_type": "", - "memories": "", + "map_settings": "تنظیمات نقشه", + "matches": "تطابق‌ها", + "media_type": "نوع رسانه", + "memories": "خاطرات", "memories_setting_description": "", - "memory": "", - "menu": "", - "merge": "", - "merge_people": "", + "memory": "خاطره", + "menu": "منو", + "merge": "ادغام", + "merge_people": "ادغام افراد", "merge_people_limit": "", "merge_people_prompt": "", - "merge_people_successfully": "", - "minimize": "", - "minute": "", - "missing": "", - "model": "", - "month": "", - "more": "", - "moved_to_trash": "", - "my_albums": "", - "name": "", - "name_or_nickname": "", - "never": "", - "new_api_key": "", - "new_password": "", - "new_person": "", - "new_user_created": "", - "newest_first": "", - "next": "", - "next_memory": "", - "no": "", + "merge_people_successfully": "ادغام افراد با موفقیت انجام شد", + "minimize": "کوچک کردن", + "minute": "دقیقه", + "missing": "گمشده", + "model": "مدل", + "month": "ماه", + "more": "بیشتر", + "moved_to_trash": "به سطل زباله منتقل شد", + "my_albums": "آلبوم‌های من", + "name": "نام", + "name_or_nickname": "نام یا لقب", + "never": "هرگز", + "new_api_key": "کلید API جدید", + "new_password": "رمز عبور جدید", + "new_person": "فرد جدید", + "new_user_created": "کاربر جدید ایجاد شد", + "newest_first": "جدیدترین ابتدا", + "next": "بعدی", + "next_memory": "خاطره بعدی", + "no": "خیر", "no_albums_message": "", "no_archived_assets_message": "", "no_assets_message": "", - "no_duplicates_found": "", - "no_exif_info_available": "", + "no_duplicates_found": "هیچ تکراری یافت نشد.", + "no_exif_info_available": "اطلاعات EXIF موجود نیست", "no_explore_results_message": "", "no_favorites_message": "", "no_libraries_message": "", - "no_name": "", - "no_places": "", - "no_results": "", + "no_name": "بدون نام", + "no_places": "مکانی یافت نشد", + "no_results": "نتیجه‌ای یافت نشد", "no_shared_albums_message": "", - "not_in_any_album": "", + "not_in_any_album": "در هیچ آلبومی نیست", "note_apply_storage_label_to_previously_uploaded assets": "", "note_unlimited_quota": "", - "notes": "", - "notification_toggle_setting_description": "", - "notifications": "", - "notifications_setting_description": "", - "oauth": "", - "offline": "", - "offline_paths": "", + "notes": "یادداشت‌ها", + "notification_toggle_setting_description": "اعلان‌های ایمیلی را فعال کنید", + "notifications": "اعلان‌ها", + "notifications_setting_description": "مدیریت اعلان‌ها", + "oauth": "OAuth", + "offline": "آفلاین", + "offline_paths": "مسیرهای آفلاین", "offline_paths_description": "", - "ok": "", - "oldest_first": "", - "online": "", - "only_favorites": "", - "open_the_search_filters": "", - "options": "", - "organize_your_library": "", - "other": "", - "other_devices": "", - "other_variables": "", - "owned": "", - "owner": "", - "partner": "", - "partner_can_access": "", + "ok": "تأیید", + "oldest_first": "قدیمی‌ترین ابتدا", + "online": "آنلاین", + "only_favorites": "فقط علاقه‌مندی‌ها", + "open_the_search_filters": "باز کردن فیلترهای جستجو", + "options": "گزینه‌ها", + "organize_your_library": "کتابخانه خود را سازماندهی کنید", + "other": "دیگر", + "other_devices": "دستگاه‌های دیگر", + "other_variables": "متغیرهای دیگر", + "owned": "مالکیت", + "owner": "مالک", + "partner": "شریک", + "partner_can_access": "{partner} می‌تواند دسترسی داشته باشد", "partner_can_access_assets": "", - "partner_can_access_location": "", - "partner_sharing": "", - "partners": "", - "password": "", - "password_does_not_match": "", - "password_required": "", - "password_reset_success": "", + "partner_can_access_location": "مکان‌هایی که عکس‌های شما گرفته شده‌اند", + "partner_sharing": "اشتراک‌گذاری با شریک", + "partners": "شرکا", + "password": "رمز عبور", + "password_does_not_match": "رمز عبور مطابقت ندارد", + "password_required": "رمز عبور مورد نیاز است", + "password_reset_success": "بازنشانی رمز عبور موفقیت‌آمیز بود", "past_durations": { "days": "", "hours": "", "years": "" }, - "path": "", - "pattern": "", - "pause": "", - "pause_memories": "", - "paused": "", - "pending": "", - "people": "", + "path": "مسیر", + "pattern": "الگو", + "pause": "توقف", + "pause_memories": "توقف خاطرات", + "paused": "متوقف شده", + "pending": "در انتظار", + "people": "افراد", "people_sidebar_description": "", - "permanent_deletion_warning": "", - "permanent_deletion_warning_setting_description": "", - "permanently_delete": "", - "permanently_deleted_asset": "", - "person": "", - "photos": "", + "permanent_deletion_warning": "هشدار حذف دائمی", + "permanent_deletion_warning_setting_description": "نمایش هشدار هنگام حذف دائمی محتواها", + "permanently_delete": "حذف دائمی", + "permanently_deleted_asset": "محتوای حذف شده دائمی", + "person": "فرد", + "photos": "عکس‌ها", "photos_count": "", - "photos_from_previous_years": "", - "pick_a_location": "", - "place": "", - "places": "", - "play": "", - "play_memories": "", - "play_motion_photo": "", - "play_or_pause_video": "", - "port": "", - "preset": "", - "preview": "", - "previous": "", - "previous_memory": "", - "previous_or_next_photo": "", - "primary": "", - "profile_picture_set": "", - "public_share": "", - "reaction_options": "", - "read_changelog": "", - "recent": "", - "recent_searches": "", - "refresh": "", - "refreshed": "", + "photos_from_previous_years": "عکس‌های سال‌های گذشته", + "pick_a_location": "یک مکان انتخاب کنید", + "place": "مکان", + "places": "مکان‌ها", + "play": "پخش", + "play_memories": "پخش خاطرات", + "play_motion_photo": "پخش عکس متحرک", + "play_or_pause_video": "پخش یا توقف ویدیو", + "port": "پورت", + "preset": "پیش‌فرض", + "preview": "پیش‌نمایش", + "previous": "قبلی", + "previous_memory": "خاطره قبلی", + "previous_or_next_photo": "عکس قبلی یا بعدی", + "primary": "اصلی", + "profile_picture_set": "تصویر پروفایل تنظیم شد.", + "public_share": "اشتراک عمومی", + "reaction_options": "گزینه‌های واکنش", + "read_changelog": "مطالعه تغییرات نسخه", + "recent": "اخیر", + "recent_searches": "جستجوهای اخیر", + "refresh": "تازه سازی", + "refreshed": "تازه سازی شد", "refreshes_every_file": "", - "remove": "", - "remove_deleted_assets": "", - "remove_from_album": "", - "remove_from_favorites": "", + "remove": "حذف", + "remove_deleted_assets": "حذف محتواهای حذف‌شده", + "remove_from_album": "حذف از آلبوم", + "remove_from_favorites": "حذف از علاقه‌مندی‌ها", "remove_from_shared_link": "", "removed_api_key": "", - "rename": "", - "repair": "", + "rename": "تغییر نام", + "repair": "تعمیر", "repair_no_results_message": "", - "replace_with_upload": "", + "replace_with_upload": "جایگزینی با آپلود", "require_password": "", "require_user_to_change_password_on_first_login": "", - "reset": "", - "reset_password": "", + "reset": "بازنشانی", + "reset_password": "بازنشانی رمز عبور", "reset_people_visibility": "", "resolved_all_duplicates": "", - "restore": "", - "restore_all": "", - "restore_user": "", - "resume": "", + "restore": "بازیابی", + "restore_all": "بازیابی همه", + "restore_user": "بازیابی کاربر", + "resume": "ادامه", "retry_upload": "", - "review_duplicates": "", - "role": "", - "save": "", + "review_duplicates": "بررسی تکراری‌ها", + "role": "نقش", + "save": "ذخیره", "saved_api_key": "", - "saved_profile": "", - "saved_settings": "", - "say_something": "", - "scan_all_libraries": "", - "scan_settings": "", + "saved_profile": "پروفایل ذخیره شد", + "saved_settings": "تنظیمات ذخیره شد", + "say_something": "چیزی بگویید", + "scan_all_libraries": "اسکن همه کتابخانه‌ها", + "scan_settings": "تنظیمات اسکن", "scanning_for_album": "", - "search": "", - "search_albums": "", - "search_by_context": "", - "search_camera_make": "", - "search_camera_model": "", - "search_city": "", - "search_country": "", - "search_for_existing_person": "", - "search_people": "", - "search_places": "", - "search_state": "", - "search_timezone": "", - "search_type": "", + "search": "جستجو", + "search_albums": "جستجوی آلبوم‌ها", + "search_by_context": "جستجو براساس زمینه", + "search_camera_make": "جستجوی برند دوربین...", + "search_camera_model": "جستجوی مدل دوربین...", + "search_city": "جستجوی شهر...", + "search_country": "جستجوی کشور...", + "search_for_existing_person": "جستجوی فرد موجود", + "search_people": "جستجوی افراد", + "search_places": "جستجوی مکان‌ها", + "search_state": "جستجوی ایالت...", + "search_timezone": "جستجوی منطقه زمانی...", + "search_type": "نوع جستجو", "search_your_photos": "", "searching_locales": "", - "second": "", - "select_album_cover": "", - "select_all": "", - "select_avatar_color": "", - "select_face": "", - "select_featured_photo": "", - "select_keep_all": "", - "select_library_owner": "", - "select_new_face": "", - "select_photos": "", + "second": "ثانیه", + "select_album_cover": "انتخاب جلد آلبوم", + "select_all": "انتخاب همه", + "select_avatar_color": "انتخاب رنگ آواتار", + "select_face": "انتخاب چهره", + "select_featured_photo": "انتخاب عکس ویژه", + "select_keep_all": "انتخاب نگهداری همه", + "select_library_owner": "انتخاب مالک کتابخانه", + "select_new_face": "انتخاب چهره جدید", + "select_photos": "انتخاب عکس‌ها", "select_trash_all": "", - "selected": "", - "send_message": "", - "send_welcome_email": "", - "server_stats": "", - "set": "", + "selected": "انتخاب شده", + "send_message": "ارسال پیام", + "send_welcome_email": "ارسال ایمیل خوش‌آمدگویی", + "server_stats": "آمار سرور", + "set": "تنظیم", "set_as_album_cover": "", "set_as_profile_picture": "", - "set_date_of_birth": "", - "set_profile_picture": "", + "set_date_of_birth": "تنظیم تاریخ تولد", + "set_profile_picture": "تنظیم تصویر پروفایل", "set_slideshow_to_fullscreen": "", - "settings": "", - "settings_saved": "", - "share": "", - "shared": "", - "shared_by": "", + "settings": "تنظیمات", + "settings_saved": "تنظیمات ذخیره شد", + "share": "اشتراک‌گذاری", + "shared": "مشترک", + "shared_by": "مشترک توسط", "shared_by_you": "", - "shared_from_partner": "", - "shared_links": "", + "shared_from_partner": "عکس‌ها از {partner}", + "shared_links": "لینک‌های اشتراکی", "shared_photos_and_videos_count": "", - "shared_with_partner": "", - "sharing": "", + "shared_with_partner": "مشترک با {partner}", + "sharing": "اشتراک‌گذاری", "sharing_sidebar_description": "", - "show_album_options": "", + "show_album_options": "نمایش گزینه‌های آلبوم", "show_and_hide_people": "", - "show_file_location": "", - "show_gallery": "", - "show_hidden_people": "", + "show_file_location": "نمایش مسیر فایل", + "show_gallery": "نمایش گالری", + "show_hidden_people": "نمایش افراد پنهان", "show_in_timeline": "", "show_in_timeline_setting_description": "", "show_keyboard_shortcuts": "", - "show_metadata": "", + "show_metadata": "نمایش اطلاعات متا", "show_or_hide_info": "", - "show_password": "", + "show_password": "نمایش رمز عبور", "show_person_options": "", - "show_progress_bar": "", - "show_search_options": "", - "shuffle": "", - "sign_out": "", - "sign_up": "", - "size": "", - "skip_to_content": "", - "slideshow": "", - "slideshow_settings": "", + "show_progress_bar": "نمایش نوار پیشرفت", + "show_search_options": "نمایش گزینه‌های جستجو", + "shuffle": "تصادفی", + "sign_out": "خروج", + "sign_up": "ثبت‌نام", + "size": "اندازه", + "skip_to_content": "رفتن به محتوا", + "slideshow": "نمایش اسلاید", + "slideshow_settings": "تنظیمات نمایش اسلاید", "sort_albums_by": "", - "stack": "", + "stack": "پشته", "stack_selected_photos": "", "stacktrace": "", - "start": "", - "start_date": "", - "state": "", - "status": "", - "stop_motion_photo": "", + "start": "شروع", + "start_date": "تاریخ شروع", + "state": "ایالت", + "status": "وضعیت", + "stop_motion_photo": "توقف عکس متحرک", "stop_photo_sharing": "", "stop_photo_sharing_description": "", "stop_sharing_photos_with_user": "", - "storage": "", - "storage_label": "", + "storage": "فضای ذخیره‌سازی", + "storage_label": "برچسب فضای ذخیره‌سازی", "storage_usage": "", - "submit": "", - "suggestions": "", + "submit": "ارسال", + "suggestions": "پیشنهادات", "sunrise_on_the_beach": "", - "swap_merge_direction": "", - "sync": "", - "template": "", - "theme": "", - "theme_selection": "", + "swap_merge_direction": "تغییر جهت ادغام", + "sync": "همگام‌سازی", + "template": "الگو", + "theme": "تم", + "theme_selection": "انتخاب تم", "theme_selection_description": "", "time_based_memories": "", - "timezone": "", - "to_archive": "", - "to_favorite": "", + "timezone": "منطقه زمانی", + "to_archive": "بایگانی", + "to_favorite": "به علاقه‌مندی‌ها", "to_trash": "", - "toggle_settings": "", - "toggle_theme": "", - "total_usage": "", - "trash": "", + "toggle_settings": "تغییر تنظیمات", + "toggle_theme": "تغییر تم تاریک", + "total_usage": "استفاده کلی", + "trash": "سطل زباله", "trash_all": "", "trash_count": "", "trash_no_results_message": "", "trashed_items_will_be_permanently_deleted_after": "", - "type": "", + "type": "نوع", "unarchive": "", - "unfavorite": "", - "unhide_person": "", - "unknown": "", - "unknown_year": "", - "unlimited": "", - "unlink_oauth": "", + "unfavorite": "حذف از علاقه‌مندی‌ها", + "unhide_person": "آشکار کردن فرد", + "unknown": "ناشناخته", + "unknown_year": "سال نامشخص", + "unlimited": "نامحدود", + "unlink_oauth": "لغو اتصال OAuth", "unlinked_oauth_account": "", - "unnamed_album": "", - "unnamed_share": "", - "unselect_all": "", + "unnamed_album": "آلبوم بدون نام", + "unnamed_share": "اشتراک بدون نام", + "unselect_all": "لغو انتخاب همه", "unstack": "", "untracked_files": "", "untracked_files_decription": "", - "up_next": "", + "up_next": "مورد بعدی", "updated_password": "", - "upload": "", - "upload_concurrency": "", - "url": "", - "usage": "", - "user": "", - "user_id": "", - "user_usage_detail": "", - "username": "", - "users": "", - "utilities": "", - "validate": "", - "variables": "", - "version": "", + "upload": "آپلود", + "upload_concurrency": "تعداد آپلود همزمان", + "url": "آدرس", + "usage": "استفاده", + "user": "کاربر", + "user_id": "شناسه کاربر", + "user_usage_detail": "جزئیات استفاده کاربر", + "username": "نام کاربری", + "users": "کاربران", + "utilities": "ابزارها", + "validate": "اعتبارسنجی", + "variables": "متغیرها", + "version": "نسخه", "version_announcement_message": "", - "video": "", + "video": "ویدیو", "video_hover_setting": "", "video_hover_setting_description": "", - "videos": "", + "videos": "ویدیوها", "videos_count": "", - "view": "", - "view_all": "", - "view_all_users": "", - "view_links": "", - "view_next_asset": "", - "view_previous_asset": "", - "waiting": "", - "week": "", - "welcome": "", + "view": "مشاهده", + "view_all": "مشاهده همه", + "view_all_users": "مشاهده همه کاربران", + "view_links": "مشاهده لینک‌ها", + "view_next_asset": "مشاهده محتوای بعدی", + "view_previous_asset": "مشاهده محتوای قبلی", + "waiting": "در انتظار", + "week": "هفته", + "welcome": "خوش آمدید", "welcome_to_immich": "", - "year": "", - "yes": "", + "year": "سال", + "yes": "بله", "you_dont_have_any_shared_links": "", "zoom_image": "بزرگنمایی تصویر" } From 52b650093dd7535f4702fd42d5950c6ead28e9ef Mon Sep 17 00:00:00 2001 From: Daniel Dietzler <36593685+danieldietzler@users.noreply.github.com> Date: Mon, 10 Feb 2025 17:56:40 +0100 Subject: [PATCH 110/395] fix: merch link (#15999) --- docs/src/pages/index.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/src/pages/index.tsx b/docs/src/pages/index.tsx index 8ea8e1220d..1d73ed36f8 100644 --- a/docs/src/pages/index.tsx +++ b/docs/src/pages/index.tsx @@ -53,7 +53,7 @@ function HomepageHeader() { Buy Merch From 90c607c1a65500bfd8ce8a95b9290c976b7e5aee Mon Sep 17 00:00:00 2001 From: Alex Date: Mon, 10 Feb 2025 11:12:36 -0600 Subject: [PATCH 111/395] chore(mobile): post release task (#15998) --- mobile/ios/Runner.xcodeproj/project.pbxproj | 12 ++++++------ mobile/ios/Runner/Info.plist | 4 ++-- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/mobile/ios/Runner.xcodeproj/project.pbxproj b/mobile/ios/Runner.xcodeproj/project.pbxproj index 89c18ba5b7..9aad116ce6 100644 --- a/mobile/ios/Runner.xcodeproj/project.pbxproj +++ b/mobile/ios/Runner.xcodeproj/project.pbxproj @@ -541,7 +541,7 @@ CODE_SIGN_ENTITLEMENTS = Runner/RunnerProfile.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 190; + CURRENT_PROJECT_VERSION = 193; CUSTOM_GROUP_ID = group.app.immich.share; DEVELOPMENT_TEAM = 2F67MQ8R79; ENABLE_BITCODE = NO; @@ -685,7 +685,7 @@ CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 190; + CURRENT_PROJECT_VERSION = 193; CUSTOM_GROUP_ID = group.app.immich.share; DEVELOPMENT_TEAM = 2F67MQ8R79; ENABLE_BITCODE = NO; @@ -715,7 +715,7 @@ CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 190; + CURRENT_PROJECT_VERSION = 193; CUSTOM_GROUP_ID = group.app.immich.share; DEVELOPMENT_TEAM = 2F67MQ8R79; ENABLE_BITCODE = NO; @@ -748,7 +748,7 @@ CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 190; + CURRENT_PROJECT_VERSION = 193; CUSTOM_GROUP_ID = group.app.immich.share; DEVELOPMENT_TEAM = 2F67MQ8R79; ENABLE_USER_SCRIPT_SANDBOXING = YES; @@ -791,7 +791,7 @@ CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 190; + CURRENT_PROJECT_VERSION = 193; CUSTOM_GROUP_ID = group.app.immich.share; DEVELOPMENT_TEAM = 2F67MQ8R79; ENABLE_USER_SCRIPT_SANDBOXING = YES; @@ -831,7 +831,7 @@ CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 190; + CURRENT_PROJECT_VERSION = 193; CUSTOM_GROUP_ID = group.app.immich.share; DEVELOPMENT_TEAM = 2F67MQ8R79; ENABLE_USER_SCRIPT_SANDBOXING = YES; diff --git a/mobile/ios/Runner/Info.plist b/mobile/ios/Runner/Info.plist index a2688775dc..5ae45ac401 100644 --- a/mobile/ios/Runner/Info.plist +++ b/mobile/ios/Runner/Info.plist @@ -78,7 +78,7 @@ CFBundlePackageType APPL CFBundleShortVersionString - 1.125.2 + 1.126.0 CFBundleSignature ???? CFBundleURLTypes @@ -93,7 +93,7 @@ CFBundleVersion - 190 + 193 FLTEnableImpeller ITSAppUsesNonExemptEncryption From cef19eed976257bc85a26dc7a671fc3dced3238f Mon Sep 17 00:00:00 2001 From: Alex Date: Mon, 10 Feb 2025 11:39:43 -0600 Subject: [PATCH 112/395] chore(mobile): patch openapi preference (#16000) --- mobile/lib/utils/openapi_patching.dart | 1 + 1 file changed, 1 insertion(+) diff --git a/mobile/lib/utils/openapi_patching.dart b/mobile/lib/utils/openapi_patching.dart index 255ad01247..708aec603f 100644 --- a/mobile/lib/utils/openapi_patching.dart +++ b/mobile/lib/utils/openapi_patching.dart @@ -10,6 +10,7 @@ dynamic upgradeDto(dynamic value, String targetType) { addDefault(value, 'ratings', RatingsResponse().toJson()); addDefault(value, 'people', PeopleResponse().toJson()); addDefault(value, 'tags', TagsResponse().toJson()); + addDefault(value, 'sharedLinks', SharedLinksResponse().toJson()); } break; case 'ServerConfigDto': From 8794c84e9dd08b52e968b8838d1591e4f2c9c6d3 Mon Sep 17 00:00:00 2001 From: github-actions <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 10 Feb 2025 17:54:02 +0000 Subject: [PATCH 113/395] chore: version v1.126.1 --- cli/package-lock.json | 6 +++--- cli/package.json | 2 +- docs/static/archived-versions.json | 4 ++++ e2e/package-lock.json | 8 ++++---- e2e/package.json | 2 +- machine-learning/pyproject.toml | 2 +- mobile/android/fastlane/Fastfile | 4 ++-- mobile/ios/fastlane/Fastfile | 2 +- mobile/openapi/README.md | 2 +- mobile/pubspec.yaml | 2 +- open-api/immich-openapi-specs.json | 2 +- open-api/typescript-sdk/package-lock.json | 4 ++-- open-api/typescript-sdk/package.json | 2 +- open-api/typescript-sdk/src/fetch-client.ts | 2 +- server/package-lock.json | 4 ++-- server/package.json | 2 +- web/package-lock.json | 6 +++--- web/package.json | 2 +- 18 files changed, 31 insertions(+), 27 deletions(-) diff --git a/cli/package-lock.json b/cli/package-lock.json index ce1d3addb2..04c32fed82 100644 --- a/cli/package-lock.json +++ b/cli/package-lock.json @@ -1,12 +1,12 @@ { "name": "@immich/cli", - "version": "2.2.49", + "version": "2.2.50", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@immich/cli", - "version": "2.2.49", + "version": "2.2.50", "license": "GNU Affero General Public License version 3", "dependencies": { "fast-glob": "^3.3.2", @@ -52,7 +52,7 @@ }, "../open-api/typescript-sdk": { "name": "@immich/sdk", - "version": "1.126.0", + "version": "1.126.1", "dev": true, "license": "GNU Affero General Public License version 3", "dependencies": { diff --git a/cli/package.json b/cli/package.json index 76a09ea2d8..c9780ed993 100644 --- a/cli/package.json +++ b/cli/package.json @@ -1,6 +1,6 @@ { "name": "@immich/cli", - "version": "2.2.49", + "version": "2.2.50", "description": "Command Line Interface (CLI) for Immich", "type": "module", "exports": "./dist/index.js", diff --git a/docs/static/archived-versions.json b/docs/static/archived-versions.json index b55a7ef0f6..a9fd22de84 100644 --- a/docs/static/archived-versions.json +++ b/docs/static/archived-versions.json @@ -1,4 +1,8 @@ [ + { + "label": "v1.126.1", + "url": "https://v1.126.1.archive.immich.app" + }, { "label": "v1.126.0", "url": "https://v1.126.0.archive.immich.app" diff --git a/e2e/package-lock.json b/e2e/package-lock.json index 81554a319f..c77852b8ac 100644 --- a/e2e/package-lock.json +++ b/e2e/package-lock.json @@ -1,12 +1,12 @@ { "name": "immich-e2e", - "version": "1.126.0", + "version": "1.126.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "immich-e2e", - "version": "1.126.0", + "version": "1.126.1", "license": "GNU Affero General Public License version 3", "devDependencies": { "@eslint/eslintrc": "^3.1.0", @@ -45,7 +45,7 @@ }, "../cli": { "name": "@immich/cli", - "version": "2.2.49", + "version": "2.2.50", "dev": true, "license": "GNU Affero General Public License version 3", "dependencies": { @@ -92,7 +92,7 @@ }, "../open-api/typescript-sdk": { "name": "@immich/sdk", - "version": "1.126.0", + "version": "1.126.1", "dev": true, "license": "GNU Affero General Public License version 3", "dependencies": { diff --git a/e2e/package.json b/e2e/package.json index e2644ebfc5..2947cda348 100644 --- a/e2e/package.json +++ b/e2e/package.json @@ -1,6 +1,6 @@ { "name": "immich-e2e", - "version": "1.126.0", + "version": "1.126.1", "description": "", "main": "index.js", "type": "module", diff --git a/machine-learning/pyproject.toml b/machine-learning/pyproject.toml index c87956d4df..7446435388 100644 --- a/machine-learning/pyproject.toml +++ b/machine-learning/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "machine-learning" -version = "1.126.0" +version = "1.126.1" description = "" authors = ["Hau Tran "] readme = "README.md" diff --git a/mobile/android/fastlane/Fastfile b/mobile/android/fastlane/Fastfile index 729f279ee1..f46a0dd4ec 100644 --- a/mobile/android/fastlane/Fastfile +++ b/mobile/android/fastlane/Fastfile @@ -35,8 +35,8 @@ platform :android do task: 'bundle', build_type: 'Release', properties: { - "android.injected.version.code" => 183, - "android.injected.version.name" => "1.126.0", + "android.injected.version.code" => 184, + "android.injected.version.name" => "1.126.1", } ) upload_to_play_store(skip_upload_apk: true, skip_upload_images: true, skip_upload_screenshots: true, aab: '../build/app/outputs/bundle/release/app-release.aab') diff --git a/mobile/ios/fastlane/Fastfile b/mobile/ios/fastlane/Fastfile index 5c90ec0482..2f5616deb8 100644 --- a/mobile/ios/fastlane/Fastfile +++ b/mobile/ios/fastlane/Fastfile @@ -19,7 +19,7 @@ platform :ios do desc "iOS Release" lane :release do increment_version_number( - version_number: "1.126.0" + version_number: "1.126.1" ) increment_build_number( build_number: latest_testflight_build_number + 1, diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index bcae401ce5..5b0e067dcd 100644 --- a/mobile/openapi/README.md +++ b/mobile/openapi/README.md @@ -3,7 +3,7 @@ Immich API This Dart package is automatically generated by the [OpenAPI Generator](https://openapi-generator.tech) project: -- API version: 1.126.0 +- API version: 1.126.1 - Generator version: 7.8.0 - Build package: org.openapitools.codegen.languages.DartClientCodegen diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml index 18d515b92b..dd046a60eb 100644 --- a/mobile/pubspec.yaml +++ b/mobile/pubspec.yaml @@ -2,7 +2,7 @@ name: immich_mobile description: Immich - selfhosted backup media file on mobile phone publish_to: 'none' -version: 1.126.0+183 +version: 1.126.1+184 environment: sdk: '>=3.3.0 <4.0.0' diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index 28e64dc6d4..25d649e195 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -7468,7 +7468,7 @@ "info": { "title": "Immich", "description": "Immich API", - "version": "1.126.0", + "version": "1.126.1", "contact": {} }, "tags": [], diff --git a/open-api/typescript-sdk/package-lock.json b/open-api/typescript-sdk/package-lock.json index 1ff92584d6..eec9263746 100644 --- a/open-api/typescript-sdk/package-lock.json +++ b/open-api/typescript-sdk/package-lock.json @@ -1,12 +1,12 @@ { "name": "@immich/sdk", - "version": "1.126.0", + "version": "1.126.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@immich/sdk", - "version": "1.126.0", + "version": "1.126.1", "license": "GNU Affero General Public License version 3", "dependencies": { "@oazapfts/runtime": "^1.0.2" diff --git a/open-api/typescript-sdk/package.json b/open-api/typescript-sdk/package.json index c3523ef01c..170347c8fd 100644 --- a/open-api/typescript-sdk/package.json +++ b/open-api/typescript-sdk/package.json @@ -1,6 +1,6 @@ { "name": "@immich/sdk", - "version": "1.126.0", + "version": "1.126.1", "description": "Auto-generated TypeScript SDK for the Immich API", "type": "module", "main": "./build/index.js", diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index 2f7ff04e2e..0473b5603b 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -1,6 +1,6 @@ /** * Immich - * 1.126.0 + * 1.126.1 * DO NOT MODIFY - This file has been generated using oazapfts. * See https://www.npmjs.com/package/oazapfts */ diff --git a/server/package-lock.json b/server/package-lock.json index 4619f8fe67..d79bf1a5b6 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -1,12 +1,12 @@ { "name": "immich", - "version": "1.126.0", + "version": "1.126.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "immich", - "version": "1.126.0", + "version": "1.126.1", "license": "GNU Affero General Public License version 3", "dependencies": { "@nestjs/bullmq": "^11.0.1", diff --git a/server/package.json b/server/package.json index 5121713576..ed8aa335c5 100644 --- a/server/package.json +++ b/server/package.json @@ -1,6 +1,6 @@ { "name": "immich", - "version": "1.126.0", + "version": "1.126.1", "description": "", "author": "", "private": true, diff --git a/web/package-lock.json b/web/package-lock.json index 90b5d95cce..6e6cc62fae 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -1,12 +1,12 @@ { "name": "immich-web", - "version": "1.126.0", + "version": "1.126.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "immich-web", - "version": "1.126.0", + "version": "1.126.1", "license": "GNU Affero General Public License version 3", "dependencies": { "@formatjs/icu-messageformat-parser": "^2.9.8", @@ -77,7 +77,7 @@ }, "../open-api/typescript-sdk": { "name": "@immich/sdk", - "version": "1.126.0", + "version": "1.126.1", "license": "GNU Affero General Public License version 3", "dependencies": { "@oazapfts/runtime": "^1.0.2" diff --git a/web/package.json b/web/package.json index 9fb94af628..47934ce7e2 100644 --- a/web/package.json +++ b/web/package.json @@ -1,6 +1,6 @@ { "name": "immich-web", - "version": "1.126.0", + "version": "1.126.1", "license": "GNU Affero General Public License version 3", "scripts": { "dev": "vite dev --host 0.0.0.0 --port 3000", From 735f8d661eeeccff6f98599fd4572df53e822439 Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Mon, 10 Feb 2025 18:47:42 -0500 Subject: [PATCH 114/395] refactor: test mocks (#16008) --- server/src/cores/storage.core.ts | 22 +- server/src/repositories/asset.repository.ts | 2 +- .../repositories/logging.repository.spec.ts | 4 +- .../notification.repository.spec.ts | 9 +- .../repositories/storage.repository.spec.ts | 8 +- server/src/services/activity.service.spec.ts | 69 +- server/src/services/album.service.spec.ts | 379 +++--- server/src/services/api-key.service.spec.ts | 51 +- .../src/services/asset-media.service.spec.ts | 236 ++-- server/src/services/asset.service.spec.ts | 289 ++--- server/src/services/audit.service.spec.ts | 94 +- server/src/services/auth.service.spec.ts | 273 ++-- server/src/services/backup.service.spec.ts | 117 +- server/src/services/base.service.ts | 3 +- server/src/services/cli.service.spec.ts | 34 +- server/src/services/database.service.spec.ts | 241 ++-- server/src/services/download.service.spec.ts | 85 +- server/src/services/duplicate.service.spec.ts | 103 +- server/src/services/job.service.spec.ts | 110 +- server/src/services/library.service.spec.ts | 410 +++--- server/src/services/map.service.spec.ts | 35 +- server/src/services/media.service.spec.ts | 1129 ++++++++--------- server/src/services/memory.service.spec.ts | 87 +- server/src/services/metadata.service.spec.ts | 741 ++++++----- .../src/services/notification.service.spec.ts | 254 ++-- server/src/services/partner.service.spec.ts | 44 +- server/src/services/person.service.spec.ts | 782 ++++++------ server/src/services/search.service.spec.ts | 64 +- server/src/services/server.service.spec.ts | 57 +- server/src/services/session.service.spec.ts | 40 +- .../src/services/shared-link.service.spec.ts | 121 +- .../src/services/smart-info.service.spec.ts | 239 ++-- server/src/services/stack.service.spec.ts | 103 +- .../services/storage-template.service.spec.ts | 330 +++-- server/src/services/storage.service.spec.ts | 103 +- server/src/services/sync.service.spec.ts | 47 +- .../services/system-config.service.spec.ts | 100 +- .../services/system-metadata.service.spec.ts | 20 +- server/src/services/tag.service.spec.ts | 145 +-- server/src/services/timeline.service.spec.ts | 48 +- server/src/services/trash.service.spec.ts | 54 +- .../src/services/user-admin.service.spec.ts | 83 +- server/src/services/user.service.spec.ts | 160 ++- server/src/services/version.service.spec.ts | 77 +- server/src/services/view.service.spec.ts | 17 +- server/src/types.ts | 57 +- server/src/utils/config.ts | 11 +- server/src/utils/file.ts | 4 +- server/src/utils/logger.ts | 4 +- server/src/utils/misc.ts | 4 +- server/test/medium/metadata.service.spec.ts | 22 +- .../repositories/access.repository.mock.ts | 9 +- .../repositories/activity.repository.mock.ts | 5 +- .../album-user.repository.mock.ts | 5 +- .../repositories/api-key.repository.mock.ts | 5 +- .../repositories/audit.repository.mock.ts | 5 +- .../repositories/config.repository.mock.ts | 6 +- .../test/repositories/cron.repository.mock.ts | 5 +- .../repositories/logger.repository.mock.ts | 16 +- .../test/repositories/map.repository.mock.ts | 5 +- .../repositories/media.repository.mock.ts | 5 +- .../repositories/memory.repository.mock.ts | 5 +- .../repositories/metadata.repository.mock.ts | 5 +- .../notification.repository.mock.ts | 5 +- .../repositories/oauth.repository.mock.ts | 5 +- .../repositories/process.repository.mock.ts | 5 +- .../server-info.repository.mock.ts | 5 +- .../repositories/session.repository.mock.ts | 5 +- .../system-metadata.repository.mock.ts | 5 +- .../repositories/telemetry.repository.mock.ts | 5 +- .../repositories/trash.repository.mock.ts | 5 +- .../version-history.repository.mock.ts | 5 +- .../test/repositories/view.repository.mock.ts | 5 +- server/test/utils.ts | 216 ++-- 74 files changed, 3820 insertions(+), 4043 deletions(-) diff --git a/server/src/cores/storage.core.ts b/server/src/cores/storage.core.ts index 0dc225b90c..86f7be9ffd 100644 --- a/server/src/cores/storage.core.ts +++ b/server/src/cores/storage.core.ts @@ -5,11 +5,13 @@ import { AssetEntity } from 'src/entities/asset.entity'; import { PersonEntity } from 'src/entities/person.entity'; import { AssetFileType, AssetPathType, ImageFormat, PathType, PersonPathType, StorageFolder } from 'src/enum'; import { IAssetRepository } from 'src/interfaces/asset.interface'; -import { ICryptoRepository } from 'src/interfaces/crypto.interface'; import { IMoveRepository } from 'src/interfaces/move.interface'; import { IPersonRepository } from 'src/interfaces/person.interface'; import { IStorageRepository } from 'src/interfaces/storage.interface'; -import { IConfigRepository, ILoggingRepository, ISystemMetadataRepository } from 'src/types'; +import { ConfigRepository } from 'src/repositories/config.repository'; +import { CryptoRepository } from 'src/repositories/crypto.repository'; +import { LoggingRepository } from 'src/repositories/logging.repository'; +import { SystemMetadataRepository } from 'src/repositories/system-metadata.repository'; import { getAssetFiles } from 'src/utils/asset.util'; import { getConfig } from 'src/utils/config'; @@ -32,24 +34,24 @@ let instance: StorageCore | null; export class StorageCore { private constructor( private assetRepository: IAssetRepository, - private configRepository: IConfigRepository, - private cryptoRepository: ICryptoRepository, + private configRepository: ConfigRepository, + private cryptoRepository: CryptoRepository, private moveRepository: IMoveRepository, private personRepository: IPersonRepository, private storageRepository: IStorageRepository, - private systemMetadataRepository: ISystemMetadataRepository, - private logger: ILoggingRepository, + private systemMetadataRepository: SystemMetadataRepository, + private logger: LoggingRepository, ) {} static create( assetRepository: IAssetRepository, - configRepository: IConfigRepository, - cryptoRepository: ICryptoRepository, + configRepository: ConfigRepository, + cryptoRepository: CryptoRepository, moveRepository: IMoveRepository, personRepository: IPersonRepository, storageRepository: IStorageRepository, - systemMetadataRepository: ISystemMetadataRepository, - logger: ILoggingRepository, + systemMetadataRepository: SystemMetadataRepository, + logger: LoggingRepository, ) { if (!instance) { instance = new StorageCore( diff --git a/server/src/repositories/asset.repository.ts b/server/src/repositories/asset.repository.ts index 201f295d49..2ac81bbf97 100644 --- a/server/src/repositories/asset.repository.ts +++ b/server/src/repositories/asset.repository.ts @@ -524,7 +524,7 @@ export class AssetRepository implements IAssetRepository { .executeTakeFirst() as Promise; } - getMapMarkers(ownerIds: string[], options: MapMarkerSearchOptions = {}): Promise { + private getMapMarkers(ownerIds: string[], options: MapMarkerSearchOptions = {}): Promise { const { isArchived, isFavorite, fileCreatedAfter, fileCreatedBefore } = options; return this.db diff --git a/server/src/repositories/logging.repository.spec.ts b/server/src/repositories/logging.repository.spec.ts index 11fa19e48b..10c1a6516c 100644 --- a/server/src/repositories/logging.repository.spec.ts +++ b/server/src/repositories/logging.repository.spec.ts @@ -1,14 +1,14 @@ import { ClsService } from 'nestjs-cls'; import { ImmichWorker } from 'src/enum'; +import { ConfigRepository } from 'src/repositories/config.repository'; import { LoggingRepository } from 'src/repositories/logging.repository'; -import { IConfigRepository } from 'src/types'; import { mockEnvData, newConfigRepositoryMock } from 'test/repositories/config.repository.mock'; import { Mocked } from 'vitest'; describe(LoggingRepository.name, () => { let sut: LoggingRepository; - let configMock: Mocked; + let configMock: Mocked; let clsMock: Mocked; beforeEach(() => { diff --git a/server/src/repositories/notification.repository.spec.ts b/server/src/repositories/notification.repository.spec.ts index 0d8e826c66..7707069dd9 100644 --- a/server/src/repositories/notification.repository.spec.ts +++ b/server/src/repositories/notification.repository.spec.ts @@ -1,17 +1,16 @@ import { LoggingRepository } from 'src/repositories/logging.repository'; import { EmailRenderRequest, EmailTemplate, NotificationRepository } from 'src/repositories/notification.repository'; -import { ILoggingRepository } from 'src/types'; -import { newLoggingRepositoryMock } from 'test/repositories/logger.repository.mock'; +import { ILoggingRepository, newLoggingRepositoryMock } from 'test/repositories/logger.repository.mock'; import { Mocked } from 'vitest'; describe(NotificationRepository.name, () => { let sut: NotificationRepository; - let loggerMock: Mocked; + let loggerMock: Mocked; beforeEach(() => { - loggerMock = newLoggingRepositoryMock(); + loggerMock = newLoggingRepositoryMock() as ILoggingRepository as Mocked; - sut = new NotificationRepository(loggerMock as ILoggingRepository as LoggingRepository); + sut = new NotificationRepository(loggerMock as LoggingRepository); }); describe('renderEmail', () => { diff --git a/server/src/repositories/storage.repository.spec.ts b/server/src/repositories/storage.repository.spec.ts index 4c4a9d50b6..3ab9e615ec 100644 --- a/server/src/repositories/storage.repository.spec.ts +++ b/server/src/repositories/storage.repository.spec.ts @@ -2,8 +2,8 @@ import mockfs from 'mock-fs'; import { CrawlOptionsDto } from 'src/dtos/library.dto'; import { LoggingRepository } from 'src/repositories/logging.repository'; import { StorageRepository } from 'src/repositories/storage.repository'; -import { ILoggingRepository } from 'src/types'; -import { newLoggingRepositoryMock } from 'test/repositories/logger.repository.mock'; +import { ILoggingRepository, newLoggingRepositoryMock } from 'test/repositories/logger.repository.mock'; +import { Mocked } from 'vitest'; interface Test { test: string; @@ -182,11 +182,11 @@ const tests: Test[] = [ describe(StorageRepository.name, () => { let sut: StorageRepository; - let logger: ILoggingRepository; + let logger: Mocked; beforeEach(() => { logger = newLoggingRepositoryMock(); - sut = new StorageRepository(logger as LoggingRepository); + sut = new StorageRepository(logger as ILoggingRepository as LoggingRepository); }); afterEach(() => { diff --git a/server/src/services/activity.service.spec.ts b/server/src/services/activity.service.spec.ts index 4ee656abe5..bb63c7bf7b 100644 --- a/server/src/services/activity.service.spec.ts +++ b/server/src/services/activity.service.spec.ts @@ -1,21 +1,16 @@ import { BadRequestException } from '@nestjs/common'; import { ReactionType } from 'src/dtos/activity.dto'; import { ActivityService } from 'src/services/activity.service'; -import { IActivityRepository } from 'src/types'; import { activityStub } from 'test/fixtures/activity.stub'; import { authStub } from 'test/fixtures/auth.stub'; -import { IAccessRepositoryMock } from 'test/repositories/access.repository.mock'; -import { newTestService } from 'test/utils'; -import { Mocked } from 'vitest'; +import { newTestService, ServiceMocks } from 'test/utils'; describe(ActivityService.name, () => { let sut: ActivityService; - - let accessMock: IAccessRepositoryMock; - let activityMock: Mocked; + let mocks: ServiceMocks; beforeEach(() => { - ({ sut, accessMock, activityMock } = newTestService(ActivityService)); + ({ sut, mocks } = newTestService(ActivityService)); }); it('should work', () => { @@ -24,12 +19,12 @@ describe(ActivityService.name, () => { describe('getAll', () => { it('should get all', async () => { - accessMock.album.checkOwnerAccess.mockResolvedValue(new Set(['album-id'])); - activityMock.search.mockResolvedValue([]); + mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set(['album-id'])); + mocks.activity.search.mockResolvedValue([]); await expect(sut.getAll(authStub.admin, { assetId: 'asset-id', albumId: 'album-id' })).resolves.toEqual([]); - expect(activityMock.search).toHaveBeenCalledWith({ + expect(mocks.activity.search).toHaveBeenCalledWith({ assetId: 'asset-id', albumId: 'album-id', isLiked: undefined, @@ -37,14 +32,14 @@ describe(ActivityService.name, () => { }); it('should filter by type=like', async () => { - accessMock.album.checkOwnerAccess.mockResolvedValue(new Set(['album-id'])); - activityMock.search.mockResolvedValue([]); + mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set(['album-id'])); + mocks.activity.search.mockResolvedValue([]); await expect( sut.getAll(authStub.admin, { assetId: 'asset-id', albumId: 'album-id', type: ReactionType.LIKE }), ).resolves.toEqual([]); - expect(activityMock.search).toHaveBeenCalledWith({ + expect(mocks.activity.search).toHaveBeenCalledWith({ assetId: 'asset-id', albumId: 'album-id', isLiked: true, @@ -52,14 +47,14 @@ describe(ActivityService.name, () => { }); it('should filter by type=comment', async () => { - accessMock.album.checkOwnerAccess.mockResolvedValue(new Set(['album-id'])); - activityMock.search.mockResolvedValue([]); + mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set(['album-id'])); + mocks.activity.search.mockResolvedValue([]); await expect( sut.getAll(authStub.admin, { assetId: 'asset-id', albumId: 'album-id', type: ReactionType.COMMENT }), ).resolves.toEqual([]); - expect(activityMock.search).toHaveBeenCalledWith({ + expect(mocks.activity.search).toHaveBeenCalledWith({ assetId: 'asset-id', albumId: 'album-id', isLiked: false, @@ -69,8 +64,8 @@ describe(ActivityService.name, () => { describe('getStatistics', () => { it('should get the comment count', async () => { - activityMock.getStatistics.mockResolvedValue(1); - accessMock.album.checkOwnerAccess.mockResolvedValue(new Set([activityStub.oneComment.albumId])); + mocks.activity.getStatistics.mockResolvedValue(1); + mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set([activityStub.oneComment.albumId])); await expect( sut.getStatistics(authStub.admin, { assetId: 'asset-id', @@ -93,8 +88,8 @@ describe(ActivityService.name, () => { }); it('should create a comment', async () => { - accessMock.activity.checkCreateAccess.mockResolvedValue(new Set(['album-id'])); - activityMock.create.mockResolvedValue(activityStub.oneComment); + mocks.access.activity.checkCreateAccess.mockResolvedValue(new Set(['album-id'])); + mocks.activity.create.mockResolvedValue(activityStub.oneComment); await sut.create(authStub.admin, { albumId: 'album-id', @@ -103,7 +98,7 @@ describe(ActivityService.name, () => { comment: 'comment', }); - expect(activityMock.create).toHaveBeenCalledWith({ + expect(mocks.activity.create).toHaveBeenCalledWith({ userId: 'admin_id', albumId: 'album-id', assetId: 'asset-id', @@ -113,8 +108,8 @@ describe(ActivityService.name, () => { }); it('should fail because activity is disabled for the album', async () => { - accessMock.album.checkOwnerAccess.mockResolvedValue(new Set(['album-id'])); - activityMock.create.mockResolvedValue(activityStub.oneComment); + mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set(['album-id'])); + mocks.activity.create.mockResolvedValue(activityStub.oneComment); await expect( sut.create(authStub.admin, { @@ -127,9 +122,9 @@ describe(ActivityService.name, () => { }); it('should create a like', async () => { - accessMock.activity.checkCreateAccess.mockResolvedValue(new Set(['album-id'])); - activityMock.create.mockResolvedValue(activityStub.liked); - activityMock.search.mockResolvedValue([]); + mocks.access.activity.checkCreateAccess.mockResolvedValue(new Set(['album-id'])); + mocks.activity.create.mockResolvedValue(activityStub.liked); + mocks.activity.search.mockResolvedValue([]); await sut.create(authStub.admin, { albumId: 'album-id', @@ -137,7 +132,7 @@ describe(ActivityService.name, () => { type: ReactionType.LIKE, }); - expect(activityMock.create).toHaveBeenCalledWith({ + expect(mocks.activity.create).toHaveBeenCalledWith({ userId: 'admin_id', albumId: 'album-id', assetId: 'asset-id', @@ -146,9 +141,9 @@ describe(ActivityService.name, () => { }); it('should skip if like exists', async () => { - accessMock.album.checkOwnerAccess.mockResolvedValue(new Set(['album-id'])); - accessMock.activity.checkCreateAccess.mockResolvedValue(new Set(['album-id'])); - activityMock.search.mockResolvedValue([activityStub.liked]); + mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set(['album-id'])); + mocks.access.activity.checkCreateAccess.mockResolvedValue(new Set(['album-id'])); + mocks.activity.search.mockResolvedValue([activityStub.liked]); await sut.create(authStub.admin, { albumId: 'album-id', @@ -156,26 +151,26 @@ describe(ActivityService.name, () => { type: ReactionType.LIKE, }); - expect(activityMock.create).not.toHaveBeenCalled(); + expect(mocks.activity.create).not.toHaveBeenCalled(); }); }); describe('delete', () => { it('should require access', async () => { await expect(sut.delete(authStub.admin, activityStub.oneComment.id)).rejects.toBeInstanceOf(BadRequestException); - expect(activityMock.delete).not.toHaveBeenCalled(); + expect(mocks.activity.delete).not.toHaveBeenCalled(); }); it('should let the activity owner delete a comment', async () => { - accessMock.activity.checkOwnerAccess.mockResolvedValue(new Set(['activity-id'])); + mocks.access.activity.checkOwnerAccess.mockResolvedValue(new Set(['activity-id'])); await sut.delete(authStub.admin, 'activity-id'); - expect(activityMock.delete).toHaveBeenCalledWith('activity-id'); + expect(mocks.activity.delete).toHaveBeenCalledWith('activity-id'); }); it('should let the album owner delete a comment', async () => { - accessMock.activity.checkAlbumOwnerAccess.mockResolvedValue(new Set(['activity-id'])); + mocks.access.activity.checkAlbumOwnerAccess.mockResolvedValue(new Set(['activity-id'])); await sut.delete(authStub.admin, 'activity-id'); - expect(activityMock.delete).toHaveBeenCalledWith('activity-id'); + expect(mocks.activity.delete).toHaveBeenCalledWith('activity-id'); }); }); }); diff --git a/server/src/services/album.service.spec.ts b/server/src/services/album.service.spec.ts index 942615b0d9..832ed59dd5 100644 --- a/server/src/services/album.service.spec.ts +++ b/server/src/services/album.service.spec.ts @@ -2,29 +2,18 @@ import { BadRequestException } from '@nestjs/common'; import _ from 'lodash'; import { BulkIdErrorReason } from 'src/dtos/asset-ids.response.dto'; import { AlbumUserRole } from 'src/enum'; -import { IAlbumRepository } from 'src/interfaces/album.interface'; -import { IEventRepository } from 'src/interfaces/event.interface'; -import { IUserRepository } from 'src/interfaces/user.interface'; import { AlbumService } from 'src/services/album.service'; -import { IAlbumUserRepository } from 'src/types'; import { albumStub } from 'test/fixtures/album.stub'; import { authStub } from 'test/fixtures/auth.stub'; import { userStub } from 'test/fixtures/user.stub'; -import { IAccessRepositoryMock } from 'test/repositories/access.repository.mock'; -import { newTestService } from 'test/utils'; -import { Mocked } from 'vitest'; +import { newTestService, ServiceMocks } from 'test/utils'; describe(AlbumService.name, () => { let sut: AlbumService; - - let accessMock: IAccessRepositoryMock; - let albumMock: Mocked; - let albumUserMock: Mocked; - let eventMock: Mocked; - let userMock: Mocked; + let mocks: ServiceMocks; beforeEach(() => { - ({ sut, accessMock, albumMock, albumUserMock, eventMock, userMock } = newTestService(AlbumService)); + ({ sut, mocks } = newTestService(AlbumService)); }); it('should work', () => { @@ -33,25 +22,25 @@ describe(AlbumService.name, () => { describe('getStatistics', () => { it('should get the album count', async () => { - albumMock.getOwned.mockResolvedValue([]); - albumMock.getShared.mockResolvedValue([]); - albumMock.getNotShared.mockResolvedValue([]); + mocks.album.getOwned.mockResolvedValue([]); + mocks.album.getShared.mockResolvedValue([]); + mocks.album.getNotShared.mockResolvedValue([]); await expect(sut.getStatistics(authStub.admin)).resolves.toEqual({ owned: 0, shared: 0, notShared: 0, }); - expect(albumMock.getOwned).toHaveBeenCalledWith(authStub.admin.user.id); - expect(albumMock.getShared).toHaveBeenCalledWith(authStub.admin.user.id); - expect(albumMock.getNotShared).toHaveBeenCalledWith(authStub.admin.user.id); + expect(mocks.album.getOwned).toHaveBeenCalledWith(authStub.admin.user.id); + expect(mocks.album.getShared).toHaveBeenCalledWith(authStub.admin.user.id); + expect(mocks.album.getNotShared).toHaveBeenCalledWith(authStub.admin.user.id); }); }); describe('getAll', () => { it('gets list of albums for auth user', async () => { - albumMock.getOwned.mockResolvedValue([albumStub.empty, albumStub.sharedWithUser]); - albumMock.getMetadataForIds.mockResolvedValue([ + mocks.album.getOwned.mockResolvedValue([albumStub.empty, albumStub.sharedWithUser]); + mocks.album.getMetadataForIds.mockResolvedValue([ { albumId: albumStub.empty.id, assetCount: 0, startDate: null, endDate: null }, { albumId: albumStub.sharedWithUser.id, assetCount: 0, startDate: null, endDate: null }, ]); @@ -63,8 +52,8 @@ describe(AlbumService.name, () => { }); it('gets list of albums that have a specific asset', async () => { - albumMock.getByAssetId.mockResolvedValue([albumStub.oneAsset]); - albumMock.getMetadataForIds.mockResolvedValue([ + mocks.album.getByAssetId.mockResolvedValue([albumStub.oneAsset]); + mocks.album.getMetadataForIds.mockResolvedValue([ { albumId: albumStub.oneAsset.id, assetCount: 1, @@ -76,37 +65,37 @@ describe(AlbumService.name, () => { const result = await sut.getAll(authStub.admin, { assetId: albumStub.oneAsset.id }); expect(result).toHaveLength(1); expect(result[0].id).toEqual(albumStub.oneAsset.id); - expect(albumMock.getByAssetId).toHaveBeenCalledTimes(1); + expect(mocks.album.getByAssetId).toHaveBeenCalledTimes(1); }); it('gets list of albums that are shared', async () => { - albumMock.getShared.mockResolvedValue([albumStub.sharedWithUser]); - albumMock.getMetadataForIds.mockResolvedValue([ + mocks.album.getShared.mockResolvedValue([albumStub.sharedWithUser]); + mocks.album.getMetadataForIds.mockResolvedValue([ { albumId: albumStub.sharedWithUser.id, assetCount: 0, startDate: null, endDate: null }, ]); const result = await sut.getAll(authStub.admin, { shared: true }); expect(result).toHaveLength(1); expect(result[0].id).toEqual(albumStub.sharedWithUser.id); - expect(albumMock.getShared).toHaveBeenCalledTimes(1); + expect(mocks.album.getShared).toHaveBeenCalledTimes(1); }); it('gets list of albums that are NOT shared', async () => { - albumMock.getNotShared.mockResolvedValue([albumStub.empty]); - albumMock.getMetadataForIds.mockResolvedValue([ + mocks.album.getNotShared.mockResolvedValue([albumStub.empty]); + mocks.album.getMetadataForIds.mockResolvedValue([ { albumId: albumStub.empty.id, assetCount: 0, startDate: null, endDate: null }, ]); const result = await sut.getAll(authStub.admin, { shared: false }); expect(result).toHaveLength(1); expect(result[0].id).toEqual(albumStub.empty.id); - expect(albumMock.getNotShared).toHaveBeenCalledTimes(1); + expect(mocks.album.getNotShared).toHaveBeenCalledTimes(1); }); }); it('counts assets correctly', async () => { - albumMock.getOwned.mockResolvedValue([albumStub.oneAsset]); - albumMock.getMetadataForIds.mockResolvedValue([ + mocks.album.getOwned.mockResolvedValue([albumStub.oneAsset]); + mocks.album.getMetadataForIds.mockResolvedValue([ { albumId: albumStub.oneAsset.id, assetCount: 1, @@ -119,14 +108,14 @@ describe(AlbumService.name, () => { expect(result).toHaveLength(1); expect(result[0].assetCount).toEqual(1); - expect(albumMock.getOwned).toHaveBeenCalledTimes(1); + expect(mocks.album.getOwned).toHaveBeenCalledTimes(1); }); describe('create', () => { it('creates album', async () => { - albumMock.create.mockResolvedValue(albumStub.empty); - userMock.get.mockResolvedValue(userStub.user1); - accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['123'])); + mocks.album.create.mockResolvedValue(albumStub.empty); + mocks.user.get.mockResolvedValue(userStub.user1); + mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['123'])); await sut.create(authStub.admin, { albumName: 'Empty album', @@ -135,7 +124,7 @@ describe(AlbumService.name, () => { assetIds: ['123'], }); - expect(albumMock.create).toHaveBeenCalledWith( + expect(mocks.album.create).toHaveBeenCalledWith( { ownerId: authStub.admin.user.id, albumName: albumStub.empty.albumName, @@ -147,30 +136,30 @@ describe(AlbumService.name, () => { [{ userId: 'user-id', role: AlbumUserRole.EDITOR }], ); - expect(userMock.get).toHaveBeenCalledWith('user-id', {}); - expect(accessMock.asset.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['123'])); - expect(eventMock.emit).toHaveBeenCalledWith('album.invite', { + expect(mocks.user.get).toHaveBeenCalledWith('user-id', {}); + expect(mocks.access.asset.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['123'])); + expect(mocks.event.emit).toHaveBeenCalledWith('album.invite', { id: albumStub.empty.id, userId: 'user-id', }); }); it('should require valid userIds', async () => { - userMock.get.mockResolvedValue(void 0); + mocks.user.get.mockResolvedValue(void 0); await expect( sut.create(authStub.admin, { albumName: 'Empty album', albumUsers: [{ userId: 'user-3', role: AlbumUserRole.EDITOR }], }), ).rejects.toBeInstanceOf(BadRequestException); - expect(userMock.get).toHaveBeenCalledWith('user-3', {}); - expect(albumMock.create).not.toHaveBeenCalled(); + expect(mocks.user.get).toHaveBeenCalledWith('user-3', {}); + expect(mocks.album.create).not.toHaveBeenCalled(); }); it('should only add assets the user is allowed to access', async () => { - userMock.get.mockResolvedValue(userStub.user1); - albumMock.create.mockResolvedValue(albumStub.oneAsset); - accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1'])); + mocks.user.get.mockResolvedValue(userStub.user1); + mocks.album.create.mockResolvedValue(albumStub.oneAsset); + mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1'])); await sut.create(authStub.admin, { albumName: 'Test album', @@ -178,7 +167,7 @@ describe(AlbumService.name, () => { assetIds: ['asset-1', 'asset-2'], }); - expect(albumMock.create).toHaveBeenCalledWith( + expect(mocks.album.create).toHaveBeenCalledWith( { ownerId: authStub.admin.user.id, albumName: 'Test album', @@ -189,7 +178,7 @@ describe(AlbumService.name, () => { ['asset-1'], [], ); - expect(accessMock.asset.checkOwnerAccess).toHaveBeenCalledWith( + expect(mocks.access.asset.checkOwnerAccess).toHaveBeenCalledWith( authStub.admin.user.id, new Set(['asset-1', 'asset-2']), ); @@ -198,7 +187,7 @@ describe(AlbumService.name, () => { describe('update', () => { it('should prevent updating an album that does not exist', async () => { - albumMock.getById.mockResolvedValue(void 0); + mocks.album.getById.mockResolvedValue(void 0); await expect( sut.update(authStub.user1, 'invalid-id', { @@ -206,7 +195,7 @@ describe(AlbumService.name, () => { }), ).rejects.toBeInstanceOf(BadRequestException); - expect(albumMock.update).not.toHaveBeenCalled(); + expect(mocks.album.update).not.toHaveBeenCalled(); }); it('should prevent updating a not owned album (shared with auth user)', async () => { @@ -218,10 +207,10 @@ describe(AlbumService.name, () => { }); it('should require a valid thumbnail asset id', async () => { - accessMock.album.checkOwnerAccess.mockResolvedValue(new Set(['album-4'])); - albumMock.getById.mockResolvedValue(albumStub.oneAsset); - albumMock.update.mockResolvedValue(albumStub.oneAsset); - albumMock.getAssetIds.mockResolvedValue(new Set()); + mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set(['album-4'])); + mocks.album.getById.mockResolvedValue(albumStub.oneAsset); + mocks.album.update.mockResolvedValue(albumStub.oneAsset); + mocks.album.getAssetIds.mockResolvedValue(new Set()); await expect( sut.update(authStub.admin, albumStub.oneAsset.id, { @@ -229,22 +218,22 @@ describe(AlbumService.name, () => { }), ).rejects.toBeInstanceOf(BadRequestException); - expect(albumMock.getAssetIds).toHaveBeenCalledWith('album-4', ['not-in-album']); - expect(albumMock.update).not.toHaveBeenCalled(); + expect(mocks.album.getAssetIds).toHaveBeenCalledWith('album-4', ['not-in-album']); + expect(mocks.album.update).not.toHaveBeenCalled(); }); it('should allow the owner to update the album', async () => { - accessMock.album.checkOwnerAccess.mockResolvedValue(new Set(['album-4'])); + mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set(['album-4'])); - albumMock.getById.mockResolvedValue(albumStub.oneAsset); - albumMock.update.mockResolvedValue(albumStub.oneAsset); + mocks.album.getById.mockResolvedValue(albumStub.oneAsset); + mocks.album.update.mockResolvedValue(albumStub.oneAsset); await sut.update(authStub.admin, albumStub.oneAsset.id, { albumName: 'new album name', }); - expect(albumMock.update).toHaveBeenCalledTimes(1); - expect(albumMock.update).toHaveBeenCalledWith('album-4', { + expect(mocks.album.update).toHaveBeenCalledTimes(1); + expect(mocks.album.update).toHaveBeenCalledWith('album-4', { id: 'album-4', albumName: 'new album name', }); @@ -253,33 +242,33 @@ describe(AlbumService.name, () => { describe('delete', () => { it('should throw an error for an album not found', async () => { - accessMock.album.checkOwnerAccess.mockResolvedValue(new Set()); + mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set()); await expect(sut.delete(authStub.admin, albumStub.sharedWithAdmin.id)).rejects.toBeInstanceOf( BadRequestException, ); - expect(albumMock.delete).not.toHaveBeenCalled(); + expect(mocks.album.delete).not.toHaveBeenCalled(); }); it('should not let a shared user delete the album', async () => { - albumMock.getById.mockResolvedValue(albumStub.sharedWithAdmin); + mocks.album.getById.mockResolvedValue(albumStub.sharedWithAdmin); await expect(sut.delete(authStub.admin, albumStub.sharedWithAdmin.id)).rejects.toBeInstanceOf( BadRequestException, ); - expect(albumMock.delete).not.toHaveBeenCalled(); + expect(mocks.album.delete).not.toHaveBeenCalled(); }); it('should let the owner delete an album', async () => { - accessMock.album.checkOwnerAccess.mockResolvedValue(new Set([albumStub.empty.id])); - albumMock.getById.mockResolvedValue(albumStub.empty); + mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set([albumStub.empty.id])); + mocks.album.getById.mockResolvedValue(albumStub.empty); await sut.delete(authStub.admin, albumStub.empty.id); - expect(albumMock.delete).toHaveBeenCalledTimes(1); - expect(albumMock.delete).toHaveBeenCalledWith(albumStub.empty.id); + expect(mocks.album.delete).toHaveBeenCalledTimes(1); + expect(mocks.album.delete).toHaveBeenCalledWith(albumStub.empty.id); }); }); @@ -288,47 +277,47 @@ describe(AlbumService.name, () => { await expect( sut.addUsers(authStub.admin, albumStub.sharedWithAdmin.id, { albumUsers: [{ userId: 'user-1' }] }), ).rejects.toBeInstanceOf(BadRequestException); - expect(albumMock.update).not.toHaveBeenCalled(); + expect(mocks.album.update).not.toHaveBeenCalled(); }); it('should throw an error if the userId is already added', async () => { - accessMock.album.checkOwnerAccess.mockResolvedValue(new Set([albumStub.sharedWithAdmin.id])); - albumMock.getById.mockResolvedValue(albumStub.sharedWithAdmin); + mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set([albumStub.sharedWithAdmin.id])); + mocks.album.getById.mockResolvedValue(albumStub.sharedWithAdmin); await expect( sut.addUsers(authStub.user1, albumStub.sharedWithAdmin.id, { albumUsers: [{ userId: authStub.admin.user.id }], }), ).rejects.toBeInstanceOf(BadRequestException); - expect(albumMock.update).not.toHaveBeenCalled(); + expect(mocks.album.update).not.toHaveBeenCalled(); }); it('should throw an error if the userId does not exist', async () => { - accessMock.album.checkOwnerAccess.mockResolvedValue(new Set([albumStub.sharedWithAdmin.id])); - albumMock.getById.mockResolvedValue(albumStub.sharedWithAdmin); - userMock.get.mockResolvedValue(void 0); + mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set([albumStub.sharedWithAdmin.id])); + mocks.album.getById.mockResolvedValue(albumStub.sharedWithAdmin); + mocks.user.get.mockResolvedValue(void 0); await expect( sut.addUsers(authStub.user1, albumStub.sharedWithAdmin.id, { albumUsers: [{ userId: 'user-3' }] }), ).rejects.toBeInstanceOf(BadRequestException); - expect(albumMock.update).not.toHaveBeenCalled(); + expect(mocks.album.update).not.toHaveBeenCalled(); }); it('should throw an error if the userId is the ownerId', async () => { - accessMock.album.checkOwnerAccess.mockResolvedValue(new Set([albumStub.sharedWithAdmin.id])); - albumMock.getById.mockResolvedValue(albumStub.sharedWithAdmin); + mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set([albumStub.sharedWithAdmin.id])); + mocks.album.getById.mockResolvedValue(albumStub.sharedWithAdmin); await expect( sut.addUsers(authStub.user1, albumStub.sharedWithAdmin.id, { albumUsers: [{ userId: userStub.user1.id }], }), ).rejects.toBeInstanceOf(BadRequestException); - expect(albumMock.update).not.toHaveBeenCalled(); + expect(mocks.album.update).not.toHaveBeenCalled(); }); it('should add valid shared users', async () => { - accessMock.album.checkOwnerAccess.mockResolvedValue(new Set([albumStub.sharedWithAdmin.id])); - albumMock.getById.mockResolvedValue(_.cloneDeep(albumStub.sharedWithAdmin)); - albumMock.update.mockResolvedValue(albumStub.sharedWithAdmin); - userMock.get.mockResolvedValue(userStub.user2); - albumUserMock.create.mockResolvedValue({ + mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set([albumStub.sharedWithAdmin.id])); + mocks.album.getById.mockResolvedValue(_.cloneDeep(albumStub.sharedWithAdmin)); + mocks.album.update.mockResolvedValue(albumStub.sharedWithAdmin); + mocks.user.get.mockResolvedValue(userStub.user2); + mocks.albumUser.create.mockResolvedValue({ usersId: userStub.user2.id, albumsId: albumStub.sharedWithAdmin.id, role: AlbumUserRole.EDITOR, @@ -336,11 +325,11 @@ describe(AlbumService.name, () => { await sut.addUsers(authStub.user1, albumStub.sharedWithAdmin.id, { albumUsers: [{ userId: authStub.user2.user.id }], }); - expect(albumUserMock.create).toHaveBeenCalledWith({ + expect(mocks.albumUser.create).toHaveBeenCalledWith({ usersId: authStub.user2.user.id, albumsId: albumStub.sharedWithAdmin.id, }); - expect(eventMock.emit).toHaveBeenCalledWith('album.invite', { + expect(mocks.event.emit).toHaveBeenCalledWith('album.invite', { id: albumStub.sharedWithAdmin.id, userId: userStub.user2.id, }); @@ -349,94 +338,94 @@ describe(AlbumService.name, () => { describe('removeUser', () => { it('should require a valid album id', async () => { - accessMock.album.checkOwnerAccess.mockResolvedValue(new Set(['album-1'])); - albumMock.getById.mockResolvedValue(void 0); + mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set(['album-1'])); + mocks.album.getById.mockResolvedValue(void 0); await expect(sut.removeUser(authStub.admin, 'album-1', 'user-1')).rejects.toBeInstanceOf(BadRequestException); - expect(albumMock.update).not.toHaveBeenCalled(); + expect(mocks.album.update).not.toHaveBeenCalled(); }); it('should remove a shared user from an owned album', async () => { - accessMock.album.checkOwnerAccess.mockResolvedValue(new Set([albumStub.sharedWithUser.id])); - albumMock.getById.mockResolvedValue(albumStub.sharedWithUser); + mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set([albumStub.sharedWithUser.id])); + mocks.album.getById.mockResolvedValue(albumStub.sharedWithUser); await expect( sut.removeUser(authStub.admin, albumStub.sharedWithUser.id, userStub.user1.id), ).resolves.toBeUndefined(); - expect(albumUserMock.delete).toHaveBeenCalledTimes(1); - expect(albumUserMock.delete).toHaveBeenCalledWith({ + expect(mocks.albumUser.delete).toHaveBeenCalledTimes(1); + expect(mocks.albumUser.delete).toHaveBeenCalledWith({ albumsId: albumStub.sharedWithUser.id, usersId: userStub.user1.id, }); - expect(albumMock.getById).toHaveBeenCalledWith(albumStub.sharedWithUser.id, { withAssets: false }); + expect(mocks.album.getById).toHaveBeenCalledWith(albumStub.sharedWithUser.id, { withAssets: false }); }); it('should prevent removing a shared user from a not-owned album (shared with auth user)', async () => { - albumMock.getById.mockResolvedValue(albumStub.sharedWithMultiple); + mocks.album.getById.mockResolvedValue(albumStub.sharedWithMultiple); await expect( sut.removeUser(authStub.user1, albumStub.sharedWithMultiple.id, authStub.user2.user.id), ).rejects.toBeInstanceOf(BadRequestException); - expect(albumUserMock.delete).not.toHaveBeenCalled(); - expect(accessMock.album.checkOwnerAccess).toHaveBeenCalledWith( + expect(mocks.albumUser.delete).not.toHaveBeenCalled(); + expect(mocks.access.album.checkOwnerAccess).toHaveBeenCalledWith( authStub.user1.user.id, new Set([albumStub.sharedWithMultiple.id]), ); }); it('should allow a shared user to remove themselves', async () => { - albumMock.getById.mockResolvedValue(albumStub.sharedWithUser); + mocks.album.getById.mockResolvedValue(albumStub.sharedWithUser); await sut.removeUser(authStub.user1, albumStub.sharedWithUser.id, authStub.user1.user.id); - expect(albumUserMock.delete).toHaveBeenCalledTimes(1); - expect(albumUserMock.delete).toHaveBeenCalledWith({ + expect(mocks.albumUser.delete).toHaveBeenCalledTimes(1); + expect(mocks.albumUser.delete).toHaveBeenCalledWith({ albumsId: albumStub.sharedWithUser.id, usersId: authStub.user1.user.id, }); }); it('should allow a shared user to remove themselves using "me"', async () => { - albumMock.getById.mockResolvedValue(albumStub.sharedWithUser); + mocks.album.getById.mockResolvedValue(albumStub.sharedWithUser); await sut.removeUser(authStub.user1, albumStub.sharedWithUser.id, 'me'); - expect(albumUserMock.delete).toHaveBeenCalledTimes(1); - expect(albumUserMock.delete).toHaveBeenCalledWith({ + expect(mocks.albumUser.delete).toHaveBeenCalledTimes(1); + expect(mocks.albumUser.delete).toHaveBeenCalledWith({ albumsId: albumStub.sharedWithUser.id, usersId: authStub.user1.user.id, }); }); it('should not allow the owner to be removed', async () => { - albumMock.getById.mockResolvedValue(albumStub.empty); + mocks.album.getById.mockResolvedValue(albumStub.empty); await expect(sut.removeUser(authStub.admin, albumStub.empty.id, authStub.admin.user.id)).rejects.toBeInstanceOf( BadRequestException, ); - expect(albumMock.update).not.toHaveBeenCalled(); + expect(mocks.album.update).not.toHaveBeenCalled(); }); it('should throw an error for a user not in the album', async () => { - albumMock.getById.mockResolvedValue(albumStub.empty); + mocks.album.getById.mockResolvedValue(albumStub.empty); await expect(sut.removeUser(authStub.admin, albumStub.empty.id, 'user-3')).rejects.toBeInstanceOf( BadRequestException, ); - expect(albumMock.update).not.toHaveBeenCalled(); + expect(mocks.album.update).not.toHaveBeenCalled(); }); }); describe('updateUser', () => { it('should update user role', async () => { - accessMock.album.checkOwnerAccess.mockResolvedValue(new Set([albumStub.sharedWithAdmin.id])); + mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set([albumStub.sharedWithAdmin.id])); await sut.updateUser(authStub.user1, albumStub.sharedWithAdmin.id, userStub.admin.id, { role: AlbumUserRole.EDITOR, }); - expect(albumUserMock.update).toHaveBeenCalledWith( + expect(mocks.albumUser.update).toHaveBeenCalledWith( { albumsId: albumStub.sharedWithAdmin.id, usersId: userStub.admin.id }, { role: AlbumUserRole.EDITOR }, ); @@ -445,9 +434,9 @@ describe(AlbumService.name, () => { describe('getAlbumInfo', () => { it('should get a shared album', async () => { - albumMock.getById.mockResolvedValue(albumStub.oneAsset); - accessMock.album.checkOwnerAccess.mockResolvedValue(new Set([albumStub.oneAsset.id])); - albumMock.getMetadataForIds.mockResolvedValue([ + mocks.album.getById.mockResolvedValue(albumStub.oneAsset); + mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set([albumStub.oneAsset.id])); + mocks.album.getMetadataForIds.mockResolvedValue([ { albumId: albumStub.oneAsset.id, assetCount: 1, @@ -458,17 +447,17 @@ describe(AlbumService.name, () => { await sut.get(authStub.admin, albumStub.oneAsset.id, {}); - expect(albumMock.getById).toHaveBeenCalledWith(albumStub.oneAsset.id, { withAssets: true }); - expect(accessMock.album.checkOwnerAccess).toHaveBeenCalledWith( + expect(mocks.album.getById).toHaveBeenCalledWith(albumStub.oneAsset.id, { withAssets: true }); + expect(mocks.access.album.checkOwnerAccess).toHaveBeenCalledWith( authStub.admin.user.id, new Set([albumStub.oneAsset.id]), ); }); it('should get a shared album via a shared link', async () => { - albumMock.getById.mockResolvedValue(albumStub.oneAsset); - accessMock.album.checkSharedLinkAccess.mockResolvedValue(new Set(['album-123'])); - albumMock.getMetadataForIds.mockResolvedValue([ + mocks.album.getById.mockResolvedValue(albumStub.oneAsset); + mocks.access.album.checkSharedLinkAccess.mockResolvedValue(new Set(['album-123'])); + mocks.album.getMetadataForIds.mockResolvedValue([ { albumId: albumStub.oneAsset.id, assetCount: 1, @@ -479,17 +468,17 @@ describe(AlbumService.name, () => { await sut.get(authStub.adminSharedLink, 'album-123', {}); - expect(albumMock.getById).toHaveBeenCalledWith('album-123', { withAssets: true }); - expect(accessMock.album.checkSharedLinkAccess).toHaveBeenCalledWith( + expect(mocks.album.getById).toHaveBeenCalledWith('album-123', { withAssets: true }); + expect(mocks.access.album.checkSharedLinkAccess).toHaveBeenCalledWith( authStub.adminSharedLink.sharedLink?.id, new Set(['album-123']), ); }); it('should get a shared album via shared with user', async () => { - albumMock.getById.mockResolvedValue(albumStub.oneAsset); - accessMock.album.checkSharedAlbumAccess.mockResolvedValue(new Set(['album-123'])); - albumMock.getMetadataForIds.mockResolvedValue([ + mocks.album.getById.mockResolvedValue(albumStub.oneAsset); + mocks.access.album.checkSharedAlbumAccess.mockResolvedValue(new Set(['album-123'])); + mocks.album.getMetadataForIds.mockResolvedValue([ { albumId: albumStub.oneAsset.id, assetCount: 1, @@ -500,8 +489,8 @@ describe(AlbumService.name, () => { await sut.get(authStub.user1, 'album-123', {}); - expect(albumMock.getById).toHaveBeenCalledWith('album-123', { withAssets: true }); - expect(accessMock.album.checkSharedAlbumAccess).toHaveBeenCalledWith( + expect(mocks.album.getById).toHaveBeenCalledWith('album-123', { withAssets: true }); + expect(mocks.access.album.checkSharedAlbumAccess).toHaveBeenCalledWith( authStub.user1.user.id, new Set(['album-123']), AlbumUserRole.VIEWER, @@ -511,8 +500,8 @@ describe(AlbumService.name, () => { it('should throw an error for no access', async () => { await expect(sut.get(authStub.admin, 'album-123', {})).rejects.toBeInstanceOf(BadRequestException); - expect(accessMock.album.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['album-123'])); - expect(accessMock.album.checkSharedAlbumAccess).toHaveBeenCalledWith( + expect(mocks.access.album.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['album-123'])); + expect(mocks.access.album.checkSharedAlbumAccess).toHaveBeenCalledWith( authStub.admin.user.id, new Set(['album-123']), AlbumUserRole.VIEWER, @@ -522,10 +511,10 @@ describe(AlbumService.name, () => { describe('addAssets', () => { it('should allow the owner to add assets', async () => { - accessMock.album.checkOwnerAccess.mockResolvedValue(new Set(['album-123'])); - accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1', 'asset-2', 'asset-3'])); - albumMock.getById.mockResolvedValue(_.cloneDeep(albumStub.oneAsset)); - albumMock.getAssetIds.mockResolvedValueOnce(new Set()); + mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set(['album-123'])); + mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1', 'asset-2', 'asset-3'])); + mocks.album.getById.mockResolvedValue(_.cloneDeep(albumStub.oneAsset)); + mocks.album.getAssetIds.mockResolvedValueOnce(new Set()); await expect( sut.addAssets(authStub.admin, 'album-123', { ids: ['asset-1', 'asset-2', 'asset-3'] }), @@ -535,37 +524,37 @@ describe(AlbumService.name, () => { { success: true, id: 'asset-3' }, ]); - expect(albumMock.update).toHaveBeenCalledWith('album-123', { + expect(mocks.album.update).toHaveBeenCalledWith('album-123', { id: 'album-123', updatedAt: expect.any(Date), albumThumbnailAssetId: 'asset-1', }); - expect(albumMock.addAssetIds).toHaveBeenCalledWith('album-123', ['asset-1', 'asset-2', 'asset-3']); + expect(mocks.album.addAssetIds).toHaveBeenCalledWith('album-123', ['asset-1', 'asset-2', 'asset-3']); }); it('should not set the thumbnail if the album has one already', async () => { - accessMock.album.checkOwnerAccess.mockResolvedValue(new Set(['album-123'])); - accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1'])); - albumMock.getById.mockResolvedValue(_.cloneDeep({ ...albumStub.empty, albumThumbnailAssetId: 'asset-id' })); - albumMock.getAssetIds.mockResolvedValueOnce(new Set()); + mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set(['album-123'])); + mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1'])); + mocks.album.getById.mockResolvedValue(_.cloneDeep({ ...albumStub.empty, albumThumbnailAssetId: 'asset-id' })); + mocks.album.getAssetIds.mockResolvedValueOnce(new Set()); await expect(sut.addAssets(authStub.admin, 'album-123', { ids: ['asset-1'] })).resolves.toEqual([ { success: true, id: 'asset-1' }, ]); - expect(albumMock.update).toHaveBeenCalledWith('album-123', { + expect(mocks.album.update).toHaveBeenCalledWith('album-123', { id: 'album-123', updatedAt: expect.any(Date), albumThumbnailAssetId: 'asset-id', }); - expect(albumMock.addAssetIds).toHaveBeenCalled(); + expect(mocks.album.addAssetIds).toHaveBeenCalled(); }); it('should allow a shared user to add assets', async () => { - accessMock.album.checkSharedAlbumAccess.mockResolvedValue(new Set(['album-123'])); - accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1', 'asset-2', 'asset-3'])); - albumMock.getById.mockResolvedValue(_.cloneDeep(albumStub.sharedWithUser)); - albumMock.getAssetIds.mockResolvedValueOnce(new Set()); + mocks.access.album.checkSharedAlbumAccess.mockResolvedValue(new Set(['album-123'])); + mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1', 'asset-2', 'asset-3'])); + mocks.album.getById.mockResolvedValue(_.cloneDeep(albumStub.sharedWithUser)); + mocks.album.getAssetIds.mockResolvedValueOnce(new Set()); await expect( sut.addAssets(authStub.user1, 'album-123', { ids: ['asset-1', 'asset-2', 'asset-3'] }), @@ -575,34 +564,34 @@ describe(AlbumService.name, () => { { success: true, id: 'asset-3' }, ]); - expect(albumMock.update).toHaveBeenCalledWith('album-123', { + expect(mocks.album.update).toHaveBeenCalledWith('album-123', { id: 'album-123', updatedAt: expect.any(Date), albumThumbnailAssetId: 'asset-1', }); - expect(albumMock.addAssetIds).toHaveBeenCalledWith('album-123', ['asset-1', 'asset-2', 'asset-3']); - expect(eventMock.emit).toHaveBeenCalledWith('album.update', { + expect(mocks.album.addAssetIds).toHaveBeenCalledWith('album-123', ['asset-1', 'asset-2', 'asset-3']); + expect(mocks.event.emit).toHaveBeenCalledWith('album.update', { id: 'album-123', recipientIds: ['admin_id'], }); }); it('should not allow a shared user with viewer access to add assets', async () => { - accessMock.album.checkSharedAlbumAccess.mockResolvedValue(new Set([])); - albumMock.getById.mockResolvedValue(_.cloneDeep(albumStub.sharedWithUser)); + mocks.access.album.checkSharedAlbumAccess.mockResolvedValue(new Set([])); + mocks.album.getById.mockResolvedValue(_.cloneDeep(albumStub.sharedWithUser)); await expect( sut.addAssets(authStub.user2, 'album-123', { ids: ['asset-1', 'asset-2', 'asset-3'] }), ).rejects.toBeInstanceOf(BadRequestException); - expect(albumMock.update).not.toHaveBeenCalled(); + expect(mocks.album.update).not.toHaveBeenCalled(); }); it('should allow a shared link user to add assets', async () => { - accessMock.album.checkSharedLinkAccess.mockResolvedValue(new Set(['album-123'])); - accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1', 'asset-2', 'asset-3'])); - albumMock.getById.mockResolvedValue(_.cloneDeep(albumStub.oneAsset)); - albumMock.getAssetIds.mockResolvedValueOnce(new Set()); + mocks.access.album.checkSharedLinkAccess.mockResolvedValue(new Set(['album-123'])); + mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1', 'asset-2', 'asset-3'])); + mocks.album.getById.mockResolvedValue(_.cloneDeep(albumStub.oneAsset)); + mocks.album.getAssetIds.mockResolvedValueOnce(new Set()); await expect( sut.addAssets(authStub.adminSharedLink, 'album-123', { ids: ['asset-1', 'asset-2', 'asset-3'] }), @@ -612,115 +601,115 @@ describe(AlbumService.name, () => { { success: true, id: 'asset-3' }, ]); - expect(albumMock.update).toHaveBeenCalledWith('album-123', { + expect(mocks.album.update).toHaveBeenCalledWith('album-123', { id: 'album-123', updatedAt: expect.any(Date), albumThumbnailAssetId: 'asset-1', }); - expect(albumMock.addAssetIds).toHaveBeenCalledWith('album-123', ['asset-1', 'asset-2', 'asset-3']); + expect(mocks.album.addAssetIds).toHaveBeenCalledWith('album-123', ['asset-1', 'asset-2', 'asset-3']); - expect(accessMock.album.checkSharedLinkAccess).toHaveBeenCalledWith( + expect(mocks.access.album.checkSharedLinkAccess).toHaveBeenCalledWith( authStub.adminSharedLink.sharedLink?.id, new Set(['album-123']), ); }); it('should allow adding assets shared via partner sharing', async () => { - accessMock.album.checkOwnerAccess.mockResolvedValue(new Set(['album-123'])); - accessMock.asset.checkPartnerAccess.mockResolvedValue(new Set(['asset-1'])); - albumMock.getById.mockResolvedValue(_.cloneDeep(albumStub.oneAsset)); - albumMock.getAssetIds.mockResolvedValueOnce(new Set()); + mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set(['album-123'])); + mocks.access.asset.checkPartnerAccess.mockResolvedValue(new Set(['asset-1'])); + mocks.album.getById.mockResolvedValue(_.cloneDeep(albumStub.oneAsset)); + mocks.album.getAssetIds.mockResolvedValueOnce(new Set()); await expect(sut.addAssets(authStub.admin, 'album-123', { ids: ['asset-1'] })).resolves.toEqual([ { success: true, id: 'asset-1' }, ]); - expect(albumMock.update).toHaveBeenCalledWith('album-123', { + expect(mocks.album.update).toHaveBeenCalledWith('album-123', { id: 'album-123', updatedAt: expect.any(Date), albumThumbnailAssetId: 'asset-1', }); - expect(accessMock.asset.checkPartnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['asset-1'])); + expect(mocks.access.asset.checkPartnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['asset-1'])); }); it('should skip duplicate assets', async () => { - accessMock.album.checkOwnerAccess.mockResolvedValue(new Set(['album-123'])); - accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-id'])); - albumMock.getById.mockResolvedValue(_.cloneDeep(albumStub.oneAsset)); - albumMock.getAssetIds.mockResolvedValueOnce(new Set(['asset-id'])); + mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set(['album-123'])); + mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-id'])); + mocks.album.getById.mockResolvedValue(_.cloneDeep(albumStub.oneAsset)); + mocks.album.getAssetIds.mockResolvedValueOnce(new Set(['asset-id'])); await expect(sut.addAssets(authStub.admin, 'album-123', { ids: ['asset-id'] })).resolves.toEqual([ { success: false, id: 'asset-id', error: BulkIdErrorReason.DUPLICATE }, ]); - expect(albumMock.update).not.toHaveBeenCalled(); + expect(mocks.album.update).not.toHaveBeenCalled(); }); it('should skip assets not shared with user', async () => { - accessMock.album.checkOwnerAccess.mockResolvedValue(new Set(['album-123'])); - albumMock.getById.mockResolvedValue(albumStub.oneAsset); - albumMock.getAssetIds.mockResolvedValueOnce(new Set()); + mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set(['album-123'])); + mocks.album.getById.mockResolvedValue(albumStub.oneAsset); + mocks.album.getAssetIds.mockResolvedValueOnce(new Set()); await expect(sut.addAssets(authStub.admin, 'album-123', { ids: ['asset-1'] })).resolves.toEqual([ { success: false, id: 'asset-1', error: BulkIdErrorReason.NO_PERMISSION }, ]); - expect(accessMock.asset.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['asset-1'])); - expect(accessMock.asset.checkPartnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['asset-1'])); + expect(mocks.access.asset.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['asset-1'])); + expect(mocks.access.asset.checkPartnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['asset-1'])); }); it('should not allow unauthorized access to the album', async () => { - albumMock.getById.mockResolvedValue(albumStub.oneAsset); + mocks.album.getById.mockResolvedValue(albumStub.oneAsset); await expect( sut.addAssets(authStub.admin, 'album-123', { ids: ['asset-1', 'asset-2', 'asset-3'] }), ).rejects.toBeInstanceOf(BadRequestException); - expect(accessMock.album.checkOwnerAccess).toHaveBeenCalled(); - expect(accessMock.album.checkSharedAlbumAccess).toHaveBeenCalled(); + expect(mocks.access.album.checkOwnerAccess).toHaveBeenCalled(); + expect(mocks.access.album.checkSharedAlbumAccess).toHaveBeenCalled(); }); it('should not allow unauthorized shared link access to the album', async () => { - albumMock.getById.mockResolvedValue(albumStub.oneAsset); + mocks.album.getById.mockResolvedValue(albumStub.oneAsset); await expect( sut.addAssets(authStub.adminSharedLink, 'album-123', { ids: ['asset-1', 'asset-2', 'asset-3'] }), ).rejects.toBeInstanceOf(BadRequestException); - expect(accessMock.album.checkSharedLinkAccess).toHaveBeenCalled(); + expect(mocks.access.album.checkSharedLinkAccess).toHaveBeenCalled(); }); }); describe('removeAssets', () => { it('should allow the owner to remove assets', async () => { - accessMock.album.checkOwnerAccess.mockResolvedValue(new Set(['album-123'])); - accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-id'])); - albumMock.getById.mockResolvedValue(_.cloneDeep(albumStub.oneAsset)); - albumMock.getAssetIds.mockResolvedValue(new Set(['asset-id'])); + mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set(['album-123'])); + mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-id'])); + mocks.album.getById.mockResolvedValue(_.cloneDeep(albumStub.oneAsset)); + mocks.album.getAssetIds.mockResolvedValue(new Set(['asset-id'])); await expect(sut.removeAssets(authStub.admin, 'album-123', { ids: ['asset-id'] })).resolves.toEqual([ { success: true, id: 'asset-id' }, ]); - expect(albumMock.removeAssetIds).toHaveBeenCalledWith('album-123', ['asset-id']); + expect(mocks.album.removeAssetIds).toHaveBeenCalledWith('album-123', ['asset-id']); }); it('should skip assets not in the album', async () => { - accessMock.album.checkOwnerAccess.mockResolvedValue(new Set(['album-123'])); - albumMock.getById.mockResolvedValue(_.cloneDeep(albumStub.empty)); - albumMock.getAssetIds.mockResolvedValue(new Set()); + mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set(['album-123'])); + mocks.album.getById.mockResolvedValue(_.cloneDeep(albumStub.empty)); + mocks.album.getAssetIds.mockResolvedValue(new Set()); await expect(sut.removeAssets(authStub.admin, 'album-123', { ids: ['asset-id'] })).resolves.toEqual([ { success: false, id: 'asset-id', error: BulkIdErrorReason.NOT_FOUND }, ]); - expect(albumMock.update).not.toHaveBeenCalled(); + expect(mocks.album.update).not.toHaveBeenCalled(); }); it('should allow owner to remove all assets from the album', async () => { - accessMock.album.checkOwnerAccess.mockResolvedValue(new Set(['album-123'])); - albumMock.getById.mockResolvedValue(_.cloneDeep(albumStub.oneAsset)); - albumMock.getAssetIds.mockResolvedValue(new Set(['asset-id'])); + mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set(['album-123'])); + mocks.album.getById.mockResolvedValue(_.cloneDeep(albumStub.oneAsset)); + mocks.album.getAssetIds.mockResolvedValue(new Set(['asset-id'])); await expect(sut.removeAssets(authStub.admin, 'album-123', { ids: ['asset-id'] })).resolves.toEqual([ { success: true, id: 'asset-id' }, @@ -728,16 +717,16 @@ describe(AlbumService.name, () => { }); it('should reset the thumbnail if it is removed', async () => { - accessMock.album.checkOwnerAccess.mockResolvedValue(new Set(['album-123'])); - accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-id'])); - albumMock.getById.mockResolvedValue(_.cloneDeep(albumStub.twoAssets)); - albumMock.getAssetIds.mockResolvedValue(new Set(['asset-id'])); + mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set(['album-123'])); + mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-id'])); + mocks.album.getById.mockResolvedValue(_.cloneDeep(albumStub.twoAssets)); + mocks.album.getAssetIds.mockResolvedValue(new Set(['asset-id'])); await expect(sut.removeAssets(authStub.admin, 'album-123', { ids: ['asset-id'] })).resolves.toEqual([ { success: true, id: 'asset-id' }, ]); - expect(albumMock.updateThumbnails).toHaveBeenCalled(); + expect(mocks.album.updateThumbnails).toHaveBeenCalled(); }); }); diff --git a/server/src/services/api-key.service.spec.ts b/server/src/services/api-key.service.spec.ts index 928978b698..905bbede2a 100644 --- a/server/src/services/api-key.service.spec.ts +++ b/server/src/services/api-key.service.spec.ts @@ -1,50 +1,45 @@ import { BadRequestException } from '@nestjs/common'; import { Permission } from 'src/enum'; -import { ICryptoRepository } from 'src/interfaces/crypto.interface'; import { APIKeyService } from 'src/services/api-key.service'; -import { IApiKeyRepository } from 'src/types'; import { keyStub } from 'test/fixtures/api-key.stub'; import { authStub } from 'test/fixtures/auth.stub'; -import { newTestService } from 'test/utils'; -import { Mocked } from 'vitest'; +import { newTestService, ServiceMocks } from 'test/utils'; describe(APIKeyService.name, () => { let sut: APIKeyService; - - let cryptoMock: Mocked; - let keyMock: Mocked; + let mocks: ServiceMocks; beforeEach(() => { - ({ sut, cryptoMock, keyMock } = newTestService(APIKeyService)); + ({ sut, mocks } = newTestService(APIKeyService)); }); describe('create', () => { it('should create a new key', async () => { - keyMock.create.mockResolvedValue(keyStub.admin); + mocks.apiKey.create.mockResolvedValue(keyStub.admin); await sut.create(authStub.admin, { name: 'Test Key', permissions: [Permission.ALL] }); - expect(keyMock.create).toHaveBeenCalledWith({ + expect(mocks.apiKey.create).toHaveBeenCalledWith({ key: 'cmFuZG9tLWJ5dGVz (hashed)', name: 'Test Key', permissions: [Permission.ALL], userId: authStub.admin.user.id, }); - expect(cryptoMock.newPassword).toHaveBeenCalled(); - expect(cryptoMock.hashSha256).toHaveBeenCalled(); + expect(mocks.crypto.newPassword).toHaveBeenCalled(); + expect(mocks.crypto.hashSha256).toHaveBeenCalled(); }); it('should not require a name', async () => { - keyMock.create.mockResolvedValue(keyStub.admin); + mocks.apiKey.create.mockResolvedValue(keyStub.admin); await sut.create(authStub.admin, { permissions: [Permission.ALL] }); - expect(keyMock.create).toHaveBeenCalledWith({ + expect(mocks.apiKey.create).toHaveBeenCalledWith({ key: 'cmFuZG9tLWJ5dGVz (hashed)', name: 'API Key', permissions: [Permission.ALL], userId: authStub.admin.user.id, }); - expect(cryptoMock.newPassword).toHaveBeenCalled(); - expect(cryptoMock.hashSha256).toHaveBeenCalled(); + expect(mocks.crypto.newPassword).toHaveBeenCalled(); + expect(mocks.crypto.hashSha256).toHaveBeenCalled(); }); it('should throw an error if the api key does not have sufficient permissions', async () => { @@ -60,16 +55,16 @@ describe(APIKeyService.name, () => { BadRequestException, ); - expect(keyMock.update).not.toHaveBeenCalledWith('random-guid'); + expect(mocks.apiKey.update).not.toHaveBeenCalledWith('random-guid'); }); it('should update a key', async () => { - keyMock.getById.mockResolvedValue(keyStub.admin); - keyMock.update.mockResolvedValue(keyStub.admin); + mocks.apiKey.getById.mockResolvedValue(keyStub.admin); + mocks.apiKey.update.mockResolvedValue(keyStub.admin); await sut.update(authStub.admin, 'random-guid', { name: 'New Name' }); - expect(keyMock.update).toHaveBeenCalledWith(authStub.admin.user.id, 'random-guid', { name: 'New Name' }); + expect(mocks.apiKey.update).toHaveBeenCalledWith(authStub.admin.user.id, 'random-guid', { name: 'New Name' }); }); }); @@ -77,15 +72,15 @@ describe(APIKeyService.name, () => { it('should throw an error if the key is not found', async () => { await expect(sut.delete(authStub.admin, 'random-guid')).rejects.toBeInstanceOf(BadRequestException); - expect(keyMock.delete).not.toHaveBeenCalledWith('random-guid'); + expect(mocks.apiKey.delete).not.toHaveBeenCalledWith('random-guid'); }); it('should delete a key', async () => { - keyMock.getById.mockResolvedValue(keyStub.admin); + mocks.apiKey.getById.mockResolvedValue(keyStub.admin); await sut.delete(authStub.admin, 'random-guid'); - expect(keyMock.delete).toHaveBeenCalledWith(authStub.admin.user.id, 'random-guid'); + expect(mocks.apiKey.delete).toHaveBeenCalledWith(authStub.admin.user.id, 'random-guid'); }); }); @@ -93,25 +88,25 @@ describe(APIKeyService.name, () => { it('should throw an error if the key is not found', async () => { await expect(sut.getById(authStub.admin, 'random-guid')).rejects.toBeInstanceOf(BadRequestException); - expect(keyMock.getById).toHaveBeenCalledWith(authStub.admin.user.id, 'random-guid'); + expect(mocks.apiKey.getById).toHaveBeenCalledWith(authStub.admin.user.id, 'random-guid'); }); it('should get a key by id', async () => { - keyMock.getById.mockResolvedValue(keyStub.admin); + mocks.apiKey.getById.mockResolvedValue(keyStub.admin); await sut.getById(authStub.admin, 'random-guid'); - expect(keyMock.getById).toHaveBeenCalledWith(authStub.admin.user.id, 'random-guid'); + expect(mocks.apiKey.getById).toHaveBeenCalledWith(authStub.admin.user.id, 'random-guid'); }); }); describe('getAll', () => { it('should return all the keys for a user', async () => { - keyMock.getByUserId.mockResolvedValue([keyStub.admin]); + mocks.apiKey.getByUserId.mockResolvedValue([keyStub.admin]); await expect(sut.getAll(authStub.admin)).resolves.toHaveLength(1); - expect(keyMock.getByUserId).toHaveBeenCalledWith(authStub.admin.user.id); + expect(mocks.apiKey.getByUserId).toHaveBeenCalledWith(authStub.admin.user.id); }); }); }); diff --git a/server/src/services/asset-media.service.spec.ts b/server/src/services/asset-media.service.spec.ts index 9ebaa80d21..e52f086df0 100644 --- a/server/src/services/asset-media.service.spec.ts +++ b/server/src/services/asset-media.service.spec.ts @@ -10,10 +10,7 @@ import { AssetMediaCreateDto, AssetMediaReplaceDto, AssetMediaSize, UploadFieldN import { AssetFileEntity } from 'src/entities/asset-files.entity'; import { ASSET_CHECKSUM_CONSTRAINT, AssetEntity } from 'src/entities/asset.entity'; import { AssetFileType, AssetStatus, AssetType, CacheControl } from 'src/enum'; -import { IAssetRepository } from 'src/interfaces/asset.interface'; -import { IJobRepository, JobName } from 'src/interfaces/job.interface'; -import { IStorageRepository } from 'src/interfaces/storage.interface'; -import { IUserRepository } from 'src/interfaces/user.interface'; +import { JobName } from 'src/interfaces/job.interface'; import { AuthRequest } from 'src/middleware/auth.guard'; import { AssetMediaService } from 'src/services/asset-media.service'; import { ImmichFileResponse } from 'src/utils/file'; @@ -21,9 +18,7 @@ import { assetStub } from 'test/fixtures/asset.stub'; import { authStub } from 'test/fixtures/auth.stub'; import { fileStub } from 'test/fixtures/file.stub'; import { userStub } from 'test/fixtures/user.stub'; -import { IAccessRepositoryMock } from 'test/repositories/access.repository.mock'; -import { newTestService } from 'test/utils'; -import { Mocked } from 'vitest'; +import { newTestService, ServiceMocks } from 'test/utils'; const file1 = Buffer.from('d2947b871a706081be194569951b7db246907957', 'hex'); @@ -203,15 +198,10 @@ const copiedAsset = Object.freeze({ describe(AssetMediaService.name, () => { let sut: AssetMediaService; - - let accessMock: IAccessRepositoryMock; - let assetMock: Mocked; - let jobMock: Mocked; - let storageMock: Mocked; - let userMock: Mocked; + let mocks: ServiceMocks; beforeEach(() => { - ({ sut, accessMock, assetMock, jobMock, storageMock, userMock } = newTestService(AssetMediaService)); + ({ sut, mocks } = newTestService(AssetMediaService)); }); describe('getUploadAssetIdByChecksum', () => { @@ -221,25 +211,25 @@ describe(AssetMediaService.name, () => { it('should handle a non-existent asset', async () => { await expect(sut.getUploadAssetIdByChecksum(authStub.admin, file1.toString('hex'))).resolves.toBeUndefined(); - expect(assetMock.getUploadAssetIdByChecksum).toHaveBeenCalledWith(authStub.admin.user.id, file1); + expect(mocks.asset.getUploadAssetIdByChecksum).toHaveBeenCalledWith(authStub.admin.user.id, file1); }); it('should find an existing asset', async () => { - assetMock.getUploadAssetIdByChecksum.mockResolvedValue('asset-id'); + mocks.asset.getUploadAssetIdByChecksum.mockResolvedValue('asset-id'); await expect(sut.getUploadAssetIdByChecksum(authStub.admin, file1.toString('hex'))).resolves.toEqual({ id: 'asset-id', status: AssetMediaStatus.DUPLICATE, }); - expect(assetMock.getUploadAssetIdByChecksum).toHaveBeenCalledWith(authStub.admin.user.id, file1); + expect(mocks.asset.getUploadAssetIdByChecksum).toHaveBeenCalledWith(authStub.admin.user.id, file1); }); it('should find an existing asset by base64', async () => { - assetMock.getUploadAssetIdByChecksum.mockResolvedValue('asset-id'); + mocks.asset.getUploadAssetIdByChecksum.mockResolvedValue('asset-id'); await expect(sut.getUploadAssetIdByChecksum(authStub.admin, file1.toString('base64'))).resolves.toEqual({ id: 'asset-id', status: AssetMediaStatus.DUPLICATE, }); - expect(assetMock.getUploadAssetIdByChecksum).toHaveBeenCalledWith(authStub.admin.user.id, file1); + expect(mocks.asset.getUploadAssetIdByChecksum).toHaveBeenCalledWith(authStub.admin.user.id, file1); }); }); @@ -308,14 +298,14 @@ describe(AssetMediaService.name, () => { expect(sut.getUploadFolder(uploadFile.filename(UploadFieldName.PROFILE_DATA, 'image.jpg'))).toEqual( 'upload/profile/admin_id', ); - expect(storageMock.mkdirSync).toHaveBeenCalledWith('upload/profile/admin_id'); + expect(mocks.storage.mkdirSync).toHaveBeenCalledWith('upload/profile/admin_id'); }); it('should return upload for everything else', () => { expect(sut.getUploadFolder(uploadFile.filename(UploadFieldName.ASSET_DATA, 'image.jpg'))).toEqual( 'upload/upload/admin_id/ra/nd', ); - expect(storageMock.mkdirSync).toHaveBeenCalledWith('upload/upload/admin_id/ra/nd'); + expect(mocks.storage.mkdirSync).toHaveBeenCalledWith('upload/upload/admin_id/ra/nd'); }); }); @@ -330,7 +320,7 @@ describe(AssetMediaService.name, () => { size: 42, }; - assetMock.create.mockResolvedValue(assetEntity); + mocks.asset.create.mockResolvedValue(assetEntity); await expect( sut.uploadAsset( @@ -340,9 +330,9 @@ describe(AssetMediaService.name, () => { ), ).rejects.toBeInstanceOf(BadRequestException); - expect(assetMock.create).not.toHaveBeenCalled(); - expect(userMock.updateUsage).not.toHaveBeenCalledWith(authStub.user1.user.id, file.size); - expect(storageMock.utimes).not.toHaveBeenCalledWith( + expect(mocks.asset.create).not.toHaveBeenCalled(); + expect(mocks.user.updateUsage).not.toHaveBeenCalledWith(authStub.user1.user.id, file.size); + expect(mocks.storage.utimes).not.toHaveBeenCalledWith( file.originalPath, expect.any(Date), new Date(createDto.fileModifiedAt), @@ -359,16 +349,16 @@ describe(AssetMediaService.name, () => { size: 42, }; - assetMock.create.mockResolvedValue(assetEntity); + mocks.asset.create.mockResolvedValue(assetEntity); await expect(sut.uploadAsset(authStub.user1, createDto, file)).resolves.toEqual({ id: 'id_1', status: AssetMediaStatus.CREATED, }); - expect(assetMock.create).toHaveBeenCalled(); - expect(userMock.updateUsage).toHaveBeenCalledWith(authStub.user1.user.id, file.size); - expect(storageMock.utimes).toHaveBeenCalledWith( + expect(mocks.asset.create).toHaveBeenCalled(); + expect(mocks.user.updateUsage).toHaveBeenCalledWith(authStub.user1.user.id, file.size); + expect(mocks.storage.utimes).toHaveBeenCalledWith( file.originalPath, expect.any(Date), new Date(createDto.fileModifiedAt), @@ -387,19 +377,19 @@ describe(AssetMediaService.name, () => { const error = new Error('unique key violation'); (error as any).constraint_name = ASSET_CHECKSUM_CONSTRAINT; - assetMock.create.mockRejectedValue(error); - assetMock.getUploadAssetIdByChecksum.mockResolvedValue(assetEntity.id); + mocks.asset.create.mockRejectedValue(error); + mocks.asset.getUploadAssetIdByChecksum.mockResolvedValue(assetEntity.id); await expect(sut.uploadAsset(authStub.user1, createDto, file)).resolves.toEqual({ id: 'id_1', status: AssetMediaStatus.DUPLICATE, }); - expect(jobMock.queue).toHaveBeenCalledWith({ + expect(mocks.job.queue).toHaveBeenCalledWith({ name: JobName.DELETE_FILES, data: { files: ['fake_path/asset_1.jpeg', undefined] }, }); - expect(userMock.updateUsage).not.toHaveBeenCalled(); + expect(mocks.user.updateUsage).not.toHaveBeenCalled(); }); it('should throw an error if the duplicate could not be found by checksum', async () => { @@ -414,22 +404,22 @@ describe(AssetMediaService.name, () => { const error = new Error('unique key violation'); (error as any).constraint_name = ASSET_CHECKSUM_CONSTRAINT; - assetMock.create.mockRejectedValue(error); + mocks.asset.create.mockRejectedValue(error); await expect(sut.uploadAsset(authStub.user1, createDto, file)).rejects.toBeInstanceOf( InternalServerErrorException, ); - expect(jobMock.queue).toHaveBeenCalledWith({ + expect(mocks.job.queue).toHaveBeenCalledWith({ name: JobName.DELETE_FILES, data: { files: ['fake_path/asset_1.jpeg', undefined] }, }); - expect(userMock.updateUsage).not.toHaveBeenCalled(); + expect(mocks.user.updateUsage).not.toHaveBeenCalled(); }); it('should handle a live photo', async () => { - assetMock.getById.mockResolvedValueOnce(assetStub.livePhotoMotionAsset); - assetMock.create.mockResolvedValueOnce(assetStub.livePhotoStillAsset); + mocks.asset.getById.mockResolvedValueOnce(assetStub.livePhotoMotionAsset); + mocks.asset.create.mockResolvedValueOnce(assetStub.livePhotoStillAsset); await expect( sut.uploadAsset( @@ -442,13 +432,13 @@ describe(AssetMediaService.name, () => { id: 'live-photo-still-asset', }); - expect(assetMock.getById).toHaveBeenCalledWith('live-photo-motion-asset'); - expect(assetMock.update).not.toHaveBeenCalled(); + expect(mocks.asset.getById).toHaveBeenCalledWith('live-photo-motion-asset'); + expect(mocks.asset.update).not.toHaveBeenCalled(); }); it('should hide the linked motion asset', async () => { - assetMock.getById.mockResolvedValueOnce({ ...assetStub.livePhotoMotionAsset, isVisible: true }); - assetMock.create.mockResolvedValueOnce(assetStub.livePhotoStillAsset); + mocks.asset.getById.mockResolvedValueOnce({ ...assetStub.livePhotoMotionAsset, isVisible: true }); + mocks.asset.create.mockResolvedValueOnce(assetStub.livePhotoStillAsset); await expect( sut.uploadAsset( @@ -461,25 +451,25 @@ describe(AssetMediaService.name, () => { id: 'live-photo-still-asset', }); - expect(assetMock.getById).toHaveBeenCalledWith('live-photo-motion-asset'); - expect(assetMock.update).toHaveBeenCalledWith({ id: 'live-photo-motion-asset', isVisible: false }); + expect(mocks.asset.getById).toHaveBeenCalledWith('live-photo-motion-asset'); + expect(mocks.asset.update).toHaveBeenCalledWith({ id: 'live-photo-motion-asset', isVisible: false }); }); it('should handle a sidecar file', async () => { - assetMock.getById.mockResolvedValueOnce(assetStub.image); - assetMock.create.mockResolvedValueOnce(assetStub.image); + mocks.asset.getById.mockResolvedValueOnce(assetStub.image); + mocks.asset.create.mockResolvedValueOnce(assetStub.image); await expect(sut.uploadAsset(authStub.user1, createDto, fileStub.photo, fileStub.photoSidecar)).resolves.toEqual({ status: AssetMediaStatus.CREATED, id: assetStub.image.id, }); - expect(storageMock.utimes).toHaveBeenCalledWith( + expect(mocks.storage.utimes).toHaveBeenCalledWith( fileStub.photoSidecar.originalPath, expect.any(Date), new Date(createDto.fileModifiedAt), ); - expect(assetMock.update).not.toHaveBeenCalled(); + expect(mocks.asset.update).not.toHaveBeenCalled(); }); }); @@ -487,22 +477,22 @@ describe(AssetMediaService.name, () => { it('should require the asset.download permission', async () => { await expect(sut.downloadOriginal(authStub.admin, 'asset-1')).rejects.toBeInstanceOf(BadRequestException); - expect(accessMock.asset.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['asset-1'])); - expect(accessMock.asset.checkAlbumAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['asset-1'])); - expect(accessMock.asset.checkPartnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['asset-1'])); + expect(mocks.access.asset.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['asset-1'])); + expect(mocks.access.asset.checkAlbumAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['asset-1'])); + expect(mocks.access.asset.checkPartnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['asset-1'])); }); it('should throw an error if the asset is not found', async () => { - accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1'])); + mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1'])); await expect(sut.downloadOriginal(authStub.admin, 'asset-1')).rejects.toBeInstanceOf(NotFoundException); - expect(assetMock.getById).toHaveBeenCalledWith('asset-1', { files: true }); + expect(mocks.asset.getById).toHaveBeenCalledWith('asset-1', { files: true }); }); it('should download a file', async () => { - accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1'])); - assetMock.getById.mockResolvedValue(assetStub.image); + mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1'])); + mocks.asset.getById.mockResolvedValue(assetStub.image); await expect(sut.downloadOriginal(authStub.admin, 'asset-1')).resolves.toEqual( new ImmichFileResponse({ @@ -518,13 +508,13 @@ describe(AssetMediaService.name, () => { it('should require asset.view permissions', async () => { await expect(sut.viewThumbnail(authStub.admin, 'id', {})).rejects.toBeInstanceOf(BadRequestException); - expect(accessMock.asset.checkOwnerAccess).toHaveBeenCalledWith(userStub.admin.id, new Set(['id'])); - expect(accessMock.asset.checkAlbumAccess).toHaveBeenCalledWith(userStub.admin.id, new Set(['id'])); - expect(accessMock.asset.checkPartnerAccess).toHaveBeenCalledWith(userStub.admin.id, new Set(['id'])); + expect(mocks.access.asset.checkOwnerAccess).toHaveBeenCalledWith(userStub.admin.id, new Set(['id'])); + expect(mocks.access.asset.checkAlbumAccess).toHaveBeenCalledWith(userStub.admin.id, new Set(['id'])); + expect(mocks.access.asset.checkPartnerAccess).toHaveBeenCalledWith(userStub.admin.id, new Set(['id'])); }); it('should throw an error if the asset does not exist', async () => { - accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.image.id])); + mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.image.id])); await expect( sut.viewThumbnail(authStub.admin, assetStub.image.id, { size: AssetMediaSize.PREVIEW }), @@ -532,8 +522,8 @@ describe(AssetMediaService.name, () => { }); it('should throw an error if the requested thumbnail file does not exist', async () => { - accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.image.id])); - assetMock.getById.mockResolvedValue({ ...assetStub.image, files: [] }); + mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.image.id])); + mocks.asset.getById.mockResolvedValue({ ...assetStub.image, files: [] }); await expect( sut.viewThumbnail(authStub.admin, assetStub.image.id, { size: AssetMediaSize.THUMBNAIL }), @@ -541,8 +531,8 @@ describe(AssetMediaService.name, () => { }); it('should throw an error if the requested preview file does not exist', async () => { - accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.image.id])); - assetMock.getById.mockResolvedValue({ + mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.image.id])); + mocks.asset.getById.mockResolvedValue({ ...assetStub.image, files: [ { @@ -561,8 +551,8 @@ describe(AssetMediaService.name, () => { }); it('should fall back to preview if the requested thumbnail file does not exist', async () => { - accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.image.id])); - assetMock.getById.mockResolvedValue({ + mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.image.id])); + mocks.asset.getById.mockResolvedValue({ ...assetStub.image, files: [ { @@ -589,8 +579,8 @@ describe(AssetMediaService.name, () => { }); it('should get preview file', async () => { - accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.image.id])); - assetMock.getById.mockResolvedValue({ ...assetStub.image }); + mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.image.id])); + mocks.asset.getById.mockResolvedValue({ ...assetStub.image }); await expect( sut.viewThumbnail(authStub.admin, assetStub.image.id, { size: AssetMediaSize.PREVIEW }), ).resolves.toEqual( @@ -604,8 +594,8 @@ describe(AssetMediaService.name, () => { }); it('should get thumbnail file', async () => { - accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.image.id])); - assetMock.getById.mockResolvedValue({ ...assetStub.image }); + mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.image.id])); + mocks.asset.getById.mockResolvedValue({ ...assetStub.image }); await expect( sut.viewThumbnail(authStub.admin, assetStub.image.id, { size: AssetMediaSize.THUMBNAIL }), ).resolves.toEqual( @@ -623,27 +613,27 @@ describe(AssetMediaService.name, () => { it('should require asset.view permissions', async () => { await expect(sut.playbackVideo(authStub.admin, 'id')).rejects.toBeInstanceOf(BadRequestException); - expect(accessMock.asset.checkOwnerAccess).toHaveBeenCalledWith(userStub.admin.id, new Set(['id'])); - expect(accessMock.asset.checkAlbumAccess).toHaveBeenCalledWith(userStub.admin.id, new Set(['id'])); - expect(accessMock.asset.checkPartnerAccess).toHaveBeenCalledWith(userStub.admin.id, new Set(['id'])); + expect(mocks.access.asset.checkOwnerAccess).toHaveBeenCalledWith(userStub.admin.id, new Set(['id'])); + expect(mocks.access.asset.checkAlbumAccess).toHaveBeenCalledWith(userStub.admin.id, new Set(['id'])); + expect(mocks.access.asset.checkPartnerAccess).toHaveBeenCalledWith(userStub.admin.id, new Set(['id'])); }); it('should throw an error if the asset does not exist', async () => { - accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.image.id])); + mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.image.id])); await expect(sut.playbackVideo(authStub.admin, assetStub.image.id)).rejects.toBeInstanceOf(NotFoundException); }); it('should throw an error if the asset is not a video', async () => { - accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.image.id])); - assetMock.getById.mockResolvedValue(assetStub.image); + mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.image.id])); + mocks.asset.getById.mockResolvedValue(assetStub.image); await expect(sut.playbackVideo(authStub.admin, assetStub.image.id)).rejects.toBeInstanceOf(BadRequestException); }); it('should return the encoded video path if available', async () => { - accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.hasEncodedVideo.id])); - assetMock.getById.mockResolvedValue(assetStub.hasEncodedVideo); + mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.hasEncodedVideo.id])); + mocks.asset.getById.mockResolvedValue(assetStub.hasEncodedVideo); await expect(sut.playbackVideo(authStub.admin, assetStub.hasEncodedVideo.id)).resolves.toEqual( new ImmichFileResponse({ @@ -655,8 +645,8 @@ describe(AssetMediaService.name, () => { }); it('should fall back to the original path', async () => { - accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.video.id])); - assetMock.getById.mockResolvedValue(assetStub.video); + mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.video.id])); + mocks.asset.getById.mockResolvedValue(assetStub.video); await expect(sut.playbackVideo(authStub.admin, assetStub.video.id)).resolves.toEqual( new ImmichFileResponse({ @@ -670,12 +660,12 @@ describe(AssetMediaService.name, () => { describe('checkExistingAssets', () => { it('should get existing asset ids', async () => { - assetMock.getByDeviceIds.mockResolvedValue(['42']); + mocks.asset.getByDeviceIds.mockResolvedValue(['42']); await expect( sut.checkExistingAssets(authStub.admin, { deviceId: '420', deviceAssetIds: ['69'] }), ).resolves.toEqual({ existingIds: ['42'] }); - expect(assetMock.getByDeviceIds).toHaveBeenCalledWith(userStub.admin.id, '420', ['69']); + expect(mocks.asset.getByDeviceIds).toHaveBeenCalledWith(userStub.admin.id, '420', ['69']); }); }); @@ -685,26 +675,26 @@ describe(AssetMediaService.name, () => { 'Not found or no asset.update access', ); - expect(assetMock.create).not.toHaveBeenCalled(); + expect(mocks.asset.create).not.toHaveBeenCalled(); }); it('should update a photo with no sidecar to photo with no sidecar', async () => { const updatedFile = fileStub.photo; const updatedAsset = { ...existingAsset, ...updatedFile }; - assetMock.getById.mockResolvedValueOnce(existingAsset); - assetMock.getById.mockResolvedValueOnce(updatedAsset); - accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set([existingAsset.id])); + mocks.asset.getById.mockResolvedValueOnce(existingAsset); + mocks.asset.getById.mockResolvedValueOnce(updatedAsset); + mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([existingAsset.id])); // this is the original file size - storageMock.stat.mockResolvedValue({ size: 0 } as Stats); + mocks.storage.stat.mockResolvedValue({ size: 0 } as Stats); // this is for the clone call - assetMock.create.mockResolvedValue(copiedAsset); + mocks.asset.create.mockResolvedValue(copiedAsset); await expect(sut.replaceAsset(authStub.user1, existingAsset.id, replaceDto, updatedFile)).resolves.toEqual({ status: AssetMediaStatus.REPLACED, id: 'copied-asset', }); - expect(assetMock.update).toHaveBeenCalledWith( + expect(mocks.asset.update).toHaveBeenCalledWith( expect.objectContaining({ id: existingAsset.id, sidecarPath: null, @@ -712,7 +702,7 @@ describe(AssetMediaService.name, () => { originalPath: 'fake_path/photo1.jpeg', }), ); - expect(assetMock.create).toHaveBeenCalledWith( + expect(mocks.asset.create).toHaveBeenCalledWith( expect.objectContaining({ sidecarPath: null, originalFileName: 'existing-filename.jpeg', @@ -720,12 +710,12 @@ describe(AssetMediaService.name, () => { }), ); - expect(assetMock.updateAll).toHaveBeenCalledWith([copiedAsset.id], { + expect(mocks.asset.updateAll).toHaveBeenCalledWith([copiedAsset.id], { deletedAt: expect.any(Date), status: AssetStatus.TRASHED, }); - expect(userMock.updateUsage).toHaveBeenCalledWith(authStub.user1.user.id, updatedFile.size); - expect(storageMock.utimes).toHaveBeenCalledWith( + expect(mocks.user.updateUsage).toHaveBeenCalledWith(authStub.user1.user.id, updatedFile.size); + expect(mocks.storage.utimes).toHaveBeenCalledWith( updatedFile.originalPath, expect.any(Date), new Date(replaceDto.fileModifiedAt), @@ -736,13 +726,13 @@ describe(AssetMediaService.name, () => { const updatedFile = fileStub.photo; const sidecarFile = fileStub.photoSidecar; const updatedAsset = { ...sidecarAsset, ...updatedFile }; - assetMock.getById.mockResolvedValueOnce(existingAsset); - assetMock.getById.mockResolvedValueOnce(updatedAsset); - accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set([sidecarAsset.id])); + mocks.asset.getById.mockResolvedValueOnce(existingAsset); + mocks.asset.getById.mockResolvedValueOnce(updatedAsset); + mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([sidecarAsset.id])); // this is the original file size - storageMock.stat.mockResolvedValue({ size: 0 } as Stats); + mocks.storage.stat.mockResolvedValue({ size: 0 } as Stats); // this is for the clone call - assetMock.create.mockResolvedValue(copiedAsset); + mocks.asset.create.mockResolvedValue(copiedAsset); await expect( sut.replaceAsset(authStub.user1, sidecarAsset.id, replaceDto, updatedFile, sidecarFile), @@ -751,12 +741,12 @@ describe(AssetMediaService.name, () => { id: 'copied-asset', }); - expect(assetMock.updateAll).toHaveBeenCalledWith([copiedAsset.id], { + expect(mocks.asset.updateAll).toHaveBeenCalledWith([copiedAsset.id], { deletedAt: expect.any(Date), status: AssetStatus.TRASHED, }); - expect(userMock.updateUsage).toHaveBeenCalledWith(authStub.user1.user.id, updatedFile.size); - expect(storageMock.utimes).toHaveBeenCalledWith( + expect(mocks.user.updateUsage).toHaveBeenCalledWith(authStub.user1.user.id, updatedFile.size); + expect(mocks.storage.utimes).toHaveBeenCalledWith( updatedFile.originalPath, expect.any(Date), new Date(replaceDto.fileModifiedAt), @@ -767,25 +757,25 @@ describe(AssetMediaService.name, () => { const updatedFile = fileStub.photo; const updatedAsset = { ...sidecarAsset, ...updatedFile }; - assetMock.getById.mockResolvedValueOnce(sidecarAsset); - assetMock.getById.mockResolvedValueOnce(updatedAsset); - accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set([sidecarAsset.id])); + mocks.asset.getById.mockResolvedValueOnce(sidecarAsset); + mocks.asset.getById.mockResolvedValueOnce(updatedAsset); + mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([sidecarAsset.id])); // this is the original file size - storageMock.stat.mockResolvedValue({ size: 0 } as Stats); + mocks.storage.stat.mockResolvedValue({ size: 0 } as Stats); // this is for the copy call - assetMock.create.mockResolvedValue(copiedAsset); + mocks.asset.create.mockResolvedValue(copiedAsset); await expect(sut.replaceAsset(authStub.user1, existingAsset.id, replaceDto, updatedFile)).resolves.toEqual({ status: AssetMediaStatus.REPLACED, id: 'copied-asset', }); - expect(assetMock.updateAll).toHaveBeenCalledWith([copiedAsset.id], { + expect(mocks.asset.updateAll).toHaveBeenCalledWith([copiedAsset.id], { deletedAt: expect.any(Date), status: AssetStatus.TRASHED, }); - expect(userMock.updateUsage).toHaveBeenCalledWith(authStub.user1.user.id, updatedFile.size); - expect(storageMock.utimes).toHaveBeenCalledWith( + expect(mocks.user.updateUsage).toHaveBeenCalledWith(authStub.user1.user.id, updatedFile.size); + expect(mocks.storage.utimes).toHaveBeenCalledWith( updatedFile.originalPath, expect.any(Date), new Date(replaceDto.fileModifiedAt), @@ -797,27 +787,27 @@ describe(AssetMediaService.name, () => { const error = new Error('unique key violation'); (error as any).constraint_name = ASSET_CHECKSUM_CONSTRAINT; - assetMock.update.mockRejectedValue(error); - assetMock.getById.mockResolvedValueOnce(sidecarAsset); - assetMock.getUploadAssetIdByChecksum.mockResolvedValue(sidecarAsset.id); - accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set([sidecarAsset.id])); + mocks.asset.update.mockRejectedValue(error); + mocks.asset.getById.mockResolvedValueOnce(sidecarAsset); + mocks.asset.getUploadAssetIdByChecksum.mockResolvedValue(sidecarAsset.id); + mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([sidecarAsset.id])); // this is the original file size - storageMock.stat.mockResolvedValue({ size: 0 } as Stats); + mocks.storage.stat.mockResolvedValue({ size: 0 } as Stats); // this is for the clone call - assetMock.create.mockResolvedValue(copiedAsset); + mocks.asset.create.mockResolvedValue(copiedAsset); await expect(sut.replaceAsset(authStub.user1, sidecarAsset.id, replaceDto, updatedFile)).resolves.toEqual({ status: AssetMediaStatus.DUPLICATE, id: sidecarAsset.id, }); - expect(assetMock.create).not.toHaveBeenCalled(); - expect(assetMock.updateAll).not.toHaveBeenCalled(); - expect(jobMock.queue).toHaveBeenCalledWith({ + expect(mocks.asset.create).not.toHaveBeenCalled(); + expect(mocks.asset.updateAll).not.toHaveBeenCalled(); + expect(mocks.job.queue).toHaveBeenCalledWith({ name: JobName.DELETE_FILES, data: { files: [updatedFile.originalPath, undefined] }, }); - expect(userMock.updateUsage).not.toHaveBeenCalled(); + expect(mocks.user.updateUsage).not.toHaveBeenCalled(); }); }); @@ -826,7 +816,7 @@ describe(AssetMediaService.name, () => { const file1 = Buffer.from('d2947b871a706081be194569951b7db246907957', 'hex'); const file2 = Buffer.from('53be335e99f18a66ff12e9a901c7a6171dd76573', 'hex'); - assetMock.getByChecksums.mockResolvedValue([ + mocks.asset.getByChecksums.mockResolvedValue([ { id: 'asset-1', checksum: file1 } as AssetEntity, { id: 'asset-2', checksum: file2 } as AssetEntity, ]); @@ -857,14 +847,14 @@ describe(AssetMediaService.name, () => { ], }); - expect(assetMock.getByChecksums).toHaveBeenCalledWith(authStub.admin.user.id, [file1, file2]); + expect(mocks.asset.getByChecksums).toHaveBeenCalledWith(authStub.admin.user.id, [file1, file2]); }); it('should return non-duplicates as well', async () => { const file1 = Buffer.from('d2947b871a706081be194569951b7db246907957', 'hex'); const file2 = Buffer.from('53be335e99f18a66ff12e9a901c7a6171dd76573', 'hex'); - assetMock.getByChecksums.mockResolvedValue([{ id: 'asset-1', checksum: file1 } as AssetEntity]); + mocks.asset.getByChecksums.mockResolvedValue([{ id: 'asset-1', checksum: file1 } as AssetEntity]); await expect( sut.bulkUploadCheck(authStub.admin, { @@ -889,7 +879,7 @@ describe(AssetMediaService.name, () => { ], }); - expect(assetMock.getByChecksums).toHaveBeenCalledWith(authStub.admin.user.id, [file1, file2]); + expect(mocks.asset.getByChecksums).toHaveBeenCalledWith(authStub.admin.user.id, [file1, file2]); }); }); @@ -910,7 +900,7 @@ describe(AssetMediaService.name, () => { await sut.onUploadError(request, file); - expect(jobMock.queue).toHaveBeenCalledWith({ + expect(mocks.job.queue).toHaveBeenCalledWith({ name: JobName.DELETE_FILES, data: { files: ['upload/upload/user-id/ra/nd/random-uuid.jpg'] }, }); diff --git a/server/src/services/asset.service.spec.ts b/server/src/services/asset.service.spec.ts index f1e537a3d8..9d64aacf10 100755 --- a/server/src/services/asset.service.spec.ts +++ b/server/src/services/asset.service.spec.ts @@ -4,22 +4,16 @@ import { mapAsset } from 'src/dtos/asset-response.dto'; import { AssetJobName, AssetStatsResponseDto } from 'src/dtos/asset.dto'; import { AssetEntity } from 'src/entities/asset.entity'; import { AssetStatus, AssetType } from 'src/enum'; -import { AssetStats, IAssetRepository } from 'src/interfaces/asset.interface'; -import { IEventRepository } from 'src/interfaces/event.interface'; -import { IJobRepository, JobName, JobStatus } from 'src/interfaces/job.interface'; -import { IPartnerRepository } from 'src/interfaces/partner.interface'; -import { IStackRepository } from 'src/interfaces/stack.interface'; -import { IUserRepository } from 'src/interfaces/user.interface'; +import { AssetStats } from 'src/interfaces/asset.interface'; +import { JobName, JobStatus } from 'src/interfaces/job.interface'; import { AssetService } from 'src/services/asset.service'; -import { ISystemMetadataRepository } from 'src/types'; import { assetStub } from 'test/fixtures/asset.stub'; import { authStub } from 'test/fixtures/auth.stub'; import { faceStub } from 'test/fixtures/face.stub'; import { partnerStub } from 'test/fixtures/partner.stub'; import { userStub } from 'test/fixtures/user.stub'; -import { IAccessRepositoryMock } from 'test/repositories/access.repository.mock'; -import { newTestService } from 'test/utils'; -import { Mocked, vitest } from 'vitest'; +import { newTestService, ServiceMocks } from 'test/utils'; +import { vitest } from 'vitest'; const stats: AssetStats = { [AssetType.IMAGE]: 10, @@ -36,27 +30,18 @@ const statResponse: AssetStatsResponseDto = { describe(AssetService.name, () => { let sut: AssetService; - - let accessMock: IAccessRepositoryMock; - let assetMock: Mocked; - let eventMock: Mocked; - let jobMock: Mocked; - let partnerMock: Mocked; - let stackMock: Mocked; - let systemMock: Mocked; - let userMock: Mocked; + let mocks: ServiceMocks; it('should work', () => { expect(sut).toBeDefined(); }); const mockGetById = (assets: AssetEntity[]) => { - assetMock.getById.mockImplementation((assetId) => Promise.resolve(assets.find((asset) => asset.id === assetId))); + mocks.asset.getById.mockImplementation((assetId) => Promise.resolve(assets.find((asset) => asset.id === assetId))); }; beforeEach(() => { - ({ sut, accessMock, assetMock, eventMock, jobMock, partnerMock, stackMock, systemMock, userMock } = - newTestService(AssetService)); + ({ sut, mocks } = newTestService(AssetService)); mockGetById([assetStub.livePhotoStillAsset, assetStub.livePhotoMotionAsset]); }); @@ -77,8 +62,8 @@ describe(AssetService.name, () => { const image3 = { ...assetStub.image, localDateTime: new Date(2015, 1, 15) }; const image4 = { ...assetStub.image, localDateTime: new Date(2009, 1, 15) }; - partnerMock.getAll.mockResolvedValue([]); - assetMock.getByDayOfYear.mockResolvedValue([ + mocks.partner.getAll.mockResolvedValue([]); + mocks.asset.getByDayOfYear.mockResolvedValue([ { yearsAgo: 1, assets: [image1, image2], @@ -99,16 +84,16 @@ describe(AssetService.name, () => { { yearsAgo: 15, title: '15 years ago', assets: [mapAsset(image4)] }, ]); - expect(assetMock.getByDayOfYear.mock.calls).toEqual([[[authStub.admin.user.id], { day: 15, month: 1 }]]); + expect(mocks.asset.getByDayOfYear.mock.calls).toEqual([[[authStub.admin.user.id], { day: 15, month: 1 }]]); }); it('should get memories with partners with inTimeline enabled', async () => { - partnerMock.getAll.mockResolvedValue([partnerStub.user1ToAdmin1]); - assetMock.getByDayOfYear.mockResolvedValue([]); + mocks.partner.getAll.mockResolvedValue([partnerStub.user1ToAdmin1]); + mocks.asset.getByDayOfYear.mockResolvedValue([]); await sut.getMemoryLane(authStub.admin, { day: 15, month: 1 }); - expect(assetMock.getByDayOfYear.mock.calls).toEqual([ + expect(mocks.asset.getByDayOfYear.mock.calls).toEqual([ [[authStub.admin.user.id, userStub.user1.id], { day: 15, month: 1 }], ]); }); @@ -116,76 +101,76 @@ describe(AssetService.name, () => { describe('getStatistics', () => { it('should get the statistics for a user, excluding archived assets', async () => { - assetMock.getStatistics.mockResolvedValue(stats); + mocks.asset.getStatistics.mockResolvedValue(stats); await expect(sut.getStatistics(authStub.admin, { isArchived: false })).resolves.toEqual(statResponse); - expect(assetMock.getStatistics).toHaveBeenCalledWith(authStub.admin.user.id, { isArchived: false }); + expect(mocks.asset.getStatistics).toHaveBeenCalledWith(authStub.admin.user.id, { isArchived: false }); }); it('should get the statistics for a user for archived assets', async () => { - assetMock.getStatistics.mockResolvedValue(stats); + mocks.asset.getStatistics.mockResolvedValue(stats); await expect(sut.getStatistics(authStub.admin, { isArchived: true })).resolves.toEqual(statResponse); - expect(assetMock.getStatistics).toHaveBeenCalledWith(authStub.admin.user.id, { isArchived: true }); + expect(mocks.asset.getStatistics).toHaveBeenCalledWith(authStub.admin.user.id, { isArchived: true }); }); it('should get the statistics for a user for favorite assets', async () => { - assetMock.getStatistics.mockResolvedValue(stats); + mocks.asset.getStatistics.mockResolvedValue(stats); await expect(sut.getStatistics(authStub.admin, { isFavorite: true })).resolves.toEqual(statResponse); - expect(assetMock.getStatistics).toHaveBeenCalledWith(authStub.admin.user.id, { isFavorite: true }); + expect(mocks.asset.getStatistics).toHaveBeenCalledWith(authStub.admin.user.id, { isFavorite: true }); }); it('should get the statistics for a user for all assets', async () => { - assetMock.getStatistics.mockResolvedValue(stats); + mocks.asset.getStatistics.mockResolvedValue(stats); await expect(sut.getStatistics(authStub.admin, {})).resolves.toEqual(statResponse); - expect(assetMock.getStatistics).toHaveBeenCalledWith(authStub.admin.user.id, {}); + expect(mocks.asset.getStatistics).toHaveBeenCalledWith(authStub.admin.user.id, {}); }); }); describe('getRandom', () => { it('should get own random assets', async () => { - assetMock.getRandom.mockResolvedValue([assetStub.image]); + mocks.asset.getRandom.mockResolvedValue([assetStub.image]); await sut.getRandom(authStub.admin, 1); - expect(assetMock.getRandom).toHaveBeenCalledWith([authStub.admin.user.id], 1); + expect(mocks.asset.getRandom).toHaveBeenCalledWith([authStub.admin.user.id], 1); }); it('should not include partner assets if not in timeline', async () => { - assetMock.getRandom.mockResolvedValue([assetStub.image]); - partnerMock.getAll.mockResolvedValue([{ ...partnerStub.user1ToAdmin1, inTimeline: false }]); + mocks.asset.getRandom.mockResolvedValue([assetStub.image]); + mocks.partner.getAll.mockResolvedValue([{ ...partnerStub.user1ToAdmin1, inTimeline: false }]); await sut.getRandom(authStub.admin, 1); - expect(assetMock.getRandom).toHaveBeenCalledWith([authStub.admin.user.id], 1); + expect(mocks.asset.getRandom).toHaveBeenCalledWith([authStub.admin.user.id], 1); }); it('should include partner assets if in timeline', async () => { - assetMock.getRandom.mockResolvedValue([assetStub.image]); - partnerMock.getAll.mockResolvedValue([partnerStub.user1ToAdmin1]); + mocks.asset.getRandom.mockResolvedValue([assetStub.image]); + mocks.partner.getAll.mockResolvedValue([partnerStub.user1ToAdmin1]); await sut.getRandom(authStub.admin, 1); - expect(assetMock.getRandom).toHaveBeenCalledWith([userStub.admin.id, userStub.user1.id], 1); + expect(mocks.asset.getRandom).toHaveBeenCalledWith([userStub.admin.id, userStub.user1.id], 1); }); }); describe('get', () => { it('should allow owner access', async () => { - accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.image.id])); - assetMock.getById.mockResolvedValue(assetStub.image); + mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.image.id])); + mocks.asset.getById.mockResolvedValue(assetStub.image); await sut.get(authStub.admin, assetStub.image.id); - expect(accessMock.asset.checkOwnerAccess).toHaveBeenCalledWith( + expect(mocks.access.asset.checkOwnerAccess).toHaveBeenCalledWith( authStub.admin.user.id, new Set([assetStub.image.id]), ); }); it('should allow shared link access', async () => { - accessMock.asset.checkSharedLinkAccess.mockResolvedValue(new Set([assetStub.image.id])); - assetMock.getById.mockResolvedValue(assetStub.image); + mocks.access.asset.checkSharedLinkAccess.mockResolvedValue(new Set([assetStub.image.id])); + mocks.asset.getById.mockResolvedValue(assetStub.image); await sut.get(authStub.adminSharedLink, assetStub.image.id); - expect(accessMock.asset.checkSharedLinkAccess).toHaveBeenCalledWith( + expect(mocks.access.asset.checkSharedLinkAccess).toHaveBeenCalledWith( authStub.adminSharedLink.sharedLink?.id, new Set([assetStub.image.id]), ); }); it('should strip metadata for shared link if exif is disabled', async () => { - accessMock.asset.checkSharedLinkAccess.mockResolvedValue(new Set([assetStub.image.id])); - assetMock.getById.mockResolvedValue(assetStub.image); + mocks.access.asset.checkSharedLinkAccess.mockResolvedValue(new Set([assetStub.image.id])); + mocks.asset.getById.mockResolvedValue(assetStub.image); const result = await sut.get( { ...authStub.adminSharedLink, sharedLink: { ...authStub.adminSharedLink.sharedLink!, showExif: false } }, @@ -194,27 +179,27 @@ describe(AssetService.name, () => { expect(result).toEqual(expect.objectContaining({ hasMetadata: false })); expect(result).not.toHaveProperty('exifInfo'); - expect(accessMock.asset.checkSharedLinkAccess).toHaveBeenCalledWith( + expect(mocks.access.asset.checkSharedLinkAccess).toHaveBeenCalledWith( authStub.adminSharedLink.sharedLink?.id, new Set([assetStub.image.id]), ); }); it('should allow partner sharing access', async () => { - accessMock.asset.checkPartnerAccess.mockResolvedValue(new Set([assetStub.image.id])); - assetMock.getById.mockResolvedValue(assetStub.image); + mocks.access.asset.checkPartnerAccess.mockResolvedValue(new Set([assetStub.image.id])); + mocks.asset.getById.mockResolvedValue(assetStub.image); await sut.get(authStub.admin, assetStub.image.id); - expect(accessMock.asset.checkPartnerAccess).toHaveBeenCalledWith( + expect(mocks.access.asset.checkPartnerAccess).toHaveBeenCalledWith( authStub.admin.user.id, new Set([assetStub.image.id]), ); }); it('should allow shared album access', async () => { - accessMock.asset.checkAlbumAccess.mockResolvedValue(new Set([assetStub.image.id])); - assetMock.getById.mockResolvedValue(assetStub.image); + mocks.access.asset.checkAlbumAccess.mockResolvedValue(new Set([assetStub.image.id])); + mocks.asset.getById.mockResolvedValue(assetStub.image); await sut.get(authStub.admin, assetStub.image.id); - expect(accessMock.asset.checkAlbumAccess).toHaveBeenCalledWith( + expect(mocks.access.asset.checkAlbumAccess).toHaveBeenCalledWith( authStub.admin.user.id, new Set([assetStub.image.id]), ); @@ -222,17 +207,17 @@ describe(AssetService.name, () => { it('should throw an error for no access', async () => { await expect(sut.get(authStub.admin, assetStub.image.id)).rejects.toBeInstanceOf(BadRequestException); - expect(assetMock.getById).not.toHaveBeenCalled(); + expect(mocks.asset.getById).not.toHaveBeenCalled(); }); it('should throw an error for an invalid shared link', async () => { await expect(sut.get(authStub.adminSharedLink, assetStub.image.id)).rejects.toBeInstanceOf(BadRequestException); - expect(accessMock.asset.checkOwnerAccess).not.toHaveBeenCalled(); - expect(assetMock.getById).not.toHaveBeenCalled(); + expect(mocks.access.asset.checkOwnerAccess).not.toHaveBeenCalled(); + expect(mocks.asset.getById).not.toHaveBeenCalled(); }); it('should throw an error if the asset could not be found', async () => { - accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.image.id])); + mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.image.id])); await expect(sut.get(authStub.admin, assetStub.image.id)).rejects.toBeInstanceOf(BadRequestException); }); }); @@ -242,40 +227,40 @@ describe(AssetService.name, () => { await expect(sut.update(authStub.admin, 'asset-1', { isArchived: false })).rejects.toBeInstanceOf( BadRequestException, ); - expect(assetMock.update).not.toHaveBeenCalled(); + expect(mocks.asset.update).not.toHaveBeenCalled(); }); it('should update the asset', async () => { - accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1'])); - assetMock.getById.mockResolvedValue(assetStub.image); - assetMock.update.mockResolvedValue(assetStub.image); + mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1'])); + mocks.asset.getById.mockResolvedValue(assetStub.image); + mocks.asset.update.mockResolvedValue(assetStub.image); await sut.update(authStub.admin, 'asset-1', { isFavorite: true }); - expect(assetMock.update).toHaveBeenCalledWith({ id: 'asset-1', isFavorite: true }); + expect(mocks.asset.update).toHaveBeenCalledWith({ id: 'asset-1', isFavorite: true }); }); it('should update the exif description', async () => { - accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1'])); - assetMock.getById.mockResolvedValue(assetStub.image); - assetMock.update.mockResolvedValue(assetStub.image); + mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1'])); + mocks.asset.getById.mockResolvedValue(assetStub.image); + mocks.asset.update.mockResolvedValue(assetStub.image); await sut.update(authStub.admin, 'asset-1', { description: 'Test description' }); - expect(assetMock.upsertExif).toHaveBeenCalledWith({ assetId: 'asset-1', description: 'Test description' }); + expect(mocks.asset.upsertExif).toHaveBeenCalledWith({ assetId: 'asset-1', description: 'Test description' }); }); it('should update the exif rating', async () => { - accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1'])); - assetMock.getById.mockResolvedValueOnce(assetStub.image); - assetMock.update.mockResolvedValueOnce(assetStub.image); + mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1'])); + mocks.asset.getById.mockResolvedValueOnce(assetStub.image); + mocks.asset.update.mockResolvedValueOnce(assetStub.image); await sut.update(authStub.admin, 'asset-1', { rating: 3 }); - expect(assetMock.upsertExif).toHaveBeenCalledWith({ assetId: 'asset-1', rating: 3 }); + expect(mocks.asset.upsertExif).toHaveBeenCalledWith({ assetId: 'asset-1', rating: 3 }); }); it('should fail linking a live video if the motion part could not be found', async () => { - accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.livePhotoStillAsset.id])); + mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.livePhotoStillAsset.id])); await expect( sut.update(authStub.admin, assetStub.livePhotoStillAsset.id, { @@ -283,20 +268,20 @@ describe(AssetService.name, () => { }), ).rejects.toBeInstanceOf(BadRequestException); - expect(assetMock.update).not.toHaveBeenCalledWith({ + expect(mocks.asset.update).not.toHaveBeenCalledWith({ id: assetStub.livePhotoStillAsset.id, livePhotoVideoId: assetStub.livePhotoMotionAsset.id, }); - expect(assetMock.update).not.toHaveBeenCalledWith({ id: assetStub.livePhotoMotionAsset.id, isVisible: true }); - expect(eventMock.emit).not.toHaveBeenCalledWith('asset.show', { + expect(mocks.asset.update).not.toHaveBeenCalledWith({ id: assetStub.livePhotoMotionAsset.id, isVisible: true }); + expect(mocks.event.emit).not.toHaveBeenCalledWith('asset.show', { assetId: assetStub.livePhotoMotionAsset.id, userId: userStub.admin.id, }); }); it('should fail linking a live video if the motion part is not a video', async () => { - accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.livePhotoStillAsset.id])); - assetMock.getById.mockResolvedValue(assetStub.livePhotoStillAsset); + mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.livePhotoStillAsset.id])); + mocks.asset.getById.mockResolvedValue(assetStub.livePhotoStillAsset); await expect( sut.update(authStub.admin, assetStub.livePhotoStillAsset.id, { @@ -304,20 +289,20 @@ describe(AssetService.name, () => { }), ).rejects.toBeInstanceOf(BadRequestException); - expect(assetMock.update).not.toHaveBeenCalledWith({ + expect(mocks.asset.update).not.toHaveBeenCalledWith({ id: assetStub.livePhotoStillAsset.id, livePhotoVideoId: assetStub.livePhotoMotionAsset.id, }); - expect(assetMock.update).not.toHaveBeenCalledWith({ id: assetStub.livePhotoMotionAsset.id, isVisible: true }); - expect(eventMock.emit).not.toHaveBeenCalledWith('asset.show', { + expect(mocks.asset.update).not.toHaveBeenCalledWith({ id: assetStub.livePhotoMotionAsset.id, isVisible: true }); + expect(mocks.event.emit).not.toHaveBeenCalledWith('asset.show', { assetId: assetStub.livePhotoMotionAsset.id, userId: userStub.admin.id, }); }); it('should fail linking a live video if the motion part has a different owner', async () => { - accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.livePhotoStillAsset.id])); - assetMock.getById.mockResolvedValue(assetStub.livePhotoMotionAsset); + mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.livePhotoStillAsset.id])); + mocks.asset.getById.mockResolvedValue(assetStub.livePhotoMotionAsset); await expect( sut.update(authStub.admin, assetStub.livePhotoStillAsset.id, { @@ -325,79 +310,79 @@ describe(AssetService.name, () => { }), ).rejects.toBeInstanceOf(BadRequestException); - expect(assetMock.update).not.toHaveBeenCalledWith({ + expect(mocks.asset.update).not.toHaveBeenCalledWith({ id: assetStub.livePhotoStillAsset.id, livePhotoVideoId: assetStub.livePhotoMotionAsset.id, }); - expect(assetMock.update).not.toHaveBeenCalledWith({ id: assetStub.livePhotoMotionAsset.id, isVisible: true }); - expect(eventMock.emit).not.toHaveBeenCalledWith('asset.show', { + expect(mocks.asset.update).not.toHaveBeenCalledWith({ id: assetStub.livePhotoMotionAsset.id, isVisible: true }); + expect(mocks.event.emit).not.toHaveBeenCalledWith('asset.show', { assetId: assetStub.livePhotoMotionAsset.id, userId: userStub.admin.id, }); }); it('should link a live video', async () => { - accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.livePhotoStillAsset.id])); - assetMock.getById.mockResolvedValueOnce({ + mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.livePhotoStillAsset.id])); + mocks.asset.getById.mockResolvedValueOnce({ ...assetStub.livePhotoMotionAsset, ownerId: authStub.admin.user.id, isVisible: true, }); - assetMock.getById.mockResolvedValueOnce(assetStub.image); - assetMock.update.mockResolvedValue(assetStub.image); + mocks.asset.getById.mockResolvedValueOnce(assetStub.image); + mocks.asset.update.mockResolvedValue(assetStub.image); await sut.update(authStub.admin, assetStub.livePhotoStillAsset.id, { livePhotoVideoId: assetStub.livePhotoMotionAsset.id, }); - expect(assetMock.update).toHaveBeenCalledWith({ id: assetStub.livePhotoMotionAsset.id, isVisible: false }); - expect(eventMock.emit).toHaveBeenCalledWith('asset.hide', { + expect(mocks.asset.update).toHaveBeenCalledWith({ id: assetStub.livePhotoMotionAsset.id, isVisible: false }); + expect(mocks.event.emit).toHaveBeenCalledWith('asset.hide', { assetId: assetStub.livePhotoMotionAsset.id, userId: userStub.admin.id, }); - expect(assetMock.update).toHaveBeenCalledWith({ + expect(mocks.asset.update).toHaveBeenCalledWith({ id: assetStub.livePhotoStillAsset.id, livePhotoVideoId: assetStub.livePhotoMotionAsset.id, }); }); it('should throw an error if asset could not be found after update', async () => { - accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1'])); + mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1'])); await expect(sut.update(authStub.admin, 'asset-1', { isFavorite: true })).rejects.toBeInstanceOf( BadRequestException, ); }); it('should unlink a live video', async () => { - accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.livePhotoStillAsset.id])); - assetMock.getById.mockResolvedValueOnce(assetStub.livePhotoStillAsset); - assetMock.getById.mockResolvedValueOnce(assetStub.livePhotoMotionAsset); - assetMock.update.mockResolvedValueOnce(assetStub.image); + mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.livePhotoStillAsset.id])); + mocks.asset.getById.mockResolvedValueOnce(assetStub.livePhotoStillAsset); + mocks.asset.getById.mockResolvedValueOnce(assetStub.livePhotoMotionAsset); + mocks.asset.update.mockResolvedValueOnce(assetStub.image); await sut.update(authStub.admin, assetStub.livePhotoStillAsset.id, { livePhotoVideoId: null }); - expect(assetMock.update).toHaveBeenCalledWith({ + expect(mocks.asset.update).toHaveBeenCalledWith({ id: assetStub.livePhotoStillAsset.id, livePhotoVideoId: null, }); - expect(assetMock.update).toHaveBeenCalledWith({ id: assetStub.livePhotoMotionAsset.id, isVisible: true }); - expect(eventMock.emit).toHaveBeenCalledWith('asset.show', { + expect(mocks.asset.update).toHaveBeenCalledWith({ id: assetStub.livePhotoMotionAsset.id, isVisible: true }); + expect(mocks.event.emit).toHaveBeenCalledWith('asset.show', { assetId: assetStub.livePhotoMotionAsset.id, userId: userStub.admin.id, }); }); it('should fail unlinking a live video if the asset could not be found', async () => { - accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.livePhotoStillAsset.id])); + mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.livePhotoStillAsset.id])); // eslint-disable-next-line unicorn/no-useless-undefined - assetMock.getById.mockResolvedValueOnce(undefined); + mocks.asset.getById.mockResolvedValueOnce(undefined); await expect( sut.update(authStub.admin, assetStub.livePhotoStillAsset.id, { livePhotoVideoId: null }), ).rejects.toBeInstanceOf(BadRequestException); - expect(assetMock.update).not.toHaveBeenCalled(); - expect(eventMock.emit).not.toHaveBeenCalled(); + expect(mocks.asset.update).not.toHaveBeenCalled(); + expect(mocks.event.emit).not.toHaveBeenCalled(); }); }); @@ -412,13 +397,13 @@ describe(AssetService.name, () => { }); it('should update all assets', async () => { - accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1', 'asset-2'])); + mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1', 'asset-2'])); await sut.updateAll(authStub.admin, { ids: ['asset-1', 'asset-2'], isArchived: true }); - expect(assetMock.updateAll).toHaveBeenCalledWith(['asset-1', 'asset-2'], { isArchived: true }); + expect(mocks.asset.updateAll).toHaveBeenCalledWith(['asset-1', 'asset-2'], { isArchived: true }); }); it('should not update Assets table if no relevant fields are provided', async () => { - accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1'])); + mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1'])); await sut.updateAll(authStub.admin, { ids: ['asset-1'], latitude: 0, @@ -428,11 +413,11 @@ describe(AssetService.name, () => { duplicateId: undefined, rating: undefined, }); - expect(assetMock.updateAll).not.toHaveBeenCalled(); + expect(mocks.asset.updateAll).not.toHaveBeenCalled(); }); it('should update Assets table if isArchived field is provided', async () => { - accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1'])); + mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1'])); await sut.updateAll(authStub.admin, { ids: ['asset-1'], latitude: 0, @@ -442,7 +427,7 @@ describe(AssetService.name, () => { duplicateId: undefined, rating: undefined, }); - expect(assetMock.updateAll).toHaveBeenCalled(); + expect(mocks.asset.updateAll).toHaveBeenCalled(); }); }); @@ -456,26 +441,26 @@ describe(AssetService.name, () => { }); it('should force delete a batch of assets', async () => { - accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset1', 'asset2'])); + mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset1', 'asset2'])); await sut.deleteAll(authStub.user1, { ids: ['asset1', 'asset2'], force: true }); - expect(eventMock.emit).toHaveBeenCalledWith('assets.delete', { + expect(mocks.event.emit).toHaveBeenCalledWith('assets.delete', { assetIds: ['asset1', 'asset2'], userId: 'user-id', }); }); it('should soft delete a batch of assets', async () => { - accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset1', 'asset2'])); + mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset1', 'asset2'])); await sut.deleteAll(authStub.user1, { ids: ['asset1', 'asset2'], force: false }); - expect(assetMock.updateAll).toHaveBeenCalledWith(['asset1', 'asset2'], { + expect(mocks.asset.updateAll).toHaveBeenCalledWith(['asset1', 'asset2'], { deletedAt: expect.any(Date), status: AssetStatus.TRASHED, }); - expect(jobMock.queue.mock.calls).toEqual([]); + expect(mocks.job.queue.mock.calls).toEqual([]); }); }); @@ -489,27 +474,27 @@ describe(AssetService.name, () => { }); it('should immediately queue assets for deletion if trash is disabled', async () => { - assetMock.getAll.mockResolvedValue({ hasNextPage: false, items: [assetStub.image] }); - systemMock.get.mockResolvedValue({ trash: { enabled: false } }); + mocks.asset.getAll.mockResolvedValue({ hasNextPage: false, items: [assetStub.image] }); + mocks.systemMetadata.get.mockResolvedValue({ trash: { enabled: false } }); await expect(sut.handleAssetDeletionCheck()).resolves.toBe(JobStatus.SUCCESS); - expect(assetMock.getAll).toHaveBeenCalledWith(expect.anything(), { trashedBefore: new Date() }); - expect(jobMock.queueAll).toHaveBeenCalledWith([ + expect(mocks.asset.getAll).toHaveBeenCalledWith(expect.anything(), { trashedBefore: new Date() }); + expect(mocks.job.queueAll).toHaveBeenCalledWith([ { name: JobName.ASSET_DELETION, data: { id: assetStub.image.id, deleteOnDisk: true } }, ]); }); it('should queue assets for deletion after trash duration', async () => { - assetMock.getAll.mockResolvedValue({ hasNextPage: false, items: [assetStub.image] }); - systemMock.get.mockResolvedValue({ trash: { enabled: true, days: 7 } }); + mocks.asset.getAll.mockResolvedValue({ hasNextPage: false, items: [assetStub.image] }); + mocks.systemMetadata.get.mockResolvedValue({ trash: { enabled: true, days: 7 } }); await expect(sut.handleAssetDeletionCheck()).resolves.toBe(JobStatus.SUCCESS); - expect(assetMock.getAll).toHaveBeenCalledWith(expect.anything(), { + expect(mocks.asset.getAll).toHaveBeenCalledWith(expect.anything(), { trashedBefore: DateTime.now().minus({ days: 7 }).toJSDate(), }); - expect(jobMock.queueAll).toHaveBeenCalledWith([ + expect(mocks.job.queueAll).toHaveBeenCalledWith([ { name: JobName.ASSET_DELETION, data: { id: assetStub.image.id, deleteOnDisk: true } }, ]); }); @@ -519,11 +504,11 @@ describe(AssetService.name, () => { it('should remove faces', async () => { const assetWithFace = { ...assetStub.image, faces: [faceStub.face1, faceStub.mergeFace1] }; - assetMock.getById.mockResolvedValue(assetWithFace); + mocks.asset.getById.mockResolvedValue(assetWithFace); await sut.handleAssetDeletion({ id: assetWithFace.id, deleteOnDisk: true }); - expect(jobMock.queue.mock.calls).toEqual([ + expect(mocks.job.queue.mock.calls).toEqual([ [ { name: JobName.DELETE_FILES, @@ -540,41 +525,41 @@ describe(AssetService.name, () => { ], ]); - expect(assetMock.remove).toHaveBeenCalledWith(assetWithFace); + expect(mocks.asset.remove).toHaveBeenCalledWith(assetWithFace); }); it('should update stack primary asset if deleted asset was primary asset in a stack', async () => { - assetMock.getById.mockResolvedValue(assetStub.primaryImage as AssetEntity); + mocks.asset.getById.mockResolvedValue(assetStub.primaryImage as AssetEntity); await sut.handleAssetDeletion({ id: assetStub.primaryImage.id, deleteOnDisk: true }); - expect(stackMock.update).toHaveBeenCalledWith('stack-1', { + expect(mocks.stack.update).toHaveBeenCalledWith('stack-1', { id: 'stack-1', primaryAssetId: 'stack-child-asset-1', }); }); it('should delete the entire stack if deleted asset was the primary asset and the stack would only contain one asset afterwards', async () => { - assetMock.getById.mockResolvedValue({ + mocks.asset.getById.mockResolvedValue({ ...assetStub.primaryImage, stack: { ...assetStub.primaryImage.stack, assets: assetStub.primaryImage.stack!.assets.slice(0, 2) }, } as AssetEntity); await sut.handleAssetDeletion({ id: assetStub.primaryImage.id, deleteOnDisk: true }); - expect(stackMock.delete).toHaveBeenCalledWith('stack-1'); + expect(mocks.stack.delete).toHaveBeenCalledWith('stack-1'); }); it('should delete a live photo', async () => { - assetMock.getById.mockResolvedValue(assetStub.livePhotoStillAsset); - assetMock.getLivePhotoCount.mockResolvedValue(0); + mocks.asset.getById.mockResolvedValue(assetStub.livePhotoStillAsset); + mocks.asset.getLivePhotoCount.mockResolvedValue(0); await sut.handleAssetDeletion({ id: assetStub.livePhotoStillAsset.id, deleteOnDisk: true, }); - expect(jobMock.queue.mock.calls).toEqual([ + expect(mocks.job.queue.mock.calls).toEqual([ [ { name: JobName.ASSET_DELETION, @@ -596,15 +581,15 @@ describe(AssetService.name, () => { }); it('should not delete a live motion part if it is being used by another asset', async () => { - assetMock.getLivePhotoCount.mockResolvedValue(2); - assetMock.getById.mockResolvedValue(assetStub.livePhotoStillAsset); + mocks.asset.getLivePhotoCount.mockResolvedValue(2); + mocks.asset.getById.mockResolvedValue(assetStub.livePhotoStillAsset); await sut.handleAssetDeletion({ id: assetStub.livePhotoStillAsset.id, deleteOnDisk: true, }); - expect(jobMock.queue.mock.calls).toEqual([ + expect(mocks.job.queue.mock.calls).toEqual([ [ { name: JobName.DELETE_FILES, @@ -617,9 +602,9 @@ describe(AssetService.name, () => { }); it('should update usage', async () => { - assetMock.getById.mockResolvedValue(assetStub.image); + mocks.asset.getById.mockResolvedValue(assetStub.image); await sut.handleAssetDeletion({ id: assetStub.image.id, deleteOnDisk: true }); - expect(userMock.updateUsage).toHaveBeenCalledWith(assetStub.image.ownerId, -5000); + expect(mocks.user.updateUsage).toHaveBeenCalledWith(assetStub.image.ownerId, -5000); }); it('should fail if asset could not be found', async () => { @@ -631,27 +616,27 @@ describe(AssetService.name, () => { describe('run', () => { it('should run the refresh faces job', async () => { - accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1'])); + mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1'])); await sut.run(authStub.admin, { assetIds: ['asset-1'], name: AssetJobName.REFRESH_FACES }); - expect(jobMock.queueAll).toHaveBeenCalledWith([{ name: JobName.FACE_DETECTION, data: { id: 'asset-1' } }]); + expect(mocks.job.queueAll).toHaveBeenCalledWith([{ name: JobName.FACE_DETECTION, data: { id: 'asset-1' } }]); }); it('should run the refresh metadata job', async () => { - accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1'])); + mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1'])); await sut.run(authStub.admin, { assetIds: ['asset-1'], name: AssetJobName.REFRESH_METADATA }); - expect(jobMock.queueAll).toHaveBeenCalledWith([{ name: JobName.METADATA_EXTRACTION, data: { id: 'asset-1' } }]); + expect(mocks.job.queueAll).toHaveBeenCalledWith([{ name: JobName.METADATA_EXTRACTION, data: { id: 'asset-1' } }]); }); it('should run the refresh thumbnails job', async () => { - accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1'])); + mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1'])); await sut.run(authStub.admin, { assetIds: ['asset-1'], name: AssetJobName.REGENERATE_THUMBNAIL }); - expect(jobMock.queueAll).toHaveBeenCalledWith([{ name: JobName.GENERATE_THUMBNAILS, data: { id: 'asset-1' } }]); + expect(mocks.job.queueAll).toHaveBeenCalledWith([{ name: JobName.GENERATE_THUMBNAILS, data: { id: 'asset-1' } }]); }); it('should run the transcode video', async () => { - accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1'])); + mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1'])); await sut.run(authStub.admin, { assetIds: ['asset-1'], name: AssetJobName.TRANSCODE_VIDEO }); - expect(jobMock.queueAll).toHaveBeenCalledWith([{ name: JobName.VIDEO_CONVERSION, data: { id: 'asset-1' } }]); + expect(mocks.job.queueAll).toHaveBeenCalledWith([{ name: JobName.VIDEO_CONVERSION, data: { id: 'asset-1' } }]); }); }); @@ -659,7 +644,7 @@ describe(AssetService.name, () => { it('get assets by device id', async () => { const assets = [assetStub.image, assetStub.image1]; - assetMock.getAllByDeviceId.mockResolvedValue(assets.map((asset) => asset.deviceAssetId)); + mocks.asset.getAllByDeviceId.mockResolvedValue(assets.map((asset) => asset.deviceAssetId)); const deviceId = 'device-id'; const result = await sut.getUserAssetsByDeviceId(authStub.user1, deviceId); diff --git a/server/src/services/audit.service.spec.ts b/server/src/services/audit.service.spec.ts index dd853042fb..b459ecb473 100644 --- a/server/src/services/audit.service.spec.ts +++ b/server/src/services/audit.service.spec.ts @@ -1,28 +1,18 @@ import { BadRequestException } from '@nestjs/common'; import { FileReportItemDto } from 'src/dtos/audit.dto'; import { AssetFileType, AssetPathType, DatabaseAction, EntityType, PersonPathType, UserPathType } from 'src/enum'; -import { IAssetRepository } from 'src/interfaces/asset.interface'; -import { ICryptoRepository } from 'src/interfaces/crypto.interface'; import { JobStatus } from 'src/interfaces/job.interface'; -import { IPersonRepository } from 'src/interfaces/person.interface'; -import { IUserRepository } from 'src/interfaces/user.interface'; import { AuditService } from 'src/services/audit.service'; -import { IAuditRepository } from 'src/types'; import { auditStub } from 'test/fixtures/audit.stub'; import { authStub } from 'test/fixtures/auth.stub'; -import { newTestService } from 'test/utils'; -import { Mocked } from 'vitest'; +import { newTestService, ServiceMocks } from 'test/utils'; describe(AuditService.name, () => { let sut: AuditService; - let auditMock: Mocked; - let assetMock: Mocked; - let cryptoMock: Mocked; - let personMock: Mocked; - let userMock: Mocked; + let mocks: ServiceMocks; beforeEach(() => { - ({ sut, auditMock, assetMock, cryptoMock, personMock, userMock } = newTestService(AuditService)); + ({ sut, mocks } = newTestService(AuditService)); }); it('should work', () => { @@ -32,13 +22,13 @@ describe(AuditService.name, () => { describe('handleCleanup', () => { it('should delete old audit entries', async () => { await expect(sut.handleCleanup()).resolves.toBe(JobStatus.SUCCESS); - expect(auditMock.removeBefore).toHaveBeenCalledWith(expect.any(Date)); + expect(mocks.audit.removeBefore).toHaveBeenCalledWith(expect.any(Date)); }); }); describe('getDeletes', () => { it('should require full sync if the request is older than 100 days', async () => { - auditMock.getAfter.mockResolvedValue([]); + mocks.audit.getAfter.mockResolvedValue([]); const date = new Date(2022, 0, 1); await expect(sut.getDeletes(authStub.admin, { after: date, entityType: EntityType.ASSET })).resolves.toEqual({ @@ -46,7 +36,7 @@ describe(AuditService.name, () => { ids: [], }); - expect(auditMock.getAfter).toHaveBeenCalledWith(date, { + expect(mocks.audit.getAfter).toHaveBeenCalledWith(date, { action: DatabaseAction.DELETE, userIds: [authStub.admin.user.id], entityType: EntityType.ASSET, @@ -54,7 +44,7 @@ describe(AuditService.name, () => { }); it('should get any new or updated assets and deleted ids', async () => { - auditMock.getAfter.mockResolvedValue([auditStub.delete.entityId]); + mocks.audit.getAfter.mockResolvedValue([auditStub.delete.entityId]); const date = new Date(); await expect(sut.getDeletes(authStub.admin, { after: date, entityType: EntityType.ASSET })).resolves.toEqual({ @@ -62,7 +52,7 @@ describe(AuditService.name, () => { ids: ['asset-deleted'], }); - expect(auditMock.getAfter).toHaveBeenCalledWith(date, { + expect(mocks.audit.getAfter).toHaveBeenCalledWith(date, { action: DatabaseAction.DELETE, userIds: [authStub.admin.user.id], entityType: EntityType.ASSET, @@ -74,7 +64,7 @@ describe(AuditService.name, () => { it('should fail if the file is not in the immich path', async () => { await expect(sut.getChecksums({ filenames: ['foo/bar'] })).rejects.toBeInstanceOf(BadRequestException); - expect(cryptoMock.hashFile).not.toHaveBeenCalled(); + expect(mocks.crypto.hashFile).not.toHaveBeenCalled(); }); it('should get checksum for valid file', async () => { @@ -82,7 +72,7 @@ describe(AuditService.name, () => { { filename: './upload/my-file.jpg', checksum: expect.any(String) }, ]); - expect(cryptoMock.hashFile).toHaveBeenCalledWith('./upload/my-file.jpg'); + expect(mocks.crypto.hashFile).toHaveBeenCalledWith('./upload/my-file.jpg'); }); }); @@ -94,10 +84,10 @@ describe(AuditService.name, () => { ]), ).rejects.toBeInstanceOf(BadRequestException); - expect(assetMock.update).not.toHaveBeenCalled(); - expect(assetMock.upsertFile).not.toHaveBeenCalled(); - expect(personMock.update).not.toHaveBeenCalled(); - expect(userMock.update).not.toHaveBeenCalled(); + expect(mocks.asset.update).not.toHaveBeenCalled(); + expect(mocks.asset.upsertFile).not.toHaveBeenCalled(); + expect(mocks.person.update).not.toHaveBeenCalled(); + expect(mocks.user.update).not.toHaveBeenCalled(); }); it('should update encoded video path', async () => { @@ -109,10 +99,10 @@ describe(AuditService.name, () => { } as FileReportItemDto, ]); - expect(assetMock.update).toHaveBeenCalledWith({ id: 'my-id', encodedVideoPath: './upload/my-video.mp4' }); - expect(assetMock.upsertFile).not.toHaveBeenCalled(); - expect(personMock.update).not.toHaveBeenCalled(); - expect(userMock.update).not.toHaveBeenCalled(); + expect(mocks.asset.update).toHaveBeenCalledWith({ id: 'my-id', encodedVideoPath: './upload/my-video.mp4' }); + expect(mocks.asset.upsertFile).not.toHaveBeenCalled(); + expect(mocks.person.update).not.toHaveBeenCalled(); + expect(mocks.user.update).not.toHaveBeenCalled(); }); it('should update preview path', async () => { @@ -124,14 +114,14 @@ describe(AuditService.name, () => { } as FileReportItemDto, ]); - expect(assetMock.upsertFile).toHaveBeenCalledWith({ + expect(mocks.asset.upsertFile).toHaveBeenCalledWith({ assetId: 'my-id', type: AssetFileType.PREVIEW, path: './upload/my-preview.png', }); - expect(assetMock.update).not.toHaveBeenCalled(); - expect(personMock.update).not.toHaveBeenCalled(); - expect(userMock.update).not.toHaveBeenCalled(); + expect(mocks.asset.update).not.toHaveBeenCalled(); + expect(mocks.person.update).not.toHaveBeenCalled(); + expect(mocks.user.update).not.toHaveBeenCalled(); }); it('should update thumbnail path', async () => { @@ -143,14 +133,14 @@ describe(AuditService.name, () => { } as FileReportItemDto, ]); - expect(assetMock.upsertFile).toHaveBeenCalledWith({ + expect(mocks.asset.upsertFile).toHaveBeenCalledWith({ assetId: 'my-id', type: AssetFileType.THUMBNAIL, path: './upload/my-thumbnail.webp', }); - expect(assetMock.update).not.toHaveBeenCalled(); - expect(personMock.update).not.toHaveBeenCalled(); - expect(userMock.update).not.toHaveBeenCalled(); + expect(mocks.asset.update).not.toHaveBeenCalled(); + expect(mocks.person.update).not.toHaveBeenCalled(); + expect(mocks.user.update).not.toHaveBeenCalled(); }); it('should update original path', async () => { @@ -162,10 +152,10 @@ describe(AuditService.name, () => { } as FileReportItemDto, ]); - expect(assetMock.update).toHaveBeenCalledWith({ id: 'my-id', originalPath: './upload/my-original.png' }); - expect(assetMock.upsertFile).not.toHaveBeenCalled(); - expect(personMock.update).not.toHaveBeenCalled(); - expect(userMock.update).not.toHaveBeenCalled(); + expect(mocks.asset.update).toHaveBeenCalledWith({ id: 'my-id', originalPath: './upload/my-original.png' }); + expect(mocks.asset.upsertFile).not.toHaveBeenCalled(); + expect(mocks.person.update).not.toHaveBeenCalled(); + expect(mocks.user.update).not.toHaveBeenCalled(); }); it('should update sidecar path', async () => { @@ -177,10 +167,10 @@ describe(AuditService.name, () => { } as FileReportItemDto, ]); - expect(assetMock.update).toHaveBeenCalledWith({ id: 'my-id', sidecarPath: './upload/my-sidecar.xmp' }); - expect(assetMock.upsertFile).not.toHaveBeenCalled(); - expect(personMock.update).not.toHaveBeenCalled(); - expect(userMock.update).not.toHaveBeenCalled(); + expect(mocks.asset.update).toHaveBeenCalledWith({ id: 'my-id', sidecarPath: './upload/my-sidecar.xmp' }); + expect(mocks.asset.upsertFile).not.toHaveBeenCalled(); + expect(mocks.person.update).not.toHaveBeenCalled(); + expect(mocks.user.update).not.toHaveBeenCalled(); }); it('should update face path', async () => { @@ -192,10 +182,10 @@ describe(AuditService.name, () => { } as FileReportItemDto, ]); - expect(personMock.update).toHaveBeenCalledWith({ id: 'my-id', thumbnailPath: './upload/my-face.jpg' }); - expect(assetMock.update).not.toHaveBeenCalled(); - expect(assetMock.upsertFile).not.toHaveBeenCalled(); - expect(userMock.update).not.toHaveBeenCalled(); + expect(mocks.person.update).toHaveBeenCalledWith({ id: 'my-id', thumbnailPath: './upload/my-face.jpg' }); + expect(mocks.asset.update).not.toHaveBeenCalled(); + expect(mocks.asset.upsertFile).not.toHaveBeenCalled(); + expect(mocks.user.update).not.toHaveBeenCalled(); }); it('should update profile path', async () => { @@ -207,10 +197,10 @@ describe(AuditService.name, () => { } as FileReportItemDto, ]); - expect(userMock.update).toHaveBeenCalledWith('my-id', { profileImagePath: './upload/my-profile-pic.jpg' }); - expect(assetMock.update).not.toHaveBeenCalled(); - expect(assetMock.upsertFile).not.toHaveBeenCalled(); - expect(personMock.update).not.toHaveBeenCalled(); + expect(mocks.user.update).toHaveBeenCalledWith('my-id', { profileImagePath: './upload/my-profile-pic.jpg' }); + expect(mocks.asset.update).not.toHaveBeenCalled(); + expect(mocks.asset.upsertFile).not.toHaveBeenCalled(); + expect(mocks.person.update).not.toHaveBeenCalled(); }); }); }); diff --git a/server/src/services/auth.service.spec.ts b/server/src/services/auth.service.spec.ts index e3b418d350..9fb1af128e 100644 --- a/server/src/services/auth.service.spec.ts +++ b/server/src/services/auth.service.spec.ts @@ -3,20 +3,14 @@ import { AuthDto, SignUpDto } from 'src/dtos/auth.dto'; import { UserMetadataEntity } from 'src/entities/user-metadata.entity'; import { UserEntity } from 'src/entities/user.entity'; import { AuthType, Permission } from 'src/enum'; -import { ICryptoRepository } from 'src/interfaces/crypto.interface'; -import { IEventRepository } from 'src/interfaces/event.interface'; -import { ISharedLinkRepository } from 'src/interfaces/shared-link.interface'; -import { IUserRepository } from 'src/interfaces/user.interface'; import { AuthService } from 'src/services/auth.service'; -import { IApiKeyRepository, IOAuthRepository, ISessionRepository, ISystemMetadataRepository } from 'src/types'; import { keyStub } from 'test/fixtures/api-key.stub'; import { authStub } from 'test/fixtures/auth.stub'; import { sessionStub } from 'test/fixtures/session.stub'; import { sharedLinkStub } from 'test/fixtures/shared-link.stub'; import { systemConfigStub } from 'test/fixtures/system-config.stub'; import { userStub } from 'test/fixtures/user.stub'; -import { newTestService } from 'test/utils'; -import { Mocked } from 'vitest'; +import { newTestService, ServiceMocks } from 'test/utils'; const oauthResponse = { accessToken: 'cmFuZG9tLWJ5dGVz', @@ -56,23 +50,14 @@ const oauthUserWithDefaultQuota = { describe('AuthService', () => { let sut: AuthService; - - let cryptoMock: Mocked; - let eventMock: Mocked; - let keyMock: Mocked; - let oauthMock: Mocked; - let sessionMock: Mocked; - let sharedLinkMock: Mocked; - let systemMock: Mocked; - let userMock: Mocked; + let mocks: ServiceMocks; beforeEach(() => { - ({ sut, cryptoMock, eventMock, keyMock, oauthMock, sessionMock, sharedLinkMock, systemMock, userMock } = - newTestService(AuthService)); + ({ sut, mocks } = newTestService(AuthService)); - oauthMock.authorize.mockResolvedValue('access-token'); - oauthMock.getProfile.mockResolvedValue({ sub, email }); - oauthMock.getLogoutEndpoint.mockResolvedValue('http://end-session-endpoint'); + mocks.oauth.authorize.mockResolvedValue('access-token'); + mocks.oauth.getProfile.mockResolvedValue({ sub, email }); + mocks.oauth.getLogoutEndpoint.mockResolvedValue('http://end-session-endpoint'); }); it('should be defined', () => { @@ -82,31 +67,31 @@ describe('AuthService', () => { describe('onBootstrap', () => { it('should init the repo', () => { sut.onBootstrap(); - expect(oauthMock.init).toHaveBeenCalled(); + expect(mocks.oauth.init).toHaveBeenCalled(); }); }); describe('login', () => { it('should throw an error if password login is disabled', async () => { - systemMock.get.mockResolvedValue(systemConfigStub.disabled); + mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.disabled); await expect(sut.login(fixtures.login, loginDetails)).rejects.toBeInstanceOf(UnauthorizedException); }); it('should check the user exists', async () => { - userMock.getByEmail.mockResolvedValue(void 0); + mocks.user.getByEmail.mockResolvedValue(void 0); await expect(sut.login(fixtures.login, loginDetails)).rejects.toBeInstanceOf(UnauthorizedException); - expect(userMock.getByEmail).toHaveBeenCalledTimes(1); + expect(mocks.user.getByEmail).toHaveBeenCalledTimes(1); }); it('should check the user has a password', async () => { - userMock.getByEmail.mockResolvedValue({} as UserEntity); + mocks.user.getByEmail.mockResolvedValue({} as UserEntity); await expect(sut.login(fixtures.login, loginDetails)).rejects.toBeInstanceOf(UnauthorizedException); - expect(userMock.getByEmail).toHaveBeenCalledTimes(1); + expect(mocks.user.getByEmail).toHaveBeenCalledTimes(1); }); it('should successfully log the user in', async () => { - userMock.getByEmail.mockResolvedValue(userStub.user1); - sessionMock.create.mockResolvedValue(sessionStub.valid); + mocks.user.getByEmail.mockResolvedValue(userStub.user1); + mocks.session.create.mockResolvedValue(sessionStub.valid); await expect(sut.login(fixtures.login, loginDetails)).resolves.toEqual({ accessToken: 'cmFuZG9tLWJ5dGVz', userId: 'user-id', @@ -116,7 +101,7 @@ describe('AuthService', () => { isAdmin: false, shouldChangePassword: false, }); - expect(userMock.getByEmail).toHaveBeenCalledTimes(1); + expect(mocks.user.getByEmail).toHaveBeenCalledTimes(1); }); }); @@ -125,23 +110,23 @@ describe('AuthService', () => { const auth = { user: { email: 'test@imimch.com' } } as AuthDto; const dto = { password: 'old-password', newPassword: 'new-password' }; - userMock.getByEmail.mockResolvedValue({ + mocks.user.getByEmail.mockResolvedValue({ email: 'test@immich.com', password: 'hash-password', } as UserEntity); - userMock.update.mockResolvedValue(userStub.user1); + mocks.user.update.mockResolvedValue(userStub.user1); await sut.changePassword(auth, dto); - expect(userMock.getByEmail).toHaveBeenCalledWith(auth.user.email, true); - expect(cryptoMock.compareBcrypt).toHaveBeenCalledWith('old-password', 'hash-password'); + expect(mocks.user.getByEmail).toHaveBeenCalledWith(auth.user.email, true); + expect(mocks.crypto.compareBcrypt).toHaveBeenCalledWith('old-password', 'hash-password'); }); it('should throw when auth user email is not found', async () => { const auth = { user: { email: 'test@imimch.com' } } as AuthDto; const dto = { password: 'old-password', newPassword: 'new-password' }; - userMock.getByEmail.mockResolvedValue(void 0); + mocks.user.getByEmail.mockResolvedValue(void 0); await expect(sut.changePassword(auth, dto)).rejects.toBeInstanceOf(UnauthorizedException); }); @@ -150,9 +135,9 @@ describe('AuthService', () => { const auth = { user: { email: 'test@imimch.com' } as UserEntity }; const dto = { password: 'old-password', newPassword: 'new-password' }; - cryptoMock.compareBcrypt.mockReturnValue(false); + mocks.crypto.compareBcrypt.mockReturnValue(false); - userMock.getByEmail.mockResolvedValue({ + mocks.user.getByEmail.mockResolvedValue({ email: 'test@immich.com', password: 'hash-password', } as UserEntity); @@ -164,7 +149,7 @@ describe('AuthService', () => { const auth = { user: { email: 'test@imimch.com' } } as AuthDto; const dto = { password: 'old-password', newPassword: 'new-password' }; - userMock.getByEmail.mockResolvedValue({ + mocks.user.getByEmail.mockResolvedValue({ email: 'test@immich.com', password: '', } as UserEntity); @@ -175,7 +160,7 @@ describe('AuthService', () => { describe('logout', () => { it('should return the end session endpoint', async () => { - systemMock.get.mockResolvedValue(systemConfigStub.enabled); + mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.enabled); const auth = { user: { id: '123' } } as AuthDto; await expect(sut.logout(auth, AuthType.OAUTH)).resolves.toEqual({ successful: true, @@ -200,8 +185,8 @@ describe('AuthService', () => { redirectUri: '/auth/login?autoLaunch=0', }); - expect(sessionMock.delete).toHaveBeenCalledWith('token123'); - expect(eventMock.emit).toHaveBeenCalledWith('session.delete', { sessionId: 'token123' }); + expect(mocks.session.delete).toHaveBeenCalledWith('token123'); + expect(mocks.event.emit).toHaveBeenCalledWith('session.delete', { sessionId: 'token123' }); }); it('should return the default redirect if auth type is OAUTH but oauth is not enabled', async () => { @@ -218,14 +203,14 @@ describe('AuthService', () => { const dto: SignUpDto = { email: 'test@immich.com', password: 'password', name: 'immich admin' }; it('should only allow one admin', async () => { - userMock.getAdmin.mockResolvedValue({} as UserEntity); + mocks.user.getAdmin.mockResolvedValue({} as UserEntity); await expect(sut.adminSignUp(dto)).rejects.toBeInstanceOf(BadRequestException); - expect(userMock.getAdmin).toHaveBeenCalled(); + expect(mocks.user.getAdmin).toHaveBeenCalled(); }); it('should sign up the admin', async () => { - userMock.getAdmin.mockResolvedValue(void 0); - userMock.create.mockResolvedValue({ + mocks.user.getAdmin.mockResolvedValue(void 0); + mocks.user.create.mockResolvedValue({ ...dto, id: 'admin', createdAt: new Date('2021-01-01'), @@ -238,8 +223,8 @@ describe('AuthService', () => { email: 'test@immich.com', name: 'immich admin', }); - expect(userMock.getAdmin).toHaveBeenCalled(); - expect(userMock.create).toHaveBeenCalled(); + expect(mocks.user.getAdmin).toHaveBeenCalled(); + expect(mocks.user.create).toHaveBeenCalled(); }); }); @@ -255,8 +240,8 @@ describe('AuthService', () => { }); it('should validate using authorization header', async () => { - userMock.get.mockResolvedValue(userStub.user1); - sessionMock.getByToken.mockResolvedValue(sessionStub.valid as any); + mocks.user.get.mockResolvedValue(userStub.user1); + mocks.session.getByToken.mockResolvedValue(sessionStub.valid as any); await expect( sut.authenticate({ headers: { authorization: 'Bearer auth_token' }, @@ -282,7 +267,7 @@ describe('AuthService', () => { }); it('should not accept an expired key', async () => { - sharedLinkMock.getByKey.mockResolvedValue(sharedLinkStub.expired); + mocks.sharedLink.getByKey.mockResolvedValue(sharedLinkStub.expired); await expect( sut.authenticate({ headers: { 'x-immich-share-key': 'key' }, @@ -293,7 +278,7 @@ describe('AuthService', () => { }); it('should not accept a key on a non-shared route', async () => { - sharedLinkMock.getByKey.mockResolvedValue(sharedLinkStub.valid); + mocks.sharedLink.getByKey.mockResolvedValue(sharedLinkStub.valid); await expect( sut.authenticate({ headers: { 'x-immich-share-key': 'key' }, @@ -304,8 +289,8 @@ describe('AuthService', () => { }); it('should not accept a key without a user', async () => { - sharedLinkMock.getByKey.mockResolvedValue(sharedLinkStub.expired); - userMock.get.mockResolvedValue(void 0); + mocks.sharedLink.getByKey.mockResolvedValue(sharedLinkStub.expired); + mocks.user.get.mockResolvedValue(void 0); await expect( sut.authenticate({ headers: { 'x-immich-share-key': 'key' }, @@ -316,8 +301,8 @@ describe('AuthService', () => { }); it('should accept a base64url key', async () => { - sharedLinkMock.getByKey.mockResolvedValue(sharedLinkStub.valid); - userMock.get.mockResolvedValue(userStub.admin); + mocks.sharedLink.getByKey.mockResolvedValue(sharedLinkStub.valid); + mocks.user.get.mockResolvedValue(userStub.admin); await expect( sut.authenticate({ headers: { 'x-immich-share-key': sharedLinkStub.valid.key.toString('base64url') }, @@ -328,12 +313,12 @@ describe('AuthService', () => { user: userStub.admin, sharedLink: sharedLinkStub.valid, }); - expect(sharedLinkMock.getByKey).toHaveBeenCalledWith(sharedLinkStub.valid.key); + expect(mocks.sharedLink.getByKey).toHaveBeenCalledWith(sharedLinkStub.valid.key); }); it('should accept a hex key', async () => { - sharedLinkMock.getByKey.mockResolvedValue(sharedLinkStub.valid); - userMock.get.mockResolvedValue(userStub.admin); + mocks.sharedLink.getByKey.mockResolvedValue(sharedLinkStub.valid); + mocks.user.get.mockResolvedValue(userStub.admin); await expect( sut.authenticate({ headers: { 'x-immich-share-key': sharedLinkStub.valid.key.toString('hex') }, @@ -344,13 +329,13 @@ describe('AuthService', () => { user: userStub.admin, sharedLink: sharedLinkStub.valid, }); - expect(sharedLinkMock.getByKey).toHaveBeenCalledWith(sharedLinkStub.valid.key); + expect(mocks.sharedLink.getByKey).toHaveBeenCalledWith(sharedLinkStub.valid.key); }); }); describe('validate - user token', () => { it('should throw if no token is found', async () => { - sessionMock.getByToken.mockResolvedValue(void 0); + mocks.session.getByToken.mockResolvedValue(void 0); await expect( sut.authenticate({ headers: { 'x-immich-user-token': 'auth_token' }, @@ -361,7 +346,7 @@ describe('AuthService', () => { }); it('should return an auth dto', async () => { - sessionMock.getByToken.mockResolvedValue(sessionStub.valid as any); + mocks.session.getByToken.mockResolvedValue(sessionStub.valid as any); await expect( sut.authenticate({ headers: { cookie: 'immich_access_token=auth_token' }, @@ -375,7 +360,7 @@ describe('AuthService', () => { }); it('should throw if admin route and not an admin', async () => { - sessionMock.getByToken.mockResolvedValue(sessionStub.valid as any); + mocks.session.getByToken.mockResolvedValue(sessionStub.valid as any); await expect( sut.authenticate({ headers: { cookie: 'immich_access_token=auth_token' }, @@ -386,8 +371,8 @@ describe('AuthService', () => { }); it('should update when access time exceeds an hour', async () => { - sessionMock.getByToken.mockResolvedValue(sessionStub.inactive as any); - sessionMock.update.mockResolvedValue(sessionStub.valid); + mocks.session.getByToken.mockResolvedValue(sessionStub.inactive as any); + mocks.session.update.mockResolvedValue(sessionStub.valid); await expect( sut.authenticate({ headers: { cookie: 'immich_access_token=auth_token' }, @@ -395,13 +380,13 @@ describe('AuthService', () => { metadata: { adminRoute: false, sharedLinkRoute: false, uri: 'test' }, }), ).resolves.toBeDefined(); - expect(sessionMock.update.mock.calls[0][1]).toMatchObject({ id: 'not_active', updatedAt: expect.any(Date) }); + expect(mocks.session.update.mock.calls[0][1]).toMatchObject({ id: 'not_active', updatedAt: expect.any(Date) }); }); }); describe('validate - api key', () => { it('should throw an error if no api key is found', async () => { - keyMock.getKey.mockResolvedValue(void 0); + mocks.apiKey.getKey.mockResolvedValue(void 0); await expect( sut.authenticate({ headers: { 'x-api-key': 'auth_token' }, @@ -409,11 +394,11 @@ describe('AuthService', () => { metadata: { adminRoute: false, sharedLinkRoute: false, uri: 'test' }, }), ).rejects.toBeInstanceOf(UnauthorizedException); - expect(keyMock.getKey).toHaveBeenCalledWith('auth_token (hashed)'); + expect(mocks.apiKey.getKey).toHaveBeenCalledWith('auth_token (hashed)'); }); it('should throw an error if api key has insufficient permissions', async () => { - keyMock.getKey.mockResolvedValue(keyStub.authKey); + mocks.apiKey.getKey.mockResolvedValue(keyStub.authKey); await expect( sut.authenticate({ headers: { 'x-api-key': 'auth_token' }, @@ -424,7 +409,7 @@ describe('AuthService', () => { }); it('should return an auth dto', async () => { - keyMock.getKey.mockResolvedValue(keyStub.authKey); + mocks.apiKey.getKey.mockResolvedValue(keyStub.authKey); await expect( sut.authenticate({ headers: { 'x-api-key': 'auth_token' }, @@ -432,7 +417,7 @@ describe('AuthService', () => { metadata: { adminRoute: false, sharedLinkRoute: false, uri: 'test' }, }), ).resolves.toEqual({ user: userStub.admin, apiKey: keyStub.authKey }); - expect(keyMock.getKey).toHaveBeenCalledWith('auth_token (hashed)'); + expect(mocks.apiKey.getKey).toHaveBeenCalledWith('auth_token (hashed)'); }); }); @@ -450,14 +435,14 @@ describe('AuthService', () => { describe('authorize', () => { it('should fail if oauth is disabled', async () => { - systemMock.get.mockResolvedValue({ oauth: { enabled: false } }); + mocks.systemMetadata.get.mockResolvedValue({ oauth: { enabled: false } }); await expect(sut.authorize({ redirectUri: 'https://demo.immich.app' })).rejects.toBeInstanceOf( BadRequestException, ); }); it('should authorize the user', async () => { - systemMock.get.mockResolvedValue(systemConfigStub.oauthWithMobileOverride); + mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithMobileOverride); await sut.authorize({ redirectUri: 'https://demo.immich.app' }); }); }); @@ -468,71 +453,71 @@ describe('AuthService', () => { }); it('should not allow auto registering', async () => { - systemMock.get.mockResolvedValue(systemConfigStub.oauthEnabled); - userMock.getByEmail.mockResolvedValue(void 0); + mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthEnabled); + mocks.user.getByEmail.mockResolvedValue(void 0); await expect(sut.callback({ url: 'http://immich/auth/login?code=abc123' }, loginDetails)).rejects.toBeInstanceOf( BadRequestException, ); - expect(userMock.getByEmail).toHaveBeenCalledTimes(1); + expect(mocks.user.getByEmail).toHaveBeenCalledTimes(1); }); it('should link an existing user', async () => { - systemMock.get.mockResolvedValue(systemConfigStub.oauthEnabled); - userMock.getByEmail.mockResolvedValue(userStub.user1); - userMock.update.mockResolvedValue(userStub.user1); - sessionMock.create.mockResolvedValue(sessionStub.valid); + mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthEnabled); + mocks.user.getByEmail.mockResolvedValue(userStub.user1); + mocks.user.update.mockResolvedValue(userStub.user1); + mocks.session.create.mockResolvedValue(sessionStub.valid); await expect(sut.callback({ url: 'http://immich/auth/login?code=abc123' }, loginDetails)).resolves.toEqual( oauthResponse, ); - expect(userMock.getByEmail).toHaveBeenCalledTimes(1); - expect(userMock.update).toHaveBeenCalledWith(userStub.user1.id, { oauthId: sub }); + expect(mocks.user.getByEmail).toHaveBeenCalledTimes(1); + expect(mocks.user.update).toHaveBeenCalledWith(userStub.user1.id, { oauthId: sub }); }); it('should not link to a user with a different oauth sub', async () => { - systemMock.get.mockResolvedValue(systemConfigStub.oauthWithAutoRegister); - userMock.getByEmail.mockResolvedValueOnce({ ...userStub.user1, oauthId: 'existing-sub' }); - userMock.getAdmin.mockResolvedValue(userStub.user1); - userMock.create.mockResolvedValue(userStub.user1); + mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithAutoRegister); + mocks.user.getByEmail.mockResolvedValueOnce({ ...userStub.user1, oauthId: 'existing-sub' }); + mocks.user.getAdmin.mockResolvedValue(userStub.user1); + mocks.user.create.mockResolvedValue(userStub.user1); await expect(sut.callback({ url: 'http://immich/auth/login?code=abc123' }, loginDetails)).rejects.toThrow( BadRequestException, ); - expect(userMock.update).not.toHaveBeenCalled(); - expect(userMock.create).not.toHaveBeenCalled(); + expect(mocks.user.update).not.toHaveBeenCalled(); + expect(mocks.user.create).not.toHaveBeenCalled(); }); it('should allow auto registering by default', async () => { - systemMock.get.mockResolvedValue(systemConfigStub.enabled); - userMock.getByEmail.mockResolvedValue(void 0); - userMock.getAdmin.mockResolvedValue(userStub.user1); - userMock.create.mockResolvedValue(userStub.user1); - sessionMock.create.mockResolvedValue(sessionStub.valid); + mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.enabled); + mocks.user.getByEmail.mockResolvedValue(void 0); + mocks.user.getAdmin.mockResolvedValue(userStub.user1); + mocks.user.create.mockResolvedValue(userStub.user1); + mocks.session.create.mockResolvedValue(sessionStub.valid); await expect(sut.callback({ url: 'http://immich/auth/login?code=abc123' }, loginDetails)).resolves.toEqual( oauthResponse, ); - expect(userMock.getByEmail).toHaveBeenCalledTimes(2); // second call is for domain check before create - expect(userMock.create).toHaveBeenCalledTimes(1); + expect(mocks.user.getByEmail).toHaveBeenCalledTimes(2); // second call is for domain check before create + expect(mocks.user.create).toHaveBeenCalledTimes(1); }); it('should throw an error if user should be auto registered but the email claim does not exist', async () => { - systemMock.get.mockResolvedValue(systemConfigStub.enabled); - userMock.getByEmail.mockResolvedValue(void 0); - userMock.getAdmin.mockResolvedValue(userStub.user1); - userMock.create.mockResolvedValue(userStub.user1); - sessionMock.create.mockResolvedValue(sessionStub.valid); - oauthMock.getProfile.mockResolvedValue({ sub, email: undefined }); + mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.enabled); + mocks.user.getByEmail.mockResolvedValue(void 0); + mocks.user.getAdmin.mockResolvedValue(userStub.user1); + mocks.user.create.mockResolvedValue(userStub.user1); + mocks.session.create.mockResolvedValue(sessionStub.valid); + mocks.oauth.getProfile.mockResolvedValue({ sub, email: undefined }); await expect(sut.callback({ url: 'http://immich/auth/login?code=abc123' }, loginDetails)).rejects.toBeInstanceOf( BadRequestException, ); - expect(userMock.getByEmail).not.toHaveBeenCalled(); - expect(userMock.create).not.toHaveBeenCalled(); + expect(mocks.user.getByEmail).not.toHaveBeenCalled(); + expect(mocks.user.create).not.toHaveBeenCalled(); }); for (const url of [ @@ -544,68 +529,68 @@ describe('AuthService', () => { 'app.immich:///oauth-callback?code=abc123', ]) { it(`should use the mobile redirect override for a url of ${url}`, async () => { - systemMock.get.mockResolvedValue(systemConfigStub.oauthWithMobileOverride); - userMock.getByOAuthId.mockResolvedValue(userStub.user1); - sessionMock.create.mockResolvedValue(sessionStub.valid); + mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithMobileOverride); + mocks.user.getByOAuthId.mockResolvedValue(userStub.user1); + mocks.session.create.mockResolvedValue(sessionStub.valid); await sut.callback({ url }, loginDetails); - expect(oauthMock.getProfile).toHaveBeenCalledWith(expect.objectContaining({}), url, 'http://mobile-redirect'); + expect(mocks.oauth.getProfile).toHaveBeenCalledWith(expect.objectContaining({}), url, 'http://mobile-redirect'); }); } it('should use the default quota', async () => { - systemMock.get.mockResolvedValue(systemConfigStub.oauthWithStorageQuota); - userMock.getByEmail.mockResolvedValue(void 0); - userMock.getAdmin.mockResolvedValue(userStub.user1); - userMock.create.mockResolvedValue(userStub.user1); + mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithStorageQuota); + mocks.user.getByEmail.mockResolvedValue(void 0); + mocks.user.getAdmin.mockResolvedValue(userStub.user1); + mocks.user.create.mockResolvedValue(userStub.user1); await expect(sut.callback({ url: 'http://immich/auth/login?code=abc123' }, loginDetails)).resolves.toEqual( oauthResponse, ); - expect(userMock.create).toHaveBeenCalledWith({ ...oauthUserWithDefaultQuota, quotaSizeInBytes: 1_073_741_824 }); + expect(mocks.user.create).toHaveBeenCalledWith({ ...oauthUserWithDefaultQuota, quotaSizeInBytes: 1_073_741_824 }); }); it('should ignore an invalid storage quota', async () => { - systemMock.get.mockResolvedValue(systemConfigStub.oauthWithStorageQuota); - userMock.getByEmail.mockResolvedValue(void 0); - userMock.getAdmin.mockResolvedValue(userStub.user1); - userMock.create.mockResolvedValue(userStub.user1); - oauthMock.getProfile.mockResolvedValue({ sub, email, immich_quota: 'abc' }); + mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithStorageQuota); + mocks.user.getByEmail.mockResolvedValue(void 0); + mocks.user.getAdmin.mockResolvedValue(userStub.user1); + mocks.user.create.mockResolvedValue(userStub.user1); + mocks.oauth.getProfile.mockResolvedValue({ sub, email, immich_quota: 'abc' }); await expect(sut.callback({ url: 'http://immich/auth/login?code=abc123' }, loginDetails)).resolves.toEqual( oauthResponse, ); - expect(userMock.create).toHaveBeenCalledWith({ ...oauthUserWithDefaultQuota, quotaSizeInBytes: 1_073_741_824 }); + expect(mocks.user.create).toHaveBeenCalledWith({ ...oauthUserWithDefaultQuota, quotaSizeInBytes: 1_073_741_824 }); }); it('should ignore a negative quota', async () => { - systemMock.get.mockResolvedValue(systemConfigStub.oauthWithStorageQuota); - userMock.getByEmail.mockResolvedValue(void 0); - userMock.getAdmin.mockResolvedValue(userStub.user1); - userMock.create.mockResolvedValue(userStub.user1); - oauthMock.getProfile.mockResolvedValue({ sub, email, immich_quota: -5 }); + mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithStorageQuota); + mocks.user.getByEmail.mockResolvedValue(void 0); + mocks.user.getAdmin.mockResolvedValue(userStub.user1); + mocks.user.create.mockResolvedValue(userStub.user1); + mocks.oauth.getProfile.mockResolvedValue({ sub, email, immich_quota: -5 }); await expect(sut.callback({ url: 'http://immich/auth/login?code=abc123' }, loginDetails)).resolves.toEqual( oauthResponse, ); - expect(userMock.create).toHaveBeenCalledWith({ ...oauthUserWithDefaultQuota, quotaSizeInBytes: 1_073_741_824 }); + expect(mocks.user.create).toHaveBeenCalledWith({ ...oauthUserWithDefaultQuota, quotaSizeInBytes: 1_073_741_824 }); }); it('should not set quota for 0 quota', async () => { - systemMock.get.mockResolvedValue(systemConfigStub.oauthWithStorageQuota); - userMock.getByEmail.mockResolvedValue(void 0); - userMock.getAdmin.mockResolvedValue(userStub.user1); - userMock.create.mockResolvedValue(userStub.user1); - oauthMock.getProfile.mockResolvedValue({ sub, email, immich_quota: 0 }); + mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithStorageQuota); + mocks.user.getByEmail.mockResolvedValue(void 0); + mocks.user.getAdmin.mockResolvedValue(userStub.user1); + mocks.user.create.mockResolvedValue(userStub.user1); + mocks.oauth.getProfile.mockResolvedValue({ sub, email, immich_quota: 0 }); await expect(sut.callback({ url: 'http://immich/auth/login?code=abc123' }, loginDetails)).resolves.toEqual( oauthResponse, ); - expect(userMock.create).toHaveBeenCalledWith({ + expect(mocks.user.create).toHaveBeenCalledWith({ email, name: ' ', oauthId: sub, @@ -615,17 +600,17 @@ describe('AuthService', () => { }); it('should use a valid storage quota', async () => { - systemMock.get.mockResolvedValue(systemConfigStub.oauthWithStorageQuota); - userMock.getByEmail.mockResolvedValue(void 0); - userMock.getAdmin.mockResolvedValue(userStub.user1); - userMock.create.mockResolvedValue(userStub.user1); - oauthMock.getProfile.mockResolvedValue({ sub, email, immich_quota: 5 }); + mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithStorageQuota); + mocks.user.getByEmail.mockResolvedValue(void 0); + mocks.user.getAdmin.mockResolvedValue(userStub.user1); + mocks.user.create.mockResolvedValue(userStub.user1); + mocks.oauth.getProfile.mockResolvedValue({ sub, email, immich_quota: 5 }); await expect(sut.callback({ url: 'http://immich/auth/login?code=abc123' }, loginDetails)).resolves.toEqual( oauthResponse, ); - expect(userMock.create).toHaveBeenCalledWith({ + expect(mocks.user.create).toHaveBeenCalledWith({ email, name: ' ', oauthId: sub, @@ -637,34 +622,34 @@ describe('AuthService', () => { describe('link', () => { it('should link an account', async () => { - systemMock.get.mockResolvedValue(systemConfigStub.enabled); - userMock.update.mockResolvedValue(userStub.user1); + mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.enabled); + mocks.user.update.mockResolvedValue(userStub.user1); await sut.link(authStub.user1, { url: 'http://immich/user-settings?code=abc123' }); - expect(userMock.update).toHaveBeenCalledWith(authStub.user1.user.id, { oauthId: sub }); + expect(mocks.user.update).toHaveBeenCalledWith(authStub.user1.user.id, { oauthId: sub }); }); it('should not link an already linked oauth.sub', async () => { - systemMock.get.mockResolvedValue(systemConfigStub.enabled); - userMock.getByOAuthId.mockResolvedValue({ id: 'other-user' } as UserEntity); + mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.enabled); + mocks.user.getByOAuthId.mockResolvedValue({ id: 'other-user' } as UserEntity); await expect(sut.link(authStub.user1, { url: 'http://immich/user-settings?code=abc123' })).rejects.toBeInstanceOf( BadRequestException, ); - expect(userMock.update).not.toHaveBeenCalled(); + expect(mocks.user.update).not.toHaveBeenCalled(); }); }); describe('unlink', () => { it('should unlink an account', async () => { - systemMock.get.mockResolvedValue(systemConfigStub.enabled); - userMock.update.mockResolvedValue(userStub.user1); + mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.enabled); + mocks.user.update.mockResolvedValue(userStub.user1); await sut.unlink(authStub.user1); - expect(userMock.update).toHaveBeenCalledWith(authStub.user1.user.id, { oauthId: '' }); + expect(mocks.user.update).toHaveBeenCalledWith(authStub.user1.user.id, { oauthId: '' }); }); }); }); diff --git a/server/src/services/backup.service.spec.ts b/server/src/services/backup.service.spec.ts index 7b8454f61e..fbed87a6d3 100644 --- a/server/src/services/backup.service.spec.ts +++ b/server/src/services/backup.service.spec.ts @@ -2,27 +2,18 @@ import { PassThrough } from 'node:stream'; import { defaults, SystemConfig } from 'src/config'; import { StorageCore } from 'src/cores/storage.core'; import { ImmichWorker, StorageFolder } from 'src/enum'; -import { IDatabaseRepository } from 'src/interfaces/database.interface'; import { JobStatus } from 'src/interfaces/job.interface'; -import { IStorageRepository } from 'src/interfaces/storage.interface'; import { BackupService } from 'src/services/backup.service'; -import { IConfigRepository, ICronRepository, IProcessRepository, ISystemMetadataRepository } from 'src/types'; import { systemConfigStub } from 'test/fixtures/system-config.stub'; -import { mockSpawn, newTestService } from 'test/utils'; -import { describe, Mocked } from 'vitest'; +import { mockSpawn, newTestService, ServiceMocks } from 'test/utils'; +import { describe } from 'vitest'; describe(BackupService.name, () => { let sut: BackupService; - - let databaseMock: Mocked; - let configMock: Mocked; - let cronMock: Mocked; - let processMock: Mocked; - let storageMock: Mocked; - let systemMock: Mocked; + let mocks: ServiceMocks; beforeEach(() => { - ({ sut, cronMock, configMock, databaseMock, processMock, storageMock, systemMock } = newTestService(BackupService)); + ({ sut, mocks } = newTestService(BackupService)); }); it('should work', () => { @@ -31,32 +22,32 @@ describe(BackupService.name, () => { describe('onBootstrapEvent', () => { it('should init cron job and handle config changes', async () => { - databaseMock.tryLock.mockResolvedValue(true); + mocks.database.tryLock.mockResolvedValue(true); await sut.onConfigInit({ newConfig: systemConfigStub.backupEnabled as SystemConfig }); - expect(cronMock.create).toHaveBeenCalled(); + expect(mocks.cron.create).toHaveBeenCalled(); }); it('should not initialize backup database cron job when lock is taken', async () => { - databaseMock.tryLock.mockResolvedValue(false); + mocks.database.tryLock.mockResolvedValue(false); await sut.onConfigInit({ newConfig: systemConfigStub.backupEnabled as SystemConfig }); - expect(cronMock.create).not.toHaveBeenCalled(); + expect(mocks.cron.create).not.toHaveBeenCalled(); }); it('should not initialise backup database job when running on microservices', async () => { - configMock.getWorker.mockReturnValue(ImmichWorker.MICROSERVICES); + mocks.config.getWorker.mockReturnValue(ImmichWorker.MICROSERVICES); await sut.onConfigInit({ newConfig: systemConfigStub.backupEnabled as SystemConfig }); - expect(cronMock.create).not.toHaveBeenCalled(); + expect(mocks.cron.create).not.toHaveBeenCalled(); }); }); describe('onConfigUpdateEvent', () => { beforeEach(async () => { - databaseMock.tryLock.mockResolvedValue(true); + mocks.database.tryLock.mockResolvedValue(true); await sut.onConfigInit({ newConfig: defaults }); }); @@ -73,66 +64,66 @@ describe(BackupService.name, () => { } as SystemConfig, }); - expect(cronMock.update).toHaveBeenCalledWith({ name: 'backupDatabase', expression: '0 1 * * *', start: true }); - expect(cronMock.update).toHaveBeenCalled(); + expect(mocks.cron.update).toHaveBeenCalledWith({ name: 'backupDatabase', expression: '0 1 * * *', start: true }); + expect(mocks.cron.update).toHaveBeenCalled(); }); it('should do nothing if instance does not have the backup database lock', async () => { - databaseMock.tryLock.mockResolvedValue(false); + mocks.database.tryLock.mockResolvedValue(false); await sut.onConfigInit({ newConfig: defaults }); sut.onConfigUpdate({ newConfig: systemConfigStub.backupEnabled as SystemConfig, oldConfig: defaults }); - expect(cronMock.update).not.toHaveBeenCalled(); + expect(mocks.cron.update).not.toHaveBeenCalled(); }); }); describe('cleanupDatabaseBackups', () => { it('should do nothing if not reached keepLastAmount', async () => { - systemMock.get.mockResolvedValue(systemConfigStub.backupEnabled); - storageMock.readdir.mockResolvedValue(['immich-db-backup-1.sql.gz']); + mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.backupEnabled); + mocks.storage.readdir.mockResolvedValue(['immich-db-backup-1.sql.gz']); await sut.cleanupDatabaseBackups(); - expect(storageMock.unlink).not.toHaveBeenCalled(); + expect(mocks.storage.unlink).not.toHaveBeenCalled(); }); it('should remove failed backup files', async () => { - systemMock.get.mockResolvedValue(systemConfigStub.backupEnabled); - storageMock.readdir.mockResolvedValue([ + mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.backupEnabled); + mocks.storage.readdir.mockResolvedValue([ 'immich-db-backup-123.sql.gz.tmp', 'immich-db-backup-234.sql.gz', 'immich-db-backup-345.sql.gz.tmp', ]); await sut.cleanupDatabaseBackups(); - expect(storageMock.unlink).toHaveBeenCalledTimes(2); - expect(storageMock.unlink).toHaveBeenCalledWith( + expect(mocks.storage.unlink).toHaveBeenCalledTimes(2); + expect(mocks.storage.unlink).toHaveBeenCalledWith( `${StorageCore.getBaseFolder(StorageFolder.BACKUPS)}/immich-db-backup-123.sql.gz.tmp`, ); - expect(storageMock.unlink).toHaveBeenCalledWith( + expect(mocks.storage.unlink).toHaveBeenCalledWith( `${StorageCore.getBaseFolder(StorageFolder.BACKUPS)}/immich-db-backup-345.sql.gz.tmp`, ); }); it('should remove old backup files over keepLastAmount', async () => { - systemMock.get.mockResolvedValue(systemConfigStub.backupEnabled); - storageMock.readdir.mockResolvedValue(['immich-db-backup-1.sql.gz', 'immich-db-backup-2.sql.gz']); + mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.backupEnabled); + mocks.storage.readdir.mockResolvedValue(['immich-db-backup-1.sql.gz', 'immich-db-backup-2.sql.gz']); await sut.cleanupDatabaseBackups(); - expect(storageMock.unlink).toHaveBeenCalledTimes(1); - expect(storageMock.unlink).toHaveBeenCalledWith( + expect(mocks.storage.unlink).toHaveBeenCalledTimes(1); + expect(mocks.storage.unlink).toHaveBeenCalledWith( `${StorageCore.getBaseFolder(StorageFolder.BACKUPS)}/immich-db-backup-1.sql.gz`, ); }); it('should remove old backup files over keepLastAmount and failed backups', async () => { - systemMock.get.mockResolvedValue(systemConfigStub.backupEnabled); - storageMock.readdir.mockResolvedValue([ + mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.backupEnabled); + mocks.storage.readdir.mockResolvedValue([ 'immich-db-backup-1.sql.gz.tmp', 'immich-db-backup-2.sql.gz', 'immich-db-backup-3.sql.gz', ]); await sut.cleanupDatabaseBackups(); - expect(storageMock.unlink).toHaveBeenCalledTimes(2); - expect(storageMock.unlink).toHaveBeenCalledWith( + expect(mocks.storage.unlink).toHaveBeenCalledTimes(2); + expect(mocks.storage.unlink).toHaveBeenCalledWith( `${StorageCore.getBaseFolder(StorageFolder.BACKUPS)}/immich-db-backup-1.sql.gz.tmp`, ); - expect(storageMock.unlink).toHaveBeenCalledWith( + expect(mocks.storage.unlink).toHaveBeenCalledWith( `${StorageCore.getBaseFolder(StorageFolder.BACKUPS)}/immich-db-backup-2.sql.gz`, ); }); @@ -140,57 +131,57 @@ describe(BackupService.name, () => { describe('handleBackupDatabase', () => { beforeEach(() => { - storageMock.readdir.mockResolvedValue([]); - processMock.spawn.mockReturnValue(mockSpawn(0, 'data', '')); - storageMock.rename.mockResolvedValue(); - storageMock.unlink.mockResolvedValue(); - systemMock.get.mockResolvedValue(systemConfigStub.backupEnabled); - storageMock.createWriteStream.mockReturnValue(new PassThrough()); + 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()); }); it('should run a database backup successfully', async () => { const result = await sut.handleBackupDatabase(); expect(result).toBe(JobStatus.SUCCESS); - expect(storageMock.createWriteStream).toHaveBeenCalled(); + expect(mocks.storage.createWriteStream).toHaveBeenCalled(); }); it('should rename file on success', async () => { const result = await sut.handleBackupDatabase(); expect(result).toBe(JobStatus.SUCCESS); - expect(storageMock.rename).toHaveBeenCalled(); + expect(mocks.storage.rename).toHaveBeenCalled(); }); it('should fail if pg_dumpall fails', async () => { - processMock.spawn.mockReturnValueOnce(mockSpawn(1, '', 'error')); + mocks.process.spawn.mockReturnValueOnce(mockSpawn(1, '', 'error')); const result = await sut.handleBackupDatabase(); expect(result).toBe(JobStatus.FAILED); }); it('should not rename file if pgdump fails and gzip succeeds', async () => { - processMock.spawn.mockReturnValueOnce(mockSpawn(1, '', 'error')); + mocks.process.spawn.mockReturnValueOnce(mockSpawn(1, '', 'error')); const result = await sut.handleBackupDatabase(); expect(result).toBe(JobStatus.FAILED); - expect(storageMock.rename).not.toHaveBeenCalled(); + expect(mocks.storage.rename).not.toHaveBeenCalled(); }); it('should fail if gzip fails', async () => { - processMock.spawn.mockReturnValueOnce(mockSpawn(0, 'data', '')); - processMock.spawn.mockReturnValueOnce(mockSpawn(1, '', 'error')); + mocks.process.spawn.mockReturnValueOnce(mockSpawn(0, 'data', '')); + mocks.process.spawn.mockReturnValueOnce(mockSpawn(1, '', 'error')); const result = await sut.handleBackupDatabase(); expect(result).toBe(JobStatus.FAILED); }); it('should fail if write stream fails', async () => { - storageMock.createWriteStream.mockImplementation(() => { + mocks.storage.createWriteStream.mockImplementation(() => { throw new Error('error'); }); const result = await sut.handleBackupDatabase(); expect(result).toBe(JobStatus.FAILED); }); it('should fail if rename fails', async () => { - storageMock.rename.mockRejectedValue(new Error('error')); + mocks.storage.rename.mockRejectedValue(new Error('error')); const result = await sut.handleBackupDatabase(); expect(result).toBe(JobStatus.FAILED); }); it('should ignore unlink failing and still return failed job status', async () => { - processMock.spawn.mockReturnValueOnce(mockSpawn(1, '', 'error')); - storageMock.unlink.mockRejectedValue(new Error('error')); + mocks.process.spawn.mockReturnValueOnce(mockSpawn(1, '', 'error')); + mocks.storage.unlink.mockRejectedValue(new Error('error')); const result = await sut.handleBackupDatabase(); - expect(storageMock.unlink).toHaveBeenCalled(); + expect(mocks.storage.unlink).toHaveBeenCalled(); expect(result).toBe(JobStatus.FAILED); }); it.each` @@ -204,9 +195,9 @@ describe(BackupService.name, () => { `( `should use pg_dumpall $expectedVersion with postgres version $postgresVersion`, async ({ postgresVersion, expectedVersion }) => { - databaseMock.getPostgresVersion.mockResolvedValue(postgresVersion); + mocks.database.getPostgresVersion.mockResolvedValue(postgresVersion); await sut.handleBackupDatabase(); - expect(processMock.spawn).toHaveBeenCalledWith( + expect(mocks.process.spawn).toHaveBeenCalledWith( `/usr/lib/postgresql/${expectedVersion}/bin/pg_dumpall`, expect.any(Array), expect.any(Object), @@ -218,9 +209,9 @@ describe(BackupService.name, () => { ${'13.99.99'} ${'18.0.0'} `(`should fail if postgres version $postgresVersion is not supported`, async ({ postgresVersion }) => { - databaseMock.getPostgresVersion.mockResolvedValue(postgresVersion); + mocks.database.getPostgresVersion.mockResolvedValue(postgresVersion); const result = await sut.handleBackupDatabase(); - expect(processMock.spawn).not.toHaveBeenCalled(); + expect(mocks.process.spawn).not.toHaveBeenCalled(); expect(result).toBe(JobStatus.FAILED); }); }); diff --git a/server/src/services/base.service.ts b/server/src/services/base.service.ts index 8a690b752e..9285c69ced 100644 --- a/server/src/services/base.service.ts +++ b/server/src/services/base.service.ts @@ -30,6 +30,7 @@ import { ApiKeyRepository } from 'src/repositories/api-key.repository'; import { AuditRepository } from 'src/repositories/audit.repository'; import { ConfigRepository } from 'src/repositories/config.repository'; import { CronRepository } from 'src/repositories/cron.repository'; +import { CryptoRepository } from 'src/repositories/crypto.repository'; import { LoggingRepository } from 'src/repositories/logging.repository'; import { MapRepository } from 'src/repositories/map.repository'; import { MediaRepository } from 'src/repositories/media.repository'; @@ -61,7 +62,7 @@ export class BaseService { @Inject(IAssetRepository) protected assetRepository: IAssetRepository, protected configRepository: ConfigRepository, protected cronRepository: CronRepository, - @Inject(ICryptoRepository) protected cryptoRepository: ICryptoRepository, + @Inject(ICryptoRepository) protected cryptoRepository: CryptoRepository, @Inject(IDatabaseRepository) protected databaseRepository: IDatabaseRepository, @Inject(IEventRepository) protected eventRepository: IEventRepository, @Inject(IJobRepository) protected jobRepository: IJobRepository, diff --git a/server/src/services/cli.service.spec.ts b/server/src/services/cli.service.spec.ts index 987af3a287..c585142cbf 100644 --- a/server/src/services/cli.service.spec.ts +++ b/server/src/services/cli.service.spec.ts @@ -1,31 +1,27 @@ -import { IUserRepository } from 'src/interfaces/user.interface'; import { CliService } from 'src/services/cli.service'; -import { ISystemMetadataRepository } from 'src/types'; import { userStub } from 'test/fixtures/user.stub'; -import { newTestService } from 'test/utils'; -import { Mocked, describe, it } from 'vitest'; +import { newTestService, ServiceMocks } from 'test/utils'; +import { describe, it } from 'vitest'; describe(CliService.name, () => { let sut: CliService; - - let userMock: Mocked; - let systemMock: Mocked; + let mocks: ServiceMocks; beforeEach(() => { - ({ sut, userMock, systemMock } = newTestService(CliService)); + ({ sut, mocks } = newTestService(CliService)); }); describe('listUsers', () => { it('should list users', async () => { - userMock.getList.mockResolvedValue([userStub.admin]); + mocks.user.getList.mockResolvedValue([userStub.admin]); await expect(sut.listUsers()).resolves.toEqual([expect.objectContaining({ isAdmin: true })]); - expect(userMock.getList).toHaveBeenCalledWith({ withDeleted: true }); + expect(mocks.user.getList).toHaveBeenCalledWith({ withDeleted: true }); }); }); describe('resetAdminPassword', () => { it('should only work when there is an admin account', async () => { - userMock.getAdmin.mockResolvedValue(void 0); + mocks.user.getAdmin.mockResolvedValue(void 0); const ask = vitest.fn().mockResolvedValue('new-password'); await expect(sut.resetAdminPassword(ask)).rejects.toThrowError('Admin account does not exist'); @@ -34,12 +30,12 @@ describe(CliService.name, () => { }); it('should default to a random password', async () => { - userMock.getAdmin.mockResolvedValue(userStub.admin); + mocks.user.getAdmin.mockResolvedValue(userStub.admin); const ask = vitest.fn().mockImplementation(() => {}); const response = await sut.resetAdminPassword(ask); - const [id, update] = userMock.update.mock.calls[0]; + const [id, update] = mocks.user.update.mock.calls[0]; expect(response.provided).toBe(false); expect(ask).toHaveBeenCalled(); @@ -48,12 +44,12 @@ describe(CliService.name, () => { }); it('should use the supplied password', async () => { - userMock.getAdmin.mockResolvedValue(userStub.admin); + mocks.user.getAdmin.mockResolvedValue(userStub.admin); const ask = vitest.fn().mockResolvedValue('new-password'); const response = await sut.resetAdminPassword(ask); - const [id, update] = userMock.update.mock.calls[0]; + const [id, update] = mocks.user.update.mock.calls[0]; expect(response.provided).toBe(true); expect(ask).toHaveBeenCalled(); @@ -65,28 +61,28 @@ describe(CliService.name, () => { describe('disablePasswordLogin', () => { it('should disable password login', async () => { await sut.disablePasswordLogin(); - expect(systemMock.set).toHaveBeenCalledWith('system-config', { passwordLogin: { enabled: false } }); + expect(mocks.systemMetadata.set).toHaveBeenCalledWith('system-config', { passwordLogin: { enabled: false } }); }); }); describe('enablePasswordLogin', () => { it('should enable password login', async () => { await sut.enablePasswordLogin(); - expect(systemMock.set).toHaveBeenCalledWith('system-config', {}); + expect(mocks.systemMetadata.set).toHaveBeenCalledWith('system-config', {}); }); }); describe('disableOAuthLogin', () => { it('should disable oauth login', async () => { await sut.disableOAuthLogin(); - expect(systemMock.set).toHaveBeenCalledWith('system-config', {}); + expect(mocks.systemMetadata.set).toHaveBeenCalledWith('system-config', {}); }); }); describe('enableOAuthLogin', () => { it('should enable oauth login', async () => { await sut.enableOAuthLogin(); - expect(systemMock.set).toHaveBeenCalledWith('system-config', { oauth: { enabled: true } }); + expect(mocks.systemMetadata.set).toHaveBeenCalledWith('system-config', { oauth: { enabled: true } }); }); }); }); diff --git a/server/src/services/database.service.spec.ts b/server/src/services/database.service.spec.ts index edd2f9dc62..566cd32778 100644 --- a/server/src/services/database.service.spec.ts +++ b/server/src/services/database.service.spec.ts @@ -1,21 +1,12 @@ -import { - DatabaseExtension, - EXTENSION_NAMES, - IDatabaseRepository, - VectorExtension, -} from 'src/interfaces/database.interface'; +import { DatabaseExtension, EXTENSION_NAMES, VectorExtension } from 'src/interfaces/database.interface'; import { DatabaseService } from 'src/services/database.service'; -import { IConfigRepository, ILoggingRepository } from 'src/types'; import { mockEnvData } from 'test/repositories/config.repository.mock'; -import { newTestService } from 'test/utils'; -import { Mocked } from 'vitest'; +import { newTestService, ServiceMocks } from 'test/utils'; describe(DatabaseService.name, () => { let sut: DatabaseService; + let mocks: ServiceMocks; - let configMock: Mocked; - let databaseMock: Mocked; - let loggerMock: Mocked; let extensionRange: string; let versionBelowRange: string; let minVersionInRange: string; @@ -23,16 +14,16 @@ describe(DatabaseService.name, () => { let versionAboveRange: string; beforeEach(() => { - ({ sut, configMock, databaseMock, loggerMock } = newTestService(DatabaseService)); + ({ sut, mocks } = newTestService(DatabaseService)); extensionRange = '0.2.x'; - databaseMock.getExtensionVersionRange.mockReturnValue(extensionRange); + mocks.database.getExtensionVersionRange.mockReturnValue(extensionRange); versionBelowRange = '0.1.0'; minVersionInRange = '0.2.0'; updateInRange = '0.2.1'; versionAboveRange = '0.3.0'; - databaseMock.getExtensionVersion.mockResolvedValue({ + mocks.database.getExtensionVersion.mockResolvedValue({ installedVersion: minVersionInRange, availableVersion: minVersionInRange, }); @@ -44,11 +35,11 @@ describe(DatabaseService.name, () => { describe('onBootstrap', () => { it('should throw an error if PostgreSQL version is below minimum supported version', async () => { - databaseMock.getPostgresVersion.mockResolvedValueOnce('13.10.0'); + mocks.database.getPostgresVersion.mockResolvedValueOnce('13.10.0'); await expect(sut.onBootstrap()).rejects.toThrow('Invalid PostgreSQL version. Found 13.10.0'); - expect(databaseMock.getPostgresVersion).toHaveBeenCalledTimes(1); + expect(mocks.database.getPostgresVersion).toHaveBeenCalledTimes(1); }); describe.each(>[ @@ -56,7 +47,7 @@ describe(DatabaseService.name, () => { { extension: DatabaseExtension.VECTORS, extensionName: EXTENSION_NAMES[DatabaseExtension.VECTORS] }, ])('should work with $extensionName', ({ extension, extensionName }) => { beforeEach(() => { - configMock.getEnv.mockReturnValue( + mocks.config.getEnv.mockReturnValue( mockEnvData({ database: { config: { @@ -85,34 +76,34 @@ describe(DatabaseService.name, () => { }); it(`should start up successfully with ${extension}`, async () => { - databaseMock.getPostgresVersion.mockResolvedValue('14.0.0'); - databaseMock.getExtensionVersion.mockResolvedValue({ + mocks.database.getPostgresVersion.mockResolvedValue('14.0.0'); + mocks.database.getExtensionVersion.mockResolvedValue({ installedVersion: null, availableVersion: minVersionInRange, }); await expect(sut.onBootstrap()).resolves.toBeUndefined(); - expect(databaseMock.getPostgresVersion).toHaveBeenCalled(); - expect(databaseMock.createExtension).toHaveBeenCalledWith(extension); - expect(databaseMock.createExtension).toHaveBeenCalledTimes(1); - expect(databaseMock.getExtensionVersion).toHaveBeenCalled(); - expect(databaseMock.runMigrations).toHaveBeenCalledTimes(1); - expect(loggerMock.fatal).not.toHaveBeenCalled(); + expect(mocks.database.getPostgresVersion).toHaveBeenCalled(); + expect(mocks.database.createExtension).toHaveBeenCalledWith(extension); + expect(mocks.database.createExtension).toHaveBeenCalledTimes(1); + expect(mocks.database.getExtensionVersion).toHaveBeenCalled(); + expect(mocks.database.runMigrations).toHaveBeenCalledTimes(1); + expect(mocks.logger.fatal).not.toHaveBeenCalled(); }); it(`should throw an error if the ${extension} extension is not installed`, async () => { - databaseMock.getExtensionVersion.mockResolvedValue({ installedVersion: null, availableVersion: null }); + mocks.database.getExtensionVersion.mockResolvedValue({ installedVersion: null, availableVersion: null }); const message = `The ${extensionName} extension is not available in this Postgres instance. If using a container image, ensure the image has the extension installed.`; await expect(sut.onBootstrap()).rejects.toThrow(message); - expect(databaseMock.createExtension).not.toHaveBeenCalled(); - expect(databaseMock.runMigrations).not.toHaveBeenCalled(); + expect(mocks.database.createExtension).not.toHaveBeenCalled(); + expect(mocks.database.runMigrations).not.toHaveBeenCalled(); }); it(`should throw an error if the ${extension} extension version is below minimum supported version`, async () => { - databaseMock.getExtensionVersion.mockResolvedValue({ + mocks.database.getExtensionVersion.mockResolvedValue({ installedVersion: versionBelowRange, availableVersion: versionBelowRange, }); @@ -121,80 +112,80 @@ describe(DatabaseService.name, () => { `The ${extensionName} extension version is ${versionBelowRange}, but Immich only supports ${extensionRange}`, ); - expect(databaseMock.runMigrations).not.toHaveBeenCalled(); + expect(mocks.database.runMigrations).not.toHaveBeenCalled(); }); it(`should throw an error if ${extension} extension version is a nightly`, async () => { - databaseMock.getExtensionVersion.mockResolvedValue({ installedVersion: '0.0.0', availableVersion: '0.0.0' }); + mocks.database.getExtensionVersion.mockResolvedValue({ installedVersion: '0.0.0', availableVersion: '0.0.0' }); await expect(sut.onBootstrap()).rejects.toThrow( `The ${extensionName} extension version is 0.0.0, which means it is a nightly release.`, ); - expect(databaseMock.createExtension).not.toHaveBeenCalled(); - expect(databaseMock.updateVectorExtension).not.toHaveBeenCalled(); - expect(databaseMock.runMigrations).not.toHaveBeenCalled(); + expect(mocks.database.createExtension).not.toHaveBeenCalled(); + expect(mocks.database.updateVectorExtension).not.toHaveBeenCalled(); + expect(mocks.database.runMigrations).not.toHaveBeenCalled(); }); it(`should do in-range update for ${extension} extension`, async () => { - databaseMock.getExtensionVersion.mockResolvedValue({ + mocks.database.getExtensionVersion.mockResolvedValue({ availableVersion: updateInRange, installedVersion: minVersionInRange, }); - databaseMock.updateVectorExtension.mockResolvedValue({ restartRequired: false }); + mocks.database.updateVectorExtension.mockResolvedValue({ restartRequired: false }); await expect(sut.onBootstrap()).resolves.toBeUndefined(); - expect(databaseMock.updateVectorExtension).toHaveBeenCalledWith(extension, updateInRange); - expect(databaseMock.updateVectorExtension).toHaveBeenCalledTimes(1); - expect(databaseMock.getExtensionVersion).toHaveBeenCalled(); - expect(databaseMock.runMigrations).toHaveBeenCalledTimes(1); - expect(loggerMock.fatal).not.toHaveBeenCalled(); + expect(mocks.database.updateVectorExtension).toHaveBeenCalledWith(extension, updateInRange); + expect(mocks.database.updateVectorExtension).toHaveBeenCalledTimes(1); + expect(mocks.database.getExtensionVersion).toHaveBeenCalled(); + expect(mocks.database.runMigrations).toHaveBeenCalledTimes(1); + expect(mocks.logger.fatal).not.toHaveBeenCalled(); }); it(`should not upgrade ${extension} if same version`, async () => { - databaseMock.getExtensionVersion.mockResolvedValue({ + mocks.database.getExtensionVersion.mockResolvedValue({ availableVersion: minVersionInRange, installedVersion: minVersionInRange, }); await expect(sut.onBootstrap()).resolves.toBeUndefined(); - expect(databaseMock.updateVectorExtension).not.toHaveBeenCalled(); - expect(databaseMock.runMigrations).toHaveBeenCalledTimes(1); - expect(loggerMock.fatal).not.toHaveBeenCalled(); + expect(mocks.database.updateVectorExtension).not.toHaveBeenCalled(); + expect(mocks.database.runMigrations).toHaveBeenCalledTimes(1); + expect(mocks.logger.fatal).not.toHaveBeenCalled(); }); it(`should throw error if ${extension} available version is below range`, async () => { - databaseMock.getExtensionVersion.mockResolvedValue({ + mocks.database.getExtensionVersion.mockResolvedValue({ availableVersion: versionBelowRange, installedVersion: null, }); await expect(sut.onBootstrap()).rejects.toThrow(); - expect(databaseMock.createExtension).not.toHaveBeenCalled(); - expect(databaseMock.updateVectorExtension).not.toHaveBeenCalled(); - expect(databaseMock.runMigrations).not.toHaveBeenCalled(); - expect(loggerMock.fatal).not.toHaveBeenCalled(); + expect(mocks.database.createExtension).not.toHaveBeenCalled(); + expect(mocks.database.updateVectorExtension).not.toHaveBeenCalled(); + expect(mocks.database.runMigrations).not.toHaveBeenCalled(); + expect(mocks.logger.fatal).not.toHaveBeenCalled(); }); it(`should throw error if ${extension} available version is above range`, async () => { - databaseMock.getExtensionVersion.mockResolvedValue({ + mocks.database.getExtensionVersion.mockResolvedValue({ availableVersion: versionAboveRange, installedVersion: minVersionInRange, }); await expect(sut.onBootstrap()).rejects.toThrow(); - expect(databaseMock.createExtension).not.toHaveBeenCalled(); - expect(databaseMock.updateVectorExtension).not.toHaveBeenCalled(); - expect(databaseMock.runMigrations).not.toHaveBeenCalled(); - expect(loggerMock.fatal).not.toHaveBeenCalled(); + expect(mocks.database.createExtension).not.toHaveBeenCalled(); + expect(mocks.database.updateVectorExtension).not.toHaveBeenCalled(); + expect(mocks.database.runMigrations).not.toHaveBeenCalled(); + expect(mocks.logger.fatal).not.toHaveBeenCalled(); }); it('should throw error if available version is below installed version', async () => { - databaseMock.getExtensionVersion.mockResolvedValue({ + mocks.database.getExtensionVersion.mockResolvedValue({ availableVersion: minVersionInRange, installedVersion: updateInRange, }); @@ -203,13 +194,13 @@ describe(DatabaseService.name, () => { `The database currently has ${extensionName} ${updateInRange} activated, but the Postgres instance only has ${minVersionInRange} available.`, ); - expect(databaseMock.updateVectorExtension).not.toHaveBeenCalled(); - expect(databaseMock.runMigrations).not.toHaveBeenCalled(); - expect(loggerMock.fatal).not.toHaveBeenCalled(); + expect(mocks.database.updateVectorExtension).not.toHaveBeenCalled(); + expect(mocks.database.runMigrations).not.toHaveBeenCalled(); + expect(mocks.logger.fatal).not.toHaveBeenCalled(); }); it('should throw error if installed version is not in version range', async () => { - databaseMock.getExtensionVersion.mockResolvedValue({ + mocks.database.getExtensionVersion.mockResolvedValue({ availableVersion: minVersionInRange, installedVersion: versionAboveRange, }); @@ -218,84 +209,84 @@ describe(DatabaseService.name, () => { `The ${extensionName} extension version is ${versionAboveRange}, but Immich only supports`, ); - expect(databaseMock.updateVectorExtension).not.toHaveBeenCalled(); - expect(databaseMock.runMigrations).not.toHaveBeenCalled(); - expect(loggerMock.fatal).not.toHaveBeenCalled(); + expect(mocks.database.updateVectorExtension).not.toHaveBeenCalled(); + expect(mocks.database.runMigrations).not.toHaveBeenCalled(); + expect(mocks.logger.fatal).not.toHaveBeenCalled(); }); it(`should raise error if ${extension} extension upgrade failed`, async () => { - databaseMock.getExtensionVersion.mockResolvedValue({ + mocks.database.getExtensionVersion.mockResolvedValue({ availableVersion: updateInRange, installedVersion: minVersionInRange, }); - databaseMock.updateVectorExtension.mockRejectedValue(new Error('Failed to update extension')); + mocks.database.updateVectorExtension.mockRejectedValue(new Error('Failed to update extension')); await expect(sut.onBootstrap()).rejects.toThrow('Failed to update extension'); - expect(loggerMock.warn.mock.calls[0][0]).toContain( + expect(mocks.logger.warn.mock.calls[0][0]).toContain( `The ${extensionName} extension can be updated to ${updateInRange}.`, ); - expect(loggerMock.fatal).not.toHaveBeenCalled(); - expect(databaseMock.updateVectorExtension).toHaveBeenCalledWith(extension, updateInRange); - expect(databaseMock.runMigrations).not.toHaveBeenCalled(); + expect(mocks.logger.fatal).not.toHaveBeenCalled(); + expect(mocks.database.updateVectorExtension).toHaveBeenCalledWith(extension, updateInRange); + expect(mocks.database.runMigrations).not.toHaveBeenCalled(); }); it(`should warn if ${extension} extension update requires restart`, async () => { - databaseMock.getExtensionVersion.mockResolvedValue({ + mocks.database.getExtensionVersion.mockResolvedValue({ availableVersion: updateInRange, installedVersion: minVersionInRange, }); - databaseMock.updateVectorExtension.mockResolvedValue({ restartRequired: true }); + mocks.database.updateVectorExtension.mockResolvedValue({ restartRequired: true }); await expect(sut.onBootstrap()).resolves.toBeUndefined(); - expect(loggerMock.warn).toHaveBeenCalledTimes(1); - expect(loggerMock.warn.mock.calls[0][0]).toContain(extensionName); - expect(databaseMock.updateVectorExtension).toHaveBeenCalledWith(extension, updateInRange); - expect(databaseMock.runMigrations).toHaveBeenCalledTimes(1); - expect(loggerMock.fatal).not.toHaveBeenCalled(); + expect(mocks.logger.warn).toHaveBeenCalledTimes(1); + expect(mocks.logger.warn.mock.calls[0][0]).toContain(extensionName); + expect(mocks.database.updateVectorExtension).toHaveBeenCalledWith(extension, updateInRange); + expect(mocks.database.runMigrations).toHaveBeenCalledTimes(1); + expect(mocks.logger.fatal).not.toHaveBeenCalled(); }); it(`should reindex ${extension} indices if needed`, async () => { - databaseMock.shouldReindex.mockResolvedValue(true); + mocks.database.shouldReindex.mockResolvedValue(true); await expect(sut.onBootstrap()).resolves.toBeUndefined(); - expect(databaseMock.shouldReindex).toHaveBeenCalledTimes(2); - expect(databaseMock.reindex).toHaveBeenCalledTimes(2); - expect(databaseMock.runMigrations).toHaveBeenCalledTimes(1); - expect(loggerMock.fatal).not.toHaveBeenCalled(); + expect(mocks.database.shouldReindex).toHaveBeenCalledTimes(2); + expect(mocks.database.reindex).toHaveBeenCalledTimes(2); + expect(mocks.database.runMigrations).toHaveBeenCalledTimes(1); + expect(mocks.logger.fatal).not.toHaveBeenCalled(); }); it(`should throw an error if reindexing fails`, async () => { - databaseMock.shouldReindex.mockResolvedValue(true); - databaseMock.reindex.mockRejectedValue(new Error('Error reindexing')); + mocks.database.shouldReindex.mockResolvedValue(true); + mocks.database.reindex.mockRejectedValue(new Error('Error reindexing')); await expect(sut.onBootstrap()).rejects.toBeDefined(); - expect(databaseMock.shouldReindex).toHaveBeenCalledTimes(1); - expect(databaseMock.reindex).toHaveBeenCalledTimes(1); - expect(databaseMock.runMigrations).not.toHaveBeenCalled(); - expect(loggerMock.fatal).not.toHaveBeenCalled(); - expect(loggerMock.warn).toHaveBeenCalledWith( + expect(mocks.database.shouldReindex).toHaveBeenCalledTimes(1); + expect(mocks.database.reindex).toHaveBeenCalledTimes(1); + expect(mocks.database.runMigrations).not.toHaveBeenCalled(); + expect(mocks.logger.fatal).not.toHaveBeenCalled(); + expect(mocks.logger.warn).toHaveBeenCalledWith( expect.stringContaining('Could not run vector reindexing checks.'), ); }); it(`should not reindex ${extension} indices if not needed`, async () => { - databaseMock.shouldReindex.mockResolvedValue(false); + mocks.database.shouldReindex.mockResolvedValue(false); await expect(sut.onBootstrap()).resolves.toBeUndefined(); - expect(databaseMock.shouldReindex).toHaveBeenCalledTimes(2); - expect(databaseMock.reindex).toHaveBeenCalledTimes(0); - expect(databaseMock.runMigrations).toHaveBeenCalledTimes(1); - expect(loggerMock.fatal).not.toHaveBeenCalled(); + expect(mocks.database.shouldReindex).toHaveBeenCalledTimes(2); + expect(mocks.database.reindex).toHaveBeenCalledTimes(0); + expect(mocks.database.runMigrations).toHaveBeenCalledTimes(1); + expect(mocks.logger.fatal).not.toHaveBeenCalled(); }); }); it('should skip migrations if DB_SKIP_MIGRATIONS=true', async () => { - configMock.getEnv.mockReturnValue( + mocks.config.getEnv.mockReturnValue( mockEnvData({ database: { config: { @@ -324,11 +315,11 @@ describe(DatabaseService.name, () => { await expect(sut.onBootstrap()).resolves.toBeUndefined(); - expect(databaseMock.runMigrations).not.toHaveBeenCalled(); + expect(mocks.database.runMigrations).not.toHaveBeenCalled(); }); it(`should throw error if pgvector extension could not be created`, async () => { - configMock.getEnv.mockReturnValue( + mocks.config.getEnv.mockReturnValue( mockEnvData({ database: { config: { @@ -354,41 +345,41 @@ describe(DatabaseService.name, () => { }, }), ); - databaseMock.getExtensionVersion.mockResolvedValue({ + mocks.database.getExtensionVersion.mockResolvedValue({ installedVersion: null, availableVersion: minVersionInRange, }); - databaseMock.updateVectorExtension.mockResolvedValue({ restartRequired: false }); - databaseMock.createExtension.mockRejectedValue(new Error('Failed to create extension')); + mocks.database.updateVectorExtension.mockResolvedValue({ restartRequired: false }); + mocks.database.createExtension.mockRejectedValue(new Error('Failed to create extension')); await expect(sut.onBootstrap()).rejects.toThrow('Failed to create extension'); - expect(loggerMock.fatal).toHaveBeenCalledTimes(1); - expect(loggerMock.fatal.mock.calls[0][0]).toContain( + expect(mocks.logger.fatal).toHaveBeenCalledTimes(1); + expect(mocks.logger.fatal.mock.calls[0][0]).toContain( `Alternatively, if your Postgres instance has pgvecto.rs, you may use this instead`, ); - expect(databaseMock.createExtension).toHaveBeenCalledTimes(1); - expect(databaseMock.updateVectorExtension).not.toHaveBeenCalled(); - expect(databaseMock.runMigrations).not.toHaveBeenCalled(); + expect(mocks.database.createExtension).toHaveBeenCalledTimes(1); + expect(mocks.database.updateVectorExtension).not.toHaveBeenCalled(); + expect(mocks.database.runMigrations).not.toHaveBeenCalled(); }); it(`should throw error if pgvecto.rs extension could not be created`, async () => { - databaseMock.getExtensionVersion.mockResolvedValue({ + mocks.database.getExtensionVersion.mockResolvedValue({ installedVersion: null, availableVersion: minVersionInRange, }); - databaseMock.updateVectorExtension.mockResolvedValue({ restartRequired: false }); - databaseMock.createExtension.mockRejectedValue(new Error('Failed to create extension')); + mocks.database.updateVectorExtension.mockResolvedValue({ restartRequired: false }); + mocks.database.createExtension.mockRejectedValue(new Error('Failed to create extension')); await expect(sut.onBootstrap()).rejects.toThrow('Failed to create extension'); - expect(loggerMock.fatal).toHaveBeenCalledTimes(1); - expect(loggerMock.fatal.mock.calls[0][0]).toContain( + expect(mocks.logger.fatal).toHaveBeenCalledTimes(1); + expect(mocks.logger.fatal.mock.calls[0][0]).toContain( `Alternatively, if your Postgres instance has pgvector, you may use this instead`, ); - expect(databaseMock.createExtension).toHaveBeenCalledTimes(1); - expect(databaseMock.updateVectorExtension).not.toHaveBeenCalled(); - expect(databaseMock.runMigrations).not.toHaveBeenCalled(); + expect(mocks.database.createExtension).toHaveBeenCalledTimes(1); + expect(mocks.database.updateVectorExtension).not.toHaveBeenCalled(); + expect(mocks.database.runMigrations).not.toHaveBeenCalled(); }); }); @@ -403,38 +394,38 @@ describe(DatabaseService.name, () => { it('should not override interval', () => { sut.handleConnectionError(new Error('Error')); - expect(loggerMock.error).toHaveBeenCalled(); + expect(mocks.logger.error).toHaveBeenCalled(); sut.handleConnectionError(new Error('foo')); - expect(loggerMock.error).toHaveBeenCalledTimes(1); + expect(mocks.logger.error).toHaveBeenCalledTimes(1); }); it('should reconnect when interval elapses', async () => { - databaseMock.reconnect.mockResolvedValue(true); + mocks.database.reconnect.mockResolvedValue(true); sut.handleConnectionError(new Error('error')); await vi.advanceTimersByTimeAsync(5000); - expect(databaseMock.reconnect).toHaveBeenCalledTimes(1); - expect(loggerMock.log).toHaveBeenCalledWith('Database reconnected'); + expect(mocks.database.reconnect).toHaveBeenCalledTimes(1); + expect(mocks.logger.log).toHaveBeenCalledWith('Database reconnected'); await vi.advanceTimersByTimeAsync(5000); - expect(databaseMock.reconnect).toHaveBeenCalledTimes(1); + expect(mocks.database.reconnect).toHaveBeenCalledTimes(1); }); it('should try again when reconnection fails', async () => { - databaseMock.reconnect.mockResolvedValueOnce(false); + mocks.database.reconnect.mockResolvedValueOnce(false); sut.handleConnectionError(new Error('error')); await vi.advanceTimersByTimeAsync(5000); - expect(databaseMock.reconnect).toHaveBeenCalledTimes(1); - expect(loggerMock.warn).toHaveBeenCalledWith(expect.stringContaining('Database connection failed')); + expect(mocks.database.reconnect).toHaveBeenCalledTimes(1); + expect(mocks.logger.warn).toHaveBeenCalledWith(expect.stringContaining('Database connection failed')); - databaseMock.reconnect.mockResolvedValueOnce(true); + mocks.database.reconnect.mockResolvedValueOnce(true); await vi.advanceTimersByTimeAsync(5000); - expect(databaseMock.reconnect).toHaveBeenCalledTimes(2); - expect(loggerMock.log).toHaveBeenCalledWith('Database reconnected'); + expect(mocks.database.reconnect).toHaveBeenCalledTimes(2); + expect(mocks.logger.log).toHaveBeenCalledWith('Database reconnected'); }); }); }); diff --git a/server/src/services/download.service.spec.ts b/server/src/services/download.service.spec.ts index 12e3414ac3..d9e60dfdb4 100644 --- a/server/src/services/download.service.spec.ts +++ b/server/src/services/download.service.spec.ts @@ -1,16 +1,12 @@ import { BadRequestException } from '@nestjs/common'; import { DownloadResponseDto } from 'src/dtos/download.dto'; import { AssetEntity } from 'src/entities/asset.entity'; -import { IAssetRepository } from 'src/interfaces/asset.interface'; -import { IStorageRepository } from 'src/interfaces/storage.interface'; import { DownloadService } from 'src/services/download.service'; -import { ILoggingRepository } from 'src/types'; import { assetStub } from 'test/fixtures/asset.stub'; import { authStub } from 'test/fixtures/auth.stub'; -import { IAccessRepositoryMock } from 'test/repositories/access.repository.mock'; -import { newTestService } from 'test/utils'; +import { newTestService, ServiceMocks } from 'test/utils'; import { Readable } from 'typeorm/platform/PlatformTools.js'; -import { Mocked, vitest } from 'vitest'; +import { vitest } from 'vitest'; const downloadResponse: DownloadResponseDto = { totalSize: 105_000, @@ -24,17 +20,14 @@ const downloadResponse: DownloadResponseDto = { describe(DownloadService.name, () => { let sut: DownloadService; - let accessMock: IAccessRepositoryMock; - let assetMock: Mocked; - let loggerMock: Mocked; - let storageMock: Mocked; + let mocks: ServiceMocks; it('should work', () => { expect(sut).toBeDefined(); }); beforeEach(() => { - ({ sut, accessMock, assetMock, loggerMock, storageMock } = newTestService(DownloadService)); + ({ sut, mocks } = newTestService(DownloadService)); }); describe('downloadArchive', () => { @@ -45,9 +38,9 @@ describe(DownloadService.name, () => { stream: new Readable(), }; - accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1', 'asset-2'])); - assetMock.getByIds.mockResolvedValue([{ ...assetStub.noResizePath, id: 'asset-1' }]); - storageMock.createZipStream.mockReturnValue(archiveMock); + mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1', 'asset-2'])); + mocks.asset.getByIds.mockResolvedValue([{ ...assetStub.noResizePath, id: 'asset-1' }]); + mocks.storage.createZipStream.mockReturnValue(archiveMock); await expect(sut.downloadArchive(authStub.admin, { assetIds: ['asset-1', 'asset-2'] })).resolves.toEqual({ stream: archiveMock.stream, @@ -64,19 +57,19 @@ describe(DownloadService.name, () => { stream: new Readable(), }; - accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1', 'asset-2'])); - storageMock.realpath.mockRejectedValue(new Error('Could not read file')); - assetMock.getByIds.mockResolvedValue([ + mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1', 'asset-2'])); + mocks.storage.realpath.mockRejectedValue(new Error('Could not read file')); + mocks.asset.getByIds.mockResolvedValue([ { ...assetStub.noResizePath, id: 'asset-1' }, { ...assetStub.noWebpPath, id: 'asset-2' }, ]); - storageMock.createZipStream.mockReturnValue(archiveMock); + mocks.storage.createZipStream.mockReturnValue(archiveMock); await expect(sut.downloadArchive(authStub.admin, { assetIds: ['asset-1', 'asset-2'] })).resolves.toEqual({ stream: archiveMock.stream, }); - expect(loggerMock.warn).toHaveBeenCalledTimes(2); + expect(mocks.logger.warn).toHaveBeenCalledTimes(2); expect(archiveMock.addFile).toHaveBeenCalledTimes(2); expect(archiveMock.addFile).toHaveBeenNthCalledWith(1, 'upload/library/IMG_123.jpg', 'IMG_123.jpg'); expect(archiveMock.addFile).toHaveBeenNthCalledWith(2, 'upload/library/IMG_456.jpg', 'IMG_456.jpg'); @@ -89,12 +82,12 @@ describe(DownloadService.name, () => { stream: new Readable(), }; - accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1', 'asset-2'])); - assetMock.getByIds.mockResolvedValue([ + mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1', 'asset-2'])); + mocks.asset.getByIds.mockResolvedValue([ { ...assetStub.noResizePath, id: 'asset-1' }, { ...assetStub.noWebpPath, id: 'asset-2' }, ]); - storageMock.createZipStream.mockReturnValue(archiveMock); + mocks.storage.createZipStream.mockReturnValue(archiveMock); await expect(sut.downloadArchive(authStub.admin, { assetIds: ['asset-1', 'asset-2'] })).resolves.toEqual({ stream: archiveMock.stream, @@ -112,12 +105,12 @@ describe(DownloadService.name, () => { stream: new Readable(), }; - accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1', 'asset-2'])); - assetMock.getByIds.mockResolvedValue([ + mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1', 'asset-2'])); + mocks.asset.getByIds.mockResolvedValue([ { ...assetStub.noResizePath, id: 'asset-1' }, { ...assetStub.noResizePath, id: 'asset-2' }, ]); - storageMock.createZipStream.mockReturnValue(archiveMock); + mocks.storage.createZipStream.mockReturnValue(archiveMock); await expect(sut.downloadArchive(authStub.admin, { assetIds: ['asset-1', 'asset-2'] })).resolves.toEqual({ stream: archiveMock.stream, @@ -135,12 +128,12 @@ describe(DownloadService.name, () => { stream: new Readable(), }; - accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1', 'asset-2'])); - assetMock.getByIds.mockResolvedValue([ + mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1', 'asset-2'])); + mocks.asset.getByIds.mockResolvedValue([ { ...assetStub.noResizePath, id: 'asset-2' }, { ...assetStub.noResizePath, id: 'asset-1' }, ]); - storageMock.createZipStream.mockReturnValue(archiveMock); + mocks.storage.createZipStream.mockReturnValue(archiveMock); await expect(sut.downloadArchive(authStub.admin, { assetIds: ['asset-1', 'asset-2'] })).resolves.toEqual({ stream: archiveMock.stream, @@ -158,12 +151,12 @@ describe(DownloadService.name, () => { stream: new Readable(), }; - accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1'])); - assetMock.getByIds.mockResolvedValue([ + mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1'])); + mocks.asset.getByIds.mockResolvedValue([ { ...assetStub.noResizePath, id: 'asset-1', originalPath: '/path/to/symlink.jpg' }, ]); - storageMock.realpath.mockResolvedValue('/path/to/realpath.jpg'); - storageMock.createZipStream.mockReturnValue(archiveMock); + mocks.storage.realpath.mockResolvedValue('/path/to/realpath.jpg'); + mocks.storage.createZipStream.mockReturnValue(archiveMock); await expect(sut.downloadArchive(authStub.admin, { assetIds: ['asset-1'] })).resolves.toEqual({ stream: archiveMock.stream, @@ -179,30 +172,30 @@ describe(DownloadService.name, () => { }); it('should return a list of archives (assetIds)', async () => { - accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1', 'asset-2'])); - assetMock.getByIds.mockResolvedValue([assetStub.image, assetStub.video]); + mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1', 'asset-2'])); + mocks.asset.getByIds.mockResolvedValue([assetStub.image, assetStub.video]); const assetIds = ['asset-1', 'asset-2']; await expect(sut.getDownloadInfo(authStub.admin, { assetIds })).resolves.toEqual(downloadResponse); - expect(assetMock.getByIds).toHaveBeenCalledWith(['asset-1', 'asset-2'], { exifInfo: true }); + expect(mocks.asset.getByIds).toHaveBeenCalledWith(['asset-1', 'asset-2'], { exifInfo: true }); }); it('should return a list of archives (albumId)', async () => { - accessMock.album.checkOwnerAccess.mockResolvedValue(new Set(['album-1'])); - assetMock.getByAlbumId.mockResolvedValue({ + mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set(['album-1'])); + mocks.asset.getByAlbumId.mockResolvedValue({ items: [assetStub.image, assetStub.video], hasNextPage: false, }); await expect(sut.getDownloadInfo(authStub.admin, { albumId: 'album-1' })).resolves.toEqual(downloadResponse); - expect(accessMock.album.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['album-1'])); - expect(assetMock.getByAlbumId).toHaveBeenCalledWith({ take: 2500, skip: 0 }, 'album-1'); + expect(mocks.access.album.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['album-1'])); + expect(mocks.asset.getByAlbumId).toHaveBeenCalledWith({ take: 2500, skip: 0 }, 'album-1'); }); it('should return a list of archives (userId)', async () => { - assetMock.getByUserId.mockResolvedValue({ + mocks.asset.getByUserId.mockResolvedValue({ items: [assetStub.image, assetStub.video], hasNextPage: false, }); @@ -211,13 +204,13 @@ describe(DownloadService.name, () => { downloadResponse, ); - expect(assetMock.getByUserId).toHaveBeenCalledWith({ take: 2500, skip: 0 }, authStub.admin.user.id, { + expect(mocks.asset.getByUserId).toHaveBeenCalledWith({ take: 2500, skip: 0 }, authStub.admin.user.id, { isVisible: true, }); }); it('should split archives by size', async () => { - assetMock.getByUserId.mockResolvedValue({ + mocks.asset.getByUserId.mockResolvedValue({ items: [ { ...assetStub.image, id: 'asset-1' }, { ...assetStub.video, id: 'asset-2' }, @@ -245,8 +238,8 @@ describe(DownloadService.name, () => { const assetIds = [assetStub.livePhotoStillAsset.id]; const assets = [assetStub.livePhotoStillAsset, assetStub.livePhotoMotionAsset]; - accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(assetIds)); - assetMock.getByIds.mockImplementation( + mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(assetIds)); + mocks.asset.getByIds.mockImplementation( (ids) => Promise.resolve( ids.map((id) => assets.find((asset) => asset.id === id)).filter((asset) => !!asset), @@ -271,8 +264,8 @@ describe(DownloadService.name, () => { { ...assetStub.livePhotoMotionAsset, originalPath: 'upload/encoded-video/uuid-MP.mp4' }, ]; - accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(assetIds)); - assetMock.getByIds.mockImplementation( + mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(assetIds)); + mocks.asset.getByIds.mockImplementation( (ids) => Promise.resolve( ids.map((id) => assets.find((asset) => asset.id === id)).filter((asset) => !!asset), diff --git a/server/src/services/duplicate.service.spec.ts b/server/src/services/duplicate.service.spec.ts index 6fdb9c2b5c..0451f1f2b3 100644 --- a/server/src/services/duplicate.service.spec.ts +++ b/server/src/services/duplicate.service.spec.ts @@ -1,27 +1,20 @@ -import { IAssetRepository, WithoutProperty } from 'src/interfaces/asset.interface'; -import { IJobRepository, JobName, JobStatus } from 'src/interfaces/job.interface'; -import { ISearchRepository } from 'src/interfaces/search.interface'; +import { WithoutProperty } from 'src/interfaces/asset.interface'; +import { JobName, JobStatus } from 'src/interfaces/job.interface'; import { DuplicateService } from 'src/services/duplicate.service'; import { SearchService } from 'src/services/search.service'; -import { ILoggingRepository, ISystemMetadataRepository } from 'src/types'; import { assetStub } from 'test/fixtures/asset.stub'; import { authStub } from 'test/fixtures/auth.stub'; -import { newTestService } from 'test/utils'; -import { Mocked, beforeEach, vitest } from 'vitest'; +import { newTestService, ServiceMocks } from 'test/utils'; +import { beforeEach, vitest } from 'vitest'; vitest.useFakeTimers(); describe(SearchService.name, () => { let sut: DuplicateService; - - let assetMock: Mocked; - let jobMock: Mocked; - let loggerMock: Mocked; - let searchMock: Mocked; - let systemMock: Mocked; + let mocks: ServiceMocks; beforeEach(() => { - ({ sut, assetMock, jobMock, loggerMock, searchMock, systemMock } = newTestService(DuplicateService)); + ({ sut, mocks } = newTestService(DuplicateService)); }); it('should work', () => { @@ -30,7 +23,7 @@ describe(SearchService.name, () => { describe('getDuplicates', () => { it('should get duplicates', async () => { - assetMock.getDuplicates.mockResolvedValue([ + mocks.asset.getDuplicates.mockResolvedValue([ { duplicateId: assetStub.hasDupe.duplicateId!, assets: [assetStub.hasDupe, assetStub.hasDupe], @@ -50,7 +43,7 @@ describe(SearchService.name, () => { describe('handleQueueSearchDuplicates', () => { beforeEach(() => { - systemMock.get.mockResolvedValue({ + mocks.systemMetadata.get.mockResolvedValue({ machineLearning: { enabled: true, duplicateDetection: { @@ -61,7 +54,7 @@ describe(SearchService.name, () => { }); it('should skip if machine learning is disabled', async () => { - systemMock.get.mockResolvedValue({ + mocks.systemMetadata.get.mockResolvedValue({ machineLearning: { enabled: false, duplicateDetection: { @@ -71,13 +64,13 @@ describe(SearchService.name, () => { }); await expect(sut.handleQueueSearchDuplicates({})).resolves.toBe(JobStatus.SKIPPED); - expect(jobMock.queue).not.toHaveBeenCalled(); - expect(jobMock.queueAll).not.toHaveBeenCalled(); - expect(systemMock.get).toHaveBeenCalled(); + expect(mocks.job.queue).not.toHaveBeenCalled(); + expect(mocks.job.queueAll).not.toHaveBeenCalled(); + expect(mocks.systemMetadata.get).toHaveBeenCalled(); }); it('should skip if duplicate detection is disabled', async () => { - systemMock.get.mockResolvedValue({ + mocks.systemMetadata.get.mockResolvedValue({ machineLearning: { enabled: true, duplicateDetection: { @@ -87,21 +80,21 @@ describe(SearchService.name, () => { }); await expect(sut.handleQueueSearchDuplicates({})).resolves.toBe(JobStatus.SKIPPED); - expect(jobMock.queue).not.toHaveBeenCalled(); - expect(jobMock.queueAll).not.toHaveBeenCalled(); - expect(systemMock.get).toHaveBeenCalled(); + expect(mocks.job.queue).not.toHaveBeenCalled(); + expect(mocks.job.queueAll).not.toHaveBeenCalled(); + expect(mocks.systemMetadata.get).toHaveBeenCalled(); }); it('should queue missing assets', async () => { - assetMock.getWithout.mockResolvedValue({ + mocks.asset.getWithout.mockResolvedValue({ items: [assetStub.image], hasNextPage: false, }); await sut.handleQueueSearchDuplicates({}); - expect(assetMock.getWithout).toHaveBeenCalledWith({ skip: 0, take: 1000 }, WithoutProperty.DUPLICATE); - expect(jobMock.queueAll).toHaveBeenCalledWith([ + expect(mocks.asset.getWithout).toHaveBeenCalledWith({ skip: 0, take: 1000 }, WithoutProperty.DUPLICATE); + expect(mocks.job.queueAll).toHaveBeenCalledWith([ { name: JobName.DUPLICATE_DETECTION, data: { id: assetStub.image.id }, @@ -110,15 +103,15 @@ describe(SearchService.name, () => { }); it('should queue all assets', async () => { - assetMock.getAll.mockResolvedValue({ + mocks.asset.getAll.mockResolvedValue({ items: [assetStub.image], hasNextPage: false, }); await sut.handleQueueSearchDuplicates({ force: true }); - expect(assetMock.getAll).toHaveBeenCalled(); - expect(jobMock.queueAll).toHaveBeenCalledWith([ + expect(mocks.asset.getAll).toHaveBeenCalled(); + expect(mocks.job.queueAll).toHaveBeenCalledWith([ { name: JobName.DUPLICATE_DETECTION, data: { id: assetStub.image.id }, @@ -129,7 +122,7 @@ describe(SearchService.name, () => { describe('handleSearchDuplicates', () => { beforeEach(() => { - systemMock.get.mockResolvedValue({ + mocks.systemMetadata.get.mockResolvedValue({ machineLearning: { enabled: true, duplicateDetection: { @@ -140,7 +133,7 @@ describe(SearchService.name, () => { }); it('should skip if machine learning is disabled', async () => { - systemMock.get.mockResolvedValue({ + mocks.systemMetadata.get.mockResolvedValue({ machineLearning: { enabled: false, duplicateDetection: { @@ -149,7 +142,7 @@ describe(SearchService.name, () => { }, }); const id = assetStub.livePhotoMotionAsset.id; - assetMock.getById.mockResolvedValue(assetStub.livePhotoMotionAsset); + mocks.asset.getById.mockResolvedValue(assetStub.livePhotoMotionAsset); const result = await sut.handleSearchDuplicates({ id }); @@ -157,7 +150,7 @@ describe(SearchService.name, () => { }); it('should skip if duplicate detection is disabled', async () => { - systemMock.get.mockResolvedValue({ + mocks.systemMetadata.get.mockResolvedValue({ machineLearning: { enabled: true, duplicateDetection: { @@ -166,7 +159,7 @@ describe(SearchService.name, () => { }, }); const id = assetStub.livePhotoMotionAsset.id; - assetMock.getById.mockResolvedValue(assetStub.livePhotoMotionAsset); + mocks.asset.getById.mockResolvedValue(assetStub.livePhotoMotionAsset); const result = await sut.handleSearchDuplicates({ id }); @@ -177,40 +170,40 @@ describe(SearchService.name, () => { const result = await sut.handleSearchDuplicates({ id: assetStub.image.id }); expect(result).toBe(JobStatus.FAILED); - expect(loggerMock.error).toHaveBeenCalledWith(`Asset ${assetStub.image.id} not found`); + expect(mocks.logger.error).toHaveBeenCalledWith(`Asset ${assetStub.image.id} not found`); }); it('should skip if asset is not visible', async () => { const id = assetStub.livePhotoMotionAsset.id; - assetMock.getById.mockResolvedValue(assetStub.livePhotoMotionAsset); + mocks.asset.getById.mockResolvedValue(assetStub.livePhotoMotionAsset); const result = await sut.handleSearchDuplicates({ id }); expect(result).toBe(JobStatus.SKIPPED); - expect(loggerMock.debug).toHaveBeenCalledWith(`Asset ${id} is not visible, skipping`); + expect(mocks.logger.debug).toHaveBeenCalledWith(`Asset ${id} is not visible, skipping`); }); it('should fail if asset is missing preview image', async () => { - assetMock.getById.mockResolvedValue(assetStub.noResizePath); + mocks.asset.getById.mockResolvedValue(assetStub.noResizePath); const result = await sut.handleSearchDuplicates({ id: assetStub.noResizePath.id }); expect(result).toBe(JobStatus.FAILED); - expect(loggerMock.warn).toHaveBeenCalledWith(`Asset ${assetStub.noResizePath.id} is missing preview image`); + expect(mocks.logger.warn).toHaveBeenCalledWith(`Asset ${assetStub.noResizePath.id} is missing preview image`); }); it('should fail if asset is missing embedding', async () => { - assetMock.getById.mockResolvedValue(assetStub.image); + mocks.asset.getById.mockResolvedValue(assetStub.image); const result = await sut.handleSearchDuplicates({ id: assetStub.image.id }); expect(result).toBe(JobStatus.FAILED); - expect(loggerMock.debug).toHaveBeenCalledWith(`Asset ${assetStub.image.id} is missing embedding`); + expect(mocks.logger.debug).toHaveBeenCalledWith(`Asset ${assetStub.image.id} is missing embedding`); }); it('should search for duplicates and update asset with duplicateId', async () => { - assetMock.getById.mockResolvedValue(assetStub.hasEmbedding); - searchMock.searchDuplicates.mockResolvedValue([ + mocks.asset.getById.mockResolvedValue(assetStub.hasEmbedding); + mocks.search.searchDuplicates.mockResolvedValue([ { assetId: assetStub.image.id, distance: 0.01, duplicateId: null }, ]); const expectedAssetIds = [assetStub.image.id, assetStub.hasEmbedding.id]; @@ -218,58 +211,58 @@ describe(SearchService.name, () => { const result = await sut.handleSearchDuplicates({ id: assetStub.hasEmbedding.id }); expect(result).toBe(JobStatus.SUCCESS); - expect(searchMock.searchDuplicates).toHaveBeenCalledWith({ + expect(mocks.search.searchDuplicates).toHaveBeenCalledWith({ assetId: assetStub.hasEmbedding.id, embedding: assetStub.hasEmbedding.smartSearch!.embedding, maxDistance: 0.01, type: assetStub.hasEmbedding.type, userIds: [assetStub.hasEmbedding.ownerId], }); - expect(assetMock.updateDuplicates).toHaveBeenCalledWith({ + expect(mocks.asset.updateDuplicates).toHaveBeenCalledWith({ assetIds: expectedAssetIds, targetDuplicateId: expect.any(String), duplicateIds: [], }); - expect(assetMock.upsertJobStatus).toHaveBeenCalledWith( + expect(mocks.asset.upsertJobStatus).toHaveBeenCalledWith( ...expectedAssetIds.map((assetId) => ({ assetId, duplicatesDetectedAt: expect.any(Date) })), ); }); it('should use existing duplicate ID among matched duplicates', async () => { const duplicateId = assetStub.hasDupe.duplicateId; - assetMock.getById.mockResolvedValue(assetStub.hasEmbedding); - searchMock.searchDuplicates.mockResolvedValue([{ assetId: assetStub.hasDupe.id, distance: 0.01, duplicateId }]); + mocks.asset.getById.mockResolvedValue(assetStub.hasEmbedding); + mocks.search.searchDuplicates.mockResolvedValue([{ assetId: assetStub.hasDupe.id, distance: 0.01, duplicateId }]); const expectedAssetIds = [assetStub.hasEmbedding.id]; const result = await sut.handleSearchDuplicates({ id: assetStub.hasEmbedding.id }); expect(result).toBe(JobStatus.SUCCESS); - expect(searchMock.searchDuplicates).toHaveBeenCalledWith({ + expect(mocks.search.searchDuplicates).toHaveBeenCalledWith({ assetId: assetStub.hasEmbedding.id, embedding: assetStub.hasEmbedding.smartSearch!.embedding, maxDistance: 0.01, type: assetStub.hasEmbedding.type, userIds: [assetStub.hasEmbedding.ownerId], }); - expect(assetMock.updateDuplicates).toHaveBeenCalledWith({ + expect(mocks.asset.updateDuplicates).toHaveBeenCalledWith({ assetIds: expectedAssetIds, targetDuplicateId: assetStub.hasDupe.duplicateId, duplicateIds: [], }); - expect(assetMock.upsertJobStatus).toHaveBeenCalledWith( + expect(mocks.asset.upsertJobStatus).toHaveBeenCalledWith( ...expectedAssetIds.map((assetId) => ({ assetId, duplicatesDetectedAt: expect.any(Date) })), ); }); it('should remove duplicateId if no duplicates found and asset has duplicateId', async () => { - assetMock.getById.mockResolvedValue(assetStub.hasDupe); - searchMock.searchDuplicates.mockResolvedValue([]); + mocks.asset.getById.mockResolvedValue(assetStub.hasDupe); + mocks.search.searchDuplicates.mockResolvedValue([]); const result = await sut.handleSearchDuplicates({ id: assetStub.hasDupe.id }); expect(result).toBe(JobStatus.SUCCESS); - expect(assetMock.update).toHaveBeenCalledWith({ id: assetStub.hasDupe.id, duplicateId: null }); - expect(assetMock.upsertJobStatus).toHaveBeenCalledWith({ + expect(mocks.asset.update).toHaveBeenCalledWith({ id: assetStub.hasDupe.id, duplicateId: null }); + expect(mocks.asset.upsertJobStatus).toHaveBeenCalledWith({ assetId: assetStub.hasDupe.id, duplicatesDetectedAt: expect.any(Date), }); diff --git a/server/src/services/job.service.spec.ts b/server/src/services/job.service.spec.ts index 5d11f895a1..8b0b5c408c 100644 --- a/server/src/services/job.service.spec.ts +++ b/server/src/services/job.service.spec.ts @@ -1,27 +1,19 @@ import { BadRequestException } from '@nestjs/common'; import { defaults, SystemConfig } from 'src/config'; import { ImmichWorker } from 'src/enum'; -import { IAssetRepository } from 'src/interfaces/asset.interface'; -import { IJobRepository, JobCommand, JobItem, JobName, JobStatus, QueueName } from 'src/interfaces/job.interface'; +import { JobCommand, JobItem, JobName, JobStatus, QueueName } from 'src/interfaces/job.interface'; import { JobService } from 'src/services/job.service'; -import { IConfigRepository, ILoggingRepository } from 'src/types'; import { assetStub } from 'test/fixtures/asset.stub'; -import { ITelemetryRepositoryMock } from 'test/repositories/telemetry.repository.mock'; -import { newTestService } from 'test/utils'; -import { Mocked } from 'vitest'; +import { newTestService, ServiceMocks } from 'test/utils'; describe(JobService.name, () => { let sut: JobService; - let assetMock: Mocked; - let configMock: Mocked; - let jobMock: Mocked; - let loggerMock: Mocked; - let telemetryMock: ITelemetryRepositoryMock; + let mocks: ServiceMocks; beforeEach(() => { - ({ sut, assetMock, configMock, jobMock, loggerMock, telemetryMock } = newTestService(JobService, {})); + ({ sut, mocks } = newTestService(JobService, {})); - configMock.getWorker.mockReturnValue(ImmichWorker.MICROSERVICES); + mocks.config.getWorker.mockReturnValue(ImmichWorker.MICROSERVICES); }); it('should work', () => { @@ -32,11 +24,11 @@ describe(JobService.name, () => { it('should update concurrency', () => { sut.onConfigUpdate({ newConfig: defaults, oldConfig: {} as SystemConfig }); - expect(jobMock.setConcurrency).toHaveBeenCalledTimes(15); - expect(jobMock.setConcurrency).toHaveBeenNthCalledWith(5, QueueName.FACIAL_RECOGNITION, 1); - expect(jobMock.setConcurrency).toHaveBeenNthCalledWith(7, QueueName.DUPLICATE_DETECTION, 1); - expect(jobMock.setConcurrency).toHaveBeenNthCalledWith(8, QueueName.BACKGROUND_TASK, 5); - expect(jobMock.setConcurrency).toHaveBeenNthCalledWith(9, QueueName.STORAGE_TEMPLATE_MIGRATION, 1); + expect(mocks.job.setConcurrency).toHaveBeenCalledTimes(15); + expect(mocks.job.setConcurrency).toHaveBeenNthCalledWith(5, QueueName.FACIAL_RECOGNITION, 1); + expect(mocks.job.setConcurrency).toHaveBeenNthCalledWith(7, QueueName.DUPLICATE_DETECTION, 1); + expect(mocks.job.setConcurrency).toHaveBeenNthCalledWith(8, QueueName.BACKGROUND_TASK, 5); + expect(mocks.job.setConcurrency).toHaveBeenNthCalledWith(9, QueueName.STORAGE_TEMPLATE_MIGRATION, 1); }); }); @@ -44,7 +36,7 @@ describe(JobService.name, () => { it('should run the scheduled jobs', async () => { await sut.handleNightlyJobs(); - expect(jobMock.queueAll).toHaveBeenCalledWith([ + expect(mocks.job.queueAll).toHaveBeenCalledWith([ { name: JobName.ASSET_DELETION_CHECK }, { name: JobName.USER_DELETE_CHECK }, { name: JobName.PERSON_CLEANUP }, @@ -59,7 +51,7 @@ describe(JobService.name, () => { describe('getAllJobStatus', () => { it('should get all job statuses', async () => { - jobMock.getJobCounts.mockResolvedValue({ + mocks.job.getJobCounts.mockResolvedValue({ active: 1, completed: 1, failed: 1, @@ -67,7 +59,7 @@ describe(JobService.name, () => { waiting: 1, paused: 1, }); - jobMock.getQueueStatus.mockResolvedValue({ + mocks.job.getQueueStatus.mockResolvedValue({ isActive: true, isPaused: true, }); @@ -111,121 +103,121 @@ describe(JobService.name, () => { it('should handle a pause command', async () => { await sut.handleCommand(QueueName.METADATA_EXTRACTION, { command: JobCommand.PAUSE, force: false }); - expect(jobMock.pause).toHaveBeenCalledWith(QueueName.METADATA_EXTRACTION); + expect(mocks.job.pause).toHaveBeenCalledWith(QueueName.METADATA_EXTRACTION); }); it('should handle a resume command', async () => { await sut.handleCommand(QueueName.METADATA_EXTRACTION, { command: JobCommand.RESUME, force: false }); - expect(jobMock.resume).toHaveBeenCalledWith(QueueName.METADATA_EXTRACTION); + expect(mocks.job.resume).toHaveBeenCalledWith(QueueName.METADATA_EXTRACTION); }); it('should handle an empty command', async () => { await sut.handleCommand(QueueName.METADATA_EXTRACTION, { command: JobCommand.EMPTY, force: false }); - expect(jobMock.empty).toHaveBeenCalledWith(QueueName.METADATA_EXTRACTION); + expect(mocks.job.empty).toHaveBeenCalledWith(QueueName.METADATA_EXTRACTION); }); it('should not start a job that is already running', async () => { - jobMock.getQueueStatus.mockResolvedValue({ isActive: true, isPaused: false }); + mocks.job.getQueueStatus.mockResolvedValue({ isActive: true, isPaused: false }); await expect( sut.handleCommand(QueueName.VIDEO_CONVERSION, { command: JobCommand.START, force: false }), ).rejects.toBeInstanceOf(BadRequestException); - expect(jobMock.queue).not.toHaveBeenCalled(); - expect(jobMock.queueAll).not.toHaveBeenCalled(); + expect(mocks.job.queue).not.toHaveBeenCalled(); + expect(mocks.job.queueAll).not.toHaveBeenCalled(); }); it('should handle a start video conversion command', async () => { - jobMock.getQueueStatus.mockResolvedValue({ isActive: false, isPaused: false }); + mocks.job.getQueueStatus.mockResolvedValue({ isActive: false, isPaused: false }); await sut.handleCommand(QueueName.VIDEO_CONVERSION, { command: JobCommand.START, force: false }); - expect(jobMock.queue).toHaveBeenCalledWith({ name: JobName.QUEUE_VIDEO_CONVERSION, data: { force: false } }); + expect(mocks.job.queue).toHaveBeenCalledWith({ name: JobName.QUEUE_VIDEO_CONVERSION, data: { force: false } }); }); it('should handle a start storage template migration command', async () => { - jobMock.getQueueStatus.mockResolvedValue({ isActive: false, isPaused: false }); + mocks.job.getQueueStatus.mockResolvedValue({ isActive: false, isPaused: false }); await sut.handleCommand(QueueName.STORAGE_TEMPLATE_MIGRATION, { command: JobCommand.START, force: false }); - expect(jobMock.queue).toHaveBeenCalledWith({ name: JobName.STORAGE_TEMPLATE_MIGRATION }); + expect(mocks.job.queue).toHaveBeenCalledWith({ name: JobName.STORAGE_TEMPLATE_MIGRATION }); }); it('should handle a start smart search command', async () => { - jobMock.getQueueStatus.mockResolvedValue({ isActive: false, isPaused: false }); + mocks.job.getQueueStatus.mockResolvedValue({ isActive: false, isPaused: false }); await sut.handleCommand(QueueName.SMART_SEARCH, { command: JobCommand.START, force: false }); - expect(jobMock.queue).toHaveBeenCalledWith({ name: JobName.QUEUE_SMART_SEARCH, data: { force: false } }); + expect(mocks.job.queue).toHaveBeenCalledWith({ name: JobName.QUEUE_SMART_SEARCH, data: { force: false } }); }); it('should handle a start metadata extraction command', async () => { - jobMock.getQueueStatus.mockResolvedValue({ isActive: false, isPaused: false }); + mocks.job.getQueueStatus.mockResolvedValue({ isActive: false, isPaused: false }); await sut.handleCommand(QueueName.METADATA_EXTRACTION, { command: JobCommand.START, force: false }); - expect(jobMock.queue).toHaveBeenCalledWith({ name: JobName.QUEUE_METADATA_EXTRACTION, data: { force: false } }); + expect(mocks.job.queue).toHaveBeenCalledWith({ name: JobName.QUEUE_METADATA_EXTRACTION, data: { force: false } }); }); it('should handle a start sidecar command', async () => { - jobMock.getQueueStatus.mockResolvedValue({ isActive: false, isPaused: false }); + mocks.job.getQueueStatus.mockResolvedValue({ isActive: false, isPaused: false }); await sut.handleCommand(QueueName.SIDECAR, { command: JobCommand.START, force: false }); - expect(jobMock.queue).toHaveBeenCalledWith({ name: JobName.QUEUE_SIDECAR, data: { force: false } }); + expect(mocks.job.queue).toHaveBeenCalledWith({ name: JobName.QUEUE_SIDECAR, data: { force: false } }); }); it('should handle a start thumbnail generation command', async () => { - jobMock.getQueueStatus.mockResolvedValue({ isActive: false, isPaused: false }); + mocks.job.getQueueStatus.mockResolvedValue({ isActive: false, isPaused: false }); await sut.handleCommand(QueueName.THUMBNAIL_GENERATION, { command: JobCommand.START, force: false }); - expect(jobMock.queue).toHaveBeenCalledWith({ name: JobName.QUEUE_GENERATE_THUMBNAILS, data: { force: false } }); + expect(mocks.job.queue).toHaveBeenCalledWith({ name: JobName.QUEUE_GENERATE_THUMBNAILS, data: { force: false } }); }); it('should handle a start face detection command', async () => { - jobMock.getQueueStatus.mockResolvedValue({ isActive: false, isPaused: false }); + mocks.job.getQueueStatus.mockResolvedValue({ isActive: false, isPaused: false }); await sut.handleCommand(QueueName.FACE_DETECTION, { command: JobCommand.START, force: false }); - expect(jobMock.queue).toHaveBeenCalledWith({ name: JobName.QUEUE_FACE_DETECTION, data: { force: false } }); + expect(mocks.job.queue).toHaveBeenCalledWith({ name: JobName.QUEUE_FACE_DETECTION, data: { force: false } }); }); it('should handle a start facial recognition command', async () => { - jobMock.getQueueStatus.mockResolvedValue({ isActive: false, isPaused: false }); + mocks.job.getQueueStatus.mockResolvedValue({ isActive: false, isPaused: false }); await sut.handleCommand(QueueName.FACIAL_RECOGNITION, { command: JobCommand.START, force: false }); - expect(jobMock.queue).toHaveBeenCalledWith({ name: JobName.QUEUE_FACIAL_RECOGNITION, data: { force: false } }); + expect(mocks.job.queue).toHaveBeenCalledWith({ name: JobName.QUEUE_FACIAL_RECOGNITION, data: { force: false } }); }); it('should throw a bad request when an invalid queue is used', async () => { - jobMock.getQueueStatus.mockResolvedValue({ isActive: false, isPaused: false }); + mocks.job.getQueueStatus.mockResolvedValue({ isActive: false, isPaused: false }); await expect( sut.handleCommand(QueueName.BACKGROUND_TASK, { command: JobCommand.START, force: false }), ).rejects.toBeInstanceOf(BadRequestException); - expect(jobMock.queue).not.toHaveBeenCalled(); - expect(jobMock.queueAll).not.toHaveBeenCalled(); + expect(mocks.job.queue).not.toHaveBeenCalled(); + expect(mocks.job.queueAll).not.toHaveBeenCalled(); }); }); describe('onJobStart', () => { it('should process a successful job', async () => { - jobMock.run.mockResolvedValue(JobStatus.SUCCESS); + mocks.job.run.mockResolvedValue(JobStatus.SUCCESS); await sut.onJobStart(QueueName.BACKGROUND_TASK, { name: JobName.DELETE_FILES, data: { files: ['path/to/file'] }, }); - expect(telemetryMock.jobs.addToGauge).toHaveBeenCalledWith('immich.queues.background_task.active', 1); - expect(telemetryMock.jobs.addToGauge).toHaveBeenCalledWith('immich.queues.background_task.active', -1); - expect(telemetryMock.jobs.addToCounter).toHaveBeenCalledWith('immich.jobs.delete_files.success', 1); - expect(loggerMock.error).not.toHaveBeenCalled(); + expect(mocks.telemetry.jobs.addToGauge).toHaveBeenCalledWith('immich.queues.background_task.active', 1); + expect(mocks.telemetry.jobs.addToGauge).toHaveBeenCalledWith('immich.queues.background_task.active', -1); + expect(mocks.telemetry.jobs.addToCounter).toHaveBeenCalledWith('immich.jobs.delete_files.success', 1); + expect(mocks.logger.error).not.toHaveBeenCalled(); }); const tests: Array<{ item: JobItem; jobs: JobName[] }> = [ @@ -287,34 +279,34 @@ describe(JobService.name, () => { it(`should queue ${jobs.length} jobs when a ${item.name} job finishes successfully`, async () => { if (item.name === JobName.GENERATE_THUMBNAILS && item.data.source === 'upload') { if (item.data.id === 'asset-live-image') { - assetMock.getByIdsWithAllRelations.mockResolvedValue([assetStub.livePhotoStillAsset]); + mocks.asset.getByIdsWithAllRelations.mockResolvedValue([assetStub.livePhotoStillAsset]); } else { - assetMock.getByIdsWithAllRelations.mockResolvedValue([assetStub.livePhotoMotionAsset]); + mocks.asset.getByIdsWithAllRelations.mockResolvedValue([assetStub.livePhotoMotionAsset]); } } - jobMock.run.mockResolvedValue(JobStatus.SUCCESS); + mocks.job.run.mockResolvedValue(JobStatus.SUCCESS); await sut.onJobStart(QueueName.BACKGROUND_TASK, item); if (jobs.length > 1) { - expect(jobMock.queueAll).toHaveBeenCalledWith( + expect(mocks.job.queueAll).toHaveBeenCalledWith( jobs.map((jobName) => ({ name: jobName, data: expect.anything() })), ); } else { - expect(jobMock.queue).toHaveBeenCalledTimes(jobs.length); + expect(mocks.job.queue).toHaveBeenCalledTimes(jobs.length); for (const jobName of jobs) { - expect(jobMock.queue).toHaveBeenCalledWith({ name: jobName, data: expect.anything() }); + expect(mocks.job.queue).toHaveBeenCalledWith({ name: jobName, data: expect.anything() }); } } }); it(`should not queue any jobs when ${item.name} fails`, async () => { - jobMock.run.mockResolvedValue(JobStatus.FAILED); + mocks.job.run.mockResolvedValue(JobStatus.FAILED); await sut.onJobStart(QueueName.BACKGROUND_TASK, item); - expect(jobMock.queueAll).not.toHaveBeenCalled(); + expect(mocks.job.queueAll).not.toHaveBeenCalled(); }); } }); diff --git a/server/src/services/library.service.spec.ts b/server/src/services/library.service.spec.ts index 9f60e35dcc..24b1265ae9 100644 --- a/server/src/services/library.service.spec.ts +++ b/server/src/services/library.service.spec.ts @@ -4,28 +4,22 @@ import { defaults, SystemConfig } from 'src/config'; import { mapLibrary } from 'src/dtos/library.dto'; import { UserEntity } from 'src/entities/user.entity'; import { AssetType, ImmichWorker } from 'src/enum'; -import { IAssetRepository } from 'src/interfaces/asset.interface'; -import { IDatabaseRepository } from 'src/interfaces/database.interface'; import { - IJobRepository, ILibraryAssetJob, ILibraryFileJob, JobName, JOBS_LIBRARY_PAGINATION_SIZE, JobStatus, } from 'src/interfaces/job.interface'; -import { ILibraryRepository } from 'src/interfaces/library.interface'; -import { IStorageRepository } from 'src/interfaces/storage.interface'; import { LibraryService } from 'src/services/library.service'; -import { IConfigRepository, ICronRepository } from 'src/types'; import { assetStub } from 'test/fixtures/asset.stub'; import { authStub } from 'test/fixtures/auth.stub'; import { libraryStub } from 'test/fixtures/library.stub'; import { systemConfigStub } from 'test/fixtures/system-config.stub'; import { userStub } from 'test/fixtures/user.stub'; import { makeMockWatcher } from 'test/repositories/storage.repository.mock'; -import { newTestService } from 'test/utils'; -import { Mocked, vitest } from 'vitest'; +import { newTestService, ServiceMocks } from 'test/utils'; +import { vitest } from 'vitest'; async function* mockWalk() { yield await Promise.resolve(['/data/user1/photo.jpg']); @@ -33,21 +27,13 @@ async function* mockWalk() { describe(LibraryService.name, () => { let sut: LibraryService; - - let assetMock: Mocked; - let configMock: Mocked; - let cronMock: Mocked; - let databaseMock: Mocked; - let jobMock: Mocked; - let libraryMock: Mocked; - let storageMock: Mocked; + let mocks: ServiceMocks; beforeEach(() => { - ({ sut, assetMock, configMock, cronMock, databaseMock, jobMock, libraryMock, storageMock } = - newTestService(LibraryService)); + ({ sut, mocks } = newTestService(LibraryService)); - databaseMock.tryLock.mockResolvedValue(true); - configMock.getWorker.mockReturnValue(ImmichWorker.MICROSERVICES); + mocks.database.tryLock.mockResolvedValue(true); + mocks.config.getWorker.mockReturnValue(ImmichWorker.MICROSERVICES); }); it('should work', () => { @@ -58,7 +44,7 @@ describe(LibraryService.name, () => { it('should init cron job and handle config changes', async () => { await sut.onConfigInit({ newConfig: defaults }); - expect(cronMock.create).toHaveBeenCalled(); + expect(mocks.cron.create).toHaveBeenCalled(); await sut.onConfigUpdate({ oldConfig: defaults, @@ -73,16 +59,16 @@ describe(LibraryService.name, () => { } as SystemConfig, }); - expect(cronMock.update).toHaveBeenCalledWith({ name: 'libraryScan', expression: '0 1 * * *', start: true }); + expect(mocks.cron.update).toHaveBeenCalledWith({ name: 'libraryScan', expression: '0 1 * * *', start: true }); }); it('should initialize watcher for all external libraries', async () => { - libraryMock.getAll.mockResolvedValue([ + mocks.library.getAll.mockResolvedValue([ libraryStub.externalLibraryWithImportPaths1, libraryStub.externalLibraryWithImportPaths2, ]); - libraryMock.get.mockImplementation((id) => + mocks.library.get.mockImplementation((id) => Promise.resolve( [libraryStub.externalLibraryWithImportPaths1, libraryStub.externalLibraryWithImportPaths2].find( (library) => library.id === id, @@ -92,7 +78,7 @@ describe(LibraryService.name, () => { await sut.onConfigInit({ newConfig: systemConfigStub.libraryWatchEnabled as SystemConfig }); - expect(storageMock.watch.mock.calls).toEqual( + expect(mocks.storage.watch.mock.calls).toEqual( expect.arrayContaining([ (libraryStub.externalLibrary1.importPaths, expect.anything()), (libraryStub.externalLibrary2.importPaths, expect.anything()), @@ -103,47 +89,47 @@ describe(LibraryService.name, () => { it('should not initialize watcher when watching is disabled', async () => { await sut.onConfigInit({ newConfig: systemConfigStub.libraryWatchDisabled as SystemConfig }); - expect(storageMock.watch).not.toHaveBeenCalled(); + expect(mocks.storage.watch).not.toHaveBeenCalled(); }); it('should not initialize watcher when lock is taken', async () => { - databaseMock.tryLock.mockResolvedValue(false); + mocks.database.tryLock.mockResolvedValue(false); await sut.onConfigInit({ newConfig: systemConfigStub.libraryWatchEnabled as SystemConfig }); - expect(storageMock.watch).not.toHaveBeenCalled(); + expect(mocks.storage.watch).not.toHaveBeenCalled(); }); it('should not initialize library scan cron job when lock is taken', async () => { - databaseMock.tryLock.mockResolvedValue(false); + mocks.database.tryLock.mockResolvedValue(false); await sut.onConfigInit({ newConfig: systemConfigStub.libraryWatchEnabled as SystemConfig }); - expect(cronMock.create).not.toHaveBeenCalled(); + expect(mocks.cron.create).not.toHaveBeenCalled(); }); }); describe('onConfigUpdateEvent', () => { beforeEach(async () => { - databaseMock.tryLock.mockResolvedValue(true); + mocks.database.tryLock.mockResolvedValue(true); await sut.onConfigInit({ newConfig: defaults }); }); it('should do nothing if instance does not have the watch lock', async () => { - databaseMock.tryLock.mockResolvedValue(false); + mocks.database.tryLock.mockResolvedValue(false); await sut.onConfigInit({ newConfig: defaults }); await sut.onConfigUpdate({ newConfig: systemConfigStub.libraryScan as SystemConfig, oldConfig: defaults }); - expect(cronMock.update).not.toHaveBeenCalled(); + expect(mocks.cron.update).not.toHaveBeenCalled(); }); it('should update cron job and enable watching', async () => { - libraryMock.getAll.mockResolvedValue([]); + mocks.library.getAll.mockResolvedValue([]); await sut.onConfigUpdate({ newConfig: systemConfigStub.libraryScanAndWatch as SystemConfig, oldConfig: defaults, }); - expect(cronMock.update).toHaveBeenCalledWith({ + expect(mocks.cron.update).toHaveBeenCalledWith({ name: 'libraryScan', expression: systemConfigStub.libraryScan.library.scan.cronExpression, start: systemConfigStub.libraryScan.library.scan.enabled, @@ -151,7 +137,7 @@ describe(LibraryService.name, () => { }); it('should update cron job and disable watching', async () => { - libraryMock.getAll.mockResolvedValue([]); + mocks.library.getAll.mockResolvedValue([]); await sut.onConfigUpdate({ newConfig: systemConfigStub.libraryScanAndWatch as SystemConfig, oldConfig: defaults, @@ -161,7 +147,7 @@ describe(LibraryService.name, () => { oldConfig: defaults, }); - expect(cronMock.update).toHaveBeenCalledWith({ + expect(mocks.cron.update).toHaveBeenCalledWith({ name: 'libraryScan', expression: systemConfigStub.libraryScan.library.scan.cronExpression, start: systemConfigStub.libraryScan.library.scan.enabled, @@ -171,12 +157,12 @@ describe(LibraryService.name, () => { describe('handleQueueSyncFiles', () => { it('should queue refresh of a new asset', async () => { - libraryMock.get.mockResolvedValue(libraryStub.externalLibrary1); - storageMock.walk.mockImplementation(mockWalk); + mocks.library.get.mockResolvedValue(libraryStub.externalLibrary1); + mocks.storage.walk.mockImplementation(mockWalk); await sut.handleQueueSyncFiles({ id: libraryStub.externalLibrary1.id }); - expect(jobMock.queueAll).toHaveBeenCalledWith([ + expect(mocks.job.queueAll).toHaveBeenCalledWith([ { name: JobName.LIBRARY_SYNC_FILE, data: { @@ -193,7 +179,7 @@ describe(LibraryService.name, () => { }); it('should ignore import paths that do not exist', async () => { - storageMock.stat.mockImplementation((path): Promise => { + mocks.storage.stat.mockImplementation((path): Promise => { if (path === libraryStub.externalLibraryWithImportPaths1.importPaths[0]) { const error = { code: 'ENOENT' } as any; throw error; @@ -203,13 +189,13 @@ describe(LibraryService.name, () => { } as Stats); }); - storageMock.checkFileExists.mockResolvedValue(true); + mocks.storage.checkFileExists.mockResolvedValue(true); - libraryMock.get.mockResolvedValue(libraryStub.externalLibraryWithImportPaths1); + mocks.library.get.mockResolvedValue(libraryStub.externalLibraryWithImportPaths1); await sut.handleQueueSyncFiles({ id: libraryStub.externalLibraryWithImportPaths1.id }); - expect(storageMock.walk).toHaveBeenCalledWith({ + expect(mocks.storage.walk).toHaveBeenCalledWith({ pathsToCrawl: [libraryStub.externalLibraryWithImportPaths1.importPaths[1]], exclusionPatterns: [], includeHidden: false, @@ -220,13 +206,13 @@ describe(LibraryService.name, () => { describe('handleQueueRemoveDeleted', () => { it('should queue online check of existing assets', async () => { - libraryMock.get.mockResolvedValue(libraryStub.externalLibrary1); - storageMock.walk.mockImplementation(async function* generator() {}); - assetMock.getAll.mockResolvedValue({ items: [assetStub.external], hasNextPage: false }); + mocks.library.get.mockResolvedValue(libraryStub.externalLibrary1); + mocks.storage.walk.mockImplementation(async function* generator() {}); + mocks.asset.getAll.mockResolvedValue({ items: [assetStub.external], hasNextPage: false }); await sut.handleQueueSyncAssets({ id: libraryStub.externalLibrary1.id }); - expect(jobMock.queueAll).toHaveBeenCalledWith([ + expect(mocks.job.queueAll).toHaveBeenCalledWith([ { name: JobName.LIBRARY_SYNC_ASSET, data: { @@ -253,7 +239,7 @@ describe(LibraryService.name, () => { await expect(sut.handleSyncAsset(mockAssetJob)).resolves.toBe(JobStatus.SKIPPED); - expect(assetMock.remove).not.toHaveBeenCalled(); + expect(mocks.asset.remove).not.toHaveBeenCalled(); }); it('should offline assets no longer on disk', async () => { @@ -263,12 +249,12 @@ describe(LibraryService.name, () => { exclusionPatterns: [], }; - assetMock.getById.mockResolvedValue(assetStub.external); - storageMock.stat.mockRejectedValue(new Error('ENOENT, no such file or directory')); + mocks.asset.getById.mockResolvedValue(assetStub.external); + mocks.storage.stat.mockRejectedValue(new Error('ENOENT, no such file or directory')); await expect(sut.handleSyncAsset(mockAssetJob)).resolves.toBe(JobStatus.SUCCESS); - expect(assetMock.updateAll).toHaveBeenCalledWith([assetStub.external.id], { + expect(mocks.asset.updateAll).toHaveBeenCalledWith([assetStub.external.id], { isOffline: true, deletedAt: expect.any(Date), }); @@ -281,10 +267,10 @@ describe(LibraryService.name, () => { exclusionPatterns: ['**/user1/**'], }; - assetMock.getById.mockResolvedValue(assetStub.external); + mocks.asset.getById.mockResolvedValue(assetStub.external); await expect(sut.handleSyncAsset(mockAssetJob)).resolves.toBe(JobStatus.SUCCESS); - expect(assetMock.updateAll).toHaveBeenCalledWith([assetStub.external.id], { + expect(mocks.asset.updateAll).toHaveBeenCalledWith([assetStub.external.id], { isOffline: true, deletedAt: expect.any(Date), }); @@ -297,12 +283,12 @@ describe(LibraryService.name, () => { exclusionPatterns: [], }; - assetMock.getById.mockResolvedValue(assetStub.external); - storageMock.checkFileExists.mockResolvedValue(true); + mocks.asset.getById.mockResolvedValue(assetStub.external); + mocks.storage.checkFileExists.mockResolvedValue(true); await expect(sut.handleSyncAsset(mockAssetJob)).resolves.toBe(JobStatus.SUCCESS); - expect(assetMock.updateAll).toHaveBeenCalledWith([assetStub.external.id], { + expect(mocks.asset.updateAll).toHaveBeenCalledWith([assetStub.external.id], { isOffline: true, deletedAt: expect.any(Date), }); @@ -315,12 +301,12 @@ describe(LibraryService.name, () => { exclusionPatterns: [], }; - assetMock.getById.mockResolvedValue(assetStub.external); - storageMock.stat.mockResolvedValue({ mtime: assetStub.external.fileModifiedAt } as Stats); + mocks.asset.getById.mockResolvedValue(assetStub.external); + mocks.storage.stat.mockResolvedValue({ mtime: assetStub.external.fileModifiedAt } as Stats); await expect(sut.handleSyncAsset(mockAssetJob)).resolves.toBe(JobStatus.SUCCESS); - expect(assetMock.updateAll).not.toHaveBeenCalled(); + expect(mocks.asset.updateAll).not.toHaveBeenCalled(); }); it('should un-trash an asset previously marked as offline', async () => { @@ -330,12 +316,12 @@ describe(LibraryService.name, () => { exclusionPatterns: [], }; - assetMock.getById.mockResolvedValue(assetStub.trashedOffline); - storageMock.stat.mockResolvedValue({ mtime: assetStub.trashedOffline.fileModifiedAt } as Stats); + mocks.asset.getById.mockResolvedValue(assetStub.trashedOffline); + mocks.storage.stat.mockResolvedValue({ mtime: assetStub.trashedOffline.fileModifiedAt } as Stats); await expect(sut.handleSyncAsset(mockAssetJob)).resolves.toBe(JobStatus.SUCCESS); - expect(assetMock.updateAll).toHaveBeenCalledWith([assetStub.trashedOffline.id], { + expect(mocks.asset.updateAll).toHaveBeenCalledWith([assetStub.trashedOffline.id], { deletedAt: null, fileModifiedAt: assetStub.trashedOffline.fileModifiedAt, isOffline: false, @@ -350,12 +336,12 @@ describe(LibraryService.name, () => { exclusionPatterns: [], }; - assetMock.getById.mockResolvedValue(assetStub.trashedOffline); - storageMock.stat.mockResolvedValue({ mtime: assetStub.trashedOffline.fileModifiedAt } as Stats); + mocks.asset.getById.mockResolvedValue(assetStub.trashedOffline); + mocks.storage.stat.mockResolvedValue({ mtime: assetStub.trashedOffline.fileModifiedAt } as Stats); await expect(sut.handleSyncAsset(mockAssetJob)).resolves.toBe(JobStatus.SUCCESS); - expect(assetMock.updateAll).toHaveBeenCalledWith( + expect(mocks.asset.updateAll).toHaveBeenCalledWith( [assetStub.trashedOffline.id], expect.not.objectContaining({ fileCreatedAt: expect.anything(), @@ -372,12 +358,12 @@ describe(LibraryService.name, () => { }; const newMTime = new Date(); - assetMock.getById.mockResolvedValue(assetStub.external); - storageMock.stat.mockResolvedValue({ mtime: newMTime } as Stats); + mocks.asset.getById.mockResolvedValue(assetStub.external); + mocks.storage.stat.mockResolvedValue({ mtime: newMTime } as Stats); await expect(sut.handleSyncAsset(mockAssetJob)).resolves.toBe(JobStatus.SUCCESS); - expect(assetMock.updateAll).toHaveBeenCalledWith([assetStub.external.id], { + expect(mocks.asset.updateAll).toHaveBeenCalledWith([assetStub.external.id], { fileModifiedAt: newMTime, isOffline: false, originalFileName: 'photo.jpg', @@ -391,7 +377,7 @@ describe(LibraryService.name, () => { beforeEach(() => { mockUser = userStub.admin; - storageMock.stat.mockResolvedValue({ + mocks.storage.stat.mockResolvedValue({ size: 100, mtime: new Date('2023-01-01'), ctime: new Date('2023-01-01'), @@ -405,12 +391,12 @@ describe(LibraryService.name, () => { assetPath: '/data/user1/photo.jpg', }; - assetMock.create.mockResolvedValue(assetStub.image); - libraryMock.get.mockResolvedValue(libraryStub.externalLibrary1); + mocks.asset.create.mockResolvedValue(assetStub.image); + mocks.library.get.mockResolvedValue(libraryStub.externalLibrary1); await expect(sut.handleSyncFile(mockLibraryJob)).resolves.toBe(JobStatus.SUCCESS); - expect(assetMock.create.mock.calls).toEqual([ + expect(mocks.asset.create.mock.calls).toEqual([ [ { ownerId: mockUser.id, @@ -429,7 +415,7 @@ describe(LibraryService.name, () => { ], ]); - expect(jobMock.queue.mock.calls).toEqual([ + expect(mocks.job.queue.mock.calls).toEqual([ [ { name: JobName.SIDECAR_DISCOVERY, @@ -449,12 +435,12 @@ describe(LibraryService.name, () => { assetPath: '/data/user1/video.mp4', }; - assetMock.create.mockResolvedValue(assetStub.video); - libraryMock.get.mockResolvedValue(libraryStub.externalLibrary1); + mocks.asset.create.mockResolvedValue(assetStub.video); + mocks.library.get.mockResolvedValue(libraryStub.externalLibrary1); await expect(sut.handleSyncFile(mockLibraryJob)).resolves.toBe(JobStatus.SUCCESS); - expect(assetMock.create.mock.calls).toEqual([ + expect(mocks.asset.create.mock.calls).toEqual([ [ { ownerId: mockUser.id, @@ -473,7 +459,7 @@ describe(LibraryService.name, () => { ], ]); - expect(jobMock.queue.mock.calls).toEqual([ + expect(mocks.job.queue.mock.calls).toEqual([ [ { name: JobName.SIDECAR_DISCOVERY, @@ -493,12 +479,12 @@ describe(LibraryService.name, () => { assetPath: '/data/user1/photo.jpg', }; - assetMock.create.mockResolvedValue(assetStub.image); - libraryMock.get.mockResolvedValue({ ...libraryStub.externalLibrary1, deletedAt: new Date() }); + mocks.asset.create.mockResolvedValue(assetStub.image); + mocks.library.get.mockResolvedValue({ ...libraryStub.externalLibrary1, deletedAt: new Date() }); await expect(sut.handleSyncFile(mockLibraryJob)).resolves.toBe(JobStatus.FAILED); - expect(assetMock.create.mock.calls).toEqual([]); + expect(mocks.asset.create.mock.calls).toEqual([]); }); it('should not refresh a file whose mtime matches existing asset', async () => { @@ -508,18 +494,18 @@ describe(LibraryService.name, () => { assetPath: assetStub.hasFileExtension.originalPath, }; - storageMock.stat.mockResolvedValue({ + mocks.storage.stat.mockResolvedValue({ size: 100, mtime: assetStub.hasFileExtension.fileModifiedAt, ctime: new Date('2023-01-01'), } as Stats); - assetMock.getByLibraryIdAndOriginalPath.mockResolvedValue(assetStub.hasFileExtension); + mocks.asset.getByLibraryIdAndOriginalPath.mockResolvedValue(assetStub.hasFileExtension); await expect(sut.handleSyncFile(mockLibraryJob)).resolves.toBe(JobStatus.SKIPPED); - expect(jobMock.queue).not.toHaveBeenCalled(); - expect(jobMock.queueAll).not.toHaveBeenCalled(); + expect(mocks.job.queue).not.toHaveBeenCalled(); + expect(mocks.job.queueAll).not.toHaveBeenCalled(); }); it('should skip existing asset', async () => { @@ -529,7 +515,7 @@ describe(LibraryService.name, () => { assetPath: '/data/user1/photo.jpg', }; - assetMock.getByLibraryIdAndOriginalPath.mockResolvedValue(assetStub.image); + mocks.asset.getByLibraryIdAndOriginalPath.mockResolvedValue(assetStub.image); await expect(sut.handleSyncFile(mockLibraryJob)).resolves.toBe(JobStatus.SKIPPED); }); @@ -541,16 +527,16 @@ describe(LibraryService.name, () => { assetPath: assetStub.hasFileExtension.originalPath, }; - assetMock.getByLibraryIdAndOriginalPath.mockResolvedValue(assetStub.trashed); + mocks.asset.getByLibraryIdAndOriginalPath.mockResolvedValue(assetStub.trashed); await expect(sut.handleSyncFile(mockLibraryJob)).resolves.toBe(JobStatus.SKIPPED); - expect(jobMock.queue).not.toHaveBeenCalled(); - expect(jobMock.queueAll).not.toHaveBeenCalled(); + expect(mocks.job.queue).not.toHaveBeenCalled(); + expect(mocks.job.queueAll).not.toHaveBeenCalled(); }); it('should fail when the file could not be read', async () => { - storageMock.stat.mockRejectedValue(new Error('Could not read file')); + mocks.storage.stat.mockRejectedValue(new Error('Could not read file')); const mockLibraryJob: ILibraryFileJob = { id: libraryStub.externalLibrary1.id, @@ -558,17 +544,17 @@ describe(LibraryService.name, () => { assetPath: '/data/user1/photo.jpg', }; - assetMock.create.mockResolvedValue(assetStub.image); + mocks.asset.create.mockResolvedValue(assetStub.image); await expect(sut.handleSyncFile(mockLibraryJob)).resolves.toBe(JobStatus.FAILED); - expect(libraryMock.get).not.toHaveBeenCalled(); - expect(assetMock.create).not.toHaveBeenCalled(); + expect(mocks.library.get).not.toHaveBeenCalled(); + expect(mocks.asset.create).not.toHaveBeenCalled(); }); it('should skip if the file could not be found', async () => { const error = new Error('File not found') as any; error.code = 'ENOENT'; - storageMock.stat.mockRejectedValue(error); + mocks.storage.stat.mockRejectedValue(error); const mockLibraryJob: ILibraryFileJob = { id: libraryStub.externalLibrary1.id, @@ -576,50 +562,50 @@ describe(LibraryService.name, () => { assetPath: '/data/user1/photo.jpg', }; - assetMock.create.mockResolvedValue(assetStub.image); + mocks.asset.create.mockResolvedValue(assetStub.image); await expect(sut.handleSyncFile(mockLibraryJob)).resolves.toBe(JobStatus.SKIPPED); - expect(libraryMock.get).not.toHaveBeenCalled(); - expect(assetMock.create).not.toHaveBeenCalled(); + expect(mocks.library.get).not.toHaveBeenCalled(); + expect(mocks.asset.create).not.toHaveBeenCalled(); }); }); describe('delete', () => { it('should delete a library', async () => { - assetMock.getByLibraryIdAndOriginalPath.mockResolvedValue(assetStub.image); - libraryMock.get.mockResolvedValue(libraryStub.externalLibrary1); + mocks.asset.getByLibraryIdAndOriginalPath.mockResolvedValue(assetStub.image); + mocks.library.get.mockResolvedValue(libraryStub.externalLibrary1); await sut.delete(libraryStub.externalLibrary1.id); - expect(jobMock.queue).toHaveBeenCalledWith({ + expect(mocks.job.queue).toHaveBeenCalledWith({ name: JobName.LIBRARY_DELETE, data: { id: libraryStub.externalLibrary1.id }, }); - expect(libraryMock.softDelete).toHaveBeenCalledWith(libraryStub.externalLibrary1.id); + expect(mocks.library.softDelete).toHaveBeenCalledWith(libraryStub.externalLibrary1.id); }); it('should allow an external library to be deleted', async () => { - assetMock.getByLibraryIdAndOriginalPath.mockResolvedValue(assetStub.image); - libraryMock.get.mockResolvedValue(libraryStub.externalLibrary1); + mocks.asset.getByLibraryIdAndOriginalPath.mockResolvedValue(assetStub.image); + mocks.library.get.mockResolvedValue(libraryStub.externalLibrary1); await sut.delete(libraryStub.externalLibrary1.id); - expect(jobMock.queue).toHaveBeenCalledWith({ + expect(mocks.job.queue).toHaveBeenCalledWith({ name: JobName.LIBRARY_DELETE, data: { id: libraryStub.externalLibrary1.id }, }); - expect(libraryMock.softDelete).toHaveBeenCalledWith(libraryStub.externalLibrary1.id); + expect(mocks.library.softDelete).toHaveBeenCalledWith(libraryStub.externalLibrary1.id); }); it('should unwatch an external library when deleted', async () => { - assetMock.getByLibraryIdAndOriginalPath.mockResolvedValue(assetStub.image); - libraryMock.get.mockResolvedValue(libraryStub.externalLibraryWithImportPaths1); - libraryMock.getAll.mockResolvedValue([libraryStub.externalLibraryWithImportPaths1]); + mocks.asset.getByLibraryIdAndOriginalPath.mockResolvedValue(assetStub.image); + mocks.library.get.mockResolvedValue(libraryStub.externalLibraryWithImportPaths1); + mocks.library.getAll.mockResolvedValue([libraryStub.externalLibraryWithImportPaths1]); const mockClose = vitest.fn(); - storageMock.watch.mockImplementation(makeMockWatcher({ close: mockClose })); + mocks.storage.watch.mockImplementation(makeMockWatcher({ close: mockClose })); await sut.onConfigInit({ newConfig: systemConfigStub.libraryWatchEnabled as SystemConfig }); await sut.delete(libraryStub.externalLibraryWithImportPaths1.id); @@ -630,7 +616,7 @@ describe(LibraryService.name, () => { describe('get', () => { it('should return a library', async () => { - libraryMock.get.mockResolvedValue(libraryStub.externalLibrary1); + mocks.library.get.mockResolvedValue(libraryStub.externalLibrary1); await expect(sut.get(libraryStub.externalLibrary1.id)).resolves.toEqual( expect.objectContaining({ id: libraryStub.externalLibrary1.id, @@ -639,18 +625,18 @@ describe(LibraryService.name, () => { }), ); - expect(libraryMock.get).toHaveBeenCalledWith(libraryStub.externalLibrary1.id); + expect(mocks.library.get).toHaveBeenCalledWith(libraryStub.externalLibrary1.id); }); it('should throw an error when a library is not found', async () => { await expect(sut.get(libraryStub.externalLibrary1.id)).rejects.toBeInstanceOf(BadRequestException); - expect(libraryMock.get).toHaveBeenCalledWith(libraryStub.externalLibrary1.id); + expect(mocks.library.get).toHaveBeenCalledWith(libraryStub.externalLibrary1.id); }); }); describe('getStatistics', () => { it('should return library statistics', async () => { - libraryMock.getStatistics.mockResolvedValue({ photos: 10, videos: 0, total: 10, usage: 1337 }); + mocks.library.getStatistics.mockResolvedValue({ photos: 10, videos: 0, total: 10, usage: 1337 }); await expect(sut.getStatistics(libraryStub.externalLibrary1.id)).resolves.toEqual({ photos: 10, videos: 0, @@ -658,7 +644,7 @@ describe(LibraryService.name, () => { usage: 1337, }); - expect(libraryMock.getStatistics).toHaveBeenCalledWith(libraryStub.externalLibrary1.id); + expect(mocks.library.getStatistics).toHaveBeenCalledWith(libraryStub.externalLibrary1.id); }); it('should throw an error if the library could not be found', async () => { @@ -669,7 +655,7 @@ describe(LibraryService.name, () => { describe('create', () => { describe('external library', () => { it('should create with default settings', async () => { - libraryMock.create.mockResolvedValue(libraryStub.externalLibrary1); + mocks.library.create.mockResolvedValue(libraryStub.externalLibrary1); await expect(sut.create({ ownerId: authStub.admin.user.id })).resolves.toEqual( expect.objectContaining({ id: libraryStub.externalLibrary1.id, @@ -684,7 +670,7 @@ describe(LibraryService.name, () => { }), ); - expect(libraryMock.create).toHaveBeenCalledWith( + expect(mocks.library.create).toHaveBeenCalledWith( expect.objectContaining({ name: expect.any(String), importPaths: [], @@ -694,7 +680,7 @@ describe(LibraryService.name, () => { }); it('should create with name', async () => { - libraryMock.create.mockResolvedValue(libraryStub.externalLibrary1); + mocks.library.create.mockResolvedValue(libraryStub.externalLibrary1); await expect(sut.create({ ownerId: authStub.admin.user.id, name: 'My Awesome Library' })).resolves.toEqual( expect.objectContaining({ id: libraryStub.externalLibrary1.id, @@ -709,7 +695,7 @@ describe(LibraryService.name, () => { }), ); - expect(libraryMock.create).toHaveBeenCalledWith( + expect(mocks.library.create).toHaveBeenCalledWith( expect.objectContaining({ name: 'My Awesome Library', importPaths: [], @@ -719,7 +705,7 @@ describe(LibraryService.name, () => { }); it('should create with import paths', async () => { - libraryMock.create.mockResolvedValue(libraryStub.externalLibrary1); + mocks.library.create.mockResolvedValue(libraryStub.externalLibrary1); await expect( sut.create({ ownerId: authStub.admin.user.id, @@ -739,7 +725,7 @@ describe(LibraryService.name, () => { }), ); - expect(libraryMock.create).toHaveBeenCalledWith( + expect(mocks.library.create).toHaveBeenCalledWith( expect.objectContaining({ name: expect.any(String), importPaths: ['/data/images', '/data/videos'], @@ -749,9 +735,9 @@ describe(LibraryService.name, () => { }); it('should create watched with import paths', async () => { - libraryMock.create.mockResolvedValue(libraryStub.externalLibraryWithImportPaths1); - libraryMock.get.mockResolvedValue(libraryStub.externalLibraryWithImportPaths1); - libraryMock.getAll.mockResolvedValue([]); + mocks.library.create.mockResolvedValue(libraryStub.externalLibraryWithImportPaths1); + mocks.library.get.mockResolvedValue(libraryStub.externalLibraryWithImportPaths1); + mocks.library.getAll.mockResolvedValue([]); await sut.onConfigInit({ newConfig: systemConfigStub.libraryWatchEnabled as SystemConfig }); await sut.create({ @@ -761,7 +747,7 @@ describe(LibraryService.name, () => { }); it('should create with exclusion patterns', async () => { - libraryMock.create.mockResolvedValue(libraryStub.externalLibrary1); + mocks.library.create.mockResolvedValue(libraryStub.externalLibrary1); await expect( sut.create({ ownerId: authStub.admin.user.id, @@ -781,7 +767,7 @@ describe(LibraryService.name, () => { }), ); - expect(libraryMock.create).toHaveBeenCalledWith( + expect(mocks.library.create).toHaveBeenCalledWith( expect.objectContaining({ name: expect.any(String), importPaths: [], @@ -794,17 +780,17 @@ describe(LibraryService.name, () => { describe('getAll', () => { it('should get all libraries', async () => { - libraryMock.getAll.mockResolvedValue([libraryStub.externalLibrary1]); + mocks.library.getAll.mockResolvedValue([libraryStub.externalLibrary1]); await expect(sut.getAll()).resolves.toEqual([expect.objectContaining({ id: libraryStub.externalLibrary1.id })]); }); }); describe('handleQueueCleanup', () => { it('should queue cleanup jobs', async () => { - libraryMock.getAllDeleted.mockResolvedValue([libraryStub.externalLibrary1, libraryStub.externalLibrary2]); + mocks.library.getAllDeleted.mockResolvedValue([libraryStub.externalLibrary1, libraryStub.externalLibrary2]); await expect(sut.handleQueueCleanup()).resolves.toBe(JobStatus.SUCCESS); - expect(jobMock.queueAll).toHaveBeenCalledWith([ + expect(mocks.job.queueAll).toHaveBeenCalledWith([ { name: JobName.LIBRARY_DELETE, data: { id: libraryStub.externalLibrary1.id } }, { name: JobName.LIBRARY_DELETE, data: { id: libraryStub.externalLibrary2.id } }, ]); @@ -813,31 +799,31 @@ describe(LibraryService.name, () => { describe('update', () => { beforeEach(async () => { - libraryMock.getAll.mockResolvedValue([]); + mocks.library.getAll.mockResolvedValue([]); await sut.onConfigInit({ newConfig: systemConfigStub.libraryWatchEnabled as SystemConfig }); }); it('should throw an error if an import path is invalid', async () => { - libraryMock.update.mockResolvedValue(libraryStub.externalLibrary1); - libraryMock.get.mockResolvedValue(libraryStub.externalLibrary1); + mocks.library.update.mockResolvedValue(libraryStub.externalLibrary1); + mocks.library.get.mockResolvedValue(libraryStub.externalLibrary1); await expect(sut.update('library-id', { importPaths: ['foo/bar'] })).rejects.toBeInstanceOf(BadRequestException); - expect(libraryMock.update).not.toHaveBeenCalled(); + expect(mocks.library.update).not.toHaveBeenCalled(); }); it('should update library', async () => { - libraryMock.update.mockResolvedValue(libraryStub.externalLibrary1); - libraryMock.get.mockResolvedValue(libraryStub.externalLibrary1); - storageMock.stat.mockResolvedValue({ isDirectory: () => true } as Stats); - storageMock.checkFileExists.mockResolvedValue(true); + mocks.library.update.mockResolvedValue(libraryStub.externalLibrary1); + mocks.library.get.mockResolvedValue(libraryStub.externalLibrary1); + mocks.storage.stat.mockResolvedValue({ isDirectory: () => true } as Stats); + mocks.storage.checkFileExists.mockResolvedValue(true); const cwd = process.cwd(); await expect(sut.update('library-id', { importPaths: [`${cwd}/foo/bar`] })).resolves.toEqual( mapLibrary(libraryStub.externalLibrary1), ); - expect(libraryMock.update).toHaveBeenCalledWith( + expect(mocks.library.update).toHaveBeenCalledWith( 'library-id', expect.objectContaining({ importPaths: [`${cwd}/foo/bar`] }), ); @@ -861,27 +847,27 @@ describe(LibraryService.name, () => { }); it('should not watch library', async () => { - libraryMock.getAll.mockResolvedValue([libraryStub.externalLibraryWithImportPaths1]); + mocks.library.getAll.mockResolvedValue([libraryStub.externalLibraryWithImportPaths1]); await sut.watchAll(); - expect(storageMock.watch).not.toHaveBeenCalled(); + expect(mocks.storage.watch).not.toHaveBeenCalled(); }); }); describe('watching enabled', () => { beforeEach(async () => { - libraryMock.getAll.mockResolvedValue([]); + mocks.library.getAll.mockResolvedValue([]); await sut.onConfigInit({ newConfig: systemConfigStub.libraryWatchEnabled as SystemConfig }); }); it('should watch library', async () => { - libraryMock.get.mockResolvedValue(libraryStub.externalLibraryWithImportPaths1); - libraryMock.getAll.mockResolvedValue([libraryStub.externalLibraryWithImportPaths1]); + mocks.library.get.mockResolvedValue(libraryStub.externalLibraryWithImportPaths1); + mocks.library.getAll.mockResolvedValue([libraryStub.externalLibraryWithImportPaths1]); await sut.watchAll(); - expect(storageMock.watch).toHaveBeenCalledWith( + expect(mocks.storage.watch).toHaveBeenCalledWith( libraryStub.externalLibraryWithImportPaths1.importPaths, expect.anything(), expect.anything(), @@ -889,10 +875,10 @@ describe(LibraryService.name, () => { }); it('should watch and unwatch library', async () => { - libraryMock.getAll.mockResolvedValue([libraryStub.externalLibraryWithImportPaths1]); - libraryMock.get.mockResolvedValue(libraryStub.externalLibraryWithImportPaths1); + mocks.library.getAll.mockResolvedValue([libraryStub.externalLibraryWithImportPaths1]); + mocks.library.get.mockResolvedValue(libraryStub.externalLibraryWithImportPaths1); const mockClose = vitest.fn(); - storageMock.watch.mockImplementation(makeMockWatcher({ close: mockClose })); + mocks.storage.watch.mockImplementation(makeMockWatcher({ close: mockClose })); await sut.watchAll(); await sut.unwatch(libraryStub.externalLibraryWithImportPaths1.id); @@ -901,23 +887,23 @@ describe(LibraryService.name, () => { }); it('should not watch library without import paths', async () => { - libraryMock.get.mockResolvedValue(libraryStub.externalLibrary1); - libraryMock.getAll.mockResolvedValue([libraryStub.externalLibrary1]); + mocks.library.get.mockResolvedValue(libraryStub.externalLibrary1); + mocks.library.getAll.mockResolvedValue([libraryStub.externalLibrary1]); await sut.watchAll(); - expect(storageMock.watch).not.toHaveBeenCalled(); + expect(mocks.storage.watch).not.toHaveBeenCalled(); }); it('should handle a new file event', async () => { - libraryMock.get.mockResolvedValue(libraryStub.externalLibraryWithImportPaths1); - libraryMock.getAll.mockResolvedValue([libraryStub.externalLibraryWithImportPaths1]); - assetMock.getByLibraryIdAndOriginalPath.mockResolvedValue(assetStub.image); - storageMock.watch.mockImplementation(makeMockWatcher({ items: [{ event: 'add', value: '/foo/photo.jpg' }] })); + mocks.library.get.mockResolvedValue(libraryStub.externalLibraryWithImportPaths1); + mocks.library.getAll.mockResolvedValue([libraryStub.externalLibraryWithImportPaths1]); + mocks.asset.getByLibraryIdAndOriginalPath.mockResolvedValue(assetStub.image); + mocks.storage.watch.mockImplementation(makeMockWatcher({ items: [{ event: 'add', value: '/foo/photo.jpg' }] })); await sut.watchAll(); - expect(jobMock.queueAll).toHaveBeenCalledWith([ + expect(mocks.job.queueAll).toHaveBeenCalledWith([ { name: JobName.LIBRARY_SYNC_FILE, data: { @@ -927,22 +913,22 @@ describe(LibraryService.name, () => { }, }, ]); - expect(jobMock.queueAll).toHaveBeenCalledWith([ + expect(mocks.job.queueAll).toHaveBeenCalledWith([ { name: JobName.LIBRARY_SYNC_ASSET, data: expect.objectContaining({ id: assetStub.image.id }) }, ]); }); it('should handle a file change event', async () => { - libraryMock.get.mockResolvedValue(libraryStub.externalLibraryWithImportPaths1); - libraryMock.getAll.mockResolvedValue([libraryStub.externalLibraryWithImportPaths1]); - assetMock.getByLibraryIdAndOriginalPath.mockResolvedValue(assetStub.image); - storageMock.watch.mockImplementation( + mocks.library.get.mockResolvedValue(libraryStub.externalLibraryWithImportPaths1); + mocks.library.getAll.mockResolvedValue([libraryStub.externalLibraryWithImportPaths1]); + mocks.asset.getByLibraryIdAndOriginalPath.mockResolvedValue(assetStub.image); + mocks.storage.watch.mockImplementation( makeMockWatcher({ items: [{ event: 'change', value: '/foo/photo.jpg' }] }), ); await sut.watchAll(); - expect(jobMock.queueAll).toHaveBeenCalledWith([ + expect(mocks.job.queueAll).toHaveBeenCalledWith([ { name: JobName.LIBRARY_SYNC_FILE, data: { @@ -952,31 +938,31 @@ describe(LibraryService.name, () => { }, }, ]); - expect(jobMock.queueAll).toHaveBeenCalledWith([ + expect(mocks.job.queueAll).toHaveBeenCalledWith([ { name: JobName.LIBRARY_SYNC_ASSET, data: expect.objectContaining({ id: assetStub.image.id }) }, ]); }); it('should handle a file unlink event', async () => { - libraryMock.get.mockResolvedValue(libraryStub.externalLibraryWithImportPaths1); - libraryMock.getAll.mockResolvedValue([libraryStub.externalLibraryWithImportPaths1]); - assetMock.getByLibraryIdAndOriginalPath.mockResolvedValue(assetStub.image); - storageMock.watch.mockImplementation( + mocks.library.get.mockResolvedValue(libraryStub.externalLibraryWithImportPaths1); + mocks.library.getAll.mockResolvedValue([libraryStub.externalLibraryWithImportPaths1]); + mocks.asset.getByLibraryIdAndOriginalPath.mockResolvedValue(assetStub.image); + mocks.storage.watch.mockImplementation( makeMockWatcher({ items: [{ event: 'unlink', value: '/foo/photo.jpg' }] }), ); await sut.watchAll(); - expect(jobMock.queueAll).toHaveBeenCalledWith([ + expect(mocks.job.queueAll).toHaveBeenCalledWith([ { name: JobName.LIBRARY_SYNC_ASSET, data: expect.objectContaining({ id: assetStub.image.id }) }, ]); }); it('should handle an error event', async () => { - libraryMock.get.mockResolvedValue(libraryStub.externalLibraryWithImportPaths1); - assetMock.getByLibraryIdAndOriginalPath.mockResolvedValue(assetStub.external); - libraryMock.getAll.mockResolvedValue([libraryStub.externalLibraryWithImportPaths1]); - storageMock.watch.mockImplementation( + mocks.library.get.mockResolvedValue(libraryStub.externalLibraryWithImportPaths1); + mocks.asset.getByLibraryIdAndOriginalPath.mockResolvedValue(assetStub.external); + mocks.library.getAll.mockResolvedValue([libraryStub.externalLibraryWithImportPaths1]); + mocks.storage.watch.mockImplementation( makeMockWatcher({ items: [{ event: 'error', value: 'Error!' }], }), @@ -986,47 +972,51 @@ describe(LibraryService.name, () => { }); it('should ignore unknown extensions', async () => { - libraryMock.get.mockResolvedValue(libraryStub.externalLibraryWithImportPaths1); - libraryMock.getAll.mockResolvedValue([libraryStub.externalLibraryWithImportPaths1]); - storageMock.watch.mockImplementation(makeMockWatcher({ items: [{ event: 'add', value: '/foo/photo.jpg' }] })); + mocks.library.get.mockResolvedValue(libraryStub.externalLibraryWithImportPaths1); + mocks.library.getAll.mockResolvedValue([libraryStub.externalLibraryWithImportPaths1]); + mocks.storage.watch.mockImplementation(makeMockWatcher({ items: [{ event: 'add', value: '/foo/photo.jpg' }] })); await sut.watchAll(); - expect(jobMock.queue).not.toHaveBeenCalled(); + expect(mocks.job.queue).not.toHaveBeenCalled(); }); it('should ignore excluded paths', async () => { - libraryMock.get.mockResolvedValue(libraryStub.patternPath); - libraryMock.getAll.mockResolvedValue([libraryStub.patternPath]); - storageMock.watch.mockImplementation(makeMockWatcher({ items: [{ event: 'add', value: '/dir1/photo.txt' }] })); + mocks.library.get.mockResolvedValue(libraryStub.patternPath); + mocks.library.getAll.mockResolvedValue([libraryStub.patternPath]); + mocks.storage.watch.mockImplementation( + makeMockWatcher({ items: [{ event: 'add', value: '/dir1/photo.txt' }] }), + ); await sut.watchAll(); - expect(jobMock.queue).not.toHaveBeenCalled(); + expect(mocks.job.queue).not.toHaveBeenCalled(); }); it('should ignore excluded paths without case sensitivity', async () => { - libraryMock.get.mockResolvedValue(libraryStub.patternPath); - libraryMock.getAll.mockResolvedValue([libraryStub.patternPath]); - storageMock.watch.mockImplementation(makeMockWatcher({ items: [{ event: 'add', value: '/DIR1/photo.txt' }] })); + mocks.library.get.mockResolvedValue(libraryStub.patternPath); + mocks.library.getAll.mockResolvedValue([libraryStub.patternPath]); + mocks.storage.watch.mockImplementation( + makeMockWatcher({ items: [{ event: 'add', value: '/DIR1/photo.txt' }] }), + ); await sut.watchAll(); - expect(jobMock.queue).not.toHaveBeenCalled(); + expect(mocks.job.queue).not.toHaveBeenCalled(); }); }); }); describe('teardown', () => { it('should tear down all watchers', async () => { - libraryMock.getAll.mockResolvedValue([ + mocks.library.getAll.mockResolvedValue([ libraryStub.externalLibraryWithImportPaths1, libraryStub.externalLibraryWithImportPaths2, ]); - libraryMock.get.mockResolvedValue(libraryStub.externalLibrary1); + mocks.library.get.mockResolvedValue(libraryStub.externalLibrary1); - libraryMock.get.mockImplementation((id) => + mocks.library.get.mockImplementation((id) => Promise.resolve( [libraryStub.externalLibraryWithImportPaths1, libraryStub.externalLibraryWithImportPaths2].find( (library) => library.id === id, @@ -1035,7 +1025,7 @@ describe(LibraryService.name, () => { ); const mockClose = vitest.fn(); - storageMock.watch.mockImplementation(makeMockWatcher({ close: mockClose })); + mocks.storage.watch.mockImplementation(makeMockWatcher({ close: mockClose })); await sut.onConfigInit({ newConfig: systemConfigStub.libraryWatchEnabled as SystemConfig }); await sut.onShutdown(); @@ -1046,18 +1036,18 @@ describe(LibraryService.name, () => { describe('handleDeleteLibrary', () => { it('should delete an empty library', async () => { - libraryMock.get.mockResolvedValue(libraryStub.externalLibrary1); - assetMock.getAll.mockResolvedValue({ items: [], hasNextPage: false }); + mocks.library.get.mockResolvedValue(libraryStub.externalLibrary1); + mocks.asset.getAll.mockResolvedValue({ items: [], hasNextPage: false }); await expect(sut.handleDeleteLibrary({ id: libraryStub.externalLibrary1.id })).resolves.toBe(JobStatus.SUCCESS); - expect(libraryMock.delete).toHaveBeenCalled(); + expect(mocks.library.delete).toHaveBeenCalled(); }); it('should delete all assets in a library', async () => { - libraryMock.get.mockResolvedValue(libraryStub.externalLibrary1); - assetMock.getAll.mockResolvedValue({ items: [assetStub.image1], hasNextPage: false }); + mocks.library.get.mockResolvedValue(libraryStub.externalLibrary1); + mocks.asset.getAll.mockResolvedValue({ items: [assetStub.image1], hasNextPage: false }); - assetMock.getById.mockResolvedValue(assetStub.image1); + mocks.asset.getById.mockResolvedValue(assetStub.image1); await expect(sut.handleDeleteLibrary({ id: libraryStub.externalLibrary1.id })).resolves.toBe(JobStatus.SUCCESS); }); @@ -1065,11 +1055,11 @@ describe(LibraryService.name, () => { describe('queueScan', () => { it('should queue a library scan', async () => { - libraryMock.get.mockResolvedValue(libraryStub.externalLibrary1); + mocks.library.get.mockResolvedValue(libraryStub.externalLibrary1); await sut.queueScan(libraryStub.externalLibrary1.id); - expect(jobMock.queue.mock.calls).toEqual([ + expect(mocks.job.queue.mock.calls).toEqual([ [ { name: JobName.LIBRARY_QUEUE_SYNC_FILES, @@ -1092,11 +1082,11 @@ describe(LibraryService.name, () => { describe('handleQueueAllScan', () => { it('should queue the refresh job', async () => { - libraryMock.getAll.mockResolvedValue([libraryStub.externalLibrary1]); + mocks.library.getAll.mockResolvedValue([libraryStub.externalLibrary1]); await expect(sut.handleQueueSyncAll()).resolves.toBe(JobStatus.SUCCESS); - expect(jobMock.queue.mock.calls).toEqual([ + expect(mocks.job.queue.mock.calls).toEqual([ [ { name: JobName.LIBRARY_QUEUE_CLEANUP, @@ -1104,7 +1094,7 @@ describe(LibraryService.name, () => { }, ], ]); - expect(jobMock.queueAll).toHaveBeenCalledWith([ + expect(mocks.job.queueAll).toHaveBeenCalledWith([ { name: JobName.LIBRARY_QUEUE_SYNC_FILES, data: { @@ -1117,13 +1107,13 @@ describe(LibraryService.name, () => { describe('handleQueueAssetOfflineCheck', () => { it('should queue removal jobs', async () => { - libraryMock.get.mockResolvedValue(libraryStub.externalLibrary1); - assetMock.getAll.mockResolvedValue({ items: [assetStub.image1], hasNextPage: false }); - assetMock.getById.mockResolvedValue(assetStub.image1); + mocks.library.get.mockResolvedValue(libraryStub.externalLibrary1); + mocks.asset.getAll.mockResolvedValue({ items: [assetStub.image1], hasNextPage: false }); + mocks.asset.getById.mockResolvedValue(assetStub.image1); await expect(sut.handleQueueSyncAssets({ id: libraryStub.externalLibrary1.id })).resolves.toBe(JobStatus.SUCCESS); - expect(jobMock.queueAll).toHaveBeenCalledWith([ + expect(mocks.job.queueAll).toHaveBeenCalledWith([ { name: JobName.LIBRARY_SYNC_ASSET, data: { @@ -1142,11 +1132,11 @@ describe(LibraryService.name, () => { }); it('should validate directory', async () => { - storageMock.stat.mockResolvedValue({ + mocks.storage.stat.mockResolvedValue({ isDirectory: () => true, } as Stats); - storageMock.checkFileExists.mockResolvedValue(true); + mocks.storage.checkFileExists.mockResolvedValue(true); await expect(sut.validate('library-id', { importPaths: ['/data/user1/'] })).resolves.toEqual({ importPaths: [ @@ -1160,7 +1150,7 @@ describe(LibraryService.name, () => { }); it('should detect when path does not exist', async () => { - storageMock.stat.mockImplementation(() => { + mocks.storage.stat.mockImplementation(() => { const error = { code: 'ENOENT' } as any; throw error; }); @@ -1177,7 +1167,7 @@ describe(LibraryService.name, () => { }); it('should detect when path is not a directory', async () => { - storageMock.stat.mockResolvedValue({ + mocks.storage.stat.mockResolvedValue({ isDirectory: () => false, } as Stats); @@ -1193,7 +1183,7 @@ describe(LibraryService.name, () => { }); it('should return an unknown exception from stat', async () => { - storageMock.stat.mockImplementation(() => { + mocks.storage.stat.mockImplementation(() => { throw new Error('Unknown error'); }); @@ -1209,11 +1199,11 @@ describe(LibraryService.name, () => { }); it('should detect when access rights are missing', async () => { - storageMock.stat.mockResolvedValue({ + mocks.storage.stat.mockResolvedValue({ isDirectory: () => true, } as Stats); - storageMock.checkFileExists.mockResolvedValue(false); + mocks.storage.checkFileExists.mockResolvedValue(false); await expect(sut.validate('library-id', { importPaths: ['/data/user1/'] })).resolves.toEqual({ importPaths: [ @@ -1241,11 +1231,11 @@ describe(LibraryService.name, () => { }); it('should detect when import path is in immich media folder', async () => { - storageMock.stat.mockResolvedValue({ isDirectory: () => true } as Stats); + mocks.storage.stat.mockResolvedValue({ isDirectory: () => true } as Stats); const cwd = process.cwd(); const validImport = `${cwd}/${libraryStub.hasImmichPaths.importPaths[1]}`; - storageMock.checkFileExists.mockImplementation((importPath) => Promise.resolve(importPath === validImport)); + mocks.storage.checkFileExists.mockImplementation((importPath) => Promise.resolve(importPath === validImport)); const pathStubs = libraryStub.hasImmichPaths.importPaths; const importPaths = [pathStubs[0], validImport, pathStubs[2]]; diff --git a/server/src/services/map.service.spec.ts b/server/src/services/map.service.spec.ts index 30505f7f5b..e86ad92976 100644 --- a/server/src/services/map.service.spec.ts +++ b/server/src/services/map.service.spec.ts @@ -1,23 +1,16 @@ -import { IAlbumRepository } from 'src/interfaces/album.interface'; -import { IPartnerRepository } from 'src/interfaces/partner.interface'; import { MapService } from 'src/services/map.service'; -import { IMapRepository } from 'src/types'; import { albumStub } from 'test/fixtures/album.stub'; import { assetStub } from 'test/fixtures/asset.stub'; import { authStub } from 'test/fixtures/auth.stub'; import { partnerStub } from 'test/fixtures/partner.stub'; -import { newTestService } from 'test/utils'; -import { Mocked } from 'vitest'; +import { newTestService, ServiceMocks } from 'test/utils'; describe(MapService.name, () => { let sut: MapService; - - let albumMock: Mocked; - let mapMock: Mocked; - let partnerMock: Mocked; + let mocks: ServiceMocks; beforeEach(() => { - ({ sut, albumMock, mapMock, partnerMock } = newTestService(MapService)); + ({ sut, mocks } = newTestService(MapService)); }); describe('getMapMarkers', () => { @@ -31,8 +24,8 @@ describe(MapService.name, () => { state: asset.exifInfo!.state, country: asset.exifInfo!.country, }; - partnerMock.getAll.mockResolvedValue([]); - mapMock.getMapMarkers.mockResolvedValue([marker]); + mocks.partner.getAll.mockResolvedValue([]); + mocks.map.getMapMarkers.mockResolvedValue([marker]); const markers = await sut.getMapMarkers(authStub.user1, {}); @@ -50,12 +43,12 @@ describe(MapService.name, () => { state: asset.exifInfo!.state, country: asset.exifInfo!.country, }; - partnerMock.getAll.mockResolvedValue([partnerStub.adminToUser1]); - mapMock.getMapMarkers.mockResolvedValue([marker]); + mocks.partner.getAll.mockResolvedValue([partnerStub.adminToUser1]); + mocks.map.getMapMarkers.mockResolvedValue([marker]); const markers = await sut.getMapMarkers(authStub.user1, { withPartners: true }); - expect(mapMock.getMapMarkers).toHaveBeenCalledWith( + expect(mocks.map.getMapMarkers).toHaveBeenCalledWith( [authStub.user1.user.id, partnerStub.adminToUser1.sharedById], expect.arrayContaining([]), { withPartners: true }, @@ -74,10 +67,10 @@ describe(MapService.name, () => { state: asset.exifInfo!.state, country: asset.exifInfo!.country, }; - partnerMock.getAll.mockResolvedValue([]); - mapMock.getMapMarkers.mockResolvedValue([marker]); - albumMock.getOwned.mockResolvedValue([albumStub.empty]); - albumMock.getShared.mockResolvedValue([albumStub.sharedWithUser]); + mocks.partner.getAll.mockResolvedValue([]); + mocks.map.getMapMarkers.mockResolvedValue([marker]); + mocks.album.getOwned.mockResolvedValue([albumStub.empty]); + mocks.album.getShared.mockResolvedValue([albumStub.sharedWithUser]); const markers = await sut.getMapMarkers(authStub.user1, { withSharedAlbums: true }); @@ -88,13 +81,13 @@ describe(MapService.name, () => { describe('reverseGeocode', () => { it('should reverse geocode a location', async () => { - mapMock.reverseGeocode.mockResolvedValue({ city: 'foo', state: 'bar', country: 'baz' }); + mocks.map.reverseGeocode.mockResolvedValue({ city: 'foo', state: 'bar', country: 'baz' }); await expect(sut.reverseGeocode({ lat: 42, lon: 69 })).resolves.toEqual([ { city: 'foo', state: 'bar', country: 'baz' }, ]); - expect(mapMock.reverseGeocode).toHaveBeenCalledWith({ latitude: 42, longitude: 69 }); + expect(mocks.map.reverseGeocode).toHaveBeenCalledWith({ latitude: 42, longitude: 69 }); }); }); }); diff --git a/server/src/services/media.service.spec.ts b/server/src/services/media.service.spec.ts index 9844aa7f0f..3f48d8534a 100644 --- a/server/src/services/media.service.spec.ts +++ b/server/src/services/media.service.spec.ts @@ -13,35 +13,22 @@ import { TranscodePolicy, VideoCodec, } from 'src/enum'; -import { IAssetRepository, WithoutProperty } from 'src/interfaces/asset.interface'; -import { IJobRepository, JobCounts, JobName, JobStatus } from 'src/interfaces/job.interface'; -import { IMoveRepository } from 'src/interfaces/move.interface'; -import { IPersonRepository } from 'src/interfaces/person.interface'; -import { IStorageRepository } from 'src/interfaces/storage.interface'; +import { WithoutProperty } from 'src/interfaces/asset.interface'; +import { JobCounts, JobName, JobStatus } from 'src/interfaces/job.interface'; import { MediaService } from 'src/services/media.service'; -import { ILoggingRepository, IMediaRepository, ISystemMetadataRepository, RawImageInfo } from 'src/types'; +import { RawImageInfo } from 'src/types'; import { assetStub } from 'test/fixtures/asset.stub'; import { faceStub } from 'test/fixtures/face.stub'; import { probeStub } from 'test/fixtures/media.stub'; import { personStub } from 'test/fixtures/person.stub'; -import { makeStream, newTestService } from 'test/utils'; -import { Mocked } from 'vitest'; +import { makeStream, newTestService, ServiceMocks } from 'test/utils'; describe(MediaService.name, () => { let sut: MediaService; - - let assetMock: Mocked; - let jobMock: Mocked; - let loggerMock: Mocked; - let mediaMock: Mocked; - let moveMock: Mocked; - let personMock: Mocked; - let storageMock: Mocked; - let systemMock: Mocked; + let mocks: ServiceMocks; beforeEach(() => { - ({ sut, assetMock, jobMock, loggerMock, mediaMock, moveMock, personMock, storageMock, systemMock } = - newTestService(MediaService)); + ({ sut, mocks } = newTestService(MediaService)); }); it('should be defined', () => { @@ -50,27 +37,27 @@ describe(MediaService.name, () => { describe('handleQueueGenerateThumbnails', () => { it('should queue all assets', async () => { - assetMock.getAll.mockResolvedValue({ + mocks.asset.getAll.mockResolvedValue({ items: [assetStub.image], hasNextPage: false, }); - personMock.getAll.mockReturnValue(makeStream([personStub.newThumbnail])); - personMock.getFacesByIds.mockResolvedValue([faceStub.face1]); + mocks.person.getAll.mockReturnValue(makeStream([personStub.newThumbnail])); + mocks.person.getFacesByIds.mockResolvedValue([faceStub.face1]); await sut.handleQueueGenerateThumbnails({ force: true }); - expect(assetMock.getAll).toHaveBeenCalled(); - expect(assetMock.getWithout).not.toHaveBeenCalled(); - expect(jobMock.queueAll).toHaveBeenCalledWith([ + expect(mocks.asset.getAll).toHaveBeenCalled(); + expect(mocks.asset.getWithout).not.toHaveBeenCalled(); + expect(mocks.job.queueAll).toHaveBeenCalledWith([ { name: JobName.GENERATE_THUMBNAILS, data: { id: assetStub.image.id }, }, ]); - expect(personMock.getAll).toHaveBeenCalledWith(undefined); - expect(jobMock.queueAll).toHaveBeenCalledWith([ + expect(mocks.person.getAll).toHaveBeenCalledWith(undefined); + expect(mocks.job.queueAll).toHaveBeenCalledWith([ { name: JobName.GENERATE_PERSON_THUMBNAIL, data: { id: personStub.newThumbnail.id }, @@ -79,20 +66,20 @@ describe(MediaService.name, () => { }); it('should queue trashed assets when force is true', async () => { - assetMock.getAll.mockResolvedValue({ + mocks.asset.getAll.mockResolvedValue({ items: [assetStub.trashed], hasNextPage: false, }); - personMock.getAll.mockReturnValue(makeStream()); + mocks.person.getAll.mockReturnValue(makeStream()); await sut.handleQueueGenerateThumbnails({ force: true }); - expect(assetMock.getAll).toHaveBeenCalledWith( + expect(mocks.asset.getAll).toHaveBeenCalledWith( { skip: 0, take: 1000 }, expect.objectContaining({ withDeleted: true }), ); - expect(assetMock.getWithout).not.toHaveBeenCalled(); - expect(jobMock.queueAll).toHaveBeenCalledWith([ + expect(mocks.asset.getWithout).not.toHaveBeenCalled(); + expect(mocks.job.queueAll).toHaveBeenCalledWith([ { name: JobName.GENERATE_THUMBNAILS, data: { id: assetStub.trashed.id }, @@ -101,20 +88,20 @@ describe(MediaService.name, () => { }); it('should queue archived assets when force is true', async () => { - assetMock.getAll.mockResolvedValue({ + mocks.asset.getAll.mockResolvedValue({ items: [assetStub.archived], hasNextPage: false, }); - personMock.getAll.mockReturnValue(makeStream()); + mocks.person.getAll.mockReturnValue(makeStream()); await sut.handleQueueGenerateThumbnails({ force: true }); - expect(assetMock.getAll).toHaveBeenCalledWith( + expect(mocks.asset.getAll).toHaveBeenCalledWith( { skip: 0, take: 1000 }, expect.objectContaining({ withArchived: true }), ); - expect(assetMock.getWithout).not.toHaveBeenCalled(); - expect(jobMock.queueAll).toHaveBeenCalledWith([ + expect(mocks.asset.getWithout).not.toHaveBeenCalled(); + expect(mocks.job.queueAll).toHaveBeenCalledWith([ { name: JobName.GENERATE_THUMBNAILS, data: { id: assetStub.archived.id }, @@ -123,22 +110,22 @@ describe(MediaService.name, () => { }); it('should queue all people with missing thumbnail path', async () => { - assetMock.getWithout.mockResolvedValue({ + mocks.asset.getWithout.mockResolvedValue({ items: [assetStub.image], hasNextPage: false, }); - personMock.getAll.mockReturnValue(makeStream([personStub.noThumbnail, personStub.noThumbnail])); - personMock.getRandomFace.mockResolvedValueOnce(faceStub.face1); + mocks.person.getAll.mockReturnValue(makeStream([personStub.noThumbnail, personStub.noThumbnail])); + mocks.person.getRandomFace.mockResolvedValueOnce(faceStub.face1); await sut.handleQueueGenerateThumbnails({ force: false }); - expect(assetMock.getAll).not.toHaveBeenCalled(); - expect(assetMock.getWithout).toHaveBeenCalledWith({ skip: 0, take: 1000 }, WithoutProperty.THUMBNAIL); + expect(mocks.asset.getAll).not.toHaveBeenCalled(); + expect(mocks.asset.getWithout).toHaveBeenCalledWith({ skip: 0, take: 1000 }, WithoutProperty.THUMBNAIL); - expect(personMock.getAll).toHaveBeenCalledWith({ thumbnailPath: '' }); - expect(personMock.getRandomFace).toHaveBeenCalled(); - expect(personMock.update).toHaveBeenCalledTimes(1); - expect(jobMock.queueAll).toHaveBeenCalledWith([ + expect(mocks.person.getAll).toHaveBeenCalledWith({ thumbnailPath: '' }); + expect(mocks.person.getRandomFace).toHaveBeenCalled(); + expect(mocks.person.update).toHaveBeenCalledTimes(1); + expect(mocks.job.queueAll).toHaveBeenCalledWith([ { name: JobName.GENERATE_PERSON_THUMBNAIL, data: { @@ -149,79 +136,79 @@ describe(MediaService.name, () => { }); it('should queue all assets with missing resize path', async () => { - assetMock.getWithout.mockResolvedValue({ + mocks.asset.getWithout.mockResolvedValue({ items: [assetStub.noResizePath], hasNextPage: false, }); - personMock.getAll.mockReturnValue(makeStream()); + mocks.person.getAll.mockReturnValue(makeStream()); await sut.handleQueueGenerateThumbnails({ force: false }); - expect(assetMock.getAll).not.toHaveBeenCalled(); - expect(assetMock.getWithout).toHaveBeenCalledWith({ skip: 0, take: 1000 }, WithoutProperty.THUMBNAIL); - expect(jobMock.queueAll).toHaveBeenCalledWith([ + expect(mocks.asset.getAll).not.toHaveBeenCalled(); + expect(mocks.asset.getWithout).toHaveBeenCalledWith({ skip: 0, take: 1000 }, WithoutProperty.THUMBNAIL); + expect(mocks.job.queueAll).toHaveBeenCalledWith([ { name: JobName.GENERATE_THUMBNAILS, data: { id: assetStub.image.id }, }, ]); - expect(personMock.getAll).toHaveBeenCalledWith({ thumbnailPath: '' }); + expect(mocks.person.getAll).toHaveBeenCalledWith({ thumbnailPath: '' }); }); it('should queue all assets with missing webp path', async () => { - assetMock.getWithout.mockResolvedValue({ + mocks.asset.getWithout.mockResolvedValue({ items: [assetStub.noWebpPath], hasNextPage: false, }); - personMock.getAll.mockReturnValue(makeStream()); + mocks.person.getAll.mockReturnValue(makeStream()); await sut.handleQueueGenerateThumbnails({ force: false }); - expect(assetMock.getAll).not.toHaveBeenCalled(); - expect(assetMock.getWithout).toHaveBeenCalledWith({ skip: 0, take: 1000 }, WithoutProperty.THUMBNAIL); - expect(jobMock.queueAll).toHaveBeenCalledWith([ + expect(mocks.asset.getAll).not.toHaveBeenCalled(); + expect(mocks.asset.getWithout).toHaveBeenCalledWith({ skip: 0, take: 1000 }, WithoutProperty.THUMBNAIL); + expect(mocks.job.queueAll).toHaveBeenCalledWith([ { name: JobName.GENERATE_THUMBNAILS, data: { id: assetStub.image.id }, }, ]); - expect(personMock.getAll).toHaveBeenCalledWith({ thumbnailPath: '' }); + expect(mocks.person.getAll).toHaveBeenCalledWith({ thumbnailPath: '' }); }); it('should queue all assets with missing thumbhash', async () => { - assetMock.getWithout.mockResolvedValue({ + mocks.asset.getWithout.mockResolvedValue({ items: [assetStub.noThumbhash], hasNextPage: false, }); - personMock.getAll.mockReturnValue(makeStream()); + mocks.person.getAll.mockReturnValue(makeStream()); await sut.handleQueueGenerateThumbnails({ force: false }); - expect(assetMock.getAll).not.toHaveBeenCalled(); - expect(assetMock.getWithout).toHaveBeenCalledWith({ skip: 0, take: 1000 }, WithoutProperty.THUMBNAIL); - expect(jobMock.queueAll).toHaveBeenCalledWith([ + expect(mocks.asset.getAll).not.toHaveBeenCalled(); + expect(mocks.asset.getWithout).toHaveBeenCalledWith({ skip: 0, take: 1000 }, WithoutProperty.THUMBNAIL); + expect(mocks.job.queueAll).toHaveBeenCalledWith([ { name: JobName.GENERATE_THUMBNAILS, data: { id: assetStub.image.id }, }, ]); - expect(personMock.getAll).toHaveBeenCalledWith({ thumbnailPath: '' }); + expect(mocks.person.getAll).toHaveBeenCalledWith({ thumbnailPath: '' }); }); }); describe('handleQueueMigration', () => { it('should remove empty directories and queue jobs', async () => { - assetMock.getAll.mockResolvedValue({ hasNextPage: false, items: [assetStub.image] }); - jobMock.getJobCounts.mockResolvedValue({ active: 1, waiting: 0 } as JobCounts); - personMock.getAll.mockReturnValue(makeStream([personStub.withName])); + mocks.asset.getAll.mockResolvedValue({ hasNextPage: false, items: [assetStub.image] }); + mocks.job.getJobCounts.mockResolvedValue({ active: 1, waiting: 0 } as JobCounts); + mocks.person.getAll.mockReturnValue(makeStream([personStub.withName])); await expect(sut.handleQueueMigration()).resolves.toBe(JobStatus.SUCCESS); - expect(storageMock.removeEmptyDirs).toHaveBeenCalledTimes(2); - expect(jobMock.queueAll).toHaveBeenCalledWith([ + expect(mocks.storage.removeEmptyDirs).toHaveBeenCalledTimes(2); + expect(mocks.job.queueAll).toHaveBeenCalledWith([ { name: JobName.MIGRATE_ASSET, data: { id: assetStub.image.id } }, ]); - expect(jobMock.queueAll).toHaveBeenCalledWith([ + expect(mocks.job.queueAll).toHaveBeenCalledWith([ { name: JobName.MIGRATE_PERSON, data: { id: personStub.withName.id } }, ]); }); @@ -231,12 +218,12 @@ describe(MediaService.name, () => { it('should fail if asset does not exist', async () => { await expect(sut.handleAssetMigration({ id: assetStub.image.id })).resolves.toBe(JobStatus.FAILED); - expect(moveMock.getByEntity).not.toHaveBeenCalled(); + expect(mocks.move.getByEntity).not.toHaveBeenCalled(); }); it('should move asset files', async () => { - assetMock.getByIds.mockResolvedValue([assetStub.image]); - moveMock.create.mockResolvedValue({ + mocks.asset.getByIds.mockResolvedValue([assetStub.image]); + mocks.move.create.mockResolvedValue({ entityId: assetStub.image.id, id: 'move-id', newPath: '/new/path', @@ -245,7 +232,7 @@ describe(MediaService.name, () => { }); await expect(sut.handleAssetMigration({ id: assetStub.image.id })).resolves.toBe(JobStatus.SUCCESS); - expect(moveMock.create).toHaveBeenCalledTimes(2); + expect(mocks.move.create).toHaveBeenCalledTimes(2); }); }); @@ -256,72 +243,72 @@ describe(MediaService.name, () => { beforeEach(() => { rawBuffer = Buffer.from('image data'); rawInfo = { width: 100, height: 100, channels: 3 }; - mediaMock.decodeImage.mockResolvedValue({ data: rawBuffer, info: rawInfo as OutputInfo }); + mocks.media.decodeImage.mockResolvedValue({ data: rawBuffer, info: rawInfo as OutputInfo }); }); it('should skip thumbnail generation if asset not found', async () => { await sut.handleGenerateThumbnails({ id: assetStub.image.id }); - expect(mediaMock.generateThumbnail).not.toHaveBeenCalled(); - expect(assetMock.update).not.toHaveBeenCalledWith(); + expect(mocks.media.generateThumbnail).not.toHaveBeenCalled(); + expect(mocks.asset.update).not.toHaveBeenCalledWith(); }); it('should skip thumbnail generation if asset type is unknown', async () => { - assetMock.getById.mockResolvedValue({ ...assetStub.image, type: 'foo' } as never as AssetEntity); + mocks.asset.getById.mockResolvedValue({ ...assetStub.image, type: 'foo' } as never as AssetEntity); await expect(sut.handleGenerateThumbnails({ id: assetStub.image.id })).resolves.toBe(JobStatus.SKIPPED); - expect(mediaMock.probe).not.toHaveBeenCalled(); - expect(mediaMock.generateThumbnail).not.toHaveBeenCalled(); - expect(assetMock.update).not.toHaveBeenCalledWith(); + expect(mocks.media.probe).not.toHaveBeenCalled(); + expect(mocks.media.generateThumbnail).not.toHaveBeenCalled(); + expect(mocks.asset.update).not.toHaveBeenCalledWith(); }); it('should skip video thumbnail generation if no video stream', async () => { - mediaMock.probe.mockResolvedValue(probeStub.noVideoStreams); - assetMock.getById.mockResolvedValue(assetStub.video); + mocks.media.probe.mockResolvedValue(probeStub.noVideoStreams); + mocks.asset.getById.mockResolvedValue(assetStub.video); await expect(sut.handleGenerateThumbnails({ id: assetStub.video.id })).rejects.toThrowError(); - expect(mediaMock.generateThumbnail).not.toHaveBeenCalled(); - expect(assetMock.update).not.toHaveBeenCalledWith(); + expect(mocks.media.generateThumbnail).not.toHaveBeenCalled(); + expect(mocks.asset.update).not.toHaveBeenCalledWith(); }); it('should skip invisible assets', async () => { - assetMock.getById.mockResolvedValue(assetStub.livePhotoMotionAsset); + mocks.asset.getById.mockResolvedValue(assetStub.livePhotoMotionAsset); expect(await sut.handleGenerateThumbnails({ id: assetStub.livePhotoMotionAsset.id })).toEqual(JobStatus.SKIPPED); - expect(mediaMock.generateThumbnail).not.toHaveBeenCalled(); - expect(assetMock.update).not.toHaveBeenCalledWith(); + expect(mocks.media.generateThumbnail).not.toHaveBeenCalled(); + expect(mocks.asset.update).not.toHaveBeenCalledWith(); }); it('should delete previous preview if different path', async () => { - systemMock.get.mockResolvedValue({ image: { thumbnail: { format: ImageFormat.WEBP } } }); - assetMock.getById.mockResolvedValue(assetStub.image); + mocks.systemMetadata.get.mockResolvedValue({ image: { thumbnail: { format: ImageFormat.WEBP } } }); + mocks.asset.getById.mockResolvedValue(assetStub.image); await sut.handleGenerateThumbnails({ id: assetStub.image.id }); - expect(storageMock.unlink).toHaveBeenCalledWith('/uploads/user-id/thumbs/path.jpg'); + expect(mocks.storage.unlink).toHaveBeenCalledWith('/uploads/user-id/thumbs/path.jpg'); }); it('should generate P3 thumbnails for a wide gamut image', async () => { - assetMock.getById.mockResolvedValue({ + mocks.asset.getById.mockResolvedValue({ ...assetStub.image, exifInfo: { profileDescription: 'Adobe RGB', bitsPerSample: 14 } as ExifEntity, }); const thumbhashBuffer = Buffer.from('a thumbhash', 'utf8'); - mediaMock.generateThumbhash.mockResolvedValue(thumbhashBuffer); + mocks.media.generateThumbhash.mockResolvedValue(thumbhashBuffer); await sut.handleGenerateThumbnails({ id: assetStub.image.id }); - expect(storageMock.mkdirSync).toHaveBeenCalledWith('upload/thumbs/user-id/as/se'); + expect(mocks.storage.mkdirSync).toHaveBeenCalledWith('upload/thumbs/user-id/as/se'); - expect(mediaMock.decodeImage).toHaveBeenCalledOnce(); - expect(mediaMock.decodeImage).toHaveBeenCalledWith(assetStub.image.originalPath, { + expect(mocks.media.decodeImage).toHaveBeenCalledOnce(); + expect(mocks.media.decodeImage).toHaveBeenCalledWith(assetStub.image.originalPath, { colorspace: Colorspace.P3, processInvalidImages: false, size: 1440, }); - expect(mediaMock.generateThumbnail).toHaveBeenCalledTimes(2); - expect(mediaMock.generateThumbnail).toHaveBeenCalledWith( + expect(mocks.media.generateThumbnail).toHaveBeenCalledTimes(2); + expect(mocks.media.generateThumbnail).toHaveBeenCalledWith( rawBuffer, { colorspace: Colorspace.P3, @@ -333,7 +320,7 @@ describe(MediaService.name, () => { }, 'upload/thumbs/user-id/as/se/asset-id-preview.jpeg', ); - expect(mediaMock.generateThumbnail).toHaveBeenCalledWith( + expect(mocks.media.generateThumbnail).toHaveBeenCalledWith( rawBuffer, { colorspace: Colorspace.P3, @@ -346,14 +333,14 @@ describe(MediaService.name, () => { 'upload/thumbs/user-id/as/se/asset-id-thumbnail.webp', ); - expect(mediaMock.generateThumbhash).toHaveBeenCalledOnce(); - expect(mediaMock.generateThumbhash).toHaveBeenCalledWith(rawBuffer, { + expect(mocks.media.generateThumbhash).toHaveBeenCalledOnce(); + expect(mocks.media.generateThumbhash).toHaveBeenCalledWith(rawBuffer, { colorspace: Colorspace.P3, processInvalidImages: false, raw: rawInfo, }); - expect(assetMock.upsertFiles).toHaveBeenCalledWith([ + expect(mocks.asset.upsertFiles).toHaveBeenCalledWith([ { assetId: 'asset-id', type: AssetFileType.PREVIEW, @@ -365,16 +352,16 @@ describe(MediaService.name, () => { path: 'upload/thumbs/user-id/as/se/asset-id-thumbnail.webp', }, ]); - expect(assetMock.update).toHaveBeenCalledWith({ id: 'asset-id', thumbhash: thumbhashBuffer }); + expect(mocks.asset.update).toHaveBeenCalledWith({ id: 'asset-id', thumbhash: thumbhashBuffer }); }); it('should generate a thumbnail for a video', async () => { - mediaMock.probe.mockResolvedValue(probeStub.videoStream2160p); - assetMock.getById.mockResolvedValue(assetStub.video); + mocks.media.probe.mockResolvedValue(probeStub.videoStream2160p); + mocks.asset.getById.mockResolvedValue(assetStub.video); await sut.handleGenerateThumbnails({ id: assetStub.video.id }); - expect(storageMock.mkdirSync).toHaveBeenCalledWith('upload/thumbs/user-id/as/se'); - expect(mediaMock.transcode).toHaveBeenCalledWith( + expect(mocks.storage.mkdirSync).toHaveBeenCalledWith('upload/thumbs/user-id/as/se'); + expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/thumbs/user-id/as/se/asset-id-preview.jpeg', expect.objectContaining({ @@ -389,7 +376,7 @@ describe(MediaService.name, () => { twoPass: false, }), ); - expect(assetMock.upsertFiles).toHaveBeenCalledWith([ + expect(mocks.asset.upsertFiles).toHaveBeenCalledWith([ { assetId: 'asset-id', type: AssetFileType.PREVIEW, @@ -404,12 +391,12 @@ describe(MediaService.name, () => { }); it('should tonemap thumbnail for hdr video', async () => { - mediaMock.probe.mockResolvedValue(probeStub.videoStreamHDR); - assetMock.getById.mockResolvedValue(assetStub.video); + mocks.media.probe.mockResolvedValue(probeStub.videoStreamHDR); + mocks.asset.getById.mockResolvedValue(assetStub.video); await sut.handleGenerateThumbnails({ id: assetStub.video.id }); - expect(storageMock.mkdirSync).toHaveBeenCalledWith('upload/thumbs/user-id/as/se'); - expect(mediaMock.transcode).toHaveBeenCalledWith( + expect(mocks.storage.mkdirSync).toHaveBeenCalledWith('upload/thumbs/user-id/as/se'); + expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/thumbs/user-id/as/se/asset-id-preview.jpeg', expect.objectContaining({ @@ -424,7 +411,7 @@ describe(MediaService.name, () => { twoPass: false, }), ); - expect(assetMock.upsertFiles).toHaveBeenCalledWith([ + expect(mocks.asset.upsertFiles).toHaveBeenCalledWith([ { assetId: 'asset-id', type: AssetFileType.PREVIEW, @@ -439,14 +426,14 @@ describe(MediaService.name, () => { }); it('should always generate video thumbnail in one pass', async () => { - mediaMock.probe.mockResolvedValue(probeStub.videoStreamHDR); - systemMock.get.mockResolvedValue({ + mocks.media.probe.mockResolvedValue(probeStub.videoStreamHDR); + mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { twoPass: true, maxBitrate: '5000k' }, }); - assetMock.getById.mockResolvedValue(assetStub.video); + mocks.asset.getById.mockResolvedValue(assetStub.video); await sut.handleGenerateThumbnails({ id: assetStub.video.id }); - expect(mediaMock.transcode).toHaveBeenCalledWith( + expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/thumbs/user-id/as/se/asset-id-preview.jpeg', expect.objectContaining({ @@ -463,11 +450,11 @@ describe(MediaService.name, () => { ); }); it('should not skip intra frames for MTS file', async () => { - mediaMock.probe.mockResolvedValue(probeStub.videoStreamMTS); - assetMock.getById.mockResolvedValue(assetStub.video); + mocks.media.probe.mockResolvedValue(probeStub.videoStreamMTS); + mocks.asset.getById.mockResolvedValue(assetStub.video); await sut.handleGenerateThumbnails({ id: assetStub.video.id }); - expect(mediaMock.transcode).toHaveBeenCalledWith( + expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/thumbs/user-id/as/se/asset-id-preview.jpeg', expect.objectContaining({ @@ -480,12 +467,12 @@ describe(MediaService.name, () => { }); it('should use scaling divisible by 2 even when using quick sync', async () => { - mediaMock.probe.mockResolvedValue(probeStub.videoStream2160p); - systemMock.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.QSV } }); - assetMock.getById.mockResolvedValue(assetStub.video); + mocks.media.probe.mockResolvedValue(probeStub.videoStream2160p); + mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.QSV } }); + mocks.asset.getById.mockResolvedValue(assetStub.video); await sut.handleGenerateThumbnails({ id: assetStub.video.id }); - expect(mediaMock.transcode).toHaveBeenCalledWith( + expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/thumbs/user-id/as/se/asset-id-preview.jpeg', expect.objectContaining({ @@ -497,25 +484,25 @@ describe(MediaService.name, () => { }); it.each(Object.values(ImageFormat))('should generate an image preview in %s format', async (format) => { - systemMock.get.mockResolvedValue({ image: { preview: { format } } }); - assetMock.getById.mockResolvedValue(assetStub.image); + mocks.systemMetadata.get.mockResolvedValue({ image: { preview: { format } } }); + mocks.asset.getById.mockResolvedValue(assetStub.image); const thumbhashBuffer = Buffer.from('a thumbhash', 'utf8'); - mediaMock.generateThumbhash.mockResolvedValue(thumbhashBuffer); + mocks.media.generateThumbhash.mockResolvedValue(thumbhashBuffer); const previewPath = `upload/thumbs/user-id/as/se/asset-id-preview.${format}`; const thumbnailPath = `upload/thumbs/user-id/as/se/asset-id-thumbnail.webp`; await sut.handleGenerateThumbnails({ id: assetStub.image.id }); - expect(storageMock.mkdirSync).toHaveBeenCalledWith('upload/thumbs/user-id/as/se'); - expect(mediaMock.decodeImage).toHaveBeenCalledOnce(); - expect(mediaMock.decodeImage).toHaveBeenCalledWith(assetStub.image.originalPath, { + expect(mocks.storage.mkdirSync).toHaveBeenCalledWith('upload/thumbs/user-id/as/se'); + expect(mocks.media.decodeImage).toHaveBeenCalledOnce(); + expect(mocks.media.decodeImage).toHaveBeenCalledWith(assetStub.image.originalPath, { colorspace: Colorspace.SRGB, processInvalidImages: false, size: 1440, }); - expect(mediaMock.generateThumbnail).toHaveBeenCalledTimes(2); - expect(mediaMock.generateThumbnail).toHaveBeenCalledWith( + expect(mocks.media.generateThumbnail).toHaveBeenCalledTimes(2); + expect(mocks.media.generateThumbnail).toHaveBeenCalledWith( rawBuffer, { colorspace: Colorspace.SRGB, @@ -527,7 +514,7 @@ describe(MediaService.name, () => { }, previewPath, ); - expect(mediaMock.generateThumbnail).toHaveBeenCalledWith( + expect(mocks.media.generateThumbnail).toHaveBeenCalledWith( rawBuffer, { colorspace: Colorspace.SRGB, @@ -542,25 +529,25 @@ describe(MediaService.name, () => { }); it.each(Object.values(ImageFormat))('should generate an image thumbnail in %s format', async (format) => { - systemMock.get.mockResolvedValue({ image: { thumbnail: { format } } }); - assetMock.getById.mockResolvedValue(assetStub.image); + mocks.systemMetadata.get.mockResolvedValue({ image: { thumbnail: { format } } }); + mocks.asset.getById.mockResolvedValue(assetStub.image); const thumbhashBuffer = Buffer.from('a thumbhash', 'utf8'); - mediaMock.generateThumbhash.mockResolvedValue(thumbhashBuffer); + mocks.media.generateThumbhash.mockResolvedValue(thumbhashBuffer); const previewPath = `upload/thumbs/user-id/as/se/asset-id-preview.jpeg`; const thumbnailPath = `upload/thumbs/user-id/as/se/asset-id-thumbnail.${format}`; await sut.handleGenerateThumbnails({ id: assetStub.image.id }); - expect(storageMock.mkdirSync).toHaveBeenCalledWith('upload/thumbs/user-id/as/se'); - expect(mediaMock.decodeImage).toHaveBeenCalledOnce(); - expect(mediaMock.decodeImage).toHaveBeenCalledWith(assetStub.image.originalPath, { + expect(mocks.storage.mkdirSync).toHaveBeenCalledWith('upload/thumbs/user-id/as/se'); + expect(mocks.media.decodeImage).toHaveBeenCalledOnce(); + expect(mocks.media.decodeImage).toHaveBeenCalledWith(assetStub.image.originalPath, { colorspace: Colorspace.SRGB, processInvalidImages: false, size: 1440, }); - expect(mediaMock.generateThumbnail).toHaveBeenCalledTimes(2); - expect(mediaMock.generateThumbnail).toHaveBeenCalledWith( + expect(mocks.media.generateThumbnail).toHaveBeenCalledTimes(2); + expect(mocks.media.generateThumbnail).toHaveBeenCalledWith( rawBuffer, { colorspace: Colorspace.SRGB, @@ -572,7 +559,7 @@ describe(MediaService.name, () => { }, previewPath, ); - expect(mediaMock.generateThumbnail).toHaveBeenCalledWith( + expect(mocks.media.generateThumbnail).toHaveBeenCalledWith( rawBuffer, { colorspace: Colorspace.SRGB, @@ -587,132 +574,132 @@ describe(MediaService.name, () => { }); it('should delete previous thumbnail if different path', async () => { - systemMock.get.mockResolvedValue({ image: { thumbnail: { format: ImageFormat.WEBP } } }); - assetMock.getById.mockResolvedValue(assetStub.image); + mocks.systemMetadata.get.mockResolvedValue({ image: { thumbnail: { format: ImageFormat.WEBP } } }); + mocks.asset.getById.mockResolvedValue(assetStub.image); await sut.handleGenerateThumbnails({ id: assetStub.image.id }); - expect(storageMock.unlink).toHaveBeenCalledWith('/uploads/user-id/webp/path.ext'); + expect(mocks.storage.unlink).toHaveBeenCalledWith('/uploads/user-id/webp/path.ext'); }); it('should extract embedded image if enabled and available', async () => { - mediaMock.extract.mockResolvedValue(true); - mediaMock.getImageDimensions.mockResolvedValue({ width: 3840, height: 2160 }); - systemMock.get.mockResolvedValue({ image: { extractEmbedded: true } }); - assetMock.getById.mockResolvedValue(assetStub.imageDng); + mocks.media.extract.mockResolvedValue(true); + mocks.media.getImageDimensions.mockResolvedValue({ width: 3840, height: 2160 }); + mocks.systemMetadata.get.mockResolvedValue({ image: { extractEmbedded: true } }); + mocks.asset.getById.mockResolvedValue(assetStub.imageDng); await sut.handleGenerateThumbnails({ id: assetStub.image.id }); - const extractedPath = mediaMock.extract.mock.calls.at(-1)?.[1].toString(); - expect(mediaMock.decodeImage).toHaveBeenCalledOnce(); - expect(mediaMock.decodeImage).toHaveBeenCalledWith(extractedPath, { + const extractedPath = mocks.media.extract.mock.calls.at(-1)?.[1].toString(); + expect(mocks.media.decodeImage).toHaveBeenCalledOnce(); + expect(mocks.media.decodeImage).toHaveBeenCalledWith(extractedPath, { colorspace: Colorspace.P3, processInvalidImages: false, size: 1440, }); expect(extractedPath?.endsWith('.tmp')).toBe(true); - expect(storageMock.unlink).toHaveBeenCalledWith(extractedPath); + expect(mocks.storage.unlink).toHaveBeenCalledWith(extractedPath); }); it('should resize original image if embedded image is too small', async () => { - mediaMock.extract.mockResolvedValue(true); - mediaMock.getImageDimensions.mockResolvedValue({ width: 1000, height: 1000 }); - systemMock.get.mockResolvedValue({ image: { extractEmbedded: true } }); - assetMock.getById.mockResolvedValue(assetStub.imageDng); + mocks.media.extract.mockResolvedValue(true); + mocks.media.getImageDimensions.mockResolvedValue({ width: 1000, height: 1000 }); + mocks.systemMetadata.get.mockResolvedValue({ image: { extractEmbedded: true } }); + mocks.asset.getById.mockResolvedValue(assetStub.imageDng); await sut.handleGenerateThumbnails({ id: assetStub.image.id }); - expect(mediaMock.decodeImage).toHaveBeenCalledOnce(); - expect(mediaMock.decodeImage).toHaveBeenCalledWith(assetStub.imageDng.originalPath, { + expect(mocks.media.decodeImage).toHaveBeenCalledOnce(); + expect(mocks.media.decodeImage).toHaveBeenCalledWith(assetStub.imageDng.originalPath, { colorspace: Colorspace.P3, processInvalidImages: false, size: 1440, }); - const extractedPath = mediaMock.extract.mock.calls.at(-1)?.[1].toString(); + const extractedPath = mocks.media.extract.mock.calls.at(-1)?.[1].toString(); expect(extractedPath?.endsWith('.tmp')).toBe(true); - expect(storageMock.unlink).toHaveBeenCalledWith(extractedPath); + expect(mocks.storage.unlink).toHaveBeenCalledWith(extractedPath); }); it('should resize original image if embedded image not found', async () => { - systemMock.get.mockResolvedValue({ image: { extractEmbedded: true } }); - assetMock.getById.mockResolvedValue(assetStub.imageDng); + mocks.systemMetadata.get.mockResolvedValue({ image: { extractEmbedded: true } }); + mocks.asset.getById.mockResolvedValue(assetStub.imageDng); await sut.handleGenerateThumbnails({ id: assetStub.image.id }); - expect(mediaMock.decodeImage).toHaveBeenCalledOnce(); - expect(mediaMock.decodeImage).toHaveBeenCalledWith(assetStub.imageDng.originalPath, { + expect(mocks.media.decodeImage).toHaveBeenCalledOnce(); + expect(mocks.media.decodeImage).toHaveBeenCalledWith(assetStub.imageDng.originalPath, { colorspace: Colorspace.P3, processInvalidImages: false, size: 1440, }); - expect(mediaMock.getImageDimensions).not.toHaveBeenCalled(); + expect(mocks.media.getImageDimensions).not.toHaveBeenCalled(); }); it('should resize original image if embedded image extraction is not enabled', async () => { - systemMock.get.mockResolvedValue({ image: { extractEmbedded: false } }); - assetMock.getById.mockResolvedValue(assetStub.imageDng); + mocks.systemMetadata.get.mockResolvedValue({ image: { extractEmbedded: false } }); + mocks.asset.getById.mockResolvedValue(assetStub.imageDng); await sut.handleGenerateThumbnails({ id: assetStub.image.id }); - expect(mediaMock.extract).not.toHaveBeenCalled(); - expect(mediaMock.decodeImage).toHaveBeenCalledOnce(); - expect(mediaMock.decodeImage).toHaveBeenCalledWith(assetStub.imageDng.originalPath, { + expect(mocks.media.extract).not.toHaveBeenCalled(); + expect(mocks.media.decodeImage).toHaveBeenCalledOnce(); + expect(mocks.media.decodeImage).toHaveBeenCalledWith(assetStub.imageDng.originalPath, { colorspace: Colorspace.P3, processInvalidImages: false, size: 1440, }); - expect(mediaMock.getImageDimensions).not.toHaveBeenCalled(); + expect(mocks.media.getImageDimensions).not.toHaveBeenCalled(); }); it('should process invalid images if enabled', async () => { vi.stubEnv('IMMICH_PROCESS_INVALID_IMAGES', 'true'); - assetMock.getById.mockResolvedValue(assetStub.imageDng); + mocks.asset.getById.mockResolvedValue(assetStub.imageDng); await sut.handleGenerateThumbnails({ id: assetStub.image.id }); - expect(mediaMock.decodeImage).toHaveBeenCalledOnce(); - expect(mediaMock.decodeImage).toHaveBeenCalledWith( + expect(mocks.media.decodeImage).toHaveBeenCalledOnce(); + expect(mocks.media.decodeImage).toHaveBeenCalledWith( assetStub.imageDng.originalPath, expect.objectContaining({ processInvalidImages: true }), ); - expect(mediaMock.generateThumbnail).toHaveBeenCalledTimes(2); - expect(mediaMock.generateThumbnail).toHaveBeenCalledWith( + expect(mocks.media.generateThumbnail).toHaveBeenCalledTimes(2); + expect(mocks.media.generateThumbnail).toHaveBeenCalledWith( rawBuffer, expect.objectContaining({ processInvalidImages: true }), 'upload/thumbs/user-id/as/se/asset-id-preview.jpeg', ); - expect(mediaMock.generateThumbnail).toHaveBeenCalledWith( + expect(mocks.media.generateThumbnail).toHaveBeenCalledWith( rawBuffer, expect.objectContaining({ processInvalidImages: true }), 'upload/thumbs/user-id/as/se/asset-id-thumbnail.webp', ); - expect(mediaMock.generateThumbhash).toHaveBeenCalledOnce(); - expect(mediaMock.generateThumbhash).toHaveBeenCalledWith( + expect(mocks.media.generateThumbhash).toHaveBeenCalledOnce(); + expect(mocks.media.generateThumbhash).toHaveBeenCalledWith( rawBuffer, expect.objectContaining({ processInvalidImages: true }), ); - expect(mediaMock.getImageDimensions).not.toHaveBeenCalled(); + expect(mocks.media.getImageDimensions).not.toHaveBeenCalled(); vi.unstubAllEnvs(); }); }); describe('handleQueueVideoConversion', () => { it('should queue all video assets', async () => { - assetMock.getAll.mockResolvedValue({ + mocks.asset.getAll.mockResolvedValue({ items: [assetStub.video], hasNextPage: false, }); - personMock.getAll.mockReturnValue(makeStream()); + mocks.person.getAll.mockReturnValue(makeStream()); await sut.handleQueueVideoConversion({ force: true }); - expect(assetMock.getAll).toHaveBeenCalledWith({ skip: 0, take: 1000 }, { type: AssetType.VIDEO }); - expect(assetMock.getWithout).not.toHaveBeenCalled(); - expect(jobMock.queueAll).toHaveBeenCalledWith([ + expect(mocks.asset.getAll).toHaveBeenCalledWith({ skip: 0, take: 1000 }, { type: AssetType.VIDEO }); + expect(mocks.asset.getWithout).not.toHaveBeenCalled(); + expect(mocks.job.queueAll).toHaveBeenCalledWith([ { name: JobName.VIDEO_CONVERSION, data: { id: assetStub.video.id }, @@ -721,16 +708,16 @@ describe(MediaService.name, () => { }); it('should queue all video assets without encoded videos', async () => { - assetMock.getWithout.mockResolvedValue({ + mocks.asset.getWithout.mockResolvedValue({ items: [assetStub.video], hasNextPage: false, }); await sut.handleQueueVideoConversion({}); - expect(assetMock.getAll).not.toHaveBeenCalled(); - expect(assetMock.getWithout).toHaveBeenCalledWith({ skip: 0, take: 1000 }, WithoutProperty.ENCODED_VIDEO); - expect(jobMock.queueAll).toHaveBeenCalledWith([ + expect(mocks.asset.getAll).not.toHaveBeenCalled(); + expect(mocks.asset.getWithout).toHaveBeenCalledWith({ skip: 0, take: 1000 }, WithoutProperty.ENCODED_VIDEO); + expect(mocks.job.queueAll).toHaveBeenCalledWith([ { name: JobName.VIDEO_CONVERSION, data: { id: assetStub.video.id }, @@ -741,35 +728,35 @@ describe(MediaService.name, () => { describe('handleVideoConversion', () => { beforeEach(() => { - assetMock.getByIds.mockResolvedValue([assetStub.video]); + mocks.asset.getByIds.mockResolvedValue([assetStub.video]); sut.videoInterfaces = { dri: ['renderD128'], mali: true }; }); it('should skip transcoding if asset not found', async () => { - assetMock.getByIds.mockResolvedValue([]); + mocks.asset.getByIds.mockResolvedValue([]); await sut.handleVideoConversion({ id: assetStub.video.id }); - expect(mediaMock.probe).not.toHaveBeenCalled(); - expect(mediaMock.transcode).not.toHaveBeenCalled(); + expect(mocks.media.probe).not.toHaveBeenCalled(); + expect(mocks.media.transcode).not.toHaveBeenCalled(); }); it('should skip transcoding if non-video asset', async () => { - assetMock.getByIds.mockResolvedValue([assetStub.image]); + mocks.asset.getByIds.mockResolvedValue([assetStub.image]); await sut.handleVideoConversion({ id: assetStub.image.id }); - expect(mediaMock.probe).not.toHaveBeenCalled(); - expect(mediaMock.transcode).not.toHaveBeenCalled(); + expect(mocks.media.probe).not.toHaveBeenCalled(); + expect(mocks.media.transcode).not.toHaveBeenCalled(); }); it('should transcode the longest stream', async () => { - assetMock.getByIds.mockResolvedValue([assetStub.video]); - loggerMock.isLevelEnabled.mockReturnValue(false); - mediaMock.probe.mockResolvedValue(probeStub.multipleVideoStreams); + mocks.asset.getByIds.mockResolvedValue([assetStub.video]); + mocks.logger.isLevelEnabled.mockReturnValue(false); + mocks.media.probe.mockResolvedValue(probeStub.multipleVideoStreams); await sut.handleVideoConversion({ id: assetStub.video.id }); - expect(mediaMock.probe).toHaveBeenCalledWith('/original/path.ext', { countFrames: false }); - expect(systemMock.get).toHaveBeenCalled(); - expect(storageMock.mkdirSync).toHaveBeenCalled(); - expect(mediaMock.transcode).toHaveBeenCalledWith( + expect(mocks.media.probe).toHaveBeenCalledWith('/original/path.ext', { countFrames: false }); + expect(mocks.systemMetadata.get).toHaveBeenCalled(); + expect(mocks.storage.mkdirSync).toHaveBeenCalled(); + expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', expect.objectContaining({ @@ -781,46 +768,46 @@ describe(MediaService.name, () => { }); it('should skip a video without any streams', async () => { - mediaMock.probe.mockResolvedValue(probeStub.noVideoStreams); - assetMock.getByIds.mockResolvedValue([assetStub.video]); + mocks.media.probe.mockResolvedValue(probeStub.noVideoStreams); + mocks.asset.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); - expect(mediaMock.transcode).not.toHaveBeenCalled(); + expect(mocks.media.transcode).not.toHaveBeenCalled(); }); it('should skip a video without any height', async () => { - mediaMock.probe.mockResolvedValue(probeStub.noHeight); - assetMock.getByIds.mockResolvedValue([assetStub.video]); + mocks.media.probe.mockResolvedValue(probeStub.noHeight); + mocks.asset.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); - expect(mediaMock.transcode).not.toHaveBeenCalled(); + expect(mocks.media.transcode).not.toHaveBeenCalled(); }); it('should throw an error if an unknown transcode policy is configured', async () => { - mediaMock.probe.mockResolvedValue(probeStub.noAudioStreams); - systemMock.get.mockResolvedValue({ ffmpeg: { transcode: 'foo' } } as never as SystemConfig); - assetMock.getByIds.mockResolvedValue([assetStub.video]); + mocks.media.probe.mockResolvedValue(probeStub.noAudioStreams); + mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { transcode: 'foo' } } as never as SystemConfig); + mocks.asset.getByIds.mockResolvedValue([assetStub.video]); await expect(sut.handleVideoConversion({ id: assetStub.video.id })).rejects.toThrowError(); - expect(mediaMock.transcode).not.toHaveBeenCalled(); + expect(mocks.media.transcode).not.toHaveBeenCalled(); }); it('should throw an error if transcoding fails and hw acceleration is disabled', async () => { - mediaMock.probe.mockResolvedValue(probeStub.multipleVideoStreams); - systemMock.get.mockResolvedValue({ + mocks.media.probe.mockResolvedValue(probeStub.multipleVideoStreams); + mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { transcode: TranscodePolicy.ALL, accel: TranscodeHWAccel.DISABLED }, }); - assetMock.getByIds.mockResolvedValue([assetStub.video]); - mediaMock.transcode.mockRejectedValue(new Error('Error transcoding video')); + mocks.asset.getByIds.mockResolvedValue([assetStub.video]); + mocks.media.transcode.mockRejectedValue(new Error('Error transcoding video')); await expect(sut.handleVideoConversion({ id: assetStub.video.id })).resolves.toBe(JobStatus.FAILED); - expect(mediaMock.transcode).toHaveBeenCalledTimes(1); + expect(mocks.media.transcode).toHaveBeenCalledTimes(1); }); it('should transcode when set to all', async () => { - mediaMock.probe.mockResolvedValue(probeStub.multipleVideoStreams); - systemMock.get.mockResolvedValue({ ffmpeg: { transcode: TranscodePolicy.ALL } }); - assetMock.getByIds.mockResolvedValue([assetStub.video]); + mocks.media.probe.mockResolvedValue(probeStub.multipleVideoStreams); + mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { transcode: TranscodePolicy.ALL } }); + mocks.asset.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); - expect(mediaMock.transcode).toHaveBeenCalledWith( + expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', expect.objectContaining({ @@ -832,10 +819,10 @@ describe(MediaService.name, () => { }); it('should transcode when optimal and too big', async () => { - mediaMock.probe.mockResolvedValue(probeStub.videoStream2160p); - systemMock.get.mockResolvedValue({ ffmpeg: { transcode: TranscodePolicy.OPTIMAL } }); + mocks.media.probe.mockResolvedValue(probeStub.videoStream2160p); + mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { transcode: TranscodePolicy.OPTIMAL } }); await sut.handleVideoConversion({ id: assetStub.video.id }); - expect(mediaMock.transcode).toHaveBeenCalledWith( + expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', expect.objectContaining({ @@ -847,10 +834,10 @@ describe(MediaService.name, () => { }); it('should transcode when policy bitrate and bitrate higher than max bitrate', async () => { - mediaMock.probe.mockResolvedValue(probeStub.videoStream40Mbps); - systemMock.get.mockResolvedValue({ ffmpeg: { transcode: TranscodePolicy.BITRATE, maxBitrate: '30M' } }); + mocks.media.probe.mockResolvedValue(probeStub.videoStream40Mbps); + mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { transcode: TranscodePolicy.BITRATE, maxBitrate: '30M' } }); await sut.handleVideoConversion({ id: assetStub.video.id }); - expect(mediaMock.transcode).toHaveBeenCalledWith( + expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', expect.objectContaining({ @@ -862,10 +849,10 @@ describe(MediaService.name, () => { }); it('should transcode when max bitrate is not a number', async () => { - mediaMock.probe.mockResolvedValue(probeStub.videoStream40Mbps); - systemMock.get.mockResolvedValue({ ffmpeg: { transcode: TranscodePolicy.BITRATE, maxBitrate: 'foo' } }); + mocks.media.probe.mockResolvedValue(probeStub.videoStream40Mbps); + mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { transcode: TranscodePolicy.BITRATE, maxBitrate: 'foo' } }); await sut.handleVideoConversion({ id: assetStub.video.id }); - expect(mediaMock.transcode).toHaveBeenCalledWith( + expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', expect.objectContaining({ @@ -877,10 +864,12 @@ describe(MediaService.name, () => { }); it('should not scale resolution if no target resolution', async () => { - mediaMock.probe.mockResolvedValue(probeStub.videoStream2160p); - systemMock.get.mockResolvedValue({ ffmpeg: { transcode: TranscodePolicy.ALL, targetResolution: 'original' } }); + mocks.media.probe.mockResolvedValue(probeStub.videoStream2160p); + mocks.systemMetadata.get.mockResolvedValue({ + ffmpeg: { transcode: TranscodePolicy.ALL, targetResolution: 'original' }, + }); await sut.handleVideoConversion({ id: assetStub.video.id }); - expect(mediaMock.transcode).toHaveBeenCalledWith( + expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', expect.objectContaining({ @@ -892,11 +881,11 @@ describe(MediaService.name, () => { }); it('should scale horizontally when video is horizontal', async () => { - mediaMock.probe.mockResolvedValue(probeStub.videoStream2160p); - systemMock.get.mockResolvedValue({ ffmpeg: { transcode: TranscodePolicy.OPTIMAL } }); - assetMock.getByIds.mockResolvedValue([assetStub.video]); + mocks.media.probe.mockResolvedValue(probeStub.videoStream2160p); + mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { transcode: TranscodePolicy.OPTIMAL } }); + mocks.asset.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); - expect(mediaMock.transcode).toHaveBeenCalledWith( + expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', expect.objectContaining({ @@ -908,11 +897,11 @@ describe(MediaService.name, () => { }); it('should scale vertically when video is vertical', async () => { - mediaMock.probe.mockResolvedValue(probeStub.videoStreamVertical2160p); - systemMock.get.mockResolvedValue({ ffmpeg: { transcode: TranscodePolicy.OPTIMAL } }); - assetMock.getByIds.mockResolvedValue([assetStub.video]); + mocks.media.probe.mockResolvedValue(probeStub.videoStreamVertical2160p); + mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { transcode: TranscodePolicy.OPTIMAL } }); + mocks.asset.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); - expect(mediaMock.transcode).toHaveBeenCalledWith( + expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', expect.objectContaining({ @@ -924,11 +913,13 @@ describe(MediaService.name, () => { }); it('should always scale video if height is uneven', async () => { - mediaMock.probe.mockResolvedValue(probeStub.videoStreamOddHeight); - systemMock.get.mockResolvedValue({ ffmpeg: { transcode: TranscodePolicy.ALL, targetResolution: 'original' } }); - assetMock.getByIds.mockResolvedValue([assetStub.video]); + mocks.media.probe.mockResolvedValue(probeStub.videoStreamOddHeight); + mocks.systemMetadata.get.mockResolvedValue({ + ffmpeg: { transcode: TranscodePolicy.ALL, targetResolution: 'original' }, + }); + mocks.asset.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); - expect(mediaMock.transcode).toHaveBeenCalledWith( + expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', expect.objectContaining({ @@ -940,11 +931,13 @@ describe(MediaService.name, () => { }); it('should always scale video if width is uneven', async () => { - mediaMock.probe.mockResolvedValue(probeStub.videoStreamOddWidth); - systemMock.get.mockResolvedValue({ ffmpeg: { transcode: TranscodePolicy.ALL, targetResolution: 'original' } }); - assetMock.getByIds.mockResolvedValue([assetStub.video]); + mocks.media.probe.mockResolvedValue(probeStub.videoStreamOddWidth); + mocks.systemMetadata.get.mockResolvedValue({ + ffmpeg: { transcode: TranscodePolicy.ALL, targetResolution: 'original' }, + }); + mocks.asset.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); - expect(mediaMock.transcode).toHaveBeenCalledWith( + expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', expect.objectContaining({ @@ -956,13 +949,13 @@ describe(MediaService.name, () => { }); it('should copy video stream when video matches target', async () => { - mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer); - systemMock.get.mockResolvedValue({ + mocks.media.probe.mockResolvedValue(probeStub.matroskaContainer); + mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { targetVideoCodec: VideoCodec.HEVC, acceptedAudioCodecs: [AudioCodec.AAC] }, }); - assetMock.getByIds.mockResolvedValue([assetStub.video]); + mocks.asset.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); - expect(mediaMock.transcode).toHaveBeenCalledWith( + expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', expect.objectContaining({ @@ -974,17 +967,17 @@ describe(MediaService.name, () => { }); it('should not include hevc tag when target is hevc and video stream is copied from a different codec', async () => { - mediaMock.probe.mockResolvedValue(probeStub.videoStreamH264); - systemMock.get.mockResolvedValue({ + mocks.media.probe.mockResolvedValue(probeStub.videoStreamH264); + mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { targetVideoCodec: VideoCodec.HEVC, acceptedVideoCodecs: [VideoCodec.H264, VideoCodec.HEVC], acceptedAudioCodecs: [AudioCodec.AAC], }, }); - assetMock.getByIds.mockResolvedValue([assetStub.video]); + mocks.asset.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); - expect(mediaMock.transcode).toHaveBeenCalledWith( + expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', expect.objectContaining({ @@ -996,17 +989,17 @@ describe(MediaService.name, () => { }); it('should include hevc tag when target is hevc and copying hevc video stream', async () => { - mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer); - systemMock.get.mockResolvedValue({ + mocks.media.probe.mockResolvedValue(probeStub.matroskaContainer); + mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { targetVideoCodec: VideoCodec.HEVC, acceptedVideoCodecs: [VideoCodec.H264, VideoCodec.HEVC], acceptedAudioCodecs: [AudioCodec.AAC], }, }); - assetMock.getByIds.mockResolvedValue([assetStub.video]); + mocks.asset.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); - expect(mediaMock.transcode).toHaveBeenCalledWith( + expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', expect.objectContaining({ @@ -1018,11 +1011,11 @@ describe(MediaService.name, () => { }); it('should copy audio stream when audio matches target', async () => { - mediaMock.probe.mockResolvedValue(probeStub.audioStreamAac); - systemMock.get.mockResolvedValue({ ffmpeg: { transcode: TranscodePolicy.OPTIMAL } }); - assetMock.getByIds.mockResolvedValue([assetStub.video]); + mocks.media.probe.mockResolvedValue(probeStub.audioStreamAac); + mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { transcode: TranscodePolicy.OPTIMAL } }); + mocks.asset.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); - expect(mediaMock.transcode).toHaveBeenCalledWith( + expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', expect.objectContaining({ @@ -1034,10 +1027,10 @@ describe(MediaService.name, () => { }); it('should remux when input is not an accepted container', async () => { - mediaMock.probe.mockResolvedValue(probeStub.videoStreamAvi); - assetMock.getByIds.mockResolvedValue([assetStub.video]); + mocks.media.probe.mockResolvedValue(probeStub.videoStreamAvi); + mocks.asset.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); - expect(mediaMock.transcode).toHaveBeenCalledWith( + expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', expect.objectContaining({ @@ -1049,58 +1042,58 @@ describe(MediaService.name, () => { }); it('should throw an exception if transcode value is invalid', async () => { - mediaMock.probe.mockResolvedValue(probeStub.videoStream2160p); - systemMock.get.mockResolvedValue({ ffmpeg: { transcode: 'invalid' as any } }); + mocks.media.probe.mockResolvedValue(probeStub.videoStream2160p); + mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { transcode: 'invalid' as any } }); await expect(sut.handleVideoConversion({ id: assetStub.video.id })).rejects.toThrowError(); - expect(mediaMock.transcode).not.toHaveBeenCalled(); + expect(mocks.media.transcode).not.toHaveBeenCalled(); }); it('should not transcode if transcoding is disabled', async () => { - mediaMock.probe.mockResolvedValue(probeStub.videoStream2160p); - systemMock.get.mockResolvedValue({ ffmpeg: { transcode: TranscodePolicy.DISABLED } }); - assetMock.getByIds.mockResolvedValue([assetStub.video]); + mocks.media.probe.mockResolvedValue(probeStub.videoStream2160p); + mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { transcode: TranscodePolicy.DISABLED } }); + mocks.asset.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); - expect(mediaMock.transcode).not.toHaveBeenCalled(); + expect(mocks.media.transcode).not.toHaveBeenCalled(); }); it('should not remux when input is not an accepted container and transcoding is disabled', async () => { - mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer); - systemMock.get.mockResolvedValue({ ffmpeg: { transcode: TranscodePolicy.DISABLED } }); - assetMock.getByIds.mockResolvedValue([assetStub.video]); + mocks.media.probe.mockResolvedValue(probeStub.matroskaContainer); + mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { transcode: TranscodePolicy.DISABLED } }); + mocks.asset.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); - expect(mediaMock.transcode).not.toHaveBeenCalled(); + expect(mocks.media.transcode).not.toHaveBeenCalled(); }); it('should not transcode if target codec is invalid', async () => { - mediaMock.probe.mockResolvedValue(probeStub.videoStream2160p); - systemMock.get.mockResolvedValue({ ffmpeg: { targetVideoCodec: 'invalid' as any } }); - assetMock.getByIds.mockResolvedValue([assetStub.video]); + mocks.media.probe.mockResolvedValue(probeStub.videoStream2160p); + mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { targetVideoCodec: 'invalid' as any } }); + mocks.asset.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); - expect(mediaMock.transcode).not.toHaveBeenCalled(); + expect(mocks.media.transcode).not.toHaveBeenCalled(); }); it('should delete existing transcode if current policy does not require transcoding', async () => { const asset = assetStub.hasEncodedVideo; - mediaMock.probe.mockResolvedValue(probeStub.videoStream2160p); - systemMock.get.mockResolvedValue({ ffmpeg: { transcode: TranscodePolicy.DISABLED } }); - assetMock.getByIds.mockResolvedValue([asset]); + mocks.media.probe.mockResolvedValue(probeStub.videoStream2160p); + mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { transcode: TranscodePolicy.DISABLED } }); + mocks.asset.getByIds.mockResolvedValue([asset]); await sut.handleVideoConversion({ id: asset.id }); - expect(mediaMock.transcode).not.toHaveBeenCalled(); - expect(jobMock.queue).toHaveBeenCalledWith({ + expect(mocks.media.transcode).not.toHaveBeenCalled(); + expect(mocks.job.queue).toHaveBeenCalledWith({ name: JobName.DELETE_FILES, data: { files: [asset.encodedVideoPath] }, }); }); it('should set max bitrate if above 0', async () => { - mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer); - systemMock.get.mockResolvedValue({ ffmpeg: { maxBitrate: '4500k' } }); - assetMock.getByIds.mockResolvedValue([assetStub.video]); + mocks.media.probe.mockResolvedValue(probeStub.matroskaContainer); + mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { maxBitrate: '4500k' } }); + mocks.asset.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); - expect(mediaMock.transcode).toHaveBeenCalledWith( + expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', expect.objectContaining({ @@ -1112,11 +1105,11 @@ describe(MediaService.name, () => { }); it('should default max bitrate to kbps if no unit is provided', async () => { - mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer); - systemMock.get.mockResolvedValue({ ffmpeg: { maxBitrate: '4500' } }); - assetMock.getByIds.mockResolvedValue([assetStub.video]); + mocks.media.probe.mockResolvedValue(probeStub.matroskaContainer); + mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { maxBitrate: '4500' } }); + mocks.asset.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); - expect(mediaMock.transcode).toHaveBeenCalledWith( + expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', expect.objectContaining({ @@ -1128,11 +1121,11 @@ describe(MediaService.name, () => { }); it('should transcode in two passes for h264/h265 when enabled and max bitrate is above 0', async () => { - mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer); - systemMock.get.mockResolvedValue({ ffmpeg: { twoPass: true, maxBitrate: '4500k' } }); - assetMock.getByIds.mockResolvedValue([assetStub.video]); + mocks.media.probe.mockResolvedValue(probeStub.matroskaContainer); + mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { twoPass: true, maxBitrate: '4500k' } }); + mocks.asset.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); - expect(mediaMock.transcode).toHaveBeenCalledWith( + expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', expect.objectContaining({ @@ -1144,11 +1137,11 @@ describe(MediaService.name, () => { }); it('should fallback to one pass for h264/h265 if two-pass is enabled but no max bitrate is set', async () => { - mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer); - systemMock.get.mockResolvedValue({ ffmpeg: { twoPass: true } }); - assetMock.getByIds.mockResolvedValue([assetStub.video]); + mocks.media.probe.mockResolvedValue(probeStub.matroskaContainer); + mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { twoPass: true } }); + mocks.asset.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); - expect(mediaMock.transcode).toHaveBeenCalledWith( + expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', expect.objectContaining({ @@ -1160,17 +1153,17 @@ describe(MediaService.name, () => { }); it('should transcode by bitrate in two passes for vp9 when two pass mode and max bitrate are enabled', async () => { - mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer); - systemMock.get.mockResolvedValue({ + mocks.media.probe.mockResolvedValue(probeStub.matroskaContainer); + mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { maxBitrate: '4500k', twoPass: true, targetVideoCodec: VideoCodec.VP9, }, }); - assetMock.getByIds.mockResolvedValue([assetStub.video]); + mocks.asset.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); - expect(mediaMock.transcode).toHaveBeenCalledWith( + expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', expect.objectContaining({ @@ -1182,17 +1175,17 @@ describe(MediaService.name, () => { }); it('should transcode by crf in two passes for vp9 when two pass mode is enabled and max bitrate is disabled', async () => { - mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer); - systemMock.get.mockResolvedValue({ + mocks.media.probe.mockResolvedValue(probeStub.matroskaContainer); + mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { maxBitrate: '0', twoPass: true, targetVideoCodec: VideoCodec.VP9, }, }); - assetMock.getByIds.mockResolvedValue([assetStub.video]); + mocks.asset.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); - expect(mediaMock.transcode).toHaveBeenCalledWith( + expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', expect.objectContaining({ @@ -1204,11 +1197,11 @@ describe(MediaService.name, () => { }); it('should configure preset for vp9', async () => { - mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer); - systemMock.get.mockResolvedValue({ ffmpeg: { targetVideoCodec: VideoCodec.VP9, preset: 'slow' } }); - assetMock.getByIds.mockResolvedValue([assetStub.video]); + mocks.media.probe.mockResolvedValue(probeStub.matroskaContainer); + mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { targetVideoCodec: VideoCodec.VP9, preset: 'slow' } }); + mocks.asset.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); - expect(mediaMock.transcode).toHaveBeenCalledWith( + expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', expect.objectContaining({ @@ -1220,11 +1213,11 @@ describe(MediaService.name, () => { }); it('should not configure preset for vp9 if invalid', async () => { - mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer); - systemMock.get.mockResolvedValue({ ffmpeg: { preset: 'invalid', targetVideoCodec: VideoCodec.VP9 } }); - assetMock.getByIds.mockResolvedValue([assetStub.video]); + mocks.media.probe.mockResolvedValue(probeStub.matroskaContainer); + mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { preset: 'invalid', targetVideoCodec: VideoCodec.VP9 } }); + mocks.asset.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); - expect(mediaMock.transcode).toHaveBeenCalledWith( + expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', expect.objectContaining({ @@ -1236,11 +1229,11 @@ describe(MediaService.name, () => { }); it('should configure threads if above 0', async () => { - mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer); - systemMock.get.mockResolvedValue({ ffmpeg: { targetVideoCodec: VideoCodec.VP9, threads: 2 } }); - assetMock.getByIds.mockResolvedValue([assetStub.video]); + mocks.media.probe.mockResolvedValue(probeStub.matroskaContainer); + mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { targetVideoCodec: VideoCodec.VP9, threads: 2 } }); + mocks.asset.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); - expect(mediaMock.transcode).toHaveBeenCalledWith( + expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', expect.objectContaining({ @@ -1252,11 +1245,11 @@ describe(MediaService.name, () => { }); it('should disable thread pooling for h264 if thread limit is 1', async () => { - mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer); - systemMock.get.mockResolvedValue({ ffmpeg: { threads: 1 } }); - assetMock.getByIds.mockResolvedValue([assetStub.video]); + mocks.media.probe.mockResolvedValue(probeStub.matroskaContainer); + mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { threads: 1 } }); + mocks.asset.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); - expect(mediaMock.transcode).toHaveBeenCalledWith( + expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', expect.objectContaining({ @@ -1268,11 +1261,11 @@ describe(MediaService.name, () => { }); it('should omit thread flags for h264 if thread limit is at or below 0', async () => { - mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer); - systemMock.get.mockResolvedValue({ ffmpeg: { threads: 0 } }); - assetMock.getByIds.mockResolvedValue([assetStub.video]); + mocks.media.probe.mockResolvedValue(probeStub.matroskaContainer); + mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { threads: 0 } }); + mocks.asset.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); - expect(mediaMock.transcode).toHaveBeenCalledWith( + expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', expect.objectContaining({ @@ -1284,11 +1277,11 @@ describe(MediaService.name, () => { }); it('should disable thread pooling for hevc if thread limit is 1', async () => { - mediaMock.probe.mockResolvedValue(probeStub.videoStreamVp9); - systemMock.get.mockResolvedValue({ ffmpeg: { threads: 1, targetVideoCodec: VideoCodec.HEVC } }); - assetMock.getByIds.mockResolvedValue([assetStub.video]); + mocks.media.probe.mockResolvedValue(probeStub.videoStreamVp9); + mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { threads: 1, targetVideoCodec: VideoCodec.HEVC } }); + mocks.asset.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); - expect(mediaMock.transcode).toHaveBeenCalledWith( + expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', expect.objectContaining({ @@ -1300,11 +1293,11 @@ describe(MediaService.name, () => { }); it('should omit thread flags for hevc if thread limit is at or below 0', async () => { - mediaMock.probe.mockResolvedValue(probeStub.videoStreamVp9); - systemMock.get.mockResolvedValue({ ffmpeg: { threads: 0, targetVideoCodec: VideoCodec.HEVC } }); - assetMock.getByIds.mockResolvedValue([assetStub.video]); + mocks.media.probe.mockResolvedValue(probeStub.videoStreamVp9); + mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { threads: 0, targetVideoCodec: VideoCodec.HEVC } }); + mocks.asset.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); - expect(mediaMock.transcode).toHaveBeenCalledWith( + expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', expect.objectContaining({ @@ -1316,11 +1309,11 @@ describe(MediaService.name, () => { }); it('should use av1 if specified', async () => { - mediaMock.probe.mockResolvedValue(probeStub.videoStreamVp9); - systemMock.get.mockResolvedValue({ ffmpeg: { targetVideoCodec: VideoCodec.AV1 } }); - assetMock.getByIds.mockResolvedValue([assetStub.video]); + mocks.media.probe.mockResolvedValue(probeStub.videoStreamVp9); + mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { targetVideoCodec: VideoCodec.AV1 } }); + mocks.asset.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); - expect(mediaMock.transcode).toHaveBeenCalledWith( + expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', expect.objectContaining({ @@ -1342,11 +1335,11 @@ describe(MediaService.name, () => { }); it('should map `veryslow` preset to 4 for av1', async () => { - mediaMock.probe.mockResolvedValue(probeStub.videoStreamVp9); - systemMock.get.mockResolvedValue({ ffmpeg: { targetVideoCodec: VideoCodec.AV1, preset: 'veryslow' } }); - assetMock.getByIds.mockResolvedValue([assetStub.video]); + mocks.media.probe.mockResolvedValue(probeStub.videoStreamVp9); + mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { targetVideoCodec: VideoCodec.AV1, preset: 'veryslow' } }); + mocks.asset.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); - expect(mediaMock.transcode).toHaveBeenCalledWith( + expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', expect.objectContaining({ @@ -1358,11 +1351,11 @@ describe(MediaService.name, () => { }); it('should set max bitrate for av1 if specified', async () => { - mediaMock.probe.mockResolvedValue(probeStub.videoStreamVp9); - systemMock.get.mockResolvedValue({ ffmpeg: { targetVideoCodec: VideoCodec.AV1, maxBitrate: '2M' } }); - assetMock.getByIds.mockResolvedValue([assetStub.video]); + mocks.media.probe.mockResolvedValue(probeStub.videoStreamVp9); + mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { targetVideoCodec: VideoCodec.AV1, maxBitrate: '2M' } }); + mocks.asset.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); - expect(mediaMock.transcode).toHaveBeenCalledWith( + expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', expect.objectContaining({ @@ -1374,11 +1367,11 @@ describe(MediaService.name, () => { }); it('should set threads for av1 if specified', async () => { - mediaMock.probe.mockResolvedValue(probeStub.videoStreamVp9); - systemMock.get.mockResolvedValue({ ffmpeg: { targetVideoCodec: VideoCodec.AV1, threads: 4 } }); - assetMock.getByIds.mockResolvedValue([assetStub.video]); + mocks.media.probe.mockResolvedValue(probeStub.videoStreamVp9); + mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { targetVideoCodec: VideoCodec.AV1, threads: 4 } }); + mocks.asset.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); - expect(mediaMock.transcode).toHaveBeenCalledWith( + expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', expect.objectContaining({ @@ -1390,11 +1383,13 @@ describe(MediaService.name, () => { }); it('should set both bitrate and threads for av1 if specified', async () => { - mediaMock.probe.mockResolvedValue(probeStub.videoStreamVp9); - systemMock.get.mockResolvedValue({ ffmpeg: { targetVideoCodec: VideoCodec.AV1, threads: 4, maxBitrate: '2M' } }); - assetMock.getByIds.mockResolvedValue([assetStub.video]); + mocks.media.probe.mockResolvedValue(probeStub.videoStreamVp9); + mocks.systemMetadata.get.mockResolvedValue({ + ffmpeg: { targetVideoCodec: VideoCodec.AV1, threads: 4, maxBitrate: '2M' }, + }); + mocks.asset.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); - expect(mediaMock.transcode).toHaveBeenCalledWith( + expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', expect.objectContaining({ @@ -1406,41 +1401,43 @@ describe(MediaService.name, () => { }); it('should skip transcoding for audioless videos with optimal policy if video codec is correct', async () => { - mediaMock.probe.mockResolvedValue(probeStub.noAudioStreams); - systemMock.get.mockResolvedValue({ + mocks.media.probe.mockResolvedValue(probeStub.noAudioStreams); + mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { targetVideoCodec: VideoCodec.HEVC, transcode: TranscodePolicy.OPTIMAL, targetResolution: '1080p', }, }); - assetMock.getByIds.mockResolvedValue([assetStub.video]); + mocks.asset.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); - expect(mediaMock.transcode).not.toHaveBeenCalled(); + expect(mocks.media.transcode).not.toHaveBeenCalled(); }); it('should fail if hwaccel is enabled for an unsupported codec', async () => { - mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer); - systemMock.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.NVENC, targetVideoCodec: VideoCodec.VP9 } }); - assetMock.getByIds.mockResolvedValue([assetStub.video]); + mocks.media.probe.mockResolvedValue(probeStub.matroskaContainer); + mocks.systemMetadata.get.mockResolvedValue({ + ffmpeg: { accel: TranscodeHWAccel.NVENC, targetVideoCodec: VideoCodec.VP9 }, + }); + mocks.asset.getByIds.mockResolvedValue([assetStub.video]); await expect(sut.handleVideoConversion({ id: assetStub.video.id })).rejects.toThrowError(); - expect(mediaMock.transcode).not.toHaveBeenCalled(); + expect(mocks.media.transcode).not.toHaveBeenCalled(); }); it('should fail if hwaccel option is invalid', async () => { - mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer); - systemMock.get.mockResolvedValue({ ffmpeg: { accel: 'invalid' as any } }); - assetMock.getByIds.mockResolvedValue([assetStub.video]); + mocks.media.probe.mockResolvedValue(probeStub.matroskaContainer); + mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { accel: 'invalid' as any } }); + mocks.asset.getByIds.mockResolvedValue([assetStub.video]); await expect(sut.handleVideoConversion({ id: assetStub.video.id })).rejects.toThrowError(); - expect(mediaMock.transcode).not.toHaveBeenCalled(); + expect(mocks.media.transcode).not.toHaveBeenCalled(); }); it('should set options for nvenc', async () => { - mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer); - systemMock.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.NVENC } }); - assetMock.getByIds.mockResolvedValue([assetStub.video]); + mocks.media.probe.mockResolvedValue(probeStub.matroskaContainer); + mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.NVENC } }); + mocks.asset.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); - expect(mediaMock.transcode).toHaveBeenCalledWith( + expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', expect.objectContaining({ @@ -1468,17 +1465,17 @@ describe(MediaService.name, () => { }); it('should set two pass options for nvenc when enabled', async () => { - mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer); - systemMock.get.mockResolvedValue({ + mocks.media.probe.mockResolvedValue(probeStub.matroskaContainer); + mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.NVENC, maxBitrate: '10000k', twoPass: true, }, }); - assetMock.getByIds.mockResolvedValue([assetStub.video]); + mocks.asset.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); - expect(mediaMock.transcode).toHaveBeenCalledWith( + expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', expect.objectContaining({ @@ -1490,11 +1487,11 @@ describe(MediaService.name, () => { }); it('should set vbr options for nvenc when max bitrate is enabled', async () => { - mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer); - systemMock.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.NVENC, maxBitrate: '10000k' } }); - assetMock.getByIds.mockResolvedValue([assetStub.video]); + mocks.media.probe.mockResolvedValue(probeStub.matroskaContainer); + mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.NVENC, maxBitrate: '10000k' } }); + mocks.asset.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); - expect(mediaMock.transcode).toHaveBeenCalledWith( + expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', expect.objectContaining({ @@ -1506,11 +1503,11 @@ describe(MediaService.name, () => { }); it('should set cq options for nvenc when max bitrate is disabled', async () => { - mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer); - systemMock.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.NVENC, maxBitrate: '10000k' } }); - assetMock.getByIds.mockResolvedValue([assetStub.video]); + mocks.media.probe.mockResolvedValue(probeStub.matroskaContainer); + mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.NVENC, maxBitrate: '10000k' } }); + mocks.asset.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); - expect(mediaMock.transcode).toHaveBeenCalledWith( + expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', expect.objectContaining({ @@ -1522,11 +1519,11 @@ describe(MediaService.name, () => { }); it('should omit preset for nvenc if invalid', async () => { - mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer); - systemMock.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.NVENC, preset: 'invalid' } }); - assetMock.getByIds.mockResolvedValue([assetStub.video]); + mocks.media.probe.mockResolvedValue(probeStub.matroskaContainer); + mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.NVENC, preset: 'invalid' } }); + mocks.asset.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); - expect(mediaMock.transcode).toHaveBeenCalledWith( + expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', expect.objectContaining({ @@ -1538,11 +1535,11 @@ describe(MediaService.name, () => { }); it('should ignore two pass for nvenc if max bitrate is disabled', async () => { - mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer); - systemMock.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.NVENC } }); - assetMock.getByIds.mockResolvedValue([assetStub.video]); + mocks.media.probe.mockResolvedValue(probeStub.matroskaContainer); + mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.NVENC } }); + mocks.asset.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); - expect(mediaMock.transcode).toHaveBeenCalledWith( + expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', expect.objectContaining({ @@ -1554,13 +1551,13 @@ describe(MediaService.name, () => { }); it('should use hardware decoding for nvenc if enabled', async () => { - mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer); - systemMock.get.mockResolvedValue({ + mocks.media.probe.mockResolvedValue(probeStub.matroskaContainer); + mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.NVENC, accelDecode: true }, }); - assetMock.getByIds.mockResolvedValue([assetStub.video]); + mocks.asset.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); - expect(mediaMock.transcode).toHaveBeenCalledWith( + expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', expect.objectContaining({ @@ -1577,13 +1574,13 @@ describe(MediaService.name, () => { }); it('should use hardware tone-mapping for nvenc if hardware decoding is enabled and should tone map', async () => { - mediaMock.probe.mockResolvedValue(probeStub.videoStreamHDR); - systemMock.get.mockResolvedValue({ + mocks.media.probe.mockResolvedValue(probeStub.videoStreamHDR); + mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.NVENC, accelDecode: true }, }); - assetMock.getByIds.mockResolvedValue([assetStub.video]); + mocks.asset.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); - expect(mediaMock.transcode).toHaveBeenCalledWith( + expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', expect.objectContaining({ @@ -1599,13 +1596,13 @@ describe(MediaService.name, () => { }); it('should set format to nv12 for nvenc if input is not yuv420p', async () => { - mediaMock.probe.mockResolvedValue(probeStub.videoStream10Bit); - systemMock.get.mockResolvedValue({ + mocks.media.probe.mockResolvedValue(probeStub.videoStream10Bit); + mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.NVENC, accelDecode: true }, }); - assetMock.getByIds.mockResolvedValue([assetStub.video]); + mocks.asset.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); - expect(mediaMock.transcode).toHaveBeenCalledWith( + expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', expect.objectContaining({ @@ -1617,11 +1614,11 @@ describe(MediaService.name, () => { }); it('should set options for qsv', async () => { - mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer); - systemMock.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.QSV, maxBitrate: '10000k' } }); - assetMock.getByIds.mockResolvedValue([assetStub.video]); + mocks.media.probe.mockResolvedValue(probeStub.matroskaContainer); + mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.QSV, maxBitrate: '10000k' } }); + mocks.asset.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); - expect(mediaMock.transcode).toHaveBeenCalledWith( + expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', expect.objectContaining({ @@ -1652,17 +1649,17 @@ describe(MediaService.name, () => { }); it('should set options for qsv with custom dri node', async () => { - mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer); - systemMock.get.mockResolvedValue({ + mocks.media.probe.mockResolvedValue(probeStub.matroskaContainer); + mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.QSV, maxBitrate: '10000k', preferredHwDevice: '/dev/dri/renderD128', }, }); - assetMock.getByIds.mockResolvedValue([assetStub.video]); + mocks.asset.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); - expect(mediaMock.transcode).toHaveBeenCalledWith( + expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', expect.objectContaining({ @@ -1677,11 +1674,11 @@ describe(MediaService.name, () => { }); it('should omit preset for qsv if invalid', async () => { - mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer); - systemMock.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.QSV, preset: 'invalid' } }); - assetMock.getByIds.mockResolvedValue([assetStub.video]); + mocks.media.probe.mockResolvedValue(probeStub.matroskaContainer); + mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.QSV, preset: 'invalid' } }); + mocks.asset.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); - expect(mediaMock.transcode).toHaveBeenCalledWith( + expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', expect.objectContaining({ @@ -1696,11 +1693,13 @@ describe(MediaService.name, () => { }); it('should set low power mode for qsv if target video codec is vp9', async () => { - mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer); - systemMock.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.QSV, targetVideoCodec: VideoCodec.VP9 } }); - assetMock.getByIds.mockResolvedValue([assetStub.video]); + mocks.media.probe.mockResolvedValue(probeStub.matroskaContainer); + mocks.systemMetadata.get.mockResolvedValue({ + ffmpeg: { accel: TranscodeHWAccel.QSV, targetVideoCodec: VideoCodec.VP9 }, + }); + mocks.asset.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); - expect(mediaMock.transcode).toHaveBeenCalledWith( + expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', expect.objectContaining({ @@ -1716,22 +1715,22 @@ describe(MediaService.name, () => { it('should fail for qsv if no hw devices', async () => { sut.videoInterfaces = { dri: [], mali: false }; - mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer); - systemMock.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.QSV } }); - assetMock.getByIds.mockResolvedValue([assetStub.video]); + mocks.media.probe.mockResolvedValue(probeStub.matroskaContainer); + mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.QSV } }); + mocks.asset.getByIds.mockResolvedValue([assetStub.video]); await expect(sut.handleVideoConversion({ id: assetStub.video.id })).rejects.toThrowError(); - expect(mediaMock.transcode).not.toHaveBeenCalled(); + expect(mocks.media.transcode).not.toHaveBeenCalled(); }); it('should prefer higher index renderD* device for qsv', async () => { sut.videoInterfaces = { dri: ['card1', 'renderD129', 'card0', 'renderD128'], mali: false }; - mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer); - systemMock.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.QSV } }); - assetMock.getByIds.mockResolvedValue([assetStub.video]); + mocks.media.probe.mockResolvedValue(probeStub.matroskaContainer); + mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.QSV } }); + mocks.asset.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); - expect(mediaMock.transcode).toHaveBeenCalledWith( + expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', expect.objectContaining({ @@ -1746,15 +1745,15 @@ describe(MediaService.name, () => { }); it('should use hardware decoding for qsv if enabled', async () => { - mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer); - systemMock.get.mockResolvedValue({ + mocks.media.probe.mockResolvedValue(probeStub.matroskaContainer); + mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.QSV, accelDecode: true }, }); - assetMock.getByIds.mockResolvedValue([assetStub.video]); + mocks.asset.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); - expect(mediaMock.transcode).toHaveBeenCalledWith( + expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', expect.objectContaining({ @@ -1775,15 +1774,15 @@ describe(MediaService.name, () => { }); it('should use hardware tone-mapping for qsv if hardware decoding is enabled and should tone map', async () => { - mediaMock.probe.mockResolvedValue(probeStub.videoStreamHDR); - systemMock.get.mockResolvedValue({ + mocks.media.probe.mockResolvedValue(probeStub.videoStreamHDR); + mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.QSV, accelDecode: true }, }); - assetMock.getByIds.mockResolvedValue([assetStub.video]); + mocks.asset.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); - expect(mediaMock.transcode).toHaveBeenCalledWith( + expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', expect.objectContaining({ @@ -1805,14 +1804,14 @@ describe(MediaService.name, () => { it('should use preferred device for qsv when hardware decoding', async () => { sut.videoInterfaces = { dri: ['renderD128', 'renderD129', 'renderD130'], mali: false }; - mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer); - systemMock.get.mockResolvedValue({ + mocks.media.probe.mockResolvedValue(probeStub.matroskaContainer); + mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.QSV, accelDecode: true, preferredHwDevice: 'renderD129' }, }); - assetMock.getByIds.mockResolvedValue([assetStub.video]); + mocks.asset.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); - expect(mediaMock.transcode).toHaveBeenCalledWith( + expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', expect.objectContaining({ @@ -1824,15 +1823,15 @@ describe(MediaService.name, () => { }); it('should set format to nv12 for qsv if input is not yuv420p', async () => { - mediaMock.probe.mockResolvedValue(probeStub.videoStream10Bit); - systemMock.get.mockResolvedValue({ + mocks.media.probe.mockResolvedValue(probeStub.videoStream10Bit); + mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.QSV, accelDecode: true }, }); - assetMock.getByIds.mockResolvedValue([assetStub.video]); + mocks.asset.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); - expect(mediaMock.transcode).toHaveBeenCalledWith( + expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', expect.objectContaining({ @@ -1849,11 +1848,11 @@ describe(MediaService.name, () => { }); it('should set options for vaapi', async () => { - mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer); - systemMock.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.VAAPI } }); - assetMock.getByIds.mockResolvedValue([assetStub.video]); + mocks.media.probe.mockResolvedValue(probeStub.matroskaContainer); + mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.VAAPI } }); + mocks.asset.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); - expect(mediaMock.transcode).toHaveBeenCalledWith( + expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', expect.objectContaining({ @@ -1880,11 +1879,11 @@ describe(MediaService.name, () => { }); it('should set vbr options for vaapi when max bitrate is enabled', async () => { - mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer); - systemMock.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.VAAPI, maxBitrate: '10000k' } }); - assetMock.getByIds.mockResolvedValue([assetStub.video]); + mocks.media.probe.mockResolvedValue(probeStub.matroskaContainer); + mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.VAAPI, maxBitrate: '10000k' } }); + mocks.asset.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); - expect(mediaMock.transcode).toHaveBeenCalledWith( + expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', expect.objectContaining({ @@ -1905,11 +1904,11 @@ describe(MediaService.name, () => { }); it('should set cq options for vaapi when max bitrate is disabled', async () => { - mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer); - systemMock.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.VAAPI } }); - assetMock.getByIds.mockResolvedValue([assetStub.video]); + mocks.media.probe.mockResolvedValue(probeStub.matroskaContainer); + mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.VAAPI } }); + mocks.asset.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); - expect(mediaMock.transcode).toHaveBeenCalledWith( + expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', expect.objectContaining({ @@ -1930,11 +1929,11 @@ describe(MediaService.name, () => { }); it('should omit preset for vaapi if invalid', async () => { - mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer); - systemMock.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.VAAPI, preset: 'invalid' } }); - assetMock.getByIds.mockResolvedValue([assetStub.video]); + mocks.media.probe.mockResolvedValue(probeStub.matroskaContainer); + mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.VAAPI, preset: 'invalid' } }); + mocks.asset.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); - expect(mediaMock.transcode).toHaveBeenCalledWith( + expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', expect.objectContaining({ @@ -1950,11 +1949,11 @@ describe(MediaService.name, () => { it('should prefer higher index renderD* device for vaapi', async () => { sut.videoInterfaces = { dri: ['card1', 'renderD129', 'card0', 'renderD128'], mali: false }; - mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer); - systemMock.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.VAAPI } }); - assetMock.getByIds.mockResolvedValue([assetStub.video]); + mocks.media.probe.mockResolvedValue(probeStub.matroskaContainer); + mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.VAAPI } }); + mocks.asset.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); - expect(mediaMock.transcode).toHaveBeenCalledWith( + expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', expect.objectContaining({ @@ -1970,13 +1969,13 @@ describe(MediaService.name, () => { it('should select specific gpu node if selected', async () => { sut.videoInterfaces = { dri: ['renderD129', 'card1', 'card0', 'renderD128'], mali: false }; - mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer); - systemMock.get.mockResolvedValue({ + mocks.media.probe.mockResolvedValue(probeStub.matroskaContainer); + mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.VAAPI, preferredHwDevice: '/dev/dri/renderD128' }, }); - assetMock.getByIds.mockResolvedValue([assetStub.video]); + mocks.asset.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); - expect(mediaMock.transcode).toHaveBeenCalledWith( + expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', expect.objectContaining({ @@ -1991,15 +1990,15 @@ describe(MediaService.name, () => { }); it('should use hardware decoding for vaapi if enabled', async () => { - mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer); - systemMock.get.mockResolvedValue({ + mocks.media.probe.mockResolvedValue(probeStub.matroskaContainer); + mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.VAAPI, accelDecode: true }, }); - assetMock.getByIds.mockResolvedValue([assetStub.video]); + mocks.asset.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); - expect(mediaMock.transcode).toHaveBeenCalledWith( + expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', expect.objectContaining({ @@ -2019,15 +2018,15 @@ describe(MediaService.name, () => { }); it('should use hardware tone-mapping for vaapi if hardware decoding is enabled and should tone map', async () => { - mediaMock.probe.mockResolvedValue(probeStub.videoStreamHDR); - systemMock.get.mockResolvedValue({ + mocks.media.probe.mockResolvedValue(probeStub.videoStreamHDR); + mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.VAAPI, accelDecode: true }, }); - assetMock.getByIds.mockResolvedValue([assetStub.video]); + mocks.asset.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); - expect(mediaMock.transcode).toHaveBeenCalledWith( + expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', expect.objectContaining({ @@ -2043,15 +2042,15 @@ describe(MediaService.name, () => { }); it('should set format to nv12 for vaapi if input is not yuv420p', async () => { - mediaMock.probe.mockResolvedValue(probeStub.videoStream10Bit); - systemMock.get.mockResolvedValue({ + mocks.media.probe.mockResolvedValue(probeStub.videoStream10Bit); + mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.VAAPI, accelDecode: true }, }); - assetMock.getByIds.mockResolvedValue([assetStub.video]); + mocks.asset.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); - expect(mediaMock.transcode).toHaveBeenCalledWith( + expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', expect.objectContaining({ @@ -2064,14 +2063,14 @@ describe(MediaService.name, () => { it('should use preferred device for vaapi when hardware decoding', async () => { sut.videoInterfaces = { dri: ['renderD128', 'renderD129', 'renderD130'], mali: false }; - mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer); - systemMock.get.mockResolvedValue({ + mocks.media.probe.mockResolvedValue(probeStub.matroskaContainer); + mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.VAAPI, accelDecode: true, preferredHwDevice: 'renderD129' }, }); - assetMock.getByIds.mockResolvedValue([assetStub.video]); + mocks.asset.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); - expect(mediaMock.transcode).toHaveBeenCalledWith( + expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', expect.objectContaining({ @@ -2083,13 +2082,13 @@ describe(MediaService.name, () => { }); it('should fallback to hw encoding and sw decoding if hw transcoding fails and hw decoding is enabled', async () => { - mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer); - systemMock.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.VAAPI, accelDecode: true } }); - assetMock.getByIds.mockResolvedValue([assetStub.video]); - mediaMock.transcode.mockRejectedValueOnce(new Error('error')); + mocks.media.probe.mockResolvedValue(probeStub.matroskaContainer); + mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.VAAPI, accelDecode: true } }); + mocks.asset.getByIds.mockResolvedValue([assetStub.video]); + mocks.media.transcode.mockRejectedValueOnce(new Error('error')); await sut.handleVideoConversion({ id: assetStub.video.id }); - expect(mediaMock.transcode).toHaveBeenCalledTimes(2); - expect(mediaMock.transcode).toHaveBeenLastCalledWith( + expect(mocks.media.transcode).toHaveBeenCalledTimes(2); + expect(mocks.media.transcode).toHaveBeenLastCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', expect.objectContaining({ @@ -2104,14 +2103,14 @@ describe(MediaService.name, () => { }); it('should fallback to sw decoding if fallback to sw decoding + hw encoding fails', async () => { - mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer); - systemMock.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.VAAPI, accelDecode: true } }); - assetMock.getByIds.mockResolvedValue([assetStub.video]); - mediaMock.transcode.mockRejectedValueOnce(new Error('error')); - mediaMock.transcode.mockRejectedValueOnce(new Error('error')); + mocks.media.probe.mockResolvedValue(probeStub.matroskaContainer); + mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.VAAPI, accelDecode: true } }); + mocks.asset.getByIds.mockResolvedValue([assetStub.video]); + mocks.media.transcode.mockRejectedValueOnce(new Error('error')); + mocks.media.transcode.mockRejectedValueOnce(new Error('error')); await sut.handleVideoConversion({ id: assetStub.video.id }); - expect(mediaMock.transcode).toHaveBeenCalledTimes(3); - expect(mediaMock.transcode).toHaveBeenLastCalledWith( + expect(mocks.media.transcode).toHaveBeenCalledTimes(3); + expect(mocks.media.transcode).toHaveBeenLastCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', expect.objectContaining({ @@ -2123,13 +2122,13 @@ describe(MediaService.name, () => { }); it('should fallback to sw transcoding if hw transcoding fails and hw decoding is disabled', async () => { - mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer); - systemMock.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.VAAPI } }); - assetMock.getByIds.mockResolvedValue([assetStub.video]); - mediaMock.transcode.mockRejectedValueOnce(new Error('error')); + mocks.media.probe.mockResolvedValue(probeStub.matroskaContainer); + mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.VAAPI } }); + mocks.asset.getByIds.mockResolvedValue([assetStub.video]); + mocks.media.transcode.mockRejectedValueOnce(new Error('error')); await sut.handleVideoConversion({ id: assetStub.video.id }); - expect(mediaMock.transcode).toHaveBeenCalledTimes(2); - expect(mediaMock.transcode).toHaveBeenLastCalledWith( + expect(mocks.media.transcode).toHaveBeenCalledTimes(2); + expect(mocks.media.transcode).toHaveBeenLastCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', expect.objectContaining({ @@ -2142,19 +2141,19 @@ describe(MediaService.name, () => { it('should fail for vaapi if no hw devices', async () => { sut.videoInterfaces = { dri: [], mali: true }; - mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer); - systemMock.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.VAAPI } }); - assetMock.getByIds.mockResolvedValue([assetStub.video]); + mocks.media.probe.mockResolvedValue(probeStub.matroskaContainer); + mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.VAAPI } }); + mocks.asset.getByIds.mockResolvedValue([assetStub.video]); await expect(sut.handleVideoConversion({ id: assetStub.video.id })).rejects.toThrowError(); - expect(mediaMock.transcode).not.toHaveBeenCalled(); + expect(mocks.media.transcode).not.toHaveBeenCalled(); }); it('should set options for rkmpp', async () => { - mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer); - systemMock.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.RKMPP, accelDecode: true } }); - assetMock.getByIds.mockResolvedValue([assetStub.video]); + mocks.media.probe.mockResolvedValue(probeStub.matroskaContainer); + mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.RKMPP, accelDecode: true } }); + mocks.asset.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); - expect(mediaMock.transcode).toHaveBeenCalledWith( + expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', expect.objectContaining({ @@ -2184,8 +2183,8 @@ describe(MediaService.name, () => { }); it('should set vbr options for rkmpp when max bitrate is enabled', async () => { - mediaMock.probe.mockResolvedValue(probeStub.videoStreamVp9); - systemMock.get.mockResolvedValue({ + mocks.media.probe.mockResolvedValue(probeStub.videoStreamVp9); + mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.RKMPP, accelDecode: true, @@ -2193,9 +2192,9 @@ describe(MediaService.name, () => { targetVideoCodec: VideoCodec.HEVC, }, }); - assetMock.getByIds.mockResolvedValue([assetStub.video]); + mocks.asset.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); - expect(mediaMock.transcode).toHaveBeenCalledWith( + expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', expect.objectContaining({ @@ -2207,13 +2206,13 @@ describe(MediaService.name, () => { }); it('should set cqp options for rkmpp when max bitrate is disabled', async () => { - mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer); - systemMock.get.mockResolvedValue({ + mocks.media.probe.mockResolvedValue(probeStub.matroskaContainer); + mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.RKMPP, accelDecode: true, crf: 30, maxBitrate: '0' }, }); - assetMock.getByIds.mockResolvedValue([assetStub.video]); + mocks.asset.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); - expect(mediaMock.transcode).toHaveBeenCalledWith( + expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', expect.objectContaining({ @@ -2225,13 +2224,13 @@ describe(MediaService.name, () => { }); it('should set OpenCL tonemapping options for rkmpp when OpenCL is available', async () => { - mediaMock.probe.mockResolvedValue(probeStub.videoStreamHDR); - systemMock.get.mockResolvedValue({ + mocks.media.probe.mockResolvedValue(probeStub.videoStreamHDR); + mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.RKMPP, accelDecode: true, crf: 30, maxBitrate: '0' }, }); - assetMock.getByIds.mockResolvedValue([assetStub.video]); + mocks.asset.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); - expect(mediaMock.transcode).toHaveBeenCalledWith( + expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', expect.objectContaining({ @@ -2248,13 +2247,13 @@ describe(MediaService.name, () => { it('should set hardware decoding options for rkmpp when hardware decoding is enabled with no OpenCL on non-HDR file', async () => { sut.videoInterfaces = { dri: ['renderD128'], mali: false }; - mediaMock.probe.mockResolvedValue(probeStub.noAudioStreams); - systemMock.get.mockResolvedValue({ + mocks.media.probe.mockResolvedValue(probeStub.noAudioStreams); + mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.RKMPP, accelDecode: true, crf: 30, maxBitrate: '0' }, }); - assetMock.getByIds.mockResolvedValue([assetStub.video]); + mocks.asset.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); - expect(mediaMock.transcode).toHaveBeenCalledWith( + expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', expect.objectContaining({ @@ -2268,13 +2267,13 @@ describe(MediaService.name, () => { }); it('should use software decoding and tone-mapping if hardware decoding is disabled', async () => { - mediaMock.probe.mockResolvedValue(probeStub.videoStreamHDR); - systemMock.get.mockResolvedValue({ + mocks.media.probe.mockResolvedValue(probeStub.videoStreamHDR); + mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.RKMPP, accelDecode: false, crf: 30, maxBitrate: '0' }, }); - assetMock.getByIds.mockResolvedValue([assetStub.video]); + mocks.asset.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); - expect(mediaMock.transcode).toHaveBeenCalledWith( + expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', expect.objectContaining({ @@ -2291,13 +2290,13 @@ describe(MediaService.name, () => { it('should use software tone-mapping if opencl is not available', async () => { sut.videoInterfaces = { dri: ['renderD128'], mali: false }; - mediaMock.probe.mockResolvedValue(probeStub.videoStreamHDR); - systemMock.get.mockResolvedValue({ + mocks.media.probe.mockResolvedValue(probeStub.videoStreamHDR); + mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.RKMPP, accelDecode: true, crf: 30, maxBitrate: '0' }, }); - assetMock.getByIds.mockResolvedValue([assetStub.video]); + mocks.asset.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); - expect(mediaMock.transcode).toHaveBeenCalledWith( + expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', expect.objectContaining({ @@ -2313,11 +2312,11 @@ describe(MediaService.name, () => { }); it('should tonemap when policy is required and video is hdr', async () => { - mediaMock.probe.mockResolvedValue(probeStub.videoStreamHDR); - systemMock.get.mockResolvedValue({ ffmpeg: { transcode: TranscodePolicy.REQUIRED } }); - assetMock.getByIds.mockResolvedValue([assetStub.video]); + mocks.media.probe.mockResolvedValue(probeStub.videoStreamHDR); + mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { transcode: TranscodePolicy.REQUIRED } }); + mocks.asset.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); - expect(mediaMock.transcode).toHaveBeenCalledWith( + expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', expect.objectContaining({ @@ -2333,11 +2332,11 @@ describe(MediaService.name, () => { }); it('should tonemap when policy is optimal and video is hdr', async () => { - mediaMock.probe.mockResolvedValue(probeStub.videoStreamHDR); - systemMock.get.mockResolvedValue({ ffmpeg: { transcode: TranscodePolicy.OPTIMAL } }); - assetMock.getByIds.mockResolvedValue([assetStub.video]); + mocks.media.probe.mockResolvedValue(probeStub.videoStreamHDR); + mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { transcode: TranscodePolicy.OPTIMAL } }); + mocks.asset.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); - expect(mediaMock.transcode).toHaveBeenCalledWith( + expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', expect.objectContaining({ @@ -2353,11 +2352,11 @@ describe(MediaService.name, () => { }); it('should transcode when policy is required and video is not yuv420p', async () => { - mediaMock.probe.mockResolvedValue(probeStub.videoStream10Bit); - systemMock.get.mockResolvedValue({ ffmpeg: { transcode: TranscodePolicy.REQUIRED } }); - assetMock.getByIds.mockResolvedValue([assetStub.video]); + mocks.media.probe.mockResolvedValue(probeStub.videoStream10Bit); + mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { transcode: TranscodePolicy.REQUIRED } }); + mocks.asset.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); - expect(mediaMock.transcode).toHaveBeenCalledWith( + expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', expect.objectContaining({ @@ -2369,14 +2368,14 @@ describe(MediaService.name, () => { }); it('should count frames for progress when log level is debug', async () => { - mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer); - loggerMock.isLevelEnabled.mockReturnValue(true); - assetMock.getByIds.mockResolvedValue([assetStub.video]); + mocks.media.probe.mockResolvedValue(probeStub.matroskaContainer); + mocks.logger.isLevelEnabled.mockReturnValue(true); + mocks.asset.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); - expect(mediaMock.probe).toHaveBeenCalledWith(assetStub.video.originalPath, { countFrames: true }); - expect(mediaMock.transcode).toHaveBeenCalledWith( + expect(mocks.media.probe).toHaveBeenCalledWith(assetStub.video.originalPath, { countFrames: true }); + expect(mocks.media.transcode).toHaveBeenCalledWith( assetStub.video.originalPath, 'upload/encoded-video/user-id/as/se/asset-id.mp4', { @@ -2392,20 +2391,20 @@ describe(MediaService.name, () => { }); it('should not count frames for progress when log level is not debug', async () => { - mediaMock.probe.mockResolvedValue(probeStub.videoStream2160p); - loggerMock.isLevelEnabled.mockReturnValue(false); - assetMock.getByIds.mockResolvedValue([assetStub.video]); + mocks.media.probe.mockResolvedValue(probeStub.videoStream2160p); + mocks.logger.isLevelEnabled.mockReturnValue(false); + mocks.asset.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); - expect(mediaMock.probe).toHaveBeenCalledWith(assetStub.video.originalPath, { countFrames: false }); + expect(mocks.media.probe).toHaveBeenCalledWith(assetStub.video.originalPath, { countFrames: false }); }); it('should process unknown audio stream', async () => { - mediaMock.probe.mockResolvedValue(probeStub.audioStreamUnknown); - assetMock.getByIds.mockResolvedValue([assetStub.video]); + mocks.media.probe.mockResolvedValue(probeStub.audioStreamUnknown); + mocks.asset.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); - expect(mediaMock.transcode).toHaveBeenCalledWith( + expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', expect.objectContaining({ diff --git a/server/src/services/memory.service.spec.ts b/server/src/services/memory.service.spec.ts index a5fa6a9cab..54acfa7baa 100644 --- a/server/src/services/memory.service.spec.ts +++ b/server/src/services/memory.service.spec.ts @@ -1,22 +1,17 @@ import { BadRequestException } from '@nestjs/common'; import { MemoryType } from 'src/enum'; import { MemoryService } from 'src/services/memory.service'; -import { IMemoryRepository } from 'src/types'; import { authStub } from 'test/fixtures/auth.stub'; import { memoryStub } from 'test/fixtures/memory.stub'; import { userStub } from 'test/fixtures/user.stub'; -import { IAccessRepositoryMock } from 'test/repositories/access.repository.mock'; -import { newTestService } from 'test/utils'; -import { Mocked } from 'vitest'; +import { newTestService, ServiceMocks } from 'test/utils'; describe(MemoryService.name, () => { let sut: MemoryService; - - let accessMock: IAccessRepositoryMock; - let memoryMock: Mocked; + let mocks: ServiceMocks; beforeEach(() => { - ({ sut, accessMock, memoryMock } = newTestService(MemoryService)); + ({ sut, mocks } = newTestService(MemoryService)); }); it('should be defined', () => { @@ -25,7 +20,7 @@ describe(MemoryService.name, () => { describe('search', () => { it('should search memories', async () => { - memoryMock.search.mockResolvedValue([memoryStub.memory1, memoryStub.empty]); + mocks.memory.search.mockResolvedValue([memoryStub.memory1, memoryStub.empty]); await expect(sut.search(authStub.admin)).resolves.toEqual( expect.arrayContaining([ expect.objectContaining({ id: 'memory1', assets: expect.any(Array) }), @@ -45,22 +40,22 @@ describe(MemoryService.name, () => { }); it('should throw an error when the memory is not found', async () => { - accessMock.memory.checkOwnerAccess.mockResolvedValue(new Set(['race-condition'])); + mocks.access.memory.checkOwnerAccess.mockResolvedValue(new Set(['race-condition'])); await expect(sut.get(authStub.admin, 'race-condition')).rejects.toBeInstanceOf(BadRequestException); }); it('should get a memory by id', async () => { - memoryMock.get.mockResolvedValue(memoryStub.memory1); - accessMock.memory.checkOwnerAccess.mockResolvedValue(new Set(['memory1'])); + mocks.memory.get.mockResolvedValue(memoryStub.memory1); + mocks.access.memory.checkOwnerAccess.mockResolvedValue(new Set(['memory1'])); await expect(sut.get(authStub.admin, 'memory1')).resolves.toMatchObject({ id: 'memory1' }); - expect(memoryMock.get).toHaveBeenCalledWith('memory1'); - expect(accessMock.memory.checkOwnerAccess).toHaveBeenCalledWith(userStub.admin.id, new Set(['memory1'])); + expect(mocks.memory.get).toHaveBeenCalledWith('memory1'); + expect(mocks.access.memory.checkOwnerAccess).toHaveBeenCalledWith(userStub.admin.id, new Set(['memory1'])); }); }); describe('create', () => { it('should skip assets the user does not have access to', async () => { - memoryMock.create.mockResolvedValue(memoryStub.empty); + mocks.memory.create.mockResolvedValue(memoryStub.empty); await expect( sut.create(authStub.admin, { type: MemoryType.ON_THIS_DAY, @@ -69,7 +64,7 @@ describe(MemoryService.name, () => { memoryAt: new Date(2024), }), ).resolves.toMatchObject({ assets: [] }); - expect(memoryMock.create).toHaveBeenCalledWith( + expect(mocks.memory.create).toHaveBeenCalledWith( { ownerId: 'admin_id', memoryAt: expect.any(Date), @@ -83,8 +78,8 @@ describe(MemoryService.name, () => { }); it('should create a memory', async () => { - accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset1'])); - memoryMock.create.mockResolvedValue(memoryStub.memory1); + mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset1'])); + mocks.memory.create.mockResolvedValue(memoryStub.memory1); await expect( sut.create(authStub.admin, { type: MemoryType.ON_THIS_DAY, @@ -93,7 +88,7 @@ describe(MemoryService.name, () => { memoryAt: new Date(2024, 0, 1), }), ).resolves.toBeDefined(); - expect(memoryMock.create).toHaveBeenCalledWith( + expect(mocks.memory.create).toHaveBeenCalledWith( expect.objectContaining({ ownerId: userStub.admin.id, }), @@ -102,7 +97,7 @@ describe(MemoryService.name, () => { }); it('should create a memory without assets', async () => { - memoryMock.create.mockResolvedValue(memoryStub.memory1); + mocks.memory.create.mockResolvedValue(memoryStub.memory1); await expect( sut.create(authStub.admin, { type: MemoryType.ON_THIS_DAY, @@ -118,27 +113,27 @@ describe(MemoryService.name, () => { await expect(sut.update(authStub.admin, 'not-found', { isSaved: true })).rejects.toBeInstanceOf( BadRequestException, ); - expect(memoryMock.update).not.toHaveBeenCalled(); + expect(mocks.memory.update).not.toHaveBeenCalled(); }); it('should update a memory', async () => { - accessMock.memory.checkOwnerAccess.mockResolvedValue(new Set(['memory1'])); - memoryMock.update.mockResolvedValue(memoryStub.memory1); + mocks.access.memory.checkOwnerAccess.mockResolvedValue(new Set(['memory1'])); + mocks.memory.update.mockResolvedValue(memoryStub.memory1); await expect(sut.update(authStub.admin, 'memory1', { isSaved: true })).resolves.toBeDefined(); - expect(memoryMock.update).toHaveBeenCalledWith('memory1', expect.objectContaining({ isSaved: true })); + expect(mocks.memory.update).toHaveBeenCalledWith('memory1', expect.objectContaining({ isSaved: true })); }); }); describe('remove', () => { it('should require access', async () => { await expect(sut.remove(authStub.admin, 'not-found')).rejects.toBeInstanceOf(BadRequestException); - expect(memoryMock.delete).not.toHaveBeenCalled(); + expect(mocks.memory.delete).not.toHaveBeenCalled(); }); it('should delete a memory', async () => { - accessMock.memory.checkOwnerAccess.mockResolvedValue(new Set(['memory1'])); + mocks.access.memory.checkOwnerAccess.mockResolvedValue(new Set(['memory1'])); await expect(sut.remove(authStub.admin, 'memory1')).resolves.toBeUndefined(); - expect(memoryMock.delete).toHaveBeenCalledWith('memory1'); + expect(mocks.memory.delete).toHaveBeenCalledWith('memory1'); }); }); @@ -147,36 +142,36 @@ describe(MemoryService.name, () => { await expect(sut.addAssets(authStub.admin, 'not-found', { ids: ['asset1'] })).rejects.toBeInstanceOf( BadRequestException, ); - expect(memoryMock.addAssetIds).not.toHaveBeenCalled(); + expect(mocks.memory.addAssetIds).not.toHaveBeenCalled(); }); it('should require asset access', async () => { - accessMock.memory.checkOwnerAccess.mockResolvedValue(new Set(['memory1'])); - memoryMock.get.mockResolvedValue(memoryStub.memory1); + mocks.access.memory.checkOwnerAccess.mockResolvedValue(new Set(['memory1'])); + mocks.memory.get.mockResolvedValue(memoryStub.memory1); await expect(sut.addAssets(authStub.admin, 'memory1', { ids: ['not-found'] })).resolves.toEqual([ { error: 'no_permission', id: 'not-found', success: false }, ]); - expect(memoryMock.addAssetIds).not.toHaveBeenCalled(); + expect(mocks.memory.addAssetIds).not.toHaveBeenCalled(); }); it('should skip assets already in the memory', async () => { - accessMock.memory.checkOwnerAccess.mockResolvedValue(new Set(['memory1'])); - memoryMock.get.mockResolvedValue(memoryStub.memory1); - memoryMock.getAssetIds.mockResolvedValue(new Set(['asset1'])); + mocks.access.memory.checkOwnerAccess.mockResolvedValue(new Set(['memory1'])); + mocks.memory.get.mockResolvedValue(memoryStub.memory1); + mocks.memory.getAssetIds.mockResolvedValue(new Set(['asset1'])); await expect(sut.addAssets(authStub.admin, 'memory1', { ids: ['asset1'] })).resolves.toEqual([ { error: 'duplicate', id: 'asset1', success: false }, ]); - expect(memoryMock.addAssetIds).not.toHaveBeenCalled(); + expect(mocks.memory.addAssetIds).not.toHaveBeenCalled(); }); it('should add assets', async () => { - accessMock.memory.checkOwnerAccess.mockResolvedValue(new Set(['memory1'])); - accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset1'])); - memoryMock.get.mockResolvedValue(memoryStub.memory1); + mocks.access.memory.checkOwnerAccess.mockResolvedValue(new Set(['memory1'])); + mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset1'])); + mocks.memory.get.mockResolvedValue(memoryStub.memory1); await expect(sut.addAssets(authStub.admin, 'memory1', { ids: ['asset1'] })).resolves.toEqual([ { id: 'asset1', success: true }, ]); - expect(memoryMock.addAssetIds).toHaveBeenCalledWith('memory1', ['asset1']); + expect(mocks.memory.addAssetIds).toHaveBeenCalledWith('memory1', ['asset1']); }); }); @@ -185,25 +180,25 @@ describe(MemoryService.name, () => { await expect(sut.removeAssets(authStub.admin, 'not-found', { ids: ['asset1'] })).rejects.toBeInstanceOf( BadRequestException, ); - expect(memoryMock.removeAssetIds).not.toHaveBeenCalled(); + expect(mocks.memory.removeAssetIds).not.toHaveBeenCalled(); }); it('should skip assets not in the memory', async () => { - accessMock.memory.checkOwnerAccess.mockResolvedValue(new Set(['memory1'])); + mocks.access.memory.checkOwnerAccess.mockResolvedValue(new Set(['memory1'])); await expect(sut.removeAssets(authStub.admin, 'memory1', { ids: ['not-found'] })).resolves.toEqual([ { error: 'not_found', id: 'not-found', success: false }, ]); - expect(memoryMock.removeAssetIds).not.toHaveBeenCalled(); + expect(mocks.memory.removeAssetIds).not.toHaveBeenCalled(); }); it('should remove assets', async () => { - accessMock.memory.checkOwnerAccess.mockResolvedValue(new Set(['memory1'])); - accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset1'])); - memoryMock.getAssetIds.mockResolvedValue(new Set(['asset1'])); + mocks.access.memory.checkOwnerAccess.mockResolvedValue(new Set(['memory1'])); + mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset1'])); + mocks.memory.getAssetIds.mockResolvedValue(new Set(['asset1'])); await expect(sut.removeAssets(authStub.admin, 'memory1', { ids: ['asset1'] })).resolves.toEqual([ { id: 'asset1', success: true }, ]); - expect(memoryMock.removeAssetIds).toHaveBeenCalledWith('memory1', ['asset1']); + expect(mocks.memory.removeAssetIds).toHaveBeenCalledWith('memory1', ['asset1']); }); }); }); diff --git a/server/src/services/metadata.service.spec.ts b/server/src/services/metadata.service.spec.ts index 6384c17a42..5657dd02b9 100644 --- a/server/src/services/metadata.service.spec.ts +++ b/server/src/services/metadata.service.spec.ts @@ -5,79 +5,34 @@ import { constants } from 'node:fs/promises'; import { AssetEntity } from 'src/entities/asset.entity'; import { ExifEntity } from 'src/entities/exif.entity'; import { AssetType, ExifOrientation, ImmichWorker, SourceType } from 'src/enum'; -import { IAlbumRepository } from 'src/interfaces/album.interface'; -import { IAssetRepository, WithoutProperty } from 'src/interfaces/asset.interface'; -import { ICryptoRepository } from 'src/interfaces/crypto.interface'; -import { IEventRepository } from 'src/interfaces/event.interface'; -import { IJobRepository, JobName, JobStatus } from 'src/interfaces/job.interface'; -import { IPersonRepository } from 'src/interfaces/person.interface'; -import { IStorageRepository } from 'src/interfaces/storage.interface'; -import { ITagRepository } from 'src/interfaces/tag.interface'; -import { IUserRepository } from 'src/interfaces/user.interface'; +import { WithoutProperty } from 'src/interfaces/asset.interface'; +import { JobName, JobStatus } from 'src/interfaces/job.interface'; import { ImmichTags } from 'src/repositories/metadata.repository'; import { MetadataService } from 'src/services/metadata.service'; -import { - IConfigRepository, - IMapRepository, - IMediaRepository, - IMetadataRepository, - ISystemMetadataRepository, -} from 'src/types'; import { assetStub } from 'test/fixtures/asset.stub'; import { fileStub } from 'test/fixtures/file.stub'; import { probeStub } from 'test/fixtures/media.stub'; import { metadataStub } from 'test/fixtures/metadata.stub'; import { personStub } from 'test/fixtures/person.stub'; import { tagStub } from 'test/fixtures/tag.stub'; -import { newTestService } from 'test/utils'; -import { Mocked } from 'vitest'; +import { newTestService, ServiceMocks } from 'test/utils'; describe(MetadataService.name, () => { let sut: MetadataService; - - let albumMock: Mocked; - let assetMock: Mocked; - let configMock: Mocked; - let cryptoMock: Mocked; - let eventMock: Mocked; - let jobMock: Mocked; - let mapMock: Mocked; - let mediaMock: Mocked; - let metadataMock: Mocked; - let personMock: Mocked; - let storageMock: Mocked; - let systemMock: Mocked; - let tagMock: Mocked; - let userMock: Mocked; + let mocks: ServiceMocks; const mockReadTags = (exifData?: Partial, sidecarData?: Partial) => { - metadataMock.readTags.mockReset(); - metadataMock.readTags.mockResolvedValueOnce(exifData ?? {}); - metadataMock.readTags.mockResolvedValueOnce(sidecarData ?? {}); + mocks.metadata.readTags.mockReset(); + mocks.metadata.readTags.mockResolvedValueOnce(exifData ?? {}); + mocks.metadata.readTags.mockResolvedValueOnce(sidecarData ?? {}); }; beforeEach(() => { - ({ - sut, - albumMock, - assetMock, - configMock, - cryptoMock, - eventMock, - jobMock, - mapMock, - mediaMock, - metadataMock, - personMock, - storageMock, - systemMock, - tagMock, - userMock, - } = newTestService(MetadataService)); + ({ sut, mocks } = newTestService(MetadataService)); mockReadTags(); - configMock.getWorker.mockReturnValue(ImmichWorker.MICROSERVICES); + mocks.config.getWorker.mockReturnValue(ImmichWorker.MICROSERVICES); delete process.env.TZ; }); @@ -94,43 +49,43 @@ describe(MetadataService.name, () => { it('should pause and resume queue during init', async () => { await sut.onBootstrap(); - expect(jobMock.pause).toHaveBeenCalledTimes(1); - expect(mapMock.init).toHaveBeenCalledTimes(1); - expect(jobMock.resume).toHaveBeenCalledTimes(1); + expect(mocks.job.pause).toHaveBeenCalledTimes(1); + expect(mocks.map.init).toHaveBeenCalledTimes(1); + expect(mocks.job.resume).toHaveBeenCalledTimes(1); }); }); describe('handleLivePhotoLinking', () => { it('should handle an asset that could not be found', async () => { await expect(sut.handleLivePhotoLinking({ id: assetStub.image.id })).resolves.toBe(JobStatus.FAILED); - expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.image.id], { exifInfo: true }); - expect(assetMock.findLivePhotoMatch).not.toHaveBeenCalled(); - expect(assetMock.update).not.toHaveBeenCalled(); - expect(albumMock.removeAsset).not.toHaveBeenCalled(); + expect(mocks.asset.getByIds).toHaveBeenCalledWith([assetStub.image.id], { exifInfo: true }); + expect(mocks.asset.findLivePhotoMatch).not.toHaveBeenCalled(); + expect(mocks.asset.update).not.toHaveBeenCalled(); + expect(mocks.album.removeAsset).not.toHaveBeenCalled(); }); it('should handle an asset without exif info', async () => { - assetMock.getByIds.mockResolvedValue([{ ...assetStub.image, exifInfo: undefined }]); + mocks.asset.getByIds.mockResolvedValue([{ ...assetStub.image, exifInfo: undefined }]); await expect(sut.handleLivePhotoLinking({ id: assetStub.image.id })).resolves.toBe(JobStatus.FAILED); - expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.image.id], { exifInfo: true }); - expect(assetMock.findLivePhotoMatch).not.toHaveBeenCalled(); - expect(assetMock.update).not.toHaveBeenCalled(); - expect(albumMock.removeAsset).not.toHaveBeenCalled(); + expect(mocks.asset.getByIds).toHaveBeenCalledWith([assetStub.image.id], { exifInfo: true }); + expect(mocks.asset.findLivePhotoMatch).not.toHaveBeenCalled(); + expect(mocks.asset.update).not.toHaveBeenCalled(); + expect(mocks.album.removeAsset).not.toHaveBeenCalled(); }); it('should handle livePhotoCID not set', async () => { - assetMock.getByIds.mockResolvedValue([{ ...assetStub.image }]); + mocks.asset.getByIds.mockResolvedValue([{ ...assetStub.image }]); await expect(sut.handleLivePhotoLinking({ id: assetStub.image.id })).resolves.toBe(JobStatus.SKIPPED); - expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.image.id], { exifInfo: true }); - expect(assetMock.findLivePhotoMatch).not.toHaveBeenCalled(); - expect(assetMock.update).not.toHaveBeenCalled(); - expect(albumMock.removeAsset).not.toHaveBeenCalled(); + expect(mocks.asset.getByIds).toHaveBeenCalledWith([assetStub.image.id], { exifInfo: true }); + expect(mocks.asset.findLivePhotoMatch).not.toHaveBeenCalled(); + expect(mocks.asset.update).not.toHaveBeenCalled(); + expect(mocks.album.removeAsset).not.toHaveBeenCalled(); }); it('should handle not finding a match', async () => { - assetMock.getByIds.mockResolvedValue([ + mocks.asset.getByIds.mockResolvedValue([ { ...assetStub.livePhotoMotionAsset, exifInfo: { livePhotoCID: assetStub.livePhotoStillAsset.id } as ExifEntity, @@ -140,64 +95,64 @@ describe(MetadataService.name, () => { await expect(sut.handleLivePhotoLinking({ id: assetStub.livePhotoMotionAsset.id })).resolves.toBe( JobStatus.SKIPPED, ); - expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.livePhotoMotionAsset.id], { exifInfo: true }); - expect(assetMock.findLivePhotoMatch).toHaveBeenCalledWith({ + expect(mocks.asset.getByIds).toHaveBeenCalledWith([assetStub.livePhotoMotionAsset.id], { exifInfo: true }); + expect(mocks.asset.findLivePhotoMatch).toHaveBeenCalledWith({ livePhotoCID: assetStub.livePhotoStillAsset.id, ownerId: assetStub.livePhotoMotionAsset.ownerId, otherAssetId: assetStub.livePhotoMotionAsset.id, type: AssetType.IMAGE, }); - expect(assetMock.update).not.toHaveBeenCalled(); - expect(albumMock.removeAsset).not.toHaveBeenCalled(); + expect(mocks.asset.update).not.toHaveBeenCalled(); + expect(mocks.album.removeAsset).not.toHaveBeenCalled(); }); it('should link photo and video', async () => { - assetMock.getByIds.mockResolvedValue([ + mocks.asset.getByIds.mockResolvedValue([ { ...assetStub.livePhotoStillAsset, exifInfo: { livePhotoCID: assetStub.livePhotoMotionAsset.id } as ExifEntity, }, ]); - assetMock.findLivePhotoMatch.mockResolvedValue(assetStub.livePhotoMotionAsset); + mocks.asset.findLivePhotoMatch.mockResolvedValue(assetStub.livePhotoMotionAsset); await expect(sut.handleLivePhotoLinking({ id: assetStub.livePhotoStillAsset.id })).resolves.toBe( JobStatus.SUCCESS, ); - expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.livePhotoStillAsset.id], { exifInfo: true }); - expect(assetMock.findLivePhotoMatch).toHaveBeenCalledWith({ + expect(mocks.asset.getByIds).toHaveBeenCalledWith([assetStub.livePhotoStillAsset.id], { exifInfo: true }); + expect(mocks.asset.findLivePhotoMatch).toHaveBeenCalledWith({ livePhotoCID: assetStub.livePhotoMotionAsset.id, ownerId: assetStub.livePhotoStillAsset.ownerId, otherAssetId: assetStub.livePhotoStillAsset.id, type: AssetType.VIDEO, }); - expect(assetMock.update).toHaveBeenCalledWith({ + expect(mocks.asset.update).toHaveBeenCalledWith({ id: assetStub.livePhotoStillAsset.id, livePhotoVideoId: assetStub.livePhotoMotionAsset.id, }); - expect(assetMock.update).toHaveBeenCalledWith({ id: assetStub.livePhotoMotionAsset.id, isVisible: false }); - expect(albumMock.removeAsset).toHaveBeenCalledWith(assetStub.livePhotoMotionAsset.id); + expect(mocks.asset.update).toHaveBeenCalledWith({ id: assetStub.livePhotoMotionAsset.id, isVisible: false }); + expect(mocks.album.removeAsset).toHaveBeenCalledWith(assetStub.livePhotoMotionAsset.id); }); it('should notify clients on live photo link', async () => { - assetMock.getByIds.mockResolvedValue([ + mocks.asset.getByIds.mockResolvedValue([ { ...assetStub.livePhotoStillAsset, exifInfo: { livePhotoCID: assetStub.livePhotoMotionAsset.id } as ExifEntity, }, ]); - assetMock.findLivePhotoMatch.mockResolvedValue(assetStub.livePhotoMotionAsset); + mocks.asset.findLivePhotoMatch.mockResolvedValue(assetStub.livePhotoMotionAsset); await expect(sut.handleLivePhotoLinking({ id: assetStub.livePhotoStillAsset.id })).resolves.toBe( JobStatus.SUCCESS, ); - expect(eventMock.emit).toHaveBeenCalledWith('asset.hide', { + expect(mocks.event.emit).toHaveBeenCalledWith('asset.hide', { userId: assetStub.livePhotoMotionAsset.ownerId, assetId: assetStub.livePhotoMotionAsset.id, }); }); it('should search by libraryId', async () => { - assetMock.getByIds.mockResolvedValue([ + mocks.asset.getByIds.mockResolvedValue([ { ...assetStub.livePhotoStillAsset, libraryId: 'library-id', @@ -209,7 +164,7 @@ describe(MetadataService.name, () => { JobStatus.SKIPPED, ); - expect(assetMock.findLivePhotoMatch).toHaveBeenCalledWith({ + expect(mocks.asset.findLivePhotoMatch).toHaveBeenCalledWith({ ownerId: 'user-id', otherAssetId: 'live-photo-still-asset', livePhotoCID: 'CID', @@ -221,11 +176,11 @@ describe(MetadataService.name, () => { describe('handleQueueMetadataExtraction', () => { it('should queue metadata extraction for all assets without exif values', async () => { - assetMock.getWithout.mockResolvedValue({ items: [assetStub.image], hasNextPage: false }); + mocks.asset.getWithout.mockResolvedValue({ items: [assetStub.image], hasNextPage: false }); await expect(sut.handleQueueMetadataExtraction({ force: false })).resolves.toBe(JobStatus.SUCCESS); - expect(assetMock.getWithout).toHaveBeenCalled(); - expect(jobMock.queueAll).toHaveBeenCalledWith([ + expect(mocks.asset.getWithout).toHaveBeenCalled(); + expect(mocks.job.queueAll).toHaveBeenCalledWith([ { name: JobName.METADATA_EXTRACTION, data: { id: assetStub.image.id }, @@ -234,11 +189,11 @@ describe(MetadataService.name, () => { }); it('should queue metadata extraction for all assets', async () => { - assetMock.getAll.mockResolvedValue({ items: [assetStub.image], hasNextPage: false }); + mocks.asset.getAll.mockResolvedValue({ items: [assetStub.image], hasNextPage: false }); await expect(sut.handleQueueMetadataExtraction({ force: true })).resolves.toBe(JobStatus.SUCCESS); - expect(assetMock.getAll).toHaveBeenCalled(); - expect(jobMock.queueAll).toHaveBeenCalledWith([ + expect(mocks.asset.getAll).toHaveBeenCalled(); + expect(mocks.job.queueAll).toHaveBeenCalledWith([ { name: JobName.METADATA_EXTRACTION, data: { id: assetStub.image.id }, @@ -249,27 +204,27 @@ describe(MetadataService.name, () => { describe('handleMetadataExtraction', () => { beforeEach(() => { - storageMock.stat.mockResolvedValue({ size: 123_456 } as Stats); + mocks.storage.stat.mockResolvedValue({ size: 123_456 } as Stats); }); it('should handle an asset that could not be found', async () => { await expect(sut.handleMetadataExtraction({ id: assetStub.image.id })).resolves.toBe(JobStatus.FAILED); - expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.image.id], { faces: { person: false } }); - expect(assetMock.upsertExif).not.toHaveBeenCalled(); - expect(assetMock.update).not.toHaveBeenCalled(); + expect(mocks.asset.getByIds).toHaveBeenCalledWith([assetStub.image.id], { faces: { person: false } }); + expect(mocks.asset.upsertExif).not.toHaveBeenCalled(); + expect(mocks.asset.update).not.toHaveBeenCalled(); }); it('should handle a date in a sidecar file', async () => { const originalDate = new Date('2023-11-21T16:13:17.517Z'); const sidecarDate = new Date('2022-01-01T00:00:00.000Z'); - assetMock.getByIds.mockResolvedValue([assetStub.sidecar]); + mocks.asset.getByIds.mockResolvedValue([assetStub.sidecar]); mockReadTags({ CreationDate: originalDate.toISOString() }, { CreationDate: sidecarDate.toISOString() }); await sut.handleMetadataExtraction({ id: assetStub.image.id }); - expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.sidecar.id], { faces: { person: false } }); - expect(assetMock.upsertExif).toHaveBeenCalledWith(expect.objectContaining({ dateTimeOriginal: sidecarDate })); - expect(assetMock.update).toHaveBeenCalledWith({ + expect(mocks.asset.getByIds).toHaveBeenCalledWith([assetStub.sidecar.id], { faces: { person: false } }); + expect(mocks.asset.upsertExif).toHaveBeenCalledWith(expect.objectContaining({ dateTimeOriginal: sidecarDate })); + expect(mocks.asset.update).toHaveBeenCalledWith({ id: assetStub.image.id, duration: null, fileCreatedAt: sidecarDate, @@ -280,13 +235,15 @@ describe(MetadataService.name, () => { it('should take the file modification date when missing exif and earliest than creation date', async () => { const fileCreatedAt = new Date('2022-01-01T00:00:00.000Z'); const fileModifiedAt = new Date('2021-01-01T00:00:00.000Z'); - assetMock.getByIds.mockResolvedValue([{ ...assetStub.image, fileCreatedAt, fileModifiedAt }]); + mocks.asset.getByIds.mockResolvedValue([{ ...assetStub.image, fileCreatedAt, fileModifiedAt }]); mockReadTags(); await sut.handleMetadataExtraction({ id: assetStub.image.id }); - expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.image.id], { faces: { person: false } }); - expect(assetMock.upsertExif).toHaveBeenCalledWith(expect.objectContaining({ dateTimeOriginal: fileModifiedAt })); - expect(assetMock.update).toHaveBeenCalledWith({ + expect(mocks.asset.getByIds).toHaveBeenCalledWith([assetStub.image.id], { faces: { person: false } }); + expect(mocks.asset.upsertExif).toHaveBeenCalledWith( + expect.objectContaining({ dateTimeOriginal: fileModifiedAt }), + ); + expect(mocks.asset.update).toHaveBeenCalledWith({ id: assetStub.image.id, duration: null, fileCreatedAt: fileModifiedAt, @@ -297,13 +254,13 @@ describe(MetadataService.name, () => { it('should take the file creation date when missing exif and earliest than modification date', async () => { const fileCreatedAt = new Date('2021-01-01T00:00:00.000Z'); const fileModifiedAt = new Date('2022-01-01T00:00:00.000Z'); - assetMock.getByIds.mockResolvedValue([{ ...assetStub.image, fileCreatedAt, fileModifiedAt }]); + mocks.asset.getByIds.mockResolvedValue([{ ...assetStub.image, fileCreatedAt, fileModifiedAt }]); mockReadTags(); await sut.handleMetadataExtraction({ id: assetStub.image.id }); - expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.image.id], { faces: { person: false } }); - expect(assetMock.upsertExif).toHaveBeenCalledWith(expect.objectContaining({ dateTimeOriginal: fileCreatedAt })); - expect(assetMock.update).toHaveBeenCalledWith({ + expect(mocks.asset.getByIds).toHaveBeenCalledWith([assetStub.image.id], { faces: { person: false } }); + expect(mocks.asset.upsertExif).toHaveBeenCalledWith(expect.objectContaining({ dateTimeOriginal: fileCreatedAt })); + expect(mocks.asset.update).toHaveBeenCalledWith({ id: assetStub.image.id, duration: null, fileCreatedAt, @@ -313,17 +270,17 @@ describe(MetadataService.name, () => { it('should account for the server being in a non-UTC timezone', async () => { process.env.TZ = 'America/Los_Angeles'; - assetMock.getByIds.mockResolvedValue([assetStub.sidecar]); + mocks.asset.getByIds.mockResolvedValue([assetStub.sidecar]); mockReadTags({ DateTimeOriginal: '2022:01:01 00:00:00' }); await sut.handleMetadataExtraction({ id: assetStub.image.id }); - expect(assetMock.upsertExif).toHaveBeenCalledWith( + expect(mocks.asset.upsertExif).toHaveBeenCalledWith( expect.objectContaining({ dateTimeOriginal: new Date('2022-01-01T08:00:00.000Z'), }), ); - expect(assetMock.update).toHaveBeenCalledWith( + expect(mocks.asset.update).toHaveBeenCalledWith( expect.objectContaining({ localDateTime: new Date('2022-01-01T00:00:00.000Z'), }), @@ -331,13 +288,13 @@ describe(MetadataService.name, () => { }); it('should handle lists of numbers', async () => { - assetMock.getByIds.mockResolvedValue([assetStub.image]); + mocks.asset.getByIds.mockResolvedValue([assetStub.image]); mockReadTags({ ISO: [160] }); await sut.handleMetadataExtraction({ id: assetStub.image.id }); - expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.image.id], { faces: { person: false } }); - expect(assetMock.upsertExif).toHaveBeenCalledWith(expect.objectContaining({ iso: 160 })); - expect(assetMock.update).toHaveBeenCalledWith({ + expect(mocks.asset.getByIds).toHaveBeenCalledWith([assetStub.image.id], { faces: { person: false } }); + expect(mocks.asset.upsertExif).toHaveBeenCalledWith(expect.objectContaining({ iso: 160 })); + expect(mocks.asset.update).toHaveBeenCalledWith({ id: assetStub.image.id, duration: null, fileCreatedAt: assetStub.image.fileCreatedAt, @@ -346,20 +303,20 @@ describe(MetadataService.name, () => { }); it('should apply reverse geocoding', async () => { - assetMock.getByIds.mockResolvedValue([assetStub.withLocation]); - systemMock.get.mockResolvedValue({ reverseGeocoding: { enabled: true } }); - mapMock.reverseGeocode.mockResolvedValue({ city: 'City', state: 'State', country: 'Country' }); + mocks.asset.getByIds.mockResolvedValue([assetStub.withLocation]); + mocks.systemMetadata.get.mockResolvedValue({ reverseGeocoding: { enabled: true } }); + mocks.map.reverseGeocode.mockResolvedValue({ city: 'City', state: 'State', country: 'Country' }); mockReadTags({ GPSLatitude: assetStub.withLocation.exifInfo!.latitude!, GPSLongitude: assetStub.withLocation.exifInfo!.longitude!, }); await sut.handleMetadataExtraction({ id: assetStub.image.id }); - expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.image.id], { faces: { person: false } }); - expect(assetMock.upsertExif).toHaveBeenCalledWith( + expect(mocks.asset.getByIds).toHaveBeenCalledWith([assetStub.image.id], { faces: { person: false } }); + expect(mocks.asset.upsertExif).toHaveBeenCalledWith( expect.objectContaining({ city: 'City', state: 'State', country: 'Country' }), ); - expect(assetMock.update).toHaveBeenCalledWith({ + expect(mocks.asset.update).toHaveBeenCalledWith({ id: assetStub.withLocation.id, duration: null, fileCreatedAt: assetStub.withLocation.createdAt, @@ -368,37 +325,41 @@ describe(MetadataService.name, () => { }); it('should discard latitude and longitude on null island', async () => { - assetMock.getByIds.mockResolvedValue([assetStub.withLocation]); + mocks.asset.getByIds.mockResolvedValue([assetStub.withLocation]); mockReadTags({ GPSLatitude: 0, GPSLongitude: 0, }); await sut.handleMetadataExtraction({ id: assetStub.image.id }); - expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.image.id], { faces: { person: false } }); - expect(assetMock.upsertExif).toHaveBeenCalledWith(expect.objectContaining({ latitude: null, longitude: null })); + expect(mocks.asset.getByIds).toHaveBeenCalledWith([assetStub.image.id], { faces: { person: false } }); + expect(mocks.asset.upsertExif).toHaveBeenCalledWith(expect.objectContaining({ latitude: null, longitude: null })); }); it('should extract tags from TagsList', async () => { - assetMock.getByIds.mockResolvedValue([assetStub.image]); + mocks.asset.getByIds.mockResolvedValue([assetStub.image]); mockReadTags({ TagsList: ['Parent'] }); - tagMock.upsertValue.mockResolvedValue(tagStub.parent); + mocks.tag.upsertValue.mockResolvedValue(tagStub.parent); await sut.handleMetadataExtraction({ id: assetStub.image.id }); - expect(tagMock.upsertValue).toHaveBeenCalledWith({ userId: 'user-id', value: 'Parent', parent: undefined }); + expect(mocks.tag.upsertValue).toHaveBeenCalledWith({ userId: 'user-id', value: 'Parent', parent: undefined }); }); it('should extract hierarchy from TagsList', async () => { - assetMock.getByIds.mockResolvedValue([assetStub.image]); + mocks.asset.getByIds.mockResolvedValue([assetStub.image]); mockReadTags({ TagsList: ['Parent/Child'] }); - tagMock.upsertValue.mockResolvedValueOnce(tagStub.parent); - tagMock.upsertValue.mockResolvedValueOnce(tagStub.child); + mocks.tag.upsertValue.mockResolvedValueOnce(tagStub.parent); + mocks.tag.upsertValue.mockResolvedValueOnce(tagStub.child); await sut.handleMetadataExtraction({ id: assetStub.image.id }); - expect(tagMock.upsertValue).toHaveBeenNthCalledWith(1, { userId: 'user-id', value: 'Parent', parent: undefined }); - expect(tagMock.upsertValue).toHaveBeenNthCalledWith(2, { + expect(mocks.tag.upsertValue).toHaveBeenNthCalledWith(1, { + userId: 'user-id', + value: 'Parent', + parent: undefined, + }); + expect(mocks.tag.upsertValue).toHaveBeenNthCalledWith(2, { userId: 'user-id', value: 'Parent/Child', parent: tagStub.parent, @@ -406,45 +367,49 @@ describe(MetadataService.name, () => { }); it('should extract tags from Keywords as a string', async () => { - assetMock.getByIds.mockResolvedValue([assetStub.image]); + mocks.asset.getByIds.mockResolvedValue([assetStub.image]); mockReadTags({ Keywords: 'Parent' }); - tagMock.upsertValue.mockResolvedValue(tagStub.parent); + mocks.tag.upsertValue.mockResolvedValue(tagStub.parent); await sut.handleMetadataExtraction({ id: assetStub.image.id }); - expect(tagMock.upsertValue).toHaveBeenCalledWith({ userId: 'user-id', value: 'Parent', parent: undefined }); + expect(mocks.tag.upsertValue).toHaveBeenCalledWith({ userId: 'user-id', value: 'Parent', parent: undefined }); }); it('should extract tags from Keywords as a list', async () => { - assetMock.getByIds.mockResolvedValue([assetStub.image]); + mocks.asset.getByIds.mockResolvedValue([assetStub.image]); mockReadTags({ Keywords: ['Parent'] }); - tagMock.upsertValue.mockResolvedValue(tagStub.parent); + mocks.tag.upsertValue.mockResolvedValue(tagStub.parent); await sut.handleMetadataExtraction({ id: assetStub.image.id }); - expect(tagMock.upsertValue).toHaveBeenCalledWith({ userId: 'user-id', value: 'Parent', parent: undefined }); + expect(mocks.tag.upsertValue).toHaveBeenCalledWith({ userId: 'user-id', value: 'Parent', parent: undefined }); }); it('should extract tags from Keywords as a list with a number', async () => { - assetMock.getByIds.mockResolvedValue([assetStub.image]); + mocks.asset.getByIds.mockResolvedValue([assetStub.image]); mockReadTags({ Keywords: ['Parent', 2024] }); - tagMock.upsertValue.mockResolvedValue(tagStub.parent); + mocks.tag.upsertValue.mockResolvedValue(tagStub.parent); await sut.handleMetadataExtraction({ id: assetStub.image.id }); - expect(tagMock.upsertValue).toHaveBeenCalledWith({ userId: 'user-id', value: 'Parent', parent: undefined }); - expect(tagMock.upsertValue).toHaveBeenCalledWith({ userId: 'user-id', value: '2024', parent: undefined }); + expect(mocks.tag.upsertValue).toHaveBeenCalledWith({ userId: 'user-id', value: 'Parent', parent: undefined }); + expect(mocks.tag.upsertValue).toHaveBeenCalledWith({ userId: 'user-id', value: '2024', parent: undefined }); }); it('should extract hierarchal tags from Keywords', async () => { - assetMock.getByIds.mockResolvedValue([assetStub.image]); + mocks.asset.getByIds.mockResolvedValue([assetStub.image]); mockReadTags({ Keywords: 'Parent/Child' }); - tagMock.upsertValue.mockResolvedValue(tagStub.parent); + mocks.tag.upsertValue.mockResolvedValue(tagStub.parent); await sut.handleMetadataExtraction({ id: assetStub.image.id }); - expect(tagMock.upsertValue).toHaveBeenNthCalledWith(1, { userId: 'user-id', value: 'Parent', parent: undefined }); - expect(tagMock.upsertValue).toHaveBeenNthCalledWith(2, { + expect(mocks.tag.upsertValue).toHaveBeenNthCalledWith(1, { + userId: 'user-id', + value: 'Parent', + parent: undefined, + }); + expect(mocks.tag.upsertValue).toHaveBeenNthCalledWith(2, { userId: 'user-id', value: 'Parent/Child', parent: tagStub.parent, @@ -452,14 +417,18 @@ describe(MetadataService.name, () => { }); it('should ignore Keywords when TagsList is present', async () => { - assetMock.getByIds.mockResolvedValue([assetStub.image]); + mocks.asset.getByIds.mockResolvedValue([assetStub.image]); mockReadTags({ Keywords: 'Child', TagsList: ['Parent/Child'] }); - tagMock.upsertValue.mockResolvedValue(tagStub.parent); + mocks.tag.upsertValue.mockResolvedValue(tagStub.parent); await sut.handleMetadataExtraction({ id: assetStub.image.id }); - expect(tagMock.upsertValue).toHaveBeenNthCalledWith(1, { userId: 'user-id', value: 'Parent', parent: undefined }); - expect(tagMock.upsertValue).toHaveBeenNthCalledWith(2, { + expect(mocks.tag.upsertValue).toHaveBeenNthCalledWith(1, { + userId: 'user-id', + value: 'Parent', + parent: undefined, + }); + expect(mocks.tag.upsertValue).toHaveBeenNthCalledWith(2, { userId: 'user-id', value: 'Parent/Child', parent: tagStub.parent, @@ -467,41 +436,45 @@ describe(MetadataService.name, () => { }); it('should extract hierarchy from HierarchicalSubject', async () => { - assetMock.getByIds.mockResolvedValue([assetStub.image]); + mocks.asset.getByIds.mockResolvedValue([assetStub.image]); mockReadTags({ HierarchicalSubject: ['Parent|Child', 'TagA'] }); - tagMock.upsertValue.mockResolvedValueOnce(tagStub.parent); - tagMock.upsertValue.mockResolvedValueOnce(tagStub.child); + mocks.tag.upsertValue.mockResolvedValueOnce(tagStub.parent); + mocks.tag.upsertValue.mockResolvedValueOnce(tagStub.child); await sut.handleMetadataExtraction({ id: assetStub.image.id }); - expect(tagMock.upsertValue).toHaveBeenNthCalledWith(1, { userId: 'user-id', value: 'Parent', parent: undefined }); - expect(tagMock.upsertValue).toHaveBeenNthCalledWith(2, { + expect(mocks.tag.upsertValue).toHaveBeenNthCalledWith(1, { + userId: 'user-id', + value: 'Parent', + parent: undefined, + }); + expect(mocks.tag.upsertValue).toHaveBeenNthCalledWith(2, { userId: 'user-id', value: 'Parent/Child', parent: tagStub.parent, }); - expect(tagMock.upsertValue).toHaveBeenNthCalledWith(3, { userId: 'user-id', value: 'TagA', parent: undefined }); + expect(mocks.tag.upsertValue).toHaveBeenNthCalledWith(3, { userId: 'user-id', value: 'TagA', parent: undefined }); }); it('should extract tags from HierarchicalSubject as a list with a number', async () => { - assetMock.getByIds.mockResolvedValue([assetStub.image]); + mocks.asset.getByIds.mockResolvedValue([assetStub.image]); mockReadTags({ HierarchicalSubject: ['Parent', 2024] }); - tagMock.upsertValue.mockResolvedValue(tagStub.parent); + mocks.tag.upsertValue.mockResolvedValue(tagStub.parent); await sut.handleMetadataExtraction({ id: assetStub.image.id }); - expect(tagMock.upsertValue).toHaveBeenCalledWith({ userId: 'user-id', value: 'Parent', parent: undefined }); - expect(tagMock.upsertValue).toHaveBeenCalledWith({ userId: 'user-id', value: '2024', parent: undefined }); + expect(mocks.tag.upsertValue).toHaveBeenCalledWith({ userId: 'user-id', value: 'Parent', parent: undefined }); + expect(mocks.tag.upsertValue).toHaveBeenCalledWith({ userId: 'user-id', value: '2024', parent: undefined }); }); it('should extract ignore / characters in a HierarchicalSubject tag', async () => { - assetMock.getByIds.mockResolvedValue([assetStub.image]); + mocks.asset.getByIds.mockResolvedValue([assetStub.image]); mockReadTags({ HierarchicalSubject: ['Mom/Dad'] }); - tagMock.upsertValue.mockResolvedValueOnce(tagStub.parent); + mocks.tag.upsertValue.mockResolvedValueOnce(tagStub.parent); await sut.handleMetadataExtraction({ id: assetStub.image.id }); - expect(tagMock.upsertValue).toHaveBeenCalledWith({ + expect(mocks.tag.upsertValue).toHaveBeenCalledWith({ userId: 'user-id', value: 'Mom|Dad', parent: undefined, @@ -509,14 +482,18 @@ describe(MetadataService.name, () => { }); it('should ignore HierarchicalSubject when TagsList is present', async () => { - assetMock.getByIds.mockResolvedValue([assetStub.image]); + mocks.asset.getByIds.mockResolvedValue([assetStub.image]); mockReadTags({ HierarchicalSubject: ['Parent2|Child2'], TagsList: ['Parent/Child'] }); - tagMock.upsertValue.mockResolvedValue(tagStub.parent); + mocks.tag.upsertValue.mockResolvedValue(tagStub.parent); await sut.handleMetadataExtraction({ id: assetStub.image.id }); - expect(tagMock.upsertValue).toHaveBeenNthCalledWith(1, { userId: 'user-id', value: 'Parent', parent: undefined }); - expect(tagMock.upsertValue).toHaveBeenNthCalledWith(2, { + expect(mocks.tag.upsertValue).toHaveBeenNthCalledWith(1, { + userId: 'user-id', + value: 'Parent', + parent: undefined, + }); + expect(mocks.tag.upsertValue).toHaveBeenNthCalledWith(2, { userId: 'user-id', value: 'Parent/Child', parent: tagStub.parent, @@ -524,32 +501,32 @@ describe(MetadataService.name, () => { }); it('should remove existing tags', async () => { - assetMock.getByIds.mockResolvedValue([assetStub.image]); + mocks.asset.getByIds.mockResolvedValue([assetStub.image]); mockReadTags({}); await sut.handleMetadataExtraction({ id: assetStub.image.id }); - expect(tagMock.upsertAssetTags).toHaveBeenCalledWith({ assetId: 'asset-id', tagIds: [] }); + expect(mocks.tag.upsertAssetTags).toHaveBeenCalledWith({ assetId: 'asset-id', tagIds: [] }); }); it('should not apply motion photos if asset is video', async () => { - assetMock.getByIds.mockResolvedValue([{ ...assetStub.livePhotoMotionAsset, isVisible: true }]); - mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer); + mocks.asset.getByIds.mockResolvedValue([{ ...assetStub.livePhotoMotionAsset, isVisible: true }]); + mocks.media.probe.mockResolvedValue(probeStub.matroskaContainer); await sut.handleMetadataExtraction({ id: assetStub.livePhotoMotionAsset.id }); - expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.livePhotoMotionAsset.id], { + expect(mocks.asset.getByIds).toHaveBeenCalledWith([assetStub.livePhotoMotionAsset.id], { faces: { person: false }, }); - expect(storageMock.createOrOverwriteFile).not.toHaveBeenCalled(); - expect(jobMock.queue).not.toHaveBeenCalled(); - expect(jobMock.queueAll).not.toHaveBeenCalled(); - expect(assetMock.update).not.toHaveBeenCalledWith( + expect(mocks.storage.createOrOverwriteFile).not.toHaveBeenCalled(); + expect(mocks.job.queue).not.toHaveBeenCalled(); + expect(mocks.job.queueAll).not.toHaveBeenCalled(); + expect(mocks.asset.update).not.toHaveBeenCalledWith( expect.objectContaining({ assetType: AssetType.VIDEO, isVisible: false }), ); }); it('should handle an invalid Directory Item', async () => { - assetMock.getByIds.mockResolvedValue([assetStub.image]); + mocks.asset.getByIds.mockResolvedValue([assetStub.image]); mockReadTags({ MotionPhoto: 1, ContainerDirectory: [{ Foo: 100 }], @@ -559,20 +536,20 @@ describe(MetadataService.name, () => { }); it('should extract the correct video orientation', async () => { - assetMock.getByIds.mockResolvedValue([assetStub.video]); - mediaMock.probe.mockResolvedValue(probeStub.videoStreamVertical2160p); + mocks.asset.getByIds.mockResolvedValue([assetStub.video]); + mocks.media.probe.mockResolvedValue(probeStub.videoStreamVertical2160p); mockReadTags({}); await sut.handleMetadataExtraction({ id: assetStub.video.id }); - expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.video.id], { faces: { person: false } }); - expect(assetMock.upsertExif).toHaveBeenCalledWith( + expect(mocks.asset.getByIds).toHaveBeenCalledWith([assetStub.video.id], { faces: { person: false } }); + expect(mocks.asset.upsertExif).toHaveBeenCalledWith( expect.objectContaining({ orientation: ExifOrientation.Rotate270CW.toString() }), ); }); it('should extract the MotionPhotoVideo tag from Samsung HEIC motion photos', async () => { - assetMock.getByIds.mockResolvedValue([{ ...assetStub.livePhotoWithOriginalFileName, livePhotoVideoId: null }]); + mocks.asset.getByIds.mockResolvedValue([{ ...assetStub.livePhotoWithOriginalFileName, livePhotoVideoId: null }]); mockReadTags({ Directory: 'foo/bar/', MotionPhotoVideo: new BinaryField(0, ''), @@ -581,21 +558,21 @@ describe(MetadataService.name, () => { EmbeddedVideoFile: new BinaryField(0, ''), EmbeddedVideoType: 'MotionPhoto_Data', }); - cryptoMock.hashSha1.mockReturnValue(randomBytes(512)); - assetMock.create.mockResolvedValue(assetStub.livePhotoMotionAsset); - cryptoMock.randomUUID.mockReturnValue(fileStub.livePhotoMotion.uuid); + mocks.crypto.hashSha1.mockReturnValue(randomBytes(512)); + mocks.asset.create.mockResolvedValue(assetStub.livePhotoMotionAsset); + mocks.crypto.randomUUID.mockReturnValue(fileStub.livePhotoMotion.uuid); const video = randomBytes(512); - metadataMock.extractBinaryTag.mockResolvedValue(video); + mocks.metadata.extractBinaryTag.mockResolvedValue(video); await sut.handleMetadataExtraction({ id: assetStub.livePhotoWithOriginalFileName.id }); - expect(metadataMock.extractBinaryTag).toHaveBeenCalledWith( + expect(mocks.metadata.extractBinaryTag).toHaveBeenCalledWith( assetStub.livePhotoWithOriginalFileName.originalPath, 'MotionPhotoVideo', ); - expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.livePhotoWithOriginalFileName.id], { + expect(mocks.asset.getByIds).toHaveBeenCalledWith([assetStub.livePhotoWithOriginalFileName.id], { faces: { person: false }, }); - expect(assetMock.create).toHaveBeenCalledWith({ + expect(mocks.asset.create).toHaveBeenCalledWith({ checksum: expect.any(Buffer), deviceAssetId: 'NONE', deviceId: 'NONE', @@ -610,36 +587,36 @@ describe(MetadataService.name, () => { ownerId: assetStub.livePhotoWithOriginalFileName.ownerId, type: AssetType.VIDEO, }); - expect(userMock.updateUsage).toHaveBeenCalledWith(assetStub.livePhotoMotionAsset.ownerId, 512); - expect(storageMock.createFile).toHaveBeenCalledWith(assetStub.livePhotoMotionAsset.originalPath, video); - expect(assetMock.update).toHaveBeenNthCalledWith(1, { + expect(mocks.user.updateUsage).toHaveBeenCalledWith(assetStub.livePhotoMotionAsset.ownerId, 512); + expect(mocks.storage.createFile).toHaveBeenCalledWith(assetStub.livePhotoMotionAsset.originalPath, video); + expect(mocks.asset.update).toHaveBeenNthCalledWith(1, { id: assetStub.livePhotoWithOriginalFileName.id, livePhotoVideoId: fileStub.livePhotoMotion.uuid, }); }); it('should extract the EmbeddedVideo tag from Samsung JPEG motion photos', async () => { - assetMock.getByIds.mockResolvedValue([{ ...assetStub.livePhotoWithOriginalFileName, livePhotoVideoId: null }]); + mocks.asset.getByIds.mockResolvedValue([{ ...assetStub.livePhotoWithOriginalFileName, livePhotoVideoId: null }]); mockReadTags({ Directory: 'foo/bar/', EmbeddedVideoFile: new BinaryField(0, ''), EmbeddedVideoType: 'MotionPhoto_Data', }); - cryptoMock.hashSha1.mockReturnValue(randomBytes(512)); - assetMock.create.mockResolvedValue(assetStub.livePhotoMotionAsset); - cryptoMock.randomUUID.mockReturnValue(fileStub.livePhotoMotion.uuid); + mocks.crypto.hashSha1.mockReturnValue(randomBytes(512)); + mocks.asset.create.mockResolvedValue(assetStub.livePhotoMotionAsset); + mocks.crypto.randomUUID.mockReturnValue(fileStub.livePhotoMotion.uuid); const video = randomBytes(512); - metadataMock.extractBinaryTag.mockResolvedValue(video); + mocks.metadata.extractBinaryTag.mockResolvedValue(video); await sut.handleMetadataExtraction({ id: assetStub.livePhotoWithOriginalFileName.id }); - expect(metadataMock.extractBinaryTag).toHaveBeenCalledWith( + expect(mocks.metadata.extractBinaryTag).toHaveBeenCalledWith( assetStub.livePhotoWithOriginalFileName.originalPath, 'EmbeddedVideoFile', ); - expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.livePhotoWithOriginalFileName.id], { + expect(mocks.asset.getByIds).toHaveBeenCalledWith([assetStub.livePhotoWithOriginalFileName.id], { faces: { person: false }, }); - expect(assetMock.create).toHaveBeenCalledWith({ + expect(mocks.asset.create).toHaveBeenCalledWith({ checksum: expect.any(Buffer), deviceAssetId: 'NONE', deviceId: 'NONE', @@ -654,37 +631,37 @@ describe(MetadataService.name, () => { ownerId: assetStub.livePhotoWithOriginalFileName.ownerId, type: AssetType.VIDEO, }); - expect(userMock.updateUsage).toHaveBeenCalledWith(assetStub.livePhotoMotionAsset.ownerId, 512); - expect(storageMock.createFile).toHaveBeenCalledWith(assetStub.livePhotoMotionAsset.originalPath, video); - expect(assetMock.update).toHaveBeenNthCalledWith(1, { + expect(mocks.user.updateUsage).toHaveBeenCalledWith(assetStub.livePhotoMotionAsset.ownerId, 512); + expect(mocks.storage.createFile).toHaveBeenCalledWith(assetStub.livePhotoMotionAsset.originalPath, video); + expect(mocks.asset.update).toHaveBeenNthCalledWith(1, { id: assetStub.livePhotoWithOriginalFileName.id, livePhotoVideoId: fileStub.livePhotoMotion.uuid, }); }); it('should extract the motion photo video from the XMP directory entry ', async () => { - assetMock.getByIds.mockResolvedValue([{ ...assetStub.livePhotoWithOriginalFileName, livePhotoVideoId: null }]); + mocks.asset.getByIds.mockResolvedValue([{ ...assetStub.livePhotoWithOriginalFileName, livePhotoVideoId: null }]); mockReadTags({ Directory: 'foo/bar/', MotionPhoto: 1, MicroVideo: 1, MicroVideoOffset: 1, }); - cryptoMock.hashSha1.mockReturnValue(randomBytes(512)); - assetMock.create.mockResolvedValue(assetStub.livePhotoMotionAsset); - cryptoMock.randomUUID.mockReturnValue(fileStub.livePhotoMotion.uuid); + mocks.crypto.hashSha1.mockReturnValue(randomBytes(512)); + mocks.asset.create.mockResolvedValue(assetStub.livePhotoMotionAsset); + mocks.crypto.randomUUID.mockReturnValue(fileStub.livePhotoMotion.uuid); const video = randomBytes(512); - storageMock.readFile.mockResolvedValue(video); + mocks.storage.readFile.mockResolvedValue(video); await sut.handleMetadataExtraction({ id: assetStub.livePhotoWithOriginalFileName.id }); - expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.livePhotoWithOriginalFileName.id], { + expect(mocks.asset.getByIds).toHaveBeenCalledWith([assetStub.livePhotoWithOriginalFileName.id], { faces: { person: false }, }); - expect(storageMock.readFile).toHaveBeenCalledWith( + expect(mocks.storage.readFile).toHaveBeenCalledWith( assetStub.livePhotoWithOriginalFileName.originalPath, expect.any(Object), ); - expect(assetMock.create).toHaveBeenCalledWith({ + expect(mocks.asset.create).toHaveBeenCalledWith({ checksum: expect.any(Buffer), deviceAssetId: 'NONE', deviceId: 'NONE', @@ -699,88 +676,88 @@ describe(MetadataService.name, () => { ownerId: assetStub.livePhotoWithOriginalFileName.ownerId, type: AssetType.VIDEO, }); - expect(userMock.updateUsage).toHaveBeenCalledWith(assetStub.livePhotoMotionAsset.ownerId, 512); - expect(storageMock.createFile).toHaveBeenCalledWith(assetStub.livePhotoMotionAsset.originalPath, video); - expect(assetMock.update).toHaveBeenNthCalledWith(1, { + expect(mocks.user.updateUsage).toHaveBeenCalledWith(assetStub.livePhotoMotionAsset.ownerId, 512); + expect(mocks.storage.createFile).toHaveBeenCalledWith(assetStub.livePhotoMotionAsset.originalPath, video); + expect(mocks.asset.update).toHaveBeenNthCalledWith(1, { id: assetStub.livePhotoWithOriginalFileName.id, livePhotoVideoId: fileStub.livePhotoMotion.uuid, }); }); it('should delete old motion photo video assets if they do not match what is extracted', async () => { - assetMock.getByIds.mockResolvedValue([assetStub.livePhotoWithOriginalFileName]); + mocks.asset.getByIds.mockResolvedValue([assetStub.livePhotoWithOriginalFileName]); mockReadTags({ Directory: 'foo/bar/', MotionPhoto: 1, MicroVideo: 1, MicroVideoOffset: 1, }); - cryptoMock.hashSha1.mockReturnValue(randomBytes(512)); - assetMock.create.mockImplementation( + mocks.crypto.hashSha1.mockReturnValue(randomBytes(512)); + mocks.asset.create.mockImplementation( (asset) => Promise.resolve({ ...assetStub.livePhotoMotionAsset, ...asset }) as Promise, ); const video = randomBytes(512); - storageMock.readFile.mockResolvedValue(video); + mocks.storage.readFile.mockResolvedValue(video); await sut.handleMetadataExtraction({ id: assetStub.livePhotoWithOriginalFileName.id }); - expect(jobMock.queue).toHaveBeenNthCalledWith(1, { + expect(mocks.job.queue).toHaveBeenNthCalledWith(1, { name: JobName.ASSET_DELETION, data: { id: assetStub.livePhotoWithOriginalFileName.livePhotoVideoId, deleteOnDisk: true }, }); - expect(jobMock.queue).toHaveBeenNthCalledWith(2, { + expect(mocks.job.queue).toHaveBeenNthCalledWith(2, { name: JobName.METADATA_EXTRACTION, data: { id: 'random-uuid' }, }); }); it('should not create a new motion photo video asset if the hash of the extracted video matches an existing asset', async () => { - assetMock.getByIds.mockResolvedValue([assetStub.livePhotoStillAsset]); + mocks.asset.getByIds.mockResolvedValue([assetStub.livePhotoStillAsset]); mockReadTags({ Directory: 'foo/bar/', MotionPhoto: 1, MicroVideo: 1, MicroVideoOffset: 1, }); - cryptoMock.hashSha1.mockReturnValue(randomBytes(512)); - assetMock.getByChecksum.mockResolvedValue(assetStub.livePhotoMotionAsset); + mocks.crypto.hashSha1.mockReturnValue(randomBytes(512)); + mocks.asset.getByChecksum.mockResolvedValue(assetStub.livePhotoMotionAsset); const video = randomBytes(512); - storageMock.readFile.mockResolvedValue(video); - storageMock.checkFileExists.mockResolvedValue(true); + mocks.storage.readFile.mockResolvedValue(video); + mocks.storage.checkFileExists.mockResolvedValue(true); await sut.handleMetadataExtraction({ id: assetStub.livePhotoStillAsset.id }); - expect(assetMock.create).toHaveBeenCalledTimes(0); - expect(storageMock.createOrOverwriteFile).toHaveBeenCalledTimes(0); + expect(mocks.asset.create).toHaveBeenCalledTimes(0); + expect(mocks.storage.createOrOverwriteFile).toHaveBeenCalledTimes(0); // The still asset gets saved by handleMetadataExtraction, but not the video - expect(assetMock.update).toHaveBeenCalledTimes(1); - expect(jobMock.queue).toHaveBeenCalledTimes(0); + expect(mocks.asset.update).toHaveBeenCalledTimes(1); + expect(mocks.job.queue).toHaveBeenCalledTimes(0); }); it('should link and hide motion video asset to still asset if the hash of the extracted video matches an existing asset', async () => { - assetMock.getByIds.mockResolvedValue([{ ...assetStub.livePhotoStillAsset, livePhotoVideoId: null }]); + mocks.asset.getByIds.mockResolvedValue([{ ...assetStub.livePhotoStillAsset, livePhotoVideoId: null }]); mockReadTags({ Directory: 'foo/bar/', MotionPhoto: 1, MicroVideo: 1, MicroVideoOffset: 1, }); - cryptoMock.hashSha1.mockReturnValue(randomBytes(512)); - assetMock.getByChecksum.mockResolvedValue({ ...assetStub.livePhotoMotionAsset, isVisible: true }); + mocks.crypto.hashSha1.mockReturnValue(randomBytes(512)); + mocks.asset.getByChecksum.mockResolvedValue({ ...assetStub.livePhotoMotionAsset, isVisible: true }); const video = randomBytes(512); - storageMock.readFile.mockResolvedValue(video); + mocks.storage.readFile.mockResolvedValue(video); await sut.handleMetadataExtraction({ id: assetStub.livePhotoStillAsset.id }); - expect(assetMock.update).toHaveBeenNthCalledWith(1, { + expect(mocks.asset.update).toHaveBeenNthCalledWith(1, { id: assetStub.livePhotoMotionAsset.id, isVisible: false, }); - expect(assetMock.update).toHaveBeenNthCalledWith(2, { + expect(mocks.asset.update).toHaveBeenNthCalledWith(2, { id: assetStub.livePhotoStillAsset.id, livePhotoVideoId: assetStub.livePhotoMotionAsset.id, }); }); it('should not update storage usage if motion photo is external', async () => { - assetMock.getByIds.mockResolvedValue([ + mocks.asset.getByIds.mockResolvedValue([ { ...assetStub.livePhotoStillAsset, livePhotoVideoId: null, isExternal: true }, ]); mockReadTags({ @@ -789,13 +766,13 @@ describe(MetadataService.name, () => { MicroVideo: 1, MicroVideoOffset: 1, }); - cryptoMock.hashSha1.mockReturnValue(randomBytes(512)); - assetMock.create.mockResolvedValue(assetStub.livePhotoMotionAsset); + mocks.crypto.hashSha1.mockReturnValue(randomBytes(512)); + mocks.asset.create.mockResolvedValue(assetStub.livePhotoMotionAsset); const video = randomBytes(512); - storageMock.readFile.mockResolvedValue(video); + mocks.storage.readFile.mockResolvedValue(video); await sut.handleMetadataExtraction({ id: assetStub.livePhotoStillAsset.id }); - expect(userMock.updateUsage).not.toHaveBeenCalled(); + expect(mocks.user.updateUsage).not.toHaveBeenCalled(); }); it('should save all metadata', async () => { @@ -824,12 +801,12 @@ describe(MetadataService.name, () => { tz: 'UTC-11:30', Rating: 3, }; - assetMock.getByIds.mockResolvedValue([assetStub.image]); + mocks.asset.getByIds.mockResolvedValue([assetStub.image]); mockReadTags(tags); await sut.handleMetadataExtraction({ id: assetStub.image.id }); - expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.image.id], { faces: { person: false } }); - expect(assetMock.upsertExif).toHaveBeenCalledWith({ + expect(mocks.asset.getByIds).toHaveBeenCalledWith([assetStub.image.id], { faces: { person: false } }); + expect(mocks.asset.upsertExif).toHaveBeenCalledWith({ assetId: assetStub.image.id, bitsPerSample: expect.any(Number), autoStackId: null, @@ -860,7 +837,7 @@ describe(MetadataService.name, () => { state: null, city: null, }); - expect(assetMock.update).toHaveBeenCalledWith({ + expect(mocks.asset.update).toHaveBeenCalledWith({ id: assetStub.image.id, duration: null, fileCreatedAt: dateForTest, @@ -882,12 +859,12 @@ describe(MetadataService.name, () => { DateTimeOriginal: ExifDateTime.fromISO(someDate + '+00:00'), tz: undefined, }; - assetMock.getByIds.mockResolvedValue([assetStub.image]); + mocks.asset.getByIds.mockResolvedValue([assetStub.image]); mockReadTags(tags); await sut.handleMetadataExtraction({ id: assetStub.image.id }); - expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.image.id], { faces: { person: false } }); - expect(assetMock.upsertExif).toHaveBeenCalledWith( + expect(mocks.asset.getByIds).toHaveBeenCalledWith([assetStub.image.id], { faces: { person: false } }); + expect(mocks.asset.upsertExif).toHaveBeenCalledWith( expect.objectContaining({ timeZone: 'UTC+0', }), @@ -895,8 +872,8 @@ describe(MetadataService.name, () => { }); it('should extract duration', async () => { - assetMock.getByIds.mockResolvedValue([{ ...assetStub.video }]); - mediaMock.probe.mockResolvedValue({ + mocks.asset.getByIds.mockResolvedValue([{ ...assetStub.video }]); + mocks.media.probe.mockResolvedValue({ ...probeStub.videoStreamH264, format: { ...probeStub.videoStreamH264.format, @@ -906,9 +883,9 @@ describe(MetadataService.name, () => { await sut.handleMetadataExtraction({ id: assetStub.video.id }); - expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.video.id], { faces: { person: false } }); - expect(assetMock.upsertExif).toHaveBeenCalled(); - expect(assetMock.update).toHaveBeenCalledWith( + expect(mocks.asset.getByIds).toHaveBeenCalledWith([assetStub.video.id], { faces: { person: false } }); + expect(mocks.asset.upsertExif).toHaveBeenCalled(); + expect(mocks.asset.update).toHaveBeenCalledWith( expect.objectContaining({ id: assetStub.image.id, duration: '00:00:06.210', @@ -917,8 +894,8 @@ describe(MetadataService.name, () => { }); it('should only extract duration for videos', async () => { - assetMock.getByIds.mockResolvedValue([{ ...assetStub.image }]); - mediaMock.probe.mockResolvedValue({ + mocks.asset.getByIds.mockResolvedValue([{ ...assetStub.image }]); + mocks.media.probe.mockResolvedValue({ ...probeStub.videoStreamH264, format: { ...probeStub.videoStreamH264.format, @@ -927,9 +904,9 @@ describe(MetadataService.name, () => { }); await sut.handleMetadataExtraction({ id: assetStub.image.id }); - expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.image.id], { faces: { person: false } }); - expect(assetMock.upsertExif).toHaveBeenCalled(); - expect(assetMock.update).toHaveBeenCalledWith( + expect(mocks.asset.getByIds).toHaveBeenCalledWith([assetStub.image.id], { faces: { person: false } }); + expect(mocks.asset.upsertExif).toHaveBeenCalled(); + expect(mocks.asset.update).toHaveBeenCalledWith( expect.objectContaining({ id: assetStub.image.id, duration: null, @@ -938,8 +915,8 @@ describe(MetadataService.name, () => { }); it('should omit duration of zero', async () => { - assetMock.getByIds.mockResolvedValue([{ ...assetStub.video }]); - mediaMock.probe.mockResolvedValue({ + mocks.asset.getByIds.mockResolvedValue([{ ...assetStub.video }]); + mocks.media.probe.mockResolvedValue({ ...probeStub.videoStreamH264, format: { ...probeStub.videoStreamH264.format, @@ -949,9 +926,9 @@ describe(MetadataService.name, () => { await sut.handleMetadataExtraction({ id: assetStub.video.id }); - expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.video.id], { faces: { person: false } }); - expect(assetMock.upsertExif).toHaveBeenCalled(); - expect(assetMock.update).toHaveBeenCalledWith( + expect(mocks.asset.getByIds).toHaveBeenCalledWith([assetStub.video.id], { faces: { person: false } }); + expect(mocks.asset.upsertExif).toHaveBeenCalled(); + expect(mocks.asset.update).toHaveBeenCalledWith( expect.objectContaining({ id: assetStub.image.id, duration: null, @@ -960,8 +937,8 @@ describe(MetadataService.name, () => { }); it('should a handle duration of 1 week', async () => { - assetMock.getByIds.mockResolvedValue([{ ...assetStub.video }]); - mediaMock.probe.mockResolvedValue({ + mocks.asset.getByIds.mockResolvedValue([{ ...assetStub.video }]); + mocks.media.probe.mockResolvedValue({ ...probeStub.videoStreamH264, format: { ...probeStub.videoStreamH264.format, @@ -971,9 +948,9 @@ describe(MetadataService.name, () => { await sut.handleMetadataExtraction({ id: assetStub.video.id }); - expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.video.id], { faces: { person: false } }); - expect(assetMock.upsertExif).toHaveBeenCalled(); - expect(assetMock.update).toHaveBeenCalledWith( + expect(mocks.asset.getByIds).toHaveBeenCalledWith([assetStub.video.id], { faces: { person: false } }); + expect(mocks.asset.upsertExif).toHaveBeenCalled(); + expect(mocks.asset.update).toHaveBeenCalledWith( expect.objectContaining({ id: assetStub.video.id, duration: '168:00:00.000', @@ -982,19 +959,19 @@ describe(MetadataService.name, () => { }); it('should ignore duration from exif data', async () => { - assetMock.getByIds.mockResolvedValue([assetStub.image]); + mocks.asset.getByIds.mockResolvedValue([assetStub.image]); mockReadTags({}, { Duration: { Value: 123 } }); await sut.handleMetadataExtraction({ id: assetStub.image.id }); - expect(assetMock.update).toHaveBeenCalledWith(expect.objectContaining({ duration: null })); + expect(mocks.asset.update).toHaveBeenCalledWith(expect.objectContaining({ duration: null })); }); it('should trim whitespace from description', async () => { - assetMock.getByIds.mockResolvedValue([assetStub.image]); + mocks.asset.getByIds.mockResolvedValue([assetStub.image]); mockReadTags({ Description: '\t \v \f \n \r' }); await sut.handleMetadataExtraction({ id: assetStub.image.id }); - expect(assetMock.upsertExif).toHaveBeenCalledWith( + expect(mocks.asset.upsertExif).toHaveBeenCalledWith( expect.objectContaining({ description: '', }), @@ -1002,7 +979,7 @@ describe(MetadataService.name, () => { mockReadTags({ ImageDescription: ' my\n description' }); await sut.handleMetadataExtraction({ id: assetStub.image.id }); - expect(assetMock.upsertExif).toHaveBeenCalledWith( + expect(mocks.asset.upsertExif).toHaveBeenCalledWith( expect.objectContaining({ description: 'my\n description', }), @@ -1010,11 +987,11 @@ describe(MetadataService.name, () => { }); it('should handle a numeric description', async () => { - assetMock.getByIds.mockResolvedValue([assetStub.image]); + mocks.asset.getByIds.mockResolvedValue([assetStub.image]); mockReadTags({ Description: 1000 }); await sut.handleMetadataExtraction({ id: assetStub.image.id }); - expect(assetMock.upsertExif).toHaveBeenCalledWith( + expect(mocks.asset.upsertExif).toHaveBeenCalledWith( expect.objectContaining({ description: '1000', }), @@ -1022,57 +999,59 @@ describe(MetadataService.name, () => { }); it('should skip importing metadata when the feature is disabled', async () => { - assetMock.getByIds.mockResolvedValue([assetStub.primaryImage]); - systemMock.get.mockResolvedValue({ metadata: { faces: { import: false } } }); + mocks.asset.getByIds.mockResolvedValue([assetStub.primaryImage]); + mocks.systemMetadata.get.mockResolvedValue({ metadata: { faces: { import: false } } }); mockReadTags(metadataStub.withFace); await sut.handleMetadataExtraction({ id: assetStub.image.id }); - expect(personMock.getDistinctNames).not.toHaveBeenCalled(); + expect(mocks.person.getDistinctNames).not.toHaveBeenCalled(); }); it('should skip importing metadata face for assets without tags.RegionInfo', async () => { - assetMock.getByIds.mockResolvedValue([assetStub.primaryImage]); - systemMock.get.mockResolvedValue({ metadata: { faces: { import: true } } }); + mocks.asset.getByIds.mockResolvedValue([assetStub.primaryImage]); + mocks.systemMetadata.get.mockResolvedValue({ metadata: { faces: { import: true } } }); mockReadTags(metadataStub.empty); await sut.handleMetadataExtraction({ id: assetStub.image.id }); - expect(personMock.getDistinctNames).not.toHaveBeenCalled(); + expect(mocks.person.getDistinctNames).not.toHaveBeenCalled(); }); it('should skip importing faces without name', async () => { - assetMock.getByIds.mockResolvedValue([assetStub.primaryImage]); - systemMock.get.mockResolvedValue({ metadata: { faces: { import: true } } }); + mocks.asset.getByIds.mockResolvedValue([assetStub.primaryImage]); + mocks.systemMetadata.get.mockResolvedValue({ metadata: { faces: { import: true } } }); mockReadTags(metadataStub.withFaceNoName); - personMock.getDistinctNames.mockResolvedValue([]); - personMock.createAll.mockResolvedValue([]); + mocks.person.getDistinctNames.mockResolvedValue([]); + mocks.person.createAll.mockResolvedValue([]); await sut.handleMetadataExtraction({ id: assetStub.image.id }); - expect(personMock.createAll).not.toHaveBeenCalled(); - expect(personMock.refreshFaces).not.toHaveBeenCalled(); - expect(personMock.updateAll).not.toHaveBeenCalled(); + expect(mocks.person.createAll).not.toHaveBeenCalled(); + expect(mocks.person.refreshFaces).not.toHaveBeenCalled(); + expect(mocks.person.updateAll).not.toHaveBeenCalled(); }); it('should skip importing faces with empty name', async () => { - assetMock.getByIds.mockResolvedValue([assetStub.primaryImage]); - systemMock.get.mockResolvedValue({ metadata: { faces: { import: true } } }); + mocks.asset.getByIds.mockResolvedValue([assetStub.primaryImage]); + mocks.systemMetadata.get.mockResolvedValue({ metadata: { faces: { import: true } } }); mockReadTags(metadataStub.withFaceEmptyName); - personMock.getDistinctNames.mockResolvedValue([]); - personMock.createAll.mockResolvedValue([]); + mocks.person.getDistinctNames.mockResolvedValue([]); + mocks.person.createAll.mockResolvedValue([]); await sut.handleMetadataExtraction({ id: assetStub.image.id }); - expect(personMock.createAll).not.toHaveBeenCalled(); - expect(personMock.refreshFaces).not.toHaveBeenCalled(); - expect(personMock.updateAll).not.toHaveBeenCalled(); + expect(mocks.person.createAll).not.toHaveBeenCalled(); + expect(mocks.person.refreshFaces).not.toHaveBeenCalled(); + expect(mocks.person.updateAll).not.toHaveBeenCalled(); }); it('should apply metadata face tags creating new persons', async () => { - assetMock.getByIds.mockResolvedValue([assetStub.primaryImage]); - systemMock.get.mockResolvedValue({ metadata: { faces: { import: true } } }); + mocks.asset.getByIds.mockResolvedValue([assetStub.primaryImage]); + mocks.systemMetadata.get.mockResolvedValue({ metadata: { faces: { import: true } } }); mockReadTags(metadataStub.withFace); - personMock.getDistinctNames.mockResolvedValue([]); - personMock.createAll.mockResolvedValue([personStub.withName.id]); - personMock.update.mockResolvedValue(personStub.withName); + mocks.person.getDistinctNames.mockResolvedValue([]); + mocks.person.createAll.mockResolvedValue([personStub.withName.id]); + mocks.person.update.mockResolvedValue(personStub.withName); await sut.handleMetadataExtraction({ id: assetStub.primaryImage.id }); - expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.primaryImage.id], { faces: { person: false } }); - expect(personMock.getDistinctNames).toHaveBeenCalledWith(assetStub.primaryImage.ownerId, { withHidden: true }); - expect(personMock.createAll).toHaveBeenCalledWith([expect.objectContaining({ name: personStub.withName.name })]); - expect(personMock.refreshFaces).toHaveBeenCalledWith( + expect(mocks.asset.getByIds).toHaveBeenCalledWith([assetStub.primaryImage.id], { faces: { person: false } }); + expect(mocks.person.getDistinctNames).toHaveBeenCalledWith(assetStub.primaryImage.ownerId, { withHidden: true }); + expect(mocks.person.createAll).toHaveBeenCalledWith([ + expect.objectContaining({ name: personStub.withName.name }), + ]); + expect(mocks.person.refreshFaces).toHaveBeenCalledWith( [ { id: 'random-uuid', @@ -1089,10 +1068,10 @@ describe(MetadataService.name, () => { ], [], ); - expect(personMock.updateAll).toHaveBeenCalledWith([ + expect(mocks.person.updateAll).toHaveBeenCalledWith([ { id: 'random-uuid', ownerId: 'admin-id', faceAssetId: 'random-uuid' }, ]); - expect(jobMock.queueAll).toHaveBeenCalledWith([ + expect(mocks.job.queueAll).toHaveBeenCalledWith([ { name: JobName.GENERATE_PERSON_THUMBNAIL, data: { id: personStub.withName.id }, @@ -1101,17 +1080,17 @@ describe(MetadataService.name, () => { }); it('should assign metadata face tags to existing persons', async () => { - assetMock.getByIds.mockResolvedValue([assetStub.primaryImage]); - systemMock.get.mockResolvedValue({ metadata: { faces: { import: true } } }); + mocks.asset.getByIds.mockResolvedValue([assetStub.primaryImage]); + mocks.systemMetadata.get.mockResolvedValue({ metadata: { faces: { import: true } } }); mockReadTags(metadataStub.withFace); - personMock.getDistinctNames.mockResolvedValue([{ id: personStub.withName.id, name: personStub.withName.name }]); - personMock.createAll.mockResolvedValue([]); - personMock.update.mockResolvedValue(personStub.withName); + mocks.person.getDistinctNames.mockResolvedValue([{ id: personStub.withName.id, name: personStub.withName.name }]); + mocks.person.createAll.mockResolvedValue([]); + mocks.person.update.mockResolvedValue(personStub.withName); await sut.handleMetadataExtraction({ id: assetStub.primaryImage.id }); - expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.primaryImage.id], { faces: { person: false } }); - expect(personMock.getDistinctNames).toHaveBeenCalledWith(assetStub.primaryImage.ownerId, { withHidden: true }); - expect(personMock.createAll).not.toHaveBeenCalled(); - expect(personMock.refreshFaces).toHaveBeenCalledWith( + expect(mocks.asset.getByIds).toHaveBeenCalledWith([assetStub.primaryImage.id], { faces: { person: false } }); + expect(mocks.person.getDistinctNames).toHaveBeenCalledWith(assetStub.primaryImage.ownerId, { withHidden: true }); + expect(mocks.person.createAll).not.toHaveBeenCalled(); + expect(mocks.person.refreshFaces).toHaveBeenCalledWith( [ { id: 'random-uuid', @@ -1128,16 +1107,16 @@ describe(MetadataService.name, () => { ], [], ); - expect(personMock.updateAll).not.toHaveBeenCalled(); - expect(jobMock.queueAll).not.toHaveBeenCalledWith(); + expect(mocks.person.updateAll).not.toHaveBeenCalled(); + expect(mocks.job.queueAll).not.toHaveBeenCalledWith(); }); it('should handle invalid modify date', async () => { - assetMock.getByIds.mockResolvedValue([assetStub.image]); + mocks.asset.getByIds.mockResolvedValue([assetStub.image]); mockReadTags({ ModifyDate: '00:00:00.000' }); await sut.handleMetadataExtraction({ id: assetStub.image.id }); - expect(assetMock.upsertExif).toHaveBeenCalledWith( + expect(mocks.asset.upsertExif).toHaveBeenCalledWith( expect.objectContaining({ modifyDate: expect.any(Date), }), @@ -1145,11 +1124,11 @@ describe(MetadataService.name, () => { }); it('should handle invalid rating value', async () => { - assetMock.getByIds.mockResolvedValue([assetStub.image]); + mocks.asset.getByIds.mockResolvedValue([assetStub.image]); mockReadTags({ Rating: 6 }); await sut.handleMetadataExtraction({ id: assetStub.image.id }); - expect(assetMock.upsertExif).toHaveBeenCalledWith( + expect(mocks.asset.upsertExif).toHaveBeenCalledWith( expect.objectContaining({ rating: null, }), @@ -1157,22 +1136,22 @@ describe(MetadataService.name, () => { }); it('should handle valid rating value', async () => { - assetMock.getByIds.mockResolvedValue([assetStub.image]); + mocks.asset.getByIds.mockResolvedValue([assetStub.image]); mockReadTags({ Rating: 5 }); await sut.handleMetadataExtraction({ id: assetStub.image.id }); - expect(assetMock.upsertExif).toHaveBeenCalledWith( + expect(mocks.asset.upsertExif).toHaveBeenCalledWith( expect.objectContaining({ rating: 5, }), ); }); it('should handle valid negative rating value', async () => { - assetMock.getByIds.mockResolvedValue([assetStub.image]); + mocks.asset.getByIds.mockResolvedValue([assetStub.image]); mockReadTags({ Rating: -1 }); await sut.handleMetadataExtraction({ id: assetStub.image.id }); - expect(assetMock.upsertExif).toHaveBeenCalledWith( + expect(mocks.asset.upsertExif).toHaveBeenCalledWith( expect.objectContaining({ rating: -1, }), @@ -1182,13 +1161,13 @@ describe(MetadataService.name, () => { describe('handleQueueSidecar', () => { it('should queue assets with sidecar files', async () => { - assetMock.getAll.mockResolvedValue({ items: [assetStub.sidecar], hasNextPage: false }); + mocks.asset.getAll.mockResolvedValue({ items: [assetStub.sidecar], hasNextPage: false }); await sut.handleQueueSidecar({ force: true }); - expect(assetMock.getAll).toHaveBeenCalledWith({ take: 1000, skip: 0 }); - expect(assetMock.getWithout).not.toHaveBeenCalled(); - expect(jobMock.queueAll).toHaveBeenCalledWith([ + expect(mocks.asset.getAll).toHaveBeenCalledWith({ take: 1000, skip: 0 }); + expect(mocks.asset.getWithout).not.toHaveBeenCalled(); + expect(mocks.job.queueAll).toHaveBeenCalledWith([ { name: JobName.SIDECAR_SYNC, data: { id: assetStub.sidecar.id }, @@ -1197,13 +1176,13 @@ describe(MetadataService.name, () => { }); it('should queue assets without sidecar files', async () => { - assetMock.getWithout.mockResolvedValue({ items: [assetStub.image], hasNextPage: false }); + mocks.asset.getWithout.mockResolvedValue({ items: [assetStub.image], hasNextPage: false }); await sut.handleQueueSidecar({ force: false }); - expect(assetMock.getWithout).toHaveBeenCalledWith({ take: 1000, skip: 0 }, WithoutProperty.SIDECAR); - expect(assetMock.getAll).not.toHaveBeenCalled(); - expect(jobMock.queueAll).toHaveBeenCalledWith([ + expect(mocks.asset.getWithout).toHaveBeenCalledWith({ take: 1000, skip: 0 }, WithoutProperty.SIDECAR); + expect(mocks.asset.getAll).not.toHaveBeenCalled(); + expect(mocks.job.queueAll).toHaveBeenCalledWith([ { name: JobName.SIDECAR_DISCOVERY, data: { id: assetStub.image.id }, @@ -1214,71 +1193,77 @@ describe(MetadataService.name, () => { describe('handleSidecarSync', () => { it('should do nothing if asset could not be found', async () => { - assetMock.getByIds.mockResolvedValue([]); + mocks.asset.getByIds.mockResolvedValue([]); await expect(sut.handleSidecarSync({ id: assetStub.image.id })).resolves.toBe(JobStatus.FAILED); - expect(assetMock.update).not.toHaveBeenCalled(); + expect(mocks.asset.update).not.toHaveBeenCalled(); }); it('should do nothing if asset has no sidecar path', async () => { - assetMock.getByIds.mockResolvedValue([assetStub.image]); + mocks.asset.getByIds.mockResolvedValue([assetStub.image]); await expect(sut.handleSidecarSync({ id: assetStub.image.id })).resolves.toBe(JobStatus.FAILED); - expect(assetMock.update).not.toHaveBeenCalled(); + expect(mocks.asset.update).not.toHaveBeenCalled(); }); it('should set sidecar path if exists (sidecar named photo.ext.xmp)', async () => { - assetMock.getByIds.mockResolvedValue([assetStub.sidecar]); - storageMock.checkFileExists.mockResolvedValue(true); + mocks.asset.getByIds.mockResolvedValue([assetStub.sidecar]); + mocks.storage.checkFileExists.mockResolvedValue(true); await expect(sut.handleSidecarSync({ id: assetStub.sidecar.id })).resolves.toBe(JobStatus.SUCCESS); - expect(storageMock.checkFileExists).toHaveBeenCalledWith(`${assetStub.sidecar.originalPath}.xmp`, constants.R_OK); - expect(assetMock.update).toHaveBeenCalledWith({ + expect(mocks.storage.checkFileExists).toHaveBeenCalledWith( + `${assetStub.sidecar.originalPath}.xmp`, + constants.R_OK, + ); + expect(mocks.asset.update).toHaveBeenCalledWith({ id: assetStub.sidecar.id, sidecarPath: assetStub.sidecar.sidecarPath, }); }); it('should set sidecar path if exists (sidecar named photo.xmp)', async () => { - assetMock.getByIds.mockResolvedValue([assetStub.sidecarWithoutExt]); - storageMock.checkFileExists.mockResolvedValueOnce(false); - storageMock.checkFileExists.mockResolvedValueOnce(true); + mocks.asset.getByIds.mockResolvedValue([assetStub.sidecarWithoutExt]); + mocks.storage.checkFileExists.mockResolvedValueOnce(false); + mocks.storage.checkFileExists.mockResolvedValueOnce(true); await expect(sut.handleSidecarSync({ id: assetStub.sidecarWithoutExt.id })).resolves.toBe(JobStatus.SUCCESS); - expect(storageMock.checkFileExists).toHaveBeenNthCalledWith( + expect(mocks.storage.checkFileExists).toHaveBeenNthCalledWith( 2, assetStub.sidecarWithoutExt.sidecarPath, constants.R_OK, ); - expect(assetMock.update).toHaveBeenCalledWith({ + expect(mocks.asset.update).toHaveBeenCalledWith({ id: assetStub.sidecarWithoutExt.id, sidecarPath: assetStub.sidecarWithoutExt.sidecarPath, }); }); it('should set sidecar path if exists (two sidecars named photo.ext.xmp and photo.xmp, should pick photo.ext.xmp)', async () => { - assetMock.getByIds.mockResolvedValue([assetStub.sidecar]); - storageMock.checkFileExists.mockResolvedValueOnce(true); - storageMock.checkFileExists.mockResolvedValueOnce(true); + mocks.asset.getByIds.mockResolvedValue([assetStub.sidecar]); + mocks.storage.checkFileExists.mockResolvedValueOnce(true); + mocks.storage.checkFileExists.mockResolvedValueOnce(true); await expect(sut.handleSidecarSync({ id: assetStub.sidecar.id })).resolves.toBe(JobStatus.SUCCESS); - expect(storageMock.checkFileExists).toHaveBeenNthCalledWith(1, assetStub.sidecar.sidecarPath, constants.R_OK); - expect(storageMock.checkFileExists).toHaveBeenNthCalledWith( + expect(mocks.storage.checkFileExists).toHaveBeenNthCalledWith(1, assetStub.sidecar.sidecarPath, constants.R_OK); + expect(mocks.storage.checkFileExists).toHaveBeenNthCalledWith( 2, assetStub.sidecarWithoutExt.sidecarPath, constants.R_OK, ); - expect(assetMock.update).toHaveBeenCalledWith({ + expect(mocks.asset.update).toHaveBeenCalledWith({ id: assetStub.sidecar.id, sidecarPath: assetStub.sidecar.sidecarPath, }); }); it('should unset sidecar path if file does not exist anymore', async () => { - assetMock.getByIds.mockResolvedValue([assetStub.sidecar]); - storageMock.checkFileExists.mockResolvedValue(false); + mocks.asset.getByIds.mockResolvedValue([assetStub.sidecar]); + mocks.storage.checkFileExists.mockResolvedValue(false); await expect(sut.handleSidecarSync({ id: assetStub.sidecar.id })).resolves.toBe(JobStatus.SUCCESS); - expect(storageMock.checkFileExists).toHaveBeenCalledWith(`${assetStub.sidecar.originalPath}.xmp`, constants.R_OK); - expect(assetMock.update).toHaveBeenCalledWith({ + expect(mocks.storage.checkFileExists).toHaveBeenCalledWith( + `${assetStub.sidecar.originalPath}.xmp`, + constants.R_OK, + ); + expect(mocks.asset.update).toHaveBeenCalledWith({ id: assetStub.sidecar.id, sidecarPath: null, }); @@ -1287,41 +1272,41 @@ describe(MetadataService.name, () => { describe('handleSidecarDiscovery', () => { it('should skip hidden assets', async () => { - assetMock.getByIds.mockResolvedValue([assetStub.livePhotoMotionAsset]); + mocks.asset.getByIds.mockResolvedValue([assetStub.livePhotoMotionAsset]); await sut.handleSidecarDiscovery({ id: assetStub.livePhotoMotionAsset.id }); - expect(storageMock.checkFileExists).not.toHaveBeenCalled(); + expect(mocks.storage.checkFileExists).not.toHaveBeenCalled(); }); it('should skip assets with a sidecar path', async () => { - assetMock.getByIds.mockResolvedValue([assetStub.sidecar]); + mocks.asset.getByIds.mockResolvedValue([assetStub.sidecar]); await sut.handleSidecarDiscovery({ id: assetStub.sidecar.id }); - expect(storageMock.checkFileExists).not.toHaveBeenCalled(); + expect(mocks.storage.checkFileExists).not.toHaveBeenCalled(); }); it('should do nothing when a sidecar is not found ', async () => { - assetMock.getByIds.mockResolvedValue([assetStub.image]); - storageMock.checkFileExists.mockResolvedValue(false); + mocks.asset.getByIds.mockResolvedValue([assetStub.image]); + mocks.storage.checkFileExists.mockResolvedValue(false); await sut.handleSidecarDiscovery({ id: assetStub.image.id }); - expect(assetMock.update).not.toHaveBeenCalled(); + expect(mocks.asset.update).not.toHaveBeenCalled(); }); it('should update a image asset when a sidecar is found', async () => { - assetMock.getByIds.mockResolvedValue([assetStub.image]); - storageMock.checkFileExists.mockResolvedValue(true); + mocks.asset.getByIds.mockResolvedValue([assetStub.image]); + mocks.storage.checkFileExists.mockResolvedValue(true); await sut.handleSidecarDiscovery({ id: assetStub.image.id }); - expect(storageMock.checkFileExists).toHaveBeenCalledWith('/original/path.jpg.xmp', constants.R_OK); - expect(assetMock.update).toHaveBeenCalledWith({ + expect(mocks.storage.checkFileExists).toHaveBeenCalledWith('/original/path.jpg.xmp', constants.R_OK); + expect(mocks.asset.update).toHaveBeenCalledWith({ id: assetStub.image.id, sidecarPath: '/original/path.jpg.xmp', }); }); it('should update a video asset when a sidecar is found', async () => { - assetMock.getByIds.mockResolvedValue([assetStub.video]); - storageMock.checkFileExists.mockResolvedValue(true); + mocks.asset.getByIds.mockResolvedValue([assetStub.video]); + mocks.storage.checkFileExists.mockResolvedValue(true); await sut.handleSidecarDiscovery({ id: assetStub.video.id }); - expect(storageMock.checkFileExists).toHaveBeenCalledWith('/original/path.ext.xmp', constants.R_OK); - expect(assetMock.update).toHaveBeenCalledWith({ + expect(mocks.storage.checkFileExists).toHaveBeenCalledWith('/original/path.ext.xmp', constants.R_OK); + expect(mocks.asset.update).toHaveBeenCalledWith({ id: assetStub.image.id, sidecarPath: '/original/path.ext.xmp', }); @@ -1330,15 +1315,15 @@ describe(MetadataService.name, () => { describe('handleSidecarWrite', () => { it('should skip assets that do not exist anymore', async () => { - assetMock.getByIds.mockResolvedValue([]); + mocks.asset.getByIds.mockResolvedValue([]); await expect(sut.handleSidecarWrite({ id: 'asset-123' })).resolves.toBe(JobStatus.FAILED); - expect(metadataMock.writeTags).not.toHaveBeenCalled(); + expect(mocks.metadata.writeTags).not.toHaveBeenCalled(); }); it('should skip jobs with not metadata', async () => { - assetMock.getByIds.mockResolvedValue([assetStub.sidecar]); + mocks.asset.getByIds.mockResolvedValue([assetStub.sidecar]); await expect(sut.handleSidecarWrite({ id: assetStub.sidecar.id })).resolves.toBe(JobStatus.SKIPPED); - expect(metadataMock.writeTags).not.toHaveBeenCalled(); + expect(mocks.metadata.writeTags).not.toHaveBeenCalled(); }); it('should write tags', async () => { @@ -1346,7 +1331,7 @@ describe(MetadataService.name, () => { const gps = 12; const date = '2023-11-22T04:56:12.196Z'; - assetMock.getByIds.mockResolvedValue([assetStub.sidecar]); + mocks.asset.getByIds.mockResolvedValue([assetStub.sidecar]); await expect( sut.handleSidecarWrite({ id: assetStub.sidecar.id, @@ -1356,7 +1341,7 @@ describe(MetadataService.name, () => { dateTimeOriginal: date, }), ).resolves.toBe(JobStatus.SUCCESS); - expect(metadataMock.writeTags).toHaveBeenCalledWith(assetStub.sidecar.sidecarPath, { + expect(mocks.metadata.writeTags).toHaveBeenCalledWith(assetStub.sidecar.sidecarPath, { Description: description, ImageDescription: description, DateTimeOriginal: date, diff --git a/server/src/services/notification.service.spec.ts b/server/src/services/notification.service.spec.ts index 41e9e35ff8..35f1601c72 100644 --- a/server/src/services/notification.service.spec.ts +++ b/server/src/services/notification.service.spec.ts @@ -4,19 +4,13 @@ import { SystemConfigDto } from 'src/dtos/system-config.dto'; import { AlbumUserEntity } from 'src/entities/album-user.entity'; import { AssetFileEntity } from 'src/entities/asset-files.entity'; import { AssetFileType, UserMetadataKey } from 'src/enum'; -import { IAlbumRepository } from 'src/interfaces/album.interface'; -import { IAssetRepository } from 'src/interfaces/asset.interface'; -import { IEventRepository } from 'src/interfaces/event.interface'; -import { IJobRepository, INotifyAlbumUpdateJob, JobName, JobStatus } from 'src/interfaces/job.interface'; -import { IUserRepository } from 'src/interfaces/user.interface'; +import { INotifyAlbumUpdateJob, JobName, JobStatus } from 'src/interfaces/job.interface'; import { EmailTemplate } from 'src/repositories/notification.repository'; import { NotificationService } from 'src/services/notification.service'; -import { INotificationRepository, ISystemMetadataRepository } from 'src/types'; import { albumStub } from 'test/fixtures/album.stub'; import { assetStub } from 'test/fixtures/asset.stub'; import { userStub } from 'test/fixtures/user.stub'; -import { newTestService } from 'test/utils'; -import { Mocked } from 'vitest'; +import { newTestService, ServiceMocks } from 'test/utils'; const configs = { smtpDisabled: Object.freeze({ @@ -57,18 +51,10 @@ const configs = { describe(NotificationService.name, () => { let sut: NotificationService; - - let albumMock: Mocked; - let assetMock: Mocked; - let eventMock: Mocked; - let jobMock: Mocked; - let notificationMock: Mocked; - let systemMock: Mocked; - let userMock: Mocked; + let mocks: ServiceMocks; beforeEach(() => { - ({ sut, albumMock, assetMock, eventMock, jobMock, notificationMock, systemMock, userMock } = - newTestService(NotificationService)); + ({ sut, mocks } = newTestService(NotificationService)); }); it('should work', () => { @@ -79,8 +65,8 @@ describe(NotificationService.name, () => { it('should emit client and server events', () => { const update = { oldConfig: defaults, newConfig: defaults }; expect(sut.onConfigUpdate(update)).toBeUndefined(); - expect(eventMock.clientBroadcast).toHaveBeenCalledWith('on_config_update'); - expect(eventMock.serverSend).toHaveBeenCalledWith('config.update', update); + expect(mocks.event.clientBroadcast).toHaveBeenCalledWith('on_config_update'); + expect(mocks.event.serverSend).toHaveBeenCalledWith('config.update', update); }); }); @@ -89,18 +75,18 @@ describe(NotificationService.name, () => { const oldConfig = configs.smtpDisabled; const newConfig = configs.smtpEnabled; - notificationMock.verifySmtp.mockResolvedValue(true); + mocks.notification.verifySmtp.mockResolvedValue(true); await expect(sut.onConfigValidate({ oldConfig, newConfig })).resolves.not.toThrow(); - expect(notificationMock.verifySmtp).toHaveBeenCalledWith(newConfig.notifications.smtp.transport); + expect(mocks.notification.verifySmtp).toHaveBeenCalledWith(newConfig.notifications.smtp.transport); }); it('validates smtp config when transport changes', async () => { const oldConfig = configs.smtpEnabled; const newConfig = configs.smtpTransport; - notificationMock.verifySmtp.mockResolvedValue(true); + mocks.notification.verifySmtp.mockResolvedValue(true); await expect(sut.onConfigValidate({ oldConfig, newConfig })).resolves.not.toThrow(); - expect(notificationMock.verifySmtp).toHaveBeenCalledWith(newConfig.notifications.smtp.transport); + expect(mocks.notification.verifySmtp).toHaveBeenCalledWith(newConfig.notifications.smtp.transport); }); it('skips smtp validation when there are no changes', async () => { @@ -108,7 +94,7 @@ describe(NotificationService.name, () => { const newConfig = { ...configs.smtpEnabled }; await expect(sut.onConfigValidate({ oldConfig, newConfig })).resolves.not.toThrow(); - expect(notificationMock.verifySmtp).not.toHaveBeenCalled(); + expect(mocks.notification.verifySmtp).not.toHaveBeenCalled(); }); it('skips smtp validation with DTO when there are no changes', async () => { @@ -116,7 +102,7 @@ describe(NotificationService.name, () => { const newConfig = plainToInstance(SystemConfigDto, configs.smtpEnabled); await expect(sut.onConfigValidate({ oldConfig, newConfig })).resolves.not.toThrow(); - expect(notificationMock.verifySmtp).not.toHaveBeenCalled(); + expect(mocks.notification.verifySmtp).not.toHaveBeenCalled(); }); it('skips smtp validation when smtp is disabled', async () => { @@ -124,14 +110,14 @@ describe(NotificationService.name, () => { const newConfig = { ...configs.smtpDisabled }; await expect(sut.onConfigValidate({ oldConfig, newConfig })).resolves.not.toThrow(); - expect(notificationMock.verifySmtp).not.toHaveBeenCalled(); + expect(mocks.notification.verifySmtp).not.toHaveBeenCalled(); }); it('should fail if smtp configuration is invalid', async () => { const oldConfig = configs.smtpDisabled; const newConfig = configs.smtpEnabled; - notificationMock.verifySmtp.mockRejectedValue(new Error('Failed validating smtp')); + mocks.notification.verifySmtp.mockRejectedValue(new Error('Failed validating smtp')); await expect(sut.onConfigValidate({ oldConfig, newConfig })).rejects.toBeInstanceOf(Error); }); }); @@ -139,14 +125,14 @@ describe(NotificationService.name, () => { describe('onAssetHide', () => { it('should send connected clients an event', () => { sut.onAssetHide({ assetId: 'asset-id', userId: 'user-id' }); - expect(eventMock.clientSend).toHaveBeenCalledWith('on_asset_hidden', 'user-id', 'asset-id'); + expect(mocks.event.clientSend).toHaveBeenCalledWith('on_asset_hidden', 'user-id', 'asset-id'); }); }); describe('onAssetShow', () => { it('should queue the generate thumbnail job', async () => { await sut.onAssetShow({ assetId: 'asset-id', userId: 'user-id' }); - expect(jobMock.queue).toHaveBeenCalledWith({ + expect(mocks.job.queue).toHaveBeenCalledWith({ name: JobName.GENERATE_THUMBNAILS, data: { id: 'asset-id', notify: true }, }); @@ -156,12 +142,12 @@ describe(NotificationService.name, () => { describe('onUserSignupEvent', () => { it('skips when notify is false', async () => { await sut.onUserSignup({ id: '', notify: false }); - expect(jobMock.queue).not.toHaveBeenCalled(); + expect(mocks.job.queue).not.toHaveBeenCalled(); }); it('should queue notify signup event if notify is true', async () => { await sut.onUserSignup({ id: '', notify: true }); - expect(jobMock.queue).toHaveBeenCalledWith({ + expect(mocks.job.queue).toHaveBeenCalledWith({ name: JobName.NOTIFY_SIGNUP, data: { id: '', tempPassword: undefined }, }); @@ -171,7 +157,7 @@ describe(NotificationService.name, () => { describe('onAlbumUpdateEvent', () => { it('should queue notify album update event', async () => { await sut.onAlbumUpdate({ id: 'album', recipientIds: ['42'] }); - expect(jobMock.queue).toHaveBeenCalledWith({ + expect(mocks.job.queue).toHaveBeenCalledWith({ name: JobName.NOTIFY_ALBUM_UPDATE, data: { id: 'album', recipientIds: ['42'], delay: 300_000 }, }); @@ -181,7 +167,7 @@ describe(NotificationService.name, () => { describe('onAlbumInviteEvent', () => { it('should queue notify album invite event', async () => { await sut.onAlbumInvite({ id: '', userId: '42' }); - expect(jobMock.queue).toHaveBeenCalledWith({ + expect(mocks.job.queue).toHaveBeenCalledWith({ name: JobName.NOTIFY_ALBUM_INVITE, data: { id: '', recipientId: '42' }, }); @@ -192,67 +178,67 @@ describe(NotificationService.name, () => { it('should send a on_session_delete client event', () => { vi.useFakeTimers(); sut.onSessionDelete({ sessionId: 'id' }); - expect(eventMock.clientSend).not.toHaveBeenCalled(); + expect(mocks.event.clientSend).not.toHaveBeenCalled(); vi.advanceTimersByTime(500); - expect(eventMock.clientSend).toHaveBeenCalledWith('on_session_delete', 'id', 'id'); + expect(mocks.event.clientSend).toHaveBeenCalledWith('on_session_delete', 'id', 'id'); }); }); describe('onAssetTrash', () => { it('should send connected clients an event', () => { sut.onAssetTrash({ assetId: 'asset-id', userId: 'user-id' }); - expect(eventMock.clientSend).toHaveBeenCalledWith('on_asset_trash', 'user-id', ['asset-id']); + expect(mocks.event.clientSend).toHaveBeenCalledWith('on_asset_trash', 'user-id', ['asset-id']); }); }); describe('onAssetDelete', () => { it('should send connected clients an event', () => { sut.onAssetDelete({ assetId: 'asset-id', userId: 'user-id' }); - expect(eventMock.clientSend).toHaveBeenCalledWith('on_asset_delete', 'user-id', 'asset-id'); + expect(mocks.event.clientSend).toHaveBeenCalledWith('on_asset_delete', 'user-id', 'asset-id'); }); }); describe('onAssetsTrash', () => { it('should send connected clients an event', () => { sut.onAssetsTrash({ assetIds: ['asset-id'], userId: 'user-id' }); - expect(eventMock.clientSend).toHaveBeenCalledWith('on_asset_trash', 'user-id', ['asset-id']); + expect(mocks.event.clientSend).toHaveBeenCalledWith('on_asset_trash', 'user-id', ['asset-id']); }); }); describe('onAssetsRestore', () => { it('should send connected clients an event', () => { sut.onAssetsRestore({ assetIds: ['asset-id'], userId: 'user-id' }); - expect(eventMock.clientSend).toHaveBeenCalledWith('on_asset_restore', 'user-id', ['asset-id']); + expect(mocks.event.clientSend).toHaveBeenCalledWith('on_asset_restore', 'user-id', ['asset-id']); }); }); describe('onStackCreate', () => { it('should send connected clients an event', () => { sut.onStackCreate({ stackId: 'stack-id', userId: 'user-id' }); - expect(eventMock.clientSend).toHaveBeenCalledWith('on_asset_stack_update', 'user-id'); + expect(mocks.event.clientSend).toHaveBeenCalledWith('on_asset_stack_update', 'user-id'); }); }); describe('onStackUpdate', () => { it('should send connected clients an event', () => { sut.onStackUpdate({ stackId: 'stack-id', userId: 'user-id' }); - expect(eventMock.clientSend).toHaveBeenCalledWith('on_asset_stack_update', 'user-id'); + expect(mocks.event.clientSend).toHaveBeenCalledWith('on_asset_stack_update', 'user-id'); }); }); describe('onStackDelete', () => { it('should send connected clients an event', () => { sut.onStackDelete({ stackId: 'stack-id', userId: 'user-id' }); - expect(eventMock.clientSend).toHaveBeenCalledWith('on_asset_stack_update', 'user-id'); + expect(mocks.event.clientSend).toHaveBeenCalledWith('on_asset_stack_update', 'user-id'); }); }); describe('onStacksDelete', () => { it('should send connected clients an event', () => { sut.onStacksDelete({ stackIds: ['stack-id'], userId: 'user-id' }); - expect(eventMock.clientSend).toHaveBeenCalledWith('on_asset_stack_update', 'user-id'); + expect(mocks.event.clientSend).toHaveBeenCalledWith('on_asset_stack_update', 'user-id'); }); }); @@ -262,8 +248,8 @@ describe(NotificationService.name, () => { }); it('should throw error if smtp validation fails', async () => { - userMock.get.mockResolvedValue(userStub.admin); - notificationMock.verifySmtp.mockRejectedValue(''); + mocks.user.get.mockResolvedValue(userStub.admin); + mocks.notification.verifySmtp.mockRejectedValue(''); await expect(sut.sendTestEmail('', configs.smtpTransport.notifications.smtp)).rejects.toThrow( 'Failed to verify SMTP configuration', @@ -271,16 +257,16 @@ describe(NotificationService.name, () => { }); it('should send email to default domain', async () => { - userMock.get.mockResolvedValue(userStub.admin); - notificationMock.verifySmtp.mockResolvedValue(true); - notificationMock.renderEmail.mockResolvedValue({ html: '', text: '' }); + mocks.user.get.mockResolvedValue(userStub.admin); + mocks.notification.verifySmtp.mockResolvedValue(true); + mocks.notification.renderEmail.mockResolvedValue({ html: '', text: '' }); await expect(sut.sendTestEmail('', configs.smtpTransport.notifications.smtp)).resolves.not.toThrow(); - expect(notificationMock.renderEmail).toHaveBeenCalledWith({ + expect(mocks.notification.renderEmail).toHaveBeenCalledWith({ template: EmailTemplate.TEST_EMAIL, data: { baseUrl: 'http://localhost:2283', displayName: userStub.admin.name }, }); - expect(notificationMock.sendEmail).toHaveBeenCalledWith( + expect(mocks.notification.sendEmail).toHaveBeenCalledWith( expect.objectContaining({ subject: 'Test email from Immich', smtp: configs.smtpTransport.notifications.smtp.transport, @@ -289,17 +275,17 @@ describe(NotificationService.name, () => { }); it('should send email to external domain', async () => { - userMock.get.mockResolvedValue(userStub.admin); - notificationMock.verifySmtp.mockResolvedValue(true); - notificationMock.renderEmail.mockResolvedValue({ html: '', text: '' }); - systemMock.get.mockResolvedValue({ server: { externalDomain: 'https://demo.immich.app' } }); + mocks.user.get.mockResolvedValue(userStub.admin); + mocks.notification.verifySmtp.mockResolvedValue(true); + mocks.notification.renderEmail.mockResolvedValue({ html: '', text: '' }); + mocks.systemMetadata.get.mockResolvedValue({ server: { externalDomain: 'https://demo.immich.app' } }); await expect(sut.sendTestEmail('', configs.smtpTransport.notifications.smtp)).resolves.not.toThrow(); - expect(notificationMock.renderEmail).toHaveBeenCalledWith({ + expect(mocks.notification.renderEmail).toHaveBeenCalledWith({ template: EmailTemplate.TEST_EMAIL, data: { baseUrl: 'https://demo.immich.app', displayName: userStub.admin.name }, }); - expect(notificationMock.sendEmail).toHaveBeenCalledWith( + expect(mocks.notification.sendEmail).toHaveBeenCalledWith( expect.objectContaining({ subject: 'Test email from Immich', smtp: configs.smtpTransport.notifications.smtp.transport, @@ -308,18 +294,18 @@ describe(NotificationService.name, () => { }); it('should send email with replyTo', async () => { - userMock.get.mockResolvedValue(userStub.admin); - notificationMock.verifySmtp.mockResolvedValue(true); - notificationMock.renderEmail.mockResolvedValue({ html: '', text: '' }); + mocks.user.get.mockResolvedValue(userStub.admin); + mocks.notification.verifySmtp.mockResolvedValue(true); + mocks.notification.renderEmail.mockResolvedValue({ html: '', text: '' }); await expect( sut.sendTestEmail('', { ...configs.smtpTransport.notifications.smtp, replyTo: 'demo@immich.app' }), ).resolves.not.toThrow(); - expect(notificationMock.renderEmail).toHaveBeenCalledWith({ + expect(mocks.notification.renderEmail).toHaveBeenCalledWith({ template: EmailTemplate.TEST_EMAIL, data: { baseUrl: 'http://localhost:2283', displayName: userStub.admin.name }, }); - expect(notificationMock.sendEmail).toHaveBeenCalledWith( + expect(mocks.notification.sendEmail).toHaveBeenCalledWith( expect.objectContaining({ subject: 'Test email from Immich', smtp: configs.smtpTransport.notifications.smtp.transport, @@ -335,12 +321,12 @@ describe(NotificationService.name, () => { }); it('should be successful', async () => { - userMock.get.mockResolvedValue(userStub.admin); - systemMock.get.mockResolvedValue({ server: {} }); - notificationMock.renderEmail.mockResolvedValue({ html: '', text: '' }); + mocks.user.get.mockResolvedValue(userStub.admin); + mocks.systemMetadata.get.mockResolvedValue({ server: {} }); + mocks.notification.renderEmail.mockResolvedValue({ html: '', text: '' }); await expect(sut.handleUserSignup({ id: '' })).resolves.toBe(JobStatus.SUCCESS); - expect(jobMock.queue).toHaveBeenCalledWith({ + expect(mocks.job.queue).toHaveBeenCalledWith({ name: JobName.SEND_EMAIL, data: expect.objectContaining({ subject: 'Welcome to Immich' }), }); @@ -350,19 +336,19 @@ describe(NotificationService.name, () => { describe('handleAlbumInvite', () => { it('should skip if album could not be found', async () => { await expect(sut.handleAlbumInvite({ id: '', recipientId: '' })).resolves.toBe(JobStatus.SKIPPED); - expect(userMock.get).not.toHaveBeenCalled(); + expect(mocks.user.get).not.toHaveBeenCalled(); }); it('should skip if recipient could not be found', async () => { - albumMock.getById.mockResolvedValue(albumStub.empty); + mocks.album.getById.mockResolvedValue(albumStub.empty); await expect(sut.handleAlbumInvite({ id: '', recipientId: '' })).resolves.toBe(JobStatus.SKIPPED); - expect(assetMock.getById).not.toHaveBeenCalled(); + expect(mocks.asset.getById).not.toHaveBeenCalled(); }); it('should skip if the recipient has email notifications disabled', async () => { - albumMock.getById.mockResolvedValue(albumStub.empty); - userMock.get.mockResolvedValue({ + mocks.album.getById.mockResolvedValue(albumStub.empty); + mocks.user.get.mockResolvedValue({ ...userStub.user1, metadata: [ { @@ -378,8 +364,8 @@ describe(NotificationService.name, () => { }); it('should skip if the recipient has email notifications for album invite disabled', async () => { - albumMock.getById.mockResolvedValue(albumStub.empty); - userMock.get.mockResolvedValue({ + mocks.album.getById.mockResolvedValue(albumStub.empty); + mocks.user.get.mockResolvedValue({ ...userStub.user1, metadata: [ { @@ -395,8 +381,8 @@ describe(NotificationService.name, () => { }); it('should send invite email', async () => { - albumMock.getById.mockResolvedValue(albumStub.empty); - userMock.get.mockResolvedValue({ + mocks.album.getById.mockResolvedValue(albumStub.empty); + mocks.user.get.mockResolvedValue({ ...userStub.user1, metadata: [ { @@ -407,19 +393,19 @@ describe(NotificationService.name, () => { }, ], }); - systemMock.get.mockResolvedValue({ server: {} }); - notificationMock.renderEmail.mockResolvedValue({ html: '', text: '' }); + mocks.systemMetadata.get.mockResolvedValue({ server: {} }); + mocks.notification.renderEmail.mockResolvedValue({ html: '', text: '' }); await expect(sut.handleAlbumInvite({ id: '', recipientId: '' })).resolves.toBe(JobStatus.SUCCESS); - expect(jobMock.queue).toHaveBeenCalledWith({ + expect(mocks.job.queue).toHaveBeenCalledWith({ name: JobName.SEND_EMAIL, data: expect.objectContaining({ subject: expect.stringContaining('You have been added to a shared album') }), }); }); it('should send invite email without album thumbnail if thumbnail asset does not exist', async () => { - albumMock.getById.mockResolvedValue(albumStub.emptyWithValidThumbnail); - userMock.get.mockResolvedValue({ + mocks.album.getById.mockResolvedValue(albumStub.emptyWithValidThumbnail); + mocks.user.get.mockResolvedValue({ ...userStub.user1, metadata: [ { @@ -430,14 +416,14 @@ describe(NotificationService.name, () => { }, ], }); - systemMock.get.mockResolvedValue({ server: {} }); - notificationMock.renderEmail.mockResolvedValue({ html: '', text: '' }); + mocks.systemMetadata.get.mockResolvedValue({ server: {} }); + mocks.notification.renderEmail.mockResolvedValue({ html: '', text: '' }); await expect(sut.handleAlbumInvite({ id: '', recipientId: '' })).resolves.toBe(JobStatus.SUCCESS); - expect(assetMock.getById).toHaveBeenCalledWith(albumStub.emptyWithValidThumbnail.albumThumbnailAssetId, { + expect(mocks.asset.getById).toHaveBeenCalledWith(albumStub.emptyWithValidThumbnail.albumThumbnailAssetId, { files: true, }); - expect(jobMock.queue).toHaveBeenCalledWith({ + expect(mocks.job.queue).toHaveBeenCalledWith({ name: JobName.SEND_EMAIL, data: expect.objectContaining({ subject: expect.stringContaining('You have been added to a shared album'), @@ -447,8 +433,8 @@ describe(NotificationService.name, () => { }); it('should send invite email with album thumbnail as jpeg', async () => { - albumMock.getById.mockResolvedValue(albumStub.emptyWithValidThumbnail); - userMock.get.mockResolvedValue({ + mocks.album.getById.mockResolvedValue(albumStub.emptyWithValidThumbnail); + mocks.user.get.mockResolvedValue({ ...userStub.user1, metadata: [ { @@ -459,18 +445,18 @@ describe(NotificationService.name, () => { }, ], }); - systemMock.get.mockResolvedValue({ server: {} }); - notificationMock.renderEmail.mockResolvedValue({ html: '', text: '' }); - assetMock.getById.mockResolvedValue({ + mocks.systemMetadata.get.mockResolvedValue({ server: {} }); + mocks.notification.renderEmail.mockResolvedValue({ html: '', text: '' }); + mocks.asset.getById.mockResolvedValue({ ...assetStub.image, files: [{ assetId: 'asset-id', type: AssetFileType.THUMBNAIL, path: 'path-to-thumb.jpg' } as AssetFileEntity], }); await expect(sut.handleAlbumInvite({ id: '', recipientId: '' })).resolves.toBe(JobStatus.SUCCESS); - expect(assetMock.getById).toHaveBeenCalledWith(albumStub.emptyWithValidThumbnail.albumThumbnailAssetId, { + expect(mocks.asset.getById).toHaveBeenCalledWith(albumStub.emptyWithValidThumbnail.albumThumbnailAssetId, { files: true, }); - expect(jobMock.queue).toHaveBeenCalledWith({ + expect(mocks.job.queue).toHaveBeenCalledWith({ name: JobName.SEND_EMAIL, data: expect.objectContaining({ subject: expect.stringContaining('You have been added to a shared album'), @@ -480,8 +466,8 @@ describe(NotificationService.name, () => { }); it('should send invite email with album thumbnail and arbitrary extension', async () => { - albumMock.getById.mockResolvedValue(albumStub.emptyWithValidThumbnail); - userMock.get.mockResolvedValue({ + mocks.album.getById.mockResolvedValue(albumStub.emptyWithValidThumbnail); + mocks.user.get.mockResolvedValue({ ...userStub.user1, metadata: [ { @@ -492,15 +478,15 @@ describe(NotificationService.name, () => { }, ], }); - systemMock.get.mockResolvedValue({ server: {} }); - notificationMock.renderEmail.mockResolvedValue({ html: '', text: '' }); - assetMock.getById.mockResolvedValue(assetStub.image); + mocks.systemMetadata.get.mockResolvedValue({ server: {} }); + mocks.notification.renderEmail.mockResolvedValue({ html: '', text: '' }); + mocks.asset.getById.mockResolvedValue(assetStub.image); await expect(sut.handleAlbumInvite({ id: '', recipientId: '' })).resolves.toBe(JobStatus.SUCCESS); - expect(assetMock.getById).toHaveBeenCalledWith(albumStub.emptyWithValidThumbnail.albumThumbnailAssetId, { + expect(mocks.asset.getById).toHaveBeenCalledWith(albumStub.emptyWithValidThumbnail.albumThumbnailAssetId, { files: true, }); - expect(jobMock.queue).toHaveBeenCalledWith({ + expect(mocks.job.queue).toHaveBeenCalledWith({ name: JobName.SEND_EMAIL, data: expect.objectContaining({ subject: expect.stringContaining('You have been added to a shared album'), @@ -513,35 +499,35 @@ describe(NotificationService.name, () => { describe('handleAlbumUpdate', () => { it('should skip if album could not be found', async () => { await expect(sut.handleAlbumUpdate({ id: '', recipientIds: ['1'] })).resolves.toBe(JobStatus.SKIPPED); - expect(userMock.get).not.toHaveBeenCalled(); + expect(mocks.user.get).not.toHaveBeenCalled(); }); it('should skip if owner could not be found', async () => { - albumMock.getById.mockResolvedValue(albumStub.emptyWithValidThumbnail); + mocks.album.getById.mockResolvedValue(albumStub.emptyWithValidThumbnail); await expect(sut.handleAlbumUpdate({ id: '', recipientIds: ['1'] })).resolves.toBe(JobStatus.SKIPPED); - expect(systemMock.get).not.toHaveBeenCalled(); + expect(mocks.systemMetadata.get).not.toHaveBeenCalled(); }); it('should skip recipient that could not be looked up', async () => { - albumMock.getById.mockResolvedValue({ + mocks.album.getById.mockResolvedValue({ ...albumStub.emptyWithValidThumbnail, albumUsers: [{ user: { id: userStub.user1.id } } as AlbumUserEntity], }); - userMock.get.mockResolvedValueOnce(userStub.user1); - notificationMock.renderEmail.mockResolvedValue({ html: '', text: '' }); + mocks.user.get.mockResolvedValueOnce(userStub.user1); + mocks.notification.renderEmail.mockResolvedValue({ html: '', text: '' }); await sut.handleAlbumUpdate({ id: '', recipientIds: [userStub.user1.id] }); - expect(userMock.get).toHaveBeenCalledWith(userStub.user1.id, { withDeleted: false }); - expect(notificationMock.renderEmail).not.toHaveBeenCalled(); + expect(mocks.user.get).toHaveBeenCalledWith(userStub.user1.id, { withDeleted: false }); + expect(mocks.notification.renderEmail).not.toHaveBeenCalled(); }); it('should skip recipient with disabled email notifications', async () => { - albumMock.getById.mockResolvedValue({ + mocks.album.getById.mockResolvedValue({ ...albumStub.emptyWithValidThumbnail, albumUsers: [{ user: { id: userStub.user1.id } } as AlbumUserEntity], }); - userMock.get.mockResolvedValue({ + mocks.user.get.mockResolvedValue({ ...userStub.user1, metadata: [ { @@ -552,19 +538,19 @@ describe(NotificationService.name, () => { }, ], }); - notificationMock.renderEmail.mockResolvedValue({ html: '', text: '' }); + mocks.notification.renderEmail.mockResolvedValue({ html: '', text: '' }); await sut.handleAlbumUpdate({ id: '', recipientIds: [userStub.user1.id] }); - expect(userMock.get).toHaveBeenCalledWith(userStub.user1.id, { withDeleted: false }); - expect(notificationMock.renderEmail).not.toHaveBeenCalled(); + expect(mocks.user.get).toHaveBeenCalledWith(userStub.user1.id, { withDeleted: false }); + expect(mocks.notification.renderEmail).not.toHaveBeenCalled(); }); it('should skip recipient with disabled email notifications for the album update event', async () => { - albumMock.getById.mockResolvedValue({ + mocks.album.getById.mockResolvedValue({ ...albumStub.emptyWithValidThumbnail, albumUsers: [{ user: { id: userStub.user1.id } } as AlbumUserEntity], }); - userMock.get.mockResolvedValue({ + mocks.user.get.mockResolvedValue({ ...userStub.user1, metadata: [ { @@ -575,31 +561,31 @@ describe(NotificationService.name, () => { }, ], }); - notificationMock.renderEmail.mockResolvedValue({ html: '', text: '' }); + mocks.notification.renderEmail.mockResolvedValue({ html: '', text: '' }); await sut.handleAlbumUpdate({ id: '', recipientIds: [userStub.user1.id] }); - expect(userMock.get).toHaveBeenCalledWith(userStub.user1.id, { withDeleted: false }); - expect(notificationMock.renderEmail).not.toHaveBeenCalled(); + expect(mocks.user.get).toHaveBeenCalledWith(userStub.user1.id, { withDeleted: false }); + expect(mocks.notification.renderEmail).not.toHaveBeenCalled(); }); it('should send email', async () => { - albumMock.getById.mockResolvedValue({ + mocks.album.getById.mockResolvedValue({ ...albumStub.emptyWithValidThumbnail, albumUsers: [{ user: { id: userStub.user1.id } } as AlbumUserEntity], }); - userMock.get.mockResolvedValue(userStub.user1); - notificationMock.renderEmail.mockResolvedValue({ html: '', text: '' }); + mocks.user.get.mockResolvedValue(userStub.user1); + mocks.notification.renderEmail.mockResolvedValue({ html: '', text: '' }); await sut.handleAlbumUpdate({ id: '', recipientIds: [userStub.user1.id] }); - expect(userMock.get).toHaveBeenCalledWith(userStub.user1.id, { withDeleted: false }); - expect(notificationMock.renderEmail).toHaveBeenCalled(); - expect(jobMock.queue).toHaveBeenCalled(); + expect(mocks.user.get).toHaveBeenCalledWith(userStub.user1.id, { withDeleted: false }); + expect(mocks.notification.renderEmail).toHaveBeenCalled(); + expect(mocks.job.queue).toHaveBeenCalled(); }); it('should add new recipients for new images if job is already queued', async () => { - jobMock.removeJob.mockResolvedValue({ id: '1', recipientIds: ['2', '3', '4'] } as INotifyAlbumUpdateJob); + mocks.job.removeJob.mockResolvedValue({ id: '1', recipientIds: ['2', '3', '4'] } as INotifyAlbumUpdateJob); await sut.onAlbumUpdate({ id: '1', recipientIds: ['1', '2', '3'] } as INotifyAlbumUpdateJob); - expect(jobMock.queue).toHaveBeenCalledWith({ + expect(mocks.job.queue).toHaveBeenCalledWith({ name: JobName.NOTIFY_ALBUM_UPDATE, data: { id: '1', @@ -612,26 +598,32 @@ describe(NotificationService.name, () => { describe('handleSendEmail', () => { it('should skip if smtp notifications are disabled', async () => { - systemMock.get.mockResolvedValue({ notifications: { smtp: { enabled: false } } }); + mocks.systemMetadata.get.mockResolvedValue({ notifications: { smtp: { enabled: false } } }); await expect(sut.handleSendEmail({ html: '', subject: '', text: '', to: '' })).resolves.toBe(JobStatus.SKIPPED); }); it('should send mail successfully', async () => { - systemMock.get.mockResolvedValue({ notifications: { smtp: { enabled: true, from: 'test@immich.app' } } }); - notificationMock.sendEmail.mockResolvedValue({ messageId: '', response: '' }); + mocks.systemMetadata.get.mockResolvedValue({ + notifications: { smtp: { enabled: true, from: 'test@immich.app' } }, + }); + mocks.notification.sendEmail.mockResolvedValue({ messageId: '', response: '' }); await expect(sut.handleSendEmail({ html: '', subject: '', text: '', to: '' })).resolves.toBe(JobStatus.SUCCESS); - expect(notificationMock.sendEmail).toHaveBeenCalledWith(expect.objectContaining({ replyTo: 'test@immich.app' })); + expect(mocks.notification.sendEmail).toHaveBeenCalledWith( + expect.objectContaining({ replyTo: 'test@immich.app' }), + ); }); it('should send mail with replyTo successfully', async () => { - systemMock.get.mockResolvedValue({ + mocks.systemMetadata.get.mockResolvedValue({ notifications: { smtp: { enabled: true, from: 'test@immich.app', replyTo: 'demo@immich.app' } }, }); - notificationMock.sendEmail.mockResolvedValue({ messageId: '', response: '' }); + mocks.notification.sendEmail.mockResolvedValue({ messageId: '', response: '' }); await expect(sut.handleSendEmail({ html: '', subject: '', text: '', to: '' })).resolves.toBe(JobStatus.SUCCESS); - expect(notificationMock.sendEmail).toHaveBeenCalledWith(expect.objectContaining({ replyTo: 'demo@immich.app' })); + expect(mocks.notification.sendEmail).toHaveBeenCalledWith( + expect.objectContaining({ replyTo: 'demo@immich.app' }), + ); }); }); }); diff --git a/server/src/services/partner.service.spec.ts b/server/src/services/partner.service.spec.ts index e7b7348e98..02dff32a72 100644 --- a/server/src/services/partner.service.spec.ts +++ b/server/src/services/partner.service.spec.ts @@ -1,20 +1,16 @@ import { BadRequestException } from '@nestjs/common'; -import { IPartnerRepository, PartnerDirection } from 'src/interfaces/partner.interface'; +import { PartnerDirection } from 'src/interfaces/partner.interface'; import { PartnerService } from 'src/services/partner.service'; import { authStub } from 'test/fixtures/auth.stub'; import { partnerStub } from 'test/fixtures/partner.stub'; -import { IAccessRepositoryMock } from 'test/repositories/access.repository.mock'; -import { newTestService } from 'test/utils'; -import { Mocked } from 'vitest'; +import { newTestService, ServiceMocks } from 'test/utils'; describe(PartnerService.name, () => { let sut: PartnerService; - - let accessMock: IAccessRepositoryMock; - let partnerMock: Mocked; + let mocks: ServiceMocks; beforeEach(() => { - ({ sut, accessMock, partnerMock } = newTestService(PartnerService)); + ({ sut, mocks } = newTestService(PartnerService)); }); it('should work', () => { @@ -23,55 +19,55 @@ describe(PartnerService.name, () => { describe('search', () => { it("should return a list of partners with whom I've shared my library", async () => { - partnerMock.getAll.mockResolvedValue([partnerStub.adminToUser1, partnerStub.user1ToAdmin1]); + mocks.partner.getAll.mockResolvedValue([partnerStub.adminToUser1, partnerStub.user1ToAdmin1]); await expect(sut.search(authStub.user1, { direction: PartnerDirection.SharedBy })).resolves.toBeDefined(); - expect(partnerMock.getAll).toHaveBeenCalledWith(authStub.user1.user.id); + expect(mocks.partner.getAll).toHaveBeenCalledWith(authStub.user1.user.id); }); it('should return a list of partners who have shared their libraries with me', async () => { - partnerMock.getAll.mockResolvedValue([partnerStub.adminToUser1, partnerStub.user1ToAdmin1]); + mocks.partner.getAll.mockResolvedValue([partnerStub.adminToUser1, partnerStub.user1ToAdmin1]); await expect(sut.search(authStub.user1, { direction: PartnerDirection.SharedWith })).resolves.toBeDefined(); - expect(partnerMock.getAll).toHaveBeenCalledWith(authStub.user1.user.id); + expect(mocks.partner.getAll).toHaveBeenCalledWith(authStub.user1.user.id); }); }); describe('create', () => { it('should create a new partner', async () => { - partnerMock.get.mockResolvedValue(void 0); - partnerMock.create.mockResolvedValue(partnerStub.adminToUser1); + mocks.partner.get.mockResolvedValue(void 0); + mocks.partner.create.mockResolvedValue(partnerStub.adminToUser1); await expect(sut.create(authStub.admin, authStub.user1.user.id)).resolves.toBeDefined(); - expect(partnerMock.create).toHaveBeenCalledWith({ + expect(mocks.partner.create).toHaveBeenCalledWith({ sharedById: authStub.admin.user.id, sharedWithId: authStub.user1.user.id, }); }); it('should throw an error when the partner already exists', async () => { - partnerMock.get.mockResolvedValue(partnerStub.adminToUser1); + mocks.partner.get.mockResolvedValue(partnerStub.adminToUser1); await expect(sut.create(authStub.admin, authStub.user1.user.id)).rejects.toBeInstanceOf(BadRequestException); - expect(partnerMock.create).not.toHaveBeenCalled(); + expect(mocks.partner.create).not.toHaveBeenCalled(); }); }); describe('remove', () => { it('should remove a partner', async () => { - partnerMock.get.mockResolvedValue(partnerStub.adminToUser1); + mocks.partner.get.mockResolvedValue(partnerStub.adminToUser1); await sut.remove(authStub.admin, authStub.user1.user.id); - expect(partnerMock.remove).toHaveBeenCalledWith(partnerStub.adminToUser1); + expect(mocks.partner.remove).toHaveBeenCalledWith(partnerStub.adminToUser1); }); it('should throw an error when the partner does not exist', async () => { - partnerMock.get.mockResolvedValue(void 0); + mocks.partner.get.mockResolvedValue(void 0); await expect(sut.remove(authStub.admin, authStub.user1.user.id)).rejects.toBeInstanceOf(BadRequestException); - expect(partnerMock.remove).not.toHaveBeenCalled(); + expect(mocks.partner.remove).not.toHaveBeenCalled(); }); }); @@ -83,11 +79,11 @@ describe(PartnerService.name, () => { }); it('should update partner', async () => { - accessMock.partner.checkUpdateAccess.mockResolvedValue(new Set(['shared-by-id'])); - partnerMock.update.mockResolvedValue(partnerStub.adminToUser1); + mocks.access.partner.checkUpdateAccess.mockResolvedValue(new Set(['shared-by-id'])); + mocks.partner.update.mockResolvedValue(partnerStub.adminToUser1); await expect(sut.update(authStub.admin, 'shared-by-id', { inTimeline: true })).resolves.toBeDefined(); - expect(partnerMock.update).toHaveBeenCalledWith( + expect(mocks.partner.update).toHaveBeenCalledWith( { sharedById: 'shared-by-id', sharedWithId: authStub.admin.user.id }, { inTimeline: true }, ); diff --git a/server/src/services/person.service.spec.ts b/server/src/services/person.service.spec.ts index 65cd8815f8..0b3adec571 100644 --- a/server/src/services/person.service.spec.ts +++ b/server/src/services/person.service.spec.ts @@ -1,26 +1,20 @@ import { BadRequestException, NotFoundException } from '@nestjs/common'; import { BulkIdErrorReason } from 'src/dtos/asset-ids.response.dto'; -import { PersonResponseDto, mapFaces, mapPerson } from 'src/dtos/person.dto'; +import { mapFaces, mapPerson, PersonResponseDto } from 'src/dtos/person.dto'; import { AssetFaceEntity } from 'src/entities/asset-face.entity'; import { CacheControl, Colorspace, ImageFormat, SourceType, SystemMetadataKey } from 'src/enum'; -import { IAssetRepository, WithoutProperty } from 'src/interfaces/asset.interface'; -import { ICryptoRepository } from 'src/interfaces/crypto.interface'; -import { IJobRepository, JobName, JobStatus } from 'src/interfaces/job.interface'; -import { DetectedFaces, IMachineLearningRepository } from 'src/interfaces/machine-learning.interface'; -import { IPersonRepository } from 'src/interfaces/person.interface'; -import { FaceSearchResult, ISearchRepository } from 'src/interfaces/search.interface'; -import { IStorageRepository } from 'src/interfaces/storage.interface'; +import { WithoutProperty } from 'src/interfaces/asset.interface'; +import { JobName, JobStatus } from 'src/interfaces/job.interface'; +import { DetectedFaces } from 'src/interfaces/machine-learning.interface'; +import { FaceSearchResult } from 'src/interfaces/search.interface'; import { PersonService } from 'src/services/person.service'; -import { IMediaRepository, ISystemMetadataRepository } from 'src/types'; import { ImmichFileResponse } from 'src/utils/file'; import { assetStub } from 'test/fixtures/asset.stub'; import { authStub } from 'test/fixtures/auth.stub'; import { faceStub } from 'test/fixtures/face.stub'; import { personStub } from 'test/fixtures/person.stub'; import { systemConfigStub } from 'test/fixtures/system-config.stub'; -import { IAccessRepositoryMock } from 'test/repositories/access.repository.mock'; -import { makeStream, newTestService } from 'test/utils'; -import { Mocked } from 'vitest'; +import { makeStream, newTestService, ServiceMocks } from 'test/utils'; const responseDto: PersonResponseDto = { id: 'person-1', @@ -65,32 +59,10 @@ const detectFaceMock: DetectedFaces = { describe(PersonService.name, () => { let sut: PersonService; - - let accessMock: IAccessRepositoryMock; - let assetMock: Mocked; - let cryptoMock: Mocked; - let jobMock: Mocked; - let machineLearningMock: Mocked; - let mediaMock: Mocked; - let personMock: Mocked; - let searchMock: Mocked; - let storageMock: Mocked; - let systemMock: Mocked; + let mocks: ServiceMocks; beforeEach(() => { - ({ - sut, - accessMock, - assetMock, - cryptoMock, - jobMock, - machineLearningMock, - mediaMock, - personMock, - searchMock, - storageMock, - systemMock, - } = newTestService(PersonService)); + ({ sut, mocks } = newTestService(PersonService)); }); it('should be defined', () => { @@ -99,11 +71,11 @@ describe(PersonService.name, () => { describe('getAll', () => { it('should get all hidden and visible people with thumbnails', async () => { - personMock.getAllForUser.mockResolvedValue({ + mocks.person.getAllForUser.mockResolvedValue({ items: [personStub.withName, personStub.hidden], hasNextPage: false, }); - personMock.getNumberOfPeople.mockResolvedValue({ total: 2, hidden: 1 }); + mocks.person.getNumberOfPeople.mockResolvedValue({ total: 2, hidden: 1 }); await expect(sut.getAll(authStub.admin, { withHidden: true, page: 1, size: 10 })).resolves.toEqual({ hasNextPage: false, total: 2, @@ -121,18 +93,18 @@ describe(PersonService.name, () => { }, ], }); - expect(personMock.getAllForUser).toHaveBeenCalledWith({ skip: 0, take: 10 }, authStub.admin.user.id, { + expect(mocks.person.getAllForUser).toHaveBeenCalledWith({ skip: 0, take: 10 }, authStub.admin.user.id, { minimumFaceCount: 3, withHidden: true, }); }); it('should get all visible people and favorites should be first in the array', async () => { - personMock.getAllForUser.mockResolvedValue({ + mocks.person.getAllForUser.mockResolvedValue({ items: [personStub.isFavorite, personStub.withName], hasNextPage: false, }); - personMock.getNumberOfPeople.mockResolvedValue({ total: 2, hidden: 1 }); + mocks.person.getNumberOfPeople.mockResolvedValue({ total: 2, hidden: 1 }); await expect(sut.getAll(authStub.admin, { withHidden: false, page: 1, size: 10 })).resolves.toEqual({ hasNextPage: false, total: 2, @@ -150,7 +122,7 @@ describe(PersonService.name, () => { responseDto, ], }); - expect(personMock.getAllForUser).toHaveBeenCalledWith({ skip: 0, take: 10 }, authStub.admin.user.id, { + expect(mocks.person.getAllForUser).toHaveBeenCalledWith({ skip: 0, take: 10 }, authStub.admin.user.id, { minimumFaceCount: 3, withHidden: false, }); @@ -159,54 +131,54 @@ describe(PersonService.name, () => { describe('getById', () => { it('should require person.read permission', async () => { - personMock.getById.mockResolvedValue(personStub.withName); + mocks.person.getById.mockResolvedValue(personStub.withName); await expect(sut.getById(authStub.admin, 'person-1')).rejects.toBeInstanceOf(BadRequestException); - expect(accessMock.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['person-1'])); + expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['person-1'])); }); it('should throw a bad request when person is not found', async () => { - personMock.getById.mockResolvedValue(null); - accessMock.person.checkOwnerAccess.mockResolvedValue(new Set(['person-1'])); + mocks.person.getById.mockResolvedValue(null); + mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set(['person-1'])); await expect(sut.getById(authStub.admin, 'person-1')).rejects.toBeInstanceOf(BadRequestException); - expect(accessMock.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['person-1'])); + expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['person-1'])); }); it('should get a person by id', async () => { - personMock.getById.mockResolvedValue(personStub.withName); - accessMock.person.checkOwnerAccess.mockResolvedValue(new Set(['person-1'])); + mocks.person.getById.mockResolvedValue(personStub.withName); + mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set(['person-1'])); await expect(sut.getById(authStub.admin, 'person-1')).resolves.toEqual(responseDto); - expect(personMock.getById).toHaveBeenCalledWith('person-1'); - expect(accessMock.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['person-1'])); + expect(mocks.person.getById).toHaveBeenCalledWith('person-1'); + expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['person-1'])); }); }); describe('getThumbnail', () => { it('should require person.read permission', async () => { - personMock.getById.mockResolvedValue(personStub.noName); + mocks.person.getById.mockResolvedValue(personStub.noName); await expect(sut.getThumbnail(authStub.admin, 'person-1')).rejects.toBeInstanceOf(BadRequestException); - expect(storageMock.createReadStream).not.toHaveBeenCalled(); - expect(accessMock.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['person-1'])); + expect(mocks.storage.createReadStream).not.toHaveBeenCalled(); + expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['person-1'])); }); it('should throw an error when personId is invalid', async () => { - personMock.getById.mockResolvedValue(null); - accessMock.person.checkOwnerAccess.mockResolvedValue(new Set(['person-1'])); + mocks.person.getById.mockResolvedValue(null); + mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set(['person-1'])); await expect(sut.getThumbnail(authStub.admin, 'person-1')).rejects.toBeInstanceOf(NotFoundException); - expect(storageMock.createReadStream).not.toHaveBeenCalled(); - expect(accessMock.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['person-1'])); + expect(mocks.storage.createReadStream).not.toHaveBeenCalled(); + expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['person-1'])); }); it('should throw an error when person has no thumbnail', async () => { - personMock.getById.mockResolvedValue(personStub.noThumbnail); - accessMock.person.checkOwnerAccess.mockResolvedValue(new Set(['person-1'])); + mocks.person.getById.mockResolvedValue(personStub.noThumbnail); + mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set(['person-1'])); await expect(sut.getThumbnail(authStub.admin, 'person-1')).rejects.toBeInstanceOf(NotFoundException); - expect(storageMock.createReadStream).not.toHaveBeenCalled(); - expect(accessMock.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['person-1'])); + expect(mocks.storage.createReadStream).not.toHaveBeenCalled(); + expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['person-1'])); }); it('should serve the thumbnail', async () => { - personMock.getById.mockResolvedValue(personStub.noName); - accessMock.person.checkOwnerAccess.mockResolvedValue(new Set(['person-1'])); + mocks.person.getById.mockResolvedValue(personStub.noName); + mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set(['person-1'])); await expect(sut.getThumbnail(authStub.admin, 'person-1')).resolves.toEqual( new ImmichFileResponse({ path: '/path/to/thumbnail.jpg', @@ -214,42 +186,42 @@ describe(PersonService.name, () => { cacheControl: CacheControl.PRIVATE_WITHOUT_CACHE, }), ); - expect(accessMock.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['person-1'])); + expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['person-1'])); }); }); describe('update', () => { it('should require person.write permission', async () => { - personMock.getById.mockResolvedValue(personStub.noName); + mocks.person.getById.mockResolvedValue(personStub.noName); await expect(sut.update(authStub.admin, 'person-1', { name: 'Person 1' })).rejects.toBeInstanceOf( BadRequestException, ); - expect(personMock.update).not.toHaveBeenCalled(); - expect(accessMock.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['person-1'])); + expect(mocks.person.update).not.toHaveBeenCalled(); + expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['person-1'])); }); it('should throw an error when personId is invalid', async () => { - accessMock.person.checkOwnerAccess.mockResolvedValue(new Set()); + mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set()); await expect(sut.update(authStub.admin, 'person-1', { name: 'Person 1' })).rejects.toBeInstanceOf( BadRequestException, ); - expect(personMock.update).not.toHaveBeenCalled(); - expect(accessMock.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['person-1'])); + expect(mocks.person.update).not.toHaveBeenCalled(); + expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['person-1'])); }); it("should update a person's name", async () => { - personMock.update.mockResolvedValue(personStub.withName); - accessMock.person.checkOwnerAccess.mockResolvedValue(new Set(['person-1'])); + mocks.person.update.mockResolvedValue(personStub.withName); + mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set(['person-1'])); await expect(sut.update(authStub.admin, 'person-1', { name: 'Person 1' })).resolves.toEqual(responseDto); - expect(personMock.update).toHaveBeenCalledWith({ id: 'person-1', name: 'Person 1' }); - expect(accessMock.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['person-1'])); + expect(mocks.person.update).toHaveBeenCalledWith({ id: 'person-1', name: 'Person 1' }); + expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['person-1'])); }); it("should update a person's date of birth", async () => { - personMock.update.mockResolvedValue(personStub.withBirthDate); - accessMock.person.checkOwnerAccess.mockResolvedValue(new Set(['person-1'])); + mocks.person.update.mockResolvedValue(personStub.withBirthDate); + mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set(['person-1'])); await expect(sut.update(authStub.admin, 'person-1', { birthDate: '1976-06-30' })).resolves.toEqual({ id: 'person-1', @@ -260,103 +232,106 @@ describe(PersonService.name, () => { isFavorite: false, updatedAt: expect.any(Date), }); - expect(personMock.update).toHaveBeenCalledWith({ id: 'person-1', birthDate: '1976-06-30' }); - expect(jobMock.queue).not.toHaveBeenCalled(); - expect(jobMock.queueAll).not.toHaveBeenCalled(); - expect(accessMock.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['person-1'])); + expect(mocks.person.update).toHaveBeenCalledWith({ id: 'person-1', birthDate: '1976-06-30' }); + expect(mocks.job.queue).not.toHaveBeenCalled(); + expect(mocks.job.queueAll).not.toHaveBeenCalled(); + expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['person-1'])); }); it('should update a person visibility', async () => { - personMock.update.mockResolvedValue(personStub.withName); - accessMock.person.checkOwnerAccess.mockResolvedValue(new Set(['person-1'])); + mocks.person.update.mockResolvedValue(personStub.withName); + mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set(['person-1'])); await expect(sut.update(authStub.admin, 'person-1', { isHidden: false })).resolves.toEqual(responseDto); - expect(personMock.update).toHaveBeenCalledWith({ id: 'person-1', isHidden: false }); - expect(accessMock.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['person-1'])); + expect(mocks.person.update).toHaveBeenCalledWith({ id: 'person-1', isHidden: false }); + expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['person-1'])); }); it('should update a person favorite status', async () => { - personMock.update.mockResolvedValue(personStub.withName); - accessMock.person.checkOwnerAccess.mockResolvedValue(new Set(['person-1'])); + mocks.person.update.mockResolvedValue(personStub.withName); + mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set(['person-1'])); await expect(sut.update(authStub.admin, 'person-1', { isFavorite: true })).resolves.toEqual(responseDto); - expect(personMock.update).toHaveBeenCalledWith({ id: 'person-1', isFavorite: true }); - expect(accessMock.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['person-1'])); + expect(mocks.person.update).toHaveBeenCalledWith({ id: 'person-1', isFavorite: true }); + expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['person-1'])); }); it("should update a person's thumbnailPath", async () => { - personMock.update.mockResolvedValue(personStub.withName); - personMock.getFacesByIds.mockResolvedValue([faceStub.face1]); - accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.image.id])); - accessMock.person.checkOwnerAccess.mockResolvedValue(new Set(['person-1'])); + mocks.person.update.mockResolvedValue(personStub.withName); + mocks.person.getFacesByIds.mockResolvedValue([faceStub.face1]); + mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.image.id])); + mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set(['person-1'])); await expect( sut.update(authStub.admin, 'person-1', { featureFaceAssetId: faceStub.face1.assetId }), ).resolves.toEqual(responseDto); - expect(personMock.update).toHaveBeenCalledWith({ id: 'person-1', faceAssetId: faceStub.face1.id }); - expect(personMock.getFacesByIds).toHaveBeenCalledWith([ + expect(mocks.person.update).toHaveBeenCalledWith({ id: 'person-1', faceAssetId: faceStub.face1.id }); + expect(mocks.person.getFacesByIds).toHaveBeenCalledWith([ { assetId: faceStub.face1.assetId, personId: 'person-1', }, ]); - expect(jobMock.queue).toHaveBeenCalledWith({ name: JobName.GENERATE_PERSON_THUMBNAIL, data: { id: 'person-1' } }); - expect(accessMock.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['person-1'])); + expect(mocks.job.queue).toHaveBeenCalledWith({ + name: JobName.GENERATE_PERSON_THUMBNAIL, + data: { id: 'person-1' }, + }); + expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['person-1'])); }); it('should throw an error when the face feature assetId is invalid', async () => { - personMock.getById.mockResolvedValue(personStub.withName); - accessMock.person.checkOwnerAccess.mockResolvedValue(new Set(['person-1'])); + mocks.person.getById.mockResolvedValue(personStub.withName); + mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set(['person-1'])); await expect(sut.update(authStub.admin, 'person-1', { featureFaceAssetId: '-1' })).rejects.toThrow( BadRequestException, ); - expect(personMock.update).not.toHaveBeenCalled(); - expect(accessMock.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['person-1'])); + expect(mocks.person.update).not.toHaveBeenCalled(); + expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['person-1'])); }); }); describe('updateAll', () => { it('should throw an error when personId is invalid', async () => { - accessMock.person.checkOwnerAccess.mockResolvedValue(new Set()); + mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set()); await expect(sut.updateAll(authStub.admin, { people: [{ id: 'person-1', name: 'Person 1' }] })).resolves.toEqual([ { error: BulkIdErrorReason.UNKNOWN, id: 'person-1', success: false }, ]); - expect(personMock.update).not.toHaveBeenCalled(); - expect(accessMock.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['person-1'])); + expect(mocks.person.update).not.toHaveBeenCalled(); + expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['person-1'])); }); }); describe('reassignFaces', () => { it('should throw an error if user has no access to the person', async () => { - accessMock.person.checkOwnerAccess.mockResolvedValue(new Set()); + mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set()); await expect( sut.reassignFaces(authStub.admin, personStub.noName.id, { data: [{ personId: 'asset-face-1', assetId: '' }], }), ).rejects.toBeInstanceOf(BadRequestException); - expect(jobMock.queue).not.toHaveBeenCalledWith(); - expect(jobMock.queueAll).not.toHaveBeenCalledWith(); + expect(mocks.job.queue).not.toHaveBeenCalledWith(); + expect(mocks.job.queueAll).not.toHaveBeenCalledWith(); }); it('should reassign a face', async () => { - accessMock.person.checkOwnerAccess.mockResolvedValue(new Set([personStub.withName.id])); - personMock.getById.mockResolvedValue(personStub.noName); - accessMock.person.checkFaceOwnerAccess.mockResolvedValue(new Set([faceStub.face1.id])); - personMock.getFacesByIds.mockResolvedValue([faceStub.face1]); - personMock.reassignFace.mockResolvedValue(1); - personMock.getRandomFace.mockResolvedValue(faceStub.primaryFace1); + mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set([personStub.withName.id])); + mocks.person.getById.mockResolvedValue(personStub.noName); + mocks.access.person.checkFaceOwnerAccess.mockResolvedValue(new Set([faceStub.face1.id])); + mocks.person.getFacesByIds.mockResolvedValue([faceStub.face1]); + mocks.person.reassignFace.mockResolvedValue(1); + mocks.person.getRandomFace.mockResolvedValue(faceStub.primaryFace1); await expect( sut.reassignFaces(authStub.admin, personStub.noName.id, { data: [{ personId: personStub.withName.id, assetId: assetStub.image.id }], }), ).resolves.toBeDefined(); - expect(jobMock.queueAll).toHaveBeenCalledWith([ + expect(mocks.job.queueAll).toHaveBeenCalledWith([ { name: JobName.GENERATE_PERSON_THUMBNAIL, data: { id: personStub.newThumbnail.id }, @@ -367,22 +342,22 @@ describe(PersonService.name, () => { describe('handlePersonMigration', () => { it('should not move person files', async () => { - personMock.getById.mockResolvedValue(null); + mocks.person.getById.mockResolvedValue(null); await expect(sut.handlePersonMigration(personStub.noName)).resolves.toBe(JobStatus.FAILED); }); }); describe('getFacesById', () => { it('should get the bounding boxes for an asset', async () => { - accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set([faceStub.face1.assetId])); - personMock.getFaces.mockResolvedValue([faceStub.primaryFace1]); + mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([faceStub.face1.assetId])); + mocks.person.getFaces.mockResolvedValue([faceStub.primaryFace1]); await expect(sut.getFacesById(authStub.admin, { id: faceStub.face1.assetId })).resolves.toStrictEqual([ mapFaces(faceStub.primaryFace1, authStub.admin), ]); }); it('should reject if the user has not access to the asset', async () => { - accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set()); - personMock.getFaces.mockResolvedValue([faceStub.primaryFace1]); + mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set()); + mocks.person.getFaces.mockResolvedValue([faceStub.primaryFace1]); await expect(sut.getFacesById(authStub.admin, { id: faceStub.primaryFace1.assetId })).rejects.toBeInstanceOf( BadRequestException, ); @@ -391,9 +366,9 @@ describe(PersonService.name, () => { describe('createNewFeaturePhoto', () => { it('should change person feature photo', async () => { - personMock.getRandomFace.mockResolvedValue(faceStub.primaryFace1); + mocks.person.getRandomFace.mockResolvedValue(faceStub.primaryFace1); await sut.createNewFeaturePhoto([personStub.newThumbnail.id]); - expect(jobMock.queueAll).toHaveBeenCalledWith([ + expect(mocks.job.queueAll).toHaveBeenCalledWith([ { name: JobName.GENERATE_PERSON_THUMBNAIL, data: { id: personStub.newThumbnail.id }, @@ -404,11 +379,11 @@ describe(PersonService.name, () => { describe('reassignFacesById', () => { it('should create a new person', async () => { - accessMock.person.checkOwnerAccess.mockResolvedValue(new Set([personStub.noName.id])); - accessMock.person.checkFaceOwnerAccess.mockResolvedValue(new Set([faceStub.face1.id])); - personMock.getFaceById.mockResolvedValue(faceStub.face1); - personMock.reassignFace.mockResolvedValue(1); - personMock.getById.mockResolvedValue(personStub.noName); + mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set([personStub.noName.id])); + mocks.access.person.checkFaceOwnerAccess.mockResolvedValue(new Set([faceStub.face1.id])); + mocks.person.getFaceById.mockResolvedValue(faceStub.face1); + mocks.person.reassignFace.mockResolvedValue(1); + mocks.person.getById.mockResolvedValue(personStub.noName); await expect( sut.reassignFacesById(authStub.admin, personStub.noName.id, { id: faceStub.face1.id, @@ -423,67 +398,67 @@ describe(PersonService.name, () => { updatedAt: expect.any(Date), }); - expect(jobMock.queue).not.toHaveBeenCalledWith(); - expect(jobMock.queueAll).not.toHaveBeenCalledWith(); + expect(mocks.job.queue).not.toHaveBeenCalledWith(); + expect(mocks.job.queueAll).not.toHaveBeenCalledWith(); }); it('should fail if user has not the correct permissions on the asset', async () => { - accessMock.person.checkOwnerAccess.mockResolvedValue(new Set([personStub.noName.id])); - personMock.getFaceById.mockResolvedValue(faceStub.face1); - personMock.reassignFace.mockResolvedValue(1); - personMock.getById.mockResolvedValue(personStub.noName); + mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set([personStub.noName.id])); + mocks.person.getFaceById.mockResolvedValue(faceStub.face1); + mocks.person.reassignFace.mockResolvedValue(1); + mocks.person.getById.mockResolvedValue(personStub.noName); await expect( sut.reassignFacesById(authStub.admin, personStub.noName.id, { id: faceStub.face1.id, }), ).rejects.toBeInstanceOf(BadRequestException); - expect(jobMock.queue).not.toHaveBeenCalledWith(); - expect(jobMock.queueAll).not.toHaveBeenCalledWith(); + expect(mocks.job.queue).not.toHaveBeenCalledWith(); + expect(mocks.job.queueAll).not.toHaveBeenCalledWith(); }); }); describe('createPerson', () => { it('should create a new person', async () => { - personMock.create.mockResolvedValue(personStub.primaryPerson); + mocks.person.create.mockResolvedValue(personStub.primaryPerson); await expect(sut.create(authStub.admin, {})).resolves.toBeDefined(); - expect(personMock.create).toHaveBeenCalledWith({ ownerId: authStub.admin.user.id }); + expect(mocks.person.create).toHaveBeenCalledWith({ ownerId: authStub.admin.user.id }); }); }); describe('handlePersonCleanup', () => { it('should delete people without faces', async () => { - personMock.getAllWithoutFaces.mockResolvedValue([personStub.noName]); + mocks.person.getAllWithoutFaces.mockResolvedValue([personStub.noName]); await sut.handlePersonCleanup(); - expect(personMock.delete).toHaveBeenCalledWith([personStub.noName]); - expect(storageMock.unlink).toHaveBeenCalledWith(personStub.noName.thumbnailPath); + expect(mocks.person.delete).toHaveBeenCalledWith([personStub.noName]); + expect(mocks.storage.unlink).toHaveBeenCalledWith(personStub.noName.thumbnailPath); }); }); describe('handleQueueDetectFaces', () => { it('should skip if machine learning is disabled', async () => { - systemMock.get.mockResolvedValue(systemConfigStub.machineLearningDisabled); + mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.machineLearningDisabled); await expect(sut.handleQueueDetectFaces({})).resolves.toBe(JobStatus.SKIPPED); - expect(jobMock.queue).not.toHaveBeenCalled(); - expect(jobMock.queueAll).not.toHaveBeenCalled(); - expect(systemMock.get).toHaveBeenCalled(); + expect(mocks.job.queue).not.toHaveBeenCalled(); + expect(mocks.job.queueAll).not.toHaveBeenCalled(); + expect(mocks.systemMetadata.get).toHaveBeenCalled(); }); it('should queue missing assets', async () => { - assetMock.getWithout.mockResolvedValue({ + mocks.asset.getWithout.mockResolvedValue({ items: [assetStub.image], hasNextPage: false, }); await sut.handleQueueDetectFaces({ force: false }); - expect(assetMock.getWithout).toHaveBeenCalledWith({ skip: 0, take: 1000 }, WithoutProperty.FACES); - expect(jobMock.queueAll).toHaveBeenCalledWith([ + expect(mocks.asset.getWithout).toHaveBeenCalledWith({ skip: 0, take: 1000 }, WithoutProperty.FACES); + expect(mocks.job.queueAll).toHaveBeenCalledWith([ { name: JobName.FACE_DETECTION, data: { id: assetStub.image.id }, @@ -492,19 +467,19 @@ describe(PersonService.name, () => { }); it('should queue all assets', async () => { - assetMock.getAll.mockResolvedValue({ + mocks.asset.getAll.mockResolvedValue({ items: [assetStub.image], hasNextPage: false, }); - personMock.getAllWithoutFaces.mockResolvedValue([personStub.withName]); + mocks.person.getAllWithoutFaces.mockResolvedValue([personStub.withName]); await sut.handleQueueDetectFaces({ force: true }); - expect(personMock.deleteFaces).toHaveBeenCalledWith({ sourceType: SourceType.MACHINE_LEARNING }); - expect(personMock.delete).toHaveBeenCalledWith([personStub.withName]); - expect(storageMock.unlink).toHaveBeenCalledWith(personStub.withName.thumbnailPath); - expect(assetMock.getAll).toHaveBeenCalled(); - expect(jobMock.queueAll).toHaveBeenCalledWith([ + expect(mocks.person.deleteFaces).toHaveBeenCalledWith({ sourceType: SourceType.MACHINE_LEARNING }); + expect(mocks.person.delete).toHaveBeenCalledWith([personStub.withName]); + expect(mocks.storage.unlink).toHaveBeenCalledWith(personStub.withName.thumbnailPath); + expect(mocks.asset.getAll).toHaveBeenCalled(); + expect(mocks.job.queueAll).toHaveBeenCalledWith([ { name: JobName.FACE_DETECTION, data: { id: assetStub.image.id }, @@ -513,127 +488,165 @@ describe(PersonService.name, () => { }); it('should refresh all assets', async () => { - assetMock.getAll.mockResolvedValue({ + mocks.asset.getAll.mockResolvedValue({ items: [assetStub.image], hasNextPage: false, }); await sut.handleQueueDetectFaces({ force: undefined }); - expect(personMock.delete).not.toHaveBeenCalled(); - expect(personMock.deleteFaces).not.toHaveBeenCalled(); - expect(storageMock.unlink).not.toHaveBeenCalled(); - expect(assetMock.getAll).toHaveBeenCalled(); - expect(jobMock.queueAll).toHaveBeenCalledWith([ + expect(mocks.person.delete).not.toHaveBeenCalled(); + expect(mocks.person.deleteFaces).not.toHaveBeenCalled(); + expect(mocks.storage.unlink).not.toHaveBeenCalled(); + expect(mocks.asset.getAll).toHaveBeenCalled(); + expect(mocks.job.queueAll).toHaveBeenCalledWith([ { name: JobName.FACE_DETECTION, data: { id: assetStub.image.id }, }, ]); - expect(jobMock.queue).toHaveBeenCalledWith({ name: JobName.PERSON_CLEANUP }); + expect(mocks.job.queue).toHaveBeenCalledWith({ name: JobName.PERSON_CLEANUP }); }); it('should delete existing people and faces if forced', async () => { - personMock.getAll.mockReturnValue(makeStream([faceStub.face1.person, personStub.randomPerson])); - personMock.getAllFaces.mockReturnValue(makeStream([faceStub.face1])); - assetMock.getAll.mockResolvedValue({ + mocks.person.getAll.mockReturnValue(makeStream([faceStub.face1.person, personStub.randomPerson])); + mocks.person.getAllFaces.mockReturnValue(makeStream([faceStub.face1])); + mocks.asset.getAll.mockResolvedValue({ items: [assetStub.image], hasNextPage: false, }); - personMock.getAllWithoutFaces.mockResolvedValue([personStub.randomPerson]); + mocks.person.getAllWithoutFaces.mockResolvedValue([personStub.randomPerson]); await sut.handleQueueDetectFaces({ force: true }); - expect(assetMock.getAll).toHaveBeenCalled(); - expect(jobMock.queueAll).toHaveBeenCalledWith([ + expect(mocks.asset.getAll).toHaveBeenCalled(); + expect(mocks.job.queueAll).toHaveBeenCalledWith([ { name: JobName.FACE_DETECTION, data: { id: assetStub.image.id }, }, ]); - expect(personMock.delete).toHaveBeenCalledWith([personStub.randomPerson]); - expect(storageMock.unlink).toHaveBeenCalledWith(personStub.randomPerson.thumbnailPath); + expect(mocks.person.delete).toHaveBeenCalledWith([personStub.randomPerson]); + expect(mocks.storage.unlink).toHaveBeenCalledWith(personStub.randomPerson.thumbnailPath); }); }); describe('handleQueueRecognizeFaces', () => { it('should skip if machine learning is disabled', async () => { - jobMock.getJobCounts.mockResolvedValue({ active: 1, waiting: 0, paused: 0, completed: 0, failed: 0, delayed: 0 }); - systemMock.get.mockResolvedValue(systemConfigStub.machineLearningDisabled); + mocks.job.getJobCounts.mockResolvedValue({ + active: 1, + waiting: 0, + paused: 0, + completed: 0, + failed: 0, + delayed: 0, + }); + mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.machineLearningDisabled); await expect(sut.handleQueueRecognizeFaces({})).resolves.toBe(JobStatus.SKIPPED); - expect(jobMock.queueAll).not.toHaveBeenCalled(); - expect(systemMock.get).toHaveBeenCalled(); - expect(systemMock.set).not.toHaveBeenCalled(); + expect(mocks.job.queueAll).not.toHaveBeenCalled(); + expect(mocks.systemMetadata.get).toHaveBeenCalled(); + expect(mocks.systemMetadata.set).not.toHaveBeenCalled(); }); it('should skip if recognition jobs are already queued', async () => { - jobMock.getJobCounts.mockResolvedValue({ active: 1, waiting: 1, paused: 0, completed: 0, failed: 0, delayed: 0 }); + mocks.job.getJobCounts.mockResolvedValue({ + active: 1, + waiting: 1, + paused: 0, + completed: 0, + failed: 0, + delayed: 0, + }); await expect(sut.handleQueueRecognizeFaces({})).resolves.toBe(JobStatus.SKIPPED); - expect(jobMock.queueAll).not.toHaveBeenCalled(); - expect(systemMock.set).not.toHaveBeenCalled(); + expect(mocks.job.queueAll).not.toHaveBeenCalled(); + expect(mocks.systemMetadata.set).not.toHaveBeenCalled(); }); it('should queue missing assets', async () => { - jobMock.getJobCounts.mockResolvedValue({ active: 1, waiting: 0, paused: 0, completed: 0, failed: 0, delayed: 0 }); - personMock.getAllFaces.mockReturnValue(makeStream([faceStub.face1])); - personMock.getAllWithoutFaces.mockResolvedValue([]); + mocks.job.getJobCounts.mockResolvedValue({ + active: 1, + waiting: 0, + paused: 0, + completed: 0, + failed: 0, + delayed: 0, + }); + mocks.person.getAllFaces.mockReturnValue(makeStream([faceStub.face1])); + mocks.person.getAllWithoutFaces.mockResolvedValue([]); await sut.handleQueueRecognizeFaces({}); - expect(personMock.getAllFaces).toHaveBeenCalledWith({ personId: null, sourceType: SourceType.MACHINE_LEARNING }); - expect(jobMock.queueAll).toHaveBeenCalledWith([ + expect(mocks.person.getAllFaces).toHaveBeenCalledWith({ + personId: null, + sourceType: SourceType.MACHINE_LEARNING, + }); + expect(mocks.job.queueAll).toHaveBeenCalledWith([ { name: JobName.FACIAL_RECOGNITION, data: { id: faceStub.face1.id, deferred: false }, }, ]); - expect(systemMock.set).toHaveBeenCalledWith(SystemMetadataKey.FACIAL_RECOGNITION_STATE, { + expect(mocks.systemMetadata.set).toHaveBeenCalledWith(SystemMetadataKey.FACIAL_RECOGNITION_STATE, { lastRun: expect.any(String), }); }); it('should queue all assets', async () => { - jobMock.getJobCounts.mockResolvedValue({ active: 1, waiting: 0, paused: 0, completed: 0, failed: 0, delayed: 0 }); - personMock.getAll.mockReturnValue(makeStream()); - personMock.getAllFaces.mockReturnValue(makeStream([faceStub.face1])); - personMock.getAllWithoutFaces.mockResolvedValue([]); + mocks.job.getJobCounts.mockResolvedValue({ + active: 1, + waiting: 0, + paused: 0, + completed: 0, + failed: 0, + delayed: 0, + }); + mocks.person.getAll.mockReturnValue(makeStream()); + mocks.person.getAllFaces.mockReturnValue(makeStream([faceStub.face1])); + mocks.person.getAllWithoutFaces.mockResolvedValue([]); await sut.handleQueueRecognizeFaces({ force: true }); - expect(personMock.getAllFaces).toHaveBeenCalledWith(undefined); - expect(jobMock.queueAll).toHaveBeenCalledWith([ + expect(mocks.person.getAllFaces).toHaveBeenCalledWith(undefined); + expect(mocks.job.queueAll).toHaveBeenCalledWith([ { name: JobName.FACIAL_RECOGNITION, data: { id: faceStub.face1.id, deferred: false }, }, ]); - expect(systemMock.set).toHaveBeenCalledWith(SystemMetadataKey.FACIAL_RECOGNITION_STATE, { + expect(mocks.systemMetadata.set).toHaveBeenCalledWith(SystemMetadataKey.FACIAL_RECOGNITION_STATE, { lastRun: expect.any(String), }); }); it('should run nightly if new face has been added since last run', async () => { - personMock.getLatestFaceDate.mockResolvedValue(new Date().toISOString()); - personMock.getAllFaces.mockReturnValue(makeStream([faceStub.face1])); - jobMock.getJobCounts.mockResolvedValue({ active: 1, waiting: 0, paused: 0, completed: 0, failed: 0, delayed: 0 }); - personMock.getAll.mockReturnValue(makeStream()); - personMock.getAllFaces.mockReturnValue(makeStream([faceStub.face1])); - personMock.getAllWithoutFaces.mockResolvedValue([]); + mocks.person.getLatestFaceDate.mockResolvedValue(new Date().toISOString()); + mocks.person.getAllFaces.mockReturnValue(makeStream([faceStub.face1])); + mocks.job.getJobCounts.mockResolvedValue({ + active: 1, + waiting: 0, + paused: 0, + completed: 0, + failed: 0, + delayed: 0, + }); + mocks.person.getAll.mockReturnValue(makeStream()); + mocks.person.getAllFaces.mockReturnValue(makeStream([faceStub.face1])); + mocks.person.getAllWithoutFaces.mockResolvedValue([]); await sut.handleQueueRecognizeFaces({ force: true, nightly: true }); - expect(systemMock.get).toHaveBeenCalledWith(SystemMetadataKey.FACIAL_RECOGNITION_STATE); - expect(personMock.getLatestFaceDate).toHaveBeenCalledOnce(); - expect(personMock.getAllFaces).toHaveBeenCalledWith(undefined); - expect(jobMock.queueAll).toHaveBeenCalledWith([ + expect(mocks.systemMetadata.get).toHaveBeenCalledWith(SystemMetadataKey.FACIAL_RECOGNITION_STATE); + expect(mocks.person.getLatestFaceDate).toHaveBeenCalledOnce(); + expect(mocks.person.getAllFaces).toHaveBeenCalledWith(undefined); + expect(mocks.job.queueAll).toHaveBeenCalledWith([ { name: JobName.FACIAL_RECOGNITION, data: { id: faceStub.face1.id, deferred: false }, }, ]); - expect(systemMock.set).toHaveBeenCalledWith(SystemMetadataKey.FACIAL_RECOGNITION_STATE, { + expect(mocks.systemMetadata.set).toHaveBeenCalledWith(SystemMetadataKey.FACIAL_RECOGNITION_STATE, { lastRun: expect.any(String), }); }); @@ -641,62 +654,69 @@ describe(PersonService.name, () => { it('should skip nightly if no new face has been added since last run', async () => { const lastRun = new Date(); - systemMock.get.mockResolvedValue({ lastRun: lastRun.toISOString() }); - personMock.getLatestFaceDate.mockResolvedValue(new Date(lastRun.getTime() - 1).toISOString()); - personMock.getAllFaces.mockReturnValue(makeStream([faceStub.face1])); - personMock.getAllWithoutFaces.mockResolvedValue([]); + mocks.systemMetadata.get.mockResolvedValue({ lastRun: lastRun.toISOString() }); + mocks.person.getLatestFaceDate.mockResolvedValue(new Date(lastRun.getTime() - 1).toISOString()); + mocks.person.getAllFaces.mockReturnValue(makeStream([faceStub.face1])); + mocks.person.getAllWithoutFaces.mockResolvedValue([]); await sut.handleQueueRecognizeFaces({ force: true, nightly: true }); - expect(systemMock.get).toHaveBeenCalledWith(SystemMetadataKey.FACIAL_RECOGNITION_STATE); - expect(personMock.getLatestFaceDate).toHaveBeenCalledOnce(); - expect(personMock.getAllFaces).not.toHaveBeenCalled(); - expect(jobMock.queueAll).not.toHaveBeenCalled(); - expect(systemMock.set).not.toHaveBeenCalled(); + expect(mocks.systemMetadata.get).toHaveBeenCalledWith(SystemMetadataKey.FACIAL_RECOGNITION_STATE); + expect(mocks.person.getLatestFaceDate).toHaveBeenCalledOnce(); + expect(mocks.person.getAllFaces).not.toHaveBeenCalled(); + expect(mocks.job.queueAll).not.toHaveBeenCalled(); + expect(mocks.systemMetadata.set).not.toHaveBeenCalled(); }); it('should delete existing people if forced', async () => { - jobMock.getJobCounts.mockResolvedValue({ active: 1, waiting: 0, paused: 0, completed: 0, failed: 0, delayed: 0 }); - personMock.getAll.mockReturnValue(makeStream([faceStub.face1.person, personStub.randomPerson])); - personMock.getAllFaces.mockReturnValue(makeStream([faceStub.face1])); - personMock.getAllWithoutFaces.mockResolvedValue([personStub.randomPerson]); + mocks.job.getJobCounts.mockResolvedValue({ + active: 1, + waiting: 0, + paused: 0, + completed: 0, + failed: 0, + delayed: 0, + }); + mocks.person.getAll.mockReturnValue(makeStream([faceStub.face1.person, personStub.randomPerson])); + mocks.person.getAllFaces.mockReturnValue(makeStream([faceStub.face1])); + mocks.person.getAllWithoutFaces.mockResolvedValue([personStub.randomPerson]); await sut.handleQueueRecognizeFaces({ force: true }); - expect(personMock.deleteFaces).not.toHaveBeenCalled(); - expect(personMock.unassignFaces).toHaveBeenCalledWith({ sourceType: SourceType.MACHINE_LEARNING }); - expect(jobMock.queueAll).toHaveBeenCalledWith([ + expect(mocks.person.deleteFaces).not.toHaveBeenCalled(); + expect(mocks.person.unassignFaces).toHaveBeenCalledWith({ sourceType: SourceType.MACHINE_LEARNING }); + expect(mocks.job.queueAll).toHaveBeenCalledWith([ { name: JobName.FACIAL_RECOGNITION, data: { id: faceStub.face1.id, deferred: false }, }, ]); - expect(personMock.delete).toHaveBeenCalledWith([personStub.randomPerson]); - expect(storageMock.unlink).toHaveBeenCalledWith(personStub.randomPerson.thumbnailPath); + expect(mocks.person.delete).toHaveBeenCalledWith([personStub.randomPerson]); + expect(mocks.storage.unlink).toHaveBeenCalledWith(personStub.randomPerson.thumbnailPath); }); }); describe('handleDetectFaces', () => { beforeEach(() => { - cryptoMock.randomUUID.mockReturnValue(faceId); + mocks.crypto.randomUUID.mockReturnValue(faceId); }); it('should skip if machine learning is disabled', async () => { - systemMock.get.mockResolvedValue(systemConfigStub.machineLearningDisabled); + mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.machineLearningDisabled); await expect(sut.handleDetectFaces({ id: 'foo' })).resolves.toBe(JobStatus.SKIPPED); - expect(assetMock.getByIds).not.toHaveBeenCalled(); - expect(systemMock.get).toHaveBeenCalled(); + expect(mocks.asset.getByIds).not.toHaveBeenCalled(); + expect(mocks.systemMetadata.get).toHaveBeenCalled(); }); it('should skip when no resize path', async () => { - assetMock.getByIds.mockResolvedValue([assetStub.noResizePath]); + mocks.asset.getByIds.mockResolvedValue([assetStub.noResizePath]); await sut.handleDetectFaces({ id: assetStub.noResizePath.id }); - expect(machineLearningMock.detectFaces).not.toHaveBeenCalled(); + expect(mocks.machineLearning.detectFaces).not.toHaveBeenCalled(); }); it('should skip it the asset has already been processed', async () => { - assetMock.getByIds.mockResolvedValue([ + mocks.asset.getByIds.mockResolvedValue([ { ...assetStub.noResizePath, faces: [ @@ -709,103 +729,103 @@ describe(PersonService.name, () => { }, ]); await sut.handleDetectFaces({ id: assetStub.noResizePath.id }); - expect(machineLearningMock.detectFaces).not.toHaveBeenCalled(); + expect(mocks.machineLearning.detectFaces).not.toHaveBeenCalled(); }); it('should handle no results', async () => { const start = Date.now(); - machineLearningMock.detectFaces.mockResolvedValue({ imageHeight: 500, imageWidth: 400, faces: [] }); - assetMock.getByIds.mockResolvedValue([assetStub.image]); + mocks.machineLearning.detectFaces.mockResolvedValue({ imageHeight: 500, imageWidth: 400, faces: [] }); + mocks.asset.getByIds.mockResolvedValue([assetStub.image]); await sut.handleDetectFaces({ id: assetStub.image.id }); - expect(machineLearningMock.detectFaces).toHaveBeenCalledWith( + expect(mocks.machineLearning.detectFaces).toHaveBeenCalledWith( ['http://immich-machine-learning:3003'], '/uploads/user-id/thumbs/path.jpg', expect.objectContaining({ minScore: 0.7, modelName: 'buffalo_l' }), ); - expect(jobMock.queue).not.toHaveBeenCalled(); - expect(jobMock.queueAll).not.toHaveBeenCalled(); + expect(mocks.job.queue).not.toHaveBeenCalled(); + expect(mocks.job.queueAll).not.toHaveBeenCalled(); - expect(assetMock.upsertJobStatus).toHaveBeenCalledWith({ + expect(mocks.asset.upsertJobStatus).toHaveBeenCalledWith({ assetId: assetStub.image.id, facesRecognizedAt: expect.any(Date), }); - const facesRecognizedAt = assetMock.upsertJobStatus.mock.calls[0][0].facesRecognizedAt as Date; + const facesRecognizedAt = mocks.asset.upsertJobStatus.mock.calls[0][0].facesRecognizedAt as Date; expect(facesRecognizedAt.getTime()).toBeGreaterThan(start); }); it('should create a face with no person and queue recognition job', async () => { - machineLearningMock.detectFaces.mockResolvedValue(detectFaceMock); - searchMock.searchFaces.mockResolvedValue([{ ...faceStub.face1, distance: 0.7 }]); - assetMock.getByIds.mockResolvedValue([assetStub.image]); + mocks.machineLearning.detectFaces.mockResolvedValue(detectFaceMock); + mocks.search.searchFaces.mockResolvedValue([{ ...faceStub.face1, distance: 0.7 }]); + mocks.asset.getByIds.mockResolvedValue([assetStub.image]); await sut.handleDetectFaces({ id: assetStub.image.id }); - expect(personMock.refreshFaces).toHaveBeenCalledWith([face], [], [faceSearch]); - expect(jobMock.queueAll).toHaveBeenCalledWith([ + expect(mocks.person.refreshFaces).toHaveBeenCalledWith([face], [], [faceSearch]); + expect(mocks.job.queueAll).toHaveBeenCalledWith([ { name: JobName.QUEUE_FACIAL_RECOGNITION, data: { force: false } }, { name: JobName.FACIAL_RECOGNITION, data: { id: faceId } }, ]); - expect(personMock.reassignFace).not.toHaveBeenCalled(); - expect(personMock.reassignFaces).not.toHaveBeenCalled(); + expect(mocks.person.reassignFace).not.toHaveBeenCalled(); + expect(mocks.person.reassignFaces).not.toHaveBeenCalled(); }); it('should delete an existing face not among the new detected faces', async () => { - machineLearningMock.detectFaces.mockResolvedValue({ faces: [], imageHeight: 500, imageWidth: 400 }); - assetMock.getByIds.mockResolvedValue([{ ...assetStub.image, faces: [faceStub.primaryFace1] }]); + mocks.machineLearning.detectFaces.mockResolvedValue({ faces: [], imageHeight: 500, imageWidth: 400 }); + mocks.asset.getByIds.mockResolvedValue([{ ...assetStub.image, faces: [faceStub.primaryFace1] }]); await sut.handleDetectFaces({ id: assetStub.image.id }); - expect(personMock.refreshFaces).toHaveBeenCalledWith([], [faceStub.primaryFace1.id], []); - expect(jobMock.queueAll).not.toHaveBeenCalled(); - expect(personMock.reassignFace).not.toHaveBeenCalled(); - expect(personMock.reassignFaces).not.toHaveBeenCalled(); + expect(mocks.person.refreshFaces).toHaveBeenCalledWith([], [faceStub.primaryFace1.id], []); + expect(mocks.job.queueAll).not.toHaveBeenCalled(); + expect(mocks.person.reassignFace).not.toHaveBeenCalled(); + expect(mocks.person.reassignFaces).not.toHaveBeenCalled(); }); it('should add new face and delete an existing face not among the new detected faces', async () => { - machineLearningMock.detectFaces.mockResolvedValue(detectFaceMock); - assetMock.getByIds.mockResolvedValue([{ ...assetStub.image, faces: [faceStub.primaryFace1] }]); + mocks.machineLearning.detectFaces.mockResolvedValue(detectFaceMock); + mocks.asset.getByIds.mockResolvedValue([{ ...assetStub.image, faces: [faceStub.primaryFace1] }]); await sut.handleDetectFaces({ id: assetStub.image.id }); - expect(personMock.refreshFaces).toHaveBeenCalledWith([face], [faceStub.primaryFace1.id], [faceSearch]); - expect(jobMock.queueAll).toHaveBeenCalledWith([ + expect(mocks.person.refreshFaces).toHaveBeenCalledWith([face], [faceStub.primaryFace1.id], [faceSearch]); + expect(mocks.job.queueAll).toHaveBeenCalledWith([ { name: JobName.QUEUE_FACIAL_RECOGNITION, data: { force: false } }, { name: JobName.FACIAL_RECOGNITION, data: { id: faceId } }, ]); - expect(personMock.reassignFace).not.toHaveBeenCalled(); - expect(personMock.reassignFaces).not.toHaveBeenCalled(); + expect(mocks.person.reassignFace).not.toHaveBeenCalled(); + expect(mocks.person.reassignFaces).not.toHaveBeenCalled(); }); it('should add embedding to matching metadata face', async () => { - machineLearningMock.detectFaces.mockResolvedValue(detectFaceMock); - assetMock.getByIds.mockResolvedValue([{ ...assetStub.image, faces: [faceStub.fromExif1] }]); + mocks.machineLearning.detectFaces.mockResolvedValue(detectFaceMock); + mocks.asset.getByIds.mockResolvedValue([{ ...assetStub.image, faces: [faceStub.fromExif1] }]); await sut.handleDetectFaces({ id: assetStub.image.id }); - expect(personMock.refreshFaces).toHaveBeenCalledWith( + expect(mocks.person.refreshFaces).toHaveBeenCalledWith( [], [], [{ faceId: faceStub.fromExif1.id, embedding: faceSearch.embedding }], ); - expect(jobMock.queueAll).not.toHaveBeenCalled(); - expect(personMock.reassignFace).not.toHaveBeenCalled(); - expect(personMock.reassignFaces).not.toHaveBeenCalled(); + expect(mocks.job.queueAll).not.toHaveBeenCalled(); + expect(mocks.person.reassignFace).not.toHaveBeenCalled(); + expect(mocks.person.reassignFaces).not.toHaveBeenCalled(); }); it('should not add embedding to non-matching metadata face', async () => { - machineLearningMock.detectFaces.mockResolvedValue(detectFaceMock); - assetMock.getByIds.mockResolvedValue([{ ...assetStub.image, faces: [faceStub.fromExif2] }]); + mocks.machineLearning.detectFaces.mockResolvedValue(detectFaceMock); + mocks.asset.getByIds.mockResolvedValue([{ ...assetStub.image, faces: [faceStub.fromExif2] }]); await sut.handleDetectFaces({ id: assetStub.image.id }); - expect(personMock.refreshFaces).toHaveBeenCalledWith([face], [], [faceSearch]); - expect(jobMock.queueAll).toHaveBeenCalledWith([ + expect(mocks.person.refreshFaces).toHaveBeenCalledWith([face], [], [faceSearch]); + expect(mocks.job.queueAll).toHaveBeenCalledWith([ { name: JobName.QUEUE_FACIAL_RECOGNITION, data: { force: false } }, { name: JobName.FACIAL_RECOGNITION, data: { id: faceId } }, ]); - expect(personMock.reassignFace).not.toHaveBeenCalled(); - expect(personMock.reassignFaces).not.toHaveBeenCalled(); + expect(mocks.person.reassignFace).not.toHaveBeenCalled(); + expect(mocks.person.reassignFaces).not.toHaveBeenCalled(); }); }); @@ -813,27 +833,27 @@ describe(PersonService.name, () => { it('should fail if face does not exist', async () => { expect(await sut.handleRecognizeFaces({ id: faceStub.face1.id })).toBe(JobStatus.FAILED); - expect(personMock.reassignFaces).not.toHaveBeenCalled(); - expect(personMock.create).not.toHaveBeenCalled(); + expect(mocks.person.reassignFaces).not.toHaveBeenCalled(); + expect(mocks.person.create).not.toHaveBeenCalled(); }); it('should fail if face does not have asset', async () => { const face = { ...faceStub.face1, asset: null } as AssetFaceEntity & { asset: null }; - personMock.getFaceByIdWithAssets.mockResolvedValue(face); + mocks.person.getFaceByIdWithAssets.mockResolvedValue(face); expect(await sut.handleRecognizeFaces({ id: faceStub.face1.id })).toBe(JobStatus.FAILED); - expect(personMock.reassignFaces).not.toHaveBeenCalled(); - expect(personMock.create).not.toHaveBeenCalled(); + expect(mocks.person.reassignFaces).not.toHaveBeenCalled(); + expect(mocks.person.create).not.toHaveBeenCalled(); }); it('should skip if face already has an assigned person', async () => { - personMock.getFaceByIdWithAssets.mockResolvedValue(faceStub.face1); + mocks.person.getFaceByIdWithAssets.mockResolvedValue(faceStub.face1); expect(await sut.handleRecognizeFaces({ id: faceStub.face1.id })).toBe(JobStatus.SKIPPED); - expect(personMock.reassignFaces).not.toHaveBeenCalled(); - expect(personMock.create).not.toHaveBeenCalled(); + expect(mocks.person.reassignFaces).not.toHaveBeenCalled(); + expect(mocks.person.create).not.toHaveBeenCalled(); }); it('should match existing person', async () => { @@ -848,20 +868,20 @@ describe(PersonService.name, () => { { ...faceStub.face1, distance: 0.4 }, ] as FaceSearchResult[]; - systemMock.get.mockResolvedValue({ machineLearning: { facialRecognition: { minFaces: 1 } } }); - searchMock.searchFaces.mockResolvedValue(faces); - personMock.getFaceByIdWithAssets.mockResolvedValue(faceStub.noPerson1); - personMock.create.mockResolvedValue(faceStub.primaryFace1.person); + mocks.systemMetadata.get.mockResolvedValue({ machineLearning: { facialRecognition: { minFaces: 1 } } }); + mocks.search.searchFaces.mockResolvedValue(faces); + mocks.person.getFaceByIdWithAssets.mockResolvedValue(faceStub.noPerson1); + mocks.person.create.mockResolvedValue(faceStub.primaryFace1.person); await sut.handleRecognizeFaces({ id: faceStub.noPerson1.id }); - expect(personMock.create).not.toHaveBeenCalled(); - expect(personMock.reassignFaces).toHaveBeenCalledTimes(1); - expect(personMock.reassignFaces).toHaveBeenCalledWith({ + expect(mocks.person.create).not.toHaveBeenCalled(); + expect(mocks.person.reassignFaces).toHaveBeenCalledTimes(1); + expect(mocks.person.reassignFaces).toHaveBeenCalledWith({ faceIds: expect.arrayContaining([faceStub.noPerson1.id]), newPersonId: faceStub.primaryFace1.person.id, }); - expect(personMock.reassignFaces).toHaveBeenCalledWith({ + expect(mocks.person.reassignFaces).toHaveBeenCalledWith({ faceIds: expect.not.arrayContaining([faceStub.face1.id]), newPersonId: faceStub.primaryFace1.person.id, }); @@ -873,18 +893,18 @@ describe(PersonService.name, () => { { ...faceStub.noPerson2, distance: 0.3 }, ] as FaceSearchResult[]; - systemMock.get.mockResolvedValue({ machineLearning: { facialRecognition: { minFaces: 1 } } }); - searchMock.searchFaces.mockResolvedValue(faces); - personMock.getFaceByIdWithAssets.mockResolvedValue(faceStub.noPerson1); - personMock.create.mockResolvedValue(personStub.withName); + mocks.systemMetadata.get.mockResolvedValue({ machineLearning: { facialRecognition: { minFaces: 1 } } }); + mocks.search.searchFaces.mockResolvedValue(faces); + mocks.person.getFaceByIdWithAssets.mockResolvedValue(faceStub.noPerson1); + mocks.person.create.mockResolvedValue(personStub.withName); await sut.handleRecognizeFaces({ id: faceStub.noPerson1.id }); - expect(personMock.create).toHaveBeenCalledWith({ + expect(mocks.person.create).toHaveBeenCalledWith({ ownerId: faceStub.noPerson1.asset.ownerId, faceAssetId: faceStub.noPerson1.id, }); - expect(personMock.reassignFaces).toHaveBeenCalledWith({ + expect(mocks.person.reassignFaces).toHaveBeenCalledWith({ faceIds: [faceStub.noPerson1.id], newPersonId: personStub.withName.id, }); @@ -893,16 +913,16 @@ describe(PersonService.name, () => { it('should not queue face with no matches', async () => { const faces = [{ ...faceStub.noPerson1, distance: 0 }] as FaceSearchResult[]; - searchMock.searchFaces.mockResolvedValue(faces); - personMock.getFaceByIdWithAssets.mockResolvedValue(faceStub.noPerson1); - personMock.create.mockResolvedValue(personStub.withName); + mocks.search.searchFaces.mockResolvedValue(faces); + mocks.person.getFaceByIdWithAssets.mockResolvedValue(faceStub.noPerson1); + mocks.person.create.mockResolvedValue(personStub.withName); await sut.handleRecognizeFaces({ id: faceStub.noPerson1.id }); - expect(jobMock.queue).not.toHaveBeenCalled(); - expect(searchMock.searchFaces).toHaveBeenCalledTimes(1); - expect(personMock.create).not.toHaveBeenCalled(); - expect(personMock.reassignFaces).not.toHaveBeenCalled(); + expect(mocks.job.queue).not.toHaveBeenCalled(); + expect(mocks.search.searchFaces).toHaveBeenCalledTimes(1); + expect(mocks.person.create).not.toHaveBeenCalled(); + expect(mocks.person.reassignFaces).not.toHaveBeenCalled(); }); it('should defer non-core faces to end of queue', async () => { @@ -911,20 +931,20 @@ describe(PersonService.name, () => { { ...faceStub.noPerson2, distance: 0.4 }, ] as FaceSearchResult[]; - systemMock.get.mockResolvedValue({ machineLearning: { facialRecognition: { minFaces: 3 } } }); - searchMock.searchFaces.mockResolvedValue(faces); - personMock.getFaceByIdWithAssets.mockResolvedValue(faceStub.noPerson1); - personMock.create.mockResolvedValue(personStub.withName); + mocks.systemMetadata.get.mockResolvedValue({ machineLearning: { facialRecognition: { minFaces: 3 } } }); + mocks.search.searchFaces.mockResolvedValue(faces); + mocks.person.getFaceByIdWithAssets.mockResolvedValue(faceStub.noPerson1); + mocks.person.create.mockResolvedValue(personStub.withName); await sut.handleRecognizeFaces({ id: faceStub.noPerson1.id }); - expect(jobMock.queue).toHaveBeenCalledWith({ + expect(mocks.job.queue).toHaveBeenCalledWith({ name: JobName.FACIAL_RECOGNITION, data: { id: faceStub.noPerson1.id, deferred: true }, }); - expect(searchMock.searchFaces).toHaveBeenCalledTimes(1); - expect(personMock.create).not.toHaveBeenCalled(); - expect(personMock.reassignFaces).not.toHaveBeenCalled(); + expect(mocks.search.searchFaces).toHaveBeenCalledTimes(1); + expect(mocks.person.create).not.toHaveBeenCalled(); + expect(mocks.person.reassignFaces).not.toHaveBeenCalled(); }); it('should not assign person to deferred non-core face with no matching person', async () => { @@ -933,66 +953,66 @@ describe(PersonService.name, () => { { ...faceStub.noPerson2, distance: 0.4 }, ] as FaceSearchResult[]; - systemMock.get.mockResolvedValue({ machineLearning: { facialRecognition: { minFaces: 3 } } }); - searchMock.searchFaces.mockResolvedValueOnce(faces).mockResolvedValueOnce([]); - personMock.getFaceByIdWithAssets.mockResolvedValue(faceStub.noPerson1); - personMock.create.mockResolvedValue(personStub.withName); + mocks.systemMetadata.get.mockResolvedValue({ machineLearning: { facialRecognition: { minFaces: 3 } } }); + mocks.search.searchFaces.mockResolvedValueOnce(faces).mockResolvedValueOnce([]); + mocks.person.getFaceByIdWithAssets.mockResolvedValue(faceStub.noPerson1); + mocks.person.create.mockResolvedValue(personStub.withName); await sut.handleRecognizeFaces({ id: faceStub.noPerson1.id, deferred: true }); - expect(jobMock.queue).not.toHaveBeenCalled(); - expect(searchMock.searchFaces).toHaveBeenCalledTimes(2); - expect(personMock.create).not.toHaveBeenCalled(); - expect(personMock.reassignFaces).not.toHaveBeenCalled(); + expect(mocks.job.queue).not.toHaveBeenCalled(); + expect(mocks.search.searchFaces).toHaveBeenCalledTimes(2); + expect(mocks.person.create).not.toHaveBeenCalled(); + expect(mocks.person.reassignFaces).not.toHaveBeenCalled(); }); }); describe('handleGeneratePersonThumbnail', () => { it('should skip if machine learning is disabled', async () => { - systemMock.get.mockResolvedValue(systemConfigStub.machineLearningDisabled); + mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.machineLearningDisabled); await expect(sut.handleGeneratePersonThumbnail({ id: 'person-1' })).resolves.toBe(JobStatus.SKIPPED); - expect(assetMock.getByIds).not.toHaveBeenCalled(); - expect(systemMock.get).toHaveBeenCalled(); + expect(mocks.asset.getByIds).not.toHaveBeenCalled(); + expect(mocks.systemMetadata.get).toHaveBeenCalled(); }); it('should skip a person not found', async () => { - personMock.getById.mockResolvedValue(null); + mocks.person.getById.mockResolvedValue(null); await sut.handleGeneratePersonThumbnail({ id: 'person-1' }); - expect(mediaMock.generateThumbnail).not.toHaveBeenCalled(); + expect(mocks.media.generateThumbnail).not.toHaveBeenCalled(); }); it('should skip a person without a face asset id', async () => { - personMock.getById.mockResolvedValue(personStub.noThumbnail); + mocks.person.getById.mockResolvedValue(personStub.noThumbnail); await sut.handleGeneratePersonThumbnail({ id: 'person-1' }); - expect(mediaMock.generateThumbnail).not.toHaveBeenCalled(); + expect(mocks.media.generateThumbnail).not.toHaveBeenCalled(); }); it('should skip a person with a face asset id not found', async () => { - personMock.getById.mockResolvedValue({ ...personStub.primaryPerson, faceAssetId: faceStub.middle.id }); - personMock.getFaceByIdWithAssets.mockResolvedValue(faceStub.face1); + mocks.person.getById.mockResolvedValue({ ...personStub.primaryPerson, faceAssetId: faceStub.middle.id }); + mocks.person.getFaceByIdWithAssets.mockResolvedValue(faceStub.face1); await sut.handleGeneratePersonThumbnail({ id: 'person-1' }); - expect(mediaMock.generateThumbnail).not.toHaveBeenCalled(); + expect(mocks.media.generateThumbnail).not.toHaveBeenCalled(); }); it('should skip a person with a face asset id without a thumbnail', async () => { - personMock.getById.mockResolvedValue({ ...personStub.primaryPerson, faceAssetId: faceStub.middle.assetId }); - personMock.getFaceByIdWithAssets.mockResolvedValue(faceStub.face1); - assetMock.getByIds.mockResolvedValue([assetStub.noResizePath]); + mocks.person.getById.mockResolvedValue({ ...personStub.primaryPerson, faceAssetId: faceStub.middle.assetId }); + mocks.person.getFaceByIdWithAssets.mockResolvedValue(faceStub.face1); + mocks.asset.getByIds.mockResolvedValue([assetStub.noResizePath]); await sut.handleGeneratePersonThumbnail({ id: 'person-1' }); - expect(mediaMock.generateThumbnail).not.toHaveBeenCalled(); + expect(mocks.media.generateThumbnail).not.toHaveBeenCalled(); }); it('should generate a thumbnail', async () => { - personMock.getById.mockResolvedValue({ ...personStub.primaryPerson, faceAssetId: faceStub.middle.assetId }); - personMock.getFaceByIdWithAssets.mockResolvedValue(faceStub.middle); - assetMock.getById.mockResolvedValue(assetStub.primaryImage); + mocks.person.getById.mockResolvedValue({ ...personStub.primaryPerson, faceAssetId: faceStub.middle.assetId }); + mocks.person.getFaceByIdWithAssets.mockResolvedValue(faceStub.middle); + mocks.asset.getById.mockResolvedValue(assetStub.primaryImage); await sut.handleGeneratePersonThumbnail({ id: personStub.primaryPerson.id }); - expect(assetMock.getById).toHaveBeenCalledWith(faceStub.middle.assetId, { exifInfo: true, files: true }); - expect(storageMock.mkdirSync).toHaveBeenCalledWith('upload/thumbs/admin_id/pe/rs'); - expect(mediaMock.generateThumbnail).toHaveBeenCalledWith( + expect(mocks.asset.getById).toHaveBeenCalledWith(faceStub.middle.assetId, { exifInfo: true, files: true }); + expect(mocks.storage.mkdirSync).toHaveBeenCalledWith('upload/thumbs/admin_id/pe/rs'); + expect(mocks.media.generateThumbnail).toHaveBeenCalledWith( assetStub.primaryImage.originalPath, { colorspace: Colorspace.P3, @@ -1009,20 +1029,20 @@ describe(PersonService.name, () => { }, 'upload/thumbs/admin_id/pe/rs/person-1.jpeg', ); - expect(personMock.update).toHaveBeenCalledWith({ + expect(mocks.person.update).toHaveBeenCalledWith({ id: 'person-1', thumbnailPath: 'upload/thumbs/admin_id/pe/rs/person-1.jpeg', }); }); it('should generate a thumbnail without going negative', async () => { - personMock.getById.mockResolvedValue({ ...personStub.primaryPerson, faceAssetId: faceStub.start.assetId }); - personMock.getFaceByIdWithAssets.mockResolvedValue(faceStub.start); - assetMock.getById.mockResolvedValue(assetStub.image); + mocks.person.getById.mockResolvedValue({ ...personStub.primaryPerson, faceAssetId: faceStub.start.assetId }); + mocks.person.getFaceByIdWithAssets.mockResolvedValue(faceStub.start); + mocks.asset.getById.mockResolvedValue(assetStub.image); await sut.handleGeneratePersonThumbnail({ id: personStub.primaryPerson.id }); - expect(mediaMock.generateThumbnail).toHaveBeenCalledWith( + expect(mocks.media.generateThumbnail).toHaveBeenCalledWith( assetStub.primaryImage.originalPath, { colorspace: Colorspace.P3, @@ -1042,13 +1062,13 @@ describe(PersonService.name, () => { }); it('should generate a thumbnail without overflowing', async () => { - personMock.getById.mockResolvedValue({ ...personStub.primaryPerson, faceAssetId: faceStub.end.assetId }); - personMock.getFaceByIdWithAssets.mockResolvedValue(faceStub.end); - assetMock.getById.mockResolvedValue(assetStub.primaryImage); + mocks.person.getById.mockResolvedValue({ ...personStub.primaryPerson, faceAssetId: faceStub.end.assetId }); + mocks.person.getFaceByIdWithAssets.mockResolvedValue(faceStub.end); + mocks.asset.getById.mockResolvedValue(assetStub.primaryImage); await sut.handleGeneratePersonThumbnail({ id: personStub.primaryPerson.id }); - expect(mediaMock.generateThumbnail).toHaveBeenCalledWith( + expect(mocks.media.generateThumbnail).toHaveBeenCalledWith( assetStub.primaryImage.originalPath, { colorspace: Colorspace.P3, @@ -1070,117 +1090,117 @@ describe(PersonService.name, () => { describe('mergePerson', () => { it('should require person.write and person.merge permission', async () => { - personMock.getById.mockResolvedValueOnce(personStub.primaryPerson); - personMock.getById.mockResolvedValueOnce(personStub.mergePerson); + mocks.person.getById.mockResolvedValueOnce(personStub.primaryPerson); + mocks.person.getById.mockResolvedValueOnce(personStub.mergePerson); await expect(sut.mergePerson(authStub.admin, 'person-1', { ids: ['person-2'] })).rejects.toBeInstanceOf( BadRequestException, ); - expect(personMock.reassignFaces).not.toHaveBeenCalled(); + expect(mocks.person.reassignFaces).not.toHaveBeenCalled(); - expect(personMock.delete).not.toHaveBeenCalled(); - expect(accessMock.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['person-1'])); + expect(mocks.person.delete).not.toHaveBeenCalled(); + expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['person-1'])); }); it('should merge two people without smart merge', async () => { - personMock.getById.mockResolvedValueOnce(personStub.primaryPerson); - personMock.getById.mockResolvedValueOnce(personStub.mergePerson); - accessMock.person.checkOwnerAccess.mockResolvedValueOnce(new Set(['person-1'])); - accessMock.person.checkOwnerAccess.mockResolvedValueOnce(new Set(['person-2'])); + mocks.person.getById.mockResolvedValueOnce(personStub.primaryPerson); + mocks.person.getById.mockResolvedValueOnce(personStub.mergePerson); + mocks.access.person.checkOwnerAccess.mockResolvedValueOnce(new Set(['person-1'])); + mocks.access.person.checkOwnerAccess.mockResolvedValueOnce(new Set(['person-2'])); await expect(sut.mergePerson(authStub.admin, 'person-1', { ids: ['person-2'] })).resolves.toEqual([ { id: 'person-2', success: true }, ]); - expect(personMock.reassignFaces).toHaveBeenCalledWith({ + expect(mocks.person.reassignFaces).toHaveBeenCalledWith({ newPersonId: personStub.primaryPerson.id, oldPersonId: personStub.mergePerson.id, }); - expect(accessMock.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['person-1'])); + expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['person-1'])); }); it('should merge two people with smart merge', async () => { - personMock.getById.mockResolvedValueOnce(personStub.randomPerson); - personMock.getById.mockResolvedValueOnce(personStub.primaryPerson); - personMock.update.mockResolvedValue({ ...personStub.randomPerson, name: personStub.primaryPerson.name }); - accessMock.person.checkOwnerAccess.mockResolvedValueOnce(new Set(['person-3'])); - accessMock.person.checkOwnerAccess.mockResolvedValueOnce(new Set(['person-1'])); + mocks.person.getById.mockResolvedValueOnce(personStub.randomPerson); + mocks.person.getById.mockResolvedValueOnce(personStub.primaryPerson); + mocks.person.update.mockResolvedValue({ ...personStub.randomPerson, name: personStub.primaryPerson.name }); + mocks.access.person.checkOwnerAccess.mockResolvedValueOnce(new Set(['person-3'])); + mocks.access.person.checkOwnerAccess.mockResolvedValueOnce(new Set(['person-1'])); await expect(sut.mergePerson(authStub.admin, 'person-3', { ids: ['person-1'] })).resolves.toEqual([ { id: 'person-1', success: true }, ]); - expect(personMock.reassignFaces).toHaveBeenCalledWith({ + expect(mocks.person.reassignFaces).toHaveBeenCalledWith({ newPersonId: personStub.randomPerson.id, oldPersonId: personStub.primaryPerson.id, }); - expect(personMock.update).toHaveBeenCalledWith({ + expect(mocks.person.update).toHaveBeenCalledWith({ id: personStub.randomPerson.id, name: personStub.primaryPerson.name, }); - expect(accessMock.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['person-1'])); + expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['person-1'])); }); it('should throw an error when the primary person is not found', async () => { - personMock.getById.mockResolvedValue(null); - accessMock.person.checkOwnerAccess.mockResolvedValue(new Set(['person-1'])); + mocks.person.getById.mockResolvedValue(null); + mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set(['person-1'])); await expect(sut.mergePerson(authStub.admin, 'person-1', { ids: ['person-2'] })).rejects.toBeInstanceOf( BadRequestException, ); - expect(personMock.delete).not.toHaveBeenCalled(); - expect(accessMock.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['person-1'])); + expect(mocks.person.delete).not.toHaveBeenCalled(); + expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['person-1'])); }); it('should handle invalid merge ids', async () => { - personMock.getById.mockResolvedValueOnce(personStub.primaryPerson); - personMock.getById.mockResolvedValueOnce(null); - accessMock.person.checkOwnerAccess.mockResolvedValueOnce(new Set(['person-1'])); - accessMock.person.checkOwnerAccess.mockResolvedValueOnce(new Set(['person-2'])); + mocks.person.getById.mockResolvedValueOnce(personStub.primaryPerson); + mocks.person.getById.mockResolvedValueOnce(null); + mocks.access.person.checkOwnerAccess.mockResolvedValueOnce(new Set(['person-1'])); + mocks.access.person.checkOwnerAccess.mockResolvedValueOnce(new Set(['person-2'])); await expect(sut.mergePerson(authStub.admin, 'person-1', { ids: ['person-2'] })).resolves.toEqual([ { id: 'person-2', success: false, error: BulkIdErrorReason.NOT_FOUND }, ]); - expect(personMock.reassignFaces).not.toHaveBeenCalled(); - expect(personMock.delete).not.toHaveBeenCalled(); - expect(accessMock.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['person-1'])); + expect(mocks.person.reassignFaces).not.toHaveBeenCalled(); + expect(mocks.person.delete).not.toHaveBeenCalled(); + expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['person-1'])); }); it('should handle an error reassigning faces', async () => { - personMock.getById.mockResolvedValueOnce(personStub.primaryPerson); - personMock.getById.mockResolvedValueOnce(personStub.mergePerson); - personMock.reassignFaces.mockRejectedValue(new Error('update failed')); - accessMock.person.checkOwnerAccess.mockResolvedValueOnce(new Set(['person-1'])); - accessMock.person.checkOwnerAccess.mockResolvedValueOnce(new Set(['person-2'])); + mocks.person.getById.mockResolvedValueOnce(personStub.primaryPerson); + mocks.person.getById.mockResolvedValueOnce(personStub.mergePerson); + mocks.person.reassignFaces.mockRejectedValue(new Error('update failed')); + mocks.access.person.checkOwnerAccess.mockResolvedValueOnce(new Set(['person-1'])); + mocks.access.person.checkOwnerAccess.mockResolvedValueOnce(new Set(['person-2'])); await expect(sut.mergePerson(authStub.admin, 'person-1', { ids: ['person-2'] })).resolves.toEqual([ { id: 'person-2', success: false, error: BulkIdErrorReason.UNKNOWN }, ]); - expect(personMock.delete).not.toHaveBeenCalled(); - expect(accessMock.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['person-1'])); + expect(mocks.person.delete).not.toHaveBeenCalled(); + expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['person-1'])); }); }); describe('getStatistics', () => { it('should get correct number of person', async () => { - personMock.getById.mockResolvedValue(personStub.primaryPerson); - personMock.getStatistics.mockResolvedValue(statistics); - accessMock.person.checkOwnerAccess.mockResolvedValue(new Set(['person-1'])); + mocks.person.getById.mockResolvedValue(personStub.primaryPerson); + mocks.person.getStatistics.mockResolvedValue(statistics); + mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set(['person-1'])); await expect(sut.getStatistics(authStub.admin, 'person-1')).resolves.toEqual({ assets: 3 }); - expect(accessMock.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['person-1'])); + expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['person-1'])); }); it('should require person.read permission', async () => { - personMock.getById.mockResolvedValue(personStub.primaryPerson); + mocks.person.getById.mockResolvedValue(personStub.primaryPerson); await expect(sut.getStatistics(authStub.admin, 'person-1')).rejects.toBeInstanceOf(BadRequestException); - expect(accessMock.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['person-1'])); + expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['person-1'])); }); }); diff --git a/server/src/services/search.service.spec.ts b/server/src/services/search.service.spec.ts index 9f16ddf82d..34f4c7b39f 100644 --- a/server/src/services/search.service.spec.ts +++ b/server/src/services/search.service.spec.ts @@ -1,26 +1,20 @@ import { mapAsset } from 'src/dtos/asset-response.dto'; import { SearchSuggestionType } from 'src/dtos/search.dto'; -import { IAssetRepository } from 'src/interfaces/asset.interface'; -import { IPersonRepository } from 'src/interfaces/person.interface'; -import { ISearchRepository } from 'src/interfaces/search.interface'; import { SearchService } from 'src/services/search.service'; import { assetStub } from 'test/fixtures/asset.stub'; import { authStub } from 'test/fixtures/auth.stub'; import { personStub } from 'test/fixtures/person.stub'; -import { newTestService } from 'test/utils'; -import { Mocked, beforeEach, vitest } from 'vitest'; +import { newTestService, ServiceMocks } from 'test/utils'; +import { beforeEach, vitest } from 'vitest'; vitest.useFakeTimers(); describe(SearchService.name, () => { let sut: SearchService; - - let assetMock: Mocked; - let personMock: Mocked; - let searchMock: Mocked; + let mocks: ServiceMocks; beforeEach(() => { - ({ sut, assetMock, personMock, searchMock } = newTestService(SearchService)); + ({ sut, mocks } = newTestService(SearchService)); }); it('should work', () => { @@ -31,25 +25,25 @@ describe(SearchService.name, () => { it('should pass options to search', async () => { const { name } = personStub.withName; - personMock.getByName.mockResolvedValue([]); + mocks.person.getByName.mockResolvedValue([]); await sut.searchPerson(authStub.user1, { name, withHidden: false }); - expect(personMock.getByName).toHaveBeenCalledWith(authStub.user1.user.id, name, { withHidden: false }); + expect(mocks.person.getByName).toHaveBeenCalledWith(authStub.user1.user.id, name, { withHidden: false }); await sut.searchPerson(authStub.user1, { name, withHidden: true }); - expect(personMock.getByName).toHaveBeenCalledWith(authStub.user1.user.id, name, { withHidden: true }); + expect(mocks.person.getByName).toHaveBeenCalledWith(authStub.user1.user.id, name, { withHidden: true }); }); }); describe('getExploreData', () => { it('should get assets by city and tag', async () => { - assetMock.getAssetIdByCity.mockResolvedValue({ + mocks.asset.getAssetIdByCity.mockResolvedValue({ fieldName: 'exifInfo.city', items: [{ value: 'test-city', data: assetStub.withLocation.id }], }); - assetMock.getByIdsWithAllRelations.mockResolvedValue([assetStub.withLocation]); + mocks.asset.getByIdsWithAllRelations.mockResolvedValue([assetStub.withLocation]); const expectedResponse = [ { fieldName: 'exifInfo.city', items: [{ value: 'test-city', data: mapAsset(assetStub.withLocation) }] }, ]; @@ -62,83 +56,83 @@ describe(SearchService.name, () => { describe('getSearchSuggestions', () => { it('should return search suggestions for country', async () => { - searchMock.getCountries.mockResolvedValue(['USA']); + mocks.search.getCountries.mockResolvedValue(['USA']); await expect( sut.getSearchSuggestions(authStub.user1, { includeNull: false, type: SearchSuggestionType.COUNTRY }), ).resolves.toEqual(['USA']); - expect(searchMock.getCountries).toHaveBeenCalledWith([authStub.user1.user.id]); + expect(mocks.search.getCountries).toHaveBeenCalledWith([authStub.user1.user.id]); }); it('should return search suggestions for country (including null)', async () => { - searchMock.getCountries.mockResolvedValue(['USA']); + mocks.search.getCountries.mockResolvedValue(['USA']); await expect( sut.getSearchSuggestions(authStub.user1, { includeNull: true, type: SearchSuggestionType.COUNTRY }), ).resolves.toEqual(['USA', null]); - expect(searchMock.getCountries).toHaveBeenCalledWith([authStub.user1.user.id]); + expect(mocks.search.getCountries).toHaveBeenCalledWith([authStub.user1.user.id]); }); it('should return search suggestions for state', async () => { - searchMock.getStates.mockResolvedValue(['California']); + mocks.search.getStates.mockResolvedValue(['California']); await expect( sut.getSearchSuggestions(authStub.user1, { includeNull: false, type: SearchSuggestionType.STATE }), ).resolves.toEqual(['California']); - expect(searchMock.getStates).toHaveBeenCalledWith([authStub.user1.user.id], expect.anything()); + expect(mocks.search.getStates).toHaveBeenCalledWith([authStub.user1.user.id], expect.anything()); }); it('should return search suggestions for state (including null)', async () => { - searchMock.getStates.mockResolvedValue(['California']); + mocks.search.getStates.mockResolvedValue(['California']); await expect( sut.getSearchSuggestions(authStub.user1, { includeNull: true, type: SearchSuggestionType.STATE }), ).resolves.toEqual(['California', null]); - expect(searchMock.getStates).toHaveBeenCalledWith([authStub.user1.user.id], expect.anything()); + expect(mocks.search.getStates).toHaveBeenCalledWith([authStub.user1.user.id], expect.anything()); }); it('should return search suggestions for city', async () => { - searchMock.getCities.mockResolvedValue(['Denver']); + mocks.search.getCities.mockResolvedValue(['Denver']); await expect( sut.getSearchSuggestions(authStub.user1, { includeNull: false, type: SearchSuggestionType.CITY }), ).resolves.toEqual(['Denver']); - expect(searchMock.getCities).toHaveBeenCalledWith([authStub.user1.user.id], expect.anything()); + expect(mocks.search.getCities).toHaveBeenCalledWith([authStub.user1.user.id], expect.anything()); }); it('should return search suggestions for city (including null)', async () => { - searchMock.getCities.mockResolvedValue(['Denver']); + mocks.search.getCities.mockResolvedValue(['Denver']); await expect( sut.getSearchSuggestions(authStub.user1, { includeNull: true, type: SearchSuggestionType.CITY }), ).resolves.toEqual(['Denver', null]); - expect(searchMock.getCities).toHaveBeenCalledWith([authStub.user1.user.id], expect.anything()); + expect(mocks.search.getCities).toHaveBeenCalledWith([authStub.user1.user.id], expect.anything()); }); it('should return search suggestions for camera make', async () => { - searchMock.getCameraMakes.mockResolvedValue(['Nikon']); + mocks.search.getCameraMakes.mockResolvedValue(['Nikon']); await expect( sut.getSearchSuggestions(authStub.user1, { includeNull: false, type: SearchSuggestionType.CAMERA_MAKE }), ).resolves.toEqual(['Nikon']); - expect(searchMock.getCameraMakes).toHaveBeenCalledWith([authStub.user1.user.id], expect.anything()); + expect(mocks.search.getCameraMakes).toHaveBeenCalledWith([authStub.user1.user.id], expect.anything()); }); it('should return search suggestions for camera make (including null)', async () => { - searchMock.getCameraMakes.mockResolvedValue(['Nikon']); + mocks.search.getCameraMakes.mockResolvedValue(['Nikon']); await expect( sut.getSearchSuggestions(authStub.user1, { includeNull: true, type: SearchSuggestionType.CAMERA_MAKE }), ).resolves.toEqual(['Nikon', null]); - expect(searchMock.getCameraMakes).toHaveBeenCalledWith([authStub.user1.user.id], expect.anything()); + expect(mocks.search.getCameraMakes).toHaveBeenCalledWith([authStub.user1.user.id], expect.anything()); }); it('should return search suggestions for camera model', async () => { - searchMock.getCameraModels.mockResolvedValue(['Fujifilm X100VI']); + mocks.search.getCameraModels.mockResolvedValue(['Fujifilm X100VI']); await expect( sut.getSearchSuggestions(authStub.user1, { includeNull: false, type: SearchSuggestionType.CAMERA_MODEL }), ).resolves.toEqual(['Fujifilm X100VI']); - expect(searchMock.getCameraModels).toHaveBeenCalledWith([authStub.user1.user.id], expect.anything()); + expect(mocks.search.getCameraModels).toHaveBeenCalledWith([authStub.user1.user.id], expect.anything()); }); it('should return search suggestions for camera model (including null)', async () => { - searchMock.getCameraModels.mockResolvedValue(['Fujifilm X100VI']); + mocks.search.getCameraModels.mockResolvedValue(['Fujifilm X100VI']); await expect( sut.getSearchSuggestions(authStub.user1, { includeNull: true, type: SearchSuggestionType.CAMERA_MODEL }), ).resolves.toEqual(['Fujifilm X100VI', null]); - expect(searchMock.getCameraModels).toHaveBeenCalledWith([authStub.user1.user.id], expect.anything()); + expect(mocks.search.getCameraModels).toHaveBeenCalledWith([authStub.user1.user.id], expect.anything()); }); }); }); diff --git a/server/src/services/server.service.spec.ts b/server/src/services/server.service.spec.ts index 4a405629c9..05ebda6a94 100644 --- a/server/src/services/server.service.spec.ts +++ b/server/src/services/server.service.spec.ts @@ -1,20 +1,13 @@ import { SystemMetadataKey } from 'src/enum'; -import { IStorageRepository } from 'src/interfaces/storage.interface'; -import { IUserRepository } from 'src/interfaces/user.interface'; import { ServerService } from 'src/services/server.service'; -import { ISystemMetadataRepository } from 'src/types'; -import { newTestService } from 'test/utils'; -import { Mocked } from 'vitest'; +import { newTestService, ServiceMocks } from 'test/utils'; describe(ServerService.name, () => { let sut: ServerService; - - let storageMock: Mocked; - let systemMock: Mocked; - let userMock: Mocked; + let mocks: ServiceMocks; beforeEach(() => { - ({ sut, storageMock, systemMock, userMock } = newTestService(ServerService)); + ({ sut, mocks } = newTestService(ServerService)); }); it('should work', () => { @@ -23,7 +16,7 @@ describe(ServerService.name, () => { describe('getStorage', () => { it('should return the disk space as B', async () => { - storageMock.checkDiskUsage.mockResolvedValue({ free: 200, available: 300, total: 500 }); + mocks.storage.checkDiskUsage.mockResolvedValue({ free: 200, available: 300, total: 500 }); await expect(sut.getStorage()).resolves.toEqual({ diskAvailable: '300 B', @@ -35,11 +28,11 @@ describe(ServerService.name, () => { diskUseRaw: 300, }); - expect(storageMock.checkDiskUsage).toHaveBeenCalledWith('upload/library'); + expect(mocks.storage.checkDiskUsage).toHaveBeenCalledWith('upload/library'); }); it('should return the disk space as KiB', async () => { - storageMock.checkDiskUsage.mockResolvedValue({ free: 200_000, available: 300_000, total: 500_000 }); + mocks.storage.checkDiskUsage.mockResolvedValue({ free: 200_000, available: 300_000, total: 500_000 }); await expect(sut.getStorage()).resolves.toEqual({ diskAvailable: '293.0 KiB', @@ -51,11 +44,11 @@ describe(ServerService.name, () => { diskUseRaw: 300_000, }); - expect(storageMock.checkDiskUsage).toHaveBeenCalledWith('upload/library'); + expect(mocks.storage.checkDiskUsage).toHaveBeenCalledWith('upload/library'); }); it('should return the disk space as MiB', async () => { - storageMock.checkDiskUsage.mockResolvedValue({ free: 200_000_000, available: 300_000_000, total: 500_000_000 }); + mocks.storage.checkDiskUsage.mockResolvedValue({ free: 200_000_000, available: 300_000_000, total: 500_000_000 }); await expect(sut.getStorage()).resolves.toEqual({ diskAvailable: '286.1 MiB', @@ -67,11 +60,11 @@ describe(ServerService.name, () => { diskUseRaw: 300_000_000, }); - expect(storageMock.checkDiskUsage).toHaveBeenCalledWith('upload/library'); + expect(mocks.storage.checkDiskUsage).toHaveBeenCalledWith('upload/library'); }); it('should return the disk space as GiB', async () => { - storageMock.checkDiskUsage.mockResolvedValue({ + mocks.storage.checkDiskUsage.mockResolvedValue({ free: 200_000_000_000, available: 300_000_000_000, total: 500_000_000_000, @@ -87,11 +80,11 @@ describe(ServerService.name, () => { diskUseRaw: 300_000_000_000, }); - expect(storageMock.checkDiskUsage).toHaveBeenCalledWith('upload/library'); + expect(mocks.storage.checkDiskUsage).toHaveBeenCalledWith('upload/library'); }); it('should return the disk space as TiB', async () => { - storageMock.checkDiskUsage.mockResolvedValue({ + mocks.storage.checkDiskUsage.mockResolvedValue({ free: 200_000_000_000_000, available: 300_000_000_000_000, total: 500_000_000_000_000, @@ -107,11 +100,11 @@ describe(ServerService.name, () => { diskUseRaw: 300_000_000_000_000, }); - expect(storageMock.checkDiskUsage).toHaveBeenCalledWith('upload/library'); + expect(mocks.storage.checkDiskUsage).toHaveBeenCalledWith('upload/library'); }); it('should return the disk space as PiB', async () => { - storageMock.checkDiskUsage.mockResolvedValue({ + mocks.storage.checkDiskUsage.mockResolvedValue({ free: 200_000_000_000_000_000, available: 300_000_000_000_000_000, total: 500_000_000_000_000_000, @@ -127,7 +120,7 @@ describe(ServerService.name, () => { diskUseRaw: 300_000_000_000_000_000, }); - expect(storageMock.checkDiskUsage).toHaveBeenCalledWith('upload/library'); + expect(mocks.storage.checkDiskUsage).toHaveBeenCalledWith('upload/library'); }); }); @@ -155,7 +148,7 @@ describe(ServerService.name, () => { trash: true, email: false, }); - expect(systemMock.get).toHaveBeenCalled(); + expect(mocks.systemMetadata.get).toHaveBeenCalled(); }); }); @@ -173,13 +166,13 @@ describe(ServerService.name, () => { mapDarkStyleUrl: 'https://tiles.immich.cloud/v1/style/dark.json', mapLightStyleUrl: 'https://tiles.immich.cloud/v1/style/light.json', }); - expect(systemMock.get).toHaveBeenCalled(); + expect(mocks.systemMetadata.get).toHaveBeenCalled(); }); }); describe('getStats', () => { it('should total up usage by user', async () => { - userMock.getUserStats.mockResolvedValue([ + mocks.user.getUserStats.mockResolvedValue([ { userId: 'user1', userName: '1 User', @@ -252,36 +245,36 @@ describe(ServerService.name, () => { ], }); - expect(userMock.getUserStats).toHaveBeenCalled(); + expect(mocks.user.getUserStats).toHaveBeenCalled(); }); }); describe('setLicense', () => { it('should save license if valid', async () => { - systemMock.set.mockResolvedValue(); + mocks.systemMetadata.set.mockResolvedValue(); const license = { licenseKey: 'IMSV-license-key', activationKey: 'activation-key' }; await sut.setLicense(license); - expect(systemMock.set).toHaveBeenCalledWith(SystemMetadataKey.LICENSE, expect.any(Object)); + expect(mocks.systemMetadata.set).toHaveBeenCalledWith(SystemMetadataKey.LICENSE, expect.any(Object)); }); it('should not save license if invalid', async () => { - userMock.upsertMetadata.mockResolvedValue(); + mocks.user.upsertMetadata.mockResolvedValue(); const license = { licenseKey: 'license-key', activationKey: 'activation-key' }; const call = sut.setLicense(license); await expect(call).rejects.toThrowError('Invalid license key'); - expect(userMock.upsertMetadata).not.toHaveBeenCalled(); + expect(mocks.user.upsertMetadata).not.toHaveBeenCalled(); }); }); describe('deleteLicense', () => { it('should delete license', async () => { - userMock.upsertMetadata.mockResolvedValue(); + mocks.user.upsertMetadata.mockResolvedValue(); await sut.deleteLicense(); - expect(userMock.upsertMetadata).not.toHaveBeenCalled(); + expect(mocks.user.upsertMetadata).not.toHaveBeenCalled(); }); }); }); diff --git a/server/src/services/session.service.spec.ts b/server/src/services/session.service.spec.ts index 8d989db5df..c25c0feb82 100644 --- a/server/src/services/session.service.spec.ts +++ b/server/src/services/session.service.spec.ts @@ -1,20 +1,15 @@ import { JobStatus } from 'src/interfaces/job.interface'; import { SessionService } from 'src/services/session.service'; -import { ISessionRepository } from 'src/types'; import { authStub } from 'test/fixtures/auth.stub'; import { sessionStub } from 'test/fixtures/session.stub'; -import { IAccessRepositoryMock } from 'test/repositories/access.repository.mock'; -import { newTestService } from 'test/utils'; -import { Mocked } from 'vitest'; +import { newTestService, ServiceMocks } from 'test/utils'; describe('SessionService', () => { let sut: SessionService; - - let accessMock: Mocked; - let sessionMock: Mocked; + let mocks: ServiceMocks; beforeEach(() => { - ({ sut, accessMock, sessionMock } = newTestService(SessionService)); + ({ sut, mocks } = newTestService(SessionService)); }); it('should be defined', () => { @@ -23,13 +18,13 @@ describe('SessionService', () => { describe('handleCleanup', () => { it('should return skipped if nothing is to be deleted', async () => { - sessionMock.search.mockResolvedValue([]); + mocks.session.search.mockResolvedValue([]); await expect(sut.handleCleanup()).resolves.toEqual(JobStatus.SKIPPED); - expect(sessionMock.search).toHaveBeenCalled(); + expect(mocks.session.search).toHaveBeenCalled(); }); it('should delete sessions', async () => { - sessionMock.search.mockResolvedValue([ + mocks.session.search.mockResolvedValue([ { createdAt: new Date('1970-01-01T00:00:00.00Z'), updatedAt: new Date('1970-01-02T00:00:00.00Z'), @@ -42,13 +37,13 @@ describe('SessionService', () => { ]); await expect(sut.handleCleanup()).resolves.toEqual(JobStatus.SUCCESS); - expect(sessionMock.delete).toHaveBeenCalledWith('123'); + expect(mocks.session.delete).toHaveBeenCalledWith('123'); }); }); describe('getAll', () => { it('should get the devices', async () => { - sessionMock.getByUserId.mockResolvedValue([sessionStub.valid as any, sessionStub.inactive]); + mocks.session.getByUserId.mockResolvedValue([sessionStub.valid as any, sessionStub.inactive]); await expect(sut.getAll(authStub.user1)).resolves.toEqual([ { createdAt: '2021-01-01T00:00:00.000Z', @@ -68,30 +63,33 @@ describe('SessionService', () => { }, ]); - expect(sessionMock.getByUserId).toHaveBeenCalledWith(authStub.user1.user.id); + expect(mocks.session.getByUserId).toHaveBeenCalledWith(authStub.user1.user.id); }); }); describe('logoutDevices', () => { it('should logout all devices', async () => { - sessionMock.getByUserId.mockResolvedValue([sessionStub.inactive, sessionStub.valid] as any[]); + mocks.session.getByUserId.mockResolvedValue([sessionStub.inactive, sessionStub.valid] as any[]); await sut.deleteAll(authStub.user1); - expect(sessionMock.getByUserId).toHaveBeenCalledWith(authStub.user1.user.id); - expect(sessionMock.delete).toHaveBeenCalledWith('not_active'); - expect(sessionMock.delete).not.toHaveBeenCalledWith('token-id'); + expect(mocks.session.getByUserId).toHaveBeenCalledWith(authStub.user1.user.id); + expect(mocks.session.delete).toHaveBeenCalledWith('not_active'); + expect(mocks.session.delete).not.toHaveBeenCalledWith('token-id'); }); }); describe('logoutDevice', () => { it('should logout the device', async () => { - accessMock.authDevice.checkOwnerAccess.mockResolvedValue(new Set(['token-1'])); + mocks.access.authDevice.checkOwnerAccess.mockResolvedValue(new Set(['token-1'])); await sut.delete(authStub.user1, 'token-1'); - expect(accessMock.authDevice.checkOwnerAccess).toHaveBeenCalledWith(authStub.user1.user.id, new Set(['token-1'])); - expect(sessionMock.delete).toHaveBeenCalledWith('token-1'); + expect(mocks.access.authDevice.checkOwnerAccess).toHaveBeenCalledWith( + authStub.user1.user.id, + new Set(['token-1']), + ); + expect(mocks.session.delete).toHaveBeenCalledWith('token-1'); }); }); }); diff --git a/server/src/services/shared-link.service.spec.ts b/server/src/services/shared-link.service.spec.ts index 0e29012876..4d6cdee6cb 100644 --- a/server/src/services/shared-link.service.spec.ts +++ b/server/src/services/shared-link.service.spec.ts @@ -2,24 +2,19 @@ import { BadRequestException, ForbiddenException, UnauthorizedException } from ' import _ from 'lodash'; import { AssetIdErrorReason } from 'src/dtos/asset-ids.response.dto'; import { SharedLinkType } from 'src/enum'; -import { ISharedLinkRepository } from 'src/interfaces/shared-link.interface'; import { SharedLinkService } from 'src/services/shared-link.service'; import { albumStub } from 'test/fixtures/album.stub'; import { assetStub } from 'test/fixtures/asset.stub'; import { authStub } from 'test/fixtures/auth.stub'; import { sharedLinkResponseStub, sharedLinkStub } from 'test/fixtures/shared-link.stub'; -import { IAccessRepositoryMock } from 'test/repositories/access.repository.mock'; -import { newTestService } from 'test/utils'; -import { Mocked } from 'vitest'; +import { newTestService, ServiceMocks } from 'test/utils'; describe(SharedLinkService.name, () => { let sut: SharedLinkService; - - let accessMock: IAccessRepositoryMock; - let sharedLinkMock: Mocked; + let mocks: ServiceMocks; beforeEach(() => { - ({ sut, accessMock, sharedLinkMock } = newTestService(SharedLinkService)); + ({ sut, mocks } = newTestService(SharedLinkService)); }); it('should work', () => { @@ -28,46 +23,46 @@ describe(SharedLinkService.name, () => { describe('getAll', () => { it('should return all shared links for a user', async () => { - sharedLinkMock.getAll.mockResolvedValue([sharedLinkStub.expired, sharedLinkStub.valid]); + mocks.sharedLink.getAll.mockResolvedValue([sharedLinkStub.expired, sharedLinkStub.valid]); await expect(sut.getAll(authStub.user1, {})).resolves.toEqual([ sharedLinkResponseStub.expired, sharedLinkResponseStub.valid, ]); - expect(sharedLinkMock.getAll).toHaveBeenCalledWith({ userId: authStub.user1.user.id }); + expect(mocks.sharedLink.getAll).toHaveBeenCalledWith({ userId: authStub.user1.user.id }); }); }); describe('getMine', () => { it('should only work for a public user', async () => { await expect(sut.getMine(authStub.admin, {})).rejects.toBeInstanceOf(ForbiddenException); - expect(sharedLinkMock.get).not.toHaveBeenCalled(); + expect(mocks.sharedLink.get).not.toHaveBeenCalled(); }); it('should return the shared link for the public user', async () => { const authDto = authStub.adminSharedLink; - sharedLinkMock.get.mockResolvedValue(sharedLinkStub.valid); + mocks.sharedLink.get.mockResolvedValue(sharedLinkStub.valid); await expect(sut.getMine(authDto, {})).resolves.toEqual(sharedLinkResponseStub.valid); - expect(sharedLinkMock.get).toHaveBeenCalledWith(authDto.user.id, authDto.sharedLink?.id); + expect(mocks.sharedLink.get).toHaveBeenCalledWith(authDto.user.id, authDto.sharedLink?.id); }); it('should not return metadata', async () => { const authDto = authStub.adminSharedLinkNoExif; - sharedLinkMock.get.mockResolvedValue(sharedLinkStub.readonlyNoExif); + mocks.sharedLink.get.mockResolvedValue(sharedLinkStub.readonlyNoExif); await expect(sut.getMine(authDto, {})).resolves.toEqual(sharedLinkResponseStub.readonlyNoMetadata); - expect(sharedLinkMock.get).toHaveBeenCalledWith(authDto.user.id, authDto.sharedLink?.id); + expect(mocks.sharedLink.get).toHaveBeenCalledWith(authDto.user.id, authDto.sharedLink?.id); }); it('should throw an error for an invalid password protected shared link', async () => { const authDto = authStub.adminSharedLink; - sharedLinkMock.get.mockResolvedValue(sharedLinkStub.passwordRequired); + mocks.sharedLink.get.mockResolvedValue(sharedLinkStub.passwordRequired); await expect(sut.getMine(authDto, {})).rejects.toBeInstanceOf(UnauthorizedException); - expect(sharedLinkMock.get).toHaveBeenCalledWith(authDto.user.id, authDto.sharedLink?.id); + expect(mocks.sharedLink.get).toHaveBeenCalledWith(authDto.user.id, authDto.sharedLink?.id); }); it('should allow a correct password on a password protected shared link', async () => { - sharedLinkMock.get.mockResolvedValue({ ...sharedLinkStub.individual, password: '123' }); + mocks.sharedLink.get.mockResolvedValue({ ...sharedLinkStub.individual, password: '123' }); await expect(sut.getMine(authStub.adminSharedLink, { password: '123' })).resolves.toBeDefined(); - expect(sharedLinkMock.get).toHaveBeenCalledWith( + expect(mocks.sharedLink.get).toHaveBeenCalledWith( authStub.adminSharedLink.user.id, authStub.adminSharedLink.sharedLink?.id, ); @@ -77,14 +72,14 @@ describe(SharedLinkService.name, () => { describe('get', () => { it('should throw an error for an invalid shared link', async () => { await expect(sut.get(authStub.user1, 'missing-id')).rejects.toBeInstanceOf(BadRequestException); - expect(sharedLinkMock.get).toHaveBeenCalledWith(authStub.user1.user.id, 'missing-id'); - expect(sharedLinkMock.update).not.toHaveBeenCalled(); + expect(mocks.sharedLink.get).toHaveBeenCalledWith(authStub.user1.user.id, 'missing-id'); + expect(mocks.sharedLink.update).not.toHaveBeenCalled(); }); it('should get a shared link by id', async () => { - sharedLinkMock.get.mockResolvedValue(sharedLinkStub.valid); + mocks.sharedLink.get.mockResolvedValue(sharedLinkStub.valid); await expect(sut.get(authStub.user1, sharedLinkStub.valid.id)).resolves.toEqual(sharedLinkResponseStub.valid); - expect(sharedLinkMock.get).toHaveBeenCalledWith(authStub.user1.user.id, sharedLinkStub.valid.id); + expect(mocks.sharedLink.get).toHaveBeenCalledWith(authStub.user1.user.id, sharedLinkStub.valid.id); }); }); @@ -114,16 +109,16 @@ describe(SharedLinkService.name, () => { }); it('should create an album shared link', async () => { - accessMock.album.checkOwnerAccess.mockResolvedValue(new Set([albumStub.oneAsset.id])); - sharedLinkMock.create.mockResolvedValue(sharedLinkStub.valid); + mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set([albumStub.oneAsset.id])); + mocks.sharedLink.create.mockResolvedValue(sharedLinkStub.valid); await sut.create(authStub.admin, { type: SharedLinkType.ALBUM, albumId: albumStub.oneAsset.id }); - expect(accessMock.album.checkOwnerAccess).toHaveBeenCalledWith( + expect(mocks.access.album.checkOwnerAccess).toHaveBeenCalledWith( authStub.admin.user.id, new Set([albumStub.oneAsset.id]), ); - expect(sharedLinkMock.create).toHaveBeenCalledWith({ + expect(mocks.sharedLink.create).toHaveBeenCalledWith({ type: SharedLinkType.ALBUM, userId: authStub.admin.user.id, albumId: albumStub.oneAsset.id, @@ -137,8 +132,8 @@ describe(SharedLinkService.name, () => { }); it('should create an individual shared link', async () => { - accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.image.id])); - sharedLinkMock.create.mockResolvedValue(sharedLinkStub.individual); + mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.image.id])); + mocks.sharedLink.create.mockResolvedValue(sharedLinkStub.individual); await sut.create(authStub.admin, { type: SharedLinkType.INDIVIDUAL, @@ -148,11 +143,11 @@ describe(SharedLinkService.name, () => { allowUpload: true, }); - expect(accessMock.asset.checkOwnerAccess).toHaveBeenCalledWith( + expect(mocks.access.asset.checkOwnerAccess).toHaveBeenCalledWith( authStub.admin.user.id, new Set([assetStub.image.id]), ); - expect(sharedLinkMock.create).toHaveBeenCalledWith({ + expect(mocks.sharedLink.create).toHaveBeenCalledWith({ type: SharedLinkType.INDIVIDUAL, userId: authStub.admin.user.id, albumId: null, @@ -167,8 +162,8 @@ describe(SharedLinkService.name, () => { }); it('should create a shared link with allowDownload set to false when showMetadata is false', async () => { - accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.image.id])); - sharedLinkMock.create.mockResolvedValue(sharedLinkStub.individual); + mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.image.id])); + mocks.sharedLink.create.mockResolvedValue(sharedLinkStub.individual); await sut.create(authStub.admin, { type: SharedLinkType.INDIVIDUAL, @@ -178,11 +173,11 @@ describe(SharedLinkService.name, () => { allowUpload: true, }); - expect(accessMock.asset.checkOwnerAccess).toHaveBeenCalledWith( + expect(mocks.access.asset.checkOwnerAccess).toHaveBeenCalledWith( authStub.admin.user.id, new Set([assetStub.image.id]), ); - expect(sharedLinkMock.create).toHaveBeenCalledWith({ + expect(mocks.sharedLink.create).toHaveBeenCalledWith({ type: SharedLinkType.INDIVIDUAL, userId: authStub.admin.user.id, albumId: null, @@ -200,16 +195,16 @@ describe(SharedLinkService.name, () => { describe('update', () => { it('should throw an error for an invalid shared link', async () => { await expect(sut.update(authStub.user1, 'missing-id', {})).rejects.toBeInstanceOf(BadRequestException); - expect(sharedLinkMock.get).toHaveBeenCalledWith(authStub.user1.user.id, 'missing-id'); - expect(sharedLinkMock.update).not.toHaveBeenCalled(); + expect(mocks.sharedLink.get).toHaveBeenCalledWith(authStub.user1.user.id, 'missing-id'); + expect(mocks.sharedLink.update).not.toHaveBeenCalled(); }); it('should update a shared link', async () => { - sharedLinkMock.get.mockResolvedValue(sharedLinkStub.valid); - sharedLinkMock.update.mockResolvedValue(sharedLinkStub.valid); + mocks.sharedLink.get.mockResolvedValue(sharedLinkStub.valid); + mocks.sharedLink.update.mockResolvedValue(sharedLinkStub.valid); await sut.update(authStub.user1, sharedLinkStub.valid.id, { allowDownload: false }); - expect(sharedLinkMock.get).toHaveBeenCalledWith(authStub.user1.user.id, sharedLinkStub.valid.id); - expect(sharedLinkMock.update).toHaveBeenCalledWith({ + expect(mocks.sharedLink.get).toHaveBeenCalledWith(authStub.user1.user.id, sharedLinkStub.valid.id); + expect(mocks.sharedLink.update).toHaveBeenCalledWith({ id: sharedLinkStub.valid.id, userId: authStub.user1.user.id, allowDownload: false, @@ -220,30 +215,30 @@ describe(SharedLinkService.name, () => { describe('remove', () => { it('should throw an error for an invalid shared link', async () => { await expect(sut.remove(authStub.user1, 'missing-id')).rejects.toBeInstanceOf(BadRequestException); - expect(sharedLinkMock.get).toHaveBeenCalledWith(authStub.user1.user.id, 'missing-id'); - expect(sharedLinkMock.update).not.toHaveBeenCalled(); + expect(mocks.sharedLink.get).toHaveBeenCalledWith(authStub.user1.user.id, 'missing-id'); + expect(mocks.sharedLink.update).not.toHaveBeenCalled(); }); it('should remove a key', async () => { - sharedLinkMock.get.mockResolvedValue(sharedLinkStub.valid); + mocks.sharedLink.get.mockResolvedValue(sharedLinkStub.valid); await sut.remove(authStub.user1, sharedLinkStub.valid.id); - expect(sharedLinkMock.get).toHaveBeenCalledWith(authStub.user1.user.id, sharedLinkStub.valid.id); - expect(sharedLinkMock.remove).toHaveBeenCalledWith(sharedLinkStub.valid); + expect(mocks.sharedLink.get).toHaveBeenCalledWith(authStub.user1.user.id, sharedLinkStub.valid.id); + expect(mocks.sharedLink.remove).toHaveBeenCalledWith(sharedLinkStub.valid); }); }); describe('addAssets', () => { it('should not work on album shared links', async () => { - sharedLinkMock.get.mockResolvedValue(sharedLinkStub.valid); + mocks.sharedLink.get.mockResolvedValue(sharedLinkStub.valid); await expect(sut.addAssets(authStub.admin, 'link-1', { assetIds: ['asset-1'] })).rejects.toBeInstanceOf( BadRequestException, ); }); it('should add assets to a shared link', async () => { - sharedLinkMock.get.mockResolvedValue(_.cloneDeep(sharedLinkStub.individual)); - sharedLinkMock.create.mockResolvedValue(sharedLinkStub.individual); - accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-3'])); + mocks.sharedLink.get.mockResolvedValue(_.cloneDeep(sharedLinkStub.individual)); + mocks.sharedLink.create.mockResolvedValue(sharedLinkStub.individual); + mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-3'])); await expect( sut.addAssets(authStub.admin, 'link-1', { assetIds: [assetStub.image.id, 'asset-2', 'asset-3'] }), @@ -253,9 +248,9 @@ describe(SharedLinkService.name, () => { { assetId: 'asset-3', success: true }, ]); - expect(accessMock.asset.checkOwnerAccess).toHaveBeenCalledTimes(1); - expect(sharedLinkMock.update).toHaveBeenCalled(); - expect(sharedLinkMock.update).toHaveBeenCalledWith({ + expect(mocks.access.asset.checkOwnerAccess).toHaveBeenCalledTimes(1); + expect(mocks.sharedLink.update).toHaveBeenCalled(); + expect(mocks.sharedLink.update).toHaveBeenCalledWith({ ...sharedLinkStub.individual, assetIds: ['asset-3'], }); @@ -264,15 +259,15 @@ describe(SharedLinkService.name, () => { describe('removeAssets', () => { it('should not work on album shared links', async () => { - sharedLinkMock.get.mockResolvedValue(sharedLinkStub.valid); + mocks.sharedLink.get.mockResolvedValue(sharedLinkStub.valid); await expect(sut.removeAssets(authStub.admin, 'link-1', { assetIds: ['asset-1'] })).rejects.toBeInstanceOf( BadRequestException, ); }); it('should remove assets from a shared link', async () => { - sharedLinkMock.get.mockResolvedValue(_.cloneDeep(sharedLinkStub.individual)); - sharedLinkMock.create.mockResolvedValue(sharedLinkStub.individual); + mocks.sharedLink.get.mockResolvedValue(_.cloneDeep(sharedLinkStub.individual)); + mocks.sharedLink.create.mockResolvedValue(sharedLinkStub.individual); await expect( sut.removeAssets(authStub.admin, 'link-1', { assetIds: [assetStub.image.id, 'asset-2'] }), @@ -281,39 +276,39 @@ describe(SharedLinkService.name, () => { { assetId: 'asset-2', success: false, error: AssetIdErrorReason.NOT_FOUND }, ]); - expect(sharedLinkMock.update).toHaveBeenCalledWith({ ...sharedLinkStub.individual, assets: [] }); + expect(mocks.sharedLink.update).toHaveBeenCalledWith({ ...sharedLinkStub.individual, assets: [] }); }); }); describe('getMetadataTags', () => { it('should return null when auth is not a shared link', async () => { await expect(sut.getMetadataTags(authStub.admin)).resolves.toBe(null); - expect(sharedLinkMock.get).not.toHaveBeenCalled(); + expect(mocks.sharedLink.get).not.toHaveBeenCalled(); }); it('should return null when shared link has a password', async () => { await expect(sut.getMetadataTags(authStub.passwordSharedLink)).resolves.toBe(null); - expect(sharedLinkMock.get).not.toHaveBeenCalled(); + expect(mocks.sharedLink.get).not.toHaveBeenCalled(); }); it('should return metadata tags', async () => { - sharedLinkMock.get.mockResolvedValue(sharedLinkStub.individual); + mocks.sharedLink.get.mockResolvedValue(sharedLinkStub.individual); await expect(sut.getMetadataTags(authStub.adminSharedLink)).resolves.toEqual({ description: '1 shared photos & videos', imageUrl: `http://localhost:2283/api/assets/asset-id/thumbnail?key=LCtkaJX4R1O_9D-2lq0STzsPryoL1UdAbyb6Sna1xxmQCSuqU2J1ZUsqt6GR-yGm1s0`, title: 'Public Share', }); - expect(sharedLinkMock.get).toHaveBeenCalled(); + expect(mocks.sharedLink.get).toHaveBeenCalled(); }); it('should return metadata tags with a default image path if the asset id is not set', async () => { - sharedLinkMock.get.mockResolvedValue({ ...sharedLinkStub.individual, album: undefined, assets: [] }); + mocks.sharedLink.get.mockResolvedValue({ ...sharedLinkStub.individual, album: undefined, assets: [] }); await expect(sut.getMetadataTags(authStub.adminSharedLink)).resolves.toEqual({ description: '0 shared photos & videos', imageUrl: `http://localhost:2283/feature-panel.png`, title: 'Public Share', }); - expect(sharedLinkMock.get).toHaveBeenCalled(); + expect(mocks.sharedLink.get).toHaveBeenCalled(); }); }); }); diff --git a/server/src/services/smart-info.service.spec.ts b/server/src/services/smart-info.service.spec.ts index 1b985ab421..79e13ea7ab 100644 --- a/server/src/services/smart-info.service.spec.ts +++ b/server/src/services/smart-info.service.spec.ts @@ -1,35 +1,22 @@ import { SystemConfig } from 'src/config'; import { ImmichWorker } from 'src/enum'; -import { IAssetRepository, WithoutProperty } from 'src/interfaces/asset.interface'; -import { IDatabaseRepository } from 'src/interfaces/database.interface'; -import { IJobRepository, JobName, JobStatus } from 'src/interfaces/job.interface'; -import { IMachineLearningRepository } from 'src/interfaces/machine-learning.interface'; -import { ISearchRepository } from 'src/interfaces/search.interface'; +import { WithoutProperty } from 'src/interfaces/asset.interface'; +import { JobName, JobStatus } from 'src/interfaces/job.interface'; import { SmartInfoService } from 'src/services/smart-info.service'; -import { IConfigRepository, ISystemMetadataRepository } from 'src/types'; import { getCLIPModelInfo } from 'src/utils/misc'; import { assetStub } from 'test/fixtures/asset.stub'; import { systemConfigStub } from 'test/fixtures/system-config.stub'; -import { newTestService } from 'test/utils'; -import { Mocked } from 'vitest'; +import { newTestService, ServiceMocks } from 'test/utils'; describe(SmartInfoService.name, () => { let sut: SmartInfoService; - - let assetMock: Mocked; - let databaseMock: Mocked; - let jobMock: Mocked; - let machineLearningMock: Mocked; - let searchMock: Mocked; - let systemMock: Mocked; - let configMock: Mocked; + let mocks: ServiceMocks; beforeEach(() => { - ({ sut, assetMock, databaseMock, jobMock, machineLearningMock, searchMock, systemMock, configMock } = - newTestService(SmartInfoService)); + ({ sut, mocks } = newTestService(SmartInfoService)); - assetMock.getByIds.mockResolvedValue([assetStub.image]); - configMock.getWorker.mockReturnValue(ImmichWorker.MICROSERVICES); + mocks.asset.getByIds.mockResolvedValue([assetStub.image]); + mocks.config.getWorker.mockReturnValue(ImmichWorker.MICROSERVICES); }); it('should work', () => { @@ -69,79 +56,79 @@ describe(SmartInfoService.name, () => { it('should return if machine learning is disabled', async () => { await sut.onConfigInit({ newConfig: systemConfigStub.machineLearningDisabled as SystemConfig }); - expect(searchMock.getDimensionSize).not.toHaveBeenCalled(); - expect(searchMock.setDimensionSize).not.toHaveBeenCalled(); - expect(searchMock.deleteAllSearchEmbeddings).not.toHaveBeenCalled(); - expect(jobMock.getQueueStatus).not.toHaveBeenCalled(); - expect(jobMock.pause).not.toHaveBeenCalled(); - expect(jobMock.waitForQueueCompletion).not.toHaveBeenCalled(); - expect(jobMock.resume).not.toHaveBeenCalled(); + expect(mocks.search.getDimensionSize).not.toHaveBeenCalled(); + expect(mocks.search.setDimensionSize).not.toHaveBeenCalled(); + expect(mocks.search.deleteAllSearchEmbeddings).not.toHaveBeenCalled(); + expect(mocks.job.getQueueStatus).not.toHaveBeenCalled(); + expect(mocks.job.pause).not.toHaveBeenCalled(); + expect(mocks.job.waitForQueueCompletion).not.toHaveBeenCalled(); + expect(mocks.job.resume).not.toHaveBeenCalled(); }); it('should return if model and DB dimension size are equal', async () => { - searchMock.getDimensionSize.mockResolvedValue(512); + mocks.search.getDimensionSize.mockResolvedValue(512); await sut.onConfigInit({ newConfig: systemConfigStub.machineLearningEnabled as SystemConfig }); - expect(searchMock.getDimensionSize).toHaveBeenCalledTimes(1); - expect(searchMock.setDimensionSize).not.toHaveBeenCalled(); - expect(searchMock.deleteAllSearchEmbeddings).not.toHaveBeenCalled(); - expect(jobMock.getQueueStatus).not.toHaveBeenCalled(); - expect(jobMock.pause).not.toHaveBeenCalled(); - expect(jobMock.waitForQueueCompletion).not.toHaveBeenCalled(); - expect(jobMock.resume).not.toHaveBeenCalled(); + expect(mocks.search.getDimensionSize).toHaveBeenCalledTimes(1); + expect(mocks.search.setDimensionSize).not.toHaveBeenCalled(); + expect(mocks.search.deleteAllSearchEmbeddings).not.toHaveBeenCalled(); + expect(mocks.job.getQueueStatus).not.toHaveBeenCalled(); + expect(mocks.job.pause).not.toHaveBeenCalled(); + expect(mocks.job.waitForQueueCompletion).not.toHaveBeenCalled(); + expect(mocks.job.resume).not.toHaveBeenCalled(); }); it('should update DB dimension size if model and DB have different values', async () => { - searchMock.getDimensionSize.mockResolvedValue(768); - jobMock.getQueueStatus.mockResolvedValue({ isActive: false, isPaused: false }); + mocks.search.getDimensionSize.mockResolvedValue(768); + mocks.job.getQueueStatus.mockResolvedValue({ isActive: false, isPaused: false }); await sut.onConfigInit({ newConfig: systemConfigStub.machineLearningEnabled as SystemConfig }); - expect(searchMock.getDimensionSize).toHaveBeenCalledTimes(1); - expect(searchMock.setDimensionSize).toHaveBeenCalledWith(512); - expect(jobMock.getQueueStatus).toHaveBeenCalledTimes(1); - expect(jobMock.pause).toHaveBeenCalledTimes(1); - expect(jobMock.waitForQueueCompletion).toHaveBeenCalledTimes(1); - expect(jobMock.resume).toHaveBeenCalledTimes(1); + expect(mocks.search.getDimensionSize).toHaveBeenCalledTimes(1); + expect(mocks.search.setDimensionSize).toHaveBeenCalledWith(512); + expect(mocks.job.getQueueStatus).toHaveBeenCalledTimes(1); + expect(mocks.job.pause).toHaveBeenCalledTimes(1); + expect(mocks.job.waitForQueueCompletion).toHaveBeenCalledTimes(1); + expect(mocks.job.resume).toHaveBeenCalledTimes(1); }); it('should skip pausing and resuming queue if already paused', async () => { - searchMock.getDimensionSize.mockResolvedValue(768); - jobMock.getQueueStatus.mockResolvedValue({ isActive: false, isPaused: true }); + mocks.search.getDimensionSize.mockResolvedValue(768); + mocks.job.getQueueStatus.mockResolvedValue({ isActive: false, isPaused: true }); await sut.onConfigInit({ newConfig: systemConfigStub.machineLearningEnabled as SystemConfig }); - expect(searchMock.getDimensionSize).toHaveBeenCalledTimes(1); - expect(searchMock.setDimensionSize).toHaveBeenCalledWith(512); - expect(jobMock.getQueueStatus).toHaveBeenCalledTimes(1); - expect(jobMock.pause).not.toHaveBeenCalled(); - expect(jobMock.waitForQueueCompletion).toHaveBeenCalledTimes(1); - expect(jobMock.resume).not.toHaveBeenCalled(); + expect(mocks.search.getDimensionSize).toHaveBeenCalledTimes(1); + expect(mocks.search.setDimensionSize).toHaveBeenCalledWith(512); + expect(mocks.job.getQueueStatus).toHaveBeenCalledTimes(1); + expect(mocks.job.pause).not.toHaveBeenCalled(); + expect(mocks.job.waitForQueueCompletion).toHaveBeenCalledTimes(1); + expect(mocks.job.resume).not.toHaveBeenCalled(); }); }); describe('onConfigUpdateEvent', () => { it('should return if machine learning is disabled', async () => { - systemMock.get.mockResolvedValue(systemConfigStub.machineLearningDisabled); + mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.machineLearningDisabled); await sut.onConfigUpdate({ newConfig: systemConfigStub.machineLearningDisabled as SystemConfig, oldConfig: systemConfigStub.machineLearningDisabled as SystemConfig, }); - expect(systemMock.get).not.toHaveBeenCalled(); - expect(searchMock.getDimensionSize).not.toHaveBeenCalled(); - expect(searchMock.setDimensionSize).not.toHaveBeenCalled(); - expect(searchMock.deleteAllSearchEmbeddings).not.toHaveBeenCalled(); - expect(jobMock.getQueueStatus).not.toHaveBeenCalled(); - expect(jobMock.pause).not.toHaveBeenCalled(); - expect(jobMock.waitForQueueCompletion).not.toHaveBeenCalled(); - expect(jobMock.resume).not.toHaveBeenCalled(); + expect(mocks.systemMetadata.get).not.toHaveBeenCalled(); + expect(mocks.search.getDimensionSize).not.toHaveBeenCalled(); + expect(mocks.search.setDimensionSize).not.toHaveBeenCalled(); + expect(mocks.search.deleteAllSearchEmbeddings).not.toHaveBeenCalled(); + expect(mocks.job.getQueueStatus).not.toHaveBeenCalled(); + expect(mocks.job.pause).not.toHaveBeenCalled(); + expect(mocks.job.waitForQueueCompletion).not.toHaveBeenCalled(); + expect(mocks.job.resume).not.toHaveBeenCalled(); }); it('should return if model and DB dimension size are equal', async () => { - searchMock.getDimensionSize.mockResolvedValue(512); + mocks.search.getDimensionSize.mockResolvedValue(512); await sut.onConfigUpdate({ newConfig: { @@ -152,18 +139,18 @@ describe(SmartInfoService.name, () => { } as SystemConfig, }); - expect(searchMock.getDimensionSize).toHaveBeenCalledTimes(1); - expect(searchMock.setDimensionSize).not.toHaveBeenCalled(); - expect(searchMock.deleteAllSearchEmbeddings).not.toHaveBeenCalled(); - expect(jobMock.getQueueStatus).not.toHaveBeenCalled(); - expect(jobMock.pause).not.toHaveBeenCalled(); - expect(jobMock.waitForQueueCompletion).not.toHaveBeenCalled(); - expect(jobMock.resume).not.toHaveBeenCalled(); + expect(mocks.search.getDimensionSize).toHaveBeenCalledTimes(1); + expect(mocks.search.setDimensionSize).not.toHaveBeenCalled(); + expect(mocks.search.deleteAllSearchEmbeddings).not.toHaveBeenCalled(); + expect(mocks.job.getQueueStatus).not.toHaveBeenCalled(); + expect(mocks.job.pause).not.toHaveBeenCalled(); + expect(mocks.job.waitForQueueCompletion).not.toHaveBeenCalled(); + expect(mocks.job.resume).not.toHaveBeenCalled(); }); it('should update DB dimension size if model and DB have different values', async () => { - searchMock.getDimensionSize.mockResolvedValue(512); - jobMock.getQueueStatus.mockResolvedValue({ isActive: false, isPaused: false }); + mocks.search.getDimensionSize.mockResolvedValue(512); + mocks.job.getQueueStatus.mockResolvedValue({ isActive: false, isPaused: false }); await sut.onConfigUpdate({ newConfig: { @@ -174,17 +161,17 @@ describe(SmartInfoService.name, () => { } as SystemConfig, }); - expect(searchMock.getDimensionSize).toHaveBeenCalledTimes(1); - expect(searchMock.setDimensionSize).toHaveBeenCalledWith(768); - expect(jobMock.getQueueStatus).toHaveBeenCalledTimes(1); - expect(jobMock.pause).toHaveBeenCalledTimes(1); - expect(jobMock.waitForQueueCompletion).toHaveBeenCalledTimes(1); - expect(jobMock.resume).toHaveBeenCalledTimes(1); + expect(mocks.search.getDimensionSize).toHaveBeenCalledTimes(1); + expect(mocks.search.setDimensionSize).toHaveBeenCalledWith(768); + expect(mocks.job.getQueueStatus).toHaveBeenCalledTimes(1); + expect(mocks.job.pause).toHaveBeenCalledTimes(1); + expect(mocks.job.waitForQueueCompletion).toHaveBeenCalledTimes(1); + expect(mocks.job.resume).toHaveBeenCalledTimes(1); }); it('should clear embeddings if old and new models are different', async () => { - searchMock.getDimensionSize.mockResolvedValue(512); - jobMock.getQueueStatus.mockResolvedValue({ isActive: false, isPaused: false }); + mocks.search.getDimensionSize.mockResolvedValue(512); + mocks.job.getQueueStatus.mockResolvedValue({ isActive: false, isPaused: false }); await sut.onConfigUpdate({ newConfig: { @@ -195,18 +182,18 @@ describe(SmartInfoService.name, () => { } as SystemConfig, }); - expect(searchMock.deleteAllSearchEmbeddings).toHaveBeenCalled(); - expect(searchMock.getDimensionSize).toHaveBeenCalledTimes(1); - expect(searchMock.setDimensionSize).not.toHaveBeenCalled(); - expect(jobMock.getQueueStatus).toHaveBeenCalledTimes(1); - expect(jobMock.pause).toHaveBeenCalledTimes(1); - expect(jobMock.waitForQueueCompletion).toHaveBeenCalledTimes(1); - expect(jobMock.resume).toHaveBeenCalledTimes(1); + expect(mocks.search.deleteAllSearchEmbeddings).toHaveBeenCalled(); + expect(mocks.search.getDimensionSize).toHaveBeenCalledTimes(1); + expect(mocks.search.setDimensionSize).not.toHaveBeenCalled(); + expect(mocks.job.getQueueStatus).toHaveBeenCalledTimes(1); + expect(mocks.job.pause).toHaveBeenCalledTimes(1); + expect(mocks.job.waitForQueueCompletion).toHaveBeenCalledTimes(1); + expect(mocks.job.resume).toHaveBeenCalledTimes(1); }); it('should skip pausing and resuming queue if already paused', async () => { - searchMock.getDimensionSize.mockResolvedValue(512); - jobMock.getQueueStatus.mockResolvedValue({ isActive: false, isPaused: true }); + mocks.search.getDimensionSize.mockResolvedValue(512); + mocks.job.getQueueStatus.mockResolvedValue({ isActive: false, isPaused: true }); await sut.onConfigUpdate({ newConfig: { @@ -217,115 +204,119 @@ describe(SmartInfoService.name, () => { } as SystemConfig, }); - expect(searchMock.getDimensionSize).toHaveBeenCalledTimes(1); - expect(searchMock.setDimensionSize).not.toHaveBeenCalled(); - expect(jobMock.getQueueStatus).toHaveBeenCalledTimes(1); - expect(jobMock.pause).not.toHaveBeenCalled(); - expect(jobMock.waitForQueueCompletion).toHaveBeenCalledTimes(1); - expect(jobMock.resume).not.toHaveBeenCalled(); + expect(mocks.search.getDimensionSize).toHaveBeenCalledTimes(1); + expect(mocks.search.setDimensionSize).not.toHaveBeenCalled(); + expect(mocks.job.getQueueStatus).toHaveBeenCalledTimes(1); + expect(mocks.job.pause).not.toHaveBeenCalled(); + expect(mocks.job.waitForQueueCompletion).toHaveBeenCalledTimes(1); + expect(mocks.job.resume).not.toHaveBeenCalled(); }); }); describe('handleQueueEncodeClip', () => { it('should do nothing if machine learning is disabled', async () => { - systemMock.get.mockResolvedValue(systemConfigStub.machineLearningDisabled); + mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.machineLearningDisabled); await sut.handleQueueEncodeClip({}); - expect(assetMock.getAll).not.toHaveBeenCalled(); - expect(assetMock.getWithout).not.toHaveBeenCalled(); + expect(mocks.asset.getAll).not.toHaveBeenCalled(); + expect(mocks.asset.getWithout).not.toHaveBeenCalled(); }); it('should queue the assets without clip embeddings', async () => { - assetMock.getWithout.mockResolvedValue({ + mocks.asset.getWithout.mockResolvedValue({ items: [assetStub.image], hasNextPage: false, }); await sut.handleQueueEncodeClip({ force: false }); - expect(jobMock.queueAll).toHaveBeenCalledWith([{ name: JobName.SMART_SEARCH, data: { id: assetStub.image.id } }]); - expect(assetMock.getWithout).toHaveBeenCalledWith({ skip: 0, take: 1000 }, WithoutProperty.SMART_SEARCH); - expect(searchMock.deleteAllSearchEmbeddings).not.toHaveBeenCalled(); + expect(mocks.job.queueAll).toHaveBeenCalledWith([ + { name: JobName.SMART_SEARCH, data: { id: assetStub.image.id } }, + ]); + expect(mocks.asset.getWithout).toHaveBeenCalledWith({ skip: 0, take: 1000 }, WithoutProperty.SMART_SEARCH); + expect(mocks.search.deleteAllSearchEmbeddings).not.toHaveBeenCalled(); }); it('should queue all the assets', async () => { - assetMock.getAll.mockResolvedValue({ + mocks.asset.getAll.mockResolvedValue({ items: [assetStub.image], hasNextPage: false, }); await sut.handleQueueEncodeClip({ force: true }); - expect(jobMock.queueAll).toHaveBeenCalledWith([{ name: JobName.SMART_SEARCH, data: { id: assetStub.image.id } }]); - expect(assetMock.getAll).toHaveBeenCalled(); - expect(searchMock.deleteAllSearchEmbeddings).toHaveBeenCalled(); + expect(mocks.job.queueAll).toHaveBeenCalledWith([ + { name: JobName.SMART_SEARCH, data: { id: assetStub.image.id } }, + ]); + expect(mocks.asset.getAll).toHaveBeenCalled(); + expect(mocks.search.deleteAllSearchEmbeddings).toHaveBeenCalled(); }); }); describe('handleEncodeClip', () => { it('should do nothing if machine learning is disabled', async () => { - systemMock.get.mockResolvedValue(systemConfigStub.machineLearningDisabled); + mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.machineLearningDisabled); expect(await sut.handleEncodeClip({ id: '123' })).toEqual(JobStatus.SKIPPED); - expect(assetMock.getByIds).not.toHaveBeenCalled(); - expect(machineLearningMock.encodeImage).not.toHaveBeenCalled(); + expect(mocks.asset.getByIds).not.toHaveBeenCalled(); + expect(mocks.machineLearning.encodeImage).not.toHaveBeenCalled(); }); it('should skip assets without a resize path', async () => { - assetMock.getByIds.mockResolvedValue([assetStub.noResizePath]); + mocks.asset.getByIds.mockResolvedValue([assetStub.noResizePath]); expect(await sut.handleEncodeClip({ id: assetStub.noResizePath.id })).toEqual(JobStatus.FAILED); - expect(searchMock.upsert).not.toHaveBeenCalled(); - expect(machineLearningMock.encodeImage).not.toHaveBeenCalled(); + expect(mocks.search.upsert).not.toHaveBeenCalled(); + expect(mocks.machineLearning.encodeImage).not.toHaveBeenCalled(); }); it('should save the returned objects', async () => { - machineLearningMock.encodeImage.mockResolvedValue('[0.01, 0.02, 0.03]'); + mocks.machineLearning.encodeImage.mockResolvedValue('[0.01, 0.02, 0.03]'); expect(await sut.handleEncodeClip({ id: assetStub.image.id })).toEqual(JobStatus.SUCCESS); - expect(machineLearningMock.encodeImage).toHaveBeenCalledWith( + expect(mocks.machineLearning.encodeImage).toHaveBeenCalledWith( ['http://immich-machine-learning:3003'], '/uploads/user-id/thumbs/path.jpg', expect.objectContaining({ modelName: 'ViT-B-32__openai' }), ); - expect(searchMock.upsert).toHaveBeenCalledWith(assetStub.image.id, '[0.01, 0.02, 0.03]'); + expect(mocks.search.upsert).toHaveBeenCalledWith(assetStub.image.id, '[0.01, 0.02, 0.03]'); }); it('should skip invisible assets', async () => { - assetMock.getByIds.mockResolvedValue([assetStub.livePhotoMotionAsset]); + mocks.asset.getByIds.mockResolvedValue([assetStub.livePhotoMotionAsset]); expect(await sut.handleEncodeClip({ id: assetStub.livePhotoMotionAsset.id })).toEqual(JobStatus.SKIPPED); - expect(machineLearningMock.encodeImage).not.toHaveBeenCalled(); - expect(searchMock.upsert).not.toHaveBeenCalled(); + expect(mocks.machineLearning.encodeImage).not.toHaveBeenCalled(); + expect(mocks.search.upsert).not.toHaveBeenCalled(); }); it('should fail if asset could not be found', async () => { - assetMock.getByIds.mockResolvedValue([]); + mocks.asset.getByIds.mockResolvedValue([]); expect(await sut.handleEncodeClip({ id: assetStub.image.id })).toEqual(JobStatus.FAILED); - expect(machineLearningMock.encodeImage).not.toHaveBeenCalled(); - expect(searchMock.upsert).not.toHaveBeenCalled(); + expect(mocks.machineLearning.encodeImage).not.toHaveBeenCalled(); + expect(mocks.search.upsert).not.toHaveBeenCalled(); }); it('should wait for database', async () => { - machineLearningMock.encodeImage.mockResolvedValue('[0.01, 0.02, 0.03]'); - databaseMock.isBusy.mockReturnValue(true); + mocks.machineLearning.encodeImage.mockResolvedValue('[0.01, 0.02, 0.03]'); + mocks.database.isBusy.mockReturnValue(true); expect(await sut.handleEncodeClip({ id: assetStub.image.id })).toEqual(JobStatus.SUCCESS); - expect(databaseMock.wait).toHaveBeenCalledWith(512); - expect(machineLearningMock.encodeImage).toHaveBeenCalledWith( + expect(mocks.database.wait).toHaveBeenCalledWith(512); + expect(mocks.machineLearning.encodeImage).toHaveBeenCalledWith( ['http://immich-machine-learning:3003'], '/uploads/user-id/thumbs/path.jpg', expect.objectContaining({ modelName: 'ViT-B-32__openai' }), ); - expect(searchMock.upsert).toHaveBeenCalledWith(assetStub.image.id, '[0.01, 0.02, 0.03]'); + expect(mocks.search.upsert).toHaveBeenCalledWith(assetStub.image.id, '[0.01, 0.02, 0.03]'); }); }); diff --git a/server/src/services/stack.service.spec.ts b/server/src/services/stack.service.spec.ts index f37e2c4af4..5fbc0e185d 100644 --- a/server/src/services/stack.service.spec.ts +++ b/server/src/services/stack.service.spec.ts @@ -1,22 +1,15 @@ import { BadRequestException } from '@nestjs/common'; -import { IEventRepository } from 'src/interfaces/event.interface'; -import { IStackRepository } from 'src/interfaces/stack.interface'; import { StackService } from 'src/services/stack.service'; import { assetStub, stackStub } from 'test/fixtures/asset.stub'; import { authStub } from 'test/fixtures/auth.stub'; -import { IAccessRepositoryMock } from 'test/repositories/access.repository.mock'; -import { newTestService } from 'test/utils'; -import { Mocked } from 'vitest'; +import { newTestService, ServiceMocks } from 'test/utils'; describe(StackService.name, () => { let sut: StackService; - - let accessMock: IAccessRepositoryMock; - let eventMock: Mocked; - let stackMock: Mocked; + let mocks: ServiceMocks; beforeEach(() => { - ({ sut, accessMock, eventMock, stackMock } = newTestService(StackService)); + ({ sut, mocks } = newTestService(StackService)); }); it('should be defined', () => { @@ -25,10 +18,10 @@ describe(StackService.name, () => { describe('search', () => { it('should search stacks', async () => { - stackMock.search.mockResolvedValue([stackStub('stack-id', [assetStub.image])]); + mocks.stack.search.mockResolvedValue([stackStub('stack-id', [assetStub.image])]); await sut.search(authStub.admin, { primaryAssetId: assetStub.image.id }); - expect(stackMock.search).toHaveBeenCalledWith({ + expect(mocks.stack.search).toHaveBeenCalledWith({ ownerId: authStub.admin.user.id, primaryAssetId: assetStub.image.id, }); @@ -41,13 +34,13 @@ describe(StackService.name, () => { sut.create(authStub.admin, { assetIds: [assetStub.image.id, assetStub.image1.id] }), ).rejects.toBeInstanceOf(BadRequestException); - expect(accessMock.asset.checkOwnerAccess).toHaveBeenCalled(); - expect(stackMock.create).not.toHaveBeenCalled(); + expect(mocks.access.asset.checkOwnerAccess).toHaveBeenCalled(); + expect(mocks.stack.create).not.toHaveBeenCalled(); }); it('should create a stack', async () => { - accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.image.id, assetStub.image1.id])); - stackMock.create.mockResolvedValue(stackStub('stack-id', [assetStub.image, assetStub.image1])); + mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.image.id, assetStub.image1.id])); + mocks.stack.create.mockResolvedValue(stackStub('stack-id', [assetStub.image, assetStub.image1])); await expect( sut.create(authStub.admin, { assetIds: [assetStub.image.id, assetStub.image1.id] }), ).resolves.toEqual({ @@ -59,11 +52,11 @@ describe(StackService.name, () => { ], }); - expect(eventMock.emit).toHaveBeenCalledWith('stack.create', { + expect(mocks.event.emit).toHaveBeenCalledWith('stack.create', { stackId: 'stack-id', userId: authStub.admin.user.id, }); - expect(accessMock.asset.checkOwnerAccess).toHaveBeenCalled(); + expect(mocks.access.asset.checkOwnerAccess).toHaveBeenCalled(); }); }); @@ -71,22 +64,22 @@ describe(StackService.name, () => { it('should require stack.read permissions', async () => { await expect(sut.get(authStub.admin, 'stack-id')).rejects.toBeInstanceOf(BadRequestException); - expect(accessMock.stack.checkOwnerAccess).toHaveBeenCalled(); - expect(stackMock.getById).not.toHaveBeenCalled(); + expect(mocks.access.stack.checkOwnerAccess).toHaveBeenCalled(); + expect(mocks.stack.getById).not.toHaveBeenCalled(); }); it('should fail if stack could not be found', async () => { - accessMock.stack.checkOwnerAccess.mockResolvedValue(new Set(['stack-id'])); + mocks.access.stack.checkOwnerAccess.mockResolvedValue(new Set(['stack-id'])); await expect(sut.get(authStub.admin, 'stack-id')).rejects.toBeInstanceOf(Error); - expect(accessMock.stack.checkOwnerAccess).toHaveBeenCalled(); - expect(stackMock.getById).toHaveBeenCalledWith('stack-id'); + expect(mocks.access.stack.checkOwnerAccess).toHaveBeenCalled(); + expect(mocks.stack.getById).toHaveBeenCalledWith('stack-id'); }); it('should get stack', async () => { - accessMock.stack.checkOwnerAccess.mockResolvedValue(new Set(['stack-id'])); - stackMock.getById.mockResolvedValue(stackStub('stack-id', [assetStub.image, assetStub.image1])); + mocks.access.stack.checkOwnerAccess.mockResolvedValue(new Set(['stack-id'])); + mocks.stack.getById.mockResolvedValue(stackStub('stack-id', [assetStub.image, assetStub.image1])); await expect(sut.get(authStub.admin, 'stack-id')).resolves.toEqual({ id: 'stack-id', @@ -96,8 +89,8 @@ describe(StackService.name, () => { expect.objectContaining({ id: assetStub.image1.id }), ], }); - expect(accessMock.stack.checkOwnerAccess).toHaveBeenCalled(); - expect(stackMock.getById).toHaveBeenCalledWith('stack-id'); + expect(mocks.access.stack.checkOwnerAccess).toHaveBeenCalled(); + expect(mocks.stack.getById).toHaveBeenCalledWith('stack-id'); }); }); @@ -105,47 +98,47 @@ describe(StackService.name, () => { it('should require stack.update permissions', async () => { await expect(sut.update(authStub.admin, 'stack-id', {})).rejects.toBeInstanceOf(BadRequestException); - expect(stackMock.getById).not.toHaveBeenCalled(); - expect(stackMock.update).not.toHaveBeenCalled(); - expect(eventMock.emit).not.toHaveBeenCalled(); + expect(mocks.stack.getById).not.toHaveBeenCalled(); + expect(mocks.stack.update).not.toHaveBeenCalled(); + expect(mocks.event.emit).not.toHaveBeenCalled(); }); it('should fail if stack could not be found', async () => { - accessMock.stack.checkOwnerAccess.mockResolvedValue(new Set(['stack-id'])); + mocks.access.stack.checkOwnerAccess.mockResolvedValue(new Set(['stack-id'])); await expect(sut.update(authStub.admin, 'stack-id', {})).rejects.toBeInstanceOf(Error); - expect(stackMock.getById).toHaveBeenCalledWith('stack-id'); - expect(stackMock.update).not.toHaveBeenCalled(); - expect(eventMock.emit).not.toHaveBeenCalled(); + expect(mocks.stack.getById).toHaveBeenCalledWith('stack-id'); + expect(mocks.stack.update).not.toHaveBeenCalled(); + expect(mocks.event.emit).not.toHaveBeenCalled(); }); it('should fail if the provided primary asset id is not in the stack', async () => { - accessMock.stack.checkOwnerAccess.mockResolvedValue(new Set(['stack-id'])); - stackMock.getById.mockResolvedValue(stackStub('stack-id', [assetStub.image, assetStub.image1])); + mocks.access.stack.checkOwnerAccess.mockResolvedValue(new Set(['stack-id'])); + mocks.stack.getById.mockResolvedValue(stackStub('stack-id', [assetStub.image, assetStub.image1])); await expect(sut.update(authStub.admin, 'stack-id', { primaryAssetId: 'unknown-asset' })).rejects.toBeInstanceOf( BadRequestException, ); - expect(stackMock.getById).toHaveBeenCalledWith('stack-id'); - expect(stackMock.update).not.toHaveBeenCalled(); - expect(eventMock.emit).not.toHaveBeenCalled(); + expect(mocks.stack.getById).toHaveBeenCalledWith('stack-id'); + expect(mocks.stack.update).not.toHaveBeenCalled(); + expect(mocks.event.emit).not.toHaveBeenCalled(); }); it('should update stack', async () => { - accessMock.stack.checkOwnerAccess.mockResolvedValue(new Set(['stack-id'])); - stackMock.getById.mockResolvedValue(stackStub('stack-id', [assetStub.image, assetStub.image1])); - stackMock.update.mockResolvedValue(stackStub('stack-id', [assetStub.image, assetStub.image1])); + mocks.access.stack.checkOwnerAccess.mockResolvedValue(new Set(['stack-id'])); + mocks.stack.getById.mockResolvedValue(stackStub('stack-id', [assetStub.image, assetStub.image1])); + mocks.stack.update.mockResolvedValue(stackStub('stack-id', [assetStub.image, assetStub.image1])); await sut.update(authStub.admin, 'stack-id', { primaryAssetId: assetStub.image1.id }); - expect(stackMock.getById).toHaveBeenCalledWith('stack-id'); - expect(stackMock.update).toHaveBeenCalledWith('stack-id', { + expect(mocks.stack.getById).toHaveBeenCalledWith('stack-id'); + expect(mocks.stack.update).toHaveBeenCalledWith('stack-id', { id: 'stack-id', primaryAssetId: assetStub.image1.id, }); - expect(eventMock.emit).toHaveBeenCalledWith('stack.update', { + expect(mocks.event.emit).toHaveBeenCalledWith('stack.update', { stackId: 'stack-id', userId: authStub.admin.user.id, }); @@ -156,17 +149,17 @@ describe(StackService.name, () => { it('should require stack.delete permissions', async () => { await expect(sut.delete(authStub.admin, 'stack-id')).rejects.toBeInstanceOf(BadRequestException); - expect(stackMock.delete).not.toHaveBeenCalled(); - expect(eventMock.emit).not.toHaveBeenCalled(); + expect(mocks.stack.delete).not.toHaveBeenCalled(); + expect(mocks.event.emit).not.toHaveBeenCalled(); }); it('should delete stack', async () => { - accessMock.stack.checkOwnerAccess.mockResolvedValue(new Set(['stack-id'])); + mocks.access.stack.checkOwnerAccess.mockResolvedValue(new Set(['stack-id'])); await sut.delete(authStub.admin, 'stack-id'); - expect(stackMock.delete).toHaveBeenCalledWith('stack-id'); - expect(eventMock.emit).toHaveBeenCalledWith('stack.delete', { + expect(mocks.stack.delete).toHaveBeenCalledWith('stack-id'); + expect(mocks.event.emit).toHaveBeenCalledWith('stack.delete', { stackId: 'stack-id', userId: authStub.admin.user.id, }); @@ -177,17 +170,17 @@ describe(StackService.name, () => { it('should require stack.delete permissions', async () => { await expect(sut.deleteAll(authStub.admin, { ids: ['stack-id'] })).rejects.toBeInstanceOf(BadRequestException); - expect(stackMock.deleteAll).not.toHaveBeenCalled(); - expect(eventMock.emit).not.toHaveBeenCalled(); + expect(mocks.stack.deleteAll).not.toHaveBeenCalled(); + expect(mocks.event.emit).not.toHaveBeenCalled(); }); it('should delete all stacks', async () => { - accessMock.stack.checkOwnerAccess.mockResolvedValue(new Set(['stack-id'])); + mocks.access.stack.checkOwnerAccess.mockResolvedValue(new Set(['stack-id'])); await sut.deleteAll(authStub.admin, { ids: ['stack-id'] }); - expect(stackMock.deleteAll).toHaveBeenCalledWith(['stack-id']); - expect(eventMock.emit).toHaveBeenCalledWith('stacks.delete', { + expect(mocks.stack.deleteAll).toHaveBeenCalledWith(['stack-id']); + expect(mocks.event.emit).toHaveBeenCalledWith('stacks.delete', { stackIds: ['stack-id'], userId: authStub.admin.user.id, }); diff --git a/server/src/services/storage-template.service.spec.ts b/server/src/services/storage-template.service.spec.ts index 467456d5aa..cf272c7e5f 100644 --- a/server/src/services/storage-template.service.spec.ts +++ b/server/src/services/storage-template.service.spec.ts @@ -1,42 +1,26 @@ import { Stats } from 'node:fs'; -import { SystemConfig, defaults } from 'src/config'; +import { defaults, SystemConfig } from 'src/config'; import { AssetEntity } from 'src/entities/asset.entity'; import { AssetPathType } from 'src/enum'; -import { IAlbumRepository } from 'src/interfaces/album.interface'; -import { IAssetRepository } from 'src/interfaces/asset.interface'; -import { ICryptoRepository } from 'src/interfaces/crypto.interface'; import { JobStatus } from 'src/interfaces/job.interface'; -import { IMoveRepository } from 'src/interfaces/move.interface'; -import { IStorageRepository } from 'src/interfaces/storage.interface'; -import { IUserRepository } from 'src/interfaces/user.interface'; import { StorageTemplateService } from 'src/services/storage-template.service'; -import { ISystemMetadataRepository } from 'src/types'; import { albumStub } from 'test/fixtures/album.stub'; import { assetStub } from 'test/fixtures/asset.stub'; import { userStub } from 'test/fixtures/user.stub'; -import { newTestService } from 'test/utils'; -import { Mocked } from 'vitest'; +import { newTestService, ServiceMocks } from 'test/utils'; describe(StorageTemplateService.name, () => { let sut: StorageTemplateService; - - let albumMock: Mocked; - let assetMock: Mocked; - let cryptoMock: Mocked; - let moveMock: Mocked; - let storageMock: Mocked; - let systemMock: Mocked; - let userMock: Mocked; + let mocks: ServiceMocks; it('should work', () => { expect(sut).toBeDefined(); }); beforeEach(() => { - ({ sut, albumMock, assetMock, cryptoMock, moveMock, storageMock, systemMock, userMock } = - newTestService(StorageTemplateService)); + ({ sut, mocks } = newTestService(StorageTemplateService)); - systemMock.get.mockResolvedValue({ storageTemplate: { enabled: true } }); + mocks.systemMetadata.get.mockResolvedValue({ storageTemplate: { enabled: true } }); sut.onConfigInit({ newConfig: defaults }); }); @@ -107,31 +91,31 @@ describe(StorageTemplateService.name, () => { describe('handleMigrationSingle', () => { it('should skip when storage template is disabled', async () => { - systemMock.get.mockResolvedValue({ storageTemplate: { enabled: false } }); + mocks.systemMetadata.get.mockResolvedValue({ storageTemplate: { enabled: false } }); await expect(sut.handleMigrationSingle({ id: assetStub.image.id })).resolves.toBe(JobStatus.SKIPPED); - expect(assetMock.getByIds).not.toHaveBeenCalled(); - expect(storageMock.checkFileExists).not.toHaveBeenCalled(); - expect(storageMock.rename).not.toHaveBeenCalled(); - expect(storageMock.copyFile).not.toHaveBeenCalled(); - expect(assetMock.update).not.toHaveBeenCalled(); - expect(moveMock.create).not.toHaveBeenCalled(); - expect(moveMock.update).not.toHaveBeenCalled(); - expect(storageMock.stat).not.toHaveBeenCalled(); + expect(mocks.asset.getByIds).not.toHaveBeenCalled(); + expect(mocks.storage.checkFileExists).not.toHaveBeenCalled(); + expect(mocks.storage.rename).not.toHaveBeenCalled(); + expect(mocks.storage.copyFile).not.toHaveBeenCalled(); + expect(mocks.asset.update).not.toHaveBeenCalled(); + expect(mocks.move.create).not.toHaveBeenCalled(); + expect(mocks.move.update).not.toHaveBeenCalled(); + expect(mocks.storage.stat).not.toHaveBeenCalled(); }); it('should migrate single moving picture', async () => { - userMock.get.mockResolvedValue(userStub.user1); + mocks.user.get.mockResolvedValue(userStub.user1); const newMotionPicturePath = `upload/library/${userStub.user1.id}/2022/2022-06-19/${assetStub.livePhotoStillAsset.id}.mp4`; const newStillPicturePath = `upload/library/${userStub.user1.id}/2022/2022-06-19/${assetStub.livePhotoStillAsset.id}.jpeg`; - assetMock.getByIds.mockImplementation((ids) => { + mocks.asset.getByIds.mockImplementation((ids) => { const assets = [assetStub.livePhotoStillAsset, assetStub.livePhotoMotionAsset]; return Promise.resolve( ids.map((id) => assets.find((asset) => asset.id === id)).filter((asset) => !!asset), ) as Promise; }); - moveMock.create.mockResolvedValueOnce({ + mocks.move.create.mockResolvedValueOnce({ id: '123', entityId: assetStub.livePhotoStillAsset.id, pathType: AssetPathType.ORIGINAL, @@ -139,7 +123,7 @@ describe(StorageTemplateService.name, () => { newPath: newStillPicturePath, }); - moveMock.create.mockResolvedValueOnce({ + mocks.move.create.mockResolvedValueOnce({ id: '124', entityId: assetStub.livePhotoMotionAsset.id, pathType: AssetPathType.ORIGINAL, @@ -151,14 +135,14 @@ describe(StorageTemplateService.name, () => { JobStatus.SUCCESS, ); - expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.livePhotoStillAsset.id], { exifInfo: true }); - expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.livePhotoMotionAsset.id], { exifInfo: true }); - expect(storageMock.checkFileExists).toHaveBeenCalledTimes(2); - expect(assetMock.update).toHaveBeenCalledWith({ + expect(mocks.asset.getByIds).toHaveBeenCalledWith([assetStub.livePhotoStillAsset.id], { exifInfo: true }); + expect(mocks.asset.getByIds).toHaveBeenCalledWith([assetStub.livePhotoMotionAsset.id], { exifInfo: true }); + expect(mocks.storage.checkFileExists).toHaveBeenCalledTimes(2); + expect(mocks.asset.update).toHaveBeenCalledWith({ id: assetStub.livePhotoStillAsset.id, originalPath: newStillPicturePath, }); - expect(assetMock.update).toHaveBeenCalledWith({ + expect(mocks.asset.update).toHaveBeenCalledWith({ id: assetStub.livePhotoMotionAsset.id, originalPath: newMotionPicturePath, }); @@ -173,13 +157,13 @@ describe(StorageTemplateService.name, () => { sut.onConfigInit({ newConfig: config }); - userMock.get.mockResolvedValue(user); - assetMock.getByIds.mockResolvedValueOnce([asset]); - albumMock.getByAssetId.mockResolvedValueOnce([album]); + mocks.user.get.mockResolvedValue(user); + mocks.asset.getByIds.mockResolvedValueOnce([asset]); + mocks.album.getByAssetId.mockResolvedValueOnce([album]); expect(await sut.handleMigrationSingle({ id: asset.id })).toBe(JobStatus.SUCCESS); - expect(moveMock.create).toHaveBeenCalledWith({ + expect(mocks.move.create).toHaveBeenCalledWith({ entityId: asset.id, newPath: `upload/library/${user.id}/${asset.fileCreatedAt.getFullYear()}/${album.albumName}/${asset.originalFileName}`, oldPath: asset.originalPath, @@ -194,13 +178,13 @@ describe(StorageTemplateService.name, () => { config.storageTemplate.template = '{{y}}/{{#if album}}{{album}}{{else}}other//{{MM}}{{/if}}/{{filename}}'; sut.onConfigInit({ newConfig: config }); - userMock.get.mockResolvedValue(user); - assetMock.getByIds.mockResolvedValueOnce([asset]); + mocks.user.get.mockResolvedValue(user); + mocks.asset.getByIds.mockResolvedValueOnce([asset]); expect(await sut.handleMigrationSingle({ id: asset.id })).toBe(JobStatus.SUCCESS); const month = (asset.fileCreatedAt.getMonth() + 1).toString().padStart(2, '0'); - expect(moveMock.create).toHaveBeenCalledWith({ + expect(mocks.move.create).toHaveBeenCalledWith({ entityId: asset.id, newPath: `upload/library/${user.id}/${asset.fileCreatedAt.getFullYear()}/other/${month}/${asset.originalFileName}`, oldPath: asset.originalPath, @@ -209,20 +193,22 @@ describe(StorageTemplateService.name, () => { }); it('should migrate previously failed move from original path when it still exists', async () => { - userMock.get.mockResolvedValue(userStub.user1); + mocks.user.get.mockResolvedValue(userStub.user1); const previousFailedNewPath = `upload/library/${userStub.user1.id}/2023/Feb/${assetStub.image.id}.jpg`; const newPath = `upload/library/${userStub.user1.id}/2023/2023-02-23/${assetStub.image.id}.jpg`; - storageMock.checkFileExists.mockImplementation((path) => Promise.resolve(path === assetStub.image.originalPath)); - moveMock.getByEntity.mockResolvedValue({ + mocks.storage.checkFileExists.mockImplementation((path) => + Promise.resolve(path === assetStub.image.originalPath), + ); + mocks.move.getByEntity.mockResolvedValue({ id: '123', entityId: assetStub.image.id, pathType: AssetPathType.ORIGINAL, oldPath: assetStub.image.originalPath, newPath: previousFailedNewPath, }); - assetMock.getByIds.mockResolvedValue([assetStub.image]); - moveMock.update.mockResolvedValue({ + mocks.asset.getByIds.mockResolvedValue([assetStub.image]); + mocks.move.update.mockResolvedValue({ id: '123', entityId: assetStub.image.id, pathType: AssetPathType.ORIGINAL, @@ -232,37 +218,37 @@ describe(StorageTemplateService.name, () => { await expect(sut.handleMigrationSingle({ id: assetStub.image.id })).resolves.toBe(JobStatus.SUCCESS); - expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.image.id], { exifInfo: true }); - expect(storageMock.checkFileExists).toHaveBeenCalledTimes(3); - expect(storageMock.rename).toHaveBeenCalledWith(assetStub.image.originalPath, newPath); - expect(moveMock.update).toHaveBeenCalledWith('123', { + expect(mocks.asset.getByIds).toHaveBeenCalledWith([assetStub.image.id], { exifInfo: true }); + expect(mocks.storage.checkFileExists).toHaveBeenCalledTimes(3); + expect(mocks.storage.rename).toHaveBeenCalledWith(assetStub.image.originalPath, newPath); + expect(mocks.move.update).toHaveBeenCalledWith('123', { id: '123', oldPath: assetStub.image.originalPath, newPath, }); - expect(assetMock.update).toHaveBeenCalledWith({ + expect(mocks.asset.update).toHaveBeenCalledWith({ id: assetStub.image.id, originalPath: newPath, }); }); it('should migrate previously failed move from previous new path when old path no longer exists, should validate file size still matches before moving', async () => { - userMock.get.mockResolvedValue(userStub.user1); + mocks.user.get.mockResolvedValue(userStub.user1); const previousFailedNewPath = `upload/library/${userStub.user1.id}/2023/Feb/${assetStub.image.id}.jpg`; const newPath = `upload/library/${userStub.user1.id}/2023/2023-02-23/${assetStub.image.id}.jpg`; - storageMock.checkFileExists.mockImplementation((path) => Promise.resolve(path === previousFailedNewPath)); - storageMock.stat.mockResolvedValue({ size: 5000 } as Stats); - cryptoMock.hashFile.mockResolvedValue(assetStub.image.checksum); - moveMock.getByEntity.mockResolvedValue({ + mocks.storage.checkFileExists.mockImplementation((path) => Promise.resolve(path === previousFailedNewPath)); + mocks.storage.stat.mockResolvedValue({ size: 5000 } as Stats); + mocks.crypto.hashFile.mockResolvedValue(assetStub.image.checksum); + mocks.move.getByEntity.mockResolvedValue({ id: '123', entityId: assetStub.image.id, pathType: AssetPathType.ORIGINAL, oldPath: assetStub.image.originalPath, newPath: previousFailedNewPath, }); - assetMock.getByIds.mockResolvedValue([assetStub.image]); - moveMock.update.mockResolvedValue({ + mocks.asset.getByIds.mockResolvedValue([assetStub.image]); + mocks.move.update.mockResolvedValue({ id: '123', entityId: assetStub.image.id, pathType: AssetPathType.ORIGINAL, @@ -272,31 +258,31 @@ describe(StorageTemplateService.name, () => { await expect(sut.handleMigrationSingle({ id: assetStub.image.id })).resolves.toBe(JobStatus.SUCCESS); - expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.image.id], { exifInfo: true }); - expect(storageMock.checkFileExists).toHaveBeenCalledTimes(3); - expect(storageMock.stat).toHaveBeenCalledWith(previousFailedNewPath); - expect(storageMock.rename).toHaveBeenCalledWith(previousFailedNewPath, newPath); - expect(storageMock.copyFile).not.toHaveBeenCalled(); - expect(moveMock.update).toHaveBeenCalledWith('123', { + expect(mocks.asset.getByIds).toHaveBeenCalledWith([assetStub.image.id], { exifInfo: true }); + expect(mocks.storage.checkFileExists).toHaveBeenCalledTimes(3); + expect(mocks.storage.stat).toHaveBeenCalledWith(previousFailedNewPath); + expect(mocks.storage.rename).toHaveBeenCalledWith(previousFailedNewPath, newPath); + expect(mocks.storage.copyFile).not.toHaveBeenCalled(); + expect(mocks.move.update).toHaveBeenCalledWith('123', { id: '123', oldPath: previousFailedNewPath, newPath, }); - expect(assetMock.update).toHaveBeenCalledWith({ + expect(mocks.asset.update).toHaveBeenCalledWith({ id: assetStub.image.id, originalPath: newPath, }); }); it('should fail move if copying and hash of asset and the new file do not match', async () => { - userMock.get.mockResolvedValue(userStub.user1); + mocks.user.get.mockResolvedValue(userStub.user1); const newPath = `upload/library/${userStub.user1.id}/2023/2023-02-23/${assetStub.image.id}.jpg`; - storageMock.rename.mockRejectedValue({ code: 'EXDEV' }); - storageMock.stat.mockResolvedValue({ size: 5000 } as Stats); - cryptoMock.hashFile.mockResolvedValue(Buffer.from('different-hash', 'utf8')); - assetMock.getByIds.mockResolvedValue([assetStub.image]); - moveMock.create.mockResolvedValue({ + mocks.storage.rename.mockRejectedValue({ code: 'EXDEV' }); + mocks.storage.stat.mockResolvedValue({ size: 5000 } as Stats); + mocks.crypto.hashFile.mockResolvedValue(Buffer.from('different-hash', 'utf8')); + mocks.asset.getByIds.mockResolvedValue([assetStub.image]); + mocks.move.create.mockResolvedValue({ id: '123', entityId: assetStub.image.id, pathType: AssetPathType.ORIGINAL, @@ -306,20 +292,20 @@ describe(StorageTemplateService.name, () => { await expect(sut.handleMigrationSingle({ id: assetStub.image.id })).resolves.toBe(JobStatus.SUCCESS); - expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.image.id], { exifInfo: true }); - expect(storageMock.checkFileExists).toHaveBeenCalledTimes(1); - expect(storageMock.stat).toHaveBeenCalledWith(newPath); - expect(moveMock.create).toHaveBeenCalledWith({ + expect(mocks.asset.getByIds).toHaveBeenCalledWith([assetStub.image.id], { exifInfo: true }); + expect(mocks.storage.checkFileExists).toHaveBeenCalledTimes(1); + expect(mocks.storage.stat).toHaveBeenCalledWith(newPath); + expect(mocks.move.create).toHaveBeenCalledWith({ entityId: assetStub.image.id, pathType: AssetPathType.ORIGINAL, oldPath: assetStub.image.originalPath, newPath, }); - expect(storageMock.rename).toHaveBeenCalledWith(assetStub.image.originalPath, newPath); - expect(storageMock.copyFile).toHaveBeenCalledWith(assetStub.image.originalPath, newPath); - expect(storageMock.unlink).toHaveBeenCalledWith(newPath); - expect(storageMock.unlink).toHaveBeenCalledTimes(1); - expect(assetMock.update).not.toHaveBeenCalled(); + expect(mocks.storage.rename).toHaveBeenCalledWith(assetStub.image.originalPath, newPath); + expect(mocks.storage.copyFile).toHaveBeenCalledWith(assetStub.image.originalPath, newPath); + expect(mocks.storage.unlink).toHaveBeenCalledWith(newPath); + expect(mocks.storage.unlink).toHaveBeenCalledTimes(1); + expect(mocks.asset.update).not.toHaveBeenCalled(); }); it.each` @@ -329,22 +315,22 @@ describe(StorageTemplateService.name, () => { `( 'should fail to migrate previously failed move from previous new path when old path no longer exists if $reason validation fails', async ({ failedPathChecksum, failedPathSize }) => { - userMock.get.mockResolvedValue(userStub.user1); + mocks.user.get.mockResolvedValue(userStub.user1); const previousFailedNewPath = `upload/library/${userStub.user1.id}/2023/Feb/${assetStub.image.id}.jpg`; const newPath = `upload/library/${userStub.user1.id}/2023/2023-02-23/${assetStub.image.id}.jpg`; - storageMock.checkFileExists.mockImplementation((path) => Promise.resolve(previousFailedNewPath === path)); - storageMock.stat.mockResolvedValue({ size: failedPathSize } as Stats); - cryptoMock.hashFile.mockResolvedValue(failedPathChecksum); - moveMock.getByEntity.mockResolvedValue({ + mocks.storage.checkFileExists.mockImplementation((path) => Promise.resolve(previousFailedNewPath === path)); + mocks.storage.stat.mockResolvedValue({ size: failedPathSize } as Stats); + mocks.crypto.hashFile.mockResolvedValue(failedPathChecksum); + mocks.move.getByEntity.mockResolvedValue({ id: '123', entityId: assetStub.image.id, pathType: AssetPathType.ORIGINAL, oldPath: assetStub.image.originalPath, newPath: previousFailedNewPath, }); - assetMock.getByIds.mockResolvedValue([assetStub.image]); - moveMock.update.mockResolvedValue({ + mocks.asset.getByIds.mockResolvedValue([assetStub.image]); + mocks.move.update.mockResolvedValue({ id: '123', entityId: assetStub.image.id, pathType: AssetPathType.ORIGINAL, @@ -354,37 +340,37 @@ describe(StorageTemplateService.name, () => { await expect(sut.handleMigrationSingle({ id: assetStub.image.id })).resolves.toBe(JobStatus.SUCCESS); - expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.image.id], { exifInfo: true }); - expect(storageMock.checkFileExists).toHaveBeenCalledTimes(3); - expect(storageMock.stat).toHaveBeenCalledWith(previousFailedNewPath); - expect(storageMock.rename).not.toHaveBeenCalled(); - expect(storageMock.copyFile).not.toHaveBeenCalled(); - expect(moveMock.update).not.toHaveBeenCalled(); - expect(assetMock.update).not.toHaveBeenCalled(); + expect(mocks.asset.getByIds).toHaveBeenCalledWith([assetStub.image.id], { exifInfo: true }); + expect(mocks.storage.checkFileExists).toHaveBeenCalledTimes(3); + expect(mocks.storage.stat).toHaveBeenCalledWith(previousFailedNewPath); + expect(mocks.storage.rename).not.toHaveBeenCalled(); + expect(mocks.storage.copyFile).not.toHaveBeenCalled(); + expect(mocks.move.update).not.toHaveBeenCalled(); + expect(mocks.asset.update).not.toHaveBeenCalled(); }, ); }); describe('handle template migration', () => { it('should handle no assets', async () => { - assetMock.getAll.mockResolvedValue({ + mocks.asset.getAll.mockResolvedValue({ items: [], hasNextPage: false, }); - userMock.getList.mockResolvedValue([]); + mocks.user.getList.mockResolvedValue([]); await sut.handleMigration(); - expect(assetMock.getAll).toHaveBeenCalled(); + expect(mocks.asset.getAll).toHaveBeenCalled(); }); it('should handle an asset with a duplicate destination', async () => { - assetMock.getAll.mockResolvedValue({ + mocks.asset.getAll.mockResolvedValue({ items: [assetStub.image], hasNextPage: false, }); - userMock.getList.mockResolvedValue([userStub.user1]); - moveMock.create.mockResolvedValue({ + mocks.user.getList.mockResolvedValue([userStub.user1]); + mocks.move.create.mockResolvedValue({ id: '123', entityId: assetStub.image.id, pathType: AssetPathType.ORIGINAL, @@ -392,22 +378,22 @@ describe(StorageTemplateService.name, () => { newPath: 'upload/library/user-id/2023/2023-02-23/asset-id+1.jpg', }); - storageMock.checkFileExists.mockResolvedValueOnce(true); - storageMock.checkFileExists.mockResolvedValueOnce(false); + mocks.storage.checkFileExists.mockResolvedValueOnce(true); + mocks.storage.checkFileExists.mockResolvedValueOnce(false); await sut.handleMigration(); - expect(assetMock.getAll).toHaveBeenCalled(); - expect(storageMock.checkFileExists).toHaveBeenCalledTimes(2); - expect(assetMock.update).toHaveBeenCalledWith({ + expect(mocks.asset.getAll).toHaveBeenCalled(); + expect(mocks.storage.checkFileExists).toHaveBeenCalledTimes(2); + expect(mocks.asset.update).toHaveBeenCalledWith({ id: assetStub.image.id, originalPath: 'upload/library/user-id/2023/2023-02-23/asset-id+1.jpg', }); - expect(userMock.getList).toHaveBeenCalled(); + expect(mocks.user.getList).toHaveBeenCalled(); }); it('should skip when an asset already matches the template', async () => { - assetMock.getAll.mockResolvedValue({ + mocks.asset.getAll.mockResolvedValue({ items: [ { ...assetStub.image, @@ -416,19 +402,19 @@ describe(StorageTemplateService.name, () => { ], hasNextPage: false, }); - userMock.getList.mockResolvedValue([userStub.user1]); + mocks.user.getList.mockResolvedValue([userStub.user1]); await sut.handleMigration(); - expect(assetMock.getAll).toHaveBeenCalled(); - expect(storageMock.rename).not.toHaveBeenCalled(); - expect(storageMock.copyFile).not.toHaveBeenCalled(); - expect(storageMock.checkFileExists).not.toHaveBeenCalledTimes(2); - expect(assetMock.update).not.toHaveBeenCalled(); + expect(mocks.asset.getAll).toHaveBeenCalled(); + expect(mocks.storage.rename).not.toHaveBeenCalled(); + expect(mocks.storage.copyFile).not.toHaveBeenCalled(); + expect(mocks.storage.checkFileExists).not.toHaveBeenCalledTimes(2); + expect(mocks.asset.update).not.toHaveBeenCalled(); }); it('should skip when an asset is probably a duplicate', async () => { - assetMock.getAll.mockResolvedValue({ + mocks.asset.getAll.mockResolvedValue({ items: [ { ...assetStub.image, @@ -437,24 +423,24 @@ describe(StorageTemplateService.name, () => { ], hasNextPage: false, }); - userMock.getList.mockResolvedValue([userStub.user1]); + mocks.user.getList.mockResolvedValue([userStub.user1]); await sut.handleMigration(); - expect(assetMock.getAll).toHaveBeenCalled(); - expect(storageMock.rename).not.toHaveBeenCalled(); - expect(storageMock.copyFile).not.toHaveBeenCalled(); - expect(storageMock.checkFileExists).not.toHaveBeenCalledTimes(2); - expect(assetMock.update).not.toHaveBeenCalled(); + expect(mocks.asset.getAll).toHaveBeenCalled(); + expect(mocks.storage.rename).not.toHaveBeenCalled(); + expect(mocks.storage.copyFile).not.toHaveBeenCalled(); + expect(mocks.storage.checkFileExists).not.toHaveBeenCalledTimes(2); + expect(mocks.asset.update).not.toHaveBeenCalled(); }); it('should move an asset', async () => { - assetMock.getAll.mockResolvedValue({ + mocks.asset.getAll.mockResolvedValue({ items: [assetStub.image], hasNextPage: false, }); - userMock.getList.mockResolvedValue([userStub.user1]); - moveMock.create.mockResolvedValue({ + mocks.user.getList.mockResolvedValue([userStub.user1]); + mocks.move.create.mockResolvedValue({ id: '123', entityId: assetStub.image.id, pathType: AssetPathType.ORIGINAL, @@ -464,24 +450,24 @@ describe(StorageTemplateService.name, () => { await sut.handleMigration(); - expect(assetMock.getAll).toHaveBeenCalled(); - expect(storageMock.rename).toHaveBeenCalledWith( + expect(mocks.asset.getAll).toHaveBeenCalled(); + expect(mocks.storage.rename).toHaveBeenCalledWith( '/original/path.jpg', 'upload/library/user-id/2023/2023-02-23/asset-id.jpg', ); - expect(assetMock.update).toHaveBeenCalledWith({ + expect(mocks.asset.update).toHaveBeenCalledWith({ id: assetStub.image.id, originalPath: 'upload/library/user-id/2023/2023-02-23/asset-id.jpg', }); }); it('should use the user storage label', async () => { - assetMock.getAll.mockResolvedValue({ + mocks.asset.getAll.mockResolvedValue({ items: [assetStub.image], hasNextPage: false, }); - userMock.getList.mockResolvedValue([userStub.storageLabel]); - moveMock.create.mockResolvedValue({ + mocks.user.getList.mockResolvedValue([userStub.storageLabel]); + mocks.move.create.mockResolvedValue({ id: '123', entityId: assetStub.image.id, pathType: AssetPathType.ORIGINAL, @@ -491,12 +477,12 @@ describe(StorageTemplateService.name, () => { await sut.handleMigration(); - expect(assetMock.getAll).toHaveBeenCalled(); - expect(storageMock.rename).toHaveBeenCalledWith( + expect(mocks.asset.getAll).toHaveBeenCalled(); + expect(mocks.storage.rename).toHaveBeenCalledWith( '/original/path.jpg', 'upload/library/label-1/2023/2023-02-23/asset-id.jpg', ); - expect(assetMock.update).toHaveBeenCalledWith({ + expect(mocks.asset.update).toHaveBeenCalledWith({ id: assetStub.image.id, originalPath: 'upload/library/label-1/2023/2023-02-23/asset-id.jpg', }); @@ -504,105 +490,105 @@ describe(StorageTemplateService.name, () => { it('should copy the file if rename fails due to EXDEV (rename across filesystems)', async () => { const newPath = 'upload/library/user-id/2023/2023-02-23/asset-id.jpg'; - assetMock.getAll.mockResolvedValue({ + mocks.asset.getAll.mockResolvedValue({ items: [assetStub.image], hasNextPage: false, }); - storageMock.rename.mockRejectedValue({ code: 'EXDEV' }); - userMock.getList.mockResolvedValue([userStub.user1]); - moveMock.create.mockResolvedValue({ + mocks.storage.rename.mockRejectedValue({ code: 'EXDEV' }); + mocks.user.getList.mockResolvedValue([userStub.user1]); + mocks.move.create.mockResolvedValue({ id: '123', entityId: assetStub.image.id, pathType: AssetPathType.ORIGINAL, oldPath: assetStub.image.originalPath, newPath, }); - storageMock.stat.mockResolvedValueOnce({ + mocks.storage.stat.mockResolvedValueOnce({ atime: new Date(), mtime: new Date(), } as Stats); - storageMock.stat.mockResolvedValueOnce({ + mocks.storage.stat.mockResolvedValueOnce({ size: 5000, } as Stats); - storageMock.stat.mockResolvedValueOnce({ + mocks.storage.stat.mockResolvedValueOnce({ atime: new Date(), mtime: new Date(), } as Stats); - cryptoMock.hashFile.mockResolvedValue(assetStub.image.checksum); + mocks.crypto.hashFile.mockResolvedValue(assetStub.image.checksum); await sut.handleMigration(); - expect(assetMock.getAll).toHaveBeenCalled(); - expect(storageMock.rename).toHaveBeenCalledWith('/original/path.jpg', newPath); - expect(storageMock.copyFile).toHaveBeenCalledWith('/original/path.jpg', newPath); - expect(storageMock.stat).toHaveBeenCalledWith(newPath); - expect(storageMock.stat).toHaveBeenCalledWith(assetStub.image.originalPath); - expect(storageMock.utimes).toHaveBeenCalledWith(newPath, expect.any(Date), expect.any(Date)); - expect(storageMock.unlink).toHaveBeenCalledWith(assetStub.image.originalPath); - expect(storageMock.unlink).toHaveBeenCalledTimes(1); - expect(assetMock.update).toHaveBeenCalledWith({ + expect(mocks.asset.getAll).toHaveBeenCalled(); + expect(mocks.storage.rename).toHaveBeenCalledWith('/original/path.jpg', newPath); + expect(mocks.storage.copyFile).toHaveBeenCalledWith('/original/path.jpg', newPath); + expect(mocks.storage.stat).toHaveBeenCalledWith(newPath); + expect(mocks.storage.stat).toHaveBeenCalledWith(assetStub.image.originalPath); + expect(mocks.storage.utimes).toHaveBeenCalledWith(newPath, expect.any(Date), expect.any(Date)); + expect(mocks.storage.unlink).toHaveBeenCalledWith(assetStub.image.originalPath); + expect(mocks.storage.unlink).toHaveBeenCalledTimes(1); + expect(mocks.asset.update).toHaveBeenCalledWith({ id: assetStub.image.id, originalPath: newPath, }); }); it('should not update the database if the move fails due to incorrect newPath filesize', async () => { - assetMock.getAll.mockResolvedValue({ + mocks.asset.getAll.mockResolvedValue({ items: [assetStub.image], hasNextPage: false, }); - storageMock.rename.mockRejectedValue({ code: 'EXDEV' }); - userMock.getList.mockResolvedValue([userStub.user1]); - moveMock.create.mockResolvedValue({ + mocks.storage.rename.mockRejectedValue({ code: 'EXDEV' }); + mocks.user.getList.mockResolvedValue([userStub.user1]); + mocks.move.create.mockResolvedValue({ id: '123', entityId: assetStub.image.id, pathType: AssetPathType.ORIGINAL, oldPath: assetStub.image.originalPath, newPath: 'upload/library/user-id/2023/2023-02-23/asset-id.jpg', }); - storageMock.stat.mockResolvedValue({ + mocks.storage.stat.mockResolvedValue({ size: 100, } as Stats); await sut.handleMigration(); - expect(assetMock.getAll).toHaveBeenCalled(); - expect(storageMock.rename).toHaveBeenCalledWith( + expect(mocks.asset.getAll).toHaveBeenCalled(); + expect(mocks.storage.rename).toHaveBeenCalledWith( '/original/path.jpg', 'upload/library/user-id/2023/2023-02-23/asset-id.jpg', ); - expect(storageMock.copyFile).toHaveBeenCalledWith( + expect(mocks.storage.copyFile).toHaveBeenCalledWith( '/original/path.jpg', 'upload/library/user-id/2023/2023-02-23/asset-id.jpg', ); - expect(storageMock.stat).toHaveBeenCalledWith('upload/library/user-id/2023/2023-02-23/asset-id.jpg'); - expect(assetMock.update).not.toHaveBeenCalled(); + expect(mocks.storage.stat).toHaveBeenCalledWith('upload/library/user-id/2023/2023-02-23/asset-id.jpg'); + expect(mocks.asset.update).not.toHaveBeenCalled(); }); it('should not update the database if the move fails', async () => { - assetMock.getAll.mockResolvedValue({ + mocks.asset.getAll.mockResolvedValue({ items: [assetStub.image], hasNextPage: false, }); - storageMock.rename.mockRejectedValue(new Error('Read only system')); - storageMock.copyFile.mockRejectedValue(new Error('Read only system')); - moveMock.create.mockResolvedValue({ + mocks.storage.rename.mockRejectedValue(new Error('Read only system')); + mocks.storage.copyFile.mockRejectedValue(new Error('Read only system')); + mocks.move.create.mockResolvedValue({ id: 'move-123', entityId: '123', pathType: AssetPathType.ORIGINAL, oldPath: assetStub.image.originalPath, newPath: '', }); - userMock.getList.mockResolvedValue([userStub.user1]); + mocks.user.getList.mockResolvedValue([userStub.user1]); await sut.handleMigration(); - expect(assetMock.getAll).toHaveBeenCalled(); - expect(storageMock.rename).toHaveBeenCalledWith( + expect(mocks.asset.getAll).toHaveBeenCalled(); + expect(mocks.storage.rename).toHaveBeenCalledWith( '/original/path.jpg', 'upload/library/user-id/2023/2023-02-23/asset-id.jpg', ); - expect(assetMock.update).not.toHaveBeenCalled(); + expect(mocks.asset.update).not.toHaveBeenCalled(); }); }); }); diff --git a/server/src/services/storage.service.spec.ts b/server/src/services/storage.service.spec.ts index d92fd09e53..2d28489fae 100644 --- a/server/src/services/storage.service.spec.ts +++ b/server/src/services/storage.service.spec.ts @@ -1,22 +1,15 @@ import { SystemMetadataKey } from 'src/enum'; -import { IStorageRepository } from 'src/interfaces/storage.interface'; import { StorageService } from 'src/services/storage.service'; -import { IConfigRepository, ILoggingRepository, ISystemMetadataRepository } from 'src/types'; import { ImmichStartupError } from 'src/utils/misc'; import { mockEnvData } from 'test/repositories/config.repository.mock'; -import { newTestService } from 'test/utils'; -import { Mocked } from 'vitest'; +import { newTestService, ServiceMocks } from 'test/utils'; describe(StorageService.name, () => { let sut: StorageService; - - let configMock: Mocked; - let loggerMock: Mocked; - let storageMock: Mocked; - let systemMock: Mocked; + let mocks: ServiceMocks; beforeEach(() => { - ({ sut, configMock, loggerMock, storageMock, systemMock } = newTestService(StorageService)); + ({ sut, mocks } = newTestService(StorageService)); }); it('should work', () => { @@ -25,11 +18,11 @@ describe(StorageService.name, () => { describe('onBootstrap', () => { it('should enable mount folder checking', async () => { - systemMock.get.mockResolvedValue(null); + mocks.systemMetadata.get.mockResolvedValue(null); await expect(sut.onBootstrap()).resolves.toBeUndefined(); - expect(systemMock.set).toHaveBeenCalledWith(SystemMetadataKey.SYSTEM_FLAGS, { + expect(mocks.systemMetadata.set).toHaveBeenCalledWith(SystemMetadataKey.SYSTEM_FLAGS, { mountChecks: { backups: true, 'encoded-video': true, @@ -39,22 +32,22 @@ describe(StorageService.name, () => { upload: true, }, }); - expect(storageMock.mkdirSync).toHaveBeenCalledWith('upload/encoded-video'); - expect(storageMock.mkdirSync).toHaveBeenCalledWith('upload/library'); - expect(storageMock.mkdirSync).toHaveBeenCalledWith('upload/profile'); - expect(storageMock.mkdirSync).toHaveBeenCalledWith('upload/thumbs'); - expect(storageMock.mkdirSync).toHaveBeenCalledWith('upload/upload'); - expect(storageMock.mkdirSync).toHaveBeenCalledWith('upload/backups'); - expect(storageMock.createFile).toHaveBeenCalledWith('upload/encoded-video/.immich', expect.any(Buffer)); - expect(storageMock.createFile).toHaveBeenCalledWith('upload/library/.immich', expect.any(Buffer)); - expect(storageMock.createFile).toHaveBeenCalledWith('upload/profile/.immich', expect.any(Buffer)); - expect(storageMock.createFile).toHaveBeenCalledWith('upload/thumbs/.immich', expect.any(Buffer)); - expect(storageMock.createFile).toHaveBeenCalledWith('upload/upload/.immich', expect.any(Buffer)); - expect(storageMock.createFile).toHaveBeenCalledWith('upload/backups/.immich', expect.any(Buffer)); + expect(mocks.storage.mkdirSync).toHaveBeenCalledWith('upload/encoded-video'); + expect(mocks.storage.mkdirSync).toHaveBeenCalledWith('upload/library'); + expect(mocks.storage.mkdirSync).toHaveBeenCalledWith('upload/profile'); + expect(mocks.storage.mkdirSync).toHaveBeenCalledWith('upload/thumbs'); + expect(mocks.storage.mkdirSync).toHaveBeenCalledWith('upload/upload'); + expect(mocks.storage.mkdirSync).toHaveBeenCalledWith('upload/backups'); + expect(mocks.storage.createFile).toHaveBeenCalledWith('upload/encoded-video/.immich', expect.any(Buffer)); + expect(mocks.storage.createFile).toHaveBeenCalledWith('upload/library/.immich', expect.any(Buffer)); + expect(mocks.storage.createFile).toHaveBeenCalledWith('upload/profile/.immich', expect.any(Buffer)); + expect(mocks.storage.createFile).toHaveBeenCalledWith('upload/thumbs/.immich', expect.any(Buffer)); + expect(mocks.storage.createFile).toHaveBeenCalledWith('upload/upload/.immich', expect.any(Buffer)); + expect(mocks.storage.createFile).toHaveBeenCalledWith('upload/backups/.immich', expect.any(Buffer)); }); it('should enable mount folder checking for a new folder type', async () => { - systemMock.get.mockResolvedValue({ + mocks.systemMetadata.get.mockResolvedValue({ mountChecks: { backups: false, 'encoded-video': true, @@ -67,7 +60,7 @@ describe(StorageService.name, () => { await expect(sut.onBootstrap()).resolves.toBeUndefined(); - expect(systemMock.set).toHaveBeenCalledWith(SystemMetadataKey.SYSTEM_FLAGS, { + expect(mocks.systemMetadata.set).toHaveBeenCalledWith(SystemMetadataKey.SYSTEM_FLAGS, { mountChecks: { backups: true, 'encoded-video': true, @@ -77,64 +70,68 @@ describe(StorageService.name, () => { upload: true, }, }); - expect(storageMock.mkdirSync).toHaveBeenCalledTimes(2); - expect(storageMock.mkdirSync).toHaveBeenCalledWith('upload/library'); - expect(storageMock.mkdirSync).toHaveBeenCalledWith('upload/backups'); - expect(storageMock.createFile).toHaveBeenCalledTimes(2); - expect(storageMock.createFile).toHaveBeenCalledWith('upload/library/.immich', expect.any(Buffer)); - expect(storageMock.createFile).toHaveBeenCalledWith('upload/backups/.immich', expect.any(Buffer)); + expect(mocks.storage.mkdirSync).toHaveBeenCalledTimes(2); + expect(mocks.storage.mkdirSync).toHaveBeenCalledWith('upload/library'); + expect(mocks.storage.mkdirSync).toHaveBeenCalledWith('upload/backups'); + expect(mocks.storage.createFile).toHaveBeenCalledTimes(2); + expect(mocks.storage.createFile).toHaveBeenCalledWith('upload/library/.immich', expect.any(Buffer)); + expect(mocks.storage.createFile).toHaveBeenCalledWith('upload/backups/.immich', expect.any(Buffer)); }); it('should throw an error if .immich is missing', async () => { - systemMock.get.mockResolvedValue({ mountChecks: { upload: true } }); - storageMock.readFile.mockRejectedValue(new Error("ENOENT: no such file or directory, open '/app/.immich'")); + mocks.systemMetadata.get.mockResolvedValue({ mountChecks: { upload: true } }); + mocks.storage.readFile.mockRejectedValue(new Error("ENOENT: no such file or directory, open '/app/.immich'")); await expect(sut.onBootstrap()).rejects.toThrow('Failed to read'); - expect(storageMock.createOrOverwriteFile).not.toHaveBeenCalled(); - expect(systemMock.set).not.toHaveBeenCalled(); + expect(mocks.storage.createOrOverwriteFile).not.toHaveBeenCalled(); + expect(mocks.systemMetadata.set).not.toHaveBeenCalled(); }); it('should throw an error if .immich is present but read-only', async () => { - systemMock.get.mockResolvedValue({ mountChecks: { upload: true } }); - storageMock.overwriteFile.mockRejectedValue(new Error("ENOENT: no such file or directory, open '/app/.immich'")); + mocks.systemMetadata.get.mockResolvedValue({ mountChecks: { upload: true } }); + mocks.storage.overwriteFile.mockRejectedValue( + new Error("ENOENT: no such file or directory, open '/app/.immich'"), + ); await expect(sut.onBootstrap()).rejects.toThrow('Failed to write'); - expect(systemMock.set).not.toHaveBeenCalled(); + expect(mocks.systemMetadata.set).not.toHaveBeenCalled(); }); it('should skip mount file creation if file already exists', async () => { const error = new Error('Error creating file') as any; error.code = 'EEXIST'; - systemMock.get.mockResolvedValue({ mountChecks: {} }); - storageMock.createFile.mockRejectedValue(error); + mocks.systemMetadata.get.mockResolvedValue({ mountChecks: {} }); + mocks.storage.createFile.mockRejectedValue(error); await expect(sut.onBootstrap()).resolves.toBeUndefined(); - expect(loggerMock.warn).toHaveBeenCalledWith('Found existing mount file, skipping creation'); + expect(mocks.logger.warn).toHaveBeenCalledWith('Found existing mount file, skipping creation'); }); it('should throw an error if mount file could not be created', async () => { - systemMock.get.mockResolvedValue({ mountChecks: {} }); - storageMock.createFile.mockRejectedValue(new Error('Error creating file')); + mocks.systemMetadata.get.mockResolvedValue({ mountChecks: {} }); + mocks.storage.createFile.mockRejectedValue(new Error('Error creating file')); await expect(sut.onBootstrap()).rejects.toBeInstanceOf(ImmichStartupError); - expect(systemMock.set).not.toHaveBeenCalled(); + expect(mocks.systemMetadata.set).not.toHaveBeenCalled(); }); it('should startup if checks are disabled', async () => { - systemMock.get.mockResolvedValue({ mountChecks: { upload: true } }); - configMock.getEnv.mockReturnValue( + mocks.systemMetadata.get.mockResolvedValue({ mountChecks: { upload: true } }); + mocks.config.getEnv.mockReturnValue( mockEnvData({ storage: { ignoreMountCheckErrors: true }, }), ); - storageMock.overwriteFile.mockRejectedValue(new Error("ENOENT: no such file or directory, open '/app/.immich'")); + mocks.storage.overwriteFile.mockRejectedValue( + new Error("ENOENT: no such file or directory, open '/app/.immich'"), + ); await expect(sut.onBootstrap()).resolves.toBeUndefined(); - expect(systemMock.set).not.toHaveBeenCalled(); + expect(mocks.systemMetadata.set).not.toHaveBeenCalled(); }); }); @@ -142,21 +139,21 @@ describe(StorageService.name, () => { it('should handle null values', async () => { await sut.handleDeleteFiles({ files: [undefined, null] }); - expect(storageMock.unlink).not.toHaveBeenCalled(); + expect(mocks.storage.unlink).not.toHaveBeenCalled(); }); it('should handle an error removing a file', async () => { - storageMock.unlink.mockRejectedValue(new Error('something-went-wrong')); + mocks.storage.unlink.mockRejectedValue(new Error('something-went-wrong')); await sut.handleDeleteFiles({ files: ['path/to/something'] }); - expect(storageMock.unlink).toHaveBeenCalledWith('path/to/something'); + expect(mocks.storage.unlink).toHaveBeenCalledWith('path/to/something'); }); it('should remove the file', async () => { await sut.handleDeleteFiles({ files: ['path/to/something'] }); - expect(storageMock.unlink).toHaveBeenCalledWith('path/to/something'); + expect(mocks.storage.unlink).toHaveBeenCalledWith('path/to/something'); }); }); }); diff --git a/server/src/services/sync.service.spec.ts b/server/src/services/sync.service.spec.ts index 3bedd13d8f..d5e53c83a2 100644 --- a/server/src/services/sync.service.spec.ts +++ b/server/src/services/sync.service.spec.ts @@ -1,27 +1,20 @@ import { mapAsset } from 'src/dtos/asset-response.dto'; import { AssetEntity } from 'src/entities/asset.entity'; -import { IAssetRepository } from 'src/interfaces/asset.interface'; -import { IPartnerRepository } from 'src/interfaces/partner.interface'; import { SyncService } from 'src/services/sync.service'; -import { IAuditRepository } from 'src/types'; import { assetStub } from 'test/fixtures/asset.stub'; import { authStub } from 'test/fixtures/auth.stub'; import { partnerStub } from 'test/fixtures/partner.stub'; -import { newTestService } from 'test/utils'; -import { Mocked } from 'vitest'; +import { newTestService, ServiceMocks } from 'test/utils'; const untilDate = new Date(2024); const mapAssetOpts = { auth: authStub.user1, stripMetadata: false, withStack: true }; describe(SyncService.name, () => { let sut: SyncService; - - let assetMock: Mocked; - let auditMock: Mocked; - let partnerMock: Mocked; + let mocks: ServiceMocks; beforeEach(() => { - ({ sut, assetMock, auditMock, partnerMock } = newTestService(SyncService)); + ({ sut, mocks } = newTestService(SyncService)); }); it('should exist', () => { @@ -30,12 +23,12 @@ describe(SyncService.name, () => { describe('getAllAssetsForUserFullSync', () => { it('should return a list of all assets owned by the user', async () => { - assetMock.getAllForUserFullSync.mockResolvedValue([assetStub.external, assetStub.hasEncodedVideo]); + mocks.asset.getAllForUserFullSync.mockResolvedValue([assetStub.external, assetStub.hasEncodedVideo]); await expect(sut.getFullSync(authStub.user1, { limit: 2, updatedUntil: untilDate })).resolves.toEqual([ mapAsset(assetStub.external, mapAssetOpts), mapAsset(assetStub.hasEncodedVideo, mapAssetOpts), ]); - expect(assetMock.getAllForUserFullSync).toHaveBeenCalledWith({ + expect(mocks.asset.getAllForUserFullSync).toHaveBeenCalledWith({ ownerId: authStub.user1.user.id, updatedUntil: untilDate, limit: 2, @@ -45,39 +38,39 @@ describe(SyncService.name, () => { describe('getChangesForDeltaSync', () => { it('should return a response requiring a full sync when partners are out of sync', async () => { - partnerMock.getAll.mockResolvedValue([partnerStub.adminToUser1]); + mocks.partner.getAll.mockResolvedValue([partnerStub.adminToUser1]); await expect( sut.getDeltaSync(authStub.user1, { updatedAfter: new Date(), userIds: [authStub.user1.user.id] }), ).resolves.toEqual({ needsFullSync: true, upserted: [], deleted: [] }); - expect(assetMock.getChangedDeltaSync).toHaveBeenCalledTimes(0); - expect(auditMock.getAfter).toHaveBeenCalledTimes(0); + expect(mocks.asset.getChangedDeltaSync).toHaveBeenCalledTimes(0); + expect(mocks.audit.getAfter).toHaveBeenCalledTimes(0); }); it('should return a response requiring a full sync when last sync was too long ago', async () => { - partnerMock.getAll.mockResolvedValue([]); + mocks.partner.getAll.mockResolvedValue([]); await expect( sut.getDeltaSync(authStub.user1, { updatedAfter: new Date(2000), userIds: [authStub.user1.user.id] }), ).resolves.toEqual({ needsFullSync: true, upserted: [], deleted: [] }); - expect(assetMock.getChangedDeltaSync).toHaveBeenCalledTimes(0); - expect(auditMock.getAfter).toHaveBeenCalledTimes(0); + expect(mocks.asset.getChangedDeltaSync).toHaveBeenCalledTimes(0); + expect(mocks.audit.getAfter).toHaveBeenCalledTimes(0); }); it('should return a response requiring a full sync when there are too many changes', async () => { - partnerMock.getAll.mockResolvedValue([]); - assetMock.getChangedDeltaSync.mockResolvedValue( + mocks.partner.getAll.mockResolvedValue([]); + mocks.asset.getChangedDeltaSync.mockResolvedValue( Array.from({ length: 10_000 }).fill(assetStub.image), ); await expect( sut.getDeltaSync(authStub.user1, { updatedAfter: new Date(), userIds: [authStub.user1.user.id] }), ).resolves.toEqual({ needsFullSync: true, upserted: [], deleted: [] }); - expect(assetMock.getChangedDeltaSync).toHaveBeenCalledTimes(1); - expect(auditMock.getAfter).toHaveBeenCalledTimes(0); + expect(mocks.asset.getChangedDeltaSync).toHaveBeenCalledTimes(1); + expect(mocks.audit.getAfter).toHaveBeenCalledTimes(0); }); it('should return a response with changes and deletions', async () => { - partnerMock.getAll.mockResolvedValue([]); - assetMock.getChangedDeltaSync.mockResolvedValue([assetStub.image1]); - auditMock.getAfter.mockResolvedValue([assetStub.external.id]); + mocks.partner.getAll.mockResolvedValue([]); + mocks.asset.getChangedDeltaSync.mockResolvedValue([assetStub.image1]); + mocks.audit.getAfter.mockResolvedValue([assetStub.external.id]); await expect( sut.getDeltaSync(authStub.user1, { updatedAfter: new Date(), userIds: [authStub.user1.user.id] }), ).resolves.toEqual({ @@ -85,8 +78,8 @@ describe(SyncService.name, () => { upserted: [mapAsset(assetStub.image1, mapAssetOpts)], deleted: [assetStub.external.id], }); - expect(assetMock.getChangedDeltaSync).toHaveBeenCalledTimes(1); - expect(auditMock.getAfter).toHaveBeenCalledTimes(1); + expect(mocks.asset.getChangedDeltaSync).toHaveBeenCalledTimes(1); + expect(mocks.audit.getAfter).toHaveBeenCalledTimes(1); }); }); }); diff --git a/server/src/services/system-config.service.spec.ts b/server/src/services/system-config.service.spec.ts index 537ef21056..027bcc1c15 100644 --- a/server/src/services/system-config.service.spec.ts +++ b/server/src/services/system-config.service.spec.ts @@ -12,13 +12,11 @@ import { VideoCodec, VideoContainer, } from 'src/enum'; -import { IEventRepository } from 'src/interfaces/event.interface'; import { QueueName } from 'src/interfaces/job.interface'; import { SystemConfigService } from 'src/services/system-config.service'; -import { DeepPartial, IConfigRepository, ILoggingRepository, ISystemMetadataRepository } from 'src/types'; +import { DeepPartial } from 'src/types'; import { mockEnvData } from 'test/repositories/config.repository.mock'; -import { newTestService } from 'test/utils'; -import { Mocked } from 'vitest'; +import { newTestService, ServiceMocks } from 'test/utils'; const partialConfig = { ffmpeg: { crf: 30 }, @@ -198,14 +196,10 @@ const updatedConfig = Object.freeze({ describe(SystemConfigService.name, () => { let sut: SystemConfigService; - - let configMock: Mocked; - let eventMock: Mocked; - let loggerMock: Mocked; - let systemMock: Mocked; + let mocks: ServiceMocks; beforeEach(() => { - ({ sut, configMock, eventMock, loggerMock, systemMock } = newTestService(SystemConfigService)); + ({ sut, mocks } = newTestService(SystemConfigService)); }); it('should work', () => { @@ -214,22 +208,22 @@ describe(SystemConfigService.name, () => { describe('getDefaults', () => { it('should return the default config', () => { - systemMock.get.mockResolvedValue(partialConfig); + mocks.systemMetadata.get.mockResolvedValue(partialConfig); expect(sut.getDefaults()).toEqual(defaults); - expect(systemMock.get).not.toHaveBeenCalled(); + expect(mocks.systemMetadata.get).not.toHaveBeenCalled(); }); }); describe('getConfig', () => { it('should return the default config', async () => { - systemMock.get.mockResolvedValue({}); + mocks.systemMetadata.get.mockResolvedValue({}); await expect(sut.getSystemConfig()).resolves.toEqual(defaults); }); it('should merge the overrides', async () => { - systemMock.get.mockResolvedValue({ + mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { crf: 30 }, oauth: { autoLaunch: true }, trash: { days: 10 }, @@ -240,17 +234,17 @@ describe(SystemConfigService.name, () => { }); it('should load the config from a json file', async () => { - configMock.getEnv.mockReturnValue(mockEnvData({ configFile: 'immich-config.json' })); - systemMock.readFile.mockResolvedValue(JSON.stringify(partialConfig)); + mocks.config.getEnv.mockReturnValue(mockEnvData({ configFile: 'immich-config.json' })); + mocks.systemMetadata.readFile.mockResolvedValue(JSON.stringify(partialConfig)); await expect(sut.getSystemConfig()).resolves.toEqual(updatedConfig); - expect(systemMock.readFile).toHaveBeenCalledWith('immich-config.json'); + expect(mocks.systemMetadata.readFile).toHaveBeenCalledWith('immich-config.json'); }); it('should transform booleans', async () => { - configMock.getEnv.mockReturnValue(mockEnvData({ configFile: 'immich-config.json' })); - systemMock.readFile.mockResolvedValue(JSON.stringify({ ffmpeg: { twoPass: 'false' } })); + mocks.config.getEnv.mockReturnValue(mockEnvData({ configFile: 'immich-config.json' })); + mocks.systemMetadata.readFile.mockResolvedValue(JSON.stringify({ ffmpeg: { twoPass: 'false' } })); await expect(sut.getSystemConfig()).resolves.toMatchObject({ ffmpeg: expect.objectContaining({ twoPass: false }), @@ -258,8 +252,8 @@ describe(SystemConfigService.name, () => { }); it('should transform numbers', async () => { - configMock.getEnv.mockReturnValue(mockEnvData({ configFile: 'immich-config.json' })); - systemMock.readFile.mockResolvedValue(JSON.stringify({ ffmpeg: { threads: '42' } })); + mocks.config.getEnv.mockReturnValue(mockEnvData({ configFile: 'immich-config.json' })); + mocks.systemMetadata.readFile.mockResolvedValue(JSON.stringify({ ffmpeg: { threads: '42' } })); await expect(sut.getSystemConfig()).resolves.toMatchObject({ ffmpeg: expect.objectContaining({ threads: 42 }), @@ -267,8 +261,10 @@ describe(SystemConfigService.name, () => { }); it('should accept valid cron expressions', async () => { - configMock.getEnv.mockReturnValue(mockEnvData({ configFile: 'immich-config.json' })); - systemMock.readFile.mockResolvedValue(JSON.stringify({ library: { scan: { cronExpression: '0 0 * * *' } } })); + mocks.config.getEnv.mockReturnValue(mockEnvData({ configFile: 'immich-config.json' })); + mocks.systemMetadata.readFile.mockResolvedValue( + JSON.stringify({ library: { scan: { cronExpression: '0 0 * * *' } } }), + ); await expect(sut.getSystemConfig()).resolves.toMatchObject({ library: { @@ -281,8 +277,8 @@ describe(SystemConfigService.name, () => { }); it('should reject invalid cron expressions', async () => { - configMock.getEnv.mockReturnValue(mockEnvData({ configFile: 'immich-config.json' })); - systemMock.readFile.mockResolvedValue(JSON.stringify({ library: { scan: { cronExpression: 'foo' } } })); + mocks.config.getEnv.mockReturnValue(mockEnvData({ configFile: 'immich-config.json' })); + mocks.systemMetadata.readFile.mockResolvedValue(JSON.stringify({ library: { scan: { cronExpression: 'foo' } } })); await expect(sut.getSystemConfig()).rejects.toThrow( 'library.scan.cronExpression has failed the following constraints: cronValidator', @@ -290,22 +286,22 @@ describe(SystemConfigService.name, () => { }); it('should log errors with the config file', async () => { - configMock.getEnv.mockReturnValue(mockEnvData({ configFile: 'immich-config.json' })); + mocks.config.getEnv.mockReturnValue(mockEnvData({ configFile: 'immich-config.json' })); - systemMock.readFile.mockResolvedValue(`{ "ffmpeg2": true, "ffmpeg2": true }`); + mocks.systemMetadata.readFile.mockResolvedValue(`{ "ffmpeg2": true, "ffmpeg2": true }`); await expect(sut.getSystemConfig()).rejects.toBeInstanceOf(Error); - expect(systemMock.readFile).toHaveBeenCalledWith('immich-config.json'); - expect(loggerMock.error).toHaveBeenCalledTimes(2); - expect(loggerMock.error.mock.calls[0][0]).toEqual('Unable to load configuration file: immich-config.json'); - expect(loggerMock.error.mock.calls[1][0].toString()).toEqual( + expect(mocks.systemMetadata.readFile).toHaveBeenCalledWith('immich-config.json'); + expect(mocks.logger.error).toHaveBeenCalledTimes(2); + expect(mocks.logger.error.mock.calls[0][0]).toEqual('Unable to load configuration file: immich-config.json'); + expect(mocks.logger.error.mock.calls[1][0].toString()).toEqual( expect.stringContaining('YAMLException: duplicated mapping key (1:20)'), ); }); it('should load the config from a yaml file', async () => { - configMock.getEnv.mockReturnValue(mockEnvData({ configFile: 'immich-config.yaml' })); + mocks.config.getEnv.mockReturnValue(mockEnvData({ configFile: 'immich-config.yaml' })); const partialConfig = ` ffmpeg: crf: 30 @@ -316,26 +312,26 @@ describe(SystemConfigService.name, () => { user: deleteDelay: 15 `; - systemMock.readFile.mockResolvedValue(partialConfig); + mocks.systemMetadata.readFile.mockResolvedValue(partialConfig); await expect(sut.getSystemConfig()).resolves.toEqual(updatedConfig); - expect(systemMock.readFile).toHaveBeenCalledWith('immich-config.yaml'); + expect(mocks.systemMetadata.readFile).toHaveBeenCalledWith('immich-config.yaml'); }); it('should accept an empty configuration file', async () => { - configMock.getEnv.mockReturnValue(mockEnvData({ configFile: 'immich-config.json' })); - systemMock.readFile.mockResolvedValue(JSON.stringify({})); + mocks.config.getEnv.mockReturnValue(mockEnvData({ configFile: 'immich-config.json' })); + mocks.systemMetadata.readFile.mockResolvedValue(JSON.stringify({})); await expect(sut.getSystemConfig()).resolves.toEqual(defaults); - expect(systemMock.readFile).toHaveBeenCalledWith('immich-config.json'); + expect(mocks.systemMetadata.readFile).toHaveBeenCalledWith('immich-config.json'); }); it('should allow underscores in the machine learning url', async () => { - configMock.getEnv.mockReturnValue(mockEnvData({ configFile: 'immich-config.json' })); + mocks.config.getEnv.mockReturnValue(mockEnvData({ configFile: 'immich-config.json' })); const partialConfig = { machineLearning: { urls: ['immich_machine_learning'] } }; - systemMock.readFile.mockResolvedValue(JSON.stringify(partialConfig)); + mocks.systemMetadata.readFile.mockResolvedValue(JSON.stringify(partialConfig)); const config = await sut.getSystemConfig(); expect(config.machineLearning.urls).toEqual(['immich_machine_learning']); @@ -349,9 +345,9 @@ describe(SystemConfigService.name, () => { for (const { should, externalDomain, result } of externalDomainTests) { it(`should normalize an external domain ${should}`, async () => { - configMock.getEnv.mockReturnValue(mockEnvData({ configFile: 'immich-config.json' })); + mocks.config.getEnv.mockReturnValue(mockEnvData({ configFile: 'immich-config.json' })); const partialConfig = { server: { externalDomain } }; - systemMock.readFile.mockResolvedValue(JSON.stringify(partialConfig)); + mocks.systemMetadata.readFile.mockResolvedValue(JSON.stringify(partialConfig)); const config = await sut.getSystemConfig(); expect(config.server.externalDomain).toEqual(result ?? 'https://demo.immich.app'); @@ -359,14 +355,14 @@ describe(SystemConfigService.name, () => { } it('should warn for unknown options in yaml', async () => { - configMock.getEnv.mockReturnValue(mockEnvData({ configFile: 'immich-config.yaml' })); + mocks.config.getEnv.mockReturnValue(mockEnvData({ configFile: 'immich-config.yaml' })); const partialConfig = ` unknownOption: true `; - systemMock.readFile.mockResolvedValue(partialConfig); + mocks.systemMetadata.readFile.mockResolvedValue(partialConfig); await sut.getSystemConfig(); - expect(loggerMock.warn).toHaveBeenCalled(); + expect(mocks.logger.warn).toHaveBeenCalled(); }); const tests = [ @@ -380,12 +376,12 @@ describe(SystemConfigService.name, () => { for (const test of tests) { it(`should ${test.should}`, async () => { - configMock.getEnv.mockReturnValue(mockEnvData({ configFile: 'immich-config.json' })); - systemMock.readFile.mockResolvedValue(JSON.stringify(test.config)); + mocks.config.getEnv.mockReturnValue(mockEnvData({ configFile: 'immich-config.json' })); + mocks.systemMetadata.readFile.mockResolvedValue(JSON.stringify(test.config)); if (test.warn) { await sut.getSystemConfig(); - expect(loggerMock.warn).toHaveBeenCalled(); + expect(mocks.logger.warn).toHaveBeenCalled(); } else { await expect(sut.getSystemConfig()).rejects.toBeInstanceOf(Error); } @@ -395,19 +391,19 @@ describe(SystemConfigService.name, () => { describe('updateConfig', () => { it('should update the config and emit an event', async () => { - systemMock.get.mockResolvedValue(partialConfig); + mocks.systemMetadata.get.mockResolvedValue(partialConfig); await expect(sut.updateSystemConfig(updatedConfig)).resolves.toEqual(updatedConfig); - expect(eventMock.emit).toHaveBeenCalledWith( + expect(mocks.event.emit).toHaveBeenCalledWith( 'config.update', expect.objectContaining({ oldConfig: expect.any(Object), newConfig: updatedConfig }), ); }); it('should throw an error if a config file is in use', async () => { - configMock.getEnv.mockReturnValue(mockEnvData({ configFile: 'immich-config.json' })); - systemMock.readFile.mockResolvedValue(JSON.stringify({})); + mocks.config.getEnv.mockReturnValue(mockEnvData({ configFile: 'immich-config.json' })); + mocks.systemMetadata.readFile.mockResolvedValue(JSON.stringify({})); await expect(sut.updateSystemConfig(defaults)).rejects.toBeInstanceOf(BadRequestException); - expect(systemMock.set).not.toHaveBeenCalled(); + expect(mocks.systemMetadata.set).not.toHaveBeenCalled(); }); }); diff --git a/server/src/services/system-metadata.service.spec.ts b/server/src/services/system-metadata.service.spec.ts index 071626d593..a8d6c0cdcc 100644 --- a/server/src/services/system-metadata.service.spec.ts +++ b/server/src/services/system-metadata.service.spec.ts @@ -1,15 +1,13 @@ import { SystemMetadataKey } from 'src/enum'; import { SystemMetadataService } from 'src/services/system-metadata.service'; -import { ISystemMetadataRepository } from 'src/types'; -import { newTestService } from 'test/utils'; -import { Mocked } from 'vitest'; +import { newTestService, ServiceMocks } from 'test/utils'; describe(SystemMetadataService.name, () => { let sut: SystemMetadataService; - let systemMock: Mocked; + let mocks: ServiceMocks; beforeEach(() => { - ({ sut, systemMock } = newTestService(SystemMetadataService)); + ({ sut, mocks } = newTestService(SystemMetadataService)); }); it('should work', () => { @@ -18,32 +16,32 @@ describe(SystemMetadataService.name, () => { describe('getAdminOnboarding', () => { it('should get isOnboarded state', async () => { - systemMock.get.mockResolvedValue({ isOnboarded: true }); + mocks.systemMetadata.get.mockResolvedValue({ isOnboarded: true }); await expect(sut.getAdminOnboarding()).resolves.toEqual({ isOnboarded: true }); - expect(systemMock.get).toHaveBeenCalledWith('admin-onboarding'); + expect(mocks.systemMetadata.get).toHaveBeenCalledWith('admin-onboarding'); }); it('should default isOnboarded to false', async () => { await expect(sut.getAdminOnboarding()).resolves.toEqual({ isOnboarded: false }); - expect(systemMock.get).toHaveBeenCalledWith('admin-onboarding'); + expect(mocks.systemMetadata.get).toHaveBeenCalledWith('admin-onboarding'); }); }); describe('updateAdminOnboarding', () => { it('should update isOnboarded to true', async () => { await expect(sut.updateAdminOnboarding({ isOnboarded: true })).resolves.toBeUndefined(); - expect(systemMock.set).toHaveBeenCalledWith(SystemMetadataKey.ADMIN_ONBOARDING, { isOnboarded: true }); + expect(mocks.systemMetadata.set).toHaveBeenCalledWith(SystemMetadataKey.ADMIN_ONBOARDING, { isOnboarded: true }); }); it('should update isOnboarded to false', async () => { await expect(sut.updateAdminOnboarding({ isOnboarded: false })).resolves.toBeUndefined(); - expect(systemMock.set).toHaveBeenCalledWith(SystemMetadataKey.ADMIN_ONBOARDING, { isOnboarded: false }); + expect(mocks.systemMetadata.set).toHaveBeenCalledWith(SystemMetadataKey.ADMIN_ONBOARDING, { isOnboarded: false }); }); }); describe('getReverseGeocodingState', () => { it('should get reverse geocoding state', async () => { - systemMock.get.mockResolvedValue({ lastUpdate: '2024-01-01', lastImportFileName: 'foo.bar' }); + mocks.systemMetadata.get.mockResolvedValue({ lastUpdate: '2024-01-01', lastImportFileName: 'foo.bar' }); await expect(sut.getReverseGeocodingState()).resolves.toEqual({ lastUpdate: '2024-01-01', lastImportFileName: 'foo.bar', diff --git a/server/src/services/tag.service.spec.ts b/server/src/services/tag.service.spec.ts index 8d54eb31db..48d1b00379 100644 --- a/server/src/services/tag.service.spec.ts +++ b/server/src/services/tag.service.spec.ts @@ -1,24 +1,19 @@ import { BadRequestException } from '@nestjs/common'; import { BulkIdErrorReason } from 'src/dtos/asset-ids.response.dto'; import { JobStatus } from 'src/interfaces/job.interface'; -import { ITagRepository } from 'src/interfaces/tag.interface'; import { TagService } from 'src/services/tag.service'; import { authStub } from 'test/fixtures/auth.stub'; import { tagResponseStub, tagStub } from 'test/fixtures/tag.stub'; -import { IAccessRepositoryMock } from 'test/repositories/access.repository.mock'; -import { newTestService } from 'test/utils'; -import { Mocked } from 'vitest'; +import { newTestService, ServiceMocks } from 'test/utils'; describe(TagService.name, () => { let sut: TagService; - - let accessMock: IAccessRepositoryMock; - let tagMock: Mocked; + let mocks: ServiceMocks; beforeEach(() => { - ({ sut, accessMock, tagMock } = newTestService(TagService)); + ({ sut, mocks } = newTestService(TagService)); - accessMock.tag.checkOwnerAccess.mockResolvedValue(new Set(['tag-1'])); + mocks.access.tag.checkOwnerAccess.mockResolvedValue(new Set(['tag-1'])); }); it('should work', () => { @@ -27,76 +22,76 @@ describe(TagService.name, () => { describe('getAll', () => { it('should return all tags for a user', async () => { - tagMock.getAll.mockResolvedValue([tagStub.tag1]); + mocks.tag.getAll.mockResolvedValue([tagStub.tag1]); await expect(sut.getAll(authStub.admin)).resolves.toEqual([tagResponseStub.tag1]); - expect(tagMock.getAll).toHaveBeenCalledWith(authStub.admin.user.id); + expect(mocks.tag.getAll).toHaveBeenCalledWith(authStub.admin.user.id); }); }); describe('get', () => { it('should throw an error for an invalid id', async () => { - tagMock.get.mockResolvedValue(null); + mocks.tag.get.mockResolvedValue(null); await expect(sut.get(authStub.admin, 'tag-1')).rejects.toBeInstanceOf(BadRequestException); - expect(tagMock.get).toHaveBeenCalledWith('tag-1'); + expect(mocks.tag.get).toHaveBeenCalledWith('tag-1'); }); it('should return a tag for a user', async () => { - tagMock.get.mockResolvedValue(tagStub.tag1); + mocks.tag.get.mockResolvedValue(tagStub.tag1); await expect(sut.get(authStub.admin, 'tag-1')).resolves.toEqual(tagResponseStub.tag1); - expect(tagMock.get).toHaveBeenCalledWith('tag-1'); + expect(mocks.tag.get).toHaveBeenCalledWith('tag-1'); }); }); describe('create', () => { it('should throw an error for no parent tag access', async () => { - accessMock.tag.checkOwnerAccess.mockResolvedValue(new Set()); + mocks.access.tag.checkOwnerAccess.mockResolvedValue(new Set()); await expect(sut.create(authStub.admin, { name: 'tag', parentId: 'tag-parent' })).rejects.toBeInstanceOf( BadRequestException, ); - expect(tagMock.create).not.toHaveBeenCalled(); + expect(mocks.tag.create).not.toHaveBeenCalled(); }); it('should create a tag with a parent', async () => { - accessMock.tag.checkOwnerAccess.mockResolvedValue(new Set(['tag-parent'])); - tagMock.create.mockResolvedValue(tagStub.tag1); - tagMock.get.mockResolvedValueOnce(tagStub.parent); - tagMock.get.mockResolvedValueOnce(tagStub.child); + mocks.access.tag.checkOwnerAccess.mockResolvedValue(new Set(['tag-parent'])); + mocks.tag.create.mockResolvedValue(tagStub.tag1); + mocks.tag.get.mockResolvedValueOnce(tagStub.parent); + mocks.tag.get.mockResolvedValueOnce(tagStub.child); await expect(sut.create(authStub.admin, { name: 'tagA', parentId: 'tag-parent' })).resolves.toBeDefined(); - expect(tagMock.create).toHaveBeenCalledWith(expect.objectContaining({ value: 'Parent/tagA' })); + expect(mocks.tag.create).toHaveBeenCalledWith(expect.objectContaining({ value: 'Parent/tagA' })); }); it('should handle invalid parent ids', async () => { - accessMock.tag.checkOwnerAccess.mockResolvedValue(new Set(['tag-parent'])); + mocks.access.tag.checkOwnerAccess.mockResolvedValue(new Set(['tag-parent'])); await expect(sut.create(authStub.admin, { name: 'tagA', parentId: 'tag-parent' })).rejects.toBeInstanceOf( BadRequestException, ); - expect(tagMock.create).not.toHaveBeenCalled(); + expect(mocks.tag.create).not.toHaveBeenCalled(); }); }); describe('create', () => { it('should throw an error for a duplicate tag', async () => { - tagMock.getByValue.mockResolvedValue(tagStub.tag1); + mocks.tag.getByValue.mockResolvedValue(tagStub.tag1); await expect(sut.create(authStub.admin, { name: 'tag-1' })).rejects.toBeInstanceOf(BadRequestException); - expect(tagMock.getByValue).toHaveBeenCalledWith(authStub.admin.user.id, 'tag-1'); - expect(tagMock.create).not.toHaveBeenCalled(); + expect(mocks.tag.getByValue).toHaveBeenCalledWith(authStub.admin.user.id, 'tag-1'); + expect(mocks.tag.create).not.toHaveBeenCalled(); }); it('should create a new tag', async () => { - tagMock.create.mockResolvedValue(tagStub.tag1); + mocks.tag.create.mockResolvedValue(tagStub.tag1); await expect(sut.create(authStub.admin, { name: 'tag-1' })).resolves.toEqual(tagResponseStub.tag1); - expect(tagMock.create).toHaveBeenCalledWith({ + expect(mocks.tag.create).toHaveBeenCalledWith({ userId: authStub.admin.user.id, value: 'tag-1', }); }); it('should create a new tag with optional color', async () => { - tagMock.create.mockResolvedValue(tagStub.color1); + mocks.tag.create.mockResolvedValue(tagStub.color1); await expect(sut.create(authStub.admin, { name: 'tag-1', color: '#000000' })).resolves.toEqual( tagResponseStub.color1, ); - expect(tagMock.create).toHaveBeenCalledWith({ + expect(mocks.tag.create).toHaveBeenCalledWith({ userId: authStub.admin.user.id, value: 'tag-1', color: '#000000', @@ -106,26 +101,26 @@ describe(TagService.name, () => { describe('update', () => { it('should throw an error for no update permission', async () => { - accessMock.tag.checkOwnerAccess.mockResolvedValue(new Set()); + mocks.access.tag.checkOwnerAccess.mockResolvedValue(new Set()); await expect(sut.update(authStub.admin, 'tag-1', { color: '#000000' })).rejects.toBeInstanceOf( BadRequestException, ); - expect(tagMock.update).not.toHaveBeenCalled(); + expect(mocks.tag.update).not.toHaveBeenCalled(); }); it('should update a tag', async () => { - accessMock.tag.checkOwnerAccess.mockResolvedValue(new Set(['tag-1'])); - tagMock.update.mockResolvedValue(tagStub.color1); + mocks.access.tag.checkOwnerAccess.mockResolvedValue(new Set(['tag-1'])); + mocks.tag.update.mockResolvedValue(tagStub.color1); await expect(sut.update(authStub.admin, 'tag-1', { color: '#000000' })).resolves.toEqual(tagResponseStub.color1); - expect(tagMock.update).toHaveBeenCalledWith({ id: 'tag-1', color: '#000000' }); + expect(mocks.tag.update).toHaveBeenCalledWith({ id: 'tag-1', color: '#000000' }); }); }); describe('upsert', () => { it('should upsert a new tag', async () => { - tagMock.upsertValue.mockResolvedValue(tagStub.parent); + mocks.tag.upsertValue.mockResolvedValue(tagStub.parent); await expect(sut.upsert(authStub.admin, { tags: ['Parent'] })).resolves.toBeDefined(); - expect(tagMock.upsertValue).toHaveBeenCalledWith({ + expect(mocks.tag.upsertValue).toHaveBeenCalledWith({ value: 'Parent', userId: 'admin_id', parentId: undefined, @@ -133,16 +128,16 @@ describe(TagService.name, () => { }); it('should upsert a nested tag', async () => { - tagMock.getByValue.mockResolvedValueOnce(null); - tagMock.upsertValue.mockResolvedValueOnce(tagStub.parent); - tagMock.upsertValue.mockResolvedValueOnce(tagStub.child); + mocks.tag.getByValue.mockResolvedValueOnce(null); + mocks.tag.upsertValue.mockResolvedValueOnce(tagStub.parent); + mocks.tag.upsertValue.mockResolvedValueOnce(tagStub.child); await expect(sut.upsert(authStub.admin, { tags: ['Parent/Child'] })).resolves.toBeDefined(); - expect(tagMock.upsertValue).toHaveBeenNthCalledWith(1, { + expect(mocks.tag.upsertValue).toHaveBeenNthCalledWith(1, { value: 'Parent', userId: 'admin_id', parent: undefined, }); - expect(tagMock.upsertValue).toHaveBeenNthCalledWith(2, { + expect(mocks.tag.upsertValue).toHaveBeenNthCalledWith(2, { value: 'Parent/Child', userId: 'admin_id', parent: expect.objectContaining({ id: 'tag-parent' }), @@ -150,16 +145,16 @@ describe(TagService.name, () => { }); it('should upsert a tag and ignore leading and trailing slashes', async () => { - tagMock.getByValue.mockResolvedValueOnce(null); - tagMock.upsertValue.mockResolvedValueOnce(tagStub.parent); - tagMock.upsertValue.mockResolvedValueOnce(tagStub.child); + mocks.tag.getByValue.mockResolvedValueOnce(null); + mocks.tag.upsertValue.mockResolvedValueOnce(tagStub.parent); + mocks.tag.upsertValue.mockResolvedValueOnce(tagStub.child); await expect(sut.upsert(authStub.admin, { tags: ['/Parent/Child/'] })).resolves.toBeDefined(); - expect(tagMock.upsertValue).toHaveBeenNthCalledWith(1, { + expect(mocks.tag.upsertValue).toHaveBeenNthCalledWith(1, { value: 'Parent', userId: 'admin_id', parent: undefined, }); - expect(tagMock.upsertValue).toHaveBeenNthCalledWith(2, { + expect(mocks.tag.upsertValue).toHaveBeenNthCalledWith(2, { value: 'Parent/Child', userId: 'admin_id', parent: expect.objectContaining({ id: 'tag-parent' }), @@ -169,32 +164,32 @@ describe(TagService.name, () => { describe('remove', () => { it('should throw an error for an invalid id', async () => { - accessMock.tag.checkOwnerAccess.mockResolvedValue(new Set()); + mocks.access.tag.checkOwnerAccess.mockResolvedValue(new Set()); await expect(sut.remove(authStub.admin, 'tag-1')).rejects.toBeInstanceOf(BadRequestException); - expect(tagMock.delete).not.toHaveBeenCalled(); + expect(mocks.tag.delete).not.toHaveBeenCalled(); }); it('should remove a tag', async () => { - tagMock.get.mockResolvedValue(tagStub.tag1); + mocks.tag.get.mockResolvedValue(tagStub.tag1); await sut.remove(authStub.admin, 'tag-1'); - expect(tagMock.delete).toHaveBeenCalledWith('tag-1'); + expect(mocks.tag.delete).toHaveBeenCalledWith('tag-1'); }); }); describe('bulkTagAssets', () => { it('should handle invalid requests', async () => { - accessMock.tag.checkOwnerAccess.mockResolvedValue(new Set()); - tagMock.upsertAssetIds.mockResolvedValue([]); + mocks.access.tag.checkOwnerAccess.mockResolvedValue(new Set()); + mocks.tag.upsertAssetIds.mockResolvedValue([]); await expect(sut.bulkTagAssets(authStub.admin, { tagIds: ['tag-1'], assetIds: ['asset-1'] })).resolves.toEqual({ count: 0, }); - expect(tagMock.upsertAssetIds).toHaveBeenCalledWith([]); + expect(mocks.tag.upsertAssetIds).toHaveBeenCalledWith([]); }); it('should upsert records', async () => { - accessMock.tag.checkOwnerAccess.mockResolvedValue(new Set(['tag-1', 'tag-2'])); - accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1', 'asset-2', 'asset-3'])); - tagMock.upsertAssetIds.mockResolvedValue([ + mocks.access.tag.checkOwnerAccess.mockResolvedValue(new Set(['tag-1', 'tag-2'])); + mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1', 'asset-2', 'asset-3'])); + mocks.tag.upsertAssetIds.mockResolvedValue([ { tagId: 'tag-1', assetId: 'asset-1' }, { tagId: 'tag-1', assetId: 'asset-2' }, { tagId: 'tag-1', assetId: 'asset-3' }, @@ -207,7 +202,7 @@ describe(TagService.name, () => { ).resolves.toEqual({ count: 6, }); - expect(tagMock.upsertAssetIds).toHaveBeenCalledWith([ + expect(mocks.tag.upsertAssetIds).toHaveBeenCalledWith([ { tagId: 'tag-1', assetId: 'asset-1' }, { tagId: 'tag-1', assetId: 'asset-2' }, { tagId: 'tag-1', assetId: 'asset-3' }, @@ -220,19 +215,19 @@ describe(TagService.name, () => { describe('addAssets', () => { it('should handle invalid ids', async () => { - tagMock.get.mockResolvedValue(null); - tagMock.getAssetIds.mockResolvedValue(new Set([])); + mocks.tag.get.mockResolvedValue(null); + mocks.tag.getAssetIds.mockResolvedValue(new Set([])); await expect(sut.addAssets(authStub.admin, 'tag-1', { ids: ['asset-1'] })).resolves.toEqual([ { id: 'asset-1', success: false, error: 'no_permission' }, ]); - expect(tagMock.getAssetIds).toHaveBeenCalledWith('tag-1', ['asset-1']); - expect(tagMock.addAssetIds).not.toHaveBeenCalled(); + expect(mocks.tag.getAssetIds).toHaveBeenCalledWith('tag-1', ['asset-1']); + expect(mocks.tag.addAssetIds).not.toHaveBeenCalled(); }); it('should accept accept ids that are new and reject the rest', async () => { - tagMock.get.mockResolvedValue(tagStub.tag1); - tagMock.getAssetIds.mockResolvedValue(new Set(['asset-1'])); - accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-2'])); + mocks.tag.get.mockResolvedValue(tagStub.tag1); + mocks.tag.getAssetIds.mockResolvedValue(new Set(['asset-1'])); + mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-2'])); await expect( sut.addAssets(authStub.admin, 'tag-1', { @@ -243,23 +238,23 @@ describe(TagService.name, () => { { id: 'asset-2', success: true }, ]); - expect(tagMock.getAssetIds).toHaveBeenCalledWith('tag-1', ['asset-1', 'asset-2']); - expect(tagMock.addAssetIds).toHaveBeenCalledWith('tag-1', ['asset-2']); + expect(mocks.tag.getAssetIds).toHaveBeenCalledWith('tag-1', ['asset-1', 'asset-2']); + expect(mocks.tag.addAssetIds).toHaveBeenCalledWith('tag-1', ['asset-2']); }); }); describe('removeAssets', () => { it('should throw an error for an invalid id', async () => { - tagMock.get.mockResolvedValue(null); - tagMock.getAssetIds.mockResolvedValue(new Set()); + mocks.tag.get.mockResolvedValue(null); + mocks.tag.getAssetIds.mockResolvedValue(new Set()); await expect(sut.removeAssets(authStub.admin, 'tag-1', { ids: ['asset-1'] })).resolves.toEqual([ { id: 'asset-1', success: false, error: 'not_found' }, ]); }); it('should accept accept ids that are tagged and reject the rest', async () => { - tagMock.get.mockResolvedValue(tagStub.tag1); - tagMock.getAssetIds.mockResolvedValue(new Set(['asset-1'])); + mocks.tag.get.mockResolvedValue(tagStub.tag1); + mocks.tag.getAssetIds.mockResolvedValue(new Set(['asset-1'])); await expect( sut.removeAssets(authStub.admin, 'tag-1', { @@ -270,15 +265,15 @@ describe(TagService.name, () => { { id: 'asset-2', success: false, error: BulkIdErrorReason.NOT_FOUND }, ]); - expect(tagMock.getAssetIds).toHaveBeenCalledWith('tag-1', ['asset-1', 'asset-2']); - expect(tagMock.removeAssetIds).toHaveBeenCalledWith('tag-1', ['asset-1']); + expect(mocks.tag.getAssetIds).toHaveBeenCalledWith('tag-1', ['asset-1', 'asset-2']); + expect(mocks.tag.removeAssetIds).toHaveBeenCalledWith('tag-1', ['asset-1']); }); }); describe('handleTagCleanup', () => { it('should delete empty tags', async () => { await expect(sut.handleTagCleanup()).resolves.toBe(JobStatus.SUCCESS); - expect(tagMock.deleteEmptyTags).toHaveBeenCalled(); + expect(mocks.tag.deleteEmptyTags).toHaveBeenCalled(); }); }); }); diff --git a/server/src/services/timeline.service.spec.ts b/server/src/services/timeline.service.spec.ts index 41f9919189..15dab6bc05 100644 --- a/server/src/services/timeline.service.spec.ts +++ b/server/src/services/timeline.service.spec.ts @@ -1,32 +1,28 @@ import { BadRequestException } from '@nestjs/common'; -import { IAssetRepository, TimeBucketSize } from 'src/interfaces/asset.interface'; +import { TimeBucketSize } from 'src/interfaces/asset.interface'; import { TimelineService } from 'src/services/timeline.service'; import { assetStub } from 'test/fixtures/asset.stub'; import { authStub } from 'test/fixtures/auth.stub'; -import { IAccessRepositoryMock } from 'test/repositories/access.repository.mock'; -import { newTestService } from 'test/utils'; -import { Mocked } from 'vitest'; +import { newTestService, ServiceMocks } from 'test/utils'; describe(TimelineService.name, () => { let sut: TimelineService; - - let accessMock: IAccessRepositoryMock; - let assetMock: Mocked; + let mocks: ServiceMocks; beforeEach(() => { - ({ sut, accessMock, assetMock } = newTestService(TimelineService)); + ({ sut, mocks } = newTestService(TimelineService)); }); describe('getTimeBuckets', () => { it("should return buckets if userId and albumId aren't set", async () => { - assetMock.getTimeBuckets.mockResolvedValue([{ timeBucket: 'bucket', count: 1 }]); + mocks.asset.getTimeBuckets.mockResolvedValue([{ timeBucket: 'bucket', count: 1 }]); await expect( sut.getTimeBuckets(authStub.admin, { size: TimeBucketSize.DAY, }), ).resolves.toEqual(expect.arrayContaining([{ timeBucket: 'bucket', count: 1 }])); - expect(assetMock.getTimeBuckets).toHaveBeenCalledWith({ + expect(mocks.asset.getTimeBuckets).toHaveBeenCalledWith({ size: TimeBucketSize.DAY, userIds: [authStub.admin.user.id], }); @@ -35,15 +31,15 @@ describe(TimelineService.name, () => { describe('getTimeBucket', () => { it('should return the assets for a album time bucket if user has album.read', async () => { - accessMock.album.checkOwnerAccess.mockResolvedValue(new Set(['album-id'])); - assetMock.getTimeBucket.mockResolvedValue([assetStub.image]); + mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set(['album-id'])); + mocks.asset.getTimeBucket.mockResolvedValue([assetStub.image]); await expect( sut.getTimeBucket(authStub.admin, { size: TimeBucketSize.DAY, timeBucket: 'bucket', albumId: 'album-id' }), ).resolves.toEqual(expect.arrayContaining([expect.objectContaining({ id: 'asset-id' })])); - expect(accessMock.album.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['album-id'])); - expect(assetMock.getTimeBucket).toHaveBeenCalledWith('bucket', { + expect(mocks.access.album.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['album-id'])); + expect(mocks.asset.getTimeBucket).toHaveBeenCalledWith('bucket', { size: TimeBucketSize.DAY, timeBucket: 'bucket', albumId: 'album-id', @@ -51,7 +47,7 @@ describe(TimelineService.name, () => { }); it('should return the assets for a archive time bucket if user has archive.read', async () => { - assetMock.getTimeBucket.mockResolvedValue([assetStub.image]); + mocks.asset.getTimeBucket.mockResolvedValue([assetStub.image]); await expect( sut.getTimeBucket(authStub.admin, { @@ -61,7 +57,7 @@ describe(TimelineService.name, () => { userId: authStub.admin.user.id, }), ).resolves.toEqual(expect.arrayContaining([expect.objectContaining({ id: 'asset-id' })])); - expect(assetMock.getTimeBucket).toHaveBeenCalledWith( + expect(mocks.asset.getTimeBucket).toHaveBeenCalledWith( 'bucket', expect.objectContaining({ size: TimeBucketSize.DAY, @@ -73,7 +69,7 @@ describe(TimelineService.name, () => { }); it('should include partner shared assets', async () => { - assetMock.getTimeBucket.mockResolvedValue([assetStub.image]); + mocks.asset.getTimeBucket.mockResolvedValue([assetStub.image]); await expect( sut.getTimeBucket(authStub.admin, { @@ -84,7 +80,7 @@ describe(TimelineService.name, () => { withPartners: true, }), ).resolves.toEqual(expect.arrayContaining([expect.objectContaining({ id: 'asset-id' })])); - expect(assetMock.getTimeBucket).toHaveBeenCalledWith('bucket', { + expect(mocks.asset.getTimeBucket).toHaveBeenCalledWith('bucket', { size: TimeBucketSize.DAY, timeBucket: 'bucket', isArchived: false, @@ -94,8 +90,8 @@ describe(TimelineService.name, () => { }); it('should check permissions to read tag', async () => { - assetMock.getTimeBucket.mockResolvedValue([assetStub.image]); - accessMock.tag.checkOwnerAccess.mockResolvedValue(new Set(['tag-123'])); + mocks.asset.getTimeBucket.mockResolvedValue([assetStub.image]); + mocks.access.tag.checkOwnerAccess.mockResolvedValue(new Set(['tag-123'])); await expect( sut.getTimeBucket(authStub.admin, { @@ -105,7 +101,7 @@ describe(TimelineService.name, () => { tagId: 'tag-123', }), ).resolves.toEqual(expect.arrayContaining([expect.objectContaining({ id: 'asset-id' })])); - expect(assetMock.getTimeBucket).toHaveBeenCalledWith('bucket', { + expect(mocks.asset.getTimeBucket).toHaveBeenCalledWith('bucket', { size: TimeBucketSize.DAY, tagId: 'tag-123', timeBucket: 'bucket', @@ -114,8 +110,8 @@ describe(TimelineService.name, () => { }); it('should strip metadata if showExif is disabled', async () => { - accessMock.album.checkSharedLinkAccess.mockResolvedValue(new Set(['album-id'])); - assetMock.getTimeBucket.mockResolvedValue([assetStub.image]); + mocks.access.album.checkSharedLinkAccess.mockResolvedValue(new Set(['album-id'])); + mocks.asset.getTimeBucket.mockResolvedValue([assetStub.image]); const buckets = await sut.getTimeBucket( { ...authStub.admin, sharedLink: { ...authStub.adminSharedLink.sharedLink!, showExif: false } }, @@ -128,7 +124,7 @@ describe(TimelineService.name, () => { ); expect(buckets).toEqual([expect.objectContaining({ id: 'asset-id' })]); expect(buckets[0]).not.toHaveProperty('exif'); - expect(assetMock.getTimeBucket).toHaveBeenCalledWith('bucket', { + expect(mocks.asset.getTimeBucket).toHaveBeenCalledWith('bucket', { size: TimeBucketSize.DAY, timeBucket: 'bucket', isArchived: true, @@ -137,7 +133,7 @@ describe(TimelineService.name, () => { }); it('should return the assets for a library time bucket if user has library.read', async () => { - assetMock.getTimeBucket.mockResolvedValue([assetStub.image]); + mocks.asset.getTimeBucket.mockResolvedValue([assetStub.image]); await expect( sut.getTimeBucket(authStub.admin, { @@ -146,7 +142,7 @@ describe(TimelineService.name, () => { userId: authStub.admin.user.id, }), ).resolves.toEqual(expect.arrayContaining([expect.objectContaining({ id: 'asset-id' })])); - expect(assetMock.getTimeBucket).toHaveBeenCalledWith( + expect(mocks.asset.getTimeBucket).toHaveBeenCalledWith( 'bucket', expect.objectContaining({ size: TimeBucketSize.DAY, diff --git a/server/src/services/trash.service.spec.ts b/server/src/services/trash.service.spec.ts index 8b93e899e7..536fb65f9a 100644 --- a/server/src/services/trash.service.spec.ts +++ b/server/src/services/trash.service.spec.ts @@ -1,11 +1,8 @@ import { BadRequestException } from '@nestjs/common'; -import { IJobRepository, JobName, JobStatus } from 'src/interfaces/job.interface'; +import { JobName, JobStatus } from 'src/interfaces/job.interface'; import { TrashService } from 'src/services/trash.service'; -import { ITrashRepository } from 'src/types'; import { authStub } from 'test/fixtures/auth.stub'; -import { IAccessRepositoryMock } from 'test/repositories/access.repository.mock'; -import { newTestService } from 'test/utils'; -import { Mocked } from 'vitest'; +import { newTestService, ServiceMocks } from 'test/utils'; async function* makeAssetIdStream(count: number): AsyncIterableIterator<{ id: string }> { for (let i = 0; i < count; i++) { @@ -16,17 +13,14 @@ async function* makeAssetIdStream(count: number): AsyncIterableIterator<{ id: st describe(TrashService.name, () => { let sut: TrashService; - - let accessMock: IAccessRepositoryMock; - let jobMock: Mocked; - let trashMock: Mocked; + let mocks: ServiceMocks; it('should work', () => { expect(sut).toBeDefined(); }); beforeEach(() => { - ({ sut, accessMock, jobMock, trashMock } = newTestService(TrashService)); + ({ sut, mocks } = newTestService(TrashService)); }); describe('restoreAssets', () => { @@ -40,64 +34,64 @@ describe(TrashService.name, () => { it('should handle an empty list', async () => { await expect(sut.restoreAssets(authStub.user1, { ids: [] })).resolves.toEqual({ count: 0 }); - expect(accessMock.asset.checkOwnerAccess).not.toHaveBeenCalled(); + expect(mocks.access.asset.checkOwnerAccess).not.toHaveBeenCalled(); }); it('should restore a batch of assets', async () => { - accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset1', 'asset2'])); + mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset1', 'asset2'])); await sut.restoreAssets(authStub.user1, { ids: ['asset1', 'asset2'] }); - expect(trashMock.restoreAll).toHaveBeenCalledWith(['asset1', 'asset2']); - expect(jobMock.queue.mock.calls).toEqual([]); + expect(mocks.trash.restoreAll).toHaveBeenCalledWith(['asset1', 'asset2']); + expect(mocks.job.queue.mock.calls).toEqual([]); }); }); describe('restore', () => { it('should handle an empty trash', async () => { - trashMock.getDeletedIds.mockResolvedValue(makeAssetIdStream(0)); - trashMock.restore.mockResolvedValue(0); + mocks.trash.getDeletedIds.mockResolvedValue(makeAssetIdStream(0)); + mocks.trash.restore.mockResolvedValue(0); await expect(sut.restore(authStub.user1)).resolves.toEqual({ count: 0 }); - expect(trashMock.restore).toHaveBeenCalledWith('user-id'); + expect(mocks.trash.restore).toHaveBeenCalledWith('user-id'); }); it('should restore', async () => { - trashMock.getDeletedIds.mockResolvedValue(makeAssetIdStream(1)); - trashMock.restore.mockResolvedValue(1); + mocks.trash.getDeletedIds.mockResolvedValue(makeAssetIdStream(1)); + mocks.trash.restore.mockResolvedValue(1); await expect(sut.restore(authStub.user1)).resolves.toEqual({ count: 1 }); - expect(trashMock.restore).toHaveBeenCalledWith('user-id'); + expect(mocks.trash.restore).toHaveBeenCalledWith('user-id'); }); }); describe('empty', () => { it('should handle an empty trash', async () => { - trashMock.getDeletedIds.mockResolvedValue(makeAssetIdStream(0)); - trashMock.empty.mockResolvedValue(0); + mocks.trash.getDeletedIds.mockResolvedValue(makeAssetIdStream(0)); + mocks.trash.empty.mockResolvedValue(0); await expect(sut.empty(authStub.user1)).resolves.toEqual({ count: 0 }); - expect(jobMock.queue).not.toHaveBeenCalled(); + expect(mocks.job.queue).not.toHaveBeenCalled(); }); it('should empty the trash', async () => { - trashMock.getDeletedIds.mockResolvedValue(makeAssetIdStream(1)); - trashMock.empty.mockResolvedValue(1); + mocks.trash.getDeletedIds.mockResolvedValue(makeAssetIdStream(1)); + mocks.trash.empty.mockResolvedValue(1); await expect(sut.empty(authStub.user1)).resolves.toEqual({ count: 1 }); - expect(trashMock.empty).toHaveBeenCalledWith('user-id'); - expect(jobMock.queue).toHaveBeenCalledWith({ name: JobName.QUEUE_TRASH_EMPTY, data: {} }); + expect(mocks.trash.empty).toHaveBeenCalledWith('user-id'); + expect(mocks.job.queue).toHaveBeenCalledWith({ name: JobName.QUEUE_TRASH_EMPTY, data: {} }); }); }); describe('onAssetsDelete', () => { it('should queue the empty trash job', async () => { await expect(sut.onAssetsDelete()).resolves.toBeUndefined(); - expect(jobMock.queue).toHaveBeenCalledWith({ name: JobName.QUEUE_TRASH_EMPTY, data: {} }); + expect(mocks.job.queue).toHaveBeenCalledWith({ name: JobName.QUEUE_TRASH_EMPTY, data: {} }); }); }); describe('handleQueueEmptyTrash', () => { it('should queue asset delete jobs', async () => { - trashMock.getDeletedIds.mockReturnValue(makeAssetIdStream(1)); + mocks.trash.getDeletedIds.mockReturnValue(makeAssetIdStream(1)); await expect(sut.handleQueueEmptyTrash()).resolves.toEqual(JobStatus.SUCCESS); - expect(jobMock.queueAll).toHaveBeenCalledWith([ + expect(mocks.job.queueAll).toHaveBeenCalledWith([ { name: JobName.ASSET_DELETION, data: { id: 'asset-1', deleteOnDisk: true }, diff --git a/server/src/services/user-admin.service.spec.ts b/server/src/services/user-admin.service.spec.ts index b14f1c8655..604062c97a 100644 --- a/server/src/services/user-admin.service.spec.ts +++ b/server/src/services/user-admin.service.spec.ts @@ -1,31 +1,28 @@ import { BadRequestException, ForbiddenException } from '@nestjs/common'; import { mapUserAdmin } from 'src/dtos/user.dto'; import { UserStatus } from 'src/enum'; -import { IJobRepository, JobName } from 'src/interfaces/job.interface'; -import { IUserRepository } from 'src/interfaces/user.interface'; +import { JobName } from 'src/interfaces/job.interface'; import { UserAdminService } from 'src/services/user-admin.service'; import { authStub } from 'test/fixtures/auth.stub'; import { userStub } from 'test/fixtures/user.stub'; -import { newTestService } from 'test/utils'; -import { Mocked, describe } from 'vitest'; +import { newTestService, ServiceMocks } from 'test/utils'; +import { describe } from 'vitest'; describe(UserAdminService.name, () => { let sut: UserAdminService; - - let jobMock: Mocked; - let userMock: Mocked; + let mocks: ServiceMocks; beforeEach(() => { - ({ sut, jobMock, userMock } = newTestService(UserAdminService)); + ({ sut, mocks } = newTestService(UserAdminService)); - userMock.get.mockImplementation((userId) => + mocks.user.get.mockImplementation((userId) => Promise.resolve([userStub.admin, userStub.user1].find((user) => user.id === userId) ?? undefined), ); }); describe('create', () => { it('should not create a user if there is no local admin account', async () => { - userMock.getAdmin.mockResolvedValueOnce(void 0); + mocks.user.getAdmin.mockResolvedValueOnce(void 0); await expect( sut.create({ @@ -37,8 +34,8 @@ describe(UserAdminService.name, () => { }); it('should create user', async () => { - userMock.getAdmin.mockResolvedValue(userStub.admin); - userMock.create.mockResolvedValue(userStub.user1); + mocks.user.getAdmin.mockResolvedValue(userStub.admin); + mocks.user.create.mockResolvedValue(userStub.user1); await expect( sut.create({ @@ -49,8 +46,8 @@ describe(UserAdminService.name, () => { }), ).resolves.toEqual(mapUserAdmin(userStub.user1)); - expect(userMock.getAdmin).toBeCalled(); - expect(userMock.create).toBeCalledWith({ + expect(mocks.user.getAdmin).toBeCalled(); + expect(mocks.user.create).toBeCalledWith({ email: userStub.user1.email, name: userStub.user1.name, storageLabel: 'label', @@ -66,20 +63,20 @@ describe(UserAdminService.name, () => { email: 'immich@test.com', storageLabel: 'storage_label', }; - userMock.getByEmail.mockResolvedValue(void 0); - userMock.getByStorageLabel.mockResolvedValue(void 0); - userMock.update.mockResolvedValue(userStub.user1); + mocks.user.getByEmail.mockResolvedValue(void 0); + mocks.user.getByStorageLabel.mockResolvedValue(void 0); + mocks.user.update.mockResolvedValue(userStub.user1); await sut.update(authStub.user1, userStub.user1.id, update); - expect(userMock.getByEmail).toHaveBeenCalledWith(update.email); - expect(userMock.getByStorageLabel).toHaveBeenCalledWith(update.storageLabel); + expect(mocks.user.getByEmail).toHaveBeenCalledWith(update.email); + expect(mocks.user.getByStorageLabel).toHaveBeenCalledWith(update.storageLabel); }); it('should not set an empty string for storage label', async () => { - userMock.update.mockResolvedValue(userStub.user1); + mocks.user.update.mockResolvedValue(userStub.user1); await sut.update(authStub.admin, userStub.user1.id, { storageLabel: '' }); - expect(userMock.update).toHaveBeenCalledWith(userStub.user1.id, { + expect(mocks.user.update).toHaveBeenCalledWith(userStub.user1.id, { storageLabel: null, updatedAt: expect.any(Date), }); @@ -88,27 +85,27 @@ describe(UserAdminService.name, () => { it('should not change an email to one already in use', async () => { const dto = { id: userStub.user1.id, email: 'updated@test.com' }; - userMock.get.mockResolvedValue(userStub.user1); - userMock.getByEmail.mockResolvedValue(userStub.admin); + mocks.user.get.mockResolvedValue(userStub.user1); + mocks.user.getByEmail.mockResolvedValue(userStub.admin); await expect(sut.update(authStub.admin, userStub.user1.id, dto)).rejects.toBeInstanceOf(BadRequestException); - expect(userMock.update).not.toHaveBeenCalled(); + expect(mocks.user.update).not.toHaveBeenCalled(); }); it('should not let the admin change the storage label to one already in use', async () => { const dto = { id: userStub.user1.id, storageLabel: 'admin' }; - userMock.get.mockResolvedValue(userStub.user1); - userMock.getByStorageLabel.mockResolvedValue(userStub.admin); + mocks.user.get.mockResolvedValue(userStub.user1); + mocks.user.getByStorageLabel.mockResolvedValue(userStub.admin); await expect(sut.update(authStub.admin, userStub.user1.id, dto)).rejects.toBeInstanceOf(BadRequestException); - expect(userMock.update).not.toHaveBeenCalled(); + expect(mocks.user.update).not.toHaveBeenCalled(); }); it('update user information should throw error if user not found', async () => { - userMock.get.mockResolvedValueOnce(void 0); + mocks.user.get.mockResolvedValueOnce(void 0); await expect( sut.update(authStub.admin, userStub.user1.id, { shouldChangePassword: true }), @@ -118,10 +115,10 @@ describe(UserAdminService.name, () => { describe('delete', () => { it('should throw error if user could not be found', async () => { - userMock.get.mockResolvedValue(void 0); + mocks.user.get.mockResolvedValue(void 0); await expect(sut.delete(authStub.admin, userStub.admin.id, {})).rejects.toThrowError(BadRequestException); - expect(userMock.delete).not.toHaveBeenCalled(); + expect(mocks.user.delete).not.toHaveBeenCalled(); }); it('cannot delete admin user', async () => { @@ -131,33 +128,33 @@ describe(UserAdminService.name, () => { it('should require the auth user be an admin', async () => { await expect(sut.delete(authStub.user1, authStub.admin.user.id, {})).rejects.toBeInstanceOf(ForbiddenException); - expect(userMock.delete).not.toHaveBeenCalled(); + expect(mocks.user.delete).not.toHaveBeenCalled(); }); it('should delete user', async () => { - userMock.get.mockResolvedValue(userStub.user1); - userMock.update.mockResolvedValue(userStub.user1); + mocks.user.get.mockResolvedValue(userStub.user1); + mocks.user.update.mockResolvedValue(userStub.user1); await expect(sut.delete(authStub.admin, userStub.user1.id, {})).resolves.toEqual(mapUserAdmin(userStub.user1)); - expect(userMock.update).toHaveBeenCalledWith(userStub.user1.id, { + expect(mocks.user.update).toHaveBeenCalledWith(userStub.user1.id, { status: UserStatus.DELETED, deletedAt: expect.any(Date), }); }); it('should force delete user', async () => { - userMock.get.mockResolvedValue(userStub.user1); - userMock.update.mockResolvedValue(userStub.user1); + mocks.user.get.mockResolvedValue(userStub.user1); + mocks.user.update.mockResolvedValue(userStub.user1); await expect(sut.delete(authStub.admin, userStub.user1.id, { force: true })).resolves.toEqual( mapUserAdmin(userStub.user1), ); - expect(userMock.update).toHaveBeenCalledWith(userStub.user1.id, { + expect(mocks.user.update).toHaveBeenCalledWith(userStub.user1.id, { status: UserStatus.REMOVING, deletedAt: expect.any(Date), }); - expect(jobMock.queue).toHaveBeenCalledWith({ + expect(mocks.job.queue).toHaveBeenCalledWith({ name: JobName.USER_DELETION, data: { id: userStub.user1.id, force: true }, }); @@ -166,16 +163,16 @@ describe(UserAdminService.name, () => { describe('restore', () => { it('should throw error if user could not be found', async () => { - userMock.get.mockResolvedValue(void 0); + mocks.user.get.mockResolvedValue(void 0); await expect(sut.restore(authStub.admin, userStub.admin.id)).rejects.toThrowError(BadRequestException); - expect(userMock.update).not.toHaveBeenCalled(); + expect(mocks.user.update).not.toHaveBeenCalled(); }); it('should restore an user', async () => { - userMock.get.mockResolvedValue(userStub.user1); - userMock.restore.mockResolvedValue(userStub.user1); + mocks.user.get.mockResolvedValue(userStub.user1); + mocks.user.restore.mockResolvedValue(userStub.user1); await expect(sut.restore(authStub.admin, userStub.user1.id)).resolves.toEqual(mapUserAdmin(userStub.user1)); - expect(userMock.restore).toHaveBeenCalledWith(userStub.user1.id); + expect(mocks.user.restore).toHaveBeenCalledWith(userStub.user1.id); }); }); }); diff --git a/server/src/services/user.service.spec.ts b/server/src/services/user.service.spec.ts index 9ed941c36c..8762c7c766 100644 --- a/server/src/services/user.service.spec.ts +++ b/server/src/services/user.service.spec.ts @@ -1,18 +1,13 @@ import { BadRequestException, InternalServerErrorException, NotFoundException } from '@nestjs/common'; import { UserEntity } from 'src/entities/user.entity'; import { CacheControl, UserMetadataKey } from 'src/enum'; -import { IAlbumRepository } from 'src/interfaces/album.interface'; -import { IJobRepository, JobName } from 'src/interfaces/job.interface'; -import { IStorageRepository } from 'src/interfaces/storage.interface'; -import { IUserRepository } from 'src/interfaces/user.interface'; +import { JobName } from 'src/interfaces/job.interface'; import { UserService } from 'src/services/user.service'; -import { ISystemMetadataRepository } from 'src/types'; import { ImmichFileResponse } from 'src/utils/file'; import { authStub } from 'test/fixtures/auth.stub'; import { systemConfigStub } from 'test/fixtures/system-config.stub'; import { userStub } from 'test/fixtures/user.stub'; -import { newTestService } from 'test/utils'; -import { Mocked } from 'vitest'; +import { newTestService, ServiceMocks } from 'test/utils'; const makeDeletedAt = (daysAgo: number) => { const deletedAt = new Date(); @@ -22,68 +17,63 @@ const makeDeletedAt = (daysAgo: number) => { describe(UserService.name, () => { let sut: UserService; - - let albumMock: Mocked; - let jobMock: Mocked; - let storageMock: Mocked; - let systemMock: Mocked; - let userMock: Mocked; + let mocks: ServiceMocks; beforeEach(() => { - ({ sut, albumMock, jobMock, storageMock, systemMock, userMock } = newTestService(UserService)); + ({ sut, mocks } = newTestService(UserService)); - userMock.get.mockImplementation((userId) => + mocks.user.get.mockImplementation((userId) => Promise.resolve([userStub.admin, userStub.user1].find((user) => user.id === userId) ?? undefined), ); }); describe('getAll', () => { it('admin should get all users', async () => { - userMock.getList.mockResolvedValue([userStub.admin]); + mocks.user.getList.mockResolvedValue([userStub.admin]); await expect(sut.search(authStub.admin)).resolves.toEqual([ expect.objectContaining({ id: authStub.admin.user.id, email: authStub.admin.user.email, }), ]); - expect(userMock.getList).toHaveBeenCalledWith({ withDeleted: false }); + expect(mocks.user.getList).toHaveBeenCalledWith({ withDeleted: false }); }); it('non-admin should get all users when publicUsers enabled', async () => { - userMock.getList.mockResolvedValue([userStub.user1]); + mocks.user.getList.mockResolvedValue([userStub.user1]); await expect(sut.search(authStub.user1)).resolves.toEqual([ expect.objectContaining({ id: authStub.user1.user.id, email: authStub.user1.user.email, }), ]); - expect(userMock.getList).toHaveBeenCalledWith({ withDeleted: false }); + expect(mocks.user.getList).toHaveBeenCalledWith({ withDeleted: false }); }); it('non-admin user should only receive itself when publicUsers is disabled', async () => { - userMock.getList.mockResolvedValue([userStub.user1]); - systemMock.get.mockResolvedValue(systemConfigStub.publicUsersDisabled); + mocks.user.getList.mockResolvedValue([userStub.user1]); + mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.publicUsersDisabled); await expect(sut.search(authStub.user1)).resolves.toEqual([ expect.objectContaining({ id: authStub.user1.user.id, email: authStub.user1.user.email, }), ]); - expect(userMock.getList).not.toHaveBeenCalledWith({ withDeleted: false }); + expect(mocks.user.getList).not.toHaveBeenCalledWith({ withDeleted: false }); }); }); describe('get', () => { it('should get a user by id', async () => { - userMock.get.mockResolvedValue(userStub.admin); + mocks.user.get.mockResolvedValue(userStub.admin); await sut.get(authStub.admin.user.id); - expect(userMock.get).toHaveBeenCalledWith(authStub.admin.user.id, { withDeleted: false }); + expect(mocks.user.get).toHaveBeenCalledWith(authStub.admin.user.id, { withDeleted: false }); }); it('should throw an error if a user is not found', async () => { - userMock.get.mockResolvedValue(void 0); + mocks.user.get.mockResolvedValue(void 0); await expect(sut.get(authStub.admin.user.id)).rejects.toBeInstanceOf(BadRequestException); - expect(userMock.get).toHaveBeenCalledWith(authStub.admin.user.id, { withDeleted: false }); + expect(mocks.user.get).toHaveBeenCalledWith(authStub.admin.user.id, { withDeleted: false }); }); }); @@ -100,78 +90,78 @@ describe(UserService.name, () => { describe('createProfileImage', () => { it('should throw an error if the user does not exist', async () => { const file = { path: '/profile/path' } as Express.Multer.File; - userMock.get.mockResolvedValue(void 0); - userMock.update.mockResolvedValue({ ...userStub.admin, profileImagePath: file.path }); + mocks.user.get.mockResolvedValue(void 0); + mocks.user.update.mockResolvedValue({ ...userStub.admin, profileImagePath: file.path }); await expect(sut.createProfileImage(authStub.admin, file)).rejects.toThrowError(BadRequestException); }); it('should throw an error if the user profile could not be updated with the new image', async () => { const file = { path: '/profile/path' } as Express.Multer.File; - userMock.get.mockResolvedValue(userStub.profilePath); - userMock.update.mockRejectedValue(new InternalServerErrorException('mocked error')); + mocks.user.get.mockResolvedValue(userStub.profilePath); + mocks.user.update.mockRejectedValue(new InternalServerErrorException('mocked error')); await expect(sut.createProfileImage(authStub.admin, file)).rejects.toThrowError(InternalServerErrorException); }); it('should delete the previous profile image', async () => { const file = { path: '/profile/path' } as Express.Multer.File; - userMock.get.mockResolvedValue(userStub.profilePath); + mocks.user.get.mockResolvedValue(userStub.profilePath); const files = [userStub.profilePath.profileImagePath]; - userMock.update.mockResolvedValue({ ...userStub.admin, profileImagePath: file.path }); + mocks.user.update.mockResolvedValue({ ...userStub.admin, profileImagePath: file.path }); await sut.createProfileImage(authStub.admin, file); - expect(jobMock.queue.mock.calls).toEqual([[{ name: JobName.DELETE_FILES, data: { files } }]]); + expect(mocks.job.queue.mock.calls).toEqual([[{ name: JobName.DELETE_FILES, data: { files } }]]); }); it('should not delete the profile image if it has not been set', async () => { const file = { path: '/profile/path' } as Express.Multer.File; - userMock.get.mockResolvedValue(userStub.admin); - userMock.update.mockResolvedValue({ ...userStub.admin, profileImagePath: file.path }); + mocks.user.get.mockResolvedValue(userStub.admin); + mocks.user.update.mockResolvedValue({ ...userStub.admin, profileImagePath: file.path }); await sut.createProfileImage(authStub.admin, file); - expect(jobMock.queue).not.toHaveBeenCalled(); - expect(jobMock.queueAll).not.toHaveBeenCalled(); + expect(mocks.job.queue).not.toHaveBeenCalled(); + expect(mocks.job.queueAll).not.toHaveBeenCalled(); }); }); describe('deleteProfileImage', () => { it('should send an http error has no profile image', async () => { - userMock.get.mockResolvedValue(userStub.admin); + mocks.user.get.mockResolvedValue(userStub.admin); await expect(sut.deleteProfileImage(authStub.admin)).rejects.toBeInstanceOf(BadRequestException); - expect(jobMock.queue).not.toHaveBeenCalled(); - expect(jobMock.queueAll).not.toHaveBeenCalled(); + expect(mocks.job.queue).not.toHaveBeenCalled(); + expect(mocks.job.queueAll).not.toHaveBeenCalled(); }); it('should delete the profile image if user has one', async () => { - userMock.get.mockResolvedValue(userStub.profilePath); + mocks.user.get.mockResolvedValue(userStub.profilePath); const files = [userStub.profilePath.profileImagePath]; await sut.deleteProfileImage(authStub.admin); - expect(jobMock.queue.mock.calls).toEqual([[{ name: JobName.DELETE_FILES, data: { files } }]]); + expect(mocks.job.queue.mock.calls).toEqual([[{ name: JobName.DELETE_FILES, data: { files } }]]); }); }); describe('getUserProfileImage', () => { it('should throw an error if the user does not exist', async () => { - userMock.get.mockResolvedValue(void 0); + mocks.user.get.mockResolvedValue(void 0); await expect(sut.getProfileImage(userStub.admin.id)).rejects.toBeInstanceOf(BadRequestException); - expect(userMock.get).toHaveBeenCalledWith(userStub.admin.id, {}); + expect(mocks.user.get).toHaveBeenCalledWith(userStub.admin.id, {}); }); it('should throw an error if the user does not have a picture', async () => { - userMock.get.mockResolvedValue(userStub.admin); + mocks.user.get.mockResolvedValue(userStub.admin); await expect(sut.getProfileImage(userStub.admin.id)).rejects.toBeInstanceOf(NotFoundException); - expect(userMock.get).toHaveBeenCalledWith(userStub.admin.id, {}); + expect(mocks.user.get).toHaveBeenCalledWith(userStub.admin.id, {}); }); it('should return the profile picture', async () => { - userMock.get.mockResolvedValue(userStub.profilePath); + mocks.user.get.mockResolvedValue(userStub.profilePath); await expect(sut.getProfileImage(userStub.profilePath.id)).resolves.toEqual( new ImmichFileResponse({ @@ -181,13 +171,13 @@ describe(UserService.name, () => { }), ); - expect(userMock.get).toHaveBeenCalledWith(userStub.profilePath.id, {}); + expect(mocks.user.get).toHaveBeenCalledWith(userStub.profilePath.id, {}); }); }); describe('handleQueueUserDelete', () => { it('should skip users not ready for deletion', async () => { - userMock.getDeletedUsers.mockResolvedValue([ + mocks.user.getDeletedUsers.mockResolvedValue([ {}, { deletedAt: undefined }, { deletedAt: null }, @@ -196,14 +186,14 @@ describe(UserService.name, () => { await sut.handleUserDeleteCheck(); - expect(userMock.getDeletedUsers).toHaveBeenCalled(); - expect(jobMock.queue).not.toHaveBeenCalled(); - expect(jobMock.queueAll).toHaveBeenCalledWith([]); + expect(mocks.user.getDeletedUsers).toHaveBeenCalled(); + expect(mocks.job.queue).not.toHaveBeenCalled(); + expect(mocks.job.queueAll).toHaveBeenCalledWith([]); }); it('should skip users not ready for deletion - deleteDelay30', async () => { - systemMock.get.mockResolvedValue(systemConfigStub.deleteDelay30); - userMock.getDeletedUsers.mockResolvedValue([ + mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.deleteDelay30); + mocks.user.getDeletedUsers.mockResolvedValue([ {}, { deletedAt: undefined }, { deletedAt: null }, @@ -212,120 +202,120 @@ describe(UserService.name, () => { await sut.handleUserDeleteCheck(); - expect(userMock.getDeletedUsers).toHaveBeenCalled(); - expect(jobMock.queue).not.toHaveBeenCalled(); - expect(jobMock.queueAll).toHaveBeenCalledWith([]); + expect(mocks.user.getDeletedUsers).toHaveBeenCalled(); + expect(mocks.job.queue).not.toHaveBeenCalled(); + expect(mocks.job.queueAll).toHaveBeenCalledWith([]); }); it('should queue user ready for deletion', async () => { const user = { id: 'deleted-user', deletedAt: makeDeletedAt(10) }; - userMock.getDeletedUsers.mockResolvedValue([user] as UserEntity[]); + mocks.user.getDeletedUsers.mockResolvedValue([user] as UserEntity[]); await sut.handleUserDeleteCheck(); - expect(userMock.getDeletedUsers).toHaveBeenCalled(); - expect(jobMock.queueAll).toHaveBeenCalledWith([{ name: JobName.USER_DELETION, data: { id: user.id } }]); + expect(mocks.user.getDeletedUsers).toHaveBeenCalled(); + expect(mocks.job.queueAll).toHaveBeenCalledWith([{ name: JobName.USER_DELETION, data: { id: user.id } }]); }); it('should queue user ready for deletion - deleteDelay30', async () => { const user = { id: 'deleted-user', deletedAt: makeDeletedAt(31) }; - userMock.getDeletedUsers.mockResolvedValue([user] as UserEntity[]); + mocks.user.getDeletedUsers.mockResolvedValue([user] as UserEntity[]); await sut.handleUserDeleteCheck(); - expect(userMock.getDeletedUsers).toHaveBeenCalled(); - expect(jobMock.queueAll).toHaveBeenCalledWith([{ name: JobName.USER_DELETION, data: { id: user.id } }]); + expect(mocks.user.getDeletedUsers).toHaveBeenCalled(); + expect(mocks.job.queueAll).toHaveBeenCalledWith([{ name: JobName.USER_DELETION, data: { id: user.id } }]); }); }); describe('handleUserDelete', () => { it('should skip users not ready for deletion', async () => { const user = { id: 'user-1', deletedAt: makeDeletedAt(5) } as UserEntity; - userMock.get.mockResolvedValue(user); + mocks.user.get.mockResolvedValue(user); await sut.handleUserDelete({ id: user.id }); - expect(storageMock.unlinkDir).not.toHaveBeenCalled(); - expect(userMock.delete).not.toHaveBeenCalled(); + expect(mocks.storage.unlinkDir).not.toHaveBeenCalled(); + expect(mocks.user.delete).not.toHaveBeenCalled(); }); it('should delete the user and associated assets', async () => { const user = { id: 'deleted-user', deletedAt: makeDeletedAt(10) } as UserEntity; - userMock.get.mockResolvedValue(user); + mocks.user.get.mockResolvedValue(user); await sut.handleUserDelete({ id: user.id }); const options = { force: true, recursive: true }; - expect(storageMock.unlinkDir).toHaveBeenCalledWith('upload/library/deleted-user', options); - expect(storageMock.unlinkDir).toHaveBeenCalledWith('upload/upload/deleted-user', options); - expect(storageMock.unlinkDir).toHaveBeenCalledWith('upload/profile/deleted-user', options); - expect(storageMock.unlinkDir).toHaveBeenCalledWith('upload/thumbs/deleted-user', options); - expect(storageMock.unlinkDir).toHaveBeenCalledWith('upload/encoded-video/deleted-user', options); - expect(albumMock.deleteAll).toHaveBeenCalledWith(user.id); - expect(userMock.delete).toHaveBeenCalledWith(user, true); + expect(mocks.storage.unlinkDir).toHaveBeenCalledWith('upload/library/deleted-user', options); + expect(mocks.storage.unlinkDir).toHaveBeenCalledWith('upload/upload/deleted-user', options); + expect(mocks.storage.unlinkDir).toHaveBeenCalledWith('upload/profile/deleted-user', options); + expect(mocks.storage.unlinkDir).toHaveBeenCalledWith('upload/thumbs/deleted-user', options); + expect(mocks.storage.unlinkDir).toHaveBeenCalledWith('upload/encoded-video/deleted-user', options); + expect(mocks.album.deleteAll).toHaveBeenCalledWith(user.id); + expect(mocks.user.delete).toHaveBeenCalledWith(user, true); }); it('should delete the library path for a storage label', async () => { const user = { id: 'deleted-user', deletedAt: makeDeletedAt(10), storageLabel: 'admin' } as UserEntity; - userMock.get.mockResolvedValue(user); + mocks.user.get.mockResolvedValue(user); await sut.handleUserDelete({ id: user.id }); const options = { force: true, recursive: true }; - expect(storageMock.unlinkDir).toHaveBeenCalledWith('upload/library/admin', options); + expect(mocks.storage.unlinkDir).toHaveBeenCalledWith('upload/library/admin', options); }); }); describe('setLicense', () => { it('should save client license if valid', async () => { - userMock.upsertMetadata.mockResolvedValue(); + mocks.user.upsertMetadata.mockResolvedValue(); const license = { licenseKey: 'IMCL-license-key', activationKey: 'activation-key' }; await sut.setLicense(authStub.user1, license); - expect(userMock.upsertMetadata).toHaveBeenCalledWith(authStub.user1.user.id, { + expect(mocks.user.upsertMetadata).toHaveBeenCalledWith(authStub.user1.user.id, { key: UserMetadataKey.LICENSE, value: expect.any(Object), }); }); it('should save server license as client if valid', async () => { - userMock.upsertMetadata.mockResolvedValue(); + mocks.user.upsertMetadata.mockResolvedValue(); const license = { licenseKey: 'IMSV-license-key', activationKey: 'activation-key' }; await sut.setLicense(authStub.user1, license); - expect(userMock.upsertMetadata).toHaveBeenCalledWith(authStub.user1.user.id, { + expect(mocks.user.upsertMetadata).toHaveBeenCalledWith(authStub.user1.user.id, { key: UserMetadataKey.LICENSE, value: expect.any(Object), }); }); it('should not save license if invalid', async () => { - userMock.upsertMetadata.mockResolvedValue(); + mocks.user.upsertMetadata.mockResolvedValue(); const license = { licenseKey: 'license-key', activationKey: 'activation-key' }; const call = sut.setLicense(authStub.admin, license); await expect(call).rejects.toThrowError('Invalid license key'); - expect(userMock.upsertMetadata).not.toHaveBeenCalled(); + expect(mocks.user.upsertMetadata).not.toHaveBeenCalled(); }); }); describe('deleteLicense', () => { it('should delete license', async () => { - userMock.upsertMetadata.mockResolvedValue(); + mocks.user.upsertMetadata.mockResolvedValue(); await sut.deleteLicense(authStub.admin); - expect(userMock.upsertMetadata).not.toHaveBeenCalled(); + expect(mocks.user.upsertMetadata).not.toHaveBeenCalled(); }); }); describe('handleUserSyncUsage', () => { it('should sync usage', async () => { await sut.handleUserSyncUsage(); - expect(userMock.syncUsage).toHaveBeenCalledTimes(1); + expect(mocks.user.syncUsage).toHaveBeenCalledTimes(1); }); }); }); diff --git a/server/src/services/version.service.spec.ts b/server/src/services/version.service.spec.ts index 1fe55afc45..32378c52df 100644 --- a/server/src/services/version.service.spec.ts +++ b/server/src/services/version.service.spec.ts @@ -2,19 +2,10 @@ import { DateTime } from 'luxon'; import { SemVer } from 'semver'; import { serverVersion } from 'src/constants'; import { ImmichEnvironment, SystemMetadataKey } from 'src/enum'; -import { IEventRepository } from 'src/interfaces/event.interface'; -import { IJobRepository, JobName, JobStatus } from 'src/interfaces/job.interface'; +import { JobName, JobStatus } from 'src/interfaces/job.interface'; import { VersionService } from 'src/services/version.service'; -import { - IConfigRepository, - ILoggingRepository, - IServerInfoRepository, - ISystemMetadataRepository, - IVersionHistoryRepository, -} from 'src/types'; import { mockEnvData } from 'test/repositories/config.repository.mock'; -import { newTestService } from 'test/utils'; -import { Mocked } from 'vitest'; +import { newTestService, ServiceMocks } from 'test/utils'; const mockRelease = (version: string) => ({ id: 1, @@ -28,18 +19,10 @@ const mockRelease = (version: string) => ({ describe(VersionService.name, () => { let sut: VersionService; - - let configMock: Mocked; - let eventMock: Mocked; - let jobMock: Mocked; - let loggerMock: Mocked; - let serverInfoMock: Mocked; - let systemMock: Mocked; - let versionHistoryMock: Mocked; + let mocks: ServiceMocks; beforeEach(() => { - ({ sut, configMock, eventMock, jobMock, loggerMock, serverInfoMock, systemMock, versionHistoryMock } = - newTestService(VersionService)); + ({ sut, mocks } = newTestService(VersionService)); }); it('should work', () => { @@ -49,17 +32,17 @@ describe(VersionService.name, () => { describe('onBootstrap', () => { it('should record a new version', async () => { await expect(sut.onBootstrap()).resolves.toBeUndefined(); - expect(versionHistoryMock.create).toHaveBeenCalledWith({ version: expect.any(String) }); + expect(mocks.versionHistory.create).toHaveBeenCalledWith({ version: expect.any(String) }); }); it('should skip a duplicate version', async () => { - versionHistoryMock.getLatest.mockResolvedValue({ + mocks.versionHistory.getLatest.mockResolvedValue({ id: 'version-1', createdAt: new Date(), version: serverVersion.toString(), }); await expect(sut.onBootstrap()).resolves.toBeUndefined(); - expect(versionHistoryMock.create).not.toHaveBeenCalled(); + expect(mocks.versionHistory.create).not.toHaveBeenCalled(); }); }); @@ -76,7 +59,7 @@ describe(VersionService.name, () => { describe('getVersionHistory', () => { it('should respond the server version history', async () => { const upgrade = { id: 'upgrade-1', createdAt: new Date(), version: '1.0.0' }; - versionHistoryMock.getAll.mockResolvedValue([upgrade]); + mocks.versionHistory.getAll.mockResolvedValue([upgrade]); await expect(sut.getVersionHistory()).resolves.toEqual([upgrade]); }); }); @@ -84,22 +67,22 @@ describe(VersionService.name, () => { describe('handQueueVersionCheck', () => { it('should queue a version check job', async () => { await expect(sut.handleQueueVersionCheck()).resolves.toBeUndefined(); - expect(jobMock.queue).toHaveBeenCalledWith({ name: JobName.VERSION_CHECK, data: {} }); + expect(mocks.job.queue).toHaveBeenCalledWith({ name: JobName.VERSION_CHECK, data: {} }); }); }); describe('handVersionCheck', () => { beforeEach(() => { - configMock.getEnv.mockReturnValue(mockEnvData({ environment: ImmichEnvironment.PRODUCTION })); + mocks.config.getEnv.mockReturnValue(mockEnvData({ environment: ImmichEnvironment.PRODUCTION })); }); it('should not run in dev mode', async () => { - configMock.getEnv.mockReturnValue(mockEnvData({ environment: ImmichEnvironment.DEVELOPMENT })); + mocks.config.getEnv.mockReturnValue(mockEnvData({ environment: ImmichEnvironment.DEVELOPMENT })); await expect(sut.handleVersionCheck()).resolves.toEqual(JobStatus.SKIPPED); }); it('should not run if the last check was < 60 minutes ago', async () => { - systemMock.get.mockResolvedValue({ + mocks.systemMetadata.get.mockResolvedValue({ checkedAt: DateTime.utc().minus({ minutes: 5 }).toISO(), releaseVersion: '1.0.0', }); @@ -107,53 +90,53 @@ describe(VersionService.name, () => { }); it('should not run if version check is disabled', async () => { - systemMock.get.mockResolvedValue({ newVersionCheck: { enabled: false } }); + mocks.systemMetadata.get.mockResolvedValue({ newVersionCheck: { enabled: false } }); await expect(sut.handleVersionCheck()).resolves.toEqual(JobStatus.SKIPPED); }); it('should run if it has been > 60 minutes', async () => { - serverInfoMock.getGitHubRelease.mockResolvedValue(mockRelease('v100.0.0')); - systemMock.get.mockResolvedValue({ + mocks.serverInfo.getGitHubRelease.mockResolvedValue(mockRelease('v100.0.0')); + mocks.systemMetadata.get.mockResolvedValue({ checkedAt: DateTime.utc().minus({ minutes: 65 }).toISO(), releaseVersion: '1.0.0', }); await expect(sut.handleVersionCheck()).resolves.toEqual(JobStatus.SUCCESS); - expect(systemMock.set).toHaveBeenCalled(); - expect(loggerMock.log).toHaveBeenCalled(); - expect(eventMock.clientBroadcast).toHaveBeenCalled(); + expect(mocks.systemMetadata.set).toHaveBeenCalled(); + expect(mocks.logger.log).toHaveBeenCalled(); + expect(mocks.event.clientBroadcast).toHaveBeenCalled(); }); it('should not notify if the version is equal', async () => { - serverInfoMock.getGitHubRelease.mockResolvedValue(mockRelease(serverVersion.toString())); + mocks.serverInfo.getGitHubRelease.mockResolvedValue(mockRelease(serverVersion.toString())); await expect(sut.handleVersionCheck()).resolves.toEqual(JobStatus.SUCCESS); - expect(systemMock.set).toHaveBeenCalledWith(SystemMetadataKey.VERSION_CHECK_STATE, { + expect(mocks.systemMetadata.set).toHaveBeenCalledWith(SystemMetadataKey.VERSION_CHECK_STATE, { checkedAt: expect.any(String), releaseVersion: serverVersion.toString(), }); - expect(eventMock.clientBroadcast).not.toHaveBeenCalled(); + expect(mocks.event.clientBroadcast).not.toHaveBeenCalled(); }); it('should handle a github error', async () => { - serverInfoMock.getGitHubRelease.mockRejectedValue(new Error('GitHub is down')); + mocks.serverInfo.getGitHubRelease.mockRejectedValue(new Error('GitHub is down')); await expect(sut.handleVersionCheck()).resolves.toEqual(JobStatus.FAILED); - expect(systemMock.set).not.toHaveBeenCalled(); - expect(eventMock.clientBroadcast).not.toHaveBeenCalled(); - expect(loggerMock.warn).toHaveBeenCalled(); + expect(mocks.systemMetadata.set).not.toHaveBeenCalled(); + expect(mocks.event.clientBroadcast).not.toHaveBeenCalled(); + expect(mocks.logger.warn).toHaveBeenCalled(); }); }); describe('onWebsocketConnectionEvent', () => { it('should send on_server_version client event', async () => { await sut.onWebsocketConnection({ userId: '42' }); - expect(eventMock.clientSend).toHaveBeenCalledWith('on_server_version', '42', expect.any(SemVer)); - expect(eventMock.clientSend).toHaveBeenCalledTimes(1); + expect(mocks.event.clientSend).toHaveBeenCalledWith('on_server_version', '42', expect.any(SemVer)); + expect(mocks.event.clientSend).toHaveBeenCalledTimes(1); }); it('should also send a new release notification', async () => { - systemMock.get.mockResolvedValue({ checkedAt: '2024-01-01', releaseVersion: 'v1.42.0' }); + mocks.systemMetadata.get.mockResolvedValue({ checkedAt: '2024-01-01', releaseVersion: 'v1.42.0' }); await sut.onWebsocketConnection({ userId: '42' }); - expect(eventMock.clientSend).toHaveBeenCalledWith('on_server_version', '42', expect.any(SemVer)); - expect(eventMock.clientSend).toHaveBeenCalledWith('on_new_release', '42', expect.any(Object)); + expect(mocks.event.clientSend).toHaveBeenCalledWith('on_server_version', '42', expect.any(SemVer)); + expect(mocks.event.clientSend).toHaveBeenCalledWith('on_new_release', '42', expect.any(Object)); }); }); }); diff --git a/server/src/services/view.service.spec.ts b/server/src/services/view.service.spec.ts index e033ec0dc8..86bfcef734 100644 --- a/server/src/services/view.service.spec.ts +++ b/server/src/services/view.service.spec.ts @@ -1,18 +1,15 @@ import { mapAsset } from 'src/dtos/asset-response.dto'; import { ViewService } from 'src/services/view.service'; -import { IViewRepository } from 'src/types'; import { assetStub } from 'test/fixtures/asset.stub'; import { authStub } from 'test/fixtures/auth.stub'; -import { newTestService } from 'test/utils'; - -import { Mocked } from 'vitest'; +import { newTestService, ServiceMocks } from 'test/utils'; describe(ViewService.name, () => { let sut: ViewService; - let viewMock: Mocked; + let mocks: ServiceMocks; beforeEach(() => { - ({ sut, viewMock } = newTestService(ViewService)); + ({ sut, mocks } = newTestService(ViewService)); }); it('should work', () => { @@ -22,12 +19,12 @@ describe(ViewService.name, () => { describe('getUniqueOriginalPaths', () => { it('should return unique original paths', async () => { const mockPaths = ['path1', 'path2', 'path3']; - viewMock.getUniqueOriginalPaths.mockResolvedValue(mockPaths); + mocks.view.getUniqueOriginalPaths.mockResolvedValue(mockPaths); const result = await sut.getUniqueOriginalPaths(authStub.admin); expect(result).toEqual(mockPaths); - expect(viewMock.getUniqueOriginalPaths).toHaveBeenCalledWith(authStub.admin.user.id); + expect(mocks.view.getUniqueOriginalPaths).toHaveBeenCalledWith(authStub.admin.user.id); }); }); @@ -42,11 +39,11 @@ describe(ViewService.name, () => { const mockAssetReponseDto = mockAssets.map((a) => mapAsset(a, { auth: authStub.admin })); - viewMock.getAssetsByOriginalPath.mockResolvedValue(mockAssets as any); + mocks.view.getAssetsByOriginalPath.mockResolvedValue(mockAssets as any); const result = await sut.getAssetsByOriginalPath(authStub.admin, path); expect(result).toEqual(mockAssetReponseDto); - await expect(viewMock.getAssetsByOriginalPath(authStub.admin.user.id, path)).resolves.toEqual(mockAssets); + await expect(mocks.view.getAssetsByOriginalPath(authStub.admin.user.id, path)).resolves.toEqual(mockAssets); }); }); }); diff --git a/server/src/types.ts b/server/src/types.ts index 8e8e329b8b..e0523333d8 100644 --- a/server/src/types.ts +++ b/server/src/types.ts @@ -1,27 +1,9 @@ import { UserEntity } from 'src/entities/user.entity'; import { ExifOrientation, ImageFormat, Permission, TranscodeTarget, VideoCodec } from 'src/enum'; -import { AccessRepository } from 'src/repositories/access.repository'; import { ActivityRepository } from 'src/repositories/activity.repository'; -import { AlbumUserRepository } from 'src/repositories/album-user.repository'; import { ApiKeyRepository } from 'src/repositories/api-key.repository'; -import { AuditRepository } from 'src/repositories/audit.repository'; -import { ConfigRepository } from 'src/repositories/config.repository'; -import { CronRepository } from 'src/repositories/cron.repository'; -import { LoggingRepository } from 'src/repositories/logging.repository'; -import { MapRepository } from 'src/repositories/map.repository'; -import { MediaRepository } from 'src/repositories/media.repository'; import { MemoryRepository } from 'src/repositories/memory.repository'; -import { MetadataRepository } from 'src/repositories/metadata.repository'; -import { NotificationRepository } from 'src/repositories/notification.repository'; -import { OAuthRepository } from 'src/repositories/oauth.repository'; -import { ProcessRepository } from 'src/repositories/process.repository'; -import { ServerInfoRepository } from 'src/repositories/server-info.repository'; import { SessionRepository } from 'src/repositories/session.repository'; -import { SystemMetadataRepository } from 'src/repositories/system-metadata.repository'; -import { MetricGroupRepository, TelemetryRepository } from 'src/repositories/telemetry.repository'; -import { TrashRepository } from 'src/repositories/trash.repository'; -import { VersionHistoryRepository } from 'src/repositories/version-history.repository'; -import { ViewRepository } from 'src/repositories/view-repository'; export type DeepPartial = T extends object ? { [K in keyof T]?: DeepPartial } : T; @@ -34,41 +16,10 @@ export type AuthApiKey = { export type RepositoryInterface = Pick; -export type IActivityRepository = RepositoryInterface; -export type IAccessRepository = { [K in keyof AccessRepository]: RepositoryInterface }; -export type IAlbumUserRepository = RepositoryInterface; -export type IApiKeyRepository = RepositoryInterface; -export type IAuditRepository = RepositoryInterface; -export type IConfigRepository = RepositoryInterface; -export type ICronRepository = RepositoryInterface; -export type ILoggingRepository = Pick< - LoggingRepository, - | 'verbose' - | 'log' - | 'debug' - | 'warn' - | 'error' - | 'fatal' - | 'isLevelEnabled' - | 'setLogLevel' - | 'setContext' - | 'setAppName' ->; -export type IMapRepository = RepositoryInterface; -export type IMediaRepository = RepositoryInterface; -export type IMemoryRepository = RepositoryInterface; -export type IMetadataRepository = RepositoryInterface; -export type IMetricGroupRepository = RepositoryInterface; -export type INotificationRepository = RepositoryInterface; -export type IOAuthRepository = RepositoryInterface; -export type IProcessRepository = RepositoryInterface; -export type ISessionRepository = RepositoryInterface; -export type IServerInfoRepository = RepositoryInterface; -export type ISystemMetadataRepository = RepositoryInterface; -export type ITelemetryRepository = RepositoryInterface; -export type ITrashRepository = RepositoryInterface; -export type IViewRepository = RepositoryInterface; -export type IVersionHistoryRepository = RepositoryInterface; +type IActivityRepository = RepositoryInterface; +type IApiKeyRepository = RepositoryInterface; +type IMemoryRepository = RepositoryInterface; +type ISessionRepository = RepositoryInterface; export type ActivityItem = | Awaited> diff --git a/server/src/utils/config.ts b/server/src/utils/config.ts index 64a0a37c86..056064c026 100644 --- a/server/src/utils/config.ts +++ b/server/src/utils/config.ts @@ -7,15 +7,18 @@ import { SystemConfig, defaults } from 'src/config'; import { SystemConfigDto } from 'src/dtos/system-config.dto'; import { SystemMetadataKey } from 'src/enum'; import { DatabaseLock } from 'src/interfaces/database.interface'; -import { DeepPartial, IConfigRepository, ILoggingRepository, ISystemMetadataRepository } from 'src/types'; +import { ConfigRepository } from 'src/repositories/config.repository'; +import { LoggingRepository } from 'src/repositories/logging.repository'; +import { SystemMetadataRepository } from 'src/repositories/system-metadata.repository'; +import { DeepPartial } from 'src/types'; import { getKeysDeep, unsetDeep } from 'src/utils/misc'; export type SystemConfigValidator = (config: SystemConfig, newConfig: SystemConfig) => void | Promise; type RepoDeps = { - configRepo: IConfigRepository; - metadataRepo: ISystemMetadataRepository; - logger: ILoggingRepository; + configRepo: ConfigRepository; + metadataRepo: SystemMetadataRepository; + logger: LoggingRepository; }; const asyncLock = new AsyncLock(); diff --git a/server/src/utils/file.ts b/server/src/utils/file.ts index 4f3009e39f..d9c599169d 100644 --- a/server/src/utils/file.ts +++ b/server/src/utils/file.ts @@ -5,7 +5,7 @@ import { basename, extname, isAbsolute } from 'node:path'; import { promisify } from 'node:util'; import { CacheControl } from 'src/enum'; import { ImmichReadStream } from 'src/interfaces/storage.interface'; -import { ILoggingRepository } from 'src/types'; +import { LoggingRepository } from 'src/repositories/logging.repository'; import { isConnectionAborted } from 'src/utils/misc'; export function getFileNameWithoutExtension(path: string): string { @@ -37,7 +37,7 @@ export const sendFile = async ( res: Response, next: NextFunction, handler: () => Promise, - logger: ILoggingRepository, + logger: LoggingRepository, ): Promise => { const _sendFile = (path: string, options: SendFileOptions) => promisify(res.sendFile).bind(res)(path, options); diff --git a/server/src/utils/logger.ts b/server/src/utils/logger.ts index 2fe2c618be..f2f47e0471 100644 --- a/server/src/utils/logger.ts +++ b/server/src/utils/logger.ts @@ -1,8 +1,8 @@ import { HttpException } from '@nestjs/common'; -import { ILoggingRepository } from 'src/types'; +import { LoggingRepository } from 'src/repositories/logging.repository'; import { TypeORMError } from 'typeorm'; -export const logGlobalError = (logger: ILoggingRepository, error: Error) => { +export const logGlobalError = (logger: LoggingRepository, error: Error) => { if (error instanceof HttpException) { const status = error.getStatus(); const response = error.getResponse(); diff --git a/server/src/utils/misc.ts b/server/src/utils/misc.ts index d53f9ecf36..13969543ef 100644 --- a/server/src/utils/misc.ts +++ b/server/src/utils/misc.ts @@ -13,7 +13,7 @@ import path from 'node:path'; import { SystemConfig } from 'src/config'; import { CLIP_MODEL_INFO, serverVersion } from 'src/constants'; import { ImmichCookie, ImmichHeader, MetadataKey } from 'src/enum'; -import { ILoggingRepository } from 'src/types'; +import { LoggingRepository } from 'src/repositories/logging.repository'; export class ImmichStartupError extends Error {} export const isStartUpError = (error: unknown): error is ImmichStartupError => error instanceof ImmichStartupError; @@ -96,7 +96,7 @@ export const isFaceImportEnabled = (metadata: SystemConfig['metadata']) => metad export const isConnectionAborted = (error: Error | any) => error.code === 'ECONNABORTED'; -export const handlePromiseError = (promise: Promise, logger: ILoggingRepository): void => { +export const handlePromiseError = (promise: Promise, logger: LoggingRepository): void => { promise.catch((error: Error | any) => logger.error(`Promise error: ${error}`, error?.stack)); }; diff --git a/server/test/medium/metadata.service.spec.ts b/server/test/medium/metadata.service.spec.ts index 1750584018..4c89ce4e37 100644 --- a/server/test/medium/metadata.service.spec.ts +++ b/server/test/medium/metadata.service.spec.ts @@ -3,15 +3,11 @@ import { writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { AssetEntity } from 'src/entities/asset.entity'; -import { IAssetRepository } from 'src/interfaces/asset.interface'; -import { IStorageRepository } from 'src/interfaces/storage.interface'; import { LoggingRepository } from 'src/repositories/logging.repository'; import { MetadataRepository } from 'src/repositories/metadata.repository'; import { MetadataService } from 'src/services/metadata.service'; -import { ILoggingRepository } from 'src/types'; -import { newLoggingRepositoryMock } from 'test/repositories/logger.repository.mock'; -import { newRandomImage, newTestService } from 'test/utils'; -import { Mocked } from 'vitest'; +import { ILoggingRepository, newLoggingRepositoryMock } from 'test/repositories/logger.repository.mock'; +import { newRandomImage, newTestService, ServiceMocks } from 'test/utils'; const metadataRepository = new MetadataRepository( newLoggingRepositoryMock() as ILoggingRepository as LoggingRepository, @@ -38,14 +34,12 @@ type TimeZoneTest = { describe(MetadataService.name, () => { let sut: MetadataService; - - let assetMock: Mocked; - let storageMock: Mocked; + let mocks: ServiceMocks; beforeEach(() => { - ({ sut, assetMock, storageMock } = newTestService(MetadataService, { metadataRepository })); + ({ sut, mocks } = newTestService(MetadataService, { metadataRepository })); - storageMock.stat.mockResolvedValue({ size: 123_456 } as Stats); + mocks.storage.stat.mockResolvedValue({ size: 123_456 } as Stats); delete process.env.TZ; }); @@ -120,18 +114,18 @@ describe(MetadataService.name, () => { process.env.TZ = serverTimeZone ?? undefined; const { filePath } = await createTestFile(exifData); - assetMock.getByIds.mockResolvedValue([{ id: 'asset-1', originalPath: filePath } as AssetEntity]); + mocks.asset.getByIds.mockResolvedValue([{ id: 'asset-1', originalPath: filePath } as AssetEntity]); await sut.handleMetadataExtraction({ id: 'asset-1' }); - expect(assetMock.upsertExif).toHaveBeenCalledWith( + expect(mocks.asset.upsertExif).toHaveBeenCalledWith( expect.objectContaining({ dateTimeOriginal: new Date(expected.dateTimeOriginal), timeZone: expected.timeZone, }), ); - expect(assetMock.update).toHaveBeenCalledWith( + expect(mocks.asset.update).toHaveBeenCalledWith( expect.objectContaining({ localDateTime: new Date(expected.localDateTime), }), diff --git a/server/test/repositories/access.repository.mock.ts b/server/test/repositories/access.repository.mock.ts index 23886e0495..ec5115b839 100644 --- a/server/test/repositories/access.repository.mock.ts +++ b/server/test/repositories/access.repository.mock.ts @@ -1,7 +1,12 @@ -import { IAccessRepository } from 'src/types'; +import { AccessRepository } from 'src/repositories/access.repository'; +import { RepositoryInterface } from 'src/types'; import { Mocked, vitest } from 'vitest'; -export type IAccessRepositoryMock = { [K in keyof IAccessRepository]: Mocked }; +type IAccessRepository = { [K in keyof AccessRepository]: RepositoryInterface }; + +export type IAccessRepositoryMock = { + [K in keyof IAccessRepository]: Mocked; +}; export const newAccessRepositoryMock = (): IAccessRepositoryMock => { return { diff --git a/server/test/repositories/activity.repository.mock.ts b/server/test/repositories/activity.repository.mock.ts index bcc27774e3..81208b7232 100644 --- a/server/test/repositories/activity.repository.mock.ts +++ b/server/test/repositories/activity.repository.mock.ts @@ -1,7 +1,8 @@ -import { IActivityRepository } from 'src/types'; +import { ActivityRepository } from 'src/repositories/activity.repository'; +import { RepositoryInterface } from 'src/types'; import { Mocked, vitest } from 'vitest'; -export const newActivityRepositoryMock = (): Mocked => { +export const newActivityRepositoryMock = (): Mocked> => { return { search: vitest.fn(), create: vitest.fn(), diff --git a/server/test/repositories/album-user.repository.mock.ts b/server/test/repositories/album-user.repository.mock.ts index aa9436e33d..e3225661a4 100644 --- a/server/test/repositories/album-user.repository.mock.ts +++ b/server/test/repositories/album-user.repository.mock.ts @@ -1,7 +1,8 @@ -import { IAlbumUserRepository } from 'src/types'; +import { AlbumUserRepository } from 'src/repositories/album-user.repository'; +import { RepositoryInterface } from 'src/types'; import { Mocked } from 'vitest'; -export const newAlbumUserRepositoryMock = (): Mocked => { +export const newAlbumUserRepositoryMock = (): Mocked> => { return { create: vitest.fn(), delete: vitest.fn(), diff --git a/server/test/repositories/api-key.repository.mock.ts b/server/test/repositories/api-key.repository.mock.ts index 8c471e520f..e8ae0bf8e2 100644 --- a/server/test/repositories/api-key.repository.mock.ts +++ b/server/test/repositories/api-key.repository.mock.ts @@ -1,7 +1,8 @@ -import { IApiKeyRepository } from 'src/types'; +import { ApiKeyRepository } from 'src/repositories/api-key.repository'; +import { RepositoryInterface } from 'src/types'; import { Mocked, vitest } from 'vitest'; -export const newKeyRepositoryMock = (): Mocked => { +export const newKeyRepositoryMock = (): Mocked> => { return { create: vitest.fn(), update: vitest.fn(), diff --git a/server/test/repositories/audit.repository.mock.ts b/server/test/repositories/audit.repository.mock.ts index 96fe407c96..a76079f45d 100644 --- a/server/test/repositories/audit.repository.mock.ts +++ b/server/test/repositories/audit.repository.mock.ts @@ -1,7 +1,8 @@ -import { IAuditRepository } from 'src/types'; +import { AuditRepository } from 'src/repositories/audit.repository'; +import { RepositoryInterface } from 'src/types'; import { Mocked, vitest } from 'vitest'; -export const newAuditRepositoryMock = (): Mocked => { +export const newAuditRepositoryMock = (): Mocked> => { return { getAfter: vitest.fn(), removeBefore: vitest.fn(), diff --git a/server/test/repositories/config.repository.mock.ts b/server/test/repositories/config.repository.mock.ts index 2b195ae8c9..800d40642b 100644 --- a/server/test/repositories/config.repository.mock.ts +++ b/server/test/repositories/config.repository.mock.ts @@ -1,7 +1,7 @@ import { ImmichEnvironment, ImmichWorker } from 'src/enum'; import { DatabaseExtension } from 'src/interfaces/database.interface'; -import { EnvData } from 'src/repositories/config.repository'; -import { IConfigRepository } from 'src/types'; +import { ConfigRepository, EnvData } from 'src/repositories/config.repository'; +import { RepositoryInterface } from 'src/types'; import { Mocked, vitest } from 'vitest'; const envData: EnvData = { @@ -97,7 +97,7 @@ const envData: EnvData = { }; export const mockEnvData = (config: Partial) => ({ ...envData, ...config }); -export const newConfigRepositoryMock = (): Mocked => { +export const newConfigRepositoryMock = (): Mocked> => { return { getEnv: vitest.fn().mockReturnValue(mockEnvData({})), getWorker: vitest.fn().mockReturnValue(ImmichWorker.API), diff --git a/server/test/repositories/cron.repository.mock.ts b/server/test/repositories/cron.repository.mock.ts index cc856909c8..5b74bd3cf5 100644 --- a/server/test/repositories/cron.repository.mock.ts +++ b/server/test/repositories/cron.repository.mock.ts @@ -1,7 +1,8 @@ -import { ICronRepository } from 'src/types'; +import { CronRepository } from 'src/repositories/cron.repository'; +import { RepositoryInterface } from 'src/types'; import { Mocked, vitest } from 'vitest'; -export const newCronRepositoryMock = (): Mocked => { +export const newCronRepositoryMock = (): Mocked> => { return { create: vitest.fn(), update: vitest.fn(), diff --git a/server/test/repositories/logger.repository.mock.ts b/server/test/repositories/logger.repository.mock.ts index 0336a66090..46a81c8965 100644 --- a/server/test/repositories/logger.repository.mock.ts +++ b/server/test/repositories/logger.repository.mock.ts @@ -1,6 +1,20 @@ -import { ILoggingRepository } from 'src/types'; +import { LoggingRepository } from 'src/repositories/logging.repository'; import { Mocked, vitest } from 'vitest'; +export type ILoggingRepository = Pick< + LoggingRepository, + | 'verbose' + | 'log' + | 'debug' + | 'warn' + | 'error' + | 'fatal' + | 'isLevelEnabled' + | 'setLogLevel' + | 'setContext' + | 'setAppName' +>; + export const newLoggingRepositoryMock = (): Mocked => { return { setLogLevel: vitest.fn(), diff --git a/server/test/repositories/map.repository.mock.ts b/server/test/repositories/map.repository.mock.ts index 4b56b9443a..9e7df32252 100644 --- a/server/test/repositories/map.repository.mock.ts +++ b/server/test/repositories/map.repository.mock.ts @@ -1,7 +1,8 @@ -import { IMapRepository } from 'src/types'; +import { MapRepository } from 'src/repositories/map.repository'; +import { RepositoryInterface } from 'src/types'; import { Mocked } from 'vitest'; -export const newMapRepositoryMock = (): Mocked => { +export const newMapRepositoryMock = (): Mocked> => { return { init: vitest.fn(), reverseGeocode: vitest.fn(), diff --git a/server/test/repositories/media.repository.mock.ts b/server/test/repositories/media.repository.mock.ts index 238066ad9e..7c651ddef6 100644 --- a/server/test/repositories/media.repository.mock.ts +++ b/server/test/repositories/media.repository.mock.ts @@ -1,7 +1,8 @@ -import { IMediaRepository } from 'src/types'; +import { MediaRepository } from 'src/repositories/media.repository'; +import { RepositoryInterface } from 'src/types'; import { Mocked, vitest } from 'vitest'; -export const newMediaRepositoryMock = (): Mocked => { +export const newMediaRepositoryMock = (): Mocked> => { return { generateThumbnail: vitest.fn().mockImplementation(() => Promise.resolve()), generateThumbhash: vitest.fn().mockResolvedValue(Buffer.from('')), diff --git a/server/test/repositories/memory.repository.mock.ts b/server/test/repositories/memory.repository.mock.ts index c818c29195..b33404f520 100644 --- a/server/test/repositories/memory.repository.mock.ts +++ b/server/test/repositories/memory.repository.mock.ts @@ -1,7 +1,8 @@ -import { IMemoryRepository } from 'src/types'; +import { MemoryRepository } from 'src/repositories/memory.repository'; +import { RepositoryInterface } from 'src/types'; import { Mocked, vitest } from 'vitest'; -export const newMemoryRepositoryMock = (): Mocked => { +export const newMemoryRepositoryMock = (): Mocked> => { return { search: vitest.fn().mockResolvedValue([]), get: vitest.fn(), diff --git a/server/test/repositories/metadata.repository.mock.ts b/server/test/repositories/metadata.repository.mock.ts index e9bb68b95b..47a0471b22 100644 --- a/server/test/repositories/metadata.repository.mock.ts +++ b/server/test/repositories/metadata.repository.mock.ts @@ -1,7 +1,8 @@ -import { IMetadataRepository } from 'src/types'; +import { MetadataRepository } from 'src/repositories/metadata.repository'; +import { RepositoryInterface } from 'src/types'; import { Mocked, vitest } from 'vitest'; -export const newMetadataRepositoryMock = (): Mocked => { +export const newMetadataRepositoryMock = (): Mocked> => { return { teardown: vitest.fn(), readTags: vitest.fn(), diff --git a/server/test/repositories/notification.repository.mock.ts b/server/test/repositories/notification.repository.mock.ts index 2065a0bf3e..3aa7f63cf2 100644 --- a/server/test/repositories/notification.repository.mock.ts +++ b/server/test/repositories/notification.repository.mock.ts @@ -1,7 +1,8 @@ -import { INotificationRepository } from 'src/types'; +import { NotificationRepository } from 'src/repositories/notification.repository'; +import { RepositoryInterface } from 'src/types'; import { Mocked } from 'vitest'; -export const newNotificationRepositoryMock = (): Mocked => { +export const newNotificationRepositoryMock = (): Mocked> => { return { renderEmail: vitest.fn(), sendEmail: vitest.fn().mockResolvedValue({ messageId: 'message-1' }), diff --git a/server/test/repositories/oauth.repository.mock.ts b/server/test/repositories/oauth.repository.mock.ts index 8980bfb14f..64777fa671 100644 --- a/server/test/repositories/oauth.repository.mock.ts +++ b/server/test/repositories/oauth.repository.mock.ts @@ -1,7 +1,8 @@ -import { IOAuthRepository } from 'src/types'; +import { OAuthRepository } from 'src/repositories/oauth.repository'; +import { RepositoryInterface } from 'src/types'; import { Mocked } from 'vitest'; -export const newOAuthRepositoryMock = (): Mocked => { +export const newOAuthRepositoryMock = (): Mocked> => { return { init: vitest.fn(), authorize: vitest.fn(), diff --git a/server/test/repositories/process.repository.mock.ts b/server/test/repositories/process.repository.mock.ts index 0ef1b0fdb1..f78975310b 100644 --- a/server/test/repositories/process.repository.mock.ts +++ b/server/test/repositories/process.repository.mock.ts @@ -1,7 +1,8 @@ -import { IProcessRepository } from 'src/types'; +import { ProcessRepository } from 'src/repositories/process.repository'; +import { RepositoryInterface } from 'src/types'; import { Mocked, vitest } from 'vitest'; -export const newProcessRepositoryMock = (): Mocked => { +export const newProcessRepositoryMock = (): Mocked> => { return { spawn: vitest.fn(), }; diff --git a/server/test/repositories/server-info.repository.mock.ts b/server/test/repositories/server-info.repository.mock.ts index 5e9ecd1387..49f955b283 100644 --- a/server/test/repositories/server-info.repository.mock.ts +++ b/server/test/repositories/server-info.repository.mock.ts @@ -1,7 +1,8 @@ -import { IServerInfoRepository } from 'src/types'; +import { ServerInfoRepository } from 'src/repositories/server-info.repository'; +import { RepositoryInterface } from 'src/types'; import { Mocked, vitest } from 'vitest'; -export const newServerInfoRepositoryMock = (): Mocked => { +export const newServerInfoRepositoryMock = (): Mocked> => { return { getGitHubRelease: vitest.fn(), getBuildVersions: vitest.fn(), diff --git a/server/test/repositories/session.repository.mock.ts b/server/test/repositories/session.repository.mock.ts index 41fa1640a2..b519b07e36 100644 --- a/server/test/repositories/session.repository.mock.ts +++ b/server/test/repositories/session.repository.mock.ts @@ -1,7 +1,8 @@ -import { ISessionRepository } from 'src/types'; +import { SessionRepository } from 'src/repositories/session.repository'; +import { RepositoryInterface } from 'src/types'; import { Mocked, vitest } from 'vitest'; -export const newSessionRepositoryMock = (): Mocked => { +export const newSessionRepositoryMock = (): Mocked> => { return { search: vitest.fn(), create: vitest.fn() as any, diff --git a/server/test/repositories/system-metadata.repository.mock.ts b/server/test/repositories/system-metadata.repository.mock.ts index b96b525697..ab9e300576 100644 --- a/server/test/repositories/system-metadata.repository.mock.ts +++ b/server/test/repositories/system-metadata.repository.mock.ts @@ -1,8 +1,9 @@ -import { ISystemMetadataRepository } from 'src/types'; +import { SystemMetadataRepository } from 'src/repositories/system-metadata.repository'; +import { RepositoryInterface } from 'src/types'; import { clearConfigCache } from 'src/utils/config'; import { Mocked, vitest } from 'vitest'; -export const newSystemMetadataRepositoryMock = (): Mocked => { +export const newSystemMetadataRepositoryMock = (): Mocked> => { clearConfigCache(); return { get: vitest.fn() as any, diff --git a/server/test/repositories/telemetry.repository.mock.ts b/server/test/repositories/telemetry.repository.mock.ts index afadcea0cf..c7442052da 100644 --- a/server/test/repositories/telemetry.repository.mock.ts +++ b/server/test/repositories/telemetry.repository.mock.ts @@ -1,4 +1,5 @@ -import { ITelemetryRepository, RepositoryInterface } from 'src/types'; +import { TelemetryRepository } from 'src/repositories/telemetry.repository'; +import { RepositoryInterface } from 'src/types'; import { Mocked, vitest } from 'vitest'; const newMetricGroupMock = () => { @@ -10,6 +11,8 @@ const newMetricGroupMock = () => { }; }; +type ITelemetryRepository = RepositoryInterface; + export type ITelemetryRepositoryMock = { [K in keyof ITelemetryRepository]: Mocked>; }; diff --git a/server/test/repositories/trash.repository.mock.ts b/server/test/repositories/trash.repository.mock.ts index f983afdce8..b42867213a 100644 --- a/server/test/repositories/trash.repository.mock.ts +++ b/server/test/repositories/trash.repository.mock.ts @@ -1,7 +1,8 @@ -import { ITrashRepository } from 'src/types'; +import { TrashRepository } from 'src/repositories/trash.repository'; +import { RepositoryInterface } from 'src/types'; import { Mocked, vitest } from 'vitest'; -export const newTrashRepositoryMock = (): Mocked => { +export const newTrashRepositoryMock = (): Mocked> => { return { empty: vitest.fn(), restore: vitest.fn(), diff --git a/server/test/repositories/version-history.repository.mock.ts b/server/test/repositories/version-history.repository.mock.ts index 9ff7708796..98a9166487 100644 --- a/server/test/repositories/version-history.repository.mock.ts +++ b/server/test/repositories/version-history.repository.mock.ts @@ -1,7 +1,8 @@ -import { IVersionHistoryRepository } from 'src/types'; +import { VersionHistoryRepository } from 'src/repositories/version-history.repository'; +import { RepositoryInterface } from 'src/types'; import { Mocked, vitest } from 'vitest'; -export const newVersionHistoryRepositoryMock = (): Mocked => { +export const newVersionHistoryRepositoryMock = (): Mocked> => { return { getAll: vitest.fn().mockResolvedValue([]), getLatest: vitest.fn(), diff --git a/server/test/repositories/view.repository.mock.ts b/server/test/repositories/view.repository.mock.ts index bb58fda8a3..057d7ee28a 100644 --- a/server/test/repositories/view.repository.mock.ts +++ b/server/test/repositories/view.repository.mock.ts @@ -1,7 +1,8 @@ -import { IViewRepository } from 'src/types'; +import { ViewRepository } from 'src/repositories/view-repository'; +import { RepositoryInterface } from 'src/types'; import { Mocked, vitest } from 'vitest'; -export const newViewRepositoryMock = (): Mocked => { +export const newViewRepositoryMock = (): Mocked> => { return { getAssetsByOriginalPath: vitest.fn(), getUniqueOriginalPaths: vitest.fn(), diff --git a/server/test/utils.ts b/server/test/utils.ts index 34af8877b1..c4fee8fe93 100644 --- a/server/test/utils.ts +++ b/server/test/utils.ts @@ -2,51 +2,49 @@ import { ChildProcessWithoutNullStreams } from 'node:child_process'; import { Writable } from 'node:stream'; import { PNG } from 'pngjs'; import { ImmichWorker } from 'src/enum'; +import { IAlbumRepository } from 'src/interfaces/album.interface'; +import { IAssetRepository } from 'src/interfaces/asset.interface'; +import { ICryptoRepository } from 'src/interfaces/crypto.interface'; +import { IEventRepository } from 'src/interfaces/event.interface'; +import { IMachineLearningRepository } from 'src/interfaces/machine-learning.interface'; +import { IUserRepository } from 'src/interfaces/user.interface'; import { AccessRepository } from 'src/repositories/access.repository'; import { ActivityRepository } from 'src/repositories/activity.repository'; import { AlbumUserRepository } from 'src/repositories/album-user.repository'; import { ApiKeyRepository } from 'src/repositories/api-key.repository'; import { AuditRepository } from 'src/repositories/audit.repository'; +import { ConfigRepository } from 'src/repositories/config.repository'; import { CronRepository } from 'src/repositories/cron.repository'; +import { CryptoRepository } from 'src/repositories/crypto.repository'; +import { DatabaseRepository } from 'src/repositories/database.repository'; +import { JobRepository } from 'src/repositories/job.repository'; +import { LibraryRepository } from 'src/repositories/library.repository'; import { LoggingRepository } from 'src/repositories/logging.repository'; import { MapRepository } from 'src/repositories/map.repository'; import { MediaRepository } from 'src/repositories/media.repository'; import { MemoryRepository } from 'src/repositories/memory.repository'; import { MetadataRepository } from 'src/repositories/metadata.repository'; +import { MoveRepository } from 'src/repositories/move.repository'; import { NotificationRepository } from 'src/repositories/notification.repository'; import { OAuthRepository } from 'src/repositories/oauth.repository'; +import { PartnerRepository } from 'src/repositories/partner.repository'; +import { PersonRepository } from 'src/repositories/person.repository'; import { ProcessRepository } from 'src/repositories/process.repository'; +import { SearchRepository } from 'src/repositories/search.repository'; import { ServerInfoRepository } from 'src/repositories/server-info.repository'; import { SessionRepository } from 'src/repositories/session.repository'; +import { SharedLinkRepository } from 'src/repositories/shared-link.repository'; +import { StackRepository } from 'src/repositories/stack.repository'; +import { StorageRepository } from 'src/repositories/storage.repository'; import { SystemMetadataRepository } from 'src/repositories/system-metadata.repository'; +import { TagRepository } from 'src/repositories/tag.repository'; import { TelemetryRepository } from 'src/repositories/telemetry.repository'; import { TrashRepository } from 'src/repositories/trash.repository'; import { VersionHistoryRepository } from 'src/repositories/version-history.repository'; import { ViewRepository } from 'src/repositories/view-repository'; import { BaseService } from 'src/services/base.service'; -import { - IAccessRepository, - IActivityRepository, - IAlbumUserRepository, - IApiKeyRepository, - IAuditRepository, - ICronRepository, - ILoggingRepository, - IMapRepository, - IMediaRepository, - IMemoryRepository, - IMetadataRepository, - INotificationRepository, - IOAuthRepository, - IProcessRepository, - IServerInfoRepository, - ISessionRepository, - ISystemMetadataRepository, - ITrashRepository, - IVersionHistoryRepository, - IViewRepository, -} from 'src/types'; -import { newAccessRepositoryMock } from 'test/repositories/access.repository.mock'; +import { RepositoryInterface } from 'src/types'; +import { IAccessRepositoryMock, newAccessRepositoryMock } from 'test/repositories/access.repository.mock'; import { newActivityRepositoryMock } from 'test/repositories/activity.repository.mock'; import { newAlbumUserRepositoryMock } from 'test/repositories/album-user.repository.mock'; import { newAlbumRepositoryMock } from 'test/repositories/album.repository.mock'; @@ -60,7 +58,7 @@ import { newDatabaseRepositoryMock } from 'test/repositories/database.repository import { newEventRepositoryMock } from 'test/repositories/event.repository.mock'; import { newJobRepositoryMock } from 'test/repositories/job.repository.mock'; import { newLibraryRepositoryMock } from 'test/repositories/library.repository.mock'; -import { newLoggingRepositoryMock } from 'test/repositories/logger.repository.mock'; +import { ILoggingRepository, newLoggingRepositoryMock } from 'test/repositories/logger.repository.mock'; import { newMachineLearningRepositoryMock } from 'test/repositories/machine-learning.repository.mock'; import { newMapRepositoryMock } from 'test/repositories/map.repository.mock'; import { newMediaRepositoryMock } from 'test/repositories/media.repository.mock'; @@ -80,7 +78,7 @@ import { newStackRepositoryMock } from 'test/repositories/stack.repository.mock' import { newStorageRepositoryMock } from 'test/repositories/storage.repository.mock'; import { newSystemMetadataRepositoryMock } from 'test/repositories/system-metadata.repository.mock'; import { newTagRepositoryMock } from 'test/repositories/tag.repository.mock'; -import { newTelemetryRepositoryMock } from 'test/repositories/telemetry.repository.mock'; +import { ITelemetryRepositoryMock, newTelemetryRepositoryMock } from 'test/repositories/telemetry.repository.mock'; import { newTrashRepositoryMock } from 'test/repositories/trash.repository.mock'; import { newUserRepositoryMock } from 'test/repositories/user.repository.mock'; import { newVersionHistoryRepositoryMock } from 'test/repositories/version-history.repository.mock'; @@ -97,6 +95,50 @@ type Constructor> = { new (...deps: Args): Type; }; +type IAccessRepository = { [K in keyof AccessRepository]: RepositoryInterface }; + +export type ServiceMocks = { + access: IAccessRepositoryMock; + activity: Mocked>; + album: Mocked; + albumUser: Mocked>; + apiKey: Mocked>; + audit: Mocked>; + asset: Mocked; + config: Mocked>; + cron: Mocked>; + crypto: Mocked; + database: Mocked>; + event: Mocked; + job: Mocked>; + library: Mocked>; + logger: Mocked; + machineLearning: Mocked; + map: Mocked>; + media: Mocked>; + memory: Mocked>; + metadata: Mocked>; + move: Mocked>; + notification: Mocked>; + oauth: Mocked>; + partner: Mocked>; + person: Mocked>; + process: Mocked>; + search: Mocked>; + serverInfo: Mocked>; + session: Mocked>; + sharedLink: Mocked>; + stack: Mocked>; + storage: Mocked>; + systemMetadata: Mocked>; + tag: Mocked>; + telemetry: ITelemetryRepositoryMock; + trash: Mocked>; + user: Mocked; + versionHistory: Mocked>; + view: Mocked>; +}; + export const newTestService = ( Service: Constructor, overrides?: Overrides, @@ -116,13 +158,15 @@ export const newTestService = ( const databaseMock = newDatabaseRepositoryMock(); const eventMock = newEventRepositoryMock(); const jobMock = newJobRepositoryMock(); - const keyMock = newKeyRepositoryMock(); + const apiKeyMock = newKeyRepositoryMock(); const libraryMock = newLibraryRepositoryMock(); const machineLearningMock = newMachineLearningRepositoryMock(); const mapMock = newMapRepositoryMock(); const mediaMock = newMediaRepositoryMock(); const memoryMock = newMemoryRepositoryMock(); - const metadataMock = (metadataRepository || newMetadataRepositoryMock()) as Mocked; + const metadataMock = (metadataRepository || newMetadataRepositoryMock()) as Mocked< + RepositoryInterface + >; const moveMock = newMoveRepositoryMock(); const notificationMock = newNotificationRepositoryMock(); const oauthMock = newOAuthRepositoryMock(); @@ -146,86 +190,88 @@ export const newTestService = ( const sut = new Service( loggerMock as ILoggingRepository as LoggingRepository, accessMock as IAccessRepository as AccessRepository, - activityMock as IActivityRepository as ActivityRepository, - auditMock as IAuditRepository as AuditRepository, + activityMock as RepositoryInterface as ActivityRepository, + auditMock as RepositoryInterface as AuditRepository, albumMock, - albumUserMock as IAlbumUserRepository as AlbumUserRepository, + albumUserMock as RepositoryInterface as AlbumUserRepository, assetMock, configMock, - cronMock as ICronRepository as CronRepository, - cryptoMock, + cronMock as RepositoryInterface as CronRepository, + cryptoMock as RepositoryInterface as CryptoRepository, databaseMock, eventMock, jobMock, - keyMock as IApiKeyRepository as ApiKeyRepository, + apiKeyMock as RepositoryInterface as ApiKeyRepository, libraryMock, machineLearningMock, - mapMock as IMapRepository as MapRepository, - mediaMock as IMediaRepository as MediaRepository, - memoryMock as IMemoryRepository as MemoryRepository, - metadataMock as IMetadataRepository as MetadataRepository, + mapMock as RepositoryInterface as MapRepository, + mediaMock as RepositoryInterface as MediaRepository, + memoryMock as RepositoryInterface as MemoryRepository, + metadataMock as RepositoryInterface as MetadataRepository, moveMock, - notificationMock as INotificationRepository as NotificationRepository, - oauthMock as IOAuthRepository as OAuthRepository, + notificationMock as RepositoryInterface as NotificationRepository, + oauthMock as RepositoryInterface as OAuthRepository, partnerMock, personMock, - processMock as IProcessRepository as ProcessRepository, + processMock as RepositoryInterface as ProcessRepository, searchMock, - serverInfoMock as IServerInfoRepository as ServerInfoRepository, - sessionMock as ISessionRepository as SessionRepository, + serverInfoMock as RepositoryInterface as ServerInfoRepository, + sessionMock as RepositoryInterface as SessionRepository, sharedLinkMock, stackMock, storageMock, - systemMock as ISystemMetadataRepository as SystemMetadataRepository, + systemMock as RepositoryInterface as SystemMetadataRepository, tagMock, telemetryMock as unknown as TelemetryRepository, - trashMock as ITrashRepository as TrashRepository, + trashMock as RepositoryInterface as TrashRepository, userMock, - versionHistoryMock as IVersionHistoryRepository as VersionHistoryRepository, - viewMock as IViewRepository as ViewRepository, + versionHistoryMock as RepositoryInterface as VersionHistoryRepository, + viewMock as RepositoryInterface as ViewRepository, ); return { sut, - accessMock, - loggerMock, - cronMock, - cryptoMock, - activityMock, - auditMock, - albumMock, - albumUserMock, - assetMock, - configMock, - databaseMock, - eventMock, - jobMock, - keyMock, - libraryMock, - machineLearningMock, - mapMock, - mediaMock, - memoryMock, - metadataMock, - moveMock, - notificationMock, - oauthMock, - partnerMock, - personMock, - processMock, - searchMock, - serverInfoMock, - sessionMock, - sharedLinkMock, - stackMock, - storageMock, - systemMock, - tagMock, - telemetryMock, - trashMock, - userMock, - versionHistoryMock, - viewMock, + mocks: { + access: accessMock, + apiKey: apiKeyMock, + cron: cronMock, + crypto: cryptoMock, + activity: activityMock, + audit: auditMock, + album: albumMock, + albumUser: albumUserMock, + asset: assetMock, + config: configMock, + database: databaseMock, + event: eventMock, + job: jobMock, + library: libraryMock, + logger: loggerMock, + machineLearning: machineLearningMock, + map: mapMock, + media: mediaMock, + memory: memoryMock, + metadata: metadataMock, + move: moveMock, + notification: notificationMock, + oauth: oauthMock, + partner: partnerMock, + person: personMock, + process: processMock, + search: searchMock, + serverInfo: serverInfoMock, + session: sessionMock, + sharedLink: sharedLinkMock, + stack: stackMock, + storage: storageMock, + systemMetadata: systemMock, + tag: tagMock, + telemetry: telemetryMock, + trash: trashMock, + user: userMock, + versionHistory: versionHistoryMock, + view: viewMock, + } as ServiceMocks, }; }; From b40963ec52f5b80693d66d4215d0a9b83e591fd0 Mon Sep 17 00:00:00 2001 From: Snowknight26 Date: Mon, 10 Feb 2025 18:00:37 -0600 Subject: [PATCH 115/395] fix(web): Update shared link Exif capitalization to match existing capitalization (#16010) Update shared link Exif capitalization to match existing capitalization --- .../album-page/album-shared-link.svelte | 37 ++++++++++--------- 1 file changed, 19 insertions(+), 18 deletions(-) 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 55c08c4d12..2b02eb8e07 100644 --- a/web/src/lib/components/album-page/album-shared-link.svelte +++ b/web/src/lib/components/album-page/album-shared-link.svelte @@ -12,29 +12,30 @@ }; const { album, sharedLink }: Props = $props(); + + const getShareProperties = () => + [ + DateTime.fromISO(sharedLink.createdAt).toLocaleString( + { + month: 'long', + day: 'numeric', + year: 'numeric', + }, + { locale: $locale }, + ), + sharedLink.allowUpload && $t('upload'), + sharedLink.allowDownload && $t('download'), + sharedLink.showMetadata && $t('exif').toUpperCase(), + sharedLink.password && $t('password'), + ] + .filter(Boolean) + .join(' • ');
{sharedLink.description || album.albumName} - {[ - DateTime.fromISO(sharedLink.createdAt).toLocaleString( - { - month: 'long', - day: 'numeric', - year: 'numeric', - }, - { locale: $locale }, - ), - sharedLink.allowUpload && $t('upload'), - sharedLink.allowDownload && $t('download'), - sharedLink.showMetadata && $t('exif'), - sharedLink.password && $t('password'), - ] - .filter(Boolean) - .join(' • ')} + {getShareProperties()}
From 2271984dbde41fc0025c61fd9fd921b4e448d406 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 11 Feb 2025 00:19:02 +0000 Subject: [PATCH 116/395] chore(deps): update dependency @types/node to ^22.13.1 (#16013) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- cli/package-lock.json | 10 +++++----- cli/package.json | 2 +- e2e/package-lock.json | 12 ++++++------ e2e/package.json | 2 +- open-api/typescript-sdk/package-lock.json | 8 ++++---- open-api/typescript-sdk/package.json | 2 +- server/package-lock.json | 8 ++++---- server/package.json | 2 +- 8 files changed, 23 insertions(+), 23 deletions(-) diff --git a/cli/package-lock.json b/cli/package-lock.json index 04c32fed82..9c3067bacf 100644 --- a/cli/package-lock.json +++ b/cli/package-lock.json @@ -24,7 +24,7 @@ "@types/cli-progress": "^3.11.0", "@types/lodash-es": "^4.17.12", "@types/mock-fs": "^4.13.1", - "@types/node": "^22.10.9", + "@types/node": "^22.13.1", "@typescript-eslint/eslint-plugin": "^8.15.0", "@typescript-eslint/parser": "^8.15.0", "@vitest/coverage-v8": "^3.0.0", @@ -59,7 +59,7 @@ "@oazapfts/runtime": "^1.0.2" }, "devDependencies": { - "@types/node": "^22.10.9", + "@types/node": "^22.13.1", "typescript": "^5.3.3" } }, @@ -1482,9 +1482,9 @@ } }, "node_modules/@types/node": { - "version": "22.10.9", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.10.9.tgz", - "integrity": "sha512-Ir6hwgsKyNESl/gLOcEz3krR4CBGgliDqBQ2ma4wIhEx0w+xnoeTq3tdrNw15kU3SxogDjOgv9sqdtLW8mIHaw==", + "version": "22.13.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.13.1.tgz", + "integrity": "sha512-jK8uzQlrvXqEU91UxiK5J7pKHyzgnI1Qnl0QDHIgVGuolJhRb9EEl28Cj9b3rGR8B2lhFCtvIm5os8lFnO/1Ew==", "dev": true, "license": "MIT", "dependencies": { diff --git a/cli/package.json b/cli/package.json index c9780ed993..c2d9b0a7ce 100644 --- a/cli/package.json +++ b/cli/package.json @@ -20,7 +20,7 @@ "@types/cli-progress": "^3.11.0", "@types/lodash-es": "^4.17.12", "@types/mock-fs": "^4.13.1", - "@types/node": "^22.10.9", + "@types/node": "^22.13.1", "@typescript-eslint/eslint-plugin": "^8.15.0", "@typescript-eslint/parser": "^8.15.0", "@vitest/coverage-v8": "^3.0.0", diff --git a/e2e/package-lock.json b/e2e/package-lock.json index c77852b8ac..b077f716bd 100644 --- a/e2e/package-lock.json +++ b/e2e/package-lock.json @@ -15,7 +15,7 @@ "@immich/sdk": "file:../open-api/typescript-sdk", "@playwright/test": "^1.44.1", "@types/luxon": "^3.4.2", - "@types/node": "^22.10.9", + "@types/node": "^22.13.1", "@types/oidc-provider": "^8.5.1", "@types/pg": "^8.11.0", "@types/pngjs": "^6.0.4", @@ -64,7 +64,7 @@ "@types/cli-progress": "^3.11.0", "@types/lodash-es": "^4.17.12", "@types/mock-fs": "^4.13.1", - "@types/node": "^22.10.9", + "@types/node": "^22.13.1", "@typescript-eslint/eslint-plugin": "^8.15.0", "@typescript-eslint/parser": "^8.15.0", "@vitest/coverage-v8": "^3.0.0", @@ -99,7 +99,7 @@ "@oazapfts/runtime": "^1.0.2" }, "devDependencies": { - "@types/node": "^22.10.9", + "@types/node": "^22.13.1", "typescript": "^5.3.3" } }, @@ -1666,9 +1666,9 @@ "dev": true }, "node_modules/@types/node": { - "version": "22.10.9", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.10.9.tgz", - "integrity": "sha512-Ir6hwgsKyNESl/gLOcEz3krR4CBGgliDqBQ2ma4wIhEx0w+xnoeTq3tdrNw15kU3SxogDjOgv9sqdtLW8mIHaw==", + "version": "22.13.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.13.1.tgz", + "integrity": "sha512-jK8uzQlrvXqEU91UxiK5J7pKHyzgnI1Qnl0QDHIgVGuolJhRb9EEl28Cj9b3rGR8B2lhFCtvIm5os8lFnO/1Ew==", "dev": true, "license": "MIT", "dependencies": { diff --git a/e2e/package.json b/e2e/package.json index 2947cda348..61f69c5c37 100644 --- a/e2e/package.json +++ b/e2e/package.json @@ -25,7 +25,7 @@ "@immich/sdk": "file:../open-api/typescript-sdk", "@playwright/test": "^1.44.1", "@types/luxon": "^3.4.2", - "@types/node": "^22.10.9", + "@types/node": "^22.13.1", "@types/oidc-provider": "^8.5.1", "@types/pg": "^8.11.0", "@types/pngjs": "^6.0.4", diff --git a/open-api/typescript-sdk/package-lock.json b/open-api/typescript-sdk/package-lock.json index eec9263746..ddfcdda289 100644 --- a/open-api/typescript-sdk/package-lock.json +++ b/open-api/typescript-sdk/package-lock.json @@ -12,7 +12,7 @@ "@oazapfts/runtime": "^1.0.2" }, "devDependencies": { - "@types/node": "^22.10.9", + "@types/node": "^22.13.1", "typescript": "^5.3.3" } }, @@ -22,9 +22,9 @@ "integrity": "sha512-8tKiYffhwTGHSHYGnZ3oneLGCjX0po/XAXQ5Ng9fqKkvIdl/xz8+Vh8i+6xjzZqvZ2pLVpUcuSfnvNI/x67L0g==" }, "node_modules/@types/node": { - "version": "22.10.9", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.10.9.tgz", - "integrity": "sha512-Ir6hwgsKyNESl/gLOcEz3krR4CBGgliDqBQ2ma4wIhEx0w+xnoeTq3tdrNw15kU3SxogDjOgv9sqdtLW8mIHaw==", + "version": "22.13.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.13.1.tgz", + "integrity": "sha512-jK8uzQlrvXqEU91UxiK5J7pKHyzgnI1Qnl0QDHIgVGuolJhRb9EEl28Cj9b3rGR8B2lhFCtvIm5os8lFnO/1Ew==", "dev": true, "license": "MIT", "dependencies": { diff --git a/open-api/typescript-sdk/package.json b/open-api/typescript-sdk/package.json index 170347c8fd..1960ecb023 100644 --- a/open-api/typescript-sdk/package.json +++ b/open-api/typescript-sdk/package.json @@ -19,7 +19,7 @@ "@oazapfts/runtime": "^1.0.2" }, "devDependencies": { - "@types/node": "^22.10.9", + "@types/node": "^22.13.1", "typescript": "^5.3.3" }, "repository": { diff --git a/server/package-lock.json b/server/package-lock.json index d79bf1a5b6..a917ba7f7c 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -86,7 +86,7 @@ "@types/lodash": "^4.14.197", "@types/mock-fs": "^4.13.1", "@types/multer": "^1.4.7", - "@types/node": "^22.10.9", + "@types/node": "^22.13.1", "@types/nodemailer": "^6.4.14", "@types/picomatch": "^3.0.0", "@types/pngjs": "^6.0.5", @@ -5842,9 +5842,9 @@ } }, "node_modules/@types/node": { - "version": "22.10.9", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.10.9.tgz", - "integrity": "sha512-Ir6hwgsKyNESl/gLOcEz3krR4CBGgliDqBQ2ma4wIhEx0w+xnoeTq3tdrNw15kU3SxogDjOgv9sqdtLW8mIHaw==", + "version": "22.13.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.13.1.tgz", + "integrity": "sha512-jK8uzQlrvXqEU91UxiK5J7pKHyzgnI1Qnl0QDHIgVGuolJhRb9EEl28Cj9b3rGR8B2lhFCtvIm5os8lFnO/1Ew==", "license": "MIT", "dependencies": { "undici-types": "~6.20.0" diff --git a/server/package.json b/server/package.json index ed8aa335c5..6bd84f2a75 100644 --- a/server/package.json +++ b/server/package.json @@ -112,7 +112,7 @@ "@types/lodash": "^4.14.197", "@types/mock-fs": "^4.13.1", "@types/multer": "^1.4.7", - "@types/node": "^22.10.9", + "@types/node": "^22.13.1", "@types/nodemailer": "^6.4.14", "@types/picomatch": "^3.0.0", "@types/pngjs": "^6.0.5", From bf1f8da884f80571ef9b509c318ad284cbf4fc93 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 11 Feb 2025 14:16:10 +0100 Subject: [PATCH 117/395] chore(deps): update docker/build-push-action action to v6.13.0 (#16022) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- .github/workflows/cli.yml | 2 +- .github/workflows/docker.yml | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/cli.yml b/.github/workflows/cli.yml index e7effc8551..b35c9f9d9b 100644 --- a/.github/workflows/cli.yml +++ b/.github/workflows/cli.yml @@ -88,7 +88,7 @@ jobs: type=raw,value=latest,enable=${{ github.event_name == 'release' }} - name: Build and push image - uses: docker/build-push-action@v6.12.0 + uses: docker/build-push-action@v6.13.0 with: file: cli/Dockerfile platforms: linux/amd64,linux/arm64 diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 3c10c3a143..48169fb859 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -174,7 +174,7 @@ jobs: fi - name: Build and push image - uses: docker/build-push-action@v6.12.0 + uses: docker/build-push-action@v6.13.0 with: context: ${{ env.context }} file: ${{ env.file }} @@ -265,7 +265,7 @@ jobs: fi - name: Build and push image - uses: docker/build-push-action@v6.12.0 + uses: docker/build-push-action@v6.13.0 with: context: ${{ env.context }} file: ${{ env.file }} From 17a63e37b29d9824ca82edcaf5b8f91bc9c2a738 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 11 Feb 2025 11:21:25 -0600 Subject: [PATCH 118/395] chore(deps): update base-image to v20250211 (major) (#16025) chore(deps): update base-image to v20250211 Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- server/Dockerfile | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/server/Dockerfile b/server/Dockerfile index 0de8c96e22..e3bea5170f 100644 --- a/server/Dockerfile +++ b/server/Dockerfile @@ -1,5 +1,5 @@ # dev build -FROM ghcr.io/immich-app/base-server-dev:20250204@sha256:8b203f19f4d5cf4619b60ee5f50d6d4b5ea3745747f5e5170d1b7404ebeb0792 AS dev +FROM ghcr.io/immich-app/base-server-dev:20250211@sha256:6ae577a6518e1ccca973db16955f4d79b01cac3ae122759ccb1c17bf6c330ba9 AS dev RUN apt-get install --no-install-recommends -yqq tini WORKDIR /usr/src/app @@ -42,7 +42,7 @@ RUN npm run build # prod build -FROM ghcr.io/immich-app/base-server-prod:20250204@sha256:2af3da713d5ab3ccca23b216b747557ea6016117e72deac101e35069ccaf9b5e +FROM ghcr.io/immich-app/base-server-prod:20250211@sha256:879cfe3d2afd4b7bdb211f694f99b6c0679a8bbd3e96964ad6c878f8471d63ea WORKDIR /usr/src/app ENV NODE_ENV=production \ From 1a190c33a037da98adf28b4e0aa0536f021b459e Mon Sep 17 00:00:00 2001 From: Alex Date: Tue, 11 Feb 2025 11:23:02 -0600 Subject: [PATCH 119/395] chore(mobile): post release task (#16004) --- mobile/ios/Runner.xcodeproj/project.pbxproj | 12 ++++++------ mobile/ios/Runner/Info.plist | 4 ++-- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/mobile/ios/Runner.xcodeproj/project.pbxproj b/mobile/ios/Runner.xcodeproj/project.pbxproj index 9aad116ce6..ab0a629ad4 100644 --- a/mobile/ios/Runner.xcodeproj/project.pbxproj +++ b/mobile/ios/Runner.xcodeproj/project.pbxproj @@ -541,7 +541,7 @@ CODE_SIGN_ENTITLEMENTS = Runner/RunnerProfile.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 193; + CURRENT_PROJECT_VERSION = 194; CUSTOM_GROUP_ID = group.app.immich.share; DEVELOPMENT_TEAM = 2F67MQ8R79; ENABLE_BITCODE = NO; @@ -685,7 +685,7 @@ CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 193; + CURRENT_PROJECT_VERSION = 194; CUSTOM_GROUP_ID = group.app.immich.share; DEVELOPMENT_TEAM = 2F67MQ8R79; ENABLE_BITCODE = NO; @@ -715,7 +715,7 @@ CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 193; + CURRENT_PROJECT_VERSION = 194; CUSTOM_GROUP_ID = group.app.immich.share; DEVELOPMENT_TEAM = 2F67MQ8R79; ENABLE_BITCODE = NO; @@ -748,7 +748,7 @@ CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 193; + CURRENT_PROJECT_VERSION = 194; CUSTOM_GROUP_ID = group.app.immich.share; DEVELOPMENT_TEAM = 2F67MQ8R79; ENABLE_USER_SCRIPT_SANDBOXING = YES; @@ -791,7 +791,7 @@ CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 193; + CURRENT_PROJECT_VERSION = 194; CUSTOM_GROUP_ID = group.app.immich.share; DEVELOPMENT_TEAM = 2F67MQ8R79; ENABLE_USER_SCRIPT_SANDBOXING = YES; @@ -831,7 +831,7 @@ CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 193; + CURRENT_PROJECT_VERSION = 194; CUSTOM_GROUP_ID = group.app.immich.share; DEVELOPMENT_TEAM = 2F67MQ8R79; ENABLE_USER_SCRIPT_SANDBOXING = YES; diff --git a/mobile/ios/Runner/Info.plist b/mobile/ios/Runner/Info.plist index 5ae45ac401..3051768e53 100644 --- a/mobile/ios/Runner/Info.plist +++ b/mobile/ios/Runner/Info.plist @@ -78,7 +78,7 @@ CFBundlePackageType APPL CFBundleShortVersionString - 1.126.0 + 1.126.1 CFBundleSignature ???? CFBundleURLTypes @@ -93,7 +93,7 @@ CFBundleVersion - 193 + 194 FLTEnableImpeller ITSAppUsesNonExemptEncryption From a3766b879e142c3303c9acfe2f3e612b6f4a7d5a Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 11 Feb 2025 11:23:54 -0600 Subject: [PATCH 120/395] fix(deps): update machine-learning (#16012) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- machine-learning/Dockerfile | 4 +- machine-learning/poetry.lock | 314 +++++++++++++++++------------------ 2 files changed, 158 insertions(+), 160 deletions(-) diff --git a/machine-learning/Dockerfile b/machine-learning/Dockerfile index 7dc206ab55..d888731149 100644 --- a/machine-learning/Dockerfile +++ b/machine-learning/Dockerfile @@ -1,6 +1,6 @@ ARG DEVICE=cpu -FROM python:3.11-bookworm@sha256:adb581d8ed80edd03efd4dcad66db115b9ce8de8522b01720b9f3e6146f0884c AS builder-cpu +FROM python:3.11-bookworm@sha256:14b4620f59a90f163dfa6bd252b68743f9a41d494a9fde935f9d7669d98094bb AS builder-cpu FROM builder-cpu AS builder-openvino @@ -34,7 +34,7 @@ RUN python3 -m venv /opt/venv COPY poetry.lock pyproject.toml ./ RUN poetry install --sync --no-interaction --no-ansi --no-root --with ${DEVICE} --without dev -FROM python:3.11-slim-bookworm@sha256:6ed5bff4d7d377e2a27d9285553b8c21cfccc4f00881de1b24c9bc8d90016e82 AS prod-cpu +FROM python:3.11-slim-bookworm@sha256:42420f737ba91d509fc60d5ed65ed0492678a90c561e1fa08786ae8ba8b52eda AS prod-cpu FROM prod-cpu AS prod-openvino diff --git a/machine-learning/poetry.lock b/machine-learning/poetry.lock index 6287ff82c7..6aaf8c2972 100644 --- a/machine-learning/poetry.lock +++ b/machine-learning/poetry.lock @@ -758,23 +758,23 @@ test = ["pytest (>=6)"] [[package]] name = "fastapi" -version = "0.115.6" +version = "0.115.8" description = "FastAPI framework, high performance, easy to learn, fast to code, ready for production" optional = false python-versions = ">=3.8" files = [ - {file = "fastapi-0.115.6-py3-none-any.whl", hash = "sha256:e9240b29e36fa8f4bb7290316988e90c381e5092e0cbe84e7818cc3713bcf305"}, - {file = "fastapi-0.115.6.tar.gz", hash = "sha256:9ec46f7addc14ea472958a96aae5b5de65f39721a46aaf5705c480d9a8b76654"}, + {file = "fastapi-0.115.8-py3-none-any.whl", hash = "sha256:753a96dd7e036b34eeef8babdfcfe3f28ff79648f86551eb36bfc1b0bf4a8cbf"}, + {file = "fastapi-0.115.8.tar.gz", hash = "sha256:0ce9111231720190473e222cdf0f07f7206ad7e53ea02beb1d2dc36e2f0741e9"}, ] [package.dependencies] pydantic = ">=1.7.4,<1.8 || >1.8,<1.8.1 || >1.8.1,<2.0.0 || >2.0.0,<2.0.1 || >2.0.1,<2.1.0 || >2.1.0,<3.0.0" -starlette = ">=0.40.0,<0.42.0" +starlette = ">=0.40.0,<0.46.0" typing-extensions = ">=4.8.0" [package.extras] -all = ["email-validator (>=2.0.0)", "fastapi-cli[standard] (>=0.0.5)", "httpx (>=0.23.0)", "itsdangerous (>=1.1.0)", "jinja2 (>=2.11.2)", "orjson (>=3.2.1)", "pydantic-extra-types (>=2.0.0)", "pydantic-settings (>=2.0.0)", "python-multipart (>=0.0.7)", "pyyaml (>=5.3.1)", "ujson (>=4.0.1,!=4.0.2,!=4.1.0,!=4.2.0,!=4.3.0,!=5.0.0,!=5.1.0)", "uvicorn[standard] (>=0.12.0)"] -standard = ["email-validator (>=2.0.0)", "fastapi-cli[standard] (>=0.0.5)", "httpx (>=0.23.0)", "jinja2 (>=2.11.2)", "python-multipart (>=0.0.7)", "uvicorn[standard] (>=0.12.0)"] +all = ["email-validator (>=2.0.0)", "fastapi-cli[standard] (>=0.0.5)", "httpx (>=0.23.0)", "itsdangerous (>=1.1.0)", "jinja2 (>=3.1.5)", "orjson (>=3.2.1)", "pydantic-extra-types (>=2.0.0)", "pydantic-settings (>=2.0.0)", "python-multipart (>=0.0.18)", "pyyaml (>=5.3.1)", "ujson (>=4.0.1,!=4.0.2,!=4.1.0,!=4.2.0,!=4.3.0,!=5.0.0,!=5.1.0)", "uvicorn[standard] (>=0.12.0)"] +standard = ["email-validator (>=2.0.0)", "fastapi-cli[standard] (>=0.0.5)", "httpx (>=0.23.0)", "jinja2 (>=3.1.5)", "python-multipart (>=0.0.18)", "uvicorn[standard] (>=0.12.0)"] [[package]] name = "filelock" @@ -1331,13 +1331,13 @@ zstd = ["zstandard (>=0.18.0)"] [[package]] name = "huggingface-hub" -version = "0.27.1" +version = "0.28.1" description = "Client library to download and publish models, datasets and other repos on the huggingface.co hub" optional = false python-versions = ">=3.8.0" files = [ - {file = "huggingface_hub-0.27.1-py3-none-any.whl", hash = "sha256:1c5155ca7d60b60c2e2fc38cbb3ffb7f7c3adf48f824015b219af9061771daec"}, - {file = "huggingface_hub-0.27.1.tar.gz", hash = "sha256:c004463ca870283909d715d20f066ebd6968c2207dae9393fdffb3c1d4d8f98b"}, + {file = "huggingface_hub-0.28.1-py3-none-any.whl", hash = "sha256:aa6b9a3ffdae939b72c464dbb0d7f99f56e649b55c3d52406f49e0a5a620c0a7"}, + {file = "huggingface_hub-0.28.1.tar.gz", hash = "sha256:893471090c98e3b6efbdfdacafe4052b20b84d59866fb6f54c33d9af18c303ae"}, ] [package.dependencies] @@ -1350,13 +1350,13 @@ tqdm = ">=4.42.1" typing-extensions = ">=3.7.4.3" [package.extras] -all = ["InquirerPy (==0.3.4)", "Jinja2", "Pillow", "aiohttp", "fastapi", "gradio (>=4.0.0)", "jedi", "libcst (==1.4.0)", "mypy (==1.5.1)", "numpy", "pytest (>=8.1.1,<8.2.2)", "pytest-asyncio", "pytest-cov", "pytest-env", "pytest-mock", "pytest-rerunfailures", "pytest-vcr", "pytest-xdist", "ruff (>=0.5.0)", "soundfile", "types-PyYAML", "types-requests", "types-simplejson", "types-toml", "types-tqdm", "types-urllib3", "typing-extensions (>=4.8.0)", "urllib3 (<2.0)"] +all = ["InquirerPy (==0.3.4)", "Jinja2", "Pillow", "aiohttp", "fastapi", "gradio (>=4.0.0)", "jedi", "libcst (==1.4.0)", "mypy (==1.5.1)", "numpy", "pytest (>=8.1.1,<8.2.2)", "pytest-asyncio", "pytest-cov", "pytest-env", "pytest-mock", "pytest-rerunfailures", "pytest-vcr", "pytest-xdist", "ruff (>=0.9.0)", "soundfile", "types-PyYAML", "types-requests", "types-simplejson", "types-toml", "types-tqdm", "types-urllib3", "typing-extensions (>=4.8.0)", "urllib3 (<2.0)"] cli = ["InquirerPy (==0.3.4)"] -dev = ["InquirerPy (==0.3.4)", "Jinja2", "Pillow", "aiohttp", "fastapi", "gradio (>=4.0.0)", "jedi", "libcst (==1.4.0)", "mypy (==1.5.1)", "numpy", "pytest (>=8.1.1,<8.2.2)", "pytest-asyncio", "pytest-cov", "pytest-env", "pytest-mock", "pytest-rerunfailures", "pytest-vcr", "pytest-xdist", "ruff (>=0.5.0)", "soundfile", "types-PyYAML", "types-requests", "types-simplejson", "types-toml", "types-tqdm", "types-urllib3", "typing-extensions (>=4.8.0)", "urllib3 (<2.0)"] +dev = ["InquirerPy (==0.3.4)", "Jinja2", "Pillow", "aiohttp", "fastapi", "gradio (>=4.0.0)", "jedi", "libcst (==1.4.0)", "mypy (==1.5.1)", "numpy", "pytest (>=8.1.1,<8.2.2)", "pytest-asyncio", "pytest-cov", "pytest-env", "pytest-mock", "pytest-rerunfailures", "pytest-vcr", "pytest-xdist", "ruff (>=0.9.0)", "soundfile", "types-PyYAML", "types-requests", "types-simplejson", "types-toml", "types-tqdm", "types-urllib3", "typing-extensions (>=4.8.0)", "urllib3 (<2.0)"] fastai = ["fastai (>=2.4)", "fastcore (>=1.3.27)", "toml"] hf-transfer = ["hf-transfer (>=0.1.4)"] inference = ["aiohttp"] -quality = ["libcst (==1.4.0)", "mypy (==1.5.1)", "ruff (>=0.5.0)"] +quality = ["libcst (==1.4.0)", "mypy (==1.5.1)", "ruff (>=0.9.0)"] tensorflow = ["graphviz", "pydot", "tensorflow"] tensorflow-testing = ["keras (<3.0)", "tensorflow"] testing = ["InquirerPy (==0.3.4)", "Jinja2", "Pillow", "aiohttp", "fastapi", "gradio (>=4.0.0)", "jedi", "numpy", "pytest (>=8.1.1,<8.2.2)", "pytest-asyncio", "pytest-cov", "pytest-env", "pytest-mock", "pytest-rerunfailures", "pytest-vcr", "pytest-xdist", "soundfile", "urllib3 (<2.0)"] @@ -1625,13 +1625,13 @@ test = ["pytest (>=7.4)", "pytest-cov (>=4.1)"] [[package]] name = "locust" -version = "2.32.6" +version = "2.32.9" description = "Developer-friendly load testing framework" optional = false python-versions = ">=3.9" files = [ - {file = "locust-2.32.6-py3-none-any.whl", hash = "sha256:d5c0e4f73134415d250087034431cf3ea42ca695d3dee7f10812287cacb6c4ef"}, - {file = "locust-2.32.6.tar.gz", hash = "sha256:6600cc308398e724764aacc56ccddf6cfcd0127c4c92dedd5c4979dd37ef5b15"}, + {file = "locust-2.32.9-py3-none-any.whl", hash = "sha256:d9447c26d2bbaec5a0ace7cadefa1a31820ed392234257b309965a43d5e8d26f"}, + {file = "locust-2.32.9.tar.gz", hash = "sha256:4c297afa5cdc3de15dfa79279576e5f33c1d69dd70006b51d079dcbd212201cc"}, ] [package.dependencies] @@ -1649,8 +1649,8 @@ psutil = ">=5.9.1" pywin32 = {version = "*", markers = "sys_platform == \"win32\""} pyzmq = ">=25.0.0" requests = [ - {version = ">=2.32.2", markers = "python_full_version > \"3.11.0\""}, {version = ">=2.26.0", markers = "python_full_version <= \"3.11.0\""}, + {version = ">=2.32.2", markers = "python_full_version > \"3.11.0\""}, ] setuptools = ">=70.0.0" tomli = {version = ">=1.1.0", markers = "python_version < \"3.11\""} @@ -1893,49 +1893,43 @@ files = [ [[package]] name = "mypy" -version = "1.14.1" +version = "1.15.0" description = "Optional static typing for Python" optional = false -python-versions = ">=3.8" +python-versions = ">=3.9" files = [ - {file = "mypy-1.14.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:52686e37cf13d559f668aa398dd7ddf1f92c5d613e4f8cb262be2fb4fedb0fcb"}, - {file = "mypy-1.14.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:1fb545ca340537d4b45d3eecdb3def05e913299ca72c290326be19b3804b39c0"}, - {file = "mypy-1.14.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:90716d8b2d1f4cd503309788e51366f07c56635a3309b0f6a32547eaaa36a64d"}, - {file = "mypy-1.14.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2ae753f5c9fef278bcf12e1a564351764f2a6da579d4a81347e1d5a15819997b"}, - {file = "mypy-1.14.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:e0fe0f5feaafcb04505bcf439e991c6d8f1bf8b15f12b05feeed96e9e7bf1427"}, - {file = "mypy-1.14.1-cp310-cp310-win_amd64.whl", hash = "sha256:7d54bd85b925e501c555a3227f3ec0cfc54ee8b6930bd6141ec872d1c572f81f"}, - {file = "mypy-1.14.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f995e511de847791c3b11ed90084a7a0aafdc074ab88c5a9711622fe4751138c"}, - {file = "mypy-1.14.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d64169ec3b8461311f8ce2fd2eb5d33e2d0f2c7b49116259c51d0d96edee48d1"}, - {file = "mypy-1.14.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ba24549de7b89b6381b91fbc068d798192b1b5201987070319889e93038967a8"}, - {file = "mypy-1.14.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:183cf0a45457d28ff9d758730cd0210419ac27d4d3f285beda038c9083363b1f"}, - {file = "mypy-1.14.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f2a0ecc86378f45347f586e4163d1769dd81c5a223d577fe351f26b179e148b1"}, - {file = "mypy-1.14.1-cp311-cp311-win_amd64.whl", hash = "sha256:ad3301ebebec9e8ee7135d8e3109ca76c23752bac1e717bc84cd3836b4bf3eae"}, - {file = "mypy-1.14.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:30ff5ef8519bbc2e18b3b54521ec319513a26f1bba19a7582e7b1f58a6e69f14"}, - {file = "mypy-1.14.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:cb9f255c18052343c70234907e2e532bc7e55a62565d64536dbc7706a20b78b9"}, - {file = "mypy-1.14.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8b4e3413e0bddea671012b063e27591b953d653209e7a4fa5e48759cda77ca11"}, - {file = "mypy-1.14.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:553c293b1fbdebb6c3c4030589dab9fafb6dfa768995a453d8a5d3b23784af2e"}, - {file = "mypy-1.14.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fad79bfe3b65fe6a1efaed97b445c3d37f7be9fdc348bdb2d7cac75579607c89"}, - {file = "mypy-1.14.1-cp312-cp312-win_amd64.whl", hash = "sha256:8fa2220e54d2946e94ab6dbb3ba0a992795bd68b16dc852db33028df2b00191b"}, - {file = "mypy-1.14.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:92c3ed5afb06c3a8e188cb5da4984cab9ec9a77ba956ee419c68a388b4595255"}, - {file = "mypy-1.14.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:dbec574648b3e25f43d23577309b16534431db4ddc09fda50841f1e34e64ed34"}, - {file = "mypy-1.14.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8c6d94b16d62eb3e947281aa7347d78236688e21081f11de976376cf010eb31a"}, - {file = "mypy-1.14.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d4b19b03fdf54f3c5b2fa474c56b4c13c9dbfb9a2db4370ede7ec11a2c5927d9"}, - {file = "mypy-1.14.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:0c911fde686394753fff899c409fd4e16e9b294c24bfd5e1ea4675deae1ac6fd"}, - {file = "mypy-1.14.1-cp313-cp313-win_amd64.whl", hash = "sha256:8b21525cb51671219f5307be85f7e646a153e5acc656e5cebf64bfa076c50107"}, - {file = "mypy-1.14.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:7084fb8f1128c76cd9cf68fe5971b37072598e7c31b2f9f95586b65c741a9d31"}, - {file = "mypy-1.14.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:8f845a00b4f420f693f870eaee5f3e2692fa84cc8514496114649cfa8fd5e2c6"}, - {file = "mypy-1.14.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:44bf464499f0e3a2d14d58b54674dee25c031703b2ffc35064bd0df2e0fac319"}, - {file = "mypy-1.14.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c99f27732c0b7dc847adb21c9d47ce57eb48fa33a17bc6d7d5c5e9f9e7ae5bac"}, - {file = "mypy-1.14.1-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:bce23c7377b43602baa0bd22ea3265c49b9ff0b76eb315d6c34721af4cdf1d9b"}, - {file = "mypy-1.14.1-cp38-cp38-win_amd64.whl", hash = "sha256:8edc07eeade7ebc771ff9cf6b211b9a7d93687ff892150cb5692e4f4272b0837"}, - {file = "mypy-1.14.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:3888a1816d69f7ab92092f785a462944b3ca16d7c470d564165fe703b0970c35"}, - {file = "mypy-1.14.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:46c756a444117c43ee984bd055db99e498bc613a70bbbc120272bd13ca579fbc"}, - {file = "mypy-1.14.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:27fc248022907e72abfd8e22ab1f10e903915ff69961174784a3900a8cba9ad9"}, - {file = "mypy-1.14.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:499d6a72fb7e5de92218db961f1a66d5f11783f9ae549d214617edab5d4dbdbb"}, - {file = "mypy-1.14.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:57961db9795eb566dc1d1b4e9139ebc4c6b0cb6e7254ecde69d1552bf7613f60"}, - {file = "mypy-1.14.1-cp39-cp39-win_amd64.whl", hash = "sha256:07ba89fdcc9451f2ebb02853deb6aaaa3d2239a236669a63ab3801bbf923ef5c"}, - {file = "mypy-1.14.1-py3-none-any.whl", hash = "sha256:b66a60cc4073aeb8ae00057f9c1f64d49e90f918fbcef9a977eb121da8b8f1d1"}, - {file = "mypy-1.14.1.tar.gz", hash = "sha256:7ec88144fe9b510e8475ec2f5f251992690fcf89ccb4500b214b4226abcd32d6"}, + {file = "mypy-1.15.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:979e4e1a006511dacf628e36fadfecbcc0160a8af6ca7dad2f5025529e082c13"}, + {file = "mypy-1.15.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c4bb0e1bd29f7d34efcccd71cf733580191e9a264a2202b0239da95984c5b559"}, + {file = "mypy-1.15.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:be68172e9fd9ad8fb876c6389f16d1c1b5f100ffa779f77b1fb2176fcc9ab95b"}, + {file = "mypy-1.15.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c7be1e46525adfa0d97681432ee9fcd61a3964c2446795714699a998d193f1a3"}, + {file = "mypy-1.15.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:2e2c2e6d3593f6451b18588848e66260ff62ccca522dd231cd4dd59b0160668b"}, + {file = "mypy-1.15.0-cp310-cp310-win_amd64.whl", hash = "sha256:6983aae8b2f653e098edb77f893f7b6aca69f6cffb19b2cc7443f23cce5f4828"}, + {file = "mypy-1.15.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:2922d42e16d6de288022e5ca321cd0618b238cfc5570e0263e5ba0a77dbef56f"}, + {file = "mypy-1.15.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2ee2d57e01a7c35de00f4634ba1bbf015185b219e4dc5909e281016df43f5ee5"}, + {file = "mypy-1.15.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:973500e0774b85d9689715feeffcc980193086551110fd678ebe1f4342fb7c5e"}, + {file = "mypy-1.15.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a95fb17c13e29d2d5195869262f8125dfdb5c134dc8d9a9d0aecf7525b10c2c"}, + {file = "mypy-1.15.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:1905f494bfd7d85a23a88c5d97840888a7bd516545fc5aaedff0267e0bb54e2f"}, + {file = "mypy-1.15.0-cp311-cp311-win_amd64.whl", hash = "sha256:c9817fa23833ff189db061e6d2eff49b2f3b6ed9856b4a0a73046e41932d744f"}, + {file = "mypy-1.15.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:aea39e0583d05124836ea645f412e88a5c7d0fd77a6d694b60d9b6b2d9f184fd"}, + {file = "mypy-1.15.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2f2147ab812b75e5b5499b01ade1f4a81489a147c01585cda36019102538615f"}, + {file = "mypy-1.15.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ce436f4c6d218a070048ed6a44c0bbb10cd2cc5e272b29e7845f6a2f57ee4464"}, + {file = "mypy-1.15.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8023ff13985661b50a5928fc7a5ca15f3d1affb41e5f0a9952cb68ef090b31ee"}, + {file = "mypy-1.15.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1124a18bc11a6a62887e3e137f37f53fbae476dc36c185d549d4f837a2a6a14e"}, + {file = "mypy-1.15.0-cp312-cp312-win_amd64.whl", hash = "sha256:171a9ca9a40cd1843abeca0e405bc1940cd9b305eaeea2dda769ba096932bb22"}, + {file = "mypy-1.15.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:93faf3fdb04768d44bf28693293f3904bbb555d076b781ad2530214ee53e3445"}, + {file = "mypy-1.15.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:811aeccadfb730024c5d3e326b2fbe9249bb7413553f15499a4050f7c30e801d"}, + {file = "mypy-1.15.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:98b7b9b9aedb65fe628c62a6dc57f6d5088ef2dfca37903a7d9ee374d03acca5"}, + {file = "mypy-1.15.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c43a7682e24b4f576d93072216bf56eeff70d9140241f9edec0c104d0c515036"}, + {file = "mypy-1.15.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:baefc32840a9f00babd83251560e0ae1573e2f9d1b067719479bfb0e987c6357"}, + {file = "mypy-1.15.0-cp313-cp313-win_amd64.whl", hash = "sha256:b9378e2c00146c44793c98b8d5a61039a048e31f429fb0eb546d93f4b000bedf"}, + {file = "mypy-1.15.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:e601a7fa172c2131bff456bb3ee08a88360760d0d2f8cbd7a75a65497e2df078"}, + {file = "mypy-1.15.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:712e962a6357634fef20412699a3655c610110e01cdaa6180acec7fc9f8513ba"}, + {file = "mypy-1.15.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f95579473af29ab73a10bada2f9722856792a36ec5af5399b653aa28360290a5"}, + {file = "mypy-1.15.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8f8722560a14cde92fdb1e31597760dc35f9f5524cce17836c0d22841830fd5b"}, + {file = "mypy-1.15.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:1fbb8da62dc352133d7d7ca90ed2fb0e9d42bb1a32724c287d3c76c58cbaa9c2"}, + {file = "mypy-1.15.0-cp39-cp39-win_amd64.whl", hash = "sha256:d10d994b41fb3497719bbf866f227b3489048ea4bbbb5015357db306249f7980"}, + {file = "mypy-1.15.0-py3-none-any.whl", hash = "sha256:5469affef548bd1895d86d3bf10ce2b44e33d86923c29e4d675b3e323437ea3e"}, + {file = "mypy-1.15.0.tar.gz", hash = "sha256:404534629d51d3efea5c800ee7c42b72a6554d6c400e6a79eafe15d11341fd43"}, ] [package.dependencies] @@ -2181,94 +2175,98 @@ files = [ [package.dependencies] numpy = [ - {version = ">=1.26.0", markers = "python_version >= \"3.12\""}, {version = ">=1.23.5", markers = "python_version >= \"3.11\" and python_version < \"3.12\""}, {version = ">=1.21.4", markers = "python_version >= \"3.10\" and platform_system == \"Darwin\" and python_version < \"3.11\""}, {version = ">=1.21.2", markers = "platform_system != \"Darwin\" and python_version >= \"3.10\" and python_version < \"3.11\""}, + {version = ">=1.26.0", markers = "python_version >= \"3.12\""}, ] [[package]] name = "orjson" -version = "3.10.14" +version = "3.10.15" description = "Fast, correct Python JSON library supporting dataclasses, datetimes, and numpy" optional = false python-versions = ">=3.8" files = [ - {file = "orjson-3.10.14-cp310-cp310-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:849ea7845a55f09965826e816cdc7689d6cf74fe9223d79d758c714af955bcb6"}, - {file = "orjson-3.10.14-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b5947b139dfa33f72eecc63f17e45230a97e741942955a6c9e650069305eb73d"}, - {file = "orjson-3.10.14-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:cde6d76910d3179dae70f164466692f4ea36da124d6fb1a61399ca589e81d69a"}, - {file = "orjson-3.10.14-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c6dfbaeb7afa77ca608a50e2770a0461177b63a99520d4928e27591b142c74b1"}, - {file = "orjson-3.10.14-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:fa45e489ef80f28ff0e5ba0a72812b8cfc7c1ef8b46a694723807d1b07c89ebb"}, - {file = "orjson-3.10.14-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4f5007abfdbb1d866e2aa8990bd1c465f0f6da71d19e695fc278282be12cffa5"}, - {file = "orjson-3.10.14-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1b49e2af011c84c3f2d541bb5cd1e3c7c2df672223e7e3ea608f09cf295e5f8a"}, - {file = "orjson-3.10.14-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:164ac155109226b3a2606ee6dda899ccfbe6e7e18b5bdc3fbc00f79cc074157d"}, - {file = "orjson-3.10.14-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:6b1225024cf0ef5d15934b5ffe9baf860fe8bc68a796513f5ea4f5056de30bca"}, - {file = "orjson-3.10.14-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:d6546e8073dc382e60fcae4a001a5a1bc46da5eab4a4878acc2d12072d6166d5"}, - {file = "orjson-3.10.14-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:9f1d2942605c894162252d6259b0121bf1cb493071a1ea8cb35d79cb3e6ac5bc"}, - {file = "orjson-3.10.14-cp310-cp310-win32.whl", hash = "sha256:397083806abd51cf2b3bbbf6c347575374d160331a2d33c5823e22249ad3118b"}, - {file = "orjson-3.10.14-cp310-cp310-win_amd64.whl", hash = "sha256:fa18f949d3183a8d468367056be989666ac2bef3a72eece0bade9cdb733b3c28"}, - {file = "orjson-3.10.14-cp311-cp311-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:f506fd666dd1ecd15a832bebc66c4df45c1902fd47526292836c339f7ba665a9"}, - {file = "orjson-3.10.14-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:efe5fd254cfb0eeee13b8ef7ecb20f5d5a56ddda8a587f3852ab2cedfefdb5f6"}, - {file = "orjson-3.10.14-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:4ddc8c866d7467f5ee2991397d2ea94bcf60d0048bdd8ca555740b56f9042725"}, - {file = "orjson-3.10.14-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3af8e42ae4363773658b8d578d56dedffb4f05ceeb4d1d4dd3fb504950b45526"}, - {file = "orjson-3.10.14-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:84dd83110503bc10e94322bf3ffab8bc49150176b49b4984dc1cce4c0a993bf9"}, - {file = "orjson-3.10.14-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:36f5bfc0399cd4811bf10ec7a759c7ab0cd18080956af8ee138097d5b5296a95"}, - {file = "orjson-3.10.14-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:868943660fb2a1e6b6b965b74430c16a79320b665b28dd4511d15ad5038d37d5"}, - {file = "orjson-3.10.14-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:33449c67195969b1a677533dee9d76e006001213a24501333624623e13c7cc8e"}, - {file = "orjson-3.10.14-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:e4c9f60f9fb0b5be66e416dcd8c9d94c3eabff3801d875bdb1f8ffc12cf86905"}, - {file = "orjson-3.10.14-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:0de4d6315cfdbd9ec803b945c23b3a68207fd47cbe43626036d97e8e9561a436"}, - {file = "orjson-3.10.14-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:83adda3db595cb1a7e2237029b3249c85afbe5c747d26b41b802e7482cb3933e"}, - {file = "orjson-3.10.14-cp311-cp311-win32.whl", hash = "sha256:998019ef74a4997a9d741b1473533cdb8faa31373afc9849b35129b4b8ec048d"}, - {file = "orjson-3.10.14-cp311-cp311-win_amd64.whl", hash = "sha256:9d034abdd36f0f0f2240f91492684e5043d46f290525d1117712d5b8137784eb"}, - {file = "orjson-3.10.14-cp312-cp312-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:2ad4b7e367efba6dc3f119c9a0fcd41908b7ec0399a696f3cdea7ec477441b09"}, - {file = "orjson-3.10.14-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f496286fc85e93ce0f71cc84fc1c42de2decf1bf494094e188e27a53694777a7"}, - {file = "orjson-3.10.14-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c7f189bbfcded40e41a6969c1068ba305850ba016665be71a217918931416fbf"}, - {file = "orjson-3.10.14-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8cc8204f0b75606869c707da331058ddf085de29558b516fc43c73ee5ee2aadb"}, - {file = "orjson-3.10.14-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:deaa2899dff7f03ab667e2ec25842d233e2a6a9e333efa484dfe666403f3501c"}, - {file = "orjson-3.10.14-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f1c3ea52642c9714dc6e56de8a451a066f6d2707d273e07fe8a9cc1ba073813d"}, - {file = "orjson-3.10.14-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:9d3f9ed72e7458ded9a1fb1b4d4ed4c4fdbaf82030ce3f9274b4dc1bff7ace2b"}, - {file = "orjson-3.10.14-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:07520685d408a2aba514c17ccc16199ff2934f9f9e28501e676c557f454a37fe"}, - {file = "orjson-3.10.14-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:76344269b550ea01488d19a2a369ab572c1ac4449a72e9f6ac0d70eb1cbfb953"}, - {file = "orjson-3.10.14-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:e2979d0f2959990620f7e62da6cd954e4620ee815539bc57a8ae46e2dacf90e3"}, - {file = "orjson-3.10.14-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:03f61ca3674555adcb1aa717b9fc87ae936aa7a63f6aba90a474a88701278780"}, - {file = "orjson-3.10.14-cp312-cp312-win32.whl", hash = "sha256:d5075c54edf1d6ad81d4c6523ce54a748ba1208b542e54b97d8a882ecd810fd1"}, - {file = "orjson-3.10.14-cp312-cp312-win_amd64.whl", hash = "sha256:175cafd322e458603e8ce73510a068d16b6e6f389c13f69bf16de0e843d7d406"}, - {file = "orjson-3.10.14-cp313-cp313-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:0905ca08a10f7e0e0c97d11359609300eb1437490a7f32bbaa349de757e2e0c7"}, - {file = "orjson-3.10.14-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:92d13292249f9f2a3e418cbc307a9fbbef043c65f4bd8ba1eb620bc2aaba3d15"}, - {file = "orjson-3.10.14-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:90937664e776ad316d64251e2fa2ad69265e4443067668e4727074fe39676414"}, - {file = "orjson-3.10.14-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:9ed3d26c4cb4f6babaf791aa46a029265850e80ec2a566581f5c2ee1a14df4f1"}, - {file = "orjson-3.10.14-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:56ee546c2bbe9599aba78169f99d1dc33301853e897dbaf642d654248280dc6e"}, - {file = "orjson-3.10.14-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:901e826cb2f1bdc1fcef3ef59adf0c451e8f7c0b5deb26c1a933fb66fb505eae"}, - {file = "orjson-3.10.14-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:26336c0d4b2d44636e1e1e6ed1002f03c6aae4a8a9329561c8883f135e9ff010"}, - {file = "orjson-3.10.14-cp313-cp313-win32.whl", hash = "sha256:e2bc525e335a8545c4e48f84dd0328bc46158c9aaeb8a1c2276546e94540ea3d"}, - {file = "orjson-3.10.14-cp313-cp313-win_amd64.whl", hash = "sha256:eca04dfd792cedad53dc9a917da1a522486255360cb4e77619343a20d9f35364"}, - {file = "orjson-3.10.14-cp38-cp38-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:9a0fba3b8a587a54c18585f077dcab6dd251c170d85cfa4d063d5746cd595a0f"}, - {file = "orjson-3.10.14-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:175abf3d20e737fec47261d278f95031736a49d7832a09ab684026528c4d96db"}, - {file = "orjson-3.10.14-cp38-cp38-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:29ca1a93e035d570e8b791b6c0feddd403c6a5388bfe870bf2aa6bba1b9d9b8e"}, - {file = "orjson-3.10.14-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f77202c80e8ab5a1d1e9faf642343bee5aaf332061e1ada4e9147dbd9eb00c46"}, - {file = "orjson-3.10.14-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6e2ec73b7099b6a29b40a62e08a23b936423bd35529f8f55c42e27acccde7954"}, - {file = "orjson-3.10.14-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a2d1679df9f9cd9504f8dff24555c1eaabba8aad7f5914f28dab99e3c2552c9d"}, - {file = "orjson-3.10.14-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:691ab9a13834310a263664313e4f747ceb93662d14a8bdf20eb97d27ed488f16"}, - {file = "orjson-3.10.14-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:b11ed82054fce82fb74cea33247d825d05ad6a4015ecfc02af5fbce442fbf361"}, - {file = "orjson-3.10.14-cp38-cp38-musllinux_1_2_armv7l.whl", hash = "sha256:e70a1d62b8288677d48f3bea66c21586a5f999c64ecd3878edb7393e8d1b548d"}, - {file = "orjson-3.10.14-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:16642f10c1ca5611251bd835de9914a4b03095e28a34c8ba6a5500b5074338bd"}, - {file = "orjson-3.10.14-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:3871bad546aa66c155e3f36f99c459780c2a392d502a64e23fb96d9abf338511"}, - {file = "orjson-3.10.14-cp38-cp38-win32.whl", hash = "sha256:0293a88815e9bb5c90af4045f81ed364d982f955d12052d989d844d6c4e50945"}, - {file = "orjson-3.10.14-cp38-cp38-win_amd64.whl", hash = "sha256:6169d3868b190d6b21adc8e61f64e3db30f50559dfbdef34a1cd6c738d409dfc"}, - {file = "orjson-3.10.14-cp39-cp39-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:06d4ec218b1ec1467d8d64da4e123b4794c781b536203c309ca0f52819a16c03"}, - {file = "orjson-3.10.14-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:962c2ec0dcaf22b76dee9831fdf0c4a33d4bf9a257a2bc5d4adc00d5c8ad9034"}, - {file = "orjson-3.10.14-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:21d3be4132f71ef1360385770474f29ea1538a242eef72ac4934fe142800e37f"}, - {file = "orjson-3.10.14-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c28ed60597c149a9e3f5ad6dd9cebaee6fb2f0e3f2d159a4a2b9b862d4748860"}, - {file = "orjson-3.10.14-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7e947f70167fe18469f2023644e91ab3d24f9aed69a5e1c78e2c81b9cea553fb"}, - {file = "orjson-3.10.14-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:64410696c97a35af2432dea7bdc4ce32416458159430ef1b4beb79fd30093ad6"}, - {file = "orjson-3.10.14-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:8050a5d81c022561ee29cd2739de5b4445f3c72f39423fde80a63299c1892c52"}, - {file = "orjson-3.10.14-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:b49a28e30d3eca86db3fe6f9b7f4152fcacbb4a467953cd1b42b94b479b77956"}, - {file = "orjson-3.10.14-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:ca041ad20291a65d853a9523744eebc3f5a4b2f7634e99f8fe88320695ddf766"}, - {file = "orjson-3.10.14-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:d313a2998b74bb26e9e371851a173a9b9474764916f1fc7971095699b3c6e964"}, - {file = "orjson-3.10.14-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:7796692136a67b3e301ef9052bde6fe8e7bd5200da766811a3a608ffa62aaff0"}, - {file = "orjson-3.10.14-cp39-cp39-win32.whl", hash = "sha256:eee4bc767f348fba485ed9dc576ca58b0a9eac237f0e160f7a59bce628ed06b3"}, - {file = "orjson-3.10.14-cp39-cp39-win_amd64.whl", hash = "sha256:96a1c0ee30fb113b3ae3c748fd75ca74a157ff4c58476c47db4d61518962a011"}, - {file = "orjson-3.10.14.tar.gz", hash = "sha256:cf31f6f071a6b8e7aa1ead1fa27b935b48d00fbfa6a28ce856cfff2d5dd68eed"}, + {file = "orjson-3.10.15-cp310-cp310-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:552c883d03ad185f720d0c09583ebde257e41b9521b74ff40e08b7dec4559c04"}, + {file = "orjson-3.10.15-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:616e3e8d438d02e4854f70bfdc03a6bcdb697358dbaa6bcd19cbe24d24ece1f8"}, + {file = "orjson-3.10.15-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7c2c79fa308e6edb0ffab0a31fd75a7841bf2a79a20ef08a3c6e3b26814c8ca8"}, + {file = "orjson-3.10.15-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:73cb85490aa6bf98abd20607ab5c8324c0acb48d6da7863a51be48505646c814"}, + {file = "orjson-3.10.15-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:763dadac05e4e9d2bc14938a45a2d0560549561287d41c465d3c58aec818b164"}, + {file = "orjson-3.10.15-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a330b9b4734f09a623f74a7490db713695e13b67c959713b78369f26b3dee6bf"}, + {file = "orjson-3.10.15-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a61a4622b7ff861f019974f73d8165be1bd9a0855e1cad18ee167acacabeb061"}, + {file = "orjson-3.10.15-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:acd271247691574416b3228db667b84775c497b245fa275c6ab90dc1ffbbd2b3"}, + {file = "orjson-3.10.15-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:e4759b109c37f635aa5c5cc93a1b26927bfde24b254bcc0e1149a9fada253d2d"}, + {file = "orjson-3.10.15-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:9e992fd5cfb8b9f00bfad2fd7a05a4299db2bbe92e6440d9dd2fab27655b3182"}, + {file = "orjson-3.10.15-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:f95fb363d79366af56c3f26b71df40b9a583b07bbaaf5b317407c4d58497852e"}, + {file = "orjson-3.10.15-cp310-cp310-win32.whl", hash = "sha256:f9875f5fea7492da8ec2444839dcc439b0ef298978f311103d0b7dfd775898ab"}, + {file = "orjson-3.10.15-cp310-cp310-win_amd64.whl", hash = "sha256:17085a6aa91e1cd70ca8533989a18b5433e15d29c574582f76f821737c8d5806"}, + {file = "orjson-3.10.15-cp311-cp311-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:c4cc83960ab79a4031f3119cc4b1a1c627a3dc09df125b27c4201dff2af7eaa6"}, + {file = "orjson-3.10.15-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ddbeef2481d895ab8be5185f2432c334d6dec1f5d1933a9c83014d188e102cef"}, + {file = "orjson-3.10.15-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9e590a0477b23ecd5b0ac865b1b907b01b3c5535f5e8a8f6ab0e503efb896334"}, + {file = "orjson-3.10.15-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a6be38bd103d2fd9bdfa31c2720b23b5d47c6796bcb1d1b598e3924441b4298d"}, + {file = "orjson-3.10.15-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ff4f6edb1578960ed628a3b998fa54d78d9bb3e2eb2cfc5c2a09732431c678d0"}, + {file = "orjson-3.10.15-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b0482b21d0462eddd67e7fce10b89e0b6ac56570424662b685a0d6fccf581e13"}, + {file = "orjson-3.10.15-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:bb5cc3527036ae3d98b65e37b7986a918955f85332c1ee07f9d3f82f3a6899b5"}, + {file = "orjson-3.10.15-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d569c1c462912acdd119ccbf719cf7102ea2c67dd03b99edcb1a3048651ac96b"}, + {file = "orjson-3.10.15-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:1e6d33efab6b71d67f22bf2962895d3dc6f82a6273a965fab762e64fa90dc399"}, + {file = "orjson-3.10.15-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:c33be3795e299f565681d69852ac8c1bc5c84863c0b0030b2b3468843be90388"}, + {file = "orjson-3.10.15-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:eea80037b9fae5339b214f59308ef0589fc06dc870578b7cce6d71eb2096764c"}, + {file = "orjson-3.10.15-cp311-cp311-win32.whl", hash = "sha256:d5ac11b659fd798228a7adba3e37c010e0152b78b1982897020a8e019a94882e"}, + {file = "orjson-3.10.15-cp311-cp311-win_amd64.whl", hash = "sha256:cf45e0214c593660339ef63e875f32ddd5aa3b4adc15e662cdb80dc49e194f8e"}, + {file = "orjson-3.10.15-cp312-cp312-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:9d11c0714fc85bfcf36ada1179400862da3288fc785c30e8297844c867d7505a"}, + {file = "orjson-3.10.15-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dba5a1e85d554e3897fa9fe6fbcff2ed32d55008973ec9a2b992bd9a65d2352d"}, + {file = "orjson-3.10.15-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7723ad949a0ea502df656948ddd8b392780a5beaa4c3b5f97e525191b102fff0"}, + {file = "orjson-3.10.15-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6fd9bc64421e9fe9bd88039e7ce8e58d4fead67ca88e3a4014b143cec7684fd4"}, + {file = "orjson-3.10.15-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dadba0e7b6594216c214ef7894c4bd5f08d7c0135f4dd0145600be4fbcc16767"}, + {file = "orjson-3.10.15-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b48f59114fe318f33bbaee8ebeda696d8ccc94c9e90bc27dbe72153094e26f41"}, + {file = "orjson-3.10.15-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:035fb83585e0f15e076759b6fedaf0abb460d1765b6a36f48018a52858443514"}, + {file = "orjson-3.10.15-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:d13b7fe322d75bf84464b075eafd8e7dd9eae05649aa2a5354cfa32f43c59f17"}, + {file = "orjson-3.10.15-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:7066b74f9f259849629e0d04db6609db4cf5b973248f455ba5d3bd58a4daaa5b"}, + {file = "orjson-3.10.15-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:88dc3f65a026bd3175eb157fea994fca6ac7c4c8579fc5a86fc2114ad05705b7"}, + {file = "orjson-3.10.15-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b342567e5465bd99faa559507fe45e33fc76b9fb868a63f1642c6bc0735ad02a"}, + {file = "orjson-3.10.15-cp312-cp312-win32.whl", hash = "sha256:0a4f27ea5617828e6b58922fdbec67b0aa4bb844e2d363b9244c47fa2180e665"}, + {file = "orjson-3.10.15-cp312-cp312-win_amd64.whl", hash = "sha256:ef5b87e7aa9545ddadd2309efe6824bd3dd64ac101c15dae0f2f597911d46eaa"}, + {file = "orjson-3.10.15-cp313-cp313-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:bae0e6ec2b7ba6895198cd981b7cca95d1487d0147c8ed751e5632ad16f031a6"}, + {file = "orjson-3.10.15-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f93ce145b2db1252dd86af37d4165b6faa83072b46e3995ecc95d4b2301b725a"}, + {file = "orjson-3.10.15-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7c203f6f969210128af3acae0ef9ea6aab9782939f45f6fe02d05958fe761ef9"}, + {file = "orjson-3.10.15-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8918719572d662e18b8af66aef699d8c21072e54b6c82a3f8f6404c1f5ccd5e0"}, + {file = "orjson-3.10.15-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f71eae9651465dff70aa80db92586ad5b92df46a9373ee55252109bb6b703307"}, + {file = "orjson-3.10.15-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e117eb299a35f2634e25ed120c37c641398826c2f5a3d3cc39f5993b96171b9e"}, + {file = "orjson-3.10.15-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:13242f12d295e83c2955756a574ddd6741c81e5b99f2bef8ed8d53e47a01e4b7"}, + {file = "orjson-3.10.15-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7946922ada8f3e0b7b958cc3eb22cfcf6c0df83d1fe5521b4a100103e3fa84c8"}, + {file = "orjson-3.10.15-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:b7155eb1623347f0f22c38c9abdd738b287e39b9982e1da227503387b81b34ca"}, + {file = "orjson-3.10.15-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:208beedfa807c922da4e81061dafa9c8489c6328934ca2a562efa707e049e561"}, + {file = "orjson-3.10.15-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eca81f83b1b8c07449e1d6ff7074e82e3fd6777e588f1a6632127f286a968825"}, + {file = "orjson-3.10.15-cp313-cp313-win32.whl", hash = "sha256:c03cd6eea1bd3b949d0d007c8d57049aa2b39bd49f58b4b2af571a5d3833d890"}, + {file = "orjson-3.10.15-cp313-cp313-win_amd64.whl", hash = "sha256:fd56a26a04f6ba5fb2045b0acc487a63162a958ed837648c5781e1fe3316cfbf"}, + {file = "orjson-3.10.15-cp38-cp38-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:5e8afd6200e12771467a1a44e5ad780614b86abb4b11862ec54861a82d677746"}, + {file = "orjson-3.10.15-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:da9a18c500f19273e9e104cca8c1f0b40a6470bcccfc33afcc088045d0bf5ea6"}, + {file = "orjson-3.10.15-cp38-cp38-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bb00b7bfbdf5d34a13180e4805d76b4567025da19a197645ca746fc2fb536586"}, + {file = "orjson-3.10.15-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:33aedc3d903378e257047fee506f11e0833146ca3e57a1a1fb0ddb789876c1e1"}, + {file = "orjson-3.10.15-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dd0099ae6aed5eb1fc84c9eb72b95505a3df4267e6962eb93cdd5af03be71c98"}, + {file = "orjson-3.10.15-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7c864a80a2d467d7786274fce0e4f93ef2a7ca4ff31f7fc5634225aaa4e9e98c"}, + {file = "orjson-3.10.15-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c25774c9e88a3e0013d7d1a6c8056926b607a61edd423b50eb5c88fd7f2823ae"}, + {file = "orjson-3.10.15-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:e78c211d0074e783d824ce7bb85bf459f93a233eb67a5b5003498232ddfb0e8a"}, + {file = "orjson-3.10.15-cp38-cp38-musllinux_1_2_armv7l.whl", hash = "sha256:43e17289ffdbbac8f39243916c893d2ae41a2ea1a9cbb060a56a4d75286351ae"}, + {file = "orjson-3.10.15-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:781d54657063f361e89714293c095f506c533582ee40a426cb6489c48a637b81"}, + {file = "orjson-3.10.15-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:6875210307d36c94873f553786a808af2788e362bd0cf4c8e66d976791e7b528"}, + {file = "orjson-3.10.15-cp38-cp38-win32.whl", hash = "sha256:305b38b2b8f8083cc3d618927d7f424349afce5975b316d33075ef0f73576b60"}, + {file = "orjson-3.10.15-cp38-cp38-win_amd64.whl", hash = "sha256:5dd9ef1639878cc3efffed349543cbf9372bdbd79f478615a1c633fe4e4180d1"}, + {file = "orjson-3.10.15-cp39-cp39-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:ffe19f3e8d68111e8644d4f4e267a069ca427926855582ff01fc012496d19969"}, + {file = "orjson-3.10.15-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d433bf32a363823863a96561a555227c18a522a8217a6f9400f00ddc70139ae2"}, + {file = "orjson-3.10.15-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:da03392674f59a95d03fa5fb9fe3a160b0511ad84b7a3914699ea5a1b3a38da2"}, + {file = "orjson-3.10.15-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3a63bb41559b05360ded9132032239e47983a39b151af1201f07ec9370715c82"}, + {file = "orjson-3.10.15-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3766ac4702f8f795ff3fa067968e806b4344af257011858cc3d6d8721588b53f"}, + {file = "orjson-3.10.15-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7a1c73dcc8fadbd7c55802d9aa093b36878d34a3b3222c41052ce6b0fc65f8e8"}, + {file = "orjson-3.10.15-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b299383825eafe642cbab34be762ccff9fd3408d72726a6b2a4506d410a71ab3"}, + {file = "orjson-3.10.15-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:abc7abecdbf67a173ef1316036ebbf54ce400ef2300b4e26a7b843bd446c2480"}, + {file = "orjson-3.10.15-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:3614ea508d522a621384c1d6639016a5a2e4f027f3e4a1c93a51867615d28829"}, + {file = "orjson-3.10.15-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:295c70f9dc154307777ba30fe29ff15c1bcc9dfc5c48632f37d20a607e9ba85a"}, + {file = "orjson-3.10.15-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:63309e3ff924c62404923c80b9e2048c1f74ba4b615e7584584389ada50ed428"}, + {file = "orjson-3.10.15-cp39-cp39-win32.whl", hash = "sha256:a2f708c62d026fb5340788ba94a55c23df4e1869fec74be455e0b2f5363b8507"}, + {file = "orjson-3.10.15-cp39-cp39-win_amd64.whl", hash = "sha256:efcf6c735c3d22ef60c4aa27a5238f1a477df85e9b15f2142f9d669beb2d13fd"}, + {file = "orjson-3.10.15.tar.gz", hash = "sha256:05ca7fe452a2e9d8d9d706a2984c95b9c2ebc5db417ce0b7a49b91d50642a23e"}, ] [[package]] @@ -2498,13 +2496,13 @@ files = [ [[package]] name = "pydantic" -version = "2.10.5" +version = "2.10.6" description = "Data validation using Python type hints" optional = false python-versions = ">=3.8" files = [ - {file = "pydantic-2.10.5-py3-none-any.whl", hash = "sha256:4dd4e322dbe55472cb7ca7e73f4b63574eecccf2835ffa2af9021ce113c83c53"}, - {file = "pydantic-2.10.5.tar.gz", hash = "sha256:278b38dbbaec562011d659ee05f63346951b3a248a6f3642e1bc68894ea2b4ff"}, + {file = "pydantic-2.10.6-py3-none-any.whl", hash = "sha256:427d664bf0b8a2b34ff5dd0f5a18df00591adcee7198fbd71981054cef37b584"}, + {file = "pydantic-2.10.6.tar.gz", hash = "sha256:ca5daa827cce33de7a42be142548b0096bf05a7e7b365aebfa5f8eeec7128236"}, ] [package.dependencies] @@ -2712,13 +2710,13 @@ dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "pygments [[package]] name = "pytest-asyncio" -version = "0.25.2" +version = "0.25.3" description = "Pytest support for asyncio" optional = false python-versions = ">=3.9" files = [ - {file = "pytest_asyncio-0.25.2-py3-none-any.whl", hash = "sha256:0d0bb693f7b99da304a0634afc0a4b19e49d5e0de2d670f38dc4bfa5727c5075"}, - {file = "pytest_asyncio-0.25.2.tar.gz", hash = "sha256:3f8ef9a98f45948ea91a0ed3dc4268b5326c0e7bce73892acc654df4262ad45f"}, + {file = "pytest_asyncio-0.25.3-py3-none-any.whl", hash = "sha256:9e89518e0f9bd08928f97a3482fdc4e244df17529460bc038291ccaf8f85c7c3"}, + {file = "pytest_asyncio-0.25.3.tar.gz", hash = "sha256:fc1da2cf9f125ada7e710b4ddad05518d4cee187ae9412e9ac9271003497f07a"}, ] [package.dependencies] @@ -3049,29 +3047,29 @@ jupyter = ["ipywidgets (>=7.5.1,<9)"] [[package]] name = "ruff" -version = "0.9.2" +version = "0.9.6" description = "An extremely fast Python linter and code formatter, written in Rust." optional = false python-versions = ">=3.7" files = [ - {file = "ruff-0.9.2-py3-none-linux_armv6l.whl", hash = "sha256:80605a039ba1454d002b32139e4970becf84b5fee3a3c3bf1c2af6f61a784347"}, - {file = "ruff-0.9.2-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:b9aab82bb20afd5f596527045c01e6ae25a718ff1784cb92947bff1f83068b00"}, - {file = "ruff-0.9.2-py3-none-macosx_11_0_arm64.whl", hash = "sha256:fbd337bac1cfa96be615f6efcd4bc4d077edbc127ef30e2b8ba2a27e18c054d4"}, - {file = "ruff-0.9.2-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:82b35259b0cbf8daa22a498018e300b9bb0174c2bbb7bcba593935158a78054d"}, - {file = "ruff-0.9.2-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8b6a9701d1e371bf41dca22015c3f89769da7576884d2add7317ec1ec8cb9c3c"}, - {file = "ruff-0.9.2-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9cc53e68b3c5ae41e8faf83a3b89f4a5d7b2cb666dff4b366bb86ed2a85b481f"}, - {file = "ruff-0.9.2-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:8efd9da7a1ee314b910da155ca7e8953094a7c10d0c0a39bfde3fcfd2a015684"}, - {file = "ruff-0.9.2-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3292c5a22ea9a5f9a185e2d131dc7f98f8534a32fb6d2ee7b9944569239c648d"}, - {file = "ruff-0.9.2-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1a605fdcf6e8b2d39f9436d343d1f0ff70c365a1e681546de0104bef81ce88df"}, - {file = "ruff-0.9.2-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c547f7f256aa366834829a08375c297fa63386cbe5f1459efaf174086b564247"}, - {file = "ruff-0.9.2-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:d18bba3d3353ed916e882521bc3e0af403949dbada344c20c16ea78f47af965e"}, - {file = "ruff-0.9.2-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:b338edc4610142355ccf6b87bd356729b62bf1bc152a2fad5b0c7dc04af77bfe"}, - {file = "ruff-0.9.2-py3-none-musllinux_1_2_i686.whl", hash = "sha256:492a5e44ad9b22a0ea98cf72e40305cbdaf27fac0d927f8bc9e1df316dcc96eb"}, - {file = "ruff-0.9.2-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:af1e9e9fe7b1f767264d26b1075ac4ad831c7db976911fa362d09b2d0356426a"}, - {file = "ruff-0.9.2-py3-none-win32.whl", hash = "sha256:71cbe22e178c5da20e1514e1e01029c73dc09288a8028a5d3446e6bba87a5145"}, - {file = "ruff-0.9.2-py3-none-win_amd64.whl", hash = "sha256:c5e1d6abc798419cf46eed03f54f2e0c3adb1ad4b801119dedf23fcaf69b55b5"}, - {file = "ruff-0.9.2-py3-none-win_arm64.whl", hash = "sha256:a1b63fa24149918f8b37cef2ee6fff81f24f0d74b6f0bdc37bc3e1f2143e41c6"}, - {file = "ruff-0.9.2.tar.gz", hash = "sha256:b5eceb334d55fae5f316f783437392642ae18e16dcf4f1858d55d3c2a0f8f5d0"}, + {file = "ruff-0.9.6-py3-none-linux_armv6l.whl", hash = "sha256:2f218f356dd2d995839f1941322ff021c72a492c470f0b26a34f844c29cdf5ba"}, + {file = "ruff-0.9.6-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:b908ff4df65dad7b251c9968a2e4560836d8f5487c2f0cc238321ed951ea0504"}, + {file = "ruff-0.9.6-py3-none-macosx_11_0_arm64.whl", hash = "sha256:b109c0ad2ececf42e75fa99dc4043ff72a357436bb171900714a9ea581ddef83"}, + {file = "ruff-0.9.6-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1de4367cca3dac99bcbd15c161404e849bb0bfd543664db39232648dc00112dc"}, + {file = "ruff-0.9.6-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ac3ee4d7c2c92ddfdaedf0bf31b2b176fa7aa8950efc454628d477394d35638b"}, + {file = "ruff-0.9.6-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5dc1edd1775270e6aa2386119aea692039781429f0be1e0949ea5884e011aa8e"}, + {file = "ruff-0.9.6-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:4a091729086dffa4bd070aa5dab7e39cc6b9d62eb2bef8f3d91172d30d599666"}, + {file = "ruff-0.9.6-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d1bbc6808bf7b15796cef0815e1dfb796fbd383e7dbd4334709642649625e7c5"}, + {file = "ruff-0.9.6-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:589d1d9f25b5754ff230dce914a174a7c951a85a4e9270613a2b74231fdac2f5"}, + {file = "ruff-0.9.6-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dc61dd5131742e21103fbbdcad683a8813be0e3c204472d520d9a5021ca8b217"}, + {file = "ruff-0.9.6-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:5e2d9126161d0357e5c8f30b0bd6168d2c3872372f14481136d13de9937f79b6"}, + {file = "ruff-0.9.6-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:68660eab1a8e65babb5229a1f97b46e3120923757a68b5413d8561f8a85d4897"}, + {file = "ruff-0.9.6-py3-none-musllinux_1_2_i686.whl", hash = "sha256:c4cae6c4cc7b9b4017c71114115db0445b00a16de3bcde0946273e8392856f08"}, + {file = "ruff-0.9.6-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:19f505b643228b417c1111a2a536424ddde0db4ef9023b9e04a46ed8a1cb4656"}, + {file = "ruff-0.9.6-py3-none-win32.whl", hash = "sha256:194d8402bceef1b31164909540a597e0d913c0e4952015a5b40e28c146121b5d"}, + {file = "ruff-0.9.6-py3-none-win_amd64.whl", hash = "sha256:03482d5c09d90d4ee3f40d97578423698ad895c87314c4de39ed2af945633caa"}, + {file = "ruff-0.9.6-py3-none-win_arm64.whl", hash = "sha256:0e2bb706a2be7ddfea4a4af918562fdc1bcb16df255e5fa595bbd800ce322a5a"}, + {file = "ruff-0.9.6.tar.gz", hash = "sha256:81761592f72b620ec8fa1068a6fd00e98a5ebee342a3642efd84454f3031dca9"}, ] [[package]] From f0a4c945bd0b9dd290a7d484c465edd27a0fba33 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 11 Feb 2025 17:24:47 +0000 Subject: [PATCH 121/395] chore(deps): update github-actions (#16032) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- .github/workflows/cli.yml | 4 ++-- .github/workflows/docker.yml | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/workflows/cli.yml b/.github/workflows/cli.yml index b35c9f9d9b..02930a2c2f 100644 --- a/.github/workflows/cli.yml +++ b/.github/workflows/cli.yml @@ -56,10 +56,10 @@ jobs: uses: actions/checkout@v4 - name: Set up QEMU - uses: docker/setup-qemu-action@v3.3.0 + uses: docker/setup-qemu-action@v3.4.0 - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3.8.0 + uses: docker/setup-buildx-action@v3.9.0 - name: Login to GitHub Container Registry uses: docker/login-action@v3 diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 48169fb859..8f6f76376b 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -122,10 +122,10 @@ jobs: uses: actions/checkout@v4 - name: Set up QEMU - uses: docker/setup-qemu-action@v3.3.0 + uses: docker/setup-qemu-action@v3.4.0 - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3.8.0 + uses: docker/setup-buildx-action@v3.9.0 - name: Login to Docker Hub # Only push to Docker Hub when making a release @@ -213,10 +213,10 @@ jobs: uses: actions/checkout@v4 - name: Set up QEMU - uses: docker/setup-qemu-action@v3.3.0 + uses: docker/setup-qemu-action@v3.4.0 - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3.8.0 + uses: docker/setup-buildx-action@v3.9.0 - name: Login to Docker Hub # Only push to Docker Hub when making a release From d2575d8f00cffbd5db5e9229315e3f852f23b946 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 11 Feb 2025 18:50:18 +0000 Subject: [PATCH 122/395] fix(deps): update typescript-projects (#16023) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Co-authored-by: Daniel Dietzler --- cli/package-lock.json | 252 +++--- e2e/package-lock.json | 790 ++++++++++-------- server/package-lock.json | 485 ++++++----- server/package.json | 2 +- web/package-lock.json | 458 +++++----- web/package.json | 4 +- .../album-page/albums-controls.svelte | 2 + .../components/elements/buttons/button.svelte | 1 - .../buttons/circle-icon-button.svelte | 1 - .../places-page/places-controls.svelte | 2 + .../navigation-bar/navigation-bar.svelte | 1 + .../[[assetId=id]]/+page.svelte | 1 + .../routes/admin/user-management/+page.svelte | 4 + 13 files changed, 1050 insertions(+), 953 deletions(-) diff --git a/cli/package-lock.json b/cli/package-lock.json index 9c3067bacf..2aca48b648 100644 --- a/cli/package-lock.json +++ b/cli/package-lock.json @@ -881,9 +881,9 @@ } }, "node_modules/@eslint/js": { - "version": "9.18.0", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.18.0.tgz", - "integrity": "sha512-fK6L7rxcq6/z+AaQMtiFTkvbHkBLNlwyRxHpKawP0x3u9+NC6MQTnFW+AdpwC6gfHTW0051cokQgtTN2FqlxQA==", + "version": "9.19.0", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.19.0.tgz", + "integrity": "sha512-rbq9/g38qjfqFLOVPvwjIvFFdNziEC5S65jmjPw5r6A//QH+W91akh9irMwjDN8zKUTak6W9EsAv4m/7Wnw0UQ==", "dev": true, "license": "MIT", "engines": { @@ -1498,21 +1498,21 @@ "dev": true }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.20.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.20.0.tgz", - "integrity": "sha512-naduuphVw5StFfqp4Gq4WhIBE2gN1GEmMUExpJYknZJdRnc+2gDzB8Z3+5+/Kv33hPQRDGzQO/0opHE72lZZ6A==", + "version": "8.23.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.23.0.tgz", + "integrity": "sha512-vBz65tJgRrA1Q5gWlRfvoH+w943dq9K1p1yDBY2pc+a1nbBLZp7fB9+Hk8DaALUbzjqlMfgaqlVPT1REJdkt/w==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/regexpp": "^4.10.0", - "@typescript-eslint/scope-manager": "8.20.0", - "@typescript-eslint/type-utils": "8.20.0", - "@typescript-eslint/utils": "8.20.0", - "@typescript-eslint/visitor-keys": "8.20.0", + "@typescript-eslint/scope-manager": "8.23.0", + "@typescript-eslint/type-utils": "8.23.0", + "@typescript-eslint/utils": "8.23.0", + "@typescript-eslint/visitor-keys": "8.23.0", "graphemer": "^1.4.0", "ignore": "^5.3.1", "natural-compare": "^1.4.0", - "ts-api-utils": "^2.0.0" + "ts-api-utils": "^2.0.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1528,16 +1528,16 @@ } }, "node_modules/@typescript-eslint/parser": { - "version": "8.20.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.20.0.tgz", - "integrity": "sha512-gKXG7A5HMyjDIedBi6bUrDcun8GIjnI8qOwVLiY3rx6T/sHP/19XLJOnIq/FgQvWLHja5JN/LSE7eklNBr612g==", + "version": "8.23.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.23.0.tgz", + "integrity": "sha512-h2lUByouOXFAlMec2mILeELUbME5SZRN/7R9Cw2RD2lRQQY08MWMM+PmVVKKJNK1aIwqTo9t/0CvOxwPbRIE2Q==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/scope-manager": "8.20.0", - "@typescript-eslint/types": "8.20.0", - "@typescript-eslint/typescript-estree": "8.20.0", - "@typescript-eslint/visitor-keys": "8.20.0", + "@typescript-eslint/scope-manager": "8.23.0", + "@typescript-eslint/types": "8.23.0", + "@typescript-eslint/typescript-estree": "8.23.0", + "@typescript-eslint/visitor-keys": "8.23.0", "debug": "^4.3.4" }, "engines": { @@ -1553,14 +1553,14 @@ } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.20.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.20.0.tgz", - "integrity": "sha512-J7+VkpeGzhOt3FeG1+SzhiMj9NzGD/M6KoGn9f4dbz3YzK9hvbhVTmLj/HiTp9DazIzJ8B4XcM80LrR9Dm1rJw==", + "version": "8.23.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.23.0.tgz", + "integrity": "sha512-OGqo7+dXHqI7Hfm+WqkZjKjsiRtFUQHPdGMXzk5mYXhJUedO7e/Y7i8AK3MyLMgZR93TX4bIzYrfyVjLC+0VSw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.20.0", - "@typescript-eslint/visitor-keys": "8.20.0" + "@typescript-eslint/types": "8.23.0", + "@typescript-eslint/visitor-keys": "8.23.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1571,16 +1571,16 @@ } }, "node_modules/@typescript-eslint/type-utils": { - "version": "8.20.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.20.0.tgz", - "integrity": "sha512-bPC+j71GGvA7rVNAHAtOjbVXbLN5PkwqMvy1cwGeaxUoRQXVuKCebRoLzm+IPW/NtFFpstn1ummSIasD5t60GA==", + "version": "8.23.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.23.0.tgz", + "integrity": "sha512-iIuLdYpQWZKbiH+RkCGc6iu+VwscP5rCtQ1lyQ7TYuKLrcZoeJVpcLiG8DliXVkUxirW/PWlmS+d6yD51L9jvA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/typescript-estree": "8.20.0", - "@typescript-eslint/utils": "8.20.0", + "@typescript-eslint/typescript-estree": "8.23.0", + "@typescript-eslint/utils": "8.23.0", "debug": "^4.3.4", - "ts-api-utils": "^2.0.0" + "ts-api-utils": "^2.0.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1595,9 +1595,9 @@ } }, "node_modules/@typescript-eslint/types": { - "version": "8.20.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.20.0.tgz", - "integrity": "sha512-cqaMiY72CkP+2xZRrFt3ExRBu0WmVitN/rYPZErA80mHjHx/Svgp8yfbzkJmDoQ/whcytOPO9/IZXnOc+wigRA==", + "version": "8.23.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.23.0.tgz", + "integrity": "sha512-1sK4ILJbCmZOTt9k4vkoulT6/y5CHJ1qUYxqpF1K/DBAd8+ZUL4LlSCxOssuH5m4rUaaN0uS0HlVPvd45zjduQ==", "dev": true, "license": "MIT", "engines": { @@ -1609,20 +1609,20 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.20.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.20.0.tgz", - "integrity": "sha512-Y7ncuy78bJqHI35NwzWol8E0X7XkRVS4K4P4TCyzWkOJih5NDvtoRDW4Ba9YJJoB2igm9yXDdYI/+fkiiAxPzA==", + "version": "8.23.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.23.0.tgz", + "integrity": "sha512-LcqzfipsB8RTvH8FX24W4UUFk1bl+0yTOf9ZA08XngFwMg4Kj8A+9hwz8Cr/ZS4KwHrmo9PJiLZkOt49vPnuvQ==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.20.0", - "@typescript-eslint/visitor-keys": "8.20.0", + "@typescript-eslint/types": "8.23.0", + "@typescript-eslint/visitor-keys": "8.23.0", "debug": "^4.3.4", "fast-glob": "^3.3.2", "is-glob": "^4.0.3", "minimatch": "^9.0.4", "semver": "^7.6.0", - "ts-api-utils": "^2.0.0" + "ts-api-utils": "^2.0.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1636,16 +1636,16 @@ } }, "node_modules/@typescript-eslint/utils": { - "version": "8.20.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.20.0.tgz", - "integrity": "sha512-dq70RUw6UK9ei7vxc4KQtBRk7qkHZv447OUZ6RPQMQl71I3NZxQJX/f32Smr+iqWrB02pHKn2yAdHBb0KNrRMA==", + "version": "8.23.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.23.0.tgz", + "integrity": "sha512-uB/+PSo6Exu02b5ZEiVtmY6RVYO7YU5xqgzTIVZwTHvvK3HsL8tZZHFaTLFtRG3CsV4A5mhOv+NZx5BlhXPyIA==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.4.0", - "@typescript-eslint/scope-manager": "8.20.0", - "@typescript-eslint/types": "8.20.0", - "@typescript-eslint/typescript-estree": "8.20.0" + "@typescript-eslint/scope-manager": "8.23.0", + "@typescript-eslint/types": "8.23.0", + "@typescript-eslint/typescript-estree": "8.23.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1660,13 +1660,13 @@ } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.20.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.20.0.tgz", - "integrity": "sha512-v/BpkeeYAsPkKCkR8BDwcno0llhzWVqPOamQrAEMdpZav2Y9OVjd9dwJyBLJWwf335B5DmlifECIkZRJCaGaHA==", + "version": "8.23.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.23.0.tgz", + "integrity": "sha512-oWWhcWDLwDfu++BGTZcmXWqpwtkwb5o7fxUIGksMQQDSdPW9prsSnfIOZMlsj4vBOSrcnjIUZMiIjODgGosFhQ==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.20.0", + "@typescript-eslint/types": "8.23.0", "eslint-visitor-keys": "^4.2.0" }, "engines": { @@ -1691,9 +1691,9 @@ } }, "node_modules/@vitest/coverage-v8": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-3.0.3.tgz", - "integrity": "sha512-uVbJ/xhImdNtzPnLyxCZJMTeTIYdgcC2nWtBBBpR1H6z0w8m7D+9/zrDIx2nNxgMg9r+X8+RY2qVpUDeW2b3nw==", + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-3.0.5.tgz", + "integrity": "sha512-zOOWIsj5fHh3jjGwQg+P+J1FW3s4jBu1Zqga0qW60yutsBtqEqNEJKWYh7cYn1yGD+1bdPsPdC/eL4eVK56xMg==", "dev": true, "license": "MIT", "dependencies": { @@ -1714,8 +1714,8 @@ "url": "https://opencollective.com/vitest" }, "peerDependencies": { - "@vitest/browser": "3.0.3", - "vitest": "3.0.3" + "@vitest/browser": "3.0.5", + "vitest": "3.0.5" }, "peerDependenciesMeta": { "@vitest/browser": { @@ -1724,14 +1724,14 @@ } }, "node_modules/@vitest/expect": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.0.3.tgz", - "integrity": "sha512-SbRCHU4qr91xguu+dH3RUdI5dC86zm8aZWydbp961aIR7G8OYNN6ZiayFuf9WAngRbFOfdrLHCGgXTj3GtoMRQ==", + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.0.5.tgz", + "integrity": "sha512-nNIOqupgZ4v5jWuQx2DSlHLEs7Q4Oh/7AYwNyE+k0UQzG7tSmjPXShUikn1mpNGzYEN2jJbTvLejwShMitovBA==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/spy": "3.0.3", - "@vitest/utils": "3.0.3", + "@vitest/spy": "3.0.5", + "@vitest/utils": "3.0.5", "chai": "^5.1.2", "tinyrainbow": "^2.0.0" }, @@ -1740,13 +1740,13 @@ } }, "node_modules/@vitest/mocker": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.0.3.tgz", - "integrity": "sha512-XT2XBc4AN9UdaxJAeIlcSZ0ILi/GzmG5G8XSly4gaiqIvPV3HMTSIDZWJVX6QRJ0PX1m+W8Cy0K9ByXNb/bPIA==", + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.0.5.tgz", + "integrity": "sha512-CLPNBFBIE7x6aEGbIjaQAX03ZZlBMaWwAjBdMkIf/cAn6xzLTiM3zYqO/WAbieEjsAZir6tO71mzeHZoodThvw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/spy": "3.0.3", + "@vitest/spy": "3.0.5", "estree-walker": "^3.0.3", "magic-string": "^0.30.17" }, @@ -1767,9 +1767,9 @@ } }, "node_modules/@vitest/pretty-format": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.0.3.tgz", - "integrity": "sha512-gCrM9F7STYdsDoNjGgYXKPq4SkSxwwIU5nkaQvdUxiQ0EcNlez+PdKOVIsUJvh9P9IeIFmjn4IIREWblOBpP2Q==", + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.0.5.tgz", + "integrity": "sha512-CjUtdmpOcm4RVtB+up8r2vVDLR16Mgm/bYdkGFe3Yj/scRfCpbSi2W/BDSDcFK7ohw8UXvjMbOp9H4fByd/cOA==", "dev": true, "license": "MIT", "dependencies": { @@ -1780,38 +1780,38 @@ } }, "node_modules/@vitest/runner": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.0.3.tgz", - "integrity": "sha512-Rgi2kOAk5ZxWZlwPguRJFOBmWs6uvvyAAR9k3MvjRvYrG7xYvKChZcmnnpJCS98311CBDMqsW9MzzRFsj2gX3g==", + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.0.5.tgz", + "integrity": "sha512-BAiZFityFexZQi2yN4OX3OkJC6scwRo8EhRB0Z5HIGGgd2q+Nq29LgHU/+ovCtd0fOfXj5ZI6pwdlUmC5bpi8A==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/utils": "3.0.3", - "pathe": "^2.0.1" + "@vitest/utils": "3.0.5", + "pathe": "^2.0.2" }, "funding": { "url": "https://opencollective.com/vitest" } }, "node_modules/@vitest/snapshot": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.0.3.tgz", - "integrity": "sha512-kNRcHlI4txBGztuJfPEJ68VezlPAXLRT1u5UCx219TU3kOG2DplNxhWLwDf2h6emwmTPogzLnGVwP6epDaJN6Q==", + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.0.5.tgz", + "integrity": "sha512-GJPZYcd7v8QNUJ7vRvLDmRwl+a1fGg4T/54lZXe+UOGy47F9yUfE18hRCtXL5aHN/AONu29NGzIXSVFh9K0feA==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "3.0.3", + "@vitest/pretty-format": "3.0.5", "magic-string": "^0.30.17", - "pathe": "^2.0.1" + "pathe": "^2.0.2" }, "funding": { "url": "https://opencollective.com/vitest" } }, "node_modules/@vitest/spy": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.0.3.tgz", - "integrity": "sha512-7/dgux8ZBbF7lEIKNnEqQlyRaER9nkAL9eTmdKJkDO3hS8p59ATGwKOCUDHcBLKr7h/oi/6hP+7djQk8049T2A==", + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.0.5.tgz", + "integrity": "sha512-5fOzHj0WbUNqPK6blI/8VzZdkBlQLnT25knX0r4dbZI9qoZDf3qAdjoMmDcLG5A83W6oUUFJgUd0EYBc2P5xqg==", "dev": true, "license": "MIT", "dependencies": { @@ -1822,13 +1822,13 @@ } }, "node_modules/@vitest/utils": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.0.3.tgz", - "integrity": "sha512-f+s8CvyzPtMFY1eZKkIHGhPsQgYo5qCm6O8KZoim9qm1/jT64qBgGpO5tHscNH6BzRHM+edLNOP+3vO8+8pE/A==", + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.0.5.tgz", + "integrity": "sha512-N9AX0NUoUtVwKwy21JtwzaqR5L5R5A99GAbrHfCCXK1lp593i/3AZAXhSP43wRQuxYsflrdzEfXZFo1reR1Nkg==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "3.0.3", + "@vitest/pretty-format": "3.0.5", "loupe": "^3.1.2", "tinyrainbow": "^2.0.0" }, @@ -2334,9 +2334,9 @@ } }, "node_modules/eslint": { - "version": "9.18.0", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.18.0.tgz", - "integrity": "sha512-+waTfRWQlSbpt3KWE+CjrPPYnbq9kfZIYUqapc0uBXyjTp8aYXZDsUH16m39Ryq3NjAVP4tjuF7KaukeqoCoaA==", + "version": "9.19.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.19.0.tgz", + "integrity": "sha512-ug92j0LepKlbbEv6hD911THhoRHmbdXt2gX+VDABAW/Ir7D3nqKdv5Pf5vtlyY6HQMTEP2skXY43ueqTCWssEA==", "dev": true, "license": "MIT", "dependencies": { @@ -2345,7 +2345,7 @@ "@eslint/config-array": "^0.19.0", "@eslint/core": "^0.10.0", "@eslint/eslintrc": "^3.2.0", - "@eslint/js": "9.18.0", + "@eslint/js": "9.19.0", "@eslint/plugin-kit": "^0.2.5", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", @@ -2407,9 +2407,9 @@ } }, "node_modules/eslint-plugin-prettier": { - "version": "5.2.2", - "resolved": "https://registry.npmjs.org/eslint-plugin-prettier/-/eslint-plugin-prettier-5.2.2.tgz", - "integrity": "sha512-1yI3/hf35wmlq66C8yOyrujQnel+v5l1Vop5Cl2I6ylyNTT1JbuUUnV3/41PzwTzcyDp/oF0jWE3HXvcH5AQOQ==", + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/eslint-plugin-prettier/-/eslint-plugin-prettier-5.2.3.tgz", + "integrity": "sha512-qJ+y0FfCp/mQYQ/vWQ3s7eUlFEL4PyKfAJxsnYTJ4YT73nsJBWqmEpFryxV9OeUiqmsTsYJ5Y+KDNaeP31wrRw==", "dev": true, "license": "MIT", "dependencies": { @@ -2685,9 +2685,9 @@ "dev": true }, "node_modules/fastq": { - "version": "1.18.0", - "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.18.0.tgz", - "integrity": "sha512-QKHXPW0hD8g4UET03SdOdunzSouc9N4AuHdsX8XNcTsuz+yYFILVNIX4l9yHABMhiEI9Db0JTTIpu0wB+Y1QQw==", + "version": "1.19.0", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.0.tgz", + "integrity": "sha512-7SFSRCNjBQIZH/xZR3iy5iQYR8aGBE0h3VG6/cwlbrpdciNYBMotQav8c1XI3HjHH+NikUpP53nPdlZSdWmFzA==", "license": "ISC", "dependencies": { "reusify": "^1.0.4" @@ -3180,9 +3180,9 @@ "dev": true }, "node_modules/loupe": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.1.2.tgz", - "integrity": "sha512-23I4pFZHmAemUnz8WZXbYRSKYj801VDaNv9ETuMh7IrMc7VuVVSo+Z9iLE3ni30+U48iDWfi30d3twAXBYmnCg==", + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.1.3.tgz", + "integrity": "sha512-kkIp7XSkP78ZxJEsSxW3712C6teJVoeHHwgo9zJ380de7IYyJ2ISlxojcH2pC5OFLewESmnRi/+XCDIEEVyoug==", "dev": true, "license": "MIT" }, @@ -3285,9 +3285,9 @@ } }, "node_modules/mock-fs": { - "version": "5.4.1", - "resolved": "https://registry.npmjs.org/mock-fs/-/mock-fs-5.4.1.tgz", - "integrity": "sha512-sz/Q8K1gXXXHR+qr0GZg2ysxCRr323kuN10O7CtQjraJsFDJ4SJ+0I5MzALz7aRp9lHk8Cc/YdsT95h9Ka1aFw==", + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/mock-fs/-/mock-fs-5.5.0.tgz", + "integrity": "sha512-d/P1M/RacgM3dB0sJ8rjeRNXxtapkPCUnMGmIN0ixJ16F/E4GUZCvWcSGfWGz8eaXYvn1s9baUwNjI4LOPEjiA==", "dev": true, "license": "MIT", "engines": { @@ -4166,9 +4166,9 @@ } }, "node_modules/ts-api-utils": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.0.0.tgz", - "integrity": "sha512-xCt/TOAc+EOHS1XPnijD3/yzpH6qg2xppZO1YDqGoVsNXfQfzHpOdNuXwrwOU8u4ITXJyDCTyt8w5g1sZv9ynQ==", + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.0.1.tgz", + "integrity": "sha512-dnlgjFSVetynI8nzgJ+qF62efpglpWRk8isUEWZGWlJYySCTD6aKvbUDu+zbPeDakk3bg5H4XpitHukgfL1m9w==", "dev": true, "license": "MIT", "engines": { @@ -4288,15 +4288,15 @@ } }, "node_modules/vite": { - "version": "6.0.11", - "resolved": "https://registry.npmjs.org/vite/-/vite-6.0.11.tgz", - "integrity": "sha512-4VL9mQPKoHy4+FE0NnRE/kbY51TOfaknxAjt3fJbGJxhIpBZiqVzlZDEesWWsuREXHwNdAoOFZ9MkPEVXczHwg==", + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.1.0.tgz", + "integrity": "sha512-RjjMipCKVoR4hVfPY6GQTgveinjNuyLw+qruksLDvA5ktI1150VmcMBKmQaEWJhg/j6Uaf6dNCNA0AfdzUb/hQ==", "dev": true, "license": "MIT", "dependencies": { "esbuild": "^0.24.2", - "postcss": "^8.4.49", - "rollup": "^4.23.0" + "postcss": "^8.5.1", + "rollup": "^4.30.1" }, "bin": { "vite": "bin/vite.js" @@ -4360,16 +4360,16 @@ } }, "node_modules/vite-node": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.0.3.tgz", - "integrity": "sha512-0sQcwhwAEw/UJGojbhOrnq3HtiZ3tC7BzpAa0lx3QaTX0S3YX70iGcik25UBdB96pmdwjyY2uyKNYruxCDmiEg==", + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.0.5.tgz", + "integrity": "sha512-02JEJl7SbtwSDJdYS537nU6l+ktdvcREfLksk/NDAqtdKWGqHl+joXzEubHROmS3E6pip+Xgu2tFezMu75jH7A==", "dev": true, "license": "MIT", "dependencies": { "cac": "^6.7.14", "debug": "^4.4.0", "es-module-lexer": "^1.6.0", - "pathe": "^2.0.1", + "pathe": "^2.0.2", "vite": "^5.0.0 || ^6.0.0" }, "bin": { @@ -4403,31 +4403,31 @@ } }, "node_modules/vitest": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.0.3.tgz", - "integrity": "sha512-dWdwTFUW9rcnL0LyF2F+IfvNQWB0w9DERySCk8VMG75F8k25C7LsZoh6XfCjPvcR8Nb+Lqi9JKr6vnzH7HSrpQ==", + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.0.5.tgz", + "integrity": "sha512-4dof+HvqONw9bvsYxtkfUp2uHsTN9bV2CZIi1pWgoFpL1Lld8LA1ka9q/ONSsoScAKG7NVGf2stJTI7XRkXb2Q==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/expect": "3.0.3", - "@vitest/mocker": "3.0.3", - "@vitest/pretty-format": "^3.0.3", - "@vitest/runner": "3.0.3", - "@vitest/snapshot": "3.0.3", - "@vitest/spy": "3.0.3", - "@vitest/utils": "3.0.3", + "@vitest/expect": "3.0.5", + "@vitest/mocker": "3.0.5", + "@vitest/pretty-format": "^3.0.5", + "@vitest/runner": "3.0.5", + "@vitest/snapshot": "3.0.5", + "@vitest/spy": "3.0.5", + "@vitest/utils": "3.0.5", "chai": "^5.1.2", "debug": "^4.4.0", "expect-type": "^1.1.0", "magic-string": "^0.30.17", - "pathe": "^2.0.1", + "pathe": "^2.0.2", "std-env": "^3.8.0", "tinybench": "^2.9.0", "tinyexec": "^0.3.2", "tinypool": "^1.0.2", "tinyrainbow": "^2.0.0", "vite": "^5.0.0 || ^6.0.0", - "vite-node": "3.0.3", + "vite-node": "3.0.5", "why-is-node-running": "^2.3.0" }, "bin": { @@ -4441,9 +4441,10 @@ }, "peerDependencies": { "@edge-runtime/vm": "*", + "@types/debug": "^4.1.12", "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", - "@vitest/browser": "3.0.3", - "@vitest/ui": "3.0.3", + "@vitest/browser": "3.0.5", + "@vitest/ui": "3.0.5", "happy-dom": "*", "jsdom": "*" }, @@ -4451,6 +4452,9 @@ "@edge-runtime/vm": { "optional": true }, + "@types/debug": { + "optional": true + }, "@types/node": { "optional": true }, diff --git a/e2e/package-lock.json b/e2e/package-lock.json index b077f716bd..73379bcdca 100644 --- a/e2e/package-lock.json +++ b/e2e/package-lock.json @@ -361,9 +361,9 @@ } }, "node_modules/@esbuild/aix-ppc64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", - "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.24.2.tgz", + "integrity": "sha512-thpVCb/rhxE/BnMLQ7GReQLLN8q9qbHmI55F4489/ByVg2aQaQ6kbcLb6FHkocZzQhxc4gx0sCk0tJkKBFzDhA==", "cpu": [ "ppc64" ], @@ -374,13 +374,13 @@ "aix" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/android-arm": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", - "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.24.2.tgz", + "integrity": "sha512-tmwl4hJkCfNHwFB3nBa8z1Uy3ypZpxqxfTQOcHX+xRByyYgunVbZ9MzUUfb0RxaHIMnbHagwAxuTL+tnNM+1/Q==", "cpu": [ "arm" ], @@ -391,13 +391,13 @@ "android" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/android-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", - "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.24.2.tgz", + "integrity": "sha512-cNLgeqCqV8WxfcTIOeL4OAtSmL8JjcN6m09XIgro1Wi7cF4t/THaWEa7eL5CMoMBdjoHOTh/vwTO/o2TRXIyzg==", "cpu": [ "arm64" ], @@ -408,13 +408,13 @@ "android" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/android-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", - "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.24.2.tgz", + "integrity": "sha512-B6Q0YQDqMx9D7rvIcsXfmJfvUYLoP722bgfBlO5cGvNVb5V/+Y7nhBE3mHV9OpxBf4eAS2S68KZztiPaWq4XYw==", "cpu": [ "x64" ], @@ -425,13 +425,13 @@ "android" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/darwin-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", - "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.24.2.tgz", + "integrity": "sha512-kj3AnYWc+CekmZnS5IPu9D+HWtUI49hbnyqk0FLEJDbzCIQt7hg7ucF1SQAilhtYpIujfaHr6O0UHlzzSPdOeA==", "cpu": [ "arm64" ], @@ -442,13 +442,13 @@ "darwin" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/darwin-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", - "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.24.2.tgz", + "integrity": "sha512-WeSrmwwHaPkNR5H3yYfowhZcbriGqooyu3zI/3GGpF8AyUdsrrP0X6KumITGA9WOyiJavnGZUwPGvxvwfWPHIA==", "cpu": [ "x64" ], @@ -459,13 +459,13 @@ "darwin" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/freebsd-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", - "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.24.2.tgz", + "integrity": "sha512-UN8HXjtJ0k/Mj6a9+5u6+2eZ2ERD7Edt1Q9IZiB5UZAIdPnVKDoG7mdTVGhHJIeEml60JteamR3qhsr1r8gXvg==", "cpu": [ "arm64" ], @@ -476,13 +476,13 @@ "freebsd" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/freebsd-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", - "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.24.2.tgz", + "integrity": "sha512-TvW7wE/89PYW+IevEJXZ5sF6gJRDY/14hyIGFXdIucxCsbRmLUcjseQu1SyTko+2idmCw94TgyaEZi9HUSOe3Q==", "cpu": [ "x64" ], @@ -493,13 +493,13 @@ "freebsd" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/linux-arm": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", - "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.24.2.tgz", + "integrity": "sha512-n0WRM/gWIdU29J57hJyUdIsk0WarGd6To0s+Y+LwvlC55wt+GT/OgkwoXCXvIue1i1sSNWblHEig00GBWiJgfA==", "cpu": [ "arm" ], @@ -510,13 +510,13 @@ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/linux-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", - "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.24.2.tgz", + "integrity": "sha512-7HnAD6074BW43YvvUmE/35Id9/NB7BeX5EoNkK9obndmZBUk8xmJJeU7DwmUeN7tkysslb2eSl6CTrYz6oEMQg==", "cpu": [ "arm64" ], @@ -527,13 +527,13 @@ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/linux-ia32": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", - "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.24.2.tgz", + "integrity": "sha512-sfv0tGPQhcZOgTKO3oBE9xpHuUqguHvSo4jl+wjnKwFpapx+vUDcawbwPNuBIAYdRAvIDBfZVvXprIj3HA+Ugw==", "cpu": [ "ia32" ], @@ -544,13 +544,13 @@ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/linux-loong64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", - "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.24.2.tgz", + "integrity": "sha512-CN9AZr8kEndGooS35ntToZLTQLHEjtVB5n7dl8ZcTZMonJ7CCfStrYhrzF97eAecqVbVJ7APOEe18RPI4KLhwQ==", "cpu": [ "loong64" ], @@ -561,13 +561,13 @@ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/linux-mips64el": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", - "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.24.2.tgz", + "integrity": "sha512-iMkk7qr/wl3exJATwkISxI7kTcmHKE+BlymIAbHO8xanq/TjHaaVThFF6ipWzPHryoFsesNQJPE/3wFJw4+huw==", "cpu": [ "mips64el" ], @@ -578,13 +578,13 @@ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/linux-ppc64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", - "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.24.2.tgz", + "integrity": "sha512-shsVrgCZ57Vr2L8mm39kO5PPIb+843FStGt7sGGoqiiWYconSxwTiuswC1VJZLCjNiMLAMh34jg4VSEQb+iEbw==", "cpu": [ "ppc64" ], @@ -595,13 +595,13 @@ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/linux-riscv64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", - "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.24.2.tgz", + "integrity": "sha512-4eSFWnU9Hhd68fW16GD0TINewo1L6dRrB+oLNNbYyMUAeOD2yCK5KXGK1GH4qD/kT+bTEXjsyTCiJGHPZ3eM9Q==", "cpu": [ "riscv64" ], @@ -612,13 +612,13 @@ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/linux-s390x": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", - "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.24.2.tgz", + "integrity": "sha512-S0Bh0A53b0YHL2XEXC20bHLuGMOhFDO6GN4b3YjRLK//Ep3ql3erpNcPlEFed93hsQAjAQDNsvcK+hV90FubSw==", "cpu": [ "s390x" ], @@ -629,13 +629,13 @@ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/linux-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", - "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.24.2.tgz", + "integrity": "sha512-8Qi4nQcCTbLnK9WoMjdC9NiTG6/E38RNICU6sUNqK0QFxCYgoARqVqxdFmWkdonVsvGqWhmm7MO0jyTqLqwj0Q==", "cpu": [ "x64" ], @@ -646,13 +646,30 @@ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.24.2.tgz", + "integrity": "sha512-wuLK/VztRRpMt9zyHSazyCVdCXlpHkKm34WUyinD2lzK07FAHTq0KQvZZlXikNWkDGoT6x3TD51jKQ7gMVpopw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" } }, "node_modules/@esbuild/netbsd-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", - "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.24.2.tgz", + "integrity": "sha512-VefFaQUc4FMmJuAxmIHgUmfNiLXY438XrL4GDNV1Y1H/RW3qow68xTwjZKfj/+Plp9NANmzbH5R40Meudu8mmw==", "cpu": [ "x64" ], @@ -663,13 +680,30 @@ "netbsd" ], "engines": { - "node": ">=12" + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.24.2.tgz", + "integrity": "sha512-YQbi46SBct6iKnszhSvdluqDmxCJA+Pu280Av9WICNwQmMxV7nLRHZfjQzwbPs3jeWnuAhE9Jy0NrnJ12Oz+0A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" } }, "node_modules/@esbuild/openbsd-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", - "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.24.2.tgz", + "integrity": "sha512-+iDS6zpNM6EnJyWv0bMGLWSWeXGN/HTaF/LXHXHwejGsVi+ooqDfMCCTerNFxEkM3wYVcExkeGXNqshc9iMaOA==", "cpu": [ "x64" ], @@ -680,13 +714,13 @@ "openbsd" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/sunos-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", - "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.24.2.tgz", + "integrity": "sha512-hTdsW27jcktEvpwNHJU4ZwWFGkz2zRJUz8pvddmXPtXDzVKTTINmlmga3ZzwcuMpUvLw7JkLy9QLKyGpD2Yxig==", "cpu": [ "x64" ], @@ -697,13 +731,13 @@ "sunos" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/win32-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", - "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.24.2.tgz", + "integrity": "sha512-LihEQ2BBKVFLOC9ZItT9iFprsE9tqjDjnbulhHoFxYQtQfai7qfluVODIYxt1PgdoyQkz23+01rzwNwYfutxUQ==", "cpu": [ "arm64" ], @@ -714,13 +748,13 @@ "win32" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/win32-ia32": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", - "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.24.2.tgz", + "integrity": "sha512-q+iGUwfs8tncmFC9pcnD5IvRHAzmbwQ3GPS5/ceCyHdjXubwQWI12MKWSNSMYLJMq23/IUCvJMS76PDqXe1fxA==", "cpu": [ "ia32" ], @@ -731,13 +765,13 @@ "win32" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/win32-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", - "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.24.2.tgz", + "integrity": "sha512-7VTgWzgMGvup6aSqDPLiW5zHaxYJGTO4OokMjIlrCtf+VpEL+cXKtCvg723iguPYI5oaUNdS+/V7OU2gvXVWEg==", "cpu": [ "x64" ], @@ -748,7 +782,7 @@ "win32" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@eslint-community/eslint-utils": { @@ -842,9 +876,9 @@ } }, "node_modules/@eslint/js": { - "version": "9.18.0", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.18.0.tgz", - "integrity": "sha512-fK6L7rxcq6/z+AaQMtiFTkvbHkBLNlwyRxHpKawP0x3u9+NC6MQTnFW+AdpwC6gfHTW0051cokQgtTN2FqlxQA==", + "version": "9.19.0", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.19.0.tgz", + "integrity": "sha512-rbq9/g38qjfqFLOVPvwjIvFFdNziEC5S65jmjPw5r6A//QH+W91akh9irMwjDN8zKUTak6W9EsAv4m/7Wnw0UQ==", "dev": true, "license": "MIT", "engines": { @@ -1211,13 +1245,13 @@ } }, "node_modules/@playwright/test": { - "version": "1.49.1", - "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.49.1.tgz", - "integrity": "sha512-Ky+BVzPz8pL6PQxHqNRW1k3mIyv933LML7HktS8uik0bUXNCdPhoS/kLihiO1tMf/egaJb4IutXd7UywvXEW+g==", + "version": "1.50.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.50.1.tgz", + "integrity": "sha512-Jii3aBg+CEDpgnuDxEp/h7BimHcUTDlpEtce89xEumlJ5ef2hqepZ+PWp1DDpYC/VO9fmWVI1IlEaoI5fK9FXQ==", "dev": true, "license": "Apache-2.0", "dependencies": { - "playwright": "1.49.1" + "playwright": "1.50.1" }, "bin": { "playwright": "cli.js" @@ -1227,9 +1261,9 @@ } }, "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.27.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.27.3.tgz", - "integrity": "sha512-EzxVSkIvCFxUd4Mgm4xR9YXrcp976qVaHnqom/Tgm+vU79k4vV4eYTjmRvGfeoW8m9LVcsAy/lGjcgVegKEhLQ==", + "version": "4.34.6", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.34.6.tgz", + "integrity": "sha512-+GcCXtOQoWuC7hhX1P00LqjjIiS/iOouHXhMdiDSnq/1DGTox4SpUvO52Xm+div6+106r+TcvOeo/cxvyEyTgg==", "cpu": [ "arm" ], @@ -1241,9 +1275,9 @@ ] }, "node_modules/@rollup/rollup-android-arm64": { - "version": "4.27.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.27.3.tgz", - "integrity": "sha512-LJc5pDf1wjlt9o/Giaw9Ofl+k/vLUaYsE2zeQGH85giX2F+wn/Cg8b3c5CDP3qmVmeO5NzwVUzQQxwZvC2eQKw==", + "version": "4.34.6", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.34.6.tgz", + "integrity": "sha512-E8+2qCIjciYUnCa1AiVF1BkRgqIGW9KzJeesQqVfyRITGQN+dFuoivO0hnro1DjT74wXLRZ7QF8MIbz+luGaJA==", "cpu": [ "arm64" ], @@ -1255,9 +1289,9 @@ ] }, "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.27.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.27.3.tgz", - "integrity": "sha512-OuRysZ1Mt7wpWJ+aYKblVbJWtVn3Cy52h8nLuNSzTqSesYw1EuN6wKp5NW/4eSre3mp12gqFRXOKTcN3AI3LqA==", + "version": "4.34.6", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.34.6.tgz", + "integrity": "sha512-z9Ib+OzqN3DZEjX7PDQMHEhtF+t6Mi2z/ueChQPLS/qUMKY7Ybn5A2ggFoKRNRh1q1T03YTQfBTQCJZiepESAg==", "cpu": [ "arm64" ], @@ -1269,9 +1303,9 @@ ] }, "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.27.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.27.3.tgz", - "integrity": "sha512-xW//zjJMlJs2sOrCmXdB4d0uiilZsOdlGQIC/jjmMWT47lkLLoB1nsNhPUcnoqyi5YR6I4h+FjBpILxbEy8JRg==", + "version": "4.34.6", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.34.6.tgz", + "integrity": "sha512-PShKVY4u0FDAR7jskyFIYVyHEPCPnIQY8s5OcXkdU8mz3Y7eXDJPdyM/ZWjkYdR2m0izD9HHWA8sGcXn+Qrsyg==", "cpu": [ "x64" ], @@ -1283,9 +1317,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.27.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.27.3.tgz", - "integrity": "sha512-58E0tIcwZ+12nK1WiLzHOD8I0d0kdrY/+o7yFVPRHuVGY3twBwzwDdTIBGRxLmyjciMYl1B/U515GJy+yn46qw==", + "version": "4.34.6", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.34.6.tgz", + "integrity": "sha512-YSwyOqlDAdKqs0iKuqvRHLN4SrD2TiswfoLfvYXseKbL47ht1grQpq46MSiQAx6rQEN8o8URtpXARCpqabqxGQ==", "cpu": [ "arm64" ], @@ -1297,9 +1331,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.27.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.27.3.tgz", - "integrity": "sha512-78fohrpcVwTLxg1ZzBMlwEimoAJmY6B+5TsyAZ3Vok7YabRBUvjYTsRXPTjGEvv/mfgVBepbW28OlMEz4w8wGA==", + "version": "4.34.6", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.34.6.tgz", + "integrity": "sha512-HEP4CgPAY1RxXwwL5sPFv6BBM3tVeLnshF03HMhJYCNc6kvSqBgTMmsEjb72RkZBAWIqiPUyF1JpEBv5XT9wKQ==", "cpu": [ "x64" ], @@ -1311,9 +1345,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.27.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.27.3.tgz", - "integrity": "sha512-h2Ay79YFXyQi+QZKo3ISZDyKaVD7uUvukEHTOft7kh00WF9mxAaxZsNs3o/eukbeKuH35jBvQqrT61fzKfAB/Q==", + "version": "4.34.6", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.34.6.tgz", + "integrity": "sha512-88fSzjC5xeH9S2Vg3rPgXJULkHcLYMkh8faix8DX4h4TIAL65ekwuQMA/g2CXq8W+NJC43V6fUpYZNjaX3+IIg==", "cpu": [ "arm" ], @@ -1325,9 +1359,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.27.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.27.3.tgz", - "integrity": "sha512-Sv2GWmrJfRY57urktVLQ0VKZjNZGogVtASAgosDZ1aUB+ykPxSi3X1nWORL5Jk0sTIIwQiPH7iE3BMi9zGWfkg==", + "version": "4.34.6", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.34.6.tgz", + "integrity": "sha512-wM4ztnutBqYFyvNeR7Av+reWI/enK9tDOTKNF+6Kk2Q96k9bwhDDOlnCUNRPvromlVXo04riSliMBs/Z7RteEg==", "cpu": [ "arm" ], @@ -1339,9 +1373,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.27.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.27.3.tgz", - "integrity": "sha512-FPoJBLsPW2bDNWjSrwNuTPUt30VnfM8GPGRoLCYKZpPx0xiIEdFip3dH6CqgoT0RnoGXptaNziM0WlKgBc+OWQ==", + "version": "4.34.6", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.34.6.tgz", + "integrity": "sha512-9RyprECbRa9zEjXLtvvshhw4CMrRa3K+0wcp3KME0zmBe1ILmvcVHnypZ/aIDXpRyfhSYSuN4EPdCCj5Du8FIA==", "cpu": [ "arm64" ], @@ -1353,9 +1387,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.27.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.27.3.tgz", - "integrity": "sha512-TKxiOvBorYq4sUpA0JT+Fkh+l+G9DScnG5Dqx7wiiqVMiRSkzTclP35pE6eQQYjP4Gc8yEkJGea6rz4qyWhp3g==", + "version": "4.34.6", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.34.6.tgz", + "integrity": "sha512-qTmklhCTyaJSB05S+iSovfo++EwnIEZxHkzv5dep4qoszUMX5Ca4WM4zAVUMbfdviLgCSQOu5oU8YoGk1s6M9Q==", "cpu": [ "arm64" ], @@ -1366,10 +1400,24 @@ "linux" ] }, + "node_modules/@rollup/rollup-linux-loongarch64-gnu": { + "version": "4.34.6", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loongarch64-gnu/-/rollup-linux-loongarch64-gnu-4.34.6.tgz", + "integrity": "sha512-4Qmkaps9yqmpjY5pvpkfOerYgKNUGzQpFxV6rnS7c/JfYbDSU0y6WpbbredB5cCpLFGJEqYX40WUmxMkwhWCjw==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, "node_modules/@rollup/rollup-linux-powerpc64le-gnu": { - "version": "4.27.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.27.3.tgz", - "integrity": "sha512-v2M/mPvVUKVOKITa0oCFksnQQ/TqGrT+yD0184/cWHIu0LoIuYHwox0Pm3ccXEz8cEQDLk6FPKd1CCm+PlsISw==", + "version": "4.34.6", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.34.6.tgz", + "integrity": "sha512-Zsrtux3PuaxuBTX/zHdLaFmcofWGzaWW1scwLU3ZbW/X+hSsFbz9wDIp6XvnT7pzYRl9MezWqEqKy7ssmDEnuQ==", "cpu": [ "ppc64" ], @@ -1381,9 +1429,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.27.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.27.3.tgz", - "integrity": "sha512-LdrI4Yocb1a/tFVkzmOE5WyYRgEBOyEhWYJe4gsDWDiwnjYKjNs7PS6SGlTDB7maOHF4kxevsuNBl2iOcj3b4A==", + "version": "4.34.6", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.34.6.tgz", + "integrity": "sha512-aK+Zp+CRM55iPrlyKiU3/zyhgzWBxLVrw2mwiQSYJRobCURb781+XstzvA8Gkjg/hbdQFuDw44aUOxVQFycrAg==", "cpu": [ "riscv64" ], @@ -1395,9 +1443,9 @@ ] }, "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.27.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.27.3.tgz", - "integrity": "sha512-d4wVu6SXij/jyiwPvI6C4KxdGzuZOvJ6y9VfrcleHTwo68fl8vZC5ZYHsCVPUi4tndCfMlFniWgwonQ5CUpQcA==", + "version": "4.34.6", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.34.6.tgz", + "integrity": "sha512-WoKLVrY9ogmaYPXwTH326+ErlCIgMmsoRSx6bO+l68YgJnlOXhygDYSZe/qbUJCSiCiZAQ+tKm88NcWuUXqOzw==", "cpu": [ "s390x" ], @@ -1409,9 +1457,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.27.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.27.3.tgz", - "integrity": "sha512-/6bn6pp1fsCGEY5n3yajmzZQAh+mW4QPItbiWxs69zskBzJuheb3tNynEjL+mKOsUSFK11X4LYF2BwwXnzWleA==", + "version": "4.34.6", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.34.6.tgz", + "integrity": "sha512-Sht4aFvmA4ToHd2vFzwMFaQCiYm2lDFho5rPcvPBT5pCdC+GwHG6CMch4GQfmWTQ1SwRKS0dhDYb54khSrjDWw==", "cpu": [ "x64" ], @@ -1423,9 +1471,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.27.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.27.3.tgz", - "integrity": "sha512-nBXOfJds8OzUT1qUreT/en3eyOXd2EH5b0wr2bVB5999qHdGKkzGzIyKYaKj02lXk6wpN71ltLIaQpu58YFBoQ==", + "version": "4.34.6", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.34.6.tgz", + "integrity": "sha512-zmmpOQh8vXc2QITsnCiODCDGXFC8LMi64+/oPpPx5qz3pqv0s6x46ps4xoycfUiVZps5PFn1gksZzo4RGTKT+A==", "cpu": [ "x64" ], @@ -1437,9 +1485,9 @@ ] }, "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.27.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.27.3.tgz", - "integrity": "sha512-ogfbEVQgIZOz5WPWXF2HVb6En+kWzScuxJo/WdQTqEgeyGkaa2ui5sQav9Zkr7bnNCLK48uxmmK0TySm22eiuw==", + "version": "4.34.6", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.34.6.tgz", + "integrity": "sha512-3/q1qUsO/tLqGBaD4uXsB6coVGB3usxw3qyeVb59aArCgedSF66MPdgRStUd7vbZOsko/CgVaY5fo2vkvPLWiA==", "cpu": [ "arm64" ], @@ -1451,9 +1499,9 @@ ] }, "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.27.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.27.3.tgz", - "integrity": "sha512-ecE36ZBMLINqiTtSNQ1vzWc5pXLQHlf/oqGp/bSbi7iedcjcNb6QbCBNG73Euyy2C+l/fn8qKWEwxr+0SSfs3w==", + "version": "4.34.6", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.34.6.tgz", + "integrity": "sha512-oLHxuyywc6efdKVTxvc0135zPrRdtYVjtVD5GUm55I3ODxhU/PwkQFD97z16Xzxa1Fz0AEe4W/2hzRtd+IfpOA==", "cpu": [ "ia32" ], @@ -1465,9 +1513,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.27.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.27.3.tgz", - "integrity": "sha512-vliZLrDmYKyaUoMzEbMTg2JkerfBjn03KmAw9CykO0Zzkzoyd7o3iZNam/TpyWNjNT+Cz2iO3P9Smv2wgrR+Eg==", + "version": "4.34.6", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.34.6.tgz", + "integrity": "sha512-0PVwmgzZ8+TZ9oGBmdZoQVXflbvuwzN/HRclujpl4N/q3i+y0lqLw8n1bXA8ru3sApDjlmONaNAuYr38y1Kr9w==", "cpu": [ "x64" ], @@ -1693,9 +1741,9 @@ } }, "node_modules/@types/pg": { - "version": "8.11.10", - "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.11.10.tgz", - "integrity": "sha512-LczQUW4dbOQzsH2RQ5qoeJ6qJPdrcM/DcMLoqWQkMLMsq83J5lAX3LXjdkWdpscFy67JSOWDnh7Ny/sPFykmkg==", + "version": "8.11.11", + "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.11.11.tgz", + "integrity": "sha512-kGT1qKM8wJQ5qlawUrEkXgvMSXoV213KfMGXcwfDwUIfUHXqXYXOfS1nE1LINRJVVVx5wCm70XnFlMHaIcQAfw==", "dev": true, "license": "MIT", "dependencies": { @@ -1830,21 +1878,21 @@ } }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.20.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.20.0.tgz", - "integrity": "sha512-naduuphVw5StFfqp4Gq4WhIBE2gN1GEmMUExpJYknZJdRnc+2gDzB8Z3+5+/Kv33hPQRDGzQO/0opHE72lZZ6A==", + "version": "8.23.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.23.0.tgz", + "integrity": "sha512-vBz65tJgRrA1Q5gWlRfvoH+w943dq9K1p1yDBY2pc+a1nbBLZp7fB9+Hk8DaALUbzjqlMfgaqlVPT1REJdkt/w==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/regexpp": "^4.10.0", - "@typescript-eslint/scope-manager": "8.20.0", - "@typescript-eslint/type-utils": "8.20.0", - "@typescript-eslint/utils": "8.20.0", - "@typescript-eslint/visitor-keys": "8.20.0", + "@typescript-eslint/scope-manager": "8.23.0", + "@typescript-eslint/type-utils": "8.23.0", + "@typescript-eslint/utils": "8.23.0", + "@typescript-eslint/visitor-keys": "8.23.0", "graphemer": "^1.4.0", "ignore": "^5.3.1", "natural-compare": "^1.4.0", - "ts-api-utils": "^2.0.0" + "ts-api-utils": "^2.0.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1860,16 +1908,16 @@ } }, "node_modules/@typescript-eslint/parser": { - "version": "8.20.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.20.0.tgz", - "integrity": "sha512-gKXG7A5HMyjDIedBi6bUrDcun8GIjnI8qOwVLiY3rx6T/sHP/19XLJOnIq/FgQvWLHja5JN/LSE7eklNBr612g==", + "version": "8.23.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.23.0.tgz", + "integrity": "sha512-h2lUByouOXFAlMec2mILeELUbME5SZRN/7R9Cw2RD2lRQQY08MWMM+PmVVKKJNK1aIwqTo9t/0CvOxwPbRIE2Q==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/scope-manager": "8.20.0", - "@typescript-eslint/types": "8.20.0", - "@typescript-eslint/typescript-estree": "8.20.0", - "@typescript-eslint/visitor-keys": "8.20.0", + "@typescript-eslint/scope-manager": "8.23.0", + "@typescript-eslint/types": "8.23.0", + "@typescript-eslint/typescript-estree": "8.23.0", + "@typescript-eslint/visitor-keys": "8.23.0", "debug": "^4.3.4" }, "engines": { @@ -1885,14 +1933,14 @@ } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.20.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.20.0.tgz", - "integrity": "sha512-J7+VkpeGzhOt3FeG1+SzhiMj9NzGD/M6KoGn9f4dbz3YzK9hvbhVTmLj/HiTp9DazIzJ8B4XcM80LrR9Dm1rJw==", + "version": "8.23.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.23.0.tgz", + "integrity": "sha512-OGqo7+dXHqI7Hfm+WqkZjKjsiRtFUQHPdGMXzk5mYXhJUedO7e/Y7i8AK3MyLMgZR93TX4bIzYrfyVjLC+0VSw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.20.0", - "@typescript-eslint/visitor-keys": "8.20.0" + "@typescript-eslint/types": "8.23.0", + "@typescript-eslint/visitor-keys": "8.23.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1903,16 +1951,16 @@ } }, "node_modules/@typescript-eslint/type-utils": { - "version": "8.20.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.20.0.tgz", - "integrity": "sha512-bPC+j71GGvA7rVNAHAtOjbVXbLN5PkwqMvy1cwGeaxUoRQXVuKCebRoLzm+IPW/NtFFpstn1ummSIasD5t60GA==", + "version": "8.23.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.23.0.tgz", + "integrity": "sha512-iIuLdYpQWZKbiH+RkCGc6iu+VwscP5rCtQ1lyQ7TYuKLrcZoeJVpcLiG8DliXVkUxirW/PWlmS+d6yD51L9jvA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/typescript-estree": "8.20.0", - "@typescript-eslint/utils": "8.20.0", + "@typescript-eslint/typescript-estree": "8.23.0", + "@typescript-eslint/utils": "8.23.0", "debug": "^4.3.4", - "ts-api-utils": "^2.0.0" + "ts-api-utils": "^2.0.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1927,9 +1975,9 @@ } }, "node_modules/@typescript-eslint/types": { - "version": "8.20.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.20.0.tgz", - "integrity": "sha512-cqaMiY72CkP+2xZRrFt3ExRBu0WmVitN/rYPZErA80mHjHx/Svgp8yfbzkJmDoQ/whcytOPO9/IZXnOc+wigRA==", + "version": "8.23.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.23.0.tgz", + "integrity": "sha512-1sK4ILJbCmZOTt9k4vkoulT6/y5CHJ1qUYxqpF1K/DBAd8+ZUL4LlSCxOssuH5m4rUaaN0uS0HlVPvd45zjduQ==", "dev": true, "license": "MIT", "engines": { @@ -1941,20 +1989,20 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.20.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.20.0.tgz", - "integrity": "sha512-Y7ncuy78bJqHI35NwzWol8E0X7XkRVS4K4P4TCyzWkOJih5NDvtoRDW4Ba9YJJoB2igm9yXDdYI/+fkiiAxPzA==", + "version": "8.23.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.23.0.tgz", + "integrity": "sha512-LcqzfipsB8RTvH8FX24W4UUFk1bl+0yTOf9ZA08XngFwMg4Kj8A+9hwz8Cr/ZS4KwHrmo9PJiLZkOt49vPnuvQ==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.20.0", - "@typescript-eslint/visitor-keys": "8.20.0", + "@typescript-eslint/types": "8.23.0", + "@typescript-eslint/visitor-keys": "8.23.0", "debug": "^4.3.4", "fast-glob": "^3.3.2", "is-glob": "^4.0.3", "minimatch": "^9.0.4", "semver": "^7.6.0", - "ts-api-utils": "^2.0.0" + "ts-api-utils": "^2.0.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1994,16 +2042,16 @@ } }, "node_modules/@typescript-eslint/utils": { - "version": "8.20.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.20.0.tgz", - "integrity": "sha512-dq70RUw6UK9ei7vxc4KQtBRk7qkHZv447OUZ6RPQMQl71I3NZxQJX/f32Smr+iqWrB02pHKn2yAdHBb0KNrRMA==", + "version": "8.23.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.23.0.tgz", + "integrity": "sha512-uB/+PSo6Exu02b5ZEiVtmY6RVYO7YU5xqgzTIVZwTHvvK3HsL8tZZHFaTLFtRG3CsV4A5mhOv+NZx5BlhXPyIA==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.4.0", - "@typescript-eslint/scope-manager": "8.20.0", - "@typescript-eslint/types": "8.20.0", - "@typescript-eslint/typescript-estree": "8.20.0" + "@typescript-eslint/scope-manager": "8.23.0", + "@typescript-eslint/types": "8.23.0", + "@typescript-eslint/typescript-estree": "8.23.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -2018,13 +2066,13 @@ } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.20.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.20.0.tgz", - "integrity": "sha512-v/BpkeeYAsPkKCkR8BDwcno0llhzWVqPOamQrAEMdpZav2Y9OVjd9dwJyBLJWwf335B5DmlifECIkZRJCaGaHA==", + "version": "8.23.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.23.0.tgz", + "integrity": "sha512-oWWhcWDLwDfu++BGTZcmXWqpwtkwb5o7fxUIGksMQQDSdPW9prsSnfIOZMlsj4vBOSrcnjIUZMiIjODgGosFhQ==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.20.0", + "@typescript-eslint/types": "8.23.0", "eslint-visitor-keys": "^4.2.0" }, "engines": { @@ -2049,9 +2097,9 @@ } }, "node_modules/@vitest/coverage-v8": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-3.0.3.tgz", - "integrity": "sha512-uVbJ/xhImdNtzPnLyxCZJMTeTIYdgcC2nWtBBBpR1H6z0w8m7D+9/zrDIx2nNxgMg9r+X8+RY2qVpUDeW2b3nw==", + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-3.0.5.tgz", + "integrity": "sha512-zOOWIsj5fHh3jjGwQg+P+J1FW3s4jBu1Zqga0qW60yutsBtqEqNEJKWYh7cYn1yGD+1bdPsPdC/eL4eVK56xMg==", "dev": true, "license": "MIT", "dependencies": { @@ -2072,8 +2120,8 @@ "url": "https://opencollective.com/vitest" }, "peerDependencies": { - "@vitest/browser": "3.0.3", - "vitest": "3.0.3" + "@vitest/browser": "3.0.5", + "vitest": "3.0.5" }, "peerDependenciesMeta": { "@vitest/browser": { @@ -2100,14 +2148,14 @@ } }, "node_modules/@vitest/expect": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.0.3.tgz", - "integrity": "sha512-SbRCHU4qr91xguu+dH3RUdI5dC86zm8aZWydbp961aIR7G8OYNN6ZiayFuf9WAngRbFOfdrLHCGgXTj3GtoMRQ==", + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.0.5.tgz", + "integrity": "sha512-nNIOqupgZ4v5jWuQx2DSlHLEs7Q4Oh/7AYwNyE+k0UQzG7tSmjPXShUikn1mpNGzYEN2jJbTvLejwShMitovBA==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/spy": "3.0.3", - "@vitest/utils": "3.0.3", + "@vitest/spy": "3.0.5", + "@vitest/utils": "3.0.5", "chai": "^5.1.2", "tinyrainbow": "^2.0.0" }, @@ -2116,13 +2164,13 @@ } }, "node_modules/@vitest/mocker": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.0.3.tgz", - "integrity": "sha512-XT2XBc4AN9UdaxJAeIlcSZ0ILi/GzmG5G8XSly4gaiqIvPV3HMTSIDZWJVX6QRJ0PX1m+W8Cy0K9ByXNb/bPIA==", + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.0.5.tgz", + "integrity": "sha512-CLPNBFBIE7x6aEGbIjaQAX03ZZlBMaWwAjBdMkIf/cAn6xzLTiM3zYqO/WAbieEjsAZir6tO71mzeHZoodThvw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/spy": "3.0.3", + "@vitest/spy": "3.0.5", "estree-walker": "^3.0.3", "magic-string": "^0.30.17" }, @@ -2143,9 +2191,9 @@ } }, "node_modules/@vitest/pretty-format": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.0.3.tgz", - "integrity": "sha512-gCrM9F7STYdsDoNjGgYXKPq4SkSxwwIU5nkaQvdUxiQ0EcNlez+PdKOVIsUJvh9P9IeIFmjn4IIREWblOBpP2Q==", + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.0.5.tgz", + "integrity": "sha512-CjUtdmpOcm4RVtB+up8r2vVDLR16Mgm/bYdkGFe3Yj/scRfCpbSi2W/BDSDcFK7ohw8UXvjMbOp9H4fByd/cOA==", "dev": true, "license": "MIT", "dependencies": { @@ -2156,38 +2204,38 @@ } }, "node_modules/@vitest/runner": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.0.3.tgz", - "integrity": "sha512-Rgi2kOAk5ZxWZlwPguRJFOBmWs6uvvyAAR9k3MvjRvYrG7xYvKChZcmnnpJCS98311CBDMqsW9MzzRFsj2gX3g==", + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.0.5.tgz", + "integrity": "sha512-BAiZFityFexZQi2yN4OX3OkJC6scwRo8EhRB0Z5HIGGgd2q+Nq29LgHU/+ovCtd0fOfXj5ZI6pwdlUmC5bpi8A==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/utils": "3.0.3", - "pathe": "^2.0.1" + "@vitest/utils": "3.0.5", + "pathe": "^2.0.2" }, "funding": { "url": "https://opencollective.com/vitest" } }, "node_modules/@vitest/snapshot": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.0.3.tgz", - "integrity": "sha512-kNRcHlI4txBGztuJfPEJ68VezlPAXLRT1u5UCx219TU3kOG2DplNxhWLwDf2h6emwmTPogzLnGVwP6epDaJN6Q==", + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.0.5.tgz", + "integrity": "sha512-GJPZYcd7v8QNUJ7vRvLDmRwl+a1fGg4T/54lZXe+UOGy47F9yUfE18hRCtXL5aHN/AONu29NGzIXSVFh9K0feA==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "3.0.3", + "@vitest/pretty-format": "3.0.5", "magic-string": "^0.30.17", - "pathe": "^2.0.1" + "pathe": "^2.0.2" }, "funding": { "url": "https://opencollective.com/vitest" } }, "node_modules/@vitest/spy": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.0.3.tgz", - "integrity": "sha512-7/dgux8ZBbF7lEIKNnEqQlyRaER9nkAL9eTmdKJkDO3hS8p59ATGwKOCUDHcBLKr7h/oi/6hP+7djQk8049T2A==", + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.0.5.tgz", + "integrity": "sha512-5fOzHj0WbUNqPK6blI/8VzZdkBlQLnT25knX0r4dbZI9qoZDf3qAdjoMmDcLG5A83W6oUUFJgUd0EYBc2P5xqg==", "dev": true, "license": "MIT", "dependencies": { @@ -2198,13 +2246,13 @@ } }, "node_modules/@vitest/utils": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.0.3.tgz", - "integrity": "sha512-f+s8CvyzPtMFY1eZKkIHGhPsQgYo5qCm6O8KZoim9qm1/jT64qBgGpO5tHscNH6BzRHM+edLNOP+3vO8+8pE/A==", + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.0.5.tgz", + "integrity": "sha512-N9AX0NUoUtVwKwy21JtwzaqR5L5R5A99GAbrHfCCXK1lp593i/3AZAXhSP43wRQuxYsflrdzEfXZFo1reR1Nkg==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "3.0.3", + "@vitest/pretty-format": "3.0.5", "loupe": "^3.1.2", "tinyrainbow": "^2.0.0" }, @@ -3036,9 +3084,9 @@ "license": "MIT" }, "node_modules/esbuild": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", - "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.24.2.tgz", + "integrity": "sha512-+9egpBW8I3CD5XPe0n6BfT5fxLzxrlDzqydF3aviG+9ni1lDC/OvMHcxqEFV0+LANZG5R1bFMWfUrjVsdwxJvA==", "dev": true, "hasInstallScript": true, "license": "MIT", @@ -3046,32 +3094,34 @@ "esbuild": "bin/esbuild" }, "engines": { - "node": ">=12" + "node": ">=18" }, "optionalDependencies": { - "@esbuild/aix-ppc64": "0.21.5", - "@esbuild/android-arm": "0.21.5", - "@esbuild/android-arm64": "0.21.5", - "@esbuild/android-x64": "0.21.5", - "@esbuild/darwin-arm64": "0.21.5", - "@esbuild/darwin-x64": "0.21.5", - "@esbuild/freebsd-arm64": "0.21.5", - "@esbuild/freebsd-x64": "0.21.5", - "@esbuild/linux-arm": "0.21.5", - "@esbuild/linux-arm64": "0.21.5", - "@esbuild/linux-ia32": "0.21.5", - "@esbuild/linux-loong64": "0.21.5", - "@esbuild/linux-mips64el": "0.21.5", - "@esbuild/linux-ppc64": "0.21.5", - "@esbuild/linux-riscv64": "0.21.5", - "@esbuild/linux-s390x": "0.21.5", - "@esbuild/linux-x64": "0.21.5", - "@esbuild/netbsd-x64": "0.21.5", - "@esbuild/openbsd-x64": "0.21.5", - "@esbuild/sunos-x64": "0.21.5", - "@esbuild/win32-arm64": "0.21.5", - "@esbuild/win32-ia32": "0.21.5", - "@esbuild/win32-x64": "0.21.5" + "@esbuild/aix-ppc64": "0.24.2", + "@esbuild/android-arm": "0.24.2", + "@esbuild/android-arm64": "0.24.2", + "@esbuild/android-x64": "0.24.2", + "@esbuild/darwin-arm64": "0.24.2", + "@esbuild/darwin-x64": "0.24.2", + "@esbuild/freebsd-arm64": "0.24.2", + "@esbuild/freebsd-x64": "0.24.2", + "@esbuild/linux-arm": "0.24.2", + "@esbuild/linux-arm64": "0.24.2", + "@esbuild/linux-ia32": "0.24.2", + "@esbuild/linux-loong64": "0.24.2", + "@esbuild/linux-mips64el": "0.24.2", + "@esbuild/linux-ppc64": "0.24.2", + "@esbuild/linux-riscv64": "0.24.2", + "@esbuild/linux-s390x": "0.24.2", + "@esbuild/linux-x64": "0.24.2", + "@esbuild/netbsd-arm64": "0.24.2", + "@esbuild/netbsd-x64": "0.24.2", + "@esbuild/openbsd-arm64": "0.24.2", + "@esbuild/openbsd-x64": "0.24.2", + "@esbuild/sunos-x64": "0.24.2", + "@esbuild/win32-arm64": "0.24.2", + "@esbuild/win32-ia32": "0.24.2", + "@esbuild/win32-x64": "0.24.2" } }, "node_modules/escalade": { @@ -3103,9 +3153,9 @@ } }, "node_modules/eslint": { - "version": "9.18.0", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.18.0.tgz", - "integrity": "sha512-+waTfRWQlSbpt3KWE+CjrPPYnbq9kfZIYUqapc0uBXyjTp8aYXZDsUH16m39Ryq3NjAVP4tjuF7KaukeqoCoaA==", + "version": "9.19.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.19.0.tgz", + "integrity": "sha512-ug92j0LepKlbbEv6hD911THhoRHmbdXt2gX+VDABAW/Ir7D3nqKdv5Pf5vtlyY6HQMTEP2skXY43ueqTCWssEA==", "dev": true, "license": "MIT", "dependencies": { @@ -3114,7 +3164,7 @@ "@eslint/config-array": "^0.19.0", "@eslint/core": "^0.10.0", "@eslint/eslintrc": "^3.2.0", - "@eslint/js": "9.18.0", + "@eslint/js": "9.19.0", "@eslint/plugin-kit": "^0.2.5", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", @@ -3176,9 +3226,9 @@ } }, "node_modules/eslint-plugin-prettier": { - "version": "5.2.2", - "resolved": "https://registry.npmjs.org/eslint-plugin-prettier/-/eslint-plugin-prettier-5.2.2.tgz", - "integrity": "sha512-1yI3/hf35wmlq66C8yOyrujQnel+v5l1Vop5Cl2I6ylyNTT1JbuUUnV3/41PzwTzcyDp/oF0jWE3HXvcH5AQOQ==", + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/eslint-plugin-prettier/-/eslint-plugin-prettier-5.2.3.tgz", + "integrity": "sha512-qJ+y0FfCp/mQYQ/vWQ3s7eUlFEL4PyKfAJxsnYTJ4YT73nsJBWqmEpFryxV9OeUiqmsTsYJ5Y+KDNaeP31wrRw==", "dev": true, "license": "MIT", "dependencies": { @@ -3491,9 +3541,9 @@ "dev": true }, "node_modules/fastq": { - "version": "1.18.0", - "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.18.0.tgz", - "integrity": "sha512-QKHXPW0hD8g4UET03SdOdunzSouc9N4AuHdsX8XNcTsuz+yYFILVNIX4l9yHABMhiEI9Db0JTTIpu0wB+Y1QQw==", + "version": "1.19.0", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.0.tgz", + "integrity": "sha512-7SFSRCNjBQIZH/xZR3iy5iQYR8aGBE0h3VG6/cwlbrpdciNYBMotQav8c1XI3HjHH+NikUpP53nPdlZSdWmFzA==", "dev": true, "license": "ISC", "dependencies": { @@ -4292,10 +4342,11 @@ } }, "node_modules/jsesc": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.0.2.tgz", - "integrity": "sha512-xKqzzWXDttJuOcawBt4KnKHHIf5oQ/Cxax+0PWFG+DFDgHNAdi+TXECADI+RYiFUMmx8792xsMbbgXj4CwnP4g==", + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", "dev": true, + "license": "MIT", "bin": { "jsesc": "bin/jsesc" }, @@ -4476,9 +4527,9 @@ "dev": true }, "node_modules/loupe": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.1.2.tgz", - "integrity": "sha512-23I4pFZHmAemUnz8WZXbYRSKYj801VDaNv9ETuMh7IrMc7VuVVSo+Z9iLE3ni30+U48iDWfi30d3twAXBYmnCg==", + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.1.3.tgz", + "integrity": "sha512-kkIp7XSkP78ZxJEsSxW3712C6teJVoeHHwgo9zJ380de7IYyJ2ISlxojcH2pC5OFLewESmnRi/+XCDIEEVyoug==", "dev": true, "license": "MIT" }, @@ -4709,9 +4760,9 @@ "license": "MIT" }, "node_modules/nanoid": { - "version": "5.0.8", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.0.8.tgz", - "integrity": "sha512-TcJPw+9RV9dibz1hHUzlLVy8N4X9TnwirAjrU08Juo6BNKggzVfP2ZJ/3ZUSq15Xl5i85i+Z89XBO90pB2PghQ==", + "version": "5.0.9", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.0.9.tgz", + "integrity": "sha512-Aooyr6MXU6HpvvWXKoVoXwKMs/KyVakWwg7xQfv5/S/RIgJMy0Ifa45H9qqYy7pTCszrHzP21Uk4PZq2HpEM8Q==", "dev": true, "funding": [ { @@ -4869,21 +4920,21 @@ "dev": true }, "node_modules/oidc-provider": { - "version": "8.6.0", - "resolved": "https://registry.npmjs.org/oidc-provider/-/oidc-provider-8.6.0.tgz", - "integrity": "sha512-LTzQza+KA72fFWe/70ttjTpCPvwZRoaydPFY2izNfQjo6u33lFOzJeqA9Q0TblTShkaH56ChoE2KdMYIQlNHdw==", + "version": "8.6.1", + "resolved": "https://registry.npmjs.org/oidc-provider/-/oidc-provider-8.6.1.tgz", + "integrity": "sha512-wJ+nhwkCjRtQiwJKACjjV8FAIn7QXGDc1UOAE5WW0i8fsqN1GgXi42S/ccOxEx/JV3tyVLEwIipAvJNsJ/3djA==", "dev": true, "license": "MIT", "dependencies": { "@koa/cors": "^5.0.0", "@koa/router": "^13.1.0", - "debug": "^4.3.7", + "debug": "^4.4.0", "eta": "^3.5.0", "got": "^13.0.0", "jose": "^5.9.6", - "jsesc": "^3.0.2", + "jsesc": "^3.1.0", "koa": "^2.15.3", - "nanoid": "^5.0.8", + "nanoid": "^5.0.9", "object-hash": "^3.0.0", "oidc-token-hash": "^5.0.3", "quick-lru": "^7.0.0", @@ -4893,6 +4944,24 @@ "url": "https://github.com/sponsors/panva" } }, + "node_modules/oidc-provider/node_modules/debug": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz", + "integrity": "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, "node_modules/oidc-token-hash": { "version": "5.0.3", "resolved": "https://registry.npmjs.org/oidc-token-hash/-/oidc-token-hash-5.0.3.tgz", @@ -5246,13 +5315,13 @@ } }, "node_modules/playwright": { - "version": "1.49.1", - "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.49.1.tgz", - "integrity": "sha512-VYL8zLoNTBxVOrJBbDuRgDWa3i+mfQgDTrL8Ah9QXZ7ax4Dsj0MSq5bYgytRnDVVe+njoKnfsYkH3HzqVj5UZA==", + "version": "1.50.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.50.1.tgz", + "integrity": "sha512-G8rwsOQJ63XG6BbKj2w5rHeavFjy5zynBA9zsJMMtBoe/Uf757oG12NXz6e6OirF7RCrTVAKFXbLmn1RbL7Qaw==", "dev": true, "license": "Apache-2.0", "dependencies": { - "playwright-core": "1.49.1" + "playwright-core": "1.50.1" }, "bin": { "playwright": "cli.js" @@ -5265,9 +5334,9 @@ } }, "node_modules/playwright-core": { - "version": "1.49.1", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.49.1.tgz", - "integrity": "sha512-BzmpVcs4kE2CH15rWfzpjzVGhWERJfmnXmniSyKeRZUs9Ws65m+RGIi7mjJK/euCegfn3i7jvqWeWyHe9y3Vgg==", + "version": "1.50.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.50.1.tgz", + "integrity": "sha512-ra9fsNWayuYumt+NiM069M6OkcRb1FZSK8bgi66AtpFoWkg2+y0bJSNmkFrWhMbEBbVKC/EruAHH3g0zmtwGmQ==", "dev": true, "license": "Apache-2.0", "bin": { @@ -5296,9 +5365,9 @@ } }, "node_modules/postcss": { - "version": "8.4.49", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.49.tgz", - "integrity": "sha512-OCVPnIObs4N29kxTjzLfUryOkvZEq+pf8jTF0lg8E7uETuWHA+v7j3c/xJmiqpX450191LlmZfUKkXxkTry7nA==", + "version": "8.5.2", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.2.tgz", + "integrity": "sha512-MjOadfU3Ys9KYoX0AdkBlFEF1Vx37uCCeN4ZHnmwm9FfpbsGWMZeBLMmmpY+6Ocqod7mkdZ0DT31OlbsFrLlkA==", "dev": true, "funding": [ { @@ -5316,7 +5385,7 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.7", + "nanoid": "^3.3.8", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, @@ -5325,9 +5394,9 @@ } }, "node_modules/postcss/node_modules/nanoid": { - "version": "3.3.7", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.7.tgz", - "integrity": "sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g==", + "version": "3.3.8", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.8.tgz", + "integrity": "sha512-WNLf5Sd8oZxOm+TzppcYk8gVOgP+l58xNy58D0nbUnOxOWRWvlcCV4kUF7ltmI6PsrLl/BgKEyS4mqsGChFN0w==", "dev": true, "funding": [ { @@ -5735,9 +5804,9 @@ } }, "node_modules/rollup": { - "version": "4.27.3", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.27.3.tgz", - "integrity": "sha512-SLsCOnlmGt9VoZ9Ek8yBK8tAdmPHeppkw+Xa7yDlCEhDTvwYei03JlWo1fdc7YTfLZ4tD8riJCUyAgTbszk1fQ==", + "version": "4.34.6", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.34.6.tgz", + "integrity": "sha512-wc2cBWqJgkU3Iz5oztRkQbfVkbxoz5EhnCGOrnJvnLnQ7O0WhQUYyv18qQI79O8L7DdHrrlJNeCHd4VGpnaXKQ==", "dev": true, "license": "MIT", "dependencies": { @@ -5751,24 +5820,25 @@ "npm": ">=8.0.0" }, "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.27.3", - "@rollup/rollup-android-arm64": "4.27.3", - "@rollup/rollup-darwin-arm64": "4.27.3", - "@rollup/rollup-darwin-x64": "4.27.3", - "@rollup/rollup-freebsd-arm64": "4.27.3", - "@rollup/rollup-freebsd-x64": "4.27.3", - "@rollup/rollup-linux-arm-gnueabihf": "4.27.3", - "@rollup/rollup-linux-arm-musleabihf": "4.27.3", - "@rollup/rollup-linux-arm64-gnu": "4.27.3", - "@rollup/rollup-linux-arm64-musl": "4.27.3", - "@rollup/rollup-linux-powerpc64le-gnu": "4.27.3", - "@rollup/rollup-linux-riscv64-gnu": "4.27.3", - "@rollup/rollup-linux-s390x-gnu": "4.27.3", - "@rollup/rollup-linux-x64-gnu": "4.27.3", - "@rollup/rollup-linux-x64-musl": "4.27.3", - "@rollup/rollup-win32-arm64-msvc": "4.27.3", - "@rollup/rollup-win32-ia32-msvc": "4.27.3", - "@rollup/rollup-win32-x64-msvc": "4.27.3", + "@rollup/rollup-android-arm-eabi": "4.34.6", + "@rollup/rollup-android-arm64": "4.34.6", + "@rollup/rollup-darwin-arm64": "4.34.6", + "@rollup/rollup-darwin-x64": "4.34.6", + "@rollup/rollup-freebsd-arm64": "4.34.6", + "@rollup/rollup-freebsd-x64": "4.34.6", + "@rollup/rollup-linux-arm-gnueabihf": "4.34.6", + "@rollup/rollup-linux-arm-musleabihf": "4.34.6", + "@rollup/rollup-linux-arm64-gnu": "4.34.6", + "@rollup/rollup-linux-arm64-musl": "4.34.6", + "@rollup/rollup-linux-loongarch64-gnu": "4.34.6", + "@rollup/rollup-linux-powerpc64le-gnu": "4.34.6", + "@rollup/rollup-linux-riscv64-gnu": "4.34.6", + "@rollup/rollup-linux-s390x-gnu": "4.34.6", + "@rollup/rollup-linux-x64-gnu": "4.34.6", + "@rollup/rollup-linux-x64-musl": "4.34.6", + "@rollup/rollup-win32-arm64-msvc": "4.34.6", + "@rollup/rollup-win32-ia32-msvc": "4.34.6", + "@rollup/rollup-win32-x64-msvc": "4.34.6", "fsevents": "~2.3.2" } }, @@ -6343,9 +6413,9 @@ "dev": true }, "node_modules/ts-api-utils": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.0.0.tgz", - "integrity": "sha512-xCt/TOAc+EOHS1XPnijD3/yzpH6qg2xppZO1YDqGoVsNXfQfzHpOdNuXwrwOU8u4ITXJyDCTyt8w5g1sZv9ynQ==", + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.0.1.tgz", + "integrity": "sha512-dnlgjFSVetynI8nzgJ+qF62efpglpWRk8isUEWZGWlJYySCTD6aKvbUDu+zbPeDakk3bg5H4XpitHukgfL1m9w==", "dev": true, "license": "MIT", "engines": { @@ -6505,21 +6575,21 @@ } }, "node_modules/vite": { - "version": "5.4.11", - "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.11.tgz", - "integrity": "sha512-c7jFQRklXua0mTzneGW9QVyxFjUgwcihC4bXEtujIo2ouWCe1Ajt/amn2PCxYnhYfd5k09JX3SB7OYWFKYqj8Q==", + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.1.0.tgz", + "integrity": "sha512-RjjMipCKVoR4hVfPY6GQTgveinjNuyLw+qruksLDvA5ktI1150VmcMBKmQaEWJhg/j6Uaf6dNCNA0AfdzUb/hQ==", "dev": true, "license": "MIT", "dependencies": { - "esbuild": "^0.21.3", - "postcss": "^8.4.43", - "rollup": "^4.20.0" + "esbuild": "^0.24.2", + "postcss": "^8.5.1", + "rollup": "^4.30.1" }, "bin": { "vite": "bin/vite.js" }, "engines": { - "node": "^18.0.0 || >=20.0.0" + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" }, "funding": { "url": "https://github.com/vitejs/vite?sponsor=1" @@ -6528,19 +6598,25 @@ "fsevents": "~2.3.3" }, "peerDependencies": { - "@types/node": "^18.0.0 || >=20.0.0", + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", - "terser": "^5.4.0" + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" }, "peerDependenciesMeta": { "@types/node": { "optional": true }, + "jiti": { + "optional": true + }, "less": { "optional": true }, @@ -6561,20 +6637,26 @@ }, "terser": { "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true } } }, "node_modules/vite-node": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.0.3.tgz", - "integrity": "sha512-0sQcwhwAEw/UJGojbhOrnq3HtiZ3tC7BzpAa0lx3QaTX0S3YX70iGcik25UBdB96pmdwjyY2uyKNYruxCDmiEg==", + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.0.5.tgz", + "integrity": "sha512-02JEJl7SbtwSDJdYS537nU6l+ktdvcREfLksk/NDAqtdKWGqHl+joXzEubHROmS3E6pip+Xgu2tFezMu75jH7A==", "dev": true, "license": "MIT", "dependencies": { "cac": "^6.7.14", "debug": "^4.4.0", "es-module-lexer": "^1.6.0", - "pathe": "^2.0.1", + "pathe": "^2.0.2", "vite": "^5.0.0 || ^6.0.0" }, "bin": { @@ -6621,31 +6703,31 @@ } }, "node_modules/vitest": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.0.3.tgz", - "integrity": "sha512-dWdwTFUW9rcnL0LyF2F+IfvNQWB0w9DERySCk8VMG75F8k25C7LsZoh6XfCjPvcR8Nb+Lqi9JKr6vnzH7HSrpQ==", + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.0.5.tgz", + "integrity": "sha512-4dof+HvqONw9bvsYxtkfUp2uHsTN9bV2CZIi1pWgoFpL1Lld8LA1ka9q/ONSsoScAKG7NVGf2stJTI7XRkXb2Q==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/expect": "3.0.3", - "@vitest/mocker": "3.0.3", - "@vitest/pretty-format": "^3.0.3", - "@vitest/runner": "3.0.3", - "@vitest/snapshot": "3.0.3", - "@vitest/spy": "3.0.3", - "@vitest/utils": "3.0.3", + "@vitest/expect": "3.0.5", + "@vitest/mocker": "3.0.5", + "@vitest/pretty-format": "^3.0.5", + "@vitest/runner": "3.0.5", + "@vitest/snapshot": "3.0.5", + "@vitest/spy": "3.0.5", + "@vitest/utils": "3.0.5", "chai": "^5.1.2", "debug": "^4.4.0", "expect-type": "^1.1.0", "magic-string": "^0.30.17", - "pathe": "^2.0.1", + "pathe": "^2.0.2", "std-env": "^3.8.0", "tinybench": "^2.9.0", "tinyexec": "^0.3.2", "tinypool": "^1.0.2", "tinyrainbow": "^2.0.0", "vite": "^5.0.0 || ^6.0.0", - "vite-node": "3.0.3", + "vite-node": "3.0.5", "why-is-node-running": "^2.3.0" }, "bin": { @@ -6659,9 +6741,10 @@ }, "peerDependencies": { "@edge-runtime/vm": "*", + "@types/debug": "^4.1.12", "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", - "@vitest/browser": "3.0.3", - "@vitest/ui": "3.0.3", + "@vitest/browser": "3.0.5", + "@vitest/ui": "3.0.5", "happy-dom": "*", "jsdom": "*" }, @@ -6669,6 +6752,9 @@ "@edge-runtime/vm": { "optional": true }, + "@types/debug": { + "optional": true + }, "@types/node": { "optional": true }, diff --git a/server/package-lock.json b/server/package-lock.json index a917ba7f7c..9c10d2d1c7 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -19,7 +19,7 @@ "@nestjs/swagger": "^11.0.2", "@nestjs/typeorm": "^11.0.0", "@nestjs/websockets": "^11.0.4", - "@opentelemetry/auto-instrumentations-node": "^0.55.0", + "@opentelemetry/auto-instrumentations-node": "^0.56.0", "@opentelemetry/context-async-hooks": "^1.24.0", "@opentelemetry/exporter-prometheus": "^0.57.0", "@opentelemetry/sdk-node": "^0.57.0", @@ -1178,9 +1178,9 @@ } }, "node_modules/@eslint/js": { - "version": "9.18.0", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.18.0.tgz", - "integrity": "sha512-fK6L7rxcq6/z+AaQMtiFTkvbHkBLNlwyRxHpKawP0x3u9+NC6MQTnFW+AdpwC6gfHTW0051cokQgtTN2FqlxQA==", + "version": "9.19.0", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.19.0.tgz", + "integrity": "sha512-rbq9/g38qjfqFLOVPvwjIvFFdNziEC5S65jmjPw5r6A//QH+W91akh9irMwjDN8zKUTak6W9EsAv4m/7Wnw0UQ==", "dev": true, "license": "MIT", "engines": { @@ -2310,9 +2310,9 @@ ] }, "node_modules/@nestjs/bull-shared": { - "version": "11.0.1", - "resolved": "https://registry.npmjs.org/@nestjs/bull-shared/-/bull-shared-11.0.1.tgz", - "integrity": "sha512-FqpmIjhCONaYo+5AjtggPdo2lRIM/fv1VHiEq7YwFZBTNSPW0eOvcT96JDb5q4OuvLvADxgpnwP7rmzZywMMiw==", + "version": "11.0.2", + "resolved": "https://registry.npmjs.org/@nestjs/bull-shared/-/bull-shared-11.0.2.tgz", + "integrity": "sha512-dFlttJvBqIFD6M8JVFbkrR4Feb39OTAJPJpFVILU50NOJCM4qziRw3dSNG84Q3v+7/M6xUGMFdZRRGvBBKxoSA==", "license": "MIT", "dependencies": { "tslib": "2.8.1" @@ -2323,12 +2323,12 @@ } }, "node_modules/@nestjs/bullmq": { - "version": "11.0.1", - "resolved": "https://registry.npmjs.org/@nestjs/bullmq/-/bullmq-11.0.1.tgz", - "integrity": "sha512-BntU0Zfiyk4R5hlasUV22n1HuqmWWKvsx3knSR5A9/5vce808pmHOmHrtm4GZDs/8Pw9X8UGY8zdLe4a36S6KQ==", + "version": "11.0.2", + "resolved": "https://registry.npmjs.org/@nestjs/bullmq/-/bullmq-11.0.2.tgz", + "integrity": "sha512-Lq6lGpKkETsm0RDcUktlzsthFoE3A5QTMp2FwPi1eztKqKD6/90KS1TcnC9CJFzjpUaYnQzIMrlNs55e+/wsHA==", "license": "MIT", "dependencies": { - "@nestjs/bull-shared": "^11.0.1", + "@nestjs/bull-shared": "^11.0.2", "tslib": "2.8.1" }, "peerDependencies": { @@ -2577,9 +2577,9 @@ } }, "node_modules/@nestjs/common": { - "version": "11.0.6", - "resolved": "https://registry.npmjs.org/@nestjs/common/-/common-11.0.6.tgz", - "integrity": "sha512-j+M3WOU6loZPNirIHDiZ1LxXRXVNb62XicgLBqdgyrDBFCJrAZaq0lfERUEPlN0/j4GBFnTSPg+CNsoGTBW1zQ==", + "version": "11.0.8", + "resolved": "https://registry.npmjs.org/@nestjs/common/-/common-11.0.8.tgz", + "integrity": "sha512-IB6wEl8RgT/vWzb6p3cmBwTY3R0qfQWvO8lW0PfIv4DTJfUiVqNNEikonGuH/6TX8KvRXNhXHCaQZrUN00Xe6g==", "license": "MIT", "dependencies": { "iterare": "1.2.1", @@ -2606,9 +2606,9 @@ } }, "node_modules/@nestjs/core": { - "version": "11.0.6", - "resolved": "https://registry.npmjs.org/@nestjs/core/-/core-11.0.6.tgz", - "integrity": "sha512-Xf33bwc3waAJ/faJBW06+Dwq3m15p3wbFOc/CcK8ua5EZna4sMjIjXXAb6bQmEjR1KfTXV5z595UD2vwp6cyHg==", + "version": "11.0.8", + "resolved": "https://registry.npmjs.org/@nestjs/core/-/core-11.0.8.tgz", + "integrity": "sha512-GQLLdZnjZOmV4Q+TzQ8YuHvEYOneRhzsDbSJRkKdFFAVuoVh+q1nWZy+bZNeTxdWZFGL2Rve70X5jc4MoSXJqQ==", "hasInstallScript": true, "license": "MIT", "dependencies": { @@ -2680,9 +2680,9 @@ } }, "node_modules/@nestjs/platform-express": { - "version": "11.0.6", - "resolved": "https://registry.npmjs.org/@nestjs/platform-express/-/platform-express-11.0.6.tgz", - "integrity": "sha512-fP6vrpqDIBaf1FNfFtBeJm/BwtGtueatI4FHxaBgw93XxKmIOV4G4ZO7ouQKqfgyIxV2mkYr/Fhg7hwRmizIjw==", + "version": "11.0.8", + "resolved": "https://registry.npmjs.org/@nestjs/platform-express/-/platform-express-11.0.8.tgz", + "integrity": "sha512-Ru7seOYYglKNGQFzNALE5ilLqkdtX/ge6AJDKLMt+WI7iElZ7lXjT40fE3+HVUiZODunmeKQ7jVxcQyZwLafVA==", "license": "MIT", "dependencies": { "cors": "2.8.5", @@ -2701,9 +2701,9 @@ } }, "node_modules/@nestjs/platform-socket.io": { - "version": "11.0.6", - "resolved": "https://registry.npmjs.org/@nestjs/platform-socket.io/-/platform-socket.io-11.0.6.tgz", - "integrity": "sha512-80r6hp0p+YK/+D5srjPTE/fc1w0aTM6ecqprJr8bFlzTPJbtYtpfHqMbEg0UfRKTsZ0krEgpv8fb0K4dSYzW0w==", + "version": "11.0.8", + "resolved": "https://registry.npmjs.org/@nestjs/platform-socket.io/-/platform-socket.io-11.0.8.tgz", + "integrity": "sha512-DUpfRSDgxu+z9czB6ddFdQFawSAIr7jEbNOvpjpjYErvDitUdos57FhTw9IJxIm2EAOHoiCk4g3tN59GfjdwfQ==", "license": "MIT", "dependencies": { "socket.io": "4.8.1", @@ -2720,9 +2720,9 @@ } }, "node_modules/@nestjs/schedule": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/@nestjs/schedule/-/schedule-5.0.0.tgz", - "integrity": "sha512-RHqJIOo3AQvdeq0WuIFDqa5N0CkgxgqwmWRla96S6GmFV6qkQD1//EeH4k19MeCu4Ac9PzZ2y/Hu0zK9f//BQg==", + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/@nestjs/schedule/-/schedule-5.0.1.tgz", + "integrity": "sha512-kFoel84I4RyS2LNPH6yHYTKxB16tb3auAEciFuc788C3ph6nABkUfzX5IE+unjVaRX+3GuruJwurNepMlHXpQg==", "license": "MIT", "dependencies": { "cron": "3.5.0" @@ -2916,9 +2916,9 @@ } }, "node_modules/@nestjs/testing": { - "version": "11.0.6", - "resolved": "https://registry.npmjs.org/@nestjs/testing/-/testing-11.0.6.tgz", - "integrity": "sha512-RZDWdnOncOQ1vT3630VlRzKee2P21ZJoF1+NAY+nzYUuYuYAaBdjrTZQGwymmiZQcrM+TQaViSjSPUmcJXdKyA==", + "version": "11.0.8", + "resolved": "https://registry.npmjs.org/@nestjs/testing/-/testing-11.0.8.tgz", + "integrity": "sha512-5Reqec4MQSm4nFasKE5Pd799cAx3MmjkweF17Wgj/EJNWhFgVdv6N9OUIWXbU8nc8Pjso1fJmv0KJyN6h51qOA==", "dev": true, "license": "MIT", "dependencies": { @@ -2957,9 +2957,9 @@ } }, "node_modules/@nestjs/websockets": { - "version": "11.0.6", - "resolved": "https://registry.npmjs.org/@nestjs/websockets/-/websockets-11.0.6.tgz", - "integrity": "sha512-bsYZnmIXmZTYWzLJ3LbusH72kdppKSQLKUe9cfuUPXuTF+FaAX6TvErP11dJJ0W0S1Iwufr+LERTK211Txs8Eg==", + "version": "11.0.8", + "resolved": "https://registry.npmjs.org/@nestjs/websockets/-/websockets-11.0.8.tgz", + "integrity": "sha512-wyS512+QWhWhE8NU1DgbAPkCaaOSNK1xBIgRlgpYg5/tKuhu4lc5r8iMdZAQn6xay++ELlOqsSlyuj0J2BixOA==", "license": "MIT", "dependencies": { "iterare": "1.2.1", @@ -3186,9 +3186,9 @@ } }, "node_modules/@opentelemetry/auto-instrumentations-node": { - "version": "0.55.3", - "resolved": "https://registry.npmjs.org/@opentelemetry/auto-instrumentations-node/-/auto-instrumentations-node-0.55.3.tgz", - "integrity": "sha512-tX5k3ZG8Nk6f1DHAF0K1ClP/OiW2hNuSeCVqDHNMcJ58dZSiad0XO2mwrvSipo77/DPXXUl0j9MxqmUVITdujQ==", + "version": "0.56.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/auto-instrumentations-node/-/auto-instrumentations-node-0.56.0.tgz", + "integrity": "sha512-d1X3DQY0+VmhNUir/3U3JO6Uh0FOSm8G91zsPzVVKc6NGDwmHP6Dn7PMVH70O6FZ0yErzlHqRx8vkNiAsTWt5A==", "license": "Apache-2.0", "dependencies": { "@opentelemetry/instrumentation": "^0.57.0", @@ -3198,7 +3198,7 @@ "@opentelemetry/instrumentation-bunyan": "^0.45.0", "@opentelemetry/instrumentation-cassandra-driver": "^0.45.0", "@opentelemetry/instrumentation-connect": "^0.43.0", - "@opentelemetry/instrumentation-cucumber": "^0.13.0", + "@opentelemetry/instrumentation-cucumber": "^0.14.0", "@opentelemetry/instrumentation-dataloader": "^0.16.0", "@opentelemetry/instrumentation-dns": "^0.43.0", "@opentelemetry/instrumentation-express": "^0.47.0", @@ -3218,10 +3218,10 @@ "@opentelemetry/instrumentation-mongodb": "^0.51.0", "@opentelemetry/instrumentation-mongoose": "^0.46.0", "@opentelemetry/instrumentation-mysql": "^0.45.0", - "@opentelemetry/instrumentation-mysql2": "^0.45.0", + "@opentelemetry/instrumentation-mysql2": "^0.45.1", "@opentelemetry/instrumentation-nestjs-core": "^0.44.0", "@opentelemetry/instrumentation-net": "^0.43.0", - "@opentelemetry/instrumentation-pg": "^0.50.0", + "@opentelemetry/instrumentation-pg": "^0.51.0", "@opentelemetry/instrumentation-pino": "^0.46.0", "@opentelemetry/instrumentation-redis": "^0.46.0", "@opentelemetry/instrumentation-redis-4": "^0.46.0", @@ -3626,9 +3626,9 @@ } }, "node_modules/@opentelemetry/instrumentation-cucumber": { - "version": "0.13.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-cucumber/-/instrumentation-cucumber-0.13.0.tgz", - "integrity": "sha512-ZBswBKONU2g7mhjEKF4vkTXxezq16QdvGaD5W4o01/t5KzvCZGQ6hYPsB34miJIj/hh6UrFLRDAjqb7nur5I3Q==", + "version": "0.14.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-cucumber/-/instrumentation-cucumber-0.14.0.tgz", + "integrity": "sha512-i/GlurL1IM+CnbmItW8kx59YxAp0wu/YQkzQQRU/YGmUjym5g+/dOVjnk/K46lAU49Nn1XyFd7S3ZNf83PHL2Q==", "license": "Apache-2.0", "dependencies": { "@opentelemetry/instrumentation": "^0.57.0", @@ -3952,9 +3952,9 @@ } }, "node_modules/@opentelemetry/instrumentation-mysql2": { - "version": "0.45.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-mysql2/-/instrumentation-mysql2-0.45.0.tgz", - "integrity": "sha512-qLslv/EPuLj0IXFvcE3b0EqhWI8LKmrgRPIa4gUd8DllbBpqJAvLNJSv3cC6vWwovpbSI3bagNO/3Q2SuXv2xA==", + "version": "0.45.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-mysql2/-/instrumentation-mysql2-0.45.1.tgz", + "integrity": "sha512-9R/vxEc02vlSqyQSmXRTvFMZVht8vgSJokKhiWA3z8Idu0mmdKFKeHiuW5yRGxM/WOi+7DWqQfYM7zw/cJc3sA==", "license": "Apache-2.0", "dependencies": { "@opentelemetry/instrumentation": "^0.57.0", @@ -4001,14 +4001,14 @@ } }, "node_modules/@opentelemetry/instrumentation-pg": { - "version": "0.50.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-pg/-/instrumentation-pg-0.50.0.tgz", - "integrity": "sha512-TtLxDdYZmBhFswm8UIsrDjh/HFBeDXd4BLmE8h2MxirNHewLJ0VS9UUddKKEverb5Sm2qFVjqRjcU+8Iw4FJ3w==", + "version": "0.51.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-pg/-/instrumentation-pg-0.51.0.tgz", + "integrity": "sha512-/NStIcUWUofc11dL7tSgMk25NqvhtbHDCncgm+yc4iJF8Ste2Q/lwUitjfxqj4qWM280uFmBEtcmtMMjbjRU7Q==", "license": "Apache-2.0", "dependencies": { "@opentelemetry/core": "^1.26.0", "@opentelemetry/instrumentation": "^0.57.0", - "@opentelemetry/semantic-conventions": "1.27.0", + "@opentelemetry/semantic-conventions": "^1.27.0", "@opentelemetry/sql-common": "^0.40.1", "@types/pg": "8.6.1", "@types/pg-pool": "2.0.6" @@ -4020,15 +4020,6 @@ "@opentelemetry/api": "^1.3.0" } }, - "node_modules/@opentelemetry/instrumentation-pg/node_modules/@opentelemetry/semantic-conventions": { - "version": "1.27.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.27.0.tgz", - "integrity": "sha512-sAay1RrB+ONOem0OZanAR1ZI/k7yDpnOQSQmTMuGImUQb2y8EbSaCJ94FQluM74xoU03vlb2d2U90hZluL6nQg==", - "license": "Apache-2.0", - "engines": { - "node": ">=14" - } - }, "node_modules/@opentelemetry/instrumentation-pino": { "version": "0.46.0", "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-pino/-/instrumentation-pino-0.46.0.tgz", @@ -5270,9 +5261,9 @@ "license": "MIT" }, "node_modules/@swc/core": { - "version": "1.10.7", - "resolved": "https://registry.npmjs.org/@swc/core/-/core-1.10.7.tgz", - "integrity": "sha512-py91kjI1jV5D5W/Q+PurBdGsdU5TFbrzamP7zSCqLdMcHkKi3rQEM5jkQcZr0MXXSJTaayLxS3MWYTBIkzPDrg==", + "version": "1.10.14", + "resolved": "https://registry.npmjs.org/@swc/core/-/core-1.10.14.tgz", + "integrity": "sha512-WSrnE6JRnH20ZYjOOgSS4aOaPv9gxlkI2KRkN24kagbZnPZMnN8bZZyzw1rrLvwgpuRGv17Uz+hflosbR+SP6w==", "dev": true, "hasInstallScript": true, "license": "Apache-2.0", @@ -5288,16 +5279,16 @@ "url": "https://opencollective.com/swc" }, "optionalDependencies": { - "@swc/core-darwin-arm64": "1.10.7", - "@swc/core-darwin-x64": "1.10.7", - "@swc/core-linux-arm-gnueabihf": "1.10.7", - "@swc/core-linux-arm64-gnu": "1.10.7", - "@swc/core-linux-arm64-musl": "1.10.7", - "@swc/core-linux-x64-gnu": "1.10.7", - "@swc/core-linux-x64-musl": "1.10.7", - "@swc/core-win32-arm64-msvc": "1.10.7", - "@swc/core-win32-ia32-msvc": "1.10.7", - "@swc/core-win32-x64-msvc": "1.10.7" + "@swc/core-darwin-arm64": "1.10.14", + "@swc/core-darwin-x64": "1.10.14", + "@swc/core-linux-arm-gnueabihf": "1.10.14", + "@swc/core-linux-arm64-gnu": "1.10.14", + "@swc/core-linux-arm64-musl": "1.10.14", + "@swc/core-linux-x64-gnu": "1.10.14", + "@swc/core-linux-x64-musl": "1.10.14", + "@swc/core-win32-arm64-msvc": "1.10.14", + "@swc/core-win32-ia32-msvc": "1.10.14", + "@swc/core-win32-x64-msvc": "1.10.14" }, "peerDependencies": { "@swc/helpers": "*" @@ -5309,9 +5300,9 @@ } }, "node_modules/@swc/core-darwin-arm64": { - "version": "1.10.7", - "resolved": "https://registry.npmjs.org/@swc/core-darwin-arm64/-/core-darwin-arm64-1.10.7.tgz", - "integrity": "sha512-SI0OFg987P6hcyT0Dbng3YRISPS9uhLX1dzW4qRrfqQdb0i75lPJ2YWe9CN47HBazrIA5COuTzrD2Dc0TcVsSQ==", + "version": "1.10.14", + "resolved": "https://registry.npmjs.org/@swc/core-darwin-arm64/-/core-darwin-arm64-1.10.14.tgz", + "integrity": "sha512-Dh4VyrhDDb05tdRmqJ/MucOPMTnrB4pRJol18HVyLlqu1HOT5EzonUniNTCdQbUXjgdv5UVJSTE1lYTzrp+myA==", "cpu": [ "arm64" ], @@ -5326,9 +5317,9 @@ } }, "node_modules/@swc/core-darwin-x64": { - "version": "1.10.7", - "resolved": "https://registry.npmjs.org/@swc/core-darwin-x64/-/core-darwin-x64-1.10.7.tgz", - "integrity": "sha512-RFIAmWVicD/l3RzxgHW0R/G1ya/6nyMspE2cAeDcTbjHi0I5qgdhBWd6ieXOaqwEwiCd0Mot1g2VZrLGoBLsjQ==", + "version": "1.10.14", + "resolved": "https://registry.npmjs.org/@swc/core-darwin-x64/-/core-darwin-x64-1.10.14.tgz", + "integrity": "sha512-KpzotL/I0O12RE3tF8NmQErINv0cQe/0mnN/Q50ESFzB5kU6bLgp2HMnnwDTm/XEZZRJCNe0oc9WJ5rKbAJFRQ==", "cpu": [ "x64" ], @@ -5343,9 +5334,9 @@ } }, "node_modules/@swc/core-linux-arm-gnueabihf": { - "version": "1.10.7", - "resolved": "https://registry.npmjs.org/@swc/core-linux-arm-gnueabihf/-/core-linux-arm-gnueabihf-1.10.7.tgz", - "integrity": "sha512-QP8vz7yELWfop5mM5foN6KkLylVO7ZUgWSF2cA0owwIaziactB2hCPZY5QU690coJouk9KmdFsPWDnaCFUP8tg==", + "version": "1.10.14", + "resolved": "https://registry.npmjs.org/@swc/core-linux-arm-gnueabihf/-/core-linux-arm-gnueabihf-1.10.14.tgz", + "integrity": "sha512-20yRXZjMJVz1wp1TcscKiGTVXistG+saIaxOmxSNQia1Qun3hSWLL+u6+5kXbfYGr7R2N6kqSwtZbIfJI25r9Q==", "cpu": [ "arm" ], @@ -5360,9 +5351,9 @@ } }, "node_modules/@swc/core-linux-arm64-gnu": { - "version": "1.10.7", - "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-gnu/-/core-linux-arm64-gnu-1.10.7.tgz", - "integrity": "sha512-NgUDBGQcOeLNR+EOpmUvSDIP/F7i/OVOKxst4wOvT5FTxhnkWrW+StJGKj+DcUVSK5eWOYboSXr1y+Hlywwokw==", + "version": "1.10.14", + "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-gnu/-/core-linux-arm64-gnu-1.10.14.tgz", + "integrity": "sha512-Gy7cGrNkiMfPxQyLGxdgXPwyWzNzbHuWycJFcoKBihxZKZIW8hkPBttkGivuLC+0qOgsV2/U+S7tlvAju7FtmQ==", "cpu": [ "arm64" ], @@ -5377,9 +5368,9 @@ } }, "node_modules/@swc/core-linux-arm64-musl": { - "version": "1.10.7", - "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-musl/-/core-linux-arm64-musl-1.10.7.tgz", - "integrity": "sha512-gp5Un3EbeSThBIh6oac5ZArV/CsSmTKj5jNuuUAuEsML3VF9vqPO+25VuxCvsRf/z3py+xOWRaN2HY/rjMeZog==", + "version": "1.10.14", + "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-musl/-/core-linux-arm64-musl-1.10.14.tgz", + "integrity": "sha512-+oYVqJvFw62InZ8PIy1rBACJPC2WTe4vbVb9kM1jJj2D7dKLm9acnnYIVIDsM5Wo7Uab8RvPHXVbs19IBurzuw==", "cpu": [ "arm64" ], @@ -5394,9 +5385,9 @@ } }, "node_modules/@swc/core-linux-x64-gnu": { - "version": "1.10.7", - "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-gnu/-/core-linux-x64-gnu-1.10.7.tgz", - "integrity": "sha512-k/OxLLMl/edYqbZyUNg6/bqEHTXJT15l9WGqsl/2QaIGwWGvles8YjruQYQ9d4h/thSXLT9gd8bExU2D0N+bUA==", + "version": "1.10.14", + "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-gnu/-/core-linux-x64-gnu-1.10.14.tgz", + "integrity": "sha512-OmEbVEKQFLQVHwo4EJl9osmlulURy46k232Opfpn/1ji0t2KcNCci3POsnfMuoZjLkGJv8vGNJdPQxX+CP+wSA==", "cpu": [ "x64" ], @@ -5411,9 +5402,9 @@ } }, "node_modules/@swc/core-linux-x64-musl": { - "version": "1.10.7", - "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-musl/-/core-linux-x64-musl-1.10.7.tgz", - "integrity": "sha512-XeDoURdWt/ybYmXLCEE8aSiTOzEn0o3Dx5l9hgt0IZEmTts7HgHHVeRgzGXbR4yDo0MfRuX5nE1dYpTmCz0uyA==", + "version": "1.10.14", + "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-musl/-/core-linux-x64-musl-1.10.14.tgz", + "integrity": "sha512-OZW+Icm8DMPqHbhdxplkuG8qrNnPk5i7xJOZWYi1y5bTjgGFI4nEzrsmmeHKMdQTaWwsFrm3uK1rlyQ48MmXmg==", "cpu": [ "x64" ], @@ -5428,9 +5419,9 @@ } }, "node_modules/@swc/core-win32-arm64-msvc": { - "version": "1.10.7", - "resolved": "https://registry.npmjs.org/@swc/core-win32-arm64-msvc/-/core-win32-arm64-msvc-1.10.7.tgz", - "integrity": "sha512-nYAbi/uLS+CU0wFtBx8TquJw2uIMKBnl04LBmiVoFrsIhqSl+0MklaA9FVMGA35NcxSJfcm92Prl2W2LfSnTqQ==", + "version": "1.10.14", + "resolved": "https://registry.npmjs.org/@swc/core-win32-arm64-msvc/-/core-win32-arm64-msvc-1.10.14.tgz", + "integrity": "sha512-sTvc+xrDQXy3HXZFtTEClY35Efvuc3D+busYm0+rb1+Thau4HLRY9WP+sOKeGwH9/16rzfzYEqD7Ds8A9ykrHw==", "cpu": [ "arm64" ], @@ -5445,9 +5436,9 @@ } }, "node_modules/@swc/core-win32-ia32-msvc": { - "version": "1.10.7", - "resolved": "https://registry.npmjs.org/@swc/core-win32-ia32-msvc/-/core-win32-ia32-msvc-1.10.7.tgz", - "integrity": "sha512-+aGAbsDsIxeLxw0IzyQLtvtAcI1ctlXVvVcXZMNXIXtTURM876yNrufRo4ngoXB3jnb1MLjIIjgXfFs/eZTUSw==", + "version": "1.10.14", + "resolved": "https://registry.npmjs.org/@swc/core-win32-ia32-msvc/-/core-win32-ia32-msvc-1.10.14.tgz", + "integrity": "sha512-j2iQ4y9GWTKtES5eMU0sDsFdYni7IxME7ejFej25Tv3Fq4B+U9tgtYWlJwh1858nIWDXelHiKcSh/UICAyVMdQ==", "cpu": [ "ia32" ], @@ -5462,9 +5453,9 @@ } }, "node_modules/@swc/core-win32-x64-msvc": { - "version": "1.10.7", - "resolved": "https://registry.npmjs.org/@swc/core-win32-x64-msvc/-/core-win32-x64-msvc-1.10.7.tgz", - "integrity": "sha512-TBf4clpDBjF/UUnkKrT0/th76/zwvudk5wwobiTFqDywMApHip5O0VpBgZ+4raY2TM8k5+ujoy7bfHb22zu17Q==", + "version": "1.10.14", + "resolved": "https://registry.npmjs.org/@swc/core-win32-x64-msvc/-/core-win32-x64-msvc-1.10.14.tgz", + "integrity": "sha512-TYtWkUSMkjs0jGPeWdtWbex4B+DlQZmN/ySVLiPI+EltYCLEXsFMkVFq6aWn48dqFHggFK0UYfvDrJUR2c3Qxg==", "cpu": [ "x64" ], @@ -5504,13 +5495,13 @@ } }, "node_modules/@testcontainers/postgresql": { - "version": "10.16.0", - "resolved": "https://registry.npmjs.org/@testcontainers/postgresql/-/postgresql-10.16.0.tgz", - "integrity": "sha512-zWFQI+3QxlEELRvVv27i6zlVEPNUz9zKaSh7iWmFlCdfhcyr78daS0FG8FIfdQ79VK7YXA4jv+dTYXa2SwXu/w==", + "version": "10.18.0", + "resolved": "https://registry.npmjs.org/@testcontainers/postgresql/-/postgresql-10.18.0.tgz", + "integrity": "sha512-WxkE/tBlBpoKvqDEqL3i/mL6BOBWnXb8FXKtLhEeZ3lSt0zlldkTozMmewNsKJtFTBZdv7uFwMzWyXP12t0sxQ==", "dev": true, "license": "MIT", "dependencies": { - "testcontainers": "^10.16.0" + "testcontainers": "^10.18.0" } }, "node_modules/@turf/boolean-point-in-polygon": { @@ -5777,9 +5768,9 @@ "license": "MIT" }, "node_modules/@types/lodash": { - "version": "4.17.14", - "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.14.tgz", - "integrity": "sha512-jsxagdikDiDBeIRaPYtArcT8my4tN1og7MtMRquFT3XNA6axxyHDRUemqDz/taRDdOUn0GnGHRCuff4q48sW9A==", + "version": "4.17.15", + "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.15.tgz", + "integrity": "sha512-w/P33JFeySuhN6JLkysYUK2gEmy9kHHFN7E8ro0tkfmlDOgxBDzWEZ/J8cWA+fHqFevpswDTFZnDx+R9lbL6xw==", "dev": true, "license": "MIT" }, @@ -5919,9 +5910,9 @@ "license": "MIT" }, "node_modules/@types/react": { - "version": "19.0.7", - "resolved": "https://registry.npmjs.org/@types/react/-/react-19.0.7.tgz", - "integrity": "sha512-MoFsEJKkAtZCrC1r6CM8U22GzhG7u2Wir8ons/aCKH6MBdD1ibV24zOSSkdZVUKqN5i396zG5VKLYZ3yaUZdLA==", + "version": "19.0.8", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.0.8.tgz", + "integrity": "sha512-9P/o1IGdfmQxrujGbIMDyYaaCykhLKc0NGCtYcECNUr9UAaDe4gwvV9bR6tvd5Br1SG0j+PBpbKr2UYY8CwqSw==", "dev": true, "license": "MIT", "dependencies": { @@ -6068,21 +6059,21 @@ "license": "MIT" }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.20.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.20.0.tgz", - "integrity": "sha512-naduuphVw5StFfqp4Gq4WhIBE2gN1GEmMUExpJYknZJdRnc+2gDzB8Z3+5+/Kv33hPQRDGzQO/0opHE72lZZ6A==", + "version": "8.23.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.23.0.tgz", + "integrity": "sha512-vBz65tJgRrA1Q5gWlRfvoH+w943dq9K1p1yDBY2pc+a1nbBLZp7fB9+Hk8DaALUbzjqlMfgaqlVPT1REJdkt/w==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/regexpp": "^4.10.0", - "@typescript-eslint/scope-manager": "8.20.0", - "@typescript-eslint/type-utils": "8.20.0", - "@typescript-eslint/utils": "8.20.0", - "@typescript-eslint/visitor-keys": "8.20.0", + "@typescript-eslint/scope-manager": "8.23.0", + "@typescript-eslint/type-utils": "8.23.0", + "@typescript-eslint/utils": "8.23.0", + "@typescript-eslint/visitor-keys": "8.23.0", "graphemer": "^1.4.0", "ignore": "^5.3.1", "natural-compare": "^1.4.0", - "ts-api-utils": "^2.0.0" + "ts-api-utils": "^2.0.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -6098,16 +6089,16 @@ } }, "node_modules/@typescript-eslint/parser": { - "version": "8.20.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.20.0.tgz", - "integrity": "sha512-gKXG7A5HMyjDIedBi6bUrDcun8GIjnI8qOwVLiY3rx6T/sHP/19XLJOnIq/FgQvWLHja5JN/LSE7eklNBr612g==", + "version": "8.23.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.23.0.tgz", + "integrity": "sha512-h2lUByouOXFAlMec2mILeELUbME5SZRN/7R9Cw2RD2lRQQY08MWMM+PmVVKKJNK1aIwqTo9t/0CvOxwPbRIE2Q==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/scope-manager": "8.20.0", - "@typescript-eslint/types": "8.20.0", - "@typescript-eslint/typescript-estree": "8.20.0", - "@typescript-eslint/visitor-keys": "8.20.0", + "@typescript-eslint/scope-manager": "8.23.0", + "@typescript-eslint/types": "8.23.0", + "@typescript-eslint/typescript-estree": "8.23.0", + "@typescript-eslint/visitor-keys": "8.23.0", "debug": "^4.3.4" }, "engines": { @@ -6123,14 +6114,14 @@ } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.20.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.20.0.tgz", - "integrity": "sha512-J7+VkpeGzhOt3FeG1+SzhiMj9NzGD/M6KoGn9f4dbz3YzK9hvbhVTmLj/HiTp9DazIzJ8B4XcM80LrR9Dm1rJw==", + "version": "8.23.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.23.0.tgz", + "integrity": "sha512-OGqo7+dXHqI7Hfm+WqkZjKjsiRtFUQHPdGMXzk5mYXhJUedO7e/Y7i8AK3MyLMgZR93TX4bIzYrfyVjLC+0VSw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.20.0", - "@typescript-eslint/visitor-keys": "8.20.0" + "@typescript-eslint/types": "8.23.0", + "@typescript-eslint/visitor-keys": "8.23.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -6141,16 +6132,16 @@ } }, "node_modules/@typescript-eslint/type-utils": { - "version": "8.20.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.20.0.tgz", - "integrity": "sha512-bPC+j71GGvA7rVNAHAtOjbVXbLN5PkwqMvy1cwGeaxUoRQXVuKCebRoLzm+IPW/NtFFpstn1ummSIasD5t60GA==", + "version": "8.23.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.23.0.tgz", + "integrity": "sha512-iIuLdYpQWZKbiH+RkCGc6iu+VwscP5rCtQ1lyQ7TYuKLrcZoeJVpcLiG8DliXVkUxirW/PWlmS+d6yD51L9jvA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/typescript-estree": "8.20.0", - "@typescript-eslint/utils": "8.20.0", + "@typescript-eslint/typescript-estree": "8.23.0", + "@typescript-eslint/utils": "8.23.0", "debug": "^4.3.4", - "ts-api-utils": "^2.0.0" + "ts-api-utils": "^2.0.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -6165,9 +6156,9 @@ } }, "node_modules/@typescript-eslint/types": { - "version": "8.20.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.20.0.tgz", - "integrity": "sha512-cqaMiY72CkP+2xZRrFt3ExRBu0WmVitN/rYPZErA80mHjHx/Svgp8yfbzkJmDoQ/whcytOPO9/IZXnOc+wigRA==", + "version": "8.23.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.23.0.tgz", + "integrity": "sha512-1sK4ILJbCmZOTt9k4vkoulT6/y5CHJ1qUYxqpF1K/DBAd8+ZUL4LlSCxOssuH5m4rUaaN0uS0HlVPvd45zjduQ==", "dev": true, "license": "MIT", "engines": { @@ -6179,20 +6170,20 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.20.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.20.0.tgz", - "integrity": "sha512-Y7ncuy78bJqHI35NwzWol8E0X7XkRVS4K4P4TCyzWkOJih5NDvtoRDW4Ba9YJJoB2igm9yXDdYI/+fkiiAxPzA==", + "version": "8.23.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.23.0.tgz", + "integrity": "sha512-LcqzfipsB8RTvH8FX24W4UUFk1bl+0yTOf9ZA08XngFwMg4Kj8A+9hwz8Cr/ZS4KwHrmo9PJiLZkOt49vPnuvQ==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.20.0", - "@typescript-eslint/visitor-keys": "8.20.0", + "@typescript-eslint/types": "8.23.0", + "@typescript-eslint/visitor-keys": "8.23.0", "debug": "^4.3.4", "fast-glob": "^3.3.2", "is-glob": "^4.0.3", "minimatch": "^9.0.4", "semver": "^7.6.0", - "ts-api-utils": "^2.0.0" + "ts-api-utils": "^2.0.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -6232,16 +6223,16 @@ } }, "node_modules/@typescript-eslint/utils": { - "version": "8.20.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.20.0.tgz", - "integrity": "sha512-dq70RUw6UK9ei7vxc4KQtBRk7qkHZv447OUZ6RPQMQl71I3NZxQJX/f32Smr+iqWrB02pHKn2yAdHBb0KNrRMA==", + "version": "8.23.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.23.0.tgz", + "integrity": "sha512-uB/+PSo6Exu02b5ZEiVtmY6RVYO7YU5xqgzTIVZwTHvvK3HsL8tZZHFaTLFtRG3CsV4A5mhOv+NZx5BlhXPyIA==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.4.0", - "@typescript-eslint/scope-manager": "8.20.0", - "@typescript-eslint/types": "8.20.0", - "@typescript-eslint/typescript-estree": "8.20.0" + "@typescript-eslint/scope-manager": "8.23.0", + "@typescript-eslint/types": "8.23.0", + "@typescript-eslint/typescript-estree": "8.23.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -6256,13 +6247,13 @@ } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.20.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.20.0.tgz", - "integrity": "sha512-v/BpkeeYAsPkKCkR8BDwcno0llhzWVqPOamQrAEMdpZav2Y9OVjd9dwJyBLJWwf335B5DmlifECIkZRJCaGaHA==", + "version": "8.23.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.23.0.tgz", + "integrity": "sha512-oWWhcWDLwDfu++BGTZcmXWqpwtkwb5o7fxUIGksMQQDSdPW9prsSnfIOZMlsj4vBOSrcnjIUZMiIjODgGosFhQ==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.20.0", + "@typescript-eslint/types": "8.23.0", "eslint-visitor-keys": "^4.2.0" }, "engines": { @@ -6287,9 +6278,9 @@ } }, "node_modules/@vitest/coverage-v8": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-3.0.3.tgz", - "integrity": "sha512-uVbJ/xhImdNtzPnLyxCZJMTeTIYdgcC2nWtBBBpR1H6z0w8m7D+9/zrDIx2nNxgMg9r+X8+RY2qVpUDeW2b3nw==", + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-3.0.5.tgz", + "integrity": "sha512-zOOWIsj5fHh3jjGwQg+P+J1FW3s4jBu1Zqga0qW60yutsBtqEqNEJKWYh7cYn1yGD+1bdPsPdC/eL4eVK56xMg==", "dev": true, "license": "MIT", "dependencies": { @@ -6310,8 +6301,8 @@ "url": "https://opencollective.com/vitest" }, "peerDependencies": { - "@vitest/browser": "3.0.3", - "vitest": "3.0.3" + "@vitest/browser": "3.0.5", + "vitest": "3.0.5" }, "peerDependenciesMeta": { "@vitest/browser": { @@ -6320,14 +6311,14 @@ } }, "node_modules/@vitest/expect": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.0.3.tgz", - "integrity": "sha512-SbRCHU4qr91xguu+dH3RUdI5dC86zm8aZWydbp961aIR7G8OYNN6ZiayFuf9WAngRbFOfdrLHCGgXTj3GtoMRQ==", + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.0.5.tgz", + "integrity": "sha512-nNIOqupgZ4v5jWuQx2DSlHLEs7Q4Oh/7AYwNyE+k0UQzG7tSmjPXShUikn1mpNGzYEN2jJbTvLejwShMitovBA==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/spy": "3.0.3", - "@vitest/utils": "3.0.3", + "@vitest/spy": "3.0.5", + "@vitest/utils": "3.0.5", "chai": "^5.1.2", "tinyrainbow": "^2.0.0" }, @@ -6336,13 +6327,13 @@ } }, "node_modules/@vitest/mocker": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.0.3.tgz", - "integrity": "sha512-XT2XBc4AN9UdaxJAeIlcSZ0ILi/GzmG5G8XSly4gaiqIvPV3HMTSIDZWJVX6QRJ0PX1m+W8Cy0K9ByXNb/bPIA==", + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.0.5.tgz", + "integrity": "sha512-CLPNBFBIE7x6aEGbIjaQAX03ZZlBMaWwAjBdMkIf/cAn6xzLTiM3zYqO/WAbieEjsAZir6tO71mzeHZoodThvw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/spy": "3.0.3", + "@vitest/spy": "3.0.5", "estree-walker": "^3.0.3", "magic-string": "^0.30.17" }, @@ -6373,9 +6364,9 @@ } }, "node_modules/@vitest/pretty-format": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.0.3.tgz", - "integrity": "sha512-gCrM9F7STYdsDoNjGgYXKPq4SkSxwwIU5nkaQvdUxiQ0EcNlez+PdKOVIsUJvh9P9IeIFmjn4IIREWblOBpP2Q==", + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.0.5.tgz", + "integrity": "sha512-CjUtdmpOcm4RVtB+up8r2vVDLR16Mgm/bYdkGFe3Yj/scRfCpbSi2W/BDSDcFK7ohw8UXvjMbOp9H4fByd/cOA==", "dev": true, "license": "MIT", "dependencies": { @@ -6386,38 +6377,38 @@ } }, "node_modules/@vitest/runner": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.0.3.tgz", - "integrity": "sha512-Rgi2kOAk5ZxWZlwPguRJFOBmWs6uvvyAAR9k3MvjRvYrG7xYvKChZcmnnpJCS98311CBDMqsW9MzzRFsj2gX3g==", + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.0.5.tgz", + "integrity": "sha512-BAiZFityFexZQi2yN4OX3OkJC6scwRo8EhRB0Z5HIGGgd2q+Nq29LgHU/+ovCtd0fOfXj5ZI6pwdlUmC5bpi8A==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/utils": "3.0.3", - "pathe": "^2.0.1" + "@vitest/utils": "3.0.5", + "pathe": "^2.0.2" }, "funding": { "url": "https://opencollective.com/vitest" } }, "node_modules/@vitest/snapshot": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.0.3.tgz", - "integrity": "sha512-kNRcHlI4txBGztuJfPEJ68VezlPAXLRT1u5UCx219TU3kOG2DplNxhWLwDf2h6emwmTPogzLnGVwP6epDaJN6Q==", + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.0.5.tgz", + "integrity": "sha512-GJPZYcd7v8QNUJ7vRvLDmRwl+a1fGg4T/54lZXe+UOGy47F9yUfE18hRCtXL5aHN/AONu29NGzIXSVFh9K0feA==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "3.0.3", + "@vitest/pretty-format": "3.0.5", "magic-string": "^0.30.17", - "pathe": "^2.0.1" + "pathe": "^2.0.2" }, "funding": { "url": "https://opencollective.com/vitest" } }, "node_modules/@vitest/spy": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.0.3.tgz", - "integrity": "sha512-7/dgux8ZBbF7lEIKNnEqQlyRaER9nkAL9eTmdKJkDO3hS8p59ATGwKOCUDHcBLKr7h/oi/6hP+7djQk8049T2A==", + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.0.5.tgz", + "integrity": "sha512-5fOzHj0WbUNqPK6blI/8VzZdkBlQLnT25knX0r4dbZI9qoZDf3qAdjoMmDcLG5A83W6oUUFJgUd0EYBc2P5xqg==", "dev": true, "license": "MIT", "dependencies": { @@ -6428,13 +6419,13 @@ } }, "node_modules/@vitest/utils": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.0.3.tgz", - "integrity": "sha512-f+s8CvyzPtMFY1eZKkIHGhPsQgYo5qCm6O8KZoim9qm1/jT64qBgGpO5tHscNH6BzRHM+edLNOP+3vO8+8pE/A==", + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.0.5.tgz", + "integrity": "sha512-N9AX0NUoUtVwKwy21JtwzaqR5L5R5A99GAbrHfCCXK1lp593i/3AZAXhSP43wRQuxYsflrdzEfXZFo1reR1Nkg==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "3.0.3", + "@vitest/pretty-format": "3.0.5", "loupe": "^3.1.2", "tinyrainbow": "^2.0.0" }, @@ -8836,9 +8827,9 @@ } }, "node_modules/eslint": { - "version": "9.18.0", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.18.0.tgz", - "integrity": "sha512-+waTfRWQlSbpt3KWE+CjrPPYnbq9kfZIYUqapc0uBXyjTp8aYXZDsUH16m39Ryq3NjAVP4tjuF7KaukeqoCoaA==", + "version": "9.19.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.19.0.tgz", + "integrity": "sha512-ug92j0LepKlbbEv6hD911THhoRHmbdXt2gX+VDABAW/Ir7D3nqKdv5Pf5vtlyY6HQMTEP2skXY43ueqTCWssEA==", "dev": true, "license": "MIT", "dependencies": { @@ -8847,7 +8838,7 @@ "@eslint/config-array": "^0.19.0", "@eslint/core": "^0.10.0", "@eslint/eslintrc": "^3.2.0", - "@eslint/js": "9.18.0", + "@eslint/js": "9.19.0", "@eslint/plugin-kit": "^0.2.5", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", @@ -8909,9 +8900,9 @@ } }, "node_modules/eslint-plugin-prettier": { - "version": "5.2.2", - "resolved": "https://registry.npmjs.org/eslint-plugin-prettier/-/eslint-plugin-prettier-5.2.2.tgz", - "integrity": "sha512-1yI3/hf35wmlq66C8yOyrujQnel+v5l1Vop5Cl2I6ylyNTT1JbuUUnV3/41PzwTzcyDp/oF0jWE3HXvcH5AQOQ==", + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/eslint-plugin-prettier/-/eslint-plugin-prettier-5.2.3.tgz", + "integrity": "sha512-qJ+y0FfCp/mQYQ/vWQ3s7eUlFEL4PyKfAJxsnYTJ4YT73nsJBWqmEpFryxV9OeUiqmsTsYJ5Y+KDNaeP31wrRw==", "dev": true, "license": "MIT", "dependencies": { @@ -9861,9 +9852,9 @@ } }, "node_modules/geo-tz": { - "version": "8.1.2", - "resolved": "https://registry.npmjs.org/geo-tz/-/geo-tz-8.1.2.tgz", - "integrity": "sha512-S1udoP7MZ+CVu+7Iy/VayVNmEHTWgfJ52TjpfC2/4f+j0SB/ZXMjGrwZTqPMo6/O2m5lrGLCFCY0bkxUqiLN+g==", + "version": "8.1.3", + "resolved": "https://registry.npmjs.org/geo-tz/-/geo-tz-8.1.3.tgz", + "integrity": "sha512-zzF0hjqLl+1n5tXDCxwdS/BmF+N1TdQc6rbubh6PO6/9DtntX/yBox1Ti0q24MrjajWG0fSv0gv2w6Zff/kmeA==", "license": "MIT", "dependencies": { "@turf/boolean-point-in-polygon": "^7.1.0", @@ -11210,9 +11201,9 @@ "license": "Apache-2.0" }, "node_modules/loupe": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.1.2.tgz", - "integrity": "sha512-23I4pFZHmAemUnz8WZXbYRSKYj801VDaNv9ETuMh7IrMc7VuVVSo+Z9iLE3ni30+U48iDWfi30d3twAXBYmnCg==", + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.1.3.tgz", + "integrity": "sha512-kkIp7XSkP78ZxJEsSxW3712C6teJVoeHHwgo9zJ380de7IYyJ2ISlxojcH2pC5OFLewESmnRi/+XCDIEEVyoug==", "dev": true, "license": "MIT" }, @@ -11515,9 +11506,9 @@ "license": "MIT" }, "node_modules/mock-fs": { - "version": "5.4.1", - "resolved": "https://registry.npmjs.org/mock-fs/-/mock-fs-5.4.1.tgz", - "integrity": "sha512-sz/Q8K1gXXXHR+qr0GZg2ysxCRr323kuN10O7CtQjraJsFDJ4SJ+0I5MzALz7aRp9lHk8Cc/YdsT95h9Ka1aFw==", + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/mock-fs/-/mock-fs-5.5.0.tgz", + "integrity": "sha512-d/P1M/RacgM3dB0sJ8rjeRNXxtapkPCUnMGmIN0ixJ16F/E4GUZCvWcSGfWGz8eaXYvn1s9baUwNjI4LOPEjiA==", "dev": true, "license": "MIT", "engines": { @@ -11803,9 +11794,9 @@ } }, "node_modules/nestjs-cls": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/nestjs-cls/-/nestjs-cls-5.0.0.tgz", - "integrity": "sha512-0rpkCngRISkd2x9z7q0bR8R4AeZe43WkLBaDuTE6Uaw9r1OEWfEGfGS6M4OlXG20CIxDtaAIbWW8wFM5YCNRkA==", + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/nestjs-cls/-/nestjs-cls-5.0.1.tgz", + "integrity": "sha512-TA1qkFkkWct1LXXjcNk4jxQgvZrL34bojK3D4oOo59ru7xrQ1erN3/737T+XezOZ2ZPngGVeJZ9WipaD9N+G0Q==", "license": "MIT", "engines": { "node": ">=16" @@ -11966,9 +11957,9 @@ "license": "MIT" }, "node_modules/nodemailer": { - "version": "6.9.16", - "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-6.9.16.tgz", - "integrity": "sha512-psAuZdTIRN08HKVd/E8ObdV6NO7NTBY3KsC30F7M4H1OnmLCUNaS56FpYxyb26zWLSyYF9Ozch9KYHhHegsiOQ==", + "version": "6.10.0", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-6.10.0.tgz", + "integrity": "sha512-SQ3wZCExjeSatLE/HBaXS5vqUOQk6GtBdIIKxiFdmm01mOQZX/POJkO3SUX1wDiYcwUOJwT23scFSC9fY2H8IA==", "license": "MIT-0", "engines": { "node": ">=6.0.0" @@ -14028,9 +14019,9 @@ } }, "node_modules/semver": { - "version": "7.6.3", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz", - "integrity": "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==", + "version": "7.7.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.1.tgz", + "integrity": "sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA==", "license": "ISC", "bin": { "semver": "bin/semver.js" @@ -14569,9 +14560,9 @@ } }, "node_modules/sql-formatter": { - "version": "15.4.9", - "resolved": "https://registry.npmjs.org/sql-formatter/-/sql-formatter-15.4.9.tgz", - "integrity": "sha512-5vmt2HlCAVozxsBZuXWkAki/KGawaK+b5GG5x+BtXOFVpN/8cqppblFUxHl4jxdA0cvo14lABhM+KBnrUapOlw==", + "version": "15.4.10", + "resolved": "https://registry.npmjs.org/sql-formatter/-/sql-formatter-15.4.10.tgz", + "integrity": "sha512-zQfiuxU1F/C7TNu+880BdL+fuvJTd1Kj8R0wv48dfZ27NR3z1PWvQFkH8ai/HrIy+NyvXCaZBkJHp/EeZFXSOA==", "dev": true, "license": "MIT", "dependencies": { @@ -15262,9 +15253,9 @@ } }, "node_modules/testcontainers": { - "version": "10.16.0", - "resolved": "https://registry.npmjs.org/testcontainers/-/testcontainers-10.16.0.tgz", - "integrity": "sha512-oxPLuOtrRWS11A+Yn0+zXB7GkmNarflWqmy6CQJk8KJ75LZs2/zlUXDpizTbPpCGtk4kE2EQYwFZjrE967F8Wg==", + "version": "10.18.0", + "resolved": "https://registry.npmjs.org/testcontainers/-/testcontainers-10.18.0.tgz", + "integrity": "sha512-MnwWsPjsN5QVe+lSU1LwLZVOyjgwSwv1INzkw8FekdwgvOtvJ7FThQEkbmzRcguQootgwmA9FG54NoTChZDRvA==", "dev": true, "license": "MIT", "dependencies": { @@ -15282,7 +15273,7 @@ "ssh-remote-port-forward": "^1.0.4", "tar-fs": "^3.0.6", "tmp": "^0.2.3", - "undici": "^5.28.4" + "undici": "^5.28.5" } }, "node_modules/testcontainers/node_modules/tmp": { @@ -15456,9 +15447,9 @@ } }, "node_modules/ts-api-utils": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.0.0.tgz", - "integrity": "sha512-xCt/TOAc+EOHS1XPnijD3/yzpH6qg2xppZO1YDqGoVsNXfQfzHpOdNuXwrwOU8u4ITXJyDCTyt8w5g1sZv9ynQ==", + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.0.1.tgz", + "integrity": "sha512-dnlgjFSVetynI8nzgJ+qF62efpglpWRk8isUEWZGWlJYySCTD6aKvbUDu+zbPeDakk3bg5H4XpitHukgfL1m9w==", "dev": true, "license": "MIT", "engines": { @@ -15839,9 +15830,9 @@ } }, "node_modules/undici": { - "version": "5.28.4", - "resolved": "https://registry.npmjs.org/undici/-/undici-5.28.4.tgz", - "integrity": "sha512-72RFADWFqKmUb2hmmvNODKL3p9hcB6Gt2DOQMis1SEBaV6a4MH8soBvzg+95CYhCKPFedut2JY9bMfrDl9D23g==", + "version": "5.28.5", + "resolved": "https://registry.npmjs.org/undici/-/undici-5.28.5.tgz", + "integrity": "sha512-zICwjrDrcrUE0pyyJc1I2QzBkLM8FINsgOrt6WjA+BgajVq9Nxu2PbFFXUrAggLfDXlZGZBVZYw7WNV5KiBiBA==", "dev": true, "license": "MIT", "dependencies": { @@ -16078,16 +16069,16 @@ } }, "node_modules/vite-node": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.0.3.tgz", - "integrity": "sha512-0sQcwhwAEw/UJGojbhOrnq3HtiZ3tC7BzpAa0lx3QaTX0S3YX70iGcik25UBdB96pmdwjyY2uyKNYruxCDmiEg==", + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.0.5.tgz", + "integrity": "sha512-02JEJl7SbtwSDJdYS537nU6l+ktdvcREfLksk/NDAqtdKWGqHl+joXzEubHROmS3E6pip+Xgu2tFezMu75jH7A==", "dev": true, "license": "MIT", "dependencies": { "cac": "^6.7.14", "debug": "^4.4.0", "es-module-lexer": "^1.6.0", - "pathe": "^2.0.1", + "pathe": "^2.0.2", "vite": "^5.0.0 || ^6.0.0" }, "bin": { @@ -16580,31 +16571,31 @@ } }, "node_modules/vitest": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.0.3.tgz", - "integrity": "sha512-dWdwTFUW9rcnL0LyF2F+IfvNQWB0w9DERySCk8VMG75F8k25C7LsZoh6XfCjPvcR8Nb+Lqi9JKr6vnzH7HSrpQ==", + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.0.5.tgz", + "integrity": "sha512-4dof+HvqONw9bvsYxtkfUp2uHsTN9bV2CZIi1pWgoFpL1Lld8LA1ka9q/ONSsoScAKG7NVGf2stJTI7XRkXb2Q==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/expect": "3.0.3", - "@vitest/mocker": "3.0.3", - "@vitest/pretty-format": "^3.0.3", - "@vitest/runner": "3.0.3", - "@vitest/snapshot": "3.0.3", - "@vitest/spy": "3.0.3", - "@vitest/utils": "3.0.3", + "@vitest/expect": "3.0.5", + "@vitest/mocker": "3.0.5", + "@vitest/pretty-format": "^3.0.5", + "@vitest/runner": "3.0.5", + "@vitest/snapshot": "3.0.5", + "@vitest/spy": "3.0.5", + "@vitest/utils": "3.0.5", "chai": "^5.1.2", "debug": "^4.4.0", "expect-type": "^1.1.0", "magic-string": "^0.30.17", - "pathe": "^2.0.1", + "pathe": "^2.0.2", "std-env": "^3.8.0", "tinybench": "^2.9.0", "tinyexec": "^0.3.2", "tinypool": "^1.0.2", "tinyrainbow": "^2.0.0", "vite": "^5.0.0 || ^6.0.0", - "vite-node": "3.0.3", + "vite-node": "3.0.5", "why-is-node-running": "^2.3.0" }, "bin": { @@ -16618,9 +16609,10 @@ }, "peerDependencies": { "@edge-runtime/vm": "*", + "@types/debug": "^4.1.12", "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", - "@vitest/browser": "3.0.3", - "@vitest/ui": "3.0.3", + "@vitest/browser": "3.0.5", + "@vitest/ui": "3.0.5", "happy-dom": "*", "jsdom": "*" }, @@ -16628,6 +16620,9 @@ "@edge-runtime/vm": { "optional": true }, + "@types/debug": { + "optional": true + }, "@types/node": { "optional": true }, diff --git a/server/package.json b/server/package.json index 6bd84f2a75..f2aeec79f8 100644 --- a/server/package.json +++ b/server/package.json @@ -45,7 +45,7 @@ "@nestjs/swagger": "^11.0.2", "@nestjs/typeorm": "^11.0.0", "@nestjs/websockets": "^11.0.4", - "@opentelemetry/auto-instrumentations-node": "^0.55.0", + "@opentelemetry/auto-instrumentations-node": "^0.56.0", "@opentelemetry/context-async-hooks": "^1.24.0", "@opentelemetry/exporter-prometheus": "^0.57.0", "@opentelemetry/sdk-node": "^0.57.0", diff --git a/web/package-lock.json b/web/package-lock.json index 6e6cc62fae..276fcb7b98 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -11,7 +11,7 @@ "dependencies": { "@formatjs/icu-messageformat-parser": "^2.9.8", "@immich/sdk": "file:../open-api/typescript-sdk", - "@immich/ui": "^0.15.0", + "@immich/ui": "^0.16.0", "@mapbox/mapbox-gl-rtl-text": "0.2.3", "@mdi/js": "^7.4.47", "@photo-sphere-viewer/core": "^5.11.5", @@ -26,7 +26,7 @@ "justified-layout": "^4.1.0", "lodash-es": "^4.17.21", "luxon": "^3.4.4", - "socket.io-client": "~4.7.5", + "socket.io-client": "~4.8.0", "svelte-gestures": "^5.1.3", "svelte-i18n": "^4.0.1", "svelte-local-storage-store": "^0.6.4", @@ -83,7 +83,7 @@ "@oazapfts/runtime": "^1.0.2" }, "devDependencies": { - "@types/node": "^22.10.9", + "@types/node": "^22.13.1", "typescript": "^5.3.3" } }, @@ -783,9 +783,9 @@ } }, "node_modules/@eslint/js": { - "version": "9.18.0", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.18.0.tgz", - "integrity": "sha512-fK6L7rxcq6/z+AaQMtiFTkvbHkBLNlwyRxHpKawP0x3u9+NC6MQTnFW+AdpwC6gfHTW0051cokQgtTN2FqlxQA==", + "version": "9.19.0", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.19.0.tgz", + "integrity": "sha512-rbq9/g38qjfqFLOVPvwjIvFFdNziEC5S65jmjPw5r6A//QH+W91akh9irMwjDN8zKUTak6W9EsAv4m/7Wnw0UQ==", "dev": true, "license": "MIT", "engines": { @@ -880,9 +880,9 @@ } }, "node_modules/@formatjs/icu-messageformat-parser": { - "version": "2.10.0", - "resolved": "https://registry.npmjs.org/@formatjs/icu-messageformat-parser/-/icu-messageformat-parser-2.10.0.tgz", - "integrity": "sha512-PDeky6nDAyHYEtmSi2X1PG9YpqE+2BRTJT7JvPix8K8JX1wBWQNao6KcPtmZpttQHUHmzMcd/rne7lFesSzUKQ==", + "version": "2.11.0", + "resolved": "https://registry.npmjs.org/@formatjs/icu-messageformat-parser/-/icu-messageformat-parser-2.11.0.tgz", + "integrity": "sha512-Hp81uTjjdTk3FLh/dggU5NK7EIsVWc5/ZDWrIldmf2rBuPejuZ13CZ/wpVE2SToyi4EiroPTQ1XJcJuZFIxTtw==", "license": "MIT", "dependencies": { "@formatjs/ecma402-abstract": "2.3.2", @@ -1345,9 +1345,9 @@ "link": true }, "node_modules/@immich/ui": { - "version": "0.15.0", - "resolved": "https://registry.npmjs.org/@immich/ui/-/ui-0.15.0.tgz", - "integrity": "sha512-vGDNEOGj5Ma/BAIgj31M1roAVoEOVWws5lkgt1xPlIxSHk4pMhGRFMQaJaCsfXeX/nTRsQCd3gOk7Yo0XNrVfg==", + "version": "0.16.0", + "resolved": "https://registry.npmjs.org/@immich/ui/-/ui-0.16.0.tgz", + "integrity": "sha512-itSOu0r7drZ46qJ+9eNNyq5YZVRAeUKwjRdzBUd6uz4Csy3oEDoepEtWF1EHDaph2XUcKJrNvQPUvETTWeQC6g==", "license": "GNU Affero General Public License version 3", "dependencies": { "@mdi/js": "^7.4.47", @@ -1743,9 +1743,9 @@ "dev": true }, "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.28.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.28.1.tgz", - "integrity": "sha512-2aZp8AES04KI2dy3Ss6/MDjXbwBzj+i0GqKtWXgw2/Ma6E4jJvujryO6gJAghIRVz7Vwr9Gtl/8na3nDUKpraQ==", + "version": "4.34.6", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.34.6.tgz", + "integrity": "sha512-+GcCXtOQoWuC7hhX1P00LqjjIiS/iOouHXhMdiDSnq/1DGTox4SpUvO52Xm+div6+106r+TcvOeo/cxvyEyTgg==", "cpu": [ "arm" ], @@ -1757,9 +1757,9 @@ ] }, "node_modules/@rollup/rollup-android-arm64": { - "version": "4.28.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.28.1.tgz", - "integrity": "sha512-EbkK285O+1YMrg57xVA+Dp0tDBRB93/BZKph9XhMjezf6F4TpYjaUSuPt5J0fZXlSag0LmZAsTmdGGqPp4pQFA==", + "version": "4.34.6", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.34.6.tgz", + "integrity": "sha512-E8+2qCIjciYUnCa1AiVF1BkRgqIGW9KzJeesQqVfyRITGQN+dFuoivO0hnro1DjT74wXLRZ7QF8MIbz+luGaJA==", "cpu": [ "arm64" ], @@ -1771,9 +1771,9 @@ ] }, "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.28.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.28.1.tgz", - "integrity": "sha512-prduvrMKU6NzMq6nxzQw445zXgaDBbMQvmKSJaxpaZ5R1QDM8w+eGxo6Y/jhT/cLoCvnZI42oEqf9KQNYz1fqQ==", + "version": "4.34.6", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.34.6.tgz", + "integrity": "sha512-z9Ib+OzqN3DZEjX7PDQMHEhtF+t6Mi2z/ueChQPLS/qUMKY7Ybn5A2ggFoKRNRh1q1T03YTQfBTQCJZiepESAg==", "cpu": [ "arm64" ], @@ -1785,9 +1785,9 @@ ] }, "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.28.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.28.1.tgz", - "integrity": "sha512-WsvbOunsUk0wccO/TV4o7IKgloJ942hVFK1CLatwv6TJspcCZb9umQkPdvB7FihmdxgaKR5JyxDjWpCOp4uZlQ==", + "version": "4.34.6", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.34.6.tgz", + "integrity": "sha512-PShKVY4u0FDAR7jskyFIYVyHEPCPnIQY8s5OcXkdU8mz3Y7eXDJPdyM/ZWjkYdR2m0izD9HHWA8sGcXn+Qrsyg==", "cpu": [ "x64" ], @@ -1799,9 +1799,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.28.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.28.1.tgz", - "integrity": "sha512-HTDPdY1caUcU4qK23FeeGxCdJF64cKkqajU0iBnTVxS8F7H/7BewvYoG+va1KPSL63kQ1PGNyiwKOfReavzvNA==", + "version": "4.34.6", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.34.6.tgz", + "integrity": "sha512-YSwyOqlDAdKqs0iKuqvRHLN4SrD2TiswfoLfvYXseKbL47ht1grQpq46MSiQAx6rQEN8o8URtpXARCpqabqxGQ==", "cpu": [ "arm64" ], @@ -1813,9 +1813,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.28.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.28.1.tgz", - "integrity": "sha512-m/uYasxkUevcFTeRSM9TeLyPe2QDuqtjkeoTpP9SW0XxUWfcYrGDMkO/m2tTw+4NMAF9P2fU3Mw4ahNvo7QmsQ==", + "version": "4.34.6", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.34.6.tgz", + "integrity": "sha512-HEP4CgPAY1RxXwwL5sPFv6BBM3tVeLnshF03HMhJYCNc6kvSqBgTMmsEjb72RkZBAWIqiPUyF1JpEBv5XT9wKQ==", "cpu": [ "x64" ], @@ -1827,9 +1827,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.28.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.28.1.tgz", - "integrity": "sha512-QAg11ZIt6mcmzpNE6JZBpKfJaKkqTm1A9+y9O+frdZJEuhQxiugM05gnCWiANHj4RmbgeVJpTdmKRmH/a+0QbA==", + "version": "4.34.6", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.34.6.tgz", + "integrity": "sha512-88fSzjC5xeH9S2Vg3rPgXJULkHcLYMkh8faix8DX4h4TIAL65ekwuQMA/g2CXq8W+NJC43V6fUpYZNjaX3+IIg==", "cpu": [ "arm" ], @@ -1841,9 +1841,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.28.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.28.1.tgz", - "integrity": "sha512-dRP9PEBfolq1dmMcFqbEPSd9VlRuVWEGSmbxVEfiq2cs2jlZAl0YNxFzAQS2OrQmsLBLAATDMb3Z6MFv5vOcXg==", + "version": "4.34.6", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.34.6.tgz", + "integrity": "sha512-wM4ztnutBqYFyvNeR7Av+reWI/enK9tDOTKNF+6Kk2Q96k9bwhDDOlnCUNRPvromlVXo04riSliMBs/Z7RteEg==", "cpu": [ "arm" ], @@ -1855,9 +1855,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.28.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.28.1.tgz", - "integrity": "sha512-uGr8khxO+CKT4XU8ZUH1TTEUtlktK6Kgtv0+6bIFSeiSlnGJHG1tSFSjm41uQ9sAO/5ULx9mWOz70jYLyv1QkA==", + "version": "4.34.6", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.34.6.tgz", + "integrity": "sha512-9RyprECbRa9zEjXLtvvshhw4CMrRa3K+0wcp3KME0zmBe1ILmvcVHnypZ/aIDXpRyfhSYSuN4EPdCCj5Du8FIA==", "cpu": [ "arm64" ], @@ -1869,9 +1869,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.28.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.28.1.tgz", - "integrity": "sha512-QF54q8MYGAqMLrX2t7tNpi01nvq5RI59UBNx+3+37zoKX5KViPo/gk2QLhsuqok05sSCRluj0D00LzCwBikb0A==", + "version": "4.34.6", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.34.6.tgz", + "integrity": "sha512-qTmklhCTyaJSB05S+iSovfo++EwnIEZxHkzv5dep4qoszUMX5Ca4WM4zAVUMbfdviLgCSQOu5oU8YoGk1s6M9Q==", "cpu": [ "arm64" ], @@ -1883,9 +1883,9 @@ ] }, "node_modules/@rollup/rollup-linux-loongarch64-gnu": { - "version": "4.28.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loongarch64-gnu/-/rollup-linux-loongarch64-gnu-4.28.1.tgz", - "integrity": "sha512-vPul4uodvWvLhRco2w0GcyZcdyBfpfDRgNKU+p35AWEbJ/HPs1tOUrkSueVbBS0RQHAf/A+nNtDpvw95PeVKOA==", + "version": "4.34.6", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loongarch64-gnu/-/rollup-linux-loongarch64-gnu-4.34.6.tgz", + "integrity": "sha512-4Qmkaps9yqmpjY5pvpkfOerYgKNUGzQpFxV6rnS7c/JfYbDSU0y6WpbbredB5cCpLFGJEqYX40WUmxMkwhWCjw==", "cpu": [ "loong64" ], @@ -1897,9 +1897,9 @@ ] }, "node_modules/@rollup/rollup-linux-powerpc64le-gnu": { - "version": "4.28.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.28.1.tgz", - "integrity": "sha512-pTnTdBuC2+pt1Rmm2SV7JWRqzhYpEILML4PKODqLz+C7Ou2apEV52h19CR7es+u04KlqplggmN9sqZlekg3R1A==", + "version": "4.34.6", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.34.6.tgz", + "integrity": "sha512-Zsrtux3PuaxuBTX/zHdLaFmcofWGzaWW1scwLU3ZbW/X+hSsFbz9wDIp6XvnT7pzYRl9MezWqEqKy7ssmDEnuQ==", "cpu": [ "ppc64" ], @@ -1911,9 +1911,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.28.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.28.1.tgz", - "integrity": "sha512-vWXy1Nfg7TPBSuAncfInmAI/WZDd5vOklyLJDdIRKABcZWojNDY0NJwruY2AcnCLnRJKSaBgf/GiJfauu8cQZA==", + "version": "4.34.6", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.34.6.tgz", + "integrity": "sha512-aK+Zp+CRM55iPrlyKiU3/zyhgzWBxLVrw2mwiQSYJRobCURb781+XstzvA8Gkjg/hbdQFuDw44aUOxVQFycrAg==", "cpu": [ "riscv64" ], @@ -1925,9 +1925,9 @@ ] }, "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.28.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.28.1.tgz", - "integrity": "sha512-/yqC2Y53oZjb0yz8PVuGOQQNOTwxcizudunl/tFs1aLvObTclTwZ0JhXF2XcPT/zuaymemCDSuuUPXJJyqeDOg==", + "version": "4.34.6", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.34.6.tgz", + "integrity": "sha512-WoKLVrY9ogmaYPXwTH326+ErlCIgMmsoRSx6bO+l68YgJnlOXhygDYSZe/qbUJCSiCiZAQ+tKm88NcWuUXqOzw==", "cpu": [ "s390x" ], @@ -1939,9 +1939,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.28.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.28.1.tgz", - "integrity": "sha512-fzgeABz7rrAlKYB0y2kSEiURrI0691CSL0+KXwKwhxvj92VULEDQLpBYLHpF49MSiPG4sq5CK3qHMnb9tlCjBw==", + "version": "4.34.6", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.34.6.tgz", + "integrity": "sha512-Sht4aFvmA4ToHd2vFzwMFaQCiYm2lDFho5rPcvPBT5pCdC+GwHG6CMch4GQfmWTQ1SwRKS0dhDYb54khSrjDWw==", "cpu": [ "x64" ], @@ -1953,9 +1953,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.28.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.28.1.tgz", - "integrity": "sha512-xQTDVzSGiMlSshpJCtudbWyRfLaNiVPXt1WgdWTwWz9n0U12cI2ZVtWe/Jgwyv/6wjL7b66uu61Vg0POWVfz4g==", + "version": "4.34.6", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.34.6.tgz", + "integrity": "sha512-zmmpOQh8vXc2QITsnCiODCDGXFC8LMi64+/oPpPx5qz3pqv0s6x46ps4xoycfUiVZps5PFn1gksZzo4RGTKT+A==", "cpu": [ "x64" ], @@ -1967,9 +1967,9 @@ ] }, "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.28.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.28.1.tgz", - "integrity": "sha512-wSXmDRVupJstFP7elGMgv+2HqXelQhuNf+IS4V+nUpNVi/GUiBgDmfwD0UGN3pcAnWsgKG3I52wMOBnk1VHr/A==", + "version": "4.34.6", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.34.6.tgz", + "integrity": "sha512-3/q1qUsO/tLqGBaD4uXsB6coVGB3usxw3qyeVb59aArCgedSF66MPdgRStUd7vbZOsko/CgVaY5fo2vkvPLWiA==", "cpu": [ "arm64" ], @@ -1981,9 +1981,9 @@ ] }, "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.28.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.28.1.tgz", - "integrity": "sha512-ZkyTJ/9vkgrE/Rk9vhMXhf8l9D+eAhbAVbsGsXKy2ohmJaWg0LPQLnIxRdRp/bKyr8tXuPlXhIoGlEB5XpJnGA==", + "version": "4.34.6", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.34.6.tgz", + "integrity": "sha512-oLHxuyywc6efdKVTxvc0135zPrRdtYVjtVD5GUm55I3ODxhU/PwkQFD97z16Xzxa1Fz0AEe4W/2hzRtd+IfpOA==", "cpu": [ "ia32" ], @@ -1995,9 +1995,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.28.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.28.1.tgz", - "integrity": "sha512-ZvK2jBafvttJjoIdKm/Q/Bh7IJ1Ose9IBOwpOXcOvW3ikGTQGmKDgxTC6oCAzW6PynbkKP8+um1du81XJHZ0JA==", + "version": "4.34.6", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.34.6.tgz", + "integrity": "sha512-0PVwmgzZ8+TZ9oGBmdZoQVXflbvuwzN/HRclujpl4N/q3i+y0lqLw8n1bXA8ru3sApDjlmONaNAuYr38y1Kr9w==", "cpu": [ "x64" ], @@ -2042,9 +2042,9 @@ } }, "node_modules/@sveltejs/kit": { - "version": "2.16.0", - "resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.16.0.tgz", - "integrity": "sha512-S9i1ZWKqluzoaJ6riYnEdbe+xJluMTMkhABouBa66GaWcAyCjW/jAc0NdJQJ/DXyK1CnP5quBW25e99MNyvLxA==", + "version": "2.17.1", + "resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.17.1.tgz", + "integrity": "sha512-CpoGSLqE2MCmcQwA2CWJvOsZ9vW+p/1H3itrFykdgajUNAEyQPbsaSn7fZb6PLHQwe+07njxje9ss0fjZoCAyw==", "dev": true, "license": "MIT", "dependencies": { @@ -2357,9 +2357,9 @@ } }, "node_modules/@testing-library/user-event": { - "version": "14.6.0", - "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.0.tgz", - "integrity": "sha512-+jsfK7kVJbqnCYtLTln8Ja/NmVrZRwBJHmHR9IxIVccMWSOZ6Oy0FkDJNeyVu4QSpMNmRfy10Xb76ObRDlWWBQ==", + "version": "14.6.1", + "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.1.tgz", + "integrity": "sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==", "dev": true, "license": "MIT", "engines": { @@ -2492,21 +2492,21 @@ } }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.20.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.20.0.tgz", - "integrity": "sha512-naduuphVw5StFfqp4Gq4WhIBE2gN1GEmMUExpJYknZJdRnc+2gDzB8Z3+5+/Kv33hPQRDGzQO/0opHE72lZZ6A==", + "version": "8.23.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.23.0.tgz", + "integrity": "sha512-vBz65tJgRrA1Q5gWlRfvoH+w943dq9K1p1yDBY2pc+a1nbBLZp7fB9+Hk8DaALUbzjqlMfgaqlVPT1REJdkt/w==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/regexpp": "^4.10.0", - "@typescript-eslint/scope-manager": "8.20.0", - "@typescript-eslint/type-utils": "8.20.0", - "@typescript-eslint/utils": "8.20.0", - "@typescript-eslint/visitor-keys": "8.20.0", + "@typescript-eslint/scope-manager": "8.23.0", + "@typescript-eslint/type-utils": "8.23.0", + "@typescript-eslint/utils": "8.23.0", + "@typescript-eslint/visitor-keys": "8.23.0", "graphemer": "^1.4.0", "ignore": "^5.3.1", "natural-compare": "^1.4.0", - "ts-api-utils": "^2.0.0" + "ts-api-utils": "^2.0.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -2522,16 +2522,16 @@ } }, "node_modules/@typescript-eslint/parser": { - "version": "8.20.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.20.0.tgz", - "integrity": "sha512-gKXG7A5HMyjDIedBi6bUrDcun8GIjnI8qOwVLiY3rx6T/sHP/19XLJOnIq/FgQvWLHja5JN/LSE7eklNBr612g==", + "version": "8.23.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.23.0.tgz", + "integrity": "sha512-h2lUByouOXFAlMec2mILeELUbME5SZRN/7R9Cw2RD2lRQQY08MWMM+PmVVKKJNK1aIwqTo9t/0CvOxwPbRIE2Q==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/scope-manager": "8.20.0", - "@typescript-eslint/types": "8.20.0", - "@typescript-eslint/typescript-estree": "8.20.0", - "@typescript-eslint/visitor-keys": "8.20.0", + "@typescript-eslint/scope-manager": "8.23.0", + "@typescript-eslint/types": "8.23.0", + "@typescript-eslint/typescript-estree": "8.23.0", + "@typescript-eslint/visitor-keys": "8.23.0", "debug": "^4.3.4" }, "engines": { @@ -2547,14 +2547,14 @@ } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.20.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.20.0.tgz", - "integrity": "sha512-J7+VkpeGzhOt3FeG1+SzhiMj9NzGD/M6KoGn9f4dbz3YzK9hvbhVTmLj/HiTp9DazIzJ8B4XcM80LrR9Dm1rJw==", + "version": "8.23.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.23.0.tgz", + "integrity": "sha512-OGqo7+dXHqI7Hfm+WqkZjKjsiRtFUQHPdGMXzk5mYXhJUedO7e/Y7i8AK3MyLMgZR93TX4bIzYrfyVjLC+0VSw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.20.0", - "@typescript-eslint/visitor-keys": "8.20.0" + "@typescript-eslint/types": "8.23.0", + "@typescript-eslint/visitor-keys": "8.23.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -2565,16 +2565,16 @@ } }, "node_modules/@typescript-eslint/type-utils": { - "version": "8.20.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.20.0.tgz", - "integrity": "sha512-bPC+j71GGvA7rVNAHAtOjbVXbLN5PkwqMvy1cwGeaxUoRQXVuKCebRoLzm+IPW/NtFFpstn1ummSIasD5t60GA==", + "version": "8.23.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.23.0.tgz", + "integrity": "sha512-iIuLdYpQWZKbiH+RkCGc6iu+VwscP5rCtQ1lyQ7TYuKLrcZoeJVpcLiG8DliXVkUxirW/PWlmS+d6yD51L9jvA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/typescript-estree": "8.20.0", - "@typescript-eslint/utils": "8.20.0", + "@typescript-eslint/typescript-estree": "8.23.0", + "@typescript-eslint/utils": "8.23.0", "debug": "^4.3.4", - "ts-api-utils": "^2.0.0" + "ts-api-utils": "^2.0.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -2589,9 +2589,9 @@ } }, "node_modules/@typescript-eslint/types": { - "version": "8.20.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.20.0.tgz", - "integrity": "sha512-cqaMiY72CkP+2xZRrFt3ExRBu0WmVitN/rYPZErA80mHjHx/Svgp8yfbzkJmDoQ/whcytOPO9/IZXnOc+wigRA==", + "version": "8.23.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.23.0.tgz", + "integrity": "sha512-1sK4ILJbCmZOTt9k4vkoulT6/y5CHJ1qUYxqpF1K/DBAd8+ZUL4LlSCxOssuH5m4rUaaN0uS0HlVPvd45zjduQ==", "dev": true, "license": "MIT", "engines": { @@ -2603,20 +2603,20 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.20.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.20.0.tgz", - "integrity": "sha512-Y7ncuy78bJqHI35NwzWol8E0X7XkRVS4K4P4TCyzWkOJih5NDvtoRDW4Ba9YJJoB2igm9yXDdYI/+fkiiAxPzA==", + "version": "8.23.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.23.0.tgz", + "integrity": "sha512-LcqzfipsB8RTvH8FX24W4UUFk1bl+0yTOf9ZA08XngFwMg4Kj8A+9hwz8Cr/ZS4KwHrmo9PJiLZkOt49vPnuvQ==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.20.0", - "@typescript-eslint/visitor-keys": "8.20.0", + "@typescript-eslint/types": "8.23.0", + "@typescript-eslint/visitor-keys": "8.23.0", "debug": "^4.3.4", "fast-glob": "^3.3.2", "is-glob": "^4.0.3", "minimatch": "^9.0.4", "semver": "^7.6.0", - "ts-api-utils": "^2.0.0" + "ts-api-utils": "^2.0.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -2656,16 +2656,16 @@ } }, "node_modules/@typescript-eslint/utils": { - "version": "8.20.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.20.0.tgz", - "integrity": "sha512-dq70RUw6UK9ei7vxc4KQtBRk7qkHZv447OUZ6RPQMQl71I3NZxQJX/f32Smr+iqWrB02pHKn2yAdHBb0KNrRMA==", + "version": "8.23.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.23.0.tgz", + "integrity": "sha512-uB/+PSo6Exu02b5ZEiVtmY6RVYO7YU5xqgzTIVZwTHvvK3HsL8tZZHFaTLFtRG3CsV4A5mhOv+NZx5BlhXPyIA==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.4.0", - "@typescript-eslint/scope-manager": "8.20.0", - "@typescript-eslint/types": "8.20.0", - "@typescript-eslint/typescript-estree": "8.20.0" + "@typescript-eslint/scope-manager": "8.23.0", + "@typescript-eslint/types": "8.23.0", + "@typescript-eslint/typescript-estree": "8.23.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -2680,13 +2680,13 @@ } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.20.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.20.0.tgz", - "integrity": "sha512-v/BpkeeYAsPkKCkR8BDwcno0llhzWVqPOamQrAEMdpZav2Y9OVjd9dwJyBLJWwf335B5DmlifECIkZRJCaGaHA==", + "version": "8.23.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.23.0.tgz", + "integrity": "sha512-oWWhcWDLwDfu++BGTZcmXWqpwtkwb5o7fxUIGksMQQDSdPW9prsSnfIOZMlsj4vBOSrcnjIUZMiIjODgGosFhQ==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.20.0", + "@typescript-eslint/types": "8.23.0", "eslint-visitor-keys": "^4.2.0" }, "engines": { @@ -2711,9 +2711,9 @@ } }, "node_modules/@vitest/coverage-v8": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-3.0.3.tgz", - "integrity": "sha512-uVbJ/xhImdNtzPnLyxCZJMTeTIYdgcC2nWtBBBpR1H6z0w8m7D+9/zrDIx2nNxgMg9r+X8+RY2qVpUDeW2b3nw==", + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-3.0.5.tgz", + "integrity": "sha512-zOOWIsj5fHh3jjGwQg+P+J1FW3s4jBu1Zqga0qW60yutsBtqEqNEJKWYh7cYn1yGD+1bdPsPdC/eL4eVK56xMg==", "dev": true, "license": "MIT", "dependencies": { @@ -2734,8 +2734,8 @@ "url": "https://opencollective.com/vitest" }, "peerDependencies": { - "@vitest/browser": "3.0.3", - "vitest": "3.0.3" + "@vitest/browser": "3.0.5", + "vitest": "3.0.5" }, "peerDependenciesMeta": { "@vitest/browser": { @@ -2762,14 +2762,14 @@ } }, "node_modules/@vitest/expect": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.0.3.tgz", - "integrity": "sha512-SbRCHU4qr91xguu+dH3RUdI5dC86zm8aZWydbp961aIR7G8OYNN6ZiayFuf9WAngRbFOfdrLHCGgXTj3GtoMRQ==", + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.0.5.tgz", + "integrity": "sha512-nNIOqupgZ4v5jWuQx2DSlHLEs7Q4Oh/7AYwNyE+k0UQzG7tSmjPXShUikn1mpNGzYEN2jJbTvLejwShMitovBA==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/spy": "3.0.3", - "@vitest/utils": "3.0.3", + "@vitest/spy": "3.0.5", + "@vitest/utils": "3.0.5", "chai": "^5.1.2", "tinyrainbow": "^2.0.0" }, @@ -2778,13 +2778,13 @@ } }, "node_modules/@vitest/mocker": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.0.3.tgz", - "integrity": "sha512-XT2XBc4AN9UdaxJAeIlcSZ0ILi/GzmG5G8XSly4gaiqIvPV3HMTSIDZWJVX6QRJ0PX1m+W8Cy0K9ByXNb/bPIA==", + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.0.5.tgz", + "integrity": "sha512-CLPNBFBIE7x6aEGbIjaQAX03ZZlBMaWwAjBdMkIf/cAn6xzLTiM3zYqO/WAbieEjsAZir6tO71mzeHZoodThvw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/spy": "3.0.3", + "@vitest/spy": "3.0.5", "estree-walker": "^3.0.3", "magic-string": "^0.30.17" }, @@ -2805,9 +2805,9 @@ } }, "node_modules/@vitest/pretty-format": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.0.3.tgz", - "integrity": "sha512-gCrM9F7STYdsDoNjGgYXKPq4SkSxwwIU5nkaQvdUxiQ0EcNlez+PdKOVIsUJvh9P9IeIFmjn4IIREWblOBpP2Q==", + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.0.5.tgz", + "integrity": "sha512-CjUtdmpOcm4RVtB+up8r2vVDLR16Mgm/bYdkGFe3Yj/scRfCpbSi2W/BDSDcFK7ohw8UXvjMbOp9H4fByd/cOA==", "dev": true, "license": "MIT", "dependencies": { @@ -2818,38 +2818,38 @@ } }, "node_modules/@vitest/runner": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.0.3.tgz", - "integrity": "sha512-Rgi2kOAk5ZxWZlwPguRJFOBmWs6uvvyAAR9k3MvjRvYrG7xYvKChZcmnnpJCS98311CBDMqsW9MzzRFsj2gX3g==", + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.0.5.tgz", + "integrity": "sha512-BAiZFityFexZQi2yN4OX3OkJC6scwRo8EhRB0Z5HIGGgd2q+Nq29LgHU/+ovCtd0fOfXj5ZI6pwdlUmC5bpi8A==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/utils": "3.0.3", - "pathe": "^2.0.1" + "@vitest/utils": "3.0.5", + "pathe": "^2.0.2" }, "funding": { "url": "https://opencollective.com/vitest" } }, "node_modules/@vitest/snapshot": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.0.3.tgz", - "integrity": "sha512-kNRcHlI4txBGztuJfPEJ68VezlPAXLRT1u5UCx219TU3kOG2DplNxhWLwDf2h6emwmTPogzLnGVwP6epDaJN6Q==", + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.0.5.tgz", + "integrity": "sha512-GJPZYcd7v8QNUJ7vRvLDmRwl+a1fGg4T/54lZXe+UOGy47F9yUfE18hRCtXL5aHN/AONu29NGzIXSVFh9K0feA==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "3.0.3", + "@vitest/pretty-format": "3.0.5", "magic-string": "^0.30.17", - "pathe": "^2.0.1" + "pathe": "^2.0.2" }, "funding": { "url": "https://opencollective.com/vitest" } }, "node_modules/@vitest/spy": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.0.3.tgz", - "integrity": "sha512-7/dgux8ZBbF7lEIKNnEqQlyRaER9nkAL9eTmdKJkDO3hS8p59ATGwKOCUDHcBLKr7h/oi/6hP+7djQk8049T2A==", + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.0.5.tgz", + "integrity": "sha512-5fOzHj0WbUNqPK6blI/8VzZdkBlQLnT25knX0r4dbZI9qoZDf3qAdjoMmDcLG5A83W6oUUFJgUd0EYBc2P5xqg==", "dev": true, "license": "MIT", "dependencies": { @@ -2860,13 +2860,13 @@ } }, "node_modules/@vitest/utils": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.0.3.tgz", - "integrity": "sha512-f+s8CvyzPtMFY1eZKkIHGhPsQgYo5qCm6O8KZoim9qm1/jT64qBgGpO5tHscNH6BzRHM+edLNOP+3vO8+8pE/A==", + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.0.5.tgz", + "integrity": "sha512-N9AX0NUoUtVwKwy21JtwzaqR5L5R5A99GAbrHfCCXK1lp593i/3AZAXhSP43wRQuxYsflrdzEfXZFo1reR1Nkg==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "3.0.3", + "@vitest/pretty-format": "3.0.5", "loupe": "^3.1.2", "tinyrainbow": "^2.0.0" }, @@ -3786,16 +3786,16 @@ "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==" }, "node_modules/engine.io-client": { - "version": "6.5.4", - "resolved": "https://registry.npmjs.org/engine.io-client/-/engine.io-client-6.5.4.tgz", - "integrity": "sha512-GeZeeRjpD2qf49cZQ0Wvh/8NJNfeXkXXcoGh+F77oEAgo9gUHwT1fCRxSNU+YEEaysOJTnsFHmM5oAcPy4ntvQ==", + "version": "6.6.3", + "resolved": "https://registry.npmjs.org/engine.io-client/-/engine.io-client-6.6.3.tgz", + "integrity": "sha512-T0iLjnyNWahNyv/lcjS2y4oE358tVS/SYQNxYXGAJ9/GLgH4VCvOQ/mhTjqU88mLZCQgiG8RIegFHYCdVC+j5w==", "license": "MIT", "dependencies": { "@socket.io/component-emitter": "~3.1.0", "debug": "~4.3.1", "engine.io-parser": "~5.2.1", "ws": "~8.17.1", - "xmlhttprequest-ssl": "~2.0.0" + "xmlhttprequest-ssl": "~2.1.1" } }, "node_modules/engine.io-parser": { @@ -3946,9 +3946,9 @@ } }, "node_modules/eslint": { - "version": "9.18.0", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.18.0.tgz", - "integrity": "sha512-+waTfRWQlSbpt3KWE+CjrPPYnbq9kfZIYUqapc0uBXyjTp8aYXZDsUH16m39Ryq3NjAVP4tjuF7KaukeqoCoaA==", + "version": "9.19.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.19.0.tgz", + "integrity": "sha512-ug92j0LepKlbbEv6hD911THhoRHmbdXt2gX+VDABAW/Ir7D3nqKdv5Pf5vtlyY6HQMTEP2skXY43ueqTCWssEA==", "dev": true, "license": "MIT", "dependencies": { @@ -3957,7 +3957,7 @@ "@eslint/config-array": "^0.19.0", "@eslint/core": "^0.10.0", "@eslint/eslintrc": "^3.2.0", - "@eslint/js": "9.18.0", + "@eslint/js": "9.19.0", "@eslint/plugin-kit": "^0.2.5", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", @@ -5005,14 +5005,14 @@ } }, "node_modules/intl-messageformat": { - "version": "10.7.12", - "resolved": "https://registry.npmjs.org/intl-messageformat/-/intl-messageformat-10.7.12.tgz", - "integrity": "sha512-4HBsPDJ61jZwNikauvm0mcLvs1AfCBbihiqOX2AGs1MX7SA1H0SNKJRSWxpZpToGoNzvoYLsJJ2pURkbEDg+Dw==", + "version": "10.7.14", + "resolved": "https://registry.npmjs.org/intl-messageformat/-/intl-messageformat-10.7.14.tgz", + "integrity": "sha512-mMGnE4E1otdEutV5vLUdCxRJygHB5ozUBxsPB5qhitewssrS/qGruq9bmvIRkkGsNeK5ZWLfYRld18UHGTIifQ==", "license": "BSD-3-Clause", "dependencies": { "@formatjs/ecma402-abstract": "2.3.2", "@formatjs/fast-memoize": "2.2.6", - "@formatjs/icu-messageformat-parser": "2.10.0", + "@formatjs/icu-messageformat-parser": "2.11.0", "tslib": "2" } }, @@ -5479,9 +5479,9 @@ "dev": true }, "node_modules/loupe": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.1.2.tgz", - "integrity": "sha512-23I4pFZHmAemUnz8WZXbYRSKYj801VDaNv9ETuMh7IrMc7VuVVSo+Z9iLE3ni30+U48iDWfi30d3twAXBYmnCg==", + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.1.3.tgz", + "integrity": "sha512-kkIp7XSkP78ZxJEsSxW3712C6teJVoeHHwgo9zJ380de7IYyJ2ISlxojcH2pC5OFLewESmnRi/+XCDIEEVyoug==", "dev": true, "license": "MIT" }, @@ -6711,9 +6711,9 @@ } }, "node_modules/rollup": { - "version": "4.28.1", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.28.1.tgz", - "integrity": "sha512-61fXYl/qNVinKmGSTHAZ6Yy8I3YIJC/r2m9feHo6SwVAVcLT5MPwOUFe7EuURA/4m0NR8lXG4BBXuo/IZEsjMg==", + "version": "4.34.6", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.34.6.tgz", + "integrity": "sha512-wc2cBWqJgkU3Iz5oztRkQbfVkbxoz5EhnCGOrnJvnLnQ7O0WhQUYyv18qQI79O8L7DdHrrlJNeCHd4VGpnaXKQ==", "dev": true, "license": "MIT", "dependencies": { @@ -6727,25 +6727,25 @@ "npm": ">=8.0.0" }, "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.28.1", - "@rollup/rollup-android-arm64": "4.28.1", - "@rollup/rollup-darwin-arm64": "4.28.1", - "@rollup/rollup-darwin-x64": "4.28.1", - "@rollup/rollup-freebsd-arm64": "4.28.1", - "@rollup/rollup-freebsd-x64": "4.28.1", - "@rollup/rollup-linux-arm-gnueabihf": "4.28.1", - "@rollup/rollup-linux-arm-musleabihf": "4.28.1", - "@rollup/rollup-linux-arm64-gnu": "4.28.1", - "@rollup/rollup-linux-arm64-musl": "4.28.1", - "@rollup/rollup-linux-loongarch64-gnu": "4.28.1", - "@rollup/rollup-linux-powerpc64le-gnu": "4.28.1", - "@rollup/rollup-linux-riscv64-gnu": "4.28.1", - "@rollup/rollup-linux-s390x-gnu": "4.28.1", - "@rollup/rollup-linux-x64-gnu": "4.28.1", - "@rollup/rollup-linux-x64-musl": "4.28.1", - "@rollup/rollup-win32-arm64-msvc": "4.28.1", - "@rollup/rollup-win32-ia32-msvc": "4.28.1", - "@rollup/rollup-win32-x64-msvc": "4.28.1", + "@rollup/rollup-android-arm-eabi": "4.34.6", + "@rollup/rollup-android-arm64": "4.34.6", + "@rollup/rollup-darwin-arm64": "4.34.6", + "@rollup/rollup-darwin-x64": "4.34.6", + "@rollup/rollup-freebsd-arm64": "4.34.6", + "@rollup/rollup-freebsd-x64": "4.34.6", + "@rollup/rollup-linux-arm-gnueabihf": "4.34.6", + "@rollup/rollup-linux-arm-musleabihf": "4.34.6", + "@rollup/rollup-linux-arm64-gnu": "4.34.6", + "@rollup/rollup-linux-arm64-musl": "4.34.6", + "@rollup/rollup-linux-loongarch64-gnu": "4.34.6", + "@rollup/rollup-linux-powerpc64le-gnu": "4.34.6", + "@rollup/rollup-linux-riscv64-gnu": "4.34.6", + "@rollup/rollup-linux-s390x-gnu": "4.34.6", + "@rollup/rollup-linux-x64-gnu": "4.34.6", + "@rollup/rollup-linux-x64-musl": "4.34.6", + "@rollup/rollup-win32-arm64-msvc": "4.34.6", + "@rollup/rollup-win32-ia32-msvc": "4.34.6", + "@rollup/rollup-win32-x64-msvc": "4.34.6", "fsevents": "~2.3.2" } }, @@ -7037,14 +7037,14 @@ } }, "node_modules/socket.io-client": { - "version": "4.7.5", - "resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-4.7.5.tgz", - "integrity": "sha512-sJ/tqHOCe7Z50JCBCXrsY3I2k03iOiUe+tj1OmKeD2lXPiGH/RUCdTZFoqVyN7l1MnpIzPrGtLcijffmeouNlQ==", + "version": "4.8.1", + "resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-4.8.1.tgz", + "integrity": "sha512-hJVXfu3E28NmzGk8o1sHhN3om52tRvwYeidbj7xKy2eIIse5IoKX3USlS6Tqt3BHAtflLIkCQBkzVrEEfWUyYQ==", "license": "MIT", "dependencies": { "@socket.io/component-emitter": "~3.1.0", "debug": "~4.3.2", - "engine.io-client": "~6.5.2", + "engine.io-client": "~6.6.1", "socket.io-parser": "~4.2.4" }, "engines": { @@ -7338,9 +7338,9 @@ } }, "node_modules/svelte": { - "version": "5.19.0", - "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.19.0.tgz", - "integrity": "sha512-qvd2GvvYnJxS/MteQKFSMyq8cQrAAut28QZ39ySv9k3ggmhw4Au4Rfcsqva74i0xMys//OhbhVCNfXPrDzL/Bg==", + "version": "5.19.8", + "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.19.8.tgz", + "integrity": "sha512-56Vd/nwJrljV0w7RCV1A8sB4/yjSbWW5qrGDTAzp7q42OxwqEWT+6obWzDt41tHjIW+C9Fs2ygtejjJrXR+ZPA==", "license": "MIT", "dependencies": { "@ampproject/remapping": "^2.3.0", @@ -8321,9 +8321,9 @@ } }, "node_modules/ts-api-utils": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.0.0.tgz", - "integrity": "sha512-xCt/TOAc+EOHS1XPnijD3/yzpH6qg2xppZO1YDqGoVsNXfQfzHpOdNuXwrwOU8u4ITXJyDCTyt8w5g1sZv9ynQ==", + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.0.1.tgz", + "integrity": "sha512-dnlgjFSVetynI8nzgJ+qF62efpglpWRk8isUEWZGWlJYySCTD6aKvbUDu+zbPeDakk3bg5H4XpitHukgfL1m9w==", "dev": true, "license": "MIT", "engines": { @@ -8493,15 +8493,15 @@ } }, "node_modules/vite": { - "version": "6.0.11", - "resolved": "https://registry.npmjs.org/vite/-/vite-6.0.11.tgz", - "integrity": "sha512-4VL9mQPKoHy4+FE0NnRE/kbY51TOfaknxAjt3fJbGJxhIpBZiqVzlZDEesWWsuREXHwNdAoOFZ9MkPEVXczHwg==", + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.1.0.tgz", + "integrity": "sha512-RjjMipCKVoR4hVfPY6GQTgveinjNuyLw+qruksLDvA5ktI1150VmcMBKmQaEWJhg/j6Uaf6dNCNA0AfdzUb/hQ==", "dev": true, "license": "MIT", "dependencies": { "esbuild": "^0.24.2", - "postcss": "^8.4.49", - "rollup": "^4.23.0" + "postcss": "^8.5.1", + "rollup": "^4.30.1" }, "bin": { "vite": "bin/vite.js" @@ -8578,16 +8578,16 @@ } }, "node_modules/vite-node": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.0.3.tgz", - "integrity": "sha512-0sQcwhwAEw/UJGojbhOrnq3HtiZ3tC7BzpAa0lx3QaTX0S3YX70iGcik25UBdB96pmdwjyY2uyKNYruxCDmiEg==", + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.0.5.tgz", + "integrity": "sha512-02JEJl7SbtwSDJdYS537nU6l+ktdvcREfLksk/NDAqtdKWGqHl+joXzEubHROmS3E6pip+Xgu2tFezMu75jH7A==", "dev": true, "license": "MIT", "dependencies": { "cac": "^6.7.14", "debug": "^4.4.0", "es-module-lexer": "^1.6.0", - "pathe": "^2.0.1", + "pathe": "^2.0.2", "vite": "^5.0.0 || ^6.0.0" }, "bin": { @@ -8638,31 +8638,31 @@ } }, "node_modules/vitest": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.0.3.tgz", - "integrity": "sha512-dWdwTFUW9rcnL0LyF2F+IfvNQWB0w9DERySCk8VMG75F8k25C7LsZoh6XfCjPvcR8Nb+Lqi9JKr6vnzH7HSrpQ==", + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.0.5.tgz", + "integrity": "sha512-4dof+HvqONw9bvsYxtkfUp2uHsTN9bV2CZIi1pWgoFpL1Lld8LA1ka9q/ONSsoScAKG7NVGf2stJTI7XRkXb2Q==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/expect": "3.0.3", - "@vitest/mocker": "3.0.3", - "@vitest/pretty-format": "^3.0.3", - "@vitest/runner": "3.0.3", - "@vitest/snapshot": "3.0.3", - "@vitest/spy": "3.0.3", - "@vitest/utils": "3.0.3", + "@vitest/expect": "3.0.5", + "@vitest/mocker": "3.0.5", + "@vitest/pretty-format": "^3.0.5", + "@vitest/runner": "3.0.5", + "@vitest/snapshot": "3.0.5", + "@vitest/spy": "3.0.5", + "@vitest/utils": "3.0.5", "chai": "^5.1.2", "debug": "^4.4.0", "expect-type": "^1.1.0", "magic-string": "^0.30.17", - "pathe": "^2.0.1", + "pathe": "^2.0.2", "std-env": "^3.8.0", "tinybench": "^2.9.0", "tinyexec": "^0.3.2", "tinypool": "^1.0.2", "tinyrainbow": "^2.0.0", "vite": "^5.0.0 || ^6.0.0", - "vite-node": "3.0.3", + "vite-node": "3.0.5", "why-is-node-running": "^2.3.0" }, "bin": { @@ -8676,9 +8676,10 @@ }, "peerDependencies": { "@edge-runtime/vm": "*", + "@types/debug": "^4.1.12", "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", - "@vitest/browser": "3.0.3", - "@vitest/ui": "3.0.3", + "@vitest/browser": "3.0.5", + "@vitest/ui": "3.0.5", "happy-dom": "*", "jsdom": "*" }, @@ -8686,6 +8687,9 @@ "@edge-runtime/vm": { "optional": true }, + "@types/debug": { + "optional": true + }, "@types/node": { "optional": true }, @@ -8969,9 +8973,9 @@ "peer": true }, "node_modules/xmlhttprequest-ssl": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.0.0.tgz", - "integrity": "sha512-QKxVRxiRACQcVuQEYFsI1hhkrMlrXHPegbbd1yn9UHOmRxY+si12nQYzri3vbzt8VdTTRviqcKxcyllFas5z2A==", + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.1.2.tgz", + "integrity": "sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ==", "engines": { "node": ">=0.4.0" } diff --git a/web/package.json b/web/package.json index 47934ce7e2..d8e470d658 100644 --- a/web/package.json +++ b/web/package.json @@ -67,7 +67,7 @@ "dependencies": { "@formatjs/icu-messageformat-parser": "^2.9.8", "@immich/sdk": "file:../open-api/typescript-sdk", - "@immich/ui": "^0.15.0", + "@immich/ui": "^0.16.0", "@mapbox/mapbox-gl-rtl-text": "0.2.3", "@mdi/js": "^7.4.47", "@photo-sphere-viewer/core": "^5.11.5", @@ -82,7 +82,7 @@ "justified-layout": "^4.1.0", "lodash-es": "^4.17.21", "luxon": "^3.4.4", - "socket.io-client": "~4.7.5", + "socket.io-client": "~4.8.0", "svelte-gestures": "^5.1.3", "svelte-i18n": "^4.0.1", "svelte-local-storage-store": "^0.6.4", diff --git a/web/src/lib/components/album-page/albums-controls.svelte b/web/src/lib/components/album-page/albums-controls.svelte index b518c56b66..1fff0c29a2 100644 --- a/web/src/lib/components/album-page/albums-controls.svelte +++ b/web/src/lib/components/album-page/albums-controls.svelte @@ -175,6 +175,7 @@ color="secondary" shape="round" icon={mdiUnfoldMoreHorizontal} + aria-label={$t('expand_all')} />
@@ -187,6 +188,7 @@ color="secondary" shape="round" icon={mdiUnfoldLessHorizontal} + aria-label={$t('collapse_all')} />
diff --git a/web/src/lib/components/elements/buttons/button.svelte b/web/src/lib/components/elements/buttons/button.svelte index 7e8418e2f5..991bbaecee 100644 --- a/web/src/lib/components/elements/buttons/button.svelte +++ b/web/src/lib/components/elements/buttons/button.svelte @@ -109,7 +109,6 @@ ); - -
@@ -86,6 +87,7 @@ color="secondary" shape="round" icon={mdiUnfoldLessHorizontal} + aria-label={$t('collapse_all')} />
diff --git a/web/src/lib/components/shared-components/navigation-bar/navigation-bar.svelte b/web/src/lib/components/shared-components/navigation-bar/navigation-bar.svelte index 2f615b7f3f..10df5a6f0e 100644 --- a/web/src/lib/components/shared-components/navigation-bar/navigation-bar.svelte +++ b/web/src/lib/components/shared-components/navigation-bar/navigation-bar.svelte @@ -94,6 +94,7 @@ title={$t('support_and_feedback')} icon={mdiHelpCircleOutline} onclick={() => (shouldShowHelpPanel = !shouldShowHelpPanel)} + aria-label={$t('support_and_feedback')} />
diff --git a/web/src/routes/(user)/utilities/duplicates/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/utilities/duplicates/[[photos=photos]]/[[assetId=id]]/+page.svelte index 4627d981b6..d517dad943 100644 --- a/web/src/routes/(user)/utilities/duplicates/[[photos=photos]]/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/utilities/duplicates/[[photos=photos]]/[[assetId=id]]/+page.svelte @@ -190,6 +190,7 @@ icon={mdiKeyboard} title={$t('show_keyboard_shortcuts')} onclick={() => (isShowKeyboardShortcut = !isShowKeyboardShortcut)} + aria-label={$t('show_keyboard_shortcuts')} /> {/snippet} diff --git a/web/src/routes/admin/user-management/+page.svelte b/web/src/routes/admin/user-management/+page.svelte index 1ad56644f5..aadbf3e949 100644 --- a/web/src/routes/admin/user-management/+page.svelte +++ b/web/src/routes/admin/user-management/+page.svelte @@ -170,6 +170,7 @@ variant="ghost" onclick={() => copyToClipboard(newPassword)} title={$t('copy_password')} + aria-label={$t('copy_password')} /> @@ -225,6 +226,7 @@ icon={mdiPencilOutline} title={$t('edit_user')} onclick={() => editUserHandler(immichUser)} + aria-label={$t('edit_user')} /> {#if immichUser.id !== $user.id} deleteUserHandler(immichUser)} + aria-label={$t('delete_user')} /> {/if} {/if} @@ -245,6 +248,7 @@ values: { date: getDeleteDate(immichUser.deletedAt) }, })} onclick={() => restoreUserHandler(immichUser)} + aria-label={$t('admin.user_restore_scheduled_removal')} /> {/if} From 9d85272c2bc8c3470689ac561532dd5b5168bc5d Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Tue, 11 Feb 2025 14:08:13 -0500 Subject: [PATCH 123/395] refactor: repositories (#16036) --- server/src/cores/storage.core.spec.ts | 1 + server/src/cores/storage.core.ts | 24 +- server/src/dtos/asset.dto.ts | 2 +- server/src/dtos/partner.dto.ts | 2 +- server/src/dtos/time-bucket.dto.ts | 2 +- server/src/entities/asset.entity.ts | 4 +- server/src/enum.ts | 7 + server/src/interfaces/album.interface.ts | 35 --- server/src/interfaces/asset.interface.ts | 170 -------------- server/src/interfaces/crypto.interface.ts | 13 -- server/src/interfaces/database.interface.ts | 78 ------- server/src/interfaces/library.interface.ts | 17 -- server/src/interfaces/move.interface.ts | 15 -- server/src/interfaces/partner.interface.ts | 23 -- server/src/interfaces/person.interface.ts | 89 -------- server/src/interfaces/search.interface.ts | 213 ------------------ .../src/interfaces/shared-link.interface.ts | 19 -- server/src/interfaces/stack.interface.ts | 18 -- server/src/interfaces/storage.interface.ts | 57 ----- server/src/interfaces/tag.interface.ts | 21 -- server/src/interfaces/user.interface.ts | 45 ---- .../1700713994428-AddCLIPEmbeddingIndex.ts | 2 +- .../1700714033632-AddFaceEmbeddingIndex.ts | 2 +- .../1718486162779-AddFaceSearchRelation.ts | 2 +- server/src/repositories/album.repository.ts | 14 +- server/src/repositories/asset.repository.ts | 150 ++++++++++-- server/src/repositories/config.repository.ts | 4 +- server/src/repositories/crypto.repository.ts | 5 +- .../src/repositories/database.repository.ts | 64 +++++- server/src/repositories/index.ts | 42 ++-- server/src/repositories/library.repository.ts | 3 +- server/src/repositories/move.repository.ts | 5 +- server/src/repositories/partner.repository.ts | 13 +- server/src/repositories/person.repository.ts | 60 +++-- server/src/repositories/search.repository.ts | 204 +++++++++++++++-- .../repositories/shared-link.repository.ts | 8 +- server/src/repositories/stack.repository.ts | 8 +- server/src/repositories/storage.repository.ts | 36 ++- server/src/repositories/tag.repository.ts | 5 +- server/src/repositories/user.repository.ts | 27 ++- server/src/services/album.service.ts | 2 +- server/src/services/asset.service.spec.ts | 2 +- server/src/services/backup.service.ts | 2 +- server/src/services/base.service.ts | 55 +++-- server/src/services/database.service.spec.ts | 3 +- server/src/services/database.service.ts | 9 +- server/src/services/download.service.ts | 2 +- server/src/services/duplicate.service.spec.ts | 2 +- server/src/services/duplicate.service.ts | 4 +- server/src/services/library.service.ts | 2 +- server/src/services/media.service.spec.ts | 2 +- server/src/services/media.service.ts | 2 +- server/src/services/metadata.service.spec.ts | 2 +- server/src/services/metadata.service.ts | 4 +- server/src/services/partner.service.spec.ts | 2 +- server/src/services/partner.service.ts | 2 +- server/src/services/person.service.spec.ts | 4 +- server/src/services/person.service.ts | 4 +- server/src/services/search.service.ts | 6 +- server/src/services/server.service.ts | 2 +- .../src/services/smart-info.service.spec.ts | 2 +- server/src/services/smart-info.service.ts | 4 +- .../src/services/storage-template.service.ts | 2 +- server/src/services/storage.service.ts | 2 +- server/src/services/tag.service.ts | 2 +- server/src/services/timeline.service.spec.ts | 2 +- server/src/services/timeline.service.ts | 2 +- server/src/services/user-admin.service.ts | 2 +- server/src/services/user.service.ts | 2 +- server/src/services/version.service.ts | 2 +- server/src/utils/asset.util.ts | 8 +- server/src/utils/config.ts | 2 +- server/src/utils/file.ts | 2 +- server/src/utils/tag.ts | 4 +- .../repositories/album.repository.mock.ts | 5 +- .../repositories/asset.repository.mock.ts | 5 +- .../repositories/config.repository.mock.ts | 3 +- .../repositories/crypto.repository.mock.ts | 5 +- .../repositories/database.repository.mock.ts | 5 +- .../repositories/library.repository.mock.ts | 5 +- .../test/repositories/move.repository.mock.ts | 5 +- .../repositories/partner.repository.mock.ts | 5 +- .../repositories/person.repository.mock.ts | 5 +- .../repositories/search.repository.mock.ts | 5 +- .../shared-link.repository.mock.ts | 5 +- .../repositories/stack.repository.mock.ts | 5 +- .../repositories/storage.repository.mock.ts | 5 +- .../test/repositories/tag.repository.mock.ts | 5 +- .../test/repositories/user.repository.mock.ts | 5 +- server/test/utils.ts | 41 ++-- 90 files changed, 686 insertions(+), 1088 deletions(-) delete mode 100644 server/src/interfaces/album.interface.ts delete mode 100644 server/src/interfaces/asset.interface.ts delete mode 100644 server/src/interfaces/crypto.interface.ts delete mode 100644 server/src/interfaces/database.interface.ts delete mode 100644 server/src/interfaces/library.interface.ts delete mode 100644 server/src/interfaces/move.interface.ts delete mode 100644 server/src/interfaces/partner.interface.ts delete mode 100644 server/src/interfaces/person.interface.ts delete mode 100644 server/src/interfaces/search.interface.ts delete mode 100644 server/src/interfaces/shared-link.interface.ts delete mode 100644 server/src/interfaces/stack.interface.ts delete mode 100644 server/src/interfaces/storage.interface.ts delete mode 100644 server/src/interfaces/tag.interface.ts delete mode 100644 server/src/interfaces/user.interface.ts diff --git a/server/src/cores/storage.core.spec.ts b/server/src/cores/storage.core.spec.ts index a663673306..7bb2cdb1be 100644 --- a/server/src/cores/storage.core.spec.ts +++ b/server/src/cores/storage.core.spec.ts @@ -5,6 +5,7 @@ vitest.mock('src/constants', () => ({ APP_MEDIA_LOCATION: '/photos', ADDED_IN_PREFIX: 'This property was added in ', DEPRECATED_IN_PREFIX: 'This property was deprecated in ', + IWorker: 'IWorker', })); describe('StorageCore', () => { diff --git a/server/src/cores/storage.core.ts b/server/src/cores/storage.core.ts index 86f7be9ffd..3160331dd4 100644 --- a/server/src/cores/storage.core.ts +++ b/server/src/cores/storage.core.ts @@ -4,13 +4,13 @@ import { APP_MEDIA_LOCATION } from 'src/constants'; import { AssetEntity } from 'src/entities/asset.entity'; import { PersonEntity } from 'src/entities/person.entity'; import { AssetFileType, AssetPathType, ImageFormat, PathType, PersonPathType, StorageFolder } from 'src/enum'; -import { IAssetRepository } from 'src/interfaces/asset.interface'; -import { IMoveRepository } from 'src/interfaces/move.interface'; -import { IPersonRepository } from 'src/interfaces/person.interface'; -import { IStorageRepository } from 'src/interfaces/storage.interface'; +import { AssetRepository } from 'src/repositories/asset.repository'; import { ConfigRepository } from 'src/repositories/config.repository'; import { CryptoRepository } from 'src/repositories/crypto.repository'; import { LoggingRepository } from 'src/repositories/logging.repository'; +import { MoveRepository } from 'src/repositories/move.repository'; +import { PersonRepository } from 'src/repositories/person.repository'; +import { StorageRepository } from 'src/repositories/storage.repository'; import { SystemMetadataRepository } from 'src/repositories/system-metadata.repository'; import { getAssetFiles } from 'src/utils/asset.util'; import { getConfig } from 'src/utils/config'; @@ -33,23 +33,23 @@ let instance: StorageCore | null; export class StorageCore { private constructor( - private assetRepository: IAssetRepository, + private assetRepository: AssetRepository, private configRepository: ConfigRepository, private cryptoRepository: CryptoRepository, - private moveRepository: IMoveRepository, - private personRepository: IPersonRepository, - private storageRepository: IStorageRepository, + private moveRepository: MoveRepository, + private personRepository: PersonRepository, + private storageRepository: StorageRepository, private systemMetadataRepository: SystemMetadataRepository, private logger: LoggingRepository, ) {} static create( - assetRepository: IAssetRepository, + assetRepository: AssetRepository, configRepository: ConfigRepository, cryptoRepository: CryptoRepository, - moveRepository: IMoveRepository, - personRepository: IPersonRepository, - storageRepository: IStorageRepository, + moveRepository: MoveRepository, + personRepository: PersonRepository, + storageRepository: StorageRepository, systemMetadataRepository: SystemMetadataRepository, logger: LoggingRepository, ) { diff --git a/server/src/dtos/asset.dto.ts b/server/src/dtos/asset.dto.ts index 8aa63f2f69..32b14055d5 100644 --- a/server/src/dtos/asset.dto.ts +++ b/server/src/dtos/asset.dto.ts @@ -15,7 +15,7 @@ import { } from 'class-validator'; import { BulkIdsDto } from 'src/dtos/asset-ids.response.dto'; import { AssetType } from 'src/enum'; -import { AssetStats } from 'src/interfaces/asset.interface'; +import { AssetStats } from 'src/repositories/asset.repository'; import { Optional, ValidateBoolean, ValidateUUID } from 'src/validation'; export class DeviceIdDto { diff --git a/server/src/dtos/partner.dto.ts b/server/src/dtos/partner.dto.ts index 38573998d6..9d86415dc3 100644 --- a/server/src/dtos/partner.dto.ts +++ b/server/src/dtos/partner.dto.ts @@ -1,7 +1,7 @@ import { ApiProperty } from '@nestjs/swagger'; import { IsEnum, IsNotEmpty } from 'class-validator'; import { UserResponseDto } from 'src/dtos/user.dto'; -import { PartnerDirection } from 'src/interfaces/partner.interface'; +import { PartnerDirection } from 'src/repositories/partner.repository'; export class UpdatePartnerDto { @IsNotEmpty() diff --git a/server/src/dtos/time-bucket.dto.ts b/server/src/dtos/time-bucket.dto.ts index dd7a01df35..a9dfa49a07 100644 --- a/server/src/dtos/time-bucket.dto.ts +++ b/server/src/dtos/time-bucket.dto.ts @@ -1,7 +1,7 @@ import { ApiProperty } from '@nestjs/swagger'; import { IsEnum, IsNotEmpty, IsString } from 'class-validator'; import { AssetOrder } from 'src/enum'; -import { TimeBucketSize } from 'src/interfaces/asset.interface'; +import { TimeBucketSize } from 'src/repositories/asset.repository'; import { Optional, ValidateBoolean, ValidateUUID } from 'src/validation'; export class TimeBucketDto { diff --git a/server/src/entities/asset.entity.ts b/server/src/entities/asset.entity.ts index 594dd17785..a90236de84 100644 --- a/server/src/entities/asset.entity.ts +++ b/server/src/entities/asset.entity.ts @@ -13,8 +13,8 @@ import { StackEntity } from 'src/entities/stack.entity'; import { TagEntity } from 'src/entities/tag.entity'; import { UserEntity } from 'src/entities/user.entity'; import { AssetFileType, AssetStatus, AssetType } from 'src/enum'; -import { TimeBucketSize } from 'src/interfaces/asset.interface'; -import { AssetSearchBuilderOptions } from 'src/interfaces/search.interface'; +import { TimeBucketSize } from 'src/repositories/asset.repository'; +import { AssetSearchBuilderOptions } from 'src/repositories/search.repository'; import { anyUuid, asUuid } from 'src/utils/database'; import { Column, diff --git a/server/src/enum.ts b/server/src/enum.ts index 3440d45cee..332ae50ee8 100644 --- a/server/src/enum.ts +++ b/server/src/enum.ts @@ -384,3 +384,10 @@ export enum ExifOrientation { MirrorHorizontalRotate90CW = 7, Rotate270CW = 8, } + +export enum DatabaseExtension { + CUBE = 'cube', + EARTH_DISTANCE = 'earthdistance', + VECTOR = 'vector', + VECTORS = 'vectors', +} diff --git a/server/src/interfaces/album.interface.ts b/server/src/interfaces/album.interface.ts deleted file mode 100644 index 36a6d8a1d2..0000000000 --- a/server/src/interfaces/album.interface.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { Insertable, Updateable } from 'kysely'; -import { Albums } from 'src/db'; -import { AlbumUserCreateDto } from 'src/dtos/album.dto'; -import { AlbumEntity } from 'src/entities/album.entity'; -import { IBulkAsset } from 'src/utils/asset.util'; - -export const IAlbumRepository = 'IAlbumRepository'; - -export interface AlbumAssetCount { - albumId: string; - assetCount: number; - startDate: Date | null; - endDate: Date | null; -} - -export interface AlbumInfoOptions { - withAssets: boolean; -} - -export interface IAlbumRepository extends IBulkAsset { - getById(id: string, options: AlbumInfoOptions): Promise; - getByAssetId(ownerId: string, assetId: string): Promise; - removeAsset(assetId: string): Promise; - getMetadataForIds(ids: string[]): Promise; - getOwned(ownerId: string): Promise; - getShared(ownerId: string): Promise; - getNotShared(ownerId: string): Promise; - restoreAll(userId: string): Promise; - softDeleteAll(userId: string): Promise; - deleteAll(userId: string): Promise; - create(album: Insertable, assetIds: string[], albumUsers: AlbumUserCreateDto[]): Promise; - update(id: string, album: Updateable): Promise; - delete(id: string): Promise; - updateThumbnails(): Promise; -} diff --git a/server/src/interfaces/asset.interface.ts b/server/src/interfaces/asset.interface.ts deleted file mode 100644 index 5abaf9af26..0000000000 --- a/server/src/interfaces/asset.interface.ts +++ /dev/null @@ -1,170 +0,0 @@ -import { Insertable, Updateable } from 'kysely'; -import { AssetFiles, AssetJobStatus, Assets, Exif } from 'src/db'; -import { AssetEntity } from 'src/entities/asset.entity'; -import { AssetFileType, AssetOrder, AssetStatus, AssetType } from 'src/enum'; -import { AssetSearchOptions, SearchExploreItem } from 'src/interfaces/search.interface'; -import { Paginated, PaginationOptions } from 'src/utils/pagination'; - -export type AssetStats = Record; - -export interface AssetStatsOptions { - isFavorite?: boolean; - isArchived?: boolean; - isTrashed?: boolean; -} - -export interface LivePhotoSearchOptions { - ownerId: string; - libraryId?: string | null; - livePhotoCID: string; - otherAssetId: string; - type: AssetType; -} - -export enum WithoutProperty { - THUMBNAIL = 'thumbnail', - ENCODED_VIDEO = 'encoded-video', - EXIF = 'exif', - SMART_SEARCH = 'smart-search', - DUPLICATE = 'duplicate', - FACES = 'faces', - SIDECAR = 'sidecar', -} - -export enum WithProperty { - SIDECAR = 'sidecar', -} - -export enum TimeBucketSize { - DAY = 'DAY', - MONTH = 'MONTH', -} - -export interface AssetBuilderOptions { - isArchived?: boolean; - isFavorite?: boolean; - isTrashed?: boolean; - isDuplicate?: boolean; - albumId?: string; - tagId?: string; - personId?: string; - userIds?: string[]; - withStacked?: boolean; - exifInfo?: boolean; - status?: AssetStatus; - assetType?: AssetType; -} - -export interface TimeBucketOptions extends AssetBuilderOptions { - size: TimeBucketSize; - order?: AssetOrder; -} - -export interface TimeBucketItem { - timeBucket: string; - count: number; -} - -export interface MonthDay { - day: number; - month: number; -} - -export interface AssetExploreFieldOptions { - maxFields: number; - minAssetsPerField: number; -} - -export interface AssetFullSyncOptions { - ownerId: string; - lastId?: string; - updatedUntil: Date; - limit: number; -} - -export interface AssetDeltaSyncOptions { - userIds: string[]; - updatedAfter: Date; - limit: number; -} - -export interface AssetUpdateDuplicateOptions { - targetDuplicateId: string | null; - assetIds: string[]; - duplicateIds: string[]; -} - -export interface UpsertFileOptions { - assetId: string; - type: AssetFileType; - path: string; -} - -export interface AssetGetByChecksumOptions { - ownerId: string; - checksum: Buffer; - libraryId?: string; -} - -export type AssetPathEntity = Pick; - -export interface GetByIdsRelations { - exifInfo?: boolean; - faces?: { person?: boolean }; - files?: boolean; - library?: boolean; - owner?: boolean; - smartSearch?: boolean; - stack?: { assets?: boolean }; - tags?: boolean; -} - -export interface DuplicateGroup { - duplicateId: string; - assets: AssetEntity[]; -} - -export interface DayOfYearAssets { - yearsAgo: number; - assets: AssetEntity[]; -} - -export const IAssetRepository = 'IAssetRepository'; - -export interface IAssetRepository { - create(asset: Insertable): Promise; - getByIds(ids: string[], relations?: GetByIdsRelations): Promise; - getByIdsWithAllRelations(ids: string[]): Promise; - getByDayOfYear(ownerIds: string[], monthDay: MonthDay): Promise; - getByChecksum(options: AssetGetByChecksumOptions): Promise; - getByChecksums(userId: string, checksums: Buffer[]): Promise; - getUploadAssetIdByChecksum(ownerId: string, checksum: Buffer): Promise; - getByAlbumId(pagination: PaginationOptions, albumId: string): Paginated; - getByDeviceIds(ownerId: string, deviceId: string, deviceAssetIds: string[]): Promise; - getByUserId(pagination: PaginationOptions, userId: string, options?: AssetSearchOptions): Paginated; - getById(id: string, relations?: GetByIdsRelations): Promise; - getWithout(pagination: PaginationOptions, property: WithoutProperty): Paginated; - getRandom(userIds: string[], count: number): Promise; - getLastUpdatedAssetForAlbumId(albumId: string): Promise; - getByLibraryIdAndOriginalPath(libraryId: string, originalPath: string): Promise; - deleteAll(ownerId: string): Promise; - getAll(pagination: PaginationOptions, options?: AssetSearchOptions): Paginated; - getAllByDeviceId(userId: string, deviceId: string): Promise; - getLivePhotoCount(motionId: string): Promise; - updateAll(ids: string[], options: Updateable): Promise; - updateDuplicates(options: AssetUpdateDuplicateOptions): Promise; - update(asset: Updateable & { id: string }): Promise; - remove(asset: AssetEntity): Promise; - findLivePhotoMatch(options: LivePhotoSearchOptions): Promise; - getStatistics(ownerId: string, options: AssetStatsOptions): Promise; - getTimeBuckets(options: TimeBucketOptions): Promise; - getTimeBucket(timeBucket: string, options: TimeBucketOptions): Promise; - upsertExif(exif: Insertable): Promise; - upsertJobStatus(...jobStatus: Insertable[]): Promise; - getAssetIdByCity(userId: string, options: AssetExploreFieldOptions): Promise>; - getDuplicates(userId: string): Promise; - getAllForUserFullSync(options: AssetFullSyncOptions): Promise; - getChangedDeltaSync(options: AssetDeltaSyncOptions): Promise; - upsertFile(options: Insertable): Promise; - upsertFiles(options: Insertable[]): Promise; -} diff --git a/server/src/interfaces/crypto.interface.ts b/server/src/interfaces/crypto.interface.ts deleted file mode 100644 index c661695cf7..0000000000 --- a/server/src/interfaces/crypto.interface.ts +++ /dev/null @@ -1,13 +0,0 @@ -export const ICryptoRepository = 'ICryptoRepository'; - -export interface ICryptoRepository { - randomBytes(size: number): Buffer; - randomUUID(): string; - hashFile(filePath: string | Buffer): Promise; - hashSha256(data: string): string; - verifySha256(data: string, encrypted: string, publicKey: string): boolean; - hashSha1(data: string | Buffer): Buffer; - hashBcrypt(data: string | Buffer, saltOrRounds: string | number): Promise; - compareBcrypt(data: string | Buffer, encrypted: string): boolean; - newPassword(bytes: number): string; -} diff --git a/server/src/interfaces/database.interface.ts b/server/src/interfaces/database.interface.ts deleted file mode 100644 index 8cfc040271..0000000000 --- a/server/src/interfaces/database.interface.ts +++ /dev/null @@ -1,78 +0,0 @@ -export enum DatabaseExtension { - CUBE = 'cube', - EARTH_DISTANCE = 'earthdistance', - VECTOR = 'vector', - VECTORS = 'vectors', -} - -export type VectorExtension = DatabaseExtension.VECTOR | DatabaseExtension.VECTORS; - -export type DatabaseConnectionURL = { - connectionType: 'url'; - url: string; -}; - -export type DatabaseConnectionParts = { - connectionType: 'parts'; - host: string; - port: number; - username: string; - password: string; - database: string; -}; - -export type DatabaseConnectionParams = DatabaseConnectionURL | DatabaseConnectionParts; - -export enum VectorIndex { - CLIP = 'clip_index', - FACE = 'face_index', -} - -export enum DatabaseLock { - GeodataImport = 100, - Migrations = 200, - SystemFileMounts = 300, - StorageTemplateMigration = 420, - VersionHistory = 500, - CLIPDimSize = 512, - Library = 1337, - GetSystemConfig = 69, - BackupDatabase = 42, -} - -export const EXTENSION_NAMES: Record = { - cube: 'cube', - earthdistance: 'earthdistance', - vector: 'pgvector', - vectors: 'pgvecto.rs', -} as const; - -export interface ExtensionVersion { - availableVersion: string | null; - installedVersion: string | null; -} - -export interface VectorUpdateResult { - restartRequired: boolean; -} - -export const IDatabaseRepository = 'IDatabaseRepository'; - -export interface IDatabaseRepository { - init(): void; - reconnect(): Promise; - shutdown(): Promise; - getExtensionVersion(extension: DatabaseExtension): Promise; - getExtensionVersionRange(extension: VectorExtension): string; - getPostgresVersion(): Promise; - getPostgresVersionRange(): string; - createExtension(extension: DatabaseExtension): Promise; - updateVectorExtension(extension: VectorExtension, version?: string): Promise; - reindex(index: VectorIndex): Promise; - shouldReindex(name: VectorIndex): Promise; - runMigrations(options?: { transaction?: 'all' | 'none' | 'each' }): Promise; - withLock(lock: DatabaseLock, callback: () => Promise): Promise; - tryLock(lock: DatabaseLock): Promise; - isBusy(lock: DatabaseLock): boolean; - wait(lock: DatabaseLock): Promise; -} diff --git a/server/src/interfaces/library.interface.ts b/server/src/interfaces/library.interface.ts deleted file mode 100644 index 66e9a7de29..0000000000 --- a/server/src/interfaces/library.interface.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { Insertable, Updateable } from 'kysely'; -import { Libraries } from 'src/db'; -import { LibraryStatsResponseDto } from 'src/dtos/library.dto'; -import { LibraryEntity } from 'src/entities/library.entity'; - -export const ILibraryRepository = 'ILibraryRepository'; - -export interface ILibraryRepository { - getAll(withDeleted?: boolean): Promise; - getAllDeleted(): Promise; - get(id: string, withDeleted?: boolean): Promise; - create(library: Insertable): Promise; - delete(id: string): Promise; - softDelete(id: string): Promise; - update(id: string, library: Updateable): Promise; - getStatistics(id: string): Promise; -} diff --git a/server/src/interfaces/move.interface.ts b/server/src/interfaces/move.interface.ts deleted file mode 100644 index 4356d9df8c..0000000000 --- a/server/src/interfaces/move.interface.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { Insertable, Updateable } from 'kysely'; -import { MoveHistory } from 'src/db'; -import { MoveEntity } from 'src/entities/move.entity'; -import { PathType } from 'src/enum'; - -export const IMoveRepository = 'IMoveRepository'; - -export type MoveCreate = Pick & Partial; - -export interface IMoveRepository { - create(entity: Insertable): Promise; - getByEntity(entityId: string, pathType: PathType): Promise; - update(id: string, entity: Updateable): Promise; - delete(id: string): Promise; -} diff --git a/server/src/interfaces/partner.interface.ts b/server/src/interfaces/partner.interface.ts deleted file mode 100644 index a6f50178ca..0000000000 --- a/server/src/interfaces/partner.interface.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { Updateable } from 'kysely'; -import { Partners } from 'src/db'; -import { PartnerEntity } from 'src/entities/partner.entity'; - -export interface PartnerIds { - sharedById: string; - sharedWithId: string; -} - -export enum PartnerDirection { - SharedBy = 'shared-by', - SharedWith = 'shared-with', -} - -export const IPartnerRepository = 'IPartnerRepository'; - -export interface IPartnerRepository { - getAll(userId: string): Promise; - get(partner: PartnerIds): Promise; - create(partner: PartnerIds): Promise; - remove(partner: PartnerIds): Promise; - update(partner: PartnerIds, entity: Updateable): Promise; -} diff --git a/server/src/interfaces/person.interface.ts b/server/src/interfaces/person.interface.ts deleted file mode 100644 index 4719f047ec..0000000000 --- a/server/src/interfaces/person.interface.ts +++ /dev/null @@ -1,89 +0,0 @@ -import { Insertable, Selectable, Updateable } from 'kysely'; -import { AssetFaces, FaceSearch, Person } from 'src/db'; -import { AssetFaceEntity } from 'src/entities/asset-face.entity'; -import { PersonEntity } from 'src/entities/person.entity'; -import { SourceType } from 'src/enum'; -import { Paginated, PaginationOptions } from 'src/utils/pagination'; -import { FindOptionsRelations } from 'typeorm'; - -export const IPersonRepository = 'IPersonRepository'; - -export interface PersonSearchOptions { - minimumFaceCount: number; - withHidden: boolean; - closestFaceAssetId?: string; -} - -export interface PersonNameSearchOptions { - withHidden?: boolean; -} - -export interface PersonNameResponse { - id: string; - name: string; -} - -export interface AssetFaceId { - assetId: string; - personId: string; -} - -export interface UpdateFacesData { - oldPersonId?: string; - faceIds?: string[]; - newPersonId: string; -} - -export interface PersonStatistics { - assets: number; -} - -export interface PeopleStatistics { - total: number; - hidden: number; -} - -export interface DeleteFacesOptions { - sourceType: SourceType; -} - -export type UnassignFacesOptions = DeleteFacesOptions; - -export type SelectFaceOptions = (keyof Selectable)[]; - -export interface IPersonRepository { - getAll(options?: Partial): AsyncIterableIterator; - getAllForUser(pagination: PaginationOptions, userId: string, options: PersonSearchOptions): Paginated; - getAllWithoutFaces(): Promise; - getById(personId: string): Promise; - getByName(userId: string, personName: string, options: PersonNameSearchOptions): Promise; - getDistinctNames(userId: string, options: PersonNameSearchOptions): Promise; - - create(person: Insertable): Promise; - createAll(people: Insertable[]): Promise; - delete(entities: PersonEntity[]): Promise; - deleteFaces(options: DeleteFacesOptions): Promise; - refreshFaces( - facesToAdd: Insertable[], - faceIdsToRemove: string[], - embeddingsToAdd?: Insertable[], - ): Promise; - getAllFaces(options?: Partial): AsyncIterableIterator; - getFaceById(id: string): Promise; - getFaceByIdWithAssets( - id: string, - relations?: FindOptionsRelations, - select?: SelectFaceOptions, - ): Promise; - getFaces(assetId: string): Promise; - getFacesByIds(ids: AssetFaceId[]): Promise; - getRandomFace(personId: string): Promise; - getStatistics(personId: string): Promise; - reassignFace(assetFaceId: string, newPersonId: string): Promise; - getNumberOfPeople(userId: string): Promise; - reassignFaces(data: UpdateFacesData): Promise; - unassignFaces(options: UnassignFacesOptions): Promise; - update(person: Updateable & { id: string }): Promise; - updateAll(people: Insertable[]): Promise; - getLatestFaceDate(): Promise; -} diff --git a/server/src/interfaces/search.interface.ts b/server/src/interfaces/search.interface.ts deleted file mode 100644 index b9ae7b7194..0000000000 --- a/server/src/interfaces/search.interface.ts +++ /dev/null @@ -1,213 +0,0 @@ -import { AssetEntity } from 'src/entities/asset.entity'; -import { GeodataPlacesEntity } from 'src/entities/geodata-places.entity'; -import { AssetStatus, AssetType } from 'src/enum'; -import { Paginated } from 'src/utils/pagination'; - -export const ISearchRepository = 'ISearchRepository'; - -export interface SearchResult { - /** total matches */ - total: number; - /** collection size */ - count: number; - /** current page */ - page: number; - /** items for page */ - items: T[]; - /** score */ - distances: number[]; - facets: SearchFacet[]; -} - -export interface SearchFacet { - fieldName: string; - counts: Array<{ - count: number; - value: string; - }>; -} - -export type SearchExploreItemSet = Array<{ - value: string; - data: T; -}>; - -export interface SearchExploreItem { - fieldName: string; - items: SearchExploreItemSet; -} - -export interface SearchAssetIDOptions { - checksum?: Buffer; - deviceAssetId?: string; - id?: string; -} - -export interface SearchUserIdOptions { - deviceId?: string; - libraryId?: string | null; - userIds?: string[]; -} - -export type SearchIdOptions = SearchAssetIDOptions & SearchUserIdOptions; - -export interface SearchStatusOptions { - isArchived?: boolean; - isEncoded?: boolean; - isFavorite?: boolean; - isMotion?: boolean; - isOffline?: boolean; - isVisible?: boolean; - isNotInAlbum?: boolean; - type?: AssetType; - status?: AssetStatus; - withArchived?: boolean; - withDeleted?: boolean; -} - -export interface SearchOneToOneRelationOptions { - withExif?: boolean; - withStacked?: boolean; -} - -export interface SearchRelationOptions extends SearchOneToOneRelationOptions { - withFaces?: boolean; - withPeople?: boolean; -} - -export interface SearchDateOptions { - createdBefore?: Date; - createdAfter?: Date; - takenBefore?: Date; - takenAfter?: Date; - trashedBefore?: Date; - trashedAfter?: Date; - updatedBefore?: Date; - updatedAfter?: Date; -} - -export interface SearchPathOptions { - encodedVideoPath?: string; - originalFileName?: string; - originalPath?: string; - previewPath?: string; - thumbnailPath?: string; -} - -export interface SearchExifOptions { - city?: string | null; - country?: string | null; - lensModel?: string | null; - make?: string | null; - model?: string | null; - state?: string | null; - description?: string | null; -} - -export interface SearchEmbeddingOptions { - embedding: string; - userIds: string[]; -} - -export interface SearchPeopleOptions { - personIds?: string[]; -} - -export interface SearchTagOptions { - tagIds?: string[]; -} - -export interface SearchOrderOptions { - orderDirection?: 'asc' | 'desc'; -} - -export interface SearchPaginationOptions { - page: number; - size: number; -} - -type BaseAssetSearchOptions = SearchDateOptions & - SearchIdOptions & - SearchExifOptions & - SearchOrderOptions & - SearchPathOptions & - SearchStatusOptions & - SearchUserIdOptions & - SearchPeopleOptions & - SearchTagOptions; - -export type AssetSearchOptions = BaseAssetSearchOptions & SearchRelationOptions; - -export type AssetSearchOneToOneRelationOptions = BaseAssetSearchOptions & SearchOneToOneRelationOptions; - -export type AssetSearchBuilderOptions = Omit; - -export type SmartSearchOptions = SearchDateOptions & - SearchEmbeddingOptions & - SearchExifOptions & - SearchOneToOneRelationOptions & - SearchStatusOptions & - SearchUserIdOptions & - SearchPeopleOptions & - SearchTagOptions; - -export interface FaceEmbeddingSearch extends SearchEmbeddingOptions { - hasPerson?: boolean; - numResults: number; - maxDistance: number; -} - -export interface AssetDuplicateSearch { - assetId: string; - embedding: string; - maxDistance: number; - type: AssetType; - userIds: string[]; -} - -export interface FaceSearchResult { - distance: number; - id: string; - personId: string | null; -} - -export interface AssetDuplicateResult { - assetId: string; - duplicateId: string | null; - distance: number; -} - -export interface GetStatesOptions { - country?: string; -} - -export interface GetCitiesOptions extends GetStatesOptions { - state?: string; -} - -export interface GetCameraModelsOptions { - make?: string; -} - -export interface GetCameraMakesOptions { - model?: string; -} - -export interface ISearchRepository { - searchMetadata(pagination: SearchPaginationOptions, options: AssetSearchOptions): Paginated; - searchSmart(pagination: SearchPaginationOptions, options: SmartSearchOptions): Paginated; - searchDuplicates(options: AssetDuplicateSearch): Promise; - searchFaces(search: FaceEmbeddingSearch): Promise; - searchRandom(size: number, options: AssetSearchOptions): Promise; - upsert(assetId: string, embedding: string): Promise; - searchPlaces(placeName: string): Promise; - getAssetsByCity(userIds: string[]): Promise; - deleteAllSearchEmbeddings(): Promise; - getDimensionSize(): Promise; - setDimensionSize(dimSize: number): Promise; - getCountries(userIds: string[]): Promise>; - getStates(userIds: string[], options: GetStatesOptions): Promise>; - getCities(userIds: string[], options: GetCitiesOptions): Promise>; - getCameraMakes(userIds: string[], options: GetCameraMakesOptions): Promise>; - getCameraModels(userIds: string[], options: GetCameraModelsOptions): Promise>; -} diff --git a/server/src/interfaces/shared-link.interface.ts b/server/src/interfaces/shared-link.interface.ts deleted file mode 100644 index c030ceb736..0000000000 --- a/server/src/interfaces/shared-link.interface.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { Insertable, Updateable } from 'kysely'; -import { SharedLinks } from 'src/db'; -import { SharedLinkEntity } from 'src/entities/shared-link.entity'; - -export const ISharedLinkRepository = 'ISharedLinkRepository'; - -export type SharedLinkSearchOptions = { - userId: string; - albumId?: string; -}; - -export interface ISharedLinkRepository { - getAll(options: SharedLinkSearchOptions): Promise; - get(userId: string, id: string): Promise; - getByKey(key: Buffer): Promise; - create(entity: Insertable & { assetIds?: string[] }): Promise; - update(entity: Updateable & { id: string; assetIds?: string[] }): Promise; - remove(entity: SharedLinkEntity): Promise; -} diff --git a/server/src/interfaces/stack.interface.ts b/server/src/interfaces/stack.interface.ts deleted file mode 100644 index a9fb8cec76..0000000000 --- a/server/src/interfaces/stack.interface.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { Updateable } from 'kysely'; -import { StackEntity } from 'src/entities/stack.entity'; - -export const IStackRepository = 'IStackRepository'; - -export interface StackSearch { - ownerId: string; - primaryAssetId?: string; -} - -export interface IStackRepository { - search(query: StackSearch): Promise; - create(stack: { ownerId: string; assetIds: string[] }): Promise; - update(id: string, entity: Updateable): Promise; - delete(id: string): Promise; - deleteAll(ids: string[]): Promise; - getById(id: string): Promise; -} diff --git a/server/src/interfaces/storage.interface.ts b/server/src/interfaces/storage.interface.ts deleted file mode 100644 index b304d94fef..0000000000 --- a/server/src/interfaces/storage.interface.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { WatchOptions } from 'chokidar'; -import { Stats } from 'node:fs'; -import { FileReadOptions } from 'node:fs/promises'; -import { Readable, Writable } from 'node:stream'; -import { CrawlOptionsDto, WalkOptionsDto } from 'src/dtos/library.dto'; - -export interface ImmichReadStream { - stream: Readable; - type?: string; - length?: number; -} - -export interface ImmichZipStream extends ImmichReadStream { - addFile: (inputPath: string, filename: string) => void; - finalize: () => Promise; -} - -export interface DiskUsage { - available: number; - free: number; - total: number; -} - -export const IStorageRepository = 'IStorageRepository'; - -export interface WatchEvents { - onReady(): void; - onAdd(path: string): void; - onChange(path: string): void; - onUnlink(path: string): void; - onError(error: Error): void; -} - -export interface IStorageRepository { - createZipStream(): ImmichZipStream; - createReadStream(filepath: string, mimeType?: string | null): Promise; - readFile(filepath: string, options?: FileReadOptions): Promise; - createFile(filepath: string, buffer: Buffer): Promise; - createWriteStream(filepath: string): Writable; - createOrOverwriteFile(filepath: string, buffer: Buffer): Promise; - overwriteFile(filepath: string, buffer: Buffer): Promise; - realpath(filepath: string): Promise; - unlink(filepath: string): Promise; - unlinkDir(folder: string, options?: { recursive?: boolean; force?: boolean }): Promise; - removeEmptyDirs(folder: string, self?: boolean): Promise; - checkFileExists(filepath: string, mode?: number): Promise; - mkdirSync(filepath: string): void; - checkDiskUsage(folder: string): Promise; - readdir(folder: string): Promise; - stat(filepath: string): Promise; - crawl(options: CrawlOptionsDto): Promise; - walk(options: WalkOptionsDto): AsyncGenerator; - copyFile(source: string, target: string): Promise; - rename(source: string, target: string): Promise; - watch(paths: string[], options: WatchOptions, events: Partial): () => Promise; - utimes(filepath: string, atime: Date, mtime: Date): Promise; -} diff --git a/server/src/interfaces/tag.interface.ts b/server/src/interfaces/tag.interface.ts deleted file mode 100644 index 16a34d6ac4..0000000000 --- a/server/src/interfaces/tag.interface.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { TagEntity } from 'src/entities/tag.entity'; -import { IBulkAsset } from 'src/utils/asset.util'; - -export const ITagRepository = 'ITagRepository'; - -export type AssetTagItem = { assetId: string; tagId: string }; - -export interface ITagRepository extends IBulkAsset { - getAll(userId: string): Promise; - getByValue(userId: string, value: string): Promise; - upsertValue(request: { userId: string; value: string; parent?: TagEntity }): Promise; - - create(tag: Partial): Promise; - get(id: string): Promise; - update(tag: { id: string } & Partial): Promise; - delete(id: string): Promise; - - upsertAssetTags({ assetId, tagIds }: { assetId: string; tagIds: string[] }): Promise; - upsertAssetIds(items: AssetTagItem[]): Promise; - deleteEmptyTags(): Promise; -} diff --git a/server/src/interfaces/user.interface.ts b/server/src/interfaces/user.interface.ts deleted file mode 100644 index f126b6eb34..0000000000 --- a/server/src/interfaces/user.interface.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { Insertable, Updateable } from 'kysely'; -import { Users } from 'src/db'; -import { UserMetadata } from 'src/entities/user-metadata.entity'; -import { UserEntity } from 'src/entities/user.entity'; - -export interface UserListFilter { - withDeleted?: boolean; -} - -export interface UserStatsQueryResponse { - userId: string; - userName: string; - photos: number; - videos: number; - usage: number; - usagePhotos: number; - usageVideos: number; - quotaSizeInBytes: number | null; -} - -export interface UserFindOptions { - withDeleted?: boolean; -} - -export const IUserRepository = 'IUserRepository'; - -export interface IUserRepository { - get(id: string, options: UserFindOptions): Promise; - getAdmin(): Promise; - hasAdmin(): Promise; - getByEmail(email: string, withPassword?: boolean): Promise; - getByStorageLabel(storageLabel: string): Promise; - getByOAuthId(oauthId: string): Promise; - getDeletedUsers(): Promise; - getList(filter?: UserListFilter): Promise; - getUserStats(): Promise; - create(user: Insertable): Promise; - update(id: string, user: Updateable): Promise; - restore(id: string): Promise; - upsertMetadata(id: string, item: { key: T; value: UserMetadata[T] }): Promise; - deleteMetadata(id: string, key: T): Promise; - delete(user: UserEntity, hard?: boolean): Promise; - updateUsage(id: string, delta: number): Promise; - syncUsage(id?: string): Promise; -} diff --git a/server/src/migrations/1700713994428-AddCLIPEmbeddingIndex.ts b/server/src/migrations/1700713994428-AddCLIPEmbeddingIndex.ts index f9ea5a0dc3..993e12f822 100644 --- a/server/src/migrations/1700713994428-AddCLIPEmbeddingIndex.ts +++ b/server/src/migrations/1700713994428-AddCLIPEmbeddingIndex.ts @@ -1,4 +1,4 @@ -import { DatabaseExtension } from 'src/interfaces/database.interface'; +import { DatabaseExtension } from 'src/enum'; import { ConfigRepository } from 'src/repositories/config.repository'; import { MigrationInterface, QueryRunner } from 'typeorm'; diff --git a/server/src/migrations/1700714033632-AddFaceEmbeddingIndex.ts b/server/src/migrations/1700714033632-AddFaceEmbeddingIndex.ts index d11e7b921e..182aae4e42 100644 --- a/server/src/migrations/1700714033632-AddFaceEmbeddingIndex.ts +++ b/server/src/migrations/1700714033632-AddFaceEmbeddingIndex.ts @@ -1,4 +1,4 @@ -import { DatabaseExtension } from 'src/interfaces/database.interface'; +import { DatabaseExtension } from 'src/enum'; import { ConfigRepository } from 'src/repositories/config.repository'; import { MigrationInterface, QueryRunner } from 'typeorm'; diff --git a/server/src/migrations/1718486162779-AddFaceSearchRelation.ts b/server/src/migrations/1718486162779-AddFaceSearchRelation.ts index ae6d752c65..e08bcb8e25 100644 --- a/server/src/migrations/1718486162779-AddFaceSearchRelation.ts +++ b/server/src/migrations/1718486162779-AddFaceSearchRelation.ts @@ -1,4 +1,4 @@ -import { DatabaseExtension } from 'src/interfaces/database.interface'; +import { DatabaseExtension } from 'src/enum'; import { ConfigRepository } from 'src/repositories/config.repository'; import { MigrationInterface, QueryRunner } from 'typeorm'; diff --git a/server/src/repositories/album.repository.ts b/server/src/repositories/album.repository.ts index c09a13750e..1fe2938fcc 100644 --- a/server/src/repositories/album.repository.ts +++ b/server/src/repositories/album.repository.ts @@ -6,7 +6,17 @@ import { Albums, DB } from 'src/db'; import { Chunked, ChunkedArray, ChunkedSet, DummyValue, GenerateSql } from 'src/decorators'; import { AlbumUserCreateDto } from 'src/dtos/album.dto'; import { AlbumEntity } from 'src/entities/album.entity'; -import { AlbumAssetCount, AlbumInfoOptions, IAlbumRepository } from 'src/interfaces/album.interface'; + +export interface AlbumAssetCount { + albumId: string; + assetCount: number; + startDate: Date | null; + endDate: Date | null; +} + +export interface AlbumInfoOptions { + withAssets: boolean; +} const userColumns = [ 'id', @@ -71,7 +81,7 @@ const withAssets = (eb: ExpressionBuilder) => { }; @Injectable() -export class AlbumRepository implements IAlbumRepository { +export class AlbumRepository { constructor(@InjectKysely() private db: Kysely) {} @GenerateSql({ params: [DummyValue.UUID, { withAssets: true }] }) diff --git a/server/src/repositories/asset.repository.ts b/server/src/repositories/asset.repository.ts index 2ac81bbf97..bd82cc0724 100644 --- a/server/src/repositories/asset.repository.ts +++ b/server/src/repositories/asset.repository.ts @@ -21,34 +21,138 @@ import { withTagId, withTags, } from 'src/entities/asset.entity'; -import { AssetFileType, AssetStatus, AssetType } from 'src/enum'; -import { - AssetDeltaSyncOptions, - AssetExploreFieldOptions, - AssetFullSyncOptions, - AssetGetByChecksumOptions, - AssetStats, - AssetStatsOptions, - AssetUpdateDuplicateOptions, - DayOfYearAssets, - DuplicateGroup, - GetByIdsRelations, - IAssetRepository, - LivePhotoSearchOptions, - MonthDay, - TimeBucketItem, - TimeBucketOptions, - TimeBucketSize, - WithProperty, - WithoutProperty, -} from 'src/interfaces/asset.interface'; -import { AssetSearchOptions, SearchExploreItem, SearchExploreItemSet } from 'src/interfaces/search.interface'; +import { AssetFileType, AssetOrder, AssetStatus, AssetType } from 'src/enum'; import { MapMarker, MapMarkerSearchOptions } from 'src/repositories/map.repository'; +import { AssetSearchOptions, SearchExploreItem, SearchExploreItemSet } from 'src/repositories/search.repository'; import { anyUuid, asUuid, mapUpsertColumns } from 'src/utils/database'; import { Paginated, PaginationOptions, paginationHelper } from 'src/utils/pagination'; +export type AssetStats = Record; + +export interface AssetStatsOptions { + isFavorite?: boolean; + isArchived?: boolean; + isTrashed?: boolean; +} + +export interface LivePhotoSearchOptions { + ownerId: string; + libraryId?: string | null; + livePhotoCID: string; + otherAssetId: string; + type: AssetType; +} + +export enum WithoutProperty { + THUMBNAIL = 'thumbnail', + ENCODED_VIDEO = 'encoded-video', + EXIF = 'exif', + SMART_SEARCH = 'smart-search', + DUPLICATE = 'duplicate', + FACES = 'faces', + SIDECAR = 'sidecar', +} + +export enum WithProperty { + SIDECAR = 'sidecar', +} + +export enum TimeBucketSize { + DAY = 'DAY', + MONTH = 'MONTH', +} + +export interface AssetBuilderOptions { + isArchived?: boolean; + isFavorite?: boolean; + isTrashed?: boolean; + isDuplicate?: boolean; + albumId?: string; + tagId?: string; + personId?: string; + userIds?: string[]; + withStacked?: boolean; + exifInfo?: boolean; + status?: AssetStatus; + assetType?: AssetType; +} + +export interface TimeBucketOptions extends AssetBuilderOptions { + size: TimeBucketSize; + order?: AssetOrder; +} + +export interface TimeBucketItem { + timeBucket: string; + count: number; +} + +export interface MonthDay { + day: number; + month: number; +} + +export interface AssetExploreFieldOptions { + maxFields: number; + minAssetsPerField: number; +} + +export interface AssetFullSyncOptions { + ownerId: string; + lastId?: string; + updatedUntil: Date; + limit: number; +} + +export interface AssetDeltaSyncOptions { + userIds: string[]; + updatedAfter: Date; + limit: number; +} + +export interface AssetUpdateDuplicateOptions { + targetDuplicateId: string | null; + assetIds: string[]; + duplicateIds: string[]; +} + +export interface UpsertFileOptions { + assetId: string; + type: AssetFileType; + path: string; +} + +export interface AssetGetByChecksumOptions { + ownerId: string; + checksum: Buffer; + libraryId?: string; +} + +export type AssetPathEntity = Pick; + +export interface GetByIdsRelations { + exifInfo?: boolean; + faces?: { person?: boolean }; + files?: boolean; + library?: boolean; + owner?: boolean; + smartSearch?: boolean; + stack?: { assets?: boolean }; + tags?: boolean; +} + +export interface DuplicateGroup { + duplicateId: string; + assets: AssetEntity[]; +} + +export interface DayOfYearAssets { + yearsAgo: number; + assets: AssetEntity[]; +} + @Injectable() -export class AssetRepository implements IAssetRepository { +export class AssetRepository { constructor(@InjectKysely() private db: Kysely) {} async upsertExif(exif: Insertable): Promise { diff --git a/server/src/repositories/config.repository.ts b/server/src/repositories/config.repository.ts index 5b04914dac..27c10bcdcc 100644 --- a/server/src/repositories/config.repository.ts +++ b/server/src/repositories/config.repository.ts @@ -13,9 +13,9 @@ import { Notice } from 'postgres'; import { citiesFile, excludePaths, IWorker } from 'src/constants'; import { Telemetry } from 'src/decorators'; import { EnvDto } from 'src/dtos/env.dto'; -import { ImmichEnvironment, ImmichHeader, ImmichTelemetry, ImmichWorker, LogLevel } from 'src/enum'; -import { DatabaseConnectionParams, DatabaseExtension, VectorExtension } from 'src/interfaces/database.interface'; +import { DatabaseExtension, ImmichEnvironment, ImmichHeader, ImmichTelemetry, ImmichWorker, LogLevel } from 'src/enum'; import { QueueName } from 'src/interfaces/job.interface'; +import { DatabaseConnectionParams, VectorExtension } from 'src/repositories/database.repository'; import { setDifference } from 'src/utils/set'; import { PostgresConnectionOptions } from 'typeorm/driver/postgres/PostgresConnectionOptions.js'; diff --git a/server/src/repositories/crypto.repository.ts b/server/src/repositories/crypto.repository.ts index ee25609fec..e471ccb031 100644 --- a/server/src/repositories/crypto.repository.ts +++ b/server/src/repositories/crypto.repository.ts @@ -2,11 +2,10 @@ import { Injectable } from '@nestjs/common'; import { compareSync, hash } from 'bcrypt'; import { createHash, createPublicKey, createVerify, randomBytes, randomUUID } from 'node:crypto'; import { createReadStream } from 'node:fs'; -import { ICryptoRepository } from 'src/interfaces/crypto.interface'; @Injectable() -export class CryptoRepository implements ICryptoRepository { - randomUUID() { +export class CryptoRepository { + randomUUID(): string { return randomUUID(); } diff --git a/server/src/repositories/database.repository.ts b/server/src/repositories/database.repository.ts index f7d52efd7a..6edcedd13c 100644 --- a/server/src/repositories/database.repository.ts +++ b/server/src/repositories/database.repository.ts @@ -6,24 +6,66 @@ import { InjectKysely } from 'nestjs-kysely'; import semver from 'semver'; import { POSTGRES_VERSION_RANGE, VECTOR_VERSION_RANGE, VECTORS_VERSION_RANGE } from 'src/constants'; import { DB } from 'src/db'; -import { - DatabaseExtension, - DatabaseLock, - EXTENSION_NAMES, - ExtensionVersion, - IDatabaseRepository, - VectorExtension, - VectorIndex, - VectorUpdateResult, -} from 'src/interfaces/database.interface'; +import { DatabaseExtension } from 'src/enum'; import { ConfigRepository } from 'src/repositories/config.repository'; import { LoggingRepository } from 'src/repositories/logging.repository'; import { UPSERT_COLUMNS } from 'src/utils/database'; import { isValidInteger } from 'src/validation'; import { DataSource, EntityManager, EntityMetadata, QueryRunner } from 'typeorm'; +export type VectorExtension = DatabaseExtension.VECTOR | DatabaseExtension.VECTORS; + +export type DatabaseConnectionURL = { + connectionType: 'url'; + url: string; +}; + +export type DatabaseConnectionParts = { + connectionType: 'parts'; + host: string; + port: number; + username: string; + password: string; + database: string; +}; + +export type DatabaseConnectionParams = DatabaseConnectionURL | DatabaseConnectionParts; + +export enum VectorIndex { + CLIP = 'clip_index', + FACE = 'face_index', +} + +export enum DatabaseLock { + GeodataImport = 100, + Migrations = 200, + SystemFileMounts = 300, + StorageTemplateMigration = 420, + VersionHistory = 500, + CLIPDimSize = 512, + Library = 1337, + GetSystemConfig = 69, + BackupDatabase = 42, +} + +export const EXTENSION_NAMES: Record = { + cube: 'cube', + earthdistance: 'earthdistance', + vector: 'pgvector', + vectors: 'pgvecto.rs', +} as const; + +export interface ExtensionVersion { + availableVersion: string | null; + installedVersion: string | null; +} + +export interface VectorUpdateResult { + restartRequired: boolean; +} + @Injectable() -export class DatabaseRepository implements IDatabaseRepository { +export class DatabaseRepository { private vectorExtension: VectorExtension; private readonly asyncLock = new AsyncLock(); diff --git a/server/src/repositories/index.ts b/server/src/repositories/index.ts index cb870bd339..d9ef84fc06 100644 --- a/server/src/repositories/index.ts +++ b/server/src/repositories/index.ts @@ -1,20 +1,6 @@ -import { IAlbumRepository } from 'src/interfaces/album.interface'; -import { IAssetRepository } from 'src/interfaces/asset.interface'; -import { ICryptoRepository } from 'src/interfaces/crypto.interface'; -import { IDatabaseRepository } from 'src/interfaces/database.interface'; import { IEventRepository } from 'src/interfaces/event.interface'; import { IJobRepository } from 'src/interfaces/job.interface'; -import { ILibraryRepository } from 'src/interfaces/library.interface'; import { IMachineLearningRepository } from 'src/interfaces/machine-learning.interface'; -import { IMoveRepository } from 'src/interfaces/move.interface'; -import { IPartnerRepository } from 'src/interfaces/partner.interface'; -import { IPersonRepository } from 'src/interfaces/person.interface'; -import { ISearchRepository } from 'src/interfaces/search.interface'; -import { ISharedLinkRepository } from 'src/interfaces/shared-link.interface'; -import { IStackRepository } from 'src/interfaces/stack.interface'; -import { IStorageRepository } from 'src/interfaces/storage.interface'; -import { ITagRepository } from 'src/interfaces/tag.interface'; -import { IUserRepository } from 'src/interfaces/user.interface'; import { AccessRepository } from 'src/repositories/access.repository'; import { ActivityRepository } from 'src/repositories/activity.repository'; import { AlbumUserRepository } from 'src/repositories/album-user.repository'; @@ -58,44 +44,44 @@ import { ViewRepository } from 'src/repositories/view-repository'; export const repositories = [ AccessRepository, ActivityRepository, + AlbumRepository, AlbumUserRepository, AuditRepository, ApiKeyRepository, + AssetRepository, ConfigRepository, CronRepository, + CryptoRepository, + DatabaseRepository, + LibraryRepository, LoggingRepository, MapRepository, MediaRepository, MemoryRepository, MetadataRepository, + MoveRepository, NotificationRepository, OAuthRepository, + PartnerRepository, + PersonRepository, ProcessRepository, + SearchRepository, SessionRepository, ServerInfoRepository, + SharedLinkRepository, + StackRepository, + StorageRepository, SystemMetadataRepository, + TagRepository, TelemetryRepository, TrashRepository, + UserRepository, ViewRepository, VersionHistoryRepository, ]; export const providers = [ - { provide: IAlbumRepository, useClass: AlbumRepository }, - { provide: IAssetRepository, useClass: AssetRepository }, - { provide: ICryptoRepository, useClass: CryptoRepository }, - { provide: IDatabaseRepository, useClass: DatabaseRepository }, { provide: IEventRepository, useClass: EventRepository }, { provide: IJobRepository, useClass: JobRepository }, - { provide: ILibraryRepository, useClass: LibraryRepository }, { provide: IMachineLearningRepository, useClass: MachineLearningRepository }, - { provide: IMoveRepository, useClass: MoveRepository }, - { provide: IPartnerRepository, useClass: PartnerRepository }, - { provide: IPersonRepository, useClass: PersonRepository }, - { provide: ISearchRepository, useClass: SearchRepository }, - { provide: ISharedLinkRepository, useClass: SharedLinkRepository }, - { provide: IStackRepository, useClass: StackRepository }, - { provide: IStorageRepository, useClass: StorageRepository }, - { provide: ITagRepository, useClass: TagRepository }, - { provide: IUserRepository, useClass: UserRepository }, ]; diff --git a/server/src/repositories/library.repository.ts b/server/src/repositories/library.repository.ts index 0e1ec94c32..f748600fbb 100644 --- a/server/src/repositories/library.repository.ts +++ b/server/src/repositories/library.repository.ts @@ -7,7 +7,6 @@ import { DummyValue, GenerateSql } from 'src/decorators'; import { LibraryStatsResponseDto } from 'src/dtos/library.dto'; import { LibraryEntity } from 'src/entities/library.entity'; import { AssetType } from 'src/enum'; -import { ILibraryRepository } from 'src/interfaces/library.interface'; const userColumns = [ 'users.id', @@ -34,7 +33,7 @@ const withOwner = (eb: ExpressionBuilder) => { }; @Injectable() -export class LibraryRepository implements ILibraryRepository { +export class LibraryRepository { constructor(@InjectKysely() private db: Kysely) {} @GenerateSql({ params: [DummyValue.UUID] }) diff --git a/server/src/repositories/move.repository.ts b/server/src/repositories/move.repository.ts index c0177f3f30..c46259fa9b 100644 --- a/server/src/repositories/move.repository.ts +++ b/server/src/repositories/move.repository.ts @@ -5,10 +5,11 @@ import { DB, MoveHistory } from 'src/db'; import { DummyValue, GenerateSql } from 'src/decorators'; import { MoveEntity } from 'src/entities/move.entity'; import { PathType } from 'src/enum'; -import { IMoveRepository } from 'src/interfaces/move.interface'; + +export type MoveCreate = Pick & Partial; @Injectable() -export class MoveRepository implements IMoveRepository { +export class MoveRepository { constructor(@InjectKysely() private db: Kysely) {} create(entity: Insertable): Promise { diff --git a/server/src/repositories/partner.repository.ts b/server/src/repositories/partner.repository.ts index 929c06a1f5..f799ff56f2 100644 --- a/server/src/repositories/partner.repository.ts +++ b/server/src/repositories/partner.repository.ts @@ -5,7 +5,16 @@ import { InjectKysely } from 'nestjs-kysely'; import { DB, Partners, Users } from 'src/db'; import { DummyValue, GenerateSql } from 'src/decorators'; import { PartnerEntity } from 'src/entities/partner.entity'; -import { IPartnerRepository, PartnerIds } from 'src/interfaces/partner.interface'; + +export interface PartnerIds { + sharedById: string; + sharedWithId: string; +} + +export enum PartnerDirection { + SharedBy = 'shared-by', + SharedWith = 'shared-with', +} const columns = ['id', 'name', 'email', 'profileImagePath', 'profileChangedAt'] as const; @@ -28,7 +37,7 @@ const withSharedWith = (eb: ExpressionBuilder) => { }; @Injectable() -export class PartnerRepository implements IPartnerRepository { +export class PartnerRepository { constructor(@InjectKysely() private db: Kysely) {} @GenerateSql({ params: [DummyValue.UUID] }) diff --git a/server/src/repositories/person.repository.ts b/server/src/repositories/person.repository.ts index 73fb8313d2..b862d66f8a 100644 --- a/server/src/repositories/person.repository.ts +++ b/server/src/repositories/person.repository.ts @@ -1,5 +1,5 @@ import { Injectable } from '@nestjs/common'; -import { ExpressionBuilder, Insertable, Kysely, sql } from 'kysely'; +import { ExpressionBuilder, Insertable, Kysely, Selectable, sql } from 'kysely'; import { jsonObjectFrom } from 'kysely/helpers/postgres'; import { InjectKysely } from 'nestjs-kysely'; import { AssetFaces, DB, FaceSearch, Person } from 'src/db'; @@ -7,23 +7,53 @@ import { ChunkedArray, DummyValue, GenerateSql } from 'src/decorators'; import { AssetFaceEntity } from 'src/entities/asset-face.entity'; import { PersonEntity } from 'src/entities/person.entity'; import { SourceType } from 'src/enum'; -import { - AssetFaceId, - DeleteFacesOptions, - IPersonRepository, - PeopleStatistics, - PersonNameResponse, - PersonNameSearchOptions, - PersonSearchOptions, - PersonStatistics, - SelectFaceOptions, - UnassignFacesOptions, - UpdateFacesData, -} from 'src/interfaces/person.interface'; import { mapUpsertColumns } from 'src/utils/database'; import { Paginated, PaginationOptions } from 'src/utils/pagination'; import { FindOptionsRelations } from 'typeorm'; +export interface PersonSearchOptions { + minimumFaceCount: number; + withHidden: boolean; + closestFaceAssetId?: string; +} + +export interface PersonNameSearchOptions { + withHidden?: boolean; +} + +export interface PersonNameResponse { + id: string; + name: string; +} + +export interface AssetFaceId { + assetId: string; + personId: string; +} + +export interface UpdateFacesData { + oldPersonId?: string; + faceIds?: string[]; + newPersonId: string; +} + +export interface PersonStatistics { + assets: number; +} + +export interface PeopleStatistics { + total: number; + hidden: number; +} + +export interface DeleteFacesOptions { + sourceType: SourceType; +} + +export type UnassignFacesOptions = DeleteFacesOptions; + +export type SelectFaceOptions = (keyof Selectable)[]; + const withPerson = (eb: ExpressionBuilder) => { return jsonObjectFrom( eb.selectFrom('person').selectAll('person').whereRef('person.id', '=', 'asset_faces.personId'), @@ -43,7 +73,7 @@ const withFaceSearch = (eb: ExpressionBuilder) => { }; @Injectable() -export class PersonRepository implements IPersonRepository { +export class PersonRepository { constructor(@InjectKysely() private db: Kysely) {} @GenerateSql({ params: [{ oldPersonId: DummyValue.UUID, newPersonId: DummyValue.UUID }] }) diff --git a/server/src/repositories/search.repository.ts b/server/src/repositories/search.repository.ts index 76b6653e3d..a6eb5c7a85 100644 --- a/server/src/repositories/search.repository.ts +++ b/server/src/repositories/search.repository.ts @@ -6,26 +6,202 @@ import { DB } from 'src/db'; import { DummyValue, GenerateSql } from 'src/decorators'; import { AssetEntity, searchAssetBuilder } from 'src/entities/asset.entity'; import { GeodataPlacesEntity } from 'src/entities/geodata-places.entity'; -import { AssetType } from 'src/enum'; -import { - AssetDuplicateSearch, - AssetSearchOptions, - FaceEmbeddingSearch, - GetCameraMakesOptions, - GetCameraModelsOptions, - GetCitiesOptions, - GetStatesOptions, - ISearchRepository, - SearchPaginationOptions, - SmartSearchOptions, -} from 'src/interfaces/search.interface'; +import { AssetStatus, AssetType } from 'src/enum'; import { LoggingRepository } from 'src/repositories/logging.repository'; import { anyUuid, asUuid } from 'src/utils/database'; import { Paginated } from 'src/utils/pagination'; import { isValidInteger } from 'src/validation'; +export interface SearchResult { + /** total matches */ + total: number; + /** collection size */ + count: number; + /** current page */ + page: number; + /** items for page */ + items: T[]; + /** score */ + distances: number[]; + facets: SearchFacet[]; +} + +export interface SearchFacet { + fieldName: string; + counts: Array<{ + count: number; + value: string; + }>; +} + +export type SearchExploreItemSet = Array<{ + value: string; + data: T; +}>; + +export interface SearchExploreItem { + fieldName: string; + items: SearchExploreItemSet; +} + +export interface SearchAssetIDOptions { + checksum?: Buffer; + deviceAssetId?: string; + id?: string; +} + +export interface SearchUserIdOptions { + deviceId?: string; + libraryId?: string | null; + userIds?: string[]; +} + +export type SearchIdOptions = SearchAssetIDOptions & SearchUserIdOptions; + +export interface SearchStatusOptions { + isArchived?: boolean; + isEncoded?: boolean; + isFavorite?: boolean; + isMotion?: boolean; + isOffline?: boolean; + isVisible?: boolean; + isNotInAlbum?: boolean; + type?: AssetType; + status?: AssetStatus; + withArchived?: boolean; + withDeleted?: boolean; +} + +export interface SearchOneToOneRelationOptions { + withExif?: boolean; + withStacked?: boolean; +} + +export interface SearchRelationOptions extends SearchOneToOneRelationOptions { + withFaces?: boolean; + withPeople?: boolean; +} + +export interface SearchDateOptions { + createdBefore?: Date; + createdAfter?: Date; + takenBefore?: Date; + takenAfter?: Date; + trashedBefore?: Date; + trashedAfter?: Date; + updatedBefore?: Date; + updatedAfter?: Date; +} + +export interface SearchPathOptions { + encodedVideoPath?: string; + originalFileName?: string; + originalPath?: string; + previewPath?: string; + thumbnailPath?: string; +} + +export interface SearchExifOptions { + city?: string | null; + country?: string | null; + lensModel?: string | null; + make?: string | null; + model?: string | null; + state?: string | null; + description?: string | null; +} + +export interface SearchEmbeddingOptions { + embedding: string; + userIds: string[]; +} + +export interface SearchPeopleOptions { + personIds?: string[]; +} + +export interface SearchTagOptions { + tagIds?: string[]; +} + +export interface SearchOrderOptions { + orderDirection?: 'asc' | 'desc'; +} + +export interface SearchPaginationOptions { + page: number; + size: number; +} + +type BaseAssetSearchOptions = SearchDateOptions & + SearchIdOptions & + SearchExifOptions & + SearchOrderOptions & + SearchPathOptions & + SearchStatusOptions & + SearchUserIdOptions & + SearchPeopleOptions & + SearchTagOptions; + +export type AssetSearchOptions = BaseAssetSearchOptions & SearchRelationOptions; + +export type AssetSearchOneToOneRelationOptions = BaseAssetSearchOptions & SearchOneToOneRelationOptions; + +export type AssetSearchBuilderOptions = Omit; + +export type SmartSearchOptions = SearchDateOptions & + SearchEmbeddingOptions & + SearchExifOptions & + SearchOneToOneRelationOptions & + SearchStatusOptions & + SearchUserIdOptions & + SearchPeopleOptions & + SearchTagOptions; + +export interface FaceEmbeddingSearch extends SearchEmbeddingOptions { + hasPerson?: boolean; + numResults: number; + maxDistance: number; +} + +export interface AssetDuplicateSearch { + assetId: string; + embedding: string; + maxDistance: number; + type: AssetType; + userIds: string[]; +} + +export interface FaceSearchResult { + distance: number; + id: string; + personId: string | null; +} + +export interface AssetDuplicateResult { + assetId: string; + duplicateId: string | null; + distance: number; +} + +export interface GetStatesOptions { + country?: string; +} + +export interface GetCitiesOptions extends GetStatesOptions { + state?: string; +} + +export interface GetCameraModelsOptions { + make?: string; +} + +export interface GetCameraMakesOptions { + model?: string; +} + @Injectable() -export class SearchRepository implements ISearchRepository { +export class SearchRepository { constructor( private logger: LoggingRepository, @InjectKysely() private db: Kysely, diff --git a/server/src/repositories/shared-link.repository.ts b/server/src/repositories/shared-link.repository.ts index 8e2e6976a5..16dc48836a 100644 --- a/server/src/repositories/shared-link.repository.ts +++ b/server/src/repositories/shared-link.repository.ts @@ -7,10 +7,14 @@ import { DB, SharedLinks } from 'src/db'; import { DummyValue, GenerateSql } from 'src/decorators'; import { SharedLinkEntity } from 'src/entities/shared-link.entity'; import { SharedLinkType } from 'src/enum'; -import { ISharedLinkRepository, SharedLinkSearchOptions } from 'src/interfaces/shared-link.interface'; + +export type SharedLinkSearchOptions = { + userId: string; + albumId?: string; +}; @Injectable() -export class SharedLinkRepository implements ISharedLinkRepository { +export class SharedLinkRepository { constructor(@InjectKysely() private db: Kysely) {} @GenerateSql({ params: [DummyValue.UUID, DummyValue.UUID] }) diff --git a/server/src/repositories/stack.repository.ts b/server/src/repositories/stack.repository.ts index 018d7e77a4..ae96005350 100644 --- a/server/src/repositories/stack.repository.ts +++ b/server/src/repositories/stack.repository.ts @@ -5,9 +5,13 @@ import { InjectKysely } from 'nestjs-kysely'; import { DB } from 'src/db'; import { DummyValue, GenerateSql } from 'src/decorators'; import { StackEntity } from 'src/entities/stack.entity'; -import { IStackRepository, StackSearch } from 'src/interfaces/stack.interface'; import { asUuid } from 'src/utils/database'; +export interface StackSearch { + ownerId: string; + primaryAssetId?: string; +} + const withAssets = (eb: ExpressionBuilder, withTags = false) => { return jsonArrayFrom( eb @@ -35,7 +39,7 @@ const withAssets = (eb: ExpressionBuilder, withTags = false) }; @Injectable() -export class StackRepository implements IStackRepository { +export class StackRepository { constructor(@InjectKysely() private db: Kysely) {} @GenerateSql({ params: [{ ownerId: DummyValue.UUID }] }) diff --git a/server/src/repositories/storage.repository.ts b/server/src/repositories/storage.repository.ts index 6766f442b8..15b81e7106 100644 --- a/server/src/repositories/storage.repository.ts +++ b/server/src/repositories/storage.repository.ts @@ -5,20 +5,38 @@ import { escapePath, glob, globStream } from 'fast-glob'; import { constants, createReadStream, createWriteStream, existsSync, mkdirSync } from 'node:fs'; import fs from 'node:fs/promises'; import path from 'node:path'; -import { Writable } from 'node:stream'; +import { Readable, Writable } from 'node:stream'; import { CrawlOptionsDto, WalkOptionsDto } from 'src/dtos/library.dto'; -import { - DiskUsage, - IStorageRepository, - ImmichReadStream, - ImmichZipStream, - WatchEvents, -} from 'src/interfaces/storage.interface'; import { LoggingRepository } from 'src/repositories/logging.repository'; import { mimeTypes } from 'src/utils/mime-types'; +export interface WatchEvents { + onReady(): void; + onAdd(path: string): void; + onChange(path: string): void; + onUnlink(path: string): void; + onError(error: Error): void; +} + +export interface ImmichReadStream { + stream: Readable; + type?: string; + length?: number; +} + +export interface ImmichZipStream extends ImmichReadStream { + addFile: (inputPath: string, filename: string) => void; + finalize: () => Promise; +} + +export interface DiskUsage { + available: number; + free: number; + total: number; +} + @Injectable() -export class StorageRepository implements IStorageRepository { +export class StorageRepository { constructor(private logger: LoggingRepository) { this.logger.setContext(StorageRepository.name); } diff --git a/server/src/repositories/tag.repository.ts b/server/src/repositories/tag.repository.ts index 3489f43640..c5156e1837 100644 --- a/server/src/repositories/tag.repository.ts +++ b/server/src/repositories/tag.repository.ts @@ -2,12 +2,13 @@ import { Injectable } from '@nestjs/common'; import { InjectDataSource, InjectRepository } from '@nestjs/typeorm'; import { Chunked, ChunkedSet, DummyValue, GenerateSql } from 'src/decorators'; import { TagEntity } from 'src/entities/tag.entity'; -import { AssetTagItem, ITagRepository } from 'src/interfaces/tag.interface'; import { LoggingRepository } from 'src/repositories/logging.repository'; import { DataSource, In, Repository } from 'typeorm'; +export type AssetTagItem = { assetId: string; tagId: string }; + @Injectable() -export class TagRepository implements ITagRepository { +export class TagRepository { constructor( @InjectDataSource() private dataSource: DataSource, @InjectRepository(TagEntity) private repository: Repository, diff --git a/server/src/repositories/user.repository.ts b/server/src/repositories/user.repository.ts index 417ee141f4..22a9ecad5c 100644 --- a/server/src/repositories/user.repository.ts +++ b/server/src/repositories/user.repository.ts @@ -6,12 +6,6 @@ import { DummyValue, GenerateSql } from 'src/decorators'; import { UserMetadata } from 'src/entities/user-metadata.entity'; import { UserEntity, withMetadata } from 'src/entities/user.entity'; import { UserStatus } from 'src/enum'; -import { - IUserRepository, - UserFindOptions, - UserListFilter, - UserStatsQueryResponse, -} from 'src/interfaces/user.interface'; import { asUuid } from 'src/utils/database'; const columns = [ @@ -34,8 +28,27 @@ const columns = [ type Upsert = Insertable; +export interface UserListFilter { + withDeleted?: boolean; +} + +export interface UserStatsQueryResponse { + userId: string; + userName: string; + photos: number; + videos: number; + usage: number; + usagePhotos: number; + usageVideos: number; + quotaSizeInBytes: number | null; +} + +export interface UserFindOptions { + withDeleted?: boolean; +} + @Injectable() -export class UserRepository implements IUserRepository { +export class UserRepository { constructor(@InjectKysely() private db: Kysely) {} @GenerateSql({ params: [DummyValue.UUID, DummyValue.BOOLEAN] }) diff --git a/server/src/services/album.service.ts b/server/src/services/album.service.ts index 0286b387c3..722745ebd2 100644 --- a/server/src/services/album.service.ts +++ b/server/src/services/album.service.ts @@ -16,7 +16,7 @@ import { AuthDto } from 'src/dtos/auth.dto'; import { AlbumUserEntity } from 'src/entities/album-user.entity'; import { AlbumEntity } from 'src/entities/album.entity'; import { Permission } from 'src/enum'; -import { AlbumAssetCount, AlbumInfoOptions } from 'src/interfaces/album.interface'; +import { AlbumAssetCount, AlbumInfoOptions } from 'src/repositories/album.repository'; import { BaseService } from 'src/services/base.service'; import { addAssets, removeAssets } from 'src/utils/asset.util'; diff --git a/server/src/services/asset.service.spec.ts b/server/src/services/asset.service.spec.ts index 9d64aacf10..3c9cf3739f 100755 --- a/server/src/services/asset.service.spec.ts +++ b/server/src/services/asset.service.spec.ts @@ -4,8 +4,8 @@ import { mapAsset } from 'src/dtos/asset-response.dto'; import { AssetJobName, AssetStatsResponseDto } from 'src/dtos/asset.dto'; import { AssetEntity } from 'src/entities/asset.entity'; import { AssetStatus, AssetType } from 'src/enum'; -import { AssetStats } from 'src/interfaces/asset.interface'; import { JobName, JobStatus } from 'src/interfaces/job.interface'; +import { AssetStats } from 'src/repositories/asset.repository'; import { AssetService } from 'src/services/asset.service'; import { assetStub } from 'test/fixtures/asset.stub'; import { authStub } from 'test/fixtures/auth.stub'; diff --git a/server/src/services/backup.service.ts b/server/src/services/backup.service.ts index 324bc4cc13..d0a8ce69b6 100644 --- a/server/src/services/backup.service.ts +++ b/server/src/services/backup.service.ts @@ -4,9 +4,9 @@ import semver from 'semver'; import { StorageCore } from 'src/cores/storage.core'; import { OnEvent, OnJob } from 'src/decorators'; import { ImmichWorker, StorageFolder } from 'src/enum'; -import { DatabaseLock } from 'src/interfaces/database.interface'; import { ArgOf } from 'src/interfaces/event.interface'; import { JobName, JobStatus, QueueName } from 'src/interfaces/job.interface'; +import { DatabaseLock } from 'src/repositories/database.repository'; import { BaseService } from 'src/services/base.service'; import { handlePromiseError } from 'src/utils/misc'; diff --git a/server/src/services/base.service.ts b/server/src/services/base.service.ts index 9285c69ced..d0f00bd2ab 100644 --- a/server/src/services/base.service.ts +++ b/server/src/services/base.service.ts @@ -6,44 +6,43 @@ import { SALT_ROUNDS } from 'src/constants'; import { StorageCore } from 'src/cores/storage.core'; import { Users } from 'src/db'; import { UserEntity } from 'src/entities/user.entity'; -import { IAlbumRepository } from 'src/interfaces/album.interface'; -import { IAssetRepository } from 'src/interfaces/asset.interface'; -import { ICryptoRepository } from 'src/interfaces/crypto.interface'; -import { IDatabaseRepository } from 'src/interfaces/database.interface'; import { IEventRepository } from 'src/interfaces/event.interface'; import { IJobRepository } from 'src/interfaces/job.interface'; -import { ILibraryRepository } from 'src/interfaces/library.interface'; import { IMachineLearningRepository } from 'src/interfaces/machine-learning.interface'; -import { IMoveRepository } from 'src/interfaces/move.interface'; -import { IPartnerRepository } from 'src/interfaces/partner.interface'; -import { IPersonRepository } from 'src/interfaces/person.interface'; -import { ISearchRepository } from 'src/interfaces/search.interface'; -import { ISharedLinkRepository } from 'src/interfaces/shared-link.interface'; -import { IStackRepository } from 'src/interfaces/stack.interface'; -import { IStorageRepository } from 'src/interfaces/storage.interface'; -import { ITagRepository } from 'src/interfaces/tag.interface'; -import { IUserRepository } from 'src/interfaces/user.interface'; import { AccessRepository } from 'src/repositories/access.repository'; import { ActivityRepository } from 'src/repositories/activity.repository'; import { AlbumUserRepository } from 'src/repositories/album-user.repository'; +import { AlbumRepository } from 'src/repositories/album.repository'; import { ApiKeyRepository } from 'src/repositories/api-key.repository'; +import { AssetRepository } from 'src/repositories/asset.repository'; import { AuditRepository } from 'src/repositories/audit.repository'; import { ConfigRepository } from 'src/repositories/config.repository'; import { CronRepository } from 'src/repositories/cron.repository'; import { CryptoRepository } from 'src/repositories/crypto.repository'; +import { DatabaseRepository } from 'src/repositories/database.repository'; +import { LibraryRepository } from 'src/repositories/library.repository'; import { LoggingRepository } from 'src/repositories/logging.repository'; import { MapRepository } from 'src/repositories/map.repository'; import { MediaRepository } from 'src/repositories/media.repository'; import { MemoryRepository } from 'src/repositories/memory.repository'; import { MetadataRepository } from 'src/repositories/metadata.repository'; +import { MoveRepository } from 'src/repositories/move.repository'; import { NotificationRepository } from 'src/repositories/notification.repository'; import { OAuthRepository } from 'src/repositories/oauth.repository'; +import { PartnerRepository } from 'src/repositories/partner.repository'; +import { PersonRepository } from 'src/repositories/person.repository'; import { ProcessRepository } from 'src/repositories/process.repository'; +import { SearchRepository } from 'src/repositories/search.repository'; import { ServerInfoRepository } from 'src/repositories/server-info.repository'; import { SessionRepository } from 'src/repositories/session.repository'; +import { SharedLinkRepository } from 'src/repositories/shared-link.repository'; +import { StackRepository } from 'src/repositories/stack.repository'; +import { StorageRepository } from 'src/repositories/storage.repository'; import { SystemMetadataRepository } from 'src/repositories/system-metadata.repository'; +import { TagRepository } from 'src/repositories/tag.repository'; import { TelemetryRepository } from 'src/repositories/telemetry.repository'; import { TrashRepository } from 'src/repositories/trash.repository'; +import { UserRepository } from 'src/repositories/user.repository'; import { VersionHistoryRepository } from 'src/repositories/version-history.repository'; import { ViewRepository } from 'src/repositories/view-repository'; import { AccessRequest, checkAccess, requireAccess } from 'src/utils/access'; @@ -57,39 +56,39 @@ export class BaseService { protected accessRepository: AccessRepository, protected activityRepository: ActivityRepository, protected auditRepository: AuditRepository, - @Inject(IAlbumRepository) protected albumRepository: IAlbumRepository, + protected albumRepository: AlbumRepository, protected albumUserRepository: AlbumUserRepository, - @Inject(IAssetRepository) protected assetRepository: IAssetRepository, + protected assetRepository: AssetRepository, protected configRepository: ConfigRepository, protected cronRepository: CronRepository, - @Inject(ICryptoRepository) protected cryptoRepository: CryptoRepository, - @Inject(IDatabaseRepository) protected databaseRepository: IDatabaseRepository, + protected cryptoRepository: CryptoRepository, + protected databaseRepository: DatabaseRepository, @Inject(IEventRepository) protected eventRepository: IEventRepository, @Inject(IJobRepository) protected jobRepository: IJobRepository, protected keyRepository: ApiKeyRepository, - @Inject(ILibraryRepository) protected libraryRepository: ILibraryRepository, + protected libraryRepository: LibraryRepository, @Inject(IMachineLearningRepository) protected machineLearningRepository: IMachineLearningRepository, protected mapRepository: MapRepository, protected mediaRepository: MediaRepository, protected memoryRepository: MemoryRepository, protected metadataRepository: MetadataRepository, - @Inject(IMoveRepository) protected moveRepository: IMoveRepository, + protected moveRepository: MoveRepository, protected notificationRepository: NotificationRepository, protected oauthRepository: OAuthRepository, - @Inject(IPartnerRepository) protected partnerRepository: IPartnerRepository, - @Inject(IPersonRepository) protected personRepository: IPersonRepository, + protected partnerRepository: PartnerRepository, + protected personRepository: PersonRepository, protected processRepository: ProcessRepository, - @Inject(ISearchRepository) protected searchRepository: ISearchRepository, + protected searchRepository: SearchRepository, protected serverInfoRepository: ServerInfoRepository, protected sessionRepository: SessionRepository, - @Inject(ISharedLinkRepository) protected sharedLinkRepository: ISharedLinkRepository, - @Inject(IStackRepository) protected stackRepository: IStackRepository, - @Inject(IStorageRepository) protected storageRepository: IStorageRepository, + protected sharedLinkRepository: SharedLinkRepository, + protected stackRepository: StackRepository, + protected storageRepository: StorageRepository, protected systemMetadataRepository: SystemMetadataRepository, - @Inject(ITagRepository) protected tagRepository: ITagRepository, + protected tagRepository: TagRepository, protected telemetryRepository: TelemetryRepository, protected trashRepository: TrashRepository, - @Inject(IUserRepository) protected userRepository: IUserRepository, + protected userRepository: UserRepository, protected versionRepository: VersionHistoryRepository, protected viewRepository: ViewRepository, ) { diff --git a/server/src/services/database.service.spec.ts b/server/src/services/database.service.spec.ts index 566cd32778..d50b7facf6 100644 --- a/server/src/services/database.service.spec.ts +++ b/server/src/services/database.service.spec.ts @@ -1,4 +1,5 @@ -import { DatabaseExtension, EXTENSION_NAMES, VectorExtension } from 'src/interfaces/database.interface'; +import { DatabaseExtension } from 'src/enum'; +import { EXTENSION_NAMES, VectorExtension } from 'src/repositories/database.repository'; import { DatabaseService } from 'src/services/database.service'; import { mockEnvData } from 'test/repositories/config.repository.mock'; import { newTestService, ServiceMocks } from 'test/utils'; diff --git a/server/src/services/database.service.ts b/server/src/services/database.service.ts index ec0075b119..eabb0b0091 100644 --- a/server/src/services/database.service.ts +++ b/server/src/services/database.service.ts @@ -2,14 +2,9 @@ import { Injectable } from '@nestjs/common'; import { Duration } from 'luxon'; import semver from 'semver'; import { OnEvent } from 'src/decorators'; -import { - DatabaseExtension, - DatabaseLock, - EXTENSION_NAMES, - VectorExtension, - VectorIndex, -} from 'src/interfaces/database.interface'; +import { DatabaseExtension } from 'src/enum'; import { BootstrapEventPriority } from 'src/interfaces/event.interface'; +import { DatabaseLock, EXTENSION_NAMES, VectorExtension, VectorIndex } from 'src/repositories/database.repository'; import { BaseService } from 'src/services/base.service'; type CreateFailedArgs = { name: string; extension: string; otherName: string }; diff --git a/server/src/services/download.service.ts b/server/src/services/download.service.ts index 3d66f009cf..dd2430778a 100644 --- a/server/src/services/download.service.ts +++ b/server/src/services/download.service.ts @@ -6,7 +6,7 @@ import { AuthDto } from 'src/dtos/auth.dto'; import { DownloadArchiveInfo, DownloadInfoDto, DownloadResponseDto } from 'src/dtos/download.dto'; import { AssetEntity } from 'src/entities/asset.entity'; import { Permission } from 'src/enum'; -import { ImmichReadStream } from 'src/interfaces/storage.interface'; +import { ImmichReadStream } from 'src/repositories/storage.repository'; import { BaseService } from 'src/services/base.service'; import { HumanReadableSize } from 'src/utils/bytes'; import { usePagination } from 'src/utils/pagination'; diff --git a/server/src/services/duplicate.service.spec.ts b/server/src/services/duplicate.service.spec.ts index 0451f1f2b3..7a8b9ed27b 100644 --- a/server/src/services/duplicate.service.spec.ts +++ b/server/src/services/duplicate.service.spec.ts @@ -1,5 +1,5 @@ -import { WithoutProperty } from 'src/interfaces/asset.interface'; import { JobName, JobStatus } from 'src/interfaces/job.interface'; +import { WithoutProperty } from 'src/repositories/asset.repository'; import { DuplicateService } from 'src/services/duplicate.service'; import { SearchService } from 'src/services/search.service'; import { assetStub } from 'test/fixtures/asset.stub'; diff --git a/server/src/services/duplicate.service.ts b/server/src/services/duplicate.service.ts index 7e8ea49991..1b0d583cc3 100644 --- a/server/src/services/duplicate.service.ts +++ b/server/src/services/duplicate.service.ts @@ -4,9 +4,9 @@ import { mapAsset } from 'src/dtos/asset-response.dto'; import { AuthDto } from 'src/dtos/auth.dto'; import { DuplicateResponseDto } from 'src/dtos/duplicate.dto'; import { AssetEntity } from 'src/entities/asset.entity'; -import { WithoutProperty } from 'src/interfaces/asset.interface'; import { JOBS_ASSET_PAGINATION_SIZE, JobName, JobOf, JobStatus, QueueName } from 'src/interfaces/job.interface'; -import { AssetDuplicateResult } from 'src/interfaces/search.interface'; +import { WithoutProperty } from 'src/repositories/asset.repository'; +import { AssetDuplicateResult } from 'src/repositories/search.repository'; import { BaseService } from 'src/services/base.service'; import { getAssetFiles } from 'src/utils/asset.util'; import { isDuplicateDetectionEnabled } from 'src/utils/misc'; diff --git a/server/src/services/library.service.ts b/server/src/services/library.service.ts index 59ac171ce6..5235b786e9 100644 --- a/server/src/services/library.service.ts +++ b/server/src/services/library.service.ts @@ -17,9 +17,9 @@ import { import { AssetEntity } from 'src/entities/asset.entity'; import { LibraryEntity } from 'src/entities/library.entity'; import { AssetType, ImmichWorker } from 'src/enum'; -import { DatabaseLock } from 'src/interfaces/database.interface'; import { ArgOf } from 'src/interfaces/event.interface'; import { JobName, JobOf, JOBS_LIBRARY_PAGINATION_SIZE, JobStatus, QueueName } from 'src/interfaces/job.interface'; +import { DatabaseLock } from 'src/repositories/database.repository'; import { BaseService } from 'src/services/base.service'; import { mimeTypes } from 'src/utils/mime-types'; import { handlePromiseError } from 'src/utils/misc'; diff --git a/server/src/services/media.service.spec.ts b/server/src/services/media.service.spec.ts index 3f48d8534a..52ccab1c91 100644 --- a/server/src/services/media.service.spec.ts +++ b/server/src/services/media.service.spec.ts @@ -13,8 +13,8 @@ import { TranscodePolicy, VideoCodec, } from 'src/enum'; -import { WithoutProperty } from 'src/interfaces/asset.interface'; import { JobCounts, JobName, JobStatus } from 'src/interfaces/job.interface'; +import { WithoutProperty } from 'src/repositories/asset.repository'; import { MediaService } from 'src/services/media.service'; import { RawImageInfo } from 'src/types'; import { assetStub } from 'test/fixtures/asset.stub'; diff --git a/server/src/services/media.service.ts b/server/src/services/media.service.ts index c22d124b63..58621c1b19 100644 --- a/server/src/services/media.service.ts +++ b/server/src/services/media.service.ts @@ -18,7 +18,6 @@ import { VideoCodec, VideoContainer, } from 'src/enum'; -import { UpsertFileOptions, WithoutProperty } from 'src/interfaces/asset.interface'; import { JOBS_ASSET_PAGINATION_SIZE, JobItem, @@ -27,6 +26,7 @@ import { JobStatus, QueueName, } from 'src/interfaces/job.interface'; +import { UpsertFileOptions, WithoutProperty } from 'src/repositories/asset.repository'; import { BaseService } from 'src/services/base.service'; import { AudioStreamInfo, VideoFormat, VideoInterfaces, VideoStreamInfo } from 'src/types'; import { getAssetFiles } from 'src/utils/asset.util'; diff --git a/server/src/services/metadata.service.spec.ts b/server/src/services/metadata.service.spec.ts index 5657dd02b9..c7c675ab3e 100644 --- a/server/src/services/metadata.service.spec.ts +++ b/server/src/services/metadata.service.spec.ts @@ -5,8 +5,8 @@ import { constants } from 'node:fs/promises'; import { AssetEntity } from 'src/entities/asset.entity'; import { ExifEntity } from 'src/entities/exif.entity'; import { AssetType, ExifOrientation, ImmichWorker, SourceType } from 'src/enum'; -import { WithoutProperty } from 'src/interfaces/asset.interface'; import { JobName, JobStatus } from 'src/interfaces/job.interface'; +import { WithoutProperty } from 'src/repositories/asset.repository'; import { ImmichTags } from 'src/repositories/metadata.repository'; import { MetadataService } from 'src/services/metadata.service'; import { assetStub } from 'test/fixtures/asset.stub'; diff --git a/server/src/services/metadata.service.ts b/server/src/services/metadata.service.ts index db3af9fca0..53110a20e0 100644 --- a/server/src/services/metadata.service.ts +++ b/server/src/services/metadata.service.ts @@ -14,10 +14,10 @@ import { AssetFaceEntity } from 'src/entities/asset-face.entity'; import { AssetEntity } from 'src/entities/asset.entity'; import { PersonEntity } from 'src/entities/person.entity'; import { AssetType, ExifOrientation, ImmichWorker, SourceType } from 'src/enum'; -import { WithoutProperty } from 'src/interfaces/asset.interface'; -import { DatabaseLock } from 'src/interfaces/database.interface'; import { ArgOf } from 'src/interfaces/event.interface'; import { JobName, JobOf, JOBS_ASSET_PAGINATION_SIZE, JobStatus, QueueName } from 'src/interfaces/job.interface'; +import { WithoutProperty } from 'src/repositories/asset.repository'; +import { DatabaseLock } from 'src/repositories/database.repository'; import { ReverseGeocodeResult } from 'src/repositories/map.repository'; import { ImmichTags } from 'src/repositories/metadata.repository'; import { BaseService } from 'src/services/base.service'; diff --git a/server/src/services/partner.service.spec.ts b/server/src/services/partner.service.spec.ts index 02dff32a72..9c29afaeaa 100644 --- a/server/src/services/partner.service.spec.ts +++ b/server/src/services/partner.service.spec.ts @@ -1,5 +1,5 @@ import { BadRequestException } from '@nestjs/common'; -import { PartnerDirection } from 'src/interfaces/partner.interface'; +import { PartnerDirection } from 'src/repositories/partner.repository'; import { PartnerService } from 'src/services/partner.service'; import { authStub } from 'test/fixtures/auth.stub'; import { partnerStub } from 'test/fixtures/partner.stub'; diff --git a/server/src/services/partner.service.ts b/server/src/services/partner.service.ts index f17bab24ba..32b3ae3d3f 100644 --- a/server/src/services/partner.service.ts +++ b/server/src/services/partner.service.ts @@ -4,7 +4,7 @@ import { PartnerResponseDto, PartnerSearchDto, UpdatePartnerDto } from 'src/dtos import { mapUser } from 'src/dtos/user.dto'; import { PartnerEntity } from 'src/entities/partner.entity'; import { Permission } from 'src/enum'; -import { PartnerDirection, PartnerIds } from 'src/interfaces/partner.interface'; +import { PartnerDirection, PartnerIds } from 'src/repositories/partner.repository'; import { BaseService } from 'src/services/base.service'; @Injectable() diff --git a/server/src/services/person.service.spec.ts b/server/src/services/person.service.spec.ts index 0b3adec571..ec2417c793 100644 --- a/server/src/services/person.service.spec.ts +++ b/server/src/services/person.service.spec.ts @@ -3,10 +3,10 @@ import { BulkIdErrorReason } from 'src/dtos/asset-ids.response.dto'; import { mapFaces, mapPerson, PersonResponseDto } from 'src/dtos/person.dto'; import { AssetFaceEntity } from 'src/entities/asset-face.entity'; import { CacheControl, Colorspace, ImageFormat, SourceType, SystemMetadataKey } from 'src/enum'; -import { WithoutProperty } from 'src/interfaces/asset.interface'; import { JobName, JobStatus } from 'src/interfaces/job.interface'; import { DetectedFaces } from 'src/interfaces/machine-learning.interface'; -import { FaceSearchResult } from 'src/interfaces/search.interface'; +import { WithoutProperty } from 'src/repositories/asset.repository'; +import { FaceSearchResult } from 'src/repositories/search.repository'; import { PersonService } from 'src/services/person.service'; import { ImmichFileResponse } from 'src/utils/file'; import { assetStub } from 'test/fixtures/asset.stub'; diff --git a/server/src/services/person.service.ts b/server/src/services/person.service.ts index 116d2ec6c8..2ae703afb3 100644 --- a/server/src/services/person.service.ts +++ b/server/src/services/person.service.ts @@ -32,7 +32,6 @@ import { SourceType, SystemMetadataKey, } from 'src/enum'; -import { WithoutProperty } from 'src/interfaces/asset.interface'; import { JOBS_ASSET_PAGINATION_SIZE, JobItem, @@ -42,7 +41,8 @@ import { QueueName, } from 'src/interfaces/job.interface'; import { BoundingBox } from 'src/interfaces/machine-learning.interface'; -import { UpdateFacesData } from 'src/interfaces/person.interface'; +import { WithoutProperty } from 'src/repositories/asset.repository'; +import { UpdateFacesData } from 'src/repositories/person.repository'; import { BaseService } from 'src/services/base.service'; import { CropOptions, ImageDimensions, InputDimensions } from 'src/types'; import { getAssetFiles } from 'src/utils/asset.util'; diff --git a/server/src/services/search.service.ts b/server/src/services/search.service.ts index b74d3d3cba..e2ad9e7f99 100644 --- a/server/src/services/search.service.ts +++ b/server/src/services/search.service.ts @@ -16,7 +16,7 @@ import { } from 'src/dtos/search.dto'; import { AssetEntity } from 'src/entities/asset.entity'; import { AssetOrder } from 'src/enum'; -import { SearchExploreItem } from 'src/interfaces/search.interface'; +import { SearchExploreItem } from 'src/repositories/search.repository'; import { BaseService } from 'src/services/base.service'; import { getMyPartnerIds } from 'src/utils/asset.util'; import { isSmartSearchEnabled } from 'src/utils/misc'; @@ -109,7 +109,7 @@ export class SearchService extends BaseService { return suggestions; } - private getSuggestions(userIds: string[], dto: SearchSuggestionRequestDto) { + private getSuggestions(userIds: string[], dto: SearchSuggestionRequestDto): Promise> { switch (dto.type) { case SearchSuggestionType.COUNTRY: { return this.searchRepository.getCountries(userIds); @@ -127,7 +127,7 @@ export class SearchService extends BaseService { return this.searchRepository.getCameraModels(userIds, dto); } default: { - return [] as (string | null)[]; + return Promise.resolve([]); } } } diff --git a/server/src/services/server.service.ts b/server/src/services/server.service.ts index e9dd908a7c..9112c40a17 100644 --- a/server/src/services/server.service.ts +++ b/server/src/services/server.service.ts @@ -14,7 +14,7 @@ import { UsageByUserDto, } from 'src/dtos/server.dto'; import { StorageFolder, SystemMetadataKey } from 'src/enum'; -import { UserStatsQueryResponse } from 'src/interfaces/user.interface'; +import { UserStatsQueryResponse } from 'src/repositories/user.repository'; import { BaseService } from 'src/services/base.service'; import { asHumanReadable } from 'src/utils/bytes'; import { mimeTypes } from 'src/utils/mime-types'; diff --git a/server/src/services/smart-info.service.spec.ts b/server/src/services/smart-info.service.spec.ts index 79e13ea7ab..403c6e9a6a 100644 --- a/server/src/services/smart-info.service.spec.ts +++ b/server/src/services/smart-info.service.spec.ts @@ -1,7 +1,7 @@ import { SystemConfig } from 'src/config'; import { ImmichWorker } from 'src/enum'; -import { WithoutProperty } from 'src/interfaces/asset.interface'; import { JobName, JobStatus } from 'src/interfaces/job.interface'; +import { WithoutProperty } from 'src/repositories/asset.repository'; import { SmartInfoService } from 'src/services/smart-info.service'; import { getCLIPModelInfo } from 'src/utils/misc'; import { assetStub } from 'test/fixtures/asset.stub'; diff --git a/server/src/services/smart-info.service.ts b/server/src/services/smart-info.service.ts index 8fef961fe1..ebf9e1d287 100644 --- a/server/src/services/smart-info.service.ts +++ b/server/src/services/smart-info.service.ts @@ -2,10 +2,10 @@ import { Injectable } from '@nestjs/common'; import { SystemConfig } from 'src/config'; import { OnEvent, OnJob } from 'src/decorators'; import { ImmichWorker } from 'src/enum'; -import { WithoutProperty } from 'src/interfaces/asset.interface'; -import { DatabaseLock } from 'src/interfaces/database.interface'; import { ArgOf } from 'src/interfaces/event.interface'; import { JOBS_ASSET_PAGINATION_SIZE, JobName, JobOf, JobStatus, QueueName } from 'src/interfaces/job.interface'; +import { WithoutProperty } from 'src/repositories/asset.repository'; +import { DatabaseLock } from 'src/repositories/database.repository'; import { BaseService } from 'src/services/base.service'; import { getAssetFiles } from 'src/utils/asset.util'; import { getCLIPModelInfo, isSmartSearchEnabled } from 'src/utils/misc'; diff --git a/server/src/services/storage-template.service.ts b/server/src/services/storage-template.service.ts index e8e4bd12a5..6b0409de1d 100644 --- a/server/src/services/storage-template.service.ts +++ b/server/src/services/storage-template.service.ts @@ -8,9 +8,9 @@ import { OnEvent, OnJob } from 'src/decorators'; import { SystemConfigTemplateStorageOptionDto } from 'src/dtos/system-config.dto'; import { AssetEntity } from 'src/entities/asset.entity'; import { AssetPathType, AssetType, StorageFolder } from 'src/enum'; -import { DatabaseLock } from 'src/interfaces/database.interface'; import { ArgOf } from 'src/interfaces/event.interface'; import { JobName, JobOf, JOBS_ASSET_PAGINATION_SIZE, JobStatus, QueueName } from 'src/interfaces/job.interface'; +import { DatabaseLock } from 'src/repositories/database.repository'; import { BaseService } from 'src/services/base.service'; import { getLivePhotoMotionFilename } from 'src/utils/file'; import { usePagination } from 'src/utils/pagination'; diff --git a/server/src/services/storage.service.ts b/server/src/services/storage.service.ts index ce26df4869..6921d33560 100644 --- a/server/src/services/storage.service.ts +++ b/server/src/services/storage.service.ts @@ -4,8 +4,8 @@ import { StorageCore } from 'src/cores/storage.core'; import { OnEvent, OnJob } from 'src/decorators'; import { SystemFlags } from 'src/entities/system-metadata.entity'; import { StorageFolder, SystemMetadataKey } from 'src/enum'; -import { DatabaseLock } from 'src/interfaces/database.interface'; import { JobName, JobOf, JobStatus, QueueName } from 'src/interfaces/job.interface'; +import { DatabaseLock } from 'src/repositories/database.repository'; import { BaseService } from 'src/services/base.service'; import { ImmichStartupError } from 'src/utils/misc'; diff --git a/server/src/services/tag.service.ts b/server/src/services/tag.service.ts index 4c31790d72..83d4b40340 100644 --- a/server/src/services/tag.service.ts +++ b/server/src/services/tag.service.ts @@ -14,7 +14,7 @@ import { import { TagEntity } from 'src/entities/tag.entity'; import { Permission } from 'src/enum'; import { JobName, JobStatus, QueueName } from 'src/interfaces/job.interface'; -import { AssetTagItem } from 'src/interfaces/tag.interface'; +import { AssetTagItem } from 'src/repositories/tag.repository'; import { BaseService } from 'src/services/base.service'; import { addAssets, removeAssets } from 'src/utils/asset.util'; import { upsertTags } from 'src/utils/tag'; diff --git a/server/src/services/timeline.service.spec.ts b/server/src/services/timeline.service.spec.ts index 15dab6bc05..749633998b 100644 --- a/server/src/services/timeline.service.spec.ts +++ b/server/src/services/timeline.service.spec.ts @@ -1,5 +1,5 @@ import { BadRequestException } from '@nestjs/common'; -import { TimeBucketSize } from 'src/interfaces/asset.interface'; +import { TimeBucketSize } from 'src/repositories/asset.repository'; import { TimelineService } from 'src/services/timeline.service'; import { assetStub } from 'test/fixtures/asset.stub'; import { authStub } from 'test/fixtures/auth.stub'; diff --git a/server/src/services/timeline.service.ts b/server/src/services/timeline.service.ts index 04fd206fe7..bc4c7fad73 100644 --- a/server/src/services/timeline.service.ts +++ b/server/src/services/timeline.service.ts @@ -3,7 +3,7 @@ import { AssetResponseDto, SanitizedAssetResponseDto, mapAsset } from 'src/dtos/ import { AuthDto } from 'src/dtos/auth.dto'; import { TimeBucketAssetDto, TimeBucketDto, TimeBucketResponseDto } from 'src/dtos/time-bucket.dto'; import { Permission } from 'src/enum'; -import { TimeBucketOptions } from 'src/interfaces/asset.interface'; +import { TimeBucketOptions } from 'src/repositories/asset.repository'; import { BaseService } from 'src/services/base.service'; import { getMyPartnerIds } from 'src/utils/asset.util'; diff --git a/server/src/services/user-admin.service.ts b/server/src/services/user-admin.service.ts index 784c95954e..3df9a218f3 100644 --- a/server/src/services/user-admin.service.ts +++ b/server/src/services/user-admin.service.ts @@ -12,7 +12,7 @@ import { } from 'src/dtos/user.dto'; import { UserMetadataKey, UserStatus } from 'src/enum'; import { JobName } from 'src/interfaces/job.interface'; -import { UserFindOptions } from 'src/interfaces/user.interface'; +import { UserFindOptions } from 'src/repositories/user.repository'; import { BaseService } from 'src/services/base.service'; import { getPreferences, getPreferencesPartial, mergePreferences } from 'src/utils/preferences'; diff --git a/server/src/services/user.service.ts b/server/src/services/user.service.ts index f4ae42b5ed..dfd9c7715f 100644 --- a/server/src/services/user.service.ts +++ b/server/src/services/user.service.ts @@ -12,7 +12,7 @@ import { UserMetadataEntity } from 'src/entities/user-metadata.entity'; import { UserEntity } from 'src/entities/user.entity'; import { CacheControl, StorageFolder, UserMetadataKey } from 'src/enum'; import { JobName, JobOf, JobStatus, QueueName } from 'src/interfaces/job.interface'; -import { UserFindOptions } from 'src/interfaces/user.interface'; +import { UserFindOptions } from 'src/repositories/user.repository'; import { BaseService } from 'src/services/base.service'; import { ImmichFileResponse } from 'src/utils/file'; import { getPreferences, getPreferencesPartial, mergePreferences } from 'src/utils/preferences'; diff --git a/server/src/services/version.service.ts b/server/src/services/version.service.ts index ff4fa3c6bf..ee36d30041 100644 --- a/server/src/services/version.service.ts +++ b/server/src/services/version.service.ts @@ -6,9 +6,9 @@ import { OnEvent, OnJob } from 'src/decorators'; import { ReleaseNotification, ServerVersionResponseDto } from 'src/dtos/server.dto'; import { VersionCheckMetadata } from 'src/entities/system-metadata.entity'; import { ImmichEnvironment, SystemMetadataKey } from 'src/enum'; -import { DatabaseLock } from 'src/interfaces/database.interface'; import { ArgOf } from 'src/interfaces/event.interface'; import { JobName, JobStatus, QueueName } from 'src/interfaces/job.interface'; +import { DatabaseLock } from 'src/repositories/database.repository'; import { BaseService } from 'src/services/base.service'; const asNotification = ({ checkedAt, releaseVersion }: VersionCheckMetadata): ReleaseNotification => { diff --git a/server/src/utils/asset.util.ts b/server/src/utils/asset.util.ts index 39593a77f3..703fe2c3d7 100644 --- a/server/src/utils/asset.util.ts +++ b/server/src/utils/asset.util.ts @@ -5,12 +5,12 @@ import { UploadFieldName } from 'src/dtos/asset-media.dto'; import { AuthDto } from 'src/dtos/auth.dto'; import { AssetFileEntity } from 'src/entities/asset-files.entity'; import { AssetFileType, AssetType, Permission } from 'src/enum'; -import { IAssetRepository } from 'src/interfaces/asset.interface'; import { IEventRepository } from 'src/interfaces/event.interface'; -import { IPartnerRepository } from 'src/interfaces/partner.interface'; import { AuthRequest } from 'src/middleware/auth.guard'; import { ImmichFile } from 'src/middleware/file-upload.interceptor'; import { AccessRepository } from 'src/repositories/access.repository'; +import { AssetRepository } from 'src/repositories/asset.repository'; +import { PartnerRepository } from 'src/repositories/partner.repository'; import { UploadFile } from 'src/services/asset-media.service'; import { checkAccess } from 'src/utils/access'; @@ -111,7 +111,7 @@ export const removeAssets = async ( export type PartnerIdOptions = { userId: string; - repository: IPartnerRepository; + repository: PartnerRepository; /** only include partners with `inTimeline: true` */ timelineEnabled?: boolean; }; @@ -139,7 +139,7 @@ export const getMyPartnerIds = async ({ userId, repository, timelineEnabled }: P return [...partnerIds]; }; -export type AssetHookRepositories = { asset: IAssetRepository; event: IEventRepository }; +export type AssetHookRepositories = { asset: AssetRepository; event: IEventRepository }; export const onBeforeLink = async ( { asset: assetRepository, event: eventRepository }: AssetHookRepositories, diff --git a/server/src/utils/config.ts b/server/src/utils/config.ts index 056064c026..30f965c37f 100644 --- a/server/src/utils/config.ts +++ b/server/src/utils/config.ts @@ -6,8 +6,8 @@ import * as _ from 'lodash'; import { SystemConfig, defaults } from 'src/config'; import { SystemConfigDto } from 'src/dtos/system-config.dto'; import { SystemMetadataKey } from 'src/enum'; -import { DatabaseLock } from 'src/interfaces/database.interface'; import { ConfigRepository } from 'src/repositories/config.repository'; +import { DatabaseLock } from 'src/repositories/database.repository'; import { LoggingRepository } from 'src/repositories/logging.repository'; import { SystemMetadataRepository } from 'src/repositories/system-metadata.repository'; import { DeepPartial } from 'src/types'; diff --git a/server/src/utils/file.ts b/server/src/utils/file.ts index d9c599169d..915c60de2a 100644 --- a/server/src/utils/file.ts +++ b/server/src/utils/file.ts @@ -4,8 +4,8 @@ import { access, constants } from 'node:fs/promises'; import { basename, extname, isAbsolute } from 'node:path'; import { promisify } from 'node:util'; import { CacheControl } from 'src/enum'; -import { ImmichReadStream } from 'src/interfaces/storage.interface'; import { LoggingRepository } from 'src/repositories/logging.repository'; +import { ImmichReadStream } from 'src/repositories/storage.repository'; import { isConnectionAborted } from 'src/utils/misc'; export function getFileNameWithoutExtension(path: string): string { diff --git a/server/src/utils/tag.ts b/server/src/utils/tag.ts index 027afcf040..4b3b360a8b 100644 --- a/server/src/utils/tag.ts +++ b/server/src/utils/tag.ts @@ -1,8 +1,8 @@ import { TagEntity } from 'src/entities/tag.entity'; -import { ITagRepository } from 'src/interfaces/tag.interface'; +import { TagRepository } from 'src/repositories/tag.repository'; type UpsertRequest = { userId: string; tags: string[] }; -export const upsertTags = async (repository: ITagRepository, { userId, tags }: UpsertRequest) => { +export const upsertTags = async (repository: TagRepository, { userId, tags }: UpsertRequest) => { tags = [...new Set(tags)]; const results: TagEntity[] = []; diff --git a/server/test/repositories/album.repository.mock.ts b/server/test/repositories/album.repository.mock.ts index dd5c3af6a8..7a1ae68a52 100644 --- a/server/test/repositories/album.repository.mock.ts +++ b/server/test/repositories/album.repository.mock.ts @@ -1,7 +1,8 @@ -import { IAlbumRepository } from 'src/interfaces/album.interface'; +import { AlbumRepository } from 'src/repositories/album.repository'; +import { RepositoryInterface } from 'src/types'; import { Mocked, vitest } from 'vitest'; -export const newAlbumRepositoryMock = (): Mocked => { +export const newAlbumRepositoryMock = (): Mocked> => { return { getById: vitest.fn(), getByAssetId: vitest.fn(), diff --git a/server/test/repositories/asset.repository.mock.ts b/server/test/repositories/asset.repository.mock.ts index 928a7956c5..a2baed7cce 100644 --- a/server/test/repositories/asset.repository.mock.ts +++ b/server/test/repositories/asset.repository.mock.ts @@ -1,7 +1,8 @@ -import { IAssetRepository } from 'src/interfaces/asset.interface'; +import { AssetRepository } from 'src/repositories/asset.repository'; +import { RepositoryInterface } from 'src/types'; import { Mocked, vitest } from 'vitest'; -export const newAssetRepositoryMock = (): Mocked => { +export const newAssetRepositoryMock = (): Mocked> => { return { create: vitest.fn(), upsertExif: vitest.fn(), diff --git a/server/test/repositories/config.repository.mock.ts b/server/test/repositories/config.repository.mock.ts index 800d40642b..7c5450c36e 100644 --- a/server/test/repositories/config.repository.mock.ts +++ b/server/test/repositories/config.repository.mock.ts @@ -1,5 +1,4 @@ -import { ImmichEnvironment, ImmichWorker } from 'src/enum'; -import { DatabaseExtension } from 'src/interfaces/database.interface'; +import { DatabaseExtension, ImmichEnvironment, ImmichWorker } from 'src/enum'; import { ConfigRepository, EnvData } from 'src/repositories/config.repository'; import { RepositoryInterface } from 'src/types'; import { Mocked, vitest } from 'vitest'; diff --git a/server/test/repositories/crypto.repository.mock.ts b/server/test/repositories/crypto.repository.mock.ts index e0b6fa2bb8..9d32a88987 100644 --- a/server/test/repositories/crypto.repository.mock.ts +++ b/server/test/repositories/crypto.repository.mock.ts @@ -1,7 +1,8 @@ -import { ICryptoRepository } from 'src/interfaces/crypto.interface'; +import { CryptoRepository } from 'src/repositories/crypto.repository'; +import { RepositoryInterface } from 'src/types'; import { Mocked, vitest } from 'vitest'; -export const newCryptoRepositoryMock = (): Mocked => { +export const newCryptoRepositoryMock = (): Mocked> => { return { randomUUID: vitest.fn().mockReturnValue('random-uuid'), randomBytes: vitest.fn().mockReturnValue(Buffer.from('random-bytes', 'utf8')), diff --git a/server/test/repositories/database.repository.mock.ts b/server/test/repositories/database.repository.mock.ts index c135772518..fe954c725b 100644 --- a/server/test/repositories/database.repository.mock.ts +++ b/server/test/repositories/database.repository.mock.ts @@ -1,7 +1,8 @@ -import { IDatabaseRepository } from 'src/interfaces/database.interface'; +import { DatabaseRepository } from 'src/repositories/database.repository'; +import { RepositoryInterface } from 'src/types'; import { Mocked, vitest } from 'vitest'; -export const newDatabaseRepositoryMock = (): Mocked => { +export const newDatabaseRepositoryMock = (): Mocked> => { return { init: vitest.fn(), shutdown: vitest.fn(), diff --git a/server/test/repositories/library.repository.mock.ts b/server/test/repositories/library.repository.mock.ts index 83e97c7ffa..5db9e18d33 100644 --- a/server/test/repositories/library.repository.mock.ts +++ b/server/test/repositories/library.repository.mock.ts @@ -1,7 +1,8 @@ -import { ILibraryRepository } from 'src/interfaces/library.interface'; +import { LibraryRepository } from 'src/repositories/library.repository'; +import { RepositoryInterface } from 'src/types'; import { Mocked, vitest } from 'vitest'; -export const newLibraryRepositoryMock = (): Mocked => { +export const newLibraryRepositoryMock = (): Mocked> => { return { get: vitest.fn(), create: vitest.fn(), diff --git a/server/test/repositories/move.repository.mock.ts b/server/test/repositories/move.repository.mock.ts index 1f982a048d..cf304b591e 100644 --- a/server/test/repositories/move.repository.mock.ts +++ b/server/test/repositories/move.repository.mock.ts @@ -1,7 +1,8 @@ -import { IMoveRepository } from 'src/interfaces/move.interface'; +import { MoveRepository } from 'src/repositories/move.repository'; +import { RepositoryInterface } from 'src/types'; import { Mocked, vitest } from 'vitest'; -export const newMoveRepositoryMock = (): Mocked => { +export const newMoveRepositoryMock = (): Mocked> => { return { create: vitest.fn(), getByEntity: vitest.fn(), diff --git a/server/test/repositories/partner.repository.mock.ts b/server/test/repositories/partner.repository.mock.ts index ec1f141075..6f9d4a36be 100644 --- a/server/test/repositories/partner.repository.mock.ts +++ b/server/test/repositories/partner.repository.mock.ts @@ -1,7 +1,8 @@ -import { IPartnerRepository } from 'src/interfaces/partner.interface'; +import { PartnerRepository } from 'src/repositories/partner.repository'; +import { RepositoryInterface } from 'src/types'; import { Mocked, vitest } from 'vitest'; -export const newPartnerRepositoryMock = (): Mocked => { +export const newPartnerRepositoryMock = (): Mocked> => { return { create: vitest.fn(), remove: vitest.fn(), diff --git a/server/test/repositories/person.repository.mock.ts b/server/test/repositories/person.repository.mock.ts index d7b92d3eab..52240aafa2 100644 --- a/server/test/repositories/person.repository.mock.ts +++ b/server/test/repositories/person.repository.mock.ts @@ -1,7 +1,8 @@ -import { IPersonRepository } from 'src/interfaces/person.interface'; +import { PersonRepository } from 'src/repositories/person.repository'; +import { RepositoryInterface } from 'src/types'; import { Mocked, vitest } from 'vitest'; -export const newPersonRepositoryMock = (): Mocked => { +export const newPersonRepositoryMock = (): Mocked> => { return { getById: vitest.fn(), getAll: vitest.fn(), diff --git a/server/test/repositories/search.repository.mock.ts b/server/test/repositories/search.repository.mock.ts index be0e753e30..520bf23b3e 100644 --- a/server/test/repositories/search.repository.mock.ts +++ b/server/test/repositories/search.repository.mock.ts @@ -1,7 +1,8 @@ -import { ISearchRepository } from 'src/interfaces/search.interface'; +import { SearchRepository } from 'src/repositories/search.repository'; +import { RepositoryInterface } from 'src/types'; import { Mocked, vitest } from 'vitest'; -export const newSearchRepositoryMock = (): Mocked => { +export const newSearchRepositoryMock = (): Mocked> => { return { searchMetadata: vitest.fn(), searchSmart: vitest.fn(), diff --git a/server/test/repositories/shared-link.repository.mock.ts b/server/test/repositories/shared-link.repository.mock.ts index 251b38d5d7..66044b9eed 100644 --- a/server/test/repositories/shared-link.repository.mock.ts +++ b/server/test/repositories/shared-link.repository.mock.ts @@ -1,7 +1,8 @@ -import { ISharedLinkRepository } from 'src/interfaces/shared-link.interface'; +import { SharedLinkRepository } from 'src/repositories/shared-link.repository'; +import { RepositoryInterface } from 'src/types'; import { Mocked, vitest } from 'vitest'; -export const newSharedLinkRepositoryMock = (): Mocked => { +export const newSharedLinkRepositoryMock = (): Mocked> => { return { getAll: vitest.fn(), get: vitest.fn(), diff --git a/server/test/repositories/stack.repository.mock.ts b/server/test/repositories/stack.repository.mock.ts index 35d1614de7..74fef6f4b1 100644 --- a/server/test/repositories/stack.repository.mock.ts +++ b/server/test/repositories/stack.repository.mock.ts @@ -1,7 +1,8 @@ -import { IStackRepository } from 'src/interfaces/stack.interface'; +import { StackRepository } from 'src/repositories/stack.repository'; +import { RepositoryInterface } from 'src/types'; import { Mocked, vitest } from 'vitest'; -export const newStackRepositoryMock = (): Mocked => { +export const newStackRepositoryMock = (): Mocked> => { return { search: vitest.fn(), create: vitest.fn(), diff --git a/server/test/repositories/storage.repository.mock.ts b/server/test/repositories/storage.repository.mock.ts index 0af16a8d17..984785d510 100644 --- a/server/test/repositories/storage.repository.mock.ts +++ b/server/test/repositories/storage.repository.mock.ts @@ -1,6 +1,7 @@ import { WatchOptions } from 'chokidar'; import { StorageCore } from 'src/cores/storage.core'; -import { IStorageRepository, WatchEvents } from 'src/interfaces/storage.interface'; +import { StorageRepository, WatchEvents } from 'src/repositories/storage.repository'; +import { RepositoryInterface } from 'src/types'; import { Mocked, vitest } from 'vitest'; interface MockWatcherOptions { @@ -39,7 +40,7 @@ export const makeMockWatcher = return () => Promise.resolve(); }; -export const newStorageRepositoryMock = (reset = true): Mocked => { +export const newStorageRepositoryMock = (reset = true): Mocked> => { if (reset) { StorageCore.reset(); } diff --git a/server/test/repositories/tag.repository.mock.ts b/server/test/repositories/tag.repository.mock.ts index acc2b59f6d..7f6f1b6c53 100644 --- a/server/test/repositories/tag.repository.mock.ts +++ b/server/test/repositories/tag.repository.mock.ts @@ -1,7 +1,8 @@ -import { ITagRepository } from 'src/interfaces/tag.interface'; +import { TagRepository } from 'src/repositories/tag.repository'; +import { RepositoryInterface } from 'src/types'; import { Mocked, vitest } from 'vitest'; -export const newTagRepositoryMock = (): Mocked => { +export const newTagRepositoryMock = (): Mocked> => { return { getAll: vitest.fn(), getByValue: vitest.fn(), diff --git a/server/test/repositories/user.repository.mock.ts b/server/test/repositories/user.repository.mock.ts index e6e8c38184..d7ebee09d8 100644 --- a/server/test/repositories/user.repository.mock.ts +++ b/server/test/repositories/user.repository.mock.ts @@ -1,7 +1,8 @@ -import { IUserRepository } from 'src/interfaces/user.interface'; +import { UserRepository } from 'src/repositories/user.repository'; +import { RepositoryInterface } from 'src/types'; import { Mocked, vitest } from 'vitest'; -export const newUserRepositoryMock = (): Mocked => { +export const newUserRepositoryMock = (): Mocked> => { return { get: vitest.fn(), getAdmin: vitest.fn(), diff --git a/server/test/utils.ts b/server/test/utils.ts index c4fee8fe93..9816266a21 100644 --- a/server/test/utils.ts +++ b/server/test/utils.ts @@ -2,16 +2,14 @@ import { ChildProcessWithoutNullStreams } from 'node:child_process'; import { Writable } from 'node:stream'; import { PNG } from 'pngjs'; import { ImmichWorker } from 'src/enum'; -import { IAlbumRepository } from 'src/interfaces/album.interface'; -import { IAssetRepository } from 'src/interfaces/asset.interface'; -import { ICryptoRepository } from 'src/interfaces/crypto.interface'; import { IEventRepository } from 'src/interfaces/event.interface'; import { IMachineLearningRepository } from 'src/interfaces/machine-learning.interface'; -import { IUserRepository } from 'src/interfaces/user.interface'; import { AccessRepository } from 'src/repositories/access.repository'; import { ActivityRepository } from 'src/repositories/activity.repository'; import { AlbumUserRepository } from 'src/repositories/album-user.repository'; +import { AlbumRepository } from 'src/repositories/album.repository'; import { ApiKeyRepository } from 'src/repositories/api-key.repository'; +import { AssetRepository } from 'src/repositories/asset.repository'; import { AuditRepository } from 'src/repositories/audit.repository'; import { ConfigRepository } from 'src/repositories/config.repository'; import { CronRepository } from 'src/repositories/cron.repository'; @@ -40,6 +38,7 @@ import { SystemMetadataRepository } from 'src/repositories/system-metadata.repos import { TagRepository } from 'src/repositories/tag.repository'; import { TelemetryRepository } from 'src/repositories/telemetry.repository'; import { TrashRepository } from 'src/repositories/trash.repository'; +import { UserRepository } from 'src/repositories/user.repository'; import { VersionHistoryRepository } from 'src/repositories/version-history.repository'; import { ViewRepository } from 'src/repositories/view-repository'; import { BaseService } from 'src/services/base.service'; @@ -100,14 +99,14 @@ type IAccessRepository = { [K in keyof AccessRepository]: RepositoryInterface>; - album: Mocked; + album: Mocked>; albumUser: Mocked>; apiKey: Mocked>; audit: Mocked>; - asset: Mocked; + asset: Mocked>; config: Mocked>; cron: Mocked>; - crypto: Mocked; + crypto: Mocked>; database: Mocked>; event: Mocked; job: Mocked>; @@ -134,7 +133,7 @@ export type ServiceMocks = { tag: Mocked>; telemetry: ITelemetryRepositoryMock; trash: Mocked>; - user: Mocked; + user: Mocked>; versionHistory: Mocked>; view: Mocked>; }; @@ -192,39 +191,39 @@ export const newTestService = ( accessMock as IAccessRepository as AccessRepository, activityMock as RepositoryInterface as ActivityRepository, auditMock as RepositoryInterface as AuditRepository, - albumMock, + albumMock as RepositoryInterface as AlbumRepository, albumUserMock as RepositoryInterface as AlbumUserRepository, - assetMock, + assetMock as RepositoryInterface as AssetRepository, configMock, cronMock as RepositoryInterface as CronRepository, cryptoMock as RepositoryInterface as CryptoRepository, - databaseMock, + databaseMock as RepositoryInterface as DatabaseRepository, eventMock, jobMock, apiKeyMock as RepositoryInterface as ApiKeyRepository, - libraryMock, + libraryMock as RepositoryInterface as LibraryRepository, machineLearningMock, mapMock as RepositoryInterface as MapRepository, mediaMock as RepositoryInterface as MediaRepository, memoryMock as RepositoryInterface as MemoryRepository, metadataMock as RepositoryInterface as MetadataRepository, - moveMock, + moveMock as RepositoryInterface as MoveRepository, notificationMock as RepositoryInterface as NotificationRepository, oauthMock as RepositoryInterface as OAuthRepository, - partnerMock, - personMock, + partnerMock as RepositoryInterface as PartnerRepository, + personMock as RepositoryInterface as PersonRepository, processMock as RepositoryInterface as ProcessRepository, - searchMock, + searchMock as RepositoryInterface as SearchRepository, serverInfoMock as RepositoryInterface as ServerInfoRepository, sessionMock as RepositoryInterface as SessionRepository, - sharedLinkMock, - stackMock, - storageMock, + sharedLinkMock as RepositoryInterface as SharedLinkRepository, + stackMock as RepositoryInterface as StackRepository, + storageMock as RepositoryInterface as StorageRepository, systemMock as RepositoryInterface as SystemMetadataRepository, - tagMock, + tagMock as RepositoryInterface as TagRepository, telemetryMock as unknown as TelemetryRepository, trashMock as RepositoryInterface as TrashRepository, - userMock, + userMock as RepositoryInterface as UserRepository, versionHistoryMock as RepositoryInterface as VersionHistoryRepository, viewMock as RepositoryInterface as ViewRepository, ); From 5f3a42a13219d95fad6327931b76171253b30066 Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Tue, 11 Feb 2025 15:12:31 -0500 Subject: [PATCH 124/395] refactor: repositories (#16038) --- server/src/app.module.ts | 14 +- server/src/decorators.ts | 2 +- server/src/enum.ts | 7 + server/src/interfaces/event.interface.ts | 114 ----------------- .../interfaces/machine-learning.interface.ts | 57 --------- server/src/repositories/event.repository.ts | 121 +++++++++++++++--- server/src/repositories/index.ts | 10 +- server/src/repositories/job.repository.ts | 6 +- .../machine-learning.repository.ts | 63 +++++++-- server/src/services/backup.service.ts | 2 +- server/src/services/base.service.ts | 8 +- server/src/services/database.service.ts | 3 +- server/src/services/job.service.ts | 2 +- server/src/services/library.service.ts | 2 +- server/src/services/metadata.service.ts | 2 +- server/src/services/notification.service.ts | 2 +- server/src/services/person.service.spec.ts | 2 +- server/src/services/person.service.ts | 2 +- server/src/services/smart-info.service.ts | 2 +- .../src/services/storage-template.service.ts | 2 +- server/src/services/system-config.service.ts | 3 +- server/src/services/version.service.ts | 2 +- server/src/utils/asset.util.ts | 4 +- .../repositories/event.repository.mock.ts | 9 +- .../machine-learning.repository.mock.ts | 5 +- server/test/utils.ts | 12 +- 26 files changed, 216 insertions(+), 242 deletions(-) delete mode 100644 server/src/interfaces/event.interface.ts delete mode 100644 server/src/interfaces/machine-learning.interface.ts diff --git a/server/src/app.module.ts b/server/src/app.module.ts index 0096cc6c26..2d6d7878dc 100644 --- a/server/src/app.module.ts +++ b/server/src/app.module.ts @@ -13,7 +13,6 @@ import { IWorker } from 'src/constants'; import { controllers } from 'src/controllers'; import { entities } from 'src/entities'; import { ImmichWorker } from 'src/enum'; -import { IEventRepository } from 'src/interfaces/event.interface'; import { IJobRepository } from 'src/interfaces/job.interface'; import { AuthGuard } from 'src/middleware/auth.guard'; import { ErrorInterceptor } from 'src/middleware/error.interceptor'; @@ -22,9 +21,11 @@ import { GlobalExceptionFilter } from 'src/middleware/global-exception.filter'; import { LoggingInterceptor } from 'src/middleware/logging.interceptor'; import { providers, repositories } from 'src/repositories'; import { ConfigRepository } from 'src/repositories/config.repository'; +import { EventRepository } from 'src/repositories/event.repository'; import { LoggingRepository } from 'src/repositories/logging.repository'; import { teardownTelemetry, TelemetryRepository } from 'src/repositories/telemetry.repository'; import { services } from 'src/services'; +import { AuthService } from 'src/services/auth.service'; import { CliService } from 'src/services/cli.service'; import { DatabaseService } from 'src/services/database.service'; @@ -78,9 +79,10 @@ class BaseModule implements OnModuleInit, OnModuleDestroy { constructor( @Inject(IWorker) private worker: ImmichWorker, logger: LoggingRepository, - @Inject(IEventRepository) private eventRepository: IEventRepository, + private eventRepository: EventRepository, @Inject(IJobRepository) private jobRepository: IJobRepository, private telemetryRepository: TelemetryRepository, + private authService: AuthService, ) { logger.setAppName(this.worker); } @@ -93,6 +95,14 @@ class BaseModule implements OnModuleInit, OnModuleDestroy { this.jobRepository.startWorkers(); } + this.eventRepository.setAuthFn(async (client) => + this.authService.authenticate({ + headers: client.request.headers, + queryParams: {}, + metadata: { adminRoute: false, sharedLinkRoute: false, uri: '/api/socket.io' }, + }), + ); + this.eventRepository.setup({ services }); await this.eventRepository.emit('app.bootstrap'); } diff --git a/server/src/decorators.ts b/server/src/decorators.ts index bb037ee097..7aab2e248a 100644 --- a/server/src/decorators.ts +++ b/server/src/decorators.ts @@ -3,8 +3,8 @@ import { ApiExtension, ApiOperation, ApiProperty, ApiTags } from '@nestjs/swagge import _ from 'lodash'; import { ADDED_IN_PREFIX, DEPRECATED_IN_PREFIX, LIFECYCLE_EXTENSION } from 'src/constants'; import { ImmichWorker, MetadataKey } from 'src/enum'; -import { EmitEvent } from 'src/interfaces/event.interface'; import { JobName, QueueName } from 'src/interfaces/job.interface'; +import { EmitEvent } from 'src/repositories/event.repository'; import { setUnion } from 'src/utils/set'; // PostgreSQL uses a 16-bit integer to indicate the number of bound parameters. This means that the diff --git a/server/src/enum.ts b/server/src/enum.ts index 332ae50ee8..b887fbace3 100644 --- a/server/src/enum.ts +++ b/server/src/enum.ts @@ -391,3 +391,10 @@ export enum DatabaseExtension { VECTOR = 'vector', VECTORS = 'vectors', } + +export enum BootstrapEventPriority { + // Database service should be initialized before anything else, most other services need database access + DatabaseService = -200, + // Initialise config after other bootstrap services, stop other services from using config on bootstrap + SystemConfig = 100, +} diff --git a/server/src/interfaces/event.interface.ts b/server/src/interfaces/event.interface.ts deleted file mode 100644 index 9a9e23cca0..0000000000 --- a/server/src/interfaces/event.interface.ts +++ /dev/null @@ -1,114 +0,0 @@ -import { ClassConstructor } from 'class-transformer'; -import { SystemConfig } from 'src/config'; -import { AssetResponseDto } from 'src/dtos/asset-response.dto'; -import { ReleaseNotification, ServerVersionResponseDto } from 'src/dtos/server.dto'; -import { JobItem, QueueName } from 'src/interfaces/job.interface'; - -export const IEventRepository = 'IEventRepository'; - -type EventMap = { - // app events - 'app.bootstrap': []; - 'app.shutdown': []; - - 'config.init': [{ newConfig: SystemConfig }]; - // config events - 'config.update': [ - { - newConfig: SystemConfig; - oldConfig: SystemConfig; - }, - ]; - 'config.validate': [{ newConfig: SystemConfig; oldConfig: SystemConfig }]; - - // album events - 'album.update': [{ id: string; recipientIds: string[] }]; - 'album.invite': [{ id: string; userId: string }]; - - // asset events - 'asset.tag': [{ assetId: string }]; - 'asset.untag': [{ assetId: string }]; - 'asset.hide': [{ assetId: string; userId: string }]; - 'asset.show': [{ assetId: string; userId: string }]; - 'asset.trash': [{ assetId: string; userId: string }]; - 'asset.delete': [{ assetId: string; userId: string }]; - - // asset bulk events - 'assets.trash': [{ assetIds: string[]; userId: string }]; - 'assets.delete': [{ assetIds: string[]; userId: string }]; - 'assets.restore': [{ assetIds: string[]; userId: string }]; - - 'job.start': [QueueName, JobItem]; - - // session events - 'session.delete': [{ sessionId: string }]; - - // stack events - 'stack.create': [{ stackId: string; userId: string }]; - 'stack.update': [{ stackId: string; userId: string }]; - 'stack.delete': [{ stackId: string; userId: string }]; - - // stack bulk events - 'stacks.delete': [{ stackIds: string[]; userId: string }]; - - // user events - 'user.signup': [{ notify: boolean; id: string; tempPassword?: string }]; - - // websocket events - 'websocket.connect': [{ userId: string }]; -}; - -export const serverEvents = ['config.update'] as const; -export type ServerEvents = (typeof serverEvents)[number]; - -export type EmitEvent = keyof EventMap; -export type EmitHandler = (...args: ArgsOf) => Promise | void; -export type ArgOf = EventMap[T][0]; -export type ArgsOf = EventMap[T]; - -export interface ClientEventMap { - on_upload_success: [AssetResponseDto]; - on_user_delete: [string]; - on_asset_delete: [string]; - on_asset_trash: [string[]]; - on_asset_update: [AssetResponseDto]; - on_asset_hidden: [string]; - on_asset_restore: [string[]]; - on_asset_stack_update: string[]; - on_person_thumbnail: [string]; - on_server_version: [ServerVersionResponseDto]; - on_config_update: []; - on_new_release: [ReleaseNotification]; - on_session_delete: [string]; -} - -export type EventItem = { - event: T; - handler: EmitHandler; - server: boolean; -}; - -export enum BootstrapEventPriority { - // Database service should be initialized before anything else, most other services need database access - DatabaseService = -200, - // Initialise config after other bootstrap services, stop other services from using config on bootstrap - SystemConfig = 100, -} - -export interface IEventRepository { - setup(options: { services: ClassConstructor[] }): void; - emit(event: T, ...args: ArgsOf): Promise; - - /** - * Send to connected clients for a specific user - */ - clientSend(event: E, room: string, ...data: ClientEventMap[E]): void; - /** - * Send to all connected clients - */ - clientBroadcast(event: E, ...data: ClientEventMap[E]): void; - /** - * Send to all connected servers - */ - serverSend(event: T, ...args: ArgsOf): void; -} diff --git a/server/src/interfaces/machine-learning.interface.ts b/server/src/interfaces/machine-learning.interface.ts deleted file mode 100644 index 934091ef8e..0000000000 --- a/server/src/interfaces/machine-learning.interface.ts +++ /dev/null @@ -1,57 +0,0 @@ -export const IMachineLearningRepository = 'IMachineLearningRepository'; - -export interface BoundingBox { - x1: number; - y1: number; - x2: number; - y2: number; -} - -export enum ModelTask { - FACIAL_RECOGNITION = 'facial-recognition', - SEARCH = 'clip', -} - -export enum ModelType { - DETECTION = 'detection', - PIPELINE = 'pipeline', - RECOGNITION = 'recognition', - TEXTUAL = 'textual', - VISUAL = 'visual', -} - -export type ModelPayload = { imagePath: string } | { text: string }; - -type ModelOptions = { modelName: string }; - -export type FaceDetectionOptions = ModelOptions & { minScore: number }; - -type VisualResponse = { imageHeight: number; imageWidth: number }; -export type ClipVisualRequest = { [ModelTask.SEARCH]: { [ModelType.VISUAL]: ModelOptions } }; -export type ClipVisualResponse = { [ModelTask.SEARCH]: string } & VisualResponse; - -export type ClipTextualRequest = { [ModelTask.SEARCH]: { [ModelType.TEXTUAL]: ModelOptions } }; -export type ClipTextualResponse = { [ModelTask.SEARCH]: string }; - -export type FacialRecognitionRequest = { - [ModelTask.FACIAL_RECOGNITION]: { - [ModelType.DETECTION]: ModelOptions & { options: { minScore: number } }; - [ModelType.RECOGNITION]: ModelOptions; - }; -}; - -export interface Face { - boundingBox: BoundingBox; - embedding: string; - score: number; -} - -export type FacialRecognitionResponse = { [ModelTask.FACIAL_RECOGNITION]: Face[] } & VisualResponse; -export type DetectedFaces = { faces: Face[] } & VisualResponse; -export type MachineLearningRequest = ClipVisualRequest | ClipTextualRequest | FacialRecognitionRequest; - -export interface IMachineLearningRepository { - encodeImage(urls: string[], imagePath: string, config: ModelOptions): Promise; - encodeText(urls: string[], text: string, config: ModelOptions): Promise; - detectFaces(urls: string[], imagePath: string, config: FaceDetectionOptions): Promise; -} diff --git a/server/src/repositories/event.repository.ts b/server/src/repositories/event.repository.ts index a443e0ed83..671b86f99c 100644 --- a/server/src/repositories/event.repository.ts +++ b/server/src/repositories/event.repository.ts @@ -10,21 +10,15 @@ import { import { ClassConstructor } from 'class-transformer'; import _ from 'lodash'; import { Server, Socket } from 'socket.io'; +import { SystemConfig } from 'src/config'; import { EventConfig } from 'src/decorators'; +import { AssetResponseDto } from 'src/dtos/asset-response.dto'; +import { AuthDto } from 'src/dtos/auth.dto'; +import { ReleaseNotification, ServerVersionResponseDto } from 'src/dtos/server.dto'; import { ImmichWorker, MetadataKey } from 'src/enum'; -import { - ArgsOf, - ClientEventMap, - EmitEvent, - EmitHandler, - EventItem, - IEventRepository, - serverEvents, - ServerEvents, -} from 'src/interfaces/event.interface'; +import { JobItem, QueueName } from 'src/interfaces/job.interface'; import { ConfigRepository } from 'src/repositories/config.repository'; import { LoggingRepository } from 'src/repositories/logging.repository'; -import { AuthService } from 'src/services/auth.service'; import { handlePromiseError } from 'src/utils/misc'; type EmitHandlers = Partial<{ [T in EmitEvent]: Array> }>; @@ -37,14 +31,99 @@ type Item = { label: string; }; +type EventMap = { + // app events + 'app.bootstrap': []; + 'app.shutdown': []; + + 'config.init': [{ newConfig: SystemConfig }]; + // config events + 'config.update': [ + { + newConfig: SystemConfig; + oldConfig: SystemConfig; + }, + ]; + 'config.validate': [{ newConfig: SystemConfig; oldConfig: SystemConfig }]; + + // album events + 'album.update': [{ id: string; recipientIds: string[] }]; + 'album.invite': [{ id: string; userId: string }]; + + // asset events + 'asset.tag': [{ assetId: string }]; + 'asset.untag': [{ assetId: string }]; + 'asset.hide': [{ assetId: string; userId: string }]; + 'asset.show': [{ assetId: string; userId: string }]; + 'asset.trash': [{ assetId: string; userId: string }]; + 'asset.delete': [{ assetId: string; userId: string }]; + + // asset bulk events + 'assets.trash': [{ assetIds: string[]; userId: string }]; + 'assets.delete': [{ assetIds: string[]; userId: string }]; + 'assets.restore': [{ assetIds: string[]; userId: string }]; + + 'job.start': [QueueName, JobItem]; + + // session events + 'session.delete': [{ sessionId: string }]; + + // stack events + 'stack.create': [{ stackId: string; userId: string }]; + 'stack.update': [{ stackId: string; userId: string }]; + 'stack.delete': [{ stackId: string; userId: string }]; + + // stack bulk events + 'stacks.delete': [{ stackIds: string[]; userId: string }]; + + // user events + 'user.signup': [{ notify: boolean; id: string; tempPassword?: string }]; + + // websocket events + 'websocket.connect': [{ userId: string }]; +}; + +export const serverEvents = ['config.update'] as const; +export type ServerEvents = (typeof serverEvents)[number]; + +export type EmitEvent = keyof EventMap; +export type EmitHandler = (...args: ArgsOf) => Promise | void; +export type ArgOf = EventMap[T][0]; +export type ArgsOf = EventMap[T]; + +export interface ClientEventMap { + on_upload_success: [AssetResponseDto]; + on_user_delete: [string]; + on_asset_delete: [string]; + on_asset_trash: [string[]]; + on_asset_update: [AssetResponseDto]; + on_asset_hidden: [string]; + on_asset_restore: [string[]]; + on_asset_stack_update: string[]; + on_person_thumbnail: [string]; + on_server_version: [ServerVersionResponseDto]; + on_config_update: []; + on_new_release: [ReleaseNotification]; + on_session_delete: [string]; +} + +export type EventItem = { + event: T; + handler: EmitHandler; + server: boolean; +}; + +export type AuthFn = (client: Socket) => Promise; + @WebSocketGateway({ cors: true, path: '/api/socket.io', transports: ['websocket'], }) @Injectable() -export class EventRepository implements OnGatewayConnection, OnGatewayDisconnect, OnGatewayInit, IEventRepository { +export class EventRepository implements OnGatewayConnection, OnGatewayDisconnect, OnGatewayInit { private emitHandlers: EmitHandlers = {}; + private authFn?: AuthFn; @WebSocketServer() private server?: Server; @@ -122,11 +201,7 @@ export class EventRepository implements OnGatewayConnection, OnGatewayDisconnect async handleConnection(client: Socket) { try { this.logger.log(`Websocket Connect: ${client.id}`); - const auth = await this.moduleRef.get(AuthService).authenticate({ - headers: client.request.headers, - queryParams: {}, - metadata: { adminRoute: false, sharedLinkRoute: false, uri: '/api/socket.io' }, - }); + const auth = await this.authenticate(client); await client.join(auth.user.id); if (auth.session) { await client.join(auth.session.id); @@ -182,4 +257,16 @@ export class EventRepository implements OnGatewayConnection, OnGatewayDisconnect this.logger.debug(`Server event: ${event} (send)`); this.server?.serverSideEmit(event, ...args); } + + setAuthFn(fn: (client: Socket) => Promise) { + this.authFn = fn; + } + + private async authenticate(client: Socket) { + if (!this.authFn) { + throw new Error('Auth function not set'); + } + + return this.authFn(client); + } } diff --git a/server/src/repositories/index.ts b/server/src/repositories/index.ts index d9ef84fc06..3c96f4c891 100644 --- a/server/src/repositories/index.ts +++ b/server/src/repositories/index.ts @@ -1,6 +1,4 @@ -import { IEventRepository } from 'src/interfaces/event.interface'; import { IJobRepository } from 'src/interfaces/job.interface'; -import { IMachineLearningRepository } from 'src/interfaces/machine-learning.interface'; import { AccessRepository } from 'src/repositories/access.repository'; import { ActivityRepository } from 'src/repositories/activity.repository'; import { AlbumUserRepository } from 'src/repositories/album-user.repository'; @@ -53,8 +51,10 @@ export const repositories = [ CronRepository, CryptoRepository, DatabaseRepository, + EventRepository, LibraryRepository, LoggingRepository, + MachineLearningRepository, MapRepository, MediaRepository, MemoryRepository, @@ -80,8 +80,4 @@ export const repositories = [ VersionHistoryRepository, ]; -export const providers = [ - { provide: IEventRepository, useClass: EventRepository }, - { provide: IJobRepository, useClass: JobRepository }, - { provide: IMachineLearningRepository, useClass: MachineLearningRepository }, -]; +export const providers = [{ provide: IJobRepository, useClass: JobRepository }]; diff --git a/server/src/repositories/job.repository.ts b/server/src/repositories/job.repository.ts index 9a5bf20df6..d6693f67f3 100644 --- a/server/src/repositories/job.repository.ts +++ b/server/src/repositories/job.repository.ts @@ -1,12 +1,11 @@ import { getQueueToken } from '@nestjs/bullmq'; -import { Inject, Injectable } from '@nestjs/common'; +import { Injectable } from '@nestjs/common'; import { ModuleRef, Reflector } from '@nestjs/core'; import { JobsOptions, Queue, Worker } from 'bullmq'; import { ClassConstructor } from 'class-transformer'; import { setTimeout } from 'node:timers/promises'; import { JobConfig } from 'src/decorators'; import { MetadataKey } from 'src/enum'; -import { IEventRepository } from 'src/interfaces/event.interface'; import { IEntityJob, IJobRepository, @@ -20,6 +19,7 @@ import { QueueStatus, } from 'src/interfaces/job.interface'; import { ConfigRepository } from 'src/repositories/config.repository'; +import { EventRepository } from 'src/repositories/event.repository'; import { LoggingRepository } from 'src/repositories/logging.repository'; import { getKeyByValue, getMethodNames, ImmichStartupError } from 'src/utils/misc'; @@ -38,7 +38,7 @@ export class JobRepository implements IJobRepository { constructor( private moduleRef: ModuleRef, private configRepository: ConfigRepository, - @Inject(IEventRepository) private eventRepository: IEventRepository, + private eventRepository: EventRepository, private logger: LoggingRepository, ) { this.logger.setContext(JobRepository.name); diff --git a/server/src/repositories/machine-learning.repository.ts b/server/src/repositories/machine-learning.repository.ts index 6266314bfd..8145bf3154 100644 --- a/server/src/repositories/machine-learning.repository.ts +++ b/server/src/repositories/machine-learning.repository.ts @@ -1,21 +1,60 @@ import { Injectable } from '@nestjs/common'; import { readFile } from 'node:fs/promises'; import { CLIPConfig } from 'src/dtos/model-config.dto'; -import { - ClipTextualResponse, - ClipVisualResponse, - FaceDetectionOptions, - FacialRecognitionResponse, - IMachineLearningRepository, - MachineLearningRequest, - ModelPayload, - ModelTask, - ModelType, -} from 'src/interfaces/machine-learning.interface'; import { LoggingRepository } from 'src/repositories/logging.repository'; +export interface BoundingBox { + x1: number; + y1: number; + x2: number; + y2: number; +} + +export enum ModelTask { + FACIAL_RECOGNITION = 'facial-recognition', + SEARCH = 'clip', +} + +export enum ModelType { + DETECTION = 'detection', + PIPELINE = 'pipeline', + RECOGNITION = 'recognition', + TEXTUAL = 'textual', + VISUAL = 'visual', +} + +export type ModelPayload = { imagePath: string } | { text: string }; + +type ModelOptions = { modelName: string }; + +export type FaceDetectionOptions = ModelOptions & { minScore: number }; + +type VisualResponse = { imageHeight: number; imageWidth: number }; +export type ClipVisualRequest = { [ModelTask.SEARCH]: { [ModelType.VISUAL]: ModelOptions } }; +export type ClipVisualResponse = { [ModelTask.SEARCH]: string } & VisualResponse; + +export type ClipTextualRequest = { [ModelTask.SEARCH]: { [ModelType.TEXTUAL]: ModelOptions } }; +export type ClipTextualResponse = { [ModelTask.SEARCH]: string }; + +export type FacialRecognitionRequest = { + [ModelTask.FACIAL_RECOGNITION]: { + [ModelType.DETECTION]: ModelOptions & { options: { minScore: number } }; + [ModelType.RECOGNITION]: ModelOptions; + }; +}; + +export interface Face { + boundingBox: BoundingBox; + embedding: string; + score: number; +} + +export type FacialRecognitionResponse = { [ModelTask.FACIAL_RECOGNITION]: Face[] } & VisualResponse; +export type DetectedFaces = { faces: Face[] } & VisualResponse; +export type MachineLearningRequest = ClipVisualRequest | ClipTextualRequest | FacialRecognitionRequest; + @Injectable() -export class MachineLearningRepository implements IMachineLearningRepository { +export class MachineLearningRepository { constructor(private logger: LoggingRepository) { this.logger.setContext(MachineLearningRepository.name); } diff --git a/server/src/services/backup.service.ts b/server/src/services/backup.service.ts index d0a8ce69b6..dee8113792 100644 --- a/server/src/services/backup.service.ts +++ b/server/src/services/backup.service.ts @@ -4,9 +4,9 @@ import semver from 'semver'; import { StorageCore } from 'src/cores/storage.core'; import { OnEvent, OnJob } from 'src/decorators'; import { ImmichWorker, StorageFolder } from 'src/enum'; -import { ArgOf } from 'src/interfaces/event.interface'; import { JobName, JobStatus, QueueName } from 'src/interfaces/job.interface'; import { DatabaseLock } from 'src/repositories/database.repository'; +import { ArgOf } from 'src/repositories/event.repository'; import { BaseService } from 'src/services/base.service'; import { handlePromiseError } from 'src/utils/misc'; diff --git a/server/src/services/base.service.ts b/server/src/services/base.service.ts index d0f00bd2ab..259248e49b 100644 --- a/server/src/services/base.service.ts +++ b/server/src/services/base.service.ts @@ -6,9 +6,7 @@ import { SALT_ROUNDS } from 'src/constants'; import { StorageCore } from 'src/cores/storage.core'; import { Users } from 'src/db'; import { UserEntity } from 'src/entities/user.entity'; -import { IEventRepository } from 'src/interfaces/event.interface'; import { IJobRepository } from 'src/interfaces/job.interface'; -import { IMachineLearningRepository } from 'src/interfaces/machine-learning.interface'; import { AccessRepository } from 'src/repositories/access.repository'; import { ActivityRepository } from 'src/repositories/activity.repository'; import { AlbumUserRepository } from 'src/repositories/album-user.repository'; @@ -20,8 +18,10 @@ import { ConfigRepository } from 'src/repositories/config.repository'; import { CronRepository } from 'src/repositories/cron.repository'; import { CryptoRepository } from 'src/repositories/crypto.repository'; import { DatabaseRepository } from 'src/repositories/database.repository'; +import { EventRepository } from 'src/repositories/event.repository'; import { LibraryRepository } from 'src/repositories/library.repository'; import { LoggingRepository } from 'src/repositories/logging.repository'; +import { MachineLearningRepository } from 'src/repositories/machine-learning.repository'; import { MapRepository } from 'src/repositories/map.repository'; import { MediaRepository } from 'src/repositories/media.repository'; import { MemoryRepository } from 'src/repositories/memory.repository'; @@ -63,11 +63,11 @@ export class BaseService { protected cronRepository: CronRepository, protected cryptoRepository: CryptoRepository, protected databaseRepository: DatabaseRepository, - @Inject(IEventRepository) protected eventRepository: IEventRepository, + protected eventRepository: EventRepository, @Inject(IJobRepository) protected jobRepository: IJobRepository, protected keyRepository: ApiKeyRepository, protected libraryRepository: LibraryRepository, - @Inject(IMachineLearningRepository) protected machineLearningRepository: IMachineLearningRepository, + protected machineLearningRepository: MachineLearningRepository, protected mapRepository: MapRepository, protected mediaRepository: MediaRepository, protected memoryRepository: MemoryRepository, diff --git a/server/src/services/database.service.ts b/server/src/services/database.service.ts index eabb0b0091..249b47c99c 100644 --- a/server/src/services/database.service.ts +++ b/server/src/services/database.service.ts @@ -2,8 +2,7 @@ import { Injectable } from '@nestjs/common'; import { Duration } from 'luxon'; import semver from 'semver'; import { OnEvent } from 'src/decorators'; -import { DatabaseExtension } from 'src/enum'; -import { BootstrapEventPriority } from 'src/interfaces/event.interface'; +import { BootstrapEventPriority, DatabaseExtension } from 'src/enum'; import { DatabaseLock, EXTENSION_NAMES, VectorExtension, VectorIndex } from 'src/repositories/database.repository'; import { BaseService } from 'src/services/base.service'; diff --git a/server/src/services/job.service.ts b/server/src/services/job.service.ts index c8ac8fc6bf..00d9a398fd 100644 --- a/server/src/services/job.service.ts +++ b/server/src/services/job.service.ts @@ -4,7 +4,6 @@ import { OnEvent } from 'src/decorators'; import { mapAsset } from 'src/dtos/asset-response.dto'; import { AllJobStatusResponseDto, JobCommandDto, JobCreateDto, JobStatusDto } from 'src/dtos/job.dto'; import { AssetType, ImmichWorker, ManualJobName } from 'src/enum'; -import { ArgOf, ArgsOf } from 'src/interfaces/event.interface'; import { ConcurrentQueueName, JobCommand, @@ -14,6 +13,7 @@ import { QueueCleanType, QueueName, } from 'src/interfaces/job.interface'; +import { ArgOf, ArgsOf } from 'src/repositories/event.repository'; import { BaseService } from 'src/services/base.service'; const asJobItem = (dto: JobCreateDto): JobItem => { diff --git a/server/src/services/library.service.ts b/server/src/services/library.service.ts index 5235b786e9..fb74827656 100644 --- a/server/src/services/library.service.ts +++ b/server/src/services/library.service.ts @@ -17,9 +17,9 @@ import { import { AssetEntity } from 'src/entities/asset.entity'; import { LibraryEntity } from 'src/entities/library.entity'; import { AssetType, ImmichWorker } from 'src/enum'; -import { ArgOf } from 'src/interfaces/event.interface'; import { JobName, JobOf, JOBS_LIBRARY_PAGINATION_SIZE, JobStatus, QueueName } from 'src/interfaces/job.interface'; import { DatabaseLock } from 'src/repositories/database.repository'; +import { ArgOf } from 'src/repositories/event.repository'; import { BaseService } from 'src/services/base.service'; import { mimeTypes } from 'src/utils/mime-types'; import { handlePromiseError } from 'src/utils/misc'; diff --git a/server/src/services/metadata.service.ts b/server/src/services/metadata.service.ts index 53110a20e0..33db5d3149 100644 --- a/server/src/services/metadata.service.ts +++ b/server/src/services/metadata.service.ts @@ -14,10 +14,10 @@ import { AssetFaceEntity } from 'src/entities/asset-face.entity'; import { AssetEntity } from 'src/entities/asset.entity'; import { PersonEntity } from 'src/entities/person.entity'; import { AssetType, ExifOrientation, ImmichWorker, SourceType } from 'src/enum'; -import { ArgOf } from 'src/interfaces/event.interface'; import { JobName, JobOf, JOBS_ASSET_PAGINATION_SIZE, JobStatus, QueueName } from 'src/interfaces/job.interface'; import { WithoutProperty } from 'src/repositories/asset.repository'; import { DatabaseLock } from 'src/repositories/database.repository'; +import { ArgOf } from 'src/repositories/event.repository'; import { ReverseGeocodeResult } from 'src/repositories/map.repository'; import { ImmichTags } from 'src/repositories/metadata.repository'; import { BaseService } from 'src/services/base.service'; diff --git a/server/src/services/notification.service.ts b/server/src/services/notification.service.ts index 85f72443d4..f9a2194088 100644 --- a/server/src/services/notification.service.ts +++ b/server/src/services/notification.service.ts @@ -2,7 +2,6 @@ import { BadRequestException, Injectable } from '@nestjs/common'; import { OnEvent, OnJob } from 'src/decorators'; import { SystemConfigSmtpDto } from 'src/dtos/system-config.dto'; import { AlbumEntity } from 'src/entities/album.entity'; -import { ArgOf } from 'src/interfaces/event.interface'; import { IEntityJob, INotifyAlbumUpdateJob, @@ -12,6 +11,7 @@ import { JobStatus, QueueName, } from 'src/interfaces/job.interface'; +import { ArgOf } from 'src/repositories/event.repository'; import { EmailImageAttachment, EmailTemplate } from 'src/repositories/notification.repository'; import { BaseService } from 'src/services/base.service'; import { getAssetFiles } from 'src/utils/asset.util'; diff --git a/server/src/services/person.service.spec.ts b/server/src/services/person.service.spec.ts index ec2417c793..8e1cff302f 100644 --- a/server/src/services/person.service.spec.ts +++ b/server/src/services/person.service.spec.ts @@ -4,8 +4,8 @@ import { mapFaces, mapPerson, PersonResponseDto } from 'src/dtos/person.dto'; import { AssetFaceEntity } from 'src/entities/asset-face.entity'; import { CacheControl, Colorspace, ImageFormat, SourceType, SystemMetadataKey } from 'src/enum'; import { JobName, JobStatus } from 'src/interfaces/job.interface'; -import { DetectedFaces } from 'src/interfaces/machine-learning.interface'; import { WithoutProperty } from 'src/repositories/asset.repository'; +import { DetectedFaces } from 'src/repositories/machine-learning.repository'; import { FaceSearchResult } from 'src/repositories/search.repository'; import { PersonService } from 'src/services/person.service'; import { ImmichFileResponse } from 'src/utils/file'; diff --git a/server/src/services/person.service.ts b/server/src/services/person.service.ts index 2ae703afb3..e9933b421c 100644 --- a/server/src/services/person.service.ts +++ b/server/src/services/person.service.ts @@ -40,8 +40,8 @@ import { JobStatus, QueueName, } from 'src/interfaces/job.interface'; -import { BoundingBox } from 'src/interfaces/machine-learning.interface'; import { WithoutProperty } from 'src/repositories/asset.repository'; +import { BoundingBox } from 'src/repositories/machine-learning.repository'; import { UpdateFacesData } from 'src/repositories/person.repository'; import { BaseService } from 'src/services/base.service'; import { CropOptions, ImageDimensions, InputDimensions } from 'src/types'; diff --git a/server/src/services/smart-info.service.ts b/server/src/services/smart-info.service.ts index ebf9e1d287..0a03c27a55 100644 --- a/server/src/services/smart-info.service.ts +++ b/server/src/services/smart-info.service.ts @@ -2,10 +2,10 @@ import { Injectable } from '@nestjs/common'; import { SystemConfig } from 'src/config'; import { OnEvent, OnJob } from 'src/decorators'; import { ImmichWorker } from 'src/enum'; -import { ArgOf } from 'src/interfaces/event.interface'; import { JOBS_ASSET_PAGINATION_SIZE, JobName, JobOf, JobStatus, QueueName } from 'src/interfaces/job.interface'; import { WithoutProperty } from 'src/repositories/asset.repository'; import { DatabaseLock } from 'src/repositories/database.repository'; +import { ArgOf } from 'src/repositories/event.repository'; import { BaseService } from 'src/services/base.service'; import { getAssetFiles } from 'src/utils/asset.util'; import { getCLIPModelInfo, isSmartSearchEnabled } from 'src/utils/misc'; diff --git a/server/src/services/storage-template.service.ts b/server/src/services/storage-template.service.ts index 6b0409de1d..6fd139c10d 100644 --- a/server/src/services/storage-template.service.ts +++ b/server/src/services/storage-template.service.ts @@ -8,9 +8,9 @@ import { OnEvent, OnJob } from 'src/decorators'; import { SystemConfigTemplateStorageOptionDto } from 'src/dtos/system-config.dto'; import { AssetEntity } from 'src/entities/asset.entity'; import { AssetPathType, AssetType, StorageFolder } from 'src/enum'; -import { ArgOf } from 'src/interfaces/event.interface'; import { JobName, JobOf, JOBS_ASSET_PAGINATION_SIZE, JobStatus, QueueName } from 'src/interfaces/job.interface'; import { DatabaseLock } from 'src/repositories/database.repository'; +import { ArgOf } from 'src/repositories/event.repository'; import { BaseService } from 'src/services/base.service'; import { getLivePhotoMotionFilename } from 'src/utils/file'; import { usePagination } from 'src/utils/pagination'; diff --git a/server/src/services/system-config.service.ts b/server/src/services/system-config.service.ts index b5ae42e098..cc32ef0c34 100644 --- a/server/src/services/system-config.service.ts +++ b/server/src/services/system-config.service.ts @@ -4,7 +4,8 @@ import _ from 'lodash'; import { defaults } from 'src/config'; import { OnEvent } from 'src/decorators'; import { SystemConfigDto, mapConfig } from 'src/dtos/system-config.dto'; -import { ArgOf, BootstrapEventPriority } from 'src/interfaces/event.interface'; +import { BootstrapEventPriority } from 'src/enum'; +import { ArgOf } from 'src/repositories/event.repository'; import { BaseService } from 'src/services/base.service'; import { clearConfigCache } from 'src/utils/config'; import { toPlainObject } from 'src/utils/object'; diff --git a/server/src/services/version.service.ts b/server/src/services/version.service.ts index ee36d30041..9679ac4b4b 100644 --- a/server/src/services/version.service.ts +++ b/server/src/services/version.service.ts @@ -6,9 +6,9 @@ import { OnEvent, OnJob } from 'src/decorators'; import { ReleaseNotification, ServerVersionResponseDto } from 'src/dtos/server.dto'; import { VersionCheckMetadata } from 'src/entities/system-metadata.entity'; import { ImmichEnvironment, SystemMetadataKey } from 'src/enum'; -import { ArgOf } from 'src/interfaces/event.interface'; import { JobName, JobStatus, QueueName } from 'src/interfaces/job.interface'; import { DatabaseLock } from 'src/repositories/database.repository'; +import { ArgOf } from 'src/repositories/event.repository'; import { BaseService } from 'src/services/base.service'; const asNotification = ({ checkedAt, releaseVersion }: VersionCheckMetadata): ReleaseNotification => { diff --git a/server/src/utils/asset.util.ts b/server/src/utils/asset.util.ts index 703fe2c3d7..5183bb2164 100644 --- a/server/src/utils/asset.util.ts +++ b/server/src/utils/asset.util.ts @@ -5,11 +5,11 @@ import { UploadFieldName } from 'src/dtos/asset-media.dto'; import { AuthDto } from 'src/dtos/auth.dto'; import { AssetFileEntity } from 'src/entities/asset-files.entity'; import { AssetFileType, AssetType, Permission } from 'src/enum'; -import { IEventRepository } from 'src/interfaces/event.interface'; import { AuthRequest } from 'src/middleware/auth.guard'; import { ImmichFile } from 'src/middleware/file-upload.interceptor'; import { AccessRepository } from 'src/repositories/access.repository'; import { AssetRepository } from 'src/repositories/asset.repository'; +import { EventRepository } from 'src/repositories/event.repository'; import { PartnerRepository } from 'src/repositories/partner.repository'; import { UploadFile } from 'src/services/asset-media.service'; import { checkAccess } from 'src/utils/access'; @@ -139,7 +139,7 @@ export const getMyPartnerIds = async ({ userId, repository, timelineEnabled }: P return [...partnerIds]; }; -export type AssetHookRepositories = { asset: AssetRepository; event: IEventRepository }; +export type AssetHookRepositories = { asset: AssetRepository; event: EventRepository }; export const onBeforeLink = async ( { asset: assetRepository, event: eventRepository }: AssetHookRepositories, diff --git a/server/test/repositories/event.repository.mock.ts b/server/test/repositories/event.repository.mock.ts index a425ddef3a..a253e93671 100644 --- a/server/test/repositories/event.repository.mock.ts +++ b/server/test/repositories/event.repository.mock.ts @@ -1,12 +1,17 @@ -import { IEventRepository } from 'src/interfaces/event.interface'; +import { EventRepository } from 'src/repositories/event.repository'; +import { RepositoryInterface } from 'src/types'; import { Mocked, vitest } from 'vitest'; -export const newEventRepositoryMock = (): Mocked => { +export const newEventRepositoryMock = (): Mocked> => { return { setup: vitest.fn(), emit: vitest.fn() as any, clientSend: vitest.fn() as any, clientBroadcast: vitest.fn() as any, serverSend: vitest.fn(), + afterInit: vitest.fn(), + handleConnection: vitest.fn(), + handleDisconnect: vitest.fn(), + setAuthFn: vitest.fn(), }; }; diff --git a/server/test/repositories/machine-learning.repository.mock.ts b/server/test/repositories/machine-learning.repository.mock.ts index 9dd1bdca29..229e8f92ec 100644 --- a/server/test/repositories/machine-learning.repository.mock.ts +++ b/server/test/repositories/machine-learning.repository.mock.ts @@ -1,7 +1,8 @@ -import { IMachineLearningRepository } from 'src/interfaces/machine-learning.interface'; +import { MachineLearningRepository } from 'src/repositories/machine-learning.repository'; +import { RepositoryInterface } from 'src/types'; import { Mocked, vitest } from 'vitest'; -export const newMachineLearningRepositoryMock = (): Mocked => { +export const newMachineLearningRepositoryMock = (): Mocked> => { return { encodeImage: vitest.fn(), encodeText: vitest.fn(), diff --git a/server/test/utils.ts b/server/test/utils.ts index 9816266a21..fbff7ba00d 100644 --- a/server/test/utils.ts +++ b/server/test/utils.ts @@ -2,8 +2,6 @@ import { ChildProcessWithoutNullStreams } from 'node:child_process'; import { Writable } from 'node:stream'; import { PNG } from 'pngjs'; import { ImmichWorker } from 'src/enum'; -import { IEventRepository } from 'src/interfaces/event.interface'; -import { IMachineLearningRepository } from 'src/interfaces/machine-learning.interface'; import { AccessRepository } from 'src/repositories/access.repository'; import { ActivityRepository } from 'src/repositories/activity.repository'; import { AlbumUserRepository } from 'src/repositories/album-user.repository'; @@ -15,9 +13,11 @@ import { ConfigRepository } from 'src/repositories/config.repository'; import { CronRepository } from 'src/repositories/cron.repository'; import { CryptoRepository } from 'src/repositories/crypto.repository'; import { DatabaseRepository } from 'src/repositories/database.repository'; +import { EventRepository } from 'src/repositories/event.repository'; import { JobRepository } from 'src/repositories/job.repository'; import { LibraryRepository } from 'src/repositories/library.repository'; import { LoggingRepository } from 'src/repositories/logging.repository'; +import { MachineLearningRepository } from 'src/repositories/machine-learning.repository'; import { MapRepository } from 'src/repositories/map.repository'; import { MediaRepository } from 'src/repositories/media.repository'; import { MemoryRepository } from 'src/repositories/memory.repository'; @@ -108,11 +108,11 @@ export type ServiceMocks = { cron: Mocked>; crypto: Mocked>; database: Mocked>; - event: Mocked; + event: Mocked>; job: Mocked>; library: Mocked>; logger: Mocked; - machineLearning: Mocked; + machineLearning: Mocked>; map: Mocked>; media: Mocked>; memory: Mocked>; @@ -198,11 +198,11 @@ export const newTestService = ( cronMock as RepositoryInterface as CronRepository, cryptoMock as RepositoryInterface as CryptoRepository, databaseMock as RepositoryInterface as DatabaseRepository, - eventMock, + eventMock as RepositoryInterface as EventRepository, jobMock, apiKeyMock as RepositoryInterface as ApiKeyRepository, libraryMock as RepositoryInterface as LibraryRepository, - machineLearningMock, + machineLearningMock as RepositoryInterface as MachineLearningRepository, mapMock as RepositoryInterface as MapRepository, mediaMock as RepositoryInterface as MediaRepository, memoryMock as RepositoryInterface as MemoryRepository, From fa5aeaf539d2e521be940fc4288142ed89d04e8b Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Tue, 11 Feb 2025 17:15:56 -0500 Subject: [PATCH 125/395] refactor: last repository (#16042) --- server/src/app.module.ts | 10 +- server/src/bin/sync-sql.ts | 22 +- server/src/config.ts | 4 +- server/src/constants.ts | 12 +- .../src/controllers/asset-media.controller.ts | 3 +- server/src/decorators.ts | 3 +- server/src/dtos/job.dto.ts | 3 +- server/src/dtos/system-config.dto.ts | 3 +- server/src/enum.ts | 139 ++++++++ server/src/interfaces/job.interface.ts | 329 ------------------ .../src/middleware/file-upload.interceptor.ts | 14 +- server/src/repositories/config.repository.ts | 13 +- .../src/repositories/database.repository.ts | 56 +-- server/src/repositories/event.repository.ts | 4 +- server/src/repositories/index.ts | 4 +- server/src/repositories/job.repository.ts | 17 +- server/src/repositories/memory.repository.ts | 2 +- .../repositories/notification.repository.ts | 7 +- .../src/services/asset-media.service.spec.ts | 3 +- server/src/services/asset-media.service.ts | 15 +- server/src/services/asset.service.spec.ts | 3 +- server/src/services/asset.service.ts | 16 +- server/src/services/audit.service.spec.ts | 11 +- server/src/services/audit.service.ts | 6 +- server/src/services/backup.service.spec.ts | 3 +- server/src/services/backup.service.ts | 4 +- server/src/services/base.service.ts | 7 +- server/src/services/database.service.spec.ts | 3 +- server/src/services/database.service.ts | 5 +- server/src/services/duplicate.service.spec.ts | 2 +- server/src/services/duplicate.service.ts | 4 +- server/src/services/job.service.spec.ts | 4 +- server/src/services/job.service.ts | 9 +- server/src/services/library.service.spec.ts | 11 +- server/src/services/library.service.ts | 6 +- server/src/services/map.service.ts | 2 + server/src/services/media.service.spec.ts | 5 +- server/src/services/media.service.ts | 14 +- server/src/services/metadata.service.spec.ts | 3 +- server/src/services/metadata.service.ts | 15 +- .../src/services/notification.service.spec.ts | 4 +- server/src/services/notification.service.ts | 13 +- server/src/services/person.service.spec.ts | 3 +- server/src/services/person.service.ts | 15 +- server/src/services/session.service.spec.ts | 2 +- server/src/services/session.service.ts | 3 +- .../src/services/smart-info.service.spec.ts | 3 +- server/src/services/smart-info.service.ts | 6 +- .../services/storage-template.service.spec.ts | 3 +- .../src/services/storage-template.service.ts | 6 +- server/src/services/storage.service.ts | 5 +- server/src/services/sync.service.ts | 2 + .../services/system-config.service.spec.ts | 2 +- server/src/services/system-config.service.ts | 2 +- server/src/services/tag.service.spec.ts | 2 +- server/src/services/tag.service.ts | 3 +- server/src/services/timeline.service.ts | 3 +- server/src/services/trash.service.spec.ts | 2 +- server/src/services/trash.service.ts | 6 +- .../src/services/user-admin.service.spec.ts | 3 +- server/src/services/user-admin.service.ts | 3 +- server/src/services/user.service.spec.ts | 3 +- server/src/services/user.service.ts | 4 +- server/src/services/version.service.spec.ts | 3 +- server/src/services/version.service.ts | 4 +- server/src/services/view.service.ts | 2 + server/src/types.ts | 253 +++++++++++++- server/src/utils/asset.util.ts | 9 +- server/src/utils/config.ts | 3 +- .../test/repositories/job.repository.mock.ts | 5 +- server/test/utils.ts | 4 +- 71 files changed, 574 insertions(+), 603 deletions(-) delete mode 100644 server/src/interfaces/job.interface.ts diff --git a/server/src/app.module.ts b/server/src/app.module.ts index 2d6d7878dc..a4518598a3 100644 --- a/server/src/app.module.ts +++ b/server/src/app.module.ts @@ -13,15 +13,15 @@ import { IWorker } from 'src/constants'; import { controllers } from 'src/controllers'; import { entities } from 'src/entities'; import { ImmichWorker } from 'src/enum'; -import { IJobRepository } from 'src/interfaces/job.interface'; import { AuthGuard } from 'src/middleware/auth.guard'; import { ErrorInterceptor } from 'src/middleware/error.interceptor'; import { FileUploadInterceptor } from 'src/middleware/file-upload.interceptor'; import { GlobalExceptionFilter } from 'src/middleware/global-exception.filter'; import { LoggingInterceptor } from 'src/middleware/logging.interceptor'; -import { providers, repositories } from 'src/repositories'; +import { repositories } from 'src/repositories'; import { ConfigRepository } from 'src/repositories/config.repository'; import { EventRepository } from 'src/repositories/event.repository'; +import { JobRepository } from 'src/repositories/job.repository'; import { LoggingRepository } from 'src/repositories/logging.repository'; import { teardownTelemetry, TelemetryRepository } from 'src/repositories/telemetry.repository'; import { services } from 'src/services'; @@ -29,7 +29,7 @@ import { AuthService } from 'src/services/auth.service'; import { CliService } from 'src/services/cli.service'; import { DatabaseService } from 'src/services/database.service'; -const common = [...services, ...providers, ...repositories]; +const common = [...repositories, ...services]; const middleware = [ FileUploadInterceptor, @@ -80,7 +80,7 @@ class BaseModule implements OnModuleInit, OnModuleDestroy { @Inject(IWorker) private worker: ImmichWorker, logger: LoggingRepository, private eventRepository: EventRepository, - @Inject(IJobRepository) private jobRepository: IJobRepository, + private jobRepository: JobRepository, private telemetryRepository: TelemetryRepository, private authService: AuthService, ) { @@ -88,7 +88,7 @@ class BaseModule implements OnModuleInit, OnModuleDestroy { } async onModuleInit() { - this.telemetryRepository.setup({ repositories: [...providers.map(({ useClass }) => useClass), ...repositories] }); + this.telemetryRepository.setup({ repositories }); this.jobRepository.setup({ services }); if (this.worker === ImmichWorker.MICROSERVICES) { diff --git a/server/src/bin/sync-sql.ts b/server/src/bin/sync-sql.ts index e0d578d58f..4765993643 100644 --- a/server/src/bin/sync-sql.ts +++ b/server/src/bin/sync-sql.ts @@ -4,6 +4,7 @@ import { Reflector } from '@nestjs/core'; import { SchedulerRegistry } from '@nestjs/schedule'; import { Test } from '@nestjs/testing'; import { TypeOrmModule } from '@nestjs/typeorm'; +import { ClassConstructor } from 'class-transformer'; import { PostgresJSDialect } from 'kysely-postgres-js'; import { KyselyModule } from 'nestjs-kysely'; import { OpenTelemetryModule } from 'nestjs-otel'; @@ -13,7 +14,7 @@ import postgres from 'postgres'; import { format } from 'sql-formatter'; import { GENERATE_SQL_KEY, GenerateSqlQueries } from 'src/decorators'; import { entities } from 'src/entities'; -import { providers, repositories } from 'src/repositories'; +import { repositories } from 'src/repositories'; import { AccessRepository } from 'src/repositories/access.repository'; import { ConfigRepository } from 'src/repositories/config.repository'; import { LoggingRepository } from 'src/repositories/logging.repository'; @@ -45,8 +46,7 @@ export class SqlLogger implements Logger { const reflector = new Reflector(); -type Repository = (typeof providers)[0]['useClass']; -type Provider = { provide: any; useClass: Repository }; +type Repository = ClassConstructor; type SqlGeneratorOptions = { targetDir: string }; class SqlGenerator { @@ -59,15 +59,11 @@ class SqlGenerator { async run() { try { await this.setup(); - const targets = [ - ...providers, - ...repositories.map((repository) => ({ provide: repository, useClass: repository as any })), - ]; - for (const repository of targets) { - if (repository.provide === LoggingRepository) { + for (const Repository of repositories) { + if (Repository === LoggingRepository) { continue; } - await this.process(repository); + await this.process(Repository); } await this.write(); this.stats(); @@ -105,19 +101,19 @@ class SqlGenerator { TypeOrmModule.forFeature(entities), OpenTelemetryModule.forRoot(otel), ], - providers: [...providers, ...repositories, AuthService, SchedulerRegistry], + providers: [...repositories, AuthService, SchedulerRegistry], }).compile(); this.app = await moduleFixture.createNestApplication().init(); } - async process({ provide: token, useClass: Repository }: Provider) { + async process(Repository: Repository) { if (!this.app) { throw new Error('Not initialized'); } const data: string[] = [`-- NOTE: This file is auto generated by ./sql-generator`]; - const instance = this.app.get(token); + const instance = this.app.get(Repository); // normal repositories data.push(...(await this.runTargets(instance, `${Repository.name}`))); diff --git a/server/src/config.ts b/server/src/config.ts index 7dd015c0fa..e7f3d4b8b6 100644 --- a/server/src/config.ts +++ b/server/src/config.ts @@ -5,14 +5,14 @@ import { CQMode, ImageFormat, LogLevel, + QueueName, ToneMapping, TranscodeHWAccel, TranscodePolicy, VideoCodec, VideoContainer, } from 'src/enum'; -import { ConcurrentQueueName, QueueName } from 'src/interfaces/job.interface'; -import { ImageOptions } from 'src/types'; +import { ConcurrentQueueName, ImageOptions } from 'src/types'; export interface SystemConfig { backup: { diff --git a/server/src/constants.ts b/server/src/constants.ts index 050a7d06fa..889ce81620 100644 --- a/server/src/constants.ts +++ b/server/src/constants.ts @@ -1,7 +1,7 @@ import { Duration } from 'luxon'; import { readFileSync } from 'node:fs'; import { SemVer } from 'semver'; -import { ExifOrientation } from 'src/enum'; +import { DatabaseExtension, ExifOrientation } from 'src/enum'; export const POSTGRES_VERSION_RANGE = '>=14.0.0'; export const VECTORS_VERSION_RANGE = '>=0.2 <0.4'; @@ -16,6 +16,16 @@ export const LIFECYCLE_EXTENSION = 'x-immich-lifecycle'; export const DEPRECATED_IN_PREFIX = 'This property was deprecated in '; export const ADDED_IN_PREFIX = 'This property was added in '; +export const JOBS_ASSET_PAGINATION_SIZE = 1000; +export const JOBS_LIBRARY_PAGINATION_SIZE = 10_000; + +export const EXTENSION_NAMES: Record = { + cube: 'cube', + earthdistance: 'earthdistance', + vector: 'pgvector', + vectors: 'pgvecto.rs', +} as const; + export const SALT_ROUNDS = 10; export const IWorker = 'IWorker'; diff --git a/server/src/controllers/asset-media.controller.ts b/server/src/controllers/asset-media.controller.ts index fd405b8928..3d2845690d 100644 --- a/server/src/controllers/asset-media.controller.ts +++ b/server/src/controllers/asset-media.controller.ts @@ -35,9 +35,10 @@ import { AuthDto } from 'src/dtos/auth.dto'; import { ImmichHeader, RouteKey } from 'src/enum'; import { AssetUploadInterceptor } from 'src/middleware/asset-upload.interceptor'; import { Auth, Authenticated, FileResponse } from 'src/middleware/auth.guard'; -import { FileUploadInterceptor, UploadFiles, getFiles } from 'src/middleware/file-upload.interceptor'; +import { FileUploadInterceptor, getFiles } from 'src/middleware/file-upload.interceptor'; import { LoggingRepository } from 'src/repositories/logging.repository'; import { AssetMediaService } from 'src/services/asset-media.service'; +import { UploadFiles } from 'src/types'; import { sendFile } from 'src/utils/file'; import { FileNotEmptyValidator, UUIDParamDto } from 'src/validation'; diff --git a/server/src/decorators.ts b/server/src/decorators.ts index 7aab2e248a..56efdd1c08 100644 --- a/server/src/decorators.ts +++ b/server/src/decorators.ts @@ -2,8 +2,7 @@ import { SetMetadata, applyDecorators } from '@nestjs/common'; import { ApiExtension, ApiOperation, ApiProperty, ApiTags } from '@nestjs/swagger'; import _ from 'lodash'; import { ADDED_IN_PREFIX, DEPRECATED_IN_PREFIX, LIFECYCLE_EXTENSION } from 'src/constants'; -import { ImmichWorker, MetadataKey } from 'src/enum'; -import { JobName, QueueName } from 'src/interfaces/job.interface'; +import { ImmichWorker, JobName, MetadataKey, QueueName } from 'src/enum'; import { EmitEvent } from 'src/repositories/event.repository'; import { setUnion } from 'src/utils/set'; diff --git a/server/src/dtos/job.dto.ts b/server/src/dtos/job.dto.ts index 31612bd8a4..ce6aad4c06 100644 --- a/server/src/dtos/job.dto.ts +++ b/server/src/dtos/job.dto.ts @@ -1,7 +1,6 @@ import { ApiProperty } from '@nestjs/swagger'; import { IsEnum, IsNotEmpty } from 'class-validator'; -import { ManualJobName } from 'src/enum'; -import { JobCommand, QueueName } from 'src/interfaces/job.interface'; +import { JobCommand, ManualJobName, QueueName } from 'src/enum'; import { ValidateBoolean } from 'src/validation'; export class JobIdParamDto { diff --git a/server/src/dtos/system-config.dto.ts b/server/src/dtos/system-config.dto.ts index 3509182545..6b51c015b7 100644 --- a/server/src/dtos/system-config.dto.ts +++ b/server/src/dtos/system-config.dto.ts @@ -25,13 +25,14 @@ import { Colorspace, ImageFormat, LogLevel, + QueueName, ToneMapping, TranscodeHWAccel, TranscodePolicy, VideoCodec, VideoContainer, } from 'src/enum'; -import { ConcurrentQueueName, QueueName } from 'src/interfaces/job.interface'; +import { ConcurrentQueueName } from 'src/types'; import { IsCronExpression, ValidateBoolean } from 'src/validation'; const isLibraryScanEnabled = (config: SystemConfigLibraryScanDto) => config.enabled; diff --git a/server/src/enum.ts b/server/src/enum.ts index b887fbace3..0c1fb01a12 100644 --- a/server/src/enum.ts +++ b/server/src/enum.ts @@ -398,3 +398,142 @@ export enum BootstrapEventPriority { // Initialise config after other bootstrap services, stop other services from using config on bootstrap SystemConfig = 100, } + +export enum QueueName { + THUMBNAIL_GENERATION = 'thumbnailGeneration', + METADATA_EXTRACTION = 'metadataExtraction', + VIDEO_CONVERSION = 'videoConversion', + FACE_DETECTION = 'faceDetection', + FACIAL_RECOGNITION = 'facialRecognition', + SMART_SEARCH = 'smartSearch', + DUPLICATE_DETECTION = 'duplicateDetection', + BACKGROUND_TASK = 'backgroundTask', + STORAGE_TEMPLATE_MIGRATION = 'storageTemplateMigration', + MIGRATION = 'migration', + SEARCH = 'search', + SIDECAR = 'sidecar', + LIBRARY = 'library', + NOTIFICATION = 'notifications', + BACKUP_DATABASE = 'backupDatabase', +} + +export enum JobName { + //backups + BACKUP_DATABASE = 'database-backup', + + // conversion + QUEUE_VIDEO_CONVERSION = 'queue-video-conversion', + VIDEO_CONVERSION = 'video-conversion', + + // thumbnails + QUEUE_GENERATE_THUMBNAILS = 'queue-generate-thumbnails', + GENERATE_THUMBNAILS = 'generate-thumbnails', + GENERATE_PERSON_THUMBNAIL = 'generate-person-thumbnail', + + // metadata + QUEUE_METADATA_EXTRACTION = 'queue-metadata-extraction', + METADATA_EXTRACTION = 'metadata-extraction', + LINK_LIVE_PHOTOS = 'link-live-photos', + + // user + USER_DELETION = 'user-deletion', + USER_DELETE_CHECK = 'user-delete-check', + USER_SYNC_USAGE = 'user-sync-usage', + + // asset + ASSET_DELETION = 'asset-deletion', + ASSET_DELETION_CHECK = 'asset-deletion-check', + + // storage template + STORAGE_TEMPLATE_MIGRATION = 'storage-template-migration', + STORAGE_TEMPLATE_MIGRATION_SINGLE = 'storage-template-migration-single', + + // tags + TAG_CLEANUP = 'tag-cleanup', + + // migration + QUEUE_MIGRATION = 'queue-migration', + MIGRATE_ASSET = 'migrate-asset', + MIGRATE_PERSON = 'migrate-person', + + // facial recognition + PERSON_CLEANUP = 'person-cleanup', + QUEUE_FACE_DETECTION = 'queue-face-detection', + FACE_DETECTION = 'face-detection', + QUEUE_FACIAL_RECOGNITION = 'queue-facial-recognition', + FACIAL_RECOGNITION = 'facial-recognition', + + // library management + LIBRARY_QUEUE_SYNC_FILES = 'library-queue-sync-files', + LIBRARY_QUEUE_SYNC_ASSETS = 'library-queue-sync-assets', + LIBRARY_SYNC_FILE = 'library-sync-file', + LIBRARY_SYNC_ASSET = 'library-sync-asset', + LIBRARY_DELETE = 'library-delete', + LIBRARY_QUEUE_SYNC_ALL = 'library-queue-sync-all', + LIBRARY_QUEUE_CLEANUP = 'library-queue-cleanup', + + // cleanup + DELETE_FILES = 'delete-files', + CLEAN_OLD_AUDIT_LOGS = 'clean-old-audit-logs', + CLEAN_OLD_SESSION_TOKENS = 'clean-old-session-tokens', + + // smart search + QUEUE_SMART_SEARCH = 'queue-smart-search', + SMART_SEARCH = 'smart-search', + + QUEUE_TRASH_EMPTY = 'queue-trash-empty', + + // duplicate detection + QUEUE_DUPLICATE_DETECTION = 'queue-duplicate-detection', + DUPLICATE_DETECTION = 'duplicate-detection', + + // XMP sidecars + QUEUE_SIDECAR = 'queue-sidecar', + SIDECAR_DISCOVERY = 'sidecar-discovery', + SIDECAR_SYNC = 'sidecar-sync', + SIDECAR_WRITE = 'sidecar-write', + + // Notification + NOTIFY_SIGNUP = 'notify-signup', + NOTIFY_ALBUM_INVITE = 'notify-album-invite', + NOTIFY_ALBUM_UPDATE = 'notify-album-update', + SEND_EMAIL = 'notification-send-email', + + // Version check + VERSION_CHECK = 'version-check', +} + +export enum JobCommand { + START = 'start', + PAUSE = 'pause', + RESUME = 'resume', + EMPTY = 'empty', + CLEAR_FAILED = 'clear-failed', +} + +export enum JobStatus { + SUCCESS = 'success', + FAILED = 'failed', + SKIPPED = 'skipped', +} + +export enum QueueCleanType { + FAILED = 'failed', +} + +export enum VectorIndex { + CLIP = 'clip_index', + FACE = 'face_index', +} + +export enum DatabaseLock { + GeodataImport = 100, + Migrations = 200, + SystemFileMounts = 300, + StorageTemplateMigration = 420, + VersionHistory = 500, + CLIPDimSize = 512, + Library = 1337, + GetSystemConfig = 69, + BackupDatabase = 42, +} diff --git a/server/src/interfaces/job.interface.ts b/server/src/interfaces/job.interface.ts deleted file mode 100644 index 1f2b92074a..0000000000 --- a/server/src/interfaces/job.interface.ts +++ /dev/null @@ -1,329 +0,0 @@ -import { ClassConstructor } from 'class-transformer'; -import { EmailImageAttachment } from 'src/repositories/notification.repository'; - -export enum QueueName { - THUMBNAIL_GENERATION = 'thumbnailGeneration', - METADATA_EXTRACTION = 'metadataExtraction', - VIDEO_CONVERSION = 'videoConversion', - FACE_DETECTION = 'faceDetection', - FACIAL_RECOGNITION = 'facialRecognition', - SMART_SEARCH = 'smartSearch', - DUPLICATE_DETECTION = 'duplicateDetection', - BACKGROUND_TASK = 'backgroundTask', - STORAGE_TEMPLATE_MIGRATION = 'storageTemplateMigration', - MIGRATION = 'migration', - SEARCH = 'search', - SIDECAR = 'sidecar', - LIBRARY = 'library', - NOTIFICATION = 'notifications', - BACKUP_DATABASE = 'backupDatabase', -} - -export type ConcurrentQueueName = Exclude< - QueueName, - | QueueName.STORAGE_TEMPLATE_MIGRATION - | QueueName.FACIAL_RECOGNITION - | QueueName.DUPLICATE_DETECTION - | QueueName.BACKUP_DATABASE ->; - -export enum JobCommand { - START = 'start', - PAUSE = 'pause', - RESUME = 'resume', - EMPTY = 'empty', - CLEAR_FAILED = 'clear-failed', -} - -export enum JobName { - //backups - BACKUP_DATABASE = 'database-backup', - - // conversion - QUEUE_VIDEO_CONVERSION = 'queue-video-conversion', - VIDEO_CONVERSION = 'video-conversion', - - // thumbnails - QUEUE_GENERATE_THUMBNAILS = 'queue-generate-thumbnails', - GENERATE_THUMBNAILS = 'generate-thumbnails', - GENERATE_PERSON_THUMBNAIL = 'generate-person-thumbnail', - - // metadata - QUEUE_METADATA_EXTRACTION = 'queue-metadata-extraction', - METADATA_EXTRACTION = 'metadata-extraction', - LINK_LIVE_PHOTOS = 'link-live-photos', - - // user - USER_DELETION = 'user-deletion', - USER_DELETE_CHECK = 'user-delete-check', - USER_SYNC_USAGE = 'user-sync-usage', - - // asset - ASSET_DELETION = 'asset-deletion', - ASSET_DELETION_CHECK = 'asset-deletion-check', - - // storage template - STORAGE_TEMPLATE_MIGRATION = 'storage-template-migration', - STORAGE_TEMPLATE_MIGRATION_SINGLE = 'storage-template-migration-single', - - // tags - TAG_CLEANUP = 'tag-cleanup', - - // migration - QUEUE_MIGRATION = 'queue-migration', - MIGRATE_ASSET = 'migrate-asset', - MIGRATE_PERSON = 'migrate-person', - - // facial recognition - PERSON_CLEANUP = 'person-cleanup', - QUEUE_FACE_DETECTION = 'queue-face-detection', - FACE_DETECTION = 'face-detection', - QUEUE_FACIAL_RECOGNITION = 'queue-facial-recognition', - FACIAL_RECOGNITION = 'facial-recognition', - - // library management - LIBRARY_QUEUE_SYNC_FILES = 'library-queue-sync-files', - LIBRARY_QUEUE_SYNC_ASSETS = 'library-queue-sync-assets', - LIBRARY_SYNC_FILE = 'library-sync-file', - LIBRARY_SYNC_ASSET = 'library-sync-asset', - LIBRARY_DELETE = 'library-delete', - LIBRARY_QUEUE_SYNC_ALL = 'library-queue-sync-all', - LIBRARY_QUEUE_CLEANUP = 'library-queue-cleanup', - - // cleanup - DELETE_FILES = 'delete-files', - CLEAN_OLD_AUDIT_LOGS = 'clean-old-audit-logs', - CLEAN_OLD_SESSION_TOKENS = 'clean-old-session-tokens', - - // smart search - QUEUE_SMART_SEARCH = 'queue-smart-search', - SMART_SEARCH = 'smart-search', - - QUEUE_TRASH_EMPTY = 'queue-trash-empty', - - // duplicate detection - QUEUE_DUPLICATE_DETECTION = 'queue-duplicate-detection', - DUPLICATE_DETECTION = 'duplicate-detection', - - // XMP sidecars - QUEUE_SIDECAR = 'queue-sidecar', - SIDECAR_DISCOVERY = 'sidecar-discovery', - SIDECAR_SYNC = 'sidecar-sync', - SIDECAR_WRITE = 'sidecar-write', - - // Notification - NOTIFY_SIGNUP = 'notify-signup', - NOTIFY_ALBUM_INVITE = 'notify-album-invite', - NOTIFY_ALBUM_UPDATE = 'notify-album-update', - SEND_EMAIL = 'notification-send-email', - - // Version check - VERSION_CHECK = 'version-check', -} - -export const JOBS_ASSET_PAGINATION_SIZE = 1000; -export const JOBS_LIBRARY_PAGINATION_SIZE = 10_000; - -export interface IBaseJob { - force?: boolean; -} - -export interface IDelayedJob extends IBaseJob { - /** The minimum time to wait to execute this job, in milliseconds. */ - delay?: number; -} - -export interface IEntityJob extends IBaseJob { - id: string; - source?: 'upload' | 'sidecar-write' | 'copy'; - notify?: boolean; -} - -export interface IAssetDeleteJob extends IEntityJob { - deleteOnDisk: boolean; -} - -export interface ILibraryFileJob extends IEntityJob { - ownerId: string; - assetPath: string; -} - -export interface ILibraryAssetJob extends IEntityJob { - importPaths: string[]; - exclusionPatterns: string[]; -} - -export interface IBulkEntityJob extends IBaseJob { - ids: string[]; -} - -export interface IDeleteFilesJob extends IBaseJob { - files: Array; -} - -export interface ISidecarWriteJob extends IEntityJob { - description?: string; - dateTimeOriginal?: string; - latitude?: number; - longitude?: number; - rating?: number; - tags?: true; -} - -export interface IDeferrableJob extends IEntityJob { - deferred?: boolean; -} - -export interface INightlyJob extends IBaseJob { - nightly?: boolean; -} - -export interface IEmailJob { - to: string; - subject: string; - html: string; - text: string; - imageAttachments?: EmailImageAttachment[]; -} - -export interface INotifySignupJob extends IEntityJob { - tempPassword?: string; -} - -export interface INotifyAlbumInviteJob extends IEntityJob { - recipientId: string; -} - -export interface INotifyAlbumUpdateJob extends IEntityJob, IDelayedJob { - recipientIds: string[]; -} - -export interface JobCounts { - active: number; - completed: number; - failed: number; - delayed: number; - waiting: number; - paused: number; -} - -export interface QueueStatus { - isActive: boolean; - isPaused: boolean; -} - -export enum QueueCleanType { - FAILED = 'failed', -} - -export type JobItem = - // Backups - | { name: JobName.BACKUP_DATABASE; data?: IBaseJob } - - // Transcoding - | { name: JobName.QUEUE_VIDEO_CONVERSION; data: IBaseJob } - | { name: JobName.VIDEO_CONVERSION; data: IEntityJob } - - // Thumbnails - | { name: JobName.QUEUE_GENERATE_THUMBNAILS; data: IBaseJob } - | { name: JobName.GENERATE_THUMBNAILS; data: IEntityJob } - - // User - | { name: JobName.USER_DELETE_CHECK; data?: IBaseJob } - | { name: JobName.USER_DELETION; data: IEntityJob } - | { name: JobName.USER_SYNC_USAGE; data?: IBaseJob } - - // Storage Template - | { name: JobName.STORAGE_TEMPLATE_MIGRATION; data?: IBaseJob } - | { name: JobName.STORAGE_TEMPLATE_MIGRATION_SINGLE; data: IEntityJob } - - // Migration - | { name: JobName.QUEUE_MIGRATION; data?: IBaseJob } - | { name: JobName.MIGRATE_ASSET; data: IEntityJob } - | { name: JobName.MIGRATE_PERSON; data: IEntityJob } - - // Metadata Extraction - | { name: JobName.QUEUE_METADATA_EXTRACTION; data: IBaseJob } - | { name: JobName.METADATA_EXTRACTION; data: IEntityJob } - | { name: JobName.LINK_LIVE_PHOTOS; data: IEntityJob } - // Sidecar Scanning - | { name: JobName.QUEUE_SIDECAR; data: IBaseJob } - | { name: JobName.SIDECAR_DISCOVERY; data: IEntityJob } - | { name: JobName.SIDECAR_SYNC; data: IEntityJob } - | { name: JobName.SIDECAR_WRITE; data: ISidecarWriteJob } - - // Facial Recognition - | { name: JobName.QUEUE_FACE_DETECTION; data: IBaseJob } - | { name: JobName.FACE_DETECTION; data: IEntityJob } - | { name: JobName.QUEUE_FACIAL_RECOGNITION; data: INightlyJob } - | { name: JobName.FACIAL_RECOGNITION; data: IDeferrableJob } - | { name: JobName.GENERATE_PERSON_THUMBNAIL; data: IEntityJob } - - // Smart Search - | { name: JobName.QUEUE_SMART_SEARCH; data: IBaseJob } - | { name: JobName.SMART_SEARCH; data: IEntityJob } - | { name: JobName.QUEUE_TRASH_EMPTY; data?: IBaseJob } - - // Duplicate Detection - | { name: JobName.QUEUE_DUPLICATE_DETECTION; data: IBaseJob } - | { name: JobName.DUPLICATE_DETECTION; data: IEntityJob } - - // Filesystem - | { name: JobName.DELETE_FILES; data: IDeleteFilesJob } - - // Cleanup - | { name: JobName.CLEAN_OLD_AUDIT_LOGS; data?: IBaseJob } - | { name: JobName.CLEAN_OLD_SESSION_TOKENS; data?: IBaseJob } - - // Tags - | { name: JobName.TAG_CLEANUP; data?: IBaseJob } - - // Asset Deletion - | { name: JobName.PERSON_CLEANUP; data?: IBaseJob } - | { name: JobName.ASSET_DELETION; data: IAssetDeleteJob } - | { name: JobName.ASSET_DELETION_CHECK; data?: IBaseJob } - - // Library Management - | { name: JobName.LIBRARY_SYNC_FILE; data: ILibraryFileJob } - | { name: JobName.LIBRARY_QUEUE_SYNC_FILES; data: IEntityJob } - | { name: JobName.LIBRARY_QUEUE_SYNC_ASSETS; data: IEntityJob } - | { name: JobName.LIBRARY_SYNC_ASSET; data: ILibraryAssetJob } - | { name: JobName.LIBRARY_DELETE; data: IEntityJob } - | { name: JobName.LIBRARY_QUEUE_SYNC_ALL; data?: IBaseJob } - | { name: JobName.LIBRARY_QUEUE_CLEANUP; data: IBaseJob } - - // Notification - | { name: JobName.SEND_EMAIL; data: IEmailJob } - | { name: JobName.NOTIFY_ALBUM_INVITE; data: INotifyAlbumInviteJob } - | { name: JobName.NOTIFY_ALBUM_UPDATE; data: INotifyAlbumUpdateJob } - | { name: JobName.NOTIFY_SIGNUP; data: INotifySignupJob } - - // Version check - | { name: JobName.VERSION_CHECK; data: IBaseJob }; - -export enum JobStatus { - SUCCESS = 'success', - FAILED = 'failed', - SKIPPED = 'skipped', -} -export type Jobs = { [K in JobItem['name']]: (JobItem & { name: K })['data'] }; -export type JobOf = Jobs[T]; - -export const IJobRepository = 'IJobRepository'; - -export interface IJobRepository { - setup(options: { services: ClassConstructor[] }): void; - startWorkers(): void; - run(job: JobItem): Promise; - setConcurrency(queueName: QueueName, concurrency: number): void; - queue(item: JobItem): Promise; - queueAll(items: JobItem[]): Promise; - pause(name: QueueName): Promise; - resume(name: QueueName): Promise; - empty(name: QueueName): Promise; - clear(name: QueueName, type: QueueCleanType): Promise; - getQueueStatus(name: QueueName): Promise; - getJobCounts(name: QueueName): Promise; - waitForQueueCompletion(...queues: QueueName[]): Promise; - removeJob(jobId: string, name: JobName): Promise; -} diff --git a/server/src/middleware/file-upload.interceptor.ts b/server/src/middleware/file-upload.interceptor.ts index 743764ff75..6f6d9aaf43 100644 --- a/server/src/middleware/file-upload.interceptor.ts +++ b/server/src/middleware/file-upload.interceptor.ts @@ -10,14 +10,10 @@ import { UploadFieldName } from 'src/dtos/asset-media.dto'; import { RouteKey } from 'src/enum'; import { AuthRequest } from 'src/middleware/auth.guard'; import { LoggingRepository } from 'src/repositories/logging.repository'; -import { AssetMediaService, UploadFile } from 'src/services/asset-media.service'; +import { AssetMediaService } from 'src/services/asset-media.service'; +import { ImmichFile, UploadFile, UploadFiles } from 'src/types'; import { asRequest, mapToUploadFile } from 'src/utils/asset.util'; -export interface UploadFiles { - assetData: ImmichFile[]; - sidecarData: ImmichFile[]; -} - export function getFile(files: UploadFiles, property: 'assetData' | 'sidecarData') { const file = files[property]?.[0]; return file ? mapToUploadFile(file) : file; @@ -30,12 +26,6 @@ export function getFiles(files: UploadFiles) { }; } -export interface ImmichFile extends Express.Multer.File { - /** sha1 hash of file */ - uuid: string; - checksum: Buffer; -} - type DiskStorageCallback = (error: Error | null, result: string) => void; type ImmichMulterFile = Express.Multer.File & { uuid: string }; diff --git a/server/src/repositories/config.repository.ts b/server/src/repositories/config.repository.ts index 27c10bcdcc..2d5f2bc2e2 100644 --- a/server/src/repositories/config.repository.ts +++ b/server/src/repositories/config.repository.ts @@ -13,9 +13,16 @@ import { Notice } from 'postgres'; import { citiesFile, excludePaths, IWorker } from 'src/constants'; import { Telemetry } from 'src/decorators'; import { EnvDto } from 'src/dtos/env.dto'; -import { DatabaseExtension, ImmichEnvironment, ImmichHeader, ImmichTelemetry, ImmichWorker, LogLevel } from 'src/enum'; -import { QueueName } from 'src/interfaces/job.interface'; -import { DatabaseConnectionParams, VectorExtension } from 'src/repositories/database.repository'; +import { + DatabaseExtension, + ImmichEnvironment, + ImmichHeader, + ImmichTelemetry, + ImmichWorker, + LogLevel, + QueueName, +} from 'src/enum'; +import { DatabaseConnectionParams, VectorExtension } from 'src/types'; import { setDifference } from 'src/utils/set'; import { PostgresConnectionOptions } from 'typeorm/driver/postgres/PostgresConnectionOptions.js'; diff --git a/server/src/repositories/database.repository.ts b/server/src/repositories/database.repository.ts index 6edcedd13c..ef97147c61 100644 --- a/server/src/repositories/database.repository.ts +++ b/server/src/repositories/database.repository.ts @@ -4,66 +4,16 @@ import AsyncLock from 'async-lock'; import { Kysely, sql } from 'kysely'; import { InjectKysely } from 'nestjs-kysely'; import semver from 'semver'; -import { POSTGRES_VERSION_RANGE, VECTOR_VERSION_RANGE, VECTORS_VERSION_RANGE } from 'src/constants'; +import { EXTENSION_NAMES, POSTGRES_VERSION_RANGE, VECTOR_VERSION_RANGE, VECTORS_VERSION_RANGE } from 'src/constants'; import { DB } from 'src/db'; -import { DatabaseExtension } from 'src/enum'; +import { DatabaseExtension, DatabaseLock, VectorIndex } from 'src/enum'; import { ConfigRepository } from 'src/repositories/config.repository'; import { LoggingRepository } from 'src/repositories/logging.repository'; +import { ExtensionVersion, VectorExtension, VectorUpdateResult } from 'src/types'; import { UPSERT_COLUMNS } from 'src/utils/database'; import { isValidInteger } from 'src/validation'; import { DataSource, EntityManager, EntityMetadata, QueryRunner } from 'typeorm'; -export type VectorExtension = DatabaseExtension.VECTOR | DatabaseExtension.VECTORS; - -export type DatabaseConnectionURL = { - connectionType: 'url'; - url: string; -}; - -export type DatabaseConnectionParts = { - connectionType: 'parts'; - host: string; - port: number; - username: string; - password: string; - database: string; -}; - -export type DatabaseConnectionParams = DatabaseConnectionURL | DatabaseConnectionParts; - -export enum VectorIndex { - CLIP = 'clip_index', - FACE = 'face_index', -} - -export enum DatabaseLock { - GeodataImport = 100, - Migrations = 200, - SystemFileMounts = 300, - StorageTemplateMigration = 420, - VersionHistory = 500, - CLIPDimSize = 512, - Library = 1337, - GetSystemConfig = 69, - BackupDatabase = 42, -} - -export const EXTENSION_NAMES: Record = { - cube: 'cube', - earthdistance: 'earthdistance', - vector: 'pgvector', - vectors: 'pgvecto.rs', -} as const; - -export interface ExtensionVersion { - availableVersion: string | null; - installedVersion: string | null; -} - -export interface VectorUpdateResult { - restartRequired: boolean; -} - @Injectable() export class DatabaseRepository { private vectorExtension: VectorExtension; diff --git a/server/src/repositories/event.repository.ts b/server/src/repositories/event.repository.ts index 671b86f99c..3156804d09 100644 --- a/server/src/repositories/event.repository.ts +++ b/server/src/repositories/event.repository.ts @@ -15,10 +15,10 @@ import { EventConfig } from 'src/decorators'; import { AssetResponseDto } from 'src/dtos/asset-response.dto'; import { AuthDto } from 'src/dtos/auth.dto'; import { ReleaseNotification, ServerVersionResponseDto } from 'src/dtos/server.dto'; -import { ImmichWorker, MetadataKey } from 'src/enum'; -import { JobItem, QueueName } from 'src/interfaces/job.interface'; +import { ImmichWorker, MetadataKey, QueueName } from 'src/enum'; import { ConfigRepository } from 'src/repositories/config.repository'; import { LoggingRepository } from 'src/repositories/logging.repository'; +import { JobItem } from 'src/types'; import { handlePromiseError } from 'src/utils/misc'; type EmitHandlers = Partial<{ [T in EmitEvent]: Array> }>; diff --git a/server/src/repositories/index.ts b/server/src/repositories/index.ts index 3c96f4c891..d3a8aeeb69 100644 --- a/server/src/repositories/index.ts +++ b/server/src/repositories/index.ts @@ -1,4 +1,3 @@ -import { IJobRepository } from 'src/interfaces/job.interface'; import { AccessRepository } from 'src/repositories/access.repository'; import { ActivityRepository } from 'src/repositories/activity.repository'; import { AlbumUserRepository } from 'src/repositories/album-user.repository'; @@ -52,6 +51,7 @@ export const repositories = [ CryptoRepository, DatabaseRepository, EventRepository, + JobRepository, LibraryRepository, LoggingRepository, MachineLearningRepository, @@ -79,5 +79,3 @@ export const repositories = [ ViewRepository, VersionHistoryRepository, ]; - -export const providers = [{ provide: IJobRepository, useClass: JobRepository }]; diff --git a/server/src/repositories/job.repository.ts b/server/src/repositories/job.repository.ts index d6693f67f3..fd9f4c5363 100644 --- a/server/src/repositories/job.repository.ts +++ b/server/src/repositories/job.repository.ts @@ -5,22 +5,11 @@ import { JobsOptions, Queue, Worker } from 'bullmq'; import { ClassConstructor } from 'class-transformer'; import { setTimeout } from 'node:timers/promises'; import { JobConfig } from 'src/decorators'; -import { MetadataKey } from 'src/enum'; -import { - IEntityJob, - IJobRepository, - JobCounts, - JobItem, - JobName, - JobOf, - JobStatus, - QueueCleanType, - QueueName, - QueueStatus, -} from 'src/interfaces/job.interface'; +import { JobName, JobStatus, MetadataKey, QueueCleanType, 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 { IEntityJob, JobCounts, JobItem, JobOf, QueueStatus } from 'src/types'; import { getKeyByValue, getMethodNames, ImmichStartupError } from 'src/utils/misc'; type JobMapItem = { @@ -31,7 +20,7 @@ type JobMapItem = { }; @Injectable() -export class JobRepository implements IJobRepository { +export class JobRepository { private workers: Partial> = {}; private handlers: Partial> = {}; diff --git a/server/src/repositories/memory.repository.ts b/server/src/repositories/memory.repository.ts index 042738fe4c..7af363012d 100644 --- a/server/src/repositories/memory.repository.ts +++ b/server/src/repositories/memory.repository.ts @@ -4,7 +4,7 @@ import { jsonArrayFrom } from 'kysely/helpers/postgres'; import { InjectKysely } from 'nestjs-kysely'; import { DB, Memories } from 'src/db'; import { Chunked, ChunkedSet, DummyValue, GenerateSql } from 'src/decorators'; -import { IBulkAsset } from 'src/utils/asset.util'; +import { IBulkAsset } from 'src/types'; @Injectable() export class MemoryRepository implements IBulkAsset { diff --git a/server/src/repositories/notification.repository.ts b/server/src/repositories/notification.repository.ts index fdb74cfdb2..91f03b928b 100644 --- a/server/src/repositories/notification.repository.ts +++ b/server/src/repositories/notification.repository.ts @@ -7,12 +7,7 @@ import { AlbumUpdateEmail } from 'src/emails/album-update.email'; import { TestEmail } from 'src/emails/test.email'; import { WelcomeEmail } from 'src/emails/welcome.email'; import { LoggingRepository } from 'src/repositories/logging.repository'; - -export type EmailImageAttachment = { - filename: string; - path: string; - cid: string; -}; +import { EmailImageAttachment } from 'src/types'; export type SendEmailOptions = { from: string; diff --git a/server/src/services/asset-media.service.spec.ts b/server/src/services/asset-media.service.spec.ts index e52f086df0..97736b905c 100644 --- a/server/src/services/asset-media.service.spec.ts +++ b/server/src/services/asset-media.service.spec.ts @@ -9,8 +9,7 @@ import { AssetMediaStatus, AssetRejectReason, AssetUploadAction } from 'src/dtos import { AssetMediaCreateDto, AssetMediaReplaceDto, AssetMediaSize, UploadFieldName } from 'src/dtos/asset-media.dto'; import { AssetFileEntity } from 'src/entities/asset-files.entity'; import { ASSET_CHECKSUM_CONSTRAINT, AssetEntity } from 'src/entities/asset.entity'; -import { AssetFileType, AssetStatus, AssetType, CacheControl } from 'src/enum'; -import { JobName } from 'src/interfaces/job.interface'; +import { AssetFileType, AssetStatus, AssetType, CacheControl, JobName } from 'src/enum'; import { AuthRequest } from 'src/middleware/auth.guard'; import { AssetMediaService } from 'src/services/asset-media.service'; import { ImmichFileResponse } from 'src/utils/file'; diff --git a/server/src/services/asset-media.service.ts b/server/src/services/asset-media.service.ts index fab836db94..09ebd9db71 100644 --- a/server/src/services/asset-media.service.ts +++ b/server/src/services/asset-media.service.ts @@ -21,29 +21,22 @@ import { } from 'src/dtos/asset-media.dto'; import { AuthDto } from 'src/dtos/auth.dto'; import { ASSET_CHECKSUM_CONSTRAINT, AssetEntity } from 'src/entities/asset.entity'; -import { AssetStatus, AssetType, CacheControl, Permission, StorageFolder } from 'src/enum'; -import { JobName } from 'src/interfaces/job.interface'; +import { AssetStatus, AssetType, CacheControl, JobName, Permission, StorageFolder } from 'src/enum'; import { AuthRequest } from 'src/middleware/auth.guard'; import { BaseService } from 'src/services/base.service'; +import { UploadFile } from 'src/types'; import { requireUploadAccess } from 'src/utils/access'; import { asRequest, getAssetFiles, onBeforeLink } from 'src/utils/asset.util'; import { getFilenameExtension, getFileNameWithoutExtension, ImmichFileResponse } from 'src/utils/file'; import { mimeTypes } from 'src/utils/mime-types'; import { fromChecksum } from 'src/utils/request'; -export interface UploadRequest { + +interface UploadRequest { auth: AuthDto | null; fieldName: UploadFieldName; file: UploadFile; } -export interface UploadFile { - uuid: string; - checksum: Buffer; - originalPath: string; - originalName: string; - size: number; -} - @Injectable() export class AssetMediaService extends BaseService { async getUploadAssetIdByChecksum(auth: AuthDto, checksum?: string): Promise { diff --git a/server/src/services/asset.service.spec.ts b/server/src/services/asset.service.spec.ts index 3c9cf3739f..336c3ac8f0 100755 --- a/server/src/services/asset.service.spec.ts +++ b/server/src/services/asset.service.spec.ts @@ -3,8 +3,7 @@ import { DateTime } from 'luxon'; import { mapAsset } from 'src/dtos/asset-response.dto'; import { AssetJobName, AssetStatsResponseDto } from 'src/dtos/asset.dto'; import { AssetEntity } from 'src/entities/asset.entity'; -import { AssetStatus, AssetType } from 'src/enum'; -import { JobName, JobStatus } from 'src/interfaces/job.interface'; +import { AssetStatus, AssetType, JobName, JobStatus } from 'src/enum'; import { AssetStats } from 'src/repositories/asset.repository'; import { AssetService } from 'src/services/asset.service'; import { assetStub } from 'test/fixtures/asset.stub'; diff --git a/server/src/services/asset.service.ts b/server/src/services/asset.service.ts index 99ddbb29cc..a9b723c9f9 100644 --- a/server/src/services/asset.service.ts +++ b/server/src/services/asset.service.ts @@ -1,6 +1,7 @@ -import { BadRequestException } from '@nestjs/common'; +import { BadRequestException, Injectable } from '@nestjs/common'; import _ from 'lodash'; import { DateTime, Duration } from 'luxon'; +import { JOBS_ASSET_PAGINATION_SIZE } from 'src/constants'; import { OnJob } from 'src/decorators'; import { AssetResponseDto, @@ -20,20 +21,13 @@ import { import { AuthDto } from 'src/dtos/auth.dto'; import { MemoryLaneDto } from 'src/dtos/search.dto'; import { AssetEntity } from 'src/entities/asset.entity'; -import { AssetStatus, Permission } from 'src/enum'; -import { - ISidecarWriteJob, - JOBS_ASSET_PAGINATION_SIZE, - JobItem, - JobName, - JobOf, - JobStatus, - QueueName, -} from 'src/interfaces/job.interface'; +import { AssetStatus, JobName, JobStatus, Permission, QueueName } from 'src/enum'; import { BaseService } from 'src/services/base.service'; +import { ISidecarWriteJob, JobItem, JobOf } from 'src/types'; import { getAssetFiles, getMyPartnerIds, onAfterUnlink, onBeforeLink, onBeforeUnlink } from 'src/utils/asset.util'; import { usePagination } from 'src/utils/pagination'; +@Injectable() export class AssetService extends BaseService { async getMemoryLane(auth: AuthDto, dto: MemoryLaneDto): Promise { const partnerIds = await getMyPartnerIds({ diff --git a/server/src/services/audit.service.spec.ts b/server/src/services/audit.service.spec.ts index b459ecb473..c64f6f2071 100644 --- a/server/src/services/audit.service.spec.ts +++ b/server/src/services/audit.service.spec.ts @@ -1,7 +1,14 @@ import { BadRequestException } from '@nestjs/common'; import { FileReportItemDto } from 'src/dtos/audit.dto'; -import { AssetFileType, AssetPathType, DatabaseAction, EntityType, PersonPathType, UserPathType } from 'src/enum'; -import { JobStatus } from 'src/interfaces/job.interface'; +import { + AssetFileType, + AssetPathType, + DatabaseAction, + EntityType, + JobStatus, + PersonPathType, + UserPathType, +} from 'src/enum'; import { AuditService } from 'src/services/audit.service'; import { auditStub } from 'test/fixtures/audit.stub'; import { authStub } from 'test/fixtures/auth.stub'; diff --git a/server/src/services/audit.service.ts b/server/src/services/audit.service.ts index 611f8f69d3..a952a0e64f 100644 --- a/server/src/services/audit.service.ts +++ b/server/src/services/audit.service.ts @@ -1,7 +1,7 @@ import { BadRequestException, Injectable } from '@nestjs/common'; import { DateTime } from 'luxon'; import { resolve } from 'node:path'; -import { AUDIT_LOG_MAX_DURATION } from 'src/constants'; +import { AUDIT_LOG_MAX_DURATION, JOBS_ASSET_PAGINATION_SIZE } from 'src/constants'; import { StorageCore } from 'src/cores/storage.core'; import { OnJob } from 'src/decorators'; import { @@ -17,12 +17,14 @@ import { AssetFileType, AssetPathType, DatabaseAction, + JobName, + JobStatus, Permission, PersonPathType, + QueueName, StorageFolder, UserPathType, } from 'src/enum'; -import { JobName, JOBS_ASSET_PAGINATION_SIZE, JobStatus, QueueName } from 'src/interfaces/job.interface'; import { BaseService } from 'src/services/base.service'; import { getAssetFiles } from 'src/utils/asset.util'; import { usePagination } from 'src/utils/pagination'; diff --git a/server/src/services/backup.service.spec.ts b/server/src/services/backup.service.spec.ts index fbed87a6d3..567859ac01 100644 --- a/server/src/services/backup.service.spec.ts +++ b/server/src/services/backup.service.spec.ts @@ -1,8 +1,7 @@ import { PassThrough } from 'node:stream'; import { defaults, SystemConfig } from 'src/config'; import { StorageCore } from 'src/cores/storage.core'; -import { ImmichWorker, StorageFolder } from 'src/enum'; -import { JobStatus } from 'src/interfaces/job.interface'; +import { ImmichWorker, JobStatus, StorageFolder } from 'src/enum'; import { BackupService } from 'src/services/backup.service'; import { systemConfigStub } from 'test/fixtures/system-config.stub'; import { mockSpawn, newTestService, ServiceMocks } from 'test/utils'; diff --git a/server/src/services/backup.service.ts b/server/src/services/backup.service.ts index dee8113792..e4fe791b19 100644 --- a/server/src/services/backup.service.ts +++ b/server/src/services/backup.service.ts @@ -3,9 +3,7 @@ import { default as path } from 'node:path'; import semver from 'semver'; import { StorageCore } from 'src/cores/storage.core'; import { OnEvent, OnJob } from 'src/decorators'; -import { ImmichWorker, StorageFolder } from 'src/enum'; -import { JobName, JobStatus, QueueName } from 'src/interfaces/job.interface'; -import { DatabaseLock } from 'src/repositories/database.repository'; +import { DatabaseLock, ImmichWorker, JobName, JobStatus, QueueName, StorageFolder } from 'src/enum'; import { ArgOf } from 'src/repositories/event.repository'; import { BaseService } from 'src/services/base.service'; import { handlePromiseError } from 'src/utils/misc'; diff --git a/server/src/services/base.service.ts b/server/src/services/base.service.ts index 259248e49b..f476adba11 100644 --- a/server/src/services/base.service.ts +++ b/server/src/services/base.service.ts @@ -1,4 +1,4 @@ -import { BadRequestException, Inject } from '@nestjs/common'; +import { BadRequestException, Injectable } from '@nestjs/common'; import { Insertable } from 'kysely'; import sanitize from 'sanitize-filename'; import { SystemConfig } from 'src/config'; @@ -6,7 +6,6 @@ import { SALT_ROUNDS } from 'src/constants'; import { StorageCore } from 'src/cores/storage.core'; import { Users } from 'src/db'; import { UserEntity } from 'src/entities/user.entity'; -import { IJobRepository } from 'src/interfaces/job.interface'; import { AccessRepository } from 'src/repositories/access.repository'; import { ActivityRepository } from 'src/repositories/activity.repository'; import { AlbumUserRepository } from 'src/repositories/album-user.repository'; @@ -19,6 +18,7 @@ import { CronRepository } from 'src/repositories/cron.repository'; import { CryptoRepository } from 'src/repositories/crypto.repository'; import { DatabaseRepository } from 'src/repositories/database.repository'; import { EventRepository } from 'src/repositories/event.repository'; +import { JobRepository } from 'src/repositories/job.repository'; import { LibraryRepository } from 'src/repositories/library.repository'; import { LoggingRepository } from 'src/repositories/logging.repository'; import { MachineLearningRepository } from 'src/repositories/machine-learning.repository'; @@ -48,6 +48,7 @@ import { ViewRepository } from 'src/repositories/view-repository'; import { AccessRequest, checkAccess, requireAccess } from 'src/utils/access'; import { getConfig, updateConfig } from 'src/utils/config'; +@Injectable() export class BaseService { protected storageCore: StorageCore; @@ -64,7 +65,7 @@ export class BaseService { protected cryptoRepository: CryptoRepository, protected databaseRepository: DatabaseRepository, protected eventRepository: EventRepository, - @Inject(IJobRepository) protected jobRepository: IJobRepository, + protected jobRepository: JobRepository, protected keyRepository: ApiKeyRepository, protected libraryRepository: LibraryRepository, protected machineLearningRepository: MachineLearningRepository, diff --git a/server/src/services/database.service.spec.ts b/server/src/services/database.service.spec.ts index d50b7facf6..1314d5327e 100644 --- a/server/src/services/database.service.spec.ts +++ b/server/src/services/database.service.spec.ts @@ -1,6 +1,7 @@ +import { EXTENSION_NAMES } from 'src/constants'; import { DatabaseExtension } from 'src/enum'; -import { EXTENSION_NAMES, VectorExtension } from 'src/repositories/database.repository'; import { DatabaseService } from 'src/services/database.service'; +import { VectorExtension } from 'src/types'; import { mockEnvData } from 'test/repositories/config.repository.mock'; import { newTestService, ServiceMocks } from 'test/utils'; diff --git a/server/src/services/database.service.ts b/server/src/services/database.service.ts index 249b47c99c..36f4c09177 100644 --- a/server/src/services/database.service.ts +++ b/server/src/services/database.service.ts @@ -1,10 +1,11 @@ import { Injectable } from '@nestjs/common'; import { Duration } from 'luxon'; import semver from 'semver'; +import { EXTENSION_NAMES } from 'src/constants'; import { OnEvent } from 'src/decorators'; -import { BootstrapEventPriority, DatabaseExtension } from 'src/enum'; -import { DatabaseLock, EXTENSION_NAMES, VectorExtension, VectorIndex } from 'src/repositories/database.repository'; +import { BootstrapEventPriority, DatabaseExtension, DatabaseLock, VectorIndex } from 'src/enum'; import { BaseService } from 'src/services/base.service'; +import { VectorExtension } from 'src/types'; type CreateFailedArgs = { name: string; extension: string; otherName: string }; type UpdateFailedArgs = { name: string; extension: string; availableVersion: string }; diff --git a/server/src/services/duplicate.service.spec.ts b/server/src/services/duplicate.service.spec.ts index 7a8b9ed27b..30b8cd3451 100644 --- a/server/src/services/duplicate.service.spec.ts +++ b/server/src/services/duplicate.service.spec.ts @@ -1,4 +1,4 @@ -import { JobName, JobStatus } from 'src/interfaces/job.interface'; +import { JobName, JobStatus } from 'src/enum'; import { WithoutProperty } from 'src/repositories/asset.repository'; import { DuplicateService } from 'src/services/duplicate.service'; import { SearchService } from 'src/services/search.service'; diff --git a/server/src/services/duplicate.service.ts b/server/src/services/duplicate.service.ts index 1b0d583cc3..5600033b47 100644 --- a/server/src/services/duplicate.service.ts +++ b/server/src/services/duplicate.service.ts @@ -1,13 +1,15 @@ import { Injectable } from '@nestjs/common'; +import { JOBS_ASSET_PAGINATION_SIZE } from 'src/constants'; import { OnJob } from 'src/decorators'; import { mapAsset } from 'src/dtos/asset-response.dto'; import { AuthDto } from 'src/dtos/auth.dto'; import { DuplicateResponseDto } from 'src/dtos/duplicate.dto'; import { AssetEntity } from 'src/entities/asset.entity'; -import { JOBS_ASSET_PAGINATION_SIZE, JobName, JobOf, JobStatus, QueueName } from 'src/interfaces/job.interface'; +import { JobName, JobStatus, QueueName } from 'src/enum'; import { WithoutProperty } from 'src/repositories/asset.repository'; import { AssetDuplicateResult } from 'src/repositories/search.repository'; import { BaseService } from 'src/services/base.service'; +import { JobOf } from 'src/types'; import { getAssetFiles } from 'src/utils/asset.util'; import { isDuplicateDetectionEnabled } from 'src/utils/misc'; import { usePagination } from 'src/utils/pagination'; diff --git a/server/src/services/job.service.spec.ts b/server/src/services/job.service.spec.ts index 8b0b5c408c..6797ffc396 100644 --- a/server/src/services/job.service.spec.ts +++ b/server/src/services/job.service.spec.ts @@ -1,8 +1,8 @@ import { BadRequestException } from '@nestjs/common'; import { defaults, SystemConfig } from 'src/config'; -import { ImmichWorker } from 'src/enum'; -import { JobCommand, JobItem, JobName, JobStatus, QueueName } from 'src/interfaces/job.interface'; +import { ImmichWorker, JobCommand, JobName, JobStatus, QueueName } from 'src/enum'; import { JobService } from 'src/services/job.service'; +import { JobItem } from 'src/types'; import { assetStub } from 'test/fixtures/asset.stub'; import { newTestService, ServiceMocks } from 'test/utils'; diff --git a/server/src/services/job.service.ts b/server/src/services/job.service.ts index 00d9a398fd..8e3919a2b1 100644 --- a/server/src/services/job.service.ts +++ b/server/src/services/job.service.ts @@ -3,18 +3,19 @@ import { snakeCase } from 'lodash'; import { OnEvent } from 'src/decorators'; import { mapAsset } from 'src/dtos/asset-response.dto'; import { AllJobStatusResponseDto, JobCommandDto, JobCreateDto, JobStatusDto } from 'src/dtos/job.dto'; -import { AssetType, ImmichWorker, ManualJobName } from 'src/enum'; import { - ConcurrentQueueName, + AssetType, + ImmichWorker, JobCommand, - JobItem, JobName, JobStatus, + ManualJobName, QueueCleanType, QueueName, -} from 'src/interfaces/job.interface'; +} from 'src/enum'; import { ArgOf, ArgsOf } from 'src/repositories/event.repository'; import { BaseService } from 'src/services/base.service'; +import { ConcurrentQueueName, JobItem } from 'src/types'; const asJobItem = (dto: JobCreateDto): JobItem => { switch (dto.name) { diff --git a/server/src/services/library.service.spec.ts b/server/src/services/library.service.spec.ts index 24b1265ae9..ded7e0630a 100644 --- a/server/src/services/library.service.spec.ts +++ b/server/src/services/library.service.spec.ts @@ -1,17 +1,12 @@ import { BadRequestException } from '@nestjs/common'; import { Stats } from 'node:fs'; import { defaults, SystemConfig } from 'src/config'; +import { JOBS_LIBRARY_PAGINATION_SIZE } from 'src/constants'; import { mapLibrary } from 'src/dtos/library.dto'; import { UserEntity } from 'src/entities/user.entity'; -import { AssetType, ImmichWorker } from 'src/enum'; -import { - ILibraryAssetJob, - ILibraryFileJob, - JobName, - JOBS_LIBRARY_PAGINATION_SIZE, - JobStatus, -} from 'src/interfaces/job.interface'; +import { AssetType, ImmichWorker, JobName, JobStatus } from 'src/enum'; import { LibraryService } from 'src/services/library.service'; +import { ILibraryAssetJob, ILibraryFileJob } from 'src/types'; import { assetStub } from 'test/fixtures/asset.stub'; import { authStub } from 'test/fixtures/auth.stub'; import { libraryStub } from 'test/fixtures/library.stub'; diff --git a/server/src/services/library.service.ts b/server/src/services/library.service.ts index fb74827656..4a9614e010 100644 --- a/server/src/services/library.service.ts +++ b/server/src/services/library.service.ts @@ -2,6 +2,7 @@ import { BadRequestException, Injectable } from '@nestjs/common'; import { R_OK } from 'node:constants'; import path, { basename, isAbsolute, parse } from 'node:path'; import picomatch from 'picomatch'; +import { JOBS_LIBRARY_PAGINATION_SIZE } from 'src/constants'; import { StorageCore } from 'src/cores/storage.core'; import { OnEvent, OnJob } from 'src/decorators'; import { @@ -16,11 +17,10 @@ import { } from 'src/dtos/library.dto'; import { AssetEntity } from 'src/entities/asset.entity'; import { LibraryEntity } from 'src/entities/library.entity'; -import { AssetType, ImmichWorker } from 'src/enum'; -import { JobName, JobOf, JOBS_LIBRARY_PAGINATION_SIZE, JobStatus, QueueName } from 'src/interfaces/job.interface'; -import { DatabaseLock } from 'src/repositories/database.repository'; +import { AssetType, DatabaseLock, ImmichWorker, JobName, JobStatus, QueueName } from 'src/enum'; import { ArgOf } from 'src/repositories/event.repository'; import { BaseService } from 'src/services/base.service'; +import { JobOf } from 'src/types'; import { mimeTypes } from 'src/utils/mime-types'; import { handlePromiseError } from 'src/utils/misc'; import { usePagination } from 'src/utils/pagination'; diff --git a/server/src/services/map.service.ts b/server/src/services/map.service.ts index 860a782e79..94eca77a60 100644 --- a/server/src/services/map.service.ts +++ b/server/src/services/map.service.ts @@ -1,8 +1,10 @@ +import { Injectable } from '@nestjs/common'; import { AuthDto } from 'src/dtos/auth.dto'; import { MapMarkerDto, MapMarkerResponseDto, MapReverseGeocodeDto } from 'src/dtos/map.dto'; import { BaseService } from 'src/services/base.service'; import { getMyPartnerIds } from 'src/utils/asset.util'; +@Injectable() export class MapService extends BaseService { async getMapMarkers(auth: AuthDto, options: MapMarkerDto): Promise { const userIds = [auth.user.id]; diff --git a/server/src/services/media.service.spec.ts b/server/src/services/media.service.spec.ts index 52ccab1c91..d98cff866f 100644 --- a/server/src/services/media.service.spec.ts +++ b/server/src/services/media.service.spec.ts @@ -9,14 +9,15 @@ import { AudioCodec, Colorspace, ImageFormat, + JobName, + JobStatus, TranscodeHWAccel, TranscodePolicy, VideoCodec, } from 'src/enum'; -import { JobCounts, JobName, JobStatus } from 'src/interfaces/job.interface'; import { WithoutProperty } from 'src/repositories/asset.repository'; import { MediaService } from 'src/services/media.service'; -import { RawImageInfo } from 'src/types'; +import { JobCounts, RawImageInfo } from 'src/types'; import { assetStub } from 'test/fixtures/asset.stub'; import { faceStub } from 'test/fixtures/face.stub'; import { probeStub } from 'test/fixtures/media.stub'; diff --git a/server/src/services/media.service.ts b/server/src/services/media.service.ts index 58621c1b19..54540dff66 100644 --- a/server/src/services/media.service.ts +++ b/server/src/services/media.service.ts @@ -1,5 +1,6 @@ import { Injectable } from '@nestjs/common'; import { dirname } from 'node:path'; +import { JOBS_ASSET_PAGINATION_SIZE } from 'src/constants'; import { StorageCore } from 'src/cores/storage.core'; import { OnEvent, OnJob } from 'src/decorators'; import { SystemConfigFFmpegDto } from 'src/dtos/system-config.dto'; @@ -10,7 +11,10 @@ import { AssetType, AudioCodec, Colorspace, + JobName, + JobStatus, LogLevel, + QueueName, StorageFolder, TranscodeHWAccel, TranscodePolicy, @@ -18,17 +22,9 @@ import { VideoCodec, VideoContainer, } from 'src/enum'; -import { - JOBS_ASSET_PAGINATION_SIZE, - JobItem, - JobName, - JobOf, - JobStatus, - QueueName, -} from 'src/interfaces/job.interface'; import { UpsertFileOptions, WithoutProperty } from 'src/repositories/asset.repository'; import { BaseService } from 'src/services/base.service'; -import { AudioStreamInfo, VideoFormat, VideoInterfaces, VideoStreamInfo } from 'src/types'; +import { AudioStreamInfo, JobItem, JobOf, VideoFormat, VideoInterfaces, VideoStreamInfo } from 'src/types'; import { getAssetFiles } from 'src/utils/asset.util'; import { BaseConfig, ThumbnailConfig } from 'src/utils/media'; import { mimeTypes } from 'src/utils/mime-types'; diff --git a/server/src/services/metadata.service.spec.ts b/server/src/services/metadata.service.spec.ts index c7c675ab3e..0e0c6546ca 100644 --- a/server/src/services/metadata.service.spec.ts +++ b/server/src/services/metadata.service.spec.ts @@ -4,8 +4,7 @@ import { Stats } from 'node:fs'; import { constants } from 'node:fs/promises'; import { AssetEntity } from 'src/entities/asset.entity'; import { ExifEntity } from 'src/entities/exif.entity'; -import { AssetType, ExifOrientation, ImmichWorker, SourceType } from 'src/enum'; -import { JobName, JobStatus } from 'src/interfaces/job.interface'; +import { AssetType, ExifOrientation, ImmichWorker, JobName, JobStatus, SourceType } from 'src/enum'; import { WithoutProperty } from 'src/repositories/asset.repository'; import { ImmichTags } from 'src/repositories/metadata.repository'; import { MetadataService } from 'src/services/metadata.service'; diff --git a/server/src/services/metadata.service.ts b/server/src/services/metadata.service.ts index 33db5d3149..8c2e3646a0 100644 --- a/server/src/services/metadata.service.ts +++ b/server/src/services/metadata.service.ts @@ -7,20 +7,29 @@ import { Duration } from 'luxon'; import { constants } from 'node:fs/promises'; import path from 'node:path'; import { SystemConfig } from 'src/config'; +import { JOBS_ASSET_PAGINATION_SIZE } from 'src/constants'; import { StorageCore } from 'src/cores/storage.core'; import { Exif } from 'src/db'; import { OnEvent, OnJob } from 'src/decorators'; import { AssetFaceEntity } from 'src/entities/asset-face.entity'; import { AssetEntity } from 'src/entities/asset.entity'; import { PersonEntity } from 'src/entities/person.entity'; -import { AssetType, ExifOrientation, ImmichWorker, SourceType } from 'src/enum'; -import { JobName, JobOf, JOBS_ASSET_PAGINATION_SIZE, JobStatus, QueueName } from 'src/interfaces/job.interface'; +import { + AssetType, + DatabaseLock, + ExifOrientation, + ImmichWorker, + JobName, + JobStatus, + QueueName, + SourceType, +} from 'src/enum'; import { WithoutProperty } from 'src/repositories/asset.repository'; -import { DatabaseLock } from 'src/repositories/database.repository'; import { ArgOf } from 'src/repositories/event.repository'; import { ReverseGeocodeResult } from 'src/repositories/map.repository'; import { ImmichTags } from 'src/repositories/metadata.repository'; import { BaseService } from 'src/services/base.service'; +import { JobOf } from 'src/types'; import { isFaceImportEnabled } from 'src/utils/misc'; import { usePagination } from 'src/utils/pagination'; import { upsertTags } from 'src/utils/tag'; diff --git a/server/src/services/notification.service.spec.ts b/server/src/services/notification.service.spec.ts index 35f1601c72..038ab1180c 100644 --- a/server/src/services/notification.service.spec.ts +++ b/server/src/services/notification.service.spec.ts @@ -3,10 +3,10 @@ import { defaults, SystemConfig } from 'src/config'; import { SystemConfigDto } from 'src/dtos/system-config.dto'; import { AlbumUserEntity } from 'src/entities/album-user.entity'; import { AssetFileEntity } from 'src/entities/asset-files.entity'; -import { AssetFileType, UserMetadataKey } from 'src/enum'; -import { INotifyAlbumUpdateJob, JobName, JobStatus } from 'src/interfaces/job.interface'; +import { AssetFileType, JobName, JobStatus, UserMetadataKey } from 'src/enum'; import { EmailTemplate } from 'src/repositories/notification.repository'; import { NotificationService } from 'src/services/notification.service'; +import { INotifyAlbumUpdateJob } from 'src/types'; import { albumStub } from 'test/fixtures/album.stub'; import { assetStub } from 'test/fixtures/asset.stub'; import { userStub } from 'test/fixtures/user.stub'; diff --git a/server/src/services/notification.service.ts b/server/src/services/notification.service.ts index f9a2194088..003b320997 100644 --- a/server/src/services/notification.service.ts +++ b/server/src/services/notification.service.ts @@ -2,18 +2,11 @@ import { BadRequestException, Injectable } from '@nestjs/common'; import { OnEvent, OnJob } from 'src/decorators'; import { SystemConfigSmtpDto } from 'src/dtos/system-config.dto'; import { AlbumEntity } from 'src/entities/album.entity'; -import { - IEntityJob, - INotifyAlbumUpdateJob, - JobItem, - JobName, - JobOf, - JobStatus, - QueueName, -} from 'src/interfaces/job.interface'; +import { JobName, JobStatus, QueueName } from 'src/enum'; import { ArgOf } from 'src/repositories/event.repository'; -import { EmailImageAttachment, EmailTemplate } from 'src/repositories/notification.repository'; +import { EmailTemplate } from 'src/repositories/notification.repository'; import { BaseService } from 'src/services/base.service'; +import { EmailImageAttachment, IEntityJob, INotifyAlbumUpdateJob, JobItem, JobOf } from 'src/types'; import { getAssetFiles } from 'src/utils/asset.util'; import { getFilenameExtension } from 'src/utils/file'; import { getExternalDomain } from 'src/utils/misc'; diff --git a/server/src/services/person.service.spec.ts b/server/src/services/person.service.spec.ts index 8e1cff302f..ba327ddae9 100644 --- a/server/src/services/person.service.spec.ts +++ b/server/src/services/person.service.spec.ts @@ -2,8 +2,7 @@ import { BadRequestException, NotFoundException } from '@nestjs/common'; import { BulkIdErrorReason } from 'src/dtos/asset-ids.response.dto'; import { mapFaces, mapPerson, PersonResponseDto } from 'src/dtos/person.dto'; import { AssetFaceEntity } from 'src/entities/asset-face.entity'; -import { CacheControl, Colorspace, ImageFormat, SourceType, SystemMetadataKey } from 'src/enum'; -import { JobName, JobStatus } from 'src/interfaces/job.interface'; +import { CacheControl, Colorspace, ImageFormat, JobName, JobStatus, SourceType, SystemMetadataKey } from 'src/enum'; import { WithoutProperty } from 'src/repositories/asset.repository'; import { DetectedFaces } from 'src/repositories/machine-learning.repository'; import { FaceSearchResult } from 'src/repositories/search.repository'; diff --git a/server/src/services/person.service.ts b/server/src/services/person.service.ts index e9933b421c..ecb8677f71 100644 --- a/server/src/services/person.service.ts +++ b/server/src/services/person.service.ts @@ -1,5 +1,5 @@ import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; -import { FACE_THUMBNAIL_SIZE } from 'src/constants'; +import { FACE_THUMBNAIL_SIZE, JOBS_ASSET_PAGINATION_SIZE } from 'src/constants'; import { StorageCore } from 'src/cores/storage.core'; import { OnJob } from 'src/decorators'; import { BulkIdErrorReason, BulkIdResponseDto } from 'src/dtos/asset-ids.response.dto'; @@ -27,24 +27,19 @@ import { AssetType, CacheControl, ImageFormat, + JobName, + JobStatus, Permission, PersonPathType, + QueueName, SourceType, SystemMetadataKey, } from 'src/enum'; -import { - JOBS_ASSET_PAGINATION_SIZE, - JobItem, - JobName, - JobOf, - JobStatus, - QueueName, -} from 'src/interfaces/job.interface'; import { WithoutProperty } from 'src/repositories/asset.repository'; import { BoundingBox } from 'src/repositories/machine-learning.repository'; import { UpdateFacesData } from 'src/repositories/person.repository'; import { BaseService } from 'src/services/base.service'; -import { CropOptions, ImageDimensions, InputDimensions } from 'src/types'; +import { CropOptions, ImageDimensions, InputDimensions, JobItem, JobOf } from 'src/types'; import { getAssetFiles } from 'src/utils/asset.util'; import { ImmichFileResponse } from 'src/utils/file'; import { mimeTypes } from 'src/utils/mime-types'; diff --git a/server/src/services/session.service.spec.ts b/server/src/services/session.service.spec.ts index c25c0feb82..8c22abb7f0 100644 --- a/server/src/services/session.service.spec.ts +++ b/server/src/services/session.service.spec.ts @@ -1,4 +1,4 @@ -import { JobStatus } from 'src/interfaces/job.interface'; +import { JobStatus } from 'src/enum'; import { SessionService } from 'src/services/session.service'; import { authStub } from 'test/fixtures/auth.stub'; import { sessionStub } from 'test/fixtures/session.stub'; diff --git a/server/src/services/session.service.ts b/server/src/services/session.service.ts index 68df7828ad..6b0632cd44 100644 --- a/server/src/services/session.service.ts +++ b/server/src/services/session.service.ts @@ -3,8 +3,7 @@ import { DateTime } from 'luxon'; import { OnJob } from 'src/decorators'; import { AuthDto } from 'src/dtos/auth.dto'; import { SessionResponseDto, mapSession } from 'src/dtos/session.dto'; -import { Permission } from 'src/enum'; -import { JobName, JobStatus, QueueName } from 'src/interfaces/job.interface'; +import { JobName, JobStatus, Permission, QueueName } from 'src/enum'; import { BaseService } from 'src/services/base.service'; @Injectable() diff --git a/server/src/services/smart-info.service.spec.ts b/server/src/services/smart-info.service.spec.ts index 403c6e9a6a..aef83a813d 100644 --- a/server/src/services/smart-info.service.spec.ts +++ b/server/src/services/smart-info.service.spec.ts @@ -1,6 +1,5 @@ import { SystemConfig } from 'src/config'; -import { ImmichWorker } from 'src/enum'; -import { JobName, JobStatus } from 'src/interfaces/job.interface'; +import { ImmichWorker, JobName, JobStatus } from 'src/enum'; import { WithoutProperty } from 'src/repositories/asset.repository'; import { SmartInfoService } from 'src/services/smart-info.service'; import { getCLIPModelInfo } from 'src/utils/misc'; diff --git a/server/src/services/smart-info.service.ts b/server/src/services/smart-info.service.ts index 0a03c27a55..063bb0bd3b 100644 --- a/server/src/services/smart-info.service.ts +++ b/server/src/services/smart-info.service.ts @@ -1,12 +1,12 @@ import { Injectable } from '@nestjs/common'; import { SystemConfig } from 'src/config'; +import { JOBS_ASSET_PAGINATION_SIZE } from 'src/constants'; import { OnEvent, OnJob } from 'src/decorators'; -import { ImmichWorker } from 'src/enum'; -import { JOBS_ASSET_PAGINATION_SIZE, JobName, JobOf, JobStatus, QueueName } from 'src/interfaces/job.interface'; +import { DatabaseLock, ImmichWorker, JobName, JobStatus, QueueName } from 'src/enum'; import { WithoutProperty } from 'src/repositories/asset.repository'; -import { DatabaseLock } from 'src/repositories/database.repository'; import { ArgOf } from 'src/repositories/event.repository'; import { BaseService } from 'src/services/base.service'; +import { JobOf } from 'src/types'; import { getAssetFiles } from 'src/utils/asset.util'; import { getCLIPModelInfo, isSmartSearchEnabled } from 'src/utils/misc'; import { usePagination } from 'src/utils/pagination'; diff --git a/server/src/services/storage-template.service.spec.ts b/server/src/services/storage-template.service.spec.ts index cf272c7e5f..0a055d0e6d 100644 --- a/server/src/services/storage-template.service.spec.ts +++ b/server/src/services/storage-template.service.spec.ts @@ -1,8 +1,7 @@ import { Stats } from 'node:fs'; import { defaults, SystemConfig } from 'src/config'; import { AssetEntity } from 'src/entities/asset.entity'; -import { AssetPathType } from 'src/enum'; -import { JobStatus } from 'src/interfaces/job.interface'; +import { AssetPathType, JobStatus } from 'src/enum'; import { StorageTemplateService } from 'src/services/storage-template.service'; import { albumStub } from 'test/fixtures/album.stub'; import { assetStub } from 'test/fixtures/asset.stub'; diff --git a/server/src/services/storage-template.service.ts b/server/src/services/storage-template.service.ts index 6fd139c10d..24a9fcd459 100644 --- a/server/src/services/storage-template.service.ts +++ b/server/src/services/storage-template.service.ts @@ -3,15 +3,15 @@ import handlebar from 'handlebars'; import { DateTime } from 'luxon'; import path from 'node:path'; import sanitize from 'sanitize-filename'; +import { JOBS_ASSET_PAGINATION_SIZE } from 'src/constants'; import { StorageCore } from 'src/cores/storage.core'; import { OnEvent, OnJob } from 'src/decorators'; import { SystemConfigTemplateStorageOptionDto } from 'src/dtos/system-config.dto'; import { AssetEntity } from 'src/entities/asset.entity'; -import { AssetPathType, AssetType, StorageFolder } from 'src/enum'; -import { JobName, JobOf, JOBS_ASSET_PAGINATION_SIZE, JobStatus, QueueName } from 'src/interfaces/job.interface'; -import { DatabaseLock } from 'src/repositories/database.repository'; +import { AssetPathType, AssetType, DatabaseLock, JobName, JobStatus, QueueName, StorageFolder } from 'src/enum'; import { ArgOf } from 'src/repositories/event.repository'; import { BaseService } from 'src/services/base.service'; +import { JobOf } from 'src/types'; import { getLivePhotoMotionFilename } from 'src/utils/file'; import { usePagination } from 'src/utils/pagination'; diff --git a/server/src/services/storage.service.ts b/server/src/services/storage.service.ts index 6921d33560..c0c7a00ae7 100644 --- a/server/src/services/storage.service.ts +++ b/server/src/services/storage.service.ts @@ -3,10 +3,9 @@ import { join } from 'node:path'; import { StorageCore } from 'src/cores/storage.core'; import { OnEvent, OnJob } from 'src/decorators'; import { SystemFlags } from 'src/entities/system-metadata.entity'; -import { StorageFolder, SystemMetadataKey } from 'src/enum'; -import { JobName, JobOf, JobStatus, QueueName } from 'src/interfaces/job.interface'; -import { DatabaseLock } from 'src/repositories/database.repository'; +import { DatabaseLock, JobName, JobStatus, QueueName, StorageFolder, SystemMetadataKey } from 'src/enum'; import { BaseService } from 'src/services/base.service'; +import { JobOf } from 'src/types'; import { ImmichStartupError } from 'src/utils/misc'; const docsMessage = `Please see https://immich.app/docs/administration/system-integrity#folder-checks for more information.`; diff --git a/server/src/services/sync.service.ts b/server/src/services/sync.service.ts index f85200db48..fe967e37e0 100644 --- a/server/src/services/sync.service.ts +++ b/server/src/services/sync.service.ts @@ -1,3 +1,4 @@ +import { Injectable } from '@nestjs/common'; import { DateTime } from 'luxon'; import { AUDIT_LOG_MAX_DURATION } from 'src/constants'; import { AssetResponseDto, mapAsset } from 'src/dtos/asset-response.dto'; @@ -10,6 +11,7 @@ import { setIsEqual } from 'src/utils/set'; const FULL_SYNC = { needsFullSync: true, deleted: [], upserted: [] }; +@Injectable() export class SyncService extends BaseService { async getFullSync(auth: AuthDto, dto: AssetFullSyncDto): Promise { // mobile implementation is faster if this is a single id diff --git a/server/src/services/system-config.service.spec.ts b/server/src/services/system-config.service.spec.ts index 027bcc1c15..8a06a883c2 100644 --- a/server/src/services/system-config.service.spec.ts +++ b/server/src/services/system-config.service.spec.ts @@ -6,13 +6,13 @@ import { CQMode, ImageFormat, LogLevel, + QueueName, ToneMapping, TranscodeHWAccel, TranscodePolicy, VideoCodec, VideoContainer, } from 'src/enum'; -import { QueueName } from 'src/interfaces/job.interface'; import { SystemConfigService } from 'src/services/system-config.service'; import { DeepPartial } from 'src/types'; import { mockEnvData } from 'test/repositories/config.repository.mock'; diff --git a/server/src/services/system-config.service.ts b/server/src/services/system-config.service.ts index cc32ef0c34..2dd8dcf0ee 100644 --- a/server/src/services/system-config.service.ts +++ b/server/src/services/system-config.service.ts @@ -3,7 +3,7 @@ import { instanceToPlain } from 'class-transformer'; import _ from 'lodash'; import { defaults } from 'src/config'; import { OnEvent } from 'src/decorators'; -import { SystemConfigDto, mapConfig } from 'src/dtos/system-config.dto'; +import { mapConfig, SystemConfigDto } from 'src/dtos/system-config.dto'; import { BootstrapEventPriority } from 'src/enum'; import { ArgOf } from 'src/repositories/event.repository'; import { BaseService } from 'src/services/base.service'; diff --git a/server/src/services/tag.service.spec.ts b/server/src/services/tag.service.spec.ts index 48d1b00379..f1385eb8c8 100644 --- a/server/src/services/tag.service.spec.ts +++ b/server/src/services/tag.service.spec.ts @@ -1,6 +1,6 @@ import { BadRequestException } from '@nestjs/common'; import { BulkIdErrorReason } from 'src/dtos/asset-ids.response.dto'; -import { JobStatus } from 'src/interfaces/job.interface'; +import { JobStatus } from 'src/enum'; import { TagService } from 'src/services/tag.service'; import { authStub } from 'test/fixtures/auth.stub'; import { tagResponseStub, tagStub } from 'test/fixtures/tag.stub'; diff --git a/server/src/services/tag.service.ts b/server/src/services/tag.service.ts index 83d4b40340..c241f59a80 100644 --- a/server/src/services/tag.service.ts +++ b/server/src/services/tag.service.ts @@ -12,8 +12,7 @@ import { mapTag, } from 'src/dtos/tag.dto'; import { TagEntity } from 'src/entities/tag.entity'; -import { Permission } from 'src/enum'; -import { JobName, JobStatus, QueueName } from 'src/interfaces/job.interface'; +import { JobName, JobStatus, Permission, QueueName } from 'src/enum'; import { AssetTagItem } from 'src/repositories/tag.repository'; import { BaseService } from 'src/services/base.service'; import { addAssets, removeAssets } from 'src/utils/asset.util'; diff --git a/server/src/services/timeline.service.ts b/server/src/services/timeline.service.ts index bc4c7fad73..4c2332afaa 100644 --- a/server/src/services/timeline.service.ts +++ b/server/src/services/timeline.service.ts @@ -1,4 +1,4 @@ -import { BadRequestException } from '@nestjs/common'; +import { BadRequestException, Injectable } from '@nestjs/common'; import { AssetResponseDto, SanitizedAssetResponseDto, mapAsset } from 'src/dtos/asset-response.dto'; import { AuthDto } from 'src/dtos/auth.dto'; import { TimeBucketAssetDto, TimeBucketDto, TimeBucketResponseDto } from 'src/dtos/time-bucket.dto'; @@ -7,6 +7,7 @@ import { TimeBucketOptions } from 'src/repositories/asset.repository'; import { BaseService } from 'src/services/base.service'; import { getMyPartnerIds } from 'src/utils/asset.util'; +@Injectable() export class TimelineService extends BaseService { async getTimeBuckets(auth: AuthDto, dto: TimeBucketDto): Promise { await this.timeBucketChecks(auth, dto); diff --git a/server/src/services/trash.service.spec.ts b/server/src/services/trash.service.spec.ts index 536fb65f9a..e7eccd374c 100644 --- a/server/src/services/trash.service.spec.ts +++ b/server/src/services/trash.service.spec.ts @@ -1,5 +1,5 @@ import { BadRequestException } from '@nestjs/common'; -import { JobName, JobStatus } from 'src/interfaces/job.interface'; +import { JobName, JobStatus } from 'src/enum'; import { TrashService } from 'src/services/trash.service'; import { authStub } from 'test/fixtures/auth.stub'; import { newTestService, ServiceMocks } from 'test/utils'; diff --git a/server/src/services/trash.service.ts b/server/src/services/trash.service.ts index d66461ef94..f33b249823 100644 --- a/server/src/services/trash.service.ts +++ b/server/src/services/trash.service.ts @@ -1,11 +1,13 @@ +import { Injectable } from '@nestjs/common'; +import { JOBS_ASSET_PAGINATION_SIZE } from 'src/constants'; import { OnEvent, OnJob } from 'src/decorators'; import { BulkIdsDto } from 'src/dtos/asset-ids.response.dto'; import { AuthDto } from 'src/dtos/auth.dto'; import { TrashResponseDto } from 'src/dtos/trash.dto'; -import { Permission } from 'src/enum'; -import { JOBS_ASSET_PAGINATION_SIZE, JobName, JobStatus, QueueName } from 'src/interfaces/job.interface'; +import { JobName, JobStatus, Permission, QueueName } from 'src/enum'; import { BaseService } from 'src/services/base.service'; +@Injectable() export class TrashService extends BaseService { async restoreAssets(auth: AuthDto, dto: BulkIdsDto): Promise { const { ids } = dto; diff --git a/server/src/services/user-admin.service.spec.ts b/server/src/services/user-admin.service.spec.ts index 604062c97a..3e613bc485 100644 --- a/server/src/services/user-admin.service.spec.ts +++ b/server/src/services/user-admin.service.spec.ts @@ -1,7 +1,6 @@ import { BadRequestException, ForbiddenException } from '@nestjs/common'; import { mapUserAdmin } from 'src/dtos/user.dto'; -import { UserStatus } from 'src/enum'; -import { JobName } from 'src/interfaces/job.interface'; +import { JobName, UserStatus } from 'src/enum'; import { UserAdminService } from 'src/services/user-admin.service'; import { authStub } from 'test/fixtures/auth.stub'; import { userStub } from 'test/fixtures/user.stub'; diff --git a/server/src/services/user-admin.service.ts b/server/src/services/user-admin.service.ts index 3df9a218f3..37c3c1e004 100644 --- a/server/src/services/user-admin.service.ts +++ b/server/src/services/user-admin.service.ts @@ -10,8 +10,7 @@ import { UserAdminUpdateDto, mapUserAdmin, } from 'src/dtos/user.dto'; -import { UserMetadataKey, UserStatus } from 'src/enum'; -import { JobName } from 'src/interfaces/job.interface'; +import { JobName, UserMetadataKey, UserStatus } from 'src/enum'; import { UserFindOptions } from 'src/repositories/user.repository'; import { BaseService } from 'src/services/base.service'; import { getPreferences, getPreferencesPartial, mergePreferences } from 'src/utils/preferences'; diff --git a/server/src/services/user.service.spec.ts b/server/src/services/user.service.spec.ts index 8762c7c766..06c928da14 100644 --- a/server/src/services/user.service.spec.ts +++ b/server/src/services/user.service.spec.ts @@ -1,7 +1,6 @@ import { BadRequestException, InternalServerErrorException, NotFoundException } from '@nestjs/common'; import { UserEntity } from 'src/entities/user.entity'; -import { CacheControl, UserMetadataKey } from 'src/enum'; -import { JobName } from 'src/interfaces/job.interface'; +import { CacheControl, JobName, UserMetadataKey } from 'src/enum'; import { UserService } from 'src/services/user.service'; import { ImmichFileResponse } from 'src/utils/file'; import { authStub } from 'test/fixtures/auth.stub'; diff --git a/server/src/services/user.service.ts b/server/src/services/user.service.ts index dfd9c7715f..3ec6281009 100644 --- a/server/src/services/user.service.ts +++ b/server/src/services/user.service.ts @@ -10,10 +10,10 @@ import { CreateProfileImageResponseDto } from 'src/dtos/user-profile.dto'; import { UserAdminResponseDto, UserResponseDto, UserUpdateMeDto, mapUser, mapUserAdmin } from 'src/dtos/user.dto'; import { UserMetadataEntity } from 'src/entities/user-metadata.entity'; import { UserEntity } from 'src/entities/user.entity'; -import { CacheControl, StorageFolder, UserMetadataKey } from 'src/enum'; -import { JobName, JobOf, JobStatus, QueueName } from 'src/interfaces/job.interface'; +import { CacheControl, JobName, JobStatus, QueueName, StorageFolder, UserMetadataKey } from 'src/enum'; import { UserFindOptions } from 'src/repositories/user.repository'; import { BaseService } from 'src/services/base.service'; +import { JobOf } from 'src/types'; import { ImmichFileResponse } from 'src/utils/file'; import { getPreferences, getPreferencesPartial, mergePreferences } from 'src/utils/preferences'; diff --git a/server/src/services/version.service.spec.ts b/server/src/services/version.service.spec.ts index 32378c52df..500478e9e7 100644 --- a/server/src/services/version.service.spec.ts +++ b/server/src/services/version.service.spec.ts @@ -1,8 +1,7 @@ import { DateTime } from 'luxon'; import { SemVer } from 'semver'; import { serverVersion } from 'src/constants'; -import { ImmichEnvironment, SystemMetadataKey } from 'src/enum'; -import { JobName, JobStatus } from 'src/interfaces/job.interface'; +import { ImmichEnvironment, JobName, JobStatus, SystemMetadataKey } from 'src/enum'; import { VersionService } from 'src/services/version.service'; import { mockEnvData } from 'test/repositories/config.repository.mock'; import { newTestService, ServiceMocks } from 'test/utils'; diff --git a/server/src/services/version.service.ts b/server/src/services/version.service.ts index 9679ac4b4b..c5240b82c1 100644 --- a/server/src/services/version.service.ts +++ b/server/src/services/version.service.ts @@ -5,9 +5,7 @@ import { serverVersion } from 'src/constants'; import { OnEvent, OnJob } from 'src/decorators'; import { ReleaseNotification, ServerVersionResponseDto } from 'src/dtos/server.dto'; import { VersionCheckMetadata } from 'src/entities/system-metadata.entity'; -import { ImmichEnvironment, SystemMetadataKey } from 'src/enum'; -import { JobName, JobStatus, QueueName } from 'src/interfaces/job.interface'; -import { DatabaseLock } from 'src/repositories/database.repository'; +import { DatabaseLock, ImmichEnvironment, JobName, JobStatus, QueueName, SystemMetadataKey } from 'src/enum'; import { ArgOf } from 'src/repositories/event.repository'; import { BaseService } from 'src/services/base.service'; diff --git a/server/src/services/view.service.ts b/server/src/services/view.service.ts index f1ef40a810..5871b04b32 100644 --- a/server/src/services/view.service.ts +++ b/server/src/services/view.service.ts @@ -1,8 +1,10 @@ +import { Injectable } from '@nestjs/common'; import { AssetResponseDto, mapAsset } from 'src/dtos/asset-response.dto'; import { AuthDto } from 'src/dtos/auth.dto'; import { AssetEntity } from 'src/entities/asset.entity'; import { BaseService } from 'src/services/base.service'; +@Injectable() export class ViewService extends BaseService { getUniqueOriginalPaths(auth: AuthDto): Promise { return this.viewRepository.getUniqueOriginalPaths(auth.user.id); diff --git a/server/src/types.ts b/server/src/types.ts index e0523333d8..47a6d20797 100644 --- a/server/src/types.ts +++ b/server/src/types.ts @@ -1,5 +1,14 @@ import { UserEntity } from 'src/entities/user.entity'; -import { ExifOrientation, ImageFormat, Permission, TranscodeTarget, VideoCodec } from 'src/enum'; +import { + DatabaseExtension, + ExifOrientation, + ImageFormat, + JobName, + Permission, + QueueName, + TranscodeTarget, + VideoCodec, +} from 'src/enum'; import { ActivityRepository } from 'src/repositories/activity.repository'; import { ApiKeyRepository } from 'src/repositories/api-key.repository'; import { MemoryRepository } from 'src/repositories/memory.repository'; @@ -167,3 +176,245 @@ export interface VideoInterfaces { dri: string[]; mali: boolean; } + +export type ConcurrentQueueName = Exclude< + QueueName, + | QueueName.STORAGE_TEMPLATE_MIGRATION + | QueueName.FACIAL_RECOGNITION + | QueueName.DUPLICATE_DETECTION + | QueueName.BACKUP_DATABASE +>; + +export type Jobs = { [K in JobItem['name']]: (JobItem & { name: K })['data'] }; +export type JobOf = Jobs[T]; + +export interface IBaseJob { + force?: boolean; +} + +export interface IDelayedJob extends IBaseJob { + /** The minimum time to wait to execute this job, in milliseconds. */ + delay?: number; +} + +export interface IEntityJob extends IBaseJob { + id: string; + source?: 'upload' | 'sidecar-write' | 'copy'; + notify?: boolean; +} + +export interface IAssetDeleteJob extends IEntityJob { + deleteOnDisk: boolean; +} + +export interface ILibraryFileJob extends IEntityJob { + ownerId: string; + assetPath: string; +} + +export interface ILibraryAssetJob extends IEntityJob { + importPaths: string[]; + exclusionPatterns: string[]; +} + +export interface IBulkEntityJob extends IBaseJob { + ids: string[]; +} + +export interface IDeleteFilesJob extends IBaseJob { + files: Array; +} + +export interface ISidecarWriteJob extends IEntityJob { + description?: string; + dateTimeOriginal?: string; + latitude?: number; + longitude?: number; + rating?: number; + tags?: true; +} + +export interface IDeferrableJob extends IEntityJob { + deferred?: boolean; +} + +export interface INightlyJob extends IBaseJob { + nightly?: boolean; +} + +export type EmailImageAttachment = { + filename: string; + path: string; + cid: string; +}; + +export interface IEmailJob { + to: string; + subject: string; + html: string; + text: string; + imageAttachments?: EmailImageAttachment[]; +} + +export interface INotifySignupJob extends IEntityJob { + tempPassword?: string; +} + +export interface INotifyAlbumInviteJob extends IEntityJob { + recipientId: string; +} + +export interface INotifyAlbumUpdateJob extends IEntityJob, IDelayedJob { + recipientIds: string[]; +} + +export interface JobCounts { + active: number; + completed: number; + failed: number; + delayed: number; + waiting: number; + paused: number; +} + +export interface QueueStatus { + isActive: boolean; + isPaused: boolean; +} + +export type JobItem = + // Backups + | { name: JobName.BACKUP_DATABASE; data?: IBaseJob } + + // Transcoding + | { name: JobName.QUEUE_VIDEO_CONVERSION; data: IBaseJob } + | { name: JobName.VIDEO_CONVERSION; data: IEntityJob } + + // Thumbnails + | { name: JobName.QUEUE_GENERATE_THUMBNAILS; data: IBaseJob } + | { name: JobName.GENERATE_THUMBNAILS; data: IEntityJob } + + // User + | { name: JobName.USER_DELETE_CHECK; data?: IBaseJob } + | { name: JobName.USER_DELETION; data: IEntityJob } + | { name: JobName.USER_SYNC_USAGE; data?: IBaseJob } + + // Storage Template + | { name: JobName.STORAGE_TEMPLATE_MIGRATION; data?: IBaseJob } + | { name: JobName.STORAGE_TEMPLATE_MIGRATION_SINGLE; data: IEntityJob } + + // Migration + | { name: JobName.QUEUE_MIGRATION; data?: IBaseJob } + | { name: JobName.MIGRATE_ASSET; data: IEntityJob } + | { name: JobName.MIGRATE_PERSON; data: IEntityJob } + + // Metadata Extraction + | { name: JobName.QUEUE_METADATA_EXTRACTION; data: IBaseJob } + | { name: JobName.METADATA_EXTRACTION; data: IEntityJob } + | { name: JobName.LINK_LIVE_PHOTOS; data: IEntityJob } + // Sidecar Scanning + | { name: JobName.QUEUE_SIDECAR; data: IBaseJob } + | { name: JobName.SIDECAR_DISCOVERY; data: IEntityJob } + | { name: JobName.SIDECAR_SYNC; data: IEntityJob } + | { name: JobName.SIDECAR_WRITE; data: ISidecarWriteJob } + + // Facial Recognition + | { name: JobName.QUEUE_FACE_DETECTION; data: IBaseJob } + | { name: JobName.FACE_DETECTION; data: IEntityJob } + | { name: JobName.QUEUE_FACIAL_RECOGNITION; data: INightlyJob } + | { name: JobName.FACIAL_RECOGNITION; data: IDeferrableJob } + | { name: JobName.GENERATE_PERSON_THUMBNAIL; data: IEntityJob } + + // Smart Search + | { name: JobName.QUEUE_SMART_SEARCH; data: IBaseJob } + | { name: JobName.SMART_SEARCH; data: IEntityJob } + | { name: JobName.QUEUE_TRASH_EMPTY; data?: IBaseJob } + + // Duplicate Detection + | { name: JobName.QUEUE_DUPLICATE_DETECTION; data: IBaseJob } + | { name: JobName.DUPLICATE_DETECTION; data: IEntityJob } + + // Filesystem + | { name: JobName.DELETE_FILES; data: IDeleteFilesJob } + + // Cleanup + | { name: JobName.CLEAN_OLD_AUDIT_LOGS; data?: IBaseJob } + | { name: JobName.CLEAN_OLD_SESSION_TOKENS; data?: IBaseJob } + + // Tags + | { name: JobName.TAG_CLEANUP; data?: IBaseJob } + + // Asset Deletion + | { name: JobName.PERSON_CLEANUP; data?: IBaseJob } + | { name: JobName.ASSET_DELETION; data: IAssetDeleteJob } + | { name: JobName.ASSET_DELETION_CHECK; data?: IBaseJob } + + // Library Management + | { name: JobName.LIBRARY_SYNC_FILE; data: ILibraryFileJob } + | { name: JobName.LIBRARY_QUEUE_SYNC_FILES; data: IEntityJob } + | { name: JobName.LIBRARY_QUEUE_SYNC_ASSETS; data: IEntityJob } + | { name: JobName.LIBRARY_SYNC_ASSET; data: ILibraryAssetJob } + | { name: JobName.LIBRARY_DELETE; data: IEntityJob } + | { name: JobName.LIBRARY_QUEUE_SYNC_ALL; data?: IBaseJob } + | { name: JobName.LIBRARY_QUEUE_CLEANUP; data: IBaseJob } + + // Notification + | { name: JobName.SEND_EMAIL; data: IEmailJob } + | { name: JobName.NOTIFY_ALBUM_INVITE; data: INotifyAlbumInviteJob } + | { name: JobName.NOTIFY_ALBUM_UPDATE; data: INotifyAlbumUpdateJob } + | { name: JobName.NOTIFY_SIGNUP; data: INotifySignupJob } + + // Version check + | { name: JobName.VERSION_CHECK; data: IBaseJob }; + +export type VectorExtension = DatabaseExtension.VECTOR | DatabaseExtension.VECTORS; + +export type DatabaseConnectionURL = { + connectionType: 'url'; + url: string; +}; + +export type DatabaseConnectionParts = { + connectionType: 'parts'; + host: string; + port: number; + username: string; + password: string; + database: string; +}; + +export type DatabaseConnectionParams = DatabaseConnectionURL | DatabaseConnectionParts; + +export interface ExtensionVersion { + availableVersion: string | null; + installedVersion: string | null; +} + +export interface VectorUpdateResult { + restartRequired: boolean; +} + +export interface ImmichFile extends Express.Multer.File { + /** sha1 hash of file */ + uuid: string; + checksum: Buffer; +} + +export interface UploadFile { + uuid: string; + checksum: Buffer; + originalPath: string; + originalName: string; + size: number; +} + +export interface UploadFiles { + assetData: ImmichFile[]; + sidecarData: ImmichFile[]; +} + +export interface IBulkAsset { + getAssetIds: (id: string, assetIds: string[]) => Promise>; + addAssetIds: (id: string, assetIds: string[]) => Promise; + removeAssetIds: (id: string, assetIds: string[]) => Promise; +} diff --git a/server/src/utils/asset.util.ts b/server/src/utils/asset.util.ts index 5183bb2164..de64720a82 100644 --- a/server/src/utils/asset.util.ts +++ b/server/src/utils/asset.util.ts @@ -6,20 +6,13 @@ import { AuthDto } from 'src/dtos/auth.dto'; import { AssetFileEntity } from 'src/entities/asset-files.entity'; import { AssetFileType, AssetType, Permission } from 'src/enum'; import { AuthRequest } from 'src/middleware/auth.guard'; -import { ImmichFile } from 'src/middleware/file-upload.interceptor'; import { AccessRepository } from 'src/repositories/access.repository'; import { AssetRepository } from 'src/repositories/asset.repository'; import { EventRepository } from 'src/repositories/event.repository'; import { PartnerRepository } from 'src/repositories/partner.repository'; -import { UploadFile } from 'src/services/asset-media.service'; +import { IBulkAsset, ImmichFile, UploadFile } from 'src/types'; import { checkAccess } from 'src/utils/access'; -export interface IBulkAsset { - getAssetIds: (id: string, assetIds: string[]) => Promise>; - addAssetIds: (id: string, assetIds: string[]) => Promise; - removeAssetIds: (id: string, assetIds: string[]) => Promise; -} - const getFileByType = (files: AssetFileEntity[] | undefined, type: AssetFileType) => { return (files || []).find((file) => file.type === type); }; diff --git a/server/src/utils/config.ts b/server/src/utils/config.ts index 30f965c37f..4dee1c348e 100644 --- a/server/src/utils/config.ts +++ b/server/src/utils/config.ts @@ -5,9 +5,8 @@ import { load as loadYaml } from 'js-yaml'; import * as _ from 'lodash'; import { SystemConfig, defaults } from 'src/config'; import { SystemConfigDto } from 'src/dtos/system-config.dto'; -import { SystemMetadataKey } from 'src/enum'; +import { DatabaseLock, SystemMetadataKey } from 'src/enum'; import { ConfigRepository } from 'src/repositories/config.repository'; -import { DatabaseLock } from 'src/repositories/database.repository'; import { LoggingRepository } from 'src/repositories/logging.repository'; import { SystemMetadataRepository } from 'src/repositories/system-metadata.repository'; import { DeepPartial } from 'src/types'; diff --git a/server/test/repositories/job.repository.mock.ts b/server/test/repositories/job.repository.mock.ts index e9557af59b..f0f4fdda00 100644 --- a/server/test/repositories/job.repository.mock.ts +++ b/server/test/repositories/job.repository.mock.ts @@ -1,7 +1,8 @@ -import { IJobRepository } from 'src/interfaces/job.interface'; +import { JobRepository } from 'src/repositories/job.repository'; +import { RepositoryInterface } from 'src/types'; import { Mocked, vitest } from 'vitest'; -export const newJobRepositoryMock = (): Mocked => { +export const newJobRepositoryMock = (): Mocked> => { return { setup: vitest.fn(), startWorkers: vitest.fn(), diff --git a/server/test/utils.ts b/server/test/utils.ts index fbff7ba00d..d1dda3eedf 100644 --- a/server/test/utils.ts +++ b/server/test/utils.ts @@ -194,12 +194,12 @@ export const newTestService = ( albumMock as RepositoryInterface as AlbumRepository, albumUserMock as RepositoryInterface as AlbumUserRepository, assetMock as RepositoryInterface as AssetRepository, - configMock, + configMock as RepositoryInterface as ConfigRepository, cronMock as RepositoryInterface as CronRepository, cryptoMock as RepositoryInterface as CryptoRepository, databaseMock as RepositoryInterface as DatabaseRepository, eventMock as RepositoryInterface as EventRepository, - jobMock, + jobMock as RepositoryInterface as JobRepository, apiKeyMock as RepositoryInterface as ApiKeyRepository, libraryMock as RepositoryInterface as LibraryRepository, machineLearningMock as RepositoryInterface as MachineLearningRepository, From 703361da1a290c61fe3206dae84d0d16b2a3642d Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 11 Feb 2025 17:24:39 -0600 Subject: [PATCH 126/395] chore(deps): update dependency svelte to v5.19.9 (#16043) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- web/package-lock.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/web/package-lock.json b/web/package-lock.json index 276fcb7b98..1ac9763ffe 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -7338,9 +7338,9 @@ } }, "node_modules/svelte": { - "version": "5.19.8", - "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.19.8.tgz", - "integrity": "sha512-56Vd/nwJrljV0w7RCV1A8sB4/yjSbWW5qrGDTAzp7q42OxwqEWT+6obWzDt41tHjIW+C9Fs2ygtejjJrXR+ZPA==", + "version": "5.19.9", + "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.19.9.tgz", + "integrity": "sha512-860s752/ZZxHIsii31ELkdKBOCeAuDsfb/AGUXJyQyzUVLRSt4oqEw/BV5+2+mNg8mbqmD3OK+vMvwWMPM6f8A==", "license": "MIT", "dependencies": { "@ampproject/remapping": "^2.3.0", From 7c821dd205dd4695e73c468ef0055b20b7622ac3 Mon Sep 17 00:00:00 2001 From: Yaros Date: Wed, 12 Feb 2025 15:56:50 +0100 Subject: [PATCH 127/395] feat(mobile): Made Map Bottom Sheet extendable higher (#16056) Made Map Bottom Sheet extendable higher --- mobile/lib/widgets/map/map_bottom_sheet.dart | 32 ++++++++++++-------- 1 file changed, 19 insertions(+), 13 deletions(-) diff --git a/mobile/lib/widgets/map/map_bottom_sheet.dart b/mobile/lib/widgets/map/map_bottom_sheet.dart index d52a426469..0249ca70dc 100644 --- a/mobile/lib/widgets/map/map_bottom_sheet.dart +++ b/mobile/lib/widgets/map/map_bottom_sheet.dart @@ -59,9 +59,10 @@ class MapBottomSheet extends HookConsumerWidget { child: DraggableScrollableSheet( controller: sheetController, minChildSize: sheetMinExtent, - maxChildSize: 0.5, + maxChildSize: 0.8, initialChildSize: sheetMinExtent, snap: true, + snapSizes: [sheetMinExtent, 0.5, 0.8], shouldCloseOnMinExtent: false, builder: (ctx, scrollController) => MapAssetGrid( controller: scrollController, @@ -78,18 +79,23 @@ class MapBottomSheet extends HookConsumerWidget { ), ValueListenableBuilder( valueListenable: bottomSheetOffset, - builder: (ctx, value, child) => Positioned( - right: 0, - bottom: context.height * (value + 0.02), - child: child!, - ), - child: ElevatedButton( - onPressed: onZoomToLocation, - style: ElevatedButton.styleFrom( - shape: const CircleBorder(), - ), - child: const Icon(Icons.my_location), - ), + builder: (context, value, child) { + return Positioned( + right: 0, + bottom: context.height * (value + 0.02), + child: AnimatedOpacity( + opacity: value < 0.8 ? 1 : 0, + duration: const Duration(milliseconds: 150), + child: ElevatedButton( + onPressed: onZoomToLocation, + style: ElevatedButton.styleFrom( + shape: const CircleBorder(), + ), + child: const Icon(Icons.my_location), + ), + ), + ); + }, ), ], ); From 2d7c333c8ca5ac7415c8900f3f88e942406cd889 Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Wed, 12 Feb 2025 15:23:08 -0500 Subject: [PATCH 128/395] refactor(server): narrow auth types (#16066) --- server/src/controllers/user.controller.ts | 6 +- server/src/database.ts | 50 ++++++++++++++ server/src/db.d.ts | 26 ++++---- server/src/dtos/auth.dto.ts | 10 ++- server/src/dtos/user.dto.ts | 2 +- server/src/entities/user-metadata.entity.ts | 9 ++- server/src/queries/api.key.repository.sql | 33 +++++----- server/src/queries/session.repository.sql | 48 +++++--------- server/src/queries/shared.link.repository.sql | 24 ++++--- server/src/repositories/api-key.repository.ts | 34 +++------- server/src/repositories/session.repository.ts | 15 ++++- .../repositories/shared-link.repository.ts | 34 +++------- server/src/repositories/user.repository.ts | 12 +++- server/src/services/auth.service.ts | 24 +++---- server/src/services/download.service.ts | 3 +- server/src/services/notification.service.ts | 4 +- server/src/services/user-admin.service.ts | 17 +++-- server/src/services/user.service.spec.ts | 4 +- server/src/services/user.service.ts | 37 +++++++---- server/src/types.ts | 9 --- server/src/utils/access.ts | 4 +- server/src/utils/preferences.ts | 16 ++--- server/test/fixtures/auth.stub.ts | 66 ++++++++----------- server/test/fixtures/user.stub.ts | 16 ++++- .../test/repositories/user.repository.mock.ts | 1 + 25 files changed, 265 insertions(+), 239 deletions(-) diff --git a/server/src/controllers/user.controller.ts b/server/src/controllers/user.controller.ts index 9dbaa00d81..f1bdf160d3 100644 --- a/server/src/controllers/user.controller.ts +++ b/server/src/controllers/user.controller.ts @@ -44,7 +44,7 @@ export class UserController { @Get('me') @Authenticated() - getMyUser(@Auth() auth: AuthDto): UserAdminResponseDto { + getMyUser(@Auth() auth: AuthDto): Promise { return this.service.getMe(auth); } @@ -56,7 +56,7 @@ export class UserController { @Get('me/preferences') @Authenticated() - getMyPreferences(@Auth() auth: AuthDto): UserPreferencesResponseDto { + getMyPreferences(@Auth() auth: AuthDto): Promise { return this.service.getMyPreferences(auth); } @@ -71,7 +71,7 @@ export class UserController { @Get('me/license') @Authenticated() - getUserLicense(@Auth() auth: AuthDto): LicenseResponseDto { + getUserLicense(@Auth() auth: AuthDto): Promise { return this.service.getLicense(auth); } diff --git a/server/src/database.ts b/server/src/database.ts index fce9ede561..4fcab0fd6d 100644 --- a/server/src/database.ts +++ b/server/src/database.ts @@ -1,3 +1,53 @@ +import { Permission } from 'src/enum'; + +export type AuthUser = { + id: string; + isAdmin: boolean; + name: string; + email: string; + quotaUsageInBytes: number; + quotaSizeInBytes: number | null; +}; + +export type AuthApiKey = { + id: string; + permissions: Permission[]; +}; + +export type AuthSharedLink = { + id: string; + expiresAt: Date | null; + userId: string; + showExif: boolean; + allowUpload: boolean; + allowDownload: boolean; + password: string | null; +}; + +export type AuthSession = { + id: string; +}; + export const columns = { + authUser: [ + 'users.id', + 'users.name', + 'users.email', + 'users.isAdmin', + 'users.quotaUsageInBytes', + 'users.quotaSizeInBytes', + ], + authApiKey: ['api_keys.id', 'api_keys.permissions'], + authSession: ['sessions.id', 'sessions.updatedAt'], + authSharedLink: [ + 'shared_links.id', + 'shared_links.userId', + 'shared_links.expiresAt', + 'shared_links.showExif', + 'shared_links.allowUpload', + 'shared_links.allowDownload', + 'shared_links.password', + ], userDto: ['id', 'name', 'email', 'profileImagePath', 'profileChangedAt'], + apiKey: ['id', 'name', 'userId', 'createdAt', 'updatedAt', 'permissions'], } as const; diff --git a/server/src/db.d.ts b/server/src/db.d.ts index 2bffe2ba5f..648ccf4bcf 100644 --- a/server/src/db.d.ts +++ b/server/src/db.d.ts @@ -3,23 +3,19 @@ * Please do not edit it manually. */ -import type { ColumnType } from "kysely"; +import type { ColumnType } from 'kysely'; +import { Permission } from 'src/enum'; -export type ArrayType = ArrayTypeImpl extends (infer U)[] - ? U[] - : ArrayTypeImpl; +export type ArrayType = ArrayTypeImpl extends (infer U)[] ? U[] : ArrayTypeImpl; -export type ArrayTypeImpl = T extends ColumnType - ? ColumnType - : T[]; +export type ArrayTypeImpl = T extends ColumnType ? ColumnType : T[]; -export type AssetsStatusEnum = "active" | "deleted" | "trashed"; +export type AssetsStatusEnum = 'active' | 'deleted' | 'trashed'; -export type Generated = T extends ColumnType - ? ColumnType - : ColumnType; +export type Generated = + T extends ColumnType ? ColumnType : ColumnType; -export type Int8 = ColumnType; +export type Int8 = ColumnType; export type Json = JsonValue; @@ -33,7 +29,7 @@ export type JsonPrimitive = boolean | number | string | null; export type JsonValue = JsonArray | JsonObject | JsonPrimitive; -export type Sourcetype = "exif" | "machine-learning"; +export type Sourcetype = 'exif' | 'machine-learning'; export type Timestamp = ColumnType; @@ -81,7 +77,7 @@ export interface ApiKeys { id: Generated; key: string; name: string; - permissions: string[]; + permissions: Permission[]; updatedAt: Generated; userId: string; } @@ -444,6 +440,6 @@ export interface DB { typeorm_metadata: TypeormMetadata; user_metadata: UserMetadata; users: Users; - "vectors.pg_vector_index_stat": VectorsPgVectorIndexStat; + 'vectors.pg_vector_index_stat': VectorsPgVectorIndexStat; version_history: VersionHistory; } diff --git a/server/src/dtos/auth.dto.ts b/server/src/dtos/auth.dto.ts index d6b73f584a..334b7a49b5 100644 --- a/server/src/dtos/auth.dto.ts +++ b/server/src/dtos/auth.dto.ts @@ -1,11 +1,9 @@ import { ApiProperty } from '@nestjs/swagger'; import { Transform } from 'class-transformer'; import { IsEmail, IsNotEmpty, IsString, MinLength } from 'class-validator'; -import { SessionEntity } from 'src/entities/session.entity'; -import { SharedLinkEntity } from 'src/entities/shared-link.entity'; +import { AuthApiKey, AuthSession, AuthSharedLink, AuthUser } from 'src/database'; import { UserEntity } from 'src/entities/user.entity'; import { ImmichCookie } from 'src/enum'; -import { AuthApiKey } from 'src/types'; import { toEmail } from 'src/validation'; export type CookieResponse = { @@ -14,11 +12,11 @@ export type CookieResponse = { }; export class AuthDto { - user!: UserEntity; + user!: AuthUser; apiKey?: AuthApiKey; - sharedLink?: SharedLinkEntity; - session?: SessionEntity; + sharedLink?: AuthSharedLink; + session?: AuthSession; } export class LoginCredentialDto { diff --git a/server/src/dtos/user.dto.ts b/server/src/dtos/user.dto.ts index 593a7934bc..a169784ebb 100644 --- a/server/src/dtos/user.dto.ts +++ b/server/src/dtos/user.dto.ts @@ -47,7 +47,7 @@ export const mapUser = (entity: UserEntity): UserResponseDto => { email: entity.email, name: entity.name, profileImagePath: entity.profileImagePath, - avatarColor: getPreferences(entity).avatar.color, + avatarColor: getPreferences(entity.email, entity.metadata || []).avatar.color, profileChangedAt: entity.profileChangedAt, }; }; diff --git a/server/src/entities/user-metadata.entity.ts b/server/src/entities/user-metadata.entity.ts index 65c187883a..8282443e0e 100644 --- a/server/src/entities/user-metadata.entity.ts +++ b/server/src/entities/user-metadata.entity.ts @@ -4,13 +4,18 @@ import { DeepPartial } from 'src/types'; import { HumanReadableSize } from 'src/utils/bytes'; import { Column, Entity, ManyToOne, PrimaryColumn } from 'typeorm'; +export type UserMetadataItem = { + key: T; + value: UserMetadata[T]; +}; + @Entity('user_metadata') -export class UserMetadataEntity { +export class UserMetadataEntity implements UserMetadataItem { @PrimaryColumn({ type: 'uuid' }) userId!: string; @ManyToOne(() => UserEntity, (user) => user.metadata, { onUpdate: 'CASCADE', onDelete: 'CASCADE' }) - user!: UserEntity; + user?: UserEntity; @PrimaryColumn({ type: 'varchar' }) key!: T; diff --git a/server/src/queries/api.key.repository.sql b/server/src/queries/api.key.repository.sql index e1ed8a3dd6..35fd5d2821 100644 --- a/server/src/queries/api.key.repository.sql +++ b/server/src/queries/api.key.repository.sql @@ -3,29 +3,28 @@ -- ApiKeyRepository.getKey select "api_keys"."id", - "api_keys"."key", - "api_keys"."userId", "api_keys"."permissions", - to_json("user") as "user" -from - "api_keys" - inner join lateral ( + ( select - "users".*, + to_json(obj) + from ( select - array_agg("user_metadata") as "metadata" + "users"."id", + "users"."name", + "users"."email", + "users"."isAdmin", + "users"."quotaUsageInBytes", + "users"."quotaSizeInBytes" from - "user_metadata" + "users" where - "users"."id" = "user_metadata"."userId" - ) as "metadata" - from - "users" - where - "users"."id" = "api_keys"."userId" - and "users"."deletedAt" is null - ) as "user" on true + "users"."id" = "api_keys"."userId" + and "users"."deletedAt" is null + ) as obj + ) as "user" +from + "api_keys" where "api_keys"."key" = $1 diff --git a/server/src/queries/session.repository.sql b/server/src/queries/session.repository.sql index b928195e72..3d115615fd 100644 --- a/server/src/queries/session.repository.sql +++ b/server/src/queries/session.repository.sql @@ -10,41 +10,29 @@ where -- SessionRepository.getByToken select - "sessions".*, - to_json("user") as "user" -from - "sessions" - inner join lateral ( + "sessions"."id", + "sessions"."updatedAt", + ( select - "id", - "email", - "createdAt", - "profileImagePath", - "isAdmin", - "shouldChangePassword", - "deletedAt", - "oauthId", - "updatedAt", - "storageLabel", - "name", - "quotaSizeInBytes", - "quotaUsageInBytes", - "status", - "profileChangedAt", + to_json(obj) + from ( select - array_agg("user_metadata") as "metadata" + "users"."id", + "users"."name", + "users"."email", + "users"."isAdmin", + "users"."quotaUsageInBytes", + "users"."quotaSizeInBytes" from - "user_metadata" + "users" where - "users"."id" = "user_metadata"."userId" - ) as "metadata" - from - "users" - where - "users"."id" = "sessions"."userId" - and "users"."deletedAt" is null - ) as "user" on true + "users"."id" = "sessions"."userId" + and "users"."deletedAt" is null + ) as obj + ) as "user" +from + "sessions" where "sessions"."token" = $1 diff --git a/server/src/queries/shared.link.repository.sql b/server/src/queries/shared.link.repository.sql index e1f6af3383..641996e2f4 100644 --- a/server/src/queries/shared.link.repository.sql +++ b/server/src/queries/shared.link.repository.sql @@ -153,12 +153,19 @@ where "shared_links"."type" = $2 or "album"."id" is not null ) + and "shared_links"."albumId" = $3 order by "shared_links"."createdAt" desc -- SharedLinkRepository.getByKey select - "shared_links".*, + "shared_links"."id", + "shared_links"."userId", + "shared_links"."expiresAt", + "shared_links"."showExif", + "shared_links"."allowUpload", + "shared_links"."allowDownload", + "shared_links"."password", ( select to_json(obj) @@ -166,20 +173,11 @@ select ( select "users"."id", - "users"."email", - "users"."createdAt", - "users"."profileImagePath", - "users"."isAdmin", - "users"."shouldChangePassword", - "users"."deletedAt", - "users"."oauthId", - "users"."updatedAt", - "users"."storageLabel", "users"."name", - "users"."quotaSizeInBytes", + "users"."email", + "users"."isAdmin", "users"."quotaUsageInBytes", - "users"."status", - "users"."profileChangedAt" + "users"."quotaSizeInBytes" from "users" where diff --git a/server/src/repositories/api-key.repository.ts b/server/src/repositories/api-key.repository.ts index 5422ad569e..4ed463365b 100644 --- a/server/src/repositories/api-key.repository.ts +++ b/server/src/repositories/api-key.repository.ts @@ -1,12 +1,12 @@ import { Injectable } from '@nestjs/common'; import { Insertable, Kysely, Updateable } from 'kysely'; +import { jsonObjectFrom } from 'kysely/helpers/postgres'; import { InjectKysely } from 'nestjs-kysely'; +import { columns } from 'src/database'; import { ApiKeys, DB } from 'src/db'; import { DummyValue, GenerateSql } from 'src/decorators'; import { asUuid } from 'src/utils/database'; -const columns = ['id', 'name', 'userId', 'createdAt', 'updatedAt', 'permissions'] as const; - @Injectable() export class ApiKeyRepository { constructor(@InjectKysely() private db: Kysely) {} @@ -33,29 +33,15 @@ export class ApiKeyRepository { getKey(hashedToken: string) { return this.db .selectFrom('api_keys') - .innerJoinLateral( - (eb) => + .select((eb) => [ + ...columns.authApiKey, + jsonObjectFrom( eb .selectFrom('users') - .selectAll('users') - .select((eb) => - eb - .selectFrom('user_metadata') - .whereRef('users.id', '=', 'user_metadata.userId') - .select((eb) => eb.fn('array_agg', [eb.table('user_metadata')]).as('metadata')) - .as('metadata'), - ) + .select(columns.authUser) .whereRef('users.id', '=', 'api_keys.userId') - .where('users.deletedAt', 'is', null) - .as('user'), - (join) => join.onTrue(), - ) - .select((eb) => [ - 'api_keys.id', - 'api_keys.key', - 'api_keys.userId', - 'api_keys.permissions', - eb.fn.toJson('user').as('user'), + .where('users.deletedAt', 'is', null), + ).as('user'), ]) .where('api_keys.key', '=', hashedToken) .executeTakeFirst(); @@ -65,7 +51,7 @@ export class ApiKeyRepository { getById(userId: string, id: string) { return this.db .selectFrom('api_keys') - .select(columns) + .select(columns.apiKey) .where('id', '=', asUuid(id)) .where('userId', '=', userId) .executeTakeFirst(); @@ -75,7 +61,7 @@ export class ApiKeyRepository { getByUserId(userId: string) { return this.db .selectFrom('api_keys') - .select(columns) + .select(columns.apiKey) .where('userId', '=', userId) .orderBy('createdAt', 'desc') .execute(); diff --git a/server/src/repositories/session.repository.ts b/server/src/repositories/session.repository.ts index 3e490bdc84..85ea5f890e 100644 --- a/server/src/repositories/session.repository.ts +++ b/server/src/repositories/session.repository.ts @@ -1,6 +1,8 @@ import { Injectable } from '@nestjs/common'; import { Insertable, Kysely, Updateable } from 'kysely'; +import { jsonObjectFrom } from 'kysely/helpers/postgres'; import { InjectKysely } from 'nestjs-kysely'; +import { columns } from 'src/database'; import { DB, Sessions } from 'src/db'; import { DummyValue, GenerateSql } from 'src/decorators'; import { withUser } from 'src/entities/session.entity'; @@ -25,9 +27,16 @@ export class SessionRepository { getByToken(token: string) { return this.db .selectFrom('sessions') - .innerJoinLateral(withUser, (join) => join.onTrue()) - .selectAll('sessions') - .select((eb) => eb.fn.toJson('user').as('user')) + .select((eb) => [ + ...columns.authSession, + jsonObjectFrom( + eb + .selectFrom('users') + .select(columns.authUser) + .whereRef('users.id', '=', 'sessions.userId') + .where('users.deletedAt', 'is', null), + ).as('user'), + ]) .where('sessions.token', '=', token) .executeTakeFirst(); } diff --git a/server/src/repositories/shared-link.repository.ts b/server/src/repositories/shared-link.repository.ts index 16dc48836a..52b5b7a2fe 100644 --- a/server/src/repositories/shared-link.repository.ts +++ b/server/src/repositories/shared-link.repository.ts @@ -3,6 +3,7 @@ import { Insertable, Kysely, sql, Updateable } from 'kysely'; import { jsonObjectFrom } from 'kysely/helpers/postgres'; import _ from 'lodash'; import { InjectKysely } from 'nestjs-kysely'; +import { columns } from 'src/database'; import { DB, SharedLinks } from 'src/db'; import { DummyValue, GenerateSql } from 'src/decorators'; import { SharedLinkEntity } from 'src/entities/shared-link.entity'; @@ -96,7 +97,7 @@ export class SharedLinkRepository { .executeTakeFirst() as Promise; } - @GenerateSql({ params: [DummyValue.UUID] }) + @GenerateSql({ params: [{ userId: DummyValue.UUID, albumId: DummyValue.UUID }] }) getAll({ userId, albumId }: SharedLinkSearchOptions): Promise { return this.db .selectFrom('shared_links') @@ -160,39 +161,20 @@ export class SharedLinkRepository { } @GenerateSql({ params: [DummyValue.BUFFER] }) - async getByKey(key: Buffer): Promise { + async getByKey(key: Buffer) { return this.db .selectFrom('shared_links') - .selectAll('shared_links') .where('shared_links.key', '=', key) .leftJoin('albums', 'albums.id', 'shared_links.albumId') .where('albums.deletedAt', 'is', null) - .select((eb) => + .select((eb) => [ + ...columns.authSharedLink, jsonObjectFrom( - eb - .selectFrom('users') - .select([ - 'users.id', - 'users.email', - 'users.createdAt', - 'users.profileImagePath', - 'users.isAdmin', - 'users.shouldChangePassword', - 'users.deletedAt', - 'users.oauthId', - 'users.updatedAt', - 'users.storageLabel', - 'users.name', - 'users.quotaSizeInBytes', - 'users.quotaUsageInBytes', - 'users.status', - 'users.profileChangedAt', - ]) - .whereRef('users.id', '=', 'shared_links.userId'), + eb.selectFrom('users').select(columns.authUser).whereRef('users.id', '=', 'shared_links.userId'), ).as('user'), - ) + ]) .where((eb) => eb.or([eb('shared_links.type', '=', SharedLinkType.INDIVIDUAL), eb('albums.id', 'is not', null)])) - .executeTakeFirst() as Promise; + .executeTakeFirst(); } async create(entity: Insertable & { assetIds?: string[] }): Promise { diff --git a/server/src/repositories/user.repository.ts b/server/src/repositories/user.repository.ts index 22a9ecad5c..fccd127378 100644 --- a/server/src/repositories/user.repository.ts +++ b/server/src/repositories/user.repository.ts @@ -3,7 +3,7 @@ import { Insertable, Kysely, sql, Updateable } from 'kysely'; import { InjectKysely } from 'nestjs-kysely'; import { DB, UserMetadata as DbUserMetadata, Users } from 'src/db'; import { DummyValue, GenerateSql } from 'src/decorators'; -import { UserMetadata } from 'src/entities/user-metadata.entity'; +import { UserMetadata, UserMetadataItem } from 'src/entities/user-metadata.entity'; import { UserEntity, withMetadata } from 'src/entities/user.entity'; import { UserStatus } from 'src/enum'; import { asUuid } from 'src/utils/database'; @@ -64,6 +64,14 @@ export class UserRepository { .executeTakeFirst() as Promise; } + getMetadata(userId: string) { + return this.db + .selectFrom('user_metadata') + .select(['key', 'value']) + .where('user_metadata.userId', '=', userId) + .execute() as Promise; + } + @GenerateSql() getAdmin(): Promise { return this.db @@ -263,7 +271,7 @@ export class UserRepository { eb .selectFrom('assets') .leftJoin('exif', 'exif.assetId', 'assets.id') - .select((eb) => eb.fn.coalesce(eb.fn.sum('exif.fileSizeInByte'), eb.lit(0)).as('usage')) + .select((eb) => eb.fn.coalesce(eb.fn.sum('exif.fileSizeInByte'), eb.lit(0)).as('usage')) .where('assets.libraryId', 'is', null) .where('assets.ownerId', '=', eb.ref('users.id')), updatedAt: new Date(), diff --git a/server/src/services/auth.service.ts b/server/src/services/auth.service.ts index f4c6c6249e..35d48cf57e 100644 --- a/server/src/services/auth.service.ts +++ b/server/src/services/auth.service.ts @@ -17,12 +17,10 @@ import { mapLoginResponse, } from 'src/dtos/auth.dto'; import { UserAdminResponseDto, mapUserAdmin } from 'src/dtos/user.dto'; -import { SessionEntity } from 'src/entities/session.entity'; import { UserEntity } from 'src/entities/user.entity'; import { AuthType, ImmichCookie, ImmichHeader, ImmichQuery, Permission } from 'src/enum'; import { OAuthProfile } from 'src/repositories/oauth.repository'; import { BaseService } from 'src/services/base.service'; -import { AuthApiKey } from 'src/types'; import { isGranted } from 'src/utils/access'; import { HumanReadableSize } from 'src/utils/bytes'; @@ -298,11 +296,11 @@ export class AuthService extends BaseService { const bytes = Buffer.from(key, key.length === 100 ? 'hex' : 'base64url'); const sharedLink = await this.sharedLinkRepository.getByKey(bytes); - if (sharedLink && (!sharedLink.expiresAt || new Date(sharedLink.expiresAt) > new Date())) { - const user = sharedLink.user; - if (user) { - return { user, sharedLink }; - } + if (sharedLink?.user && (!sharedLink.expiresAt || new Date(sharedLink.expiresAt) > new Date())) { + return { + user: sharedLink.user, + sharedLink, + }; } throw new UnauthorizedException('Invalid share key'); } @@ -310,10 +308,10 @@ export class AuthService extends BaseService { private async validateApiKey(key: string): Promise { const hashedKey = this.cryptoRepository.hashSha256(key); const apiKey = await this.keyRepository.getKey(hashedKey); - if (apiKey) { + if (apiKey?.user) { return { - user: apiKey.user as unknown as UserEntity, - apiKey: apiKey as unknown as AuthApiKey, + user: apiKey.user, + apiKey, }; } @@ -330,7 +328,6 @@ export class AuthService extends BaseService { private async validateSession(tokenValue: string): Promise { const hashedToken = this.cryptoRepository.hashSha256(tokenValue); const session = await this.sessionRepository.getByToken(hashedToken); - if (session?.user) { const now = DateTime.now(); const updatedAt = DateTime.fromJSDate(session.updatedAt); @@ -339,7 +336,10 @@ export class AuthService extends BaseService { await this.sessionRepository.update(session.id, { id: session.id, updatedAt: new Date() }); } - return { user: session.user as unknown as UserEntity, session: session as unknown as SessionEntity }; + return { + user: session.user, + session, + }; } throw new UnauthorizedException('Invalid user token'); diff --git a/server/src/services/download.service.ts b/server/src/services/download.service.ts index dd2430778a..8b18bd0a07 100644 --- a/server/src/services/download.service.ts +++ b/server/src/services/download.service.ts @@ -19,7 +19,8 @@ export class DownloadService extends BaseService { const archives: DownloadArchiveInfo[] = []; let archive: DownloadArchiveInfo = { size: 0, assetIds: [] }; - const preferences = getPreferences(auth.user); + const metadata = await this.userRepository.getMetadata(auth.user.id); + const preferences = getPreferences(auth.user.email, metadata); const assetPagination = await this.getDownloadAssets(auth, dto); for await (const assets of assetPagination) { diff --git a/server/src/services/notification.service.ts b/server/src/services/notification.service.ts index 003b320997..bc6f6b8c2f 100644 --- a/server/src/services/notification.service.ts +++ b/server/src/services/notification.service.ts @@ -276,7 +276,7 @@ export class NotificationService extends BaseService { return JobStatus.SKIPPED; } - const { emailNotifications } = getPreferences(recipient); + const { emailNotifications } = getPreferences(recipient.email, recipient.metadata); if (!emailNotifications.enabled || !emailNotifications.albumInvite) { return JobStatus.SKIPPED; @@ -340,7 +340,7 @@ export class NotificationService extends BaseService { continue; } - const { emailNotifications } = getPreferences(user); + const { emailNotifications } = getPreferences(user.email, user.metadata); if (!emailNotifications.enabled || !emailNotifications.albumUpdate) { continue; diff --git a/server/src/services/user-admin.service.ts b/server/src/services/user-admin.service.ts index 37c3c1e004..0cba749d36 100644 --- a/server/src/services/user-admin.service.ts +++ b/server/src/services/user-admin.service.ts @@ -106,21 +106,24 @@ export class UserAdminService extends BaseService { } async getPreferences(auth: AuthDto, id: string): Promise { - const user = await this.findOrFail(id, { withDeleted: false }); - const preferences = getPreferences(user); + const { email } = await this.findOrFail(id, { withDeleted: true }); + const metadata = await this.userRepository.getMetadata(id); + const preferences = getPreferences(email, metadata); return mapPreferences(preferences); } async updatePreferences(auth: AuthDto, id: string, dto: UserPreferencesUpdateDto) { - const user = await this.findOrFail(id, { withDeleted: false }); - const preferences = mergePreferences(user, dto); + const { email } = await this.findOrFail(id, { withDeleted: false }); + const metadata = await this.userRepository.getMetadata(id); + const preferences = getPreferences(email, metadata); + const newPreferences = mergePreferences(preferences, dto); - await this.userRepository.upsertMetadata(user.id, { + await this.userRepository.upsertMetadata(id, { key: UserMetadataKey.PREFERENCES, - value: getPreferencesPartial(user, preferences), + value: getPreferencesPartial({ email }, newPreferences), }); - return mapPreferences(preferences); + return mapPreferences(newPreferences); } private async findOrFail(id: string, options: UserFindOptions) { diff --git a/server/src/services/user.service.spec.ts b/server/src/services/user.service.spec.ts index 06c928da14..b9fa39a8c2 100644 --- a/server/src/services/user.service.spec.ts +++ b/server/src/services/user.service.spec.ts @@ -77,9 +77,9 @@ describe(UserService.name, () => { }); describe('getMe', () => { - it("should get the auth user's info", () => { + it("should get the auth user's info", async () => { const user = authStub.admin.user; - expect(sut.getMe(authStub.admin)).toMatchObject({ + await expect(sut.getMe(authStub.admin)).resolves.toMatchObject({ id: user.id, email: user.email, }); diff --git a/server/src/services/user.service.ts b/server/src/services/user.service.ts index 3ec6281009..ae6e94031f 100644 --- a/server/src/services/user.service.ts +++ b/server/src/services/user.service.ts @@ -22,16 +22,24 @@ export class UserService extends BaseService { async search(auth: AuthDto): Promise { const config = await this.getConfig({ withCache: false }); - let users: UserEntity[] = [auth.user]; + let users; if (auth.user.isAdmin || config.server.publicUsers) { users = await this.userRepository.getList({ withDeleted: false }); + } else { + const authUser = await this.userRepository.get(auth.user.id, {}); + users = authUser ? [authUser] : []; } return users.map((user) => mapUser(user)); } - getMe(auth: AuthDto): UserAdminResponseDto { - return mapUserAdmin(auth.user); + async getMe(auth: AuthDto): Promise { + const user = await this.userRepository.get(auth.user.id, {}); + if (!user) { + throw new BadRequestException('User not found'); + } + + return mapUserAdmin(user); } async updateMe({ user }: AuthDto, dto: UserUpdateMeDto): Promise { @@ -58,20 +66,23 @@ export class UserService extends BaseService { return mapUserAdmin(updatedUser); } - getMyPreferences({ user }: AuthDto): UserPreferencesResponseDto { - const preferences = getPreferences(user); + async getMyPreferences(auth: AuthDto): Promise { + const metadata = await this.userRepository.getMetadata(auth.user.id); + const preferences = getPreferences(auth.user.email, metadata); return mapPreferences(preferences); } - async updateMyPreferences({ user }: AuthDto, dto: UserPreferencesUpdateDto) { - const preferences = mergePreferences(user, dto); + async updateMyPreferences(auth: AuthDto, dto: UserPreferencesUpdateDto) { + const metadata = await this.userRepository.getMetadata(auth.user.id); + const current = getPreferences(auth.user.email, metadata); + const updated = mergePreferences(current, dto); - await this.userRepository.upsertMetadata(user.id, { + await this.userRepository.upsertMetadata(auth.user.id, { key: UserMetadataKey.PREFERENCES, - value: getPreferencesPartial(user, preferences), + value: getPreferencesPartial(auth.user, updated), }); - return mapPreferences(preferences); + return mapPreferences(updated); } async get(id: string): Promise { @@ -120,8 +131,10 @@ export class UserService extends BaseService { }); } - getLicense({ user }: AuthDto): LicenseResponseDto { - const license = user.metadata.find( + async getLicense(auth: AuthDto): Promise { + const metadata = await this.userRepository.getMetadata(auth.user.id); + + const license = metadata.find( (item): item is UserMetadataEntity => item.key === UserMetadataKey.LICENSE, ); if (!license) { diff --git a/server/src/types.ts b/server/src/types.ts index 47a6d20797..3a331127e6 100644 --- a/server/src/types.ts +++ b/server/src/types.ts @@ -1,10 +1,8 @@ -import { UserEntity } from 'src/entities/user.entity'; import { DatabaseExtension, ExifOrientation, ImageFormat, JobName, - Permission, QueueName, TranscodeTarget, VideoCodec, @@ -16,13 +14,6 @@ import { SessionRepository } from 'src/repositories/session.repository'; export type DeepPartial = T extends object ? { [K in keyof T]?: DeepPartial } : T; -export type AuthApiKey = { - id: string; - key: string; - user: UserEntity; - permissions: Permission[]; -}; - export type RepositoryInterface = Pick; type IActivityRepository = RepositoryInterface; diff --git a/server/src/utils/access.ts b/server/src/utils/access.ts index cb91737349..466d8851e6 100644 --- a/server/src/utils/access.ts +++ b/server/src/utils/access.ts @@ -1,6 +1,6 @@ import { BadRequestException, UnauthorizedException } from '@nestjs/common'; +import { AuthSharedLink } from 'src/database'; import { AuthDto } from 'src/dtos/auth.dto'; -import { SharedLinkEntity } from 'src/entities/shared-link.entity'; import { AlbumUserRole, Permission } from 'src/enum'; import { AccessRepository } from 'src/repositories/access.repository'; import { setDifference, setIsEqual, setIsSuperset, setUnion } from 'src/utils/set'; @@ -24,7 +24,7 @@ export type AccessRequest = { ids: Set | string[]; }; -type SharedLinkAccessRequest = { sharedLink: SharedLinkEntity; permission: Permission; ids: Set }; +type SharedLinkAccessRequest = { sharedLink: AuthSharedLink; permission: Permission; ids: Set }; type OtherAccessRequest = { auth: AuthDto; permission: Permission; ids: Set }; export const requireUploadAccess = (auth: AuthDto | null): AuthDto => { diff --git a/server/src/utils/preferences.ts b/server/src/utils/preferences.ts index ed9b5f2b83..14e61f1919 100644 --- a/server/src/utils/preferences.ts +++ b/server/src/utils/preferences.ts @@ -1,18 +1,13 @@ import _ from 'lodash'; import { UserPreferencesUpdateDto } from 'src/dtos/user-preferences.dto'; -import { UserPreferences, getDefaultPreferences } from 'src/entities/user-metadata.entity'; -import { UserEntity } from 'src/entities/user.entity'; +import { UserMetadataItem, UserPreferences, getDefaultPreferences } from 'src/entities/user-metadata.entity'; import { UserMetadataKey } from 'src/enum'; import { DeepPartial } from 'src/types'; import { getKeysDeep } from 'src/utils/misc'; -export const getPreferences = (user: UserEntity) => { - const preferences = getDefaultPreferences(user); - if (!user.metadata) { - return preferences; - } - - const item = user.metadata.find(({ key }) => key === UserMetadataKey.PREFERENCES); +export const getPreferences = (email: string, metadata: UserMetadataItem[]): UserPreferences => { + const preferences = getDefaultPreferences({ email }); + const item = metadata.find(({ key }) => key === UserMetadataKey.PREFERENCES); const partial = item?.value || {}; for (const property of getKeysDeep(partial)) { _.set(preferences, property, _.get(partial, property)); @@ -40,8 +35,7 @@ export const getPreferencesPartial = (user: { email: string }, newPreferences: U return partial; }; -export const mergePreferences = (user: UserEntity, dto: UserPreferencesUpdateDto) => { - const preferences = getPreferences(user); +export const mergePreferences = (preferences: UserPreferences, dto: UserPreferencesUpdateDto) => { for (const key of getKeysDeep(dto)) { _.set(preferences, key, _.get(dto, key)); } diff --git a/server/test/fixtures/auth.stub.ts b/server/test/fixtures/auth.stub.ts index 2989c0cce1..f894314258 100644 --- a/server/test/fixtures/auth.stub.ts +++ b/server/test/fixtures/auth.stub.ts @@ -1,25 +1,30 @@ import { AuthDto } from 'src/dtos/auth.dto'; import { SessionEntity } from 'src/entities/session.entity'; import { SharedLinkEntity } from 'src/entities/shared-link.entity'; -import { UserMetadataEntity } from 'src/entities/user-metadata.entity'; -import { UserEntity } from 'src/entities/user.entity'; + +const authUser = { + admin: { + id: 'admin_id', + name: 'admin', + email: 'admin@test.com', + isAdmin: true, + quotaSizeInBytes: null, + quotaUsageInBytes: 0, + }, + user1: { + id: 'user-id', + name: 'User 1', + email: 'immich@test.com', + isAdmin: false, + quotaSizeInBytes: null, + quotaUsageInBytes: 0, + }, +}; export const authStub = { - admin: Object.freeze({ - user: { - id: 'admin_id', - email: 'admin@test.com', - isAdmin: true, - metadata: [] as UserMetadataEntity[], - } as UserEntity, - }), + admin: Object.freeze({ user: authUser.admin }), user1: Object.freeze({ - user: { - id: 'user-id', - email: 'immich@test.com', - isAdmin: false, - metadata: [] as UserMetadataEntity[], - } as UserEntity, + user: authUser.user1, session: { id: 'token-id', } as SessionEntity, @@ -27,21 +32,18 @@ export const authStub = { user2: Object.freeze({ user: { id: 'user-2', - email: 'user2@immich.app', + name: 'User 2', + email: 'user2@immich.cloud', isAdmin: false, - metadata: [] as UserMetadataEntity[], - } as UserEntity, + quotaSizeInBytes: null, + quotaUsageInBytes: 0, + }, session: { id: 'token-id', } as SessionEntity, }), adminSharedLink: Object.freeze({ - user: { - id: 'admin_id', - email: 'admin@test.com', - isAdmin: true, - metadata: [] as UserMetadataEntity[], - } as UserEntity, + user: authUser.admin, sharedLink: { id: '123', showExif: true, @@ -51,12 +53,7 @@ export const authStub = { } as SharedLinkEntity, }), adminSharedLinkNoExif: Object.freeze({ - user: { - id: 'admin_id', - email: 'admin@test.com', - isAdmin: true, - metadata: [] as UserMetadataEntity[], - } as UserEntity, + user: authUser.admin, sharedLink: { id: '123', showExif: false, @@ -66,12 +63,7 @@ export const authStub = { } as SharedLinkEntity, }), passwordSharedLink: Object.freeze({ - user: { - id: 'admin_id', - email: 'admin@test.com', - isAdmin: true, - metadata: [] as UserMetadataEntity[], - } as UserEntity, + user: authUser.admin, sharedLink: { id: '123', allowUpload: false, diff --git a/server/test/fixtures/user.stub.ts b/server/test/fixtures/user.stub.ts index 9553b5344a..9153cfa8f2 100644 --- a/server/test/fixtures/user.stub.ts +++ b/server/test/fixtures/user.stub.ts @@ -1,10 +1,12 @@ import { UserEntity } from 'src/entities/user.entity'; -import { UserAvatarColor, UserMetadataKey } from 'src/enum'; +import { UserAvatarColor, UserMetadataKey, UserStatus } from 'src/enum'; import { authStub } from 'test/fixtures/auth.stub'; export const userStub = { admin: Object.freeze({ ...authStub.admin.user, + status: UserStatus.ACTIVE, + profileChangedAt: new Date('2021-01-01'), password: 'admin_password', name: 'admin_name', id: 'admin_id', @@ -23,6 +25,8 @@ export const userStub = { }), user1: Object.freeze({ ...authStub.user1.user, + status: UserStatus.ACTIVE, + profileChangedAt: new Date('2021-01-01'), password: 'immich_password', name: 'immich_name', storageLabel: null, @@ -36,7 +40,6 @@ export const userStub = { assets: [], metadata: [ { - user: authStub.user1.user, userId: authStub.user1.user.id, key: UserMetadataKey.PREFERENCES, value: { avatar: { color: UserAvatarColor.PRIMARY } }, @@ -47,6 +50,9 @@ export const userStub = { }), user2: Object.freeze({ ...authStub.user2.user, + status: UserStatus.ACTIVE, + profileChangedAt: new Date('2021-01-01'), + metadata: [], password: 'immich_password', name: 'immich_name', storageLabel: null, @@ -63,6 +69,9 @@ export const userStub = { }), storageLabel: Object.freeze({ ...authStub.user1.user, + status: UserStatus.ACTIVE, + profileChangedAt: new Date('2021-01-01'), + metadata: [], password: 'immich_password', name: 'immich_name', storageLabel: 'label-1', @@ -79,6 +88,9 @@ export const userStub = { }), profilePath: Object.freeze({ ...authStub.user1.user, + status: UserStatus.ACTIVE, + profileChangedAt: new Date('2021-01-01'), + metadata: [], password: 'immich_password', name: 'immich_name', storageLabel: 'label-1', diff --git a/server/test/repositories/user.repository.mock.ts b/server/test/repositories/user.repository.mock.ts index d7ebee09d8..2dc6b9eec2 100644 --- a/server/test/repositories/user.repository.mock.ts +++ b/server/test/repositories/user.repository.mock.ts @@ -5,6 +5,7 @@ import { Mocked, vitest } from 'vitest'; export const newUserRepositoryMock = (): Mocked> => { return { get: vitest.fn(), + getMetadata: vitest.fn().mockResolvedValue([]), getAdmin: vitest.fn(), getByEmail: vitest.fn(), getByStorageLabel: vitest.fn(), From 8c882b54cd325667dea0b3fa3bf416c95ec029da Mon Sep 17 00:00:00 2001 From: Joren Guillaume Date: Thu, 13 Feb 2025 11:44:33 +0100 Subject: [PATCH 129/395] docs: put Windows restore command on one line (#16074) Lots of 'unexpected newline' comments when restoring from other users, this should fix that. --- docs/docs/administration/backup-and-restore.md | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/docs/docs/administration/backup-and-restore.md b/docs/docs/administration/backup-and-restore.md index cd58604e1f..4eb4d3a8bb 100644 --- a/docs/docs/administration/backup-and-restore.md +++ b/docs/docs/administration/backup-and-restore.md @@ -77,9 +77,7 @@ docker start immich_postgres # Start Postgres server sleep 10 # Wait for Postgres server to start up docker exec -it immich_postgres bash # Enter the Docker shell and run the following command # Check the database user if you deviated from the default. If your backup ends in `.gz`, replace `cat` with `gunzip` -cat < "/dump.sql" \ -| sed "s/SELECT pg_catalog.set_config('search_path', '', false);/SELECT pg_catalog.set_config('search_path', 'public, pg_catalog', true);/g" \ -| psql --dbname=postgres --username= # Restore Backup +cat < "/dump.sql" | sed "s/SELECT pg_catalog.set_config('search_path', '', false);/SELECT pg_catalog.set_config('search_path', 'public, pg_catalog', true);/g" | psql --dbname=postgres --username= exit # Exit the Docker shell docker compose up -d # Start remainder of Immich apps ``` From bf16b61d430542877bcdaf3e517e9df440e6cab0 Mon Sep 17 00:00:00 2001 From: HelloMihai Date: Thu, 13 Feb 2025 11:46:12 -0800 Subject: [PATCH 130/395] fix: broken html id (#16084) ids cannot have spaces relative should not be in the ID of the element --- web/src/lib/components/faces-page/merge-face-selector.svelte | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/src/lib/components/faces-page/merge-face-selector.svelte b/web/src/lib/components/faces-page/merge-face-selector.svelte index 0e68ebcfcb..26ca99d863 100644 --- a/web/src/lib/components/faces-page/merge-face-selector.svelte +++ b/web/src/lib/components/faces-page/merge-face-selector.svelte @@ -118,7 +118,7 @@ {/snippet}
-
+

{$t('choose_matching_people_to_merge')}

From f5edc87e4d12747ff93fb9242ea89938323e89be Mon Sep 17 00:00:00 2001 From: bo0tzz Date: Thu, 13 Feb 2025 22:10:00 +0100 Subject: [PATCH 131/395] feat: comment URL on previewed PRs (#16085) --- .github/workflows/preview-comment.yaml | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) create mode 100644 .github/workflows/preview-comment.yaml diff --git a/.github/workflows/preview-comment.yaml b/.github/workflows/preview-comment.yaml new file mode 100644 index 0000000000..f49c271fe5 --- /dev/null +++ b/.github/workflows/preview-comment.yaml @@ -0,0 +1,17 @@ +name: Preview comment + +on: + pull_request: + types: [labeled] + +jobs: + comment-status: + runs-on: ubuntu-latest + if: ${{ github.event.label.name == 'preview' }} + permissions: + pull-requests: write + steps: + - uses: mshick/add-pr-comment@v2 + with: + message-id: "preview-status" + message: "Deploying preview environment to https://pr-${{ github.event.pull_request.number }}.preview.internal.immich.cloud/" From 5407a2853388124dc6241a1c0a262fca4aece2ae Mon Sep 17 00:00:00 2001 From: Jonathan Jogenfors Date: Thu, 13 Feb 2025 22:30:12 +0100 Subject: [PATCH 132/395] feat(server): Nullable asset dates (#15669) * nullable dates * wip * don't search for null dates * Add placeholder type * cleanup --- e2e/src/api/specs/library.e2e-spec.ts | 1 + server/src/db.d.ts | 6 ++--- server/src/entities/asset.entity.ts | 17 ++++++++++---- .../migrations/1737845696644-NullableDates.ts | 18 +++++++++++++++ server/src/queries/activity.repository.sql | 3 +++ server/src/queries/asset.repository.sql | 6 +++++ server/src/queries/search.repository.sql | 12 ++++++++++ server/src/queries/view.repository.sql | 6 +++++ .../src/repositories/activity.repository.ts | 3 +++ server/src/repositories/asset.repository.ts | 23 ++++++++++++++++--- server/src/repositories/view-repository.ts | 6 +++++ server/src/services/library.service.ts | 2 +- server/src/services/metadata.service.ts | 8 +++++++ 13 files changed, 100 insertions(+), 11 deletions(-) create mode 100644 server/src/migrations/1737845696644-NullableDates.ts diff --git a/e2e/src/api/specs/library.e2e-spec.ts b/e2e/src/api/specs/library.e2e-spec.ts index e2e69529fb..21fb945d1a 100644 --- a/e2e/src/api/specs/library.e2e-spec.ts +++ b/e2e/src/api/specs/library.e2e-spec.ts @@ -298,6 +298,7 @@ describe('/libraries', () => { expect(status).toBe(204); await utils.waitForQueueFinish(admin.accessToken, 'library'); + await utils.waitForQueueFinish(admin.accessToken, 'metadataExtraction'); const { assets } = await utils.searchAssets(admin.accessToken, { originalPath: `${testAssetDirInternal}/temp/directoryA/assetA.png`, diff --git a/server/src/db.d.ts b/server/src/db.d.ts index 648ccf4bcf..2e10e1aded 100644 --- a/server/src/db.d.ts +++ b/server/src/db.d.ts @@ -122,8 +122,8 @@ export interface Assets { duplicateId: string | null; duration: string | null; encodedVideoPath: Generated; - fileCreatedAt: Timestamp; - fileModifiedAt: Timestamp; + fileCreatedAt: Timestamp | null; + fileModifiedAt: Timestamp | null; id: Generated; isArchived: Generated; isExternal: Generated; @@ -132,7 +132,7 @@ export interface Assets { isVisible: Generated; libraryId: string | null; livePhotoVideoId: string | null; - localDateTime: Timestamp; + localDateTime: Timestamp | null; originalFileName: string; originalPath: string; ownerId: string; diff --git a/server/src/entities/asset.entity.ts b/server/src/entities/asset.entity.ts index a90236de84..1a7d3ee1c1 100644 --- a/server/src/entities/asset.entity.ts +++ b/server/src/entities/asset.entity.ts @@ -100,13 +100,13 @@ export class AssetEntity { deletedAt!: Date | null; @Index('idx_asset_file_created_at') - @Column({ type: 'timestamptz' }) + @Column({ type: 'timestamptz', nullable: true, default: null }) fileCreatedAt!: Date; - @Column({ type: 'timestamptz' }) + @Column({ type: 'timestamptz', nullable: true, default: null }) localDateTime!: Date; - @Column({ type: 'timestamptz' }) + @Column({ type: 'timestamptz', nullable: true, default: null }) fileModifiedAt!: Date; @Column({ type: 'boolean', default: false }) @@ -180,6 +180,12 @@ export class AssetEntity { duplicateId!: string | null; } +export type AssetEntityPlaceholder = AssetEntity & { + fileCreatedAt: Date | null; + fileModifiedAt: Date | null; + localDateTime: Date | null; +}; + export function withExif(qb: SelectQueryBuilder) { return qb.leftJoin('exif', 'assets.id', 'exif.assetId').select((eb) => eb.fn.toJson(eb.table('exif')).as('exifInfo')); } @@ -419,5 +425,8 @@ export function searchAssetBuilder(kysely: Kysely, options: AssetSearchBuild ) .$if(!!options.withExif, withExifInner) .$if(!!(options.withFaces || options.withPeople || options.personIds), (qb) => qb.select(withFacesAndPeople)) - .$if(!options.withDeleted, (qb) => qb.where('assets.deletedAt', 'is', null)); + .$if(!options.withDeleted, (qb) => qb.where('assets.deletedAt', 'is', null)) + .where('assets.fileCreatedAt', 'is not', null) + .where('assets.fileModifiedAt', 'is not', null) + .where('assets.localDateTime', 'is not', null); } diff --git a/server/src/migrations/1737845696644-NullableDates.ts b/server/src/migrations/1737845696644-NullableDates.ts new file mode 100644 index 0000000000..8a08b985c5 --- /dev/null +++ b/server/src/migrations/1737845696644-NullableDates.ts @@ -0,0 +1,18 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class NullableDates1737845696644 implements MigrationInterface { + name = 'NullableDates1737845696644' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "assets" ALTER COLUMN "fileCreatedAt" DROP NOT NULL`); + await queryRunner.query(`ALTER TABLE "assets" ALTER COLUMN "localDateTime" DROP NOT NULL`); + await queryRunner.query(`ALTER TABLE "assets" ALTER COLUMN "fileModifiedAt" DROP NOT NULL`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "assets" ALTER COLUMN "fileModifiedAt" SET NOT NULL`); + await queryRunner.query(`ALTER TABLE "assets" ALTER COLUMN "localDateTime" SET NOT NULL`); + await queryRunner.query(`ALTER TABLE "assets" ALTER COLUMN "fileCreatedAt" SET NOT NULL`); + } + +} diff --git a/server/src/queries/activity.repository.sql b/server/src/queries/activity.repository.sql index 8e9bb11f25..0ddb91c692 100644 --- a/server/src/queries/activity.repository.sql +++ b/server/src/queries/activity.repository.sql @@ -43,3 +43,6 @@ where and "activity"."albumId" = $2 and "activity"."isLiked" = $3 and "assets"."deletedAt" is null + and "assets"."fileCreatedAt" is not null + and "assets"."fileModifiedAt" is not null + and "assets"."localDateTime" is not null diff --git a/server/src/queries/asset.repository.sql b/server/src/queries/asset.repository.sql index 437e1e173c..3efc560b3b 100644 --- a/server/src/queries/asset.repository.sql +++ b/server/src/queries/asset.repository.sql @@ -159,6 +159,9 @@ where "ownerId" = $1::uuid and "deviceId" = $2 and "isVisible" = $3 + and "assets"."fileCreatedAt" is not null + and "assets"."fileModifiedAt" is not null + and "assets"."localDateTime" is not null and "deletedAt" is null -- AssetRepository.getLivePhotoCount @@ -260,6 +263,9 @@ with where "assets"."deletedAt" is null and "assets"."isVisible" = $2 + and "assets"."fileCreatedAt" is not null + and "assets"."fileModifiedAt" is not null + and "assets"."localDateTime" is not null ) select "timeBucket", diff --git a/server/src/queries/search.repository.sql b/server/src/queries/search.repository.sql index 72e8a6941d..9400700e56 100644 --- a/server/src/queries/search.repository.sql +++ b/server/src/queries/search.repository.sql @@ -13,6 +13,9 @@ where and "assets"."isFavorite" = $4 and "assets"."isArchived" = $5 and "assets"."deletedAt" is null + and "assets"."fileCreatedAt" is not null + and "assets"."fileModifiedAt" is not null + and "assets"."localDateTime" is not null order by "assets"."fileCreatedAt" desc limit @@ -34,6 +37,9 @@ offset and "assets"."isFavorite" = $4 and "assets"."isArchived" = $5 and "assets"."deletedAt" is null + and "assets"."fileCreatedAt" is not null + and "assets"."fileModifiedAt" is not null + and "assets"."localDateTime" is not null and "assets"."id" < $6 order by random() @@ -54,6 +60,9 @@ union all and "assets"."isFavorite" = $11 and "assets"."isArchived" = $12 and "assets"."deletedAt" is null + and "assets"."fileCreatedAt" is not null + and "assets"."fileModifiedAt" is not null + and "assets"."localDateTime" is not null and "assets"."id" > $13 order by random() @@ -77,6 +86,9 @@ where and "assets"."isFavorite" = $4 and "assets"."isArchived" = $5 and "assets"."deletedAt" is null + and "assets"."fileCreatedAt" is not null + and "assets"."fileModifiedAt" is not null + and "assets"."localDateTime" is not null order by smart_search.embedding <=> $6 limit diff --git a/server/src/queries/view.repository.sql b/server/src/queries/view.repository.sql index b368684cae..daa4159ea0 100644 --- a/server/src/queries/view.repository.sql +++ b/server/src/queries/view.repository.sql @@ -10,6 +10,9 @@ where and "isVisible" = $3 and "isArchived" = $4 and "deletedAt" is null + and "fileModifiedAt" is not null + and "fileModifiedAt" is not null + and "localDateTime" is not null -- ViewRepository.getAssetsByOriginalPath select @@ -23,6 +26,9 @@ where and "isVisible" = $2 and "isArchived" = $3 and "deletedAt" is null + and "fileModifiedAt" is not null + and "fileModifiedAt" is not null + and "localDateTime" is not null and "originalPath" like $4 and "originalPath" not like $5 order by diff --git a/server/src/repositories/activity.repository.ts b/server/src/repositories/activity.repository.ts index 99d3192341..d998fea23c 100644 --- a/server/src/repositories/activity.repository.ts +++ b/server/src/repositories/activity.repository.ts @@ -65,6 +65,9 @@ export class ActivityRepository { .where('activity.albumId', '=', albumId) .where('activity.isLiked', '=', false) .where('assets.deletedAt', 'is', null) + .where('assets.fileCreatedAt', 'is not', null) + .where('assets.fileModifiedAt', 'is not', null) + .where('assets.localDateTime', 'is not', null) .executeTakeFirstOrThrow(); return count as number; diff --git a/server/src/repositories/asset.repository.ts b/server/src/repositories/asset.repository.ts index bd82cc0724..123116c62f 100644 --- a/server/src/repositories/asset.repository.ts +++ b/server/src/repositories/asset.repository.ts @@ -7,6 +7,7 @@ import { AssetFiles, AssetJobStatus, Assets, DB, Exif } from 'src/db'; import { Chunked, ChunkedArray, DummyValue, GenerateSql } from 'src/decorators'; import { AssetEntity, + AssetEntityPlaceholder, hasPeople, searchAssetBuilder, truncatedDate, @@ -183,8 +184,12 @@ export class AssetRepository { .execute(); } - create(asset: Insertable): Promise { - return this.db.insertInto('assets').values(asset).returningAll().executeTakeFirst() as any as Promise; + create(asset: Insertable): Promise { + return this.db + .insertInto('assets') + .values(asset) + .returningAll() + .executeTakeFirst() as any as Promise; } @GenerateSql({ params: [DummyValue.UUID, { day: 1, month: 1 }] }) @@ -395,6 +400,9 @@ export class AssetRepository { .where('ownerId', '=', asUuid(ownerId)) .where('deviceId', '=', deviceId) .where('isVisible', '=', true) + .where('assets.fileCreatedAt', 'is not', null) + .where('assets.fileModifiedAt', 'is not', null) + .where('assets.localDateTime', 'is not', null) .where('deletedAt', 'is', null) .execute(); @@ -562,7 +570,10 @@ export class AssetRepository { .where('job_status.duplicatesDetectedAt', 'is', null) .where('job_status.previewAt', 'is not', null) .where((eb) => eb.exists(eb.selectFrom('smart_search').where('assetId', '=', eb.ref('assets.id')))) - .where('assets.isVisible', '=', true), + .where('assets.isVisible', '=', true) + .where('assets.fileCreatedAt', 'is not', null) + .where('assets.fileModifiedAt', 'is not', null) + .where('assets.localDateTime', 'is not', null), ) .$if(property === WithoutProperty.ENCODED_VIDEO, (qb) => qb @@ -656,6 +667,9 @@ export class AssetRepository { .select((eb) => eb.fn.countAll().filterWhere('type', '=', AssetType.VIDEO).as(AssetType.VIDEO)) .select((eb) => eb.fn.countAll().filterWhere('type', '=', AssetType.OTHER).as(AssetType.OTHER)) .where('ownerId', '=', asUuid(ownerId)) + .where('assets.fileCreatedAt', 'is not', null) + .where('assets.fileModifiedAt', 'is not', null) + .where('assets.localDateTime', 'is not', null) .where('isVisible', '=', true) .$if(isArchived !== undefined, (qb) => qb.where('isArchived', '=', isArchived!)) .$if(isFavorite !== undefined, (qb) => qb.where('isFavorite', '=', isFavorite!)) @@ -688,6 +702,9 @@ export class AssetRepository { .$if(!!options.isTrashed, (qb) => qb.where('assets.status', '!=', AssetStatus.DELETED)) .where('assets.deletedAt', options.isTrashed ? 'is not' : 'is', null) .where('assets.isVisible', '=', true) + .where('assets.fileCreatedAt', 'is not', null) + .where('assets.fileModifiedAt', 'is not', null) + .where('assets.localDateTime', 'is not', null) .$if(!!options.albumId, (qb) => qb .innerJoin('albums_assets_assets', 'assets.id', 'albums_assets_assets.assetsId') diff --git a/server/src/repositories/view-repository.ts b/server/src/repositories/view-repository.ts index f24b1bac6e..4fa670339e 100644 --- a/server/src/repositories/view-repository.ts +++ b/server/src/repositories/view-repository.ts @@ -18,6 +18,9 @@ export class ViewRepository { .where('isVisible', '=', true) .where('isArchived', '=', false) .where('deletedAt', 'is', null) + .where('fileModifiedAt', 'is not', null) + .where('fileModifiedAt', 'is not', null) + .where('localDateTime', 'is not', null) .execute(); return results.map((row) => row.directoryPath.replaceAll(/^\/|\/$/g, '')); @@ -35,6 +38,9 @@ export class ViewRepository { .where('isVisible', '=', true) .where('isArchived', '=', false) .where('deletedAt', 'is', null) + .where('fileModifiedAt', 'is not', null) + .where('fileModifiedAt', 'is not', null) + .where('localDateTime', 'is not', null) .where('originalPath', 'like', `%${normalizedPath}/%`) .where('originalPath', 'not like', `%${normalizedPath}/%/%`) .orderBy( diff --git a/server/src/services/library.service.ts b/server/src/services/library.service.ts index 4a9614e010..441d130c12 100644 --- a/server/src/services/library.service.ts +++ b/server/src/services/library.service.ts @@ -503,7 +503,7 @@ export class LibraryService extends BaseService { } const mtime = stat.mtime; - const isAssetModified = mtime.toISOString() !== asset.fileModifiedAt.toISOString(); + const isAssetModified = !asset.fileModifiedAt || mtime.toISOString() !== asset.fileModifiedAt.toISOString(); if (asset.isOffline || isAssetModified) { this.logger.debug(`Asset was offline or modified, updating asset record ${asset.originalPath}`); diff --git a/server/src/services/metadata.service.ts b/server/src/services/metadata.service.ts index 8c2e3646a0..19c3656e01 100644 --- a/server/src/services/metadata.service.ts +++ b/server/src/services/metadata.service.ts @@ -171,6 +171,14 @@ export class MetadataService extends BaseService { this.logger.verbose('Exif Tags', exifTags); + if (!asset.fileCreatedAt) { + asset.fileCreatedAt = stats.mtime; + } + + if (!asset.fileModifiedAt) { + asset.fileModifiedAt = stats.mtime; + } + const { dateTimeOriginal, localDateTime, timeZone, modifyDate } = this.getDates(asset, exifTags); const { latitude, longitude, country, state, city } = await this.getGeo(exifTags, reverseGeocoding); From dbbefde98dc59904f6a4a39c52209db0af97007b Mon Sep 17 00:00:00 2001 From: Zack Pollard Date: Fri, 14 Feb 2025 14:55:18 +0000 Subject: [PATCH 133/395] feat: native arm and amd64 server builds (#15408) --- .github/workflows/docker.yml | 129 ++++++++++++++++++++++++----------- 1 file changed, 88 insertions(+), 41 deletions(-) diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 8f6f76376b..d53cbd3a1a 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -87,7 +87,6 @@ jobs: TAG_NEW=${{ github.event.number == 0 && github.ref_name || format('pr-{0}', github.event.number) }}${{ matrix.suffix }} docker buildx imagetools create -t $REGISTRY_NAME/$REPOSITORY:$TAG_NEW $REGISTRY_NAME/$REPOSITORY:$TAG_OLD - build_and_push_ml: name: Build and Push ML needs: pre-job @@ -195,33 +194,38 @@ jobs: build_and_push_server: name: Build and Push Server - runs-on: ubuntu-latest + runs-on: ${{ matrix.runner }} needs: pre-job if: ${{ needs.pre-job.outputs.should_run_server == 'true' }} env: image: immich-server context: . file: server/Dockerfile + GHCR_REPO: ghcr.io/${{ github.repository_owner }}/immich-server + DOCKER_REPO: altran1502/immich-server strategy: fail-fast: false matrix: include: - - platforms: linux/amd64,linux/arm64 - device: cpu + - platform: linux/amd64 + runner: ubuntu-latest + - platform: linux/arm64 + runner: ubuntu-24.04-arm steps: + - name: Prepare + run: | + platform=${{ matrix.platform }} + echo "PLATFORM_PAIR=${platform//\//-}" >> $GITHUB_ENV + - name: Checkout uses: actions/checkout@v4 - - name: Set up QEMU - uses: docker/setup-qemu-action@v3.4.0 - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3.9.0 + uses: docker/setup-buildx-action@v3 - name: Login to Docker Hub - # Only push to Docker Hub when making a release - if: ${{ github.event_name == 'release' }} uses: docker/login-action@v3 + if: ${{ !github.event.pull_request.head.repo.fork }} with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} @@ -235,16 +239,81 @@ jobs: username: ${{ github.repository_owner }} password: ${{ secrets.GITHUB_TOKEN }} + - name: Build and push image + id: build + uses: docker/build-push-action@v6.13.0 + with: + context: ${{ env.context }} + file: ${{ env.file }} + platforms: ${{ matrix.platform }} + # Skip pushing when PR from a fork + push: ${{ !github.event.pull_request.head.repo.fork }} + labels: ${{ steps.metadata.outputs.labels }} + outputs: type=image,"name=${{ env.GHCR_REPO }},${{ env.DOCKER_REPO }}",push-by-digest=true,name-canonical=true,push=true + build-args: | + DEVICE=cpu + BUILD_ID=${{ github.run_id }} + BUILD_IMAGE=${{ github.event_name == 'release' && github.ref_name || steps.metadata.outputs.tags }} + BUILD_SOURCE_REF=${{ github.ref_name }} + BUILD_SOURCE_COMMIT=${{ github.sha }} + + - name: Export digest + run: | + mkdir -p ${{ runner.temp }}/digests + digest="${{ steps.build.outputs.digest }}" + touch "${{ runner.temp }}/digests/${digest#sha256:}" + + - name: Upload digest + uses: actions/upload-artifact@v4 + with: + name: digests-${{ env.PLATFORM_PAIR }} + path: ${{ runner.temp }}/digests/* + if-no-files-found: error + retention-days: 1 + + merge_server: + name: Merge & Push Server + runs-on: ubuntu-latest + if: ${{ needs.pre-job.outputs.should_run_server == 'true' && !github.event.pull_request.head.repo.fork }} + env: + GHCR_REPO: ghcr.io/${{ github.repository_owner }}/immich-server + DOCKER_REPO: altran1502/immich-server + needs: + - build_and_push_server + steps: + - name: Download digests + uses: actions/download-artifact@v4 + with: + path: ${{ runner.temp }}/digests + pattern: digests-* + merge-multiple: true + + - name: Login to Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Login to GHCR + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.repository_owner }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + - name: Generate docker image tags - id: metadata + id: meta uses: docker/metadata-action@v5 with: flavor: | # Disable latest tag latest=false images: | - name=ghcr.io/${{ github.repository_owner }}/${{env.image}} - name=altran1502/${{env.image}},enable=${{ github.event_name == 'release' }} + name=${{ env.GHCR_REPO }} + name=${{ env.DOCKER_REPO }},enable=${{ github.event_name == 'release' }} tags: | # Tag with branch name type=ref,event=branch,suffix=${{ matrix.suffix }} @@ -254,38 +323,16 @@ jobs: type=ref,event=tag,suffix=${{ matrix.suffix }} type=raw,value=release,enable=${{ github.event_name == 'release' }},suffix=${{ matrix.suffix }} - - name: Determine build cache output - id: cache-target + - name: Create manifest list and push + working-directory: ${{ runner.temp }}/digests run: | - if [[ "${{ github.event_name }}" == "pull_request" ]]; then - # Essentially just ignore the cache output (PR can't write to registry cache) - echo "cache-to=type=local,dest=/tmp/discard,ignore-error=true" >> $GITHUB_OUTPUT - else - echo "cache-to=type=registry,mode=max,ref=ghcr.io/${{ github.repository_owner }}/immich-build-cache:${{ env.image }}" >> $GITHUB_OUTPUT - fi - - - name: Build and push image - uses: docker/build-push-action@v6.13.0 - with: - context: ${{ env.context }} - file: ${{ env.file }} - platforms: ${{ matrix.platforms }} - # Skip pushing when PR from a fork - push: ${{ !github.event.pull_request.head.repo.fork }} - cache-from: type=registry,ref=ghcr.io/${{ github.repository_owner }}/immich-build-cache:${{env.image}} - cache-to: ${{ steps.cache-target.outputs.cache-to }} - tags: ${{ steps.metadata.outputs.tags }} - labels: ${{ steps.metadata.outputs.labels }} - build-args: | - DEVICE=${{ matrix.device }} - BUILD_ID=${{ github.run_id }} - BUILD_IMAGE=${{ github.event_name == 'release' && github.ref_name || steps.metadata.outputs.tags }} - BUILD_SOURCE_REF=${{ github.ref_name }} - BUILD_SOURCE_COMMIT=${{ github.sha }} + docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \ + $(printf '${{ env.GHCR_REPO }}@sha256:%s ' *) \ + $(printf '${{ env.DOCKER_REPO }}@sha256:%s ' *) success-check-server: name: Docker Build & Push Server Success - needs: [build_and_push_server, retag_server] + needs: [merge_server, retag_server] runs-on: ubuntu-latest if: always() steps: From b1f05fc18bbf6770bd4b39059704eec30458cf42 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mangat=20Singh=20Toor=20=7C=20=E0=A8=AE=E0=A9=B0=E0=A8=97?= =?UTF-8?q?=E0=A8=A4=20=E0=A8=B8=E0=A8=BF=E0=A9=B0=E0=A8=98=20=E0=A8=A4?= =?UTF-8?q?=E0=A9=82=E0=A8=B0?= Date: Fri, 14 Feb 2025 07:49:22 -0800 Subject: [PATCH 134/395] fix(web): properly project profile picture (#16095) * fix(profile-image-cropper): ensure correct image area is saved after transparency check Fixed an issue where users could not set a profile picture due to incorrect transparency detection. After addressing transparency detection by passing explicit dimensions, another issue arose where the generated blob did not represent the correct cropped image area. To fix this, a new cropped blob was generated using the canvas that was used to check for transparent pixels. - Pass image width and height explicitly to `hasTransparentPixels` for accurate processing. - Return both transparency status and the correctly cropped image blob. - Ensure the final uploaded image is taken from `croppedImageBlob` to reflect user adjustments. * chore: run pr web checklist. No issues in the changed file. * fix(profile-image-cropper): ensure correct image area is saved after transparency check Fixed an issue where users could not set a profile picture due to incorrect transparency detection. To fix this, a new cropped blob was generated using the height and width of the imgElement. Note: this is a simpler fix than the one in the previous commit. * lint --------- Co-authored-by: Alex Tran --- .../shared-components/profile-image-cropper.svelte | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/web/src/lib/components/shared-components/profile-image-cropper.svelte b/web/src/lib/components/shared-components/profile-image-cropper.svelte index b8ac866761..487a6470a8 100644 --- a/web/src/lib/components/shared-components/profile-image-cropper.svelte +++ b/web/src/lib/components/shared-components/profile-image-cropper.svelte @@ -58,7 +58,13 @@ } try { - const blob = await domtoimage.toBlob(imgElement); + const imgElementHeight = imgElement.offsetHeight; + const imgElementWidth = imgElement.offsetWidth; + const blob = await domtoimage.toBlob(imgElement, { + width: imgElementWidth, + height: imgElementHeight, + }); + if (await hasTransparentPixels(blob)) { notificationController.show({ type: NotificationType.Error, From 5b4f894211b59f0b77ede2a0bce8edacff5af999 Mon Sep 17 00:00:00 2001 From: Zack Pollard Date: Fri, 14 Feb 2025 16:08:41 +0000 Subject: [PATCH 135/395] ci: docker images sha commit tag (#16098) --- .github/workflows/docker.yml | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index d53cbd3a1a..5d1b06b899 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -61,8 +61,10 @@ jobs: REGISTRY_NAME="ghcr.io" REPOSITORY=${{ github.repository_owner }}/immich-machine-learning TAG_OLD=main${{ matrix.suffix }} - TAG_NEW=${{ github.event.number == 0 && github.ref_name || format('pr-{0}', github.event.number) }}${{ matrix.suffix }} - docker buildx imagetools create -t $REGISTRY_NAME/$REPOSITORY:$TAG_NEW $REGISTRY_NAME/$REPOSITORY:$TAG_OLD + TAG_PR=${{ github.event.number == 0 && github.ref_name || format('pr-{0}', github.event.number) }}${{ matrix.suffix }} + TAG_COMMIT=commit-${{ github.event.pull_request.head.sha }}${{ matrix.suffix }} + docker buildx imagetools create -t $REGISTRY_NAME/$REPOSITORY:$TAG_PR $REGISTRY_NAME/$REPOSITORY:$TAG_OLD + docker buildx imagetools create -t $REGISTRY_NAME/$REPOSITORY:$TAG_COMMIT $REGISTRY_NAME/$REPOSITORY:$TAG_OLD retag_server: name: Re-Tag Server @@ -84,8 +86,10 @@ jobs: REGISTRY_NAME="ghcr.io" REPOSITORY=${{ github.repository_owner }}/immich-server TAG_OLD=main${{ matrix.suffix }} - TAG_NEW=${{ github.event.number == 0 && github.ref_name || format('pr-{0}', github.event.number) }}${{ matrix.suffix }} - docker buildx imagetools create -t $REGISTRY_NAME/$REPOSITORY:$TAG_NEW $REGISTRY_NAME/$REPOSITORY:$TAG_OLD + TAG_PR=${{ github.event.number == 0 && github.ref_name || format('pr-{0}', github.event.number) }}${{ matrix.suffix }} + TAG_COMMIT=commit-${{ github.event.pull_request.head.sha }}${{ matrix.suffix }} + docker buildx imagetools create -t $REGISTRY_NAME/$REPOSITORY:$TAG_PR $REGISTRY_NAME/$REPOSITORY:$TAG_OLD + docker buildx imagetools create -t $REGISTRY_NAME/$REPOSITORY:$TAG_COMMIT $REGISTRY_NAME/$REPOSITORY:$TAG_OLD build_and_push_ml: name: Build and Push ML @@ -158,6 +162,8 @@ jobs: type=ref,event=branch,suffix=${{ matrix.suffix }} # Tag with pr-number type=ref,event=pr,suffix=${{ matrix.suffix }} + # Tag with long commit sha hash + type=sha,format=long,prefix=commit-,suffix=${{ matrix.suffix }} # Tag with git tag on release type=ref,event=tag,suffix=${{ matrix.suffix }} type=raw,value=release,enable=${{ github.event_name == 'release' }},suffix=${{ matrix.suffix }} @@ -319,6 +325,8 @@ jobs: type=ref,event=branch,suffix=${{ matrix.suffix }} # Tag with pr-number type=ref,event=pr,suffix=${{ matrix.suffix }} + # Tag with long commit sha hash + type=sha,format=long,prefix=commit-,suffix=${{ matrix.suffix }} # Tag with git tag on release type=ref,event=tag,suffix=${{ matrix.suffix }} type=raw,value=release,enable=${{ github.event_name == 'release' }},suffix=${{ matrix.suffix }} From 8ab87a88036a1fffec45e58312586302d9e7773c Mon Sep 17 00:00:00 2001 From: Zack Pollard Date: Fri, 14 Feb 2025 18:18:49 +0000 Subject: [PATCH 136/395] ci: retag commit hash unset outside of PRs (#16103) --- .github/workflows/docker.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 5d1b06b899..b60e02e806 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -62,7 +62,7 @@ jobs: REPOSITORY=${{ github.repository_owner }}/immich-machine-learning TAG_OLD=main${{ matrix.suffix }} TAG_PR=${{ github.event.number == 0 && github.ref_name || format('pr-{0}', github.event.number) }}${{ matrix.suffix }} - TAG_COMMIT=commit-${{ github.event.pull_request.head.sha }}${{ matrix.suffix }} + TAG_COMMIT=commit-${{ github.event_name != 'pull_request' && github.sha || github.event.pull_request.head.sha }}${{ matrix.suffix }} docker buildx imagetools create -t $REGISTRY_NAME/$REPOSITORY:$TAG_PR $REGISTRY_NAME/$REPOSITORY:$TAG_OLD docker buildx imagetools create -t $REGISTRY_NAME/$REPOSITORY:$TAG_COMMIT $REGISTRY_NAME/$REPOSITORY:$TAG_OLD @@ -87,7 +87,7 @@ jobs: REPOSITORY=${{ github.repository_owner }}/immich-server TAG_OLD=main${{ matrix.suffix }} TAG_PR=${{ github.event.number == 0 && github.ref_name || format('pr-{0}', github.event.number) }}${{ matrix.suffix }} - TAG_COMMIT=commit-${{ github.event.pull_request.head.sha }}${{ matrix.suffix }} + TAG_COMMIT=commit-${{ github.event_name != 'pull_request' && github.sha || github.event.pull_request.head.sha }}${{ matrix.suffix }} docker buildx imagetools create -t $REGISTRY_NAME/$REPOSITORY:$TAG_PR $REGISTRY_NAME/$REPOSITORY:$TAG_OLD docker buildx imagetools create -t $REGISTRY_NAME/$REPOSITORY:$TAG_COMMIT $REGISTRY_NAME/$REPOSITORY:$TAG_OLD From 47203d27605749a4dbb44d1850cf16eed72177ae Mon Sep 17 00:00:00 2001 From: Alex Date: Fri, 14 Feb 2025 13:23:14 -0600 Subject: [PATCH 137/395] refactor(mobile): asset stack provider (#16100) * refactor(mobile): asset stack provider * remove file from ignore list --- mobile/analysis_options.yaml | 2 +- mobile/lib/interfaces/asset.interface.dart | 2 ++ .../asset_viewer/asset_stack.provider.dart | 27 +++++-------------- mobile/lib/repositories/asset.repository.dart | 13 +++++++++ mobile/lib/routing/router.gr.dart | 8 +++++- mobile/lib/services/asset.service.dart | 4 +++ 6 files changed, 33 insertions(+), 23 deletions(-) diff --git a/mobile/analysis_options.yaml b/mobile/analysis_options.yaml index 629c71a92d..23efa2a275 100644 --- a/mobile/analysis_options.yaml +++ b/mobile/analysis_options.yaml @@ -77,7 +77,7 @@ custom_lint: - test/**.dart # refactor the remaining providers - lib/providers/{archive,asset,authentication,db,favorite,partner,trash,user}.provider.dart - - lib/providers/{album/album,album/shared_album,asset_viewer/asset_stack,asset_viewer/render_list,backup/backup,search/all_motion_photos,search/recently_added_asset}.provider.dart + - lib/providers/{album/album,album/shared_album,asset_viewer/render_list,backup/backup,search/all_motion_photos,search/recently_added_asset}.provider.dart - import_rule_openapi: message: openapi must only be used through ApiRepositories diff --git a/mobile/lib/interfaces/asset.interface.dart b/mobile/lib/interfaces/asset.interface.dart index 5aec594eb1..65cca6e86c 100644 --- a/mobile/lib/interfaces/asset.interface.dart +++ b/mobile/lib/interfaces/asset.interface.dart @@ -57,6 +57,8 @@ abstract interface class IAssetRepository implements IDatabaseRepository { Future upsertDuplicatedAssets(Iterable duplicatedAssets); Future> getAllDuplicatedAssetIds(); + + Future> getStackAssets(String stackId); } enum AssetSort { checksum, ownerIdChecksum } diff --git a/mobile/lib/providers/asset_viewer/asset_stack.provider.dart b/mobile/lib/providers/asset_viewer/asset_stack.provider.dart index 407aef1610..d7049e4e1e 100644 --- a/mobile/lib/providers/asset_viewer/asset_stack.provider.dart +++ b/mobile/lib/providers/asset_viewer/asset_stack.provider.dart @@ -1,16 +1,15 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/entities/asset.entity.dart'; -import 'package:immich_mobile/providers/db.provider.dart'; -import 'package:isar/isar.dart'; +import 'package:immich_mobile/services/asset.service.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; part 'asset_stack.provider.g.dart'; class AssetStackNotifier extends StateNotifier> { + final AssetService assetService; final String _stackId; - final Ref _ref; - AssetStackNotifier(this._stackId, this._ref) : super([]) { + AssetStackNotifier(this.assetService, this._stackId) : super([]) { _fetchStack(_stackId); } @@ -19,7 +18,7 @@ class AssetStackNotifier extends StateNotifier> { return; } - final stack = await _ref.read(assetStackProvider(stackId).future); + final stack = await assetService.getStackAssets(stackId); if (stack.isNotEmpty) { state = stack; } @@ -35,24 +34,10 @@ class AssetStackNotifier extends StateNotifier> { final assetStackStateProvider = StateNotifierProvider.autoDispose .family, String>( - (ref, stackId) => AssetStackNotifier(stackId, ref), + (ref, stackId) => + AssetStackNotifier(ref.watch(assetServiceProvider), stackId), ); -final assetStackProvider = - FutureProvider.autoDispose.family, String>((ref, stackId) { - return ref - .watch(dbProvider) - .assets - .filter() - .isArchivedEqualTo(false) - .isTrashedEqualTo(false) - .stackIdEqualTo(stackId) - // orders primary asset first as its ID is null - .sortByStackPrimaryAssetId() - .thenByFileCreatedAtDesc() - .findAll(); -}); - @riverpod int assetStackIndex(AssetStackIndexRef ref, Asset asset) { return -1; diff --git a/mobile/lib/repositories/asset.repository.dart b/mobile/lib/repositories/asset.repository.dart index 36e976a1ab..a207e15092 100644 --- a/mobile/lib/repositories/asset.repository.dart +++ b/mobile/lib/repositories/asset.repository.dart @@ -197,6 +197,19 @@ class AssetRepository extends DatabaseRepository implements IAssetRepository { @override Future deleteAllByRemoteId(List ids, {AssetState? state}) => txn(() => _getAllByRemoteIdImpl(ids, state).deleteAll()); + + @override + Future> getStackAssets(String stackId) { + return db.assets + .filter() + .isArchivedEqualTo(false) + .isTrashedEqualTo(false) + .stackIdEqualTo(stackId) + // orders primary asset first as its ID is null + .sortByStackPrimaryAssetId() + .thenByFileCreatedAtDesc() + .findAll(); + } } Future> _getMatchesImpl( diff --git a/mobile/lib/routing/router.gr.dart b/mobile/lib/routing/router.gr.dart index 48528fdfe2..1940ca26db 100644 --- a/mobile/lib/routing/router.gr.dart +++ b/mobile/lib/routing/router.gr.dart @@ -1060,6 +1060,7 @@ class NativeVideoViewerRoute extends PageRouteInfo { required Asset asset, required Widget image, bool showControls = true, + int playbackDelayFactor = 1, List? children, }) : super( NativeVideoViewerRoute.name, @@ -1068,6 +1069,7 @@ class NativeVideoViewerRoute extends PageRouteInfo { asset: asset, image: image, showControls: showControls, + playbackDelayFactor: playbackDelayFactor, ), initialChildren: children, ); @@ -1083,6 +1085,7 @@ class NativeVideoViewerRoute extends PageRouteInfo { asset: args.asset, image: args.image, showControls: args.showControls, + playbackDelayFactor: args.playbackDelayFactor, ); }, ); @@ -1094,6 +1097,7 @@ class NativeVideoViewerRouteArgs { required this.asset, required this.image, this.showControls = true, + this.playbackDelayFactor = 1, }); final Key? key; @@ -1104,9 +1108,11 @@ class NativeVideoViewerRouteArgs { final bool showControls; + final int playbackDelayFactor; + @override String toString() { - return 'NativeVideoViewerRouteArgs{key: $key, asset: $asset, image: $image, showControls: $showControls}'; + return 'NativeVideoViewerRouteArgs{key: $key, asset: $asset, image: $image, showControls: $showControls, playbackDelayFactor: $playbackDelayFactor}'; } } diff --git a/mobile/lib/services/asset.service.dart b/mobile/lib/services/asset.service.dart index 7d27d1b27b..6a9879f650 100644 --- a/mobile/lib/services/asset.service.dart +++ b/mobile/lib/services/asset.service.dart @@ -428,4 +428,8 @@ class AssetService { return 1.0; } + + Future> getStackAssets(String stackId) { + return _assetRepository.getStackAssets(stackId); + } } From 4f912de0182a36c0894a13459c5e031cb549e1ac Mon Sep 17 00:00:00 2001 From: Alex Date: Fri, 14 Feb 2025 19:27:39 -0600 Subject: [PATCH 138/395] refactor(mobile): album provider (#16099) --- mobile/analysis_options.yaml | 2 +- mobile/immich_lint/pubspec.yaml | 2 +- mobile/lib/interfaces/album.interface.dart | 9 ++ .../lib/providers/album/album.provider.dart | 89 ++++++++----------- mobile/lib/repositories/album.repository.dart | 35 ++++++++ mobile/lib/services/album.service.dart | 28 +++++- mobile/openapi/devtools_options.yaml | 3 + 7 files changed, 115 insertions(+), 53 deletions(-) create mode 100644 mobile/openapi/devtools_options.yaml diff --git a/mobile/analysis_options.yaml b/mobile/analysis_options.yaml index 23efa2a275..6160d2ad1f 100644 --- a/mobile/analysis_options.yaml +++ b/mobile/analysis_options.yaml @@ -77,7 +77,7 @@ custom_lint: - test/**.dart # refactor the remaining providers - lib/providers/{archive,asset,authentication,db,favorite,partner,trash,user}.provider.dart - - lib/providers/{album/album,album/shared_album,asset_viewer/render_list,backup/backup,search/all_motion_photos,search/recently_added_asset}.provider.dart + - lib/providers/{asset_viewer/render_list,backup/backup,search/all_motion_photos,search/recently_added_asset}.provider.dart - import_rule_openapi: message: openapi must only be used through ApiRepositories diff --git a/mobile/immich_lint/pubspec.yaml b/mobile/immich_lint/pubspec.yaml index 5d871b03e6..4cfd8abe81 100644 --- a/mobile/immich_lint/pubspec.yaml +++ b/mobile/immich_lint/pubspec.yaml @@ -5,7 +5,7 @@ environment: sdk: '>=3.0.0 <4.0.0' dependencies: - analyzer: ^7.0.0 + analyzer: ^6.0.0 analyzer_plugin: ^0.11.3 custom_lint_builder: ^0.6.4 glob: ^2.1.2 diff --git a/mobile/lib/interfaces/album.interface.dart b/mobile/lib/interfaces/album.interface.dart index cabf2dee53..86174b7dab 100644 --- a/mobile/lib/interfaces/album.interface.dart +++ b/mobile/lib/interfaces/album.interface.dart @@ -3,6 +3,7 @@ import 'package:immich_mobile/entities/asset.entity.dart'; import 'package:immich_mobile/entities/user.entity.dart'; import 'package:immich_mobile/interfaces/database.interface.dart'; import 'package:immich_mobile/models/albums/album_search.model.dart'; +import 'package:immich_mobile/widgets/asset_grid/asset_grid_data_structure.dart'; abstract interface class IAlbumRepository implements IDatabaseRepository { Future create(Album album); @@ -42,6 +43,14 @@ abstract interface class IAlbumRepository implements IDatabaseRepository { Future recalculateMetadata(Album album); Future> search(String searchTerm, QuickFilterMode filterMode); + + Stream> watchRemoteAlbums(); + + Stream> watchLocalAlbums(); + + Stream watchAlbum(int id); + + Stream getRenderListStream(Album album); } enum AlbumSort { remoteId, localId } diff --git a/mobile/lib/providers/album/album.provider.dart b/mobile/lib/providers/album/album.provider.dart index 8c06faaa6a..d9dd5bcc96 100644 --- a/mobile/lib/providers/album/album.provider.dart +++ b/mobile/lib/providers/album/album.provider.dart @@ -8,43 +8,40 @@ import 'package:immich_mobile/services/album.service.dart'; import 'package:immich_mobile/widgets/asset_grid/asset_grid_data_structure.dart'; import 'package:immich_mobile/entities/asset.entity.dart'; import 'package:immich_mobile/entities/album.entity.dart'; -import 'package:immich_mobile/providers/db.provider.dart'; -import 'package:immich_mobile/utils/renderlist_generator.dart'; -import 'package:isar/isar.dart'; final isRefreshingRemoteAlbumProvider = StateProvider((ref) => false); class AlbumNotifier extends StateNotifier> { - AlbumNotifier(this._albumService, this.db, this.ref) : super([]) { - final query = db.albums.filter().remoteIdIsNotNull(); - query.findAll().then((value) { + AlbumNotifier(this.albumService, this.ref) : super([]) { + albumService.getAllRemoteAlbums().then((value) { if (mounted) { state = value; } }); - _streamSub = query.watch().listen((data) => state = data); + + _streamSub = + albumService.watchRemoteAlbums().listen((data) => state = data); } - final AlbumService _albumService; - final Isar db; + final AlbumService albumService; final Ref ref; late final StreamSubscription> _streamSub; Future refreshRemoteAlbums() async { ref.read(isRefreshingRemoteAlbumProvider.notifier).state = true; - await _albumService.refreshRemoteAlbums(); + await albumService.refreshRemoteAlbums(); ref.read(isRefreshingRemoteAlbumProvider.notifier).state = false; } - Future refreshDeviceAlbums() => _albumService.refreshDeviceAlbums(); + Future refreshDeviceAlbums() => albumService.refreshDeviceAlbums(); - Future deleteAlbum(Album album) => _albumService.deleteAlbum(album); + Future deleteAlbum(Album album) => albumService.deleteAlbum(album); Future createAlbum( String albumTitle, Set assets, ) => - _albumService.createAlbum(albumTitle, assets, []); + albumService.createAlbum(albumTitle, assets, []); Future getAlbumByName( String albumName, { @@ -52,7 +49,7 @@ class AlbumNotifier extends StateNotifier> { bool? shared, bool? owner, }) => - _albumService.getAlbumByName( + albumService.getAlbumByName( albumName, remote: remote, shared: shared, @@ -74,7 +71,7 @@ class AlbumNotifier extends StateNotifier> { } Future leaveAlbum(Album album) async { - var res = await _albumService.leaveAlbum(album); + var res = await albumService.leaveAlbum(album); if (res) { await deleteAlbum(album); @@ -85,15 +82,15 @@ class AlbumNotifier extends StateNotifier> { } void searchAlbums(String searchTerm, QuickFilterMode filterMode) async { - state = await _albumService.search(searchTerm, filterMode); + state = await albumService.search(searchTerm, filterMode); } Future addUsers(Album album, List userIds) async { - await _albumService.addUsers(album, userIds); + await albumService.addUsers(album, userIds); } Future removeUser(Album album, User user) async { - final isRemoved = await _albumService.removeUser(album, user); + final isRemoved = await albumService.removeUser(album, user); if (isRemoved && album.sharedUsers.isEmpty) { state = state.where((element) => element.id != album.id).toList(); @@ -103,25 +100,25 @@ class AlbumNotifier extends StateNotifier> { } Future addAssets(Album album, Iterable assets) async { - await _albumService.addAssets(album, assets); + await albumService.addAssets(album, assets); } Future removeAsset(Album album, Iterable assets) async { - return await _albumService.removeAsset(album, assets); + return await albumService.removeAsset(album, assets); } Future setActivitystatus( Album album, bool enabled, ) { - return _albumService.setActivityStatus(album, enabled); + return albumService.setActivityStatus(album, enabled); } Future toggleSortOrder(Album album) { final order = album.sortOrder == SortOrder.asc ? SortOrder.desc : SortOrder.asc; - return _albumService.updateSortOrder(album, order); + return albumService.updateSortOrder(album, order); } @override @@ -135,57 +132,49 @@ final albumProvider = StateNotifierProvider.autoDispose>((ref) { return AlbumNotifier( ref.watch(albumServiceProvider), - ref.watch(dbProvider), ref, ); }); final albumWatcher = - StreamProvider.autoDispose.family((ref, albumId) async* { - final db = ref.watch(dbProvider); - final a = await db.albums.get(albumId); - if (a != null) yield a; - await for (final a in db.albums.watchObject(albumId, fireImmediately: true)) { - if (a != null) yield a; + StreamProvider.autoDispose.family((ref, id) async* { + final albumService = ref.watch(albumServiceProvider); + + final album = await albumService.getAlbumById(id); + if (album != null) { + yield album; + } + + await for (final album in albumService.watchAlbum(id)) { + if (album != null) { + yield album; + } } }); final albumRenderlistProvider = - StreamProvider.autoDispose.family((ref, albumId) { - final album = ref.watch(albumWatcher(albumId)).value; + StreamProvider.autoDispose.family((ref, id) { + final album = ref.watch(albumWatcher(id)).value; if (album != null) { - final query = album.assets.filter().isTrashedEqualTo(false); - if (album.sortOrder == SortOrder.asc) { - return renderListGeneratorWithGroupBy( - query.sortByFileCreatedAt(), - GroupAssetsBy.none, - ); - } else if (album.sortOrder == SortOrder.desc) { - return renderListGeneratorWithGroupBy( - query.sortByFileCreatedAtDesc(), - GroupAssetsBy.none, - ); - } + return ref.watch(albumServiceProvider).getRenderListGenerator(album); } return const Stream.empty(); }); class LocalAlbumsNotifier extends StateNotifier> { - LocalAlbumsNotifier(this.db) : super([]) { - final query = db.albums.where().remoteIdIsNull(); - - query.findAll().then((value) { + LocalAlbumsNotifier(this.albumService) : super([]) { + albumService.getAllLocalAlbums().then((value) { if (mounted) { state = value; } }); - _streamSub = query.watch().listen((data) => state = data); + _streamSub = albumService.watchLocalAlbums().listen((data) => state = data); } - final Isar db; + final AlbumService albumService; late final StreamSubscription> _streamSub; @override @@ -197,5 +186,5 @@ class LocalAlbumsNotifier extends StateNotifier> { final localAlbumsProvider = StateNotifierProvider.autoDispose>((ref) { - return LocalAlbumsNotifier(ref.watch(dbProvider)); + return LocalAlbumsNotifier(ref.watch(albumServiceProvider)); }); diff --git a/mobile/lib/repositories/album.repository.dart b/mobile/lib/repositories/album.repository.dart index 7f4beee3bb..041820f0f2 100644 --- a/mobile/lib/repositories/album.repository.dart +++ b/mobile/lib/repositories/album.repository.dart @@ -1,4 +1,5 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/constants/enums.dart'; import 'package:immich_mobile/entities/album.entity.dart'; import 'package:immich_mobile/entities/asset.entity.dart'; import 'package:immich_mobile/entities/store.entity.dart'; @@ -7,6 +8,7 @@ import 'package:immich_mobile/interfaces/album.interface.dart'; import 'package:immich_mobile/models/albums/album_search.model.dart'; import 'package:immich_mobile/providers/db.provider.dart'; import 'package:immich_mobile/repositories/database.repository.dart'; +import 'package:immich_mobile/widgets/asset_grid/asset_grid_data_structure.dart'; import 'package:isar/isar.dart'; final albumRepositoryProvider = @@ -152,4 +154,37 @@ class AlbumRepository extends DatabaseRepository implements IAlbumRepository { return await query.findAll(); } + + @override + Stream> watchRemoteAlbums() { + return db.albums.where().remoteIdIsNotNull().watch(); + } + + @override + Stream> watchLocalAlbums() { + return db.albums.where().localIdIsNotNull().watch(); + } + + @override + Stream watchAlbum(int id) { + return db.albums.watchObject(id, fireImmediately: true); + } + + @override + Stream getRenderListStream(Album album) async* { + final query = album.assets.filter().isTrashedEqualTo(false); + final withSortedOption = switch (album.sortOrder) { + SortOrder.asc => query.sortByFileCreatedAt(), + SortOrder.desc => query.sortByFileCreatedAtDesc(), + }; + + yield await RenderList.fromQuery( + withSortedOption, + GroupAssetsBy.none, + ); + + await for (final _ in query.watchLazy()) { + yield await RenderList.fromQuery(withSortedOption, GroupAssetsBy.none); + } + } } diff --git a/mobile/lib/services/album.service.dart b/mobile/lib/services/album.service.dart index a993705e11..49931f0b85 100644 --- a/mobile/lib/services/album.service.dart +++ b/mobile/lib/services/album.service.dart @@ -26,6 +26,7 @@ import 'package:immich_mobile/repositories/album_media.repository.dart'; import 'package:immich_mobile/services/entity.service.dart'; import 'package:immich_mobile/services/sync.service.dart'; import 'package:immich_mobile/services/user.service.dart'; +import 'package:immich_mobile/widgets/asset_grid/asset_grid_data_structure.dart'; import 'package:logging/logging.dart'; final albumServiceProvider = Provider( @@ -442,10 +443,35 @@ class AlbumService { } } - Future> getAll() async { + Future> getAllRemoteAlbums() async { return _albumRepository.getAll(remote: true); } + Future> getAllLocalAlbums() async { + return _albumRepository.getAll(remote: false); + } + + Stream> watchRemoteAlbums() { + return _albumRepository.watchRemoteAlbums(); + } + + Stream> watchLocalAlbums() { + return _albumRepository.watchLocalAlbums(); + } + + /// Get album by Isar ID + Future getAlbumById(int id) { + return _albumRepository.get(id); + } + + Stream watchAlbum(int id) { + return _albumRepository.watchAlbum(id); + } + + Stream getRenderListGenerator(Album album) { + return _albumRepository.getRenderListStream(album); + } + Future> search( String searchTerm, QuickFilterMode filterMode, diff --git a/mobile/openapi/devtools_options.yaml b/mobile/openapi/devtools_options.yaml new file mode 100644 index 0000000000..fa0b357c4f --- /dev/null +++ b/mobile/openapi/devtools_options.yaml @@ -0,0 +1,3 @@ +description: This file stores settings for Dart & Flutter DevTools. +documentation: https://docs.flutter.dev/tools/devtools/extensions#configure-extension-enablement-states +extensions: From 411f96ef4947f5092a04de2faae2f616258ce784 Mon Sep 17 00:00:00 2001 From: Zack Pollard Date: Sat, 15 Feb 2025 10:44:11 +0100 Subject: [PATCH 139/395] fix: place suggestions not clickable in asset set location modal (#16104) Co-authored-by: Alex --- .../shared-components/change-location.svelte | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/web/src/lib/components/shared-components/change-location.svelte b/web/src/lib/components/shared-components/change-location.svelte index 2b027596f4..5970b91160 100644 --- a/web/src/lib/components/shared-components/change-location.svelte +++ b/web/src/lib/components/shared-components/change-location.svelte @@ -109,10 +109,7 @@
{#if suggestionContainer} -
(hideSuggestion = true) }} - use:listNavigation={suggestionContainer} - > +
{/if} -
+
diff --git a/web/src/lib/components/shared-components/control-app-bar.svelte b/web/src/lib/components/shared-components/control-app-bar.svelte index c78edaa601..6f39536ef0 100644 --- a/web/src/lib/components/shared-components/control-app-bar.svelte +++ b/web/src/lib/components/shared-components/control-app-bar.svelte @@ -13,6 +13,7 @@ backIcon?: string; tailwindClasses?: string; forceDark?: boolean; + multiRow?: boolean; onClose?: () => void; leading?: Snippet; children?: Snippet; @@ -24,6 +25,7 @@ backIcon = mdiClose, tailwindClasses = '', forceDark = false, + multiRow = false, onClose = () => {}, leading, children, @@ -67,7 +69,7 @@
From 8634c598503b983ccc9c578de2a67672bf8d964d Mon Sep 17 00:00:00 2001 From: Krassimir Valev Date: Wed, 19 Feb 2025 15:32:52 +0100 Subject: [PATCH 172/395] feat(server): search by partial asset path (#16173) Similarly to how one can search by partial filename, change the path search to work with partial matches instead of looking for a full match. Co-authored-by: Alex --- server/src/entities/asset.entity.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/server/src/entities/asset.entity.ts b/server/src/entities/asset.entity.ts index 1a7d3ee1c1..8ff4130edd 100644 --- a/server/src/entities/asset.entity.ts +++ b/server/src/entities/asset.entity.ts @@ -394,7 +394,9 @@ export function searchAssetBuilder(kysely: Kysely, options: AssetSearchBuild .$if(!!options.libraryId, (qb) => qb.where('assets.libraryId', '=', asUuid(options.libraryId!))) .$if(!!options.userIds, (qb) => qb.where('assets.ownerId', '=', anyUuid(options.userIds!))) .$if(!!options.encodedVideoPath, (qb) => qb.where('assets.encodedVideoPath', '=', options.encodedVideoPath!)) - .$if(!!options.originalPath, (qb) => qb.where('assets.originalPath', '=', options.originalPath!)) + .$if(!!options.originalPath, (qb) => + qb.where(sql`f_unaccent(assets."originalPath")`, 'ilike', sql`'%' || f_unaccent(${options.originalPath}) || '%'`), + ) .$if(!!options.originalFileName, (qb) => qb.where( sql`f_unaccent(assets."originalFileName")`, From aeb3e0a84f0338ed10de589824405192f970758f Mon Sep 17 00:00:00 2001 From: shenlong <139912620+shenlong-tanwen@users.noreply.github.com> Date: Thu, 20 Feb 2025 00:35:24 +0530 Subject: [PATCH 173/395] refactor(mobile): split store into repo and service (#16199) * refactor(mobile): migrate store * refactor(mobile): expand abbreviations * chore(mobile): fix lint --------- Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com> Co-authored-by: Alex --- mobile/analysis_options.yaml | 3 + .../test_utils/general_helper.dart | 8 +- mobile/lib/domain/README.md | 34 +++ .../lib/domain/interfaces/db.interface.dart | 3 + .../domain/interfaces/store.interface.dart | 17 ++ mobile/lib/domain/services/store.service.dart | 106 +++++++ mobile/lib/entities/store.entity.dart | 270 ++++-------------- mobile/lib/infrastructure/README.md | 31 ++ .../infrastructure/entities/store.entity.dart | 12 + .../entities/store.entity.g.dart | 4 +- .../repositories/db.repository.dart | 19 ++ .../repositories/store.repository.dart | 107 +++++++ mobile/lib/interfaces/user.interface.dart | 2 + mobile/lib/main.dart | 52 ++-- mobile/lib/providers/auth.provider.dart | 22 +- .../providers/infrastructure/db.provider.dart | 7 + .../infrastructure/db.provider.g.dart | 24 ++ .../infrastructure/store.provider.dart | 10 + .../infrastructure/store.provider.g.dart | 25 ++ mobile/lib/providers/user.provider.dart | 2 +- mobile/lib/repositories/user.repository.dart | 5 + mobile/lib/routing/auth_guard.dart | 3 +- .../lib/routing/tab_navigation_observer.dart | 5 +- mobile/lib/services/api.service.dart | 6 +- .../app_bar_dialog/app_bar_profile_info.dart | 10 +- .../activity/activities_page_test.dart | 20 +- .../activity/activity_text_field_test.dart | 10 +- .../modules/activity/activity_tile_test.dart | 10 +- .../modules/map/map_theme_override_test.dart | 8 +- .../modules/shared/sync_service_test.dart | 7 +- .../test/pages/search/search.page_test.dart | 7 +- mobile/test/services/auth.service_test.dart | 14 +- mobile/test/test_utils.dart | 6 +- 33 files changed, 582 insertions(+), 287 deletions(-) create mode 100644 mobile/lib/domain/README.md create mode 100644 mobile/lib/domain/interfaces/db.interface.dart create mode 100644 mobile/lib/domain/interfaces/store.interface.dart create mode 100644 mobile/lib/domain/services/store.service.dart create mode 100644 mobile/lib/infrastructure/README.md create mode 100644 mobile/lib/infrastructure/entities/store.entity.dart rename mobile/lib/{ => infrastructure}/entities/store.entity.g.dart (99%) create mode 100644 mobile/lib/infrastructure/repositories/db.repository.dart create mode 100644 mobile/lib/infrastructure/repositories/store.repository.dart create mode 100644 mobile/lib/providers/infrastructure/db.provider.dart create mode 100644 mobile/lib/providers/infrastructure/db.provider.g.dart create mode 100644 mobile/lib/providers/infrastructure/store.provider.dart create mode 100644 mobile/lib/providers/infrastructure/store.provider.g.dart diff --git a/mobile/analysis_options.yaml b/mobile/analysis_options.yaml index 619ba284c1..ffeccbdd50 100644 --- a/mobile/analysis_options.yaml +++ b/mobile/analysis_options.yaml @@ -66,6 +66,9 @@ custom_lint: # required / wanted - lib/entities/*.entity.dart - lib/repositories/{album,asset,backup,database,etag,exif_info,user}.repository.dart + - lib/infrastructure/entities/*.entity.dart + - lib/infrastructure/repositories/{store,db}.repository.dart + - lib/providers/infrastructure/db.provider.dart # acceptable exceptions for the time being (until Isar is fully replaced) - integration_test/test_utils/general_helper.dart - lib/main.dart diff --git a/mobile/integration_test/test_utils/general_helper.dart b/mobile/integration_test/test_utils/general_helper.dart index bf2b252b2b..a3db2d49a8 100644 --- a/mobile/integration_test/test_utils/general_helper.dart +++ b/mobile/integration_test/test_utils/general_helper.dart @@ -4,12 +4,13 @@ import 'package:easy_localization/easy_localization.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/entities/store.entity.dart'; +import 'package:immich_mobile/main.dart' as app; import 'package:immich_mobile/providers/db.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/db.provider.dart'; import 'package:integration_test/integration_test.dart'; import 'package:isar/isar.dart'; // ignore: depend_on_referenced_packages import 'package:meta/meta.dart'; -import 'package:immich_mobile/main.dart' as app; import 'login_helper.dart'; @@ -44,7 +45,10 @@ class ImmichTestHelper { // Load main Widget await tester.pumpWidget( ProviderScope( - overrides: [dbProvider.overrideWithValue(db)], + overrides: [ + dbProvider.overrideWithValue(db), + isarProvider.overrideWithValue(db), + ], child: const app.MainWidget(), ), ); diff --git a/mobile/lib/domain/README.md b/mobile/lib/domain/README.md new file mode 100644 index 0000000000..f9bb2ee561 --- /dev/null +++ b/mobile/lib/domain/README.md @@ -0,0 +1,34 @@ +# Domain Layer + +This directory contains the domain layer of Immich. The domain layer is responsible for the business logic of the app. It includes interfaces for repositories, models, services and utilities. This layer should never depend on anything from the presentation layer or from the infrastructure layer. + +## Structure + +- **[Interfaces](./interfaces/)**: These are the interfaces that define the contract for data operations. +- **[Models](./models/)**: These are the core data classes that represent the business models. +- **[Services](./services/)**: These are the classes that contain the business logic and interact with the repositories. +- **[Utils](./utils/)**: These are utility classes and functions that provide common functionalities used across the domain layer. + +``` +domain/ +├── interfaces/ +│ └── user.interface.dart +├── models/ +│ └── user.model.dart +├── services/ +│ └── user.service.dart +└── utils/ + └── date_utils.dart +``` + +## Usage + +The domain layer provides services that implement the business logic by consuming repositories through dependency injection. Services are exposed through Riverpod providers in the root `providers` directory. + +```dart +// In presentation layer +final userService = ref.watch(userServiceProvider); +final user = await userService.getUser(userId); +``` + +The presentation layer should never directly use repositories, but instead interact with the domain layer through services. \ No newline at end of file diff --git a/mobile/lib/domain/interfaces/db.interface.dart b/mobile/lib/domain/interfaces/db.interface.dart new file mode 100644 index 0000000000..5645d15c47 --- /dev/null +++ b/mobile/lib/domain/interfaces/db.interface.dart @@ -0,0 +1,3 @@ +abstract interface class IDatabaseRepository { + Future transaction(Future Function() callback); +} diff --git a/mobile/lib/domain/interfaces/store.interface.dart b/mobile/lib/domain/interfaces/store.interface.dart new file mode 100644 index 0000000000..cbcce1159c --- /dev/null +++ b/mobile/lib/domain/interfaces/store.interface.dart @@ -0,0 +1,17 @@ +import 'package:immich_mobile/entities/store.entity.dart'; + +abstract interface class IStoreRepository { + Future insert(StoreKey key, T value); + + Future tryGet(StoreKey key); + + Stream watch(StoreKey key); + + Stream watchAll(); + + Future update(StoreKey key, T value); + + Future delete(StoreKey key); + + Future deleteAll(); +} diff --git a/mobile/lib/domain/services/store.service.dart b/mobile/lib/domain/services/store.service.dart new file mode 100644 index 0000000000..ac127b8ee6 --- /dev/null +++ b/mobile/lib/domain/services/store.service.dart @@ -0,0 +1,106 @@ +import 'dart:async'; + +import 'package:immich_mobile/domain/interfaces/store.interface.dart'; +import 'package:immich_mobile/entities/store.entity.dart'; + +class StoreService { + final IStoreRepository _storeRepository; + + final Map _cache = {}; + late final StreamSubscription _storeUpdateSubscription; + + StoreService._({ + required IStoreRepository storeRepository, + }) : _storeRepository = storeRepository; + + // TODO: Temporary typedef to make minimal changes. Remove this and make the presentation layer access store through a provider + static StoreService? _instance; + static StoreService get I { + if (_instance == null) { + throw UnsupportedError("StoreService not initialized. Call init() first"); + } + return _instance!; + } + + // TODO: Replace the implementation with the one from create after removing the typedef + /// Initializes the store with the given [storeRepository] + static Future init({ + required IStoreRepository storeRepository, + }) async { + _instance ??= await create(storeRepository: storeRepository); + return _instance!; + } + + /// Initializes the store with the given [storeRepository] + static Future create({ + required IStoreRepository storeRepository, + }) async { + final instance = StoreService._(storeRepository: storeRepository); + await instance._populateCache(); + instance._storeUpdateSubscription = instance._listenForChange(); + return instance; + } + + /// Fills the cache with the values from the DB + Future _populateCache() async { + for (StoreKey key in StoreKey.values) { + final storeValue = await _storeRepository.tryGet(key); + _cache[key.id] = storeValue; + } + } + + /// Listens for changes in the DB and updates the cache + StreamSubscription _listenForChange() => + _storeRepository.watchAll().listen((event) { + _cache[event.key.id] = event.value; + }); + + /// Disposes the store and cancels the subscription. To reuse the store call init() again + void dispose() async { + await _storeUpdateSubscription.cancel(); + _cache.clear(); + } + + /// Returns the stored value for the given key (possibly null) + T? tryGet(StoreKey key) => _cache[key.id]; + + /// Returns the stored value for the given key or if null the [defaultValue] + /// Throws a [StoreKeyNotFoundException] if both are null + T get(StoreKey key, [T? defaultValue]) { + final value = tryGet(key) ?? defaultValue; + if (value == null) { + throw StoreKeyNotFoundException(key); + } + return value; + } + + /// Asynchronously stores the value in the DB and synchronously in the cache + Future put(StoreKey key, T value) async { + if (_cache[key.id] == value) return; + await _storeRepository.insert(key, value); + _cache[key.id] = value; + } + + /// Watches a specific key for changes + Stream watch(StoreKey key) => _storeRepository.watch(key); + + /// Removes the value asynchronously from the DB and synchronously from the cache + Future delete(StoreKey key) async { + await _storeRepository.delete(key); + _cache.remove(key.id); + } + + /// Clears all values from this store (cache and DB) + Future clear() async { + await _storeRepository.deleteAll(); + _cache.clear(); + } +} + +class StoreKeyNotFoundException implements Exception { + final StoreKey key; + const StoreKeyNotFoundException(this.key); + + @override + String toString() => "Key - <${key.name}> not available in Store"; +} diff --git a/mobile/lib/entities/store.entity.dart b/mobile/lib/entities/store.entity.dart index a6ebe77c4f..f6f78d9d9c 100644 --- a/mobile/lib/entities/store.entity.dart +++ b/mobile/lib/entities/store.entity.dart @@ -1,138 +1,11 @@ import 'dart:convert'; import 'dart:typed_data'; -import 'package:collection/collection.dart'; +import 'package:immich_mobile/domain/services/store.service.dart'; import 'package:immich_mobile/entities/user.entity.dart'; -import 'package:isar/isar.dart'; -import 'package:logging/logging.dart'; -part 'store.entity.g.dart'; - -/// Key-value store for individual items enumerated in StoreKey. -/// Supports String, int and JSON-serializable Objects -/// Can be used concurrently from multiple isolates -class Store { - static final Logger _log = Logger("Store"); - static late final Isar _db; - static final List _cache = - List.filled(StoreKey.values.map((e) => e.id).max + 1, null); - - /// Initializes the store (call exactly once per app start) - static void init(Isar db) { - _db = db; - _populateCache(); - _db.storeValues.where().build().watch().listen(_onChangeListener); - } - - /// clears all values from this store (cache and DB), only for testing! - static Future clear() { - _cache.fillRange(0, _cache.length, null); - return _db.writeTxn(() => _db.storeValues.clear()); - } - - /// Returns the stored value for the given key or if null the [defaultValue] - /// Throws a [StoreKeyNotFoundException] if both are null - static T get(StoreKey key, [T? defaultValue]) { - final value = _cache[key.id] ?? defaultValue; - if (value == null) { - throw StoreKeyNotFoundException(key); - } - return value; - } - - /// Watches a specific key for changes - static Stream watch(StoreKey key) => - _db.storeValues.watchObject(key.id).map((e) => e?._extract(key)); - - /// Returns the stored value for the given key (possibly null) - static T? tryGet(StoreKey key) => _cache[key.id]; - - /// Stores the value synchronously in the cache and asynchronously in the DB - static Future put(StoreKey key, T value) { - if (_cache[key.id] == value) return Future.value(); - _cache[key.id] = value; - return _db.writeTxn( - () async => _db.storeValues.put(await StoreValue._of(value, key)), - ); - } - - /// Removes the value synchronously from the cache and asynchronously from the DB - static Future delete(StoreKey key) { - if (_cache[key.id] == null) return Future.value(); - _cache[key.id] = null; - return _db.writeTxn(() => _db.storeValues.delete(key.id)); - } - - /// Fills the cache with the values from the DB - static _populateCache() { - for (StoreKey key in StoreKey.values) { - final StoreValue? value = _db.storeValues.getSync(key.id); - if (value != null) { - _cache[key.id] = value._extract(key); - } - } - } - - /// updates the state if a value is updated in any isolate - static void _onChangeListener(List? data) { - if (data != null) { - for (StoreValue value in data) { - final key = StoreKey.values.firstWhereOrNull((e) => e.id == value.id); - if (key != null) { - _cache[value.id] = value._extract(key); - } else { - _log.warning("No key available for value id - ${value.id}"); - } - } - } - } -} - -/// Internal class for `Store`, do not use elsewhere. -@Collection(inheritance: false) -class StoreValue { - StoreValue(this.id, {this.intValue, this.strValue}); - Id id; - int? intValue; - String? strValue; - - T? _extract(StoreKey key) => switch (key.type) { - const (int) => intValue as T?, - const (bool) => intValue == null ? null : (intValue! == 1) as T, - const (DateTime) => intValue == null - ? null - : DateTime.fromMicrosecondsSinceEpoch(intValue!) as T, - const (String) => strValue as T?, - _ when key.fromDb != null => key.fromDb!.call(Store._db, intValue!), - _ => throw TypeError(), - }; - - static Future _of(T? value, StoreKey key) async { - int? i; - String? s; - switch (key.type) { - case const (int): - i = value as int?; - break; - case const (bool): - i = value == null ? null : (value == true ? 1 : 0); - break; - case const (DateTime): - i = value == null ? null : (value as DateTime).microsecondsSinceEpoch; - break; - case const (String): - s = value as String?; - break; - default: - if (key.toDb != null) { - i = await key.toDb!.call(Store._db, value); - break; - } - throw TypeError(); - } - return StoreValue(key.id, intValue: i, strValue: s); - } -} +// ignore: non_constant_identifier_names +final Store = StoreService.I; class SSLClientCertStoreVal { final Uint8List data; @@ -164,100 +37,81 @@ class SSLClientCertStoreVal { } } -class StoreKeyNotFoundException implements Exception { - final StoreKey key; - StoreKeyNotFoundException(this.key); - @override - String toString() => "Key '${key.name}' not found in Store"; -} - /// Key for each possible value in the `Store`. /// Defines the data type for each value enum StoreKey { - version(0, type: int), - assetETag(1, type: String), - currentUser(2, type: User, fromDb: _getUser, toDb: _toUser), - deviceIdHash(3, type: int), - deviceId(4, type: String), - backupFailedSince(5, type: DateTime), - backupRequireWifi(6, type: bool), - backupRequireCharging(7, type: bool), - backupTriggerDelay(8, type: int), - serverUrl(10, type: String), - accessToken(11, type: String), - serverEndpoint(12, type: String), - autoBackup(13, type: bool), - backgroundBackup(14, type: bool), - sslClientCertData(15, type: String), - sslClientPasswd(16, type: String), + version._(0), + assetETag._(1), + currentUser._(2), + deviceIdHash._(3), + deviceId._(4), + backupFailedSince._(5), + backupRequireWifi._(6), + backupRequireCharging._(7), + backupTriggerDelay._(8), + serverUrl._(10), + accessToken._(11), + serverEndpoint._(12), + autoBackup._(13), + backgroundBackup._(14), + sslClientCertData._(15), + sslClientPasswd._(16), // user settings from [AppSettingsEnum] below: - loadPreview(100, type: bool), - loadOriginal(101, type: bool), - themeMode(102, type: String), - tilesPerRow(103, type: int), - dynamicLayout(104, type: bool), - groupAssetsBy(105, type: int), - uploadErrorNotificationGracePeriod(106, type: int), - backgroundBackupTotalProgress(107, type: bool), - backgroundBackupSingleProgress(108, type: bool), - storageIndicator(109, type: bool), - thumbnailCacheSize(110, type: int), - imageCacheSize(111, type: int), - albumThumbnailCacheSize(112, type: int), - selectedAlbumSortOrder(113, type: int), - advancedTroubleshooting(114, type: bool), - logLevel(115, type: int), - preferRemoteImage(116, type: bool), - loopVideo(117, type: bool), + loadPreview._(100), + loadOriginal._(101), + themeMode._(102), + tilesPerRow._(103), + dynamicLayout._(104), + groupAssetsBy._(105), + uploadErrorNotificationGracePeriod._(106), + backgroundBackupTotalProgress._(107), + backgroundBackupSingleProgress._(108), + storageIndicator._(109), + thumbnailCacheSize._(110), + imageCacheSize._(111), + albumThumbnailCacheSize._(112), + selectedAlbumSortOrder._(113), + advancedTroubleshooting._(114), + logLevel._(115), + preferRemoteImage._(116), + loopVideo._(117), // map related settings - mapShowFavoriteOnly(118, type: bool), - mapRelativeDate(119, type: int), - selfSignedCert(120, type: bool), - mapIncludeArchived(121, type: bool), - ignoreIcloudAssets(122, type: bool), - selectedAlbumSortReverse(123, type: bool), - mapThemeMode(124, type: int), - mapwithPartners(125, type: bool), - enableHapticFeedback(126, type: bool), - customHeaders(127, type: String), + mapShowFavoriteOnly._(118), + mapRelativeDate._(119), + selfSignedCert._(120), + mapIncludeArchived._(121), + ignoreIcloudAssets._(122), + selectedAlbumSortReverse._(123), + mapThemeMode._(124), + mapwithPartners._(125), + enableHapticFeedback._(126), + customHeaders._(127), // theme settings - primaryColor(128, type: String), - dynamicTheme(129, type: bool), - colorfulInterface(130, type: bool), + primaryColor._(128), + dynamicTheme._(129), + colorfulInterface._(130), - syncAlbums(131, type: bool), + syncAlbums._(131), // Auto endpoint switching - autoEndpointSwitching(132, type: bool), - preferredWifiName(133, type: String), - localEndpoint(134, type: String), - externalEndpointList(135, type: String), + autoEndpointSwitching._(132), + preferredWifiName._(133), + localEndpoint._(134), + externalEndpointList._(135), // Video settings - loadOriginalVideo(136, type: bool), + loadOriginalVideo._(136), ; - const StoreKey( - this.id, { - required this.type, - this.fromDb, - this.toDb, - }); + const StoreKey._(this.id); final int id; - final Type type; - final T? Function(Isar, int)? fromDb; - final Future Function(Isar, T)? toDb; + Type get type => T; } -T? _getUser(Isar db, int i) { - final User? u = db.users.getSync(i); - return u as T?; -} +class StoreUpdateEvent { + final StoreKey key; + final T? value; -Future _toUser(Isar db, T u) { - if (u is User) { - return db.users.put(u); - } - throw TypeError(); + const StoreUpdateEvent(this.key, this.value); } diff --git a/mobile/lib/infrastructure/README.md b/mobile/lib/infrastructure/README.md new file mode 100644 index 0000000000..8959704270 --- /dev/null +++ b/mobile/lib/infrastructure/README.md @@ -0,0 +1,31 @@ +# Infrastructure Layer + +This directory contains the infrastructure layer of Immich. The infrastructure layer is responsible for the implementation details of the app. It includes data sources, APIs, and other external dependencies. + +## Structure + +- **[Entities](./entities/)**: These are the classes that define the database schema for the domain models. +- **[Repositories](./repositories/)**: These are the actual implementation of the domain interfaces. A single interface might have multiple implementations. +- **[Utils](./utils/)**: These are utility classes and functions specific to infrastructure implementations. + +``` +infrastructure/ +├── entities/ +│ └── user.entity.dart +├── repositories/ +│ └── user.repository.dart +└── utils/ + └── database_utils.dart +``` + +## Usage + +The infrastructure layer provides concrete implementations of repository interfaces defined in the domain layer. These implementations are exposed through Riverpod providers in the root `providers` directory. + +```dart +// In domain/services/user.service.dart +final userRepository = ref.watch(userRepositoryProvider); +final user = await userRepository.getUser(userId); +``` + +The domain layer should never directly instantiate repository implementations, but instead receive them through dependency injection. \ No newline at end of file diff --git a/mobile/lib/infrastructure/entities/store.entity.dart b/mobile/lib/infrastructure/entities/store.entity.dart new file mode 100644 index 0000000000..ef47af8f52 --- /dev/null +++ b/mobile/lib/infrastructure/entities/store.entity.dart @@ -0,0 +1,12 @@ +import 'package:isar/isar.dart'; + +part 'store.entity.g.dart'; + +/// Internal class for `Store`, do not use elsewhere. +@Collection(inheritance: false) +class StoreValue { + const StoreValue(this.id, {this.intValue, this.strValue}); + final Id id; + final int? intValue; + final String? strValue; +} diff --git a/mobile/lib/entities/store.entity.g.dart b/mobile/lib/infrastructure/entities/store.entity.g.dart similarity index 99% rename from mobile/lib/entities/store.entity.g.dart rename to mobile/lib/infrastructure/entities/store.entity.g.dart index 7d3210ff85..b97b5b0a28 100644 --- a/mobile/lib/entities/store.entity.g.dart +++ b/mobile/lib/infrastructure/entities/store.entity.g.dart @@ -105,9 +105,7 @@ List> _storeValueGetLinks(StoreValue object) { return []; } -void _storeValueAttach(IsarCollection col, Id id, StoreValue object) { - object.id = id; -} +void _storeValueAttach(IsarCollection col, Id id, StoreValue object) {} extension StoreValueQueryWhereSort on QueryBuilder { diff --git a/mobile/lib/infrastructure/repositories/db.repository.dart b/mobile/lib/infrastructure/repositories/db.repository.dart new file mode 100644 index 0000000000..74e182bdee --- /dev/null +++ b/mobile/lib/infrastructure/repositories/db.repository.dart @@ -0,0 +1,19 @@ +import 'dart:async'; + +import 'package:immich_mobile/domain/interfaces/db.interface.dart'; +import 'package:isar/isar.dart'; + +// #zoneTxn is the symbol used by Isar to mark a transaction within the current zone +// ref: isar/isar_common.dart +const Symbol _kzoneTxn = #zoneTxn; + +class IsarDatabaseRepository implements IDatabaseRepository { + final Isar _db; + const IsarDatabaseRepository(Isar db) : _db = db; + + // Isar do not support nested transactions. This is a workaround to prevent us from making nested transactions + // Reuse the current transaction if it is already active, else start a new transaction + @override + Future transaction(Future Function() callback) => + Zone.current[_kzoneTxn] == null ? _db.writeTxn(callback) : callback(); +} diff --git a/mobile/lib/infrastructure/repositories/store.repository.dart b/mobile/lib/infrastructure/repositories/store.repository.dart new file mode 100644 index 0000000000..a160a3b48f --- /dev/null +++ b/mobile/lib/infrastructure/repositories/store.repository.dart @@ -0,0 +1,107 @@ +import 'package:immich_mobile/domain/interfaces/store.interface.dart'; +import 'package:immich_mobile/entities/store.entity.dart'; +import 'package:immich_mobile/entities/user.entity.dart'; +import 'package:immich_mobile/infrastructure/entities/store.entity.dart'; +import 'package:immich_mobile/infrastructure/repositories/db.repository.dart'; +import 'package:immich_mobile/repositories/user.repository.dart'; +import 'package:isar/isar.dart'; + +class IsarStoreRepository extends IsarDatabaseRepository + implements IStoreRepository { + final Isar _db; + const IsarStoreRepository(super.db) : _db = db; + + @override + Future deleteAll() async { + return await transaction(() async { + await _db.storeValues.clear(); + return true; + }); + } + + @override + Stream watchAll() { + return _db.storeValues.where().watch().asyncExpand( + (entities) => + Stream.fromFutures(entities.map((e) async => _toUpdateEvent(e))), + ); + } + + @override + Future delete(StoreKey key) async { + return await transaction(() async => await _db.storeValues.delete(key.id)); + } + + @override + Future insert(StoreKey key, T value) async { + return await transaction(() async { + await _db.storeValues.put(await _fromValue(key, value)); + return true; + }); + } + + @override + Future tryGet(StoreKey key) async { + final entity = (await _db.storeValues.get(key.id)); + if (entity == null) { + return null; + } + return await _toValue(key, entity); + } + + @override + Future update(StoreKey key, T value) async { + return await transaction(() async { + await _db.storeValues.put(await _fromValue(key, value)); + return true; + }); + } + + @override + Stream watch(StoreKey key) async* { + yield* _db.storeValues + .watchObject(key.id, fireImmediately: true) + .asyncMap((e) async => e == null ? null : await _toValue(key, e)); + } + + Future _toUpdateEvent(StoreValue entity) async { + final key = StoreKey.values.firstWhere((e) => e.id == entity.id); + final value = await _toValue(key, entity); + return StoreUpdateEvent(key, value); + } + + Future _toValue(StoreKey key, StoreValue entity) async => + switch (key.type) { + const (int) => entity.intValue, + const (String) => entity.strValue, + const (bool) => entity.intValue == 1, + const (DateTime) => entity.intValue == null + ? null + : DateTime.fromMillisecondsSinceEpoch(entity.intValue!), + const (User) => await UserRepository(_db).getByDbId(entity.intValue!), + _ => null, + } as T?; + + Future _fromValue(StoreKey key, T value) async { + final (int? intValue, String? strValue) = switch (key.type) { + const (int) => (value as int, null), + const (String) => (null, value as String), + const (bool) => ( + (value as bool) ? 1 : 0, + null, + ), + const (DateTime) => ( + (value as DateTime).millisecondsSinceEpoch, + null, + ), + const (User) => ( + (await UserRepository(_db).update(value as User)).isarId, + null + ), + _ => throw UnsupportedError( + "Unsupported primitive type: ${key.type} for key: ${key.name}", + ), + }; + return StoreValue(key.id, intValue: intValue, strValue: strValue); + } +} diff --git a/mobile/lib/interfaces/user.interface.dart b/mobile/lib/interfaces/user.interface.dart index 601730f3f8..d099e0e50b 100644 --- a/mobile/lib/interfaces/user.interface.dart +++ b/mobile/lib/interfaces/user.interface.dart @@ -4,6 +4,8 @@ import 'package:immich_mobile/interfaces/database.interface.dart'; abstract interface class IUserRepository implements IDatabaseRepository { Future get(String id); + Future getByDbId(int id); + Future> getByIds(List ids); Future> getAll({bool self = true, UserSort? sortBy}); diff --git a/mobile/lib/main.dart b/mobile/lib/main.dart index 139366b359..822d772278 100644 --- a/mobile/lib/main.dart +++ b/mobile/lib/main.dart @@ -4,45 +4,48 @@ import 'dart:io'; import 'package:background_downloader/background_downloader.dart'; import 'package:device_info_plus/device_info_plus.dart'; import 'package:easy_localization/easy_localization.dart'; -import 'package:immich_mobile/providers/asset_viewer/share_intent_upload.provider.dart'; -import 'package:intl/date_symbol_data_local.dart'; -import 'package:timezone/data/latest.dart'; -import 'package:isar/isar.dart'; -import 'package:logging/logging.dart'; -import 'package:path_provider/path_provider.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_displaymode/flutter_displaymode.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/constants/locales.dart'; -import 'package:immich_mobile/providers/locale_provider.dart'; -import 'package:immich_mobile/providers/theme.provider.dart'; -import 'package:immich_mobile/providers/app_life_cycle.provider.dart'; -import 'package:immich_mobile/providers/db.provider.dart'; -import 'package:immich_mobile/routing/router.dart'; -import 'package:immich_mobile/routing/tab_navigation_observer.dart'; -import 'package:immich_mobile/entities/backup_album.entity.dart'; -import 'package:immich_mobile/entities/duplicated_asset.entity.dart'; +import 'package:immich_mobile/domain/services/store.service.dart'; 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'; +import 'package:immich_mobile/entities/backup_album.entity.dart'; +import 'package:immich_mobile/entities/duplicated_asset.entity.dart'; import 'package:immich_mobile/entities/etag.entity.dart'; import 'package:immich_mobile/entities/exif_info.entity.dart'; import 'package:immich_mobile/entities/ios_device_asset.entity.dart'; import 'package:immich_mobile/entities/logger_message.entity.dart'; -import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/entities/user.entity.dart'; +import 'package:immich_mobile/extensions/build_context_extensions.dart'; +import 'package:immich_mobile/infrastructure/entities/store.entity.dart'; +import 'package:immich_mobile/infrastructure/repositories/store.repository.dart'; +import 'package:immich_mobile/providers/app_life_cycle.provider.dart'; +import 'package:immich_mobile/providers/asset_viewer/share_intent_upload.provider.dart'; +import 'package:immich_mobile/providers/db.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/db.provider.dart'; +import 'package:immich_mobile/providers/locale_provider.dart'; +import 'package:immich_mobile/providers/theme.provider.dart'; +import 'package:immich_mobile/routing/router.dart'; +import 'package:immich_mobile/routing/tab_navigation_observer.dart'; import 'package:immich_mobile/services/background.service.dart'; import 'package:immich_mobile/services/immich_logger.service.dart'; import 'package:immich_mobile/services/local_notification.service.dart'; -import 'package:immich_mobile/utils/migration.dart'; -import 'package:immich_mobile/utils/download.dart'; -import 'package:immich_mobile/utils/cache/widgets_binding.dart'; -import 'package:immich_mobile/utils/http_ssl_cert_override.dart'; -import 'package:immich_mobile/theme/theme_data.dart'; import 'package:immich_mobile/theme/dynamic_theme.dart'; +import 'package:immich_mobile/theme/theme_data.dart'; +import 'package:immich_mobile/utils/cache/widgets_binding.dart'; +import 'package:immich_mobile/utils/download.dart'; +import 'package:immich_mobile/utils/http_ssl_cert_override.dart'; +import 'package:immich_mobile/utils/migration.dart'; +import 'package:intl/date_symbol_data_local.dart'; +import 'package:isar/isar.dart'; +import 'package:logging/logging.dart'; +import 'package:path_provider/path_provider.dart'; +import 'package:timezone/data/latest.dart'; void main() async { ImmichWidgetsBinding(); @@ -53,7 +56,10 @@ void main() async { runApp( ProviderScope( - overrides: [dbProvider.overrideWithValue(db)], + overrides: [ + dbProvider.overrideWithValue(db), + isarProvider.overrideWithValue(db), + ], child: const MainWidget(), ), ); @@ -135,7 +141,7 @@ Future loadDb() async { directory: dir.path, maxSizeMiB: 1024, ); - Store.init(db); + await StoreService.init(storeRepository: IsarStoreRepository(db)); return db; } diff --git a/mobile/lib/providers/auth.provider.dart b/mobile/lib/providers/auth.provider.dart index a23ffd3d68..573c490e7c 100644 --- a/mobile/lib/providers/auth.provider.dart +++ b/mobile/lib/providers/auth.provider.dart @@ -2,9 +2,9 @@ import 'package:flutter/foundation.dart'; import 'package:flutter_udid/flutter_udid.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/entities/store.entity.dart'; -import 'package:immich_mobile/models/auth/login_response.model.dart'; -import 'package:immich_mobile/models/auth/auth_state.model.dart'; import 'package:immich_mobile/entities/user.entity.dart'; +import 'package:immich_mobile/models/auth/auth_state.model.dart'; +import 'package:immich_mobile/models/auth/login_response.model.dart'; import 'package:immich_mobile/providers/api.provider.dart'; import 'package:immich_mobile/services/api.service.dart'; import 'package:immich_mobile/services/auth.service.dart'; @@ -98,7 +98,7 @@ class AuthNotifier extends StateNotifier { Future saveAuthInfo({ required String accessToken, }) async { - _apiService.setAccessToken(accessToken); + await _apiService.setAccessToken(accessToken); // Get the deviceid from the store if it exists, otherwise generate a new one String deviceId = @@ -141,13 +141,13 @@ class AuthNotifier extends StateNotifier { // If the user information is successfully retrieved, update the store // Due to the flow of the code, this will always happen on first login if (userResponse != null) { - Store.put(StoreKey.deviceId, deviceId); - Store.put(StoreKey.deviceIdHash, fastHash(deviceId)); - Store.put( + await Store.put(StoreKey.deviceId, deviceId); + await Store.put(StoreKey.deviceIdHash, fastHash(deviceId)); + await Store.put( StoreKey.currentUser, User.fromUserDto(userResponse, userPreferences), ); - Store.put(StoreKey.accessToken, accessToken); + await Store.put(StoreKey.accessToken, accessToken); user = User.fromUserDto(userResponse, userPreferences); } else { @@ -173,12 +173,12 @@ class AuthNotifier extends StateNotifier { return true; } - Future saveWifiName(String wifiName) { - return Store.put(StoreKey.preferredWifiName, wifiName); + Future saveWifiName(String wifiName) async { + await Store.put(StoreKey.preferredWifiName, wifiName); } - Future saveLocalEndpoint(String url) { - return Store.put(StoreKey.localEndpoint, url); + Future saveLocalEndpoint(String url) async { + await Store.put(StoreKey.localEndpoint, url); } String? getSavedWifiName() { diff --git a/mobile/lib/providers/infrastructure/db.provider.dart b/mobile/lib/providers/infrastructure/db.provider.dart new file mode 100644 index 0000000000..447039478e --- /dev/null +++ b/mobile/lib/providers/infrastructure/db.provider.dart @@ -0,0 +1,7 @@ +import 'package:isar/isar.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +part 'db.provider.g.dart'; + +@Riverpod(keepAlive: true) +Isar isar(IsarRef ref) => throw UnimplementedError('isar'); diff --git a/mobile/lib/providers/infrastructure/db.provider.g.dart b/mobile/lib/providers/infrastructure/db.provider.g.dart new file mode 100644 index 0000000000..a6122394ea --- /dev/null +++ b/mobile/lib/providers/infrastructure/db.provider.g.dart @@ -0,0 +1,24 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'db.provider.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +String _$isarHash() => r'69d3a06aa7e69a4381478e03f7956eb07d7f7feb'; + +/// See also [isar]. +@ProviderFor(isar) +final isarProvider = Provider.internal( + isar, + name: r'isarProvider', + debugGetCreateSourceHash: + const bool.fromEnvironment('dart.vm.product') ? null : _$isarHash, + dependencies: null, + allTransitiveDependencies: null, +); + +typedef IsarRef = ProviderRef; +// ignore_for_file: type=lint +// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member diff --git a/mobile/lib/providers/infrastructure/store.provider.dart b/mobile/lib/providers/infrastructure/store.provider.dart new file mode 100644 index 0000000000..cb7024ad51 --- /dev/null +++ b/mobile/lib/providers/infrastructure/store.provider.dart @@ -0,0 +1,10 @@ +import 'package:immich_mobile/domain/interfaces/store.interface.dart'; +import 'package:immich_mobile/infrastructure/repositories/store.repository.dart'; +import 'package:immich_mobile/providers/infrastructure/db.provider.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +part 'store.provider.g.dart'; + +@riverpod +IStoreRepository storeRepository(StoreRepositoryRef ref) => + IsarStoreRepository(ref.watch(isarProvider)); diff --git a/mobile/lib/providers/infrastructure/store.provider.g.dart b/mobile/lib/providers/infrastructure/store.provider.g.dart new file mode 100644 index 0000000000..69523af272 --- /dev/null +++ b/mobile/lib/providers/infrastructure/store.provider.g.dart @@ -0,0 +1,25 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'store.provider.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +String _$storeRepositoryHash() => r'9f378b96e552151fa14a8c8ce2c30a5f38f436ed'; + +/// See also [storeRepository]. +@ProviderFor(storeRepository) +final storeRepositoryProvider = AutoDisposeProvider.internal( + storeRepository, + name: r'storeRepositoryProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$storeRepositoryHash, + dependencies: null, + allTransitiveDependencies: null, +); + +typedef StoreRepositoryRef = AutoDisposeProviderRef; +// ignore_for_file: type=lint +// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member diff --git a/mobile/lib/providers/user.provider.dart b/mobile/lib/providers/user.provider.dart index 971cfd5103..73fc283109 100644 --- a/mobile/lib/providers/user.provider.dart +++ b/mobile/lib/providers/user.provider.dart @@ -23,7 +23,7 @@ class CurrentUserProvider extends StateNotifier { final user = await _apiService.usersApi.getMyUser(); final userPreferences = await _apiService.usersApi.getMyPreferences(); if (user != null) { - Store.put( + await Store.put( StoreKey.currentUser, User.fromUserDto(user, userPreferences), ); diff --git a/mobile/lib/repositories/user.repository.dart b/mobile/lib/repositories/user.repository.dart index e490c7d8c1..4f1a9e7267 100644 --- a/mobile/lib/repositories/user.repository.dart +++ b/mobile/lib/repositories/user.repository.dart @@ -58,6 +58,11 @@ class UserRepository extends DatabaseRepository implements IUserRepository { .isarIdEqualTo(Store.get(StoreKey.currentUser).isarId) .findAll(); + @override + Future getByDbId(int id) async { + return await db.users.get(id); + } + @override Future clearTable() async { await txn(() async { diff --git a/mobile/lib/routing/auth_guard.dart b/mobile/lib/routing/auth_guard.dart index c99a890fc8..65631911ec 100644 --- a/mobile/lib/routing/auth_guard.dart +++ b/mobile/lib/routing/auth_guard.dart @@ -1,8 +1,9 @@ import 'dart:io'; import 'package:auto_route/auto_route.dart'; -import 'package:immich_mobile/routing/router.dart'; +import 'package:immich_mobile/domain/services/store.service.dart'; import 'package:immich_mobile/entities/store.entity.dart'; +import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/services/api.service.dart'; import 'package:logging/logging.dart'; import 'package:openapi/api.dart'; diff --git a/mobile/lib/routing/tab_navigation_observer.dart b/mobile/lib/routing/tab_navigation_observer.dart index 7d96b83d02..f48e35c813 100644 --- a/mobile/lib/routing/tab_navigation_observer.dart +++ b/mobile/lib/routing/tab_navigation_observer.dart @@ -1,12 +1,11 @@ import 'package:auto_route/auto_route.dart'; import 'package:flutter/foundation.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:immich_mobile/providers/memory.provider.dart'; - import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/entities/user.entity.dart'; import 'package:immich_mobile/providers/api.provider.dart'; import 'package:immich_mobile/providers/asset.provider.dart'; +import 'package:immich_mobile/providers/memory.provider.dart'; import 'package:immich_mobile/providers/server_info.provider.dart'; class TabNavigationObserver extends AutoRouterObserver { @@ -37,7 +36,7 @@ class TabNavigationObserver extends AutoRouterObserver { return; } - Store.put( + await Store.put( StoreKey.currentUser, User.fromUserDto(userResponseDto, userPreferences), ); diff --git a/mobile/lib/services/api.service.dart b/mobile/lib/services/api.service.dart index c72a4bf1bc..17e90cd009 100644 --- a/mobile/lib/services/api.service.dart +++ b/mobile/lib/services/api.service.dart @@ -4,11 +4,11 @@ import 'dart:io'; import 'package:device_info_plus/device_info_plus.dart'; import 'package:flutter/material.dart'; +import 'package:http/http.dart'; import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/utils/url_helper.dart'; import 'package:logging/logging.dart'; import 'package:openapi/api.dart'; -import 'package:http/http.dart'; class ApiService implements Authentication { late ApiClient _apiClient; @@ -147,9 +147,9 @@ class ApiService implements Authentication { return ""; } - void setAccessToken(String accessToken) { + Future setAccessToken(String accessToken) async { _accessToken = accessToken; - Store.put(StoreKey.accessToken, accessToken); + await Store.put(StoreKey.accessToken, accessToken); } Future setDeviceInfoHeader() async { diff --git a/mobile/lib/widgets/common/app_bar_dialog/app_bar_profile_info.dart b/mobile/lib/widgets/common/app_bar_dialog/app_bar_profile_info.dart index f0006d1ada..bf01d8643a 100644 --- a/mobile/lib/widgets/common/app_bar_dialog/app_bar_profile_info.dart +++ b/mobile/lib/widgets/common/app_bar_dialog/app_bar_profile_info.dart @@ -1,14 +1,14 @@ import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:image_picker/image_picker.dart'; +import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/theme_extensions.dart'; -import 'package:immich_mobile/providers/upload_profile_image.provider.dart'; -import 'package:immich_mobile/entities/store.entity.dart'; -import 'package:immich_mobile/providers/user.provider.dart'; -import 'package:immich_mobile/widgets/common/user_circle_avatar.dart'; import 'package:immich_mobile/providers/auth.provider.dart'; +import 'package:immich_mobile/providers/upload_profile_image.provider.dart'; +import 'package:immich_mobile/providers/user.provider.dart'; import 'package:immich_mobile/widgets/common/immich_loading_indicator.dart'; +import 'package:immich_mobile/widgets/common/user_circle_avatar.dart'; class AppBarProfileInfoBox extends HookConsumerWidget { const AppBarProfileInfoBox({ @@ -67,7 +67,7 @@ class AppBarProfileInfoBox extends HookConsumerWidget { ); if (user != null) { user.profileImagePath = profileImagePath; - Store.put(StoreKey.currentUser, user); + await Store.put(StoreKey.currentUser, user); ref.read(currentUserProvider.notifier).refresh(); } } diff --git a/mobile/test/modules/activity/activities_page_test.dart b/mobile/test/modules/activity/activities_page_test.dart index a5dda5dc44..38070966c8 100644 --- a/mobile/test/modules/activity/activities_page_test.dart +++ b/mobile/test/modules/activity/activities_page_test.dart @@ -4,18 +4,20 @@ library; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'package:immich_mobile/models/activities/activity.model.dart'; -import 'package:immich_mobile/providers/activity.provider.dart'; -import 'package:immich_mobile/pages/common/activities.page.dart'; -import 'package:immich_mobile/widgets/activities/activity_text_field.dart'; -import 'package:immich_mobile/widgets/activities/dismissible_activity.dart'; -import 'package:immich_mobile/providers/album/current_album.provider.dart'; -import 'package:immich_mobile/providers/asset_viewer/current_asset.provider.dart'; +import 'package:immich_mobile/domain/services/store.service.dart'; import 'package:immich_mobile/entities/album.entity.dart'; import 'package:immich_mobile/entities/asset.entity.dart'; import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/entities/user.entity.dart'; +import 'package:immich_mobile/infrastructure/repositories/store.repository.dart'; +import 'package:immich_mobile/models/activities/activity.model.dart'; +import 'package:immich_mobile/pages/common/activities.page.dart'; +import 'package:immich_mobile/providers/activity.provider.dart'; +import 'package:immich_mobile/providers/album/current_album.provider.dart'; +import 'package:immich_mobile/providers/asset_viewer/current_asset.provider.dart'; import 'package:immich_mobile/providers/user.provider.dart'; +import 'package:immich_mobile/widgets/activities/activity_text_field.dart'; +import 'package:immich_mobile/widgets/activities/dismissible_activity.dart'; import 'package:isar/isar.dart'; import 'package:mocktail/mocktail.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; @@ -25,8 +27,8 @@ import '../../fixtures/asset.stub.dart'; import '../../fixtures/user.stub.dart'; import '../../test_utils.dart'; import '../../widget_tester_extensions.dart'; -import '../asset_viewer/asset_viewer_mocks.dart'; import '../album/album_mocks.dart'; +import '../asset_viewer/asset_viewer_mocks.dart'; import '../shared/shared_mocks.dart'; import 'activity_mocks.dart'; @@ -71,7 +73,7 @@ void main() { setUpAll(() async { TestUtils.init(); db = await TestUtils.initIsar(); - Store.init(db); + await StoreService.init(storeRepository: IsarStoreRepository(db)); Store.put(StoreKey.currentUser, UserStub.admin); Store.put(StoreKey.serverEndpoint, ''); Store.put(StoreKey.accessToken, ''); diff --git a/mobile/test/modules/activity/activity_text_field_test.dart b/mobile/test/modules/activity/activity_text_field_test.dart index caa742873a..1e94f1ddeb 100644 --- a/mobile/test/modules/activity/activity_text_field_test.dart +++ b/mobile/test/modules/activity/activity_text_field_test.dart @@ -4,11 +4,13 @@ library; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'package:immich_mobile/providers/activity.provider.dart'; -import 'package:immich_mobile/widgets/activities/activity_text_field.dart'; -import 'package:immich_mobile/providers/album/current_album.provider.dart'; +import 'package:immich_mobile/domain/services/store.service.dart'; import 'package:immich_mobile/entities/store.entity.dart'; +import 'package:immich_mobile/infrastructure/repositories/store.repository.dart'; +import 'package:immich_mobile/providers/activity.provider.dart'; +import 'package:immich_mobile/providers/album/current_album.provider.dart'; import 'package:immich_mobile/providers/user.provider.dart'; +import 'package:immich_mobile/widgets/activities/activity_text_field.dart'; import 'package:immich_mobile/widgets/common/user_circle_avatar.dart'; import 'package:isar/isar.dart'; import 'package:mocktail/mocktail.dart'; @@ -31,7 +33,7 @@ void main() { setUpAll(() async { TestUtils.init(); db = await TestUtils.initIsar(); - Store.init(db); + await StoreService.init(storeRepository: IsarStoreRepository(db)); Store.put(StoreKey.currentUser, UserStub.admin); Store.put(StoreKey.serverEndpoint, ''); }); diff --git a/mobile/test/modules/activity/activity_tile_test.dart b/mobile/test/modules/activity/activity_tile_test.dart index f64eea851a..fb48359dd5 100644 --- a/mobile/test/modules/activity/activity_tile_test.dart +++ b/mobile/test/modules/activity/activity_tile_test.dart @@ -5,10 +5,12 @@ library; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:immich_mobile/models/activities/activity.model.dart'; -import 'package:immich_mobile/widgets/activities/activity_tile.dart'; -import 'package:immich_mobile/providers/asset_viewer/current_asset.provider.dart'; +import 'package:immich_mobile/domain/services/store.service.dart'; import 'package:immich_mobile/entities/store.entity.dart'; +import 'package:immich_mobile/infrastructure/repositories/store.repository.dart'; +import 'package:immich_mobile/models/activities/activity.model.dart'; +import 'package:immich_mobile/providers/asset_viewer/current_asset.provider.dart'; +import 'package:immich_mobile/widgets/activities/activity_tile.dart'; import 'package:immich_mobile/widgets/common/user_circle_avatar.dart'; import 'package:isar/isar.dart'; @@ -27,7 +29,7 @@ void main() { TestUtils.init(); db = await TestUtils.initIsar(); // For UserCircleAvatar - Store.init(db); + await StoreService.init(storeRepository: IsarStoreRepository(db)); Store.put(StoreKey.currentUser, UserStub.admin); Store.put(StoreKey.serverEndpoint, ''); Store.put(StoreKey.accessToken, ''); diff --git a/mobile/test/modules/map/map_theme_override_test.dart b/mobile/test/modules/map/map_theme_override_test.dart index bd000c8715..5a6b163c04 100644 --- a/mobile/test/modules/map/map_theme_override_test.dart +++ b/mobile/test/modules/map/map_theme_override_test.dart @@ -4,10 +4,13 @@ library; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/domain/services/store.service.dart'; +import 'package:immich_mobile/infrastructure/repositories/store.repository.dart'; import 'package:immich_mobile/models/map/map_state.model.dart'; import 'package:immich_mobile/providers/locale_provider.dart'; import 'package:immich_mobile/providers/map/map_state.provider.dart'; import 'package:immich_mobile/widgets/map/map_theme_override.dart'; +import 'package:isar/isar.dart'; import '../../test_utils.dart'; import '../../widget_tester_extensions.dart'; @@ -17,14 +20,17 @@ void main() { late MockMapStateNotifier mapStateNotifier; late List overrides; late MapState mapState; + late Isar db; setUpAll(() async { TestUtils.init(); + db = await TestUtils.initIsar(); }); - setUp(() { + setUp(() async { mapState = MapState(themeMode: ThemeMode.dark); mapStateNotifier = MockMapStateNotifier(mapState); + await StoreService.init(storeRepository: IsarStoreRepository(db)); overrides = [ mapStateNotifierProvider.overrideWith(() => mapStateNotifier), localeProvider.overrideWithValue(const Locale("en")), diff --git a/mobile/test/modules/shared/sync_service_test.dart b/mobile/test/modules/shared/sync_service_test.dart index 5eca5016fd..a87d422b40 100644 --- a/mobile/test/modules/shared/sync_service_test.dart +++ b/mobile/test/modules/shared/sync_service_test.dart @@ -1,9 +1,11 @@ import 'package:flutter/widgets.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:immich_mobile/domain/services/store.service.dart'; import 'package:immich_mobile/entities/asset.entity.dart'; import 'package:immich_mobile/entities/etag.entity.dart'; import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/entities/user.entity.dart'; +import 'package:immich_mobile/infrastructure/repositories/store.repository.dart'; import 'package:immich_mobile/interfaces/asset.interface.dart'; import 'package:immich_mobile/interfaces/user.interface.dart'; import 'package:immich_mobile/services/immich_logger.service.dart'; @@ -63,10 +65,11 @@ void main() { setUpAll(() async { WidgetsFlutterBinding.ensureInitialized(); final db = await TestUtils.initIsar(); - ImmichLogger(); + db.writeTxnSync(() => db.clearSync()); - Store.init(db); + await StoreService.init(storeRepository: IsarStoreRepository(db)); await Store.put(StoreKey.currentUser, owner); + ImmichLogger(); }); final List initialAssets = [ makeAsset(checksum: "a", remoteId: "0-1"), diff --git a/mobile/test/pages/search/search.page_test.dart b/mobile/test/pages/search/search.page_test.dart index 32b56e9ad3..de19acc5a3 100644 --- a/mobile/test/pages/search/search.page_test.dart +++ b/mobile/test/pages/search/search.page_test.dart @@ -5,10 +5,12 @@ library; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:immich_mobile/entities/store.entity.dart'; +import 'package:immich_mobile/domain/services/store.service.dart'; +import 'package:immich_mobile/infrastructure/repositories/store.repository.dart'; import 'package:immich_mobile/pages/search/search.page.dart'; import 'package:immich_mobile/providers/api.provider.dart'; import 'package:immich_mobile/providers/db.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/db.provider.dart'; import 'package:immich_mobile/providers/search/paginated_search.provider.dart'; import 'package:immich_mobile/widgets/asset_grid/asset_grid_data_structure.dart'; import 'package:isar/isar.dart'; @@ -29,7 +31,7 @@ void main() { setUpAll(() async { TestUtils.init(); db = await TestUtils.initIsar(); - Store.init(db); + await StoreService.init(storeRepository: IsarStoreRepository(db)); mockApiService = MockApiService(); mockSearchApi = MockSearchApi(); when(() => mockApiService.searchApi).thenReturn(mockSearchApi); @@ -39,6 +41,7 @@ void main() { paginatedSearchRenderListProvider .overrideWithValue(AsyncValue.data(RenderList.empty())), dbProvider.overrideWithValue(db), + isarProvider.overrideWithValue(db), apiServiceProvider.overrideWithValue(mockApiService), ]; }); diff --git a/mobile/test/services/auth.service_test.dart b/mobile/test/services/auth.service_test.dart index edbf6495e3..e4f011d940 100644 --- a/mobile/test/services/auth.service_test.dart +++ b/mobile/test/services/auth.service_test.dart @@ -1,10 +1,13 @@ import 'package:flutter/widgets.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'package:immich_mobile/entities/store.entity.dart'; +import 'package:immich_mobile/domain/services/store.service.dart'; +import 'package:immich_mobile/infrastructure/repositories/store.repository.dart'; import 'package:immich_mobile/models/auth/auxilary_endpoint.model.dart'; import 'package:immich_mobile/services/auth.service.dart'; +import 'package:isar/isar.dart'; import 'package:mocktail/mocktail.dart'; import 'package:openapi/api.dart'; + import '../repository.mocks.dart'; import '../service.mocks.dart'; import '../test_utils.dart'; @@ -15,6 +18,7 @@ void main() { late MockAuthRepository authRepository; late MockApiService apiService; late MockNetworkService networkService; + late Isar db; setUp(() async { authApiRepository = MockAuthApiRepository(); @@ -32,12 +36,18 @@ void main() { registerFallbackValue(Uri()); }); + setUpAll(() async { + db = await TestUtils.initIsar(); + db.writeTxnSync(() => db.clearSync()); + await StoreService.init(storeRepository: IsarStoreRepository(db)); + }); + group('validateServerUrl', () { setUpAll(() async { WidgetsFlutterBinding.ensureInitialized(); final db = await TestUtils.initIsar(); db.writeTxnSync(() => db.clearSync()); - Store.init(db); + await StoreService.init(storeRepository: IsarStoreRepository(db)); }); test('Should resolve HTTP endpoint', () async { diff --git a/mobile/test/test_utils.dart b/mobile/test/test_utils.dart index 7afd209f10..39837b6e56 100644 --- a/mobile/test/test_utils.dart +++ b/mobile/test/test_utils.dart @@ -3,17 +3,17 @@ import 'dart:io'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:immich_mobile/entities/backup_album.entity.dart'; -import 'package:immich_mobile/entities/duplicated_asset.entity.dart'; 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'; +import 'package:immich_mobile/entities/backup_album.entity.dart'; +import 'package:immich_mobile/entities/duplicated_asset.entity.dart'; import 'package:immich_mobile/entities/etag.entity.dart'; import 'package:immich_mobile/entities/exif_info.entity.dart'; import 'package:immich_mobile/entities/ios_device_asset.entity.dart'; import 'package:immich_mobile/entities/logger_message.entity.dart'; -import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/entities/user.entity.dart'; +import 'package:immich_mobile/infrastructure/entities/store.entity.dart'; import 'package:isar/isar.dart'; import 'package:mocktail/mocktail.dart'; From 31dc83f3f26eaf4ecc1326585c5b85be0cc8106c Mon Sep 17 00:00:00 2001 From: Jonathan Jogenfors Date: Wed, 19 Feb 2025 20:21:13 +0100 Subject: [PATCH 174/395] fix(server): don't warn about missing timezone (#16211) fix(server): don't warn about timezone --- server/src/services/metadata.service.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/server/src/services/metadata.service.ts b/server/src/services/metadata.service.ts index 19c3656e01..37ec7fa064 100644 --- a/server/src/services/metadata.service.ts +++ b/server/src/services/metadata.service.ts @@ -601,7 +601,7 @@ export class MetadataService extends BaseService { if (timeZone) { this.logger.verbose(`Asset ${asset.id} timezone is ${timeZone} (via ${exifTags.tzSource})`); } else { - this.logger.warn(`Asset ${asset.id} has no time zone information`); + this.logger.debug(`Asset ${asset.id} has no time zone information`); } let dateTimeOriginal = dateTime?.toDate(); @@ -641,7 +641,7 @@ export class MetadataService extends BaseService { // TODO take ref into account if (latitude === 0 && longitude === 0) { - this.logger.warn('Latitude and longitude of 0, setting to null'); + this.logger.debug('Latitude and longitude of 0, setting to null'); latitude = null; longitude = null; } From 76d95cd3481f9d5b21f6f88a1f0e4a98f4dfe12e Mon Sep 17 00:00:00 2001 From: shenlong <139912620+shenlong-tanwen@users.noreply.github.com> Date: Thu, 20 Feb 2025 00:57:32 +0530 Subject: [PATCH 175/395] refactor(mobile): move store settings and store into domain folder (#16201) Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com> --- .../domain/interfaces/store.interface.dart | 2 +- mobile/lib/domain/models/store.model.dart | 80 ++++++++++++++++++ mobile/lib/domain/services/store.service.dart | 2 +- mobile/lib/entities/store.entity.dart | 81 +------------------ .../repositories/store.repository.dart | 2 +- .../pages/common/headers_settings.page.dart | 10 +-- .../common/native_video_viewer.page.dart | 1 + .../lib/pages/common/splash_screen.page.dart | 5 +- .../places/places_collection.page.dart | 1 + .../pages/share_intent/share_intent.page.dart | 9 +-- mobile/lib/providers/asset.provider.dart | 11 +-- mobile/lib/providers/auth.provider.dart | 1 + .../lib/providers/backup/backup.provider.dart | 17 ++-- mobile/lib/providers/user.provider.dart | 1 + mobile/lib/providers/websocket.provider.dart | 5 +- mobile/lib/repositories/album.repository.dart | 1 + .../repositories/album_media.repository.dart | 1 + .../repositories/asset_media.repository.dart | 1 + mobile/lib/repositories/auth.repository.dart | 1 + mobile/lib/repositories/user.repository.dart | 1 + mobile/lib/routing/auth_guard.dart | 1 + .../lib/routing/tab_navigation_observer.dart | 1 + mobile/lib/services/album.service.dart | 13 +-- mobile/lib/services/api.service.dart | 1 + mobile/lib/services/app_settings.service.dart | 1 + mobile/lib/services/auth.service.dart | 1 + mobile/lib/services/background.service.dart | 18 +++-- mobile/lib/services/backup.service.dart | 1 + .../services/backup_verification.service.dart | 1 + mobile/lib/services/device.service.dart | 1 + mobile/lib/services/download.service.dart | 3 +- .../lib/services/immich_logger.service.dart | 1 + mobile/lib/services/upload.service.dart | 1 + mobile/lib/utils/http_ssl_cert_override.dart | 4 +- mobile/lib/utils/image_url_builder.dart | 1 + mobile/lib/utils/migration.dart | 1 + mobile/lib/utils/url_helper.dart | 1 + .../widgets/album/album_thumbnail_card.dart | 3 +- .../app_bar_dialog/app_bar_profile_info.dart | 1 + mobile/lib/widgets/common/immich_app_bar.dart | 12 +-- mobile/lib/widgets/common/immich_image.dart | 5 +- mobile/lib/widgets/common/user_avatar.dart | 3 +- .../widgets/common/user_circle_avatar.dart | 1 + .../widgets/search/curated_places_row.dart | 3 +- mobile/lib/widgets/search/explore_grid.dart | 9 ++- .../external_network_preference.dart | 12 +-- .../networking_settings.dart | 11 ++- .../activity/activities_page_test.dart | 1 + .../activity/activity_text_field_test.dart | 1 + .../modules/activity/activity_tile_test.dart | 1 + .../modules/shared/sync_service_test.dart | 1 + 51 files changed, 195 insertions(+), 153 deletions(-) create mode 100644 mobile/lib/domain/models/store.model.dart diff --git a/mobile/lib/domain/interfaces/store.interface.dart b/mobile/lib/domain/interfaces/store.interface.dart index cbcce1159c..a2d248e801 100644 --- a/mobile/lib/domain/interfaces/store.interface.dart +++ b/mobile/lib/domain/interfaces/store.interface.dart @@ -1,4 +1,4 @@ -import 'package:immich_mobile/entities/store.entity.dart'; +import 'package:immich_mobile/domain/models/store.model.dart'; abstract interface class IStoreRepository { Future insert(StoreKey key, T value); diff --git a/mobile/lib/domain/models/store.model.dart b/mobile/lib/domain/models/store.model.dart new file mode 100644 index 0000000000..4aca207c6f --- /dev/null +++ b/mobile/lib/domain/models/store.model.dart @@ -0,0 +1,80 @@ +import 'package:immich_mobile/entities/user.entity.dart'; + +/// Key for each possible value in the `Store`. +/// Defines the data type for each value +enum StoreKey { + version._(0), + assetETag._(1), + currentUser._(2), + deviceIdHash._(3), + deviceId._(4), + backupFailedSince._(5), + backupRequireWifi._(6), + backupRequireCharging._(7), + backupTriggerDelay._(8), + serverUrl._(10), + accessToken._(11), + serverEndpoint._(12), + autoBackup._(13), + backgroundBackup._(14), + sslClientCertData._(15), + sslClientPasswd._(16), + // user settings from [AppSettingsEnum] below: + loadPreview._(100), + loadOriginal._(101), + themeMode._(102), + tilesPerRow._(103), + dynamicLayout._(104), + groupAssetsBy._(105), + uploadErrorNotificationGracePeriod._(106), + backgroundBackupTotalProgress._(107), + backgroundBackupSingleProgress._(108), + storageIndicator._(109), + thumbnailCacheSize._(110), + imageCacheSize._(111), + albumThumbnailCacheSize._(112), + selectedAlbumSortOrder._(113), + advancedTroubleshooting._(114), + logLevel._(115), + preferRemoteImage._(116), + loopVideo._(117), + // map related settings + mapShowFavoriteOnly._(118), + mapRelativeDate._(119), + selfSignedCert._(120), + mapIncludeArchived._(121), + ignoreIcloudAssets._(122), + selectedAlbumSortReverse._(123), + mapThemeMode._(124), + mapwithPartners._(125), + enableHapticFeedback._(126), + customHeaders._(127), + + // theme settings + primaryColor._(128), + dynamicTheme._(129), + colorfulInterface._(130), + + syncAlbums._(131), + + // Auto endpoint switching + autoEndpointSwitching._(132), + preferredWifiName._(133), + localEndpoint._(134), + externalEndpointList._(135), + + // Video settings + loadOriginalVideo._(136), + ; + + const StoreKey._(this.id); + final int id; + Type get type => T; +} + +class StoreUpdateEvent { + final StoreKey key; + final T? value; + + const StoreUpdateEvent(this.key, this.value); +} diff --git a/mobile/lib/domain/services/store.service.dart b/mobile/lib/domain/services/store.service.dart index ac127b8ee6..de79e9b71d 100644 --- a/mobile/lib/domain/services/store.service.dart +++ b/mobile/lib/domain/services/store.service.dart @@ -1,7 +1,7 @@ import 'dart:async'; import 'package:immich_mobile/domain/interfaces/store.interface.dart'; -import 'package:immich_mobile/entities/store.entity.dart'; +import 'package:immich_mobile/domain/models/store.model.dart'; class StoreService { final IStoreRepository _storeRepository; diff --git a/mobile/lib/entities/store.entity.dart b/mobile/lib/entities/store.entity.dart index f6f78d9d9c..ed955352e2 100644 --- a/mobile/lib/entities/store.entity.dart +++ b/mobile/lib/entities/store.entity.dart @@ -1,8 +1,8 @@ import 'dart:convert'; import 'dart:typed_data'; +import 'package:immich_mobile/domain/models/store.model.dart'; import 'package:immich_mobile/domain/services/store.service.dart'; -import 'package:immich_mobile/entities/user.entity.dart'; // ignore: non_constant_identifier_names final Store = StoreService.I; @@ -36,82 +36,3 @@ class SSLClientCertStoreVal { Store.delete(StoreKey.sslClientPasswd); } } - -/// Key for each possible value in the `Store`. -/// Defines the data type for each value -enum StoreKey { - version._(0), - assetETag._(1), - currentUser._(2), - deviceIdHash._(3), - deviceId._(4), - backupFailedSince._(5), - backupRequireWifi._(6), - backupRequireCharging._(7), - backupTriggerDelay._(8), - serverUrl._(10), - accessToken._(11), - serverEndpoint._(12), - autoBackup._(13), - backgroundBackup._(14), - sslClientCertData._(15), - sslClientPasswd._(16), - // user settings from [AppSettingsEnum] below: - loadPreview._(100), - loadOriginal._(101), - themeMode._(102), - tilesPerRow._(103), - dynamicLayout._(104), - groupAssetsBy._(105), - uploadErrorNotificationGracePeriod._(106), - backgroundBackupTotalProgress._(107), - backgroundBackupSingleProgress._(108), - storageIndicator._(109), - thumbnailCacheSize._(110), - imageCacheSize._(111), - albumThumbnailCacheSize._(112), - selectedAlbumSortOrder._(113), - advancedTroubleshooting._(114), - logLevel._(115), - preferRemoteImage._(116), - loopVideo._(117), - // map related settings - mapShowFavoriteOnly._(118), - mapRelativeDate._(119), - selfSignedCert._(120), - mapIncludeArchived._(121), - ignoreIcloudAssets._(122), - selectedAlbumSortReverse._(123), - mapThemeMode._(124), - mapwithPartners._(125), - enableHapticFeedback._(126), - customHeaders._(127), - - // theme settings - primaryColor._(128), - dynamicTheme._(129), - colorfulInterface._(130), - - syncAlbums._(131), - - // Auto endpoint switching - autoEndpointSwitching._(132), - preferredWifiName._(133), - localEndpoint._(134), - externalEndpointList._(135), - - // Video settings - loadOriginalVideo._(136), - ; - - const StoreKey._(this.id); - final int id; - Type get type => T; -} - -class StoreUpdateEvent { - final StoreKey key; - final T? value; - - const StoreUpdateEvent(this.key, this.value); -} diff --git a/mobile/lib/infrastructure/repositories/store.repository.dart b/mobile/lib/infrastructure/repositories/store.repository.dart index a160a3b48f..f22f0f987e 100644 --- a/mobile/lib/infrastructure/repositories/store.repository.dart +++ b/mobile/lib/infrastructure/repositories/store.repository.dart @@ -1,5 +1,5 @@ import 'package:immich_mobile/domain/interfaces/store.interface.dart'; -import 'package:immich_mobile/entities/store.entity.dart'; +import 'package:immich_mobile/domain/models/store.model.dart'; import 'package:immich_mobile/entities/user.entity.dart'; import 'package:immich_mobile/infrastructure/entities/store.entity.dart'; import 'package:immich_mobile/infrastructure/repositories/db.repository.dart'; diff --git a/mobile/lib/pages/common/headers_settings.page.dart b/mobile/lib/pages/common/headers_settings.page.dart index 7f6ee3e4e2..8674a3cbde 100644 --- a/mobile/lib/pages/common/headers_settings.page.dart +++ b/mobile/lib/pages/common/headers_settings.page.dart @@ -3,9 +3,10 @@ import 'dart:convert'; import 'package:auto_route/auto_route.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; -import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:immich_mobile/entities/store.entity.dart' as store_keys; +import 'package:flutter_hooks/flutter_hooks.dart' hide Store; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/domain/models/store.model.dart'; +import 'package:immich_mobile/entities/store.entity.dart'; class SettingsHeader { String key = ""; @@ -22,8 +23,7 @@ class HeaderSettingsPage extends HookConsumerWidget { final headers = useState>([]); final setInitialHeaders = useState(false); - var headersStr = - store_keys.Store.get(store_keys.StoreKey.customHeaders, ""); + var headersStr = Store.get(StoreKey.customHeaders, ""); if (!setInitialHeaders.value) { if (headersStr.isNotEmpty) { var customHeaders = jsonDecode(headersStr) as Map; @@ -99,7 +99,7 @@ class HeaderSettingsPage extends HookConsumerWidget { } var encoded = jsonEncode(headersMap); - store_keys.Store.put(store_keys.StoreKey.customHeaders, encoded); + Store.put(StoreKey.customHeaders, encoded); } } diff --git a/mobile/lib/pages/common/native_video_viewer.page.dart b/mobile/lib/pages/common/native_video_viewer.page.dart index 8ab0da1b44..23685db274 100644 --- a/mobile/lib/pages/common/native_video_viewer.page.dart +++ b/mobile/lib/pages/common/native_video_viewer.page.dart @@ -5,6 +5,7 @@ import 'package:auto_route/auto_route.dart'; import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart' hide Store; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/domain/models/store.model.dart'; import 'package:immich_mobile/entities/asset.entity.dart'; import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/providers/app_settings.provider.dart'; diff --git a/mobile/lib/pages/common/splash_screen.page.dart b/mobile/lib/pages/common/splash_screen.page.dart index 6a060e19f0..5ea9351c0e 100644 --- a/mobile/lib/pages/common/splash_screen.page.dart +++ b/mobile/lib/pages/common/splash_screen.page.dart @@ -1,11 +1,12 @@ import 'package:auto_route/auto_route.dart'; import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:immich_mobile/providers/backup/backup.provider.dart'; +import 'package:immich_mobile/domain/models/store.model.dart'; +import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/providers/auth.provider.dart'; +import 'package:immich_mobile/providers/backup/backup.provider.dart'; import 'package:immich_mobile/providers/gallery_permission.provider.dart'; import 'package:immich_mobile/routing/router.dart'; -import 'package:immich_mobile/entities/store.entity.dart'; import 'package:logging/logging.dart'; @RoutePage() diff --git a/mobile/lib/pages/library/places/places_collection.page.dart b/mobile/lib/pages/library/places/places_collection.page.dart index f42febc373..d4da3ff37e 100644 --- a/mobile/lib/pages/library/places/places_collection.page.dart +++ b/mobile/lib/pages/library/places/places_collection.page.dart @@ -3,6 +3,7 @@ import 'package:cached_network_image/cached_network_image.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/domain/models/store.model.dart'; import 'package:immich_mobile/entities/asset.entity.dart'; import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; diff --git a/mobile/lib/pages/share_intent/share_intent.page.dart b/mobile/lib/pages/share_intent/share_intent.page.dart index 56d093b761..d18fd15b6d 100644 --- a/mobile/lib/pages/share_intent/share_intent.page.dart +++ b/mobile/lib/pages/share_intent/share_intent.page.dart @@ -1,14 +1,14 @@ import 'package:auto_route/auto_route.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; -import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:flutter_hooks/flutter_hooks.dart' hide Store; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/domain/models/store.model.dart'; +import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/models/upload/share_intent_attachment.model.dart'; - import 'package:immich_mobile/pages/common/large_leading_tile.dart'; import 'package:immich_mobile/providers/asset_viewer/share_intent_upload.provider.dart'; -import 'package:immich_mobile/entities/store.entity.dart' as db_store; @RoutePage() class ShareIntentPage extends HookConsumerWidget { @@ -18,8 +18,7 @@ class ShareIntentPage extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final currentEndpoint = - db_store.Store.get(db_store.StoreKey.serverEndpoint); + final currentEndpoint = Store.get(StoreKey.serverEndpoint); final candidates = ref.watch(shareIntentUploadProvider); final isUploaded = useState(false); diff --git a/mobile/lib/providers/asset.provider.dart b/mobile/lib/providers/asset.provider.dart index bfc03cdd00..1c12eda6b7 100644 --- a/mobile/lib/providers/asset.provider.dart +++ b/mobile/lib/providers/asset.provider.dart @@ -1,19 +1,20 @@ import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:immich_mobile/providers/locale_provider.dart'; -import 'package:immich_mobile/providers/memory.provider.dart'; -import 'package:immich_mobile/services/album.service.dart'; +import 'package:immich_mobile/domain/models/store.model.dart'; +import 'package:immich_mobile/entities/asset.entity.dart'; import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/providers/db.provider.dart'; +import 'package:immich_mobile/providers/locale_provider.dart'; +import 'package:immich_mobile/providers/memory.provider.dart'; import 'package:immich_mobile/providers/user.provider.dart'; +import 'package:immich_mobile/services/album.service.dart'; import 'package:immich_mobile/services/asset.service.dart'; import 'package:immich_mobile/services/etag.service.dart'; import 'package:immich_mobile/services/exif.service.dart'; -import 'package:immich_mobile/widgets/asset_grid/asset_grid_data_structure.dart'; -import 'package:immich_mobile/entities/asset.entity.dart'; import 'package:immich_mobile/services/sync.service.dart'; import 'package:immich_mobile/services/user.service.dart'; import 'package:immich_mobile/utils/renderlist_generator.dart'; +import 'package:immich_mobile/widgets/asset_grid/asset_grid_data_structure.dart'; import 'package:isar/isar.dart'; import 'package:logging/logging.dart'; diff --git a/mobile/lib/providers/auth.provider.dart b/mobile/lib/providers/auth.provider.dart index 573c490e7c..e2b15753a9 100644 --- a/mobile/lib/providers/auth.provider.dart +++ b/mobile/lib/providers/auth.provider.dart @@ -1,6 +1,7 @@ import 'package:flutter/foundation.dart'; import 'package:flutter_udid/flutter_udid.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/domain/models/store.model.dart'; import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/entities/user.entity.dart'; import 'package:immich_mobile/models/auth/auth_state.model.dart'; diff --git a/mobile/lib/providers/backup/backup.provider.dart b/mobile/lib/providers/backup/backup.provider.dart index aab367485c..3b0f724411 100644 --- a/mobile/lib/providers/backup/backup.provider.dart +++ b/mobile/lib/providers/backup/backup.provider.dart @@ -5,30 +5,31 @@ import 'package:collection/collection.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/widgets.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/domain/models/store.model.dart'; import 'package:immich_mobile/entities/album.entity.dart'; +import 'package:immich_mobile/entities/backup_album.entity.dart'; +import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/interfaces/album_media.interface.dart'; import 'package:immich_mobile/interfaces/backup.interface.dart'; import 'package:immich_mobile/interfaces/file_media.interface.dart'; +import 'package:immich_mobile/models/auth/auth_state.model.dart'; import 'package:immich_mobile/models/backup/available_album.model.dart'; -import 'package:immich_mobile/entities/backup_album.entity.dart'; import 'package:immich_mobile/models/backup/backup_candidate.model.dart'; import 'package:immich_mobile/models/backup/backup_state.model.dart'; import 'package:immich_mobile/models/backup/current_upload_asset.model.dart'; import 'package:immich_mobile/models/backup/error_upload_asset.model.dart'; import 'package:immich_mobile/models/backup/success_upload_asset.model.dart'; +import 'package:immich_mobile/models/server_info/server_disk_info.model.dart'; +import 'package:immich_mobile/providers/app_life_cycle.provider.dart'; +import 'package:immich_mobile/providers/auth.provider.dart'; import 'package:immich_mobile/providers/backup/error_backup_list.provider.dart'; +import 'package:immich_mobile/providers/db.provider.dart'; +import 'package:immich_mobile/providers/gallery_permission.provider.dart'; import 'package:immich_mobile/repositories/album_media.repository.dart'; import 'package:immich_mobile/repositories/backup.repository.dart'; import 'package:immich_mobile/repositories/file_media.repository.dart'; import 'package:immich_mobile/services/background.service.dart'; import 'package:immich_mobile/services/backup.service.dart'; -import 'package:immich_mobile/models/auth/auth_state.model.dart'; -import 'package:immich_mobile/providers/auth.provider.dart'; -import 'package:immich_mobile/providers/gallery_permission.provider.dart'; -import 'package:immich_mobile/models/server_info/server_disk_info.model.dart'; -import 'package:immich_mobile/entities/store.entity.dart'; -import 'package:immich_mobile/providers/app_life_cycle.provider.dart'; -import 'package:immich_mobile/providers/db.provider.dart'; import 'package:immich_mobile/services/server_info.service.dart'; import 'package:immich_mobile/utils/backup_progress.dart'; import 'package:immich_mobile/utils/diff.dart'; diff --git a/mobile/lib/providers/user.provider.dart b/mobile/lib/providers/user.provider.dart index 73fc283109..c69245ea98 100644 --- a/mobile/lib/providers/user.provider.dart +++ b/mobile/lib/providers/user.provider.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/domain/models/store.model.dart'; import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/entities/user.entity.dart'; import 'package:immich_mobile/providers/api.provider.dart'; diff --git a/mobile/lib/providers/websocket.provider.dart b/mobile/lib/providers/websocket.provider.dart index 6889db7b7f..f92d2c8421 100644 --- a/mobile/lib/providers/websocket.provider.dart +++ b/mobile/lib/providers/websocket.provider.dart @@ -4,11 +4,12 @@ import 'package:collection/collection.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/widgets.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:immich_mobile/providers/auth.provider.dart'; +import 'package:immich_mobile/domain/models/store.model.dart'; import 'package:immich_mobile/entities/asset.entity.dart'; -import 'package:immich_mobile/models/server_info/server_version.model.dart'; import 'package:immich_mobile/entities/store.entity.dart'; +import 'package:immich_mobile/models/server_info/server_version.model.dart'; import 'package:immich_mobile/providers/asset.provider.dart'; +import 'package:immich_mobile/providers/auth.provider.dart'; import 'package:immich_mobile/providers/db.provider.dart'; import 'package:immich_mobile/providers/server_info.provider.dart'; import 'package:immich_mobile/services/api.service.dart'; diff --git a/mobile/lib/repositories/album.repository.dart b/mobile/lib/repositories/album.repository.dart index 66104e95a5..0f1cf64865 100644 --- a/mobile/lib/repositories/album.repository.dart +++ b/mobile/lib/repositories/album.repository.dart @@ -1,5 +1,6 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/constants/enums.dart'; +import 'package:immich_mobile/domain/models/store.model.dart'; import 'package:immich_mobile/entities/album.entity.dart'; import 'package:immich_mobile/entities/asset.entity.dart'; import 'package:immich_mobile/entities/store.entity.dart'; diff --git a/mobile/lib/repositories/album_media.repository.dart b/mobile/lib/repositories/album_media.repository.dart index c3795f75df..f4f31cf14e 100644 --- a/mobile/lib/repositories/album_media.repository.dart +++ b/mobile/lib/repositories/album_media.repository.dart @@ -1,4 +1,5 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/domain/models/store.model.dart'; import 'package:immich_mobile/entities/album.entity.dart'; import 'package:immich_mobile/entities/asset.entity.dart'; import 'package:immich_mobile/entities/store.entity.dart'; diff --git a/mobile/lib/repositories/asset_media.repository.dart b/mobile/lib/repositories/asset_media.repository.dart index 68fffa08a6..4f8272b44f 100644 --- a/mobile/lib/repositories/asset_media.repository.dart +++ b/mobile/lib/repositories/asset_media.repository.dart @@ -1,4 +1,5 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/domain/models/store.model.dart'; import 'package:immich_mobile/entities/asset.entity.dart'; import 'package:immich_mobile/entities/exif_info.entity.dart'; import 'package:immich_mobile/entities/store.entity.dart'; diff --git a/mobile/lib/repositories/auth.repository.dart b/mobile/lib/repositories/auth.repository.dart index fa504e6ac3..491db3c8a8 100644 --- a/mobile/lib/repositories/auth.repository.dart +++ b/mobile/lib/repositories/auth.repository.dart @@ -1,6 +1,7 @@ import 'dart:convert'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/domain/models/store.model.dart'; import 'package:immich_mobile/entities/album.entity.dart'; import 'package:immich_mobile/entities/asset.entity.dart'; import 'package:immich_mobile/entities/etag.entity.dart'; diff --git a/mobile/lib/repositories/user.repository.dart b/mobile/lib/repositories/user.repository.dart index 4f1a9e7267..ea67b30e0d 100644 --- a/mobile/lib/repositories/user.repository.dart +++ b/mobile/lib/repositories/user.repository.dart @@ -1,4 +1,5 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/domain/models/store.model.dart'; import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/entities/user.entity.dart'; import 'package:immich_mobile/interfaces/user.interface.dart'; diff --git a/mobile/lib/routing/auth_guard.dart b/mobile/lib/routing/auth_guard.dart index 65631911ec..33eb8e81ad 100644 --- a/mobile/lib/routing/auth_guard.dart +++ b/mobile/lib/routing/auth_guard.dart @@ -1,6 +1,7 @@ import 'dart:io'; import 'package:auto_route/auto_route.dart'; +import 'package:immich_mobile/domain/models/store.model.dart'; import 'package:immich_mobile/domain/services/store.service.dart'; import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/routing/router.dart'; diff --git a/mobile/lib/routing/tab_navigation_observer.dart b/mobile/lib/routing/tab_navigation_observer.dart index f48e35c813..b6a845a0b3 100644 --- a/mobile/lib/routing/tab_navigation_observer.dart +++ b/mobile/lib/routing/tab_navigation_observer.dart @@ -1,6 +1,7 @@ import 'package:auto_route/auto_route.dart'; import 'package:flutter/foundation.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/domain/models/store.model.dart'; import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/entities/user.entity.dart'; import 'package:immich_mobile/providers/api.provider.dart'; diff --git a/mobile/lib/services/album.service.dart b/mobile/lib/services/album.service.dart index 778b47a7c9..e189dbe245 100644 --- a/mobile/lib/services/album.service.dart +++ b/mobile/lib/services/album.service.dart @@ -6,23 +6,24 @@ import 'package:collection/collection.dart'; import 'package:flutter/foundation.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/constants/enums.dart'; +import 'package:immich_mobile/domain/models/store.model.dart'; +import 'package:immich_mobile/entities/album.entity.dart'; +import 'package:immich_mobile/entities/asset.entity.dart'; +import 'package:immich_mobile/entities/backup_album.entity.dart'; +import 'package:immich_mobile/entities/store.entity.dart'; +import 'package:immich_mobile/entities/user.entity.dart'; import 'package:immich_mobile/interfaces/album.interface.dart'; import 'package:immich_mobile/interfaces/album_api.interface.dart'; import 'package:immich_mobile/interfaces/album_media.interface.dart'; import 'package:immich_mobile/interfaces/asset.interface.dart'; import 'package:immich_mobile/interfaces/backup.interface.dart'; import 'package:immich_mobile/models/albums/album_add_asset_response.model.dart'; -import 'package:immich_mobile/entities/backup_album.entity.dart'; -import 'package:immich_mobile/entities/album.entity.dart'; -import 'package:immich_mobile/entities/asset.entity.dart'; -import 'package:immich_mobile/entities/store.entity.dart'; -import 'package:immich_mobile/entities/user.entity.dart'; import 'package:immich_mobile/models/albums/album_search.model.dart'; import 'package:immich_mobile/repositories/album.repository.dart'; import 'package:immich_mobile/repositories/album_api.repository.dart'; +import 'package:immich_mobile/repositories/album_media.repository.dart'; import 'package:immich_mobile/repositories/asset.repository.dart'; import 'package:immich_mobile/repositories/backup.repository.dart'; -import 'package:immich_mobile/repositories/album_media.repository.dart'; import 'package:immich_mobile/services/entity.service.dart'; import 'package:immich_mobile/services/sync.service.dart'; import 'package:immich_mobile/services/user.service.dart'; diff --git a/mobile/lib/services/api.service.dart b/mobile/lib/services/api.service.dart index 17e90cd009..823ef6a001 100644 --- a/mobile/lib/services/api.service.dart +++ b/mobile/lib/services/api.service.dart @@ -5,6 +5,7 @@ import 'dart:io'; import 'package:device_info_plus/device_info_plus.dart'; import 'package:flutter/material.dart'; import 'package:http/http.dart'; +import 'package:immich_mobile/domain/models/store.model.dart'; import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/utils/url_helper.dart'; import 'package:logging/logging.dart'; diff --git a/mobile/lib/services/app_settings.service.dart b/mobile/lib/services/app_settings.service.dart index 19e7093faf..1870a61d7a 100644 --- a/mobile/lib/services/app_settings.service.dart +++ b/mobile/lib/services/app_settings.service.dart @@ -1,4 +1,5 @@ import 'package:immich_mobile/constants/colors.dart'; +import 'package:immich_mobile/domain/models/store.model.dart'; import 'package:immich_mobile/entities/store.entity.dart'; enum AppSettingsEnum { diff --git a/mobile/lib/services/auth.service.dart b/mobile/lib/services/auth.service.dart index 08741a15db..be6c64bc43 100644 --- a/mobile/lib/services/auth.service.dart +++ b/mobile/lib/services/auth.service.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'dart:io'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/domain/models/store.model.dart'; import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/interfaces/auth.interface.dart'; import 'package:immich_mobile/interfaces/auth_api.interface.dart'; diff --git a/mobile/lib/services/background.service.dart b/mobile/lib/services/background.service.dart index c059f48f0e..81619bdca1 100644 --- a/mobile/lib/services/background.service.dart +++ b/mobile/lib/services/background.service.dart @@ -3,6 +3,7 @@ import 'dart:developer'; import 'dart:io'; import 'dart:isolate'; import 'dart:ui' show DartPluginRegistrant, IsolateNameServer, PluginUtilities; + import 'package:cancellation_token_http/http.dart'; import 'package:collection/collection.dart'; import 'package:easy_localization/easy_localization.dart'; @@ -10,18 +11,23 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart'; import 'package:flutter/widgets.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/domain/models/store.model.dart'; +import 'package:immich_mobile/entities/backup_album.entity.dart'; +import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/interfaces/backup.interface.dart'; import 'package:immich_mobile/main.dart'; import 'package:immich_mobile/models/backup/backup_candidate.model.dart'; +import 'package:immich_mobile/models/backup/current_upload_asset.model.dart'; +import 'package:immich_mobile/models/backup/error_upload_asset.model.dart'; import 'package:immich_mobile/models/backup/success_upload_asset.model.dart'; import 'package:immich_mobile/repositories/album.repository.dart'; import 'package:immich_mobile/repositories/album_api.repository.dart'; +import 'package:immich_mobile/repositories/album_media.repository.dart'; import 'package:immich_mobile/repositories/asset.repository.dart'; import 'package:immich_mobile/repositories/asset_media.repository.dart'; import 'package:immich_mobile/repositories/auth.repository.dart'; import 'package:immich_mobile/repositories/auth_api.repository.dart'; import 'package:immich_mobile/repositories/backup.repository.dart'; -import 'package:immich_mobile/repositories/album_media.repository.dart'; import 'package:immich_mobile/repositories/etag.repository.dart'; import 'package:immich_mobile/repositories/exif_info.repository.dart'; import 'package:immich_mobile/repositories/file_media.repository.dart'; @@ -31,17 +37,13 @@ import 'package:immich_mobile/repositories/permission.repository.dart'; import 'package:immich_mobile/repositories/user.repository.dart'; import 'package:immich_mobile/repositories/user_api.repository.dart'; import 'package:immich_mobile/services/album.service.dart'; +import 'package:immich_mobile/services/api.service.dart'; +import 'package:immich_mobile/services/app_settings.service.dart'; import 'package:immich_mobile/services/auth.service.dart'; +import 'package:immich_mobile/services/backup.service.dart'; import 'package:immich_mobile/services/entity.service.dart'; import 'package:immich_mobile/services/hash.service.dart'; import 'package:immich_mobile/services/localization.service.dart'; -import 'package:immich_mobile/entities/backup_album.entity.dart'; -import 'package:immich_mobile/models/backup/current_upload_asset.model.dart'; -import 'package:immich_mobile/models/backup/error_upload_asset.model.dart'; -import 'package:immich_mobile/services/backup.service.dart'; -import 'package:immich_mobile/services/app_settings.service.dart'; -import 'package:immich_mobile/entities/store.entity.dart'; -import 'package:immich_mobile/services/api.service.dart'; import 'package:immich_mobile/services/network.service.dart'; import 'package:immich_mobile/services/sync.service.dart'; import 'package:immich_mobile/services/user.service.dart'; diff --git a/mobile/lib/services/backup.service.dart b/mobile/lib/services/backup.service.dart index 802a98571e..a6468f249b 100644 --- a/mobile/lib/services/backup.service.dart +++ b/mobile/lib/services/backup.service.dart @@ -6,6 +6,7 @@ import 'package:cancellation_token_http/http.dart' as http; import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/domain/models/store.model.dart'; import 'package:immich_mobile/entities/album.entity.dart'; import 'package:immich_mobile/entities/asset.entity.dart'; import 'package:immich_mobile/entities/backup_album.entity.dart'; diff --git a/mobile/lib/services/backup_verification.service.dart b/mobile/lib/services/backup_verification.service.dart index 82cfb8347a..5938cd7813 100644 --- a/mobile/lib/services/backup_verification.service.dart +++ b/mobile/lib/services/backup_verification.service.dart @@ -5,6 +5,7 @@ import 'package:collection/collection.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/domain/models/store.model.dart'; import 'package:immich_mobile/entities/asset.entity.dart'; import 'package:immich_mobile/entities/exif_info.entity.dart'; import 'package:immich_mobile/entities/store.entity.dart'; diff --git a/mobile/lib/services/device.service.dart b/mobile/lib/services/device.service.dart index e1676d5683..393274608b 100644 --- a/mobile/lib/services/device.service.dart +++ b/mobile/lib/services/device.service.dart @@ -1,5 +1,6 @@ import 'package:flutter_udid/flutter_udid.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/domain/models/store.model.dart'; import 'package:immich_mobile/entities/store.entity.dart'; final deviceServiceProvider = Provider((ref) => DeviceService()); diff --git a/mobile/lib/services/download.service.dart b/mobile/lib/services/download.service.dart index 7cf6f309e9..45297853f6 100644 --- a/mobile/lib/services/download.service.dart +++ b/mobile/lib/services/download.service.dart @@ -3,8 +3,9 @@ import 'dart:io'; import 'package:background_downloader/background_downloader.dart'; import 'package:flutter/services.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:immich_mobile/entities/store.entity.dart'; +import 'package:immich_mobile/domain/models/store.model.dart'; import 'package:immich_mobile/entities/asset.entity.dart'; +import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/interfaces/download.interface.dart'; import 'package:immich_mobile/interfaces/file_media.interface.dart'; import 'package:immich_mobile/models/download/livephotos_medatada.model.dart'; diff --git a/mobile/lib/services/immich_logger.service.dart b/mobile/lib/services/immich_logger.service.dart index c46fdc21d7..952e8b191e 100644 --- a/mobile/lib/services/immich_logger.service.dart +++ b/mobile/lib/services/immich_logger.service.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'dart:io'; import 'package:flutter/widgets.dart'; +import 'package:immich_mobile/domain/models/store.model.dart'; import 'package:immich_mobile/entities/logger_message.entity.dart'; import 'package:immich_mobile/entities/store.entity.dart'; import 'package:isar/isar.dart'; diff --git a/mobile/lib/services/upload.service.dart b/mobile/lib/services/upload.service.dart index 1ffe01bb93..0734e57212 100644 --- a/mobile/lib/services/upload.service.dart +++ b/mobile/lib/services/upload.service.dart @@ -2,6 +2,7 @@ import 'dart:io'; import 'package:background_downloader/background_downloader.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/domain/models/store.model.dart'; import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/interfaces/upload.interface.dart'; import 'package:immich_mobile/repositories/upload.repository.dart'; diff --git a/mobile/lib/utils/http_ssl_cert_override.dart b/mobile/lib/utils/http_ssl_cert_override.dart index 9ce7334be2..ce0384b998 100644 --- a/mobile/lib/utils/http_ssl_cert_override.dart +++ b/mobile/lib/utils/http_ssl_cert_override.dart @@ -1,6 +1,8 @@ import 'dart:io'; -import 'package:immich_mobile/services/app_settings.service.dart'; + +import 'package:immich_mobile/domain/models/store.model.dart'; import 'package:immich_mobile/entities/store.entity.dart'; +import 'package:immich_mobile/services/app_settings.service.dart'; import 'package:logging/logging.dart'; class HttpSSLCertOverride extends HttpOverrides { diff --git a/mobile/lib/utils/image_url_builder.dart b/mobile/lib/utils/image_url_builder.dart index 9fc7b13eed..d063b3aa91 100644 --- a/mobile/lib/utils/image_url_builder.dart +++ b/mobile/lib/utils/image_url_builder.dart @@ -1,4 +1,5 @@ import 'package:immich_mobile/constants/constants.dart'; +import 'package:immich_mobile/domain/models/store.model.dart'; import 'package:immich_mobile/entities/album.entity.dart'; import 'package:immich_mobile/entities/asset.entity.dart'; import 'package:immich_mobile/entities/store.entity.dart'; diff --git a/mobile/lib/utils/migration.dart b/mobile/lib/utils/migration.dart index ecbda2f266..eedf8f40f8 100644 --- a/mobile/lib/utils/migration.dart +++ b/mobile/lib/utils/migration.dart @@ -1,5 +1,6 @@ import 'dart:async'; +import 'package:immich_mobile/domain/models/store.model.dart'; import 'package:immich_mobile/entities/album.entity.dart'; import 'package:immich_mobile/entities/asset.entity.dart'; import 'package:immich_mobile/entities/etag.entity.dart'; diff --git a/mobile/lib/utils/url_helper.dart b/mobile/lib/utils/url_helper.dart index d351cb5816..6b355e362f 100644 --- a/mobile/lib/utils/url_helper.dart +++ b/mobile/lib/utils/url_helper.dart @@ -1,3 +1,4 @@ +import 'package:immich_mobile/domain/models/store.model.dart'; import 'package:immich_mobile/entities/store.entity.dart'; String sanitizeUrl(String url) { diff --git a/mobile/lib/widgets/album/album_thumbnail_card.dart b/mobile/lib/widgets/album/album_thumbnail_card.dart index b728f2b541..ac62ecee03 100644 --- a/mobile/lib/widgets/album/album_thumbnail_card.dart +++ b/mobile/lib/widgets/album/album_thumbnail_card.dart @@ -1,8 +1,9 @@ import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; -import 'package:immich_mobile/extensions/build_context_extensions.dart'; +import 'package:immich_mobile/domain/models/store.model.dart'; import 'package:immich_mobile/entities/album.entity.dart'; import 'package:immich_mobile/entities/store.entity.dart'; +import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/theme_extensions.dart'; import 'package:immich_mobile/widgets/common/immich_thumbnail.dart'; diff --git a/mobile/lib/widgets/common/app_bar_dialog/app_bar_profile_info.dart b/mobile/lib/widgets/common/app_bar_dialog/app_bar_profile_info.dart index bf01d8643a..d51e122954 100644 --- a/mobile/lib/widgets/common/app_bar_dialog/app_bar_profile_info.dart +++ b/mobile/lib/widgets/common/app_bar_dialog/app_bar_profile_info.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:image_picker/image_picker.dart'; +import 'package:immich_mobile/domain/models/store.model.dart'; import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/theme_extensions.dart'; diff --git a/mobile/lib/widgets/common/immich_app_bar.dart b/mobile/lib/widgets/common/immich_app_bar.dart index 1831a2d168..7a42606797 100644 --- a/mobile/lib/widgets/common/immich_app_bar.dart +++ b/mobile/lib/widgets/common/immich_app_bar.dart @@ -3,17 +3,17 @@ import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:flutter_svg/svg.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:immich_mobile/extensions/build_context_extensions.dart'; +import 'package:immich_mobile/domain/models/store.model.dart'; import 'package:immich_mobile/entities/store.entity.dart'; -import 'package:immich_mobile/providers/immich_logo_provider.dart'; -import 'package:immich_mobile/widgets/common/app_bar_dialog/app_bar_dialog.dart'; -import 'package:immich_mobile/widgets/common/user_circle_avatar.dart'; - -import 'package:immich_mobile/routing/router.dart'; +import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/models/backup/backup_state.model.dart'; import 'package:immich_mobile/models/server_info/server_info.model.dart'; import 'package:immich_mobile/providers/backup/backup.provider.dart'; +import 'package:immich_mobile/providers/immich_logo_provider.dart'; import 'package:immich_mobile/providers/server_info.provider.dart'; +import 'package:immich_mobile/routing/router.dart'; +import 'package:immich_mobile/widgets/common/app_bar_dialog/app_bar_dialog.dart'; +import 'package:immich_mobile/widgets/common/user_circle_avatar.dart'; class ImmichAppBar extends ConsumerWidget implements PreferredSizeWidget { @override diff --git a/mobile/lib/widgets/common/immich_image.dart b/mobile/lib/widgets/common/immich_image.dart index ab0f2584b5..243ef55412 100644 --- a/mobile/lib/widgets/common/immich_image.dart +++ b/mobile/lib/widgets/common/immich_image.dart @@ -1,11 +1,12 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; +import 'package:immich_mobile/domain/models/store.model.dart'; +import 'package:immich_mobile/entities/asset.entity.dart'; +import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/providers/image/immich_local_image_provider.dart'; import 'package:immich_mobile/providers/image/immich_remote_image_provider.dart'; import 'package:immich_mobile/widgets/asset_grid/thumbnail_placeholder.dart'; -import 'package:immich_mobile/entities/asset.entity.dart'; -import 'package:immich_mobile/entities/store.entity.dart'; import 'package:octo_image/octo_image.dart'; class ImmichImage extends StatelessWidget { diff --git a/mobile/lib/widgets/common/user_avatar.dart b/mobile/lib/widgets/common/user_avatar.dart index 9a577d94b3..62491210c9 100644 --- a/mobile/lib/widgets/common/user_avatar.dart +++ b/mobile/lib/widgets/common/user_avatar.dart @@ -1,8 +1,9 @@ import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; -import 'package:immich_mobile/extensions/build_context_extensions.dart'; +import 'package:immich_mobile/domain/models/store.model.dart'; import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/entities/user.entity.dart'; +import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/services/api.service.dart'; Widget userAvatar(BuildContext context, User u, {double? radius}) { diff --git a/mobile/lib/widgets/common/user_circle_avatar.dart b/mobile/lib/widgets/common/user_circle_avatar.dart index f90da6097b..2b7eadf04b 100644 --- a/mobile/lib/widgets/common/user_circle_avatar.dart +++ b/mobile/lib/widgets/common/user_circle_avatar.dart @@ -3,6 +3,7 @@ import 'dart:math'; import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/domain/models/store.model.dart'; import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/entities/user.entity.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; diff --git a/mobile/lib/widgets/search/curated_places_row.dart b/mobile/lib/widgets/search/curated_places_row.dart index c4cfdedc27..502b09bc4b 100644 --- a/mobile/lib/widgets/search/curated_places_row.dart +++ b/mobile/lib/widgets/search/curated_places_row.dart @@ -1,8 +1,9 @@ import 'package:flutter/material.dart'; +import 'package:immich_mobile/domain/models/store.model.dart'; +import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/models/search/search_curated_content.model.dart'; import 'package:immich_mobile/widgets/search/search_map_thumbnail.dart'; import 'package:immich_mobile/widgets/search/thumbnail_with_info.dart'; -import 'package:immich_mobile/entities/store.entity.dart'; class CuratedPlacesRow extends StatelessWidget { const CuratedPlacesRow({ diff --git a/mobile/lib/widgets/search/explore_grid.dart b/mobile/lib/widgets/search/explore_grid.dart index cd937a6a42..1841f7f051 100644 --- a/mobile/lib/widgets/search/explore_grid.dart +++ b/mobile/lib/widgets/search/explore_grid.dart @@ -1,12 +1,13 @@ import 'package:auto_route/auto_route.dart'; import 'package:flutter/material.dart'; -import 'package:immich_mobile/models/search/search_curated_content.model.dart'; -import 'package:immich_mobile/models/search/search_filter.model.dart'; -import 'package:immich_mobile/widgets/search/thumbnail_with_info.dart'; -import 'package:immich_mobile/routing/router.dart'; +import 'package:immich_mobile/domain/models/store.model.dart'; import 'package:immich_mobile/entities/asset.entity.dart'; import 'package:immich_mobile/entities/store.entity.dart'; +import 'package:immich_mobile/models/search/search_curated_content.model.dart'; +import 'package:immich_mobile/models/search/search_filter.model.dart'; +import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/utils/image_url_builder.dart'; +import 'package:immich_mobile/widgets/search/thumbnail_with_info.dart'; class ExploreGrid extends StatelessWidget { final List curatedContent; diff --git a/mobile/lib/widgets/settings/networking_settings/external_network_preference.dart b/mobile/lib/widgets/settings/networking_settings/external_network_preference.dart index 13c109fa0e..09f2617152 100644 --- a/mobile/lib/widgets/settings/networking_settings/external_network_preference.dart +++ b/mobile/lib/widgets/settings/networking_settings/external_network_preference.dart @@ -2,11 +2,12 @@ import 'dart:convert'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; -import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:flutter_hooks/flutter_hooks.dart' hide Store; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/domain/models/store.model.dart'; +import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/models/auth/auxilary_endpoint.model.dart'; -import 'package:immich_mobile/entities/store.entity.dart' as db_store; import 'package:immich_mobile/widgets/settings/networking_settings/endpoint_input.dart'; class ExternalNetworkPreference extends HookConsumerWidget { @@ -30,8 +31,8 @@ class ExternalNetworkPreference extends HookConsumerWidget { final jsonString = jsonEncode(endpointList); - db_store.Store.put( - db_store.StoreKey.externalEndpointList, + Store.put( + StoreKey.externalEndpointList, jsonString, ); } @@ -81,8 +82,7 @@ class ExternalNetworkPreference extends HookConsumerWidget { useEffect( () { - final jsonString = - db_store.Store.tryGet(db_store.StoreKey.externalEndpointList); + final jsonString = Store.tryGet(StoreKey.externalEndpointList); if (jsonString == null) { return null; diff --git a/mobile/lib/widgets/settings/networking_settings/networking_settings.dart b/mobile/lib/widgets/settings/networking_settings/networking_settings.dart index d241792d95..1089029947 100644 --- a/mobile/lib/widgets/settings/networking_settings/networking_settings.dart +++ b/mobile/lib/widgets/settings/networking_settings/networking_settings.dart @@ -1,7 +1,10 @@ import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; -import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:flutter_hooks/flutter_hooks.dart' hide Store; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/domain/models/store.model.dart'; +import 'package:immich_mobile/entities/store.entity.dart'; +import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/models/auth/auxilary_endpoint.model.dart'; import 'package:immich_mobile/providers/network.provider.dart'; import 'package:immich_mobile/services/app_settings.service.dart'; @@ -10,16 +13,12 @@ import 'package:immich_mobile/widgets/settings/networking_settings/external_netw import 'package:immich_mobile/widgets/settings/networking_settings/local_network_preference.dart'; import 'package:immich_mobile/widgets/settings/settings_switch_list_tile.dart'; -import 'package:immich_mobile/entities/store.entity.dart' as db_store; -import 'package:immich_mobile/extensions/build_context_extensions.dart'; - class NetworkingSettings extends HookConsumerWidget { const NetworkingSettings({super.key}); @override Widget build(BuildContext context, WidgetRef ref) { - final currentEndpoint = - db_store.Store.get(db_store.StoreKey.serverEndpoint); + final currentEndpoint = Store.get(StoreKey.serverEndpoint); final featureEnabled = useAppSettingsState(AppSettingsEnum.autoEndpointSwitching); diff --git a/mobile/test/modules/activity/activities_page_test.dart b/mobile/test/modules/activity/activities_page_test.dart index 38070966c8..6b20692bcd 100644 --- a/mobile/test/modules/activity/activities_page_test.dart +++ b/mobile/test/modules/activity/activities_page_test.dart @@ -4,6 +4,7 @@ library; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:immich_mobile/domain/models/store.model.dart'; import 'package:immich_mobile/domain/services/store.service.dart'; import 'package:immich_mobile/entities/album.entity.dart'; import 'package:immich_mobile/entities/asset.entity.dart'; diff --git a/mobile/test/modules/activity/activity_text_field_test.dart b/mobile/test/modules/activity/activity_text_field_test.dart index 1e94f1ddeb..a124af0db9 100644 --- a/mobile/test/modules/activity/activity_text_field_test.dart +++ b/mobile/test/modules/activity/activity_text_field_test.dart @@ -4,6 +4,7 @@ library; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:immich_mobile/domain/models/store.model.dart'; import 'package:immich_mobile/domain/services/store.service.dart'; import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/infrastructure/repositories/store.repository.dart'; diff --git a/mobile/test/modules/activity/activity_tile_test.dart b/mobile/test/modules/activity/activity_tile_test.dart index fb48359dd5..22dd606540 100644 --- a/mobile/test/modules/activity/activity_tile_test.dart +++ b/mobile/test/modules/activity/activity_tile_test.dart @@ -5,6 +5,7 @@ library; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/domain/models/store.model.dart'; import 'package:immich_mobile/domain/services/store.service.dart'; import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/infrastructure/repositories/store.repository.dart'; diff --git a/mobile/test/modules/shared/sync_service_test.dart b/mobile/test/modules/shared/sync_service_test.dart index a87d422b40..464dafc82b 100644 --- a/mobile/test/modules/shared/sync_service_test.dart +++ b/mobile/test/modules/shared/sync_service_test.dart @@ -1,5 +1,6 @@ import 'package:flutter/widgets.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:immich_mobile/domain/models/store.model.dart'; import 'package:immich_mobile/domain/services/store.service.dart'; import 'package:immich_mobile/entities/asset.entity.dart'; import 'package:immich_mobile/entities/etag.entity.dart'; From 376282e538becf6887504965ac8b3f02ec3389b9 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Wed, 19 Feb 2025 14:54:12 -0600 Subject: [PATCH 176/395] chore(deps): update dependency @types/node to ^22.13.4 (#16206) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- cli/package-lock.json | 4 ++-- cli/package.json | 2 +- e2e/package-lock.json | 6 +++--- e2e/package.json | 2 +- open-api/typescript-sdk/package-lock.json | 2 +- open-api/typescript-sdk/package.json | 2 +- server/package-lock.json | 2 +- server/package.json | 2 +- 8 files changed, 11 insertions(+), 11 deletions(-) diff --git a/cli/package-lock.json b/cli/package-lock.json index 54f0f14507..3d8cf35459 100644 --- a/cli/package-lock.json +++ b/cli/package-lock.json @@ -24,7 +24,7 @@ "@types/cli-progress": "^3.11.0", "@types/lodash-es": "^4.17.12", "@types/mock-fs": "^4.13.1", - "@types/node": "^22.13.2", + "@types/node": "^22.13.4", "@typescript-eslint/eslint-plugin": "^8.15.0", "@typescript-eslint/parser": "^8.15.0", "@vitest/coverage-v8": "^3.0.0", @@ -59,7 +59,7 @@ "@oazapfts/runtime": "^1.0.2" }, "devDependencies": { - "@types/node": "^22.13.2", + "@types/node": "^22.13.4", "typescript": "^5.3.3" } }, diff --git a/cli/package.json b/cli/package.json index 56a398a6d6..2f0ddde3e4 100644 --- a/cli/package.json +++ b/cli/package.json @@ -20,7 +20,7 @@ "@types/cli-progress": "^3.11.0", "@types/lodash-es": "^4.17.12", "@types/mock-fs": "^4.13.1", - "@types/node": "^22.13.2", + "@types/node": "^22.13.4", "@typescript-eslint/eslint-plugin": "^8.15.0", "@typescript-eslint/parser": "^8.15.0", "@vitest/coverage-v8": "^3.0.0", diff --git a/e2e/package-lock.json b/e2e/package-lock.json index b935c27e6f..c32beef97f 100644 --- a/e2e/package-lock.json +++ b/e2e/package-lock.json @@ -15,7 +15,7 @@ "@immich/sdk": "file:../open-api/typescript-sdk", "@playwright/test": "^1.44.1", "@types/luxon": "^3.4.2", - "@types/node": "^22.13.2", + "@types/node": "^22.13.4", "@types/oidc-provider": "^8.5.1", "@types/pg": "^8.11.0", "@types/pngjs": "^6.0.4", @@ -64,7 +64,7 @@ "@types/cli-progress": "^3.11.0", "@types/lodash-es": "^4.17.12", "@types/mock-fs": "^4.13.1", - "@types/node": "^22.13.2", + "@types/node": "^22.13.4", "@typescript-eslint/eslint-plugin": "^8.15.0", "@typescript-eslint/parser": "^8.15.0", "@vitest/coverage-v8": "^3.0.0", @@ -99,7 +99,7 @@ "@oazapfts/runtime": "^1.0.2" }, "devDependencies": { - "@types/node": "^22.13.2", + "@types/node": "^22.13.4", "typescript": "^5.3.3" } }, diff --git a/e2e/package.json b/e2e/package.json index 5a56c69918..c0d9ec424e 100644 --- a/e2e/package.json +++ b/e2e/package.json @@ -25,7 +25,7 @@ "@immich/sdk": "file:../open-api/typescript-sdk", "@playwright/test": "^1.44.1", "@types/luxon": "^3.4.2", - "@types/node": "^22.13.2", + "@types/node": "^22.13.4", "@types/oidc-provider": "^8.5.1", "@types/pg": "^8.11.0", "@types/pngjs": "^6.0.4", diff --git a/open-api/typescript-sdk/package-lock.json b/open-api/typescript-sdk/package-lock.json index 7561fec230..9cdb5f991f 100644 --- a/open-api/typescript-sdk/package-lock.json +++ b/open-api/typescript-sdk/package-lock.json @@ -12,7 +12,7 @@ "@oazapfts/runtime": "^1.0.2" }, "devDependencies": { - "@types/node": "^22.13.2", + "@types/node": "^22.13.4", "typescript": "^5.3.3" } }, diff --git a/open-api/typescript-sdk/package.json b/open-api/typescript-sdk/package.json index 44056ceec5..be06fbdc4d 100644 --- a/open-api/typescript-sdk/package.json +++ b/open-api/typescript-sdk/package.json @@ -19,7 +19,7 @@ "@oazapfts/runtime": "^1.0.2" }, "devDependencies": { - "@types/node": "^22.13.2", + "@types/node": "^22.13.4", "typescript": "^5.3.3" }, "repository": { diff --git a/server/package-lock.json b/server/package-lock.json index a3882b8731..8a2c0c9f25 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -86,7 +86,7 @@ "@types/lodash": "^4.14.197", "@types/mock-fs": "^4.13.1", "@types/multer": "^1.4.7", - "@types/node": "^22.13.2", + "@types/node": "^22.13.4", "@types/nodemailer": "^6.4.14", "@types/picomatch": "^3.0.0", "@types/pngjs": "^6.0.5", diff --git a/server/package.json b/server/package.json index 5fa779c50f..06c23f3a1e 100644 --- a/server/package.json +++ b/server/package.json @@ -112,7 +112,7 @@ "@types/lodash": "^4.14.197", "@types/mock-fs": "^4.13.1", "@types/multer": "^1.4.7", - "@types/node": "^22.13.2", + "@types/node": "^22.13.4", "@types/nodemailer": "^6.4.14", "@types/picomatch": "^3.0.0", "@types/pngjs": "^6.0.5", From 9c95adc7fbf50e1862de131012229471486f416d Mon Sep 17 00:00:00 2001 From: Lukas Date: Thu, 20 Feb 2025 00:15:45 +0100 Subject: [PATCH 177/395] feat(web): show memories in portrait on small screens (#16213) --- web/src/lib/components/photos-page/memory-lane.svelte | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/src/lib/components/photos-page/memory-lane.svelte b/web/src/lib/components/photos-page/memory-lane.svelte index 4aa5bc7e9d..254bb78f89 100644 --- a/web/src/lib/components/photos-page/memory-lane.svelte +++ b/web/src/lib/components/photos-page/memory-lane.svelte @@ -74,7 +74,7 @@ {#each $memoryStore as memory (memory.yearsAgo)} {#if memory.assets.length > 0} Date: Thu, 20 Feb 2025 16:28:30 +0100 Subject: [PATCH 178/395] fix(server): set modifydate (#16225) --- server/src/queries/view.repository.sql | 4 ++-- server/src/repositories/view-repository.ts | 4 ++-- server/src/services/metadata.service.spec.ts | 6 ++++++ server/src/services/metadata.service.ts | 1 + 4 files changed, 11 insertions(+), 4 deletions(-) diff --git a/server/src/queries/view.repository.sql b/server/src/queries/view.repository.sql index daa4159ea0..1510521526 100644 --- a/server/src/queries/view.repository.sql +++ b/server/src/queries/view.repository.sql @@ -10,7 +10,7 @@ where and "isVisible" = $3 and "isArchived" = $4 and "deletedAt" is null - and "fileModifiedAt" is not null + and "fileCreatedAt" is not null and "fileModifiedAt" is not null and "localDateTime" is not null @@ -26,7 +26,7 @@ where and "isVisible" = $2 and "isArchived" = $3 and "deletedAt" is null - and "fileModifiedAt" is not null + and "fileCreatedAt" is not null and "fileModifiedAt" is not null and "localDateTime" is not null and "originalPath" like $4 diff --git a/server/src/repositories/view-repository.ts b/server/src/repositories/view-repository.ts index 4fa670339e..ae2303e9e2 100644 --- a/server/src/repositories/view-repository.ts +++ b/server/src/repositories/view-repository.ts @@ -18,7 +18,7 @@ export class ViewRepository { .where('isVisible', '=', true) .where('isArchived', '=', false) .where('deletedAt', 'is', null) - .where('fileModifiedAt', 'is not', null) + .where('fileCreatedAt', 'is not', null) .where('fileModifiedAt', 'is not', null) .where('localDateTime', 'is not', null) .execute(); @@ -38,7 +38,7 @@ export class ViewRepository { .where('isVisible', '=', true) .where('isArchived', '=', false) .where('deletedAt', 'is', null) - .where('fileModifiedAt', 'is not', null) + .where('fileCreatedAt', 'is not', null) .where('fileModifiedAt', 'is not', null) .where('localDateTime', 'is not', null) .where('originalPath', 'like', `%${normalizedPath}/%`) diff --git a/server/src/services/metadata.service.spec.ts b/server/src/services/metadata.service.spec.ts index 0e0c6546ca..f5b10aa379 100644 --- a/server/src/services/metadata.service.spec.ts +++ b/server/src/services/metadata.service.spec.ts @@ -227,6 +227,7 @@ describe(MetadataService.name, () => { id: assetStub.image.id, duration: null, fileCreatedAt: sidecarDate, + fileModifiedAt: new Date('2023-02-23T05:06:29.716Z'), localDateTime: sidecarDate, }); }); @@ -246,6 +247,7 @@ describe(MetadataService.name, () => { id: assetStub.image.id, duration: null, fileCreatedAt: fileModifiedAt, + fileModifiedAt, localDateTime: fileModifiedAt, }); }); @@ -263,6 +265,7 @@ describe(MetadataService.name, () => { id: assetStub.image.id, duration: null, fileCreatedAt, + fileModifiedAt, localDateTime: fileCreatedAt, }); }); @@ -297,6 +300,7 @@ describe(MetadataService.name, () => { id: assetStub.image.id, duration: null, fileCreatedAt: assetStub.image.fileCreatedAt, + fileModifiedAt: assetStub.image.fileModifiedAt, localDateTime: assetStub.image.fileCreatedAt, }); }); @@ -319,6 +323,7 @@ describe(MetadataService.name, () => { id: assetStub.withLocation.id, duration: null, fileCreatedAt: assetStub.withLocation.createdAt, + fileModifiedAt: assetStub.withLocation.createdAt, localDateTime: new Date('2023-02-22T05:06:29.716Z'), }); }); @@ -840,6 +845,7 @@ describe(MetadataService.name, () => { id: assetStub.image.id, duration: null, fileCreatedAt: dateForTest, + fileModifiedAt: dateForTest, localDateTime: dateForTest, }); }); diff --git a/server/src/services/metadata.service.ts b/server/src/services/metadata.service.ts index 37ec7fa064..02499a1ac5 100644 --- a/server/src/services/metadata.service.ts +++ b/server/src/services/metadata.service.ts @@ -238,6 +238,7 @@ export class MetadataService extends BaseService { duration: exifTags.Duration?.toString() ?? null, localDateTime, fileCreatedAt: exifData.dateTimeOriginal ?? undefined, + fileModifiedAt: exifData.modifyDate ?? undefined, }); await this.assetRepository.upsertJobStatus({ From 6b7a7b0cbc45583d5b6c1a4e106f3045a6ae4dbd Mon Sep 17 00:00:00 2001 From: Jonathan Jogenfors Date: Thu, 20 Feb 2025 16:45:34 +0100 Subject: [PATCH 179/395] feat(web): library import path onboarding (#16229) --- docs/docs/features/libraries.md | 5 +-- .../admin/library-management/+page.svelte | 45 ++++++++++++++++++- 2 files changed, 46 insertions(+), 4 deletions(-) diff --git a/docs/docs/features/libraries.md b/docs/docs/features/libraries.md index 796337f37c..a137980e00 100644 --- a/docs/docs/features/libraries.md +++ b/docs/docs/features/libraries.md @@ -111,11 +111,10 @@ These actions must be performed by the Immich administrator. - Click on Administration -> Libraries - Click on Create External Library - Select which user owns the library, this can not be changed later +- Enter `/mnt/media/christmas-trip` then click Add +- Click on Save - Click the drop-down menu on the newly created library - Click on Rename Library and rename it to "Christmas Trip" -- Click Edit Import Paths -- Click on Add Path -- Enter `/mnt/media/christmas-trip` then click Add NOTE: We have to use the `/mnt/media/christmas-trip` path and not the `/mnt/nas/christmas-trip` path since all paths have to be what the Docker containers see. diff --git a/web/src/routes/admin/library-management/+page.svelte b/web/src/routes/admin/library-management/+page.svelte index 34a42446cd..4c916bd49d 100644 --- a/web/src/routes/admin/library-management/+page.svelte +++ b/web/src/routes/admin/library-management/+page.svelte @@ -35,6 +35,7 @@ import { t } from 'svelte-i18n'; import { fade, slide } from 'svelte/transition'; import type { PageData } from './$types'; + import LibraryImportPathForm from '$lib/components/forms/library-import-path-form.svelte'; interface Props { data: PageData; @@ -57,6 +58,8 @@ let updateLibraryIndex: number | null; let dropdownOpen: boolean[] = []; let toCreateLibrary = $state(false); + let toAddImportPath = $state(false); + let importPathToAdd: string | null = $state(null); onMount(async () => { await readLibraryList(); @@ -67,6 +70,7 @@ editScanSettings = undefined; renameLibrary = undefined; updateLibraryIndex = null; + toAddImportPath = false; for (let index = 0; index < dropdownOpen.length; index++) { dropdownOpen[index] = false; @@ -93,8 +97,9 @@ } const handleCreate = async (ownerId: string) => { + let createdLibrary: LibraryResponseDto | undefined; try { - const createdLibrary = await createLibrary({ createLibraryDto: { ownerId } }); + createdLibrary = await createLibrary({ createLibraryDto: { ownerId } }); notificationController.show({ message: $t('admin.library_created', { values: { library: createdLibrary.name } }), type: NotificationType.Info, @@ -105,6 +110,29 @@ toCreateLibrary = false; await readLibraryList(); } + + if (createdLibrary) { + // Open the import paths form for the newly created library + updateLibraryIndex = libraries.findIndex((library) => library.id === createdLibrary.id); + toAddImportPath = true; + } + }; + + const handleAddImportPath = () => { + if (!updateLibraryIndex || !importPathToAdd) { + return; + } + + try { + onEditImportPathClicked(updateLibraryIndex); + + libraries[updateLibraryIndex].importPaths.push(importPathToAdd); + } catch (error) { + handleError(error, $t('errors.unable_to_add_import_path')); + } finally { + importPathToAdd = null; + toAddImportPath = false; + } }; const handleUpdate = async (library: Partial) => { @@ -216,6 +244,21 @@ (toCreateLibrary = false)} /> {/if} +{#if toAddImportPath} + { + toAddImportPath = false; + if (updateLibraryIndex) { + onEditImportPathClicked(updateLibraryIndex); + } + }} + /> +{/if} + {#snippet buttons()}
From f6ba071569cd4a36a4d48eba742c6ac4e9d962f6 Mon Sep 17 00:00:00 2001 From: Jonathan Jogenfors Date: Thu, 20 Feb 2025 16:46:18 +0100 Subject: [PATCH 180/395] feat(server): add path to metadata logging (#16212) feat(server): Prefer original path instead of id when logging --- server/src/services/metadata.service.ts | 38 +++++++++++++++---------- 1 file changed, 23 insertions(+), 15 deletions(-) diff --git a/server/src/services/metadata.service.ts b/server/src/services/metadata.service.ts index 02499a1ac5..78ea8089e6 100644 --- a/server/src/services/metadata.service.ts +++ b/server/src/services/metadata.service.ts @@ -425,7 +425,7 @@ export class MetadataService extends BaseService { return; } - this.logger.debug(`Starting motion photo video extraction (${asset.id})`); + this.logger.debug(`Starting motion photo video extraction for asset ${asset.id}: ${asset.originalPath}`); try { const stat = await this.storageRepository.stat(asset.originalPath); @@ -457,9 +457,9 @@ export class MetadataService extends BaseService { }); if (motionAsset) { this.logger.debug( - `Asset ${asset.id}'s motion photo video with checksum ${checksum.toString( + `Motion photo video with checksum ${checksum.toString( 'base64', - )} already exists in the repository`, + )} already exists in the repository for asset ${asset.id}: ${asset.originalPath}`, ); // Hide the motion photo video asset if it's not already hidden to prepare for linking @@ -516,9 +516,12 @@ export class MetadataService extends BaseService { await this.jobRepository.queue({ name: JobName.METADATA_EXTRACTION, data: { id: motionAsset.id } }); } - this.logger.debug(`Finished motion photo video extraction (${asset.id})`); + this.logger.debug(`Finished motion photo video extraction for asset ${asset.id}: ${asset.originalPath}`); } catch (error: Error | any) { - this.logger.error(`Failed to extract live photo ${asset.originalPath}: ${error}`, error?.stack); + this.logger.error( + `Failed to extract motion video for ${asset.id}: ${asset.originalPath}: ${error}`, + error?.stack, + ); } } @@ -571,11 +574,13 @@ export class MetadataService extends BaseService { const facesToRemove = asset.faces.filter((face) => face.sourceType === SourceType.EXIF).map((face) => face.id); if (facesToRemove.length > 0) { - this.logger.debug(`Removing ${facesToRemove.length} faces for asset ${asset.id}`); + this.logger.debug(`Removing ${facesToRemove.length} faces for asset ${asset.id}: ${asset.originalPath}`); } if (facesToAdd.length > 0) { - this.logger.debug(`Creating ${facesToAdd.length} faces from metadata for asset ${asset.id}`); + this.logger.debug( + `Creating ${facesToAdd.length} faces from metadata for asset ${asset.id}: ${asset.originalPath}`, + ); } if (facesToRemove.length > 0 || facesToAdd.length > 0) { @@ -589,7 +594,7 @@ export class MetadataService extends BaseService { private getDates(asset: AssetEntity, exifTags: ImmichTags) { const dateTime = firstDateTime(exifTags as Maybe, EXIF_DATE_TAGS); - this.logger.verbose(`Asset ${asset.id} date time is ${dateTime}`); + this.logger.verbose(`Date and time is ${dateTime} for asset ${asset.id}: ${asset.originalPath}`); // timezone let timeZone = exifTags.tz ?? null; @@ -600,23 +605,27 @@ export class MetadataService extends BaseService { } if (timeZone) { - this.logger.verbose(`Asset ${asset.id} timezone is ${timeZone} (via ${exifTags.tzSource})`); + this.logger.verbose( + `Found timezone ${timeZone} via ${exifTags.tzSource} for asset ${asset.id}: ${asset.originalPath}`, + ); } else { - this.logger.debug(`Asset ${asset.id} has no time zone information`); + this.logger.debug(`No timezone information found for asset ${asset.id}: ${asset.originalPath}`); } let dateTimeOriginal = dateTime?.toDate(); let localDateTime = dateTime?.toDateTime().setZone('UTC', { keepLocalTime: true }).toJSDate(); if (!localDateTime || !dateTimeOriginal) { this.logger.debug( - `No valid date found in exif tags from asset ${asset.id}, falling back to earliest timestamp between file creation and file modification`, + `No exif date time found, falling back on earliest of file creation and modification for assset ${asset.id}: ${asset.originalPath}`, ); const earliestDate = this.earliestDate(asset.fileModifiedAt, asset.fileCreatedAt); dateTimeOriginal = earliestDate; localDateTime = earliestDate; } - this.logger.verbose(`Asset ${asset.id} has a local time of ${localDateTime.toISOString()}`); + this.logger.verbose( + `Found local date time ${localDateTime.toISOString()} for asset ${asset.id}: ${asset.originalPath}`, + ); let modifyDate = asset.fileModifiedAt; try { @@ -754,6 +763,7 @@ export class MetadataService extends BaseService { } if (sidecarPath) { + this.logger.debug(`Detected sidecar at '${sidecarPath}' for asset ${asset.id}: ${asset.originalPath}`); await this.assetRepository.update({ id: asset.id, sidecarPath }); return JobStatus.SUCCESS; } @@ -762,9 +772,7 @@ export class MetadataService extends BaseService { return JobStatus.FAILED; } - this.logger.debug( - `Sidecar file was not found. Checked paths '${sidecarPathWithExt}' and '${sidecarPathWithoutExt}'. Removing sidecarPath for asset ${asset.id}`, - ); + this.logger.debug(`No sidecar found for asset ${asset.id}: ${asset.originalPath}`); await this.assetRepository.update({ id: asset.id, sidecarPath: null }); return JobStatus.SUCCESS; From 34b88bb47a4849423bc6935bb3a52a43ba9a1398 Mon Sep 17 00:00:00 2001 From: Jason Antwi-Appah Date: Thu, 20 Feb 2025 10:17:06 -0600 Subject: [PATCH 181/395] feat(web): support searching by EXIF rating (#16208) * Add rating to search DTO * Add search by EXIF rating in search query builder * Generate OpenAPI spec * Add rating filter on web * Add rating filter to search docs * Format / lint * Hide rating filter if ratings are disabled * chore: component order in form --------- Co-authored-by: Alex Tran --- docs/docs/features/searching.md | 1 + i18n/en.json | 1 + .../lib/model/metadata_search_dto.dart | 21 ++++++++++++- .../openapi/lib/model/random_search_dto.dart | 21 ++++++++++++- .../openapi/lib/model/smart_search_dto.dart | 21 ++++++++++++- open-api/immich-openapi-specs.json | 15 +++++++++ open-api/typescript-sdk/src/fetch-client.ts | 3 ++ server/src/dtos/search.dto.ts | 6 ++++ server/src/entities/asset.entity.ts | 5 +++ server/src/repositories/search.repository.ts | 1 + .../search-bar/search-filter-modal.svelte | 11 +++++++ .../search-bar/search-ratings-section.svelte | 31 +++++++++++++++++++ 12 files changed, 134 insertions(+), 3 deletions(-) create mode 100644 web/src/lib/components/shared-components/search-bar/search-ratings-section.svelte diff --git a/docs/docs/features/searching.md b/docs/docs/features/searching.md index 13547f6bac..eed5faa6fb 100644 --- a/docs/docs/features/searching.md +++ b/docs/docs/features/searching.md @@ -31,6 +31,7 @@ The filters smart search allows you to search by include: - Not in any album - Archived - Favorited + - Rating diff --git a/i18n/en.json b/i18n/en.json index 72559d4502..b6f75ce4f1 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -1134,6 +1134,7 @@ "search_timezone": "Search timezone...", "search_type": "Search type", "search_your_photos": "Search your photos", + "search_rating": "Search by rating...", "searching_locales": "Searching locales...", "second": "Second", "see_all_people": "See all people", diff --git a/mobile/openapi/lib/model/metadata_search_dto.dart b/mobile/openapi/lib/model/metadata_search_dto.dart index 3a3c141442..3fb003d164 100644 --- a/mobile/openapi/lib/model/metadata_search_dto.dart +++ b/mobile/openapi/lib/model/metadata_search_dto.dart @@ -40,6 +40,7 @@ class MetadataSearchDto { this.page, this.personIds = const [], this.previewPath, + this.rating, this.size, this.state, this.tagIds = const [], @@ -233,6 +234,16 @@ class MetadataSearchDto { /// String? previewPath; + /// Minimum value: -1 + /// Maximum value: 5 + /// + /// 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. + /// + num? rating; + /// Minimum value: 1 /// Maximum value: 1000 /// @@ -374,6 +385,7 @@ class MetadataSearchDto { other.page == page && _deepEquality.equals(other.personIds, personIds) && other.previewPath == previewPath && + other.rating == rating && other.size == size && other.state == state && _deepEquality.equals(other.tagIds, tagIds) && @@ -421,6 +433,7 @@ class MetadataSearchDto { (page == null ? 0 : page!.hashCode) + (personIds.hashCode) + (previewPath == null ? 0 : previewPath!.hashCode) + + (rating == null ? 0 : rating!.hashCode) + (size == null ? 0 : size!.hashCode) + (state == null ? 0 : state!.hashCode) + (tagIds.hashCode) + @@ -439,7 +452,7 @@ class MetadataSearchDto { (withStacked == null ? 0 : withStacked!.hashCode); @override - String toString() => 'MetadataSearchDto[checksum=$checksum, city=$city, country=$country, createdAfter=$createdAfter, createdBefore=$createdBefore, description=$description, deviceAssetId=$deviceAssetId, deviceId=$deviceId, encodedVideoPath=$encodedVideoPath, id=$id, isArchived=$isArchived, isEncoded=$isEncoded, isFavorite=$isFavorite, isMotion=$isMotion, isNotInAlbum=$isNotInAlbum, isOffline=$isOffline, isVisible=$isVisible, lensModel=$lensModel, libraryId=$libraryId, make=$make, model=$model, order=$order, originalFileName=$originalFileName, originalPath=$originalPath, page=$page, personIds=$personIds, previewPath=$previewPath, size=$size, state=$state, tagIds=$tagIds, takenAfter=$takenAfter, takenBefore=$takenBefore, thumbnailPath=$thumbnailPath, trashedAfter=$trashedAfter, trashedBefore=$trashedBefore, type=$type, updatedAfter=$updatedAfter, updatedBefore=$updatedBefore, withArchived=$withArchived, withDeleted=$withDeleted, withExif=$withExif, withPeople=$withPeople, withStacked=$withStacked]'; + String toString() => 'MetadataSearchDto[checksum=$checksum, city=$city, country=$country, createdAfter=$createdAfter, createdBefore=$createdBefore, description=$description, deviceAssetId=$deviceAssetId, deviceId=$deviceId, encodedVideoPath=$encodedVideoPath, id=$id, isArchived=$isArchived, isEncoded=$isEncoded, isFavorite=$isFavorite, isMotion=$isMotion, isNotInAlbum=$isNotInAlbum, isOffline=$isOffline, isVisible=$isVisible, lensModel=$lensModel, libraryId=$libraryId, make=$make, model=$model, order=$order, originalFileName=$originalFileName, originalPath=$originalPath, page=$page, personIds=$personIds, previewPath=$previewPath, rating=$rating, size=$size, state=$state, tagIds=$tagIds, takenAfter=$takenAfter, takenBefore=$takenBefore, thumbnailPath=$thumbnailPath, trashedAfter=$trashedAfter, trashedBefore=$trashedBefore, type=$type, updatedAfter=$updatedAfter, updatedBefore=$updatedBefore, withArchived=$withArchived, withDeleted=$withDeleted, withExif=$withExif, withPeople=$withPeople, withStacked=$withStacked]'; Map toJson() { final json = {}; @@ -570,6 +583,11 @@ class MetadataSearchDto { } else { // json[r'previewPath'] = null; } + if (this.rating != null) { + json[r'rating'] = this.rating; + } else { + // json[r'rating'] = null; + } if (this.size != null) { json[r'size'] = this.size; } else { @@ -683,6 +701,7 @@ class MetadataSearchDto { ? (json[r'personIds'] as Iterable).cast().toList(growable: false) : const [], previewPath: mapValueOfType(json, r'previewPath'), + rating: num.parse('${json[r'rating']}'), size: num.parse('${json[r'size']}'), state: mapValueOfType(json, r'state'), tagIds: json[r'tagIds'] is Iterable diff --git a/mobile/openapi/lib/model/random_search_dto.dart b/mobile/openapi/lib/model/random_search_dto.dart index c63d7e82f6..10727ec10d 100644 --- a/mobile/openapi/lib/model/random_search_dto.dart +++ b/mobile/openapi/lib/model/random_search_dto.dart @@ -30,6 +30,7 @@ class RandomSearchDto { this.make, this.model, this.personIds = const [], + this.rating, this.size, this.state, this.tagIds = const [], @@ -147,6 +148,16 @@ class RandomSearchDto { List personIds; + /// Minimum value: -1 + /// Maximum value: 5 + /// + /// 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. + /// + num? rating; + /// Minimum value: 1 /// Maximum value: 1000 /// @@ -270,6 +281,7 @@ class RandomSearchDto { other.make == make && other.model == model && _deepEquality.equals(other.personIds, personIds) && + other.rating == rating && other.size == size && other.state == state && _deepEquality.equals(other.tagIds, tagIds) && @@ -306,6 +318,7 @@ class RandomSearchDto { (make == null ? 0 : make!.hashCode) + (model == null ? 0 : model!.hashCode) + (personIds.hashCode) + + (rating == null ? 0 : rating!.hashCode) + (size == null ? 0 : size!.hashCode) + (state == null ? 0 : state!.hashCode) + (tagIds.hashCode) + @@ -323,7 +336,7 @@ class RandomSearchDto { (withStacked == null ? 0 : withStacked!.hashCode); @override - String toString() => 'RandomSearchDto[city=$city, country=$country, createdAfter=$createdAfter, createdBefore=$createdBefore, deviceId=$deviceId, isArchived=$isArchived, isEncoded=$isEncoded, isFavorite=$isFavorite, isMotion=$isMotion, isNotInAlbum=$isNotInAlbum, isOffline=$isOffline, isVisible=$isVisible, lensModel=$lensModel, libraryId=$libraryId, make=$make, model=$model, personIds=$personIds, size=$size, state=$state, tagIds=$tagIds, takenAfter=$takenAfter, takenBefore=$takenBefore, trashedAfter=$trashedAfter, trashedBefore=$trashedBefore, type=$type, updatedAfter=$updatedAfter, updatedBefore=$updatedBefore, withArchived=$withArchived, withDeleted=$withDeleted, withExif=$withExif, withPeople=$withPeople, withStacked=$withStacked]'; + String toString() => 'RandomSearchDto[city=$city, country=$country, createdAfter=$createdAfter, createdBefore=$createdBefore, deviceId=$deviceId, isArchived=$isArchived, isEncoded=$isEncoded, isFavorite=$isFavorite, isMotion=$isMotion, isNotInAlbum=$isNotInAlbum, isOffline=$isOffline, isVisible=$isVisible, lensModel=$lensModel, libraryId=$libraryId, make=$make, model=$model, personIds=$personIds, rating=$rating, size=$size, state=$state, tagIds=$tagIds, takenAfter=$takenAfter, takenBefore=$takenBefore, trashedAfter=$trashedAfter, trashedBefore=$trashedBefore, type=$type, updatedAfter=$updatedAfter, updatedBefore=$updatedBefore, withArchived=$withArchived, withDeleted=$withDeleted, withExif=$withExif, withPeople=$withPeople, withStacked=$withStacked]'; Map toJson() { final json = {}; @@ -408,6 +421,11 @@ class RandomSearchDto { // json[r'model'] = null; } json[r'personIds'] = this.personIds; + if (this.rating != null) { + json[r'rating'] = this.rating; + } else { + // json[r'rating'] = null; + } if (this.size != null) { json[r'size'] = this.size; } else { @@ -506,6 +524,7 @@ class RandomSearchDto { personIds: json[r'personIds'] is Iterable ? (json[r'personIds'] as Iterable).cast().toList(growable: false) : const [], + rating: num.parse('${json[r'rating']}'), size: num.parse('${json[r'size']}'), state: mapValueOfType(json, r'state'), tagIds: json[r'tagIds'] is Iterable diff --git a/mobile/openapi/lib/model/smart_search_dto.dart b/mobile/openapi/lib/model/smart_search_dto.dart index c81e1519b4..f377c23f22 100644 --- a/mobile/openapi/lib/model/smart_search_dto.dart +++ b/mobile/openapi/lib/model/smart_search_dto.dart @@ -32,6 +32,7 @@ class SmartSearchDto { this.page, this.personIds = const [], required this.query, + this.rating, this.size, this.state, this.tagIds = const [], @@ -158,6 +159,16 @@ class SmartSearchDto { String query; + /// Minimum value: -1 + /// Maximum value: 5 + /// + /// 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. + /// + num? rating; + /// Minimum value: 1 /// Maximum value: 1000 /// @@ -267,6 +278,7 @@ class SmartSearchDto { other.page == page && _deepEquality.equals(other.personIds, personIds) && other.query == query && + other.rating == rating && other.size == size && other.state == state && _deepEquality.equals(other.tagIds, tagIds) && @@ -303,6 +315,7 @@ class SmartSearchDto { (page == null ? 0 : page!.hashCode) + (personIds.hashCode) + (query.hashCode) + + (rating == null ? 0 : rating!.hashCode) + (size == null ? 0 : size!.hashCode) + (state == null ? 0 : state!.hashCode) + (tagIds.hashCode) + @@ -318,7 +331,7 @@ class SmartSearchDto { (withExif == null ? 0 : withExif!.hashCode); @override - String toString() => 'SmartSearchDto[city=$city, country=$country, createdAfter=$createdAfter, createdBefore=$createdBefore, deviceId=$deviceId, isArchived=$isArchived, isEncoded=$isEncoded, isFavorite=$isFavorite, isMotion=$isMotion, isNotInAlbum=$isNotInAlbum, isOffline=$isOffline, isVisible=$isVisible, lensModel=$lensModel, libraryId=$libraryId, make=$make, model=$model, page=$page, personIds=$personIds, query=$query, size=$size, state=$state, tagIds=$tagIds, takenAfter=$takenAfter, takenBefore=$takenBefore, trashedAfter=$trashedAfter, trashedBefore=$trashedBefore, type=$type, updatedAfter=$updatedAfter, updatedBefore=$updatedBefore, withArchived=$withArchived, withDeleted=$withDeleted, withExif=$withExif]'; + String toString() => 'SmartSearchDto[city=$city, country=$country, createdAfter=$createdAfter, createdBefore=$createdBefore, deviceId=$deviceId, isArchived=$isArchived, isEncoded=$isEncoded, isFavorite=$isFavorite, isMotion=$isMotion, isNotInAlbum=$isNotInAlbum, isOffline=$isOffline, isVisible=$isVisible, lensModel=$lensModel, libraryId=$libraryId, make=$make, model=$model, page=$page, personIds=$personIds, query=$query, rating=$rating, size=$size, state=$state, tagIds=$tagIds, takenAfter=$takenAfter, takenBefore=$takenBefore, trashedAfter=$trashedAfter, trashedBefore=$trashedBefore, type=$type, updatedAfter=$updatedAfter, updatedBefore=$updatedBefore, withArchived=$withArchived, withDeleted=$withDeleted, withExif=$withExif]'; Map toJson() { final json = {}; @@ -409,6 +422,11 @@ class SmartSearchDto { } json[r'personIds'] = this.personIds; json[r'query'] = this.query; + if (this.rating != null) { + json[r'rating'] = this.rating; + } else { + // json[r'rating'] = null; + } if (this.size != null) { json[r'size'] = this.size; } else { @@ -499,6 +517,7 @@ class SmartSearchDto { ? (json[r'personIds'] as Iterable).cast().toList(growable: false) : const [], query: mapValueOfType(json, r'query')!, + rating: num.parse('${json[r'rating']}'), size: num.parse('${json[r'size']}'), state: mapValueOfType(json, r'state'), tagIds: json[r'tagIds'] is Iterable diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index 5b5c3a1503..14245e11bd 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -9956,6 +9956,11 @@ "previewPath": { "type": "string" }, + "rating": { + "maximum": 5, + "minimum": -1, + "type": "number" + }, "size": { "maximum": 1000, "minimum": 1, @@ -10613,6 +10618,11 @@ }, "type": "array" }, + "rating": { + "maximum": 5, + "minimum": -1, + "type": "number" + }, "size": { "maximum": 1000, "minimum": 1, @@ -11563,6 +11573,11 @@ "query": { "type": "string" }, + "rating": { + "maximum": 5, + "minimum": -1, + "type": "number" + }, "size": { "maximum": 1000, "minimum": 1, diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index d4b36a04f0..9ff35331fb 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -811,6 +811,7 @@ export type MetadataSearchDto = { page?: number; personIds?: string[]; previewPath?: string; + rating?: number; size?: number; state?: string | null; tagIds?: string[]; @@ -878,6 +879,7 @@ export type RandomSearchDto = { make?: string; model?: string | null; personIds?: string[]; + rating?: number; size?: number; state?: string | null; tagIds?: string[]; @@ -914,6 +916,7 @@ export type SmartSearchDto = { page?: number; personIds?: string[]; query: string; + rating?: number; size?: number; state?: string | null; tagIds?: string[]; diff --git a/server/src/dtos/search.dto.ts b/server/src/dtos/search.dto.ts index 6cf34debef..3589331c78 100644 --- a/server/src/dtos/search.dto.ts +++ b/server/src/dtos/search.dto.ts @@ -114,6 +114,12 @@ class BaseSearchDto { @ValidateUUID({ each: true, optional: true }) tagIds?: string[]; + + @Optional() + @IsInt() + @Max(5) + @Min(-1) + rating?: number; } export class RandomSearchDto extends BaseSearchDto { diff --git a/server/src/entities/asset.entity.ts b/server/src/entities/asset.entity.ts index 8ff4130edd..fd69673eb5 100644 --- a/server/src/entities/asset.entity.ts +++ b/server/src/entities/asset.entity.ts @@ -387,6 +387,11 @@ export function searchAssetBuilder(kysely: Kysely, options: AssetSearchBuild .innerJoin('exif', 'assets.id', 'exif.assetId') .where('exif.lensModel', options.lensModel === null ? 'is' : '=', options.lensModel!), ) + .$if(options.rating !== undefined, (qb) => + qb + .innerJoin('exif', 'assets.id', 'exif.assetId') + .where('exif.rating', options.rating === null ? 'is' : '=', options.rating!), + ) .$if(!!options.checksum, (qb) => qb.where('assets.checksum', '=', options.checksum!)) .$if(!!options.deviceAssetId, (qb) => qb.where('assets.deviceAssetId', '=', options.deviceAssetId!)) .$if(!!options.deviceId, (qb) => qb.where('assets.deviceId', '=', options.deviceId!)) diff --git a/server/src/repositories/search.repository.ts b/server/src/repositories/search.repository.ts index a6eb5c7a85..2f313aa083 100644 --- a/server/src/repositories/search.repository.ts +++ b/server/src/repositories/search.repository.ts @@ -109,6 +109,7 @@ export interface SearchExifOptions { model?: string | null; state?: string | null; description?: string | null; + rating?: number | null; } export interface SearchEmbeddingOptions { diff --git a/web/src/lib/components/shared-components/search-bar/search-filter-modal.svelte b/web/src/lib/components/shared-components/search-bar/search-filter-modal.svelte index 8170010332..4fc646b204 100644 --- a/web/src/lib/components/shared-components/search-bar/search-filter-modal.svelte +++ b/web/src/lib/components/shared-components/search-bar/search-filter-modal.svelte @@ -14,6 +14,7 @@ date: SearchDateFilter; display: SearchDisplayFilters; mediaType: MediaType; + rating?: number; }; @@ -26,6 +27,7 @@ import SearchCameraSection, { type SearchCameraFilter } from './search-camera-section.svelte'; import SearchDateSection from './search-date-section.svelte'; import SearchMediaSection from './search-media-section.svelte'; + import SearchRatingsSection from './search-ratings-section.svelte'; import { parseUtcDate } from '$lib/utils/date-time'; import SearchDisplaySection from './search-display-section.svelte'; import SearchTextSection from './search-text-section.svelte'; @@ -34,6 +36,7 @@ import { mdiTune } from '@mdi/js'; import { generateId } from '$lib/utils/generate-id'; import { SvelteSet } from 'svelte/reactivity'; + import { preferences } from '$lib/stores/user.store'; interface Props { searchQuery: MetadataSearchDto | SmartSearchDto; @@ -81,6 +84,7 @@ : searchQuery.type === AssetTypeEnum.Video ? MediaType.Video : MediaType.All, + rating: searchQuery.rating, }); const resetForm = () => { @@ -94,6 +98,7 @@ date: {}, display: {}, mediaType: MediaType.All, + rating: undefined, }; }; @@ -124,6 +129,7 @@ personIds: filter.personIds.size > 0 ? [...filter.personIds] : undefined, tagIds: filter.tagIds.size > 0 ? [...filter.tagIds] : undefined, type, + rating: filter.rating, }; onSearch(payload); @@ -161,6 +167,11 @@ + + {#if $preferences?.ratings.enabled} + + {/if} +
diff --git a/web/src/lib/components/shared-components/search-bar/search-ratings-section.svelte b/web/src/lib/components/shared-components/search-bar/search-ratings-section.svelte new file mode 100644 index 0000000000..00e6223807 --- /dev/null +++ b/web/src/lib/components/shared-components/search-bar/search-ratings-section.svelte @@ -0,0 +1,31 @@ + + +
+ +
From 17a2043e765b9e1e2c0efa72785895200a20e3f6 Mon Sep 17 00:00:00 2001 From: Alex Date: Thu, 20 Feb 2025 22:14:41 -0600 Subject: [PATCH 182/395] refactor(mobile): trash provider (#16219) * refactor(mobile): trash provider * refactor(mobile): trash provider * pr feedback --- mobile/analysis_options.yaml | 2 +- mobile/lib/interfaces/asset.interface.dart | 5 + mobile/lib/pages/library/trash.page.dart | 5 +- mobile/lib/providers/trash.provider.dart | 136 ++---------------- mobile/lib/repositories/asset.repository.dart | 26 ++++ mobile/lib/services/trash.service.dart | 99 ++++++++----- .../widgets/asset_viewer/gallery_app_bar.dart | 3 +- mobile/openapi/devtools_options.yaml | 3 + 8 files changed, 115 insertions(+), 164 deletions(-) create mode 100644 mobile/openapi/devtools_options.yaml diff --git a/mobile/analysis_options.yaml b/mobile/analysis_options.yaml index ffeccbdd50..8ae9edef0d 100644 --- a/mobile/analysis_options.yaml +++ b/mobile/analysis_options.yaml @@ -79,7 +79,7 @@ custom_lint: - lib/widgets/asset_grid/asset_grid_data_structure.dart - test/**.dart # refactor the remaining providers - - lib/providers/{archive,asset,authentication,db,favorite,partner,trash,user}.provider.dart + - lib/providers/{archive,asset,authentication,db,favorite,partner,user}.provider.dart - lib/providers/{asset_viewer/render_list,backup/backup,search/all_motion_photos,search/recently_added_asset}.provider.dart - import_rule_openapi: diff --git a/mobile/lib/interfaces/asset.interface.dart b/mobile/lib/interfaces/asset.interface.dart index 4096a55061..b56c99a711 100644 --- a/mobile/lib/interfaces/asset.interface.dart +++ b/mobile/lib/interfaces/asset.interface.dart @@ -2,6 +2,7 @@ import 'package:immich_mobile/entities/album.entity.dart'; import 'package:immich_mobile/entities/asset.entity.dart'; import 'package:immich_mobile/entities/device_asset.entity.dart'; import 'package:immich_mobile/interfaces/database.interface.dart'; +import 'package:immich_mobile/widgets/asset_grid/asset_grid_data_structure.dart'; abstract interface class IAssetRepository implements IDatabaseRepository { Future getByRemoteId(String id); @@ -63,6 +64,10 @@ abstract interface class IAssetRepository implements IDatabaseRepository { Future clearTable(); Stream watchAsset(int id, {bool fireImmediately = false}); + + Future> getTrashAssets(int userId); + + Stream getTrashRenderListStream(int userId); } enum AssetSort { checksum, ownerIdChecksum } diff --git a/mobile/lib/pages/library/trash.page.dart b/mobile/lib/pages/library/trash.page.dart index 61c87e19a1..7322b00579 100644 --- a/mobile/lib/pages/library/trash.page.dart +++ b/mobile/lib/pages/library/trash.page.dart @@ -6,6 +6,7 @@ import 'package:fluttertoast/fluttertoast.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/extensions/asyncvalue_extensions.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; +import 'package:immich_mobile/providers/asset.provider.dart'; import 'package:immich_mobile/widgets/asset_grid/immich_asset_grid.dart'; import 'package:immich_mobile/widgets/asset_grid/delete_dialog.dart'; import 'package:immich_mobile/providers/trash.provider.dart'; @@ -67,8 +68,8 @@ class TrashPage extends HookConsumerWidget { try { if (selection.value.isNotEmpty) { final isRemoved = await ref - .read(trashProvider.notifier) - .removeAssets(selection.value); + .read(assetProvider.notifier) + .deleteAssets(selection.value, force: true); if (isRemoved) { if (context.mounted) { diff --git a/mobile/lib/providers/trash.provider.dart b/mobile/lib/providers/trash.provider.dart index 8619970d4a..0f7ae780a0 100644 --- a/mobile/lib/providers/trash.provider.dart +++ b/mobile/lib/providers/trash.provider.dart @@ -2,150 +2,44 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/widgets/asset_grid/asset_grid_data_structure.dart'; import 'package:immich_mobile/services/trash.service.dart'; import 'package:immich_mobile/entities/asset.entity.dart'; -import 'package:immich_mobile/providers/asset.provider.dart'; -import 'package:immich_mobile/providers/db.provider.dart'; import 'package:immich_mobile/providers/user.provider.dart'; -import 'package:immich_mobile/services/sync.service.dart'; -import 'package:immich_mobile/utils/renderlist_generator.dart'; -import 'package:isar/isar.dart'; import 'package:logging/logging.dart'; class TrashNotifier extends StateNotifier { - final Isar _db; - final Ref _ref; final TrashService _trashService; final _log = Logger('TrashNotifier'); TrashNotifier( this._trashService, - this._db, - this._ref, ) : super(false); Future emptyTrash() async { try { - final user = _ref.read(currentUserProvider); - if (user == null) { - return; - } await _trashService.emptyTrash(); - - final idsToRemove = await _db.assets - .where() - .remoteIdIsNotNull() - .filter() - .ownerIdEqualTo(user.isarId) - .isTrashedEqualTo(true) - .remoteIdProperty() - .findAll(); - - // TODO: handle local asset removal on emptyTrash - _ref - .read(syncServiceProvider) - .handleRemoteAssetRemoval(idsToRemove.cast().toList()); + state = true; } catch (error, stack) { _log.severe("Cannot empty trash", error, stack); + state = false; } } - Future removeAssets(Iterable assetList) async { - try { - final user = _ref.read(currentUserProvider); - if (user == null) { - return false; - } - - final isRemoved = await _ref - .read(assetProvider.notifier) - .deleteRemoteAssets(assetList, shouldDeletePermanently: true); - - if (isRemoved) { - final idsToRemove = - assetList.where((a) => a.isRemote).map((a) => a.remoteId!).toList(); - - _ref - .read(syncServiceProvider) - .handleRemoteAssetRemoval(idsToRemove.cast().toList()); - } - - return isRemoved; - } catch (error, stack) { - _log.severe("Cannot remove assets", error, stack); - } - return false; - } - - Future restoreAsset(Asset asset) async { - try { - final result = await _trashService.restoreAsset(asset); - - if (result) { - final remoteAsset = asset.isRemote; - - asset.isTrashed = false; - - if (remoteAsset) { - await _db.writeTxn(() async { - await _db.assets.put(asset); - }); - } - return true; - } - } catch (error, stack) { - _log.severe("Cannot restore asset", error, stack); - } - return false; - } - Future restoreAssets(Iterable assetList) async { try { - final result = await _trashService.restoreAssets(assetList); - - if (result) { - final remoteAssets = assetList.where((a) => a.isRemote).toList(); - - final updatedAssets = remoteAssets.map((e) { - e.isTrashed = false; - return e; - }).toList(); - - await _db.writeTxn(() async { - await _db.assets.putAll(updatedAssets); - }); - return true; - } + await _trashService.restoreAssets(assetList); + return true; } catch (error, stack) { _log.severe("Cannot restore assets", error, stack); + return false; } - return false; } Future restoreTrash() async { try { - final user = _ref.read(currentUserProvider); - if (user == null) { - return; - } await _trashService.restoreTrash(); - - final assets = await _db.assets - .where() - .remoteIdIsNotNull() - .filter() - .ownerIdEqualTo(user.isarId) - .isTrashedEqualTo(true) - .findAll(); - - final updatedAssets = assets.map((e) { - e.isTrashed = false; - return e; - }).toList(); - - await _db.writeTxn(() async { - await _db.assets.putAll(updatedAssets); - }); + state = true; } catch (error, stack) { _log.severe("Cannot restore trash", error, stack); + state = false; } } } @@ -153,20 +47,14 @@ class TrashNotifier extends StateNotifier { final trashProvider = StateNotifierProvider((ref) { return TrashNotifier( ref.watch(trashServiceProvider), - ref.watch(dbProvider), - ref, ); }); final trashedAssetsProvider = StreamProvider((ref) { final user = ref.read(currentUserProvider); - if (user == null) return const Stream.empty(); - final query = ref - .watch(dbProvider) - .assets - .filter() - .ownerIdEqualTo(user.isarId) - .isTrashedEqualTo(true) - .sortByFileCreatedAtDesc(); - return renderListGeneratorWithGroupBy(query, GroupAssetsBy.none); + if (user == null) { + return const Stream.empty(); + } + + return ref.watch(trashServiceProvider).getRenderListGenerator(user.isarId); }); diff --git a/mobile/lib/repositories/asset.repository.dart b/mobile/lib/repositories/asset.repository.dart index 351f37c4df..55add8cf96 100644 --- a/mobile/lib/repositories/asset.repository.dart +++ b/mobile/lib/repositories/asset.repository.dart @@ -11,6 +11,7 @@ import 'package:immich_mobile/entities/ios_device_asset.entity.dart'; import 'package:immich_mobile/interfaces/asset.interface.dart'; import 'package:immich_mobile/providers/db.provider.dart'; import 'package:immich_mobile/repositories/database.repository.dart'; +import 'package:immich_mobile/widgets/asset_grid/asset_grid_data_structure.dart'; import 'package:isar/isar.dart'; final assetRepositoryProvider = @@ -222,6 +223,31 @@ class AssetRepository extends DatabaseRepository implements IAssetRepository { Stream watchAsset(int id, {bool fireImmediately = false}) { return db.assets.watchObject(id, fireImmediately: fireImmediately); } + + @override + Future> getTrashAssets(int userId) { + return db.assets + .where() + .remoteIdIsNotNull() + .filter() + .ownerIdEqualTo(userId) + .isTrashedEqualTo(true) + .findAll(); + } + + @override + Stream getTrashRenderListStream(int userId) async* { + final query = db.assets + .filter() + .ownerIdEqualTo(userId) + .isTrashedEqualTo(true) + .sortByFileCreatedAtDesc(); + + yield await RenderList.fromQuery(query, GroupAssetsBy.none); + await for (final _ in query.watchLazy()) { + yield await RenderList.fromQuery(query, GroupAssetsBy.none); + } + } } Future> _getMatchesImpl( diff --git a/mobile/lib/services/trash.service.dart b/mobile/lib/services/trash.service.dart index 9342b1f1e4..690031d934 100644 --- a/mobile/lib/services/trash.service.dart +++ b/mobile/lib/services/trash.service.dart @@ -1,62 +1,89 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/entities/asset.entity.dart'; +import 'package:immich_mobile/interfaces/asset.interface.dart'; +import 'package:immich_mobile/interfaces/user.interface.dart'; import 'package:immich_mobile/providers/api.provider.dart'; +import 'package:immich_mobile/repositories/asset.repository.dart'; +import 'package:immich_mobile/repositories/user.repository.dart'; import 'package:immich_mobile/services/api.service.dart'; -import 'package:logging/logging.dart'; +import 'package:immich_mobile/widgets/asset_grid/asset_grid_data_structure.dart'; import 'package:openapi/api.dart'; final trashServiceProvider = Provider((ref) { return TrashService( ref.watch(apiServiceProvider), + ref.watch(assetRepositoryProvider), + ref.watch(userRepositoryProvider), ); }); class TrashService { - final _log = Logger("TrashService"); - final ApiService _apiService; + final IAssetRepository _assetRepository; + final IUserRepository _userRepository; - TrashService(this._apiService); + TrashService(this._apiService, this._assetRepository, this._userRepository); - Future restoreAssets(Iterable assetList) async { - try { - List remoteIds = - assetList.where((a) => a.isRemote).map((e) => e.remoteId!).toList(); - await _apiService.trashApi.restoreAssets(BulkIdsDto(ids: remoteIds)); - return true; - } catch (error, stack) { - _log.severe("Cannot restore assets", error, stack); - return false; - } - } + Future restoreAssets(Iterable assetList) async { + final remoteAssets = assetList.where((a) => a.isRemote); + await _apiService.trashApi.restoreAssets( + BulkIdsDto(ids: remoteAssets.map((e) => e.remoteId!).toList()), + ); - Future restoreAsset(Asset asset) async { - try { - if (asset.isRemote) { - List remoteId = [asset.remoteId!]; + final updatedAssets = remoteAssets.map((asset) { + asset.isTrashed = false; + return asset; + }).toList(); - await _apiService.trashApi.restoreAssets(BulkIdsDto(ids: remoteId)); - } - return true; - } catch (error, stack) { - _log.severe("Cannot restore assets", error, stack); - return false; - } + await _assetRepository.updateAll(updatedAssets); } Future emptyTrash() async { - try { - await _apiService.trashApi.emptyTrash(); - } catch (error, stack) { - _log.severe("Cannot empty trash", error, stack); - } + final user = await _userRepository.me(); + + await _apiService.trashApi.emptyTrash(); + + final trashedAssets = await _assetRepository.getTrashAssets(user.isarId); + final ids = trashedAssets.map((e) => e.remoteId!).toList(); + + await _assetRepository.transaction(() async { + await _assetRepository.deleteAllByRemoteId( + ids, + state: AssetState.remote, + ); + + final merged = await _assetRepository.getAllByRemoteId( + ids, + state: AssetState.merged, + ); + if (merged.isEmpty) { + return; + } + + for (final Asset asset in merged) { + asset.remoteId = null; + asset.isTrashed = false; + } + + await _assetRepository.updateAll(merged); + }); } Future restoreTrash() async { - try { - await _apiService.trashApi.restoreTrash(); - } catch (error, stack) { - _log.severe("Cannot restore trash", error, stack); - } + final user = await _userRepository.me(); + + await _apiService.trashApi.restoreTrash(); + + final trashedAssets = await _assetRepository.getTrashAssets(user.isarId); + final updatedAssets = trashedAssets.map((asset) { + asset.isTrashed = false; + return asset; + }).toList(); + + await _assetRepository.updateAll(updatedAssets); + } + + Stream getRenderListGenerator(int userId) { + return _assetRepository.getTrashRenderListStream(userId); } } diff --git a/mobile/lib/widgets/asset_viewer/gallery_app_bar.dart b/mobile/lib/widgets/asset_viewer/gallery_app_bar.dart index f7e2158ea9..2cb90da599 100644 --- a/mobile/lib/widgets/asset_viewer/gallery_app_bar.dart +++ b/mobile/lib/widgets/asset_viewer/gallery_app_bar.dart @@ -49,7 +49,8 @@ class GalleryAppBar extends ConsumerWidget { } handleRestore(Asset asset) async { - final result = await ref.read(trashProvider.notifier).restoreAsset(asset); + final result = + await ref.read(trashProvider.notifier).restoreAssets([asset]); if (result && context.mounted) { ImmichToast.show( diff --git a/mobile/openapi/devtools_options.yaml b/mobile/openapi/devtools_options.yaml new file mode 100644 index 0000000000..fa0b357c4f --- /dev/null +++ b/mobile/openapi/devtools_options.yaml @@ -0,0 +1,3 @@ +description: This file stores settings for Dart & Flutter DevTools. +documentation: https://docs.flutter.dev/tools/devtools/extensions#configure-extension-enablement-states +extensions: From 02cd8da87141a66191a0febf8b388526f886b249 Mon Sep 17 00:00:00 2001 From: bo0tzz Date: Fri, 21 Feb 2025 05:31:29 +0100 Subject: [PATCH 183/395] docs: clarify custom locations guide (#16122) --- docs/docs/guides/custom-locations.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/docs/docs/guides/custom-locations.md b/docs/docs/guides/custom-locations.md index 08f75b3e9d..ba5caf4b26 100644 --- a/docs/docs/guides/custom-locations.md +++ b/docs/docs/guides/custom-locations.md @@ -6,7 +6,7 @@ This guide explains how to store generated and raw files with docker's volume mo It is important to remember to update the backup settings after following the guide to back up the new backup paths if using automatic backup tools, especially `profile/`. ::: -In our `.env` file, we will define variables that will help us in the future when we want to move to a more advanced server +In our `.env` file, we will define the paths we want to use. Note that you don't have to define all of these: UPLOAD_LOCATION will be the base folder that files are stored in by default, with the other paths acting as overrides. ```diff title=".env" # You can find documentation for all the supported environment variables [here](/docs/install/environment-variables) @@ -21,7 +21,7 @@ In our `.env` file, we will define variables that will help us in the future whe ... ``` -After defining the locations of these files, we will edit the `docker-compose.yml` file accordingly and add the new variables to the `immich-server` container. +After defining the locations of these files, we will edit the `docker-compose.yml` file accordingly and add the new variables to the `immich-server` container. These paths are where the mount attaches inside of the container, so don't change those. ```diff title="docker-compose.yml" services: @@ -35,7 +35,8 @@ services: - /etc/localtime:/etc/localtime:ro ``` -Restart Immich to register the changes. +After making this change, you have to move the files over to the new folders to make sure Immich can find everything it needs. If you haven't uploaded anything important yet, you can also reset Immich entirely by deleting the database folder. +Then restart Immich to register the changes: ``` docker compose up -d From ac36effb4574f76ea29e4b6e56b22b2d7ae5c792 Mon Sep 17 00:00:00 2001 From: Zack Pollard Date: Fri, 21 Feb 2025 04:37:57 +0000 Subject: [PATCH 184/395] feat: sync implementation for the user entity (#16234) * ci: print out typeorm generation changes * feat: sync implementation for the user entity wip --------- Co-authored-by: Jason Rasmussen --- .github/workflows/test.yml | 1 + mobile/openapi/README.md | 12 + mobile/openapi/lib/api.dart | 8 + mobile/openapi/lib/api/sync_api.dart | 161 ++++++++++++ mobile/openapi/lib/api_client.dart | 16 ++ mobile/openapi/lib/api_helper.dart | 6 + .../lib/model/sync_ack_delete_dto.dart | 98 +++++++ mobile/openapi/lib/model/sync_ack_dto.dart | 107 ++++++++ .../openapi/lib/model/sync_ack_set_dto.dart | 101 ++++++++ .../openapi/lib/model/sync_entity_type.dart | 85 ++++++ .../openapi/lib/model/sync_request_type.dart | 82 ++++++ mobile/openapi/lib/model/sync_stream_dto.dart | 99 +++++++ .../lib/model/sync_user_delete_v1.dart | 99 +++++++ mobile/openapi/lib/model/sync_user_v1.dart | 127 +++++++++ open-api/immich-openapi-specs.json | 243 ++++++++++++++++++ open-api/typescript-sdk/src/fetch-client.ts | 55 ++++ server/src/app.module.ts | 2 +- server/src/controllers/sync.controller.ts | 52 +++- server/src/database.ts | 3 + server/src/db.d.ts | 18 +- server/src/dtos/sync.dto.ts | 53 +++- server/src/entities/index.ts | 4 + server/src/entities/sync-checkpoint.entity.ts | 24 ++ server/src/entities/user-audit.entity.ts | 14 + server/src/entities/user.entity.ts | 2 + server/src/enum.ts | 9 + .../src/middleware/global-exception.filter.ts | 7 + ...001232576-AddSessionSyncCheckpointTable.ts | 22 ++ .../1740064899123-AddUsersAuditTable.ts | 34 +++ server/src/repositories/index.ts | 2 + server/src/repositories/sync.repository.ts | 80 ++++++ server/src/services/base.service.ts | 2 + server/src/services/sync.service.ts | 100 ++++++- server/src/types.ts | 7 + server/src/utils/misc.ts | 2 + server/src/utils/sync.ts | 30 +++ .../test/repositories/sync.repository.mock.ts | 13 + server/test/utils.ts | 4 + 38 files changed, 1774 insertions(+), 10 deletions(-) create mode 100644 mobile/openapi/lib/model/sync_ack_delete_dto.dart create mode 100644 mobile/openapi/lib/model/sync_ack_dto.dart create mode 100644 mobile/openapi/lib/model/sync_ack_set_dto.dart create mode 100644 mobile/openapi/lib/model/sync_entity_type.dart create mode 100644 mobile/openapi/lib/model/sync_request_type.dart create mode 100644 mobile/openapi/lib/model/sync_stream_dto.dart create mode 100644 mobile/openapi/lib/model/sync_user_delete_v1.dart create mode 100644 mobile/openapi/lib/model/sync_user_v1.dart create mode 100644 server/src/entities/sync-checkpoint.entity.ts create mode 100644 server/src/entities/user-audit.entity.ts create mode 100644 server/src/migrations/1740001232576-AddSessionSyncCheckpointTable.ts create mode 100644 server/src/migrations/1740064899123-AddUsersAuditTable.ts create mode 100644 server/src/repositories/sync.repository.ts create mode 100644 server/src/utils/sync.ts create mode 100644 server/test/repositories/sync.repository.mock.ts diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index a8a8f0e5c1..9d89063f24 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -504,6 +504,7 @@ jobs: run: | echo "ERROR: Generated migration files not up to date!" echo "Changed files: ${{ steps.verify-changed-files.outputs.changed_files }}" + cat ./src/migrations/*-TestMigration.ts exit 1 - name: Run SQL generation diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index d006ef38bb..e86ac93350 100644 --- a/mobile/openapi/README.md +++ b/mobile/openapi/README.md @@ -201,8 +201,12 @@ Class | Method | HTTP request | Description *StacksApi* | [**getStack**](doc//StacksApi.md#getstack) | **GET** /stacks/{id} | *StacksApi* | [**searchStacks**](doc//StacksApi.md#searchstacks) | **GET** /stacks | *StacksApi* | [**updateStack**](doc//StacksApi.md#updatestack) | **PUT** /stacks/{id} | +*SyncApi* | [**deleteSyncAck**](doc//SyncApi.md#deletesyncack) | **DELETE** /sync/ack | *SyncApi* | [**getDeltaSync**](doc//SyncApi.md#getdeltasync) | **POST** /sync/delta-sync | *SyncApi* | [**getFullSyncForUser**](doc//SyncApi.md#getfullsyncforuser) | **POST** /sync/full-sync | +*SyncApi* | [**getSyncAck**](doc//SyncApi.md#getsyncack) | **GET** /sync/ack | +*SyncApi* | [**getSyncStream**](doc//SyncApi.md#getsyncstream) | **POST** /sync/stream | +*SyncApi* | [**sendSyncAck**](doc//SyncApi.md#sendsyncack) | **POST** /sync/ack | *SystemConfigApi* | [**getConfig**](doc//SystemConfigApi.md#getconfig) | **GET** /system-config | *SystemConfigApi* | [**getConfigDefaults**](doc//SystemConfigApi.md#getconfigdefaults) | **GET** /system-config/defaults | *SystemConfigApi* | [**getStorageTemplateOptions**](doc//SystemConfigApi.md#getstoragetemplateoptions) | **GET** /system-config/storage-template-options | @@ -413,6 +417,14 @@ Class | Method | HTTP request | Description - [StackCreateDto](doc//StackCreateDto.md) - [StackResponseDto](doc//StackResponseDto.md) - [StackUpdateDto](doc//StackUpdateDto.md) + - [SyncAckDeleteDto](doc//SyncAckDeleteDto.md) + - [SyncAckDto](doc//SyncAckDto.md) + - [SyncAckSetDto](doc//SyncAckSetDto.md) + - [SyncEntityType](doc//SyncEntityType.md) + - [SyncRequestType](doc//SyncRequestType.md) + - [SyncStreamDto](doc//SyncStreamDto.md) + - [SyncUserDeleteV1](doc//SyncUserDeleteV1.md) + - [SyncUserV1](doc//SyncUserV1.md) - [SystemConfigBackupsDto](doc//SystemConfigBackupsDto.md) - [SystemConfigDto](doc//SystemConfigDto.md) - [SystemConfigFFmpegDto](doc//SystemConfigFFmpegDto.md) diff --git a/mobile/openapi/lib/api.dart b/mobile/openapi/lib/api.dart index 2a2b6d46a4..e5794a2694 100644 --- a/mobile/openapi/lib/api.dart +++ b/mobile/openapi/lib/api.dart @@ -226,6 +226,14 @@ part 'model/source_type.dart'; part 'model/stack_create_dto.dart'; part 'model/stack_response_dto.dart'; part 'model/stack_update_dto.dart'; +part 'model/sync_ack_delete_dto.dart'; +part 'model/sync_ack_dto.dart'; +part 'model/sync_ack_set_dto.dart'; +part 'model/sync_entity_type.dart'; +part 'model/sync_request_type.dart'; +part 'model/sync_stream_dto.dart'; +part 'model/sync_user_delete_v1.dart'; +part 'model/sync_user_v1.dart'; part 'model/system_config_backups_dto.dart'; part 'model/system_config_dto.dart'; part 'model/system_config_f_fmpeg_dto.dart'; diff --git a/mobile/openapi/lib/api/sync_api.dart b/mobile/openapi/lib/api/sync_api.dart index f94eb88081..49a4963bff 100644 --- a/mobile/openapi/lib/api/sync_api.dart +++ b/mobile/openapi/lib/api/sync_api.dart @@ -16,6 +16,45 @@ class SyncApi { final ApiClient apiClient; + /// Performs an HTTP 'DELETE /sync/ack' operation and returns the [Response]. + /// Parameters: + /// + /// * [SyncAckDeleteDto] syncAckDeleteDto (required): + Future deleteSyncAckWithHttpInfo(SyncAckDeleteDto syncAckDeleteDto,) async { + // ignore: prefer_const_declarations + final path = r'/sync/ack'; + + // ignore: prefer_final_locals + Object? postBody = syncAckDeleteDto; + + final queryParams = []; + final headerParams = {}; + final formParams = {}; + + const contentTypes = ['application/json']; + + + return apiClient.invokeAPI( + path, + 'DELETE', + queryParams, + postBody, + headerParams, + formParams, + contentTypes.isEmpty ? null : contentTypes.first, + ); + } + + /// Parameters: + /// + /// * [SyncAckDeleteDto] syncAckDeleteDto (required): + Future deleteSyncAck(SyncAckDeleteDto syncAckDeleteDto,) async { + final response = await deleteSyncAckWithHttpInfo(syncAckDeleteDto,); + if (response.statusCode >= HttpStatus.badRequest) { + throw ApiException(response.statusCode, await _decodeBodyBytes(response)); + } + } + /// Performs an HTTP 'POST /sync/delta-sync' operation and returns the [Response]. /// Parameters: /// @@ -112,4 +151,126 @@ class SyncApi { } return null; } + + /// Performs an HTTP 'GET /sync/ack' operation and returns the [Response]. + Future getSyncAckWithHttpInfo() async { + // ignore: prefer_const_declarations + final path = r'/sync/ack'; + + // ignore: prefer_final_locals + Object? postBody; + + final queryParams = []; + final headerParams = {}; + final formParams = {}; + + const contentTypes = []; + + + return apiClient.invokeAPI( + path, + 'GET', + queryParams, + postBody, + headerParams, + formParams, + contentTypes.isEmpty ? null : contentTypes.first, + ); + } + + Future?> getSyncAck() async { + final response = await getSyncAckWithHttpInfo(); + 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; + } + + /// Performs an HTTP 'POST /sync/stream' operation and returns the [Response]. + /// Parameters: + /// + /// * [SyncStreamDto] syncStreamDto (required): + Future getSyncStreamWithHttpInfo(SyncStreamDto syncStreamDto,) async { + // ignore: prefer_const_declarations + final path = r'/sync/stream'; + + // ignore: prefer_final_locals + Object? postBody = syncStreamDto; + + final queryParams = []; + final headerParams = {}; + final formParams = {}; + + const contentTypes = ['application/json']; + + + return apiClient.invokeAPI( + path, + 'POST', + queryParams, + postBody, + headerParams, + formParams, + contentTypes.isEmpty ? null : contentTypes.first, + ); + } + + /// Parameters: + /// + /// * [SyncStreamDto] syncStreamDto (required): + Future getSyncStream(SyncStreamDto syncStreamDto,) async { + final response = await getSyncStreamWithHttpInfo(syncStreamDto,); + if (response.statusCode >= HttpStatus.badRequest) { + throw ApiException(response.statusCode, await _decodeBodyBytes(response)); + } + } + + /// Performs an HTTP 'POST /sync/ack' operation and returns the [Response]. + /// Parameters: + /// + /// * [SyncAckSetDto] syncAckSetDto (required): + Future sendSyncAckWithHttpInfo(SyncAckSetDto syncAckSetDto,) async { + // ignore: prefer_const_declarations + final path = r'/sync/ack'; + + // ignore: prefer_final_locals + Object? postBody = syncAckSetDto; + + final queryParams = []; + final headerParams = {}; + final formParams = {}; + + const contentTypes = ['application/json']; + + + return apiClient.invokeAPI( + path, + 'POST', + queryParams, + postBody, + headerParams, + formParams, + contentTypes.isEmpty ? null : contentTypes.first, + ); + } + + /// Parameters: + /// + /// * [SyncAckSetDto] syncAckSetDto (required): + Future sendSyncAck(SyncAckSetDto syncAckSetDto,) async { + final response = await sendSyncAckWithHttpInfo(syncAckSetDto,); + if (response.statusCode >= HttpStatus.badRequest) { + throw ApiException(response.statusCode, await _decodeBodyBytes(response)); + } + } } diff --git a/mobile/openapi/lib/api_client.dart b/mobile/openapi/lib/api_client.dart index 49fbe9464b..54a8959f6a 100644 --- a/mobile/openapi/lib/api_client.dart +++ b/mobile/openapi/lib/api_client.dart @@ -508,6 +508,22 @@ class ApiClient { return StackResponseDto.fromJson(value); case 'StackUpdateDto': return StackUpdateDto.fromJson(value); + case 'SyncAckDeleteDto': + return SyncAckDeleteDto.fromJson(value); + case 'SyncAckDto': + return SyncAckDto.fromJson(value); + case 'SyncAckSetDto': + return SyncAckSetDto.fromJson(value); + case 'SyncEntityType': + return SyncEntityTypeTypeTransformer().decode(value); + case 'SyncRequestType': + return SyncRequestTypeTypeTransformer().decode(value); + case 'SyncStreamDto': + return SyncStreamDto.fromJson(value); + case 'SyncUserDeleteV1': + return SyncUserDeleteV1.fromJson(value); + case 'SyncUserV1': + return SyncUserV1.fromJson(value); case 'SystemConfigBackupsDto': return SystemConfigBackupsDto.fromJson(value); case 'SystemConfigDto': diff --git a/mobile/openapi/lib/api_helper.dart b/mobile/openapi/lib/api_helper.dart index 6a917201aa..1ebf8314ad 100644 --- a/mobile/openapi/lib/api_helper.dart +++ b/mobile/openapi/lib/api_helper.dart @@ -127,6 +127,12 @@ String parameterToString(dynamic value) { if (value is SourceType) { return SourceTypeTypeTransformer().encode(value).toString(); } + if (value is SyncEntityType) { + return SyncEntityTypeTypeTransformer().encode(value).toString(); + } + if (value is SyncRequestType) { + return SyncRequestTypeTypeTransformer().encode(value).toString(); + } if (value is TimeBucketSize) { return TimeBucketSizeTypeTransformer().encode(value).toString(); } diff --git a/mobile/openapi/lib/model/sync_ack_delete_dto.dart b/mobile/openapi/lib/model/sync_ack_delete_dto.dart new file mode 100644 index 0000000000..998f812f2e --- /dev/null +++ b/mobile/openapi/lib/model/sync_ack_delete_dto.dart @@ -0,0 +1,98 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.18 + +// ignore_for_file: unused_element, unused_import +// ignore_for_file: always_put_required_named_parameters_first +// ignore_for_file: constant_identifier_names +// ignore_for_file: lines_longer_than_80_chars + +part of openapi.api; + +class SyncAckDeleteDto { + /// Returns a new [SyncAckDeleteDto] instance. + SyncAckDeleteDto({ + this.types = const [], + }); + + List types; + + @override + bool operator ==(Object other) => identical(this, other) || other is SyncAckDeleteDto && + _deepEquality.equals(other.types, types); + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (types.hashCode); + + @override + String toString() => 'SyncAckDeleteDto[types=$types]'; + + Map toJson() { + final json = {}; + json[r'types'] = this.types; + return json; + } + + /// Returns a new [SyncAckDeleteDto] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static SyncAckDeleteDto? fromJson(dynamic value) { + upgradeDto(value, "SyncAckDeleteDto"); + if (value is Map) { + final json = value.cast(); + + return SyncAckDeleteDto( + types: SyncEntityType.listFromJson(json[r'types']), + ); + } + 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 = SyncAckDeleteDto.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 = SyncAckDeleteDto.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of SyncAckDeleteDto-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] = SyncAckDeleteDto.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/sync_ack_dto.dart b/mobile/openapi/lib/model/sync_ack_dto.dart new file mode 100644 index 0000000000..c7fafa17d2 --- /dev/null +++ b/mobile/openapi/lib/model/sync_ack_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 SyncAckDto { + /// Returns a new [SyncAckDto] instance. + SyncAckDto({ + required this.ack, + required this.type, + }); + + String ack; + + SyncEntityType type; + + @override + bool operator ==(Object other) => identical(this, other) || other is SyncAckDto && + other.ack == ack && + other.type == type; + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (ack.hashCode) + + (type.hashCode); + + @override + String toString() => 'SyncAckDto[ack=$ack, type=$type]'; + + Map toJson() { + final json = {}; + json[r'ack'] = this.ack; + json[r'type'] = this.type; + return json; + } + + /// Returns a new [SyncAckDto] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static SyncAckDto? fromJson(dynamic value) { + upgradeDto(value, "SyncAckDto"); + if (value is Map) { + final json = value.cast(); + + return SyncAckDto( + ack: mapValueOfType(json, r'ack')!, + type: SyncEntityType.fromJson(json[r'type'])!, + ); + } + 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 = SyncAckDto.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 = SyncAckDto.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of SyncAckDto-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] = SyncAckDto.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + 'ack', + 'type', + }; +} + diff --git a/mobile/openapi/lib/model/sync_ack_set_dto.dart b/mobile/openapi/lib/model/sync_ack_set_dto.dart new file mode 100644 index 0000000000..0d9eedc389 --- /dev/null +++ b/mobile/openapi/lib/model/sync_ack_set_dto.dart @@ -0,0 +1,101 @@ +// +// 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 SyncAckSetDto { + /// Returns a new [SyncAckSetDto] instance. + SyncAckSetDto({ + this.acks = const [], + }); + + List acks; + + @override + bool operator ==(Object other) => identical(this, other) || other is SyncAckSetDto && + _deepEquality.equals(other.acks, acks); + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (acks.hashCode); + + @override + String toString() => 'SyncAckSetDto[acks=$acks]'; + + Map toJson() { + final json = {}; + json[r'acks'] = this.acks; + return json; + } + + /// Returns a new [SyncAckSetDto] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static SyncAckSetDto? fromJson(dynamic value) { + upgradeDto(value, "SyncAckSetDto"); + if (value is Map) { + final json = value.cast(); + + return SyncAckSetDto( + acks: json[r'acks'] is Iterable + ? (json[r'acks'] as Iterable).cast().toList(growable: false) + : const [], + ); + } + 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 = SyncAckSetDto.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 = SyncAckSetDto.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of SyncAckSetDto-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] = SyncAckSetDto.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + 'acks', + }; +} + diff --git a/mobile/openapi/lib/model/sync_entity_type.dart b/mobile/openapi/lib/model/sync_entity_type.dart new file mode 100644 index 0000000000..ed82205a37 --- /dev/null +++ b/mobile/openapi/lib/model/sync_entity_type.dart @@ -0,0 +1,85 @@ +// +// 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 SyncEntityType { + /// Instantiate a new enum with the provided [value]. + const SyncEntityType._(this.value); + + /// The underlying value of this enum member. + final String value; + + @override + String toString() => value; + + String toJson() => value; + + static const userV1 = SyncEntityType._(r'UserV1'); + static const userDeleteV1 = SyncEntityType._(r'UserDeleteV1'); + + /// List of all possible values in this [enum][SyncEntityType]. + static const values = [ + userV1, + userDeleteV1, + ]; + + static SyncEntityType? fromJson(dynamic value) => SyncEntityTypeTypeTransformer().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 = SyncEntityType.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } +} + +/// Transformation class that can [encode] an instance of [SyncEntityType] to String, +/// and [decode] dynamic data back to [SyncEntityType]. +class SyncEntityTypeTypeTransformer { + factory SyncEntityTypeTypeTransformer() => _instance ??= const SyncEntityTypeTypeTransformer._(); + + const SyncEntityTypeTypeTransformer._(); + + String encode(SyncEntityType data) => data.value; + + /// Decodes a [dynamic value][data] to a SyncEntityType. + /// + /// 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. + SyncEntityType? decode(dynamic data, {bool allowNull = true}) { + if (data != null) { + switch (data) { + case r'UserV1': return SyncEntityType.userV1; + case r'UserDeleteV1': return SyncEntityType.userDeleteV1; + default: + if (!allowNull) { + throw ArgumentError('Unknown enum value to decode: $data'); + } + } + } + return null; + } + + /// Singleton [SyncEntityTypeTypeTransformer] instance. + static SyncEntityTypeTypeTransformer? _instance; +} + diff --git a/mobile/openapi/lib/model/sync_request_type.dart b/mobile/openapi/lib/model/sync_request_type.dart new file mode 100644 index 0000000000..d7f1bde54c --- /dev/null +++ b/mobile/openapi/lib/model/sync_request_type.dart @@ -0,0 +1,82 @@ +// +// 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 SyncRequestType { + /// Instantiate a new enum with the provided [value]. + const SyncRequestType._(this.value); + + /// The underlying value of this enum member. + final String value; + + @override + String toString() => value; + + String toJson() => value; + + static const usersV1 = SyncRequestType._(r'UsersV1'); + + /// List of all possible values in this [enum][SyncRequestType]. + static const values = [ + usersV1, + ]; + + static SyncRequestType? fromJson(dynamic value) => SyncRequestTypeTypeTransformer().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 = SyncRequestType.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } +} + +/// Transformation class that can [encode] an instance of [SyncRequestType] to String, +/// and [decode] dynamic data back to [SyncRequestType]. +class SyncRequestTypeTypeTransformer { + factory SyncRequestTypeTypeTransformer() => _instance ??= const SyncRequestTypeTypeTransformer._(); + + const SyncRequestTypeTypeTransformer._(); + + String encode(SyncRequestType data) => data.value; + + /// Decodes a [dynamic value][data] to a SyncRequestType. + /// + /// 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. + SyncRequestType? decode(dynamic data, {bool allowNull = true}) { + if (data != null) { + switch (data) { + case r'UsersV1': return SyncRequestType.usersV1; + default: + if (!allowNull) { + throw ArgumentError('Unknown enum value to decode: $data'); + } + } + } + return null; + } + + /// Singleton [SyncRequestTypeTypeTransformer] instance. + static SyncRequestTypeTypeTransformer? _instance; +} + diff --git a/mobile/openapi/lib/model/sync_stream_dto.dart b/mobile/openapi/lib/model/sync_stream_dto.dart new file mode 100644 index 0000000000..28fd3dfaee --- /dev/null +++ b/mobile/openapi/lib/model/sync_stream_dto.dart @@ -0,0 +1,99 @@ +// +// 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 SyncStreamDto { + /// Returns a new [SyncStreamDto] instance. + SyncStreamDto({ + this.types = const [], + }); + + List types; + + @override + bool operator ==(Object other) => identical(this, other) || other is SyncStreamDto && + _deepEquality.equals(other.types, types); + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (types.hashCode); + + @override + String toString() => 'SyncStreamDto[types=$types]'; + + Map toJson() { + final json = {}; + json[r'types'] = this.types; + return json; + } + + /// Returns a new [SyncStreamDto] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static SyncStreamDto? fromJson(dynamic value) { + upgradeDto(value, "SyncStreamDto"); + if (value is Map) { + final json = value.cast(); + + return SyncStreamDto( + types: SyncRequestType.listFromJson(json[r'types']), + ); + } + 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 = SyncStreamDto.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 = SyncStreamDto.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of SyncStreamDto-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] = SyncStreamDto.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + 'types', + }; +} + diff --git a/mobile/openapi/lib/model/sync_user_delete_v1.dart b/mobile/openapi/lib/model/sync_user_delete_v1.dart new file mode 100644 index 0000000000..09411cb79d --- /dev/null +++ b/mobile/openapi/lib/model/sync_user_delete_v1.dart @@ -0,0 +1,99 @@ +// +// 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 SyncUserDeleteV1 { + /// Returns a new [SyncUserDeleteV1] instance. + SyncUserDeleteV1({ + required this.userId, + }); + + String userId; + + @override + bool operator ==(Object other) => identical(this, other) || other is SyncUserDeleteV1 && + other.userId == userId; + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (userId.hashCode); + + @override + String toString() => 'SyncUserDeleteV1[userId=$userId]'; + + Map toJson() { + final json = {}; + json[r'userId'] = this.userId; + return json; + } + + /// Returns a new [SyncUserDeleteV1] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static SyncUserDeleteV1? fromJson(dynamic value) { + upgradeDto(value, "SyncUserDeleteV1"); + if (value is Map) { + final json = value.cast(); + + return SyncUserDeleteV1( + userId: mapValueOfType(json, r'userId')!, + ); + } + 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 = SyncUserDeleteV1.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 = SyncUserDeleteV1.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of SyncUserDeleteV1-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] = SyncUserDeleteV1.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + 'userId', + }; +} + diff --git a/mobile/openapi/lib/model/sync_user_v1.dart b/mobile/openapi/lib/model/sync_user_v1.dart new file mode 100644 index 0000000000..b9b41bb723 --- /dev/null +++ b/mobile/openapi/lib/model/sync_user_v1.dart @@ -0,0 +1,127 @@ +// +// 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 SyncUserV1 { + /// Returns a new [SyncUserV1] instance. + SyncUserV1({ + required this.deletedAt, + required this.email, + required this.id, + required this.name, + }); + + DateTime? deletedAt; + + String email; + + String id; + + String name; + + @override + bool operator ==(Object other) => identical(this, other) || other is SyncUserV1 && + other.deletedAt == deletedAt && + other.email == email && + other.id == id && + other.name == name; + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (deletedAt == null ? 0 : deletedAt!.hashCode) + + (email.hashCode) + + (id.hashCode) + + (name.hashCode); + + @override + String toString() => 'SyncUserV1[deletedAt=$deletedAt, email=$email, id=$id, name=$name]'; + + Map toJson() { + final json = {}; + if (this.deletedAt != null) { + json[r'deletedAt'] = this.deletedAt!.toUtc().toIso8601String(); + } else { + // json[r'deletedAt'] = null; + } + json[r'email'] = this.email; + json[r'id'] = this.id; + json[r'name'] = this.name; + return json; + } + + /// Returns a new [SyncUserV1] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static SyncUserV1? fromJson(dynamic value) { + upgradeDto(value, "SyncUserV1"); + if (value is Map) { + final json = value.cast(); + + return SyncUserV1( + deletedAt: mapDateTime(json, r'deletedAt', r''), + email: mapValueOfType(json, r'email')!, + id: mapValueOfType(json, r'id')!, + name: mapValueOfType(json, r'name')!, + ); + } + return null; + } + + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; + if (json is List && json.isNotEmpty) { + for (final row in json) { + final value = SyncUserV1.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 = SyncUserV1.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of SyncUserV1-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] = SyncUserV1.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + 'deletedAt', + 'email', + 'id', + 'name', + }; +} + diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index 14245e11bd..1d0f065992 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -5802,6 +5802,107 @@ ] } }, + "/sync/ack": { + "delete": { + "operationId": "deleteSyncAck", + "parameters": [], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SyncAckDeleteDto" + } + } + }, + "required": true + }, + "responses": { + "204": { + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "tags": [ + "Sync" + ] + }, + "get": { + "operationId": "getSyncAck", + "parameters": [], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "items": { + "$ref": "#/components/schemas/SyncAckDto" + }, + "type": "array" + } + } + }, + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "tags": [ + "Sync" + ] + }, + "post": { + "operationId": "sendSyncAck", + "parameters": [], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SyncAckSetDto" + } + } + }, + "required": true + }, + "responses": { + "204": { + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "tags": [ + "Sync" + ] + } + }, "/sync/delta-sync": { "post": { "operationId": "getDeltaSync", @@ -5889,6 +5990,41 @@ ] } }, + "/sync/stream": { + "post": { + "operationId": "getSyncStream", + "parameters": [], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SyncStreamDto" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "tags": [ + "Sync" + ] + } + }, "/system-config": { "get": { "operationId": "getConfig", @@ -11696,6 +11832,113 @@ }, "type": "object" }, + "SyncAckDeleteDto": { + "properties": { + "types": { + "items": { + "$ref": "#/components/schemas/SyncEntityType" + }, + "type": "array" + } + }, + "type": "object" + }, + "SyncAckDto": { + "properties": { + "ack": { + "type": "string" + }, + "type": { + "allOf": [ + { + "$ref": "#/components/schemas/SyncEntityType" + } + ] + } + }, + "required": [ + "ack", + "type" + ], + "type": "object" + }, + "SyncAckSetDto": { + "properties": { + "acks": { + "items": { + "type": "string" + }, + "type": "array" + } + }, + "required": [ + "acks" + ], + "type": "object" + }, + "SyncEntityType": { + "enum": [ + "UserV1", + "UserDeleteV1" + ], + "type": "string" + }, + "SyncRequestType": { + "enum": [ + "UsersV1" + ], + "type": "string" + }, + "SyncStreamDto": { + "properties": { + "types": { + "items": { + "$ref": "#/components/schemas/SyncRequestType" + }, + "type": "array" + } + }, + "required": [ + "types" + ], + "type": "object" + }, + "SyncUserDeleteV1": { + "properties": { + "userId": { + "type": "string" + } + }, + "required": [ + "userId" + ], + "type": "object" + }, + "SyncUserV1": { + "properties": { + "deletedAt": { + "format": "date-time", + "nullable": true, + "type": "string" + }, + "email": { + "type": "string" + }, + "id": { + "type": "string" + }, + "name": { + "type": "string" + } + }, + "required": [ + "deletedAt", + "email", + "id", + "name" + ], + "type": "object" + }, "SystemConfigBackupsDto": { "properties": { "database": { diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index 9ff35331fb..8b2e881830 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -1104,6 +1104,16 @@ export type StackCreateDto = { export type StackUpdateDto = { primaryAssetId?: string; }; +export type SyncAckDeleteDto = { + types?: SyncEntityType[]; +}; +export type SyncAckDto = { + ack: string; + "type": SyncEntityType; +}; +export type SyncAckSetDto = { + acks: string[]; +}; export type AssetDeltaSyncDto = { updatedAfter: string; userIds: string[]; @@ -1119,6 +1129,9 @@ export type AssetFullSyncDto = { updatedUntil: string; userId?: string; }; +export type SyncStreamDto = { + types: SyncRequestType[]; +}; export type DatabaseBackupConfig = { cronExpression: string; enabled: boolean; @@ -2912,6 +2925,32 @@ export function updateStack({ id, stackUpdateDto }: { body: stackUpdateDto }))); } +export function deleteSyncAck({ syncAckDeleteDto }: { + syncAckDeleteDto: SyncAckDeleteDto; +}, opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchText("/sync/ack", oazapfts.json({ + ...opts, + method: "DELETE", + body: syncAckDeleteDto + }))); +} +export function getSyncAck(opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchJson<{ + status: 200; + data: SyncAckDto[]; + }>("/sync/ack", { + ...opts + })); +} +export function sendSyncAck({ syncAckSetDto }: { + syncAckSetDto: SyncAckSetDto; +}, opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchText("/sync/ack", oazapfts.json({ + ...opts, + method: "POST", + body: syncAckSetDto + }))); +} export function getDeltaSync({ assetDeltaSyncDto }: { assetDeltaSyncDto: AssetDeltaSyncDto; }, opts?: Oazapfts.RequestOpts) { @@ -2936,6 +2975,15 @@ export function getFullSyncForUser({ assetFullSyncDto }: { body: assetFullSyncDto }))); } +export function getSyncStream({ syncStreamDto }: { + syncStreamDto: SyncStreamDto; +}, opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchText("/sync/stream", oazapfts.json({ + ...opts, + method: "POST", + body: syncStreamDto + }))); +} export function getConfig(opts?: Oazapfts.RequestOpts) { return oazapfts.ok(oazapfts.fetchJson<{ status: 200; @@ -3548,6 +3596,13 @@ export enum Error2 { NoPermission = "no_permission", NotFound = "not_found" } +export enum SyncEntityType { + UserV1 = "UserV1", + UserDeleteV1 = "UserDeleteV1" +} +export enum SyncRequestType { + UsersV1 = "UsersV1" +} export enum TranscodeHWAccel { Nvenc = "nvenc", Qsv = "qsv", diff --git a/server/src/app.module.ts b/server/src/app.module.ts index a4518598a3..b02d869a1e 100644 --- a/server/src/app.module.ts +++ b/server/src/app.module.ts @@ -29,7 +29,7 @@ import { AuthService } from 'src/services/auth.service'; import { CliService } from 'src/services/cli.service'; import { DatabaseService } from 'src/services/database.service'; -const common = [...repositories, ...services]; +const common = [...repositories, ...services, GlobalExceptionFilter]; const middleware = [ FileUploadInterceptor, diff --git a/server/src/controllers/sync.controller.ts b/server/src/controllers/sync.controller.ts index 4d970a7102..0945810be7 100644 --- a/server/src/controllers/sync.controller.ts +++ b/server/src/controllers/sync.controller.ts @@ -1,15 +1,28 @@ -import { Body, Controller, HttpCode, HttpStatus, Post } from '@nestjs/common'; +import { Body, Controller, Delete, Get, Header, HttpCode, HttpStatus, Post, Res } from '@nestjs/common'; import { ApiTags } from '@nestjs/swagger'; +import { Response } from 'express'; import { AssetResponseDto } from 'src/dtos/asset-response.dto'; import { AuthDto } from 'src/dtos/auth.dto'; -import { AssetDeltaSyncDto, AssetDeltaSyncResponseDto, AssetFullSyncDto } from 'src/dtos/sync.dto'; +import { + AssetDeltaSyncDto, + AssetDeltaSyncResponseDto, + AssetFullSyncDto, + SyncAckDeleteDto, + SyncAckDto, + SyncAckSetDto, + SyncStreamDto, +} from 'src/dtos/sync.dto'; import { Auth, Authenticated } from 'src/middleware/auth.guard'; +import { GlobalExceptionFilter } from 'src/middleware/global-exception.filter'; import { SyncService } from 'src/services/sync.service'; @ApiTags('Sync') @Controller('sync') export class SyncController { - constructor(private service: SyncService) {} + constructor( + private service: SyncService, + private errorService: GlobalExceptionFilter, + ) {} @Post('full-sync') @HttpCode(HttpStatus.OK) @@ -24,4 +37,37 @@ export class SyncController { getDeltaSync(@Auth() auth: AuthDto, @Body() dto: AssetDeltaSyncDto): Promise { return this.service.getDeltaSync(auth, dto); } + + @Post('stream') + @Header('Content-Type', 'application/jsonlines+json') + @HttpCode(HttpStatus.OK) + @Authenticated() + async getSyncStream(@Auth() auth: AuthDto, @Res() res: Response, @Body() dto: SyncStreamDto) { + try { + await this.service.stream(auth, res, dto); + } catch (error: Error | any) { + res.setHeader('Content-Type', 'application/json'); + this.errorService.handleError(res, error); + } + } + + @Get('ack') + @Authenticated() + getSyncAck(@Auth() auth: AuthDto): Promise { + return this.service.getAcks(auth); + } + + @Post('ack') + @HttpCode(HttpStatus.NO_CONTENT) + @Authenticated() + sendSyncAck(@Auth() auth: AuthDto, @Body() dto: SyncAckSetDto) { + return this.service.setAcks(auth, dto); + } + + @Delete('ack') + @HttpCode(HttpStatus.NO_CONTENT) + @Authenticated() + deleteSyncAck(@Auth() auth: AuthDto, @Body() dto: SyncAckDeleteDto) { + return this.service.deleteAcks(auth, dto); + } } diff --git a/server/src/database.ts b/server/src/database.ts index 4fcab0fd6d..c3fb4cbab4 100644 --- a/server/src/database.ts +++ b/server/src/database.ts @@ -1,3 +1,4 @@ +import { sql } from 'kysely'; import { Permission } from 'src/enum'; export type AuthUser = { @@ -29,6 +30,8 @@ export type AuthSession = { }; export const columns = { + ackEpoch: (columnName: 'createdAt' | 'updatedAt' | 'deletedAt') => + sql.raw(`extract(epoch from "${columnName}")::text`).as('ackEpoch'), authUser: [ 'users.id', 'users.name', diff --git a/server/src/db.d.ts b/server/src/db.d.ts index 2e10e1aded..255ac8cd20 100644 --- a/server/src/db.d.ts +++ b/server/src/db.d.ts @@ -4,7 +4,7 @@ */ import type { ColumnType } from 'kysely'; -import { Permission } from 'src/enum'; +import { Permission, SyncEntityType } from 'src/enum'; export type ArrayType = ArrayTypeImpl extends (infer U)[] ? U[] : ArrayTypeImpl; @@ -294,6 +294,15 @@ export interface Sessions { userId: string; } +export interface SessionSyncCheckpoints { + ack: string; + createdAt: Generated; + sessionId: string; + type: SyncEntityType; + updatedAt: Generated; +} + + export interface SharedLinkAsset { assetsId: string; sharedLinksId: string; @@ -384,6 +393,11 @@ export interface Users { updatedAt: Generated; } +export interface UsersAudit { + userId: string; + deletedAt: Generated; +} + export interface VectorsPgVectorIndexStat { idx_growing: ArrayType | null; idx_indexing: boolean | null; @@ -429,6 +443,7 @@ export interface DB { partners: Partners; person: Person; sessions: Sessions; + session_sync_checkpoints: SessionSyncCheckpoints; shared_link__asset: SharedLinkAsset; shared_links: SharedLinks; smart_search: SmartSearch; @@ -440,6 +455,7 @@ export interface DB { typeorm_metadata: TypeormMetadata; user_metadata: UserMetadata; users: Users; + users_audit: UsersAudit; 'vectors.pg_vector_index_stat': VectorsPgVectorIndexStat; version_history: VersionHistory; } diff --git a/server/src/dtos/sync.dto.ts b/server/src/dtos/sync.dto.ts index 820de8d6c3..0628a566cd 100644 --- a/server/src/dtos/sync.dto.ts +++ b/server/src/dtos/sync.dto.ts @@ -1,7 +1,8 @@ import { ApiProperty } from '@nestjs/swagger'; -import { IsInt, IsPositive } from 'class-validator'; +import { IsEnum, IsInt, IsPositive, IsString } from 'class-validator'; import { AssetResponseDto } from 'src/dtos/asset-response.dto'; -import { ValidateDate, ValidateUUID } from 'src/validation'; +import { SyncEntityType, SyncRequestType } from 'src/enum'; +import { Optional, ValidateDate, ValidateUUID } from 'src/validation'; export class AssetFullSyncDto { @ValidateUUID({ optional: true }) @@ -32,3 +33,51 @@ export class AssetDeltaSyncResponseDto { upserted!: AssetResponseDto[]; deleted!: string[]; } + +export class SyncUserV1 { + id!: string; + name!: string; + email!: string; + deletedAt!: Date | null; +} + +export class SyncUserDeleteV1 { + userId!: string; +} + +export type SyncItem = { + [SyncEntityType.UserV1]: SyncUserV1; + [SyncEntityType.UserDeleteV1]: SyncUserDeleteV1; +}; + +const responseDtos = [ + // + SyncUserV1, + SyncUserDeleteV1, +]; + +export const extraSyncModels = responseDtos; + +export class SyncStreamDto { + @IsEnum(SyncRequestType, { each: true }) + @ApiProperty({ enumName: 'SyncRequestType', enum: SyncRequestType, isArray: true }) + types!: SyncRequestType[]; +} + +export class SyncAckDto { + @ApiProperty({ enumName: 'SyncEntityType', enum: SyncEntityType }) + type!: SyncEntityType; + ack!: string; +} + +export class SyncAckSetDto { + @IsString({ each: true }) + acks!: string[]; +} + +export class SyncAckDeleteDto { + @IsEnum(SyncEntityType, { each: true }) + @ApiProperty({ enumName: 'SyncEntityType', enum: SyncEntityType, isArray: true }) + @Optional() + types?: SyncEntityType[]; +} diff --git a/server/src/entities/index.ts b/server/src/entities/index.ts index 75e92038ac..a1df269c09 100644 --- a/server/src/entities/index.ts +++ b/server/src/entities/index.ts @@ -20,8 +20,10 @@ import { SessionEntity } from 'src/entities/session.entity'; import { SharedLinkEntity } from 'src/entities/shared-link.entity'; import { SmartSearchEntity } from 'src/entities/smart-search.entity'; import { StackEntity } from 'src/entities/stack.entity'; +import { SessionSyncCheckpointEntity } from 'src/entities/sync-checkpoint.entity'; import { SystemMetadataEntity } from 'src/entities/system-metadata.entity'; import { TagEntity } from 'src/entities/tag.entity'; +import { UserAuditEntity } from 'src/entities/user-audit.entity'; import { UserMetadataEntity } from 'src/entities/user-metadata.entity'; import { UserEntity } from 'src/entities/user.entity'; import { VersionHistoryEntity } from 'src/entities/version-history.entity'; @@ -44,12 +46,14 @@ export const entities = [ MoveEntity, PartnerEntity, PersonEntity, + SessionSyncCheckpointEntity, SharedLinkEntity, SmartSearchEntity, StackEntity, SystemMetadataEntity, TagEntity, UserEntity, + UserAuditEntity, UserMetadataEntity, SessionEntity, LibraryEntity, diff --git a/server/src/entities/sync-checkpoint.entity.ts b/server/src/entities/sync-checkpoint.entity.ts new file mode 100644 index 0000000000..2a91d2386c --- /dev/null +++ b/server/src/entities/sync-checkpoint.entity.ts @@ -0,0 +1,24 @@ +import { SessionEntity } from 'src/entities/session.entity'; +import { SyncEntityType } from 'src/enum'; +import { Column, CreateDateColumn, Entity, ManyToOne, PrimaryColumn, UpdateDateColumn } from 'typeorm'; + +@Entity('session_sync_checkpoints') +export class SessionSyncCheckpointEntity { + @ManyToOne(() => SessionEntity, { onDelete: 'CASCADE', onUpdate: 'CASCADE' }) + session?: SessionEntity; + + @PrimaryColumn() + sessionId!: string; + + @PrimaryColumn({ type: 'varchar' }) + type!: SyncEntityType; + + @CreateDateColumn({ type: 'timestamptz' }) + createdAt!: Date; + + @UpdateDateColumn({ type: 'timestamptz' }) + updatedAt!: Date; + + @Column() + ack!: string; +} diff --git a/server/src/entities/user-audit.entity.ts b/server/src/entities/user-audit.entity.ts new file mode 100644 index 0000000000..305994a6d6 --- /dev/null +++ b/server/src/entities/user-audit.entity.ts @@ -0,0 +1,14 @@ +import { Column, CreateDateColumn, Entity, Index, PrimaryGeneratedColumn } from 'typeorm'; + +@Entity('users_audit') +@Index('IDX_users_audit_deleted_at_asc_user_id_asc', ['deletedAt', 'userId']) +export class UserAuditEntity { + @PrimaryGeneratedColumn('increment') + id!: number; + + @Column({ type: 'uuid' }) + userId!: string; + + @CreateDateColumn({ type: 'timestamptz' }) + deletedAt!: Date; +} diff --git a/server/src/entities/user.entity.ts b/server/src/entities/user.entity.ts index 3f5b470ce4..b597d15cf9 100644 --- a/server/src/entities/user.entity.ts +++ b/server/src/entities/user.entity.ts @@ -10,12 +10,14 @@ import { CreateDateColumn, DeleteDateColumn, Entity, + Index, OneToMany, PrimaryGeneratedColumn, UpdateDateColumn, } from 'typeorm'; @Entity('users') +@Index('IDX_users_updated_at_asc_id_asc', ['updatedAt', 'id']) export class UserEntity { @PrimaryGeneratedColumn('uuid') id!: string; diff --git a/server/src/enum.ts b/server/src/enum.ts index 0c1fb01a12..b99518c4ff 100644 --- a/server/src/enum.ts +++ b/server/src/enum.ts @@ -537,3 +537,12 @@ export enum DatabaseLock { GetSystemConfig = 69, BackupDatabase = 42, } + +export enum SyncRequestType { + UsersV1 = 'UsersV1', +} + +export enum SyncEntityType { + UserV1 = 'UserV1', + UserDeleteV1 = 'UserDeleteV1', +} diff --git a/server/src/middleware/global-exception.filter.ts b/server/src/middleware/global-exception.filter.ts index 7d7ade471e..a8afa91cbc 100644 --- a/server/src/middleware/global-exception.filter.ts +++ b/server/src/middleware/global-exception.filter.ts @@ -22,6 +22,13 @@ export class GlobalExceptionFilter implements ExceptionFilter { } } + handleError(res: Response, error: Error) { + const { status, body } = this.fromError(error); + if (!res.headersSent) { + res.status(status).json({ ...body, statusCode: status, correlationId: this.cls.getId() }); + } + } + private fromError(error: Error) { logGlobalError(this.logger, error); diff --git a/server/src/migrations/1740001232576-AddSessionSyncCheckpointTable.ts b/server/src/migrations/1740001232576-AddSessionSyncCheckpointTable.ts new file mode 100644 index 0000000000..ef75dd7c0d --- /dev/null +++ b/server/src/migrations/1740001232576-AddSessionSyncCheckpointTable.ts @@ -0,0 +1,22 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class AddSessionSyncCheckpointTable1740001232576 implements MigrationInterface { + name = 'AddSessionSyncCheckpointTable1740001232576' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`CREATE TABLE "session_sync_checkpoints" ("sessionId" uuid NOT NULL, "type" character varying NOT NULL, "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "updatedAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "ack" character varying NOT NULL, CONSTRAINT "PK_b846ab547a702863ef7cd9412fb" PRIMARY KEY ("sessionId", "type"))`); + await queryRunner.query(`ALTER TABLE "session_sync_checkpoints" ADD CONSTRAINT "FK_d8ddd9d687816cc490432b3d4bc" FOREIGN KEY ("sessionId") REFERENCES "sessions"("id") ON DELETE CASCADE ON UPDATE CASCADE`); + await queryRunner.query(` + create trigger session_sync_checkpoints_updated_at + before update on session_sync_checkpoints + for each row execute procedure updated_at() + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`drop trigger session_sync_checkpoints_updated_at on session_sync_checkpoints`); + await queryRunner.query(`ALTER TABLE "session_sync_checkpoints" DROP CONSTRAINT "FK_d8ddd9d687816cc490432b3d4bc"`); + await queryRunner.query(`DROP TABLE "session_sync_checkpoints"`); + } + +} diff --git a/server/src/migrations/1740064899123-AddUsersAuditTable.ts b/server/src/migrations/1740064899123-AddUsersAuditTable.ts new file mode 100644 index 0000000000..b8f2ce5e3a --- /dev/null +++ b/server/src/migrations/1740064899123-AddUsersAuditTable.ts @@ -0,0 +1,34 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class AddUsersAuditTable1740064899123 implements MigrationInterface { + name = 'AddUsersAuditTable1740064899123' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`CREATE INDEX IF NOT EXISTS "IDX_users_updated_at_asc_id_asc" ON "users" ("updatedAt" ASC, "id" ASC);`) + await queryRunner.query(`CREATE TABLE "users_audit" ("id" SERIAL NOT NULL, "userId" uuid NOT NULL, "deletedAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), CONSTRAINT "PK_e9b2bdfd90e7eb5961091175180" PRIMARY KEY ("id"))`); + await queryRunner.query(`CREATE INDEX IF NOT EXISTS "IDX_users_audit_deleted_at_asc_user_id_asc" ON "users_audit" ("deletedAt" ASC, "userId" ASC);`) + await queryRunner.query(`CREATE OR REPLACE FUNCTION users_delete_audit() RETURNS TRIGGER AS + $$ + BEGIN + INSERT INTO users_audit ("userId") + SELECT "id" + FROM OLD; + RETURN NULL; + END; + $$ LANGUAGE plpgsql` + ); + await queryRunner.query(`CREATE OR REPLACE TRIGGER users_delete_audit + AFTER DELETE ON users + REFERENCING OLD TABLE AS OLD + FOR EACH STATEMENT + EXECUTE FUNCTION users_delete_audit(); + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TRIGGER users_delete_audit`); + await queryRunner.query(`DROP FUNCTION users_delete_audit`); + await queryRunner.query(`DROP TABLE "users_audit"`); + } + +} diff --git a/server/src/repositories/index.ts b/server/src/repositories/index.ts index d3a8aeeb69..180d8ccd4f 100644 --- a/server/src/repositories/index.ts +++ b/server/src/repositories/index.ts @@ -30,6 +30,7 @@ import { SessionRepository } from 'src/repositories/session.repository'; import { SharedLinkRepository } from 'src/repositories/shared-link.repository'; import { StackRepository } from 'src/repositories/stack.repository'; import { StorageRepository } from 'src/repositories/storage.repository'; +import { SyncRepository } from 'src/repositories/sync.repository'; import { SystemMetadataRepository } from 'src/repositories/system-metadata.repository'; import { TagRepository } from 'src/repositories/tag.repository'; import { TelemetryRepository } from 'src/repositories/telemetry.repository'; @@ -71,6 +72,7 @@ export const repositories = [ SharedLinkRepository, StackRepository, StorageRepository, + SyncRepository, SystemMetadataRepository, TagRepository, TelemetryRepository, diff --git a/server/src/repositories/sync.repository.ts b/server/src/repositories/sync.repository.ts new file mode 100644 index 0000000000..4023bf890e --- /dev/null +++ b/server/src/repositories/sync.repository.ts @@ -0,0 +1,80 @@ +import { Injectable } from '@nestjs/common'; +import { Insertable, Kysely, sql } from 'kysely'; +import { InjectKysely } from 'nestjs-kysely'; +import { columns } from 'src/database'; +import { DB, SessionSyncCheckpoints } from 'src/db'; +import { SyncEntityType } from 'src/enum'; +import { SyncAck } from 'src/types'; + +@Injectable() +export class SyncRepository { + constructor(@InjectKysely() private db: Kysely) {} + + getCheckpoints(sessionId: string) { + return this.db + .selectFrom('session_sync_checkpoints') + .select(['type', 'ack']) + .where('sessionId', '=', sessionId) + .execute(); + } + + upsertCheckpoints(items: Insertable[]) { + return this.db + .insertInto('session_sync_checkpoints') + .values(items) + .onConflict((oc) => + oc.columns(['sessionId', 'type']).doUpdateSet((eb) => ({ + ack: eb.ref('excluded.ack'), + })), + ) + .execute(); + } + + deleteCheckpoints(sessionId: string, types?: SyncEntityType[]) { + return this.db + .deleteFrom('session_sync_checkpoints') + .where('sessionId', '=', sessionId) + .$if(!!types, (qb) => qb.where('type', 'in', types!)) + .execute(); + } + + getUserUpserts(ack?: SyncAck) { + return this.db + .selectFrom('users') + .select(['id', 'name', 'email', 'deletedAt']) + .select(columns.ackEpoch('updatedAt')) + .$if(!!ack, (qb) => + qb.where((eb) => + eb.or([ + eb(eb.fn('to_timestamp', [sql.val(ack!.ackEpoch)]), '<', eb.ref('updatedAt')), + eb.and([ + eb(eb.fn('to_timestamp', [sql.val(ack!.ackEpoch)]), '<=', eb.ref('updatedAt')), + eb('id', '>', ack!.ids[0]), + ]), + ]), + ), + ) + .orderBy(['updatedAt asc', 'id asc']) + .stream(); + } + + getUserDeletes(ack?: SyncAck) { + return this.db + .selectFrom('users_audit') + .select(['userId']) + .select(columns.ackEpoch('deletedAt')) + .$if(!!ack, (qb) => + qb.where((eb) => + eb.or([ + eb(eb.fn('to_timestamp', [sql.val(ack!.ackEpoch)]), '<', eb.ref('deletedAt')), + eb.and([ + eb(eb.fn('to_timestamp', [sql.val(ack!.ackEpoch)]), '<=', eb.ref('deletedAt')), + eb('userId', '>', ack!.ids[0]), + ]), + ]), + ), + ) + .orderBy(['deletedAt asc', 'userId asc']) + .stream(); + } +} diff --git a/server/src/services/base.service.ts b/server/src/services/base.service.ts index f476adba11..63cca43cc2 100644 --- a/server/src/services/base.service.ts +++ b/server/src/services/base.service.ts @@ -38,6 +38,7 @@ import { SessionRepository } from 'src/repositories/session.repository'; import { SharedLinkRepository } from 'src/repositories/shared-link.repository'; import { StackRepository } from 'src/repositories/stack.repository'; import { StorageRepository } from 'src/repositories/storage.repository'; +import { SyncRepository } from 'src/repositories/sync.repository'; import { SystemMetadataRepository } from 'src/repositories/system-metadata.repository'; import { TagRepository } from 'src/repositories/tag.repository'; import { TelemetryRepository } from 'src/repositories/telemetry.repository'; @@ -85,6 +86,7 @@ export class BaseService { protected sharedLinkRepository: SharedLinkRepository, protected stackRepository: StackRepository, protected storageRepository: StorageRepository, + protected syncRepository: SyncRepository, protected systemMetadataRepository: SystemMetadataRepository, protected tagRepository: TagRepository, protected telemetryRepository: TelemetryRepository, diff --git a/server/src/services/sync.service.ts b/server/src/services/sync.service.ts index fe967e37e0..b94e8cfcbf 100644 --- a/server/src/services/sync.service.ts +++ b/server/src/services/sync.service.ts @@ -1,18 +1,112 @@ -import { Injectable } from '@nestjs/common'; +import { ForbiddenException, Injectable } from '@nestjs/common'; +import { Insertable } from 'kysely'; import { DateTime } from 'luxon'; +import { Writable } from 'node:stream'; import { AUDIT_LOG_MAX_DURATION } from 'src/constants'; +import { SessionSyncCheckpoints } from 'src/db'; import { AssetResponseDto, mapAsset } from 'src/dtos/asset-response.dto'; import { AuthDto } from 'src/dtos/auth.dto'; -import { AssetDeltaSyncDto, AssetDeltaSyncResponseDto, AssetFullSyncDto } from 'src/dtos/sync.dto'; -import { DatabaseAction, EntityType, Permission } from 'src/enum'; +import { + AssetDeltaSyncDto, + AssetDeltaSyncResponseDto, + AssetFullSyncDto, + SyncAckDeleteDto, + SyncAckSetDto, + SyncStreamDto, +} from 'src/dtos/sync.dto'; +import { DatabaseAction, EntityType, Permission, SyncEntityType, SyncRequestType } from 'src/enum'; import { BaseService } from 'src/services/base.service'; +import { SyncAck } from 'src/types'; import { getMyPartnerIds } from 'src/utils/asset.util'; import { setIsEqual } from 'src/utils/set'; +import { fromAck, serialize } from 'src/utils/sync'; const FULL_SYNC = { needsFullSync: true, deleted: [], upserted: [] }; +const SYNC_TYPES_ORDER = [ + // + SyncRequestType.UsersV1, +]; + +const throwSessionRequired = () => { + throw new ForbiddenException('Sync endpoints cannot be used with API keys'); +}; @Injectable() export class SyncService extends BaseService { + getAcks(auth: AuthDto) { + const sessionId = auth.session?.id; + if (!sessionId) { + return throwSessionRequired(); + } + + return this.syncRepository.getCheckpoints(sessionId); + } + + async setAcks(auth: AuthDto, dto: SyncAckSetDto) { + // TODO ack validation + + const sessionId = auth.session?.id; + if (!sessionId) { + return throwSessionRequired(); + } + + const checkpoints: Insertable[] = []; + for (const ack of dto.acks) { + const { type } = fromAck(ack); + checkpoints.push({ sessionId, type, ack }); + } + + await this.syncRepository.upsertCheckpoints(checkpoints); + } + + async deleteAcks(auth: AuthDto, dto: SyncAckDeleteDto) { + const sessionId = auth.session?.id; + if (!sessionId) { + return throwSessionRequired(); + } + + await this.syncRepository.deleteCheckpoints(sessionId, dto.types); + } + + async stream(auth: AuthDto, response: Writable, dto: SyncStreamDto) { + const sessionId = auth.session?.id; + if (!sessionId) { + return throwSessionRequired(); + } + + const checkpoints = await this.syncRepository.getCheckpoints(sessionId); + const checkpointMap: Partial> = Object.fromEntries( + checkpoints.map(({ type, ack }) => [type, fromAck(ack)]), + ); + + // TODO pre-filter/sort list based on optimal sync order + + for (const type of SYNC_TYPES_ORDER.filter((type) => dto.types.includes(type))) { + switch (type) { + case SyncRequestType.UsersV1: { + const deletes = this.syncRepository.getUserDeletes(checkpointMap[SyncEntityType.UserDeleteV1]); + for await (const { ackEpoch, ...data } of deletes) { + response.write(serialize({ type: SyncEntityType.UserDeleteV1, ackEpoch, ids: [data.userId], data })); + } + + const upserts = this.syncRepository.getUserUpserts(checkpointMap[SyncEntityType.UserV1]); + for await (const { ackEpoch, ...data } of upserts) { + response.write(serialize({ type: SyncEntityType.UserV1, ackEpoch, ids: [data.id], data })); + } + + break; + } + + default: { + this.logger.warn(`Unsupported sync type: ${type}`); + break; + } + } + } + + response.end(); + } + async getFullSync(auth: AuthDto, dto: AssetFullSyncDto): Promise { // mobile implementation is faster if this is a single id const userId = dto.userId || auth.user.id; diff --git a/server/src/types.ts b/server/src/types.ts index 3a331127e6..544d35524e 100644 --- a/server/src/types.ts +++ b/server/src/types.ts @@ -4,6 +4,7 @@ import { ImageFormat, JobName, QueueName, + SyncEntityType, TranscodeTarget, VideoCodec, } from 'src/enum'; @@ -409,3 +410,9 @@ export interface IBulkAsset { addAssetIds: (id: string, assetIds: string[]) => Promise; removeAssetIds: (id: string, assetIds: string[]) => Promise; } + +export type SyncAck = { + type: SyncEntityType; + ackEpoch: string; + ids: string[]; +}; diff --git a/server/src/utils/misc.ts b/server/src/utils/misc.ts index 13969543ef..e07d0fe03f 100644 --- a/server/src/utils/misc.ts +++ b/server/src/utils/misc.ts @@ -12,6 +12,7 @@ import { writeFileSync } from 'node:fs'; import path from 'node:path'; import { SystemConfig } from 'src/config'; import { CLIP_MODEL_INFO, serverVersion } from 'src/constants'; +import { extraSyncModels } from 'src/dtos/sync.dto'; import { ImmichCookie, ImmichHeader, MetadataKey } from 'src/enum'; import { LoggingRepository } from 'src/repositories/logging.repository'; @@ -245,6 +246,7 @@ export const useSwagger = (app: INestApplication, { write }: { write: boolean }) const options: SwaggerDocumentOptions = { operationIdFactory: (controllerKey: string, methodKey: string) => methodKey, + extraModels: extraSyncModels, }; const specification = SwaggerModule.createDocument(app, config, options); diff --git a/server/src/utils/sync.ts b/server/src/utils/sync.ts new file mode 100644 index 0000000000..8e426ab860 --- /dev/null +++ b/server/src/utils/sync.ts @@ -0,0 +1,30 @@ +import { SyncItem } from 'src/dtos/sync.dto'; +import { SyncEntityType } from 'src/enum'; +import { SyncAck } from 'src/types'; + +type Impossible = { + [P in K]: never; +}; + +type Exact = U & Impossible>; + +export const fromAck = (ack: string): SyncAck => { + const [type, timestamp, ...ids] = ack.split('|'); + return { type: type as SyncEntityType, ackEpoch: timestamp, ids }; +}; + +export const toAck = ({ type, ackEpoch, ids }: SyncAck) => [type, ackEpoch, ...ids].join('|'); + +export const mapJsonLine = (object: unknown) => JSON.stringify(object) + '\n'; + +export const serialize = ({ + type, + ackEpoch, + ids, + data, +}: { + type: T; + ackEpoch: string; + ids: string[]; + data: Exact; +}) => mapJsonLine({ type, data, ack: toAck({ type, ackEpoch, ids }) }); diff --git a/server/test/repositories/sync.repository.mock.ts b/server/test/repositories/sync.repository.mock.ts new file mode 100644 index 0000000000..fbb8ec2f62 --- /dev/null +++ b/server/test/repositories/sync.repository.mock.ts @@ -0,0 +1,13 @@ +import { SyncRepository } from 'src/repositories/sync.repository'; +import { RepositoryInterface } from 'src/types'; +import { Mocked, vitest } from 'vitest'; + +export const newSyncRepositoryMock = (): Mocked> => { + return { + getCheckpoints: vitest.fn(), + upsertCheckpoints: vitest.fn(), + deleteCheckpoints: vitest.fn(), + getUserUpserts: vitest.fn(), + getUserDeletes: vitest.fn(), + }; +}; diff --git a/server/test/utils.ts b/server/test/utils.ts index d1dda3eedf..ca2272f6b8 100644 --- a/server/test/utils.ts +++ b/server/test/utils.ts @@ -34,6 +34,7 @@ import { SessionRepository } from 'src/repositories/session.repository'; import { SharedLinkRepository } from 'src/repositories/shared-link.repository'; import { StackRepository } from 'src/repositories/stack.repository'; import { StorageRepository } from 'src/repositories/storage.repository'; +import { SyncRepository } from 'src/repositories/sync.repository'; import { SystemMetadataRepository } from 'src/repositories/system-metadata.repository'; import { TagRepository } from 'src/repositories/tag.repository'; import { TelemetryRepository } from 'src/repositories/telemetry.repository'; @@ -75,6 +76,7 @@ import { newSessionRepositoryMock } from 'test/repositories/session.repository.m import { newSharedLinkRepositoryMock } from 'test/repositories/shared-link.repository.mock'; import { newStackRepositoryMock } from 'test/repositories/stack.repository.mock'; import { newStorageRepositoryMock } from 'test/repositories/storage.repository.mock'; +import { newSyncRepositoryMock } from 'test/repositories/sync.repository.mock'; import { newSystemMetadataRepositoryMock } from 'test/repositories/system-metadata.repository.mock'; import { newTagRepositoryMock } from 'test/repositories/tag.repository.mock'; import { ITelemetryRepositoryMock, newTelemetryRepositoryMock } from 'test/repositories/telemetry.repository.mock'; @@ -178,6 +180,7 @@ export const newTestService = ( const sharedLinkMock = newSharedLinkRepositoryMock(); const stackMock = newStackRepositoryMock(); const storageMock = newStorageRepositoryMock(); + const syncMock = newSyncRepositoryMock(); const systemMock = newSystemMetadataRepositoryMock(); const tagMock = newTagRepositoryMock(); const telemetryMock = newTelemetryRepositoryMock(); @@ -219,6 +222,7 @@ export const newTestService = ( sharedLinkMock as RepositoryInterface as SharedLinkRepository, stackMock as RepositoryInterface as StackRepository, storageMock as RepositoryInterface as StorageRepository, + syncMock as RepositoryInterface as SyncRepository, systemMock as RepositoryInterface as SystemMetadataRepository, tagMock as RepositoryInterface as TagRepository, telemetryMock as unknown as TelemetryRepository, From 52f21fb331f8f01b55f8e0b934ddb48999b78664 Mon Sep 17 00:00:00 2001 From: Mert <101130780+mertalev@users.noreply.github.com> Date: Fri, 21 Feb 2025 07:38:12 +0300 Subject: [PATCH 185/395] fix(web): viewport reactivity, off-screen thumbhashes being rendered (#15435) * viewport optimizations * fade in * async bitmap * fast path for smaller date groups --------- Co-authored-by: Alex --- web/package-lock.json | 8 +- web/package.json | 3 +- web/src/lib/actions/thumbhash.ts | 132 +++++++++++++++++- .../assets/thumbnail/thumbnail.svelte | 21 ++- .../photos-page/asset-date-group.svelte | 4 +- .../components/photos-page/asset-grid.svelte | 62 ++++---- web/src/lib/stores/assets.store.ts | 2 + web/src/lib/utils/tunables.ts | 3 + 8 files changed, 183 insertions(+), 52 deletions(-) diff --git a/web/package-lock.json b/web/package-lock.json index c6615f0afc..782971cffb 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -30,8 +30,7 @@ "svelte-gestures": "^5.1.3", "svelte-i18n": "^4.0.1", "svelte-local-storage-store": "^0.6.4", - "svelte-maplibre": "^0.9.13", - "thumbhash": "^0.1.1" + "svelte-maplibre": "^0.9.13" }, "devDependencies": { "@eslint/eslintrc": "^3.1.0", @@ -8209,11 +8208,6 @@ "integrity": "sha512-AUwVmViIEUgBwxJJ7stnF0NkPpZxx1aZ6WiAbQ/Qq61h6I9UR4grXtZDmO8mnlaNORhHnIBlXJ1uBxILEKuVyw==", "license": "MIT" }, - "node_modules/thumbhash": { - "version": "0.1.1", - "resolved": "https://registry.npmjs.org/thumbhash/-/thumbhash-0.1.1.tgz", - "integrity": "sha512-kH5pKeIIBPQXAOni2AiY/Cu/NKdkFREdpH+TLdM0g6WA7RriCv0kPLgP731ady67MhTAqrVG/4mnEeibVuCJcg==" - }, "node_modules/timers-ext": { "version": "0.1.7", "resolved": "https://registry.npmjs.org/timers-ext/-/timers-ext-0.1.7.tgz", diff --git a/web/package.json b/web/package.json index 7396e1288a..3fd4396e21 100644 --- a/web/package.json +++ b/web/package.json @@ -86,8 +86,7 @@ "svelte-gestures": "^5.1.3", "svelte-i18n": "^4.0.1", "svelte-local-storage-store": "^0.6.4", - "svelte-maplibre": "^0.9.13", - "thumbhash": "^0.1.1" + "svelte-maplibre": "^0.9.13" }, "volta": { "node": "22.14.0" diff --git a/web/src/lib/actions/thumbhash.ts b/web/src/lib/actions/thumbhash.ts index e49f04dbee..d3d4e369af 100644 --- a/web/src/lib/actions/thumbhash.ts +++ b/web/src/lib/actions/thumbhash.ts @@ -1,5 +1,4 @@ import { decodeBase64 } from '$lib/utils'; -import { thumbHashToRGBA } from 'thumbhash'; /** * Renders a thumbnail onto a canvas from a base64 encoded hash. @@ -7,13 +6,132 @@ import { thumbHashToRGBA } from 'thumbhash'; * @param param1 object containing the base64 encoded hash (base64Thumbhash: yourString) */ export function thumbhash(canvas: HTMLCanvasElement, { base64ThumbHash }: { base64ThumbHash: string }) { - const ctx = canvas.getContext('2d'); + const ctx = canvas.getContext('bitmaprenderer'); if (ctx) { const { w, h, rgba } = thumbHashToRGBA(decodeBase64(base64ThumbHash)); - const pixels = ctx.createImageData(w, h); - canvas.width = w; - canvas.height = h; - pixels.data.set(rgba); - ctx.putImageData(pixels, 0, 0); + void createImageBitmap(new ImageData(rgba, w, h)).then((bitmap) => ctx.transferFromImageBitmap(bitmap)); } } + +// This copyright notice applies to the below code +// It is a modified version of the original that uses `Uint8ClampedArray` instead of `UInt8Array` and has some trivial typing/linting changes + +/* Copyright (c) 2023 Evan Wallace +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.*/ +/** + * Decodes a ThumbHash to an RGBA image. RGB is not be premultiplied by A. + * + * @param hash The bytes of the ThumbHash. + * @returns The width, height, and pixels of the rendered placeholder image. + */ +export function thumbHashToRGBA(hash: Uint8Array) { + const { PI, max, cos, round } = Math; + + // Read the constants + const header24 = hash[0] | (hash[1] << 8) | (hash[2] << 16); + const header16 = hash[3] | (hash[4] << 8); + const l_dc = (header24 & 63) / 63; + const p_dc = ((header24 >> 6) & 63) / 31.5 - 1; + const q_dc = ((header24 >> 12) & 63) / 31.5 - 1; + const l_scale = ((header24 >> 18) & 31) / 31; + const hasAlpha = header24 >> 23; + const p_scale = ((header16 >> 3) & 63) / 63; + const q_scale = ((header16 >> 9) & 63) / 63; + const isLandscape = header16 >> 15; + const lx = max(3, isLandscape ? (hasAlpha ? 5 : 7) : header16 & 7); + const ly = max(3, isLandscape ? header16 & 7 : hasAlpha ? 5 : 7); + const a_dc = hasAlpha ? (hash[5] & 15) / 15 : 1; + const a_scale = (hash[5] >> 4) / 15; + + // Read the varying factors (boost saturation by 1.25x to compensate for quantization) + const ac_start = hasAlpha ? 6 : 5; + let ac_index = 0; + const decodeChannel = (nx: number, ny: number, scale: number) => { + const ac = []; + for (let cy = 0; cy < ny; cy++) { + for (let cx = cy ? 0 : 1; cx * ny < nx * (ny - cy); cx++) { + ac.push((((hash[ac_start + (ac_index >> 1)] >> ((ac_index++ & 1) << 2)) & 15) / 7.5 - 1) * scale); + } + } + return ac; + }; + const l_ac = decodeChannel(lx, ly, l_scale); + const p_ac = decodeChannel(3, 3, p_scale * 1.25); + const q_ac = decodeChannel(3, 3, q_scale * 1.25); + const a_ac = hasAlpha ? decodeChannel(5, 5, a_scale) : null; + + // Decode using the DCT into RGB + const ratio = thumbHashToApproximateAspectRatio(hash); + const w = round(ratio > 1 ? 32 : 32 * ratio); + const h = round(ratio > 1 ? 32 / ratio : 32); + const rgba = new Uint8ClampedArray(w * h * 4), + fx = [], + fy = []; + for (let y = 0, i = 0; y < h; y++) { + for (let x = 0; x < w; x++, i += 4) { + let l = l_dc, + p = p_dc, + q = q_dc, + a = a_dc; + + // Precompute the coefficients + for (let cx = 0, n = max(lx, hasAlpha ? 5 : 3); cx < n; cx++) { + fx[cx] = cos((PI / w) * (x + 0.5) * cx); + } + for (let cy = 0, n = max(ly, hasAlpha ? 5 : 3); cy < n; cy++) { + fy[cy] = cos((PI / h) * (y + 0.5) * cy); + } + + // Decode L + for (let cy = 0, j = 0; cy < ly; cy++) { + for (let cx = cy ? 0 : 1, fy2 = fy[cy] * 2; cx * ly < lx * (ly - cy); cx++, j++) { + l += l_ac[j] * fx[cx] * fy2; + } + } + + // Decode P and Q + for (let cy = 0, j = 0; cy < 3; cy++) { + for (let cx = cy ? 0 : 1, fy2 = fy[cy] * 2; cx < 3 - cy; cx++, j++) { + const f = fx[cx] * fy2; + p += p_ac[j] * f; + q += q_ac[j] * f; + } + } + + // Decode A + if (a_ac !== null) { + for (let cy = 0, j = 0; cy < 5; cy++) { + for (let cx = cy ? 0 : 1, fy2 = fy[cy] * 2; cx < 5 - cy; cx++, j++) { + a += a_ac[j] * fx[cx] * fy2; + } + } + } + + // Convert to RGB + const b = l - (2 / 3) * p; + const r = (3 * l - b + q) / 2; + const g = r - q; + rgba[i] = 255 * r; + rgba[i + 1] = 255 * g; + rgba[i + 2] = 255 * b; + rgba[i + 3] = 255 * a; + } + } + return { w, h, rgba }; +} + +/** + * Extracts the approximate aspect ratio of the original image. + * + * @param hash The bytes of the ThumbHash. + * @returns The approximate aspect ratio (i.e. width / height). + */ +export function thumbHashToApproximateAspectRatio(hash: Uint8Array) { + const header = hash[3]; + const hasAlpha = hash[2] & 0x80; + const isLandscape = hash[4] & 0x80; + const lx = isLandscape ? (hasAlpha ? 5 : 7) : header & 7; + const ly = isLandscape ? header & 7 : hasAlpha ? 5 : 7; + return lx / ly; +} diff --git a/web/src/lib/components/assets/thumbnail/thumbnail.svelte b/web/src/lib/components/assets/thumbnail/thumbnail.svelte index c381574277..775c59bb23 100644 --- a/web/src/lib/components/assets/thumbnail/thumbnail.svelte +++ b/web/src/lib/components/assets/thumbnail/thumbnail.svelte @@ -39,6 +39,7 @@ thumbnailSize?: number | undefined; thumbnailWidth?: number | undefined; thumbnailHeight?: number | undefined; + eagerThumbhash?: boolean; selected?: boolean; selectionCandidate?: boolean; disabled?: boolean; @@ -71,6 +72,7 @@ thumbnailSize = undefined, thumbnailWidth = undefined, thumbnailHeight = undefined, + eagerThumbhash = true, selected = false, selectionCandidate = false, disabled = false, @@ -113,7 +115,6 @@ let width = $derived(thumbnailSize || thumbnailWidth || 235); let height = $derived(thumbnailSize || thumbnailHeight || 235); - let display = $derived(intersecting); const onIconClickedHandler = (e?: MouseEvent) => { e?.stopPropagation(); @@ -207,7 +208,11 @@ ? 'bg-gray-300' : 'bg-immich-primary/20 dark:bg-immich-dark-primary/20'}" > - {#if !loaded && asset.thumbhash} + + {#if eagerThumbhash && !loaded && asset.thumbhash} {/if} - {#if display} + {#if intersecting} + {#if !eagerThumbhash && !loaded && asset.thumbhash} + + {/if} +
{#each dateGroup.assets as asset, index (asset.id)} {@const box = dateGroup.geometry.boxes[index]} + {@const isSmallGroup = dateGroup.assets.length <= SMALL_GROUP_THRESHOLD}
{/each} diff --git a/web/src/lib/components/photos-page/asset-grid.svelte b/web/src/lib/components/photos-page/asset-grid.svelte index fd98f7e6a3..dfbfc2a25f 100644 --- a/web/src/lib/components/photos-page/asset-grid.svelte +++ b/web/src/lib/components/photos-page/asset-grid.svelte @@ -20,7 +20,7 @@ } from '$lib/utils/timeline-util'; import { TUNABLES } from '$lib/utils/tunables'; import type { AlbumResponseDto, AssetResponseDto, PersonResponseDto } from '@immich/sdk'; - import { throttle } from 'lodash-es'; + import { debounce, throttle } from 'lodash-es'; import { onDestroy, onMount, type Snippet } from 'svelte'; import Portal from '../shared-components/portal/portal.svelte'; import Scrubber from '../shared-components/scrubber/scrubber.svelte'; @@ -81,8 +81,9 @@ let { isViewing: showAssetViewer, asset: viewingAsset, preloadAssets, gridScrollTarget } = assetViewingStore; - const viewport: ViewportXY = $state({ width: 0, height: 0, x: 0, y: 0 }); - const safeViewport: ViewportXY = $state({ width: 0, height: 0, x: 0, y: 0 }); + // this does *not* need to be reactive and making it reactive causes expensive repeated updates + // svelte-ignore non_reactive_update + let safeViewport: ViewportXY = { width: 0, height: 0, x: 0, y: 0 }; const componentId = generateId(); let element: HTMLElement | undefined = $state(); @@ -103,7 +104,7 @@ let leadout = $state(false); const { - ASSET_GRID: { NAVIGATE_ON_ASSET_IN_VIEW }, + ASSET_GRID: { NAVIGATE_ON_ASSET_IN_VIEW, LARGE_BUCKET_THRESHOLD, LARGE_BUCKET_DEBOUNCE_MS }, BUCKET: { INTERSECTION_ROOT_TOP: BUCKET_INTERSECTION_ROOT_TOP, INTERSECTION_ROOT_BOTTOM: BUCKET_INTERSECTION_ROOT_BOTTOM, @@ -114,14 +115,6 @@ }, } = TUNABLES; - const isViewportOrigin = () => { - return viewport.height === 0 && viewport.width === 0; - }; - - const isEqual = (a: ViewportXY, b: ViewportXY) => { - return a.height == b.height && a.width == b.width && a.x === b.x && a.y === b.y; - }; - const completeNav = () => { navigating = false; if (internalScroll) { @@ -235,6 +228,14 @@ }; onMount(() => { + if (element) { + const rect = element.getBoundingClientRect(); + safeViewport.height = rect.height; + safeViewport.width = rect.width; + safeViewport.x = rect.x; + safeViewport.y = rect.y; + } + void $assetStore .init({ bucketListener }) .then(() => ($assetStore.connect(), $assetStore.updateViewport(safeViewport))); @@ -259,8 +260,6 @@ } return offset; } - const _updateViewport = () => void $assetStore.updateViewport(safeViewport); - const updateViewport = throttle(_updateViewport, 16); const getMaxScrollPercent = () => ($assetStore.timelineHeight + bottomSectionHeight + topSectionHeight - safeViewport.height) / @@ -744,23 +743,8 @@ } }); - $effect(() => { - if (element && isViewportOrigin()) { - const rect = element.getBoundingClientRect(); - viewport.height = rect.height; - viewport.width = rect.width; - viewport.x = rect.x; - viewport.y = rect.y; - } - if (!isViewportOrigin() && !isEqual(viewport, safeViewport)) { - safeViewport.height = viewport.height; - safeViewport.width = viewport.width; - safeViewport.x = viewport.x; - safeViewport.y = viewport.y; - updateViewport(); - } - }); - + let largeBucketMode = false; + let updateViewport = debounce(() => $assetStore.updateViewport(safeViewport), 8); let shortcutList = $derived( (() => { if ($isSearchEnabled || $showAssetViewer) { @@ -843,7 +827,21 @@ id="asset-grid" class="scrollbar-hidden h-full overflow-y-auto outline-none {isEmpty ? 'm-0' : 'ml-4 tall:ml-0 mr-[60px]'}" tabindex="-1" - use:resizeObserver={({ height, width }) => ((viewport.width = width), (viewport.height = height))} + use:resizeObserver={({ width, height }) => { + if (!largeBucketMode && assetStore.maxBucketAssets >= LARGE_BUCKET_THRESHOLD) { + largeBucketMode = true; + // Each viewport update causes each asset to re-decode both the thumbhash and the thumbnail. + // This is because the thumbnail components are destroyed and re-mounted, possibly because of the intersection observer. + // For larger buckets, this can lead to freezing and a poor user experience. + // As a mitigation, we aggressively debounce the viewport update to reduce the number of these events. + updateViewport = debounce(() => $assetStore.updateViewport(safeViewport), LARGE_BUCKET_DEBOUNCE_MS, { + leading: false, + trailing: true, + }); + } + safeViewport = { width, height, x: safeViewport.x, y: safeViewport.y }; + void updateViewport(); + }} bind:this={element} onscroll={() => ((assetStore.lastScrollTime = Date.now()), handleTimelineScroll())} > diff --git a/web/src/lib/stores/assets.store.ts b/web/src/lib/stores/assets.store.ts index 215707543c..99a5b9af63 100644 --- a/web/src/lib/stores/assets.store.ts +++ b/web/src/lib/stores/assets.store.ts @@ -232,6 +232,7 @@ export class AssetStore { albumAssets: Set = new Set(); pendingScrollBucket: AssetBucket | undefined; pendingScrollAssetId: string | undefined; + maxBucketAssets = 0; listeners: BucketListener[] = []; @@ -560,6 +561,7 @@ export class AssetStore { bucket.assets = assets; bucket.dateGroups = splitBucketIntoDateGroups(bucket, get(locale)); + this.maxBucketAssets = Math.max(this.maxBucketAssets, assets.length); this.updateGeometry(bucket, true); this.timelineHeight = this.buckets.reduce((accumulator, b) => accumulator + b.bucketHeight, 0); bucket.loaded(); diff --git a/web/src/lib/utils/tunables.ts b/web/src/lib/utils/tunables.ts index e21c30de77..8ffa767dc8 100644 --- a/web/src/lib/utils/tunables.ts +++ b/web/src/lib/utils/tunables.ts @@ -40,6 +40,8 @@ export const TUNABLES = { }, ASSET_GRID: { NAVIGATE_ON_ASSET_IN_VIEW: getBoolean(localStorage.getItem('ASSET_GRID.NAVIGATE_ON_ASSET_IN_VIEW'), false), + LARGE_BUCKET_THRESHOLD: getNumber(localStorage.getItem('ASSET_GRID.LARGE_BUCKET_THRESHOLD'), 3000), + LARGE_BUCKET_DEBOUNCE_MS: getNumber(localStorage.getItem('ASSET_GRID.LARGE_BUCKET_DEBOUNCE_MS'), 200), }, BUCKET: { PRIORITY: getNumber(localStorage.getItem('BUCKET.PRIORITY'), 2), @@ -51,6 +53,7 @@ export const TUNABLES = { INTERSECTION_DISABLED: getBoolean(localStorage.getItem('DATEGROUP.INTERSECTION_DISABLED'), false), INTERSECTION_ROOT_TOP: localStorage.getItem('DATEGROUP.INTERSECTION_ROOT_TOP') || '150%', INTERSECTION_ROOT_BOTTOM: localStorage.getItem('DATEGROUP.INTERSECTION_ROOT_BOTTOM') || '150%', + SMALL_GROUP_THRESHOLD: getNumber(localStorage.getItem('DATEGROUP.SMALL_GROUP_THRESHOLD'), 100), }, THUMBNAIL: { PRIORITY: getNumber(localStorage.getItem('THUMBNAIL.PRIORITY'), 8), From 3925445de8206946de8c7f9b7a6742b2df5e110d Mon Sep 17 00:00:00 2001 From: Mert <101130780+mertalev@users.noreply.github.com> Date: Fri, 21 Feb 2025 12:20:25 +0300 Subject: [PATCH 186/395] feat(web): use wasm for justified layout calculation (#15524) * working * use wrapper class * update import * simplify * it works without changing `optimizeDeps` * inline layout options * update gallery view * use es2022 * fix import * fix vitest * empty geometry * bump version * Update web/src/lib/stores/assets.store.ts Co-authored-by: Jason Rasmussen * fix: typo --------- Co-authored-by: Jason Rasmussen Co-authored-by: Alex Tran --- web/package-lock.json | 31 +++++++------ web/package.json | 4 +- .../photos-page/asset-date-group.svelte | 30 ++++++++----- .../gallery-viewer/gallery-viewer.svelte | 41 +++++++---------- web/src/lib/stores/assets.store.ts | 36 ++++++--------- web/src/lib/utils/asset-utils.ts | 11 +++++ web/src/lib/utils/timeline-util.ts | 44 +++++-------------- web/tsconfig.json | 2 +- web/vite.config.js | 9 ++++ 9 files changed, 98 insertions(+), 110 deletions(-) diff --git a/web/package-lock.json b/web/package-lock.json index 782971cffb..e97731061e 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -10,6 +10,7 @@ "license": "GNU Affero General Public License version 3", "dependencies": { "@formatjs/icu-messageformat-parser": "^2.9.8", + "@immich/justified-layout-wasm": "^0.1.2", "@immich/sdk": "file:../open-api/typescript-sdk", "@immich/ui": "^0.16.0", "@mapbox/mapbox-gl-rtl-text": "0.2.3", @@ -23,7 +24,6 @@ "dom-to-image": "^2.6.0", "handlebars": "^4.7.8", "intl-messageformat": "^10.7.11", - "justified-layout": "^4.1.0", "lodash-es": "^4.17.21", "luxon": "^3.4.4", "socket.io-client": "~4.8.0", @@ -45,7 +45,6 @@ "@testing-library/svelte": "^5.2.6", "@testing-library/user-event": "^14.5.2", "@types/dom-to-image": "^2.6.7", - "@types/justified-layout": "^4.1.4", "@types/lodash-es": "^4.17.12", "@types/luxon": "^3.4.2", "@typescript-eslint/eslint-plugin": "^8.20.0", @@ -71,6 +70,7 @@ "tslib": "^2.6.2", "typescript": "^5.7.3", "vite": "^6.0.0", + "vite-plugin-wasm": "^3.4.1", "vitest": "^3.0.0" } }, @@ -1339,6 +1339,12 @@ "url": "https://opencollective.com/libvips" } }, + "node_modules/@immich/justified-layout-wasm": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/@immich/justified-layout-wasm/-/justified-layout-wasm-0.1.2.tgz", + "integrity": "sha512-6AmzYJhLedzIXSkhO/0tfBbHAeUeLmG1c4yTzJmtuSGyn7JAzVCFp0dp4T8Wh1tfIDx0Y0pAYB9tm2xlJHdEPA==", + "license": "AGPL-3" + }, "node_modules/@immich/sdk": { "resolved": "../open-api/typescript-sdk", "link": true @@ -2413,12 +2419,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@types/justified-layout": { - "version": "4.1.4", - "resolved": "https://registry.npmjs.org/@types/justified-layout/-/justified-layout-4.1.4.tgz", - "integrity": "sha512-q2ybP0u0NVj87oMnGZOGxY2iUN8ddr48zPOBHBdbOLpsMTA/keGj+93ou+OMCnJk0xewzlNIaVEkxM6VBD3E2w==", - "dev": true - }, "node_modules/@types/leaflet": { "version": "1.9.8", "resolved": "https://registry.npmjs.org/@types/leaflet/-/leaflet-1.9.8.tgz", @@ -5382,11 +5382,6 @@ "resolved": "https://registry.npmjs.org/just-flush/-/just-flush-2.3.0.tgz", "integrity": "sha512-fBuxQ1gJ61BurmhwKS5LYTzhkbrT5j/2U7ax+UbLm9aRvCTh2h6AfzLteOckE4KKomqOf0Y3zIG3Xu57sRsKUg==" }, - "node_modules/justified-layout": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/justified-layout/-/justified-layout-4.1.0.tgz", - "integrity": "sha512-M5FimNMXgiOYerVRGsXZ2YK9YNCaTtwtYp7Hb2308U1Q9TXXHx5G0p08mcVR5O53qf8bWY4NJcPBxE6zuayXSg==" - }, "node_modules/kdbush": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/kdbush/-/kdbush-4.0.2.tgz", @@ -8625,6 +8620,16 @@ } } }, + "node_modules/vite-plugin-wasm": { + "version": "3.4.1", + "resolved": "https://registry.npmjs.org/vite-plugin-wasm/-/vite-plugin-wasm-3.4.1.tgz", + "integrity": "sha512-ja3nSo2UCkVeitltJGkS3pfQHAanHv/DqGatdI39ja6McgABlpsZ5hVgl6wuR8Qx5etY3T5qgDQhOWzc5RReZA==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "vite": "^2 || ^3 || ^4 || ^5 || ^6" + } + }, "node_modules/vitefu": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/vitefu/-/vitefu-1.0.5.tgz", diff --git a/web/package.json b/web/package.json index 3fd4396e21..2ca8a71848 100644 --- a/web/package.json +++ b/web/package.json @@ -35,7 +35,6 @@ "@testing-library/svelte": "^5.2.6", "@testing-library/user-event": "^14.5.2", "@types/dom-to-image": "^2.6.7", - "@types/justified-layout": "^4.1.4", "@types/lodash-es": "^4.17.12", "@types/luxon": "^3.4.2", "@typescript-eslint/eslint-plugin": "^8.20.0", @@ -61,11 +60,13 @@ "tslib": "^2.6.2", "typescript": "^5.7.3", "vite": "^6.0.0", + "vite-plugin-wasm": "^3.4.1", "vitest": "^3.0.0" }, "type": "module", "dependencies": { "@formatjs/icu-messageformat-parser": "^2.9.8", + "@immich/justified-layout-wasm": "^0.1.2", "@immich/sdk": "file:../open-api/typescript-sdk", "@immich/ui": "^0.16.0", "@mapbox/mapbox-gl-rtl-text": "0.2.3", @@ -79,7 +80,6 @@ "dom-to-image": "^2.6.0", "handlebars": "^4.7.8", "intl-messageformat": "^10.7.11", - "justified-layout": "^4.1.0", "lodash-es": "^4.17.21", "luxon": "^3.4.4", "socket.io-client": "~4.8.0", diff --git a/web/src/lib/components/photos-page/asset-date-group.svelte b/web/src/lib/components/photos-page/asset-date-group.svelte index 5d1497c3a9..566477e743 100644 --- a/web/src/lib/components/photos-page/asset-date-group.svelte +++ b/web/src/lib/components/photos-page/asset-date-group.svelte @@ -97,6 +97,7 @@ {#each dateGroups as dateGroup, groupIndex (dateGroup.date)} {@const display = dateGroup.intersecting || !!dateGroup.assets.some((asset) => asset.id === $assetStore.pendingScrollAssetId)} + {@const geometry = dateGroup.geometry}
{#if !display} @@ -149,7 +150,7 @@
{#if !singleSelect && ((hoveredDateGroup == dateGroup.groupTitle && isMouseOverGroup) || assetInteraction.selectedGroup.has(dateGroup.groupTitle))}
- {#each dateGroup.assets as asset, index (asset.id)} - {@const box = dateGroup.geometry.boxes[index]} + {#each dateGroup.assets as asset, i (asset.id)} {@const isSmallGroup = dateGroup.assets.length <= SMALL_GROUP_THRESHOLD} + + {@const top = geometry.getTop(i)} + {@const left = geometry.getLeft(i)} + {@const width = geometry.getWidth(i)} + {@const height = geometry.getHeight(i)} +
diff --git a/web/src/lib/components/shared-components/gallery-viewer/gallery-viewer.svelte b/web/src/lib/components/shared-components/gallery-viewer/gallery-viewer.svelte index 4c3c35aeca..df54295f04 100644 --- a/web/src/lib/components/shared-components/gallery-viewer/gallery-viewer.svelte +++ b/web/src/lib/components/shared-components/gallery-viewer/gallery-viewer.svelte @@ -8,13 +8,11 @@ import type { Viewport } from '$lib/stores/assets.store'; import { showDeleteModal } from '$lib/stores/preferences.store'; import { deleteAssets } from '$lib/utils/actions'; - import { archiveAssets, cancelMultiselect, getAssetRatio } from '$lib/utils/asset-utils'; + import { archiveAssets, cancelMultiselect, getJustifiedLayoutFromAssets } from '$lib/utils/asset-utils'; import { featureFlags } from '$lib/stores/server-config.store'; import { handleError } from '$lib/utils/handle-error'; import { navigate } from '$lib/utils/navigation'; - import { calculateWidth } from '$lib/utils/timeline-util'; import { type AssetResponseDto } from '@immich/sdk'; - import justifiedLayout from 'justified-layout'; import { t } from 'svelte-i18n'; import AssetViewer from '../../asset-viewer/asset-viewer.svelte'; import ShowShortcuts from '../show-shortcuts.svelte'; @@ -310,23 +308,12 @@ let idsSelectedAssets = $derived(assetInteraction.selectedAssetsArray.map(({ id }) => id)); let geometry = $derived( - (() => { - const justifiedLayoutResult = justifiedLayout( - assets.map((asset) => getAssetRatio(asset)), - { - boxSpacing: 2, - containerWidth: Math.floor(viewport.width), - containerPadding: 0, - targetRowHeightTolerance: 0.15, - targetRowHeight: 235, - }, - ); - - return { - ...justifiedLayoutResult, - containerWidth: calculateWidth(justifiedLayoutResult.boxes), - }; - })(), + getJustifiedLayoutFromAssets(assets, { + spacing: 2, + rowWidth: Math.floor(viewport.width), + heightTolerance: 0.15, + rowHeight: 235, + }), ); $effect(() => { @@ -364,11 +351,15 @@ {#if assets.length > 0}
- {#each assets as asset, i (i)} + {#each assets as asset, i} + {@const top = geometry.getTop(i)} + {@const left = geometry.getLeft(i)} + {@const width = geometry.getWidth(i)} + {@const height = geometry.getHeight(i)} +
{#if showAssetName}
[0]; export type AssetStoreOptions = Omit; -const LAYOUT_OPTIONS = { - boxSpacing: 2, - containerPadding: 0, - targetRowHeightTolerance: 0.15, - targetRowHeight: 235, -}; - export interface Viewport { width: number; height: number; @@ -470,32 +462,30 @@ export class AssetStore { assetGroup.heightActual = false; } } + + const viewportWidth = this.viewport.width; if (!bucket.isBucketHeightActual) { const unwrappedWidth = (3 / 2) * bucket.bucketCount * THUMBNAIL_HEIGHT * (7 / 10); - const rows = Math.ceil(unwrappedWidth / this.viewport.width); + const rows = Math.ceil(unwrappedWidth / viewportWidth); const height = 51 + rows * THUMBNAIL_HEIGHT; bucket.bucketHeight = height; } + const layoutOptions = { + spacing: 2, + heightTolerance: 0.15, + rowHeight: 235, + rowWidth: Math.floor(viewportWidth), + }; for (const assetGroup of bucket.dateGroups) { if (!assetGroup.heightActual) { const unwrappedWidth = (3 / 2) * assetGroup.assets.length * THUMBNAIL_HEIGHT * (7 / 10); - const rows = Math.ceil(unwrappedWidth / this.viewport.width); + const rows = Math.ceil(unwrappedWidth / viewportWidth); const height = rows * THUMBNAIL_HEIGHT; assetGroup.height = height; } - const layoutResult = createJustifiedLayout( - assetGroup.assets.map((g) => getAssetRatio(g)), - { - ...LAYOUT_OPTIONS, - containerWidth: Math.floor(this.viewport.width), - }, - ); - assetGroup.geometry = { - ...layoutResult, - containerWidth: calculateWidth(layoutResult.boxes), - }; + assetGroup.geometry = getJustifiedLayoutFromAssets(assetGroup.assets, layoutOptions); } } diff --git a/web/src/lib/utils/asset-utils.ts b/web/src/lib/utils/asset-utils.ts index 70f5c5f8f2..d3090bf066 100644 --- a/web/src/lib/utils/asset-utils.ts +++ b/web/src/lib/utils/asset-utils.ts @@ -12,6 +12,7 @@ import { downloadRequest, getKey, withError } from '$lib/utils'; import { createAlbum } from '$lib/utils/album-utils'; import { getByteUnitString } from '$lib/utils/byte-units'; import { getFormatter } from '$lib/utils/i18n'; +import { JustifiedLayout, type LayoutOptions } from '@immich/justified-layout-wasm'; import { addAssetsToAlbum as addAssets, createStack, @@ -587,3 +588,13 @@ export const copyImageToClipboard = async (source: HTMLImageElement | string) => const blob = source instanceof HTMLImageElement ? await imgToBlob(source) : await urlToBlob(source); await navigator.clipboard.write([new ClipboardItem({ [blob.type]: blob })]); }; + +export function getJustifiedLayoutFromAssets(assets: AssetResponseDto[], options: LayoutOptions) { + const aspectRatios = new Float32Array(assets.length); + // eslint-disable-next-line unicorn/no-for-loop + for (let i = 0; i < assets.length; i++) { + const { width, height } = getAssetRatio(assets[i]); + aspectRatios[i] = width / height; + } + return new JustifiedLayout(aspectRatios, options); +} diff --git a/web/src/lib/utils/timeline-util.ts b/web/src/lib/utils/timeline-util.ts index 7e65dcdb99..44baffb135 100644 --- a/web/src/lib/utils/timeline-util.ts +++ b/web/src/lib/utils/timeline-util.ts @@ -1,7 +1,7 @@ import type { AssetBucket } from '$lib/stores/assets.store'; import { locale } from '$lib/stores/preferences.store'; +import { JustifiedLayout } from '@immich/justified-layout-wasm'; import type { AssetResponseDto } from '@immich/sdk'; -import type createJustifiedLayout from 'justified-layout'; import { groupBy, memoize, sortBy } from 'lodash-es'; import { DateTime } from 'luxon'; import { get } from 'svelte/store'; @@ -13,7 +13,7 @@ export type DateGroup = { height: number; heightActual: boolean; intersecting: boolean; - geometry: Geometry; + geometry: JustifiedLayout; bucket: AssetBucket; }; export type ScrubberListener = ( @@ -80,18 +80,12 @@ export function formatGroupTitle(_date: DateTime): string { return date.toLocaleString(groupDateFormat); } -type Geometry = ReturnType & { - containerWidth: number; -}; - -function emptyGeometry() { - return { - containerWidth: 0, - containerHeight: 0, - widowCount: 0, - boxes: [], - }; -} +const emptyGeometry = new JustifiedLayout(Float32Array.from([]), { + rowHeight: 1, + heightTolerance: 0, + rowWidth: 1, + spacing: 0, +}); const formatDateGroupTitle = memoize(formatGroupTitle); @@ -100,6 +94,7 @@ export function splitBucketIntoDateGroups(bucket: AssetBucket, locale: string | fromLocalDateTime(asset.localDateTime).toLocaleString(groupDateFormat, { locale }), ); const sorted = sortBy(grouped, (group) => bucket.assets.indexOf(group[0])); + return sorted.map((group) => { const date = fromLocalDateTime(group[0].localDateTime).startOf('day'); return { @@ -109,31 +104,12 @@ export function splitBucketIntoDateGroups(bucket: AssetBucket, locale: string | height: 0, heightActual: false, intersecting: false, - geometry: emptyGeometry(), + geometry: emptyGeometry, bucket, }; }); } -export type LayoutBox = { - aspectRatio: number; - top: number; - width: number; - height: number; - left: number; - forcedAspectRatio?: boolean; -}; - -export function calculateWidth(boxes: LayoutBox[]): number { - let width = 0; - for (const box of boxes) { - if (box.top < 100) { - width = box.left + box.width; - } - } - return width; -} - export function findTotalOffset(element: HTMLElement, stop: HTMLElement) { let offset = 0; while (element.offsetParent && element !== stop) { diff --git a/web/tsconfig.json b/web/tsconfig.json index 31aef23e31..c7bc16f52b 100644 --- a/web/tsconfig.json +++ b/web/tsconfig.json @@ -4,7 +4,7 @@ "checkJs": true, "esModuleInterop": true, "forceConsistentCasingInFileNames": true, - "module": "es2020", + "module": "es2022", "moduleResolution": "bundler", "resolveJsonModule": true, "skipLibCheck": true, diff --git a/web/vite.config.js b/web/vite.config.js index 5d134beab0..9ab90e4873 100644 --- a/web/vite.config.js +++ b/web/vite.config.js @@ -4,6 +4,7 @@ import { svelteTesting } from '@testing-library/svelte/vite'; import path from 'node:path'; import { visualizer } from 'rollup-plugin-visualizer'; import { defineConfig } from 'vite'; +import wasm from 'vite-plugin-wasm'; const upstream = { target: process.env.IMMICH_SERVER_URL || 'http://immich-server:2283/', @@ -14,6 +15,9 @@ const upstream = { }; export default defineConfig({ + build: { + target: 'es2022', + }, resolve: { alias: { 'xmlhttprequest-ssl': './node_modules/engine.io-client/lib/xmlhttprequest.js', @@ -40,6 +44,7 @@ export default defineConfig({ : undefined, enhancedImages(), svelteTesting(), + wasm(), ], optimizeDeps: { entries: ['src/**/*.{svelte,ts,html}'], @@ -52,5 +57,9 @@ export default defineConfig({ sequence: { hooks: 'list', }, + deps: { + // workaround for https://github.com/vitest-dev/vitest/issues/2150 + inline: ['@immich/justified-layout-wasm'], + }, }, }); From 616905211d8ed7aa166610779d24ff0f3bdfd4d7 Mon Sep 17 00:00:00 2001 From: Mert <101130780+mertalev@users.noreply.github.com> Date: Fri, 21 Feb 2025 15:32:08 +0300 Subject: [PATCH 187/395] fix(server): assets in multiple albums duplicated in map view (#16245) --- server/src/queries/map.repository.sql | 13 +++++++++++-- server/src/repositories/asset.repository.ts | 21 --------------------- server/src/repositories/map.repository.ts | 18 ++++++++++++------ 3 files changed, 23 insertions(+), 29 deletions(-) diff --git a/server/src/queries/map.repository.sql b/server/src/queries/map.repository.sql index 8b508b68ef..b3bb207946 100644 --- a/server/src/queries/map.repository.sql +++ b/server/src/queries/map.repository.sql @@ -13,10 +13,19 @@ from inner join "exif" on "assets"."id" = "exif"."assetId" and "exif"."latitude" is not null and "exif"."longitude" is not null - left join "albums_assets_assets" on "assets"."id" = "albums_assets_assets"."assetsId" where "isVisible" = $1 and "deletedAt" is null - and "ownerId" in ($2) + and ( + "ownerId" in ($2) + or exists ( + select + from + "albums_assets_assets" + where + "assets"."id" = "albums_assets_assets"."assetsId" + and "albums_assets_assets"."albumsId" in ($3) + ) + ) order by "fileCreatedAt" desc diff --git a/server/src/repositories/asset.repository.ts b/server/src/repositories/asset.repository.ts index 123116c62f..e2851ef623 100644 --- a/server/src/repositories/asset.repository.ts +++ b/server/src/repositories/asset.repository.ts @@ -23,7 +23,6 @@ import { withTags, } from 'src/entities/asset.entity'; import { AssetFileType, AssetOrder, AssetStatus, AssetType } from 'src/enum'; -import { MapMarker, MapMarkerSearchOptions } from 'src/repositories/map.repository'; import { AssetSearchOptions, SearchExploreItem, SearchExploreItemSet } from 'src/repositories/search.repository'; import { anyUuid, asUuid, mapUpsertColumns } from 'src/utils/database'; import { Paginated, PaginationOptions, paginationHelper } from 'src/utils/pagination'; @@ -639,26 +638,6 @@ export class AssetRepository { .executeTakeFirst() as Promise; } - private getMapMarkers(ownerIds: string[], options: MapMarkerSearchOptions = {}): Promise { - const { isArchived, isFavorite, fileCreatedAfter, fileCreatedBefore } = options; - - return this.db - .selectFrom('assets') - .leftJoin('exif', 'assets.id', 'exif.assetId') - .select(['id', 'latitude as lat', 'longitude as lon', 'city', 'state', 'country']) - .where('ownerId', '=', anyUuid(ownerIds)) - .where('latitude', 'is not', null) - .where('longitude', 'is not', null) - .where('isVisible', '=', true) - .where('deletedAt', 'is', null) - .$if(!!isArchived, (qb) => qb.where('isArchived', '=', isArchived!)) - .$if(!!isFavorite, (qb) => qb.where('isFavorite', '=', isFavorite!)) - .$if(!!fileCreatedAfter, (qb) => qb.where('fileCreatedAt', '>=', fileCreatedAfter!)) - .$if(!!fileCreatedBefore, (qb) => qb.where('fileCreatedAt', '<=', fileCreatedBefore!)) - .orderBy('fileCreatedAt', 'desc') - .execute() as Promise; - } - getStatistics(ownerId: string, { isArchived, isFavorite, isTrashed }: AssetStatsOptions): Promise { return this.db .selectFrom('assets') diff --git a/server/src/repositories/map.repository.ts b/server/src/repositories/map.repository.ts index d813ff29f2..965e7ffd13 100644 --- a/server/src/repositories/map.repository.ts +++ b/server/src/repositories/map.repository.ts @@ -76,7 +76,7 @@ export class MapRepository { this.logger.log('Geodata import completed'); } - @GenerateSql({ params: [[DummyValue.UUID], []] }) + @GenerateSql({ params: [[DummyValue.UUID], [DummyValue.UUID]] }) getMapMarkers(ownerIds: string[], albumIds: string[], options: MapMarkerSearchOptions = {}) { const { isArchived, isFavorite, fileCreatedAfter, fileCreatedBefore } = options; @@ -89,25 +89,31 @@ export class MapRepository { .on('exif.longitude', 'is not', null), ) .select(['id', 'exif.latitude as lat', 'exif.longitude as lon', 'exif.city', 'exif.state', 'exif.country']) - .leftJoin('albums_assets_assets', (join) => join.onRef('assets.id', '=', 'albums_assets_assets.assetsId')) .where('isVisible', '=', true) .$if(isArchived !== undefined, (q) => q.where('isArchived', '=', isArchived!)) .$if(isFavorite !== undefined, (q) => q.where('isFavorite', '=', isFavorite!)) .$if(fileCreatedAfter !== undefined, (q) => q.where('fileCreatedAt', '>=', fileCreatedAfter!)) .$if(fileCreatedBefore !== undefined, (q) => q.where('fileCreatedAt', '<=', fileCreatedBefore!)) .where('deletedAt', 'is', null) - .where((builder) => { + .where((eb) => { const expression: Expression[] = []; if (ownerIds.length > 0) { - expression.push(builder('ownerId', 'in', ownerIds)); + expression.push(eb('ownerId', 'in', ownerIds)); } if (albumIds.length > 0) { - expression.push(builder('albums_assets_assets.albumsId', 'in', albumIds)); + expression.push( + eb.exists((eb) => + eb + .selectFrom('albums_assets_assets') + .whereRef('assets.id', '=', 'albums_assets_assets.assetsId') + .where('albums_assets_assets.albumsId', 'in', albumIds), + ), + ); } - return builder.or(expression); + return eb.or(expression); }) .orderBy('fileCreatedAt', 'desc') .execute() as Promise; From 5acf6868b7f2d291305893131975a07405d8b184 Mon Sep 17 00:00:00 2001 From: Alex Date: Fri, 21 Feb 2025 09:01:46 -0600 Subject: [PATCH 188/395] refactor(mobile): render list (#16239) * refactor(mobile): trash provider * refactor(mobile): trash provider * pr feedback * archive timeline * favorite * album * trash timeline * all videos timeline * refactor * refactor: home timeline and partner timeline * update analysis option --- mobile/analysis_options.yaml | 4 +- mobile/lib/interfaces/album.interface.dart | 3 - mobile/lib/interfaces/asset.interface.dart | 3 - mobile/lib/interfaces/timeline.interface.dart | 16 +++ mobile/lib/pages/album/album_viewer.dart | 3 +- mobile/lib/pages/library/archive.page.dart | 8 +- mobile/lib/pages/library/favorite.page.dart | 4 +- .../library/partner/partner_detail.page.dart | 3 +- mobile/lib/pages/library/trash.page.dart | 7 +- mobile/lib/pages/photos/photos.page.dart | 5 +- mobile/lib/pages/search/all_videos.page.dart | 4 +- .../lib/providers/album/album.provider.dart | 12 -- mobile/lib/providers/archive.provider.dart | 22 ---- mobile/lib/providers/asset.provider.dart | 44 ------- mobile/lib/providers/favorite.provider.dart | 22 ---- .../search/all_video_assets.provider.dart | 17 --- mobile/lib/providers/timeline.provider.dart | 55 ++++++++ mobile/lib/providers/trash.provider.dart | 11 -- mobile/lib/repositories/album.repository.dart | 20 --- mobile/lib/repositories/asset.repository.dart | 15 --- .../lib/repositories/timeline.repository.dart | 120 ++++++++++++++++++ mobile/lib/services/album.service.dart | 5 - mobile/lib/services/timeline.service.dart | 70 ++++++++++ mobile/lib/services/trash.service.dart | 7 +- 24 files changed, 284 insertions(+), 196 deletions(-) create mode 100644 mobile/lib/interfaces/timeline.interface.dart delete mode 100644 mobile/lib/providers/archive.provider.dart delete mode 100644 mobile/lib/providers/favorite.provider.dart delete mode 100644 mobile/lib/providers/search/all_video_assets.provider.dart create mode 100644 mobile/lib/providers/timeline.provider.dart create mode 100644 mobile/lib/repositories/timeline.repository.dart create mode 100644 mobile/lib/services/timeline.service.dart diff --git a/mobile/analysis_options.yaml b/mobile/analysis_options.yaml index 8ae9edef0d..52d0d11162 100644 --- a/mobile/analysis_options.yaml +++ b/mobile/analysis_options.yaml @@ -65,7 +65,7 @@ custom_lint: allowed: # required / wanted - lib/entities/*.entity.dart - - lib/repositories/{album,asset,backup,database,etag,exif_info,user}.repository.dart + - lib/repositories/{album,asset,backup,database,etag,exif_info,user,timeline}.repository.dart - lib/infrastructure/entities/*.entity.dart - lib/infrastructure/repositories/{store,db}.repository.dart - lib/providers/infrastructure/db.provider.dart @@ -79,7 +79,7 @@ custom_lint: - lib/widgets/asset_grid/asset_grid_data_structure.dart - test/**.dart # refactor the remaining providers - - lib/providers/{archive,asset,authentication,db,favorite,partner,user}.provider.dart + - lib/providers/{asset,authentication,db,partner,user}.provider.dart - lib/providers/{asset_viewer/render_list,backup/backup,search/all_motion_photos,search/recently_added_asset}.provider.dart - import_rule_openapi: diff --git a/mobile/lib/interfaces/album.interface.dart b/mobile/lib/interfaces/album.interface.dart index 2d8c460b67..3a83a8feb7 100644 --- a/mobile/lib/interfaces/album.interface.dart +++ b/mobile/lib/interfaces/album.interface.dart @@ -3,7 +3,6 @@ import 'package:immich_mobile/entities/asset.entity.dart'; import 'package:immich_mobile/entities/user.entity.dart'; import 'package:immich_mobile/interfaces/database.interface.dart'; import 'package:immich_mobile/models/albums/album_search.model.dart'; -import 'package:immich_mobile/widgets/asset_grid/asset_grid_data_structure.dart'; abstract interface class IAlbumRepository implements IDatabaseRepository { Future create(Album album); @@ -50,8 +49,6 @@ abstract interface class IAlbumRepository implements IDatabaseRepository { Stream watchAlbum(int id); - Stream getRenderListStream(Album album); - Future clearTable(); } diff --git a/mobile/lib/interfaces/asset.interface.dart b/mobile/lib/interfaces/asset.interface.dart index b56c99a711..0f1336c1db 100644 --- a/mobile/lib/interfaces/asset.interface.dart +++ b/mobile/lib/interfaces/asset.interface.dart @@ -2,7 +2,6 @@ import 'package:immich_mobile/entities/album.entity.dart'; import 'package:immich_mobile/entities/asset.entity.dart'; import 'package:immich_mobile/entities/device_asset.entity.dart'; import 'package:immich_mobile/interfaces/database.interface.dart'; -import 'package:immich_mobile/widgets/asset_grid/asset_grid_data_structure.dart'; abstract interface class IAssetRepository implements IDatabaseRepository { Future getByRemoteId(String id); @@ -66,8 +65,6 @@ abstract interface class IAssetRepository implements IDatabaseRepository { Stream watchAsset(int id, {bool fireImmediately = false}); Future> getTrashAssets(int userId); - - Stream getTrashRenderListStream(int userId); } enum AssetSort { checksum, ownerIdChecksum } diff --git a/mobile/lib/interfaces/timeline.interface.dart b/mobile/lib/interfaces/timeline.interface.dart new file mode 100644 index 0000000000..2d46dbf688 --- /dev/null +++ b/mobile/lib/interfaces/timeline.interface.dart @@ -0,0 +1,16 @@ +import 'package:immich_mobile/entities/album.entity.dart'; +import 'package:immich_mobile/widgets/asset_grid/asset_grid_data_structure.dart'; + +abstract class ITimelineRepository { + Stream watchArchiveTimeline(int userId); + Stream watchFavoriteTimeline(int userId); + Stream watchTrashTimeline(int userId); + Stream watchAlbumTimeline(Album album); + Stream watchAllVideosTimeline(); + + Stream watchHomeTimeline(int userId, GroupAssetsBy groupAssetsBy); + Stream watchMultiUsersTimeline( + List userIds, + GroupAssetsBy groupAssetsBy, + ); +} diff --git a/mobile/lib/pages/album/album_viewer.dart b/mobile/lib/pages/album/album_viewer.dart index 19782c4e30..a15c21659c 100644 --- a/mobile/lib/pages/album/album_viewer.dart +++ b/mobile/lib/pages/album/album_viewer.dart @@ -14,6 +14,7 @@ import 'package:immich_mobile/pages/album/album_shared_user_icons.dart'; import 'package:immich_mobile/pages/album/album_title.dart'; import 'package:immich_mobile/providers/album/album.provider.dart'; import 'package:immich_mobile/providers/album/current_album.provider.dart'; +import 'package:immich_mobile/providers/timeline.provider.dart'; import 'package:immich_mobile/utils/immich_loading_overlay.dart'; import 'package:immich_mobile/providers/multiselect.provider.dart'; import 'package:immich_mobile/providers/auth.provider.dart'; @@ -104,7 +105,7 @@ class AlbumViewer extends HookConsumerWidget { children: [ MultiselectGrid( key: const ValueKey("albumViewerMultiselectGrid"), - renderListProvider: albumRenderlistProvider(album.id), + renderListProvider: albumTimelineProvider(album.id), topWidget: Column( mainAxisAlignment: MainAxisAlignment.end, crossAxisAlignment: CrossAxisAlignment.start, diff --git a/mobile/lib/pages/library/archive.page.dart b/mobile/lib/pages/library/archive.page.dart index 0082142113..a13adc21f2 100644 --- a/mobile/lib/pages/library/archive.page.dart +++ b/mobile/lib/pages/library/archive.page.dart @@ -2,8 +2,8 @@ import 'package:auto_route/auto_route.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:immich_mobile/providers/archive.provider.dart'; import 'package:immich_mobile/providers/multiselect.provider.dart'; +import 'package:immich_mobile/providers/timeline.provider.dart'; import 'package:immich_mobile/widgets/asset_grid/multiselect_grid.dart'; @RoutePage() @@ -13,8 +13,8 @@ class ArchivePage extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { AppBar buildAppBar() { - final archivedAssets = ref.watch(archiveProvider); - final count = archivedAssets.value?.totalAssets.toString() ?? "?"; + final archiveRenderList = ref.watch(archiveTimelineProvider); + final count = archiveRenderList.value?.totalAssets.toString() ?? "?"; return AppBar( leading: IconButton( onPressed: () => context.maybePop(), @@ -31,7 +31,7 @@ class ArchivePage extends HookConsumerWidget { return Scaffold( appBar: ref.watch(multiselectProvider) ? null : buildAppBar(), body: MultiselectGrid( - renderListProvider: archiveProvider, + renderListProvider: archiveTimelineProvider, unarchive: true, archiveEnabled: true, deleteEnabled: true, diff --git a/mobile/lib/pages/library/favorite.page.dart b/mobile/lib/pages/library/favorite.page.dart index cc422f88c7..55e3937166 100644 --- a/mobile/lib/pages/library/favorite.page.dart +++ b/mobile/lib/pages/library/favorite.page.dart @@ -2,8 +2,8 @@ import 'package:auto_route/auto_route.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:immich_mobile/providers/favorite.provider.dart'; import 'package:immich_mobile/providers/multiselect.provider.dart'; +import 'package:immich_mobile/providers/timeline.provider.dart'; import 'package:immich_mobile/widgets/asset_grid/multiselect_grid.dart'; @RoutePage() @@ -29,7 +29,7 @@ class FavoritesPage extends HookConsumerWidget { return Scaffold( appBar: ref.watch(multiselectProvider) ? null : buildAppBar(), body: MultiselectGrid( - renderListProvider: favoriteAssetsProvider, + renderListProvider: favoriteTimelineProvider, favoriteEnabled: true, editEnabled: true, unfavorite: true, diff --git a/mobile/lib/pages/library/partner/partner_detail.page.dart b/mobile/lib/pages/library/partner/partner_detail.page.dart index 0874aacfa7..f018726fe2 100644 --- a/mobile/lib/pages/library/partner/partner_detail.page.dart +++ b/mobile/lib/pages/library/partner/partner_detail.page.dart @@ -7,6 +7,7 @@ import 'package:immich_mobile/providers/multiselect.provider.dart'; import 'package:immich_mobile/providers/partner.provider.dart'; import 'package:immich_mobile/entities/user.entity.dart'; import 'package:immich_mobile/providers/asset.provider.dart'; +import 'package:immich_mobile/providers/timeline.provider.dart'; import 'package:immich_mobile/widgets/asset_grid/multiselect_grid.dart'; import 'package:immich_mobile/widgets/common/immich_toast.dart'; @@ -110,7 +111,7 @@ class PartnerDetailPage extends HookConsumerWidget { ), ), ), - renderListProvider: assetsProvider(partner.isarId), + renderListProvider: singleUserTimelineProvider(partner.isarId), onRefresh: () => ref.read(assetProvider.notifier).getAllAsset(), deleteEnabled: false, favoriteEnabled: false, diff --git a/mobile/lib/pages/library/trash.page.dart b/mobile/lib/pages/library/trash.page.dart index 7322b00579..8a969c8e9a 100644 --- a/mobile/lib/pages/library/trash.page.dart +++ b/mobile/lib/pages/library/trash.page.dart @@ -7,6 +7,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/extensions/asyncvalue_extensions.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/providers/asset.provider.dart'; +import 'package:immich_mobile/providers/timeline.provider.dart'; import 'package:immich_mobile/widgets/asset_grid/immich_asset_grid.dart'; import 'package:immich_mobile/widgets/asset_grid/delete_dialog.dart'; import 'package:immich_mobile/providers/trash.provider.dart'; @@ -22,7 +23,7 @@ class TrashPage extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final trashedAssets = ref.watch(trashedAssetsProvider); + final trashRenderList = ref.watch(trashTimelineProvider); final trashDays = ref.watch(serverInfoProvider.select((v) => v.serverConfig.trashDays)); final selectionEnabledHook = useState(false); @@ -234,11 +235,11 @@ class TrashPage extends HookConsumerWidget { } return Scaffold( - appBar: trashedAssets.maybeWhen( + appBar: trashRenderList.maybeWhen( orElse: () => buildAppBar("?"), data: (data) => buildAppBar(data.totalAssets.toString()), ), - body: trashedAssets.widgetWhen( + body: trashRenderList.widgetWhen( onData: (data) => data.isEmpty ? Center( child: Text('trash_page_no_assets'.tr()), diff --git a/mobile/lib/pages/photos/photos.page.dart b/mobile/lib/pages/photos/photos.page.dart index 845de40ee7..7910d45e13 100644 --- a/mobile/lib/pages/photos/photos.page.dart +++ b/mobile/lib/pages/photos/photos.page.dart @@ -8,6 +8,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/providers/album/album.provider.dart'; import 'package:immich_mobile/providers/multiselect.provider.dart'; +import 'package:immich_mobile/providers/timeline.provider.dart'; import 'package:immich_mobile/widgets/memories/memory_lane.dart'; import 'package:immich_mobile/providers/asset.provider.dart'; import 'package:immich_mobile/providers/server_info.provider.dart'; @@ -108,8 +109,8 @@ class PhotosPage extends HookConsumerWidget { ? const MemoryLane() : const SizedBox(), renderListProvider: timelineUsers.length > 1 - ? multiUserAssetsProvider(timelineUsers) - : assetsProvider(currentUser?.isarId), + ? multiUsersTimelineProvider(timelineUsers) + : singleUserTimelineProvider(currentUser!.isarId), buildLoadingIndicator: buildLoadingIndicator, onRefresh: refreshAssets, stackEnabled: true, diff --git a/mobile/lib/pages/search/all_videos.page.dart b/mobile/lib/pages/search/all_videos.page.dart index e96e060255..b7997313f3 100644 --- a/mobile/lib/pages/search/all_videos.page.dart +++ b/mobile/lib/pages/search/all_videos.page.dart @@ -2,7 +2,7 @@ import 'package:auto_route/auto_route.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:immich_mobile/providers/search/all_video_assets.provider.dart'; +import 'package:immich_mobile/providers/timeline.provider.dart'; import 'package:immich_mobile/widgets/asset_grid/multiselect_grid.dart'; @RoutePage() @@ -19,7 +19,7 @@ class AllVideosPage extends HookConsumerWidget { icon: const Icon(Icons.arrow_back_ios_rounded), ), ), - body: MultiselectGrid(renderListProvider: allVideoAssetsProvider), + body: MultiselectGrid(renderListProvider: allVideosTimelineProvider), ); } } diff --git a/mobile/lib/providers/album/album.provider.dart b/mobile/lib/providers/album/album.provider.dart index d9dd5bcc96..a2d7db68ec 100644 --- a/mobile/lib/providers/album/album.provider.dart +++ b/mobile/lib/providers/album/album.provider.dart @@ -5,7 +5,6 @@ import 'package:immich_mobile/constants/enums.dart'; import 'package:immich_mobile/entities/user.entity.dart'; import 'package:immich_mobile/models/albums/album_search.model.dart'; import 'package:immich_mobile/services/album.service.dart'; -import 'package:immich_mobile/widgets/asset_grid/asset_grid_data_structure.dart'; import 'package:immich_mobile/entities/asset.entity.dart'; import 'package:immich_mobile/entities/album.entity.dart'; @@ -152,17 +151,6 @@ final albumWatcher = } }); -final albumRenderlistProvider = - StreamProvider.autoDispose.family((ref, id) { - final album = ref.watch(albumWatcher(id)).value; - - if (album != null) { - return ref.watch(albumServiceProvider).getRenderListGenerator(album); - } - - return const Stream.empty(); -}); - class LocalAlbumsNotifier extends StateNotifier> { LocalAlbumsNotifier(this.albumService) : super([]) { albumService.getAllLocalAlbums().then((value) { diff --git a/mobile/lib/providers/archive.provider.dart b/mobile/lib/providers/archive.provider.dart deleted file mode 100644 index ba4937bd82..0000000000 --- a/mobile/lib/providers/archive.provider.dart +++ /dev/null @@ -1,22 +0,0 @@ -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:immich_mobile/widgets/asset_grid/asset_grid_data_structure.dart'; -import 'package:immich_mobile/entities/asset.entity.dart'; -import 'package:immich_mobile/providers/db.provider.dart'; -import 'package:immich_mobile/providers/user.provider.dart'; -import 'package:immich_mobile/utils/renderlist_generator.dart'; -import 'package:isar/isar.dart'; - -final archiveProvider = StreamProvider((ref) { - final user = ref.watch(currentUserProvider); - if (user == null) return const Stream.empty(); - final query = ref - .watch(dbProvider) - .assets - .where() - .ownerIdEqualToAnyChecksum(user.isarId) - .filter() - .isArchivedEqualTo(true) - .isTrashedEqualTo(false) - .sortByFileCreatedAtDesc(); - return renderListGenerator(query, ref); -}); diff --git a/mobile/lib/providers/asset.provider.dart b/mobile/lib/providers/asset.provider.dart index 1c12eda6b7..43020e7687 100644 --- a/mobile/lib/providers/asset.provider.dart +++ b/mobile/lib/providers/asset.provider.dart @@ -4,7 +4,6 @@ import 'package:immich_mobile/domain/models/store.model.dart'; import 'package:immich_mobile/entities/asset.entity.dart'; import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/providers/db.provider.dart'; -import 'package:immich_mobile/providers/locale_provider.dart'; import 'package:immich_mobile/providers/memory.provider.dart'; import 'package:immich_mobile/providers/user.provider.dart'; import 'package:immich_mobile/services/album.service.dart'; @@ -13,8 +12,6 @@ import 'package:immich_mobile/services/etag.service.dart'; import 'package:immich_mobile/services/exif.service.dart'; import 'package:immich_mobile/services/sync.service.dart'; import 'package:immich_mobile/services/user.service.dart'; -import 'package:immich_mobile/utils/renderlist_generator.dart'; -import 'package:immich_mobile/widgets/asset_grid/asset_grid_data_structure.dart'; import 'package:isar/isar.dart'; import 'package:logging/logging.dart'; @@ -188,47 +185,6 @@ final assetWatcher = return assetService.watchAsset(asset.id, fireImmediately: true); }); -final assetsProvider = StreamProvider.family( - (ref, userId) { - if (userId == null) return const Stream.empty(); - ref.watch(localeProvider); - - final query = ref - .watch(dbProvider) - .assets - .where() - .ownerIdEqualToAnyChecksum(userId) - .filter() - .isArchivedEqualTo(false) - .isTrashedEqualTo(false) - .stackPrimaryAssetIdIsNull() - .sortByFileCreatedAtDesc(); - - return renderListGenerator(query, ref); - }, - dependencies: [localeProvider], -); - -final multiUserAssetsProvider = StreamProvider.family>( - (ref, userIds) { - if (userIds.isEmpty) return const Stream.empty(); - ref.watch(localeProvider); - final query = ref - .watch(dbProvider) - .assets - .where() - .anyOf(userIds, (q, u) => q.ownerIdEqualToAnyChecksum(u)) - .filter() - .isArchivedEqualTo(false) - .isTrashedEqualTo(false) - .stackPrimaryAssetIdIsNull() - .sortByFileCreatedAtDesc(); - - return renderListGenerator(query, ref); - }, - dependencies: [localeProvider], -); - QueryBuilder? getRemoteAssetQuery(WidgetRef ref) { final userId = ref.watch(currentUserProvider)?.isarId; if (userId == null) { diff --git a/mobile/lib/providers/favorite.provider.dart b/mobile/lib/providers/favorite.provider.dart deleted file mode 100644 index 340fd01080..0000000000 --- a/mobile/lib/providers/favorite.provider.dart +++ /dev/null @@ -1,22 +0,0 @@ -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:immich_mobile/widgets/asset_grid/asset_grid_data_structure.dart'; -import 'package:immich_mobile/entities/asset.entity.dart'; -import 'package:immich_mobile/providers/db.provider.dart'; -import 'package:immich_mobile/providers/user.provider.dart'; -import 'package:immich_mobile/utils/renderlist_generator.dart'; -import 'package:isar/isar.dart'; - -final favoriteAssetsProvider = StreamProvider((ref) { - final user = ref.watch(currentUserProvider); - if (user == null) return const Stream.empty(); - final query = ref - .watch(dbProvider) - .assets - .where() - .ownerIdEqualToAnyChecksum(user.isarId) - .filter() - .isFavoriteEqualTo(true) - .isTrashedEqualTo(false) - .sortByFileCreatedAtDesc(); - return renderListGenerator(query, ref); -}); diff --git a/mobile/lib/providers/search/all_video_assets.provider.dart b/mobile/lib/providers/search/all_video_assets.provider.dart deleted file mode 100644 index b0daf6b984..0000000000 --- a/mobile/lib/providers/search/all_video_assets.provider.dart +++ /dev/null @@ -1,17 +0,0 @@ -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:immich_mobile/widgets/asset_grid/asset_grid_data_structure.dart'; -import 'package:immich_mobile/entities/asset.entity.dart'; -import 'package:immich_mobile/providers/db.provider.dart'; -import 'package:immich_mobile/utils/renderlist_generator.dart'; - -final allVideoAssetsProvider = StreamProvider((ref) { - final query = ref - .watch(dbProvider) - .assets - .filter() - .isArchivedEqualTo(false) - .isTrashedEqualTo(false) - .typeEqualTo(AssetType.video) - .sortByFileCreatedAtDesc(); - return renderListGenerator(query, ref); -}); diff --git a/mobile/lib/providers/timeline.provider.dart b/mobile/lib/providers/timeline.provider.dart new file mode 100644 index 0000000000..8937ec0da8 --- /dev/null +++ b/mobile/lib/providers/timeline.provider.dart @@ -0,0 +1,55 @@ +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/providers/album/album.provider.dart'; +import 'package:immich_mobile/providers/locale_provider.dart'; +import 'package:immich_mobile/services/timeline.service.dart'; +import 'package:immich_mobile/widgets/asset_grid/asset_grid_data_structure.dart'; + +final singleUserTimelineProvider = StreamProvider.family( + (ref, userId) { + ref.watch(localeProvider); + final timelineService = ref.watch(timelineServiceProvider); + return timelineService.watchHomeTimeline(userId); + }, + dependencies: [localeProvider], +); + +final multiUsersTimelineProvider = StreamProvider.family>( + (ref, userIds) { + ref.watch(localeProvider); + final timelineService = ref.watch(timelineServiceProvider); + return timelineService.watchMultiUsersTimeline(userIds); + }, + dependencies: [localeProvider], +); + +final albumTimelineProvider = + StreamProvider.autoDispose.family((ref, id) { + final album = ref.watch(albumWatcher(id)).value; + final timelineService = ref.watch(timelineServiceProvider); + + if (album != null) { + return timelineService.watchAlbumTimeline(album); + } + + return const Stream.empty(); +}); + +final archiveTimelineProvider = StreamProvider((ref) { + final timelineService = ref.watch(timelineServiceProvider); + return timelineService.watchArchiveTimeline(); +}); + +final favoriteTimelineProvider = StreamProvider((ref) { + final timelineService = ref.watch(timelineServiceProvider); + return timelineService.watchFavoriteTimeline(); +}); + +final trashTimelineProvider = StreamProvider((ref) { + final timelineService = ref.watch(timelineServiceProvider); + return timelineService.watchTrashTimeline(); +}); + +final allVideosTimelineProvider = StreamProvider((ref) { + final timelineService = ref.watch(timelineServiceProvider); + return timelineService.watchAllVideosTimeline(); +}); diff --git a/mobile/lib/providers/trash.provider.dart b/mobile/lib/providers/trash.provider.dart index 0f7ae780a0..c78cccff8a 100644 --- a/mobile/lib/providers/trash.provider.dart +++ b/mobile/lib/providers/trash.provider.dart @@ -1,8 +1,6 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:immich_mobile/widgets/asset_grid/asset_grid_data_structure.dart'; import 'package:immich_mobile/services/trash.service.dart'; import 'package:immich_mobile/entities/asset.entity.dart'; -import 'package:immich_mobile/providers/user.provider.dart'; import 'package:logging/logging.dart'; class TrashNotifier extends StateNotifier { @@ -49,12 +47,3 @@ final trashProvider = StateNotifierProvider((ref) { ref.watch(trashServiceProvider), ); }); - -final trashedAssetsProvider = StreamProvider((ref) { - final user = ref.read(currentUserProvider); - if (user == null) { - return const Stream.empty(); - } - - return ref.watch(trashServiceProvider).getRenderListGenerator(user.isarId); -}); diff --git a/mobile/lib/repositories/album.repository.dart b/mobile/lib/repositories/album.repository.dart index 0f1cf64865..1d2df89579 100644 --- a/mobile/lib/repositories/album.repository.dart +++ b/mobile/lib/repositories/album.repository.dart @@ -1,5 +1,4 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:immich_mobile/constants/enums.dart'; import 'package:immich_mobile/domain/models/store.model.dart'; import 'package:immich_mobile/entities/album.entity.dart'; import 'package:immich_mobile/entities/asset.entity.dart'; @@ -9,7 +8,6 @@ import 'package:immich_mobile/interfaces/album.interface.dart'; import 'package:immich_mobile/models/albums/album_search.model.dart'; import 'package:immich_mobile/providers/db.provider.dart'; import 'package:immich_mobile/repositories/database.repository.dart'; -import 'package:immich_mobile/widgets/asset_grid/asset_grid_data_structure.dart'; import 'package:isar/isar.dart'; final albumRepositoryProvider = @@ -177,22 +175,4 @@ class AlbumRepository extends DatabaseRepository implements IAlbumRepository { Stream watchAlbum(int id) { return db.albums.watchObject(id, fireImmediately: true); } - - @override - Stream getRenderListStream(Album album) async* { - final query = album.assets.filter().isTrashedEqualTo(false); - final withSortedOption = switch (album.sortOrder) { - SortOrder.asc => query.sortByFileCreatedAt(), - SortOrder.desc => query.sortByFileCreatedAtDesc(), - }; - - yield await RenderList.fromQuery( - withSortedOption, - GroupAssetsBy.none, - ); - - await for (final _ in query.watchLazy()) { - yield await RenderList.fromQuery(withSortedOption, GroupAssetsBy.none); - } - } } diff --git a/mobile/lib/repositories/asset.repository.dart b/mobile/lib/repositories/asset.repository.dart index 55add8cf96..1f419fcb76 100644 --- a/mobile/lib/repositories/asset.repository.dart +++ b/mobile/lib/repositories/asset.repository.dart @@ -11,7 +11,6 @@ import 'package:immich_mobile/entities/ios_device_asset.entity.dart'; import 'package:immich_mobile/interfaces/asset.interface.dart'; import 'package:immich_mobile/providers/db.provider.dart'; import 'package:immich_mobile/repositories/database.repository.dart'; -import 'package:immich_mobile/widgets/asset_grid/asset_grid_data_structure.dart'; import 'package:isar/isar.dart'; final assetRepositoryProvider = @@ -234,20 +233,6 @@ class AssetRepository extends DatabaseRepository implements IAssetRepository { .isTrashedEqualTo(true) .findAll(); } - - @override - Stream getTrashRenderListStream(int userId) async* { - final query = db.assets - .filter() - .ownerIdEqualTo(userId) - .isTrashedEqualTo(true) - .sortByFileCreatedAtDesc(); - - yield await RenderList.fromQuery(query, GroupAssetsBy.none); - await for (final _ in query.watchLazy()) { - yield await RenderList.fromQuery(query, GroupAssetsBy.none); - } - } } Future> _getMatchesImpl( diff --git a/mobile/lib/repositories/timeline.repository.dart b/mobile/lib/repositories/timeline.repository.dart new file mode 100644 index 0000000000..8203e5c6e9 --- /dev/null +++ b/mobile/lib/repositories/timeline.repository.dart @@ -0,0 +1,120 @@ +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/constants/enums.dart'; +import 'package:immich_mobile/entities/album.entity.dart'; +import 'package:immich_mobile/entities/asset.entity.dart'; +import 'package:immich_mobile/interfaces/timeline.interface.dart'; +import 'package:immich_mobile/providers/db.provider.dart'; +import 'package:immich_mobile/repositories/database.repository.dart'; +import 'package:immich_mobile/widgets/asset_grid/asset_grid_data_structure.dart'; +import 'package:isar/isar.dart'; + +final timelineRepositoryProvider = + Provider((ref) => TimelineRepository(ref.watch(dbProvider))); + +class TimelineRepository extends DatabaseRepository + implements ITimelineRepository { + TimelineRepository(super.db); + + @override + Stream watchArchiveTimeline(int userId) { + final query = db.assets + .where() + .ownerIdEqualToAnyChecksum(userId) + .filter() + .isArchivedEqualTo(true) + .isTrashedEqualTo(false) + .sortByFileCreatedAtDesc(); + + return _watchRenderList(query, GroupAssetsBy.none); + } + + @override + Stream watchFavoriteTimeline(int userId) { + final query = db.assets + .where() + .ownerIdEqualToAnyChecksum(userId) + .filter() + .isFavoriteEqualTo(true) + .isTrashedEqualTo(false) + .sortByFileCreatedAtDesc(); + + return _watchRenderList(query, GroupAssetsBy.none); + } + + @override + Stream watchAlbumTimeline(Album album) { + final query = album.assets.filter().isTrashedEqualTo(false); + final withSortedOption = switch (album.sortOrder) { + SortOrder.asc => query.sortByFileCreatedAt(), + SortOrder.desc => query.sortByFileCreatedAtDesc(), + }; + + return _watchRenderList(withSortedOption, GroupAssetsBy.none); + } + + @override + Stream watchTrashTimeline(int userId) { + final query = db.assets + .filter() + .ownerIdEqualTo(userId) + .isTrashedEqualTo(true) + .sortByFileCreatedAtDesc(); + + return _watchRenderList(query, GroupAssetsBy.none); + } + + @override + Stream watchAllVideosTimeline() { + final query = db.assets + .filter() + .isArchivedEqualTo(false) + .isTrashedEqualTo(false) + .typeEqualTo(AssetType.video) + .sortByFileCreatedAtDesc(); + + return _watchRenderList(query, GroupAssetsBy.none); + } + + @override + Stream watchHomeTimeline( + int userId, + GroupAssetsBy groupAssetByOption, + ) { + final query = db.assets + .where() + .ownerIdEqualToAnyChecksum(userId) + .filter() + .isArchivedEqualTo(false) + .isTrashedEqualTo(false) + .stackPrimaryAssetIdIsNull() + .sortByFileCreatedAtDesc(); + + return _watchRenderList(query, groupAssetByOption); + } + + @override + Stream watchMultiUsersTimeline( + List userIds, + GroupAssetsBy groupAssetByOption, + ) { + final query = db.assets + .where() + .anyOf(userIds, (qb, userId) => qb.ownerIdEqualToAnyChecksum(userId)) + .filter() + .isArchivedEqualTo(false) + .isTrashedEqualTo(false) + .stackPrimaryAssetIdIsNull() + .sortByFileCreatedAtDesc(); + return _watchRenderList(query, groupAssetByOption); + } + + Stream _watchRenderList( + QueryBuilder query, + GroupAssetsBy groupAssetsBy, + ) async* { + yield await RenderList.fromQuery(query, groupAssetsBy); + await for (final _ in query.watchLazy()) { + yield await RenderList.fromQuery(query, groupAssetsBy); + } + } +} diff --git a/mobile/lib/services/album.service.dart b/mobile/lib/services/album.service.dart index e189dbe245..142ac48193 100644 --- a/mobile/lib/services/album.service.dart +++ b/mobile/lib/services/album.service.dart @@ -27,7 +27,6 @@ import 'package:immich_mobile/repositories/backup.repository.dart'; import 'package:immich_mobile/services/entity.service.dart'; import 'package:immich_mobile/services/sync.service.dart'; import 'package:immich_mobile/services/user.service.dart'; -import 'package:immich_mobile/widgets/asset_grid/asset_grid_data_structure.dart'; import 'package:logging/logging.dart'; final albumServiceProvider = Provider( @@ -469,10 +468,6 @@ class AlbumService { return _albumRepository.watchAlbum(id); } - Stream getRenderListGenerator(Album album) { - return _albumRepository.getRenderListStream(album); - } - Future> search( String searchTerm, QuickFilterMode filterMode, diff --git a/mobile/lib/services/timeline.service.dart b/mobile/lib/services/timeline.service.dart new file mode 100644 index 0000000000..e435ba72c9 --- /dev/null +++ b/mobile/lib/services/timeline.service.dart @@ -0,0 +1,70 @@ +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/entities/album.entity.dart'; +import 'package:immich_mobile/interfaces/timeline.interface.dart'; +import 'package:immich_mobile/interfaces/user.interface.dart'; +import 'package:immich_mobile/providers/app_settings.provider.dart'; +import 'package:immich_mobile/repositories/timeline.repository.dart'; +import 'package:immich_mobile/repositories/user.repository.dart'; +import 'package:immich_mobile/services/app_settings.service.dart'; +import 'package:immich_mobile/widgets/asset_grid/asset_grid_data_structure.dart'; + +final timelineServiceProvider = Provider((ref) { + return TimelineService( + ref.watch(timelineRepositoryProvider), + ref.watch(userRepositoryProvider), + ref.watch(appSettingsServiceProvider), + ); +}); + +class TimelineService { + final ITimelineRepository _timelineRepository; + final IUserRepository _userRepository; + final AppSettingsService _appSettingsService; + TimelineService( + this._timelineRepository, + this._userRepository, + this._appSettingsService, + ); + + Stream watchHomeTimeline(int userId) { + return _timelineRepository.watchHomeTimeline(userId, _getGroupByOption()); + } + + Stream watchMultiUsersTimeline(List userIds) { + return _timelineRepository.watchMultiUsersTimeline( + userIds, + _getGroupByOption(), + ); + } + + Stream watchArchiveTimeline() async* { + final user = await _userRepository.me(); + + yield* _timelineRepository.watchArchiveTimeline(user.isarId); + } + + Stream watchFavoriteTimeline() async* { + final user = await _userRepository.me(); + + yield* _timelineRepository.watchFavoriteTimeline(user.isarId); + } + + Stream watchAlbumTimeline(Album album) async* { + yield* _timelineRepository.watchAlbumTimeline(album); + } + + Stream watchTrashTimeline() async* { + final user = await _userRepository.me(); + + yield* _timelineRepository.watchTrashTimeline(user.isarId); + } + + Stream watchAllVideosTimeline() { + return _timelineRepository.watchAllVideosTimeline(); + } + + GroupAssetsBy _getGroupByOption() { + return GroupAssetsBy + .values[_appSettingsService.getSetting(AppSettingsEnum.groupAssetsBy)]; + } +} diff --git a/mobile/lib/services/trash.service.dart b/mobile/lib/services/trash.service.dart index 690031d934..8d6cdd8bab 100644 --- a/mobile/lib/services/trash.service.dart +++ b/mobile/lib/services/trash.service.dart @@ -2,11 +2,12 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/entities/asset.entity.dart'; import 'package:immich_mobile/interfaces/asset.interface.dart'; import 'package:immich_mobile/interfaces/user.interface.dart'; + import 'package:immich_mobile/providers/api.provider.dart'; import 'package:immich_mobile/repositories/asset.repository.dart'; import 'package:immich_mobile/repositories/user.repository.dart'; + import 'package:immich_mobile/services/api.service.dart'; -import 'package:immich_mobile/widgets/asset_grid/asset_grid_data_structure.dart'; import 'package:openapi/api.dart'; final trashServiceProvider = Provider((ref) { @@ -82,8 +83,4 @@ class TrashService { await _assetRepository.updateAll(updatedAssets); } - - Stream getRenderListGenerator(int userId) { - return _assetRepository.getTrashRenderListStream(userId); - } } From 94c0e8253a8c8c8d89aa46f9a223bffe5586eb22 Mon Sep 17 00:00:00 2001 From: shenlong <139912620+shenlong-tanwen@users.noreply.github.com> Date: Fri, 21 Feb 2025 20:40:42 +0530 Subject: [PATCH 189/395] test(mobile): store (#16243) Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com> --- .../domain/interfaces/store.interface.dart | 3 +- mobile/lib/domain/models/store.model.dart | 19 ++ .../repositories/store.repository.dart | 14 +- .../domain/services/store_service_test.dart | 184 ++++++++++++++++++ .../repositories/store_repository_test.dart | 181 +++++++++++++++++ .../test/infrastructure/repository.mock.dart | 4 + mobile/test/test_utils.dart | 8 +- 7 files changed, 399 insertions(+), 14 deletions(-) create mode 100644 mobile/test/domain/services/store_service_test.dart create mode 100644 mobile/test/infrastructure/repositories/store_repository_test.dart create mode 100644 mobile/test/infrastructure/repository.mock.dart diff --git a/mobile/lib/domain/interfaces/store.interface.dart b/mobile/lib/domain/interfaces/store.interface.dart index a2d248e801..7a45f9dbe0 100644 --- a/mobile/lib/domain/interfaces/store.interface.dart +++ b/mobile/lib/domain/interfaces/store.interface.dart @@ -1,6 +1,7 @@ +import 'package:immich_mobile/domain/interfaces/db.interface.dart'; import 'package:immich_mobile/domain/models/store.model.dart'; -abstract interface class IStoreRepository { +abstract interface class IStoreRepository implements IDatabaseRepository { Future insert(StoreKey key, T value); Future tryGet(StoreKey key); diff --git a/mobile/lib/domain/models/store.model.dart b/mobile/lib/domain/models/store.model.dart index 4aca207c6f..06b946b3f6 100644 --- a/mobile/lib/domain/models/store.model.dart +++ b/mobile/lib/domain/models/store.model.dart @@ -77,4 +77,23 @@ class StoreUpdateEvent { final T? value; const StoreUpdateEvent(this.key, this.value); + + @override + String toString() { + return ''' +StoreUpdateEvent: { + key: $key, + value: ${value ?? ''}, +}'''; + } + + @override + bool operator ==(covariant StoreUpdateEvent other) { + if (identical(this, other)) return true; + + return other.key == key && other.value == value; + } + + @override + int get hashCode => key.hashCode ^ value.hashCode; } diff --git a/mobile/lib/infrastructure/repositories/store.repository.dart b/mobile/lib/infrastructure/repositories/store.repository.dart index f22f0f987e..5cf6838ee1 100644 --- a/mobile/lib/infrastructure/repositories/store.repository.dart +++ b/mobile/lib/infrastructure/repositories/store.repository.dart @@ -21,7 +21,7 @@ class IsarStoreRepository extends IsarDatabaseRepository @override Stream watchAll() { - return _db.storeValues.where().watch().asyncExpand( + return _db.storeValues.where().watch(fireImmediately: true).asyncExpand( (entities) => Stream.fromFutures(entities.map((e) async => _toUpdateEvent(e))), ); @@ -86,17 +86,11 @@ class IsarStoreRepository extends IsarDatabaseRepository final (int? intValue, String? strValue) = switch (key.type) { const (int) => (value as int, null), const (String) => (null, value as String), - const (bool) => ( - (value as bool) ? 1 : 0, - null, - ), - const (DateTime) => ( - (value as DateTime).millisecondsSinceEpoch, - null, - ), + const (bool) => ((value as bool) ? 1 : 0, null), + const (DateTime) => ((value as DateTime).millisecondsSinceEpoch, null), const (User) => ( (await UserRepository(_db).update(value as User)).isarId, - null + null, ), _ => throw UnsupportedError( "Unsupported primitive type: ${key.type} for key: ${key.name}", diff --git a/mobile/test/domain/services/store_service_test.dart b/mobile/test/domain/services/store_service_test.dart new file mode 100644 index 0000000000..554ca73500 --- /dev/null +++ b/mobile/test/domain/services/store_service_test.dart @@ -0,0 +1,184 @@ +// ignore_for_file: avoid-dynamic + +import 'dart:async'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:immich_mobile/domain/interfaces/store.interface.dart'; +import 'package:immich_mobile/domain/models/store.model.dart'; +import 'package:immich_mobile/domain/services/store.service.dart'; +import 'package:mocktail/mocktail.dart'; + +import '../../infrastructure/repository.mock.dart'; + +const _kAccessToken = '#ThisIsAToken'; +const _kBackgroundBackup = false; +const _kGroupAssetsBy = 2; +final _kBackupFailedSince = DateTime.utc(2023); + +void main() { + late StoreService sut; + late IStoreRepository mockStoreRepo; + late StreamController controller; + + setUp(() async { + controller = StreamController.broadcast(); + mockStoreRepo = MockStoreRepository(); + // For generics, we need to provide fallback to each concrete type to avoid runtime errors + registerFallbackValue(StoreKey.accessToken); + registerFallbackValue(StoreKey.backupTriggerDelay); + registerFallbackValue(StoreKey.backgroundBackup); + registerFallbackValue(StoreKey.backupFailedSince); + + when(() => mockStoreRepo.tryGet(any>())) + .thenAnswer((invocation) async { + final key = invocation.positionalArguments.firstOrNull as StoreKey; + return switch (key) { + StoreKey.accessToken => _kAccessToken, + StoreKey.backgroundBackup => _kBackgroundBackup, + StoreKey.groupAssetsBy => _kGroupAssetsBy, + StoreKey.backupFailedSince => _kBackupFailedSince, + // ignore: avoid-wildcard-cases-with-enums + _ => null, + }; + }); + when(() => mockStoreRepo.watchAll()).thenAnswer((_) => controller.stream); + + sut = await StoreService.create(storeRepository: mockStoreRepo); + }); + + tearDown(() async { + sut.dispose(); + await controller.close(); + }); + + group("Store Service Init:", () { + test('Populates the internal cache on init', () { + verify(() => mockStoreRepo.tryGet(any>())) + .called(equals(StoreKey.values.length)); + expect(sut.tryGet(StoreKey.accessToken), _kAccessToken); + expect(sut.tryGet(StoreKey.backgroundBackup), _kBackgroundBackup); + expect(sut.tryGet(StoreKey.groupAssetsBy), _kGroupAssetsBy); + expect(sut.tryGet(StoreKey.backupFailedSince), _kBackupFailedSince); + // Other keys should be null + expect(sut.tryGet(StoreKey.currentUser), isNull); + }); + + test('Listens to stream of store updates', () async { + final event = + StoreUpdateEvent(StoreKey.accessToken, _kAccessToken.toUpperCase()); + controller.add(event); + + await pumpEventQueue(); + + verify(() => mockStoreRepo.watchAll()).called(1); + expect(sut.tryGet(StoreKey.accessToken), _kAccessToken.toUpperCase()); + }); + }); + + group('Store Service get:', () { + test('Returns the stored value for the given key', () { + expect(sut.get(StoreKey.accessToken), _kAccessToken); + }); + + test('Throws StoreKeyNotFoundException for nonexistent keys', () { + expect( + () => sut.get(StoreKey.currentUser), + throwsA(isA()), + ); + }); + + test('Returns the stored value for the given key or the defaultValue', () { + expect(sut.get(StoreKey.currentUser, 5), 5); + }); + }); + + group('Store Service put:', () { + setUp(() { + when(() => mockStoreRepo.insert(any>(), any())) + .thenAnswer((_) async => true); + }); + + test('Skip insert when value is not modified', () async { + await sut.put(StoreKey.accessToken, _kAccessToken); + verifyNever( + () => mockStoreRepo.insert(StoreKey.accessToken, any()), + ); + }); + + test('Insert value when modified', () async { + final newAccessToken = _kAccessToken.toUpperCase(); + await sut.put(StoreKey.accessToken, newAccessToken); + verify( + () => + mockStoreRepo.insert(StoreKey.accessToken, newAccessToken), + ).called(1); + expect(sut.tryGet(StoreKey.accessToken), newAccessToken); + }); + }); + + group('Store Service watch:', () { + late StreamController valueController; + + setUp(() { + valueController = StreamController.broadcast(); + when(() => mockStoreRepo.watch(any>())) + .thenAnswer((_) => valueController.stream); + }); + + tearDown(() async { + await valueController.close(); + }); + + test('Watches a specific key for changes', () async { + final stream = sut.watch(StoreKey.accessToken); + final events = [ + _kAccessToken, + _kAccessToken.toUpperCase(), + null, + _kAccessToken.toLowerCase(), + ]; + + expectLater(stream, emitsInOrder(events)); + + for (final event in events) { + valueController.add(event); + } + + await pumpEventQueue(); + verify(() => mockStoreRepo.watch(StoreKey.accessToken)).called(1); + }); + }); + + group('Store Service delete:', () { + setUp(() { + when(() => mockStoreRepo.delete(any>())) + .thenAnswer((_) async => true); + }); + + test('Removes the value from the DB', () async { + await sut.delete(StoreKey.accessToken); + verify(() => mockStoreRepo.delete(StoreKey.accessToken)) + .called(1); + }); + + test('Removes the value from the cache', () async { + await sut.delete(StoreKey.accessToken); + expect(sut.tryGet(StoreKey.accessToken), isNull); + }); + }); + + group('Store Service clear:', () { + setUp(() { + when(() => mockStoreRepo.deleteAll()).thenAnswer((_) async => true); + }); + + test('Clears all values from the store', () async { + await sut.clear(); + verify(() => mockStoreRepo.deleteAll()).called(1); + expect(sut.tryGet(StoreKey.accessToken), isNull); + expect(sut.tryGet(StoreKey.backgroundBackup), isNull); + expect(sut.tryGet(StoreKey.groupAssetsBy), isNull); + expect(sut.tryGet(StoreKey.backupFailedSince), isNull); + }); + }); +} diff --git a/mobile/test/infrastructure/repositories/store_repository_test.dart b/mobile/test/infrastructure/repositories/store_repository_test.dart new file mode 100644 index 0000000000..6fd3d3963a --- /dev/null +++ b/mobile/test/infrastructure/repositories/store_repository_test.dart @@ -0,0 +1,181 @@ +// ignore_for_file: avoid-dynamic + +import 'package:flutter_test/flutter_test.dart'; +import 'package:immich_mobile/domain/interfaces/store.interface.dart'; +import 'package:immich_mobile/domain/models/store.model.dart'; +import 'package:immich_mobile/entities/user.entity.dart'; +import 'package:immich_mobile/infrastructure/entities/store.entity.dart'; +import 'package:immich_mobile/infrastructure/repositories/store.repository.dart'; +import 'package:isar/isar.dart'; + +import '../../fixtures/user.stub.dart'; +import '../../test_utils.dart'; + +const _kTestAccessToken = "#TestToken"; +final _kTestBackupFailed = DateTime(2025, 2, 20, 11, 45); +const _kTestVersion = 10; +const _kTestColorfulInterface = false; +final _kTestUser = UserStub.admin; + +Future _addIntStoreValue(Isar db, StoreKey key, int? value) async { + await db.storeValues.put(StoreValue(key.id, intValue: value, strValue: null)); +} + +Future _addStrStoreValue(Isar db, StoreKey key, String? value) async { + await db.storeValues.put(StoreValue(key.id, intValue: null, strValue: value)); +} + +Future _populateStore(Isar db) async { + await db.writeTxn(() async { + await _addIntStoreValue( + db, + StoreKey.colorfulInterface, + _kTestColorfulInterface ? 1 : 0, + ); + await _addIntStoreValue( + db, + StoreKey.backupFailedSince, + _kTestBackupFailed.millisecondsSinceEpoch, + ); + await _addStrStoreValue(db, StoreKey.accessToken, _kTestAccessToken); + await _addIntStoreValue(db, StoreKey.version, _kTestVersion); + }); +} + +void main() { + late Isar db; + late IStoreRepository sut; + + setUp(() async { + db = await TestUtils.initIsar(); + sut = IsarStoreRepository(db); + }); + + group('Store Repository converters:', () { + test('converts int', () async { + int? version = await sut.tryGet(StoreKey.version); + expect(version, isNull); + await sut.insert(StoreKey.version, _kTestVersion); + version = await sut.tryGet(StoreKey.version); + expect(version, _kTestVersion); + }); + + test('converts string', () async { + String? accessToken = await sut.tryGet(StoreKey.accessToken); + expect(accessToken, isNull); + await sut.insert(StoreKey.accessToken, _kTestAccessToken); + accessToken = await sut.tryGet(StoreKey.accessToken); + expect(accessToken, _kTestAccessToken); + }); + + test('converts datetime', () async { + DateTime? backupFailedSince = + await sut.tryGet(StoreKey.backupFailedSince); + expect(backupFailedSince, isNull); + await sut.insert(StoreKey.backupFailedSince, _kTestBackupFailed); + backupFailedSince = await sut.tryGet(StoreKey.backupFailedSince); + expect(backupFailedSince, _kTestBackupFailed); + }); + + test('converts bool', () async { + bool? colorfulInterface = await sut.tryGet(StoreKey.colorfulInterface); + expect(colorfulInterface, isNull); + await sut.insert(StoreKey.colorfulInterface, _kTestColorfulInterface); + colorfulInterface = await sut.tryGet(StoreKey.colorfulInterface); + expect(colorfulInterface, _kTestColorfulInterface); + }); + + test('converts user', () async { + User? user = await sut.tryGet(StoreKey.currentUser); + expect(user, isNull); + await sut.insert(StoreKey.currentUser, _kTestUser); + user = await sut.tryGet(StoreKey.currentUser); + expect(user, _kTestUser); + }); + }); + + group('Store Repository Deletes:', () { + setUp(() async { + await _populateStore(db); + }); + + test('delete()', () async { + bool? isColorful = await sut.tryGet(StoreKey.colorfulInterface); + expect(isColorful, isFalse); + await sut.delete(StoreKey.colorfulInterface); + isColorful = await sut.tryGet(StoreKey.colorfulInterface); + expect(isColorful, isNull); + }); + + test('deleteAll()', () async { + final count = await db.storeValues.count(); + expect(count, isNot(isZero)); + await sut.deleteAll(); + expectLater(await db.storeValues.count(), isZero); + }); + }); + + group('Store Repository Updates:', () { + setUp(() async { + await _populateStore(db); + }); + + test('update()', () async { + int? version = await sut.tryGet(StoreKey.version); + expect(version, _kTestVersion); + await sut.update(StoreKey.version, _kTestVersion + 10); + version = await sut.tryGet(StoreKey.version); + expect(version, _kTestVersion + 10); + }); + }); + + group('Store Repository Watchers:', () { + setUp(() async { + await _populateStore(db); + }); + + test('watch()', () async { + final stream = sut.watch(StoreKey.version); + expectLater(stream, emitsInOrder([_kTestVersion, _kTestVersion + 10])); + await pumpEventQueue(); + await sut.update(StoreKey.version, _kTestVersion + 10); + }); + + test('watchAll()', () async { + final stream = sut.watchAll(); + expectLater( + stream, + emitsInAnyOrder([ + emits( + const StoreUpdateEvent(StoreKey.version, _kTestVersion), + ), + emits( + StoreUpdateEvent( + StoreKey.backupFailedSince, + _kTestBackupFailed, + ), + ), + emits( + const StoreUpdateEvent( + StoreKey.accessToken, + _kTestAccessToken, + ), + ), + emits( + const StoreUpdateEvent( + StoreKey.colorfulInterface, + _kTestColorfulInterface, + ), + ), + emits( + const StoreUpdateEvent( + StoreKey.version, + _kTestVersion + 10, + ), + ), + ]), + ); + await sut.update(StoreKey.version, _kTestVersion + 10); + }); + }); +} diff --git a/mobile/test/infrastructure/repository.mock.dart b/mobile/test/infrastructure/repository.mock.dart new file mode 100644 index 0000000000..ff25bdac9d --- /dev/null +++ b/mobile/test/infrastructure/repository.mock.dart @@ -0,0 +1,4 @@ +import 'package:immich_mobile/domain/interfaces/store.interface.dart'; +import 'package:mocktail/mocktail.dart'; + +class MockStoreRepository extends Mock implements IStoreRepository {} diff --git a/mobile/test/test_utils.dart b/mobile/test/test_utils.dart index 39837b6e56..35ab1fb0aa 100644 --- a/mobile/test/test_utils.dart +++ b/mobile/test/test_utils.dart @@ -21,10 +21,11 @@ import 'mock_http_override.dart'; // Listener Mock to test when a provider notifies its listeners class ListenerMock extends Mock { + // ignore: avoid-declaring-call-method void call(T? previous, T next); } -final class TestUtils { +abstract final class TestUtils { const TestUtils._(); /// Downloads Isar binaries (if required) and initializes a new Isar db @@ -50,13 +51,14 @@ final class TestUtils { AndroidDeviceAssetSchema, IOSDeviceAssetSchema, ], - maxSizeMiB: 1024, directory: "test/", + maxSizeMiB: 1024, + inspector: false, ); // Clear and close db on test end addTearDown(() async { - await db.writeTxn(() => db.clear()); + await db.writeTxn(() async => await db.clear()); await db.close(); }); return db; From 007eaaceb9b8065acbe7e98e968a7bd0acf8287a Mon Sep 17 00:00:00 2001 From: Alex Date: Fri, 21 Feb 2025 09:58:25 -0600 Subject: [PATCH 190/395] feat(web): manual face tagging and deletion (#16062) --- i18n/en.json | 4 + mobile/openapi/README.md | 4 + mobile/openapi/lib/api.dart | 2 + mobile/openapi/lib/api/faces_api.dart | 83 ++ mobile/openapi/lib/api_client.dart | 4 + .../lib/model/asset_face_create_dto.dart | 155 +++ .../lib/model/asset_face_delete_dto.dart | 99 ++ open-api/immich-openapi-specs.json | 128 +++ open-api/typescript-sdk/src/fetch-client.ts | 32 + server/src/controllers/face.controller.ts | 22 +- server/src/db.d.ts | 7 + server/src/dtos/person.dto.ts | 39 +- server/src/entities/asset-face.entity.ts | 3 + server/src/entities/asset.entity.ts | 15 +- ...036-AddDeletedAtColumnToAssetFacesTable.ts | 17 + server/src/queries/asset.repository.sql | 1 + server/src/queries/person.repository.sql | 22 + server/src/repositories/asset.repository.ts | 8 +- server/src/repositories/person.repository.ts | 24 + server/src/services/person.service.ts | 33 +- server/src/utils/access.ts | 4 + server/test/fixtures/face.stub.ts | 20 +- .../repositories/person.repository.mock.ts | 4 + web/package-lock.json | 989 ++++++++++++++++-- web/package.json | 1 + .../asset-viewer/detail-panel.svelte | 31 +- .../face-editor/face-editor.svelte | 310 ++++++ .../asset-viewer/photo-viewer.spec.ts | 8 + .../asset-viewer/photo-viewer.svelte | 30 +- .../buttons/circle-icon-button.svelte | 2 +- .../faces-page/assign-face-side-panel.svelte | 4 +- .../faces-page/person-side-panel.svelte | 46 +- web/src/lib/stores/assets.store.ts | 2 +- web/src/lib/stores/face-edit.svelte.ts | 1 + .../(user)/photos/[[assetId=id]]/+page.svelte | 6 + 35 files changed, 2054 insertions(+), 106 deletions(-) create mode 100644 mobile/openapi/lib/model/asset_face_create_dto.dart create mode 100644 mobile/openapi/lib/model/asset_face_delete_dto.dart create mode 100644 server/src/migrations/1739466714036-AddDeletedAtColumnToAssetFacesTable.ts create mode 100644 web/src/lib/components/asset-viewer/face-editor/face-editor.svelte create mode 100644 web/src/lib/stores/face-edit.svelte.ts diff --git a/i18n/en.json b/i18n/en.json index b6f75ce4f1..5d747de774 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -1,5 +1,9 @@ { + "delete_face": "Delete face", + "tag_people": "Tag People", + "error_delete_face": "Error deleting face from asset", "search_by_description_example": "Hiking day in Sapa", + "confirm_delete_face": "Are you sure you want to delete {name} face from the asset?", "search_by_description": "Search by description", "about": "About", "account": "Account", diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index e86ac93350..80d85bac9a 100644 --- a/mobile/openapi/README.md +++ b/mobile/openapi/README.md @@ -118,6 +118,8 @@ Class | Method | HTTP request | Description *DownloadApi* | [**downloadArchive**](doc//DownloadApi.md#downloadarchive) | **POST** /download/archive | *DownloadApi* | [**getDownloadInfo**](doc//DownloadApi.md#getdownloadinfo) | **POST** /download/info | *DuplicatesApi* | [**getAssetDuplicates**](doc//DuplicatesApi.md#getassetduplicates) | **GET** /duplicates | +*FacesApi* | [**createFace**](doc//FacesApi.md#createface) | **POST** /faces | +*FacesApi* | [**deleteFace**](doc//FacesApi.md#deleteface) | **DELETE** /faces/{id} | *FacesApi* | [**getFaces**](doc//FacesApi.md#getfaces) | **GET** /faces | *FacesApi* | [**reassignFacesById**](doc//FacesApi.md#reassignfacesbyid) | **PUT** /faces/{id} | *FileReportsApi* | [**fixAuditFiles**](doc//FileReportsApi.md#fixauditfiles) | **POST** /reports/fix | @@ -278,6 +280,8 @@ Class | Method | HTTP request | Description - [AssetBulkUploadCheckResult](doc//AssetBulkUploadCheckResult.md) - [AssetDeltaSyncDto](doc//AssetDeltaSyncDto.md) - [AssetDeltaSyncResponseDto](doc//AssetDeltaSyncResponseDto.md) + - [AssetFaceCreateDto](doc//AssetFaceCreateDto.md) + - [AssetFaceDeleteDto](doc//AssetFaceDeleteDto.md) - [AssetFaceResponseDto](doc//AssetFaceResponseDto.md) - [AssetFaceUpdateDto](doc//AssetFaceUpdateDto.md) - [AssetFaceUpdateItem](doc//AssetFaceUpdateItem.md) diff --git a/mobile/openapi/lib/api.dart b/mobile/openapi/lib/api.dart index e5794a2694..893587e7fc 100644 --- a/mobile/openapi/lib/api.dart +++ b/mobile/openapi/lib/api.dart @@ -87,6 +87,8 @@ part 'model/asset_bulk_upload_check_response_dto.dart'; part 'model/asset_bulk_upload_check_result.dart'; part 'model/asset_delta_sync_dto.dart'; part 'model/asset_delta_sync_response_dto.dart'; +part 'model/asset_face_create_dto.dart'; +part 'model/asset_face_delete_dto.dart'; part 'model/asset_face_response_dto.dart'; part 'model/asset_face_update_dto.dart'; part 'model/asset_face_update_item.dart'; diff --git a/mobile/openapi/lib/api/faces_api.dart b/mobile/openapi/lib/api/faces_api.dart index addda0a7a3..e92ee93e42 100644 --- a/mobile/openapi/lib/api/faces_api.dart +++ b/mobile/openapi/lib/api/faces_api.dart @@ -16,6 +16,89 @@ class FacesApi { final ApiClient apiClient; + /// Performs an HTTP 'POST /faces' operation and returns the [Response]. + /// Parameters: + /// + /// * [AssetFaceCreateDto] assetFaceCreateDto (required): + Future createFaceWithHttpInfo(AssetFaceCreateDto assetFaceCreateDto,) async { + // ignore: prefer_const_declarations + final path = r'/faces'; + + // ignore: prefer_final_locals + Object? postBody = assetFaceCreateDto; + + final queryParams = []; + final headerParams = {}; + final formParams = {}; + + const contentTypes = ['application/json']; + + + return apiClient.invokeAPI( + path, + 'POST', + queryParams, + postBody, + headerParams, + formParams, + contentTypes.isEmpty ? null : contentTypes.first, + ); + } + + /// Parameters: + /// + /// * [AssetFaceCreateDto] assetFaceCreateDto (required): + Future createFace(AssetFaceCreateDto assetFaceCreateDto,) async { + final response = await createFaceWithHttpInfo(assetFaceCreateDto,); + if (response.statusCode >= HttpStatus.badRequest) { + throw ApiException(response.statusCode, await _decodeBodyBytes(response)); + } + } + + /// Performs an HTTP 'DELETE /faces/{id}' operation and returns the [Response]. + /// Parameters: + /// + /// * [String] id (required): + /// + /// * [AssetFaceDeleteDto] assetFaceDeleteDto (required): + Future deleteFaceWithHttpInfo(String id, AssetFaceDeleteDto assetFaceDeleteDto,) async { + // ignore: prefer_const_declarations + final path = r'/faces/{id}' + .replaceAll('{id}', id); + + // ignore: prefer_final_locals + Object? postBody = assetFaceDeleteDto; + + final queryParams = []; + final headerParams = {}; + final formParams = {}; + + const contentTypes = ['application/json']; + + + return apiClient.invokeAPI( + path, + 'DELETE', + queryParams, + postBody, + headerParams, + formParams, + contentTypes.isEmpty ? null : contentTypes.first, + ); + } + + /// Parameters: + /// + /// * [String] id (required): + /// + /// * [AssetFaceDeleteDto] assetFaceDeleteDto (required): + Future deleteFace(String id, AssetFaceDeleteDto assetFaceDeleteDto,) async { + final response = await deleteFaceWithHttpInfo(id, assetFaceDeleteDto,); + if (response.statusCode >= HttpStatus.badRequest) { + throw ApiException(response.statusCode, await _decodeBodyBytes(response)); + } + } + /// Performs an HTTP 'GET /faces' operation and returns the [Response]. /// Parameters: /// diff --git a/mobile/openapi/lib/api_client.dart b/mobile/openapi/lib/api_client.dart index 54a8959f6a..7c2dc53455 100644 --- a/mobile/openapi/lib/api_client.dart +++ b/mobile/openapi/lib/api_client.dart @@ -230,6 +230,10 @@ class ApiClient { return AssetDeltaSyncDto.fromJson(value); case 'AssetDeltaSyncResponseDto': return AssetDeltaSyncResponseDto.fromJson(value); + case 'AssetFaceCreateDto': + return AssetFaceCreateDto.fromJson(value); + case 'AssetFaceDeleteDto': + return AssetFaceDeleteDto.fromJson(value); case 'AssetFaceResponseDto': return AssetFaceResponseDto.fromJson(value); case 'AssetFaceUpdateDto': diff --git a/mobile/openapi/lib/model/asset_face_create_dto.dart b/mobile/openapi/lib/model/asset_face_create_dto.dart new file mode 100644 index 0000000000..29e8244a96 --- /dev/null +++ b/mobile/openapi/lib/model/asset_face_create_dto.dart @@ -0,0 +1,155 @@ +// +// 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 AssetFaceCreateDto { + /// Returns a new [AssetFaceCreateDto] instance. + AssetFaceCreateDto({ + required this.assetId, + required this.height, + required this.imageHeight, + required this.imageWidth, + required this.personId, + required this.width, + required this.x, + required this.y, + }); + + String assetId; + + int height; + + int imageHeight; + + int imageWidth; + + String personId; + + int width; + + int x; + + int y; + + @override + bool operator ==(Object other) => identical(this, other) || other is AssetFaceCreateDto && + other.assetId == assetId && + other.height == height && + other.imageHeight == imageHeight && + other.imageWidth == imageWidth && + other.personId == personId && + other.width == width && + other.x == x && + other.y == y; + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (assetId.hashCode) + + (height.hashCode) + + (imageHeight.hashCode) + + (imageWidth.hashCode) + + (personId.hashCode) + + (width.hashCode) + + (x.hashCode) + + (y.hashCode); + + @override + String toString() => 'AssetFaceCreateDto[assetId=$assetId, height=$height, imageHeight=$imageHeight, imageWidth=$imageWidth, personId=$personId, width=$width, x=$x, y=$y]'; + + Map toJson() { + final json = {}; + json[r'assetId'] = this.assetId; + json[r'height'] = this.height; + json[r'imageHeight'] = this.imageHeight; + json[r'imageWidth'] = this.imageWidth; + json[r'personId'] = this.personId; + json[r'width'] = this.width; + json[r'x'] = this.x; + json[r'y'] = this.y; + return json; + } + + /// Returns a new [AssetFaceCreateDto] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static AssetFaceCreateDto? fromJson(dynamic value) { + upgradeDto(value, "AssetFaceCreateDto"); + if (value is Map) { + final json = value.cast(); + + return AssetFaceCreateDto( + assetId: mapValueOfType(json, r'assetId')!, + height: mapValueOfType(json, r'height')!, + imageHeight: mapValueOfType(json, r'imageHeight')!, + imageWidth: mapValueOfType(json, r'imageWidth')!, + personId: mapValueOfType(json, r'personId')!, + width: mapValueOfType(json, r'width')!, + x: mapValueOfType(json, r'x')!, + y: mapValueOfType(json, r'y')!, + ); + } + 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 = AssetFaceCreateDto.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 = AssetFaceCreateDto.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of AssetFaceCreateDto-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] = AssetFaceCreateDto.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + 'assetId', + 'height', + 'imageHeight', + 'imageWidth', + 'personId', + 'width', + 'x', + 'y', + }; +} + diff --git a/mobile/openapi/lib/model/asset_face_delete_dto.dart b/mobile/openapi/lib/model/asset_face_delete_dto.dart new file mode 100644 index 0000000000..2e53b0699c --- /dev/null +++ b/mobile/openapi/lib/model/asset_face_delete_dto.dart @@ -0,0 +1,99 @@ +// +// 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 AssetFaceDeleteDto { + /// Returns a new [AssetFaceDeleteDto] instance. + AssetFaceDeleteDto({ + required this.force, + }); + + bool force; + + @override + bool operator ==(Object other) => identical(this, other) || other is AssetFaceDeleteDto && + other.force == force; + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (force.hashCode); + + @override + String toString() => 'AssetFaceDeleteDto[force=$force]'; + + Map toJson() { + final json = {}; + json[r'force'] = this.force; + return json; + } + + /// Returns a new [AssetFaceDeleteDto] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static AssetFaceDeleteDto? fromJson(dynamic value) { + upgradeDto(value, "AssetFaceDeleteDto"); + if (value is Map) { + final json = value.cast(); + + return AssetFaceDeleteDto( + force: mapValueOfType(json, r'force')!, + ); + } + 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 = AssetFaceDeleteDto.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 = AssetFaceDeleteDto.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of AssetFaceDeleteDto-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] = AssetFaceDeleteDto.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + 'force', + }; +} + diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index 1d0f065992..7417f98c2b 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -2428,9 +2428,85 @@ "tags": [ "Faces" ] + }, + "post": { + "operationId": "createFace", + "parameters": [], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AssetFaceCreateDto" + } + } + }, + "required": true + }, + "responses": { + "201": { + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "tags": [ + "Faces" + ] } }, "/faces/{id}": { + "delete": { + "operationId": "deleteFace", + "parameters": [ + { + "name": "id", + "required": true, + "in": "path", + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AssetFaceDeleteDto" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "tags": [ + "Faces" + ] + }, "put": { "operationId": "reassignFacesById", "parameters": [ @@ -8172,6 +8248,58 @@ ], "type": "object" }, + "AssetFaceCreateDto": { + "properties": { + "assetId": { + "format": "uuid", + "type": "string" + }, + "height": { + "type": "integer" + }, + "imageHeight": { + "type": "integer" + }, + "imageWidth": { + "type": "integer" + }, + "personId": { + "format": "uuid", + "type": "string" + }, + "width": { + "type": "integer" + }, + "x": { + "type": "integer" + }, + "y": { + "type": "integer" + } + }, + "required": [ + "assetId", + "height", + "imageHeight", + "imageWidth", + "personId", + "width", + "x", + "y" + ], + "type": "object" + }, + "AssetFaceDeleteDto": { + "properties": { + "force": { + "type": "boolean" + } + }, + "required": [ + "force" + ], + "type": "object" + }, "AssetFaceResponseDto": { "properties": { "boundingBoxX1": { diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index 8b2e881830..6d7f3c17aa 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -523,6 +523,19 @@ export type AssetFaceResponseDto = { person: (PersonResponseDto) | null; sourceType?: SourceType; }; +export type AssetFaceCreateDto = { + assetId: string; + height: number; + imageHeight: number; + imageWidth: number; + personId: string; + width: number; + x: number; + y: number; +}; +export type AssetFaceDeleteDto = { + force: boolean; +}; export type FaceDto = { id: string; }; @@ -2029,6 +2042,25 @@ export function getFaces({ id }: { ...opts })); } +export function createFace({ assetFaceCreateDto }: { + assetFaceCreateDto: AssetFaceCreateDto; +}, opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchText("/faces", oazapfts.json({ + ...opts, + method: "POST", + body: assetFaceCreateDto + }))); +} +export function deleteFace({ id, assetFaceDeleteDto }: { + id: string; + assetFaceDeleteDto: AssetFaceDeleteDto; +}, opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchText(`/faces/${encodeURIComponent(id)}`, oazapfts.json({ + ...opts, + method: "DELETE", + body: assetFaceDeleteDto + }))); +} export function reassignFacesById({ id, faceDto }: { id: string; faceDto: FaceDto; diff --git a/server/src/controllers/face.controller.ts b/server/src/controllers/face.controller.ts index 7d93bfd34d..d94cd532f7 100644 --- a/server/src/controllers/face.controller.ts +++ b/server/src/controllers/face.controller.ts @@ -1,7 +1,13 @@ -import { Body, Controller, Get, Param, Put, Query } from '@nestjs/common'; +import { Body, Controller, Delete, Get, Param, Post, Put, Query } from '@nestjs/common'; import { ApiTags } from '@nestjs/swagger'; import { AuthDto } from 'src/dtos/auth.dto'; -import { AssetFaceResponseDto, FaceDto, PersonResponseDto } from 'src/dtos/person.dto'; +import { + AssetFaceCreateDto, + AssetFaceDeleteDto, + AssetFaceResponseDto, + FaceDto, + PersonResponseDto, +} from 'src/dtos/person.dto'; import { Permission } from 'src/enum'; import { Auth, Authenticated } from 'src/middleware/auth.guard'; import { PersonService } from 'src/services/person.service'; @@ -12,6 +18,12 @@ import { UUIDParamDto } from 'src/validation'; export class FaceController { constructor(private service: PersonService) {} + @Post() + @Authenticated({ permission: Permission.FACE_CREATE }) + createFace(@Auth() auth: AuthDto, @Body() dto: AssetFaceCreateDto) { + return this.service.createFace(auth, dto); + } + @Get() @Authenticated({ permission: Permission.FACE_READ }) getFaces(@Auth() auth: AuthDto, @Query() dto: FaceDto): Promise { @@ -27,4 +39,10 @@ export class FaceController { ): Promise { return this.service.reassignFacesById(auth, id, dto); } + + @Delete(':id') + @Authenticated({ permission: Permission.FACE_DELETE }) + deleteFace(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto, @Body() dto: AssetFaceDeleteDto) { + return this.service.deleteFace(auth, id, dto); + } } diff --git a/server/src/db.d.ts b/server/src/db.d.ts index 255ac8cd20..dfb451afa7 100644 --- a/server/src/db.d.ts +++ b/server/src/db.d.ts @@ -88,6 +88,7 @@ export interface AssetFaces { boundingBoxX2: Generated; boundingBoxY1: Generated; boundingBoxY2: Generated; + deletedAt: Timestamp | null; id: Generated; imageHeight: Generated; imageWidth: Generated; @@ -334,6 +335,11 @@ export interface SocketIoAttachments { payload: Buffer | null; } +export interface SystemConfig { + key: string; + value: string | null; +} + export interface SystemMetadata { key: string; value: Json; @@ -448,6 +454,7 @@ export interface DB { shared_links: SharedLinks; smart_search: SmartSearch; socket_io_attachments: SocketIoAttachments; + system_config: SystemConfig; system_metadata: SystemMetadata; tag_asset: TagAsset; tags: Tags; diff --git a/server/src/dtos/person.dto.ts b/server/src/dtos/person.dto.ts index ca705154a2..0778c35b8f 100644 --- a/server/src/dtos/person.dto.ts +++ b/server/src/dtos/person.dto.ts @@ -1,6 +1,6 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { Type } from 'class-transformer'; -import { IsArray, IsInt, IsNotEmpty, IsString, Max, Min, ValidateNested } from 'class-validator'; +import { IsArray, IsInt, IsNotEmpty, IsNumber, IsString, Max, Min, ValidateNested } from 'class-validator'; import { DateTime } from 'luxon'; import { PropertyLifecycle } from 'src/decorators'; import { AuthDto } from 'src/dtos/auth.dto'; @@ -164,6 +164,43 @@ export class AssetFaceUpdateItem { assetId!: string; } +export class AssetFaceCreateDto extends AssetFaceUpdateItem { + @ApiProperty({ type: 'integer' }) + @IsNotEmpty() + @IsNumber() + imageWidth!: number; + + @ApiProperty({ type: 'integer' }) + @IsNotEmpty() + @IsNumber() + imageHeight!: number; + + @ApiProperty({ type: 'integer' }) + @IsNotEmpty() + @IsNumber() + x!: number; + + @ApiProperty({ type: 'integer' }) + @IsNotEmpty() + @IsNumber() + y!: number; + + @ApiProperty({ type: 'integer' }) + @IsNotEmpty() + @IsNumber() + width!: number; + + @ApiProperty({ type: 'integer' }) + @IsNotEmpty() + @IsNumber() + height!: number; +} + +export class AssetFaceDeleteDto { + @IsNotEmpty() + force!: boolean; +} + export class PersonStatisticsResponseDto { @ApiProperty({ type: 'integer' }) assets!: number; diff --git a/server/src/entities/asset-face.entity.ts b/server/src/entities/asset-face.entity.ts index 3a4e916cba..b556a8b7cf 100644 --- a/server/src/entities/asset-face.entity.ts +++ b/server/src/entities/asset-face.entity.ts @@ -50,4 +50,7 @@ export class AssetFaceEntity { nullable: true, }) person!: PersonEntity | null; + + @Column({ type: 'timestamptz' }) + deletedAt!: Date | null; } diff --git a/server/src/entities/asset.entity.ts b/server/src/entities/asset.entity.ts index fd69673eb5..a325febce7 100644 --- a/server/src/entities/asset.entity.ts +++ b/server/src/entities/asset.entity.ts @@ -202,10 +202,14 @@ export function withSmartSearch(qb: SelectQueryBuilder) { .select((eb) => eb.fn.toJson(eb.table('smart_search')).as('smartSearch')); } -export function withFaces(eb: ExpressionBuilder) { - return jsonArrayFrom(eb.selectFrom('asset_faces').selectAll().whereRef('asset_faces.assetId', '=', 'assets.id')).as( - 'faces', - ); +export function withFaces(eb: ExpressionBuilder, withDeletedFace?: boolean) { + return jsonArrayFrom( + eb + .selectFrom('asset_faces') + .selectAll() + .whereRef('asset_faces.assetId', '=', 'assets.id') + .$if(!withDeletedFace, (qb) => qb.where('asset_faces.deletedAt', 'is', null)), + ).as('faces'); } export function withFiles(eb: ExpressionBuilder, type?: AssetFileType) { @@ -218,11 +222,12 @@ export function withFiles(eb: ExpressionBuilder, type?: AssetFileT ).as('files'); } -export function withFacesAndPeople(eb: ExpressionBuilder) { +export function withFacesAndPeople(eb: ExpressionBuilder, withDeletedFace?: boolean) { return eb .selectFrom('asset_faces') .leftJoin('person', 'person.id', 'asset_faces.personId') .whereRef('asset_faces.assetId', '=', 'assets.id') + .$if(!withDeletedFace, (qb) => qb.where('asset_faces.deletedAt', 'is', null)) .select((eb) => eb .fn('jsonb_agg', [ diff --git a/server/src/migrations/1739466714036-AddDeletedAtColumnToAssetFacesTable.ts b/server/src/migrations/1739466714036-AddDeletedAtColumnToAssetFacesTable.ts new file mode 100644 index 0000000000..e6f18e2618 --- /dev/null +++ b/server/src/migrations/1739466714036-AddDeletedAtColumnToAssetFacesTable.ts @@ -0,0 +1,17 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddDeletedAtColumnToAssetFacesTable1739466714036 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE asset_faces + ADD COLUMN "deletedAt" TIMESTAMP WITH TIME ZONE DEFAULT NULL + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE asset_faces + DROP COLUMN "deletedAt" + `); + } +} diff --git a/server/src/queries/asset.repository.sql b/server/src/queries/asset.repository.sql index 3efc560b3b..879152dc77 100644 --- a/server/src/queries/asset.repository.sql +++ b/server/src/queries/asset.repository.sql @@ -96,6 +96,7 @@ select left join "person" on "person"."id" = "asset_faces"."personId" where "asset_faces"."assetId" = "assets"."id" + and "asset_faces"."deletedAt" is null ) as "faces", ( select diff --git a/server/src/queries/person.repository.sql b/server/src/queries/person.repository.sql index 069d965202..e6868ae302 100644 --- a/server/src/queries/person.repository.sql +++ b/server/src/queries/person.repository.sql @@ -42,6 +42,8 @@ select from "person" left join "asset_faces" on "asset_faces"."personId" = "person"."id" +where + "asset_faces"."deletedAt" is null group by "person"."id" having @@ -67,6 +69,7 @@ from "asset_faces" where "asset_faces"."assetId" = $1 + and "asset_faces"."deletedAt" is null order by "asset_faces"."boundingBoxX1" asc @@ -90,6 +93,7 @@ from "asset_faces" where "asset_faces"."id" = $1 + and "asset_faces"."deletedAt" is null -- PersonRepository.getFaceByIdWithAssets select @@ -124,6 +128,7 @@ from "asset_faces" where "asset_faces"."id" = $1 + and "asset_faces"."deletedAt" is null -- PersonRepository.reassignFace update "asset_faces" @@ -169,6 +174,8 @@ from and "asset_faces"."personId" = $1 and "assets"."isArchived" = $2 and "assets"."deletedAt" is null +where + "asset_faces"."deletedAt" is null -- PersonRepository.getNumberOfPeople select @@ -185,6 +192,7 @@ from and "assets"."isArchived" = $2 where "person"."ownerId" = $3 + and "asset_faces"."deletedAt" is null -- PersonRepository.refreshFaces with @@ -235,6 +243,7 @@ from where "asset_faces"."assetId" in ($1) and "asset_faces"."personId" in ($2) + and "asset_faces"."deletedAt" is null -- PersonRepository.getRandomFace select @@ -243,9 +252,22 @@ from "asset_faces" where "asset_faces"."personId" = $1 + and "asset_faces"."deletedAt" is null -- PersonRepository.getLatestFaceDate select max("asset_job_status"."facesRecognizedAt")::text as "latestDate" from "asset_job_status" + +-- PersonRepository.deleteAssetFace +delete from "asset_faces" +where + "asset_faces"."id" = $1 + +-- PersonRepository.softDeleteAssetFaces +update "asset_faces" +set + "deletedAt" = $1 +where + "asset_faces"."id" = $2 diff --git a/server/src/repositories/asset.repository.ts b/server/src/repositories/asset.repository.ts index e2851ef623..139e652f03 100644 --- a/server/src/repositories/asset.repository.ts +++ b/server/src/repositories/asset.repository.ts @@ -132,7 +132,7 @@ export type AssetPathEntity = Pick; } @@ -161,6 +162,7 @@ export class PersonRepository { .on('assets.deletedAt', 'is', null), ) .where('person.ownerId', '=', userId) + .where('asset_faces.deletedAt', 'is', null) .orderBy('person.isHidden', 'asc') .orderBy('person.isFavorite', 'desc') .having((eb) => @@ -212,6 +214,7 @@ export class PersonRepository { .selectFrom('person') .selectAll('person') .leftJoin('asset_faces', 'asset_faces.personId', 'person.id') + .where('asset_faces.deletedAt', 'is', null) .having((eb) => eb.fn.count('asset_faces.assetId'), '=', 0) .groupBy('person.id') .execute() as Promise; @@ -224,6 +227,7 @@ export class PersonRepository { .selectAll('asset_faces') .select(withPerson) .where('asset_faces.assetId', '=', assetId) + .where('asset_faces.deletedAt', 'is', null) .orderBy('asset_faces.boundingBoxX1', 'asc') .execute() as Promise; } @@ -236,6 +240,7 @@ export class PersonRepository { .selectAll('asset_faces') .select(withPerson) .where('asset_faces.id', '=', id) + .where('asset_faces.deletedAt', 'is', null) .executeTakeFirstOrThrow() as Promise; } @@ -253,6 +258,7 @@ export class PersonRepository { .select(withAsset) .$if(!!relations?.faceSearch, (qb) => qb.select(withFaceSearch)) .where('asset_faces.id', '=', id) + .where('asset_faces.deletedAt', 'is', null) .executeTakeFirst() as Promise; } @@ -317,6 +323,7 @@ export class PersonRepository { .on('assets.deletedAt', 'is', null), ) .select((eb) => eb.fn.count(eb.fn('distinct', ['assets.id'])).as('count')) + .where('asset_faces.deletedAt', 'is', null) .executeTakeFirst(); return { @@ -330,6 +337,7 @@ export class PersonRepository { .selectFrom('person') .innerJoin('asset_faces', 'asset_faces.personId', 'person.id') .where('person.ownerId', '=', userId) + .where('asset_faces.deletedAt', 'is', null) .innerJoin('assets', (join) => join .onRef('assets.id', '=', 'asset_faces.assetId') @@ -434,6 +442,7 @@ export class PersonRepository { .select(withPerson) .where('asset_faces.assetId', 'in', assetIds) .where('asset_faces.personId', 'in', personIds) + .where('asset_faces.deletedAt', 'is', null) .execute() as Promise; } @@ -443,6 +452,7 @@ export class PersonRepository { .selectFrom('asset_faces') .selectAll('asset_faces') .where('asset_faces.personId', '=', personId) + .where('asset_faces.deletedAt', 'is', null) .executeTakeFirst() as Promise; } @@ -456,6 +466,20 @@ export class PersonRepository { return result?.latestDate; } + async createAssetFace(face: Insertable): Promise { + await this.db.insertInto('asset_faces').values(face).execute(); + } + + @GenerateSql({ params: [DummyValue.UUID] }) + async deleteAssetFace(id: string): Promise { + await this.db.deleteFrom('asset_faces').where('asset_faces.id', '=', id).execute(); + } + + @GenerateSql({ params: [DummyValue.UUID] }) + async softDeleteAssetFaces(id: string): Promise { + await this.db.updateTable('asset_faces').set({ deletedAt: new Date() }).where('asset_faces.id', '=', id).execute(); + } + private async vacuum({ reindexVectors }: { reindexVectors: boolean }): Promise { await sql`VACUUM ANALYZE asset_faces, face_search, person`.execute(this.db); await sql`REINDEX TABLE asset_faces`.execute(this.db); diff --git a/server/src/services/person.service.ts b/server/src/services/person.service.ts index ecb8677f71..dd998cc0fe 100644 --- a/server/src/services/person.service.ts +++ b/server/src/services/person.service.ts @@ -5,9 +5,13 @@ import { OnJob } from 'src/decorators'; import { BulkIdErrorReason, BulkIdResponseDto } from 'src/dtos/asset-ids.response.dto'; import { AuthDto } from 'src/dtos/auth.dto'; import { + AssetFaceCreateDto, + AssetFaceDeleteDto, AssetFaceResponseDto, AssetFaceUpdateDto, FaceDto, + mapFaces, + mapPerson, MergePersonDto, PeopleResponseDto, PeopleUpdateDto, @@ -16,8 +20,6 @@ import { PersonSearchDto, PersonStatisticsResponseDto, PersonUpdateDto, - mapFaces, - mapPerson, } from 'src/dtos/person.dto'; import { AssetFaceEntity } from 'src/entities/asset-face.entity'; import { AssetEntity } from 'src/entities/asset.entity'; @@ -295,7 +297,7 @@ export class PersonService extends BaseService { return JobStatus.SKIPPED; } - const relations = { exifInfo: true, faces: { person: false }, files: true }; + const relations = { exifInfo: true, faces: { person: false, withDeleted: true }, files: true }; const [asset] = await this.assetRepository.getByIds([id], relations); const { previewFile } = getAssetFiles(asset.files); if (!asset || !previewFile) { @@ -717,4 +719,29 @@ export class PersonService extends BaseService { height: newHalfSize * 2, }; } + + // TODO return a asset face response + async createFace(auth: AuthDto, dto: AssetFaceCreateDto): Promise { + await Promise.all([ + this.requireAccess({ auth, permission: Permission.ASSET_READ, ids: [dto.assetId] }), + this.requireAccess({ auth, permission: Permission.PERSON_READ, ids: [dto.personId] }), + ]); + + await this.personRepository.createAssetFace({ + personId: dto.personId, + assetId: dto.assetId, + imageHeight: dto.imageHeight, + imageWidth: dto.imageWidth, + boundingBoxX1: dto.x, + boundingBoxX2: dto.x + dto.width, + boundingBoxY1: dto.y, + boundingBoxY2: dto.y + dto.height, + }); + } + + async deleteFace(auth: AuthDto, id: string, dto: AssetFaceDeleteDto): Promise { + await this.requireAccess({ auth, permission: Permission.FACE_DELETE, ids: [id] }); + + return dto.force ? this.personRepository.deleteAssetFace(id) : this.personRepository.softDeleteAssetFaces(id); + } } diff --git a/server/src/utils/access.ts b/server/src/utils/access.ts index 466d8851e6..e88c8e1a63 100644 --- a/server/src/utils/access.ts +++ b/server/src/utils/access.ts @@ -217,6 +217,10 @@ const checkOtherAccess = async (access: AccessRepository, request: OtherAccessRe return await access.authDevice.checkOwnerAccess(auth.user.id, ids); } + case Permission.FACE_DELETE: { + return access.person.checkFaceOwnerAccess(auth.user.id, ids); + } + case Permission.TAG_ASSET: case Permission.TAG_READ: case Permission.TAG_UPDATE: diff --git a/server/test/fixtures/face.stub.ts b/server/test/fixtures/face.stub.ts index 4da4e6a0c4..74a59a85a8 100644 --- a/server/test/fixtures/face.stub.ts +++ b/server/test/fixtures/face.stub.ts @@ -20,8 +20,9 @@ export const faceStub = { imageWidth: 1024, sourceType: SourceType.MACHINE_LEARNING, faceSearch: { faceId: 'assetFaceId1', embedding: '[1, 2, 3, 4]' }, + deletedAt: new Date(), }), - primaryFace1: Object.freeze>({ + primaryFace1: Object.freeze({ id: 'assetFaceId2', assetId: assetStub.image.id, asset: assetStub.image, @@ -35,8 +36,9 @@ export const faceStub = { imageWidth: 1024, sourceType: SourceType.MACHINE_LEARNING, faceSearch: { faceId: 'assetFaceId2', embedding: '[1, 2, 3, 4]' }, + deletedAt: null, }), - mergeFace1: Object.freeze>({ + mergeFace1: Object.freeze({ id: 'assetFaceId3', assetId: assetStub.image.id, asset: assetStub.image, @@ -50,8 +52,9 @@ export const faceStub = { imageWidth: 1024, sourceType: SourceType.MACHINE_LEARNING, faceSearch: { faceId: 'assetFaceId3', embedding: '[1, 2, 3, 4]' }, + deletedAt: null, }), - start: Object.freeze>({ + start: Object.freeze({ id: 'assetFaceId5', assetId: assetStub.image.id, asset: assetStub.image, @@ -65,8 +68,9 @@ export const faceStub = { imageWidth: 2160, sourceType: SourceType.MACHINE_LEARNING, faceSearch: { faceId: 'assetFaceId5', embedding: '[1, 2, 3, 4]' }, + deletedAt: null, }), - middle: Object.freeze>({ + middle: Object.freeze({ id: 'assetFaceId6', assetId: assetStub.image.id, asset: assetStub.image, @@ -80,8 +84,9 @@ export const faceStub = { imageWidth: 400, sourceType: SourceType.MACHINE_LEARNING, faceSearch: { faceId: 'assetFaceId6', embedding: '[1, 2, 3, 4]' }, + deletedAt: null, }), - end: Object.freeze>({ + end: Object.freeze({ id: 'assetFaceId7', assetId: assetStub.image.id, asset: assetStub.image, @@ -95,6 +100,7 @@ export const faceStub = { imageWidth: 500, sourceType: SourceType.MACHINE_LEARNING, faceSearch: { faceId: 'assetFaceId7', embedding: '[1, 2, 3, 4]' }, + deletedAt: null, }), noPerson1: Object.freeze({ id: 'assetFaceId8', @@ -110,6 +116,7 @@ export const faceStub = { imageWidth: 1024, sourceType: SourceType.MACHINE_LEARNING, faceSearch: { faceId: 'assetFaceId8', embedding: '[1, 2, 3, 4]' }, + deletedAt: null, }), noPerson2: Object.freeze({ id: 'assetFaceId9', @@ -125,6 +132,7 @@ export const faceStub = { imageWidth: 1024, sourceType: SourceType.MACHINE_LEARNING, faceSearch: { faceId: 'assetFaceId9', embedding: '[1, 2, 3, 4]' }, + deletedAt: null, }), fromExif1: Object.freeze({ id: 'assetFaceId9', @@ -139,6 +147,7 @@ export const faceStub = { imageHeight: 500, imageWidth: 400, sourceType: SourceType.EXIF, + deletedAt: null, }), fromExif2: Object.freeze({ id: 'assetFaceId9', @@ -153,5 +162,6 @@ export const faceStub = { imageHeight: 1024, imageWidth: 1024, sourceType: SourceType.EXIF, + deletedAt: null, }), }; diff --git a/server/test/repositories/person.repository.mock.ts b/server/test/repositories/person.repository.mock.ts index 52240aafa2..c8a4253edc 100644 --- a/server/test/repositories/person.repository.mock.ts +++ b/server/test/repositories/person.repository.mock.ts @@ -33,5 +33,9 @@ export const newPersonRepositoryMock = (): Mocked=0.32.1 <2.0.0" } }, + "node_modules/@mapbox/node-pre-gyp": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@mapbox/node-pre-gyp/-/node-pre-gyp-1.0.11.tgz", + "integrity": "sha512-Yhlar6v9WQgUp/He7BdgzOz8lqMQ8sU+jkCq7Wx8Myc5YFJLbEe7lgui/V7G1qB1DJykHSGwreceSaD60Y0PUQ==", + "license": "BSD-3-Clause", + "optional": true, + "dependencies": { + "detect-libc": "^2.0.0", + "https-proxy-agent": "^5.0.0", + "make-dir": "^3.1.0", + "node-fetch": "^2.6.7", + "nopt": "^5.0.0", + "npmlog": "^5.0.1", + "rimraf": "^3.0.2", + "semver": "^7.3.5", + "tar": "^6.1.11" + }, + "bin": { + "node-pre-gyp": "bin/node-pre-gyp" + } + }, + "node_modules/@mapbox/node-pre-gyp/node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/@mapbox/node-pre-gyp/node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "license": "MIT", + "optional": true, + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/@mapbox/node-pre-gyp/node_modules/make-dir": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz", + "integrity": "sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==", + "license": "MIT", + "optional": true, + "dependencies": { + "semver": "^6.0.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@mapbox/node-pre-gyp/node_modules/make-dir/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "license": "ISC", + "optional": true, + "bin": { + "semver": "bin/semver.js" + } + }, "node_modules/@mapbox/point-geometry": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/@mapbox/point-geometry/-/point-geometry-0.1.0.tgz", @@ -2375,6 +2450,16 @@ "@testing-library/dom": ">=7.21.4" } }, + "node_modules/@tootallnate/once": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-2.0.0.tgz", + "integrity": "sha512-XCuKFP5PS55gnMVu3dty8KPatLqUoy/ZYzDzAGCQ8JNFCkLXzmI7vNHCR+XpbZaMWQK/vQubr7PkYq8g470J/A==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 10" + } + }, "node_modules/@types/aria-query": { "version": "5.0.2", "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.2.tgz", @@ -2902,6 +2987,21 @@ "svelte": "^3.0.0 || ^4.0.0 || ^5.0.0" } }, + "node_modules/abab": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/abab/-/abab-2.0.6.tgz", + "integrity": "sha512-j2afSsaIENvHZN2B8GOpF566vZ5WVk5opAiMTvWgaQT8DkbOqsTfvNAvHoRGU2zzP8cPoqys+xHTRDWW8L+/BA==", + "deprecated": "Use your platform's native atob() and btoa() methods instead", + "license": "BSD-3-Clause", + "optional": true + }, + "node_modules/abbrev": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz", + "integrity": "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==", + "license": "ISC", + "optional": true + }, "node_modules/acorn": { "version": "8.14.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.0.tgz", @@ -2914,6 +3014,17 @@ "node": ">=0.4.0" } }, + "node_modules/acorn-globals": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/acorn-globals/-/acorn-globals-7.0.1.tgz", + "integrity": "sha512-umOSDSDrfHbTNPuNpC2NSnnA3LUrqpevPb4T9jRx4MagXNS0rs+gwiTcAvqCRmsD6utzsrzNt+ebm00SNWiC3Q==", + "license": "MIT", + "optional": true, + "dependencies": { + "acorn": "^8.1.0", + "acorn-walk": "^8.0.2" + } + }, "node_modules/acorn-jsx": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", @@ -2932,6 +3043,19 @@ "acorn": ">=8.9.0" } }, + "node_modules/acorn-walk": { + "version": "8.3.4", + "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.4.tgz", + "integrity": "sha512-ueEepnujpqee2o5aIYnvHU6C0A42MNdsIDeqy5BydrkuC5R1ZuUFnm27EeFJGoEHJQgn3uleRvmTXaJgfXbt4g==", + "license": "MIT", + "optional": true, + "dependencies": { + "acorn": "^8.11.0" + }, + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/agent-base": { "version": "7.1.0", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.0.tgz", @@ -2999,6 +3123,28 @@ "node": ">= 8" } }, + "node_modules/aproba": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/aproba/-/aproba-2.0.0.tgz", + "integrity": "sha512-lYe4Gx7QT+MKGbDsA+Z+he/Wtef0BiwDOlK/XkBrdfsh9J/jPPXbX0tE9x9cl27Tmu5gg3QUbUrQYa/y+KOHPQ==", + "license": "ISC", + "optional": true + }, + "node_modules/are-we-there-yet": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/are-we-there-yet/-/are-we-there-yet-2.0.0.tgz", + "integrity": "sha512-Ci/qENmwHnsYo9xKIcUJN5LeDKdJ6R1Z1j9V/J5wyq8nh/mYPEpIKJbBZXtZjG04HiK7zV/p6Vs9952MrMeUIw==", + "deprecated": "This package is no longer supported.", + "license": "ISC", + "optional": true, + "dependencies": { + "delegates": "^1.0.0", + "readable-stream": "^3.6.0" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/arg": { "version": "5.0.2", "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", @@ -3049,9 +3195,7 @@ "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", - "dev": true, - "optional": true, - "peer": true + "optional": true }, "node_modules/autoprefixer": { "version": "10.4.20", @@ -3141,7 +3285,7 @@ "version": "1.1.11", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", - "dev": true, + "devOptional": true, "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" @@ -3275,6 +3419,22 @@ ], "license": "CC-BY-4.0" }, + "node_modules/canvas": { + "version": "2.11.2", + "resolved": "https://registry.npmjs.org/canvas/-/canvas-2.11.2.tgz", + "integrity": "sha512-ItanGBMrmRV7Py2Z+Xhs7cT+FNt5K0vPL4p9EZ/UX/Mu7hFbkxSjKF2KVtPwX7UYWp7dRKnrTvReflgrItJbdw==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@mapbox/node-pre-gyp": "^1.0.0", + "nan": "^2.17.0", + "simple-get": "^3.0.3" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/chai": { "version": "5.1.2", "resolved": "https://registry.npmjs.org/chai/-/chai-5.1.2.tgz", @@ -3351,6 +3511,16 @@ "node": ">= 6" } }, + "node_modules/chownr": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-2.0.0.tgz", + "integrity": "sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==", + "license": "ISC", + "optional": true, + "engines": { + "node": ">=10" + } + }, "node_modules/ci-info": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.0.0.tgz", @@ -3468,6 +3638,16 @@ "simple-swizzle": "^0.2.2" } }, + "node_modules/color-support": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-support/-/color-support-1.1.3.tgz", + "integrity": "sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg==", + "license": "ISC", + "optional": true, + "bin": { + "color-support": "bin.js" + } + }, "node_modules/color/node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -3490,9 +3670,7 @@ "version": "1.0.8", "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", - "dev": true, "optional": true, - "peer": true, "dependencies": { "delayed-stream": "~1.0.0" }, @@ -3512,7 +3690,14 @@ "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", - "dev": true + "devOptional": true + }, + "node_modules/console-control-strings": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/console-control-strings/-/console-control-strings-1.1.0.tgz", + "integrity": "sha512-ty/fTekppD2fIwRvnZAVdeOiGd1c7YXEixbgJTNzqcxJWKQnjJ/V1bNEEE6hygpM3WjwHFUVK6HTjWSzV4a8sQ==", + "license": "ISC", + "optional": true }, "node_modules/cookie": { "version": "0.6.0", @@ -3575,6 +3760,13 @@ "node": ">=4" } }, + "node_modules/cssom": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/cssom/-/cssom-0.5.0.tgz", + "integrity": "sha512-iKuQcq+NdHqlAcwUY0o/HL69XQrUaQdMjmStJ8JFmUaiiQErlhrmuigkg/CU4E2J0IyUKUrMAgl36TvN67MqTw==", + "license": "MIT", + "optional": true + }, "node_modules/cssstyle": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-3.0.0.tgz", @@ -3660,6 +3852,19 @@ "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.4.3.tgz", "integrity": "sha512-VBBaLc1MgL5XpzgIP7ny5Z6Nx3UrRkIViUkPUdtl9aya5amy3De1gsUUSB1g3+3sExYNjCAsAznmukyxCb1GRA==" }, + "node_modules/decompress-response": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-4.2.1.tgz", + "integrity": "sha512-jOSne2qbyE+/r8G1VU+G/82LBs2Fs4LAsTiLSHOCOMZQl2OKZ6i8i4IyHemTe+/yIXOtTcRQMzPcgyhoFlqPkw==", + "license": "MIT", + "optional": true, + "dependencies": { + "mimic-response": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/deep-eql": { "version": "5.0.2", "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", @@ -3697,13 +3902,18 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", - "dev": true, "optional": true, - "peer": true, "engines": { "node": ">=0.4.0" } }, + "node_modules/delegates": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delegates/-/delegates-1.0.0.tgz", + "integrity": "sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ==", + "license": "MIT", + "optional": true + }, "node_modules/dequal": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", @@ -3716,7 +3926,7 @@ "version": "2.0.3", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.3.tgz", "integrity": "sha512-bwy0MGW55bG41VqxxypOsdSdGqLwXPI/focwgTYCFMbdUiBAxLg9CFzG08sz2aqzknwiX7Hkl0bQENjg8iLByw==", - "dev": true, + "devOptional": true, "engines": { "node": ">=8" } @@ -3749,6 +3959,20 @@ "resolved": "https://registry.npmjs.org/dom-to-image/-/dom-to-image-2.6.0.tgz", "integrity": "sha512-Dt0QdaHmLpjURjU7Tnu3AgYSF2LuOmksSGsUcE6ItvJoCWTBEmiMXcqBdNSAm9+QbbwD7JMoVsuuKX6ZVQv1qA==" }, + "node_modules/domexception": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/domexception/-/domexception-4.0.0.tgz", + "integrity": "sha512-A2is4PLG+eeSfoTMA95/s4pvAoSo2mKtiM5jlHkAVewmiO8ISFTFKZjH7UAM1Atli/OT/7JHOrJRJiMKUZKYBw==", + "deprecated": "Use your platform's native DOMException instead", + "license": "MIT", + "optional": true, + "dependencies": { + "webidl-conversions": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/dotenv": { "version": "16.4.7", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.4.7.tgz", @@ -3810,9 +4034,7 @@ "version": "4.5.0", "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", - "dev": true, "optional": true, - "peer": true, "engines": { "node": ">=0.12" }, @@ -3944,6 +4166,28 @@ "node": ">=0.8.0" } }, + "node_modules/escodegen": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/escodegen/-/escodegen-2.1.0.tgz", + "integrity": "sha512-2NlIDTwUWJN0mRPQOdtQBzbUHvdGY2P1VXSyU83Q3xKxM7WHX2Ql8dKq782Q9TgQUNOLEzEYu9bzLNj1q88I5w==", + "license": "BSD-2-Clause", + "optional": true, + "dependencies": { + "esprima": "^4.0.1", + "estraverse": "^5.2.0", + "esutils": "^2.0.2" + }, + "bin": { + "escodegen": "bin/escodegen.js", + "esgenerate": "bin/esgenerate.js" + }, + "engines": { + "node": ">=6.0" + }, + "optionalDependencies": { + "source-map": "~0.6.1" + } + }, "node_modules/eslint": { "version": "9.20.1", "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.20.1.tgz", @@ -4344,6 +4588,20 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/esprima": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", + "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", + "license": "BSD-2-Clause", + "optional": true, + "bin": { + "esparse": "bin/esparse.js", + "esvalidate": "bin/esvalidate.js" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/esquery": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.6.0.tgz", @@ -4382,7 +4640,7 @@ "version": "5.3.0", "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", - "dev": true, + "devOptional": true, "engines": { "node": ">=4.0" } @@ -4401,7 +4659,7 @@ "version": "2.0.3", "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", - "dev": true, + "devOptional": true, "engines": { "node": ">=0.10.0" } @@ -4444,6 +4702,228 @@ "node": ">=0.10.0" } }, + "node_modules/fabric": { + "version": "6.5.4", + "resolved": "https://registry.npmjs.org/fabric/-/fabric-6.5.4.tgz", + "integrity": "sha512-X+O8G+3aDQSp3lxRekvIy/gMwtzcjAG7IvGXjb4PeUD6+nUJfSfGnaEWpni9aAcXuGz8zXhpMQQELV+4FfRTwA==", + "license": "MIT", + "engines": { + "node": ">=16.20.0" + }, + "optionalDependencies": { + "canvas": "^2.11.2", + "jsdom": "^20.0.1" + } + }, + "node_modules/fabric/node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/fabric/node_modules/cssstyle": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-2.3.0.tgz", + "integrity": "sha512-AZL67abkUzIuvcHqk7c09cezpGNcxUxU4Ioi/05xHk4DQeTkWmGYftIE6ctU6AEt+Gn4n1lDStOtj7FKycP71A==", + "license": "MIT", + "optional": true, + "dependencies": { + "cssom": "~0.3.6" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/fabric/node_modules/cssstyle/node_modules/cssom": { + "version": "0.3.8", + "resolved": "https://registry.npmjs.org/cssom/-/cssom-0.3.8.tgz", + "integrity": "sha512-b0tGHbfegbhPJpxpiBPU2sCkigAqtM9O121le6bbOlgyV+NyGyCmVfJ6QW9eRjz8CpNfWEOYBIMIGRYkLwsIYg==", + "license": "MIT", + "optional": true + }, + "node_modules/fabric/node_modules/data-urls": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-3.0.2.tgz", + "integrity": "sha512-Jy/tj3ldjZJo63sVAvg6LHt2mHvl4V6AgRAmNDtLdm7faqtsx+aJG42rsyCo9JCoRVKwPFzKlIPx3DIibwSIaQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "abab": "^2.0.6", + "whatwg-mimetype": "^3.0.0", + "whatwg-url": "^11.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/fabric/node_modules/html-encoding-sniffer": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-3.0.0.tgz", + "integrity": "sha512-oWv4T4yJ52iKrufjnyZPkrN0CH3QnrUqdB6In1g5Fe1mia8GmF36gnfNySxoZtxD5+NmYw1EElVXiBk93UeskA==", + "license": "MIT", + "optional": true, + "dependencies": { + "whatwg-encoding": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/fabric/node_modules/http-proxy-agent": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-5.0.0.tgz", + "integrity": "sha512-n2hY8YdoRE1i7r6M0w9DIw5GgZN0G25P8zLCRQ8rjXtTU3vsNFBI/vWK/UIeE6g5MUUz6avwAPXmL6Fy9D/90w==", + "license": "MIT", + "optional": true, + "dependencies": { + "@tootallnate/once": "2", + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fabric/node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "license": "MIT", + "optional": true, + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fabric/node_modules/jsdom": { + "version": "20.0.3", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-20.0.3.tgz", + "integrity": "sha512-SYhBvTh89tTfCD/CRdSOm13mOBa42iTaTyfyEWBdKcGdPxPtLFBXuHR8XHb33YNYaP+lLbmSvBTsnoesCNJEsQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "abab": "^2.0.6", + "acorn": "^8.8.1", + "acorn-globals": "^7.0.0", + "cssom": "^0.5.0", + "cssstyle": "^2.3.0", + "data-urls": "^3.0.2", + "decimal.js": "^10.4.2", + "domexception": "^4.0.0", + "escodegen": "^2.0.0", + "form-data": "^4.0.0", + "html-encoding-sniffer": "^3.0.0", + "http-proxy-agent": "^5.0.0", + "https-proxy-agent": "^5.0.1", + "is-potential-custom-element-name": "^1.0.1", + "nwsapi": "^2.2.2", + "parse5": "^7.1.1", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^4.1.2", + "w3c-xmlserializer": "^4.0.0", + "webidl-conversions": "^7.0.0", + "whatwg-encoding": "^2.0.0", + "whatwg-mimetype": "^3.0.0", + "whatwg-url": "^11.0.0", + "ws": "^8.11.0", + "xml-name-validator": "^4.0.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "canvas": "^2.5.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/fabric/node_modules/tr46": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-3.0.0.tgz", + "integrity": "sha512-l7FvfAHlcmulp8kr+flpQZmVwtu7nfRV7NZujtN0OqES8EL4O4e0qqzL0DC5gAvx/ZC/9lk6rhcUwYvkBnBnYA==", + "license": "MIT", + "optional": true, + "dependencies": { + "punycode": "^2.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/fabric/node_modules/w3c-xmlserializer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-4.0.0.tgz", + "integrity": "sha512-d+BFHzbiCx6zGfz0HyQ6Rg69w9k19nviJspaj4yNscGjrHu94sVP+aRm75yEbCh+r2/yR+7q6hux9LVtbuTGBw==", + "license": "MIT", + "optional": true, + "dependencies": { + "xml-name-validator": "^4.0.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/fabric/node_modules/whatwg-encoding": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-2.0.0.tgz", + "integrity": "sha512-p41ogyeMUrw3jWclHWTQg1k05DSVXPLcVxRTYsXUk+ZooOCZLcoYgPZ/HL/D/N+uQPOtcp1me1WhBEaX02mhWg==", + "license": "MIT", + "optional": true, + "dependencies": { + "iconv-lite": "0.6.3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/fabric/node_modules/whatwg-mimetype": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-3.0.0.tgz", + "integrity": "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=12" + } + }, + "node_modules/fabric/node_modules/whatwg-url": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-11.0.0.tgz", + "integrity": "sha512-RKT8HExMpoYx4igMiVMY83lN6UeITKJlBQ+vR/8ZJ8OCdSiN3RwCq+9gH0+Xzj0+5IrM6i4j/6LuvzbZIQgEcQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "tr46": "^3.0.0", + "webidl-conversions": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/fabric/node_modules/xml-name-validator": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-4.0.0.tgz", + "integrity": "sha512-ICP2e+jsHvAj2E2lIHxa5tjXRlKDJo4IdvPvCXbXQGdzSfmSpNVyIKMvoZHjDY9DP0zV17iI85o90vRFXNccRw==", + "license": "Apache-2.0", + "optional": true, + "engines": { + "node": ">=12" + } + }, "node_modules/factory.ts": { "version": "1.4.2", "resolved": "https://registry.npmjs.org/factory.ts/-/factory.ts-1.4.2.tgz", @@ -4597,9 +5077,7 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz", "integrity": "sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==", - "dev": true, "optional": true, - "peer": true, "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", @@ -4622,6 +5100,39 @@ "url": "https://github.com/sponsors/rawify" } }, + "node_modules/fs-minipass": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-2.1.0.tgz", + "integrity": "sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==", + "license": "ISC", + "optional": true, + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/fs-minipass/node_modules/minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "license": "ISC", + "optional": true, + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "license": "ISC", + "optional": true + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -4640,6 +5151,35 @@ "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz", "integrity": "sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==" }, + "node_modules/gauge": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/gauge/-/gauge-3.0.2.tgz", + "integrity": "sha512-+5J6MS/5XksCuXq++uFRsnUd7Ovu1XenbeuIuNRJxYWjgQbPuFhT14lAvsWfqfAmnwluf1OwMjz39HjfLPci0Q==", + "deprecated": "This package is no longer supported.", + "license": "ISC", + "optional": true, + "dependencies": { + "aproba": "^1.0.3 || ^2.0.0", + "color-support": "^1.1.2", + "console-control-strings": "^1.0.0", + "has-unicode": "^2.0.1", + "object-assign": "^4.1.1", + "signal-exit": "^3.0.0", + "string-width": "^4.2.3", + "strip-ansi": "^6.0.1", + "wide-align": "^1.1.2" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/gauge/node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "license": "ISC", + "optional": true + }, "node_modules/geojson-vt": { "version": "3.2.1", "resolved": "https://registry.npmjs.org/geojson-vt/-/geojson-vt-3.2.1.tgz", @@ -4833,6 +5373,13 @@ "node": ">=4" } }, + "node_modules/has-unicode": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/has-unicode/-/has-unicode-2.0.1.tgz", + "integrity": "sha512-8Rf9Y83NBReMnx0gFzA8JImQACstCYWUplepDa9xprwwtmgEZUF0h/i5xSA625zB/I37EtrswSST6OXxwaaIJQ==", + "license": "ISC", + "optional": true + }, "node_modules/hosted-git-info": { "version": "2.8.9", "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-2.8.9.tgz", @@ -4893,9 +5440,7 @@ "version": "0.6.3", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", - "dev": true, "optional": true, - "peer": true, "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" }, @@ -4997,6 +5542,25 @@ "node": ">=8" } }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", + "license": "ISC", + "optional": true, + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC", + "optional": true + }, "node_modules/ini": { "version": "1.3.8", "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", @@ -5145,9 +5709,7 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", - "dev": true, - "optional": true, - "peer": true + "optional": true }, "node_modules/is-promise": { "version": "2.2.2", @@ -5709,9 +6271,7 @@ "version": "1.52.0", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", - "dev": true, "optional": true, - "peer": true, "engines": { "node": ">= 0.6" } @@ -5720,9 +6280,7 @@ "version": "2.1.35", "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", - "dev": true, "optional": true, - "peer": true, "dependencies": { "mime-db": "1.52.0" }, @@ -5730,6 +6288,19 @@ "node": ">= 0.6" } }, + "node_modules/mimic-response": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-2.1.0.tgz", + "integrity": "sha512-wXqjST+SLt7R009ySCglWBCFpjUygmCIfD790/kVbiGmUgfYGuB14PiTd5DwVxSV4NcYHjzMkoj5LjQZwTQLEA==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/min-indent": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", @@ -5743,7 +6314,7 @@ "version": "3.1.2", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", - "dev": true, + "devOptional": true, "dependencies": { "brace-expansion": "^1.1.7" }, @@ -5767,6 +6338,46 @@ "node": ">=16 || 14 >=14.17" } }, + "node_modules/minizlib": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-2.1.2.tgz", + "integrity": "sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==", + "license": "MIT", + "optional": true, + "dependencies": { + "minipass": "^3.0.0", + "yallist": "^4.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/minizlib/node_modules/minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "license": "ISC", + "optional": true, + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/mkdirp": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", + "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==", + "license": "MIT", + "optional": true, + "bin": { + "mkdirp": "bin/cmd.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/mri": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz", @@ -5805,6 +6416,13 @@ "thenify-all": "^1.0.0" } }, + "node_modules/nan": { + "version": "2.22.0", + "resolved": "https://registry.npmjs.org/nan/-/nan-2.22.0.tgz", + "integrity": "sha512-nbajikzWTMwsW+eSsNm3QwlOs7het9gGJU5dDZzRTQGk03vyBOauxgI4VakDzE0PtsGTmXPsXTbbjVhRwR5mpw==", + "license": "MIT", + "optional": true + }, "node_modules/nanoid": { "version": "3.3.8", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.8.tgz", @@ -5839,6 +6457,52 @@ "resolved": "https://registry.npmjs.org/next-tick/-/next-tick-1.1.0.tgz", "integrity": "sha512-CXdUiJembsNjuToQvxayPZF9Vqht7hewsvy2sOWafLvi2awflj9mOC6bHIg50orX8IJvWKY9wYQ/zB2kogPslQ==" }, + "node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "license": "MIT", + "optional": true, + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, + "node_modules/node-fetch/node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "license": "MIT", + "optional": true + }, + "node_modules/node-fetch/node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "license": "BSD-2-Clause", + "optional": true + }, + "node_modules/node-fetch/node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "license": "MIT", + "optional": true, + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, "node_modules/node-releases": { "version": "2.0.19", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.19.tgz", @@ -5846,6 +6510,22 @@ "dev": true, "license": "MIT" }, + "node_modules/nopt": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/nopt/-/nopt-5.0.0.tgz", + "integrity": "sha512-Tbj67rffqceeLpcRXrT7vKAN8CwfPeIBgM7E6iBkmKLV7bEMwpGgYLGv0jACUsECaa/vuxP0IjEont6umdMgtQ==", + "license": "ISC", + "optional": true, + "dependencies": { + "abbrev": "1" + }, + "bin": { + "nopt": "bin/nopt.js" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/normalize-package-data": { "version": "2.5.0", "resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-2.5.0.tgz", @@ -5884,13 +6564,25 @@ "node": ">=0.10.0" } }, + "node_modules/npmlog": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/npmlog/-/npmlog-5.0.1.tgz", + "integrity": "sha512-AqZtDUWOMKs1G/8lwylVjrdYgqA4d9nu8hc+0gzRxlDb1I10+FHBGMXs6aiQHFdCUUlqH99MUMuLfzWDNDtfxw==", + "deprecated": "This package is no longer supported.", + "license": "ISC", + "optional": true, + "dependencies": { + "are-we-there-yet": "^2.0.0", + "console-control-strings": "^1.1.0", + "gauge": "^3.0.0", + "set-blocking": "^2.0.0" + } + }, "node_modules/nwsapi": { "version": "2.2.7", "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.7.tgz", "integrity": "sha512-ub5E4+FBPKwAZx0UwIQOjYWGHTEq5sPqHQNRN8Z9e4A7u3Tj1weLJsL59yH9vmvqEtBHaOmT6cYQKIZOxp35FQ==", - "dev": true, - "optional": true, - "peer": true + "optional": true }, "node_modules/object-assign": { "version": "4.1.1", @@ -5908,6 +6600,16 @@ "node": ">= 6" } }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "optional": true, + "dependencies": { + "wrappy": "1" + } + }, "node_modules/open": { "version": "8.4.2", "resolved": "https://registry.npmjs.org/open/-/open-8.4.2.tgz", @@ -6020,9 +6722,7 @@ "version": "7.1.2", "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.1.2.tgz", "integrity": "sha512-Czj1WaSVpaoj0wbhMzLmWD69anp2WH7FXMB9n1Sy8/ZFF9jolSQVMu1Ij5WIyGmcBmhk7EOndpO4mIpihVqAXw==", - "dev": true, "optional": true, - "peer": true, "dependencies": { "entities": "^4.4.0" }, @@ -6039,6 +6739,16 @@ "node": ">=8" } }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/path-key": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", @@ -6445,15 +7155,13 @@ "version": "1.9.0", "resolved": "https://registry.npmjs.org/psl/-/psl-1.9.0.tgz", "integrity": "sha512-E/ZsdU4HLs/68gYzgGTkMicWTLPdAftJLfJFlLUAAKZGkStNU72sZjT66SnMDVOfOWY/YAoiD7Jxa9iHvngcag==", - "dev": true, - "optional": true, - "peer": true + "optional": true }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", - "dev": true, + "devOptional": true, "engines": { "node": ">=6" } @@ -6462,9 +7170,7 @@ "version": "2.2.0", "resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz", "integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==", - "dev": true, - "optional": true, - "peer": true + "optional": true }, "node_modules/queue-microtask": { "version": "1.2.3", @@ -6606,6 +7312,21 @@ "node": ">=8" } }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "optional": true, + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/readdirp": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", @@ -6679,9 +7400,7 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==", - "dev": true, - "optional": true, - "peer": true + "optional": true }, "node_modules/resolve": { "version": "1.22.8", @@ -6717,6 +7436,45 @@ "node": ">=0.10.0" } }, + "node_modules/rimraf": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", + "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", + "deprecated": "Rimraf versions prior to v4 are no longer supported", + "license": "ISC", + "optional": true, + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/rimraf/node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Glob versions prior to v9 are no longer supported", + "license": "ISC", + "optional": true, + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/rollup": { "version": "4.34.6", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.34.6.tgz", @@ -6870,21 +7628,38 @@ "node": ">=6" } }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "optional": true + }, "node_modules/safer-buffer": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", - "dev": true, - "optional": true, - "peer": true + "optional": true }, "node_modules/saxes": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", - "dev": true, "optional": true, - "peer": true, "dependencies": { "xmlchars": "^2.2.0" }, @@ -6896,7 +7671,7 @@ "version": "7.6.3", "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz", "integrity": "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==", - "dev": true, + "devOptional": true, "license": "ISC", "bin": { "semver": "bin/semver.js" @@ -6905,6 +7680,13 @@ "node": ">=10" } }, + "node_modules/set-blocking": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", + "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==", + "license": "ISC", + "optional": true + }, "node_modules/set-cookie-parser": { "version": "2.6.0", "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.6.0.tgz", @@ -7013,6 +7795,39 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/simple-concat": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", + "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "optional": true + }, + "node_modules/simple-get": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-3.1.1.tgz", + "integrity": "sha512-CQ5LTKGfCpvE1K0n2us+kuMPbk/q0EKl82s4aheV9oXjFEz6W/Y7oQFVJuU6QG77hRT4Ghb5RURteF5vnWjupA==", + "license": "MIT", + "optional": true, + "dependencies": { + "decompress-response": "^4.2.0", + "once": "^1.3.1", + "simple-concat": "^1.0.0" + } + }, "node_modules/simple-swizzle": { "version": "0.2.2", "resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.2.tgz", @@ -7208,6 +8023,16 @@ "dev": true, "license": "MIT" }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "optional": true, + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, "node_modules/string-width": { "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", @@ -8026,9 +8851,7 @@ "version": "3.2.4", "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", - "dev": true, - "optional": true, - "peer": true + "optional": true }, "node_modules/tailwind-merge": { "version": "2.6.0", @@ -8140,6 +8963,34 @@ } } }, + "node_modules/tar": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/tar/-/tar-6.2.1.tgz", + "integrity": "sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==", + "license": "ISC", + "optional": true, + "dependencies": { + "chownr": "^2.0.0", + "fs-minipass": "^2.0.0", + "minipass": "^5.0.0", + "minizlib": "^2.1.1", + "mkdirp": "^1.0.3", + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/tar/node_modules/minipass": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-5.0.0.tgz", + "integrity": "sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ==", + "license": "ISC", + "optional": true, + "engines": { + "node": ">=8" + } + }, "node_modules/test-exclude": { "version": "7.0.1", "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-7.0.1.tgz", @@ -8295,9 +9146,7 @@ "version": "4.1.3", "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-4.1.3.tgz", "integrity": "sha512-aX/y5pVRkfRnfmuX+OdbSdXvPe6ieKX/G2s7e98f4poJHnqH3281gDPm/metm6E/WRamfx7WC4HUqkWHfQHprw==", - "dev": true, "optional": true, - "peer": true, "dependencies": { "psl": "^1.1.33", "punycode": "^2.1.1", @@ -8420,9 +9269,7 @@ "version": "0.2.0", "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.2.0.tgz", "integrity": "sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg==", - "dev": true, "optional": true, - "peer": true, "engines": { "node": ">= 4.0.0" } @@ -8471,9 +9318,7 @@ "version": "1.5.10", "resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.5.10.tgz", "integrity": "sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==", - "dev": true, "optional": true, - "peer": true, "dependencies": { "querystringify": "^2.1.1", "requires-port": "^1.0.0" @@ -8765,9 +9610,7 @@ "version": "7.0.0", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", - "dev": true, "optional": true, - "peer": true, "engines": { "node": ">=12" } @@ -8842,6 +9685,16 @@ "node": ">=8" } }, + "node_modules/wide-align": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/wide-align/-/wide-align-1.1.5.tgz", + "integrity": "sha512-eDMORYaPNZ4sQIuuYPDHdQvf4gyCF9rEEV/yPxGfwPkRodwEgiMUUXTx/dex+Me0wxx53S+NgUHaP7y3MGlDmg==", + "license": "ISC", + "optional": true, + "dependencies": { + "string-width": "^1.0.2 || 2 || 3 || 4" + } + }, "node_modules/wordwrap": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-1.0.0.tgz", @@ -8944,6 +9797,13 @@ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", "dev": true }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC", + "optional": true + }, "node_modules/ws": { "version": "8.17.1", "resolved": "https://registry.npmjs.org/ws/-/ws-8.17.1.tgz", @@ -8980,9 +9840,7 @@ "version": "2.2.0", "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", - "dev": true, - "optional": true, - "peer": true + "optional": true }, "node_modules/xmlhttprequest-ssl": { "version": "2.1.2", @@ -9001,6 +9859,13 @@ "node": ">=10" } }, + "node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "license": "ISC", + "optional": true + }, "node_modules/yaml": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.7.0.tgz", diff --git a/web/package.json b/web/package.json index 2ca8a71848..b457af0619 100644 --- a/web/package.json +++ b/web/package.json @@ -78,6 +78,7 @@ "@photo-sphere-viewer/video-plugin": "^5.11.5", "@zoom-image/svelte": "^0.3.0", "dom-to-image": "^2.6.0", + "fabric": "^6.5.4", "handlebars": "^4.7.8", "intl-messageformat": "^10.7.11", "lodash-es": "^4.17.21", diff --git a/web/src/lib/components/asset-viewer/detail-panel.svelte b/web/src/lib/components/asset-viewer/detail-panel.svelte index cdc00e247f..a83c5edd1e 100644 --- a/web/src/lib/components/asset-viewer/detail-panel.svelte +++ b/web/src/lib/components/asset-viewer/detail-panel.svelte @@ -25,7 +25,6 @@ type ExifResponseDto, } from '@immich/sdk'; import { - mdiAccountOff, mdiCalendar, mdiCameraIris, mdiClose, @@ -34,6 +33,7 @@ mdiImageOutline, mdiInformationOutline, mdiPencil, + mdiPlus, } from '@mdi/js'; import { DateTime } from 'luxon'; import { t } from 'svelte-i18n'; @@ -46,6 +46,7 @@ import AlbumListItemDetails from './album-list-item-details.svelte'; import Portal from '$lib/components/shared-components/portal/portal.svelte'; import { getMetadataSearchQuery } from '$lib/utils/metadata-search'; + import { isFaceEditMode } from '$lib/stores/face-edit.svelte'; interface Props { asset: AssetResponseDto; @@ -186,20 +187,11 @@ - {#if (!isSharedLink() && unassignedFaces.length > 0) || people.length > 0} + {#if !isSharedLink() && isOwner}

{$t('people').toUpperCase()}

- {#if unassignedFaces.length > 0} - - {/if} {#if people.some((person) => person.isHidden)} {/if} (showEditFaces = true)} + onclick={() => (isFaceEditMode.value = !isFaceEditMode.value)} /> + + {#if people.length > 0 || unassignedFaces.length > 0} + (showEditFaces = true)} + /> + {/if}
diff --git a/web/src/lib/components/asset-viewer/face-editor/face-editor.svelte b/web/src/lib/components/asset-viewer/face-editor/face-editor.svelte new file mode 100644 index 0000000000..fdf42000f0 --- /dev/null +++ b/web/src/lib/components/asset-viewer/face-editor/face-editor.svelte @@ -0,0 +1,310 @@ + + +
+ + +
+

Select a person to tag

+ +
+
+ {#each candidates as person} + + {/each} +
+
+ + +
+
diff --git a/web/src/lib/components/asset-viewer/photo-viewer.spec.ts b/web/src/lib/components/asset-viewer/photo-viewer.spec.ts index 31b690ad0c..d90fb89c23 100644 --- a/web/src/lib/components/asset-viewer/photo-viewer.spec.ts +++ b/web/src/lib/components/asset-viewer/photo-viewer.spec.ts @@ -7,6 +7,14 @@ import { sharedLinkFactory } from '@test-data/factories/shared-link-factory'; import { render } from '@testing-library/svelte'; import type { MockInstance } from 'vitest'; +class ResizeObserver { + observe() {} + unobserve() {} + disconnect() {} +} + +globalThis.ResizeObserver = ResizeObserver; + vi.mock('$lib/utils', async (originalImport) => { const meta = await originalImport(); return { diff --git a/web/src/lib/components/asset-viewer/photo-viewer.svelte b/web/src/lib/components/asset-viewer/photo-viewer.svelte index 2e0355611b..582f56fab3 100644 --- a/web/src/lib/components/asset-viewer/photo-viewer.svelte +++ b/web/src/lib/components/asset-viewer/photo-viewer.svelte @@ -2,7 +2,6 @@ import { shortcuts } from '$lib/actions/shortcut'; import { zoomImageAction, zoomed } from '$lib/actions/zoom-image'; import BrokenAsset from '$lib/components/assets/broken-asset.svelte'; - import { photoViewer } from '$lib/stores/assets.store'; import { boundingBoxesArray } from '$lib/stores/people.store'; import { alwaysLoadOriginalFile } from '$lib/stores/preferences.store'; import { SlideshowLook, SlideshowState, slideshowLookCssMapping, slideshowStore } from '$lib/stores/slideshow.store'; @@ -19,6 +18,9 @@ import LoadingSpinner from '../shared-components/loading-spinner.svelte'; import { NotificationType, notificationController } from '../shared-components/notification/notification'; import { handleError } from '$lib/utils/handle-error'; + import FaceEditor from '$lib/components/asset-viewer/face-editor/face-editor.svelte'; + import { photoViewerImgElement } from '$lib/stores/assets.store'; + import { isFaceEditMode } from '$lib/stores/face-edit.svelte'; interface Props { asset: AssetResponseDto; @@ -91,7 +93,7 @@ } try { - await copyImageToClipboard($photoViewer ?? assetFileUrl); + await copyImageToClipboard($photoViewerImgElement ?? assetFileUrl); notificationController.show({ type: NotificationType.Info, message: $t('copied_image_to_clipboard'), @@ -106,6 +108,12 @@ $zoomed = $zoomed ? false : true; }; + $effect(() => { + if (isFaceEditMode.value && $photoZoomState.currentZoom > 1) { + zoomToggle(); + } + }); + const onCopyShortcut = (event: KeyboardEvent) => { if (globalThis.getSelection()?.type === 'Range') { return; @@ -159,6 +167,9 @@ }); let imageLoaderUrl = $derived(getAssetUrl(asset.id, useOriginalImage, asset.thumbhash)); + + let containerWidth = $state(0); + let containerHeight = $state(0); -
+
{/if} {$getAltText(asset)} - {#each getBoundingBox($boundingBoxesArray, $photoZoomState, $photoViewer) as boundingbox} + {#each getBoundingBox($boundingBoxesArray, $photoZoomState, $photoViewerImgElement) as boundingbox}
{/each}
+ + {#if isFaceEditMode.value} + + {/if} {/if}
diff --git a/web/src/lib/components/elements/buttons/circle-icon-button.svelte b/web/src/lib/components/elements/buttons/circle-icon-button.svelte index 849c811d70..c243c06f92 100644 --- a/web/src/lib/components/elements/buttons/circle-icon-button.svelte +++ b/web/src/lib/components/elements/buttons/circle-icon-button.svelte @@ -64,7 +64,7 @@ transparent: 'bg-transparent hover:bg-[#d3d3d3] dark:text-immich-dark-fg', opaque: 'bg-transparent hover:bg-immich-bg/30 text-white hover:dark:text-white', light: 'bg-white hover:bg-[#d3d3d3]', - red: 'text-red-400 hover:bg-[#d3d3d3]', + red: 'text-red-400 bg-red-100 hover:bg-[#d3d3d3]', dark: 'bg-[#202123] hover:bg-[#d3d3d3]', alert: 'text-[#ff0000] hover:text-white', gray: 'bg-[#d3d3d3] hover:bg-[#e2e7e9] text-immich-dark-gray hover:text-black', diff --git a/web/src/lib/components/faces-page/assign-face-side-panel.svelte b/web/src/lib/components/faces-page/assign-face-side-panel.svelte index fe6a454307..59bcf6a84c 100644 --- a/web/src/lib/components/faces-page/assign-face-side-panel.svelte +++ b/web/src/lib/components/faces-page/assign-face-side-panel.svelte @@ -6,7 +6,7 @@ import { mdiArrowLeftThin, mdiClose, mdiMagnify, mdiPlus } from '@mdi/js'; import { linear } from 'svelte/easing'; import { fly } from 'svelte/transition'; - import { photoViewer } from '$lib/stores/assets.store'; + import { photoViewerImgElement } from '$lib/stores/assets.store'; import ImageThumbnail from '../assets/thumbnail/image-thumbnail.svelte'; import LoadingSpinner from '../shared-components/loading-spinner.svelte'; import SearchPeople from '$lib/components/faces-page/people-search.svelte'; @@ -62,7 +62,7 @@ const handleCreatePerson = async () => { const timeout = setTimeout(() => (isShowLoadingNewPerson = true), timeBeforeShowLoadingSpinner); - const newFeaturePhoto = await zoomImageToBase64(editedFace, assetId, assetType, $photoViewer); + const newFeaturePhoto = await zoomImageToBase64(editedFace, assetId, assetType, $photoViewerImgElement); onCreatePerson(newFeaturePhoto); diff --git a/web/src/lib/components/faces-page/person-side-panel.svelte b/web/src/lib/components/faces-page/person-side-panel.svelte index f2bab9996a..7bf87fc67b 100644 --- a/web/src/lib/components/faces-page/person-side-panel.svelte +++ b/web/src/lib/components/faces-page/person-side-panel.svelte @@ -13,9 +13,10 @@ AssetTypeEnum, type AssetFaceResponseDto, type PersonResponseDto, + deleteFace, } from '@immich/sdk'; import Icon from '$lib/components/elements/icon.svelte'; - import { mdiAccountOff, mdiArrowLeftThin, mdiPencil, mdiRestart } from '@mdi/js'; + import { mdiAccountOff, mdiArrowLeftThin, mdiPencil, mdiRestart, mdiTrashCan } from '@mdi/js'; import { onMount } from 'svelte'; import { linear } from 'svelte/easing'; import { fly } from 'svelte/transition'; @@ -24,8 +25,10 @@ import AssignFaceSidePanel from './assign-face-side-panel.svelte'; import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte'; import { zoomImageToBase64 } from '$lib/utils/people-utils'; - import { photoViewer } from '$lib/stores/assets.store'; + import { photoViewerImgElement } from '$lib/stores/assets.store'; import { t } from 'svelte-i18n'; + import { dialogController } from '$lib/components/shared-components/dialog/dialog'; + import { assetViewingStore } from '$lib/stores/asset-viewing.store'; interface Props { assetId: string; @@ -163,6 +166,30 @@ editedFace = face; showSelectedFaces = true; }; + + const deleteAssetFace = async (face: AssetFaceResponseDto) => { + try { + if (!face.person) { + return; + } + + const isConfirmed = await dialogController.show({ + prompt: $t('confirm_delete_face', { values: { name: face.person.name } }), + }); + + if (!isConfirmed) { + return; + } + + await deleteFace({ id: face.id, assetFaceDeleteDto: { force: false } }); + + peopleWithFaces = peopleWithFaces.filter((f) => f.id !== face.id); + + await assetViewingStore.setAssetId(assetId); + } catch (error) { + handleError(error, $t('error_delete_face')); + } + };
+ {#if face.person != null} +
+ deleteAssetFace(face)} + /> +
+ {/if}
{/each} diff --git a/web/src/lib/stores/assets.store.ts b/web/src/lib/stores/assets.store.ts index 4bdc2606fa..8e02562e85 100644 --- a/web/src/lib/stores/assets.store.ts +++ b/web/src/lib/stores/assets.store.ts @@ -148,7 +148,7 @@ interface UpdateStackAssets { values: string[]; } -export const photoViewer = writable(null); +export const photoViewerImgElement = writable(null); type PendingChange = AddAsset | UpdateAsset | DeleteAsset | TrashAssets | UpdateStackAssets; diff --git a/web/src/lib/stores/face-edit.svelte.ts b/web/src/lib/stores/face-edit.svelte.ts new file mode 100644 index 0000000000..0b2f436099 --- /dev/null +++ b/web/src/lib/stores/face-edit.svelte.ts @@ -0,0 +1 @@ +export const isFaceEditMode = $state({ value: false }); diff --git a/web/src/routes/(user)/photos/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/photos/[[assetId=id]]/+page.svelte index b485dd532b..ff99599c51 100644 --- a/web/src/routes/(user)/photos/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/photos/[[assetId=id]]/+page.svelte @@ -1,4 +1,5 @@ {#if assetInteraction.selectionActive} From 36ec407c66e989cf59c5df09d485672b813638c9 Mon Sep 17 00:00:00 2001 From: bo0tzz Date: Fri, 21 Feb 2025 17:02:24 +0100 Subject: [PATCH 191/395] fix: use correct head sha on PR commit tag (#16248) --- .github/workflows/docker.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 5429d8671d..d89a08cb40 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -250,6 +250,8 @@ jobs: - name: Generate docker image tags id: meta uses: docker/metadata-action@v5 + env: + DOCKER_METADATA_PR_HEAD_SHA: "true" with: flavor: | # Disable latest tag @@ -401,6 +403,8 @@ jobs: - name: Generate docker image tags id: meta uses: docker/metadata-action@v5 + env: + DOCKER_METADATA_PR_HEAD_SHA: "true" with: flavor: | # Disable latest tag From ca9e02379d35782f684c6881d3bdba2b24357fa6 Mon Sep 17 00:00:00 2001 From: bo0tzz Date: Fri, 21 Feb 2025 17:54:11 +0100 Subject: [PATCH 192/395] feat: remove preview label on pr close (#16249) --- .github/workflows/preview-comment.yaml | 17 ------------- .github/workflows/preview-label.yaml | 33 ++++++++++++++++++++++++++ 2 files changed, 33 insertions(+), 17 deletions(-) delete mode 100644 .github/workflows/preview-comment.yaml create mode 100644 .github/workflows/preview-label.yaml diff --git a/.github/workflows/preview-comment.yaml b/.github/workflows/preview-comment.yaml deleted file mode 100644 index f49c271fe5..0000000000 --- a/.github/workflows/preview-comment.yaml +++ /dev/null @@ -1,17 +0,0 @@ -name: Preview comment - -on: - pull_request: - types: [labeled] - -jobs: - comment-status: - runs-on: ubuntu-latest - if: ${{ github.event.label.name == 'preview' }} - permissions: - pull-requests: write - steps: - - uses: mshick/add-pr-comment@v2 - with: - message-id: "preview-status" - message: "Deploying preview environment to https://pr-${{ github.event.pull_request.number }}.preview.internal.immich.cloud/" diff --git a/.github/workflows/preview-label.yaml b/.github/workflows/preview-label.yaml new file mode 100644 index 0000000000..6468d05e80 --- /dev/null +++ b/.github/workflows/preview-label.yaml @@ -0,0 +1,33 @@ +name: Preview label + +on: + pull_request: + types: [labeled] + +jobs: + comment-status: + runs-on: ubuntu-latest + if: ${{ github.event.action == 'labeled' && github.event.label.name == 'preview' }} + permissions: + pull-requests: write + steps: + - uses: mshick/add-pr-comment@v2 + with: + message-id: "preview-status" + message: "Deploying preview environment to https://pr-${{ github.event.pull_request.number }}.preview.internal.immich.cloud/" + + remove-label: + runs-on: ubuntu-latest + if: ${{ github.event.action == 'closed' && contains(github.event.pull_request.labels.*.name, 'preview') }} + permissions: + pull-requests: write + steps: + - uses: actions/github-script@v7 + with: + script: | + github.rest.issues.removeLabel({ + issue_number: context.payload.pull_request.number, + owner: context.repo.owner, + repo: context.repo.repo, + name: 'preview' + }) From 502f6e020d05f3107d3b66d89be0da1c9ec4892d Mon Sep 17 00:00:00 2001 From: "Weblate (bot)" Date: Fri, 21 Feb 2025 18:30:19 +0100 Subject: [PATCH 193/395] chore(web): update translations (#15559) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: -J- Co-authored-by: 6Leoo6 Co-authored-by: Aldis Bārbelis Co-authored-by: Alessandro Iaselli Co-authored-by: Andrea Co-authored-by: Bezruchenko Simon Co-authored-by: Bora Atıcı Co-authored-by: CRY WHY Co-authored-by: Casper Ong Co-authored-by: Changhwan Kim Co-authored-by: Chris <6st6s7rgw@mozmail.com> Co-authored-by: Christoph Auer Co-authored-by: CodingDK Co-authored-by: Daniel Co-authored-by: Daniel A Co-authored-by: Daniel Correa Lobato Co-authored-by: David Lam Co-authored-by: Denis Pacquier Co-authored-by: Eitan Nargassi Co-authored-by: Fabian Tubbing Co-authored-by: Farid Co-authored-by: Fjuro Co-authored-by: Florian Ostertag Co-authored-by: Francesco Borio Co-authored-by: HanYuan Co-authored-by: Hurricane-32 Co-authored-by: Indrek Haav Co-authored-by: Jan Schwebel Co-authored-by: Jirapan Co-authored-by: Jiri Grönroos Co-authored-by: Jordy H Co-authored-by: Josep M. Ferrer Co-authored-by: Junghyuk Kwon Co-authored-by: Karol Klimczak Co-authored-by: Laurentiu Co-authored-by: Leo Bottaro Co-authored-by: Leonardo Patti Co-authored-by: Linerly Co-authored-by: Lukas Hamm Co-authored-by: Manar Aldroubi Co-authored-by: Mark Rieder Co-authored-by: Martin Popovski Co-authored-by: Matjaž T Co-authored-by: Max Lengerer Co-authored-by: Michel Heusschen <59014050+michelheusschen@users.noreply.github.com> Co-authored-by: Miki Mrvos Co-authored-by: Mohammed Al Otaibi Co-authored-by: Nicolò Co-authored-by: Oleh Horbachov Co-authored-by: Pablo Portas López Co-authored-by: Peder Vaagland Co-authored-by: Petri Hämäläinen Co-authored-by: Rafa Co-authored-by: Ram Sujith Reddibathini (Ram) Co-authored-by: Riccardo Co-authored-by: Rodrigo Bourbon Navarro Co-authored-by: Roi Gabay Co-authored-by: Rookie Nguyễn Co-authored-by: Runskrift Co-authored-by: Shawn Co-authored-by: Sylvain Pichon Co-authored-by: Theofilos Nikolaou Co-authored-by: Torin Wu Co-authored-by: Vegard Fladby Co-authored-by: Vladislav Tkalin Co-authored-by: Xo Co-authored-by: YapWC Co-authored-by: Zulhilmi Ramli Co-authored-by: anton garcias Co-authored-by: chamdim Co-authored-by: chapvic Co-authored-by: eav5jhl0 Co-authored-by: iancbogue Co-authored-by: intothevolt Co-authored-by: kiwinho Co-authored-by: krzemyk Co-authored-by: pierrebengtsson Co-authored-by: shiuh67 Co-authored-by: szelek Co-authored-by: thehijacker Co-authored-by: timmy61109 Co-authored-by: waclaw66 Co-authored-by: wickdj Co-authored-by: wojtasiq Co-authored-by: xmh10000 Co-authored-by: Вячеслав Лукьяненко Co-authored-by: Мĕтри Сантăр ывалĕ Упа-Миччи Co-authored-by: Zack Pollard --- i18n/af.json | 23 +- i18n/ar.json | 101 ++++++--- i18n/ca.json | 14 +- i18n/cs.json | 21 +- i18n/cv.json | 2 +- i18n/da.json | 93 ++++---- i18n/de.json | 37 ++-- i18n/el.json | 33 ++- i18n/en.json | 32 +-- i18n/es.json | 25 ++- i18n/et.json | 30 ++- i18n/fa.json | 2 +- i18n/fi.json | 4 +- i18n/fr.json | 27 ++- i18n/he.json | 167 +++++++------- i18n/hu.json | 18 +- i18n/id.json | 21 +- i18n/it.json | 45 ++-- i18n/ko.json | 25 ++- i18n/lt.json | 1 + i18n/lv.json | 14 +- i18n/mk.json | 305 ++++++++++++++++++++++++-- i18n/ms.json | 88 +++++++- i18n/nb_NO.json | 52 ++--- i18n/nl.json | 21 +- i18n/nn.json | 130 +++++++++-- i18n/pl.json | 27 ++- i18n/pt.json | 23 +- i18n/pt_BR.json | 53 +++-- i18n/ro.json | 10 + i18n/ru.json | 37 ++-- i18n/sk.json | 4 +- i18n/sl.json | 21 +- i18n/sr_Cyrl.json | 21 +- i18n/sr_Latn.json | 21 +- i18n/sv.json | 332 ++++++++++++++++++++-------- i18n/te.json | 2 + i18n/th.json | 469 +++++++++++++++++++++++++++++++++++----- i18n/tr.json | 23 +- i18n/uk.json | 21 +- i18n/vi.json | 2 + i18n/zh_Hant.json | 165 +++++++------- i18n/zh_SIMPLIFIED.json | 21 +- 43 files changed, 1927 insertions(+), 656 deletions(-) diff --git a/i18n/af.json b/i18n/af.json index fb4ae82741..92286a7f04 100644 --- a/i18n/af.json +++ b/i18n/af.json @@ -20,7 +20,7 @@ "add_partner": "Voeg vennoot by", "add_path": "Voeg pad by", "add_photos": "Voeg foto's by", - "add_to": "Voeg na...", + "add_to": "Voeg by…", "add_to_album": "Voeg na album", "add_to_shared_album": "Voeg na gedeelde album", "add_url": "Voeg URL by", @@ -57,6 +57,23 @@ "exclusion_pattern_description": "Met uitsluitingspatrone kan jy lêers en vouers ignoreer wanneer jy jou biblioteek skandeer. Dit is nuttig as jy vouers het wat lêers bevat wat jy nie wil invoer nie, soos RAW-lêers.", "external_library_created_at": "Eksterne biblioteek (geskep op {date})", "external_library_management": "Eksterne Biblioteek-opsies", - "face_detection": "Gesigsopsporing" - } + "face_detection": "Gesigsopsporing", + "failed_job_command": "Opdrag {command} het misluk vir werk: {job}", + "force_delete_user_warning": "WAARSKUWING: Dit sal onmiddellik die gebruiker en alle bates verwyder. Dit kan nie ontdoen word nie en die lêers kan nie herstel word nie.", + "forcing_refresh_library_files": "Forseer herlaai van alle biblioteeklêers", + "image_format": "Formaat", + "image_format_description": "WebP produseer kleiner lêers as JPEG, maar is stadiger om te enkodeer.", + "image_prefer_embedded_preview": "Verkies ingebedde voorskou", + "image_prefer_wide_gamut": "Verkies wye spektrum", + "image_preview_description": "Mediumgrootte prent met gestroopte metadata, wat gebruik word wanneer 'n enkele bate bekyk word en vir masjienleer", + "image_preview_quality_description": "Voorskou kwaliteit van 1-100. Hoër is beter, maar produseer groter lêers en kan app-reaksie verminder. Die stel van 'n lae waarde kan masjienleerkwaliteit beïnvloed.", + "image_preview_title": "Voorskou Instellings", + "image_quality": "Kwaliteit", + "image_resolution": "Resolusie", + "image_resolution_description": "Hoër resolusies kan meer detail bewaar, maar neem langer om te enkodeer, het groter lêergroottes en kan app-reaksie verminder.", + "image_settings": "Prent Instellings", + "image_settings_description": "Bestuur die kwaliteit en resolusie van gegenereerde beelde" + }, + "search_by_description": "Soek by beskrywing", + "search_by_description_example": "Stapdag in Sapa" } diff --git a/i18n/ar.json b/i18n/ar.json index 5c2b1a9506..b94a9bf08a 100644 --- a/i18n/ar.json +++ b/i18n/ar.json @@ -219,7 +219,7 @@ "reset_settings_to_default": "إعادة ضبط الإعدادات إلى الوضع الافتراضي", "reset_settings_to_recent_saved": "إعادة ضبط الإعدادات إلى الإعدادات المحفوظة مؤخرًا", "scanning_library": "مسح المكتبة", - "search_jobs": "البحث عن وظائف...", + "search_jobs": "البحث عن وظائف…", "send_welcome_email": "إرسال بريد ترحيبي", "server_external_domain_settings": "إسم النطاق الخارجي", "server_external_domain_settings_description": "إسم النطاق لروابط المشاركة العامة، بما في ذلك http(s)://", @@ -250,8 +250,16 @@ "storage_template_user_label": "{label} هو تسمية التخزين الخاصة بالمستخدم", "system_settings": "إعدادات النظام", "tag_cleanup_job": "تنظيف العلامة", + "template_email_available_tags": "يمكنك استخدام المتغيرات التالية في القالب الخاص بك: {tags}", + "template_email_if_empty": "إذا كان القالب فارغا، فسيتم استخدام البريد الإلكتروني الافتراضي.", + "template_email_invite_album": "قالب دعوة الألبوم", "template_email_preview": "عرض مسبق", "template_email_settings": "نماذج البريد الالكتروني", + "template_email_settings_description": "إدارة قوالب إشعارات البريد الإلكتروني المخصصة", + "template_email_update_album": "تحديث قالب الألبوم", + "template_email_welcome": "قالب البريد الإلكتروني الترحيبي", + "template_settings": "قوالب الإشعارات", + "template_settings_description": "إدارة القوالب المخصصة للإشعارات.", "theme_custom_css_settings": "CSS مخصص", "theme_custom_css_settings_description": "أوراق الأنماط المتتالية تسمح بتخصيص تصميم Immich.", "theme_settings": "إعدادات السمة", @@ -281,6 +289,8 @@ "transcoding_constant_rate_factor": "عامل معدل الجودة الثابت (-crf)", "transcoding_constant_rate_factor_description": "مستوى جودة الفيديو. القيم النموذجية هي 23 لـ H.264، 28 لـ HEVC، 31 لـ VP9، و 35 لـ AV1. كلما كانت القيمة أقل كان ذلك أفضل، ولكن يؤدي إلى ملفات أكبر.", "transcoding_disabled_description": "لا تقم بتحويل أي مقاطع فيديو، قد تؤدي إلى عدم تشغيلها على بعض العملاء", + "transcoding_encoding_options": "خيارات الترميز", + "transcoding_encoding_options_description": "اضبط برامج الترميز والدقة والجودة والخيارات الأخرى لمقاطع الفيديو المشفرة", "transcoding_hardware_acceleration": "التسريع العتادي", "transcoding_hardware_acceleration_description": "تجريبي؛ أسرع بكثير، ولكن ستكون جودتها أقل عند نفس معدل البت", "transcoding_hardware_decoding": "فك تشفير الأجهزة", @@ -293,6 +303,8 @@ "transcoding_max_keyframe_interval": "الحد الأقصى للفاصل الزمني للإطار الرئيسي", "transcoding_max_keyframe_interval_description": "يضبط الحد الأقصى لمسافة الإطار بين الإطارات الرئيسية. تؤدي القيم المنخفضة إلى زيادة سوء كفاءة الضغط، ولكنها تعمل على تحسين أوقات البحث وقد تعمل على تحسين الجودة في المشاهد ذات الحركة السريعة. 0 يضبط هذه القيمة تلقائيًا.", "transcoding_optimal_description": "مقاطع الفيديو ذات الدقة الأعلى من الدقة المستهدفة أو بتنسيق غير مقبول", + "transcoding_policy": "سياسة تحويل الترميز", + "transcoding_policy_description": "اضبط متى سيتم تحويل ترميز الفيديو", "transcoding_preferred_hardware_device": "الجهاز المفضل", "transcoding_preferred_hardware_device_description": "ينطبق فقط على VAAPI وQSV. يضبط عقدة dri المستخدمة لتحويل ترميز الأجهزة.", "transcoding_preset_preset": "الضبط المُسبق (-preset)", @@ -301,7 +313,7 @@ "transcoding_reference_frames_description": "عدد الإطارات التي يجب الرجوع إليها عند ضغط إطار معين. تعمل القيم الأعلى على تحسين كفاءة الضغط، ولكنها تبطئ عملية التشفير. 0 يضبط هذه القيمة تلقائيًا.", "transcoding_required_description": "فقط مقاطع الفيديو ذات التنسيق غير المقبول", "transcoding_settings": "إعدادات تحويل ترميز الفيديو", - "transcoding_settings_description": "إدارة معلومات الدقة والترميز لملفات الفيديو", + "transcoding_settings_description": "إدارة مقاطع الفيديو التي يجب تحويل ترميزها وكيفية معالجتها", "transcoding_target_resolution": "القرار المستهدف", "transcoding_target_resolution_description": "يمكن أن تحافظ الدقة الأعلى على المزيد من التفاصيل ولكنها تستغرق وقتًا أطول للتشفير، ولها أحجام ملفات أكبر، ويمكن أن تقلل من استجابة التطبيق.", "transcoding_temporal_aq": "التكميم التكيفي الزمني", @@ -314,7 +326,7 @@ "transcoding_transcode_policy_description": "سياسة تحديد متى يجب ترميز الفيديو. سيتم دائمًا ترميز مقاطع الفيديو HDR (ما لم يتم تعطيل الترميز).", "transcoding_two_pass_encoding": "الترميز بمرورين", "transcoding_two_pass_encoding_setting_description": "ترميز بمرورين لإنتاج مقاطع فيديو بترميز أفضل. عند تمكين الحد الأقصى لمعدل البت (مطلوب لكي يعمل مع H.264 و HEVC)، يستخدم هذا الوضع نطاق معدل البت استنادًا إلى الحد الأقصى لمعدل البت ويتجاهل CRF. بالنسبة لـ VP9، يمكن استخدام CRF إذا تم تعطيل الحد الأقصى لمعدل البت.", - "transcoding_video_codec": "كود الفيديو", + "transcoding_video_codec": "ترميز الفيديو", "transcoding_video_codec_description": "يتمتع VP9 بكفاءة عالية وتوافق مع الويب، ولكنه يستغرق وقتًا أطول في تحويل التعليمات البرمجية. يعمل HEVC بشكل مشابه، لكن توافقه مع الويب أقل. H.264 متوافق على نطاق واسع وسريع في تحويل التعليمات البرمجية، ولكنه ينتج ملفات أكبر بكثير. AV1 هو برنامج الترميز الأكثر كفاءة ولكنه يفتقر إلى الدعم على الأجهزة القديمة.", "trash_enabled_description": "تفعيل ميزات سلة المهملات", "trash_number_of_days": "عدد الأيام", @@ -394,17 +406,17 @@ "are_these_the_same_person": "هل هؤلاء هم نفس الشخص؟", "are_you_sure_to_do_this": "هل انت متأكد من أنك تريد أن تفعل هذا؟", "asset_added_to_album": "تمت إضافته إلى الألبوم", - "asset_adding_to_album": "جارٍ الإضافة إلى الألبوم...", + "asset_adding_to_album": "جارٍ الإضافة إلى الألبوم…", "asset_description_updated": "تم تحديث وصف المحتوى", "asset_filename_is_offline": "الأصل {filename} غير متصل", "asset_has_unassigned_faces": "يحتوي الأصل على وجوه غير مخصصة", - "asset_hashing": "التجزئة...", + "asset_hashing": "التجزئة…", "asset_offline": "المحتوى غير اتصال", "asset_offline_description": "لم يعد هذا الأصل الخارجي موجودًا على القرص. يرجى الاتصال بمسؤول Immich للحصول على المساعدة.", "asset_skipped": "تم تخطيه", "asset_skipped_in_trash": "في سلة المهملات", "asset_uploaded": "تم الرفع", - "asset_uploading": "جارٍ الرفع...", + "asset_uploading": "جارٍ الرفع…", "assets": "المحتويات", "assets_added_count": "تمت إضافة {count, plural, one {# محتوى} other {# محتويات}}", "assets_added_to_album_count": "تمت إضافة {count, plural, one {# الأصل} other {# الأصول}} إلى الألبوم", @@ -511,6 +523,10 @@ "date_range": "نطاق الموعد", "day": "يوم", "deduplicate_all": "إلغاء تكرار الكل", + "deduplication_criteria_1": "حجم الصورة بوحدات البايت", + "deduplication_criteria_2": "عدد بيانات EXIF", + "deduplication_info": "معلومات إلغاء البيانات المكررة", + "deduplication_info_description": "لتحديد الأصول مسبقا تلقائيا وإزالة التكرارات بكميات كبيرة، ننظر إلى:", "default_locale": "اللغة الافتراضية", "default_locale_description": "تنسيق التواريخ والأرقام بناءً على لغة المتصفح الخاص بك", "delete": "حذف", @@ -726,6 +742,7 @@ "external": "خارجي", "external_libraries": "المكتبات الخارجية", "face_unassigned": "غير معين", + "failed_to_load_assets": "فشل تحميل الأصول", "favorite": "مفضل", "favorite_or_unfavorite_photo": "تفضيل أو إلغاء تفضيل الصورة", "favorites": "المفضلة", @@ -746,10 +763,13 @@ "get_help": "الحصول على المساعدة", "getting_started": "البدء", "go_back": "الرجوع للخلف", + "go_to_folder": "اذهب إلى المجلد", "go_to_search": "اذهب إلى البحث", "group_albums_by": "تجميع الألبومات حسب...", + "group_country": "مجموعة البلد", "group_no": "بدون تجميع", "group_owner": "تجميع حسب المالك", + "group_places_by": "تجميع الأماكن حسب", "group_year": "تجميع حسب السنة", "has_quota": "محدد بحصة", "hi_user": "مرحبا {name} ({email})", @@ -782,6 +802,7 @@ "include_shared_albums": "تضمين الألبومات المشتركة", "include_shared_partner_assets": "تضمين محتويات الشريك المشتركة", "individual_share": "حصة فردية", + "individual_shares": "المشاركات الفردية", "info": "معلومات", "interval": { "day_at_onepm": "كل يوم الساعة الواحدة ظهرا", @@ -804,6 +825,7 @@ "latest_version": "احدث اصدار", "latitude": "خط العرض", "leave": "مغادرة", + "lens_model": "نموذج العدسات", "let_others_respond": "دع الآخرين يستجيبون", "level": "المستوى", "library": "مكتبة", @@ -966,6 +988,7 @@ "pick_a_location": "اختر موقعًا", "place": "مكان", "places": "الأماكن", + "places_count": "{count, plural, one {{count, number} مكان} other {{count, number} أماكن}}", "play": "تشغيل", "play_memories": "تشغيل الذكريات", "play_motion_photo": "تشغيل الصور المتحركة", @@ -1025,6 +1048,7 @@ "reassigned_assets_to_new_person": "تمت إعادة تعيين {count, plural, one {# المحتوى} other {# المحتويات}} إلى شخص جديد", "reassing_hint": "تعيين المحتويات المحددة لشخص موجود", "recent": "حديث", + "recent-albums": "ألبومات الحديثة", "recent_searches": "عمليات البحث الأخيرة", "refresh": "تحديث", "refresh_encoded_videos": "تحديث مقاطع الفيديو المشفرة", @@ -1046,6 +1070,7 @@ "remove_from_album": "إزالة من الألبوم", "remove_from_favorites": "إزالة من المفضلة", "remove_from_shared_link": "إزالة من الرابط المشترك", + "remove_url": "إزالة عنوان URL", "remove_user": "إزالة المستخدم", "removed_api_key": "تم إزالة مفتاح API: {name}", "removed_from_archive": "تمت إزالتها من الأرشيف", @@ -1084,15 +1109,18 @@ "scan_library": "مسح", "scan_settings": "إعدادات الفحص", "scanning_for_album": "جارٍ الفحص عن ألبوم...", - "search": "بحث", - "search_albums": "بحث في الألبومات", + "search": "البحث", + "search_albums": "البحث في الألبومات", "search_by_context": "البحث حسب السياق", - "search_by_filename": "إبحث بإسم الملف أو نوعه", + "search_by_description": "البحث حسب الوصف", + "search_by_description_example": "يوم المشي لمسافات طويلة في سابا", + "search_by_filename": "البحث بإسم الملف أو نوعه", "search_by_filename_example": "كـ IMG_1234.JPG أو PNG", "search_camera_make": "البحث حسب الشركة المصنعة للكاميرا...", "search_camera_model": "البحث حسب موديل الكاميرا...", "search_city": "البحث حسب المدينة...", "search_country": "البحث حسب الدولة...", + "search_for": "البحث عن", "search_for_existing_person": "البحث عن شخص موجود", "search_no_people": "لا يوجد أشخاص", "search_no_people_named": "لا يوجد أشخاص بالاسم \"{name}\"", @@ -1104,36 +1132,37 @@ "search_tags": "البحث عن العلامات...", "search_timezone": "البحث حسب المنطقة الزمنية...", "search_type": "نوع البحث", - "search_your_photos": "ابحث عن صورك", + "search_your_photos": "البحث عن صورك", "searching_locales": "جارٍ البحث في اللغات...", "second": "ثانية", "see_all_people": "عرض جميع الأشخاص", - "select_album_cover": "حدد غلاف الألبوم", + "select_album_cover": "تحديد غلاف الألبوم", "select_all": "تحديد الكل", "select_all_duplicates": "تحديد جميع النسخ المكررة", - "select_avatar_color": "حدد لون الصورة الشخصية", - "select_face": "اختيار وجه", - "select_featured_photo": "حدد الصورة المميزة", - "select_from_computer": "اختر من الجهاز", - "select_keep_all": "حدد الاحتفاظ بالكل", - "select_library_owner": "اختر مالِك المكتبة", - "select_new_face": "اختيار وجه جديد", - "select_photos": "حدد الصور", - "select_trash_all": "حدّد حذف الكلِ", - "selected": "المُحدّد", + "select_avatar_color": "تحديد لون الصورة الشخصية", + "select_face": "تحديد وجه", + "select_featured_photo": "تحديد الصورة المميزة", + "select_from_computer": "تحديد من الحاسب الآلي", + "select_keep_all": "تحديد الأحتفاظ بالكل", + "select_library_owner": "تحديد مالِك المكتبة", + "select_new_face": "تحديد وجه جديد", + "select_photos": "تحديد الصور", + "select_trash_all": "تحديد حذف الكلِ", + "selected": "التحديد", "selected_count": "{count, plural, other {# محددة }}", - "send_message": "أرسل رسالة", - "send_welcome_email": "أرسل بريدًا إلكترونيًا ترحيبيًا", + "send_message": "‏إرسال رسالة", + "send_welcome_email": "إرسال بريدًا إلكترونيًا ترحيبيًا", "server_offline": "الخادم غير متصل", "server_online": "الخادم متصل", "server_stats": "إحصائيات الخادم", "server_version": "إصدار الخادم", - "set": "تعيين", - "set_as_album_cover": "تعيين كغلاف للألبوم", - "set_as_profile_picture": "تعيين كصورة الملف الشخصي", + "set": "‏تحديد", + "set_as_album_cover": "تحديد كغلاف للألبوم", + "set_as_featured_photo": "تحديد كصورة مميزة", + "set_as_profile_picture": "تحديد كصورة الملف الشخصي", "set_date_of_birth": "تحديد تاريخ الميلاد", - "set_profile_picture": "تعيين صورة الملف الشخصي", - "set_slideshow_to_fullscreen": "اضبط عرض الشرائح على وضع ملء الشاشة", + "set_profile_picture": "تحديد صورة الملف الشخصي", + "set_slideshow_to_fullscreen": "تحديد عرض الشرائح على وضع ملء الشاشة", "settings": "الإعدادات", "settings_saved": "تم حفظ الإعدادات", "share": "مشاركة", @@ -1144,6 +1173,7 @@ "shared_from_partner": "صور من {partner}", "shared_link_options": "خيارات الرابط المشترك", "shared_links": "روابط مشتركة", + "shared_links_description": "وصف الروابط المشتركة", "shared_photos_and_videos_count": "{assetCount, plural, other {# الصور ومقاطع الفيديو المُشارَكة.}}", "shared_with_partner": "تمت المشاركة مع {partner}", "sharing": "مشاركة", @@ -1155,17 +1185,18 @@ "show_all_people": "إظهار جميع الأشخاص", "show_and_hide_people": "إظهار وإخفاء الأشخاص", "show_file_location": "إظهار موقع الملف", - "show_gallery": "عرض المعرض", + "show_gallery": "إظهار المعرض", "show_hidden_people": "إظهار الأشخاص المخفيين", - "show_in_timeline": "عرض في المخطط الزمني", - "show_in_timeline_setting_description": "عرض الصور ومقاطع الفيديو من هذا المستخدم في المخطط الزمني الخاص بك", + "show_in_timeline": "إظهار في المخطط الزمني", + "show_in_timeline_setting_description": "إظهار الصور ومقاطع الفيديو من هذا المستخدم في المخطط الزمني الخاص بك", "show_keyboard_shortcuts": "إظهار اختصارات لوحة المفاتيح", - "show_metadata": "عرض البيانات الوصفية", + "show_metadata": "إظهار البيانات الوصفية", "show_or_hide_info": "إظهار أو إخفاء المعلومات", - "show_password": "عرض كلمة المرور", + "show_password": "إظهار كلمة المرور", "show_person_options": "إظهار خيارات الشخص", "show_progress_bar": "إظهار شريط التقدم", "show_search_options": "إظهار خيارات البحث", + "show_shared_links": "عرض الروابط المشتركة", "show_slideshow_transition": "إظهار انتقال عرض الشرائح", "show_supporter_badge": "شارة المؤيد", "show_supporter_badge_description": "إظهار شارة المؤيد", @@ -1173,7 +1204,7 @@ "sidebar": "الشريط الجانبي", "sidebar_display_description": "عرض رابط للعرض في الشريط الجانبي", "sign_out": "خروج", - "sign_up": "تسجيل", + "sign_up": "التسجيل", "size": "الحجم", "skip_to_content": "تخطي إلى المحتوى", "skip_to_folders": "تخطي إلى المجلدات", @@ -1185,6 +1216,7 @@ "sort_items": "عدد العناصر", "sort_modified": "تم تعديل التاريخ", "sort_oldest": "أقدم صورة", + "sort_people_by_similarity": "رتب الأشخاص حسب التشابه", "sort_recent": "أحدث صورة", "sort_title": "العنوان", "source": "المصدر", @@ -1252,6 +1284,7 @@ "unfavorite": "أزل التفضيل", "unhide_person": "أظهر الشخص", "unknown": "غير معروف", + "unknown_country": "بلد غير معروف", "unknown_year": "سنة غير معروفة", "unlimited": "غير محدود", "unlink_motion_video": "إلغاء ربط فيديو الحركة", diff --git a/i18n/ca.json b/i18n/ca.json index 7d0a538f5a..ff513d9683 100644 --- a/i18n/ca.json +++ b/i18n/ca.json @@ -20,7 +20,7 @@ "add_partner": "Afegir company/a", "add_path": "Afegir una ruta", "add_photos": "Afegir fotografies", - "add_to": "Afegir a...", + "add_to": "Afegir a…", "add_to_album": "Afegir a un l'àlbum", "add_to_shared_album": "Afegir a un àlbum compartit", "add_url": "Afegir URL", @@ -219,7 +219,7 @@ "reset_settings_to_default": "Restablir configuracions per defecte", "reset_settings_to_recent_saved": "Restablir la configuració guardada més recent", "scanning_library": "Escanejant biblioteca", - "search_jobs": "Tasques de cerca...", + "search_jobs": "Cercar treballs…", "send_welcome_email": "Enviar correu electrònic de benvinguda", "server_external_domain_settings": "Domini extern", "server_external_domain_settings_description": "Domini per enllaços públics compartits, incloent http(s)://", @@ -406,17 +406,17 @@ "are_these_the_same_person": "Són la mateixa persona?", "are_you_sure_to_do_this": "Esteu segurs que voleu fer-ho?", "asset_added_to_album": "Afegit a l'àlbum", - "asset_adding_to_album": "Afegint a l'àlbum...", + "asset_adding_to_album": "Afegint a l'àlbum…", "asset_description_updated": "La descripció del recurs s'ha actualitzat", "asset_filename_is_offline": "L'element {filename} està fora de línia", "asset_has_unassigned_faces": "L'element té cares no assignades", - "asset_hashing": "Hashing...", + "asset_hashing": "Hashing…", "asset_offline": "Element fora de línia", "asset_offline_description": "Aquest recurs extern ja no es troba al disc. Poseu-vos en contacte amb el vostre administrador d'Immich per obtenir ajuda.", "asset_skipped": "Saltat", "asset_skipped_in_trash": "A la paperera", "asset_uploaded": "Carregat", - "asset_uploading": "S'està carregant...", + "asset_uploading": "S'està carregant…", "assets": "Elements", "assets_added_count": "{count, plural, one {Afegit un element} other {Afegits # elements}}", "assets_added_to_album_count": "{count, plural, one {Afegit un element} other {Afegits # elements}} a l'àlbum", @@ -822,6 +822,7 @@ "latest_version": "Última versió", "latitude": "Latitud", "leave": "Marxar", + "lens_model": "Model de lents", "let_others_respond": "Deixa que els altres responguin", "level": "Nivell", "library": "Bibilioteca", @@ -1094,7 +1095,7 @@ "review_duplicates": "Revisar duplicats", "role": "Rol", "role_editor": "Editor", - "role_viewer": "Visor", + "role_viewer": "Visualitzador", "save": "Desa", "saved_api_key": "Clau d'API guardada", "saved_profile": "Perfil guardat", @@ -1113,6 +1114,7 @@ "search_camera_model": "Buscar per model de càmera...", "search_city": "Buscar per ciutat...", "search_country": "Buscar per país...", + "search_for": "Cercar", "search_for_existing_person": "Busca una persona existent", "search_no_people": "Cap persona", "search_no_people_named": "Cap persona anomenada \"{name}\"", diff --git a/i18n/cs.json b/i18n/cs.json index fbfbdf3bfc..9551ba0b9e 100644 --- a/i18n/cs.json +++ b/i18n/cs.json @@ -20,7 +20,7 @@ "add_partner": "Přidat partnera", "add_path": "Přidat cestu", "add_photos": "Přidat fotky", - "add_to": "Přidat do...", + "add_to": "Přidat do…", "add_to_album": "Přidat do alba", "add_to_shared_album": "Přidat do sdíleného alba", "add_url": "Přidat URL", @@ -219,7 +219,7 @@ "reset_settings_to_default": "Obnovení výchozího nastavení", "reset_settings_to_recent_saved": "Obnovit poslední uložené nastavení", "scanning_library": "Prohledat knihovnu", - "search_jobs": "Hledat úlohy...", + "search_jobs": "Hledat úlohy…", "send_welcome_email": "Odeslat uvítací e-mail", "server_external_domain_settings": "Externí doména", "server_external_domain_settings_description": "Doména pro veřejně sdílené odkazy, včetně http(s)://", @@ -406,17 +406,17 @@ "are_these_the_same_person": "Jedná se o stejnou osobu?", "are_you_sure_to_do_this": "Opravdu to chcete udělat?", "asset_added_to_album": "Přidáno do alba", - "asset_adding_to_album": "Přidávání do alba...", + "asset_adding_to_album": "Přidávání do alba…", "asset_description_updated": "Popis položky byl aktualizován", "asset_filename_is_offline": "Položka {filename} je offline", "asset_has_unassigned_faces": "Položka má nepřiřazené obličeje", - "asset_hashing": "Hashování...", + "asset_hashing": "Hashování…", "asset_offline": "Offline položka", "asset_offline_description": "Toto externí položka se již na disku nenachází. Obraťte se na Immich správce a požádejte o pomoc.", "asset_skipped": "Přeskočeno", "asset_skipped_in_trash": "V koši", "asset_uploaded": "Nahráno", - "asset_uploading": "Nahrávání...", + "asset_uploading": "Nahrávání…", "assets": "Položky", "assets_added_count": "{count, plural, one {Přidána # položka} few {Přidány # položky} other {Přidáno # položek}}", "assets_added_to_album_count": "Do alba {count, plural, one {byla přidána # položka} few {byly přidány # položky} other {bylo přidáno # položek}}", @@ -766,8 +766,10 @@ "go_to_folder": "Přejít do složky", "go_to_search": "Přejít na vyhledávání", "group_albums_by": "Seskupit alba podle...", + "group_country": "Seskupit podle země", "group_no": "Neseskupovat", "group_owner": "Seskupit podle uživatele", + "group_places_by": "Seskupit místa podle...", "group_year": "Seskupit podle roku", "has_quota": "Má kvótu", "hi_user": "Ahoj {name} ({email})", @@ -800,6 +802,7 @@ "include_shared_albums": "Včetně sdílených alb", "include_shared_partner_assets": "Včetně sdílených položek partnera", "individual_share": "Sdílení jednotlivých položek", + "individual_shares": "Sdílení jednotlivých položek", "info": "Informace", "interval": { "day_at_onepm": "Každý den ve 13:00", @@ -822,6 +825,7 @@ "latest_version": "Nejnovější verze", "latitude": "Zeměpisná šířka", "leave": "Opustit", + "lens_model": "Model objektivu", "let_others_respond": "Nechte ostatní reagovat", "level": "Úroveň", "library": "Knihovna", @@ -984,6 +988,7 @@ "pick_a_location": "Vyberte polohu", "place": "Místo", "places": "Místa", + "places_count": "{count, plural, one {{count, number} místo} few {{count, number} místa} other {{count, number} míst}}", "play": "Přehrávat", "play_memories": "Přehrát vzpomníky", "play_motion_photo": "Přehrát pohybovou fotografii", @@ -1107,12 +1112,15 @@ "search": "Hledat", "search_albums": "Vyhledávejte alba", "search_by_context": "Vyhledávání podle obsahu", + "search_by_description": "Vyhledávat podle popisu", + "search_by_description_example": "Pěší turistika v Sapě", "search_by_filename": "Vyhledávání podle názvu nebo přípony souboru", "search_by_filename_example": "např. IMG_1234.JPG nebo PNG", "search_camera_make": "Vyhledat výrobce fotoaparátu...", "search_camera_model": "Vyhledat model fotoaparátu...", "search_city": "Vyhledat město...", "search_country": "Vyhledat zemi...", + "search_for": "Vyhledat", "search_for_existing_person": "Vyhledat existující osobu", "search_no_people": "Žádní lidé", "search_no_people_named": "Žádní lidé se jménem \"{name}\"", @@ -1165,6 +1173,7 @@ "shared_from_partner": "Fotky od {partner}", "shared_link_options": "Možnosti sdíleného odkazu", "shared_links": "Sdílené odkazy", + "shared_links_description": "Sdílet fotky a videa pomocí odkazu", "shared_photos_and_videos_count": "{assetCount, plural, one {# sdílená fotografie a video.} few {# sdílené fotografie a videa.} other {# sdílených fotografií a videí.}}", "shared_with_partner": "Sdíleno s {partner}", "sharing": "Sdílení", @@ -1187,6 +1196,7 @@ "show_person_options": "Zobrazit možnosti osoby", "show_progress_bar": "Zobrazit ukazatel průběhu", "show_search_options": "Zobrazit možnosti vyhledávání", + "show_shared_links": "Zobrazit sdílené odkazy", "show_slideshow_transition": "Zobrazit přechod prezentace", "show_supporter_badge": "Odznak podporovatele", "show_supporter_badge_description": "Zobrazit odznak podporovatele", @@ -1274,6 +1284,7 @@ "unfavorite": "Zrušit oblíbení", "unhide_person": "Zrušit skrytí osoby", "unknown": "Neznámý", + "unknown_country": "Neznámá země", "unknown_year": "Neznámý rok", "unlimited": "Neomezeně", "unlink_motion_video": "Odpojit pohyblivé video", diff --git a/i18n/cv.json b/i18n/cv.json index 19a7a86ae4..9010911c25 100644 --- a/i18n/cv.json +++ b/i18n/cv.json @@ -20,7 +20,7 @@ "add_partner": "Мӑшӑр хуш", "add_path": "Ҫулне хуш", "add_photos": "Сӑнӳкерчӗксем хуш", - "add_to": "Мӗн те пулин хуш...", + "add_to": "Мӗн те пулин хуш…", "add_to_album": "Альбома хуш", "add_to_shared_album": "Пӗрлехи альбома хуш", "add_url": "URL хушӑр", diff --git a/i18n/da.json b/i18n/da.json index b1fd80ff0a..ec05232e6f 100644 --- a/i18n/da.json +++ b/i18n/da.json @@ -7,7 +7,7 @@ "actions": "Handlinger", "active": "Aktive", "activity": "Aktivitet", - "activity_changed": "Aktivitet er {aktiveret, vælg, sandt {aktiveret} andet {deaktiveret}}", + "activity_changed": "Aktivitet er {enabled, select, true {aktiveret} other {deaktiveret}}", "add": "Tilføj", "add_a_description": "Tilføj en beskrivelse", "add_a_location": "Tilføj en placering", @@ -20,7 +20,7 @@ "add_partner": "Tilføj partner", "add_path": "Tilføj sti", "add_photos": "Tilføj billeder", - "add_to": "Tilføj til...", + "add_to": "Tilføj til…", "add_to_album": "Tilføj til album", "add_to_shared_album": "Tilføj til delt album", "add_url": "Tilføj URL", @@ -219,7 +219,7 @@ "reset_settings_to_default": "Nulstil indstillingerne til standard", "reset_settings_to_recent_saved": "Nulstil indstillinger til de senest gemte indstillinger", "scanning_library": "Scanner bibliotek", - "search_jobs": "søg opgaver ..", + "search_jobs": "Søg opgaver…", "send_welcome_email": "Send velkomst-email", "server_external_domain_settings": "Eksternt domæne", "server_external_domain_settings_description": "Domæne til offentligt delte links, inklusiv http(s)://", @@ -360,9 +360,9 @@ "admin_password": "Administratoradgangskode", "administration": "Administration", "advanced": "Avanceret", - "age_months": "Alder {months, plural, one {# month} other {# months}}", - "age_year_months": "Alder 1 år, {måneder, flertal, en {# måned} flere {# months}}", - "age_years": "{år, år, andre {Alder #}}", + "age_months": "Alder {months, plural, one {# måned} other {# måneder}}", + "age_year_months": "Alder 1 år, {months, plural, one {# måned} other {# måneder}}", + "age_years": "{years, plural, other {Alder #}}", "album_added": "Album tilføjet", "album_added_notification_setting_description": "Modtag en emailnotifikation når du bliver tilføjet til en delt album", "album_cover_updated": "Albumcover opdateret", @@ -402,33 +402,33 @@ "archive_or_unarchive_photo": "Arkivér eller dearkivér billede", "archive_size": "Arkiv størelse", "archive_size_description": "Konfigurer arkivstørrelsen for downloads (i GiB)", - "archived_count": "{antal, flertal, andet {Arkiveret #}}", + "archived_count": "{count, plural, other {Arkiveret #}}", "are_these_the_same_person": "Er disse den samme person?", "are_you_sure_to_do_this": "Er du sikker på, at du vil gøre det her?", "asset_added_to_album": "Tilføjet til album", - "asset_adding_to_album": "Tilføjer til album...", + "asset_adding_to_album": "Tilføjer til album…", "asset_description_updated": "Mediefilsbeskrivelse er blevet opdateret", "asset_filename_is_offline": "Mediefil {filename} er offline", "asset_has_unassigned_faces": "Aktivet har ikke-tildelte ansigter", - "asset_hashing": "Hashing...", + "asset_hashing": "Hashing…", "asset_offline": "Mediefil offline", "asset_offline_description": "Denne eksterne mediefil kan ikke længere findes på drevet. Kontakt venligst din Immich-administrator for hjælp.", "asset_skipped": "Sprunget over", "asset_skipped_in_trash": "I skraldespand", - "asset_uploaded": "Uploaded", - "asset_uploading": "Uploader...", + "asset_uploaded": "Uploadet", + "asset_uploading": "Uploader…", "assets": "elementer", "assets_added_count": "Tilføjet {count, plural, one {# mediefil} other {# mediefiler}}", - "assets_added_to_album_count": "Tilføjet {count, plural, one {# mediefil} other {# mediefiler}} til albummet", + "assets_added_to_album_count": "{count, plural, one {# mediefil} other {# mediefiler}} tilføjet til albummet", "assets_added_to_name_count": "Tilføjet {count, plural, one {# mediefil} other {# mediefiler}} til {hasName, select, true {{name}} other {nyt album}}", "assets_count": "{count, plural, one {# mediefil} other {# mediefiler}}", "assets_moved_to_trash_count": "Flyttede {count, plural, one {# mediefil} other {# mediefiler}} til papirkurven", - "assets_permanently_deleted_count": "Slettet permanent {count, plural, one {# mediefil} other {# mediefiler}}", + "assets_permanently_deleted_count": "{count, plural, one {# mediefil} other {# mediefiler}} slettet permanent", "assets_removed_count": "Fjernede {count, plural, one {# mediefil} other {# mediefiler}}", - "assets_restore_confirmation": "Er du sikker på, at du vil gendanne alle dine aktiver i papirkurven? Du kan ikke fortryde denne handling! Bemærk, at offline mediefiler ikke kan gendannes på denne måde.", - "assets_restored_count": "Gendannet {count, plural, one {# mediefil} other {# mediefiler}}", - "assets_trashed_count": "Smidt {count, plural, one {# mediefil} other {# mediefiler}} i papirkurven", - "assets_were_part_of_album_count": "{count, plural, one {Asset was} other {Assets were}} er allerede en del af albummet", + "assets_restore_confirmation": "Er du sikker på, at du vil gendanne alle dine mediafiler i papirkurven? Du kan ikke fortryde denne handling! Bemærk, at offline mediefiler ikke kan gendannes på denne måde.", + "assets_restored_count": "{count, plural, one {# mediefil} other {# mediefiler}} gendannet", + "assets_trashed_count": "{count, plural, one {# mediefil} other {# mediefiler}} smidt i papirkurven", + "assets_were_part_of_album_count": "mediefil{count, plural, one {mediefil} other {mediefiler}} er allerede en del af albummet", "authorized_devices": "Tilladte enheder", "back": "Tilbage", "back_close_deselect": "Tilbage, luk eller fravælg", @@ -441,7 +441,7 @@ "build_image": "Byggefil", "bulk_delete_duplicates_confirmation": "Er du sikker på, at du vil slette alle {count, plural, one {# duplicate asset} other {# duplicate assets}}? Dette vil beholde den største fil i hver gruppe og slette alle dubletter. Denne handling kan ikke fortrydes!", "bulk_keep_duplicates_confirmation": "Er du sikker på, at du vil beholde {count, plural, one {# duplicate asset} other {# duplicate assets}}? Dette vil løse alle dubletgrupper uden at slette noget.", - "bulk_trash_duplicates_confirmation": "Er du sikker på, at du vil masseslette {count, plural, one {# duplicate asset} other {# duplicate assets}}? Dette vil beholde det største aktiv i hver gruppe og smide alle andre dubletter.", + "bulk_trash_duplicates_confirmation": "Er du sikker på, at du vil masseslette {count, plural, one {# duplikeret objekt} other {# duplikerede objekter}}? Dette vil beholde det største objekt i hver gruppe og slette alle andre dubletter.", "buy": "Køb Immich", "camera": "Kamera", "camera_brand": "Kameramærke", @@ -595,7 +595,7 @@ "editor_crop_tool_h2_rotation": "Rotation", "email": "E-mail", "empty_trash": "Tøm papirkurv", - "empty_trash_confirmation": "Er du sikker på, at du vil tømme papirkurven? Dette vil fjerne alle aktiver i papirkurven permanent fra Immich.\nDu kan ikke fortryde denne handling!", + "empty_trash_confirmation": "Er du sikker på, at du vil tømme papirkurven? Dette vil fjerne alle objekter i papirkurven permanent fra Immich.\nDu kan ikke fortryde denne handling!", "enable": "Aktivér", "enabled": "Aktiveret", "end_date": "Slutdato", @@ -608,7 +608,7 @@ "cant_apply_changes": "Ændringerne kan ikke anvendes", "cant_change_activity": "Kan ikke {enabled, select, true {disable} other {enable}} aktivitet", "cant_change_asset_favorite": "Kan ikke ændre favorit til aktiv", - "cant_change_metadata_assets_count": "Kan ikke ændre metadata for {count, plural, one {# asset} other {# assets}}", + "cant_change_metadata_assets_count": "Kan ikke ændre metadata for {count, plural, one {# objekt} other {# objekter}}", "cant_get_faces": "Kan ikke hente ansigter", "cant_get_number_of_comments": "Kan ikke få antallet af kommentarer", "cant_search_people": "Kan ikke søge efter folk", @@ -648,7 +648,7 @@ "unable_to_add_partners": "Ikke i stand til at tilføje partnere", "unable_to_add_remove_archive": "Kan Ikke {archived, select, true {fjerne aktiv fra} other {tilføje aktiv til}} Arkiv", "unable_to_add_remove_favorites": "Kan ikke {favorite, select, true {tilføje aktiv til} other {fjerne aktiv fra}} favoritter", - "unable_to_archive_unarchive": "Ude af stand til at {arkiveret, vælg, sand {arkiv} andet {arkiv}}", + "unable_to_archive_unarchive": "Ude af stand til at {archived, select, true {arkivere} other {fjerne fra arkiv}}", "unable_to_change_album_user_role": "Ikke i stand til at ændre albumbrugerens rolle", "unable_to_change_date": "Ikke i stand til at ændre dato", "unable_to_change_favorite": "Kan ikke ændre favorit for aktiv", @@ -689,8 +689,8 @@ "unable_to_log_out_device": "Enheden kunne ikke logges af", "unable_to_login_with_oauth": "Kan ikke logge på med OAuth", "unable_to_play_video": "Ikke i stand til at afspille video", - "unable_to_reassign_assets_existing_person": "Kan ikke gentildele aktiver til {navn, vælg, null {en eksisterende person} anden {{name}}}", - "unable_to_reassign_assets_new_person": "Kan ikke omfordele aktiver til en ny person", + "unable_to_reassign_assets_existing_person": "Kunne ikke tildele mediafiler til {name, select, null {en eksisterende person} other {{name}}}", + "unable_to_reassign_assets_new_person": "Kan ikke omfordele objekter til en ny person", "unable_to_refresh_user": "Ikke i stand til at genopfriske bruger", "unable_to_remove_album_users": "Ikke i stand til at fjerne brugere fra album", "unable_to_remove_api_key": "Kunne ikke fjerne API-nøgle", @@ -720,7 +720,7 @@ "unable_to_unlink_account": "Ikke i stand til at frakoble konto", "unable_to_unlink_motion_video": "Kunne ikke fjerne linket til bevægelsesvideo", "unable_to_update_album_cover": "Albumomslaget kunne ikke opdateres", - "unable_to_update_album_info": "Albumoplysningerne kunne ikke opdateres", + "unable_to_update_album_info": "Albumsoplysningerne kunne ikke opdateres", "unable_to_update_library": "Ikke i stand til at opdatere bibliotek", "unable_to_update_location": "Ikke i stand til at opdatere sted", "unable_to_update_settings": "Ikke i stand til at opdatere indstillinger", @@ -766,8 +766,10 @@ "go_to_folder": "Gå til mappe", "go_to_search": "Gå til søgning", "group_albums_by": "Gruppér albummer efter...", + "group_country": "Gruppér efter land", "group_no": "Ingen gruppering", "group_owner": "Grupper efter ejer", + "group_places_by": "Gruppér steder efter...", "group_year": "Grupper efter år", "has_quota": "Har kvote", "hi_user": "Hej {name} ({email})", @@ -800,6 +802,7 @@ "include_shared_albums": "Inkludér delte albummer", "include_shared_partner_assets": "Inkludér delte partnermedier", "individual_share": "Individuel andel", + "individual_shares": "Individuelle delinger", "info": "Info", "interval": { "day_at_onepm": "Hver dag kl. 13", @@ -809,7 +812,7 @@ }, "invite_people": "Inviter personer", "invite_to_album": "Inviter til album", - "items_count": "{count, plural, one {# genstand} other {# genstande}}", + "items_count": "{count, plural, one {# element} other {# elementer}}", "jobs": "Opgaver", "keep": "Behold", "keep_all": "Behold alle", @@ -822,13 +825,14 @@ "latest_version": "Seneste version", "latitude": "Breddegrad", "leave": "Forlad", + "lens_model": "Objektivmodel", "let_others_respond": "Lad andre svare", "level": "Niveau", "library": "Bibliotek", "library_options": "Biblioteksindstillinger", "light": "Lys", "like_deleted": "Ligesom slettet", - "link_motion_video": "Link bevægelses video", + "link_motion_video": "Link bevægelsesvideo", "link_options": "Link-indstillinger", "link_to_oauth": "Link til OAuth", "linked_oauth_account": "Tilsluttet OAuth-konto", @@ -864,7 +868,7 @@ "media_type": "Medietype", "memories": "Minder", "memories_setting_description": "Administrér hvad du ser i dine minder", - "memory": "Hukommelse", + "memory": "Minde", "memory_lane_title": "Minder {title}", "menu": "Menu", "merge": "Sammenflet", @@ -872,7 +876,7 @@ "merge_people_limit": "Du kan kun flette op til 5 ansigter ad gangen", "merge_people_prompt": "Vil du slå disse mennesker sammen? Denne handling er uigenkaldelig.", "merge_people_successfully": "Personer sammenflettet med succes", - "merged_people_count": "Slået sammen {count, plural, one {# person} other {# people}}", + "merged_people_count": "{count, plural, one {# person} other {# personer}} lagt sammen", "minimize": "Minimér", "minute": "Minut", "missing": "Mangler", @@ -923,9 +927,9 @@ "offline_paths_description": "Disse resultater kan være på grund af manuel sletning af filer, som ikke er en del af et eksternt bibliotek.", "ok": "Ok", "oldest_first": "Ældste først", - "onboarding": "Onboarding", + "onboarding": "Introduktion", "onboarding_privacy_description": "Følgende (valgfrie) funktioner er afhængige af eksterne tjenester, og kan til enhver tid deaktiveres i administrationsindstillingerne.", - "onboarding_theme_description": "Vælg et farvetema til din forekomst. Du kan ændre dette senere i dine indstillinger.", + "onboarding_theme_description": "Vælg et farvetema til din instans. Du kan ændre dette senere i dine indstillinger.", "onboarding_welcome_description": "Lad os få din instans sat op med nogle almindelige indstillinger.", "onboarding_welcome_user": "Velkommen, {user}", "online": "Online", @@ -940,7 +944,7 @@ "other": "Andet", "other_devices": "Andre enheder", "other_variables": "Andre variable", - "owned": "Ejet", + "owned": "Egne", "owner": "Ejer", "partner": "Partner", "partner_can_access": "{partner} kan tilgå", @@ -973,7 +977,7 @@ "permanently_delete_assets_count": "Slet permanent {count, plural, one {asset} other {assets}}", "permanently_delete_assets_prompt": "Er du sikker på, at du permanent vil slette {count, plural, one {dette aktiv?} other {disse # aktiver?}} Dette vil også fjerne {count, plural, one {det fra dets} other {dem fra deres}} album(er).", "permanently_deleted_asset": "Permanent slettet medie", - "permanently_deleted_assets_count": "Slettet permanent {count, plural, one {# aktiv} other {# aktiver}}", + "permanently_deleted_assets_count": "{count, plural, one {# aktiv} other {# aktiver}} permanent slettet", "person": "Person", "person_hidden": "{name}{hidden, select, true { (skjult)} other {}}", "photo_shared_all_users": "Det ser ud til, at du har delt dine billeder med alle brugere, eller også har du ikke nogen bruger at dele med.", @@ -984,6 +988,7 @@ "pick_a_location": "Vælg et sted", "place": "Sted", "places": "Steder", + "places_count": "{count, plural, one {{count, number} Sted} other {{count, number} Steder}}", "play": "Afspil", "play_memories": "Afspil minder", "play_motion_photo": "Afspil bevægelsesbillede", @@ -1018,11 +1023,11 @@ "purchase_input_suggestion": "Har du en produktnøgle? Indtast nøglen nedenfor", "purchase_license_subtitle": "Køb Immich for at understøtte den fortsatte udvikling af tjenesten", "purchase_lifetime_description": "Livsvarigt køb", - "purchase_option_title": "KØBEMULIGHEDER", + "purchase_option_title": "KØBSMULIGHEDER", "purchase_panel_info_1": "At bygge Immich tager meget tid og kræfter, og vi har fuldtidsingeniører, der arbejder på det for at gøre det så godt, som vi overhovedet kan. Vores mission er, at open source-software og etisk forretningspraksis bliver en bæredygtig indtægtskilde for udviklere og at skabe et privatlivsrespekterende økosystem med reelle alternativer til udnyttende cloud-tjenester.", "purchase_panel_info_2": "Da vi er forpligtet til ikke at tilføje betalingsvægge, vil dette køb ikke give dig yderligere funktioner i Immich. Vi er afhængige af, at brugere som dig støtter Immichs løbende udvikling.", "purchase_panel_title": "Støt projektet", - "purchase_per_server": "Per server", + "purchase_per_server": "Pr. server", "purchase_per_user": "Per bruger", "purchase_remove_product_key": "Fjern produktnøgle", "purchase_remove_product_key_prompt": "Er du sikker på, at du vil fjerne produktnøglen?", @@ -1039,7 +1044,7 @@ "reaction_options": "Reaktionsindstillinger", "read_changelog": "Læs ændringslog", "reassign": "Gentildel", - "reassigned_assets_to_existing_person": "Gentildelt {count, plural, one {# aktiv} other {# aktiver}} til {name, select, null {en eksisterende person} other {{navne}}}", + "reassigned_assets_to_existing_person": "{count, plural, one {# mediefil} other {# mediefiler}} er blevet gentildelt til {name, select, null {en eksisterende person} other {{name}}}", "reassigned_assets_to_new_person": "Gentildelt {count, plural, one {# aktiv} other {# aktiver}} til en ny person", "reassing_hint": "Tildel valgte aktiver til en eksisterende person", "recent": "For nylig", @@ -1084,7 +1089,7 @@ "reset_people_visibility": "Nulstil personsynlighed", "reset_to_default": "Nulstil til standard", "resolve_duplicates": "Løs dubletter", - "resolved_all_duplicates": "Løste alle dubletter", + "resolved_all_duplicates": "Alle dubletter løst", "restore": "Gendan", "restore_all": "Gendan alle", "restore_user": "Gendan bruger", @@ -1107,12 +1112,15 @@ "search": "Søg", "search_albums": "Søg i albummer", "search_by_context": "Søg efter kontekst", + "search_by_description": "Søg efter beskrivelse", + "search_by_description_example": "Vandredag i Paris", "search_by_filename": "Søg efter filnavn eller filtypenavn", "search_by_filename_example": "dvs. IMG_1234.JPG eller PNG", "search_camera_make": "Søg efter kameraproducent...", "search_camera_model": "Søg efter kameramodel...", "search_city": "Søg efter by...", "search_country": "Søg efter land...", + "search_for": "Søg efter", "search_for_existing_person": "Søg efter eksisterende person", "search_no_people": "Ingen personer", "search_no_people_named": "Ingen personer med navnet \"{name}\"", @@ -1141,7 +1149,7 @@ "select_photos": "Vælg billeder", "select_trash_all": "Vælg smid alle ud", "selected": "Valgt", - "selected_count": "{count, plural, other {# valgt}}", + "selected_count": "{count, plural, one {# valgt} other {# valgte}}", "send_message": "Send besked", "send_welcome_email": "Send velkomstemail", "server_offline": "Server Offline", @@ -1165,6 +1173,7 @@ "shared_from_partner": "Billeder fra {partner}", "shared_link_options": "Muligheder for delt link", "shared_links": "Delte links", + "shared_links_description": "Del billeder og videoer med et link", "shared_photos_and_videos_count": "{assetCount, plural, other {# delte billeder & videoer.}}", "shared_with_partner": "Delt med {partner}", "sharing": "Delte", @@ -1187,6 +1196,7 @@ "show_person_options": "Vis personindstillinger", "show_progress_bar": "Vis statuslinje", "show_search_options": "Vis søgeindstillinger", + "show_shared_links": "Vis delte links", "show_slideshow_transition": "Vis overgang til diasshow", "show_supporter_badge": "Supportermærke", "show_supporter_badge_description": "Vis et supportermærke", @@ -1264,9 +1274,9 @@ "total_usage": "Samlet forbrug", "trash": "Papirkurv", "trash_all": "Smid alle ud", - "trash_count": "Skrald {count, number}", + "trash_count": "Slet {count, number}", "trash_delete_asset": "Papirkurv/slet aktiv", - "trash_no_results_message": "Udsmidte billeder og videoer vil kunne findes her.", + "trash_no_results_message": "Billeder og videoer markeret til sletning vil blive vist her.", "trashed_items_will_be_permanently_deleted_after": "Mediefiler i skraldespanden vil blive slettet permanent efter {days, plural, one {# dag} other {# dage}}.", "type": "Type", "unarchive": "Afakivér", @@ -1274,6 +1284,7 @@ "unfavorite": "Fjern favorit", "unhide_person": "Hold op med at gemme person væk", "unknown": "Ukendt", + "unknown_country": "Ukendt land", "unknown_year": "Ukendt år", "unlimited": "Ubegrænset", "unlink_motion_video": "Fjern link til bevægelsesvideo", @@ -1287,7 +1298,7 @@ "unselect_all_duplicates": "Fjern markeringen af alle dubletter", "unstack": "Fjern fra stak", "unstacked_assets_count": "Ikke-stablet {count, plural, one {# aktiv} other {# aktiver}}", - "untracked_files": "Usporede filer", + "untracked_files": "Ikke overvågede filer", "untracked_files_decription": "Disse filer bliver ikke sporet af applikationen. De kan være resultatet af mislykkede flytninger, afbrudte uploads eller efterladt på grund af en fejl", "up_next": "Næste", "updated_password": "Opdaterede adgangskode", @@ -1298,7 +1309,7 @@ "upload_skipped_duplicates": "Sprang over {count, plural, one {# duplet aktiv} other {# duplikerede aktiver}}", "upload_status_duplicates": "Dubletter", "upload_status_errors": "Fejl", - "upload_status_uploaded": "Uploaded", + "upload_status_uploaded": "Uploadet", "upload_success": "Upload gennemført. Opdater siden for at se nye uploadaktiver.", "url": "URL", "usage": "Forbrug", diff --git a/i18n/de.json b/i18n/de.json index 3490d2c7df..e1efb48ba7 100644 --- a/i18n/de.json +++ b/i18n/de.json @@ -20,7 +20,7 @@ "add_partner": "Partner hinzufügen", "add_path": "Pfad hinzufügen", "add_photos": "Fotos hinzufügen", - "add_to": "Hinzufügen zu ...", + "add_to": "Hinzufügen zu …", "add_to_album": "Zu Album hinzufügen", "add_to_shared_album": "Zu geteiltem Album hinzufügen", "add_url": "URL hinzufügen", @@ -31,7 +31,7 @@ "add_exclusion_pattern_description": "Ausschlussmuster hinzufügen. Platzhalter, wie *, **, und ? werden unterstützt. Um alle Dateien in einem Verzeichnis namens „Raw\" zu ignorieren, „**/Raw/**“ verwenden. Um alle Dateien zu ignorieren, die auf „.tif“ enden, „**/*.tif“ verwenden. Um einen absoluten Pfad zu ignorieren, „/pfad/zum/ignorieren/**“ verwenden.", "asset_offline_description": "Diese Datei einer externen Bibliothek befindet sich nicht mehr auf der Festplatte und wurde in den Papierkorb verschoben. Falls die Datei innerhalb der Bibliothek verschoben wurde, überprüfe deine Zeitleiste auf die neue entsprechende Datei. Um diese Datei wiederherzustellen, stelle bitte sicher, dass Immich auf den unten stehenden Dateipfad zugreifen kann und scanne die Bibliothek.", "authentication_settings": "Authentifizierungseinstellungen", - "authentication_settings_description": "Passwort-, OAuth- und sonstigen Authentifizierungseinstellungen verwalten", + "authentication_settings_description": "Passwort-, OAuth- und sonstige Authentifizierungseinstellungen verwalten", "authentication_settings_disable_all": "Bist du sicher, dass du alle Anmeldemethoden deaktivieren willst? Die Anmeldung wird vollständig deaktiviert.", "authentication_settings_reenable": "Nutze einen Server-Befehl zur Reaktivierung.", "background_task_job": "Hintergrundaufgaben", @@ -187,7 +187,7 @@ "oauth_issuer_url": "Aussteller-URL", "oauth_mobile_redirect_uri": "Mobile Umleitungs-URI", "oauth_mobile_redirect_uri_override": "Mobile Umleitungs-URI überschreiben", - "oauth_mobile_redirect_uri_override_description": "Einschalten, wenn der OAuth-Provider keine mobile URI wie '{callback}' erlaubt", + "oauth_mobile_redirect_uri_override_description": "Einschalten, wenn der OAuth-Anbieter keine mobile URI wie '{callback}' erlaubt", "oauth_profile_signing_algorithm": "Algorithmus zur Profilsignierung", "oauth_profile_signing_algorithm_description": "Dieser Algorithmus wird für die Signatur des Benutzerprofils verwendet.", "oauth_scope": "Umfang", @@ -219,7 +219,7 @@ "reset_settings_to_default": "Einstellungen auf Standard zurücksetzen", "reset_settings_to_recent_saved": "Einstellungen auf die zuletzt gespeicherten Einstellungen zurücksetzen", "scanning_library": "Bibliothek scannen", - "search_jobs": "Aufgaben suchen...", + "search_jobs": "Suchaufgaben…", "send_welcome_email": "Begrüssungsmail senden", "server_external_domain_settings": "Externe Domain", "server_external_domain_settings_description": "Domäne für öffentlich freigegebene Links, einschließlich http(s)://", @@ -290,7 +290,7 @@ "transcoding_constant_rate_factor_description": "Videoqualitätsstufe. Typische Werte sind 23 für H.264, 28 für HEVC, 31 für VP9 und 35 für AV1. Ein niedrigerer Wert ist besser, erzeugt aber größere Dateien.", "transcoding_disabled_description": "Videos nicht transkodieren, dies kann die Wiedergabe auf manchen Geräten beeinträchtigen", "transcoding_encoding_options": "Kodierungsoptionen", - "transcoding_encoding_options_description": "Setze Codec, Auflösung, Qualität und andere Optionen für kodierte Videos", + "transcoding_encoding_options_description": "Setze Codec, Auflösung, Qualität und andere Optionen für die kodierten Videos", "transcoding_hardware_acceleration": "Hardware-Beschleunigung", "transcoding_hardware_acceleration_description": "Experimentell; viel schneller, aber bei gleicher Bitrate mit geringerer Qualität", "transcoding_hardware_decoding": "Hardware-Dekodierung", @@ -313,7 +313,7 @@ "transcoding_reference_frames_description": "Die Anzahl der Bilder, auf die bei der Komprimierung eines bestimmten Bildes Bezug genommen wird. Höhere Werte verbessern die Komprimierungseffizienz, verlangsamen aber die Kodierung. 0 setzt diesen Wert automatisch.", "transcoding_required_description": "Nur Videos in einem nicht akzeptierten Format", "transcoding_settings": "Einstellungen für die Videotranskodierung", - "transcoding_settings_description": "Verwalten welche Videos transkodiert werden und wie diese verarbeitet werden", + "transcoding_settings_description": "Verwalten welche Videos transkodiert und wie diese verarbeitet werden", "transcoding_target_resolution": "Ziel-Auflösung", "transcoding_target_resolution_description": "Höhere Auflösungen können mehr Details erhalten, benötigen aber mehr Zeit für die Codierung, haben größere Dateigrößen und können die Reaktionszeit der Anwendung beeinträchtigen.", "transcoding_temporal_aq": "Temporäre AQ", @@ -406,17 +406,17 @@ "are_these_the_same_person": "Ist das dieselbe Person?", "are_you_sure_to_do_this": "Bist du sicher, dass du das tun willst?", "asset_added_to_album": "Zum Album hinzugefügt", - "asset_adding_to_album": "Hinzufügen zum Album...", + "asset_adding_to_album": "Hinzufügen zum Album…", "asset_description_updated": "Die Beschreibung der Datei wurde aktualisiert", "asset_filename_is_offline": "Datei {filename} ist offline", "asset_has_unassigned_faces": "Datei hat nicht zugewiesene Gesichter", - "asset_hashing": "Berechnung des Hashwerts...", + "asset_hashing": "Berechne Prüfsumme…", "asset_offline": "Datei offline", "asset_offline_description": "Diese externe Datei ist nicht mehr auf dem Datenträger vorhanden. Bitte wende dich an deinen Immich-Administrator, um Hilfe zu erhalten.", "asset_skipped": "Übersprungen", "asset_skipped_in_trash": "Im Papierkorb", "asset_uploaded": "Hochgeladen", - "asset_uploading": "Hochladen...", + "asset_uploading": "Hochladen…", "assets": "Dateien", "assets_added_count": "{count, plural, one {# Datei} other {# Dateien}} hinzugefügt", "assets_added_to_album_count": "{count, plural, one {# Datei} other {# Dateien}} zum Album hinzugefügt", @@ -687,7 +687,7 @@ "unable_to_load_liked_status": "Gewünschter Status kann nicht geladen werden", "unable_to_log_out_all_devices": "Konnte nicht von allen Geräten abmelden", "unable_to_log_out_device": "Konnte nicht vom Gerät abmelden", - "unable_to_login_with_oauth": "Konnte nicht mit OAuth anmelden", + "unable_to_login_with_oauth": "Anmeldung mit OAuth nicht möglich", "unable_to_play_video": "Das Video kann nicht wiedergegeben werden", "unable_to_reassign_assets_existing_person": "Kann Dateien nicht {name, select, null {einer vorhandenen Person} other {{name}}} zuweisen", "unable_to_reassign_assets_new_person": "Dateien konnten nicht einer neuen Person zugeordnet werden", @@ -766,8 +766,10 @@ "go_to_folder": "Gehe zu Ordner", "go_to_search": "Zur Suche gehen", "group_albums_by": "Alben gruppieren nach...", + "group_country": "Nach Land gruppieren", "group_no": "Keine Gruppierung", "group_owner": "Gruppierung nach Besitzer", + "group_places_by": "Orte gruppieren nach...", "group_year": "Gruppierung nach Jahr", "has_quota": "Kontingent", "hi_user": "Hallo {name} ({email})", @@ -800,6 +802,7 @@ "include_shared_albums": "Freigegebene Alben einbeziehen", "include_shared_partner_assets": "Geteilte Partner-Dateien mit einbeziehen", "individual_share": "Individuelle Freigabe", + "individual_shares": "Individuelles Teilen", "info": "Info", "interval": { "day_at_onepm": "Täglich um 13:00 Uhr", @@ -822,6 +825,7 @@ "latest_version": "Aktuellste Version", "latitude": "Breitengrad", "leave": "Verlassen", + "lens_model": "Objektivmodell", "let_others_respond": "Antworten zulassen", "level": "Level", "library": "Bibliothek", @@ -830,7 +834,7 @@ "like_deleted": "Like gelöscht", "link_motion_video": "Bewegungsvideo verknüpfen", "link_options": "Link-Optionen", - "link_to_oauth": "Link zu OAuth", + "link_to_oauth": "Mit OAuth verknüpfen", "linked_oauth_account": "Verknüpftes OAuth-Konto", "list": "Liste", "loading": "Laden", @@ -855,7 +859,7 @@ "manage_your_account": "Dein Konto verwalten", "manage_your_api_keys": "Deine API-Schlüssel verwalten", "manage_your_devices": "Deine eingeloggten Geräte verwalten", - "manage_your_oauth_connection": "Deine OAuth-Verbindung verwalten", + "manage_your_oauth_connection": "Deine OAuth-Verknüpfung verwalten", "map": "Karte", "map_marker_for_images": "Kartenmarkierung für Bilder, die in {city}, {country} aufgenommen wurden", "map_marker_with_image": "Kartenmarkierung mit Bild", @@ -984,6 +988,7 @@ "pick_a_location": "Wähle einen Ort", "place": "Ort", "places": "Orte", + "places_count": "{count, plural, one {{count, number} Ort} other {{count, number} Orte}}", "play": "Abspielen", "play_memories": "Erinnerungen abspielen", "play_motion_photo": "Bewegte Bilder abspielen", @@ -1107,12 +1112,15 @@ "search": "Suche", "search_albums": "Album suchen", "search_by_context": "Suche nach Kontext", + "search_by_description": "Nach Beschreibung suchen", + "search_by_description_example": "Wandern in Sapa", "search_by_filename": "Suche nach Dateiname oder -erweiterung", "search_by_filename_example": "z.B. IMG_1234.JPG oder PNG", "search_camera_make": "Suche nach Kameramarke...", "search_camera_model": "Suche nach Kameramodell...", "search_city": "Suche nach Stadt...", "search_country": "Suche nach Land...", + "search_for": "Suche nach", "search_for_existing_person": "Suche nach vorhandener Person", "search_no_people": "Keine Personen", "search_no_people_named": "Keine Person mit dem Namen \"{name}\"", @@ -1165,6 +1173,7 @@ "shared_from_partner": "Fotos von {partner}", "shared_link_options": "Optionen für geteilten Link", "shared_links": "Geteilte Links", + "shared_links_description": "Teile Fotos und Videos mit einem Link", "shared_photos_and_videos_count": "{assetCount, plural, one {# geteiltes Foto oder Video.} other {# geteilte Fotos & Videos.}}", "shared_with_partner": "Geteilt mit {partner}", "sharing": "Geteiltes", @@ -1187,6 +1196,7 @@ "show_person_options": "Personen-Optionen anzeigen", "show_progress_bar": "Fortschrittsbalken anzeigen", "show_search_options": "Suchoptionen anzeigen", + "show_shared_links": "Zeige geteilte Links", "show_slideshow_transition": "Slideshow-Übergang anzeigen", "show_supporter_badge": "Unterstützerabzeichen", "show_supporter_badge_description": "Zeige Unterstützerabzeichen", @@ -1274,11 +1284,12 @@ "unfavorite": "Entfavorisieren", "unhide_person": "Person einblenden", "unknown": "Unbekannt", + "unknown_country": "Unbekanntes Land", "unknown_year": "Unbekanntes Jahr", "unlimited": "Unlimitiert", "unlink_motion_video": "Verknüpfung zum Bewegungsvideo aufheben", "unlink_oauth": "OAuth entfernen", - "unlinked_oauth_account": "Nicht verknüpftes OAuth-Konto", + "unlinked_oauth_account": "OAuth-Konto entfernt", "unnamed_album": "Unbenanntes Album", "unnamed_album_delete_confirmation": "Bist du sicher, dass du dieses Album löschen willst?", "unnamed_share": "Unbenannte Freigabe", diff --git a/i18n/el.json b/i18n/el.json index ed06812e77..0371e76285 100644 --- a/i18n/el.json +++ b/i18n/el.json @@ -20,7 +20,7 @@ "add_partner": "Προσθήκη συνεργάτη", "add_path": "Προσθήκη διαδρομής", "add_photos": "Προσθήκη φωτογραφιών", - "add_to": "Προσθήκη σε...", + "add_to": "Προσθήκη σε…", "add_to_album": "Προσθήκη σε άλμπουμ", "add_to_shared_album": "Προσθήκη σε κοινόχρηστο άλμπουμ", "add_url": "Προσθήκη Συνδέσμου", @@ -114,24 +114,24 @@ "machine_learning_facial_recognition": "Αναγνώριση Προσώπου", "machine_learning_facial_recognition_description": "Εντοπισμός, αναγνώριση και ομαδοποίηση προσώπων που υπάρχουν σε εικόνες", "machine_learning_facial_recognition_model": "Μοντέλο αναγνώρισης προσώπου", - "machine_learning_facial_recognition_model_description": "Τα μοντέλα παρατίθενται με φθίνουσα σειρά μεγέθους. Τα μεγαλύτερα μοντέλα είναι πιο αργά και χρησιμοποιούν περισσότερη μνήμη, αλλά παράγουν καλύτερα αποτελέσματα. Σημειώστε ότι πρέπει να εκτελέσετε ξανά την εργασία Ανίχνευση προσώπου για όλες τις εικόνες κατά την αλλαγή ενός μοντέλου.", + "machine_learning_facial_recognition_model_description": "Τα μοντέλα παρατίθενται με φθίνουσα σειρά μεγέθους. Τα μεγαλύτερα μοντέλα είναι πιο αργά και χρησιμοποιούν περισσότερη μνήμη, αλλά παράγουν καλύτερα αποτελέσματα. Σημειώστε ότι πρέπει να επανεκτελέσετε την εργασία \"Ανίχνευση Προσώπου\" για όλες τις εικόνες μετά την αλλαγή μοντέλου.", "machine_learning_facial_recognition_setting": "Ενεργοποίηση αναγνώρισης προσώπου", "machine_learning_facial_recognition_setting_description": "Αν απενεργοποιηθεί, οι εικόνες δεν θα κωδικοποιούνται για αναγνώριση προσώπου και δεν θα συμπληρώνουν την ενότητα Άτομα στη σελίδα Περιήγησης.", "machine_learning_max_detection_distance": "Μέγιστη απόσταση ανίχνευσης", "machine_learning_max_detection_distance_description": "Η μέγιστη απόσταση μεταξύ δύο εικόνων για να θεωρηθούν διπλότυπες, που κυμαίνεται από 0,001-0,1. Οι υψηλότερες τιμές θα εντοπίσουν περισσότερες διπλότυπες, αλλά μπορεί να οδηγήσουν σε ψευδώς θετικά αποτελέσματα.", "machine_learning_max_recognition_distance": "Μέγιστη απόσταση αναγνώρισης", - "machine_learning_max_recognition_distance_description": "Η μέγιστη απόσταση μεταξύ δύο προσώπων για να θεωρείται το ίδιο άτομο, που κυμαίνεται από 0-2. Η μείωση αυτή μπορεί να αποτρέψει την επισήμανση δύο ατόμων ως το ίδιο άτομο, ενώ η αύξηση της μπορεί να αποτρέψει την επισήμανση του ίδιου ατόμου ως δύο διαφορετικών ατόμων. Λάβετε υπόψη ότι είναι πιο εύκολο να συγχωνεύσετε δύο άτομα παρά να χωρίσετε ένα άτομο στα δύο, οπότε προτιμήστε ένα χαμηλότερο όριο, όταν αυτό είναι δυνατό.", + "machine_learning_max_recognition_distance_description": "Η μέγιστη απόσταση μεταξύ δύο προσώπων για να θεωρείται το ίδιο άτομο, που κυμαίνεται από 0-2. Η μείωση αυτής της τιμής μπορεί να αποτρέψει την επισήμανση δύο ατόμων ως το ίδιο άτομο, ενώ η αύξηση της μπορεί να αποτρέψει την επισήμανση του ίδιου ατόμου ως δύο διαφορετικών ατόμων. Λάβετε υπόψη ότι είναι πιο εύκολο να συγχωνεύσετε δύο άτομα παρά να χωρίσετε ένα άτομο στα δύο, οπότε προτιμήστε ένα χαμηλότερο όριο, όταν αυτό είναι δυνατό.", "machine_learning_min_detection_score": "Ελάχιστο σκορ ανίχνευσης", "machine_learning_min_detection_score_description": "Ελάχιστο σκορ εμπιστοσύνης για ανίχνευση προσώπου από 0-1. Οι χαμηλότερες τιμές θα εντοπίσουν περισσότερα πρόσωπα, αλλά μπορεί να οδηγήσουν σε ψευδώς θετικά αποτελέσματα.", "machine_learning_min_recognized_faces": "Ελάχιστα αναγνωρισμένα πρόσωπα", "machine_learning_min_recognized_faces_description": "Ο ελάχιστος αριθμός αναγνωρισμένων προσώπων για ένα άτομο που θα δημιουργηθεί. Η αύξηση αυτή καθιστά την Αναγνώριση Προσώπου πιο ακριβή με το κόστος να αυξηθεί η πιθανότητα να μην εκχωρηθεί ένα πρόσωπο σε ένα άτομο.", - "machine_learning_settings": "Ρυθμίσεις Μηχανικής Εκμάθησης", - "machine_learning_settings_description": "Διαχειριστείτε τις λειτουργίες και τις ρυθμίσεις μηχανικής εκμάθησης", + "machine_learning_settings": "Ρυθμίσεις Μηχανικής Μάθησης", + "machine_learning_settings_description": "Διαχειριστείτε τις λειτουργίες και τις ρυθμίσεις μηχανικής μάθησης", "machine_learning_smart_search": "Έξυπνη Αναζήτηση", "machine_learning_smart_search_description": "Αναζητήστε εικόνες σημασιολογικά χρησιμοποιώντας ενσωματώσεις CLIP", "machine_learning_smart_search_enabled": "Ενεργοποίηση έξυπνης αναζήτησης", "machine_learning_smart_search_enabled_description": "Αν απενεργοποιηθεί, οι εικόνες δεν θα κωδικοποιούνται για έξυπνη αναζήτηση.", - "machine_learning_url_description": "Η διεύθυνση URL του διακομιστή μηχανικής εκμάθησης. Αν παρέχονται περισσότερες από μία διευθύνσεις URL, τότε, κάθε διακομιστής θα προσπαθήσει να συνδεθεί διαδοχικά, από την πρώτη μέχρι την τελευταία, έως ότου απαντήσει επιτυχώς.", + "machine_learning_url_description": "Η διεύθυνση URL του διακομιστή μηχανικής μάθησης. Αν παρέχονται περισσότερες από μία διευθύνσεις, τότε θα γίνει προσπάθεια σύνδεσης σε κάθε μια διαδοχικά από την πρώτη μέχρι την τελευταία, έως ότου κάποια να είναι επιτυχής.", "manage_concurrency": "Διαχείριση ταυτόχρονη εκτέλεσης", "manage_log_settings": "Διαχείριση ρυθμίσεων αρχείου καταγραφής", "map_dark_style": "Σκούρο Θέμα", @@ -219,7 +219,7 @@ "reset_settings_to_default": "Επαναφορά προεπιλεγμένων ρυθμίσεων", "reset_settings_to_recent_saved": "Επαναφορά ρυθμίσεων στις πρόσφατα αποθηκευμένες ρυθμίσεις", "scanning_library": "Σάρωση βιβλιοθήκης", - "search_jobs": "Αναζήτηση εργασιών...", + "search_jobs": "Αναζήτηση εργασιών…", "send_welcome_email": "Αποστολή email καλωσορίσματος", "server_external_domain_settings": "Εξωτερική διεύθυνση τομέα", "server_external_domain_settings_description": "Διεύθυνση τομέα για δημόσιους κοινούς συνδέσμους, περιλαμβανομένου του http(s)://", @@ -232,7 +232,7 @@ "sidecar_job": "Μεταδεδομένα συνοδευτικού αρχείου", "sidecar_job_description": "Ανακάλυψη ή συγχρονισμός των μεταδεδομένων του συνοδευτικού αρχείου από το σύστημα αρχείων", "slideshow_duration_description": "Αριθμός δευτερολέπτων για την εμφάνιση κάθε εικόνας", - "smart_search_job_description": "Εκτέλεση της μηχανικής εκμάθησης, σε αρχεία, για την υποστήριξη της έξυπνης αναζήτησης", + "smart_search_job_description": "Εκτέλεση της μηχανικής μάθησης, σε αρχεία, για την υποστήριξη της έξυπνης αναζήτησης", "storage_template_date_time_description": "Η χρονική σήμανση της δημιουργίας του αρχείου, χρησιμοποιείται για τις πληροφορίες ημερομηνίας και ώρας", "storage_template_date_time_sample": "Χρόνος δείγματος {date}", "storage_template_enable_description": "Ενεργοποίηση του μηχανισμού των προτύπων αποθήκευσης", @@ -406,17 +406,17 @@ "are_these_the_same_person": "Είναι το ίδιο άτομο;", "are_you_sure_to_do_this": "Είστε σίγουροι ότι θέλετε να το κάνετε αυτό;", "asset_added_to_album": "Προστέθηκε στο άλμπουμ", - "asset_adding_to_album": "Προστίθεται στο άλμπουμ...", + "asset_adding_to_album": "Προστίθεται στο άλμπουμ…", "asset_description_updated": "Η περιγραφή του αντικειμένου έχει ενημερωθεί", "asset_filename_is_offline": "Το αντικείμενο {filename} είναι εκτός σύνδεσης", "asset_has_unassigned_faces": "Το αντικείμενο έχει μη ανατεθειμένα πρόσωπα", - "asset_hashing": "Δημιουργία κατακερματισμού...", + "asset_hashing": "Δημιουργία κατακερματισμού…", "asset_offline": "Αντικείμενο εκτός σύνδεσης", "asset_offline_description": "Αυτό το εξωτερικό αντικείμενο δεν βρέθηκε πλέον στον δίσκο. Παρακαλώ επικοινωνήστε με τον διαχειριστή του Immich για βοήθεια.", "asset_skipped": "Παραλείφθηκε", "asset_skipped_in_trash": "Στον κάδο απορριμμάτων", "asset_uploaded": "Ανεβάστηκε", - "asset_uploading": "Ανεβάζεται...", + "asset_uploading": "Ανεβάζεται…", "assets": "Αντικείμενα", "assets_added_count": "Προστέθηκε {count, plural, one {# αρχείο} other {# αρχεία}}", "assets_added_to_album_count": "Προστέθηκε {count, plural, one {# αρχείο} other {# αρχεία}} στο άλμπουμ", @@ -766,8 +766,10 @@ "go_to_folder": "Μετάβαση στο φάκελο", "go_to_search": "Πηγαίνετε στην αναζήτηση", "group_albums_by": "Ομαδοποίηση άλμπουμ κατά...", + "group_country": "Ομαδοποίηση κατά χώρα", "group_no": "Καμία ομοδοποίηση", "group_owner": "Ομαδοποίηση κατά ιδιοκτήτη", + "group_places_by": "Ομοδοποίηση τοποθεσιών κατά...", "group_year": "Ομαδοποίηση κατά έτος", "has_quota": "Έχει ποσόστωση", "hi_user": "Γειά σου {name} {email}", @@ -800,6 +802,7 @@ "include_shared_albums": "Συμπερίληψη διαμοιρασμένων άλμπουμ", "include_shared_partner_assets": "Συμπερίληψη των στοιχείων των συνεργατών που έχουν κοινοποιηθεί", "individual_share": "Μεμονωμένος διαμοιρασμός", + "individual_shares": "Μεμονωμένες κοινοποιήσεις", "info": "Πληροφορίες", "interval": { "day_at_onepm": "Κάθε μέρα στη 1μμ", @@ -822,6 +825,7 @@ "latest_version": "Τελευταία Έκδοση", "latitude": "Γεωγραφικό πλάτος", "leave": "Εγκατάλειψη", + "lens_model": "Μοντέλο φακού", "let_others_respond": "Επέτρεψε σε άλλους να απαντήσουν", "level": "Επίπεδο", "library": "Βιβλιοθήκη", @@ -984,6 +988,7 @@ "pick_a_location": "Επιλέξτε μια τοποθεσία", "place": "Τοποθεσία", "places": "Τοποθεσίες", + "places_count": "{count, plural, one {{count} Τοποθεσία} other {{count} Τοποθεσίες}}", "play": "Αναπαραγωγή", "play_memories": "Αναπαραγωγή αναμνήσεων", "play_motion_photo": "Αναπαραγωγή Κινούμενης Φωτογραφίας", @@ -1107,12 +1112,15 @@ "search": "Αναζήτηση", "search_albums": "Αναζήτηση άλμπουμ", "search_by_context": "Αναζήτηση με βάση το πλαίσιο", + "search_by_description": "Αναζήτηση με βάση την περιγραφή", + "search_by_description_example": "Ημερήσια πεζοπορία στο Πάπιγκο", "search_by_filename": "Αναζήτηση βάσει ονόματος αρχείου ή επέκτασης αρχείου", "search_by_filename_example": "π.χ. IMG_1234.JPG ή PNG", "search_camera_make": "Αναζήτηση κατασκευαστή κάμερας...", "search_camera_model": "Αναζήτηση μοντέλου κάμερας...", "search_city": "Αναζήτηση πόλης...", "search_country": "Αναζήτηση χώρας...", + "search_for": "Αναζήτηση για", "search_for_existing_person": "Αναζήτηση υπάρχοντος ατόμου", "search_no_people": "Κανένα άτομο", "search_no_people_named": "Κανένα άτομο με όνομα \"{name}\"", @@ -1165,6 +1173,7 @@ "shared_from_partner": "Φωτογραφίες από {partner}", "shared_link_options": "Επιλογές κοινόχρηστου συνδέσμου", "shared_links": "Κοινόχρηστοι σύνδεσμοι", + "shared_links_description": "Μοιραστείτε φωτογραφίες και βίντεο με σύνδεσμο", "shared_photos_and_videos_count": "{assetCount, plural, other {# κοινόχρηστες φωτογραφίες & βίντεο.}}", "shared_with_partner": "Σε κοινή χρήση με {partner}", "sharing": "Κοινοποίηση", @@ -1187,6 +1196,7 @@ "show_person_options": "Εμφάνιση επιλογών ατόμου", "show_progress_bar": "Εμφάνιση γραμμής προόδου", "show_search_options": "Εμφάνιση επιλογών αναζήτησης", + "show_shared_links": "Εμφάνιση κοινών συνδέσμων", "show_slideshow_transition": "Εμφάνιση μετάβασης παρουσίασης", "show_supporter_badge": "Σήμα υποστηρικτή", "show_supporter_badge_description": "Εμφάνιση σήματος υποστηρικτή", @@ -1274,6 +1284,7 @@ "unfavorite": "Αποεπιλογή από τα αγαπημένα", "unhide_person": "Αναίρεση απόκρυψης ατόμου", "unknown": "Άγνωστο", + "unknown_country": "Άγνωστη Χώρα", "unknown_year": "Άγνωστο Έτος", "unlimited": "Απεριόριστο", "unlink_motion_video": "Αποσυνδέστε το βίντεο κίνησης", diff --git a/i18n/en.json b/i18n/en.json index 5d747de774..e9c4d70c44 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -1,10 +1,4 @@ { - "delete_face": "Delete face", - "tag_people": "Tag People", - "error_delete_face": "Error deleting face from asset", - "search_by_description_example": "Hiking day in Sapa", - "confirm_delete_face": "Are you sure you want to delete {name} face from the asset?", - "search_by_description": "Search by description", "about": "About", "account": "Account", "account_settings": "Account Settings", @@ -26,7 +20,7 @@ "add_partner": "Add partner", "add_path": "Add path", "add_photos": "Add photos", - "add_to": "Add to...", + "add_to": "Add to…", "add_to_album": "Add to album", "add_to_shared_album": "Add to shared album", "add_url": "Add URL", @@ -225,7 +219,7 @@ "reset_settings_to_default": "Reset settings to default", "reset_settings_to_recent_saved": "Reset settings to the recent saved settings", "scanning_library": "Scanning library", - "search_jobs": "Search jobs...", + "search_jobs": "Search jobs…", "send_welcome_email": "Send welcome email", "server_external_domain_settings": "External domain", "server_external_domain_settings_description": "Domain for public shared links, including http(s)://", @@ -412,17 +406,17 @@ "are_these_the_same_person": "Are these the same person?", "are_you_sure_to_do_this": "Are you sure you want to do this?", "asset_added_to_album": "Added to album", - "asset_adding_to_album": "Adding to album...", + "asset_adding_to_album": "Adding to album…", "asset_description_updated": "Asset description has been updated", "asset_filename_is_offline": "Asset {filename} is offline", "asset_has_unassigned_faces": "Asset has unassigned faces", - "asset_hashing": "Hashing...", + "asset_hashing": "Hashing…", "asset_offline": "Asset Offline", "asset_offline_description": "This external asset is no longer found on disk. Please contact your Immich administrator for help.", "asset_skipped": "Skipped", "asset_skipped_in_trash": "In trash", "asset_uploaded": "Uploaded", - "asset_uploading": "Uploading...", + "asset_uploading": "Uploading…", "assets": "Assets", "assets_added_count": "Added {count, plural, one {# asset} other {# assets}}", "assets_added_to_album_count": "Added {count, plural, one {# asset} other {# assets}} to the album", @@ -440,7 +434,6 @@ "back_close_deselect": "Back, close, or deselect", "backward": "Backward", "birthdate_saved": "Date of birth saved successfully", - "show_shared_links": "Show shared links", "birthdate_set_description": "Date of birth is used to calculate the age of this person at the time of a photo.", "blurred_background": "Blurred background", "bugs_and_feature_requests": "Bugs & Feature Requests", @@ -488,6 +481,7 @@ "comments_are_disabled": "Comments are disabled", "confirm": "Confirm", "confirm_admin_password": "Confirm Admin Password", + "confirm_delete_face": "Are you sure you want to delete {name} face from the asset?", "confirm_delete_shared_link": "Are you sure you want to delete this shared link?", "confirm_keep_this_delete_others": "All other assets in the stack will be deleted except for this asset. Are you sure you want to continue?", "confirm_password": "Confirm password", @@ -530,16 +524,17 @@ "date_range": "Date range", "day": "Day", "deduplicate_all": "Deduplicate All", - "deduplication_info": "Deduplication Info", - "deduplication_info_description": "To automatically preselect assets and remove duplicates in bulk, we look at:", "deduplication_criteria_1": "Image size in bytes", "deduplication_criteria_2": "Count of EXIF data", + "deduplication_info": "Deduplication Info", + "deduplication_info_description": "To automatically preselect assets and remove duplicates in bulk, we look at:", "default_locale": "Default Locale", "default_locale_description": "Format dates and numbers based on your browser locale", "delete": "Delete", "delete_album": "Delete album", "delete_api_key_prompt": "Are you sure you want to delete this API key?", "delete_duplicates_confirmation": "Are you sure you want to permanently delete these duplicates?", + "delete_face": "Delete face", "delete_key": "Delete key", "delete_library": "Delete Library", "delete_link": "Delete link", @@ -607,6 +602,7 @@ "enabled": "Enabled", "end_date": "End date", "error": "Error", + "error_delete_face": "Error deleting face from asset", "error_loading_image": "Error loading image", "error_title": "Error - Something went wrong", "errors": { @@ -770,8 +766,8 @@ "get_help": "Get Help", "getting_started": "Getting Started", "go_back": "Go back", - "go_to_search": "Go to search", "go_to_folder": "Go to folder", + "go_to_search": "Go to search", "group_albums_by": "Group albums by...", "group_country": "Group by country", "group_no": "No grouping", @@ -1119,6 +1115,8 @@ "search": "Search", "search_albums": "Search albums", "search_by_context": "Search by context", + "search_by_description": "Search by description", + "search_by_description_example": "Hiking day in Sapa", "search_by_filename": "Search by file name or extension", "search_by_filename_example": "i.e. IMG_1234.JPG or PNG", "search_camera_make": "Search camera make...", @@ -1164,8 +1162,8 @@ "server_version": "Server Version", "set": "Set", "set_as_album_cover": "Set as album cover", - "set_as_profile_picture": "Set as profile picture", "set_as_featured_photo": "Set as featured photo", + "set_as_profile_picture": "Set as profile picture", "set_date_of_birth": "Set date of birth", "set_profile_picture": "Set profile picture", "set_slideshow_to_fullscreen": "Set Slideshow to fullscreen", @@ -1202,6 +1200,7 @@ "show_person_options": "Show person options", "show_progress_bar": "Show Progress Bar", "show_search_options": "Show search options", + "show_shared_links": "Show shared links", "show_slideshow_transition": "Show slideshow transition", "show_supporter_badge": "Supporter badge", "show_supporter_badge_description": "Show a supporter badge", @@ -1255,6 +1254,7 @@ "tag_created": "Created tag: {tag}", "tag_feature_description": "Browsing photos and videos grouped by logical tag topics", "tag_not_found_question": "Cannot find a tag? Create a new tag.", + "tag_people": "Tag People", "tag_updated": "Updated tag: {tag}", "tagged_assets": "Tagged {count, plural, one {# asset} other {# assets}}", "tags": "Tags", diff --git a/i18n/es.json b/i18n/es.json index c619fbfeb8..c33f8fd1c6 100644 --- a/i18n/es.json +++ b/i18n/es.json @@ -20,7 +20,7 @@ "add_partner": "Agregar compañero", "add_path": "Agregar carpeta", "add_photos": "Agregar fotos", - "add_to": "Agregar a...", + "add_to": "Agregar a…", "add_to_album": "Incluir en álbum", "add_to_shared_album": "Incluir en álbum compartido", "add_url": "Añadir URL", @@ -219,7 +219,7 @@ "reset_settings_to_default": "Restablecer la configuración predeterminada", "reset_settings_to_recent_saved": "Restablecer la configuración a la configuración guardada recientemente", "scanning_library": "Escaneando la biblioteca", - "search_jobs": "Buscar trabajo...", + "search_jobs": "Buscar trabajos…", "send_welcome_email": "Enviar correo de bienvenida", "server_external_domain_settings": "Dominio externo", "server_external_domain_settings_description": "Dominio para enlaces públicos compartidos, incluidos http(s)://", @@ -406,17 +406,17 @@ "are_these_the_same_person": "¿Son la misma persona?", "are_you_sure_to_do_this": "¿Estas seguro de que quieres hacer esto?", "asset_added_to_album": "Añadido al álbum", - "asset_adding_to_album": "Añadiendo al álbum...", + "asset_adding_to_album": "Añadiendo al álbum…", "asset_description_updated": "La descripción del elemento ha sido actualizada", "asset_filename_is_offline": "El archivo {filename} está offline", "asset_has_unassigned_faces": "El archivo no tiene rostros asignados", - "asset_hashing": "Hashing...", + "asset_hashing": "Hashing…", "asset_offline": "Archivos sin conexión", "asset_offline_description": "Este activo externo ya no se encuentra en el disco. Por favor, póngase en contacto con su administrador de Immich para obtener ayuda.", "asset_skipped": "Omitido", "asset_skipped_in_trash": "En la papelera", "asset_uploaded": "Subido", - "asset_uploading": "Subiendo...", + "asset_uploading": "Subiendo…", "assets": "elementos", "assets_added_count": "Añadido {count, plural, one {# asset} other {# assets}}", "assets_added_to_album_count": "Añadido {count, plural, one {# asset} other {# assets}} al álbum", @@ -438,7 +438,7 @@ "blurred_background": "Fondo borroso", "bugs_and_feature_requests": "Errores y solicitudes de funciones", "build": "Compilación", - "build_image": "Imagen", + "build_image": "Construir imagen", "bulk_delete_duplicates_confirmation": "¿Estás seguro de que deseas eliminar de forma masiva {count, plural, one {# elemento duplicado} other {# elementos duplicados}}? Esto mantendrá el activo más grande de cada grupo y eliminará permanentemente todos los demás duplicados. ¡Esta acción no se puede deshacer!", "bulk_keep_duplicates_confirmation": "¿Estas seguro de que desea mantener {count, plural, one {# duplicate asset} other {# duplicate assets}} archivos duplicados? Esto resolverá todos los grupos duplicados sin borrar nada.", "bulk_trash_duplicates_confirmation": "¿Estas seguro de que desea eliminar masivamente {count, plural, one {# duplicate asset} other {# duplicate assets}} archivos duplicados? Esto mantendrá el archivo más grande de cada grupo y eliminará todos los demás duplicados.", @@ -766,8 +766,10 @@ "go_to_folder": "Ir al directorio", "go_to_search": "Ir a búsqueda", "group_albums_by": "Agrupar albums por...", + "group_country": "Agrupar por país", "group_no": "Sin agrupación", "group_owner": "Agrupar por propietario", + "group_places_by": "Agrupar lugares por...", "group_year": "Agrupar por año", "has_quota": "Su cuota", "hi_user": "Hola {name} ({email})", @@ -800,6 +802,7 @@ "include_shared_albums": "Incluir álbumes compartidos", "include_shared_partner_assets": "Incluir archivos compartidos de invitados", "individual_share": "Compartir individualmente", + "individual_shares": "Acciones individuales", "info": "Información", "interval": { "day_at_onepm": "Todos los días a las 1pm", @@ -822,6 +825,7 @@ "latest_version": "Última versión", "latitude": "Latitud", "leave": "Abandonar", + "lens_model": "Modelo de objetivo", "let_others_respond": "Permitir que otros respondan", "level": "Nivel", "library": "Biblioteca", @@ -984,6 +988,7 @@ "pick_a_location": "Elige una ubicación", "place": "Lugar", "places": "Lugares", + "places_count": "{count, plural, one {{count, number} Lugar} other {{count, number} Lugares}}", "play": "Reproducir", "play_memories": "Reproducir recuerdos", "play_motion_photo": "Reproducir foto en movimiento", @@ -1107,12 +1112,15 @@ "search": "Buscar", "search_albums": "Buscar álbums", "search_by_context": "Buscar por contexto", + "search_by_description": "Buscar por descripción", + "search_by_description_example": "Día de senderismo en Sapa", "search_by_filename": "Buscar por nombre de archivo o extensión", "search_by_filename_example": "es decir IMG_1234.JPG o PNG", "search_camera_make": "Buscar fabricante de cámara...", "search_camera_model": "Buscar modelo de cámara...", "search_city": "Buscar ciudad...", "search_country": "Buscar país...", + "search_for": "Buscar", "search_for_existing_person": "Buscar persona existente", "search_no_people": "Ninguna persona", "search_no_people_named": "Ninguna persona llamada \"{name}\"", @@ -1139,7 +1147,7 @@ "select_library_owner": "Seleccionar propietario de la biblioteca", "select_new_face": "Seleccionar nueva cara", "select_photos": "Seleccionar Fotos", - "select_trash_all": "Descartar todo", + "select_trash_all": "Seleccionar eliminar todo", "selected": "Seleccionado", "selected_count": "{count, plural, one {# seleccionado} other {# seleccionados}}", "send_message": "Enviar mensaje", @@ -1165,6 +1173,7 @@ "shared_from_partner": "Fotos de {partner}", "shared_link_options": "Opciones de enlaces compartidos", "shared_links": "Enlaces compartidos", + "shared_links_description": "Comparte fotos y vídeos con un enlace", "shared_photos_and_videos_count": "{assetCount, plural, other {# Fotos y vídeos compartidos.}}", "shared_with_partner": "Compartido con {partner}", "sharing": "Compartido", @@ -1187,6 +1196,7 @@ "show_person_options": "Mostrar opciones de la persona", "show_progress_bar": "Mostrar barra de progreso", "show_search_options": "Mostrar opciones de búsqueda", + "show_shared_links": "Mostrar enlaces compartidos", "show_slideshow_transition": "Mostrar la transición de las diapositivas", "show_supporter_badge": "Insignia de colaborador", "show_supporter_badge_description": "Mostrar una insignia de colaborador", @@ -1274,6 +1284,7 @@ "unfavorite": "Retirar favorito", "unhide_person": "Mostrar persona", "unknown": "Desconocido", + "unknown_country": "País desconocido", "unknown_year": "Año desconocido", "unlimited": "Ilimitado", "unlink_motion_video": "Desvincular vídeo en movimiento", diff --git a/i18n/et.json b/i18n/et.json index ab17fad19f..dea16174f9 100644 --- a/i18n/et.json +++ b/i18n/et.json @@ -20,7 +20,7 @@ "add_partner": "Lisa partner", "add_path": "Lisa tee", "add_photos": "Lisa fotosid", - "add_to": "Lisa kohta...", + "add_to": "Lisa kohta…", "add_to_album": "Lisa albumisse", "add_to_shared_album": "Lisa jagatud albumisse", "add_url": "Lisa URL", @@ -219,7 +219,7 @@ "reset_settings_to_default": "Lähtesta seaded", "reset_settings_to_recent_saved": "Taasta hiljuti salvestatud seaded", "scanning_library": "Kogu skaneerimine", - "search_jobs": "Otsi töödet...", + "search_jobs": "Otsi töödet…", "send_welcome_email": "Saada tervituskiri", "server_external_domain_settings": "Väline domeen", "server_external_domain_settings_description": "Domeen avalikult jagatud linkide jaoks, k.a. http(s)://", @@ -406,17 +406,17 @@ "are_these_the_same_person": "Kas need on sama isik?", "are_you_sure_to_do_this": "Kas oled kindel, et soovid seda teha?", "asset_added_to_album": "Lisatud albumisse", - "asset_adding_to_album": "Albumisse lisamine...", + "asset_adding_to_album": "Albumisse lisamine…", "asset_description_updated": "Üksuse kirjeldus on muudetud", "asset_filename_is_offline": "Üksus {filename} ei ole kättesaadav", "asset_has_unassigned_faces": "Üksusel on seostamata nägusid", - "asset_hashing": "Räsimine...", + "asset_hashing": "Räsimine…", "asset_offline": "Üksus pole kättesaadav", "asset_offline_description": "Seda välise kogu üksust ei leitud kettalt. Abi saamiseks palun võta ühendust oma Immich'i administraatoriga.", "asset_skipped": "Vahele jäetud", "asset_skipped_in_trash": "Prügikastis", "asset_uploaded": "Üleslaaditud", - "asset_uploading": "Üleslaadimine...", + "asset_uploading": "Üleslaadimine…", "assets": "Üksused", "assets_added_count": "{count, plural, one {# üksus} other {# üksust}} lisatud", "assets_added_to_album_count": "{count, plural, one {# üksus} other {# üksust}} albumisse lisatud", @@ -763,8 +763,10 @@ "go_to_folder": "Mine kausta", "go_to_search": "Otsingusse", "group_albums_by": "Grupeeri albumid...", + "group_country": "Grupeeri riigi kaupa", "group_no": "Ära grupeeri", "group_owner": "Grupeeri omaniku kaupa", + "group_places_by": "Grupeeri kohad...", "group_year": "Grupeeri aasta kaupa", "has_quota": "On kvoot", "hi_user": "Tere {name} ({email})", @@ -819,6 +821,7 @@ "latest_version": "Uusim versioon", "latitude": "Laiuskraad", "leave": "Lahku", + "lens_model": "Läätse mudel", "let_others_respond": "Luba teistel vastata", "level": "Tase", "library": "Kogu", @@ -861,6 +864,7 @@ "memories": "Mälestused", "memories_setting_description": "Halda, mida sa oma mälestustes näed", "memory": "Mälestus", + "memory_lane_title": "Mälestus {title}", "menu": "Menüü", "merge": "Ühenda", "merge_people": "Ühenda isikud", @@ -979,6 +983,7 @@ "pick_a_location": "Vali asukoht", "place": "Asukoht", "places": "Kohad", + "places_count": "{count, plural, one {{count, number} koht} other {{count, number} kohta}}", "play": "Esita", "play_memories": "Esita mälestused", "play_motion_photo": "Esita liikuv foto", @@ -994,6 +999,7 @@ "profile_image_of_user": "Kasutaja {user} profiilipilt", "profile_picture_set": "Profiilipilt määratud.", "public_album": "Avalik album", + "public_share": "Avalik jagamine", "purchase_account_info": "Toetaja", "purchase_activated_subtitle": "Aitäh, et toetad Immich'it ja avatud lähtekoodiga tarkvara", "purchase_activated_time": "Aktiveeritud {date, date}", @@ -1032,6 +1038,7 @@ "rating_description": "Kuva infopaneelis EXIF hinnangut", "reaction_options": "Reaktsiooni valikud", "read_changelog": "Vaata muudatuste ülevaadet", + "reassign": "Määra uuesti", "reassigned_assets_to_existing_person": "{count, plural, one {# üksus} other {# üksust}} seostatud {name, select, null {olemasoleva isikuga} other {isikuga {name}}}", "reassigned_assets_to_new_person": "{count, plural, one {# üksus} other {# üksust}} seostatud uue isikuga", "reassing_hint": "Seosta valitud üksused olemasoleva isikuga", @@ -1066,6 +1073,7 @@ "removed_from_favorites_count": "{count, plural, other {# eemaldatud}} lemmikutest", "removed_tagged_assets": "Silt eemaldatud {count, plural, one {# üksuselt} other {# üksuselt}}", "rename": "Nimeta ümber", + "repair": "Parandus", "repair_no_results_message": "Mittejälgitavad ja puuduvad failid kuvatakse siin", "replace_with_upload": "Asenda üleslaadimisega", "repository": "Koodihoidla", @@ -1099,12 +1107,15 @@ "search": "Otsi", "search_albums": "Otsi albumeid", "search_by_context": "Otsi konteksti alusel", + "search_by_description": "Otsi kirjelduse alusel", + "search_by_description_example": "Matkapäev Sapas", "search_by_filename": "Otsi failinime või -laiendi järgi", "search_by_filename_example": "st. IMG_1234.JPG või PNG", "search_camera_make": "Otsi kaamera marki...", "search_camera_model": "Otsi kaamera mudelit...", "search_city": "Otsi linna...", "search_country": "Otsi riiki...", + "search_for": "Otsi", "search_for_existing_person": "Otsi olemasolevat isikut", "search_no_people": "Isikuid ei ole", "search_no_people_named": "Ei ole isikuid nimega \"{name}\"", @@ -1127,9 +1138,11 @@ "select_face": "Vali nägu", "select_featured_photo": "Vali esiletõstetud foto", "select_from_computer": "Vali arvutist", + "select_keep_all": "Vali jäta kõik alles", "select_library_owner": "Vali kogu omanik", "select_new_face": "Vali uus nägu", "select_photos": "Vali fotod", + "select_trash_all": "Vali kõik prügikasti", "selected": "Valitud", "selected_count": "{count, plural, other {# valitud}}", "send_message": "Saada sõnum", @@ -1155,6 +1168,7 @@ "shared_from_partner": "Fotod partnerilt {partner}", "shared_link_options": "Jagatud lingi valikud", "shared_links": "Jagatud lingid", + "shared_links_description": "Jaga fotosid ja videosid lingiga", "shared_photos_and_videos_count": "{assetCount, plural, other {# jagatud fotot ja videot.}}", "shared_with_partner": "Jagatud partneriga {partner}", "sharing": "Jagamine", @@ -1177,6 +1191,7 @@ "show_person_options": "Näita isiku valikuid", "show_progress_bar": "Kuva edenemisriba", "show_search_options": "Kuva otsingu valikud", + "show_shared_links": "Näita jagatud linke", "show_slideshow_transition": "Kuva slaidiesitluse üleminekud", "show_supporter_badge": "Toetaja märk", "show_supporter_badge_description": "Kuva toetaja märki", @@ -1217,6 +1232,7 @@ "storage": "Talletusruum", "storage_label": "Talletussilt", "storage_usage": "{used}/{available} kasutatud", + "submit": "Saada", "suggestions": "Soovitused", "sunrise_on_the_beach": "Päikesetõus rannal", "support": "Tugi", @@ -1245,6 +1261,7 @@ "to_change_password": "Muuda parool", "to_favorite": "Lemmik", "to_login": "Logi sisse", + "to_parent": "Tase üles", "to_trash": "Prügikasti", "toggle_settings": "Kuva/peida seaded", "toggle_theme": "Lülita tume teema", @@ -1262,6 +1279,7 @@ "unfavorite": "Eemalda lemmikutest", "unhide_person": "Ära peida isikut", "unknown": "Teadmata", + "unknown_country": "Tundmatu riik", "unknown_year": "Teadmata aasta", "unlimited": "Piiramatu", "unlink_oauth": "Eemalda OAuth ühendus", @@ -1269,6 +1287,8 @@ "unnamed_album": "Nimetu album", "unnamed_album_delete_confirmation": "Kas oled kindel, et soovid selle albumi kustutada?", "unsaved_change": "Salvestamata muudatus", + "unselect_all": "Ära vali ühtegi", + "unselect_all_duplicates": "Ära vali duplikaate", "unstack": "Eralda", "unstacked_assets_count": "{count, plural, one {# üksus} other {# üksust}} eraldatud", "untracked_files": "Mittejälgitavad failid", diff --git a/i18n/fa.json b/i18n/fa.json index 4417786169..ef058deb65 100644 --- a/i18n/fa.json +++ b/i18n/fa.json @@ -19,7 +19,7 @@ "add_partner": "افزودن شریک", "add_path": "افزودن مسیر", "add_photos": "افزودن عکس ها", - "add_to": "افزودن به ...", + "add_to": "افزودن به …", "add_to_album": "افزودن به آلبوم", "add_to_shared_album": "افزودن به آلبوم اشتراکی", "added_to_archive": "به آرشیو اضافه شد", diff --git a/i18n/fi.json b/i18n/fi.json index e67178782c..843e42f887 100644 --- a/i18n/fi.json +++ b/i18n/fi.json @@ -20,7 +20,7 @@ "add_partner": "Lisää kumppani", "add_path": "Lisää polku", "add_photos": "Lisää kuvia", - "add_to": "Lisää...", + "add_to": "Lisää…", "add_to_album": "Lisää albumiin", "add_to_shared_album": "Lisää jaettuun albumiin", "add_url": "Lisää URL", @@ -540,7 +540,7 @@ "delete_shared_link": "Poista jaettu linkki", "delete_tag": "Poista tunniste", "delete_tag_confirmation_prompt": "Haluatko varmasti poistaa tunnisteen {tagName}?", - "delete_user": "Poista käyttäjä pysyvästi", + "delete_user": "Poista käyttäjä", "deleted_shared_link": "Jaettu linkki poistettu", "deletes_missing_assets": "Poistaa levyltä puuttuvat resurssit", "description": "Kuvaus", diff --git a/i18n/fr.json b/i18n/fr.json index 2b5635fd91..dfb095046f 100644 --- a/i18n/fr.json +++ b/i18n/fr.json @@ -59,7 +59,7 @@ "external_library_management": "Gestion de la bibliothèque externe", "face_detection": "Détection des visages", "face_detection_description": "Détection des visages dans les médias à l'aide de l'apprentissage automatique. Pour les vidéos, seule la miniature est prise en compte. « Actualiser » (re)traite tous les médias. « Réinitialiser » retraite tous les médias en repartant de zéro. « Manquant » met en file d'attente les médias qui n'ont pas encore été pris en compte. Lorsque la détection est terminée, tous les visages détectés sont ensuite mis en file d'attente pour la reconnaissance faciale.", - "facial_recognition_job_description": "Regrouper les visages détectés en personnes. Cette étape est exécutée une fois la détection des visages terminée. « Rafraichir » (re)regroupe tous les visages. « Manquant » met en file d'attente les visages auxquels aucune personne n'a été attribuée.", + "facial_recognition_job_description": "Regrouper les visages détectés en personnes. Cette étape est exécutée une fois la détection des visages terminée. « Réinitialiser » (re)regroupe tous les visages. « Manquant » met en file d'attente les visages auxquels aucune personne n'a été attribuée.", "failed_job_command": "La commande {command} a échoué pour la tâche : {job}", "force_delete_user_warning": "ATTENTION : Cette opération entraîne la suppression immédiate de l'utilisateur et de tous ses médias. Cette opération ne peut être annulée et les fichiers ne peuvent être récupérés.", "forcing_refresh_library_files": "Forcer le rafraîchissement de tous les fichiers de la bibliothèque", @@ -219,7 +219,7 @@ "reset_settings_to_default": "Réinitialiser les paramètres par défaut", "reset_settings_to_recent_saved": "Paramètres réinitialisés avec les derniers paramètres enregistrés", "scanning_library": "Analyse de la bibliothèque", - "search_jobs": "Recherche des tâches ...", + "search_jobs": "Recherche des tâches…", "send_welcome_email": "Envoyer un courriel de bienvenue", "server_external_domain_settings": "Domaine externe", "server_external_domain_settings_description": "Nom de domaine pour les liens partagés publics, y compris http(s)://", @@ -406,17 +406,17 @@ "are_these_the_same_person": "Est-ce la même personne ?", "are_you_sure_to_do_this": "Êtes-vous sûr de vouloir faire ceci ?", "asset_added_to_album": "Ajouté à l'album", - "asset_adding_to_album": "Ajout à l'album...", + "asset_adding_to_album": "Ajout à l'album…", "asset_description_updated": "La description du média a été mise à jour", "asset_filename_is_offline": "Le média {filename} est hors ligne", "asset_has_unassigned_faces": "Le média a des visages non attribués", - "asset_hashing": "Hachage...", + "asset_hashing": "Hachage…", "asset_offline": "Média hors ligne", "asset_offline_description": "Ce média externe n'est plus accessible sur le disque. Veuillez contacter votre administrateur Immich pour obtenir de l'aide.", "asset_skipped": "Sauté", "asset_skipped_in_trash": "À la corbeille", "asset_uploaded": "Envoyé", - "asset_uploading": "Envoi...", + "asset_uploading": "Téléversement…", "assets": "Médias", "assets_added_count": "{count, plural, one {# média ajouté} other {# médias ajoutés}}", "assets_added_to_album_count": "{count, plural, one {# média ajouté} other {# médias ajoutés}} à l'album", @@ -766,9 +766,11 @@ "go_to_folder": "Dossier", "go_to_search": "Faire une recherche", "group_albums_by": "Grouper les albums par...", + "group_country": "Grouper par pays", "group_no": "Pas de groupe", - "group_owner": "Groupe par propriétaire", - "group_year": "Groupe par année", + "group_owner": "Grouper par propriétaire", + "group_places_by": "Grouper les lieux par...", + "group_year": "Grouper par année", "has_quota": "Quota", "hi_user": "Bonjour {name} ({email})", "hide_all_people": "Cacher toutes les personnes", @@ -799,7 +801,8 @@ "include_archived": "Inclure les archives", "include_shared_albums": "Inclure les albums partagés", "include_shared_partner_assets": "Inclure les médias partagés du partenaire", - "individual_share": "Partage individuel", + "individual_share": "Partage d'un média unique", + "individual_shares": "Partages d'un média unique", "info": "Information", "interval": { "day_at_onepm": "Tous les jours à 13h", @@ -822,6 +825,7 @@ "latest_version": "Dernière version", "latitude": "Latitude", "leave": "Quitter", + "lens_model": "Modèle d'objectif", "let_others_respond": "Laisser les autres réagir", "level": "Niveau", "library": "Bibliothèque", @@ -984,6 +988,7 @@ "pick_a_location": "Choisissez un lieu", "place": "Lieu", "places": "Lieux", + "places_count": "{count, plural, one {{count, number} Lieu} other {{count, number} Lieux}}", "play": "Jouer", "play_memories": "Lancer les souvenirs", "play_motion_photo": "Jouer la photo animée", @@ -1107,12 +1112,15 @@ "search": "Recherche", "search_albums": "Rechercher des albums", "search_by_context": "Rechercher par contexte", + "search_by_description": "Recherche par description", + "search_by_description_example": "Randonnée à Sapa", "search_by_filename": "Rechercher par nom du fichier ou extension", "search_by_filename_example": "Exemple : IMG_1234.JPG ou PNG", "search_camera_make": "Rechercher par marque d'appareil photo...", "search_camera_model": "Rechercher par modèle d'appareil photo...", "search_city": "Rechercher par ville...", "search_country": "Rechercher par pays...", + "search_for": "Chercher", "search_for_existing_person": "Rechercher une personne existante", "search_no_people": "Aucune personne", "search_no_people_named": "Aucune personne nommée « {name} »", @@ -1165,6 +1173,7 @@ "shared_from_partner": "Photos de {partner}", "shared_link_options": "Options de lien partagé", "shared_links": "Liens partagés", + "shared_links_description": "Partager les photos et vidéos via un lien", "shared_photos_and_videos_count": "{assetCount, plural, other {# photos et vidéos partagées.}}", "shared_with_partner": "Partagé avec {partner}", "sharing": "Partage", @@ -1187,6 +1196,7 @@ "show_person_options": "Afficher les options de personnes", "show_progress_bar": "Afficher la barre de progression", "show_search_options": "Afficher les options de recherche", + "show_shared_links": "Afficher les liens partagés", "show_slideshow_transition": "Afficher la transition du diaporama", "show_supporter_badge": "Badge de contributeur", "show_supporter_badge_description": "Afficher le badge de contributeur", @@ -1274,6 +1284,7 @@ "unfavorite": "Enlever des favoris", "unhide_person": "Afficher la personne", "unknown": "Inconnu", + "unknown_country": "Pays non connu", "unknown_year": "Année inconnue", "unlimited": "Illimité", "unlink_motion_video": "Détacher la photo animée", diff --git a/i18n/he.json b/i18n/he.json index daf7b1aa39..72a214079b 100644 --- a/i18n/he.json +++ b/i18n/he.json @@ -8,39 +8,39 @@ "active": "פעיל", "activity": "פעילות", "activity_changed": "הפעילות {enabled, select, true {מופעלת} other {מושבתת}}", - "add": "הוסף", - "add_a_description": "הוסף תיאור", - "add_a_location": "הוסף מיקום", - "add_a_name": "הוסף שם", - "add_a_title": "הוסף כותרת", - "add_exclusion_pattern": "הוסף דפוס החרגה", - "add_import_path": "הוסף נתיב יבוא", - "add_location": "הוסף מיקום", - "add_more_users": "הוסף עוד משתמשים", - "add_partner": "הוסף שותף", - "add_path": "הוסף נתיב", - "add_photos": "הוסף תמונות", - "add_to": "הוסף ל..", - "add_to_album": "הוסף לאלבום", - "add_to_shared_album": "הוסף לאלבום משותף", + "add": "הוספה", + "add_a_description": "הוספת תיאור", + "add_a_location": "הוספת מיקום", + "add_a_name": "הוספת שם", + "add_a_title": "הוספת כותרת", + "add_exclusion_pattern": "הוספת דפוס החרגה", + "add_import_path": "הוספת נתיב יבוא", + "add_location": "הוספת מיקום", + "add_more_users": "הוספת עוד משתמשים", + "add_partner": "הוספת שותף", + "add_path": "הוספת נתיב", + "add_photos": "הוספת תמונות", + "add_to": "הוספה ל…", + "add_to_album": "הוספה לאלבום", + "add_to_shared_album": "הוספה לאלבום משותף", "add_url": "הוספת קישור", "added_to_archive": "נוסף לארכיון", "added_to_favorites": "נוסף למועדפים", "added_to_favorites_count": "{count, number} נוספו למועדפים", "admin": { - "add_exclusion_pattern_description": "הוסף דפוסי החרגה. נתמכת התאמת דפוסים באמצעות *, ** ו-?. כדי להתעלם מכל הקבצים בתיקיה כלשהי בשם \"Raw\", השתמש ב \"**/Raw/**\". כדי להתעלם מכל הקבצים המסתיימים ב \"tif.\", השתמש ב \"tif.*/**\". כדי להתעלם מנתיב מוחלט, השתמש ב \"**/נתיב/להתעלמות\".", - "asset_offline_description": "נכס ספרייה חיצונית זה לא נמצא יותר בדיסק והועבר לאשפה. אם הקובץ הועבר מתוך הספרייה, בדוק את ציר הזמן שלך עבור הנכס המקביל החדש. כדי לשחזר נכס זה, נא לוודא ש-Immich יכול לגשת אל נתיב הקובץ למטה וסרוק מחדש את הספרייה.", + "add_exclusion_pattern_description": "הוספת דפוסי החרגה. נתמכת התאמת דפוסים באמצעות *, ** ו-?. כדי להתעלם מכל הקבצים בתיקיה כלשהי בשם \"Raw\", יש להשתמש ב \"**/Raw/**\". כדי להתעלם מכל הקבצים המסתיימים ב \"tif.\", יש להשתמש ב \"tif.*/**\". כדי להתעלם מנתיב מוחלט, יש להשתמש ב \"**/נתיב/להתעלמות\".", + "asset_offline_description": "נכס ספרייה חיצונית זה לא נמצא יותר בדיסק והועבר לאשפה. אם הקובץ הועבר מתוך הספרייה, נא לבדוק את ציר הזמן שלך עבור הנכס המקביל החדש. כדי לשחזר נכס זה, נא לוודא ש-Immich יכול לגשת אל נתיב הקובץ למטה ולסרוק מחדש את הספרייה.", "authentication_settings": "הגדרות התחברות", - "authentication_settings_description": "נהל סיסמה, OAuth, והגדרות התחברות אחרות", + "authentication_settings_description": "ניהול סיסמה, OAuth, והגדרות התחברות אחרות", "authentication_settings_disable_all": "האם ברצונך להשבית את כל שיטות ההתחברות? כניסה למערכת תהיה מושבתת לחלוטין.", - "authentication_settings_reenable": "כדי לאפשר מחדש, השתמש בפקודת שרת.", + "authentication_settings_reenable": "כדי לאפשר מחדש, נא להשתמש בפקודת שרת.", "background_task_job": "משימות רקע", "backup_database": "גיבוי מסד נתונים", "backup_database_enable_description": "אפשר גיבויי מסד נתונים", "backup_keep_last_amount": "כמות של גיבויים קודמים שיש לשמור", "backup_settings": "הגדרות גיבוי", - "backup_settings_description": "נהל הגדרות גיבוי מסד נתונים", - "check_all": "סמן הכל", + "backup_settings_description": "ניהול הגדרות גיבוי מסד נתונים", + "check_all": "סימון הכל", "cleared_jobs": "נוקו משימות עבור: {job}", "config_set_by_file": "התצורה מוגדרת כעת על ידי קובץ תצורה", "confirm_delete_library": "האם את/ה בטוח/ה שברצונך למחוק את הספרייה {library}?", @@ -76,7 +76,7 @@ "image_resolution": "רזולוציה", "image_resolution_description": "רזולוציות גבוהות יותר יכולות לשמר פרטים רבים יותר אך לוקחות זמן רב יותר לקידוד, יש להן גדלי קבצים גדולים יותר ויכולות להפחית את תגובתיות היישום.", "image_settings": "הגדרות תמונה", - "image_settings_description": "נהל את האיכות והרזולוציה של תמונות שנוצרו", + "image_settings_description": "ניהול האיכות והרזולוציה של תמונות שנוצרו", "image_thumbnail_description": "תמונה ממוזערת קטנה עם מטא-נתונים שהוסרו, משמשת בעת צפייה בקבוצות של תמונות כמו ציר הזמן הראשי", "image_thumbnail_quality_description": "איכות תמונה ממוזערת בין 1-100. גבוה יותר הוא טוב יותר, אבל מייצר קבצים גדולים יותר ויכול להפחית את תגובתיות היישום.", "image_thumbnail_title": "הגדרות תמונה ממוזערת", @@ -95,7 +95,7 @@ "library_scanning_description": "הגדר סריקת ספרייה תקופתית", "library_scanning_enable_description": "אפשר סריקת ספרייה תקופתית", "library_settings": "ספרייה חיצונית", - "library_settings_description": "נהל הגדרות ספרייה חיצונית", + "library_settings_description": "ניהול הגדרות ספרייה חיצונית", "library_tasks_description": "ביצוע משימות ספרייה", "library_watching_enable_description": "עקוב אחר שינויי קבצים בספריות חיצוניות", "library_watching_settings": "צפיית ספרייה (ניסיוני)", @@ -126,33 +126,33 @@ "machine_learning_min_recognized_faces": "מינימום פנים מזוהים", "machine_learning_min_recognized_faces_description": "המספר המינימלי של פנים מזוהים ליצירת אדם. הגדלת ערך זה הופכת את זיהוי הפנים למדויק יותר בעלות של הגברת הסיכוי שלא יוקצו פנים לאדם.", "machine_learning_settings": "הגדרות למידת מכונה", - "machine_learning_settings_description": "נהל את התכונות וההגדרות של למידת המכונה", + "machine_learning_settings_description": "ניהול התכונות וההגדרות של למידת המכונה", "machine_learning_smart_search": "חיפוש חכם", - "machine_learning_smart_search_description": "חפש תמונות באופן סמנטי באמצעות הטמעות של CLIP", + "machine_learning_smart_search_description": "חיפוש תמונות באופן סמנטי באמצעות הטמעות של CLIP", "machine_learning_smart_search_enabled": "אפשר חיפוש חכם", "machine_learning_smart_search_enabled_description": "אם מושבת, תמונות לא יקודדו לחיפוש חכם.", "machine_learning_url_description": "כתובת האתר של שרת למידת המכונה. אם ניתנת יותר מכתובת אחת, כל שרת ינסה בתורו עד אשר יענה בחיוב, בסדר התחלתי.", - "manage_concurrency": "נהל בו-זמניות", - "manage_log_settings": "נהל הגדרות רישום ביומן", + "manage_concurrency": "ניהול בו-זמניות", + "manage_log_settings": "ניהול הגדרות רישום ביומן", "map_dark_style": "עיצוב כהה", "map_enable_description": "אפשר תכונות מפה", "map_gps_settings": "הגדרות מפה & GPS", - "map_gps_settings_description": "נהל הגדרות מפה & GPS (קידוד גאוגרפי הפוך)", + "map_gps_settings_description": "ניהול הגדרות מפה & GPS (קידוד גאוגרפי הפוך)", "map_implications": "תכונת המפה מסתמכת על שירות אריח חיצוני (tiles.immich.cloud)", "map_light_style": "עיצוב בהיר", - "map_manage_reverse_geocoding_settings": "נהל הגדרות קידוד גאוגרפי הפוך", + "map_manage_reverse_geocoding_settings": "ניהול הגדרות קידוד גאוגרפי הפוך", "map_reverse_geocoding": "קידוד גיאוגרפי הפוך", "map_reverse_geocoding_enable_description": "אפשר קידוד גיאוגרפי הפוך", "map_reverse_geocoding_settings": "הגדרות קידוד גיאוגרפי הפוך", "map_settings": "מפה", - "map_settings_description": "נהל הגדרות מפה", + "map_settings_description": "ניהול הגדרות מפה", "map_style_description": "כתובת אתר לערכת נושא של מפה style.json", "metadata_extraction_job": "חלץ מטא-נתונים", "metadata_extraction_job_description": "חלץ מידע מטא-נתונים מכל נכס, כגון GPS, פנים ורזולוציה", "metadata_faces_import_setting": "אפשר יבוא פנים", "metadata_faces_import_setting_description": "יבא פנים מנתוני EXIF של תמונה ומקבצים נלווים", "metadata_settings": "הגדרות מטא-נתונים", - "metadata_settings_description": "נהל הגדרות מטא-נתונים", + "metadata_settings_description": "ניהול הגדרות מטא-נתונים", "migration_job": "העברה", "migration_job_description": "העבר תמונות ממוזערות של נכסים ופנים למבנה התיקיות העדכני ביותר", "no_paths_added": "לא נוספו נתיבים", @@ -175,7 +175,7 @@ "notification_email_username_description": "שם משתמש לשימוש בעת אימות עם שרת הדוא\"ל", "notification_enable_email_notifications": "אפשר התראות דוא\"ל", "notification_settings": "הגדרות התראות", - "notification_settings_description": "נהל הגדרות התראות, כולל דוא\"ל", + "notification_settings_description": "ניהול הגדרות התראות, כולל דוא\"ל", "oauth_auto_launch": "הפעלה אוטומטית", "oauth_auto_launch_description": "התחל את זרימת ההתחברות של OAuth באופן אוטומטי עם הניווט לדף ההתחברות", "oauth_auto_register": "רישום אוטומטי", @@ -192,7 +192,7 @@ "oauth_profile_signing_algorithm_description": "אלגוריתם המשמש לחתימה על פרופיל המשתמש.", "oauth_scope": "רמת הרשאה", "oauth_settings": "OAuth", - "oauth_settings_description": "נהל הגדרות התחברות עם OAuth", + "oauth_settings_description": "ניהול הגדרות התחברות עם OAuth", "oauth_settings_more_details": "למידע נוסף אודות תכונה זו, בדוק את התיעוד.", "oauth_signing_algorithm": "אלגוריתם חתימה", "oauth_storage_label_claim": "דרישת תווית אחסון", @@ -205,7 +205,7 @@ "offline_paths_description": "תוצאות אלו עשויות להיות עקב מחיקה ידנית של קבצים שאינם חלק מספרייה חיצונית.", "password_enable_description": "התחבר עם דוא\"ל וסיסמה", "password_settings": "סיסמת התחברות", - "password_settings_description": "נהל הגדרות סיסמת התחברות", + "password_settings_description": "ניהול הגדרות סיסמת התחברות", "paths_validated_successfully": "כל הנתיבים אומתו בהצלחה", "person_cleanup_job": "ניקוי אדם", "quota_size_gib": "גודל מכסה (GiB)", @@ -219,14 +219,14 @@ "reset_settings_to_default": "אפס הגדרות לברירת המחדל", "reset_settings_to_recent_saved": "אפס הגדרות להגדרות שנשמרו לאחרונה", "scanning_library": "סורק ספרייה", - "search_jobs": "חיפוש עבודות...", + "search_jobs": "חיפוש עבודות…", "send_welcome_email": "שלח דוא\"ל ברוכים הבאים", "server_external_domain_settings": "דומיין חיצוני", "server_external_domain_settings_description": "דומיין עבור קישורים משותפים ציבוריים, כולל http(s)://", "server_public_users": "משתמשים ציבוריים", - "server_public_users_description": "כל המשתמשים (שם ודוא\"ל) מופיעים בעת הוספת משתמש לאלבומים משותפים. כאשר התכונה מושבתת, רשימת המשתמשים תהיה זמינה רק למשתמשים בעלי הרשאות מנהל.", + "server_public_users_description": "כל המשתמשים (שם ודוא\"ל) מופיעים בעת הוספת משתמש לאלבומים משותפים. כאשר התכונה מושבתת, רשימת המשתמשים תהיה זמינה רק למשתמשים בעלי הרשאות ניהול.", "server_settings": "הגדרות שרת", - "server_settings_description": "נהל הגדרות שרת", + "server_settings_description": "ניהול הגדרות שרת", "server_welcome_message": "הודעת פתיחה", "server_welcome_message_description": "הודעה שמוצגת במסך ההתחברות.", "sidecar_job": "מטא-נתונים נלווים", @@ -246,7 +246,7 @@ "storage_template_onboarding_description": "כאשר מופעלת, תכונה זו תארגן אוטומטית קבצים בהתבסס על תבנית שהמשתמש הגדיר. עקב בעיות יציבות התכונה כבויה כברירת מחדל. למידע נוסף, נא לראות את התיעוד.", "storage_template_path_length": "מגבלת אורך נתיב משוערת: {length, number}/{limit, number}", "storage_template_settings": "תבנית אחסון", - "storage_template_settings_description": "נהל את מבנה התיקיות ואת שם הקובץ של נכס ההעלאה", + "storage_template_settings_description": "ניהול מבנה התיקיות ואת שם הקובץ של נכס ההעלאה", "storage_template_user_label": "{label} היא תווית האחסון של המשתמש", "system_settings": "הגדרות מערכת", "tag_cleanup_job": "ניקוי תגים", @@ -255,15 +255,15 @@ "template_email_invite_album": "תבנית הזמנת אלבום", "template_email_preview": "תצוגה מקדימה", "template_email_settings": "תבניות דוא\"ל", - "template_email_settings_description": "נהל תבניות התראת דוא\"ל מותאמות אישית", + "template_email_settings_description": "ניהול תבניות התראת דוא\"ל מותאמות אישית", "template_email_update_album": "עדכון תבנית אלבום", "template_email_welcome": "תבנית דוא\"ל ברוכים הבאים", "template_settings": "תבניות התראה", - "template_settings_description": "נהל תבניות מותאמות אישית עבור התראות.", + "template_settings_description": "ניהול תבניות מותאמות אישית עבור התראות.", "theme_custom_css_settings": "CSS בהתאמה אישית", "theme_custom_css_settings_description": "גיליונות סגנון מדורגים (CSS) מאפשרים התאמה אישית של העיצוב של Immich.", "theme_settings": "הגדרות ערכת נושא", - "theme_settings_description": "נהל התאמה אישית של ממשק האינטרנט של Immich", + "theme_settings_description": "ניהול התאמה אישית של ממשק האינטרנט של Immich", "these_files_matched_by_checksum": "קבצים אלה תואמים לפי סיכומי הביקורת שלהם", "thumbnail_generation_job": "צור תמונות ממוזערות", "thumbnail_generation_job_description": "יוצר תמונות ממוזערות גדולות, קטנות ומטושטשות עבור כל נכס, כמו גם תמונות ממוזערות עבור כל אדם", @@ -313,10 +313,10 @@ "transcoding_reference_frames_description": "מספר הפריימים לייחוס בעת דחיסה של פריים נתון. ערכים גבוהים יותר משפרים את יעילות הדחיסה, אך מאטים את הקידוד. 0 מגדיר את הערך זה באופן אוטומטי.", "transcoding_required_description": "רק סרטונים שאינם בפורמט מקובל", "transcoding_settings": "הגדרות המרת קידוד סרטונים", - "transcoding_settings_description": "נהל אילו סרטונים להמיר וכיצד לעבד אותם", + "transcoding_settings_description": "ניהול אילו סרטונים להמיר וכיצד לעבד אותם", "transcoding_target_resolution": "רזולוציה יעד", "transcoding_target_resolution_description": "רזולוציות גבוהות יותר יכולות לשמר פרטים רבים יותר אך לוקחות זמן רב יותר לקידוד, יש להן גדלי קבצים גדולים יותר, ויכולות להפחית את תגובתיות היישום.", - "transcoding_temporal_aq": "Temporal AQ", + "transcoding_temporal_aq": "AQ מבוסס זמן", "transcoding_temporal_aq_description": "חל רק על NVENC. מגביר את האיכות של סצנות עם רמת פירוט גבוהה בהילוך איטי. ייתכן שלא יהיה תואם למכשירים ישנים יותר.", "transcoding_threads": "תהליכונים", "transcoding_threads_description": "ערכים גבוהים יותר מובילים לקידוד מהיר יותר, אך משאירים פחות מקום לשרת לעבד משימות אחרות בעודו פעיל. ערך זה לא אמור להיות יותר ממספר ליבות המעבד. ממקסם את הניצול אם מוגדר ל-0.", @@ -332,7 +332,7 @@ "trash_number_of_days": "מספר הימים", "trash_number_of_days_description": "מספר הימים לשמירה על הנכסים באשפה לפני הסרתם לצמיתות", "trash_settings": "הגדרות האשפה", - "trash_settings_description": "נהל את הגדרות האשפה", + "trash_settings_description": "ניהול הגדרות האשפה", "untracked_files": "קבצים ללא מעקב", "untracked_files_description": "קבצים אלה אינם נמצאים במעקב של היישום. הם יכולים להיות תוצאות של העברות כושלות, העלאות שנקטעו, או שנותרו מאחור בגלל שיבוש בתוכנה", "user_cleanup_job": "ניקוי משתמשים", @@ -347,7 +347,7 @@ "user_restore_description": "החשבון של {user} ישוחזר.", "user_restore_scheduled_removal": "שחזר משתמש - מחיקה מתוזמנת ב-{date, date, long}", "user_settings": "הגדרות משתמש", - "user_settings_description": "נהל הגדרות משתמש", + "user_settings_description": "ניהול הגדרות משתמש", "user_successfully_removed": "המשתמש {email} הוסר בהצלחה.", "version_check_enabled_description": "אפשר בדיקת גרסה", "version_check_implications": "תכונת בדיקת הגרסה מסתמכת על תקשורת תקופתית עם github.com", @@ -406,17 +406,17 @@ "are_these_the_same_person": "האם אלה אותו האדם?", "are_you_sure_to_do_this": "האם את/ה בטוח/ה שברצונך לעשות את זה?", "asset_added_to_album": "נוסף לאלבום", - "asset_adding_to_album": "מוסיף לאלבום...", + "asset_adding_to_album": "מוסיף לאלבום…", "asset_description_updated": "תיאור הנכס עודכן", "asset_filename_is_offline": "הנכס {filename} אינו מקוון", "asset_has_unassigned_faces": "לנכס יש פנים שלא הוקצו", - "asset_hashing": "מגבב...", + "asset_hashing": "מגבב…", "asset_offline": "נכס לא מקוון", - "asset_offline_description": "הנכס החיצוני הזה כבר לא נמצא בדיסק. אנא צור קשר עם מנהל Immich שלך לקבלת עזרה.", + "asset_offline_description": "הנכס החיצוני הזה כבר לא נמצא בדיסק. נא ליצור קשר עם מנהל Immich שלך לקבלת עזרה.", "asset_skipped": "דילג", "asset_skipped_in_trash": "באשפה", "asset_uploaded": "הועלה", - "asset_uploading": "מעלה...", + "asset_uploading": "מעלה…", "assets": "נכסים", "assets_added_count": "{count, plural, one {נוסף נכס #} other {נוספו # נכסים}}", "assets_added_to_album_count": "{count, plural, one {נוסף נכס #} other {נוספו # נכסים}} לאלבום", @@ -437,7 +437,7 @@ "birthdate_set_description": "תאריך לידה משמש לחישוב הגיל של האדם הזה בזמן תצלום.", "blurred_background": "רקע מטושטש", "bugs_and_feature_requests": "באגים & בקשות לתכונות", - "build": "Build", + "build": "גרסאת בנייה", "build_image": "גרסת תוכנה", "bulk_delete_duplicates_confirmation": "האם את/ה בטוח/ה שברצונך למחוק בכמות גדולה {count, plural, one {נכס # כפול} other {# נכסים כפולים}}? זה ישמור על הנכס הכי גדול של כל קבוצה וימחק לצמיתות את כל שאר הכפילויות. את/ה לא יכול/ה לבטל את הפעולה הזו!", "bulk_keep_duplicates_confirmation": "האם את/ה בטוח/ה שברצונך להשאיר {count, plural, one {נכס # כפול} other {# נכסים כפולים}}? זה יפתור את כל הקבוצות הכפולות מבלי למחוק דבר.", @@ -480,7 +480,7 @@ "comments_and_likes": "תגובות & לייקים", "comments_are_disabled": "תגובות מושבתות", "confirm": "אישור", - "confirm_admin_password": "אשר סיסמת מנהל", + "confirm_admin_password": "אישור סיסמת מנהל", "confirm_delete_shared_link": "האם את/ה בטוח/ה שברצונך למחוק את הקישור המשותף הזה?", "confirm_keep_this_delete_others": "כל שאר הנכסים בערימה יימחקו למעט נכס זה. האם את/ה בטוח/ה שברצונך להמשיך?", "confirm_password": "אשר סיסמה", @@ -548,7 +548,7 @@ "direction": "כיוון", "disabled": "מושבת", "disallow_edits": "אל תאפשר עריכות", - "discord": "Discord", + "discord": "דיסקורד", "discover": "גילוי", "dismiss_all_errors": "התעלמות מכל השגיאות", "dismiss_error": "התעלמות מהשגיאה", @@ -563,7 +563,7 @@ "download_include_embedded_motion_videos": "סרטונים מוטמעים", "download_include_embedded_motion_videos_description": "כלול סרטונים מוטעמים בתמונות עם תנועה כקובץ נפרד", "download_settings": "הורדה", - "download_settings_description": "נהל הגדרות הקשורות להורדת נכסים", + "download_settings_description": "ניהול הגדרות הקשורות להורדת נכסים", "downloading": "מוריד", "downloading_asset_filename": "מוריד נכס {filename}", "drop_files_to_upload": "שחרר קבצים בכל מקום כדי להעלות", @@ -748,7 +748,7 @@ "favorites": "מועדפים", "feature_photo_updated": "תמונה מייצגת עודכנה", "features": "תכונות", - "features_setting_description": "נהל את תכונות היישום", + "features_setting_description": "ניהול תכונות היישום", "file_name": "שם הקובץ", "file_name_or_extension": "שם קובץ או סיומת", "filename": "שם קובץ", @@ -766,8 +766,10 @@ "go_to_folder": "עבור לתיקיה", "go_to_search": "עבור לחיפוש", "group_albums_by": "קבץ אלבומים לפי..", + "group_country": "קבץ לפי מדינה", "group_no": "אין קיבוץ", "group_owner": "קבץ לפי בעלים", + "group_places_by": "קבץ מקומות לפי...", "group_year": "קבץ לפי שנה", "has_quota": "יש מכסה", "hi_user": "היי {name}, ({email})", @@ -800,6 +802,7 @@ "include_shared_albums": "כלול אלבומים משותפים", "include_shared_partner_assets": "כלול נכסי שותף משותפים", "individual_share": "שיתוף יחיד", + "individual_shares": "שיתופים בודדים", "info": "מידע", "interval": { "day_at_onepm": "כל יום בשעה 13:00", @@ -822,6 +825,7 @@ "latest_version": "גרסה עדכנית ביותר", "latitude": "קו רוחב", "leave": "לעזוב", + "lens_model": "דגם עדשה", "let_others_respond": "אפשר לאחרים להגיב", "level": "רמה", "library": "ספרייה", @@ -849,13 +853,13 @@ "loop_videos_description": "אפשר הפעלה חוזרת אוטומטית של סרטון במציג הפרטים.", "main_branch_warning": "את/ה משתמש/ת בגרסת פיתוח; אנחנו ממליצים בחום להשתמש בגרסה יציבה!", "make": "תוצרת", - "manage_shared_links": "נהל קישורים משותפים", - "manage_sharing_with_partners": "נהל שיתוף עם שותפים", - "manage_the_app_settings": "נהל את הגדרות האפליקציה", - "manage_your_account": "נהל את החשבון שלך", - "manage_your_api_keys": "נהל את מפתחות ה API שלך", - "manage_your_devices": "נהל את המכשירים המחוברים שלך", - "manage_your_oauth_connection": "נהל את חיבור ה-OAuth שלך", + "manage_shared_links": "ניהול קישורים משותפים", + "manage_sharing_with_partners": "ניהול שיתוף עם שותפים", + "manage_the_app_settings": "ניהול הגדרות האפליקציה", + "manage_your_account": "ניהול החשבון שלך", + "manage_your_api_keys": "ניהול מפתחות ה API שלך", + "manage_your_devices": "ניהול המכשירים המחוברים שלך", + "manage_your_oauth_connection": "ניהול חיבור ה-OAuth שלך", "map": "מפה", "map_marker_for_images": "סמן מפה לתמונות שצולמו ב{city}, {country}", "map_marker_with_image": "סמן מפה עם תמונה", @@ -863,7 +867,7 @@ "matches": "התאמות", "media_type": "סוג מדיה", "memories": "זכרונות", - "memories_setting_description": "נהל מה שאת/ה רואה בזכרונות שלך", + "memories_setting_description": "ניהול מה שאת/ה רואה בזכרונות שלך", "memory": "זיכרון", "memory_lane_title": "משעול הזיכרונות {title}", "menu": "תפריט", @@ -915,7 +919,7 @@ "notes": "הערות", "notification_toggle_setting_description": "אפשר התראות דוא\"ל", "notifications": "התראות", - "notifications_setting_description": "נהל התראות", + "notifications_setting_description": "ניהול התראות", "oauth": "OAuth", "official_immich_resources": "משאבי Immich רשמיים", "offline": "לא מקוון", @@ -984,6 +988,7 @@ "pick_a_location": "בחר מיקום", "place": "מקום", "places": "מקומות", + "places_count": "{count, plural, one {מקום {count, number}} other {{count, number} מקומות}}", "play": "נגן", "play_memories": "נגן זכרונות", "play_motion_photo": "הפעל תמונה עם תנועה", @@ -1104,27 +1109,30 @@ "scan_library": "סרוק", "scan_settings": "הגדרות סריקה", "scanning_for_album": "סורק אחר אלבום...", - "search": "חפש", - "search_albums": "חפש אלבומים", - "search_by_context": "חפש לפי הקשר", + "search": "חיפוש", + "search_albums": "חיפוש אלבומים", + "search_by_context": "חיפוש לפי הקשר", + "search_by_description": "חיפוש לפי תיאור", + "search_by_description_example": "יום טיול בסאפה", "search_by_filename": "חיפוש לפי שם קובץ או סיומת", "search_by_filename_example": "לדוגמא IMG_1234.JPG או PNG", - "search_camera_make": "חפש תוצרת מצלמה...", - "search_camera_model": "חפש דגם מצלמה...", - "search_city": "חפש עיר...", - "search_country": "חפש ארץ...", - "search_for_existing_person": "חפש אדם קיים", + "search_camera_make": "חיפוש תוצרת המצלמה...", + "search_camera_model": "חפש דגם המצלמה...", + "search_city": "חיפוש עיר...", + "search_country": "חיפוש ארץ...", + "search_for": "חיפוש", + "search_for_existing_person": "חיפוש אדם קיים", "search_no_people": "אין אנשים", "search_no_people_named": "אין אנשים בשם \"{name}\"", "search_options": "אפשרויות חיפוש", - "search_people": "חפש אנשים", - "search_places": "חפש מקומות", + "search_people": "חיפוש אנשים", + "search_places": "חיפוש מקומות", "search_settings": "הגדרות חיפוש", - "search_state": "חפש מדינה...", + "search_state": "חיפוש מדינה...", "search_tags": "חיפוש תגים...", - "search_timezone": "חפש אזור זמן...", + "search_timezone": "חיפוש אזור זמן...", "search_type": "סוג חיפוש", - "search_your_photos": "חפש בתמונות שלך", + "search_your_photos": "חיפוש בתמונות שלך", "searching_locales": "מחפש אזורי שפה...", "second": "שנייה", "see_all_people": "ראה את כל האנשים", @@ -1165,6 +1173,7 @@ "shared_from_partner": "תמונות מאת {partner}", "shared_link_options": "אפשרויות קישור משותף", "shared_links": "קישורים משותפים", + "shared_links_description": "שתף תמונות וסרטונים עם קישור", "shared_photos_and_videos_count": "{assetCount, plural, other {# תמונות וסרטונים משותפים.}}", "shared_with_partner": "משותף עם {partner}", "sharing": "שיתוף", @@ -1187,6 +1196,7 @@ "show_person_options": "הצג אפשרויות אדם", "show_progress_bar": "הצג סרגל התקדמות", "show_search_options": "הצג אפשרויות חיפוש", + "show_shared_links": "הצג קישורים משותפים", "show_slideshow_transition": "הצג מעבר מצגת", "show_supporter_badge": "תג תומך", "show_supporter_badge_description": "הצג תג תומך", @@ -1215,7 +1225,7 @@ "stack_select_one_photo": "בחר תמונה ראשית אחת עבור הערימה", "stack_selected_photos": "צור ערימת תמונות נבחרות", "stacked_assets_count": "{count, plural, one {נכס # נערם} other {# נכסים נערמו}}", - "stacktrace": "Stacktrace", + "stacktrace": "Stack trace", "start": "התחל", "start_date": "תאריך התחלה", "state": "מדינה", @@ -1274,6 +1284,7 @@ "unfavorite": "לא מועדף", "unhide_person": "בטל הסתרת אדם", "unknown": "לא ידוע", + "unknown_country": "מדינה לא ידועה", "unknown_year": "שנה לא ידועה", "unlimited": "בלתי מוגבל", "unlink_motion_video": "בטל קישור סרטון תנועה", @@ -1307,7 +1318,7 @@ "user_id": "מזהה משתמש", "user_liked": "{user} אהב את {type, select, photo {התמונה הזאת} video {הסרטון הזה} asset {הנכס הזה} other {זה}}", "user_purchase_settings": "רכישה", - "user_purchase_settings_description": "נהל את הרכישה שלך", + "user_purchase_settings_description": "ניהול הרכישה שלך", "user_role_set": "הגדר את {user} בתור {role}", "user_usage_detail": "פרטי השימוש של המשתמש", "user_usage_stats": "סטטיסטיקות שימוש בחשבון", diff --git a/i18n/hu.json b/i18n/hu.json index 7c1616c9a0..5da84cdf3a 100644 --- a/i18n/hu.json +++ b/i18n/hu.json @@ -20,7 +20,7 @@ "add_partner": "Partner hozzáadása", "add_path": "Elérési útvonal megadása", "add_photos": "Fotók hozzáadása", - "add_to": "Hozzáadás ide...", + "add_to": "Hozzáadás ide…", "add_to_album": "Felvétel albumba", "add_to_shared_album": "Felvétel megosztott albumba", "add_url": "URL hozzáadása", @@ -219,7 +219,7 @@ "reset_settings_to_default": "Beállítások visszaállítása az alapértelmezettre", "reset_settings_to_recent_saved": "Beállítások visszaállítása a legutóbb mentettre", "scanning_library": "Képtár átfésülése", - "search_jobs": "Feladatok keresése...", + "search_jobs": "Feladatok keresése…", "send_welcome_email": "Üdvözlő email küldése", "server_external_domain_settings": "Külső domain", "server_external_domain_settings_description": "Nyilvánosan megosztott linkek domainje (http(s)://-sel)", @@ -406,17 +406,17 @@ "are_these_the_same_person": "Ugyanaz a személy?", "are_you_sure_to_do_this": "Biztosan ezt szeretnéd csinálni?", "asset_added_to_album": "Hozzáadva az albumhoz", - "asset_adding_to_album": "Hozzáadás az albumhoz...", + "asset_adding_to_album": "Hozzáadás az albumhoz…", "asset_description_updated": "Az elem leírása frissült", "asset_filename_is_offline": "A(z) {filename} elem nem elérhető, mert offline", "asset_has_unassigned_faces": "Az elemnek hozzá nem rendelt arcai vannak", - "asset_hashing": "Hash számítása...", + "asset_hashing": "Hash számítása…", "asset_offline": "Elem Offline", "asset_offline_description": "Ez a külső elem már nem elérhető a lemezen. Kérlek, lépj kapcsolatba az Immich adminisztrátorával.", "asset_skipped": "Kihagyva", "asset_skipped_in_trash": "Lomtárban", "asset_uploaded": "Feltöltve", - "asset_uploading": "Feltöltés...", + "asset_uploading": "Feltöltés…", "assets": "Elemek", "assets_added_count": "{count, plural, other {# elem}} hozzáadva", "assets_added_to_album_count": "{count, plural, other {# elem}} hozzáadva az albumhoz", @@ -766,8 +766,10 @@ "go_to_folder": "Ugrás a mappához", "go_to_search": "Ugrás a kereséshez", "group_albums_by": "Albumok csoportosítása...", + "group_country": "Csoportosítás ország szerint", "group_no": "Nincs csoportosítás", "group_owner": "Csoportosítás tulajdonos szerint", + "group_places_by": "Helyszínek csoportosítása...", "group_year": "Csoportosítás év szerint", "has_quota": "Kvóta", "hi_user": "Szia {name} ({email})", @@ -822,6 +824,7 @@ "latest_version": "Legfrissebb Verzió", "latitude": "Szélesség", "leave": "Elhagyás", + "lens_model": "Objektív modell", "let_others_respond": "Mások is reagálhatnak", "level": "Szint", "library": "Képtár", @@ -1107,12 +1110,15 @@ "search": "Keresés", "search_albums": "Albumok keresése", "search_by_context": "Keresés tartalom alapján", + "search_by_description": "Keresés leírás alapján", + "search_by_description_example": "Túrázós nap Szapában", "search_by_filename": "Keresés fájlnév vagy kiterjesztés alapján", "search_by_filename_example": "például IMG_1234.JPG vagy PNG", "search_camera_make": "Kameragyártó keresése...", "search_camera_model": "Kameramodell keresése...", "search_city": "Város keresése...", "search_country": "Ország keresése...", + "search_for": "Keresés", "search_for_existing_person": "Már meglévő személy keresése", "search_no_people": "Nincs személy", "search_no_people_named": "Nincs \"{name}\" nevű személy", @@ -1187,6 +1193,7 @@ "show_person_options": "Személy beállítások mutatása", "show_progress_bar": "Folyamatjelző Mutatása", "show_search_options": "Keresési lehetőségek mutatása", + "show_shared_links": "Megosztott linkek megjelenítése", "show_slideshow_transition": "Vetítés áttűnési effekt mutatása", "show_supporter_badge": "Támogató jelvény", "show_supporter_badge_description": "Támogató jelvény mutatása", @@ -1274,6 +1281,7 @@ "unfavorite": "Kedvenc közül kivesz", "unhide_person": "Nem rejtett személy", "unknown": "Ismeretlen", + "unknown_country": "Ismeretlen ország", "unknown_year": "Ismeretlen Év", "unlimited": "Korlátlan", "unlink_motion_video": "Mozgókép leválasztása", diff --git a/i18n/id.json b/i18n/id.json index 41ef0b008c..79c9deb178 100644 --- a/i18n/id.json +++ b/i18n/id.json @@ -20,7 +20,7 @@ "add_partner": "Tambahkan partner", "add_path": "Tambahkan jalur", "add_photos": "Tambahkan foto", - "add_to": "Tambahkan ke...", + "add_to": "Tambahkan ke…", "add_to_album": "Tambahkan ke album", "add_to_shared_album": "Tambahkan ke album terbagi", "add_url": "Tambahkan URL", @@ -219,7 +219,7 @@ "reset_settings_to_default": "Atur ulang pengaturan ke bawaan", "reset_settings_to_recent_saved": "Atur ulang pengaturan ke pengaturan tersimpan terkini", "scanning_library": "Memindai pustaka", - "search_jobs": "Mencari tugas...", + "search_jobs": "Mencari tugas…", "send_welcome_email": "Kirim surel selamat datang", "server_external_domain_settings": "Domain eksternal", "server_external_domain_settings_description": "Domain untuk tautan terbagi publik, termasuk http(s)://", @@ -406,17 +406,17 @@ "are_these_the_same_person": "Apakah ini adalah orang yang sama?", "are_you_sure_to_do_this": "Apakah Anda yakin ingin melakukan ini?", "asset_added_to_album": "Telah ditambahkan ke album", - "asset_adding_to_album": "Menambahkan ke album...", + "asset_adding_to_album": "Menambahkan ke album…", "asset_description_updated": "Deskripsi aset telah diperbarui", "asset_filename_is_offline": "Aset {filename} sedang luring", "asset_has_unassigned_faces": "Aset memiliki wajah yang belum ditetapkan", - "asset_hashing": "Memilah...", + "asset_hashing": "Memilah…", "asset_offline": "Aset Luring", "asset_offline_description": "Aset eksternal ini tidak ada lagi di diska. Silakan hubungi administrator Immich Anda untuk bantuan.", "asset_skipped": "Dilewati", "asset_skipped_in_trash": "Dalam sampah", "asset_uploaded": "Sudah diunggah", - "asset_uploading": "Mengunggah...", + "asset_uploading": "Mengunggah…", "assets": "Aset", "assets_added_count": "{count, plural, one {# aset} other {# aset}} ditambahkan", "assets_added_to_album_count": "Ditambahkan {count, plural, one {# aset} other {# aset}} ke album", @@ -766,8 +766,10 @@ "go_to_folder": "Pergi ke folder", "go_to_search": "Pergi ke pencarian", "group_albums_by": "Kelompokkan album berdasarkan...", + "group_country": "Kelompokkan berdasarkan negara", "group_no": "Tidak ada pengelompokan", "group_owner": "Kelompokkan berdasarkan pemilik", + "group_places_by": "Kelompokkan tempat berdasarkan…", "group_year": "Kelompokkan berdasarkan tahun", "has_quota": "Memiliki kuota", "hi_user": "Hai {name} ({email})", @@ -800,6 +802,7 @@ "include_shared_albums": "Termasuk album terbagi", "include_shared_partner_assets": "Termasuk aset terbagi dengan partner", "individual_share": "Bagikan individu", + "individual_shares": "Pembagian individu", "info": "Info", "interval": { "day_at_onepm": "Setiap hari pada 13.00", @@ -822,6 +825,7 @@ "latest_version": "Versi Terkini", "latitude": "Lintang", "leave": "Tinggalkan", + "lens_model": "Model lensa", "let_others_respond": "Biarkan orang lain merespons", "level": "Tingkat", "library": "Pustaka", @@ -984,6 +988,7 @@ "pick_a_location": "Pilih lokasi", "place": "Tempat", "places": "Tempat", + "places_count": "{count, plural, one {{count, number} Tempat} other {{count, number} Tempat}}", "play": "Putar", "play_memories": "Putar kenangan", "play_motion_photo": "Putar Foto Gerak", @@ -1107,12 +1112,15 @@ "search": "Cari", "search_albums": "Cari album", "search_by_context": "Cari berdasarkan konteks", + "search_by_description": "Cari berdasarkan deskripsi", + "search_by_description_example": "Hari mendaki di Sapa", "search_by_filename": "Cari berdasarkan nama berkas atau ekstensi", "search_by_filename_example": "mis. IMG_1234.JPG atau PNG", "search_camera_make": "Cari merek kamera...", "search_camera_model": "Cari model kamera...", "search_city": "Cari kota...", "search_country": "Cari negara...", + "search_for": "Cari", "search_for_existing_person": "Cari orang yang sudah ada", "search_no_people": "Tidak ada orang", "search_no_people_named": "Tidak ada orang bernama \"{name}\"", @@ -1165,6 +1173,7 @@ "shared_from_partner": "Foto dari {partner}", "shared_link_options": "Pilihan tautan bersama", "shared_links": "Tautan terbagi", + "shared_links_description": "Bagikan foto dan video dengan tautan", "shared_photos_and_videos_count": "{assetCount, plural, other {# foto & video terbagi.}}", "shared_with_partner": "Dibagikan dengan {partner}", "sharing": "Pembagian", @@ -1187,6 +1196,7 @@ "show_person_options": "Tampilkan opsi orang", "show_progress_bar": "Tampilkan Bilah Progres", "show_search_options": "Tampilkan opsi pencarian", + "show_shared_links": "Tampilkan tautan terbagi", "show_slideshow_transition": "Tampilkan transisi salindia", "show_supporter_badge": "Lencana suporter", "show_supporter_badge_description": "Tampilkan lencana suporter", @@ -1274,6 +1284,7 @@ "unfavorite": "Hapus favorit", "unhide_person": "Munculkan orang", "unknown": "Tidak diketahui", + "unknown_country": "Negara Tidak Diketahui", "unknown_year": "Tahun Tidak Diketahui", "unlimited": "Tidak terbatas", "unlink_motion_video": "Membatalkan tautan video gerak", diff --git a/i18n/it.json b/i18n/it.json index 51fceebd62..a00980bf4c 100644 --- a/i18n/it.json +++ b/i18n/it.json @@ -1,11 +1,11 @@ { - "about": "Informazioni su", + "about": "Informazioni", "account": "Profilo", - "account_settings": "Impostazioni Account", - "acknowledge": "Acconsento", + "account_settings": "Impostazioni Profilo", + "acknowledge": "Ho capito", "action": "Azione", "actions": "Azioni", - "active": "Attivi", + "active": "Attivo", "activity": "Attività", "activity_changed": "L'attività è {enabled, select, true {abilitata} other {disabilitata}}", "add": "Aggiungi", @@ -20,25 +20,25 @@ "add_partner": "Aggiungi partner", "add_path": "Aggiungi percorso", "add_photos": "Aggiungi foto", - "add_to": "Aggiungi a...", + "add_to": "Aggiungi a…", "add_to_album": "Aggiungi all'album", - "add_to_shared_album": "Aggiungi all'album condiviso", + "add_to_shared_album": "Aggiungi ad album condiviso", "add_url": "Aggiungi URL", "added_to_archive": "Aggiunto all'archivio", "added_to_favorites": "Aggiunto ai preferiti", - "added_to_favorites_count": "Aggiunti {count, number} ai preferiti", + "added_to_favorites_count": "Aggiunto {count, number} ai preferiti", "admin": { "add_exclusion_pattern_description": "Aggiungi modelli di esclusione. È supportato il globbing utilizzando *, ** e ?. Per ignorare tutti i file in qualsiasi directory denominata \"Raw\", usa \"**/Raw/**\". Per ignorare tutti i file con estensione \".tif\", usa \"**/*.tif\". Per ignorare un percorso assoluto, usa \"/percorso/da/ignorare/**\".", - "asset_offline_description": "Questa risorsa della libreria esterna non si trova più sul disco ed è stata spostata nel cestino. Se il file è stato spostato all'interno della libreria, controlla la timeline per la nuova risorsa corrispondente. Per ripristinare questa risorsa, assicurati che Immich possa accedere al percorso del file seguente ed esegui la scansione della libreria.", - "authentication_settings": "Autenticazione", + "asset_offline_description": "Questa risorsa della libreria esterna non si trova più sul disco ed è stata spostata nel cestino. Se il file è stato spostato all'interno della libreria, controlla la timeline per la nuova risorsa corrispondente. Per ripristinare questa risorsa, assicurati che Immich possa accedere al percorso del file ed esegui la scansione della libreria.", + "authentication_settings": "Impostazioni di Autenticazione", "authentication_settings_description": "Gestisci password, OAuth e altre impostazioni di autenticazione", "authentication_settings_disable_all": "Sei sicuro di voler disabilitare tutte le modalità di accesso? Il login verrà disabilitato completamente.", - "authentication_settings_reenable": "Per riabilitare, utilizza un Comando Server.", + "authentication_settings_reenable": "Per ri-abilitare, utilizza un Comando Server.", "background_task_job": "Attività in Background", - "backup_database": "Backup Database", + "backup_database": "Database di Backup", "backup_database_enable_description": "Abilita i backup del database", "backup_keep_last_amount": "Quantità di backup precedenti da mantenere", - "backup_settings": "Impostazioni backup", + "backup_settings": "Impostazioni di backup", "backup_settings_description": "Gestisci le impostazioni dei backup", "check_all": "Controlla Tutto", "cleared_jobs": "Cancellati i processi per: {job}", @@ -48,7 +48,7 @@ "confirm_email_below": "Per confermare, scrivi \"{email}\" qui sotto", "confirm_reprocess_all_faces": "Sei sicuro di voler riprocessare tutti i volti? Questo cancellerà tutte le persone nominate.", "confirm_user_password_reset": "Sei sicuro di voler resettare la password di {user}?", - "create_job": "creare lavoro", + "create_job": "Crea un lavoro", "cron_expression": "Espressione Cron", "cron_expression_description": "Imposta il tempo di scansione utilizzando il formato Cron. Per ulteriori informazioni fare riferimento a Crontab Guru", "cron_expression_presets": "Espressione Cron preimpostata", @@ -63,7 +63,7 @@ "failed_job_command": "Il comando {command} è fallito per il processo: {job}", "force_delete_user_warning": "ATTENZIONE: Questo rimuoverà immediatamente l'utente e tutti i suoi assets. Non è possibile tornare indietro e i file non potranno essere recuperati.", "forcing_refresh_library_files": "Forzando l'aggiornamento completo della libreria", - "image_format": "formato", + "image_format": "Formato", "image_format_description": "WebP produce file più piccoli rispetto a JPEG, ma l'encoding è più lento.", "image_prefer_embedded_preview": "Preferisci l'anteprima integrata", "image_prefer_embedded_preview_setting_description": "Usa l'anteprima integrata nelle foto RAW come input per l'elaborazione delle immagini, se disponibile. Questo permette un miglioramento dei colori per alcune immagini, ma la qualità delle anteprime dipende dalla macchina fotografica. Inoltre le immagini potrebbero presentare artefatti di compressione.", @@ -219,7 +219,7 @@ "reset_settings_to_default": "Ripristina impostazioni predefinite", "reset_settings_to_recent_saved": "Ripristina impostazioni alle impostazioni salvate di recente", "scanning_library": "Scansione della libreria", - "search_jobs": "Cerca Jobs...", + "search_jobs": "Cerca Attività…", "send_welcome_email": "Invia email di benvenuto", "server_external_domain_settings": "Dominio esterno", "server_external_domain_settings_description": "Dominio per link condivisi pubblicamente, incluso http(s)://", @@ -410,13 +410,13 @@ "asset_description_updated": "La descrizione del media è stata aggiornata", "asset_filename_is_offline": "Il media {filename} è offline", "asset_has_unassigned_faces": "Il media ha dei volti non categorizzati", - "asset_hashing": "Hashing in corso ...", + "asset_hashing": "Hashing in corso …", "asset_offline": "Risorsa Offline", "asset_offline_description": "Questo media non è stato trovato nel disco. Contatta il tuo amministratore di Immich per assistenza.", "asset_skipped": "Saltato", "asset_skipped_in_trash": "Nel cestino", "asset_uploaded": "Caricato", - "asset_uploading": "Caricamento...", + "asset_uploading": "Caricamento…", "assets": "Risorse", "assets_added_count": "{count, plural, one {# asset aggiunto} other {# asset aggiunti}}", "assets_added_to_album_count": "{count, plural, one {# asset aggiunto} other {# asset aggiunti}} all'album", @@ -766,8 +766,10 @@ "go_to_folder": "Vai alla cartella", "go_to_search": "Vai alla ricerca", "group_albums_by": "Raggruppa album in base a...", + "group_country": "Raggruppa per paese", "group_no": "Nessun raggruppamento", "group_owner": "Raggruppa in base al proprietario", + "group_places_by": "Raggruppa posti per", "group_year": "Raggruppa per anno", "has_quota": "Ha limite", "hi_user": "Ciao {name} ({email})", @@ -800,6 +802,7 @@ "include_shared_albums": "Includi album condivisi", "include_shared_partner_assets": "Includi asset condivisi del compagno", "individual_share": "Condivisione individuale", + "individual_shares": "Condivisioni individuali", "info": "Info", "interval": { "day_at_onepm": "Ogni giorno alle 13", @@ -822,6 +825,7 @@ "latest_version": "Ultima Versione", "latitude": "Latitudine", "leave": "Esci", + "lens_model": "Modello lenti", "let_others_respond": "Permetti agli altri di rispondere", "level": "Livello", "library": "Libreria", @@ -984,6 +988,7 @@ "pick_a_location": "Scegli una posizione", "place": "Posizione", "places": "Luoghi", + "places_count": "{count, plural, one {{count, number} Luogo} altro {{count, number} Luoghi}}", "play": "Avvia", "play_memories": "Avvia ricordi", "play_motion_photo": "Avvia Foto in movimento", @@ -1107,12 +1112,15 @@ "search": "Cerca", "search_albums": "Cerca album", "search_by_context": "Cerca con contesto", + "search_by_description": "Ricerca per descrizione", + "search_by_description_example": "Giornata di escursioni a Sapa", "search_by_filename": "Cerca per nome del file o estensione", "search_by_filename_example": "es. IMG_1234.JPG o PNG", "search_camera_make": "Cerca produttore fotocamera...", "search_camera_model": "Cerca modello fotocamera...", "search_city": "Cerca città...", "search_country": "Cerca paese...", + "search_for": "Cerca per", "search_for_existing_person": "Cerca per persona esistente", "search_no_people": "Nessuna persona", "search_no_people_named": "Nessuna persona chiamate \"{name}\"", @@ -1165,6 +1173,7 @@ "shared_from_partner": "Foto da {partner}", "shared_link_options": "Opzioni link condiviso", "shared_links": "Link condivisi", + "shared_links_description": "Condividi foto e video con un link", "shared_photos_and_videos_count": "{assetCount, plural, other {# foto & video condivisi.}}", "shared_with_partner": "Condiviso con {partner}", "sharing": "Condivisione", @@ -1187,6 +1196,7 @@ "show_person_options": "Mostra opzioni persona", "show_progress_bar": "Mostra Barra Avanzamento", "show_search_options": "Mostra impostazioni di ricerca", + "show_shared_links": "Mostra link condivisi", "show_slideshow_transition": "Mostra la transizione della presentazione", "show_supporter_badge": "Medaglia di Contributore", "show_supporter_badge_description": "Mostra la medaglia di contributore", @@ -1274,6 +1284,7 @@ "unfavorite": "Rimuovi preferito", "unhide_person": "Mostra persona", "unknown": "Sconosciuto", + "unknown_country": "Paese sconosciuto", "unknown_year": "Anno sconosciuto", "unlimited": "Illimitato", "unlink_motion_video": "Scollega video in movimento", diff --git a/i18n/ko.json b/i18n/ko.json index b19d85246d..ef8227c3f2 100644 --- a/i18n/ko.json +++ b/i18n/ko.json @@ -20,7 +20,7 @@ "add_partner": "파트너 추가", "add_path": "경로 추가", "add_photos": "사진 추가", - "add_to": "앨범에 추가...", + "add_to": "앨범에 추가…", "add_to_album": "앨범에 추가", "add_to_shared_album": "공유 앨범에 추가", "add_url": "URL 추가", @@ -131,7 +131,7 @@ "machine_learning_smart_search_description": "CLIP 임베딩으로 자연어를 사용하여 이미지 검색", "machine_learning_smart_search_enabled": "스마트 검색 활성화", "machine_learning_smart_search_enabled_description": "비활성화된 경우 스마트 검색을 위한 이미지 처리를 진행하지 않습니다.", - "machine_learning_url_description": "기계 학습 서버 URL", + "machine_learning_url_description": "기계 학습 서버의 URL을 입럭합니다. 여러 개의 URL이 입력된 경우 모든 서버에 응답을 보낸 뒤 응답에 성공한 서버가 사용됩니다.", "manage_concurrency": "동시성 관리", "manage_log_settings": "로그 설정 관리", "map_dark_style": "다크 스타일", @@ -219,11 +219,11 @@ "reset_settings_to_default": "설정을 기본값으로 복원", "reset_settings_to_recent_saved": "마지막으로 저장된 설정으로 복원", "scanning_library": "라이브러리 스캔 중", - "search_jobs": "작업 검색...", + "search_jobs": "작업 검색…", "send_welcome_email": "환영 이메일 전송", "server_external_domain_settings": "외부 도메인", "server_external_domain_settings_description": "공개 공유 링크에 사용할 도메인 (http(s):// 포함)", - "server_public_users": "공공 사용자", + "server_public_users": "모든 사용자", "server_public_users_description": "공유 앨범에 사용자를 추가할 경우 모든 사용자(이름, 이메일)가 나열됩니다. 비활성화 할 경우, 관리자만이 사용자 목록을 사용할 수 있습니다.", "server_settings": "서버 설정", "server_settings_description": "서버 설정 관리", @@ -250,6 +250,10 @@ "storage_template_user_label": "사용자의 스토리지 레이블: {label}", "system_settings": "시스템 설정", "tag_cleanup_job": "태그 정리", + "template_email_if_empty": "비어 있는 경우 기본 템플릿이 사용됩니다.", + "template_email_preview": "미리보기", + "template_email_settings": "이메일 템플릿", + "template_email_settings_description": "사용자 정의 이메일 템플릿 관리", "theme_custom_css_settings": "사용자 정의 CSS", "theme_custom_css_settings_description": "Immich에 적용할 사용자 정의 CSS(Cascading Style Sheets) 설정", "theme_settings": "테마 설정", @@ -392,17 +396,17 @@ "are_these_the_same_person": "동일한 인물인가요?", "are_you_sure_to_do_this": "계속 진행하시겠습니까?", "asset_added_to_album": "앨범에 추가되었습니다.", - "asset_adding_to_album": "앨범에 추가 중...", + "asset_adding_to_album": "앨범에 추가 중…", "asset_description_updated": "항목의 설명이 업데이트되었습니다.", "asset_filename_is_offline": "{filename} 항목 누락됨", "asset_has_unassigned_faces": "항목에 할당되지 않은 얼굴이 있음", - "asset_hashing": "해시 확인 중...", + "asset_hashing": "해싱 중…", "asset_offline": "누락된 항목", "asset_offline_description": "디스크에서 항목을 더이상 찾을 수 없습니다. 서버 관리자에게 연락하여 도움을 받으세요.", "asset_skipped": "건너뜀", "asset_skipped_in_trash": "휴지통의 항목", "asset_uploaded": "업로드 완료", - "asset_uploading": "업로드 중...", + "asset_uploading": "업로드 중…", "assets": "항목", "assets_added_count": "항목 {count, plural, one {#개} other {#개}}가 추가되었습니다.", "assets_added_to_album_count": "앨범에 항목 {count, plural, one {#개} other {#개}} 추가됨", @@ -1080,6 +1084,8 @@ "search": "검색", "search_albums": "앨범 검색", "search_by_context": "내용 검색", + "search_by_description": "설명으로 검색", + "search_by_description_example": "사파에서 즐기는 하이킹", "search_by_filename": "파일명 또는 확장자로 검색", "search_by_filename_example": "예시: IMG_1234.JPG or PNG", "search_camera_make": "카메라 제조사 검색...", @@ -1221,6 +1227,7 @@ "they_will_be_merged_together": "선택한 인물들이 병합됩니다.", "third_party_resources": "서드 파티 리소스", "time_based_memories": "시간 기준 추억", + "timeline": "타임라인", "timezone": "시간대", "to_archive": "보관함으로 이동", "to_change_password": "비밀번호 변경", @@ -1243,6 +1250,7 @@ "unfavorite": "즐겨찾기 해제", "unhide_person": "인물 숨김 해제", "unknown": "알 수 없음", + "unknown_country": "알 수 없는 지역", "unknown_year": "알 수 없는 연도", "unlimited": "무제한", "unlink_motion_video": "모션 비디오 링크 해제", @@ -1279,6 +1287,7 @@ "user_purchase_settings_description": "구매 및 제품 키 관리", "user_role_set": "{user}님에게 {role} 역할을 설정했습니다.", "user_usage_detail": "사용자 사용량 상세", + "user_usage_stats_description": "계정 사용량 통계 보기", "username": "계정명", "users": "사용자", "utilities": "도구", @@ -1286,7 +1295,7 @@ "variables": "변수", "version": "버전", "version_announcement_closing": "당신의 친구, Alex가", - "version_announcement_message": "안녕하세요, 새 버전의 Immich를 사용할 수 있습니다. 자세한 내용은 릴리스 노트를 참조하세요. WatchTower 등의 자동 업데이트 기능을 사용하는 경우 의도하지 않은 동작을 방지하기 위해 docker-compose.yml.env 구성이 최신인지 확인하세요.", + "version_announcement_message": "안녕하세요! 새 버전의 Immich를 사용할 수 있습니다. 잘못된 구성을 방지하고 Immich를 최신 상태로 유지하기 위해 잠시 시간을 내어 릴리스 노트를 읽어보는 것을 권장합니다. 특히 WatchTower 등의 자동 업데이트 기능을 사용하는 경우 의도하지 않은 동작을 방지하기 위해 더더욱 권장됩니다.", "version_history": "버전 기록", "version_history_item": "{date} 버전 {version} 설치", "video": "동영상", diff --git a/i18n/lt.json b/i18n/lt.json index d998d33e01..5e478a37a9 100644 --- a/i18n/lt.json +++ b/i18n/lt.json @@ -932,6 +932,7 @@ "search": "Ieškoti", "search_albums": "", "search_by_context": "Ieškoti pagal kontekstą", + "search_by_description_example": "Žygio diena Sapoje", "search_by_filename": "Ieškoti pagal failo pavadinimą arba plėtinį", "search_by_filename_example": "pvz. IMG_1234.JPG arba PNG", "search_camera_make": "", diff --git a/i18n/lv.json b/i18n/lv.json index 3d64db2b9f..38b0bf6426 100644 --- a/i18n/lv.json +++ b/i18n/lv.json @@ -841,13 +841,14 @@ "toggle_theme": "", "total_usage": "Kopējais lietojums", "trash": "Atkritne", - "trash_all": "", + "trash_all": "Dzēst Visu", "trash_no_results_message": "", "type": "", "unarchive": "Atarhivēt", "unfavorite": "Noņemt no izlases", "unhide_person": "Atcelt personas slēpšanu", "unknown": "", + "unknown_country": "Nezināma Valsts", "unknown_year": "Nezināms gads", "unlimited": "Neierobežots", "unlink_oauth": "", @@ -857,7 +858,7 @@ "unselect_all": "", "unstack": "At-Stekot", "up_next": "", - "updated_password": "", + "updated_password": "Parole ir atjaunināta", "upload": "Augšupielādēt", "upload_concurrency": "", "upload_status_duplicates": "Dublikāti", @@ -868,7 +869,7 @@ "user": "Lietotājs", "user_id": "Lietotāja ID", "user_usage_detail": "Informācija par lietotāju lietojumu", - "username": "", + "username": "Lietotājvārds", "users": "Lietotāji", "utilities": "Rīki", "validate": "", @@ -879,15 +880,16 @@ "video": "Videoklips", "video_hover_setting_description": "", "videos": "Videoklipi", + "view_album": "Skatīt Albumu", "view_all": "Apskatīt visu", - "view_all_users": "", + "view_all_users": "Skatīt visus lietotājus", "view_links": "", "view_next_asset": "", "view_previous_asset": "", "waiting": "Gaida", - "week": "", + "week": "Nedēļa", "welcome_to_immich": "", - "year": "", + "year": "Gads", "years_ago": "Pirms {years, plural, one {# gada} other {# gadiem}}", "yes": "Jā", "zoom_image": "Pietuvināt attēlu" diff --git a/i18n/mk.json b/i18n/mk.json index 0bfbcfefe9..658ab9453e 100644 --- a/i18n/mk.json +++ b/i18n/mk.json @@ -1,28 +1,295 @@ { - "about": "Освежи", + "about": "За Immich", "account": "Профил", "account_settings": "Поставки за профилот", - "acknowledge": "Означи како прочитано", + "acknowledge": "Прочитано", "action": "Акција", "actions": "Акции", "active": "Активни", - "activity": "Активности", - "add": "Додај", - "add_a_description": "Додај опис", - "add_a_location": "Додај локација", - "add_a_name": "Додај име", - "add_a_title": "Додај наслов", - "add_exclusion_pattern": "Додај патерн за игнотирање", - "add_import_path": "Додај патека за импортирање", - "add_location": "Додај локација", - "add_more_users": "Додај уште корисници", - "add_partner": "Додај партнер", - "add_path": "Додај патека", - "add_photos": "Додај слики", - "add_to": "Додај во...", - "add_to_album": "Додај во албум", - "add_to_shared_album": "Додај во споделен албум", + "activity": "Активност", + "activity_changed": "Активноста е {enabled, select, true {овозможена} other {неовозможена}}", + "add": "Додади", + "add_a_description": "Додади опис", + "add_a_location": "Додади локација", + "add_a_name": "Додади име", + "add_a_title": "Додади наслов", + "add_exclusion_pattern": "Додади шаблон за исклучување", + "add_import_path": "Додади патека за импортирање", + "add_location": "Додади локација", + "add_more_users": "Додади уште корисници", + "add_partner": "Додади партнер", + "add_path": "Додади патека", + "add_photos": "Додади слики", + "add_to": "Додади во…", + "add_to_album": "Додади во албум", + "add_to_shared_album": "Додади во споделен албум", + "add_url": "Додади URL", "added_to_archive": "Додадено во архива", "added_to_favorites": "Додадено во омилени", - "added_to_favorites_count": "Додадени {count, number} во омилени" + "added_to_favorites_count": "Додадени {count, number} во омилени", + "admin": { + "add_exclusion_pattern_description": "Додади шаблони за исклучување. Поддржано е користење на glob со *, **, и ?. За да се игнорираат сите датотеки во кој било директориум именуван \"Raw\", користи \"**/Raw/**\". За да се игнорираат сите датотеки што завршуваат со \".tif\", користи \"**/*.tif\". За да се игнорира апсолутна патека, користи \"/path/to/ignore/**\".", + "asset_offline_description": "Ова средство од екстерна библиотека веќе не е пронајдено на дискот и е преместено во ѓубре. Ако датотеката била преместена во рамките на библиотеката, проверете ја вашата временска линија за новото соодветно средство. За да го вратите ова средство, осигурајте се дека долунаведената патека може да биде пристапена од Immich и скенирајте ја библиотеката.", + "authentication_settings": "Поставки за автентикација", + "authentication_settings_description": "Управувај со лозинки, OAuth, и други поставки за автентикација", + "authentication_settings_disable_all": "Дали сте сигурни дека сакате да ги исклучите сите методи за најава? Целосно ќе биде оневозможено најавување.", + "authentication_settings_reenable": "За повторно да овозможите, искористете Сервер команда.", + "background_task_job": "Позадински задачи", + "backup_database": "Резервна копија од базата на податоци", + "backup_database_enable_description": "Овозможи резервни копии од базата на податоци", + "backup_keep_last_amount": "Количина на претходни резервни копии за чување", + "backup_settings": "Поставки за резервни копии", + "backup_settings_description": "Управувај со поставки за резервни копии на базата на податоци", + "check_all": "Провери сѐ", + "cleared_jobs": "Исчистени задачи за: {job}", + "config_set_by_file": "Конгигурацијата е моментално поставена од конфигурациска датотека", + "confirm_delete_library": "Дали сте сигурни дека сакате да ја избришете библиотеката {library}?", + "confirm_delete_library_assets": "Дали сте сигурни дека сакате да ја избришете оваа библиотека? Ова ќе {count, plural, one {избрише # содржано средство} other {ги избрише сите # содржани средства}} од Immich и нема да може да се {count, plural, one {врати} other {вратат}} назад. Датотеките ќе останат на диск.", + "confirm_email_below": "За да потврдите, внесете \"{email}\" доле", + "confirm_reprocess_all_faces": "Дали сте сигурни дека сакате да се обработат одново сите лица? Ова ќе ги избрише и сите именувани луѓе.", + "confirm_user_password_reset": "Дали сте сигурни дека сакате да се поништи лозинката на {user}?", + "create_job": "Создади задача", + "cron_expression": "Cron израз", + "cron_expression_description": "Подеси го интервалот на скенирање користејќи го cron форматот. За повеќе информации погледнете на пр. Crontab Guru", + "cron_expression_presets": "Предефинирани Cron изрази", + "disable_login": "Оневозможи најава", + "duplicate_detection_job_description": "Пушти машинско учење на средствата за да се откријат слични слики. Се потпира на Smart Search", + "force_delete_user_warning": "ПРЕДУПРЕДУВАЊЕ: Ова веднаш ќе го отстрани корисникот и сите средства. Оваа акција не може да се поништи и датотеките нема да може да се вратат назад.", + "image_format": "Формат", + "image_quality": "Квалитет", + "image_resolution": "Резолуција", + "image_settings": "Поставки за слики", + "library_scanning": "Периодично скенирање", + "library_settings": "Екстерна библиотека", + "logging_enable_description": "Вклучи евидентирање", + "logging_settings": "Евидентирање", + "map_dark_style": "Темен стил", + "map_light_style": "Светол стил", + "map_settings": "Карта", + "metadata_extraction_job": "Извлечи метаподатоци", + "migration_job": "Миграција", + "oauth_auto_launch": "Автоматско започнување", + "oauth_auto_register": "Автоматска регистрација", + "oauth_button_text": "Текст на копче", + "oauth_client_id": "Клиентски ID", + "oauth_client_secret": "Клиентска тајна", + "oauth_issuer_url": "URL на издавач", + "oauth_scope": "Опсег", + "oauth_settings": "OAuth", + "oauth_signing_algorithm": "Алгоритам за потпишување", + "offline_paths": "Офлајн патеки", + "password_settings": "Најава со лозинка", + "repair_all": "Поправи ги сите", + "sidecar_job": "Sidecar метаподатоци", + "storage_template_settings": "Шаблон за складирање", + "system_settings": "Системски поставки", + "thumbnail_generation_job": "Генерирај сликички", + "transcoding_acceleration_vaapi": "VAAPI", + "transcoding_threads": "Нишки", + "untracked_files": "Неследени датотеки" + }, + "admin_email": "Администрациска Е-пошта", + "admin_password": "Администрациска лозинка", + "administration": "Администрација", + "advanced": "Напредно", + "albums": "Албуми", + "all": "Сите", + "all_people": "Сите луѓе", + "anti_clockwise": "Спротивно од стрелките на часовникот", + "appears_in": "Се појавува во", + "archive": "Архива", + "archive_size": "Големина на архива", + "asset_hashing": "Хеширање…", + "asset_offline": "Средството е офлајн", + "asset_skipped": "Пропуштено", + "assets": "Средства", + "authorized_devices": "Авторизирани уреди", + "back": "Назад", + "backward": "Наназад", + "blurred_background": "Заматена позадина", + "camera": "Камера", + "camera_brand": "Марка на камера", + "camera_model": "Модел на камера", + "cancel": "Откажи", + "cancel_search": "Откажи пребарување", + "change_password": "Промени лозинка", + "city": "Град", + "clear": "Исчисти", + "clear_all": "Исчисти сѐ", + "clockwise": "Во насока на стрелките на часовникот", + "close": "Затвори", + "collapse": "Колапс", + "collapse_all": "Колапсирај сѐ", + "color": "Боја", + "comment_options": "Опции за коментар", + "confirm": "Потврди", + "confirm_password": "Потврди лозинка", + "contain": "Во рамки на прозорецот", + "context": "Контекст", + "continue": "Продолжи", + "copy_image": "Копирај слика", + "copy_link": "Копирај линк", + "country": "Држава", + "cover": "Покриј го прозорецот", + "covers": "Насловни", + "create": "Создади", + "create_album": "Создади албум", + "create_link": "Создади линк", + "created": "Создадено", + "current_device": "Тековен уред", + "dark": "Темно", + "day": "Ден", + "delete": "Избриши", + "delete_link": "Избриши линк", + "description": "Опис", + "details": "Детали", + "direction": "Насока", + "disabled": "Оневозможено", + "discord": "Дискорд", + "discover": "Откриј", + "display_options": "Опции за приказ", + "documentation": "Документација", + "done": "Готово", + "download": "Превземи", + "download_settings": "Превземање", + "downloading": "Се превземува", + "duplicates": "Дупликати", + "duration": "Времетраење", + "edit": "Уреди", + "edit_date": "Датум на уредување", + "edit_faces": "Уреди лица", + "edit_link": "Уреди линк", + "edit_location": "Уреди локација", + "edit_people": "Уреди луѓе", + "edit_user": "Уреди корисник", + "edited": "Уредено", + "editor": "Уредувач", + "editor_crop_tool_h2_rotation": "Ротација", + "email": "Е-пошта", + "empty_trash": "Испразни го ѓубрето", + "enable": "Овозможи", + "enabled": "Овозможено", + "end_date": "Краен датум", + "error": "Грешка", + "exif": "Exif", + "expand_all": "Прошири ги сите", + "expire_after": "Да истече после", + "expired": "Истечено", + "explore": "Истражи", + "export": "Извези", + "extension": "Екстензија", + "external": "Екстерно", + "external_libraries": "Екстерни библиотеки", + "face_unassigned": "Недоделено", + "favorite": "Омилено", + "favorites": "Омилени", + "features": "Функии", + "file_name": "Име на датотека", + "filename": "Име на датотека", + "filetype": "Тип на датотека", + "filter_people": "Филтрирај луѓе", + "folders": "Папки", + "forward": "Нанапред", + "general": "Генерално", + "get_help": "Побарај помош", + "go_back": "Врати се назад", + "hide_password": "Скриј лозинка", + "host": "Хост", + "hour": "Час", + "image": "Слика", + "in_archive": "Во архива", + "individual_share": "Индивидуално споделување", + "info": "Информации", + "jobs": "Задачи", + "keep": "Задржи", + "language": "Јазик", + "last_seen": "Последно видено", + "latitude": "Географска ширина", + "leave": "Напушти", + "level": "Ниво", + "library": "Библиотека", + "light": "Светло", + "link_options": "Опции за линк", + "list": "Листа", + "loading": "Вчитување", + "log_out": "Одјави се", + "login": "Најава", + "longitude": "Географска должина", + "look": "Изглед", + "make": "Марка", + "map": "Карта", + "matches": "Софпаѓања", + "media_type": "Тип на медија", + "memories": "Мемории", + "memory": "Меморија", + "menu": "Мени", + "merge": "Спој", + "minimize": "Минимизирај", + "minute": "Минута", + "missing": "Недостасувачки", + "model": "Модел", + "month": "Месец", + "more": "Уште", + "name": "Име", + "never": "Никогаш", + "new_password": "Нова лозинка", + "new_person": "Нова личност", + "next": "Следно", + "no": "Не", + "no_name": "Без име", + "no_results": "Нема резултати", + "notes": "Белешки", + "notifications": "Нотификации", + "oauth": "OAuth", + "offline": "Офлајн", + "ok": "Ок", + "online": "Онлајн", + "options": "Опции", + "or": "или", + "original": "оригинално", + "other": "Друго", + "other_devices": "Други уреди", + "other_variables": "Други променливи", + "password": "Лозинка", + "people": "Луѓе", + "permanently_delete": "Трајни избриши", + "photos": "Слики", + "place": "Место", + "preset": "Претходно поставено", + "preview": "Преглед", + "reaction_options": "Опции за реакција", + "read_changelog": "Прочитај дневник на промени", + "refresh": "Освежи", + "refreshed": "Освежено", + "remove": "Отстрани", + "repair": "Поправи", + "require_password": "Потребно лозинка", + "reset": "Ресетирај", + "restore": "Поврати", + "role": "Улога", + "save": "Зачувај", + "search": "Пребарај", + "second": "Секунда", + "selected": "Избрано", + "settings": "Поставки", + "share": "Сподели", + "sharing": "Споделување", + "slideshow": "Слајдшоу", + "state": "Регион", + "suggestions": "Предлози", + "sync": "Синхронизација", + "template": "Шаблон", + "to_archive": "Архива", + "to_favorite": "Додади во омилени", + "trash": "Ѓубре", + "unarchive": "Извади од архива", + "unfavorite": "Извади од омилени", + "unknown": "Непознато", + "users": "Korisnici", + "utilities": "Алатки", + "variables": "Променливи", + "video": "Видео", + "waiting": "Во исчекување", + "week": "Недела", + "year": "Година" } diff --git a/i18n/ms.json b/i18n/ms.json index d03ef614b4..9916ca62a0 100644 --- a/i18n/ms.json +++ b/i18n/ms.json @@ -20,13 +20,13 @@ "add_partner": "Tambah rakan", "add_path": "Tambah laluan", "add_photos": "Tambah gambar", - "add_to": "Tambah ke...", + "add_to": "Tambah ke…", "add_to_album": "Tambah ke album", "add_to_shared_album": "Tambah ke album yang dikongsi", "add_url": "Tambah URL", "added_to_archive": "Tambah ke arkib", - "added_to_favorites": "Ditambah pada favorit", - "added_to_favorites_count": "Menambahkan {count, number} ke favorit", + "added_to_favorites": "Ditambah ke kegemaran", + "added_to_favorites_count": "Menambahkan {count, number} ke kegemaran", "admin": { "add_exclusion_pattern_description": "Tambahkan corak pengecualian. Globbing menggunakan *, **, dan ? disokong. Untuk mengabaikan semua fail dalam mana-mana direktori bernama \"Raw\", gunakan \"**/Raw/**\". Untuk mengabaikan semua fail yang berakhir dengan \".tif\", gunakan \"**/*.tif\". Untuk mengabaikan laluan mutlak, gunakan \"/path/to/ignore/**\".", "asset_offline_description": "Aset pustaka luaran ini tidak lagi ditemui pada cakera dan telah dialihkan ke sampah. Jika fail telah dialihkan dalam pustaka, semak garis masa anda untuk aset baharu yang sepadan. Untuk memulihkan aset ini, sila pastikan bahawa laluan fail di bawah boleh diakses oleh Immich dan mengimbas pustaka.", @@ -219,7 +219,7 @@ "reset_settings_to_default": "Tetapkan semula tetapan kepada lalai", "reset_settings_to_recent_saved": "Tetapkan semula tetapan kepada tetapan yang disimpan baru-baru ini", "scanning_library": "Mengimbas perpustakaan", - "search_jobs": "Cari kerja...", + "search_jobs": "Cari kerja…", "send_welcome_email": "Hantar e-mel alu-aluan", "server_external_domain_settings": "Domain luaran", "server_external_domain_settings_description": "Domain untuk pautan kongsi awam, termasuk http(s)://", @@ -230,6 +230,7 @@ "server_welcome_message": "Mesej alu-aluan", "server_welcome_message_description": "Mesej yang dipaparkan pada halaman log masuk.", "sidecar_job": "Metadata kereta sisi", + "sidecar_job_description": "Temui atau segerakkan metadata sampingan daripada sistem fail", "slideshow_duration_description": "Bilangan saat untuk memaparkan setiap imej", "smart_search_job_description": "Jalankan pembelajaran mesin pada aset-aset untuk menyokong carian pintar", "storage_template_date_time_description": "Cap masa penciptaan aset digunakan untuk maklumat masa dan tarikh", @@ -242,10 +243,85 @@ "storage_template_migration_info": "Perubahan templat hanya akan digunakan pada aset baharu. Untuk menggunakan templat secara retroaktif pada aset-aset yang dimuat naik sebelum ini, jalankan {job}.", "storage_template_migration_job": "Kerja Migrasi Templat Storan", "storage_template_more_details": "Untuk butiran lanjut tentang ciri ini, rujuk kepada Templat Storan dan implikasi", + "storage_template_onboarding_description": "Apabila didayakan, ciri ini akan menyusun fail secara automatik berdasarkan templat yang ditentukan pengguna. Disebabkan isu kestabilan, ciri ini telah dimatikan secara umum. Untuk mendapatkan maklumat lanjut, sila lihat dokumentasi.", + "storage_template_path_length": "Anggaran kepanjangan laluan: {length, number}/{limit, number}", "storage_template_settings": "Templat Storan", + "storage_template_settings_description": "Urus struktur folder dan nama fail aset dimuat naik", + "storage_template_user_label": "{label} ialah Label Storan pengguna", + "system_settings": "Tetapan Sistem", + "tag_cleanup_job": "Pembersihan tag", + "template_email_available_tags": "Anda boleh menggunakan pembolehubah berikut dalam templat anda: {tags}", + "template_email_if_empty": "Jika templat kosong, e-mel yang terpilih sebelum ini akan digunakan.", + "template_email_invite_album": "Templat Jemputan Album", + "template_email_preview": "Previu", + "template_email_settings": "Templat E-mel", + "template_email_settings_description": "Templat urus pemberitahuan dengan e-mel tersuai", + "template_email_update_album": "Templat Kemas kini Album", + "template_email_welcome": "Templat e-mel alu-aluan", + "template_settings": "Templat Pemberitahuan", + "template_settings_description": "Urus templat tersuai untuk pemberitahuan.", + "theme_custom_css_settings": "CSS tersuai", + "theme_custom_css_settings_description": "Lembaran Gaya Lata membolehkan reka bentuk Immich disuaikan.", + "theme_settings": "Tetapan Tema", "theme_settings_description": "Urus penyesuaian antara muka web Immich", + "these_files_matched_by_checksum": "Fail ini dipadankan dengan semakan mereka", "thumbnail_generation_job": "Jana Imej Kenit", - "thumbnail_generation_job_description": "Janakan imej kenit yang besar, kecil, dan kabur untuk setiap aset, serta imej kenit untuk setiap orang" + "thumbnail_generation_job_description": "Janakan imej kenit yang besar, kecil, dan kabur untuk setiap aset, serta imej kenit untuk setiap orang", + "transcoding_acceleration_api": "API Pecutan", + "transcoding_acceleration_api_description": "API yang akan berinteraksi dengan peranti anda untuk mempercepatkan transcoding. Tetapan ini adalah 'usaha terbaik': ia akan berundur kepada transkod perisian apabila gagal. VP9 mungkin berfungsi atau tidak bergantung pada perkakasan anda.", + "transcoding_acceleration_nvenc": "NVENC (memerlukan GPU NVIDIA)", + "transcoding_acceleration_qsv": "Pensegerakan Pantas (memerlukan CPU Intel generasi ke-7 atau lebih baru)", + "transcoding_acceleration_rkmpp": "RKMPP (hanya pada SOC Rockchip)", + "transcoding_acceleration_vaapi": "VAAPI", + "transcoding_accepted_audio_codecs": "Codec audio yang diterima", + "transcoding_accepted_audio_codecs_description": "Pilih codec audio yang tidak perlu ditranskodkan. Hanya digunakan untuk dasar transkod tertentu.", + "transcoding_accepted_containers": "Bekas yang diterima", + "transcoding_accepted_containers_description": "Pilih format bekas yang tidak perlu ditukar semula kepada MP4. Hanya digunakan untuk dasar transkod tertentu.", + "transcoding_accepted_video_codecs": "Codec video yang diterima", + "transcoding_accepted_video_codecs_description": "Pilih codec video yang tidak perlu ditranskodkan. Hanya digunakan untuk dasar transkod tertentu.", + "transcoding_advanced_options_description": "Pilihan yang tidak perlu diubah untuk kebanyakan pengguna", + "transcoding_audio_codec": "Codec audio", + "transcoding_audio_codec_description": "Opus ialah pilihan kualiti tertinggi, tetapi mempunyai keserasian yang lebih rendah dengan peranti atau perisian lama.", + "transcoding_bitrate_description": "Video yang lebih tinggi daripada kadar bit maksimum atau tidak dalam format yang diterima", + "transcoding_codecs_learn_more": "Untuk mengetahui lebih lanjut tentang istilah yang digunakan di sini, rujuk dokumentasi FFmpeg untuk codec H.264, codec HEVC dan codec VP9.", + "transcoding_constant_quality_mode": "Mod kualiti berterusan", + "transcoding_constant_quality_mode_description": "ICQ lebih baik daripada CQP, tetapi ada beberapa peranti pecutan perkakasan tidak menyokong mod ini. Menetapkan pilihan ini akan memilih mod yang ditentukan apabila menggunakan pengekodan berasaskan kualiti. Diabaikan oleh NVENC kerana ia tidak menyokong ICQ.", + "transcoding_constant_rate_factor": "Faktor kadar malar (-crf)", + "transcoding_constant_rate_factor_description": "Tahap kualiti video. Nilai biasa ialah 23 untuk H.264, 28 untuk HEVC, 31 untuk VP9 dan 35 untuk AV1. Lebih rendah adalah lebih baik, tetapi menghasilkan fail yang lebih besar.", + "transcoding_disabled_description": "Jangan transcode mana-mana video, boleh memecahkan main balik pada sesetengah pelanggan", + "transcoding_encoding_options": "Pilihan Pengekodan", + "transcoding_encoding_options_description": "Tetapkan codec, resolusi, kualiti dan pilihan lain untuk video yang dikodkan", + "transcoding_hardware_acceleration": "Pecutan Perkakasan", + "transcoding_hardware_acceleration_description": "Eksperimen; lebih pantas, tetapi akan mempunyai kualiti yang lebih rendah pada kadar bit yang sama", + "transcoding_hardware_decoding": "Penyahkodan perkakasan", + "transcoding_hardware_decoding_setting_description": "Mendayakan pecutan hujung ke hujung dan bukannya hanya mempercepatkan pengekodan. Mungkin tidak berfungsi pada semua video.", + "transcoding_hevc_codec": "Codec HEVC", + "transcoding_max_b_frames": "Bingkai-B maksimum", + "transcoding_max_b_frames_description": "Nilai yang lebih tinggi meningkatkan kecekapan mampatan, tetapi memperlahankan pengekodan. Mungkin tidak serasi dengan pecutan perkakasan pada peranti lama. 0 melumpuhkan bingkai B, manakala -1 menetapkan nilai ini secara automatik.", + "transcoding_max_bitrate": "Kadar bit maksimum", + "transcoding_max_bitrate_description": "Menetapkan kadar bit maksima boleh menjadikan saiz fail lebih boleh diramal dengan kekurangan yang kecil kepada kualiti. Pada 720p, nilai biasa ialah 2600k untuk VP9 atau HEVC, atau 4500k untuk H.264. Dilumpuhkan jika ditetapkan kepada 0.", + "transcoding_max_keyframe_interval": "Selangan keyframe maksimum", + "transcoding_max_keyframe_interval_description": "Menetapkan jarak bingkai maksimum antara keyframes. Nilai yang lebih rendah memburukkan kecekapan mampatan, tetapi menambah baik masa carian dan mungkin meningkatkan kualiti dalam adegan dengan pergerakan pantas. 0 menetapkan nilai ini secara automatik.", + "transcoding_optimal_description": "Video yang lebih tinggi daripada resolusi sasaran atau tidak dalam format yang diterima", + "transcoding_policy": "Dasar Transkod", + "transcoding_policy_description": "Tetapkan masa bila video akan ditranskodkan", + "transcoding_preferred_hardware_device": "Pilihan peranti perkakasan", + "transcoding_preferred_hardware_device_description": "Terpakai hanya untuk VAAPI dan QSV. Menetapkan nod dri yang digunakan untuk transkod perkakasan.", + "transcoding_preset_preset": "Pratetap (-preset)", + "transcoding_preset_preset_description": "Kelajuan mampatan. Pratetap yang lebih perlahan menghasilkan fail yang lebih kecil dan meningkatkan kualiti apabila pada kadar bit tertentu. VP9 mengabaikan kelajuan di atas 'lebih cepat'.", + "transcoding_reference_frames": "Bingkai rujukan", + "transcoding_reference_frames_description": "Bilangan bingkai untuk dirujuk semasa memampatkan bingkai yang diberikan. Nilai yang lebih tinggi meningkatkan kecekapan mampatan, tetapi memperlahankan pengekodan. 0 menetapkan nilai ini secara automatik.", + "transcoding_required_description": "Hanya untuk video yang tidak dalam format yang diterima", + "transcoding_settings": "Tetapan Transkod Video", + "transcoding_settings_description": "Urus video yang hendak ditranskod dan cara memprosesnya", + "transcoding_target_resolution": "Resolusi sasaran", + "transcoding_target_resolution_description": "Peleraian yang lebih tinggi boleh mengekalkan lebih banyak butiran tetapi mengambil masa lebih lama untuk mengekod, mempunyai saiz fail yang lebih besar dan boleh mengurangkan responsif app.", + "transcoding_temporal_aq": "AQ sementara", + "transcoding_temporal_aq_description": "Terpakai hanya untuk NVEC. Meningkatkan kualiti adegan yang berperinci tinggi dan berpunya rendah gerakan. Mungkin tidak serasi dengan peranti lama.", + "transcoding_threads": "Benang", + "transcoding_threads_description": "Nilai yang lebih tinggi membawa kepada pengekodan yang lebih pantas, tetapi meninggalkan lebih sedikit ruang untuk pemproses tugas lain semasa aktif. Nilai ini tidak boleh lebih daripada bilangan teras CPU. Memaksimumkan penggunaan jika ditetapkan kepada 0.", + "transcoding_tone_mapping": "Pemetaan nada", + "transcoding_tone_mapping_description": "Percubaan untuk mengekalkan penampilan video HDR apabila ditukar kepada SDR. Setiap algoritma membuat pertukaran yang berbeza untuk warna, perincian dan kecerahan. Hable mengekalkan perincian, Mobius mengekalkan warna, dan Reinhard mengekalkan kecerahan." }, "deduplication_criteria_1": "Saiz imej dalam bait", "deduplication_criteria_2": "Kiraan data EXIF", @@ -286,6 +362,8 @@ "download_settings": "Muat Turun", "download_settings_description": "Urus tetapan yang berkaitan dengan muat turun aset", "downloading": "Memuat turun", + "search_by_description": "Carian secara huraian", + "search_by_description_example": "Hari mendaki di Sapa", "timeline": "Garis masa", "total": "Jumlah", "user_usage_stats": "Statistik penggunaan akaun", diff --git a/i18n/nb_NO.json b/i18n/nb_NO.json index e8c63a696a..b29c8c1db6 100644 --- a/i18n/nb_NO.json +++ b/i18n/nb_NO.json @@ -14,13 +14,13 @@ "add_a_name": "Legg til navn", "add_a_title": "Legg til tittel", "add_exclusion_pattern": "Legg til ekskluderingsmønster", - "add_import_path": "Legg til importbane", + "add_import_path": "Legg til importsti", "add_location": "Legg til sted", "add_more_users": "Legg til flere brukere", "add_partner": "Legg til partner", - "add_path": "Legg til bane", + "add_path": "Legg til sti", "add_photos": "Legg til bilder", - "add_to": "Legg til...", + "add_to": "Legg til…", "add_to_album": "Legg til album", "add_to_shared_album": "Legg til delt album", "add_url": "Legg til URL", @@ -28,9 +28,9 @@ "added_to_favorites": "Lagt til i favoritter", "added_to_favorites_count": "Lagt til {count, number} i favoritter", "admin": { - "add_exclusion_pattern_description": "Legg til ekskluderingsmønstre. Globbing med *, ** og ? støttes. For å ignorere alle filer i en hvilken som helst mappe som heter \"Raw\", bruk \"**/Raw/**\". For å ignorere alle filer som slutter på \".tif\", bruk \"**/*.tif\". For å ignorere en absolutt filplassering, bruk \"/filbane/til/ignorer/**\".", + "add_exclusion_pattern_description": "Legg til ekskluderingsmønstre. Globbing med *, ** og ? støttes. For å ignorere alle filer i en hvilken som helst mappe som heter \"Raw\", bruk \"**/Raw/**\". For å ignorere alle filer som slutter på \".tif\", bruk \"**/*.tif\". For å ignorere en absolutt filplassering, bruk \"/filsti/til/ignorer/**\".", "asset_offline_description": "Denne eksterne bibliotekressursen finnes ikke lenger på disk og har blitt flyttet til papirkurven. Hvis filen ble flyttet innad i biblioteket, sjekk tidslinjen din for den tilsvarende ressursen. For å gjenopprette ressursen, vennligst sørg for at filstien under er tilgjengelig for Immich og skan biblioteket.", - "authentication_settings": "Autentiserings innstillinger", + "authentication_settings": "Godkjenningsinnstillinger", "authentication_settings_description": "Administrer passord, OAuth, og andre innstillinger for autentisering", "authentication_settings_disable_all": "Er du sikker på at du ønsker å deaktivere alle innloggingsmetoder? Innlogging vil bli fullstendig deaktivert.", "authentication_settings_reenable": "For å aktivere på nytt, bruk en Server Command.", @@ -58,10 +58,10 @@ "external_library_created_at": "Ekstern bibliotek (opprettet {date})", "external_library_management": "Administrasjon av eksterne biblioteker", "face_detection": "Ansiktsgjenkjennelse", - "face_detection_description": "Oppdag ansikter i filer ved hjelp av maskinlæring. For videoer vurderes bare miniatyrbildet. \"All\" (om-)behandler alle ressurser. \"Missing\" stiller opp ressurser som ikke har blitt behandlet ennå. Oppdagede ansikter vil bli stilt opp for ansiktsgjenkjenning etter at ansiktsgjenkjenning er fullført, og de grupperes i eksisterende eller nye personer.", - "facial_recognition_job_description": "Grupper oppdagede ansikter i personer. Denne trinn utføres etter at ansiktsgjenkjenning er fullført. \"All\" (om-)grupperer alle ansikter på nytt. \"Missing\" stiller opp ansikter som ikke har blitt tilordnet en person ennå.", - "failed_job_command": "Kommandoen {command} feilet for jobben: {job}", - "force_delete_user_warning": "ADVARSEL: Dette vil umiddelbart fjerne brukeren og alle eiendeler. Dette kan ikke angres, og filene kan ikke gjenopprettes.", + "face_detection_description": "Finn ansikter i bilder ved hjelp av maskinlæring. For videoer brukes bare miniatyrbildet. \"Alle\" går gjennom alle bilder (igjen). \"Tilbakestill\" fjerner all gjeldende ansiktsdata. \"Manglende\" legger til filer som ikke har blitt behandlet enda i køen. Oppdagede ansikter vil blir sendt til ansiktsgjenkjenning, og koblet til eksisterende eller nye personer.", + "facial_recognition_job_description": "Kobler oppdagede ansikt til personer. Dette utføres etter at ansiktssøk er fullført. \"Tilbakestill\" (om-)grupperer alle ansikt på nytt. \"Missing\" stiller opp ansikt som ikke har blitt tilordnet en person ennå.", + "failed_job_command": "Kommandoen {command} feilet for jobb: {job}", + "force_delete_user_warning": "ADVARSEL: Dette vil umiddelbart fjerne brukeren og alle data. Dette kan ikke angres, og filene kan ikke gjenopprettes.", "forcing_refresh_library_files": "Tvinger oppdatering av alle bibliotekfiler", "image_format": "Format", "image_format_description": "WebP gir mindre filer enn JPEG, men er tregere å lage.", @@ -102,7 +102,7 @@ "library_watching_settings_description": "Se automatisk etter endrede filer", "logging_enable_description": "Aktiver logging", "logging_level_description": "Hvis aktivert, hvilket loggnivå som skal brukes.", - "logging_settings": "Logging", + "logging_settings": "Logger", "machine_learning_clip_model": "Clip-modell", "machine_learning_clip_model_description": "Navnet på en CLIP-modell finnes her. Merk at du må kjøre 'Smart Søk'-jobben på nytt for alle bilder etter at du har endret modell.", "machine_learning_duplicate_detection": "Duplikat-deteksjon", @@ -155,7 +155,7 @@ "metadata_settings_description": "Administrer metadatainnstillinger", "migration_job": "Migrering", "migration_job_description": "Migrer miniatyrbilder for filer og ansikter til den nyeste mappestrukturen", - "no_paths_added": "Ingen filbaner lagt til", + "no_paths_added": "Ingen filstier lagt til", "no_pattern_added": "Ingen mønster lagt til", "note_apply_storage_label_previous_assets": "Merk: For å bruke lagringsetiketten på tidligere opplastede filer, kjør", "note_cannot_be_changed_later": "MERK: Dette kan ikke endres senere!", @@ -201,12 +201,12 @@ "oauth_storage_quota_claim_description": "Sett automatisk brukerens lagringskvote til verdien av dette kravet.", "oauth_storage_quota_default": "Standard lagringskvote (GiB)", "oauth_storage_quota_default_description": "Kvote i GiB som skal brukes når ingen krav er oppgitt (Skriv 0 for ubegrenset kvote).", - "offline_paths": "Frakoblede filbaner", + "offline_paths": "Frakoblede filstier", "offline_paths_description": "Disse resultatene kan skyldes manuell sletting av filer som ikke er en del av et eksternt bibliotek.", "password_enable_description": "Logg inn med e-post og passord", "password_settings": "Passordinnlogging", "password_settings_description": "Administrer innstillinger for passordinnlogging", - "paths_validated_successfully": "Alle filbaner validert uten problemer", + "paths_validated_successfully": "Alle filstier validert uten problemer", "person_cleanup_job": "Person opprydding", "quota_size_gib": "Kvotestørrelse (GiB)", "refreshing_all_libraries": "Oppdaterer alle biblioteker", @@ -219,7 +219,7 @@ "reset_settings_to_default": "Tilbakestill innstillinger til standard", "reset_settings_to_recent_saved": "Tilbakestill innstillingene til de nylig lagrede innstillingene", "scanning_library": "Søk biblioteket", - "search_jobs": "Søk etter jobber...", + "search_jobs": "Søk etter jobber…", "send_welcome_email": "Send velkomst-e-post", "server_external_domain_settings": "Eksternt domene", "server_external_domain_settings_description": "Domene for offentlige delingslenker, inkludert http(s)://", @@ -362,7 +362,7 @@ "advanced": "Avansert", "age_months": "Alder {months, plural, one {# måned} other {# måneder}}", "age_year_months": "Alder 1 år, {months, plural, one {# måned} other {# måneder}}", - "age_years": "{years, plural, other {Age #}}", + "age_years": "{years, plural, other {Alder #}}", "album_added": "Album lagt til", "album_added_notification_setting_description": "Motta en e-postvarsling når du legges til i et delt album", "album_cover_updated": "Albumomslag oppdatert", @@ -406,22 +406,22 @@ "are_these_the_same_person": "Er disse samme person?", "are_you_sure_to_do_this": "Er du sikker på at du vil gjøre dette?", "asset_added_to_album": "Lagt til i album", - "asset_adding_to_album": "Legger til i album...", + "asset_adding_to_album": "Legger til i album…", "asset_description_updated": "Elementbeskrivelse har blitt oppdatert", "asset_filename_is_offline": "Element {filename} er offline", "asset_has_unassigned_faces": "Element har ikke-tilordnede ansikter", - "asset_hashing": "Hasher...", + "asset_hashing": "Hasher…", "asset_offline": "Fil utilgjengelig", "asset_offline_description": "Dette elementet er offline. Immich kan ikke aksessere dets lokasjon. Vennlist påse at elementet er tilgijengelig og skann så biblioteket på nytt.", "asset_skipped": "Hoppet over", "asset_skipped_in_trash": "I søppelbøtten", "asset_uploaded": "Lastet opp", - "asset_uploading": "Laster opp...", + "asset_uploading": "Laster opp…", "assets": "Filer", "assets_added_count": "Lagt til {count, plural, one {# element} other {# elementer}}", "assets_added_to_album_count": "Lagt til {count, plural, one {# asset} other {# assets}} i album", "assets_added_to_name_count": "Lagt til {count, plural, one {# asset} other {# assets}} i {hasName, select, true {{name}} other {new album}}", - "assets_count": "{count, plural, one {# asset} other {# assets}}", + "assets_count": "{count, plural, one {# fil} other {# filer}}", "assets_moved_to_trash_count": "Flyttet {count, plural, one {# asset} other {# assets}} til søppel", "assets_permanently_deleted_count": "Permanent slettet {count, plural, one {# asset} other {# assets}}", "assets_removed_count": "Slettet {count, plural, one {# asset} other {# assets}}", @@ -509,7 +509,7 @@ "create_new_person_hint": "Tildel valgte eiendeler til en ny person", "create_new_user": "Opprett ny bruker", "create_tag": "Lag tag", - "create_tag_description": "Lag en ny tag. For undertag, vennligst fullfør hele banen til taggen, inkludert forovervendt skråstrek.", + "create_tag_description": "Lag en ny tag. For undertag, vennligst fullfør hele stien til taggen, inkludert forovervendt skråstrek.", "create_user": "Opprett Bruker", "created": "Opprettet", "current_device": "Nåværende enhet", @@ -809,7 +809,7 @@ }, "invite_people": "Inviter Personer", "invite_to_album": "Inviter til album", - "items_count": "{count, plural, one {# item} other {# items}}", + "items_count": "{count, plural, one {# gjenstand} other {# gjenstander}}", "jobs": "Oppgaver", "keep": "Behold", "keep_all": "Behold alle", @@ -822,6 +822,7 @@ "latest_version": "Siste versjon", "latitude": "Breddegrad", "leave": "Forlat", + "lens_model": "Objektiv", "let_others_respond": "La andre respondere", "level": "Nivå", "library": "Bibliotek", @@ -975,7 +976,7 @@ "permanently_deleted_asset": "Filen har blitt permanent slettet", "permanently_deleted_assets_count": "Permanent slett {count, plural, one {# asset} other {# assets}}", "person": "Person", - "person_hidden": "{name}{hidden, select, true { (hidden)} other {}}", + "person_hidden": "{name}{hidden, select, true { (skjult)} other {}}", "photo_shared_all_users": "Det ser ut som om du deler bildene med alle brukere eller det er ingen brukere å dele med.", "photos": "Bilder", "photos_and_videos": "Bilder & Videoer", @@ -1034,7 +1035,7 @@ "purchase_settings_server_activated": "Produktnøkkel for server er administrert av administratoren", "rating": "Stjernevurdering", "rating_clear": "Slett vurdering", - "rating_count": "{count, plural, one {# star} other {# stars}}", + "rating_count": "{count, plural, one {# sjerne} other {# stjerner}}", "rating_description": "Hvis EXIF vurdering i informasjons panelet", "reaction_options": "Reaksjonsalternativer", "read_changelog": "Les endringslogg", @@ -1113,6 +1114,7 @@ "search_camera_model": "Søk etter kamera modell...", "search_city": "Søk etter by...", "search_country": "Søk etter land...", + "search_for": "Søk etter", "search_for_existing_person": "Søk etter eksisterende person", "search_no_people": "Ingen personer", "search_no_people_named": "Ingen personer med navnet \"{name}\"", @@ -1141,7 +1143,7 @@ "select_photos": "Velg bilder", "select_trash_all": "Velg å flytte alt til papirkurven", "selected": "Valgt", - "selected_count": "{count, plural, other {# selected}}", + "selected_count": "{count, plural, other {# valgt}}", "send_message": "Send melding", "send_welcome_email": "Send velkomstmelding", "server_offline": "Server frakoblet", @@ -1270,7 +1272,7 @@ "trashed_items_will_be_permanently_deleted_after": "Elementer i papirkurven vil bli permanent slettet etter {days, plural, one {# dag} other {# dager}}.", "type": "Type", "unarchive": "Fjern fra arkiv", - "unarchived_count": "{count, plural, other {Unarchived #}}", + "unarchived_count": "{count, plural, other {uarkivert #}}", "unfavorite": "Fjern favoritt", "unhide_person": "Vis person", "unknown": "Ukjent", diff --git a/i18n/nl.json b/i18n/nl.json index 62d87bb63e..43e205b52a 100644 --- a/i18n/nl.json +++ b/i18n/nl.json @@ -20,7 +20,7 @@ "add_partner": "Partner toevoegen", "add_path": "Pad toevoegen", "add_photos": "Foto's toevoegen", - "add_to": "Toevoegen aan...", + "add_to": "Toevoegen aan…", "add_to_album": "Aan album toevoegen", "add_to_shared_album": "Aan gedeeld album toevoegen", "add_url": "URL toevoegen", @@ -219,7 +219,7 @@ "reset_settings_to_default": "Instellingen teruggezet naar standaard", "reset_settings_to_recent_saved": "Instellingen zijn gereset naar de recent opgeslagen instellingen", "scanning_library": "Bibliotheek scannen", - "search_jobs": "Taak zoeken...", + "search_jobs": "Taak zoeken…", "send_welcome_email": "Stuur een welkomstmail", "server_external_domain_settings": "Extern domein", "server_external_domain_settings_description": "Domein voor openbaar gedeelde links, inclusief http(s)://", @@ -406,17 +406,17 @@ "are_these_the_same_person": "Zijn dit dezelfde personen?", "are_you_sure_to_do_this": "Weet je zeker dat je dit wilt doen?", "asset_added_to_album": "Toegevoegd aan album", - "asset_adding_to_album": "Toevoegen aan album...", + "asset_adding_to_album": "Toevoegen aan album…", "asset_description_updated": "Asset beschrijving is bijgewerkt", "asset_filename_is_offline": "Asset {filename} is offline", "asset_has_unassigned_faces": "Asset heeft niet-toegewezen gezichten", - "asset_hashing": "Hashen...", + "asset_hashing": "Hashen…", "asset_offline": "Asset offline", "asset_offline_description": "Deze externe asset is niet meer op de schijf te vinden. Neem contact op met de Immich beheerder voor hulp.", "asset_skipped": "Overgeslagen", "asset_skipped_in_trash": "In prullenbak", "asset_uploaded": "Geüpload", - "asset_uploading": "Uploaden...", + "asset_uploading": "Uploaden…", "assets": "Assets", "assets_added_count": "{count, plural, one {# asset} other {# assets}} toegevoegd", "assets_added_to_album_count": "{count, plural, one {# asset} other {# assets}} aan het album toegevoegd", @@ -766,8 +766,10 @@ "go_to_folder": "Ga naar map", "go_to_search": "Ga naar zoeken", "group_albums_by": "Groepeer albums op...", + "group_country": "Groepeer op land", "group_no": "Niet groeperen", "group_owner": "Groeperen op eigenaar", + "group_places_by": "Groepeer plaatsen op...", "group_year": "Groeperen op jaar", "has_quota": "Heeft limiet", "hi_user": "Hallo {name} ({email})", @@ -800,6 +802,7 @@ "include_shared_albums": "Toon gedeelde albums", "include_shared_partner_assets": "Toon assets van gedeelde partner", "individual_share": "Individuele deellink", + "individual_shares": "Individuele deellinks", "info": "Info", "interval": { "day_at_onepm": "Iedere dag om 13 uur", @@ -822,6 +825,7 @@ "latest_version": "Nieuwste versie", "latitude": "Breedtegraad", "leave": "Verlaten", + "lens_model": "Lens model", "let_others_respond": "Laat anderen reageren", "level": "Niveau", "library": "Bibliotheek", @@ -984,6 +988,7 @@ "pick_a_location": "Kies een locatie", "place": "Plaats", "places": "Plaatsen", + "places_count": "{count, plural, one {{count, number} Plaats} other {{count, number} Plaatsen}}", "play": "Afspelen", "play_memories": "Herinneringen afspelen", "play_motion_photo": "Bewegingsfoto afspelen", @@ -1107,12 +1112,15 @@ "search": "Zoeken", "search_albums": "Zoek albums", "search_by_context": "Zoeken op context", + "search_by_description": "Zoeken op beschrijving", + "search_by_description_example": "Wandelen in Sapa", "search_by_filename": "Zoeken op bestandsnaam of -extensie", "search_by_filename_example": "b.v. IMG_1234.JPG of PNG", "search_camera_make": "Zoek cameramerk...", "search_camera_model": "Zoek cameramodel...", "search_city": "Zoek stad...", "search_country": "Zoek land...", + "search_for": "Zoeken naar", "search_for_existing_person": "Zoek naar bestaande persoon", "search_no_people": "Geen mensen", "search_no_people_named": "Geen mensen genaamd \"{name}\"", @@ -1165,6 +1173,7 @@ "shared_from_partner": "Foto's van {partner}", "shared_link_options": "Opties voor gedeelde links", "shared_links": "Gedeelde links", + "shared_links_description": "Deel foto's en video's via een link", "shared_photos_and_videos_count": "{assetCount, plural, other {# gedeelde foto's & video's.}}", "shared_with_partner": "Gedeeld met {partner}", "sharing": "Delen", @@ -1187,6 +1196,7 @@ "show_person_options": "Toon persoonopties", "show_progress_bar": "Toon voortgangsbalk", "show_search_options": "Zoekopties weergeven", + "show_shared_links": "Toon gedeelde links", "show_slideshow_transition": "Diavoorstellingsovergang tonen", "show_supporter_badge": "Supporter badge", "show_supporter_badge_description": "Toon een supporterbadge", @@ -1274,6 +1284,7 @@ "unfavorite": "Verwijderen uit favorieten", "unhide_person": "Persoon zichtbaar maken", "unknown": "Onbekend", + "unknown_country": "Onbekend Land", "unknown_year": "Onbekend jaar", "unlimited": "Onbeperkt", "unlink_motion_video": "Maak bewegende video los", diff --git a/i18n/nn.json b/i18n/nn.json index e5a912e203..4cda30dce2 100644 --- a/i18n/nn.json +++ b/i18n/nn.json @@ -2,10 +2,10 @@ "about": "Om", "account": "Konto", "account_settings": "Kontoinnstillingar", - "acknowledge": "Godkjenn", + "acknowledge": "Bekreft", "action": "Handling", "actions": "Handlingar", - "active": "Aktiv", + "active": "Aktive", "activity": "Aktivitet", "activity_changed": "Aktivitet er {enabled, select, true {aktivert} other {deaktivert}}", "add": "Legg til", @@ -13,14 +13,14 @@ "add_a_location": "Legg til ein stad", "add_a_name": "Legg til eit namn", "add_a_title": "Legg til ein tittel", - "add_exclusion_pattern": "Legg til ekskluderingsmønster", + "add_exclusion_pattern": "Legg til unnlatingsmønster", "add_import_path": "Legg til sti for importering", "add_location": "Legg til stad", "add_more_users": "Legg til fleire brukarar", "add_partner": "Legg til partnar", "add_path": "Legg til sti", "add_photos": "Legg til bilete", - "add_to": "Legg til...", + "add_to": "Legg til…", "add_to_album": "Legg til album", "add_to_shared_album": "Legg til delt album", "add_url": "Legg til URL", @@ -28,27 +28,98 @@ "added_to_favorites": "Lagt til favorittar", "added_to_favorites_count": "Lagt {count, number} til favorittar", "admin": { + "add_exclusion_pattern_description": "Legg til utelatingsmønstre. Du kan bruke jokerteikna *, **, og ? for å finne filer som passar mønsteret. For å ignorere alle filer i ei mappe kalla \"Raw\", bruk \"Raw\", bruk \"**/Raw/**\". For å ignorere alle filer som sluttar på \".tif\", bruk \"**/*.tif\". For å ignorere ein absolutt sti, bruk \"/path/to/ignore/**\".", "asset_offline_description": "Denne eksterne bibliotekressursen finst ikkje lenger på disk og har blitt flytta til papirkurven. Om fila blei flytta innad i biblioteket, sjekk tidslinja di for den tilsvarande ressursen. For å gjenopprette ressursen, vennligst sørg for at filstien under er tilgjengeleg for Immich og skann biblioteket.", - "backup_settings": "Backupinnstillingar", + "authentication_settings": "Godkjenningsinnstillingar", + "authentication_settings_description": "Handsam passord, OAuth, og godkjenningsinnstillingar", + "authentication_settings_disable_all": "Er du sikker at du ynskjer å gjera alle innloggingsmetodar uverksame? Innlogging vil bli heilt uverksam.", + "authentication_settings_reenable": "For å aktivere på nytt, bruk ein Server Command.", + "background_task_job": "Bakgrunnsjobbar", + "backup_database": "Sikkerheistkopier database", + "backup_database_enable_description": "Aktiver sikkerheitskopiering av database", + "backup_keep_last_amount": "Antal sikkerheitskopiar å behalde", + "backup_settings": "Sikkerheitskopi-innstillingar", + "backup_settings_description": "Handsam innstillingar for sikkerheitskopiering av database", "check_all": "Sjekk alle", - "confirm_delete_library": "Er du sikker på at du vil slette biblioteket {library}?", + "cleared_jobs": "Rydda jobbar for: {job}", + "config_set_by_file": "Oppsettet blir sett av ei oppsettfil", + "confirm_delete_library": "Er du sikker at du vil slette biblioteket {library}?", + "confirm_delete_library_assets": "Er du sikker at du vil slette dette biblioteket? Det kjem til å slette {count, plural, one {# contained asset} other {all # contained assets}} frå Immich og kan ikkje gjerast om. Filane blir verande på disken.", + "confirm_email_below": "For å bekrefte, skriv \"{email}\" under", + "confirm_reprocess_all_faces": "Er du sikker på at du vil behandle alle ansikt på nytt? Det vil òg fjerne namngjevne personar.", + "confirm_user_password_reset": "Er du sikker at du vil tilbakestille passordet til {user}?", "create_job": "Lag jobb", + "cron_expression": "Cron uttrykk", + "cron_expression_description": "Set inn skanningsintervall med cron-formatet. For meir informasjon sjå t.d. Crontab Guru", + "cron_expression_presets": "Førehandsinstillingar for Cron-uttrykk", "disable_login": "Deaktiver innlogging", - "face_detection": "Ansiktsdeteksjon", + "duplicate_detection_job_description": "Kjør maskinlæring på filer for å oppdage liknande bilete. Krev bruk av Smart Search", + "exclusion_pattern_description": "Utelatingsmønster let deg utelate filer og mapper når du skannar biblioteket ditt. Det er nyttig om du har mapper som inneheld filer du ikkje ynskjer å importere, til dømes RAW-filer.", + "external_library_created_at": "Eksterne bibliotek (oppretta {date})", + "external_library_management": "Handsaming av eksterne bibliotek", + "face_detection": "Ansiktssøk", + "face_detection_description": "Finn ansikt i bilete ved hjelp av maskinlæring. For videoar vert berre miniatyrbilete bruka. \"Alle\" søkjer (opp att) gjennom alle bilete. \"Tilbakestill\" fjernar all gjeldande ansiktsdata. \"Manglande\" legg filer som ikkje vert behandla til i køa for ansiktssøk. Oppdaga ansikt vert lagt i køa for ansiktsattkjenning, og kopla til eksisterande eller nye personar.", + "facial_recognition_job_description": "Koplar attkjende ansikt til personar. Det skjer fyrst når anskiktssøkjet er ferdig. \"Tilbakestill\" fjernar alle koplingar til personar, og tilbakestiller ansiktsgrupper. \"Manglande\" legg ansikt som ikkje er oppkopla til i køa.", + "failed_job_command": "Kommandoen {command} feila for jobb: {job}", + "force_delete_user_warning": "ÅTVARING: Handlinga fjernar brukaren og all data. Du kan ikkje angre, og filane kan ikkje gjenopprettast.", + "forcing_refresh_library_files": "Tvingar lasting av alle filer i bibliotek", "image_format": "Format", - "image_preview_title": "Forhandsvis innstillingar", + "image_format_description": "WebP gjev mindre filstorleik enn JPEG, men er treigare å lage.", + "image_prefer_embedded_preview": "Bruk helst innebygd førehandsvisning", + "image_prefer_embedded_preview_setting_description": "Når mogleg bruk innebygd førehandsvisning av RAW bilete som inndata til biletehandsaming. For noko bilete kan det gje meir nøyaktige farger, men kvaliteten kjem an på kamera og det kan oppstå komprimeringsartefakt i bilete.", + "image_prefer_wide_gamut": "Bruk helst breitt fargespektrum", + "image_prefer_wide_gamut_setting_description": "Bruk Display P3 for miniatyrbilete. For bilete med eit breitt fargerom tek det betre vare på ljosstyrke, men på einingar med gamal nettlesarversjon kan bilete sjå usamde ut. Beheld sRGB bilete som sRGB for å unnga fargeforskuvingar.", + "image_preview_description": "Mellomstore bilete utan metadata, bruka ved vising av ei enkelt fil og til maskinlæring", + "image_preview_quality_description": "Kvalitet på førehandsvising frå 1-100. Høgare tal gjev betre kvalitet, men gjev større filstorleik og kan senkje farta på systemet. Ved låge tal kan det påverkje kvaliteten på maskinlæringa.", + "image_preview_title": "Innstillingar for førehandsvisning", "image_quality": "Kvalitet", "image_resolution": "Oppløysing", + "image_resolution_description": "Høgare oppløysing inneheld meir detalj, men tek lengre tid å kode, gjev større filstorleik, og kan senkje appresponsen.", + "image_settings": "Innstillingar for bilete", + "image_settings_description": "Handsam kvalitet og oppløysing på framstilte bilete", "image_thumbnail_description": "Lite miniatyrbilete med fjerna metadata, brukt når ein ser på grupper av bilete som hovudtidslinja", + "image_thumbnail_quality_description": "Kvalitet på miniatyrbilete frå 1-100. Høgare er betre, men gjev større filstorleik, og kan senkje appresposen.", + "image_thumbnail_title": "Innstillingar for miniatyrbilete", + "job_concurrency": "{job} samstundes utføring", "job_created": "Jobb laga", + "job_not_concurrency_safe": "Kan ikke trygt utføre jobben samstundes.", "job_settings": "Jobbinnstillingar", + "job_settings_description": "Handsam samstundes utføring av jobber", "job_status": "Jobbstatus", + "jobs_delayed": "{jobCount, plural, other {# forsinka}}", + "jobs_failed": "{jobCount, plural, other {# mislykkast}}", + "library_created": "Opprett bibliotek: {library}", "library_deleted": "Bibliotek sletta", - "library_scanning": "Periodisk skanning", + "library_import_path_description": "Angje ei mappe å importere. Mappa, inkludert undermapper, bli skanna for bilete og videoar.", + "library_scanning": "Regelbunden skanning", + "library_scanning_description": "Sett opp regelbunden skanning av biblioteket", + "library_scanning_enable_description": "Aktiver regelbunden skanning av biblioteket", "library_settings": "Eksternt Bibliotek", + "library_settings_description": "Handsam eksterne biblioteksinnstillingar", + "library_tasks_description": "Utfør bibliotekstoppgåver", + "library_watching_enable_description": "Sjekk eksterne bibliotek for forandringar", + "library_watching_settings": "Biblioteksovervåking (EKSPERIMENTELL)", + "library_watching_settings_description": "Sjekk automatisk for forandringar", + "logging_enable_description": "Aktiver loggføring", + "logging_level_description": "Når aktivert, kva loggnivå å bruke.", "logging_settings": "Logging", + "machine_learning_clip_model": "CLIP modell", + "machine_learning_clip_model_description": "Namnet på ein CLIP modell finst her. Merk at du må køyre 'Smart Søk'-jobben på nytt for alle bilete etter du har forandra modell.", "machine_learning_duplicate_detection": "Duplikatdeteksjon", + "machine_learning_duplicate_detection_enabled": "Aktiver duplikatattkjenning", + "machine_learning_duplicate_detection_enabled_description": "Om uverksam, vil identiske filer framleis bli fjerna som duplikat.", + "machine_learning_duplicate_detection_setting_description": "Bruk CLIP-innkapslingar for å finne moglege duplikat", + "machine_learning_enabled": "Aktiver maskinlæring", + "machine_learning_enabled_description": "Om uverksam blir alle ML-funksjonar uverksame uavhengig av instillingane under.", "machine_learning_facial_recognition": "Ansiktsgjenkjenning", + "machine_learning_facial_recognition_description": "Finn, kjenn att, og kople ansikt i bilete", + "machine_learning_facial_recognition_model": "Ansiktsattkjenningsmodell", + "machine_learning_facial_recognition_model_description": "Modellane er oppført i søkkjande rekkjefølge etter storleik. Større modellar er treigare og brukar meir minne, men gjev betre resultat. Om du forandrar modell lyt du køyre ansiktsattkjenning om att på alle bilete.", + "machine_learning_facial_recognition_setting": "Aktiver ansiktsattkjenning", + "machine_learning_facial_recognition_setting_description": "Om uverksam blir ikkje bilete koda for ansiktsattkjenning og dukkar ikkje opp i \"Personar\" i Utforsk-sida.", + "machine_learning_max_detection_distance": "Maksimal oppdagingsverdi", + "machine_learning_max_detection_distance_description": "Den største skilnaden mellom to bilete for å rekne dei som duplikat, frå 0.001-0.1. Større verdiar finn fleire duplikat, men kan gje falske treff.", + "machine_learning_max_recognition_distance": "Maksimal attkjenningsverdi", "machine_learning_smart_search": "Smart Søk", "map_dark_style": "Mørk modus", "map_light_style": "Lys modus", @@ -60,18 +131,20 @@ "notification_settings": "Varselinnstillingar", "oauth_auto_launch": "Autostart", "oauth_button_text": "Tekst på knapp", - "password_settings": "Passord innlogging", + "password_enable_description": "Logg inn med e-post og passord", + "password_settings": "Passordinnlogging", "person_cleanup_job": "Personopprydding", + "refreshing_all_libraries": "Laster alle bibliotek opp att", "registration": "Administrator registrering", "registration_description": "Sidan du er den første brukaren på systemet, vil du bli utnevnt til administrator og ha ansvar for administrative oppgåver. Du vil òg opprette eventuelle nye brukarar.", "repair_all": "Reparer alle", - "repair_matched_items": "Samsvarte med {count, plural, one {# element} other {# elementer}}", - "repaired_items": "Reparerte {count, plural, one {# item} other {# items}}", + "repair_matched_items": "Samsvarte med {count, plural, one {# element} other {# element}}", + "repaired_items": "Reparerte {count, plural, one {# element} other {# element}}", "require_password_change_on_login": "Krev at brukaren endrar passord ved første pålogging", "reset_settings_to_default": "Tilbakestill innstillingar til standard", "reset_settings_to_recent_saved": "Tilbakestill innstillingane til de nyleg lagra innstillingane", "scanning_library": "Skann bibliotek", - "search_jobs": "Søk etter jobbar", + "search_jobs": "Søk etter jobbar…", "send_welcome_email": "Send velkomst-e-post", "server_external_domain_settings": "Eksternt domene", "server_external_domain_settings_description": "Domene for offentlege delingslenkjer, inkludert http(s)://", @@ -81,8 +154,26 @@ "server_settings_description": "Administrer serverinnstillingar", "server_welcome_message": "Velkomstmelding", "server_welcome_message_description": "Ei melding som synast på innloggingssida.", - "template_email_preview": "Førehandsvisning" + "system_settings": "Systeminnstillingar", + "template_email_preview": "Førehandsvisning", + "transcoding_acceleration_nvenc": "NVENC (Krev NVIDIA GPU)", + "transcoding_acceleration_qsv": "Quick Sync (Krev 7. generasjons Intel CPU eller nyare)", + "transcoding_acceleration_rkmpp": "RKMPP (Berre på Rockchip SOCer)", + "transcoding_acceleration_vaapi": "VAAPI", + "transcoding_accepted_audio_codecs": "Tillatne lydkodekar", + "transcoding_accepted_audio_codecs_description": "Vel kva for lydkodekar som ikkje må omkodast. Blir berre bruka for noko omkodingsval.", + "transcoding_accepted_containers": "Tillatne behaldarar", + "transcoding_accepted_containers_description": "Vel kva for behaldarar som ikkje må omkodast til MP4. Blir berre bruka for nokon omkodingsval.", + "transcoding_accepted_video_codecs": "Tillatne videokodekar", + "transcoding_accepted_video_codecs_description": "Vel kva for videokodekar som ikkje må omkodast. Berre bruka for nokon omkodingsval.", + "transcoding_advanced_options_description": "Innstillingar dei fleste brukarar ikkje treng forandre på", + "transcoding_audio_codec": "Lydkodek", + "transcoding_audio_codec_description": "Opus er det valet med høgast lydkvalitet, men mindre kompabilitet med gamlare einingar og programvare.", + "transcoding_bitrate_description": "Videoar med bitrate over høgste tillatte verdi, eller i eit format som ikkje er tillate", + "transcoding_codecs_learn_more": "For å lære meir om nytta begrep, sjå FFmpeg dokumentasjon for H.264 codec, HEVC codec and VP9 codec." }, + "admin_email": "Adminisrator E-post", + "admin_password": "Administratorpassord", "administration": "Administrasjon", "advanced": "Avansert", "album_with_link_access": "Lat kven som helst med lenka sjå bilete og folk i dette albumet.", @@ -92,7 +183,7 @@ "archive": "Arkiv", "asset_skipped": "Hoppa over", "asset_uploaded": "Opplasta", - "asset_uploading": "Lastar opp...", + "asset_uploading": "Lastar opp…", "back": "Tilbake", "backward": "Bakover", "camera": "Kamera", @@ -166,7 +257,7 @@ "never": "Aldri", "next": "Neste", "no": "Nei", - "no_albums_message": "Lag eit album for å organisere bileta og videoane dine.", + "no_albums_message": "Lag eit album for å organisere bileta og videoane dine", "no_archived_assets_message": "Arkiver bilder og videoar for å skjule dei frå bileta dine", "no_explore_results_message": "Last opp fleire bilete for å utforske samlinga di.", "no_libraries_message": "Lag eit eksternt bibliotek for å sjå bileta og videoane dine", @@ -232,7 +323,7 @@ "shared_from_partner": "Bilete frå {partner}", "sharing": "Deling", "show_in_timeline_setting_description": "Vis bilete og videoar frå denne brukaren i tidslinja di", - "sidebar": "Sidebar", + "sidebar": "Sidefelt", "size": "Størrelse", "slideshow": "Lysbildeframvisning", "sort_title": "Tittel", @@ -282,10 +373,13 @@ "version": "Versjon", "video": "Video", "videos": "Videoar", + "visibility_changed": "Synlegheit forandra for {count, plural, one {# person} other {# personar}}", "waiting": "Ventar", "warning": "Advarsel", "week": "Veke", "welcome": "Velkomen", "year": "År", - "yes": "Ja" + "years_ago": "{years, plural, one {# År} other {# År}} sidan", + "yes": "Ja", + "zoom_image": "Forstørr bilete" } diff --git a/i18n/pl.json b/i18n/pl.json index 49de4dc9e8..b8303604f4 100644 --- a/i18n/pl.json +++ b/i18n/pl.json @@ -20,7 +20,7 @@ "add_partner": "Dodaj partnera", "add_path": "Dodaj ścieżkę", "add_photos": "Dodaj zdjęcia", - "add_to": "Dodaj do...", + "add_to": "Dodaj do…", "add_to_album": "Dodaj do albumu", "add_to_shared_album": "Dodaj do udostępnionego albumu", "add_url": "Dodaj URL", @@ -39,7 +39,7 @@ "backup_database_enable_description": "Włącz kopię zapasową bazy danych", "backup_keep_last_amount": "Ile poprzednich kopii zapasowych przechowywać", "backup_settings": "Ustawienia kopii zapasowej", - "backup_settings_description": "Zarządzaj ustawieniami kopii zapasowej bazy dnaych", + "backup_settings_description": "Zarządzaj ustawieniami kopii zapasowej bazy danych", "check_all": "Zaznacz Wszystko", "cleared_jobs": "Usunięto zadania dla: {job}", "config_set_by_file": "Konfiguracja pochodzi z pliku konfiguracyjnego", @@ -50,7 +50,7 @@ "confirm_user_password_reset": "Czy na pewno chcesz zresetować hasło użytkownika {user}?", "create_job": "Utwórz zadanie", "cron_expression": "Wyrażenie Cron", - "cron_expression_description": "Ustaw intwerwał skanowania przy pomocy formatu Cron'a. Po więcej informacji na temat formatu Cron zobacz . Crontab Guru", + "cron_expression_description": "Ustaw interwał skanowania przy pomocy formatu Cron'a. Po więcej informacji na temat formatu Cron zobacz . Crontab Guru", "cron_expression_presets": "Predefiniowane wyrażenia Cron'a", "disable_login": "Wyłącz logowanie", "duplicate_detection_job_description": "Włącz uczenie maszynowe na zasobie aby wykrywać podobne obrazy. Ta funkcja opiera się na inteligentnym wyszukiwaniu", @@ -196,7 +196,7 @@ "oauth_settings_more_details": "Więcej informacji o tej funkcji znajdziesz w dokumentacji.", "oauth_signing_algorithm": "Algorytm podpisywania", "oauth_storage_label_claim": "Roszczenie dotyczące etykiety przechowywania", - "oauth_storage_label_claim_description": "Automatycznie ustaw ilość miejsca w magazynie użytkownikowi na podaną niżej wartość.", + "oauth_storage_label_claim_description": "Automatycznie ustaw etykietę przechowywania użytkownika na podaną niżej wartość.", "oauth_storage_quota_claim": "Ilość miejsca w magazynie", "oauth_storage_quota_claim_description": "Automatycznie ustaw ilość miejsca w magazynie na podaną niżej wartość.", "oauth_storage_quota_default": "Domyślna ilość miejsca w magazynie (GiB)", @@ -219,7 +219,7 @@ "reset_settings_to_default": "Przywróć ustawienia fabryczne", "reset_settings_to_recent_saved": "Przywróć ustawienia do ostatnio zapisanych", "scanning_library": "Skanowanie biblioteki", - "search_jobs": "Zadania przeszukiwania...", + "search_jobs": "Zadania przeszukiwania…", "send_welcome_email": "Wyślij powitalny e-mail", "server_external_domain_settings": "Domena zewnętrzna", "server_external_domain_settings_description": "Domena dla publicznie udostępnionych linków, wraz z http(s)://", @@ -250,7 +250,7 @@ "storage_template_user_label": "{label} to jest etykieta przechowywania użytkownika", "system_settings": "Ustawienia Systemowe", "tag_cleanup_job": "Porządkowanie etykiet", - "template_email_available_tags": "Możesz uzyć tych zmiennych w swoim szablonie: {tags}", + "template_email_available_tags": "Możesz użyć tych zmiennych w swoim szablonie: {tags}", "template_email_if_empty": "Zostaw puste, aby użyć domyślny adres e-mail.", "template_email_invite_album": "Szablon zaproszenia do albumu", "template_email_preview": "Podgląd", @@ -261,7 +261,7 @@ "template_settings": "Szablony Powiadomień", "template_settings_description": "Zarządzaj niestandardowymi szablonami powiadomień e-mail.", "theme_custom_css_settings": "Własny CSS", - "theme_custom_css_settings_description": "Właśny CSS pozwala na zmianę wyglądu aplikacji Immich.", + "theme_custom_css_settings_description": "Własny CSS pozwala na zmianę wyglądu aplikacji Immich.", "theme_settings": "Ustawienia Motywu", "theme_settings_description": "Zarządzaj wyglądem aplikacji Immich w przeglądarce", "these_files_matched_by_checksum": "Pliki te są powiązane na podstawie ich sum kontrolnych", @@ -406,17 +406,17 @@ "are_these_the_same_person": "Czy to jedna i ta sama osoba?", "are_you_sure_to_do_this": "Czy aby na pewno chcesz to zrobić?", "asset_added_to_album": "Dodano do albumu", - "asset_adding_to_album": "Dodawanie do albumu...", + "asset_adding_to_album": "Dodawanie do albumu…", "asset_description_updated": "Zaktualizowano opis zasobu", "asset_filename_is_offline": "Zasób {filename} jest offline", "asset_has_unassigned_faces": "Zasób ma nieprzypisane twarze", - "asset_hashing": "Hashowanie...", + "asset_hashing": "Hashowanie…", "asset_offline": "Zasób niedostępny", "asset_offline_description": "Ten zewnętrzny zasób nie jest już dostępny na dysku. Aby uzyskać pomoc, skontaktuj się z administratorem Immich.", "asset_skipped": "Pominięto", "asset_skipped_in_trash": "W koszu", "asset_uploaded": "Przesłano", - "asset_uploading": "Przesyłanie...", + "asset_uploading": "Przesyłanie…", "assets": "Zasoby", "assets_added_count": "Dodano {count, plural, one {# zasób} few {# zasoby} many {# zasobów} other {# zasobów}}", "assets_added_to_album_count": "Dodano {count, plural, one {# zasób} few {# zasoby} many {# zasobów} other {# zasobów}} do albumu", @@ -822,6 +822,7 @@ "latest_version": "Ostatnia Wersja", "latitude": "Szerokość geograficzna", "leave": "Opuść", + "lens_model": "Model obiektywu", "let_others_respond": "Pozwól innym reagować", "level": "Poziom", "library": "Biblioteka", @@ -862,7 +863,7 @@ "map_settings": "Ustawienia mapy", "matches": "Powiązania", "media_type": "Typ zasobu", - "memories": "Wspomienia", + "memories": "Wspomnienia", "memories_setting_description": "Zarządzaj wspomnieniami", "memory": "Pamięć", "memory_lane_title": "Aleja Wspomnień {title}", @@ -902,7 +903,7 @@ "no_duplicates_found": "Nie znaleziono duplikatów.", "no_exif_info_available": "Nie znaleziono informacji exif", "no_explore_results_message": "Prześlij więcej zdjęć, aby przeglądać swój zbiór.", - "no_favorites_message": "Dodaj ulubione aby szybko znaleść swoje najlepsze zdjęcia i filmy", + "no_favorites_message": "Dodaj ulubione aby szybko znaleźć swoje najlepsze zdjęcia i filmy", "no_libraries_message": "Stwórz bibliotekę zewnętrzną, aby przeglądać swoje zdjęcia i filmy", "no_name": "Brak Nazwy", "no_places": "Brak miejsc", @@ -1107,12 +1108,14 @@ "search": "Szukaj", "search_albums": "Przeszukaj albumy", "search_by_context": "Wyszukaj według treści", + "search_by_description": "Wyszukaj według opisu", "search_by_filename": "Szukaj według nazwy pliku lub rozszerzenia", "search_by_filename_example": "np. IMG_1234.JPG lub PNG", "search_camera_make": "Wyszukaj markę aparatu...", "search_camera_model": "Wyszukaj model aparatu...", "search_city": "Wyszukaj miasto...", "search_country": "Wyszukaj kraj...", + "search_for": "Szukaj wśród", "search_for_existing_person": "Wyszukaj istniejącą osobę", "search_no_people": "Brak osób", "search_no_people_named": "Brak osób nazwanych \"{name}\"", diff --git a/i18n/pt.json b/i18n/pt.json index fa88c287fe..26359a86ab 100644 --- a/i18n/pt.json +++ b/i18n/pt.json @@ -20,7 +20,7 @@ "add_partner": "Adicionar parceiro", "add_path": "Adicionar caminho", "add_photos": "Adicionar fotos", - "add_to": "Adicionar a...", + "add_to": "Adicionar a…", "add_to_album": "Adicionar ao álbum", "add_to_shared_album": "Adicionar ao álbum partilhado", "add_url": "Adicionar URL", @@ -219,7 +219,7 @@ "reset_settings_to_default": "Redefinir as definições para o padrão", "reset_settings_to_recent_saved": "Redefinir as definições para as guardadas mais recentemente", "scanning_library": "A analisar biblioteca", - "search_jobs": "Pesquisar tarefas...", + "search_jobs": "Pesquisar tarefas…", "send_welcome_email": "Enviar e-mail de boas-vindas", "server_external_domain_settings": "Domínio externo", "server_external_domain_settings_description": "Domínio para links públicos partilhados, incluindo http(s)://", @@ -406,17 +406,17 @@ "are_these_the_same_person": "Estas pessoas são a mesma pessoa?", "are_you_sure_to_do_this": "Tem a certeza de que quer fazer isto?", "asset_added_to_album": "Adicionado ao álbum", - "asset_adding_to_album": "A adicionar ao álbum...", + "asset_adding_to_album": "A adicionar ao álbum…", "asset_description_updated": "A descrição do ficheiro foi atualizada", "asset_filename_is_offline": "O ficheiro {filename} não está disponível", "asset_has_unassigned_faces": "O ficheiro tem rostos não atribuídas", - "asset_hashing": "A criar hash...", + "asset_hashing": "A criar hash…", "asset_offline": "Ficheiro Indisponível", "asset_offline_description": "Este ficheiro externo deixou de estar disponível no disco. Contacte o seu administrador do Immich para obter ajuda.", "asset_skipped": "Ignorado", "asset_skipped_in_trash": "Na reciclagem", "asset_uploaded": "Enviado", - "asset_uploading": "A enviar...", + "asset_uploading": "A enviar…", "assets": "Ficheiros", "assets_added_count": "{count, plural, one {# ficheiro adicionado} other {# ficheiros adicionados}}", "assets_added_to_album_count": "{count, plural, one {# ficheiro adicionado} other {# ficheiros adicionados}} ao álbum", @@ -526,7 +526,7 @@ "deduplication_criteria_1": "Tamanho da imagem em bytes", "deduplication_criteria_2": "Quantidade de dados EXIF", "deduplication_info": "Informações sobre remoção de duplicados", - "deduplication_info_description": "Para selecionar automaticamente itens e remover duplicados em massa, vemos o seguinte:", + "deduplication_info_description": "Para selecionar automaticamente itens e remover duplicados em massa, iremos ver o seguinte:", "default_locale": "Localização Padrão", "default_locale_description": "Formatar datas e números baseados na linguagem do seu navegador", "delete": "Eliminar", @@ -766,8 +766,10 @@ "go_to_folder": "Ir para a pasta", "go_to_search": "Ir para a pesquisa", "group_albums_by": "Agrupar álbuns por...", + "group_country": "Agrupar por país", "group_no": "Sem agrupamento", "group_owner": "Agrupar por dono", + "group_places_by": "Agrupar lugares por...", "group_year": "Agrupar por ano", "has_quota": "Tem quota", "hi_user": "Olá {name} ({email})", @@ -800,6 +802,7 @@ "include_shared_albums": "Incluir álbuns partilhados", "include_shared_partner_assets": "Incluir ficheiros partilhados por parceiros", "individual_share": "Partilha individual", + "individual_shares": "Partilhas individuais", "info": "Informações", "interval": { "day_at_onepm": "Todos os dias, às 13:00", @@ -822,6 +825,7 @@ "latest_version": "Versão mais recente", "latitude": "Latitude", "leave": "Sair", + "lens_model": "Modelo de lente", "let_others_respond": "Permitir respostas", "level": "Nível", "library": "Biblioteca", @@ -984,6 +988,7 @@ "pick_a_location": "Selecione uma localização", "place": "Lugar", "places": "Lugares", + "places_count": "{count, plural, one {{count, number} Lugar} other {{count, number} Lugares}}", "play": "Reproduzir", "play_memories": "Reproduzir memórias", "play_motion_photo": "Reproduzir foto em movimento", @@ -1107,12 +1112,15 @@ "search": "Pesquisar", "search_albums": "Pesquisar álbuns", "search_by_context": "Pesquisar por contexto", + "search_by_description": "Pesquisar por descrição", + "search_by_description_example": "Dia de caminhada em Leiria", "search_by_filename": "Pesquisar por nome de ficheiro ou extensão", "search_by_filename_example": "por exemplo, IMG_1234.JPG ou PNG", "search_camera_make": "Pesquisar por marca da câmara...", "search_camera_model": "Pesquisar por modelo da câmara...", "search_city": "Pesquisar cidade...", "search_country": "Pesquisar país...", + "search_for": "Pesquisar por", "search_for_existing_person": "Pesquisar por pessoas existentes", "search_no_people": "Sem pessoas", "search_no_people_named": "Nenhuma pessoa chamada \"{name}\"", @@ -1165,6 +1173,7 @@ "shared_from_partner": "Fotos de {partner}", "shared_link_options": "Opções de link partilhado", "shared_links": "Links partilhados", + "shared_links_description": "Partilhar fotos e videos com um link", "shared_photos_and_videos_count": "{assetCount, plural, other {# Fotos & videos partilhados.}}", "shared_with_partner": "Partilhado com {partner}", "sharing": "Partilha", @@ -1187,6 +1196,7 @@ "show_person_options": "Exibir opções da pessoa", "show_progress_bar": "Exibir barra de progresso", "show_search_options": "Exibir opções de pesquisa", + "show_shared_links": "Mostrar links partilhados", "show_slideshow_transition": "Mostrar transições no Modo de Apresentação", "show_supporter_badge": "Emblema de apoiante", "show_supporter_badge_description": "Mostrar um emblema de apoiante", @@ -1274,6 +1284,7 @@ "unfavorite": "Remover favorito", "unhide_person": "Exibir pessoa", "unknown": "Desconhecido", + "unknown_country": "País desconhecido", "unknown_year": "Ano desconhecido", "unlimited": "Ilimitado", "unlink_motion_video": "Remover relação com video animado", diff --git a/i18n/pt_BR.json b/i18n/pt_BR.json index 6ad0be429b..d826997f5e 100644 --- a/i18n/pt_BR.json +++ b/i18n/pt_BR.json @@ -20,7 +20,7 @@ "add_partner": "Adicionar parceiro", "add_path": "Adicionar caminho", "add_photos": "Adicionar fotos", - "add_to": "Adicionar a...", + "add_to": "Adicionar a…", "add_to_album": "Adicionar ao álbum", "add_to_shared_album": "Adicionar ao álbum compartilhado", "add_url": "Adicionar URL", @@ -39,7 +39,7 @@ "backup_database_enable_description": "Ativar backup do banco de dados", "backup_keep_last_amount": "Quantidade de backups anteriores para manter salvo", "backup_settings": "Configurações de backup", - "backup_settings_description": "Gerenciar configurações de backup", + "backup_settings_description": "Gerenciar configurações de backup do banco de dados", "check_all": "Selecionar Tudo", "cleared_jobs": "Tarefas removidas de: {job}", "config_set_by_file": "A configuração está atualmente definida por um arquivo de configuração", @@ -53,7 +53,7 @@ "cron_expression_description": "Defina o intervalo de análise no formato Cron. Para mais informações, por favor veja o Crontab Guru", "cron_expression_presets": "Sugestões de expressão Cron", "disable_login": "Desabilitar login", - "duplicate_detection_job_description": "Execute a inteligência artificial em arquivos para detectar imagens semelhantes. Depende da Pesquisa Inteligente", + "duplicate_detection_job_description": "Execute o aprendizado de máquina em arquivos para detectar imagens semelhantes. Depende da Pesquisa Inteligente", "exclusion_pattern_description": "Os padrões de exclusão permitem ignorar arquivos e pastas ao escanear sua biblioteca. Isso é útil se você tiver pastas que contenham arquivos que não deseja importar, como arquivos RAW.", "external_library_created_at": "Biblioteca externa (criada em {date})", "external_library_management": "Gerenciamento de bibliotecas externas", @@ -69,8 +69,8 @@ "image_prefer_embedded_preview_setting_description": "Use visualizações incorporadas em fotos RAW como entrada para processamento de imagem, quando disponível. Isso pode produzir cores mais precisas para algumas imagens, mas a qualidade da visualização depende da câmera e a imagem pode ter mais artefatos de compactação.", "image_prefer_wide_gamut": "Prefira ampla gama", "image_prefer_wide_gamut_setting_description": "Use o Display P3 para miniaturas. Isso preserva melhor a vibração das imagens com espaços de cores amplos, mas as imagens podem aparecer de maneira diferente em dispositivos antigos com uma versão antiga do navegador. As imagens sRGB são mantidas como sRGB para evitar mudanças de cores.", - "image_preview_description": "Imagem de tamanho médio sem os metadados, utilizado quando visualizar um único arquivo e também pela inteligência artificial", - "image_preview_quality_description": "Qualidade da pré-visualização, de 1-100. Maior é melhor, mas produz arquivos maiores e pode reduzir a velocidade do aplicativo. Definir um valor muito baixo pode afetar a qualidade da inteligência artificial.", + "image_preview_description": "Imagem de tamanho médio sem os metadados, utilizado quando visualizando um único arquivo e também pelo aprendizado de máquina", + "image_preview_quality_description": "Qualidade da pré-visualização, de 1-100. Maior é melhor, mas produz arquivos maiores e pode reduzir a velocidade do aplicativo. Definir um valor muito baixo pode afetar a qualidade do aprendizado de máquina.", "image_preview_title": "Configurações de pré-visualização", "image_quality": "Qualidade", "image_resolution": "Resolução", @@ -108,13 +108,13 @@ "machine_learning_duplicate_detection": "Detecção de duplicidade", "machine_learning_duplicate_detection_enabled": "Habilitar detecção de duplicidade", "machine_learning_duplicate_detection_enabled_description": "Se desativado, arquivos exatamente idênticos ainda serão desduplicados.", - "machine_learning_duplicate_detection_setting_description": "Use embeddings CLIP para encontrar prováveis duplicidades", - "machine_learning_enabled": "Habilitar a inteligência artificial", - "machine_learning_enabled_description": "Se desativado, todos os recursos de ML serão desativados, independentemente das configurações abaixo.", + "machine_learning_duplicate_detection_setting_description": "Usar CLIP integrado para encontrar prováveis duplicidades", + "machine_learning_enabled": "Habilitar aprendizado de máquina", + "machine_learning_enabled_description": "Se desativado, todos os recursos de AM serão desativados, independentemente das configurações abaixo.", "machine_learning_facial_recognition": "Reconhecimento Facial", "machine_learning_facial_recognition_description": "Detectar, reconhecer e agrupar rostos em imagens", "machine_learning_facial_recognition_model": "Modelo de reconhecimento facial", - "machine_learning_facial_recognition_model_description": "Os modelos estão listados em ordem decrescente de tamanho. Modelos maiores são mais lentos e utilizam mais memória, mas produzem melhores resultados. Observe que ao alterar um modelo, você deve executar novamente a tarefa de Detecção de Rostos para todas as imagens.", + "machine_learning_facial_recognition_model_description": "Os modelos estão listados em ordem decrescente de tamanho. Modelos maiores são mais lentos e utilizam mais memória, mas produzem resultados melhores. Observe que ao alterar um modelo, você deve executar novamente a tarefa de Detecção de Rostos para todas as imagens.", "machine_learning_facial_recognition_setting": "Ativar reconhecimento facial", "machine_learning_facial_recognition_setting_description": "Se desativado, as imagens não serão codificadas para reconhecimento facial e não preencherão a seção Pessoas na página Explorar.", "machine_learning_max_detection_distance": "Distância máxima de detecção", @@ -124,14 +124,14 @@ "machine_learning_min_detection_score": "Pontuação mínima de detecção", "machine_learning_min_detection_score_description": "Pontuação mínima de confiança para um rosto ser detectado, de 0 a 1. Valores mais baixos detectam mais rostos, mas poderão resultar em falsos positivos.", "machine_learning_min_recognized_faces": "Mínimo de rostos reconhecidos", - "machine_learning_min_recognized_faces_description": "O número mínimo de rostos reconhecidos para uma pessoa ser criada na lista. Aumentar isso torna o Reconhecimento Facial mais preciso, ao custo de aumentar a chance de um rosto não ser atribuído a uma pessoa.", - "machine_learning_settings": "Configurações de inteligência artificial", - "machine_learning_settings_description": "Gerenciar recursos e configurações da inteligência artificial", + "machine_learning_min_recognized_faces_description": "O número mínimo de rostos reconhecidos para uma pessoa ser criada. Aumentar isso torna o Reconhecimento Facial mais preciso, ao custo de aumentar a chance de um rosto não ser atribuído a uma pessoa.", + "machine_learning_settings": "Configurações de aprendizado de máquina", + "machine_learning_settings_description": "Gerenciar recursos e configurações do aprendizado de máquina", "machine_learning_smart_search": "Pesquisa Inteligente", - "machine_learning_smart_search_description": "Buscar imagens semanticamente usando embeddings CLIP", + "machine_learning_smart_search_description": "Buscar imagens semanticamente usando integrações CLIP", "machine_learning_smart_search_enabled": "Habilitar a Pesquisa Inteligente", "machine_learning_smart_search_enabled_description": "Se desativado, as imagens não serão codificadas para pesquisa inteligente.", - "machine_learning_url_description": "A URL do servidor de inteligência artificial. Se mais de uma URL for configurada, o servidor irá tentar uma de cada vez até que uma delas responda com sucesso, em ordem sequencial igual a configurada.", + "machine_learning_url_description": "A URL do servidor de aprendizado de máquina. Se mais de uma URL for fornecida, elas serão tentadas, uma de cada vez e na ordem indicada, até que uma responda com sucesso.", "manage_concurrency": "Gerenciar simultaneidade", "manage_log_settings": "Gerenciar configurações de registro", "map_dark_style": "Tema Escuro", @@ -219,7 +219,7 @@ "reset_settings_to_default": "Redefinir as configurações para o padrão", "reset_settings_to_recent_saved": "Redefinir as configurações para as configurações salvas recentemente", "scanning_library": "Analisando a biblioteca", - "search_jobs": "Pesquisar tarefas...", + "search_jobs": "Pesquisar tarefas…", "send_welcome_email": "Enviar e-mail de boas-vindas", "server_external_domain_settings": "Domínio externo", "server_external_domain_settings_description": "Domínio para links públicos compartilhados, incluindo http(s)://", @@ -232,7 +232,7 @@ "sidecar_job": "Metadados secundários", "sidecar_job_description": "Descubra ou sincronize metadados secundários do sistema de arquivos", "slideshow_duration_description": "Tempo em segundos para exibir cada imagem", - "smart_search_job_description": "Execute a inteligência artificial em arquivos para oferecer suporte à pesquisa inteligente", + "smart_search_job_description": "Execute aprendizado de máquina em arquivos para oferecer suporte à pesquisa inteligente", "storage_template_date_time_description": "A data e hora da criação do ativo é usado para a informações de data e hora", "storage_template_date_time_sample": "Exemplo {date}", "storage_template_enable_description": "Habilitar mecanismo de modelo de armazenamento", @@ -406,17 +406,17 @@ "are_these_the_same_person": "Essas pessoas são a mesma pessoa?", "are_you_sure_to_do_this": "Tem certeza de que deseja fazer isso?", "asset_added_to_album": "Adicionado ao álbum", - "asset_adding_to_album": "Adicionando ao álbum...", + "asset_adding_to_album": "Adicionando ao álbum…", "asset_description_updated": "A descrição do ativo foi atualizada", "asset_filename_is_offline": "O arquivo {filename} não está disponível", "asset_has_unassigned_faces": "O arquivo tem rostos sem nomes", - "asset_hashing": "Processando...", + "asset_hashing": "Processando…", "asset_offline": "Arquivo indisponível", "asset_offline_description": "Este arquivo externo não está mais disponível. Contate seu administrador do Immich para obter ajuda.", "asset_skipped": "Ignorado", "asset_skipped_in_trash": "Na lixeira", "asset_uploaded": "Carregado", - "asset_uploading": "Carregando...", + "asset_uploading": "Carregando…", "assets": "Arquivos", "assets_added_count": "{count, plural, one {# arquivo adicionado} other {# arquivos adicionados}}", "assets_added_to_album_count": "{count, plural, one {# arquivo adicionado} other {# arquivos adicionados}} ao álbum", @@ -766,8 +766,10 @@ "go_to_folder": "Ir para a pasta", "go_to_search": "Ir para a pesquisa", "group_albums_by": "Agrupar álbuns por...", + "group_country": "Agrupar por país", "group_no": "Sem agrupamento", "group_owner": "Agrupar por dono", + "group_places_by": "Agrupar lugares por...", "group_year": "Agrupar por ano", "has_quota": "Há cota", "hi_user": "Olá {name} ({email})", @@ -800,6 +802,7 @@ "include_shared_albums": "Incluir álbuns compartilhados", "include_shared_partner_assets": "Incluir arquivos compartilhados por parceiros", "individual_share": "Compartilhamento único", + "individual_shares": "Compartilhamentos individuais", "info": "Informações", "interval": { "day_at_onepm": "Todo dia, 1pm", @@ -822,6 +825,7 @@ "latest_version": "Versão mais recente", "latitude": "Latitude", "leave": "Sair", + "lens_model": "Modelo da lente", "let_others_respond": "Permitir respostas", "level": "Nível", "library": "Biblioteca", @@ -984,6 +988,7 @@ "pick_a_location": "Selecione uma localização", "place": "Lugar", "places": "Lugares", + "places_count": "{count, plural, one {{count, number} Lugar} other {{count, number} Lugares}}", "play": "Reproduzir", "play_memories": "Reproduzir memórias", "play_motion_photo": "Reproduzir foto em movimento", @@ -1095,7 +1100,7 @@ "role": "Função", "role_editor": "Editor", "role_viewer": "Visualizador", - "save": "Guardar", + "save": "Salvar", "saved_api_key": "Chave de API salva", "saved_profile": "Perfil Salvo", "saved_settings": "Configurações salvas", @@ -1107,12 +1112,15 @@ "search": "Pesquisar", "search_albums": "Pesquisar álbuns", "search_by_context": "Pesquisar por contexto", + "search_by_description": "Pesquisar por descrição", + "search_by_description_example": "Dia de caminhada no Ibirapuera", "search_by_filename": "Pesquisa por nome de arquivo ou extensão", "search_by_filename_example": "Por exemplo, IMG_1234.JPG ou PNG", "search_camera_make": "Pesquisar câmeras da marca...", "search_camera_model": "Pesquisar câmera do modelo...", "search_city": "Pesquisar cidade...", "search_country": "Pesquisar país...", + "search_for": "Pesquisar por", "search_for_existing_person": "Pesquisar por pessoas", "search_no_people": "Nenhuma pessoa", "search_no_people_named": "Nenhuma pessoa chamada \"{name}\"", @@ -1165,6 +1173,7 @@ "shared_from_partner": "Fotos de {partner}", "shared_link_options": "Opções do link compartilhado", "shared_links": "Links compartilhados", + "shared_links_description": "Compartilhar fotos e videos com um link", "shared_photos_and_videos_count": "{assetCount, plural, one {# arquivo compartilhado.} other {# arquivos compartilhados.}}", "shared_with_partner": "Compartilhado com {partner}", "sharing": "Compartilhar", @@ -1187,6 +1196,7 @@ "show_person_options": "Exibir opções da pessoa", "show_progress_bar": "Exibir barra de progresso", "show_search_options": "Exibir opções de pesquisa", + "show_shared_links": "Mostrar links compartilhados", "show_slideshow_transition": "Usar transições no modo de apresentação", "show_supporter_badge": "Insígnia de Contribuidor", "show_supporter_badge_description": "Mostrar a insígnia de contribuidor", @@ -1215,7 +1225,7 @@ "stack_select_one_photo": "Selecione uma foto principal para a pilha", "stack_selected_photos": "Empilhar fotos selecionadas", "stacked_assets_count": "{count, plural, one {# arquivo empilhado} other {# arquivos empilhados}}", - "stacktrace": "Stacktrace", + "stacktrace": "Rastreamento de pilha", "start": "Início", "start_date": "Data inicial", "state": "Estado", @@ -1274,6 +1284,7 @@ "unfavorite": "Remover favorito", "unhide_person": "Exibir pessoa", "unknown": "Desconhecido", + "unknown_country": "País desconhecido", "unknown_year": "Ano desconhecido", "unlimited": "Ilimitado", "unlink_motion_video": "Remover relação com video animado", diff --git a/i18n/ro.json b/i18n/ro.json index d4205e331c..135d647b54 100644 --- a/i18n/ro.json +++ b/i18n/ro.json @@ -766,8 +766,10 @@ "go_to_folder": "Accesați folderul", "go_to_search": "Spre căutare", "group_albums_by": "Grupați albume de...", + "group_country": "Grupare după țară", "group_no": "Fără grupare", "group_owner": "Grupați după proprietar", + "group_places_by": "Grupare locuri după...", "group_year": "Grupați după an", "has_quota": "Are spațiu de stocare", "hi_user": "Bună {name} ({email})", @@ -800,6 +802,7 @@ "include_shared_albums": "Include albumele partajate", "include_shared_partner_assets": "Include resursele partenerilor partajați", "individual_share": "Cota individuală", + "individual_shares": "Partajări individuale", "info": "Informație", "interval": { "day_at_onepm": "În fiecare zi la ora 13.00", @@ -822,6 +825,7 @@ "latest_version": "Ultima Versiune", "latitude": "Latitudine", "leave": "Părăsiți", + "lens_model": "Model obiectiv", "let_others_respond": "Permite altora să răspundă", "level": "Nivel", "library": "Librărie", @@ -1107,12 +1111,15 @@ "search": "Căutați", "search_albums": "Căutați albume", "search_by_context": "Căutați după context", + "search_by_description": "Căutare după descriere", + "search_by_description_example": "Zi de drumeție în Sapa", "search_by_filename": "Căutați după numele fișierului sau extensie", "search_by_filename_example": "i.e. IMG_1234.JPG sau PNG", "search_camera_make": "Se caută marca camerei...", "search_camera_model": "Se caută modelul camerei...", "search_city": "Se caută orașul...", "search_country": "Se caută țara...", + "search_for": "Căutare după", "search_for_existing_person": "Se caută o persoană existentă", "search_no_people": "Fără persoane", "search_no_people_named": "Nicio persoană numită \"{name}\"", @@ -1165,6 +1172,7 @@ "shared_from_partner": "Fotografii de la {partner}", "shared_link_options": "Opțiuni de link partajat", "shared_links": "Link-uri distribuite", + "shared_links_description": "Partajare imagini și clipuri printr-un link", "shared_photos_and_videos_count": "{assetCount, plural, other {# fotografii și videoclipuri partajate.}}", "shared_with_partner": "Partajat cu {partner}", "sharing": "Distribuire", @@ -1187,6 +1195,7 @@ "show_person_options": "Afișați opțiunile persoanelor", "show_progress_bar": "Afișați Bara de Progres", "show_search_options": "Afișați opțiunile de căutare", + "show_shared_links": "Afișare linkuri partajate", "show_slideshow_transition": "Afișați tranziția de prezentare", "show_supporter_badge": "Insigna suporterului", "show_supporter_badge_description": "Arată o insignă de suporter", @@ -1274,6 +1283,7 @@ "unfavorite": "Ștergeți din favorite", "unhide_person": "Dezvăluie persoana", "unknown": "Necunoscut", + "unknown_country": "Țară necunoscută", "unknown_year": "An Necunoscut", "unlimited": "Nelimitat", "unlink_motion_video": "Deconectați videoclipul în mișcare", diff --git a/i18n/ru.json b/i18n/ru.json index 887222cb9c..f2c191b004 100644 --- a/i18n/ru.json +++ b/i18n/ru.json @@ -20,7 +20,7 @@ "add_partner": "Добавить партнёра", "add_path": "Добавить путь", "add_photos": "Добавить фото", - "add_to": "Добавить в...", + "add_to": "Добавить в…", "add_to_album": "Добавить в альбом", "add_to_shared_album": "Добавить в общий альбом", "add_url": "Добавить URL", @@ -219,7 +219,7 @@ "reset_settings_to_default": "Сброс настроек до значений по умолчанию", "reset_settings_to_recent_saved": "Сбросьте настройки к последним сохраненным настройкам", "scanning_library": "Сканирование библиотеки", - "search_jobs": "Поиск заданий...", + "search_jobs": "Поиск заданий…", "send_welcome_email": "Отправить приветственное письмо", "server_external_domain_settings": "Внешний домен", "server_external_domain_settings_description": "Домен для публичных ссылок, включая http(s)://", @@ -406,17 +406,17 @@ "are_these_the_same_person": "Это один и тот же человек?", "are_you_sure_to_do_this": "Вы уверены, что хотите это сделать?", "asset_added_to_album": "Добавлено в альбом", - "asset_adding_to_album": "Добавление в альбом...", + "asset_adding_to_album": "Добавление в альбом…", "asset_description_updated": "Описание обновлено", "asset_filename_is_offline": "Объект {filename} находится в офлайн-режиме", "asset_has_unassigned_faces": "Есть не распознанные лица", - "asset_hashing": "Хеширование...", + "asset_hashing": "Хеширование…", "asset_offline": "Объект отключён", "asset_offline_description": "Этот внешний файл не найден на диске. Пожалуйста, свяжитесь с администратором Immich для получения помощи.", "asset_skipped": "Пропущено", "asset_skipped_in_trash": "В корзине", "asset_uploaded": "Загружено", - "asset_uploading": "Загрузка...", + "asset_uploading": "Загрузка…", "assets": "Объекты", "assets_added_count": "Добавлено {count, plural, one {# объект} few {# объекта} other {# объектов}}", "assets_added_to_album_count": "В альбом добавлено {count, plural, one {# объект} few {# объекта} other {# объектов}}", @@ -482,7 +482,7 @@ "confirm": "Подтвердить", "confirm_admin_password": "Подтвердите пароль Администратора", "confirm_delete_shared_link": "Вы уверены, что хотите удалить эту публичную ссылку?", - "confirm_keep_this_delete_others": "Все остальные объекты в серии будут удалены, кроме этого объекта. Вы уверены, что хотите продолжить?", + "confirm_keep_this_delete_others": "Все остальные объекты в группе будут удалены, кроме этого объекта. Вы уверены, что хотите продолжить?", "confirm_password": "Подтвердите пароль", "contain": "Вместить", "context": "Контекст", @@ -766,8 +766,10 @@ "go_to_folder": "Перейти в папку", "go_to_search": "Перейти к поиску", "group_albums_by": "Группировать альбомы по...", + "group_country": "Группировать по странам", "group_no": "Без группировки", "group_owner": "Группировать по владельцу", + "group_places_by": "Группировать места по...", "group_year": "Группировать по годам", "has_quota": "Квота", "hi_user": "Привет {name} ({email})", @@ -800,6 +802,7 @@ "include_shared_albums": "Включать общие альбомы", "include_shared_partner_assets": "Включать общие ресурсы партнера", "individual_share": "Персональный доступ", + "individual_shares": "Индивидуальный доступ", "info": "Информация", "interval": { "day_at_onepm": "Каждый день в 13:00", @@ -822,6 +825,7 @@ "latest_version": "Последняя Версия", "latitude": "Широта", "leave": "Покинуть", + "lens_model": "Модель объектива", "let_others_respond": "Позволять другим откликаться", "level": "Уровень", "library": "Библиотека", @@ -984,6 +988,7 @@ "pick_a_location": "Выбрать местоположение", "place": "Места", "places": "Места", + "places_count": "{count, plural, one {{count, number} Место} other {{count, number} Мест}}", "play": "Воспроизвести", "play_memories": "Воспроизвести воспоминания", "play_motion_photo": "Воспроизводить движущиеся фото", @@ -1107,12 +1112,15 @@ "search": "Поиск", "search_albums": "Поиск альбомов", "search_by_context": "Поиск по контексту", + "search_by_description": "Поиск по описанию", + "search_by_description_example": "День пешего туризма в Сапе", "search_by_filename": "Искать по имени файла или расширению", "search_by_filename_example": "например, IMG_1234.JPG или PNG", "search_camera_make": "Поиск производителя камеры...", "search_camera_model": "Поиск модели камеры...", "search_city": "Поиск города...", "search_country": "Поиск страны...", + "search_for": "Поиск по", "search_for_existing_person": "Поиск существующего человека", "search_no_people": "Нет людей", "search_no_people_named": "Нет людей с именем \"{name}\"", @@ -1165,6 +1173,7 @@ "shared_from_partner": "Фото от {partner}", "shared_link_options": "Параметры публичных ссылок", "shared_links": "Публичные ссылки", + "shared_links_description": "Делитесь фотографиями и видео по ссылке", "shared_photos_and_videos_count": "{assetCount, plural, other {# фото и видео.}}", "shared_with_partner": "Совместно с {partner}", "sharing": "Общие", @@ -1187,6 +1196,7 @@ "show_person_options": "Показать опции персоны", "show_progress_bar": "Показать Индикатор Выполнения", "show_search_options": "Показать параметры поиска", + "show_shared_links": "Показать публичные ссылки", "show_slideshow_transition": "Показать слайд-шоу переход", "show_supporter_badge": "Значок поддержки", "show_supporter_badge_description": "Показать значок поддержки", @@ -1210,11 +1220,11 @@ "sort_recent": "Недавние фото", "sort_title": "Заголовок", "source": "Исходный код", - "stack": "Превратить в серию", - "stack_duplicates": "Превратить дубликаты в серию", - "stack_select_one_photo": "Выберите главную фотографию для серии", - "stack_selected_photos": "Объединить выбранные объекты в серию", - "stacked_assets_count": "{count, plural, one {# объект добавлен} few {# объекта добавлено} other {# объектов добавлено}} в серию", + "stack": "Группировать", + "stack_duplicates": "Группировать дубликаты", + "stack_select_one_photo": "Выберите главную фотографию для группы", + "stack_selected_photos": "Группировать выбранные объекты", + "stacked_assets_count": "{count, plural, one {# объект добавлен} few {# объекта добавлено} other {# объектов добавлено}} в группу", "stacktrace": "Трассировка стека", "start": "Старт", "start_date": "Дата начала", @@ -1274,6 +1284,7 @@ "unfavorite": "Удалить из избранного", "unhide_person": "Показать персону", "unknown": "Неизвестно", + "unknown_country": "Неизвестная страна", "unknown_year": "Неизвестный Год", "unlimited": "Не ограничено", "unlink_motion_video": "Отсоединить движущееся видео", @@ -1285,8 +1296,8 @@ "unsaved_change": "Не сохраненное изменение", "unselect_all": "Снять всё", "unselect_all_duplicates": "Отменить выбор всех дубликатов", - "unstack": "Разгруппировать серию", - "unstacked_assets_count": "{count, plural, one {# объект извлечен} few {# объекта извлечено} other {# объектов извлечено}} из серии", + "unstack": "Разгруппировать", + "unstacked_assets_count": "{count, plural, one {# объект извлечен} few {# объекта извлечено} other {# объектов извлечено}} из группы", "untracked_files": "НЕОТСЛЕЖИВАЕМЫЕ ФАЙЛЫ", "untracked_files_decription": "Приложение не отслеживает эти файлы. Они могут быть результатом неудачных перемещений, прерванных загрузок или пропущены из-за ошибки", "up_next": "Следующее", diff --git a/i18n/sk.json b/i18n/sk.json index 9af007999b..5b958ab431 100644 --- a/i18n/sk.json +++ b/i18n/sk.json @@ -15,7 +15,7 @@ "add_a_title": "Pridať názov", "add_exclusion_pattern": "Pridať vzor vylúčenia", "add_import_path": "Pridať cestu pre import", - "add_location": "Pridať lokáciu", + "add_location": "Pridať polohu", "add_more_users": "Pridať viac používateľov", "add_partner": "Pridať partnera", "add_path": "Pridať cestu", @@ -822,6 +822,7 @@ "latest_version": "Najnovšia verzia", "latitude": "Zemepisná šírka", "leave": "Opustiť", + "lens_model": "Model objektívu", "let_others_respond": "Nechajte ostatných reagovať", "level": "Level", "library": "Knižnica", @@ -1113,6 +1114,7 @@ "search_camera_model": "Hľadať model fotoaparátu...", "search_city": "Hľadať mesto...", "search_country": "Hľadať krajinu...", + "search_for": "Vyhľadať", "search_for_existing_person": "Hľadať existujúcu osobu", "search_no_people": "Žiadne osoby", "search_no_people_named": "Žiadne osoby menom \"{name}\"", diff --git a/i18n/sl.json b/i18n/sl.json index 5073efcabc..2e576dc5ec 100644 --- a/i18n/sl.json +++ b/i18n/sl.json @@ -20,7 +20,7 @@ "add_partner": "Dodaj partnerja", "add_path": "Dodaj pot", "add_photos": "Dodaj fotografije", - "add_to": "Dodaj v...", + "add_to": "Dodaj v…", "add_to_album": "Dodaj v album", "add_to_shared_album": "Dodaj k deljenemu albumu", "add_url": "Dodaj URL", @@ -219,7 +219,7 @@ "reset_settings_to_default": "Ponastavi nastavitve na privzete", "reset_settings_to_recent_saved": "Ponastavite nastavitve na nedavno shranjene nastavitve", "scanning_library": "Pregledovanje knjižnice", - "search_jobs": "Iskalna opravila...", + "search_jobs": "Iskanje opravil…", "send_welcome_email": "Pošlji pozdravno e-pošto", "server_external_domain_settings": "Zunanja domena", "server_external_domain_settings_description": "Domena za javne skupne povezave, vključno s http(s)://", @@ -406,17 +406,17 @@ "are_these_the_same_person": "Ali je to ista oseba?", "are_you_sure_to_do_this": "Ste prepričani, da želite to narediti?", "asset_added_to_album": "Dodano v album", - "asset_adding_to_album": "Dodajanje v album ...", + "asset_adding_to_album": "Dodajanje v album…", "asset_description_updated": "Opis sredstva je posodobljen", "asset_filename_is_offline": "Sredstvo {filename} je brez povezave", "asset_has_unassigned_faces": "Sredstvo ima nedodeljene obraze", - "asset_hashing": "Zgoščevanje ...", + "asset_hashing": "Zgoščevanje…", "asset_offline": "Sredstvo brez povezave", "asset_offline_description": "Tega zunanjega sredstva ni več mogoče najti na disku. Za pomoč kontaktirajte Immich skrbnika.", "asset_skipped": "Preskočeno", "asset_skipped_in_trash": "V smetnjak", "asset_uploaded": "Naloženo", - "asset_uploading": "Nalaganje ...", + "asset_uploading": "Nalaganje…", "assets": "Sredstva", "assets_added_count": "Dodano{count, plural, one {# sredstvo} other {# sredstev}}", "assets_added_to_album_count": "Dodano{count, plural, one {# sredstvo} other {# sredstev}} v album", @@ -766,8 +766,10 @@ "go_to_folder": "Pojdi na mapo", "go_to_search": "Pojdi na iskanje", "group_albums_by": "Združi albume po ...", + "group_country": "Združi po državah", "group_no": "Brez združevanja", "group_owner": "Združi po lastniku", + "group_places_by": "Združi kraje po...", "group_year": "Združi po letih", "has_quota": "Ima kvoto", "hi_user": "Živijo {name} ({email})", @@ -800,6 +802,7 @@ "include_shared_albums": "Vključite skupne albume", "include_shared_partner_assets": "Vključite partnerjeva skupna sredstva", "individual_share": "Samostojna delitev", + "individual_shares": "Posamezne delitve", "info": "Info", "interval": { "day_at_onepm": "Vsak dan ob 13h", @@ -822,6 +825,7 @@ "latest_version": "Najnovejša različica", "latitude": "Zemljepisna širina", "leave": "Zapusti", + "lens_model": "Model leč", "let_others_respond": "Naj drugi odgovorijo", "level": "Raven", "library": "Knjižnica", @@ -984,6 +988,7 @@ "pick_a_location": "Izberi lokacijo", "place": "Lokacija", "places": "Lokacije", + "places_count": "{count, plural, one {{count, number} kraj} other {{count, number} krajev}}", "play": "Predvajaj", "play_memories": "Predvajaj spomine", "play_motion_photo": "Predvajaj premikajočo fotografijo", @@ -1107,12 +1112,15 @@ "search": "Iskanje", "search_albums": "Iskanje albumov", "search_by_context": "Iskanje po kontekstu", + "search_by_description": "Iskanje po opisu", + "search_by_description_example": "Pohodniški dan v Sapi", "search_by_filename": "Iskanje po imenu datoteke ali priponi", "search_by_filename_example": "na primer IMG_1234.JPG ali PNG", "search_camera_make": "Iskanje proizvajalca kamere...", "search_camera_model": "Išči model kamere...", "search_city": "Iskanje mesta...", "search_country": "Iskanje države...", + "search_for": "Poišči za", "search_for_existing_person": "Iskanje obstoječe osebe", "search_no_people": "Brez oseb", "search_no_people_named": "Ni oseb z imenom \"{name}\"", @@ -1165,6 +1173,7 @@ "shared_from_partner": "Fotografije od {partner}", "shared_link_options": "Možnosti skupne povezave", "shared_links": "Povezave v skupni rabi", + "shared_links_description": "Deli fotografije in videoposnetke s povezavo", "shared_photos_and_videos_count": "{assetCount, plural, other {# deljenih fotografij & videoposnetkov.}}", "shared_with_partner": "V skupni rabi s/z {partner}", "sharing": "Skupna raba", @@ -1187,6 +1196,7 @@ "show_person_options": "Prikaži možnosti osebe", "show_progress_bar": "Prikaži vrstico napredka", "show_search_options": "Prikaži možnosti iskanja", + "show_shared_links": "Pokaži povezave v skupni rabi", "show_slideshow_transition": "Prikaži prehod diaprojekcije", "show_supporter_badge": "Značka podpornika", "show_supporter_badge_description": "Prikaži značko podpornika", @@ -1274,6 +1284,7 @@ "unfavorite": "Odznači priljubljeno", "unhide_person": "Prikaži osebo", "unknown": "Neznano", + "unknown_country": "Neznana država", "unknown_year": "Neznano leto", "unlimited": "Neomejeno", "unlink_motion_video": "Prekini povezavo videoposnetka gibanja", diff --git a/i18n/sr_Cyrl.json b/i18n/sr_Cyrl.json index 56662c8d53..6254b53c9a 100644 --- a/i18n/sr_Cyrl.json +++ b/i18n/sr_Cyrl.json @@ -20,7 +20,7 @@ "add_partner": "Додај партнер", "add_path": "Додај путању", "add_photos": "Додај фотографије", - "add_to": "Додај у...", + "add_to": "Додај у…", "add_to_album": "Додај у албум", "add_to_shared_album": "Додај у дељен албум", "add_url": "Додај URL", @@ -219,7 +219,7 @@ "reset_settings_to_default": "Ресетујте подешавања на подразумеване вредности", "reset_settings_to_recent_saved": "Ресетујте подешавања на недавно сачувана подешавања", "scanning_library": "Скенирање библиотеке", - "search_jobs": "Тражи послове...", + "search_jobs": "Тражи послове…", "send_welcome_email": "Пошаљите е-пошту добродошлице", "server_external_domain_settings": "Екстерни домаин", "server_external_domain_settings_description": "Домаин за јавне дељене везе, укључујући http(s)://", @@ -406,17 +406,17 @@ "are_these_the_same_person": "Да ли су ово иста особа?", "are_you_sure_to_do_this": "Јесте ли сигурни да желите ово да урадите?", "asset_added_to_album": "Додато у албум", - "asset_adding_to_album": "Додаје се у албум...", + "asset_adding_to_album": "Додаје се у албум…", "asset_description_updated": "Опис датотеке је ажуриран", "asset_filename_is_offline": "Датотека {filename} је ван мреже (offline)", "asset_has_unassigned_faces": "Датотека има недодељена лица", - "asset_hashing": "Хеширање...", + "asset_hashing": "Хеширање…", "asset_offline": "Датотека одсутна (offline)", "asset_offline_description": "Ова вањска датотека се више не налази на диску. Молимо контактирајте свог Имич администратора за помоћ.", "asset_skipped": "Прескочено", "asset_skipped_in_trash": "У отпад", "asset_uploaded": "Отпремљено (Уплоадед)", - "asset_uploading": "Отпремање...", + "asset_uploading": "Отпремање…", "assets": "Записи", "assets_added_count": "Додато {count, plural, one {# датотека} other {# датотека}}", "assets_added_to_album_count": "Додато је {count, plural, one {# датотека} other {# датотека}} у албум", @@ -766,8 +766,10 @@ "go_to_folder": "Иди у фасциклу", "go_to_search": "Иди на претрагу", "group_albums_by": "Групни албуми по...", + "group_country": "Група по држава", "group_no": "Без груписања", "group_owner": "Групирајте по власнику", + "group_places_by": "Групирајте места по...", "group_year": "Групирајте по години", "has_quota": "Има квоту", "hi_user": "Здраво {name} ({email})", @@ -800,6 +802,7 @@ "include_shared_albums": "Обухвати дељене албуме", "include_shared_partner_assets": "Обухвати заједничке датотеке партнера", "individual_share": "Индивидуални удео", + "individual_shares": "Појединачне акције", "info": "Информација", "interval": { "day_at_onepm": "Сваки дан у 1пм", @@ -822,6 +825,7 @@ "latest_version": "Најновија верзија", "latitude": "Географска ширина", "leave": "Напусти", + "lens_model": "Модел сочива", "let_others_respond": "Дозволи да други коментаришу", "level": "Ниво", "library": "Библиотека", @@ -984,6 +988,7 @@ "pick_a_location": "Одабери локацију", "place": "Место", "places": "Места", + "places_count": "{count, plural, one {{count, number} Место} other {{count, number} Местa}}", "play": "Покрени", "play_memories": "Покрени сећања", "play_motion_photo": "Покрени покретну фотографију", @@ -1107,12 +1112,15 @@ "search": "Претрага", "search_albums": "Претражи албуме", "search_by_context": "Претражујте по контексту", + "search_by_description": "Тражи по опису", + "search_by_description_example": "Дан пешачења у Сапи", "search_by_filename": "Претражите по имену датотеке или екстензији", "search_by_filename_example": "нпр. IMG_1234.JPG или PNG", "search_camera_make": "Претрага произвођача камере...", "search_camera_model": "Претражи модел камере...", "search_city": "Претражи град...", "search_country": "Тражи земљу...", + "search_for": "Тражи", "search_for_existing_person": "Потражите постојећу особу", "search_no_people": "Без особа", "search_no_people_named": "Нема особа са именом „{name}“", @@ -1165,6 +1173,7 @@ "shared_from_partner": "Слике од {partner}", "shared_link_options": "Опције дељене везе", "shared_links": "Дељене везе", + "shared_links_description": "Делите фотографије и видео записе помоћу линка", "shared_photos_and_videos_count": "{assetCount, plural, other {# дељене фотографије и видео записе.}}", "shared_with_partner": "Дели се са {partner}", "sharing": "Дељење", @@ -1187,6 +1196,7 @@ "show_person_options": "Прикажи опције особе", "show_progress_bar": "Прикажи траку напретка", "show_search_options": "Прикажи опције претраге", + "show_shared_links": "Прикажи дељене везе", "show_slideshow_transition": "Прикажи прелаз пројекције слајдова", "show_supporter_badge": "Значка подршке", "show_supporter_badge_description": "Покажите значку подршке", @@ -1274,6 +1284,7 @@ "unfavorite": "Избаци из омиљених (унфаворите)", "unhide_person": "Откриј особу", "unknown": "Непознат", + "unknown_country": "Непозната земља", "unknown_year": "Непозната Година", "unlimited": "Неограничено", "unlink_motion_video": "Прекините везу са видео снимком", diff --git a/i18n/sr_Latn.json b/i18n/sr_Latn.json index b5993cc9a6..6efa67c9a4 100644 --- a/i18n/sr_Latn.json +++ b/i18n/sr_Latn.json @@ -20,7 +20,7 @@ "add_partner": "Dodaj partner", "add_path": "Dodaj putanju", "add_photos": "Dodaj fotografije", - "add_to": "Dodaj u...", + "add_to": "Dodaj u…", "add_to_album": "Dodaj u album", "add_to_shared_album": "Dodaj u deljen album", "add_url": "Dodaj URL", @@ -219,7 +219,7 @@ "reset_settings_to_default": "Resetujte podešavanja na podrazumevane vrednosti", "reset_settings_to_recent_saved": "Resetujte podešavanja na nedavno sačuvana podešavanja", "scanning_library": "Skeniranje biblioteke", - "search_jobs": "Traži poslove...", + "search_jobs": "Traži poslove…", "send_welcome_email": "Pošaljite e-poštu dobrodošlice", "server_external_domain_settings": "Eksterni domain", "server_external_domain_settings_description": "Domain za javne deljene veze, uključujući http(s)://", @@ -406,17 +406,17 @@ "are_these_the_same_person": "Da li su ovo ista osoba?", "are_you_sure_to_do_this": "Jeste li sigurni da želite ovo da uradite?", "asset_added_to_album": "Dodato u album", - "asset_adding_to_album": "Dodaje se u album...", + "asset_adding_to_album": "Dodaje se u album…", "asset_description_updated": "Opis datoteke je ažuriran", "asset_filename_is_offline": "Datoteka {filename} je van mreže (offline)", "asset_has_unassigned_faces": "Datoteka ima nedodeljena lica", - "asset_hashing": "Heširanje...", + "asset_hashing": "Heširanje…", "asset_offline": "Datoteka odsutna", "asset_offline_description": "Ova vanjska datoteka se više ne nalazi na disku. Molimo kontaktirajte svog Immich administratora za pomoć.", "asset_skipped": "Preskočeno", "asset_skipped_in_trash": "U otpad", "asset_uploaded": "Otpremljeno (Uploaded)", - "asset_uploading": "Otpremanje...", + "asset_uploading": "Otpremanje…", "assets": "Zapisi", "assets_added_count": "Dodato {count, plural, one {# datoteka} other {# datoteka}}", "assets_added_to_album_count": "Dodato je {count, plural, one {# datoteka} other {# datoteka}} u album", @@ -766,8 +766,10 @@ "go_to_folder": "Idi u fasciklu", "go_to_search": "Idi na pretragu", "group_albums_by": "Grupni albumi po...", + "group_country": "Grupa po država", "group_no": "Bez grupisanja", "group_owner": "Grupirajte po vlasniku", + "group_places_by": "Grupirajte mesta po...", "group_year": "Grupirajte po godini", "has_quota": "Ima kvotu", "hi_user": "Zdravo {name} ({email})", @@ -800,6 +802,7 @@ "include_shared_albums": "Obuhvati deljene albume", "include_shared_partner_assets": "Obuhvati zajedničke datoteke partnera", "individual_share": "Individualni udeo", + "individual_shares": "Pojedinačne akcije", "info": "Informacija", "interval": { "day_at_onepm": "Svaki dan u 1pm", @@ -822,6 +825,7 @@ "latest_version": "Najnovija verzija", "latitude": "Geografska širina", "leave": "Napusti", + "lens_model": "Model sočiva", "let_others_respond": "Dozvoli da drugi komentarišu", "level": "Nivo", "library": "Biblioteka", @@ -984,6 +988,7 @@ "pick_a_location": "Odaberi lokaciju", "place": "Mesto", "places": "Mesta", + "places_count": "{count, plural, one {{count, number} Mesto} other {{count, number} Mesta}}", "play": "Pokreni", "play_memories": "Pokreni sećanja", "play_motion_photo": "Pokreni pokretnu fotografiju", @@ -1107,12 +1112,15 @@ "search": "Pretraga", "search_albums": "Pretraži albume", "search_by_context": "Pretražujte po kontekstu", + "search_by_description": "Traži po opisu", + "search_by_description_example": "Dan pešačenja u Sapi", "search_by_filename": "Pretražite po imenu datoteke ili ekstenziji", "search_by_filename_example": "npr. IMG_1234.JPG ili PNG", "search_camera_make": "Pretraga proizvođača kamere...", "search_camera_model": "Pretraži model kamere...", "search_city": "Pretraži grad...", "search_country": "Traži zemlju...", + "search_for": "Traži", "search_for_existing_person": "Potražite postojeću osobu", "search_no_people": "Bez osoba", "search_no_people_named": "Nema osoba sa imenom „{name}“", @@ -1165,6 +1173,7 @@ "shared_from_partner": "Slike od {partner}", "shared_link_options": "Opcije deljene veze", "shared_links": "Deljene veze", + "shared_links_description": "Delite fotografije i video zapise pomoću linka", "shared_photos_and_videos_count": "{assetCount, plural, other {# deljene fotografije i video zapise.}}", "shared_with_partner": "Deli se sa {partner}", "sharing": "Deljenje", @@ -1187,6 +1196,7 @@ "show_person_options": "Prikaži opcije osobe", "show_progress_bar": "Prikaži traku napretka", "show_search_options": "Prikaži opcije pretrage", + "show_shared_links": "Prikaži deljene veze", "show_slideshow_transition": "Prikaži prelaz projekcije slajdova", "show_supporter_badge": "Značka podrške", "show_supporter_badge_description": "Pokažite značku podrške", @@ -1274,6 +1284,7 @@ "unfavorite": "Izbaci iz omiljenih (unfavorite)", "unhide_person": "Otkrij osobu", "unknown": "Nepoznat", + "unknown_country": "Nepoznata zemlja", "unknown_year": "Nepoznata Godina", "unlimited": "Neograničeno", "unlink_motion_video": "Odveži video od slike", diff --git a/i18n/sv.json b/i18n/sv.json index 80f2687b7d..8cb756bef3 100644 --- a/i18n/sv.json +++ b/i18n/sv.json @@ -20,7 +20,7 @@ "add_partner": "Lägg till partner", "add_path": "Lägg till sökväg", "add_photos": "Lägg till foton", - "add_to": "Lägg till...", + "add_to": "Lägg till i...…", "add_to_album": "Lägg till i album", "add_to_shared_album": "Lägg till i delat album", "add_url": "Lägg till URL", @@ -59,7 +59,7 @@ "external_library_management": "Hantera externa bibliotek", "face_detection": "Ansiktsdetektering", "face_detection_description": "Identifiera ansikten i foton med hjälp av maskininlärning. För videor används endast miniatyrbilden. \"Alla\" gör om sökningen för alla objekt. \"Saknade\" letar i de objekt som ännu inte sökts igenom. Alla ansikten som identifierats läggs sedan i jobbkön för ansiktsigenkänning där de mappas till nya eller befintliga personer.", - "facial_recognition_job_description": "Gruppera upptäckta ansikten till personer. Det här steget körs efter att ansiktsigenkänning är klar. \"Alla\" (åter-) grupperar alla ansikten. \"Saknade\" köer ansikten som inte har en person tilldelad.", + "facial_recognition_job_description": "Gruppera upptäckta ansikten till personer. Det här steget körs efter att ansiktsdetektering är klar. \"Alla\" (åter-) grupperar alla ansikten. \"Saknade\" köer ansikten som inte har en person tilldelad.", "failed_job_command": "Kommando {command} misslyckades för jobb: {job}", "force_delete_user_warning": "VARNING: Detta tar omedelbart bort användaren och alla mediafiler. Detta kan inte ångras och filerna kan inte återställas.", "forcing_refresh_library_files": "Tvingar uppdatering av alla biblioteksfiler", @@ -219,7 +219,7 @@ "reset_settings_to_default": "Återställ inställningar till standard", "reset_settings_to_recent_saved": "Återställ inställningar till de senaste sparade", "scanning_library": "Skanna bibliotek", - "search_jobs": "Sök Jobb...", + "search_jobs": "Sökjobb…", "send_welcome_email": "Skicka välkomstmail", "server_external_domain_settings": "Extern domän", "server_external_domain_settings_description": "Domän för publikt delade länkar, inklusive http(s)://", @@ -291,8 +291,8 @@ "transcoding_disabled_description": "Omkoda inte videofiler, detta kan störa uppspelning på vissa klienter", "transcoding_encoding_options": "Kodningsval", "transcoding_encoding_options_description": "Välj codec, upplösning, kvalitet och andra val för kodade videor", - "transcoding_hardware_acceleration": "Hardvaruacceleration", - "transcoding_hardware_acceleration_description": "Forskningsmässig; betydligt snabbare men med lägre kvalitet vid samma biträtta", + "transcoding_hardware_acceleration": "Hårdvaruacceleration", + "transcoding_hardware_acceleration_description": "Experimentell; betydligt snabbare men med lägre kvalitet vid samma bittakt", "transcoding_hardware_decoding": "Hårdvaruavkodning", "transcoding_hardware_decoding_setting_description": "Tillämpas enbart på NVENC, QSV och RKMPP. Aktiverar end-to-end accelerering i stället för endast kodningsacceleration. Fungerar inte med alla videor.", "transcoding_hevc_codec": "HEVC-codec", @@ -362,7 +362,7 @@ "advanced": "Avancerat", "age_months": "Ålder {months, plural, one {# month} other {# months}}", "age_year_months": "Ålder 1 år, {months, plural, one {# month} other {# months}}", - "age_years": "{years, plural, other {Age #}}", + "age_years": "{years, plural, other {Ålder #}}", "album_added": "Albumet har lagts till", "album_added_notification_setting_description": "Få ett e-postmeddelande när du läggs till i ett delat album", "album_cover_updated": "Albumomslaget uppdaterat", @@ -382,7 +382,7 @@ "album_user_removed": "Tog bort {user}", "album_with_link_access": "Låt alla med länken se foton och personer i det här albumet.", "albums": "Album", - "albums_count": "{count, plural, one {{count, number} Album} other {{count, number} Albums}}", + "albums_count": "{count, plural, one {{count, number} Album} other {{count, number} Album}}", "all": "Allt", "all_albums": "Alla album", "all_people": "Alla personer", @@ -402,26 +402,26 @@ "archive_or_unarchive_photo": "Arkivera eller oarkivera fotot", "archive_size": "Arkivstorlek", "archive_size_description": "Konfigurera arkivstorleken för nedladdningar (i GiB)", - "archived_count": "{count, plural, other {Archived #}}", + "archived_count": "{count, plural, other {Arkiverade #}}", "are_these_the_same_person": "Är det samma person?", "are_you_sure_to_do_this": "Är du säker på att du vill göra det här?", "asset_added_to_album": "Lades till i album", - "asset_adding_to_album": "Lägger till i album...", + "asset_adding_to_album": "Lägger till i album...…", "asset_description_updated": "Tillgångens beskrivning har uppdaterats", "asset_filename_is_offline": "Tillgången {filename} är offline", "asset_has_unassigned_faces": "Tillgången har otilldelade ansikten", - "asset_hashing": "Hashing...", + "asset_hashing": "Hashing...…", "asset_offline": "Tillgång offline", "asset_offline_description": "Denna externa tillgång finns inte längre på disken. Kontakta din Immich-administratör för hjälp.", "asset_skipped": "Överhoppad", "asset_skipped_in_trash": "I papperskorgen", "asset_uploaded": "Uppladdad", - "asset_uploading": "Laddar upp...", + "asset_uploading": "Laddar upp...…", "assets": "Objekt", "assets_added_count": "La till {count, plural, one {# asset} other {# assets}}", "assets_added_to_album_count": "Lade till {count, plural, one {# asset} other {# assets}} i albumet", "assets_added_to_name_count": "Lade till {count, plural, one {# asset} other {# assets}} till {hasName, select, true {{name}} other {new album}}", - "assets_count": "{count, plural, one {# asset} other {# assets}}", + "assets_count": "{count, plural, one {# objekt} other {# objekt}}", "assets_moved_to_trash_count": "Flyttade {count, plural, one {# asset} other {# assets}} till papperskorgen", "assets_permanently_deleted_count": "Raderad permanent {count, plural, one {# asset} other {# assets}}", "assets_removed_count": "Tog bort {count, plural, one {# asset} other {# assets}}", @@ -526,6 +526,7 @@ "deduplication_criteria_1": "Bildstorlek i bytes", "deduplication_criteria_2": "Räkning av EXIF-data", "deduplication_info": "Dedupliceringsinformation", + "deduplication_info_description": "För att automatiskt välja filer och ta bort dubletter i bulk analyserar vi:", "default_locale": "Standardplats", "default_locale_description": "Formatera datum och siffror baserat på din webbläsares språkversion", "delete": "Radera", @@ -765,8 +766,10 @@ "go_to_folder": "Gå till mapp", "go_to_search": "Gå till sök", "group_albums_by": "Gruppera album efter...", + "group_country": "Gruppera per land", "group_no": "Ingen gruppering", "group_owner": "Grupper efter ägare", + "group_places_by": "Gruppera platser efter…", "group_year": "Gruppera efter årtal", "has_quota": "Har kvot", "hi_user": "Hej {name} ({email})", @@ -799,6 +802,7 @@ "include_shared_albums": "Inkludera delade album", "include_shared_partner_assets": "Inkludera delade partners tillgångar", "individual_share": "Enskild delning", + "individual_shares": "Individuella delningar", "info": "Information", "interval": { "day_at_onepm": "Alla dagar vid kl 13.00", @@ -821,6 +825,7 @@ "latest_version": "Senaste versionen", "latitude": "Latitud", "leave": "Lämna", + "lens_model": "Objektiv", "let_others_respond": "Låt andra svara", "level": "Nivå", "library": "Bibliotek", @@ -970,8 +975,12 @@ "permanent_deletion_warning_setting_description": "Visa en varning när tillgångar raderas permanent", "permanently_delete": "Radera permanent", "permanently_delete_assets_count": "Radera {count, plural, one {asset} other {assets}} permanent", + "permanently_delete_assets_prompt": "Är du säker på att du permanent vill ta bort {count, plural, one {denna fil?} other{these # filer?}} Detta kommer också ta bort {count, plural, one {dem från } other{them from their}} album.", "permanently_deleted_asset": "Permanent raderad tillgång", + "permanently_deleted_assets_count": "Permanent borttagning av {count, plural, one {# asset} other {# assets}}", "person": "Person", + "person_hidden": "{name}{hidden, select, true { (dold)} other {}}", + "photo_shared_all_users": "Du har antingen delat dina foton med alla användare eller så har du inga användare att dela dem med.", "photos": "Foton", "photos_and_videos": "Foton & videor", "photos_count": "{count, plural, one {{count, number} Foto} other {{count, number} Foton}}", @@ -979,6 +988,7 @@ "pick_a_location": "Välj en plats", "place": "Plats", "places": "Platser", + "places_count": "{count, plural, one {{count, number} Plats} other {{count, number} Platser}}", "play": "Spela upp", "play_memories": "Spela upp minnen", "play_motion_photo": "Spela upp rörligt foto", @@ -990,10 +1000,11 @@ "previous_memory": "Föregående minne", "previous_or_next_photo": "Föregående eller nästa foto", "primary": "Primär", + "privacy": "Sekretess", "profile_image_of_user": "{user} profilbild", "profile_picture_set": "Profilbild vald.", "public_album": "Publikt album", - "public_share": "", + "public_share": "Offentlig delning", "purchase_account_info": "Supporter", "purchase_activated_subtitle": "Tack för att du stödjer Immich och open source-mjukvara", "purchase_activated_time": "Aktiverad {date, date}", @@ -1016,129 +1027,232 @@ "purchase_panel_info_1": "Att bygga Immich kräver mycket tid och engagemang och våra tekniker jobbar heltid för att göra det så bra som vi möjligt kan. Vårt mål är att open source-mjukvara och etiska affärsmetoder ska bli en hållbar inkomstkälla för utvecklare och att skapa ett ekosystem som repekterar personlig integritet med verkliga alternativ till exploaterande molntjänster.", "purchase_panel_info_2": "Då vi åtagit oss att inte ha betalväggar kommer detta köp inte att ge dig några utökade funktioner i Immich. Vi sätter vår tillit till användare som du som stödjer Immichs fortsatta utveckling.", "purchase_panel_title": "Stöd projektet", + "purchase_per_server": "Per server", + "purchase_per_user": "Per användare", "purchase_remove_product_key": "Ta bort produktnyckel", "purchase_remove_product_key_prompt": "Vill du verkligen ta bort produktnyckeln?", + "purchase_remove_server_product_key": "Ta bort serverns produktnyckel", + "purchase_remove_server_product_key_prompt": "Är du säker på att du vill ta bort serverns produktnyckel?", + "purchase_server_description_1": "För hela servern", "purchase_server_description_2": "Supporterstatus", "purchase_server_title": "Server", "purchase_settings_server_activated": "Produktnyckeln för servern hanteras av administratören", - "reaction_options": "", + "rating": "Antal stjärnor", + "rating_clear": "Ta bort betyg", + "rating_count": "{count, plural, one {# stjärna} other {# stjärnor}}", + "rating_description": "Visa EXIF betyget i informationspanelen", + "reaction_options": "Hovringstext för knappen som visar åtgärder man kan utföra på en reaktion.", "read_changelog": "Läs ändringslogg", - "recent": "", - "recent_searches": "", + "reassign": "Omfördela", + "reassigned_assets_to_existing_person": "Tilldelade om {count, plural, one {# objekt} other {# objekt}} till {name, select, null {an existing person} other {{name}}}", + "reassigned_assets_to_new_person": "Tilldelade om {count, plural, one {# objekt} other {# objekt}} till en ny persson", + "reassing_hint": "Tilldela valda tillgångar till en befintlig person", + "recent": "Nyligen", + "recent-albums": "Senaste album", + "recent_searches": "Senaste sökningar", "refresh": "Ladda om", "refresh_encoded_videos": "Ladda om kodade videor", "refresh_faces": "Ladda om ansikten", "refresh_metadata": "Ladda om metadata", + "refresh_thumbnails": "Uppdatera miniatyrer", "refreshed": "Omladdad", "refreshes_every_file": "Läser in alla existerande och nya filer på nytt", "refreshing_encoded_video": "Återladdar kodad video", "refreshing_faces": "Återladdar ansikten", "refreshing_metadata": "Återladdar metadata", + "regenerating_thumbnails": "Uppdaterar miniatyrer", "remove": "Ta bort", + "remove_assets_album_confirmation": "Är du säker på att du vill ta bort {count, plural, one {# asset} other {# assets}} från albumet?", + "remove_assets_shared_link_confirmation": "Är du säker på att du vill ta bort {count, plural, one {# asset} other {# assets}} från denna delade länk?", "remove_assets_title": "Ta bort filer?", - "remove_deleted_assets": "", + "remove_custom_date_range": "Ta bort anpassat datumintervall", + "remove_deleted_assets": "Ta bort borttagna tillgångar", "remove_from_album": "Ta bort från album", - "remove_from_favorites": "", - "remove_from_shared_link": "", - "repair": "", - "repair_no_results_message": "", - "replace_with_upload": "", - "require_password": "", - "reset": "", - "reset_password": "", - "reset_people_visibility": "", + "remove_from_favorites": "Ta bort från favoriter", + "remove_from_shared_link": "Ta bort från delad länk", + "remove_url": "Ta bort URL", + "remove_user": "Ta bort användare", + "removed_api_key": "Tog bort API nyckel: {name}", + "removed_from_archive": "Borttagen från arkivet", + "removed_from_favorites": "Borttagen från favoriter", + "removed_from_favorites_count": "{count, plural, other {Tog bort #}} från favoriter", + "removed_tagged_assets": "Tog bort tagg från {count, plural, one {# objekt} other {# objekt}}", + "rename": "Döp om", + "repair": "Reparera", + "repair_no_results_message": "Ospårade och saknade filer kommer att dyka upp här", + "replace_with_upload": "Ersätt med uppladdning", + "repository": "Förvar", + "require_password": "Kräver lösenord", + "require_user_to_change_password_on_first_login": "Kräv att användaren ändrar lösenord vid första inloggning", + "reset": "Återställ", + "reset_password": "Nollställ lösenord", + "reset_people_visibility": "Återställ personers synlighet", + "reset_to_default": "Återställ till standard", + "resolve_duplicates": "Lös dubletter", + "resolved_all_duplicates": "Lös alla dubletter", "restore": "Återställ", - "restore_user": "", - "retry_upload": "", + "restore_all": "Återställ alla", + "restore_user": "Återställ användare", + "restored_asset": "Återställ tillgång", + "resume": "Återuppta", + "retry_upload": "Ladda upp igen", "review_duplicates": "Granska dubbletter", - "role": "", + "role": "Roll", + "role_editor": "Redigerare", + "role_viewer": "Visare", "save": "Spara", - "saved_profile": "", - "saved_settings": "", + "saved_api_key": "Sparad API-nyckel", + "saved_profile": "Sparade profil", + "saved_settings": "Sparade inställningar", "say_something": "Säg något", "scan_all_libraries": "Skanna alla bibliotek", - "scan_settings": "", + "scan_library": "Skanna", + "scan_settings": "Skanningsinställningar", + "scanning_for_album": "Söker efter album...", "search": "Sök", - "search_albums": "", + "search_albums": "Sök album", "search_by_context": "Sök efter sammanhang", - "search_camera_make": "", - "search_camera_model": "", - "search_city": "", - "search_country": "", - "search_for_existing_person": "", - "search_people": "", - "search_places": "", - "search_state": "", - "search_timezone": "", + "search_by_description": "Sök via beskrivning", + "search_by_description_example": "Vandringsdag i Sapa", + "search_by_filename": "Sök efter filnamn eller filändelse", + "search_by_filename_example": "t.ex. IMG_1234.JPG eller PNG", + "search_camera_make": "Sök efter kameratillverkare...", + "search_camera_model": "Sök efter kameramodell...", + "search_city": "Sök efter stad...", + "search_country": "Sök efter land...", + "search_for": "Sök efter", + "search_for_existing_person": "Sök efter befintlig person", + "search_no_people": "Inga personer", + "search_no_people_named": "Inga personer med namnet \"{name}\"", + "search_options": "Sökinställningar", + "search_people": "Sök personer", + "search_places": "Sök platser", + "search_settings": "Sök inställningar", + "search_state": "Sök stat...", + "search_tags": "Sök taggar...", + "search_timezone": "Sök tidszon...", "search_type": "Söktyp", "search_your_photos": "Sök bland dina foton", - "searching_locales": "", - "second": "", + "searching_locales": "Söker efter språk...", + "second": "Sekund", "see_all_people": "Se alla personer", - "select_album_cover": "", - "select_all": "", - "select_avatar_color": "", - "select_face": "", - "select_featured_photo": "", - "select_library_owner": "", - "select_new_face": "", + "select_album_cover": "Välj albumomslag", + "select_all": "Välj alla", + "select_all_duplicates": "Välj alla dubletter", + "select_avatar_color": "Välj färg för avatar", + "select_face": "Välj person", + "select_featured_photo": "Välj utvald bild", + "select_from_computer": "Välj från datorn", + "select_keep_all": "Spara alla", + "select_library_owner": "Välj biblioteksägare", + "select_new_face": "Välj nytt ansikte", "select_photos": "Välj foton", - "selected": "", - "send_message": "", + "select_trash_all": "Släng alla", + "selected": "Valda", + "selected_count": "{count, plural, other {# selected}}", + "send_message": "Skicka meddelande", + "send_welcome_email": "Skicka välkomstmejl", + "server_offline": "Servern offline", + "server_online": "Server online", "server_stats": "Serverstatistik", - "set": "", - "set_as_album_cover": "", + "server_version": "Serverversion", + "set": "Välj", + "set_as_album_cover": "Ange som albumomslag", + "set_as_featured_photo": "Ställ in som utvalt foto", "set_as_profile_picture": "Ange som profilbild", - "set_date_of_birth": "", - "set_profile_picture": "", - "set_slideshow_to_fullscreen": "", + "set_date_of_birth": "Ange födelsedatum", + "set_profile_picture": "Ange som profilbild", + "set_slideshow_to_fullscreen": "Ställ in bildspel på helskärm", "settings": "Inställningar", - "settings_saved": "", + "settings_saved": "Inställningar sparade", "share": "Dela", "shared": "Delad", - "shared_by": "", - "shared_by_you": "", + "shared_by": "Delad av", + "shared_by_user": "Delad av {user}", + "shared_by_you": "Delad av dig", + "shared_from_partner": "Foton från {partner}", + "shared_link_options": "Alternativ för delad länk", "shared_links": "Delade Länkar", + "shared_links_description": "Dela foton och videor med en länk", + "shared_photos_and_videos_count": "{assetCount, plural, other {# delade foton och videor.}}", + "shared_with_partner": "Delad med {partner}", "sharing": "Delning", - "sharing_sidebar_description": "", - "show_album_options": "", - "show_file_location": "", - "show_gallery": "", - "show_hidden_people": "", - "show_in_timeline": "", - "show_in_timeline_setting_description": "", - "show_keyboard_shortcuts": "", + "sharing_enter_password": "Ange lösenord för att visa denna sidan.", + "sharing_sidebar_description": "Visa en länk till Delning i sidopanelen", + "shift_to_permanent_delete": "tryck på ⇧ för att permanent radera tillgången", + "show_album_options": "Visa albumalternativ", + "show_albums": "Visa album", + "show_all_people": "Visa alla personer", + "show_and_hide_people": "Visa & göm personer", + "show_file_location": "Visa sökväg", + "show_gallery": "Visa galleri", + "show_hidden_people": "Visa gömda personer", + "show_in_timeline": "Visa på tidslinje", + "show_in_timeline_setting_description": "Visa foton och videor från denna användaren på din tidslinje", + "show_keyboard_shortcuts": "Visa kortkommandon", "show_metadata": "Visa metadata", - "show_or_hide_info": "", - "show_password": "", - "show_person_options": "", - "show_progress_bar": "", - "show_search_options": "", - "shuffle": "", + "show_or_hide_info": "Visa eller göm information", + "show_password": "Visa lösenord", + "show_person_options": "Visa alternativ för person", + "show_progress_bar": "Visa förloppsindikator", + "show_search_options": "Visa sökalternativ", + "show_shared_links": "Visa delade länkar", + "show_slideshow_transition": "Visa bildspelsövergång", + "show_supporter_badge": "Supporteremblem", + "show_supporter_badge_description": "Visa supporteremblem", + "shuffle": "Blanda", + "sidebar": "Sidopanel", + "sidebar_display_description": "Visa en länk till vyn i sidofältet", "sign_out": "Logga ut", - "sign_up": "", - "size": "", - "skip_to_content": "", - "slideshow": "", - "slideshow_settings": "", - "sort_albums_by": "", + "sign_up": "Registrera dig", + "size": "Storlek", + "skip_to_content": "Hoppa till innehåll", + "skip_to_folders": "Hoppa till mapp", + "skip_to_tags": "Hoppa till taggar", + "slideshow": "Bildspel", + "slideshow_settings": "Bildspelsinställningar", + "sort_albums_by": "Sortera album efter...", + "sort_created": "Skapat datum", + "sort_items": "Antal artiklar", + "sort_modified": "Datum ändrat", + "sort_oldest": "Äldsta foto", + "sort_people_by_similarity": "Sortera människor efter likhet", + "sort_recent": "Senaste fotot", + "sort_title": "Rubrik", + "source": "Källa", "stack": "Stapel", - "stack_selected_photos": "", - "stacktrace": "", + "stack_duplicates": "Stapla dubletter", + "stack_select_one_photo": "Välj ett huvudfoto för stapeln", + "stack_selected_photos": "Stapla valda foton", + "stacked_assets_count": "Staplade {count, plural, one {# asset} other {# assets}}", + "stacktrace": "Stapelspårning", "start": "Starta", "start_date": "Startdatum", "state": "Stat", "status": "Status", - "stop_motion_photo": "", + "stop_motion_photo": "Stanna rörligt foto", "stop_photo_sharing": "Sluta dela dina foton?", + "stop_photo_sharing_description": "{partner} kommer inte länga ha tillgång till dina foton.", + "stop_sharing_photos_with_user": "Sluta dela dina bilder med denna användaren", "storage": "Lagring", - "storage_label": "", + "storage_label": "Förvaringsetikett", "storage_usage": "{used} av {available} används", - "submit": "", + "submit": "Skicka", "suggestions": "Förslag", "sunrise_on_the_beach": "Soluppgång på stranden", - "swap_merge_direction": "", + "support": "Support", + "support_and_feedback": "Support & Feedback", + "support_third_party_description": "Din Immich-installation paketerades av en tredje part. Problem som du upplever kan orsakas av det paketet, så vänligen ta upp problem med dem i första hand med hjälp av länkarna nedan.", + "swap_merge_direction": "Byt sammanfogningsriktning", "sync": "Synka", + "tag": "Tagg", + "tag_assets": "Tagga tillgångar", + "tag_created": "Skapade tagg: {tag}", + "tag_feature_description": "Bläddra bland foton och videor grupperade efter logiska taggar", + "tag_not_found_question": "Kan du inte hitta en tagg? Skapa en ny tagg.", + "tag_updated": "Uppdaterade tagg: {tag}", + "tagged_assets": "Taggade {count, plural, one {# objekt} other {# objekt}}", + "tags": "Taggar", "template": "Mall", "theme": "Tema", "theme_selection": "Val av tema", @@ -1146,44 +1260,67 @@ "they_will_be_merged_together": "De kommer att slås samman", "third_party_resources": "Tredjepartsresurser", "time_based_memories": "Tidsbaserade minnen", + "timeline": "Tidslinje", "timezone": "Tidszon", "to_archive": "Arkivera", "to_change_password": "Ändra lösenord", "to_favorite": "Favorit", "to_login": "Logga in", + "to_parent": "Gå till förälder", "to_trash": "Papperskorg", - "toggle_settings": "", + "toggle_settings": "Växla inställningar", "toggle_theme": "Växla tema", + "total": "Total", "total_usage": "Total användning", "trash": "Papperskorg", "trash_all": "Kasta alla", + "trash_count": "Papperskorg {count, number}", + "trash_delete_asset": "Papperskorgen/Ta bort tillgång", "trash_no_results_message": "Borttagna foton och videor kommer att visas här.", "trashed_items_will_be_permanently_deleted_after": "Objekt i papperskorgen raderas permanent efter {days, plural, one {# dag} other {# dagar}}.", "type": "Typ", "unarchive": "Ångra arkivering", + "unarchived_count": "{count, plural, other {Unarchived #}}", "unfavorite": "Avfavorisera", - "unhide_person": "", + "unhide_person": "Visa person", "unknown": "Okänd", + "unknown_country": "Okänt Land", "unknown_year": "Okänt år", "unlimited": "Obegränsat", + "unlink_motion_video": "Ta bort länken till rörlig video", "unlink_oauth": "Ta bort länken till OAuth", - "unlinked_oauth_account": "", + "unlinked_oauth_account": "Olänkat OAuth-konto", + "unnamed_album": "Namnlöst Album", + "unnamed_album_delete_confirmation": "Är du säker på att du vill ta bort detta album?", + "unnamed_share": "Namnlös delning", "unsaved_change": "Osparade ändringar", - "unselect_all": "", + "unselect_all": "Avmarkera alla", + "unselect_all_duplicates": "Avmarkera alla dubletter", "unstack": "Stapla Av", + "unstacked_assets_count": "Avstaplade {count, plural, one {# asset} other {# assets}}", + "untracked_files": "Ospårade filer", + "untracked_files_decription": "Dessa filer spåras inte av applikationen. Det kan bero på misslyckad flytt, avbruten uppladdning eller att de lämnats kvar på grund av en bugg", "up_next": "Nästa", "updated_password": "Lösenordet har uppdaterats", "upload": "Ladda upp", - "upload_concurrency": "", + "upload_concurrency": "Uppladdning samtidighet", + "upload_errors": "Uppladdning klar med {count, plural, one {# fel} other {# fel}}, ladda om sidan för att se nya objekt.", + "upload_progress": "Återstående {remaining, number} - Bearbetade {processed, number}/{total, number}", + "upload_skipped_duplicates": "Hoppade över {count, plural, one {# dublett} other {# dubletter}}", "upload_status_duplicates": "Dubbletter", "upload_status_errors": "Fel", + "upload_status_uploaded": "Uppladdad", + "upload_success": "Uppladdning lyckades, ladda om sidan för att se nya objekt.", "url": "URL", "usage": "Användning", + "use_custom_date_range": "Använd anpassat datumintervall istället", "user": "Användare", "user_id": "Användar-ID", + "user_liked": "{user} gillade {type, select, photo {detta fotot} video {denna filmen} asset {detta objekt} other {detta}}", "user_purchase_settings": "Köp", "user_purchase_settings_description": "Hantera dina köp", - "user_usage_detail": "", + "user_role_set": "Sätt {user} som {role}", + "user_usage_detail": "Användaranvändningsdetaljer", "user_usage_stats": "Kontoinformation - statistik", "user_usage_stats_description": "Se statistik - kontoanvändande", "username": "Användarnamn", @@ -1193,8 +1330,12 @@ "variables": "Variabler", "version": "Version", "version_announcement_closing": "Din vän, Alex", + "version_announcement_message": "Hej där! En ny version av Immich är tillgänglig. Ta dig tid att läsa versionsfakta för att säkerställa att dina inställningar är uppdaterade för att förhindra eventuella felkonfigurationer, särskilt om du använder WatchTower eller någon mekanism som hanterar uppdatering av din Immich instans automatiskt.", + "version_history": "Versionshistorik", + "version_history_item": "Version {version} installerad {date}", "video": "Video", - "video_hover_setting_description": "", + "video_hover_setting": "Spela upp videotumnagel när muspekaren är över den", + "video_hover_setting_description": "Spela upp videotumnagel när muspekaren är över den. Även när den är deaktiverad kan uppspelning startas när muspekaren är över play-ikonen.", "videos": "Videor", "videos_count": "{count, plural, one {# Video} other {# Videor}}", "view": "Visa", @@ -1203,8 +1344,11 @@ "view_all_users": "Visa alla användare", "view_in_timeline": "Visa i tidslinjen", "view_links": "Visa länkar", + "view_name": "Visa", "view_next_asset": "Visa nästa objekt", "view_previous_asset": "Visa föregående objekt", + "view_stack": "Visa Stapel", + "visibility_changed": "Synlighet ändrad för {count, plural, one {# person} other {# personer}}", "waiting": "Väntar", "warning": "Varning", "week": "Vecka", diff --git a/i18n/te.json b/i18n/te.json index 3f0f6ff546..5fc4300bb7 100644 --- a/i18n/te.json +++ b/i18n/te.json @@ -23,6 +23,7 @@ "add_to": "జోడించండి...", "add_to_album": "ఆల్బమ్‌కు జోడించండి", "add_to_shared_album": "భాగస్వామ్య ఆల్బమ్‌కు జోడించండి", + "add_url": "URLని జోడించండి", "added_to_archive": "ఆర్కైవ్‌కి జోడించబడింది", "added_to_favorites": "ఇష్టమైన వాటికి జోడించబడింది", "added_to_favorites_count": "ఇష్టమైన వాటికి {count, number} జోడించబడింది", @@ -213,6 +214,7 @@ "notifications": "నోటిఫికేషన్‌లు", "notifications_setting_description": "నోటిఫికేషన్‌లను నిర్వహించండి", "oauth": "OAuth", + "search_by_description": "వివరణ ద్వారా శోధించండి", "unsaved_change": "సేవ్ చేయని మార్పు", "unselect_all": "ఎంచుకున్నవన్నీ తొలగించు", "unselect_all_duplicates": "అన్ని నకిలీల ఎంపికను తీసివేయండి", diff --git a/i18n/th.json b/i18n/th.json index 4657ac2685..3e0523c886 100644 --- a/i18n/th.json +++ b/i18n/th.json @@ -3,33 +3,33 @@ "account": "บัญชีผู้ใช้", "account_settings": "การตั้งค่าบัญชี", "acknowledge": "รับทราบ", - "action": "การดำเนินการ", + "action": "ดำเนินการ", "actions": "การดำเนินการ", "active": "ใช้งานอยู่", "activity": "กิจกรรม", "activity_changed": "กิจกรรม{enabled, select, true {เปิด} other {ปิด}}อยู่", "add": "เพิ่ม", - "add_a_description": "เพื่มรายละเอียด", + "add_a_description": "เพิ่มรายละเอียด", "add_a_location": "เพิ่มตำแหน่ง", "add_a_name": "เพิ่มชื่อ", "add_a_title": "เพิ่มหัวข้อ", "add_exclusion_pattern": "เพิ่มข้อยกเว้น", - "add_import_path": "เพิ่มพาธนำเข้า", + "add_import_path": "เพิ่มเส้นทางนำเข้า", "add_location": "เพิ่มตำแหน่ง", "add_more_users": "เพิ่มผู้ใช้งาน", - "add_partner": "เพิ่มคู่หู", - "add_path": "เพิ่มพาธ", + "add_partner": "เพิ่มพันธมิตร", + "add_path": "เพิ่มพาทที่ตั้ง", "add_photos": "เพิ่มรูปภาพ", - "add_to": "เพิ่มเข้า...", - "add_to_album": "เพิ่มเข้าอัลบั้ม", - "add_to_shared_album": "เพิ่มลงในอัลบั้มที่แชร์กัน", + "add_to": "เพิ่มไปยัง …", + "add_to_album": "เพิ่มไปอัลบั้ม", + "add_to_shared_album": "เพิ่มไปยังอัลบั้มที่แชร์กัน", "add_url": "เพิ่ม URL", - "added_to_archive": "เพิ่มเข้าที่เก็บถาวร", + "added_to_archive": "เพิ่มไปยังที่จัดเก็บถาวร", "added_to_favorites": "เพิ่มเข้ารายการโปรด", "added_to_favorites_count": "{count, number} รูปถูกเพิ่มเข้ารายการโปรด", "admin": { "add_exclusion_pattern_description": "เพิ่มรูปแบบข้อยกเว้น รองรับการใช้ *, ** และ ? หากต้องการละเว้นไฟล์ทั้งหมดในไดเร็กทอรีที่ชื่อว่า \"Raw\" ให้ใช้ \"**/Raw/**\" ถ้าต้องการละเว้นไฟล์ทั้งหมดที่ลงท้ายด้วย \".tif\" ให้ใช้ \"**/*.tif\" ถ้าต้องการละเว้นพาธที่เริ่มจากไดเรกทอรีบนสุดให้ใช้ \"/พาธ/ที่ต้องการ/ละเว้น/**\"", - "asset_offline_description": "Immich", + "asset_offline_description": "ไฟล์ Asset ของไลบรารีภายนอกนี้ไม่พบในดิสก์แล้ว และถูกย้ายไปที่ถังขยะ หากไฟล์ถูกย้ายภายในไลบรารี โปรดตรวจสอบไทม์ไลน์ของคุณเพื่อหาแอสเซ็ตที่เกี่ยวข้องใหม่ หากต้องการกู้คืน Asset นี้ โปรดตรวจสอบให้แน่ใจว่า Immich สามารถเข้าถึงเส้นทางไฟล์ด้านล่างได้ และทำการสแกนไลบรารีอีกครั้ง", "authentication_settings": "การตั้งค่าการเข้าถึง", "authentication_settings_description": "จัดการรหัสผ่าน, OAuth, และตั้งค่าการเข้าถึงอื่นๆ", "authentication_settings_disable_all": "คุณแน่ใจว่าต้องการปิดวิธีการล็อกอินทั้งหมดหรือไม่? ล็อกอินจะถูกปิดทั้งหมด", @@ -38,14 +38,14 @@ "backup_database": "สำรองฐานข้อมูล", "backup_database_enable_description": "เปิดใช้งานการสำรองฐานข้อมูล", "backup_keep_last_amount": "จำนวนข้อมูลสำรองก่อนหน้าที่ต้องเก็บไว้", - "backup_settings": "ตั้งค่ารการสำรองข้อมูล", + "backup_settings": "ตั้งค่าการสำรองข้อมูล", "backup_settings_description": "จัดการการตั้งค่าการสำรองฐานข้อมูล", "check_all": "ตรวจสอบทั้งหมด", "cleared_jobs": "เคลียร์งานสำหรับ: {job}", - "config_set_by_file": "ปัจจุบันการกำหนดค่าถูกตั้งค่าโดยไฟล์กำหนดค่า", + "config_set_by_file": "การตั้งค่าคอนฟิกกำลังถูกกำหนดโดยไฟล์คอนฟิก", "confirm_delete_library": "คุณแน่ใจว่าอยากลบคลังภาพ {library} หรือไม่?", "confirm_delete_library_assets": "คุณแน่ใจว่าอยากลบคลังภาพนี้หรือไม่? สี่อทั้งหมด {count, plural, one {# สื่อ} other {all # สื่อ}} สี่อในคลังจะถูกลบออกจาก Immich โดยถาวร ไฟล์จะยังคงอยู่บนดิสก์", - "confirm_email_below": "เพื่อยืนยัน พิมพ์ \"{email}\" ข้างล่าง", + "confirm_email_below": "โปรดยืนยัน โดยการพิมพ์ \"{email}\" ข้างล่าง", "confirm_reprocess_all_faces": "คุณแน่ใจว่าคุณต้องการประมวลผลใบหน้าทั้งหมดใหม่? ชื่อคนจะถูกลบไปด้วย", "confirm_user_password_reset": "คุณแน่ใจว่าต้องการรีเซ็ตรหัสผ่านของ {user} หรือไม่?", "create_job": "สร้างงาน", @@ -68,7 +68,8 @@ "image_prefer_embedded_preview": "ใช้พรีวิวแบบฝังตัว", "image_prefer_embedded_preview_setting_description": "ใช้พรีวิวฝังตัวในรูปภาพ RAW ในการวิเคราะห์รูปภาพถ้ามี แต่คุณภาพรูปภาพขึ้นอยู่กับกล้อง และอาจจะมีสิ่งตกค้างจากการย่อขนาดไฟล์", "image_prefer_wide_gamut": "ใช้ช่วงสีกว้าง", - "image_prefer_wide_gamut_setting_description": "ใช้การแสดงผลแบบ P3 สําหรับภาพตัวอย่าง คงความเข้มและความกว้างขอบเขตสี แต่ภาพอาจดูแตกต่างกันในอุปกรณ์เก่าที่มีเว็บเบราว์เซอร์รุ่นเก่า ภาพ sRGB จะถูกเก็บในรูปแบบ sRGB เพื่อลดการเคลื่อนของสี", + "image_prefer_wide_gamut_setting_description": "ใช้ Display P3 สำหรับภาพตัวอย่าง (thumbnails) เพื่อรักษาความสดใสของภาพที่มีช่วงสีที่กว้างขึ้น อย่างไรก็ตาม ภาพอาจแสดงผลแตกต่างกันบนอุปกรณ์เก่าที่ใช้เว็บเบราว์เซอร์เวอร์ชันเก่า สำหรับภาพที่อยู่ใน sRGB จะยังคงใช้ sRGB ต่อไปเพื่อหลีกเลี่ยงการเปลี่ยนแปลงของสี", + "image_preview_description": "ภาพขนาดปานกลางที่ถูกลบข้อมูลเมตา ใช้สำหรับการดูแอสเซ็ตเดี่ยวและสำหรับการเรียนรู้ของเครื่อง (Machine Learning)", "image_preview_quality_description": "คุณภาพการแสดงตัวอย่างตั้งแต่ 1-100 ยิ่งสูงยิ่งดี แต่จะทำให้ไฟล์มีขนาดใหญ่ขึ้นและอาจทำให้แอปตอบสนองช้าลง การตั้งค่าต่ำอาจส่งผลต่อคุณภาพ Machine Learning", "image_preview_title": "ตั้งค่าพรีวิว", "image_quality": "คุณภาพ", @@ -76,6 +77,7 @@ "image_resolution_description": "ความละเอียดสูกว่าสามารถเก็บรายละเอียดได้มากกว่าแต่ใช้เวลา encode นานกว่า ไฟล์ใหญ่กว่า และลดความตอบสนองของแอป", "image_settings": "การตั้งค่ารูปภาพ", "image_settings_description": "จัดการคุณภาพและความคมชัดของภาพที่สร้างขึ้น", + "image_thumbnail_description": "รูปขนาดย่อที่มีการลบข้อมูลเมตาด้าต้า ใช้เมื่อดูภาพถ่ายในกลุ่ม เช่น ในไทม์ไลน์หลัก", "image_thumbnail_quality_description": "คุณภาพของภาพขนาดย่อตั้งแต่ 1-100 ยิ่งสูงยิ่งดี แต่จะทำให้ไฟล์มีขนาดใหญ่ขึ้นและอาจทำให้แอปตอบสนองช้าลง", "image_thumbnail_title": "ตั้งค่า Thumbnail", "job_concurrency": "{job} งานพร้อมกัน", @@ -205,6 +207,7 @@ "password_settings": "ล็อกอินผ่านรหัสผ่าน", "password_settings_description": "จัดการการตั้งค่าของการล็อกอินผ่านรหัสผ่าน", "paths_validated_successfully": "เส้นทางทั้งหมดถูกตรวจสอบสำเร็จแล้ว", + "person_cleanup_job": "การทำความสะอาด", "quota_size_gib": "โควตา (GiB)", "refreshing_all_libraries": "รีเฟรชคลังภาพทั้งหมด", "registration": "ลงทะเบียนผู้จัดการ", @@ -221,6 +224,7 @@ "server_external_domain_settings": "โดเมนภายนอก", "server_external_domain_settings_description": "โดเมนสำหรับลิงก์แชร์สาธารณะ แบบมี http(s)://", "server_public_users": "ผู้ใช้สาธารณะ", + "server_public_users_description": "ผู้ใช้ทั้งหมด (ชื่อและอีเมล) จะแสดงรายการเมื่อเพิ่มผู้ใช้ไปยังอัลบั้มที่แชร์ เมื่อปิดใช้งาน รายชื่อผู้ใช้จะพร้อมใช้งานสำหรับผู้ใช้ที่เป็นผู้ดูแลระบบเท่านั้น", "server_settings": "การตั้งค่าเซิร์ฟเวอร์", "server_settings_description": "จัดการการตั้งค่าเซิร์ฟเวอร์", "server_welcome_message": "ข้อความต้อนรับ", @@ -236,12 +240,26 @@ "storage_template_hash_verification_enabled_description": "เปิดใช้งานการตรวจสอบ hash ห้ามปิดใช้งานเว้นแต่คุณจะเข้าใจผลกระทบ", "storage_template_migration": "การย้ายเทมเพลตที่เก็บข้อมูล", "storage_template_migration_description": "ใช้{template}ปัจจุบันกับสื่อที่อัปโหลดก่อนหน้านี้", - "storage_template_migration_job": "", + "storage_template_migration_info": "การเปลี่ยนแปลงเท็มเพลตจะมีผลกับแอสเซ็ตใหม่เท่านั้น หากต้องการนำเทมเพลตไปใช้กับ Asset ที่อัปโหลดก่อนหน้านี้ ให้รัน {job}.", + "storage_template_migration_job": "เทมเพลตการ Migration ข้อมูล", + "storage_template_more_details": "สำหรับรายละเอียดเพิ่มเติมเกี่ยวกับฟีเจอร์นี้ โปรดดูที่ Storage Template และ ผลกระทบ", + "storage_template_onboarding_description": "เมื่อเปิดใช้งาน ฟีเจอร์นี้จะจัดระเบียบไฟล์โดยอัตโนมัติตามเทมเพลตที่ผู้ใช้กำหนด เนื่องจากปัญหาด้านความเสถียร ฟีเจอร์นี้จึงถูกปิดใช้งานเป็นค่าเริ่มต้น สำหรับข้อมูลเพิ่มเติม โปรดดูที่ เอกสารประกอบ", "storage_template_path_length": "ขีดจำกัดของความยาวพาธโดยประมาณ: {length, number}/{limit, number}", "storage_template_settings": "เทมเพลตการจัดเก็บข้อมูล", "storage_template_settings_description": "จัดการโครงสร้างโฟลเดอร์และชื่อไฟล์ที่อัปโหลด", + "storage_template_user_label": "{label} ป้ายกำกับพื้นที่จักเก็บข้อมูล", "system_settings": "การตั้งค่าระบบ", + "tag_cleanup_job": "เคลียร์ Tags", + "template_email_available_tags": "คุณสามารถใช้ตัวแปรต่อไปนี้ในเทมเพลตของคุณได้: {tags}", + "template_email_if_empty": "หากเทมเพลตว่างเปล่า ระบบจะใช้อีเมลเริ่มต้น", + "template_email_invite_album": "เทมเพลตเชิญเข้าอัลบั้ม", "template_email_preview": "ตัวอย่าง", + "template_email_settings": "อีเมลเท็มเพลต", + "template_email_settings_description": "ปรับแต่งรูปแบบการแจ้งเตือนอีเมล", + "template_email_update_album": "อัปเดตเทมเพลตอัลบั้ม", + "template_email_welcome": "เทมเพลตสำหรับอีเมลต้อนรับ", + "template_settings": "เทมเพลตการแจ้งเตือน", + "template_settings_description": "ปรับแต่งเทมเพลตแจ้งเตือน", "theme_custom_css_settings": "CSS กําหนดเอง", "theme_custom_css_settings_description": "Cascading Style Sheets ช่วยให้ปรับแต่งเค้าโครง Immich ได้", "theme_settings": "การตั้งค่าธีม", @@ -250,54 +268,62 @@ "thumbnail_generation_job": "สร้างภาพตัวอย่าง", "thumbnail_generation_job_description": "สร้างภาพตัวอย่างขนาดใหญ่ ขนาดเล็กและแบบเบลอ สําหรับแต่ละสื่อและบุคคล", "transcoding_acceleration_api": "API เร่งความเร็วแปลงสื่อ", - "transcoding_acceleration_api_description": "", + "transcoding_acceleration_api_description": "API ที่จะโต้ตอบกับอุปกรณ์ของคุณเพื่อเร่งการแปลงรหัส การตั้งค่านี้คือ 'ความพยายามที่ดีที่สุด': มันจะย้อนกลับไปยังการการแปลงรหัสของซอฟต์แวร์เมื่อเกิดความล้มเหลว VP9 อาจทำงานหรือไม่ทำงานก็ได้ ขึ้นอยู่กับฮาร์ดแวร์ของคุณ", "transcoding_acceleration_nvenc": "NVENC (ต้องมีการ์ดจอ NVIDIA)", "transcoding_acceleration_qsv": "Quick Sync (ต้องมี Intel CPU รุ่นที่ 7 หรือใหม่กว่า)", "transcoding_acceleration_rkmpp": "RKMPP (สำหรับ Rockchip SOCs เท่านั้น)", "transcoding_acceleration_vaapi": "VAAPI", "transcoding_accepted_audio_codecs": "แบบไฟล์เสียงที่ยอมรับ", "transcoding_accepted_audio_codecs_description": "เลือกแบบไฟล์เสียงที่จะไม่ถูกแปลงใหม่ ใช้สําหรับกฏการแปลงแบบไฟล์", + "transcoding_accepted_containers": "Containers ที่ยอมรับ", + "transcoding_accepted_containers_description": "เลือกรูปแบบคอนเทนเนอร์ที่ไม่จำเป็นต้องรีมิกซ์เป็น MP4 ใช้สำหรับนโยบายการแปลงบางอย่างเท่านั้น", "transcoding_accepted_video_codecs": "แบบไฟล์วิดีโอที่ยอมรับ", "transcoding_accepted_video_codecs_description": "เลือกแบบไฟล์วิดีโอที่จะไม่ถูกแปลงใหม่ ใช้สําหรับกฎการแปลงแบบไฟล์", "transcoding_advanced_options_description": "ตัวเลือกที่ผู้ใช้งานส่วนใหญ่ไม่จำเป็นต้องเปลี่ยน", "transcoding_audio_codec": "แบบไฟล์เสียง", "transcoding_audio_codec_description": "Opus ให้คุณภาพสูงสุด แต่อาจจะเข้ากันไม่ได้กับอุปกรณ์เก่าหรือซอฟต์แวร์เก่า", "transcoding_bitrate_description": "วิดีโอมีค่า bitrate สูงกว่าค่าสูงสุดหรือไฟล์วิดีโอไม่รองรับ", + "transcoding_codecs_learn_more": "หากต้องการเรียนรู้เพิ่มเติมเกี่ยวกับคำศัพท์ที่ใช้ที่นี่ โปรดดูเอกสารประกอบของ FFmpeg สำหรับ H.264 codec, HEVC codec และ VP9 codec", "transcoding_constant_quality_mode": "โหมดคุณภาพคงที่", "transcoding_constant_quality_mode_description": "ICQ ดีกว่า CQP แต่อุปกรณ์บางตัวอาจจะไม่รองรับโหมดนี้ การตั้งค่าตัวนี้จะเลือกโหมดที่ระบุไว้เมื่อใช้การแปลงคุณภาพไฟล์ ไม่สนใจ NVENC เพราะไม่รองรับ ICQ", "transcoding_constant_rate_factor": "ตัวแปรค่าคงที่ (-crf)", "transcoding_constant_rate_factor_description": "คุณภาพของวิดีโอ ค่าโดยปกติคือ 23 สําหรับ H.264, 28 สําหรับ HEVC, 31 สําหรับ VP9 และ 35 สําหรับ AV1 ค่าต่ำกว่าคุณภาพจะดีกว่า แต่ไฟล์จะขนาดใหญ่กว่า", "transcoding_disabled_description": "ไม่แปลงไฟล์วิดีโอเลย อาจเล่นวิดีโอในเครื่องเล่นบางตัวไม่ได้", + "transcoding_encoding_options": "ตัวเลือกการเข้ารหัส", + "transcoding_encoding_options_description": "ตั้งค่า codecs, ความละเอียด, คุณภาพ (และตัวเลือกอื่นๆ สำหรับวิดีโอที่เข้ารหัส (encoded videos)", "transcoding_hardware_acceleration": "การเร่งความเร็วด้วยฮาร์ดแวร์", "transcoding_hardware_acceleration_description": "การทดลอง เร็วกว่ามาก แต่จะมีคุณภาพต่ำกว่าที่บิตเรตเท่ากัน", "transcoding_hardware_decoding": "การถอดรหัสด้วยฮาร์ดแวร์", - "transcoding_hardware_decoding_setting_description": "", + "transcoding_hardware_decoding_setting_description": "เปิดใช้งานการเร่งความเร็วแบบทั้งหมด แทนการเร่งความเร็วการเข้ารหัสเพียงอย่างเดียว อาจใช้ไม่ได้กับวิดีโอทั้งหมด", "transcoding_hevc_codec": "แบบไฟล์ HEVC", - "transcoding_max_b_frames": "", - "transcoding_max_b_frames_description": "", + "transcoding_max_b_frames": "B-frames สูงสุด", + "transcoding_max_b_frames_description": "ค่าที่สูงขึ้นจะช่วยเพิ่มประสิทธิภาพในการบีบอัด แต่จะทำให้การเข้ารหัสช้าลง อาจไม่สามารถใช้งานร่วมกับการเร่งความเร็วฮาร์ดแวร์บนอุปกรณ์เก่าได้ ค่าที่เป็น 0 จะปิดการใช้งาน B-frame ในขณะที่ค่า -1 จะตั้งค่าค่านี้โดยอัตโนมัติ", "transcoding_max_bitrate": "bitrate สูงสุด", "transcoding_max_bitrate_description": "การตั้งค่า bitrate สูงสุดจะสามารถคาดเดาขนาดไฟล์ได้มากขึ้นโดยไม่กระทบคุณภาพ สำหรับความคมชัด 720p ค่าทั่วไปคือ 2600k สําหรับ VP9 หรือ HEVC, 4500k สําหรับ H.264 ปิดการตั้งค่าเมี่อตั้งค่าเป็น 0", - "transcoding_max_keyframe_interval": "", - "transcoding_max_keyframe_interval_description": "", + "transcoding_max_keyframe_interval": "ช่วงเวลาสูงสุดระหว่างกราฟฟ์เคลื่อนไหว", + "transcoding_max_keyframe_interval_description": "ตั้งค่าระยะห่างสูงสุดระหว่างคีย์เฟรม (keyframes) ค่าที่ต่ำลงจะทำให้ประสิทธิภาพการบีบอัดแย่ลง แต่จะช่วยปรับปรุงเวลาในการค้นหาภาพ (seek times) และอาจช่วยปรับปรุงคุณภาพในฉากที่มีการเคลื่อนไหวเร็ว ค่า 0 จะตั้งค่านี้โดยอัตโนมัติ", "transcoding_optimal_description": "วีดิโอมีความคมชัดสูงกว่าเป้าหมายหรืออยู่ในรูปแบบที่รับไม่ได้", - "transcoding_preferred_hardware_device": "", - "transcoding_preferred_hardware_device_description": "", + "transcoding_policy": "นโยบายการเข้ารหัส", + "transcoding_policy_description": "ตั้งค่าเวลาที่วิดีโอจะถูกแปลงรหัส", + "transcoding_preferred_hardware_device": "อุปกรณ์ฮาร์ดแวร์ที่ต้องการ", + "transcoding_preferred_hardware_device_description": "ใช้ได้กับ VAAPI และ QSV เท่านั้น ตั้งค่าโหนด dri ที่ใช้สำหรับทรานส์โค้ดฮาร์ดแวร์", "transcoding_preset_preset": "พรีเซ็ต (-preset)", - "transcoding_preset_preset_description": "", - "transcoding_reference_frames": "", - "transcoding_reference_frames_description": "", - "transcoding_required_description": "", + "transcoding_preset_preset_description": "ความเร็วในการบีบอัด พรีเซ็ตที่ช้ากว่าจะสร้างไฟล์ที่มีขนาดเล็กลงและเพิ่มคุณภาพเมื่อกำหนดเป้าหมายที่อัตราบิตเรตที่กำหนด VP9 จะไม่สนใจความเร็วที่สูงกว่า 'เร็วกว่า'", + "transcoding_reference_frames": "frames อ้างอิง", + "transcoding_reference_frames_description": "จำนวนเฟรมที่จะอ้างอิงเมื่อบีบอัดเฟรมที่กำหนด ค่าที่สูงขึ้นจะช่วยเพิ่มประสิทธิภาพในการบีบอัด แต่จะทำให้การเข้ารหัสช้าลง ค่า 0 จะตั้งค่านี้โดยอัตโนมัติ", + "transcoding_required_description": "เฉพาะวิดีโอที่ไม่อยู่ในรูปแบบที่ยอมรับเท่านั้น", "transcoding_settings": "การตั้งค่าการแปลงไฟล์วิดีโอ", "transcoding_settings_description": "จัดการข้อมูลความคมชัดและแบบไฟล์วิดีโอ", "transcoding_target_resolution": "เป้าหมายความคมชัด", "transcoding_target_resolution_description": "ความคมชัดที่สูงกว่าจะเก็บรายละเอียดดีกว่าแต่ใช้เวลาแปลงไฟล์นานกว่า ขนาดไฟล์ใหญ่กว่า และลดการตอบสนองของแอป", - "transcoding_temporal_aq": "", + "transcoding_temporal_aq": "AQ ชั่วคราว", "transcoding_temporal_aq_description": "เฉพาะ NVENC เท่านั้น เพิ่มคุณภาพของฉากที่มีรายละเอียดสูงและการเคลื่อนไหวต่ำ อาจไม่รองรับอุปกรณ์ที่เก่ากว่า", "transcoding_threads": "เธรด", "transcoding_threads_description": "ค่ายิ่งเยอะจะแปลงไฟล์เร็วกว่า แต่จะเหลือพื้นที่ให้เซิร์ฟเวอร์ประมวลผลงานอื่นน้อยลงเมื่อทํางานนี้ ค่านี้ไม่ควรมากกว่าจํานวน CPU core จะประมวลผลเต็มที่เมื่อตั้งเป็น 0", "transcoding_tone_mapping": "การฉายโทนสี", - "transcoding_tone_mapping_description": "", + "transcoding_tone_mapping_description": "พยายามรักษารูปแบบวิดีโอ HDR เมื่อแปลงเป็น SDR อัลกอริทึมแต่ละตัวจะปรับสี,รายละเอียด และความสว่างแตกต่างกัน Hable จะรักษารายละเอียด Mobius จะรักษาสี และ Reinhard จะรักษาความสว่าง", "transcoding_transcode_policy": "กฎการแปลงไฟล์", + "transcoding_transcode_policy_description": "นโยบายเกี่ยวกับเวลาที่วิดีโอจะต้องได้รับการแปลงรหัส วิดีโอ HDR จะได้รับการแปลงรหัสเสมอ (ยกเว้นในกรณีที่ปิดใช้งานการแปลงรหัส)", "transcoding_two_pass_encoding": "การแปลงไฟล์สองรอบ", "transcoding_two_pass_encoding_setting_description": "การแปลงไฟล์สองรอบจะช่วยให้ได้วิดีโอที่ดีขึ้น เมื่อเปิดใช้งาน bitrate สูงสุด (จำเป็นสำหรับไฟล์ H.264 และ HEVC) โหมดนี้จะใช้ช่วง bitrate ที่ขึ้นอยู่กับค่า bitrate สูงสุดและไม่สนใจ CRF สำหรับ VP9 สามารถใช้ค่า CRF ได้ถ้าปิดใช้งาน bitrate สูงสุด", "transcoding_video_codec": "แบบไฟล์วิดีโอ", @@ -307,17 +333,24 @@ "trash_number_of_days_description": "จํานวนวันที่เก็บสื่อไว้ในถังขยะก่อนที่จะลบถาวร", "trash_settings": "การตั้งค่าถังขยะ", "trash_settings_description": "จัดการการตั้งค่าถังขยะ", + "untracked_files": "ไฟล์ที่ไม่ได้ติดตาม", + "untracked_files_description": "แอปพลิเคชันจะไม่ติดตามไฟล์เหล่านี้ อาจเป็นผลลัพธ์จากการย้ายที่ล้มเหลว การอัปโหลดที่หยุดชะงัก หรือการถูกทิ้งไว้เนื่องจากจุดบกพร่อง", "user_cleanup_job": "ล้างผู้ใช้", "user_delete_delay": "บัญชีและสื่อของ {user} จะถูกตั้งเวลาสำหรับการลบถาวรใน {delay, plural, one {# วัน} other {# วัน}}", "user_delete_delay_settings": "ลบการถ่วงเวลา", "user_delete_delay_settings_description": "จํานวนวันหลังจากที่เอาออกเพื่อลบบัญชีผู้ใช้และสื่อถาวร งานลบบัญชีผู้ใช้ทํางานทุกเที่ยงคืนเพื่อตรวจสอบผู้ใช้ที่พร้อมที่จะถูกลบข้อมูลแล้ว การตั้งค่าครั้งนี้จะมีผลครั้งต่อไป", "user_delete_immediately": "บัญชีและสื่อของ {user} จะอยู่ในคิวสำหรับการลบถาวร โดยทันที", - "user_settings": "การตั้งค่าผู้ใช้", + "user_delete_immediately_checkbox": "คิวผู้ใช้และสื่อสำหรับการลบทันที", "user_management": "การจัดการผู้ใช้", "user_password_has_been_reset": "รหัสผ่านของผู้ใช้ {user} ถูกตั้งค่าใหม่แล้ว", "user_password_reset_description": "รหัสผ่านของผู้ใช้จะถูกตั้งค่าใหม่และส่งไปยังอีเมลที่ลงทะเบียน", + "user_restore_description": "บัญชีของ {user} จะได้รับการคืนค่า", + "user_restore_scheduled_removal": "กู้คืนผู้ใช้ - กำหนดการลบในวันที่ {date, date,long}", + "user_settings": "การตั้งค่าผู้ใช้", "user_settings_description": "จัดการการตั้งค่าผู้ใช้", + "user_successfully_removed": "ลบผู้ใช้ {email} เรียบร้อยแล้ว", "version_check_enabled_description": "เช็ค GitHub เป็นระยะ ๆ เพื่อตรวจสอบรุ่นใหม่", + "version_check_implications": "การตรวจสอบเวอร์ชันใหม่จะต้องติดต่อกับ github.com เป็นระยะ", "version_check_settings": "ตรวจสอบรุ่น", "version_check_settings_description": "เปิด/ปิดการแจ้งเตือนรุ่นใหม่", "video_conversion_job_description": "แปลงไฟล์วิดีโอเพึ่อรองรับบราวเซอร์และเครื่องเล่นอื่น ๆ มากขึ้น" @@ -332,12 +365,20 @@ "album_added": "เพิ่มอัลบั้มแล้ว", "album_added_notification_setting_description": "แจ้งเตือนอีเมลเมื่อคุณถูกเพิ่มไปในอัลบั้มที่แชร์กัน", "album_cover_updated": "อัพเดทหน้าปกอัลบั้มแล้ว", - "album_info_updated": "อัพเดทข้อมูลอัลบั้มแล้ว", + "album_delete_confirmation": "คุณแน่ใจที่จะลบอัลบั้ม {album} นี้ ?", + "album_delete_confirmation_description": "หากแชร์อัลบั้มนี้ ผู้ใช้รายอื่นจะไม่สามารถเข้าถึงได้อีก", + "album_info_updated": "อัปเดทข้อมูลอัลบั้มแล้ว", + "album_leave": "ออกจากอัลบั้ม ?", + "album_leave_confirmation": "คุณต้องการออกจากอัลบั้ม {album} ใช่หรือไม่", "album_name": "ชื่ออัลบั้ม", "album_options": "ตัวเลือกอัลบั้ม", - "album_updated": "อัพเดทอัลบั้มแล้ว", + "album_remove_user": "ลบผู้ใช้ ?", + "album_remove_user_confirmation": "คุณต้องการที่จะลบผู้ใช้ {user} ?", + "album_share_no_users": "ดูเหมือนว่าคุณได้แชร์อัลบั้มนี้กับผู้ใช้ทั้งหมดแล้ว", + "album_updated": "อัปเดทอัลบั้มแล้ว", "album_updated_setting_description": "แจ้งเตือนอีเมลเมื่ออัลบั้มที่แชร์กันมีสื่อใหม่", "album_user_left": "ออกจาก {album}", + "album_user_removed": "ลบผู้ใช้ {user} แล้ว", "album_with_link_access": "อนุญาตให้ทุกคนที่มีลิงก์สามารถดูรูปภาพและผู้คนที่อยู่ในอัลบั้มนี้", "albums": "อัลบั้ม", "albums_count": "{count, plural, one {{count, number} อัลบั้ม} other {{count, number} อัลบั้ม}}", @@ -351,7 +392,9 @@ "allow_public_user_to_upload": "อนุญาตให้ผู้ใช้สาธารณะอัปโหลดได้", "anti_clockwise": "ทวนเข็มนาฬิกา", "api_key": "API key", - "api_keys": "API Keys", + "api_key_description": "ค่านี้จะแสดงเพียงครั้งเดียว โปรดคัดลอกก่อนปิดหน้าต่าง", + "api_key_empty": "ชื่อคีย์ API ของคุณไม่ควรว่างเปล่า", + "api_keys": "API คีย์", "app_settings": "การตั้งค่าแอป", "appears_in": "อยู่ใน", "archive": "เก็บถาวร", @@ -362,22 +405,46 @@ "are_you_sure_to_do_this": "คุณแน่ใจว่าต้องการทำสิ่งนี้หรือไม่?", "asset_added_to_album": "เพิ่มไปยังอัลบั้มแล้ว", "asset_adding_to_album": "กำลังเพิ่มไปยังอัลบั้ม...", + "asset_description_updated": "อัปเดตรายละเอียดสำเร็จ", + "asset_filename_is_offline": "สื่อ {filename} ออฟไลน์อยู่", + "asset_has_unassigned_faces": "สื่อไม่ได้ระบุใบหน้า", "asset_offline": "สื่อออฟไลน์", + "asset_offline_description": "ไม่พบทรัพยากรภายนอกนี้ในดิสก์อีกต่อไป โปรดติดต่อผู้ดูแลระบบ Immich ของคุณเพื่อขอความช่วยเหลือ", "asset_skipped": "ข้ามแล้ว", "asset_skipped_in_trash": "ในถังขยะ", "asset_uploaded": "อัปโหลดแล้ว", "asset_uploading": "กำลังอัปโหลด...", "assets": "สื่อ", + "assets_added_to_album_count": "เพิ่ม {count, plural, one {# asset} other {# assets}} ไปยังอัลบั้ม", + "assets_added_to_name_count": "เพิ่ม {count, plural, one {# asset} other {# assets}} ไปยัง {hasName, select, true {{name}} other {new album}}", + "assets_moved_to_trash_count": "ย้าย {count, plural, one {# asset} other {# assets}} ไปยังถังขยะแล้ว", + "assets_permanently_deleted_count": "ลบ {count, plural, one {# asset} other {# assets}} ทิ้งถาวร", + "assets_removed_count": "{count, plural, one {# asset} other {# assets}} ถูกลบแล้ว", + "assets_restore_confirmation": "คุณแน่ใจหรือไม่ว่าต้องการกู้คืนสื่อที่ทิ้งทั้งหมด? คุณไม่สามารถย้อนกลับการดำเนินการนี้ได้! โปรดทราบว่าสื่อออฟไลน์ใดๆ ไม่สามารถกู้คืนได้ด้วยวิธีนี้", + "assets_restored_count": "{count, plural, one {# asset} other {# assets}} คืนค่า", + "assets_trashed_count": "{count, plural, one {# asset} other {# assets}} ถูกลบ", + "assets_were_part_of_album_count": "{count, plural, one {Asset was} other {Assets were}} อยู่ในอัลบั้มอยู่แล้ว", "authorized_devices": "อุปกรณ์ที่ได้รับอนุญาต", "back": "กลับ", + "back_close_deselect": "ย้อนกลับ, ปิด, หรือยกเลิกการเลือก", "backward": "กลับหลัง", + "birthdate_saved": "บันทึกวันเกิดแล้ว", + "birthdate_set_description": "วันที่เกิดจะนำมาใช้ในการคำนวณอายุของบุคคลนี้ในขณะที่ถ่ายรูป", "blurred_background": "พื้นหลังแบบเบลอ", + "bugs_and_feature_requests": "รายงานข้อผิดพลาด & ข้อเสนอแนะ", + "build": "สร้าง", + "build_image": "สร้าง Image", + "bulk_delete_duplicates_confirmation": "คุณแน่ใจหรือไม่ว่าต้องการลบ {count, plural, one {# duplicate asset} other {# duplicate asset}} เป็นกลุ่ม การดำเนินการนี้จะเก็บสื่อที่ใหญ่ที่สุดของแต่ละกลุ่มและลบสื่อที่ซ้ำกันทั้งหมดอย่างถาวร คุณไม่สามารถย้อนกลับการดำเนินการนี้ได้!", + "bulk_keep_duplicates_confirmation": "คุณแน่ใจหรือไม่ว่าต้องการเก็บ {count, plural, one {# duplicate asset} other {# duplicate asset}} ไว้ การดำเนินการนี้จะแก้ไขกลุ่มที่ซ้ำกันทั้งหมดโดยไม่ต้องลบสิ่งใดเลย", + "bulk_trash_duplicates_confirmation": "คุณแน่ใจหรือไม่ว่าต้องการลบข้อมูลจำนวนมาก {count, plural, one {# duplicate asset} other {# duplicate asset}} การทำเช่นนี้จะเก็บสื่อที่ใหญ่ที่สุดของแต่ละกลุ่มและลบข้อมูลซ้ำอื่น ๆ ทั้งหมด", + "buy": "ซื้อ Immich", "camera": "กล้อง", "camera_brand": "ยี่ห้อกล้อง", "camera_model": "รุ่นกล้อง", "cancel": "ยกเลิก", "cancel_search": "ยกเลิกการค้นหา", "cannot_merge_people": "ไม่สามารถรวมกลุ่มคนได้", + "cannot_undo_this_action": "ไม่สามารถย้อนกลับได้", "cannot_update_the_description": "ไม่สามารถอัพเดทรายละเอียดได้", "change_date": "เปลี่ยนวันที่", "change_expiration_time": "เปลี่ยนเวลาหมดอายุ", @@ -385,27 +452,34 @@ "change_name": "เปลี่ยนชื่อ", "change_name_successfully": "เปลี่ยนชื่อเรียบร้อยแล้ว", "change_password": "เปลี่ยนรหัสผ่าน", + "change_password_description": "การเข้าสู่ระบบครั้งแรก จำเป็นจต้องเปลี่ยนรหัสผ่านของคุณเพื่อความปลอดภัย โปรดป้อนรหัสผ่านใหม่ด้านล่าง", "change_your_password": "เปลี่ยนรหัสผ่านของคุณ", "changed_visibility_successfully": "เปลี่ยนการมองเห็นเรียบร้อยแล้ว", + "check_all": "เลือกทั้งหมด", "check_logs": "ตรวจสอบบันทึก", + "choose_matching_people_to_merge": "เลือกคนที่ตรงกันเพื่อรวมเข้าด้วยกัน", "city": "เมือง", "clear": "ล้าง", "clear_all": "ล้างทั้งหมด", + "clear_all_recent_searches": "ล้างประวัติการค้นหา", "clear_message": "ล้างข้อความ", "clear_value": "ล้างค่า", + "clockwise": "ตามเข็มนาฬิกา", "close": "ปิด", "collapse": "ย่อ", "collapse_all": "ย่อทั้งหมด", "color": "สี", "color_theme": "สีธีม", "comment_deleted": "ลบความคิดเห็นแล้ว", - "comment_options": "", + "comment_options": "ตัวเลือกความคิดเห็น", "comments_and_likes": "ความคิดเห็นและการถูกใจ", "comments_are_disabled": "ความคิดเห็นถูกปิดใช้งาน", "confirm": "ยืนยัน", "confirm_admin_password": "ยืนยันรหัสผ่านผู้ดูแลระบบ", + "confirm_delete_shared_link": "คุณต้องการที่จะลบลิงก์ที่แชร์ใช่หรือไม่ ?", + "confirm_keep_this_delete_others": "จะลบทั้งหมดในรายการ และยกเว้นสื่อนี้หรือไม่ คุณแน่ใจใช่ไหมที่ต้องการดำเนินการต่อ?", "confirm_password": "ยืนยันรหัสผ่าน", - "contain": "มี", + "contain": "มีอยู่", "context": "บริบท", "continue": "ต่อไป", "copied_image_to_clipboard": "คัดลอกภาพไปยังคลิปบอร์ดแล้ว", @@ -425,8 +499,12 @@ "create_library": "สร้างคลังภาพ", "create_link": "สร้างลิงก์", "create_link_to_share": "สร้างลิงก์เพื่อแชร์", + "create_link_to_share_description": "ผู้ที่มีลิงก์ สามารถดูรูปที่เลือกได้", "create_new_person": "สร้างคนใหม่", + "create_new_person_hint": "กำหนดสื่อที่เลือกให้กับคนใหม่", "create_new_user": "สร้างผู้ใช้งานใหม่", + "create_tag": "สร้างแท็กใหม่", + "create_tag_description": "สร้างแท็กใหม่ สำหรับแท็กที่ซ้อนกัน โปรดป้อนเส้นทางทั้งหมดของแท็ก รวมถึงเครื่องหมายทับ", "create_user": "สร้างผู้ใช้", "created": "สร้างแล้ว", "current_device": "อุปกรณ์ปัจจุบัน", @@ -436,22 +514,36 @@ "date_after": "วันที่หลังจาก", "date_and_time": "วันและเวลา", "date_before": "วันที่ก่อน", + "date_of_birth_saved": "บันทึกวันเกิดเรียบร้อยแล้ว", "date_range": "ช่วงวันที่", "day": "วัน", + "deduplicate_all": "รวมเข้าด้วยกันทั้งหมด", + "deduplication_criteria_1": "ขนาดไบต์ของรูปภาพ", + "deduplication_criteria_2": "จำนวนข้อมูล EXIF", + "deduplication_info": "ข้อมูลการขจัดข้อมูลซ้ำซ้อน", + "deduplication_info_description": "เลือกสื่อล่วงหน้าโดยอัตโนมัติและลบรายการซ้ำซ้อนจำนวนมาก เราจะดูที่:", "default_locale": "ภาษาท้องถิ่นปกติ", "default_locale_description": "ใช้รูปแบบวันที่และตัวเลขจากเบราว์เซอร์ของคุณ", "delete": "ลบออก", "delete_album": "ลบอัลบั้ม", + "delete_api_key_prompt": "คุณต้องการลบ API คีย์ นี้ใช่ไหม ?", + "delete_duplicates_confirmation": "คุณแน่ใจที่ต้องการลบรายการซ้ำอย่างถาวรใช่ไหม ?", "delete_key": "ลบกุญแจ", "delete_library": "ลบคลังภาพ", "delete_link": "ลบลิงก์", + "delete_others": "ลบผู้อื่น", "delete_shared_link": "ลบลิงก์ที่แชร์", + "delete_tag": "ลบแท็ก", + "delete_tag_confirmation_prompt": "คุณต้องการลบแท็ก {tagName} ใช่หรือไม่", "delete_user": "ลบผู้ใช้", "deleted_shared_link": "ลบลิงก์ที่แชร์แล้ว", + "deletes_missing_assets": "ลบสื่อที่หายไปออกจากดิสถ์", "description": "รายละเอียด", "details": "รายละเอียด", "direction": "เส้นทาง", + "disabled": "ปิดการใช้งาน", "disallow_edits": "ไม่อนุญาตให้แก้ไข", + "discord": "Discord", "discover": "ค้นพบ", "dismiss_all_errors": "ปฏิเสธข้อผิดพลาดทั้งหมด", "dismiss_error": "ปฏิเสธข้อผิดพลาด", @@ -459,14 +551,21 @@ "display_order": "ลำดับการแสดงผล", "display_original_photos": "แสดงภาพต้นฉบับ", "display_original_photos_setting_description": "การตั้งค่าแสดงผลรูปภาพต้นฉบับ เมื่อเปิดรูปภาพ การตั้งค่านี้อาจจะทำให้การแสดงภาพได้ช้าลง", + "do_not_show_again": "ไม่แสดงข้อความนี้อีก", + "documentation": "เอกสาร", "done": "ดำเนินการสำเร็จ", "download": "ดาวน์โหลด", "download_include_embedded_motion_videos": "รวมวิดีโอที่ฝังอยู่ในภาพเคลื่อนไหว", "download_include_embedded_motion_videos_description": "รวมวิดีโอที่ฝังอยู่ในภาพเคลื่อนไหวเมื่อดาวน์โหลดอัลบั้ม", - "downloading": "กำลังดาวน์โหลด", "download_settings": "การตั้งค่าการดาวน์โหลด", "download_settings_description": "จัดการการตั้งค่าการดาวน์โหลด", + "downloading": "กำลังดาวน์โหลด", + "downloading_asset_filename": "กำลังดาวน์โหลด {filename}", + "drop_files_to_upload": "วางไฟล์ในช่องอัปโหลด", + "duplicates": "รายการที่ซ้ำกัน", + "duplicates_description": "แก้ไขแต่ละกลุ่มโดยระบุว่ากลุ่มใดซ้ำกันหากมี", "duration": "ระยะเวลา", + "edit": "แก้ไข", "edit_album": "แก้ไขอัลบั้ม", "edit_avatar": "แก้ไขตัวละคร", "edit_date": "แก้ไขวันที่", @@ -480,17 +579,24 @@ "edit_location": "แก้ไขตำแหน่ง", "edit_name": "แก้ไขชื่อ", "edit_people": "แก้ไขผู้คน", + "edit_tag": "แก้ไขแท็ก", "edit_title": "แก้ไขชื่อ", "edit_user": "แก้ไขผู้ใช้", "edited": "แก้ไขแล้ว", "editor": "ผู้แก้ไข", + "editor_close_without_save_prompt": "การเปลี่ยนแปลงนี้จะไม่ได้รับการบันทึก", + "editor_close_without_save_title": "ปิดโปรแกรมแก้ไข?", + "editor_crop_tool_h2_aspect_ratios": "อัตราส่วนภาพ", + "editor_crop_tool_h2_rotation": "การหมุน", "email": "อีเมล", "empty_trash": "ทิ้งจากถังขยะ", + "empty_trash_confirmation": "คุณแน่ใจหรือไม่ว่าต้องการล้างถังขยะ การดำเนินการนี้จะลบทรัพยากรทั้งหมดในถังขยะออกจาก Immich อย่างถาวร\nคุณไม่สามารถย้อนกลับการดำเนินการนี้ได้!", "enable": "เปิดใช้งาน", "enabled": "เปิดใช้งาน", "end_date": "วันสิ้นสุด", "error": "เกิดข้อผิดพลาด", "error_loading_image": "เกิดข้อผิดพลาดระหว่างโหลดภาพ", + "error_title": "เกิดข้อผิดพลาด", "errors": { "cannot_navigate_next_asset": "ไม่สามารถเปลี่ยนเส้นทางได้", "cannot_navigate_previous_asset": "ไม่สามารถเปลี่ยนเส้นทางก่อนหน้าได้", @@ -523,31 +629,68 @@ "failed_to_remove_product_key": "Failed to remove product key", "failed_to_stack_assets": "Failed to stack assets", "failed_to_unstack_assets": "Failed to un-stack assets", - "incorrect_email_or_password": "อีเมลหรือรหัสผ่านไม่ถูกต้อง", "import_path_already_exists": "พาธนำเข้านี้มีอยู่แล้ว", + "incorrect_email_or_password": "อีเมลหรือรหัสผ่านไม่ถูกต้อง", + "paths_validation_failed": "การตรวจสอบ {paths, plural, one {# path} other {# paths}} ล้มเหลว", + "profile_picture_transparent_pixels": "รูปโปรไฟล์ไม่สามารถมีพิกเซลโปร่งใสได้ โปรดซูมเข้าและ/หรือย้ายรูปภาพ", + "quota_higher_than_disk_size": "คุณตั้งโควตาไว้สูงกว่าขนาดดิสก์", + "repair_unable_to_check_items": "ไม่สามารถตรวจสอบ {count, select, one {item} other {items}} ได้", "unable_to_add_album_users": "ไม่สามารถเพิ่มผู้ใช้ไปยังอัลบั้มได้", + "unable_to_add_assets_to_shared_link": "ไม่สามารถเพิ่มลงในลิงก์ที่แชร์ได้", "unable_to_add_comment": "ไม่สามารถเพิ่มความเห็นได้", + "unable_to_add_exclusion_pattern": "ไม่สามารถเพิ่มรูปแบบข้อยกเว้นได้", + "unable_to_add_import_path": "ไม่สามารถเพิ่มเส้นทางนำเข้าได้", "unable_to_add_partners": "ไม่สามารถเพิ่มคู่หูได้", + "unable_to_add_remove_archive": "ไม่สามารถจัดเก็บรายการ {archived, select, true {remove asset from} other {add asset to}} ไปยังการจัดเก็บถาวรได้", + "unable_to_add_remove_favorites": "ไม่สามารถทำรายการ {favorite, select, true {add asset to} other {remove asset from}} เข้ารายการโปรดได้", + "unable_to_archive_unarchive": "ไม่สามารถทำรายการ {archived, select, true {archive} other {unarchive}}", "unable_to_change_album_user_role": "ไม่สามารถเปลี่ยนบทบาทผู้ใช้ในอัลบั้มได้", "unable_to_change_date": "ไม่สามารถเปลี่ยนวันที่ได้", + "unable_to_change_favorite": "ไม่สามารถเปลี่ยนแปลงสื่อรายการโปรดได้", "unable_to_change_location": "ไม่สามารถเปลี่ยนตําแหน่งได้", + "unable_to_change_password": "ไม่สามารถเปลี่ยนรหัสผ่านได้", + "unable_to_change_visibility": "ไม่สามารถเปลี่ยนการมองเห็นสำหรับ {count, plural, one {# person} other {# people}}", + "unable_to_complete_oauth_login": "ไม่สามารถทำการเข้าสู่ระบบ OAuth ให้เสร็จสมบูรณ์ได้", + "unable_to_connect": "ไม่สามารถเชื่อมต่อได้", + "unable_to_connect_to_server": "ไม่สามารถเชื่อมต่อกับ Server ได้", + "unable_to_copy_to_clipboard": "ไม่สามารถคัดลอกไปยังคลิปบอร์ดได้ ตรวจสอบให้แน่ใจว่าคุณเข้าถึงหน้าผ่านทาง https", "unable_to_create_admin_account": "ไม่สามารถสร้างบัญชีผู้ดูแลระบบได้", + "unable_to_create_api_key": "ไม่สามารถสร้าง API คีย์ ได้", "unable_to_create_library": "ไม่สามารถสร้างคลังภาพได้", "unable_to_create_user": "ไม่สามารถสร้างผู้ใช้ได้", "unable_to_delete_album": "ไม่สามารถลบอัลบั้มได้", "unable_to_delete_asset": "ไม่สามารถลบสื่อได้", + "unable_to_delete_assets": "เกิดผิดพลาดในการลบ", + "unable_to_delete_exclusion_pattern": "ไม่สามารถลบรูปแบบที่ยกเว้น", + "unable_to_delete_import_path": "ไม่สามารถลบเส้นทางนำเข้าได้", + "unable_to_delete_shared_link": "ไม่สามารถลบลิงก์ที่แชร์ได้", "unable_to_delete_user": "ไม่สามารถลบผู้ใช้ได้", + "unable_to_download_files": "ไม่สามารถดาวน์โหลดไฟล์ได้", + "unable_to_edit_exclusion_pattern": "ไม่สามารถแก้ไขรูปแบบยกเว้นได้", + "unable_to_edit_import_path": "ไม่สามารถแก้ไขเส้นทางนำเข้าได้", "unable_to_empty_trash": "ไม่สามารถลบถังขยะได้", "unable_to_enter_fullscreen": "ไม่สามารถเปิดเต็มจอได้", "unable_to_exit_fullscreen": "ไม่สามารถออกโหมดเต็มจอได้", + "unable_to_get_comments_number": "ไม่สามารถร้องขอจำนวนแสดงความคิดเห็น", + "unable_to_get_shared_link": "การร้องขอลิงก์ที่แชร์ล้มเหลว", "unable_to_hide_person": "ไม่สามารถซ่อนบุคคลได้", + "unable_to_link_motion_video": "ไม่สามารถเชื่อมโยงวิดีโอเคลื่อนไหวได้", + "unable_to_link_oauth_account": "ไม่สามารถเชื่อมโยงบัญชี OAuth ได้", "unable_to_load_album": "ไม่สามารถโหลดอัลบั้มได้", "unable_to_load_asset_activity": "ไม่สามารถโหลดข้อมูลของสื่อได้", "unable_to_load_items": "ไม่สามารถโหลดรายการได้", "unable_to_load_liked_status": "ไม่สามารถโหลดสถานะ like ได้", + "unable_to_log_out_all_devices": "ไม่สามารถออกจากของอุปกรณ์ทั้งหมดได้", + "unable_to_log_out_device": "ไม่สามารถออกจากระบบได้", + "unable_to_login_with_oauth": "ไม่สามารถเข้าสู่ระบบกับ OAuth ได้", "unable_to_play_video": "ไม่สามารถเล่นวิดีโอได้", + "unable_to_reassign_assets_existing_person": "ไม่สามารถกำหนดให้กับ {name, select, null {an existing person} other {{name}}}", + "unable_to_reassign_assets_new_person": "ไม่สามารถมอบหมาย ให้กับบุคคลใหม่ได้", "unable_to_refresh_user": "ไม่สามารถรีเฟรชผู้ใช้ได้", "unable_to_remove_album_users": "ไม่สามารถลบผู้ใช้ออกจากอัลบั้มได้", + "unable_to_remove_api_key": "ไม่สามารถลบ API Key ได้", + "unable_to_remove_assets_from_shared_link": "ไม่สามารถลบออกจากลิงก์ที่แชร์ได้", + "unable_to_remove_deleted_assets": "ไม่สามารถลบไฟล์ออฟไลน์ได้", "unable_to_remove_library": "ไม่สามารถลบคลังภาพได้", "unable_to_remove_partner": "ไม่สามารถลบคู่หูได้", "unable_to_remove_reaction": "ไม่สามารถลบ reaction ได้", @@ -558,56 +701,93 @@ "unable_to_restore_trash": "ไม่สามารถเรียกคืนถังขยะได้", "unable_to_restore_user": "ไม่สามารถเรียกคืนผู้ใช้ได้", "unable_to_save_album": "ไม่สามารถบันทึกอัลบั้มได้", + "unable_to_save_api_key": "ไม่สามารถบันทึก API คีย์ ได้", + "unable_to_save_date_of_birth": "ไม่สามารถตั้งวันเกิดได้", "unable_to_save_name": "ไม่สามารถบันทึกชื่อได้", "unable_to_save_profile": "ไม่สามารถบันทึกโปรไฟล์ได้", "unable_to_save_settings": "ไม่สามารถบันทึกการตั้งค่าได้", "unable_to_scan_libraries": "ไม่สามารถสแกนคลังภาพได้", "unable_to_scan_library": "ไม่สามารถสแกนคลังภาพได้", + "unable_to_set_feature_photo": "ไม่สามารถตั้งรูปภาพเป็น Feature ได้", "unable_to_set_profile_picture": "ไม่สามารถตั้งภาพโปรไฟล์ได้", "unable_to_submit_job": "ไม่สามารถส่งงานได้", "unable_to_trash_asset": "ไม่สามารถทิ้งสื่อได้", "unable_to_unlink_account": "ไม่สามารถยกเลิกการเชื่อมโยงบัญชีผู้ใช้ได้", + "unable_to_unlink_motion_video": "ไม่สามารถแยกการเคลื่อนไหวได้", + "unable_to_update_album_cover": "ไม่สามารถอัปเดตรูปภาพปกอัลบั้มได้", + "unable_to_update_album_info": "ไม่สามารถอัปเดตข้อมูลอัลบั้มได้", "unable_to_update_library": "ไม่สามารถอัพเดทคลังภาพได้", "unable_to_update_location": "ไม่สามารถอัพเดทตําแหน่งได้", "unable_to_update_settings": "ไม่สามารถอัพเดทการตั้งค่าได้", - "unable_to_update_user": "ไม่สามารถอัพเดทผู้ใช้ได้" + "unable_to_update_timeline_display_status": "ไม่สามารถแก้ไขสถานะการแสดงลำดับเวลาได้", + "unable_to_update_user": "ไม่สามารถอัพเดทผู้ใช้ได้", + "unable_to_upload_file": "ไม่สามารถอัปโหลดได้" }, "exit_slideshow": "ออกจากการนำเสนอ", "expand_all": "ขยายทั้งหมด", "expire_after": "หมดอายุหลังจาก", "expired": "หมดอายุแล้ว", + "expires_date": "หมดอายุวันที่ {date}", "explore": "สํารวจ", + "explorer": "เครื่องมือสำรวจ", + "export": "ส่งออก", + "export_as_json": "ส่งออกเป็นไฟล์ JSON", "extension": "ส่วนต่อขยาย", + "external": "ภายนอก", "external_libraries": "ภายนอกคลังภาพ", + "face_unassigned": "ไม่กำหนดมอบหมาย", + "failed_to_load_assets": "เกิดข้อผิดพลาดในการโหลดสื่อ", "favorite": "รายการโปรด", "favorite_or_unfavorite_photo": "โปรดหรือไม่โปรดภาพ", "favorites": "รายการโปรด", "feature_photo_updated": "อัพเดทภาพเด่นแล้ว", + "features": "ฟีเจอร์", + "features_setting_description": "จัดการฟีเจอร์แอป", "file_name": "ชื่อไฟล์", "file_name_or_extension": "นามสกุลหรือชื่อไฟล์", "filename": "ชื่อไฟล์", "filetype": "ชนิดไฟล์", "filter_people": "กรองผู้คน", + "find_them_fast": "ค้นหาโดยชื่ออย่างรวดเร็ว", "fix_incorrect_match": "แก้ไขการจับคู่ที่ไม่ถูกต้อง", + "folders": "โฟล์เดอร์", + "folders_feature_description": "การเรียกดูมุมมองโฟลเดอร์สำหรับภาพถ่ายและวิดีโอในระบบไฟล์", "forward": "ไปข้างหน้า", "general": "ทั่วไป", "get_help": "ขอความช่วยเหลือ", "getting_started": "เริ่มต้นใช้งาน", "go_back": "กลับ", + "go_to_folder": "ไปที่โฟล์เดอร์", "go_to_search": "กลับไปยังการค้นหา", "group_albums_by": "จัดกลุ่มอัลบั้มตาม", "group_no": "ไม่จัดกลุ่ม", "group_owner": "จัดกลุ่มโดยเจ้าของ", "group_year": "จัดกลุ่มตามปี", "has_quota": "เหลือพื้นที่", + "hi_user": "สวัสดีคุณ {name} {email}", + "hide_all_people": "ซ่อนบุคคลทั้งหมด", "hide_gallery": "ซ่อนคลังภาพ", + "hide_named_person": "ซ่อน {name}", "hide_password": "ซ่อนรหัสผ่าน", "hide_person": "ซ่อนบุคคล", + "hide_unnamed_people": "ซ่อนบุคคลที่ไม่ได้ระบุชื่อ", "host": "โฮสต์", "hour": "ชั่วโมง", "image": "รูปภาพ", - "immich_logo": "", - "import_path": "นำเข้าพาธ", + "image_alt_text_date_1_person": "{isVideo, select, true {Video} other {Image}} ถ่ายกับ {person1} วันที่ {date}", + "image_alt_text_date_2_people": "{isVideo, select, true {Video} other {Image}} ถ่ายกับ {person1} และ {person2} วันที่ {date}", + "image_alt_text_date_3_people": "{isVideo, select, true {Video} other {Image}} ถ่ายกับ {person1}, {person2},และ {person3} วันที่ {date}", + "image_alt_text_date_4_or_more_people": "{isVideo, select, true {Video} other {Image}} ถ่ายกับ {person1}, {person2},และ {additionalCount, number} วันที่ {date}", + "image_alt_text_date_place": "{isVideo, select, true {Video} other {Image}} ถ่ายใน {city}, {country} วันที่ {date}", + "image_alt_text_date_place_1_person": "{isVideo, select, true {Video} other {Image}} ถ่ายใน {city}, {country} กับ {person1} วันที่ {date}", + "image_alt_text_date_place_2_people": "{isVideo, select, true {Video} other {Image}} ถ่ายใน {city}, {country} กับ {person1} และ {person2} วันที่ {date}", + "image_alt_text_date_place_3_people": "{isVideo, select, true {Video} other {Image}} ถ่ายใน {city}, {country} กับ {person1}, {person2},และ {person3} วันที่ {date}", + "image_alt_text_date_place_4_or_more_people": "{isVideo, select, true {Video} other {Image}} ถ่ายใน {city}, {country} กับ {person1}, {person2}, และ {additionalCount, number} ในวันที่ {date}", + "immich_logo": "โลโก้ Immich", + "immich_web_interface": "หน้าตาเว็บไซต์ Immich", + "import_from_json": "นำเข้าจาก JSON", + "import_path": "นำเข้าเส้นทาง", + "in_albums": "ใน {count, plural, one {# album} other {# albums}}", "in_archive": "ในที่เก็บถาวร", "include_archived": "รวมไฟล์เก็บถาวร", "include_shared_albums": "รวมอัลบั้มที่แชร์กัน", @@ -616,25 +796,33 @@ "info": "ข้อมูล", "interval": { "day_at_onepm": "ทุกวันเวลาบ่ายโมง", - "hours": "", + "hours": "ทุก ๆ {hours, plural, one {hour} other {{hours, number} hours}}", "night_at_midnight": "ทุกเที่ยงคืน", "night_at_twoam": "ทุกวันเวลาตี 2" }, "invite_people": "เชิญผู้คน", "invite_to_album": "เชิญเข้าอัลบั้ม", + "items_count": "{count, plural, one {# รายการ} other {#รายการ}}", "jobs": "งาน", "keep": "เก็บ", + "keep_all": "เก็บทั้งหมด", + "keep_this_delete_others": "เก็บสิ่งนี้ไว้ ลบอันอื่นออก", + "kept_this_deleted_others": "เก็บเนื้อหานี้และลบ {count, plural, one {# Asset} other {# Asset}}", "keyboard_shortcuts": "ปุ่มพิมพ์ลัด", "language": "ภาษา", "language_setting_description": "เลือกภาษาที่ต้องการ", "last_seen": "เห็นล่าสุด", "latest_version": "เวอร์ชันล่าสุด", + "latitude": "ละติจูด", "leave": "ทิ้ง", + "lens_model": "รูปแบบเลนช์", "let_others_respond": "ให้คนอื่นตอบ", "level": "ระดับ", "library": "คลังภาพ", "library_options": "ตัวเลือกคลังภาพ", "light": "สว่าง", + "like_deleted": "ลบที่ถูกใจแล้ว", + "link_motion_video": "ลิงก์วิดีโอเคลื่อนไหว", "link_options": "ตัวเลือกลิงก์", "link_to_oauth": "ลิงก์ไปยัง OAuth", "linked_oauth_account": "ลิงก์บัญชีผู้ใช้ OAuth", @@ -643,10 +831,17 @@ "loading_search_results_failed": "โหลดผลการค้นหาล้มเหลว", "log_out": "ออกจากระบบ", "log_out_all_devices": "ให้ทุกอุปกรณ์ออกจากระบบทั้งหมด", + "logged_out_all_devices": "ออกจากระบบทั้งหมดแล้ว", + "logged_out_device": "ออกจากระบบแล้ว", + "login": "เข้าสู่ระบบ", "login_has_been_disabled": "ปิดการใช้งานการเข้าสู่ระบบแล้ว", + "logout_all_device_confirmation": "คุณต้องการออกจากระบบทุกอุปกรณ์ ใช่หรือไม่ ?", + "logout_this_device_confirmation": "คุณต้องการออกจากระบบใช่หรือไม่ ?", + "longitude": "ลองจิจูด", "look": "ดู", "loop_videos": "วนวิดีโอ", "loop_videos_description": "เปิดเพื่อให้วิดีโอวนลูปในที่ดูรายละเอียด", + "main_branch_warning": "คุณกำลังใช้เวอร์ชันการพัฒนา เราขอแนะนำอย่างยิ่งให้ใช้เวอร์ชันเสถียร !", "make": "สร้าง", "manage_shared_links": "จัดการลิงก์ที่แชร์", "manage_sharing_with_partners": "จัดการการแชร์กับคู่หู", @@ -671,6 +866,7 @@ "merge_people_limit": "คุณรวมใบหน้าได้มากถึง 5 รูปต่อครั้ง", "merge_people_prompt": "คุณต้องการรวมคนพวกนี้หรือไม่ การกระทำนี้ไม่สามารถย้อนกลับได้", "merge_people_successfully": "รวมผู้คนเรียบร้อยแล้ว", + "merged_people_count": "{count, plural, one {# person} other {# people}} ถูกรวมเข้าด้วยกัน", "minimize": "ย่อลง", "minute": "นาที", "missing": "ขาดหาย", @@ -683,7 +879,7 @@ "name_or_nickname": "ชื่อหรือชื่อเล่น", "never": "ไม่เคย", "new_album": "อัลบั้มใหม่", - "new_api_key": "กุญแจ API ใหม่", + "new_api_key": "สร้าง API คีย์ใหม่", "new_password": "รหัสผ่านใหม่", "new_person": "คนใหม่", "new_user_created": "สร้างผู้ใช้ใหม่แล้ว", @@ -694,6 +890,7 @@ "no": "ไม่", "no_albums_message": "สร้างอัลบั้มเพื่อจัดการรูปภาพและวิดีโอของคุณ", "no_albums_with_name_yet": "ดูเหมือนว่าไม่มีอัลบั้มไหนที่ใช้ชื่อนี้", + "no_albums_yet": "ดูเหมือนว่าคุณยังไม่มีอัลบั้มใดๆ", "no_archived_assets_message": "จัดเก็บรูปภาพและวีดิโอถาวรเพื่อซ่อนจากมุมมองคุณ", "no_assets_message": "กดเพื่อใส่ภาพคุณภาพแรก", "no_duplicates_found": "ไม่พบรายการที่ซ้ำกัน", @@ -704,8 +901,10 @@ "no_name": "ไม่มีชื่อ", "no_places": "ไม่มีสถานที่", "no_results": "ไม่มีผลลัพธ์", + "no_results_description": "ลองใช้คำพ้องหรือคำหลักที่กว้างกว่านี้", "no_shared_albums_message": "สร้างอัลบั้มเพื่อแชร์รูปภาพและวิดีโอกับคนในเครือข่ายของคุณ", "not_in_any_album": "ไม่อยู่ในอัลบั้มใด ๆ", + "note_apply_storage_label_to_previously_uploaded assets": "หมายเหตุ: หากต้องการใช้ป้ายกำกับพื้นที่เก็บข้อมูลกับเนื้อหาที่อัปโหลดก่อนหน้านี้ ให้เรียกใช้", "note_unlimited_quota": "หมายเหตุ: กรอก 0 สำหรับโควตาแบบไม่จำกัด", "notes": "หมายเหตุ", "notification_toggle_setting_description": "เปิด/ปิด การแจ้งเตือนอีเมล", @@ -714,11 +913,18 @@ "oauth": "OAuth", "official_immich_resources": "แหล่งข้อมูล Immich อย่างเป็นทางการ", "offline": "ออฟไลน์", + "offline_paths": "เส้นทางที่ตั้งออฟไลน์", + "offline_paths_description": "ผลลัพธ์เหล่านี้อาจเกิดจากการลบไฟล์ที่ไม่ได้เป็นส่วนหนึ่งของไลบรารีภายนอกด้วยตนเอง", "ok": "ตกลง", "oldest_first": "เรียงเก่าสุดก่อน", + "onboarding": "การเริ่มต้นใช้งาน", + "onboarding_privacy_description": "คุณลักษณะ (ไม่จำเป็น) ต่อไปนี้ต้องอาศัยบริการภายนอก และสามารถปิดใช้งานได้ตลอดเวลาในการตั้งค่าการดูแลระบบ", + "onboarding_theme_description": "เลือกธีมสี คุณสามารถเปลี่ยนแปลงได้ในภายหลังในการตั้งค่าของคุณ", + "onboarding_welcome_description": "มาตั้งค่า Immich ของคุณ ด้วยการตั้งค่าทั่วไปกัน", "onboarding_welcome_user": "ยินดีต้อนรับคุณ {user}", "online": "ออนไลน์", "only_favorites": "รายการโปรดเท่านั้น", + "open_in_map_view": "เปิดดูในแผนที่", "open_in_openstreetmap": "เปิดใน OpenStreetMap", "open_the_search_filters": "เปิดตัวกรองการค้นหา", "options": "ตัวเลือก", @@ -730,12 +936,12 @@ "other_variables": "ตัวแปรอื่น", "owned": "เป็นเจ้าของ", "owner": "เจ้าของ", - "partner": "คู่หู", + "partner": "พาร์ทเนอร์", "partner_can_access": "{partner} สามารถเข้าถึง", "partner_can_access_assets": "รูปภาพและวิดีโอทั้งหมดยกเว้นที่อยู่ในเก็บถาวรและถูกลบทิ้ง", "partner_can_access_location": "ตำแหน่งที่รูปถูกถ่าย", - "partner_sharing": "การแชร์แบบคู่หู", - "partners": "คู่หู", + "partner_sharing": "แชร์สำหรับพาร์ทเนอร์", + "partners": "พาร์ทเนอร์", "password": "รหัสผ่าน", "password_does_not_match": "รหัสผ่านไม่ตรงกัน", "password_required": "จำเป็นต้องมีรหัสผ่าน", @@ -745,19 +951,28 @@ "hours": "{hours, plural, one {ชั่วโมง} other {# ชั่วโมง}}ที่ผ่านมา", "years": "{years, plural, one {ปี} other {# ปี}}ที่ผ่านมา" }, - "path": "", + "path": "เส้นทาง", "pattern": "รูปแบบ", "pause": "หยุด", "pause_memories": "หยุดดูความทรงจํา", "paused": "หยุด", "pending": "กำลังรอ", "people": "ผู้คน", + "people_edits_count": "{count, plural, one {# person} other {# people}} ถูกแก้ไข", + "people_feature_description": "เรียกดูภาพถ่ายและวิดีโอที่จัดกลุ่มตามผู้คน", "people_sidebar_description": "แสดงลิงก์ไปยังผู้คนในแถบด้านข้าง", "permanent_deletion_warning": "แจ้งเตือนการลบถาวร", "permanent_deletion_warning_setting_description": "เตือนเมื่อจะลบสื่อถาวร", "permanently_delete": "ลบถาวร", + "permanently_delete_assets_count": "ลบ {count, plural, one {asset} other {assets}} ทิ้งถาวร", + "permanently_delete_assets_prompt": "คุณแน่ใจหรือไม่ว่าต้องการลบ {count, plural, one {this asset?} other {these # asset?}}อย่างถาวร การดำเนินการนี้จะลบ {count, plural, one {it from its} other {them from their}} อัลบั้มด้วย", "permanently_deleted_asset": "ลบสื่อถาวรแล้ว", + "permanently_deleted_assets_count": "ลบ {count, plural, one {# asset} other {# assets}} เรียบร้อยแล้ว", + "person": "บุคคล", + "photo_shared_all_users": "ดูเหมือนว่าคุณได้แชร์รูปภาพของคุณกับผู้ใช้ทั้งหมด หรือคุณไม่มีผู้ใช้ใดที่จะแชร์ด้วย", "photos": "รูปภาพ", + "photos_and_videos": "รูปภาพ และ วิดีโอ", + "photos_count": "{count, plural, one {{count, number} รูป} other {{count, number} รูป}}", "photos_from_previous_years": "ภาพถ่ายจากปีก่อน", "pick_a_location": "เลือกตําแหน่ง", "place": "สถานที่", @@ -773,71 +988,162 @@ "previous_memory": "ความทรงจําก่อนหน้า", "previous_or_next_photo": "ภาพก่อนหน้าหรือภาพถัดไป", "primary": "หลัก", + "privacy": "ความเป็นส่วนตัว", + "profile_image_of_user": "รูปภาพโปรไฟล์ของ {user}", "profile_picture_set": "ตั้งภาพโปรไฟล์แล้ว", + "public_album": "อัลบั้มสาธารณะ", "public_share": "แชร์แบบสาธารณะ", + "purchase_account_info": "ผู้สนับสนุน", + "purchase_activated_subtitle": "ขอบคุณสำหรับการสนับสนุน Immich และซอฟต์แวร์เสรี (Open source software)", + "purchase_activated_time": "เปิดใช้งานวันที่ {date, date}", + "purchase_activated_title": "รหัสของคุณถูกเปิดใช้งานเรียบร้อยแล้ว", + "purchase_button_activate": "เปิดใช้งาน", + "purchase_button_buy": "ซื้อ", + "purchase_button_buy_immich": "ซื้อ Immich", + "purchase_button_never_show_again": "ยังไม่ต้องแสดง", + "purchase_button_reminder": "เตือนฉันในอีก 30 วัน", + "purchase_button_remove_key": "ลบรหัส", + "purchase_button_select": "เลือก", + "purchase_failed_activation": "เปิดใช้งานไม่สำเร็จ! โปรดตรวจสอบอีเมลของคุณเพื่อดูรหัสผลิตภัณฑ์ที่ถูกต้อง!", + "purchase_individual_description_1": "สำหรับบุคคลทั่วไป", + "purchase_individual_description_2": "สถานะผู้สนับสนุน", + "purchase_individual_title": "บุคคลทั่วไป", + "purchase_input_suggestion": "มีรหัสผลิตภัณฑ์หรือไม่? ใส่รหัสด้านล่าง", + "purchase_license_subtitle": "ซื้อ Immich เพื่อรองรับการพัฒนาบริการอย่างต่อเนื่อง", + "purchase_lifetime_description": "ซื้อตลอดชีพ", + "purchase_option_title": "ตัวเลือกการซื้อ", + "purchase_panel_info_1": "ทางทีม Immich ต้องใช้เวลาและความพยายามอย่างมากในการพัฒนาระบบนี้ขึ้นมา และเรามีวิศวกรที่ทำงานเต็มเวลาเพื่อพัฒนาให้ดีที่สุดเท่าที่จะทำได้ ภารกิจของเราคือการทำให้ซอฟต์แวร์โอเพ่นซอร์สและแนวทางปฏิบัติทางธุรกิจที่ถูกต้องตามจริยธรรมกลายเป็นแหล่งรายได้ที่ยั่งยืนสำหรับนักพัฒนา และสร้างระบบนิเวศที่เคารพความเป็นส่วนตัวพร้อมทางเลือกอื่นที่เป็นรูปธรรมแทนบริการคลาวด์ที่เอารัดเอาเปรียบ", + "purchase_panel_info_2": "เนื่องจากเราให้คำมั่นว่า จะไม่เพิ่มระบบชำระเงินในระบบของเรา ดังนั้นการซื้อครั้งนี้จะไม่ทำให้คุณได้รับฟีเจอร์เพิ่มเติมใน Immich เป็นพิเศษ เราอาศัยผู้คนแบบท่านในการสนับสนุนการพัฒนาอย่างต่อเนื่องของ Immich", + "purchase_panel_title": "สนับสนุนโครงการนี้", + "purchase_per_server": "ต่อเซิร์ฟเวอร์", + "purchase_per_user": "ต่อผู้ใช้งาน", + "purchase_remove_product_key": "ลบรหัสผลิตภันฑ์", + "purchase_remove_product_key_prompt": "คุณแน่ใจว่าต้องการลบรหัสผลิตภัณฑ์หรือไม่?", + "purchase_remove_server_product_key": "ลบรหัสผลิตภัณฑ์เซิร์ฟเวอร์", + "purchase_remove_server_product_key_prompt": "คุณแน่ใจหรือไม่ว่าต้องการลบรหัสผลิตภัณฑ์เซิร์ฟเวอร์?", + "purchase_server_description_1": "สำหรับทั้งเซิฟเวอร์", + "purchase_server_description_2": "สถานะผู้สนับสนุน", + "purchase_server_title": "เซิฟเวอร์", + "purchase_settings_server_activated": "รหัสผลิตภัณฑ์เซิร์ฟเวอร์ได้รับการจัดการโดยผู้ดูแลระบบ", + "rating": "การให้คะแนน", + "rating_clear": "ล้างคะแนน", + "rating_count": "{count, plural, one {# ดาว} other {# ดาว}}", + "rating_description": "แสดงคะแนน EXIF ใน Info panel", "reaction_options": "ตัวเลือก reaction", "read_changelog": "อ่านบันทึกการเปลี่ยนแปลง", + "reassign": "มอบหมายใหม่", + "reassigned_assets_to_existing_person": "มอบหมาย {count, plural, one {# สื่อ} other {# สื่อ}} ให้กับ {name, select, null {an existing person} other {{name}}}", + "reassigned_assets_to_new_person": "มอบหมาย {count, plural, one {# สื่อ} other {# สื่อ}} ให้กับบุคคลใหม่", + "reassing_hint": "มอบหมายสื่อที่เลือกให้กับบุคคลที่มีอยู่แล้ว", "recent": "ล่าสุด", + "recent-albums": "อัลบั้มล่าสุด", "recent_searches": "การค้นหาล่าสุด", "refresh": "รีเฟรช", + "refresh_encoded_videos": "โหลดการ encoded วิดีโอใหม่", + "refresh_faces": "รีเฟรชใบหน้า", + "refresh_metadata": "รีเฟรชข้อมูลเมตาดาต้า", + "refresh_thumbnails": "รีโหลดรูป thumbnails", "refreshed": "รีเฟรช", "refreshes_every_file": "รีเฟรชทุกไฟล์", + "refreshing_encoded_video": "กำลังรีเฟรชการเข้ารหัสวิดีโอ", + "refreshing_faces": "กำลังรีเฟรชใบหน้า", + "refreshing_metadata": "กำลังรีเฟรชข้อมูลเมตาดาต้า", + "regenerating_thumbnails": "กำลังสร้างรูป thumbnails ใหม่", "remove": "ลบ", + "remove_assets_album_confirmation": "คุณแน่ใจที่จะลบ {count, plural, one {# สื่อ} other {# สื่อ}} ออกจากอัลบั้ม ?", + "remove_assets_shared_link_confirmation": "คุณแน่ใจที่จะลบ {count, plural, one {# สื่อ} other {# สื่อ}} ออกจากลิงก์ที่แชร์นี้ ?", + "remove_assets_title": "ลบสื่อใช่ไหม ?", + "remove_custom_date_range": "ลบการปรับช่วงเวลา", "remove_deleted_assets": "ลบสื่อที่ถูกลบ", "remove_from_album": "ลบออกจากอัลบั้ม", "remove_from_favorites": "เอาออกจากรายการโปรด", "remove_from_shared_link": "ลบออกจากลิงก์ที่แชร์", - "repair": "ซ่อม", + "remove_url": "ลบ URL", + "remove_user": "ลบผู้ใช้", + "removed_api_key": "API คีย์ของ: {name} ถูกลบแล้ว", + "removed_from_archive": "ลบจากเก็บถาวรแล้ว", + "removed_from_favorites": "ลบจากรายการโปรดแล้ว", + "removed_from_favorites_count": "{count, plural, other {ถูกลบ#}} จากรายการโปรดแล้ว", + "removed_tagged_assets": "ลบแท็กจาก {count, plural, one {# สื่อ} other {# สื่อ}}", + "rename": "เปลี่ยนชื่อ", + "repair": "ซ่อมแซม", "repair_no_results_message": "ไม่สามารถซ่อมแซมได้", "replace_with_upload": "อัปโหลดทับรูปหรือวิดีโอนี้", "require_password": "ต้องการรหัสผ่าน", + "require_user_to_change_password_on_first_login": "จำเป็นต้องเปลี่ยนรหัสผ่าน ในการเข้าสู่ระบบครั้งแรก", "reset": "รีเซ็ต", "reset_password": "ตั้งค่ารหัสผ่านใหม่", "reset_people_visibility": "ปรับการมองเห็นใหม่", + "reset_to_default": "กลับไปค่าเริ่มต้น", + "resolve_duplicates": "แก้ไขข้อมูลซ้ำซ้อน", + "resolved_all_duplicates": "แก้ไขข้อมูลซ้ำซ้อนทั้งหมด", "restore": "เรียกคืน", "restore_all": "เรียกคืนทั้งหมด", "restore_user": "เรียกคืนผู้ใช้", + "restored_asset": "asset ถูกคืนค่า", + "resume": "กลับคืน", "retry_upload": "ลองอัปโหลดใหม่", "review_duplicates": "ตรวจสอบรายการที่ซ้ำกัน", "role": "บทบาท", + "role_editor": "เครื่องมือแก้ไข", + "role_viewer": "ดู", "save": "บันทึก", + "saved_api_key": "บันทึก API คีย์ แล้ว", "saved_profile": "แก้ไขโปรไฟล์สำเร็จ", "saved_settings": "บันทึกการตั้งค่าสำเร็จ", "say_something": "พูดอะไรสักอย่าง", "scan_all_libraries": "สแกนคลังภาพทั้งหมด", + "scan_library": "สแกน", "scan_settings": "ตั้งค่าการสแกน", + "scanning_for_album": "กำลังสแกนอัลบั้ม...", "search": "ค้นหา", "search_albums": "ค้นหาอัลบั้ม", "search_by_context": "ค้นหาตามบริบท", - "search_camera_make": "", - "search_camera_model": "", + "search_by_filename": "ค้นหาชื่อไฟล์ชื่อ หรือ ชนิดไฟล์", + "search_by_filename_example": "ตัวอย่าง. IMG_1234.JPG หรือ PNG", + "search_camera_make": "ค้นหายี่ห้อกล้อง", + "search_camera_model": "ค้นหารุ่นกล้อง", "search_city": "ค้นหาตามเมือง", "search_country": "ค้นหาตามประเทศ", + "search_for": "การค้นหาสำหรับ", "search_for_existing_person": "ค้นหาบุคคลที่มีอยู่", "search_no_people": "ไม่พบบุคคลคน", "search_no_people_named": "ไม่พบ \"{name}\"", "search_options": "ตัวเลือกการค้นหา", "search_people": "ค้นหาผู้คน", "search_places": "ค้นหาสถานที่", + "search_settings": "ตั้งค่าการค้นหา", "search_state": "ค้นหาตามรัฐ", + "search_tags": "ค้นหาแท็ก", "search_timezone": "ค้นหาตามวันที่และเวลา", "search_type": "ค้นหาตามประเภท", "search_your_photos": "ค้นหารูปภาพของคุณ", "searching_locales": "ค้นหาตามภูมิภาค", "second": "วินาที", + "see_all_people": "ดูบุคคลทั้งหมด", "select_album_cover": "เลือกภาพปกอัลบั้ม", "select_all": "เลือกทั้งหมด", + "select_all_duplicates": "เลือกรายการที่ซ้ำทั้งหมด", "select_avatar_color": "เลือกสีพื้นหลังของรูปโปรไฟล์", "select_face": "เลือกใบหน้า", "select_featured_photo": "เลือกภาพเด่น", + "select_from_computer": "เลือกจากคอมพิวเตอร์", + "select_keep_all": "เลือกเก็บทั้งหมด", "select_library_owner": "เลือกเจ้าของคลังภาพ", "select_new_face": "เลือกใบหน้าใหม่", "select_photos": "เลือกรูปภาพ", + "select_trash_all": "เลือกในถังขยะทั้งหมด", "selected": "เลือก", + "selected_count": "{count, plural, other {# เลือกแล้ว}}", "send_message": "ส่งข้อความ", + "send_welcome_email": "ส่งอีเมลต้อนรับ", + "server_offline": "Server ออฟไลน์", + "server_online": "Server ออนไลน์", "server_stats": "สถิติเซิร์ฟเวอร์", - "set": "", + "server_version": "เวอร์ชันของ Server", + "set": "ตั้ง", "set_as_album_cover": "ตั้งเป็นภาพปกอัลบั้ม", + "set_as_featured_photo": "ตั้งเป็นรูปสำคัญ", "set_as_profile_picture": "ตั้งเป็นรูปโปรไฟล์", "set_date_of_birth": "ตั้งวันเกิด", "set_profile_picture": "ตั้งรูปโปรไฟล์", @@ -847,11 +1153,20 @@ "share": "แชร์", "shared": "แชร์", "shared_by": "แชร์โดย", + "shared_by_user": "แชร์โดย {user}", "shared_by_you": "แชร์โดยคุณ", + "shared_from_partner": "รูปจาก {partner}", + "shared_link_options": "ตั้งค่าลิงก์ที่แชร์", "shared_links": "ลิงก์ที่แชร์", + "shared_with_partner": "แชร์กับ {partner}", "sharing": "การแชร์", - "sharing_sidebar_description": "", + "sharing_enter_password": "โปรดป้อนรหัสผ่าน สำหรับเปิดดูหน้านี้", + "sharing_sidebar_description": "แสดงลิงก์ที่แชร์ในแถบด้านข้าง", + "shift_to_permanent_delete": "กด ⇧ to สำหรับลบสื่อถาวร", "show_album_options": "แสดงตัวเลือกอัลบั้ม", + "show_albums": "แสดงอัลบั้ม", + "show_all_people": "แสดงบุคคลทั้งหมด", + "show_and_hide_people": "แสดง & ซ่อนบุคคล", "show_file_location": "แสดงตําแหน่งของไฟล์", "show_gallery": "แสดงคลังภาพ", "show_hidden_people": "แสดงคนที่ซ่อนไว้", @@ -864,6 +1179,9 @@ "show_person_options": "แสดงตัวเลือกของตัวบุคคล", "show_progress_bar": "แสดงความคืบหน้า แถบ", "show_search_options": "แสดงตัวเลือกการค้นหา", + "show_slideshow_transition": "แสดงสไลค์โชว์", + "show_supporter_badge": "เครื่องหมายผู้สนับสนุน", + "show_supporter_badge_description": "แสดงเครื่องหมายผู้สนับสนุน", "shuffle": "สับเปลี่ยน", "sidebar": "แถบด้านข้าง", "sidebar_display_description": "เปิดหรือปิดแถบด้านข้าง", @@ -871,15 +1189,19 @@ "sign_up": "ลงทะเบียน", "size": "ขนาด", "skip_to_content": "ข้ามไปยังเนื้อหา", + "skip_to_folders": "ข้ามโฟล์เดอร์", + "skip_to_tags": "ข้ามแท็ก", "slideshow": "สไลด์", "slideshow_settings": "ตั้งค่าสไลด์", + "sort_albums_by": "จัดเรียงอัลบั้มโดย...", "sort_created": "จัดเรียงตามวันที่สร้าง", "sort_items": "จัดเรียงรายการ", "sort_modified": "จัดเรียงตามวันที่แก้ไข", "sort_oldest": "จัดเรียงตามเก่าสุด", "sort_people_by_similarity": "จุดเรียงบุคคลตามความคล้ายคลึง", "sort_recent": "จัดเรียงใหม่ล่าสุด", - "sort_albums_by": "จัดเรียงอัลบั้มโดย...", + "sort_title": "ไตเติ้ล", + "source": "แหล่ง", "stack": "ซ้อน", "stack_selected_photos": "", "stacktrace": "", @@ -888,28 +1210,40 @@ "status": "สถานะ", "stop_motion_photo": "ภาพวัตถุเคลื่อนไหว", "stop_photo_sharing": "หยุดแชร์รูปภาพ?", + "stop_sharing_photos_with_user": "หยุดการแชร์รูปภาพของคุณกับผู้ใช้นี้", "storage": "พื้นที่จัดเก็บ", "storage_label": "เนื้อที่จัดเก็บ", "storage_usage": "ใช้ไป {used} จาก {available} ", "submit": "ส่ง", "suggestions": "ข้อเสนอแนะ", "sunrise_on_the_beach": "พระอาทิตย์ขึ้นบนชายหาด", + "support": "สนับสนุน", + "support_and_feedback": "สนับสนุน & ข้อเสนอแนะ", + "support_third_party_description": "การติดตั้ง Immich ของคุณถูกจัดทำแพ็กเกจโดยบุคคลที่สาม ปัญหาที่คุณพบอาจเกิดจากแพ็กเกจดังกล่าว ดังนั้นโปรดแจ้งปัญหาที่เกิดขึ้นกับบุคคลที่สามก่อนโดยใช้ลิงก์ด้านล่าง", "swap_merge_direction": "สลับด้านรวม", "sync": "ซิงค์", - "template": "แทมแพลค", + "tag": "แท็ก", + "tag_created": "สร้างแท็ก: {tag}", + "template": "เท็มเพลต", "theme": "ธีม", "theme_selection": "การเลือกธีม", "theme_selection_description": "ตั้งค่าธีมให้สว่างหรือมืดโดยอัตโนมัติ อิงจากค่าของเบราว์เซอร์ของคุณ", + "third_party_resources": "ทรัพยากรบุคคลที่สาม", "time_based_memories": "ความทรงจําตามเวลา", - "timezone": "เขตเวลา", "timeline": "Timeline", + "timezone": "เขตเวลา", "to_archive": "จัดเก็บถาวร", "to_change_password": "Change password", + "to_favorite": "รายการโปรด", + "to_login": "เข้าสู่ระบบ", + "to_parent": "ไปยังบนสุด", + "to_trash": "ถังขยะ", "toggle_settings": "สลับการตั้งค่า", "toggle_theme": "สลับธีม", "total_usage": "การใช้งานรวม", "trash": "ถังขยะ", "trash_all": "ทิ้งทั้งหมด", + "trash_count": "{count, number} ในถังขยะ", "trash_no_results_message": "รูปภาพหรือวิดีโอที่ถูกลบจะอยู่ที่นี่", "trashed_items_will_be_permanently_deleted_after": "รายการที่ถูกลบจะถูกลบทิ้งภายใน {days, plural, one {# วัน} other {# วัน}}.", "type": "ประเภท", @@ -918,18 +1252,30 @@ "unhide_person": "ยกเลิกซ่อนบุคคล", "unknown": "ไม่ทราบ", "unknown_year": "ไม่ทราบปี", - "unlink_oauth": "", - "unlinked_oauth_account": "", + "unlimited": "ไม่จำกัด", + "unlink_oauth": "ยกเลิกเชื่อมต่อ OAuth", + "unlinked_oauth_account": "ยกเลิกการเชื่อมต่อ OAuth", + "unnamed_album": "อัลบั้มไม่มีชื่อ", + "unnamed_album_delete_confirmation": "คุณต้องการจะลบอัลบั้มนี้ ใช่หรือไม่ ?", + "unnamed_share": "แชร์แบบไม่ระบุชื่อ", "unselect_all": "ยกเลิกการเลือกทั้งหมด", "unstack": "หยุดซ้อน", "up_next": "ต่อไป", "updated_password": "รหัสผ่านเปลี่ยนแล้ว", "upload": "อัปโหลด", "upload_concurrency": "อัปโหลดพร้อมกัน", + "upload_status_duplicates": "รวมเข้าด้วยกัน", + "upload_status_errors": "ข้อผิดพลาด", + "upload_status_uploaded": "อัปโหลดแล้ว", + "upload_success": "อัปโหลดสำเร็จ, รีเฟรชหน้านี้ใหม่คุณจะเห็นสื่อที่เพิ่มล่าสุด", "url": "URL", "usage": "การใช้งาน", + "use_custom_date_range": "ใช้การปรับแต่งช่วงเวลา", "user": "ผู้ใช้", "user_id": "ไอดีผู้ใช้", + "user_purchase_settings": "ซื้อ", + "user_purchase_settings_description": "จัดการการซื้อ", + "user_role_set": "ตั้ง {role} ให้กับ {user}", "user_usage_detail": "รายละเอียดการใช้งานของผู้ใช้", "user_usage_stats": "สถิติการใช้งานบัญชี", "user_usage_stats_description": "ดูสถิติการใช้งานบัญชี", @@ -939,20 +1285,29 @@ "validate": "ตรวจสอบ", "variables": "ตัวแปร", "version": "รุ่น", + "version_announcement_message": "สวัสดี! Immich เวอร์ชันใหม่พร้อมให้ใช้งานแล้ว โปรดใช้เวลาสักครู่เพื่ออ่าน หมายเหตุการเผยแพร่ เพื่อให้แน่ใจว่าการตั้งค่าของคุณได้รับการอัปเดตแล้ว เพื่อป้องกันการกำหนดค่าผิดพลาด โดยเฉพาะอย่างยิ่งหากคุณใช้ WatchTower หรือกลไกอื่นๆ ที่จัดการการอัปเดตอินสแตนซ์ Immich ของคุณโดยอัตโนมัติ", + "version_history": "การเปลี่ยนแปลง", + "version_history_item": "ติดตั้ง {version} วันที่ {date}", "video": "วิดีโอ", - "video_hover_setting": "เล่นวิดีโอตัวอย่างเมื่อจ่อ", + "video_hover_setting": "เล่นวิดีโอแบบย่อเมื่อเลื่อนเมาส์อยู่บน", "video_hover_setting_description": "เล่นวิดีโอตัวอย่างเมื่อเมาส์จ่อข้างบน เมื่อปิดใช้งาน วิดีโอตัวอย่างยังสามารถเล่นได้โดยกดปุ่มเล่น", "videos": "วิดีโอ", + "view": "ดู", + "view_album": "ดูอัลบั้ม", "view_all": "ดูทั้งหมด", "view_all_users": "ดูผู้ใช้ทั้งหมด", + "view_in_timeline": "ดูไทม์ไลน์", "view_links": "ดูลิงก์", "view_next_asset": "ดูสื่อถัดไป", "view_previous_asset": "ดูสื่อก่อนหน้า", + "visibility_changed": "เปลี่ยนแปลงการมองเห็นสำหรับ {count, plural, one {# บุคคล} other {# บุคคล}}", "waiting": "กำลังรอ", + "warning": "คำเตือน", "week": "สัปดาห์", "welcome": "ยินดีต้อนรับ", "welcome_to_immich": "ยินดีต้อนรับสู่ immich", "year": "ปี", + "years_ago": "{years, plural, one {# ปี} other {# ปี}} ที่แล้ว", "yes": "ใช่", "you_dont_have_any_shared_links": "คุณไม่ได้มีลิงก์ที่แชร์", "zoom_image": "ซูมรูปภาพ" diff --git a/i18n/tr.json b/i18n/tr.json index 10bc00cbf4..3f766362b2 100644 --- a/i18n/tr.json +++ b/i18n/tr.json @@ -20,7 +20,7 @@ "add_partner": "Partner ekle", "add_path": "Yol ekle", "add_photos": "Fotoğraf ekle", - "add_to": "Şuraya ekle...", + "add_to": "Şuraya ekle…", "add_to_album": "Albüme ekle", "add_to_shared_album": "Paylaşılan albüme ekle", "add_url": "URL ekle", @@ -219,7 +219,7 @@ "reset_settings_to_default": "Ayarları varsayılana sıfırla", "reset_settings_to_recent_saved": "Ayarları kaydedilmiş önceki ayarlara döndür", "scanning_library": "Kütüphaneyi tarama", - "search_jobs": "Görevleri Ara...", + "search_jobs": "Görevler ara…", "send_welcome_email": "Hoş geldin e-postası gönder", "server_external_domain_settings": "Dış domain", "server_external_domain_settings_description": "Paylaşılan fotoğraflar için domain, http(s):// dahil", @@ -326,7 +326,7 @@ "transcoding_transcode_policy_description": "Bir videonun ne zaman kod dönüştürülmesi gerektiğine ilişkin ilke. Dönüştürme devre dışı bırakılmadığı sürece HDR videolar her zaman dönüştürülür.", "transcoding_two_pass_encoding": "İki geçişli kodlama", "transcoding_two_pass_encoding_setting_description": "Daha iyi kodlanmış videolar üretmek için iki geçişte kod dönüştürün. Maksimum bit hızı etkinleştirildiğinde (H.264 ve HEVC ile çalışması için gereklidir), bu mod maksimum bit hızına dayalı bir bit hızı aralığı kullanır ve CRF'yi yok sayar. VP9 için, maksimum bit hızı devre dışı bırakılırsa CRF kullanılabilir.", - "transcoding_video_codec": "Video kodek", + "transcoding_video_codec": "Video kodlayıcı", "transcoding_video_codec_description": "VP9 yüksek verimliliğe ve web uyumluluğuna sahiptir, ancak kod dönüştürme işlemi daha uzun sürer. HEVC benzer performans gösterir ancak web uyumluluğu daha düşüktür. H.264 geniş çapta uyumludur ve kod dönüştürmesi hızlıdır, ancak çok daha büyük dosyalar üretir. AV1 en verimli codec'tir ancak eski cihazlarda desteği yoktur.", "trash_enabled_description": "Çöp özelliklerini etkinleştir", "trash_number_of_days": "Gün sayısı", @@ -406,17 +406,17 @@ "are_these_the_same_person": "Bunlar aynı kişi mi?", "are_you_sure_to_do_this": "Bunu yapmak istediğinize emin misiniz?", "asset_added_to_album": "Albüme eklendi", - "asset_adding_to_album": "Şu albüme ekleniyor...", + "asset_adding_to_album": "Albüme ekleniyor…", "asset_description_updated": "Varlık açıklaması güncellendi", "asset_filename_is_offline": "Varlık {filename} çevrimdışı", "asset_has_unassigned_faces": "Varlık, atanmamış yüzler içeriyor", - "asset_hashing": "Karma (hashleme) oluşturuluyor...", + "asset_hashing": "Karma (hashleme) oluşturuluyor…", "asset_offline": "Varlık Çevrim Dışı", "asset_offline_description": "Bu harici varlık artık diskte bulunmuyor. Yardım için lütfen Immich yöneticinizle iletişime geçin.", "asset_skipped": "Atlandı", "asset_skipped_in_trash": "Çöpte", "asset_uploaded": "Yüklendi", - "asset_uploading": "Yükleniyor...", + "asset_uploading": "Yükleniyor…", "assets": "Varlıklar", "assets_added_count": "{count, plural, one {# varlık eklendi} other {# varlık eklendi}}", "assets_added_to_album_count": "{count, plural, one {# varlık} other {# varlık}} albüme eklendi", @@ -766,8 +766,10 @@ "go_to_folder": "Klasöre git", "go_to_search": "Aramaya git", "group_albums_by": "Albümleri gruplandır...", + "group_country": "Ülkeye göre grupla", "group_no": "Gruplama yok", "group_owner": "Sahibe göre gruplandır", + "group_places_by": "Yerleri gruplandır...", "group_year": "Yıla göre grupla", "has_quota": "Kota var", "hi_user": "Merhaba {name} {email}", @@ -800,6 +802,7 @@ "include_shared_albums": "Paylaşılmış albümleri dahil et", "include_shared_partner_assets": "Paylaşılan ortak varlıkları dahil et", "individual_share": "Bireysel paylaşım", + "individual_shares": "Kişisel paylaşımlar", "info": "Bilgi", "interval": { "day_at_onepm": "Her gün saat 13:00'te", @@ -822,6 +825,7 @@ "latest_version": "En son versiyon", "latitude": "Enlem", "leave": "Ayrıl", + "lens_model": "Mercek modeli", "let_others_respond": "Diğerlerinin yanıt vermesine izin ver", "level": "Seviye", "library": "Kütüphane", @@ -984,6 +988,7 @@ "pick_a_location": "Bir konum seçin", "place": "Konum", "places": "Konumlar", + "places_count": "{count, plural, one {{count, number} yer} other {{count, number} yer}}", "play": "Oynat", "play_memories": "Anıları oynat", "play_motion_photo": "Hareketli fotoğrafı oynat", @@ -1107,12 +1112,15 @@ "search": "Ara", "search_albums": "Albüm ara", "search_by_context": "Bağlama göre ara", + "search_by_description": "Açıklamaya göre ara", + "search_by_description_example": "Sapa'da yürüyüş günü", "search_by_filename": "Dosya adına göre ara", "search_by_filename_example": "Örn. IMG_1234.JPG veya PNG", "search_camera_make": "Kamera markasına göre ara...", "search_camera_model": "Kamera modeline göre ara...", "search_city": "Şehre göre ara...", "search_country": "Ülkeye göre ara...", + "search_for": "Araştır", "search_for_existing_person": "Mevcut bir kişiyi ara", "search_no_people": "Kişi yok", "search_no_people_named": "\"{name}\" isimli bir kişi yok", @@ -1165,6 +1173,7 @@ "shared_from_partner": "{partner} tarafından paylaşılan fotoğraflar", "shared_link_options": "Paylaşılan bağlantı seçenekleri", "shared_links": "Paylaşılan bağlantılar", + "shared_links_description": "Fotoğraf ve videoları bir bağlantı ile paylaş", "shared_photos_and_videos_count": "{assetCount, plural, one {# paylaşılan fotoğraf veya video.} other {# paylaşılan fotoğraf & video.}}", "shared_with_partner": "{partner} ile paylaşıldı", "sharing": "Paylaşılıyor", @@ -1187,6 +1196,7 @@ "show_person_options": "Kişi ayarlarını göster", "show_progress_bar": "İlerleme çubuğunu göster", "show_search_options": "Arama ayarlarını göster", + "show_shared_links": "Paylaşılan bağlantıları göster", "show_slideshow_transition": "Slayt geçişini göster", "show_supporter_badge": "Destekçi rozeti", "show_supporter_badge_description": "Destekçi rozetini göster", @@ -1274,6 +1284,7 @@ "unfavorite": "Favorilerden kaldır", "unhide_person": "Kişiyi göster", "unknown": "Bilinmeyen", + "unknown_country": "Bilinmeyen ülke", "unknown_year": "Bilinmeyen YIl", "unlimited": "Sınırsız", "unlink_motion_video": "Hareketli video bağlantısını kaldır", diff --git a/i18n/uk.json b/i18n/uk.json index 773b9b7c73..1cb6b0e5a6 100644 --- a/i18n/uk.json +++ b/i18n/uk.json @@ -20,7 +20,7 @@ "add_partner": "Додати партнера", "add_path": "Додати шлях", "add_photos": "Додати знімки", - "add_to": "Додати у...", + "add_to": "Додати у…", "add_to_album": "Додати у альбом", "add_to_shared_album": "Додати у спільний альбом", "add_url": "Додати URL", @@ -219,7 +219,7 @@ "reset_settings_to_default": "Скинути налаштування до заводських значень", "reset_settings_to_recent_saved": "Скинути налаштування до недавно збережених налаштувань", "scanning_library": "Сканування бібліотеки", - "search_jobs": "Пошук завдань...", + "search_jobs": "Пошук завдань…", "send_welcome_email": "Надіслати лист з вітанням", "server_external_domain_settings": "Зовнішній домен", "server_external_domain_settings_description": "Домен для публічних загальнодоступних посилань, включаючи http(s)://", @@ -406,17 +406,17 @@ "are_these_the_same_person": "Це та сама людина?", "are_you_sure_to_do_this": "Ви впевнені, що хочете це зробити?", "asset_added_to_album": "Додано до альбому", - "asset_adding_to_album": "Додати до альбому...", + "asset_adding_to_album": "Додати до альбому…", "asset_description_updated": "Оновлено опис ресурсу", "asset_filename_is_offline": "Ресурс {filename} відключено", "asset_has_unassigned_faces": "Є нерозпізнані обличчя", - "asset_hashing": "Хешування...", + "asset_hashing": "Хешування…", "asset_offline": "Актив вимкнено", "asset_offline_description": "Цей зовнішній актив більше не знайдено на диску. Будь ласка, зверніться до адміністратора Immich за допомогою.", "asset_skipped": "Пропущено", "asset_skipped_in_trash": "У смітнику", "asset_uploaded": "Завантажено", - "asset_uploading": "Завантаження...", + "asset_uploading": "Завантаження…", "assets": "елементи", "assets_added_count": "Додано {count, plural, one {# ресурс} few {# ресурси} other {# ресурсів}}", "assets_added_to_album_count": "Додано {count, plural, one {# ресурс} few {# ресурси} other {# ресурсів}} до альбому", @@ -766,8 +766,10 @@ "go_to_folder": "Перейти до папки", "go_to_search": "Перейти до пошуку", "group_albums_by": "Групувати альбоми за...", + "group_country": "Групувати за країною", "group_no": "Без групування", "group_owner": "За власником", + "group_places_by": "Групувати місця за...", "group_year": "За роком", "has_quota": "Квота", "hi_user": "Привіт {name} ({email})", @@ -800,6 +802,7 @@ "include_shared_albums": "Включити спільні альбоми", "include_shared_partner_assets": "Включайте спільні партнерські активи", "individual_share": "Індивідуальний доступ", + "individual_shares": "Окремі спільні доступи", "info": "Інформація", "interval": { "day_at_onepm": "Щодня о 13:00", @@ -822,6 +825,7 @@ "latest_version": "Остання версія", "latitude": "Широта", "leave": "Покинути", + "lens_model": "Модель об'єктива", "let_others_respond": "Дозволити іншим відповідати", "level": "Рівень", "library": "Бібліотека", @@ -984,6 +988,7 @@ "pick_a_location": "Виберіть місце розташування", "place": "Місце", "places": "Місця", + "places_count": "{count, plural, one {{count, number} Місце} other {{count, number} Місця}}", "play": "Відтворити", "play_memories": "Відтворити спогади", "play_motion_photo": "Відтворювати рухомі фото", @@ -1107,12 +1112,15 @@ "search": "Пошук", "search_albums": "Шукати альбоми", "search_by_context": "Пошук за контекстом", + "search_by_description": "Пошук за описом", + "search_by_description_example": "Похідний день у Сапі", "search_by_filename": "Пошук за назвою або розширенням файлу", "search_by_filename_example": "Наприклад, IMG_1234.JPG або PNG", "search_camera_make": "Пошук виробника камери...", "search_camera_model": "Пошук моделі камери...", "search_city": "Пошук міста...", "search_country": "Пошук країни...", + "search_for": "Шукати для", "search_for_existing_person": "Пошук існуючої особи", "search_no_people": "Немає людей", "search_no_people_named": "Немає осіб з іменем \"{name}\"", @@ -1165,6 +1173,7 @@ "shared_from_partner": "Фото від {partner}", "shared_link_options": "Опції спільних посилань", "shared_links": "Спільні посилання", + "shared_links_description": "Діліться фото та відео за посиланням", "shared_photos_and_videos_count": "{assetCount, plural, other {# спільні фотографії та відео.}}", "shared_with_partner": "Спільно з {partner}", "sharing": "Спільні", @@ -1187,6 +1196,7 @@ "show_person_options": "Показати параметри людини", "show_progress_bar": "Показати індикатор прогресу", "show_search_options": "Показати параметри пошуку", + "show_shared_links": "Показати спільні посилання", "show_slideshow_transition": "Показати перехід слайд-шоу", "show_supporter_badge": "Значок підтримки", "show_supporter_badge_description": "Показати значок підтримки", @@ -1274,6 +1284,7 @@ "unfavorite": "Видалити з улюблених", "unhide_person": "Розкрити особу", "unknown": "Невідомо", + "unknown_country": "Невідома країна", "unknown_year": "Невідомий рік", "unlimited": "Без обмежень", "unlink_motion_video": "Від'єднати рухоме відео", diff --git a/i18n/vi.json b/i18n/vi.json index ca393b2b25..9983f5a057 100644 --- a/i18n/vi.json +++ b/i18n/vi.json @@ -1081,6 +1081,8 @@ "search": "Tìm kiếm", "search_albums": "Tìm kiếm album", "search_by_context": "Tìm kiếm theo ngữ cảnh", + "search_by_description": "Tìm kiếm theo nội dung", + "search_by_description_example": "Dạo chơi Sa Pa", "search_by_filename": "Tìm kiếm theo tên hoặc phần mở rộng tập tin", "search_by_filename_example": "Ví dụ: IMG_1234.JPG hoặc PNG", "search_camera_make": "Tìm kiếm thương hiệu máy ảnh...", diff --git a/i18n/zh_Hant.json b/i18n/zh_Hant.json index 51705d11e6..443fa2cd71 100644 --- a/i18n/zh_Hant.json +++ b/i18n/zh_Hant.json @@ -2,7 +2,7 @@ "about": "關於", "account": "帳號", "account_settings": "帳號設定", - "acknowledge": "收到", + "acknowledge": "明白", "action": "操作", "actions": "操作", "active": "處理中", @@ -70,15 +70,15 @@ "image_prefer_wide_gamut": "偏好廣色域", "image_prefer_wide_gamut_setting_description": "使用 Display P3 來製作縮圖。這可以更好地保留廣色域圖片的鮮豔度,但在舊版瀏覽器或舊設備上,圖片可能會顯示不同。sRGB 圖片會維持 sRGB 以避免顏色變化。", "image_preview_description": "刪除中等尺寸圖片的詳細資料,當選擇看指定項目和機器學習時使用", - "image_preview_quality_description": "預覽品質爲 1 ~ 100。數值越大品質越高,但會產生較大的檔案,且可能降低應用程式的響應速度。而數值較小可能會影響機器學習品質。", + "image_preview_quality_description": "預覽品質為 1 ~ 100。數值越大品質越高,但會產生較大的檔案,且可能降低應用程式的回應速度。而數值較小可能會影響機器學習品質。", "image_preview_title": "預覽設定", "image_quality": "品質", "image_resolution": "解析度", - "image_resolution_description": "較高的解析度可以保留更多細節,但編碼時間較長,檔案較大且可能降低應用程式的響應速度。", + "image_resolution_description": "較高的解析度可以保留更多細節,但編碼時間較長,檔案較大且可能降低應用程式的回應速度。", "image_settings": "圖片設定", "image_settings_description": "管理產生圖片的品質和解析度", "image_thumbnail_description": "刪除縮圖的詳細資料,在快速瀏覽重要時間軸時或大量照片時使用", - "image_thumbnail_quality_description": "縮圖品質爲 1 ~ 100。數值越大品質越高,但會產生較大的檔案,且可能降低應用程式的響應速度。", + "image_thumbnail_quality_description": "縮圖品質為 1 ~ 100。數值越大品質越高,但會產生較大的檔案,且可能降低應用程式的回應速度。", "image_thumbnail_title": "縮圖設定", "job_concurrency": "{job}並行", "job_created": "已建立作業", @@ -104,7 +104,7 @@ "logging_level_description": "啟用時的記錄層級。", "logging_settings": "記錄檔", "machine_learning_clip_model": "CLIP 模型", - "machine_learning_clip_model_description": "這裏有份 CLIP 模型名單。註:更換模型後須對所有圖片重新執行「智慧搜尋」作業。", + "machine_learning_clip_model_description": "這裡有份 CLIP 模型名單。註:更換模型後須對所有圖片重新執行「智慧搜尋」作業。", "machine_learning_duplicate_detection": "重複項目偵測", "machine_learning_duplicate_detection_enabled": "啟用重複項目偵測", "machine_learning_duplicate_detection_enabled_description": "即使停用,完全一樣的素材仍會被忽略。", @@ -114,17 +114,17 @@ "machine_learning_facial_recognition": "臉部辨識", "machine_learning_facial_recognition_description": "偵測、認出並對圖片中的臉孔分組", "machine_learning_facial_recognition_model": "人臉辨識模型", - "machine_learning_facial_recognition_model_description": "模型順序由大至小排列。大的模型較慢且使用較多記憶體,但成效較嘉。更換模型後須對所有影像重新執行「人臉辨識」。", + "machine_learning_facial_recognition_model_description": "模型順序由大至小排列。大的模型較慢且使用較多記憶體,但成效較佳。更換模型後需對所有影像重新執行「人臉辨識」。", "machine_learning_facial_recognition_setting": "啟用人臉辨識", "machine_learning_facial_recognition_setting_description": "若停用,影像將不會產生人臉特徵編碼,從而「探索」頁面不會有「人物」功能。", - "machine_learning_max_detection_distance": "針測距離上限", + "machine_learning_max_detection_distance": "偵測距離上限", "machine_learning_max_detection_distance_description": "若兩張影像間的距離小於此將被判斷為相同,範圍為 0.001-0.1。數值越高能偵測到越多重複,但也更有可能誤判。", "machine_learning_max_recognition_distance": "分辨距離上限", "machine_learning_max_recognition_distance_description": "若兩張人臉間的距離小於此將被判斷為相同人物,範圍為 0-2。數值降低能減少兩人被混在一起的可能性,數值提升能減少同一人被當作不同臉的可能性。由於合並比拆分容易,建議將數值調小。", "machine_learning_min_detection_score": "最低檢測分數", "machine_learning_min_detection_score_description": "最低信任分辨率,從0到1。低值會偵測更多的面孔,但可能導致誤報。", - "machine_learning_min_recognized_faces": "最少被認出的臉孔", - "machine_learning_min_recognized_faces_description": "要創建一個人的最低認可面數。 增加此項數目使面部識別更為準確,但以增加可能不把面孔識別於任何人的機會為代價.", + "machine_learning_min_recognized_faces": "最低臉部辨識數量", + "machine_learning_min_recognized_faces_description": "歸納出新人物的最低臉部數量。調高此數值可以讓臉部辨識更準確,但也可能會讓較少出現的臉孔無法被歸納到人物清單。", "machine_learning_settings": "機器學習設定", "machine_learning_settings_description": "管理機器學習的功能和設定", "machine_learning_smart_search": "智慧搜尋", @@ -147,7 +147,7 @@ "map_settings": "地圖", "map_settings_description": "管理地圖設定", "map_style_description": "地圖主題(style.json)的網址", - "metadata_extraction_job": "擷取元資料", + "metadata_extraction_job": "擷取詮釋資料", "metadata_extraction_job_description": "擷取所有檔案的 GPS、臉孔、解析度等原始詳細資料", "metadata_faces_import_setting": "啟用臉孔匯入", "metadata_faces_import_setting_description": "從圖片的 EXIF 資料和側接檔案匯入臉孔", @@ -188,17 +188,17 @@ "oauth_mobile_redirect_uri": "移動端重定向 URI", "oauth_mobile_redirect_uri_override": "移動端重定向 URI 覆蓋", "oauth_mobile_redirect_uri_override_description": "當 OAuth 提供者不允許使用行動 URI(如「'{callback}'」)時啟用", - "oauth_profile_signing_algorithm": "用戶檔簽名算法", - "oauth_profile_signing_algorithm_description": "用於簽署用戶檔的算法。", + "oauth_profile_signing_algorithm": "設定檔簽章演算法", + "oauth_profile_signing_algorithm_description": "用於簽署使用者設定檔的演算法。", "oauth_scope": "範圍", "oauth_settings": "OAuth", "oauth_settings_description": "管理 OAuth 登入設定", "oauth_settings_more_details": "欲瞭解此功能,請參閱說明書。", - "oauth_signing_algorithm": "簽名算法", + "oauth_signing_algorithm": "簽章演算法", "oauth_storage_label_claim": "儲存標籤宣告", - "oauth_storage_label_claim_description": "自動將使用者的儲存標籤定爲此宣告之值。", + "oauth_storage_label_claim_description": "自動將使用者的儲存標籤定為此宣告之值。", "oauth_storage_quota_claim": "儲存配額宣告", - "oauth_storage_quota_claim_description": "自動將使用者的儲存配額定爲此宣告之值。", + "oauth_storage_quota_claim_description": "自動將使用者的儲存配額定為此宣告之值。", "oauth_storage_quota_default": "預設儲存配額(GiB)", "oauth_storage_quota_default_description": "未宣告時所使用的配額(單位:GiB)(輸入 0 表示不限制配額)。", "offline_paths": "失效路徑", @@ -211,7 +211,7 @@ "quota_size_gib": "配額(GiB)", "refreshing_all_libraries": "正在重新整理所有圖庫", "registration": "管理者註冊", - "registration_description": "由於您是本系統的首位使用者,因此將您指派爲負責管理本系統的管理者,其他使用者須由您協助建立帳號。", + "registration_description": "由於您是本系統的首位使用者,因此將您指派為負責管理本系統的管理者,其他使用者須由您協助建立帳號。", "repair_all": "全部糾正", "repair_matched_items": "有 {count, plural, other {# 個項目相符}}", "repaired_items": "已糾正 {count, plural, other {# 個項目}}", @@ -237,7 +237,7 @@ "storage_template_date_time_sample": "時間樣式 {date}", "storage_template_enable_description": "啟用存儲模板引擎", "storage_template_hash_verification_enabled": "散列函数驗證已啟用", - "storage_template_hash_verification_enabled_description": "啟用散列函数驗證,除非您知道自己正在做的事,否則請勿禁用此功能", + "storage_template_hash_verification_enabled_description": "啟用散列函数驗證,除非您很清楚地知道這個選項的作用,否則請勿停用此功能", "storage_template_migration": "存儲模板遷移", "storage_template_migration_description": "將當前的 {template} 應用於先前上傳的檔案", "storage_template_migration_info": "模板更改僅適用於新檔案。若要追溯應用模板至先前上傳的檔案,請運行 {job}。", @@ -266,47 +266,47 @@ "theme_settings_description": "自訂 Immich 的網頁界面", "these_files_matched_by_checksum": "這些檔案的核對和(Checksum)是相符的", "thumbnail_generation_job": "產生縮圖", - "thumbnail_generation_job_description": "爲每個檔案產生大、小及模糊縮圖,也爲每位人物產生縮圖", + "thumbnail_generation_job_description": "為每個檔案產生大、小及模糊縮圖,也為每位人物產生縮圖", "transcoding_acceleration_api": "加速 API", - "transcoding_acceleration_api_description": "該 API 將用您的設備加速轉碼。設置是“盡力而為”:如果失敗,它將退回到軟件轉碼。VP9 轉碼是否可行取決於您的硬件。", + "transcoding_acceleration_api_description": "該 API 將用您的設備加速轉碼。設置是“盡力而為”:如果失敗,它將退回到軟體轉碼。VP9 轉碼是否可行取決於您的硬體。", "transcoding_acceleration_nvenc": "NVENC(需要 NVIDIA GPU)", "transcoding_acceleration_qsv": "快速同步(需要第七代或高於第七代的 Intel CPU)", "transcoding_acceleration_rkmpp": "RKMPP(僅適用於 Rockchip SoC)", "transcoding_acceleration_vaapi": "VAAPI", - "transcoding_accepted_audio_codecs": "接受的音頻編解碼器", - "transcoding_accepted_audio_codecs_description": "選擇不需要轉碼的音頻編解碼器。僅用於某些轉碼策略。", + "transcoding_accepted_audio_codecs": "支援的音訊編碼器", + "transcoding_accepted_audio_codecs_description": "選擇不需要轉碼的音訊編碼格式。僅適用於某些轉碼策略。", "transcoding_accepted_containers": "接受的容器格式", "transcoding_accepted_containers_description": "選擇不需要重新封裝為 MP4 的容器格式。僅用於某些轉碼策略。", "transcoding_accepted_video_codecs": "支援的影片編碼器", - "transcoding_accepted_video_codecs_description": "選擇不需要轉碼的視頻編解碼器。僅用於某些轉碼策略。", + "transcoding_accepted_video_codecs_description": "選擇不需要轉碼的視訊編碼格式。僅適用於某些轉碼策略。", "transcoding_advanced_options_description": "大多數使用者不需要更改的選項", - "transcoding_audio_codec": "音頻編解碼器", - "transcoding_audio_codec_description": "Opus 是音質最高的選擇,但會與舊設備或軟件有較低的兼容性。", + "transcoding_audio_codec": "音訊編碼器", + "transcoding_audio_codec_description": "Opus 是音質最好的選項,但與舊設備或舊版軟體的相容性較低。", "transcoding_bitrate_description": "高於最大位元速率或格式不被支援的影片", "transcoding_codecs_learn_more": "欲瞭解此處使用的術語,請參閱 FFmpeg 說明書中的 H.264 編解碼器HEVC 編解碼器VP9 編解碼器。", - "transcoding_constant_quality_mode": "恆定質量模式", - "transcoding_constant_quality_mode_description": "ICQ 比 CQP 更好,但某些硬件加速設備不支持此模式。設置此選項時,會在使用基於質量的編碼時偏好指定的模式。由於 NVENC 不支持 ICQ,此選項對其無效。", + "transcoding_constant_quality_mode": "固定品質模式", + "transcoding_constant_quality_mode_description": "ICQ 比 CQP 更好,但某些硬體加速設備不支援此模式。設定此選項時,會在使用基於品質的編碼時偏好指定的模式。此選項對 NVENC 無效,因為 NVENC 不支援 ICQ。", "transcoding_constant_rate_factor": "恆定速率因子(-crf)", - "transcoding_constant_rate_factor_description": "視頻質量級別。典型值為 H.264 的 23、HEVC 的 28、VP9 的 31 和 AV1 的 35。數值越低,質量越高,但會產生較大的文件。", + "transcoding_constant_rate_factor_description": "視訊品質等級。典型值為 H.264 的 23、HEVC 的 28、VP9 的 31 和 AV1 的 35。數值越低,品質越好,但會產生較大的檔案。", "transcoding_disabled_description": "不轉碼影片,可能會讓某些客戶端無法正常播放", "transcoding_encoding_options": "編碼選項", "transcoding_encoding_options_description": "設定編碼影片的編解碼器、解析度、品質和其他選項", "transcoding_hardware_acceleration": "硬體加速", - "transcoding_hardware_acceleration_description": "實驗性功能;速度更快,但在相同比特率下質量較低", + "transcoding_hardware_acceleration_description": "實驗性功能;速度更快,但在相同位元速率下品質較差", "transcoding_hardware_decoding": "硬體解碼", "transcoding_hardware_decoding_setting_description": "不只加速編碼,還啟用端對端加速。可能不支援某些影片。", "transcoding_hevc_codec": "HEVC 編解碼器", "transcoding_max_b_frames": "最大 B 幀數", - "transcoding_max_b_frames_description": "更高的值可以提高壓縮效率,但會降低編碼速度。在舊設備上可能不兼容硬件加速。0 表示禁用 B 幀,而 -1 則會自動設置此值。", + "transcoding_max_b_frames_description": "較高的數值可提升壓縮效率,但會降低編碼速度。可能與較舊設備的硬體加速不相容。設定為 0 時會停用 B-frames,而 -1 則會自動設定此數值。", "transcoding_max_bitrate": "最大位元速率", - "transcoding_max_bitrate_description": "設置最大比特率可以使文件大小更具可預測性,但會稍微降低質量。在 720p 分辨率下,典型值為 VP9 或 HEVC 的 2600k,或 H.264 的 4500k。設置為 0 則禁用此功能。", + "transcoding_max_bitrate_description": "設定最大位元速率可以使檔案大小更具可預測性,但會稍微降低品質。在 720p 解析度下,典型值為 VP9 或 HEVC 的 2600k,或 H.264 的 4500k。設置為 0 停用此功能。", "transcoding_max_keyframe_interval": "最大關鍵幀間隔", - "transcoding_max_keyframe_interval_description": "設置關鍵幀之間的最大幀距。較低的值會降低壓縮效率,但可以改善尋找時間,並可能改善快速運動場景中的質量。0 會自動設置此值。", + "transcoding_max_keyframe_interval_description": "設置關鍵幀之間的最大幀距。較低的值會降低壓縮效率,但可以改善搜尋時間,並有可能會改善快速變動場景的品質。0 會自動設置此值。", "transcoding_optimal_description": "高於目標解析度或格式不被支援的影片", "transcoding_policy": "轉碼策略", "transcoding_policy_description": "設定影片進行轉碼的條件", - "transcoding_preferred_hardware_device": "首選硬件設備", - "transcoding_preferred_hardware_device_description": "僅適用於 VAAPI 和 QSV。設置用於硬件轉碼的 DRI 節點。", + "transcoding_preferred_hardware_device": "首選硬體設備", + "transcoding_preferred_hardware_device_description": "僅適用於 VAAPI 和 QSV。設定用於硬體轉碼的 DRI 節點。", "transcoding_preset_preset": "預設值(-preset)", "transcoding_preset_preset_description": "壓縮速度。在針對特定位元速率時,較慢的預設值會減少檔案大小並提高品質。VP9 會忽略高於「faster」的速度。", "transcoding_reference_frames": "參考幀數", @@ -315,19 +315,19 @@ "transcoding_settings": "影片轉碼", "transcoding_settings_description": "管理影片的解析度和編碼資訊", "transcoding_target_resolution": "目標解析度", - "transcoding_target_resolution_description": "較高的解析度可以保留更多細節,但編碼時間較長,檔案也較大,且可能降低應用程式的響應速度。", + "transcoding_target_resolution_description": "較高的解析度可以保留更多細節,但編碼時間較長,檔案也較大,且可能降低應用程式的回應速度。", "transcoding_temporal_aq": "時間自適應量化(Temporal AQ)", - "transcoding_temporal_aq_description": "僅適用於 NVENC。提高高細節、低運動場景的質量。可能與舊設備不兼容。", - "transcoding_threads": "線程數量", + "transcoding_temporal_aq_description": "僅適用於 NVENC,可提升高細節、低動態場景的畫質。可能與較舊的設備不相容。", + "transcoding_threads": "執行緒數量", "transcoding_threads_description": "較高的值會加快編碼速度,但會減少伺服器在運行過程中處理其他任務的空間。此值不應超過 CPU 核心數。設置為 0 可以最大化利用率。", "transcoding_tone_mapping": "色調映射", - "transcoding_tone_mapping_description": "在將 HDR 視頻轉換為 SDR 時,嘗試保留其外觀。每種算法在顏色、細節和亮度方面都有不同的權衡。Hable 保留細節,Mobius 保留顏色,Reinhard 保留亮度。", + "transcoding_tone_mapping_description": "在將 HDR 影片轉換為 SDR 時,盡量維持原始觀感。每種演算法在色彩、細節和亮度方面都有不同的權衡。Hable 保留細節,Mobius 保留色彩,Reinhard 保留亮度。", "transcoding_transcode_policy": "轉碼策略", - "transcoding_transcode_policy_description": "視頻何時應進行轉碼的策略。HDR 視頻將始終進行轉碼(除非禁用轉碼)。", - "transcoding_two_pass_encoding": "雙通道編碼", - "transcoding_two_pass_encoding_setting_description": "使用雙通道編碼以產生更高質量的編碼視頻。當啟用最大比特率時(對 H.264 和 HEVC 有效),此模式使用基於最大比特率的比特率範圍,並忽略 CRF。對於 VP9,如果禁用最大比特率,可以使用 CRF。", - "transcoding_video_codec": "視頻編解碼器", - "transcoding_video_codec_description": "VP9 具有高效能和網頁兼容性,但轉碼時間較長。HEVC 性能相似,但網頁兼容性較低。H.264 兼容性廣泛且轉碼速度快,但生成的文件較大。AV1 是最有效的編解碼器,但在舊設備上支持度不足。", + "transcoding_transcode_policy_description": "影片何時應進行轉碼的策略。HDR 影片一定會轉碼(除非停用轉碼)。", + "transcoding_two_pass_encoding": "兩階段編碼", + "transcoding_two_pass_encoding_setting_description": "使用兩階段編碼來產生品質更佳的編碼影片。當啟用最大位元速率時(H.264 和 HEVC 必須啟用此選項才能運作),此模式會以最大位元速率來調整位元速率範圍,並忽略 CRF。對於 VP9,如果停用最大位元速率,可以使用 CRF。", + "transcoding_video_codec": "視訊編碼器", + "transcoding_video_codec_description": "VP9 具有高效能且相容於網頁,但轉碼時間較長。HEVC 的效能相近,但網頁相容性較低。H.264 具有廣泛的相容性且轉碼速度快,但產生的檔案較大。AV1 是目前效率最好的編解碼器,但較舊設備不支援。", "trash_enabled_description": "啟用垃圾桶功能", "trash_number_of_days": "日數", "trash_number_of_days_description": "永久刪除之前,將檔案保留在垃圾桶中的日數", @@ -339,8 +339,8 @@ "user_delete_delay": "{user} 的帳號和項目將於 {delay, plural, other {# 天}}後永久刪除。", "user_delete_delay_settings": "延後刪除", "user_delete_delay_settings_description": "移除後,永久刪除使用者帳號和檔案的天數。使用者刪除作業會在午夜檢查是否有可以刪除的使用者。變更這項設定後,會在下次執行時檢查。", - "user_delete_immediately": "{user} 的帳戶和資產將被立即排隊進行永久刪除。", - "user_delete_immediately_checkbox": "將用戶和資產排隊進行立即刪除", + "user_delete_immediately": "{user} 的帳號和項目將立即永久刪除。", + "user_delete_immediately_checkbox": "將使用者和項目立即刪除", "user_management": "使用者管理", "user_password_has_been_reset": "使用者密碼已重設:", "user_password_reset_description": "請提供使用者臨時密碼,並告知下次登入時需要更改密碼。", @@ -386,7 +386,7 @@ "all": "全部", "all_albums": "所有相簿", "all_people": "所有人", - "all_videos": "所有視頻", + "all_videos": "所有影片", "allow_dark_mode": "允許深色模式", "allow_edits": "允許編輯", "allow_public_user_to_download": "開放給使用者下載", @@ -394,7 +394,7 @@ "anti_clockwise": "逆時針", "api_key": "API 金鑰", "api_key_description": "此值僅顯示一次。請確保在關閉窗口之前複製它。", - "api_key_empty": "您的 API 金鑰名稱不能爲空", + "api_key_empty": "您的 API 金鑰名稱不能為空", "api_keys": "API 金鑰", "app_settings": "應用程式設定", "appears_in": "出現在", @@ -410,7 +410,7 @@ "asset_description_updated": "檔案描述已更新", "asset_filename_is_offline": "檔案 {filename} 離線了", "asset_has_unassigned_faces": "檔案中有未指定的臉孔", - "asset_hashing": "Hashing中...", + "asset_hashing": "計算雜湊值…", "asset_offline": "檔案離線", "asset_offline_description": "磁碟中找不到此外部檔案。請向您的 Immich 管理員尋求協助。", "asset_skipped": "已略過", @@ -418,7 +418,7 @@ "asset_uploaded": "已上傳", "asset_uploading": "上傳中…", "assets": "檔案", - "assets_added_count": "已添加 {count, plural, one {# 個資產} other {# 個資產}}", + "assets_added_count": "已新增 {count, plural, one {# 個項目} other {# 個項目}}", "assets_added_to_album_count": "已將 {count, plural, other {# 個檔案}}加入相簿", "assets_added_to_name_count": "已將 {count, plural, other {# 個檔案}}加入{hasName, select, true {{name}} other {新相簿}}", "assets_count": "{count, plural, one {# 個檔案} other {# 個檔案}}", @@ -430,7 +430,7 @@ "assets_trashed_count": "已丟掉 {count, plural, other {# 個檔案}}", "assets_were_part_of_album_count": "{count, plural, one {檔案已} other {檔案已}} 是相冊的一部分", "authorized_devices": "授權裝置", - "back": "后退", + "back": "返回", "back_close_deselect": "返回、關閉及取消選取", "backward": "倒轉", "birthdate_saved": "出生日期儲存成功", @@ -475,10 +475,10 @@ "collapse_all": "全部折疊", "color": "顏色", "color_theme": "色彩主題", - "comment_deleted": "評論已刪除", - "comment_options": "評論選項", - "comments_and_likes": "評論與讚好", - "comments_are_disabled": "評論已禁用", + "comment_deleted": "留言已刪除", + "comment_options": "留言選項", + "comments_and_likes": "留言與喜歡", + "comments_are_disabled": "留言已停用", "confirm": "確認", "confirm_admin_password": "確認管理者密碼", "confirm_delete_shared_link": "確定刪除連結嗎?", @@ -526,7 +526,7 @@ "deduplication_criteria_1": "圖像大小(以位元組為單位)", "deduplication_criteria_2": "EXIF 資料數量", "deduplication_info": "重複資料刪除資訊", - "deduplication_info_description": "為了自動預選資產並大量刪除重複項,我們查看:", + "deduplication_info_description": "為了自動預選項目並大量刪除重複項目,我們查看:", "default_locale": "預設區域", "default_locale_description": "依瀏覽器區域設定日期和數字格式", "delete": "刪除", @@ -544,9 +544,9 @@ "deleted_shared_link": "已刪除共享鏈結", "deletes_missing_assets": "刪除磁碟中遺失的檔案", "description": "描述", - "details": "詳情", + "details": "詳細資訊", "direction": "方向", - "disabled": "禁用", + "disabled": "停用", "disallow_edits": "不允許編輯", "discord": "Discord", "discover": "探索", @@ -561,7 +561,7 @@ "done": "完成", "download": "下載", "download_include_embedded_motion_videos": "嵌入影片", - "download_include_embedded_motion_videos_description": "把嵌入動態照片的影片作爲單獨的檔案包含在內", + "download_include_embedded_motion_videos_description": "把嵌入動態照片的影片作為單獨的檔案包含在內", "download_settings": "下載", "download_settings_description": "管理與檔案下載相關的設定", "downloading": "下載中", @@ -606,11 +606,11 @@ "cannot_navigate_next_asset": "無法瀏覽下一個檔案", "cannot_navigate_previous_asset": "無法瀏覽上一個檔案", "cant_apply_changes": "無法套用更改", - "cant_change_activity": "無法{enabled, select, true {禁用} other {啟用}}活動", + "cant_change_activity": "無法{enabled, select, true {停用} other {啟用}}活動", "cant_change_asset_favorite": "無法更改檔案的收藏狀態", "cant_change_metadata_assets_count": "無法更改 {count, plural, other {# 個檔案}}的詳細資料", "cant_get_faces": "無法取得臉孔", - "cant_get_number_of_comments": "無法獲取評論數量", + "cant_get_number_of_comments": "無法取得留言數量", "cant_search_people": "無法搜尋人", "cant_search_places": "無法搜尋地點", "cleared_jobs": "已清除的作業:{job}", @@ -619,7 +619,7 @@ "error_deleting_shared_user": "刪除共享使用者時出錯", "error_downloading": "下載 {filename} 時出錯", "error_hiding_buy_button": "隱藏購置按鈕時出錯", - "error_removing_assets_from_album": "從相簿中移除檔案時出錯了,請到控制臺瞭解詳情", + "error_removing_assets_from_album": "從相簿中移除檔案時出錯了,請到控制臺瞭解詳細資訊", "error_selecting_all_assets": "選擇所有檔案時出錯", "exclusion_pattern_already_exists": "此排除模式已存在。", "failed_job_command": "命令 {command} 執行失敗,作業:{job}", @@ -642,7 +642,7 @@ "repair_unable_to_check_items": "無法檢查 {count, select, other { 個項目}}", "unable_to_add_album_users": "無法將使用者加入相簿", "unable_to_add_assets_to_shared_link": "無法加入項目到共享連結", - "unable_to_add_comment": "無法添加評論", + "unable_to_add_comment": "無法新增留言", "unable_to_add_exclusion_pattern": "無法添加排除模式", "unable_to_add_import_path": "無法添加匯入路徑", "unable_to_add_partners": "無法添加夥伴", @@ -676,7 +676,7 @@ "unable_to_empty_trash": "無法清空垃圾桶", "unable_to_enter_fullscreen": "無法進入全螢幕", "unable_to_exit_fullscreen": "無法退出全螢幕", - "unable_to_get_comments_number": "無法獲取評論數量", + "unable_to_get_comments_number": "無法取得留言數量", "unable_to_get_shared_link": "取得共享連結失敗", "unable_to_hide_person": "無法隱藏人物", "unable_to_link_motion_video": "無法鏈結動態影片", @@ -684,7 +684,7 @@ "unable_to_load_album": "無法載入相簿", "unable_to_load_asset_activity": "無法載入檔案活動", "unable_to_load_items": "無法載入項目", - "unable_to_load_liked_status": "無法載入讚好狀態", + "unable_to_load_liked_status": "無法載入喜歡狀態", "unable_to_log_out_all_devices": "無法登出所有裝置", "unable_to_log_out_device": "無法登出裝置", "unable_to_login_with_oauth": "無法使用 OAuth 登入", @@ -766,8 +766,10 @@ "go_to_folder": "轉至資料夾", "go_to_search": "前往搜尋", "group_albums_by": "分類群組的方式...", + "group_country": "按國家分組", "group_no": "無分組", "group_owner": "按擁有者分組", + "group_places_by": "分類地點的方式...", "group_year": "按年份分組", "has_quota": "配額", "hi_user": "嗨!{name}({email})", @@ -800,6 +802,7 @@ "include_shared_albums": "包含共享相簿", "include_shared_partner_assets": "包括共享夥伴檔案", "individual_share": "個別分享", + "individual_shares": "個別分享", "info": "資訊", "interval": { "day_at_onepm": "每天下午 1 點", @@ -822,6 +825,7 @@ "latest_version": "最新版本", "latitude": "緯度", "leave": "離開", + "lens_model": "鏡頭型號", "let_others_respond": "允許他人回覆", "level": "等級", "library": "圖庫", @@ -898,7 +902,7 @@ "no_albums_with_name_yet": "看來還沒有這個名字的相簿。", "no_albums_yet": "看來您還沒有任何相簿。", "no_archived_assets_message": "將照片和影片封存,就不會顯示在「照片」中", - "no_assets_message": "按這裏上傳您的第一張照片", + "no_assets_message": "按這裡上傳您的第一張照片", "no_duplicates_found": "沒發現重複項目。", "no_exif_info_available": "沒有可用的 Exif 資訊", "no_explore_results_message": "上傳更多照片以利探索。", @@ -984,6 +988,7 @@ "pick_a_location": "選擇位置", "place": "地點", "places": "地點", + "places_count": "{count, plural, one {{count, number} 個地點} other {{count, number} 個地點}}", "play": "播放", "play_memories": "播放回憶", "play_motion_photo": "播放動態照片", @@ -1019,7 +1024,7 @@ "purchase_license_subtitle": "購置 Immich 來支援軟體開發", "purchase_lifetime_description": "終身購置", "purchase_option_title": "購置選項", - "purchase_panel_info_1": "開發 Immich 可不是件容易的事,花了我們不少功夫。好在有一群全職工程師在背後默默努力,爲的就是把它做到最好。我們的目標很簡單:讓開源軟體和正當的商業模式能成爲開發者的長期飯碗,同時打造出重視隱私的生態系統,讓大家有個不被剝削的雲端服務新選擇。", + "purchase_panel_info_1": "開發 Immich 可不是件容易的事,花了我們不少功夫。好在有一群全職工程師在背後默默努力,為的就是把它做到最好。我們的目標很簡單:讓開源軟體和正當的商業模式能成為開發者的長期飯碗,同時打造出重視隱私的生態系統,讓大家有個不被剝削的雲端服務新選擇。", "purchase_panel_info_2": "我們承諾不設付費牆,所以購置 Immich 並不會讓您獲得額外的功能。我們是依賴使用者們的支援來開發 Immich 的。", "purchase_panel_title": "支援這項專案", "purchase_per_server": "每臺伺服器", @@ -1074,7 +1079,7 @@ "removed_tagged_assets": "已移除 {count, plural, other {# 個檔案}}的標記", "rename": "改名", "repair": "糾正", - "repair_no_results_message": "未被追蹤及遺失的檔案會顯示在這裏", + "repair_no_results_message": "未被追蹤及遺失的檔案會顯示在這裡", "replace_with_upload": "用上傳的檔案取代", "repository": "儲存庫", "require_password": "需要密碼", @@ -1099,7 +1104,7 @@ "saved_api_key": "已儲存的 API 密鑰", "saved_profile": "已儲存個人資料", "saved_settings": "已儲存設定", - "say_something": "说些什么", + "say_something": "說說你的想法吧", "scan_all_libraries": "掃描所有圖庫", "scan_library": "掃描", "scan_settings": "掃描設定", @@ -1107,15 +1112,18 @@ "search": "搜尋", "search_albums": "搜尋相簿", "search_by_context": "以情境搜尋", + "search_by_description": "以描述搜尋", + "search_by_description_example": "在沙壩的健行之日", "search_by_filename": "以檔名或副檔名搜尋", "search_by_filename_example": "如 IMG_1234.JPG 或 PNG", "search_camera_make": "搜尋相機製造商…", "search_camera_model": "搜尋相機型號…", "search_city": "搜尋城市…", "search_country": "搜尋國家…", + "search_for": "搜尋", "search_for_existing_person": "搜尋現有的人物", "search_no_people": "沒有人找到", - "search_no_people_named": "沒有名爲「{name}」的人物", + "search_no_people_named": "沒有名為「{name}」的人物", "search_options": "搜尋選項", "search_people": "搜尋人物", "search_places": "搜尋地點", @@ -1144,12 +1152,12 @@ "selected_count": "{count, plural, other {選了 # 項}}", "send_message": "傳訊息", "send_welcome_email": "傳送歡迎電子郵件", - "server_offline": "伺服器離線", - "server_online": "伺服器在線", + "server_offline": "伺服器已離線", + "server_online": "伺服器已上線", "server_stats": "伺服器統計", "server_version": "目前版本", "set": "設定", - "set_as_album_cover": "設爲相簿封面", + "set_as_album_cover": "設為相簿封面", "set_as_featured_photo": "設為特色照片", "set_as_profile_picture": "設為個人資料圖片", "set_date_of_birth": "設定出生日期", @@ -1165,6 +1173,7 @@ "shared_from_partner": "來自 {partner} 的照片", "shared_link_options": "共享連結選項", "shared_links": "共享連結", + "shared_links_description": "以連結分享照片和影片", "shared_photos_and_videos_count": "{assetCount, plural, other {已分享 # 張照片及影片。}}", "shared_with_partner": "與 {partner} 共享", "sharing": "共享", @@ -1187,6 +1196,7 @@ "show_person_options": "顯示人物選項", "show_progress_bar": "顯示進度條", "show_search_options": "顯示搜尋選項", + "show_shared_links": "顯示共享連結", "show_slideshow_transition": "顯示幻燈片轉場", "show_supporter_badge": "擁護者徽章", "show_supporter_badge_description": "顯示擁護者徽章", @@ -1212,7 +1222,7 @@ "source": "來源", "stack": "堆叠", "stack_duplicates": "堆疊重複項目", - "stack_select_one_photo": "爲堆疊選一張主要照片", + "stack_select_one_photo": "為堆疊選一張主要照片", "stack_selected_photos": "堆疊所選的照片", "stacked_assets_count": "已堆疊 {count, plural, one {# 個檔案} other {# 個檔案}}", "stacktrace": "堆疊追蹤", @@ -1274,6 +1284,7 @@ "unfavorite": "取消收藏", "unhide_person": "取消隱藏人物", "unknown": "未知", + "unknown_country": "未知國家", "unknown_year": "不知年份", "unlimited": "不限制", "unlink_motion_video": "取消鏈結動態影片", @@ -1308,8 +1319,8 @@ "user_liked": "{user} 喜歡了 {type, select, photo {這張照片} video {這段影片} asset {這個檔案} other {它}}", "user_purchase_settings": "購置", "user_purchase_settings_description": "管理你的購買", - "user_role_set": "設 {user} 爲{role}", - "user_usage_detail": "使用者用量詳情", + "user_role_set": "設 {user} 為{role}", + "user_usage_detail": "使用者用量詳細資訊", "user_usage_stats": "帳號使用量統計", "user_usage_stats_description": "查看帳號使用量", "username": "使用者名稱", @@ -1319,7 +1330,7 @@ "variables": "變數", "version": "版本", "version_announcement_closing": "敬祝順心,Alex", - "version_announcement_message": "嗨~新版本的 Immich 推出了。爲防止配置出錯,請花點時間閱讀發行說明,並確保設定是最新的,特別是使用 WatchTower 等自動更新工具時。", + "version_announcement_message": "嗨~新版本的 Immich 推出了。為防止配置出錯,請花點時間閱讀發行說明,並確保設定是最新的,特別是使用 WatchTower 等自動更新工具時。", "version_history": "版本紀錄", "version_history_item": "{date} 安裝了 {version}", "video": "影片", @@ -1333,14 +1344,14 @@ "view_all_users": "查看所有使用者", "view_in_timeline": "在時間軸中查看", "view_links": "檢視鏈結", - "view_name": "查看", + "view_name": "檢視分類", "view_next_asset": "查看下一項", "view_previous_asset": "查看上一項", "view_stack": "查看堆疊", "visibility_changed": "已更改 {count, plural, other {# 位人物}}的可見性", "waiting": "待處理", "warning": "警告", - "week": "周", + "week": "週", "welcome": "歡迎", "welcome_to_immich": "歡迎使用 Immich", "year": "年", diff --git a/i18n/zh_SIMPLIFIED.json b/i18n/zh_SIMPLIFIED.json index 12c72a8172..332386067a 100644 --- a/i18n/zh_SIMPLIFIED.json +++ b/i18n/zh_SIMPLIFIED.json @@ -20,7 +20,7 @@ "add_partner": "添加同伴", "add_path": "添加路径", "add_photos": "添加照片", - "add_to": "添加到...", + "add_to": "添加到…", "add_to_album": "添加到相册", "add_to_shared_album": "添加到共享相册", "add_url": "添加URL", @@ -219,7 +219,7 @@ "reset_settings_to_default": "恢复默认设置", "reset_settings_to_recent_saved": "恢复到最近保存的设置", "scanning_library": "扫描图库", - "search_jobs": "搜索任务...", + "search_jobs": "搜索任务…", "send_welcome_email": "发送欢迎邮件", "server_external_domain_settings": "外部域名", "server_external_domain_settings_description": "共享链接域名,包括 http(s)://", @@ -406,17 +406,17 @@ "are_these_the_same_person": "他们是同一位人吗?", "are_you_sure_to_do_this": "确定要这样做吗?", "asset_added_to_album": "已添加至相册", - "asset_adding_to_album": "正在添加至相册...", + "asset_adding_to_album": "正在添加至相册…", "asset_description_updated": "项目描述已更新", "asset_filename_is_offline": "项目“{filename}”已离线", "asset_has_unassigned_faces": "项目中有未分配的人脸", - "asset_hashing": "哈希校验中...", + "asset_hashing": "哈希校验中…", "asset_offline": "项目脱机", "asset_offline_description": "磁盘上已找不到该外部项目。请联系您的 Immich 管理员寻求帮助。", "asset_skipped": "已跳过", "asset_skipped_in_trash": "已回收", "asset_uploaded": "已上传", - "asset_uploading": "上传中...", + "asset_uploading": "上传中…", "assets": "项目", "assets_added_count": "已添加{count, plural, one {#个项目} other {#个项目}}", "assets_added_to_album_count": "已添加{count, plural, one {#个项目} other {#个项目}}到相册", @@ -766,8 +766,10 @@ "go_to_folder": "进入文件夹", "go_to_search": "前往搜索", "group_albums_by": "相册分组依据...", + "group_country": "按国家分组", "group_no": "未分组", "group_owner": "按所有者分组", + "group_places_by": "地点分组依据...", "group_year": "按年分组", "has_quota": "配额大小", "hi_user": "你好,{name}({email})", @@ -800,6 +802,7 @@ "include_shared_albums": "包括共享相册", "include_shared_partner_assets": "包括同伴共享项目", "individual_share": "个人分享", + "individual_shares": "个人分享", "info": "信息", "interval": { "day_at_onepm": "每天下午 1 点", @@ -822,6 +825,7 @@ "latest_version": "最新版本", "latitude": "纬度", "leave": "离开", + "lens_model": "镜头型号", "let_others_respond": "允许他人回应", "level": "等级", "library": "图库", @@ -984,6 +988,7 @@ "pick_a_location": "选择位置", "place": "地点", "places": "地点", + "places_count": "{count, plural, one {{count, number} 个地点} other {{count, number} 个地点}}", "play": "播放", "play_memories": "播放回忆", "play_motion_photo": "播放动态图片", @@ -1107,12 +1112,15 @@ "search": "搜索", "search_albums": "搜索相册", "search_by_context": "搜索内容", + "search_by_description": "通过描述搜索", + "search_by_description_example": "在沙巴徒步的日子", "search_by_filename": "通过文件名搜索", "search_by_filename_example": "如 IMG_1234.JPG 或 PNG", "search_camera_make": "搜索相机品牌...", "search_camera_model": "搜索相机型号...", "search_city": "搜索城市...", "search_country": "搜索国家...", + "search_for": "搜索", "search_for_existing_person": "搜索已有人物", "search_no_people": "找不到人物", "search_no_people_named": "人物“{name}”不存在", @@ -1165,6 +1173,7 @@ "shared_from_partner": "来自“{partner}”的照片", "shared_link_options": "共享链接选项", "shared_links": "共享链接", + "shared_links_description": "通过链接分享照片和视频", "shared_photos_and_videos_count": "{assetCount, plural, other {#项已共享照片&视频。}}", "shared_with_partner": "与“{partner}”共享", "sharing": "共享", @@ -1187,6 +1196,7 @@ "show_person_options": "显示人物选项", "show_progress_bar": "显示进度条", "show_search_options": "显示搜索选项", + "show_shared_links": "显示共享链接", "show_slideshow_transition": "显示幻灯片过渡效果", "show_supporter_badge": "支持者徽章", "show_supporter_badge_description": "展示支持者徽章", @@ -1274,6 +1284,7 @@ "unfavorite": "取消收藏", "unhide_person": "显示人物", "unknown": "未知", + "unknown_country": "未知的国家", "unknown_year": "未知年份", "unlimited": "无限制", "unlink_motion_video": "取消链接动态视频", From d350022dec434b757e99b4516cd7cc288646330e Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Fri, 21 Feb 2025 13:31:37 -0500 Subject: [PATCH 194/395] feat: persistent memories (#15953) feat: memories refactor chore: use heart as favorite icon fix: linting --- i18n/en.json | 4 + mobile/openapi/lib/api/memories_api.dart | 37 +++- mobile/openapi/lib/model/manual_job_name.dart | 6 + .../lib/model/memory_response_dto.dart | 36 +++- open-api/immich-openapi-specs.json | 48 ++++- open-api/typescript-sdk/src/fetch-client.ts | 20 ++- server/src/controllers/memory.controller.ts | 8 +- server/src/db.d.ts | 2 + server/src/dtos/memory.dto.ts | 22 ++- server/src/entities/memory.entity.ts | 6 + server/src/entities/system-metadata.entity.ts | 5 + server/src/enum.ts | 7 + .../1739824470990-AddMemoryShowHideDates.ts | 16 ++ server/src/queries/memory.repository.sql | 60 ++++++- server/src/repositories/memory.repository.ts | 36 +++- server/src/services/job.service.spec.ts | 2 + server/src/services/job.service.ts | 10 ++ server/src/services/memory.service.spec.ts | 4 +- server/src/services/memory.service.ts | 78 +++++++- server/src/types.ts | 10 +- .../repositories/memory.repository.mock.ts | 1 + .../memory-page/memory-viewer.svelte | 170 +++++++++++++++--- .../components/photos-page/memory-lane.svelte | 12 +- .../context-menu/button-context-menu.svelte | 15 +- web/src/lib/stores/memory.store.ts | 11 +- web/src/lib/utils.ts | 11 +- web/src/lib/utils/context-menu.ts | 8 +- web/src/lib/utils/date-time.ts | 8 + web/src/routes/admin/jobs-status/+page.svelte | 2 + 29 files changed, 585 insertions(+), 70 deletions(-) create mode 100644 server/src/migrations/1739824470990-AddMemoryShowHideDates.ts diff --git a/i18n/en.json b/i18n/en.json index e9c4d70c44..1bf118976e 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -352,6 +352,8 @@ "version_check_enabled_description": "Enable version check", "version_check_implications": "The version check feature relies on periodic communication with github.com", "version_check_settings": "Version Check", + "memory_cleanup_job": "Memory cleanup", + "memory_generate_job": "Memory generation", "version_check_settings_description": "Enable/disable the new version notification", "video_conversion_job": "Transcode videos", "video_conversion_job_description": "Transcode videos for wider compatibility with browsers and devices" @@ -1076,6 +1078,8 @@ "remove_url": "Remove URL", "remove_user": "Remove user", "removed_api_key": "Removed API Key: {name}", + "removed_memory": "Removed memory", + "removed_photo_from_memory": "Removed photo from memory", "removed_from_archive": "Removed from archive", "removed_from_favorites": "Removed from favorites", "removed_from_favorites_count": "{count, plural, other {Removed #}} from favorites", diff --git a/mobile/openapi/lib/api/memories_api.dart b/mobile/openapi/lib/api/memories_api.dart index 5f77a2a34e..c5b04a7c7c 100644 --- a/mobile/openapi/lib/api/memories_api.dart +++ b/mobile/openapi/lib/api/memories_api.dart @@ -262,7 +262,16 @@ class MemoriesApi { } /// Performs an HTTP 'GET /memories' operation and returns the [Response]. - Future searchMemoriesWithHttpInfo() async { + /// Parameters: + /// + /// * [DateTime] for_: + /// + /// * [bool] isSaved: + /// + /// * [bool] isTrashed: + /// + /// * [MemoryType] type: + Future searchMemoriesWithHttpInfo({ DateTime? for_, bool? isSaved, bool? isTrashed, MemoryType? type, }) async { // ignore: prefer_const_declarations final path = r'/memories'; @@ -273,6 +282,19 @@ class MemoriesApi { final headerParams = {}; final formParams = {}; + if (for_ != null) { + queryParams.addAll(_queryParams('', 'for', for_)); + } + if (isSaved != null) { + queryParams.addAll(_queryParams('', 'isSaved', isSaved)); + } + if (isTrashed != null) { + queryParams.addAll(_queryParams('', 'isTrashed', isTrashed)); + } + if (type != null) { + queryParams.addAll(_queryParams('', 'type', type)); + } + const contentTypes = []; @@ -287,8 +309,17 @@ class MemoriesApi { ); } - Future?> searchMemories() async { - final response = await searchMemoriesWithHttpInfo(); + /// Parameters: + /// + /// * [DateTime] for_: + /// + /// * [bool] isSaved: + /// + /// * [bool] isTrashed: + /// + /// * [MemoryType] type: + Future?> searchMemories({ DateTime? for_, bool? isSaved, bool? isTrashed, MemoryType? type, }) async { + final response = await searchMemoriesWithHttpInfo( for_: for_, isSaved: isSaved, isTrashed: isTrashed, type: type, ); if (response.statusCode >= HttpStatus.badRequest) { throw ApiException(response.statusCode, await _decodeBodyBytes(response)); } diff --git a/mobile/openapi/lib/model/manual_job_name.dart b/mobile/openapi/lib/model/manual_job_name.dart index 7e8d9d51b2..71c60d8e64 100644 --- a/mobile/openapi/lib/model/manual_job_name.dart +++ b/mobile/openapi/lib/model/manual_job_name.dart @@ -26,12 +26,16 @@ class ManualJobName { static const personCleanup = ManualJobName._(r'person-cleanup'); static const tagCleanup = ManualJobName._(r'tag-cleanup'); static const userCleanup = ManualJobName._(r'user-cleanup'); + static const memoryCleanup = ManualJobName._(r'memory-cleanup'); + static const memoryCreate = ManualJobName._(r'memory-create'); /// List of all possible values in this [enum][ManualJobName]. static const values = [ personCleanup, tagCleanup, userCleanup, + memoryCleanup, + memoryCreate, ]; static ManualJobName? fromJson(dynamic value) => ManualJobNameTypeTransformer().decode(value); @@ -73,6 +77,8 @@ class ManualJobNameTypeTransformer { case r'person-cleanup': return ManualJobName.personCleanup; case r'tag-cleanup': return ManualJobName.tagCleanup; case r'user-cleanup': return ManualJobName.userCleanup; + case r'memory-cleanup': return ManualJobName.memoryCleanup; + case r'memory-create': return ManualJobName.memoryCreate; default: if (!allowNull) { throw ArgumentError('Unknown enum value to decode: $data'); diff --git a/mobile/openapi/lib/model/memory_response_dto.dart b/mobile/openapi/lib/model/memory_response_dto.dart index 652c993536..7d50259e24 100644 --- a/mobile/openapi/lib/model/memory_response_dto.dart +++ b/mobile/openapi/lib/model/memory_response_dto.dart @@ -17,11 +17,13 @@ class MemoryResponseDto { required this.createdAt, required this.data, this.deletedAt, + this.hideAt, required this.id, required this.isSaved, required this.memoryAt, required this.ownerId, this.seenAt, + this.showAt, required this.type, required this.updatedAt, }); @@ -40,6 +42,14 @@ class MemoryResponseDto { /// DateTime? deletedAt; + /// + /// 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. + /// + DateTime? hideAt; + String id; bool isSaved; @@ -56,6 +66,14 @@ class MemoryResponseDto { /// DateTime? seenAt; + /// + /// 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. + /// + DateTime? showAt; + MemoryType type; DateTime updatedAt; @@ -66,11 +84,13 @@ class MemoryResponseDto { other.createdAt == createdAt && other.data == data && other.deletedAt == deletedAt && + other.hideAt == hideAt && other.id == id && other.isSaved == isSaved && other.memoryAt == memoryAt && other.ownerId == ownerId && other.seenAt == seenAt && + other.showAt == showAt && other.type == type && other.updatedAt == updatedAt; @@ -81,16 +101,18 @@ class MemoryResponseDto { (createdAt.hashCode) + (data.hashCode) + (deletedAt == null ? 0 : deletedAt!.hashCode) + + (hideAt == null ? 0 : hideAt!.hashCode) + (id.hashCode) + (isSaved.hashCode) + (memoryAt.hashCode) + (ownerId.hashCode) + (seenAt == null ? 0 : seenAt!.hashCode) + + (showAt == null ? 0 : showAt!.hashCode) + (type.hashCode) + (updatedAt.hashCode); @override - String toString() => 'MemoryResponseDto[assets=$assets, createdAt=$createdAt, data=$data, deletedAt=$deletedAt, id=$id, isSaved=$isSaved, memoryAt=$memoryAt, ownerId=$ownerId, seenAt=$seenAt, type=$type, updatedAt=$updatedAt]'; + String toString() => 'MemoryResponseDto[assets=$assets, createdAt=$createdAt, data=$data, deletedAt=$deletedAt, hideAt=$hideAt, id=$id, isSaved=$isSaved, memoryAt=$memoryAt, ownerId=$ownerId, seenAt=$seenAt, showAt=$showAt, type=$type, updatedAt=$updatedAt]'; Map toJson() { final json = {}; @@ -101,6 +123,11 @@ class MemoryResponseDto { json[r'deletedAt'] = this.deletedAt!.toUtc().toIso8601String(); } else { // json[r'deletedAt'] = null; + } + if (this.hideAt != null) { + json[r'hideAt'] = this.hideAt!.toUtc().toIso8601String(); + } else { + // json[r'hideAt'] = null; } json[r'id'] = this.id; json[r'isSaved'] = this.isSaved; @@ -110,6 +137,11 @@ class MemoryResponseDto { json[r'seenAt'] = this.seenAt!.toUtc().toIso8601String(); } else { // json[r'seenAt'] = null; + } + if (this.showAt != null) { + json[r'showAt'] = this.showAt!.toUtc().toIso8601String(); + } else { + // json[r'showAt'] = null; } json[r'type'] = this.type; json[r'updatedAt'] = this.updatedAt.toUtc().toIso8601String(); @@ -129,11 +161,13 @@ class MemoryResponseDto { createdAt: mapDateTime(json, r'createdAt', r'')!, data: OnThisDayDto.fromJson(json[r'data'])!, deletedAt: mapDateTime(json, r'deletedAt', r''), + hideAt: mapDateTime(json, r'hideAt', r''), id: mapValueOfType(json, r'id')!, isSaved: mapValueOfType(json, r'isSaved')!, memoryAt: mapDateTime(json, r'memoryAt', r'')!, ownerId: mapValueOfType(json, r'ownerId')!, seenAt: mapDateTime(json, r'seenAt', r''), + showAt: mapDateTime(json, r'showAt', r''), type: MemoryType.fromJson(json[r'type'])!, updatedAt: mapDateTime(json, r'updatedAt', r'')!, ); diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index 7417f98c2b..e8bdfa7405 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -3146,7 +3146,41 @@ "/memories": { "get": { "operationId": "searchMemories", - "parameters": [], + "parameters": [ + { + "name": "for", + "required": false, + "in": "query", + "schema": { + "format": "date-time", + "type": "string" + } + }, + { + "name": "isSaved", + "required": false, + "in": "query", + "schema": { + "type": "boolean" + } + }, + { + "name": "isTrashed", + "required": false, + "in": "query", + "schema": { + "type": "boolean" + } + }, + { + "name": "type", + "required": false, + "in": "query", + "schema": { + "$ref": "#/components/schemas/MemoryType" + } + } + ], "responses": { "200": { "content": { @@ -9882,7 +9916,9 @@ "enum": [ "person-cleanup", "tag-cleanup", - "user-cleanup" + "user-cleanup", + "memory-cleanup", + "memory-create" ], "type": "string" }, @@ -10039,6 +10075,10 @@ "format": "date-time", "type": "string" }, + "hideAt": { + "format": "date-time", + "type": "string" + }, "id": { "type": "string" }, @@ -10056,6 +10096,10 @@ "format": "date-time", "type": "string" }, + "showAt": { + "format": "date-time", + "type": "string" + }, "type": { "allOf": [ { diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index 6d7f3c17aa..bb97dbaf78 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -640,11 +640,13 @@ export type MemoryResponseDto = { createdAt: string; data: OnThisDayDto; deletedAt?: string; + hideAt?: string; id: string; isSaved: boolean; memoryAt: string; ownerId: string; seenAt?: string; + showAt?: string; "type": MemoryType; updatedAt: string; }; @@ -2222,11 +2224,21 @@ export function reverseGeocode({ lat, lon }: { ...opts })); } -export function searchMemories(opts?: Oazapfts.RequestOpts) { +export function searchMemories({ $for, isSaved, isTrashed, $type }: { + $for?: string; + isSaved?: boolean; + isTrashed?: boolean; + $type?: MemoryType; +}, opts?: Oazapfts.RequestOpts) { return oazapfts.ok(oazapfts.fetchJson<{ status: 200; data: MemoryResponseDto[]; - }>("/memories", { + }>(`/memories${QS.query(QS.explode({ + "for": $for, + isSaved, + isTrashed, + "type": $type + }))}`, { ...opts })); } @@ -3565,7 +3577,9 @@ export enum AssetMediaSize { export enum ManualJobName { PersonCleanup = "person-cleanup", TagCleanup = "tag-cleanup", - UserCleanup = "user-cleanup" + UserCleanup = "user-cleanup", + MemoryCleanup = "memory-cleanup", + MemoryCreate = "memory-create" } export enum JobName { ThumbnailGeneration = "thumbnailGeneration", diff --git a/server/src/controllers/memory.controller.ts b/server/src/controllers/memory.controller.ts index 710ca9f2f8..1f848ad705 100644 --- a/server/src/controllers/memory.controller.ts +++ b/server/src/controllers/memory.controller.ts @@ -1,8 +1,8 @@ -import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Param, Post, Put } from '@nestjs/common'; +import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Param, Post, Put, Query } from '@nestjs/common'; import { ApiTags } from '@nestjs/swagger'; import { BulkIdResponseDto, BulkIdsDto } from 'src/dtos/asset-ids.response.dto'; import { AuthDto } from 'src/dtos/auth.dto'; -import { MemoryCreateDto, MemoryResponseDto, MemoryUpdateDto } from 'src/dtos/memory.dto'; +import { MemoryCreateDto, MemoryResponseDto, MemorySearchDto, MemoryUpdateDto } from 'src/dtos/memory.dto'; import { Permission } from 'src/enum'; import { Auth, Authenticated } from 'src/middleware/auth.guard'; import { MemoryService } from 'src/services/memory.service'; @@ -15,8 +15,8 @@ export class MemoryController { @Get() @Authenticated({ permission: Permission.MEMORY_READ }) - searchMemories(@Auth() auth: AuthDto): Promise { - return this.service.search(auth); + searchMemories(@Auth() auth: AuthDto, @Query() dto: MemorySearchDto): Promise { + return this.service.search(auth, dto); } @Post() diff --git a/server/src/db.d.ts b/server/src/db.d.ts index dfb451afa7..4a2adc917f 100644 --- a/server/src/db.d.ts +++ b/server/src/db.d.ts @@ -227,11 +227,13 @@ export interface Memories { createdAt: Generated; data: Json; deletedAt: Timestamp | null; + hideAt: Timestamp | null; id: Generated; isSaved: Generated; memoryAt: Timestamp; ownerId: string; seenAt: Timestamp | null; + showAt: Timestamp | null; type: string; updatedAt: Generated; } diff --git a/server/src/dtos/memory.dto.ts b/server/src/dtos/memory.dto.ts index 194bb8ac38..9eef78d4d0 100644 --- a/server/src/dtos/memory.dto.ts +++ b/server/src/dtos/memory.dto.ts @@ -5,7 +5,7 @@ import { AssetResponseDto, mapAsset } from 'src/dtos/asset-response.dto'; import { AssetEntity } from 'src/entities/asset.entity'; import { MemoryType } from 'src/enum'; import { MemoryItem } from 'src/types'; -import { ValidateBoolean, ValidateDate, ValidateUUID } from 'src/validation'; +import { Optional, ValidateBoolean, ValidateDate, ValidateUUID } from 'src/validation'; class MemoryBaseDto { @ValidateBoolean({ optional: true }) @@ -15,6 +15,22 @@ class MemoryBaseDto { seenAt?: Date; } +export class MemorySearchDto { + @Optional() + @IsEnum(MemoryType) + @ApiProperty({ enum: MemoryType, enumName: 'MemoryType' }) + type?: MemoryType; + + @ValidateDate({ optional: true }) + for?: Date; + + @ValidateBoolean({ optional: true }) + isTrashed?: boolean; + + @ValidateBoolean({ optional: true }) + isSaved?: boolean; +} + class OnThisDayDto { @IsInt() @IsPositive() @@ -62,6 +78,8 @@ export class MemoryResponseDto { deletedAt?: Date; memoryAt!: Date; seenAt?: Date; + showAt?: Date; + hideAt?: Date; ownerId!: string; @ApiProperty({ enumName: 'MemoryType', enum: MemoryType }) type!: MemoryType; @@ -78,6 +96,8 @@ export const mapMemory = (entity: MemoryItem): MemoryResponseDto => { deletedAt: entity.deletedAt ?? undefined, memoryAt: entity.memoryAt, seenAt: entity.seenAt ?? undefined, + showAt: entity.showAt ?? undefined, + hideAt: entity.hideAt ?? undefined, ownerId: entity.ownerId, type: entity.type as MemoryType, data: entity.data as unknown as MemoryData, diff --git a/server/src/entities/memory.entity.ts b/server/src/entities/memory.entity.ts index c8121dd32e..1f53d7a5c1 100644 --- a/server/src/entities/memory.entity.ts +++ b/server/src/entities/memory.entity.ts @@ -53,6 +53,12 @@ export class MemoryEntity { @Column({ type: 'timestamptz' }) memoryAt!: Date; + @Column({ type: 'timestamptz', nullable: true }) + showAt?: Date; + + @Column({ type: 'timestamptz', nullable: true }) + hideAt?: Date; + /** when the user last viewed the memory */ @Column({ type: 'timestamptz', nullable: true }) seenAt?: Date; diff --git a/server/src/entities/system-metadata.entity.ts b/server/src/entities/system-metadata.entity.ts index 678b8f701a..b024862ba5 100644 --- a/server/src/entities/system-metadata.entity.ts +++ b/server/src/entities/system-metadata.entity.ts @@ -14,6 +14,10 @@ export class SystemMetadataEntity }; +export type MemoriesState = { + /** memories have already been created through this date */ + lastOnThisDayDate: string; +}; export interface SystemMetadata extends Record> { [SystemMetadataKey.ADMIN_ONBOARDING]: { isOnboarded: boolean }; @@ -23,4 +27,5 @@ export interface SystemMetadata extends Record; [SystemMetadataKey.SYSTEM_FLAGS]: DeepPartial; [SystemMetadataKey.VERSION_CHECK_STATE]: VersionCheckMetadata; + [SystemMetadataKey.MEMORIES_STATE]: MemoriesState; } diff --git a/server/src/enum.ts b/server/src/enum.ts index b99518c4ff..7bf4ca3dcf 100644 --- a/server/src/enum.ts +++ b/server/src/enum.ts @@ -187,6 +187,7 @@ export enum StorageFolder { export enum SystemMetadataKey { REVERSE_GEOCODING_STATE = 'reverse-geocoding-state', FACIAL_RECOGNITION_STATE = 'facial-recognition-state', + MEMORIES_STATE = 'memories-state', ADMIN_ONBOARDING = 'admin-onboarding', SYSTEM_CONFIG = 'system-config', SYSTEM_FLAGS = 'system-flags', @@ -233,6 +234,8 @@ export enum ManualJobName { PERSON_CLEANUP = 'person-cleanup', TAG_CLEANUP = 'tag-cleanup', USER_CLEANUP = 'user-cleanup', + MEMORY_CLEANUP = 'memory-cleanup', + MEMORY_CREATE = 'memory-create', } export enum AssetPathType { @@ -477,6 +480,10 @@ export enum JobName { CLEAN_OLD_AUDIT_LOGS = 'clean-old-audit-logs', CLEAN_OLD_SESSION_TOKENS = 'clean-old-session-tokens', + // memories + MEMORIES_CLEANUP = 'memories-cleanup', + MEMORIES_CREATE = 'memories-create', + // smart search QUEUE_SMART_SEARCH = 'queue-smart-search', SMART_SEARCH = 'smart-search', diff --git a/server/src/migrations/1739824470990-AddMemoryShowHideDates.ts b/server/src/migrations/1739824470990-AddMemoryShowHideDates.ts new file mode 100644 index 0000000000..d53c7c17f6 --- /dev/null +++ b/server/src/migrations/1739824470990-AddMemoryShowHideDates.ts @@ -0,0 +1,16 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class AddMemoryShowHideDates1739824470990 implements MigrationInterface { + name = 'AddMemoryShowHideDates1739824470990' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "memories" ADD "showAt" TIMESTAMP WITH TIME ZONE`); + await queryRunner.query(`ALTER TABLE "memories" ADD "hideAt" TIMESTAMP WITH TIME ZONE`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "memories" DROP COLUMN "hideAt"`); + await queryRunner.query(`ALTER TABLE "memories" DROP COLUMN "showAt"`); + } + +} diff --git a/server/src/queries/memory.repository.sql b/server/src/queries/memory.repository.sql index 3144f314dd..3b1526f487 100644 --- a/server/src/queries/memory.repository.sql +++ b/server/src/queries/memory.repository.sql @@ -1,12 +1,68 @@ -- NOTE: This file is auto generated by ./sql-generator +-- MemoryRepository.cleanup +delete from "memories" +where + "createdAt" < $1 + and "isSaved" = $2 + -- MemoryRepository.search select - * + "memories".*, + ( + select + coalesce(json_agg(agg), '[]') + from + ( + select + "assets".* + from + "assets" + inner join "memories_assets_assets" on "assets"."id" = "memories_assets_assets"."assetsId" + where + "memories_assets_assets"."memoriesId" = "memories"."id" + and "assets"."deletedAt" is null + ) as agg + ) as "assets" from "memories" where - "ownerId" = $1 + "deletedAt" is null + and "ownerId" = $1 +order by + "memoryAt" desc + +-- MemoryRepository.search (date filter) +select + "memories".*, + ( + select + coalesce(json_agg(agg), '[]') + from + ( + select + "assets".* + from + "assets" + inner join "memories_assets_assets" on "assets"."id" = "memories_assets_assets"."assetsId" + where + "memories_assets_assets"."memoriesId" = "memories"."id" + and "assets"."deletedAt" is null + ) as agg + ) as "assets" +from + "memories" +where + ( + "showAt" is null + or "showAt" <= $1 + ) + and ( + "hideAt" is null + or "hideAt" >= $2 + ) + and "deletedAt" is null + and "ownerId" = $3 order by "memoryAt" desc diff --git a/server/src/repositories/memory.repository.ts b/server/src/repositories/memory.repository.ts index 7af363012d..356acf53db 100644 --- a/server/src/repositories/memory.repository.ts +++ b/server/src/repositories/memory.repository.ts @@ -1,9 +1,11 @@ import { Injectable } from '@nestjs/common'; import { Insertable, Kysely, Updateable } from 'kysely'; import { jsonArrayFrom } from 'kysely/helpers/postgres'; +import { DateTime } from 'luxon'; import { InjectKysely } from 'nestjs-kysely'; import { DB, Memories } from 'src/db'; import { Chunked, ChunkedSet, DummyValue, GenerateSql } from 'src/decorators'; +import { MemorySearchDto } from 'src/dtos/memory.dto'; import { IBulkAsset } from 'src/types'; @Injectable() @@ -11,10 +13,40 @@ export class MemoryRepository implements IBulkAsset { constructor(@InjectKysely() private db: Kysely) {} @GenerateSql({ params: [DummyValue.UUID] }) - search(ownerId: string) { + cleanup() { + return this.db + .deleteFrom('memories') + .where('createdAt', '<', DateTime.now().minus({ days: 30 }).toJSDate()) + .where('isSaved', '=', false) + .execute(); + } + + @GenerateSql( + { params: [DummyValue.UUID, {}] }, + { name: 'date filter', params: [DummyValue.UUID, { for: DummyValue.DATE }] }, + ) + search(ownerId: string, dto: MemorySearchDto) { return this.db .selectFrom('memories') - .selectAll() + .selectAll('memories') + .select((eb) => + jsonArrayFrom( + eb + .selectFrom('assets') + .selectAll('assets') + .innerJoin('memories_assets_assets', 'assets.id', 'memories_assets_assets.assetsId') + .whereRef('memories_assets_assets.memoriesId', '=', 'memories.id') + .where('assets.deletedAt', 'is', null), + ).as('assets'), + ) + .$if(dto.isSaved !== undefined, (qb) => qb.where('isSaved', '=', dto.isSaved!)) + .$if(dto.type !== undefined, (qb) => qb.where('type', '=', dto.type!)) + .$if(dto.for !== undefined, (qb) => + qb + .where((where) => where.or([where('showAt', 'is', null), where('showAt', '<=', dto.for!)])) + .where((where) => where.or([where('hideAt', 'is', null), where('hideAt', '>=', dto.for!)])), + ) + .where('deletedAt', dto.isTrashed ? 'is not' : 'is', null) .where('ownerId', '=', ownerId) .orderBy('memoryAt', 'desc') .execute(); diff --git a/server/src/services/job.service.spec.ts b/server/src/services/job.service.spec.ts index 6797ffc396..37e58d5863 100644 --- a/server/src/services/job.service.spec.ts +++ b/server/src/services/job.service.spec.ts @@ -40,6 +40,8 @@ describe(JobService.name, () => { { name: JobName.ASSET_DELETION_CHECK }, { name: JobName.USER_DELETE_CHECK }, { name: JobName.PERSON_CLEANUP }, + { name: JobName.MEMORIES_CLEANUP }, + { name: JobName.MEMORIES_CREATE }, { name: JobName.QUEUE_GENERATE_THUMBNAILS, data: { force: false } }, { name: JobName.CLEAN_OLD_AUDIT_LOGS }, { name: JobName.USER_SYNC_USAGE }, diff --git a/server/src/services/job.service.ts b/server/src/services/job.service.ts index 8e3919a2b1..95ff1ad303 100644 --- a/server/src/services/job.service.ts +++ b/server/src/services/job.service.ts @@ -31,6 +31,14 @@ const asJobItem = (dto: JobCreateDto): JobItem => { return { name: JobName.USER_DELETE_CHECK }; } + case ManualJobName.MEMORY_CLEANUP: { + return { name: JobName.MEMORIES_CLEANUP }; + } + + case ManualJobName.MEMORY_CREATE: { + return { name: JobName.MEMORIES_CREATE }; + } + default: { throw new BadRequestException('Invalid job name'); } @@ -207,6 +215,8 @@ export class JobService extends BaseService { { name: JobName.ASSET_DELETION_CHECK }, { name: JobName.USER_DELETE_CHECK }, { name: JobName.PERSON_CLEANUP }, + { name: JobName.MEMORIES_CLEANUP }, + { name: JobName.MEMORIES_CREATE }, { name: JobName.QUEUE_GENERATE_THUMBNAILS, data: { force: false } }, { name: JobName.CLEAN_OLD_AUDIT_LOGS }, { name: JobName.USER_SYNC_USAGE }, diff --git a/server/src/services/memory.service.spec.ts b/server/src/services/memory.service.spec.ts index 54acfa7baa..e3d85133ac 100644 --- a/server/src/services/memory.service.spec.ts +++ b/server/src/services/memory.service.spec.ts @@ -21,7 +21,7 @@ describe(MemoryService.name, () => { describe('search', () => { it('should search memories', async () => { mocks.memory.search.mockResolvedValue([memoryStub.memory1, memoryStub.empty]); - await expect(sut.search(authStub.admin)).resolves.toEqual( + await expect(sut.search(authStub.admin, {})).resolves.toEqual( expect.arrayContaining([ expect.objectContaining({ id: 'memory1', assets: expect.any(Array) }), expect.objectContaining({ id: 'memoryEmpty', assets: [] }), @@ -30,7 +30,7 @@ describe(MemoryService.name, () => { }); it('should map ', async () => { - await expect(sut.search(authStub.admin)).resolves.toEqual([]); + await expect(sut.search(authStub.admin, {})).resolves.toEqual([]); }); }); diff --git a/server/src/services/memory.service.ts b/server/src/services/memory.service.ts index e3aa1f3574..10b8cee2fe 100644 --- a/server/src/services/memory.service.ts +++ b/server/src/services/memory.service.ts @@ -1,16 +1,84 @@ import { BadRequestException, Injectable } from '@nestjs/common'; +import { DateTime } from 'luxon'; import { JsonObject } from 'src/db'; +import { OnJob } from 'src/decorators'; import { BulkIdResponseDto, BulkIdsDto } from 'src/dtos/asset-ids.response.dto'; import { AuthDto } from 'src/dtos/auth.dto'; -import { MemoryCreateDto, MemoryResponseDto, MemoryUpdateDto, mapMemory } from 'src/dtos/memory.dto'; -import { Permission } from 'src/enum'; +import { MemoryCreateDto, MemoryResponseDto, MemorySearchDto, MemoryUpdateDto, mapMemory } from 'src/dtos/memory.dto'; +import { OnThisDayData } from 'src/entities/memory.entity'; +import { JobName, MemoryType, Permission, QueueName, SystemMetadataKey } from 'src/enum'; import { BaseService } from 'src/services/base.service'; -import { addAssets, removeAssets } from 'src/utils/asset.util'; +import { addAssets, getMyPartnerIds, removeAssets } from 'src/utils/asset.util'; + +const DAYS = 3; @Injectable() export class MemoryService extends BaseService { - async search(auth: AuthDto) { - const memories = await this.memoryRepository.search(auth.user.id); + @OnJob({ name: JobName.MEMORIES_CREATE, queue: QueueName.BACKGROUND_TASK }) + async onMemoriesCreate() { + const users = await this.userRepository.getList({ withDeleted: false }); + const userMap: Record = {}; + for (const user of users) { + const partnerIds = await getMyPartnerIds({ + userId: user.id, + repository: this.partnerRepository, + timelineEnabled: true, + }); + userMap[user.id] = [user.id, ...partnerIds]; + } + + const start = DateTime.utc().startOf('day').minus({ days: DAYS }); + + const state = await this.systemMetadataRepository.get(SystemMetadataKey.MEMORIES_STATE); + let lastOnThisDayDate = state?.lastOnThisDayDate ? DateTime.fromISO(state?.lastOnThisDayDate) : start; + + // generate a memory +/- X days from today + for (let i = 0; i <= DAYS * 2 + 1; i++) { + const target = start.plus({ days: i }); + if (lastOnThisDayDate > target) { + continue; + } + + const showAt = target.startOf('day').toISO(); + const hideAt = target.endOf('day').toISO(); + + this.logger.log(`Creating memories for month=${target.month}, day=${target.day}`); + + for (const [userId, userIds] of Object.entries(userMap)) { + const memories = await this.assetRepository.getByDayOfYear(userIds, target); + + for (const memory of memories) { + const data: OnThisDayData = { year: target.year - memory.yearsAgo }; + await this.memoryRepository.create( + { + ownerId: userId, + type: MemoryType.ON_THIS_DAY, + data, + memoryAt: target.minus({ years: memory.yearsAgo }).toISO(), + showAt, + hideAt, + }, + new Set(memory.assets.map(({ id }) => id)), + ); + } + } + + await this.systemMetadataRepository.set(SystemMetadataKey.MEMORIES_STATE, { + ...state, + lastOnThisDayDate: target.toISO(), + }); + + lastOnThisDayDate = target; + } + } + + @OnJob({ name: JobName.MEMORIES_CLEANUP, queue: QueueName.BACKGROUND_TASK }) + async onMemoriesCleanup() { + await this.memoryRepository.cleanup(); + } + + async search(auth: AuthDto, dto: MemorySearchDto) { + const memories = await this.memoryRepository.search(auth.user.id, dto); return memories.map((memory) => mapMemory(memory)); } diff --git a/server/src/types.ts b/server/src/types.ts index 544d35524e..3aa7a14add 100644 --- a/server/src/types.ts +++ b/server/src/types.ts @@ -326,6 +326,10 @@ export type JobItem = | { name: JobName.QUEUE_DUPLICATE_DETECTION; data: IBaseJob } | { name: JobName.DUPLICATE_DETECTION; data: IEntityJob } + // Memories + | { name: JobName.MEMORIES_CLEANUP; data?: IBaseJob } + | { name: JobName.MEMORIES_CREATE; data?: IBaseJob } + // Filesystem | { name: JobName.DELETE_FILES; data: IDeleteFilesJob } @@ -357,7 +361,11 @@ export type JobItem = | { name: JobName.NOTIFY_SIGNUP; data: INotifySignupJob } // Version check - | { name: JobName.VERSION_CHECK; data: IBaseJob }; + | { name: JobName.VERSION_CHECK; data: IBaseJob } + + // Memories + | { name: JobName.MEMORIES_CLEANUP; data?: IBaseJob } + | { name: JobName.MEMORIES_CREATE; data?: IBaseJob }; export type VectorExtension = DatabaseExtension.VECTOR | DatabaseExtension.VECTORS; diff --git a/server/test/repositories/memory.repository.mock.ts b/server/test/repositories/memory.repository.mock.ts index b33404f520..c3a6d774f0 100644 --- a/server/test/repositories/memory.repository.mock.ts +++ b/server/test/repositories/memory.repository.mock.ts @@ -12,5 +12,6 @@ export const newMemoryRepositoryMock = (): Mocked(undefined); + let isSaved = $derived(current?.memory.isSaved); let resetPromise = $state(Promise.resolve()); const { isViewing } = assetViewingStore; @@ -168,6 +185,7 @@ } current.memory.assets = current.memory.assets; }; + const handleRemove = (ids: string[]) => { if (!current) { return; @@ -186,13 +204,65 @@ current = loadFromParams($memories, $page); }; + const handleDeleteMemoryAsset = async (current?: MemoryAsset) => { + if (!current) { + return; + } + + if (current.memory.assets.length === 1) { + return handleDeleteMemory(current); + } + + if (current.previous) { + current.previous.next = current.next; + } + if (current.next) { + current.next.previous = current.previous; + } + + current.memory.assets = current.memory.assets.filter((asset) => asset.id !== current.asset.id); + + $memoryStore = $memoryStore; + + await removeMemoryAssets({ id: current.memory.id, bulkIdsDto: { ids: [current.asset.id] } }); + }; + + const handleDeleteMemory = async (current?: MemoryAsset) => { + if (!current) { + return; + } + + await deleteMemory({ id: current.memory.id }); + + notificationController.show({ message: $t('removed_memory'), type: NotificationType.Info }); + + await loadMemories(); + init(); + }; + + const handleSaveMemory = async (current?: MemoryAsset) => { + if (!current) { + return; + } + + current.memory.isSaved = !current.memory.isSaved; + + await updateMemory({ + id: current.memory.id, + memoryUpdateDto: { + isSaved: current.memory.isSaved, + }, + }); + + notificationController.show({ + message: current.memory.isSaved ? $t('added_to_favorites') : $t('removed_from_favorites'), + type: NotificationType.Info, + }); + }; + onMount(async () => { if (!$memoryStore) { - const localTime = new Date(); - $memoryStore = await getMemoryLane({ - month: localTime.getMonth() + 1, - day: localTime.getDate(), - }); + await loadMemories(); } init(); @@ -268,7 +338,7 @@ {#snippet leading()} {#if current}

- {$memoryLaneTitle(current.memory.yearsAgo)} + {$memoryLaneTitle(current.memory)}

{/if} {/snippet} @@ -352,7 +422,7 @@ {#if current.previousMemory}

{$t('previous').toUpperCase()}

-

{$memoryLaneTitle(current.previousMemory.yearsAgo)}

+

{$memoryLaneTitle(current.previousMemory)}

{/if} @@ -374,17 +444,63 @@ {/key}
- {}} - /> +
+ handleSaveMemory(current)} + class="text-white dark:text-white" + /> + + handleAction('pause')} + direction="left" + align="bottom-right" + class="text-white dark:text-white" + > + handleDeleteMemory(current)} + text={'Remove memory'} + icon={mdiCardsOutline} + /> + handleDeleteMemoryAsset(current)} + text={'Remove photo from this memory'} + icon={mdiImageMinusOutline} + /> + + +
+ +
+ +
{#if current.previous} @@ -449,7 +565,7 @@ {#if current.nextMemory}

{$t('up_next').toUpperCase()}

-

{$memoryLaneTitle(current.nextMemory.yearsAgo)}

+

{$memoryLaneTitle(current.nextMemory)}

{/if} diff --git a/web/src/lib/components/photos-page/memory-lane.svelte b/web/src/lib/components/photos-page/memory-lane.svelte index 254bb78f89..b2db2f1f04 100644 --- a/web/src/lib/components/photos-page/memory-lane.svelte +++ b/web/src/lib/components/photos-page/memory-lane.svelte @@ -2,20 +2,18 @@ import { resizeObserver } from '$lib/actions/resize-observer'; import Icon from '$lib/components/elements/icon.svelte'; import { AppRoute, QueryParameter } from '$lib/constants'; - import { memoryStore } from '$lib/stores/memory.store'; + import { loadMemories, memoryStore } from '$lib/stores/memory.store'; import { getAssetThumbnailUrl, memoryLaneTitle } from '$lib/utils'; import { getAltText } from '$lib/utils/thumbnail-util'; - import { getMemoryLane } from '@immich/sdk'; import { mdiChevronLeft, mdiChevronRight } from '@mdi/js'; import { onMount } from 'svelte'; - import { fade } from 'svelte/transition'; import { t } from 'svelte-i18n'; + import { fade } from 'svelte/transition'; let shouldRender = $derived($memoryStore?.length > 0); onMount(async () => { - const localTime = new Date(); - $memoryStore = await getMemoryLane({ month: localTime.getMonth() + 1, day: localTime.getDate() }); + await loadMemories(); }); let memoryLaneElement: HTMLElement | undefined = $state(); @@ -71,7 +69,7 @@
{/if}
(innerWidth = width)}> - {#each $memoryStore as memory (memory.yearsAgo)} + {#each $memoryStore as memory} {#if memory.assets.length > 0}

- {$memoryLaneTitle(memory.yearsAgo)} + {$memoryLaneTitle(memory)}

+ import { clickOutside } from '$lib/actions/click-outside'; + import { contextMenuNavigation } from '$lib/actions/context-menu-navigation'; + import { shortcuts } from '$lib/actions/shortcut'; import CircleIconButton, { type Color, type Padding, } from '$lib/components/elements/buttons/circle-icon-button.svelte'; import ContextMenu from '$lib/components/shared-components/context-menu/context-menu.svelte'; + import { optionClickCallbackStore, selectedIdStore } from '$lib/stores/context-menu.store'; import { getContextMenuPositionFromBoundingRect, getContextMenuPositionFromEvent, type Align, } from '$lib/utils/context-menu'; import { generateId } from '$lib/utils/generate-id'; - import { contextMenuNavigation } from '$lib/actions/context-menu-navigation'; - import { optionClickCallbackStore, selectedIdStore } from '$lib/stores/context-menu.store'; - import { clickOutside } from '$lib/actions/click-outside'; - import { shortcuts } from '$lib/actions/shortcut'; import type { Snippet } from 'svelte'; + import type { HTMLAttributes } from 'svelte/elements'; - interface Props { + type Props = { icon: string; title: string; /** @@ -36,7 +37,7 @@ buttonClass?: string | undefined; hideContent?: boolean; children?: Snippet; - } + } & HTMLAttributes; let { icon, @@ -49,6 +50,7 @@ buttonClass = undefined, hideContent = false, children, + ...restProps }: Props = $props(); let isOpen = $state(false); @@ -129,6 +131,7 @@ }} use:clickOutside={{ onOutclick: closeDropdown }} onresize={onResize} + {...restProps} >
(); +export const memoryStore = writable(); + +export const loadMemories = async () => { + const memories = await searchMemories({ $for: asLocalTimeISO(DateTime.now()) }); + memoryStore.set(memories); +}; diff --git a/web/src/lib/utils.ts b/web/src/lib/utils.ts index 4109a7c42f..c87b623549 100644 --- a/web/src/lib/utils.ts +++ b/web/src/lib/utils.ts @@ -6,6 +6,7 @@ import { AssetJobName, AssetMediaSize, JobName, + MemoryType, finishOAuth, getAssetOriginalPath, getAssetPlaybackPath, @@ -16,6 +17,7 @@ import { linkOAuthAccount, startOAuth, unlinkOAuthAccount, + type MemoryResponseDto, type PersonResponseDto, type SharedLinkResponseDto, type UserResponseDto, @@ -320,7 +322,14 @@ export const handlePromiseError = (promise: Promise): void => { }; export const memoryLaneTitle = derived(t, ($t) => { - return (yearsAgo: number) => $t('years_ago', { values: { years: yearsAgo } }); + return (memory: MemoryResponseDto) => { + const now = new Date(); + if (memory.type === MemoryType.OnThisDay) { + return $t('years_ago', { values: { years: now.getFullYear() - memory.data.year } }); + } + + return $t('unknown'); + }; }); export const withError = async (fn: () => Promise): Promise<[undefined, T] | [unknown, undefined]> => { diff --git a/web/src/lib/utils/context-menu.ts b/web/src/lib/utils/context-menu.ts index aca1033c7a..461856145e 100644 --- a/web/src/lib/utils/context-menu.ts +++ b/web/src/lib/utils/context-menu.ts @@ -1,4 +1,4 @@ -export type Align = 'middle' | 'top-left' | 'top-right'; +export type Align = 'middle' | 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right'; export type ContextMenuPosition = { x: number; y: number }; @@ -28,5 +28,11 @@ export const getContextMenuPositionFromBoundingRect = (rect: DOMRect, align: Ali case 'top-right': { return { x: rect.x + rect.width, y: rect.y }; } + case 'bottom-left': { + return { x: rect.x, y: rect.y + rect.height }; + } + case 'bottom-right': { + return { x: rect.x + rect.width, y: rect.y + rect.height }; + } } }; diff --git a/web/src/lib/utils/date-time.ts b/web/src/lib/utils/date-time.ts index ba22503c70..16236ba135 100644 --- a/web/src/lib/utils/date-time.ts +++ b/web/src/lib/utils/date-time.ts @@ -77,3 +77,11 @@ export const getAlbumDateRange = (album: { startDate?: string; endDate?: string return ''; }; + +/** + * Use this to convert from "5pm EST" to "5pm UTC" + * + * Useful with some APIs where you want to query by "today", but the values in the database are stored as UTC + */ +export const asLocalTimeISO = (date: DateTime) => + (date.setZone('utc', { keepLocalTime: true }) as DateTime).toISO(); diff --git a/web/src/routes/admin/jobs-status/+page.svelte b/web/src/routes/admin/jobs-status/+page.svelte index 6ab5cd33be..a3e3d6eb04 100644 --- a/web/src/routes/admin/jobs-status/+page.svelte +++ b/web/src/routes/admin/jobs-status/+page.svelte @@ -44,6 +44,8 @@ { title: $t('admin.person_cleanup_job'), value: ManualJobName.PersonCleanup }, { title: $t('admin.tag_cleanup_job'), value: ManualJobName.TagCleanup }, { title: $t('admin.user_cleanup_job'), value: ManualJobName.UserCleanup }, + { title: $t('admin.memory_cleanup_job'), value: ManualJobName.MemoryCleanup }, + { title: $t('admin.memory_generate_job'), value: ManualJobName.MemoryCreate }, ].map(({ value, title }) => ({ id: value, label: title, value })); const handleCancel = () => (isOpen = false); From 9c2c85cbe1bc0053edd8872cd2cf676f3dda9d10 Mon Sep 17 00:00:00 2001 From: Jonathan Jogenfors Date: Sat, 22 Feb 2025 00:00:16 +0100 Subject: [PATCH 195/395] feat(web): remove library type column (#16254) --- .../admin/library-management/+page.svelte | 40 +++++++++---------- 1 file changed, 19 insertions(+), 21 deletions(-) diff --git a/web/src/routes/admin/library-management/+page.svelte b/web/src/routes/admin/library-management/+page.svelte index 4c916bd49d..04325f9fc2 100644 --- a/web/src/routes/admin/library-management/+page.svelte +++ b/web/src/routes/admin/library-management/+page.svelte @@ -1,5 +1,4 @@ -
+
{/if} + + {#if isFaceEditMode.value} + + {/if}
From 8fbd65048321d3b618a6a42fe65d406dbc49ed4a Mon Sep 17 00:00:00 2001 From: Alex Date: Wed, 26 Feb 2025 17:04:43 -0600 Subject: [PATCH 221/395] refactor(mobile): refactor user provider (#16358) --- mobile/analysis_options.yaml | 4 ++-- mobile/lib/interfaces/user.interface.dart | 4 ++++ mobile/lib/pages/photos/photos.page.dart | 2 +- mobile/lib/providers/partner.provider.dart | 18 ++++++++++++---- mobile/lib/providers/timeline.provider.dart | 6 +++++- mobile/lib/providers/user.provider.dart | 22 +++++++------------- mobile/lib/repositories/user.repository.dart | 22 ++++++++++++++++++++ mobile/lib/services/user.service.dart | 10 +++++++++ 8 files changed, 65 insertions(+), 23 deletions(-) diff --git a/mobile/analysis_options.yaml b/mobile/analysis_options.yaml index 6794f39b81..dd081be64e 100644 --- a/mobile/analysis_options.yaml +++ b/mobile/analysis_options.yaml @@ -79,14 +79,14 @@ custom_lint: - lib/widgets/asset_grid/asset_grid_data_structure.dart - test/**.dart # refactor the remaining providers - - lib/providers/{db,user}.provider.dart + - lib/providers/db.provider.dart - lib/providers/backup/backup.provider.dart - import_rule_openapi: message: openapi must only be used through ApiRepositories restrict: package:openapi allowed: - # requried / wanted + # required / wanted - lib/repositories/*_api.repository.dart # acceptable exceptions for the time being - lib/entities/{album,asset,exif_info,user}.entity.dart # to convert DTOs to entities diff --git a/mobile/lib/interfaces/user.interface.dart b/mobile/lib/interfaces/user.interface.dart index d099e0e50b..17918ac170 100644 --- a/mobile/lib/interfaces/user.interface.dart +++ b/mobile/lib/interfaces/user.interface.dart @@ -22,6 +22,10 @@ abstract interface class IUserRepository implements IDatabaseRepository { Future me(); Future clearTable(); + + Future> getTimelineUserIds(int id); + + Stream> watchTimelineUsers(int id); } enum UserSort { id } diff --git a/mobile/lib/pages/photos/photos.page.dart b/mobile/lib/pages/photos/photos.page.dart index 7910d45e13..b3bfa366f2 100644 --- a/mobile/lib/pages/photos/photos.page.dart +++ b/mobile/lib/pages/photos/photos.page.dart @@ -110,7 +110,7 @@ class PhotosPage extends HookConsumerWidget { : const SizedBox(), renderListProvider: timelineUsers.length > 1 ? multiUsersTimelineProvider(timelineUsers) - : singleUserTimelineProvider(currentUser!.isarId), + : singleUserTimelineProvider(currentUser?.isarId), buildLoadingIndicator: buildLoadingIndicator, onRefresh: refreshAssets, stackEnabled: true, diff --git a/mobile/lib/providers/partner.provider.dart b/mobile/lib/providers/partner.provider.dart index 38b449e96a..282e779432 100644 --- a/mobile/lib/providers/partner.provider.dart +++ b/mobile/lib/providers/partner.provider.dart @@ -8,6 +8,7 @@ import 'package:immich_mobile/entities/user.entity.dart'; class PartnerSharedWithNotifier extends StateNotifier> { final PartnerService _partnerService; + late final StreamSubscription> streamSub; PartnerSharedWithNotifier(this._partnerService) : super([]) { Function eq = const ListEquality().equals; @@ -16,7 +17,7 @@ class PartnerSharedWithNotifier extends StateNotifier> { state = partners; } }).then((_) { - _partnerService.watchSharedWith().listen((partners) { + streamSub = _partnerService.watchSharedWith().listen((partners) { if (!eq(state, partners)) { state = partners; } @@ -27,6 +28,14 @@ class PartnerSharedWithNotifier extends StateNotifier> { Future updatePartner(User partner, {required bool inTimeline}) { return _partnerService.updatePartner(partner, inTimeline: inTimeline); } + + @override + void dispose() { + if (mounted) { + streamSub.cancel(); + } + super.dispose(); + } } final partnerSharedWithProvider = @@ -38,6 +47,7 @@ final partnerSharedWithProvider = class PartnerSharedByNotifier extends StateNotifier> { final PartnerService _partnerService; + late final StreamSubscription> streamSub; PartnerSharedByNotifier(this._partnerService) : super([]) { Function eq = const ListEquality().equals; @@ -54,11 +64,11 @@ class PartnerSharedByNotifier extends StateNotifier> { }); } - late final StreamSubscription> streamSub; - @override void dispose() { - streamSub.cancel(); + if (mounted) { + streamSub.cancel(); + } super.dispose(); } } diff --git a/mobile/lib/providers/timeline.provider.dart b/mobile/lib/providers/timeline.provider.dart index b0e9482b81..97d5698c4c 100644 --- a/mobile/lib/providers/timeline.provider.dart +++ b/mobile/lib/providers/timeline.provider.dart @@ -5,8 +5,12 @@ import 'package:immich_mobile/providers/locale_provider.dart'; import 'package:immich_mobile/services/timeline.service.dart'; import 'package:immich_mobile/widgets/asset_grid/asset_grid_data_structure.dart'; -final singleUserTimelineProvider = StreamProvider.family( +final singleUserTimelineProvider = StreamProvider.family( (ref, userId) { + if (userId == null) { + return const Stream.empty(); + } + ref.watch(localeProvider); final timelineService = ref.watch(timelineServiceProvider); return timelineService.watchHomeTimeline(userId); diff --git a/mobile/lib/providers/user.provider.dart b/mobile/lib/providers/user.provider.dart index c69245ea98..c143086a15 100644 --- a/mobile/lib/providers/user.provider.dart +++ b/mobile/lib/providers/user.provider.dart @@ -5,9 +5,8 @@ import 'package:immich_mobile/domain/models/store.model.dart'; import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/entities/user.entity.dart'; import 'package:immich_mobile/providers/api.provider.dart'; -import 'package:immich_mobile/providers/db.provider.dart'; import 'package:immich_mobile/services/api.service.dart'; -import 'package:isar/isar.dart'; +import 'package:immich_mobile/services/user.service.dart'; class CurrentUserProvider extends StateNotifier { CurrentUserProvider(this._apiService) : super(null) { @@ -47,18 +46,14 @@ final currentUserProvider = }); class TimelineUserIdsProvider extends StateNotifier> { - TimelineUserIdsProvider(Isar db, User? currentUser) : super([]) { - final query = db.users - .filter() - .inTimelineEqualTo(true) - .or() - .isarIdEqualTo(currentUser?.isarId ?? Isar.autoIncrement) - .isarIdProperty(); - query.findAll().then((users) => state = users); - streamSub = query.watch().listen((users) => state = users); + TimelineUserIdsProvider(this._userService) : super([]) { + _userService.getTimelineUserIds().then((users) => state = users); + streamSub = + _userService.watchTimelineUserIds().listen((users) => state = users); } late final StreamSubscription> streamSub; + final UserService _userService; @override void dispose() { @@ -69,8 +64,5 @@ class TimelineUserIdsProvider extends StateNotifier> { final timelineUsersIdsProvider = StateNotifierProvider>((ref) { - return TimelineUserIdsProvider( - ref.watch(dbProvider), - ref.watch(currentUserProvider), - ); + return TimelineUserIdsProvider(ref.watch(userServiceProvider)); }); diff --git a/mobile/lib/repositories/user.repository.dart b/mobile/lib/repositories/user.repository.dart index ea67b30e0d..190fb780c8 100644 --- a/mobile/lib/repositories/user.repository.dart +++ b/mobile/lib/repositories/user.repository.dart @@ -70,4 +70,26 @@ class UserRepository extends DatabaseRepository implements IUserRepository { await db.users.clear(); }); } + + @override + Future> getTimelineUserIds(int id) { + return db.users + .filter() + .inTimelineEqualTo(true) + .or() + .isarIdEqualTo(id) + .isarIdProperty() + .findAll(); + } + + @override + Stream> watchTimelineUsers(int id) { + return db.users + .filter() + .inTimelineEqualTo(true) + .or() + .isarIdEqualTo(id) + .isarIdProperty() + .watch(); + } } diff --git a/mobile/lib/services/user.service.dart b/mobile/lib/services/user.service.dart index 935a751e2a..a14b1c08f2 100644 --- a/mobile/lib/services/user.service.dart +++ b/mobile/lib/services/user.service.dart @@ -107,4 +107,14 @@ class UserService { Future clearTable() { return _userRepository.clearTable(); } + + Future> getTimelineUserIds() async { + final me = await _userRepository.me(); + return _userRepository.getTimelineUserIds(me.isarId); + } + + Stream> watchTimelineUserIds() async* { + final me = await _userRepository.me(); + yield* _userRepository.watchTimelineUsers(me.isarId); + } } From 4b55888d16a16fdc0fcdc3de143b59e7a3b0580d Mon Sep 17 00:00:00 2001 From: David Bourgault Date: Wed, 26 Feb 2025 21:53:21 -0500 Subject: [PATCH 222/395] fix: ensure manually tagged faces have proper source type (#16364) immich-app/immich#16062 added manual face tagging and deletion, but did not add a new 'SourceType'. The create faces would default to 'machine-learning' which is incorrect, and has the annoying downside that they will be wiped when the 'Refresh Faces' job is run. Handling of non-machine-learning faces was previously added in immich-app/immich#6455. This PR simply extends it to the new manually tagged faces. --- .../lib/model/asset_face_create_dto.dart | 10 ++++++- mobile/openapi/lib/model/source_type.dart | 3 +++ open-api/immich-openapi-specs.json | 12 ++++++++- open-api/typescript-sdk/src/fetch-client.ts | 4 ++- server/src/db.d.ts | 2 +- server/src/dtos/person.dto.ts | 6 ++++- server/src/enum.ts | 1 + .../1740619600996-AddManualSourceType.ts | 27 +++++++++++++++++++ server/src/services/person.service.ts | 1 + .../face-editor/face-editor.svelte | 3 ++- 10 files changed, 63 insertions(+), 6 deletions(-) create mode 100644 server/src/migrations/1740619600996-AddManualSourceType.ts diff --git a/mobile/openapi/lib/model/asset_face_create_dto.dart b/mobile/openapi/lib/model/asset_face_create_dto.dart index 29e8244a96..d25a5d8b82 100644 --- a/mobile/openapi/lib/model/asset_face_create_dto.dart +++ b/mobile/openapi/lib/model/asset_face_create_dto.dart @@ -18,6 +18,7 @@ class AssetFaceCreateDto { required this.imageHeight, required this.imageWidth, required this.personId, + this.sourceType = SourceType.manual, required this.width, required this.x, required this.y, @@ -33,6 +34,8 @@ class AssetFaceCreateDto { String personId; + SourceType sourceType; + int width; int x; @@ -46,6 +49,7 @@ class AssetFaceCreateDto { other.imageHeight == imageHeight && other.imageWidth == imageWidth && other.personId == personId && + other.sourceType == sourceType && other.width == width && other.x == x && other.y == y; @@ -58,12 +62,13 @@ class AssetFaceCreateDto { (imageHeight.hashCode) + (imageWidth.hashCode) + (personId.hashCode) + + (sourceType.hashCode) + (width.hashCode) + (x.hashCode) + (y.hashCode); @override - String toString() => 'AssetFaceCreateDto[assetId=$assetId, height=$height, imageHeight=$imageHeight, imageWidth=$imageWidth, personId=$personId, width=$width, x=$x, y=$y]'; + String toString() => 'AssetFaceCreateDto[assetId=$assetId, height=$height, imageHeight=$imageHeight, imageWidth=$imageWidth, personId=$personId, sourceType=$sourceType, width=$width, x=$x, y=$y]'; Map toJson() { final json = {}; @@ -72,6 +77,7 @@ class AssetFaceCreateDto { json[r'imageHeight'] = this.imageHeight; json[r'imageWidth'] = this.imageWidth; json[r'personId'] = this.personId; + json[r'sourceType'] = this.sourceType; json[r'width'] = this.width; json[r'x'] = this.x; json[r'y'] = this.y; @@ -92,6 +98,7 @@ class AssetFaceCreateDto { imageHeight: mapValueOfType(json, r'imageHeight')!, imageWidth: mapValueOfType(json, r'imageWidth')!, personId: mapValueOfType(json, r'personId')!, + sourceType: SourceType.fromJson(json[r'sourceType'])!, width: mapValueOfType(json, r'width')!, x: mapValueOfType(json, r'x')!, y: mapValueOfType(json, r'y')!, @@ -147,6 +154,7 @@ class AssetFaceCreateDto { 'imageHeight', 'imageWidth', 'personId', + 'sourceType', 'width', 'x', 'y', diff --git a/mobile/openapi/lib/model/source_type.dart b/mobile/openapi/lib/model/source_type.dart index 13c450b010..4da5aba495 100644 --- a/mobile/openapi/lib/model/source_type.dart +++ b/mobile/openapi/lib/model/source_type.dart @@ -25,11 +25,13 @@ class SourceType { static const machineLearning = SourceType._(r'machine-learning'); static const exif = SourceType._(r'exif'); + static const manual = SourceType._(r'manual'); /// List of all possible values in this [enum][SourceType]. static const values = [ machineLearning, exif, + manual, ]; static SourceType? fromJson(dynamic value) => SourceTypeTypeTransformer().decode(value); @@ -70,6 +72,7 @@ class SourceTypeTypeTransformer { switch (data) { case r'machine-learning': return SourceType.machineLearning; case r'exif': return SourceType.exif; + case r'manual': return SourceType.manual; default: if (!allowNull) { throw ArgumentError('Unknown enum value to decode: $data'); diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index 6a57001085..aeafc27ee6 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -8301,6 +8301,14 @@ "format": "uuid", "type": "string" }, + "sourceType": { + "allOf": [ + { + "$ref": "#/components/schemas/SourceType" + } + ], + "default": "manual" + }, "width": { "type": "integer" }, @@ -8317,6 +8325,7 @@ "imageHeight", "imageWidth", "personId", + "sourceType", "width", "x", "y" @@ -11952,7 +11961,8 @@ "SourceType": { "enum": [ "machine-learning", - "exif" + "exif", + "manual" ], "type": "string" }, diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index 7786c09d9a..7237e0aac3 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -529,6 +529,7 @@ export type AssetFaceCreateDto = { imageHeight: number; imageWidth: number; personId: string; + sourceType: SourceType; width: number; x: number; y: number; @@ -3453,7 +3454,8 @@ export enum AlbumUserRole { } export enum SourceType { MachineLearning = "machine-learning", - Exif = "exif" + Exif = "exif", + Manual = "manual" } export enum AssetTypeEnum { Image = "IMAGE", diff --git a/server/src/db.d.ts b/server/src/db.d.ts index 4a2adc917f..bc88d7de3c 100644 --- a/server/src/db.d.ts +++ b/server/src/db.d.ts @@ -29,7 +29,7 @@ export type JsonPrimitive = boolean | number | string | null; export type JsonValue = JsonArray | JsonObject | JsonPrimitive; -export type Sourcetype = 'exif' | 'machine-learning'; +export type Sourcetype = 'exif' | 'machine-learning' | 'manual'; export type Timestamp = ColumnType; diff --git a/server/src/dtos/person.dto.ts b/server/src/dtos/person.dto.ts index 0778c35b8f..c4d3018be2 100644 --- a/server/src/dtos/person.dto.ts +++ b/server/src/dtos/person.dto.ts @@ -1,6 +1,6 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { Type } from 'class-transformer'; -import { IsArray, IsInt, IsNotEmpty, IsNumber, IsString, Max, Min, ValidateNested } from 'class-validator'; +import { IsArray, IsEnum, IsInt, IsNotEmpty, IsNumber, IsString, Max, Min, ValidateNested } from 'class-validator'; import { DateTime } from 'luxon'; import { PropertyLifecycle } from 'src/decorators'; import { AuthDto } from 'src/dtos/auth.dto'; @@ -194,6 +194,10 @@ export class AssetFaceCreateDto extends AssetFaceUpdateItem { @IsNotEmpty() @IsNumber() height!: number; + + @ApiProperty({ type: 'string', enum: SourceType, enumName: 'SourceType' }) + @IsEnum(SourceType) + sourceType: SourceType = SourceType.MANUAL; } export class AssetFaceDeleteDto { diff --git a/server/src/enum.ts b/server/src/enum.ts index 7bf4ca3dcf..676e1d27db 100644 --- a/server/src/enum.ts +++ b/server/src/enum.ts @@ -228,6 +228,7 @@ export enum AssetStatus { export enum SourceType { MACHINE_LEARNING = 'machine-learning', EXIF = 'exif', + MANUAL = 'manual', } export enum ManualJobName { diff --git a/server/src/migrations/1740619600996-AddManualSourceType.ts b/server/src/migrations/1740619600996-AddManualSourceType.ts new file mode 100644 index 0000000000..dd53312ad7 --- /dev/null +++ b/server/src/migrations/1740619600996-AddManualSourceType.ts @@ -0,0 +1,27 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddManualSourceType1740619600996 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TYPE sourceType ADD VALUE 'manual'`); + } + + public async down(queryRunner: QueryRunner): Promise { + // Prior to this migration, manually tagged pictures had the 'machine-learning' type + await queryRunner.query( + `UPDATE "asset_faces" SET "sourceType" = 'machine-learning' WHERE "sourceType" = 'manual';`, + ); + + // Postgres doesn't allow removing values from enums, we have to recreate the type + await queryRunner.query(`ALTER TYPE sourceType RENAME TO oldSourceType`); + await queryRunner.query(`CREATE TYPE sourceType AS ENUM ('machine-learning', 'exif');`); + + await queryRunner.query(`ALTER TABLE "asset_faces" ALTER COLUMN "sourceType" DROP DEFAULT;`); + await queryRunner.query( + `ALTER TABLE "asset_faces" ALTER COLUMN "sourceType" TYPE sourceType USING "sourceType"::text::sourceType;`, + ); + await queryRunner.query( + `ALTER TABLE "asset_faces" ALTER COLUMN "sourceType" SET DEFAULT 'machine-learning'::sourceType;`, + ); + await queryRunner.query(`DROP TYPE oldSourceType;`); + } +} diff --git a/server/src/services/person.service.ts b/server/src/services/person.service.ts index dd998cc0fe..62bf55a78c 100644 --- a/server/src/services/person.service.ts +++ b/server/src/services/person.service.ts @@ -736,6 +736,7 @@ export class PersonService extends BaseService { boundingBoxX2: dto.x + dto.width, boundingBoxY1: dto.y, boundingBoxY2: dto.y + dto.height, + sourceType: dto.sourceType, }); } diff --git a/web/src/lib/components/asset-viewer/face-editor/face-editor.svelte b/web/src/lib/components/asset-viewer/face-editor/face-editor.svelte index afe45331e4..bcc9ee6875 100644 --- a/web/src/lib/components/asset-viewer/face-editor/face-editor.svelte +++ b/web/src/lib/components/asset-viewer/face-editor/face-editor.svelte @@ -4,7 +4,7 @@ import { notificationController } from '$lib/components/shared-components/notification/notification'; import { isFaceEditMode } from '$lib/stores/face-edit.svelte'; import { getPeopleThumbnailUrl } from '$lib/utils'; - import { getAllPeople, createFace, type PersonResponseDto } from '@immich/sdk'; + import { getAllPeople, createFace, type PersonResponseDto, SourceType } from '@immich/sdk'; import { Button } from '@immich/ui'; import { Canvas, InteractiveFabricObject, Rect } from 'fabric'; import { onMount } from 'svelte'; @@ -288,6 +288,7 @@ assetFaceCreateDto: { assetId, personId: person.id, + sourceType: SourceType.Manual, ...data, }, }); From 8b6911492419594f8e671bef7a041347de27bbf4 Mon Sep 17 00:00:00 2001 From: David Bourgault Date: Wed, 26 Feb 2025 22:01:29 -0500 Subject: [PATCH 223/395] feat(web): remember last chosen map location when editing (#16366) Uses a global store to remember the last location chosen by a user when editing asset locations. This fixes an annoyance when adding location data to multiple assets in a row and having to zoom in the same area everytime. --- .../shared-components/change-location.svelte | 27 ++++++++++++------- web/src/lib/stores/asset-editor.store.ts | 1 + 2 files changed, 19 insertions(+), 9 deletions(-) diff --git a/web/src/lib/components/shared-components/change-location.svelte b/web/src/lib/components/shared-components/change-location.svelte index 5970b91160..d2dbeb5488 100644 --- a/web/src/lib/components/shared-components/change-location.svelte +++ b/web/src/lib/components/shared-components/change-location.svelte @@ -2,6 +2,7 @@ import ConfirmDialog from './dialog/confirm-dialog.svelte'; import { timeDebounceOnSearch } from '$lib/constants'; import { handleError } from '$lib/utils/handle-error'; + import { lastChosenLocation } from '$lib/stores/asset-editor.store'; import { clickOutside } from '$lib/actions/click-outside'; import LoadingSpinner from './loading-spinner.svelte'; @@ -13,6 +14,7 @@ import { t } from 'svelte-i18n'; import CoordinatesInput from '$lib/components/shared-components/coordinates-input.svelte'; import Map from '$lib/components/shared-components/map/map.svelte'; + import { get } from 'svelte/store'; interface Point { lng: number; @@ -36,9 +38,15 @@ let hideSuggestion = $state(false); let mapElement = $state>(); - let lat = $derived(asset?.exifInfo?.latitude ?? undefined); - let lng = $derived(asset?.exifInfo?.longitude ?? undefined); - let zoom = $derived(lat !== undefined && lng !== undefined ? 12.5 : 1); + let previousLocation = get(lastChosenLocation); + + let assetLat = $derived(asset?.exifInfo?.latitude ?? undefined); + let assetLng = $derived(asset?.exifInfo?.longitude ?? undefined); + + let mapLat = $derived(assetLat ?? previousLocation?.lat ?? undefined); + let mapLng = $derived(assetLng ?? previousLocation?.lng ?? undefined); + + let zoom = $derived(mapLat !== undefined && mapLng !== undefined ? 12.5 : 1); $effect(() => { if (places) { @@ -53,6 +61,7 @@ const handleConfirm = () => { if (point) { + lastChosenLocation.set(point); onConfirm(point); } else { onCancel(); @@ -160,12 +169,12 @@ {:then { default: Map }} (point = selected)} @@ -183,8 +192,8 @@
{ point = { lat, lng }; mapElement?.addClipMapMarker(lng, lat); diff --git a/web/src/lib/stores/asset-editor.store.ts b/web/src/lib/stores/asset-editor.store.ts index 4d2f8977ee..ec06c2cef5 100644 --- a/web/src/lib/stores/asset-editor.store.ts +++ b/web/src/lib/stores/asset-editor.store.ts @@ -17,6 +17,7 @@ export const normaizedRorateDegrees = derived(rotateDegrees, (v) => { export const changedOriention = derived(normaizedRorateDegrees, () => get(normaizedRorateDegrees) % 180 > 0); //-----other export const showCancelConfirmDialog = writable(false); +export const lastChosenLocation = writable<{ lng: number; lat: number } | null>(null); export const editTypes = [ { From 128d653fc619be4ead3483b9d40ce6043ff21df6 Mon Sep 17 00:00:00 2001 From: Curtis Lowder Date: Wed, 26 Feb 2025 21:06:41 -0600 Subject: [PATCH 224/395] fix(web): update search modal to not jump around (#16308) * fix(web): update search modal to not jump around Search People selection will change size while loading. This causes the search modal to jump around as the people load in. * loading spinner size * remove unsued code --------- Co-authored-by: cwlowder Co-authored-by: Alex Tran --- .../search-bar/search-people-section.svelte | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/web/src/lib/components/shared-components/search-bar/search-people-section.svelte b/web/src/lib/components/shared-components/search-bar/search-people-section.svelte index d06c4dc5c0..ed17d78af3 100644 --- a/web/src/lib/components/shared-components/search-bar/search-people-section.svelte +++ b/web/src/lib/components/shared-components/search-bar/search-people-section.svelte @@ -10,6 +10,7 @@ import { t } from 'svelte-i18n'; import SingleGridRow from '$lib/components/shared-components/single-grid-row.svelte'; import type { SvelteSet } from 'svelte/reactivity'; + import LoadingSpinner from '$lib/components/shared-components/loading-spinner.svelte'; interface Props { selectedPeople: SvelteSet; @@ -52,13 +53,17 @@ }; -{#await peoplePromise then people} +{#await peoplePromise} +
+ +
+{:then people} {#if people && people.length > 0} {@const peopleList = showAllPeople ? filterPeople(people, name) : filterPeople(people, name).slice(0, numberOfPeople)} -
+

{$t('people').toUpperCase()}

From 967c69317bef6193f0f1fc1210e34a8c355bc7b4 Mon Sep 17 00:00:00 2001 From: Zack Pollard Date: Thu, 27 Feb 2025 12:55:22 +0000 Subject: [PATCH 225/395] feat: updateId uuidv7 column for all entities with updatedAt (#16353) --- server/src/db.d.ts | 13 ++ server/src/entities/activity.entity.ts | 4 + server/src/entities/album.entity.ts | 5 + server/src/entities/api-key.entity.ts | 6 +- server/src/entities/asset-files.entity.ts | 4 + server/src/entities/asset.entity.ts | 4 + server/src/entities/library.entity.ts | 5 + server/src/entities/memory.entity.ts | 5 + server/src/entities/partner.entity.ts | 15 +- server/src/entities/person.entity.ts | 5 + server/src/entities/session.entity.ts | 6 +- server/src/entities/sync-checkpoint.entity.ts | 6 +- server/src/entities/tag.entity.ts | 5 + server/src/entities/user.entity.ts | 4 + .../1740586617223-AddUpdateIdColumns.ts | 134 ++++++++++++++++++ server/src/services/session.service.spec.ts | 1 + server/test/fixtures/activity.stub.ts | 2 + server/test/fixtures/session.stub.ts | 2 + 18 files changed, 222 insertions(+), 4 deletions(-) create mode 100644 server/src/migrations/1740586617223-AddUpdateIdColumns.ts diff --git a/server/src/db.d.ts b/server/src/db.d.ts index bc88d7de3c..ff4cb4a1d2 100644 --- a/server/src/db.d.ts +++ b/server/src/db.d.ts @@ -41,6 +41,7 @@ export interface Activity { id: Generated; isLiked: Generated; updatedAt: Generated; + updateId: Generated; userId: string; } @@ -58,6 +59,7 @@ export interface Albums { order: Generated; ownerId: string; updatedAt: Generated; + updateId: Generated; } export interface AlbumsAssetsAssets { @@ -79,6 +81,7 @@ export interface ApiKeys { name: string; permissions: Permission[]; updatedAt: Generated; + updateId: Generated; userId: string; } @@ -103,6 +106,7 @@ export interface AssetFiles { path: string; type: string; updatedAt: Generated; + updateId: Generated; } export interface AssetJobStatus { @@ -143,6 +147,7 @@ export interface Assets { thumbhash: Buffer | null; type: string; updatedAt: Generated; + updateId: Generated; } export interface AssetStack { @@ -221,6 +226,7 @@ export interface Libraries { ownerId: string; refreshedAt: Timestamp | null; updatedAt: Generated; + updateId: Generated; } export interface Memories { @@ -236,6 +242,7 @@ export interface Memories { showAt: Timestamp | null; type: string; updatedAt: Generated; + updateId: Generated; } export interface MemoriesAssetsAssets { @@ -271,6 +278,7 @@ export interface Partners { sharedById: string; sharedWithId: string; updatedAt: Generated; + updateId: Generated; } export interface Person { @@ -285,6 +293,7 @@ export interface Person { ownerId: string; thumbnailPath: Generated; updatedAt: Generated; + updateId: Generated; } export interface Sessions { @@ -294,6 +303,7 @@ export interface Sessions { id: Generated; token: string; updatedAt: Generated; + updateId: Generated; userId: string; } @@ -303,6 +313,7 @@ export interface SessionSyncCheckpoints { sessionId: string; type: SyncEntityType; updatedAt: Generated; + updateId: Generated; } @@ -358,6 +369,7 @@ export interface Tags { id: Generated; parentId: string | null; updatedAt: Generated; + updateId: Generated; userId: string; value: string; } @@ -399,6 +411,7 @@ export interface Users { status: Generated; storageLabel: string | null; updatedAt: Generated; + updateId: Generated; } export interface UsersAudit { diff --git a/server/src/entities/activity.entity.ts b/server/src/entities/activity.entity.ts index 8de76ac894..dabb371977 100644 --- a/server/src/entities/activity.entity.ts +++ b/server/src/entities/activity.entity.ts @@ -25,6 +25,10 @@ export class ActivityEntity { @UpdateDateColumn({ type: 'timestamptz' }) updatedAt!: Date; + @Index('IDX_activity_update_id') + @Column({ type: 'uuid', nullable: false, default: () => 'immich_uuid_v7()' }) + updateId?: string; + @Column() albumId!: string; diff --git a/server/src/entities/album.entity.ts b/server/src/entities/album.entity.ts index 5aec5a0f47..4cd7c82394 100644 --- a/server/src/entities/album.entity.ts +++ b/server/src/entities/album.entity.ts @@ -8,6 +8,7 @@ import { CreateDateColumn, DeleteDateColumn, Entity, + Index, JoinTable, ManyToMany, ManyToOne, @@ -39,6 +40,10 @@ export class AlbumEntity { @UpdateDateColumn({ type: 'timestamptz' }) updatedAt!: Date; + @Index('IDX_albums_update_id') + @Column({ type: 'uuid', nullable: false, default: () => 'immich_uuid_v7()' }) + updateId?: string; + @DeleteDateColumn({ type: 'timestamptz' }) deletedAt!: Date | null; diff --git a/server/src/entities/api-key.entity.ts b/server/src/entities/api-key.entity.ts index 998ee4f8ef..f59bf0d918 100644 --- a/server/src/entities/api-key.entity.ts +++ b/server/src/entities/api-key.entity.ts @@ -1,6 +1,6 @@ import { UserEntity } from 'src/entities/user.entity'; import { Permission } from 'src/enum'; -import { Column, CreateDateColumn, Entity, ManyToOne, PrimaryGeneratedColumn, UpdateDateColumn } from 'typeorm'; +import { Column, CreateDateColumn, Entity, Index, ManyToOne, PrimaryGeneratedColumn, UpdateDateColumn } from 'typeorm'; @Entity('api_keys') export class APIKeyEntity { @@ -27,4 +27,8 @@ export class APIKeyEntity { @UpdateDateColumn({ type: 'timestamptz' }) updatedAt!: Date; + + @Index('IDX_api_keys_update_id') + @Column({ type: 'uuid', nullable: false, default: () => 'immich_uuid_v7()' }) + updateId?: string; } diff --git a/server/src/entities/asset-files.entity.ts b/server/src/entities/asset-files.entity.ts index a8a6ddfee1..09f96e849d 100644 --- a/server/src/entities/asset-files.entity.ts +++ b/server/src/entities/asset-files.entity.ts @@ -30,6 +30,10 @@ export class AssetFileEntity { @UpdateDateColumn({ type: 'timestamptz' }) updatedAt!: Date; + @Index('IDX_asset_files_update_id') + @Column({ type: 'uuid', nullable: false, default: () => 'immich_uuid_v7()' }) + updateId?: string; + @Column() type!: AssetFileType; diff --git a/server/src/entities/asset.entity.ts b/server/src/entities/asset.entity.ts index a325febce7..7345d9a2e6 100644 --- a/server/src/entities/asset.entity.ts +++ b/server/src/entities/asset.entity.ts @@ -96,6 +96,10 @@ export class AssetEntity { @UpdateDateColumn({ type: 'timestamptz' }) updatedAt!: Date; + @Index('IDX_assets_update_id') + @Column({ type: 'uuid', nullable: false, default: () => 'immich_uuid_v7()' }) + updateId?: string; + @DeleteDateColumn({ type: 'timestamptz', nullable: true }) deletedAt!: Date | null; diff --git a/server/src/entities/library.entity.ts b/server/src/entities/library.entity.ts index a6053e4213..a594fd83ad 100644 --- a/server/src/entities/library.entity.ts +++ b/server/src/entities/library.entity.ts @@ -5,6 +5,7 @@ import { CreateDateColumn, DeleteDateColumn, Entity, + Index, JoinTable, ManyToOne, OneToMany, @@ -42,6 +43,10 @@ export class LibraryEntity { @UpdateDateColumn({ type: 'timestamptz' }) updatedAt!: Date; + @Index('IDX_libraries_update_id') + @Column({ type: 'uuid', nullable: false, default: () => 'immich_uuid_v7()' }) + updateId?: string; + @DeleteDateColumn({ type: 'timestamptz' }) deletedAt?: Date; diff --git a/server/src/entities/memory.entity.ts b/server/src/entities/memory.entity.ts index 1f53d7a5c1..dafd7eb21c 100644 --- a/server/src/entities/memory.entity.ts +++ b/server/src/entities/memory.entity.ts @@ -6,6 +6,7 @@ import { CreateDateColumn, DeleteDateColumn, Entity, + Index, JoinTable, ManyToMany, ManyToOne, @@ -30,6 +31,10 @@ export class MemoryEntity { @UpdateDateColumn({ type: 'timestamptz' }) updatedAt!: Date; + @Index('IDX_memories_update_id') + @Column({ type: 'uuid', nullable: false, default: () => 'immich_uuid_v7()' }) + updateId?: string; + @DeleteDateColumn({ type: 'timestamptz' }) deletedAt?: Date; diff --git a/server/src/entities/partner.entity.ts b/server/src/entities/partner.entity.ts index 189f6f51a7..877330a8e7 100644 --- a/server/src/entities/partner.entity.ts +++ b/server/src/entities/partner.entity.ts @@ -1,5 +1,14 @@ import { UserEntity } from 'src/entities/user.entity'; -import { Column, CreateDateColumn, Entity, JoinColumn, ManyToOne, PrimaryColumn, UpdateDateColumn } from 'typeorm'; +import { + Column, + CreateDateColumn, + Entity, + Index, + JoinColumn, + ManyToOne, + PrimaryColumn, + UpdateDateColumn, +} from 'typeorm'; @Entity('partners') export class PartnerEntity { @@ -23,6 +32,10 @@ export class PartnerEntity { @UpdateDateColumn({ type: 'timestamptz' }) updatedAt!: Date; + @Index('IDX_partners_update_id') + @Column({ type: 'uuid', nullable: false, default: () => 'immich_uuid_v7()' }) + updateId?: string; + @Column({ type: 'boolean', default: false }) inTimeline!: boolean; } diff --git a/server/src/entities/person.entity.ts b/server/src/entities/person.entity.ts index 3785e1985e..5ca74c12d2 100644 --- a/server/src/entities/person.entity.ts +++ b/server/src/entities/person.entity.ts @@ -5,6 +5,7 @@ import { Column, CreateDateColumn, Entity, + Index, ManyToOne, OneToMany, PrimaryGeneratedColumn, @@ -23,6 +24,10 @@ export class PersonEntity { @UpdateDateColumn({ type: 'timestamptz' }) updatedAt!: Date; + @Index('IDX_person_update_id') + @Column({ type: 'uuid', nullable: false, default: () => 'immich_uuid_v7()' }) + updateId?: string; + @Column() ownerId!: string; diff --git a/server/src/entities/session.entity.ts b/server/src/entities/session.entity.ts index e21c6d52ba..cb208c958e 100644 --- a/server/src/entities/session.entity.ts +++ b/server/src/entities/session.entity.ts @@ -1,7 +1,7 @@ import { ExpressionBuilder } from 'kysely'; import { DB } from 'src/db'; import { UserEntity } from 'src/entities/user.entity'; -import { Column, CreateDateColumn, Entity, ManyToOne, PrimaryGeneratedColumn, UpdateDateColumn } from 'typeorm'; +import { Column, CreateDateColumn, Entity, Index, ManyToOne, PrimaryGeneratedColumn, UpdateDateColumn } from 'typeorm'; @Entity('sessions') export class SessionEntity { @@ -23,6 +23,10 @@ export class SessionEntity { @UpdateDateColumn({ type: 'timestamptz' }) updatedAt!: Date; + @Index('IDX_sessions_update_id') + @Column({ type: 'uuid', nullable: false, default: () => 'immich_uuid_v7()' }) + updateId!: string; + @Column({ default: '' }) deviceType!: string; diff --git a/server/src/entities/sync-checkpoint.entity.ts b/server/src/entities/sync-checkpoint.entity.ts index 2a91d2386c..7c6818aba0 100644 --- a/server/src/entities/sync-checkpoint.entity.ts +++ b/server/src/entities/sync-checkpoint.entity.ts @@ -1,6 +1,6 @@ import { SessionEntity } from 'src/entities/session.entity'; import { SyncEntityType } from 'src/enum'; -import { Column, CreateDateColumn, Entity, ManyToOne, PrimaryColumn, UpdateDateColumn } from 'typeorm'; +import { Column, CreateDateColumn, Entity, Index, ManyToOne, PrimaryColumn, UpdateDateColumn } from 'typeorm'; @Entity('session_sync_checkpoints') export class SessionSyncCheckpointEntity { @@ -19,6 +19,10 @@ export class SessionSyncCheckpointEntity { @UpdateDateColumn({ type: 'timestamptz' }) updatedAt!: Date; + @Index('IDX_session_sync_checkpoints_update_id') + @Column({ type: 'uuid', nullable: false, default: () => 'immich_uuid_v7()' }) + updateId?: string; + @Column() ack!: string; } diff --git a/server/src/entities/tag.entity.ts b/server/src/entities/tag.entity.ts index ebcc6853c9..fcbde6c779 100644 --- a/server/src/entities/tag.entity.ts +++ b/server/src/entities/tag.entity.ts @@ -4,6 +4,7 @@ import { Column, CreateDateColumn, Entity, + Index, ManyToMany, ManyToOne, PrimaryGeneratedColumn, @@ -30,6 +31,10 @@ export class TagEntity { @UpdateDateColumn({ type: 'timestamptz' }) updatedAt!: Date; + @Index('IDX_tags_update_id') + @Column({ type: 'uuid', nullable: false, default: () => 'immich_uuid_v7()' }) + updateId?: string; + @Column({ type: 'varchar', nullable: true, default: null }) color!: string | null; diff --git a/server/src/entities/user.entity.ts b/server/src/entities/user.entity.ts index b597d15cf9..5758e29098 100644 --- a/server/src/entities/user.entity.ts +++ b/server/src/entities/user.entity.ts @@ -58,6 +58,10 @@ export class UserEntity { @UpdateDateColumn({ type: 'timestamptz' }) updatedAt!: Date; + @Index('IDX_users_update_id') + @Column({ type: 'uuid', nullable: false, default: () => 'immich_uuid_v7()' }) + updateId?: string; + @OneToMany(() => TagEntity, (tag) => tag.user) tags!: TagEntity[]; diff --git a/server/src/migrations/1740586617223-AddUpdateIdColumns.ts b/server/src/migrations/1740586617223-AddUpdateIdColumns.ts new file mode 100644 index 0000000000..02d680ddf6 --- /dev/null +++ b/server/src/migrations/1740586617223-AddUpdateIdColumns.ts @@ -0,0 +1,134 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class AddUpdateIdColumns1740586617223 implements MigrationInterface { + name = 'AddUpdateIdColumns1740586617223' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + create or replace function immich_uuid_v7(p_timestamp timestamp with time zone default clock_timestamp()) + returns uuid + as $$ + select encode( + set_bit( + set_bit( + overlay(uuid_send(gen_random_uuid()) + placing substring(int8send(floor(extract(epoch from p_timestamp) * 1000)::bigint) from 3) + from 1 for 6 + ), + 52, 1 + ), + 53, 1 + ), + 'hex')::uuid; + $$ + language SQL + volatile; + `) + await queryRunner.query(` + CREATE OR REPLACE FUNCTION updated_at() RETURNS TRIGGER + LANGUAGE plpgsql + as $$ + BEGIN + return new; + END; + $$; + `) + await queryRunner.query(`ALTER TABLE "person" ADD "updateId" uuid`); + await queryRunner.query(`ALTER TABLE "asset_files" ADD "updateId" uuid`); + await queryRunner.query(`ALTER TABLE "libraries" ADD "updateId" uuid`); + await queryRunner.query(`ALTER TABLE "tags" ADD "updateId" uuid`); + await queryRunner.query(`ALTER TABLE "assets" ADD "updateId" uuid`); + await queryRunner.query(`ALTER TABLE "users" ADD "updateId" uuid`); + await queryRunner.query(`ALTER TABLE "albums" ADD "updateId" uuid`); + await queryRunner.query(`ALTER TABLE "sessions" ADD "updateId" uuid`); + await queryRunner.query(`ALTER TABLE "session_sync_checkpoints" ADD "updateId" uuid`); + await queryRunner.query(`ALTER TABLE "partners" ADD "updateId" uuid`); + await queryRunner.query(`ALTER TABLE "memories" ADD "updateId" uuid`); + await queryRunner.query(`ALTER TABLE "api_keys" ADD "updateId" uuid`); + await queryRunner.query(`ALTER TABLE "activity" ADD "updateId" uuid`); + + await queryRunner.query(`UPDATE "person" SET "updateId" = immich_uuid_v7("updatedAt")`); + await queryRunner.query(`UPDATE "asset_files" SET "updateId" = immich_uuid_v7("updatedAt")`); + await queryRunner.query(`UPDATE "libraries" SET "updateId" = immich_uuid_v7("updatedAt")`); + await queryRunner.query(`UPDATE "tags" SET "updateId" = immich_uuid_v7("updatedAt")`); + await queryRunner.query(`UPDATE "assets" SET "updateId" = immich_uuid_v7("updatedAt")`); + await queryRunner.query(`UPDATE "users" SET "updateId" = immich_uuid_v7("updatedAt")`); + await queryRunner.query(`UPDATE "albums" SET "updateId" = immich_uuid_v7("updatedAt")`); + await queryRunner.query(`UPDATE "sessions" SET "updateId" = immich_uuid_v7("updatedAt")`); + await queryRunner.query(`UPDATE "session_sync_checkpoints" SET "updateId" = immich_uuid_v7("updatedAt")`); + await queryRunner.query(`UPDATE "partners" SET "updateId" = immich_uuid_v7("updatedAt")`); + await queryRunner.query(`UPDATE "memories" SET "updateId" = immich_uuid_v7("updatedAt")`); + await queryRunner.query(`UPDATE "api_keys" SET "updateId" = immich_uuid_v7("updatedAt")`); + await queryRunner.query(`UPDATE "activity" SET "updateId" = immich_uuid_v7("updatedAt")`); + + await queryRunner.query(`ALTER TABLE "person" ALTER COLUMN "updateId" SET NOT NULL, ALTER COLUMN "updateId" SET DEFAULT immich_uuid_v7()`); + await queryRunner.query(`ALTER TABLE "asset_files" ALTER COLUMN "updateId" SET NOT NULL, ALTER COLUMN "updateId" SET DEFAULT immich_uuid_v7()`); + await queryRunner.query(`ALTER TABLE "libraries" ALTER COLUMN "updateId" SET NOT NULL, ALTER COLUMN "updateId" SET DEFAULT immich_uuid_v7()`); + await queryRunner.query(`ALTER TABLE "tags" ALTER COLUMN "updateId" SET NOT NULL, ALTER COLUMN "updateId" SET DEFAULT immich_uuid_v7()`); + await queryRunner.query(`ALTER TABLE "assets" ALTER COLUMN "updateId" SET NOT NULL, ALTER COLUMN "updateId" SET DEFAULT immich_uuid_v7()`); + await queryRunner.query(`ALTER TABLE "users" ALTER COLUMN "updateId" SET NOT NULL, ALTER COLUMN "updateId" SET DEFAULT immich_uuid_v7()`); + await queryRunner.query(`ALTER TABLE "albums" ALTER COLUMN "updateId" SET NOT NULL, ALTER COLUMN "updateId" SET DEFAULT immich_uuid_v7()`); + await queryRunner.query(`ALTER TABLE "sessions" ALTER COLUMN "updateId" SET NOT NULL, ALTER COLUMN "updateId" SET DEFAULT immich_uuid_v7()`); + await queryRunner.query(`ALTER TABLE "session_sync_checkpoints" ALTER COLUMN "updateId" SET NOT NULL, ALTER COLUMN "updateId" SET DEFAULT immich_uuid_v7()`); + await queryRunner.query(`ALTER TABLE "partners" ALTER COLUMN "updateId" SET NOT NULL, ALTER COLUMN "updateId" SET DEFAULT immich_uuid_v7()`); + await queryRunner.query(`ALTER TABLE "memories" ALTER COLUMN "updateId" SET NOT NULL, ALTER COLUMN "updateId" SET DEFAULT immich_uuid_v7()`); + await queryRunner.query(`ALTER TABLE "api_keys" ALTER COLUMN "updateId" SET NOT NULL, ALTER COLUMN "updateId" SET DEFAULT immich_uuid_v7()`); + await queryRunner.query(`ALTER TABLE "activity" ALTER COLUMN "updateId" SET NOT NULL, ALTER COLUMN "updateId" SET DEFAULT immich_uuid_v7()`); + + await queryRunner.query(`CREATE INDEX "IDX_person_update_id" ON "person" ("updateId")`); + await queryRunner.query(`CREATE INDEX "IDX_asset_files_update_id" ON "asset_files" ("updateId")`); + await queryRunner.query(`CREATE INDEX "IDX_libraries_update_id" ON "libraries" ("updateId")`); + await queryRunner.query(`CREATE INDEX "IDX_tags_update_id" ON "tags" ("updateId")`); + await queryRunner.query(`CREATE INDEX "IDX_assets_update_id" ON "assets" ("updateId")`); + await queryRunner.query(`CREATE INDEX "IDX_users_update_id" ON "users" ("updateId")`); + await queryRunner.query(`CREATE INDEX "IDX_albums_update_id" ON "albums" ("updateId")`); + await queryRunner.query(`CREATE INDEX "IDX_sessions_update_id" ON "sessions" ("updateId")`); + await queryRunner.query(`CREATE INDEX "IDX_session_sync_checkpoints_update_id" ON "session_sync_checkpoints" ("updateId")`); + await queryRunner.query(`CREATE INDEX "IDX_partners_update_id" ON "partners" ("updateId")`); + await queryRunner.query(`CREATE INDEX "IDX_memories_update_id" ON "memories" ("updateId")`); + await queryRunner.query(`CREATE INDEX "IDX_api_keys_update_id" ON "api_keys" ("updateId")`); + await queryRunner.query(`CREATE INDEX "IDX_activity_update_id" ON "activity" ("updateId")`); + + await queryRunner.query(` + CREATE OR REPLACE FUNCTION updated_at() RETURNS TRIGGER + LANGUAGE plpgsql + as $$ + DECLARE + clock_timestamp TIMESTAMP := clock_timestamp(); + BEGIN + new."updatedAt" = clock_timestamp; + new."updateId" = immich_uuid_v7(clock_timestamp); + return new; + END; + $$; + `) + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "activity" DROP COLUMN "updateId"`); + await queryRunner.query(`ALTER TABLE "api_keys" DROP COLUMN "updateId"`); + await queryRunner.query(`ALTER TABLE "memories" DROP COLUMN "updateId"`); + await queryRunner.query(`ALTER TABLE "partners" DROP COLUMN "updateId"`); + await queryRunner.query(`ALTER TABLE "session_sync_checkpoints" DROP COLUMN "updateId"`); + await queryRunner.query(`ALTER TABLE "sessions" DROP COLUMN "updateId"`); + await queryRunner.query(`ALTER TABLE "albums" DROP COLUMN "updateId"`); + await queryRunner.query(`ALTER TABLE "users" DROP COLUMN "updateId"`); + await queryRunner.query(`ALTER TABLE "assets" DROP COLUMN "updateId"`); + await queryRunner.query(`ALTER TABLE "tags" DROP COLUMN "updateId"`); + await queryRunner.query(`ALTER TABLE "libraries" DROP COLUMN "updateId"`); + await queryRunner.query(`ALTER TABLE "asset_files" DROP COLUMN "updateId"`); + await queryRunner.query(`ALTER TABLE "person" DROP COLUMN "updateId"`); + await queryRunner.query(`DROP FUNCTION immich_uuid_v7`); + await queryRunner.query(` + CREATE OR REPLACE FUNCTION updated_at() RETURNS TRIGGER + LANGUAGE plpgsql + as $$ + BEGIN + new."updatedAt" = now(); + return new; + END; + $$; + `) + } + +} diff --git a/server/src/services/session.service.spec.ts b/server/src/services/session.service.spec.ts index 8c22abb7f0..96a1dacf64 100644 --- a/server/src/services/session.service.spec.ts +++ b/server/src/services/session.service.spec.ts @@ -33,6 +33,7 @@ describe('SessionService', () => { id: '123', token: '420', userId: '42', + updateId: 'uuid-v7', }, ]); diff --git a/server/test/fixtures/activity.stub.ts b/server/test/fixtures/activity.stub.ts index 9578bcd4a1..a81fd51ca8 100644 --- a/server/test/fixtures/activity.stub.ts +++ b/server/test/fixtures/activity.stub.ts @@ -19,6 +19,7 @@ export const activityStub = { albumId: albumStub.oneAsset.id, createdAt: new Date(), updatedAt: new Date(), + updateId: 'uuid-v7', }), liked: Object.freeze({ id: 'activity-2', @@ -36,5 +37,6 @@ export const activityStub = { albumId: albumStub.oneAsset.id, createdAt: new Date(), updatedAt: new Date(), + updateId: 'uuid-v7', }), }; diff --git a/server/test/fixtures/session.stub.ts b/server/test/fixtures/session.stub.ts index cdf499c8d1..af06237473 100644 --- a/server/test/fixtures/session.stub.ts +++ b/server/test/fixtures/session.stub.ts @@ -11,6 +11,7 @@ export const sessionStub = { updatedAt: new Date(), deviceType: '', deviceOS: '', + updateId: 'uuid-v7', }), inactive: Object.freeze({ id: 'not_active', @@ -21,5 +22,6 @@ export const sessionStub = { updatedAt: new Date('2021-01-01'), deviceType: 'Mobile', deviceOS: 'Android', + updateId: 'uuid-v7', }), }; From 7d6cfd09e6ef7b5f5132ee53045a08c654ef0f6f Mon Sep 17 00:00:00 2001 From: Mert <101130780+mertalev@users.noreply.github.com> Date: Thu, 27 Feb 2025 17:17:07 +0300 Subject: [PATCH 226/395] fix(server): don't expose source types in face creation api (#16381) * don't expose source types in face creation api * update open-api * remove source type reference from web --- mobile/openapi/lib/model/asset_face_create_dto.dart | 10 +--------- open-api/immich-openapi-specs.json | 9 --------- open-api/typescript-sdk/src/fetch-client.ts | 1 - server/src/dtos/person.dto.ts | 6 +----- server/src/services/person.service.ts | 2 +- .../asset-viewer/face-editor/face-editor.svelte | 3 +-- 6 files changed, 4 insertions(+), 27 deletions(-) diff --git a/mobile/openapi/lib/model/asset_face_create_dto.dart b/mobile/openapi/lib/model/asset_face_create_dto.dart index d25a5d8b82..29e8244a96 100644 --- a/mobile/openapi/lib/model/asset_face_create_dto.dart +++ b/mobile/openapi/lib/model/asset_face_create_dto.dart @@ -18,7 +18,6 @@ class AssetFaceCreateDto { required this.imageHeight, required this.imageWidth, required this.personId, - this.sourceType = SourceType.manual, required this.width, required this.x, required this.y, @@ -34,8 +33,6 @@ class AssetFaceCreateDto { String personId; - SourceType sourceType; - int width; int x; @@ -49,7 +46,6 @@ class AssetFaceCreateDto { other.imageHeight == imageHeight && other.imageWidth == imageWidth && other.personId == personId && - other.sourceType == sourceType && other.width == width && other.x == x && other.y == y; @@ -62,13 +58,12 @@ class AssetFaceCreateDto { (imageHeight.hashCode) + (imageWidth.hashCode) + (personId.hashCode) + - (sourceType.hashCode) + (width.hashCode) + (x.hashCode) + (y.hashCode); @override - String toString() => 'AssetFaceCreateDto[assetId=$assetId, height=$height, imageHeight=$imageHeight, imageWidth=$imageWidth, personId=$personId, sourceType=$sourceType, width=$width, x=$x, y=$y]'; + String toString() => 'AssetFaceCreateDto[assetId=$assetId, height=$height, imageHeight=$imageHeight, imageWidth=$imageWidth, personId=$personId, width=$width, x=$x, y=$y]'; Map toJson() { final json = {}; @@ -77,7 +72,6 @@ class AssetFaceCreateDto { json[r'imageHeight'] = this.imageHeight; json[r'imageWidth'] = this.imageWidth; json[r'personId'] = this.personId; - json[r'sourceType'] = this.sourceType; json[r'width'] = this.width; json[r'x'] = this.x; json[r'y'] = this.y; @@ -98,7 +92,6 @@ class AssetFaceCreateDto { imageHeight: mapValueOfType(json, r'imageHeight')!, imageWidth: mapValueOfType(json, r'imageWidth')!, personId: mapValueOfType(json, r'personId')!, - sourceType: SourceType.fromJson(json[r'sourceType'])!, width: mapValueOfType(json, r'width')!, x: mapValueOfType(json, r'x')!, y: mapValueOfType(json, r'y')!, @@ -154,7 +147,6 @@ class AssetFaceCreateDto { 'imageHeight', 'imageWidth', 'personId', - 'sourceType', 'width', 'x', 'y', diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index aeafc27ee6..5730e41578 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -8301,14 +8301,6 @@ "format": "uuid", "type": "string" }, - "sourceType": { - "allOf": [ - { - "$ref": "#/components/schemas/SourceType" - } - ], - "default": "manual" - }, "width": { "type": "integer" }, @@ -8325,7 +8317,6 @@ "imageHeight", "imageWidth", "personId", - "sourceType", "width", "x", "y" diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index 7237e0aac3..b2895f6f1d 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -529,7 +529,6 @@ export type AssetFaceCreateDto = { imageHeight: number; imageWidth: number; personId: string; - sourceType: SourceType; width: number; x: number; y: number; diff --git a/server/src/dtos/person.dto.ts b/server/src/dtos/person.dto.ts index c4d3018be2..0778c35b8f 100644 --- a/server/src/dtos/person.dto.ts +++ b/server/src/dtos/person.dto.ts @@ -1,6 +1,6 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { Type } from 'class-transformer'; -import { IsArray, IsEnum, IsInt, IsNotEmpty, IsNumber, IsString, Max, Min, ValidateNested } from 'class-validator'; +import { IsArray, IsInt, IsNotEmpty, IsNumber, IsString, Max, Min, ValidateNested } from 'class-validator'; import { DateTime } from 'luxon'; import { PropertyLifecycle } from 'src/decorators'; import { AuthDto } from 'src/dtos/auth.dto'; @@ -194,10 +194,6 @@ export class AssetFaceCreateDto extends AssetFaceUpdateItem { @IsNotEmpty() @IsNumber() height!: number; - - @ApiProperty({ type: 'string', enum: SourceType, enumName: 'SourceType' }) - @IsEnum(SourceType) - sourceType: SourceType = SourceType.MANUAL; } export class AssetFaceDeleteDto { diff --git a/server/src/services/person.service.ts b/server/src/services/person.service.ts index 62bf55a78c..e297910a95 100644 --- a/server/src/services/person.service.ts +++ b/server/src/services/person.service.ts @@ -736,7 +736,7 @@ export class PersonService extends BaseService { boundingBoxX2: dto.x + dto.width, boundingBoxY1: dto.y, boundingBoxY2: dto.y + dto.height, - sourceType: dto.sourceType, + sourceType: SourceType.MANUAL, }); } diff --git a/web/src/lib/components/asset-viewer/face-editor/face-editor.svelte b/web/src/lib/components/asset-viewer/face-editor/face-editor.svelte index bcc9ee6875..afe45331e4 100644 --- a/web/src/lib/components/asset-viewer/face-editor/face-editor.svelte +++ b/web/src/lib/components/asset-viewer/face-editor/face-editor.svelte @@ -4,7 +4,7 @@ import { notificationController } from '$lib/components/shared-components/notification/notification'; import { isFaceEditMode } from '$lib/stores/face-edit.svelte'; import { getPeopleThumbnailUrl } from '$lib/utils'; - import { getAllPeople, createFace, type PersonResponseDto, SourceType } from '@immich/sdk'; + import { getAllPeople, createFace, type PersonResponseDto } from '@immich/sdk'; import { Button } from '@immich/ui'; import { Canvas, InteractiveFabricObject, Rect } from 'fabric'; import { onMount } from 'svelte'; @@ -288,7 +288,6 @@ assetFaceCreateDto: { assetId, personId: person.id, - sourceType: SourceType.Manual, ...data, }, }); From fb907d707de8102ca29d1603d7f9b325cf5a8d2f Mon Sep 17 00:00:00 2001 From: Zack Pollard Date: Thu, 27 Feb 2025 14:22:02 +0000 Subject: [PATCH 227/395] refactor: use new updateId column for user CUD sync (#16384) --- server/src/db.d.ts | 1 + server/src/entities/user-audit.entity.ts | 10 +++--- ...740595460866-UsersAuditUuidv7PrimaryKey.ts | 26 ++++++++++++++ server/src/repositories/sync.repository.ts | 35 ++++--------------- server/src/services/sync.service.ts | 8 ++--- server/src/types.ts | 3 +- server/src/utils/sync.ts | 14 ++++---- 7 files changed, 50 insertions(+), 47 deletions(-) create mode 100644 server/src/migrations/1740595460866-UsersAuditUuidv7PrimaryKey.ts diff --git a/server/src/db.d.ts b/server/src/db.d.ts index ff4cb4a1d2..7fb073d8ce 100644 --- a/server/src/db.d.ts +++ b/server/src/db.d.ts @@ -415,6 +415,7 @@ export interface Users { } export interface UsersAudit { + id: Generated; userId: string; deletedAt: Generated; } diff --git a/server/src/entities/user-audit.entity.ts b/server/src/entities/user-audit.entity.ts index 305994a6d6..c29bc94d97 100644 --- a/server/src/entities/user-audit.entity.ts +++ b/server/src/entities/user-audit.entity.ts @@ -1,14 +1,14 @@ -import { Column, CreateDateColumn, Entity, Index, PrimaryGeneratedColumn } from 'typeorm'; +import { Column, CreateDateColumn, Entity, Index, PrimaryColumn } from 'typeorm'; @Entity('users_audit') -@Index('IDX_users_audit_deleted_at_asc_user_id_asc', ['deletedAt', 'userId']) export class UserAuditEntity { - @PrimaryGeneratedColumn('increment') - id!: number; + @PrimaryColumn({ type: 'uuid', nullable: false, default: () => 'immich_uuid_v7()' }) + id!: string; @Column({ type: 'uuid' }) userId!: string; - @CreateDateColumn({ type: 'timestamptz' }) + @Index('IDX_users_audit_deleted_at') + @CreateDateColumn({ type: 'timestamptz', default: () => 'clock_timestamp()' }) deletedAt!: Date; } diff --git a/server/src/migrations/1740595460866-UsersAuditUuidv7PrimaryKey.ts b/server/src/migrations/1740595460866-UsersAuditUuidv7PrimaryKey.ts new file mode 100644 index 0000000000..997f718fd9 --- /dev/null +++ b/server/src/migrations/1740595460866-UsersAuditUuidv7PrimaryKey.ts @@ -0,0 +1,26 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class UsersAuditUuidv7PrimaryKey1740595460866 implements MigrationInterface { + name = 'UsersAuditUuidv7PrimaryKey1740595460866' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP INDEX "public"."IDX_users_audit_deleted_at_asc_user_id_asc"`); + await queryRunner.query(`ALTER TABLE "users_audit" DROP CONSTRAINT "PK_e9b2bdfd90e7eb5961091175180"`); + await queryRunner.query(`ALTER TABLE "users_audit" DROP COLUMN "id"`); + await queryRunner.query(`ALTER TABLE "users_audit" ADD "id" uuid NOT NULL DEFAULT immich_uuid_v7()`); + await queryRunner.query(`ALTER TABLE "users_audit" ADD CONSTRAINT "PK_e9b2bdfd90e7eb5961091175180" PRIMARY KEY ("id")`); + await queryRunner.query(`ALTER TABLE "users_audit" ALTER COLUMN "deletedAt" SET DEFAULT clock_timestamp()`) + await queryRunner.query(`CREATE INDEX "IDX_users_audit_deleted_at" ON "users_audit" ("deletedAt")`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP INDEX "public"."IDX_users_audit_deleted_at"`); + await queryRunner.query(`ALTER TABLE "users_audit" DROP CONSTRAINT "PK_e9b2bdfd90e7eb5961091175180"`); + await queryRunner.query(`ALTER TABLE "users_audit" DROP COLUMN "id"`); + await queryRunner.query(`ALTER TABLE "users_audit" ADD "id" SERIAL NOT NULL`); + await queryRunner.query(`ALTER TABLE "users_audit" ADD CONSTRAINT "PK_e9b2bdfd90e7eb5961091175180" PRIMARY KEY ("id")`); + await queryRunner.query(`ALTER TABLE "users_audit" ALTER COLUMN "deletedAt" SET DEFAULT now()`); + await queryRunner.query(`CREATE INDEX "IDX_users_audit_deleted_at_asc_user_id_asc" ON "users_audit" ("userId", "deletedAt") `); + } + +} diff --git a/server/src/repositories/sync.repository.ts b/server/src/repositories/sync.repository.ts index 4023bf890e..d1d0e9b8ee 100644 --- a/server/src/repositories/sync.repository.ts +++ b/server/src/repositories/sync.repository.ts @@ -1,7 +1,6 @@ import { Injectable } from '@nestjs/common'; import { Insertable, Kysely, sql } from 'kysely'; import { InjectKysely } from 'nestjs-kysely'; -import { columns } from 'src/database'; import { DB, SessionSyncCheckpoints } from 'src/db'; import { SyncEntityType } from 'src/enum'; import { SyncAck } from 'src/types'; @@ -41,39 +40,19 @@ export class SyncRepository { getUserUpserts(ack?: SyncAck) { return this.db .selectFrom('users') - .select(['id', 'name', 'email', 'deletedAt']) - .select(columns.ackEpoch('updatedAt')) - .$if(!!ack, (qb) => - qb.where((eb) => - eb.or([ - eb(eb.fn('to_timestamp', [sql.val(ack!.ackEpoch)]), '<', eb.ref('updatedAt')), - eb.and([ - eb(eb.fn('to_timestamp', [sql.val(ack!.ackEpoch)]), '<=', eb.ref('updatedAt')), - eb('id', '>', ack!.ids[0]), - ]), - ]), - ), - ) - .orderBy(['updatedAt asc', 'id asc']) + .select(['id', 'name', 'email', 'deletedAt', 'updateId']) + .$if(!!ack, (qb) => qb.where('updateId', '>', ack!.updateId)) + .where('updatedAt', '<', sql.raw("now() - interval '1 millisecond'")) + .orderBy(['updateId asc']) .stream(); } getUserDeletes(ack?: SyncAck) { return this.db .selectFrom('users_audit') - .select(['userId']) - .select(columns.ackEpoch('deletedAt')) - .$if(!!ack, (qb) => - qb.where((eb) => - eb.or([ - eb(eb.fn('to_timestamp', [sql.val(ack!.ackEpoch)]), '<', eb.ref('deletedAt')), - eb.and([ - eb(eb.fn('to_timestamp', [sql.val(ack!.ackEpoch)]), '<=', eb.ref('deletedAt')), - eb('userId', '>', ack!.ids[0]), - ]), - ]), - ), - ) + .select(['id', 'userId']) + .$if(!!ack, (qb) => qb.where('id', '>', ack!.updateId)) + .where('deletedAt', '<', sql.raw("now() - interval '1 millisecond'")) .orderBy(['deletedAt asc', 'userId asc']) .stream(); } diff --git a/server/src/services/sync.service.ts b/server/src/services/sync.service.ts index 98e4d5fb09..b756c11ef4 100644 --- a/server/src/services/sync.service.ts +++ b/server/src/services/sync.service.ts @@ -87,13 +87,13 @@ export class SyncService extends BaseService { switch (type) { case SyncRequestType.UsersV1: { const deletes = this.syncRepository.getUserDeletes(checkpointMap[SyncEntityType.UserDeleteV1]); - for await (const { ackEpoch, ...data } of deletes) { - response.write(serialize({ type: SyncEntityType.UserDeleteV1, ackEpoch, ids: [data.userId], data })); + for await (const { id, ...data } of deletes) { + response.write(serialize({ type: SyncEntityType.UserDeleteV1, updateId: id, data })); } const upserts = this.syncRepository.getUserUpserts(checkpointMap[SyncEntityType.UserV1]); - for await (const { ackEpoch, ...data } of upserts) { - response.write(serialize({ type: SyncEntityType.UserV1, ackEpoch, ids: [data.id], data })); + for await (const { updateId, ...data } of upserts) { + response.write(serialize({ type: SyncEntityType.UserV1, updateId, data })); } break; diff --git a/server/src/types.ts b/server/src/types.ts index 3aa7a14add..5360e519bd 100644 --- a/server/src/types.ts +++ b/server/src/types.ts @@ -421,6 +421,5 @@ export interface IBulkAsset { export type SyncAck = { type: SyncEntityType; - ackEpoch: string; - ids: string[]; + updateId: string; }; diff --git a/server/src/utils/sync.ts b/server/src/utils/sync.ts index 8e426ab860..cfb6660bdc 100644 --- a/server/src/utils/sync.ts +++ b/server/src/utils/sync.ts @@ -9,22 +9,20 @@ type Impossible = { type Exact = U & Impossible>; export const fromAck = (ack: string): SyncAck => { - const [type, timestamp, ...ids] = ack.split('|'); - return { type: type as SyncEntityType, ackEpoch: timestamp, ids }; + const [type, updateId] = ack.split('|'); + return { type: type as SyncEntityType, updateId }; }; -export const toAck = ({ type, ackEpoch, ids }: SyncAck) => [type, ackEpoch, ...ids].join('|'); +export const toAck = ({ type, updateId }: SyncAck) => [type, updateId].join('|'); export const mapJsonLine = (object: unknown) => JSON.stringify(object) + '\n'; export const serialize = ({ type, - ackEpoch, - ids, + updateId, data, }: { type: T; - ackEpoch: string; - ids: string[]; + updateId: string; data: Exact; -}) => mapJsonLine({ type, data, ack: toAck({ type, ackEpoch, ids }) }); +}) => mapJsonLine({ type, data, ack: toAck({ type, updateId }) }); From 6050485ad899e295bee203d8e39d9047927a2039 Mon Sep 17 00:00:00 2001 From: Mert <101130780+mertalev@users.noreply.github.com> Date: Thu, 27 Feb 2025 17:24:40 +0300 Subject: [PATCH 228/395] feat(server): set exiftool process count (#16388) exiftool concurrency control --- .../src/repositories/metadata.repository.ts | 4 ++++ server/src/services/metadata.service.spec.ts | 22 +++++++++++++++++++ server/src/services/metadata.service.ts | 10 +++++++++ .../repositories/metadata.repository.mock.ts | 1 + 4 files changed, 37 insertions(+) diff --git a/server/src/repositories/metadata.repository.ts b/server/src/repositories/metadata.repository.ts index 3f297d709b..5df37a5ea7 100644 --- a/server/src/repositories/metadata.repository.ts +++ b/server/src/repositories/metadata.repository.ts @@ -85,6 +85,10 @@ export class MetadataRepository { this.logger.setContext(MetadataRepository.name); } + setMaxConcurrency(concurrency: number) { + this.exiftool.batchCluster.setMaxProcs(concurrency); + } + async teardown() { await this.exiftool.end(); } diff --git a/server/src/services/metadata.service.spec.ts b/server/src/services/metadata.service.spec.ts index f5b10aa379..74f0231f5d 100644 --- a/server/src/services/metadata.service.spec.ts +++ b/server/src/services/metadata.service.spec.ts @@ -2,6 +2,7 @@ import { BinaryField, ExifDateTime } from 'exiftool-vendored'; import { randomBytes } from 'node:crypto'; import { Stats } from 'node:fs'; import { constants } from 'node:fs/promises'; +import { defaults } from 'src/config'; import { AssetEntity } from 'src/entities/asset.entity'; import { ExifEntity } from 'src/entities/exif.entity'; import { AssetType, ExifOrientation, ImmichWorker, JobName, JobStatus, SourceType } from 'src/enum'; @@ -54,6 +55,27 @@ describe(MetadataService.name, () => { }); }); + describe('onConfigInit', () => { + it('should update metadata processing concurrency', () => { + sut.onConfigInit({ newConfig: defaults }); + + expect(mocks.metadata.setMaxConcurrency).toHaveBeenCalledWith(defaults.job.metadataExtraction.concurrency); + expect(mocks.metadata.setMaxConcurrency).toHaveBeenCalledTimes(1); + }); + }); + + describe('onConfigUpdate', () => { + it('should update metadata processing concurrency', () => { + const newConfig = structuredClone(defaults); + newConfig.job.metadataExtraction.concurrency = 10; + + sut.onConfigUpdate({ oldConfig: defaults, newConfig }); + + expect(mocks.metadata.setMaxConcurrency).toHaveBeenCalledWith(newConfig.job.metadataExtraction.concurrency); + expect(mocks.metadata.setMaxConcurrency).toHaveBeenCalledTimes(1); + }); + }); + describe('handleLivePhotoLinking', () => { it('should handle an asset that could not be found', async () => { await expect(sut.handleLivePhotoLinking({ id: assetStub.image.id })).resolves.toBe(JobStatus.FAILED); diff --git a/server/src/services/metadata.service.ts b/server/src/services/metadata.service.ts index 78ea8089e6..592e0b836d 100644 --- a/server/src/services/metadata.service.ts +++ b/server/src/services/metadata.service.ts @@ -89,6 +89,16 @@ export class MetadataService extends BaseService { await this.metadataRepository.teardown(); } + @OnEvent({ name: 'config.init', workers: [ImmichWorker.MICROSERVICES] }) + onConfigInit({ newConfig }: ArgOf<'config.init'>) { + this.metadataRepository.setMaxConcurrency(newConfig.job.metadataExtraction.concurrency); + } + + @OnEvent({ name: 'config.update', workers: [ImmichWorker.MICROSERVICES], server: true }) + onConfigUpdate({ newConfig }: ArgOf<'config.update'>) { + this.metadataRepository.setMaxConcurrency(newConfig.job.metadataExtraction.concurrency); + } + private async init() { this.logger.log('Initializing metadata service'); diff --git a/server/test/repositories/metadata.repository.mock.ts b/server/test/repositories/metadata.repository.mock.ts index 47a0471b22..854f13b841 100644 --- a/server/test/repositories/metadata.repository.mock.ts +++ b/server/test/repositories/metadata.repository.mock.ts @@ -4,6 +4,7 @@ import { Mocked, vitest } from 'vitest'; export const newMetadataRepositoryMock = (): Mocked> => { return { + setMaxConcurrency: vitest.fn(), teardown: vitest.fn(), readTags: vitest.fn(), writeTags: vitest.fn(), From 9d705097e8d4cccf5ebd4726697e052f5e274e8a Mon Sep 17 00:00:00 2001 From: "immich-tofu[bot]" <171590969+immich-tofu[bot]@users.noreply.github.com> Date: Thu, 27 Feb 2025 14:28:08 +0000 Subject: [PATCH 229/395] chore: modify .github/FUNDING.yml --- .github/FUNDING.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml index c7519a4684..acbb7c785b 100644 --- a/.github/FUNDING.yml +++ b/.github/FUNDING.yml @@ -1 +1 @@ -custom: ['https://buy.immich.app'] +custom: ['https://buy.immich.app', 'https://immich.store'] From 9a098b46585e540f5d4f414d7e65a9d25629450f Mon Sep 17 00:00:00 2001 From: Alex Date: Thu, 27 Feb 2025 09:46:20 -0600 Subject: [PATCH 230/395] fix(web): storage template incorrect example (#16367) --- .../settings/storage-template/storage-template-settings.svelte | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/src/lib/components/admin-page/settings/storage-template/storage-template-settings.svelte b/web/src/lib/components/admin-page/settings/storage-template/storage-template-settings.svelte index 74d240a4a6..8fca558976 100644 --- a/web/src/lib/components/admin-page/settings/storage-template/storage-template-settings.svelte +++ b/web/src/lib/components/admin-page/settings/storage-template/storage-template-settings.svelte @@ -203,7 +203,7 @@

UPLOAD_LOCATION/{$user.storageLabel || $user.id}UPLOAD_LOCATION/library/{$user.storageLabel || $user.id}/{parsedTemplate()}.jpg

From 082471dfd91234f61e8f0fb73fc47379660df98e Mon Sep 17 00:00:00 2001 From: Alex Date: Thu, 27 Feb 2025 09:46:34 -0600 Subject: [PATCH 231/395] chore(mobile): post release task (#16349) --- mobile/ios/Runner.xcodeproj/project.pbxproj | 12 ++++++------ mobile/ios/Runner/Info.plist | 4 ++-- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/mobile/ios/Runner.xcodeproj/project.pbxproj b/mobile/ios/Runner.xcodeproj/project.pbxproj index ab0a629ad4..90caae97fe 100644 --- a/mobile/ios/Runner.xcodeproj/project.pbxproj +++ b/mobile/ios/Runner.xcodeproj/project.pbxproj @@ -541,7 +541,7 @@ CODE_SIGN_ENTITLEMENTS = Runner/RunnerProfile.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 194; + CURRENT_PROJECT_VERSION = 195; CUSTOM_GROUP_ID = group.app.immich.share; DEVELOPMENT_TEAM = 2F67MQ8R79; ENABLE_BITCODE = NO; @@ -685,7 +685,7 @@ CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 194; + CURRENT_PROJECT_VERSION = 195; CUSTOM_GROUP_ID = group.app.immich.share; DEVELOPMENT_TEAM = 2F67MQ8R79; ENABLE_BITCODE = NO; @@ -715,7 +715,7 @@ CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 194; + CURRENT_PROJECT_VERSION = 195; CUSTOM_GROUP_ID = group.app.immich.share; DEVELOPMENT_TEAM = 2F67MQ8R79; ENABLE_BITCODE = NO; @@ -748,7 +748,7 @@ CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 194; + CURRENT_PROJECT_VERSION = 195; CUSTOM_GROUP_ID = group.app.immich.share; DEVELOPMENT_TEAM = 2F67MQ8R79; ENABLE_USER_SCRIPT_SANDBOXING = YES; @@ -791,7 +791,7 @@ CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 194; + CURRENT_PROJECT_VERSION = 195; CUSTOM_GROUP_ID = group.app.immich.share; DEVELOPMENT_TEAM = 2F67MQ8R79; ENABLE_USER_SCRIPT_SANDBOXING = YES; @@ -831,7 +831,7 @@ CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 194; + CURRENT_PROJECT_VERSION = 195; CUSTOM_GROUP_ID = group.app.immich.share; DEVELOPMENT_TEAM = 2F67MQ8R79; ENABLE_USER_SCRIPT_SANDBOXING = YES; diff --git a/mobile/ios/Runner/Info.plist b/mobile/ios/Runner/Info.plist index 3051768e53..c08b4082cb 100644 --- a/mobile/ios/Runner/Info.plist +++ b/mobile/ios/Runner/Info.plist @@ -78,7 +78,7 @@ CFBundlePackageType APPL CFBundleShortVersionString - 1.126.1 + 1.127.0 CFBundleSignature ???? CFBundleURLTypes @@ -93,7 +93,7 @@ CFBundleVersion - 194 + 195 FLTEnableImpeller ITSAppUsesNonExemptEncryption From c70c9067b070fd650df0a6411f3dbb4c205ad076 Mon Sep 17 00:00:00 2001 From: Alex Date: Thu, 27 Feb 2025 09:56:23 -0600 Subject: [PATCH 232/395] refactor(mobile): backup provider (#16360) * refactor(mobile): backup provider * refactor(mobile): backup provider --- ...rface.dart => backup_album.interface.dart} | 2 +- .../lib/providers/backup/backup.provider.dart | 109 ++++++++---------- .../backup/manual_upload.provider.dart | 14 +-- .../lib/repositories/backup.repository.dart | 11 +- mobile/lib/services/album.service.dart | 6 +- mobile/lib/services/asset.service.dart | 6 +- mobile/lib/services/background.service.dart | 4 +- mobile/lib/services/backup_album.service.dart | 34 ++++++ mobile/test/repository.mocks.dart | 4 +- 9 files changed, 109 insertions(+), 81 deletions(-) rename mobile/lib/interfaces/{backup.interface.dart => backup_album.interface.dart} (85%) create mode 100644 mobile/lib/services/backup_album.service.dart diff --git a/mobile/lib/interfaces/backup.interface.dart b/mobile/lib/interfaces/backup_album.interface.dart similarity index 85% rename from mobile/lib/interfaces/backup.interface.dart rename to mobile/lib/interfaces/backup_album.interface.dart index c32199a58f..f98adb6821 100644 --- a/mobile/lib/interfaces/backup.interface.dart +++ b/mobile/lib/interfaces/backup_album.interface.dart @@ -1,7 +1,7 @@ import 'package:immich_mobile/entities/backup_album.entity.dart'; import 'package:immich_mobile/interfaces/database.interface.dart'; -abstract interface class IBackupRepository implements IDatabaseRepository { +abstract interface class IBackupAlbumRepository implements IDatabaseRepository { Future> getAll({BackupAlbumSort? sort}); Future> getIdsBySelection(BackupSelection backup); diff --git a/mobile/lib/providers/backup/backup.provider.dart b/mobile/lib/providers/backup/backup.provider.dart index 3b0f724411..a4f4fea45c 100644 --- a/mobile/lib/providers/backup/backup.provider.dart +++ b/mobile/lib/providers/backup/backup.provider.dart @@ -10,7 +10,7 @@ import 'package:immich_mobile/entities/album.entity.dart'; import 'package:immich_mobile/entities/backup_album.entity.dart'; import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/interfaces/album_media.interface.dart'; -import 'package:immich_mobile/interfaces/backup.interface.dart'; +import 'package:immich_mobile/interfaces/backup_album.interface.dart'; import 'package:immich_mobile/interfaces/file_media.interface.dart'; import 'package:immich_mobile/models/auth/auth_state.model.dart'; import 'package:immich_mobile/models/backup/available_album.model.dart'; @@ -23,21 +23,34 @@ import 'package:immich_mobile/models/server_info/server_disk_info.model.dart'; import 'package:immich_mobile/providers/app_life_cycle.provider.dart'; import 'package:immich_mobile/providers/auth.provider.dart'; import 'package:immich_mobile/providers/backup/error_backup_list.provider.dart'; -import 'package:immich_mobile/providers/db.provider.dart'; import 'package:immich_mobile/providers/gallery_permission.provider.dart'; import 'package:immich_mobile/repositories/album_media.repository.dart'; -import 'package:immich_mobile/repositories/backup.repository.dart'; import 'package:immich_mobile/repositories/file_media.repository.dart'; import 'package:immich_mobile/services/background.service.dart'; import 'package:immich_mobile/services/backup.service.dart'; +import 'package:immich_mobile/services/backup_album.service.dart'; import 'package:immich_mobile/services/server_info.service.dart'; import 'package:immich_mobile/utils/backup_progress.dart'; import 'package:immich_mobile/utils/diff.dart'; -import 'package:isar/isar.dart'; import 'package:logging/logging.dart'; import 'package:permission_handler/permission_handler.dart'; import 'package:photo_manager/photo_manager.dart' show PMProgressHandler; +final backupProvider = + StateNotifierProvider((ref) { + return BackupNotifier( + ref.watch(backupServiceProvider), + ref.watch(serverInfoServiceProvider), + ref.watch(authProvider), + ref.watch(backgroundServiceProvider), + ref.watch(galleryPermissionNotifier.notifier), + ref.watch(albumMediaRepositoryProvider), + ref.watch(fileMediaRepositoryProvider), + ref.watch(backupAlbumServiceProvider), + ref, + ); +}); + class BackupNotifier extends StateNotifier { BackupNotifier( this._backupService, @@ -45,10 +58,9 @@ class BackupNotifier extends StateNotifier { this._authState, this._backgroundService, this._galleryPermissionNotifier, - this._db, this._albumMediaRepository, this._fileMediaRepository, - this._backupRepository, + this._backupAlbumService, this.ref, ) : super( BackUpState( @@ -96,10 +108,9 @@ class BackupNotifier extends StateNotifier { final AuthState _authState; final BackgroundService _backgroundService; final GalleryPermissionNotifier _galleryPermissionNotifier; - final Isar _db; final IAlbumMediaRepository _albumMediaRepository; final IFileMediaRepository _fileMediaRepository; - final IBackupRepository _backupRepository; + final BackupAlbumService _backupAlbumService; final Ref ref; /// @@ -260,9 +271,9 @@ class BackupNotifier extends StateNotifier { state = state.copyWith(availableAlbums: availableAlbums); final List excludedBackupAlbums = - await _backupRepository.getAllBySelection(BackupSelection.exclude); + await _backupAlbumService.getAllBySelection(BackupSelection.exclude); final List selectedBackupAlbums = - await _backupRepository.getAllBySelection(BackupSelection.select); + await _backupAlbumService.getAllBySelection(BackupSelection.select); final Set selectedAlbums = {}; for (final BackupAlbum ba in selectedBackupAlbums) { @@ -439,7 +450,7 @@ class BackupNotifier extends StateNotifier { } /// Save user selection of selected albums and excluded albums to database - Future _updatePersistentAlbumsSelection() { + Future _updatePersistentAlbumsSelection() async { final epoch = DateTime.fromMillisecondsSinceEpoch(0, isUtc: true); final selected = state.selectedBackupAlbums.map( (e) => BackupAlbum(e.id, e.lastBackup ?? epoch, BackupSelection.select), @@ -447,29 +458,30 @@ class BackupNotifier extends StateNotifier { final excluded = state.excludedBackupAlbums.map( (e) => BackupAlbum(e.id, e.lastBackup ?? epoch, BackupSelection.exclude), ); - final backupAlbums = selected.followedBy(excluded).toList(); - backupAlbums.sortBy((e) => e.id); - return _db.writeTxn(() async { - final dbAlbums = await _db.backupAlbums.where().sortById().findAll(); - final List toDelete = []; - final List toUpsert = []; - // stores the most recent `lastBackup` per album but always keeps the `selection` the user just made - diffSortedListsSync( - dbAlbums, - backupAlbums, - compare: (BackupAlbum a, BackupAlbum b) => a.id.compareTo(b.id), - both: (BackupAlbum a, BackupAlbum b) { - b.lastBackup = - a.lastBackup.isAfter(b.lastBackup) ? a.lastBackup : b.lastBackup; - toUpsert.add(b); - return true; - }, - onlyFirst: (BackupAlbum a) => toDelete.add(a.isarId), - onlySecond: (BackupAlbum b) => toUpsert.add(b), - ); - await _db.backupAlbums.deleteAll(toDelete); - await _db.backupAlbums.putAll(toUpsert); - }); + final candidates = selected.followedBy(excluded).toList(); + candidates.sortBy((e) => e.id); + + final savedBackupAlbums = + await _backupAlbumService.getAll(sort: BackupAlbumSort.id); + final List toDelete = []; + final List toUpsert = []; + + diffSortedListsSync( + savedBackupAlbums, + candidates, + compare: (BackupAlbum a, BackupAlbum b) => a.id.compareTo(b.id), + both: (BackupAlbum a, BackupAlbum b) { + b.lastBackup = + a.lastBackup.isAfter(b.lastBackup) ? a.lastBackup : b.lastBackup; + toUpsert.add(b); + return true; + }, + onlyFirst: (BackupAlbum a) => toDelete.add(a.isarId), + onlySecond: (BackupAlbum b) => toUpsert.add(b), + ); + + await _backupAlbumService.deleteAll(toDelete); + await _backupAlbumService.updateAll(toUpsert); } /// Invoke backup process @@ -686,14 +698,10 @@ class BackupNotifier extends StateNotifier { } Future resumeBackup() async { - final List selectedBackupAlbums = await _db.backupAlbums - .filter() - .selectionEqualTo(BackupSelection.select) - .findAll(); - final List excludedBackupAlbums = await _db.backupAlbums - .filter() - .selectionEqualTo(BackupSelection.exclude) - .findAll(); + final List selectedBackupAlbums = + await _backupAlbumService.getAllBySelection(BackupSelection.select); + final List excludedBackupAlbums = + await _backupAlbumService.getAllBySelection(BackupSelection.exclude); Set selectedAlbums = state.selectedBackupAlbums; Set excludedAlbums = state.excludedBackupAlbums; if (selectedAlbums.isNotEmpty) { @@ -756,23 +764,8 @@ class BackupNotifier extends StateNotifier { } BackUpProgressEnum get backupProgress => state.backupProgress; + void updateBackupProgress(BackUpProgressEnum backupProgress) { state = state.copyWith(backupProgress: backupProgress); } } - -final backupProvider = - StateNotifierProvider((ref) { - return BackupNotifier( - ref.watch(backupServiceProvider), - ref.watch(serverInfoServiceProvider), - ref.watch(authProvider), - ref.watch(backgroundServiceProvider), - ref.watch(galleryPermissionNotifier.notifier), - ref.watch(dbProvider), - ref.watch(albumMediaRepositoryProvider), - ref.watch(fileMediaRepositoryProvider), - ref.watch(backupRepositoryProvider), - ref, - ); -}); diff --git a/mobile/lib/providers/backup/manual_upload.provider.dart b/mobile/lib/providers/backup/manual_upload.provider.dart index 192126f085..6eaf0f7226 100644 --- a/mobile/lib/providers/backup/manual_upload.provider.dart +++ b/mobile/lib/providers/backup/manual_upload.provider.dart @@ -9,7 +9,6 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/entities/backup_album.entity.dart'; import 'package:immich_mobile/models/backup/backup_candidate.model.dart'; import 'package:immich_mobile/models/backup/success_upload_asset.model.dart'; -import 'package:immich_mobile/repositories/backup.repository.dart'; import 'package:immich_mobile/repositories/file_media.repository.dart'; import 'package:immich_mobile/services/background.service.dart'; import 'package:immich_mobile/models/backup/backup_state.model.dart'; @@ -24,6 +23,7 @@ import 'package:immich_mobile/providers/app_settings.provider.dart'; import 'package:immich_mobile/services/app_settings.service.dart'; import 'package:immich_mobile/entities/asset.entity.dart'; import 'package:immich_mobile/providers/app_life_cycle.provider.dart'; +import 'package:immich_mobile/services/backup_album.service.dart'; import 'package:immich_mobile/services/local_notification.service.dart'; import 'package:immich_mobile/widgets/common/immich_toast.dart'; import 'package:immich_mobile/utils/backup_progress.dart'; @@ -37,7 +37,7 @@ final manualUploadProvider = ref.watch(localNotificationService), ref.watch(backupProvider.notifier), ref.watch(backupServiceProvider), - ref.watch(backupRepositoryProvider), + ref.watch(backupAlbumServiceProvider), ref, ); }); @@ -47,14 +47,14 @@ class ManualUploadNotifier extends StateNotifier { final LocalNotificationService _localNotificationService; final BackupNotifier _backupProvider; final BackupService _backupService; - final BackupRepository _backupRepository; + final BackupAlbumService _backupAlbumService; final Ref ref; ManualUploadNotifier( this._localNotificationService, this._backupProvider, this._backupService, - this._backupRepository, + this._backupAlbumService, this.ref, ) : super( ManualUploadState( @@ -210,9 +210,9 @@ class ManualUploadNotifier extends StateNotifier { } final selectedBackupAlbums = - await _backupRepository.getAllBySelection(BackupSelection.select); - final excludedBackupAlbums = - await _backupRepository.getAllBySelection(BackupSelection.exclude); + await _backupAlbumService.getAllBySelection(BackupSelection.select); + final excludedBackupAlbums = await _backupAlbumService + .getAllBySelection(BackupSelection.exclude); // Get candidates from selected albums and excluded albums Set candidates = diff --git a/mobile/lib/repositories/backup.repository.dart b/mobile/lib/repositories/backup.repository.dart index ed3a9c27e4..f7f3051f46 100644 --- a/mobile/lib/repositories/backup.repository.dart +++ b/mobile/lib/repositories/backup.repository.dart @@ -1,15 +1,16 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/entities/backup_album.entity.dart'; -import 'package:immich_mobile/interfaces/backup.interface.dart'; +import 'package:immich_mobile/interfaces/backup_album.interface.dart'; import 'package:immich_mobile/providers/db.provider.dart'; import 'package:immich_mobile/repositories/database.repository.dart'; import 'package:isar/isar.dart'; -final backupRepositoryProvider = - Provider((ref) => BackupRepository(ref.watch(dbProvider))); +final backupAlbumRepositoryProvider = + Provider((ref) => BackupAlbumRepository(ref.watch(dbProvider))); -class BackupRepository extends DatabaseRepository implements IBackupRepository { - BackupRepository(super.db); +class BackupAlbumRepository extends DatabaseRepository + implements IBackupAlbumRepository { + BackupAlbumRepository(super.db); @override Future> getAll({BackupAlbumSort? sort}) { diff --git a/mobile/lib/services/album.service.dart b/mobile/lib/services/album.service.dart index 142ac48193..9c1f25f0a5 100644 --- a/mobile/lib/services/album.service.dart +++ b/mobile/lib/services/album.service.dart @@ -16,7 +16,7 @@ import 'package:immich_mobile/interfaces/album.interface.dart'; import 'package:immich_mobile/interfaces/album_api.interface.dart'; import 'package:immich_mobile/interfaces/album_media.interface.dart'; import 'package:immich_mobile/interfaces/asset.interface.dart'; -import 'package:immich_mobile/interfaces/backup.interface.dart'; +import 'package:immich_mobile/interfaces/backup_album.interface.dart'; import 'package:immich_mobile/models/albums/album_add_asset_response.model.dart'; import 'package:immich_mobile/models/albums/album_search.model.dart'; import 'package:immich_mobile/repositories/album.repository.dart'; @@ -36,7 +36,7 @@ final albumServiceProvider = Provider( ref.watch(entityServiceProvider), ref.watch(albumRepositoryProvider), ref.watch(assetRepositoryProvider), - ref.watch(backupRepositoryProvider), + ref.watch(backupAlbumRepositoryProvider), ref.watch(albumMediaRepositoryProvider), ref.watch(albumApiRepositoryProvider), ), @@ -48,7 +48,7 @@ class AlbumService { final EntityService _entityService; final IAlbumRepository _albumRepository; final IAssetRepository _assetRepository; - final IBackupRepository _backupAlbumRepository; + final IBackupAlbumRepository _backupAlbumRepository; final IAlbumMediaRepository _albumMediaRepository; final IAlbumApiRepository _albumApiRepository; final Logger _log = Logger('AlbumService'); diff --git a/mobile/lib/services/asset.service.dart b/mobile/lib/services/asset.service.dart index b4a2c097b7..a4e77c216d 100644 --- a/mobile/lib/services/asset.service.dart +++ b/mobile/lib/services/asset.service.dart @@ -10,7 +10,7 @@ import 'package:immich_mobile/entities/user.entity.dart'; import 'package:immich_mobile/interfaces/asset.interface.dart'; import 'package:immich_mobile/interfaces/asset_api.interface.dart'; import 'package:immich_mobile/interfaces/asset_media.interface.dart'; -import 'package:immich_mobile/interfaces/backup.interface.dart'; +import 'package:immich_mobile/interfaces/backup_album.interface.dart'; import 'package:immich_mobile/interfaces/etag.interface.dart'; import 'package:immich_mobile/interfaces/exif_info.interface.dart'; import 'package:immich_mobile/interfaces/user.interface.dart'; @@ -39,7 +39,7 @@ final assetServiceProvider = Provider( ref.watch(exifInfoRepositoryProvider), ref.watch(userRepositoryProvider), ref.watch(etagRepositoryProvider), - ref.watch(backupRepositoryProvider), + ref.watch(backupAlbumRepositoryProvider), ref.watch(apiServiceProvider), ref.watch(syncServiceProvider), ref.watch(userServiceProvider), @@ -55,7 +55,7 @@ class AssetService { final IExifInfoRepository _exifInfoRepository; final IUserRepository _userRepository; final IETagRepository _etagRepository; - final IBackupRepository _backupRepository; + final IBackupAlbumRepository _backupRepository; final ApiService _apiService; final SyncService _syncService; final UserService _userService; diff --git a/mobile/lib/services/background.service.dart b/mobile/lib/services/background.service.dart index 81619bdca1..2a7bfb2bb4 100644 --- a/mobile/lib/services/background.service.dart +++ b/mobile/lib/services/background.service.dart @@ -14,7 +14,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/domain/models/store.model.dart'; import 'package:immich_mobile/entities/backup_album.entity.dart'; import 'package:immich_mobile/entities/store.entity.dart'; -import 'package:immich_mobile/interfaces/backup.interface.dart'; +import 'package:immich_mobile/interfaces/backup_album.interface.dart'; import 'package:immich_mobile/main.dart'; import 'package:immich_mobile/models/backup/backup_candidate.model.dart'; import 'package:immich_mobile/models/backup/current_upload_asset.model.dart'; @@ -377,7 +377,7 @@ class BackgroundService { AppSettingsService settingsService = AppSettingsService(); AlbumRepository albumRepository = AlbumRepository(db); AssetRepository assetRepository = AssetRepository(db); - BackupRepository backupRepository = BackupRepository(db); + BackupAlbumRepository backupRepository = BackupAlbumRepository(db); ExifInfoRepository exifInfoRepository = ExifInfoRepository(db); ETagRepository eTagRepository = ETagRepository(db); AlbumMediaRepository albumMediaRepository = AlbumMediaRepository(); diff --git a/mobile/lib/services/backup_album.service.dart b/mobile/lib/services/backup_album.service.dart new file mode 100644 index 0000000000..8030d66937 --- /dev/null +++ b/mobile/lib/services/backup_album.service.dart @@ -0,0 +1,34 @@ +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/entities/backup_album.entity.dart'; +import 'package:immich_mobile/interfaces/backup_album.interface.dart'; +import 'package:immich_mobile/repositories/backup.repository.dart'; + +final backupAlbumServiceProvider = Provider((ref) { + return BackupAlbumService(ref.watch(backupAlbumRepositoryProvider)); +}); + +class BackupAlbumService { + final IBackupAlbumRepository _backupAlbumRepository; + + BackupAlbumService(this._backupAlbumRepository); + + Future> getAll({BackupAlbumSort? sort}) { + return _backupAlbumRepository.getAll(sort: sort); + } + + Future> getIdsBySelection(BackupSelection backup) { + return _backupAlbumRepository.getIdsBySelection(backup); + } + + Future> getAllBySelection(BackupSelection backup) { + return _backupAlbumRepository.getAllBySelection(backup); + } + + Future deleteAll(List ids) { + return _backupAlbumRepository.deleteAll(ids); + } + + Future updateAll(List backupAlbums) { + return _backupAlbumRepository.updateAll(backupAlbums); + } +} diff --git a/mobile/test/repository.mocks.dart b/mobile/test/repository.mocks.dart index 3dda932cac..bad7d3ebab 100644 --- a/mobile/test/repository.mocks.dart +++ b/mobile/test/repository.mocks.dart @@ -5,7 +5,7 @@ import 'package:immich_mobile/interfaces/asset.interface.dart'; import 'package:immich_mobile/interfaces/asset_media.interface.dart'; import 'package:immich_mobile/interfaces/auth.interface.dart'; import 'package:immich_mobile/interfaces/auth_api.interface.dart'; -import 'package:immich_mobile/interfaces/backup.interface.dart'; +import 'package:immich_mobile/interfaces/backup_album.interface.dart'; import 'package:immich_mobile/interfaces/etag.interface.dart'; import 'package:immich_mobile/interfaces/exif_info.interface.dart'; import 'package:immich_mobile/interfaces/file_media.interface.dart'; @@ -18,7 +18,7 @@ class MockAssetRepository extends Mock implements IAssetRepository {} class MockUserRepository extends Mock implements IUserRepository {} -class MockBackupRepository extends Mock implements IBackupRepository {} +class MockBackupRepository extends Mock implements IBackupAlbumRepository {} class MockExifInfoRepository extends Mock implements IExifInfoRepository {} From a808b8610e762eab17038a0b25c2ca84d7e1e691 Mon Sep 17 00:00:00 2001 From: Tom Graham Date: Fri, 28 Feb 2025 03:14:09 +1100 Subject: [PATCH 233/395] fix(server): Fix delay with multiple ml servers (#16284) * Prospective fix for ensuring that known active ML servers are used to reduce search delay. * Added some logging and renamed backoff const. * Fix lint issues. * Update to use env vars for timeouts and updated documentation and strings. * Fix docs. * Make counter logic clearer. * Minor readability improvements. * Extract skipUrl logic per feedback, and change log to verbose. * Make code harder to read. --- docs/docs/administration/system-settings.md | 8 +++ docs/docs/install/environment-variables.md | 2 + i18n/en.json | 2 +- server/src/constants.ts | 5 ++ .../machine-learning.repository.ts | 66 +++++++++++++++++++ 5 files changed, 82 insertions(+), 1 deletion(-) diff --git a/docs/docs/administration/system-settings.md b/docs/docs/administration/system-settings.md index 92b910a01b..f241050136 100644 --- a/docs/docs/administration/system-settings.md +++ b/docs/docs/administration/system-settings.md @@ -98,6 +98,14 @@ The default Immich log level is `Log` (commonly known as `Info`). The Immich adm Through this setting, you can manage all the settings related to machine learning in Immich, from the setting of remote machine learning to the model and its parameters You can choose to disable a certain type of machine learning, for example smart search or facial recognition. +### URL + +The built in (`http://immich-machine-learning:3003`) machine learning server will be configured by default, but you can change this or add additional servers. + +Hosting the `immich-machine-learning` container on a machine with a more powerful GPU can be helpful to for processing a large number of photos (such as during batch import) or for faster search. + +If more than one URL is provided, each server will be attempted one-at-a-time until one responds successfully, in order from first to last. Servers that don't respond will be temporarily ignored until they come back online. + ### Smart Search The [smart search](/docs/features/searching) settings allow you to change the [CLIP model](https://openai.com/research/clip). Larger models will typically provide [more accurate search results](https://github.com/immich-app/immich/discussions/11862) but consume more processing power and RAM. When [changing the CLIP model](/docs/FAQ#can-i-use-a-custom-clip-model) it is mandatory to re-run the Smart Search job on all images to fully apply the change. diff --git a/docs/docs/install/environment-variables.md b/docs/docs/install/environment-variables.md index a57eef540d..16f05b6338 100644 --- a/docs/docs/install/environment-variables.md +++ b/docs/docs/install/environment-variables.md @@ -168,6 +168,8 @@ Redis (Sentinel) URL example JSON before encoding: | `MACHINE_LEARNING_ANN_TUNING_LEVEL` | ARM-NN GPU tuning level (1: rapid, 2: normal, 3: exhaustive) | `2` | machine learning | | `MACHINE_LEARNING_DEVICE_IDS`\*4 | Device IDs to use in multi-GPU environments | `0` | machine learning | | `MACHINE_LEARNING_MAX_BATCH_SIZE__FACIAL_RECOGNITION` | Set the maximum number of faces that will be processed at once by the facial recognition model | None (`1` if using OpenVINO) | machine learning | +| `MACHINE_LEARNING_PING_TIMEOUT` | How long (ms) to wait for a PING response when checking if an ML server is available | `2000` | server | +| `MACHINE_LEARNING_AVAILABILITY_BACKOFF_TIME` | How long to ignore ML servers that are offline before trying again | `30000` | server | \*1: It is recommended to begin with this parameter when changing the concurrency levels of the machine learning service and then tune the other ones. diff --git a/i18n/en.json b/i18n/en.json index 1bf118976e..e35f1906c4 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -131,7 +131,7 @@ "machine_learning_smart_search_description": "Search for images semantically using CLIP embeddings", "machine_learning_smart_search_enabled": "Enable smart search", "machine_learning_smart_search_enabled_description": "If disabled, images will not be encoded for smart search.", - "machine_learning_url_description": "The URL of the machine learning server. If more than one URL is provided, each server will be attempted one-at-a-time until one responds successfully, in order from first to last.", + "machine_learning_url_description": "The URL of the machine learning server. If more than one URL is provided, each server will be attempted one-at-a-time until one responds successfully, in order from first to last. Servers that don't respond will be temporarily ignored until they come back online.", "manage_concurrency": "Manage Concurrency", "manage_log_settings": "Manage log settings", "map_dark_style": "Dark style", diff --git a/server/src/constants.ts b/server/src/constants.ts index 889ce81620..20ce7dd497 100644 --- a/server/src/constants.ts +++ b/server/src/constants.ts @@ -38,6 +38,11 @@ export const ONE_HOUR = Duration.fromObject({ hours: 1 }); export const APP_MEDIA_LOCATION = process.env.IMMICH_MEDIA_LOCATION || './upload'; +export const MACHINE_LEARNING_PING_TIMEOUT = Number(process.env.MACHINE_LEARNING_PING_TIMEOUT || 2000); +export const MACHINE_LEARNING_AVAILABILITY_BACKOFF_TIME = Number( + process.env.MACHINE_LEARNING_AVAILABILITY_BACKOFF_TIME || 30_000, +); + export const citiesFile = 'cities500.txt'; export const MOBILE_REDIRECT = 'app.immich:///oauth-callback'; diff --git a/server/src/repositories/machine-learning.repository.ts b/server/src/repositories/machine-learning.repository.ts index 8145bf3154..5e916c71f3 100644 --- a/server/src/repositories/machine-learning.repository.ts +++ b/server/src/repositories/machine-learning.repository.ts @@ -1,5 +1,6 @@ import { Injectable } from '@nestjs/common'; import { readFile } from 'node:fs/promises'; +import { MACHINE_LEARNING_AVAILABILITY_BACKOFF_TIME, MACHINE_LEARNING_PING_TIMEOUT } from 'src/constants'; import { CLIPConfig } from 'src/dtos/model-config.dto'; import { LoggingRepository } from 'src/repositories/logging.repository'; @@ -55,16 +56,80 @@ export type MachineLearningRequest = ClipVisualRequest | ClipTextualRequest | Fa @Injectable() export class MachineLearningRepository { + // Note that deleted URL's are not removed from this map (ie: they're leaked) + // Cleaning them up is low priority since there should be very few over a + // typical server uptime cycle + private urlAvailability: { + [url: string]: + | { + active: boolean; + lastChecked: number; + } + | undefined; + }; + constructor(private logger: LoggingRepository) { this.logger.setContext(MachineLearningRepository.name); + this.urlAvailability = {}; + } + + private setUrlAvailability(url: string, active: boolean) { + const current = this.urlAvailability[url]; + if (current?.active !== active) { + this.logger.verbose(`Setting ${url} ML server to ${active ? 'active' : 'inactive'}.`); + } + this.urlAvailability[url] = { + active, + lastChecked: Date.now(), + }; + } + + private async checkAvailability(url: string) { + let active = false; + try { + const response = await fetch(new URL('/ping', url), { + signal: AbortSignal.timeout(MACHINE_LEARNING_PING_TIMEOUT), + }); + active = response.ok; + } catch {} + this.setUrlAvailability(url, active); + return active; + } + + private async shouldSkipUrl(url: string) { + const availability = this.urlAvailability[url]; + if (availability === undefined) { + // If this is a new endpoint, then check inline and skip if it fails + if (!(await this.checkAvailability(url))) { + return true; + } + return false; + } + if (!availability.active && Date.now() - availability.lastChecked < MACHINE_LEARNING_AVAILABILITY_BACKOFF_TIME) { + // If this is an old inactive endpoint that hasn't been checked in a + // while then check but don't wait for the result, just skip it + // This avoids delays on every search whilst allowing higher priority + // ML servers to recover over time. + void this.checkAvailability(url); + return true; + } + return false; } private async predict(urls: string[], payload: ModelPayload, config: MachineLearningRequest): Promise { const formData = await this.getFormData(payload, config); + let urlCounter = 0; for (const url of urls) { + urlCounter++; + const isLast = urlCounter >= urls.length; + if (!isLast && (await this.shouldSkipUrl(url))) { + continue; + } + try { const response = await fetch(new URL('/predict', url), { method: 'POST', body: formData }); if (response.ok) { + this.setUrlAvailability(url, true); return response.json(); } @@ -76,6 +141,7 @@ export class MachineLearningRepository { `Machine learning request to "${url}" failed: ${error instanceof Error ? error.message : error}`, ); } + this.setUrlAvailability(url, false); } throw new Error(`Machine learning request '${JSON.stringify(config)}' failed for all URLs`); From a708649504f9ecd952f34346ebfb21ddfb5ccb50 Mon Sep 17 00:00:00 2001 From: Mert <101130780+mertalev@users.noreply.github.com> Date: Thu, 27 Feb 2025 19:16:13 +0300 Subject: [PATCH 234/395] fix(server): skip stacked assets in duplicate detection (#16380) * skip stacked assets in duplicate detection * update sql * handle stacking after duplicate detection runs --- ...480319-UnsetStackedAssetsFromDuplicateStatus.ts | 14 ++++++++++++++ server/src/queries/asset.repository.sql | 1 + server/src/queries/search.repository.sql | 1 + server/src/repositories/asset.repository.ts | 1 + server/src/repositories/search.repository.ts | 1 + server/src/services/duplicate.service.spec.ts | 10 ++++++++++ server/src/services/duplicate.service.ts | 5 +++++ server/test/fixtures/asset.stub.ts | 1 + 8 files changed, 34 insertions(+) create mode 100644 server/src/migrations/1740654480319-UnsetStackedAssetsFromDuplicateStatus.ts diff --git a/server/src/migrations/1740654480319-UnsetStackedAssetsFromDuplicateStatus.ts b/server/src/migrations/1740654480319-UnsetStackedAssetsFromDuplicateStatus.ts new file mode 100644 index 0000000000..5c735a60bb --- /dev/null +++ b/server/src/migrations/1740654480319-UnsetStackedAssetsFromDuplicateStatus.ts @@ -0,0 +1,14 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class UnsetStackedAssetsFromDuplicateStatus1740654480319 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + update assets + set "duplicateId" = null + where "stackId" is not null`); + } + + public async down(): Promise { + // No need to revert this migration + } +} diff --git a/server/src/queries/asset.repository.sql b/server/src/queries/asset.repository.sql index 879152dc77..ce53bd1791 100644 --- a/server/src/queries/asset.repository.sql +++ b/server/src/queries/asset.repository.sql @@ -333,6 +333,7 @@ with and "assets"."duplicateId" is not null and "assets"."deletedAt" is null and "assets"."isVisible" = $2 + and "assets"."stackId" is null group by "assets"."duplicateId" ), diff --git a/server/src/queries/search.repository.sql b/server/src/queries/search.repository.sql index 9400700e56..06590dc817 100644 --- a/server/src/queries/search.repository.sql +++ b/server/src/queries/search.repository.sql @@ -112,6 +112,7 @@ with and "assets"."isVisible" = $3 and "assets"."type" = $4 and "assets"."id" != $5::uuid + and "assets"."stackId" is null order by smart_search.embedding <=> $6 limit diff --git a/server/src/repositories/asset.repository.ts b/server/src/repositories/asset.repository.ts index 139e652f03..daefacef09 100644 --- a/server/src/repositories/asset.repository.ts +++ b/server/src/repositories/asset.repository.ts @@ -794,6 +794,7 @@ export class AssetRepository { .where('assets.duplicateId', 'is not', null) .where('assets.deletedAt', 'is', null) .where('assets.isVisible', '=', true) + .where('assets.stackId', 'is', null) .groupBy('assets.duplicateId'), ) .with('unique', (qb) => diff --git a/server/src/repositories/search.repository.ts b/server/src/repositories/search.repository.ts index 2f313aa083..46f38db55f 100644 --- a/server/src/repositories/search.repository.ts +++ b/server/src/repositories/search.repository.ts @@ -318,6 +318,7 @@ export class SearchRepository { .where('assets.isVisible', '=', true) .where('assets.type', '=', type) .where('assets.id', '!=', asUuid(assetId)) + .where('assets.stackId', 'is', null) .orderBy(sql`smart_search.embedding <=> ${embedding}`) .limit(64), ) diff --git a/server/src/services/duplicate.service.spec.ts b/server/src/services/duplicate.service.spec.ts index 30b8cd3451..8be943eaf0 100644 --- a/server/src/services/duplicate.service.spec.ts +++ b/server/src/services/duplicate.service.spec.ts @@ -173,6 +173,16 @@ describe(SearchService.name, () => { expect(mocks.logger.error).toHaveBeenCalledWith(`Asset ${assetStub.image.id} not found`); }); + it('should skip if asset is part of stack', async () => { + const id = assetStub.primaryImage.id; + mocks.asset.getById.mockResolvedValue(assetStub.primaryImage); + + const result = await sut.handleSearchDuplicates({ id }); + + expect(result).toBe(JobStatus.SKIPPED); + expect(mocks.logger.debug).toHaveBeenCalledWith(`Asset ${id} is part of a stack, skipping`); + }); + it('should skip if asset is not visible', async () => { const id = assetStub.livePhotoMotionAsset.id; mocks.asset.getById.mockResolvedValue(assetStub.livePhotoMotionAsset); diff --git a/server/src/services/duplicate.service.ts b/server/src/services/duplicate.service.ts index 5600033b47..74b86f8e4e 100644 --- a/server/src/services/duplicate.service.ts +++ b/server/src/services/duplicate.service.ts @@ -59,6 +59,11 @@ export class DuplicateService extends BaseService { return JobStatus.FAILED; } + if (asset.stackId) { + this.logger.debug(`Asset ${id} is part of a stack, skipping`); + return JobStatus.SKIPPED; + } + if (!asset.isVisible) { this.logger.debug(`Asset ${id} is not visible, skipping`); return JobStatus.SKIPPED; diff --git a/server/test/fixtures/asset.stub.ts b/server/test/fixtures/asset.stub.ts index 6c20a765c7..a0619f1a10 100644 --- a/server/test/fixtures/asset.stub.ts +++ b/server/test/fixtures/asset.stub.ts @@ -184,6 +184,7 @@ export const assetStub = { exifImageHeight: 1000, exifImageWidth: 1000, } as ExifEntity, + stackId: 'stack-1', stack: stackStub('stack-1', [ { id: 'primary-asset-id' } as AssetEntity, { id: 'stack-child-asset-1' } as AssetEntity, From d20e2e268ad7a48930b5b11a6ab69396d04c1e41 Mon Sep 17 00:00:00 2001 From: Jonathan Jogenfors Date: Thu, 27 Feb 2025 17:45:16 +0100 Subject: [PATCH 235/395] fix(server): don't reimport files more than once (#16375) * fix(server) don't reimport files more than once * fix: test --------- Co-authored-by: Alex Tran --- e2e/src/api/specs/library.e2e-spec.ts | 41 ++++++++++++++++++++ server/src/services/metadata.service.spec.ts | 6 --- server/src/services/metadata.service.ts | 2 +- 3 files changed, 42 insertions(+), 7 deletions(-) diff --git a/e2e/src/api/specs/library.e2e-spec.ts b/e2e/src/api/specs/library.e2e-spec.ts index 19160fec88..4b340a2da5 100644 --- a/e2e/src/api/specs/library.e2e-spec.ts +++ b/e2e/src/api/specs/library.e2e-spec.ts @@ -526,6 +526,47 @@ describe('/libraries', () => { utils.removeImageFile(`${testAssetDir}/temp/reimport/asset.jpg`); }); + it('should not reimport a modified file more than once', async () => { + const library = await utils.createLibrary(admin.accessToken, { + ownerId: admin.userId, + importPaths: [`${testAssetDirInternal}/temp/reimport`], + }); + + utils.createImageFile(`${testAssetDir}/temp/reimport/asset.jpg`); + await utimes(`${testAssetDir}/temp/reimport/asset.jpg`, 447_775_200_000); + + await utils.scan(admin.accessToken, library.id); + + cpSync(`${testAssetDir}/albums/nature/tanners_ridge.jpg`, `${testAssetDir}/temp/reimport/asset.jpg`); + await utimes(`${testAssetDir}/temp/reimport/asset.jpg`, 447_775_200_001); + + await utils.scan(admin.accessToken, library.id); + + cpSync(`${testAssetDir}/albums/nature/el_torcal_rocks.jpg`, `${testAssetDir}/temp/reimport/asset.jpg`); + await utimes(`${testAssetDir}/temp/reimport/asset.jpg`, 447_775_200_001); + + await utils.scan(admin.accessToken, library.id); + + const { assets } = await utils.searchAssets(admin.accessToken, { + libraryId: library.id, + }); + + expect(assets.count).toEqual(1); + + const asset = await utils.getAssetInfo(admin.accessToken, assets.items[0].id); + + expect(asset).toEqual( + expect.objectContaining({ + originalFileName: 'asset.jpg', + exifInfo: expect.objectContaining({ + model: 'NIKON D750', + }), + }), + ); + + utils.removeImageFile(`${testAssetDir}/temp/reimport/asset.jpg`); + }); + it('should set an asset offline if its file is missing', async () => { const library = await utils.createLibrary(admin.accessToken, { ownerId: admin.userId, diff --git a/server/src/services/metadata.service.spec.ts b/server/src/services/metadata.service.spec.ts index 74f0231f5d..5fb4ba9a4f 100644 --- a/server/src/services/metadata.service.spec.ts +++ b/server/src/services/metadata.service.spec.ts @@ -249,7 +249,6 @@ describe(MetadataService.name, () => { id: assetStub.image.id, duration: null, fileCreatedAt: sidecarDate, - fileModifiedAt: new Date('2023-02-23T05:06:29.716Z'), localDateTime: sidecarDate, }); }); @@ -269,7 +268,6 @@ describe(MetadataService.name, () => { id: assetStub.image.id, duration: null, fileCreatedAt: fileModifiedAt, - fileModifiedAt, localDateTime: fileModifiedAt, }); }); @@ -287,7 +285,6 @@ describe(MetadataService.name, () => { id: assetStub.image.id, duration: null, fileCreatedAt, - fileModifiedAt, localDateTime: fileCreatedAt, }); }); @@ -322,7 +319,6 @@ describe(MetadataService.name, () => { id: assetStub.image.id, duration: null, fileCreatedAt: assetStub.image.fileCreatedAt, - fileModifiedAt: assetStub.image.fileModifiedAt, localDateTime: assetStub.image.fileCreatedAt, }); }); @@ -345,7 +341,6 @@ describe(MetadataService.name, () => { id: assetStub.withLocation.id, duration: null, fileCreatedAt: assetStub.withLocation.createdAt, - fileModifiedAt: assetStub.withLocation.createdAt, localDateTime: new Date('2023-02-22T05:06:29.716Z'), }); }); @@ -867,7 +862,6 @@ describe(MetadataService.name, () => { id: assetStub.image.id, duration: null, fileCreatedAt: dateForTest, - fileModifiedAt: dateForTest, localDateTime: dateForTest, }); }); diff --git a/server/src/services/metadata.service.ts b/server/src/services/metadata.service.ts index 592e0b836d..b0422c28c0 100644 --- a/server/src/services/metadata.service.ts +++ b/server/src/services/metadata.service.ts @@ -248,7 +248,7 @@ export class MetadataService extends BaseService { duration: exifTags.Duration?.toString() ?? null, localDateTime, fileCreatedAt: exifData.dateTimeOriginal ?? undefined, - fileModifiedAt: exifData.modifyDate ?? undefined, + fileModifiedAt: stats.mtime, }); await this.assetRepository.upsertJobStatus({ From 5503bf7a6062e64452acbc0e7fcdf9822dca7e2b Mon Sep 17 00:00:00 2001 From: Etienne <51496600+Etienne-bdt@users.noreply.github.com> Date: Thu, 27 Feb 2025 18:20:03 +0100 Subject: [PATCH 236/395] fix: improve contrast on disabled input field in light mode (#16368) (#16382) --- web/src/app.css | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/src/app.css b/web/src/app.css index 1127b60624..9bc1695a8f 100644 --- a/web/src/app.css +++ b/web/src/app.css @@ -127,7 +127,7 @@ input:focus-visible { @layer utilities { .immich-form-input { - @apply rounded-xl bg-slate-200 px-3 py-3 text-sm focus:border-immich-primary disabled:cursor-not-allowed disabled:bg-gray-400 disabled:text-gray-200 dark:bg-gray-600 dark:text-immich-dark-fg dark:disabled:bg-gray-800; + @apply rounded-xl bg-slate-200 px-3 py-3 text-sm focus:border-immich-primary disabled:cursor-not-allowed disabled:bg-gray-400 disabled:text-gray-400 dark:bg-gray-600 dark:text-immich-dark-fg dark:disabled:bg-gray-800 dark:disabled:text-gray-200; } .immich-form-label { From 362feb1e6246144a77e4aac122d09f35c372e136 Mon Sep 17 00:00:00 2001 From: Alex Date: Thu, 27 Feb 2025 11:49:07 -0600 Subject: [PATCH 237/395] feat(web): face tagging dialog enhancement (#16395) --- .../face-editor/face-editor.svelte | 75 ++++++++++++------- 1 file changed, 46 insertions(+), 29 deletions(-) diff --git a/web/src/lib/components/asset-viewer/face-editor/face-editor.svelte b/web/src/lib/components/asset-viewer/face-editor/face-editor.svelte index afe45331e4..e7709cd9cc 100644 --- a/web/src/lib/components/asset-viewer/face-editor/face-editor.svelte +++ b/web/src/lib/components/asset-viewer/face-editor/face-editor.svelte @@ -5,7 +5,7 @@ import { isFaceEditMode } from '$lib/stores/face-edit.svelte'; import { getPeopleThumbnailUrl } from '$lib/utils'; import { getAllPeople, createFace, type PersonResponseDto } from '@immich/sdk'; - import { Button } from '@immich/ui'; + import { Button, Input } from '@immich/ui'; import { Canvas, InteractiveFabricObject, Rect } from 'fabric'; import { onMount } from 'svelte'; import { assetViewingStore } from '$lib/stores/asset-viewing.store'; @@ -24,6 +24,16 @@ let canvas: Canvas | undefined = $state(); let faceRect: Rect | undefined = $state(); let faceSelectorEl: HTMLDivElement | undefined = $state(); + let page = $state(1); + let candidates = $state([]); + + let searchTerm = $state(''); + + let filteredCandidates = $derived( + searchTerm + ? candidates.filter((person) => person.name.toLowerCase().includes(searchTerm.toLowerCase())) + : candidates, + ); const configureControlStyle = () => { InteractiveFabricObject.ownDefaults = { @@ -133,11 +143,8 @@ isFaceEditMode.value = false; }; - let page = $state(1); - let candidates = $state([]); - const getPeople = async () => { - const { hasNextPage, people, total } = await getAllPeople({ page, size: 250, withHidden: false }); + const { hasNextPage, people, total } = await getAllPeople({ page, size: 1000, withHidden: false }); if (candidates.length === total) { return; @@ -307,33 +314,43 @@

Select a person to tag

-
-
- {#each candidates as person} - - {/each} -
+
+ +
+ +
+ {#if filteredCandidates.length > 0} +
+ {#each filteredCandidates as person} + + {/each} +
+ {:else} +
+

No matching people found

+
+ {/if}
From 4a9d80298b62dab8dc4061496d520e8405d34765 Mon Sep 17 00:00:00 2001 From: shenlong <139912620+shenlong-tanwen@users.noreply.github.com> Date: Thu, 27 Feb 2025 23:31:36 +0530 Subject: [PATCH 238/395] fix(mobile): bootstrap store inside isolates (#16392) fix: bootstrap store inside isolates Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com> Co-authored-by: Alex --- mobile/analysis_options.yaml | 1 + .../test_utils/general_helper.dart | 5 +- mobile/lib/main.dart | 42 ++-------------- mobile/lib/services/background.service.dart | 5 +- .../services/backup_verification.service.dart | 3 ++ mobile/lib/utils/bootstrap.dart | 50 +++++++++++++++++++ 6 files changed, 63 insertions(+), 43 deletions(-) create mode 100644 mobile/lib/utils/bootstrap.dart diff --git a/mobile/analysis_options.yaml b/mobile/analysis_options.yaml index dd081be64e..c8ed225ce5 100644 --- a/mobile/analysis_options.yaml +++ b/mobile/analysis_options.yaml @@ -76,6 +76,7 @@ custom_lint: - lib/routing/router.dart - lib/services/immich_logger.service.dart # not really a service... more a util - lib/utils/{db,migration}.dart + - lib/utils/bootstrap.dart - lib/widgets/asset_grid/asset_grid_data_structure.dart - test/**.dart # refactor the remaining providers diff --git a/mobile/integration_test/test_utils/general_helper.dart b/mobile/integration_test/test_utils/general_helper.dart index a3db2d49a8..8e17bae9d3 100644 --- a/mobile/integration_test/test_utils/general_helper.dart +++ b/mobile/integration_test/test_utils/general_helper.dart @@ -7,8 +7,8 @@ import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/main.dart' as app; import 'package:immich_mobile/providers/db.provider.dart'; import 'package:immich_mobile/providers/infrastructure/db.provider.dart'; +import 'package:immich_mobile/utils/bootstrap.dart'; import 'package:integration_test/integration_test.dart'; -import 'package:isar/isar.dart'; // ignore: depend_on_referenced_packages import 'package:meta/meta.dart'; @@ -39,7 +39,8 @@ class ImmichTestHelper { static Future loadApp(WidgetTester tester) async { await EasyLocalization.ensureInitialized(); // Clear all data from Isar (reuse existing instance if available) - final db = Isar.getInstance() ?? await app.loadDb(); + final db = await Bootstrap.initIsar(); + await Bootstrap.initDomain(db); await Store.clear(); await db.writeTxn(() => db.clear()); // Load main Widget diff --git a/mobile/lib/main.dart b/mobile/lib/main.dart index 822d772278..da84a8cff6 100644 --- a/mobile/lib/main.dart +++ b/mobile/lib/main.dart @@ -10,20 +10,7 @@ import 'package:flutter/services.dart'; import 'package:flutter_displaymode/flutter_displaymode.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/constants/locales.dart'; -import 'package:immich_mobile/domain/services/store.service.dart'; -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'; -import 'package:immich_mobile/entities/backup_album.entity.dart'; -import 'package:immich_mobile/entities/duplicated_asset.entity.dart'; -import 'package:immich_mobile/entities/etag.entity.dart'; -import 'package:immich_mobile/entities/exif_info.entity.dart'; -import 'package:immich_mobile/entities/ios_device_asset.entity.dart'; -import 'package:immich_mobile/entities/logger_message.entity.dart'; -import 'package:immich_mobile/entities/user.entity.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; -import 'package:immich_mobile/infrastructure/entities/store.entity.dart'; -import 'package:immich_mobile/infrastructure/repositories/store.repository.dart'; import 'package:immich_mobile/providers/app_life_cycle.provider.dart'; import 'package:immich_mobile/providers/asset_viewer/share_intent_upload.provider.dart'; import 'package:immich_mobile/providers/db.provider.dart'; @@ -37,19 +24,19 @@ import 'package:immich_mobile/services/immich_logger.service.dart'; import 'package:immich_mobile/services/local_notification.service.dart'; import 'package:immich_mobile/theme/dynamic_theme.dart'; import 'package:immich_mobile/theme/theme_data.dart'; +import 'package:immich_mobile/utils/bootstrap.dart'; import 'package:immich_mobile/utils/cache/widgets_binding.dart'; import 'package:immich_mobile/utils/download.dart'; import 'package:immich_mobile/utils/http_ssl_cert_override.dart'; import 'package:immich_mobile/utils/migration.dart'; import 'package:intl/date_symbol_data_local.dart'; -import 'package:isar/isar.dart'; import 'package:logging/logging.dart'; -import 'package:path_provider/path_provider.dart'; import 'package:timezone/data/latest.dart'; void main() async { ImmichWidgetsBinding(); - final db = await loadDb(); + final db = await Bootstrap.initIsar(); + await Bootstrap.initDomain(db); await initApp(); await migrateDatabaseIfNeeded(db); HttpOverrides.global = HttpSSLCertOverride(); @@ -122,29 +109,6 @@ Future initApp() async { await FileDownloader().trackTasks(); } -Future loadDb() async { - final dir = await getApplicationDocumentsDirectory(); - Isar db = await Isar.open( - [ - StoreValueSchema, - ExifInfoSchema, - AssetSchema, - AlbumSchema, - UserSchema, - BackupAlbumSchema, - DuplicatedAssetSchema, - LoggerMessageSchema, - ETagSchema, - if (Platform.isAndroid) AndroidDeviceAssetSchema, - if (Platform.isIOS) IOSDeviceAssetSchema, - ], - directory: dir.path, - maxSizeMiB: 1024, - ); - await StoreService.init(storeRepository: IsarStoreRepository(db)); - return db; -} - class ImmichApp extends ConsumerStatefulWidget { const ImmichApp({super.key}); diff --git a/mobile/lib/services/background.service.dart b/mobile/lib/services/background.service.dart index 2a7bfb2bb4..4e83d1f0ac 100644 --- a/mobile/lib/services/background.service.dart +++ b/mobile/lib/services/background.service.dart @@ -15,7 +15,6 @@ import 'package:immich_mobile/domain/models/store.model.dart'; import 'package:immich_mobile/entities/backup_album.entity.dart'; import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/interfaces/backup_album.interface.dart'; -import 'package:immich_mobile/main.dart'; import 'package:immich_mobile/models/backup/backup_candidate.model.dart'; import 'package:immich_mobile/models/backup/current_upload_asset.model.dart'; import 'package:immich_mobile/models/backup/error_upload_asset.model.dart'; @@ -48,6 +47,7 @@ import 'package:immich_mobile/services/network.service.dart'; import 'package:immich_mobile/services/sync.service.dart'; import 'package:immich_mobile/services/user.service.dart'; import 'package:immich_mobile/utils/backup_progress.dart'; +import 'package:immich_mobile/utils/bootstrap.dart'; import 'package:immich_mobile/utils/diff.dart'; import 'package:immich_mobile/utils/http_ssl_cert_override.dart'; import 'package:network_info_plus/network_info_plus.dart'; @@ -369,7 +369,8 @@ class BackgroundService { } Future _onAssetsChanged() async { - final db = await loadDb(); + final db = await Bootstrap.initIsar(); + await Bootstrap.initDomain(db); HttpOverrides.global = HttpSSLCertOverride(); ApiService apiService = ApiService(); diff --git a/mobile/lib/services/backup_verification.service.dart b/mobile/lib/services/backup_verification.service.dart index 5938cd7813..0d47d1a111 100644 --- a/mobile/lib/services/backup_verification.service.dart +++ b/mobile/lib/services/backup_verification.service.dart @@ -16,6 +16,7 @@ import 'package:immich_mobile/repositories/asset.repository.dart'; import 'package:immich_mobile/repositories/exif_info.repository.dart'; import 'package:immich_mobile/repositories/file_media.repository.dart'; import 'package:immich_mobile/services/api.service.dart'; +import 'package:immich_mobile/utils/bootstrap.dart'; import 'package:immich_mobile/utils/diff.dart'; /// Finds duplicates originating from missing EXIF information @@ -123,6 +124,8 @@ class BackupVerificationService { assert(tuple.deleteCandidates.length == tuple.originals.length); final List result = []; BackgroundIsolateBinaryMessenger.ensureInitialized(tuple.rootIsolateToken); + final db = await Bootstrap.initIsar(); + await Bootstrap.initDomain(db); await tuple.fileMediaRepository.enableBackgroundAccess(); final ApiService apiService = ApiService(); apiService.setEndpoint(tuple.endpoint); diff --git a/mobile/lib/utils/bootstrap.dart b/mobile/lib/utils/bootstrap.dart new file mode 100644 index 0000000000..32bdac42d5 --- /dev/null +++ b/mobile/lib/utils/bootstrap.dart @@ -0,0 +1,50 @@ +import 'dart:io'; + +import 'package:flutter/foundation.dart'; +import 'package:immich_mobile/domain/services/store.service.dart'; +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'; +import 'package:immich_mobile/entities/backup_album.entity.dart'; +import 'package:immich_mobile/entities/duplicated_asset.entity.dart'; +import 'package:immich_mobile/entities/etag.entity.dart'; +import 'package:immich_mobile/entities/exif_info.entity.dart'; +import 'package:immich_mobile/entities/ios_device_asset.entity.dart'; +import 'package:immich_mobile/entities/logger_message.entity.dart'; +import 'package:immich_mobile/entities/user.entity.dart'; +import 'package:immich_mobile/infrastructure/entities/store.entity.dart'; +import 'package:immich_mobile/infrastructure/repositories/store.repository.dart'; +import 'package:isar/isar.dart'; +import 'package:path_provider/path_provider.dart'; + +abstract final class Bootstrap { + static Future initIsar() async { + if (Isar.getInstance() != null) { + return Isar.getInstance()!; + } + + final dir = await getApplicationDocumentsDirectory(); + return await Isar.open( + [ + StoreValueSchema, + ExifInfoSchema, + AssetSchema, + AlbumSchema, + UserSchema, + BackupAlbumSchema, + DuplicatedAssetSchema, + LoggerMessageSchema, + ETagSchema, + if (Platform.isAndroid) AndroidDeviceAssetSchema, + if (Platform.isIOS) IOSDeviceAssetSchema, + ], + directory: dir.path, + maxSizeMiB: 1024, + inspector: kDebugMode, + ); + } + + static Future initDomain(Isar db) async { + await StoreService.init(storeRepository: IsarStoreRepository(db)); + } +} From 1c862930355b0ca159f659a5009eb7994fb67efe Mon Sep 17 00:00:00 2001 From: Alex Date: Thu, 27 Feb 2025 12:35:28 -0600 Subject: [PATCH 239/395] chore(mobile): update analysis option (#16396) chore-update-analysis-option --- mobile/analysis_options.yaml | 1 - 1 file changed, 1 deletion(-) diff --git a/mobile/analysis_options.yaml b/mobile/analysis_options.yaml index c8ed225ce5..5cf21e1dd6 100644 --- a/mobile/analysis_options.yaml +++ b/mobile/analysis_options.yaml @@ -81,7 +81,6 @@ custom_lint: - test/**.dart # refactor the remaining providers - lib/providers/db.provider.dart - - lib/providers/backup/backup.provider.dart - import_rule_openapi: message: openapi must only be used through ApiRepositories From fbd85a89e05213c4968b7cd995097600b08a3cf6 Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Thu, 27 Feb 2025 14:59:50 -0500 Subject: [PATCH 240/395] refactor: logger (#16393) --- server/src/bin/sync-sql.ts | 4 +- .../repositories/logging.repository.spec.ts | 30 ++-- server/src/repositories/logging.repository.ts | 140 ++++++++++++++---- server/src/repositories/map.repository.ts | 11 +- .../notification.repository.spec.ts | 9 +- .../repositories/storage.repository.spec.ts | 8 +- server/src/services/storage.service.ts | 2 +- .../medium/specs/metadata.service.spec.ts | 7 +- .../repositories/logger.repository.mock.ts | 22 +-- server/test/utils.ts | 6 +- 10 files changed, 153 insertions(+), 86 deletions(-) diff --git a/server/src/bin/sync-sql.ts b/server/src/bin/sync-sql.ts index 4765993643..a9f5d72ec9 100644 --- a/server/src/bin/sync-sql.ts +++ b/server/src/bin/sync-sql.ts @@ -6,6 +6,7 @@ import { Test } from '@nestjs/testing'; import { TypeOrmModule } from '@nestjs/typeorm'; import { ClassConstructor } from 'class-transformer'; import { PostgresJSDialect } from 'kysely-postgres-js'; +import { ClsModule } from 'nestjs-cls'; import { KyselyModule } from 'nestjs-kysely'; import { OpenTelemetryModule } from 'nestjs-otel'; import { mkdir, rm, writeFile } from 'node:fs/promises'; @@ -77,7 +78,7 @@ class SqlGenerator { await mkdir(this.options.targetDir); process.env.DB_HOSTNAME = 'localhost'; - const { database, otel } = new ConfigRepository().getEnv(); + const { database, cls, otel } = new ConfigRepository().getEnv(); const moduleFixture = await Test.createTestingModule({ imports: [ @@ -92,6 +93,7 @@ class SqlGenerator { } }, }), + ClsModule.forRoot(cls.config), TypeOrmModule.forRoot({ ...database.config.typeorm, entities, diff --git a/server/src/repositories/logging.repository.spec.ts b/server/src/repositories/logging.repository.spec.ts index 10c1a6516c..393eeb9496 100644 --- a/server/src/repositories/logging.repository.spec.ts +++ b/server/src/repositories/logging.repository.spec.ts @@ -1,8 +1,8 @@ import { ClsService } from 'nestjs-cls'; import { ImmichWorker } from 'src/enum'; import { ConfigRepository } from 'src/repositories/config.repository'; -import { LoggingRepository } from 'src/repositories/logging.repository'; -import { mockEnvData, newConfigRepositoryMock } from 'test/repositories/config.repository.mock'; +import { LoggingRepository, MyConsoleLogger } from 'src/repositories/logging.repository'; +import { newConfigRepositoryMock } from 'test/repositories/config.repository.mock'; import { Mocked } from 'vitest'; describe(LoggingRepository.name, () => { @@ -18,23 +18,25 @@ describe(LoggingRepository.name, () => { } as unknown as Mocked; }); - describe('formatContext', () => { - it('should use colors', () => { - configMock.getEnv.mockReturnValue(mockEnvData({ noColor: false })); + describe(MyConsoleLogger.name, () => { + describe('formatContext', () => { + it('should use colors', () => { + sut = new LoggingRepository(clsMock, configMock); + sut.setAppName(ImmichWorker.API); - sut = new LoggingRepository(clsMock, configMock); - sut.setAppName(ImmichWorker.API); + const logger = new MyConsoleLogger(clsMock, { color: true }); - expect(sut['formatContext']('context')).toBe('\u001B[33m[Api:context]\u001B[39m '); - }); + expect(logger.formatContext('context')).toBe('\u001B[33m[Api:context]\u001B[39m '); + }); - it('should not use colors when noColor is true', () => { - configMock.getEnv.mockReturnValue(mockEnvData({ noColor: true })); + it('should not use colors when color is false', () => { + sut = new LoggingRepository(clsMock, configMock); + sut.setAppName(ImmichWorker.API); - sut = new LoggingRepository(clsMock, configMock); - sut.setAppName(ImmichWorker.API); + const logger = new MyConsoleLogger(clsMock, { color: false }); - expect(sut['formatContext']('context')).toBe('[Api:context] '); + expect(logger.formatContext('context')).toBe('[Api:context] '); + }); }); }); }); diff --git a/server/src/repositories/logging.repository.ts b/server/src/repositories/logging.repository.ts index 7ddae26a9d..801f467a6d 100644 --- a/server/src/repositories/logging.repository.ts +++ b/server/src/repositories/logging.repository.ts @@ -5,6 +5,9 @@ import { Telemetry } from 'src/decorators'; import { LogLevel } from 'src/enum'; import { ConfigRepository } from 'src/repositories/config.repository'; +type LogDetails = any[]; +type LogFunction = () => string; + const LOG_LEVELS = [LogLevel.VERBOSE, LogLevel.DEBUG, LogLevel.LOG, LogLevel.WARN, LogLevel.ERROR, LogLevel.FATAL]; enum LogColor { @@ -16,38 +19,22 @@ enum LogColor { CYAN_BRIGHT = 96, } -@Injectable({ scope: Scope.TRANSIENT }) -@Telemetry({ enabled: false }) -export class LoggingRepository extends ConsoleLogger { - private static logLevels: LogLevel[] = [LogLevel.LOG, LogLevel.WARN, LogLevel.ERROR, LogLevel.FATAL]; - private noColor: boolean; +let appName: string | undefined; +let logLevels: LogLevel[] = [LogLevel.LOG, LogLevel.WARN, LogLevel.ERROR, LogLevel.FATAL]; + +export class MyConsoleLogger extends ConsoleLogger { + private isColorEnabled: boolean; constructor( private cls: ClsService, - configRepository: ConfigRepository, + options?: { color?: boolean; context?: string }, ) { - super(LoggingRepository.name); - - const { noColor } = configRepository.getEnv(); - this.noColor = noColor; + super(options?.context || MyConsoleLogger.name); + this.isColorEnabled = options?.color || false; } - private static appName?: string = undefined; - - setAppName(name: string): void { - LoggingRepository.appName = name.charAt(0).toUpperCase() + name.slice(1); - } - - isLevelEnabled(level: LogLevel) { - return isLogLevelEnabled(level, LoggingRepository.logLevels); - } - - setLogLevel(level: LogLevel | false): void { - LoggingRepository.logLevels = level ? LOG_LEVELS.slice(LOG_LEVELS.indexOf(level)) : []; - } - - protected formatContext(context: string): string { - let prefix = LoggingRepository.appName || ''; + formatContext(context: string): string { + let prefix = appName || ''; if (context) { prefix += (prefix ? ':' : '') + context; } @@ -74,6 +61,105 @@ export class LoggingRepository extends ConsoleLogger { }; private withColor(text: string, color: LogColor) { - return this.noColor ? text : `\u001B[${color}m${text}\u001B[39m`; + return this.isColorEnabled ? `\u001B[${color}m${text}\u001B[39m` : text; + } +} + +@Injectable({ scope: Scope.TRANSIENT }) +@Telemetry({ enabled: false }) +export class LoggingRepository { + private logger: MyConsoleLogger; + + constructor(cls: ClsService, configRepository: ConfigRepository) { + const { noColor } = configRepository.getEnv(); + this.logger = new MyConsoleLogger(cls, { context: LoggingRepository.name, color: !noColor }); + } + + setAppName(name: string): void { + appName = name.charAt(0).toUpperCase() + name.slice(1); + } + + setContext(context: string) { + this.logger.setContext(context); + } + + isLevelEnabled(level: LogLevel) { + return isLogLevelEnabled(level, logLevels); + } + + setLogLevel(level: LogLevel | false): void { + logLevels = level ? LOG_LEVELS.slice(LOG_LEVELS.indexOf(level)) : []; + } + + verbose(message: string, ...details: LogDetails) { + this.handleMessage(LogLevel.VERBOSE, message, details); + } + + verboseFn(message: LogFunction, ...details: LogDetails) { + this.handleFunction(LogLevel.VERBOSE, message, details); + } + + debug(message: string, ...details: LogDetails) { + this.handleMessage(LogLevel.DEBUG, message, details); + } + + debugFn(message: LogFunction, ...details: LogDetails) { + this.handleFunction(LogLevel.DEBUG, message, details); + } + + log(message: string, ...details: LogDetails) { + this.handleMessage(LogLevel.LOG, message, details); + } + + warn(message: string, ...details: LogDetails) { + this.handleMessage(LogLevel.WARN, message, details); + } + + error(message: string | Error, ...details: LogDetails) { + this.handleMessage(LogLevel.ERROR, message, details); + } + + fatal(message: string, ...details: LogDetails) { + this.handleMessage(LogLevel.FATAL, message, details); + } + + private handleFunction(level: LogLevel, message: LogFunction, details: LogDetails[]) { + if (this.isLevelEnabled(level)) { + this.handleMessage(level, message(), details); + } + } + + private handleMessage(level: LogLevel, message: string | Error, details: LogDetails[]) { + switch (level) { + case LogLevel.VERBOSE: { + this.logger.verbose(message, ...details); + break; + } + + case LogLevel.DEBUG: { + this.logger.debug(message, ...details); + break; + } + + case LogLevel.LOG: { + this.logger.log(message, ...details); + break; + } + + case LogLevel.WARN: { + this.logger.warn(message, ...details); + break; + } + + case LogLevel.ERROR: { + this.logger.error(message, ...details); + break; + } + + case LogLevel.FATAL: { + this.logger.fatal(message, ...details); + break; + } + } } } diff --git a/server/src/repositories/map.repository.ts b/server/src/repositories/map.repository.ts index 965e7ffd13..442225f7c8 100644 --- a/server/src/repositories/map.repository.ts +++ b/server/src/repositories/map.repository.ts @@ -10,7 +10,7 @@ import { citiesFile } from 'src/constants'; import { DB, GeodataPlaces, NaturalearthCountries } from 'src/db'; import { DummyValue, GenerateSql } from 'src/decorators'; import { NaturalEarthCountriesTempEntity } from 'src/entities/natural-earth-countries.entity'; -import { LogLevel, SystemMetadataKey } from 'src/enum'; +import { SystemMetadataKey } from 'src/enum'; import { ConfigRepository } from 'src/repositories/config.repository'; import { LoggingRepository } from 'src/repositories/logging.repository'; import { SystemMetadataRepository } from 'src/repositories/system-metadata.repository'; @@ -137,9 +137,7 @@ export class MapRepository { .executeTakeFirst(); if (response) { - if (this.logger.isLevelEnabled(LogLevel.VERBOSE)) { - this.logger.verbose(`Raw: ${JSON.stringify(response, null, 2)}`); - } + this.logger.verboseFn(() => `Raw: ${JSON.stringify(response, null, 2)}`); const { countryCode, name: city, admin1Name } = response; const country = getName(countryCode, 'en') ?? null; @@ -167,9 +165,8 @@ export class MapRepository { return { country: null, state: null, city: null }; } - if (this.logger.isLevelEnabled(LogLevel.VERBOSE)) { - this.logger.verbose(`Raw: ${JSON.stringify(ne_response, ['id', 'admin', 'admin_a3', 'type'], 2)}`); - } + this.logger.verboseFn(() => `Raw: ${JSON.stringify(ne_response, ['id', 'admin', 'admin_a3', 'type'], 2)}`); + const { admin_a3 } = ne_response; const country = getName(admin_a3, 'en') ?? null; const state = null; diff --git a/server/src/repositories/notification.repository.spec.ts b/server/src/repositories/notification.repository.spec.ts index 7707069dd9..6253697087 100644 --- a/server/src/repositories/notification.repository.spec.ts +++ b/server/src/repositories/notification.repository.spec.ts @@ -1,16 +1,11 @@ -import { LoggingRepository } from 'src/repositories/logging.repository'; import { EmailRenderRequest, EmailTemplate, NotificationRepository } from 'src/repositories/notification.repository'; -import { ILoggingRepository, newLoggingRepositoryMock } from 'test/repositories/logger.repository.mock'; -import { Mocked } from 'vitest'; +import { newFakeLoggingRepository } from 'test/repositories/logger.repository.mock'; describe(NotificationRepository.name, () => { let sut: NotificationRepository; - let loggerMock: Mocked; beforeEach(() => { - loggerMock = newLoggingRepositoryMock() as ILoggingRepository as Mocked; - - sut = new NotificationRepository(loggerMock as LoggingRepository); + sut = new NotificationRepository(newFakeLoggingRepository()); }); describe('renderEmail', () => { diff --git a/server/src/repositories/storage.repository.spec.ts b/server/src/repositories/storage.repository.spec.ts index 3ab9e615ec..93b21a7f9b 100644 --- a/server/src/repositories/storage.repository.spec.ts +++ b/server/src/repositories/storage.repository.spec.ts @@ -1,9 +1,7 @@ import mockfs from 'mock-fs'; import { CrawlOptionsDto } from 'src/dtos/library.dto'; -import { LoggingRepository } from 'src/repositories/logging.repository'; import { StorageRepository } from 'src/repositories/storage.repository'; -import { ILoggingRepository, newLoggingRepositoryMock } from 'test/repositories/logger.repository.mock'; -import { Mocked } from 'vitest'; +import { newFakeLoggingRepository } from 'test/repositories/logger.repository.mock'; interface Test { test: string; @@ -182,11 +180,9 @@ const tests: Test[] = [ describe(StorageRepository.name, () => { let sut: StorageRepository; - let logger: Mocked; beforeEach(() => { - logger = newLoggingRepositoryMock(); - sut = new StorageRepository(logger as ILoggingRepository as LoggingRepository); + sut = new StorageRepository(newFakeLoggingRepository()); }); afterEach(() => { diff --git a/server/src/services/storage.service.ts b/server/src/services/storage.service.ts index c0c7a00ae7..ca1d9e7921 100644 --- a/server/src/services/storage.service.ts +++ b/server/src/services/storage.service.ts @@ -54,7 +54,7 @@ export class StorageService extends BaseService { this.logger.log('Successfully verified system mount folder checks'); } catch (error) { if (envData.storage.ignoreMountCheckErrors) { - this.logger.error(error); + this.logger.error(error as Error); this.logger.warn('Ignoring mount folder errors'); } else { throw error; diff --git a/server/test/medium/specs/metadata.service.spec.ts b/server/test/medium/specs/metadata.service.spec.ts index 4c89ce4e37..275d3f1bda 100644 --- a/server/test/medium/specs/metadata.service.spec.ts +++ b/server/test/medium/specs/metadata.service.spec.ts @@ -3,15 +3,12 @@ import { writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { AssetEntity } from 'src/entities/asset.entity'; -import { LoggingRepository } from 'src/repositories/logging.repository'; import { MetadataRepository } from 'src/repositories/metadata.repository'; import { MetadataService } from 'src/services/metadata.service'; -import { ILoggingRepository, newLoggingRepositoryMock } from 'test/repositories/logger.repository.mock'; +import { newFakeLoggingRepository } from 'test/repositories/logger.repository.mock'; import { newRandomImage, newTestService, ServiceMocks } from 'test/utils'; -const metadataRepository = new MetadataRepository( - newLoggingRepositoryMock() as ILoggingRepository as LoggingRepository, -); +const metadataRepository = new MetadataRepository(newFakeLoggingRepository()); const createTestFile = async (exifData: Record) => { const data = newRandomImage(); diff --git a/server/test/repositories/logger.repository.mock.ts b/server/test/repositories/logger.repository.mock.ts index 46a81c8965..7257d375f1 100644 --- a/server/test/repositories/logger.repository.mock.ts +++ b/server/test/repositories/logger.repository.mock.ts @@ -1,31 +1,23 @@ import { LoggingRepository } from 'src/repositories/logging.repository'; +import { RepositoryInterface } from 'src/types'; import { Mocked, vitest } from 'vitest'; -export type ILoggingRepository = Pick< - LoggingRepository, - | 'verbose' - | 'log' - | 'debug' - | 'warn' - | 'error' - | 'fatal' - | 'isLevelEnabled' - | 'setLogLevel' - | 'setContext' - | 'setAppName' ->; - -export const newLoggingRepositoryMock = (): Mocked => { +export const newLoggingRepositoryMock = (): Mocked> => { return { setLogLevel: vitest.fn(), setContext: vitest.fn(), setAppName: vitest.fn(), isLevelEnabled: vitest.fn(), verbose: vitest.fn(), + verboseFn: vitest.fn(), debug: vitest.fn(), + debugFn: vitest.fn(), log: vitest.fn(), warn: vitest.fn(), error: vitest.fn(), fatal: vitest.fn(), }; }; + +export const newFakeLoggingRepository = () => + newLoggingRepositoryMock() as RepositoryInterface as LoggingRepository; diff --git a/server/test/utils.ts b/server/test/utils.ts index 8f65ec614d..8b3798b8b1 100644 --- a/server/test/utils.ts +++ b/server/test/utils.ts @@ -63,7 +63,7 @@ import { newDatabaseRepositoryMock } from 'test/repositories/database.repository import { newEventRepositoryMock } from 'test/repositories/event.repository.mock'; import { newJobRepositoryMock } from 'test/repositories/job.repository.mock'; import { newLibraryRepositoryMock } from 'test/repositories/library.repository.mock'; -import { ILoggingRepository, newLoggingRepositoryMock } from 'test/repositories/logger.repository.mock'; +import { newLoggingRepositoryMock } from 'test/repositories/logger.repository.mock'; import { newMachineLearningRepositoryMock } from 'test/repositories/machine-learning.repository.mock'; import { newMapRepositoryMock } from 'test/repositories/map.repository.mock'; import { newMediaRepositoryMock } from 'test/repositories/media.repository.mock'; @@ -120,7 +120,7 @@ export type ServiceMocks = { event: Mocked>; job: Mocked>; library: Mocked>; - logger: Mocked; + logger: Mocked>; machineLearning: Mocked>; map: Mocked>; media: Mocked>; @@ -197,7 +197,7 @@ export const newTestService = ( const viewMock = newViewRepositoryMock(); const sut = new Service( - loggerMock as ILoggingRepository as LoggingRepository, + loggerMock as RepositoryInterface as LoggingRepository, accessMock as IAccessRepository as AccessRepository, activityMock as RepositoryInterface as ActivityRepository, auditMock as RepositoryInterface as AuditRepository, From 28c664c76966299d876e351edcc17c3a9d6db6a7 Mon Sep 17 00:00:00 2001 From: shenlong <139912620+shenlong-tanwen@users.noreply.github.com> Date: Fri, 28 Feb 2025 01:48:49 +0530 Subject: [PATCH 241/395] refactor(mobile): log service (#16383) refactor: log service Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com> --- mobile/analysis_options.yaml | 2 +- mobile/lib/constants/constants.dart | 3 + .../lib/domain/interfaces/log.interface.dart | 16 ++ mobile/lib/domain/models/log.model.dart | 69 +++++++ mobile/lib/domain/services/log.service.dart | 153 ++++++++++++++ .../lib/entities/logger_message.entity.dart | 50 ----- .../infrastructure/entities/log.entity.dart | 52 +++++ .../entities/log.entity.g.dart} | 2 +- .../repositories/log.repository.dart | 53 +++++ mobile/lib/main.dart | 4 - mobile/lib/pages/common/app_log.page.dart | 20 +- .../lib/pages/common/app_log_detail.page.dart | 20 +- .../providers/app_life_cycle.provider.dart | 16 +- mobile/lib/routing/router.dart | 36 ++-- mobile/lib/routing/router.gr.dart | 4 +- .../lib/services/immich_logger.service.dart | 97 +-------- mobile/lib/utils/bootstrap.dart | 8 +- .../widgets/settings/advanced_settings.dart | 16 +- mobile/pubspec.lock | 2 +- mobile/pubspec.yaml | 1 + .../domain/services/log_service_test.dart | 186 ++++++++++++++++++ .../test/infrastructure/repository.mock.dart | 3 + .../modules/shared/sync_service_test.dart | 8 +- mobile/test/test_utils.dart | 36 +++- 24 files changed, 656 insertions(+), 201 deletions(-) create mode 100644 mobile/lib/domain/interfaces/log.interface.dart create mode 100644 mobile/lib/domain/models/log.model.dart create mode 100644 mobile/lib/domain/services/log.service.dart delete mode 100644 mobile/lib/entities/logger_message.entity.dart create mode 100644 mobile/lib/infrastructure/entities/log.entity.dart rename mobile/lib/{entities/logger_message.entity.g.dart => infrastructure/entities/log.entity.g.dart} (99%) create mode 100644 mobile/lib/infrastructure/repositories/log.repository.dart create mode 100644 mobile/test/domain/services/log_service_test.dart diff --git a/mobile/analysis_options.yaml b/mobile/analysis_options.yaml index 5cf21e1dd6..2e74f6153a 100644 --- a/mobile/analysis_options.yaml +++ b/mobile/analysis_options.yaml @@ -67,7 +67,7 @@ custom_lint: - lib/entities/*.entity.dart - lib/repositories/{album,asset,backup,database,etag,exif_info,user,timeline,partner}.repository.dart - lib/infrastructure/entities/*.entity.dart - - lib/infrastructure/repositories/{store,db}.repository.dart + - lib/infrastructure/repositories/{store,db,log}.repository.dart - lib/providers/infrastructure/db.provider.dart # acceptable exceptions for the time being (until Isar is fully replaced) - integration_test/test_utils/general_helper.dart diff --git a/mobile/lib/constants/constants.dart b/mobile/lib/constants/constants.dart index cc0e7ca215..868b036d1b 100644 --- a/mobile/lib/constants/constants.dart +++ b/mobile/lib/constants/constants.dart @@ -1,3 +1,6 @@ const int noDbId = -9223372036854775808; // from Isar const double downloadCompleted = -1; const double downloadFailed = -2; + +// Number of log entries to retain on app start +const int kLogTruncateLimit = 250; diff --git a/mobile/lib/domain/interfaces/log.interface.dart b/mobile/lib/domain/interfaces/log.interface.dart new file mode 100644 index 0000000000..f1cbc977dd --- /dev/null +++ b/mobile/lib/domain/interfaces/log.interface.dart @@ -0,0 +1,16 @@ +import 'dart:async'; + +import 'package:immich_mobile/domain/models/log.model.dart'; + +abstract interface class ILogRepository { + Future insert(LogMessage log); + + Future insertAll(Iterable logs); + + Future> getAll(); + + Future deleteAll(); + + /// Truncates the logs to the most recent [limit]. Defaults to recent 250 logs + Future truncate({int limit = 250}); +} diff --git a/mobile/lib/domain/models/log.model.dart b/mobile/lib/domain/models/log.model.dart new file mode 100644 index 0000000000..51f816df01 --- /dev/null +++ b/mobile/lib/domain/models/log.model.dart @@ -0,0 +1,69 @@ +// ignore_for_file: constant_identifier_names + +import 'package:logging/logging.dart'; + +/// Log levels according to dart logging [Level] +enum LogLevel { + ALL, + FINEST, + FINER, + FINE, + CONFIG, + INFO, + WARNING, + SEVERE, + SHOUT, + OFF, +} + +class LogMessage { + final String message; + final LogLevel level; + final DateTime createdAt; + final String? logger; + final String? error; + final String? stack; + + const LogMessage({ + required this.message, + required this.level, + required this.createdAt, + this.logger, + this.error, + this.stack, + }); + + @override + bool operator ==(covariant LogMessage other) { + if (identical(this, other)) return true; + + return other.message == message && + other.level == level && + other.createdAt == createdAt && + other.logger == logger && + other.error == error && + other.stack == stack; + } + + @override + int get hashCode { + return message.hashCode ^ + level.hashCode ^ + createdAt.hashCode ^ + logger.hashCode ^ + error.hashCode ^ + stack.hashCode; + } + + @override + String toString() { + return '''LogMessage: { +message: $message, +level: $level, +createdAt: $createdAt, +logger: ${logger ?? ''}, +error: ${error ?? ''}, +stack: ${stack ?? ''}, +}'''; + } +} diff --git a/mobile/lib/domain/services/log.service.dart b/mobile/lib/domain/services/log.service.dart new file mode 100644 index 0000000000..61b5638e78 --- /dev/null +++ b/mobile/lib/domain/services/log.service.dart @@ -0,0 +1,153 @@ +import 'dart:async'; + +import 'package:immich_mobile/constants/constants.dart'; +import 'package:immich_mobile/domain/interfaces/log.interface.dart'; +import 'package:immich_mobile/domain/interfaces/store.interface.dart'; +import 'package:immich_mobile/domain/models/log.model.dart'; +import 'package:immich_mobile/domain/models/store.model.dart'; +import 'package:logging/logging.dart'; + +class LogService { + final ILogRepository _logRepository; + final IStoreRepository _storeRepository; + + final List _msgBuffer = []; + + /// Whether to buffer logs in memory before writing to the database. + /// This is useful when logging in quick succession, as it increases performance + /// and reduces NAND wear. However, it may cause the logs to be lost in case of a crash / in isolates. + final bool _shouldBuffer; + Timer? _flushTimer; + + late final StreamSubscription _logSubscription; + + LogService._( + this._logRepository, + this._storeRepository, + this._shouldBuffer, + ) { + // Listen to log messages and write them to the database + _logSubscription = Logger.root.onRecord.listen(_writeLogToDatabase); + } + + static LogService? _instance; + static LogService get I { + if (_instance == null) { + throw const LoggerUnInitializedException(); + } + return _instance!; + } + + static Future init({ + required ILogRepository logRepo, + required IStoreRepository storeRepo, + bool shouldBuffer = true, + }) async { + if (_instance != null) { + return _instance!; + } + _instance = await create( + logRepo: logRepo, + storeRepo: storeRepo, + shouldBuffer: shouldBuffer, + ); + return _instance!; + } + + static Future create({ + required ILogRepository logRepo, + required IStoreRepository storeRepo, + bool shouldBuffer = true, + }) async { + final instance = LogService._(logRepo, storeRepo, shouldBuffer); + // Truncate logs to 250 + await logRepo.truncate(limit: kLogTruncateLimit); + // Get log level from store + final level = await instance._storeRepository.tryGet(StoreKey.logLevel); + if (level != null) { + Logger.root.level = Level.LEVELS.elementAtOrNull(level) ?? Level.INFO; + } + return instance; + } + + Future setlogLevel(LogLevel level) async { + await _storeRepository.insert(StoreKey.logLevel, level.index); + Logger.root.level = level.toLevel(); + } + + Future> getMessages() async { + final logsFromDb = await _logRepository.getAll(); + if (_msgBuffer.isNotEmpty) { + return [..._msgBuffer.reversed, ...logsFromDb]; + } + return logsFromDb; + } + + Future clearLogs() async { + _flushTimer?.cancel(); + _flushTimer = null; + _msgBuffer.clear(); + await _logRepository.deleteAll(); + } + + /// Flush pending log messages to persistent storage + Future flush() async { + if (_flushTimer == null) { + return; + } + _flushTimer!.cancel(); + await _flushBufferToDatabase(); + } + + Future dispose() { + _flushTimer?.cancel(); + _logSubscription.cancel(); + return _flushBufferToDatabase(); + } + + void _writeLogToDatabase(LogRecord r) { + final record = LogMessage( + message: r.message, + level: r.level.toLogLevel(), + createdAt: r.time, + logger: r.loggerName, + error: r.error?.toString(), + stack: r.stackTrace?.toString(), + ); + + if (_shouldBuffer) { + _msgBuffer.add(record); + _flushTimer ??= Timer( + const Duration(seconds: 5), + () => unawaited(_flushBufferToDatabase()), + ); + } else { + unawaited(_logRepository.insert(record)); + } + } + + Future _flushBufferToDatabase() async { + _flushTimer = null; + final buffer = [..._msgBuffer]; + _msgBuffer.clear(); + await _logRepository.insertAll(buffer); + } +} + +class LoggerUnInitializedException implements Exception { + const LoggerUnInitializedException(); + + @override + String toString() => 'Logger is not initialized. Call init()'; +} + +/// Log levels according to dart logging [Level] +extension LevelDomainToInfraExtension on Level { + LogLevel toLogLevel() => + LogLevel.values.elementAtOrNull(Level.LEVELS.indexOf(this)) ?? + LogLevel.INFO; +} + +extension on LogLevel { + Level toLevel() => Level.LEVELS.elementAtOrNull(index) ?? Level.INFO; +} diff --git a/mobile/lib/entities/logger_message.entity.dart b/mobile/lib/entities/logger_message.entity.dart deleted file mode 100644 index d904e19e7a..0000000000 --- a/mobile/lib/entities/logger_message.entity.dart +++ /dev/null @@ -1,50 +0,0 @@ -// ignore_for_file: constant_identifier_names - -import 'package:isar/isar.dart'; -import 'package:logging/logging.dart'; - -part 'logger_message.entity.g.dart'; - -@Collection(inheritance: false) -class LoggerMessage { - Id id = Isar.autoIncrement; - String message; - String? details; - @Enumerated(EnumType.ordinal) - LogLevel level = LogLevel.INFO; - DateTime createdAt; - String? context1; - String? context2; - - LoggerMessage({ - required this.message, - required this.details, - required this.level, - required this.createdAt, - required this.context1, - required this.context2, - }); - - @override - String toString() { - return 'InAppLoggerMessage(message: $message, level: $level, createdAt: $createdAt)'; - } -} - -/// Log levels according to dart logging [Level] -enum LogLevel { - ALL, - FINEST, - FINER, - FINE, - CONFIG, - INFO, - WARNING, - SEVERE, - SHOUT, - OFF, -} - -extension LevelExtension on Level { - LogLevel toLogLevel() => LogLevel.values[Level.LEVELS.indexOf(this)]; -} diff --git a/mobile/lib/infrastructure/entities/log.entity.dart b/mobile/lib/infrastructure/entities/log.entity.dart new file mode 100644 index 0000000000..6c55f17989 --- /dev/null +++ b/mobile/lib/infrastructure/entities/log.entity.dart @@ -0,0 +1,52 @@ +import 'package:immich_mobile/domain/models/log.model.dart'; +import 'package:isar/isar.dart'; + +part 'log.entity.g.dart'; + +@Collection(inheritance: false) +class LoggerMessage { + Id id = Isar.autoIncrement; + String message; + String? details; + @Enumerated(EnumType.ordinal) + LogLevel level = LogLevel.INFO; + DateTime createdAt; + String? context1; + String? context2; + + LoggerMessage({ + required this.message, + required this.details, + required this.level, + required this.createdAt, + required this.context1, + required this.context2, + }); + + @override + String toString() { + return 'LoggerMessage(message: $message, level: $level, createdAt: $createdAt)'; + } + + LogMessage toDto() { + return LogMessage( + message: message, + level: level, + createdAt: createdAt, + logger: context1, + error: details, + stack: context2, + ); + } + + static LoggerMessage fromDto(LogMessage log) { + return LoggerMessage( + message: log.message, + details: log.error, + level: log.level, + createdAt: log.createdAt, + context1: log.logger, + context2: log.stack, + ); + } +} diff --git a/mobile/lib/entities/logger_message.entity.g.dart b/mobile/lib/infrastructure/entities/log.entity.g.dart similarity index 99% rename from mobile/lib/entities/logger_message.entity.g.dart rename to mobile/lib/infrastructure/entities/log.entity.g.dart index e292e7173a..f3ee284aa4 100644 --- a/mobile/lib/entities/logger_message.entity.g.dart +++ b/mobile/lib/infrastructure/entities/log.entity.g.dart @@ -1,6 +1,6 @@ // GENERATED CODE - DO NOT MODIFY BY HAND -part of 'logger_message.entity.dart'; +part of 'log.entity.dart'; // ************************************************************************** // IsarCollectionGenerator diff --git a/mobile/lib/infrastructure/repositories/log.repository.dart b/mobile/lib/infrastructure/repositories/log.repository.dart new file mode 100644 index 0000000000..6ff128f93b --- /dev/null +++ b/mobile/lib/infrastructure/repositories/log.repository.dart @@ -0,0 +1,53 @@ +import 'package:immich_mobile/domain/interfaces/log.interface.dart'; +import 'package:immich_mobile/domain/models/log.model.dart'; +import 'package:immich_mobile/infrastructure/entities/log.entity.dart'; +import 'package:immich_mobile/infrastructure/repositories/db.repository.dart'; +import 'package:isar/isar.dart'; + +class IsarLogRepository extends IsarDatabaseRepository + implements ILogRepository { + final Isar _db; + const IsarLogRepository(super.db) : _db = db; + + @override + Future deleteAll() async { + await transaction(() async => await _db.loggerMessages.clear()); + return true; + } + + @override + Future> getAll() async { + final logs = + await _db.loggerMessages.where().sortByCreatedAtDesc().findAll(); + return logs.map((l) => l.toDto()).toList(); + } + + @override + Future insert(LogMessage log) async { + final logEntity = LoggerMessage.fromDto(log); + await transaction(() async { + await _db.loggerMessages.put(logEntity); + }); + return true; + } + + @override + Future insertAll(Iterable logs) async { + await transaction(() async { + final logEntities = + logs.map((log) => LoggerMessage.fromDto(log)).toList(); + await _db.loggerMessages.putAll(logEntities); + }); + return true; + } + + @override + Future truncate({int limit = 250}) async { + await transaction(() async { + final count = await _db.loggerMessages.count(); + if (count <= limit) return; + final toRemove = count - limit; + await _db.loggerMessages.where().limit(toRemove).deleteAll(); + }); + } +} diff --git a/mobile/lib/main.dart b/mobile/lib/main.dart index da84a8cff6..407ea86d59 100644 --- a/mobile/lib/main.dart +++ b/mobile/lib/main.dart @@ -20,7 +20,6 @@ import 'package:immich_mobile/providers/theme.provider.dart'; import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/routing/tab_navigation_observer.dart'; import 'package:immich_mobile/services/background.service.dart'; -import 'package:immich_mobile/services/immich_logger.service.dart'; import 'package:immich_mobile/services/local_notification.service.dart'; import 'package:immich_mobile/theme/dynamic_theme.dart'; import 'package:immich_mobile/theme/theme_data.dart'; @@ -67,9 +66,6 @@ Future initApp() async { await DynamicTheme.fetchSystemPalette(); - // Initialize Immich Logger Service - ImmichLogger(); - final log = Logger("ImmichErrorLogger"); FlutterError.onError = (details) { diff --git a/mobile/lib/pages/common/app_log.page.dart b/mobile/lib/pages/common/app_log.page.dart index 226d380a28..3bd2e0111f 100644 --- a/mobile/lib/pages/common/app_log.page.dart +++ b/mobile/lib/pages/common/app_log.page.dart @@ -2,10 +2,11 @@ import 'package:auto_route/auto_route.dart'; import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/domain/models/log.model.dart'; +import 'package:immich_mobile/domain/services/log.service.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/theme_extensions.dart'; import 'package:immich_mobile/routing/router.dart'; -import 'package:immich_mobile/entities/logger_message.entity.dart'; import 'package:immich_mobile/services/immich_logger.service.dart'; import 'package:intl/intl.dart'; @@ -17,8 +18,11 @@ class AppLogPage extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final immichLogger = ImmichLogger(); - final logMessages = useState(immichLogger.messages); + final immichLogger = LogService.I; + final shouldReload = useState(false); + final logMessages = useFuture( + useMemoized(() => immichLogger.getMessages(), [shouldReload.value]), + ); Widget colorStatusIndicator(Color color) { return Column( @@ -71,7 +75,7 @@ class AppLogPage extends HookConsumerWidget { ), onPressed: () { immichLogger.clearLogs(); - logMessages.value = []; + shouldReload.value = !shouldReload.value; }, ), Builder( @@ -84,7 +88,7 @@ class AppLogPage extends HookConsumerWidget { size: 20.0, ), onPressed: () { - immichLogger.shareLogs(iconContext); + ImmichLogger.shareLogs(iconContext); }, ); }, @@ -105,9 +109,9 @@ class AppLogPage extends HookConsumerWidget { separatorBuilder: (context, index) { return const Divider(height: 0); }, - itemCount: logMessages.value.length, + itemCount: logMessages.data?.length ?? 0, itemBuilder: (context, index) { - var logMessage = logMessages.value[index]; + var logMessage = logMessages.data![index]; return ListTile( onTap: () => context.pushRoute( AppLogDetailRoute( @@ -128,7 +132,7 @@ class AppLogPage extends HookConsumerWidget { ), ), subtitle: Text( - "at ${DateFormat("HH:mm:ss.SSS").format(logMessage.createdAt)} in ${logMessage.context1}", + "at ${DateFormat("HH:mm:ss.SSS").format(logMessage.createdAt)} in ${logMessage.logger}", style: TextStyle( fontSize: 12.0, color: context.colorScheme.onSurfaceSecondary, diff --git a/mobile/lib/pages/common/app_log_detail.page.dart b/mobile/lib/pages/common/app_log_detail.page.dart index dd6af81728..1bfea44ba1 100644 --- a/mobile/lib/pages/common/app_log_detail.page.dart +++ b/mobile/lib/pages/common/app_log_detail.page.dart @@ -1,15 +1,15 @@ import 'package:auto_route/auto_route.dart'; import 'package:flutter/material.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:immich_mobile/extensions/build_context_extensions.dart'; -import 'package:immich_mobile/entities/logger_message.entity.dart'; import 'package:flutter/services.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/domain/models/log.model.dart'; +import 'package:immich_mobile/extensions/build_context_extensions.dart'; @RoutePage() class AppLogDetailPage extends HookConsumerWidget { const AppLogDetailPage({super.key, required this.logMessage}); - final LoggerMessage logMessage; + final LogMessage logMessage; @override Widget build(BuildContext context, WidgetRef ref) { @@ -126,14 +126,14 @@ class AppLogDetailPage extends HookConsumerWidget { child: ListView( children: [ buildTextWithCopyButton("MESSAGE", logMessage.message), - if (logMessage.details != null) - buildTextWithCopyButton("DETAILS", logMessage.details.toString()), - if (logMessage.context1 != null) - buildLogContext1(logMessage.context1.toString()), - if (logMessage.context2 != null) + if (logMessage.error != null) + buildTextWithCopyButton("DETAILS", logMessage.error.toString()), + if (logMessage.logger != null) + buildLogContext1(logMessage.logger.toString()), + if (logMessage.stack != null) buildTextWithCopyButton( "STACK TRACE", - logMessage.context2.toString(), + logMessage.stack.toString(), ), ], ), diff --git a/mobile/lib/providers/app_life_cycle.provider.dart b/mobile/lib/providers/app_life_cycle.provider.dart index 780e22b818..92c199ab76 100644 --- a/mobile/lib/providers/app_life_cycle.provider.dart +++ b/mobile/lib/providers/app_life_cycle.provider.dart @@ -1,20 +1,22 @@ +import 'dart:async'; + import 'package:flutter/foundation.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:immich_mobile/providers/album/album.provider.dart'; -import 'package:immich_mobile/services/background.service.dart'; +import 'package:immich_mobile/domain/services/log.service.dart'; import 'package:immich_mobile/models/backup/backup_state.model.dart'; +import 'package:immich_mobile/providers/album/album.provider.dart'; +import 'package:immich_mobile/providers/asset.provider.dart'; +import 'package:immich_mobile/providers/auth.provider.dart'; import 'package:immich_mobile/providers/backup/backup.provider.dart'; import 'package:immich_mobile/providers/backup/ios_background_settings.provider.dart'; import 'package:immich_mobile/providers/backup/manual_upload.provider.dart'; -import 'package:immich_mobile/providers/auth.provider.dart'; -import 'package:immich_mobile/providers/memory.provider.dart'; import 'package:immich_mobile/providers/gallery_permission.provider.dart'; +import 'package:immich_mobile/providers/memory.provider.dart'; import 'package:immich_mobile/providers/notification_permission.provider.dart'; -import 'package:immich_mobile/providers/asset.provider.dart'; import 'package:immich_mobile/providers/server_info.provider.dart'; import 'package:immich_mobile/providers/tab.provider.dart'; import 'package:immich_mobile/providers/websocket.provider.dart'; -import 'package:immich_mobile/services/immich_logger.service.dart'; +import 'package:immich_mobile/services/background.service.dart'; import 'package:permission_handler/permission_handler.dart'; enum AppLifeCycleEnum { @@ -112,7 +114,7 @@ class AppLifeCycleNotifier extends StateNotifier { _ref.read(websocketProvider.notifier).disconnect(); } - ImmichLogger().flush(); + unawaited(LogService.I.flush()); } void handleAppDetached() { diff --git a/mobile/lib/routing/router.dart b/mobile/lib/routing/router.dart index 66a65f559e..ae5419b712 100644 --- a/mobile/lib/routing/router.dart +++ b/mobile/lib/routing/router.dart @@ -1,44 +1,48 @@ import 'package:auto_route/auto_route.dart'; import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/domain/models/log.model.dart'; import 'package:immich_mobile/entities/album.entity.dart'; import 'package:immich_mobile/entities/asset.entity.dart'; -import 'package:immich_mobile/entities/logger_message.entity.dart'; import 'package:immich_mobile/entities/user.entity.dart'; import 'package:immich_mobile/models/memories/memory.model.dart'; import 'package:immich_mobile/models/search/search_filter.model.dart'; import 'package:immich_mobile/models/shared_link/shared_link.model.dart'; import 'package:immich_mobile/models/upload/share_intent_attachment.model.dart'; -import 'package:immich_mobile/pages/backup/album_preview.page.dart'; -import 'package:immich_mobile/pages/backup/backup_album_selection.page.dart'; -import 'package:immich_mobile/pages/backup/backup_controller.page.dart'; -import 'package:immich_mobile/pages/backup/backup_options.page.dart'; -import 'package:immich_mobile/pages/backup/failed_backup_status.page.dart'; -import 'package:immich_mobile/pages/albums/albums.page.dart'; -import 'package:immich_mobile/pages/common/native_video_viewer.page.dart'; -import 'package:immich_mobile/pages/library/local_albums.page.dart'; -import 'package:immich_mobile/pages/library/people/people_collection.page.dart'; -import 'package:immich_mobile/pages/library/places/places_collection.page.dart'; -import 'package:immich_mobile/pages/library/library.page.dart'; -import 'package:immich_mobile/pages/common/activities.page.dart'; import 'package:immich_mobile/pages/album/album_additional_shared_user_selection.page.dart'; import 'package:immich_mobile/pages/album/album_asset_selection.page.dart'; import 'package:immich_mobile/pages/album/album_options.page.dart'; import 'package:immich_mobile/pages/album/album_shared_user_selection.page.dart'; import 'package:immich_mobile/pages/album/album_viewer.page.dart'; +import 'package:immich_mobile/pages/albums/albums.page.dart'; +import 'package:immich_mobile/pages/backup/album_preview.page.dart'; +import 'package:immich_mobile/pages/backup/backup_album_selection.page.dart'; +import 'package:immich_mobile/pages/backup/backup_controller.page.dart'; +import 'package:immich_mobile/pages/backup/backup_options.page.dart'; +import 'package:immich_mobile/pages/backup/failed_backup_status.page.dart'; +import 'package:immich_mobile/pages/common/activities.page.dart'; import 'package:immich_mobile/pages/common/app_log.page.dart'; import 'package:immich_mobile/pages/common/app_log_detail.page.dart'; import 'package:immich_mobile/pages/common/create_album.page.dart'; import 'package:immich_mobile/pages/common/gallery_viewer.page.dart'; import 'package:immich_mobile/pages/common/headers_settings.page.dart'; +import 'package:immich_mobile/pages/common/native_video_viewer.page.dart'; import 'package:immich_mobile/pages/common/settings.page.dart'; import 'package:immich_mobile/pages/common/splash_screen.page.dart'; import 'package:immich_mobile/pages/common/tab_controller.page.dart'; -import 'package:immich_mobile/pages/editing/edit.page.dart'; import 'package:immich_mobile/pages/editing/crop.page.dart'; +import 'package:immich_mobile/pages/editing/edit.page.dart'; import 'package:immich_mobile/pages/editing/filter.page.dart'; import 'package:immich_mobile/pages/library/archive.page.dart'; import 'package:immich_mobile/pages/library/favorite.page.dart'; +import 'package:immich_mobile/pages/library/library.page.dart'; +import 'package:immich_mobile/pages/library/local_albums.page.dart'; +import 'package:immich_mobile/pages/library/partner/partner.page.dart'; +import 'package:immich_mobile/pages/library/partner/partner_detail.page.dart'; +import 'package:immich_mobile/pages/library/people/people_collection.page.dart'; +import 'package:immich_mobile/pages/library/places/places_collection.page.dart'; +import 'package:immich_mobile/pages/library/shared_link/shared_link.page.dart'; +import 'package:immich_mobile/pages/library/shared_link/shared_link_edit.page.dart'; import 'package:immich_mobile/pages/library/trash.page.dart'; import 'package:immich_mobile/pages/login/change_password.page.dart'; import 'package:immich_mobile/pages/login/login.page.dart'; @@ -54,10 +58,6 @@ import 'package:immich_mobile/pages/search/map/map_location_picker.page.dart'; import 'package:immich_mobile/pages/search/person_result.page.dart'; import 'package:immich_mobile/pages/search/recently_added.page.dart'; import 'package:immich_mobile/pages/search/search.page.dart'; -import 'package:immich_mobile/pages/library/partner/partner.page.dart'; -import 'package:immich_mobile/pages/library/partner/partner_detail.page.dart'; -import 'package:immich_mobile/pages/library/shared_link/shared_link.page.dart'; -import 'package:immich_mobile/pages/library/shared_link/shared_link_edit.page.dart'; import 'package:immich_mobile/pages/share_intent/share_intent.page.dart'; import 'package:immich_mobile/providers/api.provider.dart'; import 'package:immich_mobile/providers/gallery_permission.provider.dart'; diff --git a/mobile/lib/routing/router.gr.dart b/mobile/lib/routing/router.gr.dart index e4f1190510..299c8a602f 100644 --- a/mobile/lib/routing/router.gr.dart +++ b/mobile/lib/routing/router.gr.dart @@ -386,7 +386,7 @@ class AllVideosRoute extends PageRouteInfo { class AppLogDetailRoute extends PageRouteInfo { AppLogDetailRoute({ Key? key, - required LoggerMessage logMessage, + required LogMessage logMessage, List? children, }) : super( AppLogDetailRoute.name, @@ -419,7 +419,7 @@ class AppLogDetailRouteArgs { final Key? key; - final LoggerMessage logMessage; + final LogMessage logMessage; @override String toString() { diff --git a/mobile/lib/services/immich_logger.service.dart b/mobile/lib/services/immich_logger.service.dart index 952e8b191e..fab4b9966a 100644 --- a/mobile/lib/services/immich_logger.service.dart +++ b/mobile/lib/services/immich_logger.service.dart @@ -2,11 +2,7 @@ import 'dart:async'; import 'dart:io'; import 'package:flutter/widgets.dart'; -import 'package:immich_mobile/domain/models/store.model.dart'; -import 'package:immich_mobile/entities/logger_message.entity.dart'; -import 'package:immich_mobile/entities/store.entity.dart'; -import 'package:isar/isar.dart'; -import 'package:logging/logging.dart'; +import 'package:immich_mobile/domain/services/log.service.dart'; import 'package:path_provider/path_provider.dart'; import 'package:share_plus/share_plus.dart'; @@ -18,75 +14,10 @@ import 'package:share_plus/share_plus.dart'; /// /// Logs can be shared by calling the `shareLogs` method, which will open a share dialog /// and generate a csv file. -class ImmichLogger { - static final ImmichLogger _instance = ImmichLogger._internal(); - final maxLogEntries = 500; - final Isar _db = Isar.getInstance()!; - List _msgBuffer = []; - Timer? _timer; +abstract final class ImmichLogger { + const ImmichLogger(); - factory ImmichLogger() => _instance; - - ImmichLogger._internal() { - _removeOverflowMessages(); - final int levelId = Store.get(StoreKey.logLevel, 5); // 5 is INFO - Logger.root.level = Level.LEVELS[levelId]; - Logger.root.onRecord.listen(_writeLogToDatabase); - } - - set level(Level level) => Logger.root.level = level; - - List get messages { - final inDb = - _db.loggerMessages.where(sort: Sort.desc).anyId().findAllSync(); - return _msgBuffer.isEmpty ? inDb : _msgBuffer.reversed.toList() + inDb; - } - - void _removeOverflowMessages() { - final msgCount = _db.loggerMessages.countSync(); - if (msgCount > maxLogEntries) { - final numberOfEntryToBeDeleted = msgCount - maxLogEntries; - _db.writeTxn( - () => _db.loggerMessages - .where() - .limit(numberOfEntryToBeDeleted) - .deleteAll(), - ); - } - } - - void _writeLogToDatabase(LogRecord record) { - debugPrint('[${record.level.name}] [${record.time}] ${record.message}'); - final lm = LoggerMessage( - message: record.message, - details: record.error?.toString(), - level: record.level.toLogLevel(), - createdAt: record.time, - context1: record.loggerName, - context2: record.stackTrace?.toString(), - ); - _msgBuffer.add(lm); - - // delayed batch writing to database: increases performance when logging - // messages in quick succession and reduces NAND wear - _timer ??= Timer(const Duration(seconds: 5), _flushBufferToDatabase); - } - - void _flushBufferToDatabase() { - _timer = null; - final buffer = _msgBuffer; - _msgBuffer = []; - _db.writeTxn(() => _db.loggerMessages.putAll(buffer)); - } - - void clearLogs() { - _timer?.cancel(); - _timer = null; - _msgBuffer.clear(); - _db.writeTxn(() => _db.loggerMessages.clear()); - } - - Future shareLogs(BuildContext context) async { + static Future shareLogs(BuildContext context) async { final tempDir = await getTemporaryDirectory(); final dateTime = DateTime.now().toIso8601String(); final filePath = '${tempDir.path}/Immich_log_$dateTime.log'; @@ -94,13 +25,13 @@ class ImmichLogger { final io = logFile.openWrite(); try { // Write messages - for (final m in messages) { + for (final m in await LogService.I.getMessages()) { final created = m.createdAt; final level = m.level.name.padRight(8); - final logger = (m.context1 ?? "").padRight(20); + final logger = (m.logger ?? "").padRight(20); final message = m.message; - final error = m.details != null ? " ${m.details} |" : ""; - final stack = m.context2 != null ? "\n${m.context2!}" : ""; + final error = m.error == null ? "" : " ${m.error} |"; + final stack = m.stack == null ? "" : "\n${m.stack!}"; io.write('$created | $level | $logger | $message |$error$stack\n'); } } finally { @@ -115,16 +46,6 @@ class ImmichLogger { [XFile(filePath)], subject: "Immich logs $dateTime", sharePositionOrigin: box!.localToGlobal(Offset.zero) & box.size, - ).then( - (value) => logFile.delete(), - ); - } - - /// Flush pending log messages to persistent storage - void flush() { - if (_timer != null) { - _timer!.cancel(); - _db.writeTxnSync(() => _db.loggerMessages.putAllSync(_msgBuffer)); - } + ).then((value) => logFile.delete()); } } diff --git a/mobile/lib/utils/bootstrap.dart b/mobile/lib/utils/bootstrap.dart index 32bdac42d5..5b9a41f28d 100644 --- a/mobile/lib/utils/bootstrap.dart +++ b/mobile/lib/utils/bootstrap.dart @@ -1,6 +1,7 @@ import 'dart:io'; import 'package:flutter/foundation.dart'; +import 'package:immich_mobile/domain/services/log.service.dart'; import 'package:immich_mobile/domain/services/store.service.dart'; import 'package:immich_mobile/entities/album.entity.dart'; import 'package:immich_mobile/entities/android_device_asset.entity.dart'; @@ -10,9 +11,10 @@ import 'package:immich_mobile/entities/duplicated_asset.entity.dart'; import 'package:immich_mobile/entities/etag.entity.dart'; import 'package:immich_mobile/entities/exif_info.entity.dart'; import 'package:immich_mobile/entities/ios_device_asset.entity.dart'; -import 'package:immich_mobile/entities/logger_message.entity.dart'; import 'package:immich_mobile/entities/user.entity.dart'; +import 'package:immich_mobile/infrastructure/entities/log.entity.dart'; import 'package:immich_mobile/infrastructure/entities/store.entity.dart'; +import 'package:immich_mobile/infrastructure/repositories/log.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/store.repository.dart'; import 'package:isar/isar.dart'; import 'package:path_provider/path_provider.dart'; @@ -46,5 +48,9 @@ abstract final class Bootstrap { static Future initDomain(Isar db) async { await StoreService.init(storeRepository: IsarStoreRepository(db)); + await LogService.init( + logRepo: IsarLogRepository(db), + storeRepo: IsarStoreRepository(db), + ); } } diff --git a/mobile/lib/widgets/settings/advanced_settings.dart b/mobile/lib/widgets/settings/advanced_settings.dart index ec1ab79cf7..4e399e8aec 100644 --- a/mobile/lib/widgets/settings/advanced_settings.dart +++ b/mobile/lib/widgets/settings/advanced_settings.dart @@ -1,18 +1,19 @@ import 'dart:io'; + import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart' hide Store; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/domain/services/log.service.dart'; +import 'package:immich_mobile/providers/user.provider.dart'; +import 'package:immich_mobile/services/app_settings.service.dart'; +import 'package:immich_mobile/utils/hooks/app_settings_update_hook.dart'; +import 'package:immich_mobile/utils/http_ssl_cert_override.dart'; import 'package:immich_mobile/widgets/settings/custom_proxy_headers_settings/custome_proxy_headers_settings.dart'; import 'package:immich_mobile/widgets/settings/local_storage_settings.dart'; import 'package:immich_mobile/widgets/settings/settings_slider_list_tile.dart'; import 'package:immich_mobile/widgets/settings/settings_sub_page_scaffold.dart'; import 'package:immich_mobile/widgets/settings/settings_switch_list_tile.dart'; -import 'package:immich_mobile/utils/hooks/app_settings_update_hook.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:immich_mobile/services/app_settings.service.dart'; -import 'package:immich_mobile/providers/user.provider.dart'; -import 'package:immich_mobile/services/immich_logger.service.dart'; -import 'package:immich_mobile/utils/http_ssl_cert_override.dart'; import 'package:immich_mobile/widgets/settings/ssl_client_cert_settings.dart'; import 'package:logging/logging.dart'; @@ -33,7 +34,8 @@ class AdvancedSettings extends HookConsumerWidget { useValueChanged( levelId.value, - (_, __) => ImmichLogger().level = Level.LEVELS[levelId.value], + (_, __) => + LogService.I.setlogLevel(Level.LEVELS[levelId.value].toLogLevel()), ); final advancedSettings = [ diff --git a/mobile/pubspec.lock b/mobile/pubspec.lock index 5a15bf5f5e..08c71e36f8 100644 --- a/mobile/pubspec.lock +++ b/mobile/pubspec.lock @@ -407,7 +407,7 @@ packages: source: hosted version: "0.0.2" fake_async: - dependency: transitive + dependency: "direct dev" description: name: fake_async sha256: "511392330127add0b769b75a987850d136345d9227c6b94c96a04cf4a391bf78" diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml index 1191612363..89e7b09ca4 100644 --- a/mobile/pubspec.yaml +++ b/mobile/pubspec.yaml @@ -113,6 +113,7 @@ dev_dependencies: mocktail: ^1.0.3 immich_mobile_immich_lint: path: './immich_lint' + fake_async: ^1.3.1 flutter: uses-material-design: true diff --git a/mobile/test/domain/services/log_service_test.dart b/mobile/test/domain/services/log_service_test.dart new file mode 100644 index 0000000000..cbceb0d165 --- /dev/null +++ b/mobile/test/domain/services/log_service_test.dart @@ -0,0 +1,186 @@ +import 'package:collection/collection.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:immich_mobile/constants/constants.dart'; +import 'package:immich_mobile/domain/interfaces/log.interface.dart'; +import 'package:immich_mobile/domain/interfaces/store.interface.dart'; +import 'package:immich_mobile/domain/models/log.model.dart'; +import 'package:immich_mobile/domain/models/store.model.dart'; +import 'package:immich_mobile/domain/services/log.service.dart'; +import 'package:logging/logging.dart'; +import 'package:mocktail/mocktail.dart'; + +import '../../infrastructure/repository.mock.dart'; +import '../../test_utils.dart'; + +final _kInfoLog = LogMessage( + message: '#Info Message', + level: LogLevel.INFO, + createdAt: DateTime(2025, 2, 26), + logger: 'Info Logger', +); + +final _kWarnLog = LogMessage( + message: '#Warn Message', + level: LogLevel.WARNING, + createdAt: DateTime(2025, 2, 27), + logger: 'Warn Logger', +); + +void main() { + late LogService sut; + late ILogRepository mockLogRepo; + late IStoreRepository mockStoreRepo; + + setUp(() async { + mockLogRepo = MockLogRepository(); + mockStoreRepo = MockStoreRepository(); + + registerFallbackValue(_kInfoLog); + + when(() => mockLogRepo.truncate(limit: any(named: 'limit'))) + .thenAnswer((_) async => {}); + when(() => mockStoreRepo.tryGet(StoreKey.logLevel)) + .thenAnswer((_) async => LogLevel.FINE.index); + when(() => mockLogRepo.getAll()).thenAnswer((_) async => []); + when(() => mockLogRepo.insert(any())).thenAnswer((_) async => true); + when(() => mockLogRepo.insertAll(any())).thenAnswer((_) async => true); + + sut = + await LogService.create(logRepo: mockLogRepo, storeRepo: mockStoreRepo); + }); + + tearDown(() async { + await sut.dispose(); + }); + + group("Log Service Init:", () { + test('Truncates the existing logs on init', () { + final limit = + verify(() => mockLogRepo.truncate(limit: captureAny(named: 'limit'))) + .captured + .firstOrNull as int?; + expect(limit, kLogTruncateLimit); + }); + + test('Sets log level based on the store setting', () { + verify(() => mockStoreRepo.tryGet(StoreKey.logLevel)).called(1); + expect(Logger.root.level, Level.FINE); + }); + }); + + group("Log Service Set Level:", () { + setUp(() async { + when(() => mockStoreRepo.insert(StoreKey.logLevel, any())) + .thenAnswer((_) async => true); + await sut.setlogLevel(LogLevel.SHOUT); + }); + + test('Updates the log level in store', () { + final index = verify( + () => mockStoreRepo.insert(StoreKey.logLevel, captureAny()), + ).captured.firstOrNull; + expect(index, LogLevel.SHOUT.index); + }); + + test('Sets log level on logger', () { + expect(Logger.root.level, Level.SHOUT); + }); + }); + + group("Log Service Buffer:", () { + test('Buffers logs until timer elapses', () { + TestUtils.fakeAsync((time) async { + sut = await LogService.create( + logRepo: mockLogRepo, + storeRepo: mockStoreRepo, + shouldBuffer: true, + ); + + final logger = Logger(_kInfoLog.logger!); + logger.info(_kInfoLog.message); + expect(await sut.getMessages(), hasLength(1)); + logger.warning(_kWarnLog.message); + expect(await sut.getMessages(), hasLength(2)); + time.elapse(const Duration(seconds: 6)); + expect(await sut.getMessages(), isEmpty); + }); + }); + + test('Batch inserts all logs on timer', () { + TestUtils.fakeAsync((time) async { + sut = await LogService.create( + logRepo: mockLogRepo, + storeRepo: mockStoreRepo, + shouldBuffer: true, + ); + + final logger = Logger(_kInfoLog.logger!); + logger.info(_kInfoLog.message); + time.elapse(const Duration(seconds: 6)); + final insert = verify(() => mockLogRepo.insertAll(captureAny())); + insert.called(1); + // ignore: prefer-correct-json-casts + final captured = insert.captured.firstOrNull as List; + expect(captured.firstOrNull?.message, _kInfoLog.message); + expect(captured.firstOrNull?.logger, _kInfoLog.logger); + + verifyNever(() => mockLogRepo.insert(captureAny())); + }); + }); + + test('Does not buffer when off', () { + TestUtils.fakeAsync((time) async { + sut = await LogService.create( + logRepo: mockLogRepo, + storeRepo: mockStoreRepo, + shouldBuffer: false, + ); + + final logger = Logger(_kInfoLog.logger!); + logger.info(_kInfoLog.message); + // Ensure nothing gets buffer. This works because we mock log repo getAll to return nothing + expect(await sut.getMessages(), isEmpty); + + final insert = verify(() => mockLogRepo.insert(captureAny())); + insert.called(1); + final captured = insert.captured.firstOrNull as LogMessage; + expect(captured.message, _kInfoLog.message); + expect(captured.logger, _kInfoLog.logger); + + verifyNever(() => mockLogRepo.insertAll(captureAny())); + }); + }); + }); + + group("Log Service Get messages:", () { + setUp(() { + when(() => mockLogRepo.getAll()).thenAnswer((_) async => [_kInfoLog]); + }); + + test('Fetches result from DB', () async { + expect(await sut.getMessages(), hasLength(1)); + verify(() => mockLogRepo.getAll()).called(1); + }); + + test('Combines result from both DB + Buffer', () { + TestUtils.fakeAsync((time) async { + sut = await LogService.create( + logRepo: mockLogRepo, + storeRepo: mockStoreRepo, + shouldBuffer: true, + ); + + final logger = Logger(_kWarnLog.logger!); + logger.warning(_kWarnLog.message); + expect(await sut.getMessages(), hasLength(2)); // 1 - DB, 1 - Buff + + final messages = await sut.getMessages(); + // Logged time is assigned in the service for messages in the buffer, so compare manually + expect(messages.firstOrNull?.message, _kWarnLog.message); + expect(messages.firstOrNull?.logger, _kWarnLog.logger); + + expect(messages.elementAtOrNull(1), _kInfoLog); + }); + }); + }); +} diff --git a/mobile/test/infrastructure/repository.mock.dart b/mobile/test/infrastructure/repository.mock.dart index ff25bdac9d..3e33fdac0a 100644 --- a/mobile/test/infrastructure/repository.mock.dart +++ b/mobile/test/infrastructure/repository.mock.dart @@ -1,4 +1,7 @@ +import 'package:immich_mobile/domain/interfaces/log.interface.dart'; import 'package:immich_mobile/domain/interfaces/store.interface.dart'; import 'package:mocktail/mocktail.dart'; class MockStoreRepository extends Mock implements IStoreRepository {} + +class MockLogRepository extends Mock implements ILogRepository {} diff --git a/mobile/test/modules/shared/sync_service_test.dart b/mobile/test/modules/shared/sync_service_test.dart index 464dafc82b..e37b5ec7bc 100644 --- a/mobile/test/modules/shared/sync_service_test.dart +++ b/mobile/test/modules/shared/sync_service_test.dart @@ -1,15 +1,16 @@ import 'package:flutter/widgets.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:immich_mobile/domain/models/store.model.dart'; +import 'package:immich_mobile/domain/services/log.service.dart'; import 'package:immich_mobile/domain/services/store.service.dart'; import 'package:immich_mobile/entities/asset.entity.dart'; import 'package:immich_mobile/entities/etag.entity.dart'; import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/entities/user.entity.dart'; +import 'package:immich_mobile/infrastructure/repositories/log.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/store.repository.dart'; import 'package:immich_mobile/interfaces/asset.interface.dart'; import 'package:immich_mobile/interfaces/user.interface.dart'; -import 'package:immich_mobile/services/immich_logger.service.dart'; import 'package:immich_mobile/services/sync.service.dart'; import 'package:mocktail/mocktail.dart'; @@ -70,7 +71,10 @@ void main() { db.writeTxnSync(() => db.clearSync()); await StoreService.init(storeRepository: IsarStoreRepository(db)); await Store.put(StoreKey.currentUser, owner); - ImmichLogger(); + await LogService.init( + logRepo: IsarLogRepository(db), + storeRepo: IsarStoreRepository(db), + ); }); final List initialAssets = [ makeAsset(checksum: "a", remoteId: "0-1"), diff --git a/mobile/test/test_utils.dart b/mobile/test/test_utils.dart index 35ab1fb0aa..825d77190b 100644 --- a/mobile/test/test_utils.dart +++ b/mobile/test/test_utils.dart @@ -1,6 +1,8 @@ +import 'dart:async'; import 'dart:io'; 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/entities/album.entity.dart'; @@ -11,8 +13,8 @@ import 'package:immich_mobile/entities/duplicated_asset.entity.dart'; import 'package:immich_mobile/entities/etag.entity.dart'; import 'package:immich_mobile/entities/exif_info.entity.dart'; import 'package:immich_mobile/entities/ios_device_asset.entity.dart'; -import 'package:immich_mobile/entities/logger_message.entity.dart'; import 'package:immich_mobile/entities/user.entity.dart'; +import 'package:immich_mobile/infrastructure/entities/log.entity.dart'; import 'package:immich_mobile/infrastructure/entities/store.entity.dart'; import 'package:isar/isar.dart'; import 'package:mocktail/mocktail.dart'; @@ -88,4 +90,36 @@ abstract final class TestUtils { WidgetController.hitTestWarningShouldBeFatal = true; HttpOverrides.global = MockHttpOverrides(); } + + // Workaround till the following issue is resolved + // https://github.com/dart-lang/test/issues/2307 + static T fakeAsync( + Future Function(FakeAsync _) callback, { + DateTime? initialTime, + }) { + late final T result; + Object? error; + StackTrace? stack; + FakeAsync(initialTime: initialTime).run((FakeAsync async) { + bool shouldPump = true; + unawaited( + callback(async).then( + (value) => result = value, + onError: (e, s) { + error = e; + stack = s; + }, + ).whenComplete(() => shouldPump = false), + ); + + while (shouldPump) { + async.flushMicrotasks(); + } + }); + + if (error != null) { + Error.throwWithStackTrace(error!, stack!); + } + return result; + } } From 5c879acd5bd0b67699d65da7af1ac89d9cb6af1a Mon Sep 17 00:00:00 2001 From: Alex Date: Thu, 27 Feb 2025 17:02:00 -0600 Subject: [PATCH 242/395] fix(server): don't show assets that no longer associate with a face (#16404) --- server/src/entities/asset.entity.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/server/src/entities/asset.entity.ts b/server/src/entities/asset.entity.ts index 7345d9a2e6..c01410adc9 100644 --- a/server/src/entities/asset.entity.ts +++ b/server/src/entities/asset.entity.ts @@ -260,6 +260,7 @@ export function hasPeople(qb: SelectQueryBuilder, personIds: .selectFrom('asset_faces') .select('assetId') .where('personId', '=', anyUuid(personIds!)) + .where('deletedAt', 'is', null) .groupBy('assetId') .having((eb) => eb.fn.count('personId').distinct(), '=', personIds.length) .as('has_people'), From f2be9f7ad1132369de32e0f0e58264033bc5ac9c Mon Sep 17 00:00:00 2001 From: Calum Dingwall <29152895+caburum@users.noreply.github.com> Date: Thu, 27 Feb 2025 23:15:37 -0500 Subject: [PATCH 243/395] fix(web): person favorite icon bad placement (#16412) move favorite person icon to top left fixes #16003 Co-authored-by: Calum Dingwall --- web/src/lib/components/faces-page/people-card.svelte | 2 +- web/src/routes/(user)/explore/+page.svelte | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/web/src/lib/components/faces-page/people-card.svelte b/web/src/lib/components/faces-page/people-card.svelte index 494dd94666..4e341c5743 100644 --- a/web/src/lib/components/faces-page/people-card.svelte +++ b/web/src/lib/components/faces-page/people-card.svelte @@ -65,7 +65,7 @@ widthStyle="100%" /> {#if person.isFavorite} -
+
{/if} diff --git a/web/src/routes/(user)/explore/+page.svelte b/web/src/routes/(user)/explore/+page.svelte index 40a02f7425..ec62d5e869 100644 --- a/web/src/routes/(user)/explore/+page.svelte +++ b/web/src/routes/(user)/explore/+page.svelte @@ -64,7 +64,7 @@ widthStyle="100%" /> {#if person.isFavorite} -
+
{/if} From a185e0639987c1ba3e8ebd4435a9acae6edfbda2 Mon Sep 17 00:00:00 2001 From: Alex Date: Thu, 27 Feb 2025 23:35:48 -0600 Subject: [PATCH 244/395] fix(server): follow logs level setting (#16415) --- server/src/repositories/logging.repository.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/server/src/repositories/logging.repository.ts b/server/src/repositories/logging.repository.ts index 801f467a6d..aaf21a3d7c 100644 --- a/server/src/repositories/logging.repository.ts +++ b/server/src/repositories/logging.repository.ts @@ -33,6 +33,10 @@ export class MyConsoleLogger extends ConsoleLogger { this.isColorEnabled = options?.color || false; } + isLevelEnabled(level: LogLevel) { + return isLogLevelEnabled(level, logLevels); + } + formatContext(context: string): string { let prefix = appName || ''; if (context) { @@ -84,7 +88,7 @@ export class LoggingRepository { } isLevelEnabled(level: LogLevel) { - return isLogLevelEnabled(level, logLevels); + return this.logger.isLevelEnabled(level); } setLogLevel(level: LogLevel | false): void { @@ -124,7 +128,7 @@ export class LoggingRepository { } private handleFunction(level: LogLevel, message: LogFunction, details: LogDetails[]) { - if (this.isLevelEnabled(level)) { + if (this.logger.isLevelEnabled(level)) { this.handleMessage(level, message(), details); } } From 9a98712db7e32d48bf8b7dea72f4a052e8e8d865 Mon Sep 17 00:00:00 2001 From: shenlong <139912620+shenlong-tanwen@users.noreply.github.com> Date: Fri, 28 Feb 2025 19:08:51 +0530 Subject: [PATCH 245/395] fix(mobile): background backup failing due to store (#16418) fix: background backup failing due to store Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com> --- mobile/lib/services/background.service.dart | 1 - 1 file changed, 1 deletion(-) diff --git a/mobile/lib/services/background.service.dart b/mobile/lib/services/background.service.dart index 4e83d1f0ac..f4597831d2 100644 --- a/mobile/lib/services/background.service.dart +++ b/mobile/lib/services/background.service.dart @@ -720,7 +720,6 @@ enum IosBackgroundTask { fetch, processing } /// entry point called by Kotlin/Java code; needs to be a top-level function @pragma('vm:entry-point') void _nativeEntry() { - HttpOverrides.global = HttpSSLCertOverride(); WidgetsFlutterBinding.ensureInitialized(); DartPluginRegistrant.ensureInitialized(); BackgroundService backgroundService = BackgroundService(); From 819e56d9caff01dd0db4a55c421206b4ab4d9c03 Mon Sep 17 00:00:00 2001 From: Zack Pollard Date: Fri, 28 Feb 2025 15:22:36 +0000 Subject: [PATCH 246/395] fix: user delete sync query sort by id (#16420) --- server/src/repositories/sync.repository.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/src/repositories/sync.repository.ts b/server/src/repositories/sync.repository.ts index d1d0e9b8ee..bde4b9f10f 100644 --- a/server/src/repositories/sync.repository.ts +++ b/server/src/repositories/sync.repository.ts @@ -53,7 +53,7 @@ export class SyncRepository { .select(['id', 'userId']) .$if(!!ack, (qb) => qb.where('id', '>', ack!.updateId)) .where('deletedAt', '<', sql.raw("now() - interval '1 millisecond'")) - .orderBy(['deletedAt asc', 'userId asc']) + .orderBy(['id asc']) .stream(); } } From b3b15e9b61396fbb3fb67c426d8e2f6ab4cc151e Mon Sep 17 00:00:00 2001 From: Mert <101130780+mertalev@users.noreply.github.com> Date: Fri, 28 Feb 2025 18:23:18 +0300 Subject: [PATCH 247/395] fix(server): include deleted assets if searching offline assets (#16417) include deleted assets if searching for offline assets --- server/src/entities/asset.entity.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/src/entities/asset.entity.ts b/server/src/entities/asset.entity.ts index c01410adc9..b2589e1231 100644 --- a/server/src/entities/asset.entity.ts +++ b/server/src/entities/asset.entity.ts @@ -352,7 +352,7 @@ const joinDeduplicationPlugin = new DeduplicateJoinsPlugin(); /** TODO: This should only be used for search-related queries, not as a general purpose query builder */ export function searchAssetBuilder(kysely: Kysely, options: AssetSearchBuilderOptions) { options.isArchived ??= options.withArchived ? undefined : false; - options.withDeleted ||= !!(options.trashedAfter || options.trashedBefore); + options.withDeleted ||= !!(options.trashedAfter || options.trashedBefore || options.isOffline); return kysely .withPlugin(joinDeduplicationPlugin) .selectFrom('assets') From bfcde05b1c8911d0c9ac5bdedd8eaf8ac6fd17b3 Mon Sep 17 00:00:00 2001 From: Jonathan Jogenfors Date: Fri, 28 Feb 2025 18:45:30 +0100 Subject: [PATCH 248/395] chore(server): trash e2e cleanup (#16423) --- e2e/src/api/specs/trash.e2e-spec.ts | 31 ++++++++++++----------------- 1 file changed, 13 insertions(+), 18 deletions(-) diff --git a/e2e/src/api/specs/trash.e2e-spec.ts b/e2e/src/api/specs/trash.e2e-spec.ts index 15b915ef2a..7a1a61f946 100644 --- a/e2e/src/api/specs/trash.e2e-spec.ts +++ b/e2e/src/api/specs/trash.e2e-spec.ts @@ -1,4 +1,4 @@ -import { LoginResponseDto, getAssetInfo, getAssetStatistics, scanLibrary } from '@immich/sdk'; +import { LoginResponseDto, getAssetInfo, getAssetStatistics } from '@immich/sdk'; import { existsSync } from 'node:fs'; import { Socket } from 'socket.io-client'; import { errorDto } from 'src/responses'; @@ -6,8 +6,6 @@ import { app, asBearerAuth, testAssetDir, testAssetDirInternal, utils } from 'sr import request from 'supertest'; import { afterAll, beforeAll, describe, expect, it } from 'vitest'; -const scan = async (accessToken: string, id: string) => scanLibrary({ id }, { headers: asBearerAuth(accessToken) }); - describe('/trash', () => { let admin: LoginResponseDto; let ws: Socket; @@ -81,8 +79,7 @@ describe('/trash', () => { utils.createImageFile(`${testAssetDir}/temp/offline/offline.png`); - await scan(admin.accessToken, library.id); - await utils.waitForQueueFinish(admin.accessToken, 'library'); + await utils.scan(admin.accessToken, library.id); const { assets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id }); expect(assets.items.length).toBe(1); @@ -90,8 +87,7 @@ describe('/trash', () => { await utils.updateLibrary(admin.accessToken, library.id, { exclusionPatterns: ['**/offline/**'] }); - await scan(admin.accessToken, library.id); - await utils.waitForQueueFinish(admin.accessToken, 'library'); + await utils.scan(admin.accessToken, library.id); const assetBefore = await utils.getAssetInfo(admin.accessToken, asset.id); expect(assetBefore).toMatchObject({ isTrashed: true, isOffline: true }); @@ -116,8 +112,7 @@ describe('/trash', () => { utils.createImageFile(`${testAssetDir}/temp/offline/offline.png`); - await scan(admin.accessToken, library.id); - await utils.waitForQueueFinish(admin.accessToken, 'library'); + await utils.scan(admin.accessToken, library.id); const { assets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id }); expect(assets.items.length).toBe(1); @@ -125,8 +120,7 @@ describe('/trash', () => { await utils.updateLibrary(admin.accessToken, library.id, { exclusionPatterns: ['**/offline/**'] }); - await scan(admin.accessToken, library.id); - await utils.waitForQueueFinish(admin.accessToken, 'library'); + await utils.scan(admin.accessToken, library.id); const assetBefore = await utils.getAssetInfo(admin.accessToken, asset.id); expect(assetBefore).toMatchObject({ isTrashed: true, isOffline: true }); @@ -180,8 +174,7 @@ describe('/trash', () => { utils.createImageFile(`${testAssetDir}/temp/offline/offline.png`); - await scan(admin.accessToken, library.id); - await utils.waitForQueueFinish(admin.accessToken, 'library'); + await utils.scan(admin.accessToken, library.id); const { assets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id }); expect(assets.count).toBe(1); @@ -189,9 +182,7 @@ describe('/trash', () => { await utils.updateLibrary(admin.accessToken, library.id, { exclusionPatterns: ['**/offline/**'] }); - await scan(admin.accessToken, library.id); - - await utils.waitForQueueFinish(admin.accessToken, 'library'); + await utils.scan(admin.accessToken, library.id); const before = await getAssetInfo({ id: assetId }, { headers: asBearerAuth(admin.accessToken) }); expect(before).toStrictEqual(expect.objectContaining({ id: assetId, isOffline: true })); @@ -201,6 +192,8 @@ describe('/trash', () => { const after = await getAssetInfo({ id: assetId }, { headers: asBearerAuth(admin.accessToken) }); expect(after).toStrictEqual(expect.objectContaining({ id: assetId, isOffline: true })); + + utils.removeImageFile(`${testAssetDir}/temp/offline/offline.png`); }); }); @@ -238,7 +231,7 @@ describe('/trash', () => { utils.createImageFile(`${testAssetDir}/temp/offline/offline.png`); - await scan(admin.accessToken, library.id); + await utils.scan(admin.accessToken, library.id); await utils.waitForQueueFinish(admin.accessToken, 'library'); const { assets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id }); @@ -247,7 +240,7 @@ describe('/trash', () => { await utils.updateLibrary(admin.accessToken, library.id, { exclusionPatterns: ['**/offline/**'] }); - await scan(admin.accessToken, library.id); + await utils.scan(admin.accessToken, library.id); await utils.waitForQueueFinish(admin.accessToken, 'library'); const before = await utils.getAssetInfo(admin.accessToken, assetId); @@ -261,6 +254,8 @@ describe('/trash', () => { const after = await utils.getAssetInfo(admin.accessToken, assetId); expect(after.isTrashed).toBe(true); + + utils.removeImageFile(`${testAssetDir}/temp/offline/offline.png`); }); }); }); From 84cf0d1670a25f2b0f04f1a1571a1ff30b9f059c Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Fri, 28 Feb 2025 12:49:29 -0500 Subject: [PATCH 249/395] fix: duplicate memories (#16432) --- server/src/services/memory.service.ts | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/server/src/services/memory.service.ts b/server/src/services/memory.service.ts index 10b8cee2fe..be4d6dfc76 100644 --- a/server/src/services/memory.service.ts +++ b/server/src/services/memory.service.ts @@ -30,20 +30,18 @@ export class MemoryService extends BaseService { const start = DateTime.utc().startOf('day').minus({ days: DAYS }); const state = await this.systemMetadataRepository.get(SystemMetadataKey.MEMORIES_STATE); - let lastOnThisDayDate = state?.lastOnThisDayDate ? DateTime.fromISO(state?.lastOnThisDayDate) : start; + const lastOnThisDayDate = state?.lastOnThisDayDate ? DateTime.fromISO(state.lastOnThisDayDate) : start; // generate a memory +/- X days from today - for (let i = 0; i <= DAYS * 2 + 1; i++) { + for (let i = 0; i <= DAYS * 2; i++) { const target = start.plus({ days: i }); - if (lastOnThisDayDate > target) { + if (lastOnThisDayDate >= target) { continue; } const showAt = target.startOf('day').toISO(); const hideAt = target.endOf('day').toISO(); - this.logger.log(`Creating memories for month=${target.month}, day=${target.day}`); - for (const [userId, userIds] of Object.entries(userMap)) { const memories = await this.assetRepository.getByDayOfYear(userIds, target); @@ -67,8 +65,6 @@ export class MemoryService extends BaseService { ...state, lastOnThisDayDate: target.toISO(), }); - - lastOnThisDayDate = target; } } From 5c0538e52ca15a28d6cba3a34c3618fe3c39724c Mon Sep 17 00:00:00 2001 From: Desmond Cox Date: Fri, 28 Feb 2025 18:50:00 +0100 Subject: [PATCH 250/395] fix(server): stringify error log parameter to ensure correct overload (#16422) * fix(server): stringify error log parameter to ensure correct overload The intended error(message, stack, context) overload is only selected if context is a string. * formatter --- server/src/services/job.service.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/server/src/services/job.service.ts b/server/src/services/job.service.ts index 95ff1ad303..22408c33de 100644 --- a/server/src/services/job.service.ts +++ b/server/src/services/job.service.ts @@ -195,7 +195,11 @@ export class JobService extends BaseService { await this.onDone(job); } } catch (error: Error | any) { - this.logger.error(`Unable to run job handler (${queueName}/${job.name}): ${error}`, error?.stack, job.data); + this.logger.error( + `Unable to run job handler (${queueName}/${job.name}): ${error}`, + error?.stack, + JSON.stringify(job.data), + ); } finally { this.telemetryRepository.jobs.addToGauge(queueMetric, -1); } From e684062569c9bd0f5ec0e447d3daf698705f2fe9 Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Fri, 28 Feb 2025 13:51:28 -0500 Subject: [PATCH 251/395] fix: memories off by one (#16434) --- server/src/queries/asset.repository.sql | 7 ++++--- server/src/repositories/asset.repository.ts | 10 +++------- server/src/services/asset.service.spec.ts | 8 ++++---- server/src/services/asset.service.ts | 15 +++++++++------ server/src/services/memory.service.ts | 8 ++++---- 5 files changed, 24 insertions(+), 24 deletions(-) diff --git a/server/src/queries/asset.repository.sql b/server/src/queries/asset.repository.sql index ce53bd1791..c0b778bb50 100644 --- a/server/src/queries/asset.repository.sql +++ b/server/src/queries/asset.repository.sql @@ -55,9 +55,10 @@ with inner join "exif" on "a"."id" = "exif"."assetId" ) select - ( - (now() at time zone 'UTC')::date - ("localDateTime" at time zone 'UTC')::date - ) / 365 as "yearsAgo", + date_part( + 'year', + ("localDateTime" at time zone 'UTC')::date + )::int as "year", json_agg("res") as "assets" from "res" diff --git a/server/src/repositories/asset.repository.ts b/server/src/repositories/asset.repository.ts index daefacef09..91597ed720 100644 --- a/server/src/repositories/asset.repository.ts +++ b/server/src/repositories/asset.repository.ts @@ -192,7 +192,7 @@ export class AssetRepository { } @GenerateSql({ params: [DummyValue.UUID, { day: 1, month: 1 }] }) - getByDayOfYear(ownerIds: string[], { day, month }: MonthDay): Promise { + getByDayOfYear(ownerIds: string[], { day, month }: MonthDay) { return this.db .with('res', (qb) => qb @@ -239,16 +239,12 @@ export class AssetRepository { .select((eb) => eb.fn.toJson(eb.table('exif')).as('exifInfo')), ) .selectFrom('res') - .select( - sql`((now() at time zone 'UTC')::date - ("localDateTime" at time zone 'UTC')::date) / 365`.as( - 'yearsAgo', - ), - ) + .select(sql`date_part('year', ("localDateTime" at time zone 'UTC')::date)::int`.as('year')) .select((eb) => eb.fn.jsonAgg(eb.table('res')).as('assets')) .groupBy(sql`("localDateTime" at time zone 'UTC')::date`) .orderBy(sql`("localDateTime" at time zone 'UTC')::date`, 'desc') .limit(10) - .execute() as any as Promise; + .execute(); } @GenerateSql({ params: [[DummyValue.UUID]] }) diff --git a/server/src/services/asset.service.spec.ts b/server/src/services/asset.service.spec.ts index 336c3ac8f0..f91f600bb1 100755 --- a/server/src/services/asset.service.spec.ts +++ b/server/src/services/asset.service.spec.ts @@ -64,18 +64,18 @@ describe(AssetService.name, () => { mocks.partner.getAll.mockResolvedValue([]); mocks.asset.getByDayOfYear.mockResolvedValue([ { - yearsAgo: 1, + year: 2023, assets: [image1, image2], }, { - yearsAgo: 9, + year: 2015, assets: [image3], }, { - yearsAgo: 15, + year: 2009, assets: [image4], }, - ]); + ] as any); await expect(sut.getMemoryLane(authStub.admin, { day: 15, month: 1 })).resolves.toEqual([ { yearsAgo: 1, title: '1 year ago', assets: [mapAsset(image1), mapAsset(image2)] }, diff --git a/server/src/services/asset.service.ts b/server/src/services/asset.service.ts index a9b723c9f9..df66d405b7 100644 --- a/server/src/services/asset.service.ts +++ b/server/src/services/asset.service.ts @@ -38,12 +38,15 @@ export class AssetService extends BaseService { const userIds = [auth.user.id, ...partnerIds]; const groups = await this.assetRepository.getByDayOfYear(userIds, dto); - return groups.map(({ yearsAgo, assets }) => ({ - yearsAgo, - // TODO move this to clients - title: `${yearsAgo} year${yearsAgo > 1 ? 's' : ''} ago`, - assets: assets.map((asset) => mapAsset(asset, { auth })), - })); + return groups.map(({ year, assets }) => { + const yearsAgo = DateTime.utc().year - year; + return { + yearsAgo, + // TODO move this to clients + title: `${yearsAgo} year${yearsAgo > 1 ? 's' : ''} ago`, + assets: assets.map((asset) => mapAsset(asset as AssetEntity, { auth })), + }; + }); } async getStatistics(auth: AuthDto, dto: AssetStatsDto) { diff --git a/server/src/services/memory.service.ts b/server/src/services/memory.service.ts index be4d6dfc76..8a46b289c3 100644 --- a/server/src/services/memory.service.ts +++ b/server/src/services/memory.service.ts @@ -45,18 +45,18 @@ export class MemoryService extends BaseService { for (const [userId, userIds] of Object.entries(userMap)) { const memories = await this.assetRepository.getByDayOfYear(userIds, target); - for (const memory of memories) { - const data: OnThisDayData = { year: target.year - memory.yearsAgo }; + for (const { year, assets } of memories) { + const data: OnThisDayData = { year }; await this.memoryRepository.create( { ownerId: userId, type: MemoryType.ON_THIS_DAY, data, - memoryAt: target.minus({ years: memory.yearsAgo }).toISO(), + memoryAt: target.set({ year }).toISO(), showAt, hideAt, }, - new Set(memory.assets.map(({ id }) => id)), + new Set(assets.map(({ id }) => id)), ); } } From dc143046e3633455413424ab3d343d279183f23e Mon Sep 17 00:00:00 2001 From: github-actions <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 28 Feb 2025 18:54:08 +0000 Subject: [PATCH 252/395] chore: version v1.128.0 --- cli/package-lock.json | 6 +++--- cli/package.json | 2 +- docs/static/archived-versions.json | 4 ++++ e2e/package-lock.json | 8 ++++---- e2e/package.json | 2 +- machine-learning/pyproject.toml | 2 +- mobile/android/fastlane/Fastfile | 4 ++-- mobile/ios/fastlane/Fastfile | 2 +- mobile/openapi/README.md | 2 +- mobile/pubspec.yaml | 2 +- open-api/immich-openapi-specs.json | 2 +- open-api/typescript-sdk/package-lock.json | 4 ++-- open-api/typescript-sdk/package.json | 2 +- open-api/typescript-sdk/src/fetch-client.ts | 2 +- server/package-lock.json | 4 ++-- server/package.json | 2 +- web/package-lock.json | 6 +++--- web/package.json | 2 +- 18 files changed, 31 insertions(+), 27 deletions(-) diff --git a/cli/package-lock.json b/cli/package-lock.json index e84738e519..24d159ef7b 100644 --- a/cli/package-lock.json +++ b/cli/package-lock.json @@ -1,12 +1,12 @@ { "name": "@immich/cli", - "version": "2.2.51", + "version": "2.2.52", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@immich/cli", - "version": "2.2.51", + "version": "2.2.52", "license": "GNU Affero General Public License version 3", "dependencies": { "fast-glob": "^3.3.2", @@ -52,7 +52,7 @@ }, "../open-api/typescript-sdk": { "name": "@immich/sdk", - "version": "1.127.0", + "version": "1.128.0", "dev": true, "license": "GNU Affero General Public License version 3", "dependencies": { diff --git a/cli/package.json b/cli/package.json index f902ff0089..d684ab3b41 100644 --- a/cli/package.json +++ b/cli/package.json @@ -1,6 +1,6 @@ { "name": "@immich/cli", - "version": "2.2.51", + "version": "2.2.52", "description": "Command Line Interface (CLI) for Immich", "type": "module", "exports": "./dist/index.js", diff --git a/docs/static/archived-versions.json b/docs/static/archived-versions.json index 4a3a1a6b60..13ab48d766 100644 --- a/docs/static/archived-versions.json +++ b/docs/static/archived-versions.json @@ -1,4 +1,8 @@ [ + { + "label": "v1.128.0", + "url": "https://v1.128.0.archive.immich.app" + }, { "label": "v1.127.0", "url": "https://v1.127.0.archive.immich.app" diff --git a/e2e/package-lock.json b/e2e/package-lock.json index df4e01b4d9..c8526f5ba0 100644 --- a/e2e/package-lock.json +++ b/e2e/package-lock.json @@ -1,12 +1,12 @@ { "name": "immich-e2e", - "version": "1.127.0", + "version": "1.128.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "immich-e2e", - "version": "1.127.0", + "version": "1.128.0", "license": "GNU Affero General Public License version 3", "devDependencies": { "@eslint/eslintrc": "^3.1.0", @@ -45,7 +45,7 @@ }, "../cli": { "name": "@immich/cli", - "version": "2.2.51", + "version": "2.2.52", "dev": true, "license": "GNU Affero General Public License version 3", "dependencies": { @@ -92,7 +92,7 @@ }, "../open-api/typescript-sdk": { "name": "@immich/sdk", - "version": "1.127.0", + "version": "1.128.0", "dev": true, "license": "GNU Affero General Public License version 3", "dependencies": { diff --git a/e2e/package.json b/e2e/package.json index b1f4b79137..775247f19c 100644 --- a/e2e/package.json +++ b/e2e/package.json @@ -1,6 +1,6 @@ { "name": "immich-e2e", - "version": "1.127.0", + "version": "1.128.0", "description": "", "main": "index.js", "type": "module", diff --git a/machine-learning/pyproject.toml b/machine-learning/pyproject.toml index 032ced81b1..71174e2158 100644 --- a/machine-learning/pyproject.toml +++ b/machine-learning/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "machine-learning" -version = "1.127.0" +version = "1.128.0" description = "" authors = ["Hau Tran "] readme = "README.md" diff --git a/mobile/android/fastlane/Fastfile b/mobile/android/fastlane/Fastfile index ecda716fe2..74ee2d415b 100644 --- a/mobile/android/fastlane/Fastfile +++ b/mobile/android/fastlane/Fastfile @@ -35,8 +35,8 @@ platform :android do task: 'bundle', build_type: 'Release', properties: { - "android.injected.version.code" => 185, - "android.injected.version.name" => "1.127.0", + "android.injected.version.code" => 186, + "android.injected.version.name" => "1.128.0", } ) upload_to_play_store(skip_upload_apk: true, skip_upload_images: true, skip_upload_screenshots: true, aab: '../build/app/outputs/bundle/release/app-release.aab') diff --git a/mobile/ios/fastlane/Fastfile b/mobile/ios/fastlane/Fastfile index 43dc346284..fa3cac0d22 100644 --- a/mobile/ios/fastlane/Fastfile +++ b/mobile/ios/fastlane/Fastfile @@ -19,7 +19,7 @@ platform :ios do desc "iOS Release" lane :release do increment_version_number( - version_number: "1.127.0" + version_number: "1.128.0" ) increment_build_number( build_number: latest_testflight_build_number + 1, diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index 6e11640f4f..66c264cd76 100644 --- a/mobile/openapi/README.md +++ b/mobile/openapi/README.md @@ -3,7 +3,7 @@ Immich API This Dart package is automatically generated by the [OpenAPI Generator](https://openapi-generator.tech) project: -- API version: 1.127.0 +- API version: 1.128.0 - Generator version: 7.8.0 - Build package: org.openapitools.codegen.languages.DartClientCodegen diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml index 89e7b09ca4..fe6defe362 100644 --- a/mobile/pubspec.yaml +++ b/mobile/pubspec.yaml @@ -2,7 +2,7 @@ name: immich_mobile description: Immich - selfhosted backup media file on mobile phone publish_to: 'none' -version: 1.127.0+185 +version: 1.128.0+186 environment: sdk: '>=3.3.0 <4.0.0' diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index 5730e41578..2728fb9c91 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -7655,7 +7655,7 @@ "info": { "title": "Immich", "description": "Immich API", - "version": "1.127.0", + "version": "1.128.0", "contact": {} }, "tags": [], diff --git a/open-api/typescript-sdk/package-lock.json b/open-api/typescript-sdk/package-lock.json index 0db9c31fff..6c343e33e2 100644 --- a/open-api/typescript-sdk/package-lock.json +++ b/open-api/typescript-sdk/package-lock.json @@ -1,12 +1,12 @@ { "name": "@immich/sdk", - "version": "1.127.0", + "version": "1.128.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@immich/sdk", - "version": "1.127.0", + "version": "1.128.0", "license": "GNU Affero General Public License version 3", "dependencies": { "@oazapfts/runtime": "^1.0.2" diff --git a/open-api/typescript-sdk/package.json b/open-api/typescript-sdk/package.json index c76165fae9..2345652519 100644 --- a/open-api/typescript-sdk/package.json +++ b/open-api/typescript-sdk/package.json @@ -1,6 +1,6 @@ { "name": "@immich/sdk", - "version": "1.127.0", + "version": "1.128.0", "description": "Auto-generated TypeScript SDK for the Immich API", "type": "module", "main": "./build/index.js", diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index b2895f6f1d..7e6164099b 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -1,6 +1,6 @@ /** * Immich - * 1.127.0 + * 1.128.0 * DO NOT MODIFY - This file has been generated using oazapfts. * See https://www.npmjs.com/package/oazapfts */ diff --git a/server/package-lock.json b/server/package-lock.json index 50ef7943f3..4be6f957ff 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -1,12 +1,12 @@ { "name": "immich", - "version": "1.127.0", + "version": "1.128.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "immich", - "version": "1.127.0", + "version": "1.128.0", "license": "GNU Affero General Public License version 3", "dependencies": { "@nestjs/bullmq": "^11.0.1", diff --git a/server/package.json b/server/package.json index 9f13976d10..651a04eb0f 100644 --- a/server/package.json +++ b/server/package.json @@ -1,6 +1,6 @@ { "name": "immich", - "version": "1.127.0", + "version": "1.128.0", "description": "", "author": "", "private": true, diff --git a/web/package-lock.json b/web/package-lock.json index cb84fb2cea..054a39ec51 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -1,12 +1,12 @@ { "name": "immich-web", - "version": "1.127.0", + "version": "1.128.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "immich-web", - "version": "1.127.0", + "version": "1.128.0", "license": "GNU Affero General Public License version 3", "dependencies": { "@formatjs/icu-messageformat-parser": "^2.9.8", @@ -78,7 +78,7 @@ }, "../open-api/typescript-sdk": { "name": "@immich/sdk", - "version": "1.127.0", + "version": "1.128.0", "license": "GNU Affero General Public License version 3", "dependencies": { "@oazapfts/runtime": "^1.0.2" diff --git a/web/package.json b/web/package.json index a5774bbbe7..8180d08e9a 100644 --- a/web/package.json +++ b/web/package.json @@ -1,6 +1,6 @@ { "name": "immich-web", - "version": "1.127.0", + "version": "1.128.0", "license": "GNU Affero General Public License version 3", "scripts": { "dev": "vite dev --host 0.0.0.0 --port 3000", From efcf773ea061789447781e62f5356787a91fe0e1 Mon Sep 17 00:00:00 2001 From: Matthew Momjian <50788000+mmomjian@users.noreply.github.com> Date: Fri, 28 Feb 2025 16:04:34 -0500 Subject: [PATCH 253/395] feat(server): Shortened asset ID in storage template (#16433) * Update storage-template.service.ts * Update supported-variables-panel.svelte * docs example * Update storage-template-settings.svelte --- docs/docs/guides/database-queries.md | 4 ++++ server/src/services/storage-template.service.ts | 1 + .../storage-template/storage-template-settings.svelte | 1 + .../storage-template/supported-variables-panel.svelte | 1 + 4 files changed, 7 insertions(+) diff --git a/docs/docs/guides/database-queries.md b/docs/docs/guides/database-queries.md index 2017689984..89a4f07bc0 100644 --- a/docs/docs/guides/database-queries.md +++ b/docs/docs/guides/database-queries.md @@ -31,6 +31,10 @@ SELECT * FROM "assets" WHERE "originalPath" LIKE 'upload/library/admin/2023/%'; SELECT * FROM "assets" WHERE "id" = '9f94e60f-65b6-47b7-ae44-a4df7b57f0e9'; ``` +```sql title="Find by partial ID" +SELECT * FROM "assets" WHERE "id"::text LIKE '%ab431d3a%'; +``` + :::note You can calculate the checksum for a particular file by using the command `sha1sum `. ::: diff --git a/server/src/services/storage-template.service.ts b/server/src/services/storage-template.service.ts index 24a9fcd459..6a1548ff20 100644 --- a/server/src/services/storage-template.service.ts +++ b/server/src/services/storage-template.service.ts @@ -304,6 +304,7 @@ export class StorageTemplateService extends BaseService { filetype: asset.type == AssetType.IMAGE ? 'IMG' : 'VID', filetypefull: asset.type == AssetType.IMAGE ? 'IMAGE' : 'VIDEO', assetId: asset.id, + assetIdShort: asset.id.slice(-12), //just throw into the root if it doesn't belong to an album album: (albumName && sanitize(albumName.replaceAll(/\.+/g, ''))) || '', }; diff --git a/web/src/lib/components/admin-page/settings/storage-template/storage-template-settings.svelte b/web/src/lib/components/admin-page/settings/storage-template/storage-template-settings.svelte index 8fca558976..2a40156eac 100644 --- a/web/src/lib/components/admin-page/settings/storage-template/storage-template-settings.svelte +++ b/web/src/lib/components/admin-page/settings/storage-template/storage-template-settings.svelte @@ -73,6 +73,7 @@ filetype: 'IMG', filetypefull: 'IMAGE', assetId: 'a8312960-e277-447d-b4ea-56717ccba856', + assetIdShort: '56717ccba856', album: $t('album_name'), }; diff --git a/web/src/lib/components/admin-page/settings/storage-template/supported-variables-panel.svelte b/web/src/lib/components/admin-page/settings/storage-template/supported-variables-panel.svelte index 515f2e48f0..fc8f913281 100644 --- a/web/src/lib/components/admin-page/settings/storage-template/supported-variables-panel.svelte +++ b/web/src/lib/components/admin-page/settings/storage-template/supported-variables-panel.svelte @@ -27,6 +27,7 @@

{$t('other').toUpperCase()}

  • {`{{assetId}}`} - Asset ID
  • +
  • {`{{assetIdShort}}`} - Asset ID (last 12 characters)
  • {`{{album}}`} - Album Name
From f11080cc2d72c832c3816e90219649d16c7577ee Mon Sep 17 00:00:00 2001 From: Alex Date: Fri, 28 Feb 2025 21:09:09 -0600 Subject: [PATCH 254/395] chore(mobile): post release task (#16437) --- mobile/ios/Runner.xcodeproj/project.pbxproj | 12 ++++++------ mobile/ios/Runner/Info.plist | 4 ++-- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/mobile/ios/Runner.xcodeproj/project.pbxproj b/mobile/ios/Runner.xcodeproj/project.pbxproj index 90caae97fe..1bf67ac5f9 100644 --- a/mobile/ios/Runner.xcodeproj/project.pbxproj +++ b/mobile/ios/Runner.xcodeproj/project.pbxproj @@ -541,7 +541,7 @@ CODE_SIGN_ENTITLEMENTS = Runner/RunnerProfile.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 195; + CURRENT_PROJECT_VERSION = 196; CUSTOM_GROUP_ID = group.app.immich.share; DEVELOPMENT_TEAM = 2F67MQ8R79; ENABLE_BITCODE = NO; @@ -685,7 +685,7 @@ CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 195; + CURRENT_PROJECT_VERSION = 196; CUSTOM_GROUP_ID = group.app.immich.share; DEVELOPMENT_TEAM = 2F67MQ8R79; ENABLE_BITCODE = NO; @@ -715,7 +715,7 @@ CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 195; + CURRENT_PROJECT_VERSION = 196; CUSTOM_GROUP_ID = group.app.immich.share; DEVELOPMENT_TEAM = 2F67MQ8R79; ENABLE_BITCODE = NO; @@ -748,7 +748,7 @@ CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 195; + CURRENT_PROJECT_VERSION = 196; CUSTOM_GROUP_ID = group.app.immich.share; DEVELOPMENT_TEAM = 2F67MQ8R79; ENABLE_USER_SCRIPT_SANDBOXING = YES; @@ -791,7 +791,7 @@ CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 195; + CURRENT_PROJECT_VERSION = 196; CUSTOM_GROUP_ID = group.app.immich.share; DEVELOPMENT_TEAM = 2F67MQ8R79; ENABLE_USER_SCRIPT_SANDBOXING = YES; @@ -831,7 +831,7 @@ CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 195; + CURRENT_PROJECT_VERSION = 196; CUSTOM_GROUP_ID = group.app.immich.share; DEVELOPMENT_TEAM = 2F67MQ8R79; ENABLE_USER_SCRIPT_SANDBOXING = YES; diff --git a/mobile/ios/Runner/Info.plist b/mobile/ios/Runner/Info.plist index c08b4082cb..035b0ff642 100644 --- a/mobile/ios/Runner/Info.plist +++ b/mobile/ios/Runner/Info.plist @@ -78,7 +78,7 @@ CFBundlePackageType APPL CFBundleShortVersionString - 1.127.0 + 1.128.0 CFBundleSignature ???? CFBundleURLTypes @@ -93,7 +93,7 @@ CFBundleVersion - 195 + 196 FLTEnableImpeller ITSAppUsesNonExemptEncryption From 0cb3dc62119cb30c912f134f202fa0029368be27 Mon Sep 17 00:00:00 2001 From: bo0tzz Date: Sat, 1 Mar 2025 21:05:36 +0100 Subject: [PATCH 255/395] chore: add 'not duplicate' checkbox to issue template (#16462) --- .github/ISSUE_TEMPLATE/bug_report.yaml | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/.github/ISSUE_TEMPLATE/bug_report.yaml b/.github/ISSUE_TEMPLATE/bug_report.yaml index 346c6e60f2..86bef294fb 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yaml +++ b/.github/ISSUE_TEMPLATE/bug_report.yaml @@ -1,6 +1,13 @@ name: Report an issue with Immich description: Report an issue with Immich body: + - type: checkboxes + attributes: + label: I have searched the existing issues to make sure this is not a duplicate report. + options: + - label: "Yes" + required: true + - type: markdown attributes: value: | From c8eef5ad4d12e58d7a991c52da8fd12be206d0d2 Mon Sep 17 00:00:00 2001 From: luzpaz Date: Sat, 1 Mar 2025 15:06:47 -0500 Subject: [PATCH 256/395] fix(mobile): fix typos (#16456) Found via codespell --- .../ios/Runner/BackgroundSync/BackgroundServicePlugin.swift | 6 +++--- mobile/lib/mixins/error_logger.mixin.dart | 2 +- mobile/lib/providers/asset_viewer/download.provider.dart | 2 +- .../asset_viewer/share_intent_upload.provider.dart | 2 +- mobile/lib/providers/auth.provider.dart | 2 +- mobile/lib/providers/gallery_permission.provider.dart | 2 +- mobile/lib/services/auth.service.dart | 4 ++-- mobile/lib/services/background.service.dart | 2 +- mobile/lib/services/sync.service.dart | 2 +- mobile/lib/utils/cache/custom_image_cache.dart | 2 +- .../lib/widgets/photo_view/src/utils/photo_view_utils.dart | 2 +- 11 files changed, 14 insertions(+), 14 deletions(-) diff --git a/mobile/ios/Runner/BackgroundSync/BackgroundServicePlugin.swift b/mobile/ios/Runner/BackgroundSync/BackgroundServicePlugin.swift index c84b037daf..cac9faab01 100644 --- a/mobile/ios/Runner/BackgroundSync/BackgroundServicePlugin.swift +++ b/mobile/ios/Runner/BackgroundSync/BackgroundServicePlugin.swift @@ -160,7 +160,7 @@ class BackgroundServicePlugin: NSObject, FlutterPlugin { } } - // Called by the flutter code when enabled so that we can turn on the backround services + // Called by the flutter code when enabled so that we can turn on the background services // and save the callback information to communicate on this method channel public func handleBackgroundEnable(call: FlutterMethodCall, result: FlutterResult) { @@ -249,7 +249,7 @@ class BackgroundServicePlugin: NSObject, FlutterPlugin { result(true) } - // Returns the number of currently scheduled background processes to Flutter, striclty + // Returns the number of currently scheduled background processes to Flutter, strictly // for debugging func handleNumberOfProcesses(call: FlutterMethodCall, result: @escaping FlutterResult) { BGTaskScheduler.shared.getPendingTaskRequests { requests in @@ -355,7 +355,7 @@ class BackgroundServicePlugin: NSObject, FlutterPlugin { let isExpensive = wifiMonitor.currentPath.isExpensive if (isExpensive) { // The network is expensive and we have required Wi-Fi - // Therfore, we will simply complete the task without + // Therefore, we will simply complete the task without // running it task.setTaskCompleted(success: true) return diff --git a/mobile/lib/mixins/error_logger.mixin.dart b/mobile/lib/mixins/error_logger.mixin.dart index 9b2bc6f98e..466028c338 100644 --- a/mobile/lib/mixins/error_logger.mixin.dart +++ b/mobile/lib/mixins/error_logger.mixin.dart @@ -7,7 +7,7 @@ mixin ErrorLoggerMixin { abstract final Logger logger; /// Returns an AsyncValue if the future is successfully executed - /// Else, logs the error to the overrided logger and returns an AsyncError<> + /// Else, logs the error to the overridden logger and returns an AsyncError<> AsyncFuture guardError( Future Function() fn, { required String errorMessage, diff --git a/mobile/lib/providers/asset_viewer/download.provider.dart b/mobile/lib/providers/asset_viewer/download.provider.dart index 68b120c38a..d699c7c763 100644 --- a/mobile/lib/providers/asset_viewer/download.provider.dart +++ b/mobile/lib/providers/asset_viewer/download.provider.dart @@ -104,7 +104,7 @@ class DownloadStateNotifier extends StateNotifier { } void _taskProgressCallback(TaskProgressUpdate update) { - // Ignore if the task is cancled or completed + // Ignore if the task is canceled or completed if (update.progress == -2 || update.progress == -1) { return; } diff --git a/mobile/lib/providers/asset_viewer/share_intent_upload.provider.dart b/mobile/lib/providers/asset_viewer/share_intent_upload.provider.dart index a5a42ec796..ed2c485b13 100644 --- a/mobile/lib/providers/asset_viewer/share_intent_upload.provider.dart +++ b/mobile/lib/providers/asset_viewer/share_intent_upload.provider.dart @@ -117,7 +117,7 @@ class ShareIntentUploadStateNotifier } void _taskProgressCallback(TaskProgressUpdate update) { - // Ignore if the task is cancled or completed + // Ignore if the task is canceled or completed if (update.progress == downloadFailed || update.progress == downloadCompleted) { return; diff --git a/mobile/lib/providers/auth.provider.dart b/mobile/lib/providers/auth.provider.dart index e2b15753a9..e2939e89ce 100644 --- a/mobile/lib/providers/auth.provider.dart +++ b/mobile/lib/providers/auth.provider.dart @@ -47,7 +47,7 @@ class AuthNotifier extends StateNotifier { } /// Validating the url is the alternative connecting server url without - /// saving the infomation to the local database + /// saving the information to the local database Future validateAuxilaryServerUrl(String url) async { try { final validEndpoint = await _apiService.resolveEndpoint(url); diff --git a/mobile/lib/providers/gallery_permission.provider.dart b/mobile/lib/providers/gallery_permission.provider.dart index 8077ca99fe..07d9cca591 100644 --- a/mobile/lib/providers/gallery_permission.provider.dart +++ b/mobile/lib/providers/gallery_permission.provider.dart @@ -6,7 +6,7 @@ import 'package:permission_handler/permission_handler.dart'; class GalleryPermissionNotifier extends StateNotifier { GalleryPermissionNotifier() - : super(PermissionStatus.denied) // Denied is the intitial state + : super(PermissionStatus.denied) // Denied is the initial state { // Sets the initial state getGalleryPermissionStatus(); diff --git a/mobile/lib/services/auth.service.dart b/mobile/lib/services/auth.service.dart index be6c64bc43..20fa62dc4b 100644 --- a/mobile/lib/services/auth.service.dart +++ b/mobile/lib/services/auth.service.dart @@ -75,7 +75,7 @@ class AuthService { isValid = true; } } catch (error) { - _log.severe("Error validating auxilary endpoint", error); + _log.severe("Error validating auxiliary endpoint", error); } finally { httpclient.close(); } @@ -187,7 +187,7 @@ class AuthService { _log.severe("Cannot resolve endpoint", error); continue; } catch (_) { - _log.severe("Auxilary server is not valid"); + _log.severe("Auxiliary server is not valid"); continue; } } diff --git a/mobile/lib/services/background.service.dart b/mobile/lib/services/background.service.dart index f4597831d2..e457102d9f 100644 --- a/mobile/lib/services/background.service.dart +++ b/mobile/lib/services/background.service.dart @@ -329,7 +329,7 @@ class BackgroundService { try { _clearErrorNotifications(); - // iOS should time out after some threshhold so it doesn't wait + // iOS should time out after some threshold so it doesn't wait // indefinitely and can run later // Android is fine to wait here until the lock releases final waitForLock = Platform.isIOS diff --git a/mobile/lib/services/sync.service.dart b/mobile/lib/services/sync.service.dart index ddca266006..34df461866 100644 --- a/mobile/lib/services/sync.service.dart +++ b/mobile/lib/services/sync.service.dart @@ -639,7 +639,7 @@ class SyncService { } /// fast path for common case: only new assets were added to device album - /// returns `true` if successfull, else `false` + /// returns `true` if successful, else `false` Future _syncDeviceAlbumFast(Album deviceAlbum, Album dbAlbum) async { if (!deviceAlbum.modifiedAt.isAfter(dbAlbum.modifiedAt)) { return false; diff --git a/mobile/lib/utils/cache/custom_image_cache.dart b/mobile/lib/utils/cache/custom_image_cache.dart index a2a7839172..dcb8dacb0d 100644 --- a/mobile/lib/utils/cache/custom_image_cache.dart +++ b/mobile/lib/utils/cache/custom_image_cache.dart @@ -5,7 +5,7 @@ import 'package:immich_mobile/providers/image/immich_remote_image_provider.dart' import 'package:immich_mobile/providers/image/immich_remote_thumbnail_provider.dart'; /// [ImageCache] that uses two caches for small and large images -/// so that a single large image does not evict all small iamges +/// so that a single large image does not evict all small images final class CustomImageCache implements ImageCache { final _small = ImageCache(); final _large = ImageCache()..maximumSize = 5; // Maximum 5 images diff --git a/mobile/lib/widgets/photo_view/src/utils/photo_view_utils.dart b/mobile/lib/widgets/photo_view/src/utils/photo_view_utils.dart index 9c632df3bf..facd701725 100644 --- a/mobile/lib/widgets/photo_view/src/utils/photo_view_utils.dart +++ b/mobile/lib/widgets/photo_view/src/utils/photo_view_utils.dart @@ -26,7 +26,7 @@ double getScaleForScaleState( } /// Internal class to wraps custom scale boundaries (min, max and initial) -/// Also, stores values regarding the two sizes: the container and teh child. +/// Also, stores values regarding the two sizes: the container and the child. class ScaleBoundaries { const ScaleBoundaries( this._minScale, From 2510684bf7a5c8799dd36468f61454645c444cdb Mon Sep 17 00:00:00 2001 From: Thomas Laroche Date: Sat, 1 Mar 2025 21:07:19 +0100 Subject: [PATCH 257/395] fix(web): unable to download live photo as anonymous user (#16455) --- web/src/lib/utils/asset-utils.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/src/lib/utils/asset-utils.ts b/web/src/lib/utils/asset-utils.ts index 70f5c5f8f2..fa9725aa24 100644 --- a/web/src/lib/utils/asset-utils.ts +++ b/web/src/lib/utils/asset-utils.ts @@ -229,7 +229,7 @@ export const downloadFile = async (asset: AssetResponseDto) => { if (asset.livePhotoVideoId) { const motionAsset = await getAssetInfo({ id: asset.livePhotoVideoId, key: getKey() }); - if (!isAndroidMotionVideo(motionAsset) || get(preferences).download.includeEmbeddedVideos) { + if (!isAndroidMotionVideo(motionAsset) || get(preferences)?.download.includeEmbeddedVideos) { assets.push({ filename: motionAsset.originalFileName, id: asset.livePhotoVideoId, From f13d13b2ea7221eb6234ab28695e8632fd5efaa5 Mon Sep 17 00:00:00 2001 From: Yaros Date: Sat, 1 Mar 2025 21:34:57 +0100 Subject: [PATCH 258/395] fix(web): Fixed people list overflowing in advanced search (#16457) * Fixed people list overflowing in search * styling: better fix --------- Co-authored-by: Alex Tran --- .../search-bar/search-people-section.svelte | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/web/src/lib/components/shared-components/search-bar/search-people-section.svelte b/web/src/lib/components/shared-components/search-bar/search-people-section.svelte index ed17d78af3..bcf858d01a 100644 --- a/web/src/lib/components/shared-components/search-bar/search-people-section.svelte +++ b/web/src/lib/components/shared-components/search-bar/search-people-section.svelte @@ -54,7 +54,7 @@ {#await peoplePromise} -
+
{:then people} @@ -63,14 +63,14 @@ ? filterPeople(people, name) : filterPeople(people, name).slice(0, numberOfPeople)} -
+

{$t('people').toUpperCase()}

{#each peopleList as person (person.id)} From 506d2d0f815a8100a4a4b267418909b969e5ef5b Mon Sep 17 00:00:00 2001 From: luzpaz Date: Sat, 1 Mar 2025 17:51:50 -0500 Subject: [PATCH 259/395] fix(web): fix typos (#16466) Found via codespell --- web/src/lib/actions/intersection-observer.ts | 2 +- web/src/lib/components/i18n/__test__/format-message.spec.ts | 2 +- .../components/shared-components/notification/notification.ts | 2 +- .../purchasing/individual-purchase-option-card.svelte | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/web/src/lib/actions/intersection-observer.ts b/web/src/lib/actions/intersection-observer.ts index edbc07e5c1..3a10074051 100644 --- a/web/src/lib/actions/intersection-observer.ts +++ b/web/src/lib/actions/intersection-observer.ts @@ -60,7 +60,7 @@ const observe = (key: HTMLElement | string, target: HTMLElement, properties: Int (entries: IntersectionObserverEntry[]) => { // This IntersectionObserver is limited to observing a single element, the one the // action is attached to. If there are multiple entries, it means that this - // observer is being notified of multiple events that have occured quickly together, + // observer is being notified of multiple events that have occurred quickly together, // and the latest element is the one we are interested in. entries.sort((a, b) => a.time - b.time); diff --git a/web/src/lib/components/i18n/__test__/format-message.spec.ts b/web/src/lib/components/i18n/__test__/format-message.spec.ts index a496237f90..e7b3cd6ab9 100644 --- a/web/src/lib/components/i18n/__test__/format-message.spec.ts +++ b/web/src/lib/components/i18n/__test__/format-message.spec.ts @@ -70,7 +70,7 @@ describe('FormatMessage component', () => { expect(getSanitizedHTML(container)).toBe('You have 1 item'); }); - it('protects agains XSS injection', () => { + it('protects against XSS injection', () => { render(FormatMessage, { key: 'xss' as Translations, }); diff --git a/web/src/lib/components/shared-components/notification/notification.ts b/web/src/lib/components/shared-components/notification/notification.ts index fd54768c04..79b1edd1a9 100644 --- a/web/src/lib/components/shared-components/notification/notification.ts +++ b/web/src/lib/components/shared-components/notification/notification.ts @@ -19,7 +19,7 @@ export type Notification = { /** The action to take when the notification is clicked */ action: NotificationAction; button?: NotificationButton; - /** Timeout in miliseconds */ + /** Timeout in milliseconds */ timeout: number; }; diff --git a/web/src/lib/components/shared-components/purchasing/individual-purchase-option-card.svelte b/web/src/lib/components/shared-components/purchasing/individual-purchase-option-card.svelte index f9de919025..c77a9dac96 100644 --- a/web/src/lib/components/shared-components/purchasing/individual-purchase-option-card.svelte +++ b/web/src/lib/components/shared-components/purchasing/individual-purchase-option-card.svelte @@ -7,7 +7,7 @@ import { t } from 'svelte-i18n'; - +
From 6cc1978b2dad52b923fcda14d3c0b320dc6bb423 Mon Sep 17 00:00:00 2001 From: Lukas Jost Date: Sun, 2 Mar 2025 00:02:56 +0100 Subject: [PATCH 260/395] fix(web): Open huggingface.co link on settings page in new tab (#16470) fix(web): Open huggingface on settings page in new tab --- .../machine-learning-settings/machine-learning-settings.svelte | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/src/lib/components/admin-page/settings/machine-learning-settings/machine-learning-settings.svelte b/web/src/lib/components/admin-page/settings/machine-learning-settings/machine-learning-settings.svelte index 90131d7238..80c8904376 100644 --- a/web/src/lib/components/admin-page/settings/machine-learning-settings/machine-learning-settings.svelte +++ b/web/src/lib/components/admin-page/settings/machine-learning-settings/machine-learning-settings.svelte @@ -110,7 +110,7 @@

{#snippet children({ message })} - {message} + {message} {/snippet}

From d8d87bb5656cfe9a82ea830e6dd9762fd0a0506a Mon Sep 17 00:00:00 2001 From: shenlong <139912620+shenlong-tanwen@users.noreply.github.com> Date: Sun, 2 Mar 2025 18:00:48 +0530 Subject: [PATCH 261/395] chore(mobile): rename log enum to lowercase (#16476) * chore(mobile): rename log enum to lowercase * chore(mobile): do not abbreviate --------- Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com> --- mobile/lib/domain/models/log.model.dart | 24 ++++----- mobile/lib/domain/services/log.service.dart | 18 +++---- .../infrastructure/entities/log.entity.dart | 23 ++++----- .../infrastructure/entities/log.entity.g.dart | 49 +++++++++---------- .../infrastructure/entities/store.entity.dart | 3 +- mobile/lib/pages/common/app_log.page.dart | 12 ++--- mobile/lib/utils/bootstrap.dart | 4 +- .../domain/services/log_service_test.dart | 32 ++++++------ .../modules/shared/sync_service_test.dart | 4 +- 9 files changed, 80 insertions(+), 89 deletions(-) diff --git a/mobile/lib/domain/models/log.model.dart b/mobile/lib/domain/models/log.model.dart index 51f816df01..dffd1cccda 100644 --- a/mobile/lib/domain/models/log.model.dart +++ b/mobile/lib/domain/models/log.model.dart @@ -1,19 +1,15 @@ -// ignore_for_file: constant_identifier_names - -import 'package:logging/logging.dart'; - /// Log levels according to dart logging [Level] enum LogLevel { - ALL, - FINEST, - FINER, - FINE, - CONFIG, - INFO, - WARNING, - SEVERE, - SHOUT, - OFF, + all, + finest, + finer, + fine, + config, + info, + warning, + severe, + shout, + off, } class LogMessage { diff --git a/mobile/lib/domain/services/log.service.dart b/mobile/lib/domain/services/log.service.dart index 61b5638e78..59de5c2e94 100644 --- a/mobile/lib/domain/services/log.service.dart +++ b/mobile/lib/domain/services/log.service.dart @@ -39,29 +39,29 @@ class LogService { } static Future init({ - required ILogRepository logRepo, - required IStoreRepository storeRepo, + required ILogRepository logRepository, + required IStoreRepository storeRepository, bool shouldBuffer = true, }) async { if (_instance != null) { return _instance!; } _instance = await create( - logRepo: logRepo, - storeRepo: storeRepo, + logRepository: logRepository, + storeRepository: storeRepository, shouldBuffer: shouldBuffer, ); return _instance!; } static Future create({ - required ILogRepository logRepo, - required IStoreRepository storeRepo, + required ILogRepository logRepository, + required IStoreRepository storeRepository, bool shouldBuffer = true, }) async { - final instance = LogService._(logRepo, storeRepo, shouldBuffer); + final instance = LogService._(logRepository, storeRepository, shouldBuffer); // Truncate logs to 250 - await logRepo.truncate(limit: kLogTruncateLimit); + await logRepository.truncate(limit: kLogTruncateLimit); // Get log level from store final level = await instance._storeRepository.tryGet(StoreKey.logLevel); if (level != null) { @@ -145,7 +145,7 @@ class LoggerUnInitializedException implements Exception { extension LevelDomainToInfraExtension on Level { LogLevel toLogLevel() => LogLevel.values.elementAtOrNull(Level.LEVELS.indexOf(this)) ?? - LogLevel.INFO; + LogLevel.info; } extension on LogLevel { diff --git a/mobile/lib/infrastructure/entities/log.entity.dart b/mobile/lib/infrastructure/entities/log.entity.dart index 6c55f17989..6a38924e24 100644 --- a/mobile/lib/infrastructure/entities/log.entity.dart +++ b/mobile/lib/infrastructure/entities/log.entity.dart @@ -5,29 +5,24 @@ part 'log.entity.g.dart'; @Collection(inheritance: false) class LoggerMessage { - Id id = Isar.autoIncrement; - String message; - String? details; + final Id id = Isar.autoIncrement; + final String message; + final String? details; @Enumerated(EnumType.ordinal) - LogLevel level = LogLevel.INFO; - DateTime createdAt; - String? context1; - String? context2; + final LogLevel level; + final DateTime createdAt; + final String? context1; + final String? context2; - LoggerMessage({ + const LoggerMessage({ required this.message, required this.details, - required this.level, + this.level = LogLevel.info, required this.createdAt, required this.context1, required this.context2, }); - @override - String toString() { - return 'LoggerMessage(message: $message, level: $level, createdAt: $createdAt)'; - } - LogMessage toDto() { return LogMessage( message: message, diff --git a/mobile/lib/infrastructure/entities/log.entity.g.dart b/mobile/lib/infrastructure/entities/log.entity.g.dart index f3ee284aa4..9300cf15c5 100644 --- a/mobile/lib/infrastructure/entities/log.entity.g.dart +++ b/mobile/lib/infrastructure/entities/log.entity.g.dart @@ -117,10 +117,9 @@ LoggerMessage _loggerMessageDeserialize( createdAt: reader.readDateTime(offsets[2]), details: reader.readStringOrNull(offsets[3]), level: _LoggerMessagelevelValueEnumMap[reader.readByteOrNull(offsets[4])] ?? - LogLevel.ALL, + LogLevel.info, message: reader.readString(offsets[5]), ); - object.id = id; return object; } @@ -141,7 +140,7 @@ P _loggerMessageDeserializeProp

( return (reader.readStringOrNull(offset)) as P; case 4: return (_LoggerMessagelevelValueEnumMap[reader.readByteOrNull(offset)] ?? - LogLevel.ALL) as P; + LogLevel.info) as P; case 5: return (reader.readString(offset)) as P; default: @@ -150,28 +149,28 @@ P _loggerMessageDeserializeProp

( } const _LoggerMessagelevelEnumValueMap = { - 'ALL': 0, - 'FINEST': 1, - 'FINER': 2, - 'FINE': 3, - 'CONFIG': 4, - 'INFO': 5, - 'WARNING': 6, - 'SEVERE': 7, - 'SHOUT': 8, - 'OFF': 9, + 'all': 0, + 'finest': 1, + 'finer': 2, + 'fine': 3, + 'config': 4, + 'info': 5, + 'warning': 6, + 'severe': 7, + 'shout': 8, + 'off': 9, }; const _LoggerMessagelevelValueEnumMap = { - 0: LogLevel.ALL, - 1: LogLevel.FINEST, - 2: LogLevel.FINER, - 3: LogLevel.FINE, - 4: LogLevel.CONFIG, - 5: LogLevel.INFO, - 6: LogLevel.WARNING, - 7: LogLevel.SEVERE, - 8: LogLevel.SHOUT, - 9: LogLevel.OFF, + 0: LogLevel.all, + 1: LogLevel.finest, + 2: LogLevel.finer, + 3: LogLevel.fine, + 4: LogLevel.config, + 5: LogLevel.info, + 6: LogLevel.warning, + 7: LogLevel.severe, + 8: LogLevel.shout, + 9: LogLevel.off, }; Id _loggerMessageGetId(LoggerMessage object) { @@ -183,9 +182,7 @@ List> _loggerMessageGetLinks(LoggerMessage object) { } void _loggerMessageAttach( - IsarCollection col, Id id, LoggerMessage object) { - object.id = id; -} + IsarCollection col, Id id, LoggerMessage object) {} extension LoggerMessageQueryWhereSort on QueryBuilder { diff --git a/mobile/lib/infrastructure/entities/store.entity.dart b/mobile/lib/infrastructure/entities/store.entity.dart index ef47af8f52..8d6d9a7d16 100644 --- a/mobile/lib/infrastructure/entities/store.entity.dart +++ b/mobile/lib/infrastructure/entities/store.entity.dart @@ -5,8 +5,9 @@ part 'store.entity.g.dart'; /// Internal class for `Store`, do not use elsewhere. @Collection(inheritance: false) class StoreValue { - const StoreValue(this.id, {this.intValue, this.strValue}); final Id id; final int? intValue; final String? strValue; + + const StoreValue(this.id, {this.intValue, this.strValue}); } diff --git a/mobile/lib/pages/common/app_log.page.dart b/mobile/lib/pages/common/app_log.page.dart index 3bd2e0111f..56c32327dd 100644 --- a/mobile/lib/pages/common/app_log.page.dart +++ b/mobile/lib/pages/common/app_log.page.dart @@ -41,16 +41,16 @@ class AppLogPage extends HookConsumerWidget { } Widget buildLeadingIcon(LogLevel level) => switch (level) { - LogLevel.INFO => colorStatusIndicator(context.primaryColor), - LogLevel.SEVERE => colorStatusIndicator(Colors.redAccent), - LogLevel.WARNING => colorStatusIndicator(Colors.orangeAccent), + LogLevel.info => colorStatusIndicator(context.primaryColor), + LogLevel.severe => colorStatusIndicator(Colors.redAccent), + LogLevel.warning => colorStatusIndicator(Colors.orangeAccent), _ => colorStatusIndicator(Colors.grey), }; Color getTileColor(LogLevel level) => switch (level) { - LogLevel.INFO => Colors.transparent, - LogLevel.SEVERE => Colors.redAccent.withOpacity(0.25), - LogLevel.WARNING => Colors.orangeAccent.withOpacity(0.25), + LogLevel.info => Colors.transparent, + LogLevel.severe => Colors.redAccent.withOpacity(0.25), + LogLevel.warning => Colors.orangeAccent.withOpacity(0.25), _ => context.primaryColor.withOpacity(0.1), }; diff --git a/mobile/lib/utils/bootstrap.dart b/mobile/lib/utils/bootstrap.dart index 5b9a41f28d..4a9ce1a5e1 100644 --- a/mobile/lib/utils/bootstrap.dart +++ b/mobile/lib/utils/bootstrap.dart @@ -49,8 +49,8 @@ abstract final class Bootstrap { static Future initDomain(Isar db) async { await StoreService.init(storeRepository: IsarStoreRepository(db)); await LogService.init( - logRepo: IsarLogRepository(db), - storeRepo: IsarStoreRepository(db), + logRepository: IsarLogRepository(db), + storeRepository: IsarStoreRepository(db), ); } } diff --git a/mobile/test/domain/services/log_service_test.dart b/mobile/test/domain/services/log_service_test.dart index cbceb0d165..5811a8c430 100644 --- a/mobile/test/domain/services/log_service_test.dart +++ b/mobile/test/domain/services/log_service_test.dart @@ -14,14 +14,14 @@ import '../../test_utils.dart'; final _kInfoLog = LogMessage( message: '#Info Message', - level: LogLevel.INFO, + level: LogLevel.info, createdAt: DateTime(2025, 2, 26), logger: 'Info Logger', ); final _kWarnLog = LogMessage( message: '#Warn Message', - level: LogLevel.WARNING, + level: LogLevel.warning, createdAt: DateTime(2025, 2, 27), logger: 'Warn Logger', ); @@ -40,13 +40,15 @@ void main() { when(() => mockLogRepo.truncate(limit: any(named: 'limit'))) .thenAnswer((_) async => {}); when(() => mockStoreRepo.tryGet(StoreKey.logLevel)) - .thenAnswer((_) async => LogLevel.FINE.index); + .thenAnswer((_) async => LogLevel.fine.index); when(() => mockLogRepo.getAll()).thenAnswer((_) async => []); when(() => mockLogRepo.insert(any())).thenAnswer((_) async => true); when(() => mockLogRepo.insertAll(any())).thenAnswer((_) async => true); - sut = - await LogService.create(logRepo: mockLogRepo, storeRepo: mockStoreRepo); + sut = await LogService.create( + logRepository: mockLogRepo, + storeRepository: mockStoreRepo, + ); }); tearDown(() async { @@ -72,14 +74,14 @@ void main() { setUp(() async { when(() => mockStoreRepo.insert(StoreKey.logLevel, any())) .thenAnswer((_) async => true); - await sut.setlogLevel(LogLevel.SHOUT); + await sut.setlogLevel(LogLevel.shout); }); test('Updates the log level in store', () { final index = verify( () => mockStoreRepo.insert(StoreKey.logLevel, captureAny()), ).captured.firstOrNull; - expect(index, LogLevel.SHOUT.index); + expect(index, LogLevel.shout.index); }); test('Sets log level on logger', () { @@ -91,8 +93,8 @@ void main() { test('Buffers logs until timer elapses', () { TestUtils.fakeAsync((time) async { sut = await LogService.create( - logRepo: mockLogRepo, - storeRepo: mockStoreRepo, + logRepository: mockLogRepo, + storeRepository: mockStoreRepo, shouldBuffer: true, ); @@ -109,8 +111,8 @@ void main() { test('Batch inserts all logs on timer', () { TestUtils.fakeAsync((time) async { sut = await LogService.create( - logRepo: mockLogRepo, - storeRepo: mockStoreRepo, + logRepository: mockLogRepo, + storeRepository: mockStoreRepo, shouldBuffer: true, ); @@ -131,8 +133,8 @@ void main() { test('Does not buffer when off', () { TestUtils.fakeAsync((time) async { sut = await LogService.create( - logRepo: mockLogRepo, - storeRepo: mockStoreRepo, + logRepository: mockLogRepo, + storeRepository: mockStoreRepo, shouldBuffer: false, ); @@ -165,8 +167,8 @@ void main() { test('Combines result from both DB + Buffer', () { TestUtils.fakeAsync((time) async { sut = await LogService.create( - logRepo: mockLogRepo, - storeRepo: mockStoreRepo, + logRepository: mockLogRepo, + storeRepository: mockStoreRepo, shouldBuffer: true, ); diff --git a/mobile/test/modules/shared/sync_service_test.dart b/mobile/test/modules/shared/sync_service_test.dart index e37b5ec7bc..a58de21613 100644 --- a/mobile/test/modules/shared/sync_service_test.dart +++ b/mobile/test/modules/shared/sync_service_test.dart @@ -72,8 +72,8 @@ void main() { await StoreService.init(storeRepository: IsarStoreRepository(db)); await Store.put(StoreKey.currentUser, owner); await LogService.init( - logRepo: IsarLogRepository(db), - storeRepo: IsarStoreRepository(db), + logRepository: IsarLogRepository(db), + storeRepository: IsarStoreRepository(db), ); }); final List initialAssets = [ From fd5e9316173ee6f3a89e9fbe978b01f63e9cd8f9 Mon Sep 17 00:00:00 2001 From: Yaros Date: Sun, 2 Mar 2025 13:58:05 +0100 Subject: [PATCH 262/395] fix(mobile): Updated formatting of server address in networking (#16483) * Updated formatting of server address in networking * fallback for undefined endpoint --- .../networking_settings.dart | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/mobile/lib/widgets/settings/networking_settings/networking_settings.dart b/mobile/lib/widgets/settings/networking_settings/networking_settings.dart index 1089029947..587a0ce6d3 100644 --- a/mobile/lib/widgets/settings/networking_settings/networking_settings.dart +++ b/mobile/lib/widgets/settings/networking_settings/networking_settings.dart @@ -2,13 +2,12 @@ import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart' hide Store; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:immich_mobile/domain/models/store.model.dart'; -import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/models/auth/auxilary_endpoint.model.dart'; import 'package:immich_mobile/providers/network.provider.dart'; import 'package:immich_mobile/services/app_settings.service.dart'; import 'package:immich_mobile/utils/hooks/app_settings_update_hook.dart'; +import 'package:immich_mobile/utils/url_helper.dart'; import 'package:immich_mobile/widgets/settings/networking_settings/external_network_preference.dart'; import 'package:immich_mobile/widgets/settings/networking_settings/local_network_preference.dart'; import 'package:immich_mobile/widgets/settings/settings_switch_list_tile.dart'; @@ -18,7 +17,7 @@ class NetworkingSettings extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final currentEndpoint = Store.get(StoreKey.serverEndpoint); + final currentEndpoint = getServerUrl(); final featureEnabled = useAppSettingsState(AppSettingsEnum.autoEndpointSwitching); @@ -102,7 +101,7 @@ class NetworkingSettings extends HookConsumerWidget { padding: const EdgeInsets.only(top: 8, left: 16, bottom: 8), child: NetworkPreferenceTitle( title: "current_server_address".tr().toUpperCase(), - icon: currentEndpoint.startsWith('https') + icon: (currentEndpoint?.startsWith('https') ?? false) ? Icons.https_outlined : Icons.http_outlined, ), @@ -119,10 +118,16 @@ class NetworkingSettings extends HookConsumerWidget { ), ), child: ListTile( - leading: - const Icon(Icons.check_circle_rounded, color: Colors.green), + leading: currentEndpoint != null + ? const Icon( + Icons.check_circle_rounded, + color: Colors.green, + ) + : const Icon( + Icons.circle_outlined, + ), title: Text( - currentEndpoint, + currentEndpoint ?? "--", style: TextStyle( fontSize: 16, fontFamily: 'Inconsolata', From 366f23774a28424bcae5843e9c3d84b149808be6 Mon Sep 17 00:00:00 2001 From: Yaros Date: Sun, 2 Mar 2025 14:06:15 +0100 Subject: [PATCH 263/395] fix(web): Default to context search on web (#16485) Default to context search on web --- .../shared-components/search-bar/search-filter-modal.svelte | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/src/lib/components/shared-components/search-bar/search-filter-modal.svelte b/web/src/lib/components/shared-components/search-bar/search-filter-modal.svelte index 4fc646b204..f37894a3e2 100644 --- a/web/src/lib/components/shared-components/search-bar/search-filter-modal.svelte +++ b/web/src/lib/components/shared-components/search-bar/search-filter-modal.svelte @@ -57,7 +57,7 @@ let filter: SearchFilter = $state({ query: 'query' in searchQuery ? searchQuery.query : searchQuery.originalFileName || '', - queryType: 'query' in searchQuery ? 'smart' : 'metadata', + queryType: 'smart', personIds: new SvelteSet('personIds' in searchQuery ? searchQuery.personIds : []), tagIds: new SvelteSet('tagIds' in searchQuery ? searchQuery.tagIds : []), location: { From 6bf2e8dbcb5eecadb3c43a3fa5f6576a5ec07756 Mon Sep 17 00:00:00 2001 From: knechtandreas Date: Mon, 3 Mar 2025 00:15:00 +1100 Subject: [PATCH 264/395] feat: add album keyboard shortcuts (#16442) * 15712: Added keyboard shortcuts for opening add to album modal and highlighting/selecting an album to add to. * 15712: Re-factored logic from template code into script. Extracted new album button into separate cmponent. * 15712: Document new keyboard shortucts now that they work everywhere. * 15712: Extract some constants/helper functions. * 15712: Missing comma. * 15712: Pulled logic out into separate unit testable class. * 15712: Added a unit test. * 15712: Move the modal back up to keep the github PR happy. * 15712: PR feedback - renamed typescript files and switch to class bind directive. * 15712:Move selection modal into correct package. * 15712: Better naming of module and files. --- .../actions/add-to-album-action.svelte | 7 +- .../asset-viewer/album-list-item.svelte | 16 +- .../photos-page/actions/add-to-album.svelte | 2 +- .../album-selection-modal.svelte | 113 ------------ .../album-selection-modal.svelte | 126 +++++++++++++ .../album-selection-utils.spec.ts | 171 ++++++++++++++++++ .../album-selection/album-selection-utils.ts | 94 ++++++++++ .../new-album-list-item.svelte | 40 ++++ .../shared-components/show-shortcuts.svelte | 2 + 9 files changed, 455 insertions(+), 116 deletions(-) delete mode 100644 web/src/lib/components/shared-components/album-selection-modal.svelte create mode 100644 web/src/lib/components/shared-components/album-selection/album-selection-modal.svelte create mode 100644 web/src/lib/components/shared-components/album-selection/album-selection-utils.spec.ts create mode 100644 web/src/lib/components/shared-components/album-selection/album-selection-utils.ts create mode 100644 web/src/lib/components/shared-components/album-selection/new-album-list-item.svelte diff --git a/web/src/lib/components/asset-viewer/actions/add-to-album-action.svelte b/web/src/lib/components/asset-viewer/actions/add-to-album-action.svelte index ab0da059d0..202f0e4593 100644 --- a/web/src/lib/components/asset-viewer/actions/add-to-album-action.svelte +++ b/web/src/lib/components/asset-viewer/actions/add-to-album-action.svelte @@ -1,6 +1,7 @@ + (showSelectionModal = true) }} +/> + void; } - let { album, searchQuery = '', onAlbumClick }: Props = $props(); + let { album, searchQuery = '', selected = false, onAlbumClick }: Props = $props(); + + const scrollIntoViewIfSelected: Action = (node) => { + $effect(() => { + if (selected) { + node.scrollIntoView(SCROLL_PROPERTIES); + } + }); + }; let albumNameArray: string[] = $state(['', '', '']); @@ -31,7 +42,10 @@ - {#if filteredAlbums.length > 0} - {#if !shared && search.length === 0} -

{$t('recent').toUpperCase()}

- {#each recentAlbums as album (album.id)} - onAlbumClick(album)} /> - {/each} - {/if} - - {#if !shared} -

- {(search.length === 0 ? $t('all_albums') : $t('albums')).toUpperCase()} -

- {/if} - {#each filteredAlbums as album (album.id)} - onAlbumClick(album)} /> - {/each} - {:else if albums.length > 0} -

{$t('no_albums_with_name_yet')}

- {:else} -

{$t('no_albums_yet')}

- {/if} -
- {/if} -
- diff --git a/web/src/lib/components/shared-components/album-selection/album-selection-modal.svelte b/web/src/lib/components/shared-components/album-selection/album-selection-modal.svelte new file mode 100644 index 0000000000..49b697b62a --- /dev/null +++ b/web/src/lib/components/shared-components/album-selection/album-selection-modal.svelte @@ -0,0 +1,126 @@ + + + +
+ {#if loading} + {#each { length: 3 } as _} +
+
+
+ +
+ + +
+
+
+ {/each} + {:else} + +
+ {#each albumModalRows as row} + {#if row.type === AlbumModalRowType.NEW_ALBUM} + + {:else if row.type === AlbumModalRowType.SECTION} +

{row.text}

+ {:else if row.type === AlbumModalRowType.MESSAGE} +

{row.text}

+ {:else if row.type === AlbumModalRowType.ALBUM_ITEM && row.album} + + {/if} + {/each} +
+ {/if} +
+
diff --git a/web/src/lib/components/shared-components/album-selection/album-selection-utils.spec.ts b/web/src/lib/components/shared-components/album-selection/album-selection-utils.spec.ts new file mode 100644 index 0000000000..242809d58f --- /dev/null +++ b/web/src/lib/components/shared-components/album-selection/album-selection-utils.spec.ts @@ -0,0 +1,171 @@ +import { + type AlbumModalRow, + AlbumModalRowConverter, + AlbumModalRowType, +} from '$lib/components/shared-components/album-selection/album-selection-utils'; +import { AlbumSortBy, SortOrder } from '$lib/stores/preferences.store'; +import type { AlbumResponseDto } from '@immich/sdk'; +import { albumFactory } from '@test-data/factories/album-factory'; + +// Some helper functions to make tests below more readable +const createNewAlbumRow = (selected: boolean) => ({ + type: AlbumModalRowType.NEW_ALBUM, + selected, +}); +const createMessageRow = (message: string): AlbumModalRow => ({ + type: AlbumModalRowType.MESSAGE, + text: message, +}); +const createSectionRow = (message: string): AlbumModalRow => ({ + type: AlbumModalRowType.SECTION, + text: message, +}); +const createAlbumRow = (album: AlbumResponseDto, selected: boolean) => ({ + type: AlbumModalRowType.ALBUM_ITEM, + album, + selected, +}); + +describe('Album Modal', () => { + it('non-shared with no albums configured yet shows message and new', () => { + const converter = new AlbumModalRowConverter(false, AlbumSortBy.MostRecentPhoto, SortOrder.Desc); + const modalRows = converter.toModalRows('', [], [], -1); + + expect(modalRows).toStrictEqual([createNewAlbumRow(false), createMessageRow('no_albums_yet')]); + }); + + it('non-shared with no matching albums shows message and new', () => { + const converter = new AlbumModalRowConverter(false, AlbumSortBy.MostRecentPhoto, SortOrder.Desc); + const modalRows = converter.toModalRows('matches_nothing', [], [albumFactory.build({ albumName: 'Holidays' })], -1); + + expect(modalRows).toStrictEqual([createNewAlbumRow(false), createMessageRow('no_albums_with_name_yet')]); + }); + + it('non-shared displays single albums', () => { + const converter = new AlbumModalRowConverter(false, AlbumSortBy.MostRecentPhoto, SortOrder.Desc); + const holidayAlbum = albumFactory.build({ albumName: 'Holidays' }); + const modalRows = converter.toModalRows('', [], [holidayAlbum], -1); + + expect(modalRows).toStrictEqual([ + createNewAlbumRow(false), + createSectionRow('ALL_ALBUMS'), + createAlbumRow(holidayAlbum, false), + ]); + }); + + it('non-shared displays multiple albums and recents', () => { + const converter = new AlbumModalRowConverter(false, AlbumSortBy.MostRecentPhoto, SortOrder.Desc); + const holidayAlbum = albumFactory.build({ albumName: 'Holidays' }); + const constructionAlbum = albumFactory.build({ albumName: 'Construction' }); + const birthdayAlbum = albumFactory.build({ albumName: 'Birthday' }); + const christmasAlbum = albumFactory.build({ albumName: 'Christmas' }); + const modalRows = converter.toModalRows( + '', + [holidayAlbum, constructionAlbum], + [holidayAlbum, constructionAlbum, birthdayAlbum, christmasAlbum], + -1, + ); + + expect(modalRows).toStrictEqual([ + createNewAlbumRow(false), + createSectionRow('RECENT'), + createAlbumRow(holidayAlbum, false), + createAlbumRow(constructionAlbum, false), + createSectionRow('ALL_ALBUMS'), + createAlbumRow(holidayAlbum, false), + createAlbumRow(constructionAlbum, false), + createAlbumRow(birthdayAlbum, false), + createAlbumRow(christmasAlbum, false), + ]); + }); + + it('shared only displays albums and no recents', () => { + const converter = new AlbumModalRowConverter(true, AlbumSortBy.MostRecentPhoto, SortOrder.Desc); + const holidayAlbum = albumFactory.build({ albumName: 'Holidays' }); + const constructionAlbum = albumFactory.build({ albumName: 'Construction' }); + const birthdayAlbum = albumFactory.build({ albumName: 'Birthday' }); + const christmasAlbum = albumFactory.build({ albumName: 'Christmas' }); + const modalRows = converter.toModalRows( + '', + [holidayAlbum, constructionAlbum], + [holidayAlbum, constructionAlbum, birthdayAlbum, christmasAlbum], + -1, + ); + + expect(modalRows).toStrictEqual([ + createNewAlbumRow(false), + createAlbumRow(holidayAlbum, false), + createAlbumRow(constructionAlbum, false), + createAlbumRow(birthdayAlbum, false), + createAlbumRow(christmasAlbum, false), + ]); + }); + + it('search changes messaging and removes recent and non-matching albums', () => { + const converter = new AlbumModalRowConverter(false, AlbumSortBy.MostRecentPhoto, SortOrder.Desc); + const holidayAlbum = albumFactory.build({ albumName: 'Holidays' }); + const constructionAlbum = albumFactory.build({ albumName: 'Construction' }); + const birthdayAlbum = albumFactory.build({ albumName: 'Birthday' }); + const christmasAlbum = albumFactory.build({ albumName: 'Christmas' }); + const modalRows = converter.toModalRows( + 'Cons', + [holidayAlbum, constructionAlbum], + [holidayAlbum, constructionAlbum, birthdayAlbum, christmasAlbum], + -1, + ); + + expect(modalRows).toStrictEqual([ + createNewAlbumRow(false), + createSectionRow('ALBUMS'), + createAlbumRow(constructionAlbum, false), + ]); + }); + + it('selection can select new album row', () => { + const converter = new AlbumModalRowConverter(false, AlbumSortBy.MostRecentPhoto, SortOrder.Desc); + const holidayAlbum = albumFactory.build({ albumName: 'Holidays' }); + const constructionAlbum = albumFactory.build({ albumName: 'Construction' }); + const modalRows = converter.toModalRows('', [holidayAlbum], [holidayAlbum, constructionAlbum], 0); + + expect(modalRows).toStrictEqual([ + createNewAlbumRow(true), + createSectionRow('RECENT'), + createAlbumRow(holidayAlbum, false), + createSectionRow('ALL_ALBUMS'), + createAlbumRow(holidayAlbum, false), + createAlbumRow(constructionAlbum, false), + ]); + }); + + it('selection can select recent row', () => { + const converter = new AlbumModalRowConverter(false, AlbumSortBy.MostRecentPhoto, SortOrder.Desc); + const holidayAlbum = albumFactory.build({ albumName: 'Holidays' }); + const constructionAlbum = albumFactory.build({ albumName: 'Construction' }); + const modalRows = converter.toModalRows('', [holidayAlbum], [holidayAlbum, constructionAlbum], 1); + + expect(modalRows).toStrictEqual([ + createNewAlbumRow(false), + createSectionRow('RECENT'), + createAlbumRow(holidayAlbum, true), + createSectionRow('ALL_ALBUMS'), + createAlbumRow(holidayAlbum, false), + createAlbumRow(constructionAlbum, false), + ]); + }); + + it('selection can select last row', () => { + const converter = new AlbumModalRowConverter(false, AlbumSortBy.MostRecentPhoto, SortOrder.Desc); + const holidayAlbum = albumFactory.build({ albumName: 'Holidays' }); + const constructionAlbum = albumFactory.build({ albumName: 'Construction' }); + const modalRows = converter.toModalRows('', [holidayAlbum], [holidayAlbum, constructionAlbum], 3); + + expect(modalRows).toStrictEqual([ + createNewAlbumRow(false), + createSectionRow('RECENT'), + createAlbumRow(holidayAlbum, false), + createSectionRow('ALL_ALBUMS'), + createAlbumRow(holidayAlbum, false), + createAlbumRow(constructionAlbum, true), + ]); + }); +}); diff --git a/web/src/lib/components/shared-components/album-selection/album-selection-utils.ts b/web/src/lib/components/shared-components/album-selection/album-selection-utils.ts new file mode 100644 index 0000000000..73f289eb1d --- /dev/null +++ b/web/src/lib/components/shared-components/album-selection/album-selection-utils.ts @@ -0,0 +1,94 @@ +import { sortAlbums } from '$lib/utils/album-utils'; +import { normalizeSearchString } from '$lib/utils/string-utils'; +import type { AlbumResponseDto } from '@immich/sdk'; +import { t } from 'svelte-i18n'; +import { get } from 'svelte/store'; + +export const SCROLL_PROPERTIES: ScrollIntoViewOptions = { block: 'center', behavior: 'smooth' }; + +export enum AlbumModalRowType { + SECTION = 'section', + MESSAGE = 'message', + NEW_ALBUM = 'newAlbum', + ALBUM_ITEM = 'albumItem', +} + +export type AlbumModalRow = { + type: AlbumModalRowType; + selected?: boolean; + text?: string; + album?: AlbumResponseDto; +}; + +export const isSelectableRowType = (type: AlbumModalRowType) => + type === AlbumModalRowType.NEW_ALBUM || type === AlbumModalRowType.ALBUM_ITEM; + +const $t = get(t); + +export class AlbumModalRowConverter { + private readonly shared: boolean; + private readonly sortBy: string; + private readonly orderBy: string; + + constructor(shared: boolean, sortBy: string, orderBy: string) { + this.shared = shared; + this.sortBy = sortBy; + this.orderBy = orderBy; + } + + toModalRows( + search: string, + recentAlbums: AlbumResponseDto[], + albums: AlbumResponseDto[], + selectedRowIndex: number, + ): AlbumModalRow[] { + // only show recent albums if no search was entered, or we're in the normal albums (non-shared) modal. + const recentAlbumsToShow = !this.shared && search.length === 0 ? recentAlbums : []; + const rows: AlbumModalRow[] = []; + rows.push({ type: AlbumModalRowType.NEW_ALBUM, selected: selectedRowIndex === 0 }); + + const filteredAlbums = sortAlbums( + search.length > 0 && albums.length > 0 + ? albums.filter((album) => { + return normalizeSearchString(album.albumName).includes(normalizeSearchString(search)); + }) + : albums, + { sortBy: this.sortBy, orderBy: this.orderBy }, + ); + + if (filteredAlbums.length > 0) { + if (recentAlbumsToShow.length > 0) { + rows.push({ type: AlbumModalRowType.SECTION, text: $t('recent').toUpperCase() }); + const selectedOffsetDueToNewAlbumRow = 1; + for (const [i, album] of recentAlbums.entries()) { + rows.push({ + type: AlbumModalRowType.ALBUM_ITEM, + selected: selectedRowIndex === i + selectedOffsetDueToNewAlbumRow, + album, + }); + } + } + + if (!this.shared) { + rows.push({ + type: AlbumModalRowType.SECTION, + text: (search.length === 0 ? $t('all_albums') : $t('albums')).toUpperCase(), + }); + } + + const selectedOffsetDueToNewAndRecents = 1 + recentAlbumsToShow.length; + for (const [i, album] of filteredAlbums.entries()) { + rows.push({ + type: AlbumModalRowType.ALBUM_ITEM, + selected: selectedRowIndex === i + selectedOffsetDueToNewAndRecents, + album, + }); + } + } else if (albums.length > 0) { + rows.push({ type: AlbumModalRowType.MESSAGE, text: $t('no_albums_with_name_yet') }); + } else { + rows.push({ type: AlbumModalRowType.MESSAGE, text: $t('no_albums_yet') }); + } + return rows; + } +} diff --git a/web/src/lib/components/shared-components/album-selection/new-album-list-item.svelte b/web/src/lib/components/shared-components/album-selection/new-album-list-item.svelte new file mode 100644 index 0000000000..d8be0e2a30 --- /dev/null +++ b/web/src/lib/components/shared-components/album-selection/new-album-list-item.svelte @@ -0,0 +1,40 @@ + + + diff --git a/web/src/lib/components/shared-components/show-shortcuts.svelte b/web/src/lib/components/shared-components/show-shortcuts.svelte index a3cfd83ad5..9ca35c927e 100644 --- a/web/src/lib/components/shared-components/show-shortcuts.svelte +++ b/web/src/lib/components/shared-components/show-shortcuts.svelte @@ -33,6 +33,8 @@ { key: ['f'], action: $t('favorite_or_unfavorite_photo') }, { key: ['i'], action: $t('show_or_hide_info') }, { key: ['s'], action: $t('stack_selected_photos') }, + { key: ['l'], action: $t('add_to_album') }, + { key: ['⇧', 'l'], action: $t('add_to_shared_album') }, { key: ['⇧', 'a'], action: $t('archive_or_unarchive_photo') }, { key: ['⇧', 'd'], action: $t('download') }, { key: ['Space'], action: $t('play_or_pause_video') }, From 6e51c4ec71c2516035b393e1175c99daac1f6a81 Mon Sep 17 00:00:00 2001 From: bo0tzz Date: Mon, 3 Mar 2025 04:02:36 +0100 Subject: [PATCH 265/395] chore: add extra note to no-dupes checkbox (#16499) --- .github/DISCUSSION_TEMPLATE/feature-request.yaml | 2 +- .github/ISSUE_TEMPLATE/bug_report.yaml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/DISCUSSION_TEMPLATE/feature-request.yaml b/.github/DISCUSSION_TEMPLATE/feature-request.yaml index 9aeee8004c..7a260188ea 100644 --- a/.github/DISCUSSION_TEMPLATE/feature-request.yaml +++ b/.github/DISCUSSION_TEMPLATE/feature-request.yaml @@ -11,7 +11,7 @@ body: - type: checkboxes attributes: - label: I have searched the existing feature requests to make sure this is not a duplicate request. + label: I have searched the existing feature requests, both open and closed, to make sure this is not a duplicate request. options: - label: "Yes" required: true diff --git a/.github/ISSUE_TEMPLATE/bug_report.yaml b/.github/ISSUE_TEMPLATE/bug_report.yaml index 86bef294fb..c4e1cc2bf1 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yaml +++ b/.github/ISSUE_TEMPLATE/bug_report.yaml @@ -3,7 +3,7 @@ description: Report an issue with Immich body: - type: checkboxes attributes: - label: I have searched the existing issues to make sure this is not a duplicate report. + label: I have searched the existing issues, both open and closed, to make sure this is not a duplicate report. options: - label: "Yes" required: true From 8885e3105e3d915ae1d9f5ddd462ca9636ef66c7 Mon Sep 17 00:00:00 2001 From: Justin Cichra Date: Sun, 2 Mar 2025 22:27:20 -0500 Subject: [PATCH 266/395] chore: reword backup_manual_in_progress (#16513) fix(i18n): reword backup_manual_in_progress Split "sometime" into "some time". --- mobile/assets/i18n/ca-CA.json | 2 +- mobile/assets/i18n/en-US.json | 4 ++-- mobile/assets/i18n/ga.json | 4 ++-- mobile/assets/i18n/gl-ES.json | 2 +- mobile/assets/i18n/hi-IN.json | 4 ++-- mobile/assets/i18n/lt-LT.json | 4 ++-- mobile/assets/i18n/mn-MN.json | 4 ++-- mobile/assets/i18n/sr-Cyrl.json | 4 ++-- mobile/assets/i18n/sr-Latn.json | 4 ++-- mobile/assets/i18n/sv-FI.json | 4 ++-- 10 files changed, 18 insertions(+), 18 deletions(-) diff --git a/mobile/assets/i18n/ca-CA.json b/mobile/assets/i18n/ca-CA.json index 8366e01f93..cd7fd0e12b 100644 --- a/mobile/assets/i18n/ca-CA.json +++ b/mobile/assets/i18n/ca-CA.json @@ -108,7 +108,7 @@ "backup_info_card_assets": "elements", "backup_manual_cancelled": "Cancelled", "backup_manual_failed": "Failed", - "backup_manual_in_progress": "Upload already in progress. Try after sometime", + "backup_manual_in_progress": "Upload already in progress. Try after some time", "backup_manual_success": "Success", "backup_manual_title": "Upload status", "cache_settings_album_thumbnails": "Library page thumbnails ({} assets)", diff --git a/mobile/assets/i18n/en-US.json b/mobile/assets/i18n/en-US.json index fd628d4692..38fdb8ac5e 100644 --- a/mobile/assets/i18n/en-US.json +++ b/mobile/assets/i18n/en-US.json @@ -133,7 +133,7 @@ "backup_info_card_assets": "assets", "backup_manual_cancelled": "Cancelled", "backup_manual_failed": "Failed", - "backup_manual_in_progress": "Upload already in progress. Try after sometime", + "backup_manual_in_progress": "Upload already in progress. Try after some time", "backup_manual_success": "Success", "backup_manual_title": "Upload status", "backup_options_page_title": "Backup options", @@ -678,4 +678,4 @@ "viewer_unstack": "Un-Stack", "wifi_name": "WiFi Name", "your_wifi_name": "Your WiFi name" -} \ No newline at end of file +} diff --git a/mobile/assets/i18n/ga.json b/mobile/assets/i18n/ga.json index fd628d4692..38fdb8ac5e 100644 --- a/mobile/assets/i18n/ga.json +++ b/mobile/assets/i18n/ga.json @@ -133,7 +133,7 @@ "backup_info_card_assets": "assets", "backup_manual_cancelled": "Cancelled", "backup_manual_failed": "Failed", - "backup_manual_in_progress": "Upload already in progress. Try after sometime", + "backup_manual_in_progress": "Upload already in progress. Try after some time", "backup_manual_success": "Success", "backup_manual_title": "Upload status", "backup_options_page_title": "Backup options", @@ -678,4 +678,4 @@ "viewer_unstack": "Un-Stack", "wifi_name": "WiFi Name", "your_wifi_name": "Your WiFi name" -} \ No newline at end of file +} diff --git a/mobile/assets/i18n/gl-ES.json b/mobile/assets/i18n/gl-ES.json index 9450b4b44f..a5b43d7447 100644 --- a/mobile/assets/i18n/gl-ES.json +++ b/mobile/assets/i18n/gl-ES.json @@ -133,7 +133,7 @@ "backup_info_card_assets": "assets", "backup_manual_cancelled": "Cancelled", "backup_manual_failed": "Failed", - "backup_manual_in_progress": "Upload already in progress. Try after sometime", + "backup_manual_in_progress": "Upload already in progress. Try after some time", "backup_manual_success": "Success", "backup_manual_title": "Upload status", "backup_options_page_title": "Backup options", diff --git a/mobile/assets/i18n/hi-IN.json b/mobile/assets/i18n/hi-IN.json index 5b64f8c674..0d58fbb42e 100644 --- a/mobile/assets/i18n/hi-IN.json +++ b/mobile/assets/i18n/hi-IN.json @@ -133,7 +133,7 @@ "backup_info_card_assets": "assets", "backup_manual_cancelled": "Cancelled", "backup_manual_failed": "Failed", - "backup_manual_in_progress": "Upload already in progress. Try after sometime", + "backup_manual_in_progress": "Upload already in progress. Try after some time", "backup_manual_success": "Success", "backup_manual_title": "Upload status", "backup_options_page_title": "Backup options", @@ -678,4 +678,4 @@ "viewer_unstack": "स्टैक रद्द करें", "wifi_name": "WiFi Name", "your_wifi_name": "Your WiFi name" -} \ No newline at end of file +} diff --git a/mobile/assets/i18n/lt-LT.json b/mobile/assets/i18n/lt-LT.json index fd628d4692..38fdb8ac5e 100644 --- a/mobile/assets/i18n/lt-LT.json +++ b/mobile/assets/i18n/lt-LT.json @@ -133,7 +133,7 @@ "backup_info_card_assets": "assets", "backup_manual_cancelled": "Cancelled", "backup_manual_failed": "Failed", - "backup_manual_in_progress": "Upload already in progress. Try after sometime", + "backup_manual_in_progress": "Upload already in progress. Try after some time", "backup_manual_success": "Success", "backup_manual_title": "Upload status", "backup_options_page_title": "Backup options", @@ -678,4 +678,4 @@ "viewer_unstack": "Un-Stack", "wifi_name": "WiFi Name", "your_wifi_name": "Your WiFi name" -} \ No newline at end of file +} diff --git a/mobile/assets/i18n/mn-MN.json b/mobile/assets/i18n/mn-MN.json index 33c9ed0440..9fb9c5498f 100644 --- a/mobile/assets/i18n/mn-MN.json +++ b/mobile/assets/i18n/mn-MN.json @@ -133,7 +133,7 @@ "backup_info_card_assets": "assets", "backup_manual_cancelled": "Cancelled", "backup_manual_failed": "Failed", - "backup_manual_in_progress": "Upload already in progress. Try after sometime", + "backup_manual_in_progress": "Upload already in progress. Try after some time", "backup_manual_success": "Success", "backup_manual_title": "Upload status", "backup_options_page_title": "Backup options", @@ -678,4 +678,4 @@ "viewer_unstack": "Un-Stack", "wifi_name": "WiFi Name", "your_wifi_name": "Your WiFi name" -} \ No newline at end of file +} diff --git a/mobile/assets/i18n/sr-Cyrl.json b/mobile/assets/i18n/sr-Cyrl.json index fd628d4692..38fdb8ac5e 100644 --- a/mobile/assets/i18n/sr-Cyrl.json +++ b/mobile/assets/i18n/sr-Cyrl.json @@ -133,7 +133,7 @@ "backup_info_card_assets": "assets", "backup_manual_cancelled": "Cancelled", "backup_manual_failed": "Failed", - "backup_manual_in_progress": "Upload already in progress. Try after sometime", + "backup_manual_in_progress": "Upload already in progress. Try after some time", "backup_manual_success": "Success", "backup_manual_title": "Upload status", "backup_options_page_title": "Backup options", @@ -678,4 +678,4 @@ "viewer_unstack": "Un-Stack", "wifi_name": "WiFi Name", "your_wifi_name": "Your WiFi name" -} \ No newline at end of file +} diff --git a/mobile/assets/i18n/sr-Latn.json b/mobile/assets/i18n/sr-Latn.json index e0f8edc97b..8d67c34792 100644 --- a/mobile/assets/i18n/sr-Latn.json +++ b/mobile/assets/i18n/sr-Latn.json @@ -133,7 +133,7 @@ "backup_info_card_assets": "zapisi", "backup_manual_cancelled": "Cancelled", "backup_manual_failed": "Failed", - "backup_manual_in_progress": "Upload already in progress. Try after sometime", + "backup_manual_in_progress": "Upload already in progress. Try after some time", "backup_manual_success": "Success", "backup_manual_title": "Upload status", "backup_options_page_title": "Backup options", @@ -678,4 +678,4 @@ "viewer_unstack": "Un-Stack", "wifi_name": "WiFi Name", "your_wifi_name": "Your WiFi name" -} \ No newline at end of file +} diff --git a/mobile/assets/i18n/sv-FI.json b/mobile/assets/i18n/sv-FI.json index fd628d4692..38fdb8ac5e 100644 --- a/mobile/assets/i18n/sv-FI.json +++ b/mobile/assets/i18n/sv-FI.json @@ -133,7 +133,7 @@ "backup_info_card_assets": "assets", "backup_manual_cancelled": "Cancelled", "backup_manual_failed": "Failed", - "backup_manual_in_progress": "Upload already in progress. Try after sometime", + "backup_manual_in_progress": "Upload already in progress. Try after some time", "backup_manual_success": "Success", "backup_manual_title": "Upload status", "backup_options_page_title": "Backup options", @@ -678,4 +678,4 @@ "viewer_unstack": "Un-Stack", "wifi_name": "WiFi Name", "your_wifi_name": "Your WiFi name" -} \ No newline at end of file +} From 869839f64256927c8920a6cda2d410922603b150 Mon Sep 17 00:00:00 2001 From: Jonathan Jogenfors Date: Mon, 3 Mar 2025 04:29:02 +0100 Subject: [PATCH 267/395] feat(server): library cleanup from ui (#16226) * feat(server,web): scan all libraries from frontend * feat(server,web): scan all libraries from frontend * Add button text --- docs/docs/features/libraries.md | 2 +- i18n/en.json | 4 ++- server/src/enum.ts | 2 +- server/src/services/job.service.ts | 2 +- server/src/services/library.service.spec.ts | 2 +- server/src/services/library.service.ts | 28 +++++++++++++------ server/src/types.ts | 2 +- .../admin-page/jobs/job-tile.svelte | 2 +- .../admin-page/jobs/jobs-panel.svelte | 7 ++--- web/src/lib/utils.ts | 2 +- .../admin/library-management/+page.svelte | 8 ++++-- 11 files changed, 37 insertions(+), 24 deletions(-) diff --git a/docs/docs/features/libraries.md b/docs/docs/features/libraries.md index a137980e00..3d4ab6a892 100644 --- a/docs/docs/features/libraries.md +++ b/docs/docs/features/libraries.md @@ -68,7 +68,7 @@ In rare cases, the library watcher can hang, preventing Immich from starting up. ### Nightly job -There is an automatic scan job that is scheduled to run once a day. This job also cleans up any libraries stuck in deletion. +There is an automatic scan job that is scheduled to run once a day. This job also cleans up any libraries stuck in deletion. It is possible to trigger the cleanup by clicking "Scan all libraries" in the library managment page. ## Usage diff --git a/i18n/en.json b/i18n/en.json index e35f1906c4..4f84d140e0 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -96,7 +96,7 @@ "library_scanning_enable_description": "Enable periodic library scanning", "library_settings": "External Library", "library_settings_description": "Manage external library settings", - "library_tasks_description": "Perform library tasks", + "library_tasks_description": "Scan external libraries for new and/or changed assets", "library_watching_enable_description": "Watch external libraries for file changes", "library_watching_settings": "Library watching (EXPERIMENTAL)", "library_watching_settings_description": "Automatically watch for changed files", @@ -336,6 +336,7 @@ "untracked_files": "Untracked Files", "untracked_files_description": "These files are not tracked by the application. They can be the results of failed moves, interrupted uploads, or left behind due to a bug", "user_cleanup_job": "User cleanup", + "cleanup": "Cleanup", "user_delete_delay": "{user}'s account and assets will be scheduled for permanent deletion in {delay, plural, one {# day} other {# days}}.", "user_delete_delay_settings": "Delete delay", "user_delete_delay_settings_description": "Number of days after removal to permanently delete a user's account and assets. The user deletion job runs at midnight to check for users that are ready for deletion. Changes to this setting will be evaluated at the next execution.", @@ -1114,6 +1115,7 @@ "say_something": "Say something", "scan_all_libraries": "Scan All Libraries", "scan_library": "Scan", + "rescan": "Rescan", "scan_settings": "Scan Settings", "scanning_for_album": "Scanning for album...", "search": "Search", diff --git a/server/src/enum.ts b/server/src/enum.ts index 676e1d27db..95168b1754 100644 --- a/server/src/enum.ts +++ b/server/src/enum.ts @@ -473,7 +473,7 @@ export enum JobName { LIBRARY_SYNC_FILE = 'library-sync-file', LIBRARY_SYNC_ASSET = 'library-sync-asset', LIBRARY_DELETE = 'library-delete', - LIBRARY_QUEUE_SYNC_ALL = 'library-queue-sync-all', + LIBRARY_QUEUE_SCAN_ALL = 'library-queue-scan-all', LIBRARY_QUEUE_CLEANUP = 'library-queue-cleanup', // cleanup diff --git a/server/src/services/job.service.ts b/server/src/services/job.service.ts index 22408c33de..167c121706 100644 --- a/server/src/services/job.service.ts +++ b/server/src/services/job.service.ts @@ -170,7 +170,7 @@ export class JobService extends BaseService { } case QueueName.LIBRARY: { - return this.jobRepository.queue({ name: JobName.LIBRARY_QUEUE_SYNC_ALL, data: { force } }); + return this.jobRepository.queue({ name: JobName.LIBRARY_QUEUE_SCAN_ALL, data: { force } }); } case QueueName.BACKUP_DATABASE: { diff --git a/server/src/services/library.service.spec.ts b/server/src/services/library.service.spec.ts index ded7e0630a..c869f803f0 100644 --- a/server/src/services/library.service.spec.ts +++ b/server/src/services/library.service.spec.ts @@ -1079,7 +1079,7 @@ describe(LibraryService.name, () => { it('should queue the refresh job', async () => { mocks.library.getAll.mockResolvedValue([libraryStub.externalLibrary1]); - await expect(sut.handleQueueSyncAll()).resolves.toBe(JobStatus.SUCCESS); + await expect(sut.handleQueueScanAll()).resolves.toBe(JobStatus.SUCCESS); expect(mocks.job.queue.mock.calls).toEqual([ [ diff --git a/server/src/services/library.service.ts b/server/src/services/library.service.ts index 441d130c12..cdd6a3948f 100644 --- a/server/src/services/library.service.ts +++ b/server/src/services/library.service.ts @@ -47,7 +47,7 @@ export class LibraryService extends BaseService { name: 'libraryScan', expression: scan.cronExpression, onTick: () => - handlePromiseError(this.jobRepository.queue({ name: JobName.LIBRARY_QUEUE_SYNC_ALL }), this.logger), + handlePromiseError(this.jobRepository.queue({ name: JobName.LIBRARY_QUEUE_SCAN_ALL }), this.logger), start: scan.enabled, }); } @@ -210,11 +210,17 @@ export class LibraryService extends BaseService { @OnJob({ name: JobName.LIBRARY_QUEUE_CLEANUP, queue: QueueName.LIBRARY }) async handleQueueCleanup(): Promise { - this.logger.debug('Cleaning up any pending library deletions'); - const pendingDeletion = await this.libraryRepository.getAllDeleted(); - await this.jobRepository.queueAll( - pendingDeletion.map((libraryToDelete) => ({ name: JobName.LIBRARY_DELETE, data: { id: libraryToDelete.id } })), - ); + this.logger.log('Checking for any libraries pending deletion...'); + const pendingDeletions = await this.libraryRepository.getAllDeleted(); + if (pendingDeletions.length > 0) { + const libraryString = pendingDeletions.length === 1 ? 'library' : 'libraries'; + this.logger.log(`Found ${pendingDeletions.length} ${libraryString} pending deletion, cleaning up...`); + + await this.jobRepository.queueAll( + pendingDeletions.map((libraryToDelete) => ({ name: JobName.LIBRARY_DELETE, data: { id: libraryToDelete.id } })), + ); + } + return JobStatus.SUCCESS; } @@ -442,9 +448,13 @@ export class LibraryService extends BaseService { await this.jobRepository.queue({ name: JobName.LIBRARY_QUEUE_SYNC_ASSETS, data: { id } }); } - @OnJob({ name: JobName.LIBRARY_QUEUE_SYNC_ALL, queue: QueueName.LIBRARY }) - async handleQueueSyncAll(): Promise { - this.logger.debug(`Refreshing all external libraries`); + async queueScanAll() { + await this.jobRepository.queue({ name: JobName.LIBRARY_QUEUE_SCAN_ALL, data: {} }); + } + + @OnJob({ name: JobName.LIBRARY_QUEUE_SCAN_ALL, queue: QueueName.LIBRARY }) + async handleQueueScanAll(): Promise { + this.logger.log(`Refreshing all external libraries`); await this.jobRepository.queue({ name: JobName.LIBRARY_QUEUE_CLEANUP, data: {} }); diff --git a/server/src/types.ts b/server/src/types.ts index 5360e519bd..902e13b9ea 100644 --- a/server/src/types.ts +++ b/server/src/types.ts @@ -351,7 +351,7 @@ export type JobItem = | { name: JobName.LIBRARY_QUEUE_SYNC_ASSETS; data: IEntityJob } | { name: JobName.LIBRARY_SYNC_ASSET; data: ILibraryAssetJob } | { name: JobName.LIBRARY_DELETE; data: IEntityJob } - | { name: JobName.LIBRARY_QUEUE_SYNC_ALL; data?: IBaseJob } + | { name: JobName.LIBRARY_QUEUE_SCAN_ALL; data?: IBaseJob } | { name: JobName.LIBRARY_QUEUE_CLEANUP; data: IBaseJob } // Notification diff --git a/web/src/lib/components/admin-page/jobs/job-tile.svelte b/web/src/lib/components/admin-page/jobs/job-tile.svelte index 0e39647c75..80dd29e0be 100644 --- a/web/src/lib/components/admin-page/jobs/job-tile.svelte +++ b/web/src/lib/components/admin-page/jobs/job-tile.svelte @@ -185,7 +185,7 @@ {#if !disabled && !multipleButtons && isIdle} onCommand({ command: JobCommand.Start, force: false })}> - {$t('start').toUpperCase()} + {missingText} {/if}
diff --git a/web/src/lib/components/admin-page/jobs/jobs-panel.svelte b/web/src/lib/components/admin-page/jobs/jobs-panel.svelte index 9b4f3ffdd6..4eb0bf6bb0 100644 --- a/web/src/lib/components/admin-page/jobs/jobs-panel.svelte +++ b/web/src/lib/components/admin-page/jobs/jobs-panel.svelte @@ -79,8 +79,7 @@ icon: mdiLibraryShelves, title: $getJobName(JobName.Library), subtitle: $t('admin.library_tasks_description'), - allText: $t('all'), - missingText: $t('refresh'), + missingText: $t('rescan'), }, [JobName.Sidecar]: { title: $getJobName(JobName.Sidecar), @@ -135,14 +134,14 @@ [JobName.StorageTemplateMigration]: { icon: mdiFolderMove, title: $getJobName(JobName.StorageTemplateMigration), - missingText: $t('missing'), + missingText: $t('start'), description: StorageMigrationDescription, }, [JobName.Migration]: { icon: mdiFolderMove, title: $getJobName(JobName.Migration), subtitle: $t('admin.migration_job_description'), - missingText: $t('missing'), + missingText: $t('start'), }, }; diff --git a/web/src/lib/utils.ts b/web/src/lib/utils.ts index c87b623549..7d542a940a 100644 --- a/web/src/lib/utils.ts +++ b/web/src/lib/utils.ts @@ -146,7 +146,7 @@ export const getJobName = derived(t, ($t) => { [JobName.Migration]: $t('admin.migration_job'), [JobName.BackgroundTask]: $t('admin.background_task_job'), [JobName.Search]: $t('search'), - [JobName.Library]: $t('library'), + [JobName.Library]: $t('external_libraries'), [JobName.Notifications]: $t('notifications'), [JobName.BackupDatabase]: $t('admin.backup_database'), }; diff --git a/web/src/routes/admin/library-management/+page.svelte b/web/src/routes/admin/library-management/+page.svelte index 04325f9fc2..c397fe6d3a 100644 --- a/web/src/routes/admin/library-management/+page.svelte +++ b/web/src/routes/admin/library-management/+page.svelte @@ -22,7 +22,10 @@ getAllLibraries, getLibraryStatistics, getUserAdmin, + JobCommand, + JobName, scanLibrary, + sendJobCommand, updateLibrary, type LibraryResponseDto, type LibraryStatsResponseDto, @@ -151,9 +154,8 @@ const handleScanAll = async () => { try { - for (const library of libraries) { - await scanLibrary({ id: library.id }); - } + await sendJobCommand({ id: JobName.Library, jobCommandDto: { command: JobCommand.Start } }); + notificationController.show({ message: $t('admin.refreshing_all_libraries'), type: NotificationType.Info, From fe702ba6d78ab828a86e3e48c951dd320590e2b1 Mon Sep 17 00:00:00 2001 From: Zack Pollard Date: Mon, 3 Mar 2025 11:05:30 +0000 Subject: [PATCH 268/395] feat: partner sync (#16424) feat: partner CUD sync --- mobile/openapi/README.md | 2 + mobile/openapi/lib/api.dart | 2 + mobile/openapi/lib/api_client.dart | 4 + .../openapi/lib/model/sync_entity_type.dart | 6 + .../lib/model/sync_partner_delete_v1.dart | 107 +++++++++++ mobile/openapi/lib/model/sync_partner_v1.dart | 115 ++++++++++++ .../openapi/lib/model/sync_request_type.dart | 3 + open-api/immich-openapi-specs.json | 41 +++- open-api/typescript-sdk/src/fetch-client.ts | 7 +- server/src/db.d.ts | 9 +- server/src/dtos/sync.dto.ts | 15 ++ server/src/entities/partner-audit.entity.ts | 19 ++ server/src/enum.ts | 3 + .../1740739778549-CreatePartnersAuditTable.ts | 38 ++++ server/src/repositories/sync.repository.ts | 22 +++ server/src/services/sync.service.ts | 20 +- server/test/factory.ts | 31 ++- server/test/medium/specs/sync.service.spec.ts | 176 ++++++++++++++++++ .../test/repositories/sync.repository.mock.ts | 2 + 19 files changed, 614 insertions(+), 8 deletions(-) create mode 100644 mobile/openapi/lib/model/sync_partner_delete_v1.dart create mode 100644 mobile/openapi/lib/model/sync_partner_v1.dart create mode 100644 server/src/entities/partner-audit.entity.ts create mode 100644 server/src/migrations/1740739778549-CreatePartnersAuditTable.ts diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index 66c264cd76..3a3a3bc6ca 100644 --- a/mobile/openapi/README.md +++ b/mobile/openapi/README.md @@ -425,6 +425,8 @@ Class | Method | HTTP request | Description - [SyncAckDto](doc//SyncAckDto.md) - [SyncAckSetDto](doc//SyncAckSetDto.md) - [SyncEntityType](doc//SyncEntityType.md) + - [SyncPartnerDeleteV1](doc//SyncPartnerDeleteV1.md) + - [SyncPartnerV1](doc//SyncPartnerV1.md) - [SyncRequestType](doc//SyncRequestType.md) - [SyncStreamDto](doc//SyncStreamDto.md) - [SyncUserDeleteV1](doc//SyncUserDeleteV1.md) diff --git a/mobile/openapi/lib/api.dart b/mobile/openapi/lib/api.dart index 893587e7fc..04dc43f88c 100644 --- a/mobile/openapi/lib/api.dart +++ b/mobile/openapi/lib/api.dart @@ -232,6 +232,8 @@ part 'model/sync_ack_delete_dto.dart'; part 'model/sync_ack_dto.dart'; part 'model/sync_ack_set_dto.dart'; part 'model/sync_entity_type.dart'; +part 'model/sync_partner_delete_v1.dart'; +part 'model/sync_partner_v1.dart'; part 'model/sync_request_type.dart'; part 'model/sync_stream_dto.dart'; part 'model/sync_user_delete_v1.dart'; diff --git a/mobile/openapi/lib/api_client.dart b/mobile/openapi/lib/api_client.dart index 7c2dc53455..4d837ccb9d 100644 --- a/mobile/openapi/lib/api_client.dart +++ b/mobile/openapi/lib/api_client.dart @@ -520,6 +520,10 @@ class ApiClient { return SyncAckSetDto.fromJson(value); case 'SyncEntityType': return SyncEntityTypeTypeTransformer().decode(value); + case 'SyncPartnerDeleteV1': + return SyncPartnerDeleteV1.fromJson(value); + case 'SyncPartnerV1': + return SyncPartnerV1.fromJson(value); case 'SyncRequestType': return SyncRequestTypeTypeTransformer().decode(value); case 'SyncStreamDto': diff --git a/mobile/openapi/lib/model/sync_entity_type.dart b/mobile/openapi/lib/model/sync_entity_type.dart index ed82205a37..5d130f7f93 100644 --- a/mobile/openapi/lib/model/sync_entity_type.dart +++ b/mobile/openapi/lib/model/sync_entity_type.dart @@ -25,11 +25,15 @@ class SyncEntityType { static const userV1 = SyncEntityType._(r'UserV1'); static const userDeleteV1 = SyncEntityType._(r'UserDeleteV1'); + static const partnerV1 = SyncEntityType._(r'PartnerV1'); + static const partnerDeleteV1 = SyncEntityType._(r'PartnerDeleteV1'); /// List of all possible values in this [enum][SyncEntityType]. static const values = [ userV1, userDeleteV1, + partnerV1, + partnerDeleteV1, ]; static SyncEntityType? fromJson(dynamic value) => SyncEntityTypeTypeTransformer().decode(value); @@ -70,6 +74,8 @@ class SyncEntityTypeTypeTransformer { switch (data) { case r'UserV1': return SyncEntityType.userV1; case r'UserDeleteV1': return SyncEntityType.userDeleteV1; + case r'PartnerV1': return SyncEntityType.partnerV1; + case r'PartnerDeleteV1': return SyncEntityType.partnerDeleteV1; default: if (!allowNull) { throw ArgumentError('Unknown enum value to decode: $data'); diff --git a/mobile/openapi/lib/model/sync_partner_delete_v1.dart b/mobile/openapi/lib/model/sync_partner_delete_v1.dart new file mode 100644 index 0000000000..f5e10d6576 --- /dev/null +++ b/mobile/openapi/lib/model/sync_partner_delete_v1.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 SyncPartnerDeleteV1 { + /// Returns a new [SyncPartnerDeleteV1] instance. + SyncPartnerDeleteV1({ + required this.sharedById, + required this.sharedWithId, + }); + + String sharedById; + + String sharedWithId; + + @override + bool operator ==(Object other) => identical(this, other) || other is SyncPartnerDeleteV1 && + other.sharedById == sharedById && + other.sharedWithId == sharedWithId; + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (sharedById.hashCode) + + (sharedWithId.hashCode); + + @override + String toString() => 'SyncPartnerDeleteV1[sharedById=$sharedById, sharedWithId=$sharedWithId]'; + + Map toJson() { + final json = {}; + json[r'sharedById'] = this.sharedById; + json[r'sharedWithId'] = this.sharedWithId; + return json; + } + + /// Returns a new [SyncPartnerDeleteV1] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static SyncPartnerDeleteV1? fromJson(dynamic value) { + upgradeDto(value, "SyncPartnerDeleteV1"); + if (value is Map) { + final json = value.cast(); + + return SyncPartnerDeleteV1( + sharedById: mapValueOfType(json, r'sharedById')!, + sharedWithId: mapValueOfType(json, r'sharedWithId')!, + ); + } + 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 = SyncPartnerDeleteV1.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 = SyncPartnerDeleteV1.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of SyncPartnerDeleteV1-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] = SyncPartnerDeleteV1.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + 'sharedById', + 'sharedWithId', + }; +} + diff --git a/mobile/openapi/lib/model/sync_partner_v1.dart b/mobile/openapi/lib/model/sync_partner_v1.dart new file mode 100644 index 0000000000..e551c4c83d --- /dev/null +++ b/mobile/openapi/lib/model/sync_partner_v1.dart @@ -0,0 +1,115 @@ +// +// 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 SyncPartnerV1 { + /// Returns a new [SyncPartnerV1] instance. + SyncPartnerV1({ + required this.inTimeline, + required this.sharedById, + required this.sharedWithId, + }); + + bool inTimeline; + + String sharedById; + + String sharedWithId; + + @override + bool operator ==(Object other) => identical(this, other) || other is SyncPartnerV1 && + other.inTimeline == inTimeline && + other.sharedById == sharedById && + other.sharedWithId == sharedWithId; + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (inTimeline.hashCode) + + (sharedById.hashCode) + + (sharedWithId.hashCode); + + @override + String toString() => 'SyncPartnerV1[inTimeline=$inTimeline, sharedById=$sharedById, sharedWithId=$sharedWithId]'; + + Map toJson() { + final json = {}; + json[r'inTimeline'] = this.inTimeline; + json[r'sharedById'] = this.sharedById; + json[r'sharedWithId'] = this.sharedWithId; + return json; + } + + /// Returns a new [SyncPartnerV1] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static SyncPartnerV1? fromJson(dynamic value) { + upgradeDto(value, "SyncPartnerV1"); + if (value is Map) { + final json = value.cast(); + + return SyncPartnerV1( + inTimeline: mapValueOfType(json, r'inTimeline')!, + sharedById: mapValueOfType(json, r'sharedById')!, + sharedWithId: mapValueOfType(json, r'sharedWithId')!, + ); + } + 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 = SyncPartnerV1.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 = SyncPartnerV1.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of SyncPartnerV1-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] = SyncPartnerV1.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + 'inTimeline', + 'sharedById', + 'sharedWithId', + }; +} + diff --git a/mobile/openapi/lib/model/sync_request_type.dart b/mobile/openapi/lib/model/sync_request_type.dart index d7f1bde54c..c35b17dea1 100644 --- a/mobile/openapi/lib/model/sync_request_type.dart +++ b/mobile/openapi/lib/model/sync_request_type.dart @@ -24,10 +24,12 @@ class SyncRequestType { String toJson() => value; static const usersV1 = SyncRequestType._(r'UsersV1'); + static const partnersV1 = SyncRequestType._(r'PartnersV1'); /// List of all possible values in this [enum][SyncRequestType]. static const values = [ usersV1, + partnersV1, ]; static SyncRequestType? fromJson(dynamic value) => SyncRequestTypeTypeTransformer().decode(value); @@ -67,6 +69,7 @@ class SyncRequestTypeTypeTransformer { if (data != null) { switch (data) { case r'UsersV1': return SyncRequestType.usersV1; + case r'PartnersV1': return SyncRequestType.partnersV1; default: if (!allowNull) { throw ArgumentError('Unknown enum value to decode: $data'); diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index 2728fb9c91..7212d3b7f7 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -12052,13 +12052,50 @@ "SyncEntityType": { "enum": [ "UserV1", - "UserDeleteV1" + "UserDeleteV1", + "PartnerV1", + "PartnerDeleteV1" ], "type": "string" }, + "SyncPartnerDeleteV1": { + "properties": { + "sharedById": { + "type": "string" + }, + "sharedWithId": { + "type": "string" + } + }, + "required": [ + "sharedById", + "sharedWithId" + ], + "type": "object" + }, + "SyncPartnerV1": { + "properties": { + "inTimeline": { + "type": "boolean" + }, + "sharedById": { + "type": "string" + }, + "sharedWithId": { + "type": "string" + } + }, + "required": [ + "inTimeline", + "sharedById", + "sharedWithId" + ], + "type": "object" + }, "SyncRequestType": { "enum": [ - "UsersV1" + "UsersV1", + "PartnersV1" ], "type": "string" }, diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index 7e6164099b..8aecbe9816 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -3645,10 +3645,13 @@ export enum Error2 { } export enum SyncEntityType { UserV1 = "UserV1", - UserDeleteV1 = "UserDeleteV1" + UserDeleteV1 = "UserDeleteV1", + PartnerV1 = "PartnerV1", + PartnerDeleteV1 = "PartnerDeleteV1" } export enum SyncRequestType { - UsersV1 = "UsersV1" + UsersV1 = "UsersV1", + PartnersV1 = "PartnersV1" } export enum TranscodeHWAccel { Nvenc = "nvenc", diff --git a/server/src/db.d.ts b/server/src/db.d.ts index 7fb073d8ce..4c75562ba1 100644 --- a/server/src/db.d.ts +++ b/server/src/db.d.ts @@ -272,6 +272,13 @@ export interface NaturalearthCountries { type: string; } +export interface PartnersAudit { + deletedAt: Generated; + id: Generated; + sharedById: string; + sharedWithId: string; +} + export interface Partners { createdAt: Generated; inTimeline: Generated; @@ -316,7 +323,6 @@ export interface SessionSyncCheckpoints { updateId: Generated; } - export interface SharedLinkAsset { assetsId: string; sharedLinksId: string; @@ -462,6 +468,7 @@ export interface DB { migrations: Migrations; move_history: MoveHistory; naturalearth_countries: NaturalearthCountries; + partners_audit: PartnersAudit; partners: Partners; person: Person; sessions: Sessions; diff --git a/server/src/dtos/sync.dto.ts b/server/src/dtos/sync.dto.ts index 0628a566cd..d191c82bb3 100644 --- a/server/src/dtos/sync.dto.ts +++ b/server/src/dtos/sync.dto.ts @@ -45,15 +45,30 @@ export class SyncUserDeleteV1 { userId!: string; } +export class SyncPartnerV1 { + sharedById!: string; + sharedWithId!: string; + inTimeline!: boolean; +} + +export class SyncPartnerDeleteV1 { + sharedById!: string; + sharedWithId!: string; +} + export type SyncItem = { [SyncEntityType.UserV1]: SyncUserV1; [SyncEntityType.UserDeleteV1]: SyncUserDeleteV1; + [SyncEntityType.PartnerV1]: SyncPartnerV1; + [SyncEntityType.PartnerDeleteV1]: SyncPartnerDeleteV1; }; const responseDtos = [ // SyncUserV1, SyncUserDeleteV1, + SyncPartnerV1, + SyncPartnerDeleteV1, ]; export const extraSyncModels = responseDtos; diff --git a/server/src/entities/partner-audit.entity.ts b/server/src/entities/partner-audit.entity.ts new file mode 100644 index 0000000000..a731e017dc --- /dev/null +++ b/server/src/entities/partner-audit.entity.ts @@ -0,0 +1,19 @@ +import { Column, CreateDateColumn, Entity, Index, PrimaryColumn } from 'typeorm'; + +@Entity('partners_audit') +export class PartnerAuditEntity { + @PrimaryColumn({ type: 'uuid', nullable: false, default: () => 'immich_uuid_v7()' }) + id!: string; + + @Index('IDX_partners_audit_shared_by_id') + @Column({ type: 'uuid' }) + sharedById!: string; + + @Index('IDX_partners_audit_shared_with_id') + @Column({ type: 'uuid' }) + sharedWithId!: string; + + @Index('IDX_partners_audit_deleted_at') + @CreateDateColumn({ type: 'timestamptz', default: () => 'clock_timestamp()' }) + deletedAt!: Date; +} diff --git a/server/src/enum.ts b/server/src/enum.ts index 95168b1754..483bae2fc8 100644 --- a/server/src/enum.ts +++ b/server/src/enum.ts @@ -548,9 +548,12 @@ export enum DatabaseLock { export enum SyncRequestType { UsersV1 = 'UsersV1', + PartnersV1 = 'PartnersV1', } export enum SyncEntityType { UserV1 = 'UserV1', UserDeleteV1 = 'UserDeleteV1', + PartnerV1 = 'PartnerV1', + PartnerDeleteV1 = 'PartnerDeleteV1', } diff --git a/server/src/migrations/1740739778549-CreatePartnersAuditTable.ts b/server/src/migrations/1740739778549-CreatePartnersAuditTable.ts new file mode 100644 index 0000000000..d9c9dc1949 --- /dev/null +++ b/server/src/migrations/1740739778549-CreatePartnersAuditTable.ts @@ -0,0 +1,38 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class CreatePartnersAuditTable1740739778549 implements MigrationInterface { + name = 'CreatePartnersAuditTable1740739778549' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`CREATE TABLE "partners_audit" ("id" uuid NOT NULL DEFAULT immich_uuid_v7(), "sharedById" uuid NOT NULL, "sharedWithId" uuid NOT NULL, "deletedAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT clock_timestamp(), CONSTRAINT "PK_952b50217ff78198a7e380f0359" PRIMARY KEY ("id"))`); + await queryRunner.query(`CREATE INDEX "IDX_partners_audit_shared_by_id" ON "partners_audit" ("sharedById") `); + await queryRunner.query(`CREATE INDEX "IDX_partners_audit_shared_with_id" ON "partners_audit" ("sharedWithId") `); + await queryRunner.query(`CREATE INDEX "IDX_partners_audit_deleted_at" ON "partners_audit" ("deletedAt") `); + await queryRunner.query(`CREATE OR REPLACE FUNCTION partners_delete_audit() RETURNS TRIGGER AS + $$ + BEGIN + INSERT INTO partners_audit ("sharedById", "sharedWithId") + SELECT "sharedById", "sharedWithId" + FROM OLD; + RETURN NULL; + END; + $$ LANGUAGE plpgsql` + ); + await queryRunner.query(`CREATE OR REPLACE TRIGGER partners_delete_audit + AFTER DELETE ON partners + REFERENCING OLD TABLE AS OLD + FOR EACH STATEMENT + EXECUTE FUNCTION partners_delete_audit(); + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP INDEX "public"."IDX_partners_audit_deleted_at"`); + await queryRunner.query(`DROP INDEX "public"."IDX_partners_audit_shared_with_id"`); + await queryRunner.query(`DROP INDEX "public"."IDX_partners_audit_shared_by_id"`); + await queryRunner.query(`DROP TRIGGER partners_delete_audit`); + await queryRunner.query(`DROP FUNCTION partners_delete_audit`); + await queryRunner.query(`DROP TABLE "partners_audit"`); + } + +} diff --git a/server/src/repositories/sync.repository.ts b/server/src/repositories/sync.repository.ts index bde4b9f10f..f2c5a1fc16 100644 --- a/server/src/repositories/sync.repository.ts +++ b/server/src/repositories/sync.repository.ts @@ -56,4 +56,26 @@ export class SyncRepository { .orderBy(['id asc']) .stream(); } + + getPartnerUpserts(userId: string, ack?: SyncAck) { + return this.db + .selectFrom('partners') + .select(['sharedById', 'sharedWithId', 'inTimeline', 'updateId']) + .$if(!!ack, (qb) => qb.where('updateId', '>', ack!.updateId)) + .where((eb) => eb.or([eb('sharedById', '=', userId), eb('sharedWithId', '=', userId)])) + .where('updatedAt', '<', sql.raw("now() - interval '1 millisecond'")) + .orderBy(['updateId asc']) + .stream(); + } + + getPartnerDeletes(userId: string, ack?: SyncAck) { + return this.db + .selectFrom('partners_audit') + .select(['id', 'sharedById', 'sharedWithId']) + .$if(!!ack, (qb) => qb.where('id', '>', ack!.updateId)) + .where((eb) => eb.or([eb('sharedById', '=', userId), eb('sharedWithId', '=', userId)])) + .where('deletedAt', '<', sql.raw("now() - interval '1 millisecond'")) + .orderBy(['id asc']) + .stream(); + } } diff --git a/server/src/services/sync.service.ts b/server/src/services/sync.service.ts index b756c11ef4..45b1b7ff84 100644 --- a/server/src/services/sync.service.ts +++ b/server/src/services/sync.service.ts @@ -25,6 +25,7 @@ const FULL_SYNC = { needsFullSync: true, deleted: [], upserted: [] }; const SYNC_TYPES_ORDER = [ // SyncRequestType.UsersV1, + SyncRequestType.PartnersV1, ]; const throwSessionRequired = () => { @@ -81,8 +82,6 @@ export class SyncService extends BaseService { checkpoints.map(({ type, ack }) => [type, fromAck(ack)]), ); - // TODO pre-filter/sort list based on optimal sync order - for (const type of SYNC_TYPES_ORDER.filter((type) => dto.types.includes(type))) { switch (type) { case SyncRequestType.UsersV1: { @@ -99,6 +98,23 @@ export class SyncService extends BaseService { break; } + case SyncRequestType.PartnersV1: { + const deletes = this.syncRepository.getPartnerDeletes( + auth.user.id, + checkpointMap[SyncEntityType.PartnerDeleteV1], + ); + for await (const { id, ...data } of deletes) { + response.write(serialize({ type: SyncEntityType.PartnerDeleteV1, updateId: id, data })); + } + + const upserts = this.syncRepository.getPartnerUpserts(auth.user.id, checkpointMap[SyncEntityType.PartnerV1]); + for await (const { updateId, ...data } of upserts) { + response.write(serialize({ type: SyncEntityType.PartnerV1, updateId, data })); + } + + break; + } + default: { this.logger.warn(`Unsupported sync type: ${type}`); break; diff --git a/server/test/factory.ts b/server/test/factory.ts index 983b7cbb77..a682ad48f2 100644 --- a/server/test/factory.ts +++ b/server/test/factory.ts @@ -1,11 +1,12 @@ import { Insertable, Kysely } from 'kysely'; import { randomBytes, randomUUID } from 'node:crypto'; import { Writable } from 'node:stream'; -import { Assets, DB, Sessions, Users } from 'src/db'; +import { Assets, DB, Partners, Sessions, Users } from 'src/db'; import { AuthDto } from 'src/dtos/auth.dto'; import { AssetType } from 'src/enum'; import { AlbumRepository } from 'src/repositories/album.repository'; import { AssetRepository } from 'src/repositories/asset.repository'; +import { PartnerRepository } from 'src/repositories/partner.repository'; import { SessionRepository } from 'src/repositories/session.repository'; import { SyncRepository } from 'src/repositories/sync.repository'; import { UserRepository } from 'src/repositories/user.repository'; @@ -30,6 +31,7 @@ class CustomWritable extends Writable { type Asset = Insertable; type User = Partial>; type Session = Omit, 'token'> & { token?: string }; +type Partner = Insertable; export const newUuid = () => randomUUID() as string; @@ -37,6 +39,7 @@ export class TestFactory { private assets: Asset[] = []; private sessions: Session[] = []; private users: User[] = []; + private partners: Partner[] = []; private constructor(private context: TestContext) {} @@ -100,6 +103,17 @@ export class TestFactory { }; } + static partner(partner: Partner) { + const defaults = { + inTimeline: true, + }; + + return { + ...defaults, + ...partner, + }; + } + withAsset(asset: Asset) { this.assets.push(asset); return this; @@ -115,6 +129,11 @@ export class TestFactory { return this; } + withPartner(partner: Partner) { + this.partners.push(partner); + return this; + } + async create() { for (const asset of this.assets) { await this.context.createAsset(asset); @@ -124,6 +143,10 @@ export class TestFactory { await this.context.createUser(user); } + for (const partner of this.partners) { + await this.context.createPartner(partner); + } + for (const session of this.sessions) { await this.context.createSession(session); } @@ -138,6 +161,7 @@ export class TestContext { albumRepository: AlbumRepository; sessionRepository: SessionRepository; syncRepository: SyncRepository; + partnerRepository: PartnerRepository; private constructor(private db: Kysely) { this.userRepository = new UserRepository(this.db); @@ -145,6 +169,7 @@ export class TestContext { this.albumRepository = new AlbumRepository(this.db); this.sessionRepository = new SessionRepository(this.db); this.syncRepository = new SyncRepository(this.db); + this.partnerRepository = new PartnerRepository(this.db); } static from(db: Kysely) { @@ -159,6 +184,10 @@ export class TestContext { return this.userRepository.create(TestFactory.user(user)); } + createPartner(partner: Partner) { + return this.partnerRepository.create(TestFactory.partner(partner)); + } + createAsset(asset: Asset) { return this.assetRepository.create(TestFactory.asset(asset)); } diff --git a/server/test/medium/specs/sync.service.spec.ts b/server/test/medium/specs/sync.service.spec.ts index bab9794100..7cd849c6ff 100644 --- a/server/test/medium/specs/sync.service.spec.ts +++ b/server/test/medium/specs/sync.service.spec.ts @@ -17,6 +17,8 @@ const setup = async () => { const testSync = async (auth: AuthDto, types: SyncRequestType[]) => { const stream = TestFactory.stream(); + // Wait for 1ms to ensure all updates are available + await new Promise((resolve) => setTimeout(resolve, 1)); await sut.stream(auth, stream, { types }); return stream.getResponse(); @@ -186,4 +188,178 @@ describe(SyncService.name, () => { ); }); }); + + describe.concurrent('partners', () => { + it('should detect and sync the first partner', async () => { + const { auth, context, sut, testSync } = await setup(); + + const user1 = auth.user; + const user2 = await context.createUser(); + + const partner = await context.createPartner({ sharedById: user2.id, sharedWithId: user1.id }); + + const initialSyncResponse = await testSync(auth, [SyncRequestType.PartnersV1]); + + expect(initialSyncResponse).toHaveLength(1); + expect(initialSyncResponse).toEqual( + expect.arrayContaining([ + { + ack: expect.any(String), + data: { + inTimeline: partner.inTimeline, + sharedById: partner.sharedById, + sharedWithId: partner.sharedWithId, + }, + type: 'PartnerV1', + }, + ]), + ); + + const acks = [initialSyncResponse[0].ack]; + await sut.setAcks(auth, { acks }); + + const ackSyncResponse = await testSync(auth, [SyncRequestType.PartnersV1]); + + expect(ackSyncResponse).toHaveLength(0); + }); + + it('should detect and sync a deleted partner', async () => { + const { auth, context, sut, testSync } = await setup(); + + const user1 = auth.user; + const user2 = await context.createUser(); + + const partner = await context.createPartner({ sharedById: user2.id, sharedWithId: user1.id }); + await context.partnerRepository.remove(partner); + + const response = await testSync(auth, [SyncRequestType.PartnersV1]); + + expect(response).toHaveLength(1); + expect(response).toEqual( + expect.arrayContaining([ + { + ack: expect.any(String), + data: { + sharedById: partner.sharedById, + sharedWithId: partner.sharedWithId, + }, + type: 'PartnerDeleteV1', + }, + ]), + ); + + const acks = response.map(({ ack }) => ack); + await sut.setAcks(auth, { acks }); + + const ackSyncResponse = await testSync(auth, [SyncRequestType.PartnersV1]); + + expect(ackSyncResponse).toHaveLength(0); + }); + + it('should detect and sync a partner share both to and from another user', async () => { + const { auth, context, sut, testSync } = await setup(); + + const user1 = auth.user; + const user2 = await context.createUser(); + + const partner1 = await context.createPartner({ sharedById: user2.id, sharedWithId: user1.id }); + const partner2 = await context.createPartner({ sharedById: user1.id, sharedWithId: user2.id }); + + const response = await testSync(auth, [SyncRequestType.PartnersV1]); + + expect(response).toHaveLength(2); + expect(response).toEqual( + expect.arrayContaining([ + { + ack: expect.any(String), + data: { + inTimeline: partner1.inTimeline, + sharedById: partner1.sharedById, + sharedWithId: partner1.sharedWithId, + }, + type: 'PartnerV1', + }, + { + ack: expect.any(String), + data: { + inTimeline: partner2.inTimeline, + sharedById: partner2.sharedById, + sharedWithId: partner2.sharedWithId, + }, + type: 'PartnerV1', + }, + ]), + ); + + await sut.setAcks(auth, { acks: [response[1].ack] }); + + const ackSyncResponse = await testSync(auth, [SyncRequestType.PartnersV1]); + + expect(ackSyncResponse).toHaveLength(0); + }); + + it('should sync a partner and then an update to that same partner', async () => { + const { auth, context, sut, testSync } = await setup(); + + const user1 = auth.user; + const user2 = await context.createUser(); + + const partner = await context.createPartner({ sharedById: user2.id, sharedWithId: user1.id }); + + const initialSyncResponse = await testSync(auth, [SyncRequestType.PartnersV1]); + + expect(initialSyncResponse).toHaveLength(1); + expect(initialSyncResponse).toEqual( + expect.arrayContaining([ + { + ack: expect.any(String), + data: { + inTimeline: partner.inTimeline, + sharedById: partner.sharedById, + sharedWithId: partner.sharedWithId, + }, + type: 'PartnerV1', + }, + ]), + ); + + const acks = [initialSyncResponse[0].ack]; + await sut.setAcks(auth, { acks }); + + const updated = await context.partnerRepository.update( + { sharedById: partner.sharedById, sharedWithId: partner.sharedWithId }, + { inTimeline: true }, + ); + + const updatedSyncResponse = await testSync(auth, [SyncRequestType.PartnersV1]); + + expect(updatedSyncResponse).toHaveLength(1); + expect(updatedSyncResponse).toEqual( + expect.arrayContaining([ + { + ack: expect.any(String), + data: { + inTimeline: updated.inTimeline, + sharedById: updated.sharedById, + sharedWithId: updated.sharedWithId, + }, + type: 'PartnerV1', + }, + ]), + ); + }); + + it('should not sync a partner for an unrelated user', async () => { + const { auth, context, testSync } = await setup(); + + const user2 = await context.createUser(); + const user3 = await context.createUser(); + + await context.createPartner({ sharedById: user2.id, sharedWithId: user3.id }); + + const response = await testSync(auth, [SyncRequestType.PartnersV1]); + + expect(response).toHaveLength(0); + }); + }); }); diff --git a/server/test/repositories/sync.repository.mock.ts b/server/test/repositories/sync.repository.mock.ts index fbb8ec2f62..6d94f6e039 100644 --- a/server/test/repositories/sync.repository.mock.ts +++ b/server/test/repositories/sync.repository.mock.ts @@ -9,5 +9,7 @@ export const newSyncRepositoryMock = (): Mocked Date: Mon, 3 Mar 2025 12:39:53 +0100 Subject: [PATCH 269/395] feat: weblate checks workflow (#16251) --- .github/workflows/weblate-lock.yml | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 .github/workflows/weblate-lock.yml diff --git a/.github/workflows/weblate-lock.yml b/.github/workflows/weblate-lock.yml new file mode 100644 index 0000000000..317dd7c33a --- /dev/null +++ b/.github/workflows/weblate-lock.yml @@ -0,0 +1,25 @@ +name: Weblate checks + +on: + pull_request: + branches: [main] + paths: + - 'i18n/**' + +jobs: + enforce-lock: + runs-on: ubuntu-latest + steps: + - name: Check weblate lock + run: | + if [[ "false" = $(curl https://hosted.weblate.org/api/components/immich/immich/lock/ | jq .locked) ]]; then + exit 1 + fi + - name: Find Pull Request + uses: juliangruber/find-pull-request-action@v1 + id: find-pr + with: + branch: chore/translations + - name: Fail if existing weblate PR + if: ${{ steps.find-pr.outputs.number }} + run: exit 1 From a2aab1f3736f27fe74e0b775a270381f2868e7f8 Mon Sep 17 00:00:00 2001 From: Alex Date: Mon, 3 Mar 2025 05:40:14 -0600 Subject: [PATCH 270/395] fix: don't use public keyword in migration query (#16514) Co-authored-by: Zack Pollard --- .../migrations/1740595460866-UsersAuditUuidv7PrimaryKey.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/server/src/migrations/1740595460866-UsersAuditUuidv7PrimaryKey.ts b/server/src/migrations/1740595460866-UsersAuditUuidv7PrimaryKey.ts index 997f718fd9..59fc4dbd5b 100644 --- a/server/src/migrations/1740595460866-UsersAuditUuidv7PrimaryKey.ts +++ b/server/src/migrations/1740595460866-UsersAuditUuidv7PrimaryKey.ts @@ -4,7 +4,7 @@ export class UsersAuditUuidv7PrimaryKey1740595460866 implements MigrationInterfa name = 'UsersAuditUuidv7PrimaryKey1740595460866' public async up(queryRunner: QueryRunner): Promise { - await queryRunner.query(`DROP INDEX "public"."IDX_users_audit_deleted_at_asc_user_id_asc"`); + await queryRunner.query(`DROP INDEX "IDX_users_audit_deleted_at_asc_user_id_asc"`); await queryRunner.query(`ALTER TABLE "users_audit" DROP CONSTRAINT "PK_e9b2bdfd90e7eb5961091175180"`); await queryRunner.query(`ALTER TABLE "users_audit" DROP COLUMN "id"`); await queryRunner.query(`ALTER TABLE "users_audit" ADD "id" uuid NOT NULL DEFAULT immich_uuid_v7()`); @@ -14,7 +14,7 @@ export class UsersAuditUuidv7PrimaryKey1740595460866 implements MigrationInterfa } public async down(queryRunner: QueryRunner): Promise { - await queryRunner.query(`DROP INDEX "public"."IDX_users_audit_deleted_at"`); + await queryRunner.query(`DROP INDEX "IDX_users_audit_deleted_at"`); await queryRunner.query(`ALTER TABLE "users_audit" DROP CONSTRAINT "PK_e9b2bdfd90e7eb5961091175180"`); await queryRunner.query(`ALTER TABLE "users_audit" DROP COLUMN "id"`); await queryRunner.query(`ALTER TABLE "users_audit" ADD "id" SERIAL NOT NULL`); From 5f6c16080bb232dd27745d853307c397ed96f242 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 3 Mar 2025 11:51:13 +0000 Subject: [PATCH 271/395] chore(deps): update docker.io/tensorchord/pgvecto-rs:pg14-v0.2.0 docker digest to 739cdd6 (#16528) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- docker/docker-compose.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index 08437e17c7..fd0edf9cb0 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -56,7 +56,7 @@ services: database: container_name: immich_postgres - image: docker.io/tensorchord/pgvecto-rs:pg14-v0.2.0@sha256:90724186f0a3517cf6914295b5ab410db9ce23190a2d9d0b9dd6463e3fa298f0 + image: docker.io/tensorchord/pgvecto-rs:pg14-v0.2.0@sha256:739cdd626151ff1f796dc95a6591b55a714f341c737e27f045019ceabf8e8c52 environment: POSTGRES_PASSWORD: ${DB_PASSWORD} POSTGRES_USER: ${DB_USERNAME} From eed6465b418a4fc2c06d45bf7fe3387b8311e64b Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 3 Mar 2025 11:51:44 +0000 Subject: [PATCH 272/395] chore(deps): update grafana/grafana docker tag to v11.5.2 (#16301) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- docker/docker-compose.prod.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/docker-compose.prod.yml b/docker/docker-compose.prod.yml index ffc5c85b1d..4b394f9e02 100644 --- a/docker/docker-compose.prod.yml +++ b/docker/docker-compose.prod.yml @@ -112,7 +112,7 @@ services: command: ['./run.sh', '-disable-reporting'] ports: - 3000:3000 - image: grafana/grafana:11.5.1-ubuntu@sha256:9a4ab78cec1a2ec7d1ca5dfd5aacec6412706a1bc9e971fc7184e2f6696a63f5 + image: grafana/grafana:11.5.2-ubuntu@sha256:8b5858c447e06fd7a89006b562ba7bba7c4d5813600c7982374c41852adefaeb volumes: - grafana-data:/var/lib/grafana From 12ab56c8853b34866f21dffab7c1af20e54693cb Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 3 Mar 2025 11:52:22 +0000 Subject: [PATCH 273/395] chore(deps): update prom/prometheus docker digest to 6927e09 (#16529) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- docker/docker-compose.prod.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/docker-compose.prod.yml b/docker/docker-compose.prod.yml index 4b394f9e02..003367e21e 100644 --- a/docker/docker-compose.prod.yml +++ b/docker/docker-compose.prod.yml @@ -100,7 +100,7 @@ services: container_name: immich_prometheus ports: - 9090:9090 - image: prom/prometheus@sha256:5888c188cf09e3f7eebc97369c3b2ce713e844cdbd88ccf36f5047c958aea120 + image: prom/prometheus@sha256:6927e0919a144aa7616fd0137d4816816d42f6b816de3af269ab065250859a62 volumes: - ./prometheus.yml:/etc/prometheus/prometheus.yml - prometheus-data:/prometheus From 4b568dcbb3f1554ef66016128a8a9257fa3f285d Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 3 Mar 2025 11:57:46 +0000 Subject: [PATCH 274/395] chore(deps): update dependency black to v25 (#16033) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- machine-learning/poetry.lock | 46 ++++++++++++++++++------------------ 1 file changed, 23 insertions(+), 23 deletions(-) diff --git a/machine-learning/poetry.lock b/machine-learning/poetry.lock index 6aaf8c2972..b16c33839f 100644 --- a/machine-learning/poetry.lock +++ b/machine-learning/poetry.lock @@ -75,33 +75,33 @@ trio = ["trio (>=0.23)"] [[package]] name = "black" -version = "24.10.0" +version = "25.1.0" description = "The uncompromising code formatter." optional = false python-versions = ">=3.9" files = [ - {file = "black-24.10.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e6668650ea4b685440857138e5fe40cde4d652633b1bdffc62933d0db4ed9812"}, - {file = "black-24.10.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:1c536fcf674217e87b8cc3657b81809d3c085d7bf3ef262ead700da345bfa6ea"}, - {file = "black-24.10.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:649fff99a20bd06c6f727d2a27f401331dc0cc861fb69cde910fe95b01b5928f"}, - {file = "black-24.10.0-cp310-cp310-win_amd64.whl", hash = "sha256:fe4d6476887de70546212c99ac9bd803d90b42fc4767f058a0baa895013fbb3e"}, - {file = "black-24.10.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:5a2221696a8224e335c28816a9d331a6c2ae15a2ee34ec857dcf3e45dbfa99ad"}, - {file = "black-24.10.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f9da3333530dbcecc1be13e69c250ed8dfa67f43c4005fb537bb426e19200d50"}, - {file = "black-24.10.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4007b1393d902b48b36958a216c20c4482f601569d19ed1df294a496eb366392"}, - {file = "black-24.10.0-cp311-cp311-win_amd64.whl", hash = "sha256:394d4ddc64782e51153eadcaaca95144ac4c35e27ef9b0a42e121ae7e57a9175"}, - {file = "black-24.10.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b5e39e0fae001df40f95bd8cc36b9165c5e2ea88900167bddf258bacef9bbdc3"}, - {file = "black-24.10.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:d37d422772111794b26757c5b55a3eade028aa3fde43121ab7b673d050949d65"}, - {file = "black-24.10.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:14b3502784f09ce2443830e3133dacf2c0110d45191ed470ecb04d0f5f6fcb0f"}, - {file = "black-24.10.0-cp312-cp312-win_amd64.whl", hash = "sha256:30d2c30dc5139211dda799758559d1b049f7f14c580c409d6ad925b74a4208a8"}, - {file = "black-24.10.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1cbacacb19e922a1d75ef2b6ccaefcd6e93a2c05ede32f06a21386a04cedb981"}, - {file = "black-24.10.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:1f93102e0c5bb3907451063e08b9876dbeac810e7da5a8bfb7aeb5a9ef89066b"}, - {file = "black-24.10.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ddacb691cdcdf77b96f549cf9591701d8db36b2f19519373d60d31746068dbf2"}, - {file = "black-24.10.0-cp313-cp313-win_amd64.whl", hash = "sha256:680359d932801c76d2e9c9068d05c6b107f2584b2a5b88831c83962eb9984c1b"}, - {file = "black-24.10.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:17374989640fbca88b6a448129cd1745c5eb8d9547b464f281b251dd00155ccd"}, - {file = "black-24.10.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:63f626344343083322233f175aaf372d326de8436f5928c042639a4afbbf1d3f"}, - {file = "black-24.10.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfa1d0cb6200857f1923b602f978386a3a2758a65b52e0950299ea014be6800"}, - {file = "black-24.10.0-cp39-cp39-win_amd64.whl", hash = "sha256:2cd9c95431d94adc56600710f8813ee27eea544dd118d45896bb734e9d7a0dc7"}, - {file = "black-24.10.0-py3-none-any.whl", hash = "sha256:3bb2b7a1f7b685f85b11fed1ef10f8a9148bceb49853e47a294a3dd963c1dd7d"}, - {file = "black-24.10.0.tar.gz", hash = "sha256:846ea64c97afe3bc677b761787993be4991810ecc7a4a937816dd6bddedc4875"}, + {file = "black-25.1.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:759e7ec1e050a15f89b770cefbf91ebee8917aac5c20483bc2d80a6c3a04df32"}, + {file = "black-25.1.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:0e519ecf93120f34243e6b0054db49c00a35f84f195d5bce7e9f5cfc578fc2da"}, + {file = "black-25.1.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:055e59b198df7ac0b7efca5ad7ff2516bca343276c466be72eb04a3bcc1f82d7"}, + {file = "black-25.1.0-cp310-cp310-win_amd64.whl", hash = "sha256:db8ea9917d6f8fc62abd90d944920d95e73c83a5ee3383493e35d271aca872e9"}, + {file = "black-25.1.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a39337598244de4bae26475f77dda852ea00a93bd4c728e09eacd827ec929df0"}, + {file = "black-25.1.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:96c1c7cd856bba8e20094e36e0f948718dc688dba4a9d78c3adde52b9e6c2299"}, + {file = "black-25.1.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bce2e264d59c91e52d8000d507eb20a9aca4a778731a08cfff7e5ac4a4bb7096"}, + {file = "black-25.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:172b1dbff09f86ce6f4eb8edf9dede08b1fce58ba194c87d7a4f1a5aa2f5b3c2"}, + {file = "black-25.1.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4b60580e829091e6f9238c848ea6750efed72140b91b048770b64e74fe04908b"}, + {file = "black-25.1.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1e2978f6df243b155ef5fa7e558a43037c3079093ed5d10fd84c43900f2d8ecc"}, + {file = "black-25.1.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3b48735872ec535027d979e8dcb20bf4f70b5ac75a8ea99f127c106a7d7aba9f"}, + {file = "black-25.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:ea0213189960bda9cf99be5b8c8ce66bb054af5e9e861249cd23471bd7b0b3ba"}, + {file = "black-25.1.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8f0b18a02996a836cc9c9c78e5babec10930862827b1b724ddfe98ccf2f2fe4f"}, + {file = "black-25.1.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:afebb7098bfbc70037a053b91ae8437c3857482d3a690fefc03e9ff7aa9a5fd3"}, + {file = "black-25.1.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:030b9759066a4ee5e5aca28c3c77f9c64789cdd4de8ac1df642c40b708be6171"}, + {file = "black-25.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:a22f402b410566e2d1c950708c77ebf5ebd5d0d88a6a2e87c86d9fb48afa0d18"}, + {file = "black-25.1.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:a1ee0a0c330f7b5130ce0caed9936a904793576ef4d2b98c40835d6a65afa6a0"}, + {file = "black-25.1.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:f3df5f1bf91d36002b0a75389ca8663510cf0531cca8aa5c1ef695b46d98655f"}, + {file = "black-25.1.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d9e6827d563a2c820772b32ce8a42828dc6790f095f441beef18f96aa6f8294e"}, + {file = "black-25.1.0-cp39-cp39-win_amd64.whl", hash = "sha256:bacabb307dca5ebaf9c118d2d2f6903da0d62c9faa82bd21a33eecc319559355"}, + {file = "black-25.1.0-py3-none-any.whl", hash = "sha256:95e8176dae143ba9097f351d174fdaf0ccd29efb414b362ae3fd72bf0f710717"}, + {file = "black-25.1.0.tar.gz", hash = "sha256:33496d5cd1222ad73391352b4ae8da15253c5de89b93a80b3e2c8d9a19ec2666"}, ] [package.dependencies] From a99bd94717bd6ff3c58d099f0e69a4fa4543b036 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 3 Mar 2025 12:01:40 +0000 Subject: [PATCH 275/395] fix(deps): update dependency ua-parser-js to v2 (#14301) * fix(deps): update dependency ua-parser-js to v2 * fix: breaking changes from ua-parsed-js major update --------- Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Co-authored-by: Zack Pollard --- server/package-lock.json | 75 +++++++++++++++++-- server/package.json | 2 +- .../user-settings-page/device-card.svelte | 4 +- 3 files changed, 73 insertions(+), 8 deletions(-) diff --git a/server/package-lock.json b/server/package-lock.json index 4be6f957ff..2f14a9482a 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -66,7 +66,7 @@ "tailwindcss-preset-email": "^1.3.2", "thumbhash": "^0.1.1", "typeorm": "^0.3.17", - "ua-parser-js": "^1.0.35", + "ua-parser-js": "^2.0.0", "validator": "^13.12.0" }, "devDependencies": { @@ -8491,6 +8491,26 @@ "npm": "1.2.8000 || >= 1.4.16" } }, + "node_modules/detect-europe-js": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/detect-europe-js/-/detect-europe-js-0.1.2.tgz", + "integrity": "sha512-lgdERlL3u0aUdHocoouzT10d9I89VVhk0qNRmll7mXdGfJT1/wqZ2ZLA4oJAjeACPY5fT1wsbq2AT+GkuInsow==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/faisalman" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/ua-parser-js" + }, + { + "type": "paypal", + "url": "https://paypal.me/faisalman" + } + ], + "license": "MIT" + }, "node_modules/detect-libc": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.3.tgz", @@ -10785,6 +10805,26 @@ "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", "license": "MIT" }, + "node_modules/is-standalone-pwa": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/is-standalone-pwa/-/is-standalone-pwa-0.1.1.tgz", + "integrity": "sha512-9Cbovsa52vNQCjdXOzeQq5CnCbAcRk05aU62K20WO372NrTv0NxibLFCK6lQ4/iZEFdEA3p3t2VNOn8AJ53F5g==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/faisalman" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/ua-parser-js" + }, + { + "type": "paypal", + "url": "https://paypal.me/faisalman" + } + ], + "license": "MIT" + }, "node_modules/is-stream": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", @@ -15913,10 +15953,30 @@ "node": ">=14.17" } }, + "node_modules/ua-is-frozen": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/ua-is-frozen/-/ua-is-frozen-0.1.2.tgz", + "integrity": "sha512-RwKDW2p3iyWn4UbaxpP2+VxwqXh0jpvdxsYpZ5j/MLLiQOfbsV5shpgQiw93+KMYQPcteeMQ289MaAFzs3G9pw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/faisalman" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/ua-parser-js" + }, + { + "type": "paypal", + "url": "https://paypal.me/faisalman" + } + ], + "license": "MIT" + }, "node_modules/ua-parser-js": { - "version": "1.0.40", - "resolved": "https://registry.npmjs.org/ua-parser-js/-/ua-parser-js-1.0.40.tgz", - "integrity": "sha512-z6PJ8Lml+v3ichVojCiB8toQJBuwR42ySM4ezjXIqXK3M0HczmKQ3LF4rhU55PfD99KEEXQG6yb7iOMyvYuHew==", + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/ua-parser-js/-/ua-parser-js-2.0.1.tgz", + "integrity": "sha512-PgWLeyhIgff0Jomd3U2cYCdfp5iHbaCMlylG9NoV19tAlvXWUzM3bG2DIasLTI1PrbLtVutGr1CaezttVV2PeA==", "funding": [ { "type": "opencollective", @@ -15931,7 +15991,12 @@ "url": "https://github.com/sponsors/faisalman" } ], - "license": "MIT", + "license": "AGPL-3.0-or-later", + "dependencies": { + "detect-europe-js": "^0.1.2", + "is-standalone-pwa": "^0.1.1", + "ua-is-frozen": "^0.1.2" + }, "bin": { "ua-parser-js": "script/cli.js" }, diff --git a/server/package.json b/server/package.json index 651a04eb0f..a4fb4c5f4c 100644 --- a/server/package.json +++ b/server/package.json @@ -92,7 +92,7 @@ "tailwindcss-preset-email": "^1.3.2", "thumbhash": "^0.1.1", "typeorm": "^0.3.17", - "ua-parser-js": "^1.0.35", + "ua-parser-js": "^2.0.0", "validator": "^13.12.0" }, "devDependencies": { diff --git a/web/src/lib/components/user-settings-page/device-card.svelte b/web/src/lib/components/user-settings-page/device-card.svelte index 5248a6d119..5b70b006be 100644 --- a/web/src/lib/components/user-settings-page/device-card.svelte +++ b/web/src/lib/components/user-settings-page/device-card.svelte @@ -34,7 +34,7 @@