mirror of
https://github.com/immich-app/immich.git
synced 2025-12-25 09:14:58 +03:00
207 lines
7.3 KiB
TypeScript
207 lines
7.3 KiB
TypeScript
import { BadRequestException, Injectable } from '@nestjs/common';
|
|
import { DateTime } from 'luxon';
|
|
import { resolve } from 'node:path';
|
|
import { AUDIT_LOG_MAX_DURATION, JOBS_ASSET_PAGINATION_SIZE } from 'src/constants';
|
|
import { StorageCore } from 'src/cores/storage.core';
|
|
import { OnJob } from 'src/decorators';
|
|
import { FileChecksumDto, FileChecksumResponseDto, FileReportItemDto, PathEntityType } from 'src/dtos/audit.dto';
|
|
import {
|
|
AssetFileType,
|
|
AssetPathType,
|
|
JobName,
|
|
JobStatus,
|
|
PersonPathType,
|
|
QueueName,
|
|
StorageFolder,
|
|
UserPathType,
|
|
} from 'src/enum';
|
|
import { BaseService } from 'src/services/base.service';
|
|
import { getAssetFiles } from 'src/utils/asset.util';
|
|
import { usePagination } from 'src/utils/pagination';
|
|
|
|
@Injectable()
|
|
export class AuditService extends BaseService {
|
|
@OnJob({ name: JobName.CLEAN_OLD_AUDIT_LOGS, queue: QueueName.BACKGROUND_TASK })
|
|
async handleCleanup(): Promise<JobStatus> {
|
|
await this.auditRepository.removeBefore(DateTime.now().minus(AUDIT_LOG_MAX_DURATION).toJSDate());
|
|
return JobStatus.SUCCESS;
|
|
}
|
|
|
|
async getChecksums(dto: FileChecksumDto) {
|
|
const results: FileChecksumResponseDto[] = [];
|
|
for (const filename of dto.filenames) {
|
|
if (!StorageCore.isImmichPath(filename)) {
|
|
throw new BadRequestException(
|
|
`Could not get the checksum of ${filename} because the file isn't accessible by Immich`,
|
|
);
|
|
}
|
|
|
|
const checksum = await this.cryptoRepository.hashFile(filename);
|
|
results.push({ filename, checksum: checksum.toString('base64') });
|
|
}
|
|
return results;
|
|
}
|
|
|
|
async fixItems(items: FileReportItemDto[]) {
|
|
for (const { entityId: id, pathType, pathValue } of items) {
|
|
if (!StorageCore.isImmichPath(pathValue)) {
|
|
throw new BadRequestException(
|
|
`Could not fix item ${id} with path ${pathValue} because the file isn't accessible by Immich`,
|
|
);
|
|
}
|
|
|
|
switch (pathType) {
|
|
case AssetPathType.ENCODED_VIDEO: {
|
|
await this.assetRepository.update({ id, encodedVideoPath: pathValue });
|
|
break;
|
|
}
|
|
|
|
case AssetPathType.PREVIEW: {
|
|
await this.assetRepository.upsertFile({ assetId: id, type: AssetFileType.PREVIEW, path: pathValue });
|
|
break;
|
|
}
|
|
|
|
case AssetPathType.THUMBNAIL: {
|
|
await this.assetRepository.upsertFile({ assetId: id, type: AssetFileType.THUMBNAIL, path: pathValue });
|
|
break;
|
|
}
|
|
|
|
case AssetPathType.ORIGINAL: {
|
|
await this.assetRepository.update({ id, originalPath: pathValue });
|
|
break;
|
|
}
|
|
|
|
case AssetPathType.SIDECAR: {
|
|
await this.assetRepository.update({ id, sidecarPath: pathValue });
|
|
break;
|
|
}
|
|
|
|
case PersonPathType.FACE: {
|
|
await this.personRepository.update({ id, thumbnailPath: pathValue });
|
|
break;
|
|
}
|
|
|
|
case UserPathType.PROFILE: {
|
|
await this.userRepository.update(id, { profileImagePath: pathValue });
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private fullPath(filename: string) {
|
|
return resolve(filename);
|
|
}
|
|
|
|
async getFileReport() {
|
|
const hasFile = (items: Set<string>, filename: string) => items.has(filename) || items.has(this.fullPath(filename));
|
|
const crawl = async (folder: StorageFolder) =>
|
|
new Set(
|
|
await this.storageRepository.crawl({
|
|
includeHidden: true,
|
|
pathsToCrawl: [StorageCore.getBaseFolder(folder)],
|
|
}),
|
|
);
|
|
|
|
const uploadFiles = await crawl(StorageFolder.UPLOAD);
|
|
const libraryFiles = await crawl(StorageFolder.LIBRARY);
|
|
const thumbFiles = await crawl(StorageFolder.THUMBNAILS);
|
|
const videoFiles = await crawl(StorageFolder.ENCODED_VIDEO);
|
|
const profileFiles = await crawl(StorageFolder.PROFILE);
|
|
const allFiles = new Set<string>();
|
|
for (const list of [libraryFiles, thumbFiles, videoFiles, profileFiles, uploadFiles]) {
|
|
for (const item of list) {
|
|
allFiles.add(item);
|
|
}
|
|
}
|
|
|
|
const track = (filename: string | null | undefined) => {
|
|
if (!filename) {
|
|
return;
|
|
}
|
|
allFiles.delete(filename);
|
|
allFiles.delete(this.fullPath(filename));
|
|
};
|
|
|
|
this.logger.log(
|
|
`Found ${libraryFiles.size} original files, ${thumbFiles.size} thumbnails, ${videoFiles.size} encoded videos, ${profileFiles.size} profile files`,
|
|
);
|
|
const pagination = usePagination(JOBS_ASSET_PAGINATION_SIZE, (options) =>
|
|
this.assetRepository.getAll(options, { withDeleted: true, withArchived: true }),
|
|
);
|
|
|
|
let assetCount = 0;
|
|
|
|
const orphans: FileReportItemDto[] = [];
|
|
for await (const assets of pagination) {
|
|
assetCount += assets.length;
|
|
for (const { id, files, originalPath, encodedVideoPath, isExternal, checksum } of assets) {
|
|
const { previewFile, thumbnailFile } = getAssetFiles(files);
|
|
for (const file of [originalPath, previewFile?.path, encodedVideoPath, thumbnailFile?.path]) {
|
|
track(file);
|
|
}
|
|
|
|
const entity = { entityId: id, entityType: PathEntityType.ASSET, checksum: checksum.toString('base64') };
|
|
if (
|
|
originalPath &&
|
|
!hasFile(libraryFiles, originalPath) &&
|
|
!hasFile(uploadFiles, originalPath) &&
|
|
// Android motion assets
|
|
!hasFile(videoFiles, originalPath) &&
|
|
// ignore external library assets
|
|
!isExternal
|
|
) {
|
|
orphans.push({ ...entity, pathType: AssetPathType.ORIGINAL, pathValue: originalPath });
|
|
}
|
|
if (previewFile && !hasFile(thumbFiles, previewFile.path)) {
|
|
orphans.push({ ...entity, pathType: AssetPathType.PREVIEW, pathValue: previewFile.path });
|
|
}
|
|
if (thumbnailFile && !hasFile(thumbFiles, thumbnailFile.path)) {
|
|
orphans.push({ ...entity, pathType: AssetPathType.THUMBNAIL, pathValue: thumbnailFile.path });
|
|
}
|
|
if (encodedVideoPath && !hasFile(videoFiles, encodedVideoPath)) {
|
|
orphans.push({ ...entity, pathType: AssetPathType.THUMBNAIL, pathValue: encodedVideoPath });
|
|
}
|
|
}
|
|
}
|
|
|
|
const users = await this.userRepository.getList();
|
|
for (const { id, profileImagePath } of users) {
|
|
track(profileImagePath);
|
|
|
|
const entity = { entityId: id, entityType: PathEntityType.USER };
|
|
if (profileImagePath && !hasFile(profileFiles, profileImagePath)) {
|
|
orphans.push({ ...entity, pathType: UserPathType.PROFILE, pathValue: profileImagePath });
|
|
}
|
|
}
|
|
|
|
let peopleCount = 0;
|
|
for await (const { id, thumbnailPath } of this.personRepository.getAll()) {
|
|
track(thumbnailPath);
|
|
const entity = { entityId: id, entityType: PathEntityType.PERSON };
|
|
if (thumbnailPath && !hasFile(thumbFiles, thumbnailPath)) {
|
|
orphans.push({ ...entity, pathType: PersonPathType.FACE, pathValue: thumbnailPath });
|
|
}
|
|
|
|
if (peopleCount === JOBS_ASSET_PAGINATION_SIZE) {
|
|
this.logger.log(`Found ${assetCount} assets, ${users.length} users, ${peopleCount} people`);
|
|
peopleCount = 0;
|
|
}
|
|
}
|
|
|
|
this.logger.log(`Found ${assetCount} assets, ${users.length} users, ${peopleCount} people`);
|
|
|
|
const extras: string[] = [];
|
|
for (const file of allFiles) {
|
|
extras.push(file);
|
|
}
|
|
|
|
// send as absolute paths
|
|
for (const orphan of orphans) {
|
|
orphan.pathValue = this.fullPath(orphan.pathValue);
|
|
}
|
|
|
|
return { orphans, extras };
|
|
}
|
|
}
|