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 @@
-
-
-
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 @@
-
-
-
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 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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 @@
-
-
-
-
-
-
-
-
-
- {#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 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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 @@
-
-
-
-
-
-
-
-
-
-
- {#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)}
| {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')}
- />
-
+ |
+
|
- {#if editImportPaths === index}
-
-
- handleUpdate(lib, index)}
- onCancel={() => (editImportPaths = undefined)}
- />
-
- {/if}
- {#if editScanSettings === index}
-
-
- handleUpdate(lib, index)}
- onCancel={() => (editScanSettings = undefined)}
- />
-
- {/if}
{/each}
-
-
{: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)}
+
+
+ {folder}
+ |
+
+
+
+ |
+
+ {/each}
+
+
+ {/if}
+
+
+
+
+
+
+
+
+ {$t('exclusion_pattern')}
+
+
+
+
+
+
+
+
+ {#each library.exclusionPatterns as exclusionPattern (exclusionPattern)}
+ {@const { Edit, Delete } = getLibraryExclusionPatternActions($t, library, exclusionPattern)}
+
+
+ {exclusionPattern}
+ |
+
+
+
+ |
+
+ {/each}
+
+
+
+
+
+
+
+
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 @@