From 2daed747cd62a0ec86dac8a71e070d31b1a95686 Mon Sep 17 00:00:00 2001 From: Mert <101130780+mertalev@users.noreply.github.com> Date: Tue, 19 Mar 2024 22:42:10 -0400 Subject: [PATCH 1/7] chore(server): change `save` -> `update` in asset repository (#8055) * `save` -> `update` * change return type * include relations * fix tests * remove when mocks * fix * stricter typing * simpler type --- server/src/domain/asset/asset.service.spec.ts | 8 +-- server/src/domain/asset/asset.service.ts | 14 ++++- server/src/domain/audit/audit.service.ts | 10 ++-- .../domain/library/library.service.spec.ts | 8 +-- server/src/domain/library/library.service.ts | 6 +- server/src/domain/media/media.service.spec.ts | 20 +++---- server/src/domain/media/media.service.ts | 10 ++-- .../domain/metadata/metadata.service.spec.ts | 60 +++++++++---------- .../src/domain/metadata/metadata.service.ts | 14 ++--- .../domain/repositories/asset.repository.ts | 23 ++++++- .../storage-template.service.spec.ts | 60 +++++-------------- server/src/domain/storage/storage.core.ts | 10 ++-- .../infra/repositories/asset.repository.ts | 21 ++----- .../repositories/asset.repository.mock.ts | 2 +- 14 files changed, 128 insertions(+), 138 deletions(-) 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 3b5258b97..0c0daa165 100644 --- a/server/src/domain/library/library.service.spec.ts +++ b/server/src/domain/library/library.service.spec.ts @@ -584,7 +584,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(); }); @@ -602,7 +602,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, @@ -631,7 +631,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); }); @@ -1257,7 +1257,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 000acac29..3ef59c919 100644 --- a/server/src/domain/library/library.service.ts +++ b/server/src/domain/library/library.service.ts @@ -173,7 +173,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); }; @@ -429,7 +429,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 @@ -462,7 +462,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; } 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 c4ddb3107..c504bbb7f 100644 --- a/server/src/domain/repositories/asset.repository.ts +++ b/server/src/domain/repositories/asset.repository.ts @@ -90,6 +90,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; @@ -138,8 +157,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/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/infra/repositories/asset.repository.ts b/server/src/infra/repositories/asset.repository.ts index 09cf2c779..3af6eb150 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/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(), From f392fe7702ebb09773bf8cb6a08a369ef80f5ce5 Mon Sep 17 00:00:00 2001 From: Mert <101130780+mertalev@users.noreply.github.com> Date: Tue, 19 Mar 2024 23:23:57 -0400 Subject: [PATCH 2/7] fix(server): "view all" for cities only showing 12 cities (#8035) * view all cities * increase limit * rename endpoint * optimize query * remove pagination * update sql * linting * revert sort by count in explore page for now * fix query * fix * update sql * move to search, add partner support * update sql * pr feedback * euphemism * parameters as separate variable * move comment * update sql * linting --- mobile/openapi/README.md | 1 + mobile/openapi/doc/SearchApi.md | 52 +++++++++ mobile/openapi/lib/api/search_api.dart | 44 +++++++ mobile/openapi/test/search_api_test.dart | 5 + open-api/immich-openapi-specs.json | 35 ++++++ open-api/typescript-sdk/src/fetch-client.ts | 8 ++ .../domain/repositories/search.repository.ts | 1 + server/src/domain/search/search.service.ts | 46 ++++---- .../immich/controllers/search.controller.ts | 6 + .../infra/repositories/search.repository.ts | 58 ++++++++++ server/src/infra/sql/search.repository.sql | 108 ++++++++++++++++++ .../repositories/search.repository.mock.ts | 1 + web/src/routes/(user)/places/+page.svelte | 21 ++-- web/src/routes/(user)/places/+page.ts | 4 +- 14 files changed, 358 insertions(+), 32 deletions(-) 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/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/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/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/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/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/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, From 63b4fc6f6582396918803555f34bcfce82a4ace8 Mon Sep 17 00:00:00 2001 From: Alex Date: Tue, 19 Mar 2024 23:07:26 -0500 Subject: [PATCH 3/7] chore(mobile): svg logo (#8074) * chore(mobile): anti-aliasing logo * use svg * adjust height * better sizing --- mobile/assets/immich-logo-inline-dark.svg | 56 ++++++++++++++++++++++ mobile/assets/immich-logo-inline-light.svg | 54 +++++++++++++++++++++ mobile/ios/Podfile.lock | 2 +- mobile/lib/shared/ui/immich_app_bar.dart | 9 ++-- mobile/lib/shared/ui/immich_logo.dart | 1 + mobile/pubspec.lock | 40 ++++++++++++++++ mobile/pubspec.yaml | 1 + 7 files changed, 158 insertions(+), 5 deletions(-) create mode 100644 mobile/assets/immich-logo-inline-dark.svg create mode 100644 mobile/assets/immich-logo-inline-light.svg 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/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 From 7395b03b1f99f659b3bc20e31bc1d6bf32cc21fa Mon Sep 17 00:00:00 2001 From: Thariq Shanavas Date: Tue, 19 Mar 2024 22:12:36 -0600 Subject: [PATCH 4/7] fix(docs) minor security warning raised by Borg (#8075) * Fix minor borg security warning * Update template-backup-script.md * removed one unnecessary step * Clarified optional steps * Update template-backup-script.md --- docs/docs/guides/template-backup-script.md | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) 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. From f908bd4a645306c89c5f90be41dbc7d5273fc308 Mon Sep 17 00:00:00 2001 From: Ethan Margaillan Date: Wed, 20 Mar 2024 05:28:13 +0100 Subject: [PATCH 5/7] fix(web): prevent drag-n-drop upload overlay from showing when not dragging files (#8082) --- .../shared-components/drag-and-drop-upload-overlay.svelte | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) 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; + } }; From e6f2bb9f89c8f9b3010eec2d31f3c684d7d3bbae Mon Sep 17 00:00:00 2001 From: Jonathan Jogenfors Date: Wed, 20 Mar 2024 05:40:28 +0100 Subject: [PATCH 6/7] fix(server): use extension in originalFileName for libraries (#8083) * use file base * fix: test * fix: e2e-job tests --------- Co-authored-by: Alex Tran --- server/e2e/jobs/specs/library.e2e-spec.ts | 14 +++++++------- server/src/domain/library/library.service.spec.ts | 6 +++--- server/src/domain/library/library.service.ts | 2 +- 3 files changed, 11 insertions(+), 11 deletions(-) diff --git a/server/e2e/jobs/specs/library.e2e-spec.ts b/server/e2e/jobs/specs/library.e2e-spec.ts index a4ee4977a..75411e8fa 100644 --- a/server/e2e/jobs/specs/library.e2e-spec.ts +++ b/server/e2e/jobs/specs/library.e2e-spec.ts @@ -61,11 +61,11 @@ describe(`${LibraryController.name} (e2e)`, () => { expect.arrayContaining([ expect.objectContaining({ isOffline: true, - originalFileName: 'el_torcal_rocks', + originalFileName: 'el_torcal_rocks.jpg', }), expect.objectContaining({ isOffline: true, - originalFileName: 'tanners_ridge', + originalFileName: 'tanners_ridge.jpg', }), ]), ); @@ -97,10 +97,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', }), ]), ); @@ -137,7 +137,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, @@ -184,7 +184,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', }), @@ -224,7 +224,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/library/library.service.spec.ts b/server/src/domain/library/library.service.spec.ts index 0c0daa165..bd57a684d 100644 --- a/server/src/domain/library/library.service.spec.ts +++ b/server/src/domain/library/library.service.spec.ts @@ -377,7 +377,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, @@ -425,7 +425,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, @@ -472,7 +472,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, diff --git a/server/src/domain/library/library.service.ts b/server/src/domain/library/library.service.ts index 3ef59c919..00667539b 100644 --- a/server/src/domain/library/library.service.ts +++ b/server/src/domain/library/library.service.ts @@ -511,7 +511,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, From 9c6a26de9ff7a5e796a56696e9ab8e944a95e793 Mon Sep 17 00:00:00 2001 From: Michel Heusschen <59014050+michelheusschen@users.noreply.github.com> Date: Wed, 20 Mar 2024 05:41:31 +0100 Subject: [PATCH 7/7] chore(web): add asset store unit tests (#8077) chore(web): asset store unit tests --- web/src/lib/__mocks__/sdk.mock.ts | 18 + .../album-page/__tests__/album-card.spec.ts | 11 +- web/src/lib/stores/asset.store.spec.ts | 357 ++++++++++++++++++ web/src/test-data/factories/asset-factory.ts | 30 ++ 4 files changed, 407 insertions(+), 9 deletions(-) create mode 100644 web/src/lib/__mocks__/sdk.mock.ts create mode 100644 web/src/lib/stores/asset.store.spec.ts create mode 100644 web/src/test-data/factories/asset-factory.ts 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/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/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, +});