diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index eb48c50fa9..605993f5e9 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -338,12 +338,15 @@ jobs: name: End-to-End Tests (Server & CLI) needs: pre-job if: ${{ needs.pre-job.outputs.should_run_e2e_server_cli == 'true' }} - runs-on: mich + runs-on: ${{ matrix.runner }} permissions: contents: read defaults: run: working-directory: ./e2e + strategy: + matrix: + runner: [mich, ubuntu-24.04-arm] steps: - name: Checkout code @@ -383,12 +386,15 @@ jobs: name: End-to-End Tests (Web) needs: pre-job if: ${{ needs.pre-job.outputs.should_run_e2e_web == 'true' }} - runs-on: mich + runs-on: ${{ matrix.runner }} permissions: contents: read defaults: run: working-directory: ./e2e + strategy: + matrix: + runner: [mich, ubuntu-24.04-arm] steps: - name: Checkout code @@ -423,6 +429,21 @@ jobs: run: npx playwright test if: ${{ !cancelled() }} + success-check-e2e: + name: End-to-End Tests Success + needs: [e2e-tests-server-cli, e2e-tests-web] + permissions: {} + runs-on: ubuntu-latest + if: always() + steps: + - name: Any jobs failed? + if: ${{ contains(needs.*.result, 'failure') }} + run: exit 1 + - name: All jobs passed or skipped + if: ${{ !(contains(needs.*.result, 'failure')) }} + # zizmor: ignore[template-injection] + run: echo "All jobs passed or skipped" && echo "${{ toJSON(needs.*.result) }}" + mobile-unit-tests: name: Unit Test Mobile needs: pre-job diff --git a/e2e/src/api/specs/audit.e2e-spec.ts b/e2e/src/api/specs/audit.e2e-spec.ts deleted file mode 100644 index c6a2adbb0a..0000000000 --- a/e2e/src/api/specs/audit.e2e-spec.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { deleteAssets, getAuditFiles, updateAsset, type LoginResponseDto } from '@immich/sdk'; -import { asBearerAuth, utils } from 'src/utils'; -import { beforeAll, describe, expect, it } from 'vitest'; - -describe('/audits', () => { - let admin: LoginResponseDto; - - beforeAll(async () => { - await utils.resetDatabase(); - await utils.resetFilesystem(); - - admin = await utils.adminSetup(); - }); - - // TODO: Enable these tests again once #7436 is resolved as these were flaky - describe.skip('GET :/file-report', () => { - it('excludes assets without issues from report', async () => { - const [trashedAsset, archivedAsset] = await Promise.all([ - utils.createAsset(admin.accessToken), - utils.createAsset(admin.accessToken), - utils.createAsset(admin.accessToken), - ]); - - await Promise.all([ - deleteAssets({ assetBulkDeleteDto: { ids: [trashedAsset.id] } }, { headers: asBearerAuth(admin.accessToken) }), - updateAsset( - { - id: archivedAsset.id, - updateAssetDto: { isArchived: true }, - }, - { headers: asBearerAuth(admin.accessToken) }, - ), - ]); - - const body = await getAuditFiles({ - headers: asBearerAuth(admin.accessToken), - }); - - expect(body.orphans).toHaveLength(0); - expect(body.extras).toHaveLength(0); - }); - }); -}); diff --git a/i18n/en.json b/i18n/en.json index 6d7b8610a1..c01cd65712 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -1260,6 +1260,7 @@ "no_favorites_message": "Add favorites to quickly find your best pictures and videos", "no_libraries_message": "Create an external library to view your photos and videos", "no_name": "No Name", + "no_people_found": "No matching people found", "no_places": "No places", "no_results": "No results", "no_results_description": "Try a synonym or more general keyword", @@ -1572,6 +1573,7 @@ "select_keep_all": "Select keep all", "select_library_owner": "Select library owner", "select_new_face": "Select new face", + "select_person_to_tag": "Select a person to tag", "select_photos": "Select photos", "select_trash_all": "Select trash all", "select_user_for_sharing_page_err_album": "Failed to create album", diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index d46945f640..7c8afb09e4 100644 --- a/mobile/openapi/README.md +++ b/mobile/openapi/README.md @@ -100,7 +100,6 @@ Class | Method | HTTP request | Description *AssetsApi* | [**getAllUserAssetsByDeviceId**](doc//AssetsApi.md#getalluserassetsbydeviceid) | **GET** /assets/device/{deviceId} | getAllUserAssetsByDeviceId *AssetsApi* | [**getAssetInfo**](doc//AssetsApi.md#getassetinfo) | **GET** /assets/{id} | *AssetsApi* | [**getAssetStatistics**](doc//AssetsApi.md#getassetstatistics) | **GET** /assets/statistics | -*AssetsApi* | [**getMemoryLane**](doc//AssetsApi.md#getmemorylane) | **GET** /assets/memory-lane | *AssetsApi* | [**getRandom**](doc//AssetsApi.md#getrandom) | **GET** /assets/random | *AssetsApi* | [**playAssetVideo**](doc//AssetsApi.md#playassetvideo) | **GET** /assets/{id}/video/playback | *AssetsApi* | [**replaceAsset**](doc//AssetsApi.md#replaceasset) | **PUT** /assets/{id}/original | replaceAsset @@ -122,9 +121,6 @@ Class | Method | HTTP request | Description *FacesApi* | [**deleteFace**](doc//FacesApi.md#deleteface) | **DELETE** /faces/{id} | *FacesApi* | [**getFaces**](doc//FacesApi.md#getfaces) | **GET** /faces | *FacesApi* | [**reassignFacesById**](doc//FacesApi.md#reassignfacesbyid) | **PUT** /faces/{id} | -*FileReportsApi* | [**fixAuditFiles**](doc//FileReportsApi.md#fixauditfiles) | **POST** /reports/fix | -*FileReportsApi* | [**getAuditFiles**](doc//FileReportsApi.md#getauditfiles) | **GET** /reports | -*FileReportsApi* | [**getFileChecksums**](doc//FileReportsApi.md#getfilechecksums) | **POST** /reports/checksum | *JobsApi* | [**createJob**](doc//JobsApi.md#createjob) | **POST** /jobs | *JobsApi* | [**getAllJobsStatus**](doc//JobsApi.md#getalljobsstatus) | **GET** /jobs | *JobsApi* | [**sendJobCommand**](doc//JobsApi.md#sendjobcommand) | **PUT** /jobs/{id} | @@ -332,11 +328,6 @@ Class | Method | HTTP request | Description - [ExifResponseDto](doc//ExifResponseDto.md) - [FaceDto](doc//FaceDto.md) - [FacialRecognitionConfig](doc//FacialRecognitionConfig.md) - - [FileChecksumDto](doc//FileChecksumDto.md) - - [FileChecksumResponseDto](doc//FileChecksumResponseDto.md) - - [FileReportDto](doc//FileReportDto.md) - - [FileReportFixDto](doc//FileReportFixDto.md) - - [FileReportItemDto](doc//FileReportItemDto.md) - [FoldersResponse](doc//FoldersResponse.md) - [FoldersUpdate](doc//FoldersUpdate.md) - [ImageFormat](doc//ImageFormat.md) @@ -361,7 +352,6 @@ Class | Method | HTTP request | Description - [MemoriesResponse](doc//MemoriesResponse.md) - [MemoriesUpdate](doc//MemoriesUpdate.md) - [MemoryCreateDto](doc//MemoryCreateDto.md) - - [MemoryLaneResponseDto](doc//MemoryLaneResponseDto.md) - [MemoryResponseDto](doc//MemoryResponseDto.md) - [MemoryType](doc//MemoryType.md) - [MemoryUpdateDto](doc//MemoryUpdateDto.md) @@ -381,8 +371,6 @@ Class | Method | HTTP request | Description - [OnThisDayDto](doc//OnThisDayDto.md) - [PartnerDirection](doc//PartnerDirection.md) - [PartnerResponseDto](doc//PartnerResponseDto.md) - - [PathEntityType](doc//PathEntityType.md) - - [PathType](doc//PathType.md) - [PeopleResponse](doc//PeopleResponse.md) - [PeopleResponseDto](doc//PeopleResponseDto.md) - [PeopleUpdate](doc//PeopleUpdate.md) diff --git a/mobile/openapi/lib/api.dart b/mobile/openapi/lib/api.dart index ba64363c97..ab9b251e01 100644 --- a/mobile/openapi/lib/api.dart +++ b/mobile/openapi/lib/api.dart @@ -39,7 +39,6 @@ part 'api/deprecated_api.dart'; part 'api/download_api.dart'; part 'api/duplicates_api.dart'; part 'api/faces_api.dart'; -part 'api/file_reports_api.dart'; part 'api/jobs_api.dart'; part 'api/libraries_api.dart'; part 'api/map_api.dart'; @@ -133,11 +132,6 @@ part 'model/email_notifications_update.dart'; part 'model/exif_response_dto.dart'; part 'model/face_dto.dart'; part 'model/facial_recognition_config.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/folders_response.dart'; part 'model/folders_update.dart'; part 'model/image_format.dart'; @@ -162,7 +156,6 @@ part 'model/map_reverse_geocode_response_dto.dart'; part 'model/memories_response.dart'; part 'model/memories_update.dart'; part 'model/memory_create_dto.dart'; -part 'model/memory_lane_response_dto.dart'; part 'model/memory_response_dto.dart'; part 'model/memory_type.dart'; part 'model/memory_update_dto.dart'; @@ -182,8 +175,6 @@ part 'model/o_auth_token_endpoint_auth_method.dart'; part 'model/on_this_day_dto.dart'; part 'model/partner_direction.dart'; part 'model/partner_response_dto.dart'; -part 'model/path_entity_type.dart'; -part 'model/path_type.dart'; part 'model/people_response.dart'; part 'model/people_response_dto.dart'; part 'model/people_update.dart'; diff --git a/mobile/openapi/lib/api/assets_api.dart b/mobile/openapi/lib/api/assets_api.dart index f52c70b37f..f744988449 100644 --- a/mobile/openapi/lib/api/assets_api.dart +++ b/mobile/openapi/lib/api/assets_api.dart @@ -404,63 +404,6 @@ class AssetsApi { return null; } - /// Performs an HTTP 'GET /assets/memory-lane' operation and returns the [Response]. - /// Parameters: - /// - /// * [int] day (required): - /// - /// * [int] month (required): - Future getMemoryLaneWithHttpInfo(int day, int month,) async { - // ignore: prefer_const_declarations - final apiPath = r'/assets/memory-lane'; - - // ignore: prefer_final_locals - Object? postBody; - - final queryParams = []; - final headerParams = {}; - final formParams = {}; - - queryParams.addAll(_queryParams('', 'day', day)); - queryParams.addAll(_queryParams('', 'month', month)); - - const contentTypes = []; - - - return apiClient.invokeAPI( - apiPath, - 'GET', - queryParams, - postBody, - headerParams, - formParams, - contentTypes.isEmpty ? null : contentTypes.first, - ); - } - - /// Parameters: - /// - /// * [int] day (required): - /// - /// * [int] month (required): - Future?> getMemoryLane(int day, int month,) async { - final response = await getMemoryLaneWithHttpInfo(day, month,); - 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') as List) - .cast() - .toList(growable: false); - - } - return null; - } - /// This property was deprecated in v1.116.0 /// /// Note: This method returns the HTTP [Response]. diff --git a/mobile/openapi/lib/api/file_reports_api.dart b/mobile/openapi/lib/api/file_reports_api.dart deleted file mode 100644 index 73b3feaedb..0000000000 --- a/mobile/openapi/lib/api/file_reports_api.dart +++ /dev/null @@ -1,148 +0,0 @@ -// -// AUTO-GENERATED FILE, DO NOT MODIFY! -// -// @dart=2.18 - -// 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 FileReportsApi { - FileReportsApi([ApiClient? apiClient]) : apiClient = apiClient ?? defaultApiClient; - - final ApiClient apiClient; - - /// Performs an HTTP 'POST /reports/fix' operation and returns the [Response]. - /// Parameters: - /// - /// * [FileReportFixDto] fileReportFixDto (required): - Future fixAuditFilesWithHttpInfo(FileReportFixDto fileReportFixDto,) async { - // ignore: prefer_const_declarations - final apiPath = r'/reports/fix'; - - // ignore: prefer_final_locals - Object? postBody = fileReportFixDto; - - final queryParams = []; - final headerParams = {}; - final formParams = {}; - - const contentTypes = ['application/json']; - - - return apiClient.invokeAPI( - apiPath, - 'POST', - queryParams, - postBody, - headerParams, - formParams, - contentTypes.isEmpty ? null : contentTypes.first, - ); - } - - /// Parameters: - /// - /// * [FileReportFixDto] fileReportFixDto (required): - Future 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 /reports' operation and returns the [Response]. - Future getAuditFilesWithHttpInfo() async { - // ignore: prefer_const_declarations - final apiPath = r'/reports'; - - // ignore: prefer_final_locals - Object? postBody; - - final queryParams = []; - final headerParams = {}; - final formParams = {}; - - const contentTypes = []; - - - return apiClient.invokeAPI( - apiPath, - 'GET', - queryParams, - postBody, - headerParams, - formParams, - contentTypes.isEmpty ? null : contentTypes.first, - ); - } - - Future 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 /reports/checksum' operation and returns the [Response]. - /// Parameters: - /// - /// * [FileChecksumDto] fileChecksumDto (required): - Future getFileChecksumsWithHttpInfo(FileChecksumDto fileChecksumDto,) async { - // ignore: prefer_const_declarations - final apiPath = r'/reports/checksum'; - - // ignore: prefer_final_locals - Object? postBody = fileChecksumDto; - - final queryParams = []; - final headerParams = {}; - final formParams = {}; - - const contentTypes = ['application/json']; - - - return apiClient.invokeAPI( - apiPath, - 'POST', - queryParams, - postBody, - headerParams, - formParams, - contentTypes.isEmpty ? null : contentTypes.first, - ); - } - - /// Parameters: - /// - /// * [FileChecksumDto] fileChecksumDto (required): - Future?> 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') as List) - .cast() - .toList(growable: false); - - } - return null; - } -} diff --git a/mobile/openapi/lib/api_client.dart b/mobile/openapi/lib/api_client.dart index 6abe576aca..ec5eb09729 100644 --- a/mobile/openapi/lib/api_client.dart +++ b/mobile/openapi/lib/api_client.dart @@ -320,16 +320,6 @@ class ApiClient { return FaceDto.fromJson(value); case 'FacialRecognitionConfig': return FacialRecognitionConfig.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 'FoldersResponse': return FoldersResponse.fromJson(value); case 'FoldersUpdate': @@ -378,8 +368,6 @@ class ApiClient { return MemoriesUpdate.fromJson(value); case 'MemoryCreateDto': return MemoryCreateDto.fromJson(value); - case 'MemoryLaneResponseDto': - return MemoryLaneResponseDto.fromJson(value); case 'MemoryResponseDto': return MemoryResponseDto.fromJson(value); case 'MemoryType': @@ -418,10 +406,6 @@ class ApiClient { return PartnerDirectionTypeTransformer().decode(value); case 'PartnerResponseDto': return PartnerResponseDto.fromJson(value); - case 'PathEntityType': - return PathEntityTypeTypeTransformer().decode(value); - case 'PathType': - return PathTypeTypeTransformer().decode(value); case 'PeopleResponse': return PeopleResponse.fromJson(value); case 'PeopleResponseDto': diff --git a/mobile/openapi/lib/api_helper.dart b/mobile/openapi/lib/api_helper.dart index 5f9d15c089..eec991e903 100644 --- a/mobile/openapi/lib/api_helper.dart +++ b/mobile/openapi/lib/api_helper.dart @@ -112,12 +112,6 @@ String parameterToString(dynamic value) { if (value is PartnerDirection) { return PartnerDirectionTypeTransformer().encode(value).toString(); } - if (value is PathEntityType) { - return PathEntityTypeTypeTransformer().encode(value).toString(); - } - if (value is PathType) { - return PathTypeTypeTransformer().encode(value).toString(); - } if (value is Permission) { return PermissionTypeTransformer().encode(value).toString(); } diff --git a/mobile/openapi/lib/model/file_checksum_dto.dart b/mobile/openapi/lib/model/file_checksum_dto.dart deleted file mode 100644 index 7dc9ccdf2f..0000000000 --- a/mobile/openapi/lib/model/file_checksum_dto.dart +++ /dev/null @@ -1,101 +0,0 @@ -// -// AUTO-GENERATED FILE, DO NOT MODIFY! -// -// @dart=2.18 - -// 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 filenames; - - @override - bool operator ==(Object other) => identical(this, other) || other is FileChecksumDto && - _deepEquality.equals(other.filenames, filenames); - - @override - int get hashCode => - // ignore: unnecessary_parenthesis - (filenames.hashCode); - - @override - String toString() => 'FileChecksumDto[filenames=$filenames]'; - - Map toJson() { - final json = {}; - 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) { - upgradeDto(value, "FileChecksumDto"); - if (value is Map) { - final json = value.cast(); - - return FileChecksumDto( - filenames: json[r'filenames'] is Iterable - ? (json[r'filenames'] as Iterable).cast().toList(growable: false) - : const [], - ); - } - return null; - } - - static List listFromJson(dynamic json, {bool growable = false,}) { - final result = []; - 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 mapFromJson(dynamic json) { - final map = {}; - if (json is Map && json.isNotEmpty) { - json = json.cast(); // 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> mapListFromJson(dynamic json, {bool growable = false,}) { - final map = >{}; - if (json is Map && json.isNotEmpty) { - // ignore: parameter_assignments - json = json.cast(); - 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 = { - 'filenames', - }; -} - diff --git a/mobile/openapi/lib/model/file_checksum_response_dto.dart b/mobile/openapi/lib/model/file_checksum_response_dto.dart deleted file mode 100644 index 7b963c8bd5..0000000000 --- a/mobile/openapi/lib/model/file_checksum_response_dto.dart +++ /dev/null @@ -1,107 +0,0 @@ -// -// AUTO-GENERATED FILE, DO NOT MODIFY! -// -// @dart=2.18 - -// 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 toJson() { - final json = {}; - 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) { - upgradeDto(value, "FileChecksumResponseDto"); - if (value is Map) { - final json = value.cast(); - - return FileChecksumResponseDto( - checksum: mapValueOfType(json, r'checksum')!, - filename: mapValueOfType(json, r'filename')!, - ); - } - return null; - } - - static List listFromJson(dynamic json, {bool growable = false,}) { - final result = []; - 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 mapFromJson(dynamic json) { - final map = {}; - if (json is Map && json.isNotEmpty) { - json = json.cast(); // 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> mapListFromJson(dynamic json, {bool growable = false,}) { - final map = >{}; - if (json is Map && json.isNotEmpty) { - // ignore: parameter_assignments - json = json.cast(); - 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 = { - 'checksum', - 'filename', - }; -} - diff --git a/mobile/openapi/lib/model/file_report_dto.dart b/mobile/openapi/lib/model/file_report_dto.dart deleted file mode 100644 index 3dc892e5e7..0000000000 --- a/mobile/openapi/lib/model/file_report_dto.dart +++ /dev/null @@ -1,109 +0,0 @@ -// -// AUTO-GENERATED FILE, DO NOT MODIFY! -// -// @dart=2.18 - -// 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 extras; - - List orphans; - - @override - bool operator ==(Object other) => identical(this, other) || other is FileReportDto && - _deepEquality.equals(other.extras, extras) && - _deepEquality.equals(other.orphans, orphans); - - @override - int get hashCode => - // ignore: unnecessary_parenthesis - (extras.hashCode) + - (orphans.hashCode); - - @override - String toString() => 'FileReportDto[extras=$extras, orphans=$orphans]'; - - Map toJson() { - final json = {}; - 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) { - upgradeDto(value, "FileReportDto"); - if (value is Map) { - final json = value.cast(); - - return FileReportDto( - extras: json[r'extras'] is Iterable - ? (json[r'extras'] as Iterable).cast().toList(growable: false) - : const [], - orphans: FileReportItemDto.listFromJson(json[r'orphans']), - ); - } - return null; - } - - static List listFromJson(dynamic json, {bool growable = false,}) { - final result = []; - 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 mapFromJson(dynamic json) { - final map = {}; - if (json is Map && json.isNotEmpty) { - json = json.cast(); // 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> mapListFromJson(dynamic json, {bool growable = false,}) { - final map = >{}; - if (json is Map && json.isNotEmpty) { - // ignore: parameter_assignments - json = json.cast(); - 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 = { - 'extras', - 'orphans', - }; -} - diff --git a/mobile/openapi/lib/model/file_report_fix_dto.dart b/mobile/openapi/lib/model/file_report_fix_dto.dart deleted file mode 100644 index d46cdeb4b7..0000000000 --- a/mobile/openapi/lib/model/file_report_fix_dto.dart +++ /dev/null @@ -1,99 +0,0 @@ -// -// AUTO-GENERATED FILE, DO NOT MODIFY! -// -// @dart=2.18 - -// 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 items; - - @override - bool operator ==(Object other) => identical(this, other) || other is FileReportFixDto && - _deepEquality.equals(other.items, items); - - @override - int get hashCode => - // ignore: unnecessary_parenthesis - (items.hashCode); - - @override - String toString() => 'FileReportFixDto[items=$items]'; - - Map toJson() { - final json = {}; - 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) { - upgradeDto(value, "FileReportFixDto"); - if (value is Map) { - final json = value.cast(); - - return FileReportFixDto( - items: FileReportItemDto.listFromJson(json[r'items']), - ); - } - return null; - } - - static List listFromJson(dynamic json, {bool growable = false,}) { - final result = []; - 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 mapFromJson(dynamic json) { - final map = {}; - if (json is Map && json.isNotEmpty) { - json = json.cast(); // 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> mapListFromJson(dynamic json, {bool growable = false,}) { - final map = >{}; - if (json is Map && json.isNotEmpty) { - // ignore: parameter_assignments - json = json.cast(); - 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 = { - 'items', - }; -} - diff --git a/mobile/openapi/lib/model/file_report_item_dto.dart b/mobile/openapi/lib/model/file_report_item_dto.dart deleted file mode 100644 index 1ef08c2b48..0000000000 --- a/mobile/openapi/lib/model/file_report_item_dto.dart +++ /dev/null @@ -1,140 +0,0 @@ -// -// AUTO-GENERATED FILE, DO NOT MODIFY! -// -// @dart=2.18 - -// 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 toJson() { - final json = {}; - 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) { - upgradeDto(value, "FileReportItemDto"); - if (value is Map) { - final json = value.cast(); - - return FileReportItemDto( - checksum: mapValueOfType(json, r'checksum'), - entityId: mapValueOfType(json, r'entityId')!, - entityType: PathEntityType.fromJson(json[r'entityType'])!, - pathType: PathType.fromJson(json[r'pathType'])!, - pathValue: mapValueOfType(json, r'pathValue')!, - ); - } - return null; - } - - static List listFromJson(dynamic json, {bool growable = false,}) { - final result = []; - 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 mapFromJson(dynamic json) { - final map = {}; - if (json is Map && json.isNotEmpty) { - json = json.cast(); // 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> mapListFromJson(dynamic json, {bool growable = false,}) { - final map = >{}; - if (json is Map && json.isNotEmpty) { - // ignore: parameter_assignments - json = json.cast(); - 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 = { - 'entityId', - 'entityType', - 'pathType', - 'pathValue', - }; -} - diff --git a/mobile/openapi/lib/model/memory_lane_response_dto.dart b/mobile/openapi/lib/model/memory_lane_response_dto.dart deleted file mode 100644 index 27248d05c1..0000000000 --- a/mobile/openapi/lib/model/memory_lane_response_dto.dart +++ /dev/null @@ -1,107 +0,0 @@ -// -// AUTO-GENERATED FILE, DO NOT MODIFY! -// -// @dart=2.18 - -// 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 MemoryLaneResponseDto { - /// Returns a new [MemoryLaneResponseDto] instance. - MemoryLaneResponseDto({ - this.assets = const [], - required this.yearsAgo, - }); - - List assets; - - int yearsAgo; - - @override - bool operator ==(Object other) => identical(this, other) || other is MemoryLaneResponseDto && - _deepEquality.equals(other.assets, assets) && - other.yearsAgo == yearsAgo; - - @override - int get hashCode => - // ignore: unnecessary_parenthesis - (assets.hashCode) + - (yearsAgo.hashCode); - - @override - String toString() => 'MemoryLaneResponseDto[assets=$assets, yearsAgo=$yearsAgo]'; - - Map toJson() { - final json = {}; - json[r'assets'] = this.assets; - json[r'yearsAgo'] = this.yearsAgo; - return json; - } - - /// Returns a new [MemoryLaneResponseDto] instance and imports its values from - /// [value] if it's a [Map], null otherwise. - // ignore: prefer_constructors_over_static_methods - static MemoryLaneResponseDto? fromJson(dynamic value) { - upgradeDto(value, "MemoryLaneResponseDto"); - if (value is Map) { - final json = value.cast(); - - return MemoryLaneResponseDto( - assets: AssetResponseDto.listFromJson(json[r'assets']), - yearsAgo: mapValueOfType(json, r'yearsAgo')!, - ); - } - return null; - } - - static List listFromJson(dynamic json, {bool growable = false,}) { - final result = []; - if (json is List && json.isNotEmpty) { - for (final row in json) { - final value = MemoryLaneResponseDto.fromJson(row); - if (value != null) { - result.add(value); - } - } - } - return result.toList(growable: growable); - } - - static Map mapFromJson(dynamic json) { - final map = {}; - if (json is Map && json.isNotEmpty) { - json = json.cast(); // ignore: parameter_assignments - for (final entry in json.entries) { - final value = MemoryLaneResponseDto.fromJson(entry.value); - if (value != null) { - map[entry.key] = value; - } - } - } - return map; - } - - // maps a json object with a list of MemoryLaneResponseDto-objects as value to a dart map - static Map> mapListFromJson(dynamic json, {bool growable = false,}) { - final map = >{}; - if (json is Map && json.isNotEmpty) { - // ignore: parameter_assignments - json = json.cast(); - for (final entry in json.entries) { - map[entry.key] = MemoryLaneResponseDto.listFromJson(entry.value, growable: growable,); - } - } - return map; - } - - /// The list of required keys that must be present in a JSON. - static const requiredKeys = { - 'assets', - 'yearsAgo', - }; -} - diff --git a/mobile/openapi/lib/model/path_entity_type.dart b/mobile/openapi/lib/model/path_entity_type.dart deleted file mode 100644 index fdcdae4f1b..0000000000 --- a/mobile/openapi/lib/model/path_entity_type.dart +++ /dev/null @@ -1,88 +0,0 @@ -// -// AUTO-GENERATED FILE, DO NOT MODIFY! -// -// @dart=2.18 - -// 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 = [ - asset, - person, - user, - ]; - - static PathEntityType? fromJson(dynamic value) => PathEntityTypeTypeTransformer().decode(value); - - static List listFromJson(dynamic json, {bool growable = false,}) { - final result = []; - 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; -} - diff --git a/mobile/openapi/lib/model/path_type.dart b/mobile/openapi/lib/model/path_type.dart deleted file mode 100644 index 55453ed1e8..0000000000 --- a/mobile/openapi/lib/model/path_type.dart +++ /dev/null @@ -1,103 +0,0 @@ -// -// AUTO-GENERATED FILE, DO NOT MODIFY! -// -// @dart=2.18 - -// 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 fullsize = PathType._(r'fullsize'); - static const preview = PathType._(r'preview'); - static const thumbnail = PathType._(r'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 = [ - original, - fullsize, - preview, - thumbnail, - encodedVideo, - sidecar, - face, - profile, - ]; - - static PathType? fromJson(dynamic value) => PathTypeTypeTransformer().decode(value); - - static List listFromJson(dynamic json, {bool growable = false,}) { - final result = []; - 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'fullsize': return PathType.fullsize; - case r'preview': return PathType.preview; - case r'thumbnail': return PathType.thumbnail; - 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; -} - diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index 826af5a2ec..0951177c72 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -1726,62 +1726,6 @@ ] } }, - "/assets/memory-lane": { - "get": { - "operationId": "getMemoryLane", - "parameters": [ - { - "name": "day", - "required": true, - "in": "query", - "schema": { - "minimum": 1, - "maximum": 31, - "type": "integer" - } - }, - { - "name": "month", - "required": true, - "in": "query", - "schema": { - "minimum": 1, - "maximum": 12, - "type": "integer" - } - } - ], - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "items": { - "$ref": "#/components/schemas/MemoryLaneResponseDto" - }, - "type": "array" - } - } - }, - "description": "" - } - }, - "security": [ - { - "bearer": [] - }, - { - "cookie": [] - }, - { - "api_key": [] - } - ], - "tags": [ - "Assets" - ] - } - }, "/assets/random": { "get": { "deprecated": true, @@ -4651,118 +4595,6 @@ ] } }, - "/reports": { - "get": { - "operationId": "getAuditFiles", - "parameters": [], - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/FileReportDto" - } - } - }, - "description": "" - } - }, - "security": [ - { - "bearer": [] - }, - { - "cookie": [] - }, - { - "api_key": [] - } - ], - "tags": [ - "File Reports" - ] - } - }, - "/reports/checksum": { - "post": { - "operationId": "getFileChecksums", - "parameters": [], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/FileChecksumDto" - } - } - }, - "required": true - }, - "responses": { - "201": { - "content": { - "application/json": { - "schema": { - "items": { - "$ref": "#/components/schemas/FileChecksumResponseDto" - }, - "type": "array" - } - } - }, - "description": "" - } - }, - "security": [ - { - "bearer": [] - }, - { - "cookie": [] - }, - { - "api_key": [] - } - ], - "tags": [ - "File Reports" - ] - } - }, - "/reports/fix": { - "post": { - "operationId": "fixAuditFiles", - "parameters": [], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/FileReportFixDto" - } - } - }, - "required": true - }, - "responses": { - "201": { - "description": "" - } - }, - "security": [ - { - "bearer": [] - }, - { - "cookie": [] - }, - { - "api_key": [] - } - ], - "tags": [ - "File Reports" - ] - } - }, "/search/cities": { "get": { "operationId": "getAssetsByCity", @@ -9749,105 +9581,6 @@ ], "type": "object" }, - "FileChecksumDto": { - "properties": { - "filenames": { - "items": { - "type": "string" - }, - "type": "array" - } - }, - "required": [ - "filenames" - ], - "type": "object" - }, - "FileChecksumResponseDto": { - "properties": { - "checksum": { - "type": "string" - }, - "filename": { - "type": "string" - } - }, - "required": [ - "checksum", - "filename" - ], - "type": "object" - }, - "FileReportDto": { - "properties": { - "extras": { - "items": { - "type": "string" - }, - "type": "array" - }, - "orphans": { - "items": { - "$ref": "#/components/schemas/FileReportItemDto" - }, - "type": "array" - } - }, - "required": [ - "extras", - "orphans" - ], - "type": "object" - }, - "FileReportFixDto": { - "properties": { - "items": { - "items": { - "$ref": "#/components/schemas/FileReportItemDto" - }, - "type": "array" - } - }, - "required": [ - "items" - ], - "type": "object" - }, - "FileReportItemDto": { - "properties": { - "checksum": { - "type": "string" - }, - "entityId": { - "format": "uuid", - "type": "string" - }, - "entityType": { - "allOf": [ - { - "$ref": "#/components/schemas/PathEntityType" - } - ] - }, - "pathType": { - "allOf": [ - { - "$ref": "#/components/schemas/PathType" - } - ] - }, - "pathValue": { - "type": "string" - } - }, - "required": [ - "entityId", - "entityType", - "pathType", - "pathValue" - ], - "type": "object" - }, "FoldersResponse": { "properties": { "enabled": { @@ -10328,24 +10061,6 @@ ], "type": "object" }, - "MemoryLaneResponseDto": { - "properties": { - "assets": { - "items": { - "$ref": "#/components/schemas/AssetResponseDto" - }, - "type": "array" - }, - "yearsAgo": { - "type": "integer" - } - }, - "required": [ - "assets", - "yearsAgo" - ], - "type": "object" - }, "MemoryResponseDto": { "properties": { "assets": { @@ -10889,27 +10604,6 @@ ], "type": "object" }, - "PathEntityType": { - "enum": [ - "asset", - "person", - "user" - ], - "type": "string" - }, - "PathType": { - "enum": [ - "original", - "fullsize", - "preview", - "thumbnail", - "encoded_video", - "sidecar", - "face", - "profile" - ], - "type": "string" - }, "PeopleResponse": { "properties": { "enabled": { diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index 743eeadf03..20fb72b486 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -462,10 +462,6 @@ export type AssetJobsDto = { assetIds: string[]; name: AssetJobName; }; -export type MemoryLaneResponseDto = { - assets: AssetResponseDto[]; - yearsAgo: number; -}; export type AssetStatsResponseDto = { images: number; total: number; @@ -800,27 +796,6 @@ export type AssetFaceUpdateDto = { export type PersonStatisticsResponseDto = { assets: number; }; -export type FileReportItemDto = { - checksum?: string; - entityId: string; - entityType: PathEntityType; - pathType: PathType; - pathValue: string; -}; -export type FileReportDto = { - extras: string[]; - orphans: FileReportItemDto[]; -}; -export type FileChecksumDto = { - filenames: string[]; -}; -export type FileChecksumResponseDto = { - checksum: string; - filename: string; -}; -export type FileReportFixDto = { - items: FileReportItemDto[]; -}; export type SearchExploreItem = { data: AssetResponseDto; value: string; @@ -1887,20 +1862,6 @@ export function runAssetJobs({ assetJobsDto }: { body: assetJobsDto }))); } -export function getMemoryLane({ day, month }: { - day: number; - month: number; -}, opts?: Oazapfts.RequestOpts) { - return oazapfts.ok(oazapfts.fetchJson<{ - status: 200; - data: MemoryLaneResponseDto[]; - }>(`/assets/memory-lane${QS.query(QS.explode({ - day, - month - }))}`, { - ...opts - })); -} /** * This property was deprecated in v1.116.0 */ @@ -2663,35 +2624,6 @@ export function getPersonThumbnail({ id }: { ...opts })); } -export function getAuditFiles(opts?: Oazapfts.RequestOpts) { - return oazapfts.ok(oazapfts.fetchJson<{ - status: 200; - data: FileReportDto; - }>("/reports", { - ...opts - })); -} -export function getFileChecksums({ fileChecksumDto }: { - fileChecksumDto: FileChecksumDto; -}, opts?: Oazapfts.RequestOpts) { - return oazapfts.ok(oazapfts.fetchJson<{ - status: 201; - data: FileChecksumResponseDto[]; - }>("/reports/checksum", oazapfts.json({ - ...opts, - method: "POST", - body: fileChecksumDto - }))); -} -export function fixAuditFiles({ fileReportFixDto }: { - fileReportFixDto: FileReportFixDto; -}, opts?: Oazapfts.RequestOpts) { - return oazapfts.ok(oazapfts.fetchText("/reports/fix", oazapfts.json({ - ...opts, - method: "POST", - body: fileReportFixDto - }))); -} export function getAssetsByCity(opts?: Oazapfts.RequestOpts) { return oazapfts.ok(oazapfts.fetchJson<{ status: 200; @@ -3751,21 +3683,6 @@ export enum PartnerDirection { SharedBy = "shared-by", SharedWith = "shared-with" } -export enum PathEntityType { - Asset = "asset", - Person = "person", - User = "user" -} -export enum PathType { - Original = "original", - Fullsize = "fullsize", - Preview = "preview", - Thumbnail = "thumbnail", - EncodedVideo = "encoded_video", - Sidecar = "sidecar", - Face = "face", - Profile = "profile" -} export enum SearchSuggestionType { Country = "country", State = "state", diff --git a/server/package-lock.json b/server/package-lock.json index 4e451cc8e1..06152e9335 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -28,7 +28,7 @@ "archiver": "^7.0.0", "async-lock": "^1.4.0", "bcrypt": "^5.1.1", - "bullmq": "^4.8.0", + "bullmq": "^5.51.0", "chokidar": "^3.5.3", "class-transformer": "^0.5.1", "class-validator": "^0.14.0", @@ -6886,63 +6886,20 @@ } }, "node_modules/bullmq": { - "version": "4.18.2", - "resolved": "https://registry.npmjs.org/bullmq/-/bullmq-4.18.2.tgz", - "integrity": "sha512-Cx0O98IlGiFw7UBa+zwGz+nH0Pcl1wfTvMVBlsMna3s0219hXroVovh1xPRgomyUcbyciHiugGCkW0RRNZDHYQ==", + "version": "5.51.0", + "resolved": "https://registry.npmjs.org/bullmq/-/bullmq-5.51.0.tgz", + "integrity": "sha512-YjX+CO2U4nmbCq2ZgNb/Hnu6Xk953j8EFmp0eehTuudavPyNstoZsbnyvvM6PX9rfD9clhcc5kRLyyWoFEM3Lg==", "license": "MIT", "dependencies": { - "cron-parser": "^4.6.0", - "glob": "^8.0.3", - "ioredis": "^5.3.2", - "lodash": "^4.17.21", - "msgpackr": "^1.6.2", + "cron-parser": "^4.9.0", + "ioredis": "^5.4.1", + "msgpackr": "^1.11.2", "node-abort-controller": "^3.1.1", "semver": "^7.5.4", "tslib": "^2.0.0", "uuid": "^9.0.0" } }, - "node_modules/bullmq/node_modules/brace-expansion": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", - "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0" - } - }, - "node_modules/bullmq/node_modules/glob": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/glob/-/glob-8.1.0.tgz", - "integrity": "sha512-r8hpEjiQEYlF2QU0df3dS+nxxSIreXQS1qRhMJM0Q5NDdR386C7jb7Hwwod8Fgiuex+k0GFjgft18yvxm5XoCQ==", - "deprecated": "Glob versions prior to v9 are no longer supported", - "license": "ISC", - "dependencies": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^5.0.1", - "once": "^1.3.0" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/bullmq/node_modules/minimatch": { - "version": "5.1.6", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz", - "integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==", - "license": "ISC", - "dependencies": { - "brace-expansion": "^2.0.1" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/busboy": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz", diff --git a/server/package.json b/server/package.json index 33d1450a53..b4a8841156 100644 --- a/server/package.json +++ b/server/package.json @@ -53,7 +53,7 @@ "archiver": "^7.0.0", "async-lock": "^1.4.0", "bcrypt": "^5.1.1", - "bullmq": "^4.8.0", + "bullmq": "^5.51.0", "chokidar": "^3.5.3", "class-transformer": "^0.5.1", "class-validator": "^0.14.0", diff --git a/server/src/controllers/asset.controller.ts b/server/src/controllers/asset.controller.ts index 9a7252a087..925b64c8a8 100644 --- a/server/src/controllers/asset.controller.ts +++ b/server/src/controllers/asset.controller.ts @@ -1,7 +1,7 @@ import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Param, Post, Put, Query } from '@nestjs/common'; import { ApiOperation, ApiTags } from '@nestjs/swagger'; import { EndpointLifecycle } from 'src/decorators'; -import { AssetResponseDto, MemoryLaneResponseDto } from 'src/dtos/asset-response.dto'; +import { AssetResponseDto } from 'src/dtos/asset-response.dto'; import { AssetBulkDeleteDto, AssetBulkUpdateDto, @@ -13,7 +13,6 @@ import { UpdateAssetDto, } from 'src/dtos/asset.dto'; import { AuthDto } from 'src/dtos/auth.dto'; -import { MemoryLaneDto } from 'src/dtos/search.dto'; import { RouteKey } from 'src/enum'; import { Auth, Authenticated } from 'src/middleware/auth.guard'; import { AssetService } from 'src/services/asset.service'; @@ -24,12 +23,6 @@ import { UUIDParamDto } from 'src/validation'; export class AssetController { constructor(private service: AssetService) {} - @Get('memory-lane') - @Authenticated() - getMemoryLane(@Auth() auth: AuthDto, @Query() dto: MemoryLaneDto): Promise { - return this.service.getMemoryLane(auth, dto); - } - @Get('random') @Authenticated() @EndpointLifecycle({ deprecatedAt: 'v1.116.0' }) diff --git a/server/src/controllers/file-report.controller.ts b/server/src/controllers/file-report.controller.ts deleted file mode 100644 index a51a94a50e..0000000000 --- a/server/src/controllers/file-report.controller.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { Body, Controller, Get, Post } from '@nestjs/common'; -import { ApiTags } from '@nestjs/swagger'; -import { FileChecksumDto, FileChecksumResponseDto, FileReportDto, FileReportFixDto } from 'src/dtos/audit.dto'; -import { Authenticated } from 'src/middleware/auth.guard'; -import { AuditService } from 'src/services/audit.service'; - -@ApiTags('File Reports') -@Controller('reports') -export class ReportController { - constructor(private service: AuditService) {} - - @Get() - @Authenticated({ admin: true }) - getAuditFiles(): Promise { - return this.service.getFileReport(); - } - - @Post('checksum') - @Authenticated({ admin: true }) - getFileChecksums(@Body() dto: FileChecksumDto): Promise { - return this.service.getChecksums(dto); - } - - @Post('fix') - @Authenticated({ admin: true }) - fixAuditFiles(@Body() dto: FileReportFixDto): Promise { - return this.service.fixItems(dto.items); - } -} diff --git a/server/src/controllers/index.ts b/server/src/controllers/index.ts index e36793b3d7..9c39e580b6 100644 --- a/server/src/controllers/index.ts +++ b/server/src/controllers/index.ts @@ -8,7 +8,6 @@ import { AuthController } from 'src/controllers/auth.controller'; import { DownloadController } from 'src/controllers/download.controller'; import { DuplicateController } from 'src/controllers/duplicate.controller'; import { FaceController } from 'src/controllers/face.controller'; -import { ReportController } from 'src/controllers/file-report.controller'; import { JobController } from 'src/controllers/job.controller'; import { LibraryController } from 'src/controllers/library.controller'; import { MapController } from 'src/controllers/map.controller'; @@ -53,7 +52,6 @@ export const controllers = [ OAuthController, PartnerController, PersonController, - ReportController, SearchController, ServerController, SessionController, diff --git a/server/src/controllers/search.controller.ts b/server/src/controllers/search.controller.ts index 367c39dae9..c51ad8e06a 100644 --- a/server/src/controllers/search.controller.ts +++ b/server/src/controllers/search.controller.ts @@ -46,7 +46,7 @@ export class SearchController { @Get('explore') @Authenticated() getExploreData(@Auth() auth: AuthDto): Promise { - return this.service.getExploreData(auth) as Promise; + return this.service.getExploreData(auth); } @Get('person') diff --git a/server/src/dtos/asset-response.dto.ts b/server/src/dtos/asset-response.dto.ts index c0e589f380..3732e665cd 100644 --- a/server/src/dtos/asset-response.dto.ts +++ b/server/src/dtos/asset-response.dto.ts @@ -199,10 +199,3 @@ export function mapAsset(entity: MapAsset, options: AssetMapOptions = {}): Asset resized: true, }; } - -export class MemoryLaneResponseDto { - @ApiProperty({ type: 'integer' }) - yearsAgo!: number; - - assets!: AssetResponseDto[]; -} diff --git a/server/src/dtos/audit.dto.ts b/server/src/dtos/audit.dto.ts deleted file mode 100644 index 434da46eba..0000000000 --- a/server/src/dtos/audit.dto.ts +++ /dev/null @@ -1,73 +0,0 @@ -import { ApiProperty } from '@nestjs/swagger'; -import { Type } from 'class-transformer'; -import { IsArray, IsEnum, IsString, IsUUID, ValidateNested } from 'class-validator'; -import { AssetPathType, EntityType, PathType, PersonPathType, UserPathType } from 'src/enum'; -import { Optional, ValidateDate, ValidateUUID } from 'src/validation'; - -const PathEnum = Object.values({ ...AssetPathType, ...PersonPathType, ...UserPathType }); - -export class AuditDeletesDto { - @ValidateDate() - after!: Date; - - @ApiProperty({ enum: EntityType, enumName: 'EntityType' }) - @IsEnum(EntityType) - entityType!: EntityType; - - @Optional() - @IsUUID('4') - @ApiProperty({ format: 'uuid' }) - userId?: string; -} - -export enum PathEntityType { - ASSET = 'asset', - PERSON = 'person', - USER = 'user', -} - -export class AuditDeletesResponseDto { - needsFullSync!: boolean; - ids!: string[]; -} - -export class FileReportDto { - orphans!: FileReportItemDto[]; - extras!: string[]; -} - -export class FileChecksumDto { - @IsString({ each: true }) - filenames!: string[]; -} - -export class FileChecksumResponseDto { - filename!: string; - checksum!: string; -} - -export class FileReportFixDto { - @IsArray() - @ValidateNested({ each: true }) - @Type(() => FileReportItemDto) - items!: FileReportItemDto[]; -} - -// used both as request and response dto -export class FileReportItemDto { - @ValidateUUID() - entityId!: string; - - @ApiProperty({ enumName: 'PathEntityType', enum: PathEntityType }) - @IsEnum(PathEntityType) - entityType!: PathEntityType; - - @ApiProperty({ enumName: 'PathType', enum: PathEnum }) - @IsEnum(PathEnum) - pathType!: PathType; - - @IsString() - pathValue!: string; - - checksum?: string; -} diff --git a/server/src/queries/asset.job.repository.sql b/server/src/queries/asset.job.repository.sql index 67a3b8021d..d8e8430be7 100644 --- a/server/src/queries/asset.job.repository.sql +++ b/server/src/queries/asset.job.repository.sql @@ -194,15 +194,16 @@ where "asset_files"."assetId" = $1 and "asset_files"."type" = $2 --- AssetJobRepository.streamForEncodeClip +-- AssetJobRepository.streamForSearchDuplicates select "assets"."id" from "assets" inner join "asset_job_status" as "job_status" on "assetId" = "assets"."id" where - "job_status"."previewAt" is not null - and "assets"."isVisible" = $1 + "assets"."isVisible" = $1 + and "assets"."deletedAt" is null + and "job_status"."previewAt" is not null and not exists ( select from @@ -210,7 +211,25 @@ where where "assetId" = "assets"."id" ) + and "job_status"."duplicatesDetectedAt" is null + +-- AssetJobRepository.streamForEncodeClip +select + "assets"."id" +from + "assets" + inner join "asset_job_status" as "job_status" on "assetId" = "assets"."id" +where + "assets"."isVisible" = $1 and "assets"."deletedAt" is null + and "job_status"."previewAt" is not null + and not exists ( + select + from + "smart_search" + where + "assetId" = "assets"."id" + ) -- AssetJobRepository.getForClipEncoding select @@ -450,3 +469,37 @@ from "assets" where "assets"."deletedAt" <= $1 + +-- AssetJobRepository.streamForSidecar +select + "assets"."id" +from + "assets" +where + ( + "assets"."sidecarPath" = $1 + or "assets"."sidecarPath" is null + ) + and "assets"."isVisible" = $2 + +-- AssetJobRepository.streamForDetectFacesJob +select + "assets"."id" +from + "assets" + inner join "asset_job_status" as "job_status" on "assetId" = "assets"."id" +where + "assets"."isVisible" = $1 + and "assets"."deletedAt" is null + and "job_status"."previewAt" is not null + and "job_status"."facesRecognizedAt" is null +order by + "assets"."createdAt" desc + +-- AssetJobRepository.streamForMigrationJob +select + "id" +from + "assets" +where + "assets"."deletedAt" is null diff --git a/server/src/queries/asset.repository.sql b/server/src/queries/asset.repository.sql index a3dcb08c1e..cb438e1c6d 100644 --- a/server/src/queries/asset.repository.sql +++ b/server/src/queries/asset.repository.sql @@ -232,25 +232,6 @@ where limit $3 --- AssetRepository.getWithout (sidecar) -select - "assets".* -from - "assets" -where - ( - "assets"."sidecarPath" = $1 - or "assets"."sidecarPath" is null - ) - and "assets"."isVisible" = $2 - and "deletedAt" is null -order by - "createdAt" -limit - $3 -offset - $4 - -- AssetRepository.getTimeBuckets with "assets" as ( diff --git a/server/src/repositories/asset-job.repository.ts b/server/src/repositories/asset-job.repository.ts index 262cf6217c..1506f2997f 100644 --- a/server/src/repositories/asset-job.repository.ts +++ b/server/src/repositories/asset-job.repository.ts @@ -135,20 +135,33 @@ export class AssetJobRepository { .execute(); } - @GenerateSql({ params: [], stream: true }) - streamForEncodeClip(force?: boolean) { + private assetsWithPreviews() { return this.db .selectFrom('assets') - .select(['assets.id']) - .innerJoin('asset_job_status as job_status', 'assetId', 'assets.id') - .where('job_status.previewAt', 'is not', null) .where('assets.isVisible', '=', true) + .where('assets.deletedAt', 'is', null) + .innerJoin('asset_job_status as job_status', 'assetId', 'assets.id') + .where('job_status.previewAt', 'is not', null); + } + + @GenerateSql({ params: [], stream: true }) + streamForSearchDuplicates(force?: boolean) { + return this.assetsWithPreviews() + .where((eb) => eb.not((eb) => eb.exists(eb.selectFrom('smart_search').whereRef('assetId', '=', 'assets.id')))) + .$if(!force, (qb) => qb.where('job_status.duplicatesDetectedAt', 'is', null)) + .select(['assets.id']) + .stream(); + } + + @GenerateSql({ params: [], stream: true }) + streamForEncodeClip(force?: boolean) { + return this.assetsWithPreviews() + .select(['assets.id']) .$if(!force, (qb) => qb.where((eb) => eb.not((eb) => eb.exists(eb.selectFrom('smart_search').whereRef('assetId', '=', 'assets.id'))), ), ) - .where('assets.deletedAt', 'is', null) .stream(); } @@ -309,4 +322,30 @@ export class AssetJobRepository { .where('assets.deletedAt', '<=', trashedBefore) .stream(); } + + @GenerateSql({ params: [], stream: true }) + streamForSidecar(force?: boolean) { + return this.db + .selectFrom('assets') + .select(['assets.id']) + .$if(!force, (qb) => + qb.where((eb) => eb.or([eb('assets.sidecarPath', '=', ''), eb('assets.sidecarPath', 'is', null)])), + ) + .where('assets.isVisible', '=', true) + .stream(); + } + + @GenerateSql({ params: [], stream: true }) + streamForDetectFacesJob(force?: boolean) { + return this.assetsWithPreviews() + .$if(!force, (qb) => qb.where('job_status.facesRecognizedAt', 'is', null)) + .select(['assets.id']) + .orderBy('assets.createdAt', 'desc') + .stream(); + } + + @GenerateSql({ params: [DummyValue.DATE], stream: true }) + streamForMigrationJob() { + return this.db.selectFrom('assets').select(['id']).where('assets.deletedAt', 'is', null).stream(); + } } diff --git a/server/src/repositories/asset.repository.ts b/server/src/repositories/asset.repository.ts index ab7d9c80ea..89062c210a 100644 --- a/server/src/repositories/asset.repository.ts +++ b/server/src/repositories/asset.repository.ts @@ -7,13 +7,11 @@ import { AssetFiles, AssetJobStatus, Assets, DB, Exif } from 'src/db'; import { Chunked, ChunkedArray, DummyValue, GenerateSql } from 'src/decorators'; import { MapAsset } from 'src/dtos/asset-response.dto'; import { AssetFileType, AssetOrder, AssetStatus, AssetType } from 'src/enum'; -import { AssetSearchOptions, SearchExploreItem, SearchExploreItemSet } from 'src/repositories/search.repository'; import { anyUuid, asUuid, hasPeople, removeUndefinedKeys, - searchAssetBuilder, truncatedDate, unnest, withExif, @@ -27,7 +25,6 @@ import { withTags, } from 'src/utils/database'; import { globToSqlPattern } from 'src/utils/misc'; -import { PaginationOptions, paginationHelper } from 'src/utils/pagination'; export type AssetStats = Record; @@ -45,15 +42,6 @@ export interface LivePhotoSearchOptions { type: AssetType; } -export enum WithoutProperty { - THUMBNAIL = 'thumbnail', - ENCODED_VIDEO = 'encoded-video', - EXIF = 'exif', - DUPLICATE = 'duplicate', - FACES = 'faces', - SIDECAR = 'sidecar', -} - export enum WithProperty { SIDECAR = 'sidecar', } @@ -335,10 +323,6 @@ export class AssetRepository { return assets.map((asset) => asset.deviceAssetId); } - getByUserId(pagination: PaginationOptions, userId: string, options: Omit = {}) { - return this.getAll(pagination, { ...options, userIds: [userId] }); - } - @GenerateSql({ params: [DummyValue.UUID, DummyValue.STRING] }) getByLibraryIdAndOriginalPath(libraryId: string, originalPath: string) { return this.db @@ -350,16 +334,6 @@ export class AssetRepository { .executeTakeFirst(); } - async getAll(pagination: PaginationOptions, { orderDirection, ...options }: AssetSearchOptions = {}) { - const builder = searchAssetBuilder(this.db, options) - .select(withFiles) - .orderBy('assets.createdAt', orderDirection ?? 'asc') - .limit(pagination.take + 1) - .offset(pagination.skip ?? 0); - const items = await builder.execute(); - return paginationHelper(items, pagination.take); - } - /** * Get assets by device's Id on the database * @param ownerId @@ -529,68 +503,6 @@ export class AssetRepository { .executeTakeFirst(); } - @GenerateSql( - ...Object.values(WithProperty).map((property) => ({ - name: property, - params: [DummyValue.PAGINATION, property], - })), - ) - async getWithout(pagination: PaginationOptions, property: WithoutProperty) { - const items = await this.db - .selectFrom('assets') - .selectAll('assets') - .$if(property === WithoutProperty.DUPLICATE, (qb) => - qb - .innerJoin('asset_job_status as job_status', 'assets.id', 'job_status.assetId') - .where('job_status.duplicatesDetectedAt', 'is', null) - .where('job_status.previewAt', 'is not', null) - .where((eb) => eb.exists(eb.selectFrom('smart_search').where('assetId', '=', eb.ref('assets.id')))) - .where('assets.isVisible', '=', true), - ) - .$if(property === WithoutProperty.ENCODED_VIDEO, (qb) => - qb - .where('assets.type', '=', AssetType.VIDEO) - .where((eb) => eb.or([eb('assets.encodedVideoPath', 'is', null), eb('assets.encodedVideoPath', '=', '')])), - ) - .$if(property === WithoutProperty.EXIF, (qb) => - qb - .leftJoin('asset_job_status as job_status', 'assets.id', 'job_status.assetId') - .where((eb) => eb.or([eb('job_status.metadataExtractedAt', 'is', null), eb('assetId', 'is', null)])) - .where('assets.isVisible', '=', true), - ) - .$if(property === WithoutProperty.FACES, (qb) => - qb - .innerJoin('asset_job_status as job_status', 'assetId', 'assets.id') - .where('job_status.previewAt', 'is not', null) - .where('job_status.facesRecognizedAt', 'is', null) - .where('assets.isVisible', '=', true), - ) - .$if(property === WithoutProperty.SIDECAR, (qb) => - qb - .where((eb) => eb.or([eb('assets.sidecarPath', '=', ''), eb('assets.sidecarPath', 'is', null)])) - .where('assets.isVisible', '=', true), - ) - .$if(property === WithoutProperty.THUMBNAIL, (qb) => - qb - .innerJoin('asset_job_status as job_status', 'assetId', 'assets.id') - .where('assets.isVisible', '=', true) - .where((eb) => - eb.or([ - eb('job_status.previewAt', 'is', null), - eb('job_status.thumbnailAt', 'is', null), - eb('assets.thumbhash', 'is', null), - ]), - ), - ) - .where('deletedAt', 'is', null) - .limit(pagination.take + 1) - .offset(pagination.skip ?? 0) - .orderBy('createdAt') - .execute(); - - return paginationHelper(items, pagination.take); - } - getStatistics(ownerId: string, { isArchived, isFavorite, isTrashed }: AssetStatsOptions): Promise { return this.db .selectFrom('assets') @@ -774,10 +686,7 @@ export class AssetRepository { } @GenerateSql({ params: [DummyValue.UUID, { minAssetsPerField: 5, maxFields: 12 }] }) - async getAssetIdByCity( - ownerId: string, - { minAssetsPerField, maxFields }: AssetExploreFieldOptions, - ): Promise> { + async getAssetIdByCity(ownerId: string, { minAssetsPerField, maxFields }: AssetExploreFieldOptions) { const items = await this.db .with('cities', (qb) => qb @@ -792,6 +701,7 @@ export class AssetRepository { .innerJoin('cities', 'exif.city', 'cities.city') .distinctOn('exif.city') .select(['assetId as data', 'exif.city as value']) + .$narrowType<{ value: NotNull }>() .where('ownerId', '=', asUuid(ownerId)) .where('isVisible', '=', true) .where('isArchived', '=', false) @@ -800,7 +710,7 @@ export class AssetRepository { .limit(maxFields) .execute(); - return { fieldName: 'exifInfo.city', items: items as SearchExploreItemSet }; + return { fieldName: 'exifInfo.city', items }; } @GenerateSql({ diff --git a/server/src/repositories/event.repository.ts b/server/src/repositories/event.repository.ts index b41c007ef5..307b8b0ef4 100644 --- a/server/src/repositories/event.repository.ts +++ b/server/src/repositories/event.repository.ts @@ -19,7 +19,7 @@ import { ReleaseNotification, ServerVersionResponseDto } from 'src/dtos/server.d import { ImmichWorker, MetadataKey, QueueName } from 'src/enum'; import { ConfigRepository } from 'src/repositories/config.repository'; import { LoggingRepository } from 'src/repositories/logging.repository'; -import { JobItem } from 'src/types'; +import { JobItem, JobSource } from 'src/types'; import { handlePromiseError } from 'src/utils/misc'; type EmitHandlers = Partial<{ [T in EmitEvent]: Array> }>; @@ -48,7 +48,7 @@ type EventMap = { 'config.validate': [{ newConfig: SystemConfig; oldConfig: SystemConfig }]; // album events - 'album.update': [{ id: string; recipientIds: string[] }]; + 'album.update': [{ id: string; recipientId: string }]; 'album.invite': [{ id: string; userId: string }]; // asset events @@ -58,6 +58,7 @@ type EventMap = { 'asset.show': [{ assetId: string; userId: string }]; 'asset.trash': [{ assetId: string; userId: string }]; 'asset.delete': [{ assetId: string; userId: string }]; + 'asset.metadataExtracted': [{ assetId: string; userId: string; source?: JobSource }]; // asset bulk events 'assets.trash': [{ assetIds: string[]; userId: string }]; diff --git a/server/src/repositories/job.repository.ts b/server/src/repositories/job.repository.ts index 0912759d1c..32a4f75d67 100644 --- a/server/src/repositories/job.repository.ts +++ b/server/src/repositories/job.repository.ts @@ -9,7 +9,7 @@ import { JobName, JobStatus, MetadataKey, QueueCleanType, QueueName } from 'src/ import { ConfigRepository } from 'src/repositories/config.repository'; import { EventRepository } from 'src/repositories/event.repository'; import { LoggingRepository } from 'src/repositories/logging.repository'; -import { IEntityJob, JobCounts, JobItem, JobOf, QueueStatus } from 'src/types'; +import { JobCounts, JobItem, JobOf, QueueStatus } from 'src/types'; import { getKeyByValue, getMethodNames, ImmichStartupError } from 'src/utils/misc'; type JobMapItem = { @@ -206,7 +206,10 @@ export class JobRepository { private getJobOptions(item: JobItem): JobsOptions | null { switch (item.name) { case JobName.NOTIFY_ALBUM_UPDATE: { - return { jobId: item.data.id, delay: item.data?.delay }; + return { + jobId: `${item.data.id}/${item.data.recipientId}`, + delay: item.data?.delay, + }; } case JobName.STORAGE_TEMPLATE_MIGRATION_SINGLE: { return { jobId: item.data.id }; @@ -227,19 +230,12 @@ export class JobRepository { return this.moduleRef.get(getQueueToken(queue), { strict: false }); } - public async removeJob(jobId: string, name: JobName): Promise { - const existingJob = await this.getQueue(this.getQueueName(name)).getJob(jobId); - if (!existingJob) { - return; - } - try { + /** @deprecated */ + // todo: remove this when asset notifications no longer need it. + public async removeJob(name: JobName, jobID: string): Promise { + const existingJob = await this.getQueue(this.getQueueName(name)).getJob(jobID); + if (existingJob) { await existingJob.remove(); - } catch (error: any) { - if (error.message?.includes('Missing key for job')) { - return; - } - throw error; } - return existingJob.data; } } diff --git a/server/src/repositories/person.repository.ts b/server/src/repositories/person.repository.ts index d55d863ea7..0383a54a27 100644 --- a/server/src/repositories/person.repository.ts +++ b/server/src/repositories/person.repository.ts @@ -6,7 +6,7 @@ import { AssetFaces, DB, FaceSearch, Person } from 'src/db'; import { ChunkedArray, DummyValue, GenerateSql } from 'src/decorators'; import { AssetFileType, SourceType } from 'src/enum'; import { removeUndefinedKeys } from 'src/utils/database'; -import { PaginationOptions } from 'src/utils/pagination'; +import { paginationHelper, PaginationOptions } from 'src/utils/pagination'; export interface PersonSearchOptions { minimumFaceCount: number; @@ -200,11 +200,7 @@ export class PersonRepository { .limit(pagination.take + 1) .execute(); - if (items.length > pagination.take) { - return { items: items.slice(0, -1), hasNextPage: true }; - } - - return { items, hasNextPage: false }; + return paginationHelper(items, pagination.take); } @GenerateSql() diff --git a/server/src/repositories/search.repository.ts b/server/src/repositories/search.repository.ts index 0c958fec02..b991ecc78b 100644 --- a/server/src/repositories/search.repository.ts +++ b/server/src/repositories/search.repository.ts @@ -8,41 +8,10 @@ import { MapAsset } from 'src/dtos/asset-response.dto'; import { AssetStatus, AssetType } from 'src/enum'; import { ConfigRepository } from 'src/repositories/config.repository'; import { anyUuid, asUuid, searchAssetBuilder, vectorIndexQuery } from 'src/utils/database'; +import { paginationHelper } from 'src/utils/pagination'; import { isValidInteger } from 'src/validation'; -export interface SearchResult { - /** total matches */ - total: number; - /** collection size */ - count: number; - /** current page */ - page: number; - /** items for page */ - items: T[]; - /** score */ - distances: number[]; - facets: SearchFacet[]; -} - -export interface SearchFacet { - fieldName: string; - counts: Array<{ - count: number; - value: string; - }>; -} - -export type SearchExploreItemSet = Array<{ - value: string; - data: T; -}>; - -export interface SearchExploreItem { - fieldName: string; - items: SearchExploreItemSet; -} - -export interface SearchAssetIDOptions { +export interface SearchAssetIdOptions { checksum?: Buffer; deviceAssetId?: string; id?: string; @@ -54,7 +23,7 @@ export interface SearchUserIdOptions { userIds?: string[]; } -export type SearchIdOptions = SearchAssetIDOptions & SearchUserIdOptions; +export type SearchIdOptions = SearchAssetIdOptions & SearchUserIdOptions; export interface SearchStatusOptions { isArchived?: boolean; @@ -144,8 +113,6 @@ type BaseAssetSearchOptions = SearchDateOptions & export type AssetSearchOptions = BaseAssetSearchOptions & SearchRelationOptions; -export type AssetSearchOneToOneRelationOptions = BaseAssetSearchOptions & SearchOneToOneRelationOptions; - export type AssetSearchBuilderOptions = Omit; export type SmartSearchOptions = SearchDateOptions & @@ -226,9 +193,8 @@ export class SearchRepository { .limit(pagination.size + 1) .offset((pagination.page - 1) * pagination.size) .execute(); - const hasNextPage = items.length > pagination.size; - items.splice(pagination.size); - return { items, hasNextPage }; + + return paginationHelper(items, pagination.size); } @GenerateSql({ @@ -283,9 +249,7 @@ export class SearchRepository { .offset((pagination.page - 1) * pagination.size) .execute(); - const hasNextPage = items.length > pagination.size; - items.splice(pagination.size); - return { items, hasNextPage }; + return paginationHelper(items, pagination.size); } @GenerateSql({ diff --git a/server/src/services/album.service.spec.ts b/server/src/services/album.service.spec.ts index a0fbfc0817..9a3bb605f7 100644 --- a/server/src/services/album.service.spec.ts +++ b/server/src/services/album.service.spec.ts @@ -606,7 +606,7 @@ describe(AlbumService.name, () => { expect(mocks.album.addAssetIds).toHaveBeenCalledWith('album-123', ['asset-1', 'asset-2', 'asset-3']); expect(mocks.event.emit).toHaveBeenCalledWith('album.update', { id: 'album-123', - recipientIds: ['admin_id'], + recipientId: 'admin_id', }); }); diff --git a/server/src/services/album.service.ts b/server/src/services/album.service.ts index 1c612de8c0..d4e6ab7ffd 100644 --- a/server/src/services/album.service.ts +++ b/server/src/services/album.service.ts @@ -170,8 +170,8 @@ export class AlbumService extends BaseService { (userId) => userId !== auth.user.id, ); - if (allUsersExceptUs.length > 0) { - await this.eventRepository.emit('album.update', { id, recipientIds: allUsersExceptUs }); + for (const recipientId of allUsersExceptUs) { + await this.eventRepository.emit('album.update', { id, recipientId }); } } diff --git a/server/src/services/asset.service.spec.ts b/server/src/services/asset.service.spec.ts index af4fef5bd5..ecfb7936d2 100755 --- a/server/src/services/asset.service.spec.ts +++ b/server/src/services/asset.service.spec.ts @@ -1,6 +1,6 @@ import { BadRequestException } from '@nestjs/common'; import { DateTime } from 'luxon'; -import { MapAsset, mapAsset } from 'src/dtos/asset-response.dto'; +import { MapAsset } from 'src/dtos/asset-response.dto'; import { AssetJobName, AssetStatsResponseDto } from 'src/dtos/asset.dto'; import { AssetStatus, AssetType, JobName, JobStatus } from 'src/enum'; import { AssetStats } from 'src/repositories/asset.repository'; @@ -11,7 +11,6 @@ import { faceStub } from 'test/fixtures/face.stub'; import { userStub } from 'test/fixtures/user.stub'; import { factory } from 'test/small.factory'; import { makeStream, newTestService, ServiceMocks } from 'test/utils'; -import { vitest } from 'vitest'; const stats: AssetStats = { [AssetType.IMAGE]: 10, @@ -44,62 +43,6 @@ describe(AssetService.name, () => { mockGetById([assetStub.livePhotoStillAsset, assetStub.livePhotoMotionAsset]); }); - describe('getMemoryLane', () => { - beforeAll(() => { - vitest.useFakeTimers(); - vitest.setSystemTime(new Date('2024-01-15')); - }); - - afterAll(() => { - vitest.useRealTimers(); - }); - - it('should group the assets correctly', async () => { - const image1 = { ...assetStub.image, localDateTime: new Date(2023, 1, 15, 0, 0, 0) }; - const image2 = { ...assetStub.image, localDateTime: new Date(2023, 1, 15, 1, 0, 0) }; - const image3 = { ...assetStub.image, localDateTime: new Date(2015, 1, 15) }; - const image4 = { ...assetStub.image, localDateTime: new Date(2009, 1, 15) }; - - mocks.partner.getAll.mockResolvedValue([]); - mocks.asset.getByDayOfYear.mockResolvedValue([ - { - year: 2023, - assets: [image1, image2], - }, - { - year: 2015, - assets: [image3], - }, - { - year: 2009, - assets: [image4], - }, - ] as any); - - await expect(sut.getMemoryLane(authStub.admin, { day: 15, month: 1 })).resolves.toEqual([ - { yearsAgo: 1, title: '1 year ago', assets: [mapAsset(image1), mapAsset(image2)] }, - { yearsAgo: 9, title: '9 years ago', assets: [mapAsset(image3)] }, - { yearsAgo: 15, title: '15 years ago', assets: [mapAsset(image4)] }, - ]); - - expect(mocks.asset.getByDayOfYear.mock.calls).toEqual([[[authStub.admin.user.id], { day: 15, month: 1 }]]); - }); - - it('should get memories with partners with inTimeline enabled', async () => { - const partner = factory.partner(); - const auth = factory.auth({ user: { id: partner.sharedWithId } }); - - mocks.partner.getAll.mockResolvedValue([partner]); - mocks.asset.getByDayOfYear.mockResolvedValue([]); - - await sut.getMemoryLane(auth, { day: 15, month: 1 }); - - expect(mocks.asset.getByDayOfYear.mock.calls).toEqual([ - [[auth.user.id, partner.sharedById], { day: 15, month: 1 }], - ]); - }); - }); - describe('getStatistics', () => { it('should get the statistics for a user, excluding archived assets', async () => { mocks.asset.getStatistics.mockResolvedValue(stats); diff --git a/server/src/services/asset.service.ts b/server/src/services/asset.service.ts index 3e13ed0b8e..bcbe86875b 100644 --- a/server/src/services/asset.service.ts +++ b/server/src/services/asset.service.ts @@ -3,13 +3,7 @@ import _ from 'lodash'; import { DateTime, Duration } from 'luxon'; import { JOBS_ASSET_PAGINATION_SIZE } from 'src/constants'; import { OnJob } from 'src/decorators'; -import { - AssetResponseDto, - MapAsset, - MemoryLaneResponseDto, - SanitizedAssetResponseDto, - mapAsset, -} from 'src/dtos/asset-response.dto'; +import { AssetResponseDto, MapAsset, SanitizedAssetResponseDto, mapAsset } from 'src/dtos/asset-response.dto'; import { AssetBulkDeleteDto, AssetBulkUpdateDto, @@ -20,7 +14,6 @@ import { mapStats, } from 'src/dtos/asset.dto'; import { AuthDto } from 'src/dtos/auth.dto'; -import { MemoryLaneDto } from 'src/dtos/search.dto'; import { AssetStatus, JobName, JobStatus, Permission, QueueName } from 'src/enum'; import { BaseService } from 'src/services/base.service'; import { ISidecarWriteJob, JobItem, JobOf } from 'src/types'; @@ -28,26 +21,6 @@ import { getAssetFiles, getMyPartnerIds, onAfterUnlink, onBeforeLink, onBeforeUn @Injectable() export class AssetService extends BaseService { - async getMemoryLane(auth: AuthDto, dto: MemoryLaneDto): Promise { - const partnerIds = await getMyPartnerIds({ - userId: auth.user.id, - repository: this.partnerRepository, - timelineEnabled: true, - }); - const userIds = [auth.user.id, ...partnerIds]; - - const groups = await this.assetRepository.getByDayOfYear(userIds, dto); - return groups.map(({ year, assets }) => { - const yearsAgo = DateTime.utc().year - year; - return { - yearsAgo, - // TODO move this to clients - title: `${yearsAgo} year${yearsAgo > 1 ? 's' : ''} ago`, - assets: assets.map((asset) => mapAsset(asset, { auth })), - }; - }); - } - async getStatistics(auth: AuthDto, dto: AssetStatsDto) { const stats = await this.assetRepository.getStatistics(auth.user.id, dto); return mapStats(stats); diff --git a/server/src/services/audit.service.spec.ts b/server/src/services/audit.service.spec.ts index 6ef139f506..381b2ec7e8 100644 --- a/server/src/services/audit.service.spec.ts +++ b/server/src/services/audit.service.spec.ts @@ -1,6 +1,4 @@ -import { BadRequestException } from '@nestjs/common'; -import { FileReportItemDto } from 'src/dtos/audit.dto'; -import { AssetFileType, AssetPathType, JobStatus, PersonPathType, UserPathType } from 'src/enum'; +import { JobStatus } from 'src/enum'; import { AuditService } from 'src/services/audit.service'; import { newTestService, ServiceMocks } from 'test/utils'; @@ -25,148 +23,4 @@ describe(AuditService.name, () => { expect(mocks.audit.removeBefore).toHaveBeenCalledWith(expect.any(Date)); }); }); - - describe('getChecksums', () => { - it('should fail if the file is not in the immich path', async () => { - await expect(sut.getChecksums({ filenames: ['foo/bar'] })).rejects.toBeInstanceOf(BadRequestException); - - expect(mocks.crypto.hashFile).not.toHaveBeenCalled(); - }); - - it('should get checksum for valid file', async () => { - await expect(sut.getChecksums({ filenames: ['./upload/my-file.jpg'] })).resolves.toEqual([ - { filename: './upload/my-file.jpg', checksum: expect.any(String) }, - ]); - - expect(mocks.crypto.hashFile).toHaveBeenCalledWith('./upload/my-file.jpg'); - }); - }); - - describe('fixItems', () => { - it('should fail if the file is not in the immich path', async () => { - await expect( - sut.fixItems([ - { entityId: 'my-id', pathType: AssetPathType.ORIGINAL, pathValue: 'foo/bar' } as FileReportItemDto, - ]), - ).rejects.toBeInstanceOf(BadRequestException); - - expect(mocks.asset.update).not.toHaveBeenCalled(); - expect(mocks.asset.upsertFile).not.toHaveBeenCalled(); - expect(mocks.person.update).not.toHaveBeenCalled(); - expect(mocks.user.update).not.toHaveBeenCalled(); - }); - - it('should update encoded video path', async () => { - await sut.fixItems([ - { - entityId: 'my-id', - pathType: AssetPathType.ENCODED_VIDEO, - pathValue: './upload/my-video.mp4', - } as FileReportItemDto, - ]); - - expect(mocks.asset.update).toHaveBeenCalledWith({ id: 'my-id', encodedVideoPath: './upload/my-video.mp4' }); - expect(mocks.asset.upsertFile).not.toHaveBeenCalled(); - expect(mocks.person.update).not.toHaveBeenCalled(); - expect(mocks.user.update).not.toHaveBeenCalled(); - }); - - it('should update preview path', async () => { - await sut.fixItems([ - { - entityId: 'my-id', - pathType: AssetPathType.PREVIEW, - pathValue: './upload/my-preview.png', - } as FileReportItemDto, - ]); - - expect(mocks.asset.upsertFile).toHaveBeenCalledWith({ - assetId: 'my-id', - type: AssetFileType.PREVIEW, - path: './upload/my-preview.png', - }); - expect(mocks.asset.update).not.toHaveBeenCalled(); - expect(mocks.person.update).not.toHaveBeenCalled(); - expect(mocks.user.update).not.toHaveBeenCalled(); - }); - - it('should update thumbnail path', async () => { - await sut.fixItems([ - { - entityId: 'my-id', - pathType: AssetPathType.THUMBNAIL, - pathValue: './upload/my-thumbnail.webp', - } as FileReportItemDto, - ]); - - expect(mocks.asset.upsertFile).toHaveBeenCalledWith({ - assetId: 'my-id', - type: AssetFileType.THUMBNAIL, - path: './upload/my-thumbnail.webp', - }); - expect(mocks.asset.update).not.toHaveBeenCalled(); - expect(mocks.person.update).not.toHaveBeenCalled(); - expect(mocks.user.update).not.toHaveBeenCalled(); - }); - - it('should update original path', async () => { - await sut.fixItems([ - { - entityId: 'my-id', - pathType: AssetPathType.ORIGINAL, - pathValue: './upload/my-original.png', - } as FileReportItemDto, - ]); - - expect(mocks.asset.update).toHaveBeenCalledWith({ id: 'my-id', originalPath: './upload/my-original.png' }); - expect(mocks.asset.upsertFile).not.toHaveBeenCalled(); - expect(mocks.person.update).not.toHaveBeenCalled(); - expect(mocks.user.update).not.toHaveBeenCalled(); - }); - - it('should update sidecar path', async () => { - await sut.fixItems([ - { - entityId: 'my-id', - pathType: AssetPathType.SIDECAR, - pathValue: './upload/my-sidecar.xmp', - } as FileReportItemDto, - ]); - - expect(mocks.asset.update).toHaveBeenCalledWith({ id: 'my-id', sidecarPath: './upload/my-sidecar.xmp' }); - expect(mocks.asset.upsertFile).not.toHaveBeenCalled(); - expect(mocks.person.update).not.toHaveBeenCalled(); - expect(mocks.user.update).not.toHaveBeenCalled(); - }); - - it('should update face path', async () => { - await sut.fixItems([ - { - entityId: 'my-id', - pathType: PersonPathType.FACE, - pathValue: './upload/my-face.jpg', - } as FileReportItemDto, - ]); - - expect(mocks.person.update).toHaveBeenCalledWith({ id: 'my-id', thumbnailPath: './upload/my-face.jpg' }); - expect(mocks.asset.update).not.toHaveBeenCalled(); - expect(mocks.asset.upsertFile).not.toHaveBeenCalled(); - expect(mocks.user.update).not.toHaveBeenCalled(); - }); - - it('should update profile path', async () => { - await sut.fixItems([ - { - entityId: 'my-id', - pathType: UserPathType.PROFILE, - pathValue: './upload/my-profile-pic.jpg', - } as FileReportItemDto, - ]); - - expect(mocks.user.update).toHaveBeenCalledWith('my-id', { profileImagePath: './upload/my-profile-pic.jpg' }); - expect(mocks.asset.update).not.toHaveBeenCalled(); - expect(mocks.asset.upsertFile).not.toHaveBeenCalled(); - expect(mocks.person.update).not.toHaveBeenCalled(); - }); - }); }); diff --git a/server/src/services/audit.service.ts b/server/src/services/audit.service.ts index a049a9c64b..7c9a070dd0 100644 --- a/server/src/services/audit.service.ts +++ b/server/src/services/audit.service.ts @@ -1,23 +1,9 @@ -import { BadRequestException, Injectable } from '@nestjs/common'; +import { Injectable } from '@nestjs/common'; import { DateTime } from 'luxon'; -import { resolve } from 'node:path'; -import { AUDIT_LOG_MAX_DURATION, JOBS_ASSET_PAGINATION_SIZE } from 'src/constants'; -import { StorageCore } from 'src/cores/storage.core'; +import { AUDIT_LOG_MAX_DURATION } from 'src/constants'; import { OnJob } from 'src/decorators'; -import { FileChecksumDto, FileChecksumResponseDto, FileReportItemDto, PathEntityType } from 'src/dtos/audit.dto'; -import { - AssetFileType, - AssetPathType, - JobName, - JobStatus, - PersonPathType, - QueueName, - StorageFolder, - UserPathType, -} from 'src/enum'; +import { JobName, JobStatus, QueueName } from 'src/enum'; import { BaseService } from 'src/services/base.service'; -import { getAssetFiles } from 'src/utils/asset.util'; -import { usePagination } from 'src/utils/pagination'; @Injectable() export class AuditService extends BaseService { @@ -26,187 +12,4 @@ export class AuditService extends BaseService { await this.auditRepository.removeBefore(DateTime.now().minus(AUDIT_LOG_MAX_DURATION).toJSDate()); return JobStatus.SUCCESS; } - - async getChecksums(dto: FileChecksumDto) { - const results: FileChecksumResponseDto[] = []; - for (const filename of dto.filenames) { - if (!StorageCore.isImmichPath(filename)) { - throw new BadRequestException( - `Could not get the checksum of ${filename} because the file isn't accessible by Immich`, - ); - } - - const checksum = await this.cryptoRepository.hashFile(filename); - results.push({ filename, checksum: checksum.toString('base64') }); - } - return results; - } - - async fixItems(items: FileReportItemDto[]) { - for (const { entityId: id, pathType, pathValue } of items) { - if (!StorageCore.isImmichPath(pathValue)) { - throw new BadRequestException( - `Could not fix item ${id} with path ${pathValue} because the file isn't accessible by Immich`, - ); - } - - switch (pathType) { - case AssetPathType.ENCODED_VIDEO: { - await this.assetRepository.update({ id, encodedVideoPath: pathValue }); - break; - } - - case AssetPathType.PREVIEW: { - await this.assetRepository.upsertFile({ assetId: id, type: AssetFileType.PREVIEW, path: pathValue }); - break; - } - - case AssetPathType.THUMBNAIL: { - await this.assetRepository.upsertFile({ assetId: id, type: AssetFileType.THUMBNAIL, path: pathValue }); - break; - } - - case AssetPathType.ORIGINAL: { - await this.assetRepository.update({ id, originalPath: pathValue }); - break; - } - - case AssetPathType.SIDECAR: { - await this.assetRepository.update({ id, sidecarPath: pathValue }); - break; - } - - case PersonPathType.FACE: { - await this.personRepository.update({ id, thumbnailPath: pathValue }); - break; - } - - case UserPathType.PROFILE: { - await this.userRepository.update(id, { profileImagePath: pathValue }); - break; - } - } - } - } - - private fullPath(filename: string) { - return resolve(filename); - } - - async getFileReport() { - const hasFile = (items: Set, filename: string) => items.has(filename) || items.has(this.fullPath(filename)); - const crawl = async (folder: StorageFolder) => - new Set( - await this.storageRepository.crawl({ - includeHidden: true, - pathsToCrawl: [StorageCore.getBaseFolder(folder)], - }), - ); - - const uploadFiles = await crawl(StorageFolder.UPLOAD); - const libraryFiles = await crawl(StorageFolder.LIBRARY); - const thumbFiles = await crawl(StorageFolder.THUMBNAILS); - const videoFiles = await crawl(StorageFolder.ENCODED_VIDEO); - const profileFiles = await crawl(StorageFolder.PROFILE); - const allFiles = new Set(); - for (const list of [libraryFiles, thumbFiles, videoFiles, profileFiles, uploadFiles]) { - for (const item of list) { - allFiles.add(item); - } - } - - const track = (filename: string | null | undefined) => { - if (!filename) { - return; - } - allFiles.delete(filename); - allFiles.delete(this.fullPath(filename)); - }; - - this.logger.log( - `Found ${libraryFiles.size} original files, ${thumbFiles.size} thumbnails, ${videoFiles.size} encoded videos, ${profileFiles.size} profile files`, - ); - const pagination = usePagination(JOBS_ASSET_PAGINATION_SIZE, (options) => - this.assetRepository.getAll(options, { withDeleted: true, withArchived: true }), - ); - - let assetCount = 0; - - const orphans: FileReportItemDto[] = []; - for await (const assets of pagination) { - assetCount += assets.length; - for (const { id, files, originalPath, encodedVideoPath, isExternal, checksum } of assets) { - const { fullsizeFile, previewFile, thumbnailFile } = getAssetFiles(files); - for (const file of [ - originalPath, - fullsizeFile?.path, - previewFile?.path, - encodedVideoPath, - thumbnailFile?.path, - ]) { - track(file); - } - - const entity = { entityId: id, entityType: PathEntityType.ASSET, checksum: checksum.toString('base64') }; - if ( - originalPath && - !hasFile(libraryFiles, originalPath) && - !hasFile(uploadFiles, originalPath) && - // Android motion assets - !hasFile(videoFiles, originalPath) && - // ignore external library assets - !isExternal - ) { - orphans.push({ ...entity, pathType: AssetPathType.ORIGINAL, pathValue: originalPath }); - } - if (previewFile && !hasFile(thumbFiles, previewFile.path)) { - orphans.push({ ...entity, pathType: AssetPathType.PREVIEW, pathValue: previewFile.path }); - } - if (thumbnailFile && !hasFile(thumbFiles, thumbnailFile.path)) { - orphans.push({ ...entity, pathType: AssetPathType.THUMBNAIL, pathValue: thumbnailFile.path }); - } - if (encodedVideoPath && !hasFile(videoFiles, encodedVideoPath)) { - orphans.push({ ...entity, pathType: AssetPathType.THUMBNAIL, pathValue: encodedVideoPath }); - } - } - } - - const users = await this.userRepository.getList(); - for (const { id, profileImagePath } of users) { - track(profileImagePath); - - const entity = { entityId: id, entityType: PathEntityType.USER }; - if (profileImagePath && !hasFile(profileFiles, profileImagePath)) { - orphans.push({ ...entity, pathType: UserPathType.PROFILE, pathValue: profileImagePath }); - } - } - - let peopleCount = 0; - for await (const { id, thumbnailPath } of this.personRepository.getAll()) { - track(thumbnailPath); - const entity = { entityId: id, entityType: PathEntityType.PERSON }; - if (thumbnailPath && !hasFile(thumbFiles, thumbnailPath)) { - orphans.push({ ...entity, pathType: PersonPathType.FACE, pathValue: thumbnailPath }); - } - - if (peopleCount === JOBS_ASSET_PAGINATION_SIZE) { - this.logger.log(`Found ${assetCount} assets, ${users.length} users, ${peopleCount} people`); - peopleCount = 0; - } - } - - this.logger.log(`Found ${assetCount} assets, ${users.length} users, ${peopleCount} people`); - - const extras: string[] = []; - for (const file of allFiles) { - extras.push(file); - } - - // send as absolute paths - for (const orphan of orphans) { - orphan.pathValue = this.fullPath(orphan.pathValue); - } - - return { orphans, extras }; - } } diff --git a/server/src/services/duplicate.service.spec.ts b/server/src/services/duplicate.service.spec.ts index 57ab955514..ed8f2cf177 100644 --- a/server/src/services/duplicate.service.spec.ts +++ b/server/src/services/duplicate.service.spec.ts @@ -1,10 +1,9 @@ import { AssetFileType, AssetType, JobName, JobStatus } from 'src/enum'; -import { WithoutProperty } from 'src/repositories/asset.repository'; import { DuplicateService } from 'src/services/duplicate.service'; import { SearchService } from 'src/services/search.service'; import { assetStub } from 'test/fixtures/asset.stub'; import { authStub } from 'test/fixtures/auth.stub'; -import { newTestService, ServiceMocks } from 'test/utils'; +import { makeStream, newTestService, ServiceMocks } from 'test/utils'; import { beforeEach, vitest } from 'vitest'; vitest.useFakeTimers(); @@ -113,14 +112,11 @@ describe(SearchService.name, () => { }); it('should queue missing assets', async () => { - mocks.asset.getWithout.mockResolvedValue({ - items: [assetStub.image], - hasNextPage: false, - }); + mocks.assetJob.streamForSearchDuplicates.mockReturnValue(makeStream([assetStub.image])); await sut.handleQueueSearchDuplicates({}); - expect(mocks.asset.getWithout).toHaveBeenCalledWith({ skip: 0, take: 1000 }, WithoutProperty.DUPLICATE); + expect(mocks.assetJob.streamForSearchDuplicates).toHaveBeenCalledWith(undefined); expect(mocks.job.queueAll).toHaveBeenCalledWith([ { name: JobName.DUPLICATE_DETECTION, @@ -130,14 +126,11 @@ describe(SearchService.name, () => { }); it('should queue all assets', async () => { - mocks.asset.getAll.mockResolvedValue({ - items: [assetStub.image], - hasNextPage: false, - }); + mocks.assetJob.streamForSearchDuplicates.mockReturnValue(makeStream([assetStub.image])); await sut.handleQueueSearchDuplicates({ force: true }); - expect(mocks.asset.getAll).toHaveBeenCalled(); + expect(mocks.assetJob.streamForSearchDuplicates).toHaveBeenCalledWith(true); expect(mocks.job.queueAll).toHaveBeenCalledWith([ { name: JobName.DUPLICATE_DETECTION, diff --git a/server/src/services/duplicate.service.ts b/server/src/services/duplicate.service.ts index c504b1a305..41e3f13c4d 100644 --- a/server/src/services/duplicate.service.ts +++ b/server/src/services/duplicate.service.ts @@ -5,13 +5,11 @@ import { mapAsset } from 'src/dtos/asset-response.dto'; import { AuthDto } from 'src/dtos/auth.dto'; import { DuplicateResponseDto } from 'src/dtos/duplicate.dto'; import { AssetFileType, JobName, JobStatus, QueueName } from 'src/enum'; -import { WithoutProperty } from 'src/repositories/asset.repository'; import { AssetDuplicateResult } from 'src/repositories/search.repository'; import { BaseService } from 'src/services/base.service'; -import { JobOf } from 'src/types'; +import { JobItem, JobOf } from 'src/types'; import { getAssetFile } from 'src/utils/asset.util'; import { isDuplicateDetectionEnabled } from 'src/utils/misc'; -import { usePagination } from 'src/utils/pagination'; @Injectable() export class DuplicateService extends BaseService { @@ -30,18 +28,22 @@ export class DuplicateService extends BaseService { return JobStatus.SKIPPED; } - const assetPagination = usePagination(JOBS_ASSET_PAGINATION_SIZE, (pagination) => { - return force - ? this.assetRepository.getAll(pagination, { isVisible: true }) - : this.assetRepository.getWithout(pagination, WithoutProperty.DUPLICATE); - }); + let jobs: JobItem[] = []; + const queueAll = async () => { + await this.jobRepository.queueAll(jobs); + jobs = []; + }; - for await (const assets of assetPagination) { - await this.jobRepository.queueAll( - assets.map((asset) => ({ name: JobName.DUPLICATE_DETECTION, data: { id: asset.id } })), - ); + const assets = this.assetJobRepository.streamForSearchDuplicates(force); + for await (const asset of assets) { + jobs.push({ name: JobName.DUPLICATE_DETECTION, data: { id: asset.id } }); + if (jobs.length >= JOBS_ASSET_PAGINATION_SIZE) { + await queueAll(); + } } + await queueAll(); + return JobStatus.SUCCESS; } diff --git a/server/src/services/job.service.spec.ts b/server/src/services/job.service.spec.ts index 9acc81ceb7..c9020ed96a 100644 --- a/server/src/services/job.service.spec.ts +++ b/server/src/services/job.service.spec.ts @@ -239,10 +239,6 @@ describe(JobService.name, () => { item: { name: JobName.SIDECAR_DISCOVERY, data: { id: 'asset-1' } }, jobs: [JobName.METADATA_EXTRACTION], }, - { - item: { name: JobName.METADATA_EXTRACTION, data: { id: 'asset-1' } }, - jobs: [JobName.STORAGE_TEMPLATE_MIGRATION_SINGLE], - }, { item: { name: JobName.STORAGE_TEMPLATE_MIGRATION_SINGLE, data: { id: 'asset-1', source: 'upload' } }, jobs: [JobName.GENERATE_THUMBNAILS], diff --git a/server/src/services/job.service.ts b/server/src/services/job.service.ts index a387e6e099..cf9b87f4e6 100644 --- a/server/src/services/job.service.ts +++ b/server/src/services/job.service.ts @@ -264,17 +264,6 @@ export class JobService extends BaseService { break; } - case JobName.METADATA_EXTRACTION: { - if (item.data.source === 'sidecar-write') { - const [asset] = await this.assetRepository.getByIdsWithAllRelationsButStacks([item.data.id]); - if (asset) { - this.eventRepository.clientSend('on_asset_update', asset.ownerId, mapAsset(asset)); - } - } - await this.jobRepository.queue({ name: JobName.STORAGE_TEMPLATE_MIGRATION_SINGLE, data: item.data }); - break; - } - case JobName.STORAGE_TEMPLATE_MIGRATION_SINGLE: { if (item.data.source === 'upload' || item.data.source === 'copy') { await this.jobRepository.queue({ name: JobName.GENERATE_THUMBNAILS, data: item.data }); diff --git a/server/src/services/library.service.spec.ts b/server/src/services/library.service.spec.ts index 15b150f551..6b0817dd3b 100644 --- a/server/src/services/library.service.spec.ts +++ b/server/src/services/library.service.spec.ts @@ -273,7 +273,6 @@ describe(LibraryService.name, () => { mocks.library.get.mockResolvedValue(library); mocks.storage.walk.mockImplementation(async function* generator() {}); - mocks.asset.getAll.mockResolvedValue({ items: [assetStub.external], hasNextPage: false }); mocks.asset.getLibraryAssetCount.mockResolvedValue(1); mocks.asset.detectOfflineExternalAssets.mockResolvedValue({ numUpdatedRows: BigInt(1) }); @@ -292,7 +291,6 @@ describe(LibraryService.name, () => { mocks.library.get.mockResolvedValue(library); mocks.storage.walk.mockImplementation(async function* generator() {}); - mocks.asset.getAll.mockResolvedValue({ items: [assetStub.external], hasNextPage: false }); mocks.asset.getLibraryAssetCount.mockResolvedValue(0); mocks.asset.detectOfflineExternalAssets.mockResolvedValue({ numUpdatedRows: BigInt(1) }); diff --git a/server/src/services/media.service.spec.ts b/server/src/services/media.service.spec.ts index f39147309b..d747ee0ba8 100644 --- a/server/src/services/media.service.spec.ts +++ b/server/src/services/media.service.spec.ts @@ -38,10 +38,6 @@ describe(MediaService.name, () => { describe('handleQueueGenerateThumbnails', () => { it('should queue all assets', async () => { mocks.assetJob.streamForThumbnailJob.mockReturnValue(makeStream([assetStub.image])); - mocks.asset.getAll.mockResolvedValue({ - items: [assetStub.image], - hasNextPage: false, - }); mocks.person.getAll.mockReturnValue(makeStream([personStub.newThumbnail])); mocks.person.getFacesByIds.mockResolvedValue([faceStub.face1]); @@ -67,10 +63,6 @@ describe(MediaService.name, () => { it('should queue trashed assets when force is true', async () => { mocks.assetJob.streamForThumbnailJob.mockReturnValue(makeStream([assetStub.archived])); - mocks.asset.getAll.mockResolvedValue({ - items: [assetStub.trashed], - hasNextPage: false, - }); mocks.person.getAll.mockReturnValue(makeStream()); await sut.handleQueueGenerateThumbnails({ force: true }); @@ -171,7 +163,7 @@ describe(MediaService.name, () => { describe('handleQueueMigration', () => { it('should remove empty directories and queue jobs', async () => { - mocks.asset.getAll.mockResolvedValue({ hasNextPage: false, items: [assetStub.image] }); + mocks.assetJob.streamForMigrationJob.mockReturnValue(makeStream([assetStub.image])); mocks.job.getJobCounts.mockResolvedValue({ active: 1, waiting: 0 } as JobCounts); mocks.person.getAll.mockReturnValue(makeStream([personStub.withName])); diff --git a/server/src/services/media.service.ts b/server/src/services/media.service.ts index a0bc1b0906..546dcc930b 100644 --- a/server/src/services/media.service.ts +++ b/server/src/services/media.service.ts @@ -36,7 +36,6 @@ import { import { getAssetFiles } from 'src/utils/asset.util'; import { BaseConfig, ThumbnailConfig } from 'src/utils/media'; import { mimeTypes } from 'src/utils/mime-types'; -import { usePagination } from 'src/utils/pagination'; @Injectable() export class MediaService extends BaseService { @@ -50,18 +49,26 @@ export class MediaService extends BaseService { @OnJob({ name: JobName.QUEUE_GENERATE_THUMBNAILS, queue: QueueName.THUMBNAIL_GENERATION }) async handleQueueGenerateThumbnails({ force }: JobOf): Promise { - const thumbJobs: JobItem[] = []; + let jobs: JobItem[] = []; + + const queueAll = async () => { + await this.jobRepository.queueAll(jobs); + jobs = []; + }; + for await (const asset of this.assetJobRepository.streamForThumbnailJob(!!force)) { const { previewFile, thumbnailFile } = getAssetFiles(asset.files); if (!previewFile || !thumbnailFile || !asset.thumbhash || force) { - thumbJobs.push({ name: JobName.GENERATE_THUMBNAILS, data: { id: asset.id } }); - continue; + jobs.push({ name: JobName.GENERATE_THUMBNAILS, data: { id: asset.id } }); + } + + if (jobs.length >= JOBS_ASSET_PAGINATION_SIZE) { + await queueAll(); } } - await this.jobRepository.queueAll(thumbJobs); - const jobs: JobItem[] = []; + await queueAll(); const people = this.personRepository.getAll(force ? undefined : { thumbnailPath: '' }); @@ -76,32 +83,36 @@ export class MediaService extends BaseService { } jobs.push({ name: JobName.GENERATE_PERSON_THUMBNAIL, data: { id: person.id } }); + if (jobs.length >= JOBS_ASSET_PAGINATION_SIZE) { + await queueAll(); + } } - await this.jobRepository.queueAll(jobs); + await queueAll(); return JobStatus.SUCCESS; } @OnJob({ name: JobName.QUEUE_MIGRATION, queue: QueueName.MIGRATION }) async handleQueueMigration(): Promise { - const assetPagination = usePagination(JOBS_ASSET_PAGINATION_SIZE, (pagination) => - this.assetRepository.getAll(pagination), - ); - const { active, waiting } = await this.jobRepository.getJobCounts(QueueName.MIGRATION); if (active === 1 && waiting === 0) { await this.storageCore.removeEmptyDirs(StorageFolder.THUMBNAILS); await this.storageCore.removeEmptyDirs(StorageFolder.ENCODED_VIDEO); } - for await (const assets of assetPagination) { - await this.jobRepository.queueAll( - assets.map((asset) => ({ name: JobName.MIGRATE_ASSET, data: { id: asset.id } })), - ); + let jobs: JobItem[] = []; + const assets = this.assetJobRepository.streamForMigrationJob(); + for await (const asset of assets) { + jobs.push({ name: JobName.MIGRATE_ASSET, data: { id: asset.id } }); + if (jobs.length >= JOBS_ASSET_PAGINATION_SIZE) { + await this.jobRepository.queueAll(jobs); + jobs = []; + } } - let jobs: { name: JobName.MIGRATE_PERSON; data: { id: string } }[] = []; + await this.jobRepository.queueAll(jobs); + jobs = []; for await (const person of this.personRepository.getAll()) { jobs.push({ name: JobName.MIGRATE_PERSON, data: { id: person.id } }); @@ -255,7 +266,9 @@ export class MediaService extends BaseService { const { info, data, colorspace } = await this.decodeImage( extracted ? extracted.buffer : asset.originalPath, - asset.exifInfo, + // only specify orientation to extracted images which don't have EXIF orientation data + // or it can double rotate the image + extracted ? asset.exifInfo : { ...asset.exifInfo, orientation: null }, convertFullsize ? undefined : image.preview.size, ); diff --git a/server/src/services/metadata.service.spec.ts b/server/src/services/metadata.service.spec.ts index dfa2140824..969da6256d 100644 --- a/server/src/services/metadata.service.spec.ts +++ b/server/src/services/metadata.service.spec.ts @@ -5,7 +5,6 @@ import { constants } from 'node:fs/promises'; import { defaults } from 'src/config'; import { MapAsset } from 'src/dtos/asset-response.dto'; import { AssetType, ExifOrientation, ImmichWorker, JobName, JobStatus, SourceType } from 'src/enum'; -import { WithoutProperty } from 'src/repositories/asset.repository'; import { ImmichTags } from 'src/repositories/metadata.repository'; import { MetadataService } from 'src/services/metadata.service'; import { assetStub } from 'test/fixtures/asset.stub'; @@ -144,7 +143,8 @@ describe(MetadataService.name, () => { it('should handle an asset that could not be found', async () => { mocks.assetJob.getForMetadataExtraction.mockResolvedValue(void 0); - await expect(sut.handleMetadataExtraction({ id: assetStub.image.id })).resolves.toBe(JobStatus.FAILED); + + await sut.handleMetadataExtraction({ id: assetStub.image.id }); expect(mocks.assetJob.getForMetadataExtraction).toHaveBeenCalledWith(assetStub.image.id); expect(mocks.asset.upsertExif).not.toHaveBeenCalled(); @@ -527,7 +527,7 @@ describe(MetadataService.name, () => { ContainerDirectory: [{ Foo: 100 }], }); - await expect(sut.handleMetadataExtraction({ id: assetStub.image.id })).resolves.toBe(JobStatus.SUCCESS); + await sut.handleMetadataExtraction({ id: assetStub.image.id }); }); it('should extract the correct video orientation', async () => { @@ -1202,7 +1202,7 @@ describe(MetadataService.name, () => { it('should handle livePhotoCID not set', async () => { mocks.assetJob.getForMetadataExtraction.mockResolvedValue(assetStub.image); - await expect(sut.handleMetadataExtraction({ id: assetStub.image.id })).resolves.toBe(JobStatus.SUCCESS); + await sut.handleMetadataExtraction({ id: assetStub.image.id }); expect(mocks.assetJob.getForMetadataExtraction).toHaveBeenCalledWith(assetStub.image.id); expect(mocks.asset.findLivePhotoMatch).not.toHaveBeenCalled(); @@ -1215,9 +1215,7 @@ describe(MetadataService.name, () => { mocks.assetJob.getForMetadataExtraction.mockResolvedValue(assetStub.livePhotoMotionAsset); mockReadTags({ ContentIdentifier: 'CID' }); - await expect(sut.handleMetadataExtraction({ id: assetStub.livePhotoMotionAsset.id })).resolves.toBe( - JobStatus.SUCCESS, - ); + await sut.handleMetadataExtraction({ id: assetStub.livePhotoMotionAsset.id }); expect(mocks.assetJob.getForMetadataExtraction).toHaveBeenCalledWith(assetStub.livePhotoMotionAsset.id); expect(mocks.asset.findLivePhotoMatch).toHaveBeenCalledWith({ @@ -1236,9 +1234,7 @@ describe(MetadataService.name, () => { mocks.asset.findLivePhotoMatch.mockResolvedValue(assetStub.livePhotoMotionAsset); mockReadTags({ ContentIdentifier: 'CID' }); - await expect(sut.handleMetadataExtraction({ id: assetStub.livePhotoStillAsset.id })).resolves.toBe( - JobStatus.SUCCESS, - ); + await sut.handleMetadataExtraction({ id: assetStub.livePhotoStillAsset.id }); expect(mocks.assetJob.getForMetadataExtraction).toHaveBeenCalledWith(assetStub.livePhotoStillAsset.id); expect(mocks.asset.findLivePhotoMatch).toHaveBeenCalledWith({ @@ -1262,9 +1258,7 @@ describe(MetadataService.name, () => { mocks.asset.findLivePhotoMatch.mockResolvedValue(assetStub.livePhotoMotionAsset); mockReadTags({ ContentIdentifier: 'CID' }); - await expect(sut.handleMetadataExtraction({ id: assetStub.livePhotoStillAsset.id })).resolves.toBe( - JobStatus.SUCCESS, - ); + await sut.handleMetadataExtraction({ id: assetStub.livePhotoStillAsset.id }); expect(mocks.event.emit).toHaveBeenCalledWith('asset.hide', { userId: assetStub.livePhotoMotionAsset.ownerId, @@ -1280,10 +1274,12 @@ describe(MetadataService.name, () => { mocks.asset.findLivePhotoMatch.mockResolvedValue(assetStub.livePhotoMotionAsset); mockReadTags({ ContentIdentifier: 'CID' }); - await expect(sut.handleMetadataExtraction({ id: assetStub.livePhotoStillAsset.id })).resolves.toBe( - JobStatus.SUCCESS, - ); + await sut.handleMetadataExtraction({ id: assetStub.livePhotoStillAsset.id }); + expect(mocks.event.emit).toHaveBeenCalledWith('asset.metadataExtracted', { + assetId: assetStub.livePhotoStillAsset.id, + userId: assetStub.livePhotoStillAsset.ownerId, + }); expect(mocks.asset.findLivePhotoMatch).toHaveBeenCalledWith({ ownerId: 'user-id', otherAssetId: 'live-photo-still-asset', @@ -1346,12 +1342,11 @@ describe(MetadataService.name, () => { describe('handleQueueSidecar', () => { it('should queue assets with sidecar files', async () => { - mocks.asset.getAll.mockResolvedValue({ items: [assetStub.sidecar], hasNextPage: false }); + mocks.assetJob.streamForSidecar.mockReturnValue(makeStream([assetStub.image])); await sut.handleQueueSidecar({ force: true }); + expect(mocks.assetJob.streamForSidecar).toHaveBeenCalledWith(true); - expect(mocks.asset.getAll).toHaveBeenCalledWith({ take: 1000, skip: 0 }); - expect(mocks.asset.getWithout).not.toHaveBeenCalled(); expect(mocks.job.queueAll).toHaveBeenCalledWith([ { name: JobName.SIDECAR_SYNC, @@ -1361,12 +1356,11 @@ describe(MetadataService.name, () => { }); it('should queue assets without sidecar files', async () => { - mocks.asset.getWithout.mockResolvedValue({ items: [assetStub.image], hasNextPage: false }); + mocks.assetJob.streamForSidecar.mockReturnValue(makeStream([assetStub.image])); await sut.handleQueueSidecar({ force: false }); - expect(mocks.asset.getWithout).toHaveBeenCalledWith({ take: 1000, skip: 0 }, WithoutProperty.SIDECAR); - expect(mocks.asset.getAll).not.toHaveBeenCalled(); + expect(mocks.assetJob.streamForSidecar).toHaveBeenCalledWith(false); expect(mocks.job.queueAll).toHaveBeenCalledWith([ { name: JobName.SIDECAR_DISCOVERY, diff --git a/server/src/services/metadata.service.ts b/server/src/services/metadata.service.ts index 7c9a4c4b19..3f0c353d1d 100644 --- a/server/src/services/metadata.service.ts +++ b/server/src/services/metadata.service.ts @@ -22,14 +22,12 @@ import { QueueName, SourceType, } from 'src/enum'; -import { WithoutProperty } from 'src/repositories/asset.repository'; import { ArgOf } from 'src/repositories/event.repository'; import { ReverseGeocodeResult } from 'src/repositories/map.repository'; import { ImmichTags } from 'src/repositories/metadata.repository'; import { BaseService } from 'src/services/base.service'; -import { JobOf } from 'src/types'; +import { JobItem, JobOf } from 'src/types'; import { isFaceImportEnabled } from 'src/utils/misc'; -import { usePagination } from 'src/utils/pagination'; import { upsertTags } from 'src/utils/tag'; /** look for a date from these tags (in order) */ @@ -184,14 +182,14 @@ export class MetadataService extends BaseService { } @OnJob({ name: JobName.METADATA_EXTRACTION, queue: QueueName.METADATA_EXTRACTION }) - async handleMetadataExtraction(data: JobOf): Promise { + async handleMetadataExtraction(data: JobOf) { const [{ metadata, reverseGeocoding }, asset] = await Promise.all([ this.getConfig({ withCache: true }), this.assetJobRepository.getForMetadataExtraction(data.id), ]); if (!asset) { - return JobStatus.FAILED; + return; } const [exifTags, stats] = await Promise.all([ @@ -285,27 +283,31 @@ export class MetadataService extends BaseService { await this.assetRepository.upsertJobStatus({ assetId: asset.id, metadataExtractedAt: new Date() }); - return JobStatus.SUCCESS; + await this.eventRepository.emit('asset.metadataExtracted', { + assetId: asset.id, + userId: asset.ownerId, + source: data.source, + }); } @OnJob({ name: JobName.QUEUE_SIDECAR, queue: QueueName.SIDECAR }) - async handleQueueSidecar(job: JobOf): Promise { - const { force } = job; - const assetPagination = usePagination(JOBS_ASSET_PAGINATION_SIZE, (pagination) => { - return force - ? this.assetRepository.getAll(pagination) - : this.assetRepository.getWithout(pagination, WithoutProperty.SIDECAR); - }); + async handleQueueSidecar({ force }: JobOf): Promise { + let jobs: JobItem[] = []; + const queueAll = async () => { + await this.jobRepository.queueAll(jobs); + jobs = []; + }; - for await (const assets of assetPagination) { - await this.jobRepository.queueAll( - assets.map((asset) => ({ - name: force ? JobName.SIDECAR_SYNC : JobName.SIDECAR_DISCOVERY, - data: { id: asset.id }, - })), - ); + const assets = this.assetJobRepository.streamForSidecar(force); + for await (const asset of assets) { + jobs.push({ name: force ? JobName.SIDECAR_SYNC : JobName.SIDECAR_DISCOVERY, data: { id: asset.id } }); + if (jobs.length >= JOBS_ASSET_PAGINATION_SIZE) { + await queueAll(); + } } + await queueAll(); + return JobStatus.SUCCESS; } diff --git a/server/src/services/notification.service.spec.ts b/server/src/services/notification.service.spec.ts index 133eb9e7f6..b0f2a3ab62 100644 --- a/server/src/services/notification.service.spec.ts +++ b/server/src/services/notification.service.spec.ts @@ -154,10 +154,10 @@ describe(NotificationService.name, () => { describe('onAlbumUpdateEvent', () => { it('should queue notify album update event', async () => { - await sut.onAlbumUpdate({ id: 'album', recipientIds: ['42'] }); + await sut.onAlbumUpdate({ id: 'album', recipientId: '42' }); expect(mocks.job.queue).toHaveBeenCalledWith({ name: JobName.NOTIFY_ALBUM_UPDATE, - data: { id: 'album', recipientIds: ['42'], delay: 300_000 }, + data: { id: 'album', recipientId: '42', delay: 300_000 }, }); }); }); @@ -414,14 +414,14 @@ describe(NotificationService.name, () => { describe('handleAlbumUpdate', () => { it('should skip if album could not be found', async () => { - await expect(sut.handleAlbumUpdate({ id: '', recipientIds: ['1'] })).resolves.toBe(JobStatus.SKIPPED); + await expect(sut.handleAlbumUpdate({ id: '', recipientId: '1' })).resolves.toBe(JobStatus.SKIPPED); expect(mocks.user.get).not.toHaveBeenCalled(); }); it('should skip if owner could not be found', async () => { mocks.album.getById.mockResolvedValue(albumStub.emptyWithValidThumbnail); - await expect(sut.handleAlbumUpdate({ id: '', recipientIds: ['1'] })).resolves.toBe(JobStatus.SKIPPED); + await expect(sut.handleAlbumUpdate({ id: '', recipientId: '1' })).resolves.toBe(JobStatus.SKIPPED); expect(mocks.systemMetadata.get).not.toHaveBeenCalled(); }); @@ -434,7 +434,7 @@ describe(NotificationService.name, () => { mocks.email.renderEmail.mockResolvedValue({ html: '', text: '' }); mocks.assetJob.getAlbumThumbnailFiles.mockResolvedValue([]); - await sut.handleAlbumUpdate({ id: '', recipientIds: [userStub.user1.id] }); + await sut.handleAlbumUpdate({ id: '', recipientId: userStub.user1.id }); expect(mocks.user.get).toHaveBeenCalledWith(userStub.user1.id, { withDeleted: false }); expect(mocks.email.renderEmail).not.toHaveBeenCalled(); }); @@ -456,7 +456,7 @@ describe(NotificationService.name, () => { mocks.email.renderEmail.mockResolvedValue({ html: '', text: '' }); mocks.assetJob.getAlbumThumbnailFiles.mockResolvedValue([]); - await sut.handleAlbumUpdate({ id: '', recipientIds: [userStub.user1.id] }); + await sut.handleAlbumUpdate({ id: '', recipientId: userStub.user1.id }); expect(mocks.user.get).toHaveBeenCalledWith(userStub.user1.id, { withDeleted: false }); expect(mocks.email.renderEmail).not.toHaveBeenCalled(); }); @@ -478,7 +478,7 @@ describe(NotificationService.name, () => { mocks.email.renderEmail.mockResolvedValue({ html: '', text: '' }); mocks.assetJob.getAlbumThumbnailFiles.mockResolvedValue([]); - await sut.handleAlbumUpdate({ id: '', recipientIds: [userStub.user1.id] }); + await sut.handleAlbumUpdate({ id: '', recipientId: userStub.user1.id }); expect(mocks.user.get).toHaveBeenCalledWith(userStub.user1.id, { withDeleted: false }); expect(mocks.email.renderEmail).not.toHaveBeenCalled(); }); @@ -492,21 +492,21 @@ describe(NotificationService.name, () => { mocks.email.renderEmail.mockResolvedValue({ html: '', text: '' }); mocks.assetJob.getAlbumThumbnailFiles.mockResolvedValue([]); - await sut.handleAlbumUpdate({ id: '', recipientIds: [userStub.user1.id] }); + await sut.handleAlbumUpdate({ id: '', recipientId: userStub.user1.id }); expect(mocks.user.get).toHaveBeenCalledWith(userStub.user1.id, { withDeleted: false }); expect(mocks.email.renderEmail).toHaveBeenCalled(); expect(mocks.job.queue).toHaveBeenCalled(); }); it('should add new recipients for new images if job is already queued', async () => { - mocks.job.removeJob.mockResolvedValue({ id: '1', recipientIds: ['2', '3', '4'] } as INotifyAlbumUpdateJob); - await sut.onAlbumUpdate({ id: '1', recipientIds: ['1', '2', '3'] } as INotifyAlbumUpdateJob); + await sut.onAlbumUpdate({ id: '1', recipientId: '2' } as INotifyAlbumUpdateJob); + expect(mocks.job.removeJob).toHaveBeenCalledWith(JobName.NOTIFY_ALBUM_UPDATE, '1/2'); expect(mocks.job.queue).toHaveBeenCalledWith({ name: JobName.NOTIFY_ALBUM_UPDATE, data: { id: '1', delay: 300_000, - recipientIds: ['1', '2', '3', '4'], + recipientId: '2', }, }); }); diff --git a/server/src/services/notification.service.ts b/server/src/services/notification.service.ts index be475d1dca..e72f77ad4f 100644 --- a/server/src/services/notification.service.ts +++ b/server/src/services/notification.service.ts @@ -1,5 +1,6 @@ import { BadRequestException, Injectable } from '@nestjs/common'; import { OnEvent, OnJob } from 'src/decorators'; +import { mapAsset } from 'src/dtos/asset-response.dto'; import { AuthDto } from 'src/dtos/auth.dto'; import { mapNotification, @@ -22,7 +23,7 @@ import { import { EmailTemplate } from 'src/repositories/email.repository'; import { ArgOf } from 'src/repositories/event.repository'; import { BaseService } from 'src/services/base.service'; -import { EmailImageAttachment, IEntityJob, INotifyAlbumUpdateJob, JobItem, JobOf } from 'src/types'; +import { EmailImageAttachment, JobOf } from 'src/types'; import { getFilenameExtension } from 'src/utils/file'; import { getExternalDomain } from 'src/utils/misc'; import { isEqualObject } from 'src/utils/object'; @@ -152,6 +153,18 @@ export class NotificationService extends BaseService { this.eventRepository.clientSend('on_asset_trash', userId, assetIds); } + @OnEvent({ name: 'asset.metadataExtracted' }) + async onAssetMetadataExtracted({ assetId, userId, source }: ArgOf<'asset.metadataExtracted'>) { + if (source !== 'sidecar-write') { + return; + } + + const [asset] = await this.assetRepository.getByIdsWithAllRelationsButStacks([assetId]); + if (asset) { + this.eventRepository.clientSend('on_asset_update', userId, mapAsset(asset)); + } + } + @OnEvent({ name: 'assets.restore' }) onAssetsRestore({ assetIds, userId }: ArgOf<'assets.restore'>) { this.eventRepository.clientSend('on_asset_restore', userId, assetIds); @@ -185,30 +198,12 @@ export class NotificationService extends BaseService { } @OnEvent({ name: 'album.update' }) - async onAlbumUpdate({ id, recipientIds }: ArgOf<'album.update'>) { - // if recipientIds is empty, album likely only has one user part of it, don't queue notification if so - if (recipientIds.length === 0) { - return; - } - - const job: JobItem = { + async onAlbumUpdate({ id, recipientId }: ArgOf<'album.update'>) { + await this.jobRepository.removeJob(JobName.NOTIFY_ALBUM_UPDATE, `${id}/${recipientId}`); + await this.jobRepository.queue({ name: JobName.NOTIFY_ALBUM_UPDATE, - data: { id, recipientIds, delay: NotificationService.albumUpdateEmailDelayMs }, - }; - - const previousJobData = await this.jobRepository.removeJob(id, JobName.NOTIFY_ALBUM_UPDATE); - if (previousJobData && this.isAlbumUpdateJob(previousJobData)) { - for (const id of previousJobData.recipientIds) { - if (!recipientIds.includes(id)) { - recipientIds.push(id); - } - } - } - await this.jobRepository.queue(job); - } - - private isAlbumUpdateJob(job: IEntityJob): job is INotifyAlbumUpdateJob { - return 'recipientIds' in job; + data: { id, recipientId, delay: NotificationService.albumUpdateEmailDelayMs }, + }); } @OnEvent({ name: 'album.invite' }) @@ -399,7 +394,7 @@ export class NotificationService extends BaseService { } @OnJob({ name: JobName.NOTIFY_ALBUM_UPDATE, queue: QueueName.NOTIFICATION }) - async handleAlbumUpdate({ id, recipientIds }: JobOf) { + async handleAlbumUpdate({ id, recipientId }: JobOf) { const album = await this.albumRepository.getById(id, { withAssets: false }); if (!album) { @@ -411,49 +406,44 @@ export class NotificationService extends BaseService { return JobStatus.SKIPPED; } - const recipients = [...album.albumUsers.map((user) => user.user), owner].filter((user) => - recipientIds.includes(user.id), - ); const attachment = await this.getAlbumThumbnailAttachment(album); const { server, templates } = await this.getConfig({ withCache: false }); - for (const recipient of recipients) { - const user = await this.userRepository.get(recipient.id, { withDeleted: false }); - if (!user) { - continue; - } - - const { emailNotifications } = getPreferences(user.metadata); - - if (!emailNotifications.enabled || !emailNotifications.albumUpdate) { - continue; - } - - const { html, text } = await this.emailRepository.renderEmail({ - template: EmailTemplate.ALBUM_UPDATE, - data: { - baseUrl: getExternalDomain(server), - albumId: album.id, - albumName: album.albumName, - recipientName: recipient.name, - cid: attachment ? attachment.cid : undefined, - }, - customTemplate: templates.email.albumUpdateTemplate, - }); - - await this.jobRepository.queue({ - name: JobName.SEND_EMAIL, - data: { - to: recipient.email, - subject: `New media has been added to an album - ${album.albumName}`, - html, - text, - imageAttachments: attachment ? [attachment] : undefined, - }, - }); + const user = await this.userRepository.get(recipientId, { withDeleted: false }); + if (!user) { + return JobStatus.SKIPPED; } + const { emailNotifications } = getPreferences(user.metadata); + + if (!emailNotifications.enabled || !emailNotifications.albumUpdate) { + return JobStatus.SKIPPED; + } + + const { html, text } = await this.emailRepository.renderEmail({ + template: EmailTemplate.ALBUM_UPDATE, + data: { + baseUrl: getExternalDomain(server), + albumId: album.id, + albumName: album.albumName, + recipientName: user.name, + cid: attachment ? attachment.cid : undefined, + }, + customTemplate: templates.email.albumUpdateTemplate, + }); + + await this.jobRepository.queue({ + name: JobName.SEND_EMAIL, + data: { + to: user.email, + subject: `New media has been added to an album - ${album.albumName}`, + html, + text, + imageAttachments: attachment ? [attachment] : undefined, + }, + }); + return JobStatus.SUCCESS; } diff --git a/server/src/services/person.service.spec.ts b/server/src/services/person.service.spec.ts index 9808522434..5b88883472 100644 --- a/server/src/services/person.service.spec.ts +++ b/server/src/services/person.service.spec.ts @@ -2,7 +2,6 @@ import { BadRequestException, NotFoundException } from '@nestjs/common'; import { BulkIdErrorReason } from 'src/dtos/asset-ids.response.dto'; import { mapFaces, mapPerson, PersonResponseDto } from 'src/dtos/person.dto'; import { CacheControl, Colorspace, ImageFormat, JobName, JobStatus, SourceType, SystemMetadataKey } from 'src/enum'; -import { WithoutProperty } from 'src/repositories/asset.repository'; import { DetectedFaces } from 'src/repositories/machine-learning.repository'; import { FaceSearchResult } from 'src/repositories/search.repository'; import { PersonService } from 'src/services/person.service'; @@ -455,14 +454,11 @@ describe(PersonService.name, () => { }); it('should queue missing assets', async () => { - mocks.asset.getWithout.mockResolvedValue({ - items: [assetStub.image], - hasNextPage: false, - }); + mocks.assetJob.streamForDetectFacesJob.mockReturnValue(makeStream([assetStub.image])); await sut.handleQueueDetectFaces({ force: false }); - expect(mocks.asset.getWithout).toHaveBeenCalledWith({ skip: 0, take: 1000 }, WithoutProperty.FACES); + expect(mocks.assetJob.streamForDetectFacesJob).toHaveBeenCalledWith(false); expect(mocks.job.queueAll).toHaveBeenCalledWith([ { name: JobName.FACE_DETECTION, @@ -472,10 +468,7 @@ describe(PersonService.name, () => { }); it('should queue all assets', async () => { - mocks.asset.getAll.mockResolvedValue({ - items: [assetStub.image], - hasNextPage: false, - }); + mocks.assetJob.streamForDetectFacesJob.mockReturnValue(makeStream([assetStub.image])); mocks.person.getAllWithoutFaces.mockResolvedValue([personStub.withName]); await sut.handleQueueDetectFaces({ force: true }); @@ -483,7 +476,7 @@ describe(PersonService.name, () => { expect(mocks.person.deleteFaces).toHaveBeenCalledWith({ sourceType: SourceType.MACHINE_LEARNING }); expect(mocks.person.delete).toHaveBeenCalledWith([personStub.withName.id]); expect(mocks.storage.unlink).toHaveBeenCalledWith(personStub.withName.thumbnailPath); - expect(mocks.asset.getAll).toHaveBeenCalled(); + expect(mocks.assetJob.streamForDetectFacesJob).toHaveBeenCalledWith(true); expect(mocks.job.queueAll).toHaveBeenCalledWith([ { name: JobName.FACE_DETECTION, @@ -493,17 +486,14 @@ describe(PersonService.name, () => { }); it('should refresh all assets', async () => { - mocks.asset.getAll.mockResolvedValue({ - items: [assetStub.image], - hasNextPage: false, - }); + mocks.assetJob.streamForDetectFacesJob.mockReturnValue(makeStream([assetStub.image])); await sut.handleQueueDetectFaces({ force: undefined }); expect(mocks.person.delete).not.toHaveBeenCalled(); expect(mocks.person.deleteFaces).not.toHaveBeenCalled(); expect(mocks.storage.unlink).not.toHaveBeenCalled(); - expect(mocks.asset.getAll).toHaveBeenCalled(); + expect(mocks.assetJob.streamForDetectFacesJob).toHaveBeenCalledWith(undefined); expect(mocks.job.queueAll).toHaveBeenCalledWith([ { name: JobName.FACE_DETECTION, @@ -516,16 +506,13 @@ describe(PersonService.name, () => { it('should delete existing people and faces if forced', async () => { mocks.person.getAll.mockReturnValue(makeStream([faceStub.face1.person, personStub.randomPerson])); mocks.person.getAllFaces.mockReturnValue(makeStream([faceStub.face1])); - mocks.asset.getAll.mockResolvedValue({ - items: [assetStub.image], - hasNextPage: false, - }); + mocks.assetJob.streamForDetectFacesJob.mockReturnValue(makeStream([assetStub.image])); mocks.person.getAllWithoutFaces.mockResolvedValue([personStub.randomPerson]); mocks.person.deleteFaces.mockResolvedValue(); await sut.handleQueueDetectFaces({ force: true }); - expect(mocks.asset.getAll).toHaveBeenCalled(); + expect(mocks.assetJob.streamForDetectFacesJob).toHaveBeenCalledWith(true); expect(mocks.job.queueAll).toHaveBeenCalledWith([ { name: JobName.FACE_DETECTION, diff --git a/server/src/services/person.service.ts b/server/src/services/person.service.ts index 66d68857a0..227ea3c1c2 100644 --- a/server/src/services/person.service.ts +++ b/server/src/services/person.service.ts @@ -36,7 +36,6 @@ import { SourceType, SystemMetadataKey, } from 'src/enum'; -import { WithoutProperty } from 'src/repositories/asset.repository'; import { BoundingBox } from 'src/repositories/machine-learning.repository'; import { UpdateFacesData } from 'src/repositories/person.repository'; import { BaseService } from 'src/services/base.service'; @@ -44,7 +43,6 @@ import { CropOptions, ImageDimensions, InputDimensions, JobItem, JobOf } from 's import { ImmichFileResponse } from 'src/utils/file'; import { mimeTypes } from 'src/utils/mime-types'; import { isFaceImportEnabled, isFacialRecognitionEnabled } from 'src/utils/misc'; -import { usePagination } from 'src/utils/pagination'; @Injectable() export class PersonService extends BaseService { @@ -265,23 +263,19 @@ export class PersonService extends BaseService { await this.handlePersonCleanup(); } - const assetPagination = usePagination(JOBS_ASSET_PAGINATION_SIZE, (pagination) => { - return force === false - ? this.assetRepository.getWithout(pagination, WithoutProperty.FACES) - : this.assetRepository.getAll(pagination, { - orderDirection: 'desc', - withFaces: true, - withArchived: true, - isVisible: true, - }); - }); + let jobs: JobItem[] = []; + const assets = this.assetJobRepository.streamForDetectFacesJob(force); + for await (const asset of assets) { + jobs.push({ name: JobName.FACE_DETECTION, data: { id: asset.id } }); - for await (const assets of assetPagination) { - await this.jobRepository.queueAll( - assets.map((asset) => ({ name: JobName.FACE_DETECTION, data: { id: asset.id } })), - ); + if (jobs.length >= JOBS_ASSET_PAGINATION_SIZE) { + await this.jobRepository.queueAll(jobs); + jobs = []; + } } + await this.jobRepository.queueAll(jobs); + if (force === undefined) { await this.jobRepository.queue({ name: JobName.PERSON_CLEANUP }); } diff --git a/server/src/services/search.service.ts b/server/src/services/search.service.ts index 442d49136c..df286d1809 100644 --- a/server/src/services/search.service.ts +++ b/server/src/services/search.service.ts @@ -15,7 +15,6 @@ import { SmartSearchDto, } from 'src/dtos/search.dto'; import { AssetOrder } from 'src/enum'; -import { SearchExploreItem } from 'src/repositories/search.repository'; import { BaseService } from 'src/services/base.service'; import { getMyPartnerIds } from 'src/utils/asset.util'; import { isSmartSearchEnabled } from 'src/utils/misc'; @@ -32,7 +31,7 @@ export class SearchService extends BaseService { return places.map((place) => mapPlaces(place)); } - async getExploreData(auth: AuthDto): Promise[]> { + async getExploreData(auth: AuthDto) { const options = { maxFields: 12, minAssetsPerField: 5 }; const cities = await this.assetRepository.getAssetIdByCity(auth.user.id, options); const assets = await this.assetRepository.getByIdsWithAllRelationsButStacks(cities.items.map(({ data }) => data)); diff --git a/server/src/services/smart-info.service.spec.ts b/server/src/services/smart-info.service.spec.ts index e1c21841f0..9cc97a8f0d 100644 --- a/server/src/services/smart-info.service.spec.ts +++ b/server/src/services/smart-info.service.spec.ts @@ -151,7 +151,6 @@ describe(SmartInfoService.name, () => { await sut.handleQueueEncodeClip({}); - expect(mocks.asset.getWithout).not.toHaveBeenCalled(); expect(mocks.search.setDimensionSize).not.toHaveBeenCalled(); }); diff --git a/server/src/services/storage-template.service.ts b/server/src/services/storage-template.service.ts index 542633a03f..fcba497fa6 100644 --- a/server/src/services/storage-template.service.ts +++ b/server/src/services/storage-template.service.ts @@ -116,6 +116,11 @@ export class StorageTemplateService extends BaseService { return { ...storageTokens, presetOptions: storagePresets }; } + @OnEvent({ name: 'asset.metadataExtracted' }) + async onAssetMetadataExtracted({ source, assetId }: ArgOf<'asset.metadataExtracted'>) { + await this.jobRepository.queue({ name: JobName.STORAGE_TEMPLATE_MIGRATION_SINGLE, data: { source, id: assetId } }); + } + @OnJob({ name: JobName.STORAGE_TEMPLATE_MIGRATION_SINGLE, queue: QueueName.STORAGE_TEMPLATE_MIGRATION }) async handleMigrationSingle({ id }: JobOf): Promise { const config = await this.getConfig({ withCache: true }); diff --git a/server/src/types.ts b/server/src/types.ts index ba33e97aad..d18ef297ef 100644 --- a/server/src/types.ts +++ b/server/src/types.ts @@ -177,9 +177,10 @@ export interface IDelayedJob extends IBaseJob { delay?: number; } +export type JobSource = 'upload' | 'sidecar-write' | 'copy'; export interface IEntityJob extends IBaseJob { id: string; - source?: 'upload' | 'sidecar-write' | 'copy'; + source?: JobSource; notify?: boolean; } @@ -251,7 +252,7 @@ export interface INotifyAlbumInviteJob extends IEntityJob { } export interface INotifyAlbumUpdateJob extends IEntityJob, IDelayedJob { - recipientIds: string[]; + recipientId: string; } export interface JobCounts { diff --git a/server/src/utils/pagination.ts b/server/src/utils/pagination.ts index eb4106c86a..e440638a72 100644 --- a/server/src/utils/pagination.ts +++ b/server/src/utils/pagination.ts @@ -8,22 +8,6 @@ export interface PaginationResult { hasNextPage: boolean; } -export type Paginated = Promise>; - -/** @deprecated use `this.db. ... .stream()` instead */ -export async function* usePagination( - pageSize: number, - getNextPage: (pagination: PaginationOptions) => PaginationResult | Paginated, -) { - let hasNextPage = true; - - for (let skip = 0; hasNextPage; skip += pageSize) { - const result = await getNextPage({ take: pageSize, skip }); - hasNextPage = result.hasNextPage; - yield result.items; - } -} - export function paginationHelper(items: Entity[], take: number): PaginationResult { const hasNextPage = items.length > take; items.splice(take); diff --git a/server/test/repositories/asset.repository.mock.ts b/server/test/repositories/asset.repository.mock.ts index d540e55b2a..d8230a23f3 100644 --- a/server/test/repositories/asset.repository.mock.ts +++ b/server/test/repositories/asset.repository.mock.ts @@ -13,14 +13,11 @@ export const newAssetRepositoryMock = (): Mocked /dev/null 2>&1; do + if [ $((COUNT % 10)) -eq 0 ]; then + echo "Waiting for $UPSTREAM to start..." + fi + COUNT=$((COUNT + 1)) sleep 1 done +echo "Connected to $UPSTREAM" + node ./node_modules/.bin/vite dev --host 0.0.0.0 --port 3000 diff --git a/web/src/lib/components/asset-viewer/activity-viewer.svelte b/web/src/lib/components/asset-viewer/activity-viewer.svelte index 94b66d4c22..e98769d495 100644 --- a/web/src/lib/components/asset-viewer/activity-viewer.svelte +++ b/web/src/lib/components/asset-viewer/activity-viewer.svelte @@ -1,32 +1,24 @@ - - - {#snippet buttons()} - - - - - - - {/snippet} -
-
- {#if matches.length + extras.length + orphans.length === 0} -
- -
- {:else} -
- - - - - - - - {#each matches as match (match.extra.filename)} - handleSplit(match)} - > - - - - {/each} - -
-
-

- {$t('matches').toUpperCase()} - {matches.length > 0 ? `(${matches.length.toLocaleString($locale)})` : ''} -

-

{$t('admin.these_files_matched_by_checksum')}

-
-
- {match.orphan.pathValue} => - {match.extra.filename} - - ({match.orphan.entityType}/{match.orphan.pathType}) -
- - - - - - - - - {#each orphans as orphan, index (index)} - - - - - - {/each} - -
-
-

- {$t('admin.offline_paths').toUpperCase()} - {orphans.length > 0 ? `(${orphans.length.toLocaleString($locale)})` : ''} -

-

- {$t('admin.offline_paths_description')} -

-
-
copyToClipboard(orphan.pathValue)}> - {}} /> - - {orphan.pathValue} - - ({orphan.entityType}) -
- - - - - - - - - {#each extras as extra (extra.filename)} - handleCheckOne(extra.filename)} - title={extra.filename} - > - - - - {/each} - -
-
-

- {$t('admin.untracked_files').toUpperCase()} - {extras.length > 0 ? `(${extras.length.toLocaleString($locale)})` : ''} -

-

- {$t('admin.untracked_files_description')} -

-
-
copyToClipboard(extra.filename)}> - {}} /> - - {extra.filename} - - {#if extra.checksum} - [sha1:{extra.checksum}] - {/if} - -
-
- {/if} -
-
-
diff --git a/web/src/routes/admin/repair/+page.ts b/web/src/routes/admin/repair/+page.ts deleted file mode 100644 index 9e52abb573..0000000000 --- a/web/src/routes/admin/repair/+page.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { authenticate } from '$lib/utils/auth'; -import { getFormatter } from '$lib/utils/i18n'; -import { getAuditFiles } from '@immich/sdk'; -import type { PageLoad } from './$types'; - -export const load = (async () => { - await authenticate({ admin: true }); - const { orphans, extras } = await getAuditFiles(); - const $t = await getFormatter(); - - return { - orphans, - extras, - meta: { - title: $t('repair'), - }, - }; -}) satisfies PageLoad;