feat: integrity check jobs (missing files, untracked files, checksums) (#24205)

Co-authored-by: Daniel Dietzler <mail@ddietzler.dev>
Signed-off-by: izzy <me@insrt.uk>
This commit is contained in:
Paul Makles 2026-06-10 21:02:27 +02:00 committed by GitHub
parent 4ead3e697d
commit 74878628c8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
66 changed files with 5711 additions and 27 deletions

View file

@ -50,6 +50,22 @@ export type SystemConfig = {
enabled: boolean;
};
};
integrityChecks: {
missingFiles: {
enabled: boolean;
cronExpression: string;
};
untrackedFiles: {
enabled: boolean;
cronExpression: string;
};
checksumFiles: {
enabled: boolean;
cronExpression: string;
timeLimit: number;
percentageLimit: number;
};
};
job: Record<ConcurrentQueueName, { concurrency: number }>;
logging: {
enabled: boolean;
@ -233,6 +249,22 @@ export const defaults = Object.freeze<SystemConfig>({
enabled: false,
},
},
integrityChecks: {
missingFiles: {
enabled: true,
cronExpression: CronExpression.EVERY_DAY_AT_3AM,
},
untrackedFiles: {
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 },
@ -247,6 +279,7 @@ export const defaults = Object.freeze<SystemConfig>({
[QueueName.Notification]: { concurrency: 5 },
[QueueName.Ocr]: { concurrency: 1 },
[QueueName.Workflow]: { concurrency: 5 },
[QueueName.IntegrityCheck]: { concurrency: 1 },
[QueueName.Editor]: { concurrency: 2 },
},
logging: {

View file

@ -156,6 +156,7 @@ export const endpointTags: Record<ApiTag, string> = {
[ApiTag.Duplicates]: 'Endpoints for managing and identifying duplicate assets.',
[ApiTag.Faces]:
'A face is a detected human face within an asset, which can be associated with a person. Faces are normally detected via machine learning, but can also be created via manually.',
[ApiTag.Integrity]: 'Endpoints for viewing and managing integrity reports.',
[ApiTag.Jobs]:
'Queues and background jobs are used for processing tasks asynchronously. Queues can be paused and resumed as needed.',
[ApiTag.Libraries]:

View file

@ -10,6 +10,7 @@ import { DatabaseBackupController } from 'src/controllers/database-backup.contro
import { DownloadController } from 'src/controllers/download.controller';
import { DuplicateController } from 'src/controllers/duplicate.controller';
import { FaceController } from 'src/controllers/face.controller';
import { IntegrityAdminController } from 'src/controllers/integrity-admin.controller';
import { JobController } from 'src/controllers/job.controller';
import { LibraryController } from 'src/controllers/library.controller';
import { MaintenanceController } from 'src/controllers/maintenance.controller';
@ -52,6 +53,7 @@ export const controllers = [
DownloadController,
DuplicateController,
FaceController,
IntegrityAdminController,
JobController,
LibraryController,
MaintenanceController,

View file

@ -0,0 +1,91 @@
import { Controller, Delete, Get, HttpCode, HttpStatus, Next, Param, Query, Res } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { NextFunction, Response } from 'express';
import { Endpoint, HistoryBuilder } from 'src/decorators';
import { AuthDto } from 'src/dtos/auth.dto';
import {
IntegrityGetReportDto,
IntegrityReportResponseDto,
IntegrityReportSummaryResponseDto,
} from 'src/dtos/integrity.dto';
import { ApiTag, Permission } from 'src/enum';
import { Auth, Authenticated, FileResponse } from 'src/middleware/auth.guard';
import { LoggingRepository } from 'src/repositories/logging.repository';
import { IntegrityService } from 'src/services/integrity.service';
import { sendFile } from 'src/utils/file';
import { IntegrityReportTypeParamDto, UUIDv7ParamDto } from 'src/validation';
@ApiTags(ApiTag.Maintenance)
@Controller('admin/integrity')
export class IntegrityAdminController {
constructor(
private logger: LoggingRepository,
private service: IntegrityService,
) {}
@Get('summary')
@Endpoint({
summary: 'Get integrity report summary',
description: 'Get a count of the items flagged in each integrity report',
history: new HistoryBuilder().added('v3.0.0').alpha('v3.0.0'),
})
@Authenticated({ permission: Permission.Maintenance, admin: true })
getIntegrityReportSummary(): Promise<IntegrityReportSummaryResponseDto> {
return this.service.getIntegrityReportSummary();
}
@Get('report')
@HttpCode(HttpStatus.OK)
@Endpoint({
summary: 'Get integrity report by type',
description: 'Get all flagged items by integrity report type',
history: new HistoryBuilder().added('v3.0.0').alpha('v3.0.0'),
})
@Authenticated({ permission: Permission.Maintenance, admin: true })
getIntegrityReport(@Query() dto: IntegrityGetReportDto): Promise<IntegrityReportResponseDto> {
return this.service.getIntegrityReport(dto);
}
@Get('report/:id/file')
@Endpoint({
summary: 'Download flagged file',
description: 'Download the untracked/broken file if one exists',
history: new HistoryBuilder().added('v3.0.0').alpha('v3.0.0'),
})
@FileResponse()
@Authenticated({ permission: Permission.Maintenance, admin: true })
async getIntegrityReportFile(
@Param() { id }: UUIDv7ParamDto,
@Res() res: Response,
@Next() next: NextFunction,
): Promise<void> {
await sendFile(res, next, () => this.service.getIntegrityReportFile(id), this.logger);
}
@Delete('report/:id')
@Endpoint({
summary: 'Delete integrity report item',
description: 'Delete a given report item and perform corresponding deletion (e.g. trash asset, delete file)',
history: new HistoryBuilder().added('v3.0.0').alpha('v3.0.0'),
})
@Authenticated({ permission: Permission.Maintenance, admin: true })
async deleteIntegrityReport(@Auth() auth: AuthDto, @Param() { id }: UUIDv7ParamDto): Promise<void> {
await this.service.deleteIntegrityReport(auth.user.id, id);
}
@Get('report/:type/csv')
@Endpoint({
summary: 'Export integrity report by type as CSV',
description: 'Get all integrity report entries for a given type as a CSV',
history: new HistoryBuilder().added('v3.0.0').alpha('v3.0.0'),
})
@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);
}
}

View file

@ -0,0 +1,41 @@
import { createZodDto } from 'nestjs-zod';
import { IntegrityReport, IntegrityReportSchema } from 'src/enum';
import z from 'zod';
const IntegrityReportSummaryResponseSchema = z
.object({
[IntegrityReport.ChecksumFail]: z.int().nonnegative(),
[IntegrityReport.MissingFile]: z.int().nonnegative(),
[IntegrityReport.UntrackedFile]: z.int().nonnegative(),
})
.meta({ id: 'IntegrityReportSummaryResponseDto' });
export class IntegrityReportSummaryResponseDto extends createZodDto(IntegrityReportSummaryResponseSchema) {}
const IntegrityGetReportSchema = z
.object({
type: IntegrityReportSchema,
cursor: z.string().optional().describe('Cursor for pagination'),
limit: z.int().positive().default(500).optional().describe('Number of items per page'),
})
.meta({ id: 'IntegrityGetReportDto' });
export class IntegrityGetReportDto extends createZodDto(IntegrityGetReportSchema) {}
const IntegrityDeleteReportSchema = z.object({ type: IntegrityReport }).meta({ id: 'IntegrityDeleteReportDto' });
export class IntegrityDeleteReportDto extends createZodDto(IntegrityDeleteReportSchema) {}
const IntegrityReportResponseItemSchema = z.object({
id: z.string().describe('Integrity report item id'),
type: IntegrityReportSchema,
path: z.string().describe('Integrity report item path'),
});
const IntegrityReportResponseSchema = z
.object({
items: z.array(IntegrityReportResponseItemSchema),
nextCursor: z.string().optional(),
})
.meta({ id: 'IntegrityReportResponseDto' });
export class IntegrityReportResponseDto extends createZodDto(IntegrityReportResponseSchema) {}

View file

@ -37,6 +37,7 @@ const QueuesResponseLegacySchema = z
[QueueName.Ocr]: QueueResponseLegacySchema,
[QueueName.Workflow]: QueueResponseLegacySchema,
[QueueName.Editor]: QueueResponseLegacySchema,
[QueueName.IntegrityCheck]: QueueResponseLegacySchema,
})
.meta({ id: 'QueuesResponseLegacyDto' });

View file

@ -54,6 +54,30 @@ const DatabaseBackupSchema = z
})
.meta({ id: 'DatabaseBackupConfig' });
const SystemConfigIntegrityJobSchema = z
.object({
enabled: z.boolean().describe('Enabled'),
cronExpression: cronExpressionSchema.describe('Cron expression for when the integrity check should run'),
})
.describe('Integrity job config')
.meta({ id: 'SystemConfigIntegrityJob' });
const SystemConfigIntegrityChecksumJobSchema = SystemConfigIntegrityJobSchema.extend({
timeLimit: z.int().nonnegative().describe('How long the integrity checksum job may run for'),
percentageLimit: z.int().nonnegative().describe('Percentage limit of the integrity checksum job'),
})
.describe('Integrity checksum job config')
.meta({ id: 'SystemConfigIntegrityChecksumJob' });
const SystemConfigIntegrityChecksSchema = z
.object({
missingFiles: SystemConfigIntegrityJobSchema,
untrackedFiles: SystemConfigIntegrityJobSchema,
checksumFiles: SystemConfigIntegrityChecksumJobSchema,
})
.describe('Integrity checks config')
.meta({ id: 'SystemConfigIntegrityChecks' });
const SystemConfigBackupsSchema = z.object({ database: DatabaseBackupSchema }).meta({ id: 'SystemConfigBackupsDto' });
const SystemConfigFFmpegSchema = z
@ -103,6 +127,7 @@ const SystemConfigJobSchema = z
ocr: JobSettingsSchema,
workflow: JobSettingsSchema,
editor: JobSettingsSchema,
integrityCheck: JobSettingsSchema,
})
.meta({ id: 'SystemConfigJobDto' });
@ -382,6 +407,7 @@ export const SystemConfigSchema = z
templates: SystemConfigTemplatesSchema,
server: SystemConfigServerSchema,
user: SystemConfigUserSchema,
integrityChecks: SystemConfigIntegrityChecksSchema,
})
.describe('System configuration')
.meta({ id: 'SystemConfigDto' });

View file

@ -341,6 +341,7 @@ export enum SystemMetadataKey {
SystemFlags = 'system-flags',
VersionCheckState = 'version-check-state',
License = 'license',
IntegrityChecksumCheckpoint = 'integrity-checksum-checkpoint',
}
export enum UserMetadataKey {
@ -398,6 +399,17 @@ export enum SourceType {
export const SourceTypeSchema = z.enum(SourceType).describe('Face detection source type').meta({ id: 'SourceType' });
export enum IntegrityReport {
UntrackedFile = 'untracked_file',
MissingFile = 'missing_file',
ChecksumFail = 'checksum_mismatch',
}
export const IntegrityReportSchema = z
.enum(IntegrityReport)
.describe('Integrity report type')
.meta({ id: 'IntegrityReport' });
export enum ManualJobName {
PersonCleanup = 'person-cleanup',
TagCleanup = 'tag-cleanup',
@ -405,6 +417,15 @@ export enum ManualJobName {
MemoryCleanup = 'memory-cleanup',
MemoryCreate = 'memory-create',
BackupDatabase = 'backup-database',
IntegrityMissingFiles = `integrity-missing-files`,
IntegrityUntrackedFiles = `integrity-untracked-files`,
IntegrityChecksumFiles = `integrity-checksum-mismatch`,
IntegrityMissingFilesRefresh = `integrity-missing-files-refresh`,
IntegrityUntrackedFilesRefresh = `integrity-untracked-files-refresh`,
IntegrityChecksumFilesRefresh = `integrity-checksum-mismatch-refresh`,
IntegrityMissingFilesDeleteAll = `integrity-missing-files-delete-all`,
IntegrityUntrackedFilesDeleteAll = `integrity-untracked-files-delete-all`,
IntegrityChecksumFilesDeleteAll = `integrity-checksum-mismatch-delete-all`,
}
export const ManualJobNameSchema = z.enum(ManualJobName).describe('Manual job name').meta({ id: 'ManualJobName' });
@ -771,6 +792,7 @@ export enum QueueName {
BackupDatabase = 'backupDatabase',
Ocr = 'ocr',
Workflow = 'workflow',
IntegrityCheck = 'integrityCheck',
Editor = 'editor',
}
@ -866,6 +888,18 @@ export enum JobName {
// Workflow
WorkflowAssetTrigger = 'WorkflowAssetTrigger',
// Integrity
IntegrityUntrackedFilesQueueAll = 'IntegrityUntrackedFilesQueueAll',
IntegrityUntrackedFiles = 'IntegrityUntrackedFiles',
IntegrityUntrackedFilesRefresh = 'IntegrityUntrackedRefresh',
IntegrityMissingFilesQueueAll = 'IntegrityMissingFilesQueueAll',
IntegrityMissingFiles = 'IntegrityMissingFiles',
IntegrityMissingFilesRefresh = 'IntegrityMissingFilesRefresh',
IntegrityChecksumFiles = 'IntegrityChecksumFiles',
IntegrityChecksumFilesRefresh = 'IntegrityChecksumFilesRefresh',
IntegrityDeleteReportType = 'IntegrityDeleteReportType',
IntegrityDeleteReports = 'IntegrityDeleteReports',
}
export const JobNameSchema = z.enum(JobName).describe('Job name').meta({ id: 'JobName' });
@ -917,6 +951,7 @@ export enum DatabaseLock {
BackupDatabase = 42,
MaintenanceOperation = 621,
MemoryCreation = 777,
IntegrityCheck = 67,
VersionCheck = 800,
HlsSessionCleanup = 850,
}
@ -1131,6 +1166,7 @@ export enum ApiTag {
Download = 'Download',
Duplicates = 'Duplicates',
Faces = 'Faces',
Integrity = 'Integrity (admin)',
Jobs = 'Jobs',
Libraries = 'Libraries',
Maintenance = 'Maintenance (admin)',

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
"type",
count(*) as "count"
from
"integrity_report"
group by
"type"
-- IntegrityRepository.getIntegrityReport
select
"id",
"type",
"path",
"assetId",
"fileAssetId",
"createdAt"
from
"integrity_report"
where
"type" = $1
and "id" <= $2
order by
"id" desc
limit
$3
-- IntegrityRepository.getAssetPathsByPaths
select
"asset"."originalPath",
"asset_file"."path" as "encodedVideoPath"
from
"asset"
left join "asset_file" on "asset"."id" = "asset_file"."assetId"
and "asset_file"."type" = $1
where
(
"originalPath" in $2
or "asset_file"."path" in $3
)
-- IntegrityRepository.getAssetFilePathsByPaths
select
"path"
from
"asset_file"
where
"path" in $1
-- IntegrityRepository.getPersonThumbnailPathsByPaths
select
"person"."thumbnailPath"
from
"person"
where
"person"."thumbnailPath" in $1
-- IntegrityRepository.getAssetCount
select
count(*) as "count"
from
"asset"
-- IntegrityRepository.streamAllAssetPaths
select
"originalPath",
"asset_file"."path" as "encodedVideoPath"
from
"asset"
left join "asset_file" on "asset"."id" = "asset_file"."assetId"
and "asset_file"."type" = $1
-- 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
"path",
null::uuid as "assetId",
"asset_file"."id" as "fileAssetId"
from
"asset_file"
) as "allPaths"
left join "integrity_report" on "integrity_report"."type" = $1
and (
"integrity_report"."assetId" = "allPaths"."assetId"
or "integrity_report"."fileAssetId" = "allPaths"."fileAssetId"
)
-- IntegrityRepository.streamAssetChecksums
select
"asset"."originalPath",
"asset"."checksum",
"asset"."createdAt",
"asset"."id" as "assetId",
"integrity_report"."id" as "reportId"
from
"asset"
left join "integrity_report" on "integrity_report"."assetId" = "asset"."id"
and "integrity_report"."type" = $1
where
"asset"."deletedAt" is null
and "createdAt" >= $2
and "createdAt" <= $3
order by
"createdAt" asc
-- IntegrityRepository.streamIntegrityReports
select
"id",
"type",
"path",
"assetId",
"fileAssetId"
from
"integrity_report"
where
"type" = $1
order by
"createdAt" desc
-- IntegrityRepository.streamIntegrityReportsWithAssetChecksum
select
"integrity_report"."id" as "reportId",
"integrity_report"."path"
from
"integrity_report"
where
"integrity_report"."type" = $1
-- IntegrityRepository.streamIntegrityReportsByProperty
select
"id",
"path",
"assetId",
"fileAssetId"
from
"integrity_report"
where
"abcdefghi" is not null
-- IntegrityRepository.deleteById
delete from "integrity_report"
where
"id" = $1
-- IntegrityRepository.deleteByIds
delete from "integrity_report"
where
"id" in $1

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';
@ -69,6 +70,7 @@ export const repositories = [
DuplicateRepository,
EmailRepository,
EventRepository,
IntegrityRepository,
JobRepository,
LibraryRepository,
LoggingRepository,

View file

@ -0,0 +1,228 @@
import { Injectable } from '@nestjs/common';
import { Insertable, Kysely, sql } from 'kysely';
import { InjectKysely } from 'nestjs-kysely';
import { DummyValue, GenerateSql } from 'src/decorators';
import { AssetFileType, IntegrityReport } from 'src/enum';
import { DB } from 'src/schema';
import { IntegrityReportTable } from 'src/schema/tables/integrity-report.table';
export type ReportPaginationOptions = {
cursor?: string;
limit: number;
};
@Injectable()
export class IntegrityRepository {
constructor(@InjectKysely() private db: Kysely<DB>) {}
create(dto: Insertable<IntegrityReportTable> | Insertable<IntegrityReportTable>[]) {
return this.db
.insertInto('integrity_report')
.values(dto)
.onConflict((oc) =>
oc.columns(['path', 'type']).doUpdateSet({
assetId: (eb) => eb.ref('excluded.assetId'),
fileAssetId: (eb) => eb.ref('excluded.fileAssetId'),
}),
)
.returningAll()
.executeTakeFirstOrThrow();
}
@GenerateSql({ params: [DummyValue.STRING] })
getById(id: string) {
return this.db
.selectFrom('integrity_report')
.selectAll('integrity_report')
.where('id', '=', id)
.executeTakeFirstOrThrow();
}
@GenerateSql({ params: [] })
async getIntegrityReportSummary() {
const counts = await this.db
.selectFrom('integrity_report')
.select(['type', this.db.fn.countAll<number>().as('count')])
.groupBy('type')
.execute();
return Object.fromEntries(
Object.values(IntegrityReport).map((type) => [type, counts.find((count) => count.type === type)?.count || 0]),
) as Record<IntegrityReport, number>;
}
@GenerateSql({ params: [{ cursor: DummyValue.NUMBER, limit: 100 }, DummyValue.STRING] })
async getIntegrityReport(pagination: ReportPaginationOptions, type: IntegrityReport) {
const items = await this.db
.selectFrom('integrity_report')
.select(['id', 'type', 'path', 'assetId', 'fileAssetId', 'createdAt'])
.where('type', '=', type)
.$if(pagination.cursor !== undefined, (eb) => eb.where('id', '<=', pagination.cursor!))
.orderBy('id', 'desc')
.limit(pagination.limit + 1)
.execute();
return {
items: items.slice(0, pagination.limit),
nextCursor: items.at(pagination.limit)?.id,
};
}
@GenerateSql({ params: [DummyValue.STRING] })
getAssetPathsByPaths(paths: string[]) {
return this.db
.selectFrom('asset')
.leftJoin('asset_file', (join) =>
join.onRef('asset.id', '=', 'asset_file.assetId').on('asset_file.type', '=', AssetFileType.EncodedVideo),
)
.select(['asset.originalPath', 'asset_file.path as encodedVideoPath'])
.where((eb) => eb.or([eb('originalPath', 'in', paths), eb('asset_file.path', '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: [DummyValue.STRING] })
getPersonThumbnailPathsByPaths(paths: string[]) {
return this.db
.selectFrom('person')
.select('person.thumbnailPath')
.where('person.thumbnailPath', 'in', paths)
.execute();
}
@GenerateSql({ params: [] })
getAssetCount() {
return this.db
.selectFrom('asset')
.select((eb) => eb.fn.countAll<number>().as('count'))
.executeTakeFirstOrThrow();
}
@GenerateSql({ params: [], stream: true })
streamAllAssetPaths() {
return this.db
.selectFrom('asset')
.leftJoin('asset_file', (join) =>
join.onRef('asset.id', '=', 'asset_file.assetId').on('asset_file.type', '=', AssetFileType.EncodedVideo),
)
.select(['originalPath', 'asset_file.path as 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_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', '=', IntegrityReport.UntrackedFile)
.on((eb) =>
eb.or([
eb('integrity_report.assetId', '=', eb.ref('allPaths.assetId')),
eb('integrity_report.fileAssetId', '=', eb.ref('allPaths.fileAssetId')),
]),
),
)
.select(['allPaths.path as path', 'allPaths.assetId', 'allPaths.fileAssetId', 'integrity_report.id as reportId'])
.stream() as AsyncIterableIterator<
{ path: string; reportId: string | null } & (
| { assetId: string; fileAssetId: null }
| { assetId: null; fileAssetId: string }
)
>;
}
@GenerateSql({ params: [DummyValue.DATE, DummyValue.DATE], stream: true })
streamAssetChecksums(startMarker?: Date, endMarker?: Date) {
return this.db
.selectFrom('asset')
.where('asset.deletedAt', 'is', null)
.leftJoin('integrity_report', (join) =>
join
.onRef('integrity_report.assetId', '=', 'asset.id')
.on('integrity_report.type', '=', IntegrityReport.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: IntegrityReport) {
return this.db
.selectFrom('integrity_report')
.select(['id', 'type', 'path', 'assetId', 'fileAssetId'])
.where('type', '=', type)
.orderBy('createdAt', 'desc')
.stream();
}
@GenerateSql({ params: [DummyValue.STRING], stream: true })
streamIntegrityReportsWithAssetChecksum(type: IntegrityReport) {
return this.db
.selectFrom('integrity_report')
.select(['integrity_report.id as reportId', 'integrity_report.path'])
.where('integrity_report.type', '=', type)
.$if(type === IntegrityReport.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?: IntegrityReport) {
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

@ -49,6 +49,7 @@ import { AssetOcrTable } from 'src/schema/tables/asset-ocr.table';
import { AssetTable } from 'src/schema/tables/asset.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';
@ -115,6 +116,7 @@ export class ImmichDatabase {
AssetExifTable,
FaceSearchTable,
GeodataPlacesTable,
IntegrityReportTable,
LibraryTable,
MemoryTable,
MemoryAuditTable,
@ -219,6 +221,8 @@ export interface DB {
geodata_places: GeodataPlacesTable;
integrity_report: IntegrityReportTable;
library: LibraryTable;
memory: MemoryTable;

View file

@ -0,0 +1,24 @@
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 immich_uuid_v7(),
"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);
await sql`CREATE INDEX "asset_createdAt_idx" ON "asset" ("createdAt");`.execute(db);
}
export async function down(db: Kysely<any>): Promise<void> {
await sql`DROP TABLE "integrity_report";`.execute(db);
await sql`DROP INDEX "asset_createdAt_idx";`.execute(db);
}

View file

@ -98,7 +98,7 @@ export class AssetTable {
@UpdateDateColumn()
updatedAt!: Generated<Timestamp>;
@CreateDateColumn()
@CreateDateColumn({ index: true })
createdAt!: Generated<Timestamp>;
@Column({ index: true })

View file

@ -0,0 +1,27 @@
import { Column, CreateDateColumn, ForeignKeyColumn, Generated, Table, Timestamp, Unique } from '@immich/sql-tools';
import { PrimaryGeneratedUuidV7Column } from 'src/decorators';
import { IntegrityReport } from 'src/enum';
import { AssetFileTable } from 'src/schema/tables/asset-file.table';
import { AssetTable } from 'src/schema/tables/asset.table';
@Table('integrity_report')
@Unique({ columns: ['type', 'path'] })
export class IntegrityReportTable {
@PrimaryGeneratedUuidV7Column()
id!: Generated<string>;
@Column()
type!: IntegrityReport;
@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';
@ -81,6 +82,7 @@ export const BASE_SERVICE_DEPENDENCIES = [
DuplicateRepository,
EmailRepository,
EventRepository,
IntegrityRepository,
JobRepository,
LibraryRepository,
MachineLearningRepository,
@ -140,6 +142,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,
@ -208,6 +211,7 @@ export class BaseService {
ctx.duplicateRepository,
ctx.emailRepository,
ctx.eventRepository,
ctx.integrityRepository,
ctx.jobRepository,
ctx.libraryRepository,
ctx.machineLearningRepository,

View file

@ -12,6 +12,7 @@ import { DatabaseService } from 'src/services/database.service';
import { DownloadService } from 'src/services/download.service';
import { DuplicateService } from 'src/services/duplicate.service';
import { HlsService } from 'src/services/hls.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';
@ -63,6 +64,7 @@ export const services = [
DatabaseService,
DownloadService,
DuplicateService,
IntegrityService,
HlsService,
JobService,
LibraryService,

View file

@ -0,0 +1,29 @@
import { IntegrityService } from 'src/services/integrity.service';
import { newTestService, ServiceMocks } from 'test/utils';
describe(IntegrityService.name, () => {
let sut: IntegrityService;
let mocks: ServiceMocks;
beforeEach(() => {
({ sut, mocks } = newTestService(IntegrityService));
});
it('should work', () => {
expect(sut).toBeDefined();
});
describe('handleDeleteAllIntegrityReports', () => {
beforeEach(() => {
mocks.integrityReport.streamIntegrityReportsByProperty.mockReturnValue((function* () {})() as never);
});
it('should query all property types when no type specified', async () => {
await sut.handleDeleteAllIntegrityReports({});
expect(mocks.integrityReport.streamIntegrityReportsByProperty).toHaveBeenCalledWith(undefined, undefined);
expect(mocks.integrityReport.streamIntegrityReportsByProperty).toHaveBeenCalledWith('assetId', undefined);
expect(mocks.integrityReport.streamIntegrityReportsByProperty).toHaveBeenCalledWith('fileAssetId', undefined);
});
});
});

View file

@ -0,0 +1,724 @@
import { Injectable } from '@nestjs/common';
import { createHash } from 'node:crypto';
import { basename } from 'node:path';
import { Readable, Writable } from 'node:stream';
import { pipeline } from 'node:stream/promises';
import { JOBS_LIBRARY_PAGINATION_SIZE } from 'src/constants';
import { StorageCore } from 'src/cores/storage.core';
import { OnEvent, OnJob } from 'src/decorators';
import {
IntegrityGetReportDto,
IntegrityReportResponseDto,
IntegrityReportSummaryResponseDto,
} from 'src/dtos/integrity.dto';
import {
AssetStatus,
CacheControl,
DatabaseLock,
ImmichWorker,
IntegrityReport,
JobName,
JobStatus,
QueueName,
StorageFolder,
SystemMetadataKey,
} from 'src/enum';
import { ArgOf } from 'src/repositories/event.repository';
import { BaseService } from 'src/services/base.service';
import {
IIntegrityDeleteReportsJob,
IIntegrityDeleteReportTypeJob,
IIntegrityJob,
IIntegrityMissingFilesJob,
IIntegrityPathWithChecksumJob,
IIntegrityPathWithReportJob,
IIntegrityUntrackedFilesJob,
} from 'src/types';
import { ImmichFileResponse } from 'src/utils/file';
import { handlePromiseError } from 'src/utils/misc';
/**
* Untracked 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: { untrackedFiles, missingFiles, checksumFiles },
},
}: ArgOf<'ConfigInit'>) {
this.integrityLock = await this.databaseRepository.tryLock(DatabaseLock.IntegrityCheck);
if (!this.integrityLock) {
return;
}
this.cronRepository.create({
name: 'integrityUntrackedFiles',
expression: untrackedFiles.cronExpression,
onTick: () =>
handlePromiseError(
this.jobRepository.queue({ name: JobName.IntegrityUntrackedFilesQueueAll, data: {} }),
this.logger,
),
start: untrackedFiles.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,
});
}
@OnEvent({ name: 'ConfigUpdate', server: true })
onConfigUpdate({
newConfig: {
integrityChecks: { untrackedFiles, missingFiles, checksumFiles },
},
}: ArgOf<'ConfigUpdate'>) {
if (!this.integrityLock) {
return;
}
this.cronRepository.update({
name: 'integrityUntrackedFiles',
expression: untrackedFiles.cronExpression,
start: untrackedFiles.enabled,
});
this.cronRepository.update({
name: 'integrityMissingFiles',
expression: missingFiles.cronExpression,
start: missingFiles.enabled,
});
this.cronRepository.update({
name: 'integrityChecksumFiles',
expression: checksumFiles.cronExpression,
start: checksumFiles.enabled,
});
}
getIntegrityReportSummary(): Promise<IntegrityReportSummaryResponseDto> {
return this.integrityRepository.getIntegrityReportSummary();
}
getIntegrityReport(dto: IntegrityGetReportDto): Promise<IntegrityReportResponseDto> {
return this.integrityRepository.getIntegrityReport({ cursor: dto.cursor, limit: dto.limit ?? 100 }, dto.type);
}
getIntegrityReportCsv(type: IntegrityReport): Readable {
const items = this.integrityRepository.streamIntegrityReports(type);
// very rudimentary csv serialiser
async function* generator() {
yield 'id,type,assetId,fileAssetId,path\n';
for await (const item of items) {
// no expectation of particularly bad filenames
// but they could potentially have a newline or quote character
yield `${item.id},${item.type},${item.assetId},${item.fileAssetId},"${item.path.replaceAll('"', '""')}"\n`;
}
}
return Readable.from(generator());
}
async getIntegrityReportFile(id: string): Promise<ImmichFileResponse> {
const { path } = await this.integrityRepository.getById(id);
return new ImmichFileResponse({
path,
fileName: basename(path),
contentType: 'application/octet-stream',
cacheControl: CacheControl.PrivateWithoutCache,
});
}
async deleteIntegrityReport(userId: string, 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,
});
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);
}
}
private async queueRefreshAllUntrackedFiles() {
this.logger.log(`Checking for out of date untracked file reports...`);
const reports = this.integrityRepository.streamIntegrityReportsWithAssetChecksum(IntegrityReport.UntrackedFile);
let total = 0;
for await (const batchReports of chunk(reports, JOBS_LIBRARY_PAGINATION_SIZE)) {
await this.jobRepository.queue({
name: JobName.IntegrityUntrackedFilesRefresh,
data: {
items: batchReports,
},
});
total += batchReports.length;
this.logger.log(`Queued report check of ${batchReports.length} report(s) (${total} so far)`);
}
}
@OnJob({ name: JobName.IntegrityUntrackedFilesQueueAll, queue: QueueName.IntegrityCheck })
async handleUntrackedFilesQueueAll({ refreshOnly }: IIntegrityJob = {}): Promise<JobStatus> {
await this.queueRefreshAllUntrackedFiles();
if (refreshOnly) {
this.logger.log('Refresh complete.');
return JobStatus.Success;
}
this.logger.log(`Scanning for untracked 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;
}
}
let total = 0;
for await (const [batchType, batchPaths] of paths()) {
await this.jobRepository.queue({
name: JobName.IntegrityUntrackedFiles,
data: {
type: batchType,
paths: batchPaths,
},
});
const count = batchPaths.length;
total += count;
this.logger.log(`Queued untracked check of ${count} file(s) (${total} so far)`);
}
return JobStatus.Success;
}
@OnJob({ name: JobName.IntegrityUntrackedFiles, queue: QueueName.IntegrityCheck })
async handleUntrackedFiles({ type, paths }: IIntegrityUntrackedFilesJob): Promise<JobStatus> {
this.logger.log(`Processing batch of ${paths.length} files to check if they are untracked.`);
const untrackedFiles = new Set<string>(paths);
if (type === 'asset') {
const assets = await this.integrityRepository.getAssetPathsByPaths(paths);
for (const { originalPath, encodedVideoPath } of assets) {
untrackedFiles.delete(originalPath);
if (encodedVideoPath) {
untrackedFiles.delete(encodedVideoPath);
}
}
} else {
const assets = await this.integrityRepository.getAssetFilePathsByPaths(paths);
for (const { path } of assets) {
untrackedFiles.delete(path);
}
}
const personThumbnailPaths = await this.integrityRepository.getPersonThumbnailPathsByPaths(paths);
for (const { thumbnailPath } of personThumbnailPaths) {
untrackedFiles.delete(thumbnailPath);
}
if (untrackedFiles.size > 0) {
await this.integrityRepository.create(
[...untrackedFiles].map((path) => ({
type: IntegrityReport.UntrackedFile,
path,
})),
);
}
this.logger.log(`Processed ${paths.length} and found ${untrackedFiles.size} untracked file(s).`);
return JobStatus.Success;
}
@OnJob({ name: JobName.IntegrityUntrackedFilesRefresh, queue: QueueName.IntegrityCheck })
async handleUntrackedRefresh({ items }: IIntegrityPathWithReportJob): Promise<JobStatus> {
this.logger.log(`Processing batch of ${items.length} reports to check if they are out of date.`);
const results = await Promise.all(
items.map(({ reportId, path }) =>
this.storageRepository
.stat(path)
.then(() => void 0)
.catch(() => reportId),
),
);
const reportIds = results.filter(Boolean) as string[];
if (reportIds.length > 0) {
await this.integrityRepository.deleteByIds(reportIds);
}
this.logger.log(`Processed ${items.length} paths and found ${reportIds.length} report(s) out of date.`);
return JobStatus.Success;
}
private async queueRefreshAllMissingFiles() {
this.logger.log(`Checking for out of date missing file reports...`);
const reports = this.integrityRepository.streamIntegrityReportsWithAssetChecksum(IntegrityReport.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.');
}
@OnJob({ name: JobName.IntegrityMissingFilesQueueAll, queue: QueueName.IntegrityCheck })
async handleMissingFilesQueueAll({ refreshOnly }: IIntegrityJob = {}): Promise<JobStatus> {
if (refreshOnly) {
await this.queueRefreshAllMissingFiles();
return JobStatus.Success;
}
this.logger.log(`Scanning for missing files...`);
const assetPaths = this.integrityRepository.streamAssetPaths();
let total = 0;
for await (const batchPaths of chunk(assetPaths, JOBS_LIBRARY_PAGINATION_SIZE)) {
await this.jobRepository.queue({
name: JobName.IntegrityMissingFiles,
data: {
items: batchPaths,
},
});
total += batchPaths.length;
this.logger.log(`Queued missing check of ${batchPaths.length} file(s) (${total} so far)`);
}
return JobStatus.Success;
}
@OnJob({ name: JobName.IntegrityMissingFiles, queue: QueueName.IntegrityCheck })
async handleMissingFiles({ items }: IIntegrityMissingFilesJob): Promise<JobStatus> {
this.logger.log(`Processing batch of ${items.length} files to check if they are missing.`);
const results = await Promise.all(
items.map((item) =>
this.storageRepository
.stat(item.path)
.then(() => ({ ...item, exists: true }))
.catch(() => ({ ...item, exists: false })),
),
);
const outdatedReports = results
.filter(({ exists, reportId }) => exists && reportId)
.map(({ reportId }) => reportId!);
if (outdatedReports.length > 0) {
await this.integrityRepository.deleteByIds(outdatedReports);
}
const missingFiles = results.filter(({ exists }) => !exists);
if (missingFiles.length > 0) {
await this.integrityRepository.create(
missingFiles.map(({ path, assetId, fileAssetId }) => ({
type: IntegrityReport.MissingFile,
path,
assetId,
fileAssetId,
})),
);
}
this.logger.log(`Processed ${items.length} and found ${missingFiles.length} missing file(s).`);
return JobStatus.Success;
}
@OnJob({ name: JobName.IntegrityMissingFilesRefresh, queue: QueueName.IntegrityCheck })
async handleMissingRefresh({ items: paths }: IIntegrityPathWithReportJob): Promise<JobStatus> {
this.logger.log(`Processing batch of ${paths.length} reports to check if they are out of date.`);
const results = await Promise.all(
paths.map(({ reportId, path }) =>
this.storageRepository
.stat(path)
.then(() => reportId)
.catch(() => void 0),
),
);
const reportIds = results.filter(Boolean) as string[];
if (reportIds.length > 0) {
await this.integrityRepository.deleteByIds(reportIds);
}
this.logger.log(`Processed ${paths.length} paths and found ${reportIds.length} report(s) out of date.`);
return JobStatus.Success;
}
private async queueRefreshAllChecksumFiles() {
this.logger.log(`Checking for out of date checksum file reports...`);
const reports = this.integrityRepository.streamIntegrityReportsWithAssetChecksum(IntegrityReport.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.');
}
private async checkAssetChecksum(
originalPath: string,
checksum: Buffer<ArrayBufferLike>,
assetId: string,
reportId: string | null,
) {
const hash = createHash('sha1');
try {
await pipeline([
this.storageRepository.createPlainReadStream(originalPath),
new Writable({
write(chunk, _encoding, callback) {
hash.update(chunk);
callback();
},
}),
]);
if (checksum.equals(hash.digest())) {
if (reportId) {
await this.integrityRepository.deleteById(reportId);
}
} else {
throw new Error('File failed checksum');
}
} catch (error) {
if ((error as { code?: string }).code === 'ENOENT') {
if (reportId) {
await this.integrityRepository.deleteById(reportId);
}
// missing file; handled by the missing files job
return;
}
this.logger.warn('Failed to process a file: ' + error);
await this.integrityRepository.create({
path: originalPath,
type: IntegrityReport.ChecksumFail,
assetId,
});
}
}
@OnJob({ name: JobName.IntegrityChecksumFiles, queue: QueueName.IntegrityCheck })
async handleChecksumFiles({ refreshOnly }: IIntegrityJob = {}): Promise<JobStatus> {
if (refreshOnly) {
await this.queueRefreshAllChecksumFiles();
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) {
await this.checkAssetChecksum(originalPath, checksum, assetId, reportId);
processed++;
if (processed % 100 === 0) {
printStats();
}
if (Date.now() > startedAt + timeLimit || processed > count * percentageLimit) {
this.logger.log('Reached stop criteria.');
lastCreatedAt = createdAt;
break finishEarly;
}
}
} while (endMarker);
await this.systemMetadataRepository.set(SystemMetadataKey.IntegrityChecksumCheckpoint, {
date: lastCreatedAt?.toISOString(),
});
printStats();
if (lastCreatedAt) {
this.logger.log(`Finished checksum job, will continue from ${lastCreatedAt.toISOString()}.`);
} else {
this.logger.log(`Finished checksum job, covered all assets.`);
}
return JobStatus.Success;
}
@OnJob({ name: JobName.IntegrityChecksumFilesRefresh, queue: QueueName.IntegrityCheck })
async handleChecksumRefresh({ items: paths }: IIntegrityPathWithChecksumJob): Promise<JobStatus> {
this.logger.log(`Processing batch of ${paths.length} reports to check if they are out of date.`);
const results = await Promise.all(
paths.map(async ({ reportId, path, checksum }) => {
if (!checksum) {
return reportId;
}
const hash = createHash('sha1');
try {
await pipeline([
this.storageRepository.createPlainReadStream(path),
new Writable({
write(chunk, _encoding, callback) {
hash.update(chunk);
callback();
},
}),
]);
} catch (error) {
if ((error as { code?: string }).code === 'ENOENT') {
return reportId;
}
}
if (Buffer.from(checksum, 'hex').equals(hash.digest())) {
return reportId;
}
}),
);
const reportIds = results.filter(Boolean) as string[];
if (reportIds.length > 0) {
await this.integrityRepository.deleteByIds(reportIds);
}
this.logger.log(`Processed ${paths.length} paths and found ${reportIds.length} report(s) out of date.`);
return JobStatus.Success;
}
@OnJob({ name: JobName.IntegrityDeleteReportType, queue: QueueName.IntegrityCheck })
async handleDeleteAllIntegrityReports({ type }: IIntegrityDeleteReportTypeJob): Promise<JobStatus> {
this.logger.log(`Deleting all entries for ${type ?? 'all types of'} integrity report`);
let properties;
switch (type) {
case IntegrityReport.ChecksumFail: {
properties = ['assetId'] as const;
break;
}
case IntegrityReport.MissingFile: {
properties = ['assetId', 'fileAssetId'] as const;
break;
}
case IntegrityReport.UntrackedFile: {
properties = [void 0] as const;
break;
}
default: {
properties = [void 0, 'assetId', 'fileAssetId'] as const;
break;
}
}
for (const property of properties) {
const reports = this.integrityRepository.streamIntegrityReportsByProperty(property, type);
for await (const batch of chunk(reports, JOBS_LIBRARY_PAGINATION_SIZE)) {
await this.jobRepository.queue({
name: JobName.IntegrityDeleteReports,
data: {
reports: batch,
},
});
this.logger.log(`Queued ${batch.length} reports to delete.`);
}
}
return JobStatus.Success;
}
@OnJob({ name: JobName.IntegrityDeleteReports, queue: QueueName.IntegrityCheck })
async handleDeleteIntegrityReports({ reports }: IIntegrityDeleteReportsJob): Promise<JobStatus> {
const byAsset = reports.filter((report) => report.assetId);
const byFileAsset = reports.filter((report) => report.fileAssetId);
const byPath = reports.filter((report) => !report.assetId && !report.fileAssetId);
if (byAsset.length > 0) {
const ids = byAsset.map(({ assetId }) => assetId!);
await this.assetRepository.updateAll(ids, {
deletedAt: new Date(),
status: AssetStatus.Trashed,
});
await this.eventRepository.emit('AssetTrashAll', {
assetIds: ids,
userId: '', // we don't notify any users currently
});
await this.integrityRepository.deleteByIds(byAsset.map(({ id }) => id));
}
if (byFileAsset.length > 0) {
await this.assetRepository.deleteFiles(byFileAsset.map(({ fileAssetId }) => ({ id: fileAssetId! })));
}
if (byPath.length > 0) {
await Promise.all(byPath.map(({ path }) => this.storageRepository.unlink(path).catch(() => void 0)));
await this.integrityRepository.deleteByIds(byPath.map(({ id }) => id));
}
this.logger.log(`Deleted ${reports.length} reports.`);
return JobStatus.Success;
}
}
async function* chunk<T>(generator: AsyncIterableIterator<T>, n: number) {
let chunk: T[] = [];
for await (const item of generator) {
chunk.push(item);
if (chunk.length === n) {
yield chunk;
chunk = [];
}
}
if (chunk.length > 0) {
yield chunk;
}
}

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, IntegrityReport, 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.IntegrityUntrackedFiles: {
return { name: JobName.IntegrityUntrackedFilesQueueAll };
}
case ManualJobName.IntegrityChecksumFiles: {
return { name: JobName.IntegrityChecksumFiles };
}
case ManualJobName.IntegrityMissingFilesRefresh: {
return { name: JobName.IntegrityMissingFilesQueueAll, data: { refreshOnly: true } };
}
case ManualJobName.IntegrityUntrackedFilesRefresh: {
return { name: JobName.IntegrityUntrackedFilesQueueAll, data: { refreshOnly: true } };
}
case ManualJobName.IntegrityChecksumFilesRefresh: {
return { name: JobName.IntegrityChecksumFiles, data: { refreshOnly: true } };
}
case ManualJobName.IntegrityMissingFilesDeleteAll: {
return { name: JobName.IntegrityDeleteReportType, data: { type: IntegrityReport.MissingFile } };
}
case ManualJobName.IntegrityUntrackedFilesDeleteAll: {
return { name: JobName.IntegrityDeleteReportType, data: { type: IntegrityReport.UntrackedFile } };
}
case ManualJobName.IntegrityChecksumFilesDeleteAll: {
return { name: JobName.IntegrityDeleteReportType, data: { type: IntegrityReport.ChecksumFail } };
}
default: {
throw new BadRequestException('Invalid job name');
}

View file

@ -23,7 +23,7 @@ describe(QueueService.name, () => {
it('should update concurrency', () => {
sut.onConfigUpdate({ newConfig: defaults, oldConfig: {} as SystemConfig });
expect(mocks.job.setConcurrency).toHaveBeenCalledTimes(18);
expect(mocks.job.setConcurrency).toHaveBeenCalledTimes(19);
expect(mocks.job.setConcurrency).toHaveBeenNthCalledWith(5, QueueName.FacialRecognition, 1);
expect(mocks.job.setConcurrency).toHaveBeenNthCalledWith(7, QueueName.DuplicateDetection, 1);
expect(mocks.job.setConcurrency).toHaveBeenNthCalledWith(8, QueueName.BackgroundTask, 5);
@ -77,6 +77,7 @@ describe(QueueService.name, () => {
[QueueName.BackupDatabase]: expected,
[QueueName.Ocr]: expected,
[QueueName.Workflow]: expected,
[QueueName.IntegrityCheck]: expected,
[QueueName.Editor]: expected,
});
});

View file

@ -42,6 +42,7 @@ const updatedConfig = Object.freeze<SystemConfig>({
[QueueName.Notification]: { concurrency: 5 },
[QueueName.Ocr]: { concurrency: 1 },
[QueueName.Workflow]: { concurrency: 5 },
[QueueName.IntegrityCheck]: { concurrency: 1 },
[QueueName.Editor]: { concurrency: 2 },
},
backup: {
@ -77,6 +78,22 @@ const updatedConfig = Object.freeze<SystemConfig>({
enabled: false,
},
},
integrityChecks: {
untrackedFiles: {
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

@ -21,6 +21,7 @@ import {
H264Profile,
HevcProfile,
ImageFormat,
IntegrityReport,
JobName,
MemoryType,
QueueName,
@ -311,6 +312,43 @@ export type IWorkflowJob<T extends WorkflowType = WorkflowType> = {
type: T;
};
export interface IIntegrityJob {
refreshOnly?: boolean;
}
export interface IIntegrityDeleteReportTypeJob {
type?: IntegrityReport;
}
export interface IIntegrityDeleteReportsJob {
reports: {
id: string;
assetId: string | null;
fileAssetId: string | null;
path: string;
}[];
}
export interface IIntegrityUntrackedFilesJob {
type: 'asset' | 'asset_file';
paths: string[];
}
export interface IIntegrityMissingFilesJob {
items: ({ path: string; reportId: string | null } & (
| { assetId: string; fileAssetId: null }
| { assetId: null; fileAssetId: string }
))[];
}
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;
@ -422,6 +460,18 @@ export type JobItem =
// Workflow
| { name: JobName.WorkflowAssetTrigger; data: { workflowId: string; assetId: string } }
// Integrity
| { name: JobName.IntegrityUntrackedFilesQueueAll; data?: IIntegrityJob }
| { name: JobName.IntegrityUntrackedFiles; data: IIntegrityUntrackedFilesJob }
| { name: JobName.IntegrityUntrackedFilesRefresh; data: IIntegrityPathWithReportJob }
| { name: JobName.IntegrityMissingFilesQueueAll; data?: IIntegrityJob }
| { name: JobName.IntegrityMissingFiles; data: IIntegrityPathWithReportJob }
| { name: JobName.IntegrityMissingFilesRefresh; data: IIntegrityPathWithReportJob }
| { name: JobName.IntegrityChecksumFiles; data?: IIntegrityJob }
| { name: JobName.IntegrityChecksumFilesRefresh; data?: IIntegrityPathWithChecksumJob }
| { name: JobName.IntegrityDeleteReportType; data: IIntegrityDeleteReportTypeJob }
| { name: JobName.IntegrityDeleteReports; data: IIntegrityDeleteReportsJob }
// Editor
| { name: JobName.AssetEditThumbnailGeneration; data: IEntityJob };
@ -522,6 +572,7 @@ export interface SystemMetadata extends Record<SystemMetadataKey, Record<string,
[SystemMetadataKey.SystemFlags]: DeepPartial<SystemFlags>;
[SystemMetadataKey.VersionCheckState]: VersionCheckMetadata;
[SystemMetadataKey.MemoriesState]: MemoriesState;
[SystemMetadataKey.IntegrityChecksumCheckpoint]: { date?: string };
}
export type UserPreferences = {

View file

@ -1,6 +1,7 @@
import { ArgumentMetadata, FileValidator, Injectable, ParseUUIDPipe } from '@nestjs/common';
import { createZodDto } from 'nestjs-zod';
import sanitize from 'sanitize-filename';
import { IntegrityReportSchema } from 'src/enum';
import { isIP, isIPRange } from 'validator';
import z from 'zod';
@ -110,6 +111,12 @@ const UUIDParamSchema = z.object({
export class UUIDParamDto extends createZodDto(UUIDParamSchema) {}
const UUIDv7ParamSchema = z.object({
id: z.uuidv7(),
});
export class UUIDv7ParamDto extends createZodDto(UUIDv7ParamSchema) {}
const UUIDAssetIDParamSchema = z.object({
id: z.uuidv4(),
assetId: z.uuidv4(),
@ -125,6 +132,10 @@ const FilenameParamSchema = z.object({
export class FilenameParamDto extends createZodDto(FilenameParamSchema) {}
const IntegrityReportParamSchema = z.object({ type: IntegrityReportSchema }).meta({ id: 'IntegrityReportDto' });
export class IntegrityReportTypeParamDto extends createZodDto(IntegrityReportParamSchema) {}
/**
* Unified email validation
* Converts email strings to lowercase and validates against HTML5 email regex

View file

@ -30,6 +30,7 @@ import { CryptoRepository } from 'src/repositories/crypto.repository';
import { DatabaseRepository } from 'src/repositories/database.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 { LoggingRepository } from 'src/repositories/logging.repository';
import { MachineLearningRepository } from 'src/repositories/machine-learning.repository';
@ -415,6 +416,7 @@ const newRealRepository = <T>(key: ClassConstructor<T>, db: Kysely<DB>): T => {
case AssetRepository:
case AssetEditRepository:
case AssetJobRepository:
case IntegrityRepository:
case MemoryRepository:
case NotificationRepository:
case OcrRepository:
@ -483,6 +485,7 @@ const newMockRepository = <T>(key: ClassConstructor<T>) => {
case ConfigRepository:
case CryptoRepository:
case MemoryRepository:
case IntegrityRepository:
case NotificationRepository:
case OcrRepository:
case PartnerRepository:

File diff suppressed because it is too large Load diff

View file

@ -48,8 +48,8 @@ export const newStorageRepositoryMock = (): Mocked<RepositoryInterface<StorageRe
return {
createZipStream: vitest.fn(),
createReadStream: vitest.fn(),
createPlainReadStream: vitest.fn(),
createReadStream: vitest.fn(),
createGzip: vitest.fn(),
createGunzip: vitest.fn(),
readFile: vitest.fn(),

View file

@ -33,6 +33,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';
@ -234,6 +235,7 @@ export type ServiceOverrides = {
duplicateRepository: DuplicateRepository;
email: EmailRepository;
event: EventRepository;
integrityReport: IntegrityRepository;
job: JobRepository;
library: LibraryRepository;
logger: LoggingRepository;
@ -316,6 +318,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 }),
@ -385,6 +388,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>),
@ -540,7 +544,44 @@ export const mockDuplex =
return duplex;
};
export async function* makeStream<T>(items: T[] = []): AsyncIterableIterator<T> {
export const mockFork = vitest.fn((exitCode: number, stdout: string, stderr: string, error?: unknown) => {
const stdoutStream = new Readable({
read() {
this.push(stdout); // write mock data to stdout
this.push(null); // end stream
},
});
return {
stdout: stdoutStream,
stderr: new Readable({
read() {
this.push(stderr); // write mock data to stderr
this.push(null); // end stream
},
}),
stdin: new Writable({
write(chunk, encoding, callback) {
callback();
},
}),
exitCode,
on: vitest.fn((event, callback: any) => {
if (event === 'close') {
stdoutStream.once('end', () => callback(0));
}
if (event === 'error' && error) {
stdoutStream.once('end', () => callback(error));
}
if (event === 'exit') {
stdoutStream.once('end', () => callback(exitCode));
}
}),
kill: vitest.fn(),
} as unknown as ChildProcessWithoutNullStreams;
});
export async function* makeStream<T>(items: T[] = []): AsyncGenerator<T> {
for (const item of items) {
await Promise.resolve();
yield item;