diff --git a/cli/src/api/open-api/api.ts b/cli/src/api/open-api/api.ts index 0c8d2673cc..549cc59d0a 100644 --- a/cli/src/api/open-api/api.ts +++ b/cli/src/api/open-api/api.ts @@ -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)); + } } diff --git a/mobile/openapi/.openapi-generator/FILES b/mobile/openapi/.openapi-generator/FILES index 494835c0a7..bf699a3133 100644 --- a/mobile/openapi/.openapi-generator/FILES +++ b/mobile/openapi/.openapi-generator/FILES @@ -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 diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index 2865b1308a..8217b2f292 100644 Binary files a/mobile/openapi/README.md and b/mobile/openapi/README.md differ diff --git a/mobile/openapi/doc/AuditApi.md b/mobile/openapi/doc/AuditApi.md index 63a1c97a3b..8fbca70bc7 100644 Binary files a/mobile/openapi/doc/AuditApi.md and b/mobile/openapi/doc/AuditApi.md differ diff --git a/mobile/openapi/doc/FileChecksumDto.md b/mobile/openapi/doc/FileChecksumDto.md new file mode 100644 index 0000000000..b7070431fc Binary files /dev/null and b/mobile/openapi/doc/FileChecksumDto.md differ diff --git a/mobile/openapi/doc/FileChecksumResponseDto.md b/mobile/openapi/doc/FileChecksumResponseDto.md new file mode 100644 index 0000000000..9cdea7280a Binary files /dev/null and b/mobile/openapi/doc/FileChecksumResponseDto.md differ diff --git a/mobile/openapi/doc/FileReportDto.md b/mobile/openapi/doc/FileReportDto.md new file mode 100644 index 0000000000..c6fd73e2d0 Binary files /dev/null and b/mobile/openapi/doc/FileReportDto.md differ diff --git a/mobile/openapi/doc/FileReportFixDto.md b/mobile/openapi/doc/FileReportFixDto.md new file mode 100644 index 0000000000..58135dcb03 Binary files /dev/null and b/mobile/openapi/doc/FileReportFixDto.md differ diff --git a/mobile/openapi/doc/FileReportItemDto.md b/mobile/openapi/doc/FileReportItemDto.md new file mode 100644 index 0000000000..8ba2b0a343 Binary files /dev/null and b/mobile/openapi/doc/FileReportItemDto.md differ diff --git a/mobile/openapi/doc/PathEntityType.md b/mobile/openapi/doc/PathEntityType.md new file mode 100644 index 0000000000..12783a48f0 Binary files /dev/null and b/mobile/openapi/doc/PathEntityType.md differ diff --git a/mobile/openapi/doc/PathType.md b/mobile/openapi/doc/PathType.md new file mode 100644 index 0000000000..48e9443688 Binary files /dev/null and b/mobile/openapi/doc/PathType.md differ diff --git a/mobile/openapi/lib/api.dart b/mobile/openapi/lib/api.dart index 2c6837cb71..48745a162c 100644 Binary files a/mobile/openapi/lib/api.dart and b/mobile/openapi/lib/api.dart differ diff --git a/mobile/openapi/lib/api/audit_api.dart b/mobile/openapi/lib/api/audit_api.dart index 4eabd17c9c..24b93f178f 100644 Binary files a/mobile/openapi/lib/api/audit_api.dart and b/mobile/openapi/lib/api/audit_api.dart differ diff --git a/mobile/openapi/lib/api_client.dart b/mobile/openapi/lib/api_client.dart index 1866c46883..9a98b4997a 100644 Binary files a/mobile/openapi/lib/api_client.dart and b/mobile/openapi/lib/api_client.dart differ diff --git a/mobile/openapi/lib/api_helper.dart b/mobile/openapi/lib/api_helper.dart index ee254516df..417a282e09 100644 Binary files a/mobile/openapi/lib/api_helper.dart and b/mobile/openapi/lib/api_helper.dart differ diff --git a/mobile/openapi/lib/model/file_checksum_dto.dart b/mobile/openapi/lib/model/file_checksum_dto.dart new file mode 100644 index 0000000000..e587586cca Binary files /dev/null and b/mobile/openapi/lib/model/file_checksum_dto.dart differ diff --git a/mobile/openapi/lib/model/file_checksum_response_dto.dart b/mobile/openapi/lib/model/file_checksum_response_dto.dart new file mode 100644 index 0000000000..2cfd87b4b9 Binary files /dev/null and b/mobile/openapi/lib/model/file_checksum_response_dto.dart differ diff --git a/mobile/openapi/lib/model/file_report_dto.dart b/mobile/openapi/lib/model/file_report_dto.dart new file mode 100644 index 0000000000..0d00938b8a Binary files /dev/null and b/mobile/openapi/lib/model/file_report_dto.dart differ diff --git a/mobile/openapi/lib/model/file_report_fix_dto.dart b/mobile/openapi/lib/model/file_report_fix_dto.dart new file mode 100644 index 0000000000..8bf8062d30 Binary files /dev/null and b/mobile/openapi/lib/model/file_report_fix_dto.dart differ diff --git a/mobile/openapi/lib/model/file_report_item_dto.dart b/mobile/openapi/lib/model/file_report_item_dto.dart new file mode 100644 index 0000000000..77b3f72505 Binary files /dev/null and b/mobile/openapi/lib/model/file_report_item_dto.dart differ diff --git a/mobile/openapi/lib/model/path_entity_type.dart b/mobile/openapi/lib/model/path_entity_type.dart new file mode 100644 index 0000000000..bbc8d51586 Binary files /dev/null and b/mobile/openapi/lib/model/path_entity_type.dart differ diff --git a/mobile/openapi/lib/model/path_type.dart b/mobile/openapi/lib/model/path_type.dart new file mode 100644 index 0000000000..9cb02e0c9e Binary files /dev/null and b/mobile/openapi/lib/model/path_type.dart differ diff --git a/mobile/openapi/test/audit_api_test.dart b/mobile/openapi/test/audit_api_test.dart index 68ffede19c..2ce8d3d860 100644 Binary files a/mobile/openapi/test/audit_api_test.dart and b/mobile/openapi/test/audit_api_test.dart differ diff --git a/mobile/openapi/test/file_checksum_dto_test.dart b/mobile/openapi/test/file_checksum_dto_test.dart new file mode 100644 index 0000000000..6eb3a39023 Binary files /dev/null and b/mobile/openapi/test/file_checksum_dto_test.dart differ diff --git a/mobile/openapi/test/file_checksum_response_dto_test.dart b/mobile/openapi/test/file_checksum_response_dto_test.dart new file mode 100644 index 0000000000..a90fc61649 Binary files /dev/null and b/mobile/openapi/test/file_checksum_response_dto_test.dart differ diff --git a/mobile/openapi/test/file_report_dto_test.dart b/mobile/openapi/test/file_report_dto_test.dart new file mode 100644 index 0000000000..a843046683 Binary files /dev/null and b/mobile/openapi/test/file_report_dto_test.dart differ diff --git a/mobile/openapi/test/file_report_fix_dto_test.dart b/mobile/openapi/test/file_report_fix_dto_test.dart new file mode 100644 index 0000000000..44e7344295 Binary files /dev/null and b/mobile/openapi/test/file_report_fix_dto_test.dart differ diff --git a/mobile/openapi/test/file_report_item_dto_test.dart b/mobile/openapi/test/file_report_item_dto_test.dart new file mode 100644 index 0000000000..7e90322f70 Binary files /dev/null and b/mobile/openapi/test/file_report_item_dto_test.dart differ diff --git a/mobile/openapi/test/path_entity_type_test.dart b/mobile/openapi/test/path_entity_type_test.dart new file mode 100644 index 0000000000..7a9c9a714e Binary files /dev/null and b/mobile/openapi/test/path_entity_type_test.dart differ diff --git a/mobile/openapi/test/path_type_test.dart b/mobile/openapi/test/path_type_test.dart new file mode 100644 index 0000000000..20862a0ef5 Binary files /dev/null and b/mobile/openapi/test/path_type_test.dart differ diff --git a/server/immich-openapi-specs.json b/server/immich-openapi-specs.json index 6392669b15..cc649d784d 100644 --- a/server/immich-openapi-specs.json +++ b/server/immich-openapi-specs.json @@ -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": { diff --git a/server/src/domain/audit/audit.dto.ts b/server/src/domain/audit/audit.dto.ts index b437ed5b7f..d941f9a1df 100644 --- a/server/src/domain/audit/audit.dto.ts +++ b/server/src/domain/audit/audit.dto.ts @@ -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; +} diff --git a/server/src/domain/audit/audi.service.spec.ts b/server/src/domain/audit/audit.service.spec.ts similarity index 64% rename from server/src/domain/audit/audi.service.spec.ts rename to server/src/domain/audit/audit.service.spec.ts index 39b447330c..5e68250fab 100644 --- a/server/src/domain/audit/audi.service.spec.ts +++ b/server/src/domain/audit/audit.service.spec.ts @@ -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', () => { diff --git a/server/src/domain/audit/audit.service.ts b/server/src/domain/audit/audit.service.ts index 7e1574d485..dc3d65b653 100644 --- a/server/src/domain/audit/audit.service.ts +++ b/server/src/domain/audit/audit.service.ts @@ -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 }; + } } diff --git a/server/src/domain/metadata/metadata.service.ts b/server/src/domain/metadata/metadata.service.ts index cc03537f53..2779df54c6 100644 --- a/server/src/domain/metadata/metadata.service.ts +++ b/server/src/domain/metadata/metadata.service.ts @@ -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, diff --git a/server/src/domain/repositories/asset.repository.ts b/server/src/domain/repositories/asset.repository.ts index 89a4afbf17..5266c98ae7 100644 --- a/server/src/domain/repositories/asset.repository.ts +++ b/server/src/domain/repositories/asset.repository.ts @@ -14,6 +14,7 @@ export interface AssetSearchOptions { trashedBefore?: Date; type?: AssetType; order?: 'ASC' | 'DESC'; + withDeleted?: boolean; } export interface LivePhotoSearchOptions { diff --git a/server/src/domain/server-info/server-info.service.spec.ts b/server/src/domain/server-info/server-info.service.spec.ts index 8655f7cc52..53115594c9 100644 --- a/server/src/domain/server-info/server-info.service.spec.ts +++ b/server/src/domain/server-info/server-info.service.spec.ts @@ -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', () => { diff --git a/server/src/domain/server-info/server-info.service.ts b/server/src/domain/server-info/server-info.service.ts index d68b484735..1406423abe 100644 --- a/server/src/domain/server-info/server-info.service.ts +++ b/server/src/domain/server-info/server-info.service.ts @@ -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); diff --git a/server/src/domain/storage-template/storage-template.service.ts b/server/src/domain/storage-template/storage-template.service.ts index 6681e60625..b04ffc89a0 100644 --- a/server/src/domain/storage-template/storage-template.service.ts +++ b/server/src/domain/storage-template/storage-template.service.ts @@ -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'); diff --git a/server/src/domain/storage/storage.core.ts b/server/src/domain/storage/storage.core.ts index 249b2857f1..69e2bd7995 100644 --- a/server/src/domain/storage/storage.core.ts +++ b/server/src/domain/storage/storage.core.ts @@ -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) { diff --git a/server/src/domain/storage/storage.service.spec.ts b/server/src/domain/storage/storage.service.spec.ts index e197dee4af..0c5531e5f6 100644 --- a/server/src/domain/storage/storage.service.spec.ts +++ b/server/src/domain/storage/storage.service.spec.ts @@ -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', () => { diff --git a/server/src/domain/storage/storage.service.ts b/server/src/domain/storage/storage.service.ts index 6298113130..0d7c9432e1 100644 --- a/server/src/domain/storage/storage.service.ts +++ b/server/src/domain/storage/storage.service.ts @@ -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); } diff --git a/server/src/immich/controllers/audit.controller.ts b/server/src/immich/controllers/audit.controller.ts index bb720323fd..a50d33741d 100644 --- a/server/src/immich/controllers/audit.controller.ts +++ b/server/src/immich/controllers/audit.controller.ts @@ -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); + } } diff --git a/server/src/infra/entities/move.entity.ts b/server/src/infra/entities/move.entity.ts index daeb7f4b40..de20cb9737 100644 --- a/server/src/infra/entities/move.entity.ts +++ b/server/src/infra/entities/move.entity.ts @@ -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; diff --git a/server/src/infra/repositories/asset.repository.ts b/server/src/infra/repositories/asset.repository.ts index 37153d86fb..362b00d52b 100644 --- a/server/src/infra/repositories/asset.repository.ts +++ b/server/src/infra/repositories/asset.repository.ts @@ -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', diff --git a/web/src/api/api.ts b/web/src/api/api.ts index 00b60dfca8..9beb370d35 100644 --- a/web/src/api/api.ts +++ b/web/src/api/api.ts @@ -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); diff --git a/web/src/api/open-api/api.ts b/web/src/api/open-api/api.ts index 0c8d2673cc..549cc59d0a 100644 --- a/web/src/api/open-api/api.ts +++ b/web/src/api/open-api/api.ts @@ -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)); + } } diff --git a/web/src/lib/assets/empty-4.svg b/web/src/lib/assets/empty-4.svg new file mode 100644 index 0000000000..05aeb2b2af --- /dev/null +++ b/web/src/lib/assets/empty-4.svg @@ -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> \ No newline at end of file diff --git a/web/src/lib/components/elements/buttons/link-button.svelte b/web/src/lib/components/elements/buttons/link-button.svelte index 0d0d369077..d5fe8b29b5 100644 --- a/web/src/lib/components/elements/buttons/link-button.svelte +++ b/web/src/lib/components/elements/buttons/link-button.svelte @@ -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> diff --git a/web/src/lib/components/shared-components/side-bar/admin-side-bar.svelte b/web/src/lib/components/shared-components/side-bar/admin-side-bar.svelte index 6a88602d6b..76f87a3d92 100644 --- a/web/src/lib/components/shared-components/side-bar/admin-side-bar.svelte +++ b/web/src/lib/components/shared-components/side-bar/admin-side-bar.svelte @@ -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> diff --git a/web/src/lib/constants.ts b/web/src/lib/constants.ts index 33d311ed08..71572ef7c3 100644 --- a/web/src/lib/constants.ts +++ b/web/src/lib/constants.ts @@ -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', diff --git a/web/src/routes/admin/repair/+page.server.ts b/web/src/routes/admin/repair/+page.server.ts new file mode 100644 index 0000000000..9f04e013ca --- /dev/null +++ b/web/src/routes/admin/repair/+page.server.ts @@ -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; diff --git a/web/src/routes/admin/repair/+page.svelte b/web/src/routes/admin/repair/+page.svelte new file mode 100644 index 0000000000..57b203df85 --- /dev/null +++ b/web/src/routes/admin/repair/+page.svelte @@ -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>