feat(web,server): offline/untracked files admin tool (#4447)

* feat: admin repair orphans tool

* chore: open api

* fix: include upload folder

* fix: bugs

* feat: empty placeholder

* fix: checks

* feat: move buttons to top of page

* feat: styling and clipboard

* styling

* better clicking hitbox

* fix: show title on hover

* feat: download report

* restrict file access to immich related files

* Add description

---------

Co-authored-by: Alex Tran <alex.tran1502@gmail.com>
Co-authored-by: Daniel Dietzler <mail@ddietzler.dev>
This commit is contained in:
Jason Rasmussen
2023-10-14 13:12:59 -04:00
committed by GitHub
parent ed386dd12a
commit d2807b8d6a
53 changed files with 3104 additions and 87 deletions

View File

@@ -19,6 +19,7 @@ import {
SystemConfigApi,
UserApi,
UserApiFp,
AuditApi,
} from './open-api';
import { BASE_PATH } from './open-api/base';
import { DUMMY_BASE_URL, toPathString } from './open-api/common';
@@ -28,6 +29,7 @@ export class ImmichApi {
public albumApi: AlbumApi;
public libraryApi: LibraryApi;
public assetApi: AssetApi;
public auditApi: AuditApi;
public authenticationApi: AuthenticationApi;
public jobApi: JobApi;
public keyApi: APIKeyApi;
@@ -51,6 +53,7 @@ export class ImmichApi {
this.config = new Configuration(params);
this.albumApi = new AlbumApi(this.config);
this.auditApi = new AuditApi(this.config);
this.libraryApi = new LibraryApi(this.config);
this.assetApi = new AssetApi(this.config);
this.authenticationApi = new AuthenticationApi(this.config);

View File

@@ -1604,6 +1604,109 @@ export interface ExifResponseDto {
*/
'timeZone'?: string | null;
}
/**
*
* @export
* @interface FileChecksumDto
*/
export interface FileChecksumDto {
/**
*
* @type {Array<string>}
* @memberof FileChecksumDto
*/
'filenames': Array<string>;
}
/**
*
* @export
* @interface FileChecksumResponseDto
*/
export interface FileChecksumResponseDto {
/**
*
* @type {string}
* @memberof FileChecksumResponseDto
*/
'checksum': string;
/**
*
* @type {string}
* @memberof FileChecksumResponseDto
*/
'filename': string;
}
/**
*
* @export
* @interface FileReportDto
*/
export interface FileReportDto {
/**
*
* @type {Array<string>}
* @memberof FileReportDto
*/
'extras': Array<string>;
/**
*
* @type {Array<FileReportItemDto>}
* @memberof FileReportDto
*/
'orphans': Array<FileReportItemDto>;
}
/**
*
* @export
* @interface FileReportFixDto
*/
export interface FileReportFixDto {
/**
*
* @type {Array<FileReportItemDto>}
* @memberof FileReportFixDto
*/
'items': Array<FileReportItemDto>;
}
/**
*
* @export
* @interface FileReportItemDto
*/
export interface FileReportItemDto {
/**
*
* @type {string}
* @memberof FileReportItemDto
*/
'checksum'?: string;
/**
*
* @type {string}
* @memberof FileReportItemDto
*/
'entityId': string;
/**
*
* @type {PathEntityType}
* @memberof FileReportItemDto
*/
'entityType': PathEntityType;
/**
*
* @type {PathType}
* @memberof FileReportItemDto
*/
'pathType': PathType;
/**
*
* @type {string}
* @memberof FileReportItemDto
*/
'pathValue': string;
}
/**
*
* @export
@@ -2186,6 +2289,40 @@ export interface OAuthConfigResponseDto {
*/
'url'?: string;
}
/**
*
* @export
* @enum {string}
*/
export const PathEntityType = {
Asset: 'asset',
Person: 'person',
User: 'user'
} as const;
export type PathEntityType = typeof PathEntityType[keyof typeof PathEntityType];
/**
*
* @export
* @enum {string}
*/
export const PathType = {
Original: 'original',
JpegThumbnail: 'jpeg_thumbnail',
WebpThumbnail: 'webp_thumbnail',
EncodedVideo: 'encoded_video',
Sidecar: 'sidecar',
Face: 'face',
Profile: 'profile'
} as const;
export type PathType = typeof PathType[keyof typeof PathType];
/**
*
* @export
@@ -8821,6 +8958,50 @@ export class AssetApi extends BaseAPI {
*/
export const AuditApiAxiosParamCreator = function (configuration?: Configuration) {
return {
/**
*
* @param {FileReportFixDto} fileReportFixDto
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
fixAuditFiles: async (fileReportFixDto: FileReportFixDto, options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
// verify required parameter 'fileReportFixDto' is not null or undefined
assertParamExists('fixAuditFiles', 'fileReportFixDto', fileReportFixDto)
const localVarPath = `/audit/file-report/fix`;
// use dummy base URL string because the URL constructor only accepts absolute URLs.
const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
let baseOptions;
if (configuration) {
baseOptions = configuration.baseOptions;
}
const localVarRequestOptions = { method: 'POST', ...baseOptions, ...options};
const localVarHeaderParameter = {} as any;
const localVarQueryParameter = {} as any;
// authentication cookie required
// authentication api_key required
await setApiKeyToObject(localVarHeaderParameter, "x-api-key", configuration)
// authentication bearer required
// http bearer authentication required
await setBearerAuthToObject(localVarHeaderParameter, configuration)
localVarHeaderParameter['Content-Type'] = 'application/json';
setSearchParams(localVarUrlObj, localVarQueryParameter);
let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
localVarRequestOptions.data = serializeDataIfNeeded(fileReportFixDto, localVarRequestOptions, configuration)
return {
url: toPathString(localVarUrlObj),
options: localVarRequestOptions,
};
},
/**
*
* @param {EntityType} entityType
@@ -8875,6 +9056,88 @@ export const AuditApiAxiosParamCreator = function (configuration?: Configuration
let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
return {
url: toPathString(localVarUrlObj),
options: localVarRequestOptions,
};
},
/**
*
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
getAuditFiles: async (options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
const localVarPath = `/audit/file-report`;
// use dummy base URL string because the URL constructor only accepts absolute URLs.
const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
let baseOptions;
if (configuration) {
baseOptions = configuration.baseOptions;
}
const localVarRequestOptions = { method: 'GET', ...baseOptions, ...options};
const localVarHeaderParameter = {} as any;
const localVarQueryParameter = {} as any;
// authentication cookie required
// authentication api_key required
await setApiKeyToObject(localVarHeaderParameter, "x-api-key", configuration)
// authentication bearer required
// http bearer authentication required
await setBearerAuthToObject(localVarHeaderParameter, configuration)
setSearchParams(localVarUrlObj, localVarQueryParameter);
let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
return {
url: toPathString(localVarUrlObj),
options: localVarRequestOptions,
};
},
/**
*
* @param {FileChecksumDto} fileChecksumDto
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
getFileChecksums: async (fileChecksumDto: FileChecksumDto, options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
// verify required parameter 'fileChecksumDto' is not null or undefined
assertParamExists('getFileChecksums', 'fileChecksumDto', fileChecksumDto)
const localVarPath = `/audit/file-report/checksum`;
// use dummy base URL string because the URL constructor only accepts absolute URLs.
const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
let baseOptions;
if (configuration) {
baseOptions = configuration.baseOptions;
}
const localVarRequestOptions = { method: 'POST', ...baseOptions, ...options};
const localVarHeaderParameter = {} as any;
const localVarQueryParameter = {} as any;
// authentication cookie required
// authentication api_key required
await setApiKeyToObject(localVarHeaderParameter, "x-api-key", configuration)
// authentication bearer required
// http bearer authentication required
await setBearerAuthToObject(localVarHeaderParameter, configuration)
localVarHeaderParameter['Content-Type'] = 'application/json';
setSearchParams(localVarUrlObj, localVarQueryParameter);
let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
localVarRequestOptions.data = serializeDataIfNeeded(fileChecksumDto, localVarRequestOptions, configuration)
return {
url: toPathString(localVarUrlObj),
options: localVarRequestOptions,
@@ -8890,6 +9153,16 @@ export const AuditApiAxiosParamCreator = function (configuration?: Configuration
export const AuditApiFp = function(configuration?: Configuration) {
const localVarAxiosParamCreator = AuditApiAxiosParamCreator(configuration)
return {
/**
*
* @param {FileReportFixDto} fileReportFixDto
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
async fixAuditFiles(fileReportFixDto: FileReportFixDto, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<void>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.fixAuditFiles(fileReportFixDto, options);
return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
},
/**
*
* @param {EntityType} entityType
@@ -8902,6 +9175,25 @@ export const AuditApiFp = function(configuration?: Configuration) {
const localVarAxiosArgs = await localVarAxiosParamCreator.getAuditDeletes(entityType, after, userId, options);
return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
},
/**
*
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
async getAuditFiles(options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<FileReportDto>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.getAuditFiles(options);
return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
},
/**
*
* @param {FileChecksumDto} fileChecksumDto
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
async getFileChecksums(fileChecksumDto: FileChecksumDto, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<Array<FileChecksumResponseDto>>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.getFileChecksums(fileChecksumDto, options);
return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
},
}
};
@@ -8912,6 +9204,15 @@ export const AuditApiFp = function(configuration?: Configuration) {
export const AuditApiFactory = function (configuration?: Configuration, basePath?: string, axios?: AxiosInstance) {
const localVarFp = AuditApiFp(configuration)
return {
/**
*
* @param {AuditApiFixAuditFilesRequest} requestParameters Request parameters.
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
fixAuditFiles(requestParameters: AuditApiFixAuditFilesRequest, options?: AxiosRequestConfig): AxiosPromise<void> {
return localVarFp.fixAuditFiles(requestParameters.fileReportFixDto, options).then((request) => request(axios, basePath));
},
/**
*
* @param {AuditApiGetAuditDeletesRequest} requestParameters Request parameters.
@@ -8921,9 +9222,40 @@ export const AuditApiFactory = function (configuration?: Configuration, basePath
getAuditDeletes(requestParameters: AuditApiGetAuditDeletesRequest, options?: AxiosRequestConfig): AxiosPromise<AuditDeletesResponseDto> {
return localVarFp.getAuditDeletes(requestParameters.entityType, requestParameters.after, requestParameters.userId, options).then((request) => request(axios, basePath));
},
/**
*
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
getAuditFiles(options?: AxiosRequestConfig): AxiosPromise<FileReportDto> {
return localVarFp.getAuditFiles(options).then((request) => request(axios, basePath));
},
/**
*
* @param {AuditApiGetFileChecksumsRequest} requestParameters Request parameters.
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
getFileChecksums(requestParameters: AuditApiGetFileChecksumsRequest, options?: AxiosRequestConfig): AxiosPromise<Array<FileChecksumResponseDto>> {
return localVarFp.getFileChecksums(requestParameters.fileChecksumDto, options).then((request) => request(axios, basePath));
},
};
};
/**
* Request parameters for fixAuditFiles operation in AuditApi.
* @export
* @interface AuditApiFixAuditFilesRequest
*/
export interface AuditApiFixAuditFilesRequest {
/**
*
* @type {FileReportFixDto}
* @memberof AuditApiFixAuditFiles
*/
readonly fileReportFixDto: FileReportFixDto
}
/**
* Request parameters for getAuditDeletes operation in AuditApi.
* @export
@@ -8952,6 +9284,20 @@ export interface AuditApiGetAuditDeletesRequest {
readonly userId?: string
}
/**
* Request parameters for getFileChecksums operation in AuditApi.
* @export
* @interface AuditApiGetFileChecksumsRequest
*/
export interface AuditApiGetFileChecksumsRequest {
/**
*
* @type {FileChecksumDto}
* @memberof AuditApiGetFileChecksums
*/
readonly fileChecksumDto: FileChecksumDto
}
/**
* AuditApi - object-oriented interface
* @export
@@ -8959,6 +9305,17 @@ export interface AuditApiGetAuditDeletesRequest {
* @extends {BaseAPI}
*/
export class AuditApi extends BaseAPI {
/**
*
* @param {AuditApiFixAuditFilesRequest} requestParameters Request parameters.
* @param {*} [options] Override http request option.
* @throws {RequiredError}
* @memberof AuditApi
*/
public fixAuditFiles(requestParameters: AuditApiFixAuditFilesRequest, options?: AxiosRequestConfig) {
return AuditApiFp(this.configuration).fixAuditFiles(requestParameters.fileReportFixDto, options).then((request) => request(this.axios, this.basePath));
}
/**
*
* @param {AuditApiGetAuditDeletesRequest} requestParameters Request parameters.
@@ -8969,6 +9326,27 @@ export class AuditApi extends BaseAPI {
public getAuditDeletes(requestParameters: AuditApiGetAuditDeletesRequest, options?: AxiosRequestConfig) {
return AuditApiFp(this.configuration).getAuditDeletes(requestParameters.entityType, requestParameters.after, requestParameters.userId, options).then((request) => request(this.axios, this.basePath));
}
/**
*
* @param {*} [options] Override http request option.
* @throws {RequiredError}
* @memberof AuditApi
*/
public getAuditFiles(options?: AxiosRequestConfig) {
return AuditApiFp(this.configuration).getAuditFiles(options).then((request) => request(this.axios, this.basePath));
}
/**
*
* @param {AuditApiGetFileChecksumsRequest} requestParameters Request parameters.
* @param {*} [options] Override http request option.
* @throws {RequiredError}
* @memberof AuditApi
*/
public getFileChecksums(requestParameters: AuditApiGetFileChecksumsRequest, options?: AxiosRequestConfig) {
return AuditApiFp(this.configuration).getFileChecksums(requestParameters.fileChecksumDto, options).then((request) => request(this.axios, this.basePath));
}
}

View File

@@ -0,0 +1 @@
<svg width="900" height="600" viewBox="0 0 900 600" fill="none" xmlns="http://www.w3.org/2000/svg"><path fill="transparent" d="M0 0h900v600H0z"/><path d="M214.359 475.389c16.42 16.712 47.124 13.189 47.124 13.189s4.064-30.62-12.372-47.322c-16.419-16.712-47.109-13.198-47.109-13.198s-4.063 30.619 12.357 47.331z" fill="url(#a)"/><path d="M639.439 125.517c-17.194 9.808-41.345-.121-41.345-.121s3.743-25.827 20.946-35.623c17.194-9.808 41.335.11 41.335.11s-3.743 25.827-20.936 35.634z" fill="url(#b)"/><path d="M324.812 156.133c-17.672 17.987-50.72 14.194-50.72 14.194s-4.373-32.955 13.316-50.931c17.673-17.987 50.704-14.206 50.704-14.206s4.373 32.956-13.3 50.943z" fill="url(#c)"/><ellipse rx="15.17" ry="15.928" transform="matrix(1 0 0 -1 228.07 341.957)" fill="#E1E4E5"/><circle r="8.5" transform="matrix(1 0 0 -1 478.5 509.5)" fill="#9d9ea3"/><circle r="17.518" transform="matrix(1 0 0 -1 693.518 420.518)" fill="#9d9ea3"/><circle cx="708.183" cy="266.183" r="14.183" fill="#4F4F51"/><circle cx="247.603" cy="225.621" r="12.136" fill="#F8AE9D"/><ellipse cx="316.324" cy="510.867" rx="7.324" ry="6.867" fill="#E1E4E5"/><ellipse cx="664.796" cy="371.388" rx="9.796" ry="9.388" fill="#E1E4E5"/><circle cx="625.378" cy="479.378" r="11.377" fill="#E1E4E5"/><ellipse cx="401.025" cy="114.39" rx="5.309" ry="6.068" fill="#E1E4E5"/><circle cx="661.834" cy="300.834" r="5.58" transform="rotate(105 661.834 300.834)" fill="#E1E4E5"/><circle cx="654.769" cy="226.082" r="7.585" fill="#E1E4E5"/><ellipse cx="254.159" cy="284.946" rx="5.309" ry="4.551" fill="#E1E4E5"/><circle cx="521.363" cy="106.27" r="11.613" transform="rotate(105 521.363 106.27)" fill="#E1E4E5"/><path d="M162.314 308.103h-.149C161.284 320.589 152 320.781 152 320.781s10.238.2 10.238 14.628c0-14.428 10.238-14.628 10.238-14.628s-9.281-.192-10.162-12.678zm531.83-158.512h-.256c-1.518 21.504-17.507 21.835-17.507 21.835s17.632.345 17.632 25.192c0-24.847 17.632-25.192 17.632-25.192s-15.983-.331-17.501-21.835z" fill="#E1E4E5"/><path fill-rule="evenodd" clip-rule="evenodd" d="M553.714 397.505v56.123c0 20.672-16.743 37.416-37.415 37.416H329.22c-20.672 0-37.415-16.744-37.415-37.416V266.55c0-20.672 16.743-37.416 37.415-37.416h56.124" fill="url(#d)"/><path fill-rule="evenodd" clip-rule="evenodd" d="M363.07 155.431h214.049c26.28 0 47.566 21.286 47.566 47.566v214.049c0 26.28-21.286 47.566-47.566 47.566H363.07c-26.28 0-47.566-21.286-47.566-47.566V202.997c0-26.28 21.286-47.566 47.566-47.566z" fill="#9d9ea3"/><path d="m425.113 307.765 33.925 33.924 74.038-74.059" stroke="#fff" stroke-width="32.125" stroke-linecap="round" stroke-linejoin="round"/><defs><linearGradient id="a" x1="279.871" y1="532.474" x2="161.165" y2="346.391" gradientUnits="userSpaceOnUse"><stop stop-color="#fff"/><stop offset="1" stop-color="#EEE"/></linearGradient><linearGradient id="b" x1="573.046" y1="156.85" x2="712.364" y2="32.889" gradientUnits="userSpaceOnUse"><stop stop-color="#fff"/><stop offset="1" stop-color="#EEE"/></linearGradient><linearGradient id="c" x1="254.302" y1="217.573" x2="382.065" y2="17.293" gradientUnits="userSpaceOnUse"><stop stop-color="#fff"/><stop offset="1" stop-color="#EEE"/></linearGradient><linearGradient id="d" x1="417.175" y1="82.293" x2="425.251" y2="775.957" gradientUnits="userSpaceOnUse"><stop stop-color="#fff"/><stop offset="1" stop-color="#EEE"/></linearGradient></defs></svg>

After

Width:  |  Height:  |  Size: 3.3 KiB

View File

@@ -6,8 +6,9 @@
import Button from './button.svelte';
export let color: Color = 'transparent-gray';
export let disabled = false;
</script>
<Button size="link" {color} shadow={false} rounded="lg" on:click>
<Button size="link" {color} shadow={false} rounded="lg" {disabled} on:click>
<slot />
</Button>

View File

@@ -7,6 +7,7 @@
import AccountMultipleOutline from 'svelte-material-icons/AccountMultipleOutline.svelte';
import Cog from 'svelte-material-icons/Cog.svelte';
import Server from 'svelte-material-icons/Server.svelte';
import Tools from 'svelte-material-icons/Tools.svelte';
import Sync from 'svelte-material-icons/Sync.svelte';
</script>
@@ -27,6 +28,9 @@
<a data-sveltekit-preload-data="hover" href={AppRoute.ADMIN_STATS} draggable="false">
<SideBarButton title="Server Stats" logo={Server} isSelected={$page.route.id === AppRoute.ADMIN_STATS} />
</a>
<a data-sveltekit-preload-data="hover" href={AppRoute.ADMIN_REPAIR} draggable="false">
<SideBarButton title="Repair" logo={Tools} isSelected={$page.route.id === AppRoute.ADMIN_REPAIR} />
</a>
<div class="mb-6 mt-auto">
<StatusBox />
</div>

View File

@@ -12,6 +12,7 @@ export enum AppRoute {
ADMIN_SETTINGS = '/admin/system-settings',
ADMIN_STATS = '/admin/server-status',
ADMIN_JOBS = '/admin/jobs-status',
ADMIN_REPAIR = '/admin/repair',
ALBUMS = '/albums',
LIBRARIES = '/libraries',

View File

@@ -0,0 +1,26 @@
import { AppRoute } from '$lib/constants';
import { redirect } from '@sveltejs/kit';
import type { PageServerLoad } from './$types';
export const load = (async ({ parent, locals: { api } }) => {
const { user } = await parent();
if (!user) {
throw redirect(302, AppRoute.AUTH_LOGIN);
} else if (!user.isAdmin) {
throw redirect(302, AppRoute.PHOTOS);
}
const {
data: { orphans, extras },
} = await api.auditApi.getAuditFiles();
return {
user,
orphans,
extras,
meta: {
title: 'Repair',
},
};
}) satisfies PageServerLoad;

View File

@@ -0,0 +1,336 @@
<script lang="ts">
import empty4Url from '$lib/assets/empty-4.svg';
import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte';
import LinkButton from '$lib/components/elements/buttons/link-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/stores/download';
import { downloadBlob } from '$lib/utils/asset-utils';
import { handleError } from '$lib/utils/handle-error';
import { FileReportItemDto, api, copyToClipboard } from '@api';
import CheckAll from 'svelte-material-icons/CheckAll.svelte';
import ContentCopy from 'svelte-material-icons/ContentCopy.svelte';
import Download from 'svelte-material-icons/Download.svelte';
import Refresh from 'svelte-material-icons/Refresh.svelte';
import Wrench from 'svelte-material-icons/Wrench.svelte';
import type { PageData } from './$types';
export let data: PageData;
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 = false;
let repairing = false;
let orphans: FileReportItemDto[] = data.orphans;
let extras: UntrackedFile[] = normalize(data.extras);
let matches: Match[] = [];
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), 5_000);
}
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), 5_000);
}
};
const handleRepair = async () => {
if (matches.length === 0) {
return;
}
repairing = true;
try {
await api.auditApi.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: `Repaired ${matches.length} items`,
});
matches = [];
} catch (error) {
handleError(error, '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 { data: report } = await api.auditApi.getAuditFiles();
orphans = report.orphans;
extras = normalize(report.extras);
notificationController.show({ message: 'Refreshed', type: NotificationType.Info });
} catch (error) {
handleError(error, 'Unable to load items');
}
};
const handleCheckOne = async (filename: string) => {
try {
const matched = await loadAndMatch([filename]);
if (matched) {
notificationController.show({ message: `Matched 1 item`, type: NotificationType.Info });
}
} catch (error) {
handleError(error, 'Unable to check item');
}
};
const handleCheckAll = async () => {
checking = true;
let count = 0;
try {
const chunkSize = 10;
const filenames = [...extras.filter(({ checksum }) => !checksum).map(({ filename }) => filename)];
for (let i = 0; i < filenames.length; i += chunkSize) {
count += await loadAndMatch(filenames.slice(i, i + chunkSize));
}
} catch (error) {
handleError(error, 'Unable to check items');
} finally {
checking = false;
}
notificationController.show({ message: `Matched ${count} items`, type: NotificationType.Info });
};
const loadAndMatch = async (filenames: string[]) => {
const { data: items } = await api.auditApi.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 user={data.user} title={data.meta.title} admin>
<svelte:fragment slot="sidebar" />
<div class="flex justify-end gap-2" slot="buttons">
<LinkButton on:click={() => handleRepair()} disabled={matches.length === 0 || repairing}>
<div class="flex place-items-center gap-2 text-sm">
<Wrench size="18" />
Repair All
</div>
</LinkButton>
<LinkButton on:click={() => handleCheckAll()} disabled={extras.length === 0 || checking}>
<div class="flex place-items-center gap-2 text-sm">
<CheckAll size="18" />
Check All
</div>
</LinkButton>
<LinkButton on:click={() => handleDownload()} disabled={extras.length + orphans.length === 0}>
<div class="flex place-items-center gap-2 text-sm">
<Download size="18" />
Export
</div>
</LinkButton>
<LinkButton on:click={() => handleRefresh()}>
<div class="flex place-items-center gap-2 text-sm">
<Refresh size="18" />
Refresh
</div>
</LinkButton>
</div>
<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="Untracked and missing files will show up here"
alt="Empty report"
src={empty4Url}
/>
</div>
{:else}
<div class="gap-2">
<table class="table-fixed mt-5 w-full text-left">
<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>MATCHES {matches.length ? `(${matches.length})` : ''}</p>
<p class="text-gray-600 dark:text-gray-300 mt-1">These files are matched by their checksums</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"
on:click={() => 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-left">
<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>OFFLINE PATHS {orphans.length ? `(${orphans.length})` : ''}</p>
<p class="text-gray-600 dark:text-gray-300 mt-1">
These files are the results of manually deletion of the default upload library
</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 on:click={() => copyToClipboard(orphan.pathValue)}>
<CircleIconButton logo={ContentCopy} size="18" />
</td>
<td class="truncate text-sm font-mono text-left" 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-left 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>UNTRACKS FILES {extras.length ? `(${extras.length})` : ''}</p>
<p class="text-gray-600 dark:text-gray-300 mt-1">
These files are not tracked by the application. They can be the results of failed moves,
interrupted uploads, or left behind due to a bug
</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"
on:click={() => handleCheckOne(extra.filename)}
title={extra.filename}
>
<td on:click={() => copyToClipboard(extra.filename)}>
<CircleIconButton logo={ContentCopy} size="18" />
</td>
<td class="w-full text-md text-ellipsis flex justify-between pr-5">
<span class="text-ellipsis grow truncate font-mono text-sm pr-5" title={extra.filename}
>{extra.filename}</span
>
<span class="text-sm font-mono dark:text-immich-dark-primary text-immich-primary pr-5">
{#if extra.checksum}
[sha1:{extra.checksum}]
{/if}
</span>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
</section>
</section>
</UserPageLayout>