From 3e3ca4c104bdc16db40500add1c4fd6d3f4cc039 Mon Sep 17 00:00:00 2001 From: Tijs Date: Fri, 28 Nov 2025 12:40:33 +0100 Subject: [PATCH 01/33] feat(server): exclude syncthing folders from external libraries (#24240) * Add SyncThing folders to External library exclusion SyncThing is a popular library for syncing files (like pictures) between systems. It can really mess up your library if an external library, which is also used by SyncThing, is added and these folders are not excluded. * Plural * fix formatting --------- Co-authored-by: Jonathan Jogenfors --- server/src/services/library.service.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/server/src/services/library.service.ts b/server/src/services/library.service.ts index 5f78fa3629..841fa4743c 100644 --- a/server/src/services/library.service.ts +++ b/server/src/services/library.service.ts @@ -223,7 +223,14 @@ export class LibraryService extends BaseService { ownerId: dto.ownerId, name: dto.name ?? 'New External Library', importPaths: dto.importPaths ?? [], - exclusionPatterns: dto.exclusionPatterns ?? ['**/@eaDir/**', '**/._*', '**/#recycle/**', '**/#snapshot/**'], + exclusionPatterns: dto.exclusionPatterns ?? [ + '**/@eaDir/**', + '**/._*', + '**/#recycle/**', + '**/#snapshot/**', + '**/.stversions/**', + '**/.stfolder/**', + ], }); return mapLibrary(library); } From d38305360c56a01bb5f108fb02f72f3c4934af7c Mon Sep 17 00:00:00 2001 From: Dionysius <1341084+dionysius@users.noreply.github.com> Date: Fri, 28 Nov 2025 13:43:48 +0100 Subject: [PATCH 02/33] docs: DB_STORAGE_TYPE is only used by the database container (#24215) Co-authored-by: Daniel Dietzler --- docs/docs/install/environment-variables.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/docs/install/environment-variables.md b/docs/docs/install/environment-variables.md index 8863a13ee7..c3e9b3acd3 100644 --- a/docs/docs/install/environment-variables.md +++ b/docs/docs/install/environment-variables.md @@ -80,7 +80,7 @@ Information on the current workers can be found [here](/administration/jobs-work | `DB_SSL_MODE` | Database SSL mode | | server | | `DB_VECTOR_EXTENSION`\*2 | Database vector extension (one of [`vectorchord`, `pgvector`, `pgvecto.rs`]) | | server | | `DB_SKIP_MIGRATIONS` | Whether to skip running migrations on startup (one of [`true`, `false`]) | `false` | server | -| `DB_STORAGE_TYPE` | Optimize concurrent IO on SSDs or sequential IO on HDDs ([`SSD`, `HDD`])\*3 | `SSD` | server | +| `DB_STORAGE_TYPE` | Optimize concurrent IO on SSDs or sequential IO on HDDs ([`SSD`, `HDD`])\*3 | `SSD` | database | \*1: The values of `DB_USERNAME`, `DB_PASSWORD`, and `DB_DATABASE_NAME` are passed to the Postgres container as the variables `POSTGRES_USER`, `POSTGRES_PASSWORD`, and `POSTGRES_DB` in `docker-compose.yml`. From e98a33cf9ddc5b20cb7de6d3e8e78ca72d66b4ab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Robert=20Sch=C3=A4fer?= Date: Fri, 28 Nov 2025 15:11:17 +0100 Subject: [PATCH 03/33] fix(docs): build `cli` for e2e tests (#24184) --- docs/docs/developer/testing.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/docs/developer/testing.md b/docs/docs/developer/testing.md index e1b96f9164..d7c9edcd31 100644 --- a/docs/docs/developer/testing.md +++ b/docs/docs/developer/testing.md @@ -18,6 +18,7 @@ make e2e Before you can run the tests, you need to run the following commands _once_: - `pnpm install` (in `e2e/`) +- `pnpm run build` (in `cli/`) - `make open-api` (in the project root `/`) Once the test environment is running, the e2e tests can be run via: From 0c1fe35f2f6c7cd17f899edb4acee2c3475cb8fb Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Fri, 28 Nov 2025 15:17:25 +0100 Subject: [PATCH 04/33] chore(deps): update dependency terragrunt to v0.93.10 (#24149) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- deployment/mise.toml | 2 +- mise.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/deployment/mise.toml b/deployment/mise.toml index f3d07ac31f..39c6d3dd32 100644 --- a/deployment/mise.toml +++ b/deployment/mise.toml @@ -1,5 +1,5 @@ [tools] -terragrunt = "0.91.2" +terragrunt = "0.93.10" opentofu = "1.10.6" [tasks."tg:fmt"] diff --git a/mise.toml b/mise.toml index d24893575a..39e8fed878 100644 --- a/mise.toml +++ b/mise.toml @@ -4,7 +4,7 @@ experimental_monorepo_root = true node = "24.11.1" flutter = "3.35.7" pnpm = "10.22.0" -terragrunt = "0.91.2" +terragrunt = "0.93.10" opentofu = "1.10.6" java = "25.0.1" From 1f6eb662e5a1ba8dc05b1fac0d8b780348831197 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Fri, 28 Nov 2025 14:41:23 +0000 Subject: [PATCH 05/33] chore(deps): update dependency opentofu to v1.10.7 (#23964) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- deployment/mise.toml | 2 +- mise.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/deployment/mise.toml b/deployment/mise.toml index 39c6d3dd32..53b683a7d3 100644 --- a/deployment/mise.toml +++ b/deployment/mise.toml @@ -1,6 +1,6 @@ [tools] terragrunt = "0.93.10" -opentofu = "1.10.6" +opentofu = "1.10.7" [tasks."tg:fmt"] run = "terragrunt hclfmt" diff --git a/mise.toml b/mise.toml index 39e8fed878..5f3aef61de 100644 --- a/mise.toml +++ b/mise.toml @@ -5,7 +5,7 @@ node = "24.11.1" flutter = "3.35.7" pnpm = "10.22.0" terragrunt = "0.93.10" -opentofu = "1.10.6" +opentofu = "1.10.7" java = "25.0.1" [tools."github:CQLabs/homebrew-dcm"] From f12f609038cb2dc1c8dec38af458e015ad9f0e94 Mon Sep 17 00:00:00 2001 From: Yaros Date: Fri, 28 Nov 2025 17:18:44 +0100 Subject: [PATCH 06/33] fix(mobile): enable backup text overflows (#24227) --- .../widgets/backup/backup_toggle_button.widget.dart | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/mobile/lib/presentation/widgets/backup/backup_toggle_button.widget.dart b/mobile/lib/presentation/widgets/backup/backup_toggle_button.widget.dart index 8d374f74ff..ae4cfbd1c6 100644 --- a/mobile/lib/presentation/widgets/backup/backup_toggle_button.widget.dart +++ b/mobile/lib/presentation/widgets/backup/backup_toggle_button.widget.dart @@ -143,11 +143,13 @@ class BackupToggleButtonState extends ConsumerState with Sin Row( crossAxisAlignment: CrossAxisAlignment.center, children: [ - Text( - "enable_backup".t(context: context), - style: context.textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.w600, - color: context.primaryColor, + Flexible( + child: Text( + "enable_backup".t(context: context), + style: context.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w600, + color: context.primaryColor, + ), ), ), ], From c0a3b58bba1852a9b16ff8b67847a92c7b7c6c56 Mon Sep 17 00:00:00 2001 From: Daniel Dietzler <36593685+danieldietzler@users.noreply.github.com> Date: Fri, 28 Nov 2025 17:18:49 +0100 Subject: [PATCH 07/33] fix: rare cases of assets not loading in when scrolling backwards (#24245) --- pnpm-lock.yaml | 149 ++++++++++++++++++++++++----------------------- web/package.json | 2 +- 2 files changed, 76 insertions(+), 75 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 828fd80033..e1d38ac3a1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -718,7 +718,7 @@ importers: version: link:../open-api/typescript-sdk '@immich/ui': specifier: ^0.49.2 - version: 0.49.2(svelte@5.43.12) + version: 0.49.2(svelte@5.45.2) '@mapbox/mapbox-gl-rtl-text': specifier: 0.2.3 version: 0.2.3(mapbox-gl@1.13.3) @@ -751,7 +751,7 @@ importers: version: 0.41.3 '@zoom-image/svelte': specifier: ^0.3.0 - version: 0.3.7(svelte@5.43.12) + version: 0.3.7(svelte@5.45.2) async-mutex: specifier: ^0.5.0 version: 0.5.0 @@ -805,13 +805,13 @@ importers: version: 5.2.2 svelte-i18n: specifier: ^4.0.1 - version: 4.0.1(svelte@5.43.12) + version: 4.0.1(svelte@5.45.2) svelte-maplibre: specifier: ^1.2.5 - version: 1.2.5(svelte@5.43.12) + version: 1.2.5(svelte@5.45.2) svelte-persisted-store: specifier: ^0.12.0 - version: 0.12.0(svelte@5.43.12) + version: 0.12.0(svelte@5.45.2) tabbable: specifier: ^6.2.0 version: 6.3.0 @@ -833,16 +833,16 @@ importers: version: 3.1.2 '@sveltejs/adapter-static': specifier: ^3.0.8 - version: 3.0.10(@sveltejs/kit@2.48.5(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.43.12)(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1)))(svelte@5.43.12)(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1))) + version: 3.0.10(@sveltejs/kit@2.48.5(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.45.2)(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1)))(svelte@5.45.2)(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1))) '@sveltejs/enhanced-img': specifier: ^0.8.0 - version: 0.8.5(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.43.12)(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1)))(rollup@4.53.3)(svelte@5.43.12)(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1)) + version: 0.8.5(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.45.2)(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1)))(rollup@4.53.3)(svelte@5.45.2)(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1)) '@sveltejs/kit': specifier: ^2.27.1 - version: 2.48.5(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.43.12)(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1)))(svelte@5.43.12)(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1)) + version: 2.48.5(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.45.2)(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1)))(svelte@5.45.2)(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1)) '@sveltejs/vite-plugin-svelte': specifier: 6.2.1 - version: 6.2.1(svelte@5.43.12)(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1)) + version: 6.2.1(svelte@5.45.2)(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1)) '@tailwindcss/vite': specifier: ^4.1.7 version: 4.1.17(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1)) @@ -851,7 +851,7 @@ importers: version: 6.9.1 '@testing-library/svelte': specifier: ^5.2.8 - version: 5.2.9(svelte@5.43.12)(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1))(vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.10.1)(happy-dom@20.0.10)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1)) + version: 5.2.9(svelte@5.45.2)(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1))(vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.10.1)(happy-dom@20.0.10)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1)) '@testing-library/user-event': specifier: ^14.5.2 version: 14.6.1(@testing-library/dom@10.4.1) @@ -890,7 +890,7 @@ importers: version: 6.0.2(eslint@9.39.1(jiti@2.6.1)) eslint-plugin-svelte: specifier: ^3.12.4 - version: 3.13.0(eslint@9.39.1(jiti@2.6.1))(svelte@5.43.12) + version: 3.13.0(eslint@9.39.1(jiti@2.6.1))(svelte@5.45.2) eslint-plugin-unicorn: specifier: ^62.0.0 version: 62.0.0(eslint@9.39.1(jiti@2.6.1)) @@ -911,19 +911,19 @@ importers: version: 4.1.1(prettier@3.6.2) prettier-plugin-svelte: specifier: ^3.3.3 - version: 3.4.0(prettier@3.6.2)(svelte@5.43.12) + version: 3.4.0(prettier@3.6.2)(svelte@5.45.2) rollup-plugin-visualizer: specifier: ^6.0.0 version: 6.0.5(rollup@4.53.3) svelte: - specifier: 5.43.12 - version: 5.43.12 + specifier: 5.45.2 + version: 5.45.2 svelte-check: specifier: ^4.1.5 - version: 4.3.4(picomatch@4.0.3)(svelte@5.43.12)(typescript@5.9.3) + version: 4.3.4(picomatch@4.0.3)(svelte@5.45.2)(typescript@5.9.3) svelte-eslint-parser: specifier: ^1.3.3 - version: 1.4.0(svelte@5.43.12) + version: 1.4.0(svelte@5.45.2) tailwindcss: specifier: ^4.1.7 version: 4.1.17 @@ -6762,8 +6762,8 @@ packages: resolution: {integrity: sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==} engines: {node: '>=0.10'} - esrap@2.1.0: - resolution: {integrity: sha512-yzmPNpl7TBbMRC5Lj2JlJZNPml0tzqoqP5B1JXycNUwtqma9AKCO0M2wHrdgsHcy1WRW7S9rJknAMtByg3usgA==} + esrap@2.2.0: + resolution: {integrity: sha512-WBmtxe7R9C5mvL4n2le8nMUe4mD5V9oiK2vJpQ9I3y20ENPUomPcphBXE8D1x/Bm84oN1V+lOfgXxtqmxTp3Xg==} esrecurse@4.3.0: resolution: {integrity: sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==} @@ -10772,8 +10772,8 @@ packages: peerDependencies: svelte: ^5.30.2 - svelte@5.43.12: - resolution: {integrity: sha512-d1R+3pFa39LXoHCsxHmV//D2pSFZlEMlnxCVQ54TlrQv+4o5pewJO0/Pc5MUp+j71PJrOrPJHTvREZJHn+ymDQ==} + svelte@5.45.2: + resolution: {integrity: sha512-yyXdW2u3H0H/zxxWoGwJoQlRgaSJLp+Vhktv12iRw2WRDlKqUPT54Fi0K/PkXqrdkcQ98aBazpy0AH4BCBVfoA==} engines: {node: '>=18'} svg-parser@2.0.4: @@ -14695,19 +14695,19 @@ snapshots: '@immich/justified-layout-wasm@0.4.3': {} - '@immich/svelte-markdown-preprocess@0.1.0(svelte@5.43.12)': + '@immich/svelte-markdown-preprocess@0.1.0(svelte@5.45.2)': dependencies: - svelte: 5.43.12 + svelte: 5.45.2 - '@immich/ui@0.49.2(svelte@5.43.12)': + '@immich/ui@0.49.2(svelte@5.45.2)': dependencies: - '@immich/svelte-markdown-preprocess': 0.1.0(svelte@5.43.12) + '@immich/svelte-markdown-preprocess': 0.1.0(svelte@5.45.2) '@internationalized/date': 3.10.0 '@mdi/js': 7.4.47 - bits-ui: 2.9.8(@internationalized/date@3.10.0)(svelte@5.43.12) + bits-ui: 2.9.8(@internationalized/date@3.10.0)(svelte@5.45.2) luxon: 3.7.2 simple-icons: 15.21.0 - svelte: 5.43.12 + svelte: 5.45.2 svelte-highlight: 7.8.4 tailwind-merge: 3.3.1 tailwind-variants: 3.1.1(tailwind-merge@3.3.1)(tailwindcss@4.1.17) @@ -16208,17 +16208,17 @@ snapshots: dependencies: acorn: 8.15.0 - '@sveltejs/adapter-static@3.0.10(@sveltejs/kit@2.48.5(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.43.12)(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1)))(svelte@5.43.12)(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1)))': + '@sveltejs/adapter-static@3.0.10(@sveltejs/kit@2.48.5(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.45.2)(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1)))(svelte@5.45.2)(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1)))': dependencies: - '@sveltejs/kit': 2.48.5(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.43.12)(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1)))(svelte@5.43.12)(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1)) + '@sveltejs/kit': 2.48.5(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.45.2)(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1)))(svelte@5.45.2)(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1)) - '@sveltejs/enhanced-img@0.8.5(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.43.12)(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1)))(rollup@4.53.3)(svelte@5.43.12)(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1))': + '@sveltejs/enhanced-img@0.8.5(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.45.2)(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1)))(rollup@4.53.3)(svelte@5.45.2)(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1))': dependencies: - '@sveltejs/vite-plugin-svelte': 6.2.1(svelte@5.43.12)(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1)) + '@sveltejs/vite-plugin-svelte': 6.2.1(svelte@5.45.2)(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1)) magic-string: 0.30.21 sharp: 0.34.5 - svelte: 5.43.12 - svelte-parse-markup: 0.1.5(svelte@5.43.12) + svelte: 5.45.2 + svelte-parse-markup: 0.1.5(svelte@5.45.2) vite: 7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1) vite-imagetools: 8.0.0(rollup@4.53.3) zimmerframe: 1.1.4 @@ -16226,11 +16226,11 @@ snapshots: - rollup - supports-color - '@sveltejs/kit@2.48.5(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.43.12)(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1)))(svelte@5.43.12)(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1))': + '@sveltejs/kit@2.48.5(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.45.2)(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1)))(svelte@5.45.2)(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1))': dependencies: '@standard-schema/spec': 1.0.0 '@sveltejs/acorn-typescript': 1.0.7(acorn@8.15.0) - '@sveltejs/vite-plugin-svelte': 6.2.1(svelte@5.43.12)(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1)) + '@sveltejs/vite-plugin-svelte': 6.2.1(svelte@5.45.2)(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1)) '@types/cookie': 0.6.0 acorn: 8.15.0 cookie: 0.6.0 @@ -16242,27 +16242,27 @@ snapshots: sade: 1.8.1 set-cookie-parser: 2.7.2 sirv: 3.0.2 - svelte: 5.43.12 + svelte: 5.45.2 vite: 7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1) optionalDependencies: '@opentelemetry/api': 1.9.0 - '@sveltejs/vite-plugin-svelte-inspector@5.0.0(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.43.12)(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1)))(svelte@5.43.12)(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1))': + '@sveltejs/vite-plugin-svelte-inspector@5.0.0(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.45.2)(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1)))(svelte@5.45.2)(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1))': dependencies: - '@sveltejs/vite-plugin-svelte': 6.2.1(svelte@5.43.12)(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1)) + '@sveltejs/vite-plugin-svelte': 6.2.1(svelte@5.45.2)(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1)) debug: 4.4.3 - svelte: 5.43.12 + svelte: 5.45.2 vite: 7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1) transitivePeerDependencies: - supports-color - '@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.43.12)(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1))': + '@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.45.2)(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1))': dependencies: - '@sveltejs/vite-plugin-svelte-inspector': 5.0.0(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.43.12)(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1)))(svelte@5.43.12)(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1)) + '@sveltejs/vite-plugin-svelte-inspector': 5.0.0(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.45.2)(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1)))(svelte@5.45.2)(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1)) debug: 4.4.3 deepmerge: 4.3.1 magic-string: 0.30.21 - svelte: 5.43.12 + svelte: 5.45.2 vite: 7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1) vitefu: 1.1.1(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1)) transitivePeerDependencies: @@ -16510,10 +16510,10 @@ snapshots: picocolors: 1.1.1 redent: 3.0.0 - '@testing-library/svelte@5.2.9(svelte@5.43.12)(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1))(vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.10.1)(happy-dom@20.0.10)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1))': + '@testing-library/svelte@5.2.9(svelte@5.45.2)(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1))(vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.10.1)(happy-dom@20.0.10)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1))': dependencies: '@testing-library/dom': 10.4.1 - svelte: 5.43.12 + svelte: 5.45.2 optionalDependencies: vite: 7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1) vitest: 3.2.4(@types/debug@4.1.12)(@types/node@24.10.1)(happy-dom@20.0.10)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1) @@ -17240,10 +17240,10 @@ snapshots: dependencies: '@namnode/store': 0.1.0 - '@zoom-image/svelte@0.3.7(svelte@5.43.12)': + '@zoom-image/svelte@0.3.7(svelte@5.45.2)': dependencies: '@zoom-image/core': 0.41.3 - svelte: 5.43.12 + svelte: 5.45.2 abab@2.0.6: optional: true @@ -17606,15 +17606,15 @@ snapshots: binary-extensions@2.3.0: {} - bits-ui@2.9.8(@internationalized/date@3.10.0)(svelte@5.43.12): + bits-ui@2.9.8(@internationalized/date@3.10.0)(svelte@5.45.2): dependencies: '@floating-ui/core': 1.7.3 '@floating-ui/dom': 1.7.4 '@internationalized/date': 3.10.0 esm-env: 1.2.2 - runed: 0.29.2(svelte@5.43.12) - svelte: 5.43.12 - svelte-toolbelt: 0.9.3(svelte@5.43.12) + runed: 0.29.2(svelte@5.45.2) + svelte: 5.45.2 + svelte-toolbelt: 0.9.3(svelte@5.45.2) tabbable: 6.3.0 bl@4.1.0: @@ -18921,7 +18921,7 @@ snapshots: '@types/eslint': 9.6.1 eslint-config-prettier: 10.1.8(eslint@9.39.1(jiti@2.6.1)) - eslint-plugin-svelte@3.13.0(eslint@9.39.1(jiti@2.6.1))(svelte@5.43.12): + eslint-plugin-svelte@3.13.0(eslint@9.39.1(jiti@2.6.1))(svelte@5.45.2): dependencies: '@eslint-community/eslint-utils': 4.9.0(eslint@9.39.1(jiti@2.6.1)) '@jridgewell/sourcemap-codec': 1.5.5 @@ -18933,9 +18933,9 @@ snapshots: postcss-load-config: 3.1.4(postcss@8.5.6) postcss-safe-parser: 7.0.1(postcss@8.5.6) semver: 7.7.3 - svelte-eslint-parser: 1.4.0(svelte@5.43.12) + svelte-eslint-parser: 1.4.0(svelte@5.45.2) optionalDependencies: - svelte: 5.43.12 + svelte: 5.45.2 transitivePeerDependencies: - ts-node @@ -19037,7 +19037,7 @@ snapshots: dependencies: estraverse: 5.3.0 - esrap@2.1.0: + esrap@2.2.0: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 @@ -22623,10 +22623,10 @@ snapshots: dependencies: prettier: 3.6.2 - prettier-plugin-svelte@3.4.0(prettier@3.6.2)(svelte@5.43.12): + prettier-plugin-svelte@3.4.0(prettier@3.6.2)(svelte@5.45.2): dependencies: prettier: 3.6.2 - svelte: 5.43.12 + svelte: 5.45.2 prettier@3.6.2: {} @@ -23232,10 +23232,10 @@ snapshots: dependencies: queue-microtask: 1.2.3 - runed@0.29.2(svelte@5.43.12): + runed@0.29.2(svelte@5.45.2): dependencies: esm-env: 1.2.2 - svelte: 5.43.12 + svelte: 5.45.2 rw@1.3.3: {} @@ -23851,19 +23851,19 @@ snapshots: supports-preserve-symlinks-flag@1.0.0: {} - svelte-check@4.3.4(picomatch@4.0.3)(svelte@5.43.12)(typescript@5.9.3): + svelte-check@4.3.4(picomatch@4.0.3)(svelte@5.45.2)(typescript@5.9.3): dependencies: '@jridgewell/trace-mapping': 0.3.31 chokidar: 4.0.3 fdir: 6.5.0(picomatch@4.0.3) picocolors: 1.1.1 sade: 1.8.1 - svelte: 5.43.12 + svelte: 5.45.2 typescript: 5.9.3 transitivePeerDependencies: - picomatch - svelte-eslint-parser@1.4.0(svelte@5.43.12): + svelte-eslint-parser@1.4.0(svelte@5.45.2): dependencies: eslint-scope: 8.4.0 eslint-visitor-keys: 4.2.1 @@ -23872,7 +23872,7 @@ snapshots: postcss-scss: 4.0.9(postcss@8.5.6) postcss-selector-parser: 7.1.0 optionalDependencies: - svelte: 5.43.12 + svelte: 5.45.2 svelte-gestures@5.2.2: {} @@ -23880,7 +23880,7 @@ snapshots: dependencies: highlight.js: 11.11.1 - svelte-i18n@4.0.1(svelte@5.43.12): + svelte-i18n@4.0.1(svelte@5.45.2): dependencies: cli-color: 2.0.4 deepmerge: 4.3.1 @@ -23888,34 +23888,34 @@ snapshots: estree-walker: 2.0.2 intl-messageformat: 10.7.18 sade: 1.8.1 - svelte: 5.43.12 + svelte: 5.45.2 tiny-glob: 0.2.9 - svelte-maplibre@1.2.5(svelte@5.43.12): + svelte-maplibre@1.2.5(svelte@5.45.2): dependencies: d3-geo: 3.1.1 dequal: 2.0.3 just-compare: 2.3.0 maplibre-gl: 5.13.0 pmtiles: 3.2.1 - svelte: 5.43.12 + svelte: 5.45.2 - svelte-parse-markup@0.1.5(svelte@5.43.12): + svelte-parse-markup@0.1.5(svelte@5.45.2): dependencies: - svelte: 5.43.12 + svelte: 5.45.2 - svelte-persisted-store@0.12.0(svelte@5.43.12): + svelte-persisted-store@0.12.0(svelte@5.45.2): dependencies: - svelte: 5.43.12 + svelte: 5.45.2 - svelte-toolbelt@0.9.3(svelte@5.43.12): + svelte-toolbelt@0.9.3(svelte@5.45.2): dependencies: clsx: 2.1.1 - runed: 0.29.2(svelte@5.43.12) + runed: 0.29.2(svelte@5.45.2) style-to-object: 1.0.11 - svelte: 5.43.12 + svelte: 5.45.2 - svelte@5.43.12: + svelte@5.45.2: dependencies: '@jridgewell/remapping': 2.3.5 '@jridgewell/sourcemap-codec': 1.5.5 @@ -23925,8 +23925,9 @@ snapshots: aria-query: 5.3.2 axobject-query: 4.1.0 clsx: 2.1.1 + devalue: 5.5.0 esm-env: 1.2.2 - esrap: 2.1.0 + esrap: 2.2.0 is-reference: 3.0.3 locate-character: 3.0.0 magic-string: 0.30.21 diff --git a/web/package.json b/web/package.json index fca762ef34..c44dd6d4b7 100644 --- a/web/package.json +++ b/web/package.json @@ -97,7 +97,7 @@ "prettier-plugin-sort-json": "^4.1.1", "prettier-plugin-svelte": "^3.3.3", "rollup-plugin-visualizer": "^6.0.0", - "svelte": "5.43.12", + "svelte": "5.45.2", "svelte-check": "^4.1.5", "svelte-eslint-parser": "^1.3.3", "tailwindcss": "^4.1.7", From e36261b5529bdc5d0c359c4a3b36fdae9409ab93 Mon Sep 17 00:00:00 2001 From: Mees Frensel <33722705+meesfrensel@users.noreply.github.com> Date: Fri, 28 Nov 2025 18:50:16 +0100 Subject: [PATCH 08/33] fix(web): integrate zoom toggle button into panorama photo viewer (#24189) --- .../asset-viewer/asset-viewer.svelte | 2 +- .../asset-viewer/image-panorama-viewer.svelte | 8 ++-- .../photo-sphere-viewer-adapter.svelte | 39 ++++++++++++++++--- 3 files changed, 40 insertions(+), 9 deletions(-) diff --git a/web/src/lib/components/asset-viewer/asset-viewer.svelte b/web/src/lib/components/asset-viewer/asset-viewer.svelte index 7570278e51..b657f34ece 100644 --- a/web/src/lib/components/asset-viewer/asset-viewer.svelte +++ b/web/src/lib/components/asset-viewer/asset-viewer.svelte @@ -512,7 +512,7 @@ {:else if asset.exifInfo?.projectionType === ProjectionType.EQUIRECTANGULAR || (asset.originalPath && asset.originalPath .toLowerCase() .endsWith('.insp'))} - + {:else if isShowEditor && selectedEditType === 'crop'} {:else} 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 7334aab4d4..08ba43526d 100644 --- a/web/src/lib/components/asset-viewer/image-panorama-viewer.svelte +++ b/web/src/lib/components/asset-viewer/image-panorama-viewer.svelte @@ -7,11 +7,12 @@ import { t } from 'svelte-i18n'; import { fade } from 'svelte/transition'; - interface Props { + type Props = { asset: AssetResponseDto; - } + zoomToggle?: (() => void) | null; + }; - const { asset }: Props = $props(); + let { asset, zoomToggle = $bindable() }: Props = $props(); const loadAssetData = async (id: string) => { const data = await viewAsset({ ...authManager.params, id, size: AssetMediaSize.Preview }); @@ -24,6 +25,7 @@ {:then [data, { default: PhotoSphereViewer }]} + import { shortcuts } from '$lib/actions/shortcut'; import { boundingBoxesArray, type Faces } from '$lib/stores/people.store'; import { alwaysLoadOriginalFile } from '$lib/stores/preferences.store'; + import { photoZoomState } from '$lib/stores/zoom-image.store'; import { EquirectangularAdapter, Viewer, @@ -24,15 +26,23 @@ strokeLinejoin: 'round', }; - interface Props { + type Props = { panorama: string | { source: string }; originalPanorama?: string | { source: string }; adapter?: AdapterConstructor | [AdapterConstructor, unknown]; plugins?: (PluginConstructor | [PluginConstructor, unknown])[]; navbar?: boolean; - } + zoomToggle?: (() => void) | null; + }; - let { panorama, originalPanorama, adapter = EquirectangularAdapter, plugins = [], navbar = false }: Props = $props(); + let { + panorama, + originalPanorama, + adapter = EquirectangularAdapter, + plugins = [], + navbar = false, + zoomToggle = $bindable(), + }: Props = $props(); let container: HTMLDivElement | undefined = $state(); let viewer: Viewer; @@ -93,6 +103,14 @@ } }); + zoomToggle = () => { + if (!viewer) { + return; + } + viewer.animate({ zoom: $photoZoomState.currentZoom > 1 ? 50 : 83.3, speed: 250 }); + }; + + let hasChangedResolution: boolean = false; onMount(() => { if (!container) { return; @@ -139,10 +157,15 @@ const resolutionPlugin = viewer.getPlugin(ResolutionPlugin); const zoomHandler = ({ zoomLevel }: events.ZoomUpdatedEvent) => { // zoomLevel range: [0, 100] - if (Math.round(zoomLevel) >= 75) { + photoZoomState.set({ + ...$photoZoomState, + currentZoom: zoomLevel / 50, + }); + + if (Math.round(zoomLevel) >= 75 && !hasChangedResolution) { // Replace the preview with the original void resolutionPlugin.setResolution('original'); - viewer.removeEventListener(events.ZoomUpdatedEvent.type, zoomHandler); + hasChangedResolution = true; } }; @@ -158,7 +181,13 @@ viewer.destroy(); } boundingBoxesUnsubscribe(); + // zoomHandler is not called on initial load. Viewer initial zoom is 1, but photoZoomState could be != 1. + photoZoomState.set({ + ...$photoZoomState, + currentZoom: 1, + }); }); +
From 08f320c8015da94496f7ca580f079db46151e8b9 Mon Sep 17 00:00:00 2001 From: Niklas von Moers <49690836+NiklasvonM@users.noreply.github.com> Date: Sat, 29 Nov 2025 13:09:32 +0100 Subject: [PATCH 09/33] fix(web): use full tag path when creating nested subtags (#24249) --- web/src/lib/modals/TagCreateModal.svelte | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/src/lib/modals/TagCreateModal.svelte b/web/src/lib/modals/TagCreateModal.svelte index 360681d7b9..5c48e83b08 100644 --- a/web/src/lib/modals/TagCreateModal.svelte +++ b/web/src/lib/modals/TagCreateModal.svelte @@ -14,7 +14,7 @@ const { onClose, baseTag }: Props = $props(); - let tagValue = $state(baseTag?.value ? `${baseTag.value}/` : ''); + let tagValue = $state(baseTag?.path ? `${baseTag.path}/` : ''); const createTag = async () => { const [tag] = await upsertTags({ tagUpsertDto: { tags: [tagValue] } }); From e3ab16a5bd7883605211a18ee8a13aa225f7fddf Mon Sep 17 00:00:00 2001 From: Alex Date: Sun, 30 Nov 2025 12:43:33 -0600 Subject: [PATCH 10/33] chore: refactor mobile events (#24263) chore: refactor mobile evets --- mobile/lib/domain/models/events.model.dart | 32 +++++++++++++++++++ mobile/lib/domain/models/timeline.model.dart | 16 ---------- .../lib/domain/services/timeline.service.dart | 1 + .../drift_backup_asset_detail.page.dart | 2 +- mobile/lib/pages/common/tab_shell.page.dart | 3 +- .../archive_action_button.widget.dart | 2 +- .../delete_action_button.widget.dart | 2 +- .../delete_local_action_button.widget.dart | 2 +- ...delete_permanent_action_button.widget.dart | 2 +- ...e_to_lock_folder_action_button.widget.dart | 2 +- .../trash_action_button.widget.dart | 2 +- .../unarchive_action_button.widget.dart | 2 +- .../asset_viewer/asset_viewer.page.dart | 2 +- .../asset_viewer/asset_viewer.state.dart | 10 ------ .../asset_viewer/top_app_bar.widget.dart | 2 +- .../memory/memory_bottom_info.widget.dart | 2 +- .../widgets/timeline/timeline.widget.dart | 1 + .../timeline/multiselect.provider.dart | 6 ---- .../common/mesmerizing_sliver_app_bar.dart | 2 +- .../widgets/common/person_sliver_app_bar.dart | 2 +- .../common/remote_album_sliver_app_bar.dart | 2 +- 21 files changed, 49 insertions(+), 48 deletions(-) create mode 100644 mobile/lib/domain/models/events.model.dart diff --git a/mobile/lib/domain/models/events.model.dart b/mobile/lib/domain/models/events.model.dart new file mode 100644 index 0000000000..b3ab756414 --- /dev/null +++ b/mobile/lib/domain/models/events.model.dart @@ -0,0 +1,32 @@ +import 'package:immich_mobile/domain/utils/event_stream.dart'; + +// Timeline Events +class TimelineReloadEvent extends Event { + const TimelineReloadEvent(); +} + +class ScrollToTopEvent extends Event { + const ScrollToTopEvent(); +} + +class ScrollToDateEvent extends Event { + final DateTime date; + + const ScrollToDateEvent(this.date); +} + +// Asset Viewer Events +class ViewerOpenBottomSheetEvent extends Event { + final bool activitiesMode; + const ViewerOpenBottomSheetEvent({this.activitiesMode = false}); +} + +class ViewerReloadAssetEvent extends Event { + const ViewerReloadAssetEvent(); +} + +// Multi-Select Events +class MultiSelectToggleEvent extends Event { + final bool isEnabled; + const MultiSelectToggleEvent(this.isEnabled); +} diff --git a/mobile/lib/domain/models/timeline.model.dart b/mobile/lib/domain/models/timeline.model.dart index d4cc5ab5c6..c531fa4a94 100644 --- a/mobile/lib/domain/models/timeline.model.dart +++ b/mobile/lib/domain/models/timeline.model.dart @@ -1,5 +1,3 @@ -import 'package:immich_mobile/domain/utils/event_stream.dart'; - enum GroupAssetsBy { day, month, auto, none } enum HeaderType { none, month, day, monthAndDay } @@ -31,17 +29,3 @@ class TimeBucket extends Bucket { @override int get hashCode => super.hashCode ^ date.hashCode; } - -class TimelineReloadEvent extends Event { - const TimelineReloadEvent(); -} - -class ScrollToTopEvent extends Event { - const ScrollToTopEvent(); -} - -class ScrollToDateEvent extends Event { - final DateTime date; - - const ScrollToDateEvent(this.date); -} diff --git a/mobile/lib/domain/services/timeline.service.dart b/mobile/lib/domain/services/timeline.service.dart index 9537fe667a..96630f1eba 100644 --- a/mobile/lib/domain/services/timeline.service.dart +++ b/mobile/lib/domain/services/timeline.service.dart @@ -4,6 +4,7 @@ import 'dart:math' as math; import 'package:collection/collection.dart'; import 'package:immich_mobile/constants/constants.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; +import 'package:immich_mobile/domain/models/events.model.dart'; import 'package:immich_mobile/domain/models/setting.model.dart'; import 'package:immich_mobile/domain/models/timeline.model.dart'; import 'package:immich_mobile/domain/services/setting.service.dart'; diff --git a/mobile/lib/pages/backup/drift_backup_asset_detail.page.dart b/mobile/lib/pages/backup/drift_backup_asset_detail.page.dart index f3fdccc329..36d51c5624 100644 --- a/mobile/lib/pages/backup/drift_backup_asset_detail.page.dart +++ b/mobile/lib/pages/backup/drift_backup_asset_detail.page.dart @@ -3,7 +3,7 @@ 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/asset/base_asset.model.dart'; -import 'package:immich_mobile/domain/models/timeline.model.dart'; +import 'package:immich_mobile/domain/models/events.model.dart'; import 'package:immich_mobile/domain/utils/event_stream.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/theme_extensions.dart'; diff --git a/mobile/lib/pages/common/tab_shell.page.dart b/mobile/lib/pages/common/tab_shell.page.dart index bbb567bd3b..2fdcec4054 100644 --- a/mobile/lib/pages/common/tab_shell.page.dart +++ b/mobile/lib/pages/common/tab_shell.page.dart @@ -5,7 +5,7 @@ import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/constants/constants.dart'; -import 'package:immich_mobile/domain/models/timeline.model.dart'; +import 'package:immich_mobile/domain/models/events.model.dart'; import 'package:immich_mobile/domain/utils/event_stream.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/presentation/pages/search/paginated_search.provider.dart'; @@ -16,7 +16,6 @@ import 'package:immich_mobile/providers/infrastructure/people.provider.dart'; import 'package:immich_mobile/providers/infrastructure/readonly_mode.provider.dart'; import 'package:immich_mobile/providers/search/search_input_focus.provider.dart'; import 'package:immich_mobile/providers/tab.provider.dart'; -import 'package:immich_mobile/providers/timeline/multiselect.provider.dart'; import 'package:immich_mobile/routing/router.dart'; @RoutePage() diff --git a/mobile/lib/presentation/widgets/action_buttons/archive_action_button.widget.dart b/mobile/lib/presentation/widgets/action_buttons/archive_action_button.widget.dart index 290a19f584..4ba877bcba 100644 --- a/mobile/lib/presentation/widgets/action_buttons/archive_action_button.widget.dart +++ b/mobile/lib/presentation/widgets/action_buttons/archive_action_button.widget.dart @@ -2,10 +2,10 @@ import 'package:flutter/material.dart'; import 'package:fluttertoast/fluttertoast.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/constants/enums.dart'; +import 'package:immich_mobile/domain/models/events.model.dart'; import 'package:immich_mobile/domain/utils/event_stream.dart'; import 'package:immich_mobile/extensions/translate_extensions.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart'; -import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart'; import 'package:immich_mobile/providers/infrastructure/action.provider.dart'; import 'package:immich_mobile/providers/timeline/multiselect.provider.dart'; import 'package:immich_mobile/widgets/common/immich_toast.dart'; diff --git a/mobile/lib/presentation/widgets/action_buttons/delete_action_button.widget.dart b/mobile/lib/presentation/widgets/action_buttons/delete_action_button.widget.dart index 723700af55..8b82e5c839 100644 --- a/mobile/lib/presentation/widgets/action_buttons/delete_action_button.widget.dart +++ b/mobile/lib/presentation/widgets/action_buttons/delete_action_button.widget.dart @@ -2,11 +2,11 @@ import 'package:flutter/material.dart'; import 'package:fluttertoast/fluttertoast.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/constants/enums.dart'; +import 'package:immich_mobile/domain/models/events.model.dart'; import 'package:immich_mobile/domain/utils/event_stream.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/translate_extensions.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart'; -import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart'; import 'package:immich_mobile/providers/infrastructure/action.provider.dart'; import 'package:immich_mobile/providers/timeline/multiselect.provider.dart'; import 'package:immich_mobile/widgets/common/immich_toast.dart'; diff --git a/mobile/lib/presentation/widgets/action_buttons/delete_local_action_button.widget.dart b/mobile/lib/presentation/widgets/action_buttons/delete_local_action_button.widget.dart index 3cd939aeb6..5d8ea8671c 100644 --- a/mobile/lib/presentation/widgets/action_buttons/delete_local_action_button.widget.dart +++ b/mobile/lib/presentation/widgets/action_buttons/delete_local_action_button.widget.dart @@ -2,10 +2,10 @@ import 'package:flutter/material.dart'; import 'package:fluttertoast/fluttertoast.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/constants/enums.dart'; +import 'package:immich_mobile/domain/models/events.model.dart'; import 'package:immich_mobile/domain/utils/event_stream.dart'; import 'package:immich_mobile/extensions/translate_extensions.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart'; -import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart'; import 'package:immich_mobile/providers/infrastructure/action.provider.dart'; import 'package:immich_mobile/providers/timeline/multiselect.provider.dart'; import 'package:immich_mobile/widgets/common/immich_toast.dart'; diff --git a/mobile/lib/presentation/widgets/action_buttons/delete_permanent_action_button.widget.dart b/mobile/lib/presentation/widgets/action_buttons/delete_permanent_action_button.widget.dart index 4979df904c..a0191e2407 100644 --- a/mobile/lib/presentation/widgets/action_buttons/delete_permanent_action_button.widget.dart +++ b/mobile/lib/presentation/widgets/action_buttons/delete_permanent_action_button.widget.dart @@ -2,10 +2,10 @@ import 'package:flutter/material.dart'; import 'package:fluttertoast/fluttertoast.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/constants/enums.dart'; +import 'package:immich_mobile/domain/models/events.model.dart'; import 'package:immich_mobile/domain/utils/event_stream.dart'; import 'package:immich_mobile/extensions/translate_extensions.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart'; -import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart'; import 'package:immich_mobile/providers/infrastructure/action.provider.dart'; import 'package:immich_mobile/providers/timeline/multiselect.provider.dart'; import 'package:immich_mobile/widgets/common/immich_toast.dart'; diff --git a/mobile/lib/presentation/widgets/action_buttons/move_to_lock_folder_action_button.widget.dart b/mobile/lib/presentation/widgets/action_buttons/move_to_lock_folder_action_button.widget.dart index ddc83cb383..20d391c4a6 100644 --- a/mobile/lib/presentation/widgets/action_buttons/move_to_lock_folder_action_button.widget.dart +++ b/mobile/lib/presentation/widgets/action_buttons/move_to_lock_folder_action_button.widget.dart @@ -2,10 +2,10 @@ import 'package:flutter/material.dart'; import 'package:fluttertoast/fluttertoast.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/constants/enums.dart'; +import 'package:immich_mobile/domain/models/events.model.dart'; import 'package:immich_mobile/domain/utils/event_stream.dart'; import 'package:immich_mobile/extensions/translate_extensions.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart'; -import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart'; import 'package:immich_mobile/providers/infrastructure/action.provider.dart'; import 'package:immich_mobile/providers/timeline/multiselect.provider.dart'; import 'package:immich_mobile/widgets/common/immich_toast.dart'; diff --git a/mobile/lib/presentation/widgets/action_buttons/trash_action_button.widget.dart b/mobile/lib/presentation/widgets/action_buttons/trash_action_button.widget.dart index df8f544601..a78ff2ccd8 100644 --- a/mobile/lib/presentation/widgets/action_buttons/trash_action_button.widget.dart +++ b/mobile/lib/presentation/widgets/action_buttons/trash_action_button.widget.dart @@ -2,10 +2,10 @@ import 'package:flutter/material.dart'; import 'package:fluttertoast/fluttertoast.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/constants/enums.dart'; +import 'package:immich_mobile/domain/models/events.model.dart'; import 'package:immich_mobile/domain/utils/event_stream.dart'; import 'package:immich_mobile/extensions/translate_extensions.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart'; -import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart'; import 'package:immich_mobile/providers/infrastructure/action.provider.dart'; import 'package:immich_mobile/providers/timeline/multiselect.provider.dart'; import 'package:immich_mobile/widgets/common/immich_toast.dart'; diff --git a/mobile/lib/presentation/widgets/action_buttons/unarchive_action_button.widget.dart b/mobile/lib/presentation/widgets/action_buttons/unarchive_action_button.widget.dart index 8b04a1b05d..32147a194f 100644 --- a/mobile/lib/presentation/widgets/action_buttons/unarchive_action_button.widget.dart +++ b/mobile/lib/presentation/widgets/action_buttons/unarchive_action_button.widget.dart @@ -9,8 +9,8 @@ import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_bu import 'package:immich_mobile/providers/infrastructure/action.provider.dart'; import 'package:immich_mobile/providers/timeline/multiselect.provider.dart'; import 'package:immich_mobile/widgets/common/immich_toast.dart'; +import 'package:immich_mobile/domain/models/events.model.dart'; import 'package:immich_mobile/domain/utils/event_stream.dart'; -import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart'; // used to allow performing unarchive action from different sources (without duplicating code) Future performUnArchiveAction(BuildContext context, WidgetRef ref, {required ActionSource source}) async { diff --git a/mobile/lib/presentation/widgets/asset_viewer/asset_viewer.page.dart b/mobile/lib/presentation/widgets/asset_viewer/asset_viewer.page.dart index 50c4347301..70eb6699aa 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/asset_viewer.page.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/asset_viewer.page.dart @@ -7,7 +7,7 @@ import 'package:flutter/services.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/domain/models/album/album.model.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; -import 'package:immich_mobile/domain/models/timeline.model.dart'; +import 'package:immich_mobile/domain/models/events.model.dart'; import 'package:immich_mobile/domain/services/timeline.service.dart'; import 'package:immich_mobile/domain/utils/event_stream.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; diff --git a/mobile/lib/presentation/widgets/asset_viewer/asset_viewer.state.dart b/mobile/lib/presentation/widgets/asset_viewer/asset_viewer.state.dart index d0fb1f8ba0..36e5bf67d9 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/asset_viewer.state.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/asset_viewer.state.dart @@ -1,17 +1,7 @@ import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; -import 'package:immich_mobile/domain/utils/event_stream.dart'; import 'package:immich_mobile/providers/asset_viewer/video_player_controls_provider.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; -class ViewerOpenBottomSheetEvent extends Event { - final bool activitiesMode; - const ViewerOpenBottomSheetEvent({this.activitiesMode = false}); -} - -class ViewerReloadAssetEvent extends Event { - const ViewerReloadAssetEvent(); -} - class AssetViewerState { final int backgroundOpacity; final bool showingBottomSheet; diff --git a/mobile/lib/presentation/widgets/asset_viewer/top_app_bar.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/top_app_bar.widget.dart index ab88dffab4..5114ef6fd2 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/top_app_bar.widget.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/top_app_bar.widget.dart @@ -3,7 +3,7 @@ import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/constants/enums.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; -import 'package:immich_mobile/domain/models/timeline.model.dart'; +import 'package:immich_mobile/domain/models/events.model.dart'; import 'package:immich_mobile/domain/services/timeline.service.dart'; import 'package:immich_mobile/domain/utils/event_stream.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; diff --git a/mobile/lib/presentation/widgets/memory/memory_bottom_info.widget.dart b/mobile/lib/presentation/widgets/memory/memory_bottom_info.widget.dart index f067bc6bf3..b514f9f0a5 100644 --- a/mobile/lib/presentation/widgets/memory/memory_bottom_info.widget.dart +++ b/mobile/lib/presentation/widgets/memory/memory_bottom_info.widget.dart @@ -3,8 +3,8 @@ import 'package:auto_route/auto_route.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; +import 'package:immich_mobile/domain/models/events.model.dart'; import 'package:immich_mobile/domain/models/memory.model.dart'; -import 'package:immich_mobile/domain/models/timeline.model.dart'; import 'package:immich_mobile/domain/utils/event_stream.dart'; import 'package:immich_mobile/routing/router.dart'; diff --git a/mobile/lib/presentation/widgets/timeline/timeline.widget.dart b/mobile/lib/presentation/widgets/timeline/timeline.widget.dart index 70dd15bf7f..5868de92aa 100644 --- a/mobile/lib/presentation/widgets/timeline/timeline.widget.dart +++ b/mobile/lib/presentation/widgets/timeline/timeline.widget.dart @@ -9,6 +9,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; +import 'package:immich_mobile/domain/models/events.model.dart'; import 'package:immich_mobile/domain/models/setting.model.dart'; import 'package:immich_mobile/domain/models/timeline.model.dart'; import 'package:immich_mobile/domain/utils/event_stream.dart'; diff --git a/mobile/lib/providers/timeline/multiselect.provider.dart b/mobile/lib/providers/timeline/multiselect.provider.dart index 6949413cd9..0b3f7e610b 100644 --- a/mobile/lib/providers/timeline/multiselect.provider.dart +++ b/mobile/lib/providers/timeline/multiselect.provider.dart @@ -2,7 +2,6 @@ import 'package:collection/collection.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; import 'package:immich_mobile/domain/services/timeline.service.dart'; -import 'package:immich_mobile/domain/utils/event_stream.dart'; import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart'; final multiSelectProvider = NotifierProvider( @@ -10,11 +9,6 @@ final multiSelectProvider = NotifierProvider selectedAssets; final Set lockedSelectionAssets; diff --git a/mobile/lib/widgets/common/mesmerizing_sliver_app_bar.dart b/mobile/lib/widgets/common/mesmerizing_sliver_app_bar.dart index 73dbbfc85b..44b547a5f1 100644 --- a/mobile/lib/widgets/common/mesmerizing_sliver_app_bar.dart +++ b/mobile/lib/widgets/common/mesmerizing_sliver_app_bar.dart @@ -4,7 +4,7 @@ import 'dart:io'; import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; -import 'package:immich_mobile/domain/models/timeline.model.dart'; +import 'package:immich_mobile/domain/models/events.model.dart'; import 'package:immich_mobile/domain/services/timeline.service.dart'; import 'package:immich_mobile/domain/utils/event_stream.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; diff --git a/mobile/lib/widgets/common/person_sliver_app_bar.dart b/mobile/lib/widgets/common/person_sliver_app_bar.dart index 0f9555a101..d5a7ea7cd9 100644 --- a/mobile/lib/widgets/common/person_sliver_app_bar.dart +++ b/mobile/lib/widgets/common/person_sliver_app_bar.dart @@ -6,8 +6,8 @@ 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/asset/base_asset.model.dart'; +import 'package:immich_mobile/domain/models/events.model.dart'; import 'package:immich_mobile/domain/models/person.model.dart'; -import 'package:immich_mobile/domain/models/timeline.model.dart'; import 'package:immich_mobile/domain/services/timeline.service.dart'; import 'package:immich_mobile/domain/utils/event_stream.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; diff --git a/mobile/lib/widgets/common/remote_album_sliver_app_bar.dart b/mobile/lib/widgets/common/remote_album_sliver_app_bar.dart index c0661bad48..c486d473b0 100644 --- a/mobile/lib/widgets/common/remote_album_sliver_app_bar.dart +++ b/mobile/lib/widgets/common/remote_album_sliver_app_bar.dart @@ -7,7 +7,7 @@ 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/asset/base_asset.model.dart'; -import 'package:immich_mobile/domain/models/timeline.model.dart'; +import 'package:immich_mobile/domain/models/events.model.dart'; import 'package:immich_mobile/domain/services/timeline.service.dart'; import 'package:immich_mobile/domain/utils/event_stream.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; From 922282b2b47c21b8fe77a16db34aa16ee75859f4 Mon Sep 17 00:00:00 2001 From: Chris Peckover Date: Sun, 30 Nov 2025 13:56:03 -0500 Subject: [PATCH 11/33] feat(web): Shared album owner labels (#21171) * - pass available album users along to the thumbnail through the asset-date-group - show a small user-avatar in bottom right of thumbnail * - change owner to their name in white text instead of the avatar * cleanup * - cleanup albumUsers creation - use font-light for the user's name * fix lint * format * - add toggle to show/hide asset owner names * update new Timeline with albumUsers * add @idubnori suggestion for the name font * Don't show 'view owners' button if the album doesn't have editors * add missing import * format * fix(web): #21171 (#24298) fix: Bind timelineManager to Timeline component --------- Co-authored-by: idubnori Co-authored-by: Alex --- .../assets/thumbnail/thumbnail.svelte | 14 +++++++++++++- .../lib/components/timeline/Timeline.svelte | 5 ++++- .../[[assetId=id]]/+page.svelte | 19 +++++++++++++++++++ 3 files changed, 36 insertions(+), 2 deletions(-) diff --git a/web/src/lib/components/assets/thumbnail/thumbnail.svelte b/web/src/lib/components/assets/thumbnail/thumbnail.svelte index 0645541241..38d734fc22 100644 --- a/web/src/lib/components/assets/thumbnail/thumbnail.svelte +++ b/web/src/lib/components/assets/thumbnail/thumbnail.svelte @@ -4,7 +4,7 @@ import { getAssetOriginalUrl, getAssetPlaybackUrl, getAssetThumbnailUrl } from '$lib/utils'; import { timeToSeconds } from '$lib/utils/date-time'; import { getAltText } from '$lib/utils/thumbnail-util'; - import { AssetMediaSize, AssetVisibility } from '@immich/sdk'; + import { AssetMediaSize, AssetVisibility, type UserResponseDto } from '@immich/sdk'; import { mdiArchiveArrowDownOutline, mdiCameraBurst, @@ -46,6 +46,7 @@ imageClass?: ClassValue; brokenAssetClass?: ClassValue; dimmed?: boolean; + albumUsers?: UserResponseDto[]; onClick?: (asset: TimelineAsset) => void; onSelect?: (asset: TimelineAsset) => void; onMouseEvent?: (event: { isMouseOver: boolean; selectedGroupIndex: number }) => void; @@ -64,6 +65,7 @@ readonly = false, showArchiveIcon = false, showStackedIcon = true, + albumUsers = [], onClick = undefined, onSelect = undefined, onMouseEvent = undefined, @@ -85,6 +87,8 @@ let width = $derived(thumbnailSize || thumbnailWidth || 235); let height = $derived(thumbnailSize || thumbnailHeight || 235); + let assetOwner = $derived(albumUsers?.find((user) => user.id === asset.ownerId) ?? null); + const onIconClickedHandler = (e?: MouseEvent) => { e?.stopPropagation(); e?.preventDefault(); @@ -268,6 +272,14 @@ {/if} + {#if !!assetOwner} +
+

+ {assetOwner.name} +

+
+ {/if} + {#if !authManager.isSharedLink && showArchiveIcon && asset.visibility === AssetVisibility.Archive}
diff --git a/web/src/lib/components/timeline/Timeline.svelte b/web/src/lib/components/timeline/Timeline.svelte index d2873eca70..ba9cf37bff 100644 --- a/web/src/lib/components/timeline/Timeline.svelte +++ b/web/src/lib/components/timeline/Timeline.svelte @@ -23,7 +23,7 @@ import { mobileDevice } from '$lib/stores/mobile-device.svelte'; import { isAssetViewerRoute, navigate } from '$lib/utils/navigation'; import { getTimes, type ScrubberListener } from '$lib/utils/timeline-util'; - import { type AlbumResponseDto, type PersonResponseDto } from '@immich/sdk'; + import { type AlbumResponseDto, type PersonResponseDto, type UserResponseDto } from '@immich/sdk'; import { DateTime } from 'luxon'; import { onDestroy, onMount, type Snippet } from 'svelte'; import type { UpdatePayload } from 'vite'; @@ -49,6 +49,7 @@ showArchiveIcon?: boolean; isShared?: boolean; album?: AlbumResponseDto | null; + albumUsers?: UserResponseDto[]; person?: PersonResponseDto | null; isShowDeleteConfirmation?: boolean; onSelect?: (asset: TimelineAsset) => void; @@ -81,6 +82,7 @@ showArchiveIcon = false, isShared = false, album = null, + albumUsers = [], person = null, isShowDeleteConfirmation = $bindable(false), onSelect = () => {}, @@ -702,6 +704,7 @@ showStackedIcon={withStacked} {showArchiveIcon} {asset} + {albumUsers} {groupIndex} onClick={(asset) => { if (typeof onThumbnailClick === 'function') { diff --git a/web/src/routes/(user)/albums/[albumId=id]/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/albums/[albumId=id]/[[photos=photos]]/[[assetId=id]]/+page.svelte index b99260eee4..74b0f1d6b3 100644 --- a/web/src/routes/(user)/albums/[albumId=id]/[[photos=photos]]/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/albums/[albumId=id]/[[photos=photos]]/[[assetId=id]]/+page.svelte @@ -66,6 +66,7 @@ } from '@immich/sdk'; import { Button, Icon, IconButton, modalManager, toastManager } from '@immich/ui'; import { + mdiAccountEyeOutline, mdiArrowLeft, mdiCogOutline, mdiDeleteOutline, @@ -100,6 +101,7 @@ let isCreatingSharedAlbum = $state(false); let isShowActivity = $state(false); let albumOrder: AssetOrder | undefined = $state(data.album.order); + let showAlbumUsers = $state(false); const assetInteraction = new AssetInteraction(); const timelineInteraction = new AssetInteraction(); @@ -290,6 +292,11 @@ let album = $derived(data.album); let albumId = $derived(album.id); + const containsEditors = $derived(album?.shared && album.albumUsers.some(({ role }) => role === AlbumUserRole.Editor)); + const albumUsers = $derived( + showAlbumUsers && containsEditors ? [album.owner, ...album.albumUsers.map(({ user }) => user)] : [], + ); + $effect(() => { if (!album.isActivityEnabled && activityManager.commentCount === 0) { isShowActivity = false; @@ -418,6 +425,7 @@ + {#if containsEditors} + (showAlbumUsers = !showAlbumUsers)} + /> + {/if} + {#if isEditor} Date: Mon, 1 Dec 2025 06:01:01 +1100 Subject: [PATCH 12/33] chore: optimisation of several UI components of the mobile app (#24098) * fix(mobile): normalize scrolling behavior in networking settings Remove ClampingScrollPhysics from networking settings page to match the scrolling behavior of other settings pages. This restores the standard iOS bounce/elastic scrolling effect. * fix(mobile): use consistent native transitions for Library pages Change Trash, Shared Links, and Folders routes from CustomRoute to AutoRoute to enable native iOS transitions with swipe-back gesture support. * fix(mobile): remove SafeArea wrapper and ClampingScrollPhysics from Settings Remove SafeArea wrapper (Scaffold handles safe areas automatically) and ClampingScrollPhysics to enable native iOS bounce scrolling. * fix(mobile): remove bottom white space in Sync Status page Replace Padding wrapper with ListView padding to match other Settings pages and eliminate bottom white space. * chore: fix Dart formatting Run dart format to fix formatting issues in settings.page.dart and sync_status_and_actions.dart * Format Dart files --------- Co-authored-by: Claude Co-authored-by: kao-byte --- mobile/lib/pages/common/settings.page.dart | 8 +- mobile/lib/routing/router.dart | 14 +- .../sync_status_and_actions.dart | 138 +++++++++--------- .../networking_settings.dart | 1 - 4 files changed, 73 insertions(+), 88 deletions(-) diff --git a/mobile/lib/pages/common/settings.page.dart b/mobile/lib/pages/common/settings.page.dart index 0fe2ccec09..86c80253dc 100644 --- a/mobile/lib/pages/common/settings.page.dart +++ b/mobile/lib/pages/common/settings.page.dart @@ -58,7 +58,7 @@ class SettingsPage extends StatelessWidget { context.locale; return Scaffold( appBar: AppBar(centerTitle: false, title: const Text('settings').tr()), - body: context.isMobile ? const SafeArea(child: _MobileLayout()) : const SafeArea(child: _TabletLayout()), + body: context.isMobile ? const _MobileLayout() : const _TabletLayout(), ); } } @@ -89,11 +89,7 @@ class _MobileLayout extends StatelessWidget { ], ) .toList(); - return ListView( - physics: const ClampingScrollPhysics(), - padding: const EdgeInsets.only(top: 10.0, bottom: 16), - children: [...settings], - ); + return ListView(padding: const EdgeInsets.only(top: 10.0, bottom: 16), children: [...settings]); } } diff --git a/mobile/lib/routing/router.dart b/mobile/lib/routing/router.dart index abe7ac3fa2..30f43cf3b2 100644 --- a/mobile/lib/routing/router.dart +++ b/mobile/lib/routing/router.dart @@ -245,23 +245,15 @@ class AppRouter extends RootStackRouter { guards: [_authGuard, _duplicateGuard], transitionsBuilder: TransitionsBuilders.slideLeft, ), - CustomRoute(page: FolderRoute.page, guards: [_authGuard], transitionsBuilder: TransitionsBuilders.fadeIn), + AutoRoute(page: FolderRoute.page, guards: [_authGuard]), AutoRoute(page: PartnerDetailRoute.page, guards: [_authGuard, _duplicateGuard]), AutoRoute(page: PersonResultRoute.page, guards: [_authGuard, _duplicateGuard]), AutoRoute(page: AllPeopleRoute.page, guards: [_authGuard, _duplicateGuard]), AutoRoute(page: MemoryRoute.page, guards: [_authGuard, _duplicateGuard]), AutoRoute(page: MapRoute.page, guards: [_authGuard, _duplicateGuard]), AutoRoute(page: AlbumOptionsRoute.page, guards: [_authGuard, _duplicateGuard]), - CustomRoute( - page: TrashRoute.page, - guards: [_authGuard, _duplicateGuard], - transitionsBuilder: TransitionsBuilders.slideLeft, - ), - CustomRoute( - page: SharedLinkRoute.page, - guards: [_authGuard, _duplicateGuard], - transitionsBuilder: TransitionsBuilders.slideLeft, - ), + AutoRoute(page: TrashRoute.page, guards: [_authGuard, _duplicateGuard]), + AutoRoute(page: SharedLinkRoute.page, guards: [_authGuard, _duplicateGuard]), AutoRoute(page: SharedLinkEditRoute.page, guards: [_authGuard, _duplicateGuard]), CustomRoute( page: ActivitiesRoute.page, diff --git a/mobile/lib/widgets/settings/beta_sync_settings/sync_status_and_actions.dart b/mobile/lib/widgets/settings/beta_sync_settings/sync_status_and_actions.dart index 0296a6bd99..64c3d9b832 100644 --- a/mobile/lib/widgets/settings/beta_sync_settings/sync_status_and_actions.dart +++ b/mobile/lib/widgets/settings/beta_sync_settings/sync_status_and_actions.dart @@ -108,82 +108,80 @@ class SyncStatusAndActions extends HookConsumerWidget { ); } - return Padding( - padding: const EdgeInsets.only(top: 16, bottom: 32), - child: ListView( - children: [ - const _SyncStatsCounts(), - const Divider(height: 1, indent: 16, endIndent: 16), - const SizedBox(height: 24), - _SectionHeaderText(text: "jobs".t(context: context)), - ListTile( - title: Text( - "sync_local".t(context: context), - style: const TextStyle(fontWeight: FontWeight.w500), - ), - subtitle: Text("tap_to_run_job".t(context: context)), - leading: const Icon(Icons.sync), - trailing: _SyncStatusIcon(status: ref.watch(syncStatusProvider).localSyncStatus), - onTap: () { - ref.read(backgroundSyncProvider).syncLocal(full: true); - }, + return ListView( + padding: const EdgeInsets.only(top: 16, bottom: 96), + children: [ + const _SyncStatsCounts(), + const Divider(height: 1, indent: 16, endIndent: 16), + const SizedBox(height: 24), + _SectionHeaderText(text: "jobs".t(context: context)), + ListTile( + title: Text( + "sync_local".t(context: context), + style: const TextStyle(fontWeight: FontWeight.w500), ), - ListTile( - title: Text( - "sync_remote".t(context: context), - style: const TextStyle(fontWeight: FontWeight.w500), - ), - subtitle: Text("tap_to_run_job".t(context: context)), - leading: const Icon(Icons.cloud_sync), - trailing: _SyncStatusIcon(status: ref.watch(syncStatusProvider).remoteSyncStatus), - onTap: () { - ref.read(backgroundSyncProvider).syncRemote(); - }, + subtitle: Text("tap_to_run_job".t(context: context)), + leading: const Icon(Icons.sync), + trailing: _SyncStatusIcon(status: ref.watch(syncStatusProvider).localSyncStatus), + onTap: () { + ref.read(backgroundSyncProvider).syncLocal(full: true); + }, + ), + ListTile( + title: Text( + "sync_remote".t(context: context), + style: const TextStyle(fontWeight: FontWeight.w500), ), - ListTile( - title: Text( - "hash_asset".t(context: context), - style: const TextStyle(fontWeight: FontWeight.w500), - ), - leading: const Icon(Icons.tag), - subtitle: Text("tap_to_run_job".t(context: context)), - trailing: _SyncStatusIcon(status: ref.watch(syncStatusProvider).hashJobStatus), - onTap: () { - ref.read(backgroundSyncProvider).hashAssets(); - }, + subtitle: Text("tap_to_run_job".t(context: context)), + leading: const Icon(Icons.cloud_sync), + trailing: _SyncStatusIcon(status: ref.watch(syncStatusProvider).remoteSyncStatus), + onTap: () { + ref.read(backgroundSyncProvider).syncRemote(); + }, + ), + ListTile( + title: Text( + "hash_asset".t(context: context), + style: const TextStyle(fontWeight: FontWeight.w500), ), - const Divider(height: 1, indent: 16, endIndent: 16), - const SizedBox(height: 24), - _SectionHeaderText(text: "actions".t(context: context)), - ListTile( - title: Text( - "clear_file_cache".t(context: context), - style: const TextStyle(fontWeight: FontWeight.w500), - ), - leading: const Icon(Icons.playlist_remove_rounded), - onTap: clearFileCache, + leading: const Icon(Icons.tag), + subtitle: Text("tap_to_run_job".t(context: context)), + trailing: _SyncStatusIcon(status: ref.watch(syncStatusProvider).hashJobStatus), + onTap: () { + ref.read(backgroundSyncProvider).hashAssets(); + }, + ), + const Divider(height: 1, indent: 16, endIndent: 16), + const SizedBox(height: 24), + _SectionHeaderText(text: "actions".t(context: context)), + ListTile( + title: Text( + "clear_file_cache".t(context: context), + style: const TextStyle(fontWeight: FontWeight.w500), ), - ListTile( - title: Text( - "export_database".t(context: context), - style: const TextStyle(fontWeight: FontWeight.w500), - ), - subtitle: Text("export_database_description".t(context: context)), - leading: const Icon(Icons.download), - onTap: exportDatabase, + leading: const Icon(Icons.playlist_remove_rounded), + onTap: clearFileCache, + ), + ListTile( + title: Text( + "export_database".t(context: context), + style: const TextStyle(fontWeight: FontWeight.w500), ), - ListTile( - title: Text( - "reset_sqlite".t(context: context), - style: TextStyle(color: context.colorScheme.error, fontWeight: FontWeight.w500), - ), - leading: Icon(Icons.settings_backup_restore_rounded, color: context.colorScheme.error), - onTap: () async { - await resetSqliteDb(context); - }, + subtitle: Text("export_database_description".t(context: context)), + leading: const Icon(Icons.download), + onTap: exportDatabase, + ), + ListTile( + title: Text( + "reset_sqlite".t(context: context), + style: TextStyle(color: context.colorScheme.error, fontWeight: FontWeight.w500), ), - ], - ), + leading: Icon(Icons.settings_backup_restore_rounded, color: context.colorScheme.error), + onTap: () async { + await resetSqliteDb(context); + }, + ), + ], ); } } diff --git a/mobile/lib/widgets/settings/networking_settings/networking_settings.dart b/mobile/lib/widgets/settings/networking_settings/networking_settings.dart index 426ea5ac0f..272b83c9aa 100644 --- a/mobile/lib/widgets/settings/networking_settings/networking_settings.dart +++ b/mobile/lib/widgets/settings/networking_settings/networking_settings.dart @@ -86,7 +86,6 @@ class NetworkingSettings extends HookConsumerWidget { return ListView( padding: const EdgeInsets.only(bottom: 96), - physics: const ClampingScrollPhysics(), children: [ Padding( padding: const EdgeInsets.only(top: 8, left: 16, bottom: 8), From 46afd6a101c19046119ad9137fda29557f042552 Mon Sep 17 00:00:00 2001 From: Alex Date: Sun, 30 Nov 2025 13:01:12 -0600 Subject: [PATCH 13/33] fix: only generate memory based on users assets (#24151) --- server/src/services/memory.service.ts | 17 ++++------------- 1 file changed, 4 insertions(+), 13 deletions(-) diff --git a/server/src/services/memory.service.ts b/server/src/services/memory.service.ts index 1d39169f3e..8e91c232f7 100644 --- a/server/src/services/memory.service.ts +++ b/server/src/services/memory.service.ts @@ -6,7 +6,7 @@ import { AuthDto } from 'src/dtos/auth.dto'; import { MemoryCreateDto, MemoryResponseDto, MemorySearchDto, MemoryUpdateDto, mapMemory } from 'src/dtos/memory.dto'; import { DatabaseLock, JobName, MemoryType, Permission, QueueName, SystemMetadataKey } from 'src/enum'; import { BaseService } from 'src/services/base.service'; -import { addAssets, getMyPartnerIds, removeAssets } from 'src/utils/asset.util'; +import { addAssets, removeAssets } from 'src/utils/asset.util'; const DAYS = 3; @@ -15,15 +15,6 @@ export class MemoryService extends BaseService { @OnJob({ name: JobName.MemoryGenerate, queue: QueueName.BackgroundTask }) async onMemoriesCreate() { const users = await this.userRepository.getList({ withDeleted: false }); - const usersIds = await Promise.all( - users.map((user) => - getMyPartnerIds({ - userId: user.id, - repository: this.partnerRepository, - timelineEnabled: true, - }), - ), - ); await this.databaseRepository.withLock(DatabaseLock.MemoryCreation, async () => { const state = await this.systemMetadataRepository.get(SystemMetadataKey.MemoriesState); @@ -38,7 +29,7 @@ export class MemoryService extends BaseService { } try { - await Promise.all(users.map((owner, i) => this.createOnThisDayMemories(owner.id, usersIds[i], target))); + await Promise.all(users.map((owner) => this.createOnThisDayMemories(owner.id, target))); } catch (error) { this.logger.error(`Failed to create memories for ${target.toISO()}: ${error}`); } @@ -51,10 +42,10 @@ export class MemoryService extends BaseService { }); } - private async createOnThisDayMemories(ownerId: string, userIds: string[], target: DateTime) { + private async createOnThisDayMemories(ownerId: string, target: DateTime) { const showAt = target.startOf('day').toISO(); const hideAt = target.endOf('day').toISO(); - const memories = await this.assetRepository.getByDayOfYear([ownerId, ...userIds], target); + const memories = await this.assetRepository.getByDayOfYear([ownerId], target); await Promise.all( memories.map(({ year, assets }) => this.memoryRepository.create( From fa43fae2a5c4f7a0bfdf0989b7138492aaaa6640 Mon Sep 17 00:00:00 2001 From: Matthew Momjian <50788000+mmomjian@users.noreply.github.com> Date: Sun, 30 Nov 2025 14:01:33 -0500 Subject: [PATCH 14/33] fix(mobile): docs link (#24277) update docs link --- mobile/lib/widgets/common/app_bar_dialog/app_bar_dialog.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mobile/lib/widgets/common/app_bar_dialog/app_bar_dialog.dart b/mobile/lib/widgets/common/app_bar_dialog/app_bar_dialog.dart index c6a557964d..53fc32ddb3 100644 --- a/mobile/lib/widgets/common/app_bar_dialog/app_bar_dialog.dart +++ b/mobile/lib/widgets/common/app_bar_dialog/app_bar_dialog.dart @@ -193,7 +193,7 @@ class ImmichAppBarDialog extends HookConsumerWidget { InkWell( onTap: () { context.pop(); - launchUrl(Uri.parse('https://immich.app'), mode: LaunchMode.externalApplication); + launchUrl(Uri.parse('https://docs.immich.app'), mode: LaunchMode.externalApplication); }, child: Text("documentation", style: context.textTheme.bodySmall).tr(), ), From 65e4fdf98dcfd62597f08304a164752990ea7c11 Mon Sep 17 00:00:00 2001 From: idubnori Date: Tue, 2 Dec 2025 00:01:57 +0900 Subject: [PATCH 15/33] refactor(web): i18n-ize "view asset owners" (#24317) --- i18n/en.json | 1 + .../[albumId=id]/[[photos=photos]]/[[assetId=id]]/+page.svelte | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/i18n/en.json b/i18n/en.json index 42965e06a8..ef1bbc76d6 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -2192,6 +2192,7 @@ "view_album": "View Album", "view_all": "View All", "view_all_users": "View all users", + "view_asset_owners": "View asset owners", "view_details": "View Details", "view_in_timeline": "View in timeline", "view_link": "View link", diff --git a/web/src/routes/(user)/albums/[albumId=id]/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/albums/[albumId=id]/[[photos=photos]]/[[assetId=id]]/+page.svelte index 74b0f1d6b3..27cc4a7faa 100644 --- a/web/src/routes/(user)/albums/[albumId=id]/[[photos=photos]]/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/albums/[albumId=id]/[[photos=photos]]/[[assetId=id]]/+page.svelte @@ -610,7 +610,7 @@ variant="ghost" shape="round" color="secondary" - aria-label="view asset owners" + aria-label={$t('view_asset_owners')} icon={mdiAccountEyeOutline} onclick={() => (showAlbumUsers = !showAlbumUsers)} /> From ab35afd3b18c4de9786e89e72241d6e6971b3074 Mon Sep 17 00:00:00 2001 From: Min Idzelis Date: Mon, 1 Dec 2025 09:04:39 -0600 Subject: [PATCH 16/33] refactor(web): reimplement operation-support as part of timeline-manager (#24056) * refactor(web): reimplement operation-support as part of timeline-manager Improve clarity of methods. Add inline method documentation. Make return type of AssetOperation optional. * Review comments - self document code. remove optional return from callback --- .../actions/TimelineKeyboardActions.svelte | 5 +- .../timeline-manager/day-group.svelte.ts | 7 +- .../internal/operations-support.svelte.ts | 104 ----------- .../timeline-manager/month-group.svelte.ts | 14 +- .../timeline-manager.svelte.spec.ts | 3 +- .../timeline-manager.svelte.ts | 170 ++++++++++++++---- .../lib/managers/timeline-manager/types.ts | 2 - web/src/lib/utils/actions.ts | 19 +- .../[[assetId=id]]/+page.svelte | 12 +- .../[[assetId=id]]/+page.svelte | 12 +- .../[[assetId=id]]/+page.svelte | 6 +- .../[[assetId=id]]/+page.svelte | 12 +- .../(user)/photos/[[assetId=id]]/+page.svelte | 12 +- 13 files changed, 168 insertions(+), 210 deletions(-) delete mode 100644 web/src/lib/managers/timeline-manager/internal/operations-support.svelte.ts diff --git a/web/src/lib/components/timeline/actions/TimelineKeyboardActions.svelte b/web/src/lib/components/timeline/actions/TimelineKeyboardActions.svelte index d5b1d2ecf6..b731635355 100644 --- a/web/src/lib/components/timeline/actions/TimelineKeyboardActions.svelte +++ b/web/src/lib/components/timeline/actions/TimelineKeyboardActions.svelte @@ -80,10 +80,7 @@ const toggleArchive = async () => { const visibility = assetInteraction.isAllArchived ? AssetVisibility.Timeline : AssetVisibility.Archive; const ids = await archiveAssets(assetInteraction.selectedAssets, visibility); - timelineManager.updateAssetOperation(ids, (asset) => { - asset.visibility = visibility; - return { remove: false }; - }); + timelineManager.update(ids, (asset) => (asset.visibility = visibility)); deselectAllAssets(); }; diff --git a/web/src/lib/managers/timeline-manager/day-group.svelte.ts b/web/src/lib/managers/timeline-manager/day-group.svelte.ts index 934ca1d4ff..e21e54a6e5 100644 --- a/web/src/lib/managers/timeline-manager/day-group.svelte.ts +++ b/web/src/lib/managers/timeline-manager/day-group.svelte.ts @@ -6,7 +6,7 @@ import { plainDateTimeCompare } from '$lib/utils/timeline-util'; import { SvelteSet } from 'svelte/reactivity'; import type { MonthGroup } from './month-group.svelte'; -import type { AssetOperation, Direction, MoveAsset, TimelineAsset } from './types'; +import type { Direction, MoveAsset, TimelineAsset } from './types'; import { ViewerAsset } from './viewer-asset.svelte'; export class DayGroup { @@ -101,7 +101,7 @@ export class DayGroup { return this.viewerAssets.map((viewerAsset) => viewerAsset.asset); } - runAssetOperation(ids: Set, operation: AssetOperation) { + runAssetCallback(ids: Set, callback: (asset: TimelineAsset) => void | { remove?: boolean }) { if (ids.size === 0) { return { moveAssets: [] as MoveAsset[], @@ -122,7 +122,8 @@ export class DayGroup { const asset = this.viewerAssets[index].asset!; const oldTime = { ...asset.localDateTime }; - let { remove } = operation(asset); + const callbackResult = callback(asset); + let remove = (callbackResult as { remove?: boolean } | undefined)?.remove ?? false; const newTime = asset.localDateTime; if (oldTime.year !== newTime.year || oldTime.month !== newTime.month || oldTime.day !== newTime.day) { const { year, month, day } = newTime; diff --git a/web/src/lib/managers/timeline-manager/internal/operations-support.svelte.ts b/web/src/lib/managers/timeline-manager/internal/operations-support.svelte.ts deleted file mode 100644 index 4bc99c0315..0000000000 --- a/web/src/lib/managers/timeline-manager/internal/operations-support.svelte.ts +++ /dev/null @@ -1,104 +0,0 @@ -import { setDifference, type TimelineDate } from '$lib/utils/timeline-util'; -import { AssetOrder } from '@immich/sdk'; - -import { SvelteSet } from 'svelte/reactivity'; -import { GroupInsertionCache } from '../group-insertion-cache.svelte'; -import { MonthGroup } from '../month-group.svelte'; -import type { TimelineManager } from '../timeline-manager.svelte'; -import type { AssetOperation, TimelineAsset } from '../types'; -import { updateGeometry } from './layout-support.svelte'; -import { getMonthGroupByDate } from './search-support.svelte'; - -export function addAssetsToMonthGroups( - timelineManager: TimelineManager, - assets: TimelineAsset[], - options: { order: AssetOrder }, -) { - if (assets.length === 0) { - return; - } - - const addContext = new GroupInsertionCache(); - const updatedMonthGroups = new SvelteSet(); - const monthCount = timelineManager.months.length; - for (const asset of assets) { - let month = getMonthGroupByDate(timelineManager, asset.localDateTime); - - if (!month) { - month = new MonthGroup(timelineManager, asset.localDateTime, 1, options.order); - month.isLoaded = true; - timelineManager.months.push(month); - } - - month.addTimelineAsset(asset, addContext); - updatedMonthGroups.add(month); - } - - if (timelineManager.months.length !== monthCount) { - timelineManager.months.sort((a, b) => { - return a.yearMonth.year === b.yearMonth.year - ? b.yearMonth.month - a.yearMonth.month - : b.yearMonth.year - a.yearMonth.year; - }); - } - - for (const group of addContext.existingDayGroups) { - group.sortAssets(options.order); - } - - for (const monthGroup of addContext.bucketsWithNewDayGroups) { - monthGroup.sortDayGroups(); - } - - for (const month of addContext.updatedBuckets) { - month.sortDayGroups(); - updateGeometry(timelineManager, month, { invalidateHeight: true }); - } - timelineManager.updateIntersections(); -} - -export function runAssetOperation( - timelineManager: TimelineManager, - ids: Set, - operation: AssetOperation, - options: { order: AssetOrder }, -) { - if (ids.size === 0) { - return { processedIds: new SvelteSet(), unprocessedIds: ids, changedGeometry: false }; - } - - const changedMonthGroups = new SvelteSet(); - let idsToProcess = new SvelteSet(ids); - const idsProcessed = new SvelteSet(); - const combinedMoveAssets: { asset: TimelineAsset; date: TimelineDate }[][] = []; - for (const month of timelineManager.months) { - if (idsToProcess.size > 0) { - const { moveAssets, processedIds, changedGeometry } = month.runAssetOperation(idsToProcess, operation); - if (moveAssets.length > 0) { - combinedMoveAssets.push(moveAssets); - } - idsToProcess = setDifference(idsToProcess, processedIds); - for (const id of processedIds) { - idsProcessed.add(id); - } - if (changedGeometry) { - changedMonthGroups.add(month); - } - } - } - if (combinedMoveAssets.length > 0) { - addAssetsToMonthGroups( - timelineManager, - combinedMoveAssets.flat().map((a) => a.asset), - options, - ); - } - const changedGeometry = changedMonthGroups.size > 0; - for (const month of changedMonthGroups) { - updateGeometry(timelineManager, month, { invalidateHeight: true }); - } - if (changedGeometry) { - timelineManager.updateIntersections(); - } - return { unprocessedIds: idsToProcess, processedIds: idsProcessed, changedGeometry }; -} diff --git a/web/src/lib/managers/timeline-manager/month-group.svelte.ts b/web/src/lib/managers/timeline-manager/month-group.svelte.ts index 1d9e1bbaa7..3926055cca 100644 --- a/web/src/lib/managers/timeline-manager/month-group.svelte.ts +++ b/web/src/lib/managers/timeline-manager/month-group.svelte.ts @@ -21,7 +21,7 @@ import { SvelteSet } from 'svelte/reactivity'; import { DayGroup } from './day-group.svelte'; import { GroupInsertionCache } from './group-insertion-cache.svelte'; import type { TimelineManager } from './timeline-manager.svelte'; -import type { AssetDescriptor, AssetOperation, Direction, MoveAsset, TimelineAsset } from './types'; +import type { AssetDescriptor, Direction, MoveAsset, TimelineAsset } from './types'; import { ViewerAsset } from './viewer-asset.svelte'; export class MonthGroup { @@ -50,12 +50,13 @@ export class MonthGroup { readonly yearMonth: TimelineYearMonth; constructor( - store: TimelineManager, + timelineManager: TimelineManager, yearMonth: TimelineYearMonth, initialCount: number, + loaded: boolean, order: AssetOrder = AssetOrder.Desc, ) { - this.timelineManager = store; + this.timelineManager = timelineManager; this.#initialCount = initialCount; this.#sortOrder = order; @@ -72,6 +73,9 @@ export class MonthGroup { }, this.#handleLoadError, ); + if (loaded) { + this.isLoaded = true; + } } set intersecting(newValue: boolean) { @@ -112,7 +116,7 @@ export class MonthGroup { return this.dayGroups.sort((a, b) => b.day - a.day); } - runAssetOperation(ids: Set, operation: AssetOperation) { + runAssetCallback(ids: Set, callback: (asset: TimelineAsset) => void | { remove?: boolean }) { if (ids.size === 0) { return { moveAssets: [] as MoveAsset[], @@ -130,7 +134,7 @@ export class MonthGroup { while (index--) { if (idsToProcess.size > 0) { const group = dayGroups[index]; - const { moveAssets, processedIds, changedGeometry } = group.runAssetOperation(ids, operation); + const { moveAssets, processedIds, changedGeometry } = group.runAssetCallback(ids, callback); if (moveAssets.length > 0) { combinedMoveAssets.push(moveAssets); } diff --git a/web/src/lib/managers/timeline-manager/timeline-manager.svelte.spec.ts b/web/src/lib/managers/timeline-manager/timeline-manager.svelte.spec.ts index 62053f7a0d..2c63348f88 100644 --- a/web/src/lib/managers/timeline-manager/timeline-manager.svelte.spec.ts +++ b/web/src/lib/managers/timeline-manager/timeline-manager.svelte.spec.ts @@ -278,10 +278,11 @@ describe('TimelineManager', () => { }); it('updates existing asset', () => { + const updateAssetsSpy = vi.spyOn(timelineManager, 'upsertAssets'); const asset = deriveLocalDateTimeFromFileCreatedAt(timelineAssetFactory.build()); timelineManager.upsertAssets([asset]); - timelineManager.upsertAssets([asset]); + expect(updateAssetsSpy).toBeCalledWith([asset]); expect(timelineManager.assetCount).toEqual(1); }); diff --git a/web/src/lib/managers/timeline-manager/timeline-manager.svelte.ts b/web/src/lib/managers/timeline-manager/timeline-manager.svelte.ts index e3327663b4..93b8364930 100644 --- a/web/src/lib/managers/timeline-manager/timeline-manager.svelte.ts +++ b/web/src/lib/managers/timeline-manager/timeline-manager.svelte.ts @@ -1,12 +1,9 @@ import { VirtualScrollManager } from '$lib/managers/VirtualScrollManager/VirtualScrollManager.svelte'; import { authManager } from '$lib/managers/auth-manager.svelte'; +import { GroupInsertionCache } from '$lib/managers/timeline-manager/group-insertion-cache.svelte'; import { updateIntersectionMonthGroup } from '$lib/managers/timeline-manager/internal/intersection-support.svelte'; import { updateGeometry } from '$lib/managers/timeline-manager/internal/layout-support.svelte'; import { loadFromTimeBuckets } from '$lib/managers/timeline-manager/internal/load-support.svelte'; -import { - addAssetsToMonthGroups, - runAssetOperation, -} from '$lib/managers/timeline-manager/internal/operations-support.svelte'; import { findClosestGroupForDate, findMonthGroupForAsset as findMonthGroupForAssetUtil, @@ -17,17 +14,22 @@ import { } from '$lib/managers/timeline-manager/internal/search-support.svelte'; import { WebsocketSupport } from '$lib/managers/timeline-manager/internal/websocket-support.svelte'; import { CancellableTask } from '$lib/utils/cancellable-task'; -import { toTimelineAsset, type TimelineDateTime, type TimelineYearMonth } from '$lib/utils/timeline-util'; +import { + setDifference, + toTimelineAsset, + type TimelineDateTime, + type TimelineYearMonth, +} from '$lib/utils/timeline-util'; import { AssetOrder, getAssetInfo, getTimeBuckets } from '@immich/sdk'; import { clamp, isEqual } from 'lodash-es'; -import { SvelteDate, SvelteMap, SvelteSet } from 'svelte/reactivity'; +import { SvelteDate, SvelteSet } from 'svelte/reactivity'; import { DayGroup } from './day-group.svelte'; import { isMismatched, updateObject } from './internal/utils.svelte'; import { MonthGroup } from './month-group.svelte'; import type { AssetDescriptor, - AssetOperation, Direction, + MoveAsset, ScrubberMonth, TimelineAsset, TimelineManagerOptions, @@ -218,6 +220,7 @@ export class TimelineManager extends VirtualScrollManager { this, { year: date.getUTCFullYear(), month: date.getUTCMonth() + 1 }, timeBucket.count, + false, this.#options.order, ); }); @@ -323,7 +326,7 @@ export class TimelineManager extends VirtualScrollManager { upsertAssets(assets: TimelineAsset[]) { const notUpdated = this.#updateAssets(assets); const notExcluded = notUpdated.filter((asset) => !this.isExcluded(asset)); - addAssetsToMonthGroups(this, [...notExcluded], { order: this.#options.order ?? AssetOrder.Desc }); + this.addAssetsUpsertSegments([...notExcluded]); } async findMonthGroupForAsset(id: string) { @@ -400,38 +403,107 @@ export class TimelineManager extends VirtualScrollManager { return randomDay.viewerAssets[randomAssetIndex - accumulatedCount].asset; } - updateAssetOperation(ids: string[], operation: AssetOperation) { - runAssetOperation(this, new SvelteSet(ids), operation, { order: this.#options.order ?? AssetOrder.Desc }); - } - - #updateAssets(assets: TimelineAsset[]) { - const lookup = new SvelteMap(assets.map((asset) => [asset.id, asset])); - const { unprocessedIds } = runAssetOperation( - this, - new SvelteSet(lookup.keys()), - (asset) => { - updateObject(asset, lookup.get(asset.id)); - return { remove: false }; - }, - { order: this.#options.order ?? AssetOrder.Desc }, - ); - const result: TimelineAsset[] = []; - for (const id of unprocessedIds.values()) { - result.push(lookup.get(id)!); - } - return result; + /** + * Executes callback on assets, handling moves between groups and removals due to filter criteria. + */ + update(ids: string[], callback: (asset: TimelineAsset) => void) { + // eslint-disable-next-line svelte/prefer-svelte-reactivity + return this.#runAssetCallback(new Set(ids), callback); } removeAssets(ids: string[]) { - const { unprocessedIds } = runAssetOperation( - this, - new SvelteSet(ids), - () => { - return { remove: true }; - }, - { order: this.#options.order ?? AssetOrder.Desc }, - ); - return [...unprocessedIds]; + // eslint-disable-next-line svelte/prefer-svelte-reactivity + const result = this.#runAssetCallback(new Set(ids), () => ({ remove: true })); + return [...result.notUpdated]; + } + + protected upsertSegmentForAsset(asset: TimelineAsset) { + let month = getMonthGroupByDate(this, asset.localDateTime); + + if (!month) { + month = new MonthGroup(this, asset.localDateTime, 1, true, this.#options.order); + this.months.push(month); + } + return month; + } + + /** + * Adds assets to existing segments, creating new segments as needed. + * + * This is an internal method that assumes the provided assets are not already + * present in the timeline. For updating existing assets, use updateAssetOperation(). + */ + protected addAssetsUpsertSegments(assets: TimelineAsset[]) { + if (assets.length === 0) { + return; + } + const context = new GroupInsertionCache(); + const monthCount = this.months.length; + for (const asset of assets) { + this.upsertSegmentForAsset(asset).addTimelineAsset(asset, context); + } + if (this.months.length !== monthCount) { + this.postCreateSegments(); + } + this.postUpsert(context); + } + + #updateAssets(assets: TimelineAsset[]) { + // eslint-disable-next-line svelte/prefer-svelte-reactivity + const cache = new Map(assets.map((asset) => [asset.id, asset])); + // eslint-disable-next-line svelte/prefer-svelte-reactivity + const idsToUpdate = new Set(cache.keys()); + const result = this.#runAssetCallback(idsToUpdate, (asset) => void updateObject(asset, cache.get(asset.id))); + const notUpdated: TimelineAsset[] = []; + for (const assetId of result.notUpdated) { + notUpdated.push(cache.get(assetId)!); + } + return notUpdated; + } + + #runAssetCallback(ids: Set, callback: (asset: TimelineAsset) => void | { remove?: boolean }) { + if (ids.size === 0) { + // eslint-disable-next-line svelte/prefer-svelte-reactivity + return { updated: new Set(), notUpdated: ids, changedGeometry: false }; + } + // eslint-disable-next-line svelte/prefer-svelte-reactivity + const changedMonthGroups = new Set(); + // eslint-disable-next-line svelte/prefer-svelte-reactivity + let notUpdated = new Set(ids); + // eslint-disable-next-line svelte/prefer-svelte-reactivity + const updated = new Set(); + const assetsToMoveSegments: MoveAsset[][] = []; + for (const month of this.months) { + if (notUpdated.size === 0) { + break; + } + const result = month.runAssetCallback(notUpdated, callback); + if (result.moveAssets.length > 0) { + assetsToMoveSegments.push(result.moveAssets); + } + if (result.changedGeometry) { + changedMonthGroups.add(month); + } + notUpdated = setDifference(notUpdated, result.processedIds); + for (const id of result.processedIds) { + updated.add(id); + } + } + const assetsToAdd = []; + for (const segment of assetsToMoveSegments) { + for (const moveAsset of segment) { + assetsToAdd.push(moveAsset.asset); + } + } + this.addAssetsUpsertSegments(assetsToAdd); + const changedGeometry = changedMonthGroups.size > 0; + for (const month of changedMonthGroups) { + updateGeometry(this, month, { invalidateHeight: true }); + } + if (changedGeometry) { + this.updateIntersections(); + } + return { updated, notUpdated, changedGeometry }; } override refreshLayout() { @@ -493,4 +565,28 @@ export class TimelineManager extends VirtualScrollManager { getAssetOrder() { return this.#options.order ?? AssetOrder.Desc; } + + protected postCreateSegments(): void { + this.months.sort((a, b) => { + return a.yearMonth.year === b.yearMonth.year + ? b.yearMonth.month - a.yearMonth.month + : b.yearMonth.year - a.yearMonth.year; + }); + } + + protected postUpsert(context: GroupInsertionCache): void { + for (const group of context.existingDayGroups) { + group.sortAssets(this.#options.order); + } + + for (const monthGroup of context.bucketsWithNewDayGroups) { + monthGroup.sortDayGroups(); + } + + for (const month of context.updatedBuckets) { + month.sortDayGroups(); + updateGeometry(this, month, { invalidateHeight: true }); + } + this.updateIntersections(); + } } diff --git a/web/src/lib/managers/timeline-manager/types.ts b/web/src/lib/managers/timeline-manager/types.ts index 27c27dcb63..35d7178f97 100644 --- a/web/src/lib/managers/timeline-manager/types.ts +++ b/web/src/lib/managers/timeline-manager/types.ts @@ -37,8 +37,6 @@ export type TimelineAsset = { longitude?: number | null; }; -export type AssetOperation = (asset: TimelineAsset) => { remove: boolean }; - export type MoveAsset = { asset: TimelineAsset; date: TimelineDate }; export interface Viewport { diff --git a/web/src/lib/utils/actions.ts b/web/src/lib/utils/actions.ts index 2eb081a490..05de75d3bc 100644 --- a/web/src/lib/utils/actions.ts +++ b/web/src/lib/utils/actions.ts @@ -79,14 +79,15 @@ const undoDeleteAssets = async (onUndoDelete: OnUndoDelete, assets: TimelineAsse */ export function updateStackedAssetInTimeline(timelineManager: TimelineManager, { stack, toDeleteIds }: StackResponse) { if (stack != undefined) { - timelineManager.updateAssetOperation([stack.primaryAssetId], (asset) => { - asset.stack = { - id: stack.id, - primaryAssetId: stack.primaryAssetId, - assetCount: stack.assets.length, - }; - return { remove: false }; - }); + timelineManager.update( + [stack.primaryAssetId], + (asset) => + (asset.stack = { + id: stack.id, + primaryAssetId: stack.primaryAssetId, + assetCount: stack.assets.length, + }), + ); timelineManager.removeAssets(toDeleteIds); } @@ -101,7 +102,7 @@ export function updateStackedAssetInTimeline(timelineManager: TimelineManager, { * @param assets - The array of asset response DTOs to update in the timeline manager. */ export function updateUnstackedAssetInTimeline(timelineManager: TimelineManager, assets: TimelineAsset[]) { - timelineManager.updateAssetOperation( + timelineManager.update( assets.map((asset) => asset.id), (asset) => { asset.stack = null; diff --git a/web/src/routes/(user)/albums/[albumId=id]/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/albums/[albumId=id]/[[photos=photos]]/[[assetId=id]]/+page.svelte index 27cc4a7faa..02dc55ead2 100644 --- a/web/src/routes/(user)/albums/[albumId=id]/[[photos=photos]]/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/albums/[albumId=id]/[[photos=photos]]/[[assetId=id]]/+page.svelte @@ -555,11 +555,7 @@ {#if assetInteraction.isAllUserOwned} - timelineManager.updateAssetOperation(ids, (asset) => { - asset.isFavorite = isFavorite; - return { remove: false }; - })} + onFavorite={(ids, isFavorite) => timelineManager.update(ids, (asset) => (asset.isFavorite = isFavorite))} > {/if} @@ -578,11 +574,7 @@ - timelineManager.updateAssetOperation(ids, (asset) => { - asset.visibility = visibility; - return { remove: false }; - })} + onArchive={(ids, visibility) => timelineManager.update(ids, (asset) => (asset.visibility = visibility))} /> {/if} diff --git a/web/src/routes/(user)/archive/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/archive/[[photos=photos]]/[[assetId=id]]/+page.svelte index ac1ffc356c..42dcedc106 100644 --- a/web/src/routes/(user)/archive/[[photos=photos]]/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/archive/[[photos=photos]]/[[assetId=id]]/+page.svelte @@ -66,11 +66,7 @@ > - timelineManager.updateAssetOperation(ids, (asset) => { - asset.visibility = visibility; - return { remove: false }; - })} + onArchive={(ids, visibility) => timelineManager.update(ids, (asset) => (asset.visibility = visibility))} /> @@ -80,11 +76,7 @@ - timelineManager.updateAssetOperation(ids, (asset) => { - asset.isFavorite = isFavorite; - return { remove: false }; - })} + onFavorite={(ids, isFavorite) => timelineManager.update(ids, (asset) => (asset.isFavorite = isFavorite))} /> diff --git a/web/src/routes/(user)/favorites/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/favorites/[[photos=photos]]/[[assetId=id]]/+page.svelte index 7cb3bf8e17..781dc80ec8 100644 --- a/web/src/routes/(user)/favorites/[[photos=photos]]/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/favorites/[[photos=photos]]/[[assetId=id]]/+page.svelte @@ -85,11 +85,7 @@ - timelineManager.updateAssetOperation(ids, (asset) => { - asset.visibility = visibility; - return { remove: false }; - })} + onArchive={(ids, visibility) => timelineManager.update(ids, (asset) => (asset.visibility = visibility))} /> {#if $preferences.tags.enabled} 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 5dabd58e76..c822855310 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 @@ -492,11 +492,7 @@ - timelineManager.updateAssetOperation(ids, (asset) => { - asset.isFavorite = isFavorite; - return { remove: false }; - })} + onFavorite={(ids, isFavorite) => timelineManager.update(ids, (asset) => (asset.isFavorite = isFavorite))} /> @@ -511,11 +507,7 @@ - timelineManager.updateAssetOperation(ids, (asset) => { - asset.visibility = visibility; - return { remove: false }; - })} + onArchive={(ids, visibility) => timelineManager.update(ids, (asset) => (asset.visibility = visibility))} /> {#if $preferences.tags.enabled && assetInteraction.isAllUserOwned} diff --git a/web/src/routes/(user)/photos/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/photos/[[assetId=id]]/+page.svelte index 669ea23921..8bf8dce94e 100644 --- a/web/src/routes/(user)/photos/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/photos/[[assetId=id]]/+page.svelte @@ -120,11 +120,7 @@ - timelineManager.updateAssetOperation(ids, (asset) => { - asset.isFavorite = isFavorite; - return { remove: false }; - })} + onFavorite={(ids, isFavorite) => timelineManager.update(ids, (asset) => (asset.isFavorite = isFavorite))} > @@ -148,11 +144,7 @@ - timelineManager.updateAssetOperation(ids, (asset) => { - asset.visibility = visibility; - return { remove: false }; - })} + onArchive={(ids, visibility) => timelineManager.update(ids, (asset) => (asset.visibility = visibility))} /> {#if $preferences.tags.enabled} From d8ca2106416cc953d3aaa65bdbf06847e78da82a Mon Sep 17 00:00:00 2001 From: idubnori Date: Tue, 2 Dec 2025 01:25:12 +0900 Subject: [PATCH 17/33] chore(web): minor UX improvements of "view asset owners" feature (#24319) * feat: toggle in options modal * feat(i18n): add labels to display who uploaded each asset and show asset owners * feat: migrate asset owner settings to TimelineManager and update AlbumOptionsModal * Revert "feat(i18n): add labels to display who uploaded each asset and show asset owners" This reverts commit cf8f4eb1356d45d88fc4625c90a5b511d4d86333. * fix: simplify AlbumOptionsModal invocation and update aria-label for asset owners * feat(i18n): add label for viewing asset owners in the interface * feat: add tests for showAssetOwners functionality in TimelineManager * chore: move asset owner visibility toggle to kebabu menu --- .../timeline-manager.svelte.spec.ts | 38 +++++++++++++++++++ .../timeline-manager.svelte.ts | 14 +++++++ .../[[assetId=id]]/+page.svelte | 24 ++++++------ 3 files changed, 63 insertions(+), 13 deletions(-) diff --git a/web/src/lib/managers/timeline-manager/timeline-manager.svelte.spec.ts b/web/src/lib/managers/timeline-manager/timeline-manager.svelte.spec.ts index 2c63348f88..bb58704214 100644 --- a/web/src/lib/managers/timeline-manager/timeline-manager.svelte.spec.ts +++ b/web/src/lib/managers/timeline-manager/timeline-manager.svelte.spec.ts @@ -692,4 +692,42 @@ describe('TimelineManager', () => { expect(discoveredAssets.size).toBe(assetCount); }); }); + + describe('showAssetOwners', () => { + const LS_KEY = 'album-show-asset-owners'; + + beforeEach(() => { + // ensure clean state + globalThis.localStorage?.removeItem(LS_KEY); + }); + + it('defaults to false', () => { + const timelineManager = new TimelineManager(); + expect(timelineManager.showAssetOwners).toBe(false); + }); + + it('setShowAssetOwners updates value', () => { + const timelineManager = new TimelineManager(); + timelineManager.setShowAssetOwners(true); + expect(timelineManager.showAssetOwners).toBe(true); + timelineManager.setShowAssetOwners(false); + expect(timelineManager.showAssetOwners).toBe(false); + }); + + it('toggleShowAssetOwners flips value', () => { + const timelineManager = new TimelineManager(); + expect(timelineManager.showAssetOwners).toBe(false); + timelineManager.toggleShowAssetOwners(); + expect(timelineManager.showAssetOwners).toBe(true); + timelineManager.toggleShowAssetOwners(); + expect(timelineManager.showAssetOwners).toBe(false); + }); + + it('persists across instances via localStorage', () => { + const a = new TimelineManager(); + a.setShowAssetOwners(true); + const b = new TimelineManager(); + expect(b.showAssetOwners).toBe(true); + }); + }); }); diff --git a/web/src/lib/managers/timeline-manager/timeline-manager.svelte.ts b/web/src/lib/managers/timeline-manager/timeline-manager.svelte.ts index 93b8364930..feba73a0f8 100644 --- a/web/src/lib/managers/timeline-manager/timeline-manager.svelte.ts +++ b/web/src/lib/managers/timeline-manager/timeline-manager.svelte.ts @@ -14,6 +14,7 @@ import { } from '$lib/managers/timeline-manager/internal/search-support.svelte'; import { WebsocketSupport } from '$lib/managers/timeline-manager/internal/websocket-support.svelte'; import { CancellableTask } from '$lib/utils/cancellable-task'; +import { PersistedLocalStorage } from '$lib/utils/persisted'; import { setDifference, toTimelineAsset, @@ -90,6 +91,19 @@ export class TimelineManager extends VirtualScrollManager { #options: TimelineManagerOptions = TimelineManager.#INIT_OPTIONS; #updatingIntersections = false; #scrollableElement: HTMLElement | undefined = $state(); + #showAssetOwners = new PersistedLocalStorage('album-show-asset-owners', false); + + get showAssetOwners() { + return this.#showAssetOwners.current; + } + + setShowAssetOwners(value: boolean) { + this.#showAssetOwners.current = value; + } + + toggleShowAssetOwners() { + this.#showAssetOwners.current = !this.#showAssetOwners.current; + } constructor() { super(); diff --git a/web/src/routes/(user)/albums/[albumId=id]/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/albums/[albumId=id]/[[photos=photos]]/[[assetId=id]]/+page.svelte index 02dc55ead2..3f4d3dd39f 100644 --- a/web/src/routes/(user)/albums/[albumId=id]/[[photos=photos]]/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/albums/[albumId=id]/[[photos=photos]]/[[assetId=id]]/+page.svelte @@ -66,6 +66,7 @@ } from '@immich/sdk'; import { Button, Icon, IconButton, modalManager, toastManager } from '@immich/ui'; import { + mdiAccountEye, mdiAccountEyeOutline, mdiArrowLeft, mdiCogOutline, @@ -101,7 +102,9 @@ let isCreatingSharedAlbum = $state(false); let isShowActivity = $state(false); let albumOrder: AssetOrder | undefined = $state(data.album.order); - let showAlbumUsers = $state(false); + + let timelineManager = $state() as TimelineManager; + let showAlbumUsers = $derived(timelineManager?.showAssetOwners ?? false); const assetInteraction = new AssetInteraction(); const timelineInteraction = new AssetInteraction(); @@ -303,7 +306,6 @@ } }); - let timelineManager = $state() as TimelineManager; const options = $derived.by(() => { if (viewMode === AlbumPageViewMode.SELECT_ASSETS) { return { @@ -597,17 +599,6 @@ {#snippet trailing()} - {#if containsEditors} - (showAlbumUsers = !showAlbumUsers)} - /> - {/if} - {#if isEditor} + {#if containsEditors} + timelineManager.toggleShowAssetOwners()} + /> + {/if} {#if album.assetCount > 0} Date: Mon, 1 Dec 2025 12:24:37 -0500 Subject: [PATCH 18/33] fix(server): use bigrams for cjk (#24285) * use bigrams for cjk * update sql * linting * actually migrate ocr * fix backwards test * use array * tweaks --- server/src/repositories/ocr.repository.ts | 4 +- .../1764483051488-OCRBigramsForCJK.ts | 24 +++ server/src/services/ocr.service.spec.ts | 167 +++++++++++++----- server/src/services/ocr.service.ts | 13 +- server/src/utils/database.ts | 42 ++++- 5 files changed, 203 insertions(+), 47 deletions(-) create mode 100644 server/src/schema/migrations/1764483051488-OCRBigramsForCJK.ts diff --git a/server/src/repositories/ocr.repository.ts b/server/src/repositories/ocr.repository.ts index 1da9a96ec5..a39f0d368c 100644 --- a/server/src/repositories/ocr.repository.ts +++ b/server/src/repositories/ocr.repository.ts @@ -45,12 +45,12 @@ export class OcrRepository { textScore: DummyValue.NUMBER, }, ], + DummyValue.STRING, ], }) - upsert(assetId: string, ocrDataList: Insertable[]) { + upsert(assetId: string, ocrDataList: Insertable[], searchText: string) { let query = this.db.with('deleted_ocr', (db) => db.deleteFrom('asset_ocr').where('assetId', '=', assetId)); if (ocrDataList.length > 0) { - const searchText = ocrDataList.map((item) => item.text.trim()).join(' '); (query as any) = query .with('inserted_ocr', (db) => db.insertInto('asset_ocr').values(ocrDataList)) .with('inserted_search', (db) => diff --git a/server/src/schema/migrations/1764483051488-OCRBigramsForCJK.ts b/server/src/schema/migrations/1764483051488-OCRBigramsForCJK.ts new file mode 100644 index 0000000000..7b659396fe --- /dev/null +++ b/server/src/schema/migrations/1764483051488-OCRBigramsForCJK.ts @@ -0,0 +1,24 @@ +import { Kysely, sql } from 'kysely'; +import { tokenizeForSearch } from 'src/utils/database'; + +export async function up(db: Kysely): Promise { + await sql`truncate ${sql.table('ocr_search')}`.execute(db); + const batch = []; + for await (const { assetId, text } of db + .selectFrom('asset_ocr') + .select(['assetId', sql`string_agg(text, ' ')`.as('text')]) + .groupBy('assetId') + .stream()) { + batch.push({ assetId, text: tokenizeForSearch(text) }); + if (batch.length >= 5000) { + await db.insertInto('ocr_search').values(batch).execute(); + batch.length = 0; + } + } + + if (batch.length > 0) { + await db.insertInto('ocr_search').values(batch).execute(); + } +} + +export async function down(): Promise {} diff --git a/server/src/services/ocr.service.spec.ts b/server/src/services/ocr.service.spec.ts index 6eedba1a5f..404f423cac 100644 --- a/server/src/services/ocr.service.spec.ts +++ b/server/src/services/ocr.service.spec.ts @@ -12,8 +12,21 @@ describe(OcrService.name, () => { ({ sut, mocks } = newTestService(OcrService)); mocks.config.getWorker.mockReturnValue(ImmichWorker.Microservices); + mocks.assetJob.getForOcr.mockResolvedValue({ + visibility: AssetVisibility.Timeline, + previewFile: assetStub.image.files[1].path, + }); }); + const mockOcrResult = (...texts: string[]) => { + mocks.machineLearning.ocr.mockResolvedValue({ + box: texts.flatMap((_, i) => Array.from({ length: 8 }, (_, j) => i * 10 + j)), + boxScore: texts.map(() => 0.9), + text: texts, + textScore: texts.map(() => 0.95), + }); + }; + it('should work', () => { expect(sut).toBeDefined(); }); @@ -72,10 +85,6 @@ describe(OcrService.name, () => { text: ['One Two Three', 'Four Five'], textScore: [0.95, 0.85], }); - mocks.assetJob.getForOcr.mockResolvedValue({ - visibility: AssetVisibility.Timeline, - previewFile: assetStub.image.files[1].path, - }); expect(await sut.handleOcr({ id: assetStub.image.id })).toEqual(JobStatus.Success); @@ -88,36 +97,40 @@ describe(OcrService.name, () => { maxResolution: 736, }), ); - expect(mocks.ocr.upsert).toHaveBeenCalledWith(assetStub.image.id, [ - { - assetId: assetStub.image.id, - boxScore: 0.9, - text: 'One Two Three', - textScore: 0.95, - x1: 10, - y1: 20, - x2: 30, - y2: 40, - x3: 50, - y3: 60, - x4: 70, - y4: 80, - }, - { - assetId: assetStub.image.id, - boxScore: 0.8, - text: 'Four Five', - textScore: 0.85, - x1: 90, - y1: 100, - x2: 110, - y2: 120, - x3: 130, - y3: 140, - x4: 150, - y4: 160, - }, - ]); + expect(mocks.ocr.upsert).toHaveBeenCalledWith( + assetStub.image.id, + [ + { + assetId: assetStub.image.id, + boxScore: 0.9, + text: 'One Two Three', + textScore: 0.95, + x1: 10, + y1: 20, + x2: 30, + y2: 40, + x3: 50, + y3: 60, + x4: 70, + y4: 80, + }, + { + assetId: assetStub.image.id, + boxScore: 0.8, + text: 'Four Five', + textScore: 0.85, + x1: 90, + y1: 100, + x2: 110, + y2: 120, + x3: 130, + y3: 140, + x4: 150, + y4: 160, + }, + ], + 'One Two Three Four Five', + ); }); it('should apply config settings', async () => { @@ -133,11 +146,7 @@ describe(OcrService.name, () => { }, }, }); - mocks.machineLearning.ocr.mockResolvedValue({ box: [], boxScore: [], text: [], textScore: [] }); - mocks.assetJob.getForOcr.mockResolvedValue({ - visibility: AssetVisibility.Timeline, - previewFile: assetStub.image.files[1].path, - }); + mockOcrResult(); expect(await sut.handleOcr({ id: assetStub.image.id })).toEqual(JobStatus.Success); @@ -150,7 +159,7 @@ describe(OcrService.name, () => { maxResolution: 1500, }), ); - expect(mocks.ocr.upsert).toHaveBeenCalledWith(assetStub.image.id, []); + expect(mocks.ocr.upsert).toHaveBeenCalledWith(assetStub.image.id, [], ''); }); it('should skip invisible assets', async () => { @@ -173,5 +182,83 @@ describe(OcrService.name, () => { expect(mocks.machineLearning.ocr).not.toHaveBeenCalled(); expect(mocks.ocr.upsert).not.toHaveBeenCalled(); }); + + describe('search tokenization', () => { + it('should generate bigrams for Chinese text', async () => { + mockOcrResult('機器學習'); + + await sut.handleOcr({ id: assetStub.image.id }); + + expect(mocks.ocr.upsert).toHaveBeenCalledWith(assetStub.image.id, expect.any(Array), '機器 器學 學習'); + }); + + it('should generate bigrams for Japanese text', async () => { + mockOcrResult('テスト'); + + await sut.handleOcr({ id: assetStub.image.id }); + + expect(mocks.ocr.upsert).toHaveBeenCalledWith(assetStub.image.id, expect.any(Array), 'テス スト'); + }); + + it('should generate bigrams for Korean text', async () => { + mockOcrResult('한국어'); + + await sut.handleOcr({ id: assetStub.image.id }); + + expect(mocks.ocr.upsert).toHaveBeenCalledWith(assetStub.image.id, expect.any(Array), '한국 국어'); + }); + + it('should pass through Latin text unchanged', async () => { + mockOcrResult('Hello World'); + + await sut.handleOcr({ id: assetStub.image.id }); + + expect(mocks.ocr.upsert).toHaveBeenCalledWith(assetStub.image.id, expect.any(Array), 'Hello World'); + }); + + it('should handle mixed CJK and Latin text', async () => { + mockOcrResult('機器學習Model'); + + await sut.handleOcr({ id: assetStub.image.id }); + + expect(mocks.ocr.upsert).toHaveBeenCalledWith(assetStub.image.id, expect.any(Array), '機器 器學 學習 Model'); + }); + + it('should handle year followed by CJK', async () => { + mockOcrResult('2024年レポート'); + + await sut.handleOcr({ id: assetStub.image.id }); + + expect(mocks.ocr.upsert).toHaveBeenCalledWith( + assetStub.image.id, + expect.any(Array), + '2024 年レ レポ ポー ート', + ); + }); + + it('should join multiple OCR boxes', async () => { + mockOcrResult('機器', 'Learning'); + + await sut.handleOcr({ id: assetStub.image.id }); + + expect(mocks.ocr.upsert).toHaveBeenCalledWith(assetStub.image.id, expect.any(Array), '機器 Learning'); + }); + + it('should normalize whitespace', async () => { + mockOcrResult(' Hello World '); + + await sut.handleOcr({ id: assetStub.image.id }); + + expect(mocks.ocr.upsert).toHaveBeenCalledWith(assetStub.image.id, expect.any(Array), 'Hello World'); + }); + + it('should keep single CJK characters', async () => { + mockOcrResult('A', '中', 'B'); + + await sut.handleOcr({ id: assetStub.image.id }); + + expect(mocks.ocr.upsert).toHaveBeenCalledWith(assetStub.image.id, expect.any(Array), 'A 中 B'); + }); + }); }); }); diff --git a/server/src/services/ocr.service.ts b/server/src/services/ocr.service.ts index cba57e5bc7..d92d399dba 100644 --- a/server/src/services/ocr.service.ts +++ b/server/src/services/ocr.service.ts @@ -5,6 +5,7 @@ import { AssetVisibility, JobName, JobStatus, QueueName } from 'src/enum'; import { OCR } from 'src/repositories/machine-learning.repository'; import { BaseService } from 'src/services/base.service'; import { JobItem, JobOf } from 'src/types'; +import { tokenizeForSearch } from 'src/utils/database'; import { isOcrEnabled } from 'src/utils/misc'; @Injectable() @@ -53,8 +54,8 @@ export class OcrService extends BaseService { } const ocrResults = await this.machineLearningRepository.ocr(asset.previewFile, machineLearning.ocr); - - await this.ocrRepository.upsert(id, this.parseOcrResults(id, ocrResults)); + const { ocrDataList, searchText } = this.parseOcrResults(id, ocrResults); + await this.ocrRepository.upsert(id, ocrDataList, searchText); await this.assetRepository.upsertJobStatus({ assetId: id, ocrAt: new Date() }); @@ -64,7 +65,9 @@ export class OcrService extends BaseService { private parseOcrResults(id: string, { box, boxScore, text, textScore }: OCR) { const ocrDataList = []; + const searchTokens = []; for (let i = 0; i < text.length; i++) { + const rawText = text[i]; const boxOffset = i * 8; ocrDataList.push({ assetId: id, @@ -78,9 +81,11 @@ export class OcrService extends BaseService { y4: box[boxOffset + 7], boxScore: boxScore[i], textScore: textScore[i], - text: text[i], + text: rawText, }); + searchTokens.push(...tokenizeForSearch(rawText)); } - return ocrDataList; + + return { ocrDataList, searchText: searchTokens.join(' ') }; } } diff --git a/server/src/utils/database.ts b/server/src/utils/database.ts index 0cc3788f1a..f8dbd5e78c 100644 --- a/server/src/utils/database.ts +++ b/server/src/utils/database.ts @@ -306,6 +306,46 @@ export function withTagId(qb: SelectQueryBuilder, tagId: stri ); } +const isCJK = (c: number): boolean => + (c >= 0x4e_00 && c <= 0x9f_ff) || + (c >= 0xac_00 && c <= 0xd7_af) || + (c >= 0x30_40 && c <= 0x30_9f) || + (c >= 0x30_a0 && c <= 0x30_ff) || + (c >= 0x34_00 && c <= 0x4d_bf); + +export const tokenizeForSearch = (text: string): string[] => { + /* eslint-disable unicorn/prefer-code-point */ + const tokens: string[] = []; + let i = 0; + while (i < text.length) { + const c = text.charCodeAt(i); + if (c <= 32) { + i++; + continue; + } + + const start = i; + if (isCJK(c)) { + while (i < text.length && isCJK(text.charCodeAt(i))) { + i++; + } + if (i - start === 1) { + tokens.push(text[start]); + } else { + for (let k = start; k < i - 1; k++) { + tokens.push(text[k] + text[k + 1]); + } + } + } else { + while (i < text.length && text.charCodeAt(i) > 32 && !isCJK(text.charCodeAt(i))) { + i++; + } + tokens.push(text.slice(start, i)); + } + } + return tokens; +}; + const joinDeduplicationPlugin = new DeduplicateJoinsPlugin(); /** TODO: This should only be used for search-related queries, not as a general purpose query builder */ @@ -391,7 +431,7 @@ export function searchAssetBuilder(kysely: Kysely, options: AssetSearchBuild .$if(!!options.ocr, (qb) => qb .innerJoin('ocr_search', 'asset.id', 'ocr_search.assetId') - .where(() => sql`f_unaccent(ocr_search.text) %>> f_unaccent(${options.ocr!})`), + .where(() => sql`f_unaccent(ocr_search.text) %>> f_unaccent(${tokenizeForSearch(options.ocr!).join(' ')})`), ) .$if(!!options.type, (qb) => qb.where('asset.type', '=', options.type!)) .$if(options.isFavorite !== undefined, (qb) => qb.where('asset.isFavorite', '=', options.isFavorite!)) From 7c19b0591f9bdadf2ee339a1f24fe46f82297c85 Mon Sep 17 00:00:00 2001 From: Mert <101130780+mertalev@users.noreply.github.com> Date: Mon, 1 Dec 2025 16:41:19 -0500 Subject: [PATCH 19/33] fix(server): cjk migration (#24320) * join string * use pagination instead --- .../1764483051488-OCRBigramsForCJK.ts | 35 +++++++++++-------- 1 file changed, 21 insertions(+), 14 deletions(-) diff --git a/server/src/schema/migrations/1764483051488-OCRBigramsForCJK.ts b/server/src/schema/migrations/1764483051488-OCRBigramsForCJK.ts index 7b659396fe..3f5cb5fa8f 100644 --- a/server/src/schema/migrations/1764483051488-OCRBigramsForCJK.ts +++ b/server/src/schema/migrations/1764483051488-OCRBigramsForCJK.ts @@ -3,21 +3,28 @@ import { tokenizeForSearch } from 'src/utils/database'; export async function up(db: Kysely): Promise { await sql`truncate ${sql.table('ocr_search')}`.execute(db); - const batch = []; - for await (const { assetId, text } of db - .selectFrom('asset_ocr') - .select(['assetId', sql`string_agg(text, ' ')`.as('text')]) - .groupBy('assetId') - .stream()) { - batch.push({ assetId, text: tokenizeForSearch(text) }); - if (batch.length >= 5000) { - await db.insertInto('ocr_search').values(batch).execute(); - batch.length = 0; - } - } - if (batch.length > 0) { - await db.insertInto('ocr_search').values(batch).execute(); + let lastAssetId: string | undefined; + while (true) { + const rows = await db + .selectFrom('asset_ocr') + .select(['assetId', sql`string_agg(text, ' ')`.as('text')]) + .$if(lastAssetId !== undefined, (qb) => qb.where('assetId', '>', lastAssetId)) + .groupBy('assetId') + .orderBy('assetId') + .limit(5000) + .execute(); + + if (rows.length === 0) { + break; + } + + await db + .insertInto('ocr_search') + .values(rows.map(({ assetId, text }) => ({ assetId, text: tokenizeForSearch(text).join(' ') }))) + .execute(); + + lastAssetId = rows.at(-1)!.assetId; } } From a3f281caa374ff8e18938e9795d3ed8ad5d00d57 Mon Sep 17 00:00:00 2001 From: Jonathan Jogenfors Date: Tue, 2 Dec 2025 03:25:31 +0100 Subject: [PATCH 20/33] docs(faq): add more info on archiving (#24326) docs: add more info on archive to faq --- docs/docs/FAQ.mdx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/docs/FAQ.mdx b/docs/docs/FAQ.mdx index e3df672d35..9dcfcac48b 100644 --- a/docs/docs/FAQ.mdx +++ b/docs/docs/FAQ.mdx @@ -133,9 +133,9 @@ There are a few different scenarios that can lead to this situation. The solutio The job is only automatically run once per asset after upload. If metadata extraction originally failed, the jobs were cleared/canceled, etc., the job may not have run automatically the first time. -### How can I hide photos from the timeline? +### How can I hide a photo or video from the timeline? -You can _archive_ them. +You can _archive_ them. This will hide the asset from the main timeline and folder view, but it will still show up in searches. All archived assets can be found in the _Archive_ view ### How can I backup data from Immich? From 6249996cdb101cbbafba6beda8bbb4dc55fbd78d Mon Sep 17 00:00:00 2001 From: Mert <101130780+mertalev@users.noreply.github.com> Date: Mon, 1 Dec 2025 21:26:01 -0500 Subject: [PATCH 21/33] fix(ml): do not upscale preview (#24322) do not upscale --- machine-learning/immich_ml/models/ocr/detection.py | 1 + 1 file changed, 1 insertion(+) diff --git a/machine-learning/immich_ml/models/ocr/detection.py b/machine-learning/immich_ml/models/ocr/detection.py index 4101a5c6f7..d34a51684e 100644 --- a/machine-learning/immich_ml/models/ocr/detection.py +++ b/machine-learning/immich_ml/models/ocr/detection.py @@ -82,6 +82,7 @@ class TextDetector(InferenceModel): ratio = float(self.max_resolution) / img.height else: ratio = float(self.max_resolution) / img.width + ratio = min(ratio, 1.0) resize_h = int(img.height * ratio) resize_w = int(img.width * ratio) From 79bed80226b13b096e836ad93faa59bb6c24635d Mon Sep 17 00:00:00 2001 From: Sergey Katsubo Date: Tue, 2 Dec 2025 05:26:13 +0300 Subject: [PATCH 22/33] feat(server): log all thumbnail generation attempts at verbose level (#24324) Log thumbnail generation at verbose level --- server/src/services/media.service.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/server/src/services/media.service.ts b/server/src/services/media.service.ts index 54ddc0de48..9027e89d66 100644 --- a/server/src/services/media.service.ts +++ b/server/src/services/media.service.ts @@ -174,8 +174,10 @@ export class MediaService extends BaseService { thumbhash: Buffer; }; if (asset.type === AssetType.Video || asset.originalFileName.toLowerCase().endsWith('.gif')) { + this.logger.verbose(`Thumbnail generation for video ${id} ${asset.originalPath}`); generated = await this.generateVideoThumbnails(asset); } else if (asset.type === AssetType.Image) { + this.logger.verbose(`Thumbnail generation for image ${id} ${asset.originalPath}`); generated = await this.generateImageThumbnails(asset); } else { this.logger.warn(`Skipping thumbnail generation for asset ${id}: ${asset.type} is not an image or video`); From b452ab463bb4503000e914963c92abfee36dca95 Mon Sep 17 00:00:00 2001 From: carbonemys Date: Tue, 2 Dec 2025 03:49:31 +0100 Subject: [PATCH 23/33] fix(web): open onboarding documentation link in new tab (#24289) * fix(web): open onboarding documentation link in new tab * Update web/src/lib/components/onboarding-page/onboarding-storage-template.svelte --------- Co-authored-by: Daniel Dietzler <36593685+danieldietzler@users.noreply.github.com> --- .../onboarding-page/onboarding-storage-template.svelte | 3 ++- 1 file changed, 2 insertions(+), 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 108714348f..32622a1547 100644 --- a/web/src/lib/components/onboarding-page/onboarding-storage-template.svelte +++ b/web/src/lib/components/onboarding-page/onboarding-storage-template.svelte @@ -2,13 +2,14 @@ import StorageTemplateSettings from '$lib/components/admin-settings/StorageTemplateSettings.svelte'; import FormatMessage from '$lib/elements/FormatMessage.svelte'; import { user } from '$lib/stores/user.store'; + import { Link } from '@immich/ui';

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

From a32f4500591ed6fb83b2ba88c586d1f8ab756f65 Mon Sep 17 00:00:00 2001 From: Yaros Date: Tue, 2 Dec 2025 03:51:35 +0100 Subject: [PATCH 24/33] feat(mobile): persist album sorting & layout in settings (#22133) * fix(mobile): persist album sorting in settings * fix(mobile): persist album layout * fix: fixed store model id * fix: corrupted AppSettingsEnum * chore: refactor to remove RemoteAlbumSortMode * refactor: use t instead of tr --- mobile/lib/domain/models/store.model.dart | 1 + .../domain/services/remote_album.service.dart | 28 ++----- .../widgets/album/album_selector.widget.dart | 79 ++++++++++++++++--- .../infrastructure/remote_album.provider.dart | 3 +- mobile/lib/services/app_settings.service.dart | 5 +- mobile/lib/utils/album_filter.utils.dart | 6 +- .../domain/services/album.service_test.dart | 13 +-- 7 files changed, 92 insertions(+), 43 deletions(-) diff --git a/mobile/lib/domain/models/store.model.dart b/mobile/lib/domain/models/store.model.dart index d8404db409..a18644cd2a 100644 --- a/mobile/lib/domain/models/store.model.dart +++ b/mobile/lib/domain/models/store.model.dart @@ -71,6 +71,7 @@ enum StoreKey { readonlyModeEnabled._(138), autoPlayVideo._(139), + albumGridView._(140), // Experimental stuff photoManagerCustomFilter._(1000), diff --git a/mobile/lib/domain/services/remote_album.service.dart b/mobile/lib/domain/services/remote_album.service.dart index 67e91188e2..68c72255b0 100644 --- a/mobile/lib/domain/services/remote_album.service.dart +++ b/mobile/lib/domain/services/remote_album.service.dart @@ -7,6 +7,7 @@ import 'package:immich_mobile/domain/models/user.model.dart'; import 'package:immich_mobile/infrastructure/repositories/remote_album.repository.dart'; import 'package:immich_mobile/models/albums/album_search.model.dart'; import 'package:immich_mobile/repositories/drift_album_api_repository.dart'; +import 'package:immich_mobile/providers/album/album_sort_by_options.provider.dart'; class RemoteAlbumService { final DriftRemoteAlbumRepository _repository; @@ -32,16 +33,16 @@ class RemoteAlbumService { Future> sortAlbums( List albums, - RemoteAlbumSortMode sortMode, { + AlbumSortMode sortMode, { bool isReverse = false, }) async { final List sorted = switch (sortMode) { - RemoteAlbumSortMode.created => albums.sortedBy((album) => album.createdAt), - RemoteAlbumSortMode.title => albums.sortedBy((album) => album.name), - RemoteAlbumSortMode.lastModified => albums.sortedBy((album) => album.updatedAt), - RemoteAlbumSortMode.assetCount => albums.sortedBy((album) => album.assetCount), - RemoteAlbumSortMode.mostRecent => await _sortByNewestAsset(albums), - RemoteAlbumSortMode.mostOldest => await _sortByOldestAsset(albums), + AlbumSortMode.created => albums.sortedBy((album) => album.createdAt), + AlbumSortMode.title => albums.sortedBy((album) => album.name), + AlbumSortMode.lastModified => albums.sortedBy((album) => album.updatedAt), + AlbumSortMode.assetCount => albums.sortedBy((album) => album.assetCount), + AlbumSortMode.mostRecent => await _sortByNewestAsset(albums), + AlbumSortMode.mostOldest => await _sortByOldestAsset(albums), }; return (isReverse ? sorted.reversed : sorted).toList(); @@ -211,16 +212,3 @@ class RemoteAlbumService { return sorted.reversed.toList(); } } - -enum RemoteAlbumSortMode { - title("library_page_sort_title"), - assetCount("library_page_sort_asset_count"), - lastModified("library_page_sort_last_modified"), - created("library_page_sort_created"), - mostRecent("sort_newest"), - mostOldest("sort_oldest"); - - final String key; - - const RemoteAlbumSortMode(this.key); -} diff --git a/mobile/lib/presentation/widgets/album/album_selector.widget.dart b/mobile/lib/presentation/widgets/album/album_selector.widget.dart index 0d5b9a7636..4110966e57 100644 --- a/mobile/lib/presentation/widgets/album/album_selector.widget.dart +++ b/mobile/lib/presentation/widgets/album/album_selector.widget.dart @@ -7,7 +7,6 @@ import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/domain/models/album/album.model.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; -import 'package:immich_mobile/domain/services/remote_album.service.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/theme_extensions.dart'; import 'package:immich_mobile/extensions/translate_extensions.dart'; @@ -17,6 +16,9 @@ import 'package:immich_mobile/presentation/widgets/images/thumbnail.widget.dart' import 'package:immich_mobile/providers/infrastructure/album.provider.dart'; import 'package:immich_mobile/providers/timeline/multiselect.provider.dart'; import 'package:immich_mobile/providers/user.provider.dart'; +import 'package:immich_mobile/providers/album/album_sort_by_options.provider.dart'; +import 'package:immich_mobile/providers/app_settings.provider.dart'; +import 'package:immich_mobile/services/app_settings.service.dart'; import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/utils/album_filter.utils.dart'; import 'package:immich_mobile/widgets/common/confirm_dialog.dart'; @@ -45,14 +47,28 @@ class _AlbumSelectorState extends ConsumerState { List shownAlbums = []; AlbumFilter filter = AlbumFilter(query: "", mode: QuickFilterMode.all); - AlbumSort sort = AlbumSort(mode: RemoteAlbumSortMode.lastModified, isReverse: true); + AlbumSort sort = AlbumSort(mode: AlbumSortMode.lastModified, isReverse: true); @override void initState() { super.initState(); - // Load albums when component mounts WidgetsBinding.instance.addPostFrameCallback((_) { + final appSettings = ref.read(appSettingsServiceProvider); + final savedSortMode = appSettings.getSetting(AppSettingsEnum.selectedAlbumSortOrder); + final savedIsReverse = appSettings.getSetting(AppSettingsEnum.selectedAlbumSortReverse); + final savedIsGrid = appSettings.getSetting(AppSettingsEnum.albumGridView); + + final albumSortMode = AlbumSortMode.values.firstWhere( + (e) => e.storeIndex == savedSortMode, + orElse: () => AlbumSortMode.lastModified, + ); + + setState(() { + sort = AlbumSort(mode: albumSortMode, isReverse: savedIsReverse); + isGrid = savedIsGrid; + }); + ref.read(remoteAlbumProvider.notifier).refresh(); }); @@ -82,6 +98,7 @@ class _AlbumSelectorState extends ConsumerState { setState(() { isGrid = !isGrid; }); + ref.read(appSettingsServiceProvider).setSetting(AppSettingsEnum.albumGridView, isGrid); } void changeFilter(QuickFilterMode mode) { @@ -97,6 +114,10 @@ class _AlbumSelectorState extends ConsumerState { this.sort = sort; }); + final appSettings = ref.read(appSettingsServiceProvider); + await appSettings.setSetting(AppSettingsEnum.selectedAlbumSortOrder, sort.mode.storeIndex); + await appSettings.setSetting(AppSettingsEnum.selectedAlbumSortReverse, sort.isReverse); + await sortAlbums(); } @@ -181,6 +202,8 @@ class _AlbumSelectorState extends ConsumerState { onToggleViewMode: toggleViewMode, onSortChanged: changeSort, controller: menuController, + currentSortMode: sort.mode, + currentIsReverse: sort.isReverse, ), isGrid ? _AlbumGrid(albums: shownAlbums, userId: userId, onAlbumSelected: widget.onAlbumSelected) @@ -192,21 +215,46 @@ class _AlbumSelectorState extends ConsumerState { } class _SortButton extends ConsumerStatefulWidget { - const _SortButton(this.onSortChanged, {this.controller}); + const _SortButton( + this.onSortChanged, { + required this.initialSortMode, + required this.initialIsReverse, + this.controller, + }); final Future Function(AlbumSort) onSortChanged; final MenuController? controller; + final AlbumSortMode initialSortMode; + final bool initialIsReverse; @override ConsumerState<_SortButton> createState() => _SortButtonState(); } class _SortButtonState extends ConsumerState<_SortButton> { - RemoteAlbumSortMode albumSortOption = RemoteAlbumSortMode.lastModified; - bool albumSortIsReverse = true; + late AlbumSortMode albumSortOption; + late bool albumSortIsReverse; bool isSorting = false; - Future onMenuTapped(RemoteAlbumSortMode sortMode) async { + @override + void initState() { + super.initState(); + albumSortOption = widget.initialSortMode; + albumSortIsReverse = widget.initialIsReverse; + } + + @override + void didUpdateWidget(_SortButton oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.initialSortMode != widget.initialSortMode || oldWidget.initialIsReverse != widget.initialIsReverse) { + setState(() { + albumSortOption = widget.initialSortMode; + albumSortIsReverse = widget.initialIsReverse; + }); + } + } + + Future onMenuTapped(AlbumSortMode sortMode) async { final selected = albumSortOption == sortMode; // Switch direction if (selected) { @@ -240,7 +288,7 @@ class _SortButtonState extends ConsumerState<_SortButton> { padding: const WidgetStatePropertyAll(EdgeInsets.all(4)), ), consumeOutsideTap: true, - menuChildren: RemoteAlbumSortMode.values + menuChildren: AlbumSortMode.values .map( (sortMode) => MenuItemButton( leadingIcon: albumSortOption == sortMode @@ -269,7 +317,7 @@ class _SortButtonState extends ConsumerState<_SortButton> { ), ), child: Text( - sortMode.key.t(context: context), + sortMode.label.t(context: context), style: context.textTheme.titleSmall?.copyWith( fontWeight: FontWeight.w600, color: albumSortOption == sortMode @@ -298,7 +346,7 @@ class _SortButtonState extends ConsumerState<_SortButton> { : const Icon(Icons.keyboard_arrow_up_rounded), ), Text( - albumSortOption.key.t(context: context), + albumSortOption.label.t(context: context), style: context.textTheme.bodyLarge?.copyWith( fontWeight: FontWeight.w500, color: context.colorScheme.onSurface.withAlpha(225), @@ -465,6 +513,8 @@ class _QuickSortAndViewMode extends StatelessWidget { required this.isGrid, required this.onToggleViewMode, required this.onSortChanged, + required this.currentSortMode, + required this.currentIsReverse, this.controller, }); @@ -472,6 +522,8 @@ class _QuickSortAndViewMode extends StatelessWidget { final VoidCallback onToggleViewMode; final MenuController? controller; final Future Function(AlbumSort) onSortChanged; + final AlbumSortMode currentSortMode; + final bool currentIsReverse; @override Widget build(BuildContext context) { @@ -481,7 +533,12 @@ class _QuickSortAndViewMode extends StatelessWidget { child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - _SortButton(onSortChanged, controller: controller), + _SortButton( + onSortChanged, + controller: controller, + initialSortMode: currentSortMode, + initialIsReverse: currentIsReverse, + ), IconButton( icon: Icon(isGrid ? Icons.view_list_outlined : Icons.grid_view_outlined, size: 24), onPressed: onToggleViewMode, diff --git a/mobile/lib/providers/infrastructure/remote_album.provider.dart b/mobile/lib/providers/infrastructure/remote_album.provider.dart index 38ba52dc56..e3cffeb093 100644 --- a/mobile/lib/providers/infrastructure/remote_album.provider.dart +++ b/mobile/lib/providers/infrastructure/remote_album.provider.dart @@ -5,6 +5,7 @@ import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; import 'package:immich_mobile/domain/models/user.model.dart'; import 'package:immich_mobile/domain/services/remote_album.service.dart'; import 'package:immich_mobile/models/albums/album_search.model.dart'; +import 'package:immich_mobile/providers/album/album_sort_by_options.provider.dart'; import 'package:logging/logging.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; @@ -70,7 +71,7 @@ class RemoteAlbumNotifier extends Notifier { Future> sortAlbums( List albums, - RemoteAlbumSortMode sortMode, { + AlbumSortMode sortMode, { bool isReverse = false, }) async { return await _remoteAlbumService.sortAlbums(albums, sortMode, isReverse: isReverse); diff --git a/mobile/lib/services/app_settings.service.dart b/mobile/lib/services/app_settings.service.dart index 7149408e8a..fc08193d11 100644 --- a/mobile/lib/services/app_settings.service.dart +++ b/mobile/lib/services/app_settings.service.dart @@ -51,9 +51,10 @@ enum AppSettingsEnum { enableBackup(StoreKey.enableBackup, null, false), useCellularForUploadVideos(StoreKey.useWifiForUploadVideos, null, false), useCellularForUploadPhotos(StoreKey.useWifiForUploadPhotos, null, false), + readonlyModeEnabled(StoreKey.readonlyModeEnabled, "readonlyModeEnabled", false), + albumGridView(StoreKey.albumGridView, "albumGridView", false), backupRequireCharging(StoreKey.backupRequireCharging, null, false), - backupTriggerDelay(StoreKey.backupTriggerDelay, null, 30), - readonlyModeEnabled(StoreKey.readonlyModeEnabled, "readonlyModeEnabled", false); + backupTriggerDelay(StoreKey.backupTriggerDelay, null, 30); const AppSettingsEnum(this.storeKey, this.hiveKey, this.defaultValue); diff --git a/mobile/lib/utils/album_filter.utils.dart b/mobile/lib/utils/album_filter.utils.dart index 02142b1571..8f9363d4d9 100644 --- a/mobile/lib/utils/album_filter.utils.dart +++ b/mobile/lib/utils/album_filter.utils.dart @@ -1,5 +1,5 @@ -import 'package:immich_mobile/domain/services/remote_album.service.dart'; import 'package:immich_mobile/models/albums/album_search.model.dart'; +import 'package:immich_mobile/providers/album/album_sort_by_options.provider.dart'; class AlbumFilter { String? userId; @@ -14,12 +14,12 @@ class AlbumFilter { } class AlbumSort { - RemoteAlbumSortMode mode; + AlbumSortMode mode; bool isReverse; AlbumSort({required this.mode, this.isReverse = false}); - AlbumSort copyWith({RemoteAlbumSortMode? mode, bool? isReverse}) { + AlbumSort copyWith({AlbumSortMode? mode, bool? isReverse}) { return AlbumSort(mode: mode ?? this.mode, isReverse: isReverse ?? this.isReverse); } } diff --git a/mobile/test/domain/services/album.service_test.dart b/mobile/test/domain/services/album.service_test.dart index ebd94a9450..b86819536d 100644 --- a/mobile/test/domain/services/album.service_test.dart +++ b/mobile/test/domain/services/album.service_test.dart @@ -2,6 +2,7 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:immich_mobile/domain/models/album/album.model.dart'; import 'package:immich_mobile/domain/services/remote_album.service.dart'; import 'package:immich_mobile/infrastructure/repositories/remote_album.repository.dart'; +import 'package:immich_mobile/providers/album/album_sort_by_options.provider.dart'; import 'package:immich_mobile/repositories/drift_album_api_repository.dart'; import 'package:mocktail/mocktail.dart'; @@ -76,42 +77,42 @@ void main() { test('should sort correctly based on name', () async { final albums = [albumB, albumA]; - final result = await sut.sortAlbums(albums, RemoteAlbumSortMode.title); + final result = await sut.sortAlbums(albums, AlbumSortMode.title); expect(result, [albumA, albumB]); }); test('should sort correctly based on createdAt', () async { final albums = [albumB, albumA]; - final result = await sut.sortAlbums(albums, RemoteAlbumSortMode.created); + final result = await sut.sortAlbums(albums, AlbumSortMode.created); expect(result, [albumA, albumB]); }); test('should sort correctly based on updatedAt', () async { final albums = [albumB, albumA]; - final result = await sut.sortAlbums(albums, RemoteAlbumSortMode.lastModified); + final result = await sut.sortAlbums(albums, AlbumSortMode.lastModified); expect(result, [albumA, albumB]); }); test('should sort correctly based on assetCount', () async { final albums = [albumB, albumA]; - final result = await sut.sortAlbums(albums, RemoteAlbumSortMode.assetCount); + final result = await sut.sortAlbums(albums, AlbumSortMode.assetCount); expect(result, [albumA, albumB]); }); test('should sort correctly based on newestAssetTimestamp', () async { final albums = [albumB, albumA]; - final result = await sut.sortAlbums(albums, RemoteAlbumSortMode.mostRecent); + final result = await sut.sortAlbums(albums, AlbumSortMode.mostRecent); expect(result, [albumA, albumB]); }); test('should sort correctly based on oldestAssetTimestamp', () async { final albums = [albumB, albumA]; - final result = await sut.sortAlbums(albums, RemoteAlbumSortMode.mostOldest); + final result = await sut.sortAlbums(albums, AlbumSortMode.mostOldest); expect(result, [albumB, albumA]); }); }); From 1e37f7c8c82d3879a1ae0d73b473798fc39d32bb Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 1 Dec 2025 23:16:59 -0500 Subject: [PATCH 25/33] chore(deps): update dependency nodemailer to v7.0.11 [security] (#24330) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- pnpm-lock.yaml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e1d38ac3a1..9a0ffe117f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -492,7 +492,7 @@ importers: version: 7.0.1(@nestjs/common@11.1.9(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.9) nodemailer: specifier: ^7.0.0 - version: 7.0.10 + version: 7.0.11 openid-client: specifier: ^6.3.3 version: 6.8.1 @@ -8851,8 +8851,8 @@ packages: node-releases@2.0.27: resolution: {integrity: sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==} - nodemailer@7.0.10: - resolution: {integrity: sha512-Us/Se1WtT0ylXgNFfyFSx4LElllVLJXQjWi2Xz17xWw7amDKO2MLtFnVp1WACy7GkVGs+oBlRopVNUzlrGSw1w==} + nodemailer@7.0.11: + resolution: {integrity: sha512-gnXhNRE0FNhD7wPSCGhdNh46Hs6nm+uTyg+Kq0cZukNQiYdnCsoQjodNP9BQVG9XrcK/v6/MgpAPBUFyzh9pvw==} engines: {node: '>=6.0.0'} nopt@1.0.10: @@ -21697,7 +21697,7 @@ snapshots: node-releases@2.0.27: {} - nodemailer@7.0.10: {} + nodemailer@7.0.11: {} nopt@1.0.10: dependencies: From 75b1ef2c57bc97c653562b65b68eebac949b6036 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 2 Dec 2025 01:01:17 -0500 Subject: [PATCH 26/33] chore(deps): update machine-learning (#24334) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- machine-learning/uv.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/machine-learning/uv.lock b/machine-learning/uv.lock index ca21ffe389..356e954ef4 100644 --- a/machine-learning/uv.lock +++ b/machine-learning/uv.lock @@ -2206,7 +2206,7 @@ wheels = [ [[package]] name = "pydantic" -version = "2.12.4" +version = "2.12.5" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "annotated-types" }, @@ -2214,9 +2214,9 @@ dependencies = [ { name = "typing-extensions" }, { name = "typing-inspection" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/96/ad/a17bc283d7d81837c061c49e3eaa27a45991759a1b7eae1031921c6bd924/pydantic-2.12.4.tar.gz", hash = "sha256:0f8cb9555000a4b5b617f66bfd2566264c4984b27589d3b845685983e8ea85ac", size = 821038, upload-time = "2025-11-05T10:50:08.59Z" } +sdist = { url = "https://files.pythonhosted.org/packages/69/44/36f1a6e523abc58ae5f928898e4aca2e0ea509b5aa6f6f392a5d882be928/pydantic-2.12.5.tar.gz", hash = "sha256:4d351024c75c0f085a9febbb665ce8c0c6ec5d30e903bdb6394b7ede26aebb49", size = 821591, upload-time = "2025-11-26T15:11:46.471Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/82/2f/e68750da9b04856e2a7ec56fc6f034a5a79775e9b9a81882252789873798/pydantic-2.12.4-py3-none-any.whl", hash = "sha256:92d3d202a745d46f9be6df459ac5a064fdaa3c1c4cd8adcfa332ccf3c05f871e", size = 463400, upload-time = "2025-11-05T10:50:06.732Z" }, + { url = "https://files.pythonhosted.org/packages/5a/87/b70ad306ebb6f9b585f114d0ac2137d792b48be34d732d60e597c2f8465a/pydantic-2.12.5-py3-none-any.whl", hash = "sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d", size = 463580, upload-time = "2025-11-26T15:11:44.605Z" }, ] [[package]] From af1d4afb95ce11ee18615a99a67b60c9660796d7 Mon Sep 17 00:00:00 2001 From: Dionysius <1341084+dionysius@users.noreply.github.com> Date: Tue, 2 Dec 2025 15:25:39 +0100 Subject: [PATCH 27/33] fix(docs): server and machine-learning use IMMICH_HOST and IMMICH_PORT (#24335) --- docs/docs/install/environment-variables.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/docs/install/environment-variables.md b/docs/docs/install/environment-variables.md index c3e9b3acd3..76784b285a 100644 --- a/docs/docs/install/environment-variables.md +++ b/docs/docs/install/environment-variables.md @@ -62,10 +62,10 @@ Information on the current workers can be found [here](/administration/jobs-work ## Ports -| Variable | Description | Default | -| :------------ | :------------- | :----------------------------------------: | -| `IMMICH_HOST` | Listening host | `0.0.0.0` | -| `IMMICH_PORT` | Listening port | `2283` (server), `3003` (machine learning) | +| Variable | Description | Default | Containers | +| :------------ | :------------- | :----------------------------------------: | :----------------------- | +| `IMMICH_HOST` | Listening host | `0.0.0.0` | server, machine learning | +| `IMMICH_PORT` | Listening port | `2283` (server), `3003` (machine learning) | server, machine learning | ## Database From bb341cc77452ef6c3f550966d49725352e637123 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 2 Dec 2025 09:26:39 -0500 Subject: [PATCH 28/33] chore(deps): update docker.io/valkey/valkey docker tag to v9 (#24336) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- docker/docker-compose.dev.yml | 2 +- docker/docker-compose.prod.yml | 2 +- docker/docker-compose.yml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docker/docker-compose.dev.yml b/docker/docker-compose.dev.yml index e2fb8fbc30..6fa1c51bdd 100644 --- a/docker/docker-compose.dev.yml +++ b/docker/docker-compose.dev.yml @@ -135,7 +135,7 @@ services: redis: container_name: immich_redis - image: docker.io/valkey/valkey:8@sha256:81db6d39e1bba3b3ff32bd3a1b19a6d69690f94a3954ec131277b9a26b95b3aa + image: docker.io/valkey/valkey:9@sha256:4503e204c900a00ad393bec83c8c7c4c76b0529cd629e23b34b52011aefd1d27 healthcheck: test: redis-cli ping || exit 1 diff --git a/docker/docker-compose.prod.yml b/docker/docker-compose.prod.yml index 90dc00d942..1aae886bc6 100644 --- a/docker/docker-compose.prod.yml +++ b/docker/docker-compose.prod.yml @@ -56,7 +56,7 @@ services: redis: container_name: immich_redis - image: docker.io/valkey/valkey:8@sha256:81db6d39e1bba3b3ff32bd3a1b19a6d69690f94a3954ec131277b9a26b95b3aa + image: docker.io/valkey/valkey:9@sha256:4503e204c900a00ad393bec83c8c7c4c76b0529cd629e23b34b52011aefd1d27 healthcheck: test: redis-cli ping || exit 1 restart: always diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index e4e0f964d3..51b6d3e860 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -49,7 +49,7 @@ services: redis: container_name: immich_redis - image: docker.io/valkey/valkey:8@sha256:81db6d39e1bba3b3ff32bd3a1b19a6d69690f94a3954ec131277b9a26b95b3aa + image: docker.io/valkey/valkey:9@sha256:4503e204c900a00ad393bec83c8c7c4c76b0529cd629e23b34b52011aefd1d27 healthcheck: test: redis-cli ping || exit 1 restart: always From 759413605063fac608bdfd00070ce928f33e295e Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 2 Dec 2025 09:27:12 -0500 Subject: [PATCH 29/33] chore(deps): update dependency express to v5.2.0 [security] (#24323) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- pnpm-lock.yaml | 154 ++++++++++++++++++++++++++++++++----------------- 1 file changed, 101 insertions(+), 53 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9a0ffe117f..8bb4338791 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -432,7 +432,7 @@ importers: version: 33.5.0 express: specifier: ^5.1.0 - version: 5.1.0 + version: 5.2.0 fast-glob: specifier: ^3.3.2 version: 3.3.3 @@ -5537,8 +5537,8 @@ packages: bl@4.1.0: resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==} - body-parser@1.20.3: - resolution: {integrity: sha512-7rAxByjUMqQ3/bHJy7D6OGXvx/MMc4IqBn/X0fcM1QUcAItpZrBEYhWGem+tzXH90c+G01ypMcYJBO9Y30203g==} + body-parser@1.20.4: + resolution: {integrity: sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA==} engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16} body-parser@2.2.1: @@ -5983,9 +5983,9 @@ packages: resolution: {integrity: sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==} engines: {node: '>= 0.6'} - content-disposition@1.0.0: - resolution: {integrity: sha512-Au9nRL8VNUut/XSzbQA38+M78dzP4D+eqg3gfJHMIHHYa3bg067xj1KxMUWj+VULbiZMowKngFFbKczUrNJ1mg==} - engines: {node: '>= 0.6'} + content-disposition@1.0.1: + resolution: {integrity: sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q==} + engines: {node: '>=18'} content-type@1.0.5: resolution: {integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==} @@ -6001,6 +6001,9 @@ packages: cookie-signature@1.0.6: resolution: {integrity: sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==} + cookie-signature@1.0.7: + resolution: {integrity: sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==} + cookie-signature@1.2.2: resolution: {integrity: sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==} engines: {node: '>=6.6.0'} @@ -6009,10 +6012,6 @@ packages: resolution: {integrity: sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==} engines: {node: '>= 0.6'} - cookie@0.7.1: - resolution: {integrity: sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w==} - engines: {node: '>= 0.6'} - cookie@0.7.2: resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==} engines: {node: '>= 0.6'} @@ -6869,14 +6868,18 @@ packages: exponential-backoff@3.1.3: resolution: {integrity: sha512-ZgEeZXj30q+I0EN+CbSSpIyPaJ5HVQD18Z1m+u1FXbAeT94mr1zw50q4q6jiiC447Nl/YTcIYSAftiGqetwXCA==} - express@4.21.2: - resolution: {integrity: sha512-28HqgMZAmih1Czt9ny7qr6ek2qddF4FclbMzwhCREB6OFfH+rXAnuNCwo1/wFvrtbgsQDb4kSbX9de9lFbrXnA==} + express@4.22.1: + resolution: {integrity: sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==} engines: {node: '>= 0.10.0'} express@5.1.0: resolution: {integrity: sha512-DT9ck5YIRU+8GYzzU5kT3eHGA5iL+1Zd0EutOmTE9Dtk+Tvuzd23VBU+ec7HPNSTxXYO55gPV/hq4pSBJDjFpA==} engines: {node: '>= 18'} + express@5.2.0: + resolution: {integrity: sha512-XdpJDLxfztVY59X0zPI6sibRiGcxhTPXRD3IhJmjKf2jwMvkRGV1j7loB8U+heeamoU3XvihAaGRTR4aXXUN3A==} + engines: {node: '>= 18'} + exsolve@1.0.7: resolution: {integrity: sha512-VO5fQUzZtI6C+vx4w/4BWJpg3s/5l+6pRQEHzFRM8WFi4XffSP1Z+4qi7GbjWbvRQEbdIco5mIMq+zX4rPuLrw==} @@ -6981,13 +6984,13 @@ packages: resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} engines: {node: '>=8'} - finalhandler@1.3.1: - resolution: {integrity: sha512-6BN9trH7bp3qvnrRyzsBz+g3lZxTNZTbVO2EV1CS0WIcDbawYVdYvGflME/9QP0h0pYlCDBCTjYa9nZzMDpyxQ==} + finalhandler@1.3.2: + resolution: {integrity: sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==} engines: {node: '>= 0.8'} - finalhandler@2.1.0: - resolution: {integrity: sha512-/t88Ty3d5JWQbWYgaOGCCYfXRwV1+be02WqYYlL6h0lEiUAMPM8o8qKGO01YIkOHzka2up08wvgYD0mDiI+q3Q==} - engines: {node: '>= 0.8'} + finalhandler@2.1.1: + resolution: {integrity: sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==} + engines: {node: '>= 18.0.0'} find-cache-dir@4.0.0: resolution: {integrity: sha512-9ZonPT4ZAK4a+1pUPVPZJapbi7O5qbbJPdYw/NOQWZZbVLdDTYM3A4R9z/DpAM08IDaFGsvPgiGZ82WEwUDWjg==} @@ -9863,10 +9866,6 @@ packages: engines: {node: '>=10.13.0'} hasBin: true - qs@6.13.0: - resolution: {integrity: sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg==} - engines: {node: '>=0.6'} - qs@6.14.0: resolution: {integrity: sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==} engines: {node: '>=0.6'} @@ -9909,8 +9908,8 @@ packages: resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==} engines: {node: '>= 0.6'} - raw-body@2.5.2: - resolution: {integrity: sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA==} + raw-body@2.5.3: + resolution: {integrity: sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==} engines: {node: '>= 0.8'} raw-body@3.0.2: @@ -10331,6 +10330,10 @@ packages: resolution: {integrity: sha512-dW41u5VfLXu8SJh5bwRmyYUbAoSB3c9uQh6L8h/KtsFREPWpbX1lrljJo186Jc4nmci/sGUZ9a0a0J2zgfq2hw==} engines: {node: '>= 0.8.0'} + send@0.19.1: + resolution: {integrity: sha512-p4rRk4f23ynFEfcD9LA0xRYngj+IyGiEYyqqOak8kaN0TvNmuxC2dcVeBn62GpCeR2CpWqyHCNScTP91QbAVFg==} + engines: {node: '>= 0.8.0'} + send@1.2.0: resolution: {integrity: sha512-uaW0WwXKpL9blXE2o0bRhoL2EGXIrZxQ2ZQ4mgcfoBxdFmQold+qWsD2jLrfZ0trjKL6vOw0j//eAwcALFjKSw==} engines: {node: '>= 18'} @@ -17623,18 +17626,18 @@ snapshots: inherits: 2.0.4 readable-stream: 3.6.2 - body-parser@1.20.3: + body-parser@1.20.4: dependencies: bytes: 3.1.2 content-type: 1.0.5 debug: 2.6.9 depd: 2.0.0 destroy: 1.2.0 - http-errors: 2.0.0 + http-errors: 2.0.1 iconv-lite: 0.4.24 on-finished: 2.4.1 - qs: 6.13.0 - raw-body: 2.5.2 + qs: 6.14.0 + raw-body: 2.5.3 type-is: 1.6.18 unpipe: 1.0.0 transitivePeerDependencies: @@ -18118,9 +18121,7 @@ snapshots: dependencies: safe-buffer: 5.2.1 - content-disposition@1.0.0: - dependencies: - safe-buffer: 5.2.1 + content-disposition@1.0.1: {} content-type@1.0.5: {} @@ -18133,12 +18134,12 @@ snapshots: cookie-signature@1.0.6: {} + cookie-signature@1.0.7: {} + cookie-signature@1.2.2: {} cookie@0.6.0: {} - cookie@0.7.1: {} - cookie@0.7.2: {} cookie@1.0.2: {} @@ -19152,36 +19153,36 @@ snapshots: exponential-backoff@3.1.3: {} - express@4.21.2: + express@4.22.1: dependencies: accepts: 1.3.8 array-flatten: 1.1.1 - body-parser: 1.20.3 + body-parser: 1.20.4 content-disposition: 0.5.4 content-type: 1.0.5 - cookie: 0.7.1 - cookie-signature: 1.0.6 + cookie: 0.7.2 + cookie-signature: 1.0.7 debug: 2.6.9 depd: 2.0.0 encodeurl: 2.0.0 escape-html: 1.0.3 etag: 1.8.1 - finalhandler: 1.3.1 + finalhandler: 1.3.2 fresh: 0.5.2 - http-errors: 2.0.0 + http-errors: 2.0.1 merge-descriptors: 1.0.3 methods: 1.1.2 on-finished: 2.4.1 parseurl: 1.3.3 path-to-regexp: 0.1.12 proxy-addr: 2.0.7 - qs: 6.13.0 + qs: 6.14.0 range-parser: 1.2.1 safe-buffer: 5.2.1 - send: 0.19.0 + send: 0.19.1 serve-static: 1.16.2 setprototypeof: 1.2.0 - statuses: 2.0.1 + statuses: 2.0.2 type-is: 1.6.18 utils-merge: 1.0.1 vary: 1.1.2 @@ -19192,7 +19193,7 @@ snapshots: dependencies: accepts: 2.0.0 body-parser: 2.2.1 - content-disposition: 1.0.0 + content-disposition: 1.0.1 content-type: 1.0.5 cookie: 0.7.2 cookie-signature: 1.2.2 @@ -19200,7 +19201,40 @@ snapshots: encodeurl: 2.0.0 escape-html: 1.0.3 etag: 1.8.1 - finalhandler: 2.1.0 + finalhandler: 2.1.1 + fresh: 2.0.0 + http-errors: 2.0.1 + merge-descriptors: 2.0.0 + mime-types: 3.0.2 + on-finished: 2.4.1 + once: 1.4.0 + parseurl: 1.3.3 + proxy-addr: 2.0.7 + qs: 6.14.0 + range-parser: 1.2.1 + router: 2.2.0 + send: 1.2.0 + serve-static: 2.2.0 + statuses: 2.0.2 + type-is: 2.0.1 + vary: 1.1.2 + transitivePeerDependencies: + - supports-color + + express@5.2.0: + dependencies: + accepts: 2.0.0 + body-parser: 2.2.1 + content-disposition: 1.0.1 + content-type: 1.0.5 + cookie: 0.7.2 + cookie-signature: 1.2.2 + debug: 4.4.3 + depd: 2.0.0 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + finalhandler: 2.1.1 fresh: 2.0.0 http-errors: 2.0.1 merge-descriptors: 2.0.0 @@ -19328,19 +19362,19 @@ snapshots: dependencies: to-regex-range: 5.0.1 - finalhandler@1.3.1: + finalhandler@1.3.2: dependencies: debug: 2.6.9 encodeurl: 2.0.0 escape-html: 1.0.3 on-finished: 2.4.1 parseurl: 1.3.3 - statuses: 2.0.1 + statuses: 2.0.2 unpipe: 1.0.0 transitivePeerDependencies: - supports-color - finalhandler@2.1.0: + finalhandler@2.1.1: dependencies: debug: 4.4.3 encodeurl: 2.0.0 @@ -22739,10 +22773,6 @@ snapshots: pngjs: 5.0.0 yargs: 15.4.1 - qs@6.13.0: - dependencies: - side-channel: 1.1.0 - qs@6.14.0: dependencies: side-channel: 1.1.0 @@ -22775,10 +22805,10 @@ snapshots: range-parser@1.2.1: {} - raw-body@2.5.2: + raw-body@2.5.3: dependencies: bytes: 3.1.2 - http-errors: 2.0.0 + http-errors: 2.0.1 iconv-lite: 0.4.24 unpipe: 1.0.0 @@ -23342,6 +23372,24 @@ snapshots: transitivePeerDependencies: - supports-color + send@0.19.1: + dependencies: + debug: 2.6.9 + depd: 2.0.0 + destroy: 1.2.0 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + fresh: 0.5.2 + http-errors: 2.0.0 + mime: 1.6.0 + ms: 2.1.3 + on-finished: 2.4.1 + range-parser: 1.2.1 + statuses: 2.0.1 + transitivePeerDependencies: + - supports-color + send@1.2.0: dependencies: debug: 4.4.3 @@ -24821,7 +24869,7 @@ snapshots: colorette: 2.0.20 compression: 1.8.1 connect-history-api-fallback: 2.0.0 - express: 4.21.2 + express: 4.22.1 graceful-fs: 4.2.11 http-proxy-middleware: 2.0.9(@types/express@4.17.25) ipaddr.js: 2.2.0 From 116012f6f823aa1a0edd37bd7b2250981b7785b7 Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Tue, 2 Dec 2025 10:56:31 -0500 Subject: [PATCH 30/33] feat: less asset-metadata validation (#24342) --- .../controllers/asset-media.controller.spec.ts | 13 ------------- server/src/dtos/asset.dto.ts | 16 ++-------------- server/src/repositories/asset.repository.ts | 3 +-- server/src/schema/tables/asset-metadata.table.ts | 7 +++---- server/src/types.ts | 10 ---------- 5 files changed, 6 insertions(+), 43 deletions(-) diff --git a/server/src/controllers/asset-media.controller.spec.ts b/server/src/controllers/asset-media.controller.spec.ts index eb594fbe47..c2f6aeacef 100644 --- a/server/src/controllers/asset-media.controller.spec.ts +++ b/server/src/controllers/asset-media.controller.spec.ts @@ -85,19 +85,6 @@ describe(AssetMediaController.name, () => { expect(body).toEqual(factory.responses.badRequest(['metadata must be valid JSON'])); }); - it('should validate iCloudId is a string', async () => { - const { status, body } = await request(ctx.getHttpServer()) - .post('/assets') - .attach('assetData', assetData, filename) - .field({ - ...makeUploadDto(), - metadata: JSON.stringify([{ key: AssetMetadataKey.MobileApp, value: { iCloudId: 123 } }]), - }); - - expect(status).toBe(400); - expect(body).toEqual(factory.responses.badRequest(['metadata.0.value.iCloudId must be a string'])); - }); - it('should require `deviceAssetId`', async () => { const { status, body } = await request(ctx.getHttpServer()) .post('/assets') diff --git a/server/src/dtos/asset.dto.ts b/server/src/dtos/asset.dto.ts index dc43a0200c..03d1e31fb9 100644 --- a/server/src/dtos/asset.dto.ts +++ b/server/src/dtos/asset.dto.ts @@ -19,7 +19,6 @@ import { import { BulkIdsDto } from 'src/dtos/asset-ids.response.dto'; import { AssetMetadataKey, AssetType, AssetVisibility } from 'src/enum'; import { AssetStats } from 'src/repositories/asset.repository'; -import { AssetMetadata, AssetMetadataItem } from 'src/types'; import { IsNotSiblingOf, Optional, ValidateBoolean, ValidateEnum, ValidateUUID } from 'src/validation'; export class DeviceIdDto { @@ -154,23 +153,12 @@ export class AssetMetadataUpsertDto { items!: AssetMetadataUpsertItemDto[]; } -export class AssetMetadataUpsertItemDto implements AssetMetadataItem { +export class AssetMetadataUpsertItemDto { @ValidateEnum({ enum: AssetMetadataKey, name: 'AssetMetadataKey' }) key!: AssetMetadataKey; @IsObject() - @ValidateNested() - @Type((options) => { - switch (options?.object.key) { - case AssetMetadataKey.MobileApp: { - return AssetMetadataMobileAppDto; - } - default: { - return Object; - } - } - }) - value!: AssetMetadata[AssetMetadataKey]; + value!: object; } export class AssetMetadataMobileAppDto { diff --git a/server/src/repositories/asset.repository.ts b/server/src/repositories/asset.repository.ts index d3d9ada80f..fc83c30c9a 100644 --- a/server/src/repositories/asset.repository.ts +++ b/server/src/repositories/asset.repository.ts @@ -11,7 +11,6 @@ import { AssetExifTable } from 'src/schema/tables/asset-exif.table'; import { AssetFileTable } from 'src/schema/tables/asset-file.table'; import { AssetJobStatusTable } from 'src/schema/tables/asset-job-status.table'; import { AssetTable } from 'src/schema/tables/asset.table'; -import { AssetMetadataItem } from 'src/types'; import { anyUuid, asUuid, @@ -224,7 +223,7 @@ export class AssetRepository { .execute(); } - upsertMetadata(id: string, items: AssetMetadataItem[]) { + upsertMetadata(id: string, items: Array<{ key: AssetMetadataKey; value: object }>) { return this.db .insertInto('asset_metadata') .values(items.map((item) => ({ assetId: id, ...item }))) diff --git a/server/src/schema/tables/asset-metadata.table.ts b/server/src/schema/tables/asset-metadata.table.ts index 486101408d..d529d6ad7b 100644 --- a/server/src/schema/tables/asset-metadata.table.ts +++ b/server/src/schema/tables/asset-metadata.table.ts @@ -12,7 +12,6 @@ import { Timestamp, UpdateDateColumn, } from 'src/sql-tools'; -import { AssetMetadata, AssetMetadataItem } from 'src/types'; @UpdatedAtTrigger('asset_metadata_updated_at') @Table('asset_metadata') @@ -22,7 +21,7 @@ import { AssetMetadata, AssetMetadataItem } from 'src/types'; referencingOldTableAs: 'old', when: 'pg_trigger_depth() = 0', }) -export class AssetMetadataTable implements AssetMetadataItem { +export class AssetMetadataTable { @ForeignKeyColumn(() => AssetTable, { onUpdate: 'CASCADE', onDelete: 'CASCADE', @@ -33,10 +32,10 @@ export class AssetMetadataTable; diff --git a/server/src/types.ts b/server/src/types.ts index 848d19177d..11a5d5f66c 100644 --- a/server/src/types.ts +++ b/server/src/types.ts @@ -4,7 +4,6 @@ import { Asset } from 'src/database'; import { UploadFieldName } from 'src/dtos/asset-media.dto'; import { AuthDto } from 'src/dtos/auth.dto'; import { - AssetMetadataKey, AssetOrder, AssetType, DatabaseSslMode, @@ -563,12 +562,3 @@ export interface UserMetadata extends Record = { - key: T; - value: AssetMetadata[T]; -}; - -export interface AssetMetadata extends Record> { - [AssetMetadataKey.MobileApp]: { iCloudId: string }; -} From b11aecd18415f76ae2b4dddbc902cd7a0f702066 Mon Sep 17 00:00:00 2001 From: Hai Sullivan Date: Wed, 3 Dec 2025 03:37:19 +1100 Subject: [PATCH 31/33] fix(mobile): use correct timezone displayed in the info sheet (#24310) * fixed the timezone issue in the Immich mobile app's metadata sheet to match the web app's behavior * format dart * now uses the shared applyTimezoneOffset() utility function from mobile/lib/utils/timezone.dart * add tests --------- Co-authored-by: Alex --- mobile/lib/extensions/asset_extensions.dart | 21 +- .../asset_viewer/bottom_sheet.widget.dart | 22 +- mobile/lib/services/action.service.dart | 15 +- mobile/lib/utils/timezone.dart | 35 +++ mobile/test/utils/timezone_test.dart | 278 ++++++++++++++++++ 5 files changed, 345 insertions(+), 26 deletions(-) create mode 100644 mobile/lib/utils/timezone.dart create mode 100644 mobile/test/utils/timezone_test.dart diff --git a/mobile/lib/extensions/asset_extensions.dart b/mobile/lib/extensions/asset_extensions.dart index 22d5d5030a..a8ca7ef2aa 100644 --- a/mobile/lib/extensions/asset_extensions.dart +++ b/mobile/lib/extensions/asset_extensions.dart @@ -1,5 +1,5 @@ import 'package:immich_mobile/entities/asset.entity.dart'; -import 'package:timezone/timezone.dart'; +import 'package:immich_mobile/utils/timezone.dart'; extension TZExtension on Asset { /// Returns the created time of the asset from the exif info (if available) or from @@ -7,24 +7,11 @@ extension TZExtension on Asset { /// the timezone offset in [Duration] (DateTime, Duration) getTZAdjustedTimeAndOffset() { DateTime dt = fileCreatedAt.toLocal(); + if (exifInfo?.dateTimeOriginal != null) { - dt = exifInfo!.dateTimeOriginal!; - if (exifInfo?.timeZone != null) { - dt = dt.toUtc(); - try { - final location = getLocation(exifInfo!.timeZone!); - dt = TZDateTime.from(dt, location); - } on LocationNotFoundException { - RegExp re = RegExp(r'^utc(?:([+-]\d{1,2})(?::(\d{2}))?)?$', caseSensitive: false); - final m = re.firstMatch(exifInfo!.timeZone!); - if (m != null) { - final duration = Duration(hours: int.parse(m.group(1) ?? '0'), minutes: int.parse(m.group(2) ?? '0')); - dt = dt.add(duration); - return (dt, duration); - } - } - } + return applyTimezoneOffset(dateTime: exifInfo!.dateTimeOriginal!, timeZone: exifInfo?.timeZone); } + return (dt, dt.timeZoneOffset); } } diff --git a/mobile/lib/presentation/widgets/asset_viewer/bottom_sheet.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/bottom_sheet.widget.dart index 582a33136a..276034d3d6 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/bottom_sheet.widget.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/bottom_sheet.widget.dart @@ -10,6 +10,7 @@ import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; import 'package:immich_mobile/domain/models/exif.model.dart'; import 'package:immich_mobile/domain/models/setting.model.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; +import 'package:immich_mobile/extensions/duration_extensions.dart'; import 'package:immich_mobile/extensions/translate_extensions.dart'; import 'package:immich_mobile/presentation/widgets/album/album_tile.dart'; import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart'; @@ -29,6 +30,7 @@ import 'package:immich_mobile/repositories/asset_media.repository.dart'; import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/utils/action_button.utils.dart'; import 'package:immich_mobile/utils/bytes_units.dart'; +import 'package:immich_mobile/utils/timezone.dart'; import 'package:immich_mobile/widgets/common/immich_toast.dart'; const _kSeparator = ' • '; @@ -85,13 +87,21 @@ class AssetDetailBottomSheet extends ConsumerWidget { class _AssetDetailBottomSheet extends ConsumerWidget { const _AssetDetailBottomSheet(); - String _getDateTime(BuildContext ctx, BaseAsset asset) { - final dateTime = asset.createdAt.toLocal(); + String _getDateTime(BuildContext ctx, BaseAsset asset, ExifInfo? exifInfo) { + DateTime dateTime = asset.createdAt.toLocal(); + Duration timeZoneOffset = dateTime.timeZoneOffset; + + // Use EXIF timezone information if available (matching web app behavior) + if (exifInfo?.dateTimeOriginal != null) { + (dateTime, timeZoneOffset) = applyTimezoneOffset( + dateTime: exifInfo!.dateTimeOriginal!, + timeZone: exifInfo.timeZone, + ); + } + final date = DateFormat.yMMMEd(ctx.locale.toLanguageTag()).format(dateTime); final time = DateFormat.jm(ctx.locale.toLanguageTag()).format(dateTime); - final timezone = dateTime.timeZoneOffset.isNegative - ? 'UTC-${dateTime.timeZoneOffset.inHours.abs().toString().padLeft(2, '0')}:${(dateTime.timeZoneOffset.inMinutes.abs() % 60).toString().padLeft(2, '0')}' - : 'UTC+${dateTime.timeZoneOffset.inHours.toString().padLeft(2, '0')}:${(dateTime.timeZoneOffset.inMinutes.abs() % 60).toString().padLeft(2, '0')}'; + final timezone = 'GMT${timeZoneOffset.formatAsOffset()}'; return '$date$_kSeparator$time $timezone'; } @@ -269,7 +279,7 @@ class _AssetDetailBottomSheet extends ConsumerWidget { children: [ // Asset Date and Time SheetTile( - title: _getDateTime(context, asset), + title: _getDateTime(context, asset, exifInfo), titleStyle: context.textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w600), trailing: asset.hasRemote && isOwner ? const Icon(Icons.edit, size: 18) : null, onTap: asset.hasRemote && isOwner ? () async => await _editDateTime(context, ref) : null, diff --git a/mobile/lib/services/action.service.dart b/mobile/lib/services/action.service.dart index 59b627ecc3..4261613a19 100644 --- a/mobile/lib/services/action.service.dart +++ b/mobile/lib/services/action.service.dart @@ -15,6 +15,7 @@ import 'package:immich_mobile/repositories/asset_media.repository.dart'; import 'package:immich_mobile/repositories/download.repository.dart'; import 'package:immich_mobile/repositories/drift_album_api_repository.dart'; import 'package:immich_mobile/routing/router.dart'; +import 'package:immich_mobile/utils/timezone.dart'; import 'package:immich_mobile/widgets/common/date_time_picker.dart'; import 'package:immich_mobile/widgets/common/location_picker.dart'; import 'package:maplibre_gl/maplibre_gl.dart' as maplibre; @@ -175,9 +176,17 @@ class ActionService { } final exifData = await _remoteAssetRepository.getExif(assetId); - initialDate = asset.createdAt.toLocal(); - offset = initialDate.timeZoneOffset; - timeZone = exifData?.timeZone; + + // Use EXIF timezone information if available (matching web app and display behavior) + DateTime dt = asset.createdAt.toLocal(); + offset = dt.timeZoneOffset; + + if (exifData?.dateTimeOriginal != null) { + timeZone = exifData!.timeZone; + (dt, offset) = applyTimezoneOffset(dateTime: exifData.dateTimeOriginal!, timeZone: exifData.timeZone); + } + + initialDate = dt; } final dateTime = await showDateTimePicker( diff --git a/mobile/lib/utils/timezone.dart b/mobile/lib/utils/timezone.dart new file mode 100644 index 0000000000..d75122062f --- /dev/null +++ b/mobile/lib/utils/timezone.dart @@ -0,0 +1,35 @@ +import 'package:timezone/timezone.dart'; + +/// Applies timezone conversion to a DateTime using EXIF timezone information. +/// +/// This function handles two timezone formats: +/// 1. Named timezone locations (e.g., "Asia/Hong_Kong") +/// 2. UTC offset format (e.g., "UTC+08:00", "UTC-05:00") +/// +/// Returns a tuple of (adjusted DateTime, timezone offset Duration) +(DateTime, Duration) applyTimezoneOffset({required DateTime dateTime, required String? timeZone}) { + DateTime dt = dateTime.toUtc(); + + if (timeZone == null) { + return (dt, dt.timeZoneOffset); + } + + try { + // Try to get timezone location from database + final location = getLocation(timeZone); + dt = TZDateTime.from(dt, location); + return (dt, dt.timeZoneOffset); + } on LocationNotFoundException { + // Handle UTC offset format (e.g., "UTC+08:00") + RegExp re = RegExp(r'^utc(?:([+-]\d{1,2})(?::(\d{2}))?)?$', caseSensitive: false); + final m = re.firstMatch(timeZone); + if (m != null) { + final duration = Duration(hours: int.parse(m.group(1) ?? '0'), minutes: int.parse(m.group(2) ?? '0')); + dt = dt.add(duration); + return (dt, duration); + } + } + + // If timezone is invalid, return UTC + return (dt, dt.timeZoneOffset); +} diff --git a/mobile/test/utils/timezone_test.dart b/mobile/test/utils/timezone_test.dart new file mode 100644 index 0000000000..d1e89dc473 --- /dev/null +++ b/mobile/test/utils/timezone_test.dart @@ -0,0 +1,278 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:immich_mobile/utils/timezone.dart'; +import 'package:timezone/data/latest.dart' as tz; + +void main() { + setUpAll(() { + tz.initializeTimeZones(); + }); + + group('applyTimezoneOffset', () { + group('with named timezone locations', () { + test('should convert UTC to Asia/Hong_Kong (+08:00)', () { + final utcTime = DateTime.utc(2024, 6, 15, 12, 0, 0); + + final (adjustedTime, offset) = applyTimezoneOffset( + dateTime: utcTime, + timeZone: 'Asia/Hong_Kong', + ); + + expect(adjustedTime.hour, 20); // 12:00 UTC + 8 hours = 20:00 + expect(offset, const Duration(hours: 8)); + }); + + test('should convert UTC to America/New_York (handles DST)', () { + // Summer time (EDT = UTC-4) + final summerUtc = DateTime.utc(2024, 6, 15, 12, 0, 0); + final (summerTime, summerOffset) = applyTimezoneOffset( + dateTime: summerUtc, + timeZone: 'America/New_York', + ); + + expect(summerTime.hour, 8); // 12:00 UTC - 4 hours = 08:00 + expect(summerOffset, const Duration(hours: -4)); + + // Winter time (EST = UTC-5) + final winterUtc = DateTime.utc(2024, 1, 15, 12, 0, 0); + final (winterTime, winterOffset) = applyTimezoneOffset( + dateTime: winterUtc, + timeZone: 'America/New_York', + ); + + expect(winterTime.hour, 7); // 12:00 UTC - 5 hours = 07:00 + expect(winterOffset, const Duration(hours: -5)); + }); + + test('should convert UTC to Europe/London', () { + // Winter (GMT = UTC+0) + final winterUtc = DateTime.utc(2024, 1, 15, 12, 0, 0); + final (winterTime, winterOffset) = applyTimezoneOffset( + dateTime: winterUtc, + timeZone: 'Europe/London', + ); + + expect(winterTime.hour, 12); + expect(winterOffset, Duration.zero); + + // Summer (BST = UTC+1) + final summerUtc = DateTime.utc(2024, 6, 15, 12, 0, 0); + final (summerTime, summerOffset) = applyTimezoneOffset( + dateTime: summerUtc, + timeZone: 'Europe/London', + ); + + expect(summerTime.hour, 13); + expect(summerOffset, const Duration(hours: 1)); + }); + + test('should handle timezone with 30-minute offset (Asia/Kolkata)', () { + final utcTime = DateTime.utc(2024, 6, 15, 12, 0, 0); + + final (adjustedTime, offset) = applyTimezoneOffset( + dateTime: utcTime, + timeZone: 'Asia/Kolkata', + ); + + expect(adjustedTime.hour, 17); + expect(adjustedTime.minute, 30); // 12:00 UTC + 5:30 = 17:30 + expect(offset, const Duration(hours: 5, minutes: 30)); + }); + + test('should handle timezone with 45-minute offset (Asia/Kathmandu)', () { + final utcTime = DateTime.utc(2024, 6, 15, 12, 0, 0); + + final (adjustedTime, offset) = applyTimezoneOffset( + dateTime: utcTime, + timeZone: 'Asia/Kathmandu', + ); + + expect(adjustedTime.hour, 17); + expect(adjustedTime.minute, 45); // 12:00 UTC + 5:45 = 17:45 + expect(offset, const Duration(hours: 5, minutes: 45)); + }); + }); + + group('with UTC offset format', () { + test('should handle UTC+08:00 format', () { + final utcTime = DateTime.utc(2024, 6, 15, 12, 0, 0); + + final (adjustedTime, offset) = applyTimezoneOffset( + dateTime: utcTime, + timeZone: 'UTC+08:00', + ); + + expect(adjustedTime.hour, 20); + expect(offset, const Duration(hours: 8)); + }); + + test('should handle UTC-05:00 format', () { + final utcTime = DateTime.utc(2024, 6, 15, 12, 0, 0); + + final (adjustedTime, offset) = applyTimezoneOffset( + dateTime: utcTime, + timeZone: 'UTC-05:00', + ); + + expect(adjustedTime.hour, 7); + expect(offset, const Duration(hours: -5)); + }); + + test('should handle UTC+8 format (without minutes)', () { + final utcTime = DateTime.utc(2024, 6, 15, 12, 0, 0); + + final (adjustedTime, offset) = applyTimezoneOffset( + dateTime: utcTime, + timeZone: 'UTC+8', + ); + + expect(adjustedTime.hour, 20); + expect(offset, const Duration(hours: 8)); + }); + + test('should handle UTC-5 format (without minutes)', () { + final utcTime = DateTime.utc(2024, 6, 15, 12, 0, 0); + + final (adjustedTime, offset) = applyTimezoneOffset( + dateTime: utcTime, + timeZone: 'UTC-5', + ); + + expect(adjustedTime.hour, 7); + expect(offset, const Duration(hours: -5)); + }); + + test('should handle plain UTC format', () { + final utcTime = DateTime.utc(2024, 6, 15, 12, 0, 0); + + final (adjustedTime, offset) = applyTimezoneOffset( + dateTime: utcTime, + timeZone: 'UTC', + ); + + expect(adjustedTime.hour, 12); + expect(offset, Duration.zero); + }); + + test('should handle lowercase utc format', () { + final utcTime = DateTime.utc(2024, 6, 15, 12, 0, 0); + + final (adjustedTime, offset) = applyTimezoneOffset( + dateTime: utcTime, + timeZone: 'utc+08:00', + ); + + expect(adjustedTime.hour, 20); + expect(offset, const Duration(hours: 8)); + }); + + test('should handle UTC+05:30 format (with minutes)', () { + final utcTime = DateTime.utc(2024, 6, 15, 12, 0, 0); + + final (adjustedTime, offset) = applyTimezoneOffset( + dateTime: utcTime, + timeZone: 'UTC+05:30', + ); + + expect(adjustedTime.hour, 17); + expect(adjustedTime.minute, 30); + expect(offset, const Duration(hours: 5, minutes: 30)); + }); + }); + + group('with null or invalid timezone', () { + test('should return UTC time when timezone is null', () { + final localTime = DateTime(2024, 6, 15, 12, 0, 0); + + final (adjustedTime, offset) = applyTimezoneOffset( + dateTime: localTime, + timeZone: null, + ); + + expect(adjustedTime.isUtc, true); + expect(offset, adjustedTime.timeZoneOffset); + }); + + test('should return UTC time when timezone is invalid', () { + final utcTime = DateTime.utc(2024, 6, 15, 12, 0, 0); + + final (adjustedTime, offset) = applyTimezoneOffset( + dateTime: utcTime, + timeZone: 'Invalid/Timezone', + ); + + expect(adjustedTime.isUtc, true); + expect(adjustedTime.hour, 12); + expect(offset, adjustedTime.timeZoneOffset); + }); + + test('should return UTC time when UTC offset format is malformed', () { + final utcTime = DateTime.utc(2024, 6, 15, 12, 0, 0); + + final (adjustedTime, offset) = applyTimezoneOffset( + dateTime: utcTime, + timeZone: 'UTC++08', + ); + + expect(adjustedTime.isUtc, true); + expect(adjustedTime.hour, 12); + }); + }); + + group('edge cases', () { + test('should handle date crossing midnight forward', () { + final utcTime = DateTime.utc(2024, 6, 15, 20, 0, 0); + + final (adjustedTime, offset) = applyTimezoneOffset( + dateTime: utcTime, + timeZone: 'Asia/Tokyo', // UTC+9 + ); + + expect(adjustedTime.day, 16); // Crosses to next day + expect(adjustedTime.hour, 5); // 20:00 UTC + 9 = 05:00 next day + expect(offset, const Duration(hours: 9)); + }); + + test('should handle date crossing midnight backward', () { + final utcTime = DateTime.utc(2024, 6, 15, 3, 0, 0); + + final (adjustedTime, offset) = applyTimezoneOffset( + dateTime: utcTime, + timeZone: 'America/Los_Angeles', // UTC-7 in summer + ); + + expect(adjustedTime.day, 14); // Crosses to previous day + expect(adjustedTime.hour, 20); // 03:00 UTC - 7 = 20:00 previous day + expect(offset, const Duration(hours: -7)); + }); + + test('should handle year boundary crossing', () { + final utcTime = DateTime.utc(2024, 1, 1, 2, 0, 0); + + final (adjustedTime, offset) = applyTimezoneOffset( + dateTime: utcTime, + timeZone: 'America/New_York', // UTC-5 in winter + ); + + expect(adjustedTime.year, 2023); + expect(adjustedTime.month, 12); + expect(adjustedTime.day, 31); + expect(adjustedTime.hour, 21); // 02:00 UTC - 5 = 21:00 Dec 31 + }); + + test('should convert local time to UTC before applying timezone', () { + // Create a local time (not UTC) + final localTime = DateTime(2024, 6, 15, 12, 0, 0); + + final (adjustedTime, _) = applyTimezoneOffset( + dateTime: localTime, + timeZone: 'Asia/Hong_Kong', + ); + + // The function converts to UTC first, then applies timezone + // So local 12:00 -> UTC (depends on local timezone) -> HK time + // We can verify it's working by checking it's a TZDateTime + expect(adjustedTime, isNotNull); + }); + }); + }); +} From 62628dfcfae606f2cf578c6008e82013830fd882 Mon Sep 17 00:00:00 2001 From: Jonathan Jogenfors Date: Tue, 2 Dec 2025 18:48:12 +0100 Subject: [PATCH 32/33] fix(web): folder view sort oder (#24337) fix: folder view sort oder --- web/src/lib/utils/tree-utils.ts | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/web/src/lib/utils/tree-utils.ts b/web/src/lib/utils/tree-utils.ts index 267bb2eec7..64b51158c4 100644 --- a/web/src/lib/utils/tree-utils.ts +++ b/web/src/lib/utils/tree-utils.ts @@ -62,8 +62,16 @@ export class TreeNode extends Map { const child = this.values().next().value!; child.value = joinPaths(this.value, child.value); child.parent = this.parent; - this.parent.delete(this.value); - this.parent.set(child.value, child); + + const entries = Array.from(this.parent.entries()); + this.parent.clear(); + for (const [key, value] of entries) { + if (key === this.value) { + this.parent.set(child.value, child); + } else { + this.parent.set(key, value); + } + } } for (const child of this.values()) { From 1bcf28c06253304cb5a7634038a4d7fc606af1f2 Mon Sep 17 00:00:00 2001 From: Jonathan Jogenfors Date: Tue, 2 Dec 2025 19:31:43 +0100 Subject: [PATCH 33/33] chore(server): sidecars in asset_files (#21199) * fix: sidecar check job * feat: move sidecars to asset_files * feat: combine with handleSidecarCheck * fix(server): improved method signatures for stack and sidecar copying * fix(server): improved method signatures for stack and sidecar copying * chore: clean up --------- Co-authored-by: Jason Rasmussen --- e2e/src/api/specs/library.e2e-spec.ts | 2 +- server/src/cores/storage.core.ts | 2 +- server/src/database.ts | 9 -- server/src/dtos/asset-response.dto.ts | 1 - server/src/enum.ts | 1 + server/src/queries/asset.job.repository.sql | 118 +++++++++++++++--- server/src/queries/asset.repository.sql | 28 +++++ .../src/repositories/asset-job.repository.ts | 32 +++-- server/src/repositories/asset.repository.ts | 15 +++ .../src/repositories/database.repository.ts | 1 - .../1764698859174-SidecarInAssetFile.ts | 24 ++++ server/src/schema/tables/asset.table.ts | 3 - .../src/services/asset-media.service.spec.ts | 26 +++- server/src/services/asset-media.service.ts | 23 +++- server/src/services/asset.service.spec.ts | 4 +- server/src/services/asset.service.ts | 40 ++++-- server/src/services/metadata.service.spec.ts | 103 ++++++++++----- server/src/services/metadata.service.ts | 39 +++--- .../src/services/storage-template.service.ts | 16 ++- server/src/types.ts | 4 +- server/src/utils/asset.util.ts | 1 + server/test/fixtures/asset.stub.ts | 43 +++---- .../specs/services/asset.service.spec.ts | 12 +- .../specs/services/metadata.service.spec.ts | 6 +- .../repositories/asset.repository.mock.ts | 2 + server/test/small.factory.ts | 24 +++- 26 files changed, 425 insertions(+), 154 deletions(-) create mode 100644 server/src/schema/migrations/1764698859174-SidecarInAssetFile.ts diff --git a/e2e/src/api/specs/library.e2e-spec.ts b/e2e/src/api/specs/library.e2e-spec.ts index 59ff74cc43..4d67a84647 100644 --- a/e2e/src/api/specs/library.e2e-spec.ts +++ b/e2e/src/api/specs/library.e2e-spec.ts @@ -1006,7 +1006,7 @@ describe('/libraries', () => { rmSync(`${testAssetDir}/temp/xmp`, { recursive: true, force: true }); }); - it('should switch from using file metadata to file.xmp metadata when asset refreshes', async () => { + it('should switch from using file metadata to file.ext.xmp metadata when asset refreshes', async () => { const library = await utils.createLibrary(admin.accessToken, { ownerId: admin.userId, importPaths: [`${testAssetDirInternal}/temp/xmp`], diff --git a/server/src/cores/storage.core.ts b/server/src/cores/storage.core.ts index 25573cb08e..96623092f1 100644 --- a/server/src/cores/storage.core.ts +++ b/server/src/cores/storage.core.ts @@ -305,7 +305,7 @@ export class StorageCore { return this.assetRepository.update({ id, encodedVideoPath: newPath }); } case AssetPathType.Sidecar: { - return this.assetRepository.update({ id, sidecarPath: newPath }); + return this.assetRepository.upsertFile({ assetId: id, type: AssetFileType.Sidecar, path: newPath }); } case PersonPathType.Face: { return this.personRepository.update({ id, thumbnailPath: newPath }); diff --git a/server/src/database.ts b/server/src/database.ts index 4aa69127ff..8e18fa9408 100644 --- a/server/src/database.ts +++ b/server/src/database.ts @@ -122,7 +122,6 @@ export type Asset = { originalFileName: string; originalPath: string; ownerId: string; - sidecarPath: string | null; type: AssetType; }; @@ -156,13 +155,6 @@ export type StorageAsset = { encodedVideoPath: string | null; }; -export type SidecarWriteAsset = { - id: string; - sidecarPath: string | null; - originalPath: string; - tags: Array<{ value: string }>; -}; - export type Stack = { id: string; primaryAssetId: string; @@ -347,7 +339,6 @@ export const columns = { 'asset.originalFileName', 'asset.originalPath', 'asset.ownerId', - 'asset.sidecarPath', 'asset.type', ], assetFiles: ['asset_file.id', 'asset_file.path', 'asset_file.type'], diff --git a/server/src/dtos/asset-response.dto.ts b/server/src/dtos/asset-response.dto.ts index 1716c327f3..e228cd8f9f 100644 --- a/server/src/dtos/asset-response.dto.ts +++ b/server/src/dtos/asset-response.dto.ts @@ -124,7 +124,6 @@ export type MapAsset = { originalPath: string; owner?: User | null; ownerId: string; - sidecarPath: string | null; stack?: Stack | null; stackId: string | null; tags?: Tag[]; diff --git a/server/src/enum.ts b/server/src/enum.ts index 87ff282f31..9d0a2c0426 100644 --- a/server/src/enum.ts +++ b/server/src/enum.ts @@ -44,6 +44,7 @@ export enum AssetFileType { FullSize = 'fullsize', Preview = 'preview', Thumbnail = 'thumbnail', + Sidecar = 'sidecar', } export enum AlbumUserRole { diff --git a/server/src/queries/asset.job.repository.sql b/server/src/queries/asset.job.repository.sql index ebfd1a08c9..d6dc564458 100644 --- a/server/src/queries/asset.job.repository.sql +++ b/server/src/queries/asset.job.repository.sql @@ -20,8 +20,23 @@ limit -- AssetJobRepository.getForSidecarWriteJob select "id", - "sidecarPath", "originalPath", + ( + select + coalesce(json_agg(agg), '[]') + from + ( + select + "asset_file"."id", + "asset_file"."path", + "asset_file"."type" + from + "asset_file" + where + "asset_file"."assetId" = "asset"."id" + and "asset_file"."type" = $1 + ) as agg + ) as "files", ( select coalesce(json_agg(agg), '[]') @@ -39,21 +54,36 @@ select from "asset" where - "asset"."id" = $1::uuid + "asset"."id" = $2::uuid limit - $2 + $3 -- AssetJobRepository.getForSidecarCheckJob select "id", - "sidecarPath", - "originalPath" + "originalPath", + ( + select + coalesce(json_agg(agg), '[]') + from + ( + select + "asset_file"."id", + "asset_file"."path", + "asset_file"."type" + from + "asset_file" + where + "asset_file"."assetId" = "asset"."id" + and "asset_file"."type" = $1 + ) as agg + ) as "files" from "asset" where - "asset"."id" = $1::uuid + "asset"."id" = $2::uuid limit - $2 + $3 -- AssetJobRepository.streamForThumbnailJob select @@ -158,7 +188,6 @@ select "asset"."originalFileName", "asset"."originalPath", "asset"."ownerId", - "asset"."sidecarPath", "asset"."type", ( select @@ -173,11 +202,27 @@ select "asset_face"."assetId" = "asset"."id" and "asset_face"."deletedAt" is null ) as agg - ) as "faces" + ) as "faces", + ( + select + coalesce(json_agg(agg), '[]') + from + ( + select + "asset_file"."id", + "asset_file"."path", + "asset_file"."type" + from + "asset_file" + where + "asset_file"."assetId" = "asset"."id" + and "asset_file"."type" = $1 + ) as agg + ) as "files" from "asset" where - "asset"."id" = $1 + "asset"."id" = $2 -- AssetJobRepository.getAlbumThumbnailFiles select @@ -322,7 +367,6 @@ select "asset"."libraryId", "asset"."ownerId", "asset"."livePhotoVideoId", - "asset"."sidecarPath", "asset"."encodedVideoPath", "asset"."originalPath", to_json("asset_exif") as "exifInfo", @@ -433,18 +477,33 @@ select "asset"."checksum", "asset"."originalPath", "asset"."isExternal", - "asset"."sidecarPath", "asset"."originalFileName", "asset"."livePhotoVideoId", "asset"."fileCreatedAt", "asset_exif"."timeZone", - "asset_exif"."fileSizeInByte" + "asset_exif"."fileSizeInByte", + ( + select + coalesce(json_agg(agg), '[]') + from + ( + select + "asset_file"."id", + "asset_file"."path", + "asset_file"."type" + from + "asset_file" + where + "asset_file"."assetId" = "asset"."id" + and "asset_file"."type" = $1 + ) as agg + ) as "files" from "asset" inner join "asset_exif" on "asset"."id" = "asset_exif"."assetId" where "asset"."deletedAt" is null - and "asset"."id" = $1 + and "asset"."id" = $2 -- AssetJobRepository.streamForStorageTemplateJob select @@ -454,12 +513,27 @@ select "asset"."checksum", "asset"."originalPath", "asset"."isExternal", - "asset"."sidecarPath", "asset"."originalFileName", "asset"."livePhotoVideoId", "asset"."fileCreatedAt", "asset_exif"."timeZone", - "asset_exif"."fileSizeInByte" + "asset_exif"."fileSizeInByte", + ( + select + coalesce(json_agg(agg), '[]') + from + ( + select + "asset_file"."id", + "asset_file"."path", + "asset_file"."type" + from + "asset_file" + where + "asset_file"."assetId" = "asset"."id" + and "asset_file"."type" = $1 + ) as agg + ) as "files" from "asset" inner join "asset_exif" on "asset"."id" = "asset_exif"."assetId" @@ -481,11 +555,15 @@ select from "asset" where - ( - "asset"."sidecarPath" = $1 - or "asset"."sidecarPath" is null + not exists ( + select + "asset_file"."id" + from + "asset_file" + where + "asset_file"."assetId" = "asset"."id" + and "asset_file"."type" = $1 ) - and "asset"."visibility" != $2 -- AssetJobRepository.streamForDetectFacesJob select diff --git a/server/src/queries/asset.repository.sql b/server/src/queries/asset.repository.sql index 6cf3ec2f54..01cc6a7a89 100644 --- a/server/src/queries/asset.repository.sql +++ b/server/src/queries/asset.repository.sql @@ -216,6 +216,34 @@ from limit 3 +-- AssetRepository.getForCopy +select + "id", + "stackId", + "originalPath", + "isFavorite", + ( + select + coalesce(json_agg(agg), '[]') + from + ( + select + "asset_file"."id", + "asset_file"."path", + "asset_file"."type" + from + "asset_file" + where + "asset_file"."assetId" = "asset"."id" + ) as agg + ) as "files" +from + "asset" +where + "id" = $1::uuid +limit + $2 + -- AssetRepository.getById select "asset".* diff --git a/server/src/repositories/asset-job.repository.ts b/server/src/repositories/asset-job.repository.ts index 8d54e93c87..dc6e9e2573 100644 --- a/server/src/repositories/asset-job.repository.ts +++ b/server/src/repositories/asset-job.repository.ts @@ -6,7 +6,6 @@ import { Asset, columns } from 'src/database'; import { DummyValue, GenerateSql } from 'src/decorators'; import { AssetFileType, AssetType, AssetVisibility } from 'src/enum'; import { DB } from 'src/schema'; -import { StorageAsset } from 'src/types'; import { anyUuid, asUuid, @@ -40,7 +39,8 @@ export class AssetJobRepository { return this.db .selectFrom('asset') .where('asset.id', '=', asUuid(id)) - .select(['id', 'sidecarPath', 'originalPath']) + .select(['id', 'originalPath']) + .select((eb) => withFiles(eb, AssetFileType.Sidecar)) .select((eb) => jsonArrayFrom( eb @@ -59,7 +59,8 @@ export class AssetJobRepository { return this.db .selectFrom('asset') .where('asset.id', '=', asUuid(id)) - .select(['id', 'sidecarPath', 'originalPath']) + .select(['id', 'originalPath']) + .select((eb) => withFiles(eb, AssetFileType.Sidecar)) .limit(1) .executeTakeFirst(); } @@ -122,6 +123,7 @@ export class AssetJobRepository { .selectFrom('asset') .select(columns.asset) .select(withFaces) + .select((eb) => withFiles(eb, AssetFileType.Sidecar)) .where('asset.id', '=', id) .executeTakeFirst(); } @@ -228,7 +230,6 @@ export class AssetJobRepository { 'asset.libraryId', 'asset.ownerId', 'asset.livePhotoVideoId', - 'asset.sidecarPath', 'asset.encodedVideoPath', 'asset.originalPath', ]) @@ -306,26 +307,24 @@ export class AssetJobRepository { 'asset.checksum', 'asset.originalPath', 'asset.isExternal', - 'asset.sidecarPath', 'asset.originalFileName', 'asset.livePhotoVideoId', 'asset.fileCreatedAt', 'asset_exif.timeZone', 'asset_exif.fileSizeInByte', ]) + .select((eb) => withFiles(eb, AssetFileType.Sidecar)) .where('asset.deletedAt', 'is', null); } @GenerateSql({ params: [DummyValue.UUID] }) - getForStorageTemplateJob(id: string): Promise { - return this.storageTemplateAssetQuery().where('asset.id', '=', id).executeTakeFirst() as Promise< - StorageAsset | undefined - >; + getForStorageTemplateJob(id: string) { + return this.storageTemplateAssetQuery().where('asset.id', '=', id).executeTakeFirst(); } @GenerateSql({ params: [], stream: true }) streamForStorageTemplateJob() { - return this.storageTemplateAssetQuery().stream() as AsyncIterableIterator; + return this.storageTemplateAssetQuery().stream(); } @GenerateSql({ params: [DummyValue.DATE], stream: true }) @@ -343,9 +342,18 @@ export class AssetJobRepository { .selectFrom('asset') .select(['asset.id']) .$if(!force, (qb) => - qb.where((eb) => eb.or([eb('asset.sidecarPath', '=', ''), eb('asset.sidecarPath', 'is', null)])), + qb.where((eb) => + eb.not( + eb.exists( + eb + .selectFrom('asset_file') + .select('asset_file.id') + .whereRef('asset_file.assetId', '=', 'asset.id') + .where('asset_file.type', '=', AssetFileType.Sidecar), + ), + ), + ), ) - .where('asset.visibility', '!=', AssetVisibility.Hidden) .stream(); } diff --git a/server/src/repositories/asset.repository.ts b/server/src/repositories/asset.repository.ts index fc83c30c9a..4b8cbd7a7a 100644 --- a/server/src/repositories/asset.repository.ts +++ b/server/src/repositories/asset.repository.ts @@ -396,6 +396,17 @@ export class AssetRepository { return this.db.selectFrom('asset_file').select(['assetId', 'path']).limit(sql.lit(3)).execute(); } + @GenerateSql({ params: [DummyValue.UUID] }) + getForCopy(id: string) { + return this.db + .selectFrom('asset') + .select(['id', 'stackId', 'originalPath', 'isFavorite']) + .select(withFiles) + .where('id', '=', asUuid(id)) + .limit(1) + .executeTakeFirst(); + } + @GenerateSql({ params: [DummyValue.UUID] }) getById(id: string, { exifInfo, faces, files, library, owner, smartSearch, stack, tags }: GetByIdsRelations = {}) { return this.db @@ -842,6 +853,10 @@ export class AssetRepository { .execute(); } + async deleteFile({ assetId, type }: { assetId: string; type: AssetFileType }): Promise { + await this.db.deleteFrom('asset_file').where('assetId', '=', asUuid(assetId)).where('type', '=', type).execute(); + } + async deleteFiles(files: Pick, 'id'>[]): Promise { if (files.length === 0) { return; diff --git a/server/src/repositories/database.repository.ts b/server/src/repositories/database.repository.ts index 842576fafb..0fbaabf930 100644 --- a/server/src/repositories/database.repository.ts +++ b/server/src/repositories/database.repository.ts @@ -403,7 +403,6 @@ export class DatabaseRepository { .set((eb) => ({ originalPath: eb.fn('REGEXP_REPLACE', ['originalPath', source, target]), encodedVideoPath: eb.fn('REGEXP_REPLACE', ['encodedVideoPath', source, target]), - sidecarPath: eb.fn('REGEXP_REPLACE', ['sidecarPath', source, target]), })) .execute(); diff --git a/server/src/schema/migrations/1764698859174-SidecarInAssetFile.ts b/server/src/schema/migrations/1764698859174-SidecarInAssetFile.ts new file mode 100644 index 0000000000..183a77832e --- /dev/null +++ b/server/src/schema/migrations/1764698859174-SidecarInAssetFile.ts @@ -0,0 +1,24 @@ +import { Kysely, sql } from 'kysely'; + +export async function up(db: Kysely): Promise { + await sql`INSERT INTO "asset_file" ("assetId", "path", "type") + SELECT + id, "sidecarPath", 'sidecar' + FROM "asset" + WHERE "sidecarPath" IS NOT NULL AND "sidecarPath" != '';`.execute(db); + + await sql`ALTER TABLE "asset" DROP COLUMN "sidecarPath";`.execute(db); +} + +export async function down(db: Kysely): Promise { + await sql`ALTER TABLE "asset" ADD "sidecarPath" character varying;`.execute(db); + + await sql` + UPDATE "asset" + SET "sidecarPath" = "asset_file"."path" + FROM "asset_file" + WHERE "asset"."id" = "asset_file"."assetId" AND "asset_file"."type" = 'sidecar'; + `.execute(db); + + await sql`DELETE FROM "asset_file" WHERE "type" = 'sidecar';`.execute(db); +} diff --git a/server/src/schema/tables/asset.table.ts b/server/src/schema/tables/asset.table.ts index e92e01a1bd..b28fc99e4a 100644 --- a/server/src/schema/tables/asset.table.ts +++ b/server/src/schema/tables/asset.table.ts @@ -105,9 +105,6 @@ export class AssetTable { @Column({ index: true }) originalFileName!: string; - @Column({ nullable: true }) - sidecarPath!: string | null; - @Column({ type: 'bytea', nullable: true }) thumbhash!: Buffer | null; diff --git a/server/src/services/asset-media.service.spec.ts b/server/src/services/asset-media.service.spec.ts index f32385b937..95eb8b3c97 100644 --- a/server/src/services/asset-media.service.spec.ts +++ b/server/src/services/asset-media.service.spec.ts @@ -174,7 +174,6 @@ const assetEntity = Object.freeze({ longitude: 10.703_075, }, livePhotoVideoId: null, - sidecarPath: null, } as MapAsset); const existingAsset = Object.freeze({ @@ -188,7 +187,6 @@ const existingAsset = Object.freeze({ const sidecarAsset = Object.freeze({ ...existingAsset, - sidecarPath: 'sidecar-path', checksum: Buffer.from('_getExistingAssetWithSideCar', 'utf8'), }) as MapAsset; @@ -721,18 +719,22 @@ describe(AssetMediaService.name, () => { expect(mocks.asset.update).toHaveBeenCalledWith( expect.objectContaining({ id: existingAsset.id, - sidecarPath: null, originalFileName: 'photo1.jpeg', originalPath: 'fake_path/photo1.jpeg', }), ); expect(mocks.asset.create).toHaveBeenCalledWith( expect.objectContaining({ - sidecarPath: null, originalFileName: 'existing-filename.jpeg', originalPath: 'fake_path/asset_1.jpeg', }), ); + expect(mocks.asset.deleteFile).toHaveBeenCalledWith( + expect.objectContaining({ + assetId: existingAsset.id, + type: AssetFileType.Sidecar, + }), + ); expect(mocks.asset.updateAll).toHaveBeenCalledWith([copiedAsset.id], { deletedAt: expect.any(Date), @@ -769,6 +771,13 @@ describe(AssetMediaService.name, () => { deletedAt: expect.any(Date), status: AssetStatus.Trashed, }); + expect(mocks.asset.upsertFile).toHaveBeenCalledWith( + expect.objectContaining({ + assetId: existingAsset.id, + path: sidecarFile.originalPath, + type: AssetFileType.Sidecar, + }), + ); expect(mocks.user.updateUsage).toHaveBeenCalledWith(authStub.user1.user.id, updatedFile.size); expect(mocks.storage.utimes).toHaveBeenCalledWith( updatedFile.originalPath, @@ -798,6 +807,12 @@ describe(AssetMediaService.name, () => { deletedAt: expect.any(Date), status: AssetStatus.Trashed, }); + expect(mocks.asset.deleteFile).toHaveBeenCalledWith( + expect.objectContaining({ + assetId: existingAsset.id, + type: AssetFileType.Sidecar, + }), + ); expect(mocks.user.updateUsage).toHaveBeenCalledWith(authStub.user1.user.id, updatedFile.size); expect(mocks.storage.utimes).toHaveBeenCalledWith( updatedFile.originalPath, @@ -827,6 +842,9 @@ describe(AssetMediaService.name, () => { expect(mocks.asset.create).not.toHaveBeenCalled(); expect(mocks.asset.updateAll).not.toHaveBeenCalled(); + expect(mocks.asset.upsertFile).not.toHaveBeenCalled(); + expect(mocks.asset.deleteFile).not.toHaveBeenCalled(); + expect(mocks.job.queue).toHaveBeenCalledWith({ name: JobName.FileDelete, data: { files: [updatedFile.originalPath, undefined] }, diff --git a/server/src/services/asset-media.service.ts b/server/src/services/asset-media.service.ts index 4db60c349f..d2e1c14210 100644 --- a/server/src/services/asset-media.service.ts +++ b/server/src/services/asset-media.service.ts @@ -21,7 +21,16 @@ import { UploadFieldName, } from 'src/dtos/asset-media.dto'; import { AuthDto } from 'src/dtos/auth.dto'; -import { AssetStatus, AssetType, AssetVisibility, CacheControl, JobName, Permission, StorageFolder } from 'src/enum'; +import { + AssetFileType, + AssetStatus, + AssetType, + AssetVisibility, + CacheControl, + JobName, + Permission, + StorageFolder, +} from 'src/enum'; import { AuthRequest } from 'src/middleware/auth.guard'; import { BaseService } from 'src/services/base.service'; import { UploadFile, UploadRequest } from 'src/types'; @@ -354,9 +363,12 @@ export class AssetMediaService extends BaseService { duration: dto.duration || null, livePhotoVideoId: null, - sidecarPath: sidecarPath || null, }); + await (sidecarPath + ? this.assetRepository.upsertFile({ assetId, type: AssetFileType.Sidecar, path: sidecarPath }) + : this.assetRepository.deleteFile({ assetId, type: AssetFileType.Sidecar })); + await this.storageRepository.utimes(file.originalPath, new Date(), new Date(dto.fileModifiedAt)); await this.assetRepository.upsertExif({ assetId, fileSizeInByte: file.size }); await this.jobRepository.queue({ @@ -384,7 +396,6 @@ export class AssetMediaService extends BaseService { localDateTime: asset.localDateTime, fileModifiedAt: asset.fileModifiedAt, livePhotoVideoId: asset.livePhotoVideoId, - sidecarPath: asset.sidecarPath, }); const { size } = await this.storageRepository.stat(created.originalPath); @@ -414,7 +425,6 @@ export class AssetMediaService extends BaseService { visibility: dto.visibility ?? AssetVisibility.Timeline, livePhotoVideoId: dto.livePhotoVideoId, originalFileName: dto.filename || file.originalName, - sidecarPath: sidecarFile?.originalPath, }); if (dto.metadata) { @@ -422,6 +432,11 @@ export class AssetMediaService extends BaseService { } if (sidecarFile) { + await this.assetRepository.upsertFile({ + assetId: asset.id, + path: sidecarFile.originalPath, + type: AssetFileType.Sidecar, + }); await this.storageRepository.utimes(sidecarFile.originalPath, new Date(), new Date(dto.fileModifiedAt)); } await this.storageRepository.utimes(file.originalPath, new Date(), new Date(dto.fileModifiedAt)); diff --git a/server/src/services/asset.service.spec.ts b/server/src/services/asset.service.spec.ts index 4b0086c957..8c646e45b9 100755 --- a/server/src/services/asset.service.spec.ts +++ b/server/src/services/asset.service.spec.ts @@ -585,8 +585,8 @@ describe(AssetService.name, () => { '/uploads/user-id/webp/path.ext', '/uploads/user-id/thumbs/path.jpg', '/uploads/user-id/fullsize/path.webp', - assetWithFace.encodedVideoPath, - assetWithFace.sidecarPath, + assetWithFace.encodedVideoPath, // this value is null + undefined, // no sidecar path assetWithFace.originalPath, ], }, diff --git a/server/src/services/asset.service.ts b/server/src/services/asset.service.ts index 23cc6791dd..0a9aa7f355 100644 --- a/server/src/services/asset.service.ts +++ b/server/src/services/asset.service.ts @@ -2,6 +2,7 @@ import { BadRequestException, Injectable } from '@nestjs/common'; import _ from 'lodash'; import { DateTime, Duration } from 'luxon'; import { JOBS_ASSET_PAGINATION_SIZE } from 'src/constants'; +import { AssetFile } from 'src/database'; import { OnJob } from 'src/decorators'; import { AssetResponseDto, MapAsset, SanitizedAssetResponseDto, mapAsset } from 'src/dtos/asset-response.dto'; import { @@ -18,7 +19,16 @@ import { } from 'src/dtos/asset.dto'; import { AuthDto } from 'src/dtos/auth.dto'; import { AssetOcrResponseDto } from 'src/dtos/ocr.dto'; -import { AssetMetadataKey, AssetStatus, AssetVisibility, JobName, JobStatus, Permission, QueueName } from 'src/enum'; +import { + AssetFileType, + AssetMetadataKey, + AssetStatus, + AssetVisibility, + JobName, + JobStatus, + Permission, + QueueName, +} from 'src/enum'; import { BaseService } from 'src/services/base.service'; import { ISidecarWriteJob, JobItem, JobOf } from 'src/types'; import { requireElevatedPermission } from 'src/utils/access'; @@ -197,8 +207,8 @@ export class AssetService extends BaseService { }: AssetCopyDto, ) { await this.requireAccess({ auth, permission: Permission.AssetCopy, ids: [sourceId, targetId] }); - const sourceAsset = await this.assetRepository.getById(sourceId); - const targetAsset = await this.assetRepository.getById(targetId); + const sourceAsset = await this.assetRepository.getForCopy(sourceId); + const targetAsset = await this.assetRepository.getForCopy(targetId); if (!sourceAsset || !targetAsset) { throw new BadRequestException('Both assets must exist'); @@ -252,19 +262,25 @@ export class AssetService extends BaseService { sourceAsset, targetAsset, }: { - sourceAsset: { sidecarPath: string | null }; - targetAsset: { id: string; sidecarPath: string | null; originalPath: string }; + sourceAsset: { files: AssetFile[] }; + targetAsset: { id: string; files: AssetFile[]; originalPath: string }; }) { - if (!sourceAsset.sidecarPath) { + const { sidecarFile: sourceFile } = getAssetFiles(sourceAsset.files); + if (!sourceFile?.path) { return; } - if (targetAsset.sidecarPath) { - await this.storageRepository.unlink(targetAsset.sidecarPath); + const { sidecarFile: targetFile } = getAssetFiles(targetAsset.files ?? []); + if (targetFile?.path) { + await this.storageRepository.unlink(targetFile.path); } - await this.storageRepository.copyFile(sourceAsset.sidecarPath, `${targetAsset.originalPath}.xmp`); - await this.assetRepository.update({ id: targetAsset.id, sidecarPath: `${targetAsset.originalPath}.xmp` }); + await this.storageRepository.copyFile(sourceFile.path, `${targetAsset.originalPath}.xmp`); + await this.assetRepository.upsertFile({ + assetId: targetAsset.id, + path: `${targetAsset.originalPath}.xmp`, + type: AssetFileType.Sidecar, + }); await this.jobRepository.queue({ name: JobName.AssetExtractMetadata, data: { id: targetAsset.id } }); } @@ -344,11 +360,11 @@ export class AssetService extends BaseService { } } - const { fullsizeFile, previewFile, thumbnailFile } = getAssetFiles(asset.files ?? []); + const { fullsizeFile, previewFile, thumbnailFile, sidecarFile } = getAssetFiles(asset.files ?? []); const files = [thumbnailFile?.path, previewFile?.path, fullsizeFile?.path, asset.encodedVideoPath]; if (deleteOnDisk) { - files.push(asset.sidecarPath, asset.originalPath); + files.push(sidecarFile?.path, asset.originalPath); } await this.jobRepository.queue({ name: JobName.FileDelete, data: { files } }); diff --git a/server/src/services/metadata.service.spec.ts b/server/src/services/metadata.service.spec.ts index 1760c8a3d7..46d6fe7abc 100644 --- a/server/src/services/metadata.service.spec.ts +++ b/server/src/services/metadata.service.spec.ts @@ -4,7 +4,16 @@ import { randomBytes } from 'node:crypto'; import { Stats } from 'node:fs'; import { defaults } from 'src/config'; import { MapAsset } from 'src/dtos/asset-response.dto'; -import { AssetType, AssetVisibility, ExifOrientation, ImmichWorker, JobName, JobStatus, SourceType } from 'src/enum'; +import { + AssetFileType, + AssetType, + AssetVisibility, + ExifOrientation, + ImmichWorker, + JobName, + JobStatus, + SourceType, +} from 'src/enum'; import { ImmichTags } from 'src/repositories/metadata.repository'; import { firstDateTime, MetadataService } from 'src/services/metadata.service'; import { assetStub } from 'test/fixtures/asset.stub'; @@ -15,17 +24,24 @@ import { tagStub } from 'test/fixtures/tag.stub'; import { factory } from 'test/small.factory'; import { makeStream, newTestService, ServiceMocks } from 'test/utils'; +const removeNonSidecarFiles = (asset: any) => { + return { + ...asset, + files: asset.files.filter((file: any) => file.type === AssetFileType.Sidecar), + }; +}; + const forSidecarJob = ( asset: { id?: string; originalPath?: string; - sidecarPath?: string | null; + files?: { id: string; type: AssetFileType; path: string }[]; } = {}, ) => { return { id: factory.uuid(), originalPath: '/path/to/IMG_123.jpg', - sidecarPath: null, + files: [], ...asset, }; }; @@ -166,7 +182,7 @@ describe(MetadataService.name, () => { 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'); - mocks.assetJob.getForMetadataExtraction.mockResolvedValue(assetStub.sidecar); + mocks.assetJob.getForMetadataExtraction.mockResolvedValue(removeNonSidecarFiles(assetStub.sidecar)); mockReadTags({ CreationDate: originalDate.toISOString() }, { CreationDate: sidecarDate.toISOString() }); await sut.handleMetadataExtraction({ id: assetStub.image.id }); @@ -185,7 +201,7 @@ describe(MetadataService.name, () => { it('should take the file modification date when missing exif and earlier than creation date', async () => { const fileCreatedAt = new Date('2022-01-01T00:00:00.000Z'); const fileModifiedAt = new Date('2021-01-01T00:00:00.000Z'); - mocks.assetJob.getForMetadataExtraction.mockResolvedValue(assetStub.image); + mocks.assetJob.getForMetadataExtraction.mockResolvedValue(removeNonSidecarFiles(assetStub.image)); mocks.storage.stat.mockResolvedValue({ size: 123_456, mtime: fileModifiedAt, @@ -211,7 +227,7 @@ describe(MetadataService.name, () => { it('should take the file creation date when missing exif and earlier than modification date', async () => { const fileCreatedAt = new Date('2021-01-01T00:00:00.000Z'); const fileModifiedAt = new Date('2022-01-01T00:00:00.000Z'); - mocks.assetJob.getForMetadataExtraction.mockResolvedValue(assetStub.image); + mocks.assetJob.getForMetadataExtraction.mockResolvedValue(removeNonSidecarFiles(assetStub.image)); mocks.storage.stat.mockResolvedValue({ size: 123_456, mtime: fileModifiedAt, @@ -234,7 +250,7 @@ describe(MetadataService.name, () => { it('should determine dateTimeOriginal regardless of the server time zone', async () => { process.env.TZ = 'America/Los_Angeles'; - mocks.assetJob.getForMetadataExtraction.mockResolvedValue(assetStub.sidecar); + mocks.assetJob.getForMetadataExtraction.mockResolvedValue(removeNonSidecarFiles(assetStub.sidecar)); mockReadTags({ DateTimeOriginal: '2022:01:01 00:00:00' }); await sut.handleMetadataExtraction({ id: assetStub.image.id }); @@ -252,7 +268,7 @@ describe(MetadataService.name, () => { }); it('should handle lists of numbers', async () => { - mocks.assetJob.getForMetadataExtraction.mockResolvedValue(assetStub.image); + mocks.assetJob.getForMetadataExtraction.mockResolvedValue(removeNonSidecarFiles(assetStub.image)); mocks.storage.stat.mockResolvedValue({ size: 123_456, mtime: assetStub.image.fileModifiedAt, @@ -305,7 +321,7 @@ describe(MetadataService.name, () => { }); it('should apply reverse geocoding', async () => { - mocks.assetJob.getForMetadataExtraction.mockResolvedValue(assetStub.withLocation); + mocks.assetJob.getForMetadataExtraction.mockResolvedValue(removeNonSidecarFiles(assetStub.withLocation)); mocks.systemMetadata.get.mockResolvedValue({ reverseGeocoding: { enabled: true } }); mocks.map.reverseGeocode.mockResolvedValue({ city: 'City', state: 'State', country: 'Country' }); mocks.storage.stat.mockResolvedValue({ @@ -334,7 +350,7 @@ describe(MetadataService.name, () => { }); it('should discard latitude and longitude on null island', async () => { - mocks.assetJob.getForMetadataExtraction.mockResolvedValue(assetStub.withLocation); + mocks.assetJob.getForMetadataExtraction.mockResolvedValue(removeNonSidecarFiles(assetStub.withLocation)); mockReadTags({ GPSLatitude: 0, GPSLongitude: 0, @@ -346,7 +362,7 @@ describe(MetadataService.name, () => { }); it('should extract tags from TagsList', async () => { - mocks.assetJob.getForMetadataExtraction.mockResolvedValue(assetStub.image); + mocks.assetJob.getForMetadataExtraction.mockResolvedValue(removeNonSidecarFiles(assetStub.image)); mockReadTags({ TagsList: ['Parent'] }); mocks.tag.upsertValue.mockResolvedValue(tagStub.parentUpsert); @@ -356,7 +372,7 @@ describe(MetadataService.name, () => { }); it('should extract hierarchy from TagsList', async () => { - mocks.assetJob.getForMetadataExtraction.mockResolvedValue(assetStub.image); + mocks.assetJob.getForMetadataExtraction.mockResolvedValue(removeNonSidecarFiles(assetStub.image)); mockReadTags({ TagsList: ['Parent/Child'] }); mocks.tag.upsertValue.mockResolvedValueOnce(tagStub.parentUpsert); mocks.tag.upsertValue.mockResolvedValueOnce(tagStub.childUpsert); @@ -376,7 +392,7 @@ describe(MetadataService.name, () => { }); it('should extract tags from Keywords as a string', async () => { - mocks.assetJob.getForMetadataExtraction.mockResolvedValue(assetStub.image); + mocks.assetJob.getForMetadataExtraction.mockResolvedValue(removeNonSidecarFiles(assetStub.image)); mockReadTags({ Keywords: 'Parent' }); mocks.tag.upsertValue.mockResolvedValue(tagStub.parentUpsert); @@ -386,7 +402,7 @@ describe(MetadataService.name, () => { }); it('should extract tags from Keywords as a list', async () => { - mocks.assetJob.getForMetadataExtraction.mockResolvedValue(assetStub.image); + mocks.assetJob.getForMetadataExtraction.mockResolvedValue(removeNonSidecarFiles(assetStub.image)); mockReadTags({ Keywords: ['Parent'] }); mocks.tag.upsertValue.mockResolvedValue(tagStub.parentUpsert); @@ -396,7 +412,7 @@ describe(MetadataService.name, () => { }); it('should extract tags from Keywords as a list with a number', async () => { - mocks.assetJob.getForMetadataExtraction.mockResolvedValue(assetStub.image); + mocks.assetJob.getForMetadataExtraction.mockResolvedValue(removeNonSidecarFiles(assetStub.image)); mockReadTags({ Keywords: ['Parent', 2024] }); mocks.tag.upsertValue.mockResolvedValue(tagStub.parentUpsert); @@ -407,7 +423,7 @@ describe(MetadataService.name, () => { }); it('should extract hierarchal tags from Keywords', async () => { - mocks.assetJob.getForMetadataExtraction.mockResolvedValue(assetStub.image); + mocks.assetJob.getForMetadataExtraction.mockResolvedValue(removeNonSidecarFiles(assetStub.image)); mockReadTags({ Keywords: 'Parent/Child' }); mocks.tag.upsertValue.mockResolvedValue(tagStub.parentUpsert); @@ -426,7 +442,7 @@ describe(MetadataService.name, () => { }); it('should ignore Keywords when TagsList is present', async () => { - mocks.assetJob.getForMetadataExtraction.mockResolvedValue(assetStub.image); + mocks.assetJob.getForMetadataExtraction.mockResolvedValue(removeNonSidecarFiles(assetStub.image)); mockReadTags({ Keywords: 'Child', TagsList: ['Parent/Child'] }); mocks.tag.upsertValue.mockResolvedValue(tagStub.parentUpsert); @@ -445,7 +461,7 @@ describe(MetadataService.name, () => { }); it('should extract hierarchy from HierarchicalSubject', async () => { - mocks.assetJob.getForMetadataExtraction.mockResolvedValue(assetStub.image); + mocks.assetJob.getForMetadataExtraction.mockResolvedValue(removeNonSidecarFiles(assetStub.image)); mockReadTags({ HierarchicalSubject: ['Parent|Child', 'TagA'] }); mocks.tag.upsertValue.mockResolvedValueOnce(tagStub.parentUpsert); mocks.tag.upsertValue.mockResolvedValueOnce(tagStub.childUpsert); @@ -466,7 +482,7 @@ describe(MetadataService.name, () => { }); it('should extract tags from HierarchicalSubject as a list with a number', async () => { - mocks.assetJob.getForMetadataExtraction.mockResolvedValue(assetStub.image); + mocks.assetJob.getForMetadataExtraction.mockResolvedValue(removeNonSidecarFiles(assetStub.image)); mockReadTags({ HierarchicalSubject: ['Parent', 2024] }); mocks.tag.upsertValue.mockResolvedValue(tagStub.parentUpsert); @@ -1030,8 +1046,15 @@ describe(MetadataService.name, () => { it('should prefer Duration from exif over sidecar', async () => { mocks.assetJob.getForMetadataExtraction.mockResolvedValue({ ...assetStub.image, - sidecarPath: '/path/to/something', + files: [ + { + id: 'some-id', + type: AssetFileType.Sidecar, + path: '/path/to/something', + }, + ], }); + mockReadTags({ Duration: 123 }, { Duration: 456 }); await sut.handleMetadataExtraction({ id: assetStub.image.id }); @@ -1536,18 +1559,25 @@ describe(MetadataService.name, () => { }); it('should detect a new sidecar at .jpg.xmp', async () => { - const asset = forSidecarJob({ originalPath: '/path/to/IMG_123.jpg' }); + const asset = forSidecarJob({ originalPath: '/path/to/IMG_123.jpg', files: [] }); mocks.assetJob.getForSidecarCheckJob.mockResolvedValue(asset); mocks.storage.checkFileExists.mockResolvedValueOnce(true); await expect(sut.handleSidecarCheck({ id: asset.id })).resolves.toBe(JobStatus.Success); - expect(mocks.asset.update).toHaveBeenCalledWith({ id: asset.id, sidecarPath: `/path/to/IMG_123.jpg.xmp` }); + expect(mocks.asset.upsertFile).toHaveBeenCalledWith({ + assetId: asset.id, + type: AssetFileType.Sidecar, + path: '/path/to/IMG_123.jpg.xmp', + }); }); it('should detect a new sidecar at .xmp', async () => { - const asset = forSidecarJob({ originalPath: '/path/to/IMG_123.jpg' }); + const asset = forSidecarJob({ + originalPath: '/path/to/IMG_123.jpg', + files: [], + }); mocks.assetJob.getForSidecarCheckJob.mockResolvedValue(asset); mocks.storage.checkFileExists.mockResolvedValueOnce(false); @@ -1555,33 +1585,44 @@ describe(MetadataService.name, () => { await expect(sut.handleSidecarCheck({ id: asset.id })).resolves.toBe(JobStatus.Success); - expect(mocks.asset.update).toHaveBeenCalledWith({ id: asset.id, sidecarPath: '/path/to/IMG_123.xmp' }); + expect(mocks.asset.upsertFile).toHaveBeenCalledWith({ + assetId: asset.id, + type: AssetFileType.Sidecar, + path: '/path/to/IMG_123.xmp', + }); }); - it('should unset sidecar path if file does not exist anymore', async () => { - const asset = forSidecarJob({ originalPath: '/path/to/IMG_123.jpg', sidecarPath: '/path/to/IMG_123.jpg.xmp' }); + it('should unset sidecar path if file no longer exist', async () => { + const asset = forSidecarJob({ + originalPath: '/path/to/IMG_123.jpg', + files: [{ id: 'sidecar', path: '/path/to/IMG_123.jpg.xmp', type: AssetFileType.Sidecar }], + }); mocks.assetJob.getForSidecarCheckJob.mockResolvedValue(asset); mocks.storage.checkFileExists.mockResolvedValue(false); await expect(sut.handleSidecarCheck({ id: asset.id })).resolves.toBe(JobStatus.Success); - expect(mocks.asset.update).toHaveBeenCalledWith({ id: asset.id, sidecarPath: null }); + expect(mocks.asset.deleteFile).toHaveBeenCalledWith({ assetId: asset.id, type: AssetFileType.Sidecar }); }); it('should do nothing if the sidecar file still exists', async () => { - const asset = forSidecarJob({ originalPath: '/path/to/IMG_123.jpg', sidecarPath: '/path/to/IMG_123.jpg' }); + const asset = forSidecarJob({ + originalPath: '/path/to/IMG_123.jpg', + files: [{ id: 'sidecar', path: '/path/to/IMG_123.jpg.xmp', type: AssetFileType.Sidecar }], + }); mocks.assetJob.getForSidecarCheckJob.mockResolvedValue(asset); mocks.storage.checkFileExists.mockResolvedValueOnce(true); await expect(sut.handleSidecarCheck({ id: asset.id })).resolves.toBe(JobStatus.Skipped); - expect(mocks.asset.update).not.toHaveBeenCalled(); + expect(mocks.asset.upsertFile).not.toHaveBeenCalled(); + expect(mocks.asset.deleteFile).not.toHaveBeenCalled(); }); }); describe('handleSidecarWrite', () => { - it('should skip assets that do not exist anymore', async () => { + it('should skip assets that no longer exist', async () => { mocks.assetJob.getForSidecarWriteJob.mockResolvedValue(void 0); await expect(sut.handleSidecarWrite({ id: 'asset-123' })).resolves.toBe(JobStatus.Failed); expect(mocks.metadata.writeTags).not.toHaveBeenCalled(); @@ -1610,7 +1651,7 @@ describe(MetadataService.name, () => { dateTimeOriginal: date, }), ).resolves.toBe(JobStatus.Success); - expect(mocks.metadata.writeTags).toHaveBeenCalledWith(asset.sidecarPath, { + expect(mocks.metadata.writeTags).toHaveBeenCalledWith(asset.files[0].path, { Description: description, ImageDescription: description, DateTimeOriginal: date, diff --git a/server/src/services/metadata.service.ts b/server/src/services/metadata.service.ts index 4efefcb97a..4d6d4c190f 100644 --- a/server/src/services/metadata.service.ts +++ b/server/src/services/metadata.service.ts @@ -8,9 +8,10 @@ import { constants } from 'node:fs/promises'; import { join, parse } from 'node:path'; import { JOBS_ASSET_PAGINATION_SIZE } from 'src/constants'; import { StorageCore } from 'src/cores/storage.core'; -import { Asset, AssetFace } from 'src/database'; +import { Asset, AssetFace, AssetFile } from 'src/database'; import { OnEvent, OnJob } from 'src/decorators'; import { + AssetFileType, AssetType, AssetVisibility, DatabaseLock, @@ -29,6 +30,7 @@ import { AssetFaceTable } from 'src/schema/tables/asset-face.table'; import { PersonTable } from 'src/schema/tables/person.table'; import { BaseService } from 'src/services/base.service'; import { JobItem, JobOf } from 'src/types'; +import { getAssetFiles } from 'src/utils/asset.util'; import { isAssetChecksumConstraint } from 'src/utils/database'; import { isFaceImportEnabled } from 'src/utils/misc'; import { upsertTags } from 'src/utils/tag'; @@ -359,17 +361,21 @@ export class MetadataService extends BaseService { break; } - const isChanged = sidecarPath !== asset.sidecarPath; + const { sidecarFile } = getAssetFiles(asset.files); + + const isChanged = sidecarPath !== sidecarFile?.path; this.logger.debug( - `Sidecar check found old=${asset.sidecarPath}, new=${sidecarPath} will ${isChanged ? 'update' : 'do nothing for'} asset ${asset.id}: ${asset.originalPath}`, + `Sidecar check found old=${sidecarFile?.path}, new=${sidecarPath} will ${isChanged ? 'update' : 'do nothing for'} asset ${asset.id}: ${asset.originalPath}`, ); if (!isChanged) { return JobStatus.Skipped; } - await this.assetRepository.update({ id: asset.id, sidecarPath }); + await (sidecarPath === null + ? this.assetRepository.deleteFile({ assetId: asset.id, type: AssetFileType.Sidecar }) + : this.assetRepository.upsertFile({ assetId: asset.id, type: AssetFileType.Sidecar, path: sidecarPath })); return JobStatus.Success; } @@ -394,7 +400,9 @@ export class MetadataService extends BaseService { const tagsList = (asset.tags || []).map((tag) => tag.value); - const sidecarPath = asset.sidecarPath || `${asset.originalPath}.xmp`; + const { sidecarFile } = getAssetFiles(asset.files); + const sidecarPath = sidecarFile?.path || `${asset.originalPath}.xmp`; + const exif = _.omitBy( { Description: description, @@ -414,18 +422,19 @@ export class MetadataService extends BaseService { await this.metadataRepository.writeTags(sidecarPath, exif); - if (!asset.sidecarPath) { - await this.assetRepository.update({ id, sidecarPath }); + if (asset.files.length === 0) { + await this.assetRepository.upsertFile({ assetId: id, type: AssetFileType.Sidecar, path: sidecarPath }); } return JobStatus.Success; } - private getSidecarCandidates({ sidecarPath, originalPath }: { sidecarPath: string | null; originalPath: string }) { + private getSidecarCandidates({ files, originalPath }: { files: AssetFile[]; originalPath: string }) { const candidates: string[] = []; - if (sidecarPath) { - candidates.push(sidecarPath); + const { sidecarFile } = getAssetFiles(files); + if (sidecarFile?.path) { + candidates.push(sidecarFile.path); } const assetPath = parse(originalPath); @@ -456,14 +465,12 @@ export class MetadataService extends BaseService { return { width, height }; } - private async getExifTags(asset: { - originalPath: string; - sidecarPath: string | null; - type: AssetType; - }): Promise { + private async getExifTags(asset: { originalPath: string; files: AssetFile[]; type: AssetType }): Promise { + const { sidecarFile } = getAssetFiles(asset.files); + const [mediaTags, sidecarTags, videoTags] = await Promise.all([ this.metadataRepository.readTags(asset.originalPath), - asset.sidecarPath ? this.metadataRepository.readTags(asset.sidecarPath) : null, + sidecarFile ? this.metadataRepository.readTags(sidecarFile.path) : null, asset.type === AssetType.Video ? this.getVideoTags(asset.originalPath) : null, ]); diff --git a/server/src/services/storage-template.service.ts b/server/src/services/storage-template.service.ts index 1d38bf7011..864207bf05 100644 --- a/server/src/services/storage-template.service.ts +++ b/server/src/services/storage-template.service.ts @@ -6,10 +6,20 @@ import sanitize from 'sanitize-filename'; import { StorageCore } from 'src/cores/storage.core'; import { OnEvent, OnJob } from 'src/decorators'; import { SystemConfigTemplateStorageOptionDto } from 'src/dtos/system-config.dto'; -import { AssetPathType, AssetType, DatabaseLock, JobName, JobStatus, QueueName, StorageFolder } from 'src/enum'; +import { + AssetFileType, + 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, StorageAsset } from 'src/types'; +import { getAssetFile } from 'src/utils/asset.util'; import { getLivePhotoMotionFilename } from 'src/utils/file'; const storageTokens = { @@ -196,7 +206,7 @@ export class StorageTemplateService extends BaseService { } return this.databaseRepository.withLock(DatabaseLock.StorageTemplateMigration, async () => { - const { id, sidecarPath, originalPath, checksum, fileSizeInByte } = asset; + const { id, originalPath, checksum, fileSizeInByte } = asset; const oldPath = originalPath; const newPath = await this.getTemplatePath(asset, metadata); @@ -213,6 +223,8 @@ export class StorageTemplateService extends BaseService { newPath, assetInfo: { sizeInBytes: fileSizeInByte, checksum }, }); + + const sidecarPath = getAssetFile(asset.files, AssetFileType.Sidecar)?.path; if (sidecarPath) { await this.storageCore.moveFile({ entityId: id, diff --git a/server/src/types.ts b/server/src/types.ts index 11a5d5f66c..a33dba490c 100644 --- a/server/src/types.ts +++ b/server/src/types.ts @@ -1,6 +1,6 @@ import { SystemConfig } from 'src/config'; import { VECTOR_EXTENSIONS } from 'src/constants'; -import { Asset } from 'src/database'; +import { Asset, AssetFile } from 'src/database'; import { UploadFieldName } from 'src/dtos/asset-media.dto'; import { AuthDto } from 'src/dtos/auth.dto'; import { @@ -475,8 +475,8 @@ export type StorageAsset = { fileCreatedAt: Date; originalPath: string; originalFileName: string; - sidecarPath: string | null; fileSizeInByte: number | null; + files: AssetFile[]; }; export type OnThisDayData = { year: number }; diff --git a/server/src/utils/asset.util.ts b/server/src/utils/asset.util.ts index 629b3bf819..f3f807c829 100644 --- a/server/src/utils/asset.util.ts +++ b/server/src/utils/asset.util.ts @@ -21,6 +21,7 @@ export const getAssetFiles = (files: AssetFile[]) => ({ fullsizeFile: getAssetFile(files, AssetFileType.FullSize), previewFile: getAssetFile(files, AssetFileType.Preview), thumbnailFile: getAssetFile(files, AssetFileType.Thumbnail), + sidecarFile: getAssetFile(files, AssetFileType.Sidecar), }); export const addAssets = async ( diff --git a/server/test/fixtures/asset.stub.ts b/server/test/fixtures/asset.stub.ts index 0fd2189268..f5935d5d0e 100644 --- a/server/test/fixtures/asset.stub.ts +++ b/server/test/fixtures/asset.stub.ts @@ -24,6 +24,18 @@ const fullsizeFile: AssetFile = { path: '/uploads/user-id/fullsize/path.webp', }; +const sidecarFileWithExt: AssetFile = { + id: 'sidecar-with-ext', + type: AssetFileType.Sidecar, + path: '/original/path.ext.xmp', +}; + +const sidecarFileWithoutExt: AssetFile = { + id: 'sidecar-without-ext', + type: AssetFileType.Sidecar, + path: '/original/path.xmp', +}; + const files: AssetFile[] = [fullsizeFile, previewFile, thumbnailFile]; export const stackStub = (stackId: string, assets: (MapAsset & { exifInfo: Exif })[]) => { @@ -51,8 +63,8 @@ export const assetStub = { fileCreatedAt: new Date('2022-06-19T23:41:36.910Z'), originalPath: '/original/path.jpg', originalFileName: 'IMG_123.jpg', - sidecarPath: null, fileSizeInByte: 12_345, + files: [], ...asset, }), noResizePath: Object.freeze({ @@ -81,7 +93,6 @@ export const assetStub = { sharedLinks: [], faces: [], exifInfo: {} as Exif, - sidecarPath: null, deletedAt: null, isExternal: false, duplicateId: null, @@ -117,7 +128,6 @@ export const assetStub = { sharedLinks: [], originalFileName: 'IMG_456.jpg', faces: [], - sidecarPath: null, isExternal: false, exifInfo: { fileSizeInByte: 123_000, @@ -157,7 +167,6 @@ export const assetStub = { sharedLinks: [], originalFileName: 'asset-id.ext', faces: [], - sidecarPath: null, deletedAt: null, duplicateId: null, isOffline: false, @@ -194,7 +203,6 @@ export const assetStub = { originalFileName: 'asset-id.jpg', faces: [], deletedAt: null, - sidecarPath: null, exifInfo: { fileSizeInByte: 5000, exifImageHeight: 1000, @@ -243,7 +251,6 @@ export const assetStub = { originalFileName: 'asset-id.jpg', faces: [], deletedAt: null, - sidecarPath: null, exifInfo: { fileSizeInByte: 5000, exifImageHeight: 3840, @@ -285,7 +292,6 @@ export const assetStub = { sharedLinks: [], originalFileName: 'asset-id.jpg', faces: [], - sidecarPath: null, exifInfo: { fileSizeInByte: 5000, exifImageHeight: 3840, @@ -328,7 +334,6 @@ export const assetStub = { sharedLinks: [], originalFileName: 'asset-id.jpg', faces: [], - sidecarPath: null, exifInfo: { fileSizeInByte: 5000, exifImageHeight: 3840, @@ -367,7 +372,6 @@ export const assetStub = { originalFileName: 'asset-id.jpg', faces: [], deletedAt: null, - sidecarPath: null, exifInfo: { fileSizeInByte: 5000, exifImageHeight: 3840, @@ -409,7 +413,6 @@ export const assetStub = { originalFileName: 'asset-id.jpg', faces: [], deletedAt: null, - sidecarPath: null, exifInfo: { fileSizeInByte: 5000, } as Exif, @@ -448,7 +451,6 @@ export const assetStub = { sharedLinks: [], originalFileName: 'asset-id.ext', faces: [], - sidecarPath: null, exifInfo: { fileSizeInByte: 5000, } as Exif, @@ -490,7 +492,6 @@ export const assetStub = { sharedLinks: [], originalFileName: 'asset-id.ext', faces: [], - sidecarPath: null, exifInfo: { fileSizeInByte: 5000, } as Exif, @@ -526,7 +527,6 @@ export const assetStub = { livePhotoVideoId: null, sharedLinks: [], faces: [], - sidecarPath: null, exifInfo: { fileSizeInByte: 100_000, exifImageHeight: 2160, @@ -553,6 +553,7 @@ export const assetStub = { fileSizeInByte: 100_000, timeZone: `America/New_York`, }, + files: [] as AssetFile[], libraryId: null, visibility: AssetVisibility.Hidden, } as MapAsset & { faces: AssetFace[]; files: AssetFile[]; exifInfo: Exif }), @@ -573,7 +574,7 @@ export const assetStub = { files, faces: [] as AssetFace[], visibility: AssetVisibility.Timeline, - } as MapAsset & { faces: AssetFace[] }), + } as MapAsset & { faces: AssetFace[]; files: AssetFile[] }), livePhotoWithOriginalFileName: Object.freeze({ id: 'live-photo-still-asset', @@ -589,10 +590,11 @@ export const assetStub = { fileSizeInByte: 25_000, timeZone: `America/New_York`, }, + files: [] as AssetFile[], libraryId: null, faces: [] as AssetFace[], visibility: AssetVisibility.Timeline, - } as MapAsset & { faces: AssetFace[] }), + } as MapAsset & { faces: AssetFace[]; files: AssetFile[] }), withLocation: Object.freeze({ id: 'asset-with-favorite-id', @@ -605,7 +607,6 @@ export const assetStub = { deviceId: 'device-id', checksum: Buffer.from('file hash', 'utf8'), originalPath: '/original/path.ext', - sidecarPath: null, type: AssetType.Image, files: [previewFile], thumbhash: null, @@ -652,7 +653,7 @@ export const assetStub = { thumbhash: null, checksum: Buffer.from('file hash', 'utf8'), type: AssetType.Image, - files: [previewFile], + files: [previewFile, sidecarFileWithExt], encodedVideoPath: null, createdAt: new Date('2023-02-23T05:06:29.716Z'), updatedAt: new Date('2023-02-23T05:06:29.716Z'), @@ -665,7 +666,6 @@ export const assetStub = { sharedLinks: [], originalFileName: 'asset-id.ext', faces: [], - sidecarPath: '/original/path.ext.xmp', deletedAt: null, duplicateId: null, isOffline: false, @@ -688,7 +688,7 @@ export const assetStub = { thumbhash: null, checksum: Buffer.from('file hash', 'utf8'), type: AssetType.Image, - files: [previewFile], + files: [previewFile, sidecarFileWithoutExt], encodedVideoPath: null, createdAt: new Date('2023-02-23T05:06:29.716Z'), updatedAt: new Date('2023-02-23T05:06:29.716Z'), @@ -701,7 +701,6 @@ export const assetStub = { sharedLinks: [], originalFileName: 'asset-id.ext', faces: [], - sidecarPath: '/original/path.xmp', deletedAt: null, duplicateId: null, isOffline: false, @@ -734,7 +733,6 @@ export const assetStub = { livePhotoVideoId: null, sharedLinks: [], faces: [], - sidecarPath: null, exifInfo: { fileSizeInByte: 100_000, } as Exif, @@ -776,7 +774,6 @@ export const assetStub = { originalFileName: 'photo.jpg', faces: [], deletedAt: null, - sidecarPath: null, exifInfo: { fileSizeInByte: 5000, } as Exif, @@ -812,7 +809,6 @@ export const assetStub = { originalFileName: 'asset-id.dng', faces: [], deletedAt: null, - sidecarPath: null, exifInfo: { fileSizeInByte: 5000, profileDescription: 'Adobe RGB', @@ -853,7 +849,6 @@ export const assetStub = { originalFileName: 'asset-id.hif', faces: [], deletedAt: null, - sidecarPath: null, exifInfo: { fileSizeInByte: 5000, profileDescription: 'Adobe RGB', diff --git a/server/test/medium/specs/services/asset.service.spec.ts b/server/test/medium/specs/services/asset.service.spec.ts index b3fc481708..e9246c62b1 100644 --- a/server/test/medium/specs/services/asset.service.spec.ts +++ b/server/test/medium/specs/services/asset.service.spec.ts @@ -1,5 +1,5 @@ import { Kysely } from 'kysely'; -import { JobName, SharedLinkType } from 'src/enum'; +import { AssetFileType, JobName, SharedLinkType } from 'src/enum'; import { AccessRepository } from 'src/repositories/access.repository'; import { AlbumRepository } from 'src/repositories/album.repository'; import { AssetRepository } from 'src/repositories/asset.repository'; @@ -184,7 +184,15 @@ describe(AssetService.name, () => { jobRepo.queue.mockResolvedValue(); const { user } = await ctx.newUser(); - const { asset: oldAsset } = await ctx.newAsset({ ownerId: user.id, sidecarPath: '/path/to/my/sidecar.xmp' }); + + const { asset: oldAsset } = await ctx.newAsset({ ownerId: user.id }); + + await ctx.newAssetFile({ + assetId: oldAsset.id, + path: '/path/to/my/sidecar.xmp', + type: AssetFileType.Sidecar, + }); + const { asset: newAsset } = await ctx.newAsset({ ownerId: user.id }); await ctx.newExif({ assetId: oldAsset.id, description: 'foo' }); diff --git a/server/test/medium/specs/services/metadata.service.spec.ts b/server/test/medium/specs/services/metadata.service.spec.ts index 5d44079be5..1d144e9c9c 100644 --- a/server/test/medium/specs/services/metadata.service.spec.ts +++ b/server/test/medium/specs/services/metadata.service.spec.ts @@ -82,7 +82,11 @@ describe(MetadataService.name, () => { process.env.TZ = serverTimeZone ?? undefined; const { filePath } = await createTestFile(exifData); - mocks.assetJob.getForMetadataExtraction.mockResolvedValue({ id: 'asset-1', originalPath: filePath } as any); + mocks.assetJob.getForMetadataExtraction.mockResolvedValue({ + id: 'asset-1', + originalPath: filePath, + files: [], + } as any); await sut.handleMetadataExtraction({ id: 'asset-1' }); diff --git a/server/test/repositories/asset.repository.mock.ts b/server/test/repositories/asset.repository.mock.ts index e735b37564..5ba77ddc2f 100644 --- a/server/test/repositories/asset.repository.mock.ts +++ b/server/test/repositories/asset.repository.mock.ts @@ -10,6 +10,7 @@ export const newAssetRepositoryMock = (): Mocked = {}) => ({ originalFileName: 'IMG_123.jpg', originalPath: `/data/12/34/IMG_123.jpg`, ownerId: newUuid(), - sidecarPath: null, stackId: null, thumbhash: null, type: AssetType.Image, @@ -312,12 +319,17 @@ const versionHistoryFactory = () => ({ version: '1.123.45', }); -const assetSidecarWriteFactory = (asset: Partial = {}) => ({ +const assetSidecarWriteFactory = () => ({ id: newUuid(), - sidecarPath: '/path/to/original-path.jpg.xmp', originalPath: '/path/to/original-path.jpg.xmp', tags: [], - ...asset, + files: [ + { + id: newUuid(), + path: '/path/to/original-path.jpg.xmp', + type: AssetFileType.Sidecar, + }, + ], }); const assetOcrFactory = (