Compare commits

...

41 Commits

Author SHA1 Message Date
izzy
6e7854b5bb chore: sync SQL 2025-12-03 16:46:08 +00:00
izzy
5d5d421201 fix: path -> reportId as reportId 2025-12-03 15:24:30 +00:00
izzy
7a215c16ab fix: flip deletedAt filter 2025-12-02 14:23:56 +00:00
izzy
ae653f9bf5 chore: lint 2025-12-02 14:07:14 +00:00
izzy
73a17bb58e chore: generate SQL 2025-12-02 13:54:55 +00:00
izzy
e1a1662225 chore: more compliant csv 2025-12-02 13:33:13 +00:00
izzy
6e752bed77 fix: don't process trashed/deleted assets for integrity 2025-12-02 13:19:37 +00:00
izzy
64cc64dd56 refactor: move all new queries into integrity repository 2025-12-02 13:15:48 +00:00
izzy
6cfd1994c4 feat: ability to delete all reports (and corresponding objects) 2025-12-02 11:59:23 +00:00
izzy
806a2880ca feat: assetId, fileAssetId columns on integrity reports 2025-12-01 15:49:03 +00:00
izzy
042af30bef chore: use checksum configuration 2025-12-01 14:27:04 +00:00
izzy
06fcd54b9f feat: download csv report, download file, delete file 2025-12-01 14:20:38 +00:00
izzy
fec8923431 test: increase timeouts 2025-12-01 12:07:24 +00:00
izzy
db690bcf63 chore: generate SQL 2025-12-01 11:56:01 +00:00
izzy
1daf1b471f chore: lint 2025-12-01 11:51:49 +00:00
izzy
01f96de3e5 test: serialise the buffer over events 2025-12-01 11:20:34 +00:00
izzy
c4ac8d9f63 stash: incomplete checksum outdated test 2025-11-28 18:01:24 +00:00
izzy
0362d21945 test: take baseline, check for each issue, check refreshes work 2025-11-28 17:44:48 +00:00
izzy
4d7f7b80da feat: refresh missing & checksum 2025-11-28 17:44:37 +00:00
izzy
e447ba87c6 chore: sort i18n 2025-11-28 15:28:50 +00:00
izzy
2779fce7d0 feat: manually trigger integrity jobs
feat: update summary after job runs
2025-11-28 15:27:12 +00:00
izzy
13e9cf0ed9 stash: moving computers because pnpm is cooked 2025-11-28 12:50:30 +00:00
izzy
c50118e535 chore: remove old table comment 2025-11-28 12:10:41 +00:00
izzy
ca358f4dae feat: sub-pages for integrity reports 2025-11-28 11:40:53 +00:00
izzy
d3abed3414 feat: view integrity report in maintenance page (cherry picked) 2025-11-27 17:53:20 +00:00
izzy
0fdc7b4448 feat: draft controller entry
chore: lint & format
2025-11-27 17:23:54 +00:00
izzy
8db6132669 fix: add mock for asset repo. 2025-11-27 16:42:46 +00:00
izzy
03276de6b2 fix: add integrity report repository to service depends. 2025-11-27 16:34:28 +00:00
izzy
4462683739 chore: generate SQL queries 2025-11-27 16:19:34 +00:00
izzy
919eb839ef revert: override migration db url 2025-11-27 16:14:03 +00:00
izzy
251631948b fix: mock the new repository 2025-11-27 16:11:19 +00:00
izzy
93860238af feat: add config options & cron entries for checks 2025-11-27 16:05:26 +00:00
izzy
1744237aeb chore: open api 2025-11-27 15:40:44 +00:00
izzy
ef7d8e94fa feat: check orphaned file reports are not out of date 2025-11-27 15:40:14 +00:00
izzy
cc31b9c7f1 feat: clean up old reports of checksum or missing files
refactor: combine the stream query
2025-11-27 15:13:19 +00:00
izzy
929ad529f4 feat: add createdAt to integrity report table
refactor: rename checksum_fail to checksum_mismatched
2025-11-27 15:13:00 +00:00
izzy
1e941f3f88 feat: write integrity report to database 2025-11-27 12:53:04 +00:00
izzy
15503b150a chore: open api 2025-11-27 12:01:26 +00:00
izzy
3414210450 feat: checksum job 2025-11-27 12:00:35 +00:00
izzy
4a7120cdeb refactor: batched integrity checks 2025-11-26 17:36:28 +00:00
izzy
f77f43a83d stash: integrity checks 2025-11-26 15:45:58 +00:00
50 changed files with 3952 additions and 28 deletions

View File

@@ -1,9 +1,12 @@
import { LoginResponseDto } from '@immich/sdk';
import { IntegrityReportType, LoginResponseDto, ManualJobName, QueueName } from '@immich/sdk';
import { readFile } from 'node:fs/promises';
import { createUserDto } from 'src/fixtures';
import { errorDto } from 'src/responses';
import { app, utils } from 'src/utils';
import { app, testAssetDir, utils } from 'src/utils';
import request from 'supertest';
import { beforeAll, describe, expect, it } from 'vitest';
import { afterEach, beforeAll, describe, expect, it } from 'vitest';
const assetFilepath = `${testAssetDir}/metadata/gps-position/thompson-springs.jpg`;
describe('/admin/maintenance', () => {
let cookie: string | undefined;
@@ -34,6 +37,188 @@ describe('/admin/maintenance', () => {
});
});
describe('POST /integrity/summary (& jobs)', async () => {
let baseline: Record<IntegrityReportType, number>;
beforeAll(async () => {
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}`);
});
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.BackgroundTask);
const { status, body } = await request(app)
.get('/admin/maintenance/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.BackgroundTask);
const { status, body } = await request(app)
.get('/admin/maintenance/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.BackgroundTask);
const { status, body } = await request(app)
.get('/admin/maintenance/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.BackgroundTask);
const { status, body } = await request(app)
.get('/admin/maintenance/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.BackgroundTask);
const { status, body } = await request(app)
.get('/admin/maintenance/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.BackgroundTask);
const { status, body } = await request(app)
.get('/admin/maintenance/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.BackgroundTask);
const { status, body } = await request(app)
.get('/admin/maintenance/integrity/summary')
.set('Authorization', `Bearer ${admin.accessToken}`)
.send();
expect(status).toBe(200);
expect(body).toEqual(
expect.objectContaining({
checksum_mismatch: 0,
}),
);
});
});
// => enter maintenance mode
describe.sequential('POST /', () => {
@@ -83,8 +268,8 @@ describe('/admin/maintenance', () => {
return body.maintenanceMode;
},
{
interval: 5e2,
timeout: 1e4,
interval: 500,
timeout: 60_000,
},
)
.toBeTruthy();
@@ -162,8 +347,8 @@ describe('/admin/maintenance', () => {
return body.maintenanceMode;
},
{
interval: 5e2,
timeout: 1e4,
interval: 500,
timeout: 60_000,
},
)
.toBeFalsy();

View File

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

View File

@@ -177,6 +177,16 @@
"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_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",

View File

@@ -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/maintenance/integrity/report/{id} | Delete report entry and perform corresponding deletion action
*MaintenanceAdminApi* | [**getIntegrityReport**](doc//MaintenanceAdminApi.md#getintegrityreport) | **POST** /admin/maintenance/integrity/report | Get integrity report by type
*MaintenanceAdminApi* | [**getIntegrityReportCsv**](doc//MaintenanceAdminApi.md#getintegrityreportcsv) | **GET** /admin/maintenance/integrity/report/{type}/csv | Export integrity report by type as CSV
*MaintenanceAdminApi* | [**getIntegrityReportFile**](doc//MaintenanceAdminApi.md#getintegrityreportfile) | **GET** /admin/maintenance/integrity/report/{id}/file | Download the orphan/broken file if one exists
*MaintenanceAdminApi* | [**getIntegrityReportSummary**](doc//MaintenanceAdminApi.md#getintegrityreportsummary) | **GET** /admin/maintenance/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,7 @@ Class | Method | HTTP request | Description
- [FoldersResponse](doc//FoldersResponse.md)
- [FoldersUpdate](doc//FoldersUpdate.md)
- [ImageFormat](doc//ImageFormat.md)
- [IntegrityReportType](doc//IntegrityReportType.md)
- [JobCreateDto](doc//JobCreateDto.md)
- [JobName](doc//JobName.md)
- [JobSettingsDto](doc//JobSettingsDto.md)
@@ -416,6 +422,10 @@ Class | Method | HTTP request | Description
- [MachineLearningAvailabilityChecksDto](doc//MachineLearningAvailabilityChecksDto.md)
- [MaintenanceAction](doc//MaintenanceAction.md)
- [MaintenanceAuthDto](doc//MaintenanceAuthDto.md)
- [MaintenanceGetIntegrityReportDto](doc//MaintenanceGetIntegrityReportDto.md)
- [MaintenanceIntegrityReportDto](doc//MaintenanceIntegrityReportDto.md)
- [MaintenanceIntegrityReportResponseDto](doc//MaintenanceIntegrityReportResponseDto.md)
- [MaintenanceIntegrityReportSummaryResponseDto](doc//MaintenanceIntegrityReportSummaryResponseDto.md)
- [MaintenanceLoginDto](doc//MaintenanceLoginDto.md)
- [ManualJobName](doc//ManualJobName.md)
- [MapMarkerResponseDto](doc//MapMarkerResponseDto.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)

View File

@@ -154,6 +154,7 @@ part 'model/facial_recognition_config.dart';
part 'model/folders_response.dart';
part 'model/folders_update.dart';
part 'model/image_format.dart';
part 'model/integrity_report_type.dart';
part 'model/job_create_dto.dart';
part 'model/job_name.dart';
part 'model/job_settings_dto.dart';
@@ -168,6 +169,10 @@ part 'model/logout_response_dto.dart';
part 'model/machine_learning_availability_checks_dto.dart';
part 'model/maintenance_action.dart';
part 'model/maintenance_auth_dto.dart';
part 'model/maintenance_get_integrity_report_dto.dart';
part 'model/maintenance_integrity_report_dto.dart';
part 'model/maintenance_integrity_report_response_dto.dart';
part 'model/maintenance_integrity_report_summary_response_dto.dart';
part 'model/maintenance_login_dto.dart';
part 'model/manual_job_name.dart';
part 'model/map_marker_response_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';

View File

@@ -16,6 +16,273 @@ class MaintenanceAdminApi {
final ApiClient apiClient;
/// Delete report entry and perform corresponding deletion action
///
/// ...
///
/// 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/maintenance/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 report entry and perform corresponding deletion action
///
/// ...
///
/// 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
///
/// ...
///
/// Note: This method returns the HTTP [Response].
///
/// Parameters:
///
/// * [MaintenanceGetIntegrityReportDto] maintenanceGetIntegrityReportDto (required):
Future<Response> getIntegrityReportWithHttpInfo(MaintenanceGetIntegrityReportDto maintenanceGetIntegrityReportDto,) async {
// ignore: prefer_const_declarations
final apiPath = r'/admin/maintenance/integrity/report';
// ignore: prefer_final_locals
Object? postBody = maintenanceGetIntegrityReportDto;
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
///
/// ...
///
/// Parameters:
///
/// * [MaintenanceGetIntegrityReportDto] maintenanceGetIntegrityReportDto (required):
Future<MaintenanceIntegrityReportResponseDto?> getIntegrityReport(MaintenanceGetIntegrityReportDto maintenanceGetIntegrityReportDto,) async {
final response = await getIntegrityReportWithHttpInfo(maintenanceGetIntegrityReportDto,);
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), 'MaintenanceIntegrityReportResponseDto',) as MaintenanceIntegrityReportResponseDto;
}
return null;
}
/// Export integrity report by type as 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/maintenance/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
///
/// ...
///
/// 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 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/maintenance/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 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
///
/// ...
///
/// Note: This method returns the HTTP [Response].
Future<Response> getIntegrityReportSummaryWithHttpInfo() async {
// ignore: prefer_const_declarations
final apiPath = r'/admin/maintenance/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
///
/// ...
Future<MaintenanceIntegrityReportSummaryResponseDto?> 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), 'MaintenanceIntegrityReportSummaryResponseDto',) as MaintenanceIntegrityReportSummaryResponseDto;
}
return null;
}
/// Log into maintenance mode
///
/// Login with maintenance token or cookie to receive current information and perform further actions.

View File

@@ -356,6 +356,8 @@ class ApiClient {
return FoldersUpdate.fromJson(value);
case 'ImageFormat':
return ImageFormatTypeTransformer().decode(value);
case 'IntegrityReportType':
return IntegrityReportTypeTypeTransformer().decode(value);
case 'JobCreateDto':
return JobCreateDto.fromJson(value);
case 'JobName':
@@ -384,6 +386,14 @@ class ApiClient {
return MaintenanceActionTypeTransformer().decode(value);
case 'MaintenanceAuthDto':
return MaintenanceAuthDto.fromJson(value);
case 'MaintenanceGetIntegrityReportDto':
return MaintenanceGetIntegrityReportDto.fromJson(value);
case 'MaintenanceIntegrityReportDto':
return MaintenanceIntegrityReportDto.fromJson(value);
case 'MaintenanceIntegrityReportResponseDto':
return MaintenanceIntegrityReportResponseDto.fromJson(value);
case 'MaintenanceIntegrityReportSummaryResponseDto':
return MaintenanceIntegrityReportSummaryResponseDto.fromJson(value);
case 'MaintenanceLoginDto':
return MaintenanceLoginDto.fromJson(value);
case 'ManualJobName':
@@ -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':

View File

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

View 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;
}

View File

@@ -78,6 +78,15 @@ 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 integrityReportDelete = JobName._(r'IntegrityReportDelete');
/// List of all possible values in this [enum][JobName].
static const values = <JobName>[
@@ -136,6 +145,15 @@ class JobName {
ocrQueueAll,
ocr,
workflowRun,
integrityOrphanedFilesQueueAll,
integrityOrphanedFiles,
integrityOrphanedRefresh,
integrityMissingFilesQueueAll,
integrityMissingFiles,
integrityMissingFilesRefresh,
integrityChecksumFiles,
integrityChecksumFilesRefresh,
integrityReportDelete,
];
static JobName? fromJson(dynamic value) => JobNameTypeTransformer().decode(value);
@@ -229,6 +247,15 @@ 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'IntegrityReportDelete': return JobName.integrityReportDelete;
default:
if (!allowNull) {
throw ArgumentError('Unknown enum value to decode: $data');

View File

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

View 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 MaintenanceIntegrityReportDto {
/// Returns a new [MaintenanceIntegrityReportDto] instance.
MaintenanceIntegrityReportDto({
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 MaintenanceIntegrityReportDto &&
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() => 'MaintenanceIntegrityReportDto[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 [MaintenanceIntegrityReportDto] instance and imports its values from
/// [value] if it's a [Map], null otherwise.
// ignore: prefer_constructors_over_static_methods
static MaintenanceIntegrityReportDto? fromJson(dynamic value) {
upgradeDto(value, "MaintenanceIntegrityReportDto");
if (value is Map) {
final json = value.cast<String, dynamic>();
return MaintenanceIntegrityReportDto(
id: mapValueOfType<String>(json, r'id')!,
path: mapValueOfType<String>(json, r'path')!,
type: IntegrityReportType.fromJson(json[r'type'])!,
);
}
return null;
}
static List<MaintenanceIntegrityReportDto> listFromJson(dynamic json, {bool growable = false,}) {
final result = <MaintenanceIntegrityReportDto>[];
if (json is List && json.isNotEmpty) {
for (final row in json) {
final value = MaintenanceIntegrityReportDto.fromJson(row);
if (value != null) {
result.add(value);
}
}
}
return result.toList(growable: growable);
}
static Map<String, MaintenanceIntegrityReportDto> mapFromJson(dynamic json) {
final map = <String, MaintenanceIntegrityReportDto>{};
if (json is Map && json.isNotEmpty) {
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
for (final entry in json.entries) {
final value = MaintenanceIntegrityReportDto.fromJson(entry.value);
if (value != null) {
map[entry.key] = value;
}
}
}
return map;
}
// maps a json object with a list of MaintenanceIntegrityReportDto-objects as value to a dart map
static Map<String, List<MaintenanceIntegrityReportDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
final map = <String, List<MaintenanceIntegrityReportDto>>{};
if (json is Map && json.isNotEmpty) {
// ignore: parameter_assignments
json = json.cast<String, dynamic>();
for (final entry in json.entries) {
map[entry.key] = MaintenanceIntegrityReportDto.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',
};
}

View File

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

View 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 MaintenanceIntegrityReportSummaryResponseDto {
/// Returns a new [MaintenanceIntegrityReportSummaryResponseDto] instance.
MaintenanceIntegrityReportSummaryResponseDto({
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 MaintenanceIntegrityReportSummaryResponseDto &&
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() => 'MaintenanceIntegrityReportSummaryResponseDto[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 [MaintenanceIntegrityReportSummaryResponseDto] instance and imports its values from
/// [value] if it's a [Map], null otherwise.
// ignore: prefer_constructors_over_static_methods
static MaintenanceIntegrityReportSummaryResponseDto? fromJson(dynamic value) {
upgradeDto(value, "MaintenanceIntegrityReportSummaryResponseDto");
if (value is Map) {
final json = value.cast<String, dynamic>();
return MaintenanceIntegrityReportSummaryResponseDto(
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<MaintenanceIntegrityReportSummaryResponseDto> listFromJson(dynamic json, {bool growable = false,}) {
final result = <MaintenanceIntegrityReportSummaryResponseDto>[];
if (json is List && json.isNotEmpty) {
for (final row in json) {
final value = MaintenanceIntegrityReportSummaryResponseDto.fromJson(row);
if (value != null) {
result.add(value);
}
}
}
return result.toList(growable: growable);
}
static Map<String, MaintenanceIntegrityReportSummaryResponseDto> mapFromJson(dynamic json) {
final map = <String, MaintenanceIntegrityReportSummaryResponseDto>{};
if (json is Map && json.isNotEmpty) {
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
for (final entry in json.entries) {
final value = MaintenanceIntegrityReportSummaryResponseDto.fromJson(entry.value);
if (value != null) {
map[entry.key] = value;
}
}
}
return map;
}
// maps a json object with a list of MaintenanceIntegrityReportSummaryResponseDto-objects as value to a dart map
static Map<String, List<MaintenanceIntegrityReportSummaryResponseDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
final map = <String, List<MaintenanceIntegrityReportSummaryResponseDto>>{};
if (json is Map && json.isNotEmpty) {
// ignore: parameter_assignments
json = json.cast<String, dynamic>();
for (final entry in json.entries) {
map[entry.key] = MaintenanceIntegrityReportSummaryResponseDto.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',
};
}

View File

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

View File

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

View 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',
};
}

View 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',
};
}

View 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',
};
}

View File

@@ -372,6 +372,275 @@
"x-immich-state": "Alpha"
}
},
"/admin/maintenance/integrity/report": {
"post": {
"description": "...",
"operationId": "getIntegrityReport",
"parameters": [],
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/MaintenanceGetIntegrityReportDto"
}
}
},
"required": true
},
"responses": {
"201": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/MaintenanceIntegrityReportResponseDto"
}
}
},
"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/maintenance/integrity/report/{id}": {
"delete": {
"description": "...",
"operationId": "deleteIntegrityReport",
"parameters": [
{
"name": "id",
"required": true,
"in": "path",
"schema": {
"format": "uuid",
"type": "string"
}
}
],
"responses": {
"200": {
"description": ""
}
},
"security": [
{
"bearer": []
},
{
"cookie": []
},
{
"api_key": []
}
],
"summary": "Delete report entry and perform corresponding deletion action",
"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/integrity/report/{id}/file": {
"get": {
"description": "...",
"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 the orphan/broken file if one exists",
"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/integrity/report/{type}/csv": {
"get": {
"description": "...",
"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/maintenance/integrity/summary": {
"get": {
"description": "...",
"operationId": "getIntegrityReportSummary",
"parameters": [],
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/MaintenanceIntegrityReportSummaryResponseDto"
}
}
},
"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/login": {
"post": {
"description": "Login with maintenance token or cookie to receive current information and perform further actions.",
@@ -16589,6 +16858,14 @@
],
"type": "string"
},
"IntegrityReportType": {
"enum": [
"orphan_file",
"missing_file",
"checksum_mismatch"
],
"type": "string"
},
"JobCreateDto": {
"properties": {
"name": {
@@ -16660,7 +16937,16 @@
"VersionCheck",
"OcrQueueAll",
"Ocr",
"WorkflowRun"
"WorkflowRun",
"IntegrityOrphanedFilesQueueAll",
"IntegrityOrphanedFiles",
"IntegrityOrphanedRefresh",
"IntegrityMissingFilesQueueAll",
"IntegrityMissingFiles",
"IntegrityMissingFilesRefresh",
"IntegrityChecksumFiles",
"IntegrityChecksumFilesRefresh",
"IntegrityReportDelete"
],
"type": "string"
},
@@ -16914,6 +17200,77 @@
],
"type": "object"
},
"MaintenanceGetIntegrityReportDto": {
"properties": {
"type": {
"allOf": [
{
"$ref": "#/components/schemas/IntegrityReportType"
}
]
}
},
"required": [
"type"
],
"type": "object"
},
"MaintenanceIntegrityReportDto": {
"properties": {
"id": {
"type": "string"
},
"path": {
"type": "string"
},
"type": {
"allOf": [
{
"$ref": "#/components/schemas/IntegrityReportType"
}
]
}
},
"required": [
"id",
"path",
"type"
],
"type": "object"
},
"MaintenanceIntegrityReportResponseDto": {
"properties": {
"items": {
"items": {
"$ref": "#/components/schemas/MaintenanceIntegrityReportDto"
},
"type": "array"
}
},
"required": [
"items"
],
"type": "object"
},
"MaintenanceIntegrityReportSummaryResponseDto": {
"properties": {
"checksum_mismatch": {
"type": "integer"
},
"missing_file": {
"type": "integer"
},
"orphan_file": {
"type": "integer"
}
},
"required": [
"checksum_mismatch",
"missing_file",
"orphan_file"
],
"type": "object"
},
"MaintenanceLoginDto": {
"properties": {
"token": {
@@ -16929,7 +17286,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"
},
@@ -21231,6 +21597,9 @@
"image": {
"$ref": "#/components/schemas/SystemConfigImageDto"
},
"integrityChecks": {
"$ref": "#/components/schemas/SystemConfigIntegrityChecks"
},
"job": {
"$ref": "#/components/schemas/SystemConfigJobDto"
},
@@ -21290,6 +21659,7 @@
"backup",
"ffmpeg",
"image",
"integrityChecks",
"job",
"library",
"logging",
@@ -21536,6 +21906,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": {

View File

@@ -43,6 +43,22 @@ export type ActivityStatisticsResponseDto = {
export type SetMaintenanceModeDto = {
action: MaintenanceAction;
};
export type MaintenanceGetIntegrityReportDto = {
"type": IntegrityReportType;
};
export type MaintenanceIntegrityReportDto = {
id: string;
path: string;
"type": IntegrityReportType;
};
export type MaintenanceIntegrityReportResponseDto = {
items: MaintenanceIntegrityReportDto[];
};
export type MaintenanceIntegrityReportSummaryResponseDto = {
checksum_mismatch: number;
missing_file: number;
orphan_file: number;
};
export type MaintenanceLoginDto = {
token?: string;
};
@@ -1454,6 +1470,21 @@ 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;
};
@@ -1606,6 +1637,7 @@ export type SystemConfigDto = {
backup: SystemConfigBackupsDto;
ffmpeg: SystemConfigFFmpegDto;
image: SystemConfigImageDto;
integrityChecks: SystemConfigIntegrityChecks;
job: SystemConfigJobDto;
library: SystemConfigLibraryDto;
logging: SystemConfigLoggingDto;
@@ -1862,6 +1894,69 @@ export function setMaintenanceMode({ setMaintenanceModeDto }: {
body: setMaintenanceModeDto
})));
}
/**
* Get integrity report by type
*/
export function getIntegrityReport({ maintenanceGetIntegrityReportDto }: {
maintenanceGetIntegrityReportDto: MaintenanceGetIntegrityReportDto;
}, opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchJson<{
status: 201;
data: MaintenanceIntegrityReportResponseDto;
}>("/admin/maintenance/integrity/report", oazapfts.json({
...opts,
method: "POST",
body: maintenanceGetIntegrityReportDto
})));
}
/**
* Delete report entry and perform corresponding deletion action
*/
export function deleteIntegrityReport({ id }: {
id: string;
}, opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchText(`/admin/maintenance/integrity/report/${encodeURIComponent(id)}`, {
...opts,
method: "DELETE"
}));
}
/**
* Download the orphan/broken file if one exists
*/
export function getIntegrityReportFile({ id }: {
id: string;
}, opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchBlob<{
status: 200;
data: Blob;
}>(`/admin/maintenance/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/maintenance/integrity/report/${encodeURIComponent($type)}/csv`, {
...opts
}));
}
/**
* Get integrity report summary
*/
export function getIntegrityReportSummary(opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchJson<{
status: 200;
data: MaintenanceIntegrityReportSummaryResponseDto;
}>("/admin/maintenance/integrity/summary", {
...opts
}));
}
/**
* Log into maintenance mode
*/
@@ -5142,6 +5237,11 @@ export enum MaintenanceAction {
Start = "start",
End = "end"
}
export enum IntegrityReportType {
OrphanFile = "orphan_file",
MissingFile = "missing_file",
ChecksumMismatch = "checksum_mismatch"
}
export enum NotificationLevel {
Success = "success",
Error = "error",
@@ -5378,7 +5478,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",
@@ -5486,7 +5595,16 @@ 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",
IntegrityReportDelete = "IntegrityReportDelete"
}
export enum SearchSuggestionType {
Country = "country",

View File

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

View File

@@ -1,19 +1,32 @@
import { BadRequestException, Body, Controller, Post, Res } from '@nestjs/common';
import { BadRequestException, Body, Controller, Delete, Get, Next, Param, Post, Res } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { Response } from 'express';
import { NextFunction, Response } from 'express';
import { Endpoint, HistoryBuilder } from 'src/decorators';
import { AuthDto } from 'src/dtos/auth.dto';
import { MaintenanceAuthDto, MaintenanceLoginDto, SetMaintenanceModeDto } from 'src/dtos/maintenance.dto';
import {
MaintenanceAuthDto,
MaintenanceGetIntegrityReportDto,
MaintenanceIntegrityReportResponseDto,
MaintenanceIntegrityReportSummaryResponseDto,
MaintenanceLoginDto,
SetMaintenanceModeDto,
} from 'src/dtos/maintenance.dto';
import { ApiTag, ImmichCookie, MaintenanceAction, Permission } from 'src/enum';
import { Auth, Authenticated, GetLoginDetails } from 'src/middleware/auth.guard';
import { Auth, Authenticated, FileResponse, GetLoginDetails } from 'src/middleware/auth.guard';
import { LoggingRepository } from 'src/repositories/logging.repository';
import { LoginDetails } from 'src/services/auth.service';
import { MaintenanceService } from 'src/services/maintenance.service';
import { sendFile } from 'src/utils/file';
import { respondWithCookie } from 'src/utils/response';
import { IntegrityReportTypeParamDto, UUIDParamDto } from 'src/validation';
@ApiTags(ApiTag.Maintenance)
@Controller('admin/maintenance')
export class MaintenanceController {
constructor(private service: MaintenanceService) {}
constructor(
private logger: LoggingRepository,
private service: MaintenanceService,
) {}
@Post('login')
@Endpoint({
@@ -46,4 +59,69 @@ export class MaintenanceController {
});
}
}
@Get('integrity/summary')
@Endpoint({
summary: 'Get integrity report summary',
description: '...',
history: new HistoryBuilder().added('v9.9.9').alpha('v9.9.9'),
})
@Authenticated({ permission: Permission.Maintenance, admin: true })
getIntegrityReportSummary(): Promise<MaintenanceIntegrityReportSummaryResponseDto> {
return this.service.getIntegrityReportSummary();
}
@Post('integrity/report')
@Endpoint({
summary: 'Get integrity report by type',
description: '...',
history: new HistoryBuilder().added('v9.9.9').alpha('v9.9.9'),
})
@Authenticated({ permission: Permission.Maintenance, admin: true })
getIntegrityReport(@Body() dto: MaintenanceGetIntegrityReportDto): Promise<MaintenanceIntegrityReportResponseDto> {
return this.service.getIntegrityReport(dto);
}
@Delete('integrity/report/:id')
@Endpoint({
summary: 'Delete report entry and perform corresponding deletion action',
description: '...',
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('integrity/report/:type/csv')
@Endpoint({
summary: 'Export integrity report by type as CSV',
description: '...',
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('integrity/report/:id/file')
@Endpoint({
summary: 'Download the orphan/broken file if one exists',
description: '...',
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);
}
}

View File

@@ -1,4 +1,5 @@
import { MaintenanceAction } from 'src/enum';
import { ApiProperty } from '@nestjs/swagger';
import { IntegrityReportType, MaintenanceAction } from 'src/enum';
import { ValidateEnum, ValidateString } from 'src/validation';
export class SetMaintenanceModeDto {
@@ -14,3 +15,40 @@ export class MaintenanceLoginDto {
export class MaintenanceAuthDto {
username!: string;
}
export class MaintenanceIntegrityReportSummaryResponseDto {
@ApiProperty({ type: 'integer' })
[IntegrityReportType.ChecksumFail]!: number;
@ApiProperty({ type: 'integer' })
[IntegrityReportType.MissingFile]!: number;
@ApiProperty({ type: 'integer' })
[IntegrityReportType.OrphanFile]!: number;
}
export class MaintenanceGetIntegrityReportDto {
@ValidateEnum({ enum: IntegrityReportType, name: 'IntegrityReportType' })
type!: IntegrityReportType;
// todo: paginate
// @IsInt()
// @Min(1)
// @Type(() => Number)
// @Optional()
// page?: number;
}
export class MaintenanceDeleteIntegrityReportDto {
@ValidateEnum({ enum: IntegrityReportType, name: 'IntegrityReportType' })
type!: IntegrityReportType;
}
class MaintenanceIntegrityReportDto {
id!: string;
@ValidateEnum({ enum: IntegrityReportType, name: 'IntegrityReportType' })
type!: IntegrityReportType;
path!: string;
}
export class MaintenanceIntegrityReportResponseDto {
items!: MaintenanceIntegrityReportDto[];
}

View File

@@ -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()
@@ -649,6 +686,11 @@ export class SystemConfigDto implements SystemConfig {
@IsObject()
ffmpeg!: SystemConfigFFmpegDto;
@Type(() => SystemConfigIntegrityChecks)
@ValidateNested()
@IsObject()
integrityChecks!: SystemConfigIntegrityChecks;
@Type(() => SystemConfigLoggingDto)
@ValidateNested()
@IsObject()

View File

@@ -301,6 +301,7 @@ export enum SystemMetadataKey {
SystemFlags = 'system-flags',
VersionCheckState = 'version-check-state',
License = 'license',
IntegrityChecksumCheckpoint = 'integrity-checksum-checkpoint',
}
export enum UserMetadataKey {
@@ -344,6 +345,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',
@@ -351,6 +358,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 {
@@ -637,6 +653,17 @@ export enum JobName {
// Workflow
WorkflowRun = 'WorkflowRun',
// Integrity
IntegrityOrphanedFilesQueueAll = 'IntegrityOrphanedFilesQueueAll',
IntegrityOrphanedFiles = 'IntegrityOrphanedFiles',
IntegrityOrphanedFilesRefresh = 'IntegrityOrphanedRefresh',
IntegrityMissingFilesQueueAll = 'IntegrityMissingFilesQueueAll',
IntegrityMissingFiles = 'IntegrityMissingFiles',
IntegrityMissingFilesRefresh = 'IntegrityMissingFilesRefresh',
IntegrityChecksumFiles = 'IntegrityChecksumFiles',
IntegrityChecksumFilesRefresh = 'IntegrityChecksumFilesRefresh',
IntegrityReportDelete = 'IntegrityReportDelete',
}
export enum QueueCommand {
@@ -679,6 +706,7 @@ export enum DatabaseLock {
GetSystemConfig = 69,
BackupDatabase = 42,
MemoryCreation = 777,
IntegrityCheck = 67,
}
export enum MaintenanceAction {

View File

@@ -0,0 +1,179 @@
-- 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
-- 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
"id",
"type",
"path",
"assetId",
"fileAssetId"
from
"integrity_report"
where
"type" = $1
order by
"createdAt" desc
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
"createdAt" >= $2
and "createdAt" <= $3
order by
"createdAt" asc
-- IntegrityRepository.streamIntegrityReports
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

View File

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

View File

@@ -0,0 +1,234 @@
import { Injectable } from '@nestjs/common';
import { Insertable, Kysely, sql } from 'kysely';
import { InjectKysely } from 'nestjs-kysely';
import { Readable } from 'node:stream';
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';
@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: [DummyValue.STRING] })
getIntegrityReports(type: IntegrityReportType) {
return this.db
.selectFrom('integrity_report')
.select(['id', 'type', 'path', 'assetId', 'fileAssetId'])
.where('type', '=', type)
.orderBy('createdAt', 'desc')
.execute();
}
@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: [DummyValue.STRING], stream: true })
streamIntegrityReportsCSV(type: IntegrityReportType): Readable {
const items = this.db
.selectFrom('integrity_report')
.select(['id', 'type', 'path', 'assetId', 'fileAssetId'])
.where('type', '=', type)
.orderBy('createdAt', 'desc')
.stream();
// 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());
}
@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')
.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(['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();
}
}

View File

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

View File

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

View 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;
}

View File

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

View File

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

View File

@@ -0,0 +1,631 @@
import { Injectable } from '@nestjs/common';
import { createHash } from 'node:crypto';
import { createReadStream } from 'node:fs';
import { stat } from 'node:fs/promises';
import { 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 {
AssetStatus,
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 {
IIntegrityDeleteReportJob,
IIntegrityJob,
IIntegrityMissingFilesJob,
IIntegrityOrphanedFilesJob,
IIntegrityPathWithChecksumJob,
IIntegrityPathWithReportJob,
} from 'src/types';
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,
});
}
@OnJob({ name: JobName.IntegrityOrphanedFilesQueueAll, queue: QueueName.BackgroundTask })
async handleOrphanedFilesQueueAll({ refreshOnly }: IIntegrityJob = {}): Promise<JobStatus> {
this.logger.log(`Checking for out of date orphaned file reports...`);
const reports = this.integrityRepository.streamIntegrityReports(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.BackgroundTask })
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.BackgroundTask })
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 }) =>
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.BackgroundTask })
async handleMissingFilesQueueAll({ refreshOnly }: IIntegrityJob = {}): Promise<JobStatus> {
if (refreshOnly) {
this.logger.log(`Checking for out of date missing file reports...`);
const reports = this.integrityRepository.streamIntegrityReports(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.BackgroundTask })
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) =>
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.BackgroundTask })
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 }) =>
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.BackgroundTask })
async handleChecksumFiles({ refreshOnly }: IIntegrityJob = {}): Promise<JobStatus> {
if (refreshOnly) {
this.logger.log(`Checking for out of date checksum file reports...`);
const reports = this.integrityRepository.streamIntegrityReports(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([
createReadStream(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.BackgroundTask })
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([
createReadStream(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.IntegrityReportDelete, queue: QueueName.BackgroundTask })
async handleDeleteIntegrityReport({ type }: IIntegrityDeleteReportJob): 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 report of chunk(reports, JOBS_LIBRARY_PAGINATION_SIZE)) {
// todo: queue sub-job here instead?
switch (property) {
case 'assetId': {
const ids = report.map(({ assetId }) => assetId!);
await this.assetRepository.updateAll(ids, {
deletedAt: new Date(),
status: AssetStatus.Trashed,
});
await this.eventRepository.emit('AssetTrashAll', {
assetIds: ids,
userId: '', // ???
});
await this.integrityRepository.deleteByIds(report.map(({ id }) => id));
break;
}
case 'fileAssetId': {
await this.assetRepository.deleteFiles(report.map(({ fileAssetId }) => ({ id: fileAssetId! })));
break;
}
default: {
await Promise.all(report.map(({ path }) => this.storageRepository.unlink(path).catch(() => void 0)));
await this.integrityRepository.deleteByIds(report.map(({ id }) => id));
break;
}
}
}
}
this.logger.log('Finished deleting integrity report.');
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;
}
}

View File

@@ -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.IntegrityReportDelete, data: { type: IntegrityReportType.MissingFile } };
}
case ManualJobName.IntegrityOrphanFilesDeleteAll: {
return { name: JobName.IntegrityReportDelete, data: { type: IntegrityReportType.OrphanFile } };
}
case ManualJobName.IntegrityChecksumFilesDeleteAll: {
return { name: JobName.IntegrityReportDelete, data: { type: IntegrityReportType.ChecksumFail } };
}
default: {
throw new BadRequestException('Invalid job name');
}

View File

@@ -106,4 +106,7 @@ describe(MaintenanceService.name, () => {
expect(mocks.systemMetadata.get).toHaveBeenCalledTimes(1);
});
});
describe.skip('getIntegrityReportSummary'); // just calls repository
describe.skip('getIntegrityReport'); // just calls repository
});

View File

@@ -1,9 +1,18 @@
import { Injectable } from '@nestjs/common';
import { basename } from 'node:path';
import { Readable } from 'node:stream';
import { OnEvent } from 'src/decorators';
import { MaintenanceAuthDto } from 'src/dtos/maintenance.dto';
import { SystemMetadataKey } from 'src/enum';
import { AuthDto } from 'src/dtos/auth.dto';
import {
MaintenanceAuthDto,
MaintenanceGetIntegrityReportDto,
MaintenanceIntegrityReportResponseDto,
MaintenanceIntegrityReportSummaryResponseDto,
} from 'src/dtos/maintenance.dto';
import { AssetStatus, CacheControl, IntegrityReportType, SystemMetadataKey } from 'src/enum';
import { BaseService } from 'src/services/base.service';
import { MaintenanceModeState } from 'src/types';
import { ImmichFileResponse } from 'src/utils/file';
import { createMaintenanceLoginUrl, generateMaintenanceSecret, signMaintenanceJwt } from 'src/utils/maintenance';
import { getExternalDomain } from 'src/utils/misc';
@@ -50,4 +59,52 @@ export class MaintenanceService extends BaseService {
return await createMaintenanceLoginUrl(baseUrl, auth, secret);
}
getIntegrityReportSummary(): Promise<MaintenanceIntegrityReportSummaryResponseDto> {
return this.integrityRepository.getIntegrityReportSummary();
}
async getIntegrityReport(dto: MaintenanceGetIntegrityReportDto): Promise<MaintenanceIntegrityReportResponseDto> {
return {
items: await this.integrityRepository.getIntegrityReports(dto.type),
};
}
getIntegrityReportCsv(type: IntegrityReportType): Readable {
return this.integrityRepository.streamIntegrityReportsCSV(type);
}
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);
}
}
}

View File

@@ -72,6 +72,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,

View File

@@ -10,6 +10,7 @@ import {
DatabaseSslMode,
ExifOrientation,
ImageFormat,
IntegrityReportType,
JobName,
MemoryType,
PluginTriggerType,
@@ -282,6 +283,36 @@ export interface IWorkflowJob<T extends PluginTriggerType = PluginTriggerType> {
event: WorkflowData[T];
}
export interface IIntegrityJob {
refreshOnly?: boolean;
}
export interface IIntegrityDeleteReportJob {
type?: IntegrityReportType;
}
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;
@@ -391,7 +422,18 @@ 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.IntegrityReportDelete; data: IIntegrityDeleteReportJob };
export type VectorExtension = (typeof VECTOR_EXTENSIONS)[number];
@@ -506,6 +548,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 {

View File

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

View File

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

View File

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

View File

@@ -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_JOBS = '/admin/jobs-status',
ADMIN_REPAIR = '/admin/repair',

View File

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

View File

@@ -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, mdiSync } from '@mdi/js';
import { mdiAccountMultipleOutline, mdiBookshelf, mdiCog, mdiServer, mdiSync, mdiWrench } from '@mdi/js';
import { t } from 'svelte-i18n';
</script>
@@ -11,6 +11,7 @@
<NavbarItem title={$t('users')} href={AppRoute.ADMIN_USERS} icon={mdiAccountMultipleOutline} />
<NavbarItem title={$t('jobs')} href={AppRoute.ADMIN_JOBS} icon={mdiSync} />
<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('external_libraries')} href={AppRoute.ADMIN_LIBRARY_MANAGEMENT} icon={mdiBookshelf} />
<NavbarItem title={$t('server_stats')} href={AppRoute.ADMIN_STATS} icon={mdiServer} />
</div>

View File

@@ -0,0 +1,154 @@
<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 MaintenanceIntegrityReportSummaryResponseDto,
type QueuesResponseLegacyDto,
} from '@immich/sdk';
import { Button, HStack, Text, 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: MaintenanceIntegrityReportSummaryResponseDto = $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.backgroundTask.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.backgroundTask.queueStatus.isActive) {
expectingUpdate = true;
} else if (expectingUpdate) {
integrityReport = await getIntegrityReportSummary();
}
await asyncTimeout(5000);
}
});
onDestroy(() => {
running = false;
});
</script>
<AdminPageLayout breadcrumbs={[{ title: data.meta.title }]}>
{#snippet buttons()}
<HStack gap={1}>
<Button
leadingIcon={mdiProgressWrench}
size="small"
variant="ghost"
color="secondary"
onclick={switchToMaintenance}
>
<Text class="hidden md:block">{$t('admin.maintenance_start')}</Text>
</Button>
</HStack>
{/snippet}
<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 hidden justify-between lg:flex 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}>Check All</Button
>
<Button
onclick={() => runJob(reportType, true)}
size="tiny"
variant="ghost"
class="self-end mt-1"
disabled={jobs?.backgroundTask.queueStatus.isActive}>Refresh</Button
>
<Button
href={`${AppRoute.ADMIN_MAINTENANCE_INTEGRITY_REPORT + reportType}`}
size="tiny"
class="self-end mt-1">View</Button
>
</HStack>
{/snippet}
</ServerStatisticsCard>
{/each}
</div>
</section>
</section>
</AdminPageLayout>

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

View File

@@ -0,0 +1,177 @@
<script lang="ts">
import AdminPageLayout from '$lib/components/layouts/AdminPageLayout.svelte';
import { AppRoute } from '$lib/constants';
import { handleError } from '$lib/utils/handle-error';
import { createJob, deleteIntegrityReport, getBaseUrl, IntegrityReportType, ManualJobName } from '@immich/sdk';
import {
Button,
HStack,
IconButton,
menuManager,
modalManager,
Text,
toastManager,
type ContextMenuBaseProps,
type MenuItems,
} from '@immich/ui';
import { mdiDotsVertical, mdiDownload, mdiTrashCanOutline } from '@mdi/js';
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 integrityReport = $state(data.integrityReport.items);
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, '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 = integrityReport.filter((report) => report.id !== id);
} catch (error) {
handleError(error, '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);
},
},
],
});
};
</script>
<AdminPageLayout
breadcrumbs={[
{ title: $t('admin.maintenance_settings'), href: AppRoute.ADMIN_MAINTENANCE_SETTINGS },
{ title: $t('admin.maintenance_integrity_report') },
{ title: data.meta.title },
]}
>
{#snippet buttons()}
<HStack gap={1}>
<Button
size="small"
variant="ghost"
color="secondary"
leadingIcon={mdiDownload}
href={`${getBaseUrl()}/admin/maintenance/integrity/report/${data.type}/csv`}
>
<Text class="hidden md:block">Download CSV</Text>
</Button>
<Button size="small" variant="ghost" color="danger" leadingIcon={mdiTrashCanOutline} onclick={removeAll}>
<Text class="hidden md:block">Delete All</Text>
</Button>
</HStack>
{/snippet}
<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 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>
</table>
</section>
</section>
</AdminPageLayout>

View File

@@ -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({
maintenanceGetIntegrityReportDto: {
type,
},
});
const $t = await getFormatter();
return {
type,
integrityReport,
meta: {
title: $t(`admin.maintenance_integrity_${type}`),
},
};
}) satisfies PageLoad;