1
0
mirror of https://github.com/immich-app/immich.git synced 2024-12-25 10:43:13 +02:00

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
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
53 changed files with 1704 additions and 87 deletions

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

@ -59,6 +59,11 @@ doc/DownloadInfoDto.md
doc/DownloadResponseDto.md
doc/EntityType.md
doc/ExifResponseDto.md
doc/FileChecksumDto.md
doc/FileChecksumResponseDto.md
doc/FileReportDto.md
doc/FileReportFixDto.md
doc/FileReportItemDto.md
doc/ImportAssetDto.md
doc/JobApi.md
doc/JobCommand.md
@ -84,6 +89,8 @@ doc/OAuthCallbackDto.md
doc/OAuthConfigDto.md
doc/OAuthConfigResponseDto.md
doc/PartnerApi.md
doc/PathEntityType.md
doc/PathType.md
doc/PeopleResponseDto.md
doc/PeopleUpdateDto.md
doc/PeopleUpdateItem.md
@ -227,6 +234,11 @@ lib/model/download_info_dto.dart
lib/model/download_response_dto.dart
lib/model/entity_type.dart
lib/model/exif_response_dto.dart
lib/model/file_checksum_dto.dart
lib/model/file_checksum_response_dto.dart
lib/model/file_report_dto.dart
lib/model/file_report_fix_dto.dart
lib/model/file_report_item_dto.dart
lib/model/import_asset_dto.dart
lib/model/job_command.dart
lib/model/job_command_dto.dart
@ -248,6 +260,8 @@ lib/model/o_auth_authorize_response_dto.dart
lib/model/o_auth_callback_dto.dart
lib/model/o_auth_config_dto.dart
lib/model/o_auth_config_response_dto.dart
lib/model/path_entity_type.dart
lib/model/path_type.dart
lib/model/people_response_dto.dart
lib/model/people_update_dto.dart
lib/model/people_update_item.dart
@ -364,6 +378,11 @@ test/download_info_dto_test.dart
test/download_response_dto_test.dart
test/entity_type_test.dart
test/exif_response_dto_test.dart
test/file_checksum_dto_test.dart
test/file_checksum_response_dto_test.dart
test/file_report_dto_test.dart
test/file_report_fix_dto_test.dart
test/file_report_item_dto_test.dart
test/import_asset_dto_test.dart
test/job_api_test.dart
test/job_command_dto_test.dart
@ -389,6 +408,8 @@ test/o_auth_callback_dto_test.dart
test/o_auth_config_dto_test.dart
test/o_auth_config_response_dto_test.dart
test/partner_api_test.dart
test/path_entity_type_test.dart
test/path_type_test.dart
test/people_response_dto_test.dart
test/people_update_dto_test.dart
test/people_update_item_test.dart

BIN
mobile/openapi/README.md generated

Binary file not shown.

Binary file not shown.

BIN
mobile/openapi/doc/FileChecksumDto.md generated Normal file

Binary file not shown.

Binary file not shown.

BIN
mobile/openapi/doc/FileReportDto.md generated Normal file

Binary file not shown.

BIN
mobile/openapi/doc/FileReportFixDto.md generated Normal file

Binary file not shown.

BIN
mobile/openapi/doc/FileReportItemDto.md generated Normal file

Binary file not shown.

BIN
mobile/openapi/doc/PathEntityType.md generated Normal file

Binary file not shown.

BIN
mobile/openapi/doc/PathType.md generated Normal file

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.

BIN
mobile/openapi/lib/model/path_type.dart generated Normal file

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.

BIN
mobile/openapi/test/path_type_test.dart generated Normal file

Binary file not shown.

View File

@ -2286,6 +2286,118 @@
]
}
},
"/audit/file-report": {
"get": {
"operationId": "getAuditFiles",
"parameters": [],
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/FileReportDto"
}
}
},
"description": ""
}
},
"security": [
{
"bearer": []
},
{
"cookie": []
},
{
"api_key": []
}
],
"tags": [
"Audit"
]
}
},
"/audit/file-report/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": [
"Audit"
]
}
},
"/audit/file-report/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": [
"Audit"
]
}
},
"/auth/admin-sign-up": {
"post": {
"operationId": "adminSignUp",
@ -6580,6 +6692,97 @@
},
"type": "object"
},
"FileChecksumDto": {
"properties": {
"filenames": {
"items": {
"type": "string"
},
"type": "array"
}
},
"required": [
"filenames"
],
"type": "object"
},
"FileChecksumResponseDto": {
"properties": {
"checksum": {
"type": "string"
},
"filename": {
"type": "string"
}
},
"required": [
"filename",
"checksum"
],
"type": "object"
},
"FileReportDto": {
"properties": {
"extras": {
"items": {
"type": "string"
},
"type": "array"
},
"orphans": {
"items": {
"$ref": "#/components/schemas/FileReportItemDto"
},
"type": "array"
}
},
"required": [
"orphans",
"extras"
],
"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": {
"$ref": "#/components/schemas/PathEntityType"
},
"pathType": {
"$ref": "#/components/schemas/PathType"
},
"pathValue": {
"type": "string"
}
},
"required": [
"entityId",
"entityType",
"pathType",
"pathValue"
],
"type": "object"
},
"ImportAssetDto": {
"properties": {
"assetPath": {
@ -7027,6 +7230,26 @@
],
"type": "object"
},
"PathEntityType": {
"enum": [
"asset",
"person",
"user"
],
"type": "string"
},
"PathType": {
"enum": [
"original",
"jpeg_thumbnail",
"webp_thumbnail",
"encoded_video",
"sidecar",
"face",
"profile"
],
"type": "string"
},
"PeopleResponseDto": {
"properties": {
"people": {

View File

@ -1,8 +1,10 @@
import { EntityType } from '@app/infra/entities';
import { AssetPathType, EntityType, PathType, PersonPathType, UserPathType } from '@app/infra/entities';
import { ApiProperty } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import { IsDate, IsEnum, IsUUID } from 'class-validator';
import { Optional } from '../domain.util';
import { IsArray, IsDate, IsEnum, IsString, IsUUID, ValidateNested } from 'class-validator';
import { Optional, ValidateUUID } from '../domain.util';
const PathEnum = Object.values({ ...AssetPathType, ...PersonPathType, ...UserPathType });
export class AuditDeletesDto {
@IsDate()
@ -19,7 +21,54 @@ export class AuditDeletesDto {
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,17 +1,45 @@
import { DatabaseAction, EntityType } from '@app/infra/entities';
import { IAccessRepositoryMock, auditStub, authStub, newAccessRepositoryMock, newAuditRepositoryMock } from '@test';
import { IAuditRepository } from '../repositories';
import {
IAccessRepositoryMock,
auditStub,
authStub,
newAccessRepositoryMock,
newAssetRepositoryMock,
newAuditRepositoryMock,
newCryptoRepositoryMock,
newPersonRepositoryMock,
newStorageRepositoryMock,
newUserRepositoryMock,
} from '@test';
import {
IAssetRepository,
IAuditRepository,
ICryptoRepository,
IPersonRepository,
IStorageRepository,
IUserRepository,
} from '../repositories';
import { AuditService } from './audit.service';
describe(AuditService.name, () => {
let sut: AuditService;
let accessMock: IAccessRepositoryMock;
let assetMock: jest.Mocked<IAssetRepository>;
let auditMock: jest.Mocked<IAuditRepository>;
let cryptoMock: jest.Mocked<ICryptoRepository>;
let personMock: jest.Mocked<IPersonRepository>;
let storageMock: jest.Mocked<IStorageRepository>;
let userMock: jest.Mocked<IUserRepository>;
beforeEach(async () => {
accessMock = newAccessRepositoryMock();
assetMock = newAssetRepositoryMock();
cryptoMock = newCryptoRepositoryMock();
auditMock = newAuditRepositoryMock();
sut = new AuditService(accessMock, auditMock);
personMock = newPersonRepositoryMock();
storageMock = newStorageRepositoryMock();
userMock = newUserRepositoryMock();
sut = new AuditService(accessMock, assetMock, cryptoMock, personMock, auditMock, storageMock, userMock);
});
it('should work', () => {

View File

@ -1,19 +1,44 @@
import { DatabaseAction } from '@app/infra/entities';
import { Inject, Injectable } from '@nestjs/common';
import { AssetPathType, DatabaseAction, PersonPathType, UserPathType } from '@app/infra/entities';
import { BadRequestException, Inject, Injectable, Logger } from '@nestjs/common';
import { DateTime } from 'luxon';
import { resolve } from 'node:path';
import { AccessCore, Permission } from '../access';
import { AuthUserDto } from '../auth';
import { AUDIT_LOG_MAX_DURATION } from '../domain.constant';
import { IAccessRepository, IAuditRepository } from '../repositories';
import { AuditDeletesDto, AuditDeletesResponseDto } from './audit.dto';
import { usePagination } from '../domain.util';
import { JOBS_ASSET_PAGINATION_SIZE } from '../job';
import {
IAccessRepository,
IAssetRepository,
IAuditRepository,
ICryptoRepository,
IPersonRepository,
IStorageRepository,
IUserRepository,
} from '../repositories';
import { StorageCore, StorageFolder } from '../storage';
import {
AuditDeletesDto,
AuditDeletesResponseDto,
FileChecksumDto,
FileChecksumResponseDto,
FileReportItemDto,
PathEntityType,
} from './audit.dto';
@Injectable()
export class AuditService {
private access: AccessCore;
private logger = new Logger(AuditService.name);
constructor(
@Inject(IAccessRepository) accessRepository: IAccessRepository,
@Inject(IAssetRepository) private assetRepository: IAssetRepository,
@Inject(ICryptoRepository) private cryptoRepository: ICryptoRepository,
@Inject(IPersonRepository) private personRepository: IPersonRepository,
@Inject(IAuditRepository) private repository: IAuditRepository,
@Inject(IStorageRepository) private storageRepository: IStorageRepository,
@Inject(IUserRepository) private userRepository: IUserRepository,
) {
this.access = new AccessCore(accessRepository);
}
@ -40,4 +65,160 @@ export class AuditService {
ids: audits.map(({ entityId }) => entityId),
};
}
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.save({ id, encodedVideoPath: pathValue });
break;
case AssetPathType.JPEG_THUMBNAIL:
await this.assetRepository.save({ id, resizePath: pathValue });
break;
case AssetPathType.WEBP_THUMBNAIL:
await this.assetRepository.save({ id, webpPath: pathValue });
break;
case AssetPathType.ORIGINAL:
await this.assetRepository.save({ id, originalPath: pathValue });
break;
case AssetPathType.SIDECAR:
await this.assetRepository.save({ 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;
}
}
}
async getFileReport() {
const fullPath = (filename: string) => resolve(filename);
const hasFile = (items: Set<string>, filename: string) => items.has(filename) || items.has(fullPath(filename));
const crawl = async (folder: StorageFolder) =>
new Set(await this.storageRepository.crawl({ 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) => {
if (!filename) {
return;
}
allFiles.delete(filename);
allFiles.delete(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 }),
);
let assetCount = 0;
const orphans: FileReportItemDto[] = [];
for await (const assets of pagination) {
assetCount += assets.length;
for (const { id, originalPath, resizePath, encodedVideoPath, webpPath, isExternal, checksum } of assets) {
for (const file of [originalPath, resizePath, encodedVideoPath, webpPath]) {
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 (resizePath && !hasFile(thumbFiles, resizePath)) {
orphans.push({ ...entity, pathType: AssetPathType.JPEG_THUMBNAIL, pathValue: resizePath });
}
if (webpPath && !hasFile(thumbFiles, webpPath)) {
orphans.push({ ...entity, pathType: AssetPathType.WEBP_THUMBNAIL, pathValue: webpPath });
}
if (encodedVideoPath && !hasFile(videoFiles, encodedVideoPath)) {
orphans.push({ ...entity, pathType: AssetPathType.WEBP_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 });
}
}
const people = await this.personRepository.getAll();
for (const { id, thumbnailPath } of people) {
track(thumbnailPath);
const entity = { entityId: id, entityType: PathEntityType.PERSON };
if (thumbnailPath && !hasFile(thumbFiles, thumbnailPath)) {
orphans.push({ ...entity, pathType: PersonPathType.FACE, pathValue: thumbnailPath });
}
}
this.logger.log(`Found ${assetCount} assets, ${users.length} users, ${people.length} people`);
const extras: string[] = [];
for (const file of allFiles) {
extras.push(file);
}
// send as absolute paths
for (const orphan of orphans) {
orphan.pathValue = fullPath(orphan.pathValue);
}
return { orphans, extras };
}
}

View File

@ -289,6 +289,9 @@ export class MetadataService {
});
const checksum = this.cryptoRepository.hashSha1(video);
const motionPath = this.storageCore.getAndroidMotionPath(asset);
this.storageCore.ensureFolders(motionPath);
let motionAsset = await this.assetRepository.getByChecksum(asset.ownerId, checksum);
if (!motionAsset) {
const createdAt = asset.fileCreatedAt ?? asset.createdAt;
@ -300,7 +303,7 @@ export class MetadataService {
localDateTime: createdAt,
checksum,
ownerId: asset.ownerId,
originalPath: this.storageCore.getAndroidMotionPath(asset),
originalPath: motionPath,
originalFileName: asset.originalFileName,
isVisible: false,
isReadOnly: false,

View File

@ -14,6 +14,7 @@ export interface AssetSearchOptions {
trashedBefore?: Date;
type?: AssetType;
order?: 'ASC' | 'DESC';
withDeleted?: boolean;
}
export interface LivePhotoSearchOptions {

View File

@ -1,40 +1,20 @@
import {
newAssetRepositoryMock,
newMoveRepositoryMock,
newPersonRepositoryMock,
newStorageRepositoryMock,
newSystemConfigRepositoryMock,
newUserRepositoryMock,
} from '@test';
import { newStorageRepositoryMock, newSystemConfigRepositoryMock, newUserRepositoryMock } from '@test';
import { serverVersion } from '../domain.constant';
import {
IAssetRepository,
IMoveRepository,
IPersonRepository,
IStorageRepository,
ISystemConfigRepository,
IUserRepository,
} from '../repositories';
import { IStorageRepository, ISystemConfigRepository, IUserRepository } from '../repositories';
import { ServerInfoService } from './server-info.service';
describe(ServerInfoService.name, () => {
let sut: ServerInfoService;
let assetMock: jest.Mocked<IAssetRepository>;
let configMock: jest.Mocked<ISystemConfigRepository>;
let moveMock: jest.Mocked<IMoveRepository>;
let personMock: jest.Mocked<IPersonRepository>;
let storageMock: jest.Mocked<IStorageRepository>;
let userMock: jest.Mocked<IUserRepository>;
beforeEach(() => {
assetMock = newAssetRepositoryMock();
configMock = newSystemConfigRepositoryMock();
moveMock = newMoveRepositoryMock();
personMock = newPersonRepositoryMock();
storageMock = newStorageRepositoryMock();
userMock = newUserRepositoryMock();
sut = new ServerInfoService(assetMock, configMock, moveMock, personMock, userMock, storageMock);
sut = new ServerInfoService(configMock, userMock, storageMock);
});
it('should work', () => {

View File

@ -1,15 +1,7 @@
import { Inject, Injectable } from '@nestjs/common';
import { mimeTypes, serverVersion } from '../domain.constant';
import { asHumanReadable } from '../domain.util';
import {
IAssetRepository,
IMoveRepository,
IPersonRepository,
IStorageRepository,
ISystemConfigRepository,
IUserRepository,
UserStatsQueryResponse,
} from '../repositories';
import { IStorageRepository, ISystemConfigRepository, IUserRepository, UserStatsQueryResponse } from '../repositories';
import { StorageCore, StorageFolder } from '../storage';
import { SystemConfigCore } from '../system-config';
import {
@ -25,22 +17,17 @@ import {
@Injectable()
export class ServerInfoService {
private configCore: SystemConfigCore;
private storageCore: StorageCore;
constructor(
@Inject(IAssetRepository) assetRepository: IAssetRepository,
@Inject(ISystemConfigRepository) configRepository: ISystemConfigRepository,
@Inject(IMoveRepository) moveRepository: IMoveRepository,
@Inject(IPersonRepository) personRepository: IPersonRepository,
@Inject(IUserRepository) private userRepository: IUserRepository,
@Inject(IStorageRepository) private storageRepository: IStorageRepository,
) {
this.configCore = SystemConfigCore.create(configRepository);
this.storageCore = new StorageCore(storageRepository, assetRepository, moveRepository, personRepository);
}
async getInfo(): Promise<ServerInfoResponseDto> {
const libraryBase = this.storageCore.getBaseFolder(StorageFolder.LIBRARY);
const libraryBase = StorageCore.getBaseFolder(StorageFolder.LIBRARY);
const diskInfo = await this.storageRepository.checkDiskUsage(libraryBase);
const usagePercentage = (((diskInfo.total - diskInfo.free) / diskInfo.total) * 100).toFixed(2);

View File

@ -90,7 +90,7 @@ export class StorageTemplateService {
}
this.logger.debug('Cleaning up empty directories...');
const libraryFolder = this.storageCore.getBaseFolder(StorageFolder.LIBRARY);
const libraryFolder = StorageCore.getBaseFolder(StorageFolder.LIBRARY);
await this.storageRepository.removeEmptyDirs(libraryFolder);
this.logger.log('Finished storage template migration');

View File

@ -1,6 +1,6 @@
import { AssetEntity, AssetPathType, PathType, PersonEntity, PersonPathType } from '@app/infra/entities';
import { Logger } from '@nestjs/common';
import { dirname, join } from 'node:path';
import { dirname, join, resolve } from 'node:path';
import { APP_MEDIA_LOCATION } from '../domain.constant';
import { IAssetRepository, IMoveRepository, IPersonRepository, IStorageRepository } from '../repositories';
@ -32,14 +32,14 @@ export class StorageCore {
) {}
getFolderLocation(folder: StorageFolder, userId: string) {
return join(this.getBaseFolder(folder), userId);
return join(StorageCore.getBaseFolder(folder), userId);
}
getLibraryFolder(user: { storageLabel: string | null; id: string }) {
return join(this.getBaseFolder(StorageFolder.LIBRARY), user.storageLabel || user.id);
return join(StorageCore.getBaseFolder(StorageFolder.LIBRARY), user.storageLabel || user.id);
}
getBaseFolder(folder: StorageFolder) {
static getBaseFolder(folder: StorageFolder) {
return join(APP_MEDIA_LOCATION, folder);
}
@ -64,7 +64,11 @@ export class StorageCore {
}
isAndroidMotionPath(originalPath: string) {
return originalPath.startsWith(this.getBaseFolder(StorageFolder.ENCODED_VIDEO));
return originalPath.startsWith(StorageCore.getBaseFolder(StorageFolder.ENCODED_VIDEO));
}
static isImmichPath(path: string) {
return resolve(path).startsWith(resolve(APP_MEDIA_LOCATION));
}
async moveAssetFile(asset: AssetEntity, pathType: GeneratedAssetPath) {
@ -135,7 +139,7 @@ export class StorageCore {
}
removeEmptyDirs(folder: StorageFolder) {
return this.repository.removeEmptyDirs(this.getBaseFolder(folder));
return this.repository.removeEmptyDirs(StorageCore.getBaseFolder(folder));
}
private savePath(pathType: PathType, id: string, newPath: string) {

View File

@ -1,25 +1,14 @@
import {
newAssetRepositoryMock,
newMoveRepositoryMock,
newPersonRepositoryMock,
newStorageRepositoryMock,
} from '@test';
import { IAssetRepository, IMoveRepository, IPersonRepository, IStorageRepository } from '../repositories';
import { newStorageRepositoryMock } from '@test';
import { IStorageRepository } from '../repositories';
import { StorageService } from './storage.service';
describe(StorageService.name, () => {
let sut: StorageService;
let assetMock: jest.Mocked<IAssetRepository>;
let moveMock: jest.Mocked<IMoveRepository>;
let personMock: jest.Mocked<IPersonRepository>;
let storageMock: jest.Mocked<IStorageRepository>;
beforeEach(async () => {
assetMock = newAssetRepositoryMock();
moveMock = newMoveRepositoryMock();
personMock = newPersonRepositoryMock();
storageMock = newStorageRepositoryMock();
sut = new StorageService(assetMock, moveMock, personMock, storageMock);
sut = new StorageService(storageMock);
});
it('should work', () => {

View File

@ -1,24 +1,16 @@
import { Inject, Injectable, Logger } from '@nestjs/common';
import { IDeleteFilesJob } from '../job';
import { IAssetRepository, IMoveRepository, IPersonRepository, IStorageRepository } from '../repositories';
import { IStorageRepository } from '../repositories';
import { StorageCore, StorageFolder } from './storage.core';
@Injectable()
export class StorageService {
private logger = new Logger(StorageService.name);
private storageCore: StorageCore;
constructor(
@Inject(IAssetRepository) assetRepository: IAssetRepository,
@Inject(IMoveRepository) private moveRepository: IMoveRepository,
@Inject(IPersonRepository) personRepository: IPersonRepository,
@Inject(IStorageRepository) private storageRepository: IStorageRepository,
) {
this.storageCore = new StorageCore(storageRepository, assetRepository, moveRepository, personRepository);
}
constructor(@Inject(IStorageRepository) private storageRepository: IStorageRepository) {}
init() {
const libraryBase = this.storageCore.getBaseFolder(StorageFolder.LIBRARY);
const libraryBase = StorageCore.getBaseFolder(StorageFolder.LIBRARY);
this.storageRepository.mkdirSync(libraryBase);
}

View File

@ -1,7 +1,16 @@
import { AuditDeletesDto, AuditDeletesResponseDto, AuditService, AuthUserDto } from '@app/domain';
import { Controller, Get, Query } from '@nestjs/common';
import {
AuditDeletesDto,
AuditDeletesResponseDto,
AuditService,
AuthUserDto,
FileChecksumDto,
FileChecksumResponseDto,
FileReportDto,
FileReportFixDto,
} from '@app/domain';
import { Body, Controller, Get, Post, Query } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { AuthUser, Authenticated } from '../app.guard';
import { AdminRoute, AuthUser, Authenticated } from '../app.guard';
import { UseValidation } from '../app.utils';
@ApiTags('Audit')
@ -15,4 +24,22 @@ export class AuditController {
getAuditDeletes(@AuthUser() authUser: AuthUserDto, @Query() dto: AuditDeletesDto): Promise<AuditDeletesResponseDto> {
return this.service.getDeletes(authUser, dto);
}
@AdminRoute()
@Get('file-report')
getAuditFiles(): Promise<FileReportDto> {
return this.service.getFileReport();
}
@AdminRoute()
@Post('file-report/checksum')
getFileChecksums(@Body() dto: FileChecksumDto): Promise<FileChecksumResponseDto[]> {
return this.service.getChecksums(dto);
}
@AdminRoute()
@Post('file-report/fix')
fixAuditFiles(@Body() dto: FileReportFixDto): Promise<void> {
return this.service.fixItems(dto.items);
}
}

View File

@ -34,4 +34,8 @@ export enum PersonPathType {
FACE = 'face',
}
export type PathType = AssetPathType | PersonPathType;
export enum UserPathType {
PROFILE = 'profile',
}
export type PathType = AssetPathType | PersonPathType | UserPathType;

View File

@ -174,7 +174,7 @@ export class AssetRepository implements IAssetRepository {
person: true,
},
},
withDeleted: !!options.trashedBefore,
withDeleted: options.withDeleted ?? !!options.trashedBefore,
order: {
// Ensures correct order when paginating
createdAt: options.order ?? 'ASC',

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>