Merge branch 'main' into deduplicate-sync-album

pull/13851/head
Alex 2025-11-18 21:12:57 +07:00 committed by GitHub
commit 839ae3fdf6
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
88 changed files with 4648 additions and 1388 deletions

@ -29,6 +29,12 @@
]
}
},
"features": {
"ghcr.io/devcontainers/features/docker-in-docker:2": {
// https://github.com/devcontainers/features/issues/1466
"moby": false
}
},
"forwardPorts": [3000, 9231, 9230, 2283],
"portsAttributes": {
"3000": {

@ -0,0 +1,147 @@
name: release.yml
on:
pull_request:
types: [closed]
paths:
- CHANGELOG.md
jobs:
# Maybe double check PR source branch?
merge_translations:
uses: ./.github/workflows/merge-translations.yml
permissions:
pull-requests: write
secrets:
PUSH_O_MATIC_APP_ID: ${{ secrets.PUSH_O_MATIC_APP_ID }}
PUSH_O_MATIC_APP_KEY: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
WEBLATE_TOKEN: ${{ secrets.WEBLATE_TOKEN }}
build_mobile:
uses: ./.github/workflows/build-mobile.yml
needs: merge_translations
permissions:
contents: read
secrets:
KEY_JKS: ${{ secrets.KEY_JKS }}
ALIAS: ${{ secrets.ALIAS }}
ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
ANDROID_STORE_PASSWORD: ${{ secrets.ANDROID_STORE_PASSWORD }}
# iOS secrets
APP_STORE_CONNECT_API_KEY_ID: ${{ secrets.APP_STORE_CONNECT_API_KEY_ID }}
APP_STORE_CONNECT_API_KEY_ISSUER_ID: ${{ secrets.APP_STORE_CONNECT_API_KEY_ISSUER_ID }}
APP_STORE_CONNECT_API_KEY: ${{ secrets.APP_STORE_CONNECT_API_KEY }}
IOS_CERTIFICATE_P12: ${{ secrets.IOS_CERTIFICATE_P12 }}
IOS_CERTIFICATE_PASSWORD: ${{ secrets.IOS_CERTIFICATE_PASSWORD }}
IOS_PROVISIONING_PROFILE: ${{ secrets.IOS_PROVISIONING_PROFILE }}
IOS_PROVISIONING_PROFILE_SHARE_EXTENSION: ${{ secrets.IOS_PROVISIONING_PROFILE_SHARE_EXTENSION }}misc/release/notes.tmpl
IOS_PROVISIONING_PROFILE_WIDGET_EXTENSION: ${{ secrets.IOS_PROVISIONING_PROFILE_WIDGET_EXTENSION }}
IOS_DEVELOPMENT_PROVISIONING_PROFILE: ${{ secrets.IOS_DEVELOPMENT_PROVISIONING_PROFILE }}
IOS_DEVELOPMENT_PROVISIONING_PROFILE_SHARE_EXTENSION: ${{ secrets.IOS_DEVELOPMENT_PROVISIONING_PROFILE_SHARE_EXTENSION }}
IOS_DEVELOPMENT_PROVISIONING_PROFILE_WIDGET_EXTENSION: ${{ secrets.IOS_DEVELOPMENT_PROVISIONING_PROFILE_WIDGET_EXTENSION }}
FASTLANE_TEAM_ID: ${{ secrets.FASTLANE_TEAM_ID }}
with:
ref: main
environment: production
prepare_release:
runs-on: ubuntu-latest
needs: build_mobile
permissions:
actions: read # To download the app artifact
steps:
- name: Generate a token
id: generate-token
uses: actions/create-github-app-token@67018539274d69449ef7c02e8e71183d1719ab42 # v2.1.4
with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
- name: Checkout
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
with:
token: ${{ steps.generate-token.outputs.token }}
persist-credentials: false
ref: main
- name: Extract changelog
id: changelog
run: |
CHANGELOG_PATH=$RUNNER_TEMP/changelog.md
sed -n '1,/^---$/p' CHANGELOG.md | head -n -1 > $CHANGELOG_PATH
echo "path=$CHANGELOG_PATH" >> $GITHUB_OUTPUT
VERSION=$(sed -n 's/^# //p' $CHANGELOG_PATH)
echo "version=$VERSION" >> $GITHUB_OUTPUT
- name: Download APK
uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0
with:
name: release-apk-signed
github-token: ${{ steps.generate-token.outputs.token }}
- name: Create draft release
uses: softprops/action-gh-release@6da8fa9354ddfdc4aeace5fc48d7f679b5214090 # v2.4.1
with:
tag_name: ${{ steps.version.outputs.result }}
token: ${{ steps.generate-token.outputs.token }}
body_path: ${{ steps.changelog.outputs.path }}
files: |
docker/docker-compose.yml
docker/example.env
docker/hwaccel.ml.yml
docker/hwaccel.transcoding.yml
docker/prometheus.yml
*.apk
- name: Rename Outline document
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
continue-on-error: true
env:
OUTLINE_API_KEY: ${{ secrets.OUTLINE_API_KEY }}
VERSION: ${{ steps.changelog.outputs.version }}
with:
github-token: ${{ steps.generate-token.outputs.token }}
script: |
const outlineKey = process.env.OUTLINE_API_KEY;
const version = process.env.VERSION;
const parentDocumentId = 'da856355-0844-43df-bd71-f8edce5382d9';
const baseUrl = 'https://outline.immich.cloud';
const listResponse = await fetch(`${baseUrl}/api/documents.list`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${outlineKey}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({ parentDocumentId })
});
if (!listResponse.ok) {
throw new Error(`Outline list failed: ${listResponse.statusText}`);
}
const listData = await listResponse.json();
const allDocuments = listData.data || [];
const document = allDocuments.find(doc => doc.title === 'next');
if (document) {
console.log(`Found document 'next', renaming to '${version}'...`);
const updateResponse = await fetch(`${baseUrl}/api/documents.update`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${outlineKey}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
id: document.id,
title: version
})
});
if (!updateResponse.ok) {
throw new Error(`Failed to rename document: ${updateResponse.statusText}`);
}
} else {
console.log('No document titled "next" found to rename');
}

@ -500,8 +500,16 @@ jobs:
run: docker compose build
if: ${{ !cancelled() }}
- name: Run e2e tests (web)
env:
CI: true
run: npx playwright test
if: ${{ !cancelled() }}
- name: Archive test results
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
if: success() || failure()
with:
name: e2e-web-test-results-${{ matrix.runner }}
path: e2e/playwright-report/
success-check-e2e:
name: End-to-End Tests Success
needs: [e2e-tests-server-cli, e2e-tests-web]

@ -17,6 +17,9 @@ dev-docs:
e2e:
@trap 'make e2e-down' EXIT; COMPOSE_BAKE=true docker compose -f ./e2e/docker-compose.yml up --remove-orphans
e2e-dev:
@trap 'make e2e-down' EXIT; COMPOSE_BAKE=true docker compose -f ./e2e/docker-compose.dev.yml up --remove-orphans
e2e-update:
@trap 'make e2e-down' EXIT; COMPOSE_BAKE=true docker compose -f ./e2e/docker-compose.yml up --build -V --remove-orphans

@ -20,7 +20,7 @@
"@types/lodash-es": "^4.17.12",
"@types/micromatch": "^4.0.9",
"@types/mock-fs": "^4.13.1",
"@types/node": "^22.19.0",
"@types/node": "^22.19.1",
"@vitest/coverage-v8": "^3.0.0",
"byte-size": "^9.0.0",
"cli-progress": "^3.12.0",

@ -256,7 +256,7 @@ The Dev Container supports multiple ways to run tests:
```bash
# Run tests for specific components
make test-server # Server unit tests
make test-server # Server unit tests
make test-web # Web unit tests
make test-e2e # End-to-end tests
make test-cli # CLI tests
@ -268,12 +268,13 @@ make test-all # Runs tests for all components
make test-medium-dev # End-to-end tests
```
#### Using NPM Directly
#### Using PNPM Directly
```bash
# Server tests
cd /workspaces/immich/server
pnpm test # Run all tests
pnpm test # Run all tests
pnpm run test:medium # Medium tests (integration tests)
pnpm run test:watch # Watch mode
pnpm run test:cov # Coverage report
@ -293,21 +294,21 @@ pnpm run test:web # Run web UI tests
```bash
# Linting
make lint-server # Lint server code
make lint-web # Lint web code
make lint-all # Lint all components
make lint-web # Lint web code
make lint-all # Lint all components
# Formatting
make format-server # Format server code
make format-web # Format web code
make format-all # Format all code
make format-web # Format web code
make format-all # Format all code
# Type checking
make check-server # Type check server
make check-web # Type check web
make check-all # Check all components
make check-web # Type check web
make check-all # Check all components
# Complete hygiene check
make hygiene-all # Runs lint, format, check, SQL sync, and audit
make hygiene-all # Run lint, format, check, SQL sync, and audit
```
### Additional Make Commands
@ -315,21 +316,21 @@ make hygiene-all # Runs lint, format, check, SQL sync, and audit
```bash
# Build commands
make build-server # Build server
make build-web # Build web app
make build-all # Build everything
make build-web # Build web app
make build-all # Build everything
# API generation
make open-api # Generate OpenAPI specs
make open-api # Generate OpenAPI specs
make open-api-typescript # Generate TypeScript SDK
make open-api-dart # Generate Dart SDK
make open-api-dart # Generate Dart SDK
# Database
make sql # Sync database schema
make sql # Sync database schema
# Dependencies
make install-server # Install server dependencies
make install-web # Install web dependencies
make install-all # Install all dependencies
make install-server # Install server dependencies
make install-web # Install web dependencies
make install-all # Install all dependencies
```
### Debugging

1
e2e/.gitignore vendored

@ -4,3 +4,4 @@ node_modules/
/blob-report/
/playwright/.cache/
/dist
.env

@ -0,0 +1,105 @@
name: immich-e2e
services:
immich-server:
container_name: immich-e2e-server
command: ['immich-dev']
image: immich-server-dev:latest
build:
context: ../
dockerfile: server/Dockerfile.dev
target: dev
environment:
- DB_HOSTNAME=database
- DB_USERNAME=postgres
- DB_PASSWORD=postgres
- DB_DATABASE_NAME=immich
- IMMICH_MACHINE_LEARNING_ENABLED=false
- IMMICH_TELEMETRY_INCLUDE=all
- IMMICH_ENV=testing
- IMMICH_PORT=2285
- IMMICH_IGNORE_MOUNT_CHECK_ERRORS=true
volumes:
- ./test-assets:/test-assets
- ..:/usr/src/app
- ${UPLOAD_LOCATION}/photos:/data
- /etc/localtime:/etc/localtime:ro
- pnpm-store:/usr/src/app/.pnpm-store
- server-node_modules:/usr/src/app/server/node_modules
- web-node_modules:/usr/src/app/web/node_modules
- github-node_modules:/usr/src/app/.github/node_modules
- cli-node_modules:/usr/src/app/cli/node_modules
- docs-node_modules:/usr/src/app/docs/node_modules
- e2e-node_modules:/usr/src/app/e2e/node_modules
- sdk-node_modules:/usr/src/app/open-api/typescript-sdk/node_modules
- app-node_modules:/usr/src/app/node_modules
- sveltekit:/usr/src/app/web/.svelte-kit
- coverage:/usr/src/app/web/coverage
- ../plugins:/build/corePlugin
depends_on:
redis:
condition: service_started
database:
condition: service_healthy
immich-web:
container_name: immich-e2e-web
image: immich-web-dev:latest
build:
context: ../
dockerfile: server/Dockerfile.dev
target: dev
command: ['immich-web']
ports:
- 2285:3000
environment:
- IMMICH_SERVER_URL=http://immich-server:2285/
volumes:
- ..:/usr/src/app
- pnpm-store:/usr/src/app/.pnpm-store
- server-node_modules:/usr/src/app/server/node_modules
- web-node_modules:/usr/src/app/web/node_modules
- github-node_modules:/usr/src/app/.github/node_modules
- cli-node_modules:/usr/src/app/cli/node_modules
- docs-node_modules:/usr/src/app/docs/node_modules
- e2e-node_modules:/usr/src/app/e2e/node_modules
- sdk-node_modules:/usr/src/app/open-api/typescript-sdk/node_modules
- app-node_modules:/usr/src/app/node_modules
- sveltekit:/usr/src/app/web/.svelte-kit
- coverage:/usr/src/app/web/coverage
restart: unless-stopped
redis:
image: redis:6.2-alpine@sha256:37e002448575b32a599109664107e374c8709546905c372a34d64919043b9ceb
database:
image: ghcr.io/immich-app/postgres:14-vectorchord0.3.0@sha256:6f3e9d2c2177af16c2988ff71425d79d89ca630ec2f9c8db03209ab716542338
command: -c fsync=off -c shared_preload_libraries=vchord.so -c config_file=/var/lib/postgresql/data/postgresql.conf
environment:
POSTGRES_PASSWORD: postgres
POSTGRES_USER: postgres
POSTGRES_DB: immich
ports:
- 5435:5432
healthcheck:
test: ['CMD-SHELL', 'pg_isready -U postgres -d immich']
interval: 1s
timeout: 5s
retries: 30
start_period: 10s
volumes:
model-cache:
prometheus-data:
grafana-data:
pnpm-store:
server-node_modules:
web-node_modules:
github-node_modules:
cli-node_modules:
docs-node_modules:
e2e-node_modules:
sdk-node_modules:
app-node_modules:
sveltekit:
coverage:

@ -20,16 +20,18 @@
"license": "GNU Affero General Public License version 3",
"devDependencies": {
"@eslint/js": "^9.8.0",
"@faker-js/faker": "^10.1.0",
"@immich/cli": "file:../cli",
"@immich/sdk": "file:../open-api/typescript-sdk",
"@playwright/test": "^1.44.1",
"@socket.io/component-emitter": "^3.1.2",
"@types/luxon": "^3.4.2",
"@types/node": "^22.19.0",
"@types/node": "^22.19.1",
"@types/oidc-provider": "^9.0.0",
"@types/pg": "^8.15.1",
"@types/pngjs": "^6.0.4",
"@types/supertest": "^6.0.2",
"dotenv": "^17.2.3",
"eslint": "^9.14.0",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-prettier": "^5.1.3",

@ -1,23 +1,50 @@
import { defineConfig, devices } from '@playwright/test';
import { defineConfig, devices, PlaywrightTestConfig } from '@playwright/test';
import dotenv from 'dotenv';
import { cpus } from 'node:os';
import { resolve } from 'node:path';
export default defineConfig({
dotenv.config({ path: resolve(import.meta.dirname, '.env') });
export const playwrightHost = process.env.PLAYWRIGHT_HOST ?? '127.0.0.1';
export const playwrightDbHost = process.env.PLAYWRIGHT_DB_HOST ?? '127.0.0.1';
export const playwriteBaseUrl = process.env.PLAYWRIGHT_BASE_URL ?? `http://${playwrightHost}:2285`;
export const playwriteSlowMo = parseInt(process.env.PLAYWRIGHT_SLOW_MO ?? '0');
export const playwrightDisableWebserver = process.env.PLAYWRIGHT_DISABLE_WEBSERVER;
process.env.PW_EXPERIMENTAL_SERVICE_WORKER_NETWORK_EVENTS = '1';
const config: PlaywrightTestConfig = {
testDir: './src/web/specs',
fullyParallel: false,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: 1,
retries: process.env.CI ? 4 : 0,
reporter: 'html',
use: {
baseURL: 'http://127.0.0.1:2285',
baseURL: playwriteBaseUrl,
trace: 'on-first-retry',
screenshot: 'only-on-failure',
launchOptions: {
slowMo: playwriteSlowMo,
},
},
testMatch: /.*\.e2e-spec\.ts/,
workers: process.env.CI ? 4 : Math.round(cpus().length * 0.75),
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
testMatch: /.*\.e2e-spec\.ts/,
workers: 1,
},
{
name: 'parallel tests',
use: { ...devices['Desktop Chrome'] },
testMatch: /.*\.parallel-e2e-spec\.ts/,
fullyParallel: true,
workers: process.env.CI ? 3 : Math.max(1, Math.round(cpus().length * 0.75) - 1),
},
// {
@ -59,4 +86,8 @@ export default defineConfig({
stderr: 'pipe',
reuseExistingServer: true,
},
});
};
if (playwrightDisableWebserver) {
delete config.webServer;
}
export default defineConfig(config);

@ -0,0 +1,37 @@
export { generateTimelineData } from './timeline/model-objects';
export { createDefaultTimelineConfig, validateTimelineConfig } from './timeline/timeline-config';
export type {
MockAlbum,
MonthSpec,
SerializedTimelineData,
MockTimelineAsset as TimelineAssetConfig,
TimelineConfig,
MockTimelineData as TimelineData,
} from './timeline/timeline-config';
export {
getAlbum,
getAsset,
getTimeBucket,
getTimeBuckets,
toAssetResponseDto,
toColumnarFormat,
} from './timeline/rest-response';
export type { Changes } from './timeline/rest-response';
export { randomImage, randomImageFromString, randomPreview, randomThumbnail } from './timeline/images';
export {
SeededRandom,
getMockAsset,
parseTimeBucketKey,
selectRandom,
selectRandomDays,
selectRandomMultiple,
} from './timeline/utils';
export { ASSET_DISTRIBUTION, DAY_DISTRIBUTION } from './timeline/distribution-patterns';
export type { DayPattern, MonthDistribution } from './timeline/distribution-patterns';

@ -0,0 +1,183 @@
import { generateConsecutiveDays, generateDayAssets } from 'src/generators/timeline/model-objects';
import { SeededRandom, selectRandomDays } from 'src/generators/timeline/utils';
import type { MockTimelineAsset } from './timeline-config';
import { GENERATION_CONSTANTS } from './timeline-config';
type AssetDistributionStrategy = (rng: SeededRandom) => number;
type DayDistributionStrategy = (
year: number,
month: number,
daysInMonth: number,
totalAssets: number,
ownerId: string,
rng: SeededRandom,
) => MockTimelineAsset[];
/**
* Strategies for determining total asset count per month
*/
export const ASSET_DISTRIBUTION: Record<MonthDistribution, AssetDistributionStrategy | null> = {
empty: null, // Special case - handled separately
sparse: (rng) => rng.nextInt(3, 9), // 3-8 assets
medium: (rng) => rng.nextInt(15, 31), // 15-30 assets
dense: (rng) => rng.nextInt(50, 81), // 50-80 assets
'very-dense': (rng) => rng.nextInt(80, 151), // 80-150 assets
};
/**
* Strategies for distributing assets across days within a month
*/
export const DAY_DISTRIBUTION: Record<DayPattern, DayDistributionStrategy> = {
'single-day': (year, month, daysInMonth, totalAssets, ownerId, rng) => {
// All assets on one day in the middle of the month
const day = Math.floor(daysInMonth / 2);
return generateDayAssets(year, month, day, totalAssets, ownerId, rng);
},
'consecutive-large': (year, month, daysInMonth, totalAssets, ownerId, rng) => {
// 3-5 consecutive days with evenly distributed assets
const numDays = Math.min(5, Math.floor(totalAssets / 15));
const startDay = rng.nextInt(1, daysInMonth - numDays + 2);
return generateConsecutiveDays(year, month, startDay, numDays, totalAssets, ownerId, rng);
},
'consecutive-small': (year, month, daysInMonth, totalAssets, ownerId, rng) => {
// Multiple consecutive days with 1-3 assets each (side-by-side layout)
const assets: MockTimelineAsset[] = [];
const numDays = Math.min(totalAssets, Math.floor(daysInMonth / 2));
const startDay = rng.nextInt(1, daysInMonth - numDays + 2);
let assetIndex = 0;
for (let i = 0; i < numDays && assetIndex < totalAssets; i++) {
const dayAssets = Math.min(3, rng.nextInt(1, 4));
const actualAssets = Math.min(dayAssets, totalAssets - assetIndex);
// Create a new RNG for this day
const dayRng = new SeededRandom(rng.nextInt(0, 1_000_000));
assets.push(...generateDayAssets(year, month, startDay + i, actualAssets, ownerId, dayRng));
assetIndex += actualAssets;
}
return assets;
},
alternating: (year, month, daysInMonth, totalAssets, ownerId, rng) => {
// Alternate between large (15-25) and small (1-3) days
const assets: MockTimelineAsset[] = [];
let day = 1;
let isLarge = true;
let assetIndex = 0;
while (assetIndex < totalAssets && day <= daysInMonth) {
const dayAssets = isLarge ? Math.min(25, rng.nextInt(15, 26)) : rng.nextInt(1, 4);
const actualAssets = Math.min(dayAssets, totalAssets - assetIndex);
// Create a new RNG for this day
const dayRng = new SeededRandom(rng.nextInt(0, 1_000_000));
assets.push(...generateDayAssets(year, month, day, actualAssets, ownerId, dayRng));
assetIndex += actualAssets;
day += isLarge ? 1 : 1; // Could add gaps here
isLarge = !isLarge;
}
return assets;
},
'sparse-scattered': (year, month, daysInMonth, totalAssets, ownerId, rng) => {
// Spread assets across random days with gaps
const assets: MockTimelineAsset[] = [];
const numDays = Math.min(totalAssets, Math.floor(daysInMonth * GENERATION_CONSTANTS.SPARSE_DAY_COVERAGE));
const daysWithPhotos = selectRandomDays(daysInMonth, numDays, rng);
let assetIndex = 0;
for (let i = 0; i < daysWithPhotos.length && assetIndex < totalAssets; i++) {
const dayAssets =
Math.floor(totalAssets / numDays) + (i === daysWithPhotos.length - 1 ? totalAssets % numDays : 0);
// Create a new RNG for this day
const dayRng = new SeededRandom(rng.nextInt(0, 1_000_000));
assets.push(...generateDayAssets(year, month, daysWithPhotos[i], dayAssets, ownerId, dayRng));
assetIndex += dayAssets;
}
return assets;
},
'start-heavy': (year, month, daysInMonth, totalAssets, ownerId, rng) => {
// Most assets in first week
const assets: MockTimelineAsset[] = [];
const firstWeekAssets = Math.floor(totalAssets * 0.7);
const remainingAssets = totalAssets - firstWeekAssets;
// First 7 days
assets.push(...generateConsecutiveDays(year, month, 1, 7, firstWeekAssets, ownerId, rng));
// Remaining scattered
if (remainingAssets > 0) {
const midDay = Math.floor(daysInMonth / 2);
// Create a new RNG for the remaining assets
const remainingRng = new SeededRandom(rng.nextInt(0, 1_000_000));
assets.push(...generateDayAssets(year, month, midDay, remainingAssets, ownerId, remainingRng));
}
return assets;
},
'end-heavy': (year, month, daysInMonth, totalAssets, ownerId, rng) => {
// Most assets in last week
const assets: MockTimelineAsset[] = [];
const lastWeekAssets = Math.floor(totalAssets * 0.7);
const remainingAssets = totalAssets - lastWeekAssets;
// Remaining at start
if (remainingAssets > 0) {
// Create a new RNG for the start assets
const startRng = new SeededRandom(rng.nextInt(0, 1_000_000));
assets.push(...generateDayAssets(year, month, 2, remainingAssets, ownerId, startRng));
}
// Last 7 days
const startDay = daysInMonth - 6;
assets.push(...generateConsecutiveDays(year, month, startDay, 7, lastWeekAssets, ownerId, rng));
return assets;
},
'mid-heavy': (year, month, daysInMonth, totalAssets, ownerId, rng) => {
// Most assets in middle of month
const assets: MockTimelineAsset[] = [];
const midAssets = Math.floor(totalAssets * 0.7);
const sideAssets = Math.floor((totalAssets - midAssets) / 2);
// Start
if (sideAssets > 0) {
// Create a new RNG for the start assets
const startRng = new SeededRandom(rng.nextInt(0, 1_000_000));
assets.push(...generateDayAssets(year, month, 2, sideAssets, ownerId, startRng));
}
// Middle
const midStart = Math.floor(daysInMonth / 2) - 3;
assets.push(...generateConsecutiveDays(year, month, midStart, 7, midAssets, ownerId, rng));
// End
const endAssets = totalAssets - midAssets - sideAssets;
if (endAssets > 0) {
// Create a new RNG for the end assets
const endRng = new SeededRandom(rng.nextInt(0, 1_000_000));
assets.push(...generateDayAssets(year, month, daysInMonth - 1, endAssets, ownerId, endRng));
}
return assets;
},
};
export type MonthDistribution =
| 'empty' // 0 assets
| 'sparse' // 3-8 assets
| 'medium' // 15-30 assets
| 'dense' // 50-80 assets
| 'very-dense'; // 80-150 assets
export type DayPattern =
| 'single-day' // All images in one day
| 'consecutive-large' // Multiple days with 15-25 images each
| 'consecutive-small' // Multiple days with 1-3 images each (side-by-side)
| 'alternating' // Alternating large/small days
| 'sparse-scattered' // Few images scattered across month
| 'start-heavy' // Most images at start of month
| 'end-heavy' // Most images at end of month
| 'mid-heavy'; // Most images in middle of month

@ -0,0 +1,111 @@
import sharp from 'sharp';
import { SeededRandom } from 'src/generators/timeline/utils';
export const randomThumbnail = async (seed: string, ratio: number) => {
const height = 235;
const width = Math.round(height * ratio);
return randomImageFromString(seed, { width, height });
};
export const randomPreview = async (seed: string, ratio: number) => {
const height = 500;
const width = Math.round(height * ratio);
return randomImageFromString(seed, { width, height });
};
export const randomImageFromString = async (
seed: string = '',
{ width = 100, height = 100 }: { width: number; height: number },
) => {
// Convert string to number for seeding
let seedNumber = 0;
for (let i = 0; i < seed.length; i++) {
seedNumber = (seedNumber << 5) - seedNumber + (seed.codePointAt(i) ?? 0);
seedNumber = seedNumber & seedNumber; // Convert to 32bit integer
}
return randomImage(new SeededRandom(Math.abs(seedNumber)), { width, height });
};
export const randomImage = async (rng: SeededRandom, { width, height }: { width: number; height: number }) => {
const r1 = rng.nextInt(0, 256);
const g1 = rng.nextInt(0, 256);
const b1 = rng.nextInt(0, 256);
const r2 = rng.nextInt(0, 256);
const g2 = rng.nextInt(0, 256);
const b2 = rng.nextInt(0, 256);
const patternType = rng.nextInt(0, 5);
let svgPattern = '';
switch (patternType) {
case 0: {
// Solid color
svgPattern = `<svg width="${width}" height="${height}">
<rect x="0" y="0" width="${width}" height="${height}" fill="rgb(${r1},${g1},${b1})"/>
</svg>`;
break;
}
case 1: {
// Horizontal stripes
const stripeHeight = 10;
svgPattern = `<svg width="${width}" height="${height}">
${Array.from(
{ length: height / stripeHeight },
(_, i) =>
`<rect x="0" y="${i * stripeHeight}" width="${width}" height="${stripeHeight}"
fill="rgb(${i % 2 ? r1 : r2},${i % 2 ? g1 : g2},${i % 2 ? b1 : b2})"/>`,
).join('')}
</svg>`;
break;
}
case 2: {
// Vertical stripes
const stripeWidth = 10;
svgPattern = `<svg width="${width}" height="${height}">
${Array.from(
{ length: width / stripeWidth },
(_, i) =>
`<rect x="${i * stripeWidth}" y="0" width="${stripeWidth}" height="${height}"
fill="rgb(${i % 2 ? r1 : r2},${i % 2 ? g1 : g2},${i % 2 ? b1 : b2})"/>`,
).join('')}
</svg>`;
break;
}
case 3: {
// Checkerboard
const squareSize = 10;
svgPattern = `<svg width="${width}" height="${height}">
${Array.from({ length: height / squareSize }, (_, row) =>
Array.from({ length: width / squareSize }, (_, col) => {
const isEven = (row + col) % 2 === 0;
return `<rect x="${col * squareSize}" y="${row * squareSize}"
width="${squareSize}" height="${squareSize}"
fill="rgb(${isEven ? r1 : r2},${isEven ? g1 : g2},${isEven ? b1 : b2})"/>`;
}).join(''),
).join('')}
</svg>`;
break;
}
case 4: {
// Diagonal stripes
svgPattern = `<svg width="${width}" height="${height}">
<defs>
<pattern id="diagonal" x="0" y="0" width="20" height="20" patternUnits="userSpaceOnUse">
<rect x="0" y="0" width="10" height="20" fill="rgb(${r1},${g1},${b1})"/>
<rect x="10" y="0" width="10" height="20" fill="rgb(${r2},${g2},${b2})"/>
</pattern>
</defs>
<rect x="0" y="0" width="${width}" height="${height}" fill="url(#diagonal)" transform="rotate(45 50 50)"/>
</svg>`;
break;
}
}
const svgBuffer = Buffer.from(svgPattern);
const jpegData = await sharp(svgBuffer).jpeg({ quality: 50 }).toBuffer();
return jpegData;
};

@ -0,0 +1,265 @@
/**
* Generator functions for timeline model objects
*/
import { faker } from '@faker-js/faker';
import { AssetVisibility } from '@immich/sdk';
import { DateTime } from 'luxon';
import { writeFileSync } from 'node:fs';
import { SeededRandom } from 'src/generators/timeline/utils';
import type { DayPattern, MonthDistribution } from './distribution-patterns';
import { ASSET_DISTRIBUTION, DAY_DISTRIBUTION } from './distribution-patterns';
import type { MockTimelineAsset, MockTimelineData, SerializedTimelineData, TimelineConfig } from './timeline-config';
import { ASPECT_RATIO_WEIGHTS, GENERATION_CONSTANTS, validateTimelineConfig } from './timeline-config';
/**
* Generate a random aspect ratio based on weighted probabilities
*/
export function generateAspectRatio(rng: SeededRandom): string {
const random = rng.next();
let cumulative = 0;
for (const [ratio, weight] of Object.entries(ASPECT_RATIO_WEIGHTS)) {
cumulative += weight;
if (random < cumulative) {
return ratio;
}
}
return '16:9'; // Default fallback
}
export function generateThumbhash(rng: SeededRandom): string {
return Array.from({ length: 10 }, () => rng.nextInt(0, 256).toString(16).padStart(2, '0')).join('');
}
export function generateDuration(rng: SeededRandom): string {
return `${rng.nextInt(GENERATION_CONSTANTS.MIN_VIDEO_DURATION_SECONDS, GENERATION_CONSTANTS.MAX_VIDEO_DURATION_SECONDS)}.${rng.nextInt(0, 1000).toString().padStart(3, '0')}`;
}
export function generateUUID(): string {
return faker.string.uuid();
}
export function generateAsset(
year: number,
month: number,
day: number,
ownerId: string,
rng: SeededRandom,
): MockTimelineAsset {
const from = DateTime.fromObject({ year, month, day }).setZone('UTC');
const to = from.endOf('day');
const date = faker.date.between({ from: from.toJSDate(), to: to.toJSDate() });
const isVideo = rng.next() < GENERATION_CONSTANTS.VIDEO_PROBABILITY;
const assetId = generateUUID();
const hasGPS = rng.next() < GENERATION_CONSTANTS.GPS_PERCENTAGE;
const ratio = generateAspectRatio(rng);
const asset: MockTimelineAsset = {
id: assetId,
ownerId,
ratio: Number.parseFloat(ratio.split(':')[0]) / Number.parseFloat(ratio.split(':')[1]),
thumbhash: generateThumbhash(rng),
localDateTime: date.toISOString(),
fileCreatedAt: date.toISOString(),
isFavorite: rng.next() < GENERATION_CONSTANTS.FAVORITE_PROBABILITY,
isTrashed: false,
isVideo,
isImage: !isVideo,
duration: isVideo ? generateDuration(rng) : null,
projectionType: null,
livePhotoVideoId: null,
city: hasGPS ? faker.location.city() : null,
country: hasGPS ? faker.location.country() : null,
people: null,
latitude: hasGPS ? faker.location.latitude() : null,
longitude: hasGPS ? faker.location.longitude() : null,
visibility: AssetVisibility.Timeline,
stack: null,
fileSizeInByte: faker.number.int({ min: 510, max: 5_000_000 }),
checksum: faker.string.alphanumeric({ length: 5 }),
};
return asset;
}
/**
* Generate assets for a specific day
*/
export function generateDayAssets(
year: number,
month: number,
day: number,
assetCount: number,
ownerId: string,
rng: SeededRandom,
): MockTimelineAsset[] {
return Array.from({ length: assetCount }, () => generateAsset(year, month, day, ownerId, rng));
}
/**
* Distribute assets evenly across consecutive days
*
* @returns Array of generated timeline assets
*/
export function generateConsecutiveDays(
year: number,
month: number,
startDay: number,
numDays: number,
totalAssets: number,
ownerId: string,
rng: SeededRandom,
): MockTimelineAsset[] {
const assets: MockTimelineAsset[] = [];
const assetsPerDay = Math.floor(totalAssets / numDays);
for (let i = 0; i < numDays; i++) {
const dayAssets =
i === numDays - 1
? totalAssets - assetsPerDay * (numDays - 1) // Remainder on last day
: assetsPerDay;
// Create a new RNG with a different seed for each day
const dayRng = new SeededRandom(rng.nextInt(0, 1_000_000) + i * 100);
assets.push(...generateDayAssets(year, month, startDay + i, dayAssets, ownerId, dayRng));
}
return assets;
}
/**
* Generate assets for a month with specified distribution pattern
*/
export function generateMonthAssets(
year: number,
month: number,
ownerId: string,
distribution: MonthDistribution = 'medium',
pattern: DayPattern = 'consecutive-large',
rng: SeededRandom,
): MockTimelineAsset[] {
const daysInMonth = new Date(year, month, 0).getDate();
if (distribution === 'empty') {
return [];
}
const distributionStrategy = ASSET_DISTRIBUTION[distribution];
if (!distributionStrategy) {
console.warn(`Unknown distribution: ${distribution}, defaulting to medium`);
return [];
}
const totalAssets = distributionStrategy(rng);
const dayStrategy = DAY_DISTRIBUTION[pattern];
if (!dayStrategy) {
console.warn(`Unknown pattern: ${pattern}, defaulting to consecutive-large`);
// Fallback to consecutive-large pattern
const numDays = Math.min(5, Math.floor(totalAssets / 15));
const startDay = rng.nextInt(1, daysInMonth - numDays + 2);
const assets = generateConsecutiveDays(year, month, startDay, numDays, totalAssets, ownerId, rng);
assets.sort((a, b) => DateTime.fromISO(b.localDateTime).diff(DateTime.fromISO(a.localDateTime)).milliseconds);
return assets;
}
const assets = dayStrategy(year, month, daysInMonth, totalAssets, ownerId, rng);
assets.sort((a, b) => DateTime.fromISO(b.localDateTime).diff(DateTime.fromISO(a.localDateTime)).milliseconds);
return assets;
}
/**
* Main generator function for timeline data
*/
export function generateTimelineData(config: TimelineConfig): MockTimelineData {
validateTimelineConfig(config);
const buckets = new Map<string, MockTimelineAsset[]>();
const monthStats: Record<string, { count: number; distribution: MonthDistribution; pattern: DayPattern }> = {};
const globalRng = new SeededRandom(config.seed || GENERATION_CONSTANTS.DEFAULT_SEED);
faker.seed(globalRng.nextInt(0, 1_000_000));
for (const monthConfig of config.months) {
const { year, month, distribution, pattern } = monthConfig;
const monthSeed = globalRng.nextInt(0, 1_000_000);
const monthRng = new SeededRandom(monthSeed);
const monthAssets = generateMonthAssets(
year,
month,
config.ownerId || generateUUID(),
distribution,
pattern,
monthRng,
);
if (monthAssets.length > 0) {
const monthKey = `${year}-${month.toString().padStart(2, '0')}`;
monthStats[monthKey] = {
count: monthAssets.length,
distribution,
pattern,
};
// Create bucket key (YYYY-MM-01)
const bucketKey = `${year}-${month.toString().padStart(2, '0')}-01`;
buckets.set(bucketKey, monthAssets);
}
}
// Create a mock album from random assets
const allAssets = [...buckets.values()].flat();
// Select 10-30 random assets for the album (or all assets if less than 10)
const albumSize = Math.min(allAssets.length, globalRng.nextInt(10, 31));
const selectedAssetConfigs: MockTimelineAsset[] = [];
const usedIndices = new Set<number>();
while (selectedAssetConfigs.length < albumSize && usedIndices.size < allAssets.length) {
const randomIndex = globalRng.nextInt(0, allAssets.length);
if (!usedIndices.has(randomIndex)) {
usedIndices.add(randomIndex);
selectedAssetConfigs.push(allAssets[randomIndex]);
}
}
// Sort selected assets by date (newest first)
selectedAssetConfigs.sort(
(a, b) => DateTime.fromISO(b.localDateTime).diff(DateTime.fromISO(a.localDateTime)).milliseconds,
);
const selectedAssets = selectedAssetConfigs.map((asset) => asset.id);
const now = new Date().toISOString();
const album = {
id: generateUUID(),
albumName: 'Test Album',
description: 'A mock album for testing',
assetIds: selectedAssets,
thumbnailAssetId: selectedAssets.length > 0 ? selectedAssets[0] : null,
createdAt: now,
updatedAt: now,
};
// Write to file if configured
if (config.writeToFile) {
const outputPath = config.outputPath || '/tmp/timeline-data.json';
// Convert Map to object for serialization
const serializedData: SerializedTimelineData = {
buckets: Object.fromEntries(buckets),
album,
};
try {
writeFileSync(outputPath, JSON.stringify(serializedData, null, 2));
console.log(`Timeline data written to ${outputPath}`);
} catch (error) {
console.error(`Failed to write timeline data to ${outputPath}:`, error);
}
}
return { buckets, album };
}

@ -0,0 +1,436 @@
/**
* REST API output functions for converting timeline data to API response formats
*/
import {
AssetTypeEnum,
AssetVisibility,
UserAvatarColor,
type AlbumResponseDto,
type AssetResponseDto,
type ExifResponseDto,
type TimeBucketAssetResponseDto,
type TimeBucketsResponseDto,
type UserResponseDto,
} from '@immich/sdk';
import { DateTime } from 'luxon';
import { signupDto } from 'src/fixtures';
import { parseTimeBucketKey } from 'src/generators/timeline/utils';
import type { MockTimelineAsset, MockTimelineData } from './timeline-config';
/**
* Convert timeline/asset models to columnar format (parallel arrays)
*/
export function toColumnarFormat(assets: MockTimelineAsset[]): TimeBucketAssetResponseDto {
const result: TimeBucketAssetResponseDto = {
id: [],
ownerId: [],
ratio: [],
thumbhash: [],
fileCreatedAt: [],
localOffsetHours: [],
isFavorite: [],
isTrashed: [],
isImage: [],
duration: [],
projectionType: [],
livePhotoVideoId: [],
city: [],
country: [],
visibility: [],
};
for (const asset of assets) {
result.id.push(asset.id);
result.ownerId.push(asset.ownerId);
result.ratio.push(asset.ratio);
result.thumbhash.push(asset.thumbhash);
result.fileCreatedAt.push(asset.fileCreatedAt);
result.localOffsetHours.push(0); // Assuming UTC for mocks
result.isFavorite.push(asset.isFavorite);
result.isTrashed.push(asset.isTrashed);
result.isImage.push(asset.isImage);
result.duration.push(asset.duration);
result.projectionType.push(asset.projectionType);
result.livePhotoVideoId.push(asset.livePhotoVideoId);
result.city.push(asset.city);
result.country.push(asset.country);
result.visibility.push(asset.visibility);
}
if (assets.some((a) => a.latitude !== null || a.longitude !== null)) {
result.latitude = assets.map((a) => a.latitude);
result.longitude = assets.map((a) => a.longitude);
}
result.stack = assets.map(() => null);
return result;
}
/**
* Extract a single bucket from timeline data (mimics getTimeBucket API)
* Automatically handles both ISO timestamp and simple month formats
* Returns data in columnar format matching the actual API
* When albumId is provided, only returns assets from that album
*/
export function getTimeBucket(
timelineData: MockTimelineData,
timeBucket: string,
isTrashed: boolean | undefined,
isArchived: boolean | undefined,
isFavorite: boolean | undefined,
albumId: string | undefined,
changes: Changes,
): TimeBucketAssetResponseDto {
const bucketKey = parseTimeBucketKey(timeBucket);
let assets = timelineData.buckets.get(bucketKey);
if (!assets) {
return toColumnarFormat([]);
}
// Create sets for quick lookups
const deletedAssetIds = new Set(changes.assetDeletions);
const archivedAssetIds = new Set(changes.assetArchivals);
const favoritedAssetIds = new Set(changes.assetFavorites);
// Filter assets based on trashed/archived status
assets = assets.filter((asset) =>
shouldIncludeAsset(asset, isTrashed, isArchived, isFavorite, deletedAssetIds, archivedAssetIds, favoritedAssetIds),
);
// Filter to only include assets from the specified album
if (albumId) {
const album = timelineData.album;
if (!album || album.id !== albumId) {
return toColumnarFormat([]);
}
// Create a Set for faster lookup
const albumAssetIds = new Set([...album.assetIds, ...changes.albumAdditions]);
assets = assets.filter((asset) => albumAssetIds.has(asset.id));
}
// Override properties for assets in changes arrays
const assetsWithOverrides = assets.map((asset) => {
if (deletedAssetIds.has(asset.id) || archivedAssetIds.has(asset.id) || favoritedAssetIds.has(asset.id)) {
return {
...asset,
isFavorite: favoritedAssetIds.has(asset.id) ? true : asset.isFavorite,
isTrashed: deletedAssetIds.has(asset.id) ? true : asset.isTrashed,
visibility: archivedAssetIds.has(asset.id) ? AssetVisibility.Archive : asset.visibility,
};
}
return asset;
});
return toColumnarFormat(assetsWithOverrides);
}
export type Changes = {
// ids of assets that are newly added to the album
albumAdditions: string[];
// ids of assets that are newly deleted
assetDeletions: string[];
// ids of assets that are newly archived
assetArchivals: string[];
// ids of assets that are newly favorited
assetFavorites: string[];
};
/**
* Helper function to determine if an asset should be included based on filter criteria
* @param asset - The asset to check
* @param isTrashed - Filter for trashed status (undefined means no filter)
* @param isArchived - Filter for archived status (undefined means no filter)
* @param isFavorite - Filter for favorite status (undefined means no filter)
* @param deletedAssetIds - Set of IDs for assets that have been deleted
* @param archivedAssetIds - Set of IDs for assets that have been archived
* @param favoritedAssetIds - Set of IDs for assets that have been favorited
* @returns true if the asset matches all filter criteria
*/
function shouldIncludeAsset(
asset: MockTimelineAsset,
isTrashed: boolean | undefined,
isArchived: boolean | undefined,
isFavorite: boolean | undefined,
deletedAssetIds: Set<string>,
archivedAssetIds: Set<string>,
favoritedAssetIds: Set<string>,
): boolean {
// Determine actual status (property or in changes)
const actuallyTrashed = asset.isTrashed || deletedAssetIds.has(asset.id);
const actuallyArchived = asset.visibility === 'archive' || archivedAssetIds.has(asset.id);
const actuallyFavorited = asset.isFavorite || favoritedAssetIds.has(asset.id);
// Apply filters
if (isTrashed !== undefined && actuallyTrashed !== isTrashed) {
return false;
}
if (isArchived !== undefined && actuallyArchived !== isArchived) {
return false;
}
if (isFavorite !== undefined && actuallyFavorited !== isFavorite) {
return false;
}
return true;
}
/**
* Get summary for all buckets (mimics getTimeBuckets API)
* When albumId is provided, only includes buckets that contain assets from that album
*/
export function getTimeBuckets(
timelineData: MockTimelineData,
isTrashed: boolean | undefined,
isArchived: boolean | undefined,
isFavorite: boolean | undefined,
albumId: string | undefined,
changes: Changes,
): TimeBucketsResponseDto[] {
const summary: TimeBucketsResponseDto[] = [];
// Create sets for quick lookups
const deletedAssetIds = new Set(changes.assetDeletions);
const archivedAssetIds = new Set(changes.assetArchivals);
const favoritedAssetIds = new Set(changes.assetFavorites);
// If no albumId is specified, return summary for all assets
if (albumId) {
// Filter to only include buckets with assets from the specified album
const album = timelineData.album;
if (!album || album.id !== albumId) {
return [];
}
// Create a Set for faster lookup
const albumAssetIds = new Set([...album.assetIds, ...changes.albumAdditions]);
for (const removed of changes.assetDeletions) {
albumAssetIds.delete(removed);
}
for (const [bucketKey, assets] of timelineData.buckets) {
// Count how many assets in this bucket are in the album and match trashed/archived filters
const albumAssetsInBucket = assets.filter((asset) => {
// Must be in the album
if (!albumAssetIds.has(asset.id)) {
return false;
}
return shouldIncludeAsset(
asset,
isTrashed,
isArchived,
isFavorite,
deletedAssetIds,
archivedAssetIds,
favoritedAssetIds,
);
});
if (albumAssetsInBucket.length > 0) {
summary.push({
timeBucket: bucketKey,
count: albumAssetsInBucket.length,
});
}
}
} else {
for (const [bucketKey, assets] of timelineData.buckets) {
// Filter assets based on trashed/archived status
const filteredAssets = assets.filter((asset) =>
shouldIncludeAsset(
asset,
isTrashed,
isArchived,
isFavorite,
deletedAssetIds,
archivedAssetIds,
favoritedAssetIds,
),
);
if (filteredAssets.length > 0) {
summary.push({
timeBucket: bucketKey,
count: filteredAssets.length,
});
}
}
}
// Sort summary by date (newest first) using luxon
summary.sort((a, b) => {
const dateA = DateTime.fromISO(a.timeBucket);
const dateB = DateTime.fromISO(b.timeBucket);
return dateB.diff(dateA).milliseconds;
});
return summary;
}
const createDefaultOwner = (ownerId: string) => {
const defaultOwner: UserResponseDto = {
id: ownerId,
email: signupDto.admin.email,
name: signupDto.admin.name,
profileImagePath: '',
profileChangedAt: new Date().toISOString(),
avatarColor: UserAvatarColor.Blue,
};
return defaultOwner;
};
/**
* Convert a TimelineAssetConfig to a full AssetResponseDto
* This matches the response from GET /api/assets/:id
*/
export function toAssetResponseDto(asset: MockTimelineAsset, owner?: UserResponseDto): AssetResponseDto {
const now = new Date().toISOString();
// Default owner if not provided
const defaultOwner = createDefaultOwner(asset.ownerId);
const exifInfo: ExifResponseDto = {
make: null,
model: null,
exifImageWidth: asset.ratio > 1 ? 4000 : 3000,
exifImageHeight: asset.ratio > 1 ? Math.round(4000 / asset.ratio) : Math.round(3000 * asset.ratio),
fileSizeInByte: asset.fileSizeInByte,
orientation: '1',
dateTimeOriginal: asset.fileCreatedAt,
modifyDate: asset.fileCreatedAt,
timeZone: asset.latitude === null ? null : 'UTC',
lensModel: null,
fNumber: null,
focalLength: null,
iso: null,
exposureTime: null,
latitude: asset.latitude,
longitude: asset.longitude,
city: asset.city,
country: asset.country,
state: null,
description: null,
};
return {
id: asset.id,
deviceAssetId: `device-${asset.id}`,
ownerId: asset.ownerId,
owner: owner || defaultOwner,
libraryId: `library-${asset.ownerId}`,
deviceId: `device-${asset.ownerId}`,
type: asset.isVideo ? AssetTypeEnum.Video : AssetTypeEnum.Image,
originalPath: `/original/${asset.id}.${asset.isVideo ? 'mp4' : 'jpg'}`,
originalFileName: `${asset.id}.${asset.isVideo ? 'mp4' : 'jpg'}`,
originalMimeType: asset.isVideo ? 'video/mp4' : 'image/jpeg',
thumbhash: asset.thumbhash,
fileCreatedAt: asset.fileCreatedAt,
fileModifiedAt: asset.fileCreatedAt,
localDateTime: asset.localDateTime,
updatedAt: now,
createdAt: asset.fileCreatedAt,
isFavorite: asset.isFavorite,
isArchived: false,
isTrashed: asset.isTrashed,
visibility: asset.visibility,
duration: asset.duration || '0:00:00.00000',
exifInfo,
livePhotoVideoId: asset.livePhotoVideoId,
tags: [],
people: [],
unassignedFaces: [],
stack: asset.stack,
isOffline: false,
hasMetadata: true,
duplicateId: null,
resized: true,
checksum: asset.checksum,
};
}
/**
* Get a single asset by ID from timeline data
* This matches the response from GET /api/assets/:id
*/
export function getAsset(
timelineData: MockTimelineData,
assetId: string,
owner?: UserResponseDto,
): AssetResponseDto | undefined {
// Search through all buckets for the asset
const buckets = [...timelineData.buckets.values()];
for (const assets of buckets) {
const asset = assets.find((a) => a.id === assetId);
if (asset) {
return toAssetResponseDto(asset, owner);
}
}
return undefined;
}
/**
* Get a mock album from timeline data
* This matches the response from GET /api/albums/:id
*/
export function getAlbum(
timelineData: MockTimelineData,
ownerId: string,
albumId: string | undefined,
changes: Changes,
): AlbumResponseDto | undefined {
if (!timelineData.album) {
return undefined;
}
// If albumId is provided and doesn't match, return undefined
if (albumId && albumId !== timelineData.album.id) {
return undefined;
}
const album = timelineData.album;
const albumOwner = createDefaultOwner(ownerId);
// Get the actual asset objects from the timeline data
const albumAssets: AssetResponseDto[] = [];
const allAssets = [...timelineData.buckets.values()].flat();
for (const assetId of album.assetIds) {
const assetConfig = allAssets.find((a) => a.id === assetId);
if (assetConfig) {
albumAssets.push(toAssetResponseDto(assetConfig, albumOwner));
}
}
for (const assetId of changes.albumAdditions ?? []) {
const assetConfig = allAssets.find((a) => a.id === assetId);
if (assetConfig) {
albumAssets.push(toAssetResponseDto(assetConfig, albumOwner));
}
}
albumAssets.sort((a, b) => DateTime.fromISO(b.localDateTime).diff(DateTime.fromISO(a.localDateTime)).milliseconds);
// For a basic mock album, we don't include any albumUsers (shared users)
// The owner is represented by the owner field, not in albumUsers
const response: AlbumResponseDto = {
id: album.id,
albumName: album.albumName,
description: album.description,
albumThumbnailAssetId: album.thumbnailAssetId,
createdAt: album.createdAt,
updatedAt: album.updatedAt,
ownerId: albumOwner.id,
owner: albumOwner,
albumUsers: [], // Empty array for non-shared album
shared: false,
hasSharedLink: false,
isActivityEnabled: true,
assetCount: albumAssets.length,
assets: albumAssets,
startDate: albumAssets.length > 0 ? albumAssets.at(-1)?.fileCreatedAt : undefined,
endDate: albumAssets.length > 0 ? albumAssets[0].fileCreatedAt : undefined,
lastModifiedAssetTimestamp: albumAssets.length > 0 ? albumAssets[0].fileCreatedAt : undefined,
};
return response;
}

@ -0,0 +1,200 @@
import type { AssetVisibility } from '@immich/sdk';
import { DayPattern, MonthDistribution } from 'src/generators/timeline/distribution-patterns';
// Constants for generation parameters
export const GENERATION_CONSTANTS = {
VIDEO_PROBABILITY: 0.15, // 15% of assets are videos
GPS_PERCENTAGE: 0.7, // 70% of assets have GPS data
FAVORITE_PROBABILITY: 0.1, // 10% of assets are favorited
MIN_VIDEO_DURATION_SECONDS: 5,
MAX_VIDEO_DURATION_SECONDS: 300,
DEFAULT_SEED: 12_345,
DEFAULT_OWNER_ID: 'user-1',
MAX_SELECT_ATTEMPTS: 10,
SPARSE_DAY_COVERAGE: 0.4, // 40% of days have photos in sparse pattern
} as const;
// Aspect ratio distribution weights (must sum to 1)
export const ASPECT_RATIO_WEIGHTS = {
'4:3': 0.35, // 35% 4:3 landscape
'3:2': 0.25, // 25% 3:2 landscape
'16:9': 0.2, // 20% 16:9 landscape
'2:3': 0.1, // 10% 2:3 portrait
'1:1': 0.09, // 9% 1:1 square
'3:1': 0.01, // 1% 3:1 panorama
} as const;
export type AspectRatio = {
width: number;
height: number;
ratio: number;
name: string;
};
// Mock configuration for asset generation - will be transformed to API response formats
export type MockTimelineAsset = {
id: string;
ownerId: string;
ratio: number;
thumbhash: string | null;
localDateTime: string;
fileCreatedAt: string;
isFavorite: boolean;
isTrashed: boolean;
isVideo: boolean;
isImage: boolean;
duration: string | null;
projectionType: string | null;
livePhotoVideoId: string | null;
city: string | null;
country: string | null;
people: string[] | null;
latitude: number | null;
longitude: number | null;
visibility: AssetVisibility;
stack: null;
checksum: string;
fileSizeInByte: number;
};
export type MonthSpec = {
year: number;
month: number; // 1-12
distribution: MonthDistribution;
pattern: DayPattern;
};
/**
* Configuration for timeline data generation
*/
export type TimelineConfig = {
ownerId?: string;
months: MonthSpec[];
seed?: number;
writeToFile?: boolean;
outputPath?: string;
};
export type MockAlbum = {
id: string;
albumName: string;
description: string;
assetIds: string[]; // IDs of assets in the album
thumbnailAssetId: string | null;
createdAt: string;
updatedAt: string;
};
export type MockTimelineData = {
buckets: Map<string, MockTimelineAsset[]>;
album: MockAlbum; // Mock album created from random assets
};
export type SerializedTimelineData = {
buckets: Record<string, MockTimelineAsset[]>;
album: MockAlbum;
};
/**
* Validates a TimelineConfig object to ensure all values are within expected ranges
*/
export function validateTimelineConfig(config: TimelineConfig): void {
if (!config.months || config.months.length === 0) {
throw new Error('TimelineConfig must contain at least one month');
}
const seenMonths = new Set<string>();
for (const month of config.months) {
if (month.month < 1 || month.month > 12) {
throw new Error(`Invalid month: ${month.month}. Must be between 1 and 12`);
}
if (month.year < 1900 || month.year > 2100) {
throw new Error(`Invalid year: ${month.year}. Must be between 1900 and 2100`);
}
const monthKey = `${month.year}-${month.month}`;
if (seenMonths.has(monthKey)) {
throw new Error(`Duplicate month found: ${monthKey}`);
}
seenMonths.add(monthKey);
// Validate distribution if provided
if (month.distribution && !['empty', 'sparse', 'medium', 'dense', 'very-dense'].includes(month.distribution)) {
throw new Error(
`Invalid distribution: ${month.distribution}. Must be one of: empty, sparse, medium, dense, very-dense`,
);
}
const validPatterns = [
'single-day',
'consecutive-large',
'consecutive-small',
'alternating',
'sparse-scattered',
'start-heavy',
'end-heavy',
'mid-heavy',
];
if (month.pattern && !validPatterns.includes(month.pattern)) {
throw new Error(`Invalid pattern: ${month.pattern}. Must be one of: ${validPatterns.join(', ')}`);
}
}
// Validate seed if provided
if (config.seed !== undefined && (config.seed < 0 || !Number.isInteger(config.seed))) {
throw new Error('Seed must be a non-negative integer');
}
// Validate ownerId if provided
if (config.ownerId !== undefined && config.ownerId.trim() === '') {
throw new Error('Owner ID cannot be an empty string');
}
}
/**
* Create a default timeline configuration
*/
export function createDefaultTimelineConfig(): TimelineConfig {
const months: MonthSpec[] = [
// 2024 - Mix of patterns
{ year: 2024, month: 12, distribution: 'very-dense', pattern: 'alternating' },
{ year: 2024, month: 11, distribution: 'dense', pattern: 'consecutive-large' },
{ year: 2024, month: 10, distribution: 'medium', pattern: 'mid-heavy' },
{ year: 2024, month: 9, distribution: 'sparse', pattern: 'consecutive-small' },
{ year: 2024, month: 8, distribution: 'empty', pattern: 'single-day' },
{ year: 2024, month: 7, distribution: 'dense', pattern: 'start-heavy' },
{ year: 2024, month: 6, distribution: 'medium', pattern: 'sparse-scattered' },
{ year: 2024, month: 5, distribution: 'sparse', pattern: 'single-day' },
{ year: 2024, month: 4, distribution: 'very-dense', pattern: 'consecutive-large' },
{ year: 2024, month: 3, distribution: 'empty', pattern: 'single-day' },
{ year: 2024, month: 2, distribution: 'medium', pattern: 'end-heavy' },
{ year: 2024, month: 1, distribution: 'dense', pattern: 'alternating' },
// 2023 - Testing year boundaries and more patterns
{ year: 2023, month: 12, distribution: 'very-dense', pattern: 'end-heavy' },
{ year: 2023, month: 11, distribution: 'sparse', pattern: 'consecutive-small' },
{ year: 2023, month: 10, distribution: 'empty', pattern: 'single-day' },
{ year: 2023, month: 9, distribution: 'medium', pattern: 'alternating' },
{ year: 2023, month: 8, distribution: 'dense', pattern: 'mid-heavy' },
{ year: 2023, month: 7, distribution: 'sparse', pattern: 'sparse-scattered' },
{ year: 2023, month: 6, distribution: 'medium', pattern: 'consecutive-large' },
{ year: 2023, month: 5, distribution: 'empty', pattern: 'single-day' },
{ year: 2023, month: 4, distribution: 'sparse', pattern: 'single-day' },
{ year: 2023, month: 3, distribution: 'dense', pattern: 'start-heavy' },
{ year: 2023, month: 2, distribution: 'medium', pattern: 'alternating' },
{ year: 2023, month: 1, distribution: 'very-dense', pattern: 'consecutive-large' },
];
for (let year = 2022; year >= 2000; year--) {
for (let month = 12; month >= 1; month--) {
months.push({ year, month, distribution: 'medium', pattern: 'sparse-scattered' });
}
}
return {
months,
seed: 42,
};
}

@ -0,0 +1,186 @@
import { DateTime } from 'luxon';
import { GENERATION_CONSTANTS, MockTimelineAsset } from 'src/generators/timeline/timeline-config';
/**
* Linear Congruential Generator for deterministic pseudo-random numbers
*/
export class SeededRandom {
private seed: number;
constructor(seed: number) {
this.seed = seed;
}
/**
* Generate next random number in range [0, 1)
*/
next(): number {
// LCG parameters from Numerical Recipes
this.seed = (this.seed * 1_664_525 + 1_013_904_223) % 2_147_483_647;
return this.seed / 2_147_483_647;
}
/**
* Generate random integer in range [min, max)
*/
nextInt(min: number, max: number): number {
return Math.floor(this.next() * (max - min)) + min;
}
/**
* Generate random boolean with given probability
*/
nextBoolean(probability = 0.5): boolean {
return this.next() < probability;
}
}
/**
* Select random days using seed variation to avoid collisions.
*
* @param daysInMonth - Total number of days in the month
* @param numDays - Number of days to select
* @param rng - Random number generator instance
* @returns Array of selected day numbers, sorted in descending order
*/
export function selectRandomDays(daysInMonth: number, numDays: number, rng: SeededRandom): number[] {
const selectedDays = new Set<number>();
const maxAttempts = numDays * GENERATION_CONSTANTS.MAX_SELECT_ATTEMPTS; // Safety limit
let attempts = 0;
while (selectedDays.size < numDays && attempts < maxAttempts) {
const day = rng.nextInt(1, daysInMonth + 1);
selectedDays.add(day);
attempts++;
}
// Fallback: if we couldn't select enough random days, fill with sequential days
if (selectedDays.size < numDays) {
for (let day = 1; day <= daysInMonth && selectedDays.size < numDays; day++) {
selectedDays.add(day);
}
}
return [...selectedDays].sort((a, b) => b - a);
}
/**
* Select item from array using seeded random
*/
export function selectRandom<T>(arr: T[], rng: SeededRandom): T {
if (arr.length === 0) {
throw new Error('Cannot select from empty array');
}
const index = rng.nextInt(0, arr.length);
return arr[index];
}
/**
* Select multiple random items from array using seeded random without duplicates
*/
export function selectRandomMultiple<T>(arr: T[], count: number, rng: SeededRandom): T[] {
if (arr.length === 0) {
throw new Error('Cannot select from empty array');
}
if (count < 0) {
throw new Error('Count must be non-negative');
}
if (count > arr.length) {
throw new Error('Count cannot exceed array length');
}
const result: T[] = [];
const selectedIndices = new Set<number>();
while (result.length < count) {
const index = rng.nextInt(0, arr.length);
if (!selectedIndices.has(index)) {
selectedIndices.add(index);
result.push(arr[index]);
}
}
return result;
}
/**
* Parse timeBucket parameter to extract year-month key
* Handles both formats:
* - ISO timestamp: "2024-12-01T00:00:00.000Z" -> "2024-12-01"
* - Simple format: "2024-12-01" -> "2024-12-01"
*/
export function parseTimeBucketKey(timeBucket: string): string {
if (!timeBucket) {
throw new Error('timeBucket parameter cannot be empty');
}
const dt = DateTime.fromISO(timeBucket, { zone: 'utc' });
if (!dt.isValid) {
// Fallback to regex if not a valid ISO string
const match = timeBucket.match(/^(\d{4}-\d{2}-\d{2})/);
return match ? match[1] : timeBucket;
}
// Format as YYYY-MM-01 (first day of month)
return `${dt.year}-${String(dt.month).padStart(2, '0')}-01`;
}
export function getMockAsset(
asset: MockTimelineAsset,
sortedDescendingAssets: MockTimelineAsset[],
direction: 'next' | 'previous',
unit: 'day' | 'month' | 'year' = 'day',
): MockTimelineAsset | null {
const currentDateTime = DateTime.fromISO(asset.localDateTime, { zone: 'utc' });
const currentIndex = sortedDescendingAssets.findIndex((a) => a.id === asset.id);
if (currentIndex === -1) {
return null;
}
const step = direction === 'next' ? 1 : -1;
const startIndex = currentIndex + step;
if (direction === 'next' && currentIndex >= sortedDescendingAssets.length - 1) {
return null;
}
if (direction === 'previous' && currentIndex <= 0) {
return null;
}
const isInDifferentPeriod = (date1: DateTime, date2: DateTime): boolean => {
if (unit === 'day') {
return !date1.startOf('day').equals(date2.startOf('day'));
} else if (unit === 'month') {
return date1.year !== date2.year || date1.month !== date2.month;
} else {
return date1.year !== date2.year;
}
};
if (direction === 'next') {
// Search forward in array (backwards in time)
for (let i = startIndex; i < sortedDescendingAssets.length; i++) {
const nextAsset = sortedDescendingAssets[i];
const nextDate = DateTime.fromISO(nextAsset.localDateTime, { zone: 'utc' });
if (isInDifferentPeriod(nextDate, currentDateTime)) {
return nextAsset;
}
}
} else {
// Search backward in array (forwards in time)
for (let i = startIndex; i >= 0; i--) {
const prevAsset = sortedDescendingAssets[i];
const prevDate = DateTime.fromISO(prevAsset.localDateTime, { zone: 'utc' });
if (isInDifferentPeriod(prevDate, currentDateTime)) {
return prevAsset;
}
}
}
return null;
}

@ -0,0 +1,285 @@
import { BrowserContext } from '@playwright/test';
import { playwrightHost } from 'playwright.config';
export const setupBaseMockApiRoutes = async (context: BrowserContext, adminUserId: string) => {
await context.addCookies([
{
name: 'immich_is_authenticated',
value: 'true',
domain: playwrightHost,
path: '/',
},
]);
await context.route('**/api/users/me', async (route) => {
return route.fulfill({
status: 200,
contentType: 'application/json',
json: {
id: adminUserId,
email: 'admin@immich.cloud',
name: 'Immich Admin',
profileImagePath: '',
avatarColor: 'orange',
profileChangedAt: '2025-01-22T21:31:23.996Z',
storageLabel: 'admin',
shouldChangePassword: true,
isAdmin: true,
createdAt: '2025-01-22T21:31:23.996Z',
deletedAt: null,
updatedAt: '2025-11-14T00:00:00.369Z',
oauthId: '',
quotaSizeInBytes: null,
quotaUsageInBytes: 20_849_000_159,
status: 'active',
license: null,
},
});
});
await context.route('**/users/me/preferences', async (route) => {
return route.fulfill({
status: 200,
contentType: 'application/json',
json: {
albums: {
defaultAssetOrder: 'desc',
},
folders: {
enabled: false,
sidebarWeb: false,
},
memories: {
enabled: true,
duration: 5,
},
people: {
enabled: true,
sidebarWeb: false,
},
sharedLinks: {
enabled: true,
sidebarWeb: false,
},
ratings: {
enabled: false,
},
tags: {
enabled: false,
sidebarWeb: false,
},
emailNotifications: {
enabled: true,
albumInvite: true,
albumUpdate: true,
},
download: {
archiveSize: 4_294_967_296,
includeEmbeddedVideos: false,
},
purchase: {
showSupportBadge: true,
hideBuyButtonUntil: '2100-02-12T00:00:00.000Z',
},
cast: {
gCastEnabled: false,
},
},
});
});
await context.route('**/server/about', async (route) => {
return route.fulfill({
status: 200,
contentType: 'application/json',
json: {
version: 'v2.2.3',
versionUrl: 'https://github.com/immich-app/immich/releases/tag/v2.2.3',
licensed: false,
build: '1234567890',
buildUrl: 'https://github.com/immich-app/immich/actions/runs/1234567890',
buildImage: 'e2e',
buildImageUrl: 'https://github.com/immich-app/immich/pkgs/container/immich-server',
repository: 'immich-app/immich',
repositoryUrl: 'https://github.com/immich-app/immich',
sourceRef: 'e2e',
sourceCommit: 'e2eeeeeeeeeeeeeeeeee',
sourceUrl: 'https://github.com/immich-app/immich/commit/e2eeeeeeeeeeeeeeeeee',
nodejs: 'v22.18.0',
exiftool: '13.41',
ffmpeg: '7.1.1-6',
libvips: '8.17.2',
imagemagick: '7.1.2-2',
},
});
});
await context.route('**/api/server/features', async (route) => {
return route.fulfill({
status: 200,
contentType: 'application/json',
json: {
smartSearch: false,
facialRecognition: false,
duplicateDetection: false,
map: true,
reverseGeocoding: true,
importFaces: false,
sidecar: true,
search: true,
trash: true,
oauth: false,
oauthAutoLaunch: false,
ocr: false,
passwordLogin: true,
configFile: false,
email: false,
},
});
});
await context.route('**/api/server/config', async (route) => {
return route.fulfill({
status: 200,
contentType: 'application/json',
json: {
loginPageMessage: '',
trashDays: 30,
userDeleteDelay: 7,
oauthButtonText: 'Login with OAuth',
isInitialized: true,
isOnboarded: true,
externalDomain: '',
publicUsers: true,
mapDarkStyleUrl: 'https://tiles.immich.cloud/v1/style/dark.json',
mapLightStyleUrl: 'https://tiles.immich.cloud/v1/style/light.json',
maintenanceMode: false,
},
});
});
await context.route('**/api/server/media-types', async (route) => {
return route.fulfill({
status: 200,
contentType: 'application/json',
json: {
video: [
'.3gp',
'.3gpp',
'.avi',
'.flv',
'.insv',
'.m2t',
'.m2ts',
'.m4v',
'.mkv',
'.mov',
'.mp4',
'.mpe',
'.mpeg',
'.mpg',
'.mts',
'.vob',
'.webm',
'.wmv',
],
image: [
'.3fr',
'.ari',
'.arw',
'.cap',
'.cin',
'.cr2',
'.cr3',
'.crw',
'.dcr',
'.dng',
'.erf',
'.fff',
'.iiq',
'.k25',
'.kdc',
'.mrw',
'.nef',
'.nrw',
'.orf',
'.ori',
'.pef',
'.psd',
'.raf',
'.raw',
'.rw2',
'.rwl',
'.sr2',
'.srf',
'.srw',
'.x3f',
'.avif',
'.gif',
'.jpeg',
'.jpg',
'.png',
'.webp',
'.bmp',
'.heic',
'.heif',
'.hif',
'.insp',
'.jp2',
'.jpe',
'.jxl',
'.svg',
'.tif',
'.tiff',
],
sidecar: ['.xmp'],
},
});
});
await context.route('**/api/notifications*', async (route) => {
return route.fulfill({
status: 200,
contentType: 'application/json',
json: [],
});
});
await context.route('**/api/albums*', async (route, request) => {
if (request.url().endsWith('albums?shared=true') || request.url().endsWith('albums')) {
return route.fulfill({
status: 200,
contentType: 'application/json',
json: [],
});
}
await route.fallback();
});
await context.route('**/api/memories*', async (route) => {
return route.fulfill({
status: 200,
contentType: 'application/json',
json: [],
});
});
await context.route('**/api/server/storage', async (route) => {
return route.fulfill({
status: 200,
contentType: 'application/json',
json: {
diskSize: '100.0 GiB',
diskUse: '74.4 GiB',
diskAvailable: '25.6 GiB',
diskSizeRaw: 107_374_182_400,
diskUseRaw: 79_891_660_800,
diskAvailableRaw: 27_482_521_600,
diskUsagePercentage: 74.4,
},
});
});
await context.route('**/api/server/version-history', async (route) => {
return route.fulfill({
status: 200,
contentType: 'application/json',
json: [
{
id: 'd1fbeadc-cb4f-4db3-8d19-8c6a921d5d8e',
createdAt: '2025-11-15T20:14:01.935Z',
version: '2.2.3',
},
],
});
});
};

@ -0,0 +1,139 @@
import { BrowserContext, Page, Request, Route } from '@playwright/test';
import { basename } from 'node:path';
import {
Changes,
getAlbum,
getAsset,
getTimeBucket,
getTimeBuckets,
randomPreview,
randomThumbnail,
TimelineData,
} from 'src/generators/timeline';
import { sleep } from 'src/web/specs/timeline/utils';
export class TimelineTestContext {
slowBucket = false;
adminId = '';
}
export const setupTimelineMockApiRoutes = async (
context: BrowserContext,
timelineRestData: TimelineData,
changes: Changes,
testContext: TimelineTestContext,
) => {
await context.route('**/api/timeline**', async (route, request) => {
const url = new URL(request.url());
const pathname = url.pathname;
if (pathname === '/api/timeline/buckets') {
const albumId = url.searchParams.get('albumId') || undefined;
const isTrashed = url.searchParams.get('isTrashed') ? url.searchParams.get('isTrashed') === 'true' : undefined;
const isFavorite = url.searchParams.get('isFavorite') ? url.searchParams.get('isFavorite') === 'true' : undefined;
const isArchived = url.searchParams.get('visibility')
? url.searchParams.get('visibility') === 'archive'
: undefined;
return route.fulfill({
status: 200,
contentType: 'application/json',
json: getTimeBuckets(timelineRestData, isTrashed, isArchived, isFavorite, albumId, changes),
});
} else if (pathname === '/api/timeline/bucket') {
const timeBucket = url.searchParams.get('timeBucket');
if (!timeBucket) {
return route.continue();
}
const isTrashed = url.searchParams.get('isTrashed') ? url.searchParams.get('isTrashed') === 'true' : undefined;
const isArchived = url.searchParams.get('visibility')
? url.searchParams.get('visibility') === 'archive'
: undefined;
const isFavorite = url.searchParams.get('isFavorite') ? url.searchParams.get('isFavorite') === 'true' : undefined;
const albumId = url.searchParams.get('albumId') || undefined;
const assets = getTimeBucket(timelineRestData, timeBucket, isTrashed, isArchived, isFavorite, albumId, changes);
if (testContext.slowBucket) {
await sleep(5000);
}
return route.fulfill({
status: 200,
contentType: 'application/json',
json: assets,
});
}
return route.continue();
});
await context.route('**/api/assets/**', async (route, request) => {
const pattern = /\/api\/assets\/(?<assetId>[^/]+)\/thumbnail\?size=(?<size>preview|thumbnail)/;
const match = request.url().match(pattern);
if (!match) {
const url = new URL(request.url());
const pathname = url.pathname;
const assetId = basename(pathname);
const asset = getAsset(timelineRestData, assetId);
return route.fulfill({
status: 200,
contentType: 'application/json',
json: asset,
});
}
if (match.groups?.size === 'preview') {
if (!route.request().serviceWorker()) {
return route.continue();
}
const asset = getAsset(timelineRestData, match.groups?.assetId);
return route.fulfill({
status: 200,
headers: { 'content-type': 'image/jpeg', ETag: 'abc123', 'Cache-Control': 'public, max-age=3600' },
body: await randomPreview(
match.groups?.assetId,
(asset?.exifInfo?.exifImageWidth ?? 0) / (asset?.exifInfo?.exifImageHeight ?? 1),
),
});
}
if (match.groups?.size === 'thumbnail') {
if (!route.request().serviceWorker()) {
return route.continue();
}
const asset = getAsset(timelineRestData, match.groups?.assetId);
return route.fulfill({
status: 200,
headers: { 'content-type': 'image/jpeg' },
body: await randomThumbnail(
match.groups?.assetId,
(asset?.exifInfo?.exifImageWidth ?? 0) / (asset?.exifInfo?.exifImageHeight ?? 1),
),
});
}
return route.continue();
});
await context.route('**/api/albums/**', async (route, request) => {
const pattern = /\/api\/albums\/(?<albumId>[^/?]+)/;
const match = request.url().match(pattern);
if (!match) {
return route.continue();
}
const album = getAlbum(timelineRestData, testContext.adminId, match.groups?.albumId, changes);
return route.fulfill({
status: 200,
contentType: 'application/json',
json: album,
});
});
};
export const pageRoutePromise = async (
page: Page,
route: string,
callback: (route: Route, request: Request) => Promise<void>,
) => {
let resolveRequest: ((value: unknown | PromiseLike<unknown>) => void) | undefined;
const deleteRequest = new Promise((resolve) => {
resolveRequest = resolve;
});
await page.route(route, async (route, request) => {
await callback(route, request);
const requestJson = request.postDataJSON();
resolveRequest?.(requestJson);
});
return deleteRequest;
};

@ -54,7 +54,7 @@ import { exec, spawn } from 'node:child_process';
import { createHash } from 'node:crypto';
import { existsSync, mkdirSync, renameSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import path, { dirname } from 'node:path';
import { dirname, resolve } from 'node:path';
import { setTimeout as setAsyncTimeout } from 'node:timers/promises';
import { promisify } from 'node:util';
import pg from 'pg';
@ -62,6 +62,8 @@ import { io, type Socket } from 'socket.io-client';
import { loginDto, signupDto } from 'src/fixtures';
import { makeRandomImage } from 'src/generators';
import request from 'supertest';
import { playwrightDbHost, playwrightHost, playwriteBaseUrl } from '../playwright.config';
export type { Emitter } from '@socket.io/component-emitter';
type CommandResponse = { stdout: string; stderr: string; exitCode: number | null };
@ -70,12 +72,12 @@ type WaitOptions = { event: EventType; id?: string; total?: number; timeout?: nu
type AdminSetupOptions = { onboarding?: boolean };
type FileData = { bytes?: Buffer; filename: string };
const dbUrl = 'postgres://postgres:postgres@127.0.0.1:5435/immich';
export const baseUrl = 'http://127.0.0.1:2285';
const dbUrl = `postgres://postgres:postgres@${playwrightDbHost}:5435/immich`;
export const baseUrl = playwriteBaseUrl;
export const shareUrl = `${baseUrl}/share`;
export const app = `${baseUrl}/api`;
// TODO move test assets into e2e/assets
export const testAssetDir = path.resolve('./test-assets');
export const testAssetDir = resolve(import.meta.dirname, '../test-assets');
export const testAssetDirInternal = '/test-assets';
export const tempDir = tmpdir();
export const asBearerAuth = (accessToken: string) => ({ Authorization: `Bearer ${accessToken}` });
@ -482,7 +484,7 @@ export const utils = {
queueCommand: async (accessToken: string, name: QueueName, queueCommandDto: QueueCommandDto) =>
runQueueCommandLegacy({ name, queueCommandDto }, { headers: asBearerAuth(accessToken) }),
setAuthCookies: async (context: BrowserContext, accessToken: string, domain = '127.0.0.1') =>
setAuthCookies: async (context: BrowserContext, accessToken: string, domain = playwrightHost) =>
await context.addCookies([
{
name: 'immich_access_token',

@ -19,10 +19,9 @@ test.describe('Maintenance', () => {
await page.goto('/admin/system-settings?isOpen=maintenance');
await page.getByRole('button', { name: 'Start maintenance mode' }).click();
await page.waitForURL(`/maintenance?${new URLSearchParams({ continue: '/admin/system-settings' })}`);
await expect(page.getByText('Temporarily Unavailable')).toBeVisible();
await expect(page.getByText('Temporarily Unavailable')).toBeVisible({ timeout: 10_000 });
await page.getByRole('button', { name: 'End maintenance mode' }).click();
await page.waitForURL('/admin/system-settings');
await page.waitForURL('**/admin/system-settings*', { timeout: 10_000 });
});
test('maintenance shows no options to users until they authenticate', async ({ page }) => {
@ -35,10 +34,10 @@ test.describe('Maintenance', () => {
await expect(async () => {
await page.goto('/');
await page.waitForURL('/maintenance?**', {
timeout: 1e3,
await page.waitForURL('**/maintenance?**', {
timeout: 1000,
});
}).toPass({ timeout: 1e4 });
}).toPass({ timeout: 10_000 });
await expect(page.getByText('Temporarily Unavailable')).toBeVisible();
await expect(page.getByRole('button', { name: 'End maintenance mode' })).toHaveCount(0);
@ -47,6 +46,6 @@ test.describe('Maintenance', () => {
await expect(page.getByText('Temporarily Unavailable')).toBeVisible();
await expect(page.getByRole('button', { name: 'End maintenance mode' })).toBeVisible();
await page.getByRole('button', { name: 'End maintenance mode' }).click();
await page.waitForURL('/auth/login');
await page.waitForURL('**/auth/login');
});
});

@ -0,0 +1,776 @@
import { faker } from '@faker-js/faker';
import { expect, test } from '@playwright/test';
import { DateTime } from 'luxon';
import {
Changes,
createDefaultTimelineConfig,
generateTimelineData,
getAsset,
getMockAsset,
SeededRandom,
selectRandom,
selectRandomMultiple,
TimelineAssetConfig,
TimelineData,
} from 'src/generators/timeline';
import { setupBaseMockApiRoutes } from 'src/mock-network/base-network';
import { pageRoutePromise, setupTimelineMockApiRoutes, TimelineTestContext } from 'src/mock-network/timeline-network';
import { utils } from 'src/utils';
import {
assetViewerUtils,
cancelAllPollers,
padYearMonth,
pageUtils,
poll,
thumbnailUtils,
timelineUtils,
} from 'src/web/specs/timeline/utils';
test.describe.configure({ mode: 'parallel' });
test.describe('Timeline', () => {
let adminUserId: string;
let timelineRestData: TimelineData;
const assets: TimelineAssetConfig[] = [];
const yearMonths: string[] = [];
const testContext = new TimelineTestContext();
const changes: Changes = {
albumAdditions: [],
assetDeletions: [],
assetArchivals: [],
assetFavorites: [],
};
test.beforeAll(async () => {
test.fail(
process.env.PW_EXPERIMENTAL_SERVICE_WORKER_NETWORK_EVENTS !== '1',
'This test requires env var: PW_EXPERIMENTAL_SERVICE_WORKER_NETWORK_EVENTS=1',
);
utils.initSdk();
adminUserId = faker.string.uuid();
testContext.adminId = adminUserId;
timelineRestData = generateTimelineData({ ...createDefaultTimelineConfig(), ownerId: adminUserId });
for (const timeBucket of timelineRestData.buckets.values()) {
assets.push(...timeBucket);
}
for (const yearMonth of timelineRestData.buckets.keys()) {
const [year, month] = yearMonth.split('-');
yearMonths.push(`${year}-${Number(month)}`);
}
});
test.beforeEach(async ({ context }) => {
await setupBaseMockApiRoutes(context, adminUserId);
await setupTimelineMockApiRoutes(context, timelineRestData, changes, testContext);
});
test.afterEach(() => {
cancelAllPollers();
testContext.slowBucket = false;
changes.albumAdditions = [];
changes.assetDeletions = [];
changes.assetArchivals = [];
changes.assetFavorites = [];
});
test.describe('/photos', () => {
test('Open /photos', async ({ page }) => {
await page.goto(`/photos`);
await page.waitForSelector('#asset-grid');
await thumbnailUtils.expectTimelineHasOnScreenAssets(page);
});
test('Deep link to last photo', async ({ page }) => {
const lastAsset = assets.at(-1)!;
await pageUtils.deepLinkPhotosPage(page, lastAsset.id);
await thumbnailUtils.expectTimelineHasOnScreenAssets(page);
await thumbnailUtils.expectInViewport(page, lastAsset.id);
});
const rng = new SeededRandom(529);
for (let i = 0; i < 10; i++) {
test('Deep link to random asset ' + i, async ({ page }) => {
const asset = selectRandom(assets, rng);
await pageUtils.deepLinkPhotosPage(page, asset.id);
await thumbnailUtils.expectTimelineHasOnScreenAssets(page);
await thumbnailUtils.expectInViewport(page, asset.id);
});
}
test('Open /photos, open asset-viewer, browser back', async ({ page }) => {
const rng = new SeededRandom(22);
const asset = selectRandom(assets, rng);
await pageUtils.deepLinkPhotosPage(page, asset.id);
const scrollTopBefore = await timelineUtils.getScrollTop(page);
await thumbnailUtils.clickAssetId(page, asset.id);
await assetViewerUtils.waitForViewerLoad(page, asset);
await page.goBack();
await timelineUtils.locator(page).waitFor();
const scrollTopAfter = await timelineUtils.getScrollTop(page);
expect(scrollTopAfter).toBe(scrollTopBefore);
});
test('Open /photos, open asset-viewer, next photo, browser back, back', async ({ page }) => {
const rng = new SeededRandom(49);
const asset = selectRandom(assets, rng);
const assetIndex = assets.indexOf(asset);
const nextAsset = assets[assetIndex + 1];
await pageUtils.deepLinkPhotosPage(page, asset.id);
const scrollTopBefore = await timelineUtils.getScrollTop(page);
await thumbnailUtils.clickAssetId(page, asset.id);
await assetViewerUtils.waitForViewerLoad(page, asset);
await expect.poll(() => new URL(page.url()).pathname).toBe(`/photos/${asset.id}`);
await page.getByLabel('View next asset').click();
await assetViewerUtils.waitForViewerLoad(page, nextAsset);
await expect.poll(() => new URL(page.url()).pathname).toBe(`/photos/${nextAsset.id}`);
await page.goBack();
await assetViewerUtils.waitForViewerLoad(page, asset);
await page.goBack();
await page.waitForURL('**/photos?at=*');
const scrollTopAfter = await timelineUtils.getScrollTop(page);
expect(Math.abs(scrollTopAfter - scrollTopBefore)).toBeLessThan(5);
});
test('Open /photos, open asset-viewer, next photo 15x, backwardsArrow', async ({ page }) => {
await pageUtils.deepLinkPhotosPage(page, assets[0].id);
await thumbnailUtils.clickAssetId(page, assets[0].id);
await assetViewerUtils.waitForViewerLoad(page, assets[0]);
for (let i = 1; i <= 15; i++) {
await page.getByLabel('View next asset').click();
await assetViewerUtils.waitForViewerLoad(page, assets[i]);
}
await page.getByLabel('Go back').click();
await page.waitForURL('**/photos?at=*');
await thumbnailUtils.expectInViewport(page, assets[15].id);
await thumbnailUtils.expectBottomIsTimelineBottom(page, assets[15]!.id);
});
test('Open /photos, open asset-viewer, previous photo 15x, backwardsArrow', async ({ page }) => {
const lastAsset = assets.at(-1)!;
await pageUtils.deepLinkPhotosPage(page, lastAsset.id);
await thumbnailUtils.clickAssetId(page, lastAsset.id);
await assetViewerUtils.waitForViewerLoad(page, lastAsset);
for (let i = 1; i <= 15; i++) {
await page.getByLabel('View previous asset').click();
await assetViewerUtils.waitForViewerLoad(page, assets.at(-1 - i)!);
}
await page.getByLabel('Go back').click();
await page.waitForURL('**/photos?at=*');
await thumbnailUtils.expectInViewport(page, assets.at(-1 - 15)!.id);
await thumbnailUtils.expectTopIsTimelineTop(page, assets.at(-1 - 15)!.id);
});
});
test.describe('keyboard', () => {
/**
* This text tests keyboard nativation, and also ensures that the scroll-to-asset behavior
* scrolls the minimum amount. That is, if you are navigating using right arrow (auto scrolling
* as necessary downwards), then the asset should always be at the lowest row of the grid.
*/
test('Next/previous asset - ArrowRight/ArrowLeft', async ({ page }) => {
await pageUtils.openPhotosPage(page);
await thumbnailUtils.withAssetId(page, assets[0].id).focus();
const rightKey = 'ArrowRight';
const leftKey = 'ArrowLeft';
for (let i = 1; i < 15; i++) {
await page.keyboard.press(rightKey);
await assetViewerUtils.expectActiveAssetToBe(page, assets[i].id);
}
for (let i = 15; i <= 20; i++) {
await page.keyboard.press(rightKey);
await assetViewerUtils.expectActiveAssetToBe(page, assets[i].id);
expect(await thumbnailUtils.expectBottomIsTimelineBottom(page, assets.at(i)!.id));
}
// now test previous asset
for (let i = 19; i >= 15; i--) {
await page.keyboard.press(leftKey);
await assetViewerUtils.expectActiveAssetToBe(page, assets[i].id);
}
for (let i = 14; i > 0; i--) {
await page.keyboard.press(leftKey);
await assetViewerUtils.expectActiveAssetToBe(page, assets[i].id);
expect(await thumbnailUtils.expectTopIsTimelineTop(page, assets.at(i)!.id));
}
});
test('Next/previous asset - Tab/Shift+Tab', async ({ page }) => {
await pageUtils.openPhotosPage(page);
await thumbnailUtils.withAssetId(page, assets[0].id).focus();
const rightKey = 'Tab';
const leftKey = 'Shift+Tab';
for (let i = 1; i < 15; i++) {
await page.keyboard.press(rightKey);
await assetViewerUtils.expectActiveAssetToBe(page, assets[i].id);
}
for (let i = 15; i <= 20; i++) {
await page.keyboard.press(rightKey);
await assetViewerUtils.expectActiveAssetToBe(page, assets[i].id);
}
// now test previous asset
for (let i = 19; i >= 15; i--) {
await page.keyboard.press(leftKey);
await assetViewerUtils.expectActiveAssetToBe(page, assets[i].id);
}
for (let i = 14; i > 0; i--) {
await page.keyboard.press(leftKey);
await assetViewerUtils.expectActiveAssetToBe(page, assets[i].id);
}
});
test('Next/previous day - d, Shift+D', async ({ page }) => {
await pageUtils.openPhotosPage(page);
let asset = assets[0];
await timelineUtils.locator(page).hover();
await page.keyboard.press('d');
await assetViewerUtils.expectActiveAssetToBe(page, asset.id);
for (let i = 0; i < 15; i++) {
await page.keyboard.press('d');
const next = getMockAsset(asset, assets, 'next', 'day')!;
await assetViewerUtils.expectActiveAssetToBe(page, next.id);
asset = next;
}
for (let i = 0; i < 15; i++) {
await page.keyboard.press('Shift+D');
const previous = getMockAsset(asset, assets, 'previous', 'day')!;
await assetViewerUtils.expectActiveAssetToBe(page, previous.id);
asset = previous;
}
});
test('Next/previous month - m, Shift+M', async ({ page }) => {
await pageUtils.openPhotosPage(page);
let asset = assets[0];
await timelineUtils.locator(page).hover();
await page.keyboard.press('m');
await assetViewerUtils.expectActiveAssetToBe(page, asset.id);
for (let i = 0; i < 15; i++) {
await page.keyboard.press('m');
const next = getMockAsset(asset, assets, 'next', 'month')!;
await assetViewerUtils.expectActiveAssetToBe(page, next.id);
asset = next;
}
for (let i = 0; i < 15; i++) {
await page.keyboard.press('Shift+M');
const previous = getMockAsset(asset, assets, 'previous', 'month')!;
await assetViewerUtils.expectActiveAssetToBe(page, previous.id);
asset = previous;
}
});
test('Next/previous year - y, Shift+Y', async ({ page }) => {
await pageUtils.openPhotosPage(page);
let asset = assets[0];
await timelineUtils.locator(page).hover();
await page.keyboard.press('y');
await assetViewerUtils.expectActiveAssetToBe(page, asset.id);
for (let i = 0; i < 15; i++) {
await page.keyboard.press('y');
const next = getMockAsset(asset, assets, 'next', 'year')!;
await assetViewerUtils.expectActiveAssetToBe(page, next.id);
asset = next;
}
for (let i = 0; i < 15; i++) {
await page.keyboard.press('Shift+Y');
const previous = getMockAsset(asset, assets, 'previous', 'year')!;
await assetViewerUtils.expectActiveAssetToBe(page, previous.id);
asset = previous;
}
});
test('Navigate to time - g', async ({ page }) => {
const rng = new SeededRandom(4782);
await pageUtils.openPhotosPage(page);
for (let i = 0; i < 10; i++) {
const asset = selectRandom(assets, rng);
await pageUtils.goToAsset(page, asset.fileCreatedAt);
await thumbnailUtils.expectInViewport(page, asset.id);
}
});
});
test.describe('selection', () => {
test('Select day, unselect day', async ({ page }) => {
await pageUtils.openPhotosPage(page);
await pageUtils.selectDay(page, 'Wed, Dec 11, 2024');
await expect(thumbnailUtils.selectedAsset(page)).toHaveCount(4);
await pageUtils.selectDay(page, 'Wed, Dec 11, 2024');
await expect(thumbnailUtils.selectedAsset(page)).toHaveCount(0);
});
test('Select asset, click asset to select', async ({ page }) => {
await pageUtils.openPhotosPage(page);
await thumbnailUtils.withAssetId(page, assets[1].id).hover();
await thumbnailUtils.selectButton(page, assets[1].id).click();
await expect(thumbnailUtils.selectedAsset(page)).toHaveCount(1);
// no need to hover, once selection is active
await thumbnailUtils.clickAssetId(page, assets[2].id);
await expect(thumbnailUtils.selectedAsset(page)).toHaveCount(2);
});
test('Select asset, click unselect asset', async ({ page }) => {
await pageUtils.openPhotosPage(page);
await thumbnailUtils.withAssetId(page, assets[1].id).hover();
await thumbnailUtils.selectButton(page, assets[1].id).click();
await expect(thumbnailUtils.selectedAsset(page)).toHaveCount(1);
await thumbnailUtils.clickAssetId(page, assets[1].id);
// the hover uses a checked button too, so just move mouse away
await page.mouse.move(0, 0);
await expect(thumbnailUtils.selectedAsset(page)).toHaveCount(0);
});
test('Select asset, shift-hover candidates, shift-click end', async ({ page }) => {
await pageUtils.openPhotosPage(page);
const asset = assets[0];
await thumbnailUtils.withAssetId(page, asset.id).hover();
await thumbnailUtils.selectButton(page, asset.id).click();
await page.keyboard.down('Shift');
await thumbnailUtils.withAssetId(page, assets[2].id).hover();
await expect(
thumbnailUtils.locator(page).locator('.absolute.top-0.h-full.w-full.bg-immich-primary.opacity-40'),
).toHaveCount(3);
await thumbnailUtils.selectButton(page, assets[2].id).click();
await page.keyboard.up('Shift');
await expect(thumbnailUtils.selectedAsset(page)).toHaveCount(3);
});
test('Add multiple to selection - Select day, shift-click end', async ({ page }) => {
await pageUtils.openPhotosPage(page);
await thumbnailUtils.withAssetId(page, assets[0].id).hover();
await thumbnailUtils.selectButton(page, assets[0].id).click();
await thumbnailUtils.clickAssetId(page, assets[2].id);
await page.keyboard.down('Shift');
await thumbnailUtils.clickAssetId(page, assets[4].id);
await page.mouse.move(0, 0);
await expect(thumbnailUtils.selectedAsset(page)).toHaveCount(4);
});
});
test.describe('scroll', () => {
test('Open /photos, random click scrubber 20x', async ({ page }) => {
test.slow();
await pageUtils.openPhotosPage(page);
const rng = new SeededRandom(6637);
const selectedMonths = selectRandomMultiple(yearMonths, 20, rng);
for (const month of selectedMonths) {
await page.locator(`[data-segment-year-month="${month}"]`).click({ force: true });
const visibleMockAssetsYearMonths = await poll(page, async () => {
const assetIds = await thumbnailUtils.getAllInViewport(
page,
(assetId: string) => getYearMonth(assets, assetId) === month,
);
const visibleMockAssetsYearMonths: string[] = [];
for (const assetId of assetIds!) {
const yearMonth = getYearMonth(assets, assetId);
visibleMockAssetsYearMonths.push(yearMonth);
if (yearMonth === month) {
return [yearMonth];
}
}
});
if (page.isClosed()) {
return;
}
expect(visibleMockAssetsYearMonths).toContain(month);
}
});
test('Deep link to last photo, scroll up', async ({ page }) => {
const lastAsset = assets.at(-1)!;
await pageUtils.deepLinkPhotosPage(page, lastAsset.id);
await timelineUtils.locator(page).hover();
for (let i = 0; i < 100; i++) {
await page.mouse.wheel(0, -100);
await page.waitForTimeout(25);
}
await thumbnailUtils.expectInViewport(page, '14e5901f-fd7f-40c0-b186-4d7e7fc67968');
});
test('Deep link to first bucket, scroll down', async ({ page }) => {
const lastAsset = assets.at(0)!;
await pageUtils.deepLinkPhotosPage(page, lastAsset.id);
await timelineUtils.locator(page).hover();
for (let i = 0; i < 100; i++) {
await page.mouse.wheel(0, 100);
await page.waitForTimeout(25);
}
await thumbnailUtils.expectInViewport(page, 'b7983a13-4b4e-4950-a731-f2962d9a1555');
});
test('Deep link to last photo, drag scrubber to scroll up', async ({ page }) => {
const lastAsset = assets.at(-1)!;
await pageUtils.deepLinkPhotosPage(page, lastAsset.id);
const lastMonth = yearMonths.at(-1);
const firstScrubSegment = page.locator(`[data-segment-year-month="${yearMonths[0]}"]`);
const lastScrubSegment = page.locator(`[data-segment-year-month="${lastMonth}"]`);
const sourcebox = (await lastScrubSegment.boundingBox())!;
const targetBox = (await firstScrubSegment.boundingBox())!;
await firstScrubSegment.hover();
const currentY = sourcebox.y;
await page.mouse.move(sourcebox.x + sourcebox?.width / 2, currentY);
await page.mouse.down();
await page.mouse.move(sourcebox.x + sourcebox?.width / 2, targetBox.y, { steps: 100 });
await page.mouse.up();
await thumbnailUtils.expectInViewport(page, assets[0].id);
});
test('Deep link to first bucket, drag scrubber to scroll down', async ({ page }) => {
await pageUtils.deepLinkPhotosPage(page, assets[0].id);
const firstScrubSegment = page.locator(`[data-segment-year-month="${yearMonths[0]}"]`);
const sourcebox = (await firstScrubSegment.boundingBox())!;
await firstScrubSegment.hover();
const currentY = sourcebox.y;
await page.mouse.move(sourcebox.x + sourcebox?.width / 2, currentY);
await page.mouse.down();
const height = page.viewportSize()?.height;
expect(height).toBeDefined();
await page.mouse.move(sourcebox.x + sourcebox?.width / 2, height! - 10, {
steps: 100,
});
await page.mouse.up();
await thumbnailUtils.expectInViewport(page, assets.at(-1)!.id);
});
test('Buckets cancel on scroll', async ({ page }) => {
await pageUtils.openPhotosPage(page);
testContext.slowBucket = true;
const failedUris: string[] = [];
page.on('requestfailed', (request) => {
failedUris.push(request.url());
});
const offscreenSegment = page.locator(`[data-segment-year-month="${yearMonths[12]}"]`);
await offscreenSegment.click({ force: true });
const lastSegment = page.locator(`[data-segment-year-month="${yearMonths.at(-1)!}"]`);
await lastSegment.click({ force: true });
const uris = await poll(page, async () => (failedUris.length > 0 ? failedUris : null));
expect(uris).toEqual(expect.arrayContaining([expect.stringContaining(padYearMonth(yearMonths[12]!))]));
});
});
test.describe('/albums', () => {
test('Open album', async ({ page }) => {
const album = timelineRestData.album;
await pageUtils.openAlbumPage(page, album.id);
await thumbnailUtils.expectInViewport(page, album.assetIds[0]);
});
test('Deep link to last photo', async ({ page }) => {
const album = timelineRestData.album;
const lastAsset = album.assetIds.at(-1);
await pageUtils.deepLinkAlbumPage(page, album.id, lastAsset!);
await thumbnailUtils.expectInViewport(page, album.assetIds.at(-1)!);
await thumbnailUtils.expectBottomIsTimelineBottom(page, album.assetIds.at(-1)!);
});
test('Add photos to album pre-selects existing', async ({ page }) => {
const album = timelineRestData.album;
await pageUtils.openAlbumPage(page, album.id);
await page.getByLabel('Add photos').click();
const asset = getAsset(timelineRestData, album.assetIds[0])!;
await pageUtils.goToAsset(page, asset.fileCreatedAt);
await thumbnailUtils.expectInViewport(page, asset.id);
await thumbnailUtils.expectSelectedReadonly(page, asset.id);
});
test('Add photos to album', async ({ page }) => {
const album = timelineRestData.album;
await pageUtils.openAlbumPage(page, album.id);
await page.locator('nav button[aria-label="Add photos"]').click();
const asset = getAsset(timelineRestData, album.assetIds[0])!;
await pageUtils.goToAsset(page, asset.fileCreatedAt);
await thumbnailUtils.expectInViewport(page, asset.id);
await thumbnailUtils.expectSelectedReadonly(page, asset.id);
await pageUtils.selectDay(page, 'Tue, Feb 27, 2024');
const put = pageRoutePromise(page, `**/api/albums/${album.id}/assets`, async (route, request) => {
const requestJson = request.postDataJSON();
await route.fulfill({
status: 200,
contentType: 'application/json',
json: requestJson.ids.map((id: string) => ({ id, success: true })),
});
changes.albumAdditions.push(...requestJson.ids);
});
await page.getByText('Done').click();
await expect(put).resolves.toEqual({
ids: [
'c077ea7b-cfa1-45e4-8554-f86c00ee5658',
'040fd762-dbbc-486d-a51a-2d84115e6229',
'86af0b5f-79d3-4f75-bab3-3b61f6c72b23',
],
});
const addedAsset = getAsset(timelineRestData, 'c077ea7b-cfa1-45e4-8554-f86c00ee5658')!;
await pageUtils.goToAsset(page, addedAsset.fileCreatedAt);
await thumbnailUtils.expectInViewport(page, 'c077ea7b-cfa1-45e4-8554-f86c00ee5658');
await thumbnailUtils.expectInViewport(page, '040fd762-dbbc-486d-a51a-2d84115e6229');
await thumbnailUtils.expectInViewport(page, '86af0b5f-79d3-4f75-bab3-3b61f6c72b23');
});
});
test.describe('/trash', () => {
test('open /photos, trash photo, open /trash, restore', async ({ page }) => {
await pageUtils.openPhotosPage(page);
const assetToTrash = assets[0];
await thumbnailUtils.withAssetId(page, assetToTrash.id).hover();
await thumbnailUtils.selectButton(page, assetToTrash.id).click();
await page.getByLabel('Menu').click();
const deleteRequest = pageRoutePromise(page, '**/api/assets', async (route, request) => {
const requestJson = request.postDataJSON();
changes.assetDeletions.push(...requestJson.ids);
await route.fulfill({
status: 200,
contentType: 'application/json',
json: requestJson.ids.map((id: string) => ({ id, success: true })),
});
});
await page.getByRole('menuitem').getByText('Delete').click();
await expect(deleteRequest).resolves.toEqual({
force: false,
ids: [assetToTrash.id],
});
await page.getByText('Trash', { exact: true }).click();
await thumbnailUtils.expectInViewport(page, assetToTrash.id);
await thumbnailUtils.withAssetId(page, assetToTrash.id).hover();
await thumbnailUtils.selectButton(page, assetToTrash.id).click();
const restoreRequest = pageRoutePromise(page, '**/api/trash/restore/assets', async (route, request) => {
const requestJson = request.postDataJSON();
changes.assetDeletions = changes.assetDeletions.filter((id) => !requestJson.ids.includes(id));
await route.fulfill({
status: 200,
contentType: 'application/json',
json: { count: requestJson.ids.length },
});
});
await page.getByText('Restore', { exact: true }).click();
await expect(restoreRequest).resolves.toEqual({
ids: [assetToTrash.id],
});
await expect(thumbnailUtils.withAssetId(page, assetToTrash.id)).toHaveCount(0);
await page.getByText('Photos', { exact: true }).click();
await thumbnailUtils.expectInViewport(page, assetToTrash.id);
});
test('open album, trash photo, open /trash, restore', async ({ page }) => {
const album = timelineRestData.album;
await pageUtils.openAlbumPage(page, album.id);
const assetToTrash = getAsset(timelineRestData, album.assetIds[0])!;
await thumbnailUtils.withAssetId(page, assetToTrash.id).hover();
await thumbnailUtils.selectButton(page, assetToTrash.id).click();
await page.getByLabel('Menu').click();
const deleteRequest = pageRoutePromise(page, '**/api/assets', async (route, request) => {
const requestJson = request.postDataJSON();
changes.assetDeletions.push(...requestJson.ids);
await route.fulfill({
status: 200,
contentType: 'application/json',
json: requestJson.ids.map((id: string) => ({ id, success: true })),
});
});
await page.getByRole('menuitem').getByText('Delete').click();
await expect(deleteRequest).resolves.toEqual({
force: false,
ids: [assetToTrash.id],
});
await page.locator('#asset-selection-app-bar').getByLabel('Close').click();
await page.getByText('Trash', { exact: true }).click();
await timelineUtils.waitForTimelineLoad(page);
await thumbnailUtils.expectInViewport(page, assetToTrash.id);
await thumbnailUtils.withAssetId(page, assetToTrash.id).hover();
await thumbnailUtils.selectButton(page, assetToTrash.id).click();
const restoreRequest = pageRoutePromise(page, '**/api/trash/restore/assets', async (route, request) => {
const requestJson = request.postDataJSON();
changes.assetDeletions = changes.assetDeletions.filter((id) => !requestJson.ids.includes(id));
await route.fulfill({
status: 200,
contentType: 'application/json',
json: { count: requestJson.ids.length },
});
});
await page.getByText('Restore', { exact: true }).click();
await expect(restoreRequest).resolves.toEqual({
ids: [assetToTrash.id],
});
await expect(thumbnailUtils.withAssetId(page, assetToTrash.id)).toHaveCount(0);
await pageUtils.openAlbumPage(page, album.id);
await thumbnailUtils.expectInViewport(page, assetToTrash.id);
});
});
test.describe('/archive', () => {
test('open /photos, archive photo, open /archive, unarchive', async ({ page }) => {
await pageUtils.openPhotosPage(page);
const assetToArchive = assets[0];
await thumbnailUtils.withAssetId(page, assetToArchive.id).hover();
await thumbnailUtils.selectButton(page, assetToArchive.id).click();
await page.getByLabel('Menu').click();
const archive = pageRoutePromise(page, '**/api/assets', async (route, request) => {
const requestJson = request.postDataJSON();
if (requestJson.visibility !== 'archive') {
return await route.continue();
}
await route.fulfill({
status: 204,
});
changes.assetArchivals.push(...requestJson.ids);
});
await page.getByRole('menuitem').getByText('Archive').click();
await expect(archive).resolves.toEqual({
visibility: 'archive',
ids: [assetToArchive.id],
});
await expect(thumbnailUtils.withAssetId(page, assetToArchive.id)).toHaveCount(0);
await page.getByRole('link').getByText('Archive').click();
await thumbnailUtils.expectInViewport(page, assetToArchive.id);
await thumbnailUtils.withAssetId(page, assetToArchive.id).hover();
await thumbnailUtils.selectButton(page, assetToArchive.id).click();
const unarchiveRequest = pageRoutePromise(page, '**/api/assets', async (route, request) => {
const requestJson = request.postDataJSON();
if (requestJson.visibility !== 'timeline') {
return await route.continue();
}
changes.assetArchivals = changes.assetArchivals.filter((id) => !requestJson.ids.includes(id));
await route.fulfill({
status: 204,
});
});
await page.getByLabel('Unarchive').click();
await expect(unarchiveRequest).resolves.toEqual({
visibility: 'timeline',
ids: [assetToArchive.id],
});
console.log('Skipping assertion - TODO - fix bug with not removing asset from timeline-manager after unarchive');
// await expect(thumbnail.withAssetId(page, assetToArchive.id)).toHaveCount(0);
await page.getByText('Photos', { exact: true }).click();
await thumbnailUtils.expectInViewport(page, assetToArchive.id);
});
test('open album, archive photo, open album, unarchive', async ({ page }) => {
const album = timelineRestData.album;
await pageUtils.openAlbumPage(page, album.id);
const assetToArchive = getAsset(timelineRestData, album.assetIds[0])!;
await thumbnailUtils.withAssetId(page, assetToArchive.id).hover();
await thumbnailUtils.selectButton(page, assetToArchive.id).click();
await page.getByLabel('Menu').click();
const archive = pageRoutePromise(page, '**/api/assets', async (route, request) => {
const requestJson = request.postDataJSON();
if (requestJson.visibility !== 'archive') {
return await route.continue();
}
changes.assetArchivals.push(...requestJson.ids);
await route.fulfill({
status: 204,
});
});
await page.getByRole('menuitem').getByText('Archive').click();
await expect(archive).resolves.toEqual({
visibility: 'archive',
ids: [assetToArchive.id],
});
console.log('Skipping assertion - TODO - fix that archiving in album doesnt add icon');
// await thumbnail.expectThumbnailIsArchive(page, assetToArchive.id);
await page.locator('#asset-selection-app-bar').getByLabel('Close').click();
await page.getByRole('link').getByText('Archive').click();
await timelineUtils.waitForTimelineLoad(page);
await thumbnailUtils.expectInViewport(page, assetToArchive.id);
await thumbnailUtils.withAssetId(page, assetToArchive.id).hover();
await thumbnailUtils.selectButton(page, assetToArchive.id).click();
const unarchiveRequest = pageRoutePromise(page, '**/api/assets', async (route, request) => {
const requestJson = request.postDataJSON();
if (requestJson.visibility !== 'timeline') {
return await route.continue();
}
changes.assetArchivals = changes.assetArchivals.filter((id) => !requestJson.ids.includes(id));
await route.fulfill({
status: 204,
});
});
await page.getByLabel('Unarchive').click();
await expect(unarchiveRequest).resolves.toEqual({
visibility: 'timeline',
ids: [assetToArchive.id],
});
console.log('Skipping assertion - TODO - fix bug with not removing asset from timeline-manager after unarchive');
// await expect(thumbnail.withAssetId(page, assetToArchive.id)).toHaveCount(0);
await pageUtils.openAlbumPage(page, album.id);
await thumbnailUtils.expectInViewport(page, assetToArchive.id);
});
});
test.describe('/favorite', () => {
test('open /photos, favorite photo, open /favorites, remove favorite, open /photos', async ({ page }) => {
await pageUtils.openPhotosPage(page);
const assetToFavorite = assets[0];
await thumbnailUtils.withAssetId(page, assetToFavorite.id).hover();
await thumbnailUtils.selectButton(page, assetToFavorite.id).click();
const favorite = pageRoutePromise(page, '**/api/assets', async (route, request) => {
const requestJson = request.postDataJSON();
if (requestJson.isFavorite === undefined) {
return await route.continue();
}
const isFavorite = requestJson.isFavorite;
if (isFavorite) {
changes.assetFavorites.push(...requestJson.ids);
}
await route.fulfill({
status: 204,
});
});
await page.getByLabel('Favorite').click();
await expect(favorite).resolves.toEqual({
isFavorite: true,
ids: [assetToFavorite.id],
});
// ensure thumbnail still exists and has favorite icon
await thumbnailUtils.expectThumbnailIsFavorite(page, assetToFavorite.id);
await page.getByRole('link').getByText('Favorites').click();
await thumbnailUtils.expectInViewport(page, assetToFavorite.id);
await thumbnailUtils.withAssetId(page, assetToFavorite.id).hover();
await thumbnailUtils.selectButton(page, assetToFavorite.id).click();
const unFavoriteRequest = pageRoutePromise(page, '**/api/assets', async (route, request) => {
const requestJson = request.postDataJSON();
if (requestJson.isFavorite === undefined) {
return await route.continue();
}
changes.assetFavorites = changes.assetFavorites.filter((id) => !requestJson.ids.includes(id));
await route.fulfill({
status: 204,
});
});
await page.getByLabel('Remove from favorites').click();
await expect(unFavoriteRequest).resolves.toEqual({
isFavorite: false,
ids: [assetToFavorite.id],
});
await expect(thumbnailUtils.withAssetId(page, assetToFavorite.id)).toHaveCount(0);
await page.getByText('Photos', { exact: true }).click();
await thumbnailUtils.expectInViewport(page, assetToFavorite.id);
});
test('Open album, favorite photo, open /favorites, remove favorite, Open album', async ({ page }) => {
const album = timelineRestData.album;
await pageUtils.openAlbumPage(page, album.id);
const assetToFavorite = getAsset(timelineRestData, album.assetIds[0])!;
await thumbnailUtils.withAssetId(page, assetToFavorite.id).hover();
await thumbnailUtils.selectButton(page, assetToFavorite.id).click();
const favorite = pageRoutePromise(page, '**/api/assets', async (route, request) => {
const requestJson = request.postDataJSON();
if (requestJson.isFavorite === undefined) {
return await route.continue();
}
const isFavorite = requestJson.isFavorite;
if (isFavorite) {
changes.assetFavorites.push(...requestJson.ids);
}
await route.fulfill({
status: 204,
});
});
await page.getByLabel('Favorite').click();
await expect(favorite).resolves.toEqual({
isFavorite: true,
ids: [assetToFavorite.id],
});
// ensure thumbnail still exists and has favorite icon
await thumbnailUtils.expectThumbnailIsFavorite(page, assetToFavorite.id);
await page.locator('#asset-selection-app-bar').getByLabel('Close').click();
await page.getByRole('link').getByText('Favorites').click();
await timelineUtils.waitForTimelineLoad(page);
await pageUtils.goToAsset(page, assetToFavorite.fileCreatedAt);
await thumbnailUtils.expectInViewport(page, assetToFavorite.id);
await thumbnailUtils.withAssetId(page, assetToFavorite.id).hover();
await thumbnailUtils.selectButton(page, assetToFavorite.id).click();
const unFavoriteRequest = pageRoutePromise(page, '**/api/assets', async (route, request) => {
const requestJson = request.postDataJSON();
if (requestJson.isFavorite === undefined) {
return await route.continue();
}
changes.assetFavorites = changes.assetFavorites.filter((id) => !requestJson.ids.includes(id));
await route.fulfill({
status: 204,
});
});
await page.getByLabel('Remove from favorites').click();
await expect(unFavoriteRequest).resolves.toEqual({
isFavorite: false,
ids: [assetToFavorite.id],
});
await expect(thumbnailUtils.withAssetId(page, assetToFavorite.id)).toHaveCount(0);
await pageUtils.openAlbumPage(page, album.id);
await thumbnailUtils.expectInViewport(page, assetToFavorite.id);
});
});
});
const getYearMonth = (assets: TimelineAssetConfig[], assetId: string) => {
const mockAsset = assets.find((mockAsset) => mockAsset.id === assetId)!;
const dateTime = DateTime.fromISO(mockAsset.fileCreatedAt!);
return dateTime.year + '-' + dateTime.month;
};

@ -0,0 +1,234 @@
import { BrowserContext, expect, Page } from '@playwright/test';
import { DateTime } from 'luxon';
import { TimelineAssetConfig } from 'src/generators/timeline';
export const sleep = (ms: number) => {
return new Promise((resolve) => setTimeout(resolve, ms));
};
export const padYearMonth = (yearMonth: string) => {
const [year, month] = yearMonth.split('-');
return `${year}-${month.padStart(2, '0')}`;
};
export async function throttlePage(context: BrowserContext, page: Page) {
const session = await context.newCDPSession(page);
await session.send('Network.emulateNetworkConditions', {
offline: false,
downloadThroughput: (1.5 * 1024 * 1024) / 8,
uploadThroughput: (750 * 1024) / 8,
latency: 40,
connectionType: 'cellular3g',
});
await session.send('Emulation.setCPUThrottlingRate', { rate: 10 });
}
let activePollsAbortController = new AbortController();
export const cancelAllPollers = () => {
activePollsAbortController.abort();
activePollsAbortController = new AbortController();
};
export const poll = async <T>(
page: Page,
query: () => Promise<T>,
callback?: (result: Awaited<T> | undefined) => boolean,
) => {
let result;
const timeout = Date.now() + 10_000;
const signal = activePollsAbortController.signal;
const terminate = callback || ((result: Awaited<T> | undefined) => !!result);
while (!terminate(result) && Date.now() < timeout) {
if (signal.aborted) {
return;
}
try {
result = await query();
} catch {
// ignore
}
if (signal.aborted) {
return;
}
if (page.isClosed()) {
return;
}
try {
await page.waitForTimeout(50);
} catch {
return;
}
}
if (!result) {
// rerun to trigger error if any
result = await query();
}
return result;
};
export const thumbnailUtils = {
locator(page: Page) {
return page.locator('[data-thumbnail-focus-container]');
},
withAssetId(page: Page, assetId: string) {
return page.locator(`[data-thumbnail-focus-container][data-asset="${assetId}"]`);
},
selectButton(page: Page, assetId: string) {
return page.locator(`[data-thumbnail-focus-container][data-asset="${assetId}"] button`);
},
selectedAsset(page: Page) {
return page.locator('[data-thumbnail-focus-container]:has(button[aria-checked])');
},
async clickAssetId(page: Page, assetId: string) {
await thumbnailUtils.withAssetId(page, assetId).click();
},
async queryThumbnailInViewport(page: Page, collector: (assetId: string) => boolean) {
const assetIds: string[] = [];
for (const thumb of await this.locator(page).all()) {
const box = await thumb.boundingBox();
if (box) {
const assetId = await thumb.evaluate((e) => e.dataset.asset);
if (collector?.(assetId!)) {
return [assetId!];
}
assetIds.push(assetId!);
}
}
return assetIds;
},
async getFirstInViewport(page: Page) {
return await poll(page, () => thumbnailUtils.queryThumbnailInViewport(page, () => true));
},
async getAllInViewport(page: Page, collector: (assetId: string) => boolean) {
return await poll(page, () => thumbnailUtils.queryThumbnailInViewport(page, collector));
},
async expectThumbnailIsFavorite(page: Page, assetId: string) {
await expect(
thumbnailUtils
.withAssetId(page, assetId)
.locator(
'path[d="M12,21.35L10.55,20.03C5.4,15.36 2,12.27 2,8.5C2,5.41 4.42,3 7.5,3C9.24,3 10.91,3.81 12,5.08C13.09,3.81 14.76,3 16.5,3C19.58,3 22,5.41 22,8.5C22,12.27 18.6,15.36 13.45,20.03L12,21.35Z"]',
),
).toHaveCount(1);
},
async expectThumbnailIsArchive(page: Page, assetId: string) {
await expect(
thumbnailUtils
.withAssetId(page, assetId)
.locator('path[d="M20 21H4V10H6V19H18V10H20V21M3 3H21V9H3V3M5 5V7H19V5M10.5 11V14H8L12 18L16 14H13.5V11"]'),
).toHaveCount(1);
},
async expectSelectedReadonly(page: Page, assetId: string) {
// todo - need a data attribute for selected
await expect(
page.locator(
`[data-thumbnail-focus-container][data-asset="${assetId}"] > .group.cursor-not-allowed > .rounded-xl`,
),
).toBeVisible();
},
async expectTimelineHasOnScreenAssets(page: Page) {
const first = await thumbnailUtils.getFirstInViewport(page);
if (page.isClosed()) {
return;
}
expect(first).toBeTruthy();
},
async expectInViewport(page: Page, assetId: string) {
const box = await poll(page, () => thumbnailUtils.withAssetId(page, assetId).boundingBox());
if (page.isClosed()) {
return;
}
expect(box).toBeTruthy();
},
async expectBottomIsTimelineBottom(page: Page, assetId: string) {
const box = await thumbnailUtils.withAssetId(page, assetId).boundingBox();
const gridBox = await timelineUtils.locator(page).boundingBox();
if (page.isClosed()) {
return;
}
expect(box!.y + box!.height).toBeCloseTo(gridBox!.y + gridBox!.height, 0);
},
async expectTopIsTimelineTop(page: Page, assetId: string) {
const box = await thumbnailUtils.withAssetId(page, assetId).boundingBox();
const gridBox = await timelineUtils.locator(page).boundingBox();
if (page.isClosed()) {
return;
}
expect(box!.y).toBeCloseTo(gridBox!.y, 0);
},
};
export const timelineUtils = {
locator(page: Page) {
return page.locator('#asset-grid');
},
async waitForTimelineLoad(page: Page) {
await expect(timelineUtils.locator(page)).toBeInViewport();
await expect.poll(() => thumbnailUtils.locator(page).count()).toBeGreaterThan(0);
},
async getScrollTop(page: Page) {
const queryTop = () =>
page.evaluate(() => {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
return document.querySelector('#asset-grid').scrollTop;
});
await expect.poll(queryTop).toBeGreaterThan(0);
return await queryTop();
},
};
export const assetViewerUtils = {
locator(page: Page) {
return page.locator('#immich-asset-viewer');
},
async waitForViewerLoad(page: Page, asset: TimelineAssetConfig) {
await page
.locator(`img[draggable="false"][src="/api/assets/${asset.id}/thumbnail?size=preview&c=${asset.thumbhash}"]`)
.or(page.locator(`video[poster="/api/assets/${asset.id}/thumbnail?size=preview&c=${asset.thumbhash}"]`))
.waitFor();
},
async expectActiveAssetToBe(page: Page, assetId: string) {
const activeElement = () =>
page.evaluate(() => {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
return document.activeElement?.dataset?.asset;
});
await expect(poll(page, activeElement, (result) => result === assetId)).resolves.toBe(assetId);
},
};
export const pageUtils = {
async deepLinkPhotosPage(page: Page, assetId: string) {
await page.goto(`/photos?at=${assetId}`);
await timelineUtils.waitForTimelineLoad(page);
},
async openPhotosPage(page: Page) {
await page.goto(`/photos`);
await timelineUtils.waitForTimelineLoad(page);
},
async openAlbumPage(page: Page, albumId: string) {
await page.goto(`/albums/${albumId}`);
await timelineUtils.waitForTimelineLoad(page);
},
async deepLinkAlbumPage(page: Page, albumId: string, assetId: string) {
await page.goto(`/albums/${albumId}?at=${assetId}`);
await timelineUtils.waitForTimelineLoad(page);
},
async goToAsset(page: Page, assetDate: string) {
await timelineUtils.locator(page).hover();
const stringDate = DateTime.fromISO(assetDate).toFormat('MMddyyyy,hh:mm:ss.SSSa');
await page.keyboard.press('g');
await page.locator('#datetime').pressSequentially(stringDate);
await page.getByText('Confirm').click();
},
async selectDay(page: Page, day: string) {
await page.getByTitle(day).hover();
await page.locator('[data-group] .w-8').click();
},
async pauseTestDebug() {
console.log('NOTE: pausing test indefinately for debug');
await new Promise(() => void 0);
},
};

@ -58,8 +58,12 @@ test.describe('User Administration', () => {
await expect(page.getByLabel('Admin User')).toBeChecked();
await page.getByRole('button', { name: 'Confirm' }).click();
const updated = await getUserAdmin({ id: user.userId }, { headers: asBearerAuth(admin.accessToken) });
expect(updated.isAdmin).toBe(true);
await expect
.poll(async () => {
const userAdmin = await getUserAdmin({ id: user.userId }, { headers: asBearerAuth(admin.accessToken) });
return userAdmin.isAdmin;
})
.toBe(true);
});
test('revoke admin access', async ({ context, page }) => {
@ -83,7 +87,11 @@ test.describe('User Administration', () => {
await expect(page.getByLabel('Admin User')).not.toBeChecked();
await page.getByRole('button', { name: 'Confirm' }).click();
const updated = await getUserAdmin({ id: user.userId }, { headers: asBearerAuth(admin.accessToken) });
expect(updated.isAdmin).toBe(false);
await expect
.poll(async () => {
const userAdmin = await getUserAdmin({ id: user.userId }, { headers: asBearerAuth(admin.accessToken) });
return userAdmin.isAdmin;
})
.toBe(false);
});
});

@ -17,7 +17,6 @@
"add_birthday": "Add a birthday",
"add_endpoint": "Add endpoint",
"add_exclusion_pattern": "Add exclusion pattern",
"add_import_path": "Add import path",
"add_location": "Add location",
"add_more_users": "Add more users",
"add_partner": "Add partner",
@ -113,13 +112,17 @@
"jobs_failed": "{jobCount, plural, other {# failed}}",
"library_created": "Created library: {library}",
"library_deleted": "Library deleted",
"library_import_path_description": "Specify a folder to import. This folder, including subfolders, will be scanned for images and videos.",
"library_details": "Library details",
"library_folder_description": "Specify a folder to import. This folder, including subfolders, will be scanned for images and videos.",
"library_remove_exclusion_pattern_prompt": "Are you sure you want to remove this exclusion pattern?",
"library_remove_folder_prompt": "Are you sure you want to remove this import folder?",
"library_scanning": "Periodic Scanning",
"library_scanning_description": "Configure periodic library scanning",
"library_scanning_enable_description": "Enable periodic library scanning",
"library_settings": "External Library",
"library_settings_description": "Manage external library settings",
"library_tasks_description": "Scan external libraries for new and/or changed assets",
"library_updated": "Updated library",
"library_watching_enable_description": "Watch external libraries for file changes",
"library_watching_settings": "Library watching [EXPERIMENTAL]",
"library_watching_settings_description": "Automatically watch for changed files",
@ -901,8 +904,6 @@
"edit_description_prompt": "Please select a new description:",
"edit_exclusion_pattern": "Edit exclusion pattern",
"edit_faces": "Edit faces",
"edit_import_path": "Edit import path",
"edit_import_paths": "Edit Import Paths",
"edit_key": "Edit key",
"edit_link": "Edit link",
"edit_location": "Edit location",
@ -974,8 +975,8 @@
"failed_to_stack_assets": "Failed to stack assets",
"failed_to_unstack_assets": "Failed to un-stack assets",
"failed_to_update_notification_status": "Failed to update notification status",
"import_path_already_exists": "This import path already exists.",
"incorrect_email_or_password": "Incorrect email or password",
"library_folder_already_exists": "This import path already exists.",
"paths_validation_failed": "{paths, plural, one {# path} other {# paths}} failed validation",
"profile_picture_transparent_pixels": "Profile pictures cannot have transparent pixels. Please zoom in and/or move the image.",
"quota_higher_than_disk_size": "You set a quota higher than the disk size",
@ -984,7 +985,6 @@
"unable_to_add_assets_to_shared_link": "Unable to add assets to shared link",
"unable_to_add_comment": "Unable to add comment",
"unable_to_add_exclusion_pattern": "Unable to add exclusion pattern",
"unable_to_add_import_path": "Unable to add import path",
"unable_to_add_partners": "Unable to add partners",
"unable_to_add_remove_archive": "Unable to {archived, select, true {remove asset from} other {add asset to}} archive",
"unable_to_add_remove_favorites": "Unable to {favorite, select, true {add asset to} other {remove asset from}} favorites",
@ -1007,12 +1007,10 @@
"unable_to_delete_asset": "Unable to delete asset",
"unable_to_delete_assets": "Error deleting assets",
"unable_to_delete_exclusion_pattern": "Unable to delete exclusion pattern",
"unable_to_delete_import_path": "Unable to delete import path",
"unable_to_delete_shared_link": "Unable to delete shared link",
"unable_to_delete_user": "Unable to delete user",
"unable_to_download_files": "Unable to download files",
"unable_to_edit_exclusion_pattern": "Unable to edit exclusion pattern",
"unable_to_edit_import_path": "Unable to edit import path",
"unable_to_empty_trash": "Unable to empty trash",
"unable_to_enter_fullscreen": "Unable to enter fullscreen",
"unable_to_exit_fullscreen": "Unable to exit fullscreen",
@ -1063,6 +1061,7 @@
"unable_to_update_user": "Unable to update user",
"unable_to_upload_file": "Unable to upload file"
},
"exclusion_pattern": "Exclusion pattern",
"exif": "Exif",
"exif_bottom_sheet_description": "Add Description...",
"exif_bottom_sheet_description_error": "Error updating description",
@ -1251,6 +1250,8 @@
"let_others_respond": "Let others respond",
"level": "Level",
"library": "Library",
"library_add_folder": "Add folder",
"library_edit_folder": "Edit folder",
"library_options": "Library options",
"library_page_device_albums": "Albums on Device",
"library_page_new_album": "New album",
@ -1449,6 +1450,7 @@
"no_favorites_message": "Add favorites to quickly find your best pictures and videos",
"no_libraries_message": "Create an external library to view your photos and videos",
"no_local_assets_found": "No local assets found with this checksum",
"no_location_set": "No location set",
"no_locked_photos_message": "Photos and videos in the locked folder are hidden and won't show up as you browse or search your library.",
"no_name": "No Name",
"no_notifications": "No notifications",

@ -3,8 +3,6 @@ class ExifInfo {
final int? fileSize;
final String? description;
final bool isFlipped;
final double? width;
final double? height;
final String? orientation;
final String? timeZone;
final DateTime? dateTimeOriginal;
@ -46,8 +44,6 @@ class ExifInfo {
this.fileSize,
this.description,
this.orientation,
this.width,
this.height,
this.timeZone,
this.dateTimeOriginal,
this.isFlipped = false,
@ -72,8 +68,6 @@ class ExifInfo {
return other.fileSize == fileSize &&
other.description == description &&
other.isFlipped == isFlipped &&
other.width == width &&
other.height == height &&
other.orientation == orientation &&
other.timeZone == timeZone &&
other.dateTimeOriginal == dateTimeOriginal &&
@ -98,8 +92,6 @@ class ExifInfo {
description.hashCode ^
orientation.hashCode ^
isFlipped.hashCode ^
width.hashCode ^
height.hashCode ^
timeZone.hashCode ^
dateTimeOriginal.hashCode ^
latitude.hashCode ^
@ -123,8 +115,6 @@ class ExifInfo {
fileSize: ${fileSize ?? 'NA'},
description: ${description ?? 'NA'},
orientation: ${orientation ?? 'NA'},
width: ${width ?? 'NA'},
height: ${height ?? 'NA'},
isFlipped: $isFlipped,
timeZone: ${timeZone ?? 'NA'},
dateTimeOriginal: ${dateTimeOriginal ?? 'NA'},

@ -65,8 +65,8 @@ class AssetService {
if (asset.hasRemote) {
final exif = await getExif(asset);
isFlipped = ExifDtoConverter.isOrientationFlipped(exif?.orientation);
width = exif?.width ?? asset.width?.toDouble();
height = exif?.height ?? asset.height?.toDouble();
width = asset.width?.toDouble();
height = asset.height?.toDouble();
} else if (asset is LocalAsset) {
isFlipped = CurrentPlatform.isAndroid && (asset.orientation == 90 || asset.orientation == 270);
width = asset.width?.toDouble();

@ -177,6 +177,12 @@ class BackgroundWorkerBgService extends BackgroundWorkerFlutterApi {
}
Future<void> _cleanup() async {
await runZonedGuarded(_handleCleanup, (error, stack) {
dPrint(() => "Error during background worker cleanup: $error, $stack");
});
}
Future<void> _handleCleanup() async {
// If ref is null, it means the service was never initialized properly
if (_isCleanedUp || _ref == null) {
return;
@ -186,11 +192,16 @@ class BackgroundWorkerBgService extends BackgroundWorkerFlutterApi {
_isCleanedUp = true;
final backgroundSyncManager = _ref?.read(backgroundSyncProvider);
final nativeSyncApi = _ref?.read(nativeSyncApiProvider);
await _drift.close();
await _driftLogger.close();
_ref?.dispose();
_ref = null;
_cancellationToken.cancel();
_logger.info("Cleaning up background worker");
final cleanupFutures = [
nativeSyncApi?.cancelHashing(),
workerManagerPatch.dispose().catchError((_) async {
@ -199,8 +210,7 @@ class BackgroundWorkerBgService extends BackgroundWorkerFlutterApi {
}),
LogService.I.dispose(),
Store.dispose(),
_drift.close(),
_driftLogger.close(),
backgroundSyncManager?.cancel(),
];

@ -165,8 +165,6 @@ extension RemoteExifEntityDataDomainEx on RemoteExifEntityData {
f: fNumber?.toDouble(),
mm: focalLength?.toDouble(),
lens: lens,
width: width?.toDouble(),
height: height?.toDouble(),
isFlipped: ExifDtoConverter.isOrientationFlipped(orientation),
);
}

@ -219,8 +219,6 @@ class SyncStreamRepository extends DriftDatabaseRepository {
country: Value(exif.country),
dateTimeOriginal: Value(exif.dateTimeOriginal),
description: Value(exif.description),
height: Value(exif.exifImageHeight),
width: Value(exif.exifImageWidth),
exposureTime: Value(exif.exposureTime),
fNumber: Value(exif.fNumber),
fileSize: Value(exif.fileSizeInByte),
@ -244,6 +242,16 @@ class SyncStreamRepository extends DriftDatabaseRepository {
);
}
});
await _db.batch((batch) {
for (final exif in data) {
batch.update(
_db.remoteAssetEntity,
RemoteAssetEntityCompanion(width: Value(exif.exifImageWidth), height: Value(exif.exifImageHeight)),
where: (row) => row.id.equals(exif.assetId),
);
}
});
} catch (error, stack) {
_logger.severe('Error: updateAssetsExifV1 - $debugLabel', error, stack);
rethrow;

@ -161,8 +161,6 @@ class _AssetPropertiesSectionState extends ConsumerState<_AssetPropertiesSection
value: exif.fileSize != null ? '${(exif.fileSize! / 1024 / 1024).toStringAsFixed(2)} MB' : null,
),
_PropertyItem(label: 'Description', value: exif.description),
_PropertyItem(label: 'EXIF Width', value: exif.width?.toString()),
_PropertyItem(label: 'EXIF Height', value: exif.height?.toString()),
_PropertyItem(label: 'Date Taken', value: exif.dateTimeOriginal?.toString()),
_PropertyItem(label: 'Time Zone', value: exif.timeZone),
_PropertyItem(label: 'Camera Make', value: exif.make),

@ -4,7 +4,6 @@ import 'package:auto_route/auto_route.dart';
import 'package:collection/collection.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
@ -16,8 +15,8 @@ import 'package:immich_mobile/presentation/widgets/album/album_tile.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/bottom_sheet/sheet_location_details.widget.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/bottom_sheet/sheet_people_details.widget.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/sheet_tile.widget.dart';
import 'package:immich_mobile/presentation/widgets/bottom_sheet/base_bottom_sheet.widget.dart';
import 'package:immich_mobile/providers/haptic_feedback.provider.dart';
import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
import 'package:immich_mobile/providers/infrastructure/album.provider.dart';
import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart';
@ -97,8 +96,8 @@ class _AssetDetailBottomSheet extends ConsumerWidget {
}
String _getFileInfo(BaseAsset asset, ExifInfo? exifInfo) {
final height = asset.height ?? exifInfo?.height;
final width = asset.width ?? exifInfo?.width;
final height = asset.height;
final width = asset.width;
final resolution = (width != null && height != null) ? "${width.toInt()} x ${height.toInt()}" : null;
final fileSize = exifInfo?.fileSize != null ? formatBytes(exifInfo!.fileSize!) : null;
@ -181,7 +180,7 @@ class _AssetDetailBottomSheet extends ConsumerWidget {
spacing: 12,
children: [
if (albums.isNotEmpty)
_SheetTile(
SheetTile(
title: 'appears_in'.t(context: context).toUpperCase(),
titleStyle: context.textTheme.labelMedium?.copyWith(
color: context.textTheme.labelMedium?.color?.withAlpha(200),
@ -233,7 +232,7 @@ class _AssetDetailBottomSheet extends ConsumerWidget {
future: assetMediaRepository.getOriginalFilename(asset.id),
builder: (context, snapshot) {
final displayName = snapshot.data ?? asset.name;
return _SheetTile(
return SheetTile(
title: displayName,
titleStyle: context.textTheme.labelLarge,
leading: Icon(
@ -250,7 +249,7 @@ class _AssetDetailBottomSheet extends ConsumerWidget {
);
} else {
// For remote assets, use the name directly
return _SheetTile(
return SheetTile(
title: asset.name,
titleStyle: context.textTheme.labelLarge,
leading: Icon(
@ -269,7 +268,7 @@ class _AssetDetailBottomSheet extends ConsumerWidget {
return SliverList.list(
children: [
// Asset Date and Time
_SheetTile(
SheetTile(
title: _getDateTime(context, asset),
titleStyle: context.textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w600),
trailing: asset.hasRemote && isOwner ? const Icon(Icons.edit, size: 18) : null,
@ -279,7 +278,7 @@ class _AssetDetailBottomSheet extends ConsumerWidget {
const SheetPeopleDetails(),
const SheetLocationDetails(),
// Details header
_SheetTile(
SheetTile(
title: 'exif_bottom_sheet_details'.t(context: context),
titleStyle: context.textTheme.labelMedium?.copyWith(
color: context.textTheme.labelMedium?.color?.withAlpha(200),
@ -290,7 +289,7 @@ class _AssetDetailBottomSheet extends ConsumerWidget {
buildFileInfoTile(),
// Camera info
if (cameraTitle != null)
_SheetTile(
SheetTile(
title: cameraTitle,
titleStyle: context.textTheme.labelLarge,
leading: Icon(Icons.camera_alt_outlined, size: 24, color: context.textTheme.labelLarge?.color),
@ -301,7 +300,7 @@ class _AssetDetailBottomSheet extends ConsumerWidget {
),
// Lens info
if (lensTitle != null)
_SheetTile(
SheetTile(
title: lensTitle,
titleStyle: context.textTheme.labelLarge,
leading: Icon(Icons.camera_outlined, size: 24, color: context.textTheme.labelLarge?.color),
@ -319,77 +318,6 @@ class _AssetDetailBottomSheet extends ConsumerWidget {
}
}
class _SheetTile extends ConsumerWidget {
final String title;
final Widget? leading;
final Widget? trailing;
final String? subtitle;
final TextStyle? titleStyle;
final TextStyle? subtitleStyle;
final VoidCallback? onTap;
const _SheetTile({
required this.title,
this.titleStyle,
this.leading,
this.subtitle,
this.subtitleStyle,
this.trailing,
this.onTap,
});
void copyTitle(BuildContext context, WidgetRef ref) {
Clipboard.setData(ClipboardData(text: title));
ImmichToast.show(
context: context,
msg: 'copied_to_clipboard'.t(context: context),
toastType: ToastType.info,
);
ref.read(hapticFeedbackProvider.notifier).selectionClick();
}
@override
Widget build(BuildContext context, WidgetRef ref) {
final Widget titleWidget;
if (leading == null) {
titleWidget = LimitedBox(
maxWidth: double.infinity,
child: Text(title, style: titleStyle),
);
} else {
titleWidget = Container(
width: double.infinity,
padding: const EdgeInsets.only(left: 15),
child: Text(title, style: titleStyle),
);
}
final Widget? subtitleWidget;
if (leading == null && subtitle != null) {
subtitleWidget = Text(subtitle!, style: subtitleStyle);
} else if (leading != null && subtitle != null) {
subtitleWidget = Padding(
padding: const EdgeInsets.only(left: 15),
child: Text(subtitle!, style: subtitleStyle),
);
} else {
subtitleWidget = null;
}
return ListTile(
dense: true,
visualDensity: VisualDensity.compact,
title: GestureDetector(onLongPress: () => copyTitle(context, ref), child: titleWidget),
titleAlignment: ListTileTitleAlignment.center,
leading: leading,
trailing: trailing,
contentPadding: leading == null ? null : const EdgeInsets.only(left: 25),
subtitle: subtitleWidget,
onTap: onTap,
);
}
}
class _SheetAssetDescription extends ConsumerStatefulWidget {
final ExifInfo exif;
final bool isEditable;

@ -1,9 +1,12 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/exif.model.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/sheet_tile.widget.dart';
import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart';
import 'package:immich_mobile/widgets/asset_viewer/detail_panel/exif_map.dart';
import 'package:maplibre_gl/maplibre_gl.dart';
@ -16,8 +19,6 @@ class SheetLocationDetails extends ConsumerStatefulWidget {
}
class _SheetLocationDetailsState extends ConsumerState<SheetLocationDetails> {
BaseAsset? asset;
ExifInfo? exifInfo;
MapLibreMapController? _mapController;
String? _getLocationName(ExifInfo? exifInfo) {
@ -39,14 +40,11 @@ class _SheetLocationDetailsState extends ConsumerState<SheetLocationDetails> {
}
void _onExifChanged(AsyncValue<ExifInfo?>? previous, AsyncValue<ExifInfo?> current) {
asset = ref.read(currentAssetNotifier);
setState(() {
exifInfo = current.valueOrNull;
final hasCoordinates = exifInfo?.hasCoordinates ?? false;
if (exifInfo != null && hasCoordinates) {
_mapController?.moveCamera(CameraUpdate.newLatLng(LatLng(exifInfo!.latitude!, exifInfo!.longitude!)));
}
});
final currentExif = current.valueOrNull;
if (currentExif != null && currentExif.hasCoordinates) {
_mapController?.moveCamera(CameraUpdate.newLatLng(LatLng(currentExif.latitude!, currentExif.longitude!)));
}
}
@override
@ -55,45 +53,71 @@ class _SheetLocationDetailsState extends ConsumerState<SheetLocationDetails> {
ref.listenManual(currentAssetExifProvider, _onExifChanged, fireImmediately: true);
}
void editLocation() async {
await ref.read(actionProvider.notifier).editLocation(ActionSource.viewer, context);
}
@override
Widget build(BuildContext context) {
final asset = ref.watch(currentAssetNotifier);
final exifInfo = ref.watch(currentAssetExifProvider).valueOrNull;
final hasCoordinates = exifInfo?.hasCoordinates ?? false;
// Guard no lat/lng
if (!hasCoordinates || (asset != null && asset is LocalAsset && asset!.hasRemote)) {
// Guard local assets
if (asset != null && asset is LocalAsset && asset.hasRemote) {
return const SizedBox.shrink();
}
final remoteId = asset is LocalAsset ? (asset as LocalAsset).remoteId : (asset as RemoteAsset).id;
final remoteId = asset is LocalAsset ? asset.remoteId : (asset as RemoteAsset).id;
final locationName = _getLocationName(exifInfo);
final coordinates = "${exifInfo!.latitude!.toStringAsFixed(4)}, ${exifInfo!.longitude!.toStringAsFixed(4)}";
final coordinates = "${exifInfo?.latitude?.toStringAsFixed(4)}, ${exifInfo?.longitude?.toStringAsFixed(4)}";
return Padding(
padding: EdgeInsets.symmetric(vertical: 16.0, horizontal: context.isMobile ? 16.0 : 56.0),
padding: const EdgeInsets.only(bottom: 12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.only(bottom: 16),
child: Text(
"exif_bottom_sheet_location".t(context: context),
style: context.textTheme.labelMedium?.copyWith(
color: context.textTheme.labelMedium?.color?.withAlpha(200),
fontWeight: FontWeight.w600,
),
SheetTile(
title: 'exif_bottom_sheet_location'.t(context: context),
titleStyle: context.textTheme.labelMedium?.copyWith(
color: context.textTheme.labelMedium?.color?.withAlpha(200),
fontWeight: FontWeight.w600,
),
trailing: hasCoordinates ? const Icon(Icons.edit_location_alt, size: 20) : null,
onTap: editLocation,
),
ExifMap(exifInfo: exifInfo!, markerId: remoteId, onMapCreated: _onMapCreated),
const SizedBox(height: 15),
if (locationName != null)
if (hasCoordinates)
Padding(
padding: const EdgeInsets.only(bottom: 4.0),
child: Text(locationName, style: context.textTheme.labelLarge),
padding: EdgeInsets.symmetric(horizontal: context.isMobile ? 16.0 : 56.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ExifMap(exifInfo: exifInfo!, markerId: remoteId, onMapCreated: _onMapCreated),
const SizedBox(height: 16),
if (locationName != null)
Padding(
padding: const EdgeInsets.only(bottom: 4.0),
child: Text(locationName, style: context.textTheme.labelLarge),
),
Text(
coordinates,
style: context.textTheme.labelMedium?.copyWith(
color: context.textTheme.labelMedium?.color?.withAlpha(150),
),
),
],
),
),
if (!hasCoordinates)
SheetTile(
title: "add_a_location".t(context: context),
titleStyle: context.textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w600,
color: context.primaryColor,
),
leading: const Icon(Icons.location_off),
onTap: editLocation,
),
Text(
coordinates,
style: context.textTheme.labelMedium?.copyWith(color: context.textTheme.labelMedium?.color?.withAlpha(150)),
),
],
),
);

@ -0,0 +1,78 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/providers/haptic_feedback.provider.dart';
import 'package:immich_mobile/widgets/common/immich_toast.dart';
class SheetTile extends ConsumerWidget {
final String title;
final Widget? leading;
final Widget? trailing;
final String? subtitle;
final TextStyle? titleStyle;
final TextStyle? subtitleStyle;
final VoidCallback? onTap;
const SheetTile({
super.key,
required this.title,
this.titleStyle,
this.leading,
this.subtitle,
this.subtitleStyle,
this.trailing,
this.onTap,
});
void copyTitle(BuildContext context, WidgetRef ref) {
Clipboard.setData(ClipboardData(text: title));
ImmichToast.show(
context: context,
msg: 'copied_to_clipboard'.t(context: context),
toastType: ToastType.info,
);
ref.read(hapticFeedbackProvider.notifier).selectionClick();
}
@override
Widget build(BuildContext context, WidgetRef ref) {
final Widget titleWidget;
if (leading == null) {
titleWidget = LimitedBox(
maxWidth: double.infinity,
child: Text(title, style: titleStyle),
);
} else {
titleWidget = Container(
width: double.infinity,
padding: const EdgeInsets.only(left: 15),
child: Text(title, style: titleStyle),
);
}
final Widget? subtitleWidget;
if (leading == null && subtitle != null) {
subtitleWidget = Text(subtitle!, style: subtitleStyle);
} else if (leading != null && subtitle != null) {
subtitleWidget = Padding(
padding: const EdgeInsets.only(left: 15),
child: Text(subtitle!, style: subtitleStyle),
);
} else {
subtitleWidget = null;
}
return ListTile(
dense: true,
visualDensity: VisualDensity.compact,
title: GestureDetector(onLongPress: () => copyTitle(context, ref), child: titleWidget),
titleAlignment: ListTileTitleAlignment.center,
leading: leading,
trailing: trailing,
contentPadding: leading == null ? null : const EdgeInsets.only(left: 25),
subtitle: subtitleWidget,
onTap: onTap,
);
}
}

@ -301,6 +301,13 @@ class ActionNotifier extends Notifier<void> {
return null;
}
// This must be called since editing location
// does not update the currentAsset which means
// the exif provider will not be refreshed automatically
if (source == ActionSource.viewer) {
ref.invalidate(currentAssetExifProvider);
}
return ActionResult(count: ids.length, success: true);
} catch (error, stack) {
_logger.severe('Failed to edit location for assets', error, stack);

@ -29,7 +29,7 @@ import 'package:isar/isar.dart';
// ignore: import_rule_photo_manager
import 'package:photo_manager/photo_manager.dart';
const int targetVersion = 17;
const int targetVersion = 18;
Future<void> migrateDatabaseIfNeeded(Isar db, Drift drift) async {
final hasVersion = Store.tryGet(StoreKey.version) != null;
@ -63,7 +63,8 @@ Future<void> migrateDatabaseIfNeeded(Isar db, Drift drift) async {
await Store.populateCache();
}
await handleBetaMigration(version, await _isNewInstallation(db, drift), SyncStreamRepository(drift));
final syncStreamRepository = SyncStreamRepository(drift);
await handleBetaMigration(version, await _isNewInstallation(db, drift), syncStreamRepository);
if (version < 17 && Store.isBetaTimelineEnabled) {
final delay = Store.get(StoreKey.backupTriggerDelay, AppSettingsEnum.backupTriggerDelay.defaultValue);
@ -72,6 +73,11 @@ Future<void> migrateDatabaseIfNeeded(Isar db, Drift drift) async {
}
}
if (version < 18 && Store.isBetaTimelineEnabled) {
await syncStreamRepository.reset();
await Store.put(StoreKey.shouldResetSync, true);
}
if (targetVersion >= 12) {
await Store.put(StoreKey.version, targetVersion);
return;

@ -5,7 +5,6 @@ import 'package:flutter/services.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/string_extensions.dart';
import 'package:immich_mobile/widgets/map/map_thumbnail.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:maplibre_gl/maplibre_gl.dart';
@ -17,19 +16,36 @@ Future<LatLng?> showLocationPicker({required BuildContext context, LatLng? initi
);
}
enum _LocationPickerMode { map, manual }
class _LocationPicker extends HookWidget {
final LatLng? initialLatLng;
const _LocationPicker({this.initialLatLng});
bool _validateLat(String value) {
final l = double.tryParse(value);
return l != null && l > -90 && l < 90;
}
bool _validateLong(String value) {
final l = double.tryParse(value);
return l != null && l > -180 && l < 180;
}
@override
Widget build(BuildContext context) {
final latitude = useState(initialLatLng?.latitude ?? 0.0);
final longitude = useState(initialLatLng?.longitude ?? 0.0);
final latlng = LatLng(latitude.value, longitude.value);
final pickerMode = useState(_LocationPickerMode.map);
final latitiudeFocusNode = useFocusNode();
final longitudeFocusNode = useFocusNode();
final latitudeController = useTextEditingController(text: latitude.value.toStringAsFixed(4));
final longitudeController = useTextEditingController(text: longitude.value.toStringAsFixed(4));
useEffect(() {
latitudeController.text = latitude.value.toStringAsFixed(4);
longitudeController.text = longitude.value.toStringAsFixed(4);
return null;
}, [latitude.value, longitude.value]);
Future<void> onMapTap() async {
final newLatLng = await context.pushRoute<LatLng?>(MapLocationPickerRoute(initialLatLng: latlng));
@ -39,23 +55,55 @@ class _LocationPicker extends HookWidget {
}
}
void onLatitudeUpdated(double value) {
latitude.value = value;
longitudeFocusNode.requestFocus();
}
void onLongitudeEditingCompleted(double value) {
longitude.value = value;
longitudeFocusNode.unfocus();
}
return AlertDialog(
contentPadding: const EdgeInsets.all(30),
alignment: Alignment.center,
content: SingleChildScrollView(
child: pickerMode.value == _LocationPickerMode.map
? _MapPicker(
key: ValueKey(latlng),
latlng: latlng,
onModeSwitch: () => pickerMode.value = _LocationPickerMode.manual,
onMapTap: onMapTap,
)
: _ManualPicker(
latlng: latlng,
onModeSwitch: () => pickerMode.value = _LocationPickerMode.map,
onLatUpdated: (value) => latitude.value = value,
onLonUpdated: (value) => longitude.value = value,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text("edit_location_dialog_title", style: context.textTheme.titleMedium).tr(),
Align(
alignment: Alignment.center,
child: TextButton.icon(
icon: const Text("location_picker_choose_on_map").tr(),
label: const Icon(Icons.map_outlined, size: 16),
onPressed: onMapTap,
),
),
const SizedBox(height: 12),
_ManualPickerInput(
controller: latitudeController,
decorationText: "latitude",
hintText: "location_picker_latitude_hint",
errorText: "location_picker_latitude_error",
focusNode: latitiudeFocusNode,
validator: _validateLat,
onUpdated: onLatitudeUpdated,
),
const SizedBox(height: 24),
_ManualPickerInput(
controller: longitudeController,
decorationText: "longitude",
hintText: "location_picker_longitude_hint",
errorText: "location_picker_longitude_error",
focusNode: longitudeFocusNode,
validator: _validateLong,
onUpdated: onLongitudeEditingCompleted,
),
],
),
),
actions: [
TextButton(
@ -81,7 +129,7 @@ class _LocationPicker extends HookWidget {
}
class _ManualPickerInput extends HookWidget {
final String initialValue;
final TextEditingController controller;
final String decorationText;
final String hintText;
final String errorText;
@ -90,7 +138,7 @@ class _ManualPickerInput extends HookWidget {
final Function(double value) onUpdated;
const _ManualPickerInput({
required this.initialValue,
required this.controller,
required this.decorationText,
required this.hintText,
required this.errorText,
@ -101,7 +149,6 @@ class _ManualPickerInput extends HookWidget {
@override
Widget build(BuildContext context) {
final isValid = useState(true);
final controller = useTextEditingController(text: initialValue);
void onEditingComplete() {
isValid.value = validator(controller.text);
@ -131,109 +178,3 @@ class _ManualPickerInput extends HookWidget {
);
}
}
class _ManualPicker extends HookWidget {
final LatLng latlng;
final Function() onModeSwitch;
final Function(double) onLatUpdated;
final Function(double) onLonUpdated;
const _ManualPicker({
required this.latlng,
required this.onModeSwitch,
required this.onLatUpdated,
required this.onLonUpdated,
});
bool _validateLat(String value) {
final l = double.tryParse(value);
return l != null && l > -90 && l < 90;
}
bool _validateLong(String value) {
final l = double.tryParse(value);
return l != null && l > -180 && l < 180;
}
@override
Widget build(BuildContext context) {
final latitiudeFocusNode = useFocusNode();
final longitudeFocusNode = useFocusNode();
void onLatitudeUpdated(double value) {
onLatUpdated(value);
longitudeFocusNode.requestFocus();
}
void onLongitudeEditingCompleted(double value) {
onLonUpdated(value);
longitudeFocusNode.unfocus();
}
return Column(
mainAxisSize: MainAxisSize.min,
children: [
const Text("edit_location_dialog_title", textAlign: TextAlign.center).tr(),
const SizedBox(height: 12),
TextButton.icon(
icon: const Text("location_picker_choose_on_map").tr(),
label: const Icon(Icons.map_outlined, size: 16),
onPressed: onModeSwitch,
),
const SizedBox(height: 12),
_ManualPickerInput(
initialValue: latlng.latitude.toStringAsFixed(4),
decorationText: "latitude",
hintText: "location_picker_latitude_hint",
errorText: "location_picker_latitude_error",
focusNode: latitiudeFocusNode,
validator: _validateLat,
onUpdated: onLatitudeUpdated,
),
const SizedBox(height: 24),
_ManualPickerInput(
initialValue: latlng.longitude.toStringAsFixed(4),
decorationText: "longitude",
hintText: "location_picker_longitude_hint",
errorText: "location_picker_longitude_error",
focusNode: longitudeFocusNode,
validator: _validateLong,
onUpdated: onLongitudeEditingCompleted,
),
],
);
}
}
class _MapPicker extends StatelessWidget {
final LatLng latlng;
final Function() onModeSwitch;
final Function() onMapTap;
const _MapPicker({required this.latlng, required this.onModeSwitch, required this.onMapTap, super.key});
@override
Widget build(BuildContext context) {
return Column(
mainAxisSize: MainAxisSize.min,
children: [
const Text("edit_location_dialog_title", textAlign: TextAlign.center).tr(),
const SizedBox(height: 12),
TextButton.icon(
icon: Text("${latlng.latitude.toStringAsFixed(4)}, ${latlng.longitude.toStringAsFixed(4)}"),
label: const Icon(Icons.edit_outlined, size: 16),
onPressed: onModeSwitch,
),
const SizedBox(height: 12),
MapThumbnail(
centre: latlng,
height: 200,
width: 200,
zoom: 8,
showMarkerPin: true,
onTap: (_, __) => onMapTap(),
),
],
);
}
}

@ -452,10 +452,11 @@ packages:
drift:
dependency: "direct main"
description:
name: drift
sha256: "14a61af39d4584faf1d73b5b35e4b758a43008cf4c0fdb0576ec8e7032c0d9a5"
url: "https://pub.dev"
source: hosted
path: drift
ref: "53ef7e9f19fe8f68416251760b4b99fe43f1c575"
resolved-ref: "53ef7e9f19fe8f68416251760b4b99fe43f1c575"
url: "https://github.com/immich-app/drift"
source: git
version: "2.26.0"
drift_dev:
dependency: "direct dev"

@ -113,6 +113,13 @@ dev_dependencies:
riverpod_generator: ^2.6.1
riverpod_lint: ^2.6.1
dependency_overrides:
drift:
git:
url: https://github.com/immich-app/drift
ref: '53ef7e9f19fe8f68416251760b4b99fe43f1c575'
path: drift/
flutter:
uses-material-design: true
assets:

@ -19,7 +19,7 @@
"@oazapfts/runtime": "^1.0.2"
},
"devDependencies": {
"@types/node": "^22.19.0",
"@types/node": "^22.19.1",
"typescript": "^5.3.3"
},
"repository": {

@ -63,11 +63,11 @@ importers:
specifier: ^4.13.1
version: 4.13.4
'@types/node':
specifier: ^22.19.0
version: 22.19.0
specifier: ^22.19.1
version: 22.19.1
'@vitest/coverage-v8':
specifier: ^3.0.0
version: 3.2.4(vitest@3.2.4(@types/debug@4.1.12)(@types/node@22.19.0)(happy-dom@20.0.10)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1))
version: 3.2.4(vitest@3.2.4(@types/debug@4.1.12)(@types/node@22.19.1)(happy-dom@20.0.10)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1))
byte-size:
specifier: ^9.0.0
version: 9.0.1
@ -109,16 +109,16 @@ importers:
version: 8.46.2(eslint@9.38.0(jiti@2.6.1))(typescript@5.9.3)
vite:
specifier: ^7.0.0
version: 7.1.12(@types/node@22.19.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1)
version: 7.1.12(@types/node@22.19.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1)
vite-tsconfig-paths:
specifier: ^5.0.0
version: 5.1.4(typescript@5.9.3)(vite@7.1.12(@types/node@22.19.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1))
version: 5.1.4(typescript@5.9.3)(vite@7.1.12(@types/node@22.19.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1))
vitest:
specifier: ^3.0.0
version: 3.2.4(@types/debug@4.1.12)(@types/node@22.19.0)(happy-dom@20.0.10)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1)
version: 3.2.4(@types/debug@4.1.12)(@types/node@22.19.1)(happy-dom@20.0.10)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1)
vitest-fetch-mock:
specifier: ^0.4.0
version: 0.4.5(vitest@3.2.4(@types/debug@4.1.12)(@types/node@22.19.0)(happy-dom@20.0.10)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1))
version: 0.4.5(vitest@3.2.4(@types/debug@4.1.12)(@types/node@22.19.1)(happy-dom@20.0.10)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1))
yaml:
specifier: ^2.3.1
version: 2.8.1
@ -195,6 +195,9 @@ importers:
'@eslint/js':
specifier: ^9.8.0
version: 9.38.0
'@faker-js/faker':
specifier: ^10.1.0
version: 10.1.0
'@immich/cli':
specifier: file:../cli
version: link:../cli
@ -211,8 +214,8 @@ importers:
specifier: ^3.4.2
version: 3.7.1
'@types/node':
specifier: ^22.19.0
version: 22.19.0
specifier: ^22.19.1
version: 22.19.1
'@types/oidc-provider':
specifier: ^9.0.0
version: 9.5.0
@ -225,6 +228,9 @@ importers:
'@types/supertest':
specifier: ^6.0.2
version: 6.0.3
dotenv:
specifier: ^17.2.3
version: 17.2.3
eslint:
specifier: ^9.14.0
version: 9.38.0(jiti@2.6.1)
@ -284,7 +290,7 @@ importers:
version: 5.2.1(encoding@0.1.13)
vitest:
specifier: ^3.0.0
version: 3.2.4(@types/debug@4.1.12)(@types/node@22.19.0)(happy-dom@20.0.10)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2(encoding@0.1.13)))(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1)
version: 3.2.4(@types/debug@4.1.12)(@types/node@22.19.1)(happy-dom@20.0.10)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2(encoding@0.1.13)))(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1)
open-api/typescript-sdk:
dependencies:
@ -293,8 +299,8 @@ importers:
version: 1.0.4
devDependencies:
'@types/node':
specifier: ^22.19.0
version: 22.19.0
specifier: ^22.19.1
version: 22.19.1
typescript:
specifier: ^5.3.3
version: 5.9.3
@ -474,7 +480,7 @@ importers:
version: 2.0.2
nest-commander:
specifier: ^3.16.0
version: 3.20.1(@nestjs/common@11.1.8(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.8)(@types/inquirer@8.2.11)(@types/node@22.19.0)(typescript@5.9.3)
version: 3.20.1(@nestjs/common@11.1.8(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.8)(@types/inquirer@8.2.11)(@types/node@22.19.1)(typescript@5.9.3)
nestjs-cls:
specifier: ^5.0.0
version: 5.4.3(@nestjs/common@11.1.8(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.8)(reflect-metadata@0.2.2)(rxjs@7.8.2)
@ -556,7 +562,7 @@ importers:
version: 9.38.0
'@nestjs/cli':
specifier: ^11.0.2
version: 11.0.10(@swc/core@1.14.0(@swc/helpers@0.5.17))(@types/node@22.19.0)
version: 11.0.10(@swc/core@1.14.0(@swc/helpers@0.5.17))(@types/node@22.19.1)
'@nestjs/schematics':
specifier: ^11.0.0
version: 11.0.9(chokidar@4.0.3)(typescript@5.9.3)
@ -609,8 +615,8 @@ importers:
specifier: ^2.0.0
version: 2.0.0
'@types/node':
specifier: ^22.19.0
version: 22.19.0
specifier: ^22.19.1
version: 22.19.1
'@types/nodemailer':
specifier: ^7.0.0
version: 7.0.3
@ -640,7 +646,7 @@ importers:
version: 13.15.4
'@vitest/coverage-v8':
specifier: ^3.0.0
version: 3.2.4(vitest@3.2.4(@types/debug@4.1.12)(@types/node@22.19.0)(happy-dom@20.0.10)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1))
version: 3.2.4(vitest@3.2.4(@types/debug@4.1.12)(@types/node@22.19.1)(happy-dom@20.0.10)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1))
eslint:
specifier: ^9.14.0
version: 9.38.0(jiti@2.6.1)
@ -694,10 +700,10 @@ importers:
version: 1.5.8(@swc/core@1.14.0(@swc/helpers@0.5.17))(rollup@4.52.5)
vite-tsconfig-paths:
specifier: ^5.0.0
version: 5.1.4(typescript@5.9.3)(vite@7.1.12(@types/node@22.19.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1))
version: 5.1.4(typescript@5.9.3)(vite@7.1.12(@types/node@22.19.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1))
vitest:
specifier: ^3.0.0
version: 3.2.4(@types/debug@4.1.12)(@types/node@22.19.0)(happy-dom@20.0.10)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1)
version: 3.2.4(@types/debug@4.1.12)(@types/node@22.19.1)(happy-dom@20.0.10)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1)
web:
dependencies:
@ -4686,8 +4692,8 @@ packages:
'@types/node@20.19.24':
resolution: {integrity: sha512-FE5u0ezmi6y9OZEzlJfg37mqqf6ZDSF2V/NLjUyGrR9uTZ7Sb9F7bLNZ03S4XVUNRWGA7Ck4c1kK+YnuWjl+DA==}
'@types/node@22.19.0':
resolution: {integrity: sha512-xpr/lmLPQEj+TUnHmR+Ab91/glhJvsqcjB+yY0Ix9GO70H6Lb4FHH5GeqdOE5btAx7eIMwuHkp4H2MSkLcqWbA==}
'@types/node@22.19.1':
resolution: {integrity: sha512-LCCV0HdSZZZb34qifBsyWlUmok6W7ouER+oQIGBScS8EsZsQbrtFTUrDX4hOl+CS6p7cnNC4td+qrSVGSCTUfQ==}
'@types/node@24.10.0':
resolution: {integrity: sha512-qzQZRBqkFsYyaSWXuEHc2WR9c0a0CXwiE5FWUvn7ZM+vdy1uZLfCunD38UzhuB7YN/J11ndbDBcTmOdxJo9Q7A==}
@ -11793,11 +11799,11 @@ snapshots:
optionalDependencies:
chokidar: 4.0.3
'@angular-devkit/schematics-cli@19.2.15(@types/node@22.19.0)(chokidar@4.0.3)':
'@angular-devkit/schematics-cli@19.2.15(@types/node@22.19.1)(chokidar@4.0.3)':
dependencies:
'@angular-devkit/core': 19.2.15(chokidar@4.0.3)
'@angular-devkit/schematics': 19.2.15(chokidar@4.0.3)
'@inquirer/prompts': 7.3.2(@types/node@22.19.0)
'@inquirer/prompts': 7.3.2(@types/node@22.19.1)
ansi-colors: 4.1.3
symbol-observable: 4.0.0
yargs-parser: 21.1.1
@ -14447,27 +14453,27 @@ snapshots:
transitivePeerDependencies:
- '@internationalized/date'
'@inquirer/checkbox@4.2.1(@types/node@22.19.0)':
'@inquirer/checkbox@4.2.1(@types/node@22.19.1)':
dependencies:
'@inquirer/core': 10.1.15(@types/node@22.19.0)
'@inquirer/core': 10.1.15(@types/node@22.19.1)
'@inquirer/figures': 1.0.13
'@inquirer/type': 3.0.8(@types/node@22.19.0)
'@inquirer/type': 3.0.8(@types/node@22.19.1)
ansi-escapes: 4.3.2
yoctocolors-cjs: 2.1.2
optionalDependencies:
'@types/node': 22.19.0
'@types/node': 22.19.1
'@inquirer/confirm@5.1.15(@types/node@22.19.0)':
'@inquirer/confirm@5.1.15(@types/node@22.19.1)':
dependencies:
'@inquirer/core': 10.1.15(@types/node@22.19.0)
'@inquirer/type': 3.0.8(@types/node@22.19.0)
'@inquirer/core': 10.1.15(@types/node@22.19.1)
'@inquirer/type': 3.0.8(@types/node@22.19.1)
optionalDependencies:
'@types/node': 22.19.0
'@types/node': 22.19.1
'@inquirer/core@10.1.15(@types/node@22.19.0)':
'@inquirer/core@10.1.15(@types/node@22.19.1)':
dependencies:
'@inquirer/figures': 1.0.13
'@inquirer/type': 3.0.8(@types/node@22.19.0)
'@inquirer/type': 3.0.8(@types/node@22.19.1)
ansi-escapes: 4.3.2
cli-width: 4.1.0
mute-stream: 2.0.0
@ -14475,115 +14481,115 @@ snapshots:
wrap-ansi: 6.2.0
yoctocolors-cjs: 2.1.2
optionalDependencies:
'@types/node': 22.19.0
'@types/node': 22.19.1
'@inquirer/editor@4.2.17(@types/node@22.19.0)':
'@inquirer/editor@4.2.17(@types/node@22.19.1)':
dependencies:
'@inquirer/core': 10.1.15(@types/node@22.19.0)
'@inquirer/external-editor': 1.0.2(@types/node@22.19.0)
'@inquirer/type': 3.0.8(@types/node@22.19.0)
'@inquirer/core': 10.1.15(@types/node@22.19.1)
'@inquirer/external-editor': 1.0.2(@types/node@22.19.1)
'@inquirer/type': 3.0.8(@types/node@22.19.1)
optionalDependencies:
'@types/node': 22.19.0
'@types/node': 22.19.1
'@inquirer/expand@4.0.17(@types/node@22.19.0)':
'@inquirer/expand@4.0.17(@types/node@22.19.1)':
dependencies:
'@inquirer/core': 10.1.15(@types/node@22.19.0)
'@inquirer/type': 3.0.8(@types/node@22.19.0)
'@inquirer/core': 10.1.15(@types/node@22.19.1)
'@inquirer/type': 3.0.8(@types/node@22.19.1)
yoctocolors-cjs: 2.1.2
optionalDependencies:
'@types/node': 22.19.0
'@types/node': 22.19.1
'@inquirer/external-editor@1.0.2(@types/node@22.19.0)':
'@inquirer/external-editor@1.0.2(@types/node@22.19.1)':
dependencies:
chardet: 2.1.0
iconv-lite: 0.7.0
optionalDependencies:
'@types/node': 22.19.0
'@types/node': 22.19.1
'@inquirer/figures@1.0.13': {}
'@inquirer/input@4.2.1(@types/node@22.19.0)':
'@inquirer/input@4.2.1(@types/node@22.19.1)':
dependencies:
'@inquirer/core': 10.1.15(@types/node@22.19.0)
'@inquirer/type': 3.0.8(@types/node@22.19.0)
'@inquirer/core': 10.1.15(@types/node@22.19.1)
'@inquirer/type': 3.0.8(@types/node@22.19.1)
optionalDependencies:
'@types/node': 22.19.0
'@types/node': 22.19.1
'@inquirer/number@3.0.17(@types/node@22.19.0)':
'@inquirer/number@3.0.17(@types/node@22.19.1)':
dependencies:
'@inquirer/core': 10.1.15(@types/node@22.19.0)
'@inquirer/type': 3.0.8(@types/node@22.19.0)
'@inquirer/core': 10.1.15(@types/node@22.19.1)
'@inquirer/type': 3.0.8(@types/node@22.19.1)
optionalDependencies:
'@types/node': 22.19.0
'@types/node': 22.19.1
'@inquirer/password@4.0.17(@types/node@22.19.0)':
'@inquirer/password@4.0.17(@types/node@22.19.1)':
dependencies:
'@inquirer/core': 10.1.15(@types/node@22.19.0)
'@inquirer/type': 3.0.8(@types/node@22.19.0)
'@inquirer/core': 10.1.15(@types/node@22.19.1)
'@inquirer/type': 3.0.8(@types/node@22.19.1)
ansi-escapes: 4.3.2
optionalDependencies:
'@types/node': 22.19.0
'@inquirer/prompts@7.3.2(@types/node@22.19.0)':
dependencies:
'@inquirer/checkbox': 4.2.1(@types/node@22.19.0)
'@inquirer/confirm': 5.1.15(@types/node@22.19.0)
'@inquirer/editor': 4.2.17(@types/node@22.19.0)
'@inquirer/expand': 4.0.17(@types/node@22.19.0)
'@inquirer/input': 4.2.1(@types/node@22.19.0)
'@inquirer/number': 3.0.17(@types/node@22.19.0)
'@inquirer/password': 4.0.17(@types/node@22.19.0)
'@inquirer/rawlist': 4.1.5(@types/node@22.19.0)
'@inquirer/search': 3.1.0(@types/node@22.19.0)
'@inquirer/select': 4.3.1(@types/node@22.19.0)
'@types/node': 22.19.1
'@inquirer/prompts@7.3.2(@types/node@22.19.1)':
dependencies:
'@inquirer/checkbox': 4.2.1(@types/node@22.19.1)
'@inquirer/confirm': 5.1.15(@types/node@22.19.1)
'@inquirer/editor': 4.2.17(@types/node@22.19.1)
'@inquirer/expand': 4.0.17(@types/node@22.19.1)
'@inquirer/input': 4.2.1(@types/node@22.19.1)
'@inquirer/number': 3.0.17(@types/node@22.19.1)
'@inquirer/password': 4.0.17(@types/node@22.19.1)
'@inquirer/rawlist': 4.1.5(@types/node@22.19.1)
'@inquirer/search': 3.1.0(@types/node@22.19.1)
'@inquirer/select': 4.3.1(@types/node@22.19.1)
optionalDependencies:
'@types/node': 22.19.0
'@inquirer/prompts@7.8.0(@types/node@22.19.0)':
dependencies:
'@inquirer/checkbox': 4.2.1(@types/node@22.19.0)
'@inquirer/confirm': 5.1.15(@types/node@22.19.0)
'@inquirer/editor': 4.2.17(@types/node@22.19.0)
'@inquirer/expand': 4.0.17(@types/node@22.19.0)
'@inquirer/input': 4.2.1(@types/node@22.19.0)
'@inquirer/number': 3.0.17(@types/node@22.19.0)
'@inquirer/password': 4.0.17(@types/node@22.19.0)
'@inquirer/rawlist': 4.1.5(@types/node@22.19.0)
'@inquirer/search': 3.1.0(@types/node@22.19.0)
'@inquirer/select': 4.3.1(@types/node@22.19.0)
'@types/node': 22.19.1
'@inquirer/prompts@7.8.0(@types/node@22.19.1)':
dependencies:
'@inquirer/checkbox': 4.2.1(@types/node@22.19.1)
'@inquirer/confirm': 5.1.15(@types/node@22.19.1)
'@inquirer/editor': 4.2.17(@types/node@22.19.1)
'@inquirer/expand': 4.0.17(@types/node@22.19.1)
'@inquirer/input': 4.2.1(@types/node@22.19.1)
'@inquirer/number': 3.0.17(@types/node@22.19.1)
'@inquirer/password': 4.0.17(@types/node@22.19.1)
'@inquirer/rawlist': 4.1.5(@types/node@22.19.1)
'@inquirer/search': 3.1.0(@types/node@22.19.1)
'@inquirer/select': 4.3.1(@types/node@22.19.1)
optionalDependencies:
'@types/node': 22.19.0
'@types/node': 22.19.1
'@inquirer/rawlist@4.1.5(@types/node@22.19.0)':
'@inquirer/rawlist@4.1.5(@types/node@22.19.1)':
dependencies:
'@inquirer/core': 10.1.15(@types/node@22.19.0)
'@inquirer/type': 3.0.8(@types/node@22.19.0)
'@inquirer/core': 10.1.15(@types/node@22.19.1)
'@inquirer/type': 3.0.8(@types/node@22.19.1)
yoctocolors-cjs: 2.1.2
optionalDependencies:
'@types/node': 22.19.0
'@types/node': 22.19.1
'@inquirer/search@3.1.0(@types/node@22.19.0)':
'@inquirer/search@3.1.0(@types/node@22.19.1)':
dependencies:
'@inquirer/core': 10.1.15(@types/node@22.19.0)
'@inquirer/core': 10.1.15(@types/node@22.19.1)
'@inquirer/figures': 1.0.13
'@inquirer/type': 3.0.8(@types/node@22.19.0)
'@inquirer/type': 3.0.8(@types/node@22.19.1)
yoctocolors-cjs: 2.1.2
optionalDependencies:
'@types/node': 22.19.0
'@types/node': 22.19.1
'@inquirer/select@4.3.1(@types/node@22.19.0)':
'@inquirer/select@4.3.1(@types/node@22.19.1)':
dependencies:
'@inquirer/core': 10.1.15(@types/node@22.19.0)
'@inquirer/core': 10.1.15(@types/node@22.19.1)
'@inquirer/figures': 1.0.13
'@inquirer/type': 3.0.8(@types/node@22.19.0)
'@inquirer/type': 3.0.8(@types/node@22.19.1)
ansi-escapes: 4.3.2
yoctocolors-cjs: 2.1.2
optionalDependencies:
'@types/node': 22.19.0
'@types/node': 22.19.1
'@inquirer/type@3.0.8(@types/node@22.19.0)':
'@inquirer/type@3.0.8(@types/node@22.19.1)':
optionalDependencies:
'@types/node': 22.19.0
'@types/node': 22.19.1
'@internationalized/date@3.8.2':
dependencies:
@ -14621,7 +14627,7 @@ snapshots:
'@jest/schemas': 29.6.3
'@types/istanbul-lib-coverage': 2.0.6
'@types/istanbul-reports': 3.0.4
'@types/node': 22.19.0
'@types/node': 22.19.1
'@types/yargs': 17.0.34
chalk: 4.1.2
@ -14889,12 +14895,12 @@ snapshots:
bullmq: 5.62.1
tslib: 2.8.1
'@nestjs/cli@11.0.10(@swc/core@1.14.0(@swc/helpers@0.5.17))(@types/node@22.19.0)':
'@nestjs/cli@11.0.10(@swc/core@1.14.0(@swc/helpers@0.5.17))(@types/node@22.19.1)':
dependencies:
'@angular-devkit/core': 19.2.15(chokidar@4.0.3)
'@angular-devkit/schematics': 19.2.15(chokidar@4.0.3)
'@angular-devkit/schematics-cli': 19.2.15(@types/node@22.19.0)(chokidar@4.0.3)
'@inquirer/prompts': 7.8.0(@types/node@22.19.0)
'@angular-devkit/schematics-cli': 19.2.15(@types/node@22.19.1)(chokidar@4.0.3)
'@inquirer/prompts': 7.8.0(@types/node@22.19.1)
'@nestjs/schematics': 11.0.9(chokidar@4.0.3)(typescript@5.8.3)
ansis: 4.1.0
chokidar: 4.0.3
@ -16294,7 +16300,7 @@ snapshots:
'@types/accepts@1.3.7':
dependencies:
'@types/node': 22.19.0
'@types/node': 22.19.1
'@types/archiver@6.0.4':
dependencies:
@ -16306,16 +16312,16 @@ snapshots:
'@types/bcrypt@6.0.0':
dependencies:
'@types/node': 22.19.0
'@types/node': 22.19.1
'@types/body-parser@1.19.6':
dependencies:
'@types/connect': 3.4.38
'@types/node': 22.19.0
'@types/node': 22.19.1
'@types/bonjour@3.5.13':
dependencies:
'@types/node': 22.19.0
'@types/node': 22.19.1
'@types/braces@3.0.5': {}
@ -16336,21 +16342,21 @@ snapshots:
'@types/cli-progress@3.11.6':
dependencies:
'@types/node': 22.19.0
'@types/node': 22.19.1
'@types/compression@1.8.1':
dependencies:
'@types/express': 5.0.5
'@types/node': 22.19.0
'@types/node': 22.19.1
'@types/connect-history-api-fallback@1.5.4':
dependencies:
'@types/express-serve-static-core': 5.1.0
'@types/node': 22.19.0
'@types/node': 22.19.1
'@types/connect@3.4.38':
dependencies:
'@types/node': 22.19.0
'@types/node': 22.19.1
'@types/content-disposition@0.5.9': {}
@ -16367,11 +16373,11 @@ snapshots:
'@types/connect': 3.4.38
'@types/express': 5.0.5
'@types/keygrip': 1.0.6
'@types/node': 22.19.0
'@types/node': 22.19.1
'@types/cors@2.8.19':
dependencies:
'@types/node': 22.19.0
'@types/node': 22.19.1
'@types/debug@4.1.12':
dependencies:
@ -16381,13 +16387,13 @@ snapshots:
'@types/docker-modem@3.0.6':
dependencies:
'@types/node': 22.19.0
'@types/node': 22.19.1
'@types/ssh2': 1.15.5
'@types/dockerode@3.3.45':
dependencies:
'@types/docker-modem': 3.0.6
'@types/node': 22.19.0
'@types/node': 22.19.1
'@types/ssh2': 1.15.5
'@types/dom-to-image@2.6.7': {}
@ -16410,14 +16416,14 @@ snapshots:
'@types/express-serve-static-core@4.19.7':
dependencies:
'@types/node': 22.19.0
'@types/node': 22.19.1
'@types/qs': 6.14.0
'@types/range-parser': 1.2.7
'@types/send': 1.2.1
'@types/express-serve-static-core@5.1.0':
dependencies:
'@types/node': 22.19.0
'@types/node': 22.19.1
'@types/qs': 6.14.0
'@types/range-parser': 1.2.7
'@types/send': 1.2.1
@ -16443,7 +16449,7 @@ snapshots:
'@types/fluent-ffmpeg@2.1.28':
dependencies:
'@types/node': 22.19.0
'@types/node': 22.19.1
'@types/geojson-vt@3.2.5':
dependencies:
@ -16475,7 +16481,7 @@ snapshots:
'@types/http-proxy@1.17.17':
dependencies:
'@types/node': 22.19.0
'@types/node': 22.19.1
'@types/inquirer@8.2.11':
dependencies:
@ -16499,7 +16505,7 @@ snapshots:
'@types/jsonwebtoken@9.0.10':
dependencies:
'@types/ms': 2.1.0
'@types/node': 22.19.0
'@types/node': 22.19.1
'@types/justified-layout@4.1.4': {}
@ -16518,7 +16524,7 @@ snapshots:
'@types/http-errors': 2.0.5
'@types/keygrip': 1.0.6
'@types/koa-compose': 3.2.8
'@types/node': 22.19.0
'@types/node': 22.19.1
'@types/leaflet@1.9.21':
dependencies:
@ -16548,7 +16554,7 @@ snapshots:
'@types/mock-fs@4.13.4':
dependencies:
'@types/node': 22.19.0
'@types/node': 22.19.1
'@types/ms@2.1.0': {}
@ -16558,7 +16564,7 @@ snapshots:
'@types/node-forge@1.3.14':
dependencies:
'@types/node': 22.19.0
'@types/node': 22.19.1
'@types/node@17.0.45': {}
@ -16570,7 +16576,7 @@ snapshots:
dependencies:
undici-types: 6.21.0
'@types/node@22.19.0':
'@types/node@22.19.1':
dependencies:
undici-types: 6.21.0
@ -16582,7 +16588,7 @@ snapshots:
'@types/nodemailer@7.0.3':
dependencies:
'@aws-sdk/client-sesv2': 3.919.0
'@types/node': 22.19.0
'@types/node': 22.19.1
transitivePeerDependencies:
- aws-crt
@ -16590,7 +16596,7 @@ snapshots:
dependencies:
'@types/keygrip': 1.0.6
'@types/koa': 3.0.0
'@types/node': 22.19.0
'@types/node': 22.19.1
'@types/parse5@5.0.3': {}
@ -16600,13 +16606,13 @@ snapshots:
'@types/pg@8.15.5':
dependencies:
'@types/node': 22.19.0
'@types/node': 22.19.1
pg-protocol: 1.10.3
pg-types: 2.2.0
'@types/pg@8.15.6':
dependencies:
'@types/node': 22.19.0
'@types/node': 22.19.1
pg-protocol: 1.10.3
pg-types: 2.2.0
@ -16614,13 +16620,13 @@ snapshots:
'@types/pngjs@6.0.5':
dependencies:
'@types/node': 22.19.0
'@types/node': 22.19.1
'@types/prismjs@1.26.5': {}
'@types/qrcode@1.5.6':
dependencies:
'@types/node': 22.19.0
'@types/node': 22.19.1
'@types/qs@6.14.0': {}
@ -16649,7 +16655,7 @@ snapshots:
'@types/readdir-glob@1.1.5':
dependencies:
'@types/node': 22.19.0
'@types/node': 22.19.1
'@types/retry@0.12.2': {}
@ -16659,18 +16665,18 @@ snapshots:
'@types/sax@1.2.7':
dependencies:
'@types/node': 22.19.0
'@types/node': 22.19.1
'@types/semver@7.7.1': {}
'@types/send@0.17.6':
dependencies:
'@types/mime': 1.3.5
'@types/node': 22.19.0
'@types/node': 22.19.1
'@types/send@1.2.1':
dependencies:
'@types/node': 22.19.0
'@types/node': 22.19.1
'@types/serve-index@1.9.4':
dependencies:
@ -16679,20 +16685,20 @@ snapshots:
'@types/serve-static@1.15.10':
dependencies:
'@types/http-errors': 2.0.5
'@types/node': 22.19.0
'@types/node': 22.19.1
'@types/send': 0.17.6
'@types/sockjs@0.3.36':
dependencies:
'@types/node': 22.19.0
'@types/node': 22.19.1
'@types/ssh2-streams@0.1.13':
dependencies:
'@types/node': 22.19.0
'@types/node': 22.19.1
'@types/ssh2@0.5.52':
dependencies:
'@types/node': 22.19.0
'@types/node': 22.19.1
'@types/ssh2-streams': 0.1.13
'@types/ssh2@1.15.5':
@ -16703,7 +16709,7 @@ snapshots:
dependencies:
'@types/cookiejar': 2.1.5
'@types/methods': 1.1.4
'@types/node': 22.19.0
'@types/node': 22.19.1
form-data: 4.0.4
'@types/supercluster@7.1.3':
@ -16717,7 +16723,7 @@ snapshots:
'@types/through@0.0.33':
dependencies:
'@types/node': 22.19.0
'@types/node': 22.19.1
'@types/ua-parser-js@0.7.39': {}
@ -16731,7 +16737,7 @@ snapshots:
'@types/ws@8.18.1':
dependencies:
'@types/node': 22.19.0
'@types/node': 22.19.1
'@types/yargs-parser@21.0.3': {}
@ -16836,7 +16842,7 @@ snapshots:
'@vercel/oidc@3.0.3': {}
'@vitest/coverage-v8@3.2.4(vitest@3.2.4(@types/debug@4.1.12)(@types/node@22.19.0)(happy-dom@20.0.10)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1))':
'@vitest/coverage-v8@3.2.4(vitest@3.2.4(@types/debug@4.1.12)(@types/node@22.19.1)(happy-dom@20.0.10)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1))':
dependencies:
'@ampproject/remapping': 2.3.0
'@bcoe/v8-coverage': 1.0.2
@ -16851,7 +16857,7 @@ snapshots:
std-env: 3.10.0
test-exclude: 7.0.1
tinyrainbow: 2.0.0
vitest: 3.2.4(@types/debug@4.1.12)(@types/node@22.19.0)(happy-dom@20.0.10)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1)
vitest: 3.2.4(@types/debug@4.1.12)(@types/node@22.19.1)(happy-dom@20.0.10)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1)
transitivePeerDependencies:
- supports-color
@ -16882,13 +16888,13 @@ snapshots:
chai: 5.2.0
tinyrainbow: 2.0.0
'@vitest/mocker@3.2.4(vite@7.1.12(@types/node@22.19.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1))':
'@vitest/mocker@3.2.4(vite@7.1.12(@types/node@22.19.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1))':
dependencies:
'@vitest/spy': 3.2.4
estree-walker: 3.0.3
magic-string: 0.30.21
optionalDependencies:
vite: 7.1.12(@types/node@22.19.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1)
vite: 7.1.12(@types/node@22.19.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1)
'@vitest/mocker@3.2.4(vite@7.1.12(@types/node@24.10.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1))':
dependencies:
@ -18480,7 +18486,7 @@ snapshots:
engine.io@6.6.4:
dependencies:
'@types/cors': 2.8.19
'@types/node': 22.19.0
'@types/node': 22.19.1
accepts: 1.3.8
base64id: 2.0.0
cookie: 0.7.2
@ -18869,7 +18875,7 @@ snapshots:
eval@0.1.8:
dependencies:
'@types/node': 22.19.0
'@types/node': 22.19.1
require-like: 0.1.2
event-emitter@0.3.5:
@ -19858,9 +19864,9 @@ snapshots:
inline-style-parser@0.2.4: {}
inquirer@8.2.7(@types/node@22.19.0):
inquirer@8.2.7(@types/node@22.19.1):
dependencies:
'@inquirer/external-editor': 1.0.2(@types/node@22.19.0)
'@inquirer/external-editor': 1.0.2(@types/node@22.19.1)
ansi-escapes: 4.3.2
chalk: 4.1.2
cli-cursor: 3.1.0
@ -20074,7 +20080,7 @@ snapshots:
jest-util@29.7.0:
dependencies:
'@jest/types': 29.6.3
'@types/node': 22.19.0
'@types/node': 22.19.1
chalk: 4.1.2
ci-info: 3.9.0
graceful-fs: 4.2.11
@ -20082,13 +20088,13 @@ snapshots:
jest-worker@27.5.1:
dependencies:
'@types/node': 22.19.0
'@types/node': 22.19.1
merge-stream: 2.0.0
supports-color: 8.1.1
jest-worker@29.7.0:
dependencies:
'@types/node': 22.19.0
'@types/node': 22.19.1
jest-util: 29.7.0
merge-stream: 2.0.0
supports-color: 8.1.1
@ -21346,7 +21352,7 @@ snapshots:
neo-async@2.6.2: {}
nest-commander@3.20.1(@nestjs/common@11.1.8(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.8)(@types/inquirer@8.2.11)(@types/node@22.19.0)(typescript@5.9.3):
nest-commander@3.20.1(@nestjs/common@11.1.8(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.8)(@types/inquirer@8.2.11)(@types/node@22.19.1)(typescript@5.9.3):
dependencies:
'@fig/complete-commander': 3.2.0(commander@11.1.0)
'@golevelup/nestjs-discovery': 5.0.0(@nestjs/common@11.1.8(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.8)
@ -21355,7 +21361,7 @@ snapshots:
'@types/inquirer': 8.2.11
commander: 11.1.0
cosmiconfig: 8.3.6(typescript@5.9.3)
inquirer: 8.2.7(@types/node@22.19.0)
inquirer: 8.2.7(@types/node@22.19.1)
transitivePeerDependencies:
- '@types/node'
- typescript
@ -22453,7 +22459,7 @@ snapshots:
'@protobufjs/path': 1.1.2
'@protobufjs/pool': 1.1.0
'@protobufjs/utf8': 1.1.0
'@types/node': 22.19.0
'@types/node': 22.19.1
long: 5.3.2
protocol-buffers-schema@3.6.0: {}
@ -24350,13 +24356,13 @@ snapshots:
- rollup
- supports-color
vite-node@3.2.4(@types/node@22.19.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1):
vite-node@3.2.4(@types/node@22.19.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1):
dependencies:
cac: 6.7.14
debug: 4.4.3
es-module-lexer: 1.7.0
pathe: 2.0.3
vite: 7.1.12(@types/node@22.19.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1)
vite: 7.1.12(@types/node@22.19.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1)
transitivePeerDependencies:
- '@types/node'
- jiti
@ -24392,18 +24398,18 @@ snapshots:
- tsx
- yaml
vite-tsconfig-paths@5.1.4(typescript@5.9.3)(vite@7.1.12(@types/node@22.19.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1)):
vite-tsconfig-paths@5.1.4(typescript@5.9.3)(vite@7.1.12(@types/node@22.19.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1)):
dependencies:
debug: 4.4.3
globrex: 0.1.2
tsconfck: 3.1.6(typescript@5.9.3)
optionalDependencies:
vite: 7.1.12(@types/node@22.19.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1)
vite: 7.1.12(@types/node@22.19.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1)
transitivePeerDependencies:
- supports-color
- typescript
vite@7.1.12(@types/node@22.19.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1):
vite@7.1.12(@types/node@22.19.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1):
dependencies:
esbuild: 0.25.12
fdir: 6.5.0(picomatch@4.0.3)
@ -24412,7 +24418,7 @@ snapshots:
rollup: 4.52.5
tinyglobby: 0.2.15
optionalDependencies:
'@types/node': 22.19.0
'@types/node': 22.19.1
fsevents: 2.3.3
jiti: 2.6.1
lightningcss: 1.30.2
@ -24439,15 +24445,15 @@ snapshots:
optionalDependencies:
vite: 7.1.12(@types/node@24.10.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1)
vitest-fetch-mock@0.4.5(vitest@3.2.4(@types/debug@4.1.12)(@types/node@22.19.0)(happy-dom@20.0.10)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1)):
vitest-fetch-mock@0.4.5(vitest@3.2.4(@types/debug@4.1.12)(@types/node@22.19.1)(happy-dom@20.0.10)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1)):
dependencies:
vitest: 3.2.4(@types/debug@4.1.12)(@types/node@22.19.0)(happy-dom@20.0.10)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1)
vitest: 3.2.4(@types/debug@4.1.12)(@types/node@22.19.1)(happy-dom@20.0.10)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1)
vitest@3.2.4(@types/debug@4.1.12)(@types/node@22.19.0)(happy-dom@20.0.10)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2(encoding@0.1.13)))(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1):
vitest@3.2.4(@types/debug@4.1.12)(@types/node@22.19.1)(happy-dom@20.0.10)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2(encoding@0.1.13)))(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1):
dependencies:
'@types/chai': 5.2.2
'@vitest/expect': 3.2.4
'@vitest/mocker': 3.2.4(vite@7.1.12(@types/node@22.19.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1))
'@vitest/mocker': 3.2.4(vite@7.1.12(@types/node@22.19.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1))
'@vitest/pretty-format': 3.2.4
'@vitest/runner': 3.2.4
'@vitest/snapshot': 3.2.4
@ -24465,12 +24471,12 @@ snapshots:
tinyglobby: 0.2.15
tinypool: 1.1.1
tinyrainbow: 2.0.0
vite: 7.1.12(@types/node@22.19.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1)
vite-node: 3.2.4(@types/node@22.19.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1)
vite: 7.1.12(@types/node@22.19.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1)
vite-node: 3.2.4(@types/node@22.19.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1)
why-is-node-running: 2.3.0
optionalDependencies:
'@types/debug': 4.1.12
'@types/node': 22.19.0
'@types/node': 22.19.1
happy-dom: 20.0.10
jsdom: 26.1.0(canvas@2.11.2(encoding@0.1.13))
transitivePeerDependencies:
@ -24487,11 +24493,11 @@ snapshots:
- tsx
- yaml
vitest@3.2.4(@types/debug@4.1.12)(@types/node@22.19.0)(happy-dom@20.0.10)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1):
vitest@3.2.4(@types/debug@4.1.12)(@types/node@22.19.1)(happy-dom@20.0.10)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1):
dependencies:
'@types/chai': 5.2.2
'@vitest/expect': 3.2.4
'@vitest/mocker': 3.2.4(vite@7.1.12(@types/node@22.19.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1))
'@vitest/mocker': 3.2.4(vite@7.1.12(@types/node@22.19.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1))
'@vitest/pretty-format': 3.2.4
'@vitest/runner': 3.2.4
'@vitest/snapshot': 3.2.4
@ -24509,12 +24515,12 @@ snapshots:
tinyglobby: 0.2.15
tinypool: 1.1.1
tinyrainbow: 2.0.0
vite: 7.1.12(@types/node@22.19.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1)
vite-node: 3.2.4(@types/node@22.19.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1)
vite: 7.1.12(@types/node@22.19.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1)
vite-node: 3.2.4(@types/node@22.19.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1)
why-is-node-running: 2.3.0
optionalDependencies:
'@types/debug': 4.1.12
'@types/node': 22.19.0
'@types/node': 22.19.1
happy-dom: 20.0.10
jsdom: 26.1.0(canvas@2.11.2)
transitivePeerDependencies:

@ -50,12 +50,14 @@ RUN --mount=type=cache,id=pnpm-cli,target=/buildcache/pnpm-store \
FROM builder AS plugins
COPY --from=ghcr.io/jdx/mise:2025.11.3 /usr/local/bin/mise /usr/local/bin/mise
COPY --from=ghcr.io/jdx/mise:2025.11.3@sha256:ac26f5978c0e2783f3e68e58ce75eddb83e41b89bf8747c503bac2aa9baf22c5 /usr/local/bin/mise /usr/local/bin/mise
WORKDIR /usr/src/app
COPY ./plugins/mise.toml ./plugins/
ENV MISE_TRUSTED_CONFIG_PATHS=/usr/src/app/plugins/mise.toml
RUN mise install --cd plugins
ENV MISE_DATA_DIR=/buildcache/mise
RUN --mount=type=cache,id=mise-tools,target=/buildcache/mise \
mise install --cd plugins
COPY ./plugins ./plugins/
# Build plugins
@ -64,6 +66,7 @@ RUN --mount=type=cache,id=pnpm-plugins,target=/buildcache/pnpm-store \
--mount=type=bind,source=.pnpmfile.cjs,target=.pnpmfile.cjs \
--mount=type=bind,source=pnpm-lock.yaml,target=pnpm-lock.yaml \
--mount=type=bind,source=pnpm-workspace.yaml,target=pnpm-workspace.yaml \
--mount=type=cache,id=mise-tools,target=/buildcache/mise \
cd plugins && mise run build
FROM ghcr.io/immich-app/base-server-prod:202511041104@sha256:57c0379977fd5521d83cdf661aecd1497c83a9a661ebafe0a5243a09fc1064cb

@ -1,6 +1,6 @@
#!/usr/bin/env bash
if [ "$IMMICH_ENV" != "development" ]; then
if [[ "$IMMICH_ENV" == "production" ]]; then
echo "This command can only be run in development environments"
exit 1
fi

@ -134,7 +134,7 @@
"@types/luxon": "^3.6.2",
"@types/mock-fs": "^4.13.1",
"@types/multer": "^2.0.0",
"@types/node": "^22.19.0",
"@types/node": "^22.19.1",
"@types/nodemailer": "^7.0.0",
"@types/picomatch": "^4.0.0",
"@types/pngjs": "^6.0.5",

@ -121,6 +121,23 @@ export class MediaRepository {
}
}
async copyTagGroup(tagGroup: string, source: string, target: string): Promise<boolean> {
try {
await exiftool.write(
target,
{},
{
ignoreMinorErrors: true,
writeArgs: ['-TagsFromFile', source, `-${tagGroup}:all>${tagGroup}:all`, '-overwrite_original'],
},
);
return true;
} catch (error: any) {
this.logger.warn(`Could not copy tag data to image: ${error.message}`);
return false;
}
}
decodeImage(input: string | Buffer, options: DecodeToBufferOptions) {
return this.getImageDecodingPipeline(input, options).raw().toBuffer({ resolveWithObject: true });
}

@ -865,6 +865,7 @@ describe(MediaService.name, () => {
mocks.systemMetadata.get.mockResolvedValue({ image: { fullsize: { enabled: false } } });
mocks.media.extract.mockResolvedValue({ buffer: extractedBuffer, format: RawExtractedFormat.Jpeg });
mocks.media.getImageDimensions.mockResolvedValue({ width: 3840, height: 2160 });
mocks.media.copyTagGroup.mockResolvedValue(true);
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(assetStub.panoramaTif);
@ -890,6 +891,13 @@ describe(MediaService.name, () => {
},
expect.any(String),
);
expect(mocks.media.copyTagGroup).toHaveBeenCalledTimes(2);
expect(mocks.media.copyTagGroup).toHaveBeenCalledWith(
'XMP-GPano',
assetStub.panoramaTif.originalPath,
expect.any(String),
);
});
it('should respect encoding options when generating full-size preview', async () => {

@ -316,6 +316,16 @@ export class MediaService extends BaseService {
const outputs = await Promise.all(promises);
if (asset.exifInfo.projectionType === 'EQUIRECTANGULAR') {
const promises = [
this.mediaRepository.copyTagGroup('XMP-GPano', asset.originalPath, previewPath),
fullsizePath
? this.mediaRepository.copyTagGroup('XMP-GPano', asset.originalPath, fullsizePath)
: Promise.resolve(),
];
await Promise.all(promises);
}
return { previewPath, thumbnailPath, fullsizePath, thumbhash: outputs[0] as Buffer };
}

@ -6,6 +6,7 @@ export const newMediaRepositoryMock = (): Mocked<RepositoryInterface<MediaReposi
return {
generateThumbnail: vitest.fn().mockImplementation(() => Promise.resolve()),
writeExif: vitest.fn().mockImplementation(() => Promise.resolve()),
copyTagGroup: vitest.fn().mockImplementation(() => Promise.resolve()),
generateThumbhash: vitest.fn().mockResolvedValue(Buffer.from('')),
decodeImage: vitest.fn().mockResolvedValue({ data: Buffer.from(''), info: {} }),
extract: vitest.fn().mockResolvedValue(null),

@ -0,0 +1 @@
<svg width="900" height="600" viewBox="0 0 900 600" fill="none" xmlns="http://www.w3.org/2000/svg"><path fill="transparent" d="M0 0h900v600H0z"/><path d="M718.697 359.789c2.347 69.208-149.828 213.346-331.607 165.169-84.544-22.409-76.298-62.83-139.698-114.488-37.789-30.789-92.638-53.5-106.885-99.138-12.309-39.393-3.044-82.222 20.77-110.466 53.556-63.52 159.542-108.522 260.374-12.465 100.832 96.056 290.968-7.105 297.046 171.388z" fill="url(#a)"/><path fill-rule="evenodd" clip-rule="evenodd" d="M629.602 207.307v-51.154c0-28.251-22.902-51.153-51.154-51.153H322.681c-28.251 0-51.153 22.902-51.153 51.153v127.884" fill="#fff"/><path d="M629.602 207.307v-51.154c0-28.251-22.902-51.153-51.154-51.153H322.681c-28.251 0-51.153 22.902-51.153 51.153v127.884" stroke="#E1E4E5" stroke-width="4.13"/><path fill-rule="evenodd" clip-rule="evenodd" d="M271.528 216.252h165.353a25.578 25.578 0 0 0 21.28-11.382l35.884-53.941a25.575 25.575 0 0 1 21.357-11.407h114.2c28.251 0 51.154 22.902 51.154 51.153v255.767c0 28.252-22.903 51.154-51.154 51.154H271.528c-28.251 0-51.154-22.902-51.154-51.154V267.405c0-28.251 22.903-51.153 51.154-51.153z" fill="#fff" stroke="#E1E4E5" stroke-width="4.13"/><path fill-rule="evenodd" clip-rule="evenodd" d="M320.022 432.016v3.968h3.964A3.028 3.028 0 0 1 327 439a3.028 3.028 0 0 1-3.014 3.016h-3.964v3.968a3.028 3.028 0 0 1-3.014 3.016 3.029 3.029 0 0 1-3.014-3.016v-3.951h-3.98a3.029 3.029 0 0 1-3.014-3.017 3.029 3.029 0 0 1 3.014-3.016h3.964v-3.984a3.031 3.031 0 0 1 3.03-3.016 3.028 3.028 0 0 1 3.014 3.016zm-33.14-27.793v5.554h5.748c2.399 0 4.37 1.905 4.37 4.223 0 2.318-1.971 4.223-4.37 4.223h-5.748v5.554c0 2.318-1.971 4.223-4.37 4.223s-4.37-1.905-4.37-4.223v-5.531h-5.772c-2.399 0-4.37-1.905-4.37-4.223 0-2.318 1.971-4.223 4.37-4.223h5.748v-5.577c0-2.318 1.971-4.223 4.394-4.223 2.399 0 4.37 1.905 4.37 4.223z" fill="#E1E4E5"/><circle cx="451.101" cy="358.294" r="98.899" fill="#aaa"/><rect x="444.142" y="322.427" width="13.918" height="71.734" rx="6.959" fill="#fff"/><rect x="486.968" y="351.335" width="13.918" height="71.734" rx="6.959" transform="rotate(90 486.968 351.335)" fill="#fff"/><ellipse rx="13.917" ry="13.254" transform="matrix(-1 0 0 1 718.227 479.149)" fill="#E1E4E5"/><circle r="4.639" transform="matrix(-1 0 0 1 292.465 519.783)" fill="#E1E4E5"/><circle r="6.627" transform="matrix(-1 0 0 1 566.399 205.929)" fill="#E1E4E5"/><circle r="6.476" transform="scale(1 -1) rotate(-75 -180.786 -314.12)" fill="#E1E4E5"/><circle r="8.615" transform="matrix(-1 0 0 1 217.158 114.719)" fill="#E1E4E5"/><ellipse rx="6.627" ry="5.302" transform="matrix(-1 0 0 1 704.513 233.511)" fill="#E1E4E5"/><path d="M186.177 456.259h.174c1.026 14.545 11.844 14.769 11.844 14.769s-11.929.233-11.929 17.04c0-16.807-11.929-17.04-11.929-17.04s10.814-.224 11.84-14.769zm574.334-165.951h.18c1.067 15.36 12.309 15.596 12.309 15.596s-12.397.246-12.397 17.994c0-17.748-12.396-17.994-12.396-17.994s11.237-.236 12.304-15.596z" fill="#E1E4E5"/><defs><linearGradient id="a" x1="530.485" y1="779.032" x2="277.414" y2="-357.319" gradientUnits="userSpaceOnUse"><stop stop-color="#fff"/><stop offset="1" stop-color="#EEE"/></linearGradient></defs></svg>

After

Width:  |  Height:  |  Size: 3.1 KiB

@ -1,7 +1,7 @@
import { getAnimateMock } from '$lib/__mocks__/animate.mock';
import PhotoViewer from '$lib/components/asset-viewer/photo-viewer.svelte';
import * as utils from '$lib/utils';
import { AssetMediaSize } from '@immich/sdk';
import { AssetMediaSize, AssetTypeEnum } from '@immich/sdk';
import { assetFactory } from '@test-data/factories/asset-factory';
import { sharedLinkFactory } from '@test-data/factories/shared-link-factory';
import { render } from '@testing-library/svelte';
@ -65,7 +65,27 @@ describe('PhotoViewer component', () => {
});
it('loads the thumbnail', () => {
const asset = assetFactory.build({ originalPath: 'image.jpg', originalMimeType: 'image/jpeg' });
const asset = assetFactory.build({
originalPath: 'image.jpg',
originalMimeType: 'image/jpeg',
type: AssetTypeEnum.Image,
});
render(PhotoViewer, { asset });
expect(getAssetThumbnailUrlSpy).toBeCalledWith({
id: asset.id,
size: AssetMediaSize.Preview,
cacheKey: asset.thumbhash,
});
expect(getAssetOriginalUrlSpy).not.toBeCalled();
});
it('loads the thumbnail image for static gifs', () => {
const asset = assetFactory.build({
originalPath: 'image.gif',
originalMimeType: 'image/gif',
type: AssetTypeEnum.Image,
});
render(PhotoViewer, { asset });
expect(getAssetThumbnailUrlSpy).toBeCalledWith({
@ -76,16 +96,73 @@ describe('PhotoViewer component', () => {
expect(getAssetOriginalUrlSpy).not.toBeCalled();
});
it('loads the original image for gifs', () => {
const asset = assetFactory.build({ originalPath: 'image.gif', originalMimeType: 'image/gif' });
it('loads the thumbnail image for static webp images', () => {
const asset = assetFactory.build({
originalPath: 'image.webp',
originalMimeType: 'image/webp',
type: AssetTypeEnum.Image,
});
render(PhotoViewer, { asset });
expect(getAssetThumbnailUrlSpy).toBeCalledWith({
id: asset.id,
size: AssetMediaSize.Preview,
cacheKey: asset.thumbhash,
});
expect(getAssetOriginalUrlSpy).not.toBeCalled();
});
it('loads the original image for animated gifs', () => {
const asset = assetFactory.build({
originalPath: 'image.gif',
originalMimeType: 'image/gif',
type: AssetTypeEnum.Image,
duration: '2.0',
});
render(PhotoViewer, { asset });
expect(getAssetThumbnailUrlSpy).not.toBeCalled();
expect(getAssetOriginalUrlSpy).toBeCalledWith({ id: asset.id, cacheKey: asset.thumbhash });
});
it('loads original for shared link when download permission is true and showMetadata permission is true', () => {
const asset = assetFactory.build({ originalPath: 'image.gif', originalMimeType: 'image/gif' });
it('loads the original image for animated webp images', () => {
const asset = assetFactory.build({
originalPath: 'image.webp',
originalMimeType: 'image/webp',
type: AssetTypeEnum.Image,
duration: '2.0',
});
render(PhotoViewer, { asset });
expect(getAssetThumbnailUrlSpy).not.toBeCalled();
expect(getAssetOriginalUrlSpy).toBeCalledWith({ id: asset.id, cacheKey: asset.thumbhash });
});
it('not loads original static image in shared link even when download permission is true and showMetadata permission is true', () => {
const asset = assetFactory.build({
originalPath: 'image.gif',
originalMimeType: 'image/gif',
type: AssetTypeEnum.Image,
});
const sharedLink = sharedLinkFactory.build({ allowDownload: true, showMetadata: true, assets: [asset] });
render(PhotoViewer, { asset, sharedLink });
expect(getAssetThumbnailUrlSpy).toBeCalledWith({
id: asset.id,
size: AssetMediaSize.Preview,
cacheKey: asset.thumbhash,
});
expect(getAssetOriginalUrlSpy).not.toBeCalled();
});
it('loads original animated image in shared link when download permission is true and showMetadata permission is true', () => {
const asset = assetFactory.build({
originalPath: 'image.gif',
originalMimeType: 'image/gif',
type: AssetTypeEnum.Image,
duration: '2.0',
});
const sharedLink = sharedLinkFactory.build({ allowDownload: true, showMetadata: true, assets: [asset] });
render(PhotoViewer, { asset, sharedLink });
@ -93,8 +170,13 @@ describe('PhotoViewer component', () => {
expect(getAssetOriginalUrlSpy).toBeCalledWith({ id: asset.id, cacheKey: asset.thumbhash });
});
it('not loads original image when shared link download permission is false', () => {
const asset = assetFactory.build({ originalPath: 'image.gif', originalMimeType: 'image/gif' });
it('not loads original animated image when shared link download permission is false', () => {
const asset = assetFactory.build({
originalPath: 'image.gif',
originalMimeType: 'image/gif',
type: AssetTypeEnum.Image,
duration: '2.0',
});
const sharedLink = sharedLinkFactory.build({ allowDownload: false, assets: [asset] });
render(PhotoViewer, { asset, sharedLink });
@ -107,8 +189,13 @@ describe('PhotoViewer component', () => {
expect(getAssetOriginalUrlSpy).not.toBeCalled();
});
it('not loads original image when shared link showMetadata permission is false', () => {
const asset = assetFactory.build({ originalPath: 'image.gif', originalMimeType: 'image/gif' });
it('not loads original animated image when shared link showMetadata permission is false', () => {
const asset = assetFactory.build({
originalPath: 'image.gif',
originalMimeType: 'image/gif',
type: AssetTypeEnum.Image,
duration: '2.0',
});
const sharedLink = sharedLinkFactory.build({ showMetadata: false, assets: [asset] });
render(PhotoViewer, { asset, sharedLink });

@ -19,7 +19,7 @@
import { cancelImageUrl } from '$lib/utils/sw-messaging';
import { getAltText } from '$lib/utils/thumbnail-util';
import { toTimelineAsset } from '$lib/utils/timeline-util';
import { AssetMediaSize, type AssetResponseDto, type SharedLinkResponseDto } from '@immich/sdk';
import { AssetMediaSize, AssetTypeEnum, type AssetResponseDto, type SharedLinkResponseDto } from '@immich/sdk';
import { LoadingSpinner, toastManager } from '@immich/ui';
import { onDestroy, onMount } from 'svelte';
import { useSwipe, type SwipeCustomEvent } from 'svelte-gestures';
@ -139,7 +139,10 @@
};
// when true, will force loading of the original image
let forceUseOriginal: boolean = $derived(asset.originalMimeType === 'image/gif' || $photoZoomState.currentZoom > 1);
let forceUseOriginal: boolean = $derived(
(asset.type === AssetTypeEnum.Image && asset.duration && !asset.duration.includes('0:00:00.000')) ||
$photoZoomState.currentZoom > 1,
);
const targetImageSize = $derived.by(() => {
if ($alwaysLoadOriginalFile || forceUseOriginal || originalImageLoaded) {

@ -282,7 +282,7 @@
</div>
{/if}
{#if asset.isImage && asset.duration}
{#if asset.isImage && asset.duration && !asset.duration.includes('0:00:00.000')}
<div class="absolute end-0 top-0 flex place-items-center gap-1 text-xs font-medium text-white">
<span class="pe-2 pt-2">
<Icon icon={mdiFileGifBox} size="24" />
@ -351,7 +351,7 @@
playbackOnIconHover={!$playVideoThumbnailOnHover}
/>
</div>
{:else if asset.isImage && asset.duration && mouseOver}
{:else if asset.isImage && asset.duration && !asset.duration.includes('0:00:00.000') && mouseOver}
<!-- GIF -->
<div class="absolute top-0 h-full w-full pointer-events-none">
<div class="absolute h-full w-full bg-linear-to-b from-black/25 via-[transparent_25%]"></div>

@ -1,207 +0,0 @@
<script lang="ts">
import LibraryImportPathModal from '$lib/modals/LibraryImportPathModal.svelte';
import { handleError } from '$lib/utils/handle-error';
import type { ValidateLibraryImportPathResponseDto } from '@immich/sdk';
import { validate, type LibraryResponseDto } from '@immich/sdk';
import { Button, Icon, IconButton, modalManager, toastManager } from '@immich/ui';
import { mdiAlertOutline, mdiCheckCircleOutline, mdiPencilOutline, mdiRefresh } from '@mdi/js';
import { onMount } from 'svelte';
import { t } from 'svelte-i18n';
interface Props {
library: LibraryResponseDto;
onCancel: () => void;
onSubmit: (library: LibraryResponseDto) => void;
}
let { library = $bindable(), onCancel, onSubmit }: Props = $props();
let validatedPaths: ValidateLibraryImportPathResponseDto[] = $state([]);
let importPaths = $derived(validatedPaths.map((validatedPath) => validatedPath.importPath));
onMount(async () => {
if (library.importPaths) {
await handleValidation();
} else {
library.importPaths = [];
}
});
const handleValidation = async () => {
if (library.importPaths) {
const validation = await validate({
id: library.id,
validateLibraryDto: { importPaths: library.importPaths },
});
validatedPaths = validation.importPaths ?? [];
}
};
const revalidate = async (notifyIfSuccessful = true) => {
await handleValidation();
let failedPaths = 0;
for (const validatedPath of validatedPaths) {
if (!validatedPath.isValid) {
failedPaths++;
}
}
if (failedPaths === 0) {
if (notifyIfSuccessful) {
toastManager.success($t('admin.paths_validated_successfully'));
}
} else {
toastManager.warning($t('errors.paths_validation_failed', { values: { paths: failedPaths } }));
}
};
const handleAddImportPath = async (importPathToAdd: string | null) => {
if (!importPathToAdd) {
return;
}
if (!library.importPaths) {
library.importPaths = [];
}
try {
// Check so that import path isn't duplicated
if (!library.importPaths.includes(importPathToAdd)) {
library.importPaths.push(importPathToAdd);
await revalidate(false);
}
} catch (error) {
handleError(error, $t('errors.unable_to_add_import_path'));
}
};
const handleEditImportPath = async (editedImportPath: string | null, pathIndexToEdit: number) => {
if (editedImportPath === null) {
return;
}
if (!library.importPaths) {
library.importPaths = [];
}
try {
// Check so that import path isn't duplicated
if (!library.importPaths.includes(editedImportPath)) {
// Update import path
library.importPaths[pathIndexToEdit] = editedImportPath;
await revalidate(false);
}
} catch (error) {
handleError(error, $t('errors.unable_to_edit_import_path'));
}
};
const handleDeleteImportPath = async (pathIndexToDelete?: number) => {
if (pathIndexToDelete === undefined) {
return;
}
try {
if (!library.importPaths) {
library.importPaths = [];
}
const pathToDelete = library.importPaths[pathIndexToDelete];
library.importPaths = library.importPaths.filter((path) => path != pathToDelete);
await handleValidation();
} catch (error) {
handleError(error, $t('errors.unable_to_delete_import_path'));
}
};
const onEditImportPath = async (pathIndexToEdit?: number) => {
const result = await modalManager.show(LibraryImportPathModal, {
title: pathIndexToEdit === undefined ? $t('add_import_path') : $t('edit_import_path'),
submitText: pathIndexToEdit === undefined ? $t('add') : $t('save'),
isEditing: pathIndexToEdit !== undefined,
importPath: pathIndexToEdit === undefined ? null : library.importPaths[pathIndexToEdit],
importPaths: library.importPaths,
});
if (!result) {
return;
}
switch (result.action) {
case 'submit': {
// eslint-disable-next-line unicorn/prefer-ternary
if (pathIndexToEdit === undefined) {
await handleAddImportPath(result.importPath);
} else {
await handleEditImportPath(result.importPath, pathIndexToEdit);
}
break;
}
case 'delete': {
await handleDeleteImportPath(pathIndexToEdit);
break;
}
}
};
const onsubmit = (event: Event) => {
event.preventDefault();
onSubmit({ ...library });
};
</script>
<form {onsubmit} autocomplete="off" class="m-4 flex flex-col gap-4">
<table class="text-start">
<tbody class="block w-full overflow-y-auto rounded-md border dark:border-immich-dark-gray">
{#each validatedPaths as validatedPath, listIndex (validatedPath.importPath)}
<tr
class="flex h-20 w-full place-items-center text-center dark:text-immich-dark-fg even:bg-subtle/20 odd:bg-subtle/80"
>
<td class="w-1/8 text-ellipsis ps-8 text-sm">
{#if validatedPath.isValid}
<Icon icon={mdiCheckCircleOutline} size="24" title={validatedPath.message} class="text-success" />
{:else}
<Icon icon={mdiAlertOutline} size="24" title={validatedPath.message} class="text-warning" />
{/if}
</td>
<td class="w-4/5 text-ellipsis px-4 text-sm">{validatedPath.importPath}</td>
<td class="w-1/5 text-ellipsis flex justify-center">
<IconButton
shape="round"
color="primary"
icon={mdiPencilOutline}
aria-label={$t('edit_import_path')}
onclick={() => onEditImportPath(listIndex)}
size="small"
/>
</td>
</tr>
{/each}
<tr
class="flex h-20 w-full place-items-center text-center dark:text-immich-dark-fg even:bg-subtle/20 odd:bg-subtle/80"
>
<td class="w-4/5 text-ellipsis px-4 text-sm">
{#if importPaths.length === 0}
{$t('admin.no_paths_added')}
{/if}</td
>
<td class="w-1/5 text-ellipsis px-4 text-sm">
<Button shape="round" size="small" onclick={() => onEditImportPath()}>{$t('add_path')}</Button>
</td>
</tr>
</tbody>
</table>
<div class="flex justify-between w-full">
<div class="justify-end gap-2">
<Button shape="round" leadingIcon={mdiRefresh} size="small" color="secondary" onclick={() => revalidate()}
>{$t('validate')}</Button
>
</div>
<div class="flex justify-end gap-2">
<Button shape="round" size="small" color="secondary" onclick={onCancel}>{$t('cancel')}</Button>
<Button shape="round" size="small" type="submit">{$t('save')}</Button>
</div>
</div>
</form>

@ -1,151 +0,0 @@
<script lang="ts">
import LibraryExclusionPatternModal from '$lib/modals/LibraryExclusionPatternModal.svelte';
import { handleError } from '$lib/utils/handle-error';
import { type LibraryResponseDto } from '@immich/sdk';
import { Button, IconButton, modalManager } from '@immich/ui';
import { mdiPencilOutline } from '@mdi/js';
import { onMount } from 'svelte';
import { t } from 'svelte-i18n';
interface Props {
library: Partial<LibraryResponseDto>;
onCancel: () => void;
onSubmit: (library: Partial<LibraryResponseDto>) => void;
}
let { library = $bindable(), onCancel, onSubmit }: Props = $props();
let exclusionPatterns: string[] = $state([]);
onMount(() => {
if (library.exclusionPatterns) {
exclusionPatterns = library.exclusionPatterns;
} else {
library.exclusionPatterns = [];
}
});
const handleAddExclusionPattern = (exclusionPatternToAdd: string) => {
if (!library.exclusionPatterns) {
library.exclusionPatterns = [];
}
try {
// Check so that exclusion pattern isn't duplicated
if (!library.exclusionPatterns.includes(exclusionPatternToAdd)) {
library.exclusionPatterns.push(exclusionPatternToAdd);
exclusionPatterns = library.exclusionPatterns;
}
} catch (error) {
handleError(error, $t('errors.unable_to_add_exclusion_pattern'));
}
};
const handleEditExclusionPattern = (editedExclusionPattern: string, patternIndex: number) => {
if (!library.exclusionPatterns) {
library.exclusionPatterns = [];
}
try {
library.exclusionPatterns[patternIndex] = editedExclusionPattern;
exclusionPatterns = library.exclusionPatterns;
} catch (error) {
handleError(error, $t('errors.unable_to_edit_exclusion_pattern'));
}
};
const handleDeleteExclusionPattern = (patternIndexToDelete?: number) => {
if (patternIndexToDelete === undefined) {
return;
}
try {
if (!library.exclusionPatterns) {
library.exclusionPatterns = [];
}
const patternToDelete = library.exclusionPatterns[patternIndexToDelete];
library.exclusionPatterns = library.exclusionPatterns.filter((path) => path != patternToDelete);
exclusionPatterns = library.exclusionPatterns;
} catch (error) {
handleError(error, $t('errors.unable_to_delete_exclusion_pattern'));
}
};
const onEditExclusionPattern = async (patternIndexToEdit?: number) => {
const result = await modalManager.show(LibraryExclusionPatternModal, {
submitText: patternIndexToEdit === undefined ? $t('add') : $t('save'),
isEditing: patternIndexToEdit !== undefined,
exclusionPattern: patternIndexToEdit === undefined ? '' : exclusionPatterns[patternIndexToEdit],
exclusionPatterns,
});
if (!result) {
return;
}
switch (result.action) {
case 'submit': {
if (patternIndexToEdit === undefined) {
handleAddExclusionPattern(result.exclusionPattern);
} else {
handleEditExclusionPattern(result.exclusionPattern, patternIndexToEdit);
}
break;
}
case 'delete': {
handleDeleteExclusionPattern(patternIndexToEdit);
break;
}
}
};
const onsubmit = (event: Event) => {
event.preventDefault();
onSubmit(library);
};
</script>
<form {onsubmit} autocomplete="off" class="m-4 flex flex-col gap-4">
<table class="w-full text-start">
<tbody class="block w-full overflow-y-auto rounded-md border dark:border-immich-dark-gray">
{#each exclusionPatterns as exclusionPattern, listIndex (exclusionPattern)}
<tr
class="flex h-20 w-full place-items-center text-center dark:text-immich-dark-fg even:bg-subtle/20 odd:bg-subtle/80"
>
<td class="w-3/4 text-ellipsis px-4 text-sm">{exclusionPattern}</td>
<td class="w-1/4 text-ellipsis flex justify-center">
<IconButton
shape="round"
color="primary"
icon={mdiPencilOutline}
title={$t('edit_exclusion_pattern')}
onclick={() => onEditExclusionPattern(listIndex)}
aria-label={$t('edit_exclusion_pattern')}
size="small"
/>
</td>
</tr>
{/each}
<tr
class="flex h-20 w-full place-items-center text-center dark:text-immich-dark-fg even:bg-subtle/20 odd:bg-subtle/80"
>
<td class="w-3/4 text-ellipsis px-4 text-sm">
{#if exclusionPatterns.length === 0}
{$t('admin.no_pattern_added')}
{/if}
</td>
<td class="w-1/4 text-ellipsis px-4 text-sm flex justify-center">
<Button size="small" shape="round" onclick={() => onEditExclusionPattern()}>
{$t('add_exclusion_pattern')}
</Button>
</td>
</tr>
</tbody>
</table>
<div class="flex w-full justify-end gap-2">
<Button size="small" shape="round" color="secondary" onclick={onCancel}>{$t('cancel')}</Button>
<Button size="small" shape="round" type="submit">{$t('save')}</Button>
</div>
</form>

@ -7,9 +7,10 @@
fullWidth?: boolean;
src?: string;
title?: string;
class?: string;
}
let { onClick = undefined, text, fullWidth = false, src = empty1Url, title }: Props = $props();
let { onClick = undefined, text, fullWidth = false, src = empty1Url, title, class: className }: Props = $props();
let width = $derived(fullWidth ? 'w-full' : 'w-1/2');
@ -22,7 +23,7 @@
<svelte:element
this={onClick ? 'button' : 'div'}
onclick={onClick}
class="{width} m-auto mt-10 flex flex-col place-content-center place-items-center rounded-3xl bg-gray-50 p-5 dark:bg-immich-dark-gray {hoverClasses}"
class="{width} {className} flex flex-col place-content-center place-items-center rounded-3xl bg-gray-50 p-5 dark:bg-immich-dark-gray {hoverClasses}"
>
<img {src} alt="" width="500" draggable="false" />

@ -176,12 +176,24 @@
};
const scrollAndLoadAsset = async (assetId: string) => {
const monthGroup = await timelineManager.findMonthGroupForAsset(assetId);
if (!monthGroup) {
return false;
try {
// This flag prevents layout deferral to fix scroll positioning issues.
// When layouts are deferred and we scroll to an asset at the end of the timeline,
// we can calculate the asset's position, but the scrollableElement's scrollHeight
// hasn't been updated yet to reflect the new layout. This creates a mismatch that
// breaks scroll positioning. By disabling layout deferral in this case, we maintain
// the performance benefits of deferred layouts while still supporting deep linking
// to assets at the end of the timeline.
timelineManager.isScrollingOnLoad = true;
const monthGroup = await timelineManager.findMonthGroupForAsset(assetId);
if (!monthGroup) {
return false;
}
scrollToAssetPosition(assetId, monthGroup);
return true;
} finally {
timelineManager.isScrollingOnLoad = false;
}
scrollToAssetPosition(assetId, monthGroup);
return true;
};
const scrollToAsset = (asset: TimelineAsset) => {

@ -1,6 +1,7 @@
import type { ThemeSetting } from '$lib/managers/theme-manager.svelte';
import type {
AlbumResponseDto,
LibraryResponseDto,
LoginResponseDto,
SharedLinkResponseDto,
SystemConfigDto,
@ -27,6 +28,10 @@ export type Events = {
UserAdminRestore: [UserAdminResponseDto];
SystemConfigUpdate: [SystemConfigDto];
LibraryCreate: [LibraryResponseDto];
LibraryUpdate: [LibraryResponseDto];
LibraryDelete: [{ id: string }];
};
type Listener<EventMap extends Record<string, unknown[]>, K extends keyof EventMap> = (...params: EventMap[K]) => void;

@ -140,7 +140,7 @@ export class DayGroup {
}
layout(options: CommonLayoutOptions, noDefer: boolean) {
if (!noDefer && !this.monthGroup.intersecting) {
if (!noDefer && !this.monthGroup.intersecting && !this.monthGroup.timelineManager.isScrollingOnLoad) {
this.#deferredLayout = true;
return;
}

@ -12,7 +12,7 @@ export function updateGeometry(timelineManager: TimelineManager, month: MonthGro
if (!month.isHeightActual) {
const unwrappedWidth = (3 / 2) * month.assetsCount * timelineManager.rowHeight * (7 / 10);
const rows = Math.ceil(unwrappedWidth / viewportWidth);
const height = 51 + Math.max(1, rows) * timelineManager.rowHeight;
const height = timelineManager.headerHeight + Math.max(1, rows) * timelineManager.rowHeight;
month.height = height;
}
return;

@ -38,6 +38,9 @@ export async function loadFromTimeBuckets(
},
{ signal },
);
if (!albumAssets) {
return;
}
for (const id of albumAssets.id) {
timelineManager.albumAssets.add(id);
}

@ -84,13 +84,13 @@ describe('TimelineManager', () => {
expect.arrayContaining([
expect.objectContaining({ year: 2024, month: 3, height: 283 }),
expect.objectContaining({ year: 2024, month: 2, height: 7711 }),
expect.objectContaining({ year: 2024, month: 1, height: 286 }),
expect.objectContaining({ year: 2024, month: 1, height: 283 }),
]),
);
});
it('calculates timeline height', () => {
expect(timelineManager.totalViewerHeight).toBe(8340);
expect(timelineManager.totalViewerHeight).toBe(8337);
});
});

@ -61,6 +61,7 @@ export class TimelineManager extends VirtualScrollManager {
});
isInitialized = $state(false);
isScrollingOnLoad = false;
months: MonthGroup[] = $state([]);
albumAssets: Set<string> = new SvelteSet();
scrubberMonths: ScrubberMonth[] = $state([]);

@ -0,0 +1,43 @@
<script lang="ts">
import { handleAddLibraryExclusionPattern } from '$lib/services/library.service';
import type { LibraryResponseDto } from '@immich/sdk';
import { Button, Field, HStack, Input, Modal, ModalBody, ModalFooter, Text } from '@immich/ui';
import { mdiFolderSync } from '@mdi/js';
import { t } from 'svelte-i18n';
type Props = {
library: LibraryResponseDto;
onClose: () => void;
};
const { library, onClose }: Props = $props();
let exclusionPattern = $state('');
const onsubmit = async () => {
const success = await handleAddLibraryExclusionPattern(library, exclusionPattern);
if (success) {
onClose();
}
};
</script>
<Modal title={$t('add_exclusion_pattern')} icon={mdiFolderSync} {onClose} size="small">
<ModalBody>
<form {onsubmit} autocomplete="off" id="library-exclusion-pattern-form">
<Text size="small" class="mb-4">{$t('admin.exclusion_pattern_description')}</Text>
<Field label={$t('pattern')}>
<Input bind:value={exclusionPattern} />
</Field>
</form>
</ModalBody>
<ModalFooter>
<HStack fullWidth>
<Button shape="round" color="secondary" fullWidth onclick={() => onClose()}>{$t('cancel')}</Button>
<Button shape="round" type="submit" fullWidth form="library-exclusion-pattern-form">
{$t('add')}
</Button>
</HStack>
</ModalFooter>
</Modal>

@ -0,0 +1,45 @@
<script lang="ts">
import { handleEditExclusionPattern } from '$lib/services/library.service';
import type { LibraryResponseDto } from '@immich/sdk';
import { Button, Field, HStack, Input, Modal, ModalBody, ModalFooter, Text } from '@immich/ui';
import { mdiFolderSync } from '@mdi/js';
import { t } from 'svelte-i18n';
type Props = {
library: LibraryResponseDto;
exclusionPattern: string;
onClose: () => void;
};
const { library, exclusionPattern, onClose }: Props = $props();
let newExclusionPattern = $state(exclusionPattern);
const onsubmit = async () => {
const success = await handleEditExclusionPattern(library, exclusionPattern, newExclusionPattern);
if (success) {
onClose();
}
};
</script>
<Modal title={$t('edit_exclusion_pattern')} icon={mdiFolderSync} {onClose} size="small">
<ModalBody>
<form {onsubmit} autocomplete="off" id="library-exclusion-pattern-form">
<Text size="small" class="mb-4">{$t('admin.exclusion_pattern_description')}</Text>
<Field label={$t('pattern')}>
<Input bind:value={newExclusionPattern} />
</Field>
</form>
</ModalBody>
<ModalFooter>
<HStack fullWidth>
<Button shape="round" color="secondary" fullWidth onclick={() => onClose()}>{$t('cancel')}</Button>
<Button shape="round" type="submit" fullWidth form="library-exclusion-pattern-form">
{$t('save')}
</Button>
</HStack>
</ModalFooter>
</Modal>

@ -1,78 +0,0 @@
<script lang="ts">
import { Button, HStack, Modal, ModalBody, ModalFooter } from '@immich/ui';
import { mdiFolderRemove } from '@mdi/js';
import { onMount } from 'svelte';
import { t } from 'svelte-i18n';
interface Props {
exclusionPattern: string;
exclusionPatterns?: string[];
isEditing?: boolean;
submitText?: string;
onClose: (data?: { action: 'delete' } | { action: 'submit'; exclusionPattern: string }) => void;
}
let {
exclusionPattern = $bindable(),
exclusionPatterns = $bindable([]),
isEditing = false,
submitText = $t('submit'),
onClose,
}: Props = $props();
onMount(() => {
if (isEditing) {
exclusionPatterns = exclusionPatterns.filter((pattern) => pattern !== exclusionPattern);
}
});
let isDuplicate = $derived(exclusionPattern !== null && exclusionPatterns.includes(exclusionPattern));
let canSubmit = $derived(exclusionPattern && !exclusionPatterns.includes(exclusionPattern));
const onsubmit = (event: Event) => {
event.preventDefault();
if (canSubmit) {
onClose({ action: 'submit', exclusionPattern });
}
};
</script>
<Modal size="small" title={$t('add_exclusion_pattern')} icon={mdiFolderRemove} {onClose}>
<ModalBody>
<form {onsubmit} autocomplete="off" id="add-exclusion-pattern-form">
<p class="py-5 text-sm">
{$t('admin.exclusion_pattern_description')}
<br /><br />
{$t('admin.add_exclusion_pattern_description')}
</p>
<div class="my-4 flex flex-col gap-2">
<label class="immich-form-label" for="exclusionPattern">{$t('pattern')}</label>
<input
class="immich-form-input"
id="exclusionPattern"
name="exclusionPattern"
type="text"
bind:value={exclusionPattern}
/>
</div>
<div class="mt-8 flex w-full gap-4">
{#if isDuplicate}
<p class="text-red-500 text-sm">{$t('errors.exclusion_pattern_already_exists')}</p>
{/if}
</div>
</form>
</ModalBody>
<ModalFooter>
<HStack fullWidth>
<Button shape="round" color="secondary" fullWidth onclick={() => onClose()}>{$t('cancel')}</Button>
{#if isEditing}
<Button shape="round" color="danger" fullWidth onclick={() => onClose({ action: 'delete' })}
>{$t('delete')}</Button
>
{/if}
<Button shape="round" type="submit" disabled={!canSubmit} fullWidth form="add-exclusion-pattern-form">
{submitText}
</Button>
</HStack>
</ModalFooter>
</Modal>

@ -0,0 +1,44 @@
<script lang="ts">
import { handleAddLibraryFolder } from '$lib/services/library.service';
import type { LibraryResponseDto } from '@immich/sdk';
import { Button, Field, HStack, Input, Modal, ModalBody, ModalFooter, Text } from '@immich/ui';
import { mdiFolderSync } from '@mdi/js';
import { t } from 'svelte-i18n';
type Props = {
library: LibraryResponseDto;
onClose: () => void;
};
const { library, onClose }: Props = $props();
let folder = $state('');
const onsubmit = async () => {
const success = await handleAddLibraryFolder(library, folder);
if (success) {
onClose();
}
};
</script>
<Modal title={$t('library_add_folder')} icon={mdiFolderSync} {onClose} size="small">
<ModalBody>
<form {onsubmit} autocomplete="off" id="library-import-path-form">
<Text size="small" class="mb-4">{$t('admin.library_folder_description')}</Text>
<Field label={$t('path')}>
<Input bind:value={folder} />
</Field>
</form>
</ModalBody>
<ModalFooter>
<HStack fullWidth>
<Button shape="round" color="secondary" fullWidth onclick={() => onClose()}>{$t('cancel')}</Button>
<Button shape="round" type="submit" fullWidth form="library-import-path-form">
{$t('add')}
</Button>
</HStack>
</ModalFooter>
</Modal>

@ -0,0 +1,45 @@
<script lang="ts">
import { handleEditLibraryFolder } from '$lib/services/library.service';
import type { LibraryResponseDto } from '@immich/sdk';
import { Button, Field, HStack, Input, Modal, ModalBody, ModalFooter, Text } from '@immich/ui';
import { mdiFolderSync } from '@mdi/js';
import { t } from 'svelte-i18n';
type Props = {
library: LibraryResponseDto;
folder: string;
onClose: () => void;
};
const { library, folder, onClose }: Props = $props();
let newFolder = $state(folder);
const onsubmit = async () => {
const success = await handleEditLibraryFolder(library, folder, newFolder);
if (success) {
onClose();
}
};
</script>
<Modal title={$t('library_edit_folder')} icon={mdiFolderSync} {onClose} size="small">
<ModalBody>
<form {onsubmit} autocomplete="off" id="library-import-path-form">
<Text size="small" class="mb-4">{$t('admin.library_folder_description')}</Text>
<Field label={$t('path')}>
<Input bind:value={newFolder} />
</Field>
</form>
</ModalBody>
<ModalFooter>
<HStack fullWidth>
<Button shape="round" color="secondary" fullWidth onclick={() => onClose()}>{$t('cancel')}</Button>
<Button shape="round" type="submit" fullWidth form="library-import-path-form">
{$t('save')}
</Button>
</HStack>
</ModalFooter>
</Modal>

@ -1,75 +0,0 @@
<script lang="ts">
import { Button, HStack, Modal, ModalBody, ModalFooter } from '@immich/ui';
import { mdiFolderSync } from '@mdi/js';
import { onMount } from 'svelte';
import { t } from 'svelte-i18n';
interface Props {
importPath: string | null;
importPaths?: string[];
title?: string;
cancelText?: string;
submitText?: string;
isEditing?: boolean;
onClose: (data?: { action: 'delete' } | { action: 'submit'; importPath: string | null }) => void;
}
let {
importPath = $bindable(),
importPaths = $bindable([]),
title = $t('import_path'),
cancelText = $t('cancel'),
submitText = $t('save'),
isEditing = false,
onClose,
}: Props = $props();
onMount(() => {
if (isEditing) {
importPaths = importPaths.filter((path) => path !== importPath);
}
});
let isDuplicate = $derived(importPath !== null && importPaths.includes(importPath));
let canSubmit = $derived(importPath !== '' && importPath !== null && !importPaths.includes(importPath));
const onsubmit = (event: Event) => {
event.preventDefault();
if (canSubmit) {
onClose({ action: 'submit', importPath });
}
};
</script>
<Modal {title} icon={mdiFolderSync} {onClose} size="small">
<ModalBody>
<form {onsubmit} autocomplete="off" id="library-import-path-form">
<p class="py-5 text-sm">{$t('admin.library_import_path_description')}</p>
<div class="my-4 flex flex-col gap-2">
<label class="immich-form-label" for="path">{$t('path')}</label>
<input class="immich-form-input" id="path" name="path" type="text" bind:value={importPath} />
</div>
<div class="mt-8 flex w-full gap-4">
{#if isDuplicate}
<p class="text-red-500 text-sm">{$t('errors.import_path_already_exists')}</p>
{/if}
</div>
</form>
</ModalBody>
<ModalFooter>
<HStack fullWidth>
<Button shape="round" color="secondary" fullWidth onclick={() => onClose()}>{cancelText}</Button>
{#if isEditing}
<Button shape="round" color="danger" fullWidth onclick={() => onClose({ action: 'delete' })}>
{$t('delete')}
</Button>
{/if}
<Button shape="round" type="submit" disabled={!canSubmit} fullWidth form="library-import-path-form">
{submitText}
</Button>
</HStack>
</ModalFooter>
</Modal>

@ -1,21 +1,25 @@
<script lang="ts">
import { handleRenameLibrary } from '$lib/services/library.service';
import type { LibraryResponseDto } from '@immich/sdk';
import { Button, Field, HStack, Input, Modal, ModalBody, ModalFooter } from '@immich/ui';
import { mdiRenameOutline } from '@mdi/js';
import { t } from 'svelte-i18n';
interface Props {
library: Partial<LibraryResponseDto>;
onClose: (library?: Partial<LibraryResponseDto>) => void;
}
type Props = {
library: LibraryResponseDto;
onClose: () => void;
};
let { library, onClose }: Props = $props();
let newName = $state(library.name);
const onsubmit = (event: Event) => {
event.preventDefault();
onClose({ ...library, name: newName });
const onsubmit = async () => {
const success = await handleRenameLibrary(library, newName);
if (success) {
onClose();
}
};
</script>

@ -0,0 +1,352 @@
import { goto } from '$app/navigation';
import { AppRoute } from '$lib/constants';
import { eventManager } from '$lib/managers/event-manager.svelte';
import LibraryExclusionPatternAddModal from '$lib/modals/LibraryExclusionPatternAddModal.svelte';
import LibraryExclusionPatternEditModal from '$lib/modals/LibraryExclusionPatternEditModal.svelte';
import LibraryFolderAddModal from '$lib/modals/LibraryFolderAddModal.svelte';
import LibraryFolderEditModal from '$lib/modals/LibraryFolderEditModal.svelte';
import LibraryRenameModal from '$lib/modals/LibraryRenameModal.svelte';
import LibraryUserPickerModal from '$lib/modals/LibraryUserPickerModal.svelte';
import type { ActionItem } from '$lib/types';
import { handleError } from '$lib/utils/handle-error';
import { getFormatter } from '$lib/utils/i18n';
import {
createLibrary,
deleteLibrary,
QueueCommand,
QueueName,
runQueueCommandLegacy,
scanLibrary,
updateLibrary,
type LibraryResponseDto,
} from '@immich/sdk';
import { modalManager, toastManager } from '@immich/ui';
import { mdiPencilOutline, mdiPlusBoxOutline, mdiSync, mdiTrashCanOutline } from '@mdi/js';
import type { MessageFormatter } from 'svelte-i18n';
export const getLibrariesActions = ($t: MessageFormatter) => {
const ScanAll: ActionItem = {
title: $t('scan_all_libraries'),
icon: mdiSync,
onSelect: () => void handleScanAllLibraries(),
};
const Create: ActionItem = {
title: $t('create_library'),
icon: mdiPlusBoxOutline,
onSelect: () => void handleCreateLibrary(),
};
return { ScanAll, Create };
};
export const getLibraryActions = ($t: MessageFormatter, library: LibraryResponseDto) => {
const Rename: ActionItem = {
icon: mdiPencilOutline,
title: $t('rename'),
onSelect: () => void modalManager.show(LibraryRenameModal, { library }),
};
const Delete: ActionItem = {
icon: mdiTrashCanOutline,
title: $t('delete'),
color: 'danger',
onSelect: () => void handleDeleteLibrary(library),
};
const AddFolder: ActionItem = {
icon: mdiPlusBoxOutline,
title: $t('add'),
onSelect: () => void modalManager.show(LibraryFolderAddModal, { library }),
};
const AddExclusionPattern: ActionItem = {
icon: mdiPlusBoxOutline,
title: $t('add'),
onSelect: () => void modalManager.show(LibraryExclusionPatternAddModal, { library }),
};
const Scan: ActionItem = {
icon: mdiSync,
title: $t('scan_library'),
onSelect: () => void handleScanLibrary(library),
};
return { Rename, Delete, AddFolder, AddExclusionPattern, Scan };
};
export const getLibraryFolderActions = ($t: MessageFormatter, library: LibraryResponseDto, folder: string) => {
const Edit: ActionItem = {
icon: mdiPencilOutline,
title: $t('edit'),
onSelect: () => void modalManager.show(LibraryFolderEditModal, { folder, library }),
};
const Delete: ActionItem = {
icon: mdiTrashCanOutline,
title: $t('delete'),
onSelect: () => void handleDeleteLibraryFolder(library, folder),
};
return { Edit, Delete };
};
export const getLibraryExclusionPatternActions = (
$t: MessageFormatter,
library: LibraryResponseDto,
exclusionPattern: string,
) => {
const Edit: ActionItem = {
icon: mdiPencilOutline,
title: $t('edit'),
onSelect: () => void modalManager.show(LibraryExclusionPatternEditModal, { exclusionPattern, library }),
};
const Delete: ActionItem = {
icon: mdiTrashCanOutline,
title: $t('delete'),
onSelect: () => void handleDeleteExclusionPattern(library, exclusionPattern),
};
return { Edit, Delete };
};
const handleScanAllLibraries = async () => {
const $t = await getFormatter();
try {
await runQueueCommandLegacy({ name: QueueName.Library, queueCommandDto: { command: QueueCommand.Start } });
toastManager.info($t('admin.refreshing_all_libraries'));
} catch (error) {
handleError(error, $t('errors.unable_to_scan_libraries'));
}
};
const handleScanLibrary = async (library: LibraryResponseDto) => {
const $t = await getFormatter();
try {
await scanLibrary({ id: library.id });
toastManager.info($t('admin.scanning_library'));
} catch (error) {
handleError(error, $t('errors.unable_to_scan_library'));
}
};
export const handleViewLibrary = async (library: LibraryResponseDto) => {
await goto(`${AppRoute.ADMIN_LIBRARY_MANAGEMENT}/${library.id}`);
};
export const handleCreateLibrary = async () => {
const $t = await getFormatter();
const ownerId = await modalManager.show(LibraryUserPickerModal, {});
if (!ownerId) {
return;
}
try {
const createdLibrary = await createLibrary({ createLibraryDto: { ownerId } });
eventManager.emit('LibraryCreate', createdLibrary);
toastManager.success($t('admin.library_created', { values: { library: createdLibrary.name } }));
} catch (error) {
handleError(error, $t('errors.unable_to_create_library'));
}
};
export const handleRenameLibrary = async (library: { id: string }, name?: string) => {
const $t = await getFormatter();
if (!name) {
return false;
}
try {
const updatedLibrary = await updateLibrary({
id: library.id,
updateLibraryDto: { name },
});
eventManager.emit('LibraryUpdate', updatedLibrary);
toastManager.success($t('admin.library_updated'));
} catch (error) {
handleError(error, $t('errors.unable_to_update_library'));
return false;
}
return true;
};
const handleDeleteLibrary = async (library: LibraryResponseDto) => {
const $t = await getFormatter();
const confirmed = await modalManager.showDialog({
prompt: $t('admin.confirm_delete_library', { values: { library: library.name } }),
});
if (!confirmed) {
return;
}
if (library.assetCount > 0) {
const isConfirmed = await modalManager.showDialog({
prompt: $t('admin.confirm_delete_library_assets', { values: { count: library.assetCount } }),
});
if (!isConfirmed) {
return;
}
}
try {
await deleteLibrary({ id: library.id });
eventManager.emit('LibraryDelete', { id: library.id });
toastManager.success($t('admin.library_deleted'));
} catch (error) {
handleError(error, $t('errors.unable_to_remove_library'));
}
};
export const handleAddLibraryFolder = async (library: LibraryResponseDto, folder: string) => {
const $t = await getFormatter();
if (library.importPaths.includes(folder)) {
toastManager.danger($t('errors.library_folder_already_exists'));
return false;
}
try {
const updatedLibrary = await updateLibrary({
id: library.id,
updateLibraryDto: { importPaths: [...library.importPaths, folder] },
});
eventManager.emit('LibraryUpdate', updatedLibrary);
toastManager.success($t('admin.library_updated'));
} catch (error) {
handleError(error, $t('errors.unable_to_update_library'));
return false;
}
return true;
};
export const handleEditLibraryFolder = async (library: LibraryResponseDto, oldFolder: string, newFolder: string) => {
const $t = await getFormatter();
if (oldFolder === newFolder) {
return true;
}
const importPaths = library.importPaths.map((path) => (path === oldFolder ? newFolder : path));
try {
const updatedLibrary = await updateLibrary({ id: library.id, updateLibraryDto: { importPaths } });
eventManager.emit('LibraryUpdate', updatedLibrary);
toastManager.success($t('admin.library_updated'));
} catch (error) {
handleError(error, $t('errors.unable_to_update_library'));
return false;
}
return true;
};
const handleDeleteLibraryFolder = async (library: LibraryResponseDto, folder: string) => {
const $t = await getFormatter();
const confirmed = await modalManager.showDialog({
prompt: $t('admin.library_remove_folder_prompt'),
confirmText: $t('remove'),
});
if (!confirmed) {
return false;
}
try {
const updatedLibrary = await updateLibrary({
id: library.id,
updateLibraryDto: { importPaths: library.importPaths.filter((path) => path !== folder) },
});
eventManager.emit('LibraryUpdate', updatedLibrary);
toastManager.success($t('admin.library_updated'));
} catch (error) {
handleError(error, $t('errors.unable_to_update_library'));
return false;
}
return true;
};
export const handleAddLibraryExclusionPattern = async (library: LibraryResponseDto, exclusionPattern: string) => {
const $t = await getFormatter();
if (library.exclusionPatterns.includes(exclusionPattern)) {
toastManager.danger($t('errors.exclusion_pattern_already_exists'));
return false;
}
try {
const updatedLibrary = await updateLibrary({
id: library.id,
updateLibraryDto: { exclusionPatterns: [...library.exclusionPatterns, exclusionPattern] },
});
eventManager.emit('LibraryUpdate', updatedLibrary);
toastManager.success($t('admin.library_updated'));
} catch (error) {
handleError(error, $t('errors.unable_to_update_library'));
return false;
}
return true;
};
export const handleEditExclusionPattern = async (
library: LibraryResponseDto,
oldExclusionPattern: string,
newExclusionPattern: string,
) => {
const $t = await getFormatter();
if (oldExclusionPattern === newExclusionPattern) {
return true;
}
const exclusionPatterns = library.exclusionPatterns.map((pattern) =>
pattern === oldExclusionPattern ? newExclusionPattern : pattern,
);
try {
const updatedLibrary = await updateLibrary({ id: library.id, updateLibraryDto: { exclusionPatterns } });
eventManager.emit('LibraryUpdate', updatedLibrary);
toastManager.success($t('admin.library_updated'));
} catch (error) {
handleError(error, $t('errors.unable_to_update_library'));
return false;
}
return true;
};
const handleDeleteExclusionPattern = async (library: LibraryResponseDto, exclusionPattern: string) => {
const $t = await getFormatter();
const confirmed = await modalManager.showDialog({ prompt: $t('admin.library_remove_exclusion_pattern_prompt') });
if (!confirmed) {
return false;
}
try {
const updatedLibrary = await updateLibrary({
id: library.id,
updateLibraryDto: {
exclusionPatterns: library.exclusionPatterns.filter((pattern) => pattern !== exclusionPattern),
},
});
eventManager.emit('LibraryUpdate', updatedLibrary);
toastManager.success($t('admin.library_updated'));
} catch (error) {
handleError(error, $t('errors.unable_to_update_library'));
return false;
}
return true;
};

@ -52,7 +52,7 @@
bind:albumGroupIds={albumGroups}
>
{#snippet empty()}
<EmptyPlaceholder text={$t('no_albums_message')} onClick={() => createAlbumAndRedirect()} />
<EmptyPlaceholder text={$t('no_albums_message')} onClick={() => createAlbumAndRedirect()} class="mt-10 mx-auto" />
{/snippet}
</Albums>
</UserPageLayout>

@ -54,7 +54,7 @@
onEscape={handleEscape}
>
{#snippet empty()}
<EmptyPlaceholder text={$t('no_archived_assets_message')} />
<EmptyPlaceholder text={$t('no_archived_assets_message')} class="mt-10 mx-auto" />
{/snippet}
</Timeline>
</UserPageLayout>

@ -114,6 +114,6 @@
{/if}
{#if !hasPeople && places.length === 0}
<EmptyPlaceholder text={$t('no_explore_results_message')} />
<EmptyPlaceholder text={$t('no_explore_results_message')} class="mt-10 mx-auto" />
{/if}
</UserPageLayout>

@ -59,7 +59,7 @@
onEscape={handleEscape}
>
{#snippet empty()}
<EmptyPlaceholder text={$t('no_favorites_message')} />
<EmptyPlaceholder text={$t('no_favorites_message')} class="mt-10 mx-auto" />
{/snippet}
</Timeline>
</UserPageLayout>

@ -65,7 +65,7 @@
removeAction={AssetAction.SET_VISIBILITY_TIMELINE}
>
{#snippet empty()}
<EmptyPlaceholder text={$t('no_locked_photos_message')} title={$t('nothing_here_yet')} />
<EmptyPlaceholder text={$t('no_locked_photos_message')} title={$t('nothing_here_yet')} class="mt-10 mx-auto" />
{/snippet}
</Timeline>
</UserPageLayout>

@ -1,6 +1,7 @@
<script lang="ts">
import { goto } from '$app/navigation';
import UserPageLayout from '$lib/components/layouts/user-page-layout.svelte';
import { timeToLoadTheMap } from '$lib/constants';
import { AppRoute, timeToLoadTheMap } from '$lib/constants';
import Portal from '$lib/elements/Portal.svelte';
import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte';
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
@ -26,6 +27,10 @@
assetViewingStore.showAssetViewer(false);
});
if (!featureFlagsManager.value.map) {
handlePromiseError(goto(AppRoute.PHOTOS));
}
async function onViewAssets(assetIds: string[]) {
viewingAssets = assetIds;
viewingAssetCursor = 0;

@ -1,7 +1,3 @@
import { goto } from '$app/navigation';
import { AppRoute } from '$lib/constants';
import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte';
import { handlePromiseError } from '$lib/utils';
import { authenticate } from '$lib/utils/auth';
import { getFormatter } from '$lib/utils/i18n';
import { getAssetInfoFromParam } from '$lib/utils/navigation';
@ -12,10 +8,6 @@ export const load = (async ({ params, url }) => {
const asset = await getAssetInfoFromParam(params);
const $t = await getFormatter();
if (!featureFlagsManager.value.map) {
handlePromiseError(goto(AppRoute.PHOTOS));
}
return {
asset,
meta: {

@ -101,7 +101,7 @@
<MemoryLane />
{/if}
{#snippet empty()}
<EmptyPlaceholder text={$t('no_assets_message')} onClick={() => openFileUploadDialog()} />
<EmptyPlaceholder text={$t('no_assets_message')} onClick={() => openFileUploadDialog()} class="mt-10 mx-auto" />
{/snippet}
</Timeline>
</UserPageLayout>

@ -94,7 +94,7 @@
<Albums sharedAlbums={data.sharedAlbums} userSettings={settings} showOwner>
<!-- Empty List -->
{#snippet empty()}
<EmptyPlaceholder text={$t('no_shared_albums_message')} src={empty2Url} />
<EmptyPlaceholder text={$t('no_shared_albums_message')} src={empty2Url} class="mt-10 mx-auto" />
{/snippet}
</Albums>
</div>

@ -1,4 +1,5 @@
<script lang="ts">
import { goto } from '$app/navigation';
import empty3Url from '$lib/assets/empty-3.svg';
import UserPageLayout from '$lib/components/layouts/user-page-layout.svelte';
import EmptyPlaceholder from '$lib/components/shared-components/empty-placeholder.svelte';
@ -7,10 +8,12 @@
import SelectAllAssets from '$lib/components/timeline/actions/SelectAllAction.svelte';
import AssetSelectControlBar from '$lib/components/timeline/AssetSelectControlBar.svelte';
import Timeline from '$lib/components/timeline/Timeline.svelte';
import { AppRoute } from '$lib/constants';
import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte';
import { serverConfigManager } from '$lib/managers/server-config-manager.svelte';
import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
import { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
import { handlePromiseError } from '$lib/utils';
import { handleError } from '$lib/utils/handle-error';
import { emptyTrash, restoreTrash } from '@immich/sdk';
import { Button, HStack, modalManager, Text, toastManager } from '@immich/ui';
@ -29,6 +32,10 @@
const assetInteraction = new AssetInteraction();
if (!featureFlagsManager.value.trash) {
handlePromiseError(goto(AppRoute.PHOTOS));
}
const handleEmptyTrash = async () => {
const isConfirmed = await modalManager.showDialog({ prompt: $t('empty_trash_confirmation') });
if (!isConfirmed) {
@ -104,7 +111,7 @@
})}
</p>
{#snippet empty()}
<EmptyPlaceholder text={$t('trash_no_results_message')} src={empty3Url} />
<EmptyPlaceholder text={$t('trash_no_results_message')} src={empty3Url} class="mt-10 mx-auto" />
{/snippet}
</Timeline>
</UserPageLayout>

@ -1,7 +1,3 @@
import { goto } from '$app/navigation';
import { AppRoute } from '$lib/constants';
import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte';
import { handlePromiseError } from '$lib/utils';
import { authenticate } from '$lib/utils/auth';
import { getFormatter } from '$lib/utils/i18n';
import { getAssetInfoFromParam } from '$lib/utils/navigation';
@ -12,10 +8,6 @@ export const load = (async ({ params, url }) => {
const asset = await getAssetInfoFromParam(params);
const $t = await getFormatter();
if (!featureFlagsManager.value.trash) {
handlePromiseError(goto(AppRoute.PHOTOS));
}
return {
asset,
meta: {

@ -208,7 +208,7 @@
{/if}
{/snippet}
{#snippet empty()}
<EmptyPlaceholder text={$t('no_assets_message')} onClick={() => {}} />
<EmptyPlaceholder text={$t('no_assets_message')} onClick={() => {}} class="mt-10 mx-auto" />
{/snippet}
</Timeline>
</UserPageLayout>

@ -1,36 +1,17 @@
<script lang="ts">
import LibraryImportPathsForm from '$lib/components/forms/library-import-paths-form.svelte';
import LibraryScanSettingsForm from '$lib/components/forms/library-scan-settings-form.svelte';
import { goto } from '$app/navigation';
import HeaderButton from '$lib/components/HeaderButton.svelte';
import AdminPageLayout from '$lib/components/layouts/AdminPageLayout.svelte';
import ButtonContextMenu from '$lib/components/shared-components/context-menu/button-context-menu.svelte';
import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte';
import OnEvents from '$lib/components/OnEvents.svelte';
import EmptyPlaceholder from '$lib/components/shared-components/empty-placeholder.svelte';
import LibraryImportPathModal from '$lib/modals/LibraryImportPathModal.svelte';
import LibraryRenameModal from '$lib/modals/LibraryRenameModal.svelte';
import LibraryUserPickerModal from '$lib/modals/LibraryUserPickerModal.svelte';
import { AppRoute } from '$lib/constants';
import { getLibrariesActions, handleCreateLibrary, handleViewLibrary } from '$lib/services/library.service';
import { locale } from '$lib/stores/preferences.store';
import { ByteUnit, getBytesWithUnit } from '$lib/utils/byte-units';
import { handleError } from '$lib/utils/handle-error';
import {
createLibrary,
deleteLibrary,
getAllLibraries,
getLibraryStatistics,
getUserAdmin,
QueueCommand,
QueueName,
runQueueCommandLegacy,
scanLibrary,
updateLibrary,
type LibraryResponseDto,
type LibraryStatsResponseDto,
type UserResponseDto,
} from '@immich/sdk';
import { Button, LoadingSpinner, modalManager, Text, toastManager } from '@immich/ui';
import { mdiDotsVertical, mdiPlusBoxOutline, mdiSync } from '@mdi/js';
import { onMount } from 'svelte';
import { getBytesWithUnit } from '$lib/utils/byte-units';
import { getLibrary, getLibraryStatistics, getUserAdmin, type LibraryResponseDto } from '@immich/sdk';
import { Button } from '@immich/ui';
import { t } from 'svelte-i18n';
import { fade, slide } from 'svelte/transition';
import { fade } from 'svelte/transition';
import type { PageData } from './$types';
interface Props {
@ -39,230 +20,57 @@
let { data }: Props = $props();
let libraries: LibraryResponseDto[] = $state([]);
let libraries = $state(data.libraries);
let statistics = $state(data.statistics);
let owners = $state(data.owners);
let stats: LibraryStatsResponseDto[] = [];
let owner: UserResponseDto[] = $state([]);
let photos: number[] = $state([]);
let videos: number[] = $state([]);
let totalCount: number[] = $state([]);
let diskUsage: number[] = $state([]);
let diskUsageUnit: ByteUnit[] = $state([]);
let editImportPaths: number | undefined = $state();
let editScanSettings: number | undefined = $state();
let dropdownOpen: boolean[] = [];
const handleLibraryAdd = async (library: LibraryResponseDto) => {
statistics[library.id] = await getLibraryStatistics({ id: library.id });
owners[library.id] = await getUserAdmin({ id: library.ownerId });
libraries.push(library);
onMount(async () => {
await readLibraryList();
});
const closeAll = () => {
editImportPaths = undefined;
editScanSettings = undefined;
for (let index = 0; index < dropdownOpen.length; index++) {
dropdownOpen[index] = false;
}
await goto(`${AppRoute.ADMIN_LIBRARY_MANAGEMENT}/${library.id}`);
};
const refreshStats = async (listIndex: number) => {
stats[listIndex] = await getLibraryStatistics({ id: libraries[listIndex].id });
owner[listIndex] = await getUserAdmin({ id: libraries[listIndex].ownerId });
photos[listIndex] = stats[listIndex].photos;
videos[listIndex] = stats[listIndex].videos;
totalCount[listIndex] = stats[listIndex].total;
[diskUsage[listIndex], diskUsageUnit[listIndex]] = getBytesWithUnit(stats[listIndex].usage, 0);
};
async function readLibraryList() {
libraries = await getAllLibraries();
dropdownOpen.length = libraries.length;
for (let index = 0; index < libraries.length; index++) {
await refreshStats(index);
dropdownOpen[index] = false;
}
}
const handleCreate = async (ownerId: string) => {
let createdLibrary: LibraryResponseDto | undefined;
try {
createdLibrary = await createLibrary({ createLibraryDto: { ownerId } });
toastManager.success($t('admin.library_created', { values: { library: createdLibrary.name } }));
} catch (error) {
handleError(error, $t('errors.unable_to_create_library'));
} finally {
await readLibraryList();
}
if (createdLibrary) {
// Open the import paths form for the newly created library
const createdLibraryIndex = libraries.findIndex((library) => library.id === createdLibrary.id);
const result = await modalManager.show(LibraryImportPathModal, {
title: $t('add_import_path'),
submitText: $t('add'),
importPath: null,
});
const handleLibraryUpdate = async (library: LibraryResponseDto) => {
const index = libraries.findIndex(({ id }) => id === library.id);
if (!result) {
if (createdLibraryIndex !== null) {
onEditImportPathClicked(createdLibraryIndex);
}
return;
}
switch (result.action) {
case 'submit': {
handleAddImportPath(result.importPath, createdLibraryIndex);
break;
}
case 'delete': {
await handleDelete(libraries[createdLibraryIndex], createdLibraryIndex);
break;
}
}
}
};
const handleAddImportPath = (newImportPath: string | null, libraryIndex: number) => {
if ((libraryIndex !== 0 && !libraryIndex) || !newImportPath) {
if (index === -1) {
return;
}
try {
onEditImportPathClicked(libraryIndex);
libraries[libraryIndex].importPaths.push(newImportPath);
} catch (error) {
handleError(error, $t('errors.unable_to_add_import_path'));
}
};
const handleUpdate = async (library: Partial<LibraryResponseDto>, libraryIndex: number) => {
try {
const libraryId = libraries[libraryIndex].id;
await updateLibrary({ id: libraryId, updateLibraryDto: library });
closeAll();
await readLibraryList();
} catch (error) {
handleError(error, $t('errors.unable_to_update_library'));
}
};
const handleScanAll = async () => {
try {
await runQueueCommandLegacy({ name: QueueName.Library, queueCommandDto: { command: QueueCommand.Start } });
toastManager.info($t('admin.refreshing_all_libraries'));
} catch (error) {
handleError(error, $t('errors.unable_to_scan_libraries'));
}
};
const handleScan = async (libraryId: string) => {
try {
await scanLibrary({ id: libraryId });
toastManager.info($t('admin.scanning_library'));
} catch (error) {
handleError(error, $t('errors.unable_to_scan_library'));
}
};
const onRenameClicked = async (index: number) => {
closeAll();
const result = await modalManager.show(LibraryRenameModal, {
library: libraries[index],
});
if (result) {
await handleUpdate(result, index);
}
};
const onEditImportPathClicked = (index: number) => {
closeAll();
editImportPaths = index;
};
const onScanClicked = async (library: LibraryResponseDto) => {
closeAll();
if (library) {
await handleScan(library.id);
}
};
const onCreateNewLibraryClicked = async () => {
const result = await modalManager.show(LibraryUserPickerModal);
if (result) {
await handleCreate(result);
}
libraries[index] = await getLibrary({ id: library.id });
statistics[library.id] = await getLibraryStatistics({ id: library.id });
};
const onScanSettingClicked = (index: number) => {
closeAll();
editScanSettings = index;
const handleDeleteLibrary = ({ id }: { id: string }) => {
libraries = libraries.filter((library) => library.id !== id);
delete statistics[id];
delete owners[id];
};
const handleDelete = async (library: LibraryResponseDto, index: number) => {
closeAll();
if (!library) {
return;
}
const isConfirmed = await modalManager.showDialog({
prompt: $t('admin.confirm_delete_library', { values: { library: library.name } }),
});
if (!isConfirmed) {
return;
}
await refreshStats(index);
const assetCount = totalCount[index];
if (assetCount > 0) {
const isConfirmed = await modalManager.showDialog({
prompt: $t('admin.confirm_delete_library_assets', { values: { count: assetCount } }),
});
if (!isConfirmed) {
return;
}
}
try {
await deleteLibrary({ id: library.id });
toastManager.success($t('admin.library_deleted'));
} catch (error) {
handleError(error, $t('errors.unable_to_remove_library'));
} finally {
await readLibraryList();
}
};
const { Create, ScanAll } = $derived(getLibrariesActions($t));
</script>
<OnEvents
onLibraryCreate={handleLibraryAdd}
onLibraryUpdate={handleLibraryUpdate}
onLibraryDelete={handleDeleteLibrary}
/>
<AdminPageLayout title={data.meta.title}>
{#snippet buttons()}
<div class="flex justify-end gap-2">
{#if libraries.length > 0}
<Button leadingIcon={mdiSync} onclick={handleScanAll} size="small" variant="ghost" color="secondary">
<Text class="hidden md:block">{$t('scan_all_libraries')}</Text>
</Button>
<HeaderButton action={ScanAll} />
{/if}
<Button
leadingIcon={mdiPlusBoxOutline}
onclick={onCreateNewLibraryClicked}
size="small"
variant="ghost"
color="secondary"
>
<Text class="hidden md:block">{$t('create_library')}</Text>
</Button>
<HeaderButton action={Create} />
</div>
{/snippet}
<section class="my-4">
<div class="flex flex-col gap-2" in:fade={{ duration: 500 }}>
<div class="flex flex-col items-center gap-2" in:fade={{ duration: 500 }}>
{#if libraries.length > 0}
<table class="w-full text-start">
<table class="w-3/4 text-start">
<thead
class="mb-4 flex h-12 w-full rounded-md border bg-gray-50 text-primary dark:border-immich-dark-gray dark:bg-immich-dark-gray"
>
@ -276,91 +84,36 @@
</tr>
</thead>
<tbody class="block overflow-y-auto rounded-md border dark:border-immich-dark-gray">
{#each libraries as library, index (library.id)}
{#each libraries as library (library.id + library.name)}
{@const { photos, usage, videos } = statistics[library.id]}
{@const [diskUsage, diskUsageUnit] = getBytesWithUnit(usage, 0)}
<tr
class="grid grid-cols-6 h-20 w-full place-items-center text-center dark:text-immich-dark-fg even:bg-subtle/20 odd:bg-subtle/80"
>
<td class="text-ellipsis px-4 text-sm">{library.name}</td>
<td class="text-ellipsis px-4 text-sm">
{#if owner[index] == undefined}
<LoadingSpinner size="large" />
{:else}{owner[index].name}{/if}
{owners[library.id].name}
</td>
<td class="text-ellipsis px-4 text-sm">
{#if photos[index] == undefined}
<LoadingSpinner size="large" />
{:else}
{photos[index].toLocaleString($locale)}
{/if}
{photos.toLocaleString($locale)}
</td>
<td class="text-ellipsis px-4 text-sm">
{#if videos[index] == undefined}
<LoadingSpinner size="large" />
{:else}
{videos[index].toLocaleString($locale)}
{/if}
{videos.toLocaleString($locale)}
</td>
<td class="text-ellipsis px-4 text-sm">
{#if diskUsage[index] == undefined}
<LoadingSpinner size="large" />
{:else}
{diskUsage[index]}
{diskUsageUnit[index]}
{/if}
{diskUsage}
{diskUsageUnit}
</td>
<td class="text-ellipsis px-4 text-sm">
<ButtonContextMenu
align="top-right"
direction="left"
color="primary"
size="medium"
icon={mdiDotsVertical}
title={$t('library_options')}
variant="filled"
>
<MenuOption onClick={() => onScanClicked(library)} text={$t('scan_library')} />
<hr />
<MenuOption onClick={() => onRenameClicked(index)} text={$t('rename')} />
<MenuOption onClick={() => onEditImportPathClicked(index)} text={$t('edit_import_paths')} />
<MenuOption onClick={() => onScanSettingClicked(index)} text={$t('scan_settings')} />
<hr />
<MenuOption
onClick={() => handleDelete(library, index)}
activeColor="bg-red-200"
textColor="text-red-600"
text={$t('delete_library')}
/>
</ButtonContextMenu>
<td class="flex gap-2 text-ellipsis px-4 text-sm">
<Button size="small" onclick={() => handleViewLibrary(library)}>{$t('view')}</Button>
</td>
</tr>
{#if editImportPaths === index}
<!-- svelte-ignore node_invalid_placement_ssr -->
<div transition:slide={{ duration: 250 }}>
<LibraryImportPathsForm
{library}
onSubmit={(lib) => handleUpdate(lib, index)}
onCancel={() => (editImportPaths = undefined)}
/>
</div>
{/if}
{#if editScanSettings === index}
<!-- svelte-ignore node_invalid_placement_ssr -->
<div transition:slide={{ duration: 250 }} class="mb-4 ms-4 me-4">
<LibraryScanSettingsForm
{library}
onSubmit={(lib) => handleUpdate(lib, index)}
onCancel={() => (editScanSettings = undefined)}
/>
</div>
{/if}
{/each}
</tbody>
</table>
<!-- Empty message -->
{:else}
<EmptyPlaceholder text={$t('no_libraries_message')} onClick={onCreateNewLibraryClicked} />
<EmptyPlaceholder text={$t('no_libraries_message')} onClick={handleCreateLibrary} class="mt-10 mx-auto" />
{/if}
</div>
</section>

@ -1,6 +1,6 @@
import { authenticate, requestServerInfo } from '$lib/utils/auth';
import { getFormatter } from '$lib/utils/i18n';
import { searchUsersAdmin } from '@immich/sdk';
import { getAllLibraries, getLibraryStatistics, getUserAdmin, searchUsersAdmin } from '@immich/sdk';
import type { PageLoad } from './$types';
export const load = (async ({ url }) => {
@ -9,8 +9,19 @@ export const load = (async ({ url }) => {
const allUsers = await searchUsersAdmin({ withDeleted: false });
const $t = await getFormatter();
const libraries = await getAllLibraries();
const statistics = await Promise.all(
libraries.map(async ({ id }) => [id, await getLibraryStatistics({ id })] as const),
);
const owners = await Promise.all(
libraries.map(async ({ id, ownerId }) => [id, await getUserAdmin({ id: ownerId })] as const),
);
return {
allUsers,
libraries,
statistics: Object.fromEntries(statistics),
owners: Object.fromEntries(owners),
meta: {
title: $t('admin.external_library_management'),
},

@ -0,0 +1,131 @@
<script lang="ts">
import { goto } from '$app/navigation';
import emptyFoldersUrl from '$lib/assets/empty-folders.svg';
import HeaderButton from '$lib/components/HeaderButton.svelte';
import AdminPageLayout from '$lib/components/layouts/AdminPageLayout.svelte';
import OnEvents from '$lib/components/OnEvents.svelte';
import ServerStatisticsCard from '$lib/components/server-statistics/ServerStatisticsCard.svelte';
import EmptyPlaceholder from '$lib/components/shared-components/empty-placeholder.svelte';
import TableButton from '$lib/components/TableButton.svelte';
import { AppRoute } from '$lib/constants';
import LibraryFolderAddModal from '$lib/modals/LibraryFolderAddModal.svelte';
import {
getLibraryActions,
getLibraryExclusionPatternActions,
getLibraryFolderActions,
} from '$lib/services/library.service';
import { getBytesWithUnit } from '$lib/utils/byte-units';
import { Card, CardBody, CardHeader, CardTitle, Code, Container, Heading, Icon, modalManager } from '@immich/ui';
import { mdiCameraIris, mdiChartPie, mdiFilterMinusOutline, mdiFolderOutline, mdiPlayCircle } from '@mdi/js';
import { t } from 'svelte-i18n';
import type { PageData } from './$types';
type Props = {
data: PageData;
};
const { data }: Props = $props();
const statistics = data.statistics;
const [storageUsage, unit] = getBytesWithUnit(statistics.usage);
let library = $derived(data.library);
const { Rename, Delete, AddFolder, AddExclusionPattern, Scan } = $derived(getLibraryActions($t, library));
</script>
<OnEvents
onLibraryUpdate={(newLibrary) => (library = newLibrary)}
onLibraryDelete={({ id }) => id === library.id && goto(AppRoute.ADMIN_LIBRARY_MANAGEMENT)}
/>
<AdminPageLayout title={data.meta.title}>
{#snippet buttons()}
<div class="flex justify-end gap-2">
<HeaderButton action={Scan} />
<HeaderButton action={Rename} />
<HeaderButton action={Delete} />
</div>
{/snippet}
<Container size="large" center>
<div class="grid gap-4 grid-cols-1 lg:grid-cols-2 w-full">
<Heading tag="h1" size="large" class="col-span-full my-4">{library.name}</Heading>
<div class="flex flex-col lg:flex-row gap-4 col-span-full">
<ServerStatisticsCard icon={mdiCameraIris} title={$t('photos')} value={statistics.photos} />
<ServerStatisticsCard icon={mdiPlayCircle} title={$t('videos')} value={statistics.videos} />
<ServerStatisticsCard icon={mdiChartPie} title={$t('usage')} value={storageUsage} {unit} />
</div>
<Card color="secondary">
<CardHeader>
<div class="flex w-full justify-between items-center px-4 py-2">
<div class="flex gap-2 text-primary">
<Icon icon={mdiFolderOutline} size="1.5rem" />
<CardTitle>{$t('folders')}</CardTitle>
</div>
<HeaderButton action={AddFolder} />
</div>
</CardHeader>
<CardBody>
<div class="px-4 pb-7">
{#if library.importPaths.length === 0}
<EmptyPlaceholder
src={emptyFoldersUrl}
text={$t('admin.library_folder_description')}
fullWidth
onClick={() => modalManager.show(LibraryFolderAddModal, { library })}
/>
{:else}
<table class="w-full">
<tbody>
{#each library.importPaths as folder (folder)}
{@const { Edit, Delete } = getLibraryFolderActions($t, library, folder)}
<tr class="h-12">
<td>
<Code>{folder}</Code>
</td>
<td class="flex gap-2 justify-end">
<TableButton action={Edit} />
<TableButton action={Delete} />
</td>
</tr>
{/each}
</tbody>
</table>
{/if}
</div>
</CardBody>
</Card>
<Card color="secondary">
<CardHeader>
<div class="flex w-full justify-between items-center px-4 py-2">
<div class="flex gap-2 text-primary">
<Icon icon={mdiFilterMinusOutline} size="1.5rem" />
<CardTitle>{$t('exclusion_pattern')}</CardTitle>
</div>
<HeaderButton action={AddExclusionPattern} />
</div>
</CardHeader>
<CardBody>
<div class="px-4 pb-7">
<table class="w-full">
<tbody>
{#each library.exclusionPatterns as exclusionPattern (exclusionPattern)}
{@const { Edit, Delete } = getLibraryExclusionPatternActions($t, library, exclusionPattern)}
<tr class="h-12">
<td>
<Code>{exclusionPattern}</Code>
</td>
<td class="flex gap-2 justify-end">
<TableButton action={Edit} />
<TableButton action={Delete} />
</td>
</tr>
{/each}
</tbody>
</table>
</div>
</CardBody>
</Card>
</div>
</Container>
</AdminPageLayout>

@ -0,0 +1,28 @@
import { AppRoute } from '$lib/constants';
import { authenticate } from '$lib/utils/auth';
import { getFormatter } from '$lib/utils/i18n';
import { getLibrary, getLibraryStatistics, type LibraryResponseDto } from '@immich/sdk';
import { redirect } from '@sveltejs/kit';
import type { PageLoad } from './$types';
export const load = (async ({ params: { id }, url }) => {
await authenticate(url, { admin: true });
let library: LibraryResponseDto;
try {
library = await getLibrary({ id });
} catch {
redirect(302, AppRoute.ADMIN_LIBRARY_MANAGEMENT);
}
const statistics = await getLibraryStatistics({ id });
const $t = await getFormatter();
return {
library,
statistics,
meta: {
title: $t('admin.library_details'),
},
};
}) satisfies PageLoad;

@ -77,36 +77,34 @@
</tr>
</thead>
<tbody class="block w-full overflow-y-auto rounded-md border dark:border-immich-dark-gray">
{#if allUsers}
{#each allUsers as user (user.id)}
{@const UserAdminActions = getUserAdminActions($t, user)}
<tr
class="flex h-20 overflow-hidden w-full place-items-center text-center dark:text-immich-dark-fg {user.deletedAt
? 'bg-red-300 dark:bg-red-900'
: 'even:bg-subtle/20 odd:bg-subtle/80'}"
{#each allUsers as user (user.id)}
{@const UserAdminActions = getUserAdminActions($t, user)}
<tr
class="flex h-20 overflow-hidden w-full place-items-center text-center dark:text-immich-dark-fg {user.deletedAt
? 'bg-red-300 dark:bg-red-900'
: 'even:bg-subtle/20 odd:bg-subtle/80'}"
>
<td class="w-8/12 sm:w-5/12 lg:w-6/12 xl:w-4/12 2xl:w-5/12 text-ellipsis break-all px-2 text-sm">
{user.email}
</td>
<td class="hidden sm:block w-3/12 text-ellipsis break-all px-2 text-sm">{user.name}</td>
<td class="hidden xl:block w-3/12 2xl:w-2/12 text-ellipsis break-all px-2 text-sm">
<div class="container mx-auto flex flex-wrap justify-center">
{#if user.quotaSizeInBytes !== null && user.quotaSizeInBytes >= 0}
{getByteUnitString(user.quotaSizeInBytes, $locale)}
{:else}
<Icon icon={mdiInfinity} size="16" />
{/if}
</div>
</td>
<td
class="flex flex-row flex-wrap justify-center gap-x-2 gap-y-1 w-4/12 lg:w-3/12 xl:w-2/12 text-ellipsis break-all text-sm"
>
<td class="w-8/12 sm:w-5/12 lg:w-6/12 xl:w-4/12 2xl:w-5/12 text-ellipsis break-all px-2 text-sm">
{user.email}
</td>
<td class="hidden sm:block w-3/12 text-ellipsis break-all px-2 text-sm">{user.name}</td>
<td class="hidden xl:block w-3/12 2xl:w-2/12 text-ellipsis break-all px-2 text-sm">
<div class="container mx-auto flex flex-wrap justify-center">
{#if user.quotaSizeInBytes !== null && user.quotaSizeInBytes >= 0}
{getByteUnitString(user.quotaSizeInBytes, $locale)}
{:else}
<Icon icon={mdiInfinity} size="16" />
{/if}
</div>
</td>
<td
class="flex flex-row flex-wrap justify-center gap-x-2 gap-y-1 w-4/12 lg:w-3/12 xl:w-2/12 text-ellipsis break-all text-sm"
>
<TableButton action={UserAdminActions.View} />
<TableButton action={UserAdminActions.ContextMenu} />
</td>
</tr>
{/each}
{/if}
<TableButton action={UserAdminActions.View} />
<TableButton action={UserAdminActions.ContextMenu} />
</td>
</tr>
{/each}
</tbody>
</table>
</section>

@ -102,7 +102,7 @@
<Alert color="danger" class="my-4" title={$t('user_has_been_deleted')} icon={mdiTrashCanOutline} />
{/if}
<div class="grid gap-4 grod-cols-1 lg:grid-cols-2 w-full">
<div class="grid gap-4 grid-cols-1 lg:grid-cols-2 w-full">
<div class="col-span-full flex gap-4 items-center my-4">
<UserAvatar {user} size="md" />
<Heading tag="h1" size="large">{user.name}</Heading>