mirror of
https://github.com/immich-app/immich.git
synced 2025-12-19 02:42:38 -08:00
Compare commits
71 Commits
feat/datab
...
feat/integ
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2d48d05943 | ||
|
|
a6cac7db1d | ||
|
|
a81074fff8 | ||
|
|
bb4893d0d7 | ||
|
|
042335f3cd | ||
|
|
82351f4fb9 | ||
|
|
ff7453e46a | ||
|
|
06f81f4b14 | ||
|
|
4ed3386f07 | ||
|
|
2962c54ee2 | ||
|
|
8b1e29998e | ||
|
|
748ba6780d | ||
|
|
ff07b4ff16 | ||
|
|
b1f3c7579d | ||
|
|
92d23ce955 | ||
|
|
c045fa27af | ||
|
|
b73066268f | ||
|
|
098563ef4e | ||
|
|
5028c56ad8 | ||
|
|
31ac88f158 | ||
|
|
b2053503bb | ||
|
|
f1c7f13d20 | ||
|
|
16c2082721 | ||
|
|
05acf74626 | ||
|
|
b8feaecf86 | ||
|
|
0e75f38e4a | ||
|
|
08e532170f | ||
|
|
21c26dd65f | ||
|
|
7d71f99783 | ||
|
|
8fdec465c5 | ||
|
|
6e7854b5bb | ||
|
|
5d5d421201 | ||
|
|
7a215c16ab | ||
|
|
ae653f9bf5 | ||
|
|
73a17bb58e | ||
|
|
e1a1662225 | ||
|
|
6e752bed77 | ||
|
|
64cc64dd56 | ||
|
|
6cfd1994c4 | ||
|
|
806a2880ca | ||
|
|
042af30bef | ||
|
|
06fcd54b9f | ||
|
|
fec8923431 | ||
|
|
db690bcf63 | ||
|
|
1daf1b471f | ||
|
|
01f96de3e5 | ||
|
|
c4ac8d9f63 | ||
|
|
0362d21945 | ||
|
|
4d7f7b80da | ||
|
|
e447ba87c6 | ||
|
|
2779fce7d0 | ||
|
|
13e9cf0ed9 | ||
|
|
c50118e535 | ||
|
|
ca358f4dae | ||
|
|
d3abed3414 | ||
|
|
0fdc7b4448 | ||
|
|
8db6132669 | ||
|
|
03276de6b2 | ||
|
|
4462683739 | ||
|
|
919eb839ef | ||
|
|
251631948b | ||
|
|
93860238af | ||
|
|
1744237aeb | ||
|
|
ef7d8e94fa | ||
|
|
cc31b9c7f1 | ||
|
|
929ad529f4 | ||
|
|
1e941f3f88 | ||
|
|
15503b150a | ||
|
|
3414210450 | ||
|
|
4a7120cdeb | ||
|
|
f77f43a83d |
484
e2e/src/api/specs/integrity.e2e-spec.ts
Normal file
484
e2e/src/api/specs/integrity.e2e-spec.ts
Normal file
@@ -0,0 +1,484 @@
|
||||
import {
|
||||
AssetMediaResponseDto,
|
||||
IntegrityReportResponseDto,
|
||||
IntegrityReportType,
|
||||
LoginResponseDto,
|
||||
ManualJobName,
|
||||
QueueName,
|
||||
} from '@immich/sdk';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { app, testAssetDir, utils } from 'src/utils';
|
||||
import request from 'supertest';
|
||||
import { afterEach, beforeAll, describe, expect, it } from 'vitest';
|
||||
|
||||
const assetFilepath = `${testAssetDir}/metadata/gps-position/thompson-springs.jpg`;
|
||||
|
||||
describe('/admin/integrity', () => {
|
||||
let admin: LoginResponseDto;
|
||||
let asset: AssetMediaResponseDto;
|
||||
|
||||
beforeAll(async () => {
|
||||
await utils.resetDatabase();
|
||||
admin = await utils.adminSetup();
|
||||
});
|
||||
|
||||
beforeAll(async () => {
|
||||
asset = await utils.createAsset(admin.accessToken, {
|
||||
assetData: {
|
||||
filename: 'asset.jpg',
|
||||
bytes: await readFile(assetFilepath),
|
||||
},
|
||||
});
|
||||
|
||||
await utils.copyFolder(`/data/upload/${admin.userId}`, `/data/upload/${admin.userId}-bak`);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await utils.deleteFolder(`/data/upload/${admin.userId}`);
|
||||
await utils.copyFolder(`/data/upload/${admin.userId}-bak`, `/data/upload/${admin.userId}`);
|
||||
});
|
||||
|
||||
describe('POST /summary (& jobs)', async () => {
|
||||
let baseline: Record<IntegrityReportType, number>;
|
||||
|
||||
it.sequential('may report issues', async () => {
|
||||
await utils.createJob(admin.accessToken, {
|
||||
name: ManualJobName.IntegrityOrphanFiles,
|
||||
});
|
||||
|
||||
await utils.createJob(admin.accessToken, {
|
||||
name: ManualJobName.IntegrityMissingFiles,
|
||||
});
|
||||
|
||||
await utils.createJob(admin.accessToken, {
|
||||
name: ManualJobName.IntegrityChecksumMismatch,
|
||||
});
|
||||
|
||||
await utils.waitForQueueFinish(admin.accessToken, QueueName.IntegrityCheck);
|
||||
|
||||
const { status, body } = await request(app)
|
||||
.get('/admin/integrity/summary')
|
||||
.set('Authorization', `Bearer ${admin.accessToken}`)
|
||||
.send();
|
||||
|
||||
expect(status).toBe(200);
|
||||
expect(body).toEqual({
|
||||
missing_file: 0,
|
||||
orphan_file: expect.any(Number),
|
||||
checksum_mismatch: 0,
|
||||
});
|
||||
|
||||
baseline = body;
|
||||
});
|
||||
|
||||
it.sequential('should detect an orphan file (job: check orphan files)', async () => {
|
||||
await utils.putTextFile('orphan', `/data/upload/${admin.userId}/orphan1.png`);
|
||||
|
||||
await utils.createJob(admin.accessToken, {
|
||||
name: ManualJobName.IntegrityOrphanFiles,
|
||||
});
|
||||
|
||||
await utils.waitForQueueFinish(admin.accessToken, QueueName.IntegrityCheck);
|
||||
|
||||
const { status, body } = await request(app)
|
||||
.get('/admin/integrity/summary')
|
||||
.set('Authorization', `Bearer ${admin.accessToken}`)
|
||||
.send();
|
||||
|
||||
expect(status).toBe(200);
|
||||
expect(body).toEqual(
|
||||
expect.objectContaining({
|
||||
orphan_file: baseline.orphan_file + 1,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it.sequential('should detect outdated orphan file reports (job: refresh orphan files)', async () => {
|
||||
// these should not be detected:
|
||||
await utils.putTextFile('orphan', `/data/upload/${admin.userId}/orphan2.png`);
|
||||
await utils.putTextFile('orphan', `/data/upload/${admin.userId}/orphan3.png`);
|
||||
|
||||
await utils.createJob(admin.accessToken, {
|
||||
name: ManualJobName.IntegrityOrphanFilesRefresh,
|
||||
});
|
||||
|
||||
await utils.waitForQueueFinish(admin.accessToken, QueueName.IntegrityCheck);
|
||||
|
||||
const { status, body } = await request(app)
|
||||
.get('/admin/integrity/summary')
|
||||
.set('Authorization', `Bearer ${admin.accessToken}`)
|
||||
.send();
|
||||
|
||||
expect(status).toBe(200);
|
||||
expect(body).toEqual(
|
||||
expect.objectContaining({
|
||||
orphan_file: baseline.orphan_file,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it.sequential('should detect a missing file and not a checksum mismatch (job: check missing files)', async () => {
|
||||
await utils.deleteFolder(`/data/upload/${admin.userId}`);
|
||||
|
||||
await utils.createJob(admin.accessToken, {
|
||||
name: ManualJobName.IntegrityMissingFiles,
|
||||
});
|
||||
|
||||
await utils.waitForQueueFinish(admin.accessToken, QueueName.IntegrityCheck);
|
||||
|
||||
const { status, body } = await request(app)
|
||||
.get('/admin/integrity/summary')
|
||||
.set('Authorization', `Bearer ${admin.accessToken}`)
|
||||
.send();
|
||||
|
||||
expect(status).toBe(200);
|
||||
expect(body).toEqual(
|
||||
expect.objectContaining({
|
||||
missing_file: 1,
|
||||
checksum_mismatch: 0,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it.sequential('should detect outdated missing file reports (job: refresh missing files)', async () => {
|
||||
await utils.createJob(admin.accessToken, {
|
||||
name: ManualJobName.IntegrityMissingFilesRefresh,
|
||||
});
|
||||
|
||||
await utils.waitForQueueFinish(admin.accessToken, QueueName.IntegrityCheck);
|
||||
|
||||
const { status, body } = await request(app)
|
||||
.get('/admin/integrity/summary')
|
||||
.set('Authorization', `Bearer ${admin.accessToken}`)
|
||||
.send();
|
||||
|
||||
expect(status).toBe(200);
|
||||
expect(body).toEqual(
|
||||
expect.objectContaining({
|
||||
missing_file: 0,
|
||||
checksum_mismatch: 0,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it.sequential('should detect a checksum mismatch (job: check file checksums)', async () => {
|
||||
await utils.truncateFolder(`/data/upload/${admin.userId}`);
|
||||
|
||||
await utils.createJob(admin.accessToken, {
|
||||
name: ManualJobName.IntegrityChecksumMismatch,
|
||||
});
|
||||
|
||||
await utils.waitForQueueFinish(admin.accessToken, QueueName.IntegrityCheck);
|
||||
|
||||
const { status, body } = await request(app)
|
||||
.get('/admin/integrity/summary')
|
||||
.set('Authorization', `Bearer ${admin.accessToken}`)
|
||||
.send();
|
||||
|
||||
expect(status).toBe(200);
|
||||
expect(body).toEqual(
|
||||
expect.objectContaining({
|
||||
checksum_mismatch: 1,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it.sequential('should detect outdated checksum mismatch reports (job: refresh file checksums)', async () => {
|
||||
await utils.createJob(admin.accessToken, {
|
||||
name: ManualJobName.IntegrityChecksumMismatchRefresh,
|
||||
});
|
||||
|
||||
await utils.waitForQueueFinish(admin.accessToken, QueueName.IntegrityCheck);
|
||||
|
||||
const { status, body } = await request(app)
|
||||
.get('/admin/integrity/summary')
|
||||
.set('Authorization', `Bearer ${admin.accessToken}`)
|
||||
.send();
|
||||
|
||||
expect(status).toBe(200);
|
||||
expect(body).toEqual(
|
||||
expect.objectContaining({
|
||||
checksum_mismatch: 0,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /report', async () => {
|
||||
it.sequential('reports orphan files', async () => {
|
||||
await utils.putTextFile('orphan', `/data/upload/${admin.userId}/orphan1.png`);
|
||||
|
||||
await utils.createJob(admin.accessToken, {
|
||||
name: ManualJobName.IntegrityOrphanFiles,
|
||||
});
|
||||
|
||||
await utils.waitForQueueFinish(admin.accessToken, QueueName.IntegrityCheck);
|
||||
|
||||
const { status, body } = await request(app)
|
||||
.post('/admin/integrity/report')
|
||||
.set('Authorization', `Bearer ${admin.accessToken}`)
|
||||
.send({ type: 'orphan_file' });
|
||||
|
||||
expect(status).toBe(200);
|
||||
expect(body).toEqual({
|
||||
hasNextPage: expect.any(Boolean),
|
||||
items: expect.arrayContaining([
|
||||
{
|
||||
id: expect.any(String),
|
||||
type: 'orphan_file',
|
||||
path: `/data/upload/${admin.userId}/orphan1.png`,
|
||||
assetId: null,
|
||||
fileAssetId: null,
|
||||
},
|
||||
]),
|
||||
});
|
||||
});
|
||||
|
||||
it.sequential('reports missing files', async () => {
|
||||
await utils.deleteFolder(`/data/upload/${admin.userId}`);
|
||||
|
||||
await utils.createJob(admin.accessToken, {
|
||||
name: ManualJobName.IntegrityMissingFiles,
|
||||
});
|
||||
|
||||
await utils.waitForQueueFinish(admin.accessToken, QueueName.IntegrityCheck);
|
||||
|
||||
const { status, body } = await request(app)
|
||||
.post('/admin/integrity/report')
|
||||
.set('Authorization', `Bearer ${admin.accessToken}`)
|
||||
.send({ type: 'missing_file' });
|
||||
|
||||
expect(status).toBe(200);
|
||||
expect(body).toEqual({
|
||||
hasNextPage: expect.any(Boolean),
|
||||
items: expect.arrayContaining([
|
||||
{
|
||||
id: expect.any(String),
|
||||
type: 'missing_file',
|
||||
path: expect.any(String),
|
||||
assetId: asset.id,
|
||||
fileAssetId: null,
|
||||
},
|
||||
]),
|
||||
});
|
||||
});
|
||||
|
||||
it.sequential('reports checksum mismatched files', async () => {
|
||||
await utils.truncateFolder(`/data/upload/${admin.userId}`);
|
||||
|
||||
await utils.createJob(admin.accessToken, {
|
||||
name: ManualJobName.IntegrityChecksumMismatch,
|
||||
});
|
||||
|
||||
await utils.waitForQueueFinish(admin.accessToken, QueueName.IntegrityCheck);
|
||||
|
||||
const { status, body } = await request(app)
|
||||
.post('/admin/integrity/report')
|
||||
.set('Authorization', `Bearer ${admin.accessToken}`)
|
||||
.send({ type: 'checksum_mismatch' });
|
||||
|
||||
expect(status).toBe(200);
|
||||
expect(body).toEqual({
|
||||
hasNextPage: expect.any(Boolean),
|
||||
items: expect.arrayContaining([
|
||||
{
|
||||
id: expect.any(String),
|
||||
type: 'checksum_mismatch',
|
||||
path: expect.any(String),
|
||||
assetId: asset.id,
|
||||
fileAssetId: null,
|
||||
},
|
||||
]),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('DELETE /report/:id', async () => {
|
||||
it.sequential('delete orphan files', async () => {
|
||||
await utils.putTextFile('orphan', `/data/upload/${admin.userId}/orphan1.png`);
|
||||
|
||||
await utils.createJob(admin.accessToken, {
|
||||
name: ManualJobName.IntegrityOrphanFiles,
|
||||
});
|
||||
|
||||
await utils.waitForQueueFinish(admin.accessToken, QueueName.IntegrityCheck);
|
||||
|
||||
const { status: listStatus, body: listBody } = await request(app)
|
||||
.post('/admin/integrity/report')
|
||||
.set('Authorization', `Bearer ${admin.accessToken}`)
|
||||
.send({ type: 'orphan_file' });
|
||||
|
||||
expect(listStatus).toBe(200);
|
||||
|
||||
const report = (listBody as IntegrityReportResponseDto).items.find(
|
||||
(item) => item.path === `/data/upload/${admin.userId}/orphan1.png`,
|
||||
)!;
|
||||
|
||||
const { status } = await request(app)
|
||||
.delete(`/admin/integrity/report/${report.id}`)
|
||||
.set('Authorization', `Bearer ${admin.accessToken}`)
|
||||
.send();
|
||||
|
||||
expect(status).toBe(200);
|
||||
|
||||
await utils.createJob(admin.accessToken, {
|
||||
name: ManualJobName.IntegrityOrphanFiles,
|
||||
});
|
||||
|
||||
await utils.waitForQueueFinish(admin.accessToken, QueueName.IntegrityCheck);
|
||||
|
||||
const { status: listStatus2, body: listBody2 } = await request(app)
|
||||
.post('/admin/integrity/report')
|
||||
.set('Authorization', `Bearer ${admin.accessToken}`)
|
||||
.send({ type: 'orphan_file' });
|
||||
|
||||
expect(listStatus2).toBe(200);
|
||||
expect(listBody2).not.toBe(
|
||||
expect.objectContaining({
|
||||
items: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
id: report.id,
|
||||
}),
|
||||
]),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it.sequential('delete assets missing files', async () => {
|
||||
await utils.deleteFolder(`/data/upload/${admin.userId}`);
|
||||
|
||||
await utils.createJob(admin.accessToken, {
|
||||
name: ManualJobName.IntegrityMissingFiles,
|
||||
});
|
||||
|
||||
await utils.waitForQueueFinish(admin.accessToken, QueueName.IntegrityCheck);
|
||||
|
||||
const { status: listStatus, body: listBody } = await request(app)
|
||||
.post('/admin/integrity/report')
|
||||
.set('Authorization', `Bearer ${admin.accessToken}`)
|
||||
.send({ type: 'missing_file' });
|
||||
|
||||
expect(listStatus).toBe(200);
|
||||
expect(listBody.items.length).toBe(1);
|
||||
|
||||
const report = (listBody as IntegrityReportResponseDto).items[0];
|
||||
|
||||
const { status } = await request(app)
|
||||
.delete(`/admin/integrity/report/${report.id}`)
|
||||
.set('Authorization', `Bearer ${admin.accessToken}`)
|
||||
.send();
|
||||
|
||||
expect(status).toBe(200);
|
||||
|
||||
await utils.createJob(admin.accessToken, {
|
||||
name: ManualJobName.IntegrityMissingFiles,
|
||||
});
|
||||
|
||||
await utils.waitForQueueFinish(admin.accessToken, QueueName.IntegrityCheck);
|
||||
|
||||
const { status: listStatus2, body: listBody2 } = await request(app)
|
||||
.post('/admin/integrity/report')
|
||||
.set('Authorization', `Bearer ${admin.accessToken}`)
|
||||
.send({ type: 'missing_file' });
|
||||
|
||||
expect(listStatus2).toBe(200);
|
||||
expect(listBody2.items.length).toBe(0);
|
||||
});
|
||||
|
||||
it.sequential('delete assets with failing checksum', async () => {
|
||||
await utils.truncateFolder(`/data/upload/${admin.userId}`);
|
||||
|
||||
await utils.createJob(admin.accessToken, {
|
||||
name: ManualJobName.IntegrityChecksumMismatch,
|
||||
});
|
||||
|
||||
await utils.waitForQueueFinish(admin.accessToken, QueueName.IntegrityCheck);
|
||||
|
||||
const { status: listStatus, body: listBody } = await request(app)
|
||||
.post('/admin/integrity/report')
|
||||
.set('Authorization', `Bearer ${admin.accessToken}`)
|
||||
.send({ type: 'checksum_mismatch' });
|
||||
|
||||
expect(listStatus).toBe(200);
|
||||
expect(listBody.items.length).toBe(1);
|
||||
|
||||
const report = (listBody as IntegrityReportResponseDto).items[0];
|
||||
|
||||
const { status } = await request(app)
|
||||
.delete(`/admin/integrity/report/${report.id}`)
|
||||
.set('Authorization', `Bearer ${admin.accessToken}`)
|
||||
.send();
|
||||
|
||||
expect(status).toBe(200);
|
||||
|
||||
await utils.createJob(admin.accessToken, {
|
||||
name: ManualJobName.IntegrityChecksumMismatch,
|
||||
});
|
||||
|
||||
await utils.waitForQueueFinish(admin.accessToken, QueueName.IntegrityCheck);
|
||||
|
||||
const { status: listStatus2, body: listBody2 } = await request(app)
|
||||
.post('/admin/integrity/report')
|
||||
.set('Authorization', `Bearer ${admin.accessToken}`)
|
||||
.send({ type: 'checksum_mismatch' });
|
||||
|
||||
expect(listStatus2).toBe(200);
|
||||
expect(listBody2.items.length).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /report/:type/csv', () => {
|
||||
it.sequential('exports orphan files as csv', async () => {
|
||||
await utils.putTextFile('orphan', `/data/upload/${admin.userId}/orphan1.png`);
|
||||
|
||||
await utils.createJob(admin.accessToken, {
|
||||
name: ManualJobName.IntegrityOrphanFiles,
|
||||
});
|
||||
|
||||
await utils.waitForQueueFinish(admin.accessToken, QueueName.IntegrityCheck);
|
||||
|
||||
const { status, headers, text } = await request(app)
|
||||
.get('/admin/integrity/report/orphan_file/csv')
|
||||
.set('Authorization', `Bearer ${admin.accessToken}`)
|
||||
.send();
|
||||
|
||||
expect(status).toBe(200);
|
||||
expect(headers['content-type']).toContain('text/csv');
|
||||
expect(headers['content-disposition']).toContain('.csv');
|
||||
expect(text).toContain('id,type,assetId,fileAssetId,path');
|
||||
expect(text).toContain(`orphan_file`);
|
||||
expect(text).toContain(`/data/upload/${admin.userId}/orphan1.png`);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /report/:id/file', () => {
|
||||
it.sequential('downloads orphan file', async () => {
|
||||
await utils.putTextFile('orphan-content', `/data/upload/${admin.userId}/orphan1.png`);
|
||||
|
||||
await utils.createJob(admin.accessToken, {
|
||||
name: ManualJobName.IntegrityOrphanFiles,
|
||||
});
|
||||
|
||||
await utils.waitForQueueFinish(admin.accessToken, QueueName.IntegrityCheck);
|
||||
|
||||
const { body: listBody } = await request(app)
|
||||
.post('/admin/integrity/report')
|
||||
.set('Authorization', `Bearer ${admin.accessToken}`)
|
||||
.send({ type: 'orphan_file' });
|
||||
|
||||
const report = (listBody as IntegrityReportResponseDto).items.find(
|
||||
(item) => item.path === `/data/upload/${admin.userId}/orphan1.png`,
|
||||
)!;
|
||||
|
||||
const { status, headers, body } = await request(app)
|
||||
.get(`/admin/integrity/report/${report.id}/file`)
|
||||
.set('Authorization', `Bearer ${admin.accessToken}`)
|
||||
.buffer(true)
|
||||
.send();
|
||||
|
||||
expect(status).toBe(200);
|
||||
expect(headers['content-type']).toContain('application/octet-stream');
|
||||
expect(body.toString()).toBe('orphan-content');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -83,8 +83,8 @@ describe('/admin/maintenance', () => {
|
||||
return body.maintenanceMode;
|
||||
},
|
||||
{
|
||||
interval: 5e2,
|
||||
timeout: 1e4,
|
||||
interval: 500,
|
||||
timeout: 60_000,
|
||||
},
|
||||
)
|
||||
.toBeTruthy();
|
||||
@@ -162,8 +162,8 @@ describe('/admin/maintenance', () => {
|
||||
return body.maintenanceMode;
|
||||
},
|
||||
{
|
||||
interval: 5e2,
|
||||
timeout: 1e4,
|
||||
interval: 500,
|
||||
timeout: 60_000,
|
||||
},
|
||||
)
|
||||
.toBeFalsy();
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
CheckExistingAssetsDto,
|
||||
CreateAlbumDto,
|
||||
CreateLibraryDto,
|
||||
JobCreateDto,
|
||||
MaintenanceAction,
|
||||
MetadataSearchDto,
|
||||
Permission,
|
||||
@@ -21,6 +22,7 @@ import {
|
||||
checkExistingAssets,
|
||||
createAlbum,
|
||||
createApiKey,
|
||||
createJob,
|
||||
createLibrary,
|
||||
createPartner,
|
||||
createPerson,
|
||||
@@ -52,9 +54,12 @@ 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 pg from 'pg';
|
||||
@@ -171,6 +176,7 @@ export const utils = {
|
||||
'user',
|
||||
'system_metadata',
|
||||
'tag',
|
||||
'integrity_report',
|
||||
];
|
||||
|
||||
const sql: string[] = [];
|
||||
@@ -481,6 +487,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 +568,50 @@ export const utils = {
|
||||
mkdirSync(`${testAssetDir}/temp`, { recursive: true });
|
||||
},
|
||||
|
||||
putFile(source: string, dest: string) {
|
||||
return executeCommand('docker', ['cp', source, `immich-e2e-server:${dest}`]).promise;
|
||||
},
|
||||
|
||||
async putTextFile(contents: string, dest: string) {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'test-'));
|
||||
const fn = join(dir, 'file');
|
||||
await pipeline(Readable.from(contents), createWriteStream(fn));
|
||||
return executeCommand('docker', ['cp', fn, `immich-e2e-server:${dest}`]).promise;
|
||||
},
|
||||
|
||||
async move(source: string, dest: string) {
|
||||
return executeCommand('docker', ['exec', 'immich-e2e-server', 'mv', source, dest]).promise;
|
||||
},
|
||||
|
||||
async copyFolder(source: string, dest: string) {
|
||||
return executeCommand('docker', ['exec', 'immich-e2e-server', 'cp', '-r', source, dest]).promise;
|
||||
},
|
||||
|
||||
async deleteFile(path: string) {
|
||||
return executeCommand('docker', ['exec', 'immich-e2e-server', 'rm', path]).promise;
|
||||
},
|
||||
|
||||
async deleteFolder(path: string) {
|
||||
return executeCommand('docker', ['exec', 'immich-e2e-server', 'rm', '-r', path]).promise;
|
||||
},
|
||||
|
||||
async truncateFolder(path: string) {
|
||||
return executeCommand('docker', [
|
||||
'exec',
|
||||
'immich-e2e-server',
|
||||
'find',
|
||||
path,
|
||||
'-type',
|
||||
'f',
|
||||
'-exec',
|
||||
'truncate',
|
||||
'-s',
|
||||
'1',
|
||||
'{}',
|
||||
';',
|
||||
]).promise;
|
||||
},
|
||||
|
||||
resetAdminConfig: async (accessToken: string) => {
|
||||
const defaultConfig = await getConfigDefaults({ headers: asBearerAuth(accessToken) });
|
||||
await updateConfig({ systemConfigDto: defaultConfig }, { headers: asBearerAuth(accessToken) });
|
||||
|
||||
17
i18n/en.json
17
i18n/en.json
@@ -74,6 +74,7 @@
|
||||
"cron_expression_description": "Set the scanning interval using the cron format. For more information please refer to e.g. <link>Crontab Guru</link>",
|
||||
"cron_expression_presets": "Cron expression presets",
|
||||
"disable_login": "Disable login",
|
||||
"download_csv": "Download CSV",
|
||||
"duplicate_detection_job_description": "Run machine learning on assets to detect similar images. Relies on Smart Search",
|
||||
"exclusion_pattern_description": "Exclusion patterns lets you ignore files and folders when scanning your library. This is useful if you have folders that contain files you don't want to import, such as RAW files.",
|
||||
"export_config_as_json_description": "Download the current system config as a JSON file",
|
||||
@@ -181,6 +182,17 @@
|
||||
"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_integrity_check_all": "Check All",
|
||||
"maintenance_integrity_checksum_mismatch": "Checksum Mismatch",
|
||||
"maintenance_integrity_checksum_mismatch_job": "Check for checksum mismatches",
|
||||
"maintenance_integrity_checksum_mismatch_refresh_job": "Refresh checksum mismatch reports",
|
||||
"maintenance_integrity_missing_file": "Missing Files",
|
||||
"maintenance_integrity_missing_file_job": "Check for missing files",
|
||||
"maintenance_integrity_missing_file_refresh_job": "Refresh missing file reports",
|
||||
"maintenance_integrity_orphan_file": "Orphan Files",
|
||||
"maintenance_integrity_orphan_file_job": "Check for orphaned files",
|
||||
"maintenance_integrity_orphan_file_refresh_job": "Refresh orphan file reports",
|
||||
"maintenance_integrity_report": "Integrity Report",
|
||||
"maintenance_settings": "Maintenance",
|
||||
"maintenance_settings_description": "Put Immich into maintenance mode.",
|
||||
"maintenance_start": "Start maintenance mode",
|
||||
@@ -1109,6 +1121,7 @@
|
||||
"failed": "Failed",
|
||||
"failed_count": "Failed: {count}",
|
||||
"failed_to_authenticate": "Failed to authenticate",
|
||||
"failed_to_delete_file": "Failed to delete file",
|
||||
"failed_to_load_assets": "Failed to load assets",
|
||||
"failed_to_load_folder": "Failed to load folder",
|
||||
"favorite": "Favorite",
|
||||
@@ -1130,6 +1143,7 @@
|
||||
"filter_places": "Filter places",
|
||||
"find_them_fast": "Find them fast by name with search",
|
||||
"first": "First",
|
||||
"first_page": "First page",
|
||||
"fix_incorrect_match": "Fix incorrect match",
|
||||
"folder": "Folder",
|
||||
"folder_not_found": "Folder not found",
|
||||
@@ -1229,6 +1243,7 @@
|
||||
"individual_share": "Individual share",
|
||||
"individual_shares": "Individual shares",
|
||||
"info": "Info",
|
||||
"integrity_checks": "Integrity Checks",
|
||||
"interval": {
|
||||
"day_at_onepm": "Every day at 1pm",
|
||||
"hours": "Every {hours, plural, one {hour} other {{hours, number} hours}}",
|
||||
@@ -1453,6 +1468,7 @@
|
||||
"newest_first": "Newest first",
|
||||
"next": "Next",
|
||||
"next_memory": "Next memory",
|
||||
"next_page": "Next page",
|
||||
"no": "No",
|
||||
"no_albums_message": "Create an album to organize your photos and videos",
|
||||
"no_albums_with_name_yet": "It looks like you do not have any albums with this name yet.",
|
||||
@@ -1622,6 +1638,7 @@
|
||||
"previous_or_next_month": "Month forward/back",
|
||||
"previous_or_next_photo": "Photo forward/back",
|
||||
"previous_or_next_year": "Year forward/back",
|
||||
"previous_page": "Previous page",
|
||||
"primary": "Primary",
|
||||
"privacy": "Privacy",
|
||||
"profile": "Profile",
|
||||
|
||||
13
mobile/openapi/README.md
generated
13
mobile/openapi/README.md
generated
@@ -161,6 +161,11 @@ 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* | [**deleteIntegrityReport**](doc//MaintenanceAdminApi.md#deleteintegrityreport) | **DELETE** /admin/integrity/report/{id} | Delete integrity report item
|
||||
*MaintenanceAdminApi* | [**getIntegrityReport**](doc//MaintenanceAdminApi.md#getintegrityreport) | **POST** /admin/integrity/report | Get integrity report by type
|
||||
*MaintenanceAdminApi* | [**getIntegrityReportCsv**](doc//MaintenanceAdminApi.md#getintegrityreportcsv) | **GET** /admin/integrity/report/{type}/csv | Export integrity report by type as CSV
|
||||
*MaintenanceAdminApi* | [**getIntegrityReportFile**](doc//MaintenanceAdminApi.md#getintegrityreportfile) | **GET** /admin/integrity/report/{id}/file | Download flagged file file
|
||||
*MaintenanceAdminApi* | [**getIntegrityReportSummary**](doc//MaintenanceAdminApi.md#getintegrityreportsummary) | **GET** /admin/integrity/summary | Get integrity report summary
|
||||
*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
|
||||
@@ -402,6 +407,11 @@ Class | Method | HTTP request | Description
|
||||
- [FoldersResponse](doc//FoldersResponse.md)
|
||||
- [FoldersUpdate](doc//FoldersUpdate.md)
|
||||
- [ImageFormat](doc//ImageFormat.md)
|
||||
- [IntegrityGetReportDto](doc//IntegrityGetReportDto.md)
|
||||
- [IntegrityReportDto](doc//IntegrityReportDto.md)
|
||||
- [IntegrityReportResponseDto](doc//IntegrityReportResponseDto.md)
|
||||
- [IntegrityReportSummaryResponseDto](doc//IntegrityReportSummaryResponseDto.md)
|
||||
- [IntegrityReportType](doc//IntegrityReportType.md)
|
||||
- [JobCreateDto](doc//JobCreateDto.md)
|
||||
- [JobName](doc//JobName.md)
|
||||
- [JobSettingsDto](doc//JobSettingsDto.md)
|
||||
@@ -569,6 +579,9 @@ Class | Method | HTTP request | Description
|
||||
- [SystemConfigGeneratedFullsizeImageDto](doc//SystemConfigGeneratedFullsizeImageDto.md)
|
||||
- [SystemConfigGeneratedImageDto](doc//SystemConfigGeneratedImageDto.md)
|
||||
- [SystemConfigImageDto](doc//SystemConfigImageDto.md)
|
||||
- [SystemConfigIntegrityChecks](doc//SystemConfigIntegrityChecks.md)
|
||||
- [SystemConfigIntegrityChecksumJob](doc//SystemConfigIntegrityChecksumJob.md)
|
||||
- [SystemConfigIntegrityJob](doc//SystemConfigIntegrityJob.md)
|
||||
- [SystemConfigJobDto](doc//SystemConfigJobDto.md)
|
||||
- [SystemConfigLibraryDto](doc//SystemConfigLibraryDto.md)
|
||||
- [SystemConfigLibraryScanDto](doc//SystemConfigLibraryScanDto.md)
|
||||
|
||||
8
mobile/openapi/lib/api.dart
generated
8
mobile/openapi/lib/api.dart
generated
@@ -154,6 +154,11 @@ part 'model/facial_recognition_config.dart';
|
||||
part 'model/folders_response.dart';
|
||||
part 'model/folders_update.dart';
|
||||
part 'model/image_format.dart';
|
||||
part 'model/integrity_get_report_dto.dart';
|
||||
part 'model/integrity_report_dto.dart';
|
||||
part 'model/integrity_report_response_dto.dart';
|
||||
part 'model/integrity_report_summary_response_dto.dart';
|
||||
part 'model/integrity_report_type.dart';
|
||||
part 'model/job_create_dto.dart';
|
||||
part 'model/job_name.dart';
|
||||
part 'model/job_settings_dto.dart';
|
||||
@@ -321,6 +326,9 @@ part 'model/system_config_faces_dto.dart';
|
||||
part 'model/system_config_generated_fullsize_image_dto.dart';
|
||||
part 'model/system_config_generated_image_dto.dart';
|
||||
part 'model/system_config_image_dto.dart';
|
||||
part 'model/system_config_integrity_checks.dart';
|
||||
part 'model/system_config_integrity_checksum_job.dart';
|
||||
part 'model/system_config_integrity_job.dart';
|
||||
part 'model/system_config_job_dto.dart';
|
||||
part 'model/system_config_library_dto.dart';
|
||||
part 'model/system_config_library_scan_dto.dart';
|
||||
|
||||
267
mobile/openapi/lib/api/maintenance_admin_api.dart
generated
267
mobile/openapi/lib/api/maintenance_admin_api.dart
generated
@@ -16,6 +16,273 @@ class MaintenanceAdminApi {
|
||||
|
||||
final ApiClient apiClient;
|
||||
|
||||
/// Delete integrity report item
|
||||
///
|
||||
/// Delete a given report item and perform corresponding deletion (e.g. trash asset, delete file)
|
||||
///
|
||||
/// Note: This method returns the HTTP [Response].
|
||||
///
|
||||
/// Parameters:
|
||||
///
|
||||
/// * [String] id (required):
|
||||
Future<Response> deleteIntegrityReportWithHttpInfo(String id,) async {
|
||||
// ignore: prefer_const_declarations
|
||||
final apiPath = r'/admin/integrity/report/{id}'
|
||||
.replaceAll('{id}', id);
|
||||
|
||||
// ignore: prefer_final_locals
|
||||
Object? postBody;
|
||||
|
||||
final queryParams = <QueryParam>[];
|
||||
final headerParams = <String, String>{};
|
||||
final formParams = <String, String>{};
|
||||
|
||||
const contentTypes = <String>[];
|
||||
|
||||
|
||||
return apiClient.invokeAPI(
|
||||
apiPath,
|
||||
'DELETE',
|
||||
queryParams,
|
||||
postBody,
|
||||
headerParams,
|
||||
formParams,
|
||||
contentTypes.isEmpty ? null : contentTypes.first,
|
||||
);
|
||||
}
|
||||
|
||||
/// Delete integrity report item
|
||||
///
|
||||
/// Delete a given report item and perform corresponding deletion (e.g. trash asset, delete file)
|
||||
///
|
||||
/// Parameters:
|
||||
///
|
||||
/// * [String] id (required):
|
||||
Future<void> deleteIntegrityReport(String id,) async {
|
||||
final response = await deleteIntegrityReportWithHttpInfo(id,);
|
||||
if (response.statusCode >= HttpStatus.badRequest) {
|
||||
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
|
||||
}
|
||||
}
|
||||
|
||||
/// Get integrity report by type
|
||||
///
|
||||
/// Get all flagged items by integrity report type
|
||||
///
|
||||
/// Note: This method returns the HTTP [Response].
|
||||
///
|
||||
/// Parameters:
|
||||
///
|
||||
/// * [IntegrityGetReportDto] integrityGetReportDto (required):
|
||||
Future<Response> getIntegrityReportWithHttpInfo(IntegrityGetReportDto integrityGetReportDto,) async {
|
||||
// ignore: prefer_const_declarations
|
||||
final apiPath = r'/admin/integrity/report';
|
||||
|
||||
// ignore: prefer_final_locals
|
||||
Object? postBody = integrityGetReportDto;
|
||||
|
||||
final queryParams = <QueryParam>[];
|
||||
final headerParams = <String, String>{};
|
||||
final formParams = <String, String>{};
|
||||
|
||||
const contentTypes = <String>['application/json'];
|
||||
|
||||
|
||||
return apiClient.invokeAPI(
|
||||
apiPath,
|
||||
'POST',
|
||||
queryParams,
|
||||
postBody,
|
||||
headerParams,
|
||||
formParams,
|
||||
contentTypes.isEmpty ? null : contentTypes.first,
|
||||
);
|
||||
}
|
||||
|
||||
/// Get integrity report by type
|
||||
///
|
||||
/// Get all flagged items by integrity report type
|
||||
///
|
||||
/// Parameters:
|
||||
///
|
||||
/// * [IntegrityGetReportDto] integrityGetReportDto (required):
|
||||
Future<IntegrityReportResponseDto?> getIntegrityReport(IntegrityGetReportDto integrityGetReportDto,) async {
|
||||
final response = await getIntegrityReportWithHttpInfo(integrityGetReportDto,);
|
||||
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), 'IntegrityReportResponseDto',) as IntegrityReportResponseDto;
|
||||
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Export integrity report by type as CSV
|
||||
///
|
||||
/// Get all integrity report entries for a given type as a CSV
|
||||
///
|
||||
/// Note: This method returns the HTTP [Response].
|
||||
///
|
||||
/// Parameters:
|
||||
///
|
||||
/// * [IntegrityReportType] type (required):
|
||||
Future<Response> getIntegrityReportCsvWithHttpInfo(IntegrityReportType type,) async {
|
||||
// ignore: prefer_const_declarations
|
||||
final apiPath = r'/admin/integrity/report/{type}/csv'
|
||||
.replaceAll('{type}', type.toString());
|
||||
|
||||
// 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,
|
||||
);
|
||||
}
|
||||
|
||||
/// Export integrity report by type as CSV
|
||||
///
|
||||
/// Get all integrity report entries for a given type as a CSV
|
||||
///
|
||||
/// Parameters:
|
||||
///
|
||||
/// * [IntegrityReportType] type (required):
|
||||
Future<MultipartFile?> getIntegrityReportCsv(IntegrityReportType type,) async {
|
||||
final response = await getIntegrityReportCsvWithHttpInfo(type,);
|
||||
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;
|
||||
}
|
||||
|
||||
/// Download flagged file file
|
||||
///
|
||||
/// Download the orphan/broken file if one exists
|
||||
///
|
||||
/// Note: This method returns the HTTP [Response].
|
||||
///
|
||||
/// Parameters:
|
||||
///
|
||||
/// * [String] id (required):
|
||||
Future<Response> getIntegrityReportFileWithHttpInfo(String id,) async {
|
||||
// ignore: prefer_const_declarations
|
||||
final apiPath = r'/admin/integrity/report/{id}/file'
|
||||
.replaceAll('{id}', id);
|
||||
|
||||
// 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 flagged file file
|
||||
///
|
||||
/// Download the orphan/broken file if one exists
|
||||
///
|
||||
/// Parameters:
|
||||
///
|
||||
/// * [String] id (required):
|
||||
Future<MultipartFile?> getIntegrityReportFile(String id,) async {
|
||||
final response = await getIntegrityReportFileWithHttpInfo(id,);
|
||||
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;
|
||||
}
|
||||
|
||||
/// Get integrity report summary
|
||||
///
|
||||
/// Get a count of the items flagged in each integrity report
|
||||
///
|
||||
/// Note: This method returns the HTTP [Response].
|
||||
Future<Response> getIntegrityReportSummaryWithHttpInfo() async {
|
||||
// ignore: prefer_const_declarations
|
||||
final apiPath = r'/admin/integrity/summary';
|
||||
|
||||
// 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 integrity report summary
|
||||
///
|
||||
/// Get a count of the items flagged in each integrity report
|
||||
Future<IntegrityReportSummaryResponseDto?> getIntegrityReportSummary() async {
|
||||
final response = await getIntegrityReportSummaryWithHttpInfo();
|
||||
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), 'IntegrityReportSummaryResponseDto',) as IntegrityReportSummaryResponseDto;
|
||||
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Log into maintenance mode
|
||||
///
|
||||
/// Login with maintenance token or cookie to receive current information and perform further actions.
|
||||
|
||||
16
mobile/openapi/lib/api_client.dart
generated
16
mobile/openapi/lib/api_client.dart
generated
@@ -356,6 +356,16 @@ class ApiClient {
|
||||
return FoldersUpdate.fromJson(value);
|
||||
case 'ImageFormat':
|
||||
return ImageFormatTypeTransformer().decode(value);
|
||||
case 'IntegrityGetReportDto':
|
||||
return IntegrityGetReportDto.fromJson(value);
|
||||
case 'IntegrityReportDto':
|
||||
return IntegrityReportDto.fromJson(value);
|
||||
case 'IntegrityReportResponseDto':
|
||||
return IntegrityReportResponseDto.fromJson(value);
|
||||
case 'IntegrityReportSummaryResponseDto':
|
||||
return IntegrityReportSummaryResponseDto.fromJson(value);
|
||||
case 'IntegrityReportType':
|
||||
return IntegrityReportTypeTypeTransformer().decode(value);
|
||||
case 'JobCreateDto':
|
||||
return JobCreateDto.fromJson(value);
|
||||
case 'JobName':
|
||||
@@ -690,6 +700,12 @@ class ApiClient {
|
||||
return SystemConfigGeneratedImageDto.fromJson(value);
|
||||
case 'SystemConfigImageDto':
|
||||
return SystemConfigImageDto.fromJson(value);
|
||||
case 'SystemConfigIntegrityChecks':
|
||||
return SystemConfigIntegrityChecks.fromJson(value);
|
||||
case 'SystemConfigIntegrityChecksumJob':
|
||||
return SystemConfigIntegrityChecksumJob.fromJson(value);
|
||||
case 'SystemConfigIntegrityJob':
|
||||
return SystemConfigIntegrityJob.fromJson(value);
|
||||
case 'SystemConfigJobDto':
|
||||
return SystemConfigJobDto.fromJson(value);
|
||||
case 'SystemConfigLibraryDto':
|
||||
|
||||
3
mobile/openapi/lib/api_helper.dart
generated
3
mobile/openapi/lib/api_helper.dart
generated
@@ -94,6 +94,9 @@ String parameterToString(dynamic value) {
|
||||
if (value is ImageFormat) {
|
||||
return ImageFormatTypeTransformer().encode(value).toString();
|
||||
}
|
||||
if (value is IntegrityReportType) {
|
||||
return IntegrityReportTypeTypeTransformer().encode(value).toString();
|
||||
}
|
||||
if (value is JobName) {
|
||||
return JobNameTypeTransformer().encode(value).toString();
|
||||
}
|
||||
|
||||
135
mobile/openapi/lib/model/integrity_get_report_dto.dart
generated
Normal file
135
mobile/openapi/lib/model/integrity_get_report_dto.dart
generated
Normal file
@@ -0,0 +1,135 @@
|
||||
//
|
||||
// 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 IntegrityGetReportDto {
|
||||
/// Returns a new [IntegrityGetReportDto] instance.
|
||||
IntegrityGetReportDto({
|
||||
this.page,
|
||||
this.size,
|
||||
required this.type,
|
||||
});
|
||||
|
||||
/// Minimum value: 1
|
||||
///
|
||||
/// 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? page;
|
||||
|
||||
/// Minimum value: 1
|
||||
///
|
||||
/// 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? size;
|
||||
|
||||
IntegrityReportType type;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) => identical(this, other) || other is IntegrityGetReportDto &&
|
||||
other.page == page &&
|
||||
other.size == size &&
|
||||
other.type == type;
|
||||
|
||||
@override
|
||||
int get hashCode =>
|
||||
// ignore: unnecessary_parenthesis
|
||||
(page == null ? 0 : page!.hashCode) +
|
||||
(size == null ? 0 : size!.hashCode) +
|
||||
(type.hashCode);
|
||||
|
||||
@override
|
||||
String toString() => 'IntegrityGetReportDto[page=$page, size=$size, type=$type]';
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
final json = <String, dynamic>{};
|
||||
if (this.page != null) {
|
||||
json[r'page'] = this.page;
|
||||
} else {
|
||||
// json[r'page'] = null;
|
||||
}
|
||||
if (this.size != null) {
|
||||
json[r'size'] = this.size;
|
||||
} else {
|
||||
// json[r'size'] = null;
|
||||
}
|
||||
json[r'type'] = this.type;
|
||||
return json;
|
||||
}
|
||||
|
||||
/// Returns a new [IntegrityGetReportDto] instance and imports its values from
|
||||
/// [value] if it's a [Map], null otherwise.
|
||||
// ignore: prefer_constructors_over_static_methods
|
||||
static IntegrityGetReportDto? fromJson(dynamic value) {
|
||||
upgradeDto(value, "IntegrityGetReportDto");
|
||||
if (value is Map) {
|
||||
final json = value.cast<String, dynamic>();
|
||||
|
||||
return IntegrityGetReportDto(
|
||||
page: num.parse('${json[r'page']}'),
|
||||
size: num.parse('${json[r'size']}'),
|
||||
type: IntegrityReportType.fromJson(json[r'type'])!,
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static List<IntegrityGetReportDto> listFromJson(dynamic json, {bool growable = false,}) {
|
||||
final result = <IntegrityGetReportDto>[];
|
||||
if (json is List && json.isNotEmpty) {
|
||||
for (final row in json) {
|
||||
final value = IntegrityGetReportDto.fromJson(row);
|
||||
if (value != null) {
|
||||
result.add(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
return result.toList(growable: growable);
|
||||
}
|
||||
|
||||
static Map<String, IntegrityGetReportDto> mapFromJson(dynamic json) {
|
||||
final map = <String, IntegrityGetReportDto>{};
|
||||
if (json is Map && json.isNotEmpty) {
|
||||
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
|
||||
for (final entry in json.entries) {
|
||||
final value = IntegrityGetReportDto.fromJson(entry.value);
|
||||
if (value != null) {
|
||||
map[entry.key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
// maps a json object with a list of IntegrityGetReportDto-objects as value to a dart map
|
||||
static Map<String, List<IntegrityGetReportDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
|
||||
final map = <String, List<IntegrityGetReportDto>>{};
|
||||
if (json is Map && json.isNotEmpty) {
|
||||
// ignore: parameter_assignments
|
||||
json = json.cast<String, dynamic>();
|
||||
for (final entry in json.entries) {
|
||||
map[entry.key] = IntegrityGetReportDto.listFromJson(entry.value, growable: growable,);
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
/// The list of required keys that must be present in a JSON.
|
||||
static const requiredKeys = <String>{
|
||||
'type',
|
||||
};
|
||||
}
|
||||
|
||||
115
mobile/openapi/lib/model/integrity_report_dto.dart
generated
Normal file
115
mobile/openapi/lib/model/integrity_report_dto.dart
generated
Normal file
@@ -0,0 +1,115 @@
|
||||
//
|
||||
// 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 IntegrityReportDto {
|
||||
/// Returns a new [IntegrityReportDto] instance.
|
||||
IntegrityReportDto({
|
||||
required this.id,
|
||||
required this.path,
|
||||
required this.type,
|
||||
});
|
||||
|
||||
String id;
|
||||
|
||||
String path;
|
||||
|
||||
IntegrityReportType type;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) => identical(this, other) || other is IntegrityReportDto &&
|
||||
other.id == id &&
|
||||
other.path == path &&
|
||||
other.type == type;
|
||||
|
||||
@override
|
||||
int get hashCode =>
|
||||
// ignore: unnecessary_parenthesis
|
||||
(id.hashCode) +
|
||||
(path.hashCode) +
|
||||
(type.hashCode);
|
||||
|
||||
@override
|
||||
String toString() => 'IntegrityReportDto[id=$id, path=$path, type=$type]';
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
final json = <String, dynamic>{};
|
||||
json[r'id'] = this.id;
|
||||
json[r'path'] = this.path;
|
||||
json[r'type'] = this.type;
|
||||
return json;
|
||||
}
|
||||
|
||||
/// Returns a new [IntegrityReportDto] instance and imports its values from
|
||||
/// [value] if it's a [Map], null otherwise.
|
||||
// ignore: prefer_constructors_over_static_methods
|
||||
static IntegrityReportDto? fromJson(dynamic value) {
|
||||
upgradeDto(value, "IntegrityReportDto");
|
||||
if (value is Map) {
|
||||
final json = value.cast<String, dynamic>();
|
||||
|
||||
return IntegrityReportDto(
|
||||
id: mapValueOfType<String>(json, r'id')!,
|
||||
path: mapValueOfType<String>(json, r'path')!,
|
||||
type: IntegrityReportType.fromJson(json[r'type'])!,
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static List<IntegrityReportDto> listFromJson(dynamic json, {bool growable = false,}) {
|
||||
final result = <IntegrityReportDto>[];
|
||||
if (json is List && json.isNotEmpty) {
|
||||
for (final row in json) {
|
||||
final value = IntegrityReportDto.fromJson(row);
|
||||
if (value != null) {
|
||||
result.add(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
return result.toList(growable: growable);
|
||||
}
|
||||
|
||||
static Map<String, IntegrityReportDto> mapFromJson(dynamic json) {
|
||||
final map = <String, IntegrityReportDto>{};
|
||||
if (json is Map && json.isNotEmpty) {
|
||||
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
|
||||
for (final entry in json.entries) {
|
||||
final value = IntegrityReportDto.fromJson(entry.value);
|
||||
if (value != null) {
|
||||
map[entry.key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
// maps a json object with a list of IntegrityReportDto-objects as value to a dart map
|
||||
static Map<String, List<IntegrityReportDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
|
||||
final map = <String, List<IntegrityReportDto>>{};
|
||||
if (json is Map && json.isNotEmpty) {
|
||||
// ignore: parameter_assignments
|
||||
json = json.cast<String, dynamic>();
|
||||
for (final entry in json.entries) {
|
||||
map[entry.key] = IntegrityReportDto.listFromJson(entry.value, growable: growable,);
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
/// The list of required keys that must be present in a JSON.
|
||||
static const requiredKeys = <String>{
|
||||
'id',
|
||||
'path',
|
||||
'type',
|
||||
};
|
||||
}
|
||||
|
||||
107
mobile/openapi/lib/model/integrity_report_response_dto.dart
generated
Normal file
107
mobile/openapi/lib/model/integrity_report_response_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 IntegrityReportResponseDto {
|
||||
/// Returns a new [IntegrityReportResponseDto] instance.
|
||||
IntegrityReportResponseDto({
|
||||
required this.hasNextPage,
|
||||
this.items = const [],
|
||||
});
|
||||
|
||||
bool hasNextPage;
|
||||
|
||||
List<IntegrityReportDto> items;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) => identical(this, other) || other is IntegrityReportResponseDto &&
|
||||
other.hasNextPage == hasNextPage &&
|
||||
_deepEquality.equals(other.items, items);
|
||||
|
||||
@override
|
||||
int get hashCode =>
|
||||
// ignore: unnecessary_parenthesis
|
||||
(hasNextPage.hashCode) +
|
||||
(items.hashCode);
|
||||
|
||||
@override
|
||||
String toString() => 'IntegrityReportResponseDto[hasNextPage=$hasNextPage, items=$items]';
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
final json = <String, dynamic>{};
|
||||
json[r'hasNextPage'] = this.hasNextPage;
|
||||
json[r'items'] = this.items;
|
||||
return json;
|
||||
}
|
||||
|
||||
/// Returns a new [IntegrityReportResponseDto] instance and imports its values from
|
||||
/// [value] if it's a [Map], null otherwise.
|
||||
// ignore: prefer_constructors_over_static_methods
|
||||
static IntegrityReportResponseDto? fromJson(dynamic value) {
|
||||
upgradeDto(value, "IntegrityReportResponseDto");
|
||||
if (value is Map) {
|
||||
final json = value.cast<String, dynamic>();
|
||||
|
||||
return IntegrityReportResponseDto(
|
||||
hasNextPage: mapValueOfType<bool>(json, r'hasNextPage')!,
|
||||
items: IntegrityReportDto.listFromJson(json[r'items']),
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static List<IntegrityReportResponseDto> listFromJson(dynamic json, {bool growable = false,}) {
|
||||
final result = <IntegrityReportResponseDto>[];
|
||||
if (json is List && json.isNotEmpty) {
|
||||
for (final row in json) {
|
||||
final value = IntegrityReportResponseDto.fromJson(row);
|
||||
if (value != null) {
|
||||
result.add(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
return result.toList(growable: growable);
|
||||
}
|
||||
|
||||
static Map<String, IntegrityReportResponseDto> mapFromJson(dynamic json) {
|
||||
final map = <String, IntegrityReportResponseDto>{};
|
||||
if (json is Map && json.isNotEmpty) {
|
||||
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
|
||||
for (final entry in json.entries) {
|
||||
final value = IntegrityReportResponseDto.fromJson(entry.value);
|
||||
if (value != null) {
|
||||
map[entry.key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
// maps a json object with a list of IntegrityReportResponseDto-objects as value to a dart map
|
||||
static Map<String, List<IntegrityReportResponseDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
|
||||
final map = <String, List<IntegrityReportResponseDto>>{};
|
||||
if (json is Map && json.isNotEmpty) {
|
||||
// ignore: parameter_assignments
|
||||
json = json.cast<String, dynamic>();
|
||||
for (final entry in json.entries) {
|
||||
map[entry.key] = IntegrityReportResponseDto.listFromJson(entry.value, growable: growable,);
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
/// The list of required keys that must be present in a JSON.
|
||||
static const requiredKeys = <String>{
|
||||
'hasNextPage',
|
||||
'items',
|
||||
};
|
||||
}
|
||||
|
||||
115
mobile/openapi/lib/model/integrity_report_summary_response_dto.dart
generated
Normal file
115
mobile/openapi/lib/model/integrity_report_summary_response_dto.dart
generated
Normal file
@@ -0,0 +1,115 @@
|
||||
//
|
||||
// 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 IntegrityReportSummaryResponseDto {
|
||||
/// Returns a new [IntegrityReportSummaryResponseDto] instance.
|
||||
IntegrityReportSummaryResponseDto({
|
||||
required this.checksumMismatch,
|
||||
required this.missingFile,
|
||||
required this.orphanFile,
|
||||
});
|
||||
|
||||
int checksumMismatch;
|
||||
|
||||
int missingFile;
|
||||
|
||||
int orphanFile;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) => identical(this, other) || other is IntegrityReportSummaryResponseDto &&
|
||||
other.checksumMismatch == checksumMismatch &&
|
||||
other.missingFile == missingFile &&
|
||||
other.orphanFile == orphanFile;
|
||||
|
||||
@override
|
||||
int get hashCode =>
|
||||
// ignore: unnecessary_parenthesis
|
||||
(checksumMismatch.hashCode) +
|
||||
(missingFile.hashCode) +
|
||||
(orphanFile.hashCode);
|
||||
|
||||
@override
|
||||
String toString() => 'IntegrityReportSummaryResponseDto[checksumMismatch=$checksumMismatch, missingFile=$missingFile, orphanFile=$orphanFile]';
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
final json = <String, dynamic>{};
|
||||
json[r'checksum_mismatch'] = this.checksumMismatch;
|
||||
json[r'missing_file'] = this.missingFile;
|
||||
json[r'orphan_file'] = this.orphanFile;
|
||||
return json;
|
||||
}
|
||||
|
||||
/// Returns a new [IntegrityReportSummaryResponseDto] instance and imports its values from
|
||||
/// [value] if it's a [Map], null otherwise.
|
||||
// ignore: prefer_constructors_over_static_methods
|
||||
static IntegrityReportSummaryResponseDto? fromJson(dynamic value) {
|
||||
upgradeDto(value, "IntegrityReportSummaryResponseDto");
|
||||
if (value is Map) {
|
||||
final json = value.cast<String, dynamic>();
|
||||
|
||||
return IntegrityReportSummaryResponseDto(
|
||||
checksumMismatch: mapValueOfType<int>(json, r'checksum_mismatch')!,
|
||||
missingFile: mapValueOfType<int>(json, r'missing_file')!,
|
||||
orphanFile: mapValueOfType<int>(json, r'orphan_file')!,
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static List<IntegrityReportSummaryResponseDto> listFromJson(dynamic json, {bool growable = false,}) {
|
||||
final result = <IntegrityReportSummaryResponseDto>[];
|
||||
if (json is List && json.isNotEmpty) {
|
||||
for (final row in json) {
|
||||
final value = IntegrityReportSummaryResponseDto.fromJson(row);
|
||||
if (value != null) {
|
||||
result.add(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
return result.toList(growable: growable);
|
||||
}
|
||||
|
||||
static Map<String, IntegrityReportSummaryResponseDto> mapFromJson(dynamic json) {
|
||||
final map = <String, IntegrityReportSummaryResponseDto>{};
|
||||
if (json is Map && json.isNotEmpty) {
|
||||
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
|
||||
for (final entry in json.entries) {
|
||||
final value = IntegrityReportSummaryResponseDto.fromJson(entry.value);
|
||||
if (value != null) {
|
||||
map[entry.key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
// maps a json object with a list of IntegrityReportSummaryResponseDto-objects as value to a dart map
|
||||
static Map<String, List<IntegrityReportSummaryResponseDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
|
||||
final map = <String, List<IntegrityReportSummaryResponseDto>>{};
|
||||
if (json is Map && json.isNotEmpty) {
|
||||
// ignore: parameter_assignments
|
||||
json = json.cast<String, dynamic>();
|
||||
for (final entry in json.entries) {
|
||||
map[entry.key] = IntegrityReportSummaryResponseDto.listFromJson(entry.value, growable: growable,);
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
/// The list of required keys that must be present in a JSON.
|
||||
static const requiredKeys = <String>{
|
||||
'checksum_mismatch',
|
||||
'missing_file',
|
||||
'orphan_file',
|
||||
};
|
||||
}
|
||||
|
||||
88
mobile/openapi/lib/model/integrity_report_type.dart
generated
Normal file
88
mobile/openapi/lib/model/integrity_report_type.dart
generated
Normal file
@@ -0,0 +1,88 @@
|
||||
//
|
||||
// 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 IntegrityReportType {
|
||||
/// Instantiate a new enum with the provided [value].
|
||||
const IntegrityReportType._(this.value);
|
||||
|
||||
/// The underlying value of this enum member.
|
||||
final String value;
|
||||
|
||||
@override
|
||||
String toString() => value;
|
||||
|
||||
String toJson() => value;
|
||||
|
||||
static const orphanFile = IntegrityReportType._(r'orphan_file');
|
||||
static const missingFile = IntegrityReportType._(r'missing_file');
|
||||
static const checksumMismatch = IntegrityReportType._(r'checksum_mismatch');
|
||||
|
||||
/// List of all possible values in this [enum][IntegrityReportType].
|
||||
static const values = <IntegrityReportType>[
|
||||
orphanFile,
|
||||
missingFile,
|
||||
checksumMismatch,
|
||||
];
|
||||
|
||||
static IntegrityReportType? fromJson(dynamic value) => IntegrityReportTypeTypeTransformer().decode(value);
|
||||
|
||||
static List<IntegrityReportType> listFromJson(dynamic json, {bool growable = false,}) {
|
||||
final result = <IntegrityReportType>[];
|
||||
if (json is List && json.isNotEmpty) {
|
||||
for (final row in json) {
|
||||
final value = IntegrityReportType.fromJson(row);
|
||||
if (value != null) {
|
||||
result.add(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
return result.toList(growable: growable);
|
||||
}
|
||||
}
|
||||
|
||||
/// Transformation class that can [encode] an instance of [IntegrityReportType] to String,
|
||||
/// and [decode] dynamic data back to [IntegrityReportType].
|
||||
class IntegrityReportTypeTypeTransformer {
|
||||
factory IntegrityReportTypeTypeTransformer() => _instance ??= const IntegrityReportTypeTypeTransformer._();
|
||||
|
||||
const IntegrityReportTypeTypeTransformer._();
|
||||
|
||||
String encode(IntegrityReportType data) => data.value;
|
||||
|
||||
/// Decodes a [dynamic value][data] to a IntegrityReportType.
|
||||
///
|
||||
/// 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.
|
||||
IntegrityReportType? decode(dynamic data, {bool allowNull = true}) {
|
||||
if (data != null) {
|
||||
switch (data) {
|
||||
case r'orphan_file': return IntegrityReportType.orphanFile;
|
||||
case r'missing_file': return IntegrityReportType.missingFile;
|
||||
case r'checksum_mismatch': return IntegrityReportType.checksumMismatch;
|
||||
default:
|
||||
if (!allowNull) {
|
||||
throw ArgumentError('Unknown enum value to decode: $data');
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Singleton [IntegrityReportTypeTypeTransformer] instance.
|
||||
static IntegrityReportTypeTypeTransformer? _instance;
|
||||
}
|
||||
|
||||
30
mobile/openapi/lib/model/job_name.dart
generated
30
mobile/openapi/lib/model/job_name.dart
generated
@@ -78,6 +78,16 @@ class JobName {
|
||||
static const ocrQueueAll = JobName._(r'OcrQueueAll');
|
||||
static const ocr = JobName._(r'Ocr');
|
||||
static const workflowRun = JobName._(r'WorkflowRun');
|
||||
static const integrityOrphanedFilesQueueAll = JobName._(r'IntegrityOrphanedFilesQueueAll');
|
||||
static const integrityOrphanedFiles = JobName._(r'IntegrityOrphanedFiles');
|
||||
static const integrityOrphanedRefresh = JobName._(r'IntegrityOrphanedRefresh');
|
||||
static const integrityMissingFilesQueueAll = JobName._(r'IntegrityMissingFilesQueueAll');
|
||||
static const integrityMissingFiles = JobName._(r'IntegrityMissingFiles');
|
||||
static const integrityMissingFilesRefresh = JobName._(r'IntegrityMissingFilesRefresh');
|
||||
static const integrityChecksumFiles = JobName._(r'IntegrityChecksumFiles');
|
||||
static const integrityChecksumFilesRefresh = JobName._(r'IntegrityChecksumFilesRefresh');
|
||||
static const integrityDeleteReportType = JobName._(r'IntegrityDeleteReportType');
|
||||
static const integrityDeleteReports = JobName._(r'IntegrityDeleteReports');
|
||||
|
||||
/// List of all possible values in this [enum][JobName].
|
||||
static const values = <JobName>[
|
||||
@@ -136,6 +146,16 @@ class JobName {
|
||||
ocrQueueAll,
|
||||
ocr,
|
||||
workflowRun,
|
||||
integrityOrphanedFilesQueueAll,
|
||||
integrityOrphanedFiles,
|
||||
integrityOrphanedRefresh,
|
||||
integrityMissingFilesQueueAll,
|
||||
integrityMissingFiles,
|
||||
integrityMissingFilesRefresh,
|
||||
integrityChecksumFiles,
|
||||
integrityChecksumFilesRefresh,
|
||||
integrityDeleteReportType,
|
||||
integrityDeleteReports,
|
||||
];
|
||||
|
||||
static JobName? fromJson(dynamic value) => JobNameTypeTransformer().decode(value);
|
||||
@@ -229,6 +249,16 @@ class JobNameTypeTransformer {
|
||||
case r'OcrQueueAll': return JobName.ocrQueueAll;
|
||||
case r'Ocr': return JobName.ocr;
|
||||
case r'WorkflowRun': return JobName.workflowRun;
|
||||
case r'IntegrityOrphanedFilesQueueAll': return JobName.integrityOrphanedFilesQueueAll;
|
||||
case r'IntegrityOrphanedFiles': return JobName.integrityOrphanedFiles;
|
||||
case r'IntegrityOrphanedRefresh': return JobName.integrityOrphanedRefresh;
|
||||
case r'IntegrityMissingFilesQueueAll': return JobName.integrityMissingFilesQueueAll;
|
||||
case r'IntegrityMissingFiles': return JobName.integrityMissingFiles;
|
||||
case r'IntegrityMissingFilesRefresh': return JobName.integrityMissingFilesRefresh;
|
||||
case r'IntegrityChecksumFiles': return JobName.integrityChecksumFiles;
|
||||
case r'IntegrityChecksumFilesRefresh': return JobName.integrityChecksumFilesRefresh;
|
||||
case r'IntegrityDeleteReportType': return JobName.integrityDeleteReportType;
|
||||
case r'IntegrityDeleteReports': return JobName.integrityDeleteReports;
|
||||
default:
|
||||
if (!allowNull) {
|
||||
throw ArgumentError('Unknown enum value to decode: $data');
|
||||
|
||||
27
mobile/openapi/lib/model/manual_job_name.dart
generated
27
mobile/openapi/lib/model/manual_job_name.dart
generated
@@ -29,6 +29,15 @@ class ManualJobName {
|
||||
static const memoryCleanup = ManualJobName._(r'memory-cleanup');
|
||||
static const memoryCreate = ManualJobName._(r'memory-create');
|
||||
static const backupDatabase = ManualJobName._(r'backup-database');
|
||||
static const integrityMissingFiles = ManualJobName._(r'integrity-missing-files');
|
||||
static const integrityOrphanFiles = ManualJobName._(r'integrity-orphan-files');
|
||||
static const integrityChecksumMismatch = ManualJobName._(r'integrity-checksum-mismatch');
|
||||
static const integrityMissingFilesRefresh = ManualJobName._(r'integrity-missing-files-refresh');
|
||||
static const integrityOrphanFilesRefresh = ManualJobName._(r'integrity-orphan-files-refresh');
|
||||
static const integrityChecksumMismatchRefresh = ManualJobName._(r'integrity-checksum-mismatch-refresh');
|
||||
static const integrityMissingFilesDeleteAll = ManualJobName._(r'integrity-missing-files-delete-all');
|
||||
static const integrityOrphanFilesDeleteAll = ManualJobName._(r'integrity-orphan-files-delete-all');
|
||||
static const integrityChecksumMismatchDeleteAll = ManualJobName._(r'integrity-checksum-mismatch-delete-all');
|
||||
|
||||
/// List of all possible values in this [enum][ManualJobName].
|
||||
static const values = <ManualJobName>[
|
||||
@@ -38,6 +47,15 @@ class ManualJobName {
|
||||
memoryCleanup,
|
||||
memoryCreate,
|
||||
backupDatabase,
|
||||
integrityMissingFiles,
|
||||
integrityOrphanFiles,
|
||||
integrityChecksumMismatch,
|
||||
integrityMissingFilesRefresh,
|
||||
integrityOrphanFilesRefresh,
|
||||
integrityChecksumMismatchRefresh,
|
||||
integrityMissingFilesDeleteAll,
|
||||
integrityOrphanFilesDeleteAll,
|
||||
integrityChecksumMismatchDeleteAll,
|
||||
];
|
||||
|
||||
static ManualJobName? fromJson(dynamic value) => ManualJobNameTypeTransformer().decode(value);
|
||||
@@ -82,6 +100,15 @@ class ManualJobNameTypeTransformer {
|
||||
case r'memory-cleanup': return ManualJobName.memoryCleanup;
|
||||
case r'memory-create': return ManualJobName.memoryCreate;
|
||||
case r'backup-database': return ManualJobName.backupDatabase;
|
||||
case r'integrity-missing-files': return ManualJobName.integrityMissingFiles;
|
||||
case r'integrity-orphan-files': return ManualJobName.integrityOrphanFiles;
|
||||
case r'integrity-checksum-mismatch': return ManualJobName.integrityChecksumMismatch;
|
||||
case r'integrity-missing-files-refresh': return ManualJobName.integrityMissingFilesRefresh;
|
||||
case r'integrity-orphan-files-refresh': return ManualJobName.integrityOrphanFilesRefresh;
|
||||
case r'integrity-checksum-mismatch-refresh': return ManualJobName.integrityChecksumMismatchRefresh;
|
||||
case r'integrity-missing-files-delete-all': return ManualJobName.integrityMissingFilesDeleteAll;
|
||||
case r'integrity-orphan-files-delete-all': return ManualJobName.integrityOrphanFilesDeleteAll;
|
||||
case r'integrity-checksum-mismatch-delete-all': return ManualJobName.integrityChecksumMismatchDeleteAll;
|
||||
default:
|
||||
if (!allowNull) {
|
||||
throw ArgumentError('Unknown enum value to decode: $data');
|
||||
|
||||
3
mobile/openapi/lib/model/queue_name.dart
generated
3
mobile/openapi/lib/model/queue_name.dart
generated
@@ -40,6 +40,7 @@ class QueueName {
|
||||
static const backupDatabase = QueueName._(r'backupDatabase');
|
||||
static const ocr = QueueName._(r'ocr');
|
||||
static const workflow = QueueName._(r'workflow');
|
||||
static const integrityCheck = QueueName._(r'integrityCheck');
|
||||
|
||||
/// List of all possible values in this [enum][QueueName].
|
||||
static const values = <QueueName>[
|
||||
@@ -60,6 +61,7 @@ class QueueName {
|
||||
backupDatabase,
|
||||
ocr,
|
||||
workflow,
|
||||
integrityCheck,
|
||||
];
|
||||
|
||||
static QueueName? fromJson(dynamic value) => QueueNameTypeTransformer().decode(value);
|
||||
@@ -115,6 +117,7 @@ class QueueNameTypeTransformer {
|
||||
case r'backupDatabase': return QueueName.backupDatabase;
|
||||
case r'ocr': return QueueName.ocr;
|
||||
case r'workflow': return QueueName.workflow;
|
||||
case r'integrityCheck': return QueueName.integrityCheck;
|
||||
default:
|
||||
if (!allowNull) {
|
||||
throw ArgumentError('Unknown enum value to decode: $data');
|
||||
|
||||
@@ -18,6 +18,7 @@ class QueuesResponseLegacyDto {
|
||||
required this.duplicateDetection,
|
||||
required this.faceDetection,
|
||||
required this.facialRecognition,
|
||||
required this.integrityCheck,
|
||||
required this.library_,
|
||||
required this.metadataExtraction,
|
||||
required this.migration,
|
||||
@@ -42,6 +43,8 @@ class QueuesResponseLegacyDto {
|
||||
|
||||
QueueResponseLegacyDto facialRecognition;
|
||||
|
||||
QueueResponseLegacyDto integrityCheck;
|
||||
|
||||
QueueResponseLegacyDto library_;
|
||||
|
||||
QueueResponseLegacyDto metadataExtraction;
|
||||
@@ -73,6 +76,7 @@ class QueuesResponseLegacyDto {
|
||||
other.duplicateDetection == duplicateDetection &&
|
||||
other.faceDetection == faceDetection &&
|
||||
other.facialRecognition == facialRecognition &&
|
||||
other.integrityCheck == integrityCheck &&
|
||||
other.library_ == library_ &&
|
||||
other.metadataExtraction == metadataExtraction &&
|
||||
other.migration == migration &&
|
||||
@@ -94,6 +98,7 @@ class QueuesResponseLegacyDto {
|
||||
(duplicateDetection.hashCode) +
|
||||
(faceDetection.hashCode) +
|
||||
(facialRecognition.hashCode) +
|
||||
(integrityCheck.hashCode) +
|
||||
(library_.hashCode) +
|
||||
(metadataExtraction.hashCode) +
|
||||
(migration.hashCode) +
|
||||
@@ -108,7 +113,7 @@ class QueuesResponseLegacyDto {
|
||||
(workflow.hashCode);
|
||||
|
||||
@override
|
||||
String toString() => 'QueuesResponseLegacyDto[backgroundTask=$backgroundTask, backupDatabase=$backupDatabase, duplicateDetection=$duplicateDetection, faceDetection=$faceDetection, facialRecognition=$facialRecognition, library_=$library_, metadataExtraction=$metadataExtraction, migration=$migration, notifications=$notifications, ocr=$ocr, search=$search, sidecar=$sidecar, smartSearch=$smartSearch, storageTemplateMigration=$storageTemplateMigration, thumbnailGeneration=$thumbnailGeneration, videoConversion=$videoConversion, workflow=$workflow]';
|
||||
String toString() => 'QueuesResponseLegacyDto[backgroundTask=$backgroundTask, backupDatabase=$backupDatabase, duplicateDetection=$duplicateDetection, faceDetection=$faceDetection, facialRecognition=$facialRecognition, integrityCheck=$integrityCheck, library_=$library_, metadataExtraction=$metadataExtraction, migration=$migration, notifications=$notifications, ocr=$ocr, search=$search, sidecar=$sidecar, smartSearch=$smartSearch, storageTemplateMigration=$storageTemplateMigration, thumbnailGeneration=$thumbnailGeneration, videoConversion=$videoConversion, workflow=$workflow]';
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
final json = <String, dynamic>{};
|
||||
@@ -117,6 +122,7 @@ class QueuesResponseLegacyDto {
|
||||
json[r'duplicateDetection'] = this.duplicateDetection;
|
||||
json[r'faceDetection'] = this.faceDetection;
|
||||
json[r'facialRecognition'] = this.facialRecognition;
|
||||
json[r'integrityCheck'] = this.integrityCheck;
|
||||
json[r'library'] = this.library_;
|
||||
json[r'metadataExtraction'] = this.metadataExtraction;
|
||||
json[r'migration'] = this.migration;
|
||||
@@ -146,6 +152,7 @@ class QueuesResponseLegacyDto {
|
||||
duplicateDetection: QueueResponseLegacyDto.fromJson(json[r'duplicateDetection'])!,
|
||||
faceDetection: QueueResponseLegacyDto.fromJson(json[r'faceDetection'])!,
|
||||
facialRecognition: QueueResponseLegacyDto.fromJson(json[r'facialRecognition'])!,
|
||||
integrityCheck: QueueResponseLegacyDto.fromJson(json[r'integrityCheck'])!,
|
||||
library_: QueueResponseLegacyDto.fromJson(json[r'library'])!,
|
||||
metadataExtraction: QueueResponseLegacyDto.fromJson(json[r'metadataExtraction'])!,
|
||||
migration: QueueResponseLegacyDto.fromJson(json[r'migration'])!,
|
||||
@@ -210,6 +217,7 @@ class QueuesResponseLegacyDto {
|
||||
'duplicateDetection',
|
||||
'faceDetection',
|
||||
'facialRecognition',
|
||||
'integrityCheck',
|
||||
'library',
|
||||
'metadataExtraction',
|
||||
'migration',
|
||||
|
||||
10
mobile/openapi/lib/model/system_config_dto.dart
generated
10
mobile/openapi/lib/model/system_config_dto.dart
generated
@@ -16,6 +16,7 @@ class SystemConfigDto {
|
||||
required this.backup,
|
||||
required this.ffmpeg,
|
||||
required this.image,
|
||||
required this.integrityChecks,
|
||||
required this.job,
|
||||
required this.library_,
|
||||
required this.logging,
|
||||
@@ -42,6 +43,8 @@ class SystemConfigDto {
|
||||
|
||||
SystemConfigImageDto image;
|
||||
|
||||
SystemConfigIntegrityChecks integrityChecks;
|
||||
|
||||
SystemConfigJobDto job;
|
||||
|
||||
SystemConfigLibraryDto library_;
|
||||
@@ -83,6 +86,7 @@ class SystemConfigDto {
|
||||
other.backup == backup &&
|
||||
other.ffmpeg == ffmpeg &&
|
||||
other.image == image &&
|
||||
other.integrityChecks == integrityChecks &&
|
||||
other.job == job &&
|
||||
other.library_ == library_ &&
|
||||
other.logging == logging &&
|
||||
@@ -108,6 +112,7 @@ class SystemConfigDto {
|
||||
(backup.hashCode) +
|
||||
(ffmpeg.hashCode) +
|
||||
(image.hashCode) +
|
||||
(integrityChecks.hashCode) +
|
||||
(job.hashCode) +
|
||||
(library_.hashCode) +
|
||||
(logging.hashCode) +
|
||||
@@ -128,13 +133,14 @@ class SystemConfigDto {
|
||||
(user.hashCode);
|
||||
|
||||
@override
|
||||
String toString() => 'SystemConfigDto[backup=$backup, ffmpeg=$ffmpeg, image=$image, job=$job, library_=$library_, logging=$logging, machineLearning=$machineLearning, map=$map, metadata=$metadata, newVersionCheck=$newVersionCheck, nightlyTasks=$nightlyTasks, notifications=$notifications, oauth=$oauth, passwordLogin=$passwordLogin, reverseGeocoding=$reverseGeocoding, server=$server, storageTemplate=$storageTemplate, templates=$templates, theme=$theme, trash=$trash, user=$user]';
|
||||
String toString() => 'SystemConfigDto[backup=$backup, ffmpeg=$ffmpeg, image=$image, integrityChecks=$integrityChecks, job=$job, library_=$library_, logging=$logging, machineLearning=$machineLearning, map=$map, metadata=$metadata, newVersionCheck=$newVersionCheck, nightlyTasks=$nightlyTasks, notifications=$notifications, oauth=$oauth, passwordLogin=$passwordLogin, reverseGeocoding=$reverseGeocoding, server=$server, storageTemplate=$storageTemplate, templates=$templates, theme=$theme, trash=$trash, user=$user]';
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
final json = <String, dynamic>{};
|
||||
json[r'backup'] = this.backup;
|
||||
json[r'ffmpeg'] = this.ffmpeg;
|
||||
json[r'image'] = this.image;
|
||||
json[r'integrityChecks'] = this.integrityChecks;
|
||||
json[r'job'] = this.job;
|
||||
json[r'library'] = this.library_;
|
||||
json[r'logging'] = this.logging;
|
||||
@@ -168,6 +174,7 @@ class SystemConfigDto {
|
||||
backup: SystemConfigBackupsDto.fromJson(json[r'backup'])!,
|
||||
ffmpeg: SystemConfigFFmpegDto.fromJson(json[r'ffmpeg'])!,
|
||||
image: SystemConfigImageDto.fromJson(json[r'image'])!,
|
||||
integrityChecks: SystemConfigIntegrityChecks.fromJson(json[r'integrityChecks'])!,
|
||||
job: SystemConfigJobDto.fromJson(json[r'job'])!,
|
||||
library_: SystemConfigLibraryDto.fromJson(json[r'library'])!,
|
||||
logging: SystemConfigLoggingDto.fromJson(json[r'logging'])!,
|
||||
@@ -236,6 +243,7 @@ class SystemConfigDto {
|
||||
'backup',
|
||||
'ffmpeg',
|
||||
'image',
|
||||
'integrityChecks',
|
||||
'job',
|
||||
'library',
|
||||
'logging',
|
||||
|
||||
115
mobile/openapi/lib/model/system_config_integrity_checks.dart
generated
Normal file
115
mobile/openapi/lib/model/system_config_integrity_checks.dart
generated
Normal file
@@ -0,0 +1,115 @@
|
||||
//
|
||||
// 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 SystemConfigIntegrityChecks {
|
||||
/// Returns a new [SystemConfigIntegrityChecks] instance.
|
||||
SystemConfigIntegrityChecks({
|
||||
required this.checksumFiles,
|
||||
required this.missingFiles,
|
||||
required this.orphanedFiles,
|
||||
});
|
||||
|
||||
SystemConfigIntegrityChecksumJob checksumFiles;
|
||||
|
||||
SystemConfigIntegrityJob missingFiles;
|
||||
|
||||
SystemConfigIntegrityJob orphanedFiles;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) => identical(this, other) || other is SystemConfigIntegrityChecks &&
|
||||
other.checksumFiles == checksumFiles &&
|
||||
other.missingFiles == missingFiles &&
|
||||
other.orphanedFiles == orphanedFiles;
|
||||
|
||||
@override
|
||||
int get hashCode =>
|
||||
// ignore: unnecessary_parenthesis
|
||||
(checksumFiles.hashCode) +
|
||||
(missingFiles.hashCode) +
|
||||
(orphanedFiles.hashCode);
|
||||
|
||||
@override
|
||||
String toString() => 'SystemConfigIntegrityChecks[checksumFiles=$checksumFiles, missingFiles=$missingFiles, orphanedFiles=$orphanedFiles]';
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
final json = <String, dynamic>{};
|
||||
json[r'checksumFiles'] = this.checksumFiles;
|
||||
json[r'missingFiles'] = this.missingFiles;
|
||||
json[r'orphanedFiles'] = this.orphanedFiles;
|
||||
return json;
|
||||
}
|
||||
|
||||
/// Returns a new [SystemConfigIntegrityChecks] instance and imports its values from
|
||||
/// [value] if it's a [Map], null otherwise.
|
||||
// ignore: prefer_constructors_over_static_methods
|
||||
static SystemConfigIntegrityChecks? fromJson(dynamic value) {
|
||||
upgradeDto(value, "SystemConfigIntegrityChecks");
|
||||
if (value is Map) {
|
||||
final json = value.cast<String, dynamic>();
|
||||
|
||||
return SystemConfigIntegrityChecks(
|
||||
checksumFiles: SystemConfigIntegrityChecksumJob.fromJson(json[r'checksumFiles'])!,
|
||||
missingFiles: SystemConfigIntegrityJob.fromJson(json[r'missingFiles'])!,
|
||||
orphanedFiles: SystemConfigIntegrityJob.fromJson(json[r'orphanedFiles'])!,
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static List<SystemConfigIntegrityChecks> listFromJson(dynamic json, {bool growable = false,}) {
|
||||
final result = <SystemConfigIntegrityChecks>[];
|
||||
if (json is List && json.isNotEmpty) {
|
||||
for (final row in json) {
|
||||
final value = SystemConfigIntegrityChecks.fromJson(row);
|
||||
if (value != null) {
|
||||
result.add(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
return result.toList(growable: growable);
|
||||
}
|
||||
|
||||
static Map<String, SystemConfigIntegrityChecks> mapFromJson(dynamic json) {
|
||||
final map = <String, SystemConfigIntegrityChecks>{};
|
||||
if (json is Map && json.isNotEmpty) {
|
||||
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
|
||||
for (final entry in json.entries) {
|
||||
final value = SystemConfigIntegrityChecks.fromJson(entry.value);
|
||||
if (value != null) {
|
||||
map[entry.key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
// maps a json object with a list of SystemConfigIntegrityChecks-objects as value to a dart map
|
||||
static Map<String, List<SystemConfigIntegrityChecks>> mapListFromJson(dynamic json, {bool growable = false,}) {
|
||||
final map = <String, List<SystemConfigIntegrityChecks>>{};
|
||||
if (json is Map && json.isNotEmpty) {
|
||||
// ignore: parameter_assignments
|
||||
json = json.cast<String, dynamic>();
|
||||
for (final entry in json.entries) {
|
||||
map[entry.key] = SystemConfigIntegrityChecks.listFromJson(entry.value, growable: growable,);
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
/// The list of required keys that must be present in a JSON.
|
||||
static const requiredKeys = <String>{
|
||||
'checksumFiles',
|
||||
'missingFiles',
|
||||
'orphanedFiles',
|
||||
};
|
||||
}
|
||||
|
||||
123
mobile/openapi/lib/model/system_config_integrity_checksum_job.dart
generated
Normal file
123
mobile/openapi/lib/model/system_config_integrity_checksum_job.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 SystemConfigIntegrityChecksumJob {
|
||||
/// Returns a new [SystemConfigIntegrityChecksumJob] instance.
|
||||
SystemConfigIntegrityChecksumJob({
|
||||
required this.cronExpression,
|
||||
required this.enabled,
|
||||
required this.percentageLimit,
|
||||
required this.timeLimit,
|
||||
});
|
||||
|
||||
String cronExpression;
|
||||
|
||||
bool enabled;
|
||||
|
||||
num percentageLimit;
|
||||
|
||||
num timeLimit;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) => identical(this, other) || other is SystemConfigIntegrityChecksumJob &&
|
||||
other.cronExpression == cronExpression &&
|
||||
other.enabled == enabled &&
|
||||
other.percentageLimit == percentageLimit &&
|
||||
other.timeLimit == timeLimit;
|
||||
|
||||
@override
|
||||
int get hashCode =>
|
||||
// ignore: unnecessary_parenthesis
|
||||
(cronExpression.hashCode) +
|
||||
(enabled.hashCode) +
|
||||
(percentageLimit.hashCode) +
|
||||
(timeLimit.hashCode);
|
||||
|
||||
@override
|
||||
String toString() => 'SystemConfigIntegrityChecksumJob[cronExpression=$cronExpression, enabled=$enabled, percentageLimit=$percentageLimit, timeLimit=$timeLimit]';
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
final json = <String, dynamic>{};
|
||||
json[r'cronExpression'] = this.cronExpression;
|
||||
json[r'enabled'] = this.enabled;
|
||||
json[r'percentageLimit'] = this.percentageLimit;
|
||||
json[r'timeLimit'] = this.timeLimit;
|
||||
return json;
|
||||
}
|
||||
|
||||
/// Returns a new [SystemConfigIntegrityChecksumJob] instance and imports its values from
|
||||
/// [value] if it's a [Map], null otherwise.
|
||||
// ignore: prefer_constructors_over_static_methods
|
||||
static SystemConfigIntegrityChecksumJob? fromJson(dynamic value) {
|
||||
upgradeDto(value, "SystemConfigIntegrityChecksumJob");
|
||||
if (value is Map) {
|
||||
final json = value.cast<String, dynamic>();
|
||||
|
||||
return SystemConfigIntegrityChecksumJob(
|
||||
cronExpression: mapValueOfType<String>(json, r'cronExpression')!,
|
||||
enabled: mapValueOfType<bool>(json, r'enabled')!,
|
||||
percentageLimit: num.parse('${json[r'percentageLimit']}'),
|
||||
timeLimit: num.parse('${json[r'timeLimit']}'),
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static List<SystemConfigIntegrityChecksumJob> listFromJson(dynamic json, {bool growable = false,}) {
|
||||
final result = <SystemConfigIntegrityChecksumJob>[];
|
||||
if (json is List && json.isNotEmpty) {
|
||||
for (final row in json) {
|
||||
final value = SystemConfigIntegrityChecksumJob.fromJson(row);
|
||||
if (value != null) {
|
||||
result.add(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
return result.toList(growable: growable);
|
||||
}
|
||||
|
||||
static Map<String, SystemConfigIntegrityChecksumJob> mapFromJson(dynamic json) {
|
||||
final map = <String, SystemConfigIntegrityChecksumJob>{};
|
||||
if (json is Map && json.isNotEmpty) {
|
||||
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
|
||||
for (final entry in json.entries) {
|
||||
final value = SystemConfigIntegrityChecksumJob.fromJson(entry.value);
|
||||
if (value != null) {
|
||||
map[entry.key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
// maps a json object with a list of SystemConfigIntegrityChecksumJob-objects as value to a dart map
|
||||
static Map<String, List<SystemConfigIntegrityChecksumJob>> mapListFromJson(dynamic json, {bool growable = false,}) {
|
||||
final map = <String, List<SystemConfigIntegrityChecksumJob>>{};
|
||||
if (json is Map && json.isNotEmpty) {
|
||||
// ignore: parameter_assignments
|
||||
json = json.cast<String, dynamic>();
|
||||
for (final entry in json.entries) {
|
||||
map[entry.key] = SystemConfigIntegrityChecksumJob.listFromJson(entry.value, growable: growable,);
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
/// The list of required keys that must be present in a JSON.
|
||||
static const requiredKeys = <String>{
|
||||
'cronExpression',
|
||||
'enabled',
|
||||
'percentageLimit',
|
||||
'timeLimit',
|
||||
};
|
||||
}
|
||||
|
||||
107
mobile/openapi/lib/model/system_config_integrity_job.dart
generated
Normal file
107
mobile/openapi/lib/model/system_config_integrity_job.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 SystemConfigIntegrityJob {
|
||||
/// Returns a new [SystemConfigIntegrityJob] instance.
|
||||
SystemConfigIntegrityJob({
|
||||
required this.cronExpression,
|
||||
required this.enabled,
|
||||
});
|
||||
|
||||
String cronExpression;
|
||||
|
||||
bool enabled;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) => identical(this, other) || other is SystemConfigIntegrityJob &&
|
||||
other.cronExpression == cronExpression &&
|
||||
other.enabled == enabled;
|
||||
|
||||
@override
|
||||
int get hashCode =>
|
||||
// ignore: unnecessary_parenthesis
|
||||
(cronExpression.hashCode) +
|
||||
(enabled.hashCode);
|
||||
|
||||
@override
|
||||
String toString() => 'SystemConfigIntegrityJob[cronExpression=$cronExpression, enabled=$enabled]';
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
final json = <String, dynamic>{};
|
||||
json[r'cronExpression'] = this.cronExpression;
|
||||
json[r'enabled'] = this.enabled;
|
||||
return json;
|
||||
}
|
||||
|
||||
/// Returns a new [SystemConfigIntegrityJob] instance and imports its values from
|
||||
/// [value] if it's a [Map], null otherwise.
|
||||
// ignore: prefer_constructors_over_static_methods
|
||||
static SystemConfigIntegrityJob? fromJson(dynamic value) {
|
||||
upgradeDto(value, "SystemConfigIntegrityJob");
|
||||
if (value is Map) {
|
||||
final json = value.cast<String, dynamic>();
|
||||
|
||||
return SystemConfigIntegrityJob(
|
||||
cronExpression: mapValueOfType<String>(json, r'cronExpression')!,
|
||||
enabled: mapValueOfType<bool>(json, r'enabled')!,
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static List<SystemConfigIntegrityJob> listFromJson(dynamic json, {bool growable = false,}) {
|
||||
final result = <SystemConfigIntegrityJob>[];
|
||||
if (json is List && json.isNotEmpty) {
|
||||
for (final row in json) {
|
||||
final value = SystemConfigIntegrityJob.fromJson(row);
|
||||
if (value != null) {
|
||||
result.add(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
return result.toList(growable: growable);
|
||||
}
|
||||
|
||||
static Map<String, SystemConfigIntegrityJob> mapFromJson(dynamic json) {
|
||||
final map = <String, SystemConfigIntegrityJob>{};
|
||||
if (json is Map && json.isNotEmpty) {
|
||||
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
|
||||
for (final entry in json.entries) {
|
||||
final value = SystemConfigIntegrityJob.fromJson(entry.value);
|
||||
if (value != null) {
|
||||
map[entry.key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
// maps a json object with a list of SystemConfigIntegrityJob-objects as value to a dart map
|
||||
static Map<String, List<SystemConfigIntegrityJob>> mapListFromJson(dynamic json, {bool growable = false,}) {
|
||||
final map = <String, List<SystemConfigIntegrityJob>>{};
|
||||
if (json is Map && json.isNotEmpty) {
|
||||
// ignore: parameter_assignments
|
||||
json = json.cast<String, dynamic>();
|
||||
for (final entry in json.entries) {
|
||||
map[entry.key] = SystemConfigIntegrityJob.listFromJson(entry.value, growable: growable,);
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
/// The list of required keys that must be present in a JSON.
|
||||
static const requiredKeys = <String>{
|
||||
'cronExpression',
|
||||
'enabled',
|
||||
};
|
||||
}
|
||||
|
||||
10
mobile/openapi/lib/model/system_config_job_dto.dart
generated
10
mobile/openapi/lib/model/system_config_job_dto.dart
generated
@@ -15,6 +15,7 @@ class SystemConfigJobDto {
|
||||
SystemConfigJobDto({
|
||||
required this.backgroundTask,
|
||||
required this.faceDetection,
|
||||
required this.integrityCheck,
|
||||
required this.library_,
|
||||
required this.metadataExtraction,
|
||||
required this.migration,
|
||||
@@ -32,6 +33,8 @@ class SystemConfigJobDto {
|
||||
|
||||
JobSettingsDto faceDetection;
|
||||
|
||||
JobSettingsDto integrityCheck;
|
||||
|
||||
JobSettingsDto library_;
|
||||
|
||||
JobSettingsDto metadataExtraction;
|
||||
@@ -58,6 +61,7 @@ class SystemConfigJobDto {
|
||||
bool operator ==(Object other) => identical(this, other) || other is SystemConfigJobDto &&
|
||||
other.backgroundTask == backgroundTask &&
|
||||
other.faceDetection == faceDetection &&
|
||||
other.integrityCheck == integrityCheck &&
|
||||
other.library_ == library_ &&
|
||||
other.metadataExtraction == metadataExtraction &&
|
||||
other.migration == migration &&
|
||||
@@ -75,6 +79,7 @@ class SystemConfigJobDto {
|
||||
// ignore: unnecessary_parenthesis
|
||||
(backgroundTask.hashCode) +
|
||||
(faceDetection.hashCode) +
|
||||
(integrityCheck.hashCode) +
|
||||
(library_.hashCode) +
|
||||
(metadataExtraction.hashCode) +
|
||||
(migration.hashCode) +
|
||||
@@ -88,12 +93,13 @@ class SystemConfigJobDto {
|
||||
(workflow.hashCode);
|
||||
|
||||
@override
|
||||
String toString() => 'SystemConfigJobDto[backgroundTask=$backgroundTask, faceDetection=$faceDetection, library_=$library_, metadataExtraction=$metadataExtraction, migration=$migration, notifications=$notifications, ocr=$ocr, search=$search, sidecar=$sidecar, smartSearch=$smartSearch, thumbnailGeneration=$thumbnailGeneration, videoConversion=$videoConversion, workflow=$workflow]';
|
||||
String toString() => 'SystemConfigJobDto[backgroundTask=$backgroundTask, faceDetection=$faceDetection, integrityCheck=$integrityCheck, library_=$library_, metadataExtraction=$metadataExtraction, migration=$migration, notifications=$notifications, ocr=$ocr, search=$search, sidecar=$sidecar, smartSearch=$smartSearch, thumbnailGeneration=$thumbnailGeneration, videoConversion=$videoConversion, workflow=$workflow]';
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
final json = <String, dynamic>{};
|
||||
json[r'backgroundTask'] = this.backgroundTask;
|
||||
json[r'faceDetection'] = this.faceDetection;
|
||||
json[r'integrityCheck'] = this.integrityCheck;
|
||||
json[r'library'] = this.library_;
|
||||
json[r'metadataExtraction'] = this.metadataExtraction;
|
||||
json[r'migration'] = this.migration;
|
||||
@@ -119,6 +125,7 @@ class SystemConfigJobDto {
|
||||
return SystemConfigJobDto(
|
||||
backgroundTask: JobSettingsDto.fromJson(json[r'backgroundTask'])!,
|
||||
faceDetection: JobSettingsDto.fromJson(json[r'faceDetection'])!,
|
||||
integrityCheck: JobSettingsDto.fromJson(json[r'integrityCheck'])!,
|
||||
library_: JobSettingsDto.fromJson(json[r'library'])!,
|
||||
metadataExtraction: JobSettingsDto.fromJson(json[r'metadataExtraction'])!,
|
||||
migration: JobSettingsDto.fromJson(json[r'migration'])!,
|
||||
@@ -179,6 +186,7 @@ class SystemConfigJobDto {
|
||||
static const requiredKeys = <String>{
|
||||
'backgroundTask',
|
||||
'faceDetection',
|
||||
'integrityCheck',
|
||||
'library',
|
||||
'metadataExtraction',
|
||||
'migration',
|
||||
|
||||
@@ -322,6 +322,275 @@
|
||||
"x-immich-state": "Stable"
|
||||
}
|
||||
},
|
||||
"/admin/integrity/report": {
|
||||
"post": {
|
||||
"description": "Get all flagged items by integrity report type",
|
||||
"operationId": "getIntegrityReport",
|
||||
"parameters": [],
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/IntegrityGetReportDto"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": true
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/IntegrityReportResponseDto"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": ""
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"bearer": []
|
||||
},
|
||||
{
|
||||
"cookie": []
|
||||
},
|
||||
{
|
||||
"api_key": []
|
||||
}
|
||||
],
|
||||
"summary": "Get integrity report by type",
|
||||
"tags": [
|
||||
"Maintenance (admin)"
|
||||
],
|
||||
"x-immich-admin-only": true,
|
||||
"x-immich-history": [
|
||||
{
|
||||
"version": "v9.9.9",
|
||||
"state": "Added"
|
||||
},
|
||||
{
|
||||
"version": "v9.9.9",
|
||||
"state": "Alpha"
|
||||
}
|
||||
],
|
||||
"x-immich-permission": "maintenance",
|
||||
"x-immich-state": "Alpha"
|
||||
}
|
||||
},
|
||||
"/admin/integrity/report/{id}": {
|
||||
"delete": {
|
||||
"description": "Delete a given report item and perform corresponding deletion (e.g. trash asset, delete file)",
|
||||
"operationId": "deleteIntegrityReport",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "id",
|
||||
"required": true,
|
||||
"in": "path",
|
||||
"schema": {
|
||||
"format": "uuid",
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": ""
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"bearer": []
|
||||
},
|
||||
{
|
||||
"cookie": []
|
||||
},
|
||||
{
|
||||
"api_key": []
|
||||
}
|
||||
],
|
||||
"summary": "Delete integrity report item",
|
||||
"tags": [
|
||||
"Maintenance (admin)"
|
||||
],
|
||||
"x-immich-admin-only": true,
|
||||
"x-immich-history": [
|
||||
{
|
||||
"version": "v9.9.9",
|
||||
"state": "Added"
|
||||
},
|
||||
{
|
||||
"version": "v9.9.9",
|
||||
"state": "Alpha"
|
||||
}
|
||||
],
|
||||
"x-immich-permission": "maintenance",
|
||||
"x-immich-state": "Alpha"
|
||||
}
|
||||
},
|
||||
"/admin/integrity/report/{id}/file": {
|
||||
"get": {
|
||||
"description": "Download the orphan/broken file if one exists",
|
||||
"operationId": "getIntegrityReportFile",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "id",
|
||||
"required": true,
|
||||
"in": "path",
|
||||
"schema": {
|
||||
"format": "uuid",
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"content": {
|
||||
"application/octet-stream": {
|
||||
"schema": {
|
||||
"format": "binary",
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": ""
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"bearer": []
|
||||
},
|
||||
{
|
||||
"cookie": []
|
||||
},
|
||||
{
|
||||
"api_key": []
|
||||
}
|
||||
],
|
||||
"summary": "Download flagged file file",
|
||||
"tags": [
|
||||
"Maintenance (admin)"
|
||||
],
|
||||
"x-immich-admin-only": true,
|
||||
"x-immich-history": [
|
||||
{
|
||||
"version": "v9.9.9",
|
||||
"state": "Added"
|
||||
},
|
||||
{
|
||||
"version": "v9.9.9",
|
||||
"state": "Alpha"
|
||||
}
|
||||
],
|
||||
"x-immich-permission": "maintenance",
|
||||
"x-immich-state": "Alpha"
|
||||
}
|
||||
},
|
||||
"/admin/integrity/report/{type}/csv": {
|
||||
"get": {
|
||||
"description": "Get all integrity report entries for a given type as a CSV",
|
||||
"operationId": "getIntegrityReportCsv",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "type",
|
||||
"required": true,
|
||||
"in": "path",
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/IntegrityReportType"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"content": {
|
||||
"application/octet-stream": {
|
||||
"schema": {
|
||||
"format": "binary",
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": ""
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"bearer": []
|
||||
},
|
||||
{
|
||||
"cookie": []
|
||||
},
|
||||
{
|
||||
"api_key": []
|
||||
}
|
||||
],
|
||||
"summary": "Export integrity report by type as CSV",
|
||||
"tags": [
|
||||
"Maintenance (admin)"
|
||||
],
|
||||
"x-immich-admin-only": true,
|
||||
"x-immich-history": [
|
||||
{
|
||||
"version": "v9.9.9",
|
||||
"state": "Added"
|
||||
},
|
||||
{
|
||||
"version": "v9.9.9",
|
||||
"state": "Alpha"
|
||||
}
|
||||
],
|
||||
"x-immich-permission": "maintenance",
|
||||
"x-immich-state": "Alpha"
|
||||
}
|
||||
},
|
||||
"/admin/integrity/summary": {
|
||||
"get": {
|
||||
"description": "Get a count of the items flagged in each integrity report",
|
||||
"operationId": "getIntegrityReportSummary",
|
||||
"parameters": [],
|
||||
"responses": {
|
||||
"200": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/IntegrityReportSummaryResponseDto"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": ""
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"bearer": []
|
||||
},
|
||||
{
|
||||
"cookie": []
|
||||
},
|
||||
{
|
||||
"api_key": []
|
||||
}
|
||||
],
|
||||
"summary": "Get integrity report summary",
|
||||
"tags": [
|
||||
"Maintenance (admin)"
|
||||
],
|
||||
"x-immich-admin-only": true,
|
||||
"x-immich-history": [
|
||||
{
|
||||
"version": "v9.9.9",
|
||||
"state": "Added"
|
||||
},
|
||||
{
|
||||
"version": "v9.9.9",
|
||||
"state": "Alpha"
|
||||
}
|
||||
],
|
||||
"x-immich-permission": "maintenance",
|
||||
"x-immich-state": "Alpha"
|
||||
}
|
||||
},
|
||||
"/admin/maintenance": {
|
||||
"post": {
|
||||
"description": "Put Immich into or take it out of maintenance mode",
|
||||
@@ -14312,6 +14581,10 @@
|
||||
"name": "Faces",
|
||||
"description": "A face is a detected human face within an asset, which can be associated with a person. Faces are normally detected via machine learning, but can also be created via manually."
|
||||
},
|
||||
{
|
||||
"name": "Integrity (admin)",
|
||||
"description": "Endpoints for viewing and managing integrity reports."
|
||||
},
|
||||
{
|
||||
"name": "Jobs",
|
||||
"description": "Queues and background jobs are used for processing tasks asynchronously. Queues can be paused and resumed as needed."
|
||||
@@ -16589,6 +16862,97 @@
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"IntegrityGetReportDto": {
|
||||
"properties": {
|
||||
"page": {
|
||||
"minimum": 1,
|
||||
"type": "number"
|
||||
},
|
||||
"size": {
|
||||
"minimum": 1,
|
||||
"type": "number"
|
||||
},
|
||||
"type": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/IntegrityReportType"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"type"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"IntegrityReportDto": {
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"path": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/IntegrityReportType"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"id",
|
||||
"path",
|
||||
"type"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"IntegrityReportResponseDto": {
|
||||
"properties": {
|
||||
"hasNextPage": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"items": {
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/IntegrityReportDto"
|
||||
},
|
||||
"type": "array"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"hasNextPage",
|
||||
"items"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"IntegrityReportSummaryResponseDto": {
|
||||
"properties": {
|
||||
"checksum_mismatch": {
|
||||
"type": "integer"
|
||||
},
|
||||
"missing_file": {
|
||||
"type": "integer"
|
||||
},
|
||||
"orphan_file": {
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"checksum_mismatch",
|
||||
"missing_file",
|
||||
"orphan_file"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"IntegrityReportType": {
|
||||
"enum": [
|
||||
"orphan_file",
|
||||
"missing_file",
|
||||
"checksum_mismatch"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"JobCreateDto": {
|
||||
"properties": {
|
||||
"name": {
|
||||
@@ -16660,7 +17024,17 @@
|
||||
"VersionCheck",
|
||||
"OcrQueueAll",
|
||||
"Ocr",
|
||||
"WorkflowRun"
|
||||
"WorkflowRun",
|
||||
"IntegrityOrphanedFilesQueueAll",
|
||||
"IntegrityOrphanedFiles",
|
||||
"IntegrityOrphanedRefresh",
|
||||
"IntegrityMissingFilesQueueAll",
|
||||
"IntegrityMissingFiles",
|
||||
"IntegrityMissingFilesRefresh",
|
||||
"IntegrityChecksumFiles",
|
||||
"IntegrityChecksumFilesRefresh",
|
||||
"IntegrityDeleteReportType",
|
||||
"IntegrityDeleteReports"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
@@ -16929,7 +17303,16 @@
|
||||
"user-cleanup",
|
||||
"memory-cleanup",
|
||||
"memory-create",
|
||||
"backup-database"
|
||||
"backup-database",
|
||||
"integrity-missing-files",
|
||||
"integrity-orphan-files",
|
||||
"integrity-checksum-mismatch",
|
||||
"integrity-missing-files-refresh",
|
||||
"integrity-orphan-files-refresh",
|
||||
"integrity-checksum-mismatch-refresh",
|
||||
"integrity-missing-files-delete-all",
|
||||
"integrity-orphan-files-delete-all",
|
||||
"integrity-checksum-mismatch-delete-all"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
@@ -18537,7 +18920,8 @@
|
||||
"notifications",
|
||||
"backupDatabase",
|
||||
"ocr",
|
||||
"workflow"
|
||||
"workflow",
|
||||
"integrityCheck"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
@@ -18650,6 +19034,9 @@
|
||||
"facialRecognition": {
|
||||
"$ref": "#/components/schemas/QueueResponseLegacyDto"
|
||||
},
|
||||
"integrityCheck": {
|
||||
"$ref": "#/components/schemas/QueueResponseLegacyDto"
|
||||
},
|
||||
"library": {
|
||||
"$ref": "#/components/schemas/QueueResponseLegacyDto"
|
||||
},
|
||||
@@ -18693,6 +19080,7 @@
|
||||
"duplicateDetection",
|
||||
"faceDetection",
|
||||
"facialRecognition",
|
||||
"integrityCheck",
|
||||
"library",
|
||||
"metadataExtraction",
|
||||
"migration",
|
||||
@@ -21231,6 +21619,9 @@
|
||||
"image": {
|
||||
"$ref": "#/components/schemas/SystemConfigImageDto"
|
||||
},
|
||||
"integrityChecks": {
|
||||
"$ref": "#/components/schemas/SystemConfigIntegrityChecks"
|
||||
},
|
||||
"job": {
|
||||
"$ref": "#/components/schemas/SystemConfigJobDto"
|
||||
},
|
||||
@@ -21290,6 +21681,7 @@
|
||||
"backup",
|
||||
"ffmpeg",
|
||||
"image",
|
||||
"integrityChecks",
|
||||
"job",
|
||||
"library",
|
||||
"logging",
|
||||
@@ -21536,6 +21928,63 @@
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"SystemConfigIntegrityChecks": {
|
||||
"properties": {
|
||||
"checksumFiles": {
|
||||
"$ref": "#/components/schemas/SystemConfigIntegrityChecksumJob"
|
||||
},
|
||||
"missingFiles": {
|
||||
"$ref": "#/components/schemas/SystemConfigIntegrityJob"
|
||||
},
|
||||
"orphanedFiles": {
|
||||
"$ref": "#/components/schemas/SystemConfigIntegrityJob"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"checksumFiles",
|
||||
"missingFiles",
|
||||
"orphanedFiles"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"SystemConfigIntegrityChecksumJob": {
|
||||
"properties": {
|
||||
"cronExpression": {
|
||||
"type": "string"
|
||||
},
|
||||
"enabled": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"percentageLimit": {
|
||||
"type": "number"
|
||||
},
|
||||
"timeLimit": {
|
||||
"type": "number"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"cronExpression",
|
||||
"enabled",
|
||||
"percentageLimit",
|
||||
"timeLimit"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"SystemConfigIntegrityJob": {
|
||||
"properties": {
|
||||
"cronExpression": {
|
||||
"type": "string"
|
||||
},
|
||||
"enabled": {
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"cronExpression",
|
||||
"enabled"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"SystemConfigJobDto": {
|
||||
"properties": {
|
||||
"backgroundTask": {
|
||||
@@ -21544,6 +21993,9 @@
|
||||
"faceDetection": {
|
||||
"$ref": "#/components/schemas/JobSettingsDto"
|
||||
},
|
||||
"integrityCheck": {
|
||||
"$ref": "#/components/schemas/JobSettingsDto"
|
||||
},
|
||||
"library": {
|
||||
"$ref": "#/components/schemas/JobSettingsDto"
|
||||
},
|
||||
@@ -21581,6 +22033,7 @@
|
||||
"required": [
|
||||
"backgroundTask",
|
||||
"faceDetection",
|
||||
"integrityCheck",
|
||||
"library",
|
||||
"metadataExtraction",
|
||||
"migration",
|
||||
|
||||
@@ -40,6 +40,25 @@ export type ActivityStatisticsResponseDto = {
|
||||
comments: number;
|
||||
likes: number;
|
||||
};
|
||||
export type IntegrityGetReportDto = {
|
||||
page?: number;
|
||||
size?: number;
|
||||
"type": IntegrityReportType;
|
||||
};
|
||||
export type IntegrityReportDto = {
|
||||
id: string;
|
||||
path: string;
|
||||
"type": IntegrityReportType;
|
||||
};
|
||||
export type IntegrityReportResponseDto = {
|
||||
hasNextPage: boolean;
|
||||
items: IntegrityReportDto[];
|
||||
};
|
||||
export type IntegrityReportSummaryResponseDto = {
|
||||
checksum_mismatch: number;
|
||||
missing_file: number;
|
||||
orphan_file: number;
|
||||
};
|
||||
export type SetMaintenanceModeDto = {
|
||||
action: MaintenanceAction;
|
||||
};
|
||||
@@ -730,6 +749,7 @@ export type QueuesResponseLegacyDto = {
|
||||
duplicateDetection: QueueResponseLegacyDto;
|
||||
faceDetection: QueueResponseLegacyDto;
|
||||
facialRecognition: QueueResponseLegacyDto;
|
||||
integrityCheck: QueueResponseLegacyDto;
|
||||
library: QueueResponseLegacyDto;
|
||||
metadataExtraction: QueueResponseLegacyDto;
|
||||
migration: QueueResponseLegacyDto;
|
||||
@@ -1454,12 +1474,28 @@ export type SystemConfigImageDto = {
|
||||
preview: SystemConfigGeneratedImageDto;
|
||||
thumbnail: SystemConfigGeneratedImageDto;
|
||||
};
|
||||
export type SystemConfigIntegrityChecksumJob = {
|
||||
cronExpression: string;
|
||||
enabled: boolean;
|
||||
percentageLimit: number;
|
||||
timeLimit: number;
|
||||
};
|
||||
export type SystemConfigIntegrityJob = {
|
||||
cronExpression: string;
|
||||
enabled: boolean;
|
||||
};
|
||||
export type SystemConfigIntegrityChecks = {
|
||||
checksumFiles: SystemConfigIntegrityChecksumJob;
|
||||
missingFiles: SystemConfigIntegrityJob;
|
||||
orphanedFiles: SystemConfigIntegrityJob;
|
||||
};
|
||||
export type JobSettingsDto = {
|
||||
concurrency: number;
|
||||
};
|
||||
export type SystemConfigJobDto = {
|
||||
backgroundTask: JobSettingsDto;
|
||||
faceDetection: JobSettingsDto;
|
||||
integrityCheck: JobSettingsDto;
|
||||
library: JobSettingsDto;
|
||||
metadataExtraction: JobSettingsDto;
|
||||
migration: JobSettingsDto;
|
||||
@@ -1606,6 +1642,7 @@ export type SystemConfigDto = {
|
||||
backup: SystemConfigBackupsDto;
|
||||
ffmpeg: SystemConfigFFmpegDto;
|
||||
image: SystemConfigImageDto;
|
||||
integrityChecks: SystemConfigIntegrityChecks;
|
||||
job: SystemConfigJobDto;
|
||||
library: SystemConfigLibraryDto;
|
||||
logging: SystemConfigLoggingDto;
|
||||
@@ -1850,6 +1887,69 @@ export function unlinkAllOAuthAccountsAdmin(opts?: Oazapfts.RequestOpts) {
|
||||
method: "POST"
|
||||
}));
|
||||
}
|
||||
/**
|
||||
* Get integrity report by type
|
||||
*/
|
||||
export function getIntegrityReport({ integrityGetReportDto }: {
|
||||
integrityGetReportDto: IntegrityGetReportDto;
|
||||
}, opts?: Oazapfts.RequestOpts) {
|
||||
return oazapfts.ok(oazapfts.fetchJson<{
|
||||
status: 200;
|
||||
data: IntegrityReportResponseDto;
|
||||
}>("/admin/integrity/report", oazapfts.json({
|
||||
...opts,
|
||||
method: "POST",
|
||||
body: integrityGetReportDto
|
||||
})));
|
||||
}
|
||||
/**
|
||||
* Delete integrity report item
|
||||
*/
|
||||
export function deleteIntegrityReport({ id }: {
|
||||
id: string;
|
||||
}, opts?: Oazapfts.RequestOpts) {
|
||||
return oazapfts.ok(oazapfts.fetchText(`/admin/integrity/report/${encodeURIComponent(id)}`, {
|
||||
...opts,
|
||||
method: "DELETE"
|
||||
}));
|
||||
}
|
||||
/**
|
||||
* Download flagged file file
|
||||
*/
|
||||
export function getIntegrityReportFile({ id }: {
|
||||
id: string;
|
||||
}, opts?: Oazapfts.RequestOpts) {
|
||||
return oazapfts.ok(oazapfts.fetchBlob<{
|
||||
status: 200;
|
||||
data: Blob;
|
||||
}>(`/admin/integrity/report/${encodeURIComponent(id)}/file`, {
|
||||
...opts
|
||||
}));
|
||||
}
|
||||
/**
|
||||
* Export integrity report by type as CSV
|
||||
*/
|
||||
export function getIntegrityReportCsv({ $type }: {
|
||||
$type: IntegrityReportType;
|
||||
}, opts?: Oazapfts.RequestOpts) {
|
||||
return oazapfts.ok(oazapfts.fetchBlob<{
|
||||
status: 200;
|
||||
data: Blob;
|
||||
}>(`/admin/integrity/report/${encodeURIComponent($type)}/csv`, {
|
||||
...opts
|
||||
}));
|
||||
}
|
||||
/**
|
||||
* Get integrity report summary
|
||||
*/
|
||||
export function getIntegrityReportSummary(opts?: Oazapfts.RequestOpts) {
|
||||
return oazapfts.ok(oazapfts.fetchJson<{
|
||||
status: 200;
|
||||
data: IntegrityReportSummaryResponseDto;
|
||||
}>("/admin/integrity/summary", {
|
||||
...opts
|
||||
}));
|
||||
}
|
||||
/**
|
||||
* Set maintenance mode
|
||||
*/
|
||||
@@ -5138,6 +5238,11 @@ export enum UserAvatarColor {
|
||||
Gray = "gray",
|
||||
Amber = "amber"
|
||||
}
|
||||
export enum IntegrityReportType {
|
||||
OrphanFile = "orphan_file",
|
||||
MissingFile = "missing_file",
|
||||
ChecksumMismatch = "checksum_mismatch"
|
||||
}
|
||||
export enum MaintenanceAction {
|
||||
Start = "start",
|
||||
End = "end"
|
||||
@@ -5378,7 +5483,16 @@ export enum ManualJobName {
|
||||
UserCleanup = "user-cleanup",
|
||||
MemoryCleanup = "memory-cleanup",
|
||||
MemoryCreate = "memory-create",
|
||||
BackupDatabase = "backup-database"
|
||||
BackupDatabase = "backup-database",
|
||||
IntegrityMissingFiles = "integrity-missing-files",
|
||||
IntegrityOrphanFiles = "integrity-orphan-files",
|
||||
IntegrityChecksumMismatch = "integrity-checksum-mismatch",
|
||||
IntegrityMissingFilesRefresh = "integrity-missing-files-refresh",
|
||||
IntegrityOrphanFilesRefresh = "integrity-orphan-files-refresh",
|
||||
IntegrityChecksumMismatchRefresh = "integrity-checksum-mismatch-refresh",
|
||||
IntegrityMissingFilesDeleteAll = "integrity-missing-files-delete-all",
|
||||
IntegrityOrphanFilesDeleteAll = "integrity-orphan-files-delete-all",
|
||||
IntegrityChecksumMismatchDeleteAll = "integrity-checksum-mismatch-delete-all"
|
||||
}
|
||||
export enum QueueName {
|
||||
ThumbnailGeneration = "thumbnailGeneration",
|
||||
@@ -5397,7 +5511,8 @@ export enum QueueName {
|
||||
Notifications = "notifications",
|
||||
BackupDatabase = "backupDatabase",
|
||||
Ocr = "ocr",
|
||||
Workflow = "workflow"
|
||||
Workflow = "workflow",
|
||||
IntegrityCheck = "integrityCheck"
|
||||
}
|
||||
export enum QueueCommand {
|
||||
Start = "start",
|
||||
@@ -5486,7 +5601,17 @@ export enum JobName {
|
||||
VersionCheck = "VersionCheck",
|
||||
OcrQueueAll = "OcrQueueAll",
|
||||
Ocr = "Ocr",
|
||||
WorkflowRun = "WorkflowRun"
|
||||
WorkflowRun = "WorkflowRun",
|
||||
IntegrityOrphanedFilesQueueAll = "IntegrityOrphanedFilesQueueAll",
|
||||
IntegrityOrphanedFiles = "IntegrityOrphanedFiles",
|
||||
IntegrityOrphanedRefresh = "IntegrityOrphanedRefresh",
|
||||
IntegrityMissingFilesQueueAll = "IntegrityMissingFilesQueueAll",
|
||||
IntegrityMissingFiles = "IntegrityMissingFiles",
|
||||
IntegrityMissingFilesRefresh = "IntegrityMissingFilesRefresh",
|
||||
IntegrityChecksumFiles = "IntegrityChecksumFiles",
|
||||
IntegrityChecksumFilesRefresh = "IntegrityChecksumFilesRefresh",
|
||||
IntegrityDeleteReportType = "IntegrityDeleteReportType",
|
||||
IntegrityDeleteReports = "IntegrityDeleteReports"
|
||||
}
|
||||
export enum SearchSuggestionType {
|
||||
Country = "country",
|
||||
|
||||
@@ -46,6 +46,22 @@ export interface SystemConfig {
|
||||
accelDecode: boolean;
|
||||
tonemap: ToneMapping;
|
||||
};
|
||||
integrityChecks: {
|
||||
missingFiles: {
|
||||
enabled: boolean;
|
||||
cronExpression: string;
|
||||
};
|
||||
orphanedFiles: {
|
||||
enabled: boolean;
|
||||
cronExpression: string;
|
||||
};
|
||||
checksumFiles: {
|
||||
enabled: boolean;
|
||||
cronExpression: string;
|
||||
timeLimit: number;
|
||||
percentageLimit: number;
|
||||
};
|
||||
};
|
||||
job: Record<ConcurrentQueueName, { concurrency: number }>;
|
||||
logging: {
|
||||
enabled: boolean;
|
||||
@@ -222,6 +238,22 @@ export const defaults = Object.freeze<SystemConfig>({
|
||||
accel: TranscodeHardwareAcceleration.Disabled,
|
||||
accelDecode: false,
|
||||
},
|
||||
integrityChecks: {
|
||||
missingFiles: {
|
||||
enabled: true,
|
||||
cronExpression: CronExpression.EVERY_DAY_AT_3AM,
|
||||
},
|
||||
orphanedFiles: {
|
||||
enabled: true,
|
||||
cronExpression: CronExpression.EVERY_DAY_AT_3AM,
|
||||
},
|
||||
checksumFiles: {
|
||||
enabled: true,
|
||||
cronExpression: CronExpression.EVERY_DAY_AT_3AM,
|
||||
timeLimit: 60 * 60 * 1000, // 1 hour
|
||||
percentageLimit: 1, // 100% of assets
|
||||
},
|
||||
},
|
||||
job: {
|
||||
[QueueName.BackgroundTask]: { concurrency: 5 },
|
||||
[QueueName.SmartSearch]: { concurrency: 2 },
|
||||
@@ -236,6 +268,7 @@ export const defaults = Object.freeze<SystemConfig>({
|
||||
[QueueName.Notification]: { concurrency: 5 },
|
||||
[QueueName.Ocr]: { concurrency: 1 },
|
||||
[QueueName.Workflow]: { concurrency: 5 },
|
||||
[QueueName.IntegrityCheck]: { concurrency: 1 },
|
||||
},
|
||||
logging: {
|
||||
enabled: true,
|
||||
|
||||
@@ -146,6 +146,7 @@ export const endpointTags: Record<ApiTag, string> = {
|
||||
[ApiTag.Duplicates]: 'Endpoints for managing and identifying duplicate assets.',
|
||||
[ApiTag.Faces]:
|
||||
'A face is a detected human face within an asset, which can be associated with a person. Faces are normally detected via machine learning, but can also be created via manually.',
|
||||
[ApiTag.Integrity]: 'Endpoints for viewing and managing integrity reports.',
|
||||
[ApiTag.Jobs]:
|
||||
'Queues and background jobs are used for processing tasks asynchronously. Queues can be paused and resumed as needed.',
|
||||
[ApiTag.Libraries]:
|
||||
|
||||
@@ -9,6 +9,7 @@ import { AuthController } from 'src/controllers/auth.controller';
|
||||
import { DownloadController } from 'src/controllers/download.controller';
|
||||
import { DuplicateController } from 'src/controllers/duplicate.controller';
|
||||
import { FaceController } from 'src/controllers/face.controller';
|
||||
import { IntegrityController } from 'src/controllers/integrity.controller';
|
||||
import { JobController } from 'src/controllers/job.controller';
|
||||
import { LibraryController } from 'src/controllers/library.controller';
|
||||
import { MaintenanceController } from 'src/controllers/maintenance.controller';
|
||||
@@ -49,6 +50,7 @@ export const controllers = [
|
||||
DownloadController,
|
||||
DuplicateController,
|
||||
FaceController,
|
||||
IntegrityController,
|
||||
JobController,
|
||||
LibraryController,
|
||||
MaintenanceController,
|
||||
|
||||
91
server/src/controllers/integrity.controller.ts
Normal file
91
server/src/controllers/integrity.controller.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Next, Param, Post, Res } from '@nestjs/common';
|
||||
import { ApiTags } from '@nestjs/swagger';
|
||||
import { NextFunction, Response } from 'express';
|
||||
import { Endpoint, HistoryBuilder } from 'src/decorators';
|
||||
import { AuthDto } from 'src/dtos/auth.dto';
|
||||
import {
|
||||
IntegrityGetReportDto,
|
||||
IntegrityReportResponseDto,
|
||||
IntegrityReportSummaryResponseDto,
|
||||
} from 'src/dtos/integrity.dto';
|
||||
import { ApiTag, Permission } from 'src/enum';
|
||||
import { Auth, Authenticated, FileResponse } from 'src/middleware/auth.guard';
|
||||
import { LoggingRepository } from 'src/repositories/logging.repository';
|
||||
import { IntegrityService } from 'src/services/integrity.service';
|
||||
import { sendFile } from 'src/utils/file';
|
||||
import { IntegrityReportTypeParamDto, UUIDParamDto } from 'src/validation';
|
||||
|
||||
@ApiTags(ApiTag.Maintenance)
|
||||
@Controller('admin/integrity')
|
||||
export class IntegrityController {
|
||||
constructor(
|
||||
private logger: LoggingRepository,
|
||||
private service: IntegrityService,
|
||||
) {}
|
||||
|
||||
@Get('summary')
|
||||
@Endpoint({
|
||||
summary: 'Get integrity report summary',
|
||||
description: 'Get a count of the items flagged in each integrity report',
|
||||
history: new HistoryBuilder().added('v9.9.9').alpha('v9.9.9'),
|
||||
})
|
||||
@Authenticated({ permission: Permission.Maintenance, admin: true })
|
||||
getIntegrityReportSummary(): Promise<IntegrityReportSummaryResponseDto> {
|
||||
return this.service.getIntegrityReportSummary();
|
||||
}
|
||||
|
||||
@Post('report')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Endpoint({
|
||||
summary: 'Get integrity report by type',
|
||||
description: 'Get all flagged items by integrity report type',
|
||||
history: new HistoryBuilder().added('v9.9.9').alpha('v9.9.9'),
|
||||
})
|
||||
@Authenticated({ permission: Permission.Maintenance, admin: true })
|
||||
getIntegrityReport(@Body() dto: IntegrityGetReportDto): Promise<IntegrityReportResponseDto> {
|
||||
return this.service.getIntegrityReport(dto);
|
||||
}
|
||||
|
||||
@Delete('report/:id')
|
||||
@Endpoint({
|
||||
summary: 'Delete integrity report item',
|
||||
description: 'Delete a given report item and perform corresponding deletion (e.g. trash asset, delete file)',
|
||||
history: new HistoryBuilder().added('v9.9.9').alpha('v9.9.9'),
|
||||
})
|
||||
@Authenticated({ permission: Permission.Maintenance, admin: true })
|
||||
async deleteIntegrityReport(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise<void> {
|
||||
await this.service.deleteIntegrityReport(auth, id);
|
||||
}
|
||||
|
||||
@Get('report/:type/csv')
|
||||
@Endpoint({
|
||||
summary: 'Export integrity report by type as CSV',
|
||||
description: 'Get all integrity report entries for a given type as a CSV',
|
||||
history: new HistoryBuilder().added('v9.9.9').alpha('v9.9.9'),
|
||||
})
|
||||
@FileResponse()
|
||||
@Authenticated({ permission: Permission.Maintenance, admin: true })
|
||||
getIntegrityReportCsv(@Param() { type }: IntegrityReportTypeParamDto, @Res() res: Response): void {
|
||||
res.setHeader('Content-Type', 'text/csv');
|
||||
res.setHeader('Cache-Control', 'private, no-cache, no-transform');
|
||||
res.setHeader('Content-Disposition', `attachment; filename="${encodeURIComponent(`${Date.now()}-${type}.csv`)}"`);
|
||||
|
||||
this.service.getIntegrityReportCsv(type).pipe(res);
|
||||
}
|
||||
|
||||
@Get('report/:id/file')
|
||||
@Endpoint({
|
||||
summary: 'Download flagged file file',
|
||||
description: 'Download the orphan/broken file if one exists',
|
||||
history: new HistoryBuilder().added('v9.9.9').alpha('v9.9.9'),
|
||||
})
|
||||
@FileResponse()
|
||||
@Authenticated({ permission: Permission.Maintenance, admin: true })
|
||||
async getIntegrityReportFile(
|
||||
@Param() { id }: UUIDParamDto,
|
||||
@Res() res: Response,
|
||||
@Next() next: NextFunction,
|
||||
): Promise<void> {
|
||||
await sendFile(res, next, () => this.service.getIntegrityReportFile(id), this.logger);
|
||||
}
|
||||
}
|
||||
48
server/src/dtos/integrity.dto.ts
Normal file
48
server/src/dtos/integrity.dto.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { Type } from 'class-transformer';
|
||||
import { IsInt, IsOptional, Min } from 'class-validator';
|
||||
import { IntegrityReportType } from 'src/enum';
|
||||
import { ValidateEnum } from 'src/validation';
|
||||
|
||||
export class IntegrityReportSummaryResponseDto {
|
||||
@ApiProperty({ type: 'integer' })
|
||||
[IntegrityReportType.ChecksumFail]!: number;
|
||||
@ApiProperty({ type: 'integer' })
|
||||
[IntegrityReportType.MissingFile]!: number;
|
||||
@ApiProperty({ type: 'integer' })
|
||||
[IntegrityReportType.OrphanFile]!: number;
|
||||
}
|
||||
|
||||
export class IntegrityGetReportDto {
|
||||
@ValidateEnum({ enum: IntegrityReportType, name: 'IntegrityReportType' })
|
||||
type!: IntegrityReportType;
|
||||
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
page?: number;
|
||||
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
size?: number;
|
||||
}
|
||||
|
||||
export class IntegrityDeleteReportDto {
|
||||
@ValidateEnum({ enum: IntegrityReportType, name: 'IntegrityReportType' })
|
||||
type!: IntegrityReportType;
|
||||
}
|
||||
|
||||
class IntegrityReportDto {
|
||||
id!: string;
|
||||
@ValidateEnum({ enum: IntegrityReportType, name: 'IntegrityReportType' })
|
||||
type!: IntegrityReportType;
|
||||
path!: string;
|
||||
}
|
||||
|
||||
export class IntegrityReportResponseDto {
|
||||
items!: IntegrityReportDto[];
|
||||
hasNextPage!: boolean;
|
||||
}
|
||||
@@ -66,6 +66,9 @@ export class QueuesResponseLegacyDto implements Record<QueueName, QueueResponseL
|
||||
|
||||
@ApiProperty({ type: QueueResponseLegacyDto })
|
||||
[QueueName.Workflow]!: QueueResponseLegacyDto;
|
||||
|
||||
@ApiProperty({ type: QueueResponseLegacyDto })
|
||||
[QueueName.IntegrityCheck]!: QueueResponseLegacyDto;
|
||||
}
|
||||
|
||||
export const mapQueueLegacy = (response: QueueResponseDto): QueueResponseLegacyDto => {
|
||||
|
||||
@@ -38,6 +38,7 @@ const isOAuthEnabled = (config: SystemConfigOAuthDto) => config.enabled;
|
||||
const isOAuthOverrideEnabled = (config: SystemConfigOAuthDto) => config.mobileOverrideEnabled;
|
||||
const isEmailNotificationEnabled = (config: SystemConfigSmtpDto) => config.enabled;
|
||||
const isDatabaseBackupEnabled = (config: DatabaseBackupConfig) => config.enabled;
|
||||
const isEnabledProperty = (config: { enabled: boolean }) => config.enabled;
|
||||
|
||||
export class DatabaseBackupConfig {
|
||||
@ValidateBoolean()
|
||||
@@ -145,6 +146,42 @@ export class SystemConfigFFmpegDto {
|
||||
tonemap!: ToneMapping;
|
||||
}
|
||||
|
||||
class SystemConfigIntegrityJob {
|
||||
@ValidateBoolean()
|
||||
enabled!: boolean;
|
||||
|
||||
@ValidateIf(isEnabledProperty)
|
||||
@IsNotEmpty()
|
||||
@IsCronExpression()
|
||||
@IsString()
|
||||
cronExpression!: string;
|
||||
}
|
||||
|
||||
class SystemConfigIntegrityChecksumJob extends SystemConfigIntegrityJob {
|
||||
@IsInt()
|
||||
timeLimit!: number;
|
||||
|
||||
@IsNumber()
|
||||
percentageLimit!: number;
|
||||
}
|
||||
|
||||
class SystemConfigIntegrityChecks {
|
||||
@Type(() => SystemConfigIntegrityJob)
|
||||
@ValidateNested()
|
||||
@IsObject()
|
||||
missingFiles!: SystemConfigIntegrityJob;
|
||||
|
||||
@Type(() => SystemConfigIntegrityJob)
|
||||
@ValidateNested()
|
||||
@IsObject()
|
||||
orphanedFiles!: SystemConfigIntegrityJob;
|
||||
|
||||
@Type(() => SystemConfigIntegrityChecksumJob)
|
||||
@ValidateNested()
|
||||
@IsObject()
|
||||
checksumFiles!: SystemConfigIntegrityChecksumJob;
|
||||
}
|
||||
|
||||
class JobSettingsDto {
|
||||
@IsInt()
|
||||
@IsPositive()
|
||||
@@ -230,6 +267,12 @@ class SystemConfigJobDto implements Record<ConcurrentQueueName, JobSettingsDto>
|
||||
@IsObject()
|
||||
@Type(() => JobSettingsDto)
|
||||
[QueueName.Workflow]!: JobSettingsDto;
|
||||
|
||||
@ApiProperty({ type: JobSettingsDto })
|
||||
@ValidateNested()
|
||||
@IsObject()
|
||||
@Type(() => JobSettingsDto)
|
||||
[QueueName.IntegrityCheck]!: JobSettingsDto;
|
||||
}
|
||||
|
||||
class SystemConfigLibraryScanDto {
|
||||
@@ -649,6 +692,11 @@ export class SystemConfigDto implements SystemConfig {
|
||||
@IsObject()
|
||||
ffmpeg!: SystemConfigFFmpegDto;
|
||||
|
||||
@Type(() => SystemConfigIntegrityChecks)
|
||||
@ValidateNested()
|
||||
@IsObject()
|
||||
integrityChecks!: SystemConfigIntegrityChecks;
|
||||
|
||||
@Type(() => SystemConfigLoggingDto)
|
||||
@ValidateNested()
|
||||
@IsObject()
|
||||
|
||||
@@ -302,6 +302,7 @@ export enum SystemMetadataKey {
|
||||
SystemFlags = 'system-flags',
|
||||
VersionCheckState = 'version-check-state',
|
||||
License = 'license',
|
||||
IntegrityChecksumCheckpoint = 'integrity-checksum-checkpoint',
|
||||
}
|
||||
|
||||
export enum UserMetadataKey {
|
||||
@@ -345,6 +346,12 @@ export enum SourceType {
|
||||
Manual = 'manual',
|
||||
}
|
||||
|
||||
export enum IntegrityReportType {
|
||||
OrphanFile = 'orphan_file',
|
||||
MissingFile = 'missing_file',
|
||||
ChecksumFail = 'checksum_mismatch',
|
||||
}
|
||||
|
||||
export enum ManualJobName {
|
||||
PersonCleanup = 'person-cleanup',
|
||||
TagCleanup = 'tag-cleanup',
|
||||
@@ -352,6 +359,15 @@ export enum ManualJobName {
|
||||
MemoryCleanup = 'memory-cleanup',
|
||||
MemoryCreate = 'memory-create',
|
||||
BackupDatabase = 'backup-database',
|
||||
IntegrityMissingFiles = `integrity-missing-files`,
|
||||
IntegrityOrphanFiles = `integrity-orphan-files`,
|
||||
IntegrityChecksumFiles = `integrity-checksum-mismatch`,
|
||||
IntegrityMissingFilesRefresh = `integrity-missing-files-refresh`,
|
||||
IntegrityOrphanFilesRefresh = `integrity-orphan-files-refresh`,
|
||||
IntegrityChecksumFilesRefresh = `integrity-checksum-mismatch-refresh`,
|
||||
IntegrityMissingFilesDeleteAll = `integrity-missing-files-delete-all`,
|
||||
IntegrityOrphanFilesDeleteAll = `integrity-orphan-files-delete-all`,
|
||||
IntegrityChecksumFilesDeleteAll = `integrity-checksum-mismatch-delete-all`,
|
||||
}
|
||||
|
||||
export enum AssetPathType {
|
||||
@@ -550,6 +566,7 @@ export enum QueueName {
|
||||
BackupDatabase = 'backupDatabase',
|
||||
Ocr = 'ocr',
|
||||
Workflow = 'workflow',
|
||||
IntegrityCheck = 'integrityCheck',
|
||||
}
|
||||
|
||||
export enum QueueJobStatus {
|
||||
@@ -638,6 +655,18 @@ export enum JobName {
|
||||
|
||||
// Workflow
|
||||
WorkflowRun = 'WorkflowRun',
|
||||
|
||||
// Integrity
|
||||
IntegrityOrphanedFilesQueueAll = 'IntegrityOrphanedFilesQueueAll',
|
||||
IntegrityOrphanedFiles = 'IntegrityOrphanedFiles',
|
||||
IntegrityOrphanedFilesRefresh = 'IntegrityOrphanedRefresh',
|
||||
IntegrityMissingFilesQueueAll = 'IntegrityMissingFilesQueueAll',
|
||||
IntegrityMissingFiles = 'IntegrityMissingFiles',
|
||||
IntegrityMissingFilesRefresh = 'IntegrityMissingFilesRefresh',
|
||||
IntegrityChecksumFiles = 'IntegrityChecksumFiles',
|
||||
IntegrityChecksumFilesRefresh = 'IntegrityChecksumFilesRefresh',
|
||||
IntegrityDeleteReportType = 'IntegrityDeleteReportType',
|
||||
IntegrityDeleteReports = 'IntegrityDeleteReports',
|
||||
}
|
||||
|
||||
export enum QueueCommand {
|
||||
@@ -680,6 +709,7 @@ export enum DatabaseLock {
|
||||
GetSystemConfig = 69,
|
||||
BackupDatabase = 42,
|
||||
MemoryCreation = 777,
|
||||
IntegrityCheck = 67,
|
||||
}
|
||||
|
||||
export enum MaintenanceAction {
|
||||
@@ -835,6 +865,7 @@ export enum ApiTag {
|
||||
Download = 'Download',
|
||||
Duplicates = 'Duplicates',
|
||||
Faces = 'Faces',
|
||||
Integrity = 'Integrity (admin)',
|
||||
Jobs = 'Jobs',
|
||||
Libraries = 'Libraries',
|
||||
Maintenance = 'Maintenance (admin)',
|
||||
|
||||
186
server/src/queries/integrity.repository.sql
Normal file
186
server/src/queries/integrity.repository.sql
Normal file
@@ -0,0 +1,186 @@
|
||||
-- NOTE: This file is auto generated by ./sql-generator
|
||||
|
||||
-- IntegrityRepository.getById
|
||||
select
|
||||
"integrity_report".*
|
||||
from
|
||||
"integrity_report"
|
||||
where
|
||||
"id" = $1
|
||||
|
||||
-- IntegrityRepository.getIntegrityReportSummary
|
||||
select
|
||||
count(*) filter (
|
||||
where
|
||||
"type" = $1
|
||||
) as "checksum_mismatch",
|
||||
count(*) filter (
|
||||
where
|
||||
"type" = $2
|
||||
) as "missing_file",
|
||||
count(*) filter (
|
||||
where
|
||||
"type" = $3
|
||||
) as "orphan_file"
|
||||
from
|
||||
"integrity_report"
|
||||
|
||||
-- IntegrityRepository.getIntegrityReports
|
||||
select
|
||||
"id",
|
||||
"type",
|
||||
"path",
|
||||
"assetId",
|
||||
"fileAssetId"
|
||||
from
|
||||
"integrity_report"
|
||||
where
|
||||
"type" = $1
|
||||
order by
|
||||
"createdAt" desc
|
||||
limit
|
||||
$2
|
||||
offset
|
||||
$3
|
||||
|
||||
-- IntegrityRepository.getAssetPathsByPaths
|
||||
select
|
||||
"originalPath",
|
||||
"encodedVideoPath"
|
||||
from
|
||||
"asset"
|
||||
where
|
||||
(
|
||||
"originalPath" in $1
|
||||
or "encodedVideoPath" in $2
|
||||
)
|
||||
|
||||
-- IntegrityRepository.getAssetFilePathsByPaths
|
||||
select
|
||||
"path"
|
||||
from
|
||||
"asset_file"
|
||||
where
|
||||
"path" in $1
|
||||
|
||||
-- IntegrityRepository.getAssetCount
|
||||
select
|
||||
count(*) as "count"
|
||||
from
|
||||
"asset"
|
||||
|
||||
-- IntegrityRepository.streamAllAssetPaths
|
||||
select
|
||||
"originalPath",
|
||||
"encodedVideoPath"
|
||||
from
|
||||
"asset"
|
||||
|
||||
-- IntegrityRepository.streamAllAssetFilePaths
|
||||
select
|
||||
"path"
|
||||
from
|
||||
"asset_file"
|
||||
|
||||
-- IntegrityRepository.streamAssetPaths
|
||||
select
|
||||
"allPaths"."path" as "path",
|
||||
"allPaths"."assetId",
|
||||
"allPaths"."fileAssetId",
|
||||
"integrity_report"."id" as "reportId"
|
||||
from
|
||||
(
|
||||
select
|
||||
"asset"."originalPath" as "path",
|
||||
"asset"."id" as "assetId",
|
||||
null::uuid as "fileAssetId"
|
||||
from
|
||||
"asset"
|
||||
where
|
||||
"asset"."deletedAt" is null
|
||||
union all
|
||||
select
|
||||
"asset"."encodedVideoPath" as "path",
|
||||
"asset"."id" as "assetId",
|
||||
null::uuid as "fileAssetId"
|
||||
from
|
||||
"asset"
|
||||
where
|
||||
"asset"."deletedAt" is null
|
||||
and "asset"."encodedVideoPath" is not null
|
||||
and "asset"."encodedVideoPath" != ''
|
||||
union all
|
||||
select
|
||||
"path",
|
||||
null::uuid as "assetId",
|
||||
"asset_file"."id" as "fileAssetId"
|
||||
from
|
||||
"asset_file"
|
||||
) as "allPaths"
|
||||
left join "integrity_report" on "integrity_report"."type" = $1
|
||||
and (
|
||||
"integrity_report"."assetId" = "allPaths"."assetId"
|
||||
or "integrity_report"."fileAssetId" = "allPaths"."fileAssetId"
|
||||
)
|
||||
|
||||
-- IntegrityRepository.streamAssetChecksums
|
||||
select
|
||||
"asset"."originalPath",
|
||||
"asset"."checksum",
|
||||
"asset"."createdAt",
|
||||
"asset"."id" as "assetId",
|
||||
"integrity_report"."id" as "reportId"
|
||||
from
|
||||
"asset"
|
||||
left join "integrity_report" on "integrity_report"."assetId" = "asset"."id"
|
||||
and "integrity_report"."type" = $1
|
||||
where
|
||||
"asset"."deletedAt" is null
|
||||
and "createdAt" >= $2
|
||||
and "createdAt" <= $3
|
||||
order by
|
||||
"createdAt" asc
|
||||
|
||||
-- IntegrityRepository.streamIntegrityReports
|
||||
select
|
||||
"id",
|
||||
"type",
|
||||
"path",
|
||||
"assetId",
|
||||
"fileAssetId"
|
||||
from
|
||||
"integrity_report"
|
||||
where
|
||||
"type" = $1
|
||||
order by
|
||||
"createdAt" desc
|
||||
|
||||
-- IntegrityRepository.streamIntegrityReportsWithAssetChecksum
|
||||
select
|
||||
"integrity_report"."id" as "reportId",
|
||||
"integrity_report"."path"
|
||||
from
|
||||
"integrity_report"
|
||||
where
|
||||
"integrity_report"."type" = $1
|
||||
|
||||
-- IntegrityRepository.streamIntegrityReportsByProperty
|
||||
select
|
||||
"id",
|
||||
"path",
|
||||
"assetId",
|
||||
"fileAssetId"
|
||||
from
|
||||
"integrity_report"
|
||||
where
|
||||
"abcdefghi" is not null
|
||||
|
||||
-- IntegrityRepository.deleteById
|
||||
delete from "integrity_report"
|
||||
where
|
||||
"id" = $1
|
||||
|
||||
-- IntegrityRepository.deleteByIds
|
||||
delete from "integrity_report"
|
||||
where
|
||||
"id" in $1
|
||||
@@ -15,6 +15,7 @@ import { DownloadRepository } from 'src/repositories/download.repository';
|
||||
import { DuplicateRepository } from 'src/repositories/duplicate.repository';
|
||||
import { EmailRepository } from 'src/repositories/email.repository';
|
||||
import { EventRepository } from 'src/repositories/event.repository';
|
||||
import { IntegrityRepository } from 'src/repositories/integrity.repository';
|
||||
import { JobRepository } from 'src/repositories/job.repository';
|
||||
import { LibraryRepository } from 'src/repositories/library.repository';
|
||||
import { LoggingRepository } from 'src/repositories/logging.repository';
|
||||
@@ -68,6 +69,7 @@ export const repositories = [
|
||||
DuplicateRepository,
|
||||
EmailRepository,
|
||||
EventRepository,
|
||||
IntegrityRepository,
|
||||
JobRepository,
|
||||
LibraryRepository,
|
||||
LoggingRepository,
|
||||
|
||||
231
server/src/repositories/integrity.repository.ts
Normal file
231
server/src/repositories/integrity.repository.ts
Normal file
@@ -0,0 +1,231 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Insertable, Kysely, sql } from 'kysely';
|
||||
import { InjectKysely } from 'nestjs-kysely';
|
||||
import { DummyValue, GenerateSql } from 'src/decorators';
|
||||
import { IntegrityReportType } from 'src/enum';
|
||||
import { DB } from 'src/schema';
|
||||
import { IntegrityReportTable } from 'src/schema/tables/integrity-report.table';
|
||||
import { paginationHelper } from 'src/utils/pagination';
|
||||
|
||||
export interface ReportPaginationOptions {
|
||||
page: number;
|
||||
size: number;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class IntegrityRepository {
|
||||
constructor(@InjectKysely() private db: Kysely<DB>) {}
|
||||
|
||||
create(dto: Insertable<IntegrityReportTable> | Insertable<IntegrityReportTable>[]) {
|
||||
return this.db
|
||||
.insertInto('integrity_report')
|
||||
.values(dto)
|
||||
.onConflict((oc) =>
|
||||
oc.columns(['path', 'type']).doUpdateSet({
|
||||
assetId: (eb) => eb.ref('excluded.assetId'),
|
||||
fileAssetId: (eb) => eb.ref('excluded.fileAssetId'),
|
||||
}),
|
||||
)
|
||||
.returningAll()
|
||||
.executeTakeFirst();
|
||||
}
|
||||
|
||||
@GenerateSql({ params: [DummyValue.STRING] })
|
||||
getById(id: string) {
|
||||
return this.db
|
||||
.selectFrom('integrity_report')
|
||||
.selectAll('integrity_report')
|
||||
.where('id', '=', id)
|
||||
.executeTakeFirstOrThrow();
|
||||
}
|
||||
|
||||
@GenerateSql({ params: [] })
|
||||
getIntegrityReportSummary() {
|
||||
return this.db
|
||||
.selectFrom('integrity_report')
|
||||
.select((eb) =>
|
||||
eb.fn
|
||||
.countAll<number>()
|
||||
.filterWhere('type', '=', IntegrityReportType.ChecksumFail)
|
||||
.as(IntegrityReportType.ChecksumFail),
|
||||
)
|
||||
.select((eb) =>
|
||||
eb.fn
|
||||
.countAll<number>()
|
||||
.filterWhere('type', '=', IntegrityReportType.MissingFile)
|
||||
.as(IntegrityReportType.MissingFile),
|
||||
)
|
||||
.select((eb) =>
|
||||
eb.fn
|
||||
.countAll<number>()
|
||||
.filterWhere('type', '=', IntegrityReportType.OrphanFile)
|
||||
.as(IntegrityReportType.OrphanFile),
|
||||
)
|
||||
.executeTakeFirstOrThrow();
|
||||
}
|
||||
|
||||
@GenerateSql({ params: [{ page: 1, size: 100 }, DummyValue.STRING] })
|
||||
async getIntegrityReports(pagination: ReportPaginationOptions, type: IntegrityReportType) {
|
||||
const items = await this.db
|
||||
.selectFrom('integrity_report')
|
||||
.select(['id', 'type', 'path', 'assetId', 'fileAssetId'])
|
||||
.where('type', '=', type)
|
||||
.orderBy('createdAt', 'desc')
|
||||
.limit(pagination.size + 1)
|
||||
.offset((pagination.page - 1) * pagination.size)
|
||||
.execute();
|
||||
|
||||
return paginationHelper(items, pagination.size);
|
||||
}
|
||||
|
||||
@GenerateSql({ params: [DummyValue.STRING] })
|
||||
getAssetPathsByPaths(paths: string[]) {
|
||||
return this.db
|
||||
.selectFrom('asset')
|
||||
.select(['originalPath', 'encodedVideoPath'])
|
||||
.where((eb) => eb.or([eb('originalPath', 'in', paths), eb('encodedVideoPath', 'in', paths)]))
|
||||
.execute();
|
||||
}
|
||||
|
||||
@GenerateSql({ params: [DummyValue.STRING] })
|
||||
getAssetFilePathsByPaths(paths: string[]) {
|
||||
return this.db.selectFrom('asset_file').select(['path']).where('path', 'in', paths).execute();
|
||||
}
|
||||
|
||||
@GenerateSql({ params: [] })
|
||||
getAssetCount() {
|
||||
return this.db
|
||||
.selectFrom('asset')
|
||||
.select((eb) => eb.fn.countAll<number>().as('count'))
|
||||
.executeTakeFirstOrThrow();
|
||||
}
|
||||
|
||||
@GenerateSql({ params: [], stream: true })
|
||||
streamAllAssetPaths() {
|
||||
return this.db.selectFrom('asset').select(['originalPath', 'encodedVideoPath']).stream();
|
||||
}
|
||||
|
||||
@GenerateSql({ params: [], stream: true })
|
||||
streamAllAssetFilePaths() {
|
||||
return this.db.selectFrom('asset_file').select(['path']).stream();
|
||||
}
|
||||
|
||||
@GenerateSql({ params: [], stream: true })
|
||||
streamAssetPaths() {
|
||||
return this.db
|
||||
.selectFrom((eb) =>
|
||||
eb
|
||||
.selectFrom('asset')
|
||||
.where('asset.deletedAt', 'is', null)
|
||||
.select(['asset.originalPath as path'])
|
||||
.select((eb) => [
|
||||
eb.ref('asset.id').$castTo<string | null>().as('assetId'),
|
||||
sql<string | null>`null::uuid`.as('fileAssetId'),
|
||||
])
|
||||
.unionAll(
|
||||
eb
|
||||
.selectFrom('asset')
|
||||
.where('asset.deletedAt', 'is', null)
|
||||
.select((eb) => [
|
||||
eb.ref('asset.encodedVideoPath').$castTo<string>().as('path'),
|
||||
eb.ref('asset.id').$castTo<string | null>().as('assetId'),
|
||||
sql<string | null>`null::uuid`.as('fileAssetId'),
|
||||
])
|
||||
.where('asset.encodedVideoPath', 'is not', null)
|
||||
.where('asset.encodedVideoPath', '!=', sql<string>`''`),
|
||||
)
|
||||
.unionAll(
|
||||
eb
|
||||
.selectFrom('asset_file')
|
||||
.select(['path'])
|
||||
.select((eb) => [
|
||||
sql<string | null>`null::uuid`.as('assetId'),
|
||||
eb.ref('asset_file.id').$castTo<string | null>().as('fileAssetId'),
|
||||
]),
|
||||
)
|
||||
.as('allPaths'),
|
||||
)
|
||||
.leftJoin(
|
||||
'integrity_report',
|
||||
(join) =>
|
||||
join
|
||||
.on('integrity_report.type', '=', IntegrityReportType.OrphanFile)
|
||||
.on((eb) =>
|
||||
eb.or([
|
||||
eb('integrity_report.assetId', '=', eb.ref('allPaths.assetId')),
|
||||
eb('integrity_report.fileAssetId', '=', eb.ref('allPaths.fileAssetId')),
|
||||
]),
|
||||
),
|
||||
// .onRef('integrity_report.path', '=', 'allPaths.path')
|
||||
)
|
||||
.select(['allPaths.path as path', 'allPaths.assetId', 'allPaths.fileAssetId', 'integrity_report.id as reportId'])
|
||||
.stream();
|
||||
}
|
||||
|
||||
@GenerateSql({ params: [DummyValue.DATE, DummyValue.DATE], stream: true })
|
||||
streamAssetChecksums(startMarker?: Date, endMarker?: Date) {
|
||||
return this.db
|
||||
.selectFrom('asset')
|
||||
.where('asset.deletedAt', 'is', null)
|
||||
.leftJoin('integrity_report', (join) =>
|
||||
join
|
||||
.onRef('integrity_report.assetId', '=', 'asset.id')
|
||||
// .onRef('integrity_report.path', '=', 'asset.originalPath')
|
||||
.on('integrity_report.type', '=', IntegrityReportType.ChecksumFail),
|
||||
)
|
||||
.select([
|
||||
'asset.originalPath',
|
||||
'asset.checksum',
|
||||
'asset.createdAt',
|
||||
'asset.id as assetId',
|
||||
'integrity_report.id as reportId',
|
||||
])
|
||||
.$if(startMarker !== undefined, (qb) => qb.where('createdAt', '>=', startMarker!))
|
||||
.$if(endMarker !== undefined, (qb) => qb.where('createdAt', '<=', endMarker!))
|
||||
.orderBy('createdAt', 'asc')
|
||||
.stream();
|
||||
}
|
||||
|
||||
@GenerateSql({ params: [DummyValue.STRING], stream: true })
|
||||
streamIntegrityReports(type: IntegrityReportType) {
|
||||
return this.db
|
||||
.selectFrom('integrity_report')
|
||||
.select(['id', 'type', 'path', 'assetId', 'fileAssetId'])
|
||||
.where('type', '=', type)
|
||||
.orderBy('createdAt', 'desc')
|
||||
.stream();
|
||||
}
|
||||
|
||||
@GenerateSql({ params: [DummyValue.STRING], stream: true })
|
||||
streamIntegrityReportsWithAssetChecksum(type: IntegrityReportType) {
|
||||
return this.db
|
||||
.selectFrom('integrity_report')
|
||||
.select(['integrity_report.id as reportId', 'integrity_report.path'])
|
||||
.where('integrity_report.type', '=', type)
|
||||
.$if(type === IntegrityReportType.ChecksumFail, (eb) =>
|
||||
eb.leftJoin('asset', 'integrity_report.path', 'asset.originalPath').select('asset.checksum'),
|
||||
)
|
||||
.stream();
|
||||
}
|
||||
|
||||
@GenerateSql({ params: [DummyValue.STRING], stream: true })
|
||||
streamIntegrityReportsByProperty(property?: 'assetId' | 'fileAssetId', filterType?: IntegrityReportType) {
|
||||
return this.db
|
||||
.selectFrom('integrity_report')
|
||||
.select(['id', 'path', 'assetId', 'fileAssetId'])
|
||||
.$if(filterType !== undefined, (eb) => eb.where('type', '=', filterType!))
|
||||
.$if(property === undefined, (eb) => eb.where('assetId', 'is', null).where('fileAssetId', 'is', null))
|
||||
.$if(property !== undefined, (eb) => eb.where(property!, 'is not', null))
|
||||
.stream();
|
||||
}
|
||||
|
||||
@GenerateSql({ params: [DummyValue.STRING] })
|
||||
deleteById(id: string) {
|
||||
return this.db.deleteFrom('integrity_report').where('id', '=', id).execute();
|
||||
}
|
||||
|
||||
@GenerateSql({ params: [DummyValue.STRING] })
|
||||
deleteByIds(ids: string[]) {
|
||||
return this.db.deleteFrom('integrity_report').where('id', 'in', ids).execute();
|
||||
}
|
||||
}
|
||||
@@ -93,6 +93,10 @@ export class StorageRepository {
|
||||
return { stream: archive, addFile, finalize };
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
@@ -40,6 +40,7 @@ import { AssetTable } from 'src/schema/tables/asset.table';
|
||||
import { AuditTable } from 'src/schema/tables/audit.table';
|
||||
import { FaceSearchTable } from 'src/schema/tables/face-search.table';
|
||||
import { GeodataPlacesTable } from 'src/schema/tables/geodata-places.table';
|
||||
import { IntegrityReportTable } from 'src/schema/tables/integrity-report.table';
|
||||
import { LibraryTable } from 'src/schema/tables/library.table';
|
||||
import { MemoryAssetAuditTable } from 'src/schema/tables/memory-asset-audit.table';
|
||||
import { MemoryAssetTable } from 'src/schema/tables/memory-asset.table';
|
||||
@@ -98,6 +99,7 @@ export class ImmichDatabase {
|
||||
AssetExifTable,
|
||||
FaceSearchTable,
|
||||
GeodataPlacesTable,
|
||||
IntegrityReportTable,
|
||||
LibraryTable,
|
||||
MemoryTable,
|
||||
MemoryAuditTable,
|
||||
@@ -195,6 +197,8 @@ export interface DB {
|
||||
|
||||
geodata_places: GeodataPlacesTable;
|
||||
|
||||
integrity_report: IntegrityReportTable;
|
||||
|
||||
library: LibraryTable;
|
||||
|
||||
memory: MemoryTable;
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import { Kysely, sql } from 'kysely';
|
||||
|
||||
export async function up(db: Kysely<any>): Promise<void> {
|
||||
await sql`CREATE TABLE "integrity_report" (
|
||||
"id" uuid NOT NULL DEFAULT uuid_generate_v4(),
|
||||
"type" character varying NOT NULL,
|
||||
"path" character varying NOT NULL,
|
||||
"createdAt" timestamp with time zone NOT NULL DEFAULT now(),
|
||||
"assetId" uuid,
|
||||
"fileAssetId" uuid,
|
||||
CONSTRAINT "integrity_report_assetId_fkey" FOREIGN KEY ("assetId") REFERENCES "asset" ("id") ON UPDATE CASCADE ON DELETE CASCADE,
|
||||
CONSTRAINT "integrity_report_fileAssetId_fkey" FOREIGN KEY ("fileAssetId") REFERENCES "asset_file" ("id") ON UPDATE CASCADE ON DELETE CASCADE,
|
||||
CONSTRAINT "integrity_report_type_path_uq" UNIQUE ("type", "path"),
|
||||
CONSTRAINT "integrity_report_pkey" PRIMARY KEY ("id")
|
||||
);`.execute(db);
|
||||
await sql`CREATE INDEX "integrity_report_assetId_idx" ON "integrity_report" ("assetId");`.execute(db);
|
||||
await sql`CREATE INDEX "integrity_report_fileAssetId_idx" ON "integrity_report" ("fileAssetId");`.execute(db);
|
||||
}
|
||||
|
||||
export async function down(db: Kysely<any>): Promise<void> {
|
||||
await sql`DROP TABLE "integrity_report";`.execute(db);
|
||||
}
|
||||
35
server/src/schema/tables/integrity-report.table.ts
Normal file
35
server/src/schema/tables/integrity-report.table.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { IntegrityReportType } from 'src/enum';
|
||||
import { AssetFileTable } from 'src/schema/tables/asset-file.table';
|
||||
import { AssetTable } from 'src/schema/tables/asset.table';
|
||||
import {
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
ForeignKeyColumn,
|
||||
Generated,
|
||||
PrimaryGeneratedColumn,
|
||||
Table,
|
||||
Timestamp,
|
||||
Unique,
|
||||
} from 'src/sql-tools';
|
||||
|
||||
@Table('integrity_report')
|
||||
@Unique({ columns: ['type', 'path'] })
|
||||
export class IntegrityReportTable {
|
||||
@PrimaryGeneratedColumn()
|
||||
id!: Generated<string>;
|
||||
|
||||
@Column()
|
||||
type!: IntegrityReportType;
|
||||
|
||||
@Column()
|
||||
path!: string;
|
||||
|
||||
@CreateDateColumn()
|
||||
createdAt!: Generated<Timestamp>;
|
||||
|
||||
@ForeignKeyColumn(() => AssetTable, { onDelete: 'CASCADE', onUpdate: 'CASCADE', nullable: true })
|
||||
assetId!: string | null;
|
||||
|
||||
@ForeignKeyColumn(() => AssetFileTable, { onDelete: 'CASCADE', onUpdate: 'CASCADE', nullable: true })
|
||||
fileAssetId!: string | null;
|
||||
}
|
||||
@@ -22,6 +22,7 @@ import { DownloadRepository } from 'src/repositories/download.repository';
|
||||
import { DuplicateRepository } from 'src/repositories/duplicate.repository';
|
||||
import { EmailRepository } from 'src/repositories/email.repository';
|
||||
import { EventRepository } from 'src/repositories/event.repository';
|
||||
import { IntegrityRepository } from 'src/repositories/integrity.repository';
|
||||
import { JobRepository } from 'src/repositories/job.repository';
|
||||
import { LibraryRepository } from 'src/repositories/library.repository';
|
||||
import { LoggingRepository } from 'src/repositories/logging.repository';
|
||||
@@ -79,6 +80,7 @@ export const BASE_SERVICE_DEPENDENCIES = [
|
||||
DuplicateRepository,
|
||||
EmailRepository,
|
||||
EventRepository,
|
||||
IntegrityRepository,
|
||||
JobRepository,
|
||||
LibraryRepository,
|
||||
MachineLearningRepository,
|
||||
@@ -137,6 +139,7 @@ export class BaseService {
|
||||
protected duplicateRepository: DuplicateRepository,
|
||||
protected emailRepository: EmailRepository,
|
||||
protected eventRepository: EventRepository,
|
||||
protected integrityRepository: IntegrityRepository,
|
||||
protected jobRepository: JobRepository,
|
||||
protected libraryRepository: LibraryRepository,
|
||||
protected machineLearningRepository: MachineLearningRepository,
|
||||
|
||||
@@ -12,6 +12,7 @@ import { CliService } from 'src/services/cli.service';
|
||||
import { DatabaseService } from 'src/services/database.service';
|
||||
import { DownloadService } from 'src/services/download.service';
|
||||
import { DuplicateService } from 'src/services/duplicate.service';
|
||||
import { IntegrityService } from 'src/services/integrity.service';
|
||||
import { JobService } from 'src/services/job.service';
|
||||
import { LibraryService } from 'src/services/library.service';
|
||||
import { MaintenanceService } from 'src/services/maintenance.service';
|
||||
@@ -62,6 +63,7 @@ export const services = [
|
||||
DatabaseService,
|
||||
DownloadService,
|
||||
DuplicateService,
|
||||
IntegrityService,
|
||||
JobService,
|
||||
LibraryService,
|
||||
MaintenanceService,
|
||||
|
||||
677
server/src/services/integrity.service.spec.ts
Normal file
677
server/src/services/integrity.service.spec.ts
Normal file
@@ -0,0 +1,677 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import { Readable } from 'node:stream';
|
||||
import { text } from 'node:stream/consumers';
|
||||
import { AssetStatus, IntegrityReportType, JobName, JobStatus } from 'src/enum';
|
||||
import { IntegrityService } from 'src/services/integrity.service';
|
||||
import { newTestService, ServiceMocks } from 'test/utils';
|
||||
|
||||
describe(IntegrityService.name, () => {
|
||||
let sut: IntegrityService;
|
||||
let mocks: ServiceMocks;
|
||||
|
||||
beforeEach(() => {
|
||||
({ sut, mocks } = newTestService(IntegrityService));
|
||||
});
|
||||
|
||||
it('should work', () => {
|
||||
expect(sut).toBeDefined();
|
||||
});
|
||||
|
||||
describe('getIntegrityReportSummary', () => {
|
||||
it('gets summary', async () => {
|
||||
await sut.getIntegrityReportSummary();
|
||||
expect(mocks.integrityReport.getIntegrityReportSummary).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getIntegrityReport', () => {
|
||||
it('gets report', async () => {
|
||||
mocks.integrityReport.getIntegrityReports.mockResolvedValue({
|
||||
items: [],
|
||||
hasNextPage: false,
|
||||
});
|
||||
|
||||
await expect(sut.getIntegrityReport({ type: IntegrityReportType.ChecksumFail })).resolves.toEqual({
|
||||
items: [],
|
||||
hasNextPage: false,
|
||||
});
|
||||
|
||||
expect(mocks.integrityReport.getIntegrityReports).toHaveBeenCalledWith(
|
||||
{ page: 1, size: 100 },
|
||||
IntegrityReportType.ChecksumFail,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getIntegrityReportCsv', () => {
|
||||
it('gets report as csv', async () => {
|
||||
mocks.integrityReport.streamIntegrityReports.mockReturnValue(
|
||||
(function* () {
|
||||
yield {
|
||||
id: 'id',
|
||||
createdAt: new Date(0),
|
||||
path: '/path/to/file',
|
||||
type: IntegrityReportType.ChecksumFail,
|
||||
assetId: null,
|
||||
fileAssetId: null,
|
||||
};
|
||||
})() as never,
|
||||
);
|
||||
|
||||
await expect(text(sut.getIntegrityReportCsv(IntegrityReportType.ChecksumFail))).resolves.toMatchInlineSnapshot(`
|
||||
"id,type,assetId,fileAssetId,path
|
||||
id,checksum_mismatch,null,null,"/path/to/file"
|
||||
"
|
||||
`);
|
||||
|
||||
expect(mocks.integrityReport.streamIntegrityReports).toHaveBeenCalledWith(IntegrityReportType.ChecksumFail);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getIntegrityReportFile', () => {
|
||||
it('gets report file', async () => {
|
||||
mocks.integrityReport.getById.mockResolvedValue({
|
||||
id: 'id',
|
||||
createdAt: new Date(0),
|
||||
path: '/path/to/file',
|
||||
type: IntegrityReportType.ChecksumFail,
|
||||
assetId: null,
|
||||
fileAssetId: null,
|
||||
});
|
||||
|
||||
await expect(sut.getIntegrityReportFile('id')).resolves.toEqual({
|
||||
path: '/path/to/file',
|
||||
fileName: 'file',
|
||||
contentType: 'application/octet-stream',
|
||||
cacheControl: 'private_without_cache',
|
||||
});
|
||||
|
||||
expect(mocks.integrityReport.getById).toHaveBeenCalledWith('id');
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteIntegrityReport', () => {
|
||||
it('deletes asset if one is present', async () => {
|
||||
mocks.integrityReport.getById.mockResolvedValue({
|
||||
id: 'id',
|
||||
createdAt: new Date(0),
|
||||
path: '/path/to/file',
|
||||
type: IntegrityReportType.ChecksumFail,
|
||||
assetId: 'assetId',
|
||||
fileAssetId: null,
|
||||
});
|
||||
|
||||
await sut.deleteIntegrityReport(
|
||||
{
|
||||
user: {
|
||||
id: 'userId',
|
||||
},
|
||||
} as never,
|
||||
'id',
|
||||
);
|
||||
|
||||
expect(mocks.asset.updateAll).toHaveBeenCalledWith(['assetId'], {
|
||||
deletedAt: expect.any(Date),
|
||||
status: AssetStatus.Trashed,
|
||||
});
|
||||
|
||||
expect(mocks.event.emit).toHaveBeenCalledWith('AssetTrashAll', {
|
||||
assetIds: ['assetId'],
|
||||
userId: 'userId',
|
||||
});
|
||||
|
||||
expect(mocks.integrityReport.deleteById).toHaveBeenCalledWith('id');
|
||||
});
|
||||
|
||||
it('deletes file asset if one is present', async () => {
|
||||
mocks.integrityReport.getById.mockResolvedValue({
|
||||
id: 'id',
|
||||
createdAt: new Date(0),
|
||||
path: '/path/to/file',
|
||||
type: IntegrityReportType.ChecksumFail,
|
||||
assetId: null,
|
||||
fileAssetId: 'fileAssetId',
|
||||
});
|
||||
|
||||
await sut.deleteIntegrityReport(
|
||||
{
|
||||
user: {
|
||||
id: 'userId',
|
||||
},
|
||||
} as never,
|
||||
'id',
|
||||
);
|
||||
|
||||
expect(mocks.asset.deleteFiles).toHaveBeenCalledWith([{ id: 'fileAssetId' }]);
|
||||
});
|
||||
|
||||
it('deletes orphaned file', async () => {
|
||||
mocks.integrityReport.getById.mockResolvedValue({
|
||||
id: 'id',
|
||||
createdAt: new Date(0),
|
||||
path: '/path/to/file',
|
||||
type: IntegrityReportType.ChecksumFail,
|
||||
assetId: null,
|
||||
fileAssetId: null,
|
||||
});
|
||||
|
||||
await sut.deleteIntegrityReport(
|
||||
{
|
||||
user: {
|
||||
id: 'userId',
|
||||
},
|
||||
} as never,
|
||||
'id',
|
||||
);
|
||||
|
||||
expect(mocks.storage.unlink).toHaveBeenCalledWith('/path/to/file');
|
||||
expect(mocks.integrityReport.deleteById).toHaveBeenCalledWith('id');
|
||||
});
|
||||
});
|
||||
|
||||
describe('handleOrphanedFilesQueueAll', () => {
|
||||
beforeEach(() => {
|
||||
mocks.integrityReport.streamIntegrityReportsWithAssetChecksum.mockReturnValue((function* () {})() as never);
|
||||
});
|
||||
|
||||
it('queues jobs for all detected files', async () => {
|
||||
mocks.storage.walk.mockReturnValueOnce(
|
||||
(function* () {
|
||||
yield ['/path/to/file', '/path/to/file2'];
|
||||
yield ['/path/to/batch2'];
|
||||
})() as never,
|
||||
);
|
||||
|
||||
mocks.storage.walk.mockReturnValueOnce(
|
||||
(function* () {
|
||||
yield ['/path/to/file3', '/path/to/file4'];
|
||||
yield ['/path/to/batch4'];
|
||||
})() as never,
|
||||
);
|
||||
|
||||
await sut.handleOrphanedFilesQueueAll({ refreshOnly: false });
|
||||
|
||||
expect(mocks.job.queue).toBeCalledTimes(4);
|
||||
expect(mocks.job.queue).toBeCalledWith({
|
||||
name: JobName.IntegrityOrphanedFiles,
|
||||
data: {
|
||||
type: 'asset',
|
||||
paths: expect.arrayContaining(['/path/to/file']),
|
||||
},
|
||||
});
|
||||
|
||||
expect(mocks.job.queue).toBeCalledWith({
|
||||
name: JobName.IntegrityOrphanedFiles,
|
||||
data: {
|
||||
type: 'asset_file',
|
||||
paths: expect.arrayContaining(['/path/to/file3']),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('queues jobs to refresh reports', async () => {
|
||||
mocks.integrityReport.streamIntegrityReportsWithAssetChecksum.mockReturnValue(
|
||||
(function* () {
|
||||
yield 'mockReport';
|
||||
})() as never,
|
||||
);
|
||||
|
||||
await sut.handleOrphanedFilesQueueAll({ refreshOnly: false });
|
||||
|
||||
expect(mocks.job.queue).toBeCalledTimes(1);
|
||||
expect(mocks.job.queue).toBeCalledWith({
|
||||
name: JobName.IntegrityOrphanedFilesRefresh,
|
||||
data: {
|
||||
items: expect.arrayContaining(['mockReport']),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should succeed', async () => {
|
||||
await expect(sut.handleOrphanedFilesQueueAll({ refreshOnly: false })).resolves.toBe(JobStatus.Success);
|
||||
});
|
||||
});
|
||||
|
||||
describe('handleOrphanedFiles', () => {
|
||||
it('should detect orphaned asset files', async () => {
|
||||
mocks.integrityReport.getAssetPathsByPaths.mockResolvedValue([
|
||||
{ originalPath: '/path/to/file1', encodedVideoPath: null },
|
||||
]);
|
||||
|
||||
await sut.handleOrphanedFiles({
|
||||
type: 'asset',
|
||||
paths: ['/path/to/file1', '/path/to/orphan'],
|
||||
});
|
||||
|
||||
expect(mocks.integrityReport.getAssetPathsByPaths).toHaveBeenCalledWith(['/path/to/file1', '/path/to/orphan']);
|
||||
expect(mocks.integrityReport.create).toHaveBeenCalledWith([
|
||||
{ type: IntegrityReportType.OrphanFile, path: '/path/to/orphan' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('should not create reports when no orphans found for assets', async () => {
|
||||
mocks.integrityReport.getAssetPathsByPaths.mockResolvedValue([
|
||||
{ originalPath: '/path/to/file1', encodedVideoPath: '/path/to/encoded' },
|
||||
]);
|
||||
|
||||
await sut.handleOrphanedFiles({
|
||||
type: 'asset',
|
||||
paths: ['/path/to/file1', '/path/to/encoded'],
|
||||
});
|
||||
|
||||
expect(mocks.integrityReport.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should detect orphaned asset_file files', async () => {
|
||||
mocks.integrityReport.getAssetFilePathsByPaths.mockResolvedValue([{ path: '/path/to/thumb1' }]);
|
||||
|
||||
await sut.handleOrphanedFiles({
|
||||
type: 'asset_file',
|
||||
paths: ['/path/to/thumb1', '/path/to/orphan_thumb'],
|
||||
});
|
||||
|
||||
expect(mocks.integrityReport.create).toHaveBeenCalledWith([
|
||||
{ type: IntegrityReportType.OrphanFile, path: '/path/to/orphan_thumb' },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('handleOrphanedRefresh', () => {
|
||||
it('should delete reports for files that no longer exist', async () => {
|
||||
mocks.storage.stat
|
||||
.mockRejectedValueOnce(new Error('ENOENT'))
|
||||
.mockResolvedValueOnce({} as never)
|
||||
.mockRejectedValueOnce(new Error('ENOENT'));
|
||||
|
||||
await sut.handleOrphanedRefresh({
|
||||
items: [
|
||||
{ reportId: 'report1', path: '/path/to/missing1' },
|
||||
{ reportId: 'report2', path: '/path/to/existing' },
|
||||
{ reportId: 'report3', path: '/path/to/missing2' },
|
||||
],
|
||||
});
|
||||
|
||||
expect(mocks.integrityReport.deleteByIds).toHaveBeenCalledWith(['report1', 'report3']);
|
||||
});
|
||||
|
||||
it('should not delete reports for files that still exist', async () => {
|
||||
mocks.storage.stat.mockResolvedValue({} as never);
|
||||
|
||||
await sut.handleOrphanedRefresh({
|
||||
items: [{ reportId: 'report1', path: '/path/to/existing' }],
|
||||
});
|
||||
|
||||
expect(mocks.integrityReport.deleteByIds).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should succeed', async () => {
|
||||
await expect(sut.handleOrphanedRefresh({ items: [] })).resolves.toBe(JobStatus.Success);
|
||||
});
|
||||
});
|
||||
|
||||
describe('handleMissingFilesQueueAll', () => {
|
||||
beforeEach(() => {
|
||||
mocks.integrityReport.streamAssetPaths.mockReturnValue((function* () {})() as never);
|
||||
});
|
||||
|
||||
it('should queue jobs', async () => {
|
||||
mocks.integrityReport.streamAssetPaths.mockReturnValue(
|
||||
(function* () {
|
||||
yield { path: '/path/to/file1', assetId: 'asset1', fileAssetId: null };
|
||||
yield { path: '/path/to/file2', assetId: 'asset2', fileAssetId: null };
|
||||
})() as never,
|
||||
);
|
||||
|
||||
await sut.handleMissingFilesQueueAll({ refreshOnly: false });
|
||||
|
||||
expect(mocks.integrityReport.streamAssetPaths).toHaveBeenCalled();
|
||||
|
||||
expect(mocks.job.queue).toHaveBeenCalledWith({
|
||||
name: JobName.IntegrityMissingFiles,
|
||||
data: {
|
||||
items: expect.arrayContaining([{ path: '/path/to/file1', assetId: 'asset1', fileAssetId: null }]),
|
||||
},
|
||||
});
|
||||
|
||||
expect(mocks.job.queue).not.toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
name: JobName.IntegrityMissingFilesRefresh,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should queue refresh jobs when refreshOnly is set', async () => {
|
||||
mocks.integrityReport.streamIntegrityReportsWithAssetChecksum.mockReturnValue(
|
||||
(function* () {
|
||||
yield { reportId: 'report1', path: '/path/to/file1' };
|
||||
yield { reportId: 'report2', path: '/path/to/file2' };
|
||||
})() as never,
|
||||
);
|
||||
|
||||
await sut.handleMissingFilesQueueAll({ refreshOnly: true });
|
||||
|
||||
expect(mocks.integrityReport.streamIntegrityReportsWithAssetChecksum).toHaveBeenCalledWith(
|
||||
IntegrityReportType.MissingFile,
|
||||
);
|
||||
|
||||
expect(mocks.job.queue).toHaveBeenCalledWith({
|
||||
name: JobName.IntegrityMissingFilesRefresh,
|
||||
data: {
|
||||
items: expect.arrayContaining([{ reportId: 'report1', path: '/path/to/file1' }]),
|
||||
},
|
||||
});
|
||||
|
||||
expect(mocks.job.queue).not.toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
name: JobName.IntegrityMissingFiles,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should succeed', async () => {
|
||||
await expect(sut.handleMissingFilesQueueAll()).resolves.toBe(JobStatus.Success);
|
||||
});
|
||||
});
|
||||
|
||||
describe('handleMissingFiles', () => {
|
||||
it('should detect missing files and remove outdated reports', async () => {
|
||||
mocks.storage.stat
|
||||
.mockResolvedValueOnce({} as never)
|
||||
.mockRejectedValueOnce(new Error('ENOENT'))
|
||||
.mockResolvedValueOnce({} as never);
|
||||
|
||||
await sut.handleMissingFiles({
|
||||
items: [
|
||||
{ path: '/path/to/existing', assetId: 'asset1', fileAssetId: null, reportId: null },
|
||||
{ path: '/path/to/missing', assetId: 'asset2', fileAssetId: null, reportId: null },
|
||||
{ path: '/path/to/restored', assetId: 'asset3', fileAssetId: null, reportId: 'report2' },
|
||||
],
|
||||
});
|
||||
|
||||
expect(mocks.integrityReport.deleteByIds).toHaveBeenCalledWith(['report2']);
|
||||
expect(mocks.integrityReport.create).toHaveBeenCalledWith([
|
||||
{ type: IntegrityReportType.MissingFile, path: '/path/to/missing', assetId: 'asset2', fileAssetId: null },
|
||||
]);
|
||||
});
|
||||
|
||||
it('should succeed', async () => {
|
||||
await expect(sut.handleMissingFiles({ items: [] })).resolves.toBe(JobStatus.Success);
|
||||
});
|
||||
});
|
||||
|
||||
describe('handleMissingRefresh', () => {
|
||||
it('should remove outdated reports', async () => {
|
||||
mocks.storage.stat
|
||||
.mockResolvedValueOnce({} as never)
|
||||
.mockRejectedValueOnce(new Error('ENOENT'))
|
||||
.mockResolvedValueOnce({} as never);
|
||||
|
||||
await sut.handleMissingRefresh({
|
||||
items: [
|
||||
{ path: '/path/to/existing', reportId: null },
|
||||
{ path: '/path/to/missing', reportId: null },
|
||||
{ path: '/path/to/restored', reportId: 'report2' },
|
||||
],
|
||||
});
|
||||
|
||||
expect(mocks.integrityReport.deleteByIds).toHaveBeenCalledWith(['report2']);
|
||||
});
|
||||
|
||||
it('should succeed', async () => {
|
||||
await expect(sut.handleMissingFiles({ items: [] })).resolves.toBe(JobStatus.Success);
|
||||
});
|
||||
});
|
||||
|
||||
describe('handleChecksumFiles', () => {
|
||||
beforeEach(() => {
|
||||
mocks.integrityReport.streamIntegrityReportsWithAssetChecksum.mockReturnValue((function* () {})() as never);
|
||||
mocks.integrityReport.streamAssetChecksums.mockReturnValue((function* () {})() as never);
|
||||
mocks.integrityReport.getAssetCount.mockResolvedValue({ count: 1000 });
|
||||
mocks.systemMetadata.get.mockResolvedValue(null);
|
||||
});
|
||||
|
||||
it('should queue refresh jobs when refreshOnly', async () => {
|
||||
mocks.integrityReport.streamIntegrityReportsWithAssetChecksum.mockReturnValue(
|
||||
(function* () {
|
||||
yield { reportId: 'report1', path: '/path/to/file1', checksum: Buffer.from('abc123', 'hex') };
|
||||
})() as never,
|
||||
);
|
||||
|
||||
await sut.handleChecksumFiles({ refreshOnly: true });
|
||||
|
||||
expect(mocks.integrityReport.streamIntegrityReportsWithAssetChecksum).toHaveBeenCalledWith(
|
||||
IntegrityReportType.ChecksumFail,
|
||||
);
|
||||
|
||||
expect(mocks.job.queue).toHaveBeenCalledWith({
|
||||
name: JobName.IntegrityChecksumFilesRefresh,
|
||||
data: {
|
||||
items: [{ reportId: 'report1', path: '/path/to/file1', checksum: 'abc123' }],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should create report for checksum mismatch and delete when fixed', async () => {
|
||||
const fileContent = Buffer.from('test content');
|
||||
|
||||
mocks.integrityReport.streamAssetChecksums.mockReturnValue(
|
||||
(function* () {
|
||||
yield {
|
||||
originalPath: '/path/to/mismatch',
|
||||
checksum: 'mismatched checksum',
|
||||
createdAt: new Date(),
|
||||
assetId: 'asset1',
|
||||
reportId: null,
|
||||
};
|
||||
yield {
|
||||
originalPath: '/path/to/fixed',
|
||||
checksum: createHash('sha1').update(fileContent).digest(),
|
||||
createdAt: new Date(),
|
||||
assetId: 'asset2',
|
||||
reportId: 'report1',
|
||||
};
|
||||
})() as never,
|
||||
);
|
||||
|
||||
mocks.storage.createPlainReadStream.mockImplementation(() => Readable.from(fileContent));
|
||||
|
||||
await sut.handleChecksumFiles({ refreshOnly: false });
|
||||
|
||||
expect(mocks.integrityReport.create).toHaveBeenCalledWith({
|
||||
path: '/path/to/mismatch',
|
||||
type: IntegrityReportType.ChecksumFail,
|
||||
assetId: 'asset1',
|
||||
});
|
||||
|
||||
expect(mocks.integrityReport.deleteById).toHaveBeenCalledWith('report1');
|
||||
});
|
||||
|
||||
it('should skip missing files', async () => {
|
||||
mocks.integrityReport.streamAssetChecksums.mockReturnValue(
|
||||
(function* () {
|
||||
yield {
|
||||
originalPath: '/path/to/missing',
|
||||
checksum: Buffer.from('abc', 'hex'),
|
||||
createdAt: new Date(),
|
||||
assetId: 'asset1',
|
||||
reportId: 'report1',
|
||||
};
|
||||
})() as never,
|
||||
);
|
||||
|
||||
const error = new Error('ENOENT') as NodeJS.ErrnoException;
|
||||
error.code = 'ENOENT';
|
||||
mocks.storage.createPlainReadStream.mockImplementation(() => {
|
||||
throw error;
|
||||
});
|
||||
|
||||
await sut.handleChecksumFiles({ refreshOnly: false });
|
||||
|
||||
expect(mocks.integrityReport.deleteById).toHaveBeenCalledWith('report1');
|
||||
expect(mocks.integrityReport.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should succeed', async () => {
|
||||
await expect(sut.handleChecksumFiles({ refreshOnly: false })).resolves.toBe(JobStatus.Success);
|
||||
});
|
||||
});
|
||||
|
||||
describe('handleChecksumRefresh', () => {
|
||||
it('should delete reports when checksum now matches, file is missing, or asset is now missing', async () => {
|
||||
const fileContent = Buffer.from('test content');
|
||||
const correctChecksum = createHash('sha1').update(fileContent).digest().toString('hex');
|
||||
|
||||
const error = new Error('ENOENT') as NodeJS.ErrnoException;
|
||||
error.code = 'ENOENT';
|
||||
|
||||
mocks.storage.createPlainReadStream
|
||||
.mockImplementationOnce(() => Readable.from(fileContent))
|
||||
.mockImplementationOnce(() => {
|
||||
throw error;
|
||||
})
|
||||
.mockImplementationOnce(() => Readable.from(fileContent))
|
||||
.mockImplementationOnce(() => Readable.from(fileContent));
|
||||
|
||||
await sut.handleChecksumRefresh({
|
||||
items: [
|
||||
{ reportId: 'report1', path: '/path/to/fixed', checksum: correctChecksum },
|
||||
{ reportId: 'report2', path: '/path/to/missing', checksum: 'abc123' },
|
||||
{ reportId: 'report3', path: '/path/to/bad', checksum: 'wrongchecksum' },
|
||||
{ reportId: 'report4', path: '/path/to/missing-asset', checksum: null },
|
||||
],
|
||||
});
|
||||
|
||||
expect(mocks.integrityReport.deleteByIds).toHaveBeenCalledWith(['report1', 'report2', 'report4']);
|
||||
});
|
||||
|
||||
it('should succeed', async () => {
|
||||
await expect(sut.handleChecksumRefresh({ items: [] })).resolves.toBe(JobStatus.Success);
|
||||
});
|
||||
});
|
||||
|
||||
describe('handleDeleteAllIntegrityReports', () => {
|
||||
beforeEach(() => {
|
||||
mocks.integrityReport.streamIntegrityReportsByProperty.mockReturnValue((function* () {})() as never);
|
||||
});
|
||||
|
||||
it('should queue delete jobs for checksum fail reports', async () => {
|
||||
mocks.integrityReport.streamIntegrityReportsByProperty.mockReturnValue(
|
||||
(function* () {
|
||||
yield { id: 'report1', assetId: 'asset1', path: '/path/to/file1' };
|
||||
})() as never,
|
||||
);
|
||||
|
||||
await sut.handleDeleteAllIntegrityReports({ type: IntegrityReportType.ChecksumFail });
|
||||
|
||||
expect(mocks.integrityReport.streamIntegrityReportsByProperty).toHaveBeenCalledWith(
|
||||
'assetId',
|
||||
IntegrityReportType.ChecksumFail,
|
||||
);
|
||||
|
||||
expect(mocks.job.queue).toHaveBeenCalledWith({
|
||||
name: JobName.IntegrityDeleteReports,
|
||||
data: {
|
||||
reports: [{ id: 'report1', assetId: 'asset1', path: '/path/to/file1' }],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should queue delete jobs for missing file reports by assetId and fileAssetId', async () => {
|
||||
mocks.integrityReport.streamIntegrityReportsByProperty
|
||||
.mockReturnValueOnce(
|
||||
(function* () {
|
||||
yield { id: 'report1', assetId: 'asset1', path: '/path/to/file1' };
|
||||
})() as never,
|
||||
)
|
||||
.mockReturnValueOnce(
|
||||
(function* () {
|
||||
yield { id: 'report2', fileAssetId: 'fileAsset1', path: '/path/to/file2' };
|
||||
})() as never,
|
||||
);
|
||||
|
||||
await sut.handleDeleteAllIntegrityReports({ type: IntegrityReportType.MissingFile });
|
||||
|
||||
expect(mocks.integrityReport.streamIntegrityReportsByProperty).toHaveBeenCalledWith(
|
||||
'assetId',
|
||||
IntegrityReportType.MissingFile,
|
||||
);
|
||||
|
||||
expect(mocks.integrityReport.streamIntegrityReportsByProperty).toHaveBeenCalledWith(
|
||||
'fileAssetId',
|
||||
IntegrityReportType.MissingFile,
|
||||
);
|
||||
|
||||
expect(mocks.job.queue).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('should queue delete jobs for orphan file reports', async () => {
|
||||
mocks.integrityReport.streamIntegrityReportsByProperty.mockReturnValue(
|
||||
(function* () {
|
||||
yield { id: 'report1', path: '/path/to/orphan' };
|
||||
})() as never,
|
||||
);
|
||||
|
||||
await sut.handleDeleteAllIntegrityReports({ type: IntegrityReportType.OrphanFile });
|
||||
|
||||
expect(mocks.integrityReport.streamIntegrityReportsByProperty).toHaveBeenCalledWith(
|
||||
undefined,
|
||||
IntegrityReportType.OrphanFile,
|
||||
);
|
||||
|
||||
expect(mocks.job.queue).toHaveBeenCalledWith({
|
||||
name: JobName.IntegrityDeleteReports,
|
||||
data: {
|
||||
reports: [{ id: 'report1', path: '/path/to/orphan' }],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should query all property types when no type specified', async () => {
|
||||
await sut.handleDeleteAllIntegrityReports({});
|
||||
|
||||
expect(mocks.integrityReport.streamIntegrityReportsByProperty).toHaveBeenCalledWith(undefined, undefined);
|
||||
expect(mocks.integrityReport.streamIntegrityReportsByProperty).toHaveBeenCalledWith('assetId', undefined);
|
||||
expect(mocks.integrityReport.streamIntegrityReportsByProperty).toHaveBeenCalledWith('fileAssetId', undefined);
|
||||
});
|
||||
|
||||
it('should succeed', async () => {
|
||||
await expect(sut.handleDeleteAllIntegrityReports({})).resolves.toBe(JobStatus.Success);
|
||||
});
|
||||
});
|
||||
|
||||
describe('handleDeleteIntegrityReports', () => {
|
||||
it('should handle all report types', async () => {
|
||||
mocks.storage.unlink.mockResolvedValue(void 0);
|
||||
|
||||
await sut.handleDeleteIntegrityReports({
|
||||
reports: [
|
||||
{ id: 'report1', assetId: 'asset1', fileAssetId: null, path: '/path/to/file1' },
|
||||
{ id: 'report2', assetId: 'asset2', fileAssetId: null, path: '/path/to/file2' },
|
||||
{ id: 'report3', assetId: null, fileAssetId: 'fileAsset1', path: '/path/to/file3' },
|
||||
{ id: 'report4', assetId: null, fileAssetId: null, path: '/path/to/orphan' },
|
||||
],
|
||||
});
|
||||
|
||||
expect(mocks.asset.updateAll).toHaveBeenCalledWith(['asset1', 'asset2'], {
|
||||
deletedAt: expect.any(Date),
|
||||
status: AssetStatus.Trashed,
|
||||
});
|
||||
|
||||
expect(mocks.event.emit).toHaveBeenCalledWith('AssetTrashAll', {
|
||||
assetIds: ['asset1', 'asset2'],
|
||||
userId: '',
|
||||
});
|
||||
|
||||
expect(mocks.integrityReport.deleteByIds).toHaveBeenCalledWith(['report1', 'report2']);
|
||||
|
||||
expect(mocks.asset.deleteFiles).toHaveBeenCalledWith([{ id: 'fileAsset1' }]);
|
||||
|
||||
expect(mocks.storage.unlink).toHaveBeenCalledWith('/path/to/orphan');
|
||||
expect(mocks.integrityReport.deleteByIds).toHaveBeenCalledWith(['report4']);
|
||||
});
|
||||
|
||||
it('should succeed', async () => {
|
||||
await expect(sut.handleDeleteIntegrityReports({ reports: [] })).resolves.toBe(JobStatus.Success);
|
||||
});
|
||||
});
|
||||
});
|
||||
716
server/src/services/integrity.service.ts
Normal file
716
server/src/services/integrity.service.ts
Normal file
@@ -0,0 +1,716 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { basename } from 'node:path';
|
||||
import { Readable, Writable } from 'node:stream';
|
||||
import { pipeline } from 'node:stream/promises';
|
||||
import { JOBS_LIBRARY_PAGINATION_SIZE } from 'src/constants';
|
||||
import { StorageCore } from 'src/cores/storage.core';
|
||||
import { OnEvent, OnJob } from 'src/decorators';
|
||||
import { AuthDto } from 'src/dtos/auth.dto';
|
||||
import {
|
||||
IntegrityGetReportDto,
|
||||
IntegrityReportResponseDto,
|
||||
IntegrityReportSummaryResponseDto,
|
||||
} from 'src/dtos/integrity.dto';
|
||||
import {
|
||||
AssetStatus,
|
||||
CacheControl,
|
||||
DatabaseLock,
|
||||
ImmichWorker,
|
||||
IntegrityReportType,
|
||||
JobName,
|
||||
JobStatus,
|
||||
QueueName,
|
||||
StorageFolder,
|
||||
SystemMetadataKey,
|
||||
} from 'src/enum';
|
||||
import { ArgOf } from 'src/repositories/event.repository';
|
||||
import { BaseService } from 'src/services/base.service';
|
||||
import {
|
||||
IIntegrityDeleteReportsJob,
|
||||
IIntegrityDeleteReportTypeJob,
|
||||
IIntegrityJob,
|
||||
IIntegrityMissingFilesJob,
|
||||
IIntegrityOrphanedFilesJob,
|
||||
IIntegrityPathWithChecksumJob,
|
||||
IIntegrityPathWithReportJob,
|
||||
} from 'src/types';
|
||||
import { ImmichFileResponse } from 'src/utils/file';
|
||||
import { handlePromiseError } from 'src/utils/misc';
|
||||
|
||||
/**
|
||||
* Orphan Files:
|
||||
* Files are detected in /data/encoded-video, /data/library, /data/upload
|
||||
* Checked against the asset table
|
||||
* Files are detected in /data/thumbs
|
||||
* Checked against the asset_file table
|
||||
*
|
||||
* * Can perform download or delete of files
|
||||
*
|
||||
* Missing Files:
|
||||
* Paths are queried from asset(originalPath, encodedVideoPath), asset_file(path)
|
||||
* Check whether files exist on disk
|
||||
*
|
||||
* * Reports must include origin (asset or asset_file) & ID for further action
|
||||
* * Can perform trash (asset) or delete (asset_file)
|
||||
*
|
||||
* Checksum Mismatch:
|
||||
* Paths & checksums are queried from asset(originalPath, checksum)
|
||||
* Check whether files match checksum, missing files ignored
|
||||
*
|
||||
* * Reports must include origin (as above) for further action
|
||||
* * Can perform download or trash (asset)
|
||||
*/
|
||||
|
||||
@Injectable()
|
||||
export class IntegrityService extends BaseService {
|
||||
private integrityLock = false;
|
||||
|
||||
@OnEvent({ name: 'ConfigInit', workers: [ImmichWorker.Microservices] })
|
||||
async onConfigInit({
|
||||
newConfig: {
|
||||
integrityChecks: { orphanedFiles, missingFiles, checksumFiles },
|
||||
},
|
||||
}: ArgOf<'ConfigInit'>) {
|
||||
this.integrityLock = await this.databaseRepository.tryLock(DatabaseLock.IntegrityCheck);
|
||||
if (this.integrityLock) {
|
||||
this.cronRepository.create({
|
||||
name: 'integrityOrphanedFiles',
|
||||
expression: orphanedFiles.cronExpression,
|
||||
onTick: () =>
|
||||
handlePromiseError(
|
||||
this.jobRepository.queue({ name: JobName.IntegrityOrphanedFilesQueueAll, data: {} }),
|
||||
this.logger,
|
||||
),
|
||||
start: orphanedFiles.enabled,
|
||||
});
|
||||
|
||||
this.cronRepository.create({
|
||||
name: 'integrityMissingFiles',
|
||||
expression: missingFiles.cronExpression,
|
||||
onTick: () =>
|
||||
handlePromiseError(
|
||||
this.jobRepository.queue({ name: JobName.IntegrityMissingFilesQueueAll, data: {} }),
|
||||
this.logger,
|
||||
),
|
||||
start: missingFiles.enabled,
|
||||
});
|
||||
|
||||
this.cronRepository.create({
|
||||
name: 'integrityChecksumFiles',
|
||||
expression: checksumFiles.cronExpression,
|
||||
onTick: () =>
|
||||
handlePromiseError(this.jobRepository.queue({ name: JobName.IntegrityChecksumFiles, data: {} }), this.logger),
|
||||
start: checksumFiles.enabled,
|
||||
});
|
||||
}
|
||||
|
||||
// debug: run on boot
|
||||
setTimeout(() => {
|
||||
void this.jobRepository.queue({
|
||||
name: JobName.IntegrityOrphanedFilesQueueAll,
|
||||
data: {},
|
||||
});
|
||||
|
||||
void this.jobRepository.queue({
|
||||
name: JobName.IntegrityMissingFilesQueueAll,
|
||||
data: {},
|
||||
});
|
||||
|
||||
void this.jobRepository.queue({
|
||||
name: JobName.IntegrityChecksumFiles,
|
||||
data: {},
|
||||
});
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
@OnEvent({ name: 'ConfigUpdate', server: true })
|
||||
onConfigUpdate({
|
||||
newConfig: {
|
||||
integrityChecks: { orphanedFiles, missingFiles, checksumFiles },
|
||||
},
|
||||
}: ArgOf<'ConfigUpdate'>) {
|
||||
if (!this.integrityLock) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.cronRepository.update({
|
||||
name: 'integrityOrphanedFiles',
|
||||
expression: orphanedFiles.cronExpression,
|
||||
start: orphanedFiles.enabled,
|
||||
});
|
||||
|
||||
this.cronRepository.update({
|
||||
name: 'integrityMissingFiles',
|
||||
expression: missingFiles.cronExpression,
|
||||
start: missingFiles.enabled,
|
||||
});
|
||||
|
||||
this.cronRepository.update({
|
||||
name: 'integrityChecksumFiles',
|
||||
expression: checksumFiles.cronExpression,
|
||||
start: checksumFiles.enabled,
|
||||
});
|
||||
}
|
||||
|
||||
getIntegrityReportSummary(): Promise<IntegrityReportSummaryResponseDto> {
|
||||
return this.integrityRepository.getIntegrityReportSummary();
|
||||
}
|
||||
|
||||
async getIntegrityReport(dto: IntegrityGetReportDto): Promise<IntegrityReportResponseDto> {
|
||||
return this.integrityRepository.getIntegrityReports({ page: dto.page || 1, size: dto.size || 100 }, dto.type);
|
||||
}
|
||||
|
||||
getIntegrityReportCsv(type: IntegrityReportType): Readable {
|
||||
const items = this.integrityRepository.streamIntegrityReports(type);
|
||||
|
||||
// very rudimentary csv serialiser
|
||||
async function* generator() {
|
||||
yield 'id,type,assetId,fileAssetId,path\n';
|
||||
|
||||
for await (const item of items) {
|
||||
// no expectation of particularly bad filenames
|
||||
// but they could potentially have a newline or quote character
|
||||
yield `${item.id},${item.type},${item.assetId},${item.fileAssetId},"${item.path.replaceAll('"', '""')}"\n`;
|
||||
}
|
||||
}
|
||||
|
||||
return Readable.from(generator());
|
||||
}
|
||||
|
||||
async getIntegrityReportFile(id: string): Promise<ImmichFileResponse> {
|
||||
const { path } = await this.integrityRepository.getById(id);
|
||||
|
||||
return new ImmichFileResponse({
|
||||
path,
|
||||
fileName: basename(path),
|
||||
contentType: 'application/octet-stream',
|
||||
cacheControl: CacheControl.PrivateWithoutCache,
|
||||
});
|
||||
}
|
||||
|
||||
async deleteIntegrityReport(auth: AuthDto, id: string): Promise<void> {
|
||||
const { path, assetId, fileAssetId } = await this.integrityRepository.getById(id);
|
||||
|
||||
if (assetId) {
|
||||
await this.assetRepository.updateAll([assetId], {
|
||||
deletedAt: new Date(),
|
||||
status: AssetStatus.Trashed,
|
||||
});
|
||||
|
||||
await this.eventRepository.emit('AssetTrashAll', {
|
||||
assetIds: [assetId],
|
||||
userId: auth.user.id,
|
||||
});
|
||||
|
||||
await this.integrityRepository.deleteById(id);
|
||||
} else if (fileAssetId) {
|
||||
await this.assetRepository.deleteFiles([{ id: fileAssetId }]);
|
||||
} else {
|
||||
await this.storageRepository.unlink(path);
|
||||
await this.integrityRepository.deleteById(id);
|
||||
}
|
||||
}
|
||||
|
||||
@OnJob({ name: JobName.IntegrityOrphanedFilesQueueAll, queue: QueueName.IntegrityCheck })
|
||||
async handleOrphanedFilesQueueAll({ refreshOnly }: IIntegrityJob = {}): Promise<JobStatus> {
|
||||
this.logger.log(`Checking for out of date orphaned file reports...`);
|
||||
|
||||
const reports = this.integrityRepository.streamIntegrityReportsWithAssetChecksum(IntegrityReportType.OrphanFile);
|
||||
|
||||
let total = 0;
|
||||
for await (const batchReports of chunk(reports, JOBS_LIBRARY_PAGINATION_SIZE)) {
|
||||
await this.jobRepository.queue({
|
||||
name: JobName.IntegrityOrphanedFilesRefresh,
|
||||
data: {
|
||||
items: batchReports,
|
||||
},
|
||||
});
|
||||
|
||||
total += batchReports.length;
|
||||
this.logger.log(`Queued report check of ${batchReports.length} report(s) (${total} so far)`);
|
||||
}
|
||||
|
||||
if (refreshOnly) {
|
||||
this.logger.log('Refresh complete.');
|
||||
return JobStatus.Success;
|
||||
}
|
||||
|
||||
this.logger.log(`Scanning for orphaned files...`);
|
||||
|
||||
const assetPaths = this.storageRepository.walk({
|
||||
pathsToCrawl: [StorageFolder.EncodedVideo, StorageFolder.Library, StorageFolder.Upload].map((folder) =>
|
||||
StorageCore.getBaseFolder(folder),
|
||||
),
|
||||
includeHidden: false,
|
||||
take: JOBS_LIBRARY_PAGINATION_SIZE,
|
||||
});
|
||||
|
||||
const assetFilePaths = this.storageRepository.walk({
|
||||
pathsToCrawl: [StorageCore.getBaseFolder(StorageFolder.Thumbnails)],
|
||||
includeHidden: false,
|
||||
take: JOBS_LIBRARY_PAGINATION_SIZE,
|
||||
});
|
||||
|
||||
async function* paths() {
|
||||
for await (const batch of assetPaths) {
|
||||
yield ['asset', batch] as const;
|
||||
}
|
||||
|
||||
for await (const batch of assetFilePaths) {
|
||||
yield ['asset_file', batch] as const;
|
||||
}
|
||||
}
|
||||
|
||||
total = 0;
|
||||
for await (const [batchType, batchPaths] of paths()) {
|
||||
await this.jobRepository.queue({
|
||||
name: JobName.IntegrityOrphanedFiles,
|
||||
data: {
|
||||
type: batchType,
|
||||
paths: batchPaths,
|
||||
},
|
||||
});
|
||||
|
||||
const count = batchPaths.length;
|
||||
total += count;
|
||||
|
||||
this.logger.log(`Queued orphan check of ${count} file(s) (${total} so far)`);
|
||||
}
|
||||
|
||||
return JobStatus.Success;
|
||||
}
|
||||
|
||||
@OnJob({ name: JobName.IntegrityOrphanedFiles, queue: QueueName.IntegrityCheck })
|
||||
async handleOrphanedFiles({ type, paths }: IIntegrityOrphanedFilesJob): Promise<JobStatus> {
|
||||
this.logger.log(`Processing batch of ${paths.length} files to check if they are orphaned.`);
|
||||
|
||||
const orphanedFiles = new Set<string>(paths);
|
||||
if (type === 'asset') {
|
||||
const assets = await this.integrityRepository.getAssetPathsByPaths(paths);
|
||||
for (const { originalPath, encodedVideoPath } of assets) {
|
||||
orphanedFiles.delete(originalPath);
|
||||
|
||||
if (encodedVideoPath) {
|
||||
orphanedFiles.delete(encodedVideoPath);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const assets = await this.integrityRepository.getAssetFilePathsByPaths(paths);
|
||||
for (const { path } of assets) {
|
||||
orphanedFiles.delete(path);
|
||||
}
|
||||
}
|
||||
|
||||
if (orphanedFiles.size > 0) {
|
||||
await this.integrityRepository.create(
|
||||
[...orphanedFiles].map((path) => ({
|
||||
type: IntegrityReportType.OrphanFile,
|
||||
path,
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
this.logger.log(`Processed ${paths.length} and found ${orphanedFiles.size} orphaned file(s).`);
|
||||
return JobStatus.Success;
|
||||
}
|
||||
|
||||
@OnJob({ name: JobName.IntegrityOrphanedFilesRefresh, queue: QueueName.IntegrityCheck })
|
||||
async handleOrphanedRefresh({ items }: IIntegrityPathWithReportJob): Promise<JobStatus> {
|
||||
this.logger.log(`Processing batch of ${items.length} reports to check if they are out of date.`);
|
||||
|
||||
const results = await Promise.all(
|
||||
items.map(({ reportId, path }) =>
|
||||
this.storageRepository
|
||||
.stat(path)
|
||||
.then(() => void 0)
|
||||
.catch(() => reportId),
|
||||
),
|
||||
);
|
||||
|
||||
const reportIds = results.filter(Boolean) as string[];
|
||||
|
||||
if (reportIds.length > 0) {
|
||||
await this.integrityRepository.deleteByIds(reportIds);
|
||||
}
|
||||
|
||||
this.logger.log(`Processed ${items.length} paths and found ${reportIds.length} report(s) out of date.`);
|
||||
return JobStatus.Success;
|
||||
}
|
||||
|
||||
@OnJob({ name: JobName.IntegrityMissingFilesQueueAll, queue: QueueName.IntegrityCheck })
|
||||
async handleMissingFilesQueueAll({ refreshOnly }: IIntegrityJob = {}): Promise<JobStatus> {
|
||||
if (refreshOnly) {
|
||||
this.logger.log(`Checking for out of date missing file reports...`);
|
||||
|
||||
const reports = this.integrityRepository.streamIntegrityReportsWithAssetChecksum(IntegrityReportType.MissingFile);
|
||||
|
||||
let total = 0;
|
||||
for await (const batchReports of chunk(reports, JOBS_LIBRARY_PAGINATION_SIZE)) {
|
||||
await this.jobRepository.queue({
|
||||
name: JobName.IntegrityMissingFilesRefresh,
|
||||
data: {
|
||||
items: batchReports,
|
||||
},
|
||||
});
|
||||
|
||||
total += batchReports.length;
|
||||
this.logger.log(`Queued report check of ${batchReports.length} report(s) (${total} so far)`);
|
||||
}
|
||||
|
||||
this.logger.log('Refresh complete.');
|
||||
return JobStatus.Success;
|
||||
}
|
||||
|
||||
this.logger.log(`Scanning for missing files...`);
|
||||
|
||||
const assetPaths = this.integrityRepository.streamAssetPaths();
|
||||
|
||||
let total = 0;
|
||||
for await (const batchPaths of chunk(assetPaths, JOBS_LIBRARY_PAGINATION_SIZE)) {
|
||||
await this.jobRepository.queue({
|
||||
name: JobName.IntegrityMissingFiles,
|
||||
data: {
|
||||
items: batchPaths,
|
||||
},
|
||||
});
|
||||
|
||||
total += batchPaths.length;
|
||||
this.logger.log(`Queued missing check of ${batchPaths.length} file(s) (${total} so far)`);
|
||||
}
|
||||
|
||||
return JobStatus.Success;
|
||||
}
|
||||
|
||||
@OnJob({ name: JobName.IntegrityMissingFiles, queue: QueueName.IntegrityCheck })
|
||||
async handleMissingFiles({ items }: IIntegrityMissingFilesJob): Promise<JobStatus> {
|
||||
this.logger.log(`Processing batch of ${items.length} files to check if they are missing.`);
|
||||
|
||||
const results = await Promise.all(
|
||||
items.map((item) =>
|
||||
this.storageRepository
|
||||
.stat(item.path)
|
||||
.then(() => ({ ...item, exists: true }))
|
||||
.catch(() => ({ ...item, exists: false })),
|
||||
),
|
||||
);
|
||||
|
||||
const outdatedReports = results
|
||||
.filter(({ exists, reportId }) => exists && reportId)
|
||||
.map(({ reportId }) => reportId!);
|
||||
|
||||
if (outdatedReports.length > 0) {
|
||||
await this.integrityRepository.deleteByIds(outdatedReports);
|
||||
}
|
||||
|
||||
const missingFiles = results.filter(({ exists }) => !exists);
|
||||
if (missingFiles.length > 0) {
|
||||
await this.integrityRepository.create(
|
||||
missingFiles.map(({ path, assetId, fileAssetId }) => ({
|
||||
type: IntegrityReportType.MissingFile,
|
||||
path,
|
||||
assetId,
|
||||
fileAssetId,
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
this.logger.log(`Processed ${items.length} and found ${missingFiles.length} missing file(s).`);
|
||||
return JobStatus.Success;
|
||||
}
|
||||
|
||||
@OnJob({ name: JobName.IntegrityMissingFilesRefresh, queue: QueueName.IntegrityCheck })
|
||||
async handleMissingRefresh({ items: paths }: IIntegrityPathWithReportJob): Promise<JobStatus> {
|
||||
this.logger.log(`Processing batch of ${paths.length} reports to check if they are out of date.`);
|
||||
|
||||
const results = await Promise.all(
|
||||
paths.map(({ reportId, path }) =>
|
||||
this.storageRepository
|
||||
.stat(path)
|
||||
.then(() => reportId)
|
||||
.catch(() => void 0),
|
||||
),
|
||||
);
|
||||
|
||||
const reportIds = results.filter(Boolean) as string[];
|
||||
|
||||
if (reportIds.length > 0) {
|
||||
await this.integrityRepository.deleteByIds(reportIds);
|
||||
}
|
||||
|
||||
this.logger.log(`Processed ${paths.length} paths and found ${reportIds.length} report(s) out of date.`);
|
||||
return JobStatus.Success;
|
||||
}
|
||||
|
||||
@OnJob({ name: JobName.IntegrityChecksumFiles, queue: QueueName.IntegrityCheck })
|
||||
async handleChecksumFiles({ refreshOnly }: IIntegrityJob = {}): Promise<JobStatus> {
|
||||
if (refreshOnly) {
|
||||
this.logger.log(`Checking for out of date checksum file reports...`);
|
||||
|
||||
const reports = this.integrityRepository.streamIntegrityReportsWithAssetChecksum(
|
||||
IntegrityReportType.ChecksumFail,
|
||||
);
|
||||
|
||||
let total = 0;
|
||||
for await (const batchReports of chunk(reports, JOBS_LIBRARY_PAGINATION_SIZE)) {
|
||||
await this.jobRepository.queue({
|
||||
name: JobName.IntegrityChecksumFilesRefresh,
|
||||
data: {
|
||||
items: batchReports.map(({ path, reportId, checksum }) => ({
|
||||
path,
|
||||
reportId,
|
||||
checksum: checksum?.toString('hex'),
|
||||
})),
|
||||
},
|
||||
});
|
||||
|
||||
total += batchReports.length;
|
||||
this.logger.log(`Queued report check of ${batchReports.length} report(s) (${total} so far)`);
|
||||
}
|
||||
|
||||
this.logger.log('Refresh complete.');
|
||||
return JobStatus.Success;
|
||||
}
|
||||
|
||||
const {
|
||||
integrityChecks: {
|
||||
checksumFiles: { timeLimit, percentageLimit },
|
||||
},
|
||||
} = await this.getConfig({
|
||||
withCache: true,
|
||||
});
|
||||
|
||||
this.logger.log(
|
||||
`Checking file checksums... (will run for up to ${(timeLimit / (60 * 60 * 1000)).toFixed(2)} hours or until ${(percentageLimit * 100).toFixed(2)}% of assets are processed)`,
|
||||
);
|
||||
|
||||
let processed = 0;
|
||||
const startedAt = Date.now();
|
||||
const { count } = await this.integrityRepository.getAssetCount();
|
||||
const checkpoint = await this.systemMetadataRepository.get(SystemMetadataKey.IntegrityChecksumCheckpoint);
|
||||
|
||||
let startMarker: Date | undefined = checkpoint?.date ? new Date(checkpoint.date) : undefined;
|
||||
let endMarker: Date | undefined;
|
||||
|
||||
const printStats = () => {
|
||||
const averageTime = ((Date.now() - startedAt) / processed).toFixed(2);
|
||||
const completionProgress = ((processed / count) * 100).toFixed(2);
|
||||
|
||||
this.logger.log(
|
||||
`Processed ${processed} files so far... (avg. ${averageTime} ms/asset, ${completionProgress}% of all assets)`,
|
||||
);
|
||||
};
|
||||
|
||||
let lastCreatedAt: Date | undefined;
|
||||
|
||||
finishEarly: do {
|
||||
this.logger.log(
|
||||
`Processing assets in range [${startMarker?.toISOString() ?? 'beginning'}, ${endMarker?.toISOString() ?? 'end'}]`,
|
||||
);
|
||||
|
||||
const assets = this.integrityRepository.streamAssetChecksums(startMarker, endMarker);
|
||||
endMarker = startMarker;
|
||||
startMarker = undefined;
|
||||
|
||||
for await (const { originalPath, checksum, createdAt, assetId, reportId } of assets) {
|
||||
processed++;
|
||||
|
||||
try {
|
||||
const hash = createHash('sha1');
|
||||
|
||||
await pipeline([
|
||||
this.storageRepository.createPlainReadStream(originalPath),
|
||||
new Writable({
|
||||
write(chunk, _encoding, callback) {
|
||||
hash.update(chunk);
|
||||
callback();
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
if (checksum.equals(hash.digest())) {
|
||||
if (reportId) {
|
||||
await this.integrityRepository.deleteById(reportId);
|
||||
}
|
||||
} else {
|
||||
throw new Error('File failed checksum');
|
||||
}
|
||||
} catch (error) {
|
||||
if ((error as { code?: string }).code === 'ENOENT') {
|
||||
if (reportId) {
|
||||
await this.integrityRepository.deleteById(reportId);
|
||||
}
|
||||
// missing file; handled by the missing files job
|
||||
continue;
|
||||
}
|
||||
|
||||
this.logger.warn('Failed to process a file: ' + error);
|
||||
await this.integrityRepository.create({
|
||||
path: originalPath,
|
||||
type: IntegrityReportType.ChecksumFail,
|
||||
assetId,
|
||||
});
|
||||
}
|
||||
|
||||
if (processed % 100 === 0) {
|
||||
printStats();
|
||||
}
|
||||
|
||||
if (Date.now() > startedAt + timeLimit || processed > count * percentageLimit) {
|
||||
this.logger.log('Reached stop criteria.');
|
||||
lastCreatedAt = createdAt;
|
||||
break finishEarly;
|
||||
}
|
||||
}
|
||||
} while (endMarker);
|
||||
|
||||
await this.systemMetadataRepository.set(SystemMetadataKey.IntegrityChecksumCheckpoint, {
|
||||
date: lastCreatedAt?.toISOString(),
|
||||
});
|
||||
|
||||
printStats();
|
||||
|
||||
if (lastCreatedAt) {
|
||||
this.logger.log(`Finished checksum job, will continue from ${lastCreatedAt.toISOString()}.`);
|
||||
} else {
|
||||
this.logger.log(`Finished checksum job, covered all assets.`);
|
||||
}
|
||||
|
||||
return JobStatus.Success;
|
||||
}
|
||||
|
||||
@OnJob({ name: JobName.IntegrityChecksumFilesRefresh, queue: QueueName.IntegrityCheck })
|
||||
async handleChecksumRefresh({ items: paths }: IIntegrityPathWithChecksumJob): Promise<JobStatus> {
|
||||
this.logger.log(`Processing batch of ${paths.length} reports to check if they are out of date.`);
|
||||
|
||||
const results = await Promise.all(
|
||||
paths.map(async ({ reportId, path, checksum }) => {
|
||||
if (!checksum) {
|
||||
return reportId;
|
||||
}
|
||||
|
||||
try {
|
||||
const hash = createHash('sha1');
|
||||
|
||||
await pipeline([
|
||||
this.storageRepository.createPlainReadStream(path),
|
||||
new Writable({
|
||||
write(chunk, _encoding, callback) {
|
||||
hash.update(chunk);
|
||||
callback();
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
if (Buffer.from(checksum, 'hex').equals(hash.digest())) {
|
||||
return reportId;
|
||||
}
|
||||
} catch (error) {
|
||||
if ((error as { code?: string }).code === 'ENOENT') {
|
||||
return reportId;
|
||||
}
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
const reportIds = results.filter(Boolean) as string[];
|
||||
|
||||
if (reportIds.length > 0) {
|
||||
await this.integrityRepository.deleteByIds(reportIds);
|
||||
}
|
||||
|
||||
this.logger.log(`Processed ${paths.length} paths and found ${reportIds.length} report(s) out of date.`);
|
||||
return JobStatus.Success;
|
||||
}
|
||||
|
||||
@OnJob({ name: JobName.IntegrityDeleteReportType, queue: QueueName.IntegrityCheck })
|
||||
async handleDeleteAllIntegrityReports({ type }: IIntegrityDeleteReportTypeJob): Promise<JobStatus> {
|
||||
this.logger.log(`Deleting all entries for ${type ?? 'all types of'} integrity report`);
|
||||
|
||||
let properties;
|
||||
switch (type) {
|
||||
case IntegrityReportType.ChecksumFail: {
|
||||
properties = ['assetId'] as const;
|
||||
break;
|
||||
}
|
||||
case IntegrityReportType.MissingFile: {
|
||||
properties = ['assetId', 'fileAssetId'] as const;
|
||||
break;
|
||||
}
|
||||
case IntegrityReportType.OrphanFile: {
|
||||
properties = [void 0] as const;
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
properties = [void 0, 'assetId', 'fileAssetId'] as const;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
for (const property of properties) {
|
||||
const reports = this.integrityRepository.streamIntegrityReportsByProperty(property, type);
|
||||
for await (const batch of chunk(reports, JOBS_LIBRARY_PAGINATION_SIZE)) {
|
||||
await this.jobRepository.queue({
|
||||
name: JobName.IntegrityDeleteReports,
|
||||
data: {
|
||||
reports: batch,
|
||||
},
|
||||
});
|
||||
|
||||
this.logger.log(`Queued ${batch.length} reports to delete.`);
|
||||
}
|
||||
}
|
||||
|
||||
return JobStatus.Success;
|
||||
}
|
||||
|
||||
@OnJob({ name: JobName.IntegrityDeleteReports, queue: QueueName.IntegrityCheck })
|
||||
async handleDeleteIntegrityReports({ reports }: IIntegrityDeleteReportsJob): Promise<JobStatus> {
|
||||
const byAsset = reports.filter((report) => report.assetId);
|
||||
const byFileAsset = reports.filter((report) => report.fileAssetId);
|
||||
const byPath = reports.filter((report) => !report.assetId && !report.fileAssetId);
|
||||
|
||||
if (byAsset.length > 0) {
|
||||
const ids = byAsset.map(({ assetId }) => assetId!);
|
||||
await this.assetRepository.updateAll(ids, {
|
||||
deletedAt: new Date(),
|
||||
status: AssetStatus.Trashed,
|
||||
});
|
||||
|
||||
await this.eventRepository.emit('AssetTrashAll', {
|
||||
assetIds: ids,
|
||||
userId: '', // we don't notify any users currently
|
||||
});
|
||||
|
||||
await this.integrityRepository.deleteByIds(byAsset.map(({ id }) => id));
|
||||
}
|
||||
|
||||
if (byFileAsset.length > 0) {
|
||||
await this.assetRepository.deleteFiles(byFileAsset.map(({ fileAssetId }) => ({ id: fileAssetId! })));
|
||||
}
|
||||
|
||||
if (byPath.length > 0) {
|
||||
await Promise.all(byPath.map(({ path }) => this.storageRepository.unlink(path).catch(() => void 0)));
|
||||
await this.integrityRepository.deleteByIds(byPath.map(({ id }) => id));
|
||||
}
|
||||
|
||||
this.logger.log(`Deleted ${reports.length} reports.`);
|
||||
return JobStatus.Success;
|
||||
}
|
||||
}
|
||||
|
||||
async function* chunk<T>(generator: AsyncIterableIterator<T>, n: number) {
|
||||
let chunk: T[] = [];
|
||||
for await (const item of generator) {
|
||||
chunk.push(item);
|
||||
|
||||
if (chunk.length === n) {
|
||||
yield chunk;
|
||||
chunk = [];
|
||||
}
|
||||
}
|
||||
|
||||
if (chunk.length > 0) {
|
||||
yield chunk;
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import { BadRequestException, Injectable } from '@nestjs/common';
|
||||
import { OnEvent } from 'src/decorators';
|
||||
import { mapAsset } from 'src/dtos/asset-response.dto';
|
||||
import { JobCreateDto } from 'src/dtos/job.dto';
|
||||
import { AssetType, AssetVisibility, JobName, JobStatus, ManualJobName } from 'src/enum';
|
||||
import { AssetType, AssetVisibility, IntegrityReportType, JobName, JobStatus, ManualJobName } from 'src/enum';
|
||||
import { ArgsOf } from 'src/repositories/event.repository';
|
||||
import { BaseService } from 'src/services/base.service';
|
||||
import { JobItem } from 'src/types';
|
||||
@@ -34,6 +34,42 @@ const asJobItem = (dto: JobCreateDto): JobItem => {
|
||||
return { name: JobName.DatabaseBackup };
|
||||
}
|
||||
|
||||
case ManualJobName.IntegrityMissingFiles: {
|
||||
return { name: JobName.IntegrityMissingFilesQueueAll };
|
||||
}
|
||||
|
||||
case ManualJobName.IntegrityOrphanFiles: {
|
||||
return { name: JobName.IntegrityOrphanedFilesQueueAll };
|
||||
}
|
||||
|
||||
case ManualJobName.IntegrityChecksumFiles: {
|
||||
return { name: JobName.IntegrityChecksumFiles };
|
||||
}
|
||||
|
||||
case ManualJobName.IntegrityMissingFilesRefresh: {
|
||||
return { name: JobName.IntegrityMissingFilesQueueAll, data: { refreshOnly: true } };
|
||||
}
|
||||
|
||||
case ManualJobName.IntegrityOrphanFilesRefresh: {
|
||||
return { name: JobName.IntegrityOrphanedFilesQueueAll, data: { refreshOnly: true } };
|
||||
}
|
||||
|
||||
case ManualJobName.IntegrityChecksumFilesRefresh: {
|
||||
return { name: JobName.IntegrityChecksumFiles, data: { refreshOnly: true } };
|
||||
}
|
||||
|
||||
case ManualJobName.IntegrityMissingFilesDeleteAll: {
|
||||
return { name: JobName.IntegrityDeleteReportType, data: { type: IntegrityReportType.MissingFile } };
|
||||
}
|
||||
|
||||
case ManualJobName.IntegrityOrphanFilesDeleteAll: {
|
||||
return { name: JobName.IntegrityDeleteReportType, data: { type: IntegrityReportType.OrphanFile } };
|
||||
}
|
||||
|
||||
case ManualJobName.IntegrityChecksumFilesDeleteAll: {
|
||||
return { name: JobName.IntegrityDeleteReportType, data: { type: IntegrityReportType.ChecksumFail } };
|
||||
}
|
||||
|
||||
default: {
|
||||
throw new BadRequestException('Invalid job name');
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@ describe(QueueService.name, () => {
|
||||
it('should update concurrency', () => {
|
||||
sut.onConfigUpdate({ newConfig: defaults, oldConfig: {} as SystemConfig });
|
||||
|
||||
expect(mocks.job.setConcurrency).toHaveBeenCalledTimes(17);
|
||||
expect(mocks.job.setConcurrency).toHaveBeenCalledTimes(18);
|
||||
expect(mocks.job.setConcurrency).toHaveBeenNthCalledWith(5, QueueName.FacialRecognition, 1);
|
||||
expect(mocks.job.setConcurrency).toHaveBeenNthCalledWith(7, QueueName.DuplicateDetection, 1);
|
||||
expect(mocks.job.setConcurrency).toHaveBeenNthCalledWith(8, QueueName.BackgroundTask, 5);
|
||||
@@ -77,6 +77,7 @@ describe(QueueService.name, () => {
|
||||
[QueueName.BackupDatabase]: expected,
|
||||
[QueueName.Ocr]: expected,
|
||||
[QueueName.Workflow]: expected,
|
||||
[QueueName.IntegrityCheck]: expected,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -41,6 +41,7 @@ const updatedConfig = Object.freeze<SystemConfig>({
|
||||
[QueueName.Notification]: { concurrency: 5 },
|
||||
[QueueName.Ocr]: { concurrency: 1 },
|
||||
[QueueName.Workflow]: { concurrency: 5 },
|
||||
[QueueName.IntegrityCheck]: { concurrency: 1 },
|
||||
},
|
||||
backup: {
|
||||
database: {
|
||||
@@ -72,6 +73,22 @@ const updatedConfig = Object.freeze<SystemConfig>({
|
||||
accelDecode: false,
|
||||
tonemap: ToneMapping.Hable,
|
||||
},
|
||||
integrityChecks: {
|
||||
orphanedFiles: {
|
||||
enabled: true,
|
||||
cronExpression: '0 03 * * *',
|
||||
},
|
||||
missingFiles: {
|
||||
enabled: true,
|
||||
cronExpression: '0 03 * * *',
|
||||
},
|
||||
checksumFiles: {
|
||||
enabled: true,
|
||||
cronExpression: '0 03 * * *',
|
||||
timeLimit: 60 * 60 * 1000,
|
||||
percentageLimit: 1,
|
||||
},
|
||||
},
|
||||
logging: {
|
||||
enabled: true,
|
||||
level: LogLevel.Log,
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
DatabaseSslMode,
|
||||
ExifOrientation,
|
||||
ImageFormat,
|
||||
IntegrityReportType,
|
||||
JobName,
|
||||
MemoryType,
|
||||
PluginTriggerType,
|
||||
@@ -281,6 +282,45 @@ export interface IWorkflowJob<T extends PluginTriggerType = PluginTriggerType> {
|
||||
event: WorkflowData[T];
|
||||
}
|
||||
|
||||
export interface IIntegrityJob {
|
||||
refreshOnly?: boolean;
|
||||
}
|
||||
|
||||
export interface IIntegrityDeleteReportTypeJob {
|
||||
type?: IntegrityReportType;
|
||||
}
|
||||
|
||||
export interface IIntegrityDeleteReportsJob {
|
||||
reports: {
|
||||
id: string;
|
||||
assetId: string | null;
|
||||
fileAssetId: string | null;
|
||||
path: string;
|
||||
}[];
|
||||
}
|
||||
|
||||
export interface IIntegrityOrphanedFilesJob {
|
||||
type: 'asset' | 'asset_file';
|
||||
paths: string[];
|
||||
}
|
||||
|
||||
export interface IIntegrityMissingFilesJob {
|
||||
items: {
|
||||
path: string;
|
||||
reportId: string | null;
|
||||
assetId: string | null;
|
||||
fileAssetId: string | null;
|
||||
}[];
|
||||
}
|
||||
|
||||
export interface IIntegrityPathWithReportJob {
|
||||
items: { path: string; reportId: string | null }[];
|
||||
}
|
||||
|
||||
export interface IIntegrityPathWithChecksumJob {
|
||||
items: { path: string; reportId: string | null; checksum?: string | null }[];
|
||||
}
|
||||
|
||||
export interface JobCounts {
|
||||
active: number;
|
||||
completed: number;
|
||||
@@ -390,7 +430,19 @@ export type JobItem =
|
||||
| { name: JobName.Ocr; data: IEntityJob }
|
||||
|
||||
// Workflow
|
||||
| { name: JobName.WorkflowRun; data: IWorkflowJob };
|
||||
| { name: JobName.WorkflowRun; data: IWorkflowJob }
|
||||
|
||||
// Integrity
|
||||
| { name: JobName.IntegrityOrphanedFilesQueueAll; data?: IIntegrityJob }
|
||||
| { name: JobName.IntegrityOrphanedFiles; data: IIntegrityOrphanedFilesJob }
|
||||
| { name: JobName.IntegrityOrphanedFilesRefresh; data: IIntegrityPathWithReportJob }
|
||||
| { name: JobName.IntegrityMissingFilesQueueAll; data?: IIntegrityJob }
|
||||
| { name: JobName.IntegrityMissingFiles; data: IIntegrityPathWithReportJob }
|
||||
| { name: JobName.IntegrityMissingFilesRefresh; data: IIntegrityPathWithReportJob }
|
||||
| { name: JobName.IntegrityChecksumFiles; data?: IIntegrityJob }
|
||||
| { name: JobName.IntegrityChecksumFilesRefresh; data?: IIntegrityPathWithChecksumJob }
|
||||
| { name: JobName.IntegrityDeleteReportType; data: IIntegrityDeleteReportTypeJob }
|
||||
| { name: JobName.IntegrityDeleteReports; data: IIntegrityDeleteReportsJob };
|
||||
|
||||
export type VectorExtension = (typeof VECTOR_EXTENSIONS)[number];
|
||||
|
||||
@@ -505,6 +557,7 @@ export interface SystemMetadata extends Record<SystemMetadataKey, Record<string,
|
||||
[SystemMetadataKey.SystemFlags]: DeepPartial<SystemFlags>;
|
||||
[SystemMetadataKey.VersionCheckState]: VersionCheckMetadata;
|
||||
[SystemMetadataKey.MemoriesState]: MemoriesState;
|
||||
[SystemMetadataKey.IntegrityChecksumCheckpoint]: { date?: string };
|
||||
}
|
||||
|
||||
export interface UserPreferences {
|
||||
|
||||
@@ -33,6 +33,7 @@ import {
|
||||
import { CronJob } from 'cron';
|
||||
import { DateTime } from 'luxon';
|
||||
import sanitize from 'sanitize-filename';
|
||||
import { IntegrityReportType } from 'src/enum';
|
||||
import { isIP, isIPRange } from 'validator';
|
||||
|
||||
@Injectable()
|
||||
@@ -96,6 +97,12 @@ export class UUIDAssetIDParamDto {
|
||||
assetId!: string;
|
||||
}
|
||||
|
||||
export class IntegrityReportTypeParamDto {
|
||||
@IsNotEmpty()
|
||||
@ApiProperty({ enum: IntegrityReportType, enumName: 'IntegrityReportType' })
|
||||
type!: IntegrityReportType;
|
||||
}
|
||||
|
||||
type PinCodeOptions = { optional?: boolean } & OptionalOptions;
|
||||
export const PinCode = (options?: PinCodeOptions & ApiPropertyOptions) => {
|
||||
const { optional, nullable, emptyToNull, ...apiPropertyOptions } = {
|
||||
|
||||
@@ -48,6 +48,7 @@ export const newStorageRepositoryMock = (): Mocked<RepositoryInterface<StorageRe
|
||||
|
||||
return {
|
||||
createZipStream: vitest.fn(),
|
||||
createPlainReadStream: vitest.fn(),
|
||||
createReadStream: vitest.fn(),
|
||||
readFile: vitest.fn(),
|
||||
readTextFile: vitest.fn(),
|
||||
|
||||
@@ -31,6 +31,7 @@ import { DownloadRepository } from 'src/repositories/download.repository';
|
||||
import { DuplicateRepository } from 'src/repositories/duplicate.repository';
|
||||
import { EmailRepository } from 'src/repositories/email.repository';
|
||||
import { EventRepository } from 'src/repositories/event.repository';
|
||||
import { IntegrityRepository } from 'src/repositories/integrity.repository';
|
||||
import { JobRepository } from 'src/repositories/job.repository';
|
||||
import { LibraryRepository } from 'src/repositories/library.repository';
|
||||
import { LoggingRepository } from 'src/repositories/logging.repository';
|
||||
@@ -225,6 +226,7 @@ export type ServiceOverrides = {
|
||||
duplicateRepository: DuplicateRepository;
|
||||
email: EmailRepository;
|
||||
event: EventRepository;
|
||||
integrityReport: IntegrityRepository;
|
||||
job: JobRepository;
|
||||
library: LibraryRepository;
|
||||
logger: LoggingRepository;
|
||||
@@ -298,6 +300,7 @@ export const getMocks = () => {
|
||||
email: automock(EmailRepository, { args: [loggerMock] }),
|
||||
// eslint-disable-next-line no-sparse-arrays
|
||||
event: automock(EventRepository, { args: [, , loggerMock], strict: false }),
|
||||
integrityReport: automock(IntegrityRepository, { strict: false }),
|
||||
job: newJobRepositoryMock(),
|
||||
apiKey: automock(ApiKeyRepository),
|
||||
library: automock(LibraryRepository, { strict: false }),
|
||||
@@ -366,6 +369,7 @@ export const newTestService = <T extends BaseService>(
|
||||
overrides.duplicateRepository || (mocks.duplicateRepository as As<DuplicateRepository>),
|
||||
overrides.email || (mocks.email as As<EmailRepository>),
|
||||
overrides.event || (mocks.event as As<EventRepository>),
|
||||
overrides.integrityReport || (mocks.integrityReport as As<IntegrityRepository>),
|
||||
overrides.job || (mocks.job as As<JobRepository>),
|
||||
overrides.library || (mocks.library as As<LibraryRepository>),
|
||||
overrides.machineLearning || (mocks.machineLearning as As<MachineLearningRepository>),
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
<script lang="ts">
|
||||
import { ByteUnit } from '$lib/utils/byte-units';
|
||||
import { Code, Icon, Text } from '@immich/ui';
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
interface Props {
|
||||
icon: string;
|
||||
icon?: string;
|
||||
title: string;
|
||||
value: number;
|
||||
unit?: ByteUnit | undefined;
|
||||
footer?: Snippet<[]>;
|
||||
}
|
||||
|
||||
let { icon, title, value, unit = undefined }: Props = $props();
|
||||
let { icon, title, value, unit = undefined, footer }: Props = $props();
|
||||
|
||||
const zeros = $derived(() => {
|
||||
const maxLength = 13;
|
||||
@@ -22,7 +24,9 @@
|
||||
|
||||
<div class="flex h-35 w-full flex-col justify-between rounded-3xl bg-subtle text-primary p-5">
|
||||
<div class="flex place-items-center gap-4">
|
||||
<Icon {icon} size="40" />
|
||||
{#if icon}
|
||||
<Icon {icon} size="40" />
|
||||
{/if}
|
||||
<Text size="large" fontWeight="bold" class="uppercase">{title}</Text>
|
||||
</div>
|
||||
|
||||
@@ -32,4 +36,6 @@
|
||||
<Code color="muted" class="absolute -top-5 end-1 font-light p-0">{unit}</Code>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{@render footer?.()}
|
||||
</div>
|
||||
|
||||
@@ -22,6 +22,8 @@ export enum AppRoute {
|
||||
ADMIN_USERS = '/admin/users',
|
||||
ADMIN_LIBRARY_MANAGEMENT = '/admin/library-management',
|
||||
ADMIN_SETTINGS = '/admin/system-settings',
|
||||
ADMIN_MAINTENANCE_SETTINGS = '/admin/maintenance',
|
||||
ADMIN_MAINTENANCE_INTEGRITY_REPORT = '/admin/maintenance/integrity-report/',
|
||||
ADMIN_STATS = '/admin/server-status',
|
||||
ADMIN_QUEUES = '/admin/queues',
|
||||
ADMIN_REPAIR = '/admin/repair',
|
||||
|
||||
@@ -16,6 +16,30 @@
|
||||
{ title: $t('admin.memory_cleanup_job'), value: ManualJobName.MemoryCleanup },
|
||||
{ title: $t('admin.memory_generate_job'), value: ManualJobName.MemoryCreate },
|
||||
{ title: $t('admin.backup_database'), value: ManualJobName.BackupDatabase },
|
||||
{
|
||||
title: $t('admin.maintenance_integrity_missing_file_job'),
|
||||
value: ManualJobName.IntegrityMissingFiles,
|
||||
},
|
||||
{
|
||||
title: $t('admin.maintenance_integrity_orphan_file_job'),
|
||||
value: ManualJobName.IntegrityOrphanFiles,
|
||||
},
|
||||
{
|
||||
title: $t('admin.maintenance_integrity_checksum_mismatch_job'),
|
||||
value: ManualJobName.IntegrityChecksumMismatch,
|
||||
},
|
||||
{
|
||||
title: $t('admin.maintenance_integrity_missing_file_refresh_job'),
|
||||
value: ManualJobName.IntegrityMissingFilesRefresh,
|
||||
},
|
||||
{
|
||||
title: $t('admin.maintenance_integrity_orphan_file_refresh_job'),
|
||||
value: ManualJobName.IntegrityOrphanFilesRefresh,
|
||||
},
|
||||
{
|
||||
title: $t('admin.maintenance_integrity_checksum_mismatch_refresh_job'),
|
||||
value: ManualJobName.IntegrityChecksumMismatchRefresh,
|
||||
},
|
||||
].map(({ value, title }) => ({ id: value, label: title, value }));
|
||||
|
||||
let selectedJob: ComboBoxOption | undefined = $state(undefined);
|
||||
|
||||
@@ -243,6 +243,10 @@ export const asQueueItem = ($t: MessageFormatter, queue: { name: QueueName }): Q
|
||||
icon: mdiStateMachine,
|
||||
title: $t('workflow'),
|
||||
},
|
||||
[QueueName.IntegrityCheck]: {
|
||||
icon: '',
|
||||
title: $t('integrity_checks'),
|
||||
},
|
||||
};
|
||||
|
||||
return items[queue.name];
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import BottomInfo from '$lib/components/shared-components/side-bar/bottom-info.svelte';
|
||||
import { AppRoute } from '$lib/constants';
|
||||
import { NavbarItem } from '@immich/ui';
|
||||
import { mdiAccountMultipleOutline, mdiBookshelf, mdiCog, mdiServer, mdiTrayFull } from '@mdi/js';
|
||||
import { mdiAccountMultipleOutline, mdiBookshelf, mdiCog, mdiServer, mdiTrayFull, mdiWrench } from '@mdi/js';
|
||||
import { t } from 'svelte-i18n';
|
||||
</script>
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
<NavbarItem title={$t('external_libraries')} href={AppRoute.ADMIN_LIBRARY_MANAGEMENT} icon={mdiBookshelf} />
|
||||
<NavbarItem title={$t('admin.queues')} href={AppRoute.ADMIN_QUEUES} icon={mdiTrayFull} />
|
||||
<NavbarItem title={$t('settings')} href={AppRoute.ADMIN_SETTINGS} icon={mdiCog} />
|
||||
<NavbarItem title={$t('admin.maintenance_settings')} href={AppRoute.ADMIN_MAINTENANCE_SETTINGS} icon={mdiWrench} />
|
||||
<NavbarItem title={$t('server_stats')} href={AppRoute.ADMIN_STATS} icon={mdiServer} />
|
||||
</div>
|
||||
|
||||
|
||||
@@ -163,6 +163,7 @@ export const getQueueName = derived(t, ($t) => {
|
||||
[QueueName.BackupDatabase]: $t('admin.backup_database'),
|
||||
[QueueName.Ocr]: $t('admin.machine_learning_ocr'),
|
||||
[QueueName.Workflow]: $t('workflow'),
|
||||
[QueueName.IntegrityCheck]: $t('integrity_checks'),
|
||||
};
|
||||
|
||||
return names[name];
|
||||
|
||||
152
web/src/routes/admin/maintenance/+page.svelte
Normal file
152
web/src/routes/admin/maintenance/+page.svelte
Normal file
@@ -0,0 +1,152 @@
|
||||
<script lang="ts">
|
||||
import AdminPageLayout from '$lib/components/layouts/AdminPageLayout.svelte';
|
||||
import ServerStatisticsCard from '$lib/components/server-statistics/ServerStatisticsCard.svelte';
|
||||
import { AppRoute } from '$lib/constants';
|
||||
import { asyncTimeout } from '$lib/utils';
|
||||
import { handleError } from '$lib/utils/handle-error';
|
||||
import {
|
||||
createJob,
|
||||
getIntegrityReportSummary,
|
||||
getQueuesLegacy,
|
||||
IntegrityReportType,
|
||||
MaintenanceAction,
|
||||
ManualJobName,
|
||||
setMaintenanceMode,
|
||||
type IntegrityReportSummaryResponseDto,
|
||||
type QueuesResponseLegacyDto,
|
||||
} from '@immich/sdk';
|
||||
import { Button, HStack, toastManager } from '@immich/ui';
|
||||
import { mdiProgressWrench } from '@mdi/js';
|
||||
import { onDestroy, onMount } from 'svelte';
|
||||
import { t } from 'svelte-i18n';
|
||||
import type { PageData } from './$types';
|
||||
|
||||
interface Props {
|
||||
data: PageData;
|
||||
}
|
||||
|
||||
let { data }: Props = $props();
|
||||
|
||||
let integrityReport: IntegrityReportSummaryResponseDto = $state(data.integrityReport);
|
||||
|
||||
const TYPES: IntegrityReportType[] = [
|
||||
IntegrityReportType.OrphanFile,
|
||||
IntegrityReportType.MissingFile,
|
||||
IntegrityReportType.ChecksumMismatch,
|
||||
];
|
||||
|
||||
async function switchToMaintenance() {
|
||||
try {
|
||||
await setMaintenanceMode({
|
||||
setMaintenanceModeDto: {
|
||||
action: MaintenanceAction.Start,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
handleError(error, $t('admin.maintenance_start_error'));
|
||||
}
|
||||
}
|
||||
|
||||
let jobs: QueuesResponseLegacyDto | undefined = $state();
|
||||
let expectingUpdate: boolean = $state(false);
|
||||
|
||||
async function runJob(reportType: IntegrityReportType, refreshOnly?: boolean) {
|
||||
let name: ManualJobName;
|
||||
switch (reportType) {
|
||||
case IntegrityReportType.OrphanFile: {
|
||||
name = refreshOnly ? ManualJobName.IntegrityOrphanFilesRefresh : ManualJobName.IntegrityOrphanFiles;
|
||||
break;
|
||||
}
|
||||
case IntegrityReportType.MissingFile: {
|
||||
name = refreshOnly ? ManualJobName.IntegrityMissingFilesRefresh : ManualJobName.IntegrityMissingFiles;
|
||||
break;
|
||||
}
|
||||
case IntegrityReportType.ChecksumMismatch: {
|
||||
name = refreshOnly ? ManualJobName.IntegrityChecksumMismatchRefresh : ManualJobName.IntegrityChecksumMismatch;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await createJob({ jobCreateDto: { name } });
|
||||
if (jobs) {
|
||||
expectingUpdate = true;
|
||||
jobs.integrityCheck.queueStatus.isActive = true;
|
||||
}
|
||||
toastManager.success($t('admin.job_created'));
|
||||
} catch (error) {
|
||||
handleError(error, $t('errors.unable_to_submit_job'));
|
||||
}
|
||||
}
|
||||
|
||||
let running = true;
|
||||
|
||||
onMount(async () => {
|
||||
while (running) {
|
||||
jobs = await getQueuesLegacy();
|
||||
if (jobs.integrityCheck.queueStatus.isActive) {
|
||||
expectingUpdate = true;
|
||||
} else if (expectingUpdate) {
|
||||
integrityReport = await getIntegrityReportSummary();
|
||||
expectingUpdate = false;
|
||||
}
|
||||
|
||||
await asyncTimeout(2000);
|
||||
}
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
running = false;
|
||||
});
|
||||
</script>
|
||||
|
||||
<AdminPageLayout
|
||||
breadcrumbs={[{ title: data.meta.title }]}
|
||||
actions={[
|
||||
{
|
||||
title: $t('admin.maintenance_start'),
|
||||
onAction: switchToMaintenance,
|
||||
icon: mdiProgressWrench,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<section id="setting-content" class="flex place-content-center sm:mx-4">
|
||||
<section class="w-full pb-28 sm:w-5/6 md:w-[850px]">
|
||||
<p class="text-sm dark:text-immich-dark-fg uppercase">{$t('admin.maintenance_integrity_report')}</p>
|
||||
|
||||
<div class="mt-5 flex justify-between max-lg:flex-wrap gap-4">
|
||||
{#each TYPES as reportType (reportType)}
|
||||
<ServerStatisticsCard
|
||||
title={$t(`admin.maintenance_integrity_${reportType}`)}
|
||||
value={integrityReport[reportType]}
|
||||
>
|
||||
{#snippet footer()}
|
||||
<HStack gap={1} class="justify-end">
|
||||
<Button
|
||||
onclick={() => runJob(reportType)}
|
||||
size="tiny"
|
||||
variant="ghost"
|
||||
class="self-end mt-1"
|
||||
disabled={jobs?.backgroundTask.queueStatus.isActive}
|
||||
>{$t('admin.maintenance_integrity_check_all')}</Button
|
||||
>
|
||||
<Button
|
||||
onclick={() => runJob(reportType, true)}
|
||||
size="tiny"
|
||||
variant="ghost"
|
||||
class="self-end mt-1"
|
||||
disabled={jobs?.backgroundTask.queueStatus.isActive}>{$t('refresh')}</Button
|
||||
>
|
||||
<Button
|
||||
href={`${AppRoute.ADMIN_MAINTENANCE_INTEGRITY_REPORT + reportType}`}
|
||||
size="tiny"
|
||||
class="self-end mt-1">{$t('view')}</Button
|
||||
>
|
||||
</HStack>
|
||||
{/snippet}
|
||||
</ServerStatisticsCard>
|
||||
{/each}
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
</AdminPageLayout>
|
||||
17
web/src/routes/admin/maintenance/+page.ts
Normal file
17
web/src/routes/admin/maintenance/+page.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { authenticate } from '$lib/utils/auth';
|
||||
import { getFormatter } from '$lib/utils/i18n';
|
||||
import { getIntegrityReportSummary } from '@immich/sdk';
|
||||
import type { PageLoad } from './$types';
|
||||
|
||||
export const load = (async ({ url }) => {
|
||||
await authenticate(url, { admin: true });
|
||||
const integrityReport = await getIntegrityReportSummary();
|
||||
const $t = await getFormatter();
|
||||
|
||||
return {
|
||||
integrityReport,
|
||||
meta: {
|
||||
title: $t('admin.maintenance_settings'),
|
||||
},
|
||||
};
|
||||
}) satisfies PageLoad;
|
||||
@@ -0,0 +1,247 @@
|
||||
<script lang="ts">
|
||||
import AdminPageLayout from '$lib/components/layouts/AdminPageLayout.svelte';
|
||||
import { AppRoute } from '$lib/constants';
|
||||
import { asyncTimeout } from '$lib/utils';
|
||||
import { handleError } from '$lib/utils/handle-error';
|
||||
import {
|
||||
createJob,
|
||||
deleteIntegrityReport,
|
||||
getBaseUrl,
|
||||
getIntegrityReport,
|
||||
getQueuesLegacy,
|
||||
IntegrityReportType,
|
||||
ManualJobName,
|
||||
} from '@immich/sdk';
|
||||
import {
|
||||
HStack,
|
||||
IconButton,
|
||||
menuManager,
|
||||
modalManager,
|
||||
toastManager,
|
||||
type ContextMenuBaseProps,
|
||||
type MenuItems,
|
||||
} from '@immich/ui';
|
||||
import {
|
||||
mdiChevronLeft,
|
||||
mdiChevronRight,
|
||||
mdiDotsVertical,
|
||||
mdiDownload,
|
||||
mdiPageFirst,
|
||||
mdiTrashCanOutline,
|
||||
} from '@mdi/js';
|
||||
import { onDestroy, onMount } from 'svelte';
|
||||
import { t } from 'svelte-i18n';
|
||||
import { SvelteSet } from 'svelte/reactivity';
|
||||
import type { PageData } from './$types';
|
||||
|
||||
interface Props {
|
||||
data: PageData;
|
||||
}
|
||||
|
||||
let { data }: Props = $props();
|
||||
|
||||
let deleting = new SvelteSet();
|
||||
let page = $state(1);
|
||||
let integrityReport = $state(data.integrityReport);
|
||||
|
||||
async function loadPage(target: number) {
|
||||
integrityReport = await getIntegrityReport({
|
||||
integrityGetReportDto: {
|
||||
type: data.type,
|
||||
page: target,
|
||||
},
|
||||
});
|
||||
|
||||
page = target;
|
||||
}
|
||||
|
||||
async function removeAll() {
|
||||
const confirm = await modalManager.showDialog({
|
||||
confirmText: $t('delete'),
|
||||
});
|
||||
|
||||
if (confirm) {
|
||||
let name: ManualJobName;
|
||||
switch (data.type) {
|
||||
case IntegrityReportType.OrphanFile: {
|
||||
name = ManualJobName.IntegrityOrphanFilesDeleteAll;
|
||||
break;
|
||||
}
|
||||
case IntegrityReportType.MissingFile: {
|
||||
name = ManualJobName.IntegrityMissingFilesDeleteAll;
|
||||
break;
|
||||
}
|
||||
case IntegrityReportType.ChecksumMismatch: {
|
||||
name = ManualJobName.IntegrityChecksumMismatchDeleteAll;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
deleting.add('all');
|
||||
await createJob({ jobCreateDto: { name } });
|
||||
toastManager.success($t('admin.job_created'));
|
||||
} catch (error) {
|
||||
handleError(error, $t('failed_to_delete_file'));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function remove(id: string) {
|
||||
const confirm = await modalManager.showDialog({
|
||||
confirmText: $t('delete'),
|
||||
});
|
||||
|
||||
if (confirm) {
|
||||
try {
|
||||
deleting.add(id);
|
||||
await deleteIntegrityReport({
|
||||
id,
|
||||
});
|
||||
integrityReport.items = integrityReport.items.filter((report) => report.id !== id);
|
||||
} catch (error) {
|
||||
handleError(error, $t('failed_to_delete_file'));
|
||||
} finally {
|
||||
deleting.delete(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function download(reportId: string) {
|
||||
location.href = `${getBaseUrl()}/admin/maintenance/integrity/report/${reportId}/file`;
|
||||
}
|
||||
|
||||
const handleOpen = async (event: Event, props: Partial<ContextMenuBaseProps>, reportId: string) => {
|
||||
const items: MenuItems = [];
|
||||
|
||||
if (data.type === IntegrityReportType.OrphanFile || data.type === IntegrityReportType.ChecksumMismatch) {
|
||||
items.push({
|
||||
title: $t('download'),
|
||||
icon: mdiDownload,
|
||||
onAction() {
|
||||
void download(reportId);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
await menuManager.show({
|
||||
...props,
|
||||
target: event.currentTarget as HTMLElement,
|
||||
items: [
|
||||
...items,
|
||||
{
|
||||
title: $t('delete'),
|
||||
icon: mdiTrashCanOutline,
|
||||
color: 'danger',
|
||||
onAction() {
|
||||
void remove(reportId);
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
};
|
||||
|
||||
let running = true;
|
||||
let expectingUpdate = false;
|
||||
|
||||
onMount(async () => {
|
||||
while (running) {
|
||||
const jobs = await getQueuesLegacy();
|
||||
if (jobs.integrityCheck.queueStatus.isActive) {
|
||||
expectingUpdate = true;
|
||||
} else if (expectingUpdate) {
|
||||
await loadPage(page);
|
||||
expectingUpdate = false;
|
||||
}
|
||||
|
||||
await asyncTimeout(2000);
|
||||
}
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
running = false;
|
||||
});
|
||||
</script>
|
||||
|
||||
<AdminPageLayout
|
||||
breadcrumbs={[
|
||||
{ title: $t('admin.maintenance_settings'), href: AppRoute.ADMIN_MAINTENANCE_SETTINGS },
|
||||
{ title: $t('admin.maintenance_integrity_report') },
|
||||
{ title: data.meta.title },
|
||||
]}
|
||||
actions={[
|
||||
{
|
||||
title: $t('admin.download_csv'),
|
||||
icon: mdiDownload,
|
||||
onAction: () => {
|
||||
location.href = `${getBaseUrl()}/admin/maintenance/integrity/report/${data.type}/csv`;
|
||||
},
|
||||
},
|
||||
{
|
||||
title: $t('trash_page_delete_all'),
|
||||
onAction: removeAll,
|
||||
icon: mdiTrashCanOutline,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<section id="setting-content" class="flex place-content-center sm:mx-4">
|
||||
<section class="w-full pb-28 sm:w-5/6 md:w-[850px]">
|
||||
<table class="mt-5 w-full text-start">
|
||||
<thead
|
||||
class="mb-4 flex h-12 w-full rounded-md border bg-gray-50 text-primary dark:border-immich-dark-gray dark:bg-immich-dark-gray"
|
||||
>
|
||||
<tr class="flex w-full place-items-center">
|
||||
<th class="w-7/8 text-left px-2 text-sm font-medium">{$t('filename')}</th>
|
||||
<th class="w-1/8"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody
|
||||
class="block max-h-80 w-full overflow-y-auto rounded-md border dark:border-immich-dark-gray dark:text-immich-dark-fg"
|
||||
>
|
||||
{#each integrityReport.items as { id, path } (id)}
|
||||
<tr
|
||||
class={`flex py-1 w-full place-items-center even:bg-subtle/20 odd:bg-subtle/80 ${deleting.has(id) || deleting.has('all') ? 'text-gray-500' : ''}`}
|
||||
>
|
||||
<td class="w-7/8 text-ellipsis text-left px-2 text-sm select-all">{path}</td>
|
||||
<td class="w-1/8 text-ellipsis text-right flex justify-end px-2">
|
||||
<IconButton
|
||||
color="secondary"
|
||||
icon={mdiDotsVertical}
|
||||
variant="ghost"
|
||||
onclick={(event: Event) => handleOpen(event, { position: 'top-right' }, id)}
|
||||
aria-label={$t('open')}
|
||||
disabled={deleting.has(id) || deleting.has('all')}
|
||||
/></td
|
||||
>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<HStack class="mt-4 items-center justify-end">
|
||||
<IconButton
|
||||
disabled={page === 1}
|
||||
color="primary"
|
||||
icon={mdiPageFirst}
|
||||
aria-label={$t('first_page')}
|
||||
onclick={() => loadPage(1)}
|
||||
/>
|
||||
<IconButton
|
||||
disabled={page === 1}
|
||||
color="primary"
|
||||
icon={mdiChevronLeft}
|
||||
aria-label={$t('previous_page')}
|
||||
onclick={() => loadPage(page - 1)}
|
||||
/>
|
||||
<IconButton
|
||||
disabled={!integrityReport.hasNextPage}
|
||||
color="primary"
|
||||
icon={mdiChevronRight}
|
||||
aria-label={$t('next_page')}
|
||||
onclick={() => loadPage(page + 1)}
|
||||
/>
|
||||
</HStack>
|
||||
</tfoot>
|
||||
</table>
|
||||
</section>
|
||||
</section>
|
||||
</AdminPageLayout>
|
||||
@@ -0,0 +1,24 @@
|
||||
import { authenticate } from '$lib/utils/auth';
|
||||
import { getFormatter } from '$lib/utils/i18n';
|
||||
import { getIntegrityReport, IntegrityReportType } from '@immich/sdk';
|
||||
import type { PageLoad } from './$types';
|
||||
|
||||
export const load = (async ({ params, url }) => {
|
||||
const type = params.type as IntegrityReportType;
|
||||
|
||||
await authenticate(url, { admin: true });
|
||||
const integrityReport = await getIntegrityReport({
|
||||
integrityGetReportDto: {
|
||||
type,
|
||||
},
|
||||
});
|
||||
const $t = await getFormatter();
|
||||
|
||||
return {
|
||||
type,
|
||||
integrityReport,
|
||||
meta: {
|
||||
title: $t(`admin.maintenance_integrity_${type}`),
|
||||
},
|
||||
};
|
||||
}) satisfies PageLoad;
|
||||
Reference in New Issue
Block a user