mirror of
https://github.com/immich-app/immich.git
synced 2026-01-22 09:28:56 -08:00
Compare commits
19 Commits
renovate/f
...
fix/incorr
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a797cd6cf9 | ||
|
|
1b032339aa | ||
|
|
dc82c13ddc | ||
|
|
417af66f30 | ||
|
|
280f906e4b | ||
|
|
b669714bda | ||
|
|
0f6606848e | ||
|
|
1a8671d940 | ||
|
|
fb94ee80aa | ||
|
|
083ee0b5fe | ||
|
|
0bae88bef6 | ||
|
|
184f1a6d32 | ||
|
|
248cb86143 | ||
|
|
1649d87360 | ||
|
|
8970566865 | ||
|
|
0b4a96140e | ||
|
|
72caf8983c | ||
|
|
61a9d5cbc7 | ||
|
|
ca0d4b283a |
@@ -68,6 +68,56 @@ Now make sure that the local album is selected in the backup screen (steps 1-2 a
|
||||
title="Upload button after photos selection"
|
||||
/>
|
||||
|
||||
## Free Up Space
|
||||
|
||||
The **Free Up Space** tool allows you to remove local media files from your device that have already been successfully backed up to your Immich server (and are not in the Immich trash). This helps reclaim storage on your mobile device without losing your memories.
|
||||
|
||||
### How it works
|
||||
|
||||
1. **Configuration:**
|
||||
- **Cutoff Date:** You can select a cutoff date. The tool will only look for photos and videos **on or before** this date.
|
||||
- **Filter Options:** You can choose to remove **All** assets, or restrict removal to **Photos only** or **Videos only**.
|
||||
- **Keep Favorites:** By default, local assets marked as favorites are preserved on your device, even if they match the cutoff date.
|
||||
2. **Scan & Review:** Before any files are removed, you are presented with a review screen to verify which items will be deleted.
|
||||
3. **Deletion:** Confirmed items are moved to your device's native Trash/Recycle Bin. They will be permanently removed by the OS based on your system settings (usually after 30 days).
|
||||
|
||||
:::info Android Permissions
|
||||
For the smoothest experience on Android, you should grant Immich special delete privileges. Without this, you may be prompted to confirm deletion for every single image.
|
||||
|
||||
Go to **Immich Settings > Advanced** and enable **"Media Management Access"**.
|
||||
:::
|
||||
|
||||
### iCloud Photos (iOS Users)
|
||||
|
||||
If you use **iCloud Photos** alongside Immich, it is vital to understand how deletion affects your data. iCloud utilizes a two-way sync; this means deleting a photo from your iPhone to free up space will **also delete it from iCloud**.
|
||||
|
||||
:::warning iCloud & Backups
|
||||
If you rely on iCloud as a secondary backup (part of a 3-2-1 backup strategy), using the Free Up Space feature in Immich will remove the file from both your phone and iCloud.
|
||||
|
||||
Once deleted, the photo will exist **only** on your Immich server (and your phone's "Recently Deleted" folder for 30 days).
|
||||
|
||||
When you use iCloud Photos and delete a photo or video on one device, it's also deleted on all other devices where you're signed in with the same Apple Account.
|
||||
|
||||
More information on the [Apple Support](https://support.apple.com/en-us/108922#iCloud_photo_library) website
|
||||
|
||||
**Shared Albums**
|
||||
Assets that are part of an **iCloud Shared Album** are automatically excluded from the cleanup scan to ensure they remain viewable to others in the shared album.
|
||||
:::
|
||||
|
||||
### External App Dependencies (WhatsApp, etc.)
|
||||
|
||||
:::danger WhatsApp & Local Files
|
||||
Android applications like **WhatsApp** rely on local files to display media in chat history.
|
||||
|
||||
If Immich backs up your WhatsApp folder and you run **Free Up Space**, the local copies of these images will be deleted. Consequently, **media in your WhatsApp chats will appear blurry or missing.** You will only be able to view these photos inside the Immich app; they will no longer be visible within the WhatsApp interface.
|
||||
|
||||
**Recommendation:** If keeping chat history intact is important, please ensure you review the deletion list carefully or consider excluding WhatsApp folders from the backup if you intend to use this feature frequently.
|
||||
:::
|
||||
|
||||
:::info reclaim storage
|
||||
You must empty the system/gallery trash manually to reclaim storage.
|
||||
:::
|
||||
|
||||
## Album Sync
|
||||
|
||||
You can sync or mirror an album from your phone to the Immich server on your account. For example, if you select Recents, Camera and Videos album for backup, the corresponding album with the same name will be created on the server. Once the assets from those albums are uploaded, they will be put into the target albums automatically.
|
||||
|
||||
@@ -8,7 +8,7 @@ dotenv.config({ path: resolve(import.meta.dirname, '.env') });
|
||||
export const playwrightHost = process.env.PLAYWRIGHT_HOST ?? '127.0.0.1';
|
||||
export const playwrightDbHost = process.env.PLAYWRIGHT_DB_HOST ?? '127.0.0.1';
|
||||
export const playwriteBaseUrl = process.env.PLAYWRIGHT_BASE_URL ?? `http://${playwrightHost}:2285`;
|
||||
export const playwriteSlowMo = parseInt(process.env.PLAYWRIGHT_SLOW_MO ?? '0');
|
||||
export const playwriteSlowMo = Number.parseInt(process.env.PLAYWRIGHT_SLOW_MO ?? '0');
|
||||
export const playwrightDisableWebserver = process.env.PLAYWRIGHT_DISABLE_WEBSERVER;
|
||||
|
||||
process.env.PW_EXPERIMENTAL_SERVICE_WORKER_NETWORK_EVENTS = '1';
|
||||
@@ -39,13 +39,13 @@ const config: PlaywrightTestConfig = {
|
||||
testMatch: /.*\.e2e-spec\.ts/,
|
||||
workers: 1,
|
||||
},
|
||||
{
|
||||
name: 'parallel tests',
|
||||
use: { ...devices['Desktop Chrome'] },
|
||||
testMatch: /.*\.parallel-e2e-spec\.ts/,
|
||||
fullyParallel: true,
|
||||
workers: process.env.CI ? 3 : Math.max(1, Math.round(cpus().length * 0.75) - 1),
|
||||
},
|
||||
// {
|
||||
// name: 'parallel tests',
|
||||
// use: { ...devices['Desktop Chrome'] },
|
||||
// testMatch: /.*\.parallel-e2e-spec\.ts/,
|
||||
// fullyParallel: true,
|
||||
// workers: process.env.CI ? 3 : Math.max(1, Math.round(cpus().length * 0.75) - 1),
|
||||
// },
|
||||
|
||||
// {
|
||||
// name: 'firefox',
|
||||
|
||||
350
e2e/src/api/specs/database-backups.e2e-spec.ts
Normal file
350
e2e/src/api/specs/database-backups.e2e-spec.ts
Normal file
@@ -0,0 +1,350 @@
|
||||
import { LoginResponseDto, ManualJobName } from '@immich/sdk';
|
||||
import { errorDto } from 'src/responses';
|
||||
import { app, utils } from 'src/utils';
|
||||
import request from 'supertest';
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
|
||||
describe('/admin/database-backups', () => {
|
||||
let cookie: string | undefined;
|
||||
let admin: LoginResponseDto;
|
||||
|
||||
beforeAll(async () => {
|
||||
await utils.resetDatabase();
|
||||
admin = await utils.adminSetup();
|
||||
await utils.resetBackups(admin.accessToken);
|
||||
});
|
||||
|
||||
describe('GET /', async () => {
|
||||
it('should succeed and be empty', async () => {
|
||||
const { status, body } = await request(app)
|
||||
.get('/admin/database-backups')
|
||||
.set('Authorization', `Bearer ${admin.accessToken}`);
|
||||
expect(status).toBe(200);
|
||||
expect(body).toEqual({
|
||||
backups: [],
|
||||
});
|
||||
});
|
||||
|
||||
it('should contain a created backup', async () => {
|
||||
await utils.createJob(admin.accessToken, {
|
||||
name: ManualJobName.BackupDatabase,
|
||||
});
|
||||
|
||||
await utils.waitForQueueFinish(admin.accessToken, 'backupDatabase');
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const { status, body } = await request(app)
|
||||
.get('/admin/database-backups')
|
||||
.set('Authorization', `Bearer ${admin.accessToken}`);
|
||||
|
||||
expect(status).toBe(200);
|
||||
return body;
|
||||
},
|
||||
{
|
||||
interval: 500,
|
||||
timeout: 10_000,
|
||||
},
|
||||
)
|
||||
.toEqual(
|
||||
expect.objectContaining({
|
||||
backups: [
|
||||
expect.objectContaining({
|
||||
filename: expect.stringMatching(/immich-db-backup-\d{8}T\d{6}-v.*-pg.*\.sql\.gz$/),
|
||||
filesize: expect.any(Number),
|
||||
}),
|
||||
],
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('DELETE /', async () => {
|
||||
it('should delete backup', async () => {
|
||||
const filename = await utils.createBackup(admin.accessToken);
|
||||
|
||||
const { status } = await request(app)
|
||||
.delete(`/admin/database-backups`)
|
||||
.set('Authorization', `Bearer ${admin.accessToken}`)
|
||||
.send({ backups: [filename] });
|
||||
|
||||
expect(status).toBe(200);
|
||||
|
||||
const { status: listStatus, body } = await request(app)
|
||||
.get('/admin/database-backups')
|
||||
.set('Authorization', `Bearer ${admin.accessToken}`);
|
||||
|
||||
expect(listStatus).toBe(200);
|
||||
expect(body).toEqual(
|
||||
expect.objectContaining({
|
||||
backups: [],
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// => action: restore database flow
|
||||
|
||||
describe.sequential('POST /start-restore', () => {
|
||||
afterAll(async () => {
|
||||
await request(app).post('/admin/maintenance').set('cookie', cookie!).send({ action: 'end' });
|
||||
await utils.poll(
|
||||
() => request(app).get('/server/config'),
|
||||
({ status, body }) => status === 200 && !body.maintenanceMode,
|
||||
);
|
||||
|
||||
admin = await utils.adminSetup();
|
||||
});
|
||||
|
||||
it.sequential('should not work when the server is configured', async () => {
|
||||
const { status, body } = await request(app).post('/admin/database-backups/start-restore').send();
|
||||
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(errorDto.badRequest('The server already has an admin'));
|
||||
});
|
||||
|
||||
it.sequential('should enter maintenance mode in "database restore mode"', async () => {
|
||||
await utils.resetDatabase(); // reset database before running this test
|
||||
|
||||
const { status, headers } = await request(app).post('/admin/database-backups/start-restore').send();
|
||||
|
||||
expect(status).toBe(201);
|
||||
|
||||
cookie = headers['set-cookie'][0].split(';')[0];
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const { status, body } = await request(app).get('/server/config');
|
||||
expect(status).toBe(200);
|
||||
return body.maintenanceMode;
|
||||
},
|
||||
{
|
||||
interval: 500,
|
||||
timeout: 10_000,
|
||||
},
|
||||
)
|
||||
.toBeTruthy();
|
||||
|
||||
const { status: status2, body } = await request(app).get('/admin/maintenance/status').send({ token: 'token' });
|
||||
expect(status2).toBe(200);
|
||||
expect(body).toEqual({
|
||||
active: true,
|
||||
action: 'select_database_restore',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// => action: restore database
|
||||
|
||||
describe.sequential('POST /backups/restore', () => {
|
||||
beforeAll(async () => {
|
||||
await utils.disconnectDatabase();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await utils.connectDatabase();
|
||||
});
|
||||
|
||||
it.sequential('should restore a backup', { timeout: 60_000 }, async () => {
|
||||
let filename = await utils.createBackup(admin.accessToken);
|
||||
|
||||
// work-around until test is running on released version
|
||||
await utils.move(
|
||||
`/data/backups/${filename}`,
|
||||
'/data/backups/immich-db-backup-20260114T184016-v2.5.0-pg14.19.sql.gz',
|
||||
);
|
||||
filename = 'immich-db-backup-20260114T184016-v2.5.0-pg14.19.sql.gz';
|
||||
|
||||
const { status } = await request(app)
|
||||
.post('/admin/maintenance')
|
||||
.set('Authorization', `Bearer ${admin.accessToken}`)
|
||||
.send({
|
||||
action: 'restore_database',
|
||||
restoreBackupFilename: filename,
|
||||
});
|
||||
|
||||
expect(status).toBe(201);
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const { status, body } = await request(app).get('/server/config');
|
||||
expect(status).toBe(200);
|
||||
return body.maintenanceMode;
|
||||
},
|
||||
{
|
||||
interval: 500,
|
||||
timeout: 10_000,
|
||||
},
|
||||
)
|
||||
.toBeTruthy();
|
||||
|
||||
const { status: status2, body } = await request(app).get('/admin/maintenance/status').send({ token: 'token' });
|
||||
expect(status2).toBe(200);
|
||||
expect(body).toEqual(
|
||||
expect.objectContaining({
|
||||
active: true,
|
||||
action: 'restore_database',
|
||||
}),
|
||||
);
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const { status, body } = await request(app).get('/server/config');
|
||||
expect(status).toBe(200);
|
||||
return body.maintenanceMode;
|
||||
},
|
||||
{
|
||||
interval: 500,
|
||||
timeout: 60_000,
|
||||
},
|
||||
)
|
||||
.toBeFalsy();
|
||||
});
|
||||
|
||||
it.sequential('fail to restore a corrupted backup', { timeout: 60_000 }, async () => {
|
||||
await utils.prepareTestBackup('corrupted');
|
||||
|
||||
const { status, headers } = await request(app)
|
||||
.post('/admin/maintenance')
|
||||
.set('Authorization', `Bearer ${admin.accessToken}`)
|
||||
.send({
|
||||
action: 'restore_database',
|
||||
restoreBackupFilename: 'development-corrupted.sql.gz',
|
||||
});
|
||||
|
||||
expect(status).toBe(201);
|
||||
cookie = headers['set-cookie'][0].split(';')[0];
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const { status, body } = await request(app).get('/server/config');
|
||||
expect(status).toBe(200);
|
||||
return body.maintenanceMode;
|
||||
},
|
||||
{
|
||||
interval: 500,
|
||||
timeout: 10_000,
|
||||
},
|
||||
)
|
||||
.toBeTruthy();
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const { status, body } = await request(app).get('/admin/maintenance/status').send({ token: 'token' });
|
||||
expect(status).toBe(200);
|
||||
return body;
|
||||
},
|
||||
{
|
||||
interval: 500,
|
||||
timeout: 10_000,
|
||||
},
|
||||
)
|
||||
.toEqual(
|
||||
expect.objectContaining({
|
||||
active: true,
|
||||
action: 'restore_database',
|
||||
error: 'Something went wrong, see logs!',
|
||||
}),
|
||||
);
|
||||
|
||||
const { status: status2, body: body2 } = await request(app)
|
||||
.get('/admin/maintenance/status')
|
||||
.set('cookie', cookie!)
|
||||
.send({ token: 'token' });
|
||||
expect(status2).toBe(200);
|
||||
expect(body2).toEqual(
|
||||
expect.objectContaining({
|
||||
active: true,
|
||||
action: 'restore_database',
|
||||
error: expect.stringContaining('IM CORRUPTED'),
|
||||
}),
|
||||
);
|
||||
|
||||
await request(app).post('/admin/maintenance').set('cookie', cookie!).send({
|
||||
action: 'end',
|
||||
});
|
||||
|
||||
await utils.poll(
|
||||
() => request(app).get('/server/config'),
|
||||
({ status, body }) => status === 200 && !body.maintenanceMode,
|
||||
);
|
||||
});
|
||||
|
||||
it.sequential('rollback to restore point if backup is missing admin', { timeout: 60_000 }, async () => {
|
||||
await utils.prepareTestBackup('empty');
|
||||
|
||||
const { status, headers } = await request(app)
|
||||
.post('/admin/maintenance')
|
||||
.set('Authorization', `Bearer ${admin.accessToken}`)
|
||||
.send({
|
||||
action: 'restore_database',
|
||||
restoreBackupFilename: 'development-empty.sql.gz',
|
||||
});
|
||||
|
||||
expect(status).toBe(201);
|
||||
cookie = headers['set-cookie'][0].split(';')[0];
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const { status, body } = await request(app).get('/server/config');
|
||||
expect(status).toBe(200);
|
||||
return body.maintenanceMode;
|
||||
},
|
||||
{
|
||||
interval: 500,
|
||||
timeout: 10_000,
|
||||
},
|
||||
)
|
||||
.toBeTruthy();
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const { status, body } = await request(app).get('/admin/maintenance/status').send({ token: 'token' });
|
||||
expect(status).toBe(200);
|
||||
return body;
|
||||
},
|
||||
{
|
||||
interval: 500,
|
||||
timeout: 30_000,
|
||||
},
|
||||
)
|
||||
.toEqual(
|
||||
expect.objectContaining({
|
||||
active: true,
|
||||
action: 'restore_database',
|
||||
error: 'Something went wrong, see logs!',
|
||||
}),
|
||||
);
|
||||
|
||||
const { status: status2, body: body2 } = await request(app)
|
||||
.get('/admin/maintenance/status')
|
||||
.set('cookie', cookie!)
|
||||
.send({ token: 'token' });
|
||||
expect(status2).toBe(200);
|
||||
expect(body2).toEqual(
|
||||
expect.objectContaining({
|
||||
active: true,
|
||||
action: 'restore_database',
|
||||
error: expect.stringContaining('Server health check failed, no admin exists.'),
|
||||
}),
|
||||
);
|
||||
|
||||
await request(app).post('/admin/maintenance').set('cookie', cookie!).send({
|
||||
action: 'end',
|
||||
});
|
||||
|
||||
await utils.poll(
|
||||
() => request(app).get('/server/config'),
|
||||
({ status, body }) => status === 200 && !body.maintenanceMode,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -14,6 +14,7 @@ describe('/admin/maintenance', () => {
|
||||
await utils.resetDatabase();
|
||||
admin = await utils.adminSetup();
|
||||
nonAdmin = await utils.userSetup(admin.accessToken, createUserDto.user1);
|
||||
await utils.resetBackups(admin.accessToken);
|
||||
});
|
||||
|
||||
// => outside of maintenance mode
|
||||
@@ -26,6 +27,17 @@ describe('/admin/maintenance', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /status', async () => {
|
||||
it('to always indicate we are not in maintenance mode', async () => {
|
||||
const { status, body } = await request(app).get('/admin/maintenance/status').send({ token: 'token' });
|
||||
expect(status).toBe(200);
|
||||
expect(body).toEqual({
|
||||
active: false,
|
||||
action: 'end',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
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' });
|
||||
@@ -39,6 +51,7 @@ describe('/admin/maintenance', () => {
|
||||
describe.sequential('POST /', () => {
|
||||
it('should require authentication', async () => {
|
||||
const { status, body } = await request(app).post('/admin/maintenance').send({
|
||||
active: false,
|
||||
action: 'end',
|
||||
});
|
||||
expect(status).toBe(401);
|
||||
@@ -69,6 +82,7 @@ describe('/admin/maintenance', () => {
|
||||
.send({
|
||||
action: 'start',
|
||||
});
|
||||
|
||||
expect(status).toBe(201);
|
||||
|
||||
cookie = headers['set-cookie'][0].split(';')[0];
|
||||
@@ -79,12 +93,13 @@ describe('/admin/maintenance', () => {
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const { body } = await request(app).get('/server/config');
|
||||
const { status, body } = await request(app).get('/server/config');
|
||||
expect(status).toBe(200);
|
||||
return body.maintenanceMode;
|
||||
},
|
||||
{
|
||||
interval: 5e2,
|
||||
timeout: 1e4,
|
||||
interval: 500,
|
||||
timeout: 10_000,
|
||||
},
|
||||
)
|
||||
.toBeTruthy();
|
||||
@@ -102,6 +117,17 @@ describe('/admin/maintenance', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /status', async () => {
|
||||
it('to indicate we are in maintenance mode', async () => {
|
||||
const { status, body } = await request(app).get('/admin/maintenance/status').send({ token: 'token' });
|
||||
expect(status).toBe(200);
|
||||
expect(body).toEqual({
|
||||
active: true,
|
||||
action: 'start',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
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({});
|
||||
@@ -158,12 +184,13 @@ describe('/admin/maintenance', () => {
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const { body } = await request(app).get('/server/config');
|
||||
const { status, body } = await request(app).get('/server/config');
|
||||
expect(status).toBe(200);
|
||||
return body.maintenanceMode;
|
||||
},
|
||||
{
|
||||
interval: 5e2,
|
||||
timeout: 1e4,
|
||||
interval: 500,
|
||||
timeout: 10_000,
|
||||
},
|
||||
)
|
||||
.toBeFalsy();
|
||||
|
||||
101
e2e/src/utils.ts
101
e2e/src/utils.ts
@@ -6,7 +6,9 @@ import {
|
||||
CheckExistingAssetsDto,
|
||||
CreateAlbumDto,
|
||||
CreateLibraryDto,
|
||||
JobCreateDto,
|
||||
MaintenanceAction,
|
||||
ManualJobName,
|
||||
MetadataSearchDto,
|
||||
Permission,
|
||||
PersonCreateDto,
|
||||
@@ -21,6 +23,7 @@ import {
|
||||
checkExistingAssets,
|
||||
createAlbum,
|
||||
createApiKey,
|
||||
createJob,
|
||||
createLibrary,
|
||||
createPartner,
|
||||
createPerson,
|
||||
@@ -28,10 +31,12 @@ import {
|
||||
createStack,
|
||||
createUserAdmin,
|
||||
deleteAssets,
|
||||
deleteDatabaseBackup,
|
||||
getAssetInfo,
|
||||
getConfig,
|
||||
getConfigDefaults,
|
||||
getQueuesLegacy,
|
||||
listDatabaseBackups,
|
||||
login,
|
||||
runQueueCommandLegacy,
|
||||
scanLibrary,
|
||||
@@ -52,11 +57,15 @@ import {
|
||||
import { BrowserContext } from '@playwright/test';
|
||||
import { exec, spawn } from 'node:child_process';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { existsSync, mkdirSync, renameSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { createWriteStream, existsSync, mkdirSync, renameSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { mkdtemp } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { dirname, resolve } from 'node:path';
|
||||
import { dirname, join, resolve } from 'node:path';
|
||||
import { Readable } from 'node:stream';
|
||||
import { pipeline } from 'node:stream/promises';
|
||||
import { setTimeout as setAsyncTimeout } from 'node:timers/promises';
|
||||
import { promisify } from 'node:util';
|
||||
import { createGzip } from 'node:zlib';
|
||||
import pg from 'pg';
|
||||
import { io, type Socket } from 'socket.io-client';
|
||||
import { loginDto, signupDto } from 'src/fixtures';
|
||||
@@ -84,8 +93,9 @@ export const asBearerAuth = (accessToken: string) => ({ Authorization: `Bearer $
|
||||
export const asKeyAuth = (key: string) => ({ 'x-api-key': key });
|
||||
export const immichCli = (args: string[]) =>
|
||||
executeCommand('pnpm', ['exec', 'immich', '-d', `/${tempDir}/immich/`, ...args], { cwd: '../cli' }).promise;
|
||||
export const immichAdmin = (args: string[]) =>
|
||||
executeCommand('docker', ['exec', '-i', 'immich-e2e-server', '/bin/bash', '-c', `immich-admin ${args.join(' ')}`]);
|
||||
export const dockerExec = (args: string[]) =>
|
||||
executeCommand('docker', ['exec', '-i', 'immich-e2e-server', '/bin/bash', '-c', args.join(' ')]);
|
||||
export const immichAdmin = (args: string[]) => dockerExec([`immich-admin ${args.join(' ')}`]);
|
||||
export const specialCharStrings = ["'", '"', ',', '{', '}', '*'];
|
||||
export const TEN_TIMES = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9];
|
||||
|
||||
@@ -149,12 +159,26 @@ const onEvent = ({ event, id }: { event: EventType; id: string }) => {
|
||||
};
|
||||
|
||||
export const utils = {
|
||||
connectDatabase: async () => {
|
||||
if (!client) {
|
||||
client = new pg.Client(dbUrl);
|
||||
client.on('end', () => (client = null));
|
||||
client.on('error', () => (client = null));
|
||||
await client.connect();
|
||||
}
|
||||
|
||||
return client;
|
||||
},
|
||||
|
||||
disconnectDatabase: async () => {
|
||||
if (client) {
|
||||
await client.end();
|
||||
}
|
||||
},
|
||||
|
||||
resetDatabase: async (tables?: string[]) => {
|
||||
try {
|
||||
if (!client) {
|
||||
client = new pg.Client(dbUrl);
|
||||
await client.connect();
|
||||
}
|
||||
client = await utils.connectDatabase();
|
||||
|
||||
tables = tables || [
|
||||
// TODO e2e test for deleting a stack, since it is quite complex
|
||||
@@ -481,6 +505,9 @@ export const utils = {
|
||||
tagAssets: (accessToken: string, tagId: string, assetIds: string[]) =>
|
||||
tagAssets({ id: tagId, bulkIdsDto: { ids: assetIds } }, { headers: asBearerAuth(accessToken) }),
|
||||
|
||||
createJob: async (accessToken: string, jobCreateDto: JobCreateDto) =>
|
||||
createJob({ jobCreateDto }, { headers: asBearerAuth(accessToken) }),
|
||||
|
||||
queueCommand: async (accessToken: string, name: QueueName, queueCommandDto: QueueCommandDto) =>
|
||||
runQueueCommandLegacy({ name, queueCommandDto }, { headers: asBearerAuth(accessToken) }),
|
||||
|
||||
@@ -559,6 +586,45 @@ export const utils = {
|
||||
mkdirSync(`${testAssetDir}/temp`, { recursive: true });
|
||||
},
|
||||
|
||||
async move(source: string, dest: string) {
|
||||
return executeCommand('docker', ['exec', 'immich-e2e-server', 'mv', source, dest]).promise;
|
||||
},
|
||||
|
||||
createBackup: async (accessToken: string) => {
|
||||
await utils.createJob(accessToken, {
|
||||
name: ManualJobName.BackupDatabase,
|
||||
});
|
||||
|
||||
return utils.poll(
|
||||
() => request(app).get('/admin/database-backups').set('Authorization', `Bearer ${accessToken}`),
|
||||
({ status, body }) => status === 200 && body.backups.length === 1,
|
||||
({ body }) => body.backups[0].filename,
|
||||
);
|
||||
},
|
||||
|
||||
resetBackups: async (accessToken: string) => {
|
||||
const { backups } = await listDatabaseBackups({ headers: asBearerAuth(accessToken) });
|
||||
|
||||
const backupFiles = backups.map((b) => b.filename);
|
||||
await deleteDatabaseBackup(
|
||||
{ databaseBackupDeleteDto: { backups: backupFiles } },
|
||||
{ headers: asBearerAuth(accessToken) },
|
||||
);
|
||||
},
|
||||
|
||||
prepareTestBackup: async (generate: 'empty' | 'corrupted') => {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'test-'));
|
||||
const fn = join(dir, 'file');
|
||||
|
||||
const sql = Readable.from(generate === 'corrupted' ? 'IM CORRUPTED;' : 'SELECT 1;');
|
||||
const gzip = createGzip();
|
||||
const writeStream = createWriteStream(fn);
|
||||
await pipeline(sql, gzip, writeStream);
|
||||
|
||||
await executeCommand('docker', ['cp', fn, `immich-e2e-server:/data/backups/development-${generate}.sql.gz`])
|
||||
.promise;
|
||||
},
|
||||
|
||||
resetAdminConfig: async (accessToken: string) => {
|
||||
const defaultConfig = await getConfigDefaults({ headers: asBearerAuth(accessToken) });
|
||||
await updateConfig({ systemConfigDto: defaultConfig }, { headers: asBearerAuth(accessToken) });
|
||||
@@ -601,6 +667,25 @@ export const utils = {
|
||||
await utils.waitForQueueFinish(accessToken, 'sidecar');
|
||||
await utils.waitForQueueFinish(accessToken, 'metadataExtraction');
|
||||
},
|
||||
|
||||
async poll<T>(cb: () => Promise<T>, validate: (value: T) => boolean, map?: (value: T) => any) {
|
||||
let timeout = 0;
|
||||
while (true) {
|
||||
try {
|
||||
const data = await cb();
|
||||
if (validate(data)) {
|
||||
return map ? map(data) : data;
|
||||
}
|
||||
timeout++;
|
||||
if (timeout >= 10) {
|
||||
throw 'Could not clean up test.';
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 5e2));
|
||||
} catch {
|
||||
// no-op
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
utils.initSdk();
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
import { setupBaseMockApiRoutes } from 'src/mock-network/base-network';
|
||||
import { setupTimelineMockApiRoutes, TimelineTestContext } from 'src/mock-network/timeline-network';
|
||||
import { utils } from 'src/utils';
|
||||
import { assetViewerUtils, cancelAllPollers } from 'src/web/specs/timeline/utils';
|
||||
import { assetViewerUtils } from 'src/web/specs/timeline/utils';
|
||||
|
||||
test.describe.configure({ mode: 'parallel' });
|
||||
test.describe('asset-viewer', () => {
|
||||
@@ -49,7 +49,6 @@ test.describe('asset-viewer', () => {
|
||||
});
|
||||
|
||||
test.afterEach(() => {
|
||||
cancelAllPollers();
|
||||
testContext.slowBucket = false;
|
||||
changes.albumAdditions = [];
|
||||
changes.assetDeletions = [];
|
||||
|
||||
105
e2e/src/web/specs/database-backups.e2e-spec.ts
Normal file
105
e2e/src/web/specs/database-backups.e2e-spec.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
import { LoginResponseDto } from '@immich/sdk';
|
||||
import { expect, test } from '@playwright/test';
|
||||
import { utils } from 'src/utils';
|
||||
|
||||
test.describe.configure({ mode: 'serial' });
|
||||
|
||||
test.describe('Database Backups', () => {
|
||||
let admin: LoginResponseDto;
|
||||
|
||||
test.beforeAll(async () => {
|
||||
utils.initSdk();
|
||||
await utils.resetDatabase();
|
||||
admin = await utils.adminSetup();
|
||||
});
|
||||
|
||||
test('restore a backup from settings', async ({ context, page }) => {
|
||||
test.setTimeout(60_000);
|
||||
|
||||
await utils.resetBackups(admin.accessToken);
|
||||
const filename = await utils.createBackup(admin.accessToken);
|
||||
await utils.setAuthCookies(context, admin.accessToken);
|
||||
|
||||
// work-around until test is running on released version
|
||||
await utils.move(
|
||||
`/data/backups/${filename}`,
|
||||
'/data/backups/immich-db-backup-20260114T184016-v2.5.0-pg14.19.sql.gz',
|
||||
);
|
||||
|
||||
await page.goto('/admin/maintenance?isOpen=backups');
|
||||
await page.getByRole('button', { name: 'Restore', exact: true }).click();
|
||||
await page.getByRole('dialog').getByRole('button', { name: 'Restore' }).click();
|
||||
|
||||
await page.waitForURL('/maintenance?**');
|
||||
await page.waitForURL('/admin/maintenance**', { timeout: 60_000 });
|
||||
});
|
||||
|
||||
test('handle backup restore failure', async ({ context, page }) => {
|
||||
test.setTimeout(60_000);
|
||||
|
||||
await utils.resetBackups(admin.accessToken);
|
||||
await utils.prepareTestBackup('corrupted');
|
||||
await utils.setAuthCookies(context, admin.accessToken);
|
||||
|
||||
await page.goto('/admin/maintenance?isOpen=backups');
|
||||
await page.getByRole('button', { name: 'Restore', exact: true }).click();
|
||||
await page.getByRole('dialog').getByRole('button', { name: 'Restore' }).click();
|
||||
|
||||
await page.waitForURL('/maintenance?**');
|
||||
await expect(page.getByText('IM CORRUPTED')).toBeVisible({ timeout: 60_000 });
|
||||
await page.getByRole('button', { name: 'End maintenance mode' }).click();
|
||||
await page.waitForURL('/admin/maintenance**');
|
||||
});
|
||||
|
||||
test('rollback to restore point if backup is missing admin', async ({ context, page }) => {
|
||||
test.setTimeout(60_000);
|
||||
|
||||
await utils.resetBackups(admin.accessToken);
|
||||
await utils.prepareTestBackup('empty');
|
||||
await utils.setAuthCookies(context, admin.accessToken);
|
||||
|
||||
await page.goto('/admin/maintenance?isOpen=backups');
|
||||
await page.getByRole('button', { name: 'Restore', exact: true }).click();
|
||||
await page.getByRole('dialog').getByRole('button', { name: 'Restore' }).click();
|
||||
|
||||
await page.waitForURL('/maintenance?**');
|
||||
await expect(page.getByText('Server health check failed, no admin exists.')).toBeVisible({ timeout: 60_000 });
|
||||
await page.getByRole('button', { name: 'End maintenance mode' }).click();
|
||||
await page.waitForURL('/admin/maintenance**');
|
||||
});
|
||||
|
||||
test('restore a backup from onboarding', async ({ context, page }) => {
|
||||
test.setTimeout(60_000);
|
||||
|
||||
await utils.resetBackups(admin.accessToken);
|
||||
const filename = await utils.createBackup(admin.accessToken);
|
||||
await utils.setAuthCookies(context, admin.accessToken);
|
||||
|
||||
// work-around until test is running on released version
|
||||
await utils.move(
|
||||
`/data/backups/${filename}`,
|
||||
'/data/backups/immich-db-backup-20260114T184016-v2.5.0-pg14.19.sql.gz',
|
||||
);
|
||||
|
||||
await utils.resetDatabase();
|
||||
|
||||
await page.goto('/');
|
||||
await page.getByRole('button', { name: 'Restore from backup' }).click();
|
||||
|
||||
try {
|
||||
await page.waitForURL('/maintenance**');
|
||||
} catch {
|
||||
// when chained with the rest of the tests
|
||||
// this navigation may fail..? not sure why...
|
||||
await page.goto('/maintenance');
|
||||
await page.waitForURL('/maintenance**');
|
||||
}
|
||||
|
||||
await page.getByRole('button', { name: 'Next' }).click();
|
||||
await page.getByRole('button', { name: 'Restore', exact: true }).click();
|
||||
await page.getByRole('dialog').getByRole('button', { name: 'Restore' }).click();
|
||||
|
||||
await page.waitForURL('/maintenance?**');
|
||||
await page.waitForURL('/photos', { timeout: 60_000 });
|
||||
});
|
||||
});
|
||||
@@ -16,12 +16,12 @@ test.describe('Maintenance', () => {
|
||||
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.goto('/admin/maintenance');
|
||||
await page.getByRole('button', { name: 'Switch to maintenance mode' }).click();
|
||||
|
||||
await expect(page.getByText('Temporarily Unavailable')).toBeVisible({ timeout: 10_000 });
|
||||
await page.getByRole('button', { name: 'End maintenance mode' }).click();
|
||||
await page.waitForURL('**/admin/system-settings*', { timeout: 10_000 });
|
||||
await page.waitForURL('**/admin/maintenance*', { timeout: 10_000 });
|
||||
});
|
||||
|
||||
test('maintenance shows no options to users until they authenticate', async ({ page }) => {
|
||||
|
||||
@@ -18,7 +18,6 @@ import { pageRoutePromise, setupTimelineMockApiRoutes, TimelineTestContext } fro
|
||||
import { utils } from 'src/utils';
|
||||
import {
|
||||
assetViewerUtils,
|
||||
cancelAllPollers,
|
||||
padYearMonth,
|
||||
pageUtils,
|
||||
poll,
|
||||
@@ -64,7 +63,6 @@ test.describe('Timeline', () => {
|
||||
});
|
||||
|
||||
test.afterEach(() => {
|
||||
cancelAllPollers();
|
||||
testContext.slowBucket = false;
|
||||
changes.albumAdditions = [];
|
||||
changes.assetDeletions = [];
|
||||
|
||||
@@ -23,13 +23,6 @@ export async function throttlePage(context: BrowserContext, page: Page) {
|
||||
await session.send('Emulation.setCPUThrottlingRate', { rate: 10 });
|
||||
}
|
||||
|
||||
let activePollsAbortController = new AbortController();
|
||||
|
||||
export const cancelAllPollers = () => {
|
||||
activePollsAbortController.abort();
|
||||
activePollsAbortController = new AbortController();
|
||||
};
|
||||
|
||||
export const poll = async <T>(
|
||||
page: Page,
|
||||
query: () => Promise<T>,
|
||||
@@ -37,21 +30,14 @@ export const poll = async <T>(
|
||||
) => {
|
||||
let result;
|
||||
const timeout = Date.now() + 10_000;
|
||||
const signal = activePollsAbortController.signal;
|
||||
|
||||
const terminate = callback || ((result: Awaited<T> | undefined) => !!result);
|
||||
while (!terminate(result) && Date.now() < timeout) {
|
||||
if (signal.aborted) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
result = await query();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
if (signal.aborted) {
|
||||
return;
|
||||
}
|
||||
if (page.isClosed()) {
|
||||
return;
|
||||
}
|
||||
|
||||
37
i18n/en.json
37
i18n/en.json
@@ -188,10 +188,21 @@
|
||||
"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_delete_backup": "Delete Backup",
|
||||
"maintenance_delete_backup_description": "This file will be irrevocably deleted.",
|
||||
"maintenance_delete_error": "Failed to delete backup.",
|
||||
"maintenance_restore_backup": "Restore Backup",
|
||||
"maintenance_restore_backup_description": "Immich will be wiped and restored from the chosen backup. A backup will be created before continuing.",
|
||||
"maintenance_restore_backup_different_version": "This backup was created with a different version of Immich!",
|
||||
"maintenance_restore_backup_unknown_version": "Couldn't determine backup version.",
|
||||
"maintenance_restore_database_backup": "Restore database backup",
|
||||
"maintenance_restore_database_backup_description": "Rollback to an earlier database state using a backup file",
|
||||
"maintenance_settings": "Maintenance",
|
||||
"maintenance_settings_description": "Put Immich into maintenance mode.",
|
||||
"maintenance_start": "Start maintenance mode",
|
||||
"maintenance_start": "Switch to maintenance mode",
|
||||
"maintenance_start_error": "Failed to start maintenance mode.",
|
||||
"maintenance_upload_backup": "Upload database backup file",
|
||||
"maintenance_upload_backup_error": "Could not upload backup, is it an .sql/.sql.gz file?",
|
||||
"manage_concurrency": "Manage Concurrency",
|
||||
"manage_concurrency_description": "Navigate to the jobs page to manage job concurrency",
|
||||
"manage_log_settings": "Manage log settings",
|
||||
@@ -998,9 +1009,11 @@
|
||||
"error_getting_places": "Error getting places",
|
||||
"error_loading_image": "Error loading image",
|
||||
"error_loading_partners": "Error loading partners: {error}",
|
||||
"error_retrieving_asset_information": "Error retrieving asset information",
|
||||
"error_saving_image": "Error: {error}",
|
||||
"error_tag_face_bounding_box": "Error tagging face - cannot get bounding box coordinates",
|
||||
"error_title": "Error - Something went wrong",
|
||||
"error_while_navigating": "Error while navigating to asset",
|
||||
"errors": {
|
||||
"cannot_navigate_next_asset": "Cannot navigate to the next asset",
|
||||
"cannot_navigate_previous_asset": "Cannot navigate to previous asset",
|
||||
@@ -1404,10 +1417,28 @@
|
||||
"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_action_restore": "Restoring Database",
|
||||
"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_restore_from_backup": "Restore From Backup",
|
||||
"maintenance_restore_library": "Restore Your Library",
|
||||
"maintenance_restore_library_confirm": "If this looks correct, continue to restoring a backup!",
|
||||
"maintenance_restore_library_description": "Restoring Database",
|
||||
"maintenance_restore_library_folder_has_files": "{folder} has {count} folder(s)",
|
||||
"maintenance_restore_library_folder_no_files": "{folder} is missing files!",
|
||||
"maintenance_restore_library_folder_pass": "readable and writable",
|
||||
"maintenance_restore_library_folder_read_fail": "not readable",
|
||||
"maintenance_restore_library_folder_write_fail": "not writable",
|
||||
"maintenance_restore_library_hint_missing_files": "You may be missing important files",
|
||||
"maintenance_restore_library_hint_regenerate_later": "You can regenerate these later in settings",
|
||||
"maintenance_restore_library_hint_storage_template_missing_files": "Using storage template? You may be missing files",
|
||||
"maintenance_restore_library_loading": "Loading integrity checks and heuristics…",
|
||||
"maintenance_task_backup": "Creating a backup of the existing database…",
|
||||
"maintenance_task_migrations": "Running database migrations…",
|
||||
"maintenance_task_restore": "Restoring the chosen backup…",
|
||||
"maintenance_task_rollback": "Restore failed, rolling back to restore point…",
|
||||
"maintenance_title": "Temporarily Unavailable",
|
||||
"make": "Make",
|
||||
"manage_geolocation": "Manage location",
|
||||
@@ -1526,7 +1557,7 @@
|
||||
"no_albums_with_name_yet": "It looks like you do not have any albums with this name yet.",
|
||||
"no_albums_yet": "It looks like you do not have any albums yet.",
|
||||
"no_archived_assets_message": "Archive photos and videos to hide them from your Photos view",
|
||||
"no_assets_message": "CLICK TO UPLOAD YOUR FIRST PHOTO",
|
||||
"no_assets_message": "Click to upload your first photo",
|
||||
"no_assets_to_show": "No assets to show",
|
||||
"no_cast_devices_found": "No cast devices found",
|
||||
"no_checksum_local": "No checksum available - cannot fetch local assets",
|
||||
@@ -2160,6 +2191,7 @@
|
||||
"theme_setting_theme_subtitle": "Choose the app's theme setting",
|
||||
"theme_setting_three_stage_loading_subtitle": "Three-stage loading might increase the loading performance but causes significantly higher network load",
|
||||
"theme_setting_three_stage_loading_title": "Enable three-stage loading",
|
||||
"then": "Then",
|
||||
"they_will_be_merged_together": "They will be merged together",
|
||||
"third_party_resources": "Third-Party Resources",
|
||||
"time": "Time",
|
||||
@@ -2215,6 +2247,7 @@
|
||||
"unhide_person": "Unhide person",
|
||||
"unknown": "Unknown",
|
||||
"unknown_country": "Unknown Country",
|
||||
"unknown_date": "Unknown date",
|
||||
"unknown_year": "Unknown Year",
|
||||
"unlimited": "Unlimited",
|
||||
"unlink_motion_video": "Unlink motion video",
|
||||
|
||||
@@ -2,7 +2,7 @@ experimental_monorepo_root = true
|
||||
|
||||
[tools]
|
||||
node = "24.13.0"
|
||||
flutter = "3.38.7"
|
||||
flutter = "3.35.7"
|
||||
pnpm = "10.28.0"
|
||||
terragrunt = "0.93.10"
|
||||
opentofu = "1.10.7"
|
||||
|
||||
@@ -10,6 +10,7 @@ import 'package:immich_mobile/providers/background_sync.provider.dart';
|
||||
import 'package:immich_mobile/providers/backup/backup.provider.dart';
|
||||
import 'package:immich_mobile/providers/backup/drift_backup.provider.dart';
|
||||
import 'package:immich_mobile/providers/gallery_permission.provider.dart';
|
||||
import 'package:immich_mobile/providers/server_info.provider.dart';
|
||||
import 'package:immich_mobile/providers/websocket.provider.dart';
|
||||
import 'package:immich_mobile/routing/router.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
@@ -49,6 +50,7 @@ class SplashScreenPageState extends ConsumerState<SplashScreenPage> {
|
||||
final accessToken = Store.tryGet(StoreKey.accessToken);
|
||||
|
||||
if (accessToken != null && serverUrl != null && endpoint != null) {
|
||||
final infoProvider = ref.read(serverInfoProvider.notifier);
|
||||
final wsProvider = ref.read(websocketProvider.notifier);
|
||||
final backgroundManager = ref.read(backgroundSyncProvider);
|
||||
final backupProvider = ref.read(driftBackupProvider.notifier);
|
||||
@@ -58,6 +60,7 @@ class SplashScreenPageState extends ConsumerState<SplashScreenPage> {
|
||||
(_) async {
|
||||
try {
|
||||
wsProvider.connect();
|
||||
unawaited(infoProvider.getServerInfo());
|
||||
|
||||
if (Store.isBetaTimelineEnabled) {
|
||||
bool syncSuccess = false;
|
||||
|
||||
@@ -118,7 +118,6 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
|
||||
bool dragInProgress = false;
|
||||
bool shouldPopOnDrag = false;
|
||||
bool assetReloadRequested = false;
|
||||
double? initialScale;
|
||||
double previousExtent = _kBottomSheetMinimumExtent;
|
||||
Offset dragDownPosition = Offset.zero;
|
||||
int totalAssets = 0;
|
||||
@@ -264,7 +263,6 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
|
||||
(context.height * bottomSheetController.size) - (context.height * _kBottomSheetMinimumExtent);
|
||||
controller.position = Offset(0, -verticalOffset);
|
||||
// Apply the zoom effect when the bottom sheet is showing
|
||||
initialScale = controller.scale;
|
||||
controller.scale = (controller.scale ?? 1.0) + 0.01;
|
||||
}
|
||||
}
|
||||
@@ -316,7 +314,7 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
|
||||
hasDraggedDown = null;
|
||||
viewController?.animateMultiple(
|
||||
position: initialPhotoViewState.position,
|
||||
scale: initialPhotoViewState.scale,
|
||||
scale: viewController?.initialScale ?? initialPhotoViewState.scale,
|
||||
rotation: initialPhotoViewState.rotation,
|
||||
);
|
||||
ref.read(assetViewerProvider.notifier).setOpacity(255);
|
||||
@@ -366,8 +364,9 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
|
||||
final maxScaleDistance = ctx.height * 0.5;
|
||||
final scaleReduction = (distance / maxScaleDistance).clamp(0.0, dragRatio);
|
||||
double? updatedScale;
|
||||
if (initialPhotoViewState.scale != null) {
|
||||
updatedScale = initialPhotoViewState.scale! * (1.0 - scaleReduction);
|
||||
double? initialScale = viewController?.initialScale ?? initialPhotoViewState.scale;
|
||||
if (initialScale != null) {
|
||||
updatedScale = initialScale * (1.0 - scaleReduction);
|
||||
}
|
||||
|
||||
final backgroundOpacity = (255 * (1.0 - (scaleReduction / dragRatio))).round();
|
||||
@@ -481,8 +480,6 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
|
||||
|
||||
void _openBottomSheet(BuildContext ctx, {double extent = _kBottomSheetMinimumExtent, bool activitiesMode = false}) {
|
||||
ref.read(assetViewerProvider.notifier).setBottomSheet(true);
|
||||
initialScale = viewController?.scale;
|
||||
// viewController?.updateMultiple(scale: (viewController?.scale ?? 1.0) + 0.01);
|
||||
previousExtent = _kBottomSheetMinimumExtent;
|
||||
sheetCloseController = showBottomSheet(
|
||||
context: ctx,
|
||||
@@ -504,7 +501,7 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
|
||||
|
||||
void _handleSheetClose() {
|
||||
viewController?.animateMultiple(position: Offset.zero);
|
||||
viewController?.updateMultiple(scale: initialScale);
|
||||
viewController?.updateMultiple(scale: viewController?.initialScale);
|
||||
ref.read(assetViewerProvider.notifier).setBottomSheet(false);
|
||||
sheetCloseController = null;
|
||||
shouldPopOnDrag = false;
|
||||
|
||||
@@ -6,6 +6,7 @@ import 'package:immich_mobile/domain/models/setting.model.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/extensions/duration_extensions.dart';
|
||||
import 'package:immich_mobile/extensions/theme_extensions.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/images/thumbnail.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/timeline/constants.dart';
|
||||
import 'package:immich_mobile/providers/backup/asset_upload_progress.provider.dart';
|
||||
@@ -47,6 +48,7 @@ class _ThumbnailTileState extends ConsumerState<ThumbnailTile> {
|
||||
Widget build(BuildContext context) {
|
||||
final asset = widget.asset;
|
||||
final heroIndex = widget.heroOffset ?? TabsRouterScope.of(context)?.controller.activeIndex ?? 0;
|
||||
final isCurrentAsset = ref.watch(assetViewerProvider.select((current) => current.currentAsset == asset));
|
||||
|
||||
final assetContainerColor = context.isDarkTheme
|
||||
? context.primaryColor.darken(amount: 0.4)
|
||||
@@ -59,6 +61,10 @@ class _ThumbnailTileState extends ConsumerState<ThumbnailTile> {
|
||||
final bool storageIndicator =
|
||||
ref.watch(settingsProvider.select((s) => s.get(Setting.showStorageIndicator))) && widget.showStorageIndicator;
|
||||
|
||||
if (!isCurrentAsset) {
|
||||
_hideIndicators = false;
|
||||
}
|
||||
|
||||
if (isSelected) {
|
||||
_showSelectionContainer = true;
|
||||
}
|
||||
@@ -96,7 +102,11 @@ class _ThumbnailTileState extends ConsumerState<ThumbnailTile> {
|
||||
children: [
|
||||
Positioned.fill(
|
||||
child: Hero(
|
||||
tag: '${asset?.heroTag ?? ''}_$heroIndex',
|
||||
// This key resets the hero animation when the asset is changed in the asset viewer.
|
||||
// It doesn't seem like the best solution, and only works to reset the hero, not prime the hero of the new active asset for animation,
|
||||
// but other solutions have failed thus far.
|
||||
key: ValueKey(isCurrentAsset),
|
||||
tag: '${asset?.heroTag}_$heroIndex',
|
||||
child: Thumbnail.fromAsset(asset: asset, size: widget.size),
|
||||
// Placeholderbuilder used to hide indicators on first hero animation, since flightShuttleBuilder isn't called until both source and destination hero exist in widget tree.
|
||||
placeholderBuilder: (context, heroSize, child) {
|
||||
|
||||
@@ -160,7 +160,7 @@ class AppLifeCycleNotifier extends StateNotifier<AppLifeCycleEnum> {
|
||||
_resumeBackup();
|
||||
}),
|
||||
_resumeBackup(),
|
||||
backgroundManager.syncCloudIds(),
|
||||
_safeRun(backgroundManager.syncCloudIds(), "syncCloudIds"),
|
||||
]);
|
||||
} else {
|
||||
await _safeRun(backgroundManager.hashAssets(), "hashAssets");
|
||||
|
||||
@@ -285,7 +285,12 @@ class BackgroundUploadService {
|
||||
return null;
|
||||
}
|
||||
|
||||
final fileName = await _assetMediaRepository.getOriginalFilename(asset.id) ?? asset.name;
|
||||
String fileName = await _assetMediaRepository.getOriginalFilename(asset.id) ?? asset.name;
|
||||
final hasExtension = p.extension(fileName).isNotEmpty;
|
||||
if (!hasExtension) {
|
||||
fileName = p.setExtension(fileName, p.extension(asset.name));
|
||||
}
|
||||
|
||||
final originalFileName = entity.isLivePhoto ? p.setExtension(fileName, p.extension(file.path)) : fileName;
|
||||
|
||||
String metadata = UploadTaskMetadata(
|
||||
@@ -307,10 +312,10 @@ class BackgroundUploadService {
|
||||
priority: priority,
|
||||
isFavorite: asset.isFavorite,
|
||||
requiresWiFi: requiresWiFi,
|
||||
cloudId: asset.cloudId,
|
||||
adjustmentTime: asset.adjustmentTime?.toIso8601String(),
|
||||
latitude: asset.latitude?.toString(),
|
||||
longitude: asset.longitude?.toString(),
|
||||
cloudId: entity.isLivePhoto ? null : asset.cloudId,
|
||||
adjustmentTime: entity.isLivePhoto ? null : asset.adjustmentTime?.toIso8601String(),
|
||||
latitude: entity.isLivePhoto ? null : asset.latitude?.toString(),
|
||||
longitude: entity.isLivePhoto ? null : asset.longitude?.toString(),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ import 'package:immich_mobile/platform/connectivity_api.g.dart';
|
||||
import 'package:immich_mobile/providers/app_settings.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/platform.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/storage.provider.dart';
|
||||
import 'package:immich_mobile/repositories/asset_media.repository.dart';
|
||||
import 'package:immich_mobile/repositories/upload.repository.dart';
|
||||
import 'package:immich_mobile/services/api.service.dart';
|
||||
import 'package:immich_mobile/services/app_settings.service.dart';
|
||||
@@ -40,6 +41,7 @@ final foregroundUploadServiceProvider = Provider((ref) {
|
||||
ref.watch(backupRepositoryProvider),
|
||||
ref.watch(connectivityApiProvider),
|
||||
ref.watch(appSettingsServiceProvider),
|
||||
ref.watch(assetMediaRepositoryProvider),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -55,6 +57,7 @@ class ForegroundUploadService {
|
||||
this._backupRepository,
|
||||
this._connectivityApi,
|
||||
this._appSettingsService,
|
||||
this._assetMediaRepository,
|
||||
);
|
||||
|
||||
final UploadRepository _uploadRepository;
|
||||
@@ -62,6 +65,7 @@ class ForegroundUploadService {
|
||||
final DriftBackupRepository _backupRepository;
|
||||
final ConnectivityApi _connectivityApi;
|
||||
final AppSettingsService _appSettingsService;
|
||||
final AssetMediaRepository _assetMediaRepository;
|
||||
final Logger _logger = Logger('ForegroundUploadService');
|
||||
|
||||
bool shouldAbortUpload = false;
|
||||
@@ -311,7 +315,17 @@ class ForegroundUploadService {
|
||||
return;
|
||||
}
|
||||
|
||||
final originalFileName = entity.isLivePhoto ? p.setExtension(asset.name, p.extension(file.path)) : asset.name;
|
||||
String fileName = await _assetMediaRepository.getOriginalFilename(asset.id) ?? asset.name;
|
||||
|
||||
/// Handle special file name from DJI or Fusion app
|
||||
/// If the file name has no extension, likely due to special renaming template by specific apps
|
||||
/// we append the original extension from the asset name
|
||||
final hasExtension = p.extension(fileName).isNotEmpty;
|
||||
if (!hasExtension) {
|
||||
fileName = p.setExtension(fileName, p.extension(asset.name));
|
||||
}
|
||||
|
||||
final originalFileName = entity.isLivePhoto ? p.setExtension(fileName, p.extension(file.path)) : fileName;
|
||||
final deviceId = Store.get(StoreKey.deviceId);
|
||||
|
||||
final headers = ApiService.getRequestHeaders();
|
||||
@@ -322,19 +336,6 @@ class ForegroundUploadService {
|
||||
'fileModifiedAt': asset.updatedAt.toUtc().toIso8601String(),
|
||||
'isFavorite': asset.isFavorite.toString(),
|
||||
'duration': asset.duration.toString(),
|
||||
if (CurrentPlatform.isIOS && asset.cloudId != null)
|
||||
'metadata': jsonEncode([
|
||||
RemoteAssetMetadataItem(
|
||||
key: RemoteAssetMetadataKey.mobileApp,
|
||||
value: RemoteAssetMobileAppMetadata(
|
||||
cloudId: asset.cloudId,
|
||||
createdAt: asset.createdAt.toIso8601String(),
|
||||
adjustmentTime: asset.adjustmentTime?.toIso8601String(),
|
||||
latitude: asset.latitude?.toString(),
|
||||
longitude: asset.longitude?.toString(),
|
||||
),
|
||||
),
|
||||
]),
|
||||
};
|
||||
|
||||
// Upload live photo video first if available
|
||||
@@ -363,6 +364,22 @@ class ForegroundUploadService {
|
||||
fields['livePhotoVideoId'] = livePhotoVideoId;
|
||||
}
|
||||
|
||||
// Add cloudId metadata only to the still image, not the motion video, becasue when the sync id happens, the motion video can get associated with the wrong still image.
|
||||
if (CurrentPlatform.isIOS && asset.cloudId != null) {
|
||||
fields['metadata'] = jsonEncode([
|
||||
RemoteAssetMetadataItem(
|
||||
key: RemoteAssetMetadataKey.mobileApp,
|
||||
value: RemoteAssetMobileAppMetadata(
|
||||
cloudId: asset.cloudId,
|
||||
createdAt: asset.createdAt.toIso8601String(),
|
||||
adjustmentTime: asset.adjustmentTime?.toIso8601String(),
|
||||
latitude: asset.latitude?.toString(),
|
||||
longitude: asset.longitude?.toString(),
|
||||
),
|
||||
),
|
||||
]);
|
||||
}
|
||||
|
||||
final result = await _uploadRepository.uploadFile(
|
||||
file: file,
|
||||
originalFileName: originalFileName,
|
||||
|
||||
@@ -2,6 +2,7 @@ import 'dart:async';
|
||||
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:immich_mobile/widgets/photo_view/src/utils/ignorable_change_notifier.dart';
|
||||
import 'package:immich_mobile/widgets/photo_view/src/utils/photo_view_utils.dart';
|
||||
|
||||
/// The interface in which controllers will be implemented.
|
||||
///
|
||||
@@ -62,6 +63,9 @@ abstract class PhotoViewControllerBase<T extends PhotoViewControllerValue> {
|
||||
/// The scale factor to transform the child (image or a customChild).
|
||||
late double? scale;
|
||||
|
||||
double? get initialScale;
|
||||
ScaleBoundaries? scaleBoundaries;
|
||||
|
||||
/// Nevermind this method :D, look away
|
||||
void setScaleInvisibly(double? scale);
|
||||
|
||||
@@ -141,6 +145,9 @@ class PhotoViewController implements PhotoViewControllerBase<PhotoViewController
|
||||
|
||||
late StreamController<PhotoViewControllerValue> _outputCtrl;
|
||||
|
||||
@override
|
||||
ScaleBoundaries? scaleBoundaries;
|
||||
|
||||
late void Function(Offset)? _animatePosition;
|
||||
late void Function(double)? _animateScale;
|
||||
late void Function(double)? _animateRotation;
|
||||
@@ -311,4 +318,7 @@ class PhotoViewController implements PhotoViewControllerBase<PhotoViewController
|
||||
}
|
||||
_valueNotifier.value = newValue;
|
||||
}
|
||||
|
||||
@override
|
||||
double? get initialScale => scaleBoundaries?.initialScale ?? initial.scale;
|
||||
}
|
||||
|
||||
@@ -108,6 +108,17 @@ class _ImageWrapperState extends State<ImageWrapper> {
|
||||
}
|
||||
}
|
||||
|
||||
// Should be called only when _imageSize is not null
|
||||
ScaleBoundaries get scaleBoundaries {
|
||||
return ScaleBoundaries(
|
||||
widget.minScale ?? 0.0,
|
||||
widget.maxScale ?? double.infinity,
|
||||
widget.initialScale ?? PhotoViewComputedScale.contained,
|
||||
widget.outerSize,
|
||||
_imageSize!,
|
||||
);
|
||||
}
|
||||
|
||||
// retrieve image from the provider
|
||||
void _resolveImage() {
|
||||
final ImageStream newStream = widget.imageProvider.resolve(const ImageConfiguration());
|
||||
@@ -133,6 +144,7 @@ class _ImageWrapperState extends State<ImageWrapper> {
|
||||
_lastStack = null;
|
||||
|
||||
_didLoadSynchronously = synchronousCall;
|
||||
widget.controller.scaleBoundaries = scaleBoundaries;
|
||||
}
|
||||
|
||||
synchronousCall && !_didLoadSynchronously ? setupCB() : setState(setupCB);
|
||||
@@ -204,14 +216,6 @@ class _ImageWrapperState extends State<ImageWrapper> {
|
||||
);
|
||||
}
|
||||
|
||||
final scaleBoundaries = ScaleBoundaries(
|
||||
widget.minScale ?? 0.0,
|
||||
widget.maxScale ?? double.infinity,
|
||||
widget.initialScale ?? PhotoViewComputedScale.contained,
|
||||
widget.outerSize,
|
||||
_imageSize!,
|
||||
);
|
||||
|
||||
return PhotoViewCore(
|
||||
imageProvider: widget.imageProvider,
|
||||
backgroundDecoration: widget.backgroundDecoration,
|
||||
|
||||
14
mobile/openapi/README.md
generated
14
mobile/openapi/README.md
generated
@@ -138,6 +138,11 @@ Class | Method | HTTP request | Description
|
||||
*AuthenticationApi* | [**unlockAuthSession**](doc//AuthenticationApi.md#unlockauthsession) | **POST** /auth/session/unlock | Unlock auth session
|
||||
*AuthenticationApi* | [**validateAccessToken**](doc//AuthenticationApi.md#validateaccesstoken) | **POST** /auth/validateToken | Validate access token
|
||||
*AuthenticationAdminApi* | [**unlinkAllOAuthAccountsAdmin**](doc//AuthenticationAdminApi.md#unlinkalloauthaccountsadmin) | **POST** /admin/auth/unlink-all | Unlink all OAuth accounts
|
||||
*DatabaseBackupsAdminApi* | [**deleteDatabaseBackup**](doc//DatabaseBackupsAdminApi.md#deletedatabasebackup) | **DELETE** /admin/database-backups | Delete database backup
|
||||
*DatabaseBackupsAdminApi* | [**downloadDatabaseBackup**](doc//DatabaseBackupsAdminApi.md#downloaddatabasebackup) | **GET** /admin/database-backups/{filename} | Download database backup
|
||||
*DatabaseBackupsAdminApi* | [**listDatabaseBackups**](doc//DatabaseBackupsAdminApi.md#listdatabasebackups) | **GET** /admin/database-backups | List database backups
|
||||
*DatabaseBackupsAdminApi* | [**startDatabaseRestoreFlow**](doc//DatabaseBackupsAdminApi.md#startdatabaserestoreflow) | **POST** /admin/database-backups/start-restore | Start database backup restore flow
|
||||
*DatabaseBackupsAdminApi* | [**uploadDatabaseBackup**](doc//DatabaseBackupsAdminApi.md#uploaddatabasebackup) | **POST** /admin/database-backups/upload | Upload database backup
|
||||
*DeprecatedApi* | [**createPartnerDeprecated**](doc//DeprecatedApi.md#createpartnerdeprecated) | **POST** /partners/{id} | Create a partner
|
||||
*DeprecatedApi* | [**getAllUserAssetsByDeviceId**](doc//DeprecatedApi.md#getalluserassetsbydeviceid) | **GET** /assets/device/{deviceId} | Retrieve assets by device ID
|
||||
*DeprecatedApi* | [**getDeltaSync**](doc//DeprecatedApi.md#getdeltasync) | **POST** /sync/delta-sync | Get delta sync for user
|
||||
@@ -166,6 +171,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* | [**detectPriorInstall**](doc//MaintenanceAdminApi.md#detectpriorinstall) | **GET** /admin/maintenance/detect-install | Detect existing install
|
||||
*MaintenanceAdminApi* | [**getMaintenanceStatus**](doc//MaintenanceAdminApi.md#getmaintenancestatus) | **GET** /admin/maintenance/status | Get maintenance mode status
|
||||
*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
|
||||
@@ -405,6 +412,9 @@ Class | Method | HTTP request | Description
|
||||
- [CreateProfileImageResponseDto](doc//CreateProfileImageResponseDto.md)
|
||||
- [CropParameters](doc//CropParameters.md)
|
||||
- [DatabaseBackupConfig](doc//DatabaseBackupConfig.md)
|
||||
- [DatabaseBackupDeleteDto](doc//DatabaseBackupDeleteDto.md)
|
||||
- [DatabaseBackupDto](doc//DatabaseBackupDto.md)
|
||||
- [DatabaseBackupListResponseDto](doc//DatabaseBackupListResponseDto.md)
|
||||
- [DownloadArchiveInfo](doc//DownloadArchiveInfo.md)
|
||||
- [DownloadInfoDto](doc//DownloadInfoDto.md)
|
||||
- [DownloadResponse](doc//DownloadResponse.md)
|
||||
@@ -434,7 +444,10 @@ Class | Method | HTTP request | Description
|
||||
- [MachineLearningAvailabilityChecksDto](doc//MachineLearningAvailabilityChecksDto.md)
|
||||
- [MaintenanceAction](doc//MaintenanceAction.md)
|
||||
- [MaintenanceAuthDto](doc//MaintenanceAuthDto.md)
|
||||
- [MaintenanceDetectInstallResponseDto](doc//MaintenanceDetectInstallResponseDto.md)
|
||||
- [MaintenanceDetectInstallStorageFolderDto](doc//MaintenanceDetectInstallStorageFolderDto.md)
|
||||
- [MaintenanceLoginDto](doc//MaintenanceLoginDto.md)
|
||||
- [MaintenanceStatusResponseDto](doc//MaintenanceStatusResponseDto.md)
|
||||
- [ManualJobName](doc//ManualJobName.md)
|
||||
- [MapMarkerResponseDto](doc//MapMarkerResponseDto.md)
|
||||
- [MapReverseGeocodeResponseDto](doc//MapReverseGeocodeResponseDto.md)
|
||||
@@ -550,6 +563,7 @@ Class | Method | HTTP request | Description
|
||||
- [StackResponseDto](doc//StackResponseDto.md)
|
||||
- [StackUpdateDto](doc//StackUpdateDto.md)
|
||||
- [StatisticsSearchDto](doc//StatisticsSearchDto.md)
|
||||
- [StorageFolder](doc//StorageFolder.md)
|
||||
- [SyncAckDeleteDto](doc//SyncAckDeleteDto.md)
|
||||
- [SyncAckDto](doc//SyncAckDto.md)
|
||||
- [SyncAckSetDto](doc//SyncAckSetDto.md)
|
||||
|
||||
8
mobile/openapi/lib/api.dart
generated
8
mobile/openapi/lib/api.dart
generated
@@ -36,6 +36,7 @@ part 'api/albums_api.dart';
|
||||
part 'api/assets_api.dart';
|
||||
part 'api/authentication_api.dart';
|
||||
part 'api/authentication_admin_api.dart';
|
||||
part 'api/database_backups_admin_api.dart';
|
||||
part 'api/deprecated_api.dart';
|
||||
part 'api/download_api.dart';
|
||||
part 'api/duplicates_api.dart';
|
||||
@@ -151,6 +152,9 @@ part 'model/create_library_dto.dart';
|
||||
part 'model/create_profile_image_response_dto.dart';
|
||||
part 'model/crop_parameters.dart';
|
||||
part 'model/database_backup_config.dart';
|
||||
part 'model/database_backup_delete_dto.dart';
|
||||
part 'model/database_backup_dto.dart';
|
||||
part 'model/database_backup_list_response_dto.dart';
|
||||
part 'model/download_archive_info.dart';
|
||||
part 'model/download_info_dto.dart';
|
||||
part 'model/download_response.dart';
|
||||
@@ -180,7 +184,10 @@ 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_detect_install_response_dto.dart';
|
||||
part 'model/maintenance_detect_install_storage_folder_dto.dart';
|
||||
part 'model/maintenance_login_dto.dart';
|
||||
part 'model/maintenance_status_response_dto.dart';
|
||||
part 'model/manual_job_name.dart';
|
||||
part 'model/map_marker_response_dto.dart';
|
||||
part 'model/map_reverse_geocode_response_dto.dart';
|
||||
@@ -296,6 +303,7 @@ part 'model/stack_create_dto.dart';
|
||||
part 'model/stack_response_dto.dart';
|
||||
part 'model/stack_update_dto.dart';
|
||||
part 'model/statistics_search_dto.dart';
|
||||
part 'model/storage_folder.dart';
|
||||
part 'model/sync_ack_delete_dto.dart';
|
||||
part 'model/sync_ack_dto.dart';
|
||||
part 'model/sync_ack_set_dto.dart';
|
||||
|
||||
269
mobile/openapi/lib/api/database_backups_admin_api.dart
generated
Normal file
269
mobile/openapi/lib/api/database_backups_admin_api.dart
generated
Normal file
@@ -0,0 +1,269 @@
|
||||
//
|
||||
// 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 DatabaseBackupsAdminApi {
|
||||
DatabaseBackupsAdminApi([ApiClient? apiClient]) : apiClient = apiClient ?? defaultApiClient;
|
||||
|
||||
final ApiClient apiClient;
|
||||
|
||||
/// Delete database backup
|
||||
///
|
||||
/// Delete a backup by its filename
|
||||
///
|
||||
/// Note: This method returns the HTTP [Response].
|
||||
///
|
||||
/// Parameters:
|
||||
///
|
||||
/// * [DatabaseBackupDeleteDto] databaseBackupDeleteDto (required):
|
||||
Future<Response> deleteDatabaseBackupWithHttpInfo(DatabaseBackupDeleteDto databaseBackupDeleteDto,) async {
|
||||
// ignore: prefer_const_declarations
|
||||
final apiPath = r'/admin/database-backups';
|
||||
|
||||
// ignore: prefer_final_locals
|
||||
Object? postBody = databaseBackupDeleteDto;
|
||||
|
||||
final queryParams = <QueryParam>[];
|
||||
final headerParams = <String, String>{};
|
||||
final formParams = <String, String>{};
|
||||
|
||||
const contentTypes = <String>['application/json'];
|
||||
|
||||
|
||||
return apiClient.invokeAPI(
|
||||
apiPath,
|
||||
'DELETE',
|
||||
queryParams,
|
||||
postBody,
|
||||
headerParams,
|
||||
formParams,
|
||||
contentTypes.isEmpty ? null : contentTypes.first,
|
||||
);
|
||||
}
|
||||
|
||||
/// Delete database backup
|
||||
///
|
||||
/// Delete a backup by its filename
|
||||
///
|
||||
/// Parameters:
|
||||
///
|
||||
/// * [DatabaseBackupDeleteDto] databaseBackupDeleteDto (required):
|
||||
Future<void> deleteDatabaseBackup(DatabaseBackupDeleteDto databaseBackupDeleteDto,) async {
|
||||
final response = await deleteDatabaseBackupWithHttpInfo(databaseBackupDeleteDto,);
|
||||
if (response.statusCode >= HttpStatus.badRequest) {
|
||||
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
|
||||
}
|
||||
}
|
||||
|
||||
/// Download database backup
|
||||
///
|
||||
/// Downloads the database backup file
|
||||
///
|
||||
/// Note: This method returns the HTTP [Response].
|
||||
///
|
||||
/// Parameters:
|
||||
///
|
||||
/// * [String] filename (required):
|
||||
Future<Response> downloadDatabaseBackupWithHttpInfo(String filename,) async {
|
||||
// ignore: prefer_const_declarations
|
||||
final apiPath = r'/admin/database-backups/{filename}'
|
||||
.replaceAll('{filename}', filename);
|
||||
|
||||
// ignore: prefer_final_locals
|
||||
Object? postBody;
|
||||
|
||||
final queryParams = <QueryParam>[];
|
||||
final headerParams = <String, String>{};
|
||||
final formParams = <String, String>{};
|
||||
|
||||
const contentTypes = <String>[];
|
||||
|
||||
|
||||
return apiClient.invokeAPI(
|
||||
apiPath,
|
||||
'GET',
|
||||
queryParams,
|
||||
postBody,
|
||||
headerParams,
|
||||
formParams,
|
||||
contentTypes.isEmpty ? null : contentTypes.first,
|
||||
);
|
||||
}
|
||||
|
||||
/// Download database backup
|
||||
///
|
||||
/// Downloads the database backup file
|
||||
///
|
||||
/// Parameters:
|
||||
///
|
||||
/// * [String] filename (required):
|
||||
Future<MultipartFile?> downloadDatabaseBackup(String filename,) async {
|
||||
final response = await downloadDatabaseBackupWithHttpInfo(filename,);
|
||||
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), 'MultipartFile',) as MultipartFile;
|
||||
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// List database backups
|
||||
///
|
||||
/// Get the list of the successful and failed backups
|
||||
///
|
||||
/// Note: This method returns the HTTP [Response].
|
||||
Future<Response> listDatabaseBackupsWithHttpInfo() async {
|
||||
// ignore: prefer_const_declarations
|
||||
final apiPath = r'/admin/database-backups';
|
||||
|
||||
// ignore: prefer_final_locals
|
||||
Object? postBody;
|
||||
|
||||
final queryParams = <QueryParam>[];
|
||||
final headerParams = <String, String>{};
|
||||
final formParams = <String, String>{};
|
||||
|
||||
const contentTypes = <String>[];
|
||||
|
||||
|
||||
return apiClient.invokeAPI(
|
||||
apiPath,
|
||||
'GET',
|
||||
queryParams,
|
||||
postBody,
|
||||
headerParams,
|
||||
formParams,
|
||||
contentTypes.isEmpty ? null : contentTypes.first,
|
||||
);
|
||||
}
|
||||
|
||||
/// List database backups
|
||||
///
|
||||
/// Get the list of the successful and failed backups
|
||||
Future<DatabaseBackupListResponseDto?> listDatabaseBackups() async {
|
||||
final response = await listDatabaseBackupsWithHttpInfo();
|
||||
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), 'DatabaseBackupListResponseDto',) as DatabaseBackupListResponseDto;
|
||||
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Start database backup restore flow
|
||||
///
|
||||
/// Put Immich into maintenance mode to restore a backup (Immich must not be configured)
|
||||
///
|
||||
/// Note: This method returns the HTTP [Response].
|
||||
Future<Response> startDatabaseRestoreFlowWithHttpInfo() async {
|
||||
// ignore: prefer_const_declarations
|
||||
final apiPath = r'/admin/database-backups/start-restore';
|
||||
|
||||
// ignore: prefer_final_locals
|
||||
Object? postBody;
|
||||
|
||||
final queryParams = <QueryParam>[];
|
||||
final headerParams = <String, String>{};
|
||||
final formParams = <String, String>{};
|
||||
|
||||
const contentTypes = <String>[];
|
||||
|
||||
|
||||
return apiClient.invokeAPI(
|
||||
apiPath,
|
||||
'POST',
|
||||
queryParams,
|
||||
postBody,
|
||||
headerParams,
|
||||
formParams,
|
||||
contentTypes.isEmpty ? null : contentTypes.first,
|
||||
);
|
||||
}
|
||||
|
||||
/// Start database backup restore flow
|
||||
///
|
||||
/// Put Immich into maintenance mode to restore a backup (Immich must not be configured)
|
||||
Future<void> startDatabaseRestoreFlow() async {
|
||||
final response = await startDatabaseRestoreFlowWithHttpInfo();
|
||||
if (response.statusCode >= HttpStatus.badRequest) {
|
||||
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
|
||||
}
|
||||
}
|
||||
|
||||
/// Upload database backup
|
||||
///
|
||||
/// Uploads .sql/.sql.gz file to restore backup from
|
||||
///
|
||||
/// Note: This method returns the HTTP [Response].
|
||||
///
|
||||
/// Parameters:
|
||||
///
|
||||
/// * [MultipartFile] file:
|
||||
Future<Response> uploadDatabaseBackupWithHttpInfo({ MultipartFile? file, }) async {
|
||||
// ignore: prefer_const_declarations
|
||||
final apiPath = r'/admin/database-backups/upload';
|
||||
|
||||
// ignore: prefer_final_locals
|
||||
Object? postBody;
|
||||
|
||||
final queryParams = <QueryParam>[];
|
||||
final headerParams = <String, String>{};
|
||||
final formParams = <String, String>{};
|
||||
|
||||
const contentTypes = <String>['multipart/form-data'];
|
||||
|
||||
bool hasFields = false;
|
||||
final mp = MultipartRequest('POST', Uri.parse(apiPath));
|
||||
if (file != null) {
|
||||
hasFields = true;
|
||||
mp.fields[r'file'] = file.field;
|
||||
mp.files.add(file);
|
||||
}
|
||||
if (hasFields) {
|
||||
postBody = mp;
|
||||
}
|
||||
|
||||
return apiClient.invokeAPI(
|
||||
apiPath,
|
||||
'POST',
|
||||
queryParams,
|
||||
postBody,
|
||||
headerParams,
|
||||
formParams,
|
||||
contentTypes.isEmpty ? null : contentTypes.first,
|
||||
);
|
||||
}
|
||||
|
||||
/// Upload database backup
|
||||
///
|
||||
/// Uploads .sql/.sql.gz file to restore backup from
|
||||
///
|
||||
/// Parameters:
|
||||
///
|
||||
/// * [MultipartFile] file:
|
||||
Future<void> uploadDatabaseBackup({ MultipartFile? file, }) async {
|
||||
final response = await uploadDatabaseBackupWithHttpInfo( file: file, );
|
||||
if (response.statusCode >= HttpStatus.badRequest) {
|
||||
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
|
||||
}
|
||||
}
|
||||
}
|
||||
96
mobile/openapi/lib/api/maintenance_admin_api.dart
generated
96
mobile/openapi/lib/api/maintenance_admin_api.dart
generated
@@ -16,6 +16,102 @@ class MaintenanceAdminApi {
|
||||
|
||||
final ApiClient apiClient;
|
||||
|
||||
/// Detect existing install
|
||||
///
|
||||
/// Collect integrity checks and other heuristics about local data.
|
||||
///
|
||||
/// Note: This method returns the HTTP [Response].
|
||||
Future<Response> detectPriorInstallWithHttpInfo() async {
|
||||
// ignore: prefer_const_declarations
|
||||
final apiPath = r'/admin/maintenance/detect-install';
|
||||
|
||||
// ignore: prefer_final_locals
|
||||
Object? postBody;
|
||||
|
||||
final queryParams = <QueryParam>[];
|
||||
final headerParams = <String, String>{};
|
||||
final formParams = <String, String>{};
|
||||
|
||||
const contentTypes = <String>[];
|
||||
|
||||
|
||||
return apiClient.invokeAPI(
|
||||
apiPath,
|
||||
'GET',
|
||||
queryParams,
|
||||
postBody,
|
||||
headerParams,
|
||||
formParams,
|
||||
contentTypes.isEmpty ? null : contentTypes.first,
|
||||
);
|
||||
}
|
||||
|
||||
/// Detect existing install
|
||||
///
|
||||
/// Collect integrity checks and other heuristics about local data.
|
||||
Future<MaintenanceDetectInstallResponseDto?> detectPriorInstall() async {
|
||||
final response = await detectPriorInstallWithHttpInfo();
|
||||
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), 'MaintenanceDetectInstallResponseDto',) as MaintenanceDetectInstallResponseDto;
|
||||
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Get maintenance mode status
|
||||
///
|
||||
/// Fetch information about the currently running maintenance action.
|
||||
///
|
||||
/// Note: This method returns the HTTP [Response].
|
||||
Future<Response> getMaintenanceStatusWithHttpInfo() async {
|
||||
// ignore: prefer_const_declarations
|
||||
final apiPath = r'/admin/maintenance/status';
|
||||
|
||||
// ignore: prefer_final_locals
|
||||
Object? postBody;
|
||||
|
||||
final queryParams = <QueryParam>[];
|
||||
final headerParams = <String, String>{};
|
||||
final formParams = <String, String>{};
|
||||
|
||||
const contentTypes = <String>[];
|
||||
|
||||
|
||||
return apiClient.invokeAPI(
|
||||
apiPath,
|
||||
'GET',
|
||||
queryParams,
|
||||
postBody,
|
||||
headerParams,
|
||||
formParams,
|
||||
contentTypes.isEmpty ? null : contentTypes.first,
|
||||
);
|
||||
}
|
||||
|
||||
/// Get maintenance mode status
|
||||
///
|
||||
/// Fetch information about the currently running maintenance action.
|
||||
Future<MaintenanceStatusResponseDto?> getMaintenanceStatus() async {
|
||||
final response = await getMaintenanceStatusWithHttpInfo();
|
||||
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), 'MaintenanceStatusResponseDto',) as MaintenanceStatusResponseDto;
|
||||
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Log into maintenance mode
|
||||
///
|
||||
/// Login with maintenance token or cookie to receive current information and perform further actions.
|
||||
|
||||
14
mobile/openapi/lib/api_client.dart
generated
14
mobile/openapi/lib/api_client.dart
generated
@@ -350,6 +350,12 @@ class ApiClient {
|
||||
return CropParameters.fromJson(value);
|
||||
case 'DatabaseBackupConfig':
|
||||
return DatabaseBackupConfig.fromJson(value);
|
||||
case 'DatabaseBackupDeleteDto':
|
||||
return DatabaseBackupDeleteDto.fromJson(value);
|
||||
case 'DatabaseBackupDto':
|
||||
return DatabaseBackupDto.fromJson(value);
|
||||
case 'DatabaseBackupListResponseDto':
|
||||
return DatabaseBackupListResponseDto.fromJson(value);
|
||||
case 'DownloadArchiveInfo':
|
||||
return DownloadArchiveInfo.fromJson(value);
|
||||
case 'DownloadInfoDto':
|
||||
@@ -408,8 +414,14 @@ class ApiClient {
|
||||
return MaintenanceActionTypeTransformer().decode(value);
|
||||
case 'MaintenanceAuthDto':
|
||||
return MaintenanceAuthDto.fromJson(value);
|
||||
case 'MaintenanceDetectInstallResponseDto':
|
||||
return MaintenanceDetectInstallResponseDto.fromJson(value);
|
||||
case 'MaintenanceDetectInstallStorageFolderDto':
|
||||
return MaintenanceDetectInstallStorageFolderDto.fromJson(value);
|
||||
case 'MaintenanceLoginDto':
|
||||
return MaintenanceLoginDto.fromJson(value);
|
||||
case 'MaintenanceStatusResponseDto':
|
||||
return MaintenanceStatusResponseDto.fromJson(value);
|
||||
case 'ManualJobName':
|
||||
return ManualJobNameTypeTransformer().decode(value);
|
||||
case 'MapMarkerResponseDto':
|
||||
@@ -640,6 +652,8 @@ class ApiClient {
|
||||
return StackUpdateDto.fromJson(value);
|
||||
case 'StatisticsSearchDto':
|
||||
return StatisticsSearchDto.fromJson(value);
|
||||
case 'StorageFolder':
|
||||
return StorageFolderTypeTransformer().decode(value);
|
||||
case 'SyncAckDeleteDto':
|
||||
return SyncAckDeleteDto.fromJson(value);
|
||||
case 'SyncAckDto':
|
||||
|
||||
3
mobile/openapi/lib/api_helper.dart
generated
3
mobile/openapi/lib/api_helper.dart
generated
@@ -160,6 +160,9 @@ String parameterToString(dynamic value) {
|
||||
if (value is SourceType) {
|
||||
return SourceTypeTypeTransformer().encode(value).toString();
|
||||
}
|
||||
if (value is StorageFolder) {
|
||||
return StorageFolderTypeTransformer().encode(value).toString();
|
||||
}
|
||||
if (value is SyncEntityType) {
|
||||
return SyncEntityTypeTypeTransformer().encode(value).toString();
|
||||
}
|
||||
|
||||
101
mobile/openapi/lib/model/database_backup_delete_dto.dart
generated
Normal file
101
mobile/openapi/lib/model/database_backup_delete_dto.dart
generated
Normal file
@@ -0,0 +1,101 @@
|
||||
//
|
||||
// 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 DatabaseBackupDeleteDto {
|
||||
/// Returns a new [DatabaseBackupDeleteDto] instance.
|
||||
DatabaseBackupDeleteDto({
|
||||
this.backups = const [],
|
||||
});
|
||||
|
||||
List<String> backups;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) => identical(this, other) || other is DatabaseBackupDeleteDto &&
|
||||
_deepEquality.equals(other.backups, backups);
|
||||
|
||||
@override
|
||||
int get hashCode =>
|
||||
// ignore: unnecessary_parenthesis
|
||||
(backups.hashCode);
|
||||
|
||||
@override
|
||||
String toString() => 'DatabaseBackupDeleteDto[backups=$backups]';
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
final json = <String, dynamic>{};
|
||||
json[r'backups'] = this.backups;
|
||||
return json;
|
||||
}
|
||||
|
||||
/// Returns a new [DatabaseBackupDeleteDto] instance and imports its values from
|
||||
/// [value] if it's a [Map], null otherwise.
|
||||
// ignore: prefer_constructors_over_static_methods
|
||||
static DatabaseBackupDeleteDto? fromJson(dynamic value) {
|
||||
upgradeDto(value, "DatabaseBackupDeleteDto");
|
||||
if (value is Map) {
|
||||
final json = value.cast<String, dynamic>();
|
||||
|
||||
return DatabaseBackupDeleteDto(
|
||||
backups: json[r'backups'] is Iterable
|
||||
? (json[r'backups'] as Iterable).cast<String>().toList(growable: false)
|
||||
: const [],
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static List<DatabaseBackupDeleteDto> listFromJson(dynamic json, {bool growable = false,}) {
|
||||
final result = <DatabaseBackupDeleteDto>[];
|
||||
if (json is List && json.isNotEmpty) {
|
||||
for (final row in json) {
|
||||
final value = DatabaseBackupDeleteDto.fromJson(row);
|
||||
if (value != null) {
|
||||
result.add(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
return result.toList(growable: growable);
|
||||
}
|
||||
|
||||
static Map<String, DatabaseBackupDeleteDto> mapFromJson(dynamic json) {
|
||||
final map = <String, DatabaseBackupDeleteDto>{};
|
||||
if (json is Map && json.isNotEmpty) {
|
||||
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
|
||||
for (final entry in json.entries) {
|
||||
final value = DatabaseBackupDeleteDto.fromJson(entry.value);
|
||||
if (value != null) {
|
||||
map[entry.key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
// maps a json object with a list of DatabaseBackupDeleteDto-objects as value to a dart map
|
||||
static Map<String, List<DatabaseBackupDeleteDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
|
||||
final map = <String, List<DatabaseBackupDeleteDto>>{};
|
||||
if (json is Map && json.isNotEmpty) {
|
||||
// ignore: parameter_assignments
|
||||
json = json.cast<String, dynamic>();
|
||||
for (final entry in json.entries) {
|
||||
map[entry.key] = DatabaseBackupDeleteDto.listFromJson(entry.value, growable: growable,);
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
/// The list of required keys that must be present in a JSON.
|
||||
static const requiredKeys = <String>{
|
||||
'backups',
|
||||
};
|
||||
}
|
||||
|
||||
107
mobile/openapi/lib/model/database_backup_dto.dart
generated
Normal file
107
mobile/openapi/lib/model/database_backup_dto.dart
generated
Normal file
@@ -0,0 +1,107 @@
|
||||
//
|
||||
// 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 DatabaseBackupDto {
|
||||
/// Returns a new [DatabaseBackupDto] instance.
|
||||
DatabaseBackupDto({
|
||||
required this.filename,
|
||||
required this.filesize,
|
||||
});
|
||||
|
||||
String filename;
|
||||
|
||||
num filesize;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) => identical(this, other) || other is DatabaseBackupDto &&
|
||||
other.filename == filename &&
|
||||
other.filesize == filesize;
|
||||
|
||||
@override
|
||||
int get hashCode =>
|
||||
// ignore: unnecessary_parenthesis
|
||||
(filename.hashCode) +
|
||||
(filesize.hashCode);
|
||||
|
||||
@override
|
||||
String toString() => 'DatabaseBackupDto[filename=$filename, filesize=$filesize]';
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
final json = <String, dynamic>{};
|
||||
json[r'filename'] = this.filename;
|
||||
json[r'filesize'] = this.filesize;
|
||||
return json;
|
||||
}
|
||||
|
||||
/// Returns a new [DatabaseBackupDto] instance and imports its values from
|
||||
/// [value] if it's a [Map], null otherwise.
|
||||
// ignore: prefer_constructors_over_static_methods
|
||||
static DatabaseBackupDto? fromJson(dynamic value) {
|
||||
upgradeDto(value, "DatabaseBackupDto");
|
||||
if (value is Map) {
|
||||
final json = value.cast<String, dynamic>();
|
||||
|
||||
return DatabaseBackupDto(
|
||||
filename: mapValueOfType<String>(json, r'filename')!,
|
||||
filesize: num.parse('${json[r'filesize']}'),
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static List<DatabaseBackupDto> listFromJson(dynamic json, {bool growable = false,}) {
|
||||
final result = <DatabaseBackupDto>[];
|
||||
if (json is List && json.isNotEmpty) {
|
||||
for (final row in json) {
|
||||
final value = DatabaseBackupDto.fromJson(row);
|
||||
if (value != null) {
|
||||
result.add(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
return result.toList(growable: growable);
|
||||
}
|
||||
|
||||
static Map<String, DatabaseBackupDto> mapFromJson(dynamic json) {
|
||||
final map = <String, DatabaseBackupDto>{};
|
||||
if (json is Map && json.isNotEmpty) {
|
||||
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
|
||||
for (final entry in json.entries) {
|
||||
final value = DatabaseBackupDto.fromJson(entry.value);
|
||||
if (value != null) {
|
||||
map[entry.key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
// maps a json object with a list of DatabaseBackupDto-objects as value to a dart map
|
||||
static Map<String, List<DatabaseBackupDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
|
||||
final map = <String, List<DatabaseBackupDto>>{};
|
||||
if (json is Map && json.isNotEmpty) {
|
||||
// ignore: parameter_assignments
|
||||
json = json.cast<String, dynamic>();
|
||||
for (final entry in json.entries) {
|
||||
map[entry.key] = DatabaseBackupDto.listFromJson(entry.value, growable: growable,);
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
/// The list of required keys that must be present in a JSON.
|
||||
static const requiredKeys = <String>{
|
||||
'filename',
|
||||
'filesize',
|
||||
};
|
||||
}
|
||||
|
||||
99
mobile/openapi/lib/model/database_backup_list_response_dto.dart
generated
Normal file
99
mobile/openapi/lib/model/database_backup_list_response_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 DatabaseBackupListResponseDto {
|
||||
/// Returns a new [DatabaseBackupListResponseDto] instance.
|
||||
DatabaseBackupListResponseDto({
|
||||
this.backups = const [],
|
||||
});
|
||||
|
||||
List<DatabaseBackupDto> backups;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) => identical(this, other) || other is DatabaseBackupListResponseDto &&
|
||||
_deepEquality.equals(other.backups, backups);
|
||||
|
||||
@override
|
||||
int get hashCode =>
|
||||
// ignore: unnecessary_parenthesis
|
||||
(backups.hashCode);
|
||||
|
||||
@override
|
||||
String toString() => 'DatabaseBackupListResponseDto[backups=$backups]';
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
final json = <String, dynamic>{};
|
||||
json[r'backups'] = this.backups;
|
||||
return json;
|
||||
}
|
||||
|
||||
/// Returns a new [DatabaseBackupListResponseDto] instance and imports its values from
|
||||
/// [value] if it's a [Map], null otherwise.
|
||||
// ignore: prefer_constructors_over_static_methods
|
||||
static DatabaseBackupListResponseDto? fromJson(dynamic value) {
|
||||
upgradeDto(value, "DatabaseBackupListResponseDto");
|
||||
if (value is Map) {
|
||||
final json = value.cast<String, dynamic>();
|
||||
|
||||
return DatabaseBackupListResponseDto(
|
||||
backups: DatabaseBackupDto.listFromJson(json[r'backups']),
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static List<DatabaseBackupListResponseDto> listFromJson(dynamic json, {bool growable = false,}) {
|
||||
final result = <DatabaseBackupListResponseDto>[];
|
||||
if (json is List && json.isNotEmpty) {
|
||||
for (final row in json) {
|
||||
final value = DatabaseBackupListResponseDto.fromJson(row);
|
||||
if (value != null) {
|
||||
result.add(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
return result.toList(growable: growable);
|
||||
}
|
||||
|
||||
static Map<String, DatabaseBackupListResponseDto> mapFromJson(dynamic json) {
|
||||
final map = <String, DatabaseBackupListResponseDto>{};
|
||||
if (json is Map && json.isNotEmpty) {
|
||||
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
|
||||
for (final entry in json.entries) {
|
||||
final value = DatabaseBackupListResponseDto.fromJson(entry.value);
|
||||
if (value != null) {
|
||||
map[entry.key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
// maps a json object with a list of DatabaseBackupListResponseDto-objects as value to a dart map
|
||||
static Map<String, List<DatabaseBackupListResponseDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
|
||||
final map = <String, List<DatabaseBackupListResponseDto>>{};
|
||||
if (json is Map && json.isNotEmpty) {
|
||||
// ignore: parameter_assignments
|
||||
json = json.cast<String, dynamic>();
|
||||
for (final entry in json.entries) {
|
||||
map[entry.key] = DatabaseBackupListResponseDto.listFromJson(entry.value, growable: growable,);
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
/// The list of required keys that must be present in a JSON.
|
||||
static const requiredKeys = <String>{
|
||||
'backups',
|
||||
};
|
||||
}
|
||||
|
||||
6
mobile/openapi/lib/model/maintenance_action.dart
generated
6
mobile/openapi/lib/model/maintenance_action.dart
generated
@@ -25,11 +25,15 @@ class MaintenanceAction {
|
||||
|
||||
static const start = MaintenanceAction._(r'start');
|
||||
static const end = MaintenanceAction._(r'end');
|
||||
static const selectDatabaseRestore = MaintenanceAction._(r'select_database_restore');
|
||||
static const restoreDatabase = MaintenanceAction._(r'restore_database');
|
||||
|
||||
/// List of all possible values in this [enum][MaintenanceAction].
|
||||
static const values = <MaintenanceAction>[
|
||||
start,
|
||||
end,
|
||||
selectDatabaseRestore,
|
||||
restoreDatabase,
|
||||
];
|
||||
|
||||
static MaintenanceAction? fromJson(dynamic value) => MaintenanceActionTypeTransformer().decode(value);
|
||||
@@ -70,6 +74,8 @@ class MaintenanceActionTypeTransformer {
|
||||
switch (data) {
|
||||
case r'start': return MaintenanceAction.start;
|
||||
case r'end': return MaintenanceAction.end;
|
||||
case r'select_database_restore': return MaintenanceAction.selectDatabaseRestore;
|
||||
case r'restore_database': return MaintenanceAction.restoreDatabase;
|
||||
default:
|
||||
if (!allowNull) {
|
||||
throw ArgumentError('Unknown enum value to decode: $data');
|
||||
|
||||
99
mobile/openapi/lib/model/maintenance_detect_install_response_dto.dart
generated
Normal file
99
mobile/openapi/lib/model/maintenance_detect_install_response_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 MaintenanceDetectInstallResponseDto {
|
||||
/// Returns a new [MaintenanceDetectInstallResponseDto] instance.
|
||||
MaintenanceDetectInstallResponseDto({
|
||||
this.storage = const [],
|
||||
});
|
||||
|
||||
List<MaintenanceDetectInstallStorageFolderDto> storage;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) => identical(this, other) || other is MaintenanceDetectInstallResponseDto &&
|
||||
_deepEquality.equals(other.storage, storage);
|
||||
|
||||
@override
|
||||
int get hashCode =>
|
||||
// ignore: unnecessary_parenthesis
|
||||
(storage.hashCode);
|
||||
|
||||
@override
|
||||
String toString() => 'MaintenanceDetectInstallResponseDto[storage=$storage]';
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
final json = <String, dynamic>{};
|
||||
json[r'storage'] = this.storage;
|
||||
return json;
|
||||
}
|
||||
|
||||
/// Returns a new [MaintenanceDetectInstallResponseDto] instance and imports its values from
|
||||
/// [value] if it's a [Map], null otherwise.
|
||||
// ignore: prefer_constructors_over_static_methods
|
||||
static MaintenanceDetectInstallResponseDto? fromJson(dynamic value) {
|
||||
upgradeDto(value, "MaintenanceDetectInstallResponseDto");
|
||||
if (value is Map) {
|
||||
final json = value.cast<String, dynamic>();
|
||||
|
||||
return MaintenanceDetectInstallResponseDto(
|
||||
storage: MaintenanceDetectInstallStorageFolderDto.listFromJson(json[r'storage']),
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static List<MaintenanceDetectInstallResponseDto> listFromJson(dynamic json, {bool growable = false,}) {
|
||||
final result = <MaintenanceDetectInstallResponseDto>[];
|
||||
if (json is List && json.isNotEmpty) {
|
||||
for (final row in json) {
|
||||
final value = MaintenanceDetectInstallResponseDto.fromJson(row);
|
||||
if (value != null) {
|
||||
result.add(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
return result.toList(growable: growable);
|
||||
}
|
||||
|
||||
static Map<String, MaintenanceDetectInstallResponseDto> mapFromJson(dynamic json) {
|
||||
final map = <String, MaintenanceDetectInstallResponseDto>{};
|
||||
if (json is Map && json.isNotEmpty) {
|
||||
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
|
||||
for (final entry in json.entries) {
|
||||
final value = MaintenanceDetectInstallResponseDto.fromJson(entry.value);
|
||||
if (value != null) {
|
||||
map[entry.key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
// maps a json object with a list of MaintenanceDetectInstallResponseDto-objects as value to a dart map
|
||||
static Map<String, List<MaintenanceDetectInstallResponseDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
|
||||
final map = <String, List<MaintenanceDetectInstallResponseDto>>{};
|
||||
if (json is Map && json.isNotEmpty) {
|
||||
// ignore: parameter_assignments
|
||||
json = json.cast<String, dynamic>();
|
||||
for (final entry in json.entries) {
|
||||
map[entry.key] = MaintenanceDetectInstallResponseDto.listFromJson(entry.value, growable: growable,);
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
/// The list of required keys that must be present in a JSON.
|
||||
static const requiredKeys = <String>{
|
||||
'storage',
|
||||
};
|
||||
}
|
||||
|
||||
123
mobile/openapi/lib/model/maintenance_detect_install_storage_folder_dto.dart
generated
Normal file
123
mobile/openapi/lib/model/maintenance_detect_install_storage_folder_dto.dart
generated
Normal file
@@ -0,0 +1,123 @@
|
||||
//
|
||||
// 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 MaintenanceDetectInstallStorageFolderDto {
|
||||
/// Returns a new [MaintenanceDetectInstallStorageFolderDto] instance.
|
||||
MaintenanceDetectInstallStorageFolderDto({
|
||||
required this.files,
|
||||
required this.folder,
|
||||
required this.readable,
|
||||
required this.writable,
|
||||
});
|
||||
|
||||
num files;
|
||||
|
||||
StorageFolder folder;
|
||||
|
||||
bool readable;
|
||||
|
||||
bool writable;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) => identical(this, other) || other is MaintenanceDetectInstallStorageFolderDto &&
|
||||
other.files == files &&
|
||||
other.folder == folder &&
|
||||
other.readable == readable &&
|
||||
other.writable == writable;
|
||||
|
||||
@override
|
||||
int get hashCode =>
|
||||
// ignore: unnecessary_parenthesis
|
||||
(files.hashCode) +
|
||||
(folder.hashCode) +
|
||||
(readable.hashCode) +
|
||||
(writable.hashCode);
|
||||
|
||||
@override
|
||||
String toString() => 'MaintenanceDetectInstallStorageFolderDto[files=$files, folder=$folder, readable=$readable, writable=$writable]';
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
final json = <String, dynamic>{};
|
||||
json[r'files'] = this.files;
|
||||
json[r'folder'] = this.folder;
|
||||
json[r'readable'] = this.readable;
|
||||
json[r'writable'] = this.writable;
|
||||
return json;
|
||||
}
|
||||
|
||||
/// Returns a new [MaintenanceDetectInstallStorageFolderDto] instance and imports its values from
|
||||
/// [value] if it's a [Map], null otherwise.
|
||||
// ignore: prefer_constructors_over_static_methods
|
||||
static MaintenanceDetectInstallStorageFolderDto? fromJson(dynamic value) {
|
||||
upgradeDto(value, "MaintenanceDetectInstallStorageFolderDto");
|
||||
if (value is Map) {
|
||||
final json = value.cast<String, dynamic>();
|
||||
|
||||
return MaintenanceDetectInstallStorageFolderDto(
|
||||
files: num.parse('${json[r'files']}'),
|
||||
folder: StorageFolder.fromJson(json[r'folder'])!,
|
||||
readable: mapValueOfType<bool>(json, r'readable')!,
|
||||
writable: mapValueOfType<bool>(json, r'writable')!,
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static List<MaintenanceDetectInstallStorageFolderDto> listFromJson(dynamic json, {bool growable = false,}) {
|
||||
final result = <MaintenanceDetectInstallStorageFolderDto>[];
|
||||
if (json is List && json.isNotEmpty) {
|
||||
for (final row in json) {
|
||||
final value = MaintenanceDetectInstallStorageFolderDto.fromJson(row);
|
||||
if (value != null) {
|
||||
result.add(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
return result.toList(growable: growable);
|
||||
}
|
||||
|
||||
static Map<String, MaintenanceDetectInstallStorageFolderDto> mapFromJson(dynamic json) {
|
||||
final map = <String, MaintenanceDetectInstallStorageFolderDto>{};
|
||||
if (json is Map && json.isNotEmpty) {
|
||||
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
|
||||
for (final entry in json.entries) {
|
||||
final value = MaintenanceDetectInstallStorageFolderDto.fromJson(entry.value);
|
||||
if (value != null) {
|
||||
map[entry.key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
// maps a json object with a list of MaintenanceDetectInstallStorageFolderDto-objects as value to a dart map
|
||||
static Map<String, List<MaintenanceDetectInstallStorageFolderDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
|
||||
final map = <String, List<MaintenanceDetectInstallStorageFolderDto>>{};
|
||||
if (json is Map && json.isNotEmpty) {
|
||||
// ignore: parameter_assignments
|
||||
json = json.cast<String, dynamic>();
|
||||
for (final entry in json.entries) {
|
||||
map[entry.key] = MaintenanceDetectInstallStorageFolderDto.listFromJson(entry.value, growable: growable,);
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
/// The list of required keys that must be present in a JSON.
|
||||
static const requiredKeys = <String>{
|
||||
'files',
|
||||
'folder',
|
||||
'readable',
|
||||
'writable',
|
||||
};
|
||||
}
|
||||
|
||||
158
mobile/openapi/lib/model/maintenance_status_response_dto.dart
generated
Normal file
158
mobile/openapi/lib/model/maintenance_status_response_dto.dart
generated
Normal file
@@ -0,0 +1,158 @@
|
||||
//
|
||||
// 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 MaintenanceStatusResponseDto {
|
||||
/// Returns a new [MaintenanceStatusResponseDto] instance.
|
||||
MaintenanceStatusResponseDto({
|
||||
required this.action,
|
||||
required this.active,
|
||||
this.error,
|
||||
this.progress,
|
||||
this.task,
|
||||
});
|
||||
|
||||
MaintenanceAction action;
|
||||
|
||||
bool active;
|
||||
|
||||
///
|
||||
/// 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? error;
|
||||
|
||||
///
|
||||
/// 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.
|
||||
///
|
||||
num? progress;
|
||||
|
||||
///
|
||||
/// 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? task;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) => identical(this, other) || other is MaintenanceStatusResponseDto &&
|
||||
other.action == action &&
|
||||
other.active == active &&
|
||||
other.error == error &&
|
||||
other.progress == progress &&
|
||||
other.task == task;
|
||||
|
||||
@override
|
||||
int get hashCode =>
|
||||
// ignore: unnecessary_parenthesis
|
||||
(action.hashCode) +
|
||||
(active.hashCode) +
|
||||
(error == null ? 0 : error!.hashCode) +
|
||||
(progress == null ? 0 : progress!.hashCode) +
|
||||
(task == null ? 0 : task!.hashCode);
|
||||
|
||||
@override
|
||||
String toString() => 'MaintenanceStatusResponseDto[action=$action, active=$active, error=$error, progress=$progress, task=$task]';
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
final json = <String, dynamic>{};
|
||||
json[r'action'] = this.action;
|
||||
json[r'active'] = this.active;
|
||||
if (this.error != null) {
|
||||
json[r'error'] = this.error;
|
||||
} else {
|
||||
// json[r'error'] = null;
|
||||
}
|
||||
if (this.progress != null) {
|
||||
json[r'progress'] = this.progress;
|
||||
} else {
|
||||
// json[r'progress'] = null;
|
||||
}
|
||||
if (this.task != null) {
|
||||
json[r'task'] = this.task;
|
||||
} else {
|
||||
// json[r'task'] = null;
|
||||
}
|
||||
return json;
|
||||
}
|
||||
|
||||
/// Returns a new [MaintenanceStatusResponseDto] instance and imports its values from
|
||||
/// [value] if it's a [Map], null otherwise.
|
||||
// ignore: prefer_constructors_over_static_methods
|
||||
static MaintenanceStatusResponseDto? fromJson(dynamic value) {
|
||||
upgradeDto(value, "MaintenanceStatusResponseDto");
|
||||
if (value is Map) {
|
||||
final json = value.cast<String, dynamic>();
|
||||
|
||||
return MaintenanceStatusResponseDto(
|
||||
action: MaintenanceAction.fromJson(json[r'action'])!,
|
||||
active: mapValueOfType<bool>(json, r'active')!,
|
||||
error: mapValueOfType<String>(json, r'error'),
|
||||
progress: num.parse('${json[r'progress']}'),
|
||||
task: mapValueOfType<String>(json, r'task'),
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static List<MaintenanceStatusResponseDto> listFromJson(dynamic json, {bool growable = false,}) {
|
||||
final result = <MaintenanceStatusResponseDto>[];
|
||||
if (json is List && json.isNotEmpty) {
|
||||
for (final row in json) {
|
||||
final value = MaintenanceStatusResponseDto.fromJson(row);
|
||||
if (value != null) {
|
||||
result.add(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
return result.toList(growable: growable);
|
||||
}
|
||||
|
||||
static Map<String, MaintenanceStatusResponseDto> mapFromJson(dynamic json) {
|
||||
final map = <String, MaintenanceStatusResponseDto>{};
|
||||
if (json is Map && json.isNotEmpty) {
|
||||
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
|
||||
for (final entry in json.entries) {
|
||||
final value = MaintenanceStatusResponseDto.fromJson(entry.value);
|
||||
if (value != null) {
|
||||
map[entry.key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
// maps a json object with a list of MaintenanceStatusResponseDto-objects as value to a dart map
|
||||
static Map<String, List<MaintenanceStatusResponseDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
|
||||
final map = <String, List<MaintenanceStatusResponseDto>>{};
|
||||
if (json is Map && json.isNotEmpty) {
|
||||
// ignore: parameter_assignments
|
||||
json = json.cast<String, dynamic>();
|
||||
for (final entry in json.entries) {
|
||||
map[entry.key] = MaintenanceStatusResponseDto.listFromJson(entry.value, growable: growable,);
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
/// The list of required keys that must be present in a JSON.
|
||||
static const requiredKeys = <String>{
|
||||
'action',
|
||||
'active',
|
||||
};
|
||||
}
|
||||
|
||||
12
mobile/openapi/lib/model/permission.dart
generated
12
mobile/openapi/lib/model/permission.dart
generated
@@ -62,6 +62,10 @@ class Permission {
|
||||
static const authPeriodChangePassword = Permission._(r'auth.changePassword');
|
||||
static const authDevicePeriodDelete = Permission._(r'authDevice.delete');
|
||||
static const archivePeriodRead = Permission._(r'archive.read');
|
||||
static const backupPeriodList = Permission._(r'backup.list');
|
||||
static const backupPeriodDownload = Permission._(r'backup.download');
|
||||
static const backupPeriodUpload = Permission._(r'backup.upload');
|
||||
static const backupPeriodDelete = Permission._(r'backup.delete');
|
||||
static const duplicatePeriodRead = Permission._(r'duplicate.read');
|
||||
static const duplicatePeriodDelete = Permission._(r'duplicate.delete');
|
||||
static const facePeriodCreate = Permission._(r'face.create');
|
||||
@@ -214,6 +218,10 @@ class Permission {
|
||||
authPeriodChangePassword,
|
||||
authDevicePeriodDelete,
|
||||
archivePeriodRead,
|
||||
backupPeriodList,
|
||||
backupPeriodDownload,
|
||||
backupPeriodUpload,
|
||||
backupPeriodDelete,
|
||||
duplicatePeriodRead,
|
||||
duplicatePeriodDelete,
|
||||
facePeriodCreate,
|
||||
@@ -401,6 +409,10 @@ class PermissionTypeTransformer {
|
||||
case r'auth.changePassword': return Permission.authPeriodChangePassword;
|
||||
case r'authDevice.delete': return Permission.authDevicePeriodDelete;
|
||||
case r'archive.read': return Permission.archivePeriodRead;
|
||||
case r'backup.list': return Permission.backupPeriodList;
|
||||
case r'backup.download': return Permission.backupPeriodDownload;
|
||||
case r'backup.upload': return Permission.backupPeriodUpload;
|
||||
case r'backup.delete': return Permission.backupPeriodDelete;
|
||||
case r'duplicate.read': return Permission.duplicatePeriodRead;
|
||||
case r'duplicate.delete': return Permission.duplicatePeriodDelete;
|
||||
case r'face.create': return Permission.facePeriodCreate;
|
||||
|
||||
@@ -14,25 +14,41 @@ class SetMaintenanceModeDto {
|
||||
/// Returns a new [SetMaintenanceModeDto] instance.
|
||||
SetMaintenanceModeDto({
|
||||
required this.action,
|
||||
this.restoreBackupFilename,
|
||||
});
|
||||
|
||||
MaintenanceAction action;
|
||||
|
||||
///
|
||||
/// 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? restoreBackupFilename;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) => identical(this, other) || other is SetMaintenanceModeDto &&
|
||||
other.action == action;
|
||||
other.action == action &&
|
||||
other.restoreBackupFilename == restoreBackupFilename;
|
||||
|
||||
@override
|
||||
int get hashCode =>
|
||||
// ignore: unnecessary_parenthesis
|
||||
(action.hashCode);
|
||||
(action.hashCode) +
|
||||
(restoreBackupFilename == null ? 0 : restoreBackupFilename!.hashCode);
|
||||
|
||||
@override
|
||||
String toString() => 'SetMaintenanceModeDto[action=$action]';
|
||||
String toString() => 'SetMaintenanceModeDto[action=$action, restoreBackupFilename=$restoreBackupFilename]';
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
final json = <String, dynamic>{};
|
||||
json[r'action'] = this.action;
|
||||
if (this.restoreBackupFilename != null) {
|
||||
json[r'restoreBackupFilename'] = this.restoreBackupFilename;
|
||||
} else {
|
||||
// json[r'restoreBackupFilename'] = null;
|
||||
}
|
||||
return json;
|
||||
}
|
||||
|
||||
@@ -46,6 +62,7 @@ class SetMaintenanceModeDto {
|
||||
|
||||
return SetMaintenanceModeDto(
|
||||
action: MaintenanceAction.fromJson(json[r'action'])!,
|
||||
restoreBackupFilename: mapValueOfType<String>(json, r'restoreBackupFilename'),
|
||||
);
|
||||
}
|
||||
return null;
|
||||
|
||||
97
mobile/openapi/lib/model/storage_folder.dart
generated
Normal file
97
mobile/openapi/lib/model/storage_folder.dart
generated
Normal file
@@ -0,0 +1,97 @@
|
||||
//
|
||||
// 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 StorageFolder {
|
||||
/// Instantiate a new enum with the provided [value].
|
||||
const StorageFolder._(this.value);
|
||||
|
||||
/// The underlying value of this enum member.
|
||||
final String value;
|
||||
|
||||
@override
|
||||
String toString() => value;
|
||||
|
||||
String toJson() => value;
|
||||
|
||||
static const encodedVideo = StorageFolder._(r'encoded-video');
|
||||
static const library_ = StorageFolder._(r'library');
|
||||
static const upload = StorageFolder._(r'upload');
|
||||
static const profile = StorageFolder._(r'profile');
|
||||
static const thumbs = StorageFolder._(r'thumbs');
|
||||
static const backups = StorageFolder._(r'backups');
|
||||
|
||||
/// List of all possible values in this [enum][StorageFolder].
|
||||
static const values = <StorageFolder>[
|
||||
encodedVideo,
|
||||
library_,
|
||||
upload,
|
||||
profile,
|
||||
thumbs,
|
||||
backups,
|
||||
];
|
||||
|
||||
static StorageFolder? fromJson(dynamic value) => StorageFolderTypeTransformer().decode(value);
|
||||
|
||||
static List<StorageFolder> listFromJson(dynamic json, {bool growable = false,}) {
|
||||
final result = <StorageFolder>[];
|
||||
if (json is List && json.isNotEmpty) {
|
||||
for (final row in json) {
|
||||
final value = StorageFolder.fromJson(row);
|
||||
if (value != null) {
|
||||
result.add(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
return result.toList(growable: growable);
|
||||
}
|
||||
}
|
||||
|
||||
/// Transformation class that can [encode] an instance of [StorageFolder] to String,
|
||||
/// and [decode] dynamic data back to [StorageFolder].
|
||||
class StorageFolderTypeTransformer {
|
||||
factory StorageFolderTypeTransformer() => _instance ??= const StorageFolderTypeTransformer._();
|
||||
|
||||
const StorageFolderTypeTransformer._();
|
||||
|
||||
String encode(StorageFolder data) => data.value;
|
||||
|
||||
/// Decodes a [dynamic value][data] to a StorageFolder.
|
||||
///
|
||||
/// 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.
|
||||
StorageFolder? decode(dynamic data, {bool allowNull = true}) {
|
||||
if (data != null) {
|
||||
switch (data) {
|
||||
case r'encoded-video': return StorageFolder.encodedVideo;
|
||||
case r'library': return StorageFolder.library_;
|
||||
case r'upload': return StorageFolder.upload;
|
||||
case r'profile': return StorageFolder.profile;
|
||||
case r'thumbs': return StorageFolder.thumbs;
|
||||
case r'backups': return StorageFolder.backups;
|
||||
default:
|
||||
if (!allowNull) {
|
||||
throw ArgumentError('Unknown enum value to decode: $data');
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Singleton [StorageFolderTypeTransformer] instance.
|
||||
static StorageFolderTypeTransformer? _instance;
|
||||
}
|
||||
|
||||
@@ -322,6 +322,237 @@
|
||||
"x-immich-state": "Stable"
|
||||
}
|
||||
},
|
||||
"/admin/database-backups": {
|
||||
"delete": {
|
||||
"description": "Delete a backup by its filename",
|
||||
"operationId": "deleteDatabaseBackup",
|
||||
"parameters": [],
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/DatabaseBackupDeleteDto"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": true
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": ""
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"bearer": []
|
||||
},
|
||||
{
|
||||
"cookie": []
|
||||
},
|
||||
{
|
||||
"api_key": []
|
||||
}
|
||||
],
|
||||
"summary": "Delete database backup",
|
||||
"tags": [
|
||||
"Database Backups (admin)"
|
||||
],
|
||||
"x-immich-admin-only": true,
|
||||
"x-immich-history": [
|
||||
{
|
||||
"version": "v2.5.0",
|
||||
"state": "Added"
|
||||
},
|
||||
{
|
||||
"version": "v2.5.0",
|
||||
"state": "Alpha"
|
||||
}
|
||||
],
|
||||
"x-immich-permission": "backup.delete",
|
||||
"x-immich-state": "Alpha"
|
||||
},
|
||||
"get": {
|
||||
"description": "Get the list of the successful and failed backups",
|
||||
"operationId": "listDatabaseBackups",
|
||||
"parameters": [],
|
||||
"responses": {
|
||||
"200": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/DatabaseBackupListResponseDto"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": ""
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"bearer": []
|
||||
},
|
||||
{
|
||||
"cookie": []
|
||||
},
|
||||
{
|
||||
"api_key": []
|
||||
}
|
||||
],
|
||||
"summary": "List database backups",
|
||||
"tags": [
|
||||
"Database Backups (admin)"
|
||||
],
|
||||
"x-immich-admin-only": true,
|
||||
"x-immich-history": [
|
||||
{
|
||||
"version": "v2.5.0",
|
||||
"state": "Added"
|
||||
},
|
||||
{
|
||||
"version": "v2.5.0",
|
||||
"state": "Alpha"
|
||||
}
|
||||
],
|
||||
"x-immich-permission": "maintenance",
|
||||
"x-immich-state": "Alpha"
|
||||
}
|
||||
},
|
||||
"/admin/database-backups/start-restore": {
|
||||
"post": {
|
||||
"description": "Put Immich into maintenance mode to restore a backup (Immich must not be configured)",
|
||||
"operationId": "startDatabaseRestoreFlow",
|
||||
"parameters": [],
|
||||
"responses": {
|
||||
"201": {
|
||||
"description": ""
|
||||
}
|
||||
},
|
||||
"summary": "Start database backup restore flow",
|
||||
"tags": [
|
||||
"Database Backups (admin)"
|
||||
],
|
||||
"x-immich-history": [
|
||||
{
|
||||
"version": "v2.5.0",
|
||||
"state": "Added"
|
||||
},
|
||||
{
|
||||
"version": "v2.5.0",
|
||||
"state": "Alpha"
|
||||
}
|
||||
],
|
||||
"x-immich-state": "Alpha"
|
||||
}
|
||||
},
|
||||
"/admin/database-backups/upload": {
|
||||
"post": {
|
||||
"description": "Uploads .sql/.sql.gz file to restore backup from",
|
||||
"operationId": "uploadDatabaseBackup",
|
||||
"parameters": [],
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"multipart/form-data": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/DatabaseBackupUploadDto"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Backup Upload",
|
||||
"required": true
|
||||
},
|
||||
"responses": {
|
||||
"201": {
|
||||
"description": ""
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"bearer": []
|
||||
},
|
||||
{
|
||||
"cookie": []
|
||||
},
|
||||
{
|
||||
"api_key": []
|
||||
}
|
||||
],
|
||||
"summary": "Upload database backup",
|
||||
"tags": [
|
||||
"Database Backups (admin)"
|
||||
],
|
||||
"x-immich-admin-only": true,
|
||||
"x-immich-history": [
|
||||
{
|
||||
"version": "v2.5.0",
|
||||
"state": "Added"
|
||||
},
|
||||
{
|
||||
"version": "v2.5.0",
|
||||
"state": "Alpha"
|
||||
}
|
||||
],
|
||||
"x-immich-permission": "backup.upload",
|
||||
"x-immich-state": "Alpha"
|
||||
}
|
||||
},
|
||||
"/admin/database-backups/{filename}": {
|
||||
"get": {
|
||||
"description": "Downloads the database backup file",
|
||||
"operationId": "downloadDatabaseBackup",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "filename",
|
||||
"required": true,
|
||||
"in": "path",
|
||||
"schema": {
|
||||
"format": "string",
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"content": {
|
||||
"application/octet-stream": {
|
||||
"schema": {
|
||||
"format": "binary",
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": ""
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"bearer": []
|
||||
},
|
||||
{
|
||||
"cookie": []
|
||||
},
|
||||
{
|
||||
"api_key": []
|
||||
}
|
||||
],
|
||||
"summary": "Download database backup",
|
||||
"tags": [
|
||||
"Database Backups (admin)"
|
||||
],
|
||||
"x-immich-admin-only": true,
|
||||
"x-immich-history": [
|
||||
{
|
||||
"version": "v2.5.0",
|
||||
"state": "Added"
|
||||
},
|
||||
{
|
||||
"version": "v2.5.0",
|
||||
"state": "Alpha"
|
||||
}
|
||||
],
|
||||
"x-immich-permission": "backup.download",
|
||||
"x-immich-state": "Alpha"
|
||||
}
|
||||
},
|
||||
"/admin/maintenance": {
|
||||
"post": {
|
||||
"description": "Put Immich into or take it out of maintenance mode",
|
||||
@@ -372,6 +603,53 @@
|
||||
"x-immich-state": "Alpha"
|
||||
}
|
||||
},
|
||||
"/admin/maintenance/detect-install": {
|
||||
"get": {
|
||||
"description": "Collect integrity checks and other heuristics about local data.",
|
||||
"operationId": "detectPriorInstall",
|
||||
"parameters": [],
|
||||
"responses": {
|
||||
"200": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/MaintenanceDetectInstallResponseDto"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": ""
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"bearer": []
|
||||
},
|
||||
{
|
||||
"cookie": []
|
||||
},
|
||||
{
|
||||
"api_key": []
|
||||
}
|
||||
],
|
||||
"summary": "Detect existing install",
|
||||
"tags": [
|
||||
"Maintenance (admin)"
|
||||
],
|
||||
"x-immich-admin-only": true,
|
||||
"x-immich-history": [
|
||||
{
|
||||
"version": "v2.5.0",
|
||||
"state": "Added"
|
||||
},
|
||||
{
|
||||
"version": "v2.5.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.",
|
||||
@@ -416,6 +694,40 @@
|
||||
"x-immich-state": "Alpha"
|
||||
}
|
||||
},
|
||||
"/admin/maintenance/status": {
|
||||
"get": {
|
||||
"description": "Fetch information about the currently running maintenance action.",
|
||||
"operationId": "getMaintenanceStatus",
|
||||
"parameters": [],
|
||||
"responses": {
|
||||
"200": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/MaintenanceStatusResponseDto"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": ""
|
||||
}
|
||||
},
|
||||
"summary": "Get maintenance mode status",
|
||||
"tags": [
|
||||
"Maintenance (admin)"
|
||||
],
|
||||
"x-immich-history": [
|
||||
{
|
||||
"version": "v2.5.0",
|
||||
"state": "Added"
|
||||
},
|
||||
{
|
||||
"version": "v2.5.0",
|
||||
"state": "Alpha"
|
||||
}
|
||||
],
|
||||
"x-immich-state": "Alpha"
|
||||
}
|
||||
},
|
||||
"/admin/notifications": {
|
||||
"post": {
|
||||
"description": "Create a new notification for a specific user.",
|
||||
@@ -14661,6 +14973,10 @@
|
||||
"name": "Authentication (admin)",
|
||||
"description": "Administrative endpoints related to authentication."
|
||||
},
|
||||
{
|
||||
"name": "Database Backups (admin)",
|
||||
"description": "Manage backups of the Immich database."
|
||||
},
|
||||
{
|
||||
"name": "Deprecated",
|
||||
"description": "Deprecated endpoints that are planned for removal in the next major release."
|
||||
@@ -16849,6 +17165,58 @@
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"DatabaseBackupDeleteDto": {
|
||||
"properties": {
|
||||
"backups": {
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"backups"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"DatabaseBackupDto": {
|
||||
"properties": {
|
||||
"filename": {
|
||||
"type": "string"
|
||||
},
|
||||
"filesize": {
|
||||
"type": "number"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"filename",
|
||||
"filesize"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"DatabaseBackupListResponseDto": {
|
||||
"properties": {
|
||||
"backups": {
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/DatabaseBackupDto"
|
||||
},
|
||||
"type": "array"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"backups"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"DatabaseBackupUploadDto": {
|
||||
"properties": {
|
||||
"file": {
|
||||
"format": "binary",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"DownloadArchiveInfo": {
|
||||
"properties": {
|
||||
"assetIds": {
|
||||
@@ -17516,7 +17884,9 @@
|
||||
"MaintenanceAction": {
|
||||
"enum": [
|
||||
"start",
|
||||
"end"
|
||||
"end",
|
||||
"select_database_restore",
|
||||
"restore_database"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
@@ -17531,6 +17901,47 @@
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"MaintenanceDetectInstallResponseDto": {
|
||||
"properties": {
|
||||
"storage": {
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/MaintenanceDetectInstallStorageFolderDto"
|
||||
},
|
||||
"type": "array"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"storage"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"MaintenanceDetectInstallStorageFolderDto": {
|
||||
"properties": {
|
||||
"files": {
|
||||
"type": "number"
|
||||
},
|
||||
"folder": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/StorageFolder"
|
||||
}
|
||||
]
|
||||
},
|
||||
"readable": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"writable": {
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"files",
|
||||
"folder",
|
||||
"readable",
|
||||
"writable"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"MaintenanceLoginDto": {
|
||||
"properties": {
|
||||
"token": {
|
||||
@@ -17539,6 +17950,34 @@
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"MaintenanceStatusResponseDto": {
|
||||
"properties": {
|
||||
"action": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/MaintenanceAction"
|
||||
}
|
||||
]
|
||||
},
|
||||
"active": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"error": {
|
||||
"type": "string"
|
||||
},
|
||||
"progress": {
|
||||
"type": "number"
|
||||
},
|
||||
"task": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"action",
|
||||
"active"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"ManualJobName": {
|
||||
"enum": [
|
||||
"person-cleanup",
|
||||
@@ -18507,6 +18946,10 @@
|
||||
"auth.changePassword",
|
||||
"authDevice.delete",
|
||||
"archive.read",
|
||||
"backup.list",
|
||||
"backup.download",
|
||||
"backup.upload",
|
||||
"backup.delete",
|
||||
"duplicate.read",
|
||||
"duplicate.delete",
|
||||
"face.create",
|
||||
@@ -20285,6 +20728,9 @@
|
||||
"$ref": "#/components/schemas/MaintenanceAction"
|
||||
}
|
||||
]
|
||||
},
|
||||
"restoreBackupFilename": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
@@ -20857,6 +21303,17 @@
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"StorageFolder": {
|
||||
"enum": [
|
||||
"encoded-video",
|
||||
"library",
|
||||
"upload",
|
||||
"profile",
|
||||
"thumbs",
|
||||
"backups"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"SyncAckDeleteDto": {
|
||||
"properties": {
|
||||
"types": {
|
||||
|
||||
@@ -40,8 +40,31 @@ export type ActivityStatisticsResponseDto = {
|
||||
comments: number;
|
||||
likes: number;
|
||||
};
|
||||
export type DatabaseBackupDeleteDto = {
|
||||
backups: string[];
|
||||
};
|
||||
export type DatabaseBackupDto = {
|
||||
filename: string;
|
||||
filesize: number;
|
||||
};
|
||||
export type DatabaseBackupListResponseDto = {
|
||||
backups: DatabaseBackupDto[];
|
||||
};
|
||||
export type DatabaseBackupUploadDto = {
|
||||
file?: Blob;
|
||||
};
|
||||
export type SetMaintenanceModeDto = {
|
||||
action: MaintenanceAction;
|
||||
restoreBackupFilename?: string;
|
||||
};
|
||||
export type MaintenanceDetectInstallStorageFolderDto = {
|
||||
files: number;
|
||||
folder: StorageFolder;
|
||||
readable: boolean;
|
||||
writable: boolean;
|
||||
};
|
||||
export type MaintenanceDetectInstallResponseDto = {
|
||||
storage: MaintenanceDetectInstallStorageFolderDto[];
|
||||
};
|
||||
export type MaintenanceLoginDto = {
|
||||
token?: string;
|
||||
@@ -49,6 +72,13 @@ export type MaintenanceLoginDto = {
|
||||
export type MaintenanceAuthDto = {
|
||||
username: string;
|
||||
};
|
||||
export type MaintenanceStatusResponseDto = {
|
||||
action: MaintenanceAction;
|
||||
active: boolean;
|
||||
error?: string;
|
||||
progress?: number;
|
||||
task?: string;
|
||||
};
|
||||
export type NotificationCreateDto = {
|
||||
data?: object;
|
||||
description?: string | null;
|
||||
@@ -1920,6 +1950,63 @@ export function unlinkAllOAuthAccountsAdmin(opts?: Oazapfts.RequestOpts) {
|
||||
method: "POST"
|
||||
}));
|
||||
}
|
||||
/**
|
||||
* Delete database backup
|
||||
*/
|
||||
export function deleteDatabaseBackup({ databaseBackupDeleteDto }: {
|
||||
databaseBackupDeleteDto: DatabaseBackupDeleteDto;
|
||||
}, opts?: Oazapfts.RequestOpts) {
|
||||
return oazapfts.ok(oazapfts.fetchText("/admin/database-backups", oazapfts.json({
|
||||
...opts,
|
||||
method: "DELETE",
|
||||
body: databaseBackupDeleteDto
|
||||
})));
|
||||
}
|
||||
/**
|
||||
* List database backups
|
||||
*/
|
||||
export function listDatabaseBackups(opts?: Oazapfts.RequestOpts) {
|
||||
return oazapfts.ok(oazapfts.fetchJson<{
|
||||
status: 200;
|
||||
data: DatabaseBackupListResponseDto;
|
||||
}>("/admin/database-backups", {
|
||||
...opts
|
||||
}));
|
||||
}
|
||||
/**
|
||||
* Start database backup restore flow
|
||||
*/
|
||||
export function startDatabaseRestoreFlow(opts?: Oazapfts.RequestOpts) {
|
||||
return oazapfts.ok(oazapfts.fetchText("/admin/database-backups/start-restore", {
|
||||
...opts,
|
||||
method: "POST"
|
||||
}));
|
||||
}
|
||||
/**
|
||||
* Upload database backup
|
||||
*/
|
||||
export function uploadDatabaseBackup({ databaseBackupUploadDto }: {
|
||||
databaseBackupUploadDto: DatabaseBackupUploadDto;
|
||||
}, opts?: Oazapfts.RequestOpts) {
|
||||
return oazapfts.ok(oazapfts.fetchText("/admin/database-backups/upload", oazapfts.multipart({
|
||||
...opts,
|
||||
method: "POST",
|
||||
body: databaseBackupUploadDto
|
||||
})));
|
||||
}
|
||||
/**
|
||||
* Download database backup
|
||||
*/
|
||||
export function downloadDatabaseBackup({ filename }: {
|
||||
filename: string;
|
||||
}, opts?: Oazapfts.RequestOpts) {
|
||||
return oazapfts.ok(oazapfts.fetchBlob<{
|
||||
status: 200;
|
||||
data: Blob;
|
||||
}>(`/admin/database-backups/${encodeURIComponent(filename)}`, {
|
||||
...opts
|
||||
}));
|
||||
}
|
||||
/**
|
||||
* Set maintenance mode
|
||||
*/
|
||||
@@ -1932,6 +2019,17 @@ export function setMaintenanceMode({ setMaintenanceModeDto }: {
|
||||
body: setMaintenanceModeDto
|
||||
})));
|
||||
}
|
||||
/**
|
||||
* Detect existing install
|
||||
*/
|
||||
export function detectPriorInstall(opts?: Oazapfts.RequestOpts) {
|
||||
return oazapfts.ok(oazapfts.fetchJson<{
|
||||
status: 200;
|
||||
data: MaintenanceDetectInstallResponseDto;
|
||||
}>("/admin/maintenance/detect-install", {
|
||||
...opts
|
||||
}));
|
||||
}
|
||||
/**
|
||||
* Log into maintenance mode
|
||||
*/
|
||||
@@ -1947,6 +2045,17 @@ export function maintenanceLogin({ maintenanceLoginDto }: {
|
||||
body: maintenanceLoginDto
|
||||
})));
|
||||
}
|
||||
/**
|
||||
* Get maintenance mode status
|
||||
*/
|
||||
export function getMaintenanceStatus(opts?: Oazapfts.RequestOpts) {
|
||||
return oazapfts.ok(oazapfts.fetchJson<{
|
||||
status: 200;
|
||||
data: MaintenanceStatusResponseDto;
|
||||
}>("/admin/maintenance/status", {
|
||||
...opts
|
||||
}));
|
||||
}
|
||||
/**
|
||||
* Create a notification
|
||||
*/
|
||||
@@ -5297,7 +5406,17 @@ export enum UserAvatarColor {
|
||||
}
|
||||
export enum MaintenanceAction {
|
||||
Start = "start",
|
||||
End = "end"
|
||||
End = "end",
|
||||
SelectDatabaseRestore = "select_database_restore",
|
||||
RestoreDatabase = "restore_database"
|
||||
}
|
||||
export enum StorageFolder {
|
||||
EncodedVideo = "encoded-video",
|
||||
Library = "library",
|
||||
Upload = "upload",
|
||||
Profile = "profile",
|
||||
Thumbs = "thumbs",
|
||||
Backups = "backups"
|
||||
}
|
||||
export enum NotificationLevel {
|
||||
Success = "success",
|
||||
@@ -5395,6 +5514,10 @@ export enum Permission {
|
||||
AuthChangePassword = "auth.changePassword",
|
||||
AuthDeviceDelete = "authDevice.delete",
|
||||
ArchiveRead = "archive.read",
|
||||
BackupList = "backup.list",
|
||||
BackupDownload = "backup.download",
|
||||
BackupUpload = "backup.upload",
|
||||
BackupDelete = "backup.delete",
|
||||
DuplicateRead = "duplicate.read",
|
||||
DuplicateDelete = "duplicate.delete",
|
||||
FaceCreate = "face.create",
|
||||
|
||||
@@ -10,6 +10,7 @@ import { IWorker } from 'src/constants';
|
||||
import { controllers } from 'src/controllers';
|
||||
import { ImmichWorker } from 'src/enum';
|
||||
import { MaintenanceAuthGuard } from 'src/maintenance/maintenance-auth.guard';
|
||||
import { MaintenanceHealthRepository } from 'src/maintenance/maintenance-health.repository';
|
||||
import { MaintenanceWebsocketRepository } from 'src/maintenance/maintenance-websocket.repository';
|
||||
import { MaintenanceWorkerController } from 'src/maintenance/maintenance-worker.controller';
|
||||
import { MaintenanceWorkerService } from 'src/maintenance/maintenance-worker.service';
|
||||
@@ -21,8 +22,11 @@ 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 { DatabaseRepository } from 'src/repositories/database.repository';
|
||||
import { EventRepository } from 'src/repositories/event.repository';
|
||||
import { LoggingRepository } from 'src/repositories/logging.repository';
|
||||
import { ProcessRepository } from 'src/repositories/process.repository';
|
||||
import { StorageRepository } from 'src/repositories/storage.repository';
|
||||
import { SystemMetadataRepository } from 'src/repositories/system-metadata.repository';
|
||||
import { teardownTelemetry, TelemetryRepository } from 'src/repositories/telemetry.repository';
|
||||
import { WebsocketRepository } from 'src/repositories/websocket.repository';
|
||||
@@ -103,8 +107,12 @@ export class ApiModule extends BaseModule {}
|
||||
providers: [
|
||||
ConfigRepository,
|
||||
LoggingRepository,
|
||||
StorageRepository,
|
||||
ProcessRepository,
|
||||
DatabaseRepository,
|
||||
SystemMetadataRepository,
|
||||
AppRepository,
|
||||
MaintenanceHealthRepository,
|
||||
MaintenanceWebsocketRepository,
|
||||
MaintenanceWorkerService,
|
||||
...commonMiddleware,
|
||||
@@ -116,9 +124,14 @@ export class MaintenanceModule {
|
||||
constructor(
|
||||
@Inject(IWorker) private worker: ImmichWorker,
|
||||
logger: LoggingRepository,
|
||||
private maintenanceWorkerService: MaintenanceWorkerService,
|
||||
) {
|
||||
logger.setAppName(this.worker);
|
||||
}
|
||||
|
||||
async onModuleInit() {
|
||||
await this.maintenanceWorkerService.init();
|
||||
}
|
||||
}
|
||||
|
||||
@Module({
|
||||
|
||||
@@ -141,6 +141,7 @@ export const endpointTags: Record<ApiTag, string> = {
|
||||
[ApiTag.Assets]: 'An asset is an image or video that has been uploaded to Immich.',
|
||||
[ApiTag.Authentication]: 'Endpoints related to user authentication, including OAuth.',
|
||||
[ApiTag.AuthenticationAdmin]: 'Administrative endpoints related to authentication.',
|
||||
[ApiTag.DatabaseBackups]: 'Manage backups of the Immich database.',
|
||||
[ApiTag.Deprecated]: 'Deprecated endpoints that are planned for removal in the next major release.',
|
||||
[ApiTag.Download]: 'Endpoints for downloading assets or collections of assets.',
|
||||
[ApiTag.Duplicates]: 'Endpoints for managing and identifying duplicate assets.',
|
||||
|
||||
101
server/src/controllers/database-backup.controller.ts
Normal file
101
server/src/controllers/database-backup.controller.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
import { Body, Controller, Delete, Get, Next, Param, Post, Res, UploadedFile, UseInterceptors } from '@nestjs/common';
|
||||
import { FileInterceptor } from '@nestjs/platform-express';
|
||||
import { ApiBody, ApiConsumes, ApiTags } from '@nestjs/swagger';
|
||||
import { NextFunction, Response } from 'express';
|
||||
import { Endpoint, HistoryBuilder } from 'src/decorators';
|
||||
import {
|
||||
DatabaseBackupDeleteDto,
|
||||
DatabaseBackupListResponseDto,
|
||||
DatabaseBackupUploadDto,
|
||||
} from 'src/dtos/database-backup.dto';
|
||||
import { ApiTag, ImmichCookie, Permission } from 'src/enum';
|
||||
import { Authenticated, FileResponse, GetLoginDetails } from 'src/middleware/auth.guard';
|
||||
import { LoggingRepository } from 'src/repositories/logging.repository';
|
||||
import { LoginDetails } from 'src/services/auth.service';
|
||||
import { DatabaseBackupService } from 'src/services/database-backup.service';
|
||||
import { MaintenanceService } from 'src/services/maintenance.service';
|
||||
import { sendFile } from 'src/utils/file';
|
||||
import { respondWithCookie } from 'src/utils/response';
|
||||
import { FilenameParamDto } from 'src/validation';
|
||||
|
||||
@ApiTags(ApiTag.DatabaseBackups)
|
||||
@Controller('admin/database-backups')
|
||||
export class DatabaseBackupController {
|
||||
constructor(
|
||||
private logger: LoggingRepository,
|
||||
private service: DatabaseBackupService,
|
||||
private maintenanceService: MaintenanceService,
|
||||
) {}
|
||||
|
||||
@Get()
|
||||
@Endpoint({
|
||||
summary: 'List database backups',
|
||||
description: 'Get the list of the successful and failed backups',
|
||||
history: new HistoryBuilder().added('v2.5.0').alpha('v2.5.0'),
|
||||
})
|
||||
@Authenticated({ permission: Permission.Maintenance, admin: true })
|
||||
listDatabaseBackups(): Promise<DatabaseBackupListResponseDto> {
|
||||
return this.service.listBackups();
|
||||
}
|
||||
|
||||
@Get(':filename')
|
||||
@FileResponse()
|
||||
@Endpoint({
|
||||
summary: 'Download database backup',
|
||||
description: 'Downloads the database backup file',
|
||||
history: new HistoryBuilder().added('v2.5.0').alpha('v2.5.0'),
|
||||
})
|
||||
@Authenticated({ permission: Permission.BackupDownload, admin: true })
|
||||
async downloadDatabaseBackup(
|
||||
@Param() { filename }: FilenameParamDto,
|
||||
@Res() res: Response,
|
||||
@Next() next: NextFunction,
|
||||
): Promise<void> {
|
||||
await sendFile(res, next, () => this.service.downloadBackup(filename), this.logger);
|
||||
}
|
||||
|
||||
@Delete()
|
||||
@Endpoint({
|
||||
summary: 'Delete database backup',
|
||||
description: 'Delete a backup by its filename',
|
||||
history: new HistoryBuilder().added('v2.5.0').alpha('v2.5.0'),
|
||||
})
|
||||
@Authenticated({ permission: Permission.BackupDelete, admin: true })
|
||||
async deleteDatabaseBackup(@Body() dto: DatabaseBackupDeleteDto): Promise<void> {
|
||||
return this.service.deleteBackup(dto.backups);
|
||||
}
|
||||
|
||||
@Post('start-restore')
|
||||
@Endpoint({
|
||||
summary: 'Start database backup restore flow',
|
||||
description: 'Put Immich into maintenance mode to restore a backup (Immich must not be configured)',
|
||||
history: new HistoryBuilder().added('v2.5.0').alpha('v2.5.0'),
|
||||
})
|
||||
async startDatabaseRestoreFlow(
|
||||
@GetLoginDetails() loginDetails: LoginDetails,
|
||||
@Res({ passthrough: true }) res: Response,
|
||||
): Promise<void> {
|
||||
const { jwt } = await this.maintenanceService.startRestoreFlow();
|
||||
return respondWithCookie(res, undefined, {
|
||||
isSecure: loginDetails.isSecure,
|
||||
values: [{ key: ImmichCookie.MaintenanceToken, value: jwt }],
|
||||
});
|
||||
}
|
||||
|
||||
@Post('upload')
|
||||
@Authenticated({ permission: Permission.BackupUpload, admin: true })
|
||||
@ApiConsumes('multipart/form-data')
|
||||
@ApiBody({ description: 'Backup Upload', type: DatabaseBackupUploadDto })
|
||||
@Endpoint({
|
||||
summary: 'Upload database backup',
|
||||
description: 'Uploads .sql/.sql.gz file to restore backup from',
|
||||
history: new HistoryBuilder().added('v2.5.0').alpha('v2.5.0'),
|
||||
})
|
||||
@UseInterceptors(FileInterceptor('file'))
|
||||
uploadDatabaseBackup(
|
||||
@UploadedFile()
|
||||
file: Express.Multer.File,
|
||||
): Promise<void> {
|
||||
return this.service.uploadBackup(file);
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import { AssetMediaController } from 'src/controllers/asset-media.controller';
|
||||
import { AssetController } from 'src/controllers/asset.controller';
|
||||
import { AuthAdminController } from 'src/controllers/auth-admin.controller';
|
||||
import { AuthController } from 'src/controllers/auth.controller';
|
||||
import { DatabaseBackupController } from 'src/controllers/database-backup.controller';
|
||||
import { DownloadController } from 'src/controllers/download.controller';
|
||||
import { DuplicateController } from 'src/controllers/duplicate.controller';
|
||||
import { FaceController } from 'src/controllers/face.controller';
|
||||
@@ -46,6 +47,7 @@ export const controllers = [
|
||||
AssetMediaController,
|
||||
AuthController,
|
||||
AuthAdminController,
|
||||
DatabaseBackupController,
|
||||
DownloadController,
|
||||
DuplicateController,
|
||||
FaceController,
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
import { BadRequestException, Body, Controller, Post, Res } from '@nestjs/common';
|
||||
import { BadRequestException, Body, Controller, Get, 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 {
|
||||
MaintenanceAuthDto,
|
||||
MaintenanceDetectInstallResponseDto,
|
||||
MaintenanceLoginDto,
|
||||
MaintenanceStatusResponseDto,
|
||||
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';
|
||||
@@ -15,6 +21,27 @@ import { respondWithCookie } from 'src/utils/response';
|
||||
export class MaintenanceController {
|
||||
constructor(private service: MaintenanceService) {}
|
||||
|
||||
@Get('status')
|
||||
@Endpoint({
|
||||
summary: 'Get maintenance mode status',
|
||||
description: 'Fetch information about the currently running maintenance action.',
|
||||
history: new HistoryBuilder().added('v2.5.0').alpha('v2.5.0'),
|
||||
})
|
||||
getMaintenanceStatus(): MaintenanceStatusResponseDto {
|
||||
return this.service.getMaintenanceStatus();
|
||||
}
|
||||
|
||||
@Get('detect-install')
|
||||
@Endpoint({
|
||||
summary: 'Detect existing install',
|
||||
description: 'Collect integrity checks and other heuristics about local data.',
|
||||
history: new HistoryBuilder().added('v2.5.0').alpha('v2.5.0'),
|
||||
})
|
||||
@Authenticated({ permission: Permission.Maintenance, admin: true })
|
||||
detectPriorInstall(): Promise<MaintenanceDetectInstallResponseDto> {
|
||||
return this.service.detectPriorInstall();
|
||||
}
|
||||
|
||||
@Post('login')
|
||||
@Endpoint({
|
||||
summary: 'Log into maintenance mode',
|
||||
@@ -38,8 +65,8 @@ export class MaintenanceController {
|
||||
@GetLoginDetails() loginDetails: LoginDetails,
|
||||
@Res({ passthrough: true }) res: Response,
|
||||
): Promise<void> {
|
||||
if (dto.action === MaintenanceAction.Start) {
|
||||
const { jwt } = await this.service.startMaintenance(auth.user.name);
|
||||
if (dto.action !== MaintenanceAction.End) {
|
||||
const { jwt } = await this.service.startMaintenance(dto, auth.user.name);
|
||||
return respondWithCookie(res, undefined, {
|
||||
isSecure: loginDetails.isSecure,
|
||||
values: [{ key: ImmichCookie.MaintenanceToken, value: jwt }],
|
||||
|
||||
@@ -1,7 +1,15 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { dirname, join, resolve } from 'node:path';
|
||||
import { StorageAsset } from 'src/database';
|
||||
import { AssetFileType, AssetPathType, ImageFormat, PathType, PersonPathType, StorageFolder } from 'src/enum';
|
||||
import {
|
||||
AssetFileType,
|
||||
AssetPathType,
|
||||
ImageFormat,
|
||||
PathType,
|
||||
PersonPathType,
|
||||
RawExtractedFormat,
|
||||
StorageFolder,
|
||||
} from 'src/enum';
|
||||
import { AssetRepository } from 'src/repositories/asset.repository';
|
||||
import { ConfigRepository } from 'src/repositories/config.repository';
|
||||
import { CryptoRepository } from 'src/repositories/crypto.repository';
|
||||
@@ -24,15 +32,6 @@ export interface MoveRequest {
|
||||
};
|
||||
}
|
||||
|
||||
export type GeneratedImageType =
|
||||
| AssetPathType.Preview
|
||||
| AssetPathType.Thumbnail
|
||||
| AssetPathType.FullSize
|
||||
| AssetPathType.EditedPreview
|
||||
| AssetPathType.EditedThumbnail
|
||||
| AssetPathType.EditedFullSize;
|
||||
export type GeneratedAssetType = GeneratedImageType | AssetPathType.EncodedVideo;
|
||||
|
||||
export type ThumbnailPathEntity = { id: string; ownerId: string };
|
||||
|
||||
let instance: StorageCore | null;
|
||||
@@ -111,8 +110,19 @@ export class StorageCore {
|
||||
return StorageCore.getNestedPath(StorageFolder.Thumbnails, person.ownerId, `${person.id}.jpeg`);
|
||||
}
|
||||
|
||||
static getImagePath(asset: ThumbnailPathEntity, type: GeneratedImageType, format: 'jpeg' | 'webp') {
|
||||
return StorageCore.getNestedPath(StorageFolder.Thumbnails, asset.ownerId, `${asset.id}-${type}.${format}`);
|
||||
static getImagePath(
|
||||
asset: ThumbnailPathEntity,
|
||||
{
|
||||
fileType,
|
||||
format,
|
||||
isEdited,
|
||||
}: { fileType: AssetFileType; format: ImageFormat | RawExtractedFormat; isEdited: boolean },
|
||||
) {
|
||||
return StorageCore.getNestedPath(
|
||||
StorageFolder.Thumbnails,
|
||||
asset.ownerId,
|
||||
`${asset.id}_${fileType}${isEdited ? '_edited' : ''}.${format}`,
|
||||
);
|
||||
}
|
||||
|
||||
static getEncodedVideoPath(asset: ThumbnailPathEntity) {
|
||||
@@ -137,14 +147,14 @@ export class StorageCore {
|
||||
return normalizedPath.startsWith(normalizedAppMediaLocation);
|
||||
}
|
||||
|
||||
async moveAssetImage(asset: StorageAsset, pathType: GeneratedImageType, format: ImageFormat) {
|
||||
async moveAssetImage(asset: StorageAsset, fileType: AssetFileType, format: ImageFormat) {
|
||||
const { id: entityId, files } = asset;
|
||||
const oldFile = getAssetFile(files, pathType);
|
||||
const oldFile = getAssetFile(files, fileType, { isEdited: false });
|
||||
return this.moveFile({
|
||||
entityId,
|
||||
pathType,
|
||||
pathType: fileType,
|
||||
oldPath: oldFile?.path || null,
|
||||
newPath: StorageCore.getImagePath(asset, pathType, format),
|
||||
newPath: StorageCore.getImagePath(asset, { fileType, format, isEdited: false }),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -298,19 +308,19 @@ export class StorageCore {
|
||||
case AssetPathType.Original: {
|
||||
return this.assetRepository.update({ id, originalPath: newPath });
|
||||
}
|
||||
case AssetPathType.FullSize: {
|
||||
case AssetFileType.FullSize: {
|
||||
return this.assetRepository.upsertFile({ assetId: id, type: AssetFileType.FullSize, path: newPath });
|
||||
}
|
||||
case AssetPathType.Preview: {
|
||||
case AssetFileType.Preview: {
|
||||
return this.assetRepository.upsertFile({ assetId: id, type: AssetFileType.Preview, path: newPath });
|
||||
}
|
||||
case AssetPathType.Thumbnail: {
|
||||
case AssetFileType.Thumbnail: {
|
||||
return this.assetRepository.upsertFile({ assetId: id, type: AssetFileType.Thumbnail, path: newPath });
|
||||
}
|
||||
case AssetPathType.EncodedVideo: {
|
||||
return this.assetRepository.update({ id, encodedVideoPath: newPath });
|
||||
}
|
||||
case AssetPathType.Sidecar: {
|
||||
case AssetFileType.Sidecar: {
|
||||
return this.assetRepository.upsertFile({ assetId: id, type: AssetFileType.Sidecar, path: newPath });
|
||||
}
|
||||
case PersonPathType.Face: {
|
||||
|
||||
@@ -39,6 +39,7 @@ export type AssetFile = {
|
||||
id: string;
|
||||
type: AssetFileType;
|
||||
path: string;
|
||||
isEdited: boolean;
|
||||
};
|
||||
|
||||
export type Library = {
|
||||
@@ -344,7 +345,7 @@ export const columns = {
|
||||
'asset.width',
|
||||
'asset.height',
|
||||
],
|
||||
assetFiles: ['asset_file.id', 'asset_file.path', 'asset_file.type'],
|
||||
assetFiles: ['asset_file.id', 'asset_file.path', 'asset_file.type', 'asset_file.isEdited'],
|
||||
authUser: ['user.id', 'user.name', 'user.email', 'user.isAdmin', 'user.quotaUsageInBytes', 'user.quotaSizeInBytes'],
|
||||
authApiKey: ['api_key.id', 'api_key.permissions'],
|
||||
authSession: ['session.id', 'session.updatedAt', 'session.pinExpiresAt', 'session.appVersion'],
|
||||
@@ -457,6 +458,7 @@ export const columns = {
|
||||
'asset_exif.projectionType',
|
||||
'asset_exif.rating',
|
||||
'asset_exif.state',
|
||||
'asset_exif.tags',
|
||||
'asset_exif.timeZone',
|
||||
],
|
||||
plugin: [
|
||||
@@ -480,4 +482,5 @@ export const lockableProperties = [
|
||||
'longitude',
|
||||
'rating',
|
||||
'timeZone',
|
||||
'tags',
|
||||
] as const;
|
||||
|
||||
21
server/src/dtos/database-backup.dto.ts
Normal file
21
server/src/dtos/database-backup.dto.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsString } from 'class-validator';
|
||||
|
||||
export class DatabaseBackupDto {
|
||||
filename!: string;
|
||||
filesize!: number;
|
||||
}
|
||||
|
||||
export class DatabaseBackupListResponseDto {
|
||||
backups!: DatabaseBackupDto[];
|
||||
}
|
||||
|
||||
export class DatabaseBackupUploadDto {
|
||||
@ApiProperty({ type: 'string', format: 'binary', required: false })
|
||||
file?: any;
|
||||
}
|
||||
|
||||
export class DatabaseBackupDeleteDto {
|
||||
@IsString({ each: true })
|
||||
backups!: string[];
|
||||
}
|
||||
@@ -1,9 +1,14 @@
|
||||
import { MaintenanceAction } from 'src/enum';
|
||||
import { ValidateIf } from 'class-validator';
|
||||
import { MaintenanceAction, StorageFolder } from 'src/enum';
|
||||
import { ValidateEnum, ValidateString } from 'src/validation';
|
||||
|
||||
export class SetMaintenanceModeDto {
|
||||
@ValidateEnum({ enum: MaintenanceAction, name: 'MaintenanceAction' })
|
||||
action!: MaintenanceAction;
|
||||
|
||||
@ValidateIf((o) => o.action === MaintenanceAction.RestoreDatabase)
|
||||
@ValidateString()
|
||||
restoreBackupFilename?: string;
|
||||
}
|
||||
|
||||
export class MaintenanceLoginDto {
|
||||
@@ -14,3 +19,26 @@ export class MaintenanceLoginDto {
|
||||
export class MaintenanceAuthDto {
|
||||
username!: string;
|
||||
}
|
||||
|
||||
export class MaintenanceStatusResponseDto {
|
||||
active!: boolean;
|
||||
|
||||
@ValidateEnum({ enum: MaintenanceAction, name: 'MaintenanceAction' })
|
||||
action!: MaintenanceAction;
|
||||
|
||||
progress?: number;
|
||||
task?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export class MaintenanceDetectInstallStorageFolderDto {
|
||||
@ValidateEnum({ enum: StorageFolder, name: 'StorageFolder' })
|
||||
folder!: StorageFolder;
|
||||
readable!: boolean;
|
||||
writable!: boolean;
|
||||
files!: number;
|
||||
}
|
||||
|
||||
export class MaintenanceDetectInstallResponseDto {
|
||||
storage!: MaintenanceDetectInstallStorageFolderDto[];
|
||||
}
|
||||
|
||||
@@ -45,9 +45,6 @@ export enum AssetFileType {
|
||||
Preview = 'preview',
|
||||
Thumbnail = 'thumbnail',
|
||||
Sidecar = 'sidecar',
|
||||
FullSizeEdited = 'fullsize_edited',
|
||||
PreviewEdited = 'preview_edited',
|
||||
ThumbnailEdited = 'thumbnail_edited',
|
||||
}
|
||||
|
||||
export enum AlbumUserRole {
|
||||
@@ -136,6 +133,11 @@ export enum Permission {
|
||||
|
||||
ArchiveRead = 'archive.read',
|
||||
|
||||
BackupList = 'backup.list',
|
||||
BackupDownload = 'backup.download',
|
||||
BackupUpload = 'backup.upload',
|
||||
BackupDelete = 'backup.delete',
|
||||
|
||||
DuplicateRead = 'duplicate.read',
|
||||
DuplicateDelete = 'duplicate.delete',
|
||||
|
||||
@@ -364,14 +366,7 @@ export enum ManualJobName {
|
||||
|
||||
export enum AssetPathType {
|
||||
Original = 'original',
|
||||
FullSize = 'fullsize',
|
||||
Preview = 'preview',
|
||||
EditedFullSize = 'edited_fullsize',
|
||||
EditedPreview = 'edited_preview',
|
||||
EditedThumbnail = 'edited_thumbnail',
|
||||
Thumbnail = 'thumbnail',
|
||||
EncodedVideo = 'encoded_video',
|
||||
Sidecar = 'sidecar',
|
||||
}
|
||||
|
||||
export enum PersonPathType {
|
||||
@@ -382,7 +377,7 @@ export enum UserPathType {
|
||||
Profile = 'profile',
|
||||
}
|
||||
|
||||
export type PathType = AssetPathType | PersonPathType | UserPathType;
|
||||
export type PathType = AssetFileType | AssetPathType | PersonPathType | UserPathType;
|
||||
|
||||
export enum TranscodePolicy {
|
||||
All = 'all',
|
||||
@@ -697,12 +692,15 @@ export enum DatabaseLock {
|
||||
MediaLocation = 700,
|
||||
GetSystemConfig = 69,
|
||||
BackupDatabase = 42,
|
||||
MaintenanceOperation = 621,
|
||||
MemoryCreation = 777,
|
||||
}
|
||||
|
||||
export enum MaintenanceAction {
|
||||
Start = 'start',
|
||||
End = 'end',
|
||||
SelectDatabaseRestore = 'select_database_restore',
|
||||
RestoreDatabase = 'restore_database',
|
||||
}
|
||||
|
||||
export enum ExitCode {
|
||||
@@ -849,6 +847,7 @@ export enum ApiTag {
|
||||
Authentication = 'Authentication',
|
||||
AuthenticationAdmin = 'Authentication (admin)',
|
||||
Assets = 'Assets',
|
||||
DatabaseBackups = 'Database Backups (admin)',
|
||||
Deprecated = 'Deprecated',
|
||||
Download = 'Download',
|
||||
Duplicates = 'Duplicates',
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { Kysely } from 'kysely';
|
||||
import { Kysely, sql } 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 { ExitCode, ImmichWorker, LogLevel, SystemMetadataKey } from 'src/enum';
|
||||
import { DatabaseLock, 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';
|
||||
@@ -35,19 +35,18 @@ class Workers {
|
||||
if (isMaintenanceMode) {
|
||||
this.startWorker(ImmichWorker.Maintenance);
|
||||
} else {
|
||||
await this.waitForFreeLock();
|
||||
|
||||
for (const worker of workers) {
|
||||
this.startWorker(worker);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 { log: _, ...kyselyConfig } = getKyselyConfig(database.config);
|
||||
const kysely = new Kysely<DB>(kyselyConfig);
|
||||
const systemMetadataRepository = new SystemMetadataRepository(kysely);
|
||||
|
||||
try {
|
||||
@@ -65,6 +64,32 @@ class Workers {
|
||||
}
|
||||
}
|
||||
|
||||
private async waitForFreeLock() {
|
||||
const { database } = new ConfigRepository().getEnv();
|
||||
const kysely = new Kysely<DB>(getKyselyConfig(database.config));
|
||||
|
||||
let locked = false;
|
||||
while (!locked) {
|
||||
locked = await kysely.connection().execute(async (conn) => {
|
||||
const { rows } = await sql<{
|
||||
pg_try_advisory_lock: boolean;
|
||||
}>`SELECT pg_try_advisory_lock(${DatabaseLock.MaintenanceOperation})`.execute(conn);
|
||||
|
||||
const isLocked = rows[0].pg_try_advisory_lock;
|
||||
|
||||
if (isLocked) {
|
||||
await sql`SELECT pg_advisory_unlock(${DatabaseLock.MaintenanceOperation})`.execute(conn);
|
||||
}
|
||||
|
||||
return isLocked;
|
||||
});
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
}
|
||||
|
||||
await kysely.destroy();
|
||||
}
|
||||
|
||||
/**
|
||||
* Start an individual worker
|
||||
* @param name Worker
|
||||
|
||||
67
server/src/maintenance/maintenance-health.repository.ts
Normal file
67
server/src/maintenance/maintenance-health.repository.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { fork } from 'node:child_process';
|
||||
import { dirname, join } from 'node:path';
|
||||
|
||||
@Injectable()
|
||||
export class MaintenanceHealthRepository {
|
||||
checkApiHealth(): Promise<void> {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
// eslint-disable-next-line unicorn/prefer-module
|
||||
const basePath = dirname(__filename);
|
||||
const workerFile = join(basePath, '..', 'workers', `api.js`);
|
||||
|
||||
const worker = fork(workerFile, [], {
|
||||
execArgv: process.execArgv.filter((arg) => !arg.startsWith('--inspect')),
|
||||
env: {
|
||||
...process.env,
|
||||
IMMICH_HOST: '127.0.0.1',
|
||||
IMMICH_PORT: '33001',
|
||||
},
|
||||
stdio: ['ignore', 'pipe', 'ignore', 'ipc'],
|
||||
});
|
||||
|
||||
async function checkHealth() {
|
||||
try {
|
||||
const response = await fetch('http://127.0.0.1:33001/api/server/config');
|
||||
const { isOnboarded } = await response.json();
|
||||
if (isOnboarded) {
|
||||
resolve();
|
||||
} else {
|
||||
reject(new Error('Server health check failed, no admin exists.'));
|
||||
}
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
} finally {
|
||||
if (worker.exitCode === null) {
|
||||
worker.kill('SIGTERM');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let output = '',
|
||||
alive = false;
|
||||
|
||||
worker.stdout?.on('data', (data) => {
|
||||
if (alive) {
|
||||
return;
|
||||
}
|
||||
|
||||
output += data;
|
||||
|
||||
if (output.includes('Immich Server is listening')) {
|
||||
alive = true;
|
||||
void checkHealth();
|
||||
}
|
||||
});
|
||||
|
||||
worker.on('exit', reject);
|
||||
worker.on('error', reject);
|
||||
|
||||
setTimeout(() => {
|
||||
if (worker.exitCode === null) {
|
||||
worker.kill('SIGTERM');
|
||||
}
|
||||
}, 20_000);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -7,17 +7,24 @@ import {
|
||||
WebSocketServer,
|
||||
} from '@nestjs/websockets';
|
||||
import { Server, Socket } from 'socket.io';
|
||||
import { MaintenanceAuthDto, MaintenanceStatusResponseDto } from 'src/dtos/maintenance.dto';
|
||||
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];
|
||||
interface ServerEventMap {
|
||||
AppRestart: [AppRestartEvent];
|
||||
MaintenanceStatus: [MaintenanceStatusResponseDto];
|
||||
}
|
||||
|
||||
interface ClientEventMap {
|
||||
AppRestartV1: [AppRestartEvent];
|
||||
MaintenanceStatusV1: [MaintenanceStatusResponseDto];
|
||||
}
|
||||
|
||||
type AuthFn = (client: Socket) => Promise<MaintenanceAuthDto>;
|
||||
type StatusUpdateFn = (status: MaintenanceStatusResponseDto) => void;
|
||||
|
||||
@WebSocketGateway({
|
||||
cors: true,
|
||||
path: '/api/socket.io',
|
||||
@@ -25,8 +32,11 @@ export interface ClientEventMap {
|
||||
})
|
||||
@Injectable()
|
||||
export class MaintenanceWebsocketRepository implements OnGatewayConnection, OnGatewayDisconnect, OnGatewayInit {
|
||||
private authFn?: AuthFn;
|
||||
private statusUpdateFn?: StatusUpdateFn;
|
||||
|
||||
@WebSocketServer()
|
||||
private websocketServer?: Server;
|
||||
private server?: Server;
|
||||
|
||||
constructor(
|
||||
private logger: LoggingRepository,
|
||||
@@ -35,10 +45,10 @@ export class MaintenanceWebsocketRepository implements OnGatewayConnection, OnGa
|
||||
this.logger.setContext(MaintenanceWebsocketRepository.name);
|
||||
}
|
||||
|
||||
afterInit(websocketServer: Server) {
|
||||
afterInit(server: Server) {
|
||||
this.logger.log('Initialized websocket server');
|
||||
|
||||
websocketServer.on('AppRestart', (event: ArgsOf<'AppRestart'>, ack?: (ok: 'ok') => void) => {
|
||||
server.on('MaintenanceStatus', (status) => this.statusUpdateFn?.(status));
|
||||
server.on('AppRestart', (event: ArgsOf<'AppRestart'>, ack?: (ok: 'ok') => void) => {
|
||||
this.logger.log(`Restarting due to event... ${JSON.stringify(event)}`);
|
||||
|
||||
ack?.('ok');
|
||||
@@ -46,20 +56,40 @@ export class MaintenanceWebsocketRepository implements OnGatewayConnection, OnGa
|
||||
});
|
||||
}
|
||||
|
||||
clientSend<T extends keyof ClientEventMap>(event: T, room: string, ...data: ClientEventMap[T]) {
|
||||
this.server?.to(room).emit(event, ...data);
|
||||
}
|
||||
|
||||
clientBroadcast<T extends keyof ClientEventMap>(event: T, ...data: ClientEventMap[T]) {
|
||||
this.websocketServer?.emit(event, ...data);
|
||||
this.server?.emit(event, ...data);
|
||||
}
|
||||
|
||||
serverSend<T extends ServerEvents>(event: T, ...args: ArgsOf<T>): void {
|
||||
serverSend<T extends keyof ServerEventMap>(event: T, ...args: ServerEventMap[T]): void {
|
||||
this.logger.debug(`Server event: ${event} (send)`);
|
||||
this.websocketServer?.serverSideEmit(event, ...args);
|
||||
this.server?.serverSideEmit(event, ...args);
|
||||
}
|
||||
|
||||
handleConnection(client: Socket) {
|
||||
this.logger.log(`Websocket Connect: ${client.id}`);
|
||||
async handleConnection(client: Socket) {
|
||||
try {
|
||||
await this.authFn!(client);
|
||||
await client.join('private');
|
||||
this.logger.log(`Websocket Connect: ${client.id} (private)`);
|
||||
} catch {
|
||||
await client.join('public');
|
||||
this.logger.log(`Websocket Connect: ${client.id} (public)`);
|
||||
}
|
||||
}
|
||||
|
||||
handleDisconnect(client: Socket) {
|
||||
async handleDisconnect(client: Socket) {
|
||||
this.logger.log(`Websocket Disconnect: ${client.id}`);
|
||||
await Promise.allSettled([client.leave('private'), client.leave('public')]);
|
||||
}
|
||||
|
||||
setAuthFn(fn: (client: Socket) => Promise<MaintenanceAuthDto>) {
|
||||
this.authFn = fn;
|
||||
}
|
||||
|
||||
setStatusUpdateFn(fn: (status: MaintenanceStatusResponseDto) => void) {
|
||||
this.statusUpdateFn = fn;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,23 +1,114 @@
|
||||
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 {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
Next,
|
||||
Param,
|
||||
Post,
|
||||
Req,
|
||||
Res,
|
||||
UploadedFile,
|
||||
UseInterceptors,
|
||||
} from '@nestjs/common';
|
||||
import { FileInterceptor } from '@nestjs/platform-express';
|
||||
import { NextFunction, Request, Response } from 'express';
|
||||
import {
|
||||
MaintenanceAuthDto,
|
||||
MaintenanceDetectInstallResponseDto,
|
||||
MaintenanceLoginDto,
|
||||
MaintenanceStatusResponseDto,
|
||||
SetMaintenanceModeDto,
|
||||
} from 'src/dtos/maintenance.dto';
|
||||
import { ServerConfigDto, ServerVersionResponseDto } from 'src/dtos/server.dto';
|
||||
import { ImmichCookie } 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 { LoggingRepository } from 'src/repositories/logging.repository';
|
||||
import { LoginDetails } from 'src/services/auth.service';
|
||||
import { sendFile } from 'src/utils/file';
|
||||
import { respondWithCookie } from 'src/utils/response';
|
||||
import { FilenameParamDto } from 'src/validation';
|
||||
|
||||
import type { DatabaseBackupController as _DatabaseBackupController } from 'src/controllers/database-backup.controller';
|
||||
import type { ServerController as _ServerController } from 'src/controllers/server.controller';
|
||||
import { DatabaseBackupDeleteDto, DatabaseBackupListResponseDto } from 'src/dtos/database-backup.dto';
|
||||
|
||||
@Controller()
|
||||
export class MaintenanceWorkerController {
|
||||
constructor(private service: MaintenanceWorkerService) {}
|
||||
constructor(
|
||||
private logger: LoggingRepository,
|
||||
private service: MaintenanceWorkerService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* {@link _ServerController.getServerConfig }
|
||||
*/
|
||||
@Get('server/config')
|
||||
getServerConfig(): Promise<ServerConfigDto> {
|
||||
getServerConfig(): ServerConfigDto {
|
||||
return this.service.getSystemConfig();
|
||||
}
|
||||
|
||||
@Get('server/version')
|
||||
getServerVersion(): ServerVersionResponseDto {
|
||||
return this.service.getVersion();
|
||||
}
|
||||
|
||||
/**
|
||||
* {@link _DatabaseBackupController.listDatabaseBackups}
|
||||
*/
|
||||
@Get('admin/database-backups')
|
||||
@MaintenanceRoute()
|
||||
listDatabaseBackups(): Promise<DatabaseBackupListResponseDto> {
|
||||
return this.service.listBackups();
|
||||
}
|
||||
|
||||
/**
|
||||
* {@link _DatabaseBackupController.downloadDatabaseBackup}
|
||||
*/
|
||||
@Get('admin/database-backups/:filename')
|
||||
@MaintenanceRoute()
|
||||
async downloadDatabaseBackup(
|
||||
@Param() { filename }: FilenameParamDto,
|
||||
@Res() res: Response,
|
||||
@Next() next: NextFunction,
|
||||
) {
|
||||
await sendFile(res, next, () => this.service.downloadBackup(filename), this.logger);
|
||||
}
|
||||
|
||||
/**
|
||||
* {@link _DatabaseBackupController.deleteDatabaseBackup}
|
||||
*/
|
||||
@Delete('admin/database-backups')
|
||||
@MaintenanceRoute()
|
||||
async deleteDatabaseBackup(@Body() dto: DatabaseBackupDeleteDto): Promise<void> {
|
||||
return this.service.deleteBackup(dto.backups);
|
||||
}
|
||||
|
||||
/**
|
||||
* {@link _DatabaseBackupController.uploadDatabaseBackup}
|
||||
*/
|
||||
@Post('admin/database-backups/upload')
|
||||
@MaintenanceRoute()
|
||||
@UseInterceptors(FileInterceptor('file'))
|
||||
uploadDatabaseBackup(
|
||||
@UploadedFile()
|
||||
file: Express.Multer.File,
|
||||
): Promise<void> {
|
||||
return this.service.uploadBackup(file);
|
||||
}
|
||||
|
||||
@Get('admin/maintenance/status')
|
||||
maintenanceStatus(@Req() request: Request): Promise<MaintenanceStatusResponseDto> {
|
||||
return this.service.status(request.cookies[ImmichCookie.MaintenanceToken]);
|
||||
}
|
||||
|
||||
@Get('admin/maintenance/detect-install')
|
||||
detectPriorInstall(): Promise<MaintenanceDetectInstallResponseDto> {
|
||||
return this.service.detectPriorInstall();
|
||||
}
|
||||
|
||||
@Post('admin/maintenance/login')
|
||||
async maintenanceLogin(
|
||||
@Req() request: Request,
|
||||
@@ -35,9 +126,7 @@ export class MaintenanceWorkerController {
|
||||
|
||||
@Post('admin/maintenance')
|
||||
@MaintenanceRoute()
|
||||
async setMaintenanceMode(@Body() dto: SetMaintenanceModeDto): Promise<void> {
|
||||
if (dto.action === MaintenanceAction.End) {
|
||||
await this.service.endMaintenance();
|
||||
}
|
||||
setMaintenanceMode(@Body() dto: SetMaintenanceModeDto): void {
|
||||
void this.service.setAction(dto);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,25 +1,51 @@
|
||||
import { UnauthorizedException } from '@nestjs/common';
|
||||
import { BadRequestException, UnauthorizedException } from '@nestjs/common';
|
||||
import { SignJWT } from 'jose';
|
||||
import { SystemMetadataKey } from 'src/enum';
|
||||
import { DateTime } from 'luxon';
|
||||
import { PassThrough, Readable } from 'node:stream';
|
||||
import { StorageCore } from 'src/cores/storage.core';
|
||||
import { MaintenanceAction, StorageFolder, SystemMetadataKey } from 'src/enum';
|
||||
import { MaintenanceHealthRepository } from 'src/maintenance/maintenance-health.repository';
|
||||
import { MaintenanceWebsocketRepository } from 'src/maintenance/maintenance-websocket.repository';
|
||||
import { MaintenanceWorkerService } from 'src/maintenance/maintenance-worker.service';
|
||||
import { automock, getMocks, ServiceMocks } from 'test/utils';
|
||||
import { automock, AutoMocked, getMocks, mockDuplex, mockSpawn, ServiceMocks } from 'test/utils';
|
||||
|
||||
function* mockData() {
|
||||
yield '';
|
||||
}
|
||||
|
||||
describe(MaintenanceWorkerService.name, () => {
|
||||
let sut: MaintenanceWorkerService;
|
||||
let mocks: ServiceMocks;
|
||||
let maintenanceWorkerRepositoryMock: MaintenanceWebsocketRepository;
|
||||
let maintenanceWebsocketRepositoryMock: AutoMocked<MaintenanceWebsocketRepository>;
|
||||
let maintenanceHealthRepositoryMock: AutoMocked<MaintenanceHealthRepository>;
|
||||
|
||||
beforeEach(() => {
|
||||
mocks = getMocks();
|
||||
maintenanceWorkerRepositoryMock = automock(MaintenanceWebsocketRepository, { args: [mocks.logger], strict: false });
|
||||
maintenanceWebsocketRepositoryMock = automock(MaintenanceWebsocketRepository, {
|
||||
args: [mocks.logger],
|
||||
strict: false,
|
||||
});
|
||||
maintenanceHealthRepositoryMock = automock(MaintenanceHealthRepository, {
|
||||
args: [mocks.logger],
|
||||
strict: false,
|
||||
});
|
||||
|
||||
sut = new MaintenanceWorkerService(
|
||||
mocks.logger as never,
|
||||
mocks.app,
|
||||
mocks.config,
|
||||
mocks.systemMetadata as never,
|
||||
maintenanceWorkerRepositoryMock,
|
||||
maintenanceWebsocketRepositoryMock,
|
||||
maintenanceHealthRepositoryMock,
|
||||
mocks.storage as never,
|
||||
mocks.process,
|
||||
mocks.database as never,
|
||||
);
|
||||
|
||||
sut.mock({
|
||||
active: true,
|
||||
action: MaintenanceAction.Start,
|
||||
});
|
||||
});
|
||||
|
||||
it('should work', () => {
|
||||
@@ -27,14 +53,43 @@ describe(MaintenanceWorkerService.name, () => {
|
||||
});
|
||||
|
||||
describe('getSystemConfig', () => {
|
||||
it('should respond the server is in maintenance mode', async () => {
|
||||
await expect(sut.getSystemConfig()).resolves.toMatchObject(
|
||||
it('should respond the server is in maintenance mode', () => {
|
||||
expect(sut.getSystemConfig()).toMatchObject(
|
||||
expect.objectContaining({
|
||||
maintenanceMode: true,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(mocks.systemMetadata.get).toHaveBeenCalled();
|
||||
expect(mocks.systemMetadata.get).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe.skip('ssr');
|
||||
describe.skip('detectMediaLocation');
|
||||
|
||||
describe('setStatus', () => {
|
||||
it('should broadcast status', () => {
|
||||
sut.setStatus({
|
||||
active: true,
|
||||
action: MaintenanceAction.Start,
|
||||
task: 'abc',
|
||||
error: 'def',
|
||||
});
|
||||
|
||||
expect(maintenanceWebsocketRepositoryMock.serverSend).toHaveBeenCalled();
|
||||
expect(maintenanceWebsocketRepositoryMock.clientSend).toHaveBeenCalledTimes(2);
|
||||
expect(maintenanceWebsocketRepositoryMock.clientSend).toHaveBeenCalledWith('MaintenanceStatusV1', 'private', {
|
||||
active: true,
|
||||
action: 'start',
|
||||
task: 'abc',
|
||||
error: 'def',
|
||||
});
|
||||
expect(maintenanceWebsocketRepositoryMock.clientSend).toHaveBeenCalledWith('MaintenanceStatusV1', 'public', {
|
||||
active: true,
|
||||
action: 'start',
|
||||
task: 'abc',
|
||||
error: 'Something went wrong, see logs!',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -42,7 +97,14 @@ describe(MaintenanceWorkerService.name, () => {
|
||||
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' });
|
||||
mocks.systemMetadata.get.mockResolvedValue({
|
||||
isMaintenanceMode: true,
|
||||
secret: 'secret',
|
||||
action: {
|
||||
action: MaintenanceAction.Start,
|
||||
},
|
||||
});
|
||||
|
||||
await expect(sut.logSecret()).resolves.toBeUndefined();
|
||||
expect(mocks.logger.log).toHaveBeenCalledWith(expect.stringMatching(RE_LOGIN_URL));
|
||||
|
||||
@@ -63,7 +125,13 @@ describe(MaintenanceWorkerService.name, () => {
|
||||
});
|
||||
|
||||
it('should parse cookie properly', async () => {
|
||||
mocks.systemMetadata.get.mockResolvedValue({ isMaintenanceMode: true, secret: 'secret' });
|
||||
mocks.systemMetadata.get.mockResolvedValue({
|
||||
isMaintenanceMode: true,
|
||||
secret: 'secret',
|
||||
action: {
|
||||
action: MaintenanceAction.Start,
|
||||
},
|
||||
});
|
||||
|
||||
await expect(
|
||||
sut.authenticate({
|
||||
@@ -73,13 +141,102 @@ describe(MaintenanceWorkerService.name, () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('status', () => {
|
||||
beforeEach(() => {
|
||||
sut.mock({
|
||||
active: true,
|
||||
action: MaintenanceAction.Start,
|
||||
error: 'secret value!',
|
||||
});
|
||||
});
|
||||
|
||||
it('generates private status', async () => {
|
||||
const jwt = await new SignJWT({ _mockValue: true })
|
||||
.setProtectedHeader({ alg: 'HS256' })
|
||||
.setIssuedAt()
|
||||
.setExpirationTime('4h')
|
||||
.sign(new TextEncoder().encode('secret'));
|
||||
|
||||
await expect(sut.status(jwt)).resolves.toEqual(
|
||||
expect.objectContaining({
|
||||
error: 'secret value!',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('generates public status', async () => {
|
||||
await expect(sut.status()).resolves.toEqual(
|
||||
expect.objectContaining({
|
||||
error: 'Something went wrong, see logs!',
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('detectPriorInstall', () => {
|
||||
it('generate report about prior installation', async () => {
|
||||
mocks.storage.readdir.mockResolvedValue(['.immich', 'file1', 'file2']);
|
||||
mocks.storage.readFile.mockResolvedValue(undefined as never);
|
||||
mocks.storage.overwriteFile.mockRejectedValue(undefined as never);
|
||||
|
||||
await expect(sut.detectPriorInstall()).resolves.toMatchInlineSnapshot(`
|
||||
{
|
||||
"storage": [
|
||||
{
|
||||
"files": 2,
|
||||
"folder": "encoded-video",
|
||||
"readable": true,
|
||||
"writable": false,
|
||||
},
|
||||
{
|
||||
"files": 2,
|
||||
"folder": "library",
|
||||
"readable": true,
|
||||
"writable": false,
|
||||
},
|
||||
{
|
||||
"files": 2,
|
||||
"folder": "upload",
|
||||
"readable": true,
|
||||
"writable": false,
|
||||
},
|
||||
{
|
||||
"files": 2,
|
||||
"folder": "profile",
|
||||
"readable": true,
|
||||
"writable": false,
|
||||
},
|
||||
{
|
||||
"files": 2,
|
||||
"folder": "thumbs",
|
||||
"readable": true,
|
||||
"writable": false,
|
||||
},
|
||||
{
|
||||
"files": 2,
|
||||
"folder": "backups",
|
||||
"readable": true,
|
||||
"writable": false,
|
||||
},
|
||||
],
|
||||
}
|
||||
`);
|
||||
});
|
||||
});
|
||||
|
||||
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' });
|
||||
mocks.systemMetadata.get.mockResolvedValue({
|
||||
isMaintenanceMode: true,
|
||||
secret: 'secret',
|
||||
action: {
|
||||
action: MaintenanceAction.Start,
|
||||
},
|
||||
});
|
||||
|
||||
const jwt = await new SignJWT({})
|
||||
.setProtectedHeader({ alg: 'HS256' })
|
||||
@@ -91,7 +248,13 @@ describe(MaintenanceWorkerService.name, () => {
|
||||
});
|
||||
|
||||
it('should succeed with valid JWT', async () => {
|
||||
mocks.systemMetadata.get.mockResolvedValue({ isMaintenanceMode: true, secret: 'secret' });
|
||||
mocks.systemMetadata.get.mockResolvedValue({
|
||||
isMaintenanceMode: true,
|
||||
secret: 'secret',
|
||||
action: {
|
||||
action: MaintenanceAction.Start,
|
||||
},
|
||||
});
|
||||
|
||||
const jwt = await new SignJWT({ _mockValue: true })
|
||||
.setProtectedHeader({ alg: 'HS256' })
|
||||
@@ -107,22 +270,275 @@ describe(MaintenanceWorkerService.name, () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('endMaintenance', () => {
|
||||
describe.skip('setAction'); // just calls setStatus+runAction
|
||||
|
||||
/**
|
||||
* Actions
|
||||
*/
|
||||
|
||||
describe('action: start', () => {
|
||||
it('should not do anything', async () => {
|
||||
await sut.runAction({
|
||||
action: MaintenanceAction.Start,
|
||||
});
|
||||
|
||||
expect(mocks.logger.log).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('action: end', () => {
|
||||
it('should set maintenance mode', async () => {
|
||||
mocks.systemMetadata.get.mockResolvedValue({ isMaintenanceMode: false });
|
||||
await expect(sut.endMaintenance()).resolves.toBeUndefined();
|
||||
await sut.runAction({
|
||||
action: MaintenanceAction.End,
|
||||
});
|
||||
|
||||
expect(mocks.systemMetadata.set).toHaveBeenCalledWith(SystemMetadataKey.MaintenanceMode, {
|
||||
isMaintenanceMode: false,
|
||||
});
|
||||
|
||||
expect(maintenanceWorkerRepositoryMock.clientBroadcast).toHaveBeenCalledWith('AppRestartV1', {
|
||||
expect(maintenanceWebsocketRepositoryMock.clientBroadcast).toHaveBeenCalledWith('AppRestartV1', {
|
||||
isMaintenanceMode: false,
|
||||
});
|
||||
|
||||
expect(maintenanceWorkerRepositoryMock.serverSend).toHaveBeenCalledWith('AppRestart', {
|
||||
expect(maintenanceWebsocketRepositoryMock.serverSend).toHaveBeenCalledWith('AppRestart', {
|
||||
isMaintenanceMode: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('action: restore database', () => {
|
||||
beforeEach(() => {
|
||||
mocks.database.tryLock.mockResolvedValueOnce(true);
|
||||
|
||||
mocks.storage.readdir.mockResolvedValue([]);
|
||||
mocks.process.spawn.mockReturnValue(mockSpawn(0, 'data', ''));
|
||||
mocks.process.spawnDuplexStream.mockImplementation(() => mockDuplex('command', 0, 'data', ''));
|
||||
mocks.process.fork.mockImplementation(() => mockSpawn(0, 'Immich Server is listening', ''));
|
||||
mocks.storage.rename.mockResolvedValue();
|
||||
mocks.storage.unlink.mockResolvedValue();
|
||||
mocks.storage.createPlainReadStream.mockReturnValue(Readable.from(mockData()));
|
||||
mocks.storage.createWriteStream.mockReturnValue(new PassThrough());
|
||||
mocks.storage.createGzip.mockReturnValue(new PassThrough());
|
||||
mocks.storage.createGunzip.mockReturnValue(new PassThrough());
|
||||
});
|
||||
|
||||
it('should update maintenance mode state', async () => {
|
||||
await sut.runAction({
|
||||
action: MaintenanceAction.RestoreDatabase,
|
||||
restoreBackupFilename: 'filename',
|
||||
});
|
||||
|
||||
expect(mocks.database.tryLock).toHaveBeenCalled();
|
||||
expect(mocks.logger.log).toHaveBeenCalledWith('Running maintenance action restore_database');
|
||||
|
||||
expect(mocks.systemMetadata.set).toHaveBeenCalledWith(SystemMetadataKey.MaintenanceMode, {
|
||||
isMaintenanceMode: true,
|
||||
secret: 'secret',
|
||||
action: {
|
||||
action: 'start',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should fail to restore invalid backup', async () => {
|
||||
await sut.runAction({
|
||||
action: MaintenanceAction.RestoreDatabase,
|
||||
restoreBackupFilename: 'filename',
|
||||
});
|
||||
|
||||
expect(maintenanceWebsocketRepositoryMock.clientSend).toHaveBeenCalledWith('MaintenanceStatusV1', 'private', {
|
||||
active: true,
|
||||
action: MaintenanceAction.RestoreDatabase,
|
||||
error: 'Error: Invalid backup file format!',
|
||||
task: 'error',
|
||||
});
|
||||
});
|
||||
|
||||
it('should successfully run a backup', async () => {
|
||||
await sut.runAction({
|
||||
action: MaintenanceAction.RestoreDatabase,
|
||||
restoreBackupFilename: 'development-filename.sql',
|
||||
});
|
||||
|
||||
expect(maintenanceWebsocketRepositoryMock.clientSend).toHaveBeenCalledWith(
|
||||
'MaintenanceStatusV1',
|
||||
expect.any(String),
|
||||
{
|
||||
active: true,
|
||||
action: MaintenanceAction.RestoreDatabase,
|
||||
task: 'ready',
|
||||
progress: expect.any(Number),
|
||||
},
|
||||
);
|
||||
|
||||
expect(maintenanceWebsocketRepositoryMock.clientSend).toHaveBeenLastCalledWith(
|
||||
'MaintenanceStatusV1',
|
||||
expect.any(String),
|
||||
{
|
||||
active: true,
|
||||
action: 'end',
|
||||
},
|
||||
);
|
||||
|
||||
expect(maintenanceHealthRepositoryMock.checkApiHealth).toHaveBeenCalled();
|
||||
expect(mocks.process.spawnDuplexStream).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it('should fail if backup creation fails', async () => {
|
||||
mocks.process.spawnDuplexStream.mockReturnValueOnce(mockDuplex('pg_dump', 1, '', 'error'));
|
||||
|
||||
await sut.runAction({
|
||||
action: MaintenanceAction.RestoreDatabase,
|
||||
restoreBackupFilename: 'development-filename.sql',
|
||||
});
|
||||
|
||||
expect(maintenanceWebsocketRepositoryMock.clientSend).toHaveBeenCalledWith('MaintenanceStatusV1', 'private', {
|
||||
active: true,
|
||||
action: MaintenanceAction.RestoreDatabase,
|
||||
error: 'Error: pg_dump non-zero exit code (1)\nerror',
|
||||
task: 'error',
|
||||
});
|
||||
|
||||
expect(maintenanceWebsocketRepositoryMock.clientSend).toHaveBeenLastCalledWith(
|
||||
'MaintenanceStatusV1',
|
||||
expect.any(String),
|
||||
expect.objectContaining({
|
||||
task: 'error',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should fail if restore itself fails', async () => {
|
||||
mocks.process.spawnDuplexStream
|
||||
.mockReturnValueOnce(mockDuplex('pg_dump', 0, 'data', ''))
|
||||
.mockReturnValueOnce(mockDuplex('gzip', 0, 'data', ''))
|
||||
.mockReturnValueOnce(mockDuplex('psql', 1, '', 'error'));
|
||||
|
||||
await sut.runAction({
|
||||
action: MaintenanceAction.RestoreDatabase,
|
||||
restoreBackupFilename: 'development-filename.sql',
|
||||
});
|
||||
|
||||
expect(maintenanceWebsocketRepositoryMock.clientSend).toHaveBeenCalledWith('MaintenanceStatusV1', 'private', {
|
||||
active: true,
|
||||
action: MaintenanceAction.RestoreDatabase,
|
||||
error: 'Error: psql non-zero exit code (1)\nerror',
|
||||
task: 'error',
|
||||
});
|
||||
|
||||
expect(maintenanceWebsocketRepositoryMock.clientSend).toHaveBeenLastCalledWith(
|
||||
'MaintenanceStatusV1',
|
||||
expect.any(String),
|
||||
expect.objectContaining({
|
||||
task: 'error',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should rollback if database migrations fail', async () => {
|
||||
mocks.database.runMigrations.mockRejectedValue(new Error('Migrations Error'));
|
||||
|
||||
await sut.runAction({
|
||||
action: MaintenanceAction.RestoreDatabase,
|
||||
restoreBackupFilename: 'development-filename.sql',
|
||||
});
|
||||
|
||||
expect(maintenanceWebsocketRepositoryMock.clientSend).toHaveBeenCalledWith('MaintenanceStatusV1', 'private', {
|
||||
active: true,
|
||||
action: MaintenanceAction.RestoreDatabase,
|
||||
error: 'Error: Migrations Error',
|
||||
task: 'error',
|
||||
});
|
||||
|
||||
expect(maintenanceHealthRepositoryMock.checkApiHealth).toHaveBeenCalledTimes(0);
|
||||
expect(mocks.process.spawnDuplexStream).toHaveBeenCalledTimes(4);
|
||||
});
|
||||
|
||||
it('should rollback if API healthcheck fails', async () => {
|
||||
maintenanceHealthRepositoryMock.checkApiHealth.mockRejectedValue(new Error('Health Error'));
|
||||
|
||||
await sut.runAction({
|
||||
action: MaintenanceAction.RestoreDatabase,
|
||||
restoreBackupFilename: 'development-filename.sql',
|
||||
});
|
||||
|
||||
expect(maintenanceWebsocketRepositoryMock.clientSend).toHaveBeenCalledWith('MaintenanceStatusV1', 'private', {
|
||||
active: true,
|
||||
action: MaintenanceAction.RestoreDatabase,
|
||||
error: 'Error: Health Error',
|
||||
task: 'error',
|
||||
});
|
||||
|
||||
expect(maintenanceHealthRepositoryMock.checkApiHealth).toHaveBeenCalled();
|
||||
expect(mocks.process.spawnDuplexStream).toHaveBeenCalledTimes(4);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Backups
|
||||
*/
|
||||
|
||||
describe('listBackups', () => {
|
||||
it('should give us all backups', async () => {
|
||||
mocks.storage.readdir.mockResolvedValue([
|
||||
`immich-db-backup-${DateTime.fromISO('2025-07-25T11:02:16Z').toFormat("yyyyLLdd'T'HHmmss")}-v1.234.5-pg14.5.sql.gz.tmp`,
|
||||
`immich-db-backup-${DateTime.fromISO('2025-07-27T11:01:16Z').toFormat("yyyyLLdd'T'HHmmss")}-v1.234.5-pg14.5.sql.gz`,
|
||||
'immich-db-backup-1753789649000.sql.gz',
|
||||
`immich-db-backup-${DateTime.fromISO('2025-07-29T11:01:16Z').toFormat("yyyyLLdd'T'HHmmss")}-v1.234.5-pg14.5.sql.gz`,
|
||||
]);
|
||||
mocks.storage.stat.mockResolvedValue({ size: 1024 } as any);
|
||||
|
||||
await expect(sut.listBackups()).resolves.toMatchObject({
|
||||
backups: [
|
||||
{ filename: 'immich-db-backup-20250729T110116-v1.234.5-pg14.5.sql.gz', filesize: 1024 },
|
||||
{ filename: 'immich-db-backup-20250727T110116-v1.234.5-pg14.5.sql.gz', filesize: 1024 },
|
||||
{ filename: 'immich-db-backup-1753789649000.sql.gz', filesize: 1024 },
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteBackup', () => {
|
||||
it('should reject invalid file names', async () => {
|
||||
await expect(sut.deleteBackup(['filename'])).rejects.toThrowError(
|
||||
new BadRequestException('Invalid backup name!'),
|
||||
);
|
||||
});
|
||||
|
||||
it('should unlink the target file', async () => {
|
||||
await sut.deleteBackup(['filename.sql']);
|
||||
expect(mocks.storage.unlink).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.storage.unlink).toHaveBeenCalledWith(
|
||||
`${StorageCore.getBaseFolder(StorageFolder.Backups)}/filename.sql`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('uploadBackup', () => {
|
||||
it('should reject invalid file names', async () => {
|
||||
await expect(sut.uploadBackup({ originalname: 'invalid backup' } as never)).rejects.toThrowError(
|
||||
new BadRequestException('Invalid backup name!'),
|
||||
);
|
||||
});
|
||||
|
||||
it('should write file', async () => {
|
||||
await sut.uploadBackup({ originalname: 'path.sql.gz', buffer: 'buffer' } as never);
|
||||
expect(mocks.storage.createOrOverwriteFile).toBeCalledWith('/data/backups/uploaded-path.sql.gz', 'buffer');
|
||||
});
|
||||
});
|
||||
|
||||
describe('downloadBackup', () => {
|
||||
it('should reject invalid file names', () => {
|
||||
expect(() => sut.downloadBackup('invalid backup')).toThrowError(new BadRequestException('Invalid backup name!'));
|
||||
});
|
||||
|
||||
it('should get backup path', () => {
|
||||
expect(sut.downloadBackup('hello.sql.gz')).toEqual(
|
||||
expect.objectContaining({
|
||||
path: '/data/backups/hello.sql.gz',
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,19 +4,41 @@ 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 { serverVersion } from 'src/constants';
|
||||
import { StorageCore } from 'src/cores/storage.core';
|
||||
import {
|
||||
MaintenanceAuthDto,
|
||||
MaintenanceDetectInstallResponseDto,
|
||||
MaintenanceStatusResponseDto,
|
||||
SetMaintenanceModeDto,
|
||||
} from 'src/dtos/maintenance.dto';
|
||||
import { ServerConfigDto, ServerVersionResponseDto } from 'src/dtos/server.dto';
|
||||
import { DatabaseLock, ImmichCookie, MaintenanceAction, SystemMetadataKey } from 'src/enum';
|
||||
import { MaintenanceHealthRepository } from 'src/maintenance/maintenance-health.repository';
|
||||
import { MaintenanceWebsocketRepository } from 'src/maintenance/maintenance-websocket.repository';
|
||||
import { AppRepository } from 'src/repositories/app.repository';
|
||||
import { ConfigRepository } from 'src/repositories/config.repository';
|
||||
import { DatabaseRepository } from 'src/repositories/database.repository';
|
||||
import { LoggingRepository } from 'src/repositories/logging.repository';
|
||||
import { ProcessRepository } from 'src/repositories/process.repository';
|
||||
import { StorageRepository } from 'src/repositories/storage.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 DatabaseBackupService as _DatabaseBackupService } from 'src/services/database-backup.service';
|
||||
import { type ServerService as _ServerService } from 'src/services/server.service';
|
||||
import { type VersionService as _VersionService } from 'src/services/version.service';
|
||||
import { MaintenanceModeState } from 'src/types';
|
||||
import { getConfig } from 'src/utils/config';
|
||||
import { createMaintenanceLoginUrl } from 'src/utils/maintenance';
|
||||
import {
|
||||
deleteDatabaseBackup,
|
||||
downloadDatabaseBackup,
|
||||
listDatabaseBackups,
|
||||
restoreDatabaseBackup,
|
||||
uploadDatabaseBackup,
|
||||
} from 'src/utils/database-backups';
|
||||
import { ImmichFileResponse } from 'src/utils/file';
|
||||
import { createMaintenanceLoginUrl, detectPriorInstall } from 'src/utils/maintenance';
|
||||
import { getExternalDomain } from 'src/utils/misc';
|
||||
|
||||
/**
|
||||
@@ -24,16 +46,51 @@ import { getExternalDomain } from 'src/utils/misc';
|
||||
*/
|
||||
@Injectable()
|
||||
export class MaintenanceWorkerService {
|
||||
#secret: string | null = null;
|
||||
#status: MaintenanceStatusResponseDto = {
|
||||
active: true,
|
||||
action: MaintenanceAction.Start,
|
||||
};
|
||||
|
||||
constructor(
|
||||
protected logger: LoggingRepository,
|
||||
private appRepository: AppRepository,
|
||||
private configRepository: ConfigRepository,
|
||||
private systemMetadataRepository: SystemMetadataRepository,
|
||||
private maintenanceWorkerRepository: MaintenanceWebsocketRepository,
|
||||
private maintenanceWebsocketRepository: MaintenanceWebsocketRepository,
|
||||
private maintenanceHealthRepository: MaintenanceHealthRepository,
|
||||
private storageRepository: StorageRepository,
|
||||
private processRepository: ProcessRepository,
|
||||
private databaseRepository: DatabaseRepository,
|
||||
) {
|
||||
this.logger.setContext(this.constructor.name);
|
||||
}
|
||||
|
||||
mock(status: MaintenanceStatusResponseDto) {
|
||||
this.#secret = 'secret';
|
||||
this.#status = status;
|
||||
}
|
||||
|
||||
async init() {
|
||||
const state = (await this.systemMetadataRepository.get(
|
||||
SystemMetadataKey.MaintenanceMode,
|
||||
)) as MaintenanceModeState & { isMaintenanceMode: true };
|
||||
|
||||
this.#secret = state.secret;
|
||||
this.#status = {
|
||||
active: true,
|
||||
action: state.action.action,
|
||||
};
|
||||
|
||||
StorageCore.setMediaLocation(this.detectMediaLocation());
|
||||
|
||||
this.maintenanceWebsocketRepository.setAuthFn(async (client) => this.authenticate(client.request.headers));
|
||||
this.maintenanceWebsocketRepository.setStatusUpdateFn((status) => (this.#status = status));
|
||||
|
||||
await this.logSecret();
|
||||
void this.runAction(state.action);
|
||||
}
|
||||
|
||||
/**
|
||||
* {@link _BaseService.configRepos}
|
||||
*/
|
||||
@@ -55,22 +112,17 @@ export class MaintenanceWorkerService {
|
||||
/**
|
||||
* {@link _ServerService.getSystemConfig}
|
||||
*/
|
||||
async getSystemConfig() {
|
||||
const config = await this.getConfig({ withCache: false });
|
||||
|
||||
getSystemConfig() {
|
||||
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,
|
||||
};
|
||||
} as ServerConfigDto;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@link _VersionService.getVersion}
|
||||
*/
|
||||
getVersion() {
|
||||
return ServerVersionResponseDto.fromSemVer(serverVersion);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -106,12 +158,99 @@ export class MaintenanceWorkerService {
|
||||
};
|
||||
}
|
||||
|
||||
private async secret(): Promise<string> {
|
||||
const state = (await this.systemMetadataRepository.get(SystemMetadataKey.MaintenanceMode)) as {
|
||||
secret: string;
|
||||
};
|
||||
/**
|
||||
* {@link _StorageService.detectMediaLocation}
|
||||
*/
|
||||
detectMediaLocation(): string {
|
||||
const envData = this.configRepository.getEnv();
|
||||
if (envData.storage.mediaLocation) {
|
||||
return envData.storage.mediaLocation;
|
||||
}
|
||||
|
||||
return state.secret;
|
||||
const targets: string[] = [];
|
||||
const candidates = ['/data', '/usr/src/app/upload'];
|
||||
|
||||
for (const candidate of candidates) {
|
||||
const exists = this.storageRepository.existsSync(candidate);
|
||||
if (exists) {
|
||||
targets.push(candidate);
|
||||
}
|
||||
}
|
||||
|
||||
if (targets.length === 1) {
|
||||
return targets[0];
|
||||
}
|
||||
|
||||
return '/usr/src/app/upload';
|
||||
}
|
||||
|
||||
/**
|
||||
* {@link _DatabaseBackupService.listBackups}
|
||||
*/
|
||||
async listBackups(): Promise<{ backups: { filename: string; filesize: number }[] }> {
|
||||
const backups = await listDatabaseBackups(this.backupRepos);
|
||||
return { backups };
|
||||
}
|
||||
|
||||
/**
|
||||
* {@link _DatabaseBackupService.deleteBackup}
|
||||
*/
|
||||
async deleteBackup(files: string[]): Promise<void> {
|
||||
return deleteDatabaseBackup(this.backupRepos, files);
|
||||
}
|
||||
|
||||
/**
|
||||
* {@link _DatabaseBackupService.uploadBackup}
|
||||
*/
|
||||
async uploadBackup(file: Express.Multer.File): Promise<void> {
|
||||
return uploadDatabaseBackup(this.backupRepos, file);
|
||||
}
|
||||
|
||||
/**
|
||||
* {@link _DatabaseBackupService.downloadBackup}
|
||||
*/
|
||||
downloadBackup(fileName: string): ImmichFileResponse {
|
||||
return downloadDatabaseBackup(fileName);
|
||||
}
|
||||
|
||||
private get secret() {
|
||||
if (!this.#secret) {
|
||||
throw new Error('Secret is not initialised yet.');
|
||||
}
|
||||
|
||||
return this.#secret;
|
||||
}
|
||||
|
||||
private get backupRepos() {
|
||||
return {
|
||||
logger: this.logger,
|
||||
storage: this.storageRepository,
|
||||
config: this.configRepository,
|
||||
process: this.processRepository,
|
||||
database: this.databaseRepository,
|
||||
health: this.maintenanceHealthRepository,
|
||||
};
|
||||
}
|
||||
|
||||
private getStatus(): MaintenanceStatusResponseDto {
|
||||
return this.#status;
|
||||
}
|
||||
|
||||
private getPublicStatus(): MaintenanceStatusResponseDto {
|
||||
const state = structuredClone(this.#status);
|
||||
|
||||
if (state.error) {
|
||||
state.error = 'Something went wrong, see logs!';
|
||||
}
|
||||
|
||||
return state;
|
||||
}
|
||||
|
||||
setStatus(status: MaintenanceStatusResponseDto): void {
|
||||
this.#status = status;
|
||||
this.maintenanceWebsocketRepository.serverSend('MaintenanceStatus', status);
|
||||
this.maintenanceWebsocketRepository.clientSend('MaintenanceStatusV1', 'private', status);
|
||||
this.maintenanceWebsocketRepository.clientSend('MaintenanceStatusV1', 'public', this.getPublicStatus());
|
||||
}
|
||||
|
||||
async logSecret(): Promise<void> {
|
||||
@@ -123,7 +262,7 @@ export class MaintenanceWorkerService {
|
||||
{
|
||||
username: 'immich-admin',
|
||||
},
|
||||
await this.secret(),
|
||||
this.secret,
|
||||
);
|
||||
|
||||
this.logger.log(`\n\n🚧 Immich is in maintenance mode, you can log in using the following URL:\n${url}\n`);
|
||||
@@ -134,28 +273,115 @@ export class MaintenanceWorkerService {
|
||||
return this.login(jwtToken);
|
||||
}
|
||||
|
||||
async status(potentiallyJwt?: string): Promise<MaintenanceStatusResponseDto> {
|
||||
try {
|
||||
await this.login(potentiallyJwt);
|
||||
return this.getStatus();
|
||||
} catch {
|
||||
return this.getPublicStatus();
|
||||
}
|
||||
}
|
||||
|
||||
detectPriorInstall(): Promise<MaintenanceDetectInstallResponseDto> {
|
||||
return detectPriorInstall(this.storageRepository);
|
||||
}
|
||||
|
||||
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));
|
||||
const result = await jwtVerify<MaintenanceAuthDto>(jwt, new TextEncoder().encode(this.secret));
|
||||
return result.payload;
|
||||
} catch {
|
||||
throw new UnauthorizedException('Invalid JWT Token');
|
||||
}
|
||||
}
|
||||
|
||||
async endMaintenance(): Promise<void> {
|
||||
async setAction(action: SetMaintenanceModeDto) {
|
||||
this.setStatus({
|
||||
active: true,
|
||||
action: action.action,
|
||||
});
|
||||
|
||||
await this.runAction(action);
|
||||
}
|
||||
|
||||
async runAction(action: SetMaintenanceModeDto) {
|
||||
switch (action.action) {
|
||||
case MaintenanceAction.Start: {
|
||||
return;
|
||||
}
|
||||
case MaintenanceAction.End: {
|
||||
return this.endMaintenance();
|
||||
}
|
||||
case MaintenanceAction.SelectDatabaseRestore: {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const lock = await this.databaseRepository.tryLock(DatabaseLock.MaintenanceOperation);
|
||||
if (!lock) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.logger.log(`Running maintenance action ${action.action}`);
|
||||
|
||||
await this.systemMetadataRepository.set(SystemMetadataKey.MaintenanceMode, {
|
||||
isMaintenanceMode: true,
|
||||
secret: this.secret,
|
||||
action: {
|
||||
action: MaintenanceAction.Start,
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
if (!action.restoreBackupFilename) {
|
||||
throw new Error("Expected restoreBackupFilename but it's missing!");
|
||||
}
|
||||
|
||||
await this.restoreBackup(action.restoreBackupFilename);
|
||||
} catch (error) {
|
||||
this.logger.error(`Encountered error running action: ${error}`);
|
||||
this.setStatus({
|
||||
active: true,
|
||||
action: action.action,
|
||||
task: 'error',
|
||||
error: '' + error,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private async restoreBackup(filename: string): Promise<void> {
|
||||
this.setStatus({
|
||||
active: true,
|
||||
action: MaintenanceAction.RestoreDatabase,
|
||||
task: 'ready',
|
||||
progress: 0,
|
||||
});
|
||||
|
||||
await restoreDatabaseBackup(this.backupRepos, filename, (task, progress) =>
|
||||
this.setStatus({
|
||||
active: true,
|
||||
action: MaintenanceAction.RestoreDatabase,
|
||||
progress,
|
||||
task,
|
||||
}),
|
||||
);
|
||||
|
||||
await this.setAction({
|
||||
action: MaintenanceAction.End,
|
||||
});
|
||||
}
|
||||
|
||||
private 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.maintenanceWebsocketRepository.clientBroadcast('AppRestartV1', state);
|
||||
this.maintenanceWebsocketRepository.serverSend('AppRestart', state);
|
||||
this.appRepository.exitApp();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,7 +29,8 @@ select
|
||||
select
|
||||
"asset_file"."id",
|
||||
"asset_file"."path",
|
||||
"asset_file"."type"
|
||||
"asset_file"."type",
|
||||
"asset_file"."isEdited"
|
||||
from
|
||||
"asset_file"
|
||||
where
|
||||
@@ -37,20 +38,6 @@ select
|
||||
and "asset_file"."type" = $1
|
||||
) as agg
|
||||
) as "files",
|
||||
(
|
||||
select
|
||||
coalesce(json_agg(agg), '[]')
|
||||
from
|
||||
(
|
||||
select
|
||||
"tag"."value"
|
||||
from
|
||||
"tag"
|
||||
inner join "tag_asset" on "tag"."id" = "tag_asset"."tagId"
|
||||
where
|
||||
"asset"."id" = "tag_asset"."assetId"
|
||||
) as agg
|
||||
) as "tags",
|
||||
to_json("asset_exif") as "exifInfo"
|
||||
from
|
||||
"asset"
|
||||
@@ -72,7 +59,8 @@ select
|
||||
select
|
||||
"asset_file"."id",
|
||||
"asset_file"."path",
|
||||
"asset_file"."type"
|
||||
"asset_file"."type",
|
||||
"asset_file"."isEdited"
|
||||
from
|
||||
"asset_file"
|
||||
where
|
||||
@@ -99,7 +87,8 @@ select
|
||||
select
|
||||
"asset_file"."id",
|
||||
"asset_file"."path",
|
||||
"asset_file"."type"
|
||||
"asset_file"."type",
|
||||
"asset_file"."isEdited"
|
||||
from
|
||||
"asset_file"
|
||||
where
|
||||
@@ -145,7 +134,8 @@ select
|
||||
select
|
||||
"asset_file"."id",
|
||||
"asset_file"."path",
|
||||
"asset_file"."type"
|
||||
"asset_file"."type",
|
||||
"asset_file"."isEdited"
|
||||
from
|
||||
"asset_file"
|
||||
where
|
||||
@@ -174,7 +164,8 @@ select
|
||||
select
|
||||
"asset_file"."id",
|
||||
"asset_file"."path",
|
||||
"asset_file"."type"
|
||||
"asset_file"."type",
|
||||
"asset_file"."isEdited"
|
||||
from
|
||||
"asset_file"
|
||||
where
|
||||
@@ -244,7 +235,8 @@ select
|
||||
select
|
||||
"asset_file"."id",
|
||||
"asset_file"."path",
|
||||
"asset_file"."type"
|
||||
"asset_file"."type",
|
||||
"asset_file"."isEdited"
|
||||
from
|
||||
"asset_file"
|
||||
where
|
||||
@@ -269,7 +261,8 @@ where
|
||||
select
|
||||
"asset_file"."id",
|
||||
"asset_file"."path",
|
||||
"asset_file"."type"
|
||||
"asset_file"."type",
|
||||
"asset_file"."isEdited"
|
||||
from
|
||||
"asset_file"
|
||||
where
|
||||
@@ -318,7 +311,8 @@ select
|
||||
select
|
||||
"asset_file"."id",
|
||||
"asset_file"."path",
|
||||
"asset_file"."type"
|
||||
"asset_file"."type",
|
||||
"asset_file"."isEdited"
|
||||
from
|
||||
"asset_file"
|
||||
where
|
||||
@@ -357,7 +351,8 @@ select
|
||||
select
|
||||
"asset_file"."id",
|
||||
"asset_file"."path",
|
||||
"asset_file"."type"
|
||||
"asset_file"."type",
|
||||
"asset_file"."isEdited"
|
||||
from
|
||||
"asset_file"
|
||||
where
|
||||
@@ -444,7 +439,8 @@ select
|
||||
select
|
||||
"asset_file"."id",
|
||||
"asset_file"."path",
|
||||
"asset_file"."type"
|
||||
"asset_file"."type",
|
||||
"asset_file"."isEdited"
|
||||
from
|
||||
"asset_file"
|
||||
where
|
||||
@@ -536,7 +532,8 @@ select
|
||||
select
|
||||
"asset_file"."id",
|
||||
"asset_file"."path",
|
||||
"asset_file"."type"
|
||||
"asset_file"."type",
|
||||
"asset_file"."isEdited"
|
||||
from
|
||||
"asset_file"
|
||||
where
|
||||
@@ -575,7 +572,8 @@ select
|
||||
select
|
||||
"asset_file"."id",
|
||||
"asset_file"."path",
|
||||
"asset_file"."type"
|
||||
"asset_file"."type",
|
||||
"asset_file"."isEdited"
|
||||
from
|
||||
"asset_file"
|
||||
where
|
||||
|
||||
@@ -286,7 +286,8 @@ select
|
||||
select
|
||||
"asset_file"."id",
|
||||
"asset_file"."path",
|
||||
"asset_file"."type"
|
||||
"asset_file"."type",
|
||||
"asset_file"."isEdited"
|
||||
from
|
||||
"asset_file"
|
||||
where
|
||||
|
||||
@@ -43,6 +43,7 @@ select
|
||||
"asset_exif"."projectionType",
|
||||
"asset_exif"."rating",
|
||||
"asset_exif"."state",
|
||||
"asset_exif"."tags",
|
||||
"asset_exif"."timeZone"
|
||||
from
|
||||
"asset_exif"
|
||||
@@ -127,6 +128,7 @@ select
|
||||
"asset_exif"."projectionType",
|
||||
"asset_exif"."rating",
|
||||
"asset_exif"."state",
|
||||
"asset_exif"."tags",
|
||||
"asset_exif"."timeZone"
|
||||
from
|
||||
"asset_exif"
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Kysely } from 'kysely';
|
||||
import { jsonArrayFrom } from 'kysely/helpers/postgres';
|
||||
import { InjectKysely } from 'nestjs-kysely';
|
||||
import { Asset, columns } from 'src/database';
|
||||
import { DummyValue, GenerateSql } from 'src/decorators';
|
||||
@@ -42,15 +41,6 @@ export class AssetJobRepository {
|
||||
.where('asset.id', '=', asUuid(id))
|
||||
.select(['id', 'originalPath'])
|
||||
.select((eb) => withFiles(eb, AssetFileType.Sidecar))
|
||||
.select((eb) =>
|
||||
jsonArrayFrom(
|
||||
eb
|
||||
.selectFrom('tag')
|
||||
.select(['tag.value'])
|
||||
.innerJoin('tag_asset', 'tag.id', 'tag_asset.tagId')
|
||||
.whereRef('asset.id', '=', 'tag_asset.assetId'),
|
||||
).as('tags'),
|
||||
)
|
||||
.$call(withExifInner)
|
||||
.limit(1)
|
||||
.executeTakeFirst();
|
||||
|
||||
@@ -178,6 +178,7 @@ export class AssetRepository {
|
||||
bitsPerSample: ref('bitsPerSample'),
|
||||
rating: ref('rating'),
|
||||
fps: ref('fps'),
|
||||
tags: ref('tags'),
|
||||
lockedProperties:
|
||||
lockedPropertiesBehavior === 'append'
|
||||
? distinctLocked(eb, exif.lockedProperties ?? null)
|
||||
@@ -903,20 +904,22 @@ export class AssetRepository {
|
||||
.execute();
|
||||
}
|
||||
|
||||
async upsertFile(file: Pick<Insertable<AssetFileTable>, 'assetId' | 'path' | 'type'>): Promise<void> {
|
||||
async upsertFile(file: Pick<Insertable<AssetFileTable>, 'assetId' | 'path' | 'type' | 'isEdited'>): Promise<void> {
|
||||
const value = { ...file, assetId: asUuid(file.assetId) };
|
||||
await this.db
|
||||
.insertInto('asset_file')
|
||||
.values(value)
|
||||
.onConflict((oc) =>
|
||||
oc.columns(['assetId', 'type']).doUpdateSet((eb) => ({
|
||||
oc.columns(['assetId', 'type', 'isEdited']).doUpdateSet((eb) => ({
|
||||
path: eb.ref('excluded.path'),
|
||||
})),
|
||||
)
|
||||
.execute();
|
||||
}
|
||||
|
||||
async upsertFiles(files: Pick<Insertable<AssetFileTable>, 'assetId' | 'path' | 'type'>[]): Promise<void> {
|
||||
async upsertFiles(
|
||||
files: Pick<Insertable<AssetFileTable>, 'assetId' | 'path' | 'type' | 'isEdited'>[],
|
||||
): Promise<void> {
|
||||
if (files.length === 0) {
|
||||
return;
|
||||
}
|
||||
@@ -926,7 +929,7 @@ export class AssetRepository {
|
||||
.insertInto('asset_file')
|
||||
.values(values)
|
||||
.onConflict((oc) =>
|
||||
oc.columns(['assetId', 'type']).doUpdateSet((eb) => ({
|
||||
oc.columns(['assetId', 'type', 'isEdited']).doUpdateSet((eb) => ({
|
||||
path: eb.ref('excluded.path'),
|
||||
})),
|
||||
)
|
||||
|
||||
85
server/src/repositories/process.repository.spec.ts
Normal file
85
server/src/repositories/process.repository.spec.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import { ChildProcessWithoutNullStreams } from 'node:child_process';
|
||||
import { Readable, Writable } from 'node:stream';
|
||||
import { pipeline } from 'node:stream/promises';
|
||||
import { ProcessRepository } from 'src/repositories/process.repository';
|
||||
|
||||
function* data() {
|
||||
yield 'Hello, world!';
|
||||
}
|
||||
|
||||
describe(ProcessRepository.name, () => {
|
||||
let sut: ProcessRepository;
|
||||
let sink: Writable;
|
||||
|
||||
beforeAll(() => {
|
||||
sut = new ProcessRepository();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
sink = new Writable({
|
||||
write(_chunk, _encoding, callback) {
|
||||
callback();
|
||||
},
|
||||
|
||||
final(callback) {
|
||||
callback();
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
describe('createSpawnDuplexStream', () => {
|
||||
it('should work (drain to stdout)', async () => {
|
||||
const process = sut.spawnDuplexStream('bash', ['-c', 'exit 0']);
|
||||
await pipeline(process, sink);
|
||||
});
|
||||
|
||||
it('should throw on non-zero exit code', async () => {
|
||||
const process = sut.spawnDuplexStream('bash', ['-c', 'echo "error message" >&2; exit 1']);
|
||||
await expect(pipeline(process, sink)).rejects.toThrowErrorMatchingInlineSnapshot(`
|
||||
[Error: bash non-zero exit code (1)
|
||||
error message
|
||||
]
|
||||
`);
|
||||
});
|
||||
|
||||
it('should accept stdin / output stdout', async () => {
|
||||
let output = '';
|
||||
const sink = new Writable({
|
||||
write(chunk, _encoding, callback) {
|
||||
output += chunk;
|
||||
callback();
|
||||
},
|
||||
|
||||
final(callback) {
|
||||
callback();
|
||||
},
|
||||
});
|
||||
|
||||
const echoProcess = sut.spawnDuplexStream('cat');
|
||||
await pipeline(Readable.from(data()), echoProcess, sink);
|
||||
expect(output).toBe('Hello, world!');
|
||||
});
|
||||
|
||||
it('should drain stdin on process exit', async () => {
|
||||
let resolve1: () => void;
|
||||
let resolve2: () => void;
|
||||
const promise1 = new Promise<void>((r) => (resolve1 = r));
|
||||
const promise2 = new Promise<void>((r) => (resolve2 = r));
|
||||
|
||||
async function* data() {
|
||||
yield 'Hello, world!';
|
||||
await promise1;
|
||||
await promise2;
|
||||
yield 'Write after stdin close / process exit!';
|
||||
}
|
||||
|
||||
const process = sut.spawnDuplexStream('bash', ['-c', 'exit 0']);
|
||||
|
||||
const realProcess = (process as never as { _process: ChildProcessWithoutNullStreams })._process;
|
||||
realProcess.on('close', () => setImmediate(() => resolve1()));
|
||||
realProcess.stdin.on('close', () => setImmediate(() => resolve2()));
|
||||
|
||||
await pipeline(Readable.from(data()), process);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,9 +1,110 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { ChildProcessWithoutNullStreams, spawn, SpawnOptionsWithoutStdio } from 'node:child_process';
|
||||
import { ChildProcessWithoutNullStreams, fork, spawn, SpawnOptionsWithoutStdio } from 'node:child_process';
|
||||
import { Duplex } from 'node:stream';
|
||||
|
||||
@Injectable()
|
||||
export class ProcessRepository {
|
||||
spawn(command: string, args: readonly string[], options?: SpawnOptionsWithoutStdio): ChildProcessWithoutNullStreams {
|
||||
spawn(command: string, args?: readonly string[], options?: SpawnOptionsWithoutStdio): ChildProcessWithoutNullStreams {
|
||||
return spawn(command, args, options);
|
||||
}
|
||||
|
||||
spawnDuplexStream(command: string, args?: readonly string[], options?: SpawnOptionsWithoutStdio): Duplex {
|
||||
let stdinClosed = false;
|
||||
let drainCallback: undefined | (() => void);
|
||||
|
||||
const process = this.spawn(command, args, options);
|
||||
const duplex = new Duplex({
|
||||
// duplex -> stdin
|
||||
write(chunk, encoding, callback) {
|
||||
// drain the input if process dies
|
||||
if (stdinClosed) {
|
||||
return callback();
|
||||
}
|
||||
|
||||
// handle stream backpressure
|
||||
if (process.stdin.write(chunk, encoding)) {
|
||||
callback();
|
||||
} else {
|
||||
drainCallback = callback;
|
||||
process.stdin.once('drain', () => {
|
||||
drainCallback = undefined;
|
||||
callback();
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
read() {
|
||||
// no-op
|
||||
},
|
||||
|
||||
final(callback) {
|
||||
if (stdinClosed) {
|
||||
callback();
|
||||
} else {
|
||||
process.stdin.end(callback);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
// stdout -> duplex
|
||||
process.stdout.on('data', (chunk) => {
|
||||
// handle stream backpressure
|
||||
if (!duplex.push(chunk)) {
|
||||
process.stdout.pause();
|
||||
}
|
||||
});
|
||||
|
||||
duplex.on('resume', () => process.stdout.resume());
|
||||
|
||||
// end handling
|
||||
let stdoutClosed = false;
|
||||
function close(error?: Error) {
|
||||
stdinClosed = true;
|
||||
|
||||
if (error) {
|
||||
duplex.destroy(error);
|
||||
} else if (stdoutClosed && typeof process.exitCode === 'number') {
|
||||
duplex.push(null);
|
||||
}
|
||||
}
|
||||
|
||||
process.stdout.on('close', () => {
|
||||
stdoutClosed = true;
|
||||
close();
|
||||
});
|
||||
|
||||
// error handling
|
||||
process.on('error', close);
|
||||
process.stdout.on('error', close);
|
||||
process.stdin.on('error', (error) => {
|
||||
if ((error as { code?: 'EPIPE' })?.code === 'EPIPE') {
|
||||
try {
|
||||
drainCallback!();
|
||||
} catch (error) {
|
||||
close(error as Error);
|
||||
}
|
||||
} else {
|
||||
close(error);
|
||||
}
|
||||
});
|
||||
|
||||
let stderr = '';
|
||||
process.stderr.on('data', (chunk) => (stderr += chunk));
|
||||
|
||||
process.on('exit', (code) => {
|
||||
console.info(`${command} exited (${code})`);
|
||||
|
||||
if (code === 0) {
|
||||
close();
|
||||
} else {
|
||||
close(new Error(`${command} non-zero exit code (${code})\n${stderr}`));
|
||||
}
|
||||
});
|
||||
|
||||
return Object.assign(duplex, { _process: process });
|
||||
}
|
||||
|
||||
fork(...args: Parameters<typeof fork>): ReturnType<typeof fork> {
|
||||
return fork(...args);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,8 @@ import { escapePath, glob, globStream } from 'fast-glob';
|
||||
import { constants, createReadStream, createWriteStream, existsSync, mkdirSync, ReadOptionsWithBuffer } from 'node:fs';
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { Readable, Writable } from 'node:stream';
|
||||
import { PassThrough, Readable, Writable } from 'node:stream';
|
||||
import { createGunzip, createGzip } from 'node:zlib';
|
||||
import { CrawlOptionsDto, WalkOptionsDto } from 'src/dtos/library.dto';
|
||||
import { LoggingRepository } from 'src/repositories/logging.repository';
|
||||
import { mimeTypes } from 'src/utils/mime-types';
|
||||
@@ -93,6 +94,18 @@ export class StorageRepository {
|
||||
return { stream: archive, addFile, finalize };
|
||||
}
|
||||
|
||||
createGzip(): PassThrough {
|
||||
return createGzip();
|
||||
}
|
||||
|
||||
createGunzip(): PassThrough {
|
||||
return createGunzip();
|
||||
}
|
||||
|
||||
createPlainReadStream(filepath: string): Readable {
|
||||
return createReadStream(filepath);
|
||||
}
|
||||
|
||||
async createReadStream(filepath: string, mimeType?: string | null): Promise<ImmichReadStream> {
|
||||
const { size } = await fs.stat(filepath);
|
||||
await fs.access(filepath, constants.R_OK);
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import { Kysely, sql } from 'kysely';
|
||||
|
||||
export async function up(db: Kysely<any>): Promise<void> {
|
||||
await sql`ALTER TABLE "asset_file" DROP CONSTRAINT "asset_file_assetId_type_uq";`.execute(db);
|
||||
await sql`ALTER TABLE "asset_file" ADD "isEdited" boolean NOT NULL DEFAULT false;`.execute(db);
|
||||
await sql`ALTER TABLE "asset_file" ADD CONSTRAINT "asset_file_assetId_type_isEdited_uq" UNIQUE ("assetId", "type", "isEdited");`.execute(db);
|
||||
}
|
||||
|
||||
export async function down(db: Kysely<any>): Promise<void> {
|
||||
await sql`ALTER TABLE "asset_file" DROP CONSTRAINT "asset_file_assetId_type_isEdited_uq";`.execute(db);
|
||||
await sql`ALTER TABLE "asset_file" ADD CONSTRAINT "asset_file_assetId_type_uq" UNIQUE ("assetId", "type");`.execute(db);
|
||||
await sql`ALTER TABLE "asset_file" DROP COLUMN "isEdited";`.execute(db);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { Kysely, sql } from 'kysely';
|
||||
|
||||
export async function up(db: Kysely<any>): Promise<void> {
|
||||
await sql`ALTER TABLE "asset_exif" ADD "tags" character varying[];`.execute(db);
|
||||
}
|
||||
|
||||
export async function down(db: Kysely<any>): Promise<void> {
|
||||
await sql`ALTER TABLE "asset_exif" DROP COLUMN "tags";`.execute(db);
|
||||
}
|
||||
@@ -93,6 +93,9 @@ export class AssetExifTable {
|
||||
@Column({ type: 'integer', nullable: true })
|
||||
rating!: number | null;
|
||||
|
||||
@Column({ type: 'character varying', array: true, nullable: true })
|
||||
tags!: string[] | null;
|
||||
|
||||
@UpdateDateColumn({ default: () => 'clock_timestamp()' })
|
||||
updatedAt!: Generated<Date>;
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
} from 'src/sql-tools';
|
||||
|
||||
@Table('asset_file')
|
||||
@Unique({ columns: ['assetId', 'type'] })
|
||||
@Unique({ columns: ['assetId', 'type', 'isEdited'] })
|
||||
@UpdatedAtTrigger('asset_file_updatedAt')
|
||||
export class AssetFileTable {
|
||||
@PrimaryGeneratedColumn()
|
||||
@@ -37,4 +37,7 @@ export class AssetFileTable {
|
||||
|
||||
@UpdateIdColumn({ index: true })
|
||||
updateId!: Generated<string>;
|
||||
|
||||
@Column({ type: 'boolean', default: false })
|
||||
isEdited!: Generated<boolean>;
|
||||
}
|
||||
|
||||
@@ -529,9 +529,10 @@ describe(AssetMediaService.name, () => {
|
||||
...assetStub.withCropEdit.files,
|
||||
{
|
||||
id: 'edited-file',
|
||||
type: AssetFileType.FullSizeEdited,
|
||||
type: AssetFileType.FullSize,
|
||||
path: '/uploads/user-id/fullsize/edited.jpg',
|
||||
} as AssetFile,
|
||||
isEdited: true,
|
||||
},
|
||||
],
|
||||
};
|
||||
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1']));
|
||||
@@ -554,9 +555,10 @@ describe(AssetMediaService.name, () => {
|
||||
...assetStub.withCropEdit.files,
|
||||
{
|
||||
id: 'edited-file',
|
||||
type: AssetFileType.FullSizeEdited,
|
||||
type: AssetFileType.FullSize,
|
||||
path: '/uploads/user-id/fullsize/edited.jpg',
|
||||
} as AssetFile,
|
||||
isEdited: true,
|
||||
},
|
||||
],
|
||||
};
|
||||
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1']));
|
||||
@@ -579,9 +581,10 @@ describe(AssetMediaService.name, () => {
|
||||
...assetStub.withCropEdit.files,
|
||||
{
|
||||
id: 'edited-file',
|
||||
type: AssetFileType.FullSizeEdited,
|
||||
type: AssetFileType.FullSize,
|
||||
path: '/uploads/user-id/fullsize/edited.jpg',
|
||||
} as AssetFile,
|
||||
isEdited: true,
|
||||
},
|
||||
],
|
||||
};
|
||||
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1']));
|
||||
@@ -656,6 +659,7 @@ describe(AssetMediaService.name, () => {
|
||||
id: '42',
|
||||
path: '/path/to/preview',
|
||||
type: AssetFileType.Thumbnail,
|
||||
isEdited: false,
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -673,6 +677,7 @@ describe(AssetMediaService.name, () => {
|
||||
id: '42',
|
||||
path: '/path/to/preview.jpg',
|
||||
type: AssetFileType.Preview,
|
||||
isEdited: false,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
@@ -4,7 +4,7 @@ import { DateTime, Duration } from 'luxon';
|
||||
import { JOBS_ASSET_PAGINATION_SIZE } from 'src/constants';
|
||||
import { AssetFile } from 'src/database';
|
||||
import { OnJob } from 'src/decorators';
|
||||
import { AssetResponseDto, MapAsset, SanitizedAssetResponseDto, mapAsset } from 'src/dtos/asset-response.dto';
|
||||
import { AssetResponseDto, SanitizedAssetResponseDto, mapAsset } from 'src/dtos/asset-response.dto';
|
||||
import {
|
||||
AssetBulkDeleteDto,
|
||||
AssetBulkUpdateDto,
|
||||
@@ -112,7 +112,7 @@ export class AssetService extends BaseService {
|
||||
const { description, dateTimeOriginal, latitude, longitude, rating, ...rest } = dto;
|
||||
const repos = { asset: this.assetRepository, event: this.eventRepository };
|
||||
|
||||
let previousMotion: MapAsset | null = null;
|
||||
let previousMotion: { id: string } | null = null;
|
||||
if (rest.livePhotoVideoId) {
|
||||
await onBeforeLink(repos, { userId: auth.user.id, livePhotoVideoId: rest.livePhotoVideoId });
|
||||
} else if (rest.livePhotoVideoId === null) {
|
||||
|
||||
@@ -5,7 +5,7 @@ import { StorageCore } from 'src/cores/storage.core';
|
||||
import { ImmichWorker, JobStatus, StorageFolder } from 'src/enum';
|
||||
import { BackupService } from 'src/services/backup.service';
|
||||
import { systemConfigStub } from 'test/fixtures/system-config.stub';
|
||||
import { mockSpawn, newTestService, ServiceMocks } from 'test/utils';
|
||||
import { mockDuplex, mockSpawn, newTestService, ServiceMocks } from 'test/utils';
|
||||
import { describe } from 'vitest';
|
||||
|
||||
describe(BackupService.name, () => {
|
||||
@@ -147,6 +147,7 @@ describe(BackupService.name, () => {
|
||||
beforeEach(() => {
|
||||
mocks.storage.readdir.mockResolvedValue([]);
|
||||
mocks.process.spawn.mockReturnValue(mockSpawn(0, 'data', ''));
|
||||
mocks.process.spawnDuplexStream.mockImplementation(() => mockDuplex('command', 0, 'data', ''));
|
||||
mocks.storage.rename.mockResolvedValue();
|
||||
mocks.storage.unlink.mockResolvedValue();
|
||||
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.backupEnabled);
|
||||
@@ -165,7 +166,7 @@ describe(BackupService.name, () => {
|
||||
({ sut, mocks } = newTestService(BackupService, { config: configMock }));
|
||||
|
||||
mocks.storage.readdir.mockResolvedValue([]);
|
||||
mocks.process.spawn.mockReturnValue(mockSpawn(0, 'data', ''));
|
||||
mocks.process.spawnDuplexStream.mockImplementation(() => mockDuplex('command', 0, 'data', ''));
|
||||
mocks.storage.rename.mockResolvedValue();
|
||||
mocks.storage.unlink.mockResolvedValue();
|
||||
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.backupEnabled);
|
||||
@@ -174,14 +175,16 @@ describe(BackupService.name, () => {
|
||||
|
||||
await sut.handleBackupDatabase();
|
||||
|
||||
expect(mocks.process.spawn).toHaveBeenCalled();
|
||||
const call = mocks.process.spawn.mock.calls[0];
|
||||
expect(mocks.process.spawnDuplexStream).toHaveBeenCalled();
|
||||
const call = mocks.process.spawnDuplexStream.mock.calls[0];
|
||||
const args = call[1] as string[];
|
||||
// ['--dbname', '<url>', '--clean', '--if-exists']
|
||||
expect(args[0]).toBe('--dbname');
|
||||
const passedUrl = args[1];
|
||||
expect(passedUrl).not.toContain('uselibpqcompat');
|
||||
expect(passedUrl).toContain('sslmode=require');
|
||||
expect(args).toMatchInlineSnapshot(`
|
||||
[
|
||||
"postgresql://postgres:pwd@host:5432/immich?sslmode=require",
|
||||
"--clean",
|
||||
"--if-exists",
|
||||
]
|
||||
`);
|
||||
});
|
||||
|
||||
it('should run a database backup successfully', async () => {
|
||||
@@ -196,21 +199,21 @@ describe(BackupService.name, () => {
|
||||
expect(mocks.storage.rename).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should fail if pg_dumpall fails', async () => {
|
||||
mocks.process.spawn.mockReturnValueOnce(mockSpawn(1, '', 'error'));
|
||||
await expect(sut.handleBackupDatabase()).rejects.toThrow('Backup failed with code 1');
|
||||
it('should fail if pg_dump fails', async () => {
|
||||
mocks.process.spawnDuplexStream.mockReturnValueOnce(mockDuplex('pg_dump', 1, '', 'error'));
|
||||
await expect(sut.handleBackupDatabase()).rejects.toThrow('pg_dump non-zero exit code (1)');
|
||||
});
|
||||
|
||||
it('should not rename file if pgdump fails and gzip succeeds', async () => {
|
||||
mocks.process.spawn.mockReturnValueOnce(mockSpawn(1, '', 'error'));
|
||||
await expect(sut.handleBackupDatabase()).rejects.toThrow('Backup failed with code 1');
|
||||
mocks.process.spawnDuplexStream.mockReturnValueOnce(mockDuplex('pg_dump', 1, '', 'error'));
|
||||
await expect(sut.handleBackupDatabase()).rejects.toThrow('pg_dump non-zero exit code (1)');
|
||||
expect(mocks.storage.rename).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should fail if gzip fails', async () => {
|
||||
mocks.process.spawn.mockReturnValueOnce(mockSpawn(0, 'data', ''));
|
||||
mocks.process.spawn.mockReturnValueOnce(mockSpawn(1, '', 'error'));
|
||||
await expect(sut.handleBackupDatabase()).rejects.toThrow('Gzip failed with code 1');
|
||||
mocks.process.spawnDuplexStream.mockReturnValueOnce(mockDuplex('pg_dump', 0, 'data', ''));
|
||||
mocks.process.spawnDuplexStream.mockReturnValueOnce(mockDuplex('gzip', 1, '', 'error'));
|
||||
await expect(sut.handleBackupDatabase()).rejects.toThrow('gzip non-zero exit code (1)');
|
||||
});
|
||||
|
||||
it('should fail if write stream fails', async () => {
|
||||
@@ -226,9 +229,9 @@ describe(BackupService.name, () => {
|
||||
});
|
||||
|
||||
it('should ignore unlink failing and still return failed job status', async () => {
|
||||
mocks.process.spawn.mockReturnValueOnce(mockSpawn(1, '', 'error'));
|
||||
mocks.process.spawnDuplexStream.mockReturnValueOnce(mockDuplex('pg_dump', 1, '', 'error'));
|
||||
mocks.storage.unlink.mockRejectedValue(new Error('error'));
|
||||
await expect(sut.handleBackupDatabase()).rejects.toThrow('Backup failed with code 1');
|
||||
await expect(sut.handleBackupDatabase()).rejects.toThrow('pg_dump non-zero exit code (1)');
|
||||
expect(mocks.storage.unlink).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -242,12 +245,12 @@ describe(BackupService.name, () => {
|
||||
${'17.15.1'} | ${17}
|
||||
${'18.0.0'} | ${18}
|
||||
`(
|
||||
`should use pg_dumpall $expectedVersion with postgres version $postgresVersion`,
|
||||
`should use pg_dump $expectedVersion with postgres version $postgresVersion`,
|
||||
async ({ postgresVersion, expectedVersion }) => {
|
||||
mocks.database.getPostgresVersion.mockResolvedValue(postgresVersion);
|
||||
await sut.handleBackupDatabase();
|
||||
expect(mocks.process.spawn).toHaveBeenCalledWith(
|
||||
`/usr/lib/postgresql/${expectedVersion}/bin/pg_dumpall`,
|
||||
expect(mocks.process.spawnDuplexStream).toHaveBeenCalledWith(
|
||||
`/usr/lib/postgresql/${expectedVersion}/bin/pg_dump`,
|
||||
expect.any(Array),
|
||||
expect.any(Object),
|
||||
);
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { DateTime } from 'luxon';
|
||||
import path from 'node:path';
|
||||
import semver from 'semver';
|
||||
import { serverVersion } from 'src/constants';
|
||||
import { StorageCore } from 'src/cores/storage.core';
|
||||
import { OnEvent, OnJob } from 'src/decorators';
|
||||
import { DatabaseLock, ImmichWorker, JobName, JobStatus, QueueName, StorageFolder } from 'src/enum';
|
||||
import { ArgOf } from 'src/repositories/event.repository';
|
||||
import { BaseService } from 'src/services/base.service';
|
||||
import {
|
||||
createDatabaseBackup,
|
||||
isFailedDatabaseBackupName,
|
||||
isValidDatabaseRoutineBackupName,
|
||||
UnsupportedPostgresError,
|
||||
} from 'src/utils/database-backups';
|
||||
import { handlePromiseError } from 'src/utils/misc';
|
||||
|
||||
@Injectable()
|
||||
@@ -53,16 +56,11 @@ export class BackupService extends BaseService {
|
||||
|
||||
const backupsFolder = StorageCore.getBaseFolder(StorageFolder.Backups);
|
||||
const files = await this.storageRepository.readdir(backupsFolder);
|
||||
const failedBackups = files.filter((file) => file.match(/immich-db-backup-.*\.sql\.gz\.tmp$/));
|
||||
const backups = files
|
||||
.filter((file) => {
|
||||
const oldBackupStyle = file.match(/immich-db-backup-\d+\.sql\.gz$/);
|
||||
//immich-db-backup-20250729T114018-v1.136.0-pg14.17.sql.gz
|
||||
const newBackupStyle = file.match(/immich-db-backup-\d{8}T\d{6}-v.*-pg.*\.sql\.gz$/);
|
||||
return oldBackupStyle || newBackupStyle;
|
||||
})
|
||||
.filter((filename) => isValidDatabaseRoutineBackupName(filename))
|
||||
.toSorted()
|
||||
.toReversed();
|
||||
const failedBackups = files.filter((filename) => isFailedDatabaseBackupName(filename));
|
||||
|
||||
const toDelete = backups.slice(config.keepLastAmount);
|
||||
toDelete.push(...failedBackups);
|
||||
@@ -75,123 +73,27 @@ export class BackupService extends BaseService {
|
||||
|
||||
@OnJob({ name: JobName.DatabaseBackup, queue: QueueName.BackupDatabase })
|
||||
async handleBackupDatabase(): Promise<JobStatus> {
|
||||
this.logger.debug(`Database Backup Started`);
|
||||
const { database } = this.configRepository.getEnv();
|
||||
const config = database.config;
|
||||
|
||||
const isUrlConnection = config.connectionType === 'url';
|
||||
|
||||
let connectionUrl: string = isUrlConnection ? config.url : '';
|
||||
if (URL.canParse(connectionUrl)) {
|
||||
// remove known bad url parameters for pg_dumpall
|
||||
const url = new URL(connectionUrl);
|
||||
url.searchParams.delete('uselibpqcompat');
|
||||
connectionUrl = url.toString();
|
||||
}
|
||||
|
||||
const databaseParams = isUrlConnection
|
||||
? ['--dbname', connectionUrl]
|
||||
: [
|
||||
'--username',
|
||||
config.username,
|
||||
'--host',
|
||||
config.host,
|
||||
'--port',
|
||||
`${config.port}`,
|
||||
'--database',
|
||||
config.database,
|
||||
];
|
||||
|
||||
databaseParams.push('--clean', '--if-exists');
|
||||
const databaseVersion = await this.databaseRepository.getPostgresVersion();
|
||||
const backupFilePath = path.join(
|
||||
StorageCore.getBaseFolder(StorageFolder.Backups),
|
||||
`immich-db-backup-${DateTime.now().toFormat("yyyyLLdd'T'HHmmss")}-v${serverVersion.toString()}-pg${databaseVersion.split(' ')[0]}.sql.gz.tmp`,
|
||||
);
|
||||
const databaseSemver = semver.coerce(databaseVersion);
|
||||
const databaseMajorVersion = databaseSemver?.major;
|
||||
|
||||
if (!databaseMajorVersion || !databaseSemver || !semver.satisfies(databaseSemver, '>=14.0.0 <19.0.0')) {
|
||||
this.logger.error(`Database Backup Failure: Unsupported PostgreSQL version: ${databaseVersion}`);
|
||||
return JobStatus.Failed;
|
||||
}
|
||||
|
||||
this.logger.log(`Database Backup Starting. Database Version: ${databaseMajorVersion}`);
|
||||
|
||||
try {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const pgdump = this.processRepository.spawn(
|
||||
`/usr/lib/postgresql/${databaseMajorVersion}/bin/pg_dumpall`,
|
||||
databaseParams,
|
||||
{
|
||||
env: {
|
||||
PATH: process.env.PATH,
|
||||
PGPASSWORD: isUrlConnection ? new URL(connectionUrl).password : config.password,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
// NOTE: `--rsyncable` is only supported in GNU gzip
|
||||
const gzip = this.processRepository.spawn(`gzip`, ['--rsyncable']);
|
||||
pgdump.stdout.pipe(gzip.stdin);
|
||||
|
||||
const fileStream = this.storageRepository.createWriteStream(backupFilePath);
|
||||
|
||||
gzip.stdout.pipe(fileStream);
|
||||
|
||||
pgdump.on('error', (err) => {
|
||||
this.logger.error(`Backup failed with error: ${err}`);
|
||||
reject(err);
|
||||
});
|
||||
|
||||
gzip.on('error', (err) => {
|
||||
this.logger.error(`Gzip failed with error: ${err}`);
|
||||
reject(err);
|
||||
});
|
||||
|
||||
let pgdumpLogs = '';
|
||||
let gzipLogs = '';
|
||||
|
||||
pgdump.stderr.on('data', (data) => (pgdumpLogs += data));
|
||||
gzip.stderr.on('data', (data) => (gzipLogs += data));
|
||||
|
||||
pgdump.on('exit', (code) => {
|
||||
if (code !== 0) {
|
||||
this.logger.error(`Backup failed with code ${code}`);
|
||||
reject(`Backup failed with code ${code}`);
|
||||
this.logger.error(pgdumpLogs);
|
||||
return;
|
||||
}
|
||||
if (pgdumpLogs) {
|
||||
this.logger.debug(`pgdump_all logs\n${pgdumpLogs}`);
|
||||
}
|
||||
});
|
||||
|
||||
gzip.on('exit', (code) => {
|
||||
if (code !== 0) {
|
||||
this.logger.error(`Gzip failed with code ${code}`);
|
||||
reject(`Gzip failed with code ${code}`);
|
||||
this.logger.error(gzipLogs);
|
||||
return;
|
||||
}
|
||||
if (pgdump.exitCode !== 0) {
|
||||
this.logger.error(`Gzip exited with code 0 but pgdump exited with ${pgdump.exitCode}`);
|
||||
return;
|
||||
}
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
await this.storageRepository.rename(backupFilePath, backupFilePath.replace('.tmp', ''));
|
||||
await createDatabaseBackup(this.backupRepos);
|
||||
} catch (error) {
|
||||
this.logger.error(`Database Backup Failure: ${error}`);
|
||||
await this.storageRepository
|
||||
.unlink(backupFilePath)
|
||||
.catch((error) => this.logger.error(`Failed to delete failed backup file: ${error}`));
|
||||
if (error instanceof UnsupportedPostgresError) {
|
||||
return JobStatus.Failed;
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
|
||||
this.logger.log(`Database Backup Success`);
|
||||
await this.cleanupDatabaseBackups();
|
||||
return JobStatus.Success;
|
||||
}
|
||||
|
||||
private get backupRepos() {
|
||||
return {
|
||||
logger: this.logger,
|
||||
storage: this.storageRepository,
|
||||
config: this.configRepository,
|
||||
process: this.processRepository,
|
||||
database: this.databaseRepository,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { jwtVerify } from 'jose';
|
||||
import { SystemMetadataKey } from 'src/enum';
|
||||
import { MaintenanceAction, SystemMetadataKey } from 'src/enum';
|
||||
import { CliService } from 'src/services/cli.service';
|
||||
import { factory } from 'test/small.factory';
|
||||
import { newTestService, ServiceMocks } from 'test/utils';
|
||||
@@ -95,7 +95,14 @@ describe(CliService.name, () => {
|
||||
});
|
||||
|
||||
it('should disable maintenance mode', async () => {
|
||||
mocks.systemMetadata.get.mockResolvedValue({ isMaintenanceMode: true, secret: 'secret' });
|
||||
mocks.systemMetadata.get.mockResolvedValue({
|
||||
isMaintenanceMode: true,
|
||||
secret: 'secret',
|
||||
action: {
|
||||
action: MaintenanceAction.Start,
|
||||
},
|
||||
});
|
||||
|
||||
await expect(sut.disableMaintenanceMode()).resolves.toEqual({
|
||||
alreadyDisabled: false,
|
||||
});
|
||||
@@ -109,7 +116,14 @@ describe(CliService.name, () => {
|
||||
|
||||
describe('enableMaintenanceMode', () => {
|
||||
it('should not do anything if in maintenance mode', async () => {
|
||||
mocks.systemMetadata.get.mockResolvedValue({ isMaintenanceMode: true, secret: 'secret' });
|
||||
mocks.systemMetadata.get.mockResolvedValue({
|
||||
isMaintenanceMode: true,
|
||||
secret: 'secret',
|
||||
action: {
|
||||
action: MaintenanceAction.Start,
|
||||
},
|
||||
});
|
||||
|
||||
await expect(sut.enableMaintenanceMode()).resolves.toEqual(
|
||||
expect.objectContaining({
|
||||
alreadyEnabled: true,
|
||||
@@ -133,13 +147,22 @@ describe(CliService.name, () => {
|
||||
expect(mocks.systemMetadata.set).toHaveBeenCalledWith(SystemMetadataKey.MaintenanceMode, {
|
||||
isMaintenanceMode: true,
|
||||
secret: expect.stringMatching(/^\w{128}$/),
|
||||
action: {
|
||||
action: 'start',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
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' });
|
||||
mocks.systemMetadata.get.mockResolvedValue({
|
||||
isMaintenanceMode: true,
|
||||
secret: 'secret',
|
||||
action: {
|
||||
action: MaintenanceAction.Start,
|
||||
},
|
||||
});
|
||||
|
||||
const result = await sut.enableMaintenanceMode();
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ 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 { MaintenanceAction, SystemMetadataKey } from 'src/enum';
|
||||
import { BaseService } from 'src/services/base.service';
|
||||
import { createMaintenanceLoginUrl, generateMaintenanceSecret } from 'src/utils/maintenance';
|
||||
import { getExternalDomain } from 'src/utils/misc';
|
||||
@@ -86,6 +86,9 @@ export class CliService extends BaseService {
|
||||
await this.systemMetadataRepository.set(SystemMetadataKey.MaintenanceMode, {
|
||||
isMaintenanceMode: true,
|
||||
secret,
|
||||
action: {
|
||||
action: MaintenanceAction.Start,
|
||||
},
|
||||
});
|
||||
|
||||
await this.appRepository.sendOneShotAppRestart({
|
||||
|
||||
83
server/src/services/database-backup.service.spec.ts
Normal file
83
server/src/services/database-backup.service.spec.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
import { DateTime } from 'luxon';
|
||||
import { StorageCore } from 'src/cores/storage.core';
|
||||
import { StorageFolder } from 'src/enum';
|
||||
import { DatabaseBackupService } from 'src/services/database-backup.service';
|
||||
import { MaintenanceService } from 'src/services/maintenance.service';
|
||||
import { newTestService, ServiceMocks } from 'test/utils';
|
||||
|
||||
describe(MaintenanceService.name, () => {
|
||||
let sut: DatabaseBackupService;
|
||||
let mocks: ServiceMocks;
|
||||
|
||||
beforeEach(() => {
|
||||
({ sut, mocks } = newTestService(DatabaseBackupService));
|
||||
});
|
||||
|
||||
it('should work', () => {
|
||||
expect(sut).toBeDefined();
|
||||
});
|
||||
|
||||
describe('listBackups', () => {
|
||||
it('should give us all backups', async () => {
|
||||
mocks.storage.readdir.mockResolvedValue([
|
||||
`immich-db-backup-${DateTime.fromISO('2025-07-25T11:02:16Z').toFormat("yyyyLLdd'T'HHmmss")}-v1.234.5-pg14.5.sql.gz.tmp`,
|
||||
`immich-db-backup-${DateTime.fromISO('2025-07-27T11:01:16Z').toFormat("yyyyLLdd'T'HHmmss")}-v1.234.5-pg14.5.sql.gz`,
|
||||
'immich-db-backup-1753789649000.sql.gz',
|
||||
`immich-db-backup-${DateTime.fromISO('2025-07-29T11:01:16Z').toFormat("yyyyLLdd'T'HHmmss")}-v1.234.5-pg14.5.sql.gz`,
|
||||
]);
|
||||
mocks.storage.stat.mockResolvedValue({ size: 1024 } as any);
|
||||
|
||||
await expect(sut.listBackups()).resolves.toMatchObject({
|
||||
backups: [
|
||||
{ filename: 'immich-db-backup-20250729T110116-v1.234.5-pg14.5.sql.gz', filesize: 1024 },
|
||||
{ filename: 'immich-db-backup-20250727T110116-v1.234.5-pg14.5.sql.gz', filesize: 1024 },
|
||||
{ filename: 'immich-db-backup-1753789649000.sql.gz', filesize: 1024 },
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteBackup', () => {
|
||||
it('should reject invalid file names', async () => {
|
||||
await expect(sut.deleteBackup(['filename'])).rejects.toThrowError(
|
||||
new BadRequestException('Invalid backup name!'),
|
||||
);
|
||||
});
|
||||
|
||||
it('should unlink the target file', async () => {
|
||||
await sut.deleteBackup(['filename.sql']);
|
||||
expect(mocks.storage.unlink).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.storage.unlink).toHaveBeenCalledWith(
|
||||
`${StorageCore.getBaseFolder(StorageFolder.Backups)}/filename.sql`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('uploadBackup', () => {
|
||||
it('should reject invalid file names', async () => {
|
||||
await expect(sut.uploadBackup({ originalname: 'invalid backup' } as never)).rejects.toThrowError(
|
||||
new BadRequestException('Invalid backup name!'),
|
||||
);
|
||||
});
|
||||
|
||||
it('should write file', async () => {
|
||||
await sut.uploadBackup({ originalname: 'path.sql.gz', buffer: 'buffer' } as never);
|
||||
expect(mocks.storage.createOrOverwriteFile).toBeCalledWith('/data/backups/uploaded-path.sql.gz', 'buffer');
|
||||
});
|
||||
});
|
||||
|
||||
describe('downloadBackup', () => {
|
||||
it('should reject invalid file names', () => {
|
||||
expect(() => sut.downloadBackup('invalid backup')).toThrowError(new BadRequestException('Invalid backup name!'));
|
||||
});
|
||||
|
||||
it('should get backup path', () => {
|
||||
expect(sut.downloadBackup('hello.sql.gz')).toEqual(
|
||||
expect.objectContaining({
|
||||
path: '/data/backups/hello.sql.gz',
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
43
server/src/services/database-backup.service.ts
Normal file
43
server/src/services/database-backup.service.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { DatabaseBackupListResponseDto } from 'src/dtos/database-backup.dto';
|
||||
import { BaseService } from 'src/services/base.service';
|
||||
import {
|
||||
deleteDatabaseBackup,
|
||||
downloadDatabaseBackup,
|
||||
listDatabaseBackups,
|
||||
uploadDatabaseBackup,
|
||||
} from 'src/utils/database-backups';
|
||||
import { ImmichFileResponse } from 'src/utils/file';
|
||||
|
||||
/**
|
||||
* This service is available outside of maintenance mode to manage maintenance mode
|
||||
*/
|
||||
@Injectable()
|
||||
export class DatabaseBackupService extends BaseService {
|
||||
async listBackups(): Promise<DatabaseBackupListResponseDto> {
|
||||
const backups = await listDatabaseBackups(this.backupRepos);
|
||||
return { backups };
|
||||
}
|
||||
|
||||
deleteBackup(files: string[]): Promise<void> {
|
||||
return deleteDatabaseBackup(this.backupRepos, files);
|
||||
}
|
||||
|
||||
async uploadBackup(file: Express.Multer.File): Promise<void> {
|
||||
return uploadDatabaseBackup(this.backupRepos, file);
|
||||
}
|
||||
|
||||
downloadBackup(fileName: string): ImmichFileResponse {
|
||||
return downloadDatabaseBackup(fileName);
|
||||
}
|
||||
|
||||
private get backupRepos() {
|
||||
return {
|
||||
logger: this.logger,
|
||||
storage: this.storageRepository,
|
||||
config: this.configRepository,
|
||||
process: this.processRepository,
|
||||
database: this.databaseRepository,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import { AuthAdminService } from 'src/services/auth-admin.service';
|
||||
import { AuthService } from 'src/services/auth.service';
|
||||
import { BackupService } from 'src/services/backup.service';
|
||||
import { CliService } from 'src/services/cli.service';
|
||||
import { DatabaseBackupService } from 'src/services/database-backup.service';
|
||||
import { DatabaseService } from 'src/services/database.service';
|
||||
import { DownloadService } from 'src/services/download.service';
|
||||
import { DuplicateService } from 'src/services/duplicate.service';
|
||||
@@ -59,6 +60,7 @@ export const services = [
|
||||
AuthAdminService,
|
||||
BackupService,
|
||||
CliService,
|
||||
DatabaseBackupService,
|
||||
DatabaseService,
|
||||
DownloadService,
|
||||
DuplicateService,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { SystemMetadataKey } from 'src/enum';
|
||||
import { MaintenanceAction, SystemMetadataKey } from 'src/enum';
|
||||
import { MaintenanceService } from 'src/services/maintenance.service';
|
||||
import { newTestService, ServiceMocks } from 'test/utils';
|
||||
|
||||
@@ -36,28 +36,96 @@ describe(MaintenanceService.name, () => {
|
||||
});
|
||||
|
||||
it('should return true if enabled', async () => {
|
||||
mocks.systemMetadata.get.mockResolvedValue({ isMaintenanceMode: true, secret: '' });
|
||||
mocks.systemMetadata.get.mockResolvedValue({
|
||||
isMaintenanceMode: true,
|
||||
secret: '',
|
||||
action: { action: MaintenanceAction.Start },
|
||||
});
|
||||
|
||||
await expect(sut.getMaintenanceMode()).resolves.toEqual({
|
||||
isMaintenanceMode: true,
|
||||
secret: '',
|
||||
action: {
|
||||
action: 'start',
|
||||
},
|
||||
});
|
||||
|
||||
expect(mocks.systemMetadata.get).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('integrityCheck', () => {
|
||||
it('generate integrity report', async () => {
|
||||
mocks.storage.readdir.mockResolvedValue(['.immich', 'file1', 'file2']);
|
||||
mocks.storage.readFile.mockResolvedValue(undefined as never);
|
||||
mocks.storage.overwriteFile.mockRejectedValue(undefined as never);
|
||||
|
||||
await expect(sut.detectPriorInstall()).resolves.toMatchInlineSnapshot(`
|
||||
{
|
||||
"storage": [
|
||||
{
|
||||
"files": 2,
|
||||
"folder": "encoded-video",
|
||||
"readable": true,
|
||||
"writable": false,
|
||||
},
|
||||
{
|
||||
"files": 2,
|
||||
"folder": "library",
|
||||
"readable": true,
|
||||
"writable": false,
|
||||
},
|
||||
{
|
||||
"files": 2,
|
||||
"folder": "upload",
|
||||
"readable": true,
|
||||
"writable": false,
|
||||
},
|
||||
{
|
||||
"files": 2,
|
||||
"folder": "profile",
|
||||
"readable": true,
|
||||
"writable": false,
|
||||
},
|
||||
{
|
||||
"files": 2,
|
||||
"folder": "thumbs",
|
||||
"readable": true,
|
||||
"writable": false,
|
||||
},
|
||||
{
|
||||
"files": 2,
|
||||
"folder": "backups",
|
||||
"readable": true,
|
||||
"writable": false,
|
||||
},
|
||||
],
|
||||
}
|
||||
`);
|
||||
});
|
||||
});
|
||||
|
||||
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({
|
||||
await expect(
|
||||
sut.startMaintenance(
|
||||
{
|
||||
action: MaintenanceAction.Start,
|
||||
},
|
||||
'admin',
|
||||
),
|
||||
).resolves.toMatchObject({
|
||||
jwt: expect.any(String),
|
||||
});
|
||||
|
||||
expect(mocks.systemMetadata.set).toHaveBeenCalledWith(SystemMetadataKey.MaintenanceMode, {
|
||||
isMaintenanceMode: true,
|
||||
secret: expect.stringMatching(/^\w{128}$/),
|
||||
action: {
|
||||
action: 'start',
|
||||
},
|
||||
});
|
||||
|
||||
expect(mocks.event.emit).toHaveBeenCalledWith('AppRestart', {
|
||||
@@ -78,7 +146,13 @@ describe(MaintenanceService.name, () => {
|
||||
});
|
||||
|
||||
it('should generate a login url with JWT', async () => {
|
||||
mocks.systemMetadata.get.mockResolvedValue({ isMaintenanceMode: true, secret: 'secret' });
|
||||
mocks.systemMetadata.get.mockResolvedValue({
|
||||
isMaintenanceMode: true,
|
||||
secret: 'secret',
|
||||
action: {
|
||||
action: MaintenanceAction.Start,
|
||||
},
|
||||
});
|
||||
|
||||
await expect(
|
||||
sut.createLoginUrl({
|
||||
|
||||
@@ -1,11 +1,21 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { BadRequestException, Injectable } from '@nestjs/common';
|
||||
import { OnEvent } from 'src/decorators';
|
||||
import { MaintenanceAuthDto } from 'src/dtos/maintenance.dto';
|
||||
import { SystemMetadataKey } from 'src/enum';
|
||||
import {
|
||||
MaintenanceAuthDto,
|
||||
MaintenanceDetectInstallResponseDto,
|
||||
MaintenanceStatusResponseDto,
|
||||
SetMaintenanceModeDto,
|
||||
} from 'src/dtos/maintenance.dto';
|
||||
import { MaintenanceAction, SystemMetadataKey } from 'src/enum';
|
||||
import { ArgOf } from 'src/repositories/event.repository';
|
||||
import { BaseService } from 'src/services/base.service';
|
||||
import { MaintenanceModeState } from 'src/types';
|
||||
import { createMaintenanceLoginUrl, generateMaintenanceSecret, signMaintenanceJwt } from 'src/utils/maintenance';
|
||||
import {
|
||||
createMaintenanceLoginUrl,
|
||||
detectPriorInstall,
|
||||
generateMaintenanceSecret,
|
||||
signMaintenanceJwt,
|
||||
} from 'src/utils/maintenance';
|
||||
import { getExternalDomain } from 'src/utils/misc';
|
||||
|
||||
/**
|
||||
@@ -19,9 +29,25 @@ export class MaintenanceService extends BaseService {
|
||||
.then((state) => state ?? { isMaintenanceMode: false });
|
||||
}
|
||||
|
||||
async startMaintenance(username: string): Promise<{ jwt: string }> {
|
||||
getMaintenanceStatus(): MaintenanceStatusResponseDto {
|
||||
return {
|
||||
active: false,
|
||||
action: MaintenanceAction.End,
|
||||
};
|
||||
}
|
||||
|
||||
detectPriorInstall(): Promise<MaintenanceDetectInstallResponseDto> {
|
||||
return detectPriorInstall(this.storageRepository);
|
||||
}
|
||||
|
||||
async startMaintenance(action: SetMaintenanceModeDto, username: string): Promise<{ jwt: string }> {
|
||||
const secret = generateMaintenanceSecret();
|
||||
await this.systemMetadataRepository.set(SystemMetadataKey.MaintenanceMode, { isMaintenanceMode: true, secret });
|
||||
await this.systemMetadataRepository.set(SystemMetadataKey.MaintenanceMode, {
|
||||
isMaintenanceMode: true,
|
||||
secret,
|
||||
action,
|
||||
});
|
||||
|
||||
await this.eventRepository.emit('AppRestart', { isMaintenanceMode: true });
|
||||
|
||||
return {
|
||||
@@ -31,6 +57,20 @@ export class MaintenanceService extends BaseService {
|
||||
};
|
||||
}
|
||||
|
||||
async startRestoreFlow(): Promise<{ jwt: string }> {
|
||||
const adminUser = await this.userRepository.getAdmin();
|
||||
if (adminUser) {
|
||||
throw new BadRequestException('The server already has an admin');
|
||||
}
|
||||
|
||||
return this.startMaintenance(
|
||||
{
|
||||
action: MaintenanceAction.SelectDatabaseRestore,
|
||||
},
|
||||
'admin',
|
||||
);
|
||||
}
|
||||
|
||||
@OnEvent({ name: 'AppRestart', server: true })
|
||||
onRestart(event: ArgOf<'AppRestart'>, ack?: (ok: 'ok') => void): void {
|
||||
this.logger.log(`Restarting due to event... ${JSON.stringify(event)}`);
|
||||
|
||||
@@ -241,21 +241,21 @@ describe(MediaService.name, () => {
|
||||
await expect(sut.handleAssetMigration({ id: assetStub.image.id })).resolves.toBe(JobStatus.Success);
|
||||
expect(mocks.move.create).toHaveBeenCalledWith({
|
||||
entityId: assetStub.image.id,
|
||||
pathType: AssetPathType.FullSize,
|
||||
pathType: AssetFileType.FullSize,
|
||||
oldPath: '/uploads/user-id/fullsize/path.webp',
|
||||
newPath: expect.stringContaining('/data/thumbs/user-id/as/se/asset-id-fullsize.jpeg'),
|
||||
newPath: expect.stringContaining('/data/thumbs/user-id/as/se/asset-id_fullsize.jpeg'),
|
||||
});
|
||||
expect(mocks.move.create).toHaveBeenCalledWith({
|
||||
entityId: assetStub.image.id,
|
||||
pathType: AssetPathType.Preview,
|
||||
pathType: AssetFileType.Preview,
|
||||
oldPath: '/uploads/user-id/thumbs/path.jpg',
|
||||
newPath: expect.stringContaining('/data/thumbs/user-id/as/se/asset-id-preview.jpeg'),
|
||||
newPath: expect.stringContaining('/data/thumbs/user-id/as/se/asset-id_preview.jpeg'),
|
||||
});
|
||||
expect(mocks.move.create).toHaveBeenCalledWith({
|
||||
entityId: assetStub.image.id,
|
||||
pathType: AssetPathType.Thumbnail,
|
||||
pathType: AssetFileType.Thumbnail,
|
||||
oldPath: '/uploads/user-id/webp/path.ext',
|
||||
newPath: expect.stringContaining('/data/thumbs/user-id/as/se/asset-id-thumbnail.webp'),
|
||||
newPath: expect.stringContaining('/data/thumbs/user-id/as/se/asset-id_thumbnail.webp'),
|
||||
});
|
||||
expect(mocks.move.create).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
@@ -385,11 +385,13 @@ describe(MediaService.name, () => {
|
||||
assetId: 'asset-id',
|
||||
type: AssetFileType.Preview,
|
||||
path: expect.any(String),
|
||||
isEdited: false,
|
||||
},
|
||||
{
|
||||
assetId: 'asset-id',
|
||||
type: AssetFileType.Thumbnail,
|
||||
path: expect.any(String),
|
||||
isEdited: false,
|
||||
},
|
||||
]);
|
||||
expect(mocks.asset.update).toHaveBeenCalledWith({ id: 'asset-id', thumbhash: thumbhashBuffer });
|
||||
@@ -421,11 +423,13 @@ describe(MediaService.name, () => {
|
||||
assetId: 'asset-id',
|
||||
type: AssetFileType.Preview,
|
||||
path: expect.any(String),
|
||||
isEdited: false,
|
||||
},
|
||||
{
|
||||
assetId: 'asset-id',
|
||||
type: AssetFileType.Thumbnail,
|
||||
path: expect.any(String),
|
||||
isEdited: false,
|
||||
},
|
||||
]);
|
||||
});
|
||||
@@ -456,11 +460,13 @@ describe(MediaService.name, () => {
|
||||
assetId: 'asset-id',
|
||||
type: AssetFileType.Preview,
|
||||
path: expect.any(String),
|
||||
isEdited: false,
|
||||
},
|
||||
{
|
||||
assetId: 'asset-id',
|
||||
type: AssetFileType.Thumbnail,
|
||||
path: expect.any(String),
|
||||
isEdited: false,
|
||||
},
|
||||
]);
|
||||
});
|
||||
@@ -548,8 +554,8 @@ describe(MediaService.name, () => {
|
||||
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(assetStub.image);
|
||||
const thumbhashBuffer = Buffer.from('a thumbhash', 'utf8');
|
||||
mocks.media.generateThumbhash.mockResolvedValue(thumbhashBuffer);
|
||||
const previewPath = `/data/thumbs/user-id/as/se/asset-id-preview.${format}`;
|
||||
const thumbnailPath = `/data/thumbs/user-id/as/se/asset-id-thumbnail.webp`;
|
||||
const previewPath = `/data/thumbs/user-id/as/se/asset-id_preview.${format}`;
|
||||
const thumbnailPath = `/data/thumbs/user-id/as/se/asset-id_thumbnail.webp`;
|
||||
|
||||
await sut.handleGenerateThumbnails({ id: assetStub.image.id });
|
||||
|
||||
@@ -595,8 +601,8 @@ describe(MediaService.name, () => {
|
||||
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(assetStub.image);
|
||||
const thumbhashBuffer = Buffer.from('a thumbhash', 'utf8');
|
||||
mocks.media.generateThumbhash.mockResolvedValue(thumbhashBuffer);
|
||||
const previewPath = expect.stringContaining(`/data/thumbs/user-id/as/se/asset-id-preview.jpeg`);
|
||||
const thumbnailPath = expect.stringContaining(`/data/thumbs/user-id/as/se/asset-id-thumbnail.${format}`);
|
||||
const previewPath = expect.stringContaining(`/data/thumbs/user-id/as/se/asset-id_preview.jpeg`);
|
||||
const thumbnailPath = expect.stringContaining(`/data/thumbs/user-id/as/se/asset-id_thumbnail.${format}`);
|
||||
|
||||
await sut.handleGenerateThumbnails({ id: assetStub.image.id });
|
||||
|
||||
@@ -1026,9 +1032,9 @@ describe(MediaService.name, () => {
|
||||
|
||||
expect(mocks.asset.upsertFiles).toHaveBeenCalledWith(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ type: AssetFileType.FullSizeEdited }),
|
||||
expect.objectContaining({ type: AssetFileType.PreviewEdited }),
|
||||
expect.objectContaining({ type: AssetFileType.ThumbnailEdited }),
|
||||
expect.objectContaining({ type: AssetFileType.FullSize, isEdited: true }),
|
||||
expect.objectContaining({ type: AssetFileType.Preview, isEdited: true }),
|
||||
expect.objectContaining({ type: AssetFileType.Thumbnail, isEdited: true }),
|
||||
]),
|
||||
);
|
||||
});
|
||||
@@ -1098,17 +1104,17 @@ describe(MediaService.name, () => {
|
||||
expect(mocks.media.generateThumbnail).toHaveBeenCalledWith(
|
||||
rawBuffer,
|
||||
expect.anything(),
|
||||
expect.stringContaining('edited_preview.jpeg'),
|
||||
expect.stringContaining('preview_edited.jpeg'),
|
||||
);
|
||||
expect(mocks.media.generateThumbnail).toHaveBeenCalledWith(
|
||||
rawBuffer,
|
||||
expect.anything(),
|
||||
expect.stringContaining('edited_thumbnail.webp'),
|
||||
expect.stringContaining('thumbnail_edited.webp'),
|
||||
);
|
||||
expect(mocks.media.generateThumbnail).toHaveBeenCalledWith(
|
||||
rawBuffer,
|
||||
expect.anything(),
|
||||
expect.stringContaining('edited_fullsize.jpeg'),
|
||||
expect.stringContaining('fullsize_edited.jpeg'),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -3254,13 +3260,13 @@ describe(MediaService.name, () => {
|
||||
};
|
||||
|
||||
await sut['syncFiles'](asset, [
|
||||
{ type: AssetFileType.Preview, newPath: '/new/preview.jpg' },
|
||||
{ type: AssetFileType.Thumbnail, newPath: '/new/thumbnail.jpg' },
|
||||
{ type: AssetFileType.Preview, newPath: '/new/preview.jpg', isEdited: false },
|
||||
{ type: AssetFileType.Thumbnail, newPath: '/new/thumbnail.jpg', isEdited: false },
|
||||
]);
|
||||
|
||||
expect(mocks.asset.upsertFiles).toHaveBeenCalledWith([
|
||||
{ assetId: 'asset-id', path: '/new/preview.jpg', type: AssetFileType.Preview },
|
||||
{ assetId: 'asset-id', path: '/new/thumbnail.jpg', type: AssetFileType.Thumbnail },
|
||||
{ assetId: 'asset-id', path: '/new/preview.jpg', type: AssetFileType.Preview, isEdited: false },
|
||||
{ assetId: 'asset-id', path: '/new/thumbnail.jpg', type: AssetFileType.Thumbnail, isEdited: false },
|
||||
]);
|
||||
expect(mocks.asset.deleteFiles).not.toHaveBeenCalled();
|
||||
expect(mocks.job.queue).not.toHaveBeenCalled();
|
||||
@@ -3270,19 +3276,31 @@ describe(MediaService.name, () => {
|
||||
const asset = {
|
||||
id: 'asset-id',
|
||||
files: [
|
||||
{ id: 'file-1', assetId: 'asset-id', type: AssetFileType.Preview, path: '/old/preview.jpg' },
|
||||
{ id: 'file-2', assetId: 'asset-id', type: AssetFileType.Thumbnail, path: '/old/thumbnail.jpg' },
|
||||
{
|
||||
id: 'file-1',
|
||||
assetId: 'asset-id',
|
||||
type: AssetFileType.Preview,
|
||||
path: '/old/preview.jpg',
|
||||
isEdited: false,
|
||||
},
|
||||
{
|
||||
id: 'file-2',
|
||||
assetId: 'asset-id',
|
||||
type: AssetFileType.Thumbnail,
|
||||
path: '/old/thumbnail.jpg',
|
||||
isEdited: false,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
await sut['syncFiles'](asset, [
|
||||
{ type: AssetFileType.Preview, newPath: '/new/preview.jpg' },
|
||||
{ type: AssetFileType.Thumbnail, newPath: '/new/thumbnail.jpg' },
|
||||
{ type: AssetFileType.Preview, newPath: '/new/preview.jpg', isEdited: false },
|
||||
{ type: AssetFileType.Thumbnail, newPath: '/new/thumbnail.jpg', isEdited: false },
|
||||
]);
|
||||
|
||||
expect(mocks.asset.upsertFiles).toHaveBeenCalledWith([
|
||||
{ assetId: 'asset-id', path: '/new/preview.jpg', type: AssetFileType.Preview },
|
||||
{ assetId: 'asset-id', path: '/new/thumbnail.jpg', type: AssetFileType.Thumbnail },
|
||||
{ assetId: 'asset-id', path: '/new/preview.jpg', type: AssetFileType.Preview, isEdited: false },
|
||||
{ assetId: 'asset-id', path: '/new/thumbnail.jpg', type: AssetFileType.Thumbnail, isEdited: false },
|
||||
]);
|
||||
expect(mocks.asset.deleteFiles).not.toHaveBeenCalled();
|
||||
expect(mocks.job.queue).toHaveBeenCalledWith({
|
||||
@@ -3295,17 +3313,38 @@ describe(MediaService.name, () => {
|
||||
const asset = {
|
||||
id: 'asset-id',
|
||||
files: [
|
||||
{ id: 'file-1', assetId: 'asset-id', type: AssetFileType.Preview, path: '/old/preview.jpg' },
|
||||
{ id: 'file-2', assetId: 'asset-id', type: AssetFileType.Thumbnail, path: '/old/thumbnail.jpg' },
|
||||
{
|
||||
id: 'file-1',
|
||||
assetId: 'asset-id',
|
||||
type: AssetFileType.Preview,
|
||||
path: '/old/preview.jpg',
|
||||
isEdited: false,
|
||||
},
|
||||
{
|
||||
id: 'file-2',
|
||||
assetId: 'asset-id',
|
||||
type: AssetFileType.Thumbnail,
|
||||
path: '/old/thumbnail.jpg',
|
||||
isEdited: false,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
await sut['syncFiles'](asset, [{ type: AssetFileType.Preview }, { type: AssetFileType.Thumbnail }]);
|
||||
await sut['syncFiles'](asset, [
|
||||
{ type: AssetFileType.Preview, isEdited: false },
|
||||
{ type: AssetFileType.Thumbnail, isEdited: false },
|
||||
]);
|
||||
|
||||
expect(mocks.asset.upsertFiles).not.toHaveBeenCalled();
|
||||
expect(mocks.asset.deleteFiles).toHaveBeenCalledWith([
|
||||
{ id: 'file-1', assetId: 'asset-id', type: AssetFileType.Preview, path: '/old/preview.jpg' },
|
||||
{ id: 'file-2', assetId: 'asset-id', type: AssetFileType.Thumbnail, path: '/old/thumbnail.jpg' },
|
||||
{ id: 'file-1', assetId: 'asset-id', type: AssetFileType.Preview, path: '/old/preview.jpg', isEdited: false },
|
||||
{
|
||||
id: 'file-2',
|
||||
assetId: 'asset-id',
|
||||
type: AssetFileType.Thumbnail,
|
||||
path: '/old/thumbnail.jpg',
|
||||
isEdited: false,
|
||||
},
|
||||
]);
|
||||
expect(mocks.job.queue).toHaveBeenCalledWith({
|
||||
name: JobName.FileDelete,
|
||||
@@ -3317,14 +3356,26 @@ describe(MediaService.name, () => {
|
||||
const asset = {
|
||||
id: 'asset-id',
|
||||
files: [
|
||||
{ id: 'file-1', assetId: 'asset-id', type: AssetFileType.Preview, path: '/same/preview.jpg' },
|
||||
{ id: 'file-2', assetId: 'asset-id', type: AssetFileType.Thumbnail, path: '/same/thumbnail.jpg' },
|
||||
{
|
||||
id: 'file-1',
|
||||
assetId: 'asset-id',
|
||||
type: AssetFileType.Preview,
|
||||
path: '/same/preview.jpg',
|
||||
isEdited: false,
|
||||
},
|
||||
{
|
||||
id: 'file-2',
|
||||
assetId: 'asset-id',
|
||||
type: AssetFileType.Thumbnail,
|
||||
path: '/same/thumbnail.jpg',
|
||||
isEdited: false,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
await sut['syncFiles'](asset, [
|
||||
{ type: AssetFileType.Preview, newPath: '/same/preview.jpg' },
|
||||
{ type: AssetFileType.Thumbnail, newPath: '/same/thumbnail.jpg' },
|
||||
{ type: AssetFileType.Preview, newPath: '/same/preview.jpg', isEdited: false },
|
||||
{ type: AssetFileType.Thumbnail, newPath: '/same/thumbnail.jpg', isEdited: false },
|
||||
]);
|
||||
|
||||
expect(mocks.asset.upsertFiles).not.toHaveBeenCalled();
|
||||
@@ -3336,23 +3387,41 @@ describe(MediaService.name, () => {
|
||||
const asset = {
|
||||
id: 'asset-id',
|
||||
files: [
|
||||
{ id: 'file-1', assetId: 'asset-id', type: AssetFileType.Preview, path: '/old/preview.jpg' },
|
||||
{ id: 'file-2', assetId: 'asset-id', type: AssetFileType.Thumbnail, path: '/old/thumbnail.jpg' },
|
||||
{
|
||||
id: 'file-1',
|
||||
assetId: 'asset-id',
|
||||
type: AssetFileType.Preview,
|
||||
path: '/old/preview.jpg',
|
||||
isEdited: false,
|
||||
},
|
||||
{
|
||||
id: 'file-2',
|
||||
assetId: 'asset-id',
|
||||
type: AssetFileType.Thumbnail,
|
||||
path: '/old/thumbnail.jpg',
|
||||
isEdited: false,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
await sut['syncFiles'](asset, [
|
||||
{ type: AssetFileType.Preview, newPath: '/new/preview.jpg' }, // replace
|
||||
{ type: AssetFileType.Thumbnail }, // delete
|
||||
{ type: AssetFileType.FullSize, newPath: '/new/fullsize.jpg' }, // new
|
||||
{ type: AssetFileType.Preview, newPath: '/new/preview.jpg', isEdited: false }, // replace
|
||||
{ type: AssetFileType.Thumbnail, isEdited: false }, // delete
|
||||
{ type: AssetFileType.FullSize, newPath: '/new/fullsize.jpg', isEdited: false }, // new
|
||||
]);
|
||||
|
||||
expect(mocks.asset.upsertFiles).toHaveBeenCalledWith([
|
||||
{ assetId: 'asset-id', path: '/new/preview.jpg', type: AssetFileType.Preview },
|
||||
{ assetId: 'asset-id', path: '/new/fullsize.jpg', type: AssetFileType.FullSize },
|
||||
{ assetId: 'asset-id', path: '/new/preview.jpg', type: AssetFileType.Preview, isEdited: false },
|
||||
{ assetId: 'asset-id', path: '/new/fullsize.jpg', type: AssetFileType.FullSize, isEdited: false },
|
||||
]);
|
||||
expect(mocks.asset.deleteFiles).toHaveBeenCalledWith([
|
||||
{ id: 'file-2', assetId: 'asset-id', type: AssetFileType.Thumbnail, path: '/old/thumbnail.jpg' },
|
||||
{
|
||||
id: 'file-2',
|
||||
assetId: 'asset-id',
|
||||
type: AssetFileType.Thumbnail,
|
||||
path: '/old/thumbnail.jpg',
|
||||
isEdited: false,
|
||||
},
|
||||
]);
|
||||
expect(mocks.job.queue).toHaveBeenCalledWith({
|
||||
name: JobName.FileDelete,
|
||||
@@ -3376,11 +3445,19 @@ describe(MediaService.name, () => {
|
||||
it('should delete non-existent file types when newPath is not provided', async () => {
|
||||
const asset = {
|
||||
id: 'asset-id',
|
||||
files: [{ id: 'file-1', assetId: 'asset-id', type: AssetFileType.Preview, path: '/old/preview.jpg' }],
|
||||
files: [
|
||||
{
|
||||
id: 'file-1',
|
||||
assetId: 'asset-id',
|
||||
type: AssetFileType.Preview,
|
||||
path: '/old/preview.jpg',
|
||||
isEdited: false,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
await sut['syncFiles'](asset, [
|
||||
{ type: AssetFileType.Thumbnail }, // file doesn't exist, newPath not provided
|
||||
{ type: AssetFileType.Thumbnail, isEdited: false }, // file doesn't exist, newPath not provided
|
||||
]);
|
||||
|
||||
expect(mocks.asset.upsertFiles).not.toHaveBeenCalled();
|
||||
|
||||
@@ -8,7 +8,6 @@ import { AssetEditAction, CropParameters } from 'src/dtos/editing.dto';
|
||||
import { SystemConfigFFmpegDto } from 'src/dtos/system-config.dto';
|
||||
import {
|
||||
AssetFileType,
|
||||
AssetPathType,
|
||||
AssetType,
|
||||
AssetVisibility,
|
||||
AudioCodec,
|
||||
@@ -50,6 +49,7 @@ interface UpsertFileOptions {
|
||||
assetId: string;
|
||||
type: AssetFileType;
|
||||
path: string;
|
||||
isEdited: boolean;
|
||||
}
|
||||
|
||||
type ThumbnailAsset = NonNullable<Awaited<ReturnType<AssetJobRepository['getForGenerateThumbnailJob']>>>;
|
||||
@@ -160,9 +160,9 @@ export class MediaService extends BaseService {
|
||||
return JobStatus.Failed;
|
||||
}
|
||||
|
||||
await this.storageCore.moveAssetImage(asset, AssetPathType.FullSize, image.fullsize.format);
|
||||
await this.storageCore.moveAssetImage(asset, AssetPathType.Preview, image.preview.format);
|
||||
await this.storageCore.moveAssetImage(asset, AssetPathType.Thumbnail, image.thumbnail.format);
|
||||
await this.storageCore.moveAssetImage(asset, AssetFileType.FullSize, image.fullsize.format);
|
||||
await this.storageCore.moveAssetImage(asset, AssetFileType.Preview, image.preview.format);
|
||||
await this.storageCore.moveAssetImage(asset, AssetFileType.Thumbnail, image.thumbnail.format);
|
||||
await this.storageCore.moveAssetVideo(asset);
|
||||
|
||||
return JobStatus.Success;
|
||||
@@ -236,9 +236,9 @@ export class MediaService extends BaseService {
|
||||
}
|
||||
|
||||
await this.syncFiles(asset, [
|
||||
{ type: AssetFileType.Preview, newPath: generated.previewPath },
|
||||
{ type: AssetFileType.Thumbnail, newPath: generated.thumbnailPath },
|
||||
{ type: AssetFileType.FullSize, newPath: generated.fullsizePath },
|
||||
{ type: AssetFileType.Preview, newPath: generated.previewPath, isEdited: false },
|
||||
{ type: AssetFileType.Thumbnail, newPath: generated.thumbnailPath, isEdited: false },
|
||||
{ type: AssetFileType.FullSize, newPath: generated.fullsizePath, isEdited: false },
|
||||
]);
|
||||
|
||||
const editiedGenerated = await this.generateEditedThumbnails(asset);
|
||||
@@ -307,16 +307,16 @@ export class MediaService extends BaseService {
|
||||
|
||||
private async generateImageThumbnails(asset: ThumbnailAsset, useEdits: boolean = false) {
|
||||
const { image } = await this.getConfig({ withCache: true });
|
||||
const previewPath = StorageCore.getImagePath(
|
||||
asset,
|
||||
useEdits ? AssetPathType.EditedPreview : AssetPathType.Preview,
|
||||
image.preview.format,
|
||||
);
|
||||
const thumbnailPath = StorageCore.getImagePath(
|
||||
asset,
|
||||
useEdits ? AssetPathType.EditedThumbnail : AssetPathType.Thumbnail,
|
||||
image.thumbnail.format,
|
||||
);
|
||||
const previewPath = StorageCore.getImagePath(asset, {
|
||||
fileType: AssetFileType.Preview,
|
||||
isEdited: useEdits,
|
||||
format: image.preview.format,
|
||||
});
|
||||
const thumbnailPath = StorageCore.getImagePath(asset, {
|
||||
fileType: AssetFileType.Thumbnail,
|
||||
isEdited: useEdits,
|
||||
format: image.thumbnail.format,
|
||||
});
|
||||
this.storageCore.ensureFolders(previewPath);
|
||||
|
||||
// Handle embedded preview extraction for RAW files
|
||||
@@ -343,11 +343,11 @@ export class MediaService extends BaseService {
|
||||
|
||||
if (convertFullsize) {
|
||||
// convert a new fullsize image from the same source as the thumbnail
|
||||
fullsizePath = StorageCore.getImagePath(
|
||||
asset,
|
||||
useEdits ? AssetPathType.EditedFullSize : AssetPathType.FullSize,
|
||||
image.fullsize.format,
|
||||
);
|
||||
fullsizePath = StorageCore.getImagePath(asset, {
|
||||
fileType: AssetFileType.FullSize,
|
||||
isEdited: useEdits,
|
||||
format: image.fullsize.format,
|
||||
});
|
||||
const fullsizeOptions = {
|
||||
format: image.fullsize.format,
|
||||
quality: image.fullsize.quality,
|
||||
@@ -355,7 +355,11 @@ export class MediaService extends BaseService {
|
||||
};
|
||||
promises.push(this.mediaRepository.generateThumbnail(data, fullsizeOptions, fullsizePath));
|
||||
} else if (generateFullsize && extracted && extracted.format === RawExtractedFormat.Jpeg) {
|
||||
fullsizePath = StorageCore.getImagePath(asset, AssetPathType.FullSize, extracted.format);
|
||||
fullsizePath = StorageCore.getImagePath(asset, {
|
||||
fileType: AssetFileType.FullSize,
|
||||
format: extracted.format,
|
||||
isEdited: false,
|
||||
});
|
||||
this.storageCore.ensureFolders(fullsizePath);
|
||||
|
||||
// Write the buffer to disk with essential EXIF data
|
||||
@@ -489,8 +493,16 @@ export class MediaService extends BaseService {
|
||||
|
||||
private async generateVideoThumbnails(asset: ThumbnailPathEntity & { originalPath: string }) {
|
||||
const { image, ffmpeg } = await this.getConfig({ withCache: true });
|
||||
const previewPath = StorageCore.getImagePath(asset, AssetPathType.Preview, image.preview.format);
|
||||
const thumbnailPath = StorageCore.getImagePath(asset, AssetPathType.Thumbnail, image.thumbnail.format);
|
||||
const previewPath = StorageCore.getImagePath(asset, {
|
||||
fileType: AssetFileType.Preview,
|
||||
format: image.preview.format,
|
||||
isEdited: false,
|
||||
});
|
||||
const thumbnailPath = StorageCore.getImagePath(asset, {
|
||||
fileType: AssetFileType.Thumbnail,
|
||||
format: image.thumbnail.format,
|
||||
isEdited: false,
|
||||
});
|
||||
this.storageCore.ensureFolders(previewPath);
|
||||
|
||||
const { format, audioStreams, videoStreams } = await this.mediaRepository.probe(asset.originalPath);
|
||||
@@ -779,18 +791,18 @@ export class MediaService extends BaseService {
|
||||
|
||||
private async syncFiles(
|
||||
asset: { id: string; files: AssetFile[] },
|
||||
files: { type: AssetFileType; newPath?: string }[],
|
||||
files: { type: AssetFileType; newPath?: string; isEdited: boolean }[],
|
||||
) {
|
||||
const toUpsert: UpsertFileOptions[] = [];
|
||||
const pathsToDelete: string[] = [];
|
||||
const toDelete: AssetFile[] = [];
|
||||
|
||||
for (const { type, newPath } of files) {
|
||||
const existingFile = asset.files.find((file) => file.type === type);
|
||||
for (const { type, newPath, isEdited } of files) {
|
||||
const existingFile = asset.files.find((file) => file.type === type && file.isEdited === isEdited);
|
||||
|
||||
// upsert new file path
|
||||
if (newPath && existingFile?.path !== newPath) {
|
||||
toUpsert.push({ assetId: asset.id, path: newPath, type });
|
||||
toUpsert.push({ assetId: asset.id, path: newPath, type, isEdited });
|
||||
|
||||
// delete old file from disk
|
||||
if (existingFile) {
|
||||
@@ -829,9 +841,9 @@ export class MediaService extends BaseService {
|
||||
const generated = asset.edits.length > 0 ? await this.generateImageThumbnails(asset, true) : undefined;
|
||||
|
||||
await this.syncFiles(asset, [
|
||||
{ type: AssetFileType.PreviewEdited, newPath: generated?.previewPath },
|
||||
{ type: AssetFileType.ThumbnailEdited, newPath: generated?.thumbnailPath },
|
||||
{ type: AssetFileType.FullSizeEdited, newPath: generated?.fullsizePath },
|
||||
{ type: AssetFileType.Preview, newPath: generated?.previewPath, isEdited: true },
|
||||
{ type: AssetFileType.Thumbnail, newPath: generated?.thumbnailPath, isEdited: true },
|
||||
{ type: AssetFileType.FullSize, newPath: generated?.fullsizePath, isEdited: true },
|
||||
]);
|
||||
|
||||
const crop = asset.edits.find((e) => e.action === AssetEditAction.Crop);
|
||||
|
||||
@@ -35,7 +35,7 @@ const forSidecarJob = (
|
||||
asset: {
|
||||
id?: string;
|
||||
originalPath?: string;
|
||||
files?: { id: string; type: AssetFileType; path: string }[];
|
||||
files?: { id: string; type: AssetFileType; path: string; isEdited: boolean }[];
|
||||
} = {},
|
||||
) => {
|
||||
return {
|
||||
@@ -387,6 +387,7 @@ describe(MetadataService.name, () => {
|
||||
|
||||
it('should extract tags from TagsList', async () => {
|
||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(removeNonSidecarFiles(assetStub.image));
|
||||
mocks.asset.getById.mockResolvedValue({ exifInfo: { tags: ['Parent'] } } as any);
|
||||
mockReadTags({ TagsList: ['Parent'] });
|
||||
mocks.tag.upsertValue.mockResolvedValue(tagStub.parentUpsert);
|
||||
|
||||
@@ -397,6 +398,7 @@ describe(MetadataService.name, () => {
|
||||
|
||||
it('should extract hierarchy from TagsList', async () => {
|
||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(removeNonSidecarFiles(assetStub.image));
|
||||
mocks.asset.getById.mockResolvedValue({ exifInfo: { tags: ['Parent/Child'] } } as any);
|
||||
mockReadTags({ TagsList: ['Parent/Child'] });
|
||||
mocks.tag.upsertValue.mockResolvedValueOnce(tagStub.parentUpsert);
|
||||
mocks.tag.upsertValue.mockResolvedValueOnce(tagStub.childUpsert);
|
||||
@@ -417,6 +419,7 @@ describe(MetadataService.name, () => {
|
||||
|
||||
it('should extract tags from Keywords as a string', async () => {
|
||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(removeNonSidecarFiles(assetStub.image));
|
||||
mocks.asset.getById.mockResolvedValue({ exifInfo: { tags: ['Parent'] } } as any);
|
||||
mockReadTags({ Keywords: 'Parent' });
|
||||
mocks.tag.upsertValue.mockResolvedValue(tagStub.parentUpsert);
|
||||
|
||||
@@ -427,6 +430,7 @@ describe(MetadataService.name, () => {
|
||||
|
||||
it('should extract tags from Keywords as a list', async () => {
|
||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(removeNonSidecarFiles(assetStub.image));
|
||||
mocks.asset.getById.mockResolvedValue({ exifInfo: { tags: ['Parent'] } } as any);
|
||||
mockReadTags({ Keywords: ['Parent'] });
|
||||
mocks.tag.upsertValue.mockResolvedValue(tagStub.parentUpsert);
|
||||
|
||||
@@ -437,6 +441,7 @@ describe(MetadataService.name, () => {
|
||||
|
||||
it('should extract tags from Keywords as a list with a number', async () => {
|
||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(removeNonSidecarFiles(assetStub.image));
|
||||
mocks.asset.getById.mockResolvedValue({ exifInfo: { tags: ['Parent', '2024'] } } as any);
|
||||
mockReadTags({ Keywords: ['Parent', 2024] });
|
||||
mocks.tag.upsertValue.mockResolvedValue(tagStub.parentUpsert);
|
||||
|
||||
@@ -448,6 +453,7 @@ describe(MetadataService.name, () => {
|
||||
|
||||
it('should extract hierarchal tags from Keywords', async () => {
|
||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(removeNonSidecarFiles(assetStub.image));
|
||||
mocks.asset.getById.mockResolvedValue({ exifInfo: { tags: ['Parent/Child'] } } as any);
|
||||
mockReadTags({ Keywords: 'Parent/Child' });
|
||||
mocks.tag.upsertValue.mockResolvedValue(tagStub.parentUpsert);
|
||||
|
||||
@@ -467,6 +473,7 @@ describe(MetadataService.name, () => {
|
||||
|
||||
it('should ignore Keywords when TagsList is present', async () => {
|
||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(removeNonSidecarFiles(assetStub.image));
|
||||
mocks.asset.getById.mockResolvedValue({ exifInfo: { tags: ['Parent/Child', 'Child'] } } as any);
|
||||
mockReadTags({ Keywords: 'Child', TagsList: ['Parent/Child'] });
|
||||
mocks.tag.upsertValue.mockResolvedValue(tagStub.parentUpsert);
|
||||
|
||||
@@ -486,6 +493,7 @@ describe(MetadataService.name, () => {
|
||||
|
||||
it('should extract hierarchy from HierarchicalSubject', async () => {
|
||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(removeNonSidecarFiles(assetStub.image));
|
||||
mocks.asset.getById.mockResolvedValue({ exifInfo: { tags: ['Parent/Child', 'TagA'] } } as any);
|
||||
mockReadTags({ HierarchicalSubject: ['Parent|Child', 'TagA'] });
|
||||
mocks.tag.upsertValue.mockResolvedValueOnce(tagStub.parentUpsert);
|
||||
mocks.tag.upsertValue.mockResolvedValueOnce(tagStub.childUpsert);
|
||||
@@ -507,6 +515,7 @@ describe(MetadataService.name, () => {
|
||||
|
||||
it('should extract tags from HierarchicalSubject as a list with a number', async () => {
|
||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(removeNonSidecarFiles(assetStub.image));
|
||||
mocks.asset.getById.mockResolvedValue({ exifInfo: { tags: ['Parent', '2024'] } } as any);
|
||||
mockReadTags({ HierarchicalSubject: ['Parent', 2024] });
|
||||
mocks.tag.upsertValue.mockResolvedValue(tagStub.parentUpsert);
|
||||
|
||||
@@ -518,6 +527,7 @@ describe(MetadataService.name, () => {
|
||||
|
||||
it('should extract ignore / characters in a HierarchicalSubject tag', async () => {
|
||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(assetStub.image);
|
||||
mocks.asset.getById.mockResolvedValue({ exifInfo: { tags: ['Mom|Dad'] } } as any);
|
||||
mockReadTags({ HierarchicalSubject: ['Mom/Dad'] });
|
||||
mocks.tag.upsertValue.mockResolvedValueOnce(tagStub.parentUpsert);
|
||||
|
||||
@@ -532,6 +542,7 @@ describe(MetadataService.name, () => {
|
||||
|
||||
it('should ignore HierarchicalSubject when TagsList is present', async () => {
|
||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(assetStub.image);
|
||||
mocks.asset.getById.mockResolvedValue({ exifInfo: { tags: ['Parent/Child', 'Parent2/Child2'] } } as any);
|
||||
mockReadTags({ HierarchicalSubject: ['Parent2|Child2'], TagsList: ['Parent/Child'] });
|
||||
mocks.tag.upsertValue.mockResolvedValue(tagStub.parentUpsert);
|
||||
|
||||
@@ -896,6 +907,7 @@ describe(MetadataService.name, () => {
|
||||
ProfileDescription: 'extensive description',
|
||||
ProjectionType: 'equirectangular',
|
||||
tz: 'UTC-11:30',
|
||||
TagsList: ['parent/child'],
|
||||
Rating: 3,
|
||||
};
|
||||
|
||||
@@ -935,6 +947,7 @@ describe(MetadataService.name, () => {
|
||||
country: null,
|
||||
state: null,
|
||||
city: null,
|
||||
tags: ['parent/child'],
|
||||
},
|
||||
{ lockedPropertiesBehavior: 'skip' },
|
||||
);
|
||||
@@ -1084,6 +1097,7 @@ describe(MetadataService.name, () => {
|
||||
id: 'some-id',
|
||||
type: AssetFileType.Sidecar,
|
||||
path: '/path/to/something',
|
||||
isEdited: false,
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -1691,7 +1705,7 @@ describe(MetadataService.name, () => {
|
||||
it('should unset sidecar path if file no longer exist', async () => {
|
||||
const asset = forSidecarJob({
|
||||
originalPath: '/path/to/IMG_123.jpg',
|
||||
files: [{ id: 'sidecar', path: '/path/to/IMG_123.jpg.xmp', type: AssetFileType.Sidecar }],
|
||||
files: [{ id: 'sidecar', path: '/path/to/IMG_123.jpg.xmp', type: AssetFileType.Sidecar, isEdited: false }],
|
||||
});
|
||||
mocks.assetJob.getForSidecarCheckJob.mockResolvedValue(asset);
|
||||
mocks.storage.checkFileExists.mockResolvedValue(false);
|
||||
@@ -1704,7 +1718,7 @@ describe(MetadataService.name, () => {
|
||||
it('should do nothing if the sidecar file still exists', async () => {
|
||||
const asset = forSidecarJob({
|
||||
originalPath: '/path/to/IMG_123.jpg',
|
||||
files: [{ id: 'sidecar', path: '/path/to/IMG_123.jpg.xmp', type: AssetFileType.Sidecar }],
|
||||
files: [{ id: 'sidecar', path: '/path/to/IMG_123.jpg.xmp', type: AssetFileType.Sidecar, isEdited: false }],
|
||||
});
|
||||
|
||||
mocks.assetJob.getForSidecarCheckJob.mockResolvedValue(asset);
|
||||
|
||||
@@ -254,6 +254,8 @@ export class MetadataService extends BaseService {
|
||||
}
|
||||
}
|
||||
|
||||
const tags = this.getTagList(exifTags);
|
||||
|
||||
const exifData: Insertable<AssetExifTable> = {
|
||||
assetId: asset.id,
|
||||
|
||||
@@ -296,6 +298,8 @@ export class MetadataService extends BaseService {
|
||||
// grouping
|
||||
livePhotoCID: (exifTags.ContentIdentifier || exifTags.MediaGroupUUID) ?? null,
|
||||
autoStackId: this.getAutoStackId(exifTags),
|
||||
|
||||
tags: tags.length > 0 ? tags : null,
|
||||
};
|
||||
|
||||
const isSidewards = exifTags.Orientation && this.isOrientationSidewards(exifTags.Orientation);
|
||||
@@ -316,9 +320,10 @@ export class MetadataService extends BaseService {
|
||||
width: asset.width == null ? assetWidth : undefined,
|
||||
height: asset.height == null ? assetHeight : undefined,
|
||||
}),
|
||||
this.applyTagList(asset, exifTags),
|
||||
];
|
||||
|
||||
await this.applyTagList(asset);
|
||||
|
||||
if (this.isMotionPhoto(asset, exifTags)) {
|
||||
promises.push(this.applyMotionPhotos(asset, exifTags, dates, stats));
|
||||
}
|
||||
@@ -405,35 +410,35 @@ export class MetadataService extends BaseService {
|
||||
|
||||
@OnEvent({ name: 'AssetTag' })
|
||||
async handleTagAsset({ assetId }: ArgOf<'AssetTag'>) {
|
||||
await this.jobRepository.queue({ name: JobName.SidecarWrite, data: { id: assetId, tags: true } });
|
||||
await this.jobRepository.queue({ name: JobName.SidecarWrite, data: { id: assetId } });
|
||||
}
|
||||
|
||||
@OnEvent({ name: 'AssetUntag' })
|
||||
async handleUntagAsset({ assetId }: ArgOf<'AssetUntag'>) {
|
||||
await this.jobRepository.queue({ name: JobName.SidecarWrite, data: { id: assetId, tags: true } });
|
||||
await this.jobRepository.queue({ name: JobName.SidecarWrite, data: { id: assetId } });
|
||||
}
|
||||
|
||||
@OnJob({ name: JobName.SidecarWrite, queue: QueueName.Sidecar })
|
||||
async handleSidecarWrite(job: JobOf<JobName.SidecarWrite>): Promise<JobStatus> {
|
||||
const { id, tags } = job;
|
||||
const { id } = job;
|
||||
const asset = await this.assetJobRepository.getForSidecarWriteJob(id);
|
||||
if (!asset) {
|
||||
return JobStatus.Failed;
|
||||
}
|
||||
|
||||
const lockedProperties = await this.assetJobRepository.getLockedPropertiesForMetadataExtraction(id);
|
||||
const tagsList = (asset.tags || []).map((tag) => tag.value);
|
||||
|
||||
const { sidecarFile } = getAssetFiles(asset.files);
|
||||
const sidecarPath = sidecarFile?.path || `${asset.originalPath}.xmp`;
|
||||
|
||||
const { description, dateTimeOriginal, latitude, longitude, rating } = _.pick(
|
||||
const { description, dateTimeOriginal, latitude, longitude, rating, tags } = _.pick(
|
||||
{
|
||||
description: asset.exifInfo.description,
|
||||
dateTimeOriginal: asset.exifInfo.dateTimeOriginal,
|
||||
latitude: asset.exifInfo.latitude,
|
||||
longitude: asset.exifInfo.longitude,
|
||||
rating: asset.exifInfo.rating,
|
||||
tags: asset.exifInfo.tags,
|
||||
},
|
||||
lockedProperties,
|
||||
);
|
||||
@@ -446,7 +451,7 @@ export class MetadataService extends BaseService {
|
||||
GPSLatitude: latitude,
|
||||
GPSLongitude: longitude,
|
||||
Rating: rating,
|
||||
TagsList: tags ? tagsList : undefined,
|
||||
TagsList: tags?.length ? tags : undefined,
|
||||
},
|
||||
_.isUndefined,
|
||||
);
|
||||
@@ -560,11 +565,14 @@ export class MetadataService extends BaseService {
|
||||
return tags;
|
||||
}
|
||||
|
||||
private async applyTagList(asset: { id: string; ownerId: string }, exifTags: ImmichTags) {
|
||||
const tags = this.getTagList(exifTags);
|
||||
const results = await upsertTags(this.tagRepository, { userId: asset.ownerId, tags });
|
||||
private async applyTagList({ id, ownerId }: { id: string; ownerId: string }) {
|
||||
const asset = await this.assetRepository.getById(id, { exifInfo: true });
|
||||
const results = await upsertTags(this.tagRepository, {
|
||||
userId: ownerId,
|
||||
tags: asset?.exifInfo?.tags ?? [],
|
||||
});
|
||||
await this.tagRepository.replaceAssetTags(
|
||||
asset.id,
|
||||
id,
|
||||
results.map((tag) => tag.id),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -372,7 +372,7 @@ describe(NotificationService.name, () => {
|
||||
mocks.notification.create.mockResolvedValue(notificationStub.albumEvent);
|
||||
mocks.email.renderEmail.mockResolvedValue({ html: '', text: '' });
|
||||
mocks.assetJob.getAlbumThumbnailFiles.mockResolvedValue([
|
||||
{ id: '1', type: AssetFileType.Thumbnail, path: 'path-to-thumb.jpg' },
|
||||
{ id: '1', type: AssetFileType.Thumbnail, path: 'path-to-thumb.jpg', isEdited: false },
|
||||
]);
|
||||
|
||||
await expect(sut.handleAlbumInvite({ id: '', recipientId: '' })).resolves.toBe(JobStatus.Success);
|
||||
@@ -403,7 +403,7 @@ describe(NotificationService.name, () => {
|
||||
mocks.systemMetadata.get.mockResolvedValue({ server: {} });
|
||||
mocks.notification.create.mockResolvedValue(notificationStub.albumEvent);
|
||||
mocks.email.renderEmail.mockResolvedValue({ html: '', text: '' });
|
||||
mocks.assetJob.getAlbumThumbnailFiles.mockResolvedValue([assetStub.image.files[2]]);
|
||||
mocks.assetJob.getAlbumThumbnailFiles.mockResolvedValue([{ ...assetStub.image.files[2], isEdited: false }]);
|
||||
|
||||
await expect(sut.handleAlbumInvite({ id: '', recipientId: '' })).resolves.toBe(JobStatus.Success);
|
||||
expect(mocks.assetJob.getAlbumThumbnailFiles).toHaveBeenCalledWith(
|
||||
|
||||
@@ -240,11 +240,11 @@ export class StorageTemplateService extends BaseService {
|
||||
assetInfo: { sizeInBytes: fileSizeInByte, checksum },
|
||||
});
|
||||
|
||||
const sidecarPath = getAssetFile(asset.files, AssetFileType.Sidecar)?.path;
|
||||
const sidecarPath = getAssetFile(asset.files, AssetFileType.Sidecar, { isEdited: false })?.path;
|
||||
if (sidecarPath) {
|
||||
await this.storageCore.moveFile({
|
||||
entityId: id,
|
||||
pathType: AssetPathType.Sidecar,
|
||||
pathType: AssetFileType.Sidecar,
|
||||
oldPath: sidecarPath,
|
||||
newPath: `${newPath}.xmp`,
|
||||
});
|
||||
|
||||
@@ -191,6 +191,7 @@ describe(TagService.name, () => {
|
||||
it('should upsert records', async () => {
|
||||
mocks.access.tag.checkOwnerAccess.mockResolvedValue(new Set(['tag-1', 'tag-2']));
|
||||
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1', 'asset-2', 'asset-3']));
|
||||
mocks.asset.getById.mockResolvedValue({ tags: [{ value: 'tag-1' }, { value: 'tag-2' }] } as any);
|
||||
mocks.tag.upsertAssetIds.mockResolvedValue([
|
||||
{ tagId: 'tag-1', assetId: 'asset-1' },
|
||||
{ tagId: 'tag-1', assetId: 'asset-2' },
|
||||
@@ -204,6 +205,18 @@ describe(TagService.name, () => {
|
||||
).resolves.toEqual({
|
||||
count: 6,
|
||||
});
|
||||
expect(mocks.asset.upsertExif).toHaveBeenCalledWith(
|
||||
{ assetId: 'asset-1', tags: ['tag-1', 'tag-2'] },
|
||||
{ lockedPropertiesBehavior: 'append' },
|
||||
);
|
||||
expect(mocks.asset.upsertExif).toHaveBeenCalledWith(
|
||||
{ assetId: 'asset-2', tags: ['tag-1', 'tag-2'] },
|
||||
{ lockedPropertiesBehavior: 'append' },
|
||||
);
|
||||
expect(mocks.asset.upsertExif).toHaveBeenCalledWith(
|
||||
{ assetId: 'asset-3', tags: ['tag-1', 'tag-2'] },
|
||||
{ lockedPropertiesBehavior: 'append' },
|
||||
);
|
||||
expect(mocks.tag.upsertAssetIds).toHaveBeenCalledWith([
|
||||
{ tagId: 'tag-1', assetId: 'asset-1' },
|
||||
{ tagId: 'tag-1', assetId: 'asset-2' },
|
||||
@@ -229,6 +242,7 @@ describe(TagService.name, () => {
|
||||
mocks.tag.get.mockResolvedValue(tagStub.tag);
|
||||
mocks.tag.getAssetIds.mockResolvedValue(new Set(['asset-1']));
|
||||
mocks.tag.addAssetIds.mockResolvedValue();
|
||||
mocks.asset.getById.mockResolvedValue({ tags: [{ value: 'tag-1' }] } as any);
|
||||
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-2']));
|
||||
|
||||
await expect(
|
||||
@@ -240,6 +254,14 @@ describe(TagService.name, () => {
|
||||
{ id: 'asset-2', success: true },
|
||||
]);
|
||||
|
||||
expect(mocks.asset.upsertExif).not.toHaveBeenCalledWith(
|
||||
{ assetId: 'asset-1', tags: ['tag-1'] },
|
||||
{ lockedPropertiesBehavior: 'append' },
|
||||
);
|
||||
expect(mocks.asset.upsertExif).toHaveBeenCalledWith(
|
||||
{ assetId: 'asset-2', tags: ['tag-1'] },
|
||||
{ lockedPropertiesBehavior: 'append' },
|
||||
);
|
||||
expect(mocks.tag.getAssetIds).toHaveBeenCalledWith('tag-1', ['asset-1', 'asset-2']);
|
||||
expect(mocks.tag.addAssetIds).toHaveBeenCalledWith('tag-1', ['asset-2']);
|
||||
});
|
||||
|
||||
@@ -90,6 +90,7 @@ export class TagService extends BaseService {
|
||||
|
||||
const results = await this.tagRepository.upsertAssetIds(items);
|
||||
for (const assetId of new Set(results.map((item) => item.assetId))) {
|
||||
await this.updateTags(assetId);
|
||||
await this.eventRepository.emit('AssetTag', { assetId });
|
||||
}
|
||||
|
||||
@@ -107,6 +108,7 @@ export class TagService extends BaseService {
|
||||
|
||||
for (const { id: assetId, success } of results) {
|
||||
if (success) {
|
||||
await this.updateTags(assetId);
|
||||
await this.eventRepository.emit('AssetTag', { assetId });
|
||||
}
|
||||
}
|
||||
@@ -125,6 +127,7 @@ export class TagService extends BaseService {
|
||||
|
||||
for (const { id: assetId, success } of results) {
|
||||
if (success) {
|
||||
await this.updateTags(assetId);
|
||||
await this.eventRepository.emit('AssetUntag', { assetId });
|
||||
}
|
||||
}
|
||||
@@ -145,4 +148,12 @@ export class TagService extends BaseService {
|
||||
}
|
||||
return tag;
|
||||
}
|
||||
|
||||
private async updateTags(assetId: string) {
|
||||
const asset = await this.assetRepository.getById(assetId, { tags: true });
|
||||
await this.assetRepository.upsertExif(
|
||||
{ assetId, tags: asset?.tags?.map(({ value }) => value) ?? [] },
|
||||
{ lockedPropertiesBehavior: 'append' },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Asset, AssetFile } from 'src/database';
|
||||
import { UploadFieldName } from 'src/dtos/asset-media.dto';
|
||||
import { AuthDto } from 'src/dtos/auth.dto';
|
||||
import { AssetEditActionItem } from 'src/dtos/editing.dto';
|
||||
import { SetMaintenanceModeDto } from 'src/dtos/maintenance.dto';
|
||||
import {
|
||||
AssetOrder,
|
||||
AssetType,
|
||||
@@ -317,7 +318,7 @@ export type JobItem =
|
||||
// Sidecar Scanning
|
||||
| { name: JobName.SidecarQueueAll; data: IBaseJob }
|
||||
| { name: JobName.SidecarCheck; data: IEntityJob }
|
||||
| { name: JobName.SidecarWrite; data: ISidecarWriteJob }
|
||||
| { name: JobName.SidecarWrite; data: IEntityJob }
|
||||
|
||||
// Facial Recognition
|
||||
| { name: JobName.AssetDetectFacesQueueAll; data: IBaseJob }
|
||||
@@ -481,7 +482,9 @@ 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 MaintenanceModeState =
|
||||
| { isMaintenanceMode: true; secret: string; action: SetMaintenanceModeDto }
|
||||
| { isMaintenanceMode: false };
|
||||
export type MemoriesState = {
|
||||
/** memories have already been created through this date */
|
||||
lastOnThisDayDate: string;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
import { GeneratedImageType, StorageCore } from 'src/cores/storage.core';
|
||||
import { StorageCore } from 'src/cores/storage.core';
|
||||
import { AssetFile, Exif } from 'src/database';
|
||||
import { BulkIdErrorReason, BulkIdResponseDto } from 'src/dtos/asset-ids.response.dto';
|
||||
import { UploadFieldName } from 'src/dtos/asset-media.dto';
|
||||
@@ -14,19 +14,19 @@ import { PartnerRepository } from 'src/repositories/partner.repository';
|
||||
import { IBulkAsset, ImmichFile, UploadFile, UploadRequest } from 'src/types';
|
||||
import { checkAccess } from 'src/utils/access';
|
||||
|
||||
export const getAssetFile = (files: AssetFile[], type: AssetFileType | GeneratedImageType) => {
|
||||
return files.find((file) => file.type === type);
|
||||
export const getAssetFile = (files: AssetFile[], type: AssetFileType, { isEdited }: { isEdited: boolean }) => {
|
||||
return files.find((file) => file.type === type && file.isEdited === isEdited);
|
||||
};
|
||||
|
||||
export const getAssetFiles = (files: AssetFile[]) => ({
|
||||
fullsizeFile: getAssetFile(files, AssetFileType.FullSize),
|
||||
previewFile: getAssetFile(files, AssetFileType.Preview),
|
||||
thumbnailFile: getAssetFile(files, AssetFileType.Thumbnail),
|
||||
sidecarFile: getAssetFile(files, AssetFileType.Sidecar),
|
||||
fullsizeFile: getAssetFile(files, AssetFileType.FullSize, { isEdited: false }),
|
||||
previewFile: getAssetFile(files, AssetFileType.Preview, { isEdited: false }),
|
||||
thumbnailFile: getAssetFile(files, AssetFileType.Thumbnail, { isEdited: false }),
|
||||
sidecarFile: getAssetFile(files, AssetFileType.Sidecar, { isEdited: false }),
|
||||
|
||||
editedFullsizeFile: getAssetFile(files, AssetFileType.FullSizeEdited),
|
||||
editedPreviewFile: getAssetFile(files, AssetFileType.PreviewEdited),
|
||||
editedThumbnailFile: getAssetFile(files, AssetFileType.ThumbnailEdited),
|
||||
editedFullsizeFile: getAssetFile(files, AssetFileType.FullSize, { isEdited: true }),
|
||||
editedPreviewFile: getAssetFile(files, AssetFileType.Preview, { isEdited: true }),
|
||||
editedThumbnailFile: getAssetFile(files, AssetFileType.Preview, { isEdited: true }),
|
||||
});
|
||||
|
||||
export const addAssets = async (
|
||||
|
||||
494
server/src/utils/database-backups.ts
Normal file
494
server/src/utils/database-backups.ts
Normal file
@@ -0,0 +1,494 @@
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
import { debounce } from 'lodash';
|
||||
import { DateTime } from 'luxon';
|
||||
import path, { basename, join } from 'node:path';
|
||||
import { PassThrough, Readable, Writable } from 'node:stream';
|
||||
import { pipeline } from 'node:stream/promises';
|
||||
import semver from 'semver';
|
||||
import { serverVersion } from 'src/constants';
|
||||
import { StorageCore } from 'src/cores/storage.core';
|
||||
import { CacheControl, StorageFolder } from 'src/enum';
|
||||
import { MaintenanceHealthRepository } from 'src/maintenance/maintenance-health.repository';
|
||||
import { ConfigRepository } from 'src/repositories/config.repository';
|
||||
import { DatabaseRepository } from 'src/repositories/database.repository';
|
||||
import { LoggingRepository } from 'src/repositories/logging.repository';
|
||||
import { ProcessRepository } from 'src/repositories/process.repository';
|
||||
import { StorageRepository } from 'src/repositories/storage.repository';
|
||||
|
||||
export function isValidDatabaseBackupName(filename: string) {
|
||||
return filename.match(/^[\d\w-.]+\.sql(?:\.gz)?$/);
|
||||
}
|
||||
|
||||
export function isValidDatabaseRoutineBackupName(filename: string) {
|
||||
const oldBackupStyle = filename.match(/^immich-db-backup-\d+\.sql\.gz$/);
|
||||
//immich-db-backup-20250729T114018-v1.136.0-pg14.17.sql.gz
|
||||
const newBackupStyle = filename.match(/^immich-db-backup-\d{8}T\d{6}-v.*-pg.*\.sql\.gz$/);
|
||||
return oldBackupStyle || newBackupStyle;
|
||||
}
|
||||
|
||||
export function isFailedDatabaseBackupName(filename: string) {
|
||||
return filename.match(/^immich-db-backup-.*\.sql\.gz\.tmp$/);
|
||||
}
|
||||
|
||||
export function findVersion(filename: string) {
|
||||
return /-v(.*)-/.exec(filename)?.[1];
|
||||
}
|
||||
|
||||
type BackupRepos = {
|
||||
logger: LoggingRepository;
|
||||
storage: StorageRepository;
|
||||
config: ConfigRepository;
|
||||
process: ProcessRepository;
|
||||
database: DatabaseRepository;
|
||||
health: MaintenanceHealthRepository;
|
||||
};
|
||||
|
||||
export class UnsupportedPostgresError extends Error {
|
||||
constructor(databaseVersion: string) {
|
||||
super(`Unsupported PostgreSQL version: ${databaseVersion}`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function buildPostgresLaunchArguments(
|
||||
{ logger, config, database }: Pick<BackupRepos, 'logger' | 'config' | 'database'>,
|
||||
bin: 'pg_dump' | 'pg_dumpall' | 'psql',
|
||||
options: {
|
||||
singleTransaction?: boolean;
|
||||
username?: string;
|
||||
} = {},
|
||||
): Promise<{
|
||||
bin: string;
|
||||
args: string[];
|
||||
databasePassword: string;
|
||||
databaseVersion: string;
|
||||
databaseMajorVersion?: number;
|
||||
}> {
|
||||
const {
|
||||
database: { config: databaseConfig },
|
||||
} = config.getEnv();
|
||||
const isUrlConnection = databaseConfig.connectionType === 'url';
|
||||
|
||||
const databaseVersion = await database.getPostgresVersion();
|
||||
const databaseSemver = semver.coerce(databaseVersion);
|
||||
const databaseMajorVersion = databaseSemver?.major;
|
||||
|
||||
const args: string[] = [];
|
||||
|
||||
if (isUrlConnection) {
|
||||
if (bin !== 'pg_dump') {
|
||||
args.push('--dbname');
|
||||
}
|
||||
|
||||
let url = databaseConfig.url;
|
||||
if (URL.canParse(databaseConfig.url)) {
|
||||
const parsedUrl = new URL(databaseConfig.url);
|
||||
// remove known bad parameters
|
||||
parsedUrl.searchParams.delete('uselibpqcompat');
|
||||
|
||||
if (options.username) {
|
||||
parsedUrl.username = options.username;
|
||||
}
|
||||
|
||||
url = parsedUrl.toString();
|
||||
}
|
||||
|
||||
args.push(url);
|
||||
} else {
|
||||
args.push(
|
||||
'--username',
|
||||
options.username ?? databaseConfig.username,
|
||||
'--host',
|
||||
databaseConfig.host,
|
||||
'--port',
|
||||
databaseConfig.port.toString(),
|
||||
);
|
||||
|
||||
switch (bin) {
|
||||
case 'pg_dumpall': {
|
||||
args.push('--database');
|
||||
break;
|
||||
}
|
||||
case 'psql': {
|
||||
args.push('--dbname');
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
args.push(databaseConfig.database);
|
||||
}
|
||||
|
||||
switch (bin) {
|
||||
case 'pg_dump':
|
||||
case 'pg_dumpall': {
|
||||
args.push('--clean', '--if-exists');
|
||||
break;
|
||||
}
|
||||
case 'psql': {
|
||||
if (options.singleTransaction) {
|
||||
args.push(
|
||||
// don't commit any transaction on failure
|
||||
'--single-transaction',
|
||||
// exit with non-zero code on error
|
||||
'--set',
|
||||
'ON_ERROR_STOP=on',
|
||||
);
|
||||
}
|
||||
|
||||
args.push(
|
||||
// used for progress monitoring
|
||||
'--echo-all',
|
||||
'--output=/dev/null',
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!databaseMajorVersion || !databaseSemver || !semver.satisfies(databaseSemver, '>=14.0.0 <19.0.0')) {
|
||||
logger.error(`Database Restore Failure: Unsupported PostgreSQL version: ${databaseVersion}`);
|
||||
throw new UnsupportedPostgresError(databaseVersion);
|
||||
}
|
||||
|
||||
return {
|
||||
bin: `/usr/lib/postgresql/${databaseMajorVersion}/bin/${bin}`,
|
||||
args,
|
||||
databasePassword: isUrlConnection ? new URL(databaseConfig.url).password : databaseConfig.password,
|
||||
databaseVersion,
|
||||
databaseMajorVersion,
|
||||
};
|
||||
}
|
||||
|
||||
export async function createDatabaseBackup(
|
||||
{ logger, storage, process: processRepository, ...pgRepos }: Omit<BackupRepos, 'health'>,
|
||||
filenamePrefix: string = '',
|
||||
): Promise<string> {
|
||||
logger.debug(`Database Backup Started`);
|
||||
|
||||
const { bin, args, databasePassword, databaseVersion, databaseMajorVersion } = await buildPostgresLaunchArguments(
|
||||
{ logger, ...pgRepos },
|
||||
'pg_dump',
|
||||
);
|
||||
|
||||
logger.log(`Database Backup Starting. Database Version: ${databaseMajorVersion}`);
|
||||
|
||||
const filename = `${filenamePrefix}immich-db-backup-${DateTime.now().toFormat("yyyyLLdd'T'HHmmss")}-v${serverVersion.toString()}-pg${databaseVersion.split(' ')[0]}.sql.gz`;
|
||||
const backupFilePath = join(StorageCore.getBaseFolder(StorageFolder.Backups), filename);
|
||||
const temporaryFilePath = `${backupFilePath}.tmp`;
|
||||
|
||||
try {
|
||||
const pgdump = processRepository.spawnDuplexStream(bin, args, {
|
||||
env: {
|
||||
PATH: process.env.PATH,
|
||||
PGPASSWORD: databasePassword,
|
||||
},
|
||||
});
|
||||
|
||||
const gzip = processRepository.spawnDuplexStream('gzip', ['--rsyncable']);
|
||||
const fileStream = storage.createWriteStream(temporaryFilePath);
|
||||
|
||||
await pipeline(pgdump, gzip, fileStream);
|
||||
await storage.rename(temporaryFilePath, backupFilePath);
|
||||
} catch (error) {
|
||||
logger.error(`Database Backup Failure: ${error}`);
|
||||
await storage
|
||||
.unlink(temporaryFilePath)
|
||||
.catch((error) => logger.error(`Failed to delete failed backup file: ${error}`));
|
||||
throw error;
|
||||
}
|
||||
|
||||
logger.log(`Database Backup Success`);
|
||||
return backupFilePath;
|
||||
}
|
||||
|
||||
const SQL_DROP_CONNECTIONS = `
|
||||
-- drop all other database connections
|
||||
SELECT pg_terminate_backend(pid)
|
||||
FROM pg_stat_activity
|
||||
WHERE datname = current_database()
|
||||
AND pid <> pg_backend_pid();
|
||||
`;
|
||||
|
||||
const SQL_RESET_SCHEMA = `
|
||||
-- re-create the default schema
|
||||
DROP SCHEMA public CASCADE;
|
||||
CREATE SCHEMA public;
|
||||
|
||||
-- restore access to schema
|
||||
GRANT ALL ON SCHEMA public TO postgres;
|
||||
GRANT ALL ON SCHEMA public TO public;
|
||||
`;
|
||||
|
||||
async function* sql(inputStream: Readable, isPgClusterDump: boolean) {
|
||||
yield SQL_DROP_CONNECTIONS;
|
||||
yield isPgClusterDump
|
||||
? String.raw`
|
||||
\c postgres
|
||||
`
|
||||
: SQL_RESET_SCHEMA;
|
||||
|
||||
for await (const chunk of inputStream) {
|
||||
yield chunk;
|
||||
}
|
||||
}
|
||||
|
||||
async function* sqlRollback(inputStream: Readable, isPgClusterDump: boolean) {
|
||||
yield SQL_DROP_CONNECTIONS;
|
||||
|
||||
if (isPgClusterDump) {
|
||||
yield String.raw`
|
||||
-- try to create database
|
||||
-- may fail but script will continue running
|
||||
CREATE DATABASE immich;
|
||||
|
||||
-- switch to database / newly created database
|
||||
\c immich
|
||||
`;
|
||||
}
|
||||
|
||||
yield SQL_RESET_SCHEMA;
|
||||
|
||||
for await (const chunk of inputStream) {
|
||||
yield chunk;
|
||||
}
|
||||
}
|
||||
|
||||
export async function restoreDatabaseBackup(
|
||||
{ logger, storage, process: processRepository, database: databaseRepository, health, ...pgRepos }: BackupRepos,
|
||||
filename: string,
|
||||
progressCb?: (action: 'backup' | 'restore' | 'migrations' | 'rollback', progress: number) => void,
|
||||
): Promise<void> {
|
||||
logger.debug(`Database Restore Started`);
|
||||
|
||||
let complete = false;
|
||||
try {
|
||||
if (!isValidDatabaseBackupName(filename)) {
|
||||
throw new Error('Invalid backup file format!');
|
||||
}
|
||||
|
||||
const backupFilePath = path.join(StorageCore.getBaseFolder(StorageFolder.Backups), filename);
|
||||
await storage.stat(backupFilePath); // => check file exists
|
||||
|
||||
let isPgClusterDump = false;
|
||||
const version = findVersion(filename);
|
||||
if (version && semver.satisfies(version, '<= 2.4')) {
|
||||
isPgClusterDump = true;
|
||||
}
|
||||
|
||||
const { bin, args, databasePassword, databaseMajorVersion } = await buildPostgresLaunchArguments(
|
||||
{ logger, database: databaseRepository, ...pgRepos },
|
||||
'psql',
|
||||
{
|
||||
singleTransaction: !isPgClusterDump,
|
||||
username: isPgClusterDump ? 'postgres' : undefined,
|
||||
},
|
||||
);
|
||||
|
||||
progressCb?.('backup', 0.05);
|
||||
|
||||
const restorePointFilePath = await createDatabaseBackup(
|
||||
{ logger, storage, process: processRepository, database: databaseRepository, ...pgRepos },
|
||||
'restore-point-',
|
||||
);
|
||||
|
||||
logger.log(`Database Restore Starting. Database Version: ${databaseMajorVersion}`);
|
||||
|
||||
let inputStream: Readable;
|
||||
if (backupFilePath.endsWith('.gz')) {
|
||||
const fileStream = storage.createPlainReadStream(backupFilePath);
|
||||
const gunzip = storage.createGunzip();
|
||||
fileStream.pipe(gunzip);
|
||||
inputStream = gunzip;
|
||||
} else {
|
||||
inputStream = storage.createPlainReadStream(backupFilePath);
|
||||
}
|
||||
|
||||
const sqlStream = Readable.from(sql(inputStream, isPgClusterDump));
|
||||
const psql = processRepository.spawnDuplexStream(bin, args, {
|
||||
env: {
|
||||
PATH: process.env.PATH,
|
||||
PGPASSWORD: databasePassword,
|
||||
},
|
||||
});
|
||||
|
||||
const [progressSource, progressSink] = createSqlProgressStreams((progress) => {
|
||||
if (complete) {
|
||||
return;
|
||||
}
|
||||
|
||||
logger.log(`Restore progress ~ ${(progress * 100).toFixed(2)}%`);
|
||||
progressCb?.('restore', progress);
|
||||
});
|
||||
|
||||
await pipeline(sqlStream, progressSource, psql, progressSink);
|
||||
|
||||
try {
|
||||
progressCb?.('migrations', 0.9);
|
||||
await databaseRepository.runMigrations();
|
||||
await health.checkApiHealth();
|
||||
} catch (error) {
|
||||
progressCb?.('rollback', 0);
|
||||
|
||||
const fileStream = storage.createPlainReadStream(restorePointFilePath);
|
||||
const gunzip = storage.createGunzip();
|
||||
fileStream.pipe(gunzip);
|
||||
inputStream = gunzip;
|
||||
|
||||
const sqlStream = Readable.from(sqlRollback(inputStream, isPgClusterDump));
|
||||
const psql = processRepository.spawnDuplexStream(bin, args, {
|
||||
env: {
|
||||
PATH: process.env.PATH,
|
||||
PGPASSWORD: databasePassword,
|
||||
},
|
||||
});
|
||||
|
||||
const [progressSource, progressSink] = createSqlProgressStreams((progress) => {
|
||||
if (complete) {
|
||||
return;
|
||||
}
|
||||
|
||||
logger.log(`Rollback progress ~ ${(progress * 100).toFixed(2)}%`);
|
||||
progressCb?.('rollback', progress);
|
||||
});
|
||||
|
||||
await pipeline(sqlStream, progressSource, psql, progressSink);
|
||||
|
||||
throw error;
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`Database Restore Failure: ${error}`);
|
||||
throw error;
|
||||
} finally {
|
||||
complete = true;
|
||||
}
|
||||
|
||||
logger.log(`Database Restore Success`);
|
||||
}
|
||||
|
||||
export async function deleteDatabaseBackup({ storage }: Pick<BackupRepos, 'storage'>, files: string[]): Promise<void> {
|
||||
const backupsFolder = StorageCore.getBaseFolder(StorageFolder.Backups);
|
||||
|
||||
if (files.some((filename) => !isValidDatabaseBackupName(filename))) {
|
||||
throw new BadRequestException('Invalid backup name!');
|
||||
}
|
||||
|
||||
await Promise.all(files.map((filename) => storage.unlink(path.join(backupsFolder, filename))));
|
||||
}
|
||||
|
||||
export async function listDatabaseBackups({
|
||||
storage,
|
||||
}: Pick<BackupRepos, 'storage'>): Promise<{ filename: string; filesize: number }[]> {
|
||||
const backupsFolder = StorageCore.getBaseFolder(StorageFolder.Backups);
|
||||
const files = await storage.readdir(backupsFolder);
|
||||
|
||||
const validFiles = files
|
||||
.filter((fn) => isValidDatabaseBackupName(fn))
|
||||
.toSorted((a, b) => (a.startsWith('uploaded-') === b.startsWith('uploaded-') ? a.localeCompare(b) : 1))
|
||||
.toReversed();
|
||||
|
||||
const backups = await Promise.all(
|
||||
validFiles.map(async (filename) => {
|
||||
const stats = await storage.stat(path.join(backupsFolder, filename));
|
||||
return { filename, filesize: stats.size };
|
||||
}),
|
||||
);
|
||||
|
||||
return backups;
|
||||
}
|
||||
|
||||
export async function uploadDatabaseBackup(
|
||||
{ storage }: Pick<BackupRepos, 'storage'>,
|
||||
file: Express.Multer.File,
|
||||
): Promise<void> {
|
||||
const backupsFolder = StorageCore.getBaseFolder(StorageFolder.Backups);
|
||||
const fn = basename(file.originalname);
|
||||
if (!isValidDatabaseBackupName(fn)) {
|
||||
throw new BadRequestException('Invalid backup name!');
|
||||
}
|
||||
|
||||
const path = join(backupsFolder, `uploaded-${fn}`);
|
||||
await storage.createOrOverwriteFile(path, file.buffer);
|
||||
}
|
||||
|
||||
export function downloadDatabaseBackup(fileName: string) {
|
||||
if (!isValidDatabaseBackupName(fileName)) {
|
||||
throw new BadRequestException('Invalid backup name!');
|
||||
}
|
||||
|
||||
const path = join(StorageCore.getBaseFolder(StorageFolder.Backups), fileName);
|
||||
|
||||
return {
|
||||
path,
|
||||
fileName,
|
||||
cacheControl: CacheControl.PrivateWithoutCache,
|
||||
contentType: fileName.endsWith('.gz') ? 'application/gzip' : 'application/sql',
|
||||
};
|
||||
}
|
||||
|
||||
function createSqlProgressStreams(cb: (progress: number) => void) {
|
||||
const STDIN_START_MARKER = new TextEncoder().encode('FROM stdin');
|
||||
const STDIN_END_MARKER = new TextEncoder().encode(String.raw`\.`);
|
||||
|
||||
let readingStdin = false;
|
||||
let sequenceIdx = 0;
|
||||
|
||||
let linesSent = 0;
|
||||
let linesProcessed = 0;
|
||||
|
||||
const startedAt = +Date.now();
|
||||
const cbDebounced = debounce(
|
||||
() => {
|
||||
const progress = source.writableEnded
|
||||
? Math.min(1, linesProcessed / linesSent)
|
||||
: // progress simulation while we're in an indeterminate state
|
||||
Math.min(0.3, 0.1 + (Date.now() - startedAt) / 1e4);
|
||||
cb(progress);
|
||||
},
|
||||
100,
|
||||
{
|
||||
maxWait: 100,
|
||||
},
|
||||
);
|
||||
|
||||
let lastByte = -1;
|
||||
const source = new PassThrough({
|
||||
transform(chunk, _encoding, callback) {
|
||||
for (const byte of chunk) {
|
||||
if (!readingStdin && byte === 10 && lastByte !== 10) {
|
||||
linesSent += 1;
|
||||
}
|
||||
|
||||
lastByte = byte;
|
||||
|
||||
const sequence = readingStdin ? STDIN_END_MARKER : STDIN_START_MARKER;
|
||||
if (sequence[sequenceIdx] === byte) {
|
||||
sequenceIdx += 1;
|
||||
|
||||
if (sequence.length === sequenceIdx) {
|
||||
sequenceIdx = 0;
|
||||
readingStdin = !readingStdin;
|
||||
}
|
||||
} else {
|
||||
sequenceIdx = 0;
|
||||
}
|
||||
}
|
||||
|
||||
cbDebounced();
|
||||
this.push(chunk);
|
||||
callback();
|
||||
},
|
||||
});
|
||||
|
||||
const sink = new Writable({
|
||||
write(chunk, _encoding, callback) {
|
||||
for (const byte of chunk) {
|
||||
if (byte === 10) {
|
||||
linesProcessed++;
|
||||
}
|
||||
}
|
||||
|
||||
cbDebounced();
|
||||
callback();
|
||||
},
|
||||
});
|
||||
|
||||
return [source, sink];
|
||||
}
|
||||
@@ -42,7 +42,7 @@ const cacheControlHeaders: Record<CacheControl, string | null> = {
|
||||
export const sendFile = async (
|
||||
res: Response,
|
||||
next: NextFunction,
|
||||
handler: () => Promise<ImmichFileResponse>,
|
||||
handler: () => Promise<ImmichFileResponse> | ImmichFileResponse,
|
||||
logger: LoggingRepository,
|
||||
): Promise<void> => {
|
||||
// promisified version of 'res.sendFile' for cleaner async handling
|
||||
|
||||
@@ -1,6 +1,59 @@
|
||||
import { createAdapter } from '@socket.io/redis-adapter';
|
||||
import Redis from 'ioredis';
|
||||
import { SignJWT } from 'jose';
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import { MaintenanceAuthDto } from 'src/dtos/maintenance.dto';
|
||||
import { join } from 'node:path';
|
||||
import { Server as SocketIO } from 'socket.io';
|
||||
import { StorageCore } from 'src/cores/storage.core';
|
||||
import { MaintenanceAuthDto, MaintenanceDetectInstallResponseDto } from 'src/dtos/maintenance.dto';
|
||||
import { StorageFolder } from 'src/enum';
|
||||
import { ConfigRepository } from 'src/repositories/config.repository';
|
||||
import { AppRestartEvent } from 'src/repositories/event.repository';
|
||||
import { StorageRepository } from 'src/repositories/storage.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,
|
||||
@@ -23,3 +76,37 @@ export async function signMaintenanceJwt(secret: string, data: MaintenanceAuthDt
|
||||
export function generateMaintenanceSecret(): string {
|
||||
return randomBytes(64).toString('hex');
|
||||
}
|
||||
|
||||
export async function detectPriorInstall(
|
||||
storageRepository: StorageRepository,
|
||||
): Promise<MaintenanceDetectInstallResponseDto> {
|
||||
return {
|
||||
storage: await Promise.all(
|
||||
Object.values(StorageFolder).map(async (folder) => {
|
||||
const path = StorageCore.getBaseFolder(folder);
|
||||
const files = await storageRepository.readdir(path);
|
||||
const filename = join(StorageCore.getBaseFolder(folder), '.immich');
|
||||
|
||||
let readable = false,
|
||||
writable = false;
|
||||
|
||||
try {
|
||||
await storageRepository.readFile(filename);
|
||||
readable = true;
|
||||
|
||||
await storageRepository.overwriteFile(filename, Buffer.from(`${Date.now()}`));
|
||||
writable = true;
|
||||
} catch {
|
||||
// no-op
|
||||
}
|
||||
|
||||
return {
|
||||
folder,
|
||||
readable,
|
||||
writable,
|
||||
files: files.filter((fn) => fn !== '.immich').length,
|
||||
};
|
||||
}),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -139,6 +139,16 @@ export class UUIDAssetIDParamDto {
|
||||
assetId!: string;
|
||||
}
|
||||
|
||||
export class FilenameParamDto {
|
||||
@IsNotEmpty()
|
||||
@IsString()
|
||||
@ApiProperty({ format: 'string' })
|
||||
@Matches(/^[a-zA-Z0-9_\-.]+$/, {
|
||||
message: 'Filename contains invalid characters',
|
||||
})
|
||||
filename!: string;
|
||||
}
|
||||
|
||||
type PinCodeOptions = { optional?: boolean } & OptionalOptions;
|
||||
export const PinCode = (options?: PinCodeOptions & ApiPropertyOptions) => {
|
||||
const { optional, nullable, emptyToNull, ...apiPropertyOptions } = {
|
||||
|
||||
@@ -12,12 +12,11 @@ async function bootstrap() {
|
||||
|
||||
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) => {
|
||||
|
||||
9
server/test/fixtures/asset.stub.ts
vendored
9
server/test/fixtures/asset.stub.ts
vendored
@@ -31,18 +31,21 @@ const sidecarFileWithoutExt = factory.assetFile({
|
||||
});
|
||||
|
||||
const editedPreviewFile = factory.assetFile({
|
||||
type: AssetFileType.PreviewEdited,
|
||||
type: AssetFileType.Preview,
|
||||
path: '/uploads/user-id/preview/path_edited.jpg',
|
||||
isEdited: true,
|
||||
});
|
||||
|
||||
const editedThumbnailFile = factory.assetFile({
|
||||
type: AssetFileType.ThumbnailEdited,
|
||||
type: AssetFileType.Thumbnail,
|
||||
path: '/uploads/user-id/thumbnail/path_edited.jpg',
|
||||
isEdited: true,
|
||||
});
|
||||
|
||||
const editedFullsizeFile = factory.assetFile({
|
||||
type: AssetFileType.FullSizeEdited,
|
||||
type: AssetFileType.FullSize,
|
||||
path: '/uploads/user-id/fullsize/path_edited.jpg',
|
||||
isEdited: true,
|
||||
});
|
||||
|
||||
const files: AssetFile[] = [fullsizeFile, previewFile, thumbnailFile];
|
||||
|
||||
1
server/test/fixtures/shared-link.stub.ts
vendored
1
server/test/fixtures/shared-link.stub.ts
vendored
@@ -147,6 +147,7 @@ export const sharedLinkStub = {
|
||||
visibility: AssetVisibility.Timeline,
|
||||
width: 500,
|
||||
height: 500,
|
||||
tags: [],
|
||||
},
|
||||
sharedLinks: [],
|
||||
faces: [],
|
||||
|
||||
@@ -49,6 +49,9 @@ export const newStorageRepositoryMock = (): Mocked<RepositoryInterface<StorageRe
|
||||
return {
|
||||
createZipStream: vitest.fn(),
|
||||
createReadStream: vitest.fn(),
|
||||
createPlainReadStream: vitest.fn(),
|
||||
createGzip: vitest.fn(),
|
||||
createGunzip: vitest.fn(),
|
||||
readFile: vitest.fn(),
|
||||
readTextFile: vitest.fn(),
|
||||
createFile: vitest.fn(),
|
||||
|
||||
@@ -335,6 +335,7 @@ const assetSidecarWriteFactory = () => {
|
||||
id: newUuid(),
|
||||
path: '/path/to/original-path.jpg.xmp',
|
||||
type: AssetFileType.Sidecar,
|
||||
isEdited: false,
|
||||
},
|
||||
],
|
||||
exifInfo: {
|
||||
@@ -386,6 +387,7 @@ const assetFileFactory = (file: Partial<AssetFile> = {}): AssetFile => ({
|
||||
id: newUuid(),
|
||||
type: AssetFileType.Preview,
|
||||
path: '/uploads/user-id/thumbs/path.jpg',
|
||||
isEdited: false,
|
||||
...file,
|
||||
});
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ import { NextFunction } from 'express';
|
||||
import { Kysely } from 'kysely';
|
||||
import multer from 'multer';
|
||||
import { ChildProcessWithoutNullStreams } from 'node:child_process';
|
||||
import { Readable, Writable } from 'node:stream';
|
||||
import { Duplex, Readable, Writable } from 'node:stream';
|
||||
import { PNG } from 'pngjs';
|
||||
import postgres from 'postgres';
|
||||
import { UploadFieldName } from 'src/dtos/asset-media.dto';
|
||||
@@ -496,6 +496,74 @@ export const mockSpawn = vitest.fn((exitCode: number, stdout: string, stderr: st
|
||||
} as unknown as ChildProcessWithoutNullStreams;
|
||||
});
|
||||
|
||||
export const mockDuplex = vitest.fn(
|
||||
(command: string, exitCode: number, stdout: string, stderr: string, error?: unknown) => {
|
||||
const duplex = new Duplex({
|
||||
write(_chunk, _encoding, callback) {
|
||||
callback();
|
||||
},
|
||||
|
||||
read() {},
|
||||
|
||||
final(callback) {
|
||||
callback();
|
||||
},
|
||||
});
|
||||
|
||||
setImmediate(() => {
|
||||
if (error) {
|
||||
duplex.destroy(error as Error);
|
||||
} else if (exitCode === 0) {
|
||||
/* eslint-disable unicorn/prefer-single-call */
|
||||
duplex.push(stdout);
|
||||
duplex.push(null);
|
||||
/* eslint-enable unicorn/prefer-single-call */
|
||||
} else {
|
||||
duplex.destroy(new Error(`${command} non-zero exit code (${exitCode})\n${stderr}`));
|
||||
}
|
||||
});
|
||||
|
||||
return duplex;
|
||||
},
|
||||
);
|
||||
|
||||
export const mockFork = vitest.fn((exitCode: number, stdout: string, stderr: string, error?: unknown) => {
|
||||
const stdoutStream = new Readable({
|
||||
read() {
|
||||
this.push(stdout); // write mock data to stdout
|
||||
this.push(null); // end stream
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
stdout: stdoutStream,
|
||||
stderr: new Readable({
|
||||
read() {
|
||||
this.push(stderr); // write mock data to stderr
|
||||
this.push(null); // end stream
|
||||
},
|
||||
}),
|
||||
stdin: new Writable({
|
||||
write(chunk, encoding, callback) {
|
||||
callback();
|
||||
},
|
||||
}),
|
||||
exitCode,
|
||||
on: vitest.fn((event, callback: any) => {
|
||||
if (event === 'close') {
|
||||
stdoutStream.once('end', () => callback(0));
|
||||
}
|
||||
if (event === 'error' && error) {
|
||||
stdoutStream.once('end', () => callback(error));
|
||||
}
|
||||
if (event === 'exit') {
|
||||
stdoutStream.once('end', () => callback(exitCode));
|
||||
}
|
||||
}),
|
||||
kill: vitest.fn(),
|
||||
} as unknown as ChildProcessWithoutNullStreams;
|
||||
});
|
||||
|
||||
export async function* makeStream<T>(items: T[] = []): AsyncIterableIterator<T> {
|
||||
for (const item of items) {
|
||||
await Promise.resolve();
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
/* @import '/usr/ui/dist/theme/default.css'; */
|
||||
|
||||
@utility immich-form-input {
|
||||
@apply rounded-xl bg-slate-200 px-3 py-3 text-sm focus:border-immich-primary disabled:cursor-not-allowed disabled:bg-gray-400 disabled:text-gray-100 dark:bg-gray-600 dark:text-immich-dark-fg dark:disabled:bg-gray-800 dark:disabled:text-gray-200;
|
||||
@apply bg-gray-100 ring-1 ring-gray-200 transition outline-none focus-within:ring-1 disabled:cursor-not-allowed dark:bg-gray-800 dark:ring-neutral-900 flex w-full items-center rounded-lg disabled:bg-gray-300 disabled:text-dark dark:disabled:bg-gray-900 dark:disabled:text-gray-200 flex-1 py-2.5 text-base pl-4 pr-4;
|
||||
}
|
||||
|
||||
@utility immich-form-label {
|
||||
|
||||
@@ -1,48 +1,42 @@
|
||||
import { photoZoomState } from '$lib/stores/zoom-image.store';
|
||||
import { useZoomImageWheel } from '@zoom-image/svelte';
|
||||
import { createZoomImageWheel } from '@zoom-image/core';
|
||||
import { get } from 'svelte/store';
|
||||
|
||||
export const zoomImageAction = (node: HTMLElement, options?: { disabled?: boolean }) => {
|
||||
const { createZoomImage, zoomImageState, setZoomImageState } = useZoomImageWheel();
|
||||
|
||||
createZoomImage(node, {
|
||||
const state = get(photoZoomState);
|
||||
const zoomInstance = createZoomImageWheel(node, {
|
||||
maxZoom: 10,
|
||||
initialState: state,
|
||||
});
|
||||
|
||||
const state = get(photoZoomState);
|
||||
if (state) {
|
||||
setZoomImageState(state);
|
||||
}
|
||||
const unsubscribes = [
|
||||
photoZoomState.subscribe((state) => zoomInstance.setState(state)),
|
||||
zoomInstance.subscribe(({ state }) => {
|
||||
photoZoomState.set(state);
|
||||
}),
|
||||
];
|
||||
|
||||
// Store original event handlers so we can prevent them when disabled
|
||||
const wheelHandler = (event: WheelEvent) => {
|
||||
const stopIfDisabled = (event: Event) => {
|
||||
if (options?.disabled) {
|
||||
event.stopImmediatePropagation();
|
||||
}
|
||||
};
|
||||
|
||||
const pointerDownHandler = (event: PointerEvent) => {
|
||||
if (options?.disabled) {
|
||||
event.stopImmediatePropagation();
|
||||
}
|
||||
};
|
||||
|
||||
// Add handlers at capture phase with higher priority
|
||||
node.addEventListener('wheel', wheelHandler, { capture: true });
|
||||
node.addEventListener('pointerdown', pointerDownHandler, { capture: true });
|
||||
|
||||
const unsubscribes = [photoZoomState.subscribe(setZoomImageState), zoomImageState.subscribe(photoZoomState.set)];
|
||||
node.addEventListener('wheel', stopIfDisabled, { capture: true });
|
||||
node.addEventListener('pointerdown', stopIfDisabled, { capture: true });
|
||||
|
||||
node.style.overflow = 'visible';
|
||||
return {
|
||||
update(newOptions?: { disabled?: boolean }) {
|
||||
options = newOptions;
|
||||
},
|
||||
destroy() {
|
||||
node.removeEventListener('wheel', wheelHandler, { capture: true });
|
||||
node.removeEventListener('pointerdown', pointerDownHandler, { capture: true });
|
||||
for (const unsubscribe of unsubscribes) {
|
||||
unsubscribe();
|
||||
}
|
||||
node.removeEventListener('wheel', stopIfDisabled, { capture: true });
|
||||
node.removeEventListener('pointerdown', stopIfDisabled, { capture: true });
|
||||
zoomInstance.cleanup();
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
import { Route } from '$lib/route';
|
||||
import { asQueueItem } from '$lib/services/queue.service';
|
||||
import { locale } from '$lib/stores/preferences.store';
|
||||
import { transformToTitleCase } from '$lib/utils';
|
||||
import { QueueCommand, type QueueCommandDto, type QueueResponseDto } from '@immich/sdk';
|
||||
import { Icon, IconButton, Link } from '@immich/ui';
|
||||
import {
|
||||
@@ -53,7 +54,7 @@
|
||||
<div class="flex items-center gap-2 text-xl font-semibold text-primary">
|
||||
<Link class="flex items-center gap-2 hover:underline" href={Route.viewQueue(queue)} underline={false}>
|
||||
<Icon {icon} size="1.25em" class="hidden shrink-0 sm:block" />
|
||||
<span class="uppercase">{title}</span>
|
||||
<span>{transformToTitleCase(title)}</span>
|
||||
</Link>
|
||||
<IconButton
|
||||
color="primary"
|
||||
@@ -131,7 +132,7 @@
|
||||
onClick={() => onCommand({ command: QueueCommand.Start, force: false })}
|
||||
>
|
||||
<Icon icon={mdiAlertCircle} size="36" />
|
||||
<span class="uppercase">{$t('disabled')}</span>
|
||||
<span>{$t('disabled')}</span>
|
||||
</QueueCardButton>
|
||||
{/if}
|
||||
|
||||
@@ -139,7 +140,7 @@
|
||||
{#if waitingCount > 0}
|
||||
<QueueCardButton color="gray" onClick={() => onCommand({ command: QueueCommand.Empty, force: false })}>
|
||||
<Icon icon={mdiClose} size="24" />
|
||||
<span class="uppercase">{$t('clear')}</span>
|
||||
<span>{$t('clear')}</span>
|
||||
</QueueCardButton>
|
||||
{/if}
|
||||
{#if queue.isPaused}
|
||||
@@ -147,12 +148,12 @@
|
||||
<QueueCardButton color="light-gray" onClick={() => onCommand({ command: QueueCommand.Resume, force: false })}>
|
||||
<!-- size property is not reactive, so have to use width and height -->
|
||||
<Icon icon={mdiFastForward} {size} />
|
||||
<span class="uppercase">{$t('resume')}</span>
|
||||
<span>{$t('resume')}</span>
|
||||
</QueueCardButton>
|
||||
{:else}
|
||||
<QueueCardButton color="light-gray" onClick={() => onCommand({ command: QueueCommand.Pause, force: false })}>
|
||||
<Icon icon={mdiPause} size="24" />
|
||||
<span class="uppercase">{$t('pause')}</span>
|
||||
<span>{$t('pause')}</span>
|
||||
</QueueCardButton>
|
||||
{/if}
|
||||
{/if}
|
||||
@@ -161,25 +162,25 @@
|
||||
{#if allText}
|
||||
<QueueCardButton color="dark-gray" onClick={() => onCommand({ command: QueueCommand.Start, force: true })}>
|
||||
<Icon icon={mdiAllInclusive} size="24" />
|
||||
<span class="uppercase">{allText}</span>
|
||||
<span>{allText}</span>
|
||||
</QueueCardButton>
|
||||
{/if}
|
||||
{#if refreshText}
|
||||
<QueueCardButton color="gray" onClick={() => onCommand({ command: QueueCommand.Start, force: undefined })}>
|
||||
<Icon icon={mdiImageRefreshOutline} size="24" />
|
||||
<span class="uppercase">{refreshText}</span>
|
||||
<span>{refreshText}</span>
|
||||
</QueueCardButton>
|
||||
{/if}
|
||||
<QueueCardButton color="light-gray" onClick={() => onCommand({ command: QueueCommand.Start, force: false })}>
|
||||
<Icon icon={mdiSelectionSearch} size="24" />
|
||||
<span class="uppercase">{missingText}</span>
|
||||
<span>{missingText}</span>
|
||||
</QueueCardButton>
|
||||
{/if}
|
||||
|
||||
{#if !disabled && !multipleButtons && isIdle}
|
||||
<QueueCardButton color="light-gray" onClick={() => onCommand({ command: QueueCommand.Start, force: false })}>
|
||||
<Icon icon={mdiPlay} size="48" />
|
||||
<span class="uppercase">{missingText}</span>
|
||||
<span>{missingText}</span>
|
||||
</QueueCardButton>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user