mirror of
https://github.com/immich-app/immich.git
synced 2025-12-09 14:21:02 -08:00
Compare commits
16 Commits
feat/mobil
...
refactor/t
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8643a58444 | ||
|
|
16869a21de | ||
|
|
2db3f8c160 | ||
|
|
73571d9d69 | ||
|
|
d310c6f3cd | ||
|
|
c086a65fa8 | ||
|
|
7134dd29ca | ||
|
|
3e08953a43 | ||
|
|
58c3c7e26b | ||
|
|
237ddcb648 | ||
|
|
fbaeffd65c | ||
|
|
d64c339b4f | ||
|
|
69880ee165 | ||
|
|
15e00f82f0 | ||
|
|
ce82e27f4b | ||
|
|
eeee5147cc |
3
Makefile
3
Makefile
@@ -17,6 +17,9 @@ dev-docs:
|
||||
e2e:
|
||||
@trap 'make e2e-down' EXIT; COMPOSE_BAKE=true docker compose -f ./e2e/docker-compose.yml up --remove-orphans
|
||||
|
||||
e2e-dev:
|
||||
@trap 'make e2e-down' EXIT; COMPOSE_BAKE=true docker compose -f ./e2e/docker-compose.dev.yml up --remove-orphans
|
||||
|
||||
e2e-update:
|
||||
@trap 'make e2e-down' EXIT; COMPOSE_BAKE=true docker compose -f ./e2e/docker-compose.yml up --build -V --remove-orphans
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
18
docs/docs/administration/maintenance-mode.md
Normal file
18
docs/docs/administration/maintenance-mode.md
Normal 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.
|
||||
@@ -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
105
e2e/docker-compose.dev.yml
Normal 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:
|
||||
@@ -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",
|
||||
|
||||
172
e2e/src/api/specs/maintenance.e2e-spec.ts
Normal file
172
e2e/src/api/specs/maintenance.e2e-spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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',
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 });
|
||||
|
||||
52
e2e/src/web/specs/maintenance.e2e-spec.ts
Normal file
52
e2e/src/web/specs/maintenance.e2e-spec.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
28
i18n/en.json
28
i18n/en.json
@@ -17,7 +17,6 @@
|
||||
"add_birthday": "Add a birthday",
|
||||
"add_endpoint": "Add endpoint",
|
||||
"add_exclusion_pattern": "Add exclusion pattern",
|
||||
"add_import_path": "Add import path",
|
||||
"add_location": "Add location",
|
||||
"add_more_users": "Add more users",
|
||||
"add_partner": "Add partner",
|
||||
@@ -113,13 +112,17 @@
|
||||
"jobs_failed": "{jobCount, plural, other {# failed}}",
|
||||
"library_created": "Created library: {library}",
|
||||
"library_deleted": "Library deleted",
|
||||
"library_import_path_description": "Specify a folder to import. This folder, including subfolders, will be scanned for images and videos.",
|
||||
"library_details": "Library details",
|
||||
"library_folder_description": "Specify a folder to import. This folder, including subfolders, will be scanned for images and videos.",
|
||||
"library_remove_exclusion_pattern_prompt": "Are you sure you want to remove this exclusion pattern?",
|
||||
"library_remove_folder_prompt": "Are you sure you want to remove this import folder?",
|
||||
"library_scanning": "Periodic Scanning",
|
||||
"library_scanning_description": "Configure periodic library scanning",
|
||||
"library_scanning_enable_description": "Enable periodic library scanning",
|
||||
"library_settings": "External Library",
|
||||
"library_settings_description": "Manage external library settings",
|
||||
"library_tasks_description": "Scan external libraries for new and/or changed assets",
|
||||
"library_updated": "Updated library",
|
||||
"library_watching_enable_description": "Watch external libraries for file changes",
|
||||
"library_watching_settings": "Library watching [EXPERIMENTAL]",
|
||||
"library_watching_settings_description": "Automatically watch for changed files",
|
||||
@@ -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",
|
||||
|
||||
@@ -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(),
|
||||
];
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
6
mobile/openapi/README.md
generated
6
mobile/openapi/README.md
generated
@@ -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)
|
||||
|
||||
5
mobile/openapi/lib/api.dart
generated
5
mobile/openapi/lib/api.dart
generated
@@ -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';
|
||||
|
||||
122
mobile/openapi/lib/api/maintenance_admin_api.dart
generated
Normal file
122
mobile/openapi/lib/api/maintenance_admin_api.dart
generated
Normal 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));
|
||||
}
|
||||
}
|
||||
}
|
||||
8
mobile/openapi/lib/api_client.dart
generated
8
mobile/openapi/lib/api_client.dart
generated
@@ -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':
|
||||
|
||||
3
mobile/openapi/lib/api_helper.dart
generated
3
mobile/openapi/lib/api_helper.dart
generated
@@ -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();
|
||||
}
|
||||
|
||||
85
mobile/openapi/lib/model/maintenance_action.dart
generated
Normal file
85
mobile/openapi/lib/model/maintenance_action.dart
generated
Normal 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;
|
||||
}
|
||||
|
||||
99
mobile/openapi/lib/model/maintenance_auth_dto.dart
generated
Normal file
99
mobile/openapi/lib/model/maintenance_auth_dto.dart
generated
Normal 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',
|
||||
};
|
||||
}
|
||||
|
||||
108
mobile/openapi/lib/model/maintenance_login_dto.dart
generated
Normal file
108
mobile/openapi/lib/model/maintenance_login_dto.dart
generated
Normal 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>{
|
||||
};
|
||||
}
|
||||
|
||||
3
mobile/openapi/lib/model/permission.dart
generated
3
mobile/openapi/lib/model/permission.dart
generated
@@ -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;
|
||||
|
||||
10
mobile/openapi/lib/model/server_config_dto.dart
generated
10
mobile/openapi/lib/model/server_config_dto.dart
generated
@@ -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',
|
||||
|
||||
99
mobile/openapi/lib/model/set_maintenance_mode_dto.dart
generated
Normal file
99
mobile/openapi/lib/model/set_maintenance_mode_dto.dart
generated
Normal 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',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -452,10 +452,11 @@ packages:
|
||||
drift:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: drift
|
||||
sha256: "14a61af39d4584faf1d73b5b35e4b758a43008cf4c0fdb0576ec8e7032c0d9a5"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
path: drift
|
||||
ref: "53ef7e9f19fe8f68416251760b4b99fe43f1c575"
|
||||
resolved-ref: "53ef7e9f19fe8f68416251760b4b99fe43f1c575"
|
||||
url: "https://github.com/immich-app/drift"
|
||||
source: git
|
||||
version: "2.26.0"
|
||||
drift_dev:
|
||||
dependency: "direct dev"
|
||||
|
||||
@@ -113,6 +113,13 @@ dev_dependencies:
|
||||
riverpod_generator: ^2.6.1
|
||||
riverpod_lint: ^2.6.1
|
||||
|
||||
dependency_overrides:
|
||||
drift:
|
||||
git:
|
||||
url: https://github.com/immich-app/drift
|
||||
ref: '53ef7e9f19fe8f68416251760b4b99fe43f1c575'
|
||||
path: drift/
|
||||
|
||||
flutter:
|
||||
uses-material-design: true
|
||||
assets:
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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
341
pnpm-lock.yaml
generated
@@ -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:
|
||||
|
||||
@@ -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/
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
87
server/src/app.common.ts
Normal 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}] `);
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
37
server/src/commands/maintenance-mode.ts
Normal file
37
server/src/commands/maintenance-mode.ts
Normal 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.',
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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]:
|
||||
|
||||
@@ -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,
|
||||
|
||||
49
server/src/controllers/maintenance.controller.ts
Normal file
49
server/src/controllers/maintenance.controller.ts
Normal 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 }],
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
16
server/src/dtos/maintenance.dto.ts
Normal file
16
server/src/dtos/maintenance.dto.ts
Normal 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;
|
||||
}
|
||||
@@ -154,6 +154,7 @@ export class ServerConfigDto {
|
||||
publicUsers!: boolean;
|
||||
mapDarkStyleUrl!: string;
|
||||
mapLightStyleUrl!: string;
|
||||
maintenanceMode!: boolean;
|
||||
}
|
||||
|
||||
export class ServerFeaturesDto {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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();
|
||||
|
||||
58
server/src/maintenance/maintenance-auth.guard.ts
Normal file
58
server/src/maintenance/maintenance-auth.guard.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
59
server/src/maintenance/maintenance-websocket.repository.ts
Normal file
59
server/src/maintenance/maintenance-websocket.repository.ts
Normal 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}`);
|
||||
}
|
||||
}
|
||||
43
server/src/maintenance/maintenance-worker.controller.ts
Normal file
43
server/src/maintenance/maintenance-worker.controller.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
128
server/src/maintenance/maintenance-worker.service.spec.ts
Normal file
128
server/src/maintenance/maintenance-worker.service.spec.ts
Normal 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,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
161
server/src/maintenance/maintenance-worker.service.ts
Normal file
161
server/src/maintenance/maintenance-worker.service.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
20
server/src/repositories/app.repository.ts
Normal file
20
server/src/repositories/app.repository.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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: [] }) : '',
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
109
server/src/services/maintenance.service.spec.ts
Normal file
109
server/src/services/maintenance.service.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
53
server/src/services/maintenance.service.ts
Normal file
53
server/src/services/maintenance.service.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -130,6 +130,7 @@ export class ServerService extends BaseService {
|
||||
publicUsers: config.server.publicUsers,
|
||||
mapDarkStyleUrl: config.map.darkStyle,
|
||||
mapLightStyleUrl: config.map.lightStyle,
|
||||
maintenanceMode: false,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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>;
|
||||
|
||||
74
server/src/utils/maintenance.ts
Normal file
74
server/src/utils/maintenance.ts
Normal 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');
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
29
server/src/workers/maintenance.ts
Normal file
29
server/src/workers/maintenance.ts
Normal 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);
|
||||
});
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>),
|
||||
|
||||
1
web/src/lib/assets/empty-folders.svg
Normal file
1
web/src/lib/assets/empty-folders.svg
Normal 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 |
@@ -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>
|
||||
@@ -1,207 +0,0 @@
|
||||
<script lang="ts">
|
||||
import LibraryImportPathModal from '$lib/modals/LibraryImportPathModal.svelte';
|
||||
import { handleError } from '$lib/utils/handle-error';
|
||||
import type { ValidateLibraryImportPathResponseDto } from '@immich/sdk';
|
||||
import { validate, type LibraryResponseDto } from '@immich/sdk';
|
||||
import { Button, Icon, IconButton, modalManager, toastManager } from '@immich/ui';
|
||||
import { mdiAlertOutline, mdiCheckCircleOutline, mdiPencilOutline, mdiRefresh } from '@mdi/js';
|
||||
import { onMount } from 'svelte';
|
||||
import { t } from 'svelte-i18n';
|
||||
|
||||
interface Props {
|
||||
library: LibraryResponseDto;
|
||||
onCancel: () => void;
|
||||
onSubmit: (library: LibraryResponseDto) => void;
|
||||
}
|
||||
|
||||
let { library = $bindable(), onCancel, onSubmit }: Props = $props();
|
||||
|
||||
let validatedPaths: ValidateLibraryImportPathResponseDto[] = $state([]);
|
||||
|
||||
let importPaths = $derived(validatedPaths.map((validatedPath) => validatedPath.importPath));
|
||||
|
||||
onMount(async () => {
|
||||
if (library.importPaths) {
|
||||
await handleValidation();
|
||||
} else {
|
||||
library.importPaths = [];
|
||||
}
|
||||
});
|
||||
|
||||
const handleValidation = async () => {
|
||||
if (library.importPaths) {
|
||||
const validation = await validate({
|
||||
id: library.id,
|
||||
validateLibraryDto: { importPaths: library.importPaths },
|
||||
});
|
||||
|
||||
validatedPaths = validation.importPaths ?? [];
|
||||
}
|
||||
};
|
||||
|
||||
const revalidate = async (notifyIfSuccessful = true) => {
|
||||
await handleValidation();
|
||||
let failedPaths = 0;
|
||||
for (const validatedPath of validatedPaths) {
|
||||
if (!validatedPath.isValid) {
|
||||
failedPaths++;
|
||||
}
|
||||
}
|
||||
if (failedPaths === 0) {
|
||||
if (notifyIfSuccessful) {
|
||||
toastManager.success($t('admin.paths_validated_successfully'));
|
||||
}
|
||||
} else {
|
||||
toastManager.warning($t('errors.paths_validation_failed', { values: { paths: failedPaths } }));
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddImportPath = async (importPathToAdd: string | null) => {
|
||||
if (!importPathToAdd) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!library.importPaths) {
|
||||
library.importPaths = [];
|
||||
}
|
||||
|
||||
try {
|
||||
// Check so that import path isn't duplicated
|
||||
if (!library.importPaths.includes(importPathToAdd)) {
|
||||
library.importPaths.push(importPathToAdd);
|
||||
await revalidate(false);
|
||||
}
|
||||
} catch (error) {
|
||||
handleError(error, $t('errors.unable_to_add_import_path'));
|
||||
}
|
||||
};
|
||||
|
||||
const handleEditImportPath = async (editedImportPath: string | null, pathIndexToEdit: number) => {
|
||||
if (editedImportPath === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!library.importPaths) {
|
||||
library.importPaths = [];
|
||||
}
|
||||
|
||||
try {
|
||||
// Check so that import path isn't duplicated
|
||||
if (!library.importPaths.includes(editedImportPath)) {
|
||||
// Update import path
|
||||
library.importPaths[pathIndexToEdit] = editedImportPath;
|
||||
await revalidate(false);
|
||||
}
|
||||
} catch (error) {
|
||||
handleError(error, $t('errors.unable_to_edit_import_path'));
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteImportPath = async (pathIndexToDelete?: number) => {
|
||||
if (pathIndexToDelete === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (!library.importPaths) {
|
||||
library.importPaths = [];
|
||||
}
|
||||
|
||||
const pathToDelete = library.importPaths[pathIndexToDelete];
|
||||
library.importPaths = library.importPaths.filter((path) => path != pathToDelete);
|
||||
await handleValidation();
|
||||
} catch (error) {
|
||||
handleError(error, $t('errors.unable_to_delete_import_path'));
|
||||
}
|
||||
};
|
||||
|
||||
const onEditImportPath = async (pathIndexToEdit?: number) => {
|
||||
const result = await modalManager.show(LibraryImportPathModal, {
|
||||
title: pathIndexToEdit === undefined ? $t('add_import_path') : $t('edit_import_path'),
|
||||
submitText: pathIndexToEdit === undefined ? $t('add') : $t('save'),
|
||||
isEditing: pathIndexToEdit !== undefined,
|
||||
importPath: pathIndexToEdit === undefined ? null : library.importPaths[pathIndexToEdit],
|
||||
importPaths: library.importPaths,
|
||||
});
|
||||
|
||||
if (!result) {
|
||||
return;
|
||||
}
|
||||
|
||||
switch (result.action) {
|
||||
case 'submit': {
|
||||
// eslint-disable-next-line unicorn/prefer-ternary
|
||||
if (pathIndexToEdit === undefined) {
|
||||
await handleAddImportPath(result.importPath);
|
||||
} else {
|
||||
await handleEditImportPath(result.importPath, pathIndexToEdit);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'delete': {
|
||||
await handleDeleteImportPath(pathIndexToEdit);
|
||||
break;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const onsubmit = (event: Event) => {
|
||||
event.preventDefault();
|
||||
onSubmit({ ...library });
|
||||
};
|
||||
</script>
|
||||
|
||||
<form {onsubmit} autocomplete="off" class="m-4 flex flex-col gap-4">
|
||||
<table class="text-start">
|
||||
<tbody class="block w-full overflow-y-auto rounded-md border dark:border-immich-dark-gray">
|
||||
{#each validatedPaths as validatedPath, listIndex (validatedPath.importPath)}
|
||||
<tr
|
||||
class="flex h-20 w-full place-items-center text-center dark:text-immich-dark-fg even:bg-subtle/20 odd:bg-subtle/80"
|
||||
>
|
||||
<td class="w-1/8 text-ellipsis ps-8 text-sm">
|
||||
{#if validatedPath.isValid}
|
||||
<Icon icon={mdiCheckCircleOutline} size="24" title={validatedPath.message} class="text-success" />
|
||||
{:else}
|
||||
<Icon icon={mdiAlertOutline} size="24" title={validatedPath.message} class="text-warning" />
|
||||
{/if}
|
||||
</td>
|
||||
|
||||
<td class="w-4/5 text-ellipsis px-4 text-sm">{validatedPath.importPath}</td>
|
||||
<td class="w-1/5 text-ellipsis flex justify-center">
|
||||
<IconButton
|
||||
shape="round"
|
||||
color="primary"
|
||||
icon={mdiPencilOutline}
|
||||
aria-label={$t('edit_import_path')}
|
||||
onclick={() => onEditImportPath(listIndex)}
|
||||
size="small"
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
<tr
|
||||
class="flex h-20 w-full place-items-center text-center dark:text-immich-dark-fg even:bg-subtle/20 odd:bg-subtle/80"
|
||||
>
|
||||
<td class="w-4/5 text-ellipsis px-4 text-sm">
|
||||
{#if importPaths.length === 0}
|
||||
{$t('admin.no_paths_added')}
|
||||
{/if}</td
|
||||
>
|
||||
<td class="w-1/5 text-ellipsis px-4 text-sm">
|
||||
<Button shape="round" size="small" onclick={() => onEditImportPath()}>{$t('add_path')}</Button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="flex justify-between w-full">
|
||||
<div class="justify-end gap-2">
|
||||
<Button shape="round" leadingIcon={mdiRefresh} size="small" color="secondary" onclick={() => revalidate()}
|
||||
>{$t('validate')}</Button
|
||||
>
|
||||
</div>
|
||||
<div class="flex justify-end gap-2">
|
||||
<Button shape="round" size="small" color="secondary" onclick={onCancel}>{$t('cancel')}</Button>
|
||||
<Button shape="round" size="small" type="submit">{$t('save')}</Button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
@@ -1,151 +0,0 @@
|
||||
<script lang="ts">
|
||||
import LibraryExclusionPatternModal from '$lib/modals/LibraryExclusionPatternModal.svelte';
|
||||
import { handleError } from '$lib/utils/handle-error';
|
||||
import { type LibraryResponseDto } from '@immich/sdk';
|
||||
import { Button, IconButton, modalManager } from '@immich/ui';
|
||||
import { mdiPencilOutline } from '@mdi/js';
|
||||
import { onMount } from 'svelte';
|
||||
import { t } from 'svelte-i18n';
|
||||
|
||||
interface Props {
|
||||
library: Partial<LibraryResponseDto>;
|
||||
onCancel: () => void;
|
||||
onSubmit: (library: Partial<LibraryResponseDto>) => void;
|
||||
}
|
||||
|
||||
let { library = $bindable(), onCancel, onSubmit }: Props = $props();
|
||||
|
||||
let exclusionPatterns: string[] = $state([]);
|
||||
|
||||
onMount(() => {
|
||||
if (library.exclusionPatterns) {
|
||||
exclusionPatterns = library.exclusionPatterns;
|
||||
} else {
|
||||
library.exclusionPatterns = [];
|
||||
}
|
||||
});
|
||||
|
||||
const handleAddExclusionPattern = (exclusionPatternToAdd: string) => {
|
||||
if (!library.exclusionPatterns) {
|
||||
library.exclusionPatterns = [];
|
||||
}
|
||||
|
||||
try {
|
||||
// Check so that exclusion pattern isn't duplicated
|
||||
if (!library.exclusionPatterns.includes(exclusionPatternToAdd)) {
|
||||
library.exclusionPatterns.push(exclusionPatternToAdd);
|
||||
exclusionPatterns = library.exclusionPatterns;
|
||||
}
|
||||
} catch (error) {
|
||||
handleError(error, $t('errors.unable_to_add_exclusion_pattern'));
|
||||
}
|
||||
};
|
||||
|
||||
const handleEditExclusionPattern = (editedExclusionPattern: string, patternIndex: number) => {
|
||||
if (!library.exclusionPatterns) {
|
||||
library.exclusionPatterns = [];
|
||||
}
|
||||
|
||||
try {
|
||||
library.exclusionPatterns[patternIndex] = editedExclusionPattern;
|
||||
exclusionPatterns = library.exclusionPatterns;
|
||||
} catch (error) {
|
||||
handleError(error, $t('errors.unable_to_edit_exclusion_pattern'));
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteExclusionPattern = (patternIndexToDelete?: number) => {
|
||||
if (patternIndexToDelete === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (!library.exclusionPatterns) {
|
||||
library.exclusionPatterns = [];
|
||||
}
|
||||
|
||||
const patternToDelete = library.exclusionPatterns[patternIndexToDelete];
|
||||
library.exclusionPatterns = library.exclusionPatterns.filter((path) => path != patternToDelete);
|
||||
exclusionPatterns = library.exclusionPatterns;
|
||||
} catch (error) {
|
||||
handleError(error, $t('errors.unable_to_delete_exclusion_pattern'));
|
||||
}
|
||||
};
|
||||
|
||||
const onEditExclusionPattern = async (patternIndexToEdit?: number) => {
|
||||
const result = await modalManager.show(LibraryExclusionPatternModal, {
|
||||
submitText: patternIndexToEdit === undefined ? $t('add') : $t('save'),
|
||||
isEditing: patternIndexToEdit !== undefined,
|
||||
exclusionPattern: patternIndexToEdit === undefined ? '' : exclusionPatterns[patternIndexToEdit],
|
||||
exclusionPatterns,
|
||||
});
|
||||
|
||||
if (!result) {
|
||||
return;
|
||||
}
|
||||
|
||||
switch (result.action) {
|
||||
case 'submit': {
|
||||
if (patternIndexToEdit === undefined) {
|
||||
handleAddExclusionPattern(result.exclusionPattern);
|
||||
} else {
|
||||
handleEditExclusionPattern(result.exclusionPattern, patternIndexToEdit);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'delete': {
|
||||
handleDeleteExclusionPattern(patternIndexToEdit);
|
||||
break;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const onsubmit = (event: Event) => {
|
||||
event.preventDefault();
|
||||
onSubmit(library);
|
||||
};
|
||||
</script>
|
||||
|
||||
<form {onsubmit} autocomplete="off" class="m-4 flex flex-col gap-4">
|
||||
<table class="w-full text-start">
|
||||
<tbody class="block w-full overflow-y-auto rounded-md border dark:border-immich-dark-gray">
|
||||
{#each exclusionPatterns as exclusionPattern, listIndex (exclusionPattern)}
|
||||
<tr
|
||||
class="flex h-20 w-full place-items-center text-center dark:text-immich-dark-fg even:bg-subtle/20 odd:bg-subtle/80"
|
||||
>
|
||||
<td class="w-3/4 text-ellipsis px-4 text-sm">{exclusionPattern}</td>
|
||||
<td class="w-1/4 text-ellipsis flex justify-center">
|
||||
<IconButton
|
||||
shape="round"
|
||||
color="primary"
|
||||
icon={mdiPencilOutline}
|
||||
title={$t('edit_exclusion_pattern')}
|
||||
onclick={() => onEditExclusionPattern(listIndex)}
|
||||
aria-label={$t('edit_exclusion_pattern')}
|
||||
size="small"
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
<tr
|
||||
class="flex h-20 w-full place-items-center text-center dark:text-immich-dark-fg even:bg-subtle/20 odd:bg-subtle/80"
|
||||
>
|
||||
<td class="w-3/4 text-ellipsis px-4 text-sm">
|
||||
{#if exclusionPatterns.length === 0}
|
||||
{$t('admin.no_pattern_added')}
|
||||
{/if}
|
||||
</td>
|
||||
<td class="w-1/4 text-ellipsis px-4 text-sm flex justify-center">
|
||||
<Button size="small" shape="round" onclick={() => onEditExclusionPattern()}>
|
||||
{$t('add_exclusion_pattern')}
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div class="flex w-full justify-end gap-2">
|
||||
<Button size="small" shape="round" color="secondary" onclick={onCancel}>{$t('cancel')}</Button>
|
||||
<Button size="small" shape="round" type="submit">{$t('save')}</Button>
|
||||
</div>
|
||||
</form>
|
||||
@@ -7,9 +7,10 @@
|
||||
fullWidth?: boolean;
|
||||
src?: string;
|
||||
title?: string;
|
||||
class?: string;
|
||||
}
|
||||
|
||||
let { onClick = undefined, text, fullWidth = false, src = empty1Url, title }: Props = $props();
|
||||
let { onClick = undefined, text, fullWidth = false, src = empty1Url, title, class: className }: Props = $props();
|
||||
|
||||
let width = $derived(fullWidth ? 'w-full' : 'w-1/2');
|
||||
|
||||
@@ -22,7 +23,7 @@
|
||||
<svelte:element
|
||||
this={onClick ? 'button' : 'div'}
|
||||
onclick={onClick}
|
||||
class="{width} m-auto mt-10 flex flex-col place-content-center place-items-center rounded-3xl bg-gray-50 p-5 dark:bg-immich-dark-gray {hoverClasses}"
|
||||
class="{width} {className} flex flex-col place-content-center place-items-center rounded-3xl bg-gray-50 p-5 dark:bg-immich-dark-gray {hoverClasses}"
|
||||
>
|
||||
<img {src} alt="" width="500" draggable="false" />
|
||||
|
||||
|
||||
@@ -176,12 +176,24 @@
|
||||
};
|
||||
|
||||
const scrollAndLoadAsset = async (assetId: string) => {
|
||||
const monthGroup = await timelineManager.findMonthGroupForAsset(assetId);
|
||||
if (!monthGroup) {
|
||||
return false;
|
||||
try {
|
||||
// This flag prevents layout deferral to fix scroll positioning issues.
|
||||
// When layouts are deferred and we scroll to an asset at the end of the timeline,
|
||||
// we can calculate the asset's position, but the scrollableElement's scrollHeight
|
||||
// hasn't been updated yet to reflect the new layout. This creates a mismatch that
|
||||
// breaks scroll positioning. By disabling layout deferral in this case, we maintain
|
||||
// the performance benefits of deferred layouts while still supporting deep linking
|
||||
// to assets at the end of the timeline.
|
||||
timelineManager.isScrollingOnLoad = true;
|
||||
const monthGroup = await timelineManager.findMonthGroupForAsset(assetId);
|
||||
if (!monthGroup) {
|
||||
return false;
|
||||
}
|
||||
scrollToAssetPosition(assetId, monthGroup);
|
||||
return true;
|
||||
} finally {
|
||||
timelineManager.isScrollingOnLoad = false;
|
||||
}
|
||||
scrollToAssetPosition(assetId, monthGroup);
|
||||
return true;
|
||||
};
|
||||
|
||||
const scrollToAsset = (asset: TimelineAsset) => {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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();
|
||||
};
|
||||
|
||||
@@ -59,6 +59,8 @@ export enum AppRoute {
|
||||
FOLDERS = '/folders',
|
||||
TAGS = '/tags',
|
||||
LOCKED = '/locked',
|
||||
|
||||
MAINTENANCE = '/maintenance',
|
||||
}
|
||||
|
||||
export enum ProjectionType {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
@@ -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 },
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
43
web/src/lib/modals/LibraryExclusionPatternAddModal.svelte
Normal file
43
web/src/lib/modals/LibraryExclusionPatternAddModal.svelte
Normal 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>
|
||||
45
web/src/lib/modals/LibraryExclusionPatternEditModal.svelte
Normal file
45
web/src/lib/modals/LibraryExclusionPatternEditModal.svelte
Normal file
@@ -0,0 +1,45 @@
|
||||
<script lang="ts">
|
||||
import { handleEditExclusionPattern } from '$lib/services/library.service';
|
||||
import type { LibraryResponseDto } from '@immich/sdk';
|
||||
import { Button, Field, HStack, Input, Modal, ModalBody, ModalFooter, Text } from '@immich/ui';
|
||||
import { mdiFolderSync } from '@mdi/js';
|
||||
import { t } from 'svelte-i18n';
|
||||
|
||||
type Props = {
|
||||
library: LibraryResponseDto;
|
||||
exclusionPattern: string;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
const { library, exclusionPattern, onClose }: Props = $props();
|
||||
|
||||
let newExclusionPattern = $state(exclusionPattern);
|
||||
|
||||
const onsubmit = async () => {
|
||||
const success = await handleEditExclusionPattern(library, exclusionPattern, newExclusionPattern);
|
||||
if (success) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<Modal title={$t('edit_exclusion_pattern')} icon={mdiFolderSync} {onClose} size="small">
|
||||
<ModalBody>
|
||||
<form {onsubmit} autocomplete="off" id="library-exclusion-pattern-form">
|
||||
<Text size="small" class="mb-4">{$t('admin.exclusion_pattern_description')}</Text>
|
||||
|
||||
<Field label={$t('pattern')}>
|
||||
<Input bind:value={newExclusionPattern} />
|
||||
</Field>
|
||||
</form>
|
||||
</ModalBody>
|
||||
|
||||
<ModalFooter>
|
||||
<HStack fullWidth>
|
||||
<Button shape="round" color="secondary" fullWidth onclick={() => onClose()}>{$t('cancel')}</Button>
|
||||
<Button shape="round" type="submit" fullWidth form="library-exclusion-pattern-form">
|
||||
{$t('save')}
|
||||
</Button>
|
||||
</HStack>
|
||||
</ModalFooter>
|
||||
</Modal>
|
||||
@@ -1,78 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { Button, HStack, Modal, ModalBody, ModalFooter } from '@immich/ui';
|
||||
import { mdiFolderRemove } from '@mdi/js';
|
||||
import { onMount } from 'svelte';
|
||||
import { t } from 'svelte-i18n';
|
||||
|
||||
interface Props {
|
||||
exclusionPattern: string;
|
||||
exclusionPatterns?: string[];
|
||||
isEditing?: boolean;
|
||||
submitText?: string;
|
||||
onClose: (data?: { action: 'delete' } | { action: 'submit'; exclusionPattern: string }) => void;
|
||||
}
|
||||
|
||||
let {
|
||||
exclusionPattern = $bindable(),
|
||||
exclusionPatterns = $bindable([]),
|
||||
isEditing = false,
|
||||
submitText = $t('submit'),
|
||||
onClose,
|
||||
}: Props = $props();
|
||||
|
||||
onMount(() => {
|
||||
if (isEditing) {
|
||||
exclusionPatterns = exclusionPatterns.filter((pattern) => pattern !== exclusionPattern);
|
||||
}
|
||||
});
|
||||
|
||||
let isDuplicate = $derived(exclusionPattern !== null && exclusionPatterns.includes(exclusionPattern));
|
||||
let canSubmit = $derived(exclusionPattern && !exclusionPatterns.includes(exclusionPattern));
|
||||
|
||||
const onsubmit = (event: Event) => {
|
||||
event.preventDefault();
|
||||
if (canSubmit) {
|
||||
onClose({ action: 'submit', exclusionPattern });
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<Modal size="small" title={$t('add_exclusion_pattern')} icon={mdiFolderRemove} {onClose}>
|
||||
<ModalBody>
|
||||
<form {onsubmit} autocomplete="off" id="add-exclusion-pattern-form">
|
||||
<p class="py-5 text-sm">
|
||||
{$t('admin.exclusion_pattern_description')}
|
||||
<br /><br />
|
||||
{$t('admin.add_exclusion_pattern_description')}
|
||||
</p>
|
||||
<div class="my-4 flex flex-col gap-2">
|
||||
<label class="immich-form-label" for="exclusionPattern">{$t('pattern')}</label>
|
||||
<input
|
||||
class="immich-form-input"
|
||||
id="exclusionPattern"
|
||||
name="exclusionPattern"
|
||||
type="text"
|
||||
bind:value={exclusionPattern}
|
||||
/>
|
||||
</div>
|
||||
<div class="mt-8 flex w-full gap-4">
|
||||
{#if isDuplicate}
|
||||
<p class="text-red-500 text-sm">{$t('errors.exclusion_pattern_already_exists')}</p>
|
||||
{/if}
|
||||
</div>
|
||||
</form>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<HStack fullWidth>
|
||||
<Button shape="round" color="secondary" fullWidth onclick={() => onClose()}>{$t('cancel')}</Button>
|
||||
{#if isEditing}
|
||||
<Button shape="round" color="danger" fullWidth onclick={() => onClose({ action: 'delete' })}
|
||||
>{$t('delete')}</Button
|
||||
>
|
||||
{/if}
|
||||
<Button shape="round" type="submit" disabled={!canSubmit} fullWidth form="add-exclusion-pattern-form">
|
||||
{submitText}
|
||||
</Button>
|
||||
</HStack>
|
||||
</ModalFooter>
|
||||
</Modal>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user