Преглед на файлове

Merge branch 'main' of github.com:immich-app/immich into 4382-thumbnail-metadata

Alex Tran преди 1 година
родител
ревизия
259ed35b62
променени са 89 файла, в които са добавени 3434 реда и са изтрити 236 реда
  1. 388 4
      cli/src/api/open-api/api.ts
  2. 21 0
      mobile/openapi/.openapi-generator/FILES
  3. 10 0
      mobile/openapi/README.md
  4. 2 1
      mobile/openapi/doc/AssetResponseDto.md
  5. 163 0
      mobile/openapi/doc/AuditApi.md
  6. 15 0
      mobile/openapi/doc/FileChecksumDto.md
  7. 16 0
      mobile/openapi/doc/FileChecksumResponseDto.md
  8. 16 0
      mobile/openapi/doc/FileReportDto.md
  9. 15 0
      mobile/openapi/doc/FileReportFixDto.md
  10. 19 0
      mobile/openapi/doc/FileReportItemDto.md
  11. 14 0
      mobile/openapi/doc/PathEntityType.md
  12. 14 0
      mobile/openapi/doc/PathType.md
  13. 1 1
      mobile/openapi/doc/SharedLinkCreateDto.md
  14. 1 1
      mobile/openapi/doc/SharedLinkEditDto.md
  15. 1 1
      mobile/openapi/doc/SharedLinkResponseDto.md
  16. 7 0
      mobile/openapi/lib/api.dart
  17. 130 0
      mobile/openapi/lib/api/audit_api.dart
  18. 14 0
      mobile/openapi/lib/api_client.dart
  19. 6 0
      mobile/openapi/lib/api_helper.dart
  20. 9 2
      mobile/openapi/lib/model/asset_response_dto.dart
  21. 100 0
      mobile/openapi/lib/model/file_checksum_dto.dart
  22. 106 0
      mobile/openapi/lib/model/file_checksum_response_dto.dart
  23. 108 0
      mobile/openapi/lib/model/file_report_dto.dart
  24. 98 0
      mobile/openapi/lib/model/file_report_fix_dto.dart
  25. 139 0
      mobile/openapi/lib/model/file_report_item_dto.dart
  26. 88 0
      mobile/openapi/lib/model/path_entity_type.dart
  27. 100 0
      mobile/openapi/lib/model/path_type.dart
  28. 7 7
      mobile/openapi/lib/model/shared_link_create_dto.dart
  29. 9 9
      mobile/openapi/lib/model/shared_link_edit_dto.dart
  30. 8 8
      mobile/openapi/lib/model/shared_link_response_dto.dart
  31. 5 1
      mobile/openapi/test/asset_response_dto_test.dart
  32. 15 0
      mobile/openapi/test/audit_api_test.dart
  33. 27 0
      mobile/openapi/test/file_checksum_dto_test.dart
  34. 32 0
      mobile/openapi/test/file_checksum_response_dto_test.dart
  35. 32 0
      mobile/openapi/test/file_report_dto_test.dart
  36. 27 0
      mobile/openapi/test/file_report_fix_dto_test.dart
  37. 47 0
      mobile/openapi/test/file_report_item_dto_test.dart
  38. 21 0
      mobile/openapi/test/path_entity_type_test.dart
  39. 21 0
      mobile/openapi/test/path_type_test.dart
  40. 2 2
      mobile/openapi/test/shared_link_create_dto_test.dart
  41. 2 2
      mobile/openapi/test/shared_link_edit_dto_test.dart
  42. 2 2
      mobile/openapi/test/shared_link_response_dto_test.dart
  43. 235 9
      server/immich-openapi-specs.json
  44. 10 2
      server/src/domain/asset/asset.service.ts
  45. 31 19
      server/src/domain/asset/response-dto/asset-response.dto.ts
  46. 12 0
      server/src/domain/asset/response-dto/exif-response.dto.ts
  47. 52 3
      server/src/domain/audit/audit.dto.ts
  48. 31 3
      server/src/domain/audit/audit.service.spec.ts
  49. 185 4
      server/src/domain/audit/audit.service.ts
  50. 2 2
      server/src/domain/auth/auth.service.ts
  51. 1 1
      server/src/domain/auth/dto/auth-user.dto.ts
  52. 4 1
      server/src/domain/metadata/metadata.service.ts
  53. 1 1
      server/src/domain/person/person.service.ts
  54. 1 0
      server/src/domain/repositories/asset.repository.ts
  55. 1 1
      server/src/domain/search/search.service.ts
  56. 3 23
      server/src/domain/server-info/server-info.service.spec.ts
  57. 2 15
      server/src/domain/server-info/server-info.service.ts
  58. 8 7
      server/src/domain/shared-link/shared-link-response.dto.ts
  59. 2 2
      server/src/domain/shared-link/shared-link.dto.ts
  60. 3 3
      server/src/domain/shared-link/shared-link.service.spec.ts
  61. 5 5
      server/src/domain/shared-link/shared-link.service.ts
  62. 1 1
      server/src/domain/storage-template/storage-template.service.ts
  63. 10 6
      server/src/domain/storage/storage.core.ts
  64. 3 14
      server/src/domain/storage/storage.service.spec.ts
  65. 3 11
      server/src/domain/storage/storage.service.ts
  66. 1 1
      server/src/domain/tag/tag.service.ts
  67. 1 1
      server/src/immich/api-v1/asset/asset.controller.ts
  68. 19 12
      server/src/immich/api-v1/asset/asset.service.ts
  69. 1 1
      server/src/immich/controllers/asset.controller.ts
  70. 30 3
      server/src/immich/controllers/audit.controller.ts
  71. 5 1
      server/src/infra/entities/move.entity.ts
  72. 1 1
      server/src/infra/repositories/asset.repository.ts
  73. 1 1
      server/src/infra/repositories/person.repository.ts
  74. 7 0
      server/test/api/shared-link-api.ts
  75. 98 12
      server/test/e2e/shared-link.e2e-spec.ts
  76. 6 6
      server/test/fixtures/auth.stub.ts
  77. 18 6
      server/test/fixtures/shared-link.stub.ts
  78. 3 0
      web/src/api/api.ts
  79. 388 4
      web/src/api/open-api/api.ts
  80. 0 0
      web/src/lib/assets/empty-4.svg
  81. 9 1
      web/src/lib/components/asset-viewer/asset-viewer-nav-bar.svelte
  82. 5 3
      web/src/lib/components/asset-viewer/asset-viewer.svelte
  83. 2 1
      web/src/lib/components/elements/buttons/link-button.svelte
  84. 7 7
      web/src/lib/components/shared-components/create-share-link-modal/create-shared-link-modal.svelte
  85. 4 0
      web/src/lib/components/shared-components/side-bar/admin-side-bar.svelte
  86. 1 1
      web/src/lib/components/sharedlinks-page/shared-link-card.svelte
  87. 1 0
      web/src/lib/constants.ts
  88. 26 0
      web/src/routes/admin/repair/+page.server.ts
  89. 336 0
      web/src/routes/admin/repair/+page.svelte

+ 388 - 4
cli/src/api/open-api/api.ts

@@ -640,6 +640,12 @@ export interface AssetResponseDto {
      * @memberof AssetResponseDto
      */
     'fileModifiedAt': string;
+    /**
+     * 
+     * @type {boolean}
+     * @memberof AssetResponseDto
+     */
+    'hasMetadata': boolean;
     /**
      * 
      * @type {string}
@@ -749,7 +755,7 @@ export interface AssetResponseDto {
      */
     'tags'?: Array<TagResponseDto>;
     /**
-     * base64 encoded thumbhash
+     * 
      * @type {string}
      * @memberof AssetResponseDto
      */
@@ -1598,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
@@ -2180,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
@@ -2882,7 +3025,7 @@ export interface SharedLinkCreateDto {
      * @type {boolean}
      * @memberof SharedLinkCreateDto
      */
-    'showExif'?: boolean;
+    'showMetadata'?: boolean;
     /**
      * 
      * @type {SharedLinkType}
@@ -2927,7 +3070,7 @@ export interface SharedLinkEditDto {
      * @type {boolean}
      * @memberof SharedLinkEditDto
      */
-    'showExif'?: boolean;
+    'showMetadata'?: boolean;
 }
 /**
  * 
@@ -2994,7 +3137,7 @@ export interface SharedLinkResponseDto {
      * @type {boolean}
      * @memberof SharedLinkResponseDto
      */
-    'showExif': boolean;
+    'showMetadata': boolean;
     /**
      * 
      * @type {SharedLinkType}
@@ -8815,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 
@@ -8869,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,
@@ -8884,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 
@@ -8896,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);
+        },
     }
 };
 
@@ -8906,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.
@@ -8915,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
@@ -8946,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
@@ -8953,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.
@@ -8963,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));
+    }
 }
 
 

+ 21 - 0
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

+ 10 - 0
mobile/openapi/README.md

@@ -117,7 +117,10 @@ Class | Method | HTTP request | Description
 *AssetApi* | [**updateAsset**](doc//AssetApi.md#updateasset) | **PUT** /asset/{id} | 
 *AssetApi* | [**updateAssets**](doc//AssetApi.md#updateassets) | **PUT** /asset | 
 *AssetApi* | [**uploadFile**](doc//AssetApi.md#uploadfile) | **POST** /asset/upload | 
+*AuditApi* | [**fixAuditFiles**](doc//AuditApi.md#fixauditfiles) | **POST** /audit/file-report/fix | 
 *AuditApi* | [**getAuditDeletes**](doc//AuditApi.md#getauditdeletes) | **GET** /audit/deletes | 
+*AuditApi* | [**getAuditFiles**](doc//AuditApi.md#getauditfiles) | **GET** /audit/file-report | 
+*AuditApi* | [**getFileChecksums**](doc//AuditApi.md#getfilechecksums) | **POST** /audit/file-report/checksum | 
 *AuthenticationApi* | [**adminSignUp**](doc//AuthenticationApi.md#adminsignup) | **POST** /auth/admin-sign-up | 
 *AuthenticationApi* | [**changePassword**](doc//AuthenticationApi.md#changepassword) | **POST** /auth/change-password | 
 *AuthenticationApi* | [**getAuthDevices**](doc//AuthenticationApi.md#getauthdevices) | **GET** /auth/devices | 
@@ -247,6 +250,11 @@ Class | Method | HTTP request | Description
  - [DownloadResponseDto](doc//DownloadResponseDto.md)
  - [EntityType](doc//EntityType.md)
  - [ExifResponseDto](doc//ExifResponseDto.md)
+ - [FileChecksumDto](doc//FileChecksumDto.md)
+ - [FileChecksumResponseDto](doc//FileChecksumResponseDto.md)
+ - [FileReportDto](doc//FileReportDto.md)
+ - [FileReportFixDto](doc//FileReportFixDto.md)
+ - [FileReportItemDto](doc//FileReportItemDto.md)
  - [ImportAssetDto](doc//ImportAssetDto.md)
  - [JobCommand](doc//JobCommand.md)
  - [JobCommandDto](doc//JobCommandDto.md)
@@ -268,6 +276,8 @@ Class | Method | HTTP request | Description
  - [OAuthCallbackDto](doc//OAuthCallbackDto.md)
  - [OAuthConfigDto](doc//OAuthConfigDto.md)
  - [OAuthConfigResponseDto](doc//OAuthConfigResponseDto.md)
+ - [PathEntityType](doc//PathEntityType.md)
+ - [PathType](doc//PathType.md)
  - [PeopleResponseDto](doc//PeopleResponseDto.md)
  - [PeopleUpdateDto](doc//PeopleUpdateDto.md)
  - [PeopleUpdateItem](doc//PeopleUpdateItem.md)

+ 2 - 1
mobile/openapi/doc/AssetResponseDto.md

@@ -15,6 +15,7 @@ Name | Type | Description | Notes
 **exifInfo** | [**ExifResponseDto**](ExifResponseDto.md) |  | [optional] 
 **fileCreatedAt** | [**DateTime**](DateTime.md) |  | 
 **fileModifiedAt** | [**DateTime**](DateTime.md) |  | 
+**hasMetadata** | **bool** |  | 
 **id** | **String** |  | 
 **isArchived** | **bool** |  | 
 **isExternal** | **bool** |  | 
@@ -33,7 +34,7 @@ Name | Type | Description | Notes
 **resized** | **bool** |  | 
 **smartInfo** | [**SmartInfoResponseDto**](SmartInfoResponseDto.md) |  | [optional] 
 **tags** | [**List<TagResponseDto>**](TagResponseDto.md) |  | [optional] [default to const []]
-**thumbhash** | **String** | base64 encoded thumbhash | 
+**thumbhash** | **String** |  | 
 **type** | [**AssetTypeEnum**](AssetTypeEnum.md) |  | 
 **updatedAt** | [**DateTime**](DateTime.md) |  | 
 

+ 163 - 0
mobile/openapi/doc/AuditApi.md

@@ -9,9 +9,66 @@ All URIs are relative to */api*
 
 Method | HTTP request | Description
 ------------- | ------------- | -------------
+[**fixAuditFiles**](AuditApi.md#fixauditfiles) | **POST** /audit/file-report/fix | 
 [**getAuditDeletes**](AuditApi.md#getauditdeletes) | **GET** /audit/deletes | 
+[**getAuditFiles**](AuditApi.md#getauditfiles) | **GET** /audit/file-report | 
+[**getFileChecksums**](AuditApi.md#getfilechecksums) | **POST** /audit/file-report/checksum | 
 
 
+# **fixAuditFiles**
+> fixAuditFiles(fileReportFixDto)
+
+
+
+### Example
+```dart
+import 'package:openapi/api.dart';
+// TODO Configure API key authorization: cookie
+//defaultApiClient.getAuthentication<ApiKeyAuth>('cookie').apiKey = 'YOUR_API_KEY';
+// uncomment below to setup prefix (e.g. Bearer) for API key, if needed
+//defaultApiClient.getAuthentication<ApiKeyAuth>('cookie').apiKeyPrefix = 'Bearer';
+// TODO Configure API key authorization: api_key
+//defaultApiClient.getAuthentication<ApiKeyAuth>('api_key').apiKey = 'YOUR_API_KEY';
+// uncomment below to setup prefix (e.g. Bearer) for API key, if needed
+//defaultApiClient.getAuthentication<ApiKeyAuth>('api_key').apiKeyPrefix = 'Bearer';
+// TODO Configure HTTP Bearer authorization: bearer
+// Case 1. Use String Token
+//defaultApiClient.getAuthentication<HttpBearerAuth>('bearer').setAccessToken('YOUR_ACCESS_TOKEN');
+// Case 2. Use Function which generate token.
+// String yourTokenGeneratorFunction() { ... }
+//defaultApiClient.getAuthentication<HttpBearerAuth>('bearer').setAccessToken(yourTokenGeneratorFunction);
+
+final api_instance = AuditApi();
+final fileReportFixDto = FileReportFixDto(); // FileReportFixDto | 
+
+try {
+    api_instance.fixAuditFiles(fileReportFixDto);
+} catch (e) {
+    print('Exception when calling AuditApi->fixAuditFiles: $e\n');
+}
+```
+
+### Parameters
+
+Name | Type | Description  | Notes
+------------- | ------------- | ------------- | -------------
+ **fileReportFixDto** | [**FileReportFixDto**](FileReportFixDto.md)|  | 
+
+### Return type
+
+void (empty response body)
+
+### Authorization
+
+[cookie](../README.md#cookie), [api_key](../README.md#api_key), [bearer](../README.md#bearer)
+
+### HTTP request headers
+
+ - **Content-Type**: application/json
+ - **Accept**: Not defined
+
+[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md)
+
 # **getAuditDeletes**
 > AuditDeletesResponseDto getAuditDeletes(entityType, after, userId)
 
@@ -71,3 +128,109 @@ Name | Type | Description  | Notes
 
 [[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md)
 
+# **getAuditFiles**
+> FileReportDto getAuditFiles()
+
+
+
+### Example
+```dart
+import 'package:openapi/api.dart';
+// TODO Configure API key authorization: cookie
+//defaultApiClient.getAuthentication<ApiKeyAuth>('cookie').apiKey = 'YOUR_API_KEY';
+// uncomment below to setup prefix (e.g. Bearer) for API key, if needed
+//defaultApiClient.getAuthentication<ApiKeyAuth>('cookie').apiKeyPrefix = 'Bearer';
+// TODO Configure API key authorization: api_key
+//defaultApiClient.getAuthentication<ApiKeyAuth>('api_key').apiKey = 'YOUR_API_KEY';
+// uncomment below to setup prefix (e.g. Bearer) for API key, if needed
+//defaultApiClient.getAuthentication<ApiKeyAuth>('api_key').apiKeyPrefix = 'Bearer';
+// TODO Configure HTTP Bearer authorization: bearer
+// Case 1. Use String Token
+//defaultApiClient.getAuthentication<HttpBearerAuth>('bearer').setAccessToken('YOUR_ACCESS_TOKEN');
+// Case 2. Use Function which generate token.
+// String yourTokenGeneratorFunction() { ... }
+//defaultApiClient.getAuthentication<HttpBearerAuth>('bearer').setAccessToken(yourTokenGeneratorFunction);
+
+final api_instance = AuditApi();
+
+try {
+    final result = api_instance.getAuditFiles();
+    print(result);
+} catch (e) {
+    print('Exception when calling AuditApi->getAuditFiles: $e\n');
+}
+```
+
+### Parameters
+This endpoint does not need any parameter.
+
+### Return type
+
+[**FileReportDto**](FileReportDto.md)
+
+### Authorization
+
+[cookie](../README.md#cookie), [api_key](../README.md#api_key), [bearer](../README.md#bearer)
+
+### HTTP request headers
+
+ - **Content-Type**: Not defined
+ - **Accept**: application/json
+
+[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md)
+
+# **getFileChecksums**
+> List<FileChecksumResponseDto> getFileChecksums(fileChecksumDto)
+
+
+
+### Example
+```dart
+import 'package:openapi/api.dart';
+// TODO Configure API key authorization: cookie
+//defaultApiClient.getAuthentication<ApiKeyAuth>('cookie').apiKey = 'YOUR_API_KEY';
+// uncomment below to setup prefix (e.g. Bearer) for API key, if needed
+//defaultApiClient.getAuthentication<ApiKeyAuth>('cookie').apiKeyPrefix = 'Bearer';
+// TODO Configure API key authorization: api_key
+//defaultApiClient.getAuthentication<ApiKeyAuth>('api_key').apiKey = 'YOUR_API_KEY';
+// uncomment below to setup prefix (e.g. Bearer) for API key, if needed
+//defaultApiClient.getAuthentication<ApiKeyAuth>('api_key').apiKeyPrefix = 'Bearer';
+// TODO Configure HTTP Bearer authorization: bearer
+// Case 1. Use String Token
+//defaultApiClient.getAuthentication<HttpBearerAuth>('bearer').setAccessToken('YOUR_ACCESS_TOKEN');
+// Case 2. Use Function which generate token.
+// String yourTokenGeneratorFunction() { ... }
+//defaultApiClient.getAuthentication<HttpBearerAuth>('bearer').setAccessToken(yourTokenGeneratorFunction);
+
+final api_instance = AuditApi();
+final fileChecksumDto = FileChecksumDto(); // FileChecksumDto | 
+
+try {
+    final result = api_instance.getFileChecksums(fileChecksumDto);
+    print(result);
+} catch (e) {
+    print('Exception when calling AuditApi->getFileChecksums: $e\n');
+}
+```
+
+### Parameters
+
+Name | Type | Description  | Notes
+------------- | ------------- | ------------- | -------------
+ **fileChecksumDto** | [**FileChecksumDto**](FileChecksumDto.md)|  | 
+
+### Return type
+
+[**List<FileChecksumResponseDto>**](FileChecksumResponseDto.md)
+
+### Authorization
+
+[cookie](../README.md#cookie), [api_key](../README.md#api_key), [bearer](../README.md#bearer)
+
+### HTTP request headers
+
+ - **Content-Type**: application/json
+ - **Accept**: application/json
+
+[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md)
+

+ 15 - 0
mobile/openapi/doc/FileChecksumDto.md

@@ -0,0 +1,15 @@
+# openapi.model.FileChecksumDto
+
+## Load the model package
+```dart
+import 'package:openapi/api.dart';
+```
+
+## Properties
+Name | Type | Description | Notes
+------------ | ------------- | ------------- | -------------
+**filenames** | **List<String>** |  | [default to const []]
+
+[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)
+
+

+ 16 - 0
mobile/openapi/doc/FileChecksumResponseDto.md

@@ -0,0 +1,16 @@
+# openapi.model.FileChecksumResponseDto
+
+## Load the model package
+```dart
+import 'package:openapi/api.dart';
+```
+
+## Properties
+Name | Type | Description | Notes
+------------ | ------------- | ------------- | -------------
+**checksum** | **String** |  | 
+**filename** | **String** |  | 
+
+[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)
+
+

+ 16 - 0
mobile/openapi/doc/FileReportDto.md

@@ -0,0 +1,16 @@
+# openapi.model.FileReportDto
+
+## Load the model package
+```dart
+import 'package:openapi/api.dart';
+```
+
+## Properties
+Name | Type | Description | Notes
+------------ | ------------- | ------------- | -------------
+**extras** | **List<String>** |  | [default to const []]
+**orphans** | [**List<FileReportItemDto>**](FileReportItemDto.md) |  | [default to const []]
+
+[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)
+
+

+ 15 - 0
mobile/openapi/doc/FileReportFixDto.md

@@ -0,0 +1,15 @@
+# openapi.model.FileReportFixDto
+
+## Load the model package
+```dart
+import 'package:openapi/api.dart';
+```
+
+## Properties
+Name | Type | Description | Notes
+------------ | ------------- | ------------- | -------------
+**items** | [**List<FileReportItemDto>**](FileReportItemDto.md) |  | [default to const []]
+
+[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)
+
+

+ 19 - 0
mobile/openapi/doc/FileReportItemDto.md

@@ -0,0 +1,19 @@
+# openapi.model.FileReportItemDto
+
+## Load the model package
+```dart
+import 'package:openapi/api.dart';
+```
+
+## Properties
+Name | Type | Description | Notes
+------------ | ------------- | ------------- | -------------
+**checksum** | **String** |  | [optional] 
+**entityId** | **String** |  | 
+**entityType** | [**PathEntityType**](PathEntityType.md) |  | 
+**pathType** | [**PathType**](PathType.md) |  | 
+**pathValue** | **String** |  | 
+
+[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)
+
+

+ 14 - 0
mobile/openapi/doc/PathEntityType.md

@@ -0,0 +1,14 @@
+# openapi.model.PathEntityType
+
+## Load the model package
+```dart
+import 'package:openapi/api.dart';
+```
+
+## Properties
+Name | Type | Description | Notes
+------------ | ------------- | ------------- | -------------
+
+[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)
+
+

+ 14 - 0
mobile/openapi/doc/PathType.md

@@ -0,0 +1,14 @@
+# openapi.model.PathType
+
+## Load the model package
+```dart
+import 'package:openapi/api.dart';
+```
+
+## Properties
+Name | Type | Description | Notes
+------------ | ------------- | ------------- | -------------
+
+[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)
+
+

+ 1 - 1
mobile/openapi/doc/SharedLinkCreateDto.md

@@ -14,7 +14,7 @@ Name | Type | Description | Notes
 **assetIds** | **List<String>** |  | [optional] [default to const []]
 **description** | **String** |  | [optional] 
 **expiresAt** | [**DateTime**](DateTime.md) |  | [optional] 
-**showExif** | **bool** |  | [optional] [default to true]
+**showMetadata** | **bool** |  | [optional] [default to true]
 **type** | [**SharedLinkType**](SharedLinkType.md) |  | 
 
 [[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)

+ 1 - 1
mobile/openapi/doc/SharedLinkEditDto.md

@@ -12,7 +12,7 @@ Name | Type | Description | Notes
 **allowUpload** | **bool** |  | [optional] 
 **description** | **String** |  | [optional] 
 **expiresAt** | [**DateTime**](DateTime.md) |  | [optional] 
-**showExif** | **bool** |  | [optional] 
+**showMetadata** | **bool** |  | [optional] 
 
 [[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)
 

+ 1 - 1
mobile/openapi/doc/SharedLinkResponseDto.md

@@ -17,7 +17,7 @@ Name | Type | Description | Notes
 **expiresAt** | [**DateTime**](DateTime.md) |  | 
 **id** | **String** |  | 
 **key** | **String** |  | 
-**showExif** | **bool** |  | 
+**showMetadata** | **bool** |  | 
 **type** | [**SharedLinkType**](SharedLinkType.md) |  | 
 **userId** | **String** |  | 
 

+ 7 - 0
mobile/openapi/lib/api.dart

@@ -96,6 +96,11 @@ part 'model/download_info_dto.dart';
 part 'model/download_response_dto.dart';
 part 'model/entity_type.dart';
 part 'model/exif_response_dto.dart';
+part 'model/file_checksum_dto.dart';
+part 'model/file_checksum_response_dto.dart';
+part 'model/file_report_dto.dart';
+part 'model/file_report_fix_dto.dart';
+part 'model/file_report_item_dto.dart';
 part 'model/import_asset_dto.dart';
 part 'model/job_command.dart';
 part 'model/job_command_dto.dart';
@@ -117,6 +122,8 @@ part 'model/o_auth_authorize_response_dto.dart';
 part 'model/o_auth_callback_dto.dart';
 part 'model/o_auth_config_dto.dart';
 part 'model/o_auth_config_response_dto.dart';
+part 'model/path_entity_type.dart';
+part 'model/path_type.dart';
 part 'model/people_response_dto.dart';
 part 'model/people_update_dto.dart';
 part 'model/people_update_item.dart';

+ 130 - 0
mobile/openapi/lib/api/audit_api.dart

@@ -16,6 +16,45 @@ class AuditApi {
 
   final ApiClient apiClient;
 
+  /// Performs an HTTP 'POST /audit/file-report/fix' operation and returns the [Response].
+  /// Parameters:
+  ///
+  /// * [FileReportFixDto] fileReportFixDto (required):
+  Future<Response> fixAuditFilesWithHttpInfo(FileReportFixDto fileReportFixDto,) async {
+    // ignore: prefer_const_declarations
+    final path = r'/audit/file-report/fix';
+
+    // ignore: prefer_final_locals
+    Object? postBody = fileReportFixDto;
+
+    final queryParams = <QueryParam>[];
+    final headerParams = <String, String>{};
+    final formParams = <String, String>{};
+
+    const contentTypes = <String>['application/json'];
+
+
+    return apiClient.invokeAPI(
+      path,
+      'POST',
+      queryParams,
+      postBody,
+      headerParams,
+      formParams,
+      contentTypes.isEmpty ? null : contentTypes.first,
+    );
+  }
+
+  /// Parameters:
+  ///
+  /// * [FileReportFixDto] fileReportFixDto (required):
+  Future<void> fixAuditFiles(FileReportFixDto fileReportFixDto,) async {
+    final response = await fixAuditFilesWithHttpInfo(fileReportFixDto,);
+    if (response.statusCode >= HttpStatus.badRequest) {
+      throw ApiException(response.statusCode, await _decodeBodyBytes(response));
+    }
+  }
+
   /// Performs an HTTP 'GET /audit/deletes' operation and returns the [Response].
   /// Parameters:
   ///
@@ -76,4 +115,95 @@ class AuditApi {
     }
     return null;
   }
+
+  /// Performs an HTTP 'GET /audit/file-report' operation and returns the [Response].
+  Future<Response> getAuditFilesWithHttpInfo() async {
+    // ignore: prefer_const_declarations
+    final path = r'/audit/file-report';
+
+    // ignore: prefer_final_locals
+    Object? postBody;
+
+    final queryParams = <QueryParam>[];
+    final headerParams = <String, String>{};
+    final formParams = <String, String>{};
+
+    const contentTypes = <String>[];
+
+
+    return apiClient.invokeAPI(
+      path,
+      'GET',
+      queryParams,
+      postBody,
+      headerParams,
+      formParams,
+      contentTypes.isEmpty ? null : contentTypes.first,
+    );
+  }
+
+  Future<FileReportDto?> getAuditFiles() async {
+    final response = await getAuditFilesWithHttpInfo();
+    if (response.statusCode >= HttpStatus.badRequest) {
+      throw ApiException(response.statusCode, await _decodeBodyBytes(response));
+    }
+    // When a remote server returns no body with a status of 204, we shall not decode it.
+    // At the time of writing this, `dart:convert` will throw an "Unexpected end of input"
+    // FormatException when trying to decode an empty string.
+    if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) {
+      return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'FileReportDto',) as FileReportDto;
+    
+    }
+    return null;
+  }
+
+  /// Performs an HTTP 'POST /audit/file-report/checksum' operation and returns the [Response].
+  /// Parameters:
+  ///
+  /// * [FileChecksumDto] fileChecksumDto (required):
+  Future<Response> getFileChecksumsWithHttpInfo(FileChecksumDto fileChecksumDto,) async {
+    // ignore: prefer_const_declarations
+    final path = r'/audit/file-report/checksum';
+
+    // ignore: prefer_final_locals
+    Object? postBody = fileChecksumDto;
+
+    final queryParams = <QueryParam>[];
+    final headerParams = <String, String>{};
+    final formParams = <String, String>{};
+
+    const contentTypes = <String>['application/json'];
+
+
+    return apiClient.invokeAPI(
+      path,
+      'POST',
+      queryParams,
+      postBody,
+      headerParams,
+      formParams,
+      contentTypes.isEmpty ? null : contentTypes.first,
+    );
+  }
+
+  /// Parameters:
+  ///
+  /// * [FileChecksumDto] fileChecksumDto (required):
+  Future<List<FileChecksumResponseDto>?> getFileChecksums(FileChecksumDto fileChecksumDto,) async {
+    final response = await getFileChecksumsWithHttpInfo(fileChecksumDto,);
+    if (response.statusCode >= HttpStatus.badRequest) {
+      throw ApiException(response.statusCode, await _decodeBodyBytes(response));
+    }
+    // When a remote server returns no body with a status of 204, we shall not decode it.
+    // At the time of writing this, `dart:convert` will throw an "Unexpected end of input"
+    // FormatException when trying to decode an empty string.
+    if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) {
+      final responseBody = await _decodeBodyBytes(response);
+      return (await apiClient.deserializeAsync(responseBody, 'List<FileChecksumResponseDto>') as List)
+        .cast<FileChecksumResponseDto>()
+        .toList();
+
+    }
+    return null;
+  }
 }

+ 14 - 0
mobile/openapi/lib/api_client.dart

@@ -283,6 +283,16 @@ class ApiClient {
           return EntityTypeTypeTransformer().decode(value);
         case 'ExifResponseDto':
           return ExifResponseDto.fromJson(value);
+        case 'FileChecksumDto':
+          return FileChecksumDto.fromJson(value);
+        case 'FileChecksumResponseDto':
+          return FileChecksumResponseDto.fromJson(value);
+        case 'FileReportDto':
+          return FileReportDto.fromJson(value);
+        case 'FileReportFixDto':
+          return FileReportFixDto.fromJson(value);
+        case 'FileReportItemDto':
+          return FileReportItemDto.fromJson(value);
         case 'ImportAssetDto':
           return ImportAssetDto.fromJson(value);
         case 'JobCommand':
@@ -325,6 +335,10 @@ class ApiClient {
           return OAuthConfigDto.fromJson(value);
         case 'OAuthConfigResponseDto':
           return OAuthConfigResponseDto.fromJson(value);
+        case 'PathEntityType':
+          return PathEntityTypeTypeTransformer().decode(value);
+        case 'PathType':
+          return PathTypeTypeTransformer().decode(value);
         case 'PeopleResponseDto':
           return PeopleResponseDto.fromJson(value);
         case 'PeopleUpdateDto':

+ 6 - 0
mobile/openapi/lib/api_helper.dart

@@ -91,6 +91,12 @@ String parameterToString(dynamic value) {
   if (value is ModelType) {
     return ModelTypeTypeTransformer().encode(value).toString();
   }
+  if (value is PathEntityType) {
+    return PathEntityTypeTypeTransformer().encode(value).toString();
+  }
+  if (value is PathType) {
+    return PathTypeTypeTransformer().encode(value).toString();
+  }
   if (value is SharedLinkType) {
     return SharedLinkTypeTypeTransformer().encode(value).toString();
   }

+ 9 - 2
mobile/openapi/lib/model/asset_response_dto.dart

@@ -20,6 +20,7 @@ class AssetResponseDto {
     this.exifInfo,
     required this.fileCreatedAt,
     required this.fileModifiedAt,
+    required this.hasMetadata,
     required this.id,
     required this.isArchived,
     required this.isExternal,
@@ -64,6 +65,8 @@ class AssetResponseDto {
 
   DateTime fileModifiedAt;
 
+  bool hasMetadata;
+
   String id;
 
   bool isArchived;
@@ -112,7 +115,6 @@ class AssetResponseDto {
 
   List<TagResponseDto> tags;
 
-  /// base64 encoded thumbhash
   String? thumbhash;
 
   AssetTypeEnum type;
@@ -128,6 +130,7 @@ class AssetResponseDto {
      other.exifInfo == exifInfo &&
      other.fileCreatedAt == fileCreatedAt &&
      other.fileModifiedAt == fileModifiedAt &&
+     other.hasMetadata == hasMetadata &&
      other.id == id &&
      other.isArchived == isArchived &&
      other.isExternal == isExternal &&
@@ -160,6 +163,7 @@ class AssetResponseDto {
     (exifInfo == null ? 0 : exifInfo!.hashCode) +
     (fileCreatedAt.hashCode) +
     (fileModifiedAt.hashCode) +
+    (hasMetadata.hashCode) +
     (id.hashCode) +
     (isArchived.hashCode) +
     (isExternal.hashCode) +
@@ -183,7 +187,7 @@ class AssetResponseDto {
     (updatedAt.hashCode);
 
   @override
-  String toString() => 'AssetResponseDto[checksum=$checksum, deviceAssetId=$deviceAssetId, deviceId=$deviceId, duration=$duration, exifInfo=$exifInfo, fileCreatedAt=$fileCreatedAt, fileModifiedAt=$fileModifiedAt, id=$id, isArchived=$isArchived, isExternal=$isExternal, isFavorite=$isFavorite, isOffline=$isOffline, isReadOnly=$isReadOnly, isTrashed=$isTrashed, libraryId=$libraryId, livePhotoVideoId=$livePhotoVideoId, localDateTime=$localDateTime, originalFileName=$originalFileName, originalPath=$originalPath, owner=$owner, ownerId=$ownerId, people=$people, resized=$resized, smartInfo=$smartInfo, tags=$tags, thumbhash=$thumbhash, type=$type, updatedAt=$updatedAt]';
+  String toString() => 'AssetResponseDto[checksum=$checksum, deviceAssetId=$deviceAssetId, deviceId=$deviceId, duration=$duration, exifInfo=$exifInfo, fileCreatedAt=$fileCreatedAt, fileModifiedAt=$fileModifiedAt, hasMetadata=$hasMetadata, id=$id, isArchived=$isArchived, isExternal=$isExternal, isFavorite=$isFavorite, isOffline=$isOffline, isReadOnly=$isReadOnly, isTrashed=$isTrashed, libraryId=$libraryId, livePhotoVideoId=$livePhotoVideoId, localDateTime=$localDateTime, originalFileName=$originalFileName, originalPath=$originalPath, owner=$owner, ownerId=$ownerId, people=$people, resized=$resized, smartInfo=$smartInfo, tags=$tags, thumbhash=$thumbhash, type=$type, updatedAt=$updatedAt]';
 
   Map<String, dynamic> toJson() {
     final json = <String, dynamic>{};
@@ -198,6 +202,7 @@ class AssetResponseDto {
     }
       json[r'fileCreatedAt'] = this.fileCreatedAt.toUtc().toIso8601String();
       json[r'fileModifiedAt'] = this.fileModifiedAt.toUtc().toIso8601String();
+      json[r'hasMetadata'] = this.hasMetadata;
       json[r'id'] = this.id;
       json[r'isArchived'] = this.isArchived;
       json[r'isExternal'] = this.isExternal;
@@ -253,6 +258,7 @@ class AssetResponseDto {
         exifInfo: ExifResponseDto.fromJson(json[r'exifInfo']),
         fileCreatedAt: mapDateTime(json, r'fileCreatedAt', '')!,
         fileModifiedAt: mapDateTime(json, r'fileModifiedAt', '')!,
+        hasMetadata: mapValueOfType<bool>(json, r'hasMetadata')!,
         id: mapValueOfType<String>(json, r'id')!,
         isArchived: mapValueOfType<bool>(json, r'isArchived')!,
         isExternal: mapValueOfType<bool>(json, r'isExternal')!,
@@ -327,6 +333,7 @@ class AssetResponseDto {
     'duration',
     'fileCreatedAt',
     'fileModifiedAt',
+    'hasMetadata',
     'id',
     'isArchived',
     'isExternal',

+ 100 - 0
mobile/openapi/lib/model/file_checksum_dto.dart

@@ -0,0 +1,100 @@
+//
+// AUTO-GENERATED FILE, DO NOT MODIFY!
+//
+// @dart=2.12
+
+// ignore_for_file: unused_element, unused_import
+// ignore_for_file: always_put_required_named_parameters_first
+// ignore_for_file: constant_identifier_names
+// ignore_for_file: lines_longer_than_80_chars
+
+part of openapi.api;
+
+class FileChecksumDto {
+  /// Returns a new [FileChecksumDto] instance.
+  FileChecksumDto({
+    this.filenames = const [],
+  });
+
+  List<String> filenames;
+
+  @override
+  bool operator ==(Object other) => identical(this, other) || other is FileChecksumDto &&
+     other.filenames == filenames;
+
+  @override
+  int get hashCode =>
+    // ignore: unnecessary_parenthesis
+    (filenames.hashCode);
+
+  @override
+  String toString() => 'FileChecksumDto[filenames=$filenames]';
+
+  Map<String, dynamic> toJson() {
+    final json = <String, dynamic>{};
+      json[r'filenames'] = this.filenames;
+    return json;
+  }
+
+  /// Returns a new [FileChecksumDto] instance and imports its values from
+  /// [value] if it's a [Map], null otherwise.
+  // ignore: prefer_constructors_over_static_methods
+  static FileChecksumDto? fromJson(dynamic value) {
+    if (value is Map) {
+      final json = value.cast<String, dynamic>();
+
+      return FileChecksumDto(
+        filenames: json[r'filenames'] is List
+            ? (json[r'filenames'] as List).cast<String>()
+            : const [],
+      );
+    }
+    return null;
+  }
+
+  static List<FileChecksumDto> listFromJson(dynamic json, {bool growable = false,}) {
+    final result = <FileChecksumDto>[];
+    if (json is List && json.isNotEmpty) {
+      for (final row in json) {
+        final value = FileChecksumDto.fromJson(row);
+        if (value != null) {
+          result.add(value);
+        }
+      }
+    }
+    return result.toList(growable: growable);
+  }
+
+  static Map<String, FileChecksumDto> mapFromJson(dynamic json) {
+    final map = <String, FileChecksumDto>{};
+    if (json is Map && json.isNotEmpty) {
+      json = json.cast<String, dynamic>(); // ignore: parameter_assignments
+      for (final entry in json.entries) {
+        final value = FileChecksumDto.fromJson(entry.value);
+        if (value != null) {
+          map[entry.key] = value;
+        }
+      }
+    }
+    return map;
+  }
+
+  // maps a json object with a list of FileChecksumDto-objects as value to a dart map
+  static Map<String, List<FileChecksumDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
+    final map = <String, List<FileChecksumDto>>{};
+    if (json is Map && json.isNotEmpty) {
+      // ignore: parameter_assignments
+      json = json.cast<String, dynamic>();
+      for (final entry in json.entries) {
+        map[entry.key] = FileChecksumDto.listFromJson(entry.value, growable: growable,);
+      }
+    }
+    return map;
+  }
+
+  /// The list of required keys that must be present in a JSON.
+  static const requiredKeys = <String>{
+    'filenames',
+  };
+}
+

+ 106 - 0
mobile/openapi/lib/model/file_checksum_response_dto.dart

@@ -0,0 +1,106 @@
+//
+// AUTO-GENERATED FILE, DO NOT MODIFY!
+//
+// @dart=2.12
+
+// ignore_for_file: unused_element, unused_import
+// ignore_for_file: always_put_required_named_parameters_first
+// ignore_for_file: constant_identifier_names
+// ignore_for_file: lines_longer_than_80_chars
+
+part of openapi.api;
+
+class FileChecksumResponseDto {
+  /// Returns a new [FileChecksumResponseDto] instance.
+  FileChecksumResponseDto({
+    required this.checksum,
+    required this.filename,
+  });
+
+  String checksum;
+
+  String filename;
+
+  @override
+  bool operator ==(Object other) => identical(this, other) || other is FileChecksumResponseDto &&
+     other.checksum == checksum &&
+     other.filename == filename;
+
+  @override
+  int get hashCode =>
+    // ignore: unnecessary_parenthesis
+    (checksum.hashCode) +
+    (filename.hashCode);
+
+  @override
+  String toString() => 'FileChecksumResponseDto[checksum=$checksum, filename=$filename]';
+
+  Map<String, dynamic> toJson() {
+    final json = <String, dynamic>{};
+      json[r'checksum'] = this.checksum;
+      json[r'filename'] = this.filename;
+    return json;
+  }
+
+  /// Returns a new [FileChecksumResponseDto] instance and imports its values from
+  /// [value] if it's a [Map], null otherwise.
+  // ignore: prefer_constructors_over_static_methods
+  static FileChecksumResponseDto? fromJson(dynamic value) {
+    if (value is Map) {
+      final json = value.cast<String, dynamic>();
+
+      return FileChecksumResponseDto(
+        checksum: mapValueOfType<String>(json, r'checksum')!,
+        filename: mapValueOfType<String>(json, r'filename')!,
+      );
+    }
+    return null;
+  }
+
+  static List<FileChecksumResponseDto> listFromJson(dynamic json, {bool growable = false,}) {
+    final result = <FileChecksumResponseDto>[];
+    if (json is List && json.isNotEmpty) {
+      for (final row in json) {
+        final value = FileChecksumResponseDto.fromJson(row);
+        if (value != null) {
+          result.add(value);
+        }
+      }
+    }
+    return result.toList(growable: growable);
+  }
+
+  static Map<String, FileChecksumResponseDto> mapFromJson(dynamic json) {
+    final map = <String, FileChecksumResponseDto>{};
+    if (json is Map && json.isNotEmpty) {
+      json = json.cast<String, dynamic>(); // ignore: parameter_assignments
+      for (final entry in json.entries) {
+        final value = FileChecksumResponseDto.fromJson(entry.value);
+        if (value != null) {
+          map[entry.key] = value;
+        }
+      }
+    }
+    return map;
+  }
+
+  // maps a json object with a list of FileChecksumResponseDto-objects as value to a dart map
+  static Map<String, List<FileChecksumResponseDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
+    final map = <String, List<FileChecksumResponseDto>>{};
+    if (json is Map && json.isNotEmpty) {
+      // ignore: parameter_assignments
+      json = json.cast<String, dynamic>();
+      for (final entry in json.entries) {
+        map[entry.key] = FileChecksumResponseDto.listFromJson(entry.value, growable: growable,);
+      }
+    }
+    return map;
+  }
+
+  /// The list of required keys that must be present in a JSON.
+  static const requiredKeys = <String>{
+    'checksum',
+    'filename',
+  };
+}
+

+ 108 - 0
mobile/openapi/lib/model/file_report_dto.dart

@@ -0,0 +1,108 @@
+//
+// AUTO-GENERATED FILE, DO NOT MODIFY!
+//
+// @dart=2.12
+
+// ignore_for_file: unused_element, unused_import
+// ignore_for_file: always_put_required_named_parameters_first
+// ignore_for_file: constant_identifier_names
+// ignore_for_file: lines_longer_than_80_chars
+
+part of openapi.api;
+
+class FileReportDto {
+  /// Returns a new [FileReportDto] instance.
+  FileReportDto({
+    this.extras = const [],
+    this.orphans = const [],
+  });
+
+  List<String> extras;
+
+  List<FileReportItemDto> orphans;
+
+  @override
+  bool operator ==(Object other) => identical(this, other) || other is FileReportDto &&
+     other.extras == extras &&
+     other.orphans == orphans;
+
+  @override
+  int get hashCode =>
+    // ignore: unnecessary_parenthesis
+    (extras.hashCode) +
+    (orphans.hashCode);
+
+  @override
+  String toString() => 'FileReportDto[extras=$extras, orphans=$orphans]';
+
+  Map<String, dynamic> toJson() {
+    final json = <String, dynamic>{};
+      json[r'extras'] = this.extras;
+      json[r'orphans'] = this.orphans;
+    return json;
+  }
+
+  /// Returns a new [FileReportDto] instance and imports its values from
+  /// [value] if it's a [Map], null otherwise.
+  // ignore: prefer_constructors_over_static_methods
+  static FileReportDto? fromJson(dynamic value) {
+    if (value is Map) {
+      final json = value.cast<String, dynamic>();
+
+      return FileReportDto(
+        extras: json[r'extras'] is List
+            ? (json[r'extras'] as List).cast<String>()
+            : const [],
+        orphans: FileReportItemDto.listFromJson(json[r'orphans']),
+      );
+    }
+    return null;
+  }
+
+  static List<FileReportDto> listFromJson(dynamic json, {bool growable = false,}) {
+    final result = <FileReportDto>[];
+    if (json is List && json.isNotEmpty) {
+      for (final row in json) {
+        final value = FileReportDto.fromJson(row);
+        if (value != null) {
+          result.add(value);
+        }
+      }
+    }
+    return result.toList(growable: growable);
+  }
+
+  static Map<String, FileReportDto> mapFromJson(dynamic json) {
+    final map = <String, FileReportDto>{};
+    if (json is Map && json.isNotEmpty) {
+      json = json.cast<String, dynamic>(); // ignore: parameter_assignments
+      for (final entry in json.entries) {
+        final value = FileReportDto.fromJson(entry.value);
+        if (value != null) {
+          map[entry.key] = value;
+        }
+      }
+    }
+    return map;
+  }
+
+  // maps a json object with a list of FileReportDto-objects as value to a dart map
+  static Map<String, List<FileReportDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
+    final map = <String, List<FileReportDto>>{};
+    if (json is Map && json.isNotEmpty) {
+      // ignore: parameter_assignments
+      json = json.cast<String, dynamic>();
+      for (final entry in json.entries) {
+        map[entry.key] = FileReportDto.listFromJson(entry.value, growable: growable,);
+      }
+    }
+    return map;
+  }
+
+  /// The list of required keys that must be present in a JSON.
+  static const requiredKeys = <String>{
+    'extras',
+    'orphans',
+  };
+}
+

+ 98 - 0
mobile/openapi/lib/model/file_report_fix_dto.dart

@@ -0,0 +1,98 @@
+//
+// AUTO-GENERATED FILE, DO NOT MODIFY!
+//
+// @dart=2.12
+
+// ignore_for_file: unused_element, unused_import
+// ignore_for_file: always_put_required_named_parameters_first
+// ignore_for_file: constant_identifier_names
+// ignore_for_file: lines_longer_than_80_chars
+
+part of openapi.api;
+
+class FileReportFixDto {
+  /// Returns a new [FileReportFixDto] instance.
+  FileReportFixDto({
+    this.items = const [],
+  });
+
+  List<FileReportItemDto> items;
+
+  @override
+  bool operator ==(Object other) => identical(this, other) || other is FileReportFixDto &&
+     other.items == items;
+
+  @override
+  int get hashCode =>
+    // ignore: unnecessary_parenthesis
+    (items.hashCode);
+
+  @override
+  String toString() => 'FileReportFixDto[items=$items]';
+
+  Map<String, dynamic> toJson() {
+    final json = <String, dynamic>{};
+      json[r'items'] = this.items;
+    return json;
+  }
+
+  /// Returns a new [FileReportFixDto] instance and imports its values from
+  /// [value] if it's a [Map], null otherwise.
+  // ignore: prefer_constructors_over_static_methods
+  static FileReportFixDto? fromJson(dynamic value) {
+    if (value is Map) {
+      final json = value.cast<String, dynamic>();
+
+      return FileReportFixDto(
+        items: FileReportItemDto.listFromJson(json[r'items']),
+      );
+    }
+    return null;
+  }
+
+  static List<FileReportFixDto> listFromJson(dynamic json, {bool growable = false,}) {
+    final result = <FileReportFixDto>[];
+    if (json is List && json.isNotEmpty) {
+      for (final row in json) {
+        final value = FileReportFixDto.fromJson(row);
+        if (value != null) {
+          result.add(value);
+        }
+      }
+    }
+    return result.toList(growable: growable);
+  }
+
+  static Map<String, FileReportFixDto> mapFromJson(dynamic json) {
+    final map = <String, FileReportFixDto>{};
+    if (json is Map && json.isNotEmpty) {
+      json = json.cast<String, dynamic>(); // ignore: parameter_assignments
+      for (final entry in json.entries) {
+        final value = FileReportFixDto.fromJson(entry.value);
+        if (value != null) {
+          map[entry.key] = value;
+        }
+      }
+    }
+    return map;
+  }
+
+  // maps a json object with a list of FileReportFixDto-objects as value to a dart map
+  static Map<String, List<FileReportFixDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
+    final map = <String, List<FileReportFixDto>>{};
+    if (json is Map && json.isNotEmpty) {
+      // ignore: parameter_assignments
+      json = json.cast<String, dynamic>();
+      for (final entry in json.entries) {
+        map[entry.key] = FileReportFixDto.listFromJson(entry.value, growable: growable,);
+      }
+    }
+    return map;
+  }
+
+  /// The list of required keys that must be present in a JSON.
+  static const requiredKeys = <String>{
+    'items',
+  };
+}
+

+ 139 - 0
mobile/openapi/lib/model/file_report_item_dto.dart

@@ -0,0 +1,139 @@
+//
+// AUTO-GENERATED FILE, DO NOT MODIFY!
+//
+// @dart=2.12
+
+// ignore_for_file: unused_element, unused_import
+// ignore_for_file: always_put_required_named_parameters_first
+// ignore_for_file: constant_identifier_names
+// ignore_for_file: lines_longer_than_80_chars
+
+part of openapi.api;
+
+class FileReportItemDto {
+  /// Returns a new [FileReportItemDto] instance.
+  FileReportItemDto({
+    this.checksum,
+    required this.entityId,
+    required this.entityType,
+    required this.pathType,
+    required this.pathValue,
+  });
+
+  ///
+  /// Please note: This property should have been non-nullable! Since the specification file
+  /// does not include a default value (using the "default:" property), however, the generated
+  /// source code must fall back to having a nullable type.
+  /// Consider adding a "default:" property in the specification file to hide this note.
+  ///
+  String? checksum;
+
+  String entityId;
+
+  PathEntityType entityType;
+
+  PathType pathType;
+
+  String pathValue;
+
+  @override
+  bool operator ==(Object other) => identical(this, other) || other is FileReportItemDto &&
+     other.checksum == checksum &&
+     other.entityId == entityId &&
+     other.entityType == entityType &&
+     other.pathType == pathType &&
+     other.pathValue == pathValue;
+
+  @override
+  int get hashCode =>
+    // ignore: unnecessary_parenthesis
+    (checksum == null ? 0 : checksum!.hashCode) +
+    (entityId.hashCode) +
+    (entityType.hashCode) +
+    (pathType.hashCode) +
+    (pathValue.hashCode);
+
+  @override
+  String toString() => 'FileReportItemDto[checksum=$checksum, entityId=$entityId, entityType=$entityType, pathType=$pathType, pathValue=$pathValue]';
+
+  Map<String, dynamic> toJson() {
+    final json = <String, dynamic>{};
+    if (this.checksum != null) {
+      json[r'checksum'] = this.checksum;
+    } else {
+    //  json[r'checksum'] = null;
+    }
+      json[r'entityId'] = this.entityId;
+      json[r'entityType'] = this.entityType;
+      json[r'pathType'] = this.pathType;
+      json[r'pathValue'] = this.pathValue;
+    return json;
+  }
+
+  /// Returns a new [FileReportItemDto] instance and imports its values from
+  /// [value] if it's a [Map], null otherwise.
+  // ignore: prefer_constructors_over_static_methods
+  static FileReportItemDto? fromJson(dynamic value) {
+    if (value is Map) {
+      final json = value.cast<String, dynamic>();
+
+      return FileReportItemDto(
+        checksum: mapValueOfType<String>(json, r'checksum'),
+        entityId: mapValueOfType<String>(json, r'entityId')!,
+        entityType: PathEntityType.fromJson(json[r'entityType'])!,
+        pathType: PathType.fromJson(json[r'pathType'])!,
+        pathValue: mapValueOfType<String>(json, r'pathValue')!,
+      );
+    }
+    return null;
+  }
+
+  static List<FileReportItemDto> listFromJson(dynamic json, {bool growable = false,}) {
+    final result = <FileReportItemDto>[];
+    if (json is List && json.isNotEmpty) {
+      for (final row in json) {
+        final value = FileReportItemDto.fromJson(row);
+        if (value != null) {
+          result.add(value);
+        }
+      }
+    }
+    return result.toList(growable: growable);
+  }
+
+  static Map<String, FileReportItemDto> mapFromJson(dynamic json) {
+    final map = <String, FileReportItemDto>{};
+    if (json is Map && json.isNotEmpty) {
+      json = json.cast<String, dynamic>(); // ignore: parameter_assignments
+      for (final entry in json.entries) {
+        final value = FileReportItemDto.fromJson(entry.value);
+        if (value != null) {
+          map[entry.key] = value;
+        }
+      }
+    }
+    return map;
+  }
+
+  // maps a json object with a list of FileReportItemDto-objects as value to a dart map
+  static Map<String, List<FileReportItemDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
+    final map = <String, List<FileReportItemDto>>{};
+    if (json is Map && json.isNotEmpty) {
+      // ignore: parameter_assignments
+      json = json.cast<String, dynamic>();
+      for (final entry in json.entries) {
+        map[entry.key] = FileReportItemDto.listFromJson(entry.value, growable: growable,);
+      }
+    }
+    return map;
+  }
+
+  /// The list of required keys that must be present in a JSON.
+  static const requiredKeys = <String>{
+    'entityId',
+    'entityType',
+    'pathType',
+    'pathValue',
+  };
+}
+

+ 88 - 0
mobile/openapi/lib/model/path_entity_type.dart

@@ -0,0 +1,88 @@
+//
+// AUTO-GENERATED FILE, DO NOT MODIFY!
+//
+// @dart=2.12
+
+// ignore_for_file: unused_element, unused_import
+// ignore_for_file: always_put_required_named_parameters_first
+// ignore_for_file: constant_identifier_names
+// ignore_for_file: lines_longer_than_80_chars
+
+part of openapi.api;
+
+
+class PathEntityType {
+  /// Instantiate a new enum with the provided [value].
+  const PathEntityType._(this.value);
+
+  /// The underlying value of this enum member.
+  final String value;
+
+  @override
+  String toString() => value;
+
+  String toJson() => value;
+
+  static const asset = PathEntityType._(r'asset');
+  static const person = PathEntityType._(r'person');
+  static const user = PathEntityType._(r'user');
+
+  /// List of all possible values in this [enum][PathEntityType].
+  static const values = <PathEntityType>[
+    asset,
+    person,
+    user,
+  ];
+
+  static PathEntityType? fromJson(dynamic value) => PathEntityTypeTypeTransformer().decode(value);
+
+  static List<PathEntityType>? listFromJson(dynamic json, {bool growable = false,}) {
+    final result = <PathEntityType>[];
+    if (json is List && json.isNotEmpty) {
+      for (final row in json) {
+        final value = PathEntityType.fromJson(row);
+        if (value != null) {
+          result.add(value);
+        }
+      }
+    }
+    return result.toList(growable: growable);
+  }
+}
+
+/// Transformation class that can [encode] an instance of [PathEntityType] to String,
+/// and [decode] dynamic data back to [PathEntityType].
+class PathEntityTypeTypeTransformer {
+  factory PathEntityTypeTypeTransformer() => _instance ??= const PathEntityTypeTypeTransformer._();
+
+  const PathEntityTypeTypeTransformer._();
+
+  String encode(PathEntityType data) => data.value;
+
+  /// Decodes a [dynamic value][data] to a PathEntityType.
+  ///
+  /// If [allowNull] is true and the [dynamic value][data] cannot be decoded successfully,
+  /// then null is returned. However, if [allowNull] is false and the [dynamic value][data]
+  /// cannot be decoded successfully, then an [UnimplementedError] is thrown.
+  ///
+  /// The [allowNull] is very handy when an API changes and a new enum value is added or removed,
+  /// and users are still using an old app with the old code.
+  PathEntityType? decode(dynamic data, {bool allowNull = true}) {
+    if (data != null) {
+      switch (data) {
+        case r'asset': return PathEntityType.asset;
+        case r'person': return PathEntityType.person;
+        case r'user': return PathEntityType.user;
+        default:
+          if (!allowNull) {
+            throw ArgumentError('Unknown enum value to decode: $data');
+          }
+      }
+    }
+    return null;
+  }
+
+  /// Singleton [PathEntityTypeTypeTransformer] instance.
+  static PathEntityTypeTypeTransformer? _instance;
+}
+

+ 100 - 0
mobile/openapi/lib/model/path_type.dart

@@ -0,0 +1,100 @@
+//
+// AUTO-GENERATED FILE, DO NOT MODIFY!
+//
+// @dart=2.12
+
+// ignore_for_file: unused_element, unused_import
+// ignore_for_file: always_put_required_named_parameters_first
+// ignore_for_file: constant_identifier_names
+// ignore_for_file: lines_longer_than_80_chars
+
+part of openapi.api;
+
+
+class PathType {
+  /// Instantiate a new enum with the provided [value].
+  const PathType._(this.value);
+
+  /// The underlying value of this enum member.
+  final String value;
+
+  @override
+  String toString() => value;
+
+  String toJson() => value;
+
+  static const original = PathType._(r'original');
+  static const jpegThumbnail = PathType._(r'jpeg_thumbnail');
+  static const webpThumbnail = PathType._(r'webp_thumbnail');
+  static const encodedVideo = PathType._(r'encoded_video');
+  static const sidecar = PathType._(r'sidecar');
+  static const face = PathType._(r'face');
+  static const profile = PathType._(r'profile');
+
+  /// List of all possible values in this [enum][PathType].
+  static const values = <PathType>[
+    original,
+    jpegThumbnail,
+    webpThumbnail,
+    encodedVideo,
+    sidecar,
+    face,
+    profile,
+  ];
+
+  static PathType? fromJson(dynamic value) => PathTypeTypeTransformer().decode(value);
+
+  static List<PathType>? listFromJson(dynamic json, {bool growable = false,}) {
+    final result = <PathType>[];
+    if (json is List && json.isNotEmpty) {
+      for (final row in json) {
+        final value = PathType.fromJson(row);
+        if (value != null) {
+          result.add(value);
+        }
+      }
+    }
+    return result.toList(growable: growable);
+  }
+}
+
+/// Transformation class that can [encode] an instance of [PathType] to String,
+/// and [decode] dynamic data back to [PathType].
+class PathTypeTypeTransformer {
+  factory PathTypeTypeTransformer() => _instance ??= const PathTypeTypeTransformer._();
+
+  const PathTypeTypeTransformer._();
+
+  String encode(PathType data) => data.value;
+
+  /// Decodes a [dynamic value][data] to a PathType.
+  ///
+  /// If [allowNull] is true and the [dynamic value][data] cannot be decoded successfully,
+  /// then null is returned. However, if [allowNull] is false and the [dynamic value][data]
+  /// cannot be decoded successfully, then an [UnimplementedError] is thrown.
+  ///
+  /// The [allowNull] is very handy when an API changes and a new enum value is added or removed,
+  /// and users are still using an old app with the old code.
+  PathType? decode(dynamic data, {bool allowNull = true}) {
+    if (data != null) {
+      switch (data) {
+        case r'original': return PathType.original;
+        case r'jpeg_thumbnail': return PathType.jpegThumbnail;
+        case r'webp_thumbnail': return PathType.webpThumbnail;
+        case r'encoded_video': return PathType.encodedVideo;
+        case r'sidecar': return PathType.sidecar;
+        case r'face': return PathType.face;
+        case r'profile': return PathType.profile;
+        default:
+          if (!allowNull) {
+            throw ArgumentError('Unknown enum value to decode: $data');
+          }
+      }
+    }
+    return null;
+  }
+
+  /// Singleton [PathTypeTypeTransformer] instance.
+  static PathTypeTypeTransformer? _instance;
+}
+

+ 7 - 7
mobile/openapi/lib/model/shared_link_create_dto.dart

@@ -19,7 +19,7 @@ class SharedLinkCreateDto {
     this.assetIds = const [],
     this.description,
     this.expiresAt,
-    this.showExif = true,
+    this.showMetadata = true,
     required this.type,
   });
 
@@ -47,7 +47,7 @@ class SharedLinkCreateDto {
 
   DateTime? expiresAt;
 
-  bool showExif;
+  bool showMetadata;
 
   SharedLinkType type;
 
@@ -59,7 +59,7 @@ class SharedLinkCreateDto {
      other.assetIds == assetIds &&
      other.description == description &&
      other.expiresAt == expiresAt &&
-     other.showExif == showExif &&
+     other.showMetadata == showMetadata &&
      other.type == type;
 
   @override
@@ -71,11 +71,11 @@ class SharedLinkCreateDto {
     (assetIds.hashCode) +
     (description == null ? 0 : description!.hashCode) +
     (expiresAt == null ? 0 : expiresAt!.hashCode) +
-    (showExif.hashCode) +
+    (showMetadata.hashCode) +
     (type.hashCode);
 
   @override
-  String toString() => 'SharedLinkCreateDto[albumId=$albumId, allowDownload=$allowDownload, allowUpload=$allowUpload, assetIds=$assetIds, description=$description, expiresAt=$expiresAt, showExif=$showExif, type=$type]';
+  String toString() => 'SharedLinkCreateDto[albumId=$albumId, allowDownload=$allowDownload, allowUpload=$allowUpload, assetIds=$assetIds, description=$description, expiresAt=$expiresAt, showMetadata=$showMetadata, type=$type]';
 
   Map<String, dynamic> toJson() {
     final json = <String, dynamic>{};
@@ -97,7 +97,7 @@ class SharedLinkCreateDto {
     } else {
     //  json[r'expiresAt'] = null;
     }
-      json[r'showExif'] = this.showExif;
+      json[r'showMetadata'] = this.showMetadata;
       json[r'type'] = this.type;
     return json;
   }
@@ -118,7 +118,7 @@ class SharedLinkCreateDto {
             : const [],
         description: mapValueOfType<String>(json, r'description'),
         expiresAt: mapDateTime(json, r'expiresAt', ''),
-        showExif: mapValueOfType<bool>(json, r'showExif') ?? true,
+        showMetadata: mapValueOfType<bool>(json, r'showMetadata') ?? true,
         type: SharedLinkType.fromJson(json[r'type'])!,
       );
     }

+ 9 - 9
mobile/openapi/lib/model/shared_link_edit_dto.dart

@@ -17,7 +17,7 @@ class SharedLinkEditDto {
     this.allowUpload,
     this.description,
     this.expiresAt,
-    this.showExif,
+    this.showMetadata,
   });
 
   ///
@@ -52,7 +52,7 @@ class SharedLinkEditDto {
   /// source code must fall back to having a nullable type.
   /// Consider adding a "default:" property in the specification file to hide this note.
   ///
-  bool? showExif;
+  bool? showMetadata;
 
   @override
   bool operator ==(Object other) => identical(this, other) || other is SharedLinkEditDto &&
@@ -60,7 +60,7 @@ class SharedLinkEditDto {
      other.allowUpload == allowUpload &&
      other.description == description &&
      other.expiresAt == expiresAt &&
-     other.showExif == showExif;
+     other.showMetadata == showMetadata;
 
   @override
   int get hashCode =>
@@ -69,10 +69,10 @@ class SharedLinkEditDto {
     (allowUpload == null ? 0 : allowUpload!.hashCode) +
     (description == null ? 0 : description!.hashCode) +
     (expiresAt == null ? 0 : expiresAt!.hashCode) +
-    (showExif == null ? 0 : showExif!.hashCode);
+    (showMetadata == null ? 0 : showMetadata!.hashCode);
 
   @override
-  String toString() => 'SharedLinkEditDto[allowDownload=$allowDownload, allowUpload=$allowUpload, description=$description, expiresAt=$expiresAt, showExif=$showExif]';
+  String toString() => 'SharedLinkEditDto[allowDownload=$allowDownload, allowUpload=$allowUpload, description=$description, expiresAt=$expiresAt, showMetadata=$showMetadata]';
 
   Map<String, dynamic> toJson() {
     final json = <String, dynamic>{};
@@ -96,10 +96,10 @@ class SharedLinkEditDto {
     } else {
     //  json[r'expiresAt'] = null;
     }
-    if (this.showExif != null) {
-      json[r'showExif'] = this.showExif;
+    if (this.showMetadata != null) {
+      json[r'showMetadata'] = this.showMetadata;
     } else {
-    //  json[r'showExif'] = null;
+    //  json[r'showMetadata'] = null;
     }
     return json;
   }
@@ -116,7 +116,7 @@ class SharedLinkEditDto {
         allowUpload: mapValueOfType<bool>(json, r'allowUpload'),
         description: mapValueOfType<String>(json, r'description'),
         expiresAt: mapDateTime(json, r'expiresAt', ''),
-        showExif: mapValueOfType<bool>(json, r'showExif'),
+        showMetadata: mapValueOfType<bool>(json, r'showMetadata'),
       );
     }
     return null;

+ 8 - 8
mobile/openapi/lib/model/shared_link_response_dto.dart

@@ -22,7 +22,7 @@ class SharedLinkResponseDto {
     required this.expiresAt,
     required this.id,
     required this.key,
-    required this.showExif,
+    required this.showMetadata,
     required this.type,
     required this.userId,
   });
@@ -51,7 +51,7 @@ class SharedLinkResponseDto {
 
   String key;
 
-  bool showExif;
+  bool showMetadata;
 
   SharedLinkType type;
 
@@ -68,7 +68,7 @@ class SharedLinkResponseDto {
      other.expiresAt == expiresAt &&
      other.id == id &&
      other.key == key &&
-     other.showExif == showExif &&
+     other.showMetadata == showMetadata &&
      other.type == type &&
      other.userId == userId;
 
@@ -84,12 +84,12 @@ class SharedLinkResponseDto {
     (expiresAt == null ? 0 : expiresAt!.hashCode) +
     (id.hashCode) +
     (key.hashCode) +
-    (showExif.hashCode) +
+    (showMetadata.hashCode) +
     (type.hashCode) +
     (userId.hashCode);
 
   @override
-  String toString() => 'SharedLinkResponseDto[album=$album, allowDownload=$allowDownload, allowUpload=$allowUpload, assets=$assets, createdAt=$createdAt, description=$description, expiresAt=$expiresAt, id=$id, key=$key, showExif=$showExif, type=$type, userId=$userId]';
+  String toString() => 'SharedLinkResponseDto[album=$album, allowDownload=$allowDownload, allowUpload=$allowUpload, assets=$assets, createdAt=$createdAt, description=$description, expiresAt=$expiresAt, id=$id, key=$key, showMetadata=$showMetadata, type=$type, userId=$userId]';
 
   Map<String, dynamic> toJson() {
     final json = <String, dynamic>{};
@@ -114,7 +114,7 @@ class SharedLinkResponseDto {
     }
       json[r'id'] = this.id;
       json[r'key'] = this.key;
-      json[r'showExif'] = this.showExif;
+      json[r'showMetadata'] = this.showMetadata;
       json[r'type'] = this.type;
       json[r'userId'] = this.userId;
     return json;
@@ -137,7 +137,7 @@ class SharedLinkResponseDto {
         expiresAt: mapDateTime(json, r'expiresAt', ''),
         id: mapValueOfType<String>(json, r'id')!,
         key: mapValueOfType<String>(json, r'key')!,
-        showExif: mapValueOfType<bool>(json, r'showExif')!,
+        showMetadata: mapValueOfType<bool>(json, r'showMetadata')!,
         type: SharedLinkType.fromJson(json[r'type'])!,
         userId: mapValueOfType<String>(json, r'userId')!,
       );
@@ -195,7 +195,7 @@ class SharedLinkResponseDto {
     'expiresAt',
     'id',
     'key',
-    'showExif',
+    'showMetadata',
     'type',
     'userId',
   };

+ 5 - 1
mobile/openapi/test/asset_response_dto_test.dart

@@ -52,6 +52,11 @@ void main() {
       // TODO
     });
 
+    // bool hasMetadata
+    test('to test the property `hasMetadata`', () async {
+      // TODO
+    });
+
     // String id
     test('to test the property `id`', () async {
       // TODO
@@ -142,7 +147,6 @@ void main() {
       // TODO
     });
 
-    // base64 encoded thumbhash
     // String thumbhash
     test('to test the property `thumbhash`', () async {
       // TODO

+ 15 - 0
mobile/openapi/test/audit_api_test.dart

@@ -17,10 +17,25 @@ void main() {
   // final instance = AuditApi();
 
   group('tests for AuditApi', () {
+    //Future fixAuditFiles(FileReportFixDto fileReportFixDto) async
+    test('test fixAuditFiles', () async {
+      // TODO
+    });
+
     //Future<AuditDeletesResponseDto> getAuditDeletes(EntityType entityType, DateTime after, { String userId }) async
     test('test getAuditDeletes', () async {
       // TODO
     });
 
+    //Future<FileReportDto> getAuditFiles() async
+    test('test getAuditFiles', () async {
+      // TODO
+    });
+
+    //Future<List<FileChecksumResponseDto>> getFileChecksums(FileChecksumDto fileChecksumDto) async
+    test('test getFileChecksums', () async {
+      // TODO
+    });
+
   });
 }

+ 27 - 0
mobile/openapi/test/file_checksum_dto_test.dart

@@ -0,0 +1,27 @@
+//
+// AUTO-GENERATED FILE, DO NOT MODIFY!
+//
+// @dart=2.12
+
+// ignore_for_file: unused_element, unused_import
+// ignore_for_file: always_put_required_named_parameters_first
+// ignore_for_file: constant_identifier_names
+// ignore_for_file: lines_longer_than_80_chars
+
+import 'package:openapi/api.dart';
+import 'package:test/test.dart';
+
+// tests for FileChecksumDto
+void main() {
+  // final instance = FileChecksumDto();
+
+  group('test FileChecksumDto', () {
+    // List<String> filenames (default value: const [])
+    test('to test the property `filenames`', () async {
+      // TODO
+    });
+
+
+  });
+
+}

+ 32 - 0
mobile/openapi/test/file_checksum_response_dto_test.dart

@@ -0,0 +1,32 @@
+//
+// AUTO-GENERATED FILE, DO NOT MODIFY!
+//
+// @dart=2.12
+
+// ignore_for_file: unused_element, unused_import
+// ignore_for_file: always_put_required_named_parameters_first
+// ignore_for_file: constant_identifier_names
+// ignore_for_file: lines_longer_than_80_chars
+
+import 'package:openapi/api.dart';
+import 'package:test/test.dart';
+
+// tests for FileChecksumResponseDto
+void main() {
+  // final instance = FileChecksumResponseDto();
+
+  group('test FileChecksumResponseDto', () {
+    // String checksum
+    test('to test the property `checksum`', () async {
+      // TODO
+    });
+
+    // String filename
+    test('to test the property `filename`', () async {
+      // TODO
+    });
+
+
+  });
+
+}

+ 32 - 0
mobile/openapi/test/file_report_dto_test.dart

@@ -0,0 +1,32 @@
+//
+// AUTO-GENERATED FILE, DO NOT MODIFY!
+//
+// @dart=2.12
+
+// ignore_for_file: unused_element, unused_import
+// ignore_for_file: always_put_required_named_parameters_first
+// ignore_for_file: constant_identifier_names
+// ignore_for_file: lines_longer_than_80_chars
+
+import 'package:openapi/api.dart';
+import 'package:test/test.dart';
+
+// tests for FileReportDto
+void main() {
+  // final instance = FileReportDto();
+
+  group('test FileReportDto', () {
+    // List<String> extras (default value: const [])
+    test('to test the property `extras`', () async {
+      // TODO
+    });
+
+    // List<FileReportItemDto> orphans (default value: const [])
+    test('to test the property `orphans`', () async {
+      // TODO
+    });
+
+
+  });
+
+}

+ 27 - 0
mobile/openapi/test/file_report_fix_dto_test.dart

@@ -0,0 +1,27 @@
+//
+// AUTO-GENERATED FILE, DO NOT MODIFY!
+//
+// @dart=2.12
+
+// ignore_for_file: unused_element, unused_import
+// ignore_for_file: always_put_required_named_parameters_first
+// ignore_for_file: constant_identifier_names
+// ignore_for_file: lines_longer_than_80_chars
+
+import 'package:openapi/api.dart';
+import 'package:test/test.dart';
+
+// tests for FileReportFixDto
+void main() {
+  // final instance = FileReportFixDto();
+
+  group('test FileReportFixDto', () {
+    // List<FileReportItemDto> items (default value: const [])
+    test('to test the property `items`', () async {
+      // TODO
+    });
+
+
+  });
+
+}

+ 47 - 0
mobile/openapi/test/file_report_item_dto_test.dart

@@ -0,0 +1,47 @@
+//
+// AUTO-GENERATED FILE, DO NOT MODIFY!
+//
+// @dart=2.12
+
+// ignore_for_file: unused_element, unused_import
+// ignore_for_file: always_put_required_named_parameters_first
+// ignore_for_file: constant_identifier_names
+// ignore_for_file: lines_longer_than_80_chars
+
+import 'package:openapi/api.dart';
+import 'package:test/test.dart';
+
+// tests for FileReportItemDto
+void main() {
+  // final instance = FileReportItemDto();
+
+  group('test FileReportItemDto', () {
+    // String checksum
+    test('to test the property `checksum`', () async {
+      // TODO
+    });
+
+    // String entityId
+    test('to test the property `entityId`', () async {
+      // TODO
+    });
+
+    // PathEntityType entityType
+    test('to test the property `entityType`', () async {
+      // TODO
+    });
+
+    // PathType pathType
+    test('to test the property `pathType`', () async {
+      // TODO
+    });
+
+    // String pathValue
+    test('to test the property `pathValue`', () async {
+      // TODO
+    });
+
+
+  });
+
+}

+ 21 - 0
mobile/openapi/test/path_entity_type_test.dart

@@ -0,0 +1,21 @@
+//
+// AUTO-GENERATED FILE, DO NOT MODIFY!
+//
+// @dart=2.12
+
+// ignore_for_file: unused_element, unused_import
+// ignore_for_file: always_put_required_named_parameters_first
+// ignore_for_file: constant_identifier_names
+// ignore_for_file: lines_longer_than_80_chars
+
+import 'package:openapi/api.dart';
+import 'package:test/test.dart';
+
+// tests for PathEntityType
+void main() {
+
+  group('test PathEntityType', () {
+
+  });
+
+}

+ 21 - 0
mobile/openapi/test/path_type_test.dart

@@ -0,0 +1,21 @@
+//
+// AUTO-GENERATED FILE, DO NOT MODIFY!
+//
+// @dart=2.12
+
+// ignore_for_file: unused_element, unused_import
+// ignore_for_file: always_put_required_named_parameters_first
+// ignore_for_file: constant_identifier_names
+// ignore_for_file: lines_longer_than_80_chars
+
+import 'package:openapi/api.dart';
+import 'package:test/test.dart';
+
+// tests for PathType
+void main() {
+
+  group('test PathType', () {
+
+  });
+
+}

+ 2 - 2
mobile/openapi/test/shared_link_create_dto_test.dart

@@ -46,8 +46,8 @@ void main() {
       // TODO
     });
 
-    // bool showExif (default value: true)
-    test('to test the property `showExif`', () async {
+    // bool showMetadata (default value: true)
+    test('to test the property `showMetadata`', () async {
       // TODO
     });
 

+ 2 - 2
mobile/openapi/test/shared_link_edit_dto_test.dart

@@ -36,8 +36,8 @@ void main() {
       // TODO
     });
 
-    // bool showExif
-    test('to test the property `showExif`', () async {
+    // bool showMetadata
+    test('to test the property `showMetadata`', () async {
       // TODO
     });
 

+ 2 - 2
mobile/openapi/test/shared_link_response_dto_test.dart

@@ -61,8 +61,8 @@ void main() {
       // TODO
     });
 
-    // bool showExif
-    test('to test the property `showExif`', () async {
+    // bool showMetadata
+    test('to test the property `showMetadata`', () async {
       // TODO
     });
 

+ 235 - 9
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",
@@ -5770,6 +5882,9 @@
             "format": "date-time",
             "type": "string"
           },
+          "hasMetadata": {
+            "type": "boolean"
+          },
           "id": {
             "type": "string"
           },
@@ -5833,7 +5948,6 @@
             "type": "array"
           },
           "thumbhash": {
-            "description": "base64 encoded thumbhash",
             "nullable": true,
             "type": "string"
           },
@@ -5847,7 +5961,6 @@
         },
         "required": [
           "type",
-          "id",
           "deviceAssetId",
           "deviceId",
           "ownerId",
@@ -5855,19 +5968,21 @@
           "originalPath",
           "originalFileName",
           "resized",
-          "thumbhash",
           "fileCreatedAt",
           "fileModifiedAt",
           "updatedAt",
           "isFavorite",
           "isArchived",
           "isTrashed",
-          "localDateTime",
           "isOffline",
           "isExternal",
           "isReadOnly",
+          "checksum",
+          "id",
+          "thumbhash",
+          "localDateTime",
           "duration",
-          "checksum"
+          "hasMetadata"
         ],
         "type": "object"
       },
@@ -6577,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": {
@@ -7024,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": {
@@ -7599,7 +7825,7 @@
             "nullable": true,
             "type": "string"
           },
-          "showExif": {
+          "showMetadata": {
             "default": true,
             "type": "boolean"
           },
@@ -7628,7 +7854,7 @@
             "nullable": true,
             "type": "string"
           },
-          "showExif": {
+          "showMetadata": {
             "type": "boolean"
           }
         },
@@ -7670,7 +7896,7 @@
           "key": {
             "type": "string"
           },
-          "showExif": {
+          "showMetadata": {
             "type": "boolean"
           },
           "type": {
@@ -7691,7 +7917,7 @@
           "assets",
           "allowUpload",
           "allowDownload",
-          "showExif"
+          "showMetadata"
         ],
         "type": "object"
       },

+ 10 - 2
server/src/domain/asset/asset.service.ts

@@ -47,6 +47,7 @@ import {
   BulkIdsDto,
   MapMarkerResponseDto,
   MemoryLaneResponseDto,
+  SanitizedAssetResponseDto,
   TimeBucketResponseDto,
   mapAsset,
 } from './response-dto';
@@ -198,10 +199,17 @@ export class AssetService {
     return this.assetRepository.getTimeBuckets(dto);
   }
 
-  async getByTimeBucket(authUser: AuthUserDto, dto: TimeBucketAssetDto): Promise<AssetResponseDto[]> {
+  async getByTimeBucket(
+    authUser: AuthUserDto,
+    dto: TimeBucketAssetDto,
+  ): Promise<AssetResponseDto[] | SanitizedAssetResponseDto[]> {
     await this.timeBucketChecks(authUser, dto);
     const assets = await this.assetRepository.getByTimeBucket(dto.timeBucket, dto);
-    return assets.map(mapAsset);
+    if (authUser.isShowMetadata) {
+      return assets.map((asset) => mapAsset(asset));
+    } else {
+      return assets.map((asset) => mapAsset(asset, true));
+    }
   }
 
   async downloadFile(authUser: AuthUserDto, id: string): Promise<ImmichReadStream> {

+ 31 - 19
server/src/domain/asset/response-dto/asset-response.dto.ts

@@ -6,43 +6,62 @@ import { UserResponseDto, mapUser } from '../../user/response-dto/user-response.
 import { ExifResponseDto, mapExif } from './exif-response.dto';
 import { SmartInfoResponseDto, mapSmartInfo } from './smart-info-response.dto';
 
-export class AssetResponseDto {
+export class SanitizedAssetResponseDto {
   id!: string;
+  @ApiProperty({ enumName: 'AssetTypeEnum', enum: AssetType })
+  type!: AssetType;
+  thumbhash!: string | null;
+  resized!: boolean;
+  localDateTime!: Date;
+  duration!: string;
+  livePhotoVideoId?: string | null;
+  hasMetadata!: boolean;
+}
+
+export class AssetResponseDto extends SanitizedAssetResponseDto {
   deviceAssetId!: string;
   deviceId!: string;
   ownerId!: string;
   owner?: UserResponseDto;
   libraryId!: string;
-
-  @ApiProperty({ enumName: 'AssetTypeEnum', enum: AssetType })
-  type!: AssetType;
   originalPath!: string;
   originalFileName!: string;
   resized!: boolean;
-  /**base64 encoded thumbhash */
-  thumbhash!: string | null;
   fileCreatedAt!: Date;
   fileModifiedAt!: Date;
   updatedAt!: Date;
   isFavorite!: boolean;
   isArchived!: boolean;
   isTrashed!: boolean;
-  localDateTime!: Date;
   isOffline!: boolean;
   isExternal!: boolean;
   isReadOnly!: boolean;
-  duration!: string;
   exifInfo?: ExifResponseDto;
   smartInfo?: SmartInfoResponseDto;
-  livePhotoVideoId?: string | null;
   tags?: TagResponseDto[];
   people?: PersonResponseDto[];
   /**base64 encoded sha1 hash */
   checksum!: string;
 }
 
-function _map(entity: AssetEntity, withExif: boolean): AssetResponseDto {
+export function mapAsset(entity: AssetEntity, stripMetadata = false): AssetResponseDto {
+  const sanitizedAssetResponse: SanitizedAssetResponseDto = {
+    id: entity.id,
+    type: entity.type,
+    thumbhash: entity.thumbhash?.toString('base64') ?? null,
+    localDateTime: entity.localDateTime,
+    resized: !!entity.resizePath,
+    duration: entity.duration ?? '0:00:00.00000',
+    livePhotoVideoId: entity.livePhotoVideoId,
+    hasMetadata: false,
+  };
+
+  if (stripMetadata) {
+    return sanitizedAssetResponse as AssetResponseDto;
+  }
+
   return {
+    ...sanitizedAssetResponse,
     id: entity.id,
     deviceAssetId: entity.deviceAssetId,
     ownerId: entity.ownerId,
@@ -62,7 +81,7 @@ function _map(entity: AssetEntity, withExif: boolean): AssetResponseDto {
     isArchived: entity.isArchived,
     isTrashed: !!entity.deletedAt,
     duration: entity.duration ?? '0:00:00.00000',
-    exifInfo: withExif ? (entity.exifInfo ? mapExif(entity.exifInfo) : undefined) : undefined,
+    exifInfo: entity.exifInfo ? mapExif(entity.exifInfo) : undefined,
     smartInfo: entity.smartInfo ? mapSmartInfo(entity.smartInfo) : undefined,
     livePhotoVideoId: entity.livePhotoVideoId,
     tags: entity.tags?.map(mapTag),
@@ -71,17 +90,10 @@ function _map(entity: AssetEntity, withExif: boolean): AssetResponseDto {
     isExternal: entity.isExternal,
     isOffline: entity.isOffline,
     isReadOnly: entity.isReadOnly,
+    hasMetadata: true,
   };
 }
 
-export function mapAsset(entity: AssetEntity): AssetResponseDto {
-  return _map(entity, true);
-}
-
-export function mapAssetWithoutExif(entity: AssetEntity): AssetResponseDto {
-  return _map(entity, false);
-}
-
 export class MemoryLaneResponseDto {
   title!: string;
   assets!: AssetResponseDto[];

+ 12 - 0
server/src/domain/asset/response-dto/exif-response.dto.ts

@@ -52,3 +52,15 @@ export function mapExif(entity: ExifEntity): ExifResponseDto {
     projectionType: entity.projectionType,
   };
 }
+
+export function mapSanitizedExif(entity: ExifEntity): ExifResponseDto {
+  return {
+    fileSizeInByte: entity.fileSizeInByte ? parseInt(entity.fileSizeInByte.toString()) : null,
+    orientation: entity.orientation,
+    dateTimeOriginal: entity.dateTimeOriginal,
+    timeZone: entity.timeZone,
+    projectionType: entity.projectionType,
+    exifImageWidth: entity.exifImageWidth,
+    exifImageHeight: entity.exifImageHeight,
+  };
+}

+ 52 - 3
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;
+}

+ 31 - 3
server/src/domain/audit/audi.service.spec.ts → 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', () => {

+ 185 - 4
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 };
+  }
 }

+ 2 - 2
server/src/domain/auth/auth.service.ts

@@ -380,7 +380,7 @@ export class AuthService {
             sharedLinkId: link.id,
             isAllowUpload: link.allowUpload,
             isAllowDownload: link.allowDownload,
-            isShowExif: link.showExif,
+            isShowMetadata: link.showExif,
           };
         }
       }
@@ -431,7 +431,7 @@ export class AuthService {
         isPublicUser: false,
         isAllowUpload: true,
         isAllowDownload: true,
-        isShowExif: true,
+        isShowMetadata: true,
         accessTokenId: token.id,
       };
     }

+ 1 - 1
server/src/domain/auth/dto/auth-user.dto.ts

@@ -6,7 +6,7 @@ export class AuthUserDto {
   sharedLinkId?: string;
   isAllowUpload?: boolean;
   isAllowDownload?: boolean;
-  isShowExif?: boolean;
+  isShowMetadata?: boolean;
   accessTokenId?: string;
   externalPath?: string | null;
 }

+ 4 - 1
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,

+ 1 - 1
server/src/domain/person/person.service.ts

@@ -97,7 +97,7 @@ export class PersonService {
   async getAssets(authUser: AuthUserDto, id: string): Promise<AssetResponseDto[]> {
     await this.access.requirePermission(authUser, Permission.PERSON_READ, id);
     const assets = await this.repository.getAssets(id);
-    return assets.map(mapAsset);
+    return assets.map((asset) => mapAsset(asset));
   }
 
   async update(authUser: AuthUserDto, id: string, dto: PersonUpdateDto): Promise<PersonResponseDto> {

+ 1 - 0
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 {

+ 1 - 1
server/src/domain/search/search.service.ts

@@ -154,7 +154,7 @@ export class SearchService {
         items: assets.items
           .map((item) => lookup[item.id])
           .filter((item) => !!item)
-          .map(mapAsset),
+          .map((asset) => mapAsset(asset)),
       },
     };
   }

+ 3 - 23
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', () => {

+ 2 - 15
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);

+ 8 - 7
server/src/domain/shared-link/shared-link-response.dto.ts

@@ -2,7 +2,7 @@ import { SharedLinkEntity, SharedLinkType } from '@app/infra/entities';
 import { ApiProperty } from '@nestjs/swagger';
 import _ from 'lodash';
 import { AlbumResponseDto, mapAlbumWithoutAssets } from '../album';
-import { AssetResponseDto, mapAsset, mapAssetWithoutExif } from '../asset';
+import { AssetResponseDto, mapAsset } from '../asset';
 
 export class SharedLinkResponseDto {
   id!: string;
@@ -17,8 +17,9 @@ export class SharedLinkResponseDto {
   assets!: AssetResponseDto[];
   album?: AlbumResponseDto;
   allowUpload!: boolean;
+
   allowDownload!: boolean;
-  showExif!: boolean;
+  showMetadata!: boolean;
 }
 
 export function mapSharedLink(sharedLink: SharedLinkEntity): SharedLinkResponseDto {
@@ -35,15 +36,15 @@ export function mapSharedLink(sharedLink: SharedLinkEntity): SharedLinkResponseD
     type: sharedLink.type,
     createdAt: sharedLink.createdAt,
     expiresAt: sharedLink.expiresAt,
-    assets: assets.map(mapAsset),
+    assets: assets.map((asset) => mapAsset(asset)),
     album: sharedLink.album ? mapAlbumWithoutAssets(sharedLink.album) : undefined,
     allowUpload: sharedLink.allowUpload,
     allowDownload: sharedLink.allowDownload,
-    showExif: sharedLink.showExif,
+    showMetadata: sharedLink.showExif,
   };
 }
 
-export function mapSharedLinkWithNoExif(sharedLink: SharedLinkEntity): SharedLinkResponseDto {
+export function mapSharedLinkWithoutMetadata(sharedLink: SharedLinkEntity): SharedLinkResponseDto {
   const linkAssets = sharedLink.assets || [];
   const albumAssets = (sharedLink?.album?.assets || []).map((asset) => asset);
 
@@ -57,10 +58,10 @@ export function mapSharedLinkWithNoExif(sharedLink: SharedLinkEntity): SharedLin
     type: sharedLink.type,
     createdAt: sharedLink.createdAt,
     expiresAt: sharedLink.expiresAt,
-    assets: assets.map(mapAssetWithoutExif),
+    assets: assets.map((asset) => mapAsset(asset, true)) as AssetResponseDto[],
     album: sharedLink.album ? mapAlbumWithoutAssets(sharedLink.album) : undefined,
     allowUpload: sharedLink.allowUpload,
     allowDownload: sharedLink.allowDownload,
-    showExif: sharedLink.showExif,
+    showMetadata: sharedLink.showExif,
   };
 }

+ 2 - 2
server/src/domain/shared-link/shared-link.dto.ts

@@ -34,7 +34,7 @@ export class SharedLinkCreateDto {
 
   @Optional()
   @IsBoolean()
-  showExif?: boolean = true;
+  showMetadata?: boolean = true;
 }
 
 export class SharedLinkEditDto {
@@ -51,5 +51,5 @@ export class SharedLinkEditDto {
   allowDownload?: boolean;
 
   @Optional()
-  showExif?: boolean;
+  showMetadata?: boolean;
 }

+ 3 - 3
server/src/domain/shared-link/shared-link.service.spec.ts

@@ -59,10 +59,10 @@ describe(SharedLinkService.name, () => {
       expect(shareMock.get).toHaveBeenCalledWith(authDto.id, authDto.sharedLinkId);
     });
 
-    it('should return not return exif', async () => {
+    it('should not return metadata', async () => {
       const authDto = authStub.adminSharedLinkNoExif;
       shareMock.get.mockResolvedValue(sharedLinkStub.readonlyNoExif);
-      await expect(sut.getMine(authDto)).resolves.toEqual(sharedLinkResponseStub.readonlyNoExif);
+      await expect(sut.getMine(authDto)).resolves.toEqual(sharedLinkResponseStub.readonlyNoMetadata);
       expect(shareMock.get).toHaveBeenCalledWith(authDto.id, authDto.sharedLinkId);
     });
   });
@@ -137,7 +137,7 @@ describe(SharedLinkService.name, () => {
       await sut.create(authStub.admin, {
         type: SharedLinkType.INDIVIDUAL,
         assetIds: [assetStub.image.id],
-        showExif: true,
+        showMetadata: true,
         allowDownload: true,
         allowUpload: true,
       });

+ 5 - 5
server/src/domain/shared-link/shared-link.service.ts

@@ -4,7 +4,7 @@ import { AccessCore, Permission } from '../access';
 import { AssetIdErrorReason, AssetIdsDto, AssetIdsResponseDto } from '../asset';
 import { AuthUserDto } from '../auth';
 import { IAccessRepository, ICryptoRepository, ISharedLinkRepository } from '../repositories';
-import { SharedLinkResponseDto, mapSharedLink, mapSharedLinkWithNoExif } from './shared-link-response.dto';
+import { SharedLinkResponseDto, mapSharedLink, mapSharedLinkWithoutMetadata } from './shared-link-response.dto';
 import { SharedLinkCreateDto, SharedLinkEditDto } from './shared-link.dto';
 
 @Injectable()
@@ -24,7 +24,7 @@ export class SharedLinkService {
   }
 
   async getMine(authUser: AuthUserDto): Promise<SharedLinkResponseDto> {
-    const { sharedLinkId: id, isPublicUser, isShowExif } = authUser;
+    const { sharedLinkId: id, isPublicUser, isShowMetadata: isShowExif } = authUser;
 
     if (!isPublicUser || !id) {
       throw new ForbiddenException();
@@ -69,7 +69,7 @@ export class SharedLinkService {
       expiresAt: dto.expiresAt || null,
       allowUpload: dto.allowUpload ?? true,
       allowDownload: dto.allowDownload ?? true,
-      showExif: dto.showExif ?? true,
+      showExif: dto.showMetadata ?? true,
     });
 
     return this.map(sharedLink, { withExif: true });
@@ -84,7 +84,7 @@ export class SharedLinkService {
       expiresAt: dto.expiresAt,
       allowUpload: dto.allowUpload,
       allowDownload: dto.allowDownload,
-      showExif: dto.showExif,
+      showExif: dto.showMetadata,
     });
     return this.map(sharedLink, { withExif: true });
   }
@@ -157,6 +157,6 @@ export class SharedLinkService {
   }
 
   private map(sharedLink: SharedLinkEntity, { withExif }: { withExif: boolean }) {
-    return withExif ? mapSharedLink(sharedLink) : mapSharedLinkWithNoExif(sharedLink);
+    return withExif ? mapSharedLink(sharedLink) : mapSharedLinkWithoutMetadata(sharedLink);
   }
 }

+ 1 - 1
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');

+ 10 - 6
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) {

+ 3 - 14
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', () => {

+ 3 - 11
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);
   }
 

+ 1 - 1
server/src/domain/tag/tag.service.ts

@@ -47,7 +47,7 @@ export class TagService {
   async getAssets(authUser: AuthUserDto, id: string): Promise<AssetResponseDto[]> {
     await this.findOrFail(authUser, id);
     const assets = await this.repository.getAssets(authUser.id, id);
-    return assets.map(mapAsset);
+    return assets.map((asset) => mapAsset(asset));
   }
 
   async addAssets(authUser: AuthUserDto, id: string, dto: AssetIdsDto): Promise<AssetIdsResponseDto[]> {

+ 1 - 1
server/src/immich/api-v1/asset/asset.controller.ts

@@ -186,7 +186,7 @@ export class AssetController {
   @SharedLinkRoute()
   @Get('/assetById/:id')
   getAssetById(@AuthUser() authUser: AuthUserDto, @Param() { id }: UUIDParamDto): Promise<AssetResponseDto> {
-    return this.assetService.getAssetById(authUser, id);
+    return this.assetService.getAssetById(authUser, id) as Promise<AssetResponseDto>;
   }
 
   /**

+ 19 - 12
server/src/immich/api-v1/asset/asset.service.ts

@@ -10,9 +10,9 @@ import {
   IStorageRepository,
   JobName,
   mapAsset,
-  mapAssetWithoutExif,
   mimeTypes,
   Permission,
+  SanitizedAssetResponseDto,
   UploadFile,
 } from '@app/domain';
 import { ASSET_CHECKSUM_CONSTRAINT, AssetEntity, AssetType, LibraryType } from '@app/infra/entities';
@@ -187,22 +187,29 @@ export class AssetService {
     return assets.map((asset) => mapAsset(asset));
   }
 
-  public async getAssetById(authUser: AuthUserDto, assetId: string): Promise<AssetResponseDto> {
+  public async getAssetById(
+    authUser: AuthUserDto,
+    assetId: string,
+  ): Promise<AssetResponseDto | SanitizedAssetResponseDto> {
     await this.access.requirePermission(authUser, Permission.ASSET_READ, assetId);
 
-    const allowExif = this.getExifPermission(authUser);
+    const includeMetadata = this.getExifPermission(authUser);
     const asset = await this._assetRepository.getById(assetId);
-    const data = allowExif ? mapAsset(asset) : mapAssetWithoutExif(asset);
+    if (includeMetadata) {
+      const data = mapAsset(asset);
 
-    if (data.ownerId !== authUser.id) {
-      data.people = [];
-    }
+      if (data.ownerId !== authUser.id) {
+        data.people = [];
+      }
 
-    if (authUser.isPublicUser) {
-      delete data.owner;
-    }
+      if (authUser.isPublicUser) {
+        delete data.owner;
+      }
 
-    return data;
+      return data;
+    } else {
+      return mapAsset(asset, true);
+    }
   }
 
   async serveThumbnail(authUser: AuthUserDto, assetId: string, query: GetAssetThumbnailDto, res: Res) {
@@ -374,7 +381,7 @@ export class AssetService {
   }
 
   getExifPermission(authUser: AuthUserDto) {
-    return !authUser.isPublicUser || authUser.isShowExif;
+    return !authUser.isPublicUser || authUser.isShowMetadata;
   }
 
   private getThumbnailPath(asset: AssetEntity, format: GetAssetThumbnailFormatEnum) {

+ 1 - 1
server/src/immich/controllers/asset.controller.ts

@@ -98,7 +98,7 @@ export class AssetController {
   @Authenticated({ isShared: true })
   @Get('time-bucket')
   getByTimeBucket(@AuthUser() authUser: AuthUserDto, @Query() dto: TimeBucketAssetDto): Promise<AssetResponseDto[]> {
-    return this.service.getByTimeBucket(authUser, dto);
+    return this.service.getByTimeBucket(authUser, dto) as Promise<AssetResponseDto[]>;
   }
 
   @Post('jobs')

+ 30 - 3
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);
+  }
 }

+ 5 - 1
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;

+ 1 - 1
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',

+ 1 - 1
server/src/infra/repositories/person.repository.ts

@@ -72,7 +72,7 @@ export class PersonRepository implements IPersonRepository {
       .addOrderBy("NULLIF(person.name, '') IS NULL", 'ASC')
       .addOrderBy('COUNT(face.assetId)', 'DESC')
       .addOrderBy("NULLIF(person.name, '')", 'ASC', 'NULLS LAST')
-      .having('COUNT(face.assetId) >= :faces', { faces: options?.minimumFaceCount || 1 })
+      .having("person.name != '' OR COUNT(face.assetId) >= :faces", { faces: options?.minimumFaceCount || 1 })
       .groupBy('person.id')
       .limit(500);
     if (!options?.withHidden) {

+ 7 - 0
server/test/api/shared-link-api.ts

@@ -10,4 +10,11 @@ export const sharedLinkApi = {
     expect(status).toBe(201);
     return body as SharedLinkResponseDto;
   },
+
+  getMySharedLink: async (server: any, key: string) => {
+    const { status, body } = await request(server).get('/shared-link/me').query({ key });
+
+    expect(status).toBe(200);
+    return body as SharedLinkResponseDto;
+  },
 };

+ 98 - 12
server/test/e2e/shared-link.e2e-spec.ts

@@ -1,11 +1,17 @@
 import { AlbumResponseDto, LoginResponseDto, SharedLinkResponseDto } from '@app/domain';
 import { PartnerController } from '@app/immich';
-import { SharedLinkType } from '@app/infra/entities';
+import { LibraryType, SharedLinkType } from '@app/infra/entities';
 import { INestApplication } from '@nestjs/common';
 import { api } from '@test/api';
 import { db } from '@test/db';
 import { errorStub, uuidStub } from '@test/fixtures';
-import { createTestApp } from '@test/test-utils';
+import {
+  IMMICH_TEST_ASSET_PATH,
+  IMMICH_TEST_ASSET_TEMP_PATH,
+  createTestApp,
+  restoreTempFolder,
+} from '@test/test-utils';
+import { cp } from 'fs/promises';
 import request from 'supertest';
 
 const user1Dto = {
@@ -18,24 +24,22 @@ const user1Dto = {
 describe(`${PartnerController.name} (e2e)`, () => {
   let app: INestApplication;
   let server: any;
-  let loginResponse: LoginResponseDto;
-  let accessToken: string;
+  let admin: LoginResponseDto;
   let user1: LoginResponseDto;
   let album: AlbumResponseDto;
   let sharedLink: SharedLinkResponseDto;
 
   beforeAll(async () => {
-    app = await createTestApp();
+    app = await createTestApp(true);
     server = app.getHttpServer();
   });
 
   beforeEach(async () => {
     await db.reset();
     await api.authApi.adminSignUp(server);
-    loginResponse = await api.authApi.adminLogin(server);
-    accessToken = loginResponse.accessToken;
+    admin = await api.authApi.adminLogin(server);
 
-    await api.userApi.create(server, accessToken, user1Dto);
+    await api.userApi.create(server, admin.accessToken, user1Dto);
     user1 = await api.authApi.login(server, { email: user1Dto.email, password: user1Dto.password });
 
     album = await api.albumApi.create(server, user1.accessToken, { albumName: 'shared with link' });
@@ -48,6 +52,7 @@ describe(`${PartnerController.name} (e2e)`, () => {
   afterAll(async () => {
     await db.disconnect();
     await app.close();
+    await restoreTempFolder();
   });
 
   describe('GET /shared-link', () => {
@@ -68,7 +73,9 @@ describe(`${PartnerController.name} (e2e)`, () => {
     });
 
     it('should not get shared links created by other users', async () => {
-      const { status, body } = await request(server).get('/shared-link').set('Authorization', `Bearer ${accessToken}`);
+      const { status, body } = await request(server)
+        .get('/shared-link')
+        .set('Authorization', `Bearer ${admin.accessToken}`);
 
       expect(status).toBe(200);
       expect(body).toEqual([]);
@@ -77,7 +84,9 @@ describe(`${PartnerController.name} (e2e)`, () => {
 
   describe('GET /shared-link/me', () => {
     it('should not require admin authentication', async () => {
-      const { status } = await request(server).get('/shared-link/me').set('Authorization', `Bearer ${accessToken}`);
+      const { status } = await request(server)
+        .get('/shared-link/me')
+        .set('Authorization', `Bearer ${admin.accessToken}`);
 
       expect(status).toBe(403);
     });
@@ -104,7 +113,7 @@ describe(`${PartnerController.name} (e2e)`, () => {
         type: SharedLinkType.ALBUM,
         albumId: softDeletedAlbum.id,
       });
-      await api.userApi.delete(server, accessToken, user1.userId);
+      await api.userApi.delete(server, admin.accessToken, user1.userId);
 
       const { status, body } = await request(server).get('/shared-link/me').query({ key: softDeletedAlbumLink.key });
 
@@ -133,7 +142,7 @@ describe(`${PartnerController.name} (e2e)`, () => {
     it('should not get shared link by id if user has not created the link or it does not exist', async () => {
       const { status, body } = await request(server)
         .get(`/shared-link/${sharedLink.id}`)
-        .set('Authorization', `Bearer ${accessToken}`);
+        .set('Authorization', `Bearer ${admin.accessToken}`);
 
       expect(status).toBe(400);
       expect(body).toEqual(expect.objectContaining({ message: 'Shared link not found' }));
@@ -248,4 +257,81 @@ describe(`${PartnerController.name} (e2e)`, () => {
       expect(status).toBe(200);
     });
   });
+
+  describe('Shared link metadata', () => {
+    beforeEach(async () => {
+      await restoreTempFolder();
+
+      await cp(
+        `${IMMICH_TEST_ASSET_PATH}/metadata/gps-position/thompson-springs.jpg`,
+        `${IMMICH_TEST_ASSET_TEMP_PATH}/thompson-springs.jpg`,
+      );
+
+      await api.userApi.setExternalPath(server, admin.accessToken, admin.userId, '/');
+
+      const library = await api.libraryApi.create(server, admin.accessToken, {
+        type: LibraryType.EXTERNAL,
+        importPaths: [`${IMMICH_TEST_ASSET_TEMP_PATH}`],
+      });
+
+      await api.libraryApi.scanLibrary(server, admin.accessToken, library.id);
+
+      const assets = await api.assetApi.getAllAssets(server, admin.accessToken);
+
+      expect(assets).toHaveLength(1);
+
+      album = await api.albumApi.create(server, admin.accessToken, { albumName: 'New album' });
+      await api.albumApi.addAssets(server, admin.accessToken, album.id, { ids: [assets[0].id] });
+    });
+
+    it('should return metadata for album shared link', async () => {
+      const sharedLink = await api.sharedLinkApi.create(server, admin.accessToken, {
+        type: SharedLinkType.ALBUM,
+        albumId: album.id,
+      });
+
+      const returnedLink = await api.sharedLinkApi.getMySharedLink(server, sharedLink.key);
+
+      expect(returnedLink.assets).toHaveLength(1);
+      expect(returnedLink.album).toBeDefined();
+
+      const returnedAsset = returnedLink.assets[0];
+      expect(returnedAsset).toEqual(
+        expect.objectContaining({
+          originalFileName: 'thompson-springs',
+          resized: true,
+          localDateTime: '2022-01-10T15:15:44.310Z',
+          fileCreatedAt: '2022-01-10T19:15:44.310Z',
+          exifInfo: expect.objectContaining({
+            longitude: -108.400968333333,
+            latitude: 39.115,
+            orientation: '1',
+            dateTimeOriginal: '2022-01-10T19:15:44.310Z',
+            timeZone: 'UTC-4',
+            state: 'Mesa County, Colorado',
+            country: 'United States of America',
+          }),
+        }),
+      );
+    });
+
+    it('should not return metadata for album shared link without metadata', async () => {
+      const sharedLink = await api.sharedLinkApi.create(server, admin.accessToken, {
+        type: SharedLinkType.ALBUM,
+        albumId: album.id,
+        showMetadata: false,
+      });
+
+      const returnedLink = await api.sharedLinkApi.getMySharedLink(server, sharedLink.key);
+
+      expect(returnedLink.assets).toHaveLength(1);
+      expect(returnedLink.album).toBeDefined();
+
+      const returnedAsset = returnedLink.assets[0];
+      expect(returnedAsset).not.toHaveProperty('exifInfo');
+      expect(returnedAsset).not.toHaveProperty('fileCreatedAt');
+      expect(returnedAsset).not.toHaveProperty('originalFilename');
+      expect(returnedAsset).not.toHaveProperty('originalPath');
+    });
+  });
 });

+ 6 - 6
server/test/fixtures/auth.stub.ts

@@ -48,7 +48,7 @@ export const authStub = {
     isPublicUser: false,
     isAllowUpload: true,
     isAllowDownload: true,
-    isShowExif: true,
+    isShowMetadata: true,
     accessTokenId: 'token-id',
     externalPath: null,
   }),
@@ -59,7 +59,7 @@ export const authStub = {
     isPublicUser: false,
     isAllowUpload: true,
     isAllowDownload: true,
-    isShowExif: true,
+    isShowMetadata: true,
     accessTokenId: 'token-id',
     externalPath: null,
   }),
@@ -70,7 +70,7 @@ export const authStub = {
     isPublicUser: false,
     isAllowUpload: true,
     isAllowDownload: true,
-    isShowExif: true,
+    isShowMetadata: true,
     accessTokenId: 'token-id',
     externalPath: '/data/user1',
   }),
@@ -81,7 +81,7 @@ export const authStub = {
     isAllowUpload: true,
     isAllowDownload: true,
     isPublicUser: true,
-    isShowExif: true,
+    isShowMetadata: true,
     sharedLinkId: '123',
   }),
   adminSharedLinkNoExif: Object.freeze<AuthUserDto>({
@@ -91,7 +91,7 @@ export const authStub = {
     isAllowUpload: true,
     isAllowDownload: true,
     isPublicUser: true,
-    isShowExif: false,
+    isShowMetadata: false,
     sharedLinkId: '123',
   }),
   readonlySharedLink: Object.freeze<AuthUserDto>({
@@ -101,7 +101,7 @@ export const authStub = {
     isAllowUpload: false,
     isAllowDownload: false,
     isPublicUser: true,
-    isShowExif: true,
+    isShowMetadata: true,
     sharedLinkId: '123',
     accessTokenId: 'token-id',
   }),

+ 18 - 6
server/test/fixtures/shared-link.stub.ts

@@ -71,8 +71,20 @@ const assetResponse: AssetResponseDto = {
   checksum: 'ZmlsZSBoYXNo',
   isTrashed: false,
   libraryId: 'library-id',
+  hasMetadata: true,
 };
 
+const assetResponseWithoutMetadata = {
+  id: 'id_1',
+  type: AssetType.VIDEO,
+  resized: false,
+  thumbhash: null,
+  localDateTime: today,
+  duration: '0:00:00.00000',
+  livePhotoVideoId: null,
+  hasMetadata: false,
+} as AssetResponseDto;
+
 const albumResponse: AlbumResponseDto = {
   albumName: 'Test Album',
   description: '',
@@ -253,7 +265,7 @@ export const sharedLinkResponseStub = {
     expiresAt: tomorrow,
     id: '123',
     key: sharedLinkBytes.toString('base64url'),
-    showExif: true,
+    showMetadata: true,
     type: SharedLinkType.ALBUM,
     userId: 'admin_id',
   }),
@@ -267,7 +279,7 @@ export const sharedLinkResponseStub = {
     expiresAt: yesterday,
     id: '123',
     key: sharedLinkBytes.toString('base64url'),
-    showExif: true,
+    showMetadata: true,
     type: SharedLinkType.ALBUM,
     userId: 'admin_id',
   }),
@@ -281,11 +293,11 @@ export const sharedLinkResponseStub = {
     description: null,
     allowUpload: false,
     allowDownload: false,
-    showExif: true,
+    showMetadata: true,
     album: albumResponse,
     assets: [assetResponse],
   }),
-  readonlyNoExif: Object.freeze<SharedLinkResponseDto>({
+  readonlyNoMetadata: Object.freeze<SharedLinkResponseDto>({
     id: '123',
     userId: 'admin_id',
     key: sharedLinkBytes.toString('base64url'),
@@ -295,8 +307,8 @@ export const sharedLinkResponseStub = {
     description: null,
     allowUpload: false,
     allowDownload: false,
-    showExif: false,
+    showMetadata: false,
     album: { ...albumResponse, startDate: assetResponse.fileCreatedAt, endDate: assetResponse.fileCreatedAt },
-    assets: [{ ...assetResponse, exifInfo: undefined }],
+    assets: [{ ...assetResponseWithoutMetadata, exifInfo: undefined }],
   }),
 };

+ 3 - 0
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);

+ 388 - 4
web/src/api/open-api/api.ts

@@ -640,6 +640,12 @@ export interface AssetResponseDto {
      * @memberof AssetResponseDto
      */
     'fileModifiedAt': string;
+    /**
+     * 
+     * @type {boolean}
+     * @memberof AssetResponseDto
+     */
+    'hasMetadata': boolean;
     /**
      * 
      * @type {string}
@@ -749,7 +755,7 @@ export interface AssetResponseDto {
      */
     'tags'?: Array<TagResponseDto>;
     /**
-     * base64 encoded thumbhash
+     * 
      * @type {string}
      * @memberof AssetResponseDto
      */
@@ -1598,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
@@ -2180,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
@@ -2882,7 +3025,7 @@ export interface SharedLinkCreateDto {
      * @type {boolean}
      * @memberof SharedLinkCreateDto
      */
-    'showExif'?: boolean;
+    'showMetadata'?: boolean;
     /**
      * 
      * @type {SharedLinkType}
@@ -2927,7 +3070,7 @@ export interface SharedLinkEditDto {
      * @type {boolean}
      * @memberof SharedLinkEditDto
      */
-    'showExif'?: boolean;
+    'showMetadata'?: boolean;
 }
 /**
  * 
@@ -2994,7 +3137,7 @@ export interface SharedLinkResponseDto {
      * @type {boolean}
      * @memberof SharedLinkResponseDto
      */
-    'showExif': boolean;
+    'showMetadata': boolean;
     /**
      * 
      * @type {SharedLinkType}
@@ -8815,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 
@@ -8869,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,
@@ -8884,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 
@@ -8896,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);
+        },
     }
 };
 
@@ -8906,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.
@@ -8915,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
@@ -8946,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
@@ -8953,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.
@@ -8963,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));
+    }
 }
 
 

Файловите разлики са ограничени, защото са твърде много
+ 0 - 0
web/src/lib/assets/empty-4.svg


+ 9 - 1
web/src/lib/components/asset-viewer/asset-viewer-nav-bar.svelte

@@ -28,6 +28,7 @@
   export let showMotionPlayButton: boolean;
   export let isMotionPhotoPlaying = false;
   export let showDownloadButton: boolean;
+  export let showDetailButton: boolean;
   export let showSlideshow = false;
 
   const isOwner = asset.ownerId === $page.data.user?.id;
@@ -133,7 +134,14 @@
         title="Download"
       />
     {/if}
-    <CircleIconButton isOpacity={true} logo={InformationOutline} on:click={() => dispatch('showDetail')} title="Info" />
+    {#if showDetailButton}
+      <CircleIconButton
+        isOpacity={true}
+        logo={InformationOutline}
+        on:click={() => dispatch('showDetail')}
+        title="Info"
+      />
+    {/if}
     {#if isOwner}
       <CircleIconButton
         isOpacity={true}

+ 5 - 3
web/src/lib/components/asset-viewer/asset-viewer.svelte

@@ -55,6 +55,7 @@
   let shouldPlayMotionPhoto = false;
   let isShowProfileImageCrop = false;
   let shouldShowDownloadButton = sharedLink ? sharedLink.allowDownload : !asset.isOffline;
+  let shouldShowDetailButton = asset.hasMetadata;
   let canCopyImagesToClipboard: boolean;
 
   const onKeyboardPress = (keyInfo: KeyboardEvent) => handleKeyboardPress(keyInfo);
@@ -392,6 +393,7 @@
         showZoomButton={asset.type === AssetTypeEnum.Image}
         showMotionPlayButton={!!asset.livePhotoVideoId}
         showDownloadButton={shouldShowDownloadButton}
+        showDetailButton={shouldShowDetailButton}
         showSlideshow={!!assetStore}
         on:goBack={closeViewer}
         on:showDetail={showDetailInfoHandler}
@@ -433,9 +435,9 @@
             on:close={closeViewer}
             on:onVideoEnded={() => (shouldPlayMotionPhoto = false)}
           />
-        {:else if asset.exifInfo?.projectionType === ProjectionType.EQUIRECTANGULAR || asset.originalPath
-            .toLowerCase()
-            .endsWith('.insp')}
+        {:else if asset.exifInfo?.projectionType === ProjectionType.EQUIRECTANGULAR || (asset.originalPath && asset.originalPath
+              .toLowerCase()
+              .endsWith('.insp'))}
           <PanoramaViewer {asset} />
         {:else}
           <PhotoViewer {asset} on:close={closeViewer} />

+ 2 - 1
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>

+ 7 - 7
web/src/lib/components/shared-components/create-share-link-modal/create-shared-link-modal.svelte

@@ -21,7 +21,7 @@
   let description = '';
   let allowDownload = true;
   let allowUpload = false;
-  let showExif = true;
+  let showMetadata = true;
   let expirationTime = '';
   let shouldChangeExpirationTime = false;
   let canCopyImagesToClipboard = true;
@@ -41,7 +41,7 @@
       }
       allowUpload = editingLink.allowUpload;
       allowDownload = editingLink.allowDownload;
-      showExif = editingLink.showExif;
+      showMetadata = editingLink.showMetadata;
 
       albumId = editingLink.album?.id;
       assetIds = editingLink.assets.map(({ id }) => id);
@@ -66,7 +66,7 @@
           allowUpload,
           description,
           allowDownload,
-          showExif,
+          showMetadata,
         },
       });
       sharedLink = `${window.location.origin}/share/${data.key}`;
@@ -119,9 +119,9 @@
         sharedLinkEditDto: {
           description,
           expiresAt: shouldChangeExpirationTime ? expirationDate : undefined,
-          allowUpload: allowUpload,
-          allowDownload: allowDownload,
-          showExif: showExif,
+          allowUpload,
+          allowDownload,
+          showMetadata,
         },
       });
 
@@ -184,7 +184,7 @@
         </div>
 
         <div class="my-3">
-          <SettingSwitch bind:checked={showExif} title={'Show metadata'} />
+          <SettingSwitch bind:checked={showMetadata} title={'Show metadata'} />
         </div>
 
         <div class="my-3">

+ 4 - 0
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>

+ 1 - 1
web/src/lib/components/sharedlinks-page/shared-link-card.svelte

@@ -136,7 +136,7 @@
         </div>
       {/if}
 
-      {#if link.showExif}
+      {#if link.showMetadata}
         <div
           class="flex w-[60px] place-content-center place-items-center rounded-full bg-immich-primary px-2 py-1 text-xs text-white dark:bg-immich-dark-primary dark:text-immich-dark-gray"
         >

+ 1 - 0
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',

+ 26 - 0
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;

+ 336 - 0
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>

Някои файлове не бяха показани, защото твърде много файлове са промени