Compare commits

..

1 Commits

Author SHA1 Message Date
midzelis
f068ca9911 feat: sqlite thumbnail storage 2026-02-01 18:18:35 +00:00
93 changed files with 1607 additions and 958 deletions

View File

@@ -591,9 +591,9 @@ jobs:
- name: Lint with ruff
run: |
uv run ruff check --output-format=github immich_ml
- name: Format with ruff
- name: Check black formatting
run: |
uv run ruff format --check immich_ml
uv run black --check immich_ml
- name: Run mypy type checking
run: |
uv run mypy --strict immich_ml/

View File

@@ -1,6 +1,6 @@
{
"name": "@immich/cli",
"version": "2.5.3",
"version": "2.5.2",
"description": "Command Line Interface (CLI) for Immich",
"type": "module",
"exports": "./dist/index.js",
@@ -14,7 +14,7 @@
],
"devDependencies": {
"@eslint/js": "^9.8.0",
"@immich/sdk": "workspace:*",
"@immich/sdk": "file:../open-api/typescript-sdk",
"@types/byte-size": "^8.1.0",
"@types/cli-progress": "^3.11.0",
"@types/lodash-es": "^4.17.12",

View File

@@ -14,48 +14,6 @@
name: immich-dev
services:
immich-init:
container_name: immich_init
image: immich-server-dev:latest
build:
context: ../
dockerfile: server/Dockerfile.dev
target: dev
command:
- |
rm -f /tmp/init-complete
if [ -d "node_modules/.pnpm" ] && [ "$(ls -A node_modules/.pnpm 2>/dev/null)" ] && [ -f "pnpm-lock.yaml" ] && pnpm list --silent &>/dev/null; then
echo "Dependencies valid, skipping install"
else
echo "Installing dependencies..."
pnpm install
fi
touch /tmp/init-complete
exec tail -f /dev/null
volumes:
- ..:/usr/src/app
- pnpm-cache:/buildcache/pnpm-cache
- pnpm-store-server:/buildcache/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: 'no'
healthcheck:
test: ['CMD', 'test', '-f', '/tmp/init-complete']
interval: 2s
timeout: 3s
retries: 300
start_period: 300s
immich-server:
container_name: immich_server
command: ['immich-dev']
@@ -71,11 +29,8 @@ services:
volumes:
- ..:/usr/src/app
- ${UPLOAD_LOCATION}/photos:/data
- /LUNA/ALPHA/PERSONAL/git/immich-dev/flickr-search/downloads:/ext-lib
- /LUNA/ALPHA/PERSONAL/git/immich-dev/wikimedia-search/downloads:/ext-lib2
- /etc/localtime:/etc/localtime:ro
- pnpm-cache:/buildcache/pnpm-cache
- pnpm-store-server:/buildcache/pnpm-store
- 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
@@ -108,8 +63,6 @@ services:
- 9231:9231
- 2283:2283
depends_on:
immich-init:
condition: service_healthy
redis:
condition: service_started
database:
@@ -132,8 +85,7 @@ services:
- 24678:24678
volumes:
- ..:/usr/src/app
- pnpm-cache:/buildcache/pnpm-cache
- pnpm-store-web:/buildcache/pnpm-store
- 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
@@ -146,8 +98,6 @@ services:
- coverage:/usr/src/app/web/coverage
restart: unless-stopped
depends_on:
immich-init:
condition: service_healthy
immich-server:
condition: service_started
@@ -223,9 +173,7 @@ volumes:
model-cache:
prometheus-data:
grafana-data:
pnpm-cache:
pnpm-store-server:
pnpm-store-web:
pnpm-store:
server-node_modules:
web-node_modules:
github-node_modules:

View File

@@ -1,7 +1,7 @@
[
{
"label": "v2.5.3",
"url": "https://docs.v2.5.3.archive.immich.app"
"label": "v2.5.2",
"url": "https://docs.v2.5.2.archive.immich.app"
},
{
"label": "v2.4.1",

View File

@@ -1,55 +1,6 @@
name: immich-e2e
services:
immich-init:
container_name: immich-e2e-init
image: immich-server-dev:latest
build:
context: ../
dockerfile: server/Dockerfile.dev
target: dev
command:
- |
set -x
echo "Init container starting"
rm -f /tmp/init-complete
echo "Checking for existing dependencies"
if [ -d "node_modules/.pnpm" ] && [ "$(ls -A node_modules/.pnpm 2>/dev/null)" ] && [ -f "pnpm-lock.yaml" ] && pnpm list --silent &>/dev/null; then
echo "Dependencies valid, skipping install"
else
echo "Installing dependencies"
pnpm install
echo "Dependencies installed successfully"
fi
echo "Creating init-complete signal file"
touch /tmp/init-complete
echo "Init complete, keeping container alive"
exec tail -f /dev/null
volumes:
- ..:/usr/src/app
- pnpm-cache:/buildcache/pnpm-cache
- pnpm-store-server:/buildcache/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: "no"
healthcheck:
test: ["CMD", "test", "-f", "/tmp/init-complete"]
interval: 2s
timeout: 3s
retries: 300
start_period: 300s
immich-server:
container_name: immich-e2e-server
command: ['immich-dev']
@@ -73,8 +24,7 @@ services:
- ..:/usr/src/app
- ${UPLOAD_LOCATION}/photos:/data
- /etc/localtime:/etc/localtime:ro
- pnpm-cache:/buildcache/pnpm-cache
- pnpm-store-server:/buildcache/pnpm-store
- 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
@@ -87,8 +37,6 @@ services:
- coverage:/usr/src/app/web/coverage
- ../plugins:/build/corePlugin
depends_on:
immich-init:
condition: service_healthy
redis:
condition: service_started
database:
@@ -108,8 +56,7 @@ services:
- IMMICH_SERVER_URL=http://immich-server:2285/
volumes:
- ..:/usr/src/app
- pnpm-cache:/buildcache/pnpm-cache
- pnpm-store-web:/buildcache/pnpm-store
- 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
@@ -120,13 +67,10 @@ services:
- app-node_modules:/usr/src/app/node_modules
- sveltekit:/usr/src/app/web/.svelte-kit
- coverage:/usr/src/app/web/coverage
depends_on:
immich-init:
condition: service_healthy
restart: unless-stopped
redis:
image: redis:6.2-alpine@sha256:46884be93652d02a96a176ccf173d1040bef365c5706aa7b6a1931caec8bfeef
image: redis:6.2-alpine@sha256:37e002448575b32a599109664107e374c8709546905c372a34d64919043b9ceb
database:
image: ghcr.io/immich-app/postgres:14-vectorchord0.3.0@sha256:6f3e9d2c2177af16c2988ff71425d79d89ca630ec2f9c8db03209ab716542338
@@ -148,9 +92,7 @@ volumes:
model-cache:
prometheus-data:
grafana-data:
pnpm-cache:
pnpm-store-server:
pnpm-store-web:
pnpm-store:
server-node_modules:
web-node_modules:
github-node_modules:

View File

@@ -42,7 +42,7 @@ services:
- 2285:2285
redis:
image: redis:6.2-alpine@sha256:46884be93652d02a96a176ccf173d1040bef365c5706aa7b6a1931caec8bfeef
image: redis:6.2-alpine@sha256:37e002448575b32a599109664107e374c8709546905c372a34d64919043b9ceb
database:
image: ghcr.io/immich-app/postgres:14-vectorchord0.3.0@sha256:6f3e9d2c2177af16c2988ff71425d79d89ca630ec2f9c8db03209ab716542338

View File

@@ -1,6 +1,6 @@
{
"name": "immich-e2e",
"version": "2.5.3",
"version": "2.5.2",
"description": "",
"main": "index.js",
"type": "module",
@@ -21,9 +21,9 @@
"devDependencies": {
"@eslint/js": "^9.8.0",
"@faker-js/faker": "^10.1.0",
"@immich/cli": "workspace:*",
"@immich/e2e-auth-server": "workspace:*",
"@immich/sdk": "workspace:*",
"@immich/cli": "file:../cli",
"@immich/e2e-auth-server": "file:../e2e-auth-server",
"@immich/sdk": "file:../open-api/typescript-sdk",
"@playwright/test": "^1.44.1",
"@socket.io/component-emitter": "^3.1.2",
"@types/luxon": "^3.4.2",

View File

@@ -1,2 +0,0 @@
export { generateMemoriesFromTimeline, generateMemory } from './memory/model-objects';
export type { MemoryConfig, MemoryYearConfig } from './memory/model-objects';

View File

@@ -1,84 +0,0 @@
import { faker } from '@faker-js/faker';
import { MemoryType, type MemoryResponseDto, type OnThisDayDto } from '@immich/sdk';
import { DateTime } from 'luxon';
import { toAssetResponseDto } from 'src/generators/timeline/rest-response';
import type { MockTimelineAsset } from 'src/generators/timeline/timeline-config';
import { SeededRandom, selectRandomMultiple } from 'src/generators/timeline/utils';
export type MemoryConfig = {
id?: string;
ownerId: string;
year: number;
memoryAt: string;
isSaved?: boolean;
};
export type MemoryYearConfig = {
year: number;
assetCount: number;
};
export function generateMemory(config: MemoryConfig, assets: MockTimelineAsset[]): MemoryResponseDto {
const now = new Date().toISOString();
const memoryId = config.id ?? faker.string.uuid();
return {
id: memoryId,
assets: assets.map((asset) => toAssetResponseDto(asset)),
data: { year: config.year } as OnThisDayDto,
memoryAt: config.memoryAt,
createdAt: now,
updatedAt: now,
isSaved: config.isSaved ?? false,
ownerId: config.ownerId,
type: MemoryType.OnThisDay,
};
}
export function generateMemoriesFromTimeline(
timelineAssets: MockTimelineAsset[],
ownerId: string,
memoryConfigs: MemoryYearConfig[],
seed: number = 42,
): MemoryResponseDto[] {
const rng = new SeededRandom(seed);
const memories: MemoryResponseDto[] = [];
const usedAssetIds = new Set<string>();
for (const config of memoryConfigs) {
const yearAssets = timelineAssets.filter((asset) => {
const assetYear = DateTime.fromISO(asset.fileCreatedAt).year;
return assetYear === config.year && !usedAssetIds.has(asset.id);
});
if (yearAssets.length === 0) {
continue;
}
const countToSelect = Math.min(config.assetCount, yearAssets.length);
const selectedAssets = selectRandomMultiple(yearAssets, countToSelect, rng);
for (const asset of selectedAssets) {
usedAssetIds.add(asset.id);
}
selectedAssets.sort(
(a, b) => DateTime.fromISO(b.fileCreatedAt).diff(DateTime.fromISO(a.fileCreatedAt)).milliseconds,
);
const memoryAt = DateTime.now().set({ year: config.year }).toISO()!;
memories.push(
generateMemory(
{
ownerId,
year: config.year,
memoryAt,
},
selectedAssets,
),
);
}
return memories;
}

View File

@@ -1,65 +0,0 @@
import type { MemoryResponseDto } from '@immich/sdk';
import { BrowserContext } from '@playwright/test';
export type MemoryChanges = {
memoryDeletions: string[];
assetRemovals: Map<string, string[]>;
};
export const setupMemoryMockApiRoutes = async (
context: BrowserContext,
memories: MemoryResponseDto[],
changes: MemoryChanges,
) => {
await context.route('**/api/memories*', async (route, request) => {
const url = new URL(request.url());
const pathname = url.pathname;
if (pathname === '/api/memories' && request.method() === 'GET') {
const activeMemories = memories
.filter((memory) => !changes.memoryDeletions.includes(memory.id))
.map((memory) => {
const removedAssets = changes.assetRemovals.get(memory.id) ?? [];
return {
...memory,
assets: memory.assets.filter((asset) => !removedAssets.includes(asset.id)),
};
})
.filter((memory) => memory.assets.length > 0);
return route.fulfill({
status: 200,
contentType: 'application/json',
json: activeMemories,
});
}
const memoryMatch = pathname.match(/\/api\/memories\/([^/]+)$/);
if (memoryMatch && request.method() === 'GET') {
const memoryId = memoryMatch[1];
const memory = memories.find((m) => m.id === memoryId);
if (!memory || changes.memoryDeletions.includes(memoryId)) {
return route.fulfill({ status: 404 });
}
const removedAssets = changes.assetRemovals.get(memoryId) ?? [];
return route.fulfill({
status: 200,
contentType: 'application/json',
json: {
...memory,
assets: memory.assets.filter((asset) => !removedAssets.includes(asset.id)),
},
});
}
if (/\/api\/memories\/([^/]+)$/.test(pathname) && request.method() === 'DELETE') {
const memoryId = pathname.split('/').pop()!;
changes.memoryDeletions.push(memoryId);
return route.fulfill({ status: 204 });
}
await route.fallback();
});
};

View File

@@ -1,289 +0,0 @@
import { faker } from '@faker-js/faker';
import type { MemoryResponseDto } from '@immich/sdk';
import { test } from '@playwright/test';
import { generateMemoriesFromTimeline } from 'src/generators/memory';
import {
Changes,
createDefaultTimelineConfig,
generateTimelineData,
TimelineAssetConfig,
TimelineData,
} from 'src/generators/timeline';
import { setupBaseMockApiRoutes } from 'src/mock-network/base-network';
import { MemoryChanges, setupMemoryMockApiRoutes } from 'src/mock-network/memory-network';
import { setupTimelineMockApiRoutes, TimelineTestContext } from 'src/mock-network/timeline-network';
import { memoryAssetViewerUtils, memoryGalleryUtils, memoryViewerUtils } from 'src/web/specs/memory/utils';
test.describe.configure({ mode: 'parallel' });
test.describe('Memory Viewer - Gallery Asset Viewer Navigation', () => {
let adminUserId: string;
let timelineRestData: TimelineData;
let memories: MemoryResponseDto[];
const assets: TimelineAssetConfig[] = [];
const testContext = new TimelineTestContext();
const changes: Changes = {
albumAdditions: [],
assetDeletions: [],
assetArchivals: [],
assetFavorites: [],
};
const memoryChanges: MemoryChanges = {
memoryDeletions: [],
assetRemovals: new Map(),
};
test.beforeAll(async () => {
adminUserId = faker.string.uuid();
testContext.adminId = adminUserId;
timelineRestData = generateTimelineData({
...createDefaultTimelineConfig(),
ownerId: adminUserId,
});
for (const timeBucket of timelineRestData.buckets.values()) {
assets.push(...timeBucket);
}
memories = generateMemoriesFromTimeline(
assets,
adminUserId,
[
{ year: 2024, assetCount: 3 },
{ year: 2023, assetCount: 2 },
{ year: 2022, assetCount: 4 },
],
42,
);
});
test.beforeEach(async ({ context }) => {
await setupBaseMockApiRoutes(context, adminUserId);
await setupTimelineMockApiRoutes(context, timelineRestData, changes, testContext);
await setupMemoryMockApiRoutes(context, memories, memoryChanges);
});
test.afterEach(() => {
testContext.slowBucket = false;
changes.albumAdditions = [];
changes.assetDeletions = [];
changes.assetArchivals = [];
changes.assetFavorites = [];
memoryChanges.memoryDeletions = [];
memoryChanges.assetRemovals.clear();
});
test.describe('Asset viewer navigation from gallery', () => {
test('shows both prev/next buttons for middle asset within a memory', async ({ page }) => {
const firstMemory = memories[0];
const middleAsset = firstMemory.assets[1];
await memoryViewerUtils.openMemoryPageWithAsset(page, middleAsset.id);
await memoryGalleryUtils.clickThumbnail(page, middleAsset.id);
await memoryAssetViewerUtils.waitForViewerOpen(page);
await memoryAssetViewerUtils.waitForAssetLoad(page, middleAsset);
await memoryAssetViewerUtils.expectPreviousButtonVisible(page);
await memoryAssetViewerUtils.expectNextButtonVisible(page);
});
test('shows next button when at last asset of first memory (next memory exists)', async ({ page }) => {
const firstMemory = memories[0];
const lastAssetOfFirstMemory = firstMemory.assets.at(-1)!;
await memoryViewerUtils.openMemoryPageWithAsset(page, lastAssetOfFirstMemory.id);
await memoryGalleryUtils.clickThumbnail(page, lastAssetOfFirstMemory.id);
await memoryAssetViewerUtils.waitForViewerOpen(page);
await memoryAssetViewerUtils.waitForAssetLoad(page, lastAssetOfFirstMemory);
await memoryAssetViewerUtils.expectNextButtonVisible(page);
await memoryAssetViewerUtils.expectPreviousButtonVisible(page);
});
test('shows prev button when at first asset of last memory (prev memory exists)', async ({ page }) => {
const lastMemory = memories.at(-1)!;
const firstAssetOfLastMemory = lastMemory.assets[0];
await memoryViewerUtils.openMemoryPageWithAsset(page, firstAssetOfLastMemory.id);
await memoryGalleryUtils.clickThumbnail(page, firstAssetOfLastMemory.id);
await memoryAssetViewerUtils.waitForViewerOpen(page);
await memoryAssetViewerUtils.waitForAssetLoad(page, firstAssetOfLastMemory);
await memoryAssetViewerUtils.expectPreviousButtonVisible(page);
await memoryAssetViewerUtils.expectNextButtonVisible(page);
});
test('can navigate from last asset of memory to first asset of next memory', async ({ page }) => {
const firstMemory = memories[0];
const secondMemory = memories[1];
const lastAssetOfFirst = firstMemory.assets.at(-1)!;
const firstAssetOfSecond = secondMemory.assets[0];
await memoryViewerUtils.openMemoryPageWithAsset(page, lastAssetOfFirst.id);
await memoryGalleryUtils.clickThumbnail(page, lastAssetOfFirst.id);
await memoryAssetViewerUtils.waitForViewerOpen(page);
await memoryAssetViewerUtils.waitForAssetLoad(page, lastAssetOfFirst);
await memoryAssetViewerUtils.clickNextButton(page);
await memoryAssetViewerUtils.waitForAssetLoad(page, firstAssetOfSecond);
await memoryAssetViewerUtils.expectCurrentAssetId(page, firstAssetOfSecond.id);
});
test('can navigate from first asset of memory to last asset of previous memory', async ({ page }) => {
const firstMemory = memories[0];
const secondMemory = memories[1];
const lastAssetOfFirst = firstMemory.assets.at(-1)!;
const firstAssetOfSecond = secondMemory.assets[0];
await memoryViewerUtils.openMemoryPageWithAsset(page, firstAssetOfSecond.id);
await memoryGalleryUtils.clickThumbnail(page, firstAssetOfSecond.id);
await memoryAssetViewerUtils.waitForViewerOpen(page);
await memoryAssetViewerUtils.waitForAssetLoad(page, firstAssetOfSecond);
await memoryAssetViewerUtils.clickPreviousButton(page);
await memoryAssetViewerUtils.waitForAssetLoad(page, lastAssetOfFirst);
});
test('hides prev button at very first asset (first memory, first asset, no prev memory)', async ({ page }) => {
const firstMemory = memories[0];
const veryFirstAsset = firstMemory.assets[0];
await memoryViewerUtils.openMemoryPageWithAsset(page, veryFirstAsset.id);
await memoryGalleryUtils.clickThumbnail(page, veryFirstAsset.id);
await memoryAssetViewerUtils.waitForViewerOpen(page);
await memoryAssetViewerUtils.waitForAssetLoad(page, veryFirstAsset);
await memoryAssetViewerUtils.expectPreviousButtonNotVisible(page);
await memoryAssetViewerUtils.expectNextButtonVisible(page);
});
test('hides next button at very last asset (last memory, last asset, no next memory)', async ({ page }) => {
const lastMemory = memories.at(-1)!;
const veryLastAsset = lastMemory.assets.at(-1)!;
await memoryViewerUtils.openMemoryPageWithAsset(page, veryLastAsset.id);
await memoryGalleryUtils.clickThumbnail(page, veryLastAsset.id);
await memoryAssetViewerUtils.waitForViewerOpen(page);
await memoryAssetViewerUtils.waitForAssetLoad(page, veryLastAsset);
await memoryAssetViewerUtils.expectNextButtonNotVisible(page);
await memoryAssetViewerUtils.expectPreviousButtonVisible(page);
});
});
test.describe('Keyboard navigation', () => {
test('ArrowLeft navigates to previous asset across memory boundary', async ({ page }) => {
const firstMemory = memories[0];
const secondMemory = memories[1];
const lastAssetOfFirst = firstMemory.assets.at(-1)!;
const firstAssetOfSecond = secondMemory.assets[0];
await memoryViewerUtils.openMemoryPageWithAsset(page, firstAssetOfSecond.id);
await memoryGalleryUtils.clickThumbnail(page, firstAssetOfSecond.id);
await memoryAssetViewerUtils.waitForViewerOpen(page);
await memoryAssetViewerUtils.waitForAssetLoad(page, firstAssetOfSecond);
await page.keyboard.press('ArrowLeft');
await memoryAssetViewerUtils.waitForAssetLoad(page, lastAssetOfFirst);
});
test('ArrowRight navigates to next asset across memory boundary', async ({ page }) => {
const firstMemory = memories[0];
const secondMemory = memories[1];
const lastAssetOfFirst = firstMemory.assets.at(-1)!;
const firstAssetOfSecond = secondMemory.assets[0];
await memoryViewerUtils.openMemoryPageWithAsset(page, lastAssetOfFirst.id);
await memoryGalleryUtils.clickThumbnail(page, lastAssetOfFirst.id);
await memoryAssetViewerUtils.waitForViewerOpen(page);
await memoryAssetViewerUtils.waitForAssetLoad(page, lastAssetOfFirst);
await page.keyboard.press('ArrowRight');
await memoryAssetViewerUtils.waitForAssetLoad(page, firstAssetOfSecond);
});
});
});
test.describe('Memory Viewer - Single Asset Memory Edge Cases', () => {
let adminUserId: string;
let timelineRestData: TimelineData;
let memories: MemoryResponseDto[];
const assets: TimelineAssetConfig[] = [];
const testContext = new TimelineTestContext();
const changes: Changes = {
albumAdditions: [],
assetDeletions: [],
assetArchivals: [],
assetFavorites: [],
};
const memoryChanges: MemoryChanges = {
memoryDeletions: [],
assetRemovals: new Map(),
};
test.beforeAll(async () => {
adminUserId = faker.string.uuid();
testContext.adminId = adminUserId;
timelineRestData = generateTimelineData({
...createDefaultTimelineConfig(),
ownerId: adminUserId,
});
for (const timeBucket of timelineRestData.buckets.values()) {
assets.push(...timeBucket);
}
memories = generateMemoriesFromTimeline(
assets,
adminUserId,
[
{ year: 2024, assetCount: 2 },
{ year: 2023, assetCount: 1 },
{ year: 2022, assetCount: 2 },
],
123,
);
});
test.beforeEach(async ({ context }) => {
await setupBaseMockApiRoutes(context, adminUserId);
await setupTimelineMockApiRoutes(context, timelineRestData, changes, testContext);
await setupMemoryMockApiRoutes(context, memories, memoryChanges);
});
test.afterEach(() => {
testContext.slowBucket = false;
changes.albumAdditions = [];
changes.assetDeletions = [];
changes.assetArchivals = [];
changes.assetFavorites = [];
memoryChanges.memoryDeletions = [];
memoryChanges.assetRemovals.clear();
});
test('single asset memory shows both prev/next when surrounded by other memories', async ({ page }) => {
const singleAssetMemory = memories[1];
const singleAsset = singleAssetMemory.assets[0];
await memoryViewerUtils.openMemoryPageWithAsset(page, singleAsset.id);
await memoryGalleryUtils.clickThumbnail(page, singleAsset.id);
await memoryAssetViewerUtils.waitForViewerOpen(page);
await memoryAssetViewerUtils.waitForAssetLoad(page, singleAsset);
await memoryAssetViewerUtils.expectPreviousButtonVisible(page);
await memoryAssetViewerUtils.expectNextButtonVisible(page);
});
});

View File

@@ -1,123 +0,0 @@
import type { AssetResponseDto } from '@immich/sdk';
import { expect, Page } from '@playwright/test';
function getAssetIdFromUrl(url: URL): string | null {
const pathMatch = url.pathname.match(/\/memory\/photos\/([^/]+)/);
if (pathMatch) {
return pathMatch[1];
}
return url.searchParams.get('id');
}
export const memoryViewerUtils = {
locator(page: Page) {
return page.locator('#memory-viewer');
},
async waitForMemoryLoad(page: Page) {
await expect(this.locator(page)).toBeVisible();
await expect(page.locator('#memory-viewer img').first()).toBeVisible();
},
async openMemoryPage(page: Page) {
await page.goto('/memory');
await this.waitForMemoryLoad(page);
},
async openMemoryPageWithAsset(page: Page, assetId: string) {
await page.goto(`/memory?id=${assetId}`);
await this.waitForMemoryLoad(page);
},
};
export const memoryGalleryUtils = {
locator(page: Page) {
return page.locator('#gallery-memory');
},
thumbnailWithAssetId(page: Page, assetId: string) {
return page.locator(`#gallery-memory [data-thumbnail-focus-container][data-asset="${assetId}"]`);
},
async scrollToGallery(page: Page) {
const showGalleryButton = page.getByLabel('Show gallery');
if (await showGalleryButton.isVisible()) {
await showGalleryButton.click();
}
await expect(this.locator(page)).toBeInViewport();
},
async clickThumbnail(page: Page, assetId: string) {
await this.scrollToGallery(page);
await this.thumbnailWithAssetId(page, assetId).click();
},
async getAllThumbnails(page: Page) {
await this.scrollToGallery(page);
return page.locator('#gallery-memory [data-thumbnail-focus-container]');
},
};
export const memoryAssetViewerUtils = {
locator(page: Page) {
return page.locator('#immich-asset-viewer');
},
async waitForViewerOpen(page: Page) {
await expect(this.locator(page)).toBeVisible();
},
async waitForAssetLoad(page: Page, asset: AssetResponseDto) {
const viewer = this.locator(page);
const imgLocator = viewer.locator(`img[draggable="false"][src*="/api/assets/${asset.id}/thumbnail?size=preview"]`);
const videoLocator = viewer.locator(`video[poster*="/api/assets/${asset.id}/thumbnail?size=preview"]`);
await imgLocator.or(videoLocator).waitFor({ timeout: 10_000 });
},
nextButton(page: Page) {
return page.getByLabel('View next asset');
},
previousButton(page: Page) {
return page.getByLabel('View previous asset');
},
async expectNextButtonVisible(page: Page) {
await expect(this.nextButton(page)).toBeVisible();
},
async expectNextButtonNotVisible(page: Page) {
await expect(this.nextButton(page)).toHaveCount(0);
},
async expectPreviousButtonVisible(page: Page) {
await expect(this.previousButton(page)).toBeVisible();
},
async expectPreviousButtonNotVisible(page: Page) {
await expect(this.previousButton(page)).toHaveCount(0);
},
async clickNextButton(page: Page) {
await this.nextButton(page).click();
},
async clickPreviousButton(page: Page) {
await this.previousButton(page).click();
},
async closeViewer(page: Page) {
await page.keyboard.press('Escape');
await expect(this.locator(page)).not.toBeVisible();
},
getCurrentAssetId(page: Page): string | null {
const url = new URL(page.url());
return getAssetIdFromUrl(url);
},
async expectCurrentAssetId(page: Page, expectedAssetId: string) {
await expect.poll(() => this.getCurrentAssetId(page)).toBe(expectedAssetId);
},
};

View File

@@ -1,6 +1,6 @@
{
"name": "immich-i18n",
"version": "2.5.3",
"version": "2.5.2",
"private": true,
"scripts": {
"format": "prettier --check .",

View File

@@ -1,6 +1,6 @@
[project]
name = "immich-ml"
version = "2.5.3"
version = "2.5.2"
description = ""
authors = [{ name = "Hau Tran", email = "alex.tran1502@gmail.com" }]
requires-python = ">=3.11,<4.0"
@@ -41,6 +41,7 @@ types = [
"types-ujson>=5.10.0.20240515",
]
lint = [
"black>=23.3.0",
"mypy>=1.3.0",
"ruff>=0.0.272",
{ include-group = "types" },
@@ -92,5 +93,9 @@ target-version = "py311"
select = ["E", "F", "I"]
per-file-ignores = { "test_main.py" = ["F403"] }
[tool.black]
line-length = 120
target-version = ['py311']
[tool.pytest.ini_options]
markers = ["providers", "ov_device_ids"]

View File

@@ -85,6 +85,43 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/99/37/e8730c3587a65eb5645d4aba2d27aae48e8003614d6aaf15dda67f702f1f/bidict-0.23.1-py3-none-any.whl", hash = "sha256:5dae8d4d79b552a71cbabc7deb25dfe8ce710b17ff41711e13010ead2abfc3e5", size = 32764, upload-time = "2024-02-18T19:09:04.156Z" },
]
[[package]]
name = "black"
version = "25.12.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "click" },
{ name = "mypy-extensions" },
{ name = "packaging" },
{ name = "pathspec" },
{ name = "platformdirs" },
{ name = "pytokens" },
]
sdist = { url = "https://files.pythonhosted.org/packages/c4/d9/07b458a3f1c525ac392b5edc6b191ff140b596f9d77092429417a54e249d/black-25.12.0.tar.gz", hash = "sha256:8d3dd9cea14bff7ddc0eb243c811cdb1a011ebb4800a5f0335a01a68654796a7", size = 659264, upload-time = "2025-12-08T01:40:52.501Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/60/ad/7ac0d0e1e0612788dbc48e62aef8a8e8feffac7eb3d787db4e43b8462fa8/black-25.12.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:d0cfa263e85caea2cff57d8f917f9f51adae8e20b610e2b23de35b5b11ce691a", size = 1877003, upload-time = "2025-12-08T01:43:29.967Z" },
{ url = "https://files.pythonhosted.org/packages/e8/dd/a237e9f565f3617a88b49284b59cbca2a4f56ebe68676c1aad0ce36a54a7/black-25.12.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1a2f578ae20c19c50a382286ba78bfbeafdf788579b053d8e4980afb079ab9be", size = 1712639, upload-time = "2025-12-08T01:52:46.756Z" },
{ url = "https://files.pythonhosted.org/packages/12/80/e187079df1ea4c12a0c63282ddd8b81d5107db6d642f7d7b75a6bcd6fc21/black-25.12.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d3e1b65634b0e471d07ff86ec338819e2ef860689859ef4501ab7ac290431f9b", size = 1758143, upload-time = "2025-12-08T01:45:29.137Z" },
{ url = "https://files.pythonhosted.org/packages/93/b5/3096ccee4f29dc2c3aac57274326c4d2d929a77e629f695f544e159bfae4/black-25.12.0-cp311-cp311-win_amd64.whl", hash = "sha256:a3fa71e3b8dd9f7c6ac4d818345237dfb4175ed3bf37cd5a581dbc4c034f1ec5", size = 1420698, upload-time = "2025-12-08T01:45:53.379Z" },
{ url = "https://files.pythonhosted.org/packages/7e/39/f81c0ffbc25ffbe61c7d0385bf277e62ffc3e52f5ee668d7369d9854fadf/black-25.12.0-cp311-cp311-win_arm64.whl", hash = "sha256:51e267458f7e650afed8445dc7edb3187143003d52a1b710c7321aef22aa9655", size = 1229317, upload-time = "2025-12-08T01:46:35.606Z" },
{ url = "https://files.pythonhosted.org/packages/d1/bd/26083f805115db17fda9877b3c7321d08c647df39d0df4c4ca8f8450593e/black-25.12.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:31f96b7c98c1ddaeb07dc0f56c652e25bdedaac76d5b68a059d998b57c55594a", size = 1924178, upload-time = "2025-12-08T01:49:51.048Z" },
{ url = "https://files.pythonhosted.org/packages/89/6b/ea00d6651561e2bdd9231c4177f4f2ae19cc13a0b0574f47602a7519b6ca/black-25.12.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:05dd459a19e218078a1f98178c13f861fe6a9a5f88fc969ca4d9b49eb1809783", size = 1742643, upload-time = "2025-12-08T01:49:59.09Z" },
{ url = "https://files.pythonhosted.org/packages/6d/f3/360fa4182e36e9875fabcf3a9717db9d27a8d11870f21cff97725c54f35b/black-25.12.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c1f68c5eff61f226934be6b5b80296cf6939e5d2f0c2f7d543ea08b204bfaf59", size = 1800158, upload-time = "2025-12-08T01:44:27.301Z" },
{ url = "https://files.pythonhosted.org/packages/f8/08/2c64830cb6616278067e040acca21d4f79727b23077633953081c9445d61/black-25.12.0-cp312-cp312-win_amd64.whl", hash = "sha256:274f940c147ddab4442d316b27f9e332ca586d39c85ecf59ebdea82cc9ee8892", size = 1426197, upload-time = "2025-12-08T01:45:51.198Z" },
{ url = "https://files.pythonhosted.org/packages/d4/60/a93f55fd9b9816b7432cf6842f0e3000fdd5b7869492a04b9011a133ee37/black-25.12.0-cp312-cp312-win_arm64.whl", hash = "sha256:169506ba91ef21e2e0591563deda7f00030cb466e747c4b09cb0a9dae5db2f43", size = 1237266, upload-time = "2025-12-08T01:45:10.556Z" },
{ url = "https://files.pythonhosted.org/packages/c8/52/c551e36bc95495d2aa1a37d50566267aa47608c81a53f91daa809e03293f/black-25.12.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:a05ddeb656534c3e27a05a29196c962877c83fa5503db89e68857d1161ad08a5", size = 1923809, upload-time = "2025-12-08T01:46:55.126Z" },
{ url = "https://files.pythonhosted.org/packages/a0/f7/aac9b014140ee56d247e707af8db0aae2e9efc28d4a8aba92d0abd7ae9d1/black-25.12.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:9ec77439ef3e34896995503865a85732c94396edcc739f302c5673a2315e1e7f", size = 1742384, upload-time = "2025-12-08T01:49:37.022Z" },
{ url = "https://files.pythonhosted.org/packages/74/98/38aaa018b2ab06a863974c12b14a6266badc192b20603a81b738c47e902e/black-25.12.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0e509c858adf63aa61d908061b52e580c40eae0dfa72415fa47ac01b12e29baf", size = 1798761, upload-time = "2025-12-08T01:46:05.386Z" },
{ url = "https://files.pythonhosted.org/packages/16/3a/a8ac542125f61574a3f015b521ca83b47321ed19bb63fe6d7560f348bfe1/black-25.12.0-cp313-cp313-win_amd64.whl", hash = "sha256:252678f07f5bac4ff0d0e9b261fbb029fa530cfa206d0a636a34ab445ef8ca9d", size = 1429180, upload-time = "2025-12-08T01:45:34.903Z" },
{ url = "https://files.pythonhosted.org/packages/e6/2d/bdc466a3db9145e946762d52cd55b1385509d9f9004fec1c97bdc8debbfb/black-25.12.0-cp313-cp313-win_arm64.whl", hash = "sha256:bc5b1c09fe3c931ddd20ee548511c64ebf964ada7e6f0763d443947fd1c603ce", size = 1239350, upload-time = "2025-12-08T01:46:09.458Z" },
{ url = "https://files.pythonhosted.org/packages/35/46/1d8f2542210c502e2ae1060b2e09e47af6a5e5963cb78e22ec1a11170b28/black-25.12.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:0a0953b134f9335c2434864a643c842c44fba562155c738a2a37a4d61f00cad5", size = 1917015, upload-time = "2025-12-08T01:53:27.987Z" },
{ url = "https://files.pythonhosted.org/packages/41/37/68accadf977672beb8e2c64e080f568c74159c1aaa6414b4cd2aef2d7906/black-25.12.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:2355bbb6c3b76062870942d8cc450d4f8ac71f9c93c40122762c8784df49543f", size = 1741830, upload-time = "2025-12-08T01:54:36.861Z" },
{ url = "https://files.pythonhosted.org/packages/ac/76/03608a9d8f0faad47a3af3a3c8c53af3367f6c0dd2d23a84710456c7ac56/black-25.12.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9678bd991cc793e81d19aeeae57966ee02909877cb65838ccffef24c3ebac08f", size = 1791450, upload-time = "2025-12-08T01:44:52.581Z" },
{ url = "https://files.pythonhosted.org/packages/06/99/b2a4bd7dfaea7964974f947e1c76d6886d65fe5d24f687df2d85406b2609/black-25.12.0-cp314-cp314-win_amd64.whl", hash = "sha256:97596189949a8aad13ad12fcbb4ae89330039b96ad6742e6f6b45e75ad5cfd83", size = 1452042, upload-time = "2025-12-08T01:46:13.188Z" },
{ url = "https://files.pythonhosted.org/packages/b2/7c/d9825de75ae5dd7795d007681b752275ea85a1c5d83269b4b9c754c2aaab/black-25.12.0-cp314-cp314-win_arm64.whl", hash = "sha256:778285d9ea197f34704e3791ea9404cd6d07595745907dd2ce3da7a13627b29b", size = 1267446, upload-time = "2025-12-08T01:46:14.497Z" },
{ url = "https://files.pythonhosted.org/packages/68/11/21331aed19145a952ad28fca2756a1433ee9308079bd03bd898e903a2e53/black-25.12.0-py3-none-any.whl", hash = "sha256:48ceb36c16dbc84062740049eef990bb2ce07598272e673c17d1a7720c71c828", size = 206191, upload-time = "2025-12-08T01:40:50.963Z" },
]
[[package]]
name = "blinker"
version = "1.7.0"
@@ -882,7 +919,7 @@ wheels = [
[[package]]
name = "immich-ml"
version = "2.5.3"
version = "2.5.2"
source = { editable = "." }
dependencies = [
{ name = "aiocache" },
@@ -924,6 +961,7 @@ rknn = [
[package.dev-dependencies]
dev = [
{ name = "black" },
{ name = "httpx" },
{ name = "locust" },
{ name = "mypy" },
@@ -939,6 +977,7 @@ dev = [
{ name = "types-ujson" },
]
lint = [
{ name = "black" },
{ name = "mypy" },
{ name = "ruff" },
{ name = "types-pyyaml" },
@@ -992,6 +1031,7 @@ provides-extras = ["cpu", "cuda", "openvino", "armnn", "rknn", "rocm"]
[package.metadata.requires-dev]
dev = [
{ name = "black", specifier = ">=23.3.0" },
{ name = "httpx", specifier = ">=0.24.1" },
{ name = "locust", specifier = ">=2.15.1" },
{ name = "mypy", specifier = ">=1.3.0" },
@@ -1007,6 +1047,7 @@ dev = [
{ name = "types-ujson", specifier = ">=5.10.0.20240515" },
]
lint = [
{ name = "black", specifier = ">=23.3.0" },
{ name = "mypy", specifier = ">=1.3.0" },
{ name = "ruff", specifier = ">=0.0.272" },
{ name = "types-pyyaml", specifier = ">=6.0.12.20241230" },
@@ -2191,6 +2232,15 @@ client = [
{ name = "websocket-client" },
]
[[package]]
name = "pytokens"
version = "0.3.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/4e/8d/a762be14dae1c3bf280202ba3172020b2b0b4c537f94427435f19c413b72/pytokens-0.3.0.tar.gz", hash = "sha256:2f932b14ed08de5fcf0b391ace2642f858f1394c0857202959000b68ed7a458a", size = 17644, upload-time = "2025-11-05T13:36:35.34Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/84/25/d9db8be44e205a124f6c98bc0324b2bb149b7431c53877fc6d1038dddaf5/pytokens-0.3.0-py3-none-any.whl", hash = "sha256:95b2b5eaf832e469d141a378872480ede3f251a5a5041b8ec6e581d3ac71bbf3", size = 12195, upload-time = "2025-11-05T13:36:33.183Z" },
]
[[package]]
name = "pywin32"
version = "311"

View File

@@ -35,8 +35,8 @@ platform :android do
task: 'bundle',
build_type: 'Release',
properties: {
"android.injected.version.code" => 3034,
"android.injected.version.name" => "2.5.3",
"android.injected.version.code" => 3033,
"android.injected.version.name" => "2.5.2",
}
)
upload_to_play_store(skip_upload_apk: true, skip_upload_images: true, skip_upload_screenshots: true, aab: '../build/app/outputs/bundle/release/app-release.aab')

View File

@@ -133,6 +133,7 @@ class LocalImageApiImpl: LocalImageApi {
"height": Int64(buffer.height),
"rowBytes": Int64(buffer.rowBytes)
]))
print("Successful response for \(requestId)")
Self.remove(requestId: requestId)
} catch {
Self.remove(requestId: requestId)

View File

@@ -80,7 +80,7 @@
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>2.5.3</string>
<string>2.5.2</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleURLTypes</key>

View File

@@ -33,7 +33,7 @@ class _DriftAlbumsPageState extends ConsumerState<DriftAlbumsPage> {
@override
Widget build(BuildContext context) {
final albumCount = ref.watch(remoteAlbumProvider.select((state) => state.albums.length));
final showScrollbar = albumCount > 20;
final showScrollbar = albumCount > 10;
final scrollView = CustomScrollView(
controller: _scrollController,

View File

@@ -87,7 +87,7 @@ class _AlbumSelectorState extends ConsumerState<AlbumSelector> {
}
void onSearch(String searchTerm, QuickFilterMode filterMode) {
final userId = ref.read(currentUserProvider)?.id;
final userId = ref.watch(currentUserProvider)?.id;
filter = filter.copyWith(query: searchTerm, userId: userId, mode: filterMode);
filterAlbums();
@@ -186,7 +186,7 @@ class _AlbumSelectorState extends ConsumerState<AlbumSelector> {
@override
Widget build(BuildContext context) {
final userId = ref.watch(currentUserProvider.select((user) => user?.id));
final userId = ref.watch(currentUserProvider)?.id;
// refilter and sort when albums change
ref.listen(remoteAlbumProvider.select((state) => state.albums), (_, _) async {

View File

@@ -259,11 +259,6 @@ class DriftBackupNotifier extends StateNotifier<DriftBackupState> {
}
Future<void> startForegroundBackup(String userId) async {
// Cancel any existing backup before starting a new one
if (state.cancelToken != null) {
await stopForegroundBackup();
}
state = state.copyWith(error: BackupError.none);
final cancelToken = CancellationToken();
@@ -380,21 +375,21 @@ class DriftBackupNotifier extends StateNotifier<DriftBackupState> {
_logger.warning("Skip handleBackupResume (pre-call): notifier disposed");
return;
}
_logger.info("Start background backup sequence");
_logger.info("Resuming backup tasks...");
state = state.copyWith(error: BackupError.none);
final tasks = await _backgroundUploadService.getActiveTasks(kBackupGroup);
if (!mounted) {
_logger.warning("Skip handleBackupResume (post-call): notifier disposed");
return;
}
_logger.info("Found ${tasks.length} pending tasks");
_logger.info("Found ${tasks.length} tasks");
if (tasks.isEmpty) {
_logger.info("No pending tasks, starting new upload");
_logger.info("Start backup with URLSession");
return _backgroundUploadService.uploadBackupCandidates(userId);
}
_logger.info("Resuming upload ${tasks.length} assets");
_logger.info("Tasks to resume: ${tasks.length}");
return _backgroundUploadService.resume();
}
}

View File

@@ -164,12 +164,9 @@ class BackgroundUploadService {
final candidates = await _backupRepository.getCandidates(userId);
if (candidates.isEmpty) {
_logger.info("No new backup candidates found, finishing background upload");
return;
}
_logger.info("Found ${candidates.length} backup candidates for background tasks");
const batchSize = 100;
final batch = candidates.take(batchSize).toList();
List<UploadTask> tasks = [];
@@ -182,7 +179,6 @@ class BackgroundUploadService {
}
if (tasks.isNotEmpty && !shouldAbortQueuingTasks) {
_logger.info("Enqueuing ${tasks.length} background upload tasks");
await enqueueTasks(tasks);
}
}

View File

@@ -3,7 +3,7 @@ Immich API
This Dart package is automatically generated by the [OpenAPI Generator](https://openapi-generator.tech) project:
- API version: 2.5.3
- API version: 2.5.2
- Generator version: 7.8.0
- Build package: org.openapitools.codegen.languages.DartClientCodegen

View File

@@ -473,7 +473,7 @@ class AlbumsApi {
/// Filter albums containing this asset ID (ignores shared parameter)
///
/// * [bool] shared:
/// Filter by shared status: true = only shared, false = not shared, undefined = all owned albums
/// Filter by shared status: true = only shared, false = only own, undefined = all
Future<Response> getAllAlbumsWithHttpInfo({ String? assetId, bool? shared, }) async {
// ignore: prefer_const_declarations
final apiPath = r'/albums';
@@ -516,7 +516,7 @@ class AlbumsApi {
/// Filter albums containing this asset ID (ignores shared parameter)
///
/// * [bool] shared:
/// Filter by shared status: true = only shared, false = not shared, undefined = all owned albums
/// Filter by shared status: true = only shared, false = only own, undefined = all
Future<List<AlbumResponseDto>?> getAllAlbums({ String? assetId, bool? shared, }) async {
final response = await getAllAlbumsWithHttpInfo( assetId: assetId, shared: shared, );
if (response.statusCode >= HttpStatus.badRequest) {

View File

@@ -1249,10 +1249,10 @@ packages:
dependency: transitive
description:
name: meta
sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394"
sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c
url: "https://pub.dev"
source: hosted
version: "1.17.0"
version: "1.16.0"
mime:
dependency: transitive
description:
@@ -1942,10 +1942,10 @@ packages:
dependency: transitive
description:
name: test_api
sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55
sha256: "522f00f556e73044315fa4585ec3270f1808a4b186c936e612cab0b565ff1e00"
url: "https://pub.dev"
source: hosted
version: "0.7.7"
version: "0.7.6"
thumbhash:
dependency: "direct main"
description:

View File

@@ -2,7 +2,7 @@ name: immich_mobile
description: Immich - selfhosted backup media file on mobile phone
publish_to: 'none'
version: 2.5.3+3034
version: 2.5.2+3033
environment:
sdk: '>=3.8.0 <4.0.0'

View File

@@ -1618,7 +1618,7 @@
"name": "shared",
"required": false,
"in": "query",
"description": "Filter by shared status: true = only shared, false = not shared, undefined = all owned albums",
"description": "Filter by shared status: true = only shared, false = only own, undefined = all",
"schema": {
"type": "boolean"
}
@@ -15057,7 +15057,7 @@
"info": {
"title": "Immich",
"description": "Immich API",
"version": "2.5.3",
"version": "2.5.2",
"contact": {}
},
"tags": [

View File

@@ -1,6 +1,6 @@
{
"name": "@immich/sdk",
"version": "2.5.3",
"version": "2.5.2",
"description": "Auto-generated TypeScript SDK for the Immich API",
"type": "module",
"main": "./build/index.js",

View File

@@ -1,6 +1,6 @@
/**
* Immich
* 2.5.3
* 2.5.2
* DO NOT MODIFY - This file has been generated using oazapfts.
* See https://www.npmjs.com/package/oazapfts
*/

View File

@@ -1,6 +1,6 @@
{
"name": "immich-monorepo",
"version": "2.5.3",
"version": "2.5.2",
"description": "Monorepo for Immich",
"private": true,
"packageManager": "pnpm@10.28.0+sha512.05df71d1421f21399e053fde567cea34d446fa02c76571441bfc1c7956e98e363088982d940465fd34480d4d90a0668bc12362f8aa88000a64e83d0b0e47be48",

113
pnpm-lock.yaml generated
View File

@@ -9,7 +9,7 @@ overrides:
canvas: 2.11.2
sharp: ^0.34.5
packageExtensionsChecksum: sha256-3l4AQg4iuprBDup+q+2JaPvbPg/7XodWCE0ZteH+s54=
packageExtensionsChecksum: sha256-vheqqqBU5SU8N8ma3OjzLM07nd511Xmy+mOvgxie+Ts=
pnpmfileChecksum: sha256-AG/qwrPNpmy9q60PZwCpecoYVptglTHgH+N6RKQHOM0=
@@ -45,7 +45,7 @@ importers:
specifier: ^9.8.0
version: 9.39.2
'@immich/sdk':
specifier: workspace:*
specifier: file:../open-api/typescript-sdk
version: link:../open-api/typescript-sdk
'@types/byte-size':
specifier: ^8.1.0
@@ -202,13 +202,13 @@ importers:
specifier: ^10.1.0
version: 10.2.0
'@immich/cli':
specifier: workspace:*
specifier: file:../cli
version: link:../cli
'@immich/e2e-auth-server':
specifier: workspace:*
specifier: file:../e2e-auth-server
version: link:../e2e-auth-server
'@immich/sdk':
specifier: workspace:*
specifier: file:../open-api/typescript-sdk
version: link:../open-api/typescript-sdk
'@playwright/test':
specifier: ^1.44.1
@@ -421,6 +421,9 @@ importers:
bcrypt:
specifier: ^6.0.0
version: 6.0.0
better-sqlite3:
specifier: ^12.6.2
version: 12.6.2
body-parser:
specifier: ^2.2.0
version: 2.2.2
@@ -605,6 +608,9 @@ importers:
'@types/bcrypt':
specifier: ^6.0.0
version: 6.0.0
'@types/better-sqlite3':
specifier: ^7.6.13
version: 7.6.13
'@types/body-parser':
specifier: ^1.19.6
version: 1.19.6
@@ -738,7 +744,7 @@ importers:
specifier: ^0.4.3
version: 0.4.3
'@immich/sdk':
specifier: workspace:*
specifier: file:../open-api/typescript-sdk
version: link:../open-api/typescript-sdk
'@immich/ui':
specifier: ^0.59.0
@@ -5080,6 +5086,9 @@ packages:
'@types/bcrypt@6.0.0':
resolution: {integrity: sha512-/oJGukuH3D2+D+3H4JWLaAsJ/ji86dhRidzZ/Od7H/i8g+aCmvkeCc6Ni/f9uxGLSQVCRZkX2/lqEFG2BvWtlQ==}
'@types/better-sqlite3@7.6.13':
resolution: {integrity: sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA==}
'@types/body-parser@1.19.6':
resolution: {integrity: sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==}
@@ -6052,6 +6061,10 @@ packages:
resolution: {integrity: sha512-cU8v/EGSrnH+HnxV2z0J7/blxH8gq7Xh2JFT6Aroax7UohdmiJJlxApMxtKfuI7z68NvvVcmR78k2LbT6efhRg==}
engines: {node: '>= 18'}
better-sqlite3@12.6.2:
resolution: {integrity: sha512-8VYKM3MjCa9WcaSAI3hzwhmyHVlH8tiGFwf0RlTsZPWJ1I5MkzjiudCo4KC4DxOaL/53A5B1sI/IbldNFDbsKA==}
engines: {node: 20.x || 22.x || 23.x || 24.x || 25.x}
big.js@5.2.2:
resolution: {integrity: sha512-vyL2OymJxmarO8gxMr0mhChsO9QGwhynfuu4+MHTAW6czfq9humCB7rKpUjDd9YUiDPU4mzpyupFSvOClAwbmQ==}
@@ -6059,6 +6072,9 @@ packages:
resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==}
engines: {node: '>=8'}
bindings@1.5.0:
resolution: {integrity: sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==}
bits-ui@2.14.4:
resolution: {integrity: sha512-W6kenhnbd/YVvur+DKkaVJ6GldE53eLewur5AhUCqslYQ0vjZr8eWlOfwZnMiPB+PF5HMVqf61vXBvmyrAmPWg==}
engines: {node: '>=20'}
@@ -7585,6 +7601,10 @@ packages:
resolution: {integrity: sha512-CpNH1FAhIQG5AlKndlTf05mNbuFxINyzG9629ZI/CKwr+39zWo8swxpnXc3GUfUvUfxkCCxumDPy2QVmi3XJkQ==}
engines: {node: '>=20.0.0'}
expand-template@2.0.3:
resolution: {integrity: sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==}
engines: {node: '>=6'}
expect-type@1.3.0:
resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==}
engines: {node: '>=12.0.0'}
@@ -7700,6 +7720,9 @@ packages:
resolution: {integrity: sha512-8kPJMIGz1Yt/aPEwOsrR97ZyZaD1Iqm8PClb1nYFclUCkBi0Ma5IsYNQzvSFS9ib51lWyIw5mIT9rWzI/xjpzA==}
engines: {node: '>=20'}
file-uri-to-path@1.0.0:
resolution: {integrity: sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==}
fill-range@7.1.1:
resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==}
engines: {node: '>=8'}
@@ -7900,6 +7923,9 @@ packages:
get-tsconfig@4.13.0:
resolution: {integrity: sha512-1VKTZJCwBrvbd+Wn3AOgQP/2Av+TfTCOlE4AcRJE72W1ksZXbAx8PPBR9RzgTeSPzlPMHrbANMH3LbltH73wxQ==}
github-from-package@0.0.0:
resolution: {integrity: sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==}
github-slugger@1.5.0:
resolution: {integrity: sha512-wIh+gKBI9Nshz2o46B0B3f5k/W+WI9ZAv6y5Dn5WJ5SK1t0TnDimB4WE5rmTD05ZAIn8HALCZVmCsvj0w0v0lw==}
@@ -9531,6 +9557,9 @@ packages:
engines: {node: ^18 || >=20}
hasBin: true
napi-build-utils@2.0.0:
resolution: {integrity: sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==}
natural-compare-lite@1.4.0:
resolution: {integrity: sha512-Tj+HTDSJJKaZnfiuw+iaF9skdPpTo2GtEly5JHnWV/hfv2Qj/9RKsGISQtLh2ox3l5EAGw487hnBee0sIJ6v2g==}
@@ -9593,6 +9622,10 @@ packages:
no-case@3.0.4:
resolution: {integrity: sha512-fgAN3jGAh+RoxUGZHTSOLJIqUc2wmoBwGR4tbpNAKmmovFoWq0OdRkb0VkldReO2a2iBT/OEulG9XSUc10r3zg==}
node-abi@3.87.0:
resolution: {integrity: sha512-+CGM1L1CgmtheLcBuleyYOn7NWPVu0s0EJH2C4puxgEZb9h8QpR9G2dBfZJOAUhi7VQxuBPMd0hiISWcTyiYyQ==}
engines: {node: '>=10'}
node-abort-controller@3.1.1:
resolution: {integrity: sha512-AGK2yQKIjRuqnc6VkX2Xj5d+QW8xZ87pa1UK6yA6ouUyuxfHuMP6umE5QK7UmTeOAymo+Zx1Fxiuw9rVx8taHQ==}
@@ -10539,6 +10572,11 @@ packages:
potpack@2.1.0:
resolution: {integrity: sha512-pcaShQc1Shq0y+E7GqJqvZj8DTthWV1KeHGdi0Z6IAin2Oi3JnLCOfwnCo84qc+HAp52wT9nK9H7FAJp5a44GQ==}
prebuild-install@7.1.3:
resolution: {integrity: sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==}
engines: {node: '>=10'}
hasBin: true
prelude-ls@1.2.1:
resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==}
engines: {node: '>= 0.8.0'}
@@ -11245,6 +11283,9 @@ packages:
simple-get@3.1.1:
resolution: {integrity: sha512-CQ5LTKGfCpvE1K0n2us+kuMPbk/q0EKl82s4aheV9oXjFEz6W/Y7oQFVJuU6QG77hRT4Ghb5RURteF5vnWjupA==}
simple-get@4.0.1:
resolution: {integrity: sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==}
simple-icons@15.22.0:
resolution: {integrity: sha512-i/w5Ie4tENfGYbdCo2iJ+oies0vOFd8QXWHopKOUzudfLCvnmeheF2PpHp89Z2azpc+c2su3lMiWO/SpP+429A==}
engines: {node: '>=0.12.18'}
@@ -11941,6 +11982,9 @@ packages:
engines: {node: '>=18.0.0'}
hasBin: true
tunnel-agent@0.6.0:
resolution: {integrity: sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==}
tweetnacl@0.14.5:
resolution: {integrity: sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA==}
@@ -17855,6 +17899,10 @@ snapshots:
dependencies:
'@types/node': 24.10.9
'@types/better-sqlite3@7.6.13':
dependencies:
'@types/node': 24.10.9
'@types/body-parser@1.19.6':
dependencies:
'@types/connect': 3.4.38
@@ -19039,10 +19087,23 @@ snapshots:
transitivePeerDependencies:
- supports-color
better-sqlite3@12.6.2:
dependencies:
bindings: 1.5.0
node-addon-api: 8.5.0
node-gyp: 12.1.0
prebuild-install: 7.1.3
transitivePeerDependencies:
- supports-color
big.js@5.2.2: {}
binary-extensions@2.3.0: {}
bindings@1.5.0:
dependencies:
file-uri-to-path: 1.0.0
bits-ui@2.14.4(@internationalized/date@3.10.0)(@sveltejs/kit@2.49.5(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.48.0)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.48.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.48.0):
dependencies:
'@floating-ui/core': 1.7.3
@@ -20808,6 +20869,8 @@ snapshots:
optionalDependencies:
exiftool-vendored.exe: 13.45.0
expand-template@2.0.3: {}
expect-type@1.3.0: {}
exponential-backoff@3.1.3: {}
@@ -20985,6 +21048,8 @@ snapshots:
transitivePeerDependencies:
- supports-color
file-uri-to-path@1.0.0: {}
fill-range@7.1.1:
dependencies:
to-regex-range: 5.0.1
@@ -21201,6 +21266,8 @@ snapshots:
dependencies:
resolve-pkg-maps: 1.0.0
github-from-package@0.0.0: {}
github-slugger@1.5.0: {}
gl-matrix@3.4.4: {}
@@ -23316,6 +23383,8 @@ snapshots:
nanoid@5.1.6: {}
napi-build-utils@2.0.0: {}
natural-compare-lite@1.4.0: {}
natural-compare@1.4.0: {}
@@ -23380,6 +23449,10 @@ snapshots:
lower-case: 2.0.2
tslib: 2.8.1
node-abi@3.87.0:
dependencies:
semver: 7.7.3
node-abort-controller@3.1.1: {}
node-addon-api@4.3.0: {}
@@ -24368,6 +24441,21 @@ snapshots:
potpack@2.1.0: {}
prebuild-install@7.1.3:
dependencies:
detect-libc: 2.1.2
expand-template: 2.0.3
github-from-package: 0.0.0
minimist: 1.2.8
mkdirp-classic: 0.5.3
napi-build-utils: 2.0.0
node-abi: 3.87.0
pump: 3.0.3
rc: 1.2.8
simple-get: 4.0.1
tar-fs: 2.1.4
tunnel-agent: 0.6.0
prelude-ls@1.2.1: {}
prettier-linter-helpers@1.0.1:
@@ -25304,8 +25392,7 @@ snapshots:
signal-exit@4.1.0: {}
simple-concat@1.0.1:
optional: true
simple-concat@1.0.1: {}
simple-get@3.1.1:
dependencies:
@@ -25314,6 +25401,12 @@ snapshots:
simple-concat: 1.0.1
optional: true
simple-get@4.0.1:
dependencies:
decompress-response: 6.0.0
once: 1.4.0
simple-concat: 1.0.1
simple-icons@15.22.0: {}
simple-icons@16.4.0: {}
@@ -26150,6 +26243,10 @@ snapshots:
optionalDependencies:
fsevents: 2.3.3
tunnel-agent@0.6.0:
dependencies:
safe-buffer: 5.2.1
tweetnacl@0.14.5: {}
type-check@0.4.0:

View File

@@ -29,6 +29,7 @@ onlyBuiltDependencies:
- sharp
- '@tailwindcss/oxide'
- bcrypt
- better-sqlite3
overrides:
canvas: 2.11.2
sharp: ^0.34.5
@@ -59,6 +60,10 @@ packageExtensions:
dependencies:
node-addon-api: '*'
node-gyp: '*'
better-sqlite3:
dependencies:
node-addon-api: '*'
node-gyp: '*'
dedupePeerDependents: false
preferWorkspacePackages: true
injectWorkspacePackages: true

View File

@@ -3,19 +3,19 @@ FROM ghcr.io/immich-app/base-server-dev:202601131104@sha256:8d907eb3fe10dba4a1e0
ENV COREPACK_ENABLE_DOWNLOAD_PROMPT=0 \
CI=1 \
COREPACK_HOME=/tmp \
PNPM_HOME=/buildcache/pnpm-store
COREPACK_HOME=/tmp
RUN npm install --global corepack@latest && \
corepack enable pnpm && \
echo "devdir=/buildcache/node-gyp" >> /usr/local/etc/npmrc && \
echo "store-dir=/buildcache/pnpm-store" >> /usr/local/etc/npmrc && \
echo "cache-dir=/buildcache/pnpm-cache" >> /usr/local/etc/npmrc && \
echo "# Retry configuration - default is 2" >> /usr/local/etc/npmrc && \
echo "fetch-retries=5" >> /usr/local/etc/npmrc && \
mkdir -p /buildcache/pnpm-store /buildcache/pnpm-cache /buildcache/node-gyp && \
chmod -R o+rw /buildcache
echo "devdir=/buildcache/node-gyp" >> /usr/local/etc/npmrc
COPY ./package* ./pnpm* .pnpmfile.cjs /tmp/create-dep-cache/
COPY ./web/package* ./web/pnpm* /tmp/create-dep-cache/web/
COPY ./server/package* ./server/pnpm* /tmp/create-dep-cache/server/
COPY ./open-api/typescript-sdk/package* ./open-api/typescript-sdk/pnpm* /tmp/create-dep-cache/open-api/typescript-sdk/
WORKDIR /tmp/create-dep-cache
RUN pnpm fetch && rm -rf /tmp/create-dep-cache && chmod -R o+rw /buildcache
WORKDIR /usr/src/app
ENV PATH="${PATH}:/usr/src/app/server/bin:/usr/src/app/web/bin" \

View File

@@ -6,4 +6,4 @@ if [[ "$IMMICH_ENV" == "production" ]]; then
fi
cd /usr/src/app || exit
pnpm --filter immich exec nest start --debug "0.0.0.0:9230" --watch -- "$@"
exec pnpm --filter immich exec nest start --no-shell --debug "0.0.0.0:9230" --watch -- "$@"

View File

@@ -1,6 +1,6 @@
{
"name": "immich",
"version": "2.5.3",
"version": "2.5.2",
"description": "",
"author": "",
"private": true,
@@ -61,6 +61,7 @@
"archiver": "^7.0.0",
"async-lock": "^1.4.0",
"bcrypt": "^6.0.0",
"better-sqlite3": "^12.6.2",
"body-parser": "^2.2.0",
"bullmq": "^5.51.0",
"chokidar": "^4.0.3",
@@ -124,6 +125,7 @@
"@types/archiver": "^7.0.0",
"@types/async-lock": "^1.4.2",
"@types/bcrypt": "^6.0.0",
"@types/better-sqlite3": "^7.6.13",
"@types/body-parser": "^1.19.6",
"@types/compression": "^1.7.5",
"@types/cookie-parser": "^1.4.8",

View File

@@ -89,8 +89,10 @@ export class BaseModule implements OnModuleInit, OnModuleDestroy {
}
async onModuleDestroy() {
console.log(`[${this.worker}] onModuleDestroy called - emitting AppShutdown`);
await this.eventRepository.emit('AppShutdown');
await teardownTelemetry();
console.log(`[${this.worker}] onModuleDestroy complete`);
}
}
@@ -148,6 +150,8 @@ export class ImmichAdminModule implements OnModuleDestroy {
constructor(private service: CliService) {}
async onModuleDestroy() {
console.log('[ImmichAdmin] onModuleDestroy called');
await this.service.cleanup();
console.log('[ImmichAdmin] onModuleDestroy complete');
}
}

View File

@@ -6,6 +6,10 @@ import {
PromptConfirmMoveQuestions,
PromptMediaLocationQuestions,
} from 'src/commands/media-location.command';
import {
MigrateThumbnailsCommand,
PromptConfirmMigrationQuestion,
} from 'src/commands/migrate-thumbnails.command';
import { DisableOAuthLogin, EnableOAuthLogin } from 'src/commands/oauth-login';
import { DisablePasswordLoginCommand, EnablePasswordLoginCommand } from 'src/commands/password-login';
import { PromptPasswordQuestions, ResetAdminPasswordCommand } from 'src/commands/reset-admin-password.command';
@@ -28,4 +32,6 @@ export const commandsAndQuestions = [
ChangeMediaLocationCommand,
PromptMediaLocationQuestions,
PromptConfirmMoveQuestions,
MigrateThumbnailsCommand,
PromptConfirmMigrationQuestion,
];

View File

@@ -0,0 +1,88 @@
import { Command, CommandRunner, InquirerService, Option, Question, QuestionSet } from 'nest-commander';
import { CliService } from 'src/services/cli.service';
@Command({
name: 'migrate-thumbnails-to-sqlite',
description: 'Migrate thumbnails from filesystem to SQLite storage',
})
export class MigrateThumbnailsCommand extends CommandRunner {
constructor(
private service: CliService,
private inquirer: InquirerService,
) {
super();
}
@Option({
flags: '-p, --path <path>',
description: 'Absolute path to the SQLite database file',
})
parsePath(value: string) {
return value;
}
@Option({
flags: '-y, --yes',
description: 'Skip confirmation prompt',
})
parseYes() {
return true;
}
async run(passedParams: string[], options: { path?: string; yes?: boolean }): Promise<void> {
try {
const sqlitePath = options.path ?? this.service.getDefaultThumbnailStoragePath();
console.log(`\nMigration settings:`);
console.log(` SQLite path: ${sqlitePath}`);
console.log(`\nThis will read all thumbnail files from the filesystem and store them in SQLite.`);
console.log(`Existing entries in SQLite will be skipped.\n`);
if (!options.yes) {
const { confirmed } = await this.inquirer.ask<{ confirmed: boolean }>('prompt-confirm-migration', {});
if (!confirmed) {
console.log('Migration cancelled.');
return;
}
}
console.log('\nStarting migration...\n');
let lastProgressUpdate = 0;
const result = await this.service.migrateThumbnailsToSqlite({
sqlitePath,
onProgress: ({ current, migrated, skipped, errors }) => {
const now = Date.now();
if (now - lastProgressUpdate > 500 || current === 1) {
lastProgressUpdate = now;
process.stdout.write(
`\rProcessed: ${current} | Migrated: ${migrated} | Skipped: ${skipped} | Errors: ${errors}`,
);
}
},
});
console.log(`\n\nMigration complete!`);
console.log(` Total processed: ${result.total}`);
console.log(` Migrated: ${result.migrated}`);
console.log(` Skipped: ${result.skipped}`);
console.log(` Errors: ${result.errors}`);
} catch (error) {
console.error(error);
console.error('Migration failed.');
}
}
}
@QuestionSet({ name: 'prompt-confirm-migration' })
export class PromptConfirmMigrationQuestion {
@Question({
message: 'Do you want to proceed with the migration? [Y/n]',
name: 'confirmed',
})
parseConfirmed(value: string): boolean {
return ['yes', 'y', ''].includes((value || 'y').toLowerCase());
}
}

View File

@@ -42,7 +42,7 @@ import { FileUploadInterceptor, getFiles } from 'src/middleware/file-upload.inte
import { LoggingRepository } from 'src/repositories/logging.repository';
import { AssetMediaService } from 'src/services/asset-media.service';
import { UploadFiles } from 'src/types';
import { ImmichFileResponse, sendFile } from 'src/utils/file';
import { ImmichBufferResponse, ImmichFileResponse, sendBuffer, sendFile } from 'src/utils/file';
import { FileNotEmptyValidator, UUIDParamDto } from 'src/validation';
@ApiTags(ApiTag.Assets)
@@ -163,26 +163,29 @@ export class AssetMediaController {
if (viewThumbnailRes instanceof ImmichFileResponse) {
await sendFile(res, next, () => Promise.resolve(viewThumbnailRes), this.logger);
} else {
// viewThumbnailRes is a AssetMediaRedirectResponse
// which redirects to the original asset or a specific size to make better use of caching
const { targetSize } = viewThumbnailRes;
const [reqPath, reqSearch] = req.url.split('?');
let redirPath: string;
const redirSearchParams = new URLSearchParams(reqSearch);
if (targetSize === 'original') {
// relative path to this.downloadAsset
redirPath = 'original';
redirSearchParams.delete('size');
} else if (Object.values(AssetMediaSize).includes(targetSize)) {
redirPath = reqPath;
redirSearchParams.set('size', targetSize);
} else {
throw new Error('Invalid targetSize: ' + targetSize);
}
const finalRedirPath = redirPath + '?' + redirSearchParams.toString();
return res.redirect(finalRedirPath);
return;
}
if (viewThumbnailRes instanceof ImmichBufferResponse) {
await sendBuffer(res, next, () => Promise.resolve(viewThumbnailRes), this.logger);
return;
}
const { targetSize } = viewThumbnailRes;
const [reqPath, reqSearch] = req.url.split('?');
let redirPath: string;
const redirSearchParams = new URLSearchParams(reqSearch);
if (targetSize === 'original') {
redirPath = 'original';
redirSearchParams.delete('size');
} else if (Object.values(AssetMediaSize).includes(targetSize)) {
redirPath = reqPath;
redirSearchParams.set('size', targetSize);
} else {
throw new Error('Invalid targetSize: ' + targetSize);
}
const finalRedirPath = redirPath + '?' + redirSearchParams.toString();
return res.redirect(finalRedirPath);
}
@Get(':id/video/playback')

View File

@@ -120,6 +120,10 @@ export class StorageCore {
);
}
static getThumbnailStoragePath(): string {
return join(StorageCore.getMediaLocation(), 'thumbnails.sqlite3');
}
static getEncodedVideoPath(asset: ThumbnailPathEntity) {
return StorageCore.getNestedPath(StorageFolder.EncodedVideo, asset.ownerId, `${asset.id}.mp4`);
}

View File

@@ -102,7 +102,7 @@ export class UpdateAlbumDto {
export class GetAlbumsDto {
@ValidateBoolean({
optional: true,
description: 'Filter by shared status: true = only shared, false = not shared, undefined = all owned albums',
description: 'Filter by shared status: true = only shared, false = only own, undefined = all',
})
shared?: boolean;

View File

@@ -140,6 +140,10 @@ export class EnvDto {
@Optional()
IMMICH_WORKERS_EXCLUDE?: string;
@Optional()
@Matches(/^\//, { message: 'IMMICH_THUMBNAIL_SQLITE_PATH must be an absolute path' })
IMMICH_THUMBNAIL_SQLITE_PATH?: string;
@IsString()
@Optional()
DB_DATABASE_NAME?: string;

View File

@@ -25,10 +25,16 @@ class Workers {
*/
restarting = false;
/**
* Whether we're in graceful shutdown
*/
shuttingDown = false;
/**
* Boot all enabled workers
*/
async bootstrap() {
this.setupSignalHandlers();
const isMaintenanceMode = await this.isMaintenanceMode();
const { workers } = new ConfigRepository().getEnv();
@@ -114,7 +120,12 @@ class Workers {
} else {
const worker = new Worker(workerFile);
kill = async () => void (await worker.terminate());
kill = () => {
// Post a shutdown message to allow graceful cleanup
worker.postMessage({ type: 'shutdown' });
// Force terminate after timeout if worker doesn't exit
setTimeout(() => void worker.terminate(), 5000);
};
anyWorker = worker;
}
@@ -124,17 +135,53 @@ class Workers {
this.workers[name] = { kill };
}
private setupSignalHandlers() {
const shutdown = async (signal: NodeJS.Signals) => {
if (this.shuttingDown) {
return;
}
this.shuttingDown = true;
console.log(`Received ${signal}, initiating graceful shutdown...`);
const workerNames = Object.keys(this.workers) as ImmichWorker[];
for (const name of workerNames) {
console.log(`Sending ${signal} to ${name} worker`);
await this.workers[name]?.kill(signal);
}
// Give workers time to shutdown gracefully
setTimeout(() => {
console.log('Shutdown timeout reached, forcing exit');
process.exit(0);
}, 10_000);
};
process.on('SIGTERM', () => void shutdown('SIGTERM'));
process.on('SIGINT', () => void shutdown('SIGINT'));
}
onError(name: ImmichWorker, error: Error) {
console.error(`${name} worker error: ${error}, stack: ${error.stack}`);
}
onExit(name: ImmichWorker, exitCode: number | null) {
console.log(`${name} worker exited with code ${exitCode}`);
delete this.workers[name];
// graceful shutdown in progress
if (this.shuttingDown) {
if (Object.keys(this.workers).length === 0) {
console.log('All workers have exited, shutting down main process');
process.exit(0);
}
return;
}
// restart immich server
if (exitCode === ExitCode.AppRestart || this.restarting) {
this.restarting = true;
console.info(`${name} worker shutdown for restart`);
delete this.workers[name];
// once all workers shut down, bootstrap again
if (Object.keys(this.workers).length === 0) {
@@ -145,11 +192,9 @@ class Workers {
return;
}
// shutdown the entire process
delete this.workers[name];
// unexpected exit - shutdown the entire process
if (exitCode !== 0) {
console.error(`${name} worker exited with code ${exitCode}`);
console.error(`${name} worker exited unexpectedly`);
if (this.workers[ImmichWorker.Api] && name !== ImmichWorker.Api) {
console.error('Killing api process');

View File

@@ -423,4 +423,13 @@ export class AssetJobRepository {
streamForMigrationJob() {
return this.db.selectFrom('asset').select(['id']).where('asset.deletedAt', 'is', null).stream();
}
@GenerateSql({ params: [], stream: true })
streamAllThumbnailFiles() {
return this.db
.selectFrom('asset_file')
.select(['asset_file.assetId', 'asset_file.type', 'asset_file.path', 'asset_file.isEdited'])
.where('asset_file.type', 'in', [AssetFileType.Thumbnail, AssetFileType.Preview])
.stream();
}
}

View File

@@ -107,6 +107,10 @@ export interface EnvData {
mediaLocation?: string;
};
thumbnailStorage: {
sqlitePath?: string;
};
workers: ImmichWorker[];
plugins: {
@@ -331,6 +335,10 @@ const getEnv = (): EnvData => {
mediaLocation: dto.IMMICH_MEDIA_LOCATION,
},
thumbnailStorage: {
sqlitePath: dto.IMMICH_THUMBNAIL_SQLITE_PATH,
},
telemetry: {
apiPort: dto.IMMICH_API_METRICS_PORT || 8081,
microservicesPort: dto.IMMICH_MICROSERVICES_METRICS_PORT || 8082,

View File

@@ -44,6 +44,7 @@ import { SyncRepository } from 'src/repositories/sync.repository';
import { SystemMetadataRepository } from 'src/repositories/system-metadata.repository';
import { TagRepository } from 'src/repositories/tag.repository';
import { TelemetryRepository } from 'src/repositories/telemetry.repository';
import { ThumbnailStorageRepository } from 'src/repositories/thumbnail-storage.repository';
import { TrashRepository } from 'src/repositories/trash.repository';
import { UserRepository } from 'src/repositories/user.repository';
import { VersionHistoryRepository } from 'src/repositories/version-history.repository';
@@ -98,6 +99,7 @@ export const repositories = [
SystemMetadataRepository,
TagRepository,
TelemetryRepository,
ThumbnailStorageRepository,
TrashRepository,
UserRepository,
ViewRepository,

View File

@@ -2,7 +2,7 @@ import sharp from 'sharp';
import { AssetFace } from 'src/database';
import { AssetEditAction, MirrorAxis } from 'src/dtos/editing.dto';
import { AssetOcrResponseDto } from 'src/dtos/ocr.dto';
import { SourceType } from 'src/enum';
import { ImageFormat, SourceType } from 'src/enum';
import { LoggingRepository } from 'src/repositories/logging.repository';
import { BoundingBox } from 'src/repositories/machine-learning.repository';
import { MediaRepository } from 'src/repositories/media.repository';
@@ -664,4 +664,51 @@ describe(MediaRepository.name, () => {
});
});
});
describe('generateThumbnailToBuffer', () => {
it('should return a buffer instead of writing to file', async () => {
const inputBuffer = await sharp({
create: { width: 100, height: 100, channels: 3, background: { r: 255, g: 0, b: 0 } },
})
.png()
.toBuffer();
const result = await sut.generateThumbnailToBuffer(inputBuffer, {
format: ImageFormat.Webp,
quality: 80,
size: 50,
colorspace: 'srgb',
processInvalidImages: false,
});
expect(result).toBeInstanceOf(Buffer);
expect(result.length).toBeGreaterThan(0);
const metadata = await sharp(result).metadata();
expect(metadata.format).toBe('webp');
expect(metadata.width).toBeLessThanOrEqual(50);
});
it('should apply same options as generateThumbnail', async () => {
const inputBuffer = await sharp({
create: { width: 200, height: 200, channels: 3, background: { r: 0, g: 255, b: 0 } },
})
.png()
.toBuffer();
const result = await sut.generateThumbnailToBuffer(inputBuffer, {
format: ImageFormat.Jpeg,
quality: 90,
size: 75,
colorspace: 'srgb',
processInvalidImages: false,
progressive: true,
});
const metadata = await sharp(result).metadata();
expect(metadata.format).toBe('jpeg');
expect(metadata.width).toBe(75);
expect(metadata.height).toBe(75);
});
});
});

View File

@@ -182,6 +182,17 @@ export class MediaRepository {
await decoded.toFile(output);
}
async generateThumbnailToBuffer(input: string | Buffer, options: GenerateThumbnailOptions): Promise<Buffer> {
const pipeline = await this.getImageDecodingPipeline(input, options);
return pipeline
.toFormat(options.format, {
quality: options.quality,
chromaSubsampling: options.quality >= 80 ? '4:4:4' : '4:2:0',
progressive: options.progressive,
})
.toBuffer();
}
private async getImageDecodingPipeline(input: string | Buffer, options: DecodeToBufferOptions) {
let pipeline = sharp(input, {
// some invalid images can still be processed by sharp, but we want to fail on them by default to avoid crashes

View File

@@ -0,0 +1,305 @@
import Database from 'better-sqlite3';
import { randomUUID } from 'node:crypto';
import { existsSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { AssetFileType } from 'src/enum';
import { LoggingRepository } from 'src/repositories/logging.repository';
import { ThumbnailStorageRepository } from 'src/repositories/thumbnail-storage.repository';
import { automock } from 'test/utils';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
describe(ThumbnailStorageRepository.name, () => {
let sut: ThumbnailStorageRepository;
let testDbPath: string;
beforeEach(() => {
testDbPath = join(tmpdir(), `immich-test-thumbnails-${randomUUID()}.db`);
const logger = automock(LoggingRepository, { args: [undefined, { getEnv: () => ({}) }], strict: false });
sut = new ThumbnailStorageRepository(logger);
});
afterEach(() => {
sut.close();
if (existsSync(testDbPath)) {
rmSync(testDbPath);
}
});
describe('initialize', () => {
it('should create database and schema', () => {
sut.initialize(testDbPath);
expect(sut.isEnabled()).toBe(true);
const db = new Database(testDbPath, { readonly: true });
const tables = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='thumbnails'").all();
db.close();
expect(tables).toHaveLength(1);
});
it('should enable WAL mode', () => {
sut.initialize(testDbPath);
const db = new Database(testDbPath, { readonly: true });
const result = db.prepare('PRAGMA journal_mode').get() as { journal_mode: string };
db.close();
expect(result.journal_mode).toBe('wal');
});
});
describe('store', () => {
const assetId = randomUUID();
const testData = Buffer.from('test thumbnail data');
const mimeType = 'image/webp';
beforeEach(() => {
sut.initialize(testDbPath);
});
it('should store thumbnail data', async () => {
await sut.store({
assetId,
type: AssetFileType.Thumbnail,
isEdited: false,
data: testData,
mimeType,
});
const result = await sut.get(assetId, AssetFileType.Thumbnail, false);
expect(result).not.toBeNull();
expect(result!.data).toEqual(testData);
expect(result!.mimeType).toBe(mimeType);
expect(result!.size).toBe(testData.length);
});
it('should replace existing thumbnail on conflict', async () => {
const newData = Buffer.from('updated thumbnail data');
await sut.store({
assetId,
type: AssetFileType.Thumbnail,
isEdited: false,
data: testData,
mimeType,
});
await sut.store({
assetId,
type: AssetFileType.Thumbnail,
isEdited: false,
data: newData,
mimeType,
});
const result = await sut.get(assetId, AssetFileType.Thumbnail, false);
expect(result!.data).toEqual(newData);
expect(result!.size).toBe(newData.length);
});
it('should store with correct mime type and size', async () => {
const jpegData = Buffer.from('jpeg thumbnail data with different size');
const jpegMimeType = 'image/jpeg';
await sut.store({
assetId,
type: AssetFileType.Preview,
isEdited: false,
data: jpegData,
mimeType: jpegMimeType,
});
const result = await sut.get(assetId, AssetFileType.Preview, false);
expect(result!.mimeType).toBe(jpegMimeType);
expect(result!.size).toBe(jpegData.length);
});
});
describe('get', () => {
const assetId = randomUUID();
const testData = Buffer.from('test thumbnail data');
const mimeType = 'image/webp';
beforeEach(async () => {
sut.initialize(testDbPath);
await sut.store({
assetId,
type: AssetFileType.Thumbnail,
isEdited: false,
data: testData,
mimeType,
});
});
it('should return stored thumbnail data', async () => {
const result = await sut.get(assetId, AssetFileType.Thumbnail, false);
expect(result).not.toBeNull();
expect(result!.data).toEqual(testData);
expect(result!.mimeType).toBe(mimeType);
});
it('should return null for non-existent thumbnail', async () => {
const result = await sut.get(randomUUID(), AssetFileType.Thumbnail, false);
expect(result).toBeNull();
});
it('should distinguish between edited and non-edited', async () => {
const editedData = Buffer.from('edited thumbnail data');
await sut.store({
assetId,
type: AssetFileType.Thumbnail,
isEdited: true,
data: editedData,
mimeType,
});
const nonEditedResult = await sut.get(assetId, AssetFileType.Thumbnail, false);
const editedResult = await sut.get(assetId, AssetFileType.Thumbnail, true);
expect(nonEditedResult!.data).toEqual(testData);
expect(editedResult!.data).toEqual(editedData);
});
it('should distinguish between different thumbnail types', async () => {
const previewData = Buffer.from('preview data');
await sut.store({
assetId,
type: AssetFileType.Preview,
isEdited: false,
data: previewData,
mimeType,
});
const thumbnailResult = await sut.get(assetId, AssetFileType.Thumbnail, false);
const previewResult = await sut.get(assetId, AssetFileType.Preview, false);
expect(thumbnailResult!.data).toEqual(testData);
expect(previewResult!.data).toEqual(previewData);
});
it('should fall back to non-edited when edited is requested but not found', async () => {
const result = await sut.get(assetId, AssetFileType.Thumbnail, true);
expect(result).not.toBeNull();
expect(result!.data).toEqual(testData);
});
it('should return edited when both exist and edited is requested', async () => {
const editedData = Buffer.from('edited thumbnail data');
await sut.store({
assetId,
type: AssetFileType.Thumbnail,
isEdited: true,
data: editedData,
mimeType,
});
const result = await sut.get(assetId, AssetFileType.Thumbnail, true);
expect(result!.data).toEqual(editedData);
});
it('should not fall back to edited when non-edited is requested but not found', async () => {
const newAssetId = randomUUID();
const editedData = Buffer.from('edited only data');
await sut.store({
assetId: newAssetId,
type: AssetFileType.Thumbnail,
isEdited: true,
data: editedData,
mimeType,
});
const result = await sut.get(newAssetId, AssetFileType.Thumbnail, false);
expect(result).toBeNull();
});
});
describe('delete', () => {
const assetId = randomUUID();
const testData = Buffer.from('test thumbnail data');
const mimeType = 'image/webp';
beforeEach(async () => {
sut.initialize(testDbPath);
await sut.store({
assetId,
type: AssetFileType.Thumbnail,
isEdited: false,
data: testData,
mimeType,
});
await sut.store({
assetId,
type: AssetFileType.Preview,
isEdited: false,
data: testData,
mimeType,
});
});
it('should delete specific thumbnail', async () => {
await sut.delete(assetId, AssetFileType.Thumbnail, false);
const result = await sut.get(assetId, AssetFileType.Thumbnail, false);
expect(result).toBeNull();
});
it('should not affect other thumbnails for same asset', async () => {
await sut.delete(assetId, AssetFileType.Thumbnail, false);
const previewResult = await sut.get(assetId, AssetFileType.Preview, false);
expect(previewResult).not.toBeNull();
});
});
describe('deleteByAsset', () => {
const assetId = randomUUID();
const otherAssetId = randomUUID();
const testData = Buffer.from('test thumbnail data');
const mimeType = 'image/webp';
beforeEach(async () => {
sut.initialize(testDbPath);
await sut.store({ assetId, type: AssetFileType.Thumbnail, isEdited: false, data: testData, mimeType });
await sut.store({ assetId, type: AssetFileType.Preview, isEdited: false, data: testData, mimeType });
await sut.store({ assetId, type: AssetFileType.Thumbnail, isEdited: true, data: testData, mimeType });
await sut.store({ assetId: otherAssetId, type: AssetFileType.Thumbnail, isEdited: false, data: testData, mimeType });
});
it('should delete all thumbnails for an asset', async () => {
await sut.deleteByAsset(assetId);
expect(await sut.get(assetId, AssetFileType.Thumbnail, false)).toBeNull();
expect(await sut.get(assetId, AssetFileType.Preview, false)).toBeNull();
expect(await sut.get(assetId, AssetFileType.Thumbnail, true)).toBeNull();
});
it('should not affect other assets', async () => {
await sut.deleteByAsset(assetId);
const otherAssetResult = await sut.get(otherAssetId, AssetFileType.Thumbnail, false);
expect(otherAssetResult).not.toBeNull();
});
});
describe('isEnabled', () => {
it('should return false when database is not initialized', () => {
expect(sut.isEnabled()).toBe(false);
});
it('should return true when database is initialized', () => {
sut.initialize(testDbPath);
expect(sut.isEnabled()).toBe(true);
});
});
});

View File

@@ -0,0 +1,160 @@
import { Injectable, OnModuleDestroy } from '@nestjs/common';
import Database, { Database as DatabaseType, Statement } from 'better-sqlite3';
import { AssetFileType } from 'src/enum';
import { LoggingRepository } from 'src/repositories/logging.repository';
export interface ThumbnailData {
assetId: string;
type: AssetFileType;
isEdited: boolean;
data: Buffer;
mimeType: string;
}
export interface ThumbnailResult {
data: Buffer;
mimeType: string;
size: number;
}
interface ThumbnailRow {
data: Buffer;
mime_type: string;
size: number;
}
@Injectable()
export class ThumbnailStorageRepository implements OnModuleDestroy {
private database: DatabaseType | null = null;
private insertStatement: Statement | null = null;
private selectStatement: Statement | null = null;
private deleteStatement: Statement | null = null;
private deleteByAssetStatement: Statement | null = null;
constructor(private logger: LoggingRepository) {
this.logger.setContext(ThumbnailStorageRepository.name);
}
initialize(databasePath: string): void {
this.database = new Database(databasePath);
this.database.pragma('page_size = 32768');
this.database.pragma('journal_mode = WAL');
this.database.pragma('synchronous = NORMAL');
this.database.pragma('cache_size = -131072');
this.database.pragma('mmap_size = 2147483648');
this.database.pragma('temp_store = MEMORY');
this.database.pragma('wal_autocheckpoint = 10000');
this.database.exec(`
CREATE TABLE IF NOT EXISTS thumbnails (
asset_id TEXT NOT NULL,
type TEXT NOT NULL,
is_edited INTEGER NOT NULL DEFAULT 0,
data BLOB NOT NULL,
mime_type TEXT NOT NULL,
size INTEGER NOT NULL,
PRIMARY KEY (asset_id, type, is_edited)
)
`);
this.insertStatement = this.database.prepare(`
INSERT OR REPLACE INTO thumbnails (asset_id, type, is_edited, data, mime_type, size)
VALUES (?, ?, ?, ?, ?, ?)
`);
this.selectStatement = this.database.prepare(`
SELECT data, mime_type, size FROM thumbnails
WHERE asset_id = ? AND type = ? AND is_edited = ?
`);
this.deleteStatement = this.database.prepare(`
DELETE FROM thumbnails WHERE asset_id = ? AND type = ? AND is_edited = ?
`);
this.deleteByAssetStatement = this.database.prepare(`
DELETE FROM thumbnails WHERE asset_id = ?
`);
this.logger.log(`SQLite thumbnail storage initialized at ${databasePath}`);
}
isEnabled(): boolean {
return this.database !== null;
}
store(thumbnail: ThumbnailData): void {
if (!this.insertStatement) {
throw new Error('SQLite thumbnail storage not initialized');
}
const isEditedInt = thumbnail.isEdited ? 1 : 0;
this.insertStatement.run(
thumbnail.assetId,
thumbnail.type,
isEditedInt,
thumbnail.data,
thumbnail.mimeType,
thumbnail.data.length,
);
}
get(assetId: string, type: AssetFileType, isEdited: boolean): ThumbnailResult | null {
if (!this.selectStatement) {
return null;
}
const isEditedInt = isEdited ? 1 : 0;
let result = this.selectStatement.get(assetId, type, isEditedInt) as ThumbnailRow | undefined;
if (!result && isEdited) {
result = this.selectStatement.get(assetId, type, 0) as ThumbnailRow | undefined;
}
if (!result) {
return null;
}
return {
data: result.data,
mimeType: result.mime_type,
size: result.size,
};
}
delete(assetId: string, type: AssetFileType, isEdited: boolean): void {
if (!this.deleteStatement) {
return;
}
const isEditedInt = isEdited ? 1 : 0;
this.deleteStatement.run(assetId, type, isEditedInt);
}
deleteByAsset(assetId: string): void {
if (!this.deleteByAssetStatement) {
return;
}
this.deleteByAssetStatement.run(assetId);
}
close(): void {
if (this.database) {
this.logger.log('Closing SQLite thumbnail storage database');
this.database.pragma('wal_checkpoint(TRUNCATE)');
this.database.close();
this.database = null;
this.insertStatement = null;
this.selectStatement = null;
this.deleteStatement = null;
this.deleteByAssetStatement = null;
this.logger.log('SQLite thumbnail storage database closed');
}
}
onModuleDestroy(): void {
this.logger.log('onModuleDestroy called - closing SQLite thumbnail storage');
this.close();
}
}

View File

@@ -14,7 +14,7 @@ import { AuthRequest } from 'src/middleware/auth.guard';
import { AssetMediaService } from 'src/services/asset-media.service';
import { UploadBody } from 'src/types';
import { ASSET_CHECKSUM_CONSTRAINT } from 'src/utils/database';
import { ImmichFileResponse } from 'src/utils/file';
import { ImmichBufferResponse, ImmichFileResponse } from 'src/utils/file';
import { assetStub } from 'test/fixtures/asset.stub';
import { authStub } from 'test/fixtures/auth.stub';
import { fileStub } from 'test/fixtures/file.stub';
@@ -762,6 +762,128 @@ describe(AssetMediaService.name, () => {
);
expect(mocks.asset.getForThumbnail).toHaveBeenCalledWith(assetStub.image.id, AssetFileType.Thumbnail, true);
});
describe('with SQLite storage', () => {
it('should return ImmichBufferResponse when thumbnail exists in SQLite', async () => {
const thumbnailData = Buffer.from('thumbnail-data');
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.image.id]));
mocks.thumbnailStorage.isEnabled.mockReturnValue(true);
mocks.thumbnailStorage.get.mockReturnValue({
data: thumbnailData,
mimeType: 'image/webp',
size: thumbnailData.length,
});
await expect(
sut.viewThumbnail(authStub.admin, assetStub.image.id, { size: AssetMediaSize.THUMBNAIL }),
).resolves.toEqual(
new ImmichBufferResponse({
data: thumbnailData,
contentType: 'image/webp',
cacheControl: CacheControl.PrivateWithCache,
fileName: 'asset-id_thumbnail.webp',
}),
);
expect(mocks.thumbnailStorage.get).toHaveBeenCalledWith(assetStub.image.id, AssetFileType.Thumbnail, false);
expect(mocks.asset.getForThumbnail).not.toHaveBeenCalled();
});
it('should fall back to filesystem when thumbnail not in SQLite', async () => {
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.image.id]));
mocks.thumbnailStorage.isEnabled.mockReturnValue(true);
mocks.thumbnailStorage.get.mockReturnValue(null);
mocks.asset.getForThumbnail.mockResolvedValue({
...assetStub.image,
path: '/uploads/user-id/thumbs/path.jpg',
});
await expect(
sut.viewThumbnail(authStub.admin, assetStub.image.id, { size: AssetMediaSize.THUMBNAIL }),
).resolves.toEqual(
new ImmichFileResponse({
path: '/uploads/user-id/thumbs/path.jpg',
cacheControl: CacheControl.PrivateWithCache,
contentType: 'image/jpeg',
fileName: 'asset-id_thumbnail.jpg',
}),
);
expect(mocks.thumbnailStorage.get).toHaveBeenCalledWith(assetStub.image.id, AssetFileType.Thumbnail, false);
expect(mocks.asset.getForThumbnail).toHaveBeenCalled();
});
it('should handle edited thumbnails from SQLite', async () => {
const thumbnailData = Buffer.from('edited-thumbnail-data');
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.image.id]));
mocks.thumbnailStorage.isEnabled.mockReturnValue(true);
mocks.thumbnailStorage.get.mockReturnValue({
data: thumbnailData,
mimeType: 'image/webp',
size: thumbnailData.length,
});
await expect(
sut.viewThumbnail(authStub.admin, assetStub.image.id, { size: AssetMediaSize.THUMBNAIL, edited: true }),
).resolves.toEqual(
new ImmichBufferResponse({
data: thumbnailData,
contentType: 'image/webp',
cacheControl: CacheControl.PrivateWithCache,
fileName: 'asset-id_thumbnail.webp',
}),
);
expect(mocks.thumbnailStorage.get).toHaveBeenCalledWith(assetStub.image.id, AssetFileType.Thumbnail, true);
});
it('should handle preview size from SQLite', async () => {
const previewData = Buffer.from('preview-data');
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.image.id]));
mocks.thumbnailStorage.isEnabled.mockReturnValue(true);
mocks.thumbnailStorage.get.mockReturnValue({
data: previewData,
mimeType: 'image/jpeg',
size: previewData.length,
});
await expect(
sut.viewThumbnail(authStub.admin, assetStub.image.id, { size: AssetMediaSize.PREVIEW }),
).resolves.toEqual(
new ImmichBufferResponse({
data: previewData,
contentType: 'image/jpeg',
cacheControl: CacheControl.PrivateWithCache,
fileName: 'asset-id_preview.jpg',
}),
);
expect(mocks.thumbnailStorage.get).toHaveBeenCalledWith(assetStub.image.id, AssetFileType.Preview, false);
});
it('should skip SQLite lookup when storage is disabled', async () => {
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.image.id]));
mocks.thumbnailStorage.isEnabled.mockReturnValue(false);
mocks.asset.getForThumbnail.mockResolvedValue({
...assetStub.image,
path: '/uploads/user-id/thumbs/path.jpg',
});
await expect(
sut.viewThumbnail(authStub.admin, assetStub.image.id, { size: AssetMediaSize.THUMBNAIL }),
).resolves.toEqual(
new ImmichFileResponse({
path: '/uploads/user-id/thumbs/path.jpg',
cacheControl: CacheControl.PrivateWithCache,
contentType: 'image/jpeg',
fileName: 'asset-id_thumbnail.jpg',
}),
);
expect(mocks.thumbnailStorage.get).not.toHaveBeenCalled();
expect(mocks.asset.getForThumbnail).toHaveBeenCalled();
});
});
});
describe('playbackVideo', () => {

View File

@@ -37,7 +37,12 @@ import { UploadFile, UploadRequest } from 'src/types';
import { requireUploadAccess } from 'src/utils/access';
import { asUploadRequest, onBeforeLink } from 'src/utils/asset.util';
import { isAssetChecksumConstraint } from 'src/utils/database';
import { getFilenameExtension, getFileNameWithoutExtension, ImmichFileResponse } from 'src/utils/file';
import {
getFilenameExtension,
getFileNameWithoutExtension,
ImmichBufferResponse,
ImmichFileResponse,
} from 'src/utils/file';
import { mimeTypes } from 'src/utils/mime-types';
import { fromChecksum } from 'src/utils/request';
@@ -219,7 +224,7 @@ export class AssetMediaService extends BaseService {
auth: AuthDto,
id: string,
dto: AssetMediaOptionsDto,
): Promise<ImmichFileResponse | AssetMediaRedirectResponse> {
): Promise<ImmichFileResponse | ImmichBufferResponse | AssetMediaRedirectResponse> {
await this.requireAccess({ auth, permission: Permission.AssetView, ids: [id] });
if (dto.size === AssetMediaSize.Original) {
@@ -231,20 +236,30 @@ export class AssetMediaService extends BaseService {
}
const size = (dto.size ?? AssetMediaSize.THUMBNAIL) as unknown as AssetFileType;
const { originalPath, originalFileName, path } = await this.assetRepository.getForThumbnail(
id,
size,
dto.edited ?? false,
);
const isEdited = dto.edited ?? false;
if (this.thumbnailStorageRepository.isEnabled()) {
const thumbnail = this.thumbnailStorageRepository.get(id, size, isEdited);
if (thumbnail) {
// this.logger.log(`Thumbnail served from SQLite: assetId=${id}, type=${size}`);
const extension = mimeTypes.toExtension(thumbnail.mimeType) || '';
const fileName = `${id}_${size}${extension}`;
return new ImmichBufferResponse({
data: thumbnail.data,
contentType: thumbnail.mimeType,
cacheControl: CacheControl.PrivateWithCache,
fileName,
});
}
}
const { originalPath, originalFileName, path } = await this.assetRepository.getForThumbnail(id, size, isEdited);
if (size === AssetFileType.FullSize && mimeTypes.isWebSupportedImage(originalPath) && !dto.edited) {
// use original file for web supported images
return { targetSize: 'original' };
}
if (dto.size === AssetMediaSize.FULLSIZE && !path) {
// downgrade to preview if fullsize is not available.
// e.g. disabled or not yet (re)generated
return { targetSize: AssetMediaSize.PREVIEW };
}

View File

@@ -51,6 +51,7 @@ import { SyncRepository } from 'src/repositories/sync.repository';
import { SystemMetadataRepository } from 'src/repositories/system-metadata.repository';
import { TagRepository } from 'src/repositories/tag.repository';
import { TelemetryRepository } from 'src/repositories/telemetry.repository';
import { ThumbnailStorageRepository } from 'src/repositories/thumbnail-storage.repository';
import { TrashRepository } from 'src/repositories/trash.repository';
import { UserRepository } from 'src/repositories/user.repository';
import { VersionHistoryRepository } from 'src/repositories/version-history.repository';
@@ -108,6 +109,7 @@ export const BASE_SERVICE_DEPENDENCIES = [
SystemMetadataRepository,
TagRepository,
TelemetryRepository,
ThumbnailStorageRepository,
TrashRepository,
UserRepository,
VersionHistoryRepository,
@@ -167,6 +169,7 @@ export class BaseService {
protected systemMetadataRepository: SystemMetadataRepository,
protected tagRepository: TagRepository,
protected telemetryRepository: TelemetryRepository,
protected thumbnailStorageRepository: ThumbnailStorageRepository,
protected trashRepository: TrashRepository,
protected userRepository: UserRepository,
protected versionRepository: VersionHistoryRepository,

View File

@@ -1,12 +1,14 @@
import { Injectable } from '@nestjs/common';
import { isAbsolute } from 'node:path';
import { SALT_ROUNDS } from 'src/constants';
import { StorageCore } from 'src/cores/storage.core';
import { MaintenanceAuthDto } from 'src/dtos/maintenance.dto';
import { UserAdminResponseDto, mapUserAdmin } from 'src/dtos/user.dto';
import { MaintenanceAction, SystemMetadataKey } from 'src/enum';
import { AssetFileType, MaintenanceAction, SystemMetadataKey } from 'src/enum';
import { BaseService } from 'src/services/base.service';
import { createMaintenanceLoginUrl, generateMaintenanceSecret } from 'src/utils/maintenance';
import { getExternalDomain } from 'src/utils/misc';
import { mimeTypes } from 'src/utils/mime-types';
@Injectable()
export class CliService extends BaseService {
@@ -186,4 +188,88 @@ export class CliService extends BaseService {
cleanup() {
return this.databaseRepository.shutdown();
}
getDefaultThumbnailStoragePath(): string {
const envData = this.configRepository.getEnv();
if (envData.thumbnailStorage.sqlitePath) {
return envData.thumbnailStorage.sqlitePath;
}
const mediaLocation = envData.storage.mediaLocation ?? this.detectMediaLocation();
StorageCore.setMediaLocation(mediaLocation);
return StorageCore.getThumbnailStoragePath();
}
private detectMediaLocation(): string {
const candidates = ['/data', '/usr/src/app/upload'];
for (const candidate of candidates) {
if (this.storageRepository.existsSync(candidate)) {
return candidate;
}
}
return '/usr/src/app/upload';
}
async migrateThumbnailsToSqlite(options: {
sqlitePath: string;
onProgress: (progress: { current: number; migrated: number; skipped: number; errors: number }) => void;
}): Promise<{ total: number; migrated: number; skipped: number; errors: number }> {
const { sqlitePath, onProgress } = options;
if (!isAbsolute(sqlitePath)) {
throw new Error('SQLite path must be an absolute path');
}
this.thumbnailStorageRepository.initialize(sqlitePath);
let current = 0;
let migrated = 0;
let skipped = 0;
let errors = 0;
for await (const file of this.assetJobRepository.streamAllThumbnailFiles()) {
current++;
try {
const existingData = this.thumbnailStorageRepository.get(
file.assetId,
file.type as AssetFileType,
file.isEdited,
);
if (existingData) {
skipped++;
onProgress({ current, migrated, skipped, errors });
continue;
}
const fileExists = await this.storageRepository.checkFileExists(file.path);
if (!fileExists) {
skipped++;
onProgress({ current, migrated, skipped, errors });
continue;
}
const data = await this.storageRepository.readFile(file.path);
const mimeType = mimeTypes.lookup(file.path) || 'image/jpeg';
this.thumbnailStorageRepository.store({
assetId: file.assetId,
type: file.type as AssetFileType,
isEdited: file.isEdited,
data,
mimeType,
});
migrated++;
} catch (error) {
errors++;
this.logger.error(`Failed to migrate thumbnail for asset ${file.assetId}: ${error}`);
}
onProgress({ current, migrated, skipped, errors });
}
return { total: current, migrated, skipped, errors };
}
}

View File

@@ -1128,6 +1128,82 @@ describe(MediaService.name, () => {
expect.stringContaining('fullsize.jpeg'),
);
});
describe('with SQLite storage enabled', () => {
beforeEach(() => {
mocks.thumbnailStorage.isEnabled.mockReturnValue(true);
});
it('should store thumbnail in SQLite when enabled', async () => {
const thumbnailBuffer = Buffer.from('thumbnail-data');
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(assetStub.image);
mocks.media.generateThumbnailToBuffer.mockResolvedValue(thumbnailBuffer);
await sut.handleGenerateThumbnails({ id: assetStub.image.id });
expect(mocks.media.generateThumbnailToBuffer).toHaveBeenCalledTimes(2);
expect(mocks.thumbnailStorage.store).toHaveBeenCalledWith({
assetId: assetStub.image.id,
type: AssetFileType.Thumbnail,
isEdited: false,
data: thumbnailBuffer,
mimeType: 'image/webp',
});
expect(mocks.thumbnailStorage.store).toHaveBeenCalledWith({
assetId: assetStub.image.id,
type: AssetFileType.Preview,
isEdited: false,
data: thumbnailBuffer,
mimeType: 'image/jpeg',
});
expect(mocks.media.generateThumbnail).not.toHaveBeenCalled();
});
it('should not write to filesystem when SQLite is enabled', async () => {
const thumbnailBuffer = Buffer.from('thumbnail-data');
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(assetStub.image);
mocks.media.generateThumbnailToBuffer.mockResolvedValue(thumbnailBuffer);
await sut.handleGenerateThumbnails({ id: assetStub.image.id });
expect(mocks.media.generateThumbnail).not.toHaveBeenCalled();
expect(mocks.asset.upsertFiles).not.toHaveBeenCalled();
});
it('should store with correct mime type for JPEG preview', async () => {
const thumbnailBuffer = Buffer.from('preview-data');
mocks.systemMetadata.get.mockResolvedValue({
image: { preview: { format: ImageFormat.Jpeg } },
});
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(assetStub.image);
mocks.media.generateThumbnailToBuffer.mockResolvedValue(thumbnailBuffer);
await sut.handleGenerateThumbnails({ id: assetStub.image.id });
expect(mocks.thumbnailStorage.store).toHaveBeenCalledWith(
expect.objectContaining({
type: AssetFileType.Preview,
mimeType: 'image/jpeg',
}),
);
});
});
describe('with SQLite storage disabled', () => {
beforeEach(() => {
mocks.thumbnailStorage.isEnabled.mockReturnValue(false);
});
it('should continue using filesystem when SQLite is disabled', async () => {
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(assetStub.image);
await sut.handleGenerateThumbnails({ id: assetStub.image.id });
expect(mocks.media.generateThumbnail).toHaveBeenCalled();
expect(mocks.media.generateThumbnailToBuffer).not.toHaveBeenCalled();
expect(mocks.thumbnailStorage.store).not.toHaveBeenCalled();
});
});
});
describe('handleAssetEditThumbnailGeneration', () => {

View File

@@ -4,7 +4,7 @@ import { FACE_THUMBNAIL_SIZE, JOBS_ASSET_PAGINATION_SIZE } from 'src/constants';
import { ImagePathOptions, StorageCore, ThumbnailPathEntity } from 'src/cores/storage.core';
import { AssetFile, Exif } from 'src/database';
import { OnEvent, OnJob } from 'src/decorators';
import { AssetEditAction, CropParameters } from 'src/dtos/editing.dto';
import { AssetEditAction, AssetEditActionItem, CropParameters } from 'src/dtos/editing.dto';
import { SystemConfigFFmpegDto } from 'src/dtos/system-config.dto';
import {
AssetFileType,
@@ -35,6 +35,7 @@ import {
ImageDimensions,
JobItem,
JobOf,
RawImageInfo,
VideoFormat,
VideoInterfaces,
VideoStreamInfo,
@@ -56,6 +57,9 @@ interface UpsertFileOptions {
type ThumbnailAsset = NonNullable<Awaited<ReturnType<AssetJobRepository['getForGenerateThumbnailJob']>>>;
// Feature flag: Enable fullsize thumbnail storage in SQLite
const SQLITE_STORE_FULLSIZE = true;
@Injectable()
export class MediaService extends BaseService {
videoInterfaces: VideoInterfaces = { dri: [], mali: false };
@@ -320,8 +324,27 @@ export class MediaService extends BaseService {
const extractedImage = await this.extractOriginalImage(asset, image, useEdits);
const { info, data, colorspace, generateFullsize, convertFullsize, extracted } = extractedImage;
// generate final images
const thumbnailOptions = { colorspace, processInvalidImages: false, raw: info, edits: useEdits ? asset.edits : [] };
const decodedDimensions = { width: info.width, height: info.height };
const fullsizeDimensions = useEdits ? getOutputDimensions(asset.edits, decodedDimensions) : decodedDimensions;
if (this.thumbnailStorageRepository.isEnabled()) {
return this.generateImageThumbnailsToSqlite(
asset,
data,
image,
thumbnailOptions,
useEdits,
fullsizeDimensions,
extracted,
generateFullsize,
convertFullsize,
);
} else {
console.log('not enabled');
}
// generate final images to filesystem
const promises = [
this.mediaRepository.generateThumbhash(data, thumbnailOptions),
this.mediaRepository.generateThumbnail(data, { ...image.thumbnail, ...thumbnailOptions }, thumbnailFile.path),
@@ -376,9 +399,6 @@ export class MediaService extends BaseService {
await Promise.all(promises);
}
const decodedDimensions = { width: info.width, height: info.height };
const fullsizeDimensions = useEdits ? getOutputDimensions(asset.edits, decodedDimensions) : decodedDimensions;
return {
files: fullsizeFile ? [previewFile, thumbnailFile, fullsizeFile] : [previewFile, thumbnailFile],
thumbhash: outputs[0] as Buffer,
@@ -386,6 +406,82 @@ export class MediaService extends BaseService {
};
}
private async generateImageThumbnailsToSqlite(
asset: ThumbnailAsset,
data: Buffer,
image: SystemConfig['image'],
thumbnailOptions: {
colorspace: Colorspace;
processInvalidImages: boolean;
raw: RawImageInfo;
edits: AssetEditActionItem[];
},
useEdits: boolean,
fullsizeDimensions: ImageDimensions,
extracted: { buffer: Buffer; format: RawExtractedFormat } | null,
generateFullsize: boolean,
convertFullsize: boolean,
) {
const [thumbnailBuffer, previewBuffer, thumbhash] = await Promise.all([
this.mediaRepository.generateThumbnailToBuffer(data, { ...image.thumbnail, ...thumbnailOptions }),
this.mediaRepository.generateThumbnailToBuffer(data, { ...image.preview, ...thumbnailOptions }),
this.mediaRepository.generateThumbhash(data, thumbnailOptions),
]);
// Check if fullsize should be stored in SQLite
let fullsizeBuffer: Buffer | null = null;
let fullsizeMimeType: string | null = null;
if (SQLITE_STORE_FULLSIZE && generateFullsize) {
if (convertFullsize) {
// Convert scenario: generate fullsize from data buffer
fullsizeBuffer = await this.mediaRepository.generateThumbnailToBuffer(data, {
format: image.fullsize.format,
quality: image.fullsize.quality,
progressive: image.fullsize.progressive,
...thumbnailOptions,
});
fullsizeMimeType = `image/${image.fullsize.format}`;
} else if (extracted && extracted.format === RawExtractedFormat.Jpeg) {
// Extract scenario: use extracted buffer directly
fullsizeBuffer = extracted.buffer;
fullsizeMimeType = 'image/jpeg';
}
}
this.thumbnailStorageRepository.store({
assetId: asset.id,
type: AssetFileType.Thumbnail,
isEdited: useEdits,
data: thumbnailBuffer,
mimeType: `image/${image.thumbnail.format}`,
});
this.thumbnailStorageRepository.store({
assetId: asset.id,
type: AssetFileType.Preview,
isEdited: useEdits,
data: previewBuffer,
mimeType: `image/${image.preview.format}`,
});
// Store fullsize if generated
if (fullsizeBuffer && fullsizeMimeType) {
this.thumbnailStorageRepository.store({
assetId: asset.id,
type: AssetFileType.FullSize,
isEdited: useEdits,
data: fullsizeBuffer,
mimeType: fullsizeMimeType,
});
}
return {
files: [] as UpsertFileOptions[],
thumbhash,
fullsizeDimensions,
};
}
@OnJob({ name: JobName.PersonGenerateThumbnail, queue: QueueName.ThumbnailGeneration })
async handleGeneratePersonThumbnail({ id }: JobOf<JobName.PersonGenerateThumbnail>): Promise<JobStatus> {
const { machineLearning, metadata, image } = await this.getConfig({ withCache: true });

View File

@@ -307,6 +307,7 @@ export class MetadataService extends BaseService {
const assetHeight = isSidewards ? validate(width) : validate(height);
const promises: Promise<unknown>[] = [
this.assetRepository.upsertExif(exifData, { lockedPropertiesBehavior: 'skip' }),
this.assetRepository.update({
id: asset.id,
duration: this.getDuration(exifTags),
@@ -321,7 +322,6 @@ export class MetadataService extends BaseService {
}),
];
await this.assetRepository.upsertExif(exifData, { lockedPropertiesBehavior: 'skip' });
await this.applyTagList(asset);
if (this.isMotionPhoto(asset, exifTags)) {

View File

@@ -45,6 +45,7 @@ export class StorageService extends BaseService {
@OnEvent({ name: 'AppBootstrap', priority: BootstrapEventPriority.StorageService })
async onBootstrap() {
StorageCore.setMediaLocation(this.detectMediaLocation());
this.initializeThumbnailStorage();
await this.databaseRepository.withLock(DatabaseLock.SystemFileMounts, async () => {
const flags =
@@ -133,6 +134,12 @@ export class StorageService extends BaseService {
});
}
private initializeThumbnailStorage(): void {
const { thumbnailStorage } = this.configRepository.getEnv();
const path = thumbnailStorage.sqlitePath ?? StorageCore.getThumbnailStoragePath();
this.thumbnailStorageRepository.initialize(path);
}
@OnJob({ name: JobName.FileDelete, queue: QueueName.BackgroundTask })
async handleDeleteFiles(job: JobOf<JobName.FileDelete>): Promise<JobStatus> {
const { files } = job;

View File

@@ -30,6 +30,17 @@ export class ImmichFileResponse {
Object.assign(this, response);
}
}
export class ImmichBufferResponse {
public readonly data!: Buffer;
public readonly contentType!: string;
public readonly cacheControl!: CacheControl;
public readonly fileName?: string;
constructor(response: ImmichBufferResponse) {
Object.assign(this, response);
}
}
type SendFile = Parameters<Response['sendFile']>;
type SendFileOptions = SendFile[1];
@@ -82,6 +93,40 @@ export const sendFile = async (
}
};
export const sendBuffer = async (
res: Response,
next: NextFunction,
handler: () => Promise<ImmichBufferResponse> | ImmichBufferResponse,
logger: LoggingRepository,
): Promise<void> => {
try {
const file = await handler();
const cacheControlHeader = cacheControlHeaders[file.cacheControl];
if (cacheControlHeader) {
res.set('Cache-Control', cacheControlHeader);
}
res.header('Content-Type', file.contentType);
res.header('Content-Length', file.data.length.toString());
if (file.fileName) {
res.header('Content-Disposition', `inline; filename*=UTF-8''${encodeURIComponent(file.fileName)}`);
}
res.send(file.data);
} catch (error: Error | any) {
if (isConnectionAborted(error) || res.headersSent) {
return;
}
if (error instanceof HttpException === false) {
logger.error(`Unable to send buffer: ${error}`, error.stack);
}
res.header('Cache-Control', 'none');
next(error);
}
};
export const asStreamableFile = ({ stream, type, length }: ImmichReadStream) => {
return new StreamableFile(stream, { type, length });
};

View File

@@ -12,6 +12,7 @@ async function bootstrap() {
configureTelemetry();
const app = await NestFactory.create<NestExpressApplication>(ApiModule, { bufferLogs: true });
app.enableShutdownHooks();
app.get(AppRepository).setCloseFn(() => app.close());
void configureExpress(app, {

View File

@@ -11,6 +11,7 @@ async function bootstrap() {
configureTelemetry();
const app = await NestFactory.create<NestExpressApplication>(MaintenanceModule, { bufferLogs: true });
app.enableShutdownHooks();
app.get(AppRepository).setCloseFn(() => app.close());
void configureExpress(app, {

View File

@@ -1,5 +1,5 @@
import { NestFactory } from '@nestjs/core';
import { isMainThread } from 'node:worker_threads';
import { isMainThread, parentPort } from 'node:worker_threads';
import { MicroservicesModule } from 'src/app.module';
import { serverVersion } from 'src/constants';
import { WebSocketAdapter } from 'src/middleware/websocket.adapter';
@@ -16,6 +16,7 @@ export async function bootstrap() {
}
const app = await NestFactory.create(MicroservicesModule, { bufferLogs: true });
app.enableShutdownHooks();
const logger = await app.resolve(LoggingRepository);
const configRepository = app.get(ConfigRepository);
app.get(AppRepository).setCloseFn(() => app.close());
@@ -29,13 +30,24 @@ export async function bootstrap() {
await (host ? app.listen(0, host) : app.listen(0));
logger.log(`Immich Microservices is running [v${serverVersion}] [${environment}] `);
return app;
}
if (!isMainThread) {
bootstrap().catch((error) => {
if (!isStartUpError(error)) {
console.error(error);
}
throw error;
});
bootstrap()
.then((app) => {
parentPort?.on('message', (message) => {
if (message?.type === 'shutdown') {
console.log('Microservices worker received shutdown message');
void app.close();
}
});
})
.catch((error) => {
if (!isStartUpError(error)) {
console.error(error);
}
throw error;
});
}

View File

@@ -84,6 +84,10 @@ const envData: EnvData = {
ignoreMountCheckErrors: false,
},
thumbnailStorage: {
sqlitePath: undefined,
},
telemetry: {
apiPort: 8081,
microservicesPort: 8082,

View File

@@ -5,6 +5,7 @@ import { Mocked, vitest } from 'vitest';
export const newMediaRepositoryMock = (): Mocked<RepositoryInterface<MediaRepository>> => {
return {
generateThumbnail: vitest.fn().mockImplementation(() => Promise.resolve()),
generateThumbnailToBuffer: vitest.fn().mockResolvedValue(Buffer.from('')),
writeExif: vitest.fn().mockImplementation(() => Promise.resolve()),
copyTagGroup: vitest.fn().mockImplementation(() => Promise.resolve()),
generateThumbhash: vitest.fn().mockResolvedValue(Buffer.from('')),

View File

@@ -0,0 +1,15 @@
import { ThumbnailStorageRepository } from 'src/repositories/thumbnail-storage.repository';
import { RepositoryInterface } from 'src/types';
import { Mocked, vitest } from 'vitest';
export const newThumbnailStorageRepositoryMock = (): Mocked<RepositoryInterface<ThumbnailStorageRepository>> => {
return {
initialize: vitest.fn(),
isEnabled: vitest.fn().mockReturnValue(false),
store: vitest.fn(),
get: vitest.fn(),
delete: vitest.fn(),
deleteByAsset: vitest.fn(),
close: vitest.fn(),
};
};

View File

@@ -60,6 +60,7 @@ import { SyncRepository } from 'src/repositories/sync.repository';
import { SystemMetadataRepository } from 'src/repositories/system-metadata.repository';
import { TagRepository } from 'src/repositories/tag.repository';
import { TelemetryRepository } from 'src/repositories/telemetry.repository';
import { ThumbnailStorageRepository } from 'src/repositories/thumbnail-storage.repository';
import { TrashRepository } from 'src/repositories/trash.repository';
import { UserRepository } from 'src/repositories/user.repository';
import { VersionHistoryRepository } from 'src/repositories/version-history.repository';
@@ -81,6 +82,7 @@ import { newMediaRepositoryMock } from 'test/repositories/media.repository.mock'
import { newMetadataRepositoryMock } from 'test/repositories/metadata.repository.mock';
import { newStorageRepositoryMock } from 'test/repositories/storage.repository.mock';
import { newSystemMetadataRepositoryMock } from 'test/repositories/system-metadata.repository.mock';
import { newThumbnailStorageRepositoryMock } from 'test/repositories/thumbnail-storage.repository.mock';
import { ITelemetryRepositoryMock, newTelemetryRepositoryMock } from 'test/repositories/telemetry.repository.mock';
import { assert, Mock, Mocked, vitest } from 'vitest';
@@ -255,6 +257,7 @@ export type ServiceOverrides = {
systemMetadata: SystemMetadataRepository;
tag: TagRepository;
telemetry: TelemetryRepository;
thumbnailStorage: ThumbnailStorageRepository;
trash: TrashRepository;
user: UserRepository;
versionHistory: VersionHistoryRepository;
@@ -332,6 +335,7 @@ export const getMocks = () => {
// eslint-disable-next-line no-sparse-arrays
tag: automock(TagRepository, { args: [, loggerMock], strict: false }),
telemetry: newTelemetryRepositoryMock(),
thumbnailStorage: newThumbnailStorageRepositoryMock(),
trash: automock(TrashRepository),
user: automock(UserRepository, { strict: false }),
versionHistory: automock(VersionHistoryRepository),
@@ -397,6 +401,7 @@ export const newTestService = <T extends BaseService>(
overrides.systemMetadata || (mocks.systemMetadata as As<SystemMetadataRepository>),
overrides.tag || (mocks.tag as As<TagRepository>),
overrides.telemetry || (mocks.telemetry as unknown as TelemetryRepository),
overrides.thumbnailStorage || (mocks.thumbnailStorage as As<ThumbnailStorageRepository>),
overrides.trash || (mocks.trash as As<TrashRepository>),
overrides.user || (mocks.user as As<UserRepository>),
overrides.versionHistory || (mocks.versionHistory as As<VersionHistoryRepository>),

View File

@@ -1,6 +1,6 @@
{
"name": "immich-web",
"version": "2.5.3",
"version": "2.5.2",
"license": "GNU Affero General Public License version 3",
"type": "module",
"scripts": {
@@ -26,7 +26,7 @@
"dependencies": {
"@formatjs/icu-messageformat-parser": "^3.0.0",
"@immich/justified-layout-wasm": "^0.4.3",
"@immich/sdk": "workspace:*",
"@immich/sdk": "file:../open-api/typescript-sdk",
"@immich/ui": "^0.59.0",
"@mapbox/mapbox-gl-rtl-text": "0.2.3",
"@mdi/js": "^7.4.47",

View File

@@ -14,7 +14,6 @@
import { eventManager } from '$lib/managers/event-manager.svelte';
import { imageManager } from '$lib/managers/ImageManager.svelte';
import { Route } from '$lib/route';
import { getAssetActions } from '$lib/services/asset.service';
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
import { ocrManager } from '$lib/stores/ocr.svelte';
import { alwaysLoadOriginalVideo } from '$lib/stores/preferences.store';
@@ -37,7 +36,6 @@
type PersonResponseDto,
type StackResponseDto,
} from '@immich/sdk';
import { CommandPaletteDefaultProvider } from '@immich/ui';
import { onDestroy, onMount, untrack } from 'svelte';
import { t } from 'svelte-i18n';
import { fly } from 'svelte/transition';
@@ -428,11 +426,8 @@
!assetViewerManager.isShowEditor &&
ocrManager.hasOcrData,
);
const { Tag } = $derived(getAssetActions($t, asset));
</script>
<CommandPaletteDefaultProvider name={$t('assets')} actions={[Tag]} />
<OnEvents {onAssetReplace} {onAssetUpdate} />
<svelte:document bind:fullscreenElement />

View File

@@ -1,13 +1,12 @@
<script lang="ts">
import HeaderActionButton from '$lib/components/HeaderActionButton.svelte';
import OnEvents from '$lib/components/OnEvents.svelte';
import { shortcut } from '$lib/actions/shortcut';
import { authManager } from '$lib/managers/auth-manager.svelte';
import AssetTagModal from '$lib/modals/AssetTagModal.svelte';
import { Route } from '$lib/route';
import { getAssetActions } from '$lib/services/asset.service';
import { removeTag } from '$lib/utils/asset-utils';
import { getAssetInfo, type AssetResponseDto } from '@immich/sdk';
import { Badge, IconButton, Link, Text } from '@immich/ui';
import { mdiClose } from '@mdi/js';
import { Icon, modalManager, Text } from '@immich/ui';
import { mdiClose, mdiPlus } from '@mdi/js';
import { t } from 'svelte-i18n';
interface Props {
@@ -19,23 +18,22 @@
let tags = $derived(asset.tags || []);
const handleAddTag = async () => {
const success = await modalManager.show(AssetTagModal, { assetIds: [asset.id] });
if (success) {
asset = await getAssetInfo({ id: asset.id });
}
};
const handleRemove = async (tagId: string) => {
const ids = await removeTag({ tagIds: [tagId], assetIds: [asset.id], showNotification: false });
if (ids) {
asset = await getAssetInfo({ id: asset.id });
}
};
const onAssetsTag = async (ids: string[]) => {
if (ids.includes(asset.id)) {
asset = await getAssetInfo({ id: asset.id });
}
};
const { Tag } = $derived(getAssetActions($t, asset));
</script>
<OnEvents {onAssetsTag} />
<svelte:document use:shortcut={{ shortcut: { key: 't' }, onShortcut: handleAddTag }} />
{#if isOwner && !authManager.isSharedLink}
<section class="px-4 mt-4">
@@ -44,24 +42,36 @@
</div>
<section class="flex flex-wrap pt-2 gap-1" data-testid="detail-panel-tags">
{#each tags as tag (tag.id)}
<Badge size="small" class="items-center px-0" shape="round">
<Link
<div class="flex group transition-all">
<a
class="inline-block h-min whitespace-nowrap ps-3 pe-1 group-hover:ps-3 py-1 text-center align-baseline leading-none text-gray-100 dark:text-immich-dark-gray bg-primary rounded-s-full hover:bg-immich-primary/80 dark:hover:bg-immich-dark-primary/80 transition-all"
href={Route.tags({ path: tag.value })}
class="text-light no-underline rounded-full hover:bg-primary-400 px-2"
>
{tag.value}
</Link>
<IconButton
aria-label={$t('remove_tag')}
icon={mdiClose}
<p class="text-sm">
{tag.value}
</p>
</a>
<button
type="button"
class="text-gray-100 dark:text-immich-dark-gray bg-immich-primary/95 dark:bg-immich-dark-primary/95 rounded-e-full place-items-center place-content-center pe-2 ps-1 py-1 hover:bg-immich-primary/80 dark:hover:bg-immich-dark-primary/80 transition-all"
title="Remove tag"
onclick={() => handleRemove(tag.id)}
size="tiny"
class="hover:bg-primary-400"
shape="round"
/>
</Badge>
>
<Icon icon={mdiClose} />
</button>
</div>
{/each}
<HeaderActionButton action={Tag} />
<button
type="button"
class="rounded-full bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-700 hover:text-gray-700 dark:hover:text-gray-200 flex place-items-center place-content-center gap-1 px-2 py-1"
title={$t('add_tag')}
onclick={handleAddTag}
>
<span class="text-sm px-1 flex place-items-center place-content-center gap-1"
><Icon icon={mdiPlus} />{$t('add')}</span
>
</button>
</section>
</section>
{/if}

View File

@@ -55,10 +55,13 @@
let loader = $state<HTMLImageElement>();
$effect.pre(() => {
void asset.id;
untrack(() => assetViewerManager.resetZoomState());
});
assetViewerManager.zoomState = {
currentRotation: 0,
currentZoom: 1,
enable: true,
currentPositionX: 0,
currentPositionY: 0,
};
onDestroy(() => {
$boundingBoxesArray = [];

View File

@@ -68,11 +68,7 @@
let currentMemoryAssetFull = $derived.by(async () =>
current?.asset ? await getAssetInfo({ ...authManager.params, id: current.asset.id }) : undefined,
);
let currentTimelineAssets = $derived([
...(current?.previousMemory?.assets ?? []),
...(current?.memory.assets ?? []),
...(current?.nextMemory?.assets ?? []),
]);
let currentTimelineAssets = $derived(current?.memory.assets || []);
let isSaved = $derived(current?.memory.isSaved);
let viewerHeight = $state(0);

View File

@@ -20,8 +20,11 @@
const handleTagAssets = async () => {
const assets = [...getOwnedAssets()];
await modalManager.show(AssetTagModal, { assetIds: assets.map(({ id }) => id) });
clearSelect();
const success = await modalManager.show(AssetTagModal, { assetIds: assets.map(({ id }) => id) });
if (success) {
clearSelect();
}
};
</script>

View File

@@ -5,6 +5,7 @@
import { changePinCode } from '@immich/sdk';
import { Button, Heading, modalManager, Text, toastManager } from '@immich/ui';
import { t } from 'svelte-i18n';
import { fade } from 'svelte/transition';
let currentPinCode = $state('');
let newPinCode = $state('');
@@ -37,23 +38,27 @@
};
</script>
<form autocomplete="off" onsubmit={handleSubmit}>
<div class="flex flex-col gap-6 place-items-center place-content-center">
<Heading>{$t('change_pin_code')}</Heading>
<PinCodeInput label={$t('current_pin_code')} bind:value={currentPinCode} tabindexStart={1} pinLength={6} />
<PinCodeInput label={$t('new_pin_code')} bind:value={newPinCode} tabindexStart={7} pinLength={6} />
<PinCodeInput label={$t('confirm_new_pin_code')} bind:value={confirmPinCode} tabindexStart={13} pinLength={6} />
<button type="button" onclick={() => modalManager.show(PinCodeResetModal, {})}>
<Text color="muted" class="underline" size="small">{$t('forgot_pin_code_question')}</Text>
</button>
</div>
<section class="my-4">
<div in:fade={{ duration: 200 }}>
<form autocomplete="off" onsubmit={handleSubmit} class="mt-6">
<div class="flex flex-col gap-6 place-items-center place-content-center">
<Heading>{$t('change_pin_code')}</Heading>
<PinCodeInput label={$t('current_pin_code')} bind:value={currentPinCode} tabindexStart={1} pinLength={6} />
<PinCodeInput label={$t('new_pin_code')} bind:value={newPinCode} tabindexStart={7} pinLength={6} />
<PinCodeInput label={$t('confirm_new_pin_code')} bind:value={confirmPinCode} tabindexStart={13} pinLength={6} />
<button type="button" onclick={() => modalManager.show(PinCodeResetModal, {})}>
<Text color="muted" class="underline" size="small">{$t('forgot_pin_code_question')}</Text>
</button>
</div>
<div class="flex justify-end gap-2 mt-4">
<Button shape="round" color="secondary" type="button" size="small" onclick={resetForm}>
{$t('clear')}
</Button>
<Button shape="round" type="submit" size="small" loading={isLoading} disabled={!canSubmit}>
{$t('save')}
</Button>
<div class="flex justify-end gap-2 mt-4">
<Button shape="round" color="secondary" type="button" size="small" onclick={resetForm}>
{$t('clear')}
</Button>
<Button shape="round" type="submit" size="small" loading={isLoading} disabled={!canSubmit}>
{$t('save')}
</Button>
</div>
</form>
</div>
</form>
</section>

View File

@@ -20,7 +20,7 @@
<OnEvents {onUserPinCodeReset} />
<section class="my-4 sm:ms-8">
<section>
{#if hasPinCode}
<div in:fade={{ duration: 200 }}>
<PinCodeChangeForm />

View File

@@ -59,7 +59,7 @@
<section class="my-4">
<div in:fade={{ duration: 500 }}>
<div class="sm:ms-8 flex flex-col gap-6">
<div class="ms-8 mt-4 flex flex-col gap-6">
<Field label={$t('theme_selection')} description={$t('theme_selection_description')}>
<Switch checked={themeManager.theme.system} onCheckedChange={(checked) => themeManager.setSystem(checked)} />
</Field>

View File

@@ -23,7 +23,7 @@
<section class="my-4">
<div in:fade={{ duration: 500 }}>
<form autocomplete="off" {onsubmit}>
<div class="sm:ms-8 flex flex-col gap-4">
<div class="ms-4 mt-4 flex flex-col gap-4">
<Field label={$t('password')} required>
<PasswordInput bind:value={password} autocomplete="current-password" />
</Field>

View File

@@ -3,7 +3,6 @@
import { deleteAllSessions, deleteSession, getSessions, type SessionResponseDto } from '@immich/sdk';
import { Button, modalManager, Text, toastManager } from '@immich/ui';
import { t } from 'svelte-i18n';
import { fade } from 'svelte/transition';
import DeviceCard from './device-card.svelte';
interface Props {
@@ -51,39 +50,33 @@
</script>
<section class="my-4">
<div in:fade={{ duration: 500 }}>
<div class="sm:ms-8 flex flex-col gap-4">
{#if currentSession}
<div class="mb-6">
<Text class="mb-2" fontWeight="medium" size="tiny" color="primary">
{$t('current_device')}
</Text>
<DeviceCard session={currentSession} />
</div>
{/if}
{#if otherSessions.length > 0}
<div class="mb-6">
<Text class="mb-2" fontWeight="medium" size="tiny" color="primary">
{$t('other_devices')}
</Text>
{#each otherSessions as session, index (session.id)}
<DeviceCard {session} onDelete={() => handleDelete(session)} />
{#if index !== otherSessions.length - 1}
<hr class="my-3" />
{/if}
{/each}
</div>
<div class="my-3">
<hr />
</div>
<div class="flex justify-end">
<Button shape="round" color="danger" size="small" onclick={handleDeleteAll}
>{$t('log_out_all_devices')}</Button
>
</div>
{/if}
{#if currentSession}
<div class="mb-6">
<Text class="mb-2" fontWeight="medium" size="tiny" color="primary">
{$t('current_device')}
</Text>
<DeviceCard session={currentSession} />
</div>
</div>
{/if}
{#if otherSessions.length > 0}
<div class="mb-6">
<Text class="mb-2" fontWeight="medium" size="tiny" color="primary">
{$t('other_devices')}
</Text>
{#each otherSessions as session, index (session.id)}
<DeviceCard {session} onDelete={() => handleDelete(session)} />
{#if index !== otherSessions.length - 1}
<hr class="my-3" />
{/if}
{/each}
</div>
<div class="my-3">
<hr />
</div>
<div class="flex justify-end">
<Button shape="round" color="danger" size="small" onclick={handleDeleteAll}>{$t('log_out_all_devices')}</Button>
</div>
{/if}
</section>

View File

@@ -39,7 +39,7 @@
<section class="my-4">
<div in:fade={{ duration: 500 }}>
<form autocomplete="off" {onsubmit}>
<div class="sm:ms-8 flex flex-col gap-4">
<div class="ms-4 mt-4 flex flex-col gap-4">
<SettingInputField
inputType={SettingInputFieldType.NUMBER}
label={$t('archive_size')}

View File

@@ -67,9 +67,9 @@
<section class="my-4">
<div in:fade={{ duration: 500 }}>
<form autocomplete="off" {onsubmit}>
<div class="sm:ms-4 md:ms-8 flex flex-col">
<div class="ms-4 mt-4 flex flex-col">
<SettingAccordion key="albums" title={$t('albums')} subtitle={$t('albums_feature_description')}>
<div class="sm:ms-4 mt-4 flex flex-col gap-4">
<div class="ms-4 mt-6 flex flex-col gap-4">
<Field label={$t('albums_default_sort_order')} description={$t('albums_default_sort_order_description')}>
<Select
options={[
@@ -83,7 +83,7 @@
</SettingAccordion>
<SettingAccordion key="folders" title={$t('folders')} subtitle={$t('folders_feature_description')}>
<div class="sm:ms-4 mt-4 flex flex-col gap-4">
<div class="ms-4 mt-6 flex flex-col gap-4">
<Field label={$t('enable')}>
<Switch bind:checked={foldersEnabled} />
</Field>
@@ -97,7 +97,7 @@
</SettingAccordion>
<SettingAccordion key="memories" title={$t('time_based_memories')} subtitle={$t('photos_from_previous_years')}>
<div class="sm:ms-4 mt-4 flex flex-col gap-4">
<div class="ms-4 mt-6 flex flex-col gap-4">
<Field label={$t('enable')}>
<Switch bind:checked={memoriesEnabled} />
</Field>
@@ -109,7 +109,7 @@
</SettingAccordion>
<SettingAccordion key="people" title={$t('people')} subtitle={$t('people_feature_description')}>
<div class="sm:ms-4 mt-4 flex flex-col gap-4">
<div class="ms-4 mt-6 flex flex-col gap-4">
<Field label={$t('enable')}>
<Switch bind:checked={peopleEnabled} />
</Field>
@@ -123,7 +123,7 @@
</SettingAccordion>
<SettingAccordion key="rating" title={$t('rating')} subtitle={$t('rating_description')}>
<div class="sm:ms-4 mt-4 flex flex-col gap-4">
<div class="ms-4 mt-6 flex flex-col gap-4">
<Field label={$t('enable')}>
<Switch bind:checked={ratingsEnabled} />
</Field>
@@ -131,7 +131,7 @@
</SettingAccordion>
<SettingAccordion key="shared-links" title={$t('shared_links')} subtitle={$t('shared_links_description')}>
<div class="sm:ms-4 mt-4 flex flex-col gap-4">
<div class="ms-4 mt-6 flex flex-col gap-4">
<Field label={$t('enable')}>
<Switch bind:checked={sharedLinksEnabled} />
</Field>
@@ -145,7 +145,7 @@
</SettingAccordion>
<SettingAccordion key="tags" title={$t('tags')} subtitle={$t('tag_feature_description')}>
<div class="sm:ms-4 mt-4 flex flex-col gap-4">
<div class="ms-4 mt-6 flex flex-col gap-4">
<Field label={$t('enable')}>
<Switch bind:checked={tagsEnabled} />
</Field>
@@ -159,7 +159,7 @@
</SettingAccordion>
<SettingAccordion key="cast" title={$t('cast')} subtitle={$t('cast_description')}>
<div class="sm:ms-4 mt-4 flex flex-col gap-4">
<div class="ms-4 mt-6 flex flex-col gap-4">
<Field label={$t('gcast_enabled')} description={$t('gcast_enabled_description')}>
<Switch bind:checked={gCastEnabled} />
</Field>

View File

@@ -42,7 +42,7 @@
<section class="my-4">
<div in:fade={{ duration: 500 }}>
<form autocomplete="off" {onsubmit}>
<div class="sm:ms-8 flex flex-col gap-6">
<div class="ms-4 mt-4 flex flex-col gap-6">
<Field label={$t('enable')} description={$t('notification_toggle_setting_description')}>
<Switch bind:checked={emailNotificationsEnabled} />
</Field>

View File

@@ -45,7 +45,7 @@
<section class="my-4">
<div in:fade={{ duration: 500 }}>
<div class="sm:ms-8 flex justify-end">
<div class="flex justify-end">
{#if loading}
<div class="flex place-content-center place-items-center">
<LoadingSpinner />

View File

@@ -33,7 +33,7 @@
<OnEvents {onApiKeyCreate} {onApiKeyUpdate} {onApiKeyDelete} />
<section class="my-4">
<div class="sm:ms-8 flex flex-col gap-2" in:fade={{ duration: 500 }}>
<div class="flex flex-col gap-2" in:fade={{ duration: 500 }}>
<div class="mb-2 flex justify-end">
<Button leadingIcon={Create.icon} shape="round" size="small" onclick={() => Create.onAction(Create)}>
{Create.title}

View File

@@ -33,7 +33,7 @@
<section class="my-4">
<div in:fade={{ duration: 500 }}>
<form autocomplete="off" onsubmit={preventDefault(bubble('submit'))}>
<div class="sm:ms-8 flex flex-col gap-4">
<div class="ms-4 mt-4 flex flex-col gap-4">
<Field label={$t('user_id')} disabled>
<Input bind:value={editedUser.id} />
</Field>

View File

@@ -105,7 +105,7 @@
</script>
<section class="my-4">
<div class="sm:ms-8" in:fade={{ duration: 500 }}>
<div in:fade={{ duration: 500 }}>
{#if $isPurchased}
<!-- BADGE TOGGLE -->
<div class="mb-4">

View File

@@ -65,7 +65,7 @@
</TableRow>
{/snippet}
<section class="my-4 w-full">
<section class="my-6 w-full">
<Heading size="tiny">{$t('photos_and_videos')}</Heading>
<Table striped spacing="small" class="mt-4" size="small">
<TableHeader>

View File

@@ -5,14 +5,6 @@ import type { ZoomImageWheelState } from '@zoom-image/core';
const isShowDetailPanel = new PersistedLocalStorage<boolean>('asset-viewer-state', false);
const createDefaultZoomState = (): ZoomImageWheelState => ({
currentRotation: 0,
currentZoom: 1,
enable: true,
currentPositionX: 0,
currentPositionY: 0,
});
export type Events = {
Zoom: [];
ZoomChange: [ZoomImageWheelState];
@@ -20,7 +12,13 @@ export type Events = {
};
export class AssetViewerManager extends BaseEventManager<Events> {
#zoomState = $state(createDefaultZoomState());
#zoomState = $state<ZoomImageWheelState>({
currentRotation: 0,
currentZoom: 1,
enable: true,
currentPositionX: 0,
currentPositionY: 0,
});
imgRef = $state<HTMLImageElement | undefined>();
isShowActivityPanel = $state(false);
@@ -69,10 +67,6 @@ export class AssetViewerManager extends BaseEventManager<Events> {
this.#zoomState = state;
}
resetZoomState() {
this.zoomState = createDefaultZoomState();
}
toggleActivityPanel() {
this.closeDetailPanel();
this.isShowActivityPanel = !this.isShowActivityPanel;

View File

@@ -37,7 +37,6 @@ export type Events = {
AssetsArchive: [string[]];
AssetsDelete: [string[]];
AssetEditsApplied: [string];
AssetsTag: [string[]];
AlbumAddAssets: [];
AlbumUpdate: [AlbumResponseDto];

View File

@@ -1,5 +1,4 @@
<script lang="ts">
import { eventManager } from '$lib/managers/event-manager.svelte';
import { tagAssets } from '$lib/utils/asset-utils';
import { getAllTags, upsertTags, type TagResponseDto } from '@immich/sdk';
import { FormModal, Icon } from '@immich/ui';
@@ -10,7 +9,7 @@
import Combobox, { type ComboBoxOption } from '../components/shared-components/combobox.svelte';
interface Props {
onClose: () => void;
onClose: (success?: true) => void;
assetIds: string[];
}
@@ -31,8 +30,8 @@
return;
}
const updatedIds = await tagAssets({ tagIds: [...selectedIds], assetIds, showNotification: false });
eventManager.emit('AssetsTag', updatedIds);
await tagAssets({ tagIds: [...selectedIds], assetIds, showNotification: false });
onClose(true);
};
const handleSelect = async (option?: ComboBoxOption) => {

View File

@@ -2,7 +2,6 @@ import { ProjectionType } from '$lib/constants';
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
import { authManager } from '$lib/managers/auth-manager.svelte';
import { eventManager } from '$lib/managers/event-manager.svelte';
import AssetTagModal from '$lib/modals/AssetTagModal.svelte';
import SharedLinkCreateModal from '$lib/modals/SharedLinkCreateModal.svelte';
import { user as authUser, preferences } from '$lib/stores/user.store';
import { getAssetJobName, getSharedLink, sleep } from '$lib/utils';
@@ -42,7 +41,6 @@ import {
mdiMotionPauseOutline,
mdiMotionPlayOutline,
mdiShareVariantOutline,
mdiTagPlusOutline,
mdiTune,
} from '@mdi/js';
import type { MessageFormatter } from 'svelte-i18n';
@@ -51,7 +49,6 @@ import { get } from 'svelte/store';
export const getAssetActions = ($t: MessageFormatter, asset: AssetResponseDto) => {
const sharedLink = getSharedLink();
const currentAuthUser = get(authUser);
const userPreferences = get(preferences);
const isOwner = !!(currentAuthUser && currentAuthUser.id === asset.ownerId);
const Share: ActionItem = {
@@ -158,16 +155,7 @@ export const getAssetActions = ($t: MessageFormatter, asset: AssetResponseDto) =
type: $t('assets'),
$if: () => asset.hasMetadata,
onAction: () => assetViewerManager.toggleDetailPanel(),
shortcuts: { key: 'i' },
};
const Tag: ActionItem = {
title: $t('add_tag'),
icon: mdiTagPlusOutline,
type: $t('assets'),
$if: () => userPreferences.tags.enabled,
onAction: () => modalManager.show(AssetTagModal, { assetIds: [asset.id] }),
shortcuts: { key: 't' },
shortcuts: [{ key: 'i' }],
};
const Edit: ActionItem = {
@@ -224,7 +212,6 @@ export const getAssetActions = ($t: MessageFormatter, asset: AssetResponseDto) =
ZoomIn,
ZoomOut,
Copy,
Tag,
Edit,
RefreshFacesJob,
RefreshMetadataJob,

View File

@@ -67,7 +67,6 @@ export const getLibraryActions = ($t: MessageFormatter, library: LibraryResponse
color: 'danger',
onAction: () => handleDeleteLibrary(library),
shortcuts: { key: 'Backspace' },
shortcutOptions: { ignoreInputFields: true },
};
const AddFolder: ActionItem = {

View File

@@ -67,7 +67,6 @@ export const getUserAdminActions = ($t: MessageFormatter, user: UserAdminRespons
$if: () => get(authUser).id !== user.id && !user.deletedAt,
onAction: () => modalManager.show(UserDeleteConfirmModal, { user }),
shortcuts: { key: 'Backspace' },
shortcutOptions: { ignoreInputFields: true },
};
const getDeleteDate = (deletedAt: string): Date =>

View File

@@ -1,5 +1,5 @@
<script lang="ts">
import { goto, invalidateAll } from '$app/navigation';
import { goto } from '$app/navigation';
import AdminCard from '$lib/components/AdminCard.svelte';
import AdminPageLayout from '$lib/components/layouts/AdminPageLayout.svelte';
import OnEvents from '$lib/components/OnEvents.svelte';
@@ -48,7 +48,10 @@
const { children, data }: Props = $props();
const { user, userPreferences, userStatistics, userSessions } = $derived(data);
let user = $state(data.user);
const userPreferences = $state(data.userPreferences);
const userStatistics = $state(data.userStatistics);
const userSessions = $state(data.userSessions);
const TiB = 1024 ** 4;
const usage = $derived(user.quotaUsageInBytes ?? 0);
let [statsUsage, statsUsageUnit] = $derived(getBytesWithUnit(usage, usage > TiB ? 2 : 0));
@@ -76,10 +79,9 @@
const { ResetPassword, ResetPinCode, Update, Delete, Restore } = $derived(getUserAdminActions($t, user));
const onUpdate = async (update: UserAdminResponseDto) => {
const onUpdate = (update: UserAdminResponseDto) => {
if (update.id === user.id) {
data.user = update;
await invalidateAll();
user = update;
}
};

View File

@@ -16,10 +16,12 @@
let { data }: Props = $props();
const user = $derived(data.user);
let { isAdmin, name, email } = $derived(user);
let storageLabel = $derived(user.storageLabel || '');
const previousQuota = $derived(user.quotaSizeInBytes);
const user = $state(data.user);
let isAdmin = $state(user.isAdmin);
let name = $state(user.name);
let email = $state(user.email);
let storageLabel = $state(user.storageLabel || '');
const previousQuota = $state(user.quotaSizeInBytes);
let quotaSize = $derived(
typeof user.quotaSizeInBytes === 'number' ? convertFromBytes(user.quotaSizeInBytes, ByteUnit.GiB) : undefined,