chore: remove audit file report (#17994)

This commit is contained in:
Jason Rasmussen 2025-04-30 11:17:23 -04:00 committed by GitHub
parent ebad6a008f
commit 094a41ac9a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
22 changed files with 4 additions and 1167 deletions

View file

@ -1,43 +0,0 @@
import { deleteAssets, getAuditFiles, updateAsset, type LoginResponseDto } from '@immich/sdk';
import { asBearerAuth, utils } from 'src/utils';
import { beforeAll, describe, expect, it } from 'vitest';
describe('/audits', () => {
let admin: LoginResponseDto;
beforeAll(async () => {
await utils.resetDatabase();
await utils.resetFilesystem();
admin = await utils.adminSetup();
});
// TODO: Enable these tests again once #7436 is resolved as these were flaky
describe.skip('GET :/file-report', () => {
it('excludes assets without issues from report', async () => {
const [trashedAsset, archivedAsset] = await Promise.all([
utils.createAsset(admin.accessToken),
utils.createAsset(admin.accessToken),
utils.createAsset(admin.accessToken),
]);
await Promise.all([
deleteAssets({ assetBulkDeleteDto: { ids: [trashedAsset.id] } }, { headers: asBearerAuth(admin.accessToken) }),
updateAsset(
{
id: archivedAsset.id,
updateAssetDto: { isArchived: true },
},
{ headers: asBearerAuth(admin.accessToken) },
),
]);
const body = await getAuditFiles({
headers: asBearerAuth(admin.accessToken),
});
expect(body.orphans).toHaveLength(0);
expect(body.extras).toHaveLength(0);
});
});
});

BIN
mobile/openapi/README.md generated

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View file

@ -4651,118 +4651,6 @@
]
}
},
"/reports": {
"get": {
"operationId": "getAuditFiles",
"parameters": [],
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/FileReportDto"
}
}
},
"description": ""
}
},
"security": [
{
"bearer": []
},
{
"cookie": []
},
{
"api_key": []
}
],
"tags": [
"File Reports"
]
}
},
"/reports/checksum": {
"post": {
"operationId": "getFileChecksums",
"parameters": [],
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/FileChecksumDto"
}
}
},
"required": true
},
"responses": {
"201": {
"content": {
"application/json": {
"schema": {
"items": {
"$ref": "#/components/schemas/FileChecksumResponseDto"
},
"type": "array"
}
}
},
"description": ""
}
},
"security": [
{
"bearer": []
},
{
"cookie": []
},
{
"api_key": []
}
],
"tags": [
"File Reports"
]
}
},
"/reports/fix": {
"post": {
"operationId": "fixAuditFiles",
"parameters": [],
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/FileReportFixDto"
}
}
},
"required": true
},
"responses": {
"201": {
"description": ""
}
},
"security": [
{
"bearer": []
},
{
"cookie": []
},
{
"api_key": []
}
],
"tags": [
"File Reports"
]
}
},
"/search/cities": {
"get": {
"operationId": "getAssetsByCity",
@ -9749,105 +9637,6 @@
],
"type": "object"
},
"FileChecksumDto": {
"properties": {
"filenames": {
"items": {
"type": "string"
},
"type": "array"
}
},
"required": [
"filenames"
],
"type": "object"
},
"FileChecksumResponseDto": {
"properties": {
"checksum": {
"type": "string"
},
"filename": {
"type": "string"
}
},
"required": [
"checksum",
"filename"
],
"type": "object"
},
"FileReportDto": {
"properties": {
"extras": {
"items": {
"type": "string"
},
"type": "array"
},
"orphans": {
"items": {
"$ref": "#/components/schemas/FileReportItemDto"
},
"type": "array"
}
},
"required": [
"extras",
"orphans"
],
"type": "object"
},
"FileReportFixDto": {
"properties": {
"items": {
"items": {
"$ref": "#/components/schemas/FileReportItemDto"
},
"type": "array"
}
},
"required": [
"items"
],
"type": "object"
},
"FileReportItemDto": {
"properties": {
"checksum": {
"type": "string"
},
"entityId": {
"format": "uuid",
"type": "string"
},
"entityType": {
"allOf": [
{
"$ref": "#/components/schemas/PathEntityType"
}
]
},
"pathType": {
"allOf": [
{
"$ref": "#/components/schemas/PathType"
}
]
},
"pathValue": {
"type": "string"
}
},
"required": [
"entityId",
"entityType",
"pathType",
"pathValue"
],
"type": "object"
},
"FoldersResponse": {
"properties": {
"enabled": {
@ -10889,27 +10678,6 @@
],
"type": "object"
},
"PathEntityType": {
"enum": [
"asset",
"person",
"user"
],
"type": "string"
},
"PathType": {
"enum": [
"original",
"fullsize",
"preview",
"thumbnail",
"encoded_video",
"sidecar",
"face",
"profile"
],
"type": "string"
},
"PeopleResponse": {
"properties": {
"enabled": {

View file

@ -800,27 +800,6 @@ export type AssetFaceUpdateDto = {
export type PersonStatisticsResponseDto = {
assets: number;
};
export type FileReportItemDto = {
checksum?: string;
entityId: string;
entityType: PathEntityType;
pathType: PathType;
pathValue: string;
};
export type FileReportDto = {
extras: string[];
orphans: FileReportItemDto[];
};
export type FileChecksumDto = {
filenames: string[];
};
export type FileChecksumResponseDto = {
checksum: string;
filename: string;
};
export type FileReportFixDto = {
items: FileReportItemDto[];
};
export type SearchExploreItem = {
data: AssetResponseDto;
value: string;
@ -2663,35 +2642,6 @@ export function getPersonThumbnail({ id }: {
...opts
}));
}
export function getAuditFiles(opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchJson<{
status: 200;
data: FileReportDto;
}>("/reports", {
...opts
}));
}
export function getFileChecksums({ fileChecksumDto }: {
fileChecksumDto: FileChecksumDto;
}, opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchJson<{
status: 201;
data: FileChecksumResponseDto[];
}>("/reports/checksum", oazapfts.json({
...opts,
method: "POST",
body: fileChecksumDto
})));
}
export function fixAuditFiles({ fileReportFixDto }: {
fileReportFixDto: FileReportFixDto;
}, opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchText("/reports/fix", oazapfts.json({
...opts,
method: "POST",
body: fileReportFixDto
})));
}
export function getAssetsByCity(opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchJson<{
status: 200;
@ -3751,21 +3701,6 @@ export enum PartnerDirection {
SharedBy = "shared-by",
SharedWith = "shared-with"
}
export enum PathEntityType {
Asset = "asset",
Person = "person",
User = "user"
}
export enum PathType {
Original = "original",
Fullsize = "fullsize",
Preview = "preview",
Thumbnail = "thumbnail",
EncodedVideo = "encoded_video",
Sidecar = "sidecar",
Face = "face",
Profile = "profile"
}
export enum SearchSuggestionType {
Country = "country",
State = "state",

View file

@ -1,29 +0,0 @@
import { Body, Controller, Get, Post } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { FileChecksumDto, FileChecksumResponseDto, FileReportDto, FileReportFixDto } from 'src/dtos/audit.dto';
import { Authenticated } from 'src/middleware/auth.guard';
import { AuditService } from 'src/services/audit.service';
@ApiTags('File Reports')
@Controller('reports')
export class ReportController {
constructor(private service: AuditService) {}
@Get()
@Authenticated({ admin: true })
getAuditFiles(): Promise<FileReportDto> {
return this.service.getFileReport();
}
@Post('checksum')
@Authenticated({ admin: true })
getFileChecksums(@Body() dto: FileChecksumDto): Promise<FileChecksumResponseDto[]> {
return this.service.getChecksums(dto);
}
@Post('fix')
@Authenticated({ admin: true })
fixAuditFiles(@Body() dto: FileReportFixDto): Promise<void> {
return this.service.fixItems(dto.items);
}
}

View file

@ -8,7 +8,6 @@ import { AuthController } from 'src/controllers/auth.controller';
import { DownloadController } from 'src/controllers/download.controller';
import { DuplicateController } from 'src/controllers/duplicate.controller';
import { FaceController } from 'src/controllers/face.controller';
import { ReportController } from 'src/controllers/file-report.controller';
import { JobController } from 'src/controllers/job.controller';
import { LibraryController } from 'src/controllers/library.controller';
import { MapController } from 'src/controllers/map.controller';
@ -53,7 +52,6 @@ export const controllers = [
OAuthController,
PartnerController,
PersonController,
ReportController,
SearchController,
ServerController,
SessionController,

View file

@ -1,73 +0,0 @@
import { ApiProperty } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import { IsArray, IsEnum, IsString, IsUUID, ValidateNested } from 'class-validator';
import { AssetPathType, EntityType, PathType, PersonPathType, UserPathType } from 'src/enum';
import { Optional, ValidateDate, ValidateUUID } from 'src/validation';
const PathEnum = Object.values({ ...AssetPathType, ...PersonPathType, ...UserPathType });
export class AuditDeletesDto {
@ValidateDate()
after!: Date;
@ApiProperty({ enum: EntityType, enumName: 'EntityType' })
@IsEnum(EntityType)
entityType!: EntityType;
@Optional()
@IsUUID('4')
@ApiProperty({ format: 'uuid' })
userId?: string;
}
export enum PathEntityType {
ASSET = 'asset',
PERSON = 'person',
USER = 'user',
}
export class AuditDeletesResponseDto {
needsFullSync!: boolean;
ids!: string[];
}
export class FileReportDto {
orphans!: FileReportItemDto[];
extras!: string[];
}
export class FileChecksumDto {
@IsString({ each: true })
filenames!: string[];
}
export class FileChecksumResponseDto {
filename!: string;
checksum!: string;
}
export class FileReportFixDto {
@IsArray()
@ValidateNested({ each: true })
@Type(() => FileReportItemDto)
items!: FileReportItemDto[];
}
// used both as request and response dto
export class FileReportItemDto {
@ValidateUUID()
entityId!: string;
@ApiProperty({ enumName: 'PathEntityType', enum: PathEntityType })
@IsEnum(PathEntityType)
entityType!: PathEntityType;
@ApiProperty({ enumName: 'PathType', enum: PathEnum })
@IsEnum(PathEnum)
pathType!: PathType;
@IsString()
pathValue!: string;
checksum?: string;
}

View file

@ -1,6 +1,4 @@
import { BadRequestException } from '@nestjs/common';
import { FileReportItemDto } from 'src/dtos/audit.dto';
import { AssetFileType, AssetPathType, JobStatus, PersonPathType, UserPathType } from 'src/enum';
import { JobStatus } from 'src/enum';
import { AuditService } from 'src/services/audit.service';
import { newTestService, ServiceMocks } from 'test/utils';
@ -25,148 +23,4 @@ describe(AuditService.name, () => {
expect(mocks.audit.removeBefore).toHaveBeenCalledWith(expect.any(Date));
});
});
describe('getChecksums', () => {
it('should fail if the file is not in the immich path', async () => {
await expect(sut.getChecksums({ filenames: ['foo/bar'] })).rejects.toBeInstanceOf(BadRequestException);
expect(mocks.crypto.hashFile).not.toHaveBeenCalled();
});
it('should get checksum for valid file', async () => {
await expect(sut.getChecksums({ filenames: ['./upload/my-file.jpg'] })).resolves.toEqual([
{ filename: './upload/my-file.jpg', checksum: expect.any(String) },
]);
expect(mocks.crypto.hashFile).toHaveBeenCalledWith('./upload/my-file.jpg');
});
});
describe('fixItems', () => {
it('should fail if the file is not in the immich path', async () => {
await expect(
sut.fixItems([
{ entityId: 'my-id', pathType: AssetPathType.ORIGINAL, pathValue: 'foo/bar' } as FileReportItemDto,
]),
).rejects.toBeInstanceOf(BadRequestException);
expect(mocks.asset.update).not.toHaveBeenCalled();
expect(mocks.asset.upsertFile).not.toHaveBeenCalled();
expect(mocks.person.update).not.toHaveBeenCalled();
expect(mocks.user.update).not.toHaveBeenCalled();
});
it('should update encoded video path', async () => {
await sut.fixItems([
{
entityId: 'my-id',
pathType: AssetPathType.ENCODED_VIDEO,
pathValue: './upload/my-video.mp4',
} as FileReportItemDto,
]);
expect(mocks.asset.update).toHaveBeenCalledWith({ id: 'my-id', encodedVideoPath: './upload/my-video.mp4' });
expect(mocks.asset.upsertFile).not.toHaveBeenCalled();
expect(mocks.person.update).not.toHaveBeenCalled();
expect(mocks.user.update).not.toHaveBeenCalled();
});
it('should update preview path', async () => {
await sut.fixItems([
{
entityId: 'my-id',
pathType: AssetPathType.PREVIEW,
pathValue: './upload/my-preview.png',
} as FileReportItemDto,
]);
expect(mocks.asset.upsertFile).toHaveBeenCalledWith({
assetId: 'my-id',
type: AssetFileType.PREVIEW,
path: './upload/my-preview.png',
});
expect(mocks.asset.update).not.toHaveBeenCalled();
expect(mocks.person.update).not.toHaveBeenCalled();
expect(mocks.user.update).not.toHaveBeenCalled();
});
it('should update thumbnail path', async () => {
await sut.fixItems([
{
entityId: 'my-id',
pathType: AssetPathType.THUMBNAIL,
pathValue: './upload/my-thumbnail.webp',
} as FileReportItemDto,
]);
expect(mocks.asset.upsertFile).toHaveBeenCalledWith({
assetId: 'my-id',
type: AssetFileType.THUMBNAIL,
path: './upload/my-thumbnail.webp',
});
expect(mocks.asset.update).not.toHaveBeenCalled();
expect(mocks.person.update).not.toHaveBeenCalled();
expect(mocks.user.update).not.toHaveBeenCalled();
});
it('should update original path', async () => {
await sut.fixItems([
{
entityId: 'my-id',
pathType: AssetPathType.ORIGINAL,
pathValue: './upload/my-original.png',
} as FileReportItemDto,
]);
expect(mocks.asset.update).toHaveBeenCalledWith({ id: 'my-id', originalPath: './upload/my-original.png' });
expect(mocks.asset.upsertFile).not.toHaveBeenCalled();
expect(mocks.person.update).not.toHaveBeenCalled();
expect(mocks.user.update).not.toHaveBeenCalled();
});
it('should update sidecar path', async () => {
await sut.fixItems([
{
entityId: 'my-id',
pathType: AssetPathType.SIDECAR,
pathValue: './upload/my-sidecar.xmp',
} as FileReportItemDto,
]);
expect(mocks.asset.update).toHaveBeenCalledWith({ id: 'my-id', sidecarPath: './upload/my-sidecar.xmp' });
expect(mocks.asset.upsertFile).not.toHaveBeenCalled();
expect(mocks.person.update).not.toHaveBeenCalled();
expect(mocks.user.update).not.toHaveBeenCalled();
});
it('should update face path', async () => {
await sut.fixItems([
{
entityId: 'my-id',
pathType: PersonPathType.FACE,
pathValue: './upload/my-face.jpg',
} as FileReportItemDto,
]);
expect(mocks.person.update).toHaveBeenCalledWith({ id: 'my-id', thumbnailPath: './upload/my-face.jpg' });
expect(mocks.asset.update).not.toHaveBeenCalled();
expect(mocks.asset.upsertFile).not.toHaveBeenCalled();
expect(mocks.user.update).not.toHaveBeenCalled();
});
it('should update profile path', async () => {
await sut.fixItems([
{
entityId: 'my-id',
pathType: UserPathType.PROFILE,
pathValue: './upload/my-profile-pic.jpg',
} as FileReportItemDto,
]);
expect(mocks.user.update).toHaveBeenCalledWith('my-id', { profileImagePath: './upload/my-profile-pic.jpg' });
expect(mocks.asset.update).not.toHaveBeenCalled();
expect(mocks.asset.upsertFile).not.toHaveBeenCalled();
expect(mocks.person.update).not.toHaveBeenCalled();
});
});
});

View file

@ -1,23 +1,9 @@
import { BadRequestException, Injectable } from '@nestjs/common';
import { 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 { AUDIT_LOG_MAX_DURATION } from 'src/constants';
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 { JobName, JobStatus, QueueName } 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 {
@ -26,187 +12,4 @@ export class AuditService extends BaseService {
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 { fullsizeFile, previewFile, thumbnailFile } = getAssetFiles(files);
for (const file of [
originalPath,
fullsizeFile?.path,
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 };
}
}

View file

@ -1,358 +0,0 @@
<script lang="ts">
import empty4Url from '$lib/assets/empty-4.svg';
import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte';
import UserPageLayout from '$lib/components/layouts/user-page-layout.svelte';
import EmptyPlaceholder from '$lib/components/shared-components/empty-placeholder.svelte';
import {
NotificationType,
notificationController,
} from '$lib/components/shared-components/notification/notification';
import { downloadManager } from '$lib/managers/download-manager.svelte';
import { locale } from '$lib/stores/preferences.store';
import { copyToClipboard } from '$lib/utils';
import { downloadBlob } from '$lib/utils/asset-utils';
import { handleError } from '$lib/utils/handle-error';
import { fixAuditFiles, getAuditFiles, getFileChecksums, type FileReportItemDto } from '@immich/sdk';
import { Button, HStack, Text } from '@immich/ui';
import { mdiCheckAll, mdiContentCopy, mdiDownload, mdiRefresh, mdiWrench } from '@mdi/js';
import { t } from 'svelte-i18n';
import type { PageData } from './$types';
interface Props {
data: PageData;
}
let { data }: Props = $props();
interface UntrackedFile {
filename: string;
checksum: string | null;
}
interface Match {
orphan: FileReportItemDto;
extra: UntrackedFile;
}
const normalize = (filenames: string[]) => filenames.map((filename) => ({ filename, checksum: null }));
let checking = $state(false);
let repairing = $state(false);
let orphans: FileReportItemDto[] = $state(data.orphans);
let extras: UntrackedFile[] = $state(normalize(data.extras));
let matches: Match[] = $state([]);
const handleDownload = () => {
if (extras.length > 0) {
const blob = new Blob([extras.map(({ filename }) => filename).join('\n')], { type: 'text/plain' });
const downloadKey = 'untracked.txt';
downloadManager.add(downloadKey, blob.size);
downloadManager.update(downloadKey, blob.size);
downloadBlob(blob, downloadKey);
setTimeout(() => downloadManager.clear(downloadKey), 5000);
}
if (orphans.length > 0) {
const blob = new Blob([JSON.stringify(orphans, null, 4)], { type: 'application/json' });
const downloadKey = 'orphans.json';
downloadManager.add(downloadKey, blob.size);
downloadManager.update(downloadKey, blob.size);
downloadBlob(blob, downloadKey);
setTimeout(() => downloadManager.clear(downloadKey), 5000);
}
};
const handleRepair = async () => {
if (matches.length === 0) {
return;
}
repairing = true;
try {
await fixAuditFiles({
fileReportFixDto: {
items: matches.map(({ orphan, extra }) => ({
entityId: orphan.entityId,
entityType: orphan.entityType,
pathType: orphan.pathType,
pathValue: extra.filename,
})),
},
});
notificationController.show({
type: NotificationType.Info,
message: $t('admin.repaired_items', { values: { count: matches.length } }),
});
matches = [];
} catch (error) {
handleError(error, $t('errors.unable_to_repair_items'));
} finally {
repairing = false;
}
};
const handleSplit = (match: Match) => {
matches = matches.filter((_match) => _match !== match);
orphans = [match.orphan, ...orphans];
extras = [match.extra, ...extras];
};
const handleRefresh = async () => {
matches = [];
orphans = [];
extras = [];
try {
const report = await getAuditFiles();
orphans = report.orphans;
extras = normalize(report.extras);
notificationController.show({ message: $t('refreshed'), type: NotificationType.Info });
} catch (error) {
handleError(error, $t('errors.unable_to_load_items'));
}
};
const handleCheckOne = async (filename: string) => {
try {
const matched = await loadAndMatch([filename]);
if (matched) {
notificationController.show({
message: $t('admin.repair_matched_items', { values: { count: 1 } }),
type: NotificationType.Info,
});
}
} catch (error) {
handleError(error, $t('errors.repair_unable_to_check_items', { values: { count: 'one' } }));
}
};
const handleCheckAll = async () => {
checking = true;
let count = 0;
try {
const chunkSize = 10;
const filenames = extras.filter(({ checksum }) => !checksum).map(({ filename }) => filename);
for (let index = 0; index < filenames.length; index += chunkSize) {
count += await loadAndMatch(filenames.slice(index, index + chunkSize));
}
} catch (error) {
handleError(error, $t('errors.repair_unable_to_check_items', { values: { count: 'other' } }));
} finally {
checking = false;
}
notificationController.show({
message: $t('admin.repair_matched_items', { values: { count } }),
type: NotificationType.Info,
});
};
const loadAndMatch = async (filenames: string[]) => {
const items = await getFileChecksums({
fileChecksumDto: { filenames },
});
let count = 0;
for (const { checksum, filename } of items) {
const extra = extras.find((extra) => extra.filename === filename);
if (extra) {
extra.checksum = checksum;
extras = [...extras];
}
const orphan = orphans.find((orphan) => orphan.checksum === checksum);
if (orphan) {
count++;
matches = [...matches, { orphan, extra: { filename, checksum } }];
orphans = orphans.filter((_orphan) => _orphan !== orphan);
extras = extras.filter((extra) => extra.filename !== filename);
}
}
return count;
};
</script>
<UserPageLayout title={data.meta.title} admin>
{#snippet buttons()}
<HStack gap={0}>
<Button
leadingIcon={mdiWrench}
onclick={() => handleRepair()}
disabled={matches.length === 0 || repairing}
size="small"
variant="ghost"
color="secondary"
>
<Text class="hidden md:block">{$t('admin.repair_all')}</Text>
</Button>
<Button
leadingIcon={mdiCheckAll}
onclick={() => handleCheckAll()}
disabled={extras.length === 0 || checking}
size="small"
variant="ghost"
color="secondary"
>
<Text class="hidden md:block">{$t('admin.check_all')}</Text>
</Button>
<Button
leadingIcon={mdiDownload}
onclick={() => handleDownload()}
disabled={extras.length + orphans.length === 0}
size="small"
variant="ghost"
color="secondary"
>
<Text class="hidden md:block">{$t('export')}</Text>
</Button>
<Button leadingIcon={mdiRefresh} onclick={() => handleRefresh()} size="small" variant="ghost" color="secondary">
<Text class="hidden md:block">{$t('refresh')}</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]">
{#if matches.length + extras.length + orphans.length === 0}
<div class="w-full">
<EmptyPlaceholder fullWidth text={$t('repair_no_results_message')} src={empty4Url} />
</div>
{:else}
<div class="gap-2">
<table class="table-fixed mt-5 w-full text-start">
<thead
class="mb-4 flex w-full rounded-md border bg-gray-50 text-immich-primary dark:border-immich-dark-gray dark:bg-immich-dark-gray dark:text-immich-dark-primary"
>
<tr class="flex w-full place-items-center p-2 md:p-5">
<th class="w-full text-sm place-items-center font-medium flex justify-between" colspan="2">
<div class="px-3">
<p>
{$t('matches').toUpperCase()}
{matches.length > 0 ? `(${matches.length.toLocaleString($locale)})` : ''}
</p>
<p class="text-gray-600 dark:text-gray-300 mt-1">{$t('admin.these_files_matched_by_checksum')}</p>
</div>
</th>
</tr>
</thead>
<tbody
class="w-full overflow-y-auto rounded-md border dark:border-immich-dark-gray dark:text-immich-dark-fg max-h-[500px] block overflow-x-hidden"
>
{#each matches as match (match.extra.filename)}
<tr
class="w-full h-[75px] place-items-center border-[3px] border-transparent p-2 odd:bg-immich-gray even:bg-immich-bg hover:cursor-pointer hover:border-immich-primary/75 odd:dark:bg-immich-dark-gray/75 even:dark:bg-immich-dark-gray/50 dark:hover:border-immich-dark-primary/75 md:p-5 flex justify-between"
tabindex="0"
onclick={() => handleSplit(match)}
>
<td class="text-sm text-ellipsis flex flex-col gap-1 font-mono">
<span>{match.orphan.pathValue} =></span>
<span>{match.extra.filename}</span>
</td>
<td class="text-sm text-ellipsis d-flex font-mono">
<span>({match.orphan.entityType}/{match.orphan.pathType})</span>
</td>
</tr>
{/each}
</tbody>
</table>
<table class="table-fixed mt-5 w-full text-start">
<thead
class="mb-4 flex w-full rounded-md border bg-gray-50 text-immich-primary dark:border-immich-dark-gray dark:bg-immich-dark-gray dark:text-immich-dark-primary"
>
<tr class="flex w-full place-items-center p-1 md:p-5">
<th class="w-full text-sm font-medium justify-between place-items-center flex" colspan="2">
<div class="px-3">
<p>
{$t('admin.offline_paths').toUpperCase()}
{orphans.length > 0 ? `(${orphans.length.toLocaleString($locale)})` : ''}
</p>
<p class="text-gray-600 dark:text-gray-300 mt-1">
{$t('admin.offline_paths_description')}
</p>
</div>
</th>
</tr>
</thead>
<tbody
class="w-full rounded-md border dark:border-immich-dark-gray dark:text-immich-dark-fg overflow-y-auto max-h-[500px] block overflow-x-hidden"
>
{#each orphans as orphan, index (index)}
<tr
class="w-full h-[50px] place-items-center border-[3px] border-transparent odd:bg-immich-gray even:bg-immich-bg hover:cursor-pointer hover:border-immich-primary/75 odd:dark:bg-immich-dark-gray/75 even:dark:bg-immich-dark-gray/50 dark:hover:border-immich-dark-primary/75 md:p-5 flex justify-between"
tabindex="0"
title={orphan.pathValue}
>
<td onclick={() => copyToClipboard(orphan.pathValue)}>
<CircleIconButton title={$t('copy_file_path')} icon={mdiContentCopy} size="18" onclick={() => {}} />
</td>
<td class="truncate text-sm font-mono text-start" title={orphan.pathValue}>
{orphan.pathValue}
</td>
<td class="text-sm font-mono">
<span>({orphan.entityType})</span>
</td>
</tr>
{/each}
</tbody>
</table>
<table class="table-fixed mt-5 w-full text-start max-h-[300px]">
<thead
class="mb-4 flex w-full rounded-md border bg-gray-50 text-immich-primary dark:border-immich-dark-gray dark:bg-immich-dark-gray dark:text-immich-dark-primary"
>
<tr class="flex w-full place-items-center p-2 md:p-5">
<th class="w-full text-sm font-medium place-items-center flex justify-between" colspan="2">
<div class="px-3">
<p>
{$t('admin.untracked_files').toUpperCase()}
{extras.length > 0 ? `(${extras.length.toLocaleString($locale)})` : ''}
</p>
<p class="text-gray-600 dark:text-gray-300 mt-1">
{$t('admin.untracked_files_description')}
</p>
</div>
</th>
</tr>
</thead>
<tbody
class="w-full rounded-md border-2 dark:border-immich-dark-gray dark:text-immich-dark-fg overflow-y-auto max-h-[500px] block overflow-x-hidden"
>
{#each extras as extra (extra.filename)}
<tr
class="flex h-[50px] w-full place-items-center border-[3px] border-transparent p-1 odd:bg-immich-gray even:bg-immich-bg hover:cursor-pointer hover:border-immich-primary/75 odd:dark:bg-immich-dark-gray/75 even:dark:bg-immich-dark-gray/50 dark:hover:border-immich-dark-primary/75 md:p-5 justify-between"
tabindex="0"
onclick={() => handleCheckOne(extra.filename)}
title={extra.filename}
>
<td onclick={() => copyToClipboard(extra.filename)}>
<CircleIconButton title={$t('copy_file_path')} icon={mdiContentCopy} size="18" onclick={() => {}} />
</td>
<td class="w-full text-md text-ellipsis flex justify-between pe-5">
<span class="text-ellipsis grow truncate font-mono text-sm pe-5" title={extra.filename}
>{extra.filename}</span
>
<span class="text-sm font-mono dark:text-immich-dark-primary text-immich-primary pes-5">
{#if extra.checksum}
[sha1:{extra.checksum}]
{/if}
</span>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
</section>
</section>
</UserPageLayout>

View file

@ -1,18 +0,0 @@
import { authenticate } from '$lib/utils/auth';
import { getFormatter } from '$lib/utils/i18n';
import { getAuditFiles } from '@immich/sdk';
import type { PageLoad } from './$types';
export const load = (async () => {
await authenticate({ admin: true });
const { orphans, extras } = await getAuditFiles();
const $t = await getFormatter();
return {
orphans,
extras,
meta: {
title: $t('repair'),
},
};
}) satisfies PageLoad;