feat: draft controller entry

chore: lint & format
This commit is contained in:
izzy
2025-11-27 17:23:54 +00:00
parent 8db6132669
commit 0fdc7b4448
9 changed files with 154 additions and 19 deletions

View File

@@ -323,6 +323,51 @@
} }
}, },
"/admin/maintenance": { "/admin/maintenance": {
"get": {
"description": "...",
"operationId": "getIntegrityReport",
"parameters": [],
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/MaintenanceIntegrityReportResponseDto"
}
}
},
"description": ""
}
},
"security": [
{
"bearer": []
},
{
"cookie": []
},
{
"api_key": []
}
],
"summary": "Get integrity report",
"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"
},
"post": { "post": {
"description": "Put Immich into or take it out of maintenance mode", "description": "Put Immich into or take it out of maintenance mode",
"operationId": "setMaintenanceMode", "operationId": "setMaintenanceMode",
@@ -16920,6 +16965,40 @@
], ],
"type": "object" "type": "object"
}, },
"MaintenanceIntegrityReportDto": {
"properties": {
"path": {
"type": "string"
},
"type": {
"enum": [
"orphan_file",
"missing_file",
"checksum_mismatch"
],
"type": "string"
}
},
"required": [
"path",
"type"
],
"type": "object"
},
"MaintenanceIntegrityReportResponseDto": {
"properties": {
"items": {
"items": {
"$ref": "#/components/schemas/MaintenanceIntegrityReportDto"
},
"type": "array"
}
},
"required": [
"items"
],
"type": "object"
},
"MaintenanceLoginDto": { "MaintenanceLoginDto": {
"properties": { "properties": {
"token": { "token": {

View File

@@ -251,7 +251,7 @@ export const defaults = Object.freeze<SystemConfig>({
enabled: true, enabled: true,
cronExpression: CronExpression.EVERY_DAY_AT_3AM, cronExpression: CronExpression.EVERY_DAY_AT_3AM,
timeLimit: 60 * 60 * 1000, // 1 hour timeLimit: 60 * 60 * 1000, // 1 hour
percentageLimit: 1.0, // 100% of assets percentageLimit: 1, // 100% of assets
}, },
}, },
job: { job: {

View File

@@ -1,9 +1,15 @@
import { BadRequestException, Body, Controller, Post, Res } from '@nestjs/common'; import { BadRequestException, Body, Controller, Get, Post, Res } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger'; import { ApiTags } from '@nestjs/swagger';
import { Response } from 'express'; import { Response } from 'express';
import { Endpoint, HistoryBuilder } from 'src/decorators'; import { Endpoint, HistoryBuilder } from 'src/decorators';
import { AuthDto } from 'src/dtos/auth.dto'; import { AuthDto } from 'src/dtos/auth.dto';
import { MaintenanceAuthDto, MaintenanceLoginDto, SetMaintenanceModeDto } from 'src/dtos/maintenance.dto'; import {
MaintenanceAuthDto,
MaintenanceGetIntegrityReportDto,
MaintenanceIntegrityReportResponseDto,
MaintenanceLoginDto,
SetMaintenanceModeDto,
} from 'src/dtos/maintenance.dto';
import { ApiTag, ImmichCookie, MaintenanceAction, Permission } from 'src/enum'; import { ApiTag, ImmichCookie, MaintenanceAction, Permission } from 'src/enum';
import { Auth, Authenticated, GetLoginDetails } from 'src/middleware/auth.guard'; import { Auth, Authenticated, GetLoginDetails } from 'src/middleware/auth.guard';
import { LoginDetails } from 'src/services/auth.service'; import { LoginDetails } from 'src/services/auth.service';
@@ -46,4 +52,15 @@ export class MaintenanceController {
}); });
} }
} }
@Get()
@Endpoint({
summary: 'Get integrity report',
description: '...',
history: new HistoryBuilder().added('v9.9.9').alpha('v9.9.9'),
})
@Authenticated({ permission: Permission.Maintenance, admin: true })
getIntegrityReport(dto: MaintenanceGetIntegrityReportDto): Promise<MaintenanceIntegrityReportResponseDto> {
return this.service.getIntegrityReport(dto);
}
} }

View File

@@ -1,4 +1,5 @@
import { MaintenanceAction } from 'src/enum'; import { IsEnum } from 'class-validator';
import { IntegrityReportType, MaintenanceAction } from 'src/enum';
import { ValidateEnum, ValidateString } from 'src/validation'; import { ValidateEnum, ValidateString } from 'src/validation';
export class SetMaintenanceModeDto { export class SetMaintenanceModeDto {
@@ -14,3 +15,22 @@ export class MaintenanceLoginDto {
export class MaintenanceAuthDto { export class MaintenanceAuthDto {
username!: string; username!: string;
} }
export class MaintenanceGetIntegrityReportDto {
// todo: paginate
// @IsInt()
// @Min(1)
// @Type(() => Number)
// @Optional()
// page?: number;
}
class MaintenanceIntegrityReportDto {
@IsEnum(IntegrityReportType)
type!: IntegrityReportType;
path!: string;
}
export class MaintenanceIntegrityReportResponseDto {
items!: MaintenanceIntegrityReportDto[];
}

View File

@@ -382,11 +382,11 @@ export class AssetRepository {
return items.map((asset) => asset.deviceAssetId); return items.map((asset) => asset.deviceAssetId);
} }
async getAllAssetPaths() { getAllAssetPaths() {
return this.db.selectFrom('asset').select(['originalPath', 'encodedVideoPath']).stream(); return this.db.selectFrom('asset').select(['originalPath', 'encodedVideoPath']).stream();
} }
async getAllAssetFilePaths() { getAllAssetFilePaths() {
return this.db.selectFrom('asset_file').select(['path']).stream(); return this.db.selectFrom('asset_file').select(['path']).stream();
} }

View File

@@ -1,6 +1,7 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { Insertable, Kysely } from 'kysely'; import { Insertable, Kysely } from 'kysely';
import { InjectKysely } from 'nestjs-kysely'; import { InjectKysely } from 'nestjs-kysely';
import { MaintenanceGetIntegrityReportDto, MaintenanceIntegrityReportResponseDto } from 'src/dtos/maintenance.dto';
import { DB } from 'src/schema'; import { DB } from 'src/schema';
import { IntegrityReportTable } from 'src/schema/tables/integrity-report.table'; import { IntegrityReportTable } from 'src/schema/tables/integrity-report.table';
@@ -17,6 +18,16 @@ export class IntegrityReportRepository {
.executeTakeFirst(); .executeTakeFirst();
} }
async getIntegrityReport(_dto: MaintenanceGetIntegrityReportDto): Promise<MaintenanceIntegrityReportResponseDto> {
return {
items: await this.db
.selectFrom('integrity_report')
.select(['type', 'path'])
.orderBy('createdAt', 'desc')
.execute(),
};
}
deleteById(id: string) { deleteById(id: string) {
return this.db.deleteFrom('integrity_report').where('id', '=', id).execute(); return this.db.deleteFrom('integrity_report').where('id', '=', id).execute();
} }

View File

@@ -33,7 +33,7 @@ async function* chunk<T>(generator: AsyncIterableIterator<T>, n: number) {
} }
} }
if (chunk.length) { if (chunk.length > 0) {
yield chunk; yield chunk;
} }
} }
@@ -83,17 +83,17 @@ export class IntegrityService extends BaseService {
// debug: run on boot // debug: run on boot
setImmediate(() => { setImmediate(() => {
this.jobRepository.queue({ void this.jobRepository.queue({
name: JobName.IntegrityOrphanedFilesQueueAll, name: JobName.IntegrityOrphanedFilesQueueAll,
data: {}, data: {},
}); });
this.jobRepository.queue({ void this.jobRepository.queue({
name: JobName.IntegrityMissingFilesQueueAll, name: JobName.IntegrityMissingFilesQueueAll,
data: {}, data: {},
}); });
this.jobRepository.queue({ void this.jobRepository.queue({
name: JobName.IntegrityChecksumFiles, name: JobName.IntegrityChecksumFiles,
data: {}, data: {},
}); });
@@ -101,7 +101,7 @@ export class IntegrityService extends BaseService {
} }
@OnEvent({ name: 'ConfigUpdate', server: true }) @OnEvent({ name: 'ConfigUpdate', server: true })
async onConfigUpdate({ onConfigUpdate({
newConfig: { newConfig: {
integrityChecks: { orphanedFiles, missingFiles, checksumFiles }, integrityChecks: { orphanedFiles, missingFiles, checksumFiles },
}, },
@@ -237,9 +237,9 @@ export class IntegrityService extends BaseService {
), ),
); );
const reportIds = results.filter((reportId) => reportId) as string[]; const reportIds = results.filter(Boolean) as string[];
if (reportIds.length) { if (reportIds.length > 0) {
await this.integrityReportRepository.deleteByIds(reportIds); await this.integrityReportRepository.deleteByIds(reportIds);
} }
@@ -285,12 +285,12 @@ export class IntegrityService extends BaseService {
.filter(({ exists, reportId }) => exists && reportId) .filter(({ exists, reportId }) => exists && reportId)
.map(({ reportId }) => reportId!); .map(({ reportId }) => reportId!);
if (outdatedReports.length) { if (outdatedReports.length > 0) {
await this.integrityReportRepository.deleteByIds(outdatedReports); await this.integrityReportRepository.deleteByIds(outdatedReports);
} }
const missingFiles = results.filter(({ exists }) => !exists); const missingFiles = results.filter(({ exists }) => !exists);
if (missingFiles.length) { if (missingFiles.length > 0) {
await this.integrityReportRepository.create( await this.integrityReportRepository.create(
missingFiles.map(({ path }) => ({ missingFiles.map(({ path }) => ({
type: IntegrityReportType.MissingFile, type: IntegrityReportType.MissingFile,
@@ -306,7 +306,7 @@ export class IntegrityService extends BaseService {
@OnJob({ name: JobName.IntegrityChecksumFiles, queue: QueueName.BackgroundTask }) @OnJob({ name: JobName.IntegrityChecksumFiles, queue: QueueName.BackgroundTask })
async handleChecksumFiles(): Promise<JobStatus> { async handleChecksumFiles(): Promise<JobStatus> {
const timeLimit = 60 * 60 * 1000; // 1000; const timeLimit = 60 * 60 * 1000; // 1000;
const percentageLimit = 1.0; // 0.25; const percentageLimit = 1; // 0.25;
this.logger.log( 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)`, `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)`,
@@ -390,7 +390,7 @@ export class IntegrityService extends BaseService {
} }
} while (endMarker); } while (endMarker);
this.systemMetadataRepository.set(SystemMetadataKey.IntegrityChecksumCheckpoint, { await this.systemMetadataRepository.set(SystemMetadataKey.IntegrityChecksumCheckpoint, {
date: lastCreatedAt?.toISOString(), date: lastCreatedAt?.toISOString(),
}); });

View File

@@ -1,6 +1,10 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { OnEvent } from 'src/decorators'; import { OnEvent } from 'src/decorators';
import { MaintenanceAuthDto } from 'src/dtos/maintenance.dto'; import {
MaintenanceAuthDto,
MaintenanceGetIntegrityReportDto,
MaintenanceIntegrityReportResponseDto,
} from 'src/dtos/maintenance.dto';
import { SystemMetadataKey } from 'src/enum'; import { SystemMetadataKey } from 'src/enum';
import { BaseService } from 'src/services/base.service'; import { BaseService } from 'src/services/base.service';
import { MaintenanceModeState } from 'src/types'; import { MaintenanceModeState } from 'src/types';
@@ -50,4 +54,8 @@ export class MaintenanceService extends BaseService {
return await createMaintenanceLoginUrl(baseUrl, auth, secret); return await createMaintenanceLoginUrl(baseUrl, auth, secret);
} }
getIntegrityReport(dto: MaintenanceGetIntegrityReportDto): Promise<MaintenanceIntegrityReportResponseDto> {
return this.integrityReportRepository.getIntegrityReport(dto);
}
} }

View File

@@ -85,7 +85,7 @@ const updatedConfig = Object.freeze<SystemConfig>({
enabled: true, enabled: true,
cronExpression: '0 03 * * *', cronExpression: '0 03 * * *',
timeLimit: 60 * 60 * 1000, timeLimit: 60 * 60 * 1000,
percentageLimit: 1.0, percentageLimit: 1,
}, },
}, },
logging: { logging: {