Compare commits

..

18 Commits

Author SHA1 Message Date
midzelis
8643a58444 Run ops even if asset no longer matches criteria; add tests 2025-11-19 01:15:33 +00:00
midzelis
16869a21de Don't sort initial load of month from server 2025-11-19 01:15:33 +00:00
midzelis
2db3f8c160 Make the operation more efficient 2025-11-19 01:15:33 +00:00
midzelis
73571d9d69 refactor(web): move timeline-manager ops back into month-group; clean up API
Consolidates asset operation logic within TimelineManager class and removes the now redundant 
operations-support.svelte.ts file. 

Combines addAsset/updateAsset to be upsertAsset. 


Changes:
- Move `addAssetsToMonthGroups` logic into TimelineManager's `addAssetsToSegments`, `upsertAssetIntoSegment`, `postCreateSegments`, and `postUpsert` methods
- Move `runAssetOperation` from operations-support into TimelineManager's private `#runAssetOperation` method
- Rename public `addAssets`/`updateAssets` methods to unified `upsertAssets` for consistency
- Delete internal/operations-support.svelte.ts
- Update WebsocketSupport to use `upsertAssets` for both add and update operations
- Fix AssetOperation return type to allow undefined/void operations (not just `{ remove: boolean }`)
- Update MonthGroup constructor to accept `loaded` parameter for better initialization control
- Update all test references from `addAssets`/`updateAssets` to `upsertAssets`

This refactoring improves code maintainability by eliminating duplicate logic and consolidating all asset operations within the TimelineManager class where they belong.
2025-11-19 01:15:33 +00:00
Daniel Dietzler
d310c6f3cd feat: library details page (#23908)
* feat: library details page

* chore: clean up

---------

Co-authored-by: Jason Rasmussen <jason@rasm.me>
2025-11-18 15:27:41 -05:00
Alex
c086a65fa8 chore: update drift (#23877)
* chore: update drift

* update drift dep

---------

Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
2025-11-18 16:07:33 +00:00
renovate[bot]
7134dd29ca chore(deps): pin ghcr.io/jdx/mise docker tag to ac26f59 (#23961)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-18 12:21:28 +01:00
renovate[bot]
3e08953a43 chore(deps): update dependency @types/node to ^22.19.1 (#23963)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-18 12:20:52 +01:00
Min Idzelis
58c3c7e26b feat: run e2e server in dev mode (#23921)
* feat: run e2e server in dev mode

* Use bash syntax: [[ and ==
2025-11-17 14:16:39 -06:00
Min Idzelis
237ddcb648 fix: incorrect header height calculation in estimated month height (#23923) 2025-11-17 14:14:06 -06:00
Min Idzelis
fbaeffd65c fix: flaky user-admin.e2e-spec.ts (#23929)
* fix: flaky user-admin.e2e-spec.ts

* lint
2025-11-17 14:12:44 -06:00
Min Idzelis
d64c339b4f fix: null dereference when canceling bucket in album (#23924) 2025-11-17 14:12:28 -06:00
Min Idzelis
69880ee165 fix: deep link to last asset (#23920) 2025-11-17 14:12:07 -06:00
Paul Makles
15e00f82f0 feat: maintenance mode (#23431)
* feat: add a `maintenance.enabled` config flag

* feat: implement graceful restart
feat: restart when maintenance config is toggled

* feat: boot a stripped down maintenance api if enabled

* feat: cli command to toggle maintenance mode

* chore: fallback IMMICH_SERVER_URL environment variable in process

* chore: add additional routes to maintenance controller

* fix: don't wait for nest application to close to finish request response

* chore: add a failsafe on restart to prevent other exit codes from preventing restart

* feat: redirect into/from maintenance page

* refactor: use system metadata for maintenance status

* refactor: wait on WebSocket connection to refresh

* feat: broadcast websocket event on server restart
refactor: listen to WS instead of polling

* refactor: bubble up maintenance information instead of hijacking in fetch function
feat: show modal when server is restarting

* chore: increase timeout for ungraceful restart

* refactor: deduplicate code between api/maintenance workers

* fix: skip config check if database is not initialised

* fix: add `maintenanceMode` field to system config test

* refactor: move maintenance resolution code to static method in service

* chore: clean up linter issues

* chore: generate dart openapi

* refactor: use try{} block for maintenance mode check

* fix: logic error in server redirect

* chore: include `maintenanceMode` key in e2e test

* chore: add i18n entries for maintenance screens

* chore: remove negated condition from hook

* fix: should set default value not override in service

* fix: minor error in page

* feat: initial draft of maintenance module, repo., worker controller, worker service

* refactor: move broadcast code into notification service

* chore: connect websocket on client if in maintenance

* chore: set maintenance module app name

* refactor: rename repository to include worker
chore: configure websocket adapter

* feat: reimplement maintenance mode exit with new module

* refactor: add a constant enum for ExitCode

* refactor: remove redundant route for maintenance

* refactor: only spin up kysely on boot (rather than a Nest app)

* refactor(web): move redirect logic into +layout file where modal is setup

* feat: add Maintenance permission

* refactor: merge common code between api/maintenance

* fix: propagate changes from the CLI to servers

* feat: maintenance authentication guard

* refactor: unify maintenance code into repository
feat: add a step to generate maintenance mode token

* feat: jwt auth for maintenance

* refactor: switch from nest jwt to just jsonwebtokens

* feat: log into maintenance mode from CLI command

* refactor: use `secret` instead of `token` in jwt terminology
chore: log maintenance mode login URL on boot
chore: don't make CLI actions reload if already in target state

* docs: initial draft for maintenance mode page

* refactor: always validate the maintenance auth on the server

* feat: add a link to maintenance mode documentation

* feat: redirect users back to the last page they were on when exiting maintenance

* refactor: provide closeFn in both maintenance repos.

* refactor: ensure the user is also redirected by the server

* chore: swap jsonwebtoken for jose

* refactor: introduce AppRestartEvent w/o secret passing

* refactor: use navigation goto

* refactor: use `continue` instead of `next`

* chore: lint fixes for server

* chore: lint fixes for web

* test: add mock for maintenance repository

* test: add base service dependency to maintenance

* chore: remove @types/jsonwebtoken

* refactor: close database connection after startup check

* refactor: use `request#auth` key

* refactor: use service instead of repository
chore: read token from cookie if possible
chore: rename client event to AppRestartV1

* refactor: more concise redirect logic on web

* refactor: move redirect check into utils
refactor: update translation strings to be more sensible

* refactor: always validate login (i.e. check cookie)

* refactor: lint, open-api, remove old dto

* refactor: encode at point of usage

* refactor: remove business logic from repositories

* chore: fix server/web lints

* refactor: remove repository mock

* chore: fix formatting

* test: write service mocks for maintenance mode

* test: write cli service tests

* fix: catch errors when closing app

* fix: always report no maintenance when usual API is available

* test: api e2e maintenance spec

* chore: add response builder

* chore: add helper to set maint. auth cookie

* feat: add SSR to maintenance API

* test(e2e): write web spec for maintenance

* chore: clean up lint issues

* chore: format files

* feat: perform 302 redirect at server level during maintenance

* fix: keep trying to stop immich until it succeeds (CLI issue)

* chore: lint/format

* refactor: annotate references to other services in worker service

* chore: lint

* refactor: remove unnecessary await

Co-authored-by: Daniel Dietzler <36593685+danieldietzler@users.noreply.github.com>

* refactor: move static methods into util

* refactor: assert secret exists in maintenance worker

* refactor: remove assertion which isn't necessary anymore

* refactor: remove assertion

* refactor: remove outer try {} catch block from loadMaintenanceAuth

* refactor: undo earlier change to vite.config.ts

* chore: update tests due to refactors

* revert: vite.config.ts

* test: expect string jwt

* chore: move blanket exceptions into controllers

* test: update tests according with last change

* refactor: use respondWithCookie
refactor: merge start/end into one route
refactor: rename MaintenanceRepository to AppRepository
chore: use new ApiTag/Endpoint
refactor: apply other requested changes

* chore: regenerate openapi

* chore: lint/format

* chore: remove secureOnly for maint. cookie

* refactor: move maintenance worker code into src/maintenance\nfix: various test fixes

* refactor: use `action` property for setting maint. mode

* refactor: remove Websocket#restartApp in favour of individual methods

* chore: incomplete commit

* chore: remove stray log

* fix: call exitApp from maintenance worker on exit

* fix: add app repository mock

* fix: ensure maintenance cookies are secure

* fix: run playwright tests over secure context (localhost)

* test: update other references to 127.0.0.1

* refactor: use serverSideEmitWithAck

* chore: correct the logic in tryTerminate

* test: juggle cookies ourselves

* chore: fix lint error for e2e spec

* chore: format e2e test

* fix: set cookie secure/non-secure depending on context

* chore: format files

---------

Co-authored-by: Daniel Dietzler <36593685+danieldietzler@users.noreply.github.com>
2025-11-17 17:15:44 +00:00
Jason Rasmussen
ce82e27f4b fix: workflow medium tests (#23952) 2025-11-17 16:26:30 +00:00
Yaros
eeee5147cc fix(mobile): delete from device warning shows incorrectly (#23935)
fix(mobile): delete warning on multiple assets
2025-11-17 10:17:04 -06:00
100daysummer
af22f9b014 fix: word wrap on custom link preview (#23942)
Word break fix in create link

Adds the "break-all" tailwind style to the slug text under the custom link text box
2025-11-17 08:49:32 -05:00
Paul Makles
1086f22166 fix: devcontainer server not starting due to missing plugins mount (#23948) 2025-11-17 12:24:59 +01:00
140 changed files with 4254 additions and 1491 deletions

View File

@@ -21,6 +21,7 @@ services:
- app-node_modules:/usr/src/app/node_modules
- sveltekit:/usr/src/app/web/.svelte-kit
- coverage:/usr/src/app/web/coverage
- ../plugins:/build/corePlugin
immich-web:
env_file: !reset []
immich-machine-learning:

View File

@@ -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

View File

@@ -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",

View File

@@ -0,0 +1,18 @@
# Maintenance Mode
Maintenance mode is used to perform administrative tasks such as restoring backups to Immich.
You can enter maintenance mode by either:
- Selecting "enable maintenance mode" in system settings in administration.
- Running the enable maintenance mode [administration command](./server-commands.md).
## Logging in during maintenance
Maintenance mode uses a separate login system which is handled automatically behind the scenes in most cases. Enabling maintenance mode in settings will automatically log you into maintenance mode when the server comes back up.
If you find that you've been logged out, you can:
- Open the logs for the Immich server and look for _"🚧 Immich is in maintenance mode, you can log in using the following URL:"_
- Run the enable maintenance mode [administration command](./server-commands.md) again, this will give you a new URL to login with.
- Run the disable maintenance mode [administration command](./server-commands.md) then re-enter through system settings.

View File

@@ -2,17 +2,19 @@
The `immich-server` docker image comes preinstalled with an administrative CLI (`immich-admin`) that supports the following commands:
| Command | Description |
| ------------------------ | ------------------------------------------------------------- |
| `help` | Display help |
| `reset-admin-password` | Reset the password for the admin user |
| `disable-password-login` | Disable password login |
| `enable-password-login` | Enable password login |
| `enable-oauth-login` | Enable OAuth login |
| `disable-oauth-login` | Disable OAuth login |
| `list-users` | List Immich users |
| `version` | Print Immich version |
| `change-media-location` | Change database file paths to align with a new media location |
| Command | Description |
| -------------------------- | ------------------------------------------------------------- |
| `help` | Display help |
| `reset-admin-password` | Reset the password for the admin user |
| `disable-password-login` | Disable password login |
| `enable-password-login` | Enable password login |
| `disable-maintenance-mode` | Disable maintenance mode |
| `enable-maintenance-mode` | Enable maintenance mode |
| `enable-oauth-login` | Enable OAuth login |
| `disable-oauth-login` | Disable OAuth login |
| `list-users` | List Immich users |
| `version` | Print Immich version |
| `change-media-location` | Change database file paths to align with a new media location |
## How to run a command
@@ -47,6 +49,23 @@ immich-admin enable-password-login
Password login has been enabled.
```
Disable Maintenance Mode
```
immich-admin disable-maintenace-mode
Maintenance mode has been disabled.
```
Enable Maintenance Mode
```
immich-admin enable-maintenance-mode
Maintenance mode has been enabled.
Log in using the following URL:
https://my.immich.app/maintenance?token=<token>
```
Enable OAuth login
```

105
e2e/docker-compose.dev.yml Normal file
View File

@@ -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:

View File

@@ -25,7 +25,7 @@
"@playwright/test": "^1.44.1",
"@socket.io/component-emitter": "^3.1.2",
"@types/luxon": "^3.4.2",
"@types/node": "^22.19.0",
"@types/node": "^22.19.1",
"@types/oidc-provider": "^9.0.0",
"@types/pg": "^8.15.1",
"@types/pngjs": "^6.0.4",

View File

@@ -0,0 +1,172 @@
import { LoginResponseDto } from '@immich/sdk';
import { createUserDto } from 'src/fixtures';
import { errorDto } from 'src/responses';
import { app, utils } from 'src/utils';
import request from 'supertest';
import { beforeAll, describe, expect, it } from 'vitest';
describe('/admin/maintenance', () => {
let cookie: string | undefined;
let admin: LoginResponseDto;
let nonAdmin: LoginResponseDto;
beforeAll(async () => {
await utils.resetDatabase();
admin = await utils.adminSetup();
nonAdmin = await utils.userSetup(admin.accessToken, createUserDto.user1);
});
// => outside of maintenance mode
describe('GET ~/server/config', async () => {
it('should indicate we are out of maintenance mode', async () => {
const { status, body } = await request(app).get('/server/config');
expect(status).toBe(200);
expect(body.maintenanceMode).toBeFalsy();
});
});
describe('POST /login', async () => {
it('should not work out of maintenance mode', async () => {
const { status, body } = await request(app).post('/admin/maintenance/login').send({ token: 'token' });
expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest('Not in maintenance mode'));
});
});
// => enter maintenance mode
describe.sequential('POST /', () => {
it('should require authentication', async () => {
const { status, body } = await request(app).post('/admin/maintenance').send({
action: 'end',
});
expect(status).toBe(401);
expect(body).toEqual(errorDto.unauthorized);
});
it('should only work for admins', async () => {
const { status, body } = await request(app)
.post('/admin/maintenance')
.set('Authorization', `Bearer ${nonAdmin.accessToken}`)
.send({ action: 'end' });
expect(status).toBe(403);
expect(body).toEqual(errorDto.forbidden);
});
it('should be a no-op if try to exit maintenance mode', async () => {
const { status } = await request(app)
.post('/admin/maintenance')
.set('Authorization', `Bearer ${admin.accessToken}`)
.send({ action: 'end' });
expect(status).toBe(201);
});
it('should enter maintenance mode', async () => {
const { status, headers } = await request(app)
.post('/admin/maintenance')
.set('Authorization', `Bearer ${admin.accessToken}`)
.send({
action: 'start',
});
expect(status).toBe(201);
cookie = headers['set-cookie'][0].split(';')[0];
expect(cookie).toEqual(
expect.stringMatching(/^immich_maintenance_token=[A-Za-z0-9-_]*\.[A-Za-z0-9-_]*\.[A-Za-z0-9-_]*$/),
);
await expect
.poll(
async () => {
const { body } = await request(app).get('/server/config');
return body.maintenanceMode;
},
{
interval: 5e2,
timeout: 1e4,
},
)
.toBeTruthy();
});
});
// => in maintenance mode
describe.sequential('in maintenance mode', () => {
describe('GET ~/server/config', async () => {
it('should indicate we are in maintenance mode', async () => {
const { status, body } = await request(app).get('/server/config');
expect(status).toBe(200);
expect(body.maintenanceMode).toBeTruthy();
});
});
describe('POST /login', async () => {
it('should fail without cookie or token in body', async () => {
const { status, body } = await request(app).post('/admin/maintenance/login').send({});
expect(status).toBe(401);
expect(body).toEqual(errorDto.unauthorizedWithMessage('Missing JWT Token'));
});
it('should succeed with cookie', async () => {
const { status, body } = await request(app).post('/admin/maintenance/login').set('cookie', cookie!).send({});
expect(status).toBe(201);
expect(body).toEqual(
expect.objectContaining({
username: 'Immich Admin',
}),
);
});
it('should succeed with token', async () => {
const { status, body } = await request(app)
.post('/admin/maintenance/login')
.send({
token: cookie!.split('=')[1].trim(),
});
expect(status).toBe(201);
expect(body).toEqual(
expect.objectContaining({
username: 'Immich Admin',
}),
);
});
});
describe('POST /', async () => {
it('should be a no-op if try to enter maintenance mode', async () => {
const { status } = await request(app)
.post('/admin/maintenance')
.set('cookie', cookie!)
.send({ action: 'start' });
expect(status).toBe(201);
});
});
});
// => exit maintenance mode
describe.sequential('POST /', () => {
it('should exit maintenance mode', async () => {
const { status } = await request(app).post('/admin/maintenance').set('cookie', cookie!).send({
action: 'end',
});
expect(status).toBe(201);
await expect
.poll(
async () => {
const { body } = await request(app).get('/server/config');
return body.maintenanceMode;
},
{
interval: 5e2,
timeout: 1e4,
},
)
.toBeFalsy();
});
});
});

View File

@@ -136,6 +136,7 @@ describe('/server', () => {
externalDomain: '',
publicUsers: true,
isOnboarded: false,
maintenanceMode: false,
mapDarkStyleUrl: 'https://tiles.immich.cloud/v1/style/dark.json',
mapLightStyleUrl: 'https://tiles.immich.cloud/v1/style/light.json',
});

View File

@@ -7,6 +7,12 @@ export const errorDto = {
message: 'Authentication required',
correlationId: expect.any(String),
},
unauthorizedWithMessage: (message: string) => ({
error: 'Unauthorized',
statusCode: 401,
message,
correlationId: expect.any(String),
}),
forbidden: {
error: 'Forbidden',
statusCode: 403,

View File

@@ -6,6 +6,7 @@ import {
CheckExistingAssetsDto,
CreateAlbumDto,
CreateLibraryDto,
MaintenanceAction,
MetadataSearchDto,
Permission,
PersonCreateDto,
@@ -36,6 +37,7 @@ import {
scanLibrary,
searchAssets,
setBaseUrl,
setMaintenanceMode,
signUpAdmin,
tagAssets,
updateAdminOnboarding,
@@ -514,6 +516,42 @@ export const utils = {
},
]),
setMaintenanceAuthCookie: async (context: BrowserContext, token: string, domain = '127.0.0.1') =>
await context.addCookies([
{
name: 'immich_maintenance_token',
value: token,
domain,
path: '/',
expires: 2_058_028_213,
httpOnly: true,
secure: false,
sameSite: 'Lax',
},
]),
enterMaintenance: async (accessToken: string) => {
let setCookie: string[] | undefined;
await setMaintenanceMode(
{
setMaintenanceModeDto: {
action: MaintenanceAction.Start,
},
},
{
headers: asBearerAuth(accessToken),
fetch: (...args: Parameters<typeof fetch>) =>
fetch(...args).then((response) => {
setCookie = response.headers.getSetCookie();
return response;
}),
},
);
return setCookie;
},
resetTempFolder: () => {
rmSync(`${testAssetDir}/temp`, { recursive: true, force: true });
mkdirSync(`${testAssetDir}/temp`, { recursive: true });

View File

@@ -0,0 +1,52 @@
import { LoginResponseDto } from '@immich/sdk';
import { expect, test } from '@playwright/test';
import { utils } from 'src/utils';
test.describe.configure({ mode: 'serial' });
test.describe('Maintenance', () => {
let admin: LoginResponseDto;
test.beforeAll(async () => {
utils.initSdk();
await utils.resetDatabase();
admin = await utils.adminSetup();
});
test('enter and exit maintenance mode', async ({ context, page }) => {
await utils.setAuthCookies(context, admin.accessToken);
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 page.getByRole('button', { name: 'End maintenance mode' }).click();
await page.waitForURL('/admin/system-settings');
});
test('maintenance shows no options to users until they authenticate', async ({ page }) => {
const setCookie = await utils.enterMaintenance(admin.accessToken);
const cookie = setCookie
?.map((cookie) => cookie.split(';')[0].split('='))
?.find(([name]) => name === 'immich_maintenance_token');
expect(cookie).toBeTruthy();
await expect(async () => {
await page.goto('/');
await page.waitForURL('/maintenance?**', {
timeout: 1e3,
});
}).toPass({ timeout: 1e4 });
await expect(page.getByText('Temporarily Unavailable')).toBeVisible();
await expect(page.getByRole('button', { name: 'End maintenance mode' })).toHaveCount(0);
await page.goto(`/maintenance?${new URLSearchParams({ token: cookie![1] })}`);
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');
});
});

View File

@@ -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);
});
});

View File

@@ -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",
@@ -174,6 +177,10 @@
"machine_learning_smart_search_enabled": "Enable smart search",
"machine_learning_smart_search_enabled_description": "If disabled, images will not be encoded for smart search.",
"machine_learning_url_description": "The URL of the machine learning server. If more than one URL is provided, each server will be attempted one-at-a-time until one responds successfully, in order from first to last. Servers that don't respond will be temporarily ignored until they come back online.",
"maintenance_settings": "Maintenance",
"maintenance_settings_description": "Put Immich into maintenance mode.",
"maintenance_start": "Start maintenance mode",
"maintenance_start_error": "Failed to start maintenance mode.",
"manage_concurrency": "Manage Concurrency",
"manage_log_settings": "Manage log settings",
"map_dark_style": "Dark style",
@@ -897,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",
@@ -970,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",
@@ -980,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",
@@ -1003,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",
@@ -1059,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",
@@ -1247,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",
@@ -1318,6 +1323,11 @@
"loop_videos_description": "Enable to automatically loop a video in the detail viewer.",
"main_branch_warning": "You're using a development version; we strongly recommend using a release version!",
"main_menu": "Main menu",
"maintenance_description": "Immich has been put into <link>maintenance mode</link>.",
"maintenance_end": "End maintenance mode",
"maintenance_end_error": "Failed to end maintenance mode.",
"maintenance_logged_in_as": "Currently logged in as {user}",
"maintenance_title": "Temporarily Unavailable",
"make": "Make",
"manage_geolocation": "Manage location",
"manage_media_access_rationale": "This permission is required for proper handling of moving assets to the trash and restoring them from it.",
@@ -1830,6 +1840,8 @@
"server_offline": "Server Offline",
"server_online": "Server Online",
"server_privacy": "Server Privacy",
"server_restarting_description": "This page will refresh momentarily.",
"server_restarting_title": "Server is restarting",
"server_stats": "Server Stats",
"server_update_available": "Server update is available",
"server_version": "Server Version",

View File

@@ -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(),
];

View File

@@ -264,9 +264,8 @@ class ActionNotifier extends Notifier<void> {
}
Future<ActionResult?> deleteLocal(ActionSource source, BuildContext context) async {
// Always perform the operation if there is only one merged asset
final assets = _getAssets(source);
bool? backedUpOnly = assets.length == 1 && assets.first.storage == AssetState.merged
bool? backedUpOnly = assets.every((asset) => asset.storage == AssetState.merged)
? true
: await showDialog<bool>(
context: context,

View File

@@ -159,6 +159,8 @@ Class | Method | HTTP request | Description
*LibrariesApi* | [**scanLibrary**](doc//LibrariesApi.md#scanlibrary) | **POST** /libraries/{id}/scan | Scan a library
*LibrariesApi* | [**updateLibrary**](doc//LibrariesApi.md#updatelibrary) | **PUT** /libraries/{id} | Update a library
*LibrariesApi* | [**validate**](doc//LibrariesApi.md#validate) | **POST** /libraries/{id}/validate | Validate library settings
*MaintenanceAdminApi* | [**maintenanceLogin**](doc//MaintenanceAdminApi.md#maintenancelogin) | **POST** /admin/maintenance/login | Log into maintenance mode
*MaintenanceAdminApi* | [**setMaintenanceMode**](doc//MaintenanceAdminApi.md#setmaintenancemode) | **POST** /admin/maintenance | Set maintenance mode
*MapApi* | [**getMapMarkers**](doc//MapApi.md#getmapmarkers) | **GET** /map/markers | Retrieve map markers
*MapApi* | [**reverseGeocode**](doc//MapApi.md#reversegeocode) | **GET** /map/reverse-geocode | Reverse geocode coordinates
*MemoriesApi* | [**addMemoryAssets**](doc//MemoriesApi.md#addmemoryassets) | **PUT** /memories/{id}/assets | Add assets to a memory
@@ -404,6 +406,9 @@ Class | Method | HTTP request | Description
- [LoginResponseDto](doc//LoginResponseDto.md)
- [LogoutResponseDto](doc//LogoutResponseDto.md)
- [MachineLearningAvailabilityChecksDto](doc//MachineLearningAvailabilityChecksDto.md)
- [MaintenanceAction](doc//MaintenanceAction.md)
- [MaintenanceAuthDto](doc//MaintenanceAuthDto.md)
- [MaintenanceLoginDto](doc//MaintenanceLoginDto.md)
- [ManualJobName](doc//ManualJobName.md)
- [MapMarkerResponseDto](doc//MapMarkerResponseDto.md)
- [MapReverseGeocodeResponseDto](doc//MapReverseGeocodeResponseDto.md)
@@ -496,6 +501,7 @@ Class | Method | HTTP request | Description
- [SessionResponseDto](doc//SessionResponseDto.md)
- [SessionUnlockDto](doc//SessionUnlockDto.md)
- [SessionUpdateDto](doc//SessionUpdateDto.md)
- [SetMaintenanceModeDto](doc//SetMaintenanceModeDto.md)
- [SharedLinkCreateDto](doc//SharedLinkCreateDto.md)
- [SharedLinkEditDto](doc//SharedLinkEditDto.md)
- [SharedLinkResponseDto](doc//SharedLinkResponseDto.md)

View File

@@ -42,6 +42,7 @@ part 'api/duplicates_api.dart';
part 'api/faces_api.dart';
part 'api/jobs_api.dart';
part 'api/libraries_api.dart';
part 'api/maintenance_admin_api.dart';
part 'api/map_api.dart';
part 'api/memories_api.dart';
part 'api/notifications_api.dart';
@@ -163,6 +164,9 @@ part 'model/login_credential_dto.dart';
part 'model/login_response_dto.dart';
part 'model/logout_response_dto.dart';
part 'model/machine_learning_availability_checks_dto.dart';
part 'model/maintenance_action.dart';
part 'model/maintenance_auth_dto.dart';
part 'model/maintenance_login_dto.dart';
part 'model/manual_job_name.dart';
part 'model/map_marker_response_dto.dart';
part 'model/map_reverse_geocode_response_dto.dart';
@@ -255,6 +259,7 @@ part 'model/session_create_response_dto.dart';
part 'model/session_response_dto.dart';
part 'model/session_unlock_dto.dart';
part 'model/session_update_dto.dart';
part 'model/set_maintenance_mode_dto.dart';
part 'model/shared_link_create_dto.dart';
part 'model/shared_link_edit_dto.dart';
part 'model/shared_link_response_dto.dart';

View File

@@ -0,0 +1,122 @@
//
// AUTO-GENERATED FILE, DO NOT MODIFY!
//
// @dart=2.18
// ignore_for_file: unused_element, unused_import
// ignore_for_file: always_put_required_named_parameters_first
// ignore_for_file: constant_identifier_names
// ignore_for_file: lines_longer_than_80_chars
part of openapi.api;
class MaintenanceAdminApi {
MaintenanceAdminApi([ApiClient? apiClient]) : apiClient = apiClient ?? defaultApiClient;
final ApiClient apiClient;
/// Log into maintenance mode
///
/// Login with maintenance token or cookie to receive current information and perform further actions.
///
/// Note: This method returns the HTTP [Response].
///
/// Parameters:
///
/// * [MaintenanceLoginDto] maintenanceLoginDto (required):
Future<Response> maintenanceLoginWithHttpInfo(MaintenanceLoginDto maintenanceLoginDto,) async {
// ignore: prefer_const_declarations
final apiPath = r'/admin/maintenance/login';
// ignore: prefer_final_locals
Object? postBody = maintenanceLoginDto;
final queryParams = <QueryParam>[];
final headerParams = <String, String>{};
final formParams = <String, String>{};
const contentTypes = <String>['application/json'];
return apiClient.invokeAPI(
apiPath,
'POST',
queryParams,
postBody,
headerParams,
formParams,
contentTypes.isEmpty ? null : contentTypes.first,
);
}
/// Log into maintenance mode
///
/// Login with maintenance token or cookie to receive current information and perform further actions.
///
/// Parameters:
///
/// * [MaintenanceLoginDto] maintenanceLoginDto (required):
Future<MaintenanceAuthDto?> maintenanceLogin(MaintenanceLoginDto maintenanceLoginDto,) async {
final response = await maintenanceLoginWithHttpInfo(maintenanceLoginDto,);
if (response.statusCode >= HttpStatus.badRequest) {
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
}
// When a remote server returns no body with a status of 204, we shall not decode it.
// At the time of writing this, `dart:convert` will throw an "Unexpected end of input"
// FormatException when trying to decode an empty string.
if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) {
return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'MaintenanceAuthDto',) as MaintenanceAuthDto;
}
return null;
}
/// Set maintenance mode
///
/// Put Immich into or take it out of maintenance mode
///
/// Note: This method returns the HTTP [Response].
///
/// Parameters:
///
/// * [SetMaintenanceModeDto] setMaintenanceModeDto (required):
Future<Response> setMaintenanceModeWithHttpInfo(SetMaintenanceModeDto setMaintenanceModeDto,) async {
// ignore: prefer_const_declarations
final apiPath = r'/admin/maintenance';
// ignore: prefer_final_locals
Object? postBody = setMaintenanceModeDto;
final queryParams = <QueryParam>[];
final headerParams = <String, String>{};
final formParams = <String, String>{};
const contentTypes = <String>['application/json'];
return apiClient.invokeAPI(
apiPath,
'POST',
queryParams,
postBody,
headerParams,
formParams,
contentTypes.isEmpty ? null : contentTypes.first,
);
}
/// Set maintenance mode
///
/// Put Immich into or take it out of maintenance mode
///
/// Parameters:
///
/// * [SetMaintenanceModeDto] setMaintenanceModeDto (required):
Future<void> setMaintenanceMode(SetMaintenanceModeDto setMaintenanceModeDto,) async {
final response = await setMaintenanceModeWithHttpInfo(setMaintenanceModeDto,);
if (response.statusCode >= HttpStatus.badRequest) {
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
}
}
}

View File

@@ -378,6 +378,12 @@ class ApiClient {
return LogoutResponseDto.fromJson(value);
case 'MachineLearningAvailabilityChecksDto':
return MachineLearningAvailabilityChecksDto.fromJson(value);
case 'MaintenanceAction':
return MaintenanceActionTypeTransformer().decode(value);
case 'MaintenanceAuthDto':
return MaintenanceAuthDto.fromJson(value);
case 'MaintenanceLoginDto':
return MaintenanceLoginDto.fromJson(value);
case 'ManualJobName':
return ManualJobNameTypeTransformer().decode(value);
case 'MapMarkerResponseDto':
@@ -562,6 +568,8 @@ class ApiClient {
return SessionUnlockDto.fromJson(value);
case 'SessionUpdateDto':
return SessionUpdateDto.fromJson(value);
case 'SetMaintenanceModeDto':
return SetMaintenanceModeDto.fromJson(value);
case 'SharedLinkCreateDto':
return SharedLinkCreateDto.fromJson(value);
case 'SharedLinkEditDto':

View File

@@ -97,6 +97,9 @@ String parameterToString(dynamic value) {
if (value is LogLevel) {
return LogLevelTypeTransformer().encode(value).toString();
}
if (value is MaintenanceAction) {
return MaintenanceActionTypeTransformer().encode(value).toString();
}
if (value is ManualJobName) {
return ManualJobNameTypeTransformer().encode(value).toString();
}

View File

@@ -0,0 +1,85 @@
//
// AUTO-GENERATED FILE, DO NOT MODIFY!
//
// @dart=2.18
// ignore_for_file: unused_element, unused_import
// ignore_for_file: always_put_required_named_parameters_first
// ignore_for_file: constant_identifier_names
// ignore_for_file: lines_longer_than_80_chars
part of openapi.api;
class MaintenanceAction {
/// Instantiate a new enum with the provided [value].
const MaintenanceAction._(this.value);
/// The underlying value of this enum member.
final String value;
@override
String toString() => value;
String toJson() => value;
static const start = MaintenanceAction._(r'start');
static const end = MaintenanceAction._(r'end');
/// List of all possible values in this [enum][MaintenanceAction].
static const values = <MaintenanceAction>[
start,
end,
];
static MaintenanceAction? fromJson(dynamic value) => MaintenanceActionTypeTransformer().decode(value);
static List<MaintenanceAction> listFromJson(dynamic json, {bool growable = false,}) {
final result = <MaintenanceAction>[];
if (json is List && json.isNotEmpty) {
for (final row in json) {
final value = MaintenanceAction.fromJson(row);
if (value != null) {
result.add(value);
}
}
}
return result.toList(growable: growable);
}
}
/// Transformation class that can [encode] an instance of [MaintenanceAction] to String,
/// and [decode] dynamic data back to [MaintenanceAction].
class MaintenanceActionTypeTransformer {
factory MaintenanceActionTypeTransformer() => _instance ??= const MaintenanceActionTypeTransformer._();
const MaintenanceActionTypeTransformer._();
String encode(MaintenanceAction data) => data.value;
/// Decodes a [dynamic value][data] to a MaintenanceAction.
///
/// If [allowNull] is true and the [dynamic value][data] cannot be decoded successfully,
/// then null is returned. However, if [allowNull] is false and the [dynamic value][data]
/// cannot be decoded successfully, then an [UnimplementedError] is thrown.
///
/// The [allowNull] is very handy when an API changes and a new enum value is added or removed,
/// and users are still using an old app with the old code.
MaintenanceAction? decode(dynamic data, {bool allowNull = true}) {
if (data != null) {
switch (data) {
case r'start': return MaintenanceAction.start;
case r'end': return MaintenanceAction.end;
default:
if (!allowNull) {
throw ArgumentError('Unknown enum value to decode: $data');
}
}
}
return null;
}
/// Singleton [MaintenanceActionTypeTransformer] instance.
static MaintenanceActionTypeTransformer? _instance;
}

View File

@@ -0,0 +1,99 @@
//
// AUTO-GENERATED FILE, DO NOT MODIFY!
//
// @dart=2.18
// ignore_for_file: unused_element, unused_import
// ignore_for_file: always_put_required_named_parameters_first
// ignore_for_file: constant_identifier_names
// ignore_for_file: lines_longer_than_80_chars
part of openapi.api;
class MaintenanceAuthDto {
/// Returns a new [MaintenanceAuthDto] instance.
MaintenanceAuthDto({
required this.username,
});
String username;
@override
bool operator ==(Object other) => identical(this, other) || other is MaintenanceAuthDto &&
other.username == username;
@override
int get hashCode =>
// ignore: unnecessary_parenthesis
(username.hashCode);
@override
String toString() => 'MaintenanceAuthDto[username=$username]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
json[r'username'] = this.username;
return json;
}
/// Returns a new [MaintenanceAuthDto] instance and imports its values from
/// [value] if it's a [Map], null otherwise.
// ignore: prefer_constructors_over_static_methods
static MaintenanceAuthDto? fromJson(dynamic value) {
upgradeDto(value, "MaintenanceAuthDto");
if (value is Map) {
final json = value.cast<String, dynamic>();
return MaintenanceAuthDto(
username: mapValueOfType<String>(json, r'username')!,
);
}
return null;
}
static List<MaintenanceAuthDto> listFromJson(dynamic json, {bool growable = false,}) {
final result = <MaintenanceAuthDto>[];
if (json is List && json.isNotEmpty) {
for (final row in json) {
final value = MaintenanceAuthDto.fromJson(row);
if (value != null) {
result.add(value);
}
}
}
return result.toList(growable: growable);
}
static Map<String, MaintenanceAuthDto> mapFromJson(dynamic json) {
final map = <String, MaintenanceAuthDto>{};
if (json is Map && json.isNotEmpty) {
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
for (final entry in json.entries) {
final value = MaintenanceAuthDto.fromJson(entry.value);
if (value != null) {
map[entry.key] = value;
}
}
}
return map;
}
// maps a json object with a list of MaintenanceAuthDto-objects as value to a dart map
static Map<String, List<MaintenanceAuthDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
final map = <String, List<MaintenanceAuthDto>>{};
if (json is Map && json.isNotEmpty) {
// ignore: parameter_assignments
json = json.cast<String, dynamic>();
for (final entry in json.entries) {
map[entry.key] = MaintenanceAuthDto.listFromJson(entry.value, growable: growable,);
}
}
return map;
}
/// The list of required keys that must be present in a JSON.
static const requiredKeys = <String>{
'username',
};
}

View File

@@ -0,0 +1,108 @@
//
// AUTO-GENERATED FILE, DO NOT MODIFY!
//
// @dart=2.18
// ignore_for_file: unused_element, unused_import
// ignore_for_file: always_put_required_named_parameters_first
// ignore_for_file: constant_identifier_names
// ignore_for_file: lines_longer_than_80_chars
part of openapi.api;
class MaintenanceLoginDto {
/// Returns a new [MaintenanceLoginDto] instance.
MaintenanceLoginDto({
this.token,
});
///
/// Please note: This property should have been non-nullable! Since the specification file
/// does not include a default value (using the "default:" property), however, the generated
/// source code must fall back to having a nullable type.
/// Consider adding a "default:" property in the specification file to hide this note.
///
String? token;
@override
bool operator ==(Object other) => identical(this, other) || other is MaintenanceLoginDto &&
other.token == token;
@override
int get hashCode =>
// ignore: unnecessary_parenthesis
(token == null ? 0 : token!.hashCode);
@override
String toString() => 'MaintenanceLoginDto[token=$token]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
if (this.token != null) {
json[r'token'] = this.token;
} else {
// json[r'token'] = null;
}
return json;
}
/// Returns a new [MaintenanceLoginDto] instance and imports its values from
/// [value] if it's a [Map], null otherwise.
// ignore: prefer_constructors_over_static_methods
static MaintenanceLoginDto? fromJson(dynamic value) {
upgradeDto(value, "MaintenanceLoginDto");
if (value is Map) {
final json = value.cast<String, dynamic>();
return MaintenanceLoginDto(
token: mapValueOfType<String>(json, r'token'),
);
}
return null;
}
static List<MaintenanceLoginDto> listFromJson(dynamic json, {bool growable = false,}) {
final result = <MaintenanceLoginDto>[];
if (json is List && json.isNotEmpty) {
for (final row in json) {
final value = MaintenanceLoginDto.fromJson(row);
if (value != null) {
result.add(value);
}
}
}
return result.toList(growable: growable);
}
static Map<String, MaintenanceLoginDto> mapFromJson(dynamic json) {
final map = <String, MaintenanceLoginDto>{};
if (json is Map && json.isNotEmpty) {
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
for (final entry in json.entries) {
final value = MaintenanceLoginDto.fromJson(entry.value);
if (value != null) {
map[entry.key] = value;
}
}
}
return map;
}
// maps a json object with a list of MaintenanceLoginDto-objects as value to a dart map
static Map<String, List<MaintenanceLoginDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
final map = <String, List<MaintenanceLoginDto>>{};
if (json is Map && json.isNotEmpty) {
// ignore: parameter_assignments
json = json.cast<String, dynamic>();
for (final entry in json.entries) {
map[entry.key] = MaintenanceLoginDto.listFromJson(entry.value, growable: growable,);
}
}
return map;
}
/// The list of required keys that must be present in a JSON.
static const requiredKeys = <String>{
};
}

View File

@@ -73,6 +73,7 @@ class Permission {
static const libraryPeriodStatistics = Permission._(r'library.statistics');
static const timelinePeriodRead = Permission._(r'timeline.read');
static const timelinePeriodDownload = Permission._(r'timeline.download');
static const maintenance = Permission._(r'maintenance');
static const memoryPeriodCreate = Permission._(r'memory.create');
static const memoryPeriodRead = Permission._(r'memory.read');
static const memoryPeriodUpdate = Permission._(r'memory.update');
@@ -214,6 +215,7 @@ class Permission {
libraryPeriodStatistics,
timelinePeriodRead,
timelinePeriodDownload,
maintenance,
memoryPeriodCreate,
memoryPeriodRead,
memoryPeriodUpdate,
@@ -390,6 +392,7 @@ class PermissionTypeTransformer {
case r'library.statistics': return Permission.libraryPeriodStatistics;
case r'timeline.read': return Permission.timelinePeriodRead;
case r'timeline.download': return Permission.timelinePeriodDownload;
case r'maintenance': return Permission.maintenance;
case r'memory.create': return Permission.memoryPeriodCreate;
case r'memory.read': return Permission.memoryPeriodRead;
case r'memory.update': return Permission.memoryPeriodUpdate;

View File

@@ -17,6 +17,7 @@ class ServerConfigDto {
required this.isInitialized,
required this.isOnboarded,
required this.loginPageMessage,
required this.maintenanceMode,
required this.mapDarkStyleUrl,
required this.mapLightStyleUrl,
required this.oauthButtonText,
@@ -33,6 +34,8 @@ class ServerConfigDto {
String loginPageMessage;
bool maintenanceMode;
String mapDarkStyleUrl;
String mapLightStyleUrl;
@@ -51,6 +54,7 @@ class ServerConfigDto {
other.isInitialized == isInitialized &&
other.isOnboarded == isOnboarded &&
other.loginPageMessage == loginPageMessage &&
other.maintenanceMode == maintenanceMode &&
other.mapDarkStyleUrl == mapDarkStyleUrl &&
other.mapLightStyleUrl == mapLightStyleUrl &&
other.oauthButtonText == oauthButtonText &&
@@ -65,6 +69,7 @@ class ServerConfigDto {
(isInitialized.hashCode) +
(isOnboarded.hashCode) +
(loginPageMessage.hashCode) +
(maintenanceMode.hashCode) +
(mapDarkStyleUrl.hashCode) +
(mapLightStyleUrl.hashCode) +
(oauthButtonText.hashCode) +
@@ -73,7 +78,7 @@ class ServerConfigDto {
(userDeleteDelay.hashCode);
@override
String toString() => 'ServerConfigDto[externalDomain=$externalDomain, isInitialized=$isInitialized, isOnboarded=$isOnboarded, loginPageMessage=$loginPageMessage, mapDarkStyleUrl=$mapDarkStyleUrl, mapLightStyleUrl=$mapLightStyleUrl, oauthButtonText=$oauthButtonText, publicUsers=$publicUsers, trashDays=$trashDays, userDeleteDelay=$userDeleteDelay]';
String toString() => 'ServerConfigDto[externalDomain=$externalDomain, isInitialized=$isInitialized, isOnboarded=$isOnboarded, loginPageMessage=$loginPageMessage, maintenanceMode=$maintenanceMode, mapDarkStyleUrl=$mapDarkStyleUrl, mapLightStyleUrl=$mapLightStyleUrl, oauthButtonText=$oauthButtonText, publicUsers=$publicUsers, trashDays=$trashDays, userDeleteDelay=$userDeleteDelay]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
@@ -81,6 +86,7 @@ class ServerConfigDto {
json[r'isInitialized'] = this.isInitialized;
json[r'isOnboarded'] = this.isOnboarded;
json[r'loginPageMessage'] = this.loginPageMessage;
json[r'maintenanceMode'] = this.maintenanceMode;
json[r'mapDarkStyleUrl'] = this.mapDarkStyleUrl;
json[r'mapLightStyleUrl'] = this.mapLightStyleUrl;
json[r'oauthButtonText'] = this.oauthButtonText;
@@ -103,6 +109,7 @@ class ServerConfigDto {
isInitialized: mapValueOfType<bool>(json, r'isInitialized')!,
isOnboarded: mapValueOfType<bool>(json, r'isOnboarded')!,
loginPageMessage: mapValueOfType<String>(json, r'loginPageMessage')!,
maintenanceMode: mapValueOfType<bool>(json, r'maintenanceMode')!,
mapDarkStyleUrl: mapValueOfType<String>(json, r'mapDarkStyleUrl')!,
mapLightStyleUrl: mapValueOfType<String>(json, r'mapLightStyleUrl')!,
oauthButtonText: mapValueOfType<String>(json, r'oauthButtonText')!,
@@ -160,6 +167,7 @@ class ServerConfigDto {
'isInitialized',
'isOnboarded',
'loginPageMessage',
'maintenanceMode',
'mapDarkStyleUrl',
'mapLightStyleUrl',
'oauthButtonText',

View File

@@ -0,0 +1,99 @@
//
// AUTO-GENERATED FILE, DO NOT MODIFY!
//
// @dart=2.18
// ignore_for_file: unused_element, unused_import
// ignore_for_file: always_put_required_named_parameters_first
// ignore_for_file: constant_identifier_names
// ignore_for_file: lines_longer_than_80_chars
part of openapi.api;
class SetMaintenanceModeDto {
/// Returns a new [SetMaintenanceModeDto] instance.
SetMaintenanceModeDto({
required this.action,
});
MaintenanceAction action;
@override
bool operator ==(Object other) => identical(this, other) || other is SetMaintenanceModeDto &&
other.action == action;
@override
int get hashCode =>
// ignore: unnecessary_parenthesis
(action.hashCode);
@override
String toString() => 'SetMaintenanceModeDto[action=$action]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
json[r'action'] = this.action;
return json;
}
/// Returns a new [SetMaintenanceModeDto] instance and imports its values from
/// [value] if it's a [Map], null otherwise.
// ignore: prefer_constructors_over_static_methods
static SetMaintenanceModeDto? fromJson(dynamic value) {
upgradeDto(value, "SetMaintenanceModeDto");
if (value is Map) {
final json = value.cast<String, dynamic>();
return SetMaintenanceModeDto(
action: MaintenanceAction.fromJson(json[r'action'])!,
);
}
return null;
}
static List<SetMaintenanceModeDto> listFromJson(dynamic json, {bool growable = false,}) {
final result = <SetMaintenanceModeDto>[];
if (json is List && json.isNotEmpty) {
for (final row in json) {
final value = SetMaintenanceModeDto.fromJson(row);
if (value != null) {
result.add(value);
}
}
}
return result.toList(growable: growable);
}
static Map<String, SetMaintenanceModeDto> mapFromJson(dynamic json) {
final map = <String, SetMaintenanceModeDto>{};
if (json is Map && json.isNotEmpty) {
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
for (final entry in json.entries) {
final value = SetMaintenanceModeDto.fromJson(entry.value);
if (value != null) {
map[entry.key] = value;
}
}
}
return map;
}
// maps a json object with a list of SetMaintenanceModeDto-objects as value to a dart map
static Map<String, List<SetMaintenanceModeDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
final map = <String, List<SetMaintenanceModeDto>>{};
if (json is Map && json.isNotEmpty) {
// ignore: parameter_assignments
json = json.cast<String, dynamic>();
for (final entry in json.entries) {
map[entry.key] = SetMaintenanceModeDto.listFromJson(entry.value, growable: growable,);
}
}
return map;
}
/// The list of required keys that must be present in a JSON.
static const requiredKeys = <String>{
'action',
};
}

View File

@@ -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"

View File

@@ -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:

View File

@@ -322,6 +322,100 @@
"x-immich-state": "Stable"
}
},
"/admin/maintenance": {
"post": {
"description": "Put Immich into or take it out of maintenance mode",
"operationId": "setMaintenanceMode",
"parameters": [],
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/SetMaintenanceModeDto"
}
}
},
"required": true
},
"responses": {
"201": {
"description": ""
}
},
"security": [
{
"bearer": []
},
{
"cookie": []
},
{
"api_key": []
}
],
"summary": "Set maintenance mode",
"tags": [
"Maintenance (admin)"
],
"x-immich-admin-only": true,
"x-immich-history": [
{
"version": "v2.3.0",
"state": "Added"
},
{
"version": "v2.3.0",
"state": "Alpha"
}
],
"x-immich-permission": "maintenance",
"x-immich-state": "Alpha"
}
},
"/admin/maintenance/login": {
"post": {
"description": "Login with maintenance token or cookie to receive current information and perform further actions.",
"operationId": "maintenanceLogin",
"parameters": [],
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/MaintenanceLoginDto"
}
}
},
"required": true
},
"responses": {
"201": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/MaintenanceAuthDto"
}
}
},
"description": ""
}
},
"summary": "Log into maintenance mode",
"tags": [
"Maintenance (admin)"
],
"x-immich-history": [
{
"version": "v2.3.0",
"state": "Added"
},
{
"version": "v2.3.0",
"state": "Alpha"
}
],
"x-immich-state": "Alpha"
}
},
"/admin/notifications": {
"post": {
"description": "Create a new notification for a specific user.",
@@ -13917,6 +14011,10 @@
"name": "Libraries",
"description": "An external library is made up of input file paths or expressions that are scanned for asset files. Discovered files are automatically imported. Assets much be unique within a library, but can be duplicated across libraries. Each user has a default upload library, and can have one or more external libraries."
},
{
"name": "Maintenance (admin)",
"description": "Maintenance mode allows you to put Immich in a read-only state to perform various operations."
},
{
"name": "Map",
"description": "Map endpoints include supplemental functionality related to geolocation, such as reverse geocoding and retrieving map markers for assets with geolocation data."
@@ -16425,6 +16523,32 @@
],
"type": "object"
},
"MaintenanceAction": {
"enum": [
"start",
"end"
],
"type": "string"
},
"MaintenanceAuthDto": {
"properties": {
"username": {
"type": "string"
}
},
"required": [
"username"
],
"type": "object"
},
"MaintenanceLoginDto": {
"properties": {
"token": {
"type": "string"
}
},
"type": "object"
},
"ManualJobName": {
"enum": [
"person-cleanup",
@@ -17380,6 +17504,7 @@
"library.statistics",
"timeline.read",
"timeline.download",
"maintenance",
"memory.create",
"memory.read",
"memory.update",
@@ -18587,6 +18712,9 @@
"loginPageMessage": {
"type": "string"
},
"maintenanceMode": {
"type": "boolean"
},
"mapDarkStyleUrl": {
"type": "string"
},
@@ -18611,6 +18739,7 @@
"isInitialized",
"isOnboarded",
"loginPageMessage",
"maintenanceMode",
"mapDarkStyleUrl",
"mapLightStyleUrl",
"oauthButtonText",
@@ -18996,6 +19125,21 @@
},
"type": "object"
},
"SetMaintenanceModeDto": {
"properties": {
"action": {
"allOf": [
{
"$ref": "#/components/schemas/MaintenanceAction"
}
]
}
},
"required": [
"action"
],
"type": "object"
},
"SharedLinkCreateDto": {
"properties": {
"albumId": {

View File

@@ -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": {

View File

@@ -40,6 +40,15 @@ export type ActivityStatisticsResponseDto = {
comments: number;
likes: number;
};
export type SetMaintenanceModeDto = {
action: MaintenanceAction;
};
export type MaintenanceLoginDto = {
token?: string;
};
export type MaintenanceAuthDto = {
username: string;
};
export type NotificationCreateDto = {
data?: object;
description?: string | null;
@@ -1183,6 +1192,7 @@ export type ServerConfigDto = {
isInitialized: boolean;
isOnboarded: boolean;
loginPageMessage: string;
maintenanceMode: boolean;
mapDarkStyleUrl: string;
mapLightStyleUrl: string;
oauthButtonText: string;
@@ -1822,6 +1832,33 @@ export function unlinkAllOAuthAccountsAdmin(opts?: Oazapfts.RequestOpts) {
method: "POST"
}));
}
/**
* Set maintenance mode
*/
export function setMaintenanceMode({ setMaintenanceModeDto }: {
setMaintenanceModeDto: SetMaintenanceModeDto;
}, opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchText("/admin/maintenance", oazapfts.json({
...opts,
method: "POST",
body: setMaintenanceModeDto
})));
}
/**
* Log into maintenance mode
*/
export function maintenanceLogin({ maintenanceLoginDto }: {
maintenanceLoginDto: MaintenanceLoginDto;
}, opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchJson<{
status: 201;
data: MaintenanceAuthDto;
}>("/admin/maintenance/login", oazapfts.json({
...opts,
method: "POST",
body: maintenanceLoginDto
})));
}
/**
* Create a notification
*/
@@ -5014,6 +5051,10 @@ export enum UserAvatarColor {
Gray = "gray",
Amber = "amber"
}
export enum MaintenanceAction {
Start = "start",
End = "end"
}
export enum NotificationLevel {
Success = "success",
Error = "error",
@@ -5121,6 +5162,7 @@ export enum Permission {
LibraryStatistics = "library.statistics",
TimelineRead = "timeline.read",
TimelineDownload = "timeline.download",
Maintenance = "maintenance",
MemoryCreate = "memory.create",
MemoryRead = "memory.read",
MemoryUpdate = "memory.update",

341
pnpm-lock.yaml generated
View File

@@ -63,11 +63,11 @@ importers:
specifier: ^4.13.1
version: 4.13.4
'@types/node':
specifier: ^22.19.0
version: 22.19.0
specifier: ^22.19.1
version: 22.19.1
'@vitest/coverage-v8':
specifier: ^3.0.0
version: 3.2.4(vitest@3.2.4(@types/debug@4.1.12)(@types/node@22.19.0)(happy-dom@20.0.10)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1))
version: 3.2.4(vitest@3.2.4(@types/debug@4.1.12)(@types/node@22.19.1)(happy-dom@20.0.10)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1))
byte-size:
specifier: ^9.0.0
version: 9.0.1
@@ -109,16 +109,16 @@ importers:
version: 8.46.2(eslint@9.38.0(jiti@2.6.1))(typescript@5.9.3)
vite:
specifier: ^7.0.0
version: 7.1.12(@types/node@22.19.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1)
version: 7.1.12(@types/node@22.19.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1)
vite-tsconfig-paths:
specifier: ^5.0.0
version: 5.1.4(typescript@5.9.3)(vite@7.1.12(@types/node@22.19.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1))
version: 5.1.4(typescript@5.9.3)(vite@7.1.12(@types/node@22.19.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1))
vitest:
specifier: ^3.0.0
version: 3.2.4(@types/debug@4.1.12)(@types/node@22.19.0)(happy-dom@20.0.10)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1)
version: 3.2.4(@types/debug@4.1.12)(@types/node@22.19.1)(happy-dom@20.0.10)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1)
vitest-fetch-mock:
specifier: ^0.4.0
version: 0.4.5(vitest@3.2.4(@types/debug@4.1.12)(@types/node@22.19.0)(happy-dom@20.0.10)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1))
version: 0.4.5(vitest@3.2.4(@types/debug@4.1.12)(@types/node@22.19.1)(happy-dom@20.0.10)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1))
yaml:
specifier: ^2.3.1
version: 2.8.1
@@ -211,8 +211,8 @@ importers:
specifier: ^3.4.2
version: 3.7.1
'@types/node':
specifier: ^22.19.0
version: 22.19.0
specifier: ^22.19.1
version: 22.19.1
'@types/oidc-provider':
specifier: ^9.0.0
version: 9.5.0
@@ -284,7 +284,7 @@ importers:
version: 5.2.1(encoding@0.1.13)
vitest:
specifier: ^3.0.0
version: 3.2.4(@types/debug@4.1.12)(@types/node@22.19.0)(happy-dom@20.0.10)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2(encoding@0.1.13)))(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1)
version: 3.2.4(@types/debug@4.1.12)(@types/node@22.19.1)(happy-dom@20.0.10)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2(encoding@0.1.13)))(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1)
open-api/typescript-sdk:
dependencies:
@@ -293,8 +293,8 @@ importers:
version: 1.0.4
devDependencies:
'@types/node':
specifier: ^22.19.0
version: 22.19.0
specifier: ^22.19.1
version: 22.19.1
typescript:
specifier: ^5.3.3
version: 5.9.3
@@ -445,6 +445,9 @@ importers:
ioredis:
specifier: ^5.8.2
version: 5.8.2
jose:
specifier: ^5.10.0
version: 5.10.0
js-yaml:
specifier: ^4.1.0
version: 4.1.0
@@ -471,7 +474,7 @@ importers:
version: 2.0.2
nest-commander:
specifier: ^3.16.0
version: 3.20.1(@nestjs/common@11.1.8(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.8)(@types/inquirer@8.2.11)(@types/node@22.19.0)(typescript@5.9.3)
version: 3.20.1(@nestjs/common@11.1.8(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.8)(@types/inquirer@8.2.11)(@types/node@22.19.1)(typescript@5.9.3)
nestjs-cls:
specifier: ^5.0.0
version: 5.4.3(@nestjs/common@11.1.8(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.8)(reflect-metadata@0.2.2)(rxjs@7.8.2)
@@ -553,7 +556,7 @@ importers:
version: 9.38.0
'@nestjs/cli':
specifier: ^11.0.2
version: 11.0.10(@swc/core@1.14.0(@swc/helpers@0.5.17))(@types/node@22.19.0)
version: 11.0.10(@swc/core@1.14.0(@swc/helpers@0.5.17))(@types/node@22.19.1)
'@nestjs/schematics':
specifier: ^11.0.0
version: 11.0.9(chokidar@4.0.3)(typescript@5.9.3)
@@ -606,8 +609,8 @@ importers:
specifier: ^2.0.0
version: 2.0.0
'@types/node':
specifier: ^22.19.0
version: 22.19.0
specifier: ^22.19.1
version: 22.19.1
'@types/nodemailer':
specifier: ^7.0.0
version: 7.0.3
@@ -637,7 +640,7 @@ importers:
version: 13.15.4
'@vitest/coverage-v8':
specifier: ^3.0.0
version: 3.2.4(vitest@3.2.4(@types/debug@4.1.12)(@types/node@22.19.0)(happy-dom@20.0.10)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1))
version: 3.2.4(vitest@3.2.4(@types/debug@4.1.12)(@types/node@22.19.1)(happy-dom@20.0.10)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1))
eslint:
specifier: ^9.14.0
version: 9.38.0(jiti@2.6.1)
@@ -691,10 +694,10 @@ importers:
version: 1.5.8(@swc/core@1.14.0(@swc/helpers@0.5.17))(rollup@4.52.5)
vite-tsconfig-paths:
specifier: ^5.0.0
version: 5.1.4(typescript@5.9.3)(vite@7.1.12(@types/node@22.19.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1))
version: 5.1.4(typescript@5.9.3)(vite@7.1.12(@types/node@22.19.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1))
vitest:
specifier: ^3.0.0
version: 3.2.4(@types/debug@4.1.12)(@types/node@22.19.0)(happy-dom@20.0.10)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1)
version: 3.2.4(@types/debug@4.1.12)(@types/node@22.19.1)(happy-dom@20.0.10)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1)
web:
dependencies:
@@ -4683,8 +4686,8 @@ packages:
'@types/node@20.19.24':
resolution: {integrity: sha512-FE5u0ezmi6y9OZEzlJfg37mqqf6ZDSF2V/NLjUyGrR9uTZ7Sb9F7bLNZ03S4XVUNRWGA7Ck4c1kK+YnuWjl+DA==}
'@types/node@22.19.0':
resolution: {integrity: sha512-xpr/lmLPQEj+TUnHmR+Ab91/glhJvsqcjB+yY0Ix9GO70H6Lb4FHH5GeqdOE5btAx7eIMwuHkp4H2MSkLcqWbA==}
'@types/node@22.19.1':
resolution: {integrity: sha512-LCCV0HdSZZZb34qifBsyWlUmok6W7ouER+oQIGBScS8EsZsQbrtFTUrDX4hOl+CS6p7cnNC4td+qrSVGSCTUfQ==}
'@types/node@24.10.0':
resolution: {integrity: sha512-qzQZRBqkFsYyaSWXuEHc2WR9c0a0CXwiE5FWUvn7ZM+vdy1uZLfCunD38UzhuB7YN/J11ndbDBcTmOdxJo9Q7A==}
@@ -11790,11 +11793,11 @@ snapshots:
optionalDependencies:
chokidar: 4.0.3
'@angular-devkit/schematics-cli@19.2.15(@types/node@22.19.0)(chokidar@4.0.3)':
'@angular-devkit/schematics-cli@19.2.15(@types/node@22.19.1)(chokidar@4.0.3)':
dependencies:
'@angular-devkit/core': 19.2.15(chokidar@4.0.3)
'@angular-devkit/schematics': 19.2.15(chokidar@4.0.3)
'@inquirer/prompts': 7.3.2(@types/node@22.19.0)
'@inquirer/prompts': 7.3.2(@types/node@22.19.1)
ansi-colors: 4.1.3
symbol-observable: 4.0.0
yargs-parser: 21.1.1
@@ -14444,27 +14447,27 @@ snapshots:
transitivePeerDependencies:
- '@internationalized/date'
'@inquirer/checkbox@4.2.1(@types/node@22.19.0)':
'@inquirer/checkbox@4.2.1(@types/node@22.19.1)':
dependencies:
'@inquirer/core': 10.1.15(@types/node@22.19.0)
'@inquirer/core': 10.1.15(@types/node@22.19.1)
'@inquirer/figures': 1.0.13
'@inquirer/type': 3.0.8(@types/node@22.19.0)
'@inquirer/type': 3.0.8(@types/node@22.19.1)
ansi-escapes: 4.3.2
yoctocolors-cjs: 2.1.2
optionalDependencies:
'@types/node': 22.19.0
'@types/node': 22.19.1
'@inquirer/confirm@5.1.15(@types/node@22.19.0)':
'@inquirer/confirm@5.1.15(@types/node@22.19.1)':
dependencies:
'@inquirer/core': 10.1.15(@types/node@22.19.0)
'@inquirer/type': 3.0.8(@types/node@22.19.0)
'@inquirer/core': 10.1.15(@types/node@22.19.1)
'@inquirer/type': 3.0.8(@types/node@22.19.1)
optionalDependencies:
'@types/node': 22.19.0
'@types/node': 22.19.1
'@inquirer/core@10.1.15(@types/node@22.19.0)':
'@inquirer/core@10.1.15(@types/node@22.19.1)':
dependencies:
'@inquirer/figures': 1.0.13
'@inquirer/type': 3.0.8(@types/node@22.19.0)
'@inquirer/type': 3.0.8(@types/node@22.19.1)
ansi-escapes: 4.3.2
cli-width: 4.1.0
mute-stream: 2.0.0
@@ -14472,115 +14475,115 @@ snapshots:
wrap-ansi: 6.2.0
yoctocolors-cjs: 2.1.2
optionalDependencies:
'@types/node': 22.19.0
'@types/node': 22.19.1
'@inquirer/editor@4.2.17(@types/node@22.19.0)':
'@inquirer/editor@4.2.17(@types/node@22.19.1)':
dependencies:
'@inquirer/core': 10.1.15(@types/node@22.19.0)
'@inquirer/external-editor': 1.0.2(@types/node@22.19.0)
'@inquirer/type': 3.0.8(@types/node@22.19.0)
'@inquirer/core': 10.1.15(@types/node@22.19.1)
'@inquirer/external-editor': 1.0.2(@types/node@22.19.1)
'@inquirer/type': 3.0.8(@types/node@22.19.1)
optionalDependencies:
'@types/node': 22.19.0
'@types/node': 22.19.1
'@inquirer/expand@4.0.17(@types/node@22.19.0)':
'@inquirer/expand@4.0.17(@types/node@22.19.1)':
dependencies:
'@inquirer/core': 10.1.15(@types/node@22.19.0)
'@inquirer/type': 3.0.8(@types/node@22.19.0)
'@inquirer/core': 10.1.15(@types/node@22.19.1)
'@inquirer/type': 3.0.8(@types/node@22.19.1)
yoctocolors-cjs: 2.1.2
optionalDependencies:
'@types/node': 22.19.0
'@types/node': 22.19.1
'@inquirer/external-editor@1.0.2(@types/node@22.19.0)':
'@inquirer/external-editor@1.0.2(@types/node@22.19.1)':
dependencies:
chardet: 2.1.0
iconv-lite: 0.7.0
optionalDependencies:
'@types/node': 22.19.0
'@types/node': 22.19.1
'@inquirer/figures@1.0.13': {}
'@inquirer/input@4.2.1(@types/node@22.19.0)':
'@inquirer/input@4.2.1(@types/node@22.19.1)':
dependencies:
'@inquirer/core': 10.1.15(@types/node@22.19.0)
'@inquirer/type': 3.0.8(@types/node@22.19.0)
'@inquirer/core': 10.1.15(@types/node@22.19.1)
'@inquirer/type': 3.0.8(@types/node@22.19.1)
optionalDependencies:
'@types/node': 22.19.0
'@types/node': 22.19.1
'@inquirer/number@3.0.17(@types/node@22.19.0)':
'@inquirer/number@3.0.17(@types/node@22.19.1)':
dependencies:
'@inquirer/core': 10.1.15(@types/node@22.19.0)
'@inquirer/type': 3.0.8(@types/node@22.19.0)
'@inquirer/core': 10.1.15(@types/node@22.19.1)
'@inquirer/type': 3.0.8(@types/node@22.19.1)
optionalDependencies:
'@types/node': 22.19.0
'@types/node': 22.19.1
'@inquirer/password@4.0.17(@types/node@22.19.0)':
'@inquirer/password@4.0.17(@types/node@22.19.1)':
dependencies:
'@inquirer/core': 10.1.15(@types/node@22.19.0)
'@inquirer/type': 3.0.8(@types/node@22.19.0)
'@inquirer/core': 10.1.15(@types/node@22.19.1)
'@inquirer/type': 3.0.8(@types/node@22.19.1)
ansi-escapes: 4.3.2
optionalDependencies:
'@types/node': 22.19.0
'@types/node': 22.19.1
'@inquirer/prompts@7.3.2(@types/node@22.19.0)':
'@inquirer/prompts@7.3.2(@types/node@22.19.1)':
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)
'@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/prompts@7.8.0(@types/node@22.19.0)':
'@inquirer/prompts@7.8.0(@types/node@22.19.1)':
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)
'@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:
@@ -14618,7 +14621,7 @@ snapshots:
'@jest/schemas': 29.6.3
'@types/istanbul-lib-coverage': 2.0.6
'@types/istanbul-reports': 3.0.4
'@types/node': 22.19.0
'@types/node': 22.19.1
'@types/yargs': 17.0.34
chalk: 4.1.2
@@ -14886,12 +14889,12 @@ snapshots:
bullmq: 5.62.1
tslib: 2.8.1
'@nestjs/cli@11.0.10(@swc/core@1.14.0(@swc/helpers@0.5.17))(@types/node@22.19.0)':
'@nestjs/cli@11.0.10(@swc/core@1.14.0(@swc/helpers@0.5.17))(@types/node@22.19.1)':
dependencies:
'@angular-devkit/core': 19.2.15(chokidar@4.0.3)
'@angular-devkit/schematics': 19.2.15(chokidar@4.0.3)
'@angular-devkit/schematics-cli': 19.2.15(@types/node@22.19.0)(chokidar@4.0.3)
'@inquirer/prompts': 7.8.0(@types/node@22.19.0)
'@angular-devkit/schematics-cli': 19.2.15(@types/node@22.19.1)(chokidar@4.0.3)
'@inquirer/prompts': 7.8.0(@types/node@22.19.1)
'@nestjs/schematics': 11.0.9(chokidar@4.0.3)(typescript@5.8.3)
ansis: 4.1.0
chokidar: 4.0.3
@@ -16291,7 +16294,7 @@ snapshots:
'@types/accepts@1.3.7':
dependencies:
'@types/node': 22.19.0
'@types/node': 22.19.1
'@types/archiver@6.0.4':
dependencies:
@@ -16303,16 +16306,16 @@ snapshots:
'@types/bcrypt@6.0.0':
dependencies:
'@types/node': 22.19.0
'@types/node': 22.19.1
'@types/body-parser@1.19.6':
dependencies:
'@types/connect': 3.4.38
'@types/node': 22.19.0
'@types/node': 22.19.1
'@types/bonjour@3.5.13':
dependencies:
'@types/node': 22.19.0
'@types/node': 22.19.1
'@types/braces@3.0.5': {}
@@ -16333,21 +16336,21 @@ snapshots:
'@types/cli-progress@3.11.6':
dependencies:
'@types/node': 22.19.0
'@types/node': 22.19.1
'@types/compression@1.8.1':
dependencies:
'@types/express': 5.0.5
'@types/node': 22.19.0
'@types/node': 22.19.1
'@types/connect-history-api-fallback@1.5.4':
dependencies:
'@types/express-serve-static-core': 5.1.0
'@types/node': 22.19.0
'@types/node': 22.19.1
'@types/connect@3.4.38':
dependencies:
'@types/node': 22.19.0
'@types/node': 22.19.1
'@types/content-disposition@0.5.9': {}
@@ -16364,11 +16367,11 @@ snapshots:
'@types/connect': 3.4.38
'@types/express': 5.0.5
'@types/keygrip': 1.0.6
'@types/node': 22.19.0
'@types/node': 22.19.1
'@types/cors@2.8.19':
dependencies:
'@types/node': 22.19.0
'@types/node': 22.19.1
'@types/debug@4.1.12':
dependencies:
@@ -16378,13 +16381,13 @@ snapshots:
'@types/docker-modem@3.0.6':
dependencies:
'@types/node': 22.19.0
'@types/node': 22.19.1
'@types/ssh2': 1.15.5
'@types/dockerode@3.3.45':
dependencies:
'@types/docker-modem': 3.0.6
'@types/node': 22.19.0
'@types/node': 22.19.1
'@types/ssh2': 1.15.5
'@types/dom-to-image@2.6.7': {}
@@ -16407,14 +16410,14 @@ snapshots:
'@types/express-serve-static-core@4.19.7':
dependencies:
'@types/node': 22.19.0
'@types/node': 22.19.1
'@types/qs': 6.14.0
'@types/range-parser': 1.2.7
'@types/send': 1.2.1
'@types/express-serve-static-core@5.1.0':
dependencies:
'@types/node': 22.19.0
'@types/node': 22.19.1
'@types/qs': 6.14.0
'@types/range-parser': 1.2.7
'@types/send': 1.2.1
@@ -16440,7 +16443,7 @@ snapshots:
'@types/fluent-ffmpeg@2.1.28':
dependencies:
'@types/node': 22.19.0
'@types/node': 22.19.1
'@types/geojson-vt@3.2.5':
dependencies:
@@ -16472,7 +16475,7 @@ snapshots:
'@types/http-proxy@1.17.17':
dependencies:
'@types/node': 22.19.0
'@types/node': 22.19.1
'@types/inquirer@8.2.11':
dependencies:
@@ -16496,7 +16499,7 @@ snapshots:
'@types/jsonwebtoken@9.0.10':
dependencies:
'@types/ms': 2.1.0
'@types/node': 22.19.0
'@types/node': 22.19.1
'@types/justified-layout@4.1.4': {}
@@ -16515,7 +16518,7 @@ snapshots:
'@types/http-errors': 2.0.5
'@types/keygrip': 1.0.6
'@types/koa-compose': 3.2.8
'@types/node': 22.19.0
'@types/node': 22.19.1
'@types/leaflet@1.9.21':
dependencies:
@@ -16545,7 +16548,7 @@ snapshots:
'@types/mock-fs@4.13.4':
dependencies:
'@types/node': 22.19.0
'@types/node': 22.19.1
'@types/ms@2.1.0': {}
@@ -16555,7 +16558,7 @@ snapshots:
'@types/node-forge@1.3.14':
dependencies:
'@types/node': 22.19.0
'@types/node': 22.19.1
'@types/node@17.0.45': {}
@@ -16567,7 +16570,7 @@ snapshots:
dependencies:
undici-types: 6.21.0
'@types/node@22.19.0':
'@types/node@22.19.1':
dependencies:
undici-types: 6.21.0
@@ -16579,7 +16582,7 @@ snapshots:
'@types/nodemailer@7.0.3':
dependencies:
'@aws-sdk/client-sesv2': 3.919.0
'@types/node': 22.19.0
'@types/node': 22.19.1
transitivePeerDependencies:
- aws-crt
@@ -16587,7 +16590,7 @@ snapshots:
dependencies:
'@types/keygrip': 1.0.6
'@types/koa': 3.0.0
'@types/node': 22.19.0
'@types/node': 22.19.1
'@types/parse5@5.0.3': {}
@@ -16597,13 +16600,13 @@ snapshots:
'@types/pg@8.15.5':
dependencies:
'@types/node': 22.19.0
'@types/node': 22.19.1
pg-protocol: 1.10.3
pg-types: 2.2.0
'@types/pg@8.15.6':
dependencies:
'@types/node': 22.19.0
'@types/node': 22.19.1
pg-protocol: 1.10.3
pg-types: 2.2.0
@@ -16611,13 +16614,13 @@ snapshots:
'@types/pngjs@6.0.5':
dependencies:
'@types/node': 22.19.0
'@types/node': 22.19.1
'@types/prismjs@1.26.5': {}
'@types/qrcode@1.5.6':
dependencies:
'@types/node': 22.19.0
'@types/node': 22.19.1
'@types/qs@6.14.0': {}
@@ -16646,7 +16649,7 @@ snapshots:
'@types/readdir-glob@1.1.5':
dependencies:
'@types/node': 22.19.0
'@types/node': 22.19.1
'@types/retry@0.12.2': {}
@@ -16656,18 +16659,18 @@ snapshots:
'@types/sax@1.2.7':
dependencies:
'@types/node': 22.19.0
'@types/node': 22.19.1
'@types/semver@7.7.1': {}
'@types/send@0.17.6':
dependencies:
'@types/mime': 1.3.5
'@types/node': 22.19.0
'@types/node': 22.19.1
'@types/send@1.2.1':
dependencies:
'@types/node': 22.19.0
'@types/node': 22.19.1
'@types/serve-index@1.9.4':
dependencies:
@@ -16676,20 +16679,20 @@ snapshots:
'@types/serve-static@1.15.10':
dependencies:
'@types/http-errors': 2.0.5
'@types/node': 22.19.0
'@types/node': 22.19.1
'@types/send': 0.17.6
'@types/sockjs@0.3.36':
dependencies:
'@types/node': 22.19.0
'@types/node': 22.19.1
'@types/ssh2-streams@0.1.13':
dependencies:
'@types/node': 22.19.0
'@types/node': 22.19.1
'@types/ssh2@0.5.52':
dependencies:
'@types/node': 22.19.0
'@types/node': 22.19.1
'@types/ssh2-streams': 0.1.13
'@types/ssh2@1.15.5':
@@ -16700,7 +16703,7 @@ snapshots:
dependencies:
'@types/cookiejar': 2.1.5
'@types/methods': 1.1.4
'@types/node': 22.19.0
'@types/node': 22.19.1
form-data: 4.0.4
'@types/supercluster@7.1.3':
@@ -16714,7 +16717,7 @@ snapshots:
'@types/through@0.0.33':
dependencies:
'@types/node': 22.19.0
'@types/node': 22.19.1
'@types/ua-parser-js@0.7.39': {}
@@ -16728,7 +16731,7 @@ snapshots:
'@types/ws@8.18.1':
dependencies:
'@types/node': 22.19.0
'@types/node': 22.19.1
'@types/yargs-parser@21.0.3': {}
@@ -16833,7 +16836,7 @@ snapshots:
'@vercel/oidc@3.0.3': {}
'@vitest/coverage-v8@3.2.4(vitest@3.2.4(@types/debug@4.1.12)(@types/node@22.19.0)(happy-dom@20.0.10)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1))':
'@vitest/coverage-v8@3.2.4(vitest@3.2.4(@types/debug@4.1.12)(@types/node@22.19.1)(happy-dom@20.0.10)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1))':
dependencies:
'@ampproject/remapping': 2.3.0
'@bcoe/v8-coverage': 1.0.2
@@ -16848,7 +16851,7 @@ snapshots:
std-env: 3.10.0
test-exclude: 7.0.1
tinyrainbow: 2.0.0
vitest: 3.2.4(@types/debug@4.1.12)(@types/node@22.19.0)(happy-dom@20.0.10)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1)
vitest: 3.2.4(@types/debug@4.1.12)(@types/node@22.19.1)(happy-dom@20.0.10)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1)
transitivePeerDependencies:
- supports-color
@@ -16879,13 +16882,13 @@ snapshots:
chai: 5.2.0
tinyrainbow: 2.0.0
'@vitest/mocker@3.2.4(vite@7.1.12(@types/node@22.19.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1))':
'@vitest/mocker@3.2.4(vite@7.1.12(@types/node@22.19.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1))':
dependencies:
'@vitest/spy': 3.2.4
estree-walker: 3.0.3
magic-string: 0.30.21
optionalDependencies:
vite: 7.1.12(@types/node@22.19.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1)
vite: 7.1.12(@types/node@22.19.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1)
'@vitest/mocker@3.2.4(vite@7.1.12(@types/node@24.10.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1))':
dependencies:
@@ -18477,7 +18480,7 @@ snapshots:
engine.io@6.6.4:
dependencies:
'@types/cors': 2.8.19
'@types/node': 22.19.0
'@types/node': 22.19.1
accepts: 1.3.8
base64id: 2.0.0
cookie: 0.7.2
@@ -18866,7 +18869,7 @@ snapshots:
eval@0.1.8:
dependencies:
'@types/node': 22.19.0
'@types/node': 22.19.1
require-like: 0.1.2
event-emitter@0.3.5:
@@ -19855,9 +19858,9 @@ snapshots:
inline-style-parser@0.2.4: {}
inquirer@8.2.7(@types/node@22.19.0):
inquirer@8.2.7(@types/node@22.19.1):
dependencies:
'@inquirer/external-editor': 1.0.2(@types/node@22.19.0)
'@inquirer/external-editor': 1.0.2(@types/node@22.19.1)
ansi-escapes: 4.3.2
chalk: 4.1.2
cli-cursor: 3.1.0
@@ -20071,7 +20074,7 @@ snapshots:
jest-util@29.7.0:
dependencies:
'@jest/types': 29.6.3
'@types/node': 22.19.0
'@types/node': 22.19.1
chalk: 4.1.2
ci-info: 3.9.0
graceful-fs: 4.2.11
@@ -20079,13 +20082,13 @@ snapshots:
jest-worker@27.5.1:
dependencies:
'@types/node': 22.19.0
'@types/node': 22.19.1
merge-stream: 2.0.0
supports-color: 8.1.1
jest-worker@29.7.0:
dependencies:
'@types/node': 22.19.0
'@types/node': 22.19.1
jest-util: 29.7.0
merge-stream: 2.0.0
supports-color: 8.1.1
@@ -21343,7 +21346,7 @@ snapshots:
neo-async@2.6.2: {}
nest-commander@3.20.1(@nestjs/common@11.1.8(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.8)(@types/inquirer@8.2.11)(@types/node@22.19.0)(typescript@5.9.3):
nest-commander@3.20.1(@nestjs/common@11.1.8(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.8)(@types/inquirer@8.2.11)(@types/node@22.19.1)(typescript@5.9.3):
dependencies:
'@fig/complete-commander': 3.2.0(commander@11.1.0)
'@golevelup/nestjs-discovery': 5.0.0(@nestjs/common@11.1.8(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.8)
@@ -21352,7 +21355,7 @@ snapshots:
'@types/inquirer': 8.2.11
commander: 11.1.0
cosmiconfig: 8.3.6(typescript@5.9.3)
inquirer: 8.2.7(@types/node@22.19.0)
inquirer: 8.2.7(@types/node@22.19.1)
transitivePeerDependencies:
- '@types/node'
- typescript
@@ -22450,7 +22453,7 @@ snapshots:
'@protobufjs/path': 1.1.2
'@protobufjs/pool': 1.1.0
'@protobufjs/utf8': 1.1.0
'@types/node': 22.19.0
'@types/node': 22.19.1
long: 5.3.2
protocol-buffers-schema@3.6.0: {}
@@ -24347,13 +24350,13 @@ snapshots:
- rollup
- supports-color
vite-node@3.2.4(@types/node@22.19.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1):
vite-node@3.2.4(@types/node@22.19.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1):
dependencies:
cac: 6.7.14
debug: 4.4.3
es-module-lexer: 1.7.0
pathe: 2.0.3
vite: 7.1.12(@types/node@22.19.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1)
vite: 7.1.12(@types/node@22.19.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1)
transitivePeerDependencies:
- '@types/node'
- jiti
@@ -24389,18 +24392,18 @@ snapshots:
- tsx
- yaml
vite-tsconfig-paths@5.1.4(typescript@5.9.3)(vite@7.1.12(@types/node@22.19.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1)):
vite-tsconfig-paths@5.1.4(typescript@5.9.3)(vite@7.1.12(@types/node@22.19.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1)):
dependencies:
debug: 4.4.3
globrex: 0.1.2
tsconfck: 3.1.6(typescript@5.9.3)
optionalDependencies:
vite: 7.1.12(@types/node@22.19.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1)
vite: 7.1.12(@types/node@22.19.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1)
transitivePeerDependencies:
- supports-color
- typescript
vite@7.1.12(@types/node@22.19.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1):
vite@7.1.12(@types/node@22.19.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1):
dependencies:
esbuild: 0.25.12
fdir: 6.5.0(picomatch@4.0.3)
@@ -24409,7 +24412,7 @@ snapshots:
rollup: 4.52.5
tinyglobby: 0.2.15
optionalDependencies:
'@types/node': 22.19.0
'@types/node': 22.19.1
fsevents: 2.3.3
jiti: 2.6.1
lightningcss: 1.30.2
@@ -24436,15 +24439,15 @@ snapshots:
optionalDependencies:
vite: 7.1.12(@types/node@24.10.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1)
vitest-fetch-mock@0.4.5(vitest@3.2.4(@types/debug@4.1.12)(@types/node@22.19.0)(happy-dom@20.0.10)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1)):
vitest-fetch-mock@0.4.5(vitest@3.2.4(@types/debug@4.1.12)(@types/node@22.19.1)(happy-dom@20.0.10)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1)):
dependencies:
vitest: 3.2.4(@types/debug@4.1.12)(@types/node@22.19.0)(happy-dom@20.0.10)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1)
vitest: 3.2.4(@types/debug@4.1.12)(@types/node@22.19.1)(happy-dom@20.0.10)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1)
vitest@3.2.4(@types/debug@4.1.12)(@types/node@22.19.0)(happy-dom@20.0.10)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2(encoding@0.1.13)))(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1):
vitest@3.2.4(@types/debug@4.1.12)(@types/node@22.19.1)(happy-dom@20.0.10)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2(encoding@0.1.13)))(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1):
dependencies:
'@types/chai': 5.2.2
'@vitest/expect': 3.2.4
'@vitest/mocker': 3.2.4(vite@7.1.12(@types/node@22.19.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1))
'@vitest/mocker': 3.2.4(vite@7.1.12(@types/node@22.19.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1))
'@vitest/pretty-format': 3.2.4
'@vitest/runner': 3.2.4
'@vitest/snapshot': 3.2.4
@@ -24462,12 +24465,12 @@ snapshots:
tinyglobby: 0.2.15
tinypool: 1.1.1
tinyrainbow: 2.0.0
vite: 7.1.12(@types/node@22.19.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1)
vite-node: 3.2.4(@types/node@22.19.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1)
vite: 7.1.12(@types/node@22.19.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1)
vite-node: 3.2.4(@types/node@22.19.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1)
why-is-node-running: 2.3.0
optionalDependencies:
'@types/debug': 4.1.12
'@types/node': 22.19.0
'@types/node': 22.19.1
happy-dom: 20.0.10
jsdom: 26.1.0(canvas@2.11.2(encoding@0.1.13))
transitivePeerDependencies:
@@ -24484,11 +24487,11 @@ snapshots:
- tsx
- yaml
vitest@3.2.4(@types/debug@4.1.12)(@types/node@22.19.0)(happy-dom@20.0.10)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1):
vitest@3.2.4(@types/debug@4.1.12)(@types/node@22.19.1)(happy-dom@20.0.10)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1):
dependencies:
'@types/chai': 5.2.2
'@vitest/expect': 3.2.4
'@vitest/mocker': 3.2.4(vite@7.1.12(@types/node@22.19.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1))
'@vitest/mocker': 3.2.4(vite@7.1.12(@types/node@22.19.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1))
'@vitest/pretty-format': 3.2.4
'@vitest/runner': 3.2.4
'@vitest/snapshot': 3.2.4
@@ -24506,12 +24509,12 @@ snapshots:
tinyglobby: 0.2.15
tinypool: 1.1.1
tinyrainbow: 2.0.0
vite: 7.1.12(@types/node@22.19.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1)
vite-node: 3.2.4(@types/node@22.19.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1)
vite: 7.1.12(@types/node@22.19.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1)
vite-node: 3.2.4(@types/node@22.19.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1)
why-is-node-running: 2.3.0
optionalDependencies:
'@types/debug': 4.1.12
'@types/node': 22.19.0
'@types/node': 22.19.1
happy-dom: 20.0.10
jsdom: 26.1.0(canvas@2.11.2)
transitivePeerDependencies:

View File

@@ -50,7 +50,7 @@ RUN --mount=type=cache,id=pnpm-cli,target=/buildcache/pnpm-store \
FROM builder AS plugins
COPY --from=ghcr.io/jdx/mise:2025.11.3 /usr/local/bin/mise /usr/local/bin/mise
COPY --from=ghcr.io/jdx/mise:2025.11.3@sha256:ac26f5978c0e2783f3e68e58ce75eddb83e41b89bf8747c503bac2aa9baf22c5 /usr/local/bin/mise /usr/local/bin/mise
WORKDIR /usr/src/app
COPY ./plugins/mise.toml ./plugins/

View File

@@ -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

View File

@@ -78,6 +78,7 @@
"handlebars": "^4.7.8",
"i18n-iso-countries": "^7.6.0",
"ioredis": "^5.8.2",
"jose": "^5.10.0",
"js-yaml": "^4.1.0",
"jsonwebtoken": "^9.0.2",
"kysely": "0.28.2",
@@ -133,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",

87
server/src/app.common.ts Normal file
View File

@@ -0,0 +1,87 @@
import { NestExpressApplication } from '@nestjs/platform-express';
import { json } from 'body-parser';
import compression from 'compression';
import cookieParser from 'cookie-parser';
import { existsSync } from 'node:fs';
import sirv from 'sirv';
import { excludePaths, serverVersion } from 'src/constants';
import { MaintenanceWorkerService } from 'src/maintenance/maintenance-worker.service';
import { WebSocketAdapter } from 'src/middleware/websocket.adapter';
import { ConfigRepository } from 'src/repositories/config.repository';
import { LoggingRepository } from 'src/repositories/logging.repository';
import { bootstrapTelemetry } from 'src/repositories/telemetry.repository';
import { ApiService } from 'src/services/api.service';
import { useSwagger } from 'src/utils/misc';
export function configureTelemetry() {
const { telemetry } = new ConfigRepository().getEnv();
if (telemetry.metrics.size > 0) {
bootstrapTelemetry(telemetry.apiPort);
}
}
export async function configureExpress(
app: NestExpressApplication,
{
permitSwaggerWrite = true,
ssr,
}: {
/**
* Whether to allow swagger module to write to the specs.json
* This is not desirable when the API is not available
* @default true
*/
permitSwaggerWrite?: boolean;
/**
* Service to use for server-side rendering
*/
ssr: typeof ApiService | typeof MaintenanceWorkerService;
},
) {
const configRepository = app.get(ConfigRepository);
const { environment, host, port, resourcePaths, network } = configRepository.getEnv();
const logger = await app.resolve(LoggingRepository);
logger.setContext('Bootstrap');
app.useLogger(logger);
app.set('trust proxy', ['loopback', ...network.trustedProxies]);
app.set('etag', 'strong');
app.use(cookieParser());
app.use(json({ limit: '10mb' }));
if (configRepository.isDev()) {
app.enableCors();
}
app.setGlobalPrefix('api', { exclude: excludePaths });
app.useWebSocketAdapter(new WebSocketAdapter(app));
useSwagger(app, { write: configRepository.isDev() && permitSwaggerWrite });
if (existsSync(resourcePaths.web.root)) {
// copied from https://github.com/sveltejs/kit/blob/679b5989fe62e3964b9a73b712d7b41831aa1f07/packages/adapter-node/src/handler.js#L46
// provides serving of precompressed assets and caching of immutable assets
app.use(
sirv(resourcePaths.web.root, {
etag: true,
gzip: true,
brotli: true,
extensions: [],
setHeaders: (res, pathname) => {
if (pathname.startsWith(`/_app/immutable`) && res.statusCode === 200) {
res.setHeader('cache-control', 'public,max-age=31536000,immutable');
}
},
}),
);
}
app.use(app.get(ssr).ssr(excludePaths));
app.use(compression());
const server = await (host ? app.listen(port, host) : app.listen(port));
server.requestTimeout = 24 * 60 * 60 * 1000;
logger.log(`Immich Server is listening on ${await app.getUrl()} [v${serverVersion}] [${environment}] `);
}

View File

@@ -9,15 +9,21 @@ import { commandsAndQuestions } from 'src/commands';
import { IWorker } from 'src/constants';
import { controllers } from 'src/controllers';
import { ImmichWorker } from 'src/enum';
import { MaintenanceAuthGuard } from 'src/maintenance/maintenance-auth.guard';
import { MaintenanceWebsocketRepository } from 'src/maintenance/maintenance-websocket.repository';
import { MaintenanceWorkerController } from 'src/maintenance/maintenance-worker.controller';
import { MaintenanceWorkerService } from 'src/maintenance/maintenance-worker.service';
import { AuthGuard } from 'src/middleware/auth.guard';
import { ErrorInterceptor } from 'src/middleware/error.interceptor';
import { FileUploadInterceptor } from 'src/middleware/file-upload.interceptor';
import { GlobalExceptionFilter } from 'src/middleware/global-exception.filter';
import { LoggingInterceptor } from 'src/middleware/logging.interceptor';
import { repositories } from 'src/repositories';
import { AppRepository } from 'src/repositories/app.repository';
import { ConfigRepository } from 'src/repositories/config.repository';
import { EventRepository } from 'src/repositories/event.repository';
import { LoggingRepository } from 'src/repositories/logging.repository';
import { SystemMetadataRepository } from 'src/repositories/system-metadata.repository';
import { teardownTelemetry, TelemetryRepository } from 'src/repositories/telemetry.repository';
import { WebsocketRepository } from 'src/repositories/websocket.repository';
import { services } from 'src/services';
@@ -28,27 +34,27 @@ import { getKyselyConfig } from 'src/utils/database';
const common = [...repositories, ...services, GlobalExceptionFilter];
export const middleware = [
FileUploadInterceptor,
const commonMiddleware = [
{ provide: APP_FILTER, useClass: GlobalExceptionFilter },
{ provide: APP_PIPE, useValue: new ValidationPipe({ transform: true, whitelist: true }) },
{ provide: APP_INTERCEPTOR, useClass: LoggingInterceptor },
{ provide: APP_INTERCEPTOR, useClass: ErrorInterceptor },
{ provide: APP_GUARD, useClass: AuthGuard },
];
const apiMiddleware = [FileUploadInterceptor, ...commonMiddleware, { provide: APP_GUARD, useClass: AuthGuard }];
const configRepository = new ConfigRepository();
const { bull, cls, database, otel } = configRepository.getEnv();
const imports = [
BullModule.forRoot(bull.config),
BullModule.registerQueue(...bull.queues),
const commonImports = [
ClsModule.forRoot(cls.config),
OpenTelemetryModule.forRoot(otel),
KyselyModule.forRoot(getKyselyConfig(database.config)),
OpenTelemetryModule.forRoot(otel),
];
class BaseModule implements OnModuleInit, OnModuleDestroy {
const bullImports = [BullModule.forRoot(bull.config), BullModule.registerQueue(...bull.queues)];
export class BaseModule implements OnModuleInit, OnModuleDestroy {
constructor(
@Inject(IWorker) private worker: ImmichWorker,
logger: LoggingRepository,
@@ -85,20 +91,44 @@ class BaseModule implements OnModuleInit, OnModuleDestroy {
}
@Module({
imports: [...imports, ScheduleModule.forRoot()],
imports: [...bullImports, ...commonImports, ScheduleModule.forRoot()],
controllers: [...controllers],
providers: [...common, ...middleware, { provide: IWorker, useValue: ImmichWorker.Api }],
providers: [...common, ...apiMiddleware, { provide: IWorker, useValue: ImmichWorker.Api }],
})
export class ApiModule extends BaseModule {}
@Module({
imports: [...imports],
imports: [...commonImports],
controllers: [MaintenanceWorkerController],
providers: [
ConfigRepository,
LoggingRepository,
SystemMetadataRepository,
AppRepository,
MaintenanceWebsocketRepository,
MaintenanceWorkerService,
...commonMiddleware,
{ provide: APP_GUARD, useClass: MaintenanceAuthGuard },
{ provide: IWorker, useValue: ImmichWorker.Maintenance },
],
})
export class MaintenanceModule {
constructor(
@Inject(IWorker) private worker: ImmichWorker,
logger: LoggingRepository,
) {
logger.setAppName(this.worker);
}
}
@Module({
imports: [...bullImports, ...commonImports],
providers: [...common, { provide: IWorker, useValue: ImmichWorker.Microservices }, SchedulerRegistry],
})
export class MicroservicesModule extends BaseModule {}
@Module({
imports: [...imports],
imports: [...bullImports, ...commonImports],
providers: [...common, ...commandsAndQuestions, SchedulerRegistry],
})
export class ImmichAdminModule implements OnModuleDestroy {

View File

@@ -1,5 +1,6 @@
import { GrantAdminCommand, PromptEmailQuestion, RevokeAdminCommand } from 'src/commands/grant-admin';
import { ListUsersCommand } from 'src/commands/list-users.command';
import { DisableMaintenanceModeCommand, EnableMaintenanceModeCommand } from 'src/commands/maintenance-mode';
import {
ChangeMediaLocationCommand,
PromptConfirmMoveQuestions,
@@ -16,6 +17,8 @@ export const commandsAndQuestions = [
PromptEmailQuestion,
EnablePasswordLoginCommand,
DisablePasswordLoginCommand,
EnableMaintenanceModeCommand,
DisableMaintenanceModeCommand,
EnableOAuthLogin,
DisableOAuthLogin,
ListUsersCommand,

View File

@@ -0,0 +1,37 @@
import { Command, CommandRunner } from 'nest-commander';
import { CliService } from 'src/services/cli.service';
@Command({
name: 'enable-maintenance-mode',
description: 'Enable maintenance mode or regenerate the maintenance token',
})
export class EnableMaintenanceModeCommand extends CommandRunner {
constructor(private service: CliService) {
super();
}
async run(): Promise<void> {
const { authUrl, alreadyEnabled } = await this.service.enableMaintenanceMode();
console.info(alreadyEnabled ? 'The server is already in maintenance mode!' : 'Maintenance mode has been enabled.');
console.info(`\nLog in using the following URL:\n${authUrl}`);
}
}
@Command({
name: 'disable-maintenance-mode',
description: 'Disable maintenance mode',
})
export class DisableMaintenanceModeCommand extends CommandRunner {
constructor(private service: CliService) {
super();
}
async run(): Promise<void> {
const { alreadyDisabled } = await this.service.disableMaintenanceMode();
console.log(
alreadyDisabled ? 'The server is already out of maintenance mode!' : 'Maintenance mode has been disabled.',
);
}
}

View File

@@ -150,6 +150,7 @@ export const endpointTags: Record<ApiTag, string> = {
'Queues and background jobs are used for processing tasks asynchronously. Queues can be paused and resumed as needed.',
[ApiTag.Libraries]:
'An external library is made up of input file paths or expressions that are scanned for asset files. Discovered files are automatically imported. Assets much be unique within a library, but can be duplicated across libraries. Each user has a default upload library, and can have one or more external libraries.',
[ApiTag.Maintenance]: 'Maintenance mode allows you to put Immich in a read-only state to perform various operations.',
[ApiTag.Map]:
'Map endpoints include supplemental functionality related to geolocation, such as reverse geocoding and retrieving map markers for assets with geolocation data.',
[ApiTag.Memories]:

View File

@@ -11,6 +11,7 @@ import { DuplicateController } from 'src/controllers/duplicate.controller';
import { FaceController } from 'src/controllers/face.controller';
import { JobController } from 'src/controllers/job.controller';
import { LibraryController } from 'src/controllers/library.controller';
import { MaintenanceController } from 'src/controllers/maintenance.controller';
import { MapController } from 'src/controllers/map.controller';
import { MemoryController } from 'src/controllers/memory.controller';
import { NotificationAdminController } from 'src/controllers/notification-admin.controller';
@@ -49,6 +50,7 @@ export const controllers = [
FaceController,
JobController,
LibraryController,
MaintenanceController,
MapController,
MemoryController,
NotificationController,

View File

@@ -0,0 +1,49 @@
import { BadRequestException, Body, Controller, Post, Res } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { Response } from 'express';
import { Endpoint, HistoryBuilder } from 'src/decorators';
import { AuthDto } from 'src/dtos/auth.dto';
import { MaintenanceAuthDto, MaintenanceLoginDto, SetMaintenanceModeDto } from 'src/dtos/maintenance.dto';
import { ApiTag, ImmichCookie, MaintenanceAction, Permission } from 'src/enum';
import { Auth, Authenticated, GetLoginDetails } from 'src/middleware/auth.guard';
import { LoginDetails } from 'src/services/auth.service';
import { MaintenanceService } from 'src/services/maintenance.service';
import { respondWithCookie } from 'src/utils/response';
@ApiTags(ApiTag.Maintenance)
@Controller('admin/maintenance')
export class MaintenanceController {
constructor(private service: MaintenanceService) {}
@Post('login')
@Endpoint({
summary: 'Log into maintenance mode',
description: 'Login with maintenance token or cookie to receive current information and perform further actions.',
history: new HistoryBuilder().added('v2.3.0').alpha('v2.3.0'),
})
maintenanceLogin(@Body() _dto: MaintenanceLoginDto): MaintenanceAuthDto {
throw new BadRequestException('Not in maintenance mode');
}
@Post()
@Endpoint({
summary: 'Set maintenance mode',
description: 'Put Immich into or take it out of maintenance mode',
history: new HistoryBuilder().added('v2.3.0').alpha('v2.3.0'),
})
@Authenticated({ permission: Permission.Maintenance, admin: true })
async setMaintenanceMode(
@Auth() auth: AuthDto,
@Body() dto: SetMaintenanceModeDto,
@GetLoginDetails() loginDetails: LoginDetails,
@Res({ passthrough: true }) res: Response,
): Promise<void> {
if (dto.action === MaintenanceAction.Start) {
const { jwt } = await this.service.startMaintenance(auth.user.name);
return respondWithCookie(res, undefined, {
isSecure: loginDetails.isSecure,
values: [{ key: ImmichCookie.MaintenanceToken, value: jwt }],
});
}
}
}

View File

@@ -0,0 +1,16 @@
import { MaintenanceAction } from 'src/enum';
import { ValidateEnum, ValidateString } from 'src/validation';
export class SetMaintenanceModeDto {
@ValidateEnum({ enum: MaintenanceAction, name: 'MaintenanceAction' })
action!: MaintenanceAction;
}
export class MaintenanceLoginDto {
@ValidateString({ optional: true })
token?: string;
}
export class MaintenanceAuthDto {
username!: string;
}

View File

@@ -154,6 +154,7 @@ export class ServerConfigDto {
publicUsers!: boolean;
mapDarkStyleUrl!: string;
mapLightStyleUrl!: string;
maintenanceMode!: boolean;
}
export class ServerFeaturesDto {

View File

@@ -5,6 +5,7 @@ export enum AuthType {
export enum ImmichCookie {
AccessToken = 'immich_access_token',
MaintenanceToken = 'immich_maintenance_token',
AuthType = 'immich_auth_type',
IsAuthenticated = 'immich_is_authenticated',
SharedLinkToken = 'immich_shared_link_token',
@@ -146,6 +147,8 @@ export enum Permission {
TimelineRead = 'timeline.read',
TimelineDownload = 'timeline.download',
Maintenance = 'maintenance',
MemoryCreate = 'memory.create',
MemoryRead = 'memory.read',
MemoryUpdate = 'memory.update',
@@ -285,6 +288,7 @@ export enum SystemMetadataKey {
FacialRecognitionState = 'facial-recognition-state',
MemoriesState = 'memories-state',
AdminOnboarding = 'admin-onboarding',
MaintenanceMode = 'maintenance-mode',
SystemConfig = 'system-config',
SystemFlags = 'system-flags',
VersionCheckState = 'version-check-state',
@@ -477,6 +481,7 @@ export enum ImmichEnvironment {
export enum ImmichWorker {
Api = 'api',
Maintenance = 'maintenance',
Microservices = 'microservices',
}
@@ -655,6 +660,15 @@ export enum DatabaseLock {
MemoryCreation = 777,
}
export enum MaintenanceAction {
Start = 'start',
End = 'end',
}
export enum ExitCode {
AppRestart = 7,
}
export enum SyncRequestType {
AlbumsV1 = 'AlbumsV1',
AlbumUsersV1 = 'AlbumUsersV1',
@@ -801,6 +815,7 @@ export enum ApiTag {
Faces = 'Faces',
Jobs = 'Jobs',
Libraries = 'Libraries',
Maintenance = 'Maintenance (admin)',
Map = 'Map',
Memories = 'Memories',
Notifications = 'Notifications',

View File

@@ -1,61 +1,151 @@
import { Kysely } from 'kysely';
import { CommandFactory } from 'nest-commander';
import { ChildProcess, fork } from 'node:child_process';
import { dirname, join } from 'node:path';
import { Worker } from 'node:worker_threads';
import { PostgresError } from 'postgres';
import { ImmichAdminModule } from 'src/app.module';
import { ImmichWorker, LogLevel } from 'src/enum';
import { ExitCode, ImmichWorker, LogLevel, SystemMetadataKey } from 'src/enum';
import { ConfigRepository } from 'src/repositories/config.repository';
import { SystemMetadataRepository } from 'src/repositories/system-metadata.repository';
import { type DB } from 'src/schema';
import { getKyselyConfig } from 'src/utils/database';
const immichApp = process.argv[2];
if (immichApp) {
process.argv.splice(2, 1);
}
/**
* Manages worker lifecycle
*/
class Workers {
/**
* Currently running workers
*/
workers: Partial<Record<ImmichWorker, { kill: (signal: NodeJS.Signals) => Promise<void> | void }>> = {};
let apiProcess: ChildProcess | undefined;
/**
* Fail-safe in case anything dies during restart
*/
restarting = false;
const onError = (name: string, error: Error) => {
console.error(`${name} worker error: ${error}, stack: ${error.stack}`);
};
/**
* Boot all enabled workers
*/
async bootstrap() {
const isMaintenanceMode = await this.isMaintenanceMode();
const { workers } = new ConfigRepository().getEnv();
const onExit = (name: string, exitCode: number | null) => {
if (exitCode !== 0) {
console.error(`${name} worker exited with code ${exitCode}`);
if (apiProcess && name !== ImmichWorker.Api) {
console.error('Killing api process');
apiProcess.kill('SIGTERM');
apiProcess = undefined;
if (isMaintenanceMode) {
this.startWorker(ImmichWorker.Maintenance);
} else {
for (const worker of workers) {
this.startWorker(worker);
}
}
}
process.exit(exitCode);
};
/**
* Initialise a short-lived Nest application to build configuration
* @returns System configuration
*/
private async isMaintenanceMode(): Promise<boolean> {
const { database } = new ConfigRepository().getEnv();
const kysely = new Kysely<DB>(getKyselyConfig(database.config));
const systemMetadataRepository = new SystemMetadataRepository(kysely);
function bootstrapWorker(name: ImmichWorker) {
console.log(`Starting ${name} worker`);
try {
const value = await systemMetadataRepository.get(SystemMetadataKey.MaintenanceMode);
return value?.isMaintenanceMode || false;
} catch (error) {
// Table doesn't exist (migrations haven't run yet)
if (error instanceof PostgresError && error.code === '42P01') {
return false;
}
// eslint-disable-next-line unicorn/prefer-module
const basePath = dirname(__filename);
const workerFile = join(basePath, 'workers', `${name}.js`);
let worker: Worker | ChildProcess;
if (name === ImmichWorker.Api) {
worker = fork(workerFile, [], {
execArgv: process.execArgv.map((arg) => (arg.startsWith('--inspect') ? '--inspect=0.0.0.0:9231' : arg)),
});
apiProcess = worker;
} else {
worker = new Worker(workerFile);
throw error;
} finally {
await kysely.destroy();
}
}
worker.on('error', (error) => onError(name, error));
worker.on('exit', (exitCode) => onExit(name, exitCode));
/**
* Start an individual worker
* @param name Worker
*/
private startWorker(name: ImmichWorker) {
console.log(`Starting ${name} worker`);
// eslint-disable-next-line unicorn/prefer-module
const basePath = dirname(__filename);
const workerFile = join(basePath, 'workers', `${name}.js`);
let anyWorker: Worker | ChildProcess;
let kill: (signal?: NodeJS.Signals) => Promise<void> | void;
if (name === ImmichWorker.Api) {
const worker = fork(workerFile, [], {
execArgv: process.execArgv.map((arg) => (arg.startsWith('--inspect') ? '--inspect=0.0.0.0:9231' : arg)),
});
kill = (signal) => void worker.kill(signal);
anyWorker = worker;
} else {
const worker = new Worker(workerFile);
kill = async () => void (await worker.terminate());
anyWorker = worker;
}
anyWorker.on('error', (error) => this.onError(name, error));
anyWorker.on('exit', (exitCode) => this.onExit(name, exitCode));
this.workers[name] = { kill };
}
onError(name: ImmichWorker, error: Error) {
console.error(`${name} worker error: ${error}, stack: ${error.stack}`);
}
onExit(name: ImmichWorker, exitCode: number | null) {
// restart immich server
if (exitCode === ExitCode.AppRestart || this.restarting) {
this.restarting = true;
console.info(`${name} worker shutdown for restart`);
delete this.workers[name];
// once all workers shut down, bootstrap again
if (Object.keys(this.workers).length === 0) {
void this.bootstrap();
this.restarting = false;
}
return;
}
// shutdown the entire process
delete this.workers[name];
if (exitCode !== 0) {
console.error(`${name} worker exited with code ${exitCode}`);
if (this.workers[ImmichWorker.Api] && name !== ImmichWorker.Api) {
console.error('Killing api process');
void this.workers[ImmichWorker.Api].kill('SIGTERM');
}
}
process.exit(exitCode);
}
}
function bootstrap() {
function main() {
const immichApp = process.argv[2];
if (immichApp) {
process.argv.splice(2, 1);
}
if (immichApp === 'immich-admin') {
process.title = 'immich_admin_cli';
process.env.IMMICH_LOG_LEVEL = LogLevel.Warn;
return CommandFactory.run(ImmichAdminModule);
}
@@ -72,10 +162,7 @@ function bootstrap() {
}
process.title = 'immich';
const { workers } = new ConfigRepository().getEnv();
for (const worker of workers) {
bootstrapWorker(worker);
}
void new Workers().bootstrap();
}
void bootstrap();
void main();

View File

@@ -0,0 +1,58 @@
import {
CanActivate,
ExecutionContext,
Injectable,
SetMetadata,
applyDecorators,
createParamDecorator,
} from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { Request } from 'express';
import { MaintenanceAuthDto } from 'src/dtos/maintenance.dto';
import { MetadataKey } from 'src/enum';
import { MaintenanceWorkerService } from 'src/maintenance/maintenance-worker.service';
import { LoggingRepository } from 'src/repositories/logging.repository';
export const MaintenanceRoute = (options = {}): MethodDecorator => {
const decorators: MethodDecorator[] = [SetMetadata(MetadataKey.AuthRoute, options)];
return applyDecorators(...decorators);
};
export interface MaintenanceAuthRequest extends Request {
auth?: MaintenanceAuthDto;
}
export interface MaintenanceAuthenticatedRequest extends Request {
auth: MaintenanceAuthDto;
}
export const MaintenanceAuth = createParamDecorator((data, context: ExecutionContext): MaintenanceAuthDto => {
return context.switchToHttp().getRequest<MaintenanceAuthenticatedRequest>().auth;
});
@Injectable()
export class MaintenanceAuthGuard implements CanActivate {
constructor(
private logger: LoggingRepository,
private reflector: Reflector,
private service: MaintenanceWorkerService,
) {
this.logger.setContext(MaintenanceAuthGuard.name);
}
async canActivate(context: ExecutionContext): Promise<boolean> {
const targets = [context.getHandler()];
const options = this.reflector.getAllAndOverride<{ _emptyObject: never } | undefined>(
MetadataKey.AuthRoute,
targets,
);
if (!options) {
return true;
}
const request = context.switchToHttp().getRequest<MaintenanceAuthRequest>();
request.auth = await this.service.authenticate(request.headers);
return true;
}
}

View File

@@ -0,0 +1,59 @@
import { Injectable } from '@nestjs/common';
import {
OnGatewayConnection,
OnGatewayDisconnect,
OnGatewayInit,
WebSocketGateway,
WebSocketServer,
} from '@nestjs/websockets';
import { Server, Socket } from 'socket.io';
import { AppRepository } from 'src/repositories/app.repository';
import { AppRestartEvent, ArgsOf } from 'src/repositories/event.repository';
import { LoggingRepository } from 'src/repositories/logging.repository';
export const serverEvents = ['AppRestart'] as const;
export type ServerEvents = (typeof serverEvents)[number];
export interface ClientEventMap {
AppRestartV1: [AppRestartEvent];
}
@WebSocketGateway({
cors: true,
path: '/api/socket.io',
transports: ['websocket'],
})
@Injectable()
export class MaintenanceWebsocketRepository implements OnGatewayConnection, OnGatewayDisconnect, OnGatewayInit {
@WebSocketServer()
private websocketServer?: Server;
constructor(
private logger: LoggingRepository,
private appRepository: AppRepository,
) {
this.logger.setContext(MaintenanceWebsocketRepository.name);
}
afterInit(websocketServer: Server) {
this.logger.log('Initialized websocket server');
websocketServer.on('AppRestart', () => this.appRepository.exitApp());
}
clientBroadcast<T extends keyof ClientEventMap>(event: T, ...data: ClientEventMap[T]) {
this.websocketServer?.emit(event, ...data);
}
serverSend<T extends ServerEvents>(event: T, ...args: ArgsOf<T>): void {
this.logger.debug(`Server event: ${event} (send)`);
this.websocketServer?.serverSideEmit(event, ...args);
}
handleConnection(client: Socket) {
this.logger.log(`Websocket Connect: ${client.id}`);
}
handleDisconnect(client: Socket) {
this.logger.log(`Websocket Disconnect: ${client.id}`);
}
}

View File

@@ -0,0 +1,43 @@
import { Body, Controller, Get, Post, Req, Res } from '@nestjs/common';
import { Request, Response } from 'express';
import { MaintenanceAuthDto, MaintenanceLoginDto, SetMaintenanceModeDto } from 'src/dtos/maintenance.dto';
import { ServerConfigDto } from 'src/dtos/server.dto';
import { ImmichCookie, MaintenanceAction } from 'src/enum';
import { MaintenanceRoute } from 'src/maintenance/maintenance-auth.guard';
import { MaintenanceWorkerService } from 'src/maintenance/maintenance-worker.service';
import { GetLoginDetails } from 'src/middleware/auth.guard';
import { LoginDetails } from 'src/services/auth.service';
import { respondWithCookie } from 'src/utils/response';
@Controller()
export class MaintenanceWorkerController {
constructor(private service: MaintenanceWorkerService) {}
@Get('server/config')
getServerConfig(): Promise<ServerConfigDto> {
return this.service.getSystemConfig();
}
@Post('admin/maintenance/login')
async maintenanceLogin(
@Req() request: Request,
@Body() dto: MaintenanceLoginDto,
@GetLoginDetails() loginDetails: LoginDetails,
@Res({ passthrough: true }) res: Response,
): Promise<MaintenanceAuthDto> {
const token = dto.token ?? request.cookies[ImmichCookie.MaintenanceToken];
const auth = await this.service.login(token);
return respondWithCookie(res, auth, {
isSecure: loginDetails.isSecure,
values: [{ key: ImmichCookie.MaintenanceToken, value: token }],
});
}
@Post('admin/maintenance')
@MaintenanceRoute()
async setMaintenanceMode(@Body() dto: SetMaintenanceModeDto): Promise<void> {
if (dto.action === MaintenanceAction.End) {
await this.service.endMaintenance();
}
}
}

View File

@@ -0,0 +1,128 @@
import { UnauthorizedException } from '@nestjs/common';
import { SignJWT } from 'jose';
import { SystemMetadataKey } from 'src/enum';
import { MaintenanceWebsocketRepository } from 'src/maintenance/maintenance-websocket.repository';
import { MaintenanceWorkerService } from 'src/maintenance/maintenance-worker.service';
import { automock, getMocks, ServiceMocks } from 'test/utils';
describe(MaintenanceWorkerService.name, () => {
let sut: MaintenanceWorkerService;
let mocks: ServiceMocks;
let maintenanceWorkerRepositoryMock: MaintenanceWebsocketRepository;
beforeEach(() => {
mocks = getMocks();
maintenanceWorkerRepositoryMock = automock(MaintenanceWebsocketRepository, { args: [mocks.logger], strict: false });
sut = new MaintenanceWorkerService(
mocks.logger as never,
mocks.app,
mocks.config,
mocks.systemMetadata as never,
maintenanceWorkerRepositoryMock,
);
});
it('should work', () => {
expect(sut).toBeDefined();
});
describe('getSystemConfig', () => {
it('should respond the server is in maintenance mode', async () => {
await expect(sut.getSystemConfig()).resolves.toMatchObject(
expect.objectContaining({
maintenanceMode: true,
}),
);
expect(mocks.systemMetadata.get).toHaveBeenCalled();
});
});
describe('logSecret', () => {
const RE_LOGIN_URL = /https:\/\/my.immich.app\/maintenance\?token=([A-Za-z0-9-_]*\.[A-Za-z0-9-_]*\.[A-Za-z0-9-_]*)/;
it('should log a valid login URL', async () => {
mocks.systemMetadata.get.mockResolvedValue({ isMaintenanceMode: true, secret: 'secret' });
await expect(sut.logSecret()).resolves.toBeUndefined();
expect(mocks.logger.log).toHaveBeenCalledWith(expect.stringMatching(RE_LOGIN_URL));
const [url] = mocks.logger.log.mock.lastCall!;
const token = RE_LOGIN_URL.exec(url)![1];
await expect(sut.login(token)).resolves.toEqual(
expect.objectContaining({
username: 'immich-admin',
}),
);
});
});
describe('authenticate', () => {
it('should fail without a cookie', async () => {
await expect(sut.authenticate({})).rejects.toThrowError(new UnauthorizedException('Missing JWT Token'));
});
it('should parse cookie properly', async () => {
mocks.systemMetadata.get.mockResolvedValue({ isMaintenanceMode: true, secret: 'secret' });
await expect(
sut.authenticate({
cookie: 'immich_maintenance_token=invalid-jwt',
}),
).rejects.toThrowError(new UnauthorizedException('Invalid JWT Token'));
});
});
describe('login', () => {
it('should fail without token', async () => {
await expect(sut.login()).rejects.toThrowError(new UnauthorizedException('Missing JWT Token'));
});
it('should fail with expired JWT', async () => {
mocks.systemMetadata.get.mockResolvedValue({ isMaintenanceMode: true, secret: 'secret' });
const jwt = await new SignJWT({})
.setProtectedHeader({ alg: 'HS256' })
.setIssuedAt()
.setExpirationTime('0s')
.sign(new TextEncoder().encode('secret'));
await expect(sut.login(jwt)).rejects.toThrowError(new UnauthorizedException('Invalid JWT Token'));
});
it('should succeed with valid JWT', async () => {
mocks.systemMetadata.get.mockResolvedValue({ isMaintenanceMode: true, secret: 'secret' });
const jwt = await new SignJWT({ _mockValue: true })
.setProtectedHeader({ alg: 'HS256' })
.setIssuedAt()
.setExpirationTime('4h')
.sign(new TextEncoder().encode('secret'));
await expect(sut.login(jwt)).resolves.toEqual(
expect.objectContaining({
_mockValue: true,
}),
);
});
});
describe('endMaintenance', () => {
it('should set maintenance mode', async () => {
mocks.systemMetadata.get.mockResolvedValue({ isMaintenanceMode: false });
await expect(sut.endMaintenance()).resolves.toBeUndefined();
expect(mocks.systemMetadata.set).toHaveBeenCalledWith(SystemMetadataKey.MaintenanceMode, {
isMaintenanceMode: false,
});
expect(maintenanceWorkerRepositoryMock.clientBroadcast).toHaveBeenCalledWith('AppRestartV1', {
isMaintenanceMode: false,
});
expect(maintenanceWorkerRepositoryMock.serverSend).toHaveBeenCalledWith('AppRestart', {
isMaintenanceMode: false,
});
});
});
});

View File

@@ -0,0 +1,161 @@
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { parse } from 'cookie';
import { NextFunction, Request, Response } from 'express';
import { jwtVerify } from 'jose';
import { readFileSync } from 'node:fs';
import { IncomingHttpHeaders } from 'node:http';
import { MaintenanceAuthDto } from 'src/dtos/maintenance.dto';
import { ImmichCookie, SystemMetadataKey } from 'src/enum';
import { MaintenanceWebsocketRepository } from 'src/maintenance/maintenance-websocket.repository';
import { AppRepository } from 'src/repositories/app.repository';
import { ConfigRepository } from 'src/repositories/config.repository';
import { LoggingRepository } from 'src/repositories/logging.repository';
import { SystemMetadataRepository } from 'src/repositories/system-metadata.repository';
import { type ApiService as _ApiService } from 'src/services/api.service';
import { type BaseService as _BaseService } from 'src/services/base.service';
import { type ServerService as _ServerService } from 'src/services/server.service';
import { MaintenanceModeState } from 'src/types';
import { getConfig } from 'src/utils/config';
import { createMaintenanceLoginUrl } from 'src/utils/maintenance';
import { getExternalDomain } from 'src/utils/misc';
/**
* This service is available inside of maintenance mode to manage maintenance mode
*/
@Injectable()
export class MaintenanceWorkerService {
constructor(
protected logger: LoggingRepository,
private appRepository: AppRepository,
private configRepository: ConfigRepository,
private systemMetadataRepository: SystemMetadataRepository,
private maintenanceWorkerRepository: MaintenanceWebsocketRepository,
) {
this.logger.setContext(this.constructor.name);
}
/**
* {@link _BaseService.configRepos}
*/
private get configRepos() {
return {
configRepo: this.configRepository,
metadataRepo: this.systemMetadataRepository,
logger: this.logger,
};
}
/**
* {@link _BaseService.prototype.getConfig}
*/
private getConfig(options: { withCache: boolean }) {
return getConfig(this.configRepos, options);
}
/**
* {@link _ServerService.getSystemConfig}
*/
async getSystemConfig() {
const config = await this.getConfig({ withCache: false });
return {
loginPageMessage: config.server.loginPageMessage,
trashDays: config.trash.days,
userDeleteDelay: config.user.deleteDelay,
oauthButtonText: config.oauth.buttonText,
isInitialized: true,
isOnboarded: true,
externalDomain: config.server.externalDomain,
publicUsers: config.server.publicUsers,
mapDarkStyleUrl: config.map.darkStyle,
mapLightStyleUrl: config.map.lightStyle,
maintenanceMode: true,
};
}
/**
* {@link _ApiService.ssr}
*/
ssr(excludePaths: string[]) {
const { resourcePaths } = this.configRepository.getEnv();
let index = '';
try {
index = readFileSync(resourcePaths.web.indexHtml).toString();
} catch {
this.logger.warn(`Unable to open ${resourcePaths.web.indexHtml}, skipping SSR.`);
}
return (request: Request, res: Response, next: NextFunction) => {
if (
request.url.startsWith('/api') ||
request.method.toLowerCase() !== 'get' ||
excludePaths.some((item) => request.url.startsWith(item))
) {
return next();
}
const maintenancePath = '/maintenance';
if (!request.url.startsWith(maintenancePath)) {
const params = new URLSearchParams();
params.set('continue', request.path);
return res.redirect(`${maintenancePath}?${params}`);
}
res.status(200).type('text/html').header('Cache-Control', 'no-store').send(index);
};
}
private async secret(): Promise<string> {
const state = (await this.systemMetadataRepository.get(SystemMetadataKey.MaintenanceMode)) as {
secret: string;
};
return state.secret;
}
async logSecret(): Promise<void> {
const { server } = await this.getConfig({ withCache: true });
const baseUrl = getExternalDomain(server);
const url = await createMaintenanceLoginUrl(
baseUrl,
{
username: 'immich-admin',
},
await this.secret(),
);
this.logger.log(`\n\n🚧 Immich is in maintenance mode, you can log in using the following URL:\n${url}\n`);
}
async authenticate(headers: IncomingHttpHeaders): Promise<MaintenanceAuthDto> {
const jwtToken = parse(headers.cookie || '')[ImmichCookie.MaintenanceToken];
return this.login(jwtToken);
}
async login(jwt?: string): Promise<MaintenanceAuthDto> {
if (!jwt) {
throw new UnauthorizedException('Missing JWT Token');
}
const secret = await this.secret();
try {
const result = await jwtVerify<MaintenanceAuthDto>(jwt, new TextEncoder().encode(secret));
return result.payload;
} catch {
throw new UnauthorizedException('Invalid JWT Token');
}
}
async endMaintenance(): Promise<void> {
const state: MaintenanceModeState = { isMaintenanceMode: false as const };
await this.systemMetadataRepository.set(SystemMetadataKey.MaintenanceMode, state);
// => corresponds to notification.service.ts#onAppRestart
this.maintenanceWorkerRepository.clientBroadcast('AppRestartV1', state);
this.maintenanceWorkerRepository.serverSend('AppRestart', state);
this.appRepository.exitApp();
}
}

View File

@@ -0,0 +1,20 @@
import { Injectable } from '@nestjs/common';
import { ExitCode } from 'src/enum';
@Injectable()
export class AppRepository {
private closeFn?: () => Promise<void>;
exitApp() {
/* eslint-disable unicorn/no-process-exit */
void this.closeFn?.().finally(() => process.exit(ExitCode.AppRestart));
// in exceptional circumstance, the application may hang
setTimeout(() => process.exit(ExitCode.AppRestart), 2000);
/* eslint-enable unicorn/no-process-exit */
}
setCloseFn(fn: () => Promise<void>) {
this.closeFn = fn;
}
}

View File

@@ -26,6 +26,7 @@ type EventMap = {
// app events
AppBootstrap: [];
AppShutdown: [];
AppRestart: [AppRestartEvent];
ConfigInit: [{ newConfig: SystemConfig }];
// config events
@@ -96,6 +97,10 @@ type EventMap = {
WebsocketConnect: [{ userId: string }];
};
export type AppRestartEvent = {
isMaintenanceMode: boolean;
};
type JobSuccessEvent = { job: JobItem; response?: JobStatus };
type JobErrorEvent = { job: JobItem; error: Error | any };

View File

@@ -3,6 +3,7 @@ import { ActivityRepository } from 'src/repositories/activity.repository';
import { AlbumUserRepository } from 'src/repositories/album-user.repository';
import { AlbumRepository } from 'src/repositories/album.repository';
import { ApiKeyRepository } from 'src/repositories/api-key.repository';
import { AppRepository } from 'src/repositories/app.repository';
import { AssetJobRepository } from 'src/repositories/asset-job.repository';
import { AssetRepository } from 'src/repositories/asset.repository';
import { AuditRepository } from 'src/repositories/audit.repository';
@@ -56,6 +57,7 @@ export const repositories = [
AlbumUserRepository,
AuditRepository,
ApiKeyRepository,
AppRepository,
AssetRepository,
AssetJobRepository,
ConfigRepository,

View File

@@ -12,11 +12,11 @@ import { AuthDto } from 'src/dtos/auth.dto';
import { NotificationDto } from 'src/dtos/notification.dto';
import { ReleaseNotification, ServerVersionResponseDto } from 'src/dtos/server.dto';
import { SyncAssetExifV1, SyncAssetV1 } from 'src/dtos/sync.dto';
import { ArgsOf, EventRepository } from 'src/repositories/event.repository';
import { AppRestartEvent, ArgsOf, EventRepository } from 'src/repositories/event.repository';
import { LoggingRepository } from 'src/repositories/logging.repository';
import { handlePromiseError } from 'src/utils/misc';
export const serverEvents = ['ConfigUpdate'] as const;
export const serverEvents = ['ConfigUpdate', 'AppRestart'] as const;
export type ServerEvents = (typeof serverEvents)[number];
export interface ClientEventMap {
@@ -36,6 +36,7 @@ export interface ClientEventMap {
on_session_delete: [string];
AssetUploadReadyV1: [{ asset: SyncAssetV1; exif: SyncAssetExifV1 }];
AppRestartV1: [AppRestartEvent];
}
export type AuthFn = (client: Socket) => Promise<AuthDto>;

View File

@@ -11,7 +11,7 @@ import { SharedLinkService } from 'src/services/shared-link.service';
import { VersionService } from 'src/services/version.service';
import { OpenGraphTags } from 'src/utils/misc';
const render = (index: string, meta: OpenGraphTags) => {
export const render = (index: string, meta: OpenGraphTags) => {
const [title, description, imageUrl] = [meta.title, meta.description, meta.imageUrl].map((item) =>
item ? sanitizeHtml(item, { allowedTags: [] }) : '',
);

View File

@@ -10,6 +10,7 @@ import { ActivityRepository } from 'src/repositories/activity.repository';
import { AlbumUserRepository } from 'src/repositories/album-user.repository';
import { AlbumRepository } from 'src/repositories/album.repository';
import { ApiKeyRepository } from 'src/repositories/api-key.repository';
import { AppRepository } from 'src/repositories/app.repository';
import { AssetJobRepository } from 'src/repositories/asset-job.repository';
import { AssetRepository } from 'src/repositories/asset.repository';
import { AuditRepository } from 'src/repositories/audit.repository';
@@ -66,6 +67,7 @@ export const BASE_SERVICE_DEPENDENCIES = [
AlbumRepository,
AlbumUserRepository,
ApiKeyRepository,
AppRepository,
AssetRepository,
AssetJobRepository,
AuditRepository,
@@ -123,6 +125,7 @@ export class BaseService {
protected albumRepository: AlbumRepository,
protected albumUserRepository: AlbumUserRepository,
protected apiKeyRepository: ApiKeyRepository,
protected appRepository: AppRepository,
protected assetRepository: AssetRepository,
protected assetJobRepository: AssetJobRepository,
protected auditRepository: AuditRepository,

View File

@@ -1,3 +1,5 @@
import { jwtVerify } from 'jose';
import { SystemMetadataKey } from 'src/enum';
import { CliService } from 'src/services/cli.service';
import { factory } from 'test/small.factory';
import { newTestService, ServiceMocks } from 'test/utils';
@@ -80,6 +82,82 @@ describe(CliService.name, () => {
});
});
describe('disableMaintenanceMode', () => {
it('should not do anything if not in maintenance mode', async () => {
mocks.systemMetadata.get.mockResolvedValue({ isMaintenanceMode: false });
await expect(sut.disableMaintenanceMode()).resolves.toEqual({
alreadyDisabled: true,
});
expect(mocks.systemMetadata.set).toHaveBeenCalledTimes(0);
expect(mocks.event.emit).toHaveBeenCalledTimes(0);
});
it('should disable maintenance mode', async () => {
mocks.systemMetadata.get.mockResolvedValue({ isMaintenanceMode: true, secret: 'secret' });
await expect(sut.disableMaintenanceMode()).resolves.toEqual({
alreadyDisabled: false,
});
expect(mocks.systemMetadata.set).toHaveBeenCalledWith(SystemMetadataKey.MaintenanceMode, {
isMaintenanceMode: false,
});
});
});
describe('enableMaintenanceMode', () => {
it('should not do anything if in maintenance mode', async () => {
mocks.systemMetadata.get.mockResolvedValue({ isMaintenanceMode: true, secret: 'secret' });
await expect(sut.enableMaintenanceMode()).resolves.toEqual(
expect.objectContaining({
alreadyEnabled: true,
}),
);
expect(mocks.systemMetadata.set).toHaveBeenCalledTimes(0);
expect(mocks.event.emit).toHaveBeenCalledTimes(0);
});
it('should enable maintenance mode', async () => {
mocks.systemMetadata.get.mockResolvedValue({ isMaintenanceMode: false });
await expect(sut.enableMaintenanceMode()).resolves.toEqual(
expect.objectContaining({
alreadyEnabled: false,
}),
);
expect(mocks.systemMetadata.set).toHaveBeenCalledWith(SystemMetadataKey.MaintenanceMode, {
isMaintenanceMode: true,
secret: expect.stringMatching(/^\w{128}$/),
});
});
const RE_LOGIN_URL = /https:\/\/my.immich.app\/maintenance\?token=([A-Za-z0-9-_]*\.[A-Za-z0-9-_]*\.[A-Za-z0-9-_]*)/;
it('should return a valid login URL', async () => {
mocks.systemMetadata.get.mockResolvedValue({ isMaintenanceMode: true, secret: 'secret' });
const result = await sut.enableMaintenanceMode();
expect(result).toEqual(
expect.objectContaining({
authUrl: expect.stringMatching(RE_LOGIN_URL),
alreadyEnabled: true,
}),
);
const token = RE_LOGIN_URL.exec(result.authUrl)![1];
await expect(jwtVerify(token, new TextEncoder().encode('secret'))).resolves.toEqual(
expect.objectContaining({
payload: expect.objectContaining({
username: 'cli-admin',
}),
}),
);
});
});
describe('disableOAuthLogin', () => {
it('should disable oauth login', async () => {
await sut.disableOAuthLogin();

View File

@@ -1,8 +1,12 @@
import { Injectable } from '@nestjs/common';
import { isAbsolute } from 'node:path';
import { SALT_ROUNDS } from 'src/constants';
import { MaintenanceAuthDto } from 'src/dtos/maintenance.dto';
import { UserAdminResponseDto, mapUserAdmin } from 'src/dtos/user.dto';
import { SystemMetadataKey } from 'src/enum';
import { BaseService } from 'src/services/base.service';
import { createMaintenanceLoginUrl, generateMaintenanceSecret, sendOneShotAppRestart } from 'src/utils/maintenance';
import { getExternalDomain } from 'src/utils/misc';
@Injectable()
export class CliService extends BaseService {
@@ -38,6 +42,63 @@ export class CliService extends BaseService {
await this.updateConfig(config);
}
async disableMaintenanceMode(): Promise<{ alreadyDisabled: boolean }> {
const currentState = await this.systemMetadataRepository
.get(SystemMetadataKey.MaintenanceMode)
.then((state) => state ?? { isMaintenanceMode: false as const });
if (!currentState.isMaintenanceMode) {
return {
alreadyDisabled: true,
};
}
const state = { isMaintenanceMode: false as const };
await this.systemMetadataRepository.set(SystemMetadataKey.MaintenanceMode, state);
sendOneShotAppRestart(state);
return {
alreadyDisabled: false,
};
}
async enableMaintenanceMode(): Promise<{ authUrl: string; alreadyEnabled: boolean }> {
const { server } = await this.getConfig({ withCache: true });
const baseUrl = getExternalDomain(server);
const payload: MaintenanceAuthDto = {
username: 'cli-admin',
};
const state = await this.systemMetadataRepository
.get(SystemMetadataKey.MaintenanceMode)
.then((state) => state ?? { isMaintenanceMode: false as const });
if (state.isMaintenanceMode) {
return {
authUrl: await createMaintenanceLoginUrl(baseUrl, payload, state.secret),
alreadyEnabled: true,
};
}
const secret = generateMaintenanceSecret();
await this.systemMetadataRepository.set(SystemMetadataKey.MaintenanceMode, {
isMaintenanceMode: true,
secret,
});
sendOneShotAppRestart({
isMaintenanceMode: true,
});
return {
authUrl: await createMaintenanceLoginUrl(baseUrl, payload, secret),
alreadyEnabled: false,
};
}
async grantAdminAccess(email: string): Promise<void> {
const user = await this.userRepository.getByEmail(email);
if (!user) {

View File

@@ -14,6 +14,7 @@ import { DownloadService } from 'src/services/download.service';
import { DuplicateService } from 'src/services/duplicate.service';
import { JobService } from 'src/services/job.service';
import { LibraryService } from 'src/services/library.service';
import { MaintenanceService } from 'src/services/maintenance.service';
import { MapService } from 'src/services/map.service';
import { MediaService } from 'src/services/media.service';
import { MemoryService } from 'src/services/memory.service';
@@ -63,6 +64,7 @@ export const services = [
DuplicateService,
JobService,
LibraryService,
MaintenanceService,
MapService,
MediaService,
MemoryService,

View File

@@ -0,0 +1,109 @@
import { SystemMetadataKey } from 'src/enum';
import { MaintenanceService } from 'src/services/maintenance.service';
import { newTestService, ServiceMocks } from 'test/utils';
describe(MaintenanceService.name, () => {
let sut: MaintenanceService;
let mocks: ServiceMocks;
beforeEach(() => {
({ sut, mocks } = newTestService(MaintenanceService));
});
it('should work', () => {
expect(sut).toBeDefined();
});
describe('getMaintenanceMode', () => {
it('should return false if state unknown', async () => {
mocks.systemMetadata.get.mockResolvedValue(null);
await expect(sut.getMaintenanceMode()).resolves.toEqual({
isMaintenanceMode: false,
});
expect(mocks.systemMetadata.get).toHaveBeenCalled();
});
it('should return false if disabled', async () => {
mocks.systemMetadata.get.mockResolvedValue({ isMaintenanceMode: false });
await expect(sut.getMaintenanceMode()).resolves.toEqual({
isMaintenanceMode: false,
});
expect(mocks.systemMetadata.get).toHaveBeenCalled();
});
it('should return true if enabled', async () => {
mocks.systemMetadata.get.mockResolvedValue({ isMaintenanceMode: true, secret: '' });
await expect(sut.getMaintenanceMode()).resolves.toEqual({
isMaintenanceMode: true,
secret: '',
});
expect(mocks.systemMetadata.get).toHaveBeenCalled();
});
});
describe('startMaintenance', () => {
it('should set maintenance mode and return a secret', async () => {
mocks.systemMetadata.get.mockResolvedValue({ isMaintenanceMode: false });
await expect(sut.startMaintenance('admin')).resolves.toMatchObject({
jwt: expect.any(String),
});
expect(mocks.systemMetadata.set).toHaveBeenCalledWith(SystemMetadataKey.MaintenanceMode, {
isMaintenanceMode: true,
secret: expect.stringMatching(/^\w{128}$/),
});
expect(mocks.event.emit).toHaveBeenCalledWith('AppRestart', {
isMaintenanceMode: true,
});
});
});
describe('createLoginUrl', () => {
it('should fail outside of maintenance mode without secret', async () => {
mocks.systemMetadata.get.mockResolvedValue({ isMaintenanceMode: false });
await expect(
sut.createLoginUrl({
username: '',
}),
).rejects.toThrowError('Not in maintenance mode');
});
it('should generate a login url with JWT', async () => {
mocks.systemMetadata.get.mockResolvedValue({ isMaintenanceMode: true, secret: 'secret' });
await expect(
sut.createLoginUrl({
username: '',
}),
).resolves.toEqual(
expect.stringMatching(
/^https:\/\/my.immich.app\/maintenance\?token=[A-Za-z0-9-_]*\.[A-Za-z0-9-_]*\.[A-Za-z0-9-_]*$/,
),
);
expect(mocks.systemMetadata.get).toHaveBeenCalledTimes(2);
});
it('should use the given secret', async () => {
await expect(
sut.createLoginUrl(
{
username: '',
},
'secret',
),
).resolves.toEqual(expect.stringMatching(/./));
expect(mocks.systemMetadata.get).toHaveBeenCalledTimes(1);
});
});
});

View File

@@ -0,0 +1,53 @@
import { Injectable } from '@nestjs/common';
import { OnEvent } from 'src/decorators';
import { MaintenanceAuthDto } from 'src/dtos/maintenance.dto';
import { SystemMetadataKey } from 'src/enum';
import { BaseService } from 'src/services/base.service';
import { MaintenanceModeState } from 'src/types';
import { createMaintenanceLoginUrl, generateMaintenanceSecret, signMaintenanceJwt } from 'src/utils/maintenance';
import { getExternalDomain } from 'src/utils/misc';
/**
* This service is available outside of maintenance mode to manage maintenance mode
*/
@Injectable()
export class MaintenanceService extends BaseService {
getMaintenanceMode(): Promise<MaintenanceModeState> {
return this.systemMetadataRepository
.get(SystemMetadataKey.MaintenanceMode)
.then((state) => state ?? { isMaintenanceMode: false });
}
async startMaintenance(username: string): Promise<{ jwt: string }> {
const secret = generateMaintenanceSecret();
await this.systemMetadataRepository.set(SystemMetadataKey.MaintenanceMode, { isMaintenanceMode: true, secret });
await this.eventRepository.emit('AppRestart', { isMaintenanceMode: true });
return {
jwt: await signMaintenanceJwt(secret, {
username,
}),
};
}
@OnEvent({ name: 'AppRestart', server: true })
onRestart(): void {
this.appRepository.exitApp();
}
async createLoginUrl(auth: MaintenanceAuthDto, secret?: string): Promise<string> {
const { server } = await this.getConfig({ withCache: true });
const baseUrl = getExternalDomain(server);
if (!secret) {
const state = await this.getMaintenanceMode();
if (!state.isMaintenanceMode) {
throw new Error('Not in maintenance mode');
}
secret = state.secret;
}
return await createMaintenanceLoginUrl(baseUrl, auth, secret);
}
}

View File

@@ -114,6 +114,15 @@ export class NotificationService extends BaseService {
this.websocketRepository.serverSend('ConfigUpdate', { oldConfig, newConfig });
}
@OnEvent({ name: 'AppRestart' })
onAppRestart(state: ArgOf<'AppRestart'>) {
this.websocketRepository.clientBroadcast('AppRestartV1', {
isMaintenanceMode: state.isMaintenanceMode,
});
this.websocketRepository.serverSend('AppRestart', state);
}
@OnEvent({ name: 'ConfigValidate', priority: -100 })
async onConfigValidate({ oldConfig, newConfig }: ArgOf<'ConfigValidate'>) {
try {

View File

@@ -166,6 +166,7 @@ describe(ServerService.name, () => {
publicUsers: true,
mapDarkStyleUrl: 'https://tiles.immich.cloud/v1/style/dark.json',
mapLightStyleUrl: 'https://tiles.immich.cloud/v1/style/light.json',
maintenanceMode: false,
});
expect(mocks.systemMetadata.get).toHaveBeenCalled();
});

View File

@@ -130,6 +130,7 @@ export class ServerService extends BaseService {
publicUsers: config.server.publicUsers,
mapDarkStyleUrl: config.map.darkStyle,
mapLightStyleUrl: config.map.lightStyle,
maintenanceMode: false,
};
}

View File

@@ -493,6 +493,7 @@ export interface MemoryData {
export type VersionCheckMetadata = { checkedAt: string; releaseVersion: string };
export type SystemFlags = { mountChecks: Record<StorageFolder, boolean> };
export type MaintenanceModeState = { isMaintenanceMode: true; secret: string } | { isMaintenanceMode: false };
export type MemoriesState = {
/** memories have already been created through this date */
lastOnThisDayDate: string;
@@ -503,6 +504,7 @@ export interface SystemMetadata extends Record<SystemMetadataKey, Record<string,
[SystemMetadataKey.AdminOnboarding]: { isOnboarded: boolean };
[SystemMetadataKey.FacialRecognitionState]: { lastRun?: string };
[SystemMetadataKey.License]: { licenseKey: string; activationKey: string; activatedAt: Date };
[SystemMetadataKey.MaintenanceMode]: MaintenanceModeState;
[SystemMetadataKey.MediaLocation]: MediaLocation;
[SystemMetadataKey.ReverseGeocodingState]: { lastUpdate?: string; lastImportFileName?: string };
[SystemMetadataKey.SystemConfig]: DeepPartial<SystemConfig>;

View File

@@ -0,0 +1,74 @@
import { createAdapter } from '@socket.io/redis-adapter';
import Redis from 'ioredis';
import { SignJWT } from 'jose';
import { randomBytes } from 'node:crypto';
import { Server as SocketIO } from 'socket.io';
import { MaintenanceAuthDto } from 'src/dtos/maintenance.dto';
import { ConfigRepository } from 'src/repositories/config.repository';
import { AppRestartEvent } from 'src/repositories/event.repository';
export function sendOneShotAppRestart(state: AppRestartEvent): void {
const server = new SocketIO();
const { redis } = new ConfigRepository().getEnv();
const pubClient = new Redis(redis);
const subClient = pubClient.duplicate();
server.adapter(createAdapter(pubClient, subClient));
/**
* Keep trying until we manage to stop Immich
*
* Sometimes there appear to be communication
* issues between to the other servers.
*
* This issue only occurs with this method.
*/
async function tryTerminate() {
while (true) {
try {
const responses = await server.serverSideEmitWithAck('AppRestart', state);
if (responses.length > 0) {
return;
}
} catch (error) {
console.error(error);
console.error('Encountered an error while telling Immich to stop.');
}
console.info(
"\nIt doesn't appear that Immich stopped, trying again in a moment.\nIf Immich is already not running, you can ignore this error.",
);
await new Promise((r) => setTimeout(r, 1e3));
}
}
// => corresponds to notification.service.ts#onAppRestart
server.emit('AppRestartV1', state, () => {
void tryTerminate().finally(() => {
pubClient.disconnect();
subClient.disconnect();
});
});
}
export async function createMaintenanceLoginUrl(
baseUrl: string,
auth: MaintenanceAuthDto,
secret: string,
): Promise<string> {
return `${baseUrl}/maintenance?token=${await signMaintenanceJwt(secret, auth)}`;
}
export async function signMaintenanceJwt(secret: string, data: MaintenanceAuthDto): Promise<string> {
const alg = 'HS256';
return await new SignJWT({ ...data })
.setProtectedHeader({ alg })
.setIssuedAt()
.setExpirationTime('4h')
.sign(new TextEncoder().encode(secret));
}
export function generateMaintenanceSecret(): string {
return randomBytes(64).toString('hex');
}

View File

@@ -15,6 +15,7 @@ export const respondWithCookie = <T>(res: Response, body: T, { isSecure, values
const cookieOptions: Record<ImmichCookie, CookieOptions> = {
[ImmichCookie.AuthType]: defaults,
[ImmichCookie.AccessToken]: defaults,
[ImmichCookie.MaintenanceToken]: { ...defaults, maxAge: Duration.fromObject({ days: 1 }).toMillis() },
[ImmichCookie.OAuthState]: defaults,
[ImmichCookie.OAuthCodeVerifier]: defaults,
// no httpOnly so that the client can know the auth state

View File

@@ -1,69 +1,22 @@
import { NestFactory } from '@nestjs/core';
import { NestExpressApplication } from '@nestjs/platform-express';
import { json } from 'body-parser';
import compression from 'compression';
import cookieParser from 'cookie-parser';
import { existsSync } from 'node:fs';
import sirv from 'sirv';
import { configureExpress, configureTelemetry } from 'src/app.common';
import { ApiModule } from 'src/app.module';
import { excludePaths, serverVersion } from 'src/constants';
import { WebSocketAdapter } from 'src/middleware/websocket.adapter';
import { ConfigRepository } from 'src/repositories/config.repository';
import { LoggingRepository } from 'src/repositories/logging.repository';
import { bootstrapTelemetry } from 'src/repositories/telemetry.repository';
import { AppRepository } from 'src/repositories/app.repository';
import { ApiService } from 'src/services/api.service';
import { isStartUpError, useSwagger } from 'src/utils/misc';
import { isStartUpError } from 'src/utils/misc';
async function bootstrap() {
process.title = 'immich-api';
const { telemetry, network } = new ConfigRepository().getEnv();
if (telemetry.metrics.size > 0) {
bootstrapTelemetry(telemetry.apiPort);
}
configureTelemetry();
const app = await NestFactory.create<NestExpressApplication>(ApiModule, { bufferLogs: true });
const logger = await app.resolve(LoggingRepository);
const configRepository = app.get(ConfigRepository);
app.get(AppRepository).setCloseFn(() => app.close());
const { environment, host, port, resourcePaths } = configRepository.getEnv();
logger.setContext('Bootstrap');
app.useLogger(logger);
app.set('trust proxy', ['loopback', ...network.trustedProxies]);
app.set('etag', 'strong');
app.use(cookieParser());
app.use(json({ limit: '10mb' }));
if (configRepository.isDev()) {
app.enableCors();
}
app.useWebSocketAdapter(new WebSocketAdapter(app));
useSwagger(app, { write: configRepository.isDev() });
app.setGlobalPrefix('api', { exclude: excludePaths });
if (existsSync(resourcePaths.web.root)) {
// copied from https://github.com/sveltejs/kit/blob/679b5989fe62e3964b9a73b712d7b41831aa1f07/packages/adapter-node/src/handler.js#L46
// provides serving of precompressed assets and caching of immutable assets
app.use(
sirv(resourcePaths.web.root, {
etag: true,
gzip: true,
brotli: true,
extensions: [],
setHeaders: (res, pathname) => {
if (pathname.startsWith(`/_app/immutable`) && res.statusCode === 200) {
res.setHeader('cache-control', 'public,max-age=31536000,immutable');
}
},
}),
);
}
app.use(app.get(ApiService).ssr(excludePaths));
app.use(compression());
const server = await (host ? app.listen(port, host) : app.listen(port));
server.requestTimeout = 24 * 60 * 60 * 1000;
logger.log(`Immich Server is listening on ${await app.getUrl()} [v${serverVersion}] [${environment}] `);
void configureExpress(app, {
ssr: ApiService,
});
}
bootstrap().catch((error) => {

View File

@@ -0,0 +1,29 @@
import { NestFactory } from '@nestjs/core';
import { NestExpressApplication } from '@nestjs/platform-express';
import { configureExpress, configureTelemetry } from 'src/app.common';
import { MaintenanceModule } from 'src/app.module';
import { MaintenanceWorkerService } from 'src/maintenance/maintenance-worker.service';
import { AppRepository } from 'src/repositories/app.repository';
import { isStartUpError } from 'src/utils/misc';
async function bootstrap() {
process.title = 'immich-maintenance';
configureTelemetry();
const app = await NestFactory.create<NestExpressApplication>(MaintenanceModule, { bufferLogs: true });
app.get(AppRepository).setCloseFn(() => app.close());
void configureExpress(app, {
permitSwaggerWrite: false,
ssr: MaintenanceWorkerService,
});
void app.get(MaintenanceWorkerService).logSecret();
}
bootstrap().catch((error) => {
if (!isStartUpError(error)) {
console.error(error);
}
// eslint-disable-next-line unicorn/no-process-exit
process.exit(1);
});

View File

@@ -3,6 +3,7 @@ import { isMainThread } from 'node:worker_threads';
import { MicroservicesModule } from 'src/app.module';
import { serverVersion } from 'src/constants';
import { WebSocketAdapter } from 'src/middleware/websocket.adapter';
import { AppRepository } from 'src/repositories/app.repository';
import { ConfigRepository } from 'src/repositories/config.repository';
import { LoggingRepository } from 'src/repositories/logging.repository';
import { bootstrapTelemetry } from 'src/repositories/telemetry.repository';
@@ -17,6 +18,7 @@ export async function bootstrap() {
const app = await NestFactory.create(MicroservicesModule, { bufferLogs: true });
const logger = await app.resolve(LoggingRepository);
const configRepository = app.get(ConfigRepository);
app.get(AppRepository).setCloseFn(() => app.close());
const { environment, host } = configRepository.getEnv();

View File

@@ -7,6 +7,7 @@ import { WorkflowRepository } from 'src/repositories/workflow.repository';
import { DB } from 'src/schema';
import { WorkflowService } from 'src/services/workflow.service';
import { newMediumService } from 'test/medium.factory';
import { factory } from 'test/small.factory';
import { getKyselyDB } from 'test/utils';
let defaultDatabase: Kysely<DB>;
@@ -77,7 +78,7 @@ describe(WorkflowService.name, () => {
const { sut, ctx } = setup();
const { user } = await ctx.newUser();
const auth = { user: { id: user.id } } as any;
const auth = factory.auth({ user });
const workflow = await sut.create(auth, {
triggerType: PluginTriggerType.AssetCreate,
@@ -103,7 +104,7 @@ describe(WorkflowService.name, () => {
it('should create a workflow with filters and actions', async () => {
const { sut, ctx } = setup();
const { user } = await ctx.newUser();
const auth = { user: { id: user.id } } as any;
const auth = factory.auth({ user });
const workflow = await sut.create(auth, {
triggerType: PluginTriggerType.AssetCreate,
@@ -154,7 +155,7 @@ describe(WorkflowService.name, () => {
it('should throw error when creating workflow with invalid filter', async () => {
const { sut, ctx } = setup();
const { user } = await ctx.newUser();
const auth = { user: { id: user.id } } as any;
const auth = factory.auth({ user });
await expect(
sut.create(auth, {
@@ -162,12 +163,7 @@ describe(WorkflowService.name, () => {
name: 'invalid-workflow',
description: 'A workflow with invalid filter',
enabled: true,
filters: [
{
filterId: '66da82df-e424-4bf4-b6f3-5d8e71620dae',
filterConfig: { key: 'value' },
},
],
filters: [{ filterId: factory.uuid(), filterConfig: { key: 'value' } }],
actions: [],
}),
).rejects.toThrow('Invalid filter ID');
@@ -176,7 +172,7 @@ describe(WorkflowService.name, () => {
it('should throw error when creating workflow with invalid action', async () => {
const { sut, ctx } = setup();
const { user } = await ctx.newUser();
const auth = { user: { id: user.id } } as any;
const auth = factory.auth({ user });
await expect(
sut.create(auth, {
@@ -185,12 +181,7 @@ describe(WorkflowService.name, () => {
description: 'A workflow with invalid action',
enabled: true,
filters: [],
actions: [
{
actionId: '66da82df-e424-4bf4-b6f3-5d8e71620dae',
actionConfig: { action: 'test' },
},
],
actions: [{ actionId: factory.uuid(), actionConfig: { action: 'test' } }],
}),
).rejects.toThrow('Invalid action ID');
});
@@ -198,7 +189,7 @@ describe(WorkflowService.name, () => {
it('should throw error when filter does not support trigger context', async () => {
const { sut, ctx } = setup();
const { user } = await ctx.newUser();
const auth = { user: { id: user.id } } as any;
const auth = factory.auth({ user });
// Create a plugin with a filter that only supports Album context
const pluginRepo = new PluginRepository(defaultDatabase);
@@ -238,7 +229,7 @@ describe(WorkflowService.name, () => {
it('should throw error when action does not support trigger context', async () => {
const { sut, ctx } = setup();
const { user } = await ctx.newUser();
const auth = { user: { id: user.id } } as any;
const auth = factory.auth({ user });
// Create a plugin with an action that only supports Person context
const pluginRepo = new PluginRepository(defaultDatabase);
@@ -278,7 +269,7 @@ describe(WorkflowService.name, () => {
it('should create workflow with multiple filters and actions in correct order', async () => {
const { sut, ctx } = setup();
const { user } = await ctx.newUser();
const auth = { user: { id: user.id } } as any;
const auth = factory.auth({ user });
const workflow = await sut.create(auth, {
triggerType: PluginTriggerType.AssetCreate,
@@ -313,7 +304,7 @@ describe(WorkflowService.name, () => {
it('should return all workflows for a user', async () => {
const { sut, ctx } = setup();
const { user } = await ctx.newUser();
const auth = { user: { id: user.id } } as any;
const auth = factory.auth({ user });
const workflow1 = await sut.create(auth, {
triggerType: PluginTriggerType.AssetCreate,
@@ -347,7 +338,7 @@ describe(WorkflowService.name, () => {
it('should return empty array when user has no workflows', async () => {
const { sut, ctx } = setup();
const { user } = await ctx.newUser();
const auth = { user: { id: user.id } } as any;
const auth = factory.auth({ user });
const workflows = await sut.getAll(auth);
@@ -358,8 +349,8 @@ describe(WorkflowService.name, () => {
const { sut, ctx } = setup();
const { user: user1 } = await ctx.newUser();
const { user: user2 } = await ctx.newUser();
const auth1 = { user: { id: user1.id } } as any;
const auth2 = { user: { id: user2.id } } as any;
const auth1 = factory.auth({ user: user1 });
const auth2 = factory.auth({ user: user2 });
await sut.create(auth1, {
triggerType: PluginTriggerType.AssetCreate,
@@ -380,7 +371,7 @@ describe(WorkflowService.name, () => {
it('should return a specific workflow by id', async () => {
const { sut, ctx } = setup();
const { user } = await ctx.newUser();
const auth = { user: { id: user.id } } as any;
const auth = factory.auth({ user });
const created = await sut.create(auth, {
triggerType: PluginTriggerType.AssetCreate,
@@ -406,7 +397,7 @@ describe(WorkflowService.name, () => {
it('should throw error when workflow does not exist', async () => {
const { sut, ctx } = setup();
const { user } = await ctx.newUser();
const auth = { user: { id: user.id } } as any;
const auth = factory.auth({ user });
await expect(sut.get(auth, '66da82df-e424-4bf4-b6f3-5d8e71620dae')).rejects.toThrow();
});
@@ -415,8 +406,8 @@ describe(WorkflowService.name, () => {
const { sut, ctx } = setup();
const { user: user1 } = await ctx.newUser();
const { user: user2 } = await ctx.newUser();
const auth1 = { user: { id: user1.id } } as any;
const auth2 = { user: { id: user2.id } } as any;
const auth1 = factory.auth({ user: user1 });
const auth2 = factory.auth({ user: user2 });
const workflow = await sut.create(auth1, {
triggerType: PluginTriggerType.AssetCreate,
@@ -435,7 +426,7 @@ describe(WorkflowService.name, () => {
it('should update workflow basic fields', async () => {
const { sut, ctx } = setup();
const { user } = await ctx.newUser();
const auth = { user: { id: user.id } } as any;
const auth = factory.auth({ user });
const created = await sut.create(auth, {
triggerType: PluginTriggerType.AssetCreate,
@@ -463,7 +454,7 @@ describe(WorkflowService.name, () => {
it('should update workflow filters', async () => {
const { sut, ctx } = setup();
const { user } = await ctx.newUser();
const auth = { user: { id: user.id } } as any;
const auth = factory.auth({ user });
const created = await sut.create(auth, {
triggerType: PluginTriggerType.AssetCreate,
@@ -489,7 +480,7 @@ describe(WorkflowService.name, () => {
it('should update workflow actions', async () => {
const { sut, ctx } = setup();
const { user } = await ctx.newUser();
const auth = { user: { id: user.id } } as any;
const auth = factory.auth({ user });
const created = await sut.create(auth, {
triggerType: PluginTriggerType.AssetCreate,
@@ -515,7 +506,7 @@ describe(WorkflowService.name, () => {
it('should clear filters when updated with empty array', async () => {
const { sut, ctx } = setup();
const { user } = await ctx.newUser();
const auth = { user: { id: user.id } } as any;
const auth = factory.auth({ user });
const created = await sut.create(auth, {
triggerType: PluginTriggerType.AssetCreate,
@@ -536,7 +527,7 @@ describe(WorkflowService.name, () => {
it('should throw error when no fields to update', async () => {
const { sut, ctx } = setup();
const { user } = await ctx.newUser();
const auth = { user: { id: user.id } } as any;
const auth = factory.auth({ user });
const created = await sut.create(auth, {
triggerType: PluginTriggerType.AssetCreate,
@@ -553,21 +544,17 @@ describe(WorkflowService.name, () => {
it('should throw error when updating non-existent workflow', async () => {
const { sut, ctx } = setup();
const { user } = await ctx.newUser();
const auth = { user: { id: user.id } } as any;
const auth = factory.auth({ user });
await expect(
sut.update(auth, 'non-existent-id', {
name: 'updated-name',
}),
).rejects.toThrow();
await expect(sut.update(auth, factory.uuid(), { name: 'updated-name' })).rejects.toThrow();
});
it('should throw error when user does not have access to update workflow', async () => {
const { sut, ctx } = setup();
const { user: user1 } = await ctx.newUser();
const { user: user2 } = await ctx.newUser();
const auth1 = { user: { id: user1.id } } as any;
const auth2 = { user: { id: user2.id } } as any;
const auth1 = factory.auth({ user: user1 });
const auth2 = factory.auth({ user: user2 });
const workflow = await sut.create(auth1, {
triggerType: PluginTriggerType.AssetCreate,
@@ -588,7 +575,7 @@ describe(WorkflowService.name, () => {
it('should throw error when updating with invalid filter', async () => {
const { sut, ctx } = setup();
const { user } = await ctx.newUser();
const auth = { user: { id: user.id } } as any;
const auth = factory.auth({ user });
const created = await sut.create(auth, {
triggerType: PluginTriggerType.AssetCreate,
@@ -601,7 +588,7 @@ describe(WorkflowService.name, () => {
await expect(
sut.update(auth, created.id, {
filters: [{ filterId: 'invalid-filter-id', filterConfig: {} }],
filters: [{ filterId: factory.uuid(), filterConfig: {} }],
}),
).rejects.toThrow();
});
@@ -609,7 +596,7 @@ describe(WorkflowService.name, () => {
it('should throw error when updating with invalid action', async () => {
const { sut, ctx } = setup();
const { user } = await ctx.newUser();
const auth = { user: { id: user.id } } as any;
const auth = factory.auth({ user });
const created = await sut.create(auth, {
triggerType: PluginTriggerType.AssetCreate,
@@ -621,9 +608,7 @@ describe(WorkflowService.name, () => {
});
await expect(
sut.update(auth, created.id, {
actions: [{ actionId: 'invalid-action-id', actionConfig: {} }],
}),
sut.update(auth, created.id, { actions: [{ actionId: factory.uuid(), actionConfig: {} }] }),
).rejects.toThrow();
});
});
@@ -632,7 +617,7 @@ describe(WorkflowService.name, () => {
it('should delete a workflow', async () => {
const { sut, ctx } = setup();
const { user } = await ctx.newUser();
const auth = { user: { id: user.id } } as any;
const auth = factory.auth({ user });
const workflow = await sut.create(auth, {
triggerType: PluginTriggerType.AssetCreate,
@@ -651,7 +636,7 @@ describe(WorkflowService.name, () => {
it('should delete workflow with filters and actions', async () => {
const { sut, ctx } = setup();
const { user } = await ctx.newUser();
const auth = { user: { id: user.id } } as any;
const auth = factory.auth({ user });
const workflow = await sut.create(auth, {
triggerType: PluginTriggerType.AssetCreate,
@@ -670,17 +655,17 @@ describe(WorkflowService.name, () => {
it('should throw error when deleting non-existent workflow', async () => {
const { sut, ctx } = setup();
const { user } = await ctx.newUser();
const auth = { user: { id: user.id } } as any;
const auth = factory.auth({ user });
await expect(sut.delete(auth, 'non-existent-id')).rejects.toThrow();
await expect(sut.delete(auth, factory.uuid())).rejects.toThrow();
});
it('should throw error when user does not have access to delete workflow', async () => {
const { sut, ctx } = setup();
const { user: user1 } = await ctx.newUser();
const { user: user2 } = await ctx.newUser();
const auth1 = { user: { id: user1.id } } as any;
const auth2 = { user: { id: user2.id } } as any;
const auth1 = factory.auth({ user: user1 });
const auth2 = factory.auth({ user: user2 });
const workflow = await sut.create(auth1, {
triggerType: PluginTriggerType.AssetCreate,

View File

@@ -19,6 +19,7 @@ import { ActivityRepository } from 'src/repositories/activity.repository';
import { AlbumUserRepository } from 'src/repositories/album-user.repository';
import { AlbumRepository } from 'src/repositories/album.repository';
import { ApiKeyRepository } from 'src/repositories/api-key.repository';
import { AppRepository } from 'src/repositories/app.repository';
import { AssetJobRepository } from 'src/repositories/asset-job.repository';
import { AssetRepository } from 'src/repositories/asset.repository';
import { AuditRepository } from 'src/repositories/audit.repository';
@@ -212,6 +213,7 @@ export type ServiceOverrides = {
album: AlbumRepository;
albumUser: AlbumUserRepository;
apiKey: ApiKeyRepository;
app: AppRepository;
audit: AuditRepository;
asset: AssetRepository;
assetJob: AssetJobRepository;
@@ -271,10 +273,7 @@ type Constructor<Type, Args extends Array<any>> = {
new (...deps: Args): Type;
};
export const newTestService = <T extends BaseService>(
Service: Constructor<T, BaseServiceArgs>,
overrides: Partial<ServiceOverrides> = {},
) => {
export const getMocks = () => {
const loggerMock = { setContext: () => {} };
const configMock = { getEnv: () => ({}) };
@@ -291,6 +290,7 @@ export const newTestService = <T extends BaseService>(
albumUser: automock(AlbumUserRepository),
asset: newAssetRepositoryMock(),
assetJob: automock(AssetJobRepository),
app: automock(AppRepository, { strict: false }),
config: newConfigRepositoryMock(),
database: newDatabaseRepositoryMock(),
downloadRepository: automock(DownloadRepository, { strict: false }),
@@ -338,6 +338,15 @@ export const newTestService = <T extends BaseService>(
workflow: automock(WorkflowRepository, { strict: true }),
};
return mocks;
};
export const newTestService = <T extends BaseService>(
Service: Constructor<T, BaseServiceArgs>,
overrides: Partial<ServiceOverrides> = {},
) => {
const mocks = getMocks();
const sut = new Service(
overrides.logger || (mocks.logger as As<LoggingRepository>),
overrides.access || (mocks.access as IAccessRepository as AccessRepository),
@@ -345,6 +354,7 @@ export const newTestService = <T extends BaseService>(
overrides.album || (mocks.album as As<AlbumRepository>),
overrides.albumUser || (mocks.albumUser as As<AlbumUserRepository>),
overrides.apiKey || (mocks.apiKey as As<ApiKeyRepository>),
overrides.app || (mocks.app as As<AppRepository>),
overrides.asset || (mocks.asset as As<AssetRepository>),
overrides.assetJob || (mocks.assetJob as As<AssetJobRepository>),
overrides.audit || (mocks.audit as As<AuditRepository>),

View File

@@ -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

View File

@@ -0,0 +1,35 @@
<script lang="ts">
import { handleError } from '$lib/utils/handle-error';
import { MaintenanceAction, setMaintenanceMode } from '@immich/sdk';
import { Button } from '@immich/ui';
import { t } from 'svelte-i18n';
import { fade } from 'svelte/transition';
interface Props {
disabled?: boolean;
}
let { disabled = false }: Props = $props();
async function start() {
try {
await setMaintenanceMode({
setMaintenanceModeDto: {
action: MaintenanceAction.Start,
},
});
} catch (error) {
handleError(error, $t('admin.maintenance_start_error'));
}
}
</script>
<div>
<div in:fade={{ duration: 500 }}>
<div class="ms-4 mt-4 flex items-end gap-4">
<Button shape="round" type="submit" {disabled} size="small" onclick={start}
>{$t('admin.maintenance_start')}</Button
>
</div>
</div>
</div>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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" />

View File

@@ -326,7 +326,6 @@
const lower = 0;
const upper = rect?.height - (PADDING_TOP + PADDING_BOTTOM);
hoverY = clamp(clientY - rect?.top - PADDING_TOP, lower, upper);
const hoverAtScrubberBottom = hoverY === rect?.height - (PADDING_TOP + PADDING_BOTTOM);
const x = rect!.left + rect!.width / 2;
const { segment, monthGroupPercentY, isOnPaddingTop, isOnPaddingBottom } = getActive(x, clientY);
activeSegment = segment;
@@ -335,7 +334,7 @@
const scrubData = {
scrubberMonth: segmentDate,
overallScrollPercent: hoverAtScrubberBottom ? 1 : toTimelineY(hoverY),
overallScrollPercent: toTimelineY(hoverY),
scrubberMonthScrollPercent: monthGroupPercentY,
};
if (wasDragging === false && isDragging) {

View File

@@ -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) => {

View File

@@ -110,13 +110,9 @@
case AssetAction.ARCHIVE:
case AssetAction.UNARCHIVE:
case AssetAction.FAVORITE:
case AssetAction.UNFAVORITE: {
timelineManager.updateAssets([action.asset]);
break;
}
case AssetAction.UNFAVORITE:
case AssetAction.ADD: {
timelineManager.addAssets([action.asset]);
timelineManager.upsertAssets([action.asset]);
break;
}
@@ -135,7 +131,7 @@
break;
}
case AssetAction.REMOVE_ASSET_FROM_STACK: {
timelineManager.addAssets([toTimelineAsset(action.asset)]);
timelineManager.upsertAssets([toTimelineAsset(action.asset)]);
if (action.stack) {
//Have to unstack then restack assets in timeline in order to update the stack count in the timeline.
updateUnstackedAssetInTimeline(

View File

@@ -2,7 +2,7 @@
import Thumbnail from '$lib/components/assets/thumbnail/thumbnail.svelte';
import type { DayGroup } from '$lib/managers/timeline-manager/day-group.svelte';
import type { MonthGroup } from '$lib/managers/timeline-manager/month-group.svelte';
import type { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
import { assetSnapshot, assetsSnapshot } from '$lib/managers/timeline-manager/utils.svelte';
import type { AssetInteraction } from '$lib/stores/asset-interaction.svelte';

View File

@@ -46,7 +46,7 @@
!(isTrashEnabled && !force),
(assetIds) => timelineManager.removeAssets(assetIds),
assetInteraction.selectedAssets,
!isTrashEnabled || force ? undefined : (assets) => timelineManager.addAssets(assets),
!isTrashEnabled || force ? undefined : (assets) => timelineManager.upsertAssets(assets),
);
assetInteraction.clearMultiselect();
};

View File

@@ -59,6 +59,8 @@ export enum AppRoute {
FOLDERS = '/folders',
TAGS = '/tags',
LOCKED = '/locked',
MAINTENANCE = '/maintenance',
}
export enum ProjectionType {

View File

@@ -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;

View File

@@ -4,9 +4,9 @@ import type { CommonLayoutOptions } from '$lib/utils/layout-utils';
import { getJustifiedLayoutFromAssets } from '$lib/utils/layout-utils';
import { plainDateTimeCompare } from '$lib/utils/timeline-util';
import { SvelteSet } from 'svelte/reactivity';
import { onCreateDayGroup } from '$lib/managers/timeline-manager/internal/TestHooks.svelte';
import type { MonthGroup } from './month-group.svelte';
import type { AssetOperation, Direction, MoveAsset, TimelineAsset } from './types';
import type { AssetOperation, Direction, TimelineAsset } from './types';
import { ViewerAsset } from './viewer-asset.svelte';
export class DayGroup {
@@ -31,6 +31,9 @@ export class DayGroup {
this.monthGroup = monthGroup;
this.day = day;
this.groupTitle = groupTitle;
if (import.meta.env.DEV) {
onCreateDayGroup(this);
}
}
get top() {
@@ -104,15 +107,18 @@ export class DayGroup {
runAssetOperation(ids: Set<string>, operation: AssetOperation) {
if (ids.size === 0) {
return {
moveAssets: [] as MoveAsset[],
processedIds: new SvelteSet<string>(),
moveAssets: [] as TimelineAsset[],
// eslint-disable-next-line svelte/prefer-svelte-reactivity
processedIds: new Set<string>(),
unprocessedIds: ids,
changedGeometry: false,
};
}
const unprocessedIds = new SvelteSet<string>(ids);
const processedIds = new SvelteSet<string>();
const moveAssets: MoveAsset[] = [];
// eslint-disable-next-line svelte/prefer-svelte-reactivity
const unprocessedIds = new Set<string>(ids);
// eslint-disable-next-line svelte/prefer-svelte-reactivity
const processedIds = new Set<string>();
const moveAssets: TimelineAsset[] = [];
let changedGeometry = false;
for (const assetId of unprocessedIds) {
const index = this.viewerAssets.findIndex((viewAsset) => viewAsset.id == assetId);
@@ -121,13 +127,20 @@ export class DayGroup {
}
const asset = this.viewerAssets[index].asset!;
// save old time, pre-mutating operation
const oldTime = { ...asset.localDateTime };
let { remove } = operation(asset);
const opResult = operation(asset);
let remove = false;
if (opResult) {
remove = (opResult as { remove: boolean }).remove ?? false;
}
const newTime = asset.localDateTime;
if (oldTime.year !== newTime.year || oldTime.month !== newTime.month || oldTime.day !== newTime.day) {
const { year, month, day } = newTime;
if (
!remove &&
(oldTime.year !== newTime.year || oldTime.month !== newTime.month || oldTime.day !== newTime.day)
) {
remove = true;
moveAssets.push({ asset, date: { year, month, day } });
moveAssets.push(asset);
}
unprocessedIds.delete(assetId);
processedIds.add(assetId);
@@ -140,7 +153,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;
}

View File

@@ -1,6 +1,5 @@
import { setDifference, type TimelineDate } from '$lib/utils/timeline-util';
import { AssetOrder } from '@immich/sdk';
import { SvelteSet } from 'svelte/reactivity';
import type { DayGroup } from './day-group.svelte';
import type { MonthGroup } from './month-group.svelte';
import type { TimelineAsset } from './types';
@@ -10,8 +9,10 @@ export class GroupInsertionCache {
[year: number]: { [month: number]: { [day: number]: DayGroup } };
} = {};
unprocessedAssets: TimelineAsset[] = [];
changedDayGroups = new SvelteSet<DayGroup>();
newDayGroups = new SvelteSet<DayGroup>();
// eslint-disable-next-line svelte/prefer-svelte-reactivity
changedDayGroups = new Set<DayGroup>();
// eslint-disable-next-line svelte/prefer-svelte-reactivity
newDayGroups = new Set<DayGroup>();
getDayGroup({ year, month, day }: TimelineDate): DayGroup | undefined {
return this.#lookupCache[year]?.[month]?.[day];
@@ -32,7 +33,8 @@ export class GroupInsertionCache {
}
get updatedBuckets() {
const updated = new SvelteSet<MonthGroup>();
// eslint-disable-next-line svelte/prefer-svelte-reactivity
const updated = new Set<MonthGroup>();
for (const group of this.changedDayGroups) {
updated.add(group.monthGroup);
}
@@ -40,7 +42,8 @@ export class GroupInsertionCache {
}
get bucketsWithNewDayGroups() {
const updated = new SvelteSet<MonthGroup>();
// eslint-disable-next-line svelte/prefer-svelte-reactivity
const updated = new Set<MonthGroup>();
for (const group of this.newDayGroups) {
updated.add(group.monthGroup);
}

View File

@@ -0,0 +1,16 @@
import type { DayGroup } from '$lib/managers/timeline-manager/day-group.svelte';
import type { MonthGroup } from '$lib/managers/timeline-manager/month-group.svelte';
let testHooks: TestHooks | undefined = undefined;
export type TestHooks = {
onCreateMonthGroup(monthGroup: MonthGroup): unknown;
onCreateDayGroup(dayGroup: DayGroup): unknown;
};
export const setTestHooks = (hooks: TestHooks) => {
testHooks = hooks;
};
export const onCreateMonthGroup = (monthGroup: MonthGroup) => testHooks?.onCreateMonthGroup(monthGroup);
export const onCreateDayGroup = (dayGroup: DayGroup) => testHooks?.onCreateDayGroup(dayGroup);

View File

@@ -1,6 +1,6 @@
import { TUNABLES } from '$lib/utils/tunables';
import type { MonthGroup } from '../month-group.svelte';
import type { TimelineManager } from '../timeline-manager.svelte';
import { TimelineManager } from '../timeline-manager.svelte';
const {
TIMELINE: { INTERSECTION_EXPAND_TOP, INTERSECTION_EXPAND_BOTTOM },

View File

@@ -1,5 +1,5 @@
import type { MonthGroup } from '../month-group.svelte';
import type { TimelineManager } from '../timeline-manager.svelte';
import { TimelineManager } from '../timeline-manager.svelte';
import type { UpdateGeometryOptions } from '../types';
export function updateGeometry(timelineManager: TimelineManager, month: MonthGroup, options: UpdateGeometryOptions) {
@@ -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;

View File

@@ -2,7 +2,7 @@ import { authManager } from '$lib/managers/auth-manager.svelte';
import { toISOYearMonthUTC } from '$lib/utils/timeline-util';
import { getTimeBucket } from '@immich/sdk';
import type { MonthGroup } from '../month-group.svelte';
import type { TimelineManager } from '../timeline-manager.svelte';
import { TimelineManager } from '../timeline-manager.svelte';
import type { TimelineManagerOptions } from '../types';
export async function loadFromTimeBuckets(
@@ -38,12 +38,15 @@ export async function loadFromTimeBuckets(
},
{ signal },
);
if (!albumAssets) {
return;
}
for (const id of albumAssets.id) {
timelineManager.albumAssets.add(id);
}
}
const unprocessedAssets = monthGroup.addAssets(bucketResponse);
const unprocessedAssets = monthGroup.addAssets(bucketResponse, true);
if (unprocessedAssets.length > 0) {
console.error(
`Warning: getTimeBucket API returning assets not in requested month: ${monthGroup.yearMonth.month}, ${JSON.stringify(

View File

@@ -1,104 +0,0 @@
import { setDifference, type TimelineDate } from '$lib/utils/timeline-util';
import { AssetOrder } from '@immich/sdk';
import { SvelteSet } from 'svelte/reactivity';
import { GroupInsertionCache } from '../group-insertion-cache.svelte';
import { MonthGroup } from '../month-group.svelte';
import type { TimelineManager } from '../timeline-manager.svelte';
import type { AssetOperation, TimelineAsset } from '../types';
import { updateGeometry } from './layout-support.svelte';
import { getMonthGroupByDate } from './search-support.svelte';
export function addAssetsToMonthGroups(
timelineManager: TimelineManager,
assets: TimelineAsset[],
options: { order: AssetOrder },
) {
if (assets.length === 0) {
return;
}
const addContext = new GroupInsertionCache();
const updatedMonthGroups = new SvelteSet<MonthGroup>();
const monthCount = timelineManager.months.length;
for (const asset of assets) {
let month = getMonthGroupByDate(timelineManager, asset.localDateTime);
if (!month) {
month = new MonthGroup(timelineManager, asset.localDateTime, 1, options.order);
month.isLoaded = true;
timelineManager.months.push(month);
}
month.addTimelineAsset(asset, addContext);
updatedMonthGroups.add(month);
}
if (timelineManager.months.length !== monthCount) {
timelineManager.months.sort((a, b) => {
return a.yearMonth.year === b.yearMonth.year
? b.yearMonth.month - a.yearMonth.month
: b.yearMonth.year - a.yearMonth.year;
});
}
for (const group of addContext.existingDayGroups) {
group.sortAssets(options.order);
}
for (const monthGroup of addContext.bucketsWithNewDayGroups) {
monthGroup.sortDayGroups();
}
for (const month of addContext.updatedBuckets) {
month.sortDayGroups();
updateGeometry(timelineManager, month, { invalidateHeight: true });
}
timelineManager.updateIntersections();
}
export function runAssetOperation(
timelineManager: TimelineManager,
ids: Set<string>,
operation: AssetOperation,
options: { order: AssetOrder },
) {
if (ids.size === 0) {
return { processedIds: new SvelteSet(), unprocessedIds: ids, changedGeometry: false };
}
const changedMonthGroups = new SvelteSet<MonthGroup>();
let idsToProcess = new SvelteSet(ids);
const idsProcessed = new SvelteSet<string>();
const combinedMoveAssets: { asset: TimelineAsset; date: TimelineDate }[][] = [];
for (const month of timelineManager.months) {
if (idsToProcess.size > 0) {
const { moveAssets, processedIds, changedGeometry } = month.runAssetOperation(idsToProcess, operation);
if (moveAssets.length > 0) {
combinedMoveAssets.push(moveAssets);
}
idsToProcess = setDifference(idsToProcess, processedIds);
for (const id of processedIds) {
idsProcessed.add(id);
}
if (changedGeometry) {
changedMonthGroups.add(month);
}
}
}
if (combinedMoveAssets.length > 0) {
addAssetsToMonthGroups(
timelineManager,
combinedMoveAssets.flat().map((a) => a.asset),
options,
);
}
const changedGeometry = changedMonthGroups.size > 0;
for (const month of changedMonthGroups) {
updateGeometry(timelineManager, month, { invalidateHeight: true });
}
if (changedGeometry) {
timelineManager.updateIntersections();
}
return { unprocessedIds: idsToProcess, processedIds: idsProcessed, changedGeometry };
}

View File

@@ -2,7 +2,7 @@ import { plainDateTimeCompare, type TimelineYearMonth } from '$lib/utils/timelin
import { AssetOrder } from '@immich/sdk';
import { DateTime } from 'luxon';
import type { MonthGroup } from '../month-group.svelte';
import type { TimelineManager } from '../timeline-manager.svelte';
import { TimelineManager } from '../timeline-manager.svelte';
import type { AssetDescriptor, Direction, TimelineAsset } from '../types';
export async function getAssetWithOffset(

View File

@@ -1,4 +1,4 @@
import type { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
import type { PendingChange, TimelineAsset } from '$lib/managers/timeline-manager/types';
import { websocketEvents } from '$lib/stores/websocket';
import { toTimelineAsset } from '$lib/utils/timeline-util';
@@ -13,10 +13,10 @@ export class WebsocketSupport {
#processPendingChanges = throttle(() => {
const { add, update, remove } = this.#getPendingChangeBatches();
if (add.length > 0) {
this.#timelineManager.addAssets(add);
this.#timelineManager.upsertAssets(add);
}
if (update.length > 0) {
this.#timelineManager.updateAssets(update);
this.#timelineManager.upsertAssets(update);
}
if (remove.length > 0) {
this.#timelineManager.removeAssets(remove);

View File

@@ -9,7 +9,7 @@ import {
fromTimelinePlainDateTime,
fromTimelinePlainYearMonth,
getTimes,
setDifference,
setDifferenceInPlace,
type TimelineDateTime,
type TimelineYearMonth,
} from '$lib/utils/timeline-util';
@@ -17,11 +17,11 @@ import {
import { t } from 'svelte-i18n';
import { get } from 'svelte/store';
import { SvelteSet } from 'svelte/reactivity';
import { onCreateMonthGroup } from '$lib/managers/timeline-manager/internal/TestHooks.svelte';
import { DayGroup } from './day-group.svelte';
import { GroupInsertionCache } from './group-insertion-cache.svelte';
import type { TimelineManager } from './timeline-manager.svelte';
import type { AssetDescriptor, AssetOperation, Direction, MoveAsset, TimelineAsset } from './types';
import { TimelineManager } from './timeline-manager.svelte';
import type { AssetDescriptor, AssetOperation, Direction, TimelineAsset } from './types';
import { ViewerAsset } from './viewer-asset.svelte';
export class MonthGroup {
@@ -50,12 +50,13 @@ export class MonthGroup {
readonly yearMonth: TimelineYearMonth;
constructor(
store: TimelineManager,
timelineManager: TimelineManager,
yearMonth: TimelineYearMonth,
initialCount: number,
loaded: boolean,
order: AssetOrder = AssetOrder.Desc,
) {
this.timelineManager = store;
this.timelineManager = timelineManager;
this.#initialCount = initialCount;
this.#sortOrder = order;
@@ -72,6 +73,12 @@ export class MonthGroup {
},
this.#handleLoadError,
);
if (loaded) {
this.isLoaded = true;
}
if (import.meta.env.DEV) {
onCreateMonthGroup(this);
}
}
set intersecting(newValue: boolean) {
@@ -115,26 +122,29 @@ export class MonthGroup {
runAssetOperation(ids: Set<string>, operation: AssetOperation) {
if (ids.size === 0) {
return {
moveAssets: [] as MoveAsset[],
processedIds: new SvelteSet<string>(),
moveAssets: [] as TimelineAsset[],
// eslint-disable-next-line svelte/prefer-svelte-reactivity
processedIds: new Set<string>(),
unprocessedIds: ids,
changedGeometry: false,
};
}
const { dayGroups } = this;
let combinedChangedGeometry = false;
let idsToProcess = new SvelteSet(ids);
const idsProcessed = new SvelteSet<string>();
const combinedMoveAssets: MoveAsset[][] = [];
// eslint-disable-next-line svelte/prefer-svelte-reactivity
const idsToProcess = new Set(ids);
// eslint-disable-next-line svelte/prefer-svelte-reactivity
const idsProcessed = new Set<string>();
const combinedMoveAssets: TimelineAsset[] = [];
let index = dayGroups.length;
while (index--) {
if (idsToProcess.size > 0) {
const group = dayGroups[index];
const { moveAssets, processedIds, changedGeometry } = group.runAssetOperation(ids, operation);
if (moveAssets.length > 0) {
combinedMoveAssets.push(moveAssets);
combinedMoveAssets.push(...moveAssets);
}
idsToProcess = setDifference(idsToProcess, processedIds);
setDifferenceInPlace(idsToProcess, processedIds);
for (const id of processedIds) {
idsProcessed.add(id);
}
@@ -146,14 +156,14 @@ export class MonthGroup {
}
}
return {
moveAssets: combinedMoveAssets.flat(),
moveAssets: combinedMoveAssets,
unprocessedIds: idsToProcess,
processedIds: idsProcessed,
changedGeometry: combinedChangedGeometry,
};
}
addAssets(bucketAssets: TimeBucketAssetResponseDto) {
addAssets(bucketAssets: TimeBucketAssetResponseDto, preSorted: boolean) {
const addContext = new GroupInsertionCache();
for (let i = 0; i < bucketAssets.id.length; i++) {
const { localDateTime, fileCreatedAt } = getTimes(
@@ -194,17 +204,17 @@ export class MonthGroup {
}
this.addTimelineAsset(timelineAsset, addContext);
}
if (!preSorted) {
for (const group of addContext.existingDayGroups) {
group.sortAssets(this.#sortOrder);
}
for (const group of addContext.existingDayGroups) {
group.sortAssets(this.#sortOrder);
if (addContext.newDayGroups.size > 0) {
this.sortDayGroups();
}
addContext.sort(this, this.#sortOrder);
}
if (addContext.newDayGroups.size > 0) {
this.sortDayGroups();
}
addContext.sort(this, this.#sortOrder);
return addContext.unprocessedAssets;
}

View File

@@ -1,10 +1,14 @@
import { sdkMock } from '$lib/__mocks__/sdk.mock';
import type { DayGroup } from '$lib/managers/timeline-manager/day-group.svelte';
import { getMonthGroupByDate } from '$lib/managers/timeline-manager/internal/search-support.svelte';
import { setTestHooks } from '$lib/managers/timeline-manager/internal/TestHooks.svelte';
import type { MonthGroup } from '$lib/managers/timeline-manager/month-group.svelte';
import { AbortError } from '$lib/utils';
import { fromISODateTimeUTCToObject } from '$lib/utils/timeline-util';
import { type AssetResponseDto, type TimeBucketAssetResponseDto } from '@immich/sdk';
import { AssetVisibility, type AssetResponseDto, type TimeBucketAssetResponseDto } from '@immich/sdk';
import { timelineAssetFactory, toResponseDto } from '@test-data/factories/asset-factory';
import { tick } from 'svelte';
import type { MockInstance } from 'vitest';
import { TimelineManager } from './timeline-manager.svelte';
import type { TimelineAsset } from './types';
@@ -84,13 +88,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);
});
});
@@ -175,7 +179,7 @@ describe('TimelineManager', () => {
});
});
describe('addAssets', () => {
describe('upsertAssets', () => {
let timelineManager: TimelineManager;
beforeEach(async () => {
@@ -196,7 +200,7 @@ describe('TimelineManager', () => {
fileCreatedAt: fromISODateTimeUTCToObject('2024-01-20T12:00:00.000Z'),
}),
);
timelineManager.addAssets([asset]);
timelineManager.upsertAssets([asset]);
expect(timelineManager.months.length).toEqual(1);
expect(timelineManager.assetCount).toEqual(1);
@@ -212,8 +216,8 @@ describe('TimelineManager', () => {
fileCreatedAt: fromISODateTimeUTCToObject('2024-01-20T12:00:00.000Z'),
})
.map((asset) => deriveLocalDateTimeFromFileCreatedAt(asset));
timelineManager.addAssets([assetOne]);
timelineManager.addAssets([assetTwo]);
timelineManager.upsertAssets([assetOne]);
timelineManager.upsertAssets([assetTwo]);
expect(timelineManager.months.length).toEqual(1);
expect(timelineManager.assetCount).toEqual(2);
@@ -238,7 +242,7 @@ describe('TimelineManager', () => {
fileCreatedAt: fromISODateTimeUTCToObject('2024-01-16T12:00:00.000Z'),
}),
);
timelineManager.addAssets([assetOne, assetTwo, assetThree]);
timelineManager.upsertAssets([assetOne, assetTwo, assetThree]);
const month = getMonthGroupByDate(timelineManager, { year: 2024, month: 1 });
expect(month).not.toBeNull();
@@ -264,7 +268,7 @@ describe('TimelineManager', () => {
fileCreatedAt: fromISODateTimeUTCToObject('2023-01-20T12:00:00.000Z'),
}),
);
timelineManager.addAssets([assetOne, assetTwo, assetThree]);
timelineManager.upsertAssets([assetOne, assetTwo, assetThree]);
expect(timelineManager.months.length).toEqual(3);
expect(timelineManager.months[0].yearMonth.year).toEqual(2024);
@@ -278,11 +282,11 @@ describe('TimelineManager', () => {
});
it('updates existing asset', () => {
const updateAssetsSpy = vi.spyOn(timelineManager, 'updateAssets');
const updateAssetsSpy = vi.spyOn(timelineManager, 'upsertAssets');
const asset = deriveLocalDateTimeFromFileCreatedAt(timelineAssetFactory.build());
timelineManager.addAssets([asset]);
timelineManager.upsertAssets([asset]);
timelineManager.addAssets([asset]);
timelineManager.upsertAssets([asset]);
expect(updateAssetsSpy).toBeCalledWith([asset]);
expect(timelineManager.assetCount).toEqual(1);
});
@@ -294,12 +298,128 @@ describe('TimelineManager', () => {
const timelineManager = new TimelineManager();
await timelineManager.updateOptions({ isTrashed: true });
timelineManager.addAssets([asset, trashedAsset]);
timelineManager.upsertAssets([asset, trashedAsset]);
expect(await getAssets(timelineManager)).toEqual([trashedAsset]);
});
});
describe('updateAssets', () => {
describe('ensure efficient timeline operations', () => {
let timelineManager: TimelineManager;
let month1day1asset1: TimelineAsset,
month1day2asset1: TimelineAsset,
month1day2asset2: TimelineAsset,
month1day3asset1: TimelineAsset,
month2day1asset1: TimelineAsset,
month2day2asset1: TimelineAsset,
month2day2asset2: TimelineAsset;
type DayMocks = {
layoutFn: MockInstance;
sortAssetsFn: MockInstance;
};
type MonthMocks = {
sortDayGroupsFn: MockInstance;
};
const dayGroups = new Map<DayGroup, DayMocks>();
const monthGroups = new Map<MonthGroup, MonthMocks>();
beforeEach(async () => {
timelineManager = new TimelineManager();
setTestHooks({
onCreateDayGroup: (dayGroup: DayGroup) => {
dayGroups.set(dayGroup, {
layoutFn: vi.spyOn(dayGroup, 'layout'),
sortAssetsFn: vi.spyOn(dayGroup, 'sortAssets'),
});
},
onCreateMonthGroup: (monthGroup: MonthGroup) => {
monthGroups.set(monthGroup, {
sortDayGroupsFn: vi.spyOn(monthGroup, 'sortDayGroups'),
});
},
});
sdkMock.getTimeBuckets.mockResolvedValue([]);
month1day1asset1 = deriveLocalDateTimeFromFileCreatedAt(
timelineAssetFactory.build({
fileCreatedAt: fromISODateTimeUTCToObject('2024-01-20T12:00:00.000Z'),
}),
);
month1day2asset1 = deriveLocalDateTimeFromFileCreatedAt(
timelineAssetFactory.build({
fileCreatedAt: fromISODateTimeUTCToObject('2024-01-15T12:00:00.000Z'),
}),
);
month1day2asset2 = deriveLocalDateTimeFromFileCreatedAt(
timelineAssetFactory.build({
fileCreatedAt: fromISODateTimeUTCToObject('2024-01-15T13:00:00.000Z'),
}),
);
month1day3asset1 = deriveLocalDateTimeFromFileCreatedAt(
timelineAssetFactory.build({
fileCreatedAt: fromISODateTimeUTCToObject('2024-01-16T12:00:00.000Z'),
}),
);
month2day1asset1 = deriveLocalDateTimeFromFileCreatedAt(
timelineAssetFactory.build({
fileCreatedAt: fromISODateTimeUTCToObject('2024-02-16T12:00:00.000Z'),
}),
);
month2day2asset1 = deriveLocalDateTimeFromFileCreatedAt(
timelineAssetFactory.build({
fileCreatedAt: fromISODateTimeUTCToObject('2024-02-18T12:00:00.000Z'),
}),
);
month2day2asset2 = deriveLocalDateTimeFromFileCreatedAt(
timelineAssetFactory.build({
fileCreatedAt: fromISODateTimeUTCToObject('2024-02-18T13:00:00.000Z'),
}),
);
await timelineManager.updateViewport({ width: 1588, height: 1000 });
timelineManager.upsertAssets([
month1day1asset1,
month1day2asset1,
month1day2asset2,
month1day3asset1,
month2day1asset1,
month2day2asset1,
month2day2asset2,
]);
vitest.resetAllMocks();
});
it.skip('Not Ready Yet - optimizations not complete: moving asset between months only sorts/layout the affected months once', () => {
// move from 2024-01-15 to 2024-01-16
timelineManager.updateAssetOperation([month1day2asset1.id], (asset) => {
asset.localDateTime.day = asset.localDateTime.day + 1;
});
for (const [day, mocks] of dayGroups) {
if (day.day === 15 && day.monthGroup.yearMonth.month === 1) {
// source - should be layout once
expect.soft(mocks.layoutFn).toBeCalledTimes(1);
expect.soft(mocks.sortAssetsFn).toBeCalledTimes(1);
}
if (day.day === 16 && day.monthGroup.yearMonth.month === 1) {
// target - should be layout once
expect.soft(mocks.layoutFn).toBeCalledTimes(1);
expect.soft(mocks.sortAssetsFn).toBeCalledTimes(1);
}
// everything else - should not be layed-out
expect.soft(mocks.layoutFn).toBeCalledTimes(0);
expect.soft(mocks.sortAssetsFn).toBeCalledTimes(0);
}
for (const [_, mocks] of monthGroups) {
// if the day itself did not change, probably no need to sort it
// in the timeline manager, the day-group identity is immutable - you will never
// "move" a whole day to another day - only the assets inside will be moved from
// one to the other.
expect.soft(mocks.sortDayGroupsFn).toBeCalledTimes(0);
}
});
});
describe('upsertAssets', () => {
let timelineManager: TimelineManager;
beforeEach(async () => {
@@ -309,22 +429,15 @@ describe('TimelineManager', () => {
await timelineManager.updateViewport({ width: 1588, height: 1000 });
});
it('ignores non-existing assets', () => {
timelineManager.updateAssets([deriveLocalDateTimeFromFileCreatedAt(timelineAssetFactory.build())]);
expect(timelineManager.months.length).toEqual(0);
expect(timelineManager.assetCount).toEqual(0);
});
it('updates an asset', () => {
it('upserts an asset', () => {
const asset = deriveLocalDateTimeFromFileCreatedAt(timelineAssetFactory.build({ isFavorite: false }));
const updatedAsset = { ...asset, isFavorite: true };
timelineManager.addAssets([asset]);
timelineManager.upsertAssets([asset]);
expect(timelineManager.assetCount).toEqual(1);
expect(timelineManager.months[0].getFirstAsset().isFavorite).toEqual(false);
timelineManager.updateAssets([updatedAsset]);
timelineManager.upsertAssets([updatedAsset]);
expect(timelineManager.assetCount).toEqual(1);
expect(timelineManager.months[0].getFirstAsset().isFavorite).toEqual(true);
});
@@ -340,18 +453,81 @@ describe('TimelineManager', () => {
fileCreatedAt: fromISODateTimeUTCToObject('2024-03-20T12:00:00.000Z'),
});
timelineManager.addAssets([asset]);
timelineManager.upsertAssets([asset]);
expect(timelineManager.months.length).toEqual(1);
expect(getMonthGroupByDate(timelineManager, { year: 2024, month: 1 })).not.toBeUndefined();
expect(getMonthGroupByDate(timelineManager, { year: 2024, month: 1 })?.getAssets().length).toEqual(1);
timelineManager.updateAssets([updatedAsset]);
timelineManager.upsertAssets([updatedAsset]);
expect(timelineManager.months.length).toEqual(2);
expect(getMonthGroupByDate(timelineManager, { year: 2024, month: 1 })).not.toBeUndefined();
expect(getMonthGroupByDate(timelineManager, { year: 2024, month: 1 })?.getAssets().length).toEqual(0);
expect(getMonthGroupByDate(timelineManager, { year: 2024, month: 3 })).not.toBeUndefined();
expect(getMonthGroupByDate(timelineManager, { year: 2024, month: 3 })?.getAssets().length).toEqual(1);
});
it('asset is removed during upsert when TimelineManager if visibility changes', async () => {
await timelineManager.updateOptions({
visibility: AssetVisibility.Archive,
});
const fixture = deriveLocalDateTimeFromFileCreatedAt(
timelineAssetFactory.build({
visibility: AssetVisibility.Archive,
}),
);
timelineManager.upsertAssets([fixture]);
expect(timelineManager.assetCount).toEqual(1);
const updated = Object.freeze({ ...fixture, visibility: AssetVisibility.Timeline });
timelineManager.upsertAssets([updated]);
expect(timelineManager.assetCount).toEqual(0);
timelineManager.upsertAssets([{ ...fixture, visibility: AssetVisibility.Archive }]);
expect(timelineManager.assetCount).toEqual(1);
});
it('asset is removed during upsert when TimelineManager if isFavorite changes', async () => {
await timelineManager.updateOptions({
isFavorite: true,
});
const fixture = deriveLocalDateTimeFromFileCreatedAt(
timelineAssetFactory.build({
isFavorite: true,
}),
);
timelineManager.upsertAssets([fixture]);
expect(timelineManager.assetCount).toEqual(1);
const updated = Object.freeze({ ...fixture, isFavorite: false });
timelineManager.upsertAssets([updated]);
expect(timelineManager.assetCount).toEqual(0);
timelineManager.upsertAssets([{ ...fixture, isFavorite: true }]);
expect(timelineManager.assetCount).toEqual(1);
});
it('asset is removed during upsert when TimelineManager if isTrashed changes', async () => {
await timelineManager.updateOptions({
isTrashed: true,
});
const fixture = deriveLocalDateTimeFromFileCreatedAt(
timelineAssetFactory.build({
isTrashed: true,
}),
);
timelineManager.upsertAssets([fixture]);
expect(timelineManager.assetCount).toEqual(1);
const updated = Object.freeze({ ...fixture, isTrashed: false });
timelineManager.upsertAssets([updated]);
expect(timelineManager.assetCount).toEqual(0);
timelineManager.upsertAssets([{ ...fixture, isTrashed: true }]);
expect(timelineManager.assetCount).toEqual(1);
});
});
describe('removeAssets', () => {
@@ -365,7 +541,7 @@ describe('TimelineManager', () => {
});
it('ignores invalid IDs', () => {
timelineManager.addAssets(
timelineManager.upsertAssets(
timelineAssetFactory
.buildList(2, {
fileCreatedAt: fromISODateTimeUTCToObject('2024-01-20T12:00:00.000Z'),
@@ -385,7 +561,7 @@ describe('TimelineManager', () => {
fileCreatedAt: fromISODateTimeUTCToObject('2024-01-20T12:00:00.000Z'),
})
.map((asset) => deriveLocalDateTimeFromFileCreatedAt(asset));
timelineManager.addAssets([assetOne, assetTwo]);
timelineManager.upsertAssets([assetOne, assetTwo]);
timelineManager.removeAssets([assetOne.id]);
expect(timelineManager.assetCount).toEqual(1);
@@ -399,7 +575,7 @@ describe('TimelineManager', () => {
fileCreatedAt: fromISODateTimeUTCToObject('2024-01-20T12:00:00.000Z'),
})
.map((asset) => deriveLocalDateTimeFromFileCreatedAt(asset));
timelineManager.addAssets(assets);
timelineManager.upsertAssets(assets);
timelineManager.removeAssets(assets.map((asset) => asset.id));
expect(timelineManager.assetCount).toEqual(0);
@@ -431,7 +607,7 @@ describe('TimelineManager', () => {
fileCreatedAt: fromISODateTimeUTCToObject('2024-01-15T12:00:00.000Z'),
}),
);
timelineManager.addAssets([assetOne, assetTwo]);
timelineManager.upsertAssets([assetOne, assetTwo]);
expect(timelineManager.getFirstAsset()).toEqual(assetOne);
});
});
@@ -556,7 +732,7 @@ describe('TimelineManager', () => {
fileCreatedAt: fromISODateTimeUTCToObject('2024-02-15T12:00:00.000Z'),
}),
);
timelineManager.addAssets([assetOne, assetTwo]);
timelineManager.upsertAssets([assetOne, assetTwo]);
expect(timelineManager.getMonthGroupByAssetId(assetTwo.id)?.yearMonth.year).toEqual(2024);
expect(timelineManager.getMonthGroupByAssetId(assetTwo.id)?.yearMonth.month).toEqual(2);
@@ -575,7 +751,7 @@ describe('TimelineManager', () => {
fileCreatedAt: fromISODateTimeUTCToObject('2024-02-15T12:00:00.000Z'),
}),
);
timelineManager.addAssets([assetOne, assetTwo]);
timelineManager.upsertAssets([assetOne, assetTwo]);
timelineManager.removeAssets([assetTwo.id]);
expect(timelineManager.getMonthGroupByAssetId(assetOne.id)?.yearMonth.year).toEqual(2024);

View File

@@ -1,12 +1,9 @@
import { VirtualScrollManager } from '$lib/managers/VirtualScrollManager/VirtualScrollManager.svelte';
import { authManager } from '$lib/managers/auth-manager.svelte';
import { GroupInsertionCache } from '$lib/managers/timeline-manager/group-insertion-cache.svelte';
import { updateIntersectionMonthGroup } from '$lib/managers/timeline-manager/internal/intersection-support.svelte';
import { updateGeometry } from '$lib/managers/timeline-manager/internal/layout-support.svelte';
import { loadFromTimeBuckets } from '$lib/managers/timeline-manager/internal/load-support.svelte';
import {
addAssetsToMonthGroups,
runAssetOperation,
} from '$lib/managers/timeline-manager/internal/operations-support.svelte';
import {
findClosestGroupForDate,
findMonthGroupForAsset as findMonthGroupForAssetUtil,
@@ -17,10 +14,15 @@ import {
} from '$lib/managers/timeline-manager/internal/search-support.svelte';
import { WebsocketSupport } from '$lib/managers/timeline-manager/internal/websocket-support.svelte';
import { CancellableTask } from '$lib/utils/cancellable-task';
import { toTimelineAsset, type TimelineDateTime, type TimelineYearMonth } from '$lib/utils/timeline-util';
import {
setDifferenceInPlace,
toTimelineAsset,
type TimelineDateTime,
type TimelineYearMonth,
} from '$lib/utils/timeline-util';
import { AssetOrder, getAssetInfo, getTimeBuckets } from '@immich/sdk';
import { clamp, isEqual } from 'lodash-es';
import { SvelteDate, SvelteMap, SvelteSet } from 'svelte/reactivity';
import { SvelteDate, SvelteSet } from 'svelte/reactivity';
import { DayGroup } from './day-group.svelte';
import { isMismatched, updateObject } from './internal/utils.svelte';
import { MonthGroup } from './month-group.svelte';
@@ -61,6 +63,7 @@ export class TimelineManager extends VirtualScrollManager {
});
isInitialized = $state(false);
isScrollingOnLoad = false;
months: MonthGroup[] = $state([]);
albumAssets: Set<string> = new SvelteSet();
scrubberMonths: ScrubberMonth[] = $state([]);
@@ -217,6 +220,7 @@ export class TimelineManager extends VirtualScrollManager {
this,
{ year: date.getUTCFullYear(), month: date.getUTCMonth() + 1 },
timeBucket.count,
false,
this.#options.order,
);
});
@@ -319,10 +323,10 @@ export class TimelineManager extends VirtualScrollManager {
}
}
addAssets(assets: TimelineAsset[]) {
const assetsToUpdate = assets.filter((asset) => !this.isExcluded(asset));
const notUpdated = this.updateAssets(assetsToUpdate);
addAssetsToMonthGroups(this, [...notUpdated], { order: this.#options.order ?? AssetOrder.Desc });
upsertAssets(assets: TimelineAsset[]) {
const notUpdated = this.#updateAssets(assets);
const notExcluded = notUpdated.filter((asset) => !this.isExcluded(asset));
this.addAssetsToSegments(notExcluded);
}
async findMonthGroupForAsset(id: string) {
@@ -399,38 +403,112 @@ export class TimelineManager extends VirtualScrollManager {
return randomDay.viewerAssets[randomAssetIndex - accumulatedCount].asset;
}
/**
* Executes the given operation against every passed in asset id.
*
* @returns An object with the changed ids, unprocessed ids, and if this resulted
* in changes of the timeline geometry.
*/
updateAssetOperation(ids: string[], operation: AssetOperation) {
runAssetOperation(this, new SvelteSet(ids), operation, { order: this.#options.order ?? AssetOrder.Desc });
return this.#runAssetOperation(ids, operation);
}
updateAssets(assets: TimelineAsset[]) {
const lookup = new SvelteMap<string, TimelineAsset>(assets.map((asset) => [asset.id, asset]));
const { unprocessedIds } = runAssetOperation(
this,
new SvelteSet(lookup.keys()),
(asset) => {
updateObject(asset, lookup.get(asset.id));
return { remove: false };
},
{ order: this.#options.order ?? AssetOrder.Desc },
);
/**
* Looks up the specified asset from the TimelineAsset using its id, and then updates the
* existing object to match the rest of the TimelineAsset parameter.
* @returns list of assets that were updated (not found)
*/
#updateAssets(updatedAssets: TimelineAsset[]) {
// eslint-disable-next-line svelte/prefer-svelte-reactivity
const lookup = new Map<string, TimelineAsset>();
const ids = [];
for (const asset of updatedAssets) {
ids.push(asset.id);
lookup.set(asset.id, asset);
}
const { unprocessedIds } = this.#runAssetOperation(ids, (asset) => updateObject(asset, lookup.get(asset.id)));
const result: TimelineAsset[] = [];
for (const id of unprocessedIds.values()) {
for (const id of unprocessedIds) {
result.push(lookup.get(id)!);
}
return result;
}
removeAssets(ids: string[]) {
const { unprocessedIds } = runAssetOperation(
this,
new SvelteSet(ids),
() => {
return { remove: true };
},
{ order: this.#options.order ?? AssetOrder.Desc },
);
return [...unprocessedIds];
this.#runAssetOperation(ids, () => ({ remove: true }));
}
protected createUpsertContext(): GroupInsertionCache {
return new GroupInsertionCache();
}
protected upsertAssetIntoSegment(asset: TimelineAsset, context: GroupInsertionCache): void {
let month = getMonthGroupByDate(this, asset.localDateTime);
if (!month) {
month = new MonthGroup(this, asset.localDateTime, 1, true, this.#options.order);
this.months.push(month);
}
month.addTimelineAsset(asset, context);
}
protected addAssetsToSegments(assets: TimelineAsset[]) {
if (assets.length === 0) {
return;
}
const context = this.createUpsertContext();
const monthCount = this.months.length;
for (const asset of assets) {
this.upsertAssetIntoSegment(asset, context);
}
if (this.months.length !== monthCount) {
this.postCreateSegments();
}
this.postUpsert(context);
this.updateIntersections();
}
#runAssetOperation(ids: string[], operation: AssetOperation) {
if (ids.length === 0) {
// eslint-disable-next-line svelte/prefer-svelte-reactivity
return { processedIds: new Set<string>(), unprocessedIds: new Set<string>(), changedGeometry: false };
}
// eslint-disable-next-line svelte/prefer-svelte-reactivity
const changedMonthGroups = new Set<MonthGroup>();
// eslint-disable-next-line svelte/prefer-svelte-reactivity
const idsToProcess = new Set(ids);
// eslint-disable-next-line svelte/prefer-svelte-reactivity
const idsProcessed = new Set<string>();
const combinedMoveAssets: TimelineAsset[] = [];
for (const month of this.months) {
if (idsToProcess.size > 0) {
const { moveAssets, processedIds, changedGeometry } = month.runAssetOperation(idsToProcess, operation);
if (moveAssets.length > 0) {
combinedMoveAssets.push(...moveAssets);
}
setDifferenceInPlace(idsToProcess, processedIds);
for (const id of processedIds) {
idsProcessed.add(id);
}
if (changedGeometry) {
changedMonthGroups.add(month);
}
}
}
if (combinedMoveAssets.length > 0) {
this.addAssetsToSegments(combinedMoveAssets);
}
const changedGeometry = changedMonthGroups.size > 0;
for (const month of changedMonthGroups) {
updateGeometry(this, month, { invalidateHeight: true });
}
if (changedGeometry) {
this.updateIntersections();
}
return { unprocessedIds: idsToProcess, processedIds: idsProcessed, changedGeometry };
}
override refreshLayout() {
@@ -492,4 +570,27 @@ export class TimelineManager extends VirtualScrollManager {
getAssetOrder() {
return this.#options.order ?? AssetOrder.Desc;
}
protected postCreateSegments(): void {
this.months.sort((a, b) => {
return a.yearMonth.year === b.yearMonth.year
? b.yearMonth.month - a.yearMonth.month
: b.yearMonth.year - a.yearMonth.year;
});
}
protected postUpsert(context: GroupInsertionCache): void {
for (const group of context.existingDayGroups) {
group.sortAssets(this.#options.order);
}
for (const monthGroup of context.bucketsWithNewDayGroups) {
monthGroup.sortDayGroups();
}
for (const month of context.updatedBuckets) {
month.sortDayGroups();
updateGeometry(this, month, { invalidateHeight: true });
}
}
}

View File

@@ -1,4 +1,4 @@
import type { TimelineDate, TimelineDateTime, TimelineYearMonth } from '$lib/utils/timeline-util';
import type { TimelineDateTime, TimelineYearMonth } from '$lib/utils/timeline-util';
import type { AssetStackResponseDto, AssetVisibility } from '@immich/sdk';
export type ViewportTopMonth = TimelineYearMonth | undefined | 'lead-in' | 'lead-out';
@@ -37,9 +37,7 @@ export type TimelineAsset = {
longitude?: number | null;
};
export type AssetOperation = (asset: TimelineAsset) => { remove: boolean };
export type MoveAsset = { asset: TimelineAsset; date: TimelineDate };
export type AssetOperation = (asset: TimelineAsset) => unknown;
export interface Viewport {
width: number;

View File

@@ -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>

Some files were not shown because too many files have changed in this diff Show More