From 69880ee165f777fd8b72232e7605f40ca1e66f4c Mon Sep 17 00:00:00 2001 From: Min Idzelis Date: Mon, 17 Nov 2025 15:12:07 -0500 Subject: [PATCH 01/18] fix: deep link to last asset (#23920) --- .../lib/components/timeline/Timeline.svelte | 22 ++++++++++++++----- .../timeline-manager/day-group.svelte.ts | 2 +- .../timeline-manager.svelte.ts | 1 + 3 files changed, 19 insertions(+), 6 deletions(-) diff --git a/web/src/lib/components/timeline/Timeline.svelte b/web/src/lib/components/timeline/Timeline.svelte index 622d89b773..0a209fcde3 100644 --- a/web/src/lib/components/timeline/Timeline.svelte +++ b/web/src/lib/components/timeline/Timeline.svelte @@ -176,12 +176,24 @@ }; const scrollAndLoadAsset = async (assetId: string) => { - const monthGroup = await timelineManager.findMonthGroupForAsset(assetId); - if (!monthGroup) { - return false; + try { + // This flag prevents layout deferral to fix scroll positioning issues. + // When layouts are deferred and we scroll to an asset at the end of the timeline, + // we can calculate the asset's position, but the scrollableElement's scrollHeight + // hasn't been updated yet to reflect the new layout. This creates a mismatch that + // breaks scroll positioning. By disabling layout deferral in this case, we maintain + // the performance benefits of deferred layouts while still supporting deep linking + // to assets at the end of the timeline. + timelineManager.isScrollingOnLoad = true; + const monthGroup = await timelineManager.findMonthGroupForAsset(assetId); + if (!monthGroup) { + return false; + } + scrollToAssetPosition(assetId, monthGroup); + return true; + } finally { + timelineManager.isScrollingOnLoad = false; } - scrollToAssetPosition(assetId, monthGroup); - return true; }; const scrollToAsset = (asset: TimelineAsset) => { 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 a3d3194dd2..934ca1d4ff 100644 --- a/web/src/lib/managers/timeline-manager/day-group.svelte.ts +++ b/web/src/lib/managers/timeline-manager/day-group.svelte.ts @@ -140,7 +140,7 @@ export class DayGroup { } layout(options: CommonLayoutOptions, noDefer: boolean) { - if (!noDefer && !this.monthGroup.intersecting) { + if (!noDefer && !this.monthGroup.intersecting && !this.monthGroup.timelineManager.isScrollingOnLoad) { this.#deferredLayout = true; return; } 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 d2340224a2..24523ce9e7 100644 --- a/web/src/lib/managers/timeline-manager/timeline-manager.svelte.ts +++ b/web/src/lib/managers/timeline-manager/timeline-manager.svelte.ts @@ -61,6 +61,7 @@ export class TimelineManager extends VirtualScrollManager { }); isInitialized = $state(false); + isScrollingOnLoad = false; months: MonthGroup[] = $state([]); albumAssets: Set = new SvelteSet(); scrubberMonths: ScrubberMonth[] = $state([]); From d64c339b4fa0e1d24ebfa30cf3ad7ecef76bab45 Mon Sep 17 00:00:00 2001 From: Min Idzelis Date: Mon, 17 Nov 2025 15:12:28 -0500 Subject: [PATCH 02/18] fix: null dereference when canceling bucket in album (#23924) --- .../managers/timeline-manager/internal/load-support.svelte.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/web/src/lib/managers/timeline-manager/internal/load-support.svelte.ts b/web/src/lib/managers/timeline-manager/internal/load-support.svelte.ts index e6a80afc7f..0d966c9cee 100644 --- a/web/src/lib/managers/timeline-manager/internal/load-support.svelte.ts +++ b/web/src/lib/managers/timeline-manager/internal/load-support.svelte.ts @@ -38,6 +38,9 @@ export async function loadFromTimeBuckets( }, { signal }, ); + if (!albumAssets) { + return; + } for (const id of albumAssets.id) { timelineManager.albumAssets.add(id); } From fbaeffd65c0e2134fe07b160ef1da7433a379fa2 Mon Sep 17 00:00:00 2001 From: Min Idzelis Date: Mon, 17 Nov 2025 15:12:44 -0500 Subject: [PATCH 03/18] fix: flaky user-admin.e2e-spec.ts (#23929) * fix: flaky user-admin.e2e-spec.ts * lint --- e2e/src/web/specs/user-admin.e2e-spec.ts | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/e2e/src/web/specs/user-admin.e2e-spec.ts b/e2e/src/web/specs/user-admin.e2e-spec.ts index 611a1b3dec..e2badb4fa2 100644 --- a/e2e/src/web/specs/user-admin.e2e-spec.ts +++ b/e2e/src/web/specs/user-admin.e2e-spec.ts @@ -58,8 +58,12 @@ test.describe('User Administration', () => { await expect(page.getByLabel('Admin User')).toBeChecked(); await page.getByRole('button', { name: 'Confirm' }).click(); - const updated = await getUserAdmin({ id: user.userId }, { headers: asBearerAuth(admin.accessToken) }); - expect(updated.isAdmin).toBe(true); + await expect + .poll(async () => { + const userAdmin = await getUserAdmin({ id: user.userId }, { headers: asBearerAuth(admin.accessToken) }); + return userAdmin.isAdmin; + }) + .toBe(true); }); test('revoke admin access', async ({ context, page }) => { @@ -83,7 +87,11 @@ test.describe('User Administration', () => { await expect(page.getByLabel('Admin User')).not.toBeChecked(); await page.getByRole('button', { name: 'Confirm' }).click(); - const updated = await getUserAdmin({ id: user.userId }, { headers: asBearerAuth(admin.accessToken) }); - expect(updated.isAdmin).toBe(false); + await expect + .poll(async () => { + const userAdmin = await getUserAdmin({ id: user.userId }, { headers: asBearerAuth(admin.accessToken) }); + return userAdmin.isAdmin; + }) + .toBe(false); }); }); From 237ddcb648769212289510e7c377bf1aa0c98f7c Mon Sep 17 00:00:00 2001 From: Min Idzelis Date: Mon, 17 Nov 2025 15:14:06 -0500 Subject: [PATCH 04/18] fix: incorrect header height calculation in estimated month height (#23923) --- .../timeline-manager/internal/layout-support.svelte.ts | 2 +- .../managers/timeline-manager/timeline-manager.svelte.spec.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/web/src/lib/managers/timeline-manager/internal/layout-support.svelte.ts b/web/src/lib/managers/timeline-manager/internal/layout-support.svelte.ts index 434b2d1847..0f6ca112d1 100644 --- a/web/src/lib/managers/timeline-manager/internal/layout-support.svelte.ts +++ b/web/src/lib/managers/timeline-manager/internal/layout-support.svelte.ts @@ -12,7 +12,7 @@ export function updateGeometry(timelineManager: TimelineManager, month: MonthGro if (!month.isHeightActual) { const unwrappedWidth = (3 / 2) * month.assetsCount * timelineManager.rowHeight * (7 / 10); const rows = Math.ceil(unwrappedWidth / viewportWidth); - const height = 51 + Math.max(1, rows) * timelineManager.rowHeight; + const height = timelineManager.headerHeight + Math.max(1, rows) * timelineManager.rowHeight; month.height = height; } return; 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 a3fda3a85c..e6eddef9b6 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 @@ -84,13 +84,13 @@ describe('TimelineManager', () => { expect.arrayContaining([ expect.objectContaining({ year: 2024, month: 3, height: 283 }), expect.objectContaining({ year: 2024, month: 2, height: 7711 }), - expect.objectContaining({ year: 2024, month: 1, height: 286 }), + expect.objectContaining({ year: 2024, month: 1, height: 283 }), ]), ); }); it('calculates timeline height', () => { - expect(timelineManager.totalViewerHeight).toBe(8340); + expect(timelineManager.totalViewerHeight).toBe(8337); }); }); From 58c3c7e26b80e767d730da474a5d1d25dfdf41c4 Mon Sep 17 00:00:00 2001 From: Min Idzelis Date: Mon, 17 Nov 2025 15:16:39 -0500 Subject: [PATCH 05/18] feat: run e2e server in dev mode (#23921) * feat: run e2e server in dev mode * Use bash syntax: [[ and == --- Makefile | 3 ++ e2e/docker-compose.dev.yml | 105 +++++++++++++++++++++++++++++++++++++ server/bin/immich-dev | 2 +- 3 files changed, 109 insertions(+), 1 deletion(-) create mode 100644 e2e/docker-compose.dev.yml diff --git a/Makefile b/Makefile index fc99170676..2fc1c5d801 100644 --- a/Makefile +++ b/Makefile @@ -17,6 +17,9 @@ dev-docs: e2e: @trap 'make e2e-down' EXIT; COMPOSE_BAKE=true docker compose -f ./e2e/docker-compose.yml up --remove-orphans +e2e-dev: + @trap 'make e2e-down' EXIT; COMPOSE_BAKE=true docker compose -f ./e2e/docker-compose.dev.yml up --remove-orphans + e2e-update: @trap 'make e2e-down' EXIT; COMPOSE_BAKE=true docker compose -f ./e2e/docker-compose.yml up --build -V --remove-orphans diff --git a/e2e/docker-compose.dev.yml b/e2e/docker-compose.dev.yml new file mode 100644 index 0000000000..cd1d3d4982 --- /dev/null +++ b/e2e/docker-compose.dev.yml @@ -0,0 +1,105 @@ +name: immich-e2e + +services: + immich-server: + container_name: immich-e2e-server + command: ['immich-dev'] + image: immich-server-dev:latest + build: + context: ../ + dockerfile: server/Dockerfile.dev + target: dev + environment: + - DB_HOSTNAME=database + - DB_USERNAME=postgres + - DB_PASSWORD=postgres + - DB_DATABASE_NAME=immich + - IMMICH_MACHINE_LEARNING_ENABLED=false + - IMMICH_TELEMETRY_INCLUDE=all + - IMMICH_ENV=testing + - IMMICH_PORT=2285 + - IMMICH_IGNORE_MOUNT_CHECK_ERRORS=true + volumes: + - ./test-assets:/test-assets + - ..:/usr/src/app + - ${UPLOAD_LOCATION}/photos:/data + - /etc/localtime:/etc/localtime:ro + - pnpm-store:/usr/src/app/.pnpm-store + - server-node_modules:/usr/src/app/server/node_modules + - web-node_modules:/usr/src/app/web/node_modules + - github-node_modules:/usr/src/app/.github/node_modules + - cli-node_modules:/usr/src/app/cli/node_modules + - docs-node_modules:/usr/src/app/docs/node_modules + - e2e-node_modules:/usr/src/app/e2e/node_modules + - sdk-node_modules:/usr/src/app/open-api/typescript-sdk/node_modules + - app-node_modules:/usr/src/app/node_modules + - sveltekit:/usr/src/app/web/.svelte-kit + - coverage:/usr/src/app/web/coverage + - ../plugins:/build/corePlugin + depends_on: + redis: + condition: service_started + database: + condition: service_healthy + + immich-web: + container_name: immich-e2e-web + image: immich-web-dev:latest + build: + context: ../ + dockerfile: server/Dockerfile.dev + target: dev + command: ['immich-web'] + ports: + - 2285:3000 + environment: + - IMMICH_SERVER_URL=http://immich-server:2285/ + volumes: + - ..:/usr/src/app + - pnpm-store:/usr/src/app/.pnpm-store + - server-node_modules:/usr/src/app/server/node_modules + - web-node_modules:/usr/src/app/web/node_modules + - github-node_modules:/usr/src/app/.github/node_modules + - cli-node_modules:/usr/src/app/cli/node_modules + - docs-node_modules:/usr/src/app/docs/node_modules + - e2e-node_modules:/usr/src/app/e2e/node_modules + - sdk-node_modules:/usr/src/app/open-api/typescript-sdk/node_modules + - app-node_modules:/usr/src/app/node_modules + - sveltekit:/usr/src/app/web/.svelte-kit + - coverage:/usr/src/app/web/coverage + restart: unless-stopped + + redis: + image: redis:6.2-alpine@sha256:37e002448575b32a599109664107e374c8709546905c372a34d64919043b9ceb + + database: + image: ghcr.io/immich-app/postgres:14-vectorchord0.3.0@sha256:6f3e9d2c2177af16c2988ff71425d79d89ca630ec2f9c8db03209ab716542338 + command: -c fsync=off -c shared_preload_libraries=vchord.so -c config_file=/var/lib/postgresql/data/postgresql.conf + environment: + POSTGRES_PASSWORD: postgres + POSTGRES_USER: postgres + POSTGRES_DB: immich + ports: + - 5435:5432 + healthcheck: + test: ['CMD-SHELL', 'pg_isready -U postgres -d immich'] + interval: 1s + timeout: 5s + retries: 30 + start_period: 10s + +volumes: + model-cache: + prometheus-data: + grafana-data: + pnpm-store: + server-node_modules: + web-node_modules: + github-node_modules: + cli-node_modules: + docs-node_modules: + e2e-node_modules: + sdk-node_modules: + app-node_modules: + sveltekit: + coverage: diff --git a/server/bin/immich-dev b/server/bin/immich-dev index 28a0443be7..84c5eea8da 100755 --- a/server/bin/immich-dev +++ b/server/bin/immich-dev @@ -1,6 +1,6 @@ #!/usr/bin/env bash -if [ "$IMMICH_ENV" != "development" ]; then +if [[ "$IMMICH_ENV" == "production" ]]; then echo "This command can only be run in development environments" exit 1 fi From 3e08953a43790c0aee0c1b9826f6bcb247b2186f Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 18 Nov 2025 12:20:52 +0100 Subject: [PATCH 06/18] chore(deps): update dependency @types/node to ^22.19.1 (#23963) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- cli/package.json | 2 +- e2e/package.json | 2 +- open-api/typescript-sdk/package.json | 2 +- pnpm-lock.yaml | 346 +++++++++++++-------------- server/package.json | 2 +- 5 files changed, 177 insertions(+), 177 deletions(-) diff --git a/cli/package.json b/cli/package.json index 6fed806003..118920f19f 100644 --- a/cli/package.json +++ b/cli/package.json @@ -20,7 +20,7 @@ "@types/lodash-es": "^4.17.12", "@types/micromatch": "^4.0.9", "@types/mock-fs": "^4.13.1", - "@types/node": "^22.19.0", + "@types/node": "^22.19.1", "@vitest/coverage-v8": "^3.0.0", "byte-size": "^9.0.0", "cli-progress": "^3.12.0", diff --git a/e2e/package.json b/e2e/package.json index 84e1823e0c..9ea02161e4 100644 --- a/e2e/package.json +++ b/e2e/package.json @@ -25,7 +25,7 @@ "@playwright/test": "^1.44.1", "@socket.io/component-emitter": "^3.1.2", "@types/luxon": "^3.4.2", - "@types/node": "^22.19.0", + "@types/node": "^22.19.1", "@types/oidc-provider": "^9.0.0", "@types/pg": "^8.15.1", "@types/pngjs": "^6.0.4", diff --git a/open-api/typescript-sdk/package.json b/open-api/typescript-sdk/package.json index 1756675ddd..8716378d87 100644 --- a/open-api/typescript-sdk/package.json +++ b/open-api/typescript-sdk/package.json @@ -19,7 +19,7 @@ "@oazapfts/runtime": "^1.0.2" }, "devDependencies": { - "@types/node": "^22.19.0", + "@types/node": "^22.19.1", "typescript": "^5.3.3" }, "repository": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 28ac12ab78..20bc1de6ce 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -63,11 +63,11 @@ importers: specifier: ^4.13.1 version: 4.13.4 '@types/node': - specifier: ^22.19.0 - version: 22.19.0 + specifier: ^22.19.1 + version: 22.19.1 '@vitest/coverage-v8': specifier: ^3.0.0 - version: 3.2.4(vitest@3.2.4(@types/debug@4.1.12)(@types/node@22.19.0)(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: 3.2.4(vitest@3.2.4(@types/debug@4.1.12)(@types/node@22.19.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)) byte-size: specifier: ^9.0.0 version: 9.0.1 @@ -109,16 +109,16 @@ importers: version: 8.46.2(eslint@9.38.0(jiti@2.6.1))(typescript@5.9.3) vite: specifier: ^7.0.0 - version: 7.1.12(@types/node@22.19.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1) + version: 7.1.12(@types/node@22.19.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1) vite-tsconfig-paths: specifier: ^5.0.0 - version: 5.1.4(typescript@5.9.3)(vite@7.1.12(@types/node@22.19.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1)) + version: 5.1.4(typescript@5.9.3)(vite@7.1.12(@types/node@22.19.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1)) vitest: specifier: ^3.0.0 - version: 3.2.4(@types/debug@4.1.12)(@types/node@22.19.0)(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: 3.2.4(@types/debug@4.1.12)(@types/node@22.19.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) vitest-fetch-mock: specifier: ^0.4.0 - version: 0.4.5(vitest@3.2.4(@types/debug@4.1.12)(@types/node@22.19.0)(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: 0.4.5(vitest@3.2.4(@types/debug@4.1.12)(@types/node@22.19.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)) yaml: specifier: ^2.3.1 version: 2.8.1 @@ -211,8 +211,8 @@ importers: specifier: ^3.4.2 version: 3.7.1 '@types/node': - specifier: ^22.19.0 - version: 22.19.0 + specifier: ^22.19.1 + version: 22.19.1 '@types/oidc-provider': specifier: ^9.0.0 version: 9.5.0 @@ -284,7 +284,7 @@ importers: version: 5.2.1(encoding@0.1.13) vitest: specifier: ^3.0.0 - version: 3.2.4(@types/debug@4.1.12)(@types/node@22.19.0)(happy-dom@20.0.10)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2(encoding@0.1.13)))(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1) + version: 3.2.4(@types/debug@4.1.12)(@types/node@22.19.1)(happy-dom@20.0.10)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2(encoding@0.1.13)))(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1) open-api/typescript-sdk: dependencies: @@ -293,8 +293,8 @@ importers: version: 1.0.4 devDependencies: '@types/node': - specifier: ^22.19.0 - version: 22.19.0 + specifier: ^22.19.1 + version: 22.19.1 typescript: specifier: ^5.3.3 version: 5.9.3 @@ -474,7 +474,7 @@ importers: version: 2.0.2 nest-commander: specifier: ^3.16.0 - version: 3.20.1(@nestjs/common@11.1.8(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.8)(@types/inquirer@8.2.11)(@types/node@22.19.0)(typescript@5.9.3) + version: 3.20.1(@nestjs/common@11.1.8(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.8)(@types/inquirer@8.2.11)(@types/node@22.19.1)(typescript@5.9.3) nestjs-cls: specifier: ^5.0.0 version: 5.4.3(@nestjs/common@11.1.8(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.8)(reflect-metadata@0.2.2)(rxjs@7.8.2) @@ -556,7 +556,7 @@ importers: version: 9.38.0 '@nestjs/cli': specifier: ^11.0.2 - version: 11.0.10(@swc/core@1.14.0(@swc/helpers@0.5.17))(@types/node@22.19.0) + version: 11.0.10(@swc/core@1.14.0(@swc/helpers@0.5.17))(@types/node@22.19.1) '@nestjs/schematics': specifier: ^11.0.0 version: 11.0.9(chokidar@4.0.3)(typescript@5.9.3) @@ -609,8 +609,8 @@ importers: specifier: ^2.0.0 version: 2.0.0 '@types/node': - specifier: ^22.19.0 - version: 22.19.0 + specifier: ^22.19.1 + version: 22.19.1 '@types/nodemailer': specifier: ^7.0.0 version: 7.0.3 @@ -640,7 +640,7 @@ importers: version: 13.15.4 '@vitest/coverage-v8': specifier: ^3.0.0 - version: 3.2.4(vitest@3.2.4(@types/debug@4.1.12)(@types/node@22.19.0)(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: 3.2.4(vitest@3.2.4(@types/debug@4.1.12)(@types/node@22.19.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)) eslint: specifier: ^9.14.0 version: 9.38.0(jiti@2.6.1) @@ -694,10 +694,10 @@ importers: version: 1.5.8(@swc/core@1.14.0(@swc/helpers@0.5.17))(rollup@4.52.5) vite-tsconfig-paths: specifier: ^5.0.0 - version: 5.1.4(typescript@5.9.3)(vite@7.1.12(@types/node@22.19.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1)) + version: 5.1.4(typescript@5.9.3)(vite@7.1.12(@types/node@22.19.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1)) vitest: specifier: ^3.0.0 - version: 3.2.4(@types/debug@4.1.12)(@types/node@22.19.0)(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: 3.2.4(@types/debug@4.1.12)(@types/node@22.19.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) web: dependencies: @@ -4686,8 +4686,8 @@ packages: '@types/node@20.19.24': resolution: {integrity: sha512-FE5u0ezmi6y9OZEzlJfg37mqqf6ZDSF2V/NLjUyGrR9uTZ7Sb9F7bLNZ03S4XVUNRWGA7Ck4c1kK+YnuWjl+DA==} - '@types/node@22.19.0': - resolution: {integrity: sha512-xpr/lmLPQEj+TUnHmR+Ab91/glhJvsqcjB+yY0Ix9GO70H6Lb4FHH5GeqdOE5btAx7eIMwuHkp4H2MSkLcqWbA==} + '@types/node@22.19.1': + resolution: {integrity: sha512-LCCV0HdSZZZb34qifBsyWlUmok6W7ouER+oQIGBScS8EsZsQbrtFTUrDX4hOl+CS6p7cnNC4td+qrSVGSCTUfQ==} '@types/node@24.10.0': resolution: {integrity: sha512-qzQZRBqkFsYyaSWXuEHc2WR9c0a0CXwiE5FWUvn7ZM+vdy1uZLfCunD38UzhuB7YN/J11ndbDBcTmOdxJo9Q7A==} @@ -11793,11 +11793,11 @@ snapshots: optionalDependencies: chokidar: 4.0.3 - '@angular-devkit/schematics-cli@19.2.15(@types/node@22.19.0)(chokidar@4.0.3)': + '@angular-devkit/schematics-cli@19.2.15(@types/node@22.19.1)(chokidar@4.0.3)': dependencies: '@angular-devkit/core': 19.2.15(chokidar@4.0.3) '@angular-devkit/schematics': 19.2.15(chokidar@4.0.3) - '@inquirer/prompts': 7.3.2(@types/node@22.19.0) + '@inquirer/prompts': 7.3.2(@types/node@22.19.1) ansi-colors: 4.1.3 symbol-observable: 4.0.0 yargs-parser: 21.1.1 @@ -14447,27 +14447,27 @@ snapshots: transitivePeerDependencies: - '@internationalized/date' - '@inquirer/checkbox@4.2.1(@types/node@22.19.0)': + '@inquirer/checkbox@4.2.1(@types/node@22.19.1)': dependencies: - '@inquirer/core': 10.1.15(@types/node@22.19.0) + '@inquirer/core': 10.1.15(@types/node@22.19.1) '@inquirer/figures': 1.0.13 - '@inquirer/type': 3.0.8(@types/node@22.19.0) + '@inquirer/type': 3.0.8(@types/node@22.19.1) ansi-escapes: 4.3.2 yoctocolors-cjs: 2.1.2 optionalDependencies: - '@types/node': 22.19.0 + '@types/node': 22.19.1 - '@inquirer/confirm@5.1.15(@types/node@22.19.0)': + '@inquirer/confirm@5.1.15(@types/node@22.19.1)': dependencies: - '@inquirer/core': 10.1.15(@types/node@22.19.0) - '@inquirer/type': 3.0.8(@types/node@22.19.0) + '@inquirer/core': 10.1.15(@types/node@22.19.1) + '@inquirer/type': 3.0.8(@types/node@22.19.1) optionalDependencies: - '@types/node': 22.19.0 + '@types/node': 22.19.1 - '@inquirer/core@10.1.15(@types/node@22.19.0)': + '@inquirer/core@10.1.15(@types/node@22.19.1)': dependencies: '@inquirer/figures': 1.0.13 - '@inquirer/type': 3.0.8(@types/node@22.19.0) + '@inquirer/type': 3.0.8(@types/node@22.19.1) ansi-escapes: 4.3.2 cli-width: 4.1.0 mute-stream: 2.0.0 @@ -14475,115 +14475,115 @@ snapshots: wrap-ansi: 6.2.0 yoctocolors-cjs: 2.1.2 optionalDependencies: - '@types/node': 22.19.0 + '@types/node': 22.19.1 - '@inquirer/editor@4.2.17(@types/node@22.19.0)': + '@inquirer/editor@4.2.17(@types/node@22.19.1)': dependencies: - '@inquirer/core': 10.1.15(@types/node@22.19.0) - '@inquirer/external-editor': 1.0.2(@types/node@22.19.0) - '@inquirer/type': 3.0.8(@types/node@22.19.0) + '@inquirer/core': 10.1.15(@types/node@22.19.1) + '@inquirer/external-editor': 1.0.2(@types/node@22.19.1) + '@inquirer/type': 3.0.8(@types/node@22.19.1) optionalDependencies: - '@types/node': 22.19.0 + '@types/node': 22.19.1 - '@inquirer/expand@4.0.17(@types/node@22.19.0)': + '@inquirer/expand@4.0.17(@types/node@22.19.1)': dependencies: - '@inquirer/core': 10.1.15(@types/node@22.19.0) - '@inquirer/type': 3.0.8(@types/node@22.19.0) + '@inquirer/core': 10.1.15(@types/node@22.19.1) + '@inquirer/type': 3.0.8(@types/node@22.19.1) yoctocolors-cjs: 2.1.2 optionalDependencies: - '@types/node': 22.19.0 + '@types/node': 22.19.1 - '@inquirer/external-editor@1.0.2(@types/node@22.19.0)': + '@inquirer/external-editor@1.0.2(@types/node@22.19.1)': dependencies: chardet: 2.1.0 iconv-lite: 0.7.0 optionalDependencies: - '@types/node': 22.19.0 + '@types/node': 22.19.1 '@inquirer/figures@1.0.13': {} - '@inquirer/input@4.2.1(@types/node@22.19.0)': + '@inquirer/input@4.2.1(@types/node@22.19.1)': dependencies: - '@inquirer/core': 10.1.15(@types/node@22.19.0) - '@inquirer/type': 3.0.8(@types/node@22.19.0) + '@inquirer/core': 10.1.15(@types/node@22.19.1) + '@inquirer/type': 3.0.8(@types/node@22.19.1) optionalDependencies: - '@types/node': 22.19.0 + '@types/node': 22.19.1 - '@inquirer/number@3.0.17(@types/node@22.19.0)': + '@inquirer/number@3.0.17(@types/node@22.19.1)': dependencies: - '@inquirer/core': 10.1.15(@types/node@22.19.0) - '@inquirer/type': 3.0.8(@types/node@22.19.0) + '@inquirer/core': 10.1.15(@types/node@22.19.1) + '@inquirer/type': 3.0.8(@types/node@22.19.1) optionalDependencies: - '@types/node': 22.19.0 + '@types/node': 22.19.1 - '@inquirer/password@4.0.17(@types/node@22.19.0)': + '@inquirer/password@4.0.17(@types/node@22.19.1)': dependencies: - '@inquirer/core': 10.1.15(@types/node@22.19.0) - '@inquirer/type': 3.0.8(@types/node@22.19.0) + '@inquirer/core': 10.1.15(@types/node@22.19.1) + '@inquirer/type': 3.0.8(@types/node@22.19.1) ansi-escapes: 4.3.2 optionalDependencies: - '@types/node': 22.19.0 - - '@inquirer/prompts@7.3.2(@types/node@22.19.0)': - dependencies: - '@inquirer/checkbox': 4.2.1(@types/node@22.19.0) - '@inquirer/confirm': 5.1.15(@types/node@22.19.0) - '@inquirer/editor': 4.2.17(@types/node@22.19.0) - '@inquirer/expand': 4.0.17(@types/node@22.19.0) - '@inquirer/input': 4.2.1(@types/node@22.19.0) - '@inquirer/number': 3.0.17(@types/node@22.19.0) - '@inquirer/password': 4.0.17(@types/node@22.19.0) - '@inquirer/rawlist': 4.1.5(@types/node@22.19.0) - '@inquirer/search': 3.1.0(@types/node@22.19.0) - '@inquirer/select': 4.3.1(@types/node@22.19.0) + '@types/node': 22.19.1 + + '@inquirer/prompts@7.3.2(@types/node@22.19.1)': + dependencies: + '@inquirer/checkbox': 4.2.1(@types/node@22.19.1) + '@inquirer/confirm': 5.1.15(@types/node@22.19.1) + '@inquirer/editor': 4.2.17(@types/node@22.19.1) + '@inquirer/expand': 4.0.17(@types/node@22.19.1) + '@inquirer/input': 4.2.1(@types/node@22.19.1) + '@inquirer/number': 3.0.17(@types/node@22.19.1) + '@inquirer/password': 4.0.17(@types/node@22.19.1) + '@inquirer/rawlist': 4.1.5(@types/node@22.19.1) + '@inquirer/search': 3.1.0(@types/node@22.19.1) + '@inquirer/select': 4.3.1(@types/node@22.19.1) optionalDependencies: - '@types/node': 22.19.0 - - '@inquirer/prompts@7.8.0(@types/node@22.19.0)': - dependencies: - '@inquirer/checkbox': 4.2.1(@types/node@22.19.0) - '@inquirer/confirm': 5.1.15(@types/node@22.19.0) - '@inquirer/editor': 4.2.17(@types/node@22.19.0) - '@inquirer/expand': 4.0.17(@types/node@22.19.0) - '@inquirer/input': 4.2.1(@types/node@22.19.0) - '@inquirer/number': 3.0.17(@types/node@22.19.0) - '@inquirer/password': 4.0.17(@types/node@22.19.0) - '@inquirer/rawlist': 4.1.5(@types/node@22.19.0) - '@inquirer/search': 3.1.0(@types/node@22.19.0) - '@inquirer/select': 4.3.1(@types/node@22.19.0) + '@types/node': 22.19.1 + + '@inquirer/prompts@7.8.0(@types/node@22.19.1)': + dependencies: + '@inquirer/checkbox': 4.2.1(@types/node@22.19.1) + '@inquirer/confirm': 5.1.15(@types/node@22.19.1) + '@inquirer/editor': 4.2.17(@types/node@22.19.1) + '@inquirer/expand': 4.0.17(@types/node@22.19.1) + '@inquirer/input': 4.2.1(@types/node@22.19.1) + '@inquirer/number': 3.0.17(@types/node@22.19.1) + '@inquirer/password': 4.0.17(@types/node@22.19.1) + '@inquirer/rawlist': 4.1.5(@types/node@22.19.1) + '@inquirer/search': 3.1.0(@types/node@22.19.1) + '@inquirer/select': 4.3.1(@types/node@22.19.1) optionalDependencies: - '@types/node': 22.19.0 + '@types/node': 22.19.1 - '@inquirer/rawlist@4.1.5(@types/node@22.19.0)': + '@inquirer/rawlist@4.1.5(@types/node@22.19.1)': dependencies: - '@inquirer/core': 10.1.15(@types/node@22.19.0) - '@inquirer/type': 3.0.8(@types/node@22.19.0) + '@inquirer/core': 10.1.15(@types/node@22.19.1) + '@inquirer/type': 3.0.8(@types/node@22.19.1) yoctocolors-cjs: 2.1.2 optionalDependencies: - '@types/node': 22.19.0 + '@types/node': 22.19.1 - '@inquirer/search@3.1.0(@types/node@22.19.0)': + '@inquirer/search@3.1.0(@types/node@22.19.1)': dependencies: - '@inquirer/core': 10.1.15(@types/node@22.19.0) + '@inquirer/core': 10.1.15(@types/node@22.19.1) '@inquirer/figures': 1.0.13 - '@inquirer/type': 3.0.8(@types/node@22.19.0) + '@inquirer/type': 3.0.8(@types/node@22.19.1) yoctocolors-cjs: 2.1.2 optionalDependencies: - '@types/node': 22.19.0 + '@types/node': 22.19.1 - '@inquirer/select@4.3.1(@types/node@22.19.0)': + '@inquirer/select@4.3.1(@types/node@22.19.1)': dependencies: - '@inquirer/core': 10.1.15(@types/node@22.19.0) + '@inquirer/core': 10.1.15(@types/node@22.19.1) '@inquirer/figures': 1.0.13 - '@inquirer/type': 3.0.8(@types/node@22.19.0) + '@inquirer/type': 3.0.8(@types/node@22.19.1) ansi-escapes: 4.3.2 yoctocolors-cjs: 2.1.2 optionalDependencies: - '@types/node': 22.19.0 + '@types/node': 22.19.1 - '@inquirer/type@3.0.8(@types/node@22.19.0)': + '@inquirer/type@3.0.8(@types/node@22.19.1)': optionalDependencies: - '@types/node': 22.19.0 + '@types/node': 22.19.1 '@internationalized/date@3.8.2': dependencies: @@ -14621,7 +14621,7 @@ snapshots: '@jest/schemas': 29.6.3 '@types/istanbul-lib-coverage': 2.0.6 '@types/istanbul-reports': 3.0.4 - '@types/node': 22.19.0 + '@types/node': 22.19.1 '@types/yargs': 17.0.34 chalk: 4.1.2 @@ -14889,12 +14889,12 @@ snapshots: bullmq: 5.62.1 tslib: 2.8.1 - '@nestjs/cli@11.0.10(@swc/core@1.14.0(@swc/helpers@0.5.17))(@types/node@22.19.0)': + '@nestjs/cli@11.0.10(@swc/core@1.14.0(@swc/helpers@0.5.17))(@types/node@22.19.1)': dependencies: '@angular-devkit/core': 19.2.15(chokidar@4.0.3) '@angular-devkit/schematics': 19.2.15(chokidar@4.0.3) - '@angular-devkit/schematics-cli': 19.2.15(@types/node@22.19.0)(chokidar@4.0.3) - '@inquirer/prompts': 7.8.0(@types/node@22.19.0) + '@angular-devkit/schematics-cli': 19.2.15(@types/node@22.19.1)(chokidar@4.0.3) + '@inquirer/prompts': 7.8.0(@types/node@22.19.1) '@nestjs/schematics': 11.0.9(chokidar@4.0.3)(typescript@5.8.3) ansis: 4.1.0 chokidar: 4.0.3 @@ -16294,7 +16294,7 @@ snapshots: '@types/accepts@1.3.7': dependencies: - '@types/node': 22.19.0 + '@types/node': 22.19.1 '@types/archiver@6.0.4': dependencies: @@ -16306,16 +16306,16 @@ snapshots: '@types/bcrypt@6.0.0': dependencies: - '@types/node': 22.19.0 + '@types/node': 22.19.1 '@types/body-parser@1.19.6': dependencies: '@types/connect': 3.4.38 - '@types/node': 22.19.0 + '@types/node': 22.19.1 '@types/bonjour@3.5.13': dependencies: - '@types/node': 22.19.0 + '@types/node': 22.19.1 '@types/braces@3.0.5': {} @@ -16336,21 +16336,21 @@ snapshots: '@types/cli-progress@3.11.6': dependencies: - '@types/node': 22.19.0 + '@types/node': 22.19.1 '@types/compression@1.8.1': dependencies: '@types/express': 5.0.5 - '@types/node': 22.19.0 + '@types/node': 22.19.1 '@types/connect-history-api-fallback@1.5.4': dependencies: '@types/express-serve-static-core': 5.1.0 - '@types/node': 22.19.0 + '@types/node': 22.19.1 '@types/connect@3.4.38': dependencies: - '@types/node': 22.19.0 + '@types/node': 22.19.1 '@types/content-disposition@0.5.9': {} @@ -16367,11 +16367,11 @@ snapshots: '@types/connect': 3.4.38 '@types/express': 5.0.5 '@types/keygrip': 1.0.6 - '@types/node': 22.19.0 + '@types/node': 22.19.1 '@types/cors@2.8.19': dependencies: - '@types/node': 22.19.0 + '@types/node': 22.19.1 '@types/debug@4.1.12': dependencies: @@ -16381,13 +16381,13 @@ snapshots: '@types/docker-modem@3.0.6': dependencies: - '@types/node': 22.19.0 + '@types/node': 22.19.1 '@types/ssh2': 1.15.5 '@types/dockerode@3.3.45': dependencies: '@types/docker-modem': 3.0.6 - '@types/node': 22.19.0 + '@types/node': 22.19.1 '@types/ssh2': 1.15.5 '@types/dom-to-image@2.6.7': {} @@ -16410,14 +16410,14 @@ snapshots: '@types/express-serve-static-core@4.19.7': dependencies: - '@types/node': 22.19.0 + '@types/node': 22.19.1 '@types/qs': 6.14.0 '@types/range-parser': 1.2.7 '@types/send': 1.2.1 '@types/express-serve-static-core@5.1.0': dependencies: - '@types/node': 22.19.0 + '@types/node': 22.19.1 '@types/qs': 6.14.0 '@types/range-parser': 1.2.7 '@types/send': 1.2.1 @@ -16443,7 +16443,7 @@ snapshots: '@types/fluent-ffmpeg@2.1.28': dependencies: - '@types/node': 22.19.0 + '@types/node': 22.19.1 '@types/geojson-vt@3.2.5': dependencies: @@ -16475,7 +16475,7 @@ snapshots: '@types/http-proxy@1.17.17': dependencies: - '@types/node': 22.19.0 + '@types/node': 22.19.1 '@types/inquirer@8.2.11': dependencies: @@ -16499,7 +16499,7 @@ snapshots: '@types/jsonwebtoken@9.0.10': dependencies: '@types/ms': 2.1.0 - '@types/node': 22.19.0 + '@types/node': 22.19.1 '@types/justified-layout@4.1.4': {} @@ -16518,7 +16518,7 @@ snapshots: '@types/http-errors': 2.0.5 '@types/keygrip': 1.0.6 '@types/koa-compose': 3.2.8 - '@types/node': 22.19.0 + '@types/node': 22.19.1 '@types/leaflet@1.9.21': dependencies: @@ -16548,7 +16548,7 @@ snapshots: '@types/mock-fs@4.13.4': dependencies: - '@types/node': 22.19.0 + '@types/node': 22.19.1 '@types/ms@2.1.0': {} @@ -16558,7 +16558,7 @@ snapshots: '@types/node-forge@1.3.14': dependencies: - '@types/node': 22.19.0 + '@types/node': 22.19.1 '@types/node@17.0.45': {} @@ -16570,7 +16570,7 @@ snapshots: dependencies: undici-types: 6.21.0 - '@types/node@22.19.0': + '@types/node@22.19.1': dependencies: undici-types: 6.21.0 @@ -16582,7 +16582,7 @@ snapshots: '@types/nodemailer@7.0.3': dependencies: '@aws-sdk/client-sesv2': 3.919.0 - '@types/node': 22.19.0 + '@types/node': 22.19.1 transitivePeerDependencies: - aws-crt @@ -16590,7 +16590,7 @@ snapshots: dependencies: '@types/keygrip': 1.0.6 '@types/koa': 3.0.0 - '@types/node': 22.19.0 + '@types/node': 22.19.1 '@types/parse5@5.0.3': {} @@ -16600,13 +16600,13 @@ snapshots: '@types/pg@8.15.5': dependencies: - '@types/node': 22.19.0 + '@types/node': 22.19.1 pg-protocol: 1.10.3 pg-types: 2.2.0 '@types/pg@8.15.6': dependencies: - '@types/node': 22.19.0 + '@types/node': 22.19.1 pg-protocol: 1.10.3 pg-types: 2.2.0 @@ -16614,13 +16614,13 @@ snapshots: '@types/pngjs@6.0.5': dependencies: - '@types/node': 22.19.0 + '@types/node': 22.19.1 '@types/prismjs@1.26.5': {} '@types/qrcode@1.5.6': dependencies: - '@types/node': 22.19.0 + '@types/node': 22.19.1 '@types/qs@6.14.0': {} @@ -16649,7 +16649,7 @@ snapshots: '@types/readdir-glob@1.1.5': dependencies: - '@types/node': 22.19.0 + '@types/node': 22.19.1 '@types/retry@0.12.2': {} @@ -16659,18 +16659,18 @@ snapshots: '@types/sax@1.2.7': dependencies: - '@types/node': 22.19.0 + '@types/node': 22.19.1 '@types/semver@7.7.1': {} '@types/send@0.17.6': dependencies: '@types/mime': 1.3.5 - '@types/node': 22.19.0 + '@types/node': 22.19.1 '@types/send@1.2.1': dependencies: - '@types/node': 22.19.0 + '@types/node': 22.19.1 '@types/serve-index@1.9.4': dependencies: @@ -16679,20 +16679,20 @@ snapshots: '@types/serve-static@1.15.10': dependencies: '@types/http-errors': 2.0.5 - '@types/node': 22.19.0 + '@types/node': 22.19.1 '@types/send': 0.17.6 '@types/sockjs@0.3.36': dependencies: - '@types/node': 22.19.0 + '@types/node': 22.19.1 '@types/ssh2-streams@0.1.13': dependencies: - '@types/node': 22.19.0 + '@types/node': 22.19.1 '@types/ssh2@0.5.52': dependencies: - '@types/node': 22.19.0 + '@types/node': 22.19.1 '@types/ssh2-streams': 0.1.13 '@types/ssh2@1.15.5': @@ -16703,7 +16703,7 @@ snapshots: dependencies: '@types/cookiejar': 2.1.5 '@types/methods': 1.1.4 - '@types/node': 22.19.0 + '@types/node': 22.19.1 form-data: 4.0.4 '@types/supercluster@7.1.3': @@ -16717,7 +16717,7 @@ snapshots: '@types/through@0.0.33': dependencies: - '@types/node': 22.19.0 + '@types/node': 22.19.1 '@types/ua-parser-js@0.7.39': {} @@ -16731,7 +16731,7 @@ snapshots: '@types/ws@8.18.1': dependencies: - '@types/node': 22.19.0 + '@types/node': 22.19.1 '@types/yargs-parser@21.0.3': {} @@ -16836,7 +16836,7 @@ snapshots: '@vercel/oidc@3.0.3': {} - '@vitest/coverage-v8@3.2.4(vitest@3.2.4(@types/debug@4.1.12)(@types/node@22.19.0)(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))': + '@vitest/coverage-v8@3.2.4(vitest@3.2.4(@types/debug@4.1.12)(@types/node@22.19.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: '@ampproject/remapping': 2.3.0 '@bcoe/v8-coverage': 1.0.2 @@ -16851,7 +16851,7 @@ snapshots: std-env: 3.10.0 test-exclude: 7.0.1 tinyrainbow: 2.0.0 - vitest: 3.2.4(@types/debug@4.1.12)(@types/node@22.19.0)(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) + vitest: 3.2.4(@types/debug@4.1.12)(@types/node@22.19.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) transitivePeerDependencies: - supports-color @@ -16882,13 +16882,13 @@ snapshots: chai: 5.2.0 tinyrainbow: 2.0.0 - '@vitest/mocker@3.2.4(vite@7.1.12(@types/node@22.19.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1))': + '@vitest/mocker@3.2.4(vite@7.1.12(@types/node@22.19.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1))': dependencies: '@vitest/spy': 3.2.4 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 7.1.12(@types/node@22.19.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1) + vite: 7.1.12(@types/node@22.19.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1) '@vitest/mocker@3.2.4(vite@7.1.12(@types/node@24.10.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1))': dependencies: @@ -18480,7 +18480,7 @@ snapshots: engine.io@6.6.4: dependencies: '@types/cors': 2.8.19 - '@types/node': 22.19.0 + '@types/node': 22.19.1 accepts: 1.3.8 base64id: 2.0.0 cookie: 0.7.2 @@ -18869,7 +18869,7 @@ snapshots: eval@0.1.8: dependencies: - '@types/node': 22.19.0 + '@types/node': 22.19.1 require-like: 0.1.2 event-emitter@0.3.5: @@ -19858,9 +19858,9 @@ snapshots: inline-style-parser@0.2.4: {} - inquirer@8.2.7(@types/node@22.19.0): + inquirer@8.2.7(@types/node@22.19.1): dependencies: - '@inquirer/external-editor': 1.0.2(@types/node@22.19.0) + '@inquirer/external-editor': 1.0.2(@types/node@22.19.1) ansi-escapes: 4.3.2 chalk: 4.1.2 cli-cursor: 3.1.0 @@ -20074,7 +20074,7 @@ snapshots: jest-util@29.7.0: dependencies: '@jest/types': 29.6.3 - '@types/node': 22.19.0 + '@types/node': 22.19.1 chalk: 4.1.2 ci-info: 3.9.0 graceful-fs: 4.2.11 @@ -20082,13 +20082,13 @@ snapshots: jest-worker@27.5.1: dependencies: - '@types/node': 22.19.0 + '@types/node': 22.19.1 merge-stream: 2.0.0 supports-color: 8.1.1 jest-worker@29.7.0: dependencies: - '@types/node': 22.19.0 + '@types/node': 22.19.1 jest-util: 29.7.0 merge-stream: 2.0.0 supports-color: 8.1.1 @@ -21346,7 +21346,7 @@ snapshots: neo-async@2.6.2: {} - nest-commander@3.20.1(@nestjs/common@11.1.8(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.8)(@types/inquirer@8.2.11)(@types/node@22.19.0)(typescript@5.9.3): + nest-commander@3.20.1(@nestjs/common@11.1.8(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.8)(@types/inquirer@8.2.11)(@types/node@22.19.1)(typescript@5.9.3): dependencies: '@fig/complete-commander': 3.2.0(commander@11.1.0) '@golevelup/nestjs-discovery': 5.0.0(@nestjs/common@11.1.8(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.8) @@ -21355,7 +21355,7 @@ snapshots: '@types/inquirer': 8.2.11 commander: 11.1.0 cosmiconfig: 8.3.6(typescript@5.9.3) - inquirer: 8.2.7(@types/node@22.19.0) + inquirer: 8.2.7(@types/node@22.19.1) transitivePeerDependencies: - '@types/node' - typescript @@ -22453,7 +22453,7 @@ snapshots: '@protobufjs/path': 1.1.2 '@protobufjs/pool': 1.1.0 '@protobufjs/utf8': 1.1.0 - '@types/node': 22.19.0 + '@types/node': 22.19.1 long: 5.3.2 protocol-buffers-schema@3.6.0: {} @@ -24350,13 +24350,13 @@ snapshots: - rollup - supports-color - vite-node@3.2.4(@types/node@22.19.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1): + vite-node@3.2.4(@types/node@22.19.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1): dependencies: cac: 6.7.14 debug: 4.4.3 es-module-lexer: 1.7.0 pathe: 2.0.3 - vite: 7.1.12(@types/node@22.19.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1) + vite: 7.1.12(@types/node@22.19.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1) transitivePeerDependencies: - '@types/node' - jiti @@ -24392,18 +24392,18 @@ snapshots: - tsx - yaml - vite-tsconfig-paths@5.1.4(typescript@5.9.3)(vite@7.1.12(@types/node@22.19.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1)): + vite-tsconfig-paths@5.1.4(typescript@5.9.3)(vite@7.1.12(@types/node@22.19.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1)): dependencies: debug: 4.4.3 globrex: 0.1.2 tsconfck: 3.1.6(typescript@5.9.3) optionalDependencies: - vite: 7.1.12(@types/node@22.19.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1) + vite: 7.1.12(@types/node@22.19.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1) transitivePeerDependencies: - supports-color - typescript - vite@7.1.12(@types/node@22.19.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1): + vite@7.1.12(@types/node@22.19.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1): dependencies: esbuild: 0.25.12 fdir: 6.5.0(picomatch@4.0.3) @@ -24412,7 +24412,7 @@ snapshots: rollup: 4.52.5 tinyglobby: 0.2.15 optionalDependencies: - '@types/node': 22.19.0 + '@types/node': 22.19.1 fsevents: 2.3.3 jiti: 2.6.1 lightningcss: 1.30.2 @@ -24439,15 +24439,15 @@ snapshots: optionalDependencies: vite: 7.1.12(@types/node@24.10.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1) - vitest-fetch-mock@0.4.5(vitest@3.2.4(@types/debug@4.1.12)(@types/node@22.19.0)(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)): + vitest-fetch-mock@0.4.5(vitest@3.2.4(@types/debug@4.1.12)(@types/node@22.19.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: - vitest: 3.2.4(@types/debug@4.1.12)(@types/node@22.19.0)(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) + vitest: 3.2.4(@types/debug@4.1.12)(@types/node@22.19.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) - vitest@3.2.4(@types/debug@4.1.12)(@types/node@22.19.0)(happy-dom@20.0.10)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2(encoding@0.1.13)))(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1): + vitest@3.2.4(@types/debug@4.1.12)(@types/node@22.19.1)(happy-dom@20.0.10)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2(encoding@0.1.13)))(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1): dependencies: '@types/chai': 5.2.2 '@vitest/expect': 3.2.4 - '@vitest/mocker': 3.2.4(vite@7.1.12(@types/node@22.19.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1)) + '@vitest/mocker': 3.2.4(vite@7.1.12(@types/node@22.19.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1)) '@vitest/pretty-format': 3.2.4 '@vitest/runner': 3.2.4 '@vitest/snapshot': 3.2.4 @@ -24465,12 +24465,12 @@ snapshots: tinyglobby: 0.2.15 tinypool: 1.1.1 tinyrainbow: 2.0.0 - vite: 7.1.12(@types/node@22.19.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1) - vite-node: 3.2.4(@types/node@22.19.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1) + vite: 7.1.12(@types/node@22.19.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1) + vite-node: 3.2.4(@types/node@22.19.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1) why-is-node-running: 2.3.0 optionalDependencies: '@types/debug': 4.1.12 - '@types/node': 22.19.0 + '@types/node': 22.19.1 happy-dom: 20.0.10 jsdom: 26.1.0(canvas@2.11.2(encoding@0.1.13)) transitivePeerDependencies: @@ -24487,11 +24487,11 @@ snapshots: - tsx - yaml - vitest@3.2.4(@types/debug@4.1.12)(@types/node@22.19.0)(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): + vitest@3.2.4(@types/debug@4.1.12)(@types/node@22.19.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: '@types/chai': 5.2.2 '@vitest/expect': 3.2.4 - '@vitest/mocker': 3.2.4(vite@7.1.12(@types/node@22.19.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1)) + '@vitest/mocker': 3.2.4(vite@7.1.12(@types/node@22.19.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1)) '@vitest/pretty-format': 3.2.4 '@vitest/runner': 3.2.4 '@vitest/snapshot': 3.2.4 @@ -24509,12 +24509,12 @@ snapshots: tinyglobby: 0.2.15 tinypool: 1.1.1 tinyrainbow: 2.0.0 - vite: 7.1.12(@types/node@22.19.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1) - vite-node: 3.2.4(@types/node@22.19.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1) + vite: 7.1.12(@types/node@22.19.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1) + vite-node: 3.2.4(@types/node@22.19.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1) why-is-node-running: 2.3.0 optionalDependencies: '@types/debug': 4.1.12 - '@types/node': 22.19.0 + '@types/node': 22.19.1 happy-dom: 20.0.10 jsdom: 26.1.0(canvas@2.11.2) transitivePeerDependencies: diff --git a/server/package.json b/server/package.json index 6de6531c67..d995dee90c 100644 --- a/server/package.json +++ b/server/package.json @@ -134,7 +134,7 @@ "@types/luxon": "^3.6.2", "@types/mock-fs": "^4.13.1", "@types/multer": "^2.0.0", - "@types/node": "^22.19.0", + "@types/node": "^22.19.1", "@types/nodemailer": "^7.0.0", "@types/picomatch": "^4.0.0", "@types/pngjs": "^6.0.5", From 7134dd29cac0aa2146d93e5defbe1a93457de2b5 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 18 Nov 2025 12:21:28 +0100 Subject: [PATCH 07/18] chore(deps): pin ghcr.io/jdx/mise docker tag to ac26f59 (#23961) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- server/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/Dockerfile b/server/Dockerfile index 0bb7fc6be5..c73574a05f 100644 --- a/server/Dockerfile +++ b/server/Dockerfile @@ -50,7 +50,7 @@ RUN --mount=type=cache,id=pnpm-cli,target=/buildcache/pnpm-store \ FROM builder AS plugins -COPY --from=ghcr.io/jdx/mise:2025.11.3 /usr/local/bin/mise /usr/local/bin/mise +COPY --from=ghcr.io/jdx/mise:2025.11.3@sha256:ac26f5978c0e2783f3e68e58ce75eddb83e41b89bf8747c503bac2aa9baf22c5 /usr/local/bin/mise /usr/local/bin/mise WORKDIR /usr/src/app COPY ./plugins/mise.toml ./plugins/ From c086a65fa8b1d4e4f0b6fb9456899df0067b3715 Mon Sep 17 00:00:00 2001 From: Alex Date: Tue, 18 Nov 2025 10:07:33 -0600 Subject: [PATCH 08/18] chore: update drift (#23877) * chore: update drift * update drift dep --------- Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com> --- .../domain/services/background_worker.service.dart | 14 ++++++++++++-- mobile/pubspec.lock | 9 +++++---- mobile/pubspec.yaml | 7 +++++++ 3 files changed, 24 insertions(+), 6 deletions(-) diff --git a/mobile/lib/domain/services/background_worker.service.dart b/mobile/lib/domain/services/background_worker.service.dart index 28c87293f9..8a237f801a 100644 --- a/mobile/lib/domain/services/background_worker.service.dart +++ b/mobile/lib/domain/services/background_worker.service.dart @@ -177,6 +177,12 @@ class BackgroundWorkerBgService extends BackgroundWorkerFlutterApi { } Future _cleanup() async { + await runZonedGuarded(_handleCleanup, (error, stack) { + dPrint(() => "Error during background worker cleanup: $error, $stack"); + }); + } + + Future _handleCleanup() async { // If ref is null, it means the service was never initialized properly if (_isCleanedUp || _ref == null) { return; @@ -186,11 +192,16 @@ class BackgroundWorkerBgService extends BackgroundWorkerFlutterApi { _isCleanedUp = true; final backgroundSyncManager = _ref?.read(backgroundSyncProvider); final nativeSyncApi = _ref?.read(nativeSyncApiProvider); + + await _drift.close(); + await _driftLogger.close(); + _ref?.dispose(); _ref = null; _cancellationToken.cancel(); _logger.info("Cleaning up background worker"); + final cleanupFutures = [ nativeSyncApi?.cancelHashing(), workerManagerPatch.dispose().catchError((_) async { @@ -199,8 +210,7 @@ class BackgroundWorkerBgService extends BackgroundWorkerFlutterApi { }), LogService.I.dispose(), Store.dispose(), - _drift.close(), - _driftLogger.close(), + backgroundSyncManager?.cancel(), ]; diff --git a/mobile/pubspec.lock b/mobile/pubspec.lock index 59b23f23ca..6a067f509f 100644 --- a/mobile/pubspec.lock +++ b/mobile/pubspec.lock @@ -452,10 +452,11 @@ packages: drift: dependency: "direct main" description: - name: drift - sha256: "14a61af39d4584faf1d73b5b35e4b758a43008cf4c0fdb0576ec8e7032c0d9a5" - url: "https://pub.dev" - source: hosted + path: drift + ref: "53ef7e9f19fe8f68416251760b4b99fe43f1c575" + resolved-ref: "53ef7e9f19fe8f68416251760b4b99fe43f1c575" + url: "https://github.com/immich-app/drift" + source: git version: "2.26.0" drift_dev: dependency: "direct dev" diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml index 3dce49e4e1..187fb88f17 100644 --- a/mobile/pubspec.yaml +++ b/mobile/pubspec.yaml @@ -113,6 +113,13 @@ dev_dependencies: riverpod_generator: ^2.6.1 riverpod_lint: ^2.6.1 +dependency_overrides: + drift: + git: + url: https://github.com/immich-app/drift + ref: '53ef7e9f19fe8f68416251760b4b99fe43f1c575' + path: drift/ + flutter: uses-material-design: true assets: From d310c6f3cd71544613ef360dc54cda8896b4e657 Mon Sep 17 00:00:00 2001 From: Daniel Dietzler <36593685+danieldietzler@users.noreply.github.com> Date: Tue, 18 Nov 2025 21:27:41 +0100 Subject: [PATCH 09/18] feat: library details page (#23908) * feat: library details page * chore: clean up --------- Co-authored-by: Jason Rasmussen --- i18n/en.json | 17 +- web/src/lib/assets/empty-folders.svg | 1 + .../forms/library-import-paths-form.svelte | 207 ---------- .../forms/library-scan-settings-form.svelte | 151 -------- .../empty-placeholder.svelte | 5 +- web/src/lib/managers/event-manager.svelte.ts | 5 + .../LibraryExclusionPatternAddModal.svelte | 43 +++ .../LibraryExclusionPatternEditModal.svelte | 45 +++ .../LibraryExclusionPatternModal.svelte | 78 ---- .../lib/modals/LibraryFolderAddModal.svelte | 44 +++ .../lib/modals/LibraryFolderEditModal.svelte | 45 +++ .../lib/modals/LibraryImportPathModal.svelte | 75 ---- web/src/lib/modals/LibraryRenameModal.svelte | 18 +- web/src/lib/services/library.service.ts | 352 ++++++++++++++++++ web/src/routes/(user)/albums/+page.svelte | 2 +- .../[[assetId=id]]/+page.svelte | 2 +- web/src/routes/(user)/explore/+page.svelte | 2 +- .../[[assetId=id]]/+page.svelte | 2 +- .../[[assetId=id]]/+page.svelte | 2 +- .../(user)/photos/[[assetId=id]]/+page.svelte | 2 +- web/src/routes/(user)/sharing/+page.svelte | 2 +- .../[[assetId=id]]/+page.svelte | 2 +- .../(user)/utilities/geolocation/+page.svelte | 2 +- .../admin/library-management/+page.svelte | 343 +++-------------- .../routes/admin/library-management/+page.ts | 13 +- .../library-management/[id]/+page.svelte | 131 +++++++ .../admin/library-management/[id]/+page.ts | 28 ++ web/src/routes/admin/users/+page.svelte | 56 ++- web/src/routes/admin/users/[id]/+page.svelte | 2 +- 29 files changed, 814 insertions(+), 863 deletions(-) create mode 100644 web/src/lib/assets/empty-folders.svg delete mode 100644 web/src/lib/components/forms/library-import-paths-form.svelte delete mode 100644 web/src/lib/components/forms/library-scan-settings-form.svelte create mode 100644 web/src/lib/modals/LibraryExclusionPatternAddModal.svelte create mode 100644 web/src/lib/modals/LibraryExclusionPatternEditModal.svelte delete mode 100644 web/src/lib/modals/LibraryExclusionPatternModal.svelte create mode 100644 web/src/lib/modals/LibraryFolderAddModal.svelte create mode 100644 web/src/lib/modals/LibraryFolderEditModal.svelte delete mode 100644 web/src/lib/modals/LibraryImportPathModal.svelte create mode 100644 web/src/lib/services/library.service.ts create mode 100644 web/src/routes/admin/library-management/[id]/+page.svelte create mode 100644 web/src/routes/admin/library-management/[id]/+page.ts diff --git a/i18n/en.json b/i18n/en.json index 5edbd87973..8c4ee068fd 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -17,7 +17,6 @@ "add_birthday": "Add a birthday", "add_endpoint": "Add endpoint", "add_exclusion_pattern": "Add exclusion pattern", - "add_import_path": "Add import path", "add_location": "Add location", "add_more_users": "Add more users", "add_partner": "Add partner", @@ -113,13 +112,17 @@ "jobs_failed": "{jobCount, plural, other {# failed}}", "library_created": "Created library: {library}", "library_deleted": "Library deleted", - "library_import_path_description": "Specify a folder to import. This folder, including subfolders, will be scanned for images and videos.", + "library_details": "Library details", + "library_folder_description": "Specify a folder to import. This folder, including subfolders, will be scanned for images and videos.", + "library_remove_exclusion_pattern_prompt": "Are you sure you want to remove this exclusion pattern?", + "library_remove_folder_prompt": "Are you sure you want to remove this import folder?", "library_scanning": "Periodic Scanning", "library_scanning_description": "Configure periodic library scanning", "library_scanning_enable_description": "Enable periodic library scanning", "library_settings": "External Library", "library_settings_description": "Manage external library settings", "library_tasks_description": "Scan external libraries for new and/or changed assets", + "library_updated": "Updated library", "library_watching_enable_description": "Watch external libraries for file changes", "library_watching_settings": "Library watching [EXPERIMENTAL]", "library_watching_settings_description": "Automatically watch for changed files", @@ -901,8 +904,6 @@ "edit_description_prompt": "Please select a new description:", "edit_exclusion_pattern": "Edit exclusion pattern", "edit_faces": "Edit faces", - "edit_import_path": "Edit import path", - "edit_import_paths": "Edit Import Paths", "edit_key": "Edit key", "edit_link": "Edit link", "edit_location": "Edit location", @@ -974,8 +975,8 @@ "failed_to_stack_assets": "Failed to stack assets", "failed_to_unstack_assets": "Failed to un-stack assets", "failed_to_update_notification_status": "Failed to update notification status", - "import_path_already_exists": "This import path already exists.", "incorrect_email_or_password": "Incorrect email or password", + "library_folder_already_exists": "This import path already exists.", "paths_validation_failed": "{paths, plural, one {# path} other {# paths}} failed validation", "profile_picture_transparent_pixels": "Profile pictures cannot have transparent pixels. Please zoom in and/or move the image.", "quota_higher_than_disk_size": "You set a quota higher than the disk size", @@ -984,7 +985,6 @@ "unable_to_add_assets_to_shared_link": "Unable to add assets to shared link", "unable_to_add_comment": "Unable to add comment", "unable_to_add_exclusion_pattern": "Unable to add exclusion pattern", - "unable_to_add_import_path": "Unable to add import path", "unable_to_add_partners": "Unable to add partners", "unable_to_add_remove_archive": "Unable to {archived, select, true {remove asset from} other {add asset to}} archive", "unable_to_add_remove_favorites": "Unable to {favorite, select, true {add asset to} other {remove asset from}} favorites", @@ -1007,12 +1007,10 @@ "unable_to_delete_asset": "Unable to delete asset", "unable_to_delete_assets": "Error deleting assets", "unable_to_delete_exclusion_pattern": "Unable to delete exclusion pattern", - "unable_to_delete_import_path": "Unable to delete import path", "unable_to_delete_shared_link": "Unable to delete shared link", "unable_to_delete_user": "Unable to delete user", "unable_to_download_files": "Unable to download files", "unable_to_edit_exclusion_pattern": "Unable to edit exclusion pattern", - "unable_to_edit_import_path": "Unable to edit import path", "unable_to_empty_trash": "Unable to empty trash", "unable_to_enter_fullscreen": "Unable to enter fullscreen", "unable_to_exit_fullscreen": "Unable to exit fullscreen", @@ -1063,6 +1061,7 @@ "unable_to_update_user": "Unable to update user", "unable_to_upload_file": "Unable to upload file" }, + "exclusion_pattern": "Exclusion pattern", "exif": "Exif", "exif_bottom_sheet_description": "Add Description...", "exif_bottom_sheet_description_error": "Error updating description", @@ -1251,6 +1250,8 @@ "let_others_respond": "Let others respond", "level": "Level", "library": "Library", + "library_add_folder": "Add folder", + "library_edit_folder": "Edit folder", "library_options": "Library options", "library_page_device_albums": "Albums on Device", "library_page_new_album": "New album", diff --git a/web/src/lib/assets/empty-folders.svg b/web/src/lib/assets/empty-folders.svg new file mode 100644 index 0000000000..b4a58cf245 --- /dev/null +++ b/web/src/lib/assets/empty-folders.svg @@ -0,0 +1 @@ + diff --git a/web/src/lib/components/forms/library-import-paths-form.svelte b/web/src/lib/components/forms/library-import-paths-form.svelte deleted file mode 100644 index 02f82504a7..0000000000 --- a/web/src/lib/components/forms/library-import-paths-form.svelte +++ /dev/null @@ -1,207 +0,0 @@ - - -
- - - {#each validatedPaths as validatedPath, listIndex (validatedPath.importPath)} - - - - - - - {/each} - - - - - -
- {#if validatedPath.isValid} - - {:else} - - {/if} - {validatedPath.importPath} - onEditImportPath(listIndex)} - size="small" - /> -
- {#if importPaths.length === 0} - {$t('admin.no_paths_added')} - {/if} - -
-
-
- -
-
- - -
-
-
diff --git a/web/src/lib/components/forms/library-scan-settings-form.svelte b/web/src/lib/components/forms/library-scan-settings-form.svelte deleted file mode 100644 index fde3849599..0000000000 --- a/web/src/lib/components/forms/library-scan-settings-form.svelte +++ /dev/null @@ -1,151 +0,0 @@ - - -
- - - {#each exclusionPatterns as exclusionPattern, listIndex (exclusionPattern)} - - - - - {/each} - - - - - -
{exclusionPattern} - onEditExclusionPattern(listIndex)} - aria-label={$t('edit_exclusion_pattern')} - size="small" - /> -
- {#if exclusionPatterns.length === 0} - {$t('admin.no_pattern_added')} - {/if} - - -
- -
- - -
-
diff --git a/web/src/lib/components/shared-components/empty-placeholder.svelte b/web/src/lib/components/shared-components/empty-placeholder.svelte index ae7f9aab6a..78c675c93b 100644 --- a/web/src/lib/components/shared-components/empty-placeholder.svelte +++ b/web/src/lib/components/shared-components/empty-placeholder.svelte @@ -7,9 +7,10 @@ fullWidth?: boolean; src?: string; title?: string; + class?: string; } - let { onClick = undefined, text, fullWidth = false, src = empty1Url, title }: Props = $props(); + let { onClick = undefined, text, fullWidth = false, src = empty1Url, title, class: className }: Props = $props(); let width = $derived(fullWidth ? 'w-full' : 'w-1/2'); @@ -22,7 +23,7 @@ diff --git a/web/src/lib/managers/event-manager.svelte.ts b/web/src/lib/managers/event-manager.svelte.ts index d4f90fc3ad..62fc9df8da 100644 --- a/web/src/lib/managers/event-manager.svelte.ts +++ b/web/src/lib/managers/event-manager.svelte.ts @@ -1,6 +1,7 @@ import type { ThemeSetting } from '$lib/managers/theme-manager.svelte'; import type { AlbumResponseDto, + LibraryResponseDto, LoginResponseDto, SharedLinkResponseDto, SystemConfigDto, @@ -27,6 +28,10 @@ export type Events = { UserAdminRestore: [UserAdminResponseDto]; SystemConfigUpdate: [SystemConfigDto]; + + LibraryCreate: [LibraryResponseDto]; + LibraryUpdate: [LibraryResponseDto]; + LibraryDelete: [{ id: string }]; }; type Listener, K extends keyof EventMap> = (...params: EventMap[K]) => void; diff --git a/web/src/lib/modals/LibraryExclusionPatternAddModal.svelte b/web/src/lib/modals/LibraryExclusionPatternAddModal.svelte new file mode 100644 index 0000000000..12c13f9a06 --- /dev/null +++ b/web/src/lib/modals/LibraryExclusionPatternAddModal.svelte @@ -0,0 +1,43 @@ + + + + +
+ {$t('admin.exclusion_pattern_description')} + + + + +
+
+ + + + + + + +
diff --git a/web/src/lib/modals/LibraryExclusionPatternEditModal.svelte b/web/src/lib/modals/LibraryExclusionPatternEditModal.svelte new file mode 100644 index 0000000000..56207c8cf4 --- /dev/null +++ b/web/src/lib/modals/LibraryExclusionPatternEditModal.svelte @@ -0,0 +1,45 @@ + + + + +
+ {$t('admin.exclusion_pattern_description')} + + + + +
+
+ + + + + + + +
diff --git a/web/src/lib/modals/LibraryExclusionPatternModal.svelte b/web/src/lib/modals/LibraryExclusionPatternModal.svelte deleted file mode 100644 index fe5da01c45..0000000000 --- a/web/src/lib/modals/LibraryExclusionPatternModal.svelte +++ /dev/null @@ -1,78 +0,0 @@ - - - - -
-

- {$t('admin.exclusion_pattern_description')} -

- {$t('admin.add_exclusion_pattern_description')} -

-
- - -
-
- {#if isDuplicate} -

{$t('errors.exclusion_pattern_already_exists')}

- {/if} -
-
-
- - - - {#if isEditing} - - {/if} - - - -
diff --git a/web/src/lib/modals/LibraryFolderAddModal.svelte b/web/src/lib/modals/LibraryFolderAddModal.svelte new file mode 100644 index 0000000000..67ae5bf773 --- /dev/null +++ b/web/src/lib/modals/LibraryFolderAddModal.svelte @@ -0,0 +1,44 @@ + + + + +
+ {$t('admin.library_folder_description')} + + + + +
+
+ + + + + + + +
diff --git a/web/src/lib/modals/LibraryFolderEditModal.svelte b/web/src/lib/modals/LibraryFolderEditModal.svelte new file mode 100644 index 0000000000..c1ab657275 --- /dev/null +++ b/web/src/lib/modals/LibraryFolderEditModal.svelte @@ -0,0 +1,45 @@ + + + + +
+ {$t('admin.library_folder_description')} + + + + +
+
+ + + + + + + +
diff --git a/web/src/lib/modals/LibraryImportPathModal.svelte b/web/src/lib/modals/LibraryImportPathModal.svelte deleted file mode 100644 index 5c1454fdd9..0000000000 --- a/web/src/lib/modals/LibraryImportPathModal.svelte +++ /dev/null @@ -1,75 +0,0 @@ - - - - -
-

{$t('admin.library_import_path_description')}

- -
- - -
- -
- {#if isDuplicate} -

{$t('errors.import_path_already_exists')}

- {/if} -
-
-
- - - - - {#if isEditing} - - {/if} - - - -
diff --git a/web/src/lib/modals/LibraryRenameModal.svelte b/web/src/lib/modals/LibraryRenameModal.svelte index af204cdf0e..0a7d675b11 100644 --- a/web/src/lib/modals/LibraryRenameModal.svelte +++ b/web/src/lib/modals/LibraryRenameModal.svelte @@ -1,21 +1,25 @@ diff --git a/web/src/lib/services/library.service.ts b/web/src/lib/services/library.service.ts new file mode 100644 index 0000000000..415d6dae42 --- /dev/null +++ b/web/src/lib/services/library.service.ts @@ -0,0 +1,352 @@ +import { goto } from '$app/navigation'; +import { AppRoute } from '$lib/constants'; +import { eventManager } from '$lib/managers/event-manager.svelte'; +import LibraryExclusionPatternAddModal from '$lib/modals/LibraryExclusionPatternAddModal.svelte'; +import LibraryExclusionPatternEditModal from '$lib/modals/LibraryExclusionPatternEditModal.svelte'; +import LibraryFolderAddModal from '$lib/modals/LibraryFolderAddModal.svelte'; +import LibraryFolderEditModal from '$lib/modals/LibraryFolderEditModal.svelte'; +import LibraryRenameModal from '$lib/modals/LibraryRenameModal.svelte'; +import LibraryUserPickerModal from '$lib/modals/LibraryUserPickerModal.svelte'; +import type { ActionItem } from '$lib/types'; +import { handleError } from '$lib/utils/handle-error'; +import { getFormatter } from '$lib/utils/i18n'; +import { + createLibrary, + deleteLibrary, + QueueCommand, + QueueName, + runQueueCommandLegacy, + scanLibrary, + updateLibrary, + type LibraryResponseDto, +} from '@immich/sdk'; +import { modalManager, toastManager } from '@immich/ui'; +import { mdiPencilOutline, mdiPlusBoxOutline, mdiSync, mdiTrashCanOutline } from '@mdi/js'; +import type { MessageFormatter } from 'svelte-i18n'; + +export const getLibrariesActions = ($t: MessageFormatter) => { + const ScanAll: ActionItem = { + title: $t('scan_all_libraries'), + icon: mdiSync, + onSelect: () => void handleScanAllLibraries(), + }; + + const Create: ActionItem = { + title: $t('create_library'), + icon: mdiPlusBoxOutline, + onSelect: () => void handleCreateLibrary(), + }; + + return { ScanAll, Create }; +}; + +export const getLibraryActions = ($t: MessageFormatter, library: LibraryResponseDto) => { + const Rename: ActionItem = { + icon: mdiPencilOutline, + title: $t('rename'), + onSelect: () => void modalManager.show(LibraryRenameModal, { library }), + }; + + const Delete: ActionItem = { + icon: mdiTrashCanOutline, + title: $t('delete'), + color: 'danger', + onSelect: () => void handleDeleteLibrary(library), + }; + + const AddFolder: ActionItem = { + icon: mdiPlusBoxOutline, + title: $t('add'), + onSelect: () => void modalManager.show(LibraryFolderAddModal, { library }), + }; + + const AddExclusionPattern: ActionItem = { + icon: mdiPlusBoxOutline, + title: $t('add'), + onSelect: () => void modalManager.show(LibraryExclusionPatternAddModal, { library }), + }; + + const Scan: ActionItem = { + icon: mdiSync, + title: $t('scan_library'), + onSelect: () => void handleScanLibrary(library), + }; + + return { Rename, Delete, AddFolder, AddExclusionPattern, Scan }; +}; + +export const getLibraryFolderActions = ($t: MessageFormatter, library: LibraryResponseDto, folder: string) => { + const Edit: ActionItem = { + icon: mdiPencilOutline, + title: $t('edit'), + onSelect: () => void modalManager.show(LibraryFolderEditModal, { folder, library }), + }; + + const Delete: ActionItem = { + icon: mdiTrashCanOutline, + title: $t('delete'), + onSelect: () => void handleDeleteLibraryFolder(library, folder), + }; + + return { Edit, Delete }; +}; + +export const getLibraryExclusionPatternActions = ( + $t: MessageFormatter, + library: LibraryResponseDto, + exclusionPattern: string, +) => { + const Edit: ActionItem = { + icon: mdiPencilOutline, + title: $t('edit'), + onSelect: () => void modalManager.show(LibraryExclusionPatternEditModal, { exclusionPattern, library }), + }; + + const Delete: ActionItem = { + icon: mdiTrashCanOutline, + title: $t('delete'), + onSelect: () => void handleDeleteExclusionPattern(library, exclusionPattern), + }; + + return { Edit, Delete }; +}; + +const handleScanAllLibraries = async () => { + const $t = await getFormatter(); + + try { + await runQueueCommandLegacy({ name: QueueName.Library, queueCommandDto: { command: QueueCommand.Start } }); + toastManager.info($t('admin.refreshing_all_libraries')); + } catch (error) { + handleError(error, $t('errors.unable_to_scan_libraries')); + } +}; + +const handleScanLibrary = async (library: LibraryResponseDto) => { + const $t = await getFormatter(); + try { + await scanLibrary({ id: library.id }); + toastManager.info($t('admin.scanning_library')); + } catch (error) { + handleError(error, $t('errors.unable_to_scan_library')); + } +}; + +export const handleViewLibrary = async (library: LibraryResponseDto) => { + await goto(`${AppRoute.ADMIN_LIBRARY_MANAGEMENT}/${library.id}`); +}; + +export const handleCreateLibrary = async () => { + const $t = await getFormatter(); + + const ownerId = await modalManager.show(LibraryUserPickerModal, {}); + if (!ownerId) { + return; + } + + try { + const createdLibrary = await createLibrary({ createLibraryDto: { ownerId } }); + eventManager.emit('LibraryCreate', createdLibrary); + toastManager.success($t('admin.library_created', { values: { library: createdLibrary.name } })); + } catch (error) { + handleError(error, $t('errors.unable_to_create_library')); + } +}; + +export const handleRenameLibrary = async (library: { id: string }, name?: string) => { + const $t = await getFormatter(); + + if (!name) { + return false; + } + + try { + const updatedLibrary = await updateLibrary({ + id: library.id, + updateLibraryDto: { name }, + }); + eventManager.emit('LibraryUpdate', updatedLibrary); + toastManager.success($t('admin.library_updated')); + } catch (error) { + handleError(error, $t('errors.unable_to_update_library')); + return false; + } + + return true; +}; + +const handleDeleteLibrary = async (library: LibraryResponseDto) => { + const $t = await getFormatter(); + + const confirmed = await modalManager.showDialog({ + prompt: $t('admin.confirm_delete_library', { values: { library: library.name } }), + }); + + if (!confirmed) { + return; + } + + if (library.assetCount > 0) { + const isConfirmed = await modalManager.showDialog({ + prompt: $t('admin.confirm_delete_library_assets', { values: { count: library.assetCount } }), + }); + if (!isConfirmed) { + return; + } + } + + try { + await deleteLibrary({ id: library.id }); + eventManager.emit('LibraryDelete', { id: library.id }); + toastManager.success($t('admin.library_deleted')); + } catch (error) { + handleError(error, $t('errors.unable_to_remove_library')); + } +}; + +export const handleAddLibraryFolder = async (library: LibraryResponseDto, folder: string) => { + const $t = await getFormatter(); + + if (library.importPaths.includes(folder)) { + toastManager.danger($t('errors.library_folder_already_exists')); + return false; + } + + try { + const updatedLibrary = await updateLibrary({ + id: library.id, + updateLibraryDto: { importPaths: [...library.importPaths, folder] }, + }); + eventManager.emit('LibraryUpdate', updatedLibrary); + toastManager.success($t('admin.library_updated')); + } catch (error) { + handleError(error, $t('errors.unable_to_update_library')); + return false; + } + + return true; +}; + +export const handleEditLibraryFolder = async (library: LibraryResponseDto, oldFolder: string, newFolder: string) => { + const $t = await getFormatter(); + + if (oldFolder === newFolder) { + return true; + } + + const importPaths = library.importPaths.map((path) => (path === oldFolder ? newFolder : path)); + + try { + const updatedLibrary = await updateLibrary({ id: library.id, updateLibraryDto: { importPaths } }); + eventManager.emit('LibraryUpdate', updatedLibrary); + toastManager.success($t('admin.library_updated')); + } catch (error) { + handleError(error, $t('errors.unable_to_update_library')); + return false; + } + + return true; +}; + +const handleDeleteLibraryFolder = async (library: LibraryResponseDto, folder: string) => { + const $t = await getFormatter(); + + const confirmed = await modalManager.showDialog({ + prompt: $t('admin.library_remove_folder_prompt'), + confirmText: $t('remove'), + }); + + if (!confirmed) { + return false; + } + + try { + const updatedLibrary = await updateLibrary({ + id: library.id, + updateLibraryDto: { importPaths: library.importPaths.filter((path) => path !== folder) }, + }); + eventManager.emit('LibraryUpdate', updatedLibrary); + toastManager.success($t('admin.library_updated')); + } catch (error) { + handleError(error, $t('errors.unable_to_update_library')); + return false; + } + + return true; +}; + +export const handleAddLibraryExclusionPattern = async (library: LibraryResponseDto, exclusionPattern: string) => { + const $t = await getFormatter(); + + if (library.exclusionPatterns.includes(exclusionPattern)) { + toastManager.danger($t('errors.exclusion_pattern_already_exists')); + return false; + } + + try { + const updatedLibrary = await updateLibrary({ + id: library.id, + updateLibraryDto: { exclusionPatterns: [...library.exclusionPatterns, exclusionPattern] }, + }); + eventManager.emit('LibraryUpdate', updatedLibrary); + toastManager.success($t('admin.library_updated')); + } catch (error) { + handleError(error, $t('errors.unable_to_update_library')); + return false; + } + + return true; +}; + +export const handleEditExclusionPattern = async ( + library: LibraryResponseDto, + oldExclusionPattern: string, + newExclusionPattern: string, +) => { + const $t = await getFormatter(); + + if (oldExclusionPattern === newExclusionPattern) { + return true; + } + + const exclusionPatterns = library.exclusionPatterns.map((pattern) => + pattern === oldExclusionPattern ? newExclusionPattern : pattern, + ); + + try { + const updatedLibrary = await updateLibrary({ id: library.id, updateLibraryDto: { exclusionPatterns } }); + eventManager.emit('LibraryUpdate', updatedLibrary); + toastManager.success($t('admin.library_updated')); + } catch (error) { + handleError(error, $t('errors.unable_to_update_library')); + return false; + } + + return true; +}; + +const handleDeleteExclusionPattern = async (library: LibraryResponseDto, exclusionPattern: string) => { + const $t = await getFormatter(); + + const confirmed = await modalManager.showDialog({ prompt: $t('admin.library_remove_exclusion_pattern_prompt') }); + + if (!confirmed) { + return false; + } + + try { + const updatedLibrary = await updateLibrary({ + id: library.id, + updateLibraryDto: { + exclusionPatterns: library.exclusionPatterns.filter((pattern) => pattern !== exclusionPattern), + }, + }); + eventManager.emit('LibraryUpdate', updatedLibrary); + toastManager.success($t('admin.library_updated')); + } catch (error) { + handleError(error, $t('errors.unable_to_update_library')); + return false; + } + + return true; +}; diff --git a/web/src/routes/(user)/albums/+page.svelte b/web/src/routes/(user)/albums/+page.svelte index cdd13ba938..88bf67ca19 100644 --- a/web/src/routes/(user)/albums/+page.svelte +++ b/web/src/routes/(user)/albums/+page.svelte @@ -52,7 +52,7 @@ bind:albumGroupIds={albumGroups} > {#snippet empty()} - createAlbumAndRedirect()} /> + createAlbumAndRedirect()} class="mt-10 mx-auto" /> {/snippet} 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 165ef59344..ac1ffc356c 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 @@ -54,7 +54,7 @@ onEscape={handleEscape} > {#snippet empty()} - + {/snippet} diff --git a/web/src/routes/(user)/explore/+page.svelte b/web/src/routes/(user)/explore/+page.svelte index 86fb0850af..89505249b4 100644 --- a/web/src/routes/(user)/explore/+page.svelte +++ b/web/src/routes/(user)/explore/+page.svelte @@ -114,6 +114,6 @@ {/if} {#if !hasPeople && places.length === 0} - + {/if} 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 676a68e673..4eebc59146 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 @@ -59,7 +59,7 @@ onEscape={handleEscape} > {#snippet empty()} - + {/snippet} diff --git a/web/src/routes/(user)/locked/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/locked/[[photos=photos]]/[[assetId=id]]/+page.svelte index 510b609784..b16018bbf4 100644 --- a/web/src/routes/(user)/locked/[[photos=photos]]/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/locked/[[photos=photos]]/[[assetId=id]]/+page.svelte @@ -65,7 +65,7 @@ removeAction={AssetAction.SET_VISIBILITY_TIMELINE} > {#snippet empty()} - + {/snippet} diff --git a/web/src/routes/(user)/photos/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/photos/[[assetId=id]]/+page.svelte index 748e0b7100..fde2aeda28 100644 --- a/web/src/routes/(user)/photos/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/photos/[[assetId=id]]/+page.svelte @@ -101,7 +101,7 @@ {/if} {#snippet empty()} - openFileUploadDialog()} /> + openFileUploadDialog()} class="mt-10 mx-auto" /> {/snippet} diff --git a/web/src/routes/(user)/sharing/+page.svelte b/web/src/routes/(user)/sharing/+page.svelte index a55452b5d1..6fcca2a8f4 100644 --- a/web/src/routes/(user)/sharing/+page.svelte +++ b/web/src/routes/(user)/sharing/+page.svelte @@ -94,7 +94,7 @@ {#snippet empty()} - + {/snippet} diff --git a/web/src/routes/(user)/trash/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/trash/[[photos=photos]]/[[assetId=id]]/+page.svelte index 99aad49285..b9019d3274 100644 --- a/web/src/routes/(user)/trash/[[photos=photos]]/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/trash/[[photos=photos]]/[[assetId=id]]/+page.svelte @@ -104,7 +104,7 @@ })}

{#snippet empty()} - + {/snippet} diff --git a/web/src/routes/(user)/utilities/geolocation/+page.svelte b/web/src/routes/(user)/utilities/geolocation/+page.svelte index 732e0625ab..a90c0b5632 100644 --- a/web/src/routes/(user)/utilities/geolocation/+page.svelte +++ b/web/src/routes/(user)/utilities/geolocation/+page.svelte @@ -208,7 +208,7 @@ {/if} {/snippet} {#snippet empty()} - {}} /> + {}} class="mt-10 mx-auto" /> {/snippet} diff --git a/web/src/routes/admin/library-management/+page.svelte b/web/src/routes/admin/library-management/+page.svelte index 600b6ff048..37153d5003 100644 --- a/web/src/routes/admin/library-management/+page.svelte +++ b/web/src/routes/admin/library-management/+page.svelte @@ -1,36 +1,17 @@ + + {#snippet buttons()}
{#if libraries.length > 0} - + {/if} - +
{/snippet}
-
+
{#if libraries.length > 0} - +
@@ -276,91 +84,36 @@ - {#each libraries as library, index (library.id)} + {#each libraries as library (library.id + library.name)} + {@const { photos, usage, videos } = statistics[library.id]} + {@const [diskUsage, diskUsageUnit] = getBytesWithUnit(usage, 0)} - - {#if editImportPaths === index} - -
- handleUpdate(lib, index)} - onCancel={() => (editImportPaths = undefined)} - /> -
- {/if} - {#if editScanSettings === index} - -
- handleUpdate(lib, index)} - onCancel={() => (editScanSettings = undefined)} - /> -
- {/if} {/each}
{library.name} - {#if owner[index] == undefined} - - {:else}{owner[index].name}{/if} + {owners[library.id].name} - {#if photos[index] == undefined} - - {:else} - {photos[index].toLocaleString($locale)} - {/if} + {photos.toLocaleString($locale)} - {#if videos[index] == undefined} - - {:else} - {videos[index].toLocaleString($locale)} - {/if} + {videos.toLocaleString($locale)} - {#if diskUsage[index] == undefined} - - {:else} - {diskUsage[index]} - {diskUsageUnit[index]} - {/if} + {diskUsage} + {diskUsageUnit} - - onScanClicked(library)} text={$t('scan_library')} /> -
- onRenameClicked(index)} text={$t('rename')} /> - onEditImportPathClicked(index)} text={$t('edit_import_paths')} /> - onScanSettingClicked(index)} text={$t('scan_settings')} /> -
- handleDelete(library, index)} - activeColor="bg-red-200" - textColor="text-red-600" - text={$t('delete_library')} - /> -
+
+
- - {:else} - + {/if}
diff --git a/web/src/routes/admin/library-management/+page.ts b/web/src/routes/admin/library-management/+page.ts index 735c7fac92..cb7190b0e4 100644 --- a/web/src/routes/admin/library-management/+page.ts +++ b/web/src/routes/admin/library-management/+page.ts @@ -1,6 +1,6 @@ import { authenticate, requestServerInfo } from '$lib/utils/auth'; import { getFormatter } from '$lib/utils/i18n'; -import { searchUsersAdmin } from '@immich/sdk'; +import { getAllLibraries, getLibraryStatistics, getUserAdmin, searchUsersAdmin } from '@immich/sdk'; import type { PageLoad } from './$types'; export const load = (async ({ url }) => { @@ -9,8 +9,19 @@ export const load = (async ({ url }) => { const allUsers = await searchUsersAdmin({ withDeleted: false }); const $t = await getFormatter(); + const libraries = await getAllLibraries(); + const statistics = await Promise.all( + libraries.map(async ({ id }) => [id, await getLibraryStatistics({ id })] as const), + ); + const owners = await Promise.all( + libraries.map(async ({ id, ownerId }) => [id, await getUserAdmin({ id: ownerId })] as const), + ); + return { allUsers, + libraries, + statistics: Object.fromEntries(statistics), + owners: Object.fromEntries(owners), meta: { title: $t('admin.external_library_management'), }, diff --git a/web/src/routes/admin/library-management/[id]/+page.svelte b/web/src/routes/admin/library-management/[id]/+page.svelte new file mode 100644 index 0000000000..c6fffbbd95 --- /dev/null +++ b/web/src/routes/admin/library-management/[id]/+page.svelte @@ -0,0 +1,131 @@ + + + (library = newLibrary)} + onLibraryDelete={({ id }) => id === library.id && goto(AppRoute.ADMIN_LIBRARY_MANAGEMENT)} +/> + + + {#snippet buttons()} +
+ + + +
+ {/snippet} + +
+ {library.name} +
+ + + +
+ + +
+
+ + {$t('folders')} +
+ +
+
+ +
+ {#if library.importPaths.length === 0} + modalManager.show(LibraryFolderAddModal, { library })} + /> + {:else} + + + {#each library.importPaths as folder (folder)} + {@const { Edit, Delete } = getLibraryFolderActions($t, library, folder)} + + + + + {/each} + +
+ {folder} + + + +
+ {/if} +
+
+
+ + +
+
+ + {$t('exclusion_pattern')} +
+ +
+
+ +
+ + + {#each library.exclusionPatterns as exclusionPattern (exclusionPattern)} + {@const { Edit, Delete } = getLibraryExclusionPatternActions($t, library, exclusionPattern)} + + + + + {/each} + +
+ {exclusionPattern} + + + +
+
+
+
+
+
+
diff --git a/web/src/routes/admin/library-management/[id]/+page.ts b/web/src/routes/admin/library-management/[id]/+page.ts new file mode 100644 index 0000000000..77ce1eb1c8 --- /dev/null +++ b/web/src/routes/admin/library-management/[id]/+page.ts @@ -0,0 +1,28 @@ +import { AppRoute } from '$lib/constants'; +import { authenticate } from '$lib/utils/auth'; +import { getFormatter } from '$lib/utils/i18n'; +import { getLibrary, getLibraryStatistics, type LibraryResponseDto } from '@immich/sdk'; +import { redirect } from '@sveltejs/kit'; +import type { PageLoad } from './$types'; + +export const load = (async ({ params: { id }, url }) => { + await authenticate(url, { admin: true }); + let library: LibraryResponseDto; + + try { + library = await getLibrary({ id }); + } catch { + redirect(302, AppRoute.ADMIN_LIBRARY_MANAGEMENT); + } + + const statistics = await getLibraryStatistics({ id }); + const $t = await getFormatter(); + + return { + library, + statistics, + meta: { + title: $t('admin.library_details'), + }, + }; +}) satisfies PageLoad; diff --git a/web/src/routes/admin/users/+page.svelte b/web/src/routes/admin/users/+page.svelte index c4c1012774..d5a1fe0089 100644 --- a/web/src/routes/admin/users/+page.svelte +++ b/web/src/routes/admin/users/+page.svelte @@ -77,36 +77,34 @@ - {#if allUsers} - {#each allUsers as user (user.id)} - {@const UserAdminActions = getUserAdminActions($t, user)} - + + {user.email} + + {user.name} + +
+ {#if user.quotaSizeInBytes !== null && user.quotaSizeInBytes >= 0} + {getByteUnitString(user.quotaSizeInBytes, $locale)} + {:else} + + {/if} +
+ + - - {user.email} - - {user.name} - -
- {#if user.quotaSizeInBytes !== null && user.quotaSizeInBytes >= 0} - {getByteUnitString(user.quotaSizeInBytes, $locale)} - {:else} - - {/if} -
- - - - - - - {/each} - {/if} + + + + + {/each} diff --git a/web/src/routes/admin/users/[id]/+page.svelte b/web/src/routes/admin/users/[id]/+page.svelte index 49cfb4715a..a8b2264b6b 100644 --- a/web/src/routes/admin/users/[id]/+page.svelte +++ b/web/src/routes/admin/users/[id]/+page.svelte @@ -102,7 +102,7 @@ {/if} -
+
{user.name} From 38d4d1a5736239a7f2b75ffbc196fa5709354801 Mon Sep 17 00:00:00 2001 From: shenlong <139912620+shenlong-tanwen@users.noreply.github.com> Date: Wed, 19 Nov 2025 08:25:01 +0530 Subject: [PATCH 10/18] chore: reset remote sync on app update (#23969) reset remote sync on update Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com> --- mobile/lib/domain/models/exif.model.dart | 10 ---------- mobile/lib/domain/services/asset.service.dart | 4 ++-- mobile/lib/infrastructure/entities/exif.entity.dart | 2 -- .../repositories/sync_stream.repository.dart | 12 ++++++++++-- .../pages/drift_asset_troubleshoot.page.dart | 2 -- .../widgets/asset_viewer/bottom_sheet.widget.dart | 4 ++-- mobile/lib/utils/migration.dart | 10 ++++++++-- 7 files changed, 22 insertions(+), 22 deletions(-) diff --git a/mobile/lib/domain/models/exif.model.dart b/mobile/lib/domain/models/exif.model.dart index 6e94c44650..84456b6dcc 100644 --- a/mobile/lib/domain/models/exif.model.dart +++ b/mobile/lib/domain/models/exif.model.dart @@ -3,8 +3,6 @@ class ExifInfo { final int? fileSize; final String? description; final bool isFlipped; - final double? width; - final double? height; final String? orientation; final String? timeZone; final DateTime? dateTimeOriginal; @@ -46,8 +44,6 @@ class ExifInfo { this.fileSize, this.description, this.orientation, - this.width, - this.height, this.timeZone, this.dateTimeOriginal, this.isFlipped = false, @@ -72,8 +68,6 @@ class ExifInfo { return other.fileSize == fileSize && other.description == description && other.isFlipped == isFlipped && - other.width == width && - other.height == height && other.orientation == orientation && other.timeZone == timeZone && other.dateTimeOriginal == dateTimeOriginal && @@ -98,8 +92,6 @@ class ExifInfo { description.hashCode ^ orientation.hashCode ^ isFlipped.hashCode ^ - width.hashCode ^ - height.hashCode ^ timeZone.hashCode ^ dateTimeOriginal.hashCode ^ latitude.hashCode ^ @@ -123,8 +115,6 @@ class ExifInfo { fileSize: ${fileSize ?? 'NA'}, description: ${description ?? 'NA'}, orientation: ${orientation ?? 'NA'}, -width: ${width ?? 'NA'}, -height: ${height ?? 'NA'}, isFlipped: $isFlipped, timeZone: ${timeZone ?? 'NA'}, dateTimeOriginal: ${dateTimeOriginal ?? 'NA'}, diff --git a/mobile/lib/domain/services/asset.service.dart b/mobile/lib/domain/services/asset.service.dart index 7f8ade313c..33661105e4 100644 --- a/mobile/lib/domain/services/asset.service.dart +++ b/mobile/lib/domain/services/asset.service.dart @@ -65,8 +65,8 @@ class AssetService { if (asset.hasRemote) { final exif = await getExif(asset); isFlipped = ExifDtoConverter.isOrientationFlipped(exif?.orientation); - width = exif?.width ?? asset.width?.toDouble(); - height = exif?.height ?? asset.height?.toDouble(); + width = asset.width?.toDouble(); + height = asset.height?.toDouble(); } else if (asset is LocalAsset) { isFlipped = CurrentPlatform.isAndroid && (asset.orientation == 90 || asset.orientation == 270); width = asset.width?.toDouble(); diff --git a/mobile/lib/infrastructure/entities/exif.entity.dart b/mobile/lib/infrastructure/entities/exif.entity.dart index 9c7f9e9975..f858e8b463 100644 --- a/mobile/lib/infrastructure/entities/exif.entity.dart +++ b/mobile/lib/infrastructure/entities/exif.entity.dart @@ -165,8 +165,6 @@ extension RemoteExifEntityDataDomainEx on RemoteExifEntityData { f: fNumber?.toDouble(), mm: focalLength?.toDouble(), lens: lens, - width: width?.toDouble(), - height: height?.toDouble(), isFlipped: ExifDtoConverter.isOrientationFlipped(orientation), ); } diff --git a/mobile/lib/infrastructure/repositories/sync_stream.repository.dart b/mobile/lib/infrastructure/repositories/sync_stream.repository.dart index 8e087f836f..5ab1844571 100644 --- a/mobile/lib/infrastructure/repositories/sync_stream.repository.dart +++ b/mobile/lib/infrastructure/repositories/sync_stream.repository.dart @@ -219,8 +219,6 @@ class SyncStreamRepository extends DriftDatabaseRepository { country: Value(exif.country), dateTimeOriginal: Value(exif.dateTimeOriginal), description: Value(exif.description), - height: Value(exif.exifImageHeight), - width: Value(exif.exifImageWidth), exposureTime: Value(exif.exposureTime), fNumber: Value(exif.fNumber), fileSize: Value(exif.fileSizeInByte), @@ -244,6 +242,16 @@ class SyncStreamRepository extends DriftDatabaseRepository { ); } }); + + await _db.batch((batch) { + for (final exif in data) { + batch.update( + _db.remoteAssetEntity, + RemoteAssetEntityCompanion(width: Value(exif.exifImageWidth), height: Value(exif.exifImageHeight)), + where: (row) => row.id.equals(exif.assetId), + ); + } + }); } catch (error, stack) { _logger.severe('Error: updateAssetsExifV1 - $debugLabel', error, stack); rethrow; diff --git a/mobile/lib/presentation/pages/drift_asset_troubleshoot.page.dart b/mobile/lib/presentation/pages/drift_asset_troubleshoot.page.dart index 7a899f4e72..752ab5ba37 100644 --- a/mobile/lib/presentation/pages/drift_asset_troubleshoot.page.dart +++ b/mobile/lib/presentation/pages/drift_asset_troubleshoot.page.dart @@ -161,8 +161,6 @@ class _AssetPropertiesSectionState extends ConsumerState<_AssetPropertiesSection value: exif.fileSize != null ? '${(exif.fileSize! / 1024 / 1024).toStringAsFixed(2)} MB' : null, ), _PropertyItem(label: 'Description', value: exif.description), - _PropertyItem(label: 'EXIF Width', value: exif.width?.toString()), - _PropertyItem(label: 'EXIF Height', value: exif.height?.toString()), _PropertyItem(label: 'Date Taken', value: exif.dateTimeOriginal?.toString()), _PropertyItem(label: 'Time Zone', value: exif.timeZone), _PropertyItem(label: 'Camera Make', value: exif.make), 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 d29e09a247..c7c502194a 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/bottom_sheet.widget.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/bottom_sheet.widget.dart @@ -97,8 +97,8 @@ class _AssetDetailBottomSheet extends ConsumerWidget { } String _getFileInfo(BaseAsset asset, ExifInfo? exifInfo) { - final height = asset.height ?? exifInfo?.height; - final width = asset.width ?? exifInfo?.width; + final height = asset.height; + final width = asset.width; final resolution = (width != null && height != null) ? "${width.toInt()} x ${height.toInt()}" : null; final fileSize = exifInfo?.fileSize != null ? formatBytes(exifInfo!.fileSize!) : null; diff --git a/mobile/lib/utils/migration.dart b/mobile/lib/utils/migration.dart index 2ed6d9549f..b0d7ea6013 100644 --- a/mobile/lib/utils/migration.dart +++ b/mobile/lib/utils/migration.dart @@ -29,7 +29,7 @@ import 'package:isar/isar.dart'; // ignore: import_rule_photo_manager import 'package:photo_manager/photo_manager.dart'; -const int targetVersion = 17; +const int targetVersion = 18; Future migrateDatabaseIfNeeded(Isar db, Drift drift) async { final hasVersion = Store.tryGet(StoreKey.version) != null; @@ -63,7 +63,8 @@ Future migrateDatabaseIfNeeded(Isar db, Drift drift) async { await Store.populateCache(); } - await handleBetaMigration(version, await _isNewInstallation(db, drift), SyncStreamRepository(drift)); + final syncStreamRepository = SyncStreamRepository(drift); + await handleBetaMigration(version, await _isNewInstallation(db, drift), syncStreamRepository); if (version < 17 && Store.isBetaTimelineEnabled) { final delay = Store.get(StoreKey.backupTriggerDelay, AppSettingsEnum.backupTriggerDelay.defaultValue); @@ -72,6 +73,11 @@ Future migrateDatabaseIfNeeded(Isar db, Drift drift) async { } } + if (version < 18 && Store.isBetaTimelineEnabled) { + await syncStreamRepository.reset(); + await Store.put(StoreKey.shouldResetSync, true); + } + if (targetVersion >= 12) { await Store.put(StoreKey.version, targetVersion); return; From 4462952564f6530934d4ad849b6ff33562752815 Mon Sep 17 00:00:00 2001 From: bo0tzz Date: Wed, 19 Nov 2025 04:00:03 +0100 Subject: [PATCH 11/18] fix: proper docker caching for plugin mise deps (#23967) * fix: proper docker caching for plugin mise deps * fix: mount mise cache for build too --- server/Dockerfile | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/server/Dockerfile b/server/Dockerfile index c73574a05f..3b2d885149 100644 --- a/server/Dockerfile +++ b/server/Dockerfile @@ -55,7 +55,9 @@ COPY --from=ghcr.io/jdx/mise:2025.11.3@sha256:ac26f5978c0e2783f3e68e58ce75eddb83 WORKDIR /usr/src/app COPY ./plugins/mise.toml ./plugins/ ENV MISE_TRUSTED_CONFIG_PATHS=/usr/src/app/plugins/mise.toml -RUN mise install --cd plugins +ENV MISE_DATA_DIR=/buildcache/mise +RUN --mount=type=cache,id=mise-tools,target=/buildcache/mise \ + mise install --cd plugins COPY ./plugins ./plugins/ # Build plugins @@ -64,6 +66,7 @@ RUN --mount=type=cache,id=pnpm-plugins,target=/buildcache/pnpm-store \ --mount=type=bind,source=.pnpmfile.cjs,target=.pnpmfile.cjs \ --mount=type=bind,source=pnpm-lock.yaml,target=pnpm-lock.yaml \ --mount=type=bind,source=pnpm-workspace.yaml,target=pnpm-workspace.yaml \ + --mount=type=cache,id=mise-tools,target=/buildcache/mise \ cd plugins && mise run build FROM ghcr.io/immich-app/base-server-prod:202511041104@sha256:57c0379977fd5521d83cdf661aecd1497c83a9a661ebafe0a5243a09fc1064cb From 271a42ac7fe4c3d7c66c7a58d9dae6ece1e92044 Mon Sep 17 00:00:00 2001 From: Mees Frensel <33722705+meesfrensel@users.noreply.github.com> Date: Wed, 19 Nov 2025 04:02:12 +0100 Subject: [PATCH 12/18] fix(server): copy relevant panorama tags to preview image (#23953) --- server/src/repositories/media.repository.ts | 17 +++++++++++++++++ server/src/services/media.service.spec.ts | 8 ++++++++ server/src/services/media.service.ts | 10 ++++++++++ .../test/repositories/media.repository.mock.ts | 1 + 4 files changed, 36 insertions(+) diff --git a/server/src/repositories/media.repository.ts b/server/src/repositories/media.repository.ts index d98e018efb..a8e96709ff 100644 --- a/server/src/repositories/media.repository.ts +++ b/server/src/repositories/media.repository.ts @@ -121,6 +121,23 @@ export class MediaRepository { } } + async copyTagGroup(tagGroup: string, source: string, target: string): Promise { + try { + await exiftool.write( + target, + {}, + { + ignoreMinorErrors: true, + writeArgs: ['-TagsFromFile', source, `-${tagGroup}:all>${tagGroup}:all`, '-overwrite_original'], + }, + ); + return true; + } catch (error: any) { + this.logger.warn(`Could not copy tag data to image: ${error.message}`); + return false; + } + } + decodeImage(input: string | Buffer, options: DecodeToBufferOptions) { return this.getImageDecodingPipeline(input, options).raw().toBuffer({ resolveWithObject: true }); } diff --git a/server/src/services/media.service.spec.ts b/server/src/services/media.service.spec.ts index ad52b0e8b0..8617930534 100644 --- a/server/src/services/media.service.spec.ts +++ b/server/src/services/media.service.spec.ts @@ -865,6 +865,7 @@ describe(MediaService.name, () => { mocks.systemMetadata.get.mockResolvedValue({ image: { fullsize: { enabled: false } } }); mocks.media.extract.mockResolvedValue({ buffer: extractedBuffer, format: RawExtractedFormat.Jpeg }); mocks.media.getImageDimensions.mockResolvedValue({ width: 3840, height: 2160 }); + mocks.media.copyTagGroup.mockResolvedValue(true); mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(assetStub.panoramaTif); @@ -890,6 +891,13 @@ describe(MediaService.name, () => { }, expect.any(String), ); + + expect(mocks.media.copyTagGroup).toHaveBeenCalledTimes(2); + expect(mocks.media.copyTagGroup).toHaveBeenCalledWith( + 'XMP-GPano', + assetStub.panoramaTif.originalPath, + expect.any(String), + ); }); it('should respect encoding options when generating full-size preview', async () => { diff --git a/server/src/services/media.service.ts b/server/src/services/media.service.ts index 6caa682f5e..82f041c111 100644 --- a/server/src/services/media.service.ts +++ b/server/src/services/media.service.ts @@ -316,6 +316,16 @@ export class MediaService extends BaseService { const outputs = await Promise.all(promises); + if (asset.exifInfo.projectionType === 'EQUIRECTANGULAR') { + const promises = [ + this.mediaRepository.copyTagGroup('XMP-GPano', asset.originalPath, previewPath), + fullsizePath + ? this.mediaRepository.copyTagGroup('XMP-GPano', asset.originalPath, fullsizePath) + : Promise.resolve(), + ]; + await Promise.all(promises); + } + return { previewPath, thumbnailPath, fullsizePath, thumbhash: outputs[0] as Buffer }; } diff --git a/server/test/repositories/media.repository.mock.ts b/server/test/repositories/media.repository.mock.ts index c6ab11aaa1..b6b1e82b52 100644 --- a/server/test/repositories/media.repository.mock.ts +++ b/server/test/repositories/media.repository.mock.ts @@ -6,6 +6,7 @@ export const newMediaRepositoryMock = (): Mocked Promise.resolve()), writeExif: vitest.fn().mockImplementation(() => Promise.resolve()), + copyTagGroup: vitest.fn().mockImplementation(() => Promise.resolve()), generateThumbhash: vitest.fn().mockResolvedValue(Buffer.from('')), decodeImage: vitest.fn().mockResolvedValue({ data: Buffer.from(''), info: {} }), extract: vitest.fn().mockResolvedValue(null), From 76c73549ae8ae87003b7d167daad8ae049447992 Mon Sep 17 00:00:00 2001 From: Mees Frensel <33722705+meesfrensel@users.noreply.github.com> Date: Wed, 19 Nov 2025 04:02:52 +0100 Subject: [PATCH 13/18] feat(web): always view original of animated images (#23842) --- .../asset-viewer/photo-viewer.spec.ts | 107 ++++++++++++++++-- .../asset-viewer/photo-viewer.svelte | 7 +- .../assets/thumbnail/thumbnail.svelte | 4 +- 3 files changed, 104 insertions(+), 14 deletions(-) diff --git a/web/src/lib/components/asset-viewer/photo-viewer.spec.ts b/web/src/lib/components/asset-viewer/photo-viewer.spec.ts index 9e9f8fae62..fd1a40e4db 100644 --- a/web/src/lib/components/asset-viewer/photo-viewer.spec.ts +++ b/web/src/lib/components/asset-viewer/photo-viewer.spec.ts @@ -1,7 +1,7 @@ import { getAnimateMock } from '$lib/__mocks__/animate.mock'; import PhotoViewer from '$lib/components/asset-viewer/photo-viewer.svelte'; import * as utils from '$lib/utils'; -import { AssetMediaSize } from '@immich/sdk'; +import { AssetMediaSize, AssetTypeEnum } from '@immich/sdk'; import { assetFactory } from '@test-data/factories/asset-factory'; import { sharedLinkFactory } from '@test-data/factories/shared-link-factory'; import { render } from '@testing-library/svelte'; @@ -65,7 +65,27 @@ describe('PhotoViewer component', () => { }); it('loads the thumbnail', () => { - const asset = assetFactory.build({ originalPath: 'image.jpg', originalMimeType: 'image/jpeg' }); + const asset = assetFactory.build({ + originalPath: 'image.jpg', + originalMimeType: 'image/jpeg', + type: AssetTypeEnum.Image, + }); + render(PhotoViewer, { asset }); + + expect(getAssetThumbnailUrlSpy).toBeCalledWith({ + id: asset.id, + size: AssetMediaSize.Preview, + cacheKey: asset.thumbhash, + }); + expect(getAssetOriginalUrlSpy).not.toBeCalled(); + }); + + it('loads the thumbnail image for static gifs', () => { + const asset = assetFactory.build({ + originalPath: 'image.gif', + originalMimeType: 'image/gif', + type: AssetTypeEnum.Image, + }); render(PhotoViewer, { asset }); expect(getAssetThumbnailUrlSpy).toBeCalledWith({ @@ -76,16 +96,73 @@ describe('PhotoViewer component', () => { expect(getAssetOriginalUrlSpy).not.toBeCalled(); }); - it('loads the original image for gifs', () => { - const asset = assetFactory.build({ originalPath: 'image.gif', originalMimeType: 'image/gif' }); + it('loads the thumbnail image for static webp images', () => { + const asset = assetFactory.build({ + originalPath: 'image.webp', + originalMimeType: 'image/webp', + type: AssetTypeEnum.Image, + }); + render(PhotoViewer, { asset }); + + expect(getAssetThumbnailUrlSpy).toBeCalledWith({ + id: asset.id, + size: AssetMediaSize.Preview, + cacheKey: asset.thumbhash, + }); + expect(getAssetOriginalUrlSpy).not.toBeCalled(); + }); + + it('loads the original image for animated gifs', () => { + const asset = assetFactory.build({ + originalPath: 'image.gif', + originalMimeType: 'image/gif', + type: AssetTypeEnum.Image, + duration: '2.0', + }); render(PhotoViewer, { asset }); expect(getAssetThumbnailUrlSpy).not.toBeCalled(); expect(getAssetOriginalUrlSpy).toBeCalledWith({ id: asset.id, cacheKey: asset.thumbhash }); }); - it('loads original for shared link when download permission is true and showMetadata permission is true', () => { - const asset = assetFactory.build({ originalPath: 'image.gif', originalMimeType: 'image/gif' }); + it('loads the original image for animated webp images', () => { + const asset = assetFactory.build({ + originalPath: 'image.webp', + originalMimeType: 'image/webp', + type: AssetTypeEnum.Image, + duration: '2.0', + }); + render(PhotoViewer, { asset }); + + expect(getAssetThumbnailUrlSpy).not.toBeCalled(); + expect(getAssetOriginalUrlSpy).toBeCalledWith({ id: asset.id, cacheKey: asset.thumbhash }); + }); + + it('not loads original static image in shared link even when download permission is true and showMetadata permission is true', () => { + const asset = assetFactory.build({ + originalPath: 'image.gif', + originalMimeType: 'image/gif', + type: AssetTypeEnum.Image, + }); + const sharedLink = sharedLinkFactory.build({ allowDownload: true, showMetadata: true, assets: [asset] }); + render(PhotoViewer, { asset, sharedLink }); + + expect(getAssetThumbnailUrlSpy).toBeCalledWith({ + id: asset.id, + size: AssetMediaSize.Preview, + cacheKey: asset.thumbhash, + }); + + expect(getAssetOriginalUrlSpy).not.toBeCalled(); + }); + + it('loads original animated image in shared link when download permission is true and showMetadata permission is true', () => { + const asset = assetFactory.build({ + originalPath: 'image.gif', + originalMimeType: 'image/gif', + type: AssetTypeEnum.Image, + duration: '2.0', + }); const sharedLink = sharedLinkFactory.build({ allowDownload: true, showMetadata: true, assets: [asset] }); render(PhotoViewer, { asset, sharedLink }); @@ -93,8 +170,13 @@ describe('PhotoViewer component', () => { expect(getAssetOriginalUrlSpy).toBeCalledWith({ id: asset.id, cacheKey: asset.thumbhash }); }); - it('not loads original image when shared link download permission is false', () => { - const asset = assetFactory.build({ originalPath: 'image.gif', originalMimeType: 'image/gif' }); + it('not loads original animated image when shared link download permission is false', () => { + const asset = assetFactory.build({ + originalPath: 'image.gif', + originalMimeType: 'image/gif', + type: AssetTypeEnum.Image, + duration: '2.0', + }); const sharedLink = sharedLinkFactory.build({ allowDownload: false, assets: [asset] }); render(PhotoViewer, { asset, sharedLink }); @@ -107,8 +189,13 @@ describe('PhotoViewer component', () => { expect(getAssetOriginalUrlSpy).not.toBeCalled(); }); - it('not loads original image when shared link showMetadata permission is false', () => { - const asset = assetFactory.build({ originalPath: 'image.gif', originalMimeType: 'image/gif' }); + it('not loads original animated image when shared link showMetadata permission is false', () => { + const asset = assetFactory.build({ + originalPath: 'image.gif', + originalMimeType: 'image/gif', + type: AssetTypeEnum.Image, + duration: '2.0', + }); const sharedLink = sharedLinkFactory.build({ showMetadata: false, assets: [asset] }); render(PhotoViewer, { asset, sharedLink }); diff --git a/web/src/lib/components/asset-viewer/photo-viewer.svelte b/web/src/lib/components/asset-viewer/photo-viewer.svelte index d88609f7bb..e37773fca5 100644 --- a/web/src/lib/components/asset-viewer/photo-viewer.svelte +++ b/web/src/lib/components/asset-viewer/photo-viewer.svelte @@ -19,7 +19,7 @@ import { cancelImageUrl } from '$lib/utils/sw-messaging'; import { getAltText } from '$lib/utils/thumbnail-util'; import { toTimelineAsset } from '$lib/utils/timeline-util'; - import { AssetMediaSize, type AssetResponseDto, type SharedLinkResponseDto } from '@immich/sdk'; + import { AssetMediaSize, AssetTypeEnum, type AssetResponseDto, type SharedLinkResponseDto } from '@immich/sdk'; import { LoadingSpinner, toastManager } from '@immich/ui'; import { onDestroy, onMount } from 'svelte'; import { useSwipe, type SwipeCustomEvent } from 'svelte-gestures'; @@ -139,7 +139,10 @@ }; // when true, will force loading of the original image - let forceUseOriginal: boolean = $derived(asset.originalMimeType === 'image/gif' || $photoZoomState.currentZoom > 1); + let forceUseOriginal: boolean = $derived( + (asset.type === AssetTypeEnum.Image && asset.duration && !asset.duration.includes('0:00:00.000')) || + $photoZoomState.currentZoom > 1, + ); const targetImageSize = $derived.by(() => { if ($alwaysLoadOriginalFile || forceUseOriginal || originalImageLoaded) { diff --git a/web/src/lib/components/assets/thumbnail/thumbnail.svelte b/web/src/lib/components/assets/thumbnail/thumbnail.svelte index dd13d613b2..261829cfc6 100644 --- a/web/src/lib/components/assets/thumbnail/thumbnail.svelte +++ b/web/src/lib/components/assets/thumbnail/thumbnail.svelte @@ -282,7 +282,7 @@
{/if} - {#if asset.isImage && asset.duration} + {#if asset.isImage && asset.duration && !asset.duration.includes('0:00:00.000')}
@@ -351,7 +351,7 @@ playbackOnIconHover={!$playVideoThumbnailOnHover} />
- {:else if asset.isImage && asset.duration && mouseOver} + {:else if asset.isImage && asset.duration && !asset.duration.includes('0:00:00.000') && mouseOver}
From 5e482dabc6fe05081646b49c97c5cf5f5199d44f Mon Sep 17 00:00:00 2001 From: Sergey Katsubo Date: Wed, 19 Nov 2025 06:03:21 +0300 Subject: [PATCH 14/18] feat(server): support running medium tests in devcontainer (#23882) * Support running medium tests in devcontainer * Add "pnpm run test:medium" to the devcontainer doc * Fix indentation for inline comments in the doc * Fix a couple of words in the doc --- .devcontainer/devcontainer.json | 6 +++++ docs/docs/developer/devcontainers.md | 37 ++++++++++++++-------------- 2 files changed, 25 insertions(+), 18 deletions(-) diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 79126fd658..7584eb8075 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -29,6 +29,12 @@ ] } }, + "features": { + "ghcr.io/devcontainers/features/docker-in-docker:2": { + // https://github.com/devcontainers/features/issues/1466 + "moby": false + } + }, "forwardPorts": [3000, 9231, 9230, 2283], "portsAttributes": { "3000": { diff --git a/docs/docs/developer/devcontainers.md b/docs/docs/developer/devcontainers.md index 0a1946e6c1..f50ec62d8a 100644 --- a/docs/docs/developer/devcontainers.md +++ b/docs/docs/developer/devcontainers.md @@ -256,7 +256,7 @@ The Dev Container supports multiple ways to run tests: ```bash # Run tests for specific components -make test-server # Server unit tests +make test-server # Server unit tests make test-web # Web unit tests make test-e2e # End-to-end tests make test-cli # CLI tests @@ -268,12 +268,13 @@ make test-all # Runs tests for all components make test-medium-dev # End-to-end tests ``` -#### Using NPM Directly +#### Using PNPM Directly ```bash # Server tests cd /workspaces/immich/server -pnpm test # Run all tests +pnpm test # Run all tests +pnpm run test:medium # Medium tests (integration tests) pnpm run test:watch # Watch mode pnpm run test:cov # Coverage report @@ -293,21 +294,21 @@ pnpm run test:web # Run web UI tests ```bash # Linting make lint-server # Lint server code -make lint-web # Lint web code -make lint-all # Lint all components +make lint-web # Lint web code +make lint-all # Lint all components # Formatting make format-server # Format server code -make format-web # Format web code -make format-all # Format all code +make format-web # Format web code +make format-all # Format all code # Type checking make check-server # Type check server -make check-web # Type check web -make check-all # Check all components +make check-web # Type check web +make check-all # Check all components # Complete hygiene check -make hygiene-all # Runs lint, format, check, SQL sync, and audit +make hygiene-all # Run lint, format, check, SQL sync, and audit ``` ### Additional Make Commands @@ -315,21 +316,21 @@ make hygiene-all # Runs lint, format, check, SQL sync, and audit ```bash # Build commands make build-server # Build server -make build-web # Build web app -make build-all # Build everything +make build-web # Build web app +make build-all # Build everything # API generation -make open-api # Generate OpenAPI specs +make open-api # Generate OpenAPI specs make open-api-typescript # Generate TypeScript SDK -make open-api-dart # Generate Dart SDK +make open-api-dart # Generate Dart SDK # Database -make sql # Sync database schema +make sql # Sync database schema # Dependencies -make install-server # Install server dependencies -make install-web # Install web dependencies -make install-all # Install all dependencies +make install-server # Install server dependencies +make install-web # Install web dependencies +make install-all # Install all dependencies ``` ### Debugging From edf577d7f7131a30706fe204cc3fc4101152c3b1 Mon Sep 17 00:00:00 2001 From: bo0tzz Date: Wed, 19 Nov 2025 04:03:49 +0100 Subject: [PATCH 15/18] feat: publish on release pr merge (#23867) --- .github/workflows/release.yml | 147 ++++++++++++++++++++++++++++++++++ 1 file changed, 147 insertions(+) create mode 100644 .github/workflows/release.yml diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000000..bb4ea9e245 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,147 @@ +name: release.yml +on: + pull_request: + types: [closed] + paths: + - CHANGELOG.md + +jobs: + # Maybe double check PR source branch? + + merge_translations: + uses: ./.github/workflows/merge-translations.yml + permissions: + pull-requests: write + secrets: + PUSH_O_MATIC_APP_ID: ${{ secrets.PUSH_O_MATIC_APP_ID }} + PUSH_O_MATIC_APP_KEY: ${{ secrets.PUSH_O_MATIC_APP_KEY }} + WEBLATE_TOKEN: ${{ secrets.WEBLATE_TOKEN }} + + build_mobile: + uses: ./.github/workflows/build-mobile.yml + needs: merge_translations + permissions: + contents: read + secrets: + KEY_JKS: ${{ secrets.KEY_JKS }} + ALIAS: ${{ secrets.ALIAS }} + ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }} + ANDROID_STORE_PASSWORD: ${{ secrets.ANDROID_STORE_PASSWORD }} + # iOS secrets + APP_STORE_CONNECT_API_KEY_ID: ${{ secrets.APP_STORE_CONNECT_API_KEY_ID }} + APP_STORE_CONNECT_API_KEY_ISSUER_ID: ${{ secrets.APP_STORE_CONNECT_API_KEY_ISSUER_ID }} + APP_STORE_CONNECT_API_KEY: ${{ secrets.APP_STORE_CONNECT_API_KEY }} + IOS_CERTIFICATE_P12: ${{ secrets.IOS_CERTIFICATE_P12 }} + IOS_CERTIFICATE_PASSWORD: ${{ secrets.IOS_CERTIFICATE_PASSWORD }} + IOS_PROVISIONING_PROFILE: ${{ secrets.IOS_PROVISIONING_PROFILE }} + IOS_PROVISIONING_PROFILE_SHARE_EXTENSION: ${{ secrets.IOS_PROVISIONING_PROFILE_SHARE_EXTENSION }}misc/release/notes.tmpl + IOS_PROVISIONING_PROFILE_WIDGET_EXTENSION: ${{ secrets.IOS_PROVISIONING_PROFILE_WIDGET_EXTENSION }} + IOS_DEVELOPMENT_PROVISIONING_PROFILE: ${{ secrets.IOS_DEVELOPMENT_PROVISIONING_PROFILE }} + IOS_DEVELOPMENT_PROVISIONING_PROFILE_SHARE_EXTENSION: ${{ secrets.IOS_DEVELOPMENT_PROVISIONING_PROFILE_SHARE_EXTENSION }} + IOS_DEVELOPMENT_PROVISIONING_PROFILE_WIDGET_EXTENSION: ${{ secrets.IOS_DEVELOPMENT_PROVISIONING_PROFILE_WIDGET_EXTENSION }} + FASTLANE_TEAM_ID: ${{ secrets.FASTLANE_TEAM_ID }} + with: + ref: main + environment: production + + prepare_release: + runs-on: ubuntu-latest + needs: build_mobile + permissions: + actions: read # To download the app artifact + steps: + - name: Generate a token + id: generate-token + uses: actions/create-github-app-token@67018539274d69449ef7c02e8e71183d1719ab42 # v2.1.4 + with: + app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }} + private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }} + + - name: Checkout + uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + with: + token: ${{ steps.generate-token.outputs.token }} + persist-credentials: false + ref: main + + - name: Extract changelog + id: changelog + run: | + CHANGELOG_PATH=$RUNNER_TEMP/changelog.md + sed -n '1,/^---$/p' CHANGELOG.md | head -n -1 > $CHANGELOG_PATH + echo "path=$CHANGELOG_PATH" >> $GITHUB_OUTPUT + VERSION=$(sed -n 's/^# //p' $CHANGELOG_PATH) + echo "version=$VERSION" >> $GITHUB_OUTPUT + + - name: Download APK + uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0 + with: + name: release-apk-signed + github-token: ${{ steps.generate-token.outputs.token }} + + - name: Create draft release + uses: softprops/action-gh-release@6da8fa9354ddfdc4aeace5fc48d7f679b5214090 # v2.4.1 + with: + tag_name: ${{ steps.version.outputs.result }} + token: ${{ steps.generate-token.outputs.token }} + body_path: ${{ steps.changelog.outputs.path }} + files: | + docker/docker-compose.yml + docker/example.env + docker/hwaccel.ml.yml + docker/hwaccel.transcoding.yml + docker/prometheus.yml + *.apk + + - name: Rename Outline document + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 + continue-on-error: true + env: + OUTLINE_API_KEY: ${{ secrets.OUTLINE_API_KEY }} + VERSION: ${{ steps.changelog.outputs.version }} + with: + github-token: ${{ steps.generate-token.outputs.token }} + script: | + const outlineKey = process.env.OUTLINE_API_KEY; + const version = process.env.VERSION; + const parentDocumentId = 'da856355-0844-43df-bd71-f8edce5382d9'; + const baseUrl = 'https://outline.immich.cloud'; + + const listResponse = await fetch(`${baseUrl}/api/documents.list`, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${outlineKey}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ parentDocumentId }) + }); + + if (!listResponse.ok) { + throw new Error(`Outline list failed: ${listResponse.statusText}`); + } + + const listData = await listResponse.json(); + const allDocuments = listData.data || []; + const document = allDocuments.find(doc => doc.title === 'next'); + + if (document) { + console.log(`Found document 'next', renaming to '${version}'...`); + + const updateResponse = await fetch(`${baseUrl}/api/documents.update`, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${outlineKey}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + id: document.id, + title: version + }) + }); + + if (!updateResponse.ok) { + throw new Error(`Failed to rename document: ${updateResponse.statusText}`); + } + } else { + console.log('No document titled "next" found to rename'); + } From 5f987a95f510c9dfa786e10e52021add7cc3d2a8 Mon Sep 17 00:00:00 2001 From: Daniel Dietzler <36593685+danieldietzler@users.noreply.github.com> Date: Wed, 19 Nov 2025 04:05:53 +0100 Subject: [PATCH 16/18] fix: feature flags manager race condition (#23973) Co-authored-by: Alex --- .../map/[[photos=photos]]/[[assetId=id]]/+page.svelte | 7 ++++++- .../(user)/map/[[photos=photos]]/[[assetId=id]]/+page.ts | 8 -------- .../trash/[[photos=photos]]/[[assetId=id]]/+page.svelte | 7 +++++++ .../trash/[[photos=photos]]/[[assetId=id]]/+page.ts | 8 -------- 4 files changed, 13 insertions(+), 17 deletions(-) diff --git a/web/src/routes/(user)/map/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/map/[[photos=photos]]/[[assetId=id]]/+page.svelte index dd5cbe8b8f..fd443a6470 100644 --- a/web/src/routes/(user)/map/[[photos=photos]]/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/map/[[photos=photos]]/[[assetId=id]]/+page.svelte @@ -1,6 +1,7 @@