diff --git a/docs/docs/guides/template-backup-script.md b/docs/docs/guides/template-backup-script.md index cd43d660b..9a4f6c529 100644 --- a/docs/docs/guides/template-backup-script.md +++ b/docs/docs/guides/template-backup-script.md @@ -9,8 +9,8 @@ The database is saved to your Immich upload folder in the `database-backup` subd ### Prerequisites - Borg needs to be installed on your server as well as the remote machine. You can find instructions to install Borg [here](https://borgbackup.readthedocs.io/en/latest/installation.html). -- To run this sript as a non-root user, you should [add your username to the docker group](https://docs.docker.com/engine/install/linux-postinstall/). -- To run this script non-interactively, set up [passwordless ssh](https://www.redhat.com/sysadmin/passwordless-ssh) to your remote machine from your server. +- (Optional) To run this sript as a non-root user, you should [add your username to the docker group](https://docs.docker.com/engine/install/linux-postinstall/). +- To run this script non-interactively, set up [passwordless ssh](https://www.redhat.com/sysadmin/passwordless-ssh) to your remote machine from your server. If you skipped the previous step, make sure this step is done from your root account. To initialize the borg repository, run the following commands once. @@ -19,16 +19,13 @@ UPLOAD_LOCATION="/path/to/immich/directory" # Immich database location, as BACKUP_PATH="/path/to/local/backup/directory" mkdir "$UPLOAD_LOCATION/database-backup" -mkdir "$BACKUP_PATH/immich-borg" - borg init --encryption=none "$BACKUP_PATH/immich-borg" ## Remote set up REMOTE_HOST="remote_host@IP" REMOTE_BACKUP_PATH="/path/to/remote/backup/directory" -ssh "$REMOTE_HOST" "mkdir \"$REMOTE_BACKUP_PATH\"/immich-borg" -ssh "$REMOTE_HOST" "borg init --encryption=none \"$REMOTE_BACKUP_PATH\"/immich-borg" +borg init --encryption=none "$REMOTE_HOST:$REMOTE_BACKUP_PATH/immich-borg" ``` Edit the following script as necessary and add it to your crontab. Note that this script assumes there are no `:`, `@`, or `"` characters in your paths. If these characters exist, you will need to escape and/or rename the paths. diff --git a/mobile/assets/immich-logo-inline-dark.svg b/mobile/assets/immich-logo-inline-dark.svg new file mode 100644 index 000000000..8d72e075b --- /dev/null +++ b/mobile/assets/immich-logo-inline-dark.svg @@ -0,0 +1,56 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/mobile/assets/immich-logo-inline-light.svg b/mobile/assets/immich-logo-inline-light.svg new file mode 100644 index 000000000..d40a27a2b --- /dev/null +++ b/mobile/assets/immich-logo-inline-light.svg @@ -0,0 +1,54 @@ + + + + + + + + + + + + + + + + + + + diff --git a/mobile/ios/Podfile.lock b/mobile/ios/Podfile.lock index 6081988b7..a9ac5b338 100644 --- a/mobile/ios/Podfile.lock +++ b/mobile/ios/Podfile.lock @@ -180,4 +180,4 @@ SPEC CHECKSUMS: PODFILE CHECKSUM: 64c9b5291666c0ca3caabdfe9865c141ac40321d -COCOAPODS: 1.11.3 +COCOAPODS: 1.12.1 diff --git a/mobile/lib/shared/ui/immich_app_bar.dart b/mobile/lib/shared/ui/immich_app_bar.dart index 5b26432d8..678302dd9 100644 --- a/mobile/lib/shared/ui/immich_app_bar.dart +++ b/mobile/lib/shared/ui/immich_app_bar.dart @@ -1,6 +1,7 @@ import 'package:auto_route/auto_route.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_svg/svg.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/shared/models/store.dart'; @@ -169,11 +170,11 @@ class ImmichAppBar extends ConsumerWidget implements PreferredSizeWidget { } return Padding( padding: const EdgeInsets.only(top: 3.0), - child: Image.asset( - height: 30, + child: SvgPicture.asset( context.isDarkTheme - ? 'assets/immich-logo-inline-dark.png' - : 'assets/immich-logo-inline-light.png', + ? 'assets/immich-logo-inline-dark.svg' + : 'assets/immich-logo-inline-light.svg', + height: 40, ), ); }, diff --git a/mobile/lib/shared/ui/immich_logo.dart b/mobile/lib/shared/ui/immich_logo.dart index af83887fb..9f7725aa1 100644 --- a/mobile/lib/shared/ui/immich_logo.dart +++ b/mobile/lib/shared/ui/immich_logo.dart @@ -18,6 +18,7 @@ class ImmichLogo extends StatelessWidget { image: const AssetImage('assets/immich-logo.png'), width: size, filterQuality: FilterQuality.high, + isAntiAlias: true, ), ); } diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index d8ff4d30f..938293568 100644 --- a/mobile/openapi/README.md +++ b/mobile/openapi/README.md @@ -161,6 +161,7 @@ Class | Method | HTTP request | Description *PersonApi* | [**reassignFaces**](doc//PersonApi.md#reassignfaces) | **PUT** /person/{id}/reassign | *PersonApi* | [**updatePeople**](doc//PersonApi.md#updatepeople) | **PUT** /person | *PersonApi* | [**updatePerson**](doc//PersonApi.md#updateperson) | **PUT** /person/{id} | +*SearchApi* | [**getAssetsByCity**](doc//SearchApi.md#getassetsbycity) | **GET** /search/cities | *SearchApi* | [**getExploreData**](doc//SearchApi.md#getexploredata) | **GET** /search/explore | *SearchApi* | [**getSearchSuggestions**](doc//SearchApi.md#getsearchsuggestions) | **GET** /search/suggestions | *SearchApi* | [**search**](doc//SearchApi.md#search) | **GET** /search | diff --git a/mobile/openapi/doc/SearchApi.md b/mobile/openapi/doc/SearchApi.md index f63488222..e4ab9ecfd 100644 --- a/mobile/openapi/doc/SearchApi.md +++ b/mobile/openapi/doc/SearchApi.md @@ -9,6 +9,7 @@ All URIs are relative to */api* Method | HTTP request | Description ------------- | ------------- | ------------- +[**getAssetsByCity**](SearchApi.md#getassetsbycity) | **GET** /search/cities | [**getExploreData**](SearchApi.md#getexploredata) | **GET** /search/explore | [**getSearchSuggestions**](SearchApi.md#getsearchsuggestions) | **GET** /search/suggestions | [**search**](SearchApi.md#search) | **GET** /search | @@ -18,6 +19,57 @@ Method | HTTP request | Description [**searchSmart**](SearchApi.md#searchsmart) | **POST** /search/smart | +# **getAssetsByCity** +> List getAssetsByCity() + + + +### Example +```dart +import 'package:openapi/api.dart'; +// TODO Configure API key authorization: cookie +//defaultApiClient.getAuthentication('cookie').apiKey = 'YOUR_API_KEY'; +// uncomment below to setup prefix (e.g. Bearer) for API key, if needed +//defaultApiClient.getAuthentication('cookie').apiKeyPrefix = 'Bearer'; +// TODO Configure API key authorization: api_key +//defaultApiClient.getAuthentication('api_key').apiKey = 'YOUR_API_KEY'; +// uncomment below to setup prefix (e.g. Bearer) for API key, if needed +//defaultApiClient.getAuthentication('api_key').apiKeyPrefix = 'Bearer'; +// TODO Configure HTTP Bearer authorization: bearer +// Case 1. Use String Token +//defaultApiClient.getAuthentication('bearer').setAccessToken('YOUR_ACCESS_TOKEN'); +// Case 2. Use Function which generate token. +// String yourTokenGeneratorFunction() { ... } +//defaultApiClient.getAuthentication('bearer').setAccessToken(yourTokenGeneratorFunction); + +final api_instance = SearchApi(); + +try { + final result = api_instance.getAssetsByCity(); + print(result); +} catch (e) { + print('Exception when calling SearchApi->getAssetsByCity: $e\n'); +} +``` + +### Parameters +This endpoint does not need any parameter. + +### Return type + +[**List**](AssetResponseDto.md) + +### Authorization + +[cookie](../README.md#cookie), [api_key](../README.md#api_key), [bearer](../README.md#bearer) + +### HTTP request headers + + - **Content-Type**: Not defined + - **Accept**: application/json + +[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md) + # **getExploreData** > List getExploreData() diff --git a/mobile/openapi/lib/api/search_api.dart b/mobile/openapi/lib/api/search_api.dart index 3a0bc56bb..386a2f353 100644 --- a/mobile/openapi/lib/api/search_api.dart +++ b/mobile/openapi/lib/api/search_api.dart @@ -16,6 +16,50 @@ class SearchApi { final ApiClient apiClient; + /// Performs an HTTP 'GET /search/cities' operation and returns the [Response]. + Future getAssetsByCityWithHttpInfo() async { + // ignore: prefer_const_declarations + final path = r'/search/cities'; + + // ignore: prefer_final_locals + Object? postBody; + + final queryParams = []; + final headerParams = {}; + final formParams = {}; + + const contentTypes = []; + + + return apiClient.invokeAPI( + path, + 'GET', + queryParams, + postBody, + headerParams, + formParams, + contentTypes.isEmpty ? null : contentTypes.first, + ); + } + + Future?> getAssetsByCity() async { + final response = await getAssetsByCityWithHttpInfo(); + 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; + } + /// Performs an HTTP 'GET /search/explore' operation and returns the [Response]. Future getExploreDataWithHttpInfo() async { // ignore: prefer_const_declarations diff --git a/mobile/openapi/test/search_api_test.dart b/mobile/openapi/test/search_api_test.dart index aa4a94847..801c97a18 100644 --- a/mobile/openapi/test/search_api_test.dart +++ b/mobile/openapi/test/search_api_test.dart @@ -17,6 +17,11 @@ void main() { // final instance = SearchApi(); group('tests for SearchApi', () { + //Future> getAssetsByCity() async + test('test getAssetsByCity', () async { + // TODO + }); + //Future> getExploreData() async test('test getExploreData', () async { // TODO diff --git a/mobile/pubspec.lock b/mobile/pubspec.lock index f7a57bb2b..2f35cf591 100644 --- a/mobile/pubspec.lock +++ b/mobile/pubspec.lock @@ -560,6 +560,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.4.9" + flutter_svg: + dependency: "direct main" + description: + name: flutter_svg + sha256: d39e7f95621fc84376bc0f7d504f05c3a41488c562f4a8ad410569127507402c + url: "https://pub.dev" + source: hosted + version: "2.0.9" flutter_test: dependency: "direct dev" description: flutter @@ -1006,6 +1014,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.8.3" + path_parsing: + dependency: transitive + description: + name: path_parsing + sha256: e3e67b1629e6f7e8100b367d3db6ba6af4b1f0bb80f64db18ef1fbabd2fa9ccf + url: "https://pub.dev" + source: hosted + version: "1.0.1" path_provider: dependency: "direct main" description: @@ -1587,6 +1603,30 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.7" + vector_graphics: + dependency: transitive + description: + name: vector_graphics + sha256: "4ac59808bbfca6da38c99f415ff2d3a5d7ca0a6b4809c71d9cf30fba5daf9752" + url: "https://pub.dev" + source: hosted + version: "1.1.10+1" + vector_graphics_codec: + dependency: transitive + description: + name: vector_graphics_codec + sha256: f3247e7ab0ec77dc759263e68394990edc608fb2b480b80db8aa86ed09279e33 + url: "https://pub.dev" + source: hosted + version: "1.1.10+1" + vector_graphics_compiler: + dependency: transitive + description: + name: vector_graphics_compiler + sha256: "18489bdd8850de3dd7ca8a34e0c446f719ec63e2bab2e7a8cc66a9028dd76c5a" + url: "https://pub.dev" + source: hosted + version: "1.1.10+1" vector_math: dependency: transitive description: diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml index ed8a4fad6..a566d1aa9 100644 --- a/mobile/pubspec.yaml +++ b/mobile/pubspec.yaml @@ -34,6 +34,7 @@ dependencies: ref: acb428a005efd9832a0a8e7ef0945f899dfb3ca5 geolocator: ^11.0.0 # used to move to current location in map view flutter_udid: ^3.0.0 + flutter_svg: ^2.0.9 package_info_plus: ^5.0.1 url_launcher: ^6.2.4 http: 0.13.5 diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index 82562100a..f50abdffc 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -4597,6 +4597,41 @@ ] } }, + "/search/cities": { + "get": { + "operationId": "getAssetsByCity", + "parameters": [], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "items": { + "$ref": "#/components/schemas/AssetResponseDto" + }, + "type": "array" + } + } + }, + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "tags": [ + "Search" + ] + } + }, "/search/explore": { "get": { "operationId": "getExploreData", diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index 6b5064252..00434aaba 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -2204,6 +2204,14 @@ export function search({ clip, motion, page, q, query, recent, size, smart, $typ ...opts })); } +export function getAssetsByCity(opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchJson<{ + status: 200; + data: AssetResponseDto[]; + }>("/search/cities", { + ...opts + })); +} export function getExploreData(opts?: Oazapfts.RequestOpts) { return oazapfts.ok(oazapfts.fetchJson<{ status: 200; diff --git a/server/e2e/jobs/specs/library.e2e-spec.ts b/server/e2e/jobs/specs/library.e2e-spec.ts index 5040dad68..d76bdac02 100644 --- a/server/e2e/jobs/specs/library.e2e-spec.ts +++ b/server/e2e/jobs/specs/library.e2e-spec.ts @@ -67,11 +67,11 @@ describe(`${LibraryController.name} (e2e)`, () => { expect.arrayContaining([ expect.objectContaining({ isOffline: false, - originalFileName: 'silver_fir', + originalFileName: 'silver_fir.jpg', }), expect.objectContaining({ isOffline: false, - originalFileName: 'tanners_ridge', + originalFileName: 'tanners_ridge.jpg', }), ]), ); @@ -103,10 +103,10 @@ describe(`${LibraryController.name} (e2e)`, () => { expect(assets).toEqual( expect.arrayContaining([ expect.objectContaining({ - originalFileName: 'el_torcal_rocks', + originalFileName: 'el_torcal_rocks.jpg', }), expect.objectContaining({ - originalFileName: 'silver_fir', + originalFileName: 'silver_fir.jpg', }), ]), ); @@ -143,7 +143,7 @@ describe(`${LibraryController.name} (e2e)`, () => { expect(assets[0]).toEqual( expect.objectContaining({ - originalFileName: 'el_torcal_rocks', + originalFileName: 'el_torcal_rocks.jpg', exifInfo: expect.objectContaining({ dateTimeOriginal: '2023-09-25T08:33:30.880Z', exifImageHeight: 534, @@ -190,7 +190,7 @@ describe(`${LibraryController.name} (e2e)`, () => { expect(assets[0]).toEqual( expect.objectContaining({ - originalFileName: 'el_torcal_rocks', + originalFileName: 'el_torcal_rocks.jpg', exifInfo: expect.objectContaining({ dateTimeOriginal: '2012-08-05T11:39:59.000Z', }), @@ -230,7 +230,7 @@ describe(`${LibraryController.name} (e2e)`, () => { expect(assets[0]).toEqual( expect.objectContaining({ - originalFileName: 'el_torcal_rocks', + originalFileName: 'el_torcal_rocks.jpg', exifInfo: expect.objectContaining({ exifImageHeight: 534, exifImageWidth: 800, diff --git a/server/src/domain/asset/asset.service.spec.ts b/server/src/domain/asset/asset.service.spec.ts index 361946f61..11d2ed00b 100644 --- a/server/src/domain/asset/asset.service.spec.ts +++ b/server/src/domain/asset/asset.service.spec.ts @@ -548,19 +548,19 @@ describe(AssetService.name, () => { await expect(sut.update(authStub.admin, 'asset-1', { isArchived: false })).rejects.toBeInstanceOf( BadRequestException, ); - expect(assetMock.save).not.toHaveBeenCalled(); + expect(assetMock.update).not.toHaveBeenCalled(); }); it('should update the asset', async () => { accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1'])); - assetMock.save.mockResolvedValue(assetStub.image); + assetMock.getById.mockResolvedValue(assetStub.image); await sut.update(authStub.admin, 'asset-1', { isFavorite: true }); - expect(assetMock.save).toHaveBeenCalledWith({ id: 'asset-1', isFavorite: true }); + expect(assetMock.update).toHaveBeenCalledWith({ id: 'asset-1', isFavorite: true }); }); it('should update the exif description', async () => { accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1'])); - assetMock.save.mockResolvedValue(assetStub.image); + assetMock.getById.mockResolvedValue(assetStub.image); await sut.update(authStub.admin, 'asset-1', { description: 'Test description' }); expect(assetMock.upsertExif).toHaveBeenCalledWith({ assetId: 'asset-1', description: 'Test description' }); }); diff --git a/server/src/domain/asset/asset.service.ts b/server/src/domain/asset/asset.service.ts index e54eb8439..fbe4e91bd 100644 --- a/server/src/domain/asset/asset.service.ts +++ b/server/src/domain/asset/asset.service.ts @@ -324,7 +324,19 @@ export class AssetService { const { description, dateTimeOriginal, latitude, longitude, ...rest } = dto; await this.updateMetadata({ id, description, dateTimeOriginal, latitude, longitude }); - const asset = await this.assetRepository.save({ id, ...rest }); + await this.assetRepository.update({ id, ...rest }); + const asset = await this.assetRepository.getById(id, { + exifInfo: true, + owner: true, + smartInfo: true, + tags: true, + faces: { + person: true, + }, + }); + if (!asset) { + throw new BadRequestException('Asset not found'); + } return mapAsset(asset, { auth }); } diff --git a/server/src/domain/audit/audit.service.ts b/server/src/domain/audit/audit.service.ts index 91ebd78ee..c96f36d74 100644 --- a/server/src/domain/audit/audit.service.ts +++ b/server/src/domain/audit/audit.service.ts @@ -93,27 +93,27 @@ export class AuditService { switch (pathType) { case AssetPathType.ENCODED_VIDEO: { - await this.assetRepository.save({ id, encodedVideoPath: pathValue }); + await this.assetRepository.update({ id, encodedVideoPath: pathValue }); break; } case AssetPathType.JPEG_THUMBNAIL: { - await this.assetRepository.save({ id, resizePath: pathValue }); + await this.assetRepository.update({ id, resizePath: pathValue }); break; } case AssetPathType.WEBP_THUMBNAIL: { - await this.assetRepository.save({ id, webpPath: pathValue }); + await this.assetRepository.update({ id, webpPath: pathValue }); break; } case AssetPathType.ORIGINAL: { - await this.assetRepository.save({ id, originalPath: pathValue }); + await this.assetRepository.update({ id, originalPath: pathValue }); break; } case AssetPathType.SIDECAR: { - await this.assetRepository.save({ id, sidecarPath: pathValue }); + await this.assetRepository.update({ id, sidecarPath: pathValue }); break; } diff --git a/server/src/domain/library/library.service.spec.ts b/server/src/domain/library/library.service.spec.ts index 12b7e0cc7..7a6ff97ba 100644 --- a/server/src/domain/library/library.service.spec.ts +++ b/server/src/domain/library/library.service.spec.ts @@ -384,7 +384,7 @@ describe(LibraryService.name, () => { fileModifiedAt: expect.any(Date), localDateTime: expect.any(Date), type: AssetType.IMAGE, - originalFileName: 'photo', + originalFileName: 'photo.jpg', sidecarPath: null, isReadOnly: true, isExternal: true, @@ -432,7 +432,7 @@ describe(LibraryService.name, () => { fileModifiedAt: expect.any(Date), localDateTime: expect.any(Date), type: AssetType.IMAGE, - originalFileName: 'photo', + originalFileName: 'photo.jpg', sidecarPath: '/data/user1/photo.jpg.xmp', isReadOnly: true, isExternal: true, @@ -479,7 +479,7 @@ describe(LibraryService.name, () => { fileModifiedAt: expect.any(Date), localDateTime: expect.any(Date), type: AssetType.VIDEO, - originalFileName: 'video', + originalFileName: 'video.mp4', sidecarPath: null, isReadOnly: true, isExternal: true, @@ -591,7 +591,7 @@ describe(LibraryService.name, () => { await expect(sut.handleAssetRefresh(mockLibraryJob)).resolves.toBe(JobStatus.SUCCESS); - expect(assetMock.save).toHaveBeenCalledWith({ id: assetStub.image.id, isOffline: true }); + expect(assetMock.update).toHaveBeenCalledWith({ id: assetStub.image.id, isOffline: true }); expect(jobMock.queue).not.toHaveBeenCalled(); expect(jobMock.queueAll).not.toHaveBeenCalled(); }); @@ -609,7 +609,7 @@ describe(LibraryService.name, () => { await expect(sut.handleAssetRefresh(mockLibraryJob)).resolves.toBe(JobStatus.SUCCESS); - expect(assetMock.save).toHaveBeenCalledWith({ id: assetStub.offline.id, isOffline: false }); + expect(assetMock.update).toHaveBeenCalledWith({ id: assetStub.offline.id, isOffline: false }); expect(jobMock.queue).toHaveBeenCalledWith({ name: JobName.METADATA_EXTRACTION, @@ -638,7 +638,7 @@ describe(LibraryService.name, () => { assetMock.getByLibraryIdAndOriginalPath.mockResolvedValue(assetStub.image); assetMock.create.mockResolvedValue(assetStub.image); - expect(assetMock.save).not.toHaveBeenCalled(); + expect(assetMock.update).not.toHaveBeenCalled(); await expect(sut.handleAssetRefresh(mockLibraryJob)).resolves.toBe(JobStatus.SUCCESS); }); @@ -1264,7 +1264,7 @@ describe(LibraryService.name, () => { await sut.watchAll(); - expect(assetMock.save).toHaveBeenCalledWith({ id: assetStub.external.id, isOffline: true }); + expect(assetMock.update).toHaveBeenCalledWith({ id: assetStub.external.id, isOffline: true }); }); it('should handle an error event', async () => { diff --git a/server/src/domain/library/library.service.ts b/server/src/domain/library/library.service.ts index 1c40f3868..4007ccf28 100644 --- a/server/src/domain/library/library.service.ts +++ b/server/src/domain/library/library.service.ts @@ -172,7 +172,7 @@ export class LibraryService extends EventEmitter { this.logger.debug(`Detected deleted file at ${path} in library ${library.id}`); const asset = await this.assetRepository.getByLibraryIdAndOriginalPath(library.id, path); if (asset && matcher(path)) { - await this.assetRepository.save({ id: asset.id, isOffline: true }); + await this.assetRepository.update({ id: asset.id, isOffline: true }); } this.emit(StorageEventType.UNLINK, path); }; @@ -421,7 +421,7 @@ export class LibraryService extends EventEmitter { // Mark asset as offline this.logger.debug(`Marking asset as offline: ${assetPath}`); - await this.assetRepository.save({ id: existingAssetEntity.id, isOffline: true }); + await this.assetRepository.update({ id: existingAssetEntity.id, isOffline: true }); return JobStatus.SUCCESS; } else { // File can't be accessed and does not already exist in db @@ -454,7 +454,7 @@ export class LibraryService extends EventEmitter { if (stats && existingAssetEntity?.isOffline) { // File was previously offline but is now online this.logger.debug(`Marking previously-offline asset as online: ${assetPath}`); - await this.assetRepository.save({ id: existingAssetEntity.id, isOffline: false }); + await this.assetRepository.update({ id: existingAssetEntity.id, isOffline: false }); doRefresh = true; } @@ -503,7 +503,7 @@ export class LibraryService extends EventEmitter { fileModifiedAt: stats.mtime, localDateTime: stats.mtime, type: assetType, - originalFileName: parse(assetPath).name, + originalFileName: parse(assetPath).base, sidecarPath, isReadOnly: true, isExternal: true, diff --git a/server/src/domain/media/media.service.spec.ts b/server/src/domain/media/media.service.spec.ts index beea126bf..36d2cfdba 100644 --- a/server/src/domain/media/media.service.spec.ts +++ b/server/src/domain/media/media.service.spec.ts @@ -205,7 +205,7 @@ describe(MediaService.name, () => { assetMock.getByIds.mockResolvedValue([]); await sut.handleGenerateJpegThumbnail({ id: assetStub.image.id }); expect(mediaMock.resize).not.toHaveBeenCalled(); - expect(assetMock.save).not.toHaveBeenCalledWith(); + expect(assetMock.update).not.toHaveBeenCalledWith(); }); it('should skip video thumbnail generation if no video stream', async () => { @@ -213,7 +213,7 @@ describe(MediaService.name, () => { assetMock.getByIds.mockResolvedValue([assetStub.video]); await sut.handleGenerateJpegThumbnail({ id: assetStub.image.id }); expect(mediaMock.resize).not.toHaveBeenCalled(); - expect(assetMock.save).not.toHaveBeenCalledWith(); + expect(assetMock.update).not.toHaveBeenCalledWith(); }); it('should generate a thumbnail for an image', async () => { @@ -227,7 +227,7 @@ describe(MediaService.name, () => { quality: 80, colorspace: Colorspace.SRGB, }); - expect(assetMock.save).toHaveBeenCalledWith({ + expect(assetMock.update).toHaveBeenCalledWith({ id: 'asset-id', resizePath: 'upload/thumbs/user-id/as/se/asset-id.jpeg', }); @@ -246,7 +246,7 @@ describe(MediaService.name, () => { quality: 80, colorspace: Colorspace.P3, }); - expect(assetMock.save).toHaveBeenCalledWith({ + expect(assetMock.update).toHaveBeenCalledWith({ id: 'asset-id', resizePath: 'upload/thumbs/user-id/as/se/asset-id.jpeg', }); @@ -271,7 +271,7 @@ describe(MediaService.name, () => { twoPass: false, }, ); - expect(assetMock.save).toHaveBeenCalledWith({ + expect(assetMock.update).toHaveBeenCalledWith({ id: 'asset-id', resizePath: 'upload/thumbs/user-id/as/se/asset-id.jpeg', }); @@ -296,7 +296,7 @@ describe(MediaService.name, () => { twoPass: false, }, ); - expect(assetMock.save).toHaveBeenCalledWith({ + expect(assetMock.update).toHaveBeenCalledWith({ id: 'asset-id', resizePath: 'upload/thumbs/user-id/as/se/asset-id.jpeg', }); @@ -337,7 +337,7 @@ describe(MediaService.name, () => { assetMock.getByIds.mockResolvedValue([]); await sut.handleGenerateWebpThumbnail({ id: assetStub.image.id }); expect(mediaMock.resize).not.toHaveBeenCalled(); - expect(assetMock.save).not.toHaveBeenCalledWith(); + expect(assetMock.update).not.toHaveBeenCalledWith(); }); it('should generate a thumbnail', async () => { @@ -350,7 +350,7 @@ describe(MediaService.name, () => { quality: 80, colorspace: Colorspace.SRGB, }); - expect(assetMock.save).toHaveBeenCalledWith({ + expect(assetMock.update).toHaveBeenCalledWith({ id: 'asset-id', webpPath: 'upload/thumbs/user-id/as/se/asset-id.webp', }); @@ -370,7 +370,7 @@ describe(MediaService.name, () => { quality: 80, colorspace: Colorspace.P3, }); - expect(assetMock.save).toHaveBeenCalledWith({ + expect(assetMock.update).toHaveBeenCalledWith({ id: 'asset-id', webpPath: 'upload/thumbs/user-id/as/se/asset-id.webp', }); @@ -397,7 +397,7 @@ describe(MediaService.name, () => { await sut.handleGenerateThumbhashThumbnail({ id: assetStub.image.id }); expect(mediaMock.generateThumbhash).toHaveBeenCalledWith('/uploads/user-id/thumbs/path.jpg'); - expect(assetMock.save).toHaveBeenCalledWith({ id: 'asset-id', thumbhash: thumbhashBuffer }); + expect(assetMock.update).toHaveBeenCalledWith({ id: 'asset-id', thumbhash: thumbhashBuffer }); }); }); diff --git a/server/src/domain/media/media.service.ts b/server/src/domain/media/media.service.ts index 9d522d104..31eafcbcf 100644 --- a/server/src/domain/media/media.service.ts +++ b/server/src/domain/media/media.service.ts @@ -172,7 +172,7 @@ export class MediaService { } const resizePath = await this.generateThumbnail(asset, 'jpeg'); - await this.assetRepository.save({ id: asset.id, resizePath }); + await this.assetRepository.update({ id: asset.id, resizePath }); return JobStatus.SUCCESS; } @@ -222,7 +222,7 @@ export class MediaService { } const webpPath = await this.generateThumbnail(asset, 'webp'); - await this.assetRepository.save({ id: asset.id, webpPath }); + await this.assetRepository.update({ id: asset.id, webpPath }); return JobStatus.SUCCESS; } @@ -233,7 +233,7 @@ export class MediaService { } const thumbhash = await this.mediaRepository.generateThumbhash(asset.resizePath); - await this.assetRepository.save({ id: asset.id, thumbhash }); + await this.assetRepository.update({ id: asset.id, thumbhash }); return JobStatus.SUCCESS; } @@ -286,7 +286,7 @@ export class MediaService { if (asset.encodedVideoPath) { this.logger.log(`Transcoded video exists for asset ${asset.id}, but is no longer required. Deleting...`); await this.jobRepository.queue({ name: JobName.DELETE_FILES, data: { files: [asset.encodedVideoPath] } }); - await this.assetRepository.save({ id: asset.id, encodedVideoPath: null }); + await this.assetRepository.update({ id: asset.id, encodedVideoPath: null }); } return JobStatus.SKIPPED; @@ -321,7 +321,7 @@ export class MediaService { this.logger.log(`Successfully encoded ${asset.id}`); - await this.assetRepository.save({ id: asset.id, encodedVideoPath: output }); + await this.assetRepository.update({ id: asset.id, encodedVideoPath: output }); return JobStatus.SUCCESS; } diff --git a/server/src/domain/metadata/metadata.service.spec.ts b/server/src/domain/metadata/metadata.service.spec.ts index c28c61f22..69d31cbd5 100644 --- a/server/src/domain/metadata/metadata.service.spec.ts +++ b/server/src/domain/metadata/metadata.service.spec.ts @@ -117,7 +117,7 @@ describe(MetadataService.name, () => { await expect(sut.handleLivePhotoLinking({ id: assetStub.image.id })).resolves.toBe(JobStatus.FAILED); expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.image.id], { exifInfo: true }); expect(assetMock.findLivePhotoMatch).not.toHaveBeenCalled(); - expect(assetMock.save).not.toHaveBeenCalled(); + expect(assetMock.update).not.toHaveBeenCalled(); expect(albumMock.removeAsset).not.toHaveBeenCalled(); }); @@ -127,7 +127,7 @@ describe(MetadataService.name, () => { await expect(sut.handleLivePhotoLinking({ id: assetStub.image.id })).resolves.toBe(JobStatus.FAILED); expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.image.id], { exifInfo: true }); expect(assetMock.findLivePhotoMatch).not.toHaveBeenCalled(); - expect(assetMock.save).not.toHaveBeenCalled(); + expect(assetMock.update).not.toHaveBeenCalled(); expect(albumMock.removeAsset).not.toHaveBeenCalled(); }); @@ -137,7 +137,7 @@ describe(MetadataService.name, () => { await expect(sut.handleLivePhotoLinking({ id: assetStub.image.id })).resolves.toBe(JobStatus.SKIPPED); expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.image.id], { exifInfo: true }); expect(assetMock.findLivePhotoMatch).not.toHaveBeenCalled(); - expect(assetMock.save).not.toHaveBeenCalled(); + expect(assetMock.update).not.toHaveBeenCalled(); expect(albumMock.removeAsset).not.toHaveBeenCalled(); }); @@ -159,7 +159,7 @@ describe(MetadataService.name, () => { otherAssetId: assetStub.livePhotoMotionAsset.id, type: AssetType.IMAGE, }); - expect(assetMock.save).not.toHaveBeenCalled(); + expect(assetMock.update).not.toHaveBeenCalled(); expect(albumMock.removeAsset).not.toHaveBeenCalled(); }); @@ -182,11 +182,11 @@ describe(MetadataService.name, () => { otherAssetId: assetStub.livePhotoStillAsset.id, type: AssetType.VIDEO, }); - expect(assetMock.save).toHaveBeenCalledWith({ + expect(assetMock.update).toHaveBeenCalledWith({ id: assetStub.livePhotoStillAsset.id, livePhotoVideoId: assetStub.livePhotoMotionAsset.id, }); - expect(assetMock.save).toHaveBeenCalledWith({ id: assetStub.livePhotoMotionAsset.id, isVisible: false }); + expect(assetMock.update).toHaveBeenCalledWith({ id: assetStub.livePhotoMotionAsset.id, isVisible: false }); expect(albumMock.removeAsset).toHaveBeenCalledWith(assetStub.livePhotoMotionAsset.id); }); @@ -248,7 +248,7 @@ describe(MetadataService.name, () => { expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.image.id]); expect(assetMock.upsertExif).not.toHaveBeenCalled(); - expect(assetMock.save).not.toHaveBeenCalled(); + expect(assetMock.update).not.toHaveBeenCalled(); }); it('should handle a date in a sidecar file', async () => { @@ -267,7 +267,7 @@ describe(MetadataService.name, () => { await sut.handleMetadataExtraction({ id: assetStub.image.id }); expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.sidecar.id]); expect(assetMock.upsertExif).toHaveBeenCalledWith(expect.objectContaining({ dateTimeOriginal: sidecarDate })); - expect(assetMock.save).toHaveBeenCalledWith({ + expect(assetMock.update).toHaveBeenCalledWith({ id: assetStub.image.id, duration: null, fileCreatedAt: sidecarDate, @@ -282,7 +282,7 @@ describe(MetadataService.name, () => { await sut.handleMetadataExtraction({ id: assetStub.image.id }); expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.image.id]); expect(assetMock.upsertExif).toHaveBeenCalledWith(expect.objectContaining({ iso: 160 })); - expect(assetMock.save).toHaveBeenCalledWith({ + expect(assetMock.update).toHaveBeenCalledWith({ id: assetStub.image.id, duration: null, fileCreatedAt: assetStub.image.createdAt, @@ -304,7 +304,7 @@ describe(MetadataService.name, () => { expect(assetMock.upsertExif).toHaveBeenCalledWith( expect.objectContaining({ city: 'City', state: 'State', country: 'Country' }), ); - expect(assetMock.save).toHaveBeenCalledWith({ + expect(assetMock.update).toHaveBeenCalledWith({ id: assetStub.withLocation.id, duration: null, fileCreatedAt: assetStub.withLocation.createdAt, @@ -333,7 +333,7 @@ describe(MetadataService.name, () => { expect(storageMock.writeFile).not.toHaveBeenCalled(); expect(jobMock.queue).not.toHaveBeenCalled(); expect(jobMock.queueAll).not.toHaveBeenCalled(); - expect(assetMock.save).not.toHaveBeenCalledWith( + expect(assetMock.update).not.toHaveBeenCalledWith( expect.objectContaining({ assetType: AssetType.VIDEO, isVisible: false }), ); }); @@ -376,7 +376,7 @@ describe(MetadataService.name, () => { expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.livePhotoStillAsset.id]); expect(assetMock.create).toHaveBeenCalled(); // This could have arguments added expect(storageMock.writeFile).toHaveBeenCalledWith(assetStub.livePhotoMotionAsset.originalPath, video); - expect(assetMock.save).toHaveBeenNthCalledWith(1, { + expect(assetMock.update).toHaveBeenNthCalledWith(1, { id: assetStub.livePhotoStillAsset.id, livePhotoVideoId: fileStub.livePhotoMotion.uuid, }); @@ -404,7 +404,7 @@ describe(MetadataService.name, () => { expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.livePhotoStillAsset.id]); expect(assetMock.create).toHaveBeenCalled(); // This could have arguments added expect(storageMock.writeFile).toHaveBeenCalledWith(assetStub.livePhotoMotionAsset.originalPath, video); - expect(assetMock.save).toHaveBeenNthCalledWith(1, { + expect(assetMock.update).toHaveBeenNthCalledWith(1, { id: assetStub.livePhotoStillAsset.id, livePhotoVideoId: fileStub.livePhotoMotion.uuid, }); @@ -430,7 +430,7 @@ describe(MetadataService.name, () => { expect(storageMock.readFile).toHaveBeenCalledWith(assetStub.livePhotoStillAsset.originalPath, expect.any(Object)); expect(assetMock.create).toHaveBeenCalled(); // This could have arguments added expect(storageMock.writeFile).toHaveBeenCalledWith(assetStub.livePhotoMotionAsset.originalPath, video); - expect(assetMock.save).toHaveBeenNthCalledWith(1, { + expect(assetMock.update).toHaveBeenNthCalledWith(1, { id: assetStub.livePhotoStillAsset.id, livePhotoVideoId: fileStub.livePhotoMotion.uuid, }); @@ -470,7 +470,7 @@ describe(MetadataService.name, () => { expect(assetMock.create).toHaveBeenCalledTimes(0); expect(storageMock.writeFile).toHaveBeenCalledTimes(0); // The still asset gets saved by handleMetadataExtraction, but not the video - expect(assetMock.save).toHaveBeenCalledTimes(1); + expect(assetMock.update).toHaveBeenCalledTimes(1); expect(jobMock.queue).toHaveBeenCalledTimes(0); }); @@ -529,7 +529,7 @@ describe(MetadataService.name, () => { projectionType: 'EQUIRECTANGULAR', timeZone: tags.tz, }); - expect(assetMock.save).toHaveBeenCalledWith({ + expect(assetMock.update).toHaveBeenCalledWith({ id: assetStub.image.id, duration: null, fileCreatedAt: new Date('1970-01-01'), @@ -545,7 +545,7 @@ describe(MetadataService.name, () => { expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.image.id]); expect(assetMock.upsertExif).toHaveBeenCalled(); - expect(assetMock.save).toHaveBeenCalledWith( + expect(assetMock.update).toHaveBeenCalledWith( expect.objectContaining({ id: assetStub.image.id, duration: '00:00:06.210', @@ -561,7 +561,7 @@ describe(MetadataService.name, () => { expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.image.id]); expect(assetMock.upsertExif).toHaveBeenCalled(); - expect(assetMock.save).toHaveBeenCalledWith( + expect(assetMock.update).toHaveBeenCalledWith( expect.objectContaining({ id: assetStub.image.id, duration: '00:00:08.410', @@ -577,7 +577,7 @@ describe(MetadataService.name, () => { expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.image.id]); expect(assetMock.upsertExif).toHaveBeenCalled(); - expect(assetMock.save).toHaveBeenCalledWith( + expect(assetMock.update).toHaveBeenCalledWith( expect.objectContaining({ id: assetStub.image.id, duration: '00:00:06.200', @@ -593,7 +593,7 @@ describe(MetadataService.name, () => { expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.image.id]); expect(assetMock.upsertExif).toHaveBeenCalled(); - expect(assetMock.save).toHaveBeenCalledWith( + expect(assetMock.update).toHaveBeenCalledWith( expect.objectContaining({ id: assetStub.image.id, duration: '00:00:06.207', @@ -638,13 +638,13 @@ describe(MetadataService.name, () => { it('should do nothing if asset could not be found', async () => { assetMock.getByIds.mockResolvedValue([]); await expect(sut.handleSidecarSync({ id: assetStub.image.id })).resolves.toBe(JobStatus.FAILED); - expect(assetMock.save).not.toHaveBeenCalled(); + expect(assetMock.update).not.toHaveBeenCalled(); }); it('should do nothing if asset has no sidecar path', async () => { assetMock.getByIds.mockResolvedValue([assetStub.image]); await expect(sut.handleSidecarSync({ id: assetStub.image.id })).resolves.toBe(JobStatus.FAILED); - expect(assetMock.save).not.toHaveBeenCalled(); + expect(assetMock.update).not.toHaveBeenCalled(); }); it('should set sidecar path if exists (sidecar named photo.ext.xmp)', async () => { @@ -653,7 +653,7 @@ describe(MetadataService.name, () => { await expect(sut.handleSidecarSync({ id: assetStub.sidecar.id })).resolves.toBe(JobStatus.SUCCESS); expect(storageMock.checkFileExists).toHaveBeenCalledWith(`${assetStub.sidecar.originalPath}.xmp`, constants.R_OK); - expect(assetMock.save).toHaveBeenCalledWith({ + expect(assetMock.update).toHaveBeenCalledWith({ id: assetStub.sidecar.id, sidecarPath: assetStub.sidecar.sidecarPath, }); @@ -670,7 +670,7 @@ describe(MetadataService.name, () => { assetStub.sidecarWithoutExt.sidecarPath, constants.R_OK, ); - expect(assetMock.save).toHaveBeenCalledWith({ + expect(assetMock.update).toHaveBeenCalledWith({ id: assetStub.sidecarWithoutExt.id, sidecarPath: assetStub.sidecarWithoutExt.sidecarPath, }); @@ -688,7 +688,7 @@ describe(MetadataService.name, () => { assetStub.sidecarWithoutExt.sidecarPath, constants.R_OK, ); - expect(assetMock.save).toHaveBeenCalledWith({ + expect(assetMock.update).toHaveBeenCalledWith({ id: assetStub.sidecar.id, sidecarPath: assetStub.sidecar.sidecarPath, }); @@ -700,7 +700,7 @@ describe(MetadataService.name, () => { await expect(sut.handleSidecarSync({ id: assetStub.sidecar.id })).resolves.toBe(JobStatus.SUCCESS); expect(storageMock.checkFileExists).toHaveBeenCalledWith(`${assetStub.sidecar.originalPath}.xmp`, constants.R_OK); - expect(assetMock.save).toHaveBeenCalledWith({ + expect(assetMock.update).toHaveBeenCalledWith({ id: assetStub.sidecar.id, sidecarPath: null, }); @@ -724,16 +724,15 @@ describe(MetadataService.name, () => { assetMock.getByIds.mockResolvedValue([assetStub.image]); storageMock.checkFileExists.mockResolvedValue(false); await sut.handleSidecarDiscovery({ id: assetStub.image.id }); - expect(assetMock.save).not.toHaveBeenCalled(); + expect(assetMock.update).not.toHaveBeenCalled(); }); it('should update a image asset when a sidecar is found', async () => { assetMock.getByIds.mockResolvedValue([assetStub.image]); - assetMock.save.mockResolvedValue(assetStub.image); storageMock.checkFileExists.mockResolvedValue(true); await sut.handleSidecarDiscovery({ id: assetStub.image.id }); expect(storageMock.checkFileExists).toHaveBeenCalledWith('/original/path.jpg.xmp', constants.R_OK); - expect(assetMock.save).toHaveBeenCalledWith({ + expect(assetMock.update).toHaveBeenCalledWith({ id: assetStub.image.id, sidecarPath: '/original/path.jpg.xmp', }); @@ -741,11 +740,10 @@ describe(MetadataService.name, () => { it('should update a video asset when a sidecar is found', async () => { assetMock.getByIds.mockResolvedValue([assetStub.video]); - assetMock.save.mockResolvedValue(assetStub.video); storageMock.checkFileExists.mockResolvedValue(true); await sut.handleSidecarDiscovery({ id: assetStub.video.id }); expect(storageMock.checkFileExists).toHaveBeenCalledWith('/original/path.ext.xmp', constants.R_OK); - expect(assetMock.save).toHaveBeenCalledWith({ + expect(assetMock.update).toHaveBeenCalledWith({ id: assetStub.image.id, sidecarPath: '/original/path.ext.xmp', }); diff --git a/server/src/domain/metadata/metadata.service.ts b/server/src/domain/metadata/metadata.service.ts index 5f0b28fc4..75838330d 100644 --- a/server/src/domain/metadata/metadata.service.ts +++ b/server/src/domain/metadata/metadata.service.ts @@ -177,8 +177,8 @@ export class MetadataService { const [photoAsset, motionAsset] = asset.type === AssetType.IMAGE ? [asset, match] : [match, asset]; - await this.assetRepository.save({ id: photoAsset.id, livePhotoVideoId: motionAsset.id }); - await this.assetRepository.save({ id: motionAsset.id, isVisible: false }); + await this.assetRepository.update({ id: photoAsset.id, livePhotoVideoId: motionAsset.id }); + await this.assetRepository.update({ id: motionAsset.id, isVisible: false }); await this.albumRepository.removeAsset(motionAsset.id); // Notify clients to hide the linked live photo asset @@ -249,7 +249,7 @@ export class MetadataService { if (dateTimeOriginal && timeZoneOffset) { localDateTime = new Date(dateTimeOriginal.getTime() + timeZoneOffset * 60_000); } - await this.assetRepository.save({ + await this.assetRepository.update({ id: asset.id, duration: tags.Duration ? this.getDuration(tags.Duration) : null, localDateTime, @@ -317,7 +317,7 @@ export class MetadataService { await this.repository.writeTags(sidecarPath, exif); if (!asset.sidecarPath) { - await this.assetRepository.save({ id, sidecarPath }); + await this.assetRepository.update({ id, sidecarPath }); } return JobStatus.SUCCESS; @@ -435,7 +435,7 @@ export class MetadataService { this.storageCore.ensureFolders(motionPath); await this.storageRepository.writeFile(motionAsset.originalPath, video); await this.jobRepository.queue({ name: JobName.METADATA_EXTRACTION, data: { id: motionAsset.id } }); - await this.assetRepository.save({ id: asset.id, livePhotoVideoId: motionAsset.id }); + await this.assetRepository.update({ id: asset.id, livePhotoVideoId: motionAsset.id }); // If the asset already had an associated livePhotoVideo, delete it, because // its checksum doesn't match the checksum of the motionAsset we just extracted @@ -587,7 +587,7 @@ export class MetadataService { } if (sidecarPath) { - await this.assetRepository.save({ id: asset.id, sidecarPath }); + await this.assetRepository.update({ id: asset.id, sidecarPath }); return JobStatus.SUCCESS; } @@ -598,7 +598,7 @@ export class MetadataService { this.logger.debug( `Sidecar file was not found. Checked paths '${sidecarPathWithExt}' and '${sidecarPathWithoutExt}'. Removing sidecarPath for asset ${asset.id}`, ); - await this.assetRepository.save({ id: asset.id, sidecarPath: null }); + await this.assetRepository.update({ id: asset.id, sidecarPath: null }); return JobStatus.SUCCESS; } diff --git a/server/src/domain/repositories/asset.repository.ts b/server/src/domain/repositories/asset.repository.ts index fca0a4b3a..7ef9b8943 100644 --- a/server/src/domain/repositories/asset.repository.ts +++ b/server/src/domain/repositories/asset.repository.ts @@ -91,6 +91,25 @@ export type AssetCreate = Pick< > & Partial; +export type AssetWithoutRelations = Omit< + AssetEntity, + | 'livePhotoVideo' + | 'stack' + | 'albums' + | 'faces' + | 'owner' + | 'library' + | 'exifInfo' + | 'sharedLinks' + | 'smartInfo' + | 'smartSearch' + | 'tags' +>; + +export type AssetUpdateOptions = Pick & Partial; + +export type AssetUpdateAllOptions = Omit, 'id'>; + export interface MonthDay { day: number; month: number; @@ -139,8 +158,8 @@ export interface IAssetRepository { deleteAll(ownerId: string): Promise; getAll(pagination: PaginationOptions, options?: AssetSearchOptions): Paginated; getAllByDeviceId(userId: string, deviceId: string): Promise; - updateAll(ids: string[], options: Partial): Promise; - save(asset: Pick & Partial): Promise; + updateAll(ids: string[], options: Partial): Promise; + update(asset: AssetUpdateOptions): Promise; remove(asset: AssetEntity): Promise; softDeleteAll(ids: string[]): Promise; restoreAll(ids: string[]): Promise; diff --git a/server/src/domain/repositories/search.repository.ts b/server/src/domain/repositories/search.repository.ts index 10182a44e..bd4face86 100644 --- a/server/src/domain/repositories/search.repository.ts +++ b/server/src/domain/repositories/search.repository.ts @@ -187,5 +187,6 @@ export interface ISearchRepository { searchFaces(search: FaceEmbeddingSearch): Promise; upsert(smartInfo: Partial, embedding?: Embedding): Promise; searchPlaces(placeName: string): Promise; + getAssetsByCity(userIds: string[]): Promise; deleteAllSearchEmbeddings(): Promise; } diff --git a/server/src/domain/search/search.service.ts b/server/src/domain/search/search.service.ts index 56c4498bc..4b15dfd51 100644 --- a/server/src/domain/search/search.service.ts +++ b/server/src/domain/search/search.service.ts @@ -115,6 +115,32 @@ export class SearchService { return this.mapResponse(items, hasNextPage ? (page + 1).toString() : null); } + async getAssetsByCity(auth: AuthDto): Promise { + const userIds = await this.getUserIdsToSearch(auth); + const assets = await this.searchRepository.getAssetsByCity(userIds); + return assets.map((asset) => mapAsset(asset)); + } + + getSearchSuggestions(auth: AuthDto, dto: SearchSuggestionRequestDto): Promise { + switch (dto.type) { + case SearchSuggestionType.COUNTRY: { + return this.metadataRepository.getCountries(auth.user.id); + } + case SearchSuggestionType.STATE: { + return this.metadataRepository.getStates(auth.user.id, dto.country); + } + case SearchSuggestionType.CITY: { + return this.metadataRepository.getCities(auth.user.id, dto.country, dto.state); + } + case SearchSuggestionType.CAMERA_MAKE: { + return this.metadataRepository.getCameraMakes(auth.user.id, dto.model); + } + case SearchSuggestionType.CAMERA_MODEL: { + return this.metadataRepository.getCameraModels(auth.user.id, dto.make); + } + } + } + // TODO: remove after implementing new search filters /** @deprecated */ async search(auth: AuthDto, dto: SearchDto): Promise { @@ -191,24 +217,4 @@ export class SearchService { }, }; } - - async getSearchSuggestions(auth: AuthDto, dto: SearchSuggestionRequestDto): Promise { - switch (dto.type) { - case SearchSuggestionType.COUNTRY: { - return this.metadataRepository.getCountries(auth.user.id); - } - case SearchSuggestionType.STATE: { - return this.metadataRepository.getStates(auth.user.id, dto.country); - } - case SearchSuggestionType.CITY: { - return this.metadataRepository.getCities(auth.user.id, dto.country, dto.state); - } - case SearchSuggestionType.CAMERA_MAKE: { - return this.metadataRepository.getCameraMakes(auth.user.id, dto.model); - } - case SearchSuggestionType.CAMERA_MODEL: { - return this.metadataRepository.getCameraModels(auth.user.id, dto.make); - } - } - } } diff --git a/server/src/domain/storage-template/storage-template.service.spec.ts b/server/src/domain/storage-template/storage-template.service.spec.ts index 21fa6ef7d..a81e27c8f 100644 --- a/server/src/domain/storage-template/storage-template.service.spec.ts +++ b/server/src/domain/storage-template/storage-template.service.spec.ts @@ -111,7 +111,7 @@ describe(StorageTemplateService.name, () => { expect(storageMock.checkFileExists).not.toHaveBeenCalled(); expect(storageMock.rename).not.toHaveBeenCalled(); expect(storageMock.copyFile).not.toHaveBeenCalled(); - expect(assetMock.save).not.toHaveBeenCalled(); + expect(assetMock.update).not.toHaveBeenCalled(); expect(moveMock.create).not.toHaveBeenCalled(); expect(moveMock.update).not.toHaveBeenCalled(); expect(storageMock.stat).not.toHaveBeenCalled(); @@ -122,14 +122,6 @@ describe(StorageTemplateService.name, () => { const newMotionPicturePath = `upload/library/${userStub.user1.id}/2022/2022-06-19/${assetStub.livePhotoStillAsset.id}.mp4`; const newStillPicturePath = `upload/library/${userStub.user1.id}/2022/2022-06-19/${assetStub.livePhotoStillAsset.id}.jpeg`; - when(assetMock.save) - .calledWith({ id: assetStub.livePhotoStillAsset.id, originalPath: newStillPicturePath }) - .mockResolvedValue(assetStub.livePhotoStillAsset); - - when(assetMock.save) - .calledWith({ id: assetStub.livePhotoMotionAsset.id, originalPath: newMotionPicturePath }) - .mockResolvedValue(assetStub.livePhotoMotionAsset); - when(assetMock.getByIds) .calledWith([assetStub.livePhotoStillAsset.id], { exifInfo: true }) .mockResolvedValue([assetStub.livePhotoStillAsset]); @@ -175,11 +167,11 @@ describe(StorageTemplateService.name, () => { expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.livePhotoStillAsset.id], { exifInfo: true }); expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.livePhotoMotionAsset.id], { exifInfo: true }); expect(storageMock.checkFileExists).toHaveBeenCalledTimes(2); - expect(assetMock.save).toHaveBeenCalledWith({ + expect(assetMock.update).toHaveBeenCalledWith({ id: assetStub.livePhotoStillAsset.id, originalPath: newStillPicturePath, }); - expect(assetMock.save).toHaveBeenCalledWith({ + expect(assetMock.update).toHaveBeenCalledWith({ id: assetStub.livePhotoMotionAsset.id, originalPath: newMotionPicturePath, }); @@ -200,10 +192,6 @@ describe(StorageTemplateService.name, () => { newPath: previousFailedNewPath, }); - when(assetMock.save) - .calledWith({ id: assetStub.image.id, originalPath: newPath }) - .mockResolvedValue(assetStub.image); - when(assetMock.getByIds) .calledWith([assetStub.image.id], { exifInfo: true }) .mockResolvedValue([assetStub.image]); @@ -232,7 +220,7 @@ describe(StorageTemplateService.name, () => { oldPath: assetStub.image.originalPath, newPath, }); - expect(assetMock.save).toHaveBeenCalledWith({ + expect(assetMock.update).toHaveBeenCalledWith({ id: assetStub.image.id, originalPath: newPath, }); @@ -257,10 +245,6 @@ describe(StorageTemplateService.name, () => { newPath: previousFailedNewPath, }); - when(assetMock.save) - .calledWith({ id: assetStub.image.id, originalPath: newPath }) - .mockResolvedValue(assetStub.image); - when(assetMock.getByIds) .calledWith([assetStub.image.id], { exifInfo: true }) .mockResolvedValue([assetStub.image]); @@ -291,7 +275,7 @@ describe(StorageTemplateService.name, () => { oldPath: previousFailedNewPath, newPath, }); - expect(assetMock.save).toHaveBeenCalledWith({ + expect(assetMock.update).toHaveBeenCalledWith({ id: assetStub.image.id, originalPath: newPath, }); @@ -307,10 +291,6 @@ describe(StorageTemplateService.name, () => { .mockResolvedValue({ size: 5000 } as Stats); when(cryptoMock.hashFile).calledWith(newPath).mockResolvedValue(Buffer.from('different-hash', 'utf8')); - when(assetMock.save) - .calledWith({ id: assetStub.image.id, originalPath: newPath }) - .mockResolvedValue(assetStub.image); - when(assetMock.getByIds) .calledWith([assetStub.image.id], { exifInfo: true }) .mockResolvedValue([assetStub.image]); @@ -345,7 +325,7 @@ describe(StorageTemplateService.name, () => { expect(storageMock.copyFile).toHaveBeenCalledWith(assetStub.image.originalPath, newPath); expect(storageMock.unlink).toHaveBeenCalledWith(newPath); expect(storageMock.unlink).toHaveBeenCalledTimes(1); - expect(assetMock.save).not.toHaveBeenCalled(); + expect(assetMock.update).not.toHaveBeenCalled(); }); it.each` @@ -374,10 +354,6 @@ describe(StorageTemplateService.name, () => { newPath: previousFailedNewPath, }); - when(assetMock.save) - .calledWith({ id: assetStub.image.id, originalPath: newPath }) - .mockResolvedValue(assetStub.image); - when(assetMock.getByIds) .calledWith([assetStub.image.id], { exifInfo: true }) .mockResolvedValue([assetStub.image]); @@ -404,7 +380,7 @@ describe(StorageTemplateService.name, () => { expect(storageMock.rename).not.toHaveBeenCalled(); expect(storageMock.copyFile).not.toHaveBeenCalled(); expect(moveMock.update).not.toHaveBeenCalled(); - expect(assetMock.save).not.toHaveBeenCalled(); + expect(assetMock.update).not.toHaveBeenCalled(); }, ); }); @@ -427,7 +403,6 @@ describe(StorageTemplateService.name, () => { items: [assetStub.image], hasNextPage: false, }); - assetMock.save.mockResolvedValue(assetStub.image); userMock.getList.mockResolvedValue([userStub.user1]); moveMock.create.mockResolvedValue({ id: '123', @@ -449,7 +424,7 @@ describe(StorageTemplateService.name, () => { expect(assetMock.getAll).toHaveBeenCalled(); expect(storageMock.checkFileExists).toHaveBeenCalledTimes(2); - expect(assetMock.save).toHaveBeenCalledWith({ + expect(assetMock.update).toHaveBeenCalledWith({ id: assetStub.image.id, originalPath: 'upload/library/user-id/2023/2023-02-23/asset-id+1.jpg', }); @@ -474,7 +449,7 @@ describe(StorageTemplateService.name, () => { expect(storageMock.rename).not.toHaveBeenCalled(); expect(storageMock.copyFile).not.toHaveBeenCalled(); expect(storageMock.checkFileExists).not.toHaveBeenCalledTimes(2); - expect(assetMock.save).not.toHaveBeenCalled(); + expect(assetMock.update).not.toHaveBeenCalled(); }); it('should skip when an asset is probably a duplicate', async () => { @@ -495,7 +470,7 @@ describe(StorageTemplateService.name, () => { expect(storageMock.rename).not.toHaveBeenCalled(); expect(storageMock.copyFile).not.toHaveBeenCalled(); expect(storageMock.checkFileExists).not.toHaveBeenCalledTimes(2); - expect(assetMock.save).not.toHaveBeenCalled(); + expect(assetMock.update).not.toHaveBeenCalled(); }); it('should move an asset', async () => { @@ -503,7 +478,6 @@ describe(StorageTemplateService.name, () => { items: [assetStub.image], hasNextPage: false, }); - assetMock.save.mockResolvedValue(assetStub.image); userMock.getList.mockResolvedValue([userStub.user1]); moveMock.create.mockResolvedValue({ id: '123', @@ -520,7 +494,7 @@ describe(StorageTemplateService.name, () => { '/original/path.jpg', 'upload/library/user-id/2023/2023-02-23/asset-id.jpg', ); - expect(assetMock.save).toHaveBeenCalledWith({ + expect(assetMock.update).toHaveBeenCalledWith({ id: assetStub.image.id, originalPath: 'upload/library/user-id/2023/2023-02-23/asset-id.jpg', }); @@ -531,7 +505,6 @@ describe(StorageTemplateService.name, () => { items: [assetStub.image], hasNextPage: false, }); - assetMock.save.mockResolvedValue(assetStub.image); userMock.getList.mockResolvedValue([userStub.storageLabel]); moveMock.create.mockResolvedValue({ id: '123', @@ -548,7 +521,7 @@ describe(StorageTemplateService.name, () => { '/original/path.jpg', 'upload/library/label-1/2023/2023-02-23/asset-id.jpg', ); - expect(assetMock.save).toHaveBeenCalledWith({ + expect(assetMock.update).toHaveBeenCalledWith({ id: assetStub.image.id, originalPath: 'upload/library/label-1/2023/2023-02-23/asset-id.jpg', }); @@ -592,7 +565,7 @@ describe(StorageTemplateService.name, () => { expect(storageMock.utimes).toHaveBeenCalledWith(newPath, expect.any(Date), expect.any(Date)); expect(storageMock.unlink).toHaveBeenCalledWith(assetStub.image.originalPath); expect(storageMock.unlink).toHaveBeenCalledTimes(1); - expect(assetMock.save).toHaveBeenCalledWith({ + expect(assetMock.update).toHaveBeenCalledWith({ id: assetStub.image.id, originalPath: newPath, }); @@ -630,7 +603,7 @@ describe(StorageTemplateService.name, () => { 'upload/library/user-id/2023/2023-02-23/asset-id.jpg', ); expect(storageMock.stat).toHaveBeenCalledWith('upload/library/user-id/2023/2023-02-23/asset-id.jpg'); - expect(assetMock.save).not.toHaveBeenCalled(); + expect(assetMock.update).not.toHaveBeenCalled(); }); it('should not update the database if the move fails', async () => { @@ -656,7 +629,7 @@ describe(StorageTemplateService.name, () => { '/original/path.jpg', 'upload/library/user-id/2023/2023-02-23/asset-id.jpg', ); - expect(assetMock.save).not.toHaveBeenCalled(); + expect(assetMock.update).not.toHaveBeenCalled(); }); it('should not move read-only asset', async () => { @@ -670,7 +643,6 @@ describe(StorageTemplateService.name, () => { ], hasNextPage: false, }); - assetMock.save.mockResolvedValue(assetStub.image); userMock.getList.mockResolvedValue([userStub.user1]); await sut.handleMigration(); @@ -678,7 +650,7 @@ describe(StorageTemplateService.name, () => { expect(assetMock.getAll).toHaveBeenCalled(); expect(storageMock.rename).not.toHaveBeenCalled(); expect(storageMock.copyFile).not.toHaveBeenCalled(); - expect(assetMock.save).not.toHaveBeenCalled(); + expect(assetMock.update).not.toHaveBeenCalled(); }); }); }); diff --git a/server/src/domain/storage/storage.core.ts b/server/src/domain/storage/storage.core.ts index 36e600b24..5cf65ad7c 100644 --- a/server/src/domain/storage/storage.core.ts +++ b/server/src/domain/storage/storage.core.ts @@ -286,19 +286,19 @@ export class StorageCore { private savePath(pathType: PathType, id: string, newPath: string) { switch (pathType) { case AssetPathType.ORIGINAL: { - return this.assetRepository.save({ id, originalPath: newPath }); + return this.assetRepository.update({ id, originalPath: newPath }); } case AssetPathType.JPEG_THUMBNAIL: { - return this.assetRepository.save({ id, resizePath: newPath }); + return this.assetRepository.update({ id, resizePath: newPath }); } case AssetPathType.WEBP_THUMBNAIL: { - return this.assetRepository.save({ id, webpPath: newPath }); + return this.assetRepository.update({ id, webpPath: newPath }); } case AssetPathType.ENCODED_VIDEO: { - return this.assetRepository.save({ id, encodedVideoPath: newPath }); + return this.assetRepository.update({ id, encodedVideoPath: newPath }); } case AssetPathType.SIDECAR: { - return this.assetRepository.save({ id, sidecarPath: newPath }); + return this.assetRepository.update({ id, sidecarPath: newPath }); } case PersonPathType.FACE: { return this.personRepository.update({ id, thumbnailPath: newPath }); diff --git a/server/src/immich/controllers/search.controller.ts b/server/src/immich/controllers/search.controller.ts index d508531dd..a3527a66a 100644 --- a/server/src/immich/controllers/search.controller.ts +++ b/server/src/immich/controllers/search.controller.ts @@ -1,4 +1,5 @@ import { + AssetResponseDto, AuthDto, MetadataSearchDto, PersonResponseDto, @@ -55,6 +56,11 @@ export class SearchController { return this.service.searchPlaces(dto); } + @Get('cities') + getAssetsByCity(@Auth() auth: AuthDto): Promise { + return this.service.getAssetsByCity(auth); + } + @Get('suggestions') getSearchSuggestions(@Auth() auth: AuthDto, @Query() dto: SearchSuggestionRequestDto): Promise { return this.service.getSearchSuggestions(auth, dto); diff --git a/server/src/infra/repositories/asset.repository.ts b/server/src/infra/repositories/asset.repository.ts index 86d4e4cc2..4dc295bc6 100644 --- a/server/src/infra/repositories/asset.repository.ts +++ b/server/src/infra/repositories/asset.repository.ts @@ -6,6 +6,8 @@ import { AssetSearchOptions, AssetStats, AssetStatsOptions, + AssetUpdateAllOptions, + AssetUpdateOptions, IAssetRepository, LivePhotoSearchOptions, MapMarker, @@ -275,7 +277,7 @@ export class AssetRepository implements IAssetRepository { @GenerateSql({ params: [[DummyValue.UUID], { deviceId: DummyValue.STRING }] }) @Chunked() - async updateAll(ids: string[], options: Partial): Promise { + async updateAll(ids: string[], options: AssetUpdateAllOptions): Promise { await this.repository.update({ id: In(ids) }, options); } @@ -289,21 +291,8 @@ export class AssetRepository implements IAssetRepository { await this.repository.restore({ id: In(ids) }); } - async save(asset: Partial): Promise { - const { id } = await this.repository.save(asset); - return this.repository.findOneOrFail({ - where: { id }, - relations: { - exifInfo: true, - owner: true, - smartInfo: true, - tags: true, - faces: { - person: true, - }, - }, - withDeleted: true, - }); + async update(asset: AssetUpdateOptions): Promise { + await this.repository.update(asset.id, asset); } async remove(asset: AssetEntity): Promise { diff --git a/server/src/infra/repositories/search.repository.ts b/server/src/infra/repositories/search.repository.ts index f5d1cbda3..0e29506d1 100644 --- a/server/src/infra/repositories/search.repository.ts +++ b/server/src/infra/repositories/search.repository.ts @@ -15,6 +15,7 @@ import { getCLIPModelInfo } from '@app/domain/smart-info/smart-info.constant'; import { AssetEntity, AssetFaceEntity, + AssetType, GeodataPlacesEntity, SmartInfoEntity, SmartSearchEntity, @@ -33,6 +34,7 @@ import { Instrumentation } from '../instrumentation'; export class SearchRepository implements ISearchRepository { private logger = new ImmichLogger(SearchRepository.name); private faceColumns: string[]; + private assetsByCityQuery: string; constructor( @InjectRepository(SmartInfoEntity) private repository: Repository, @@ -45,6 +47,14 @@ export class SearchRepository implements ISearchRepository { .getMetadata(AssetFaceEntity) .ownColumns.map((column) => column.propertyName) .filter((propertyName) => propertyName !== 'embedding'); + this.assetsByCityQuery = + assetsByCityCte + + this.assetRepository + .createQueryBuilder('asset') + .innerJoinAndSelect('asset.exifInfo', 'exif') + .withDeleted() + .getQuery() + + ' INNER JOIN cte ON asset.id = cte."assetId"'; } async init(modelName: string): Promise { @@ -220,6 +230,27 @@ export class SearchRepository implements ISearchRepository { .getMany(); } + @GenerateSql({ params: [[DummyValue.UUID]] }) + async getAssetsByCity(userIds: string[]): Promise { + const parameters = [userIds.join(', '), true, false, AssetType.IMAGE]; + const rawRes = await this.repository.query(this.assetsByCityQuery, parameters); + + const items: AssetEntity[] = []; + for (const res of rawRes) { + const item = { exifInfo: {} as Record } as Record; + for (const [key, value] of Object.entries(res)) { + if (key.startsWith('exif_')) { + item.exifInfo[key.replace('exif_', '')] = value; + } else { + item[key.replace('asset_', '')] = value; + } + } + items.push(item as AssetEntity); + } + + return items; + } + async upsert(smartInfo: Partial, embedding?: Embedding): Promise { await this.repository.upsert(smartInfo, { conflictPaths: ['assetId'] }); if (!smartInfo.assetId || !embedding) { @@ -290,3 +321,30 @@ export class SearchRepository implements ISearchRepository { return runtimeConfig; } } + +// the performance difference between this and the normal way is too huge to ignore, e.g. 3s vs 4ms +const assetsByCityCte = ` +WITH RECURSIVE cte AS ( + ( + SELECT city, "assetId" + FROM exif + INNER JOIN assets ON exif."assetId" = assets.id + WHERE "ownerId" IN ($1) AND "isVisible" = $2 AND "isArchived" = $3 AND type = $4 + ORDER BY city + LIMIT 1 + ) + + UNION ALL + + SELECT l.city, l."assetId" + FROM cte c + , LATERAL ( + SELECT city, "assetId" + FROM exif + INNER JOIN assets ON exif."assetId" = assets.id + WHERE city > c.city AND "ownerId" IN ($1) AND "isVisible" = $2 AND "isArchived" = $3 AND type = $4 + ORDER BY city + LIMIT 1 + ) l +) +`; diff --git a/server/src/infra/sql/search.repository.sql b/server/src/infra/sql/search.repository.sql index a11f8805a..ff0239198 100644 --- a/server/src/infra/sql/search.repository.sql +++ b/server/src/infra/sql/search.repository.sql @@ -266,3 +266,111 @@ ORDER BY ) ASC LIMIT 20 + +-- SearchRepository.getAssetsByCity +WITH RECURSIVE + cte AS ( + ( + SELECT + city, + "assetId" + FROM + exif + INNER JOIN assets ON exif."assetId" = assets.id + WHERE + "ownerId" IN ($1) + AND "isVisible" = $2 + AND "isArchived" = $3 + AND type = $4 + ORDER BY + city + LIMIT + 1 + ) + UNION ALL + SELECT + l.city, + l."assetId" + FROM + cte c, + LATERAL ( + SELECT + city, + "assetId" + FROM + exif + INNER JOIN assets ON exif."assetId" = assets.id + WHERE + city > c.city + AND "ownerId" IN ($1) + AND "isVisible" = $2 + AND "isArchived" = $3 + AND type = $4 + ORDER BY + city + LIMIT + 1 + ) l + ) +SELECT + "asset"."id" AS "asset_id", + "asset"."deviceAssetId" AS "asset_deviceAssetId", + "asset"."ownerId" AS "asset_ownerId", + "asset"."libraryId" AS "asset_libraryId", + "asset"."deviceId" AS "asset_deviceId", + "asset"."type" AS "asset_type", + "asset"."originalPath" AS "asset_originalPath", + "asset"."resizePath" AS "asset_resizePath", + "asset"."webpPath" AS "asset_webpPath", + "asset"."thumbhash" AS "asset_thumbhash", + "asset"."encodedVideoPath" AS "asset_encodedVideoPath", + "asset"."createdAt" AS "asset_createdAt", + "asset"."updatedAt" AS "asset_updatedAt", + "asset"."deletedAt" AS "asset_deletedAt", + "asset"."fileCreatedAt" AS "asset_fileCreatedAt", + "asset"."localDateTime" AS "asset_localDateTime", + "asset"."fileModifiedAt" AS "asset_fileModifiedAt", + "asset"."isFavorite" AS "asset_isFavorite", + "asset"."isArchived" AS "asset_isArchived", + "asset"."isExternal" AS "asset_isExternal", + "asset"."isReadOnly" AS "asset_isReadOnly", + "asset"."isOffline" AS "asset_isOffline", + "asset"."checksum" AS "asset_checksum", + "asset"."duration" AS "asset_duration", + "asset"."isVisible" AS "asset_isVisible", + "asset"."livePhotoVideoId" AS "asset_livePhotoVideoId", + "asset"."originalFileName" AS "asset_originalFileName", + "asset"."sidecarPath" AS "asset_sidecarPath", + "asset"."stackId" AS "asset_stackId", + "exif"."assetId" AS "exif_assetId", + "exif"."description" AS "exif_description", + "exif"."exifImageWidth" AS "exif_exifImageWidth", + "exif"."exifImageHeight" AS "exif_exifImageHeight", + "exif"."fileSizeInByte" AS "exif_fileSizeInByte", + "exif"."orientation" AS "exif_orientation", + "exif"."dateTimeOriginal" AS "exif_dateTimeOriginal", + "exif"."modifyDate" AS "exif_modifyDate", + "exif"."timeZone" AS "exif_timeZone", + "exif"."latitude" AS "exif_latitude", + "exif"."longitude" AS "exif_longitude", + "exif"."projectionType" AS "exif_projectionType", + "exif"."city" AS "exif_city", + "exif"."livePhotoCID" AS "exif_livePhotoCID", + "exif"."autoStackId" AS "exif_autoStackId", + "exif"."state" AS "exif_state", + "exif"."country" AS "exif_country", + "exif"."make" AS "exif_make", + "exif"."model" AS "exif_model", + "exif"."lensModel" AS "exif_lensModel", + "exif"."fNumber" AS "exif_fNumber", + "exif"."focalLength" AS "exif_focalLength", + "exif"."iso" AS "exif_iso", + "exif"."exposureTime" AS "exif_exposureTime", + "exif"."profileDescription" AS "exif_profileDescription", + "exif"."colorspace" AS "exif_colorspace", + "exif"."bitsPerSample" AS "exif_bitsPerSample", + "exif"."fps" AS "exif_fps" +FROM + "assets" "asset" + INNER JOIN "exif" "exif" ON "exif"."assetId" = "asset"."id" + INNER JOIN cte ON asset.id = cte."assetId" diff --git a/server/test/repositories/asset.repository.mock.ts b/server/test/repositories/asset.repository.mock.ts index b291b7183..b9451f34f 100644 --- a/server/test/repositories/asset.repository.mock.ts +++ b/server/test/repositories/asset.repository.mock.ts @@ -24,7 +24,7 @@ export const newAssetRepositoryMock = (): jest.Mocked => { getLibraryAssetPaths: jest.fn(), getByLibraryIdAndOriginalPath: jest.fn(), deleteAll: jest.fn(), - save: jest.fn(), + update: jest.fn(), remove: jest.fn(), findLivePhotoMatch: jest.fn(), getMapMarkers: jest.fn(), diff --git a/server/test/repositories/search.repository.mock.ts b/server/test/repositories/search.repository.mock.ts index 5912d7745..7b428f0cc 100644 --- a/server/test/repositories/search.repository.mock.ts +++ b/server/test/repositories/search.repository.mock.ts @@ -8,6 +8,7 @@ export const newSearchRepositoryMock = (): jest.Mocked => { searchFaces: jest.fn(), upsert: jest.fn(), searchPlaces: jest.fn(), + getAssetsByCity: jest.fn(), deleteAllSearchEmbeddings: jest.fn(), }; }; diff --git a/web/src/lib/__mocks__/sdk.mock.ts b/web/src/lib/__mocks__/sdk.mock.ts new file mode 100644 index 000000000..a3e6f0f4d --- /dev/null +++ b/web/src/lib/__mocks__/sdk.mock.ts @@ -0,0 +1,18 @@ +import sdk from '@immich/sdk'; +import type { Mock, MockedObject } from 'vitest'; + +vi.mock('@immich/sdk', async (originalImport) => { + const module = await originalImport(); + + const mocks: Record = {}; + for (const [key, value] of Object.entries(module)) { + if (typeof value === 'function') { + mocks[key] = vi.fn(); + } + } + + const mock = { ...module, ...mocks }; + return { ...mock, default: mock }; +}); + +export const sdkMock = sdk as MockedObject; diff --git a/web/src/lib/components/album-page/__tests__/album-card.spec.ts b/web/src/lib/components/album-page/__tests__/album-card.spec.ts index b273271ce..6c6dc98f7 100644 --- a/web/src/lib/components/album-page/__tests__/album-card.spec.ts +++ b/web/src/lib/components/album-page/__tests__/album-card.spec.ts @@ -1,18 +1,11 @@ import { createObjectURLMock } from '$lib/__mocks__/jsdom-url.mock'; -import sdk, { ThumbnailFormat } from '@immich/sdk'; +import { sdkMock } from '$lib/__mocks__/sdk.mock'; +import { ThumbnailFormat } from '@immich/sdk'; import { albumFactory } from '@test-data'; import '@testing-library/jest-dom'; import { fireEvent, render, waitFor, type RenderResult } from '@testing-library/svelte'; -import type { MockedObject } from 'vitest'; import AlbumCard from '../album-card.svelte'; -vi.mock('@immich/sdk', async (originalImport) => { - const module = await originalImport(); - const mock = { ...module, getAssetThumbnail: vi.fn() }; - return { ...mock, default: mock }; -}); - -const sdkMock: MockedObject = sdk as MockedObject; const onShowContextMenu = vi.fn(); describe('AlbumCard component', () => { diff --git a/web/src/lib/components/shared-components/drag-and-drop-upload-overlay.svelte b/web/src/lib/components/shared-components/drag-and-drop-upload-overlay.svelte index c4ecb5fb1..234448d66 100644 --- a/web/src/lib/components/shared-components/drag-and-drop-upload-overlay.svelte +++ b/web/src/lib/components/shared-components/drag-and-drop-upload-overlay.svelte @@ -6,7 +6,9 @@ let dragStartTarget: EventTarget | null = null; const handleDragEnter = (e: DragEvent) => { - dragStartTarget = e.target; + if (e.dataTransfer && e.dataTransfer.types.includes('Files')) { + dragStartTarget = e.target; + } }; diff --git a/web/src/lib/stores/asset.store.spec.ts b/web/src/lib/stores/asset.store.spec.ts new file mode 100644 index 000000000..d97692ef6 --- /dev/null +++ b/web/src/lib/stores/asset.store.spec.ts @@ -0,0 +1,357 @@ +import { sdkMock } from '$lib/__mocks__/sdk.mock'; +import { TimeBucketSize, type AssetResponseDto } from '@immich/sdk'; +import { assetFactory } from '@test-data/factories/asset-factory'; +import { AssetStore, BucketPosition } from './assets.store'; + +describe('AssetStore', () => { + beforeEach(() => { + vi.resetAllMocks(); + }); + + describe('init', () => { + let assetStore: AssetStore; + const bucketAssets: Record = { + '2024-03-01T00:00:00.000Z': assetFactory.buildList(1), + '2024-02-01T00:00:00.000Z': assetFactory.buildList(100), + '2024-01-01T00:00:00.000Z': assetFactory.buildList(3), + }; + + beforeEach(async () => { + assetStore = new AssetStore({}); + sdkMock.getTimeBuckets.mockResolvedValue([ + { count: 1, timeBucket: '2024-03-01T00:00:00.000Z' }, + { count: 100, timeBucket: '2024-02-01T00:00:00.000Z' }, + { count: 3, timeBucket: '2024-01-01T00:00:00.000Z' }, + ]); + sdkMock.getTimeBucket.mockImplementation(({ timeBucket }) => Promise.resolve(bucketAssets[timeBucket])); + + await assetStore.init({ width: 1588, height: 1000 }); + }); + + it('should load buckets in viewport', () => { + expect(sdkMock.getTimeBuckets).toBeCalledTimes(1); + expect(sdkMock.getTimeBuckets).toBeCalledWith({ size: TimeBucketSize.Month }); + expect(sdkMock.getTimeBucket).toHaveBeenCalledTimes(2); + }); + + it('calculates bucket height', () => { + expect(assetStore.buckets).toEqual( + expect.arrayContaining([ + expect.objectContaining({ bucketDate: '2024-03-01T00:00:00.000Z', bucketHeight: 235 }), + expect.objectContaining({ bucketDate: '2024-02-01T00:00:00.000Z', bucketHeight: 3760 }), + expect.objectContaining({ bucketDate: '2024-01-01T00:00:00.000Z', bucketHeight: 235 }), + ]), + ); + }); + + it('calculates timeline height', () => { + expect(assetStore.timelineHeight).toBe(4230); + }); + }); + + describe('loadBucket', () => { + let assetStore: AssetStore; + const bucketAssets: Record = { + '2024-01-03T00:00:00.000Z': assetFactory.buildList(1), + '2024-01-01T00:00:00.000Z': assetFactory.buildList(3), + }; + + beforeEach(async () => { + assetStore = new AssetStore({}); + sdkMock.getTimeBuckets.mockResolvedValue([ + { count: 1, timeBucket: '2024-01-03T00:00:00.000Z' }, + { count: 3, timeBucket: '2024-01-01T00:00:00.000Z' }, + ]); + sdkMock.getTimeBucket.mockImplementation(({ timeBucket }) => Promise.resolve(bucketAssets[timeBucket])); + await assetStore.init({ width: 0, height: 0 }); + }); + + it('loads a bucket', async () => { + expect(assetStore.getBucketByDate('2024-01-01T00:00:00.000Z')?.assets.length).toEqual(0); + await assetStore.loadBucket('2024-01-01T00:00:00.000Z', BucketPosition.Visible); + expect(sdkMock.getTimeBucket).toBeCalledTimes(1); + expect(assetStore.getBucketByDate('2024-01-01T00:00:00.000Z')?.assets.length).toEqual(3); + }); + + it('ignores invalid buckets', async () => { + await assetStore.loadBucket('2023-01-01T00:00:00.000Z', BucketPosition.Visible); + expect(sdkMock.getTimeBucket).toBeCalledTimes(0); + }); + + it('only updates the position of loaded buckets', async () => { + await assetStore.loadBucket('2024-01-01T00:00:00.000Z', BucketPosition.Unknown); + expect(assetStore.getBucketByDate('2024-01-01T00:00:00.000Z')?.position).toEqual(BucketPosition.Unknown); + + await assetStore.loadBucket('2024-01-01T00:00:00.000Z', BucketPosition.Visible); + expect(assetStore.getBucketByDate('2024-01-01T00:00:00.000Z')?.position).toEqual(BucketPosition.Visible); + }); + + it('cancels bucket loading', async () => { + const abortSpy = vi.spyOn(AbortController.prototype, 'abort'); + const loadPromise = assetStore.loadBucket('2024-01-01T00:00:00.000Z', BucketPosition.Unknown); + + const bucket = assetStore.getBucketByDate('2024-01-01T00:00:00.000Z'); + expect(bucket).not.toBeNull(); + + assetStore.cancelBucket(bucket!); + expect(abortSpy).toBeCalledTimes(1); + await loadPromise; + expect(assetStore.getBucketByDate('2024-01-01T00:00:00.000Z')?.assets.length).toEqual(0); + }); + }); + + describe('addAssets', () => { + let assetStore: AssetStore; + + beforeEach(async () => { + assetStore = new AssetStore({}); + sdkMock.getTimeBuckets.mockResolvedValue([]); + await assetStore.init({ width: 1588, height: 1000 }); + }); + + it('is empty initially', () => { + expect(assetStore.buckets.length).toEqual(0); + expect(assetStore.assets.length).toEqual(0); + }); + + it('adds assets to new bucket', () => { + const asset = assetFactory.build({ fileCreatedAt: '2024-01-20T12:00:00.000Z' }); + assetStore.addAssets([asset]); + + expect(assetStore.buckets.length).toEqual(1); + expect(assetStore.assets.length).toEqual(1); + expect(assetStore.buckets[0].assets.length).toEqual(1); + expect(assetStore.buckets[0].bucketDate).toEqual('2024-01-01T00:00:00.000Z'); + expect(assetStore.assets[0].id).toEqual(asset.id); + }); + + it('adds assets to existing bucket', () => { + const [assetOne, assetTwo] = assetFactory.buildList(2, { fileCreatedAt: '2024-01-20T12:00:00.000Z' }); + assetStore.addAssets([assetOne]); + assetStore.addAssets([assetTwo]); + + expect(assetStore.buckets.length).toEqual(1); + expect(assetStore.assets.length).toEqual(2); + expect(assetStore.buckets[0].assets.length).toEqual(2); + expect(assetStore.buckets[0].bucketDate).toEqual('2024-01-01T00:00:00.000Z'); + }); + + it('orders assets in buckets by descending date', () => { + const assetOne = assetFactory.build({ fileCreatedAt: '2024-01-20T12:00:00.000Z' }); + const assetTwo = assetFactory.build({ fileCreatedAt: '2024-01-15T12:00:00.000Z' }); + const assetThree = assetFactory.build({ fileCreatedAt: '2024-01-16T12:00:00.000Z' }); + assetStore.addAssets([assetOne, assetTwo, assetThree]); + + const bucket = assetStore.getBucketByDate('2024-01-01T00:00:00.000Z'); + expect(bucket).not.toBeNull(); + expect(bucket?.assets.length).toEqual(3); + expect(bucket?.assets[0].id).toEqual(assetOne.id); + expect(bucket?.assets[1].id).toEqual(assetThree.id); + expect(bucket?.assets[2].id).toEqual(assetTwo.id); + }); + + it('orders buckets by descending date', () => { + const assetOne = assetFactory.build({ fileCreatedAt: '2024-01-20T12:00:00.000Z' }); + const assetTwo = assetFactory.build({ fileCreatedAt: '2024-04-20T12:00:00.000Z' }); + const assetThree = assetFactory.build({ fileCreatedAt: '2023-01-20T12:00:00.000Z' }); + assetStore.addAssets([assetOne, assetTwo, assetThree]); + + expect(assetStore.buckets.length).toEqual(3); + expect(assetStore.buckets[0].bucketDate).toEqual('2024-04-01T00:00:00.000Z'); + expect(assetStore.buckets[1].bucketDate).toEqual('2024-01-01T00:00:00.000Z'); + expect(assetStore.buckets[2].bucketDate).toEqual('2023-01-01T00:00:00.000Z'); + }); + + it('updates existing asset', () => { + const updateAssetsSpy = vi.spyOn(assetStore, 'updateAssets'); + const asset = assetFactory.build(); + assetStore.addAssets([asset]); + + assetStore.addAssets([asset]); + expect(updateAssetsSpy).toBeCalledWith([asset]); + expect(assetStore.assets.length).toEqual(1); + }); + }); + + describe('updateAssets', () => { + let assetStore: AssetStore; + + beforeEach(async () => { + assetStore = new AssetStore({}); + sdkMock.getTimeBuckets.mockResolvedValue([]); + await assetStore.init({ width: 1588, height: 1000 }); + }); + + it('ignores non-existing assets', () => { + assetStore.updateAssets([assetFactory.build()]); + + expect(assetStore.buckets.length).toEqual(0); + expect(assetStore.assets.length).toEqual(0); + }); + + it('updates an asset', () => { + const asset = assetFactory.build({ isFavorite: false }); + const updatedAsset = { ...asset, isFavorite: true }; + + assetStore.addAssets([asset]); + expect(assetStore.assets.length).toEqual(1); + expect(assetStore.assets[0].isFavorite).toEqual(false); + + assetStore.updateAssets([updatedAsset]); + expect(assetStore.assets.length).toEqual(1); + expect(assetStore.assets[0].isFavorite).toEqual(true); + }); + + it('replaces bucket date when asset date changes', () => { + const asset = assetFactory.build({ fileCreatedAt: '2024-01-20T12:00:00.000Z' }); + const updatedAsset = { ...asset, fileCreatedAt: '2024-03-20T12:00:00.000Z' }; + + assetStore.addAssets([asset]); + expect(assetStore.buckets.length).toEqual(1); + expect(assetStore.getBucketByDate('2024-01-01T00:00:00.000Z')).not.toBeNull(); + + assetStore.updateAssets([updatedAsset]); + expect(assetStore.buckets.length).toEqual(1); + expect(assetStore.getBucketByDate('2024-01-01T00:00:00.000Z')).toBeNull(); + expect(assetStore.getBucketByDate('2024-03-01T00:00:00.000Z')).not.toBeNull(); + }); + }); + + describe('removeAssets', () => { + let assetStore: AssetStore; + + beforeEach(async () => { + assetStore = new AssetStore({}); + sdkMock.getTimeBuckets.mockResolvedValue([]); + await assetStore.init({ width: 1588, height: 1000 }); + }); + + it('ignores invalid IDs', () => { + assetStore.addAssets(assetFactory.buildList(2, { fileCreatedAt: '2024-01-20T12:00:00.000Z' })); + assetStore.removeAssets(['', 'invalid', '4c7d9acc']); + + expect(assetStore.assets.length).toEqual(2); + expect(assetStore.buckets.length).toEqual(1); + expect(assetStore.buckets[0].assets.length).toEqual(2); + }); + + it('removes asset from bucket', () => { + const [assetOne, assetTwo] = assetFactory.buildList(2, { fileCreatedAt: '2024-01-20T12:00:00.000Z' }); + assetStore.addAssets([assetOne, assetTwo]); + assetStore.removeAssets([assetOne.id]); + + expect(assetStore.assets.length).toEqual(1); + expect(assetStore.buckets.length).toEqual(1); + expect(assetStore.buckets[0].assets.length).toEqual(1); + }); + + it('removes bucket when empty', () => { + const assets = assetFactory.buildList(2, { fileCreatedAt: '2024-01-20T12:00:00.000Z' }); + assetStore.addAssets(assets); + assetStore.removeAssets(assets.map((asset) => asset.id)); + + expect(assetStore.assets.length).toEqual(0); + expect(assetStore.buckets.length).toEqual(0); + }); + }); + + describe('getPreviousAssetId', () => { + let assetStore: AssetStore; + const bucketAssets: Record = { + '2024-03-01T00:00:00.000Z': assetFactory.buildList(1), + '2024-02-01T00:00:00.000Z': assetFactory.buildList(6), + '2024-01-01T00:00:00.000Z': assetFactory.buildList(3), + }; + + beforeEach(async () => { + assetStore = new AssetStore({}); + sdkMock.getTimeBuckets.mockResolvedValue([ + { count: 1, timeBucket: '2024-03-01T00:00:00.000Z' }, + { count: 6, timeBucket: '2024-02-01T00:00:00.000Z' }, + { count: 3, timeBucket: '2024-01-01T00:00:00.000Z' }, + ]); + sdkMock.getTimeBucket.mockImplementation(({ timeBucket }) => Promise.resolve(bucketAssets[timeBucket])); + + await assetStore.init({ width: 0, height: 0 }); + }); + + it('returns null for invalid assetId', async () => { + expect(() => assetStore.getPreviousAssetId('invalid')).not.toThrow(); + expect(await assetStore.getPreviousAssetId('invalid')).toBeNull(); + }); + + it('returns previous assetId', async () => { + await assetStore.loadBucket('2024-01-01T00:00:00.000Z', BucketPosition.Visible); + const bucket = assetStore.getBucketByDate('2024-01-01T00:00:00.000Z'); + + expect(await assetStore.getPreviousAssetId(bucket!.assets[1].id)).toEqual(bucket!.assets[0].id); + }); + + it('returns previous assetId spanning multiple buckets', async () => { + await assetStore.loadBucket('2024-02-01T00:00:00.000Z', BucketPosition.Visible); + await assetStore.loadBucket('2024-03-01T00:00:00.000Z', BucketPosition.Visible); + + const bucket = assetStore.getBucketByDate('2024-02-01T00:00:00.000Z'); + const previousBucket = assetStore.getBucketByDate('2024-03-01T00:00:00.000Z'); + expect(await assetStore.getPreviousAssetId(bucket!.assets[0].id)).toEqual(previousBucket!.assets[0].id); + }); + + it('loads previous bucket', async () => { + await assetStore.loadBucket('2024-02-01T00:00:00.000Z', BucketPosition.Visible); + + const loadBucketSpy = vi.spyOn(assetStore, 'loadBucket'); + const bucket = assetStore.getBucketByDate('2024-02-01T00:00:00.000Z'); + const previousBucket = assetStore.getBucketByDate('2024-03-01T00:00:00.000Z'); + expect(await assetStore.getPreviousAssetId(bucket!.assets[0].id)).toEqual(previousBucket!.assets[0].id); + expect(loadBucketSpy).toBeCalledTimes(1); + }); + + it('skips removed assets', async () => { + await assetStore.loadBucket('2024-01-01T00:00:00.000Z', BucketPosition.Visible); + await assetStore.loadBucket('2024-02-01T00:00:00.000Z', BucketPosition.Visible); + await assetStore.loadBucket('2024-03-01T00:00:00.000Z', BucketPosition.Visible); + + const [assetOne, assetTwo, assetThree] = assetStore.assets; + assetStore.removeAssets([assetTwo.id]); + expect(await assetStore.getPreviousAssetId(assetThree.id)).toEqual(assetOne.id); + }); + + it('returns null when no more assets', async () => { + await assetStore.loadBucket('2024-03-01T00:00:00.000Z', BucketPosition.Visible); + expect(await assetStore.getPreviousAssetId(assetStore.assets[0].id)).toBeNull(); + }); + }); + + describe('getBucketIndexByAssetId', () => { + let assetStore: AssetStore; + + beforeEach(async () => { + assetStore = new AssetStore({}); + sdkMock.getTimeBuckets.mockResolvedValue([]); + await assetStore.init({ width: 0, height: 0 }); + }); + + it('returns null for invalid buckets', () => { + expect(assetStore.getBucketByDate('invalid')).toBeNull(); + expect(assetStore.getBucketByDate('2024-03-01T00:00:00.000Z')).toBeNull(); + }); + + it('returns the bucket index', () => { + const assetOne = assetFactory.build({ fileCreatedAt: '2024-01-20T12:00:00.000Z' }); + const assetTwo = assetFactory.build({ fileCreatedAt: '2024-02-15T12:00:00.000Z' }); + assetStore.addAssets([assetOne, assetTwo]); + + expect(assetStore.getBucketIndexByAssetId(assetTwo.id)).toEqual(0); + expect(assetStore.getBucketIndexByAssetId(assetOne.id)).toEqual(1); + }); + + it('ignores removed buckets', () => { + const assetOne = assetFactory.build({ fileCreatedAt: '2024-01-20T12:00:00.000Z' }); + const assetTwo = assetFactory.build({ fileCreatedAt: '2024-02-15T12:00:00.000Z' }); + assetStore.addAssets([assetOne, assetTwo]); + + assetStore.removeAssets([assetTwo.id]); + expect(assetStore.getBucketIndexByAssetId(assetOne.id)).toEqual(0); + }); + }); +}); diff --git a/web/src/routes/(user)/places/+page.svelte b/web/src/routes/(user)/places/+page.svelte index c5528fcb9..01222ab6b 100644 --- a/web/src/routes/(user)/places/+page.svelte +++ b/web/src/routes/(user)/places/+page.svelte @@ -3,20 +3,20 @@ import Icon from '$lib/components/elements/icon.svelte'; import UserPageLayout from '$lib/components/layouts/user-page-layout.svelte'; import { AppRoute } from '$lib/constants'; - import type { SearchExploreResponseDto } from '@immich/sdk'; import { mdiMapMarkerOff } from '@mdi/js'; import type { PageData } from './$types'; import { getMetadataSearchQuery } from '$lib/utils/metadata-search'; + import type { AssetResponseDto } from '@immich/sdk'; export let data: PageData; - const CITY_FIELD = 'exifInfo.city'; - const getFieldItems = (items: SearchExploreResponseDto[]) => { - const targetField = items.find((item) => item.fieldName === CITY_FIELD); - return targetField?.items || []; + type AssetWithCity = AssetResponseDto & { + exifInfo: { + city: string; + }; }; - $: places = getFieldItems(data.items); + $: places = data.items.filter((item): item is AssetWithCity => !!item.exifInfo?.city); $: hasPlaces = places.length > 0; let innerHeight: number; @@ -27,17 +27,18 @@ {#if hasPlaces}
- {#each places as item (item.data.id)} - + {#each places as item (item.id)} + {@const city = item.exifInfo.city} +
- +
- {item.value} + {city}
{/each} diff --git a/web/src/routes/(user)/places/+page.ts b/web/src/routes/(user)/places/+page.ts index 5627111ce..1f3a15fb6 100644 --- a/web/src/routes/(user)/places/+page.ts +++ b/web/src/routes/(user)/places/+page.ts @@ -1,10 +1,10 @@ import { authenticate } from '$lib/utils/auth'; -import { getExploreData } from '@immich/sdk'; +import { getAssetsByCity } from '@immich/sdk'; import type { PageLoad } from './$types'; export const load = (async () => { await authenticate(); - const items = await getExploreData(); + const items = await getAssetsByCity(); return { items, diff --git a/web/src/test-data/factories/asset-factory.ts b/web/src/test-data/factories/asset-factory.ts new file mode 100644 index 000000000..32cb723c0 --- /dev/null +++ b/web/src/test-data/factories/asset-factory.ts @@ -0,0 +1,30 @@ +import { faker } from '@faker-js/faker'; +import { AssetTypeEnum, type AssetResponseDto } from '@immich/sdk'; +import { Sync } from 'factory.ts'; + +export const assetFactory = Sync.makeFactory({ + id: Sync.each(() => faker.string.uuid()), + deviceAssetId: Sync.each(() => faker.string.uuid()), + ownerId: Sync.each(() => faker.string.uuid()), + deviceId: '', + libraryId: Sync.each(() => faker.string.uuid()), + type: Sync.each(() => faker.helpers.enumValue(AssetTypeEnum)), + originalPath: Sync.each(() => faker.system.filePath()), + originalFileName: Sync.each(() => faker.system.fileName()), + resized: true, + thumbhash: Sync.each(() => faker.string.alphanumeric(28)), + fileCreatedAt: Sync.each(() => faker.date.past().toISOString()), + fileModifiedAt: Sync.each(() => faker.date.past().toISOString()), + localDateTime: Sync.each(() => faker.date.past().toISOString()), + updatedAt: Sync.each(() => faker.date.past().toISOString()), + isFavorite: Sync.each(() => faker.datatype.boolean()), + isArchived: Sync.each(() => faker.datatype.boolean()), + isTrashed: Sync.each(() => faker.datatype.boolean()), + duration: '0:00:00.00000', + checksum: Sync.each(() => faker.string.alphanumeric(28)), + isExternal: Sync.each(() => faker.datatype.boolean()), + isOffline: Sync.each(() => faker.datatype.boolean()), + isReadOnly: Sync.each(() => faker.datatype.boolean()), + hasMetadata: Sync.each(() => faker.datatype.boolean()), + stackCount: null, +});