diff --git a/server/src/cores/storage.core.ts b/server/src/cores/storage.core.ts index dd3edd3ab1..9bd43a662d 100644 --- a/server/src/cores/storage.core.ts +++ b/server/src/cores/storage.core.ts @@ -28,7 +28,7 @@ export interface MoveRequest { export type GeneratedImageType = AssetPathType.PREVIEW | AssetPathType.THUMBNAIL | AssetPathType.FULLSIZE; export type GeneratedAssetType = GeneratedImageType | AssetPathType.ENCODED_VIDEO; -type ThumbnailPathEntity = { id: string; ownerId: string }; +export type ThumbnailPathEntity = { id: string; ownerId: string }; let instance: StorageCore | null; diff --git a/server/src/database.ts b/server/src/database.ts index 7766a13f02..b504d39579 100644 --- a/server/src/database.ts +++ b/server/src/database.ts @@ -263,6 +263,24 @@ export type AssetJobStatus = Selectable & { const userColumns = ['id', 'name', 'email', 'profileImagePath', 'profileChangedAt'] as const; export const columns = { + asset: [ + 'assets.id', + 'assets.checksum', + 'assets.deviceAssetId', + 'assets.deviceId', + 'assets.fileCreatedAt', + 'assets.fileModifiedAt', + 'assets.isExternal', + 'assets.isVisible', + 'assets.libraryId', + 'assets.livePhotoVideoId', + 'assets.localDateTime', + 'assets.originalFileName', + 'assets.originalPath', + 'assets.ownerId', + 'assets.sidecarPath', + 'assets.type', + ], assetFiles: ['asset_files.id', 'asset_files.path', 'asset_files.type'], authUser: [ 'users.id', diff --git a/server/src/entities/asset.entity.ts b/server/src/entities/asset.entity.ts index 19e8de6702..b17328f790 100644 --- a/server/src/entities/asset.entity.ts +++ b/server/src/entities/asset.entity.ts @@ -56,13 +56,13 @@ export class AssetEntity { export function withExif(qb: SelectQueryBuilder) { return qb .leftJoin('exif', 'assets.id', 'exif.assetId') - .select((eb) => eb.fn.toJson(eb.table('exif')).$castTo().as('exifInfo')); + .select((eb) => eb.fn.toJson(eb.table('exif')).$castTo().as('exifInfo')); } export function withExifInner(qb: SelectQueryBuilder) { return qb .innerJoin('exif', 'assets.id', 'exif.assetId') - .select((eb) => eb.fn.toJson(eb.table('exif')).as('exifInfo')); + .select((eb) => eb.fn.toJson(eb.table('exif')).$castTo().as('exifInfo')); } export function withSmartSearch(qb: SelectQueryBuilder) { diff --git a/server/src/queries/asset.job.repository.sql b/server/src/queries/asset.job.repository.sql index 15172c55eb..5e28f2c4c9 100644 --- a/server/src/queries/asset.job.repository.sql +++ b/server/src/queries/asset.job.repository.sql @@ -115,6 +115,74 @@ from where "assets"."id" = $1 +-- AssetJobRepository.getForGenerateThumbnailJob +select + "assets"."id", + "assets"."isVisible", + "assets"."originalFileName", + "assets"."originalPath", + "assets"."ownerId", + "assets"."thumbhash", + "assets"."type", + ( + select + coalesce(json_agg(agg), '[]') + from + ( + select + "asset_files"."id", + "asset_files"."path", + "asset_files"."type" + from + "asset_files" + where + "asset_files"."assetId" = "assets"."id" + ) as agg + ) as "files", + to_json("exif") as "exifInfo" +from + "assets" + inner join "exif" on "assets"."id" = "exif"."assetId" +where + "assets"."id" = $1 + +-- AssetJobRepository.getForMetadataExtraction +select + "assets"."id", + "assets"."checksum", + "assets"."deviceAssetId", + "assets"."deviceId", + "assets"."fileCreatedAt", + "assets"."fileModifiedAt", + "assets"."isExternal", + "assets"."isVisible", + "assets"."libraryId", + "assets"."livePhotoVideoId", + "assets"."localDateTime", + "assets"."originalFileName", + "assets"."originalPath", + "assets"."ownerId", + "assets"."sidecarPath", + "assets"."type", + ( + select + coalesce(json_agg(agg), '[]') + from + ( + select + "asset_faces".* + from + "asset_faces" + where + "asset_faces"."assetId" = "assets"."id" + and "asset_faces"."deletedAt" is null + ) as agg + ) as "faces" +from + "assets" +where + "assets"."id" = $1 + -- AssetJobRepository.getForStorageTemplateJob select "assets"."id", diff --git a/server/src/repositories/asset-job.repository.ts b/server/src/repositories/asset-job.repository.ts index 3824c13519..770ca93891 100644 --- a/server/src/repositories/asset-job.repository.ts +++ b/server/src/repositories/asset-job.repository.ts @@ -2,9 +2,10 @@ import { Injectable } from '@nestjs/common'; import { Kysely } from 'kysely'; import { jsonArrayFrom } from 'kysely/helpers/postgres'; import { InjectKysely } from 'nestjs-kysely'; +import { columns } from 'src/database'; import { DB } from 'src/db'; import { DummyValue, GenerateSql } from 'src/decorators'; -import { withFiles } from 'src/entities/asset.entity'; +import { withExifInner, withFaces, withFiles } from 'src/entities/asset.entity'; import { AssetFileType } from 'src/enum'; import { StorageAsset } from 'src/types'; import { asUuid } from 'src/utils/database'; @@ -87,6 +88,35 @@ export class AssetJobRepository { .executeTakeFirst(); } + @GenerateSql({ params: [DummyValue.UUID] }) + getForGenerateThumbnailJob(id: string) { + return this.db + .selectFrom('assets') + .select([ + 'assets.id', + 'assets.isVisible', + 'assets.originalFileName', + 'assets.originalPath', + 'assets.ownerId', + 'assets.thumbhash', + 'assets.type', + ]) + .select(withFiles) + .$call(withExifInner) + .where('assets.id', '=', id) + .executeTakeFirst(); + } + + @GenerateSql({ params: [DummyValue.UUID] }) + getForMetadataExtraction(id: string) { + return this.db + .selectFrom('assets') + .select(columns.asset) + .select(withFaces) + .where('assets.id', '=', id) + .executeTakeFirst(); + } + private storageTemplateAssetQuery() { return this.db .selectFrom('assets') diff --git a/server/src/services/media.service.spec.ts b/server/src/services/media.service.spec.ts index 6fe14b954b..8990ad86a6 100644 --- a/server/src/services/media.service.spec.ts +++ b/server/src/services/media.service.spec.ts @@ -2,7 +2,6 @@ import { OutputInfo } from 'sharp'; import { SystemConfig } from 'src/config'; import { Exif } from 'src/database'; import { AssetMediaSize } from 'src/dtos/asset-media.dto'; -import { AssetEntity } from 'src/entities/asset.entity'; import { AssetFileType, AssetPathType, @@ -249,6 +248,7 @@ describe(MediaService.name, () => { }); it('should skip thumbnail generation if asset not found', async () => { + mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(void 0); await sut.handleGenerateThumbnails({ id: assetStub.image.id }); expect(mocks.media.generateThumbnail).not.toHaveBeenCalled(); @@ -256,7 +256,7 @@ describe(MediaService.name, () => { }); it('should skip thumbnail generation if asset type is unknown', async () => { - mocks.asset.getById.mockResolvedValue({ ...assetStub.image, type: 'foo' } as never as AssetEntity); + mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue({ ...assetStub.image, type: 'foo' as AssetType }); await expect(sut.handleGenerateThumbnails({ id: assetStub.image.id })).resolves.toBe(JobStatus.SKIPPED); expect(mocks.media.probe).not.toHaveBeenCalled(); @@ -266,14 +266,14 @@ describe(MediaService.name, () => { it('should skip video thumbnail generation if no video stream', async () => { mocks.media.probe.mockResolvedValue(probeStub.noVideoStreams); - mocks.asset.getById.mockResolvedValue(assetStub.video); + mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(assetStub.video); await expect(sut.handleGenerateThumbnails({ id: assetStub.video.id })).rejects.toThrowError(); expect(mocks.media.generateThumbnail).not.toHaveBeenCalled(); expect(mocks.asset.update).not.toHaveBeenCalledWith(); }); it('should skip invisible assets', async () => { - mocks.asset.getById.mockResolvedValue(assetStub.livePhotoMotionAsset); + mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(assetStub.livePhotoMotionAsset); expect(await sut.handleGenerateThumbnails({ id: assetStub.livePhotoMotionAsset.id })).toEqual(JobStatus.SKIPPED); @@ -283,7 +283,7 @@ describe(MediaService.name, () => { it('should delete previous preview if different path', async () => { mocks.systemMetadata.get.mockResolvedValue({ image: { thumbnail: { format: ImageFormat.WEBP } } }); - mocks.asset.getById.mockResolvedValue(assetStub.image); + mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(assetStub.image); await sut.handleGenerateThumbnails({ id: assetStub.image.id }); @@ -291,7 +291,7 @@ describe(MediaService.name, () => { }); it('should generate P3 thumbnails for a wide gamut image', async () => { - mocks.asset.getById.mockResolvedValue({ + mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue({ ...assetStub.image, exifInfo: { profileDescription: 'Adobe RGB', bitsPerSample: 14 } as Exif, }); @@ -359,7 +359,7 @@ describe(MediaService.name, () => { it('should generate a thumbnail for a video', async () => { mocks.media.probe.mockResolvedValue(probeStub.videoStream2160p); - mocks.asset.getById.mockResolvedValue(assetStub.video); + mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(assetStub.video); await sut.handleGenerateThumbnails({ id: assetStub.video.id }); expect(mocks.storage.mkdirSync).toHaveBeenCalledWith('upload/thumbs/user-id/as/se'); @@ -394,7 +394,7 @@ describe(MediaService.name, () => { it('should tonemap thumbnail for hdr video', async () => { mocks.media.probe.mockResolvedValue(probeStub.videoStreamHDR); - mocks.asset.getById.mockResolvedValue(assetStub.video); + mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(assetStub.video); await sut.handleGenerateThumbnails({ id: assetStub.video.id }); expect(mocks.storage.mkdirSync).toHaveBeenCalledWith('upload/thumbs/user-id/as/se'); @@ -432,7 +432,7 @@ describe(MediaService.name, () => { mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { twoPass: true, maxBitrate: '5000k' }, }); - mocks.asset.getById.mockResolvedValue(assetStub.video); + mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(assetStub.video); await sut.handleGenerateThumbnails({ id: assetStub.video.id }); expect(mocks.media.transcode).toHaveBeenCalledWith( @@ -453,7 +453,7 @@ describe(MediaService.name, () => { }); it('should not skip intra frames for MTS file', async () => { mocks.media.probe.mockResolvedValue(probeStub.videoStreamMTS); - mocks.asset.getById.mockResolvedValue(assetStub.video); + mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(assetStub.video); await sut.handleGenerateThumbnails({ id: assetStub.video.id }); expect(mocks.media.transcode).toHaveBeenCalledWith( @@ -471,7 +471,7 @@ describe(MediaService.name, () => { it('should use scaling divisible by 2 even when using quick sync', async () => { mocks.media.probe.mockResolvedValue(probeStub.videoStream2160p); mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.QSV } }); - mocks.asset.getById.mockResolvedValue(assetStub.video); + mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(assetStub.video); await sut.handleGenerateThumbnails({ id: assetStub.video.id }); expect(mocks.media.transcode).toHaveBeenCalledWith( @@ -487,7 +487,7 @@ describe(MediaService.name, () => { it.each(Object.values(ImageFormat))('should generate an image preview in %s format', async (format) => { mocks.systemMetadata.get.mockResolvedValue({ image: { preview: { format } } }); - mocks.asset.getById.mockResolvedValue(assetStub.image); + mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(assetStub.image); const thumbhashBuffer = Buffer.from('a thumbhash', 'utf8'); mocks.media.generateThumbhash.mockResolvedValue(thumbhashBuffer); const previewPath = `upload/thumbs/user-id/as/se/asset-id-preview.${format}`; @@ -532,7 +532,7 @@ describe(MediaService.name, () => { it.each(Object.values(ImageFormat))('should generate an image thumbnail in %s format', async (format) => { mocks.systemMetadata.get.mockResolvedValue({ image: { thumbnail: { format } } }); - mocks.asset.getById.mockResolvedValue(assetStub.image); + mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(assetStub.image); const thumbhashBuffer = Buffer.from('a thumbhash', 'utf8'); mocks.media.generateThumbhash.mockResolvedValue(thumbhashBuffer); const previewPath = `upload/thumbs/user-id/as/se/asset-id-preview.jpeg`; @@ -577,7 +577,7 @@ describe(MediaService.name, () => { it('should delete previous thumbnail if different path', async () => { mocks.systemMetadata.get.mockResolvedValue({ image: { thumbnail: { format: ImageFormat.WEBP } } }); - mocks.asset.getById.mockResolvedValue(assetStub.image); + mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(assetStub.image); await sut.handleGenerateThumbnails({ id: assetStub.image.id }); @@ -588,7 +588,7 @@ describe(MediaService.name, () => { mocks.media.extract.mockResolvedValue(true); mocks.media.getImageDimensions.mockResolvedValue({ width: 3840, height: 2160 }); mocks.systemMetadata.get.mockResolvedValue({ image: { extractEmbedded: true } }); - mocks.asset.getById.mockResolvedValue(assetStub.imageDng); + mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(assetStub.imageDng); await sut.handleGenerateThumbnails({ id: assetStub.image.id }); @@ -605,7 +605,7 @@ describe(MediaService.name, () => { mocks.media.extract.mockResolvedValue(true); mocks.media.getImageDimensions.mockResolvedValue({ width: 1000, height: 1000 }); mocks.systemMetadata.get.mockResolvedValue({ image: { extractEmbedded: true } }); - mocks.asset.getById.mockResolvedValue(assetStub.imageDng); + mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(assetStub.imageDng); await sut.handleGenerateThumbnails({ id: assetStub.image.id }); @@ -621,7 +621,7 @@ describe(MediaService.name, () => { it('should resize original image if embedded image not found', async () => { mocks.systemMetadata.get.mockResolvedValue({ image: { extractEmbedded: true } }); - mocks.asset.getById.mockResolvedValue(assetStub.imageDng); + mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(assetStub.imageDng); await sut.handleGenerateThumbnails({ id: assetStub.image.id }); @@ -636,7 +636,7 @@ describe(MediaService.name, () => { it('should resize original image if embedded image extraction is not enabled', async () => { mocks.systemMetadata.get.mockResolvedValue({ image: { extractEmbedded: false } }); - mocks.asset.getById.mockResolvedValue(assetStub.imageDng); + mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(assetStub.imageDng); await sut.handleGenerateThumbnails({ id: assetStub.image.id }); @@ -653,7 +653,7 @@ describe(MediaService.name, () => { it('should process invalid images if enabled', async () => { vi.stubEnv('IMMICH_PROCESS_INVALID_IMAGES', 'true'); - mocks.asset.getById.mockResolvedValue(assetStub.imageDng); + mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(assetStub.imageDng); await sut.handleGenerateThumbnails({ id: assetStub.image.id }); @@ -689,7 +689,7 @@ describe(MediaService.name, () => { mocks.systemMetadata.get.mockResolvedValue({ image: { fullsize: { enabled: true }, extractEmbedded: true } }); mocks.media.extract.mockResolvedValue(true); mocks.media.getImageDimensions.mockResolvedValue({ width: 3840, height: 2160 }); - mocks.asset.getById.mockResolvedValue(assetStub.imageDng); + mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(assetStub.imageDng); await sut.handleGenerateThumbnails({ id: assetStub.image.id }); @@ -719,7 +719,7 @@ describe(MediaService.name, () => { mocks.systemMetadata.get.mockResolvedValue({ image: { fullsize: { enabled: true }, extractEmbedded: false } }); mocks.media.extract.mockResolvedValue(true); mocks.media.getImageDimensions.mockResolvedValue({ width: 3840, height: 2160 }); - mocks.asset.getById.mockResolvedValue(assetStub.imageDng); + mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(assetStub.imageDng); await sut.handleGenerateThumbnails({ id: assetStub.image.id }); @@ -760,7 +760,7 @@ describe(MediaService.name, () => { mocks.media.extract.mockResolvedValue(true); mocks.media.getImageDimensions.mockResolvedValue({ width: 3840, height: 2160 }); // HEIF/HIF image taken by cameras are not web-friendly, only has limited support on Safari. - mocks.asset.getById.mockResolvedValue(assetStub.imageHif); + mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(assetStub.imageHif); await sut.handleGenerateThumbnails({ id: assetStub.image.id }); @@ -788,7 +788,7 @@ describe(MediaService.name, () => { mocks.systemMetadata.get.mockResolvedValue({ image: { fullsize: { enabled: true } } }); mocks.media.extract.mockResolvedValue(true); mocks.media.getImageDimensions.mockResolvedValue({ width: 3840, height: 2160 }); - mocks.asset.getById.mockResolvedValue(assetStub.image); + mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(assetStub.image); await sut.handleGenerateThumbnails({ id: assetStub.image.id }); @@ -814,7 +814,7 @@ describe(MediaService.name, () => { mocks.media.extract.mockResolvedValue(true); mocks.media.getImageDimensions.mockResolvedValue({ width: 3840, height: 2160 }); // HEIF/HIF image taken by cameras are not web-friendly, only has limited support on Safari. - mocks.asset.getById.mockResolvedValue(assetStub.imageHif); + mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(assetStub.imageHif); await sut.handleGenerateThumbnails({ id: assetStub.image.id }); diff --git a/server/src/services/media.service.ts b/server/src/services/media.service.ts index f855168898..59d708772b 100644 --- a/server/src/services/media.service.ts +++ b/server/src/services/media.service.ts @@ -1,9 +1,9 @@ import { Injectable } from '@nestjs/common'; import { JOBS_ASSET_PAGINATION_SIZE } from 'src/constants'; -import { StorageCore } from 'src/cores/storage.core'; +import { StorageCore, ThumbnailPathEntity } from 'src/cores/storage.core'; +import { Exif } from 'src/database'; import { OnEvent, OnJob } from 'src/decorators'; import { SystemConfigFFmpegDto } from 'src/dtos/system-config.dto'; -import { AssetEntity } from 'src/entities/asset.entity'; import { AssetFileType, AssetPathType, @@ -136,7 +136,7 @@ export class MediaService extends BaseService { @OnJob({ name: JobName.GENERATE_THUMBNAILS, queue: QueueName.THUMBNAIL_GENERATION }) async handleGenerateThumbnails({ id }: JobOf): Promise { - const asset = await this.assetRepository.getById(id, { exifInfo: true, files: true }); + const asset = await this.assetJobRepository.getForGenerateThumbnailJob(id); if (!asset) { this.logger.warn(`Thumbnail generation failed for asset ${id}: not found`); return JobStatus.FAILED; @@ -213,7 +213,13 @@ export class MediaService extends BaseService { return JobStatus.SUCCESS; } - private async generateImageThumbnails(asset: AssetEntity) { + private async generateImageThumbnails(asset: { + id: string; + ownerId: string; + originalFileName: string; + originalPath: string; + exifInfo: Exif; + }) { const { image } = await this.getConfig({ withCache: true }); const previewPath = StorageCore.getImagePath(asset, AssetPathType.PREVIEW, image.preview.format); const thumbnailPath = StorageCore.getImagePath(asset, AssetPathType.THUMBNAIL, image.thumbnail.format); @@ -286,7 +292,7 @@ export class MediaService extends BaseService { return { previewPath, thumbnailPath, fullsizePath, thumbhash: outputs[0] as Buffer }; } - private async generateVideoThumbnails(asset: AssetEntity) { + private async generateVideoThumbnails(asset: ThumbnailPathEntity & { originalPath: string }) { const { image, ffmpeg } = await this.getConfig({ withCache: true }); const previewPath = StorageCore.getImagePath(asset, AssetPathType.PREVIEW, image.preview.format); const thumbnailPath = StorageCore.getImagePath(asset, AssetPathType.THUMBNAIL, image.thumbnail.format); @@ -515,8 +521,8 @@ export class MediaService extends BaseService { return name !== VideoContainer.MP4 && !ffmpegConfig.acceptedContainers.includes(name); } - isSRGB(asset: AssetEntity): boolean { - const { colorspace, profileDescription, bitsPerSample } = asset.exifInfo ?? {}; + isSRGB(asset: { exifInfo: Exif }): boolean { + const { colorspace, profileDescription, bitsPerSample } = asset.exifInfo; if (colorspace || profileDescription) { return [colorspace, profileDescription].some((s) => s?.toLowerCase().includes('srgb')); } else if (bitsPerSample) { diff --git a/server/src/services/metadata.service.spec.ts b/server/src/services/metadata.service.spec.ts index 358c0eea5e..ca1277a8c8 100644 --- a/server/src/services/metadata.service.spec.ts +++ b/server/src/services/metadata.service.spec.ts @@ -3,7 +3,6 @@ import { randomBytes } from 'node:crypto'; import { Stats } from 'node:fs'; import { constants } from 'node:fs/promises'; import { defaults } from 'src/config'; -import { Exif } from 'src/database'; import { AssetEntity } from 'src/entities/asset.entity'; import { AssetType, ExifOrientation, ImmichWorker, JobName, JobStatus, SourceType } from 'src/enum'; import { WithoutProperty } from 'src/repositories/asset.repository'; @@ -144,9 +143,10 @@ describe(MetadataService.name, () => { }); it('should handle an asset that could not be found', async () => { + mocks.assetJob.getForMetadataExtraction.mockResolvedValue(void 0); await expect(sut.handleMetadataExtraction({ id: assetStub.image.id })).resolves.toBe(JobStatus.FAILED); - expect(mocks.asset.getByIds).toHaveBeenCalledWith([assetStub.image.id], { faces: { person: false } }); + expect(mocks.assetJob.getForMetadataExtraction).toHaveBeenCalledWith(assetStub.image.id); expect(mocks.asset.upsertExif).not.toHaveBeenCalled(); expect(mocks.asset.update).not.toHaveBeenCalled(); }); @@ -154,11 +154,11 @@ describe(MetadataService.name, () => { it('should handle a date in a sidecar file', async () => { const originalDate = new Date('2023-11-21T16:13:17.517Z'); const sidecarDate = new Date('2022-01-01T00:00:00.000Z'); - mocks.asset.getByIds.mockResolvedValue([assetStub.sidecar]); + mocks.assetJob.getForMetadataExtraction.mockResolvedValue(assetStub.sidecar); mockReadTags({ CreationDate: originalDate.toISOString() }, { CreationDate: sidecarDate.toISOString() }); await sut.handleMetadataExtraction({ id: assetStub.image.id }); - expect(mocks.asset.getByIds).toHaveBeenCalledWith([assetStub.sidecar.id], { faces: { person: false } }); + expect(mocks.assetJob.getForMetadataExtraction).toHaveBeenCalledWith(assetStub.sidecar.id); expect(mocks.asset.upsertExif).toHaveBeenCalledWith(expect.objectContaining({ dateTimeOriginal: sidecarDate })); expect(mocks.asset.update).toHaveBeenCalledWith( expect.objectContaining({ @@ -173,7 +173,7 @@ describe(MetadataService.name, () => { it('should take the file modification date when missing exif and earlier than creation date', async () => { const fileCreatedAt = new Date('2022-01-01T00:00:00.000Z'); const fileModifiedAt = new Date('2021-01-01T00:00:00.000Z'); - mocks.asset.getByIds.mockResolvedValue([assetStub.image]); + mocks.assetJob.getForMetadataExtraction.mockResolvedValue(assetStub.image); mocks.storage.stat.mockResolvedValue({ size: 123_456, mtime: fileModifiedAt, @@ -183,7 +183,7 @@ describe(MetadataService.name, () => { mockReadTags(); await sut.handleMetadataExtraction({ id: assetStub.image.id }); - expect(mocks.asset.getByIds).toHaveBeenCalledWith([assetStub.image.id], { faces: { person: false } }); + expect(mocks.assetJob.getForMetadataExtraction).toHaveBeenCalledWith(assetStub.image.id); expect(mocks.asset.upsertExif).toHaveBeenCalledWith( expect.objectContaining({ dateTimeOriginal: fileModifiedAt }), ); @@ -199,7 +199,7 @@ describe(MetadataService.name, () => { it('should take the file creation date when missing exif and earlier than modification date', async () => { const fileCreatedAt = new Date('2021-01-01T00:00:00.000Z'); const fileModifiedAt = new Date('2022-01-01T00:00:00.000Z'); - mocks.asset.getByIds.mockResolvedValue([assetStub.image]); + mocks.assetJob.getForMetadataExtraction.mockResolvedValue(assetStub.image); mocks.storage.stat.mockResolvedValue({ size: 123_456, mtime: fileModifiedAt, @@ -209,7 +209,7 @@ describe(MetadataService.name, () => { mockReadTags(); await sut.handleMetadataExtraction({ id: assetStub.image.id }); - expect(mocks.asset.getByIds).toHaveBeenCalledWith([assetStub.image.id], { faces: { person: false } }); + expect(mocks.assetJob.getForMetadataExtraction).toHaveBeenCalledWith(assetStub.image.id); expect(mocks.asset.upsertExif).toHaveBeenCalledWith(expect.objectContaining({ dateTimeOriginal: fileCreatedAt })); expect(mocks.asset.update).toHaveBeenCalledWith({ id: assetStub.image.id, @@ -222,7 +222,7 @@ describe(MetadataService.name, () => { it('should account for the server being in a non-UTC timezone', async () => { process.env.TZ = 'America/Los_Angeles'; - mocks.asset.getByIds.mockResolvedValue([assetStub.sidecar]); + mocks.assetJob.getForMetadataExtraction.mockResolvedValue(assetStub.sidecar); mockReadTags({ DateTimeOriginal: '2022:01:01 00:00:00' }); await sut.handleMetadataExtraction({ id: assetStub.image.id }); @@ -240,7 +240,7 @@ describe(MetadataService.name, () => { }); it('should handle lists of numbers', async () => { - mocks.asset.getByIds.mockResolvedValue([assetStub.image]); + mocks.assetJob.getForMetadataExtraction.mockResolvedValue(assetStub.image); mocks.storage.stat.mockResolvedValue({ size: 123_456, mtime: assetStub.image.fileModifiedAt, @@ -252,7 +252,7 @@ describe(MetadataService.name, () => { }); await sut.handleMetadataExtraction({ id: assetStub.image.id }); - expect(mocks.asset.getByIds).toHaveBeenCalledWith([assetStub.image.id], { faces: { person: false } }); + expect(mocks.assetJob.getForMetadataExtraction).toHaveBeenCalledWith(assetStub.image.id); expect(mocks.asset.upsertExif).toHaveBeenCalledWith(expect.objectContaining({ iso: 160 })); expect(mocks.asset.update).toHaveBeenCalledWith({ id: assetStub.image.id, @@ -265,7 +265,7 @@ describe(MetadataService.name, () => { it('should not delete latituide and longitude without reverse geocode', async () => { // regression test for issue 17511 - mocks.asset.getByIds.mockResolvedValue([assetStub.withLocation]); + mocks.assetJob.getForMetadataExtraction.mockResolvedValue(assetStub.withLocation); mocks.systemMetadata.get.mockResolvedValue({ reverseGeocoding: { enabled: false } }); mocks.storage.stat.mockResolvedValue({ size: 123_456, @@ -279,7 +279,7 @@ describe(MetadataService.name, () => { }); await sut.handleMetadataExtraction({ id: assetStub.image.id }); - expect(mocks.asset.getByIds).toHaveBeenCalledWith([assetStub.image.id], { faces: { person: false } }); + expect(mocks.assetJob.getForMetadataExtraction).toHaveBeenCalledWith(assetStub.image.id); expect(mocks.asset.upsertExif).toHaveBeenCalledWith( expect.objectContaining({ city: null, state: null, country: null }), ); @@ -293,7 +293,7 @@ describe(MetadataService.name, () => { }); it('should apply reverse geocoding', async () => { - mocks.asset.getByIds.mockResolvedValue([assetStub.withLocation]); + mocks.assetJob.getForMetadataExtraction.mockResolvedValue(assetStub.withLocation); mocks.systemMetadata.get.mockResolvedValue({ reverseGeocoding: { enabled: true } }); mocks.map.reverseGeocode.mockResolvedValue({ city: 'City', state: 'State', country: 'Country' }); mocks.storage.stat.mockResolvedValue({ @@ -308,7 +308,7 @@ describe(MetadataService.name, () => { }); await sut.handleMetadataExtraction({ id: assetStub.image.id }); - expect(mocks.asset.getByIds).toHaveBeenCalledWith([assetStub.image.id], { faces: { person: false } }); + expect(mocks.assetJob.getForMetadataExtraction).toHaveBeenCalledWith(assetStub.image.id); expect(mocks.asset.upsertExif).toHaveBeenCalledWith( expect.objectContaining({ city: 'City', state: 'State', country: 'Country' }), ); @@ -322,19 +322,19 @@ describe(MetadataService.name, () => { }); it('should discard latitude and longitude on null island', async () => { - mocks.asset.getByIds.mockResolvedValue([assetStub.withLocation]); + mocks.assetJob.getForMetadataExtraction.mockResolvedValue(assetStub.withLocation); mockReadTags({ GPSLatitude: 0, GPSLongitude: 0, }); await sut.handleMetadataExtraction({ id: assetStub.image.id }); - expect(mocks.asset.getByIds).toHaveBeenCalledWith([assetStub.image.id], { faces: { person: false } }); + expect(mocks.assetJob.getForMetadataExtraction).toHaveBeenCalledWith(assetStub.image.id); expect(mocks.asset.upsertExif).toHaveBeenCalledWith(expect.objectContaining({ latitude: null, longitude: null })); }); it('should extract tags from TagsList', async () => { - mocks.asset.getByIds.mockResolvedValue([assetStub.image]); + mocks.assetJob.getForMetadataExtraction.mockResolvedValue(assetStub.image); mockReadTags({ TagsList: ['Parent'] }); mocks.tag.upsertValue.mockResolvedValue(tagStub.parentUpsert); @@ -344,7 +344,7 @@ describe(MetadataService.name, () => { }); it('should extract hierarchy from TagsList', async () => { - mocks.asset.getByIds.mockResolvedValue([assetStub.image]); + mocks.assetJob.getForMetadataExtraction.mockResolvedValue(assetStub.image); mockReadTags({ TagsList: ['Parent/Child'] }); mocks.tag.upsertValue.mockResolvedValueOnce(tagStub.parentUpsert); mocks.tag.upsertValue.mockResolvedValueOnce(tagStub.childUpsert); @@ -364,7 +364,7 @@ describe(MetadataService.name, () => { }); it('should extract tags from Keywords as a string', async () => { - mocks.asset.getByIds.mockResolvedValue([assetStub.image]); + mocks.assetJob.getForMetadataExtraction.mockResolvedValue(assetStub.image); mockReadTags({ Keywords: 'Parent' }); mocks.tag.upsertValue.mockResolvedValue(tagStub.parentUpsert); @@ -374,7 +374,7 @@ describe(MetadataService.name, () => { }); it('should extract tags from Keywords as a list', async () => { - mocks.asset.getByIds.mockResolvedValue([assetStub.image]); + mocks.assetJob.getForMetadataExtraction.mockResolvedValue(assetStub.image); mockReadTags({ Keywords: ['Parent'] }); mocks.tag.upsertValue.mockResolvedValue(tagStub.parentUpsert); @@ -384,7 +384,7 @@ describe(MetadataService.name, () => { }); it('should extract tags from Keywords as a list with a number', async () => { - mocks.asset.getByIds.mockResolvedValue([assetStub.image]); + mocks.assetJob.getForMetadataExtraction.mockResolvedValue(assetStub.image); mockReadTags({ Keywords: ['Parent', 2024] }); mocks.tag.upsertValue.mockResolvedValue(tagStub.parentUpsert); @@ -395,7 +395,7 @@ describe(MetadataService.name, () => { }); it('should extract hierarchal tags from Keywords', async () => { - mocks.asset.getByIds.mockResolvedValue([assetStub.image]); + mocks.assetJob.getForMetadataExtraction.mockResolvedValue(assetStub.image); mockReadTags({ Keywords: 'Parent/Child' }); mocks.tag.upsertValue.mockResolvedValue(tagStub.parentUpsert); @@ -414,7 +414,7 @@ describe(MetadataService.name, () => { }); it('should ignore Keywords when TagsList is present', async () => { - mocks.asset.getByIds.mockResolvedValue([assetStub.image]); + mocks.assetJob.getForMetadataExtraction.mockResolvedValue(assetStub.image); mockReadTags({ Keywords: 'Child', TagsList: ['Parent/Child'] }); mocks.tag.upsertValue.mockResolvedValue(tagStub.parentUpsert); @@ -433,7 +433,7 @@ describe(MetadataService.name, () => { }); it('should extract hierarchy from HierarchicalSubject', async () => { - mocks.asset.getByIds.mockResolvedValue([assetStub.image]); + mocks.assetJob.getForMetadataExtraction.mockResolvedValue(assetStub.image); mockReadTags({ HierarchicalSubject: ['Parent|Child', 'TagA'] }); mocks.tag.upsertValue.mockResolvedValueOnce(tagStub.parentUpsert); mocks.tag.upsertValue.mockResolvedValueOnce(tagStub.childUpsert); @@ -454,7 +454,7 @@ describe(MetadataService.name, () => { }); it('should extract tags from HierarchicalSubject as a list with a number', async () => { - mocks.asset.getByIds.mockResolvedValue([assetStub.image]); + mocks.assetJob.getForMetadataExtraction.mockResolvedValue(assetStub.image); mockReadTags({ HierarchicalSubject: ['Parent', 2024] }); mocks.tag.upsertValue.mockResolvedValue(tagStub.parentUpsert); @@ -465,7 +465,7 @@ describe(MetadataService.name, () => { }); it('should extract ignore / characters in a HierarchicalSubject tag', async () => { - mocks.asset.getByIds.mockResolvedValue([assetStub.image]); + mocks.assetJob.getForMetadataExtraction.mockResolvedValue(assetStub.image); mockReadTags({ HierarchicalSubject: ['Mom/Dad'] }); mocks.tag.upsertValue.mockResolvedValueOnce(tagStub.parentUpsert); @@ -479,7 +479,7 @@ describe(MetadataService.name, () => { }); it('should ignore HierarchicalSubject when TagsList is present', async () => { - mocks.asset.getByIds.mockResolvedValue([assetStub.image]); + mocks.assetJob.getForMetadataExtraction.mockResolvedValue(assetStub.image); mockReadTags({ HierarchicalSubject: ['Parent2|Child2'], TagsList: ['Parent/Child'] }); mocks.tag.upsertValue.mockResolvedValue(tagStub.parentUpsert); @@ -498,7 +498,7 @@ describe(MetadataService.name, () => { }); it('should remove existing tags', async () => { - mocks.asset.getByIds.mockResolvedValue([assetStub.image]); + mocks.assetJob.getForMetadataExtraction.mockResolvedValue(assetStub.image); mockReadTags({}); await sut.handleMetadataExtraction({ id: assetStub.image.id }); @@ -507,13 +507,11 @@ describe(MetadataService.name, () => { }); it('should not apply motion photos if asset is video', async () => { - mocks.asset.getByIds.mockResolvedValue([{ ...assetStub.livePhotoMotionAsset, isVisible: true }]); + mocks.assetJob.getForMetadataExtraction.mockResolvedValue({ ...assetStub.livePhotoMotionAsset, isVisible: true }); mocks.media.probe.mockResolvedValue(probeStub.matroskaContainer); await sut.handleMetadataExtraction({ id: assetStub.livePhotoMotionAsset.id }); - expect(mocks.asset.getByIds).toHaveBeenCalledWith([assetStub.livePhotoMotionAsset.id], { - faces: { person: false }, - }); + expect(mocks.assetJob.getForMetadataExtraction).toHaveBeenCalledWith(assetStub.livePhotoMotionAsset.id); expect(mocks.storage.createOrOverwriteFile).not.toHaveBeenCalled(); expect(mocks.job.queue).not.toHaveBeenCalled(); expect(mocks.job.queueAll).not.toHaveBeenCalled(); @@ -523,7 +521,7 @@ describe(MetadataService.name, () => { }); it('should handle an invalid Directory Item', async () => { - mocks.asset.getByIds.mockResolvedValue([assetStub.image]); + mocks.assetJob.getForMetadataExtraction.mockResolvedValue(assetStub.image); mockReadTags({ MotionPhoto: 1, ContainerDirectory: [{ Foo: 100 }], @@ -533,19 +531,24 @@ describe(MetadataService.name, () => { }); it('should extract the correct video orientation', async () => { - mocks.asset.getByIds.mockResolvedValue([assetStub.video]); + mocks.assetJob.getForMetadataExtraction.mockResolvedValue(assetStub.video); mocks.media.probe.mockResolvedValue(probeStub.videoStreamVertical2160p); mockReadTags({}); await sut.handleMetadataExtraction({ id: assetStub.video.id }); - expect(mocks.asset.getByIds).toHaveBeenCalledWith([assetStub.video.id], { faces: { person: false } }); + expect(mocks.assetJob.getForMetadataExtraction).toHaveBeenCalledWith(assetStub.video.id); expect(mocks.asset.upsertExif).toHaveBeenCalledWith( expect.objectContaining({ orientation: ExifOrientation.Rotate270CW.toString() }), ); }); it('should extract the MotionPhotoVideo tag from Samsung HEIC motion photos', async () => { + mocks.assetJob.getForMetadataExtraction.mockResolvedValue({ + ...assetStub.livePhotoWithOriginalFileName, + livePhotoVideoId: null, + libraryId: null, + }); mocks.asset.getByIds.mockResolvedValue([{ ...assetStub.livePhotoWithOriginalFileName, livePhotoVideoId: null }]); mocks.storage.stat.mockResolvedValue({ size: 123_456, @@ -573,9 +576,7 @@ describe(MetadataService.name, () => { assetStub.livePhotoWithOriginalFileName.originalPath, 'MotionPhotoVideo', ); - expect(mocks.asset.getByIds).toHaveBeenCalledWith([assetStub.livePhotoWithOriginalFileName.id], { - faces: { person: false }, - }); + expect(mocks.assetJob.getForMetadataExtraction).toHaveBeenCalledWith(assetStub.livePhotoWithOriginalFileName.id); expect(mocks.asset.create).toHaveBeenCalledWith({ checksum: expect.any(Buffer), deviceAssetId: 'NONE', @@ -607,7 +608,11 @@ describe(MetadataService.name, () => { mtimeMs: assetStub.livePhotoWithOriginalFileName.fileModifiedAt.valueOf(), birthtimeMs: assetStub.livePhotoWithOriginalFileName.fileCreatedAt.valueOf(), } as Stats); - mocks.asset.getByIds.mockResolvedValue([{ ...assetStub.livePhotoWithOriginalFileName, livePhotoVideoId: null }]); + mocks.assetJob.getForMetadataExtraction.mockResolvedValue({ + ...assetStub.livePhotoWithOriginalFileName, + livePhotoVideoId: null, + libraryId: null, + }); mockReadTags({ Directory: 'foo/bar/', EmbeddedVideoFile: new BinaryField(0, ''), @@ -625,9 +630,7 @@ describe(MetadataService.name, () => { assetStub.livePhotoWithOriginalFileName.originalPath, 'EmbeddedVideoFile', ); - expect(mocks.asset.getByIds).toHaveBeenCalledWith([assetStub.livePhotoWithOriginalFileName.id], { - faces: { person: false }, - }); + expect(mocks.assetJob.getForMetadataExtraction).toHaveBeenCalledWith(assetStub.livePhotoWithOriginalFileName.id); expect(mocks.asset.create).toHaveBeenCalledWith({ checksum: expect.any(Buffer), deviceAssetId: 'NONE', @@ -653,7 +656,11 @@ describe(MetadataService.name, () => { }); it('should extract the motion photo video from the XMP directory entry ', async () => { - mocks.asset.getByIds.mockResolvedValue([{ ...assetStub.livePhotoWithOriginalFileName, livePhotoVideoId: null }]); + mocks.assetJob.getForMetadataExtraction.mockResolvedValue({ + ...assetStub.livePhotoWithOriginalFileName, + livePhotoVideoId: null, + libraryId: null, + }); mocks.storage.stat.mockResolvedValue({ size: 123_456, mtime: assetStub.livePhotoWithOriginalFileName.fileModifiedAt, @@ -673,9 +680,7 @@ describe(MetadataService.name, () => { mocks.storage.readFile.mockResolvedValue(video); await sut.handleMetadataExtraction({ id: assetStub.livePhotoWithOriginalFileName.id }); - expect(mocks.asset.getByIds).toHaveBeenCalledWith([assetStub.livePhotoWithOriginalFileName.id], { - faces: { person: false }, - }); + expect(mocks.assetJob.getForMetadataExtraction).toHaveBeenCalledWith(assetStub.livePhotoWithOriginalFileName.id); expect(mocks.storage.readFile).toHaveBeenCalledWith( assetStub.livePhotoWithOriginalFileName.originalPath, expect.any(Object), @@ -705,7 +710,7 @@ describe(MetadataService.name, () => { }); it('should delete old motion photo video assets if they do not match what is extracted', async () => { - mocks.asset.getByIds.mockResolvedValue([assetStub.livePhotoWithOriginalFileName]); + mocks.assetJob.getForMetadataExtraction.mockResolvedValue(assetStub.livePhotoWithOriginalFileName); mockReadTags({ Directory: 'foo/bar/', MotionPhoto: 1, @@ -727,7 +732,7 @@ describe(MetadataService.name, () => { }); it('should not create a new motion photo video asset if the hash of the extracted video matches an existing asset', async () => { - mocks.asset.getByIds.mockResolvedValue([assetStub.livePhotoStillAsset]); + mocks.assetJob.getForMetadataExtraction.mockResolvedValue(assetStub.livePhotoStillAsset); mockReadTags({ Directory: 'foo/bar/', MotionPhoto: 1, @@ -749,7 +754,10 @@ describe(MetadataService.name, () => { }); it('should link and hide motion video asset to still asset if the hash of the extracted video matches an existing asset', async () => { - mocks.asset.getByIds.mockResolvedValue([{ ...assetStub.livePhotoStillAsset, livePhotoVideoId: null }]); + mocks.assetJob.getForMetadataExtraction.mockResolvedValue({ + ...assetStub.livePhotoStillAsset, + livePhotoVideoId: null, + }); mockReadTags({ Directory: 'foo/bar/', MotionPhoto: 1, @@ -774,9 +782,11 @@ describe(MetadataService.name, () => { }); it('should not update storage usage if motion photo is external', async () => { - mocks.asset.getByIds.mockResolvedValue([ - { ...assetStub.livePhotoStillAsset, livePhotoVideoId: null, isExternal: true }, - ]); + mocks.assetJob.getForMetadataExtraction.mockResolvedValue({ + ...assetStub.livePhotoStillAsset, + livePhotoVideoId: null, + isExternal: true, + }); mockReadTags({ Directory: 'foo/bar/', MotionPhoto: 1, @@ -818,11 +828,11 @@ describe(MetadataService.name, () => { tz: 'UTC-11:30', Rating: 3, }; - mocks.asset.getByIds.mockResolvedValue([assetStub.image]); + mocks.assetJob.getForMetadataExtraction.mockResolvedValue(assetStub.image); mockReadTags(tags); await sut.handleMetadataExtraction({ id: assetStub.image.id }); - expect(mocks.asset.getByIds).toHaveBeenCalledWith([assetStub.image.id], { faces: { person: false } }); + expect(mocks.assetJob.getForMetadataExtraction).toHaveBeenCalledWith(assetStub.image.id); expect(mocks.asset.upsertExif).toHaveBeenCalledWith({ assetId: assetStub.image.id, bitsPerSample: expect.any(Number), @@ -878,11 +888,11 @@ describe(MetadataService.name, () => { DateTimeOriginal: ExifDateTime.fromISO(someDate + '+00:00'), tz: undefined, }; - mocks.asset.getByIds.mockResolvedValue([assetStub.image]); + mocks.assetJob.getForMetadataExtraction.mockResolvedValue(assetStub.image); mockReadTags(tags); await sut.handleMetadataExtraction({ id: assetStub.image.id }); - expect(mocks.asset.getByIds).toHaveBeenCalledWith([assetStub.image.id], { faces: { person: false } }); + expect(mocks.assetJob.getForMetadataExtraction).toHaveBeenCalledWith(assetStub.image.id); expect(mocks.asset.upsertExif).toHaveBeenCalledWith( expect.objectContaining({ timeZone: 'UTC+0', @@ -891,7 +901,7 @@ describe(MetadataService.name, () => { }); it('should extract duration', async () => { - mocks.asset.getByIds.mockResolvedValue([{ ...assetStub.video }]); + mocks.assetJob.getForMetadataExtraction.mockResolvedValue(assetStub.video); mocks.media.probe.mockResolvedValue({ ...probeStub.videoStreamH264, format: { @@ -902,7 +912,7 @@ describe(MetadataService.name, () => { await sut.handleMetadataExtraction({ id: assetStub.video.id }); - expect(mocks.asset.getByIds).toHaveBeenCalledWith([assetStub.video.id], { faces: { person: false } }); + expect(mocks.assetJob.getForMetadataExtraction).toHaveBeenCalledWith(assetStub.video.id); expect(mocks.asset.upsertExif).toHaveBeenCalled(); expect(mocks.asset.update).toHaveBeenCalledWith( expect.objectContaining({ @@ -913,7 +923,7 @@ describe(MetadataService.name, () => { }); it('should only extract duration for videos', async () => { - mocks.asset.getByIds.mockResolvedValue([{ ...assetStub.image }]); + mocks.assetJob.getForMetadataExtraction.mockResolvedValue(assetStub.image); mocks.media.probe.mockResolvedValue({ ...probeStub.videoStreamH264, format: { @@ -923,7 +933,7 @@ describe(MetadataService.name, () => { }); await sut.handleMetadataExtraction({ id: assetStub.image.id }); - expect(mocks.asset.getByIds).toHaveBeenCalledWith([assetStub.image.id], { faces: { person: false } }); + expect(mocks.assetJob.getForMetadataExtraction).toHaveBeenCalledWith(assetStub.image.id); expect(mocks.asset.upsertExif).toHaveBeenCalled(); expect(mocks.asset.update).toHaveBeenCalledWith( expect.objectContaining({ @@ -934,7 +944,7 @@ describe(MetadataService.name, () => { }); it('should omit duration of zero', async () => { - mocks.asset.getByIds.mockResolvedValue([{ ...assetStub.video }]); + mocks.assetJob.getForMetadataExtraction.mockResolvedValue(assetStub.video); mocks.media.probe.mockResolvedValue({ ...probeStub.videoStreamH264, format: { @@ -945,7 +955,7 @@ describe(MetadataService.name, () => { await sut.handleMetadataExtraction({ id: assetStub.video.id }); - expect(mocks.asset.getByIds).toHaveBeenCalledWith([assetStub.video.id], { faces: { person: false } }); + expect(mocks.assetJob.getForMetadataExtraction).toHaveBeenCalledWith(assetStub.video.id); expect(mocks.asset.upsertExif).toHaveBeenCalled(); expect(mocks.asset.update).toHaveBeenCalledWith( expect.objectContaining({ @@ -956,7 +966,7 @@ describe(MetadataService.name, () => { }); it('should a handle duration of 1 week', async () => { - mocks.asset.getByIds.mockResolvedValue([{ ...assetStub.video }]); + mocks.assetJob.getForMetadataExtraction.mockResolvedValue(assetStub.video); mocks.media.probe.mockResolvedValue({ ...probeStub.videoStreamH264, format: { @@ -967,7 +977,7 @@ describe(MetadataService.name, () => { await sut.handleMetadataExtraction({ id: assetStub.video.id }); - expect(mocks.asset.getByIds).toHaveBeenCalledWith([assetStub.video.id], { faces: { person: false } }); + expect(mocks.assetJob.getForMetadataExtraction).toHaveBeenCalledWith(assetStub.video.id); expect(mocks.asset.upsertExif).toHaveBeenCalled(); expect(mocks.asset.update).toHaveBeenCalledWith( expect.objectContaining({ @@ -978,7 +988,7 @@ describe(MetadataService.name, () => { }); it('should ignore duration from exif data', async () => { - mocks.asset.getByIds.mockResolvedValue([assetStub.image]); + mocks.assetJob.getForMetadataExtraction.mockResolvedValue(assetStub.image); mockReadTags({}, { Duration: { Value: 123 } }); await sut.handleMetadataExtraction({ id: assetStub.image.id }); @@ -986,7 +996,7 @@ describe(MetadataService.name, () => { }); it('should trim whitespace from description', async () => { - mocks.asset.getByIds.mockResolvedValue([assetStub.image]); + mocks.assetJob.getForMetadataExtraction.mockResolvedValue(assetStub.image); mockReadTags({ Description: '\t \v \f \n \r' }); await sut.handleMetadataExtraction({ id: assetStub.image.id }); @@ -1006,7 +1016,7 @@ describe(MetadataService.name, () => { }); it('should handle a numeric description', async () => { - mocks.asset.getByIds.mockResolvedValue([assetStub.image]); + mocks.assetJob.getForMetadataExtraction.mockResolvedValue(assetStub.image); mockReadTags({ Description: 1000 }); await sut.handleMetadataExtraction({ id: assetStub.image.id }); @@ -1018,7 +1028,7 @@ describe(MetadataService.name, () => { }); it('should skip importing metadata when the feature is disabled', async () => { - mocks.asset.getByIds.mockResolvedValue([assetStub.primaryImage]); + mocks.assetJob.getForMetadataExtraction.mockResolvedValue(assetStub.primaryImage); mocks.systemMetadata.get.mockResolvedValue({ metadata: { faces: { import: false } } }); mockReadTags(makeFaceTags({ Name: 'Person 1' })); await sut.handleMetadataExtraction({ id: assetStub.image.id }); @@ -1026,7 +1036,7 @@ describe(MetadataService.name, () => { }); it('should skip importing metadata face for assets without tags.RegionInfo', async () => { - mocks.asset.getByIds.mockResolvedValue([assetStub.primaryImage]); + mocks.assetJob.getForMetadataExtraction.mockResolvedValue(assetStub.primaryImage); mocks.systemMetadata.get.mockResolvedValue({ metadata: { faces: { import: true } } }); mockReadTags(); await sut.handleMetadataExtraction({ id: assetStub.image.id }); @@ -1034,7 +1044,7 @@ describe(MetadataService.name, () => { }); it('should skip importing faces without name', async () => { - mocks.asset.getByIds.mockResolvedValue([assetStub.primaryImage]); + mocks.assetJob.getForMetadataExtraction.mockResolvedValue(assetStub.primaryImage); mocks.systemMetadata.get.mockResolvedValue({ metadata: { faces: { import: true } } }); mockReadTags(makeFaceTags()); mocks.person.getDistinctNames.mockResolvedValue([]); @@ -1046,7 +1056,7 @@ describe(MetadataService.name, () => { }); it('should skip importing faces with empty name', async () => { - mocks.asset.getByIds.mockResolvedValue([assetStub.primaryImage]); + mocks.assetJob.getForMetadataExtraction.mockResolvedValue(assetStub.primaryImage); mocks.systemMetadata.get.mockResolvedValue({ metadata: { faces: { import: true } } }); mockReadTags(makeFaceTags({ Name: '' })); mocks.person.getDistinctNames.mockResolvedValue([]); @@ -1058,14 +1068,14 @@ describe(MetadataService.name, () => { }); it('should apply metadata face tags creating new persons', async () => { - mocks.asset.getByIds.mockResolvedValue([assetStub.primaryImage]); + mocks.assetJob.getForMetadataExtraction.mockResolvedValue(assetStub.primaryImage); mocks.systemMetadata.get.mockResolvedValue({ metadata: { faces: { import: true } } }); mockReadTags(makeFaceTags({ Name: personStub.withName.name })); mocks.person.getDistinctNames.mockResolvedValue([]); mocks.person.createAll.mockResolvedValue([personStub.withName.id]); mocks.person.update.mockResolvedValue(personStub.withName); await sut.handleMetadataExtraction({ id: assetStub.primaryImage.id }); - expect(mocks.asset.getByIds).toHaveBeenCalledWith([assetStub.primaryImage.id], { faces: { person: false } }); + expect(mocks.assetJob.getForMetadataExtraction).toHaveBeenCalledWith(assetStub.primaryImage.id); expect(mocks.person.getDistinctNames).toHaveBeenCalledWith(assetStub.primaryImage.ownerId, { withHidden: true }); expect(mocks.person.createAll).toHaveBeenCalledWith([ expect.objectContaining({ name: personStub.withName.name }), @@ -1099,14 +1109,14 @@ describe(MetadataService.name, () => { }); it('should assign metadata face tags to existing persons', async () => { - mocks.asset.getByIds.mockResolvedValue([assetStub.primaryImage]); + mocks.assetJob.getForMetadataExtraction.mockResolvedValue(assetStub.primaryImage); mocks.systemMetadata.get.mockResolvedValue({ metadata: { faces: { import: true } } }); mockReadTags(makeFaceTags({ Name: personStub.withName.name })); mocks.person.getDistinctNames.mockResolvedValue([{ id: personStub.withName.id, name: personStub.withName.name }]); mocks.person.createAll.mockResolvedValue([]); mocks.person.update.mockResolvedValue(personStub.withName); await sut.handleMetadataExtraction({ id: assetStub.primaryImage.id }); - expect(mocks.asset.getByIds).toHaveBeenCalledWith([assetStub.primaryImage.id], { faces: { person: false } }); + expect(mocks.assetJob.getForMetadataExtraction).toHaveBeenCalledWith(assetStub.primaryImage.id); expect(mocks.person.getDistinctNames).toHaveBeenCalledWith(assetStub.primaryImage.ownerId, { withHidden: true }); expect(mocks.person.createAll).not.toHaveBeenCalled(); expect(mocks.person.refreshFaces).toHaveBeenCalledWith( @@ -1131,7 +1141,7 @@ describe(MetadataService.name, () => { }); it('should handle invalid modify date', async () => { - mocks.asset.getByIds.mockResolvedValue([assetStub.image]); + mocks.assetJob.getForMetadataExtraction.mockResolvedValue(assetStub.image); mockReadTags({ ModifyDate: '00:00:00.000' }); await sut.handleMetadataExtraction({ id: assetStub.image.id }); @@ -1143,7 +1153,7 @@ describe(MetadataService.name, () => { }); it('should handle invalid rating value', async () => { - mocks.asset.getByIds.mockResolvedValue([assetStub.image]); + mocks.assetJob.getForMetadataExtraction.mockResolvedValue(assetStub.image); mockReadTags({ Rating: 6 }); await sut.handleMetadataExtraction({ id: assetStub.image.id }); @@ -1155,7 +1165,7 @@ describe(MetadataService.name, () => { }); it('should handle valid rating value', async () => { - mocks.asset.getByIds.mockResolvedValue([assetStub.image]); + mocks.assetJob.getForMetadataExtraction.mockResolvedValue(assetStub.image); mockReadTags({ Rating: 5 }); await sut.handleMetadataExtraction({ id: assetStub.image.id }); @@ -1167,7 +1177,7 @@ describe(MetadataService.name, () => { }); it('should handle valid negative rating value', async () => { - mocks.asset.getByIds.mockResolvedValue([assetStub.image]); + mocks.assetJob.getForMetadataExtraction.mockResolvedValue(assetStub.image); mockReadTags({ Rating: -1 }); await sut.handleMetadataExtraction({ id: assetStub.image.id }); @@ -1179,11 +1189,11 @@ describe(MetadataService.name, () => { }); it('should handle livePhotoCID not set', async () => { - mocks.asset.getByIds.mockResolvedValue([assetStub.image]); + mocks.assetJob.getForMetadataExtraction.mockResolvedValue(assetStub.image); await expect(sut.handleMetadataExtraction({ id: assetStub.image.id })).resolves.toBe(JobStatus.SUCCESS); - expect(mocks.asset.getByIds).toHaveBeenCalledWith([assetStub.image.id], { faces: { person: false } }); + expect(mocks.assetJob.getForMetadataExtraction).toHaveBeenCalledWith(assetStub.image.id); expect(mocks.asset.findLivePhotoMatch).not.toHaveBeenCalled(); expect(mocks.asset.update).not.toHaveBeenCalledWith(expect.objectContaining({ isVisible: false })); expect(mocks.album.removeAsset).not.toHaveBeenCalled(); @@ -1191,20 +1201,19 @@ describe(MetadataService.name, () => { it('should handle not finding a match', async () => { mocks.media.probe.mockResolvedValue(probeStub.videoStreamVertical2160p); - mocks.asset.getByIds.mockResolvedValue([assetStub.livePhotoMotionAsset]); + mocks.assetJob.getForMetadataExtraction.mockResolvedValue(assetStub.livePhotoMotionAsset); mockReadTags({ ContentIdentifier: 'CID' }); await expect(sut.handleMetadataExtraction({ id: assetStub.livePhotoMotionAsset.id })).resolves.toBe( JobStatus.SUCCESS, ); - expect(mocks.asset.getByIds).toHaveBeenCalledWith([assetStub.livePhotoMotionAsset.id], { - faces: { person: false }, - }); + expect(mocks.assetJob.getForMetadataExtraction).toHaveBeenCalledWith(assetStub.livePhotoMotionAsset.id); expect(mocks.asset.findLivePhotoMatch).toHaveBeenCalledWith({ livePhotoCID: 'CID', ownerId: assetStub.livePhotoMotionAsset.ownerId, otherAssetId: assetStub.livePhotoMotionAsset.id, + libraryId: null, type: AssetType.IMAGE, }); expect(mocks.asset.update).not.toHaveBeenCalledWith(expect.objectContaining({ isVisible: false })); @@ -1212,7 +1221,7 @@ describe(MetadataService.name, () => { }); it('should link photo and video', async () => { - mocks.asset.getByIds.mockResolvedValue([assetStub.livePhotoStillAsset]); + mocks.assetJob.getForMetadataExtraction.mockResolvedValue(assetStub.livePhotoStillAsset); mocks.asset.findLivePhotoMatch.mockResolvedValue(assetStub.livePhotoMotionAsset); mockReadTags({ ContentIdentifier: 'CID' }); @@ -1220,9 +1229,7 @@ describe(MetadataService.name, () => { JobStatus.SUCCESS, ); - expect(mocks.asset.getByIds).toHaveBeenCalledWith([assetStub.livePhotoStillAsset.id], { - faces: { person: false }, - }); + expect(mocks.assetJob.getForMetadataExtraction).toHaveBeenCalledWith(assetStub.livePhotoStillAsset.id); expect(mocks.asset.findLivePhotoMatch).toHaveBeenCalledWith({ livePhotoCID: 'CID', ownerId: assetStub.livePhotoStillAsset.ownerId, @@ -1238,12 +1245,9 @@ describe(MetadataService.name, () => { }); it('should notify clients on live photo link', async () => { - mocks.asset.getByIds.mockResolvedValue([ - { - ...assetStub.livePhotoStillAsset, - exifInfo: { livePhotoCID: assetStub.livePhotoMotionAsset.id } as Exif, - }, - ]); + mocks.assetJob.getForMetadataExtraction.mockResolvedValue({ + ...assetStub.livePhotoStillAsset, + }); mocks.asset.findLivePhotoMatch.mockResolvedValue(assetStub.livePhotoMotionAsset); mockReadTags({ ContentIdentifier: 'CID' }); @@ -1258,12 +1262,11 @@ describe(MetadataService.name, () => { }); it('should search by libraryId', async () => { - mocks.asset.getByIds.mockResolvedValue([ - { - ...assetStub.livePhotoStillAsset, - libraryId: 'library-id', - }, - ]); + mocks.assetJob.getForMetadataExtraction.mockResolvedValue({ + ...assetStub.livePhotoStillAsset, + libraryId: 'library-id', + }); + mocks.asset.findLivePhotoMatch.mockResolvedValue(assetStub.livePhotoMotionAsset); mockReadTags({ ContentIdentifier: 'CID' }); await expect(sut.handleMetadataExtraction({ id: assetStub.livePhotoStillAsset.id })).resolves.toBe( @@ -1296,7 +1299,7 @@ describe(MetadataService.name, () => { }, { exif: { AndroidMake: '1', AndroidModel: '2' }, expected: { make: '1', model: '2' } }, ])('should read camera make and model $exif -> $expected', async ({ exif, expected }) => { - mocks.asset.getByIds.mockResolvedValue([assetStub.image]); + mocks.assetJob.getForMetadataExtraction.mockResolvedValue(assetStub.image); mockReadTags(exif); await sut.handleMetadataExtraction({ id: assetStub.image.id }); @@ -1318,7 +1321,7 @@ describe(MetadataService.name, () => { { exif: { LensID: ' Unknown 6-30mm' }, expected: null }, { exif: { LensID: '' }, expected: null }, ])('should read camera lens information $exif -> $expected', async ({ exif, expected }) => { - mocks.asset.getByIds.mockResolvedValue([assetStub.image]); + mocks.assetJob.getForMetadataExtraction.mockResolvedValue(assetStub.image); mockReadTags(exif); await sut.handleMetadataExtraction({ id: assetStub.image.id }); diff --git a/server/src/services/metadata.service.ts b/server/src/services/metadata.service.ts index e460170968..ab62c38ed0 100644 --- a/server/src/services/metadata.service.ts +++ b/server/src/services/metadata.service.ts @@ -9,9 +9,9 @@ import { constants } from 'node:fs/promises'; import path from 'node:path'; import { JOBS_ASSET_PAGINATION_SIZE } from 'src/constants'; import { StorageCore } from 'src/cores/storage.core'; +import { Asset, AssetFace } from 'src/database'; import { AssetFaces, Exif, Person } from 'src/db'; import { OnEvent, OnJob } from 'src/decorators'; -import { AssetEntity } from 'src/entities/asset.entity'; import { AssetType, DatabaseLock, @@ -134,7 +134,10 @@ export class MetadataService extends BaseService { } } - private async linkLivePhotos(asset: AssetEntity, exifInfo: Insertable): Promise { + private async linkLivePhotos( + asset: { id: string; type: AssetType; ownerId: string; libraryId: string | null }, + exifInfo: Insertable, + ): Promise { if (!exifInfo.livePhotoCID) { return; } @@ -182,9 +185,9 @@ export class MetadataService extends BaseService { @OnJob({ name: JobName.METADATA_EXTRACTION, queue: QueueName.METADATA_EXTRACTION }) async handleMetadataExtraction(data: JobOf): Promise { - const [{ metadata, reverseGeocoding }, [asset]] = await Promise.all([ + const [{ metadata, reverseGeocoding }, asset] = await Promise.all([ this.getConfig({ withCache: true }), - this.assetRepository.getByIds([data.id], { faces: { person: false } }), + this.assetJobRepository.getForMetadataExtraction(data.id), ]); if (!asset) { @@ -268,7 +271,7 @@ export class MetadataService extends BaseService { ]; if (this.isMotionPhoto(asset, exifTags)) { - promises.push(this.applyMotionPhotos(asset, exifTags, dates, stats)); + promises.push(this.applyMotionPhotos(asset as unknown as Asset, exifTags, dates, stats)); } if (isFaceImportEnabled(metadata) && this.hasTaggedFaces(exifTags)) { @@ -376,7 +379,11 @@ export class MetadataService extends BaseService { return { width, height }; } - private getExifTags(asset: AssetEntity): Promise { + private getExifTags(asset: { + originalPath: string; + sidecarPath: string | null; + type: AssetType; + }): Promise { if (!asset.sidecarPath && asset.type === AssetType.IMAGE) { return this.metadataRepository.readTags(asset.originalPath); } @@ -384,7 +391,11 @@ export class MetadataService extends BaseService { return this.mergeExifTags(asset); } - private async mergeExifTags(asset: AssetEntity): Promise { + private async mergeExifTags(asset: { + originalPath: string; + sidecarPath: string | null; + type: AssetType; + }): Promise { const [mediaTags, sidecarTags, videoTags] = await Promise.all([ this.metadataRepository.readTags(asset.originalPath), asset.sidecarPath ? this.metadataRepository.readTags(asset.sidecarPath) : null, @@ -434,7 +445,7 @@ export class MetadataService extends BaseService { return tags; } - private async applyTagList(asset: AssetEntity, exifTags: ImmichTags) { + private async applyTagList(asset: { id: string; ownerId: string }, exifTags: ImmichTags) { const tags = this.getTagList(exifTags); const results = await upsertTags(this.tagRepository, { userId: asset.ownerId, tags }); await this.tagRepository.replaceAssetTags( @@ -443,11 +454,11 @@ export class MetadataService extends BaseService { ); } - private isMotionPhoto(asset: AssetEntity, tags: ImmichTags): boolean { + private isMotionPhoto(asset: { type: AssetType }, tags: ImmichTags): boolean { return asset.type === AssetType.IMAGE && !!(tags.MotionPhoto || tags.MicroVideo); } - private async applyMotionPhotos(asset: AssetEntity, tags: ImmichTags, dates: Dates, stats: Stats) { + private async applyMotionPhotos(asset: Asset, tags: ImmichTags, dates: Dates, stats: Stats) { const isMotionPhoto = tags.MotionPhoto; const isMicroVideo = tags.MicroVideo; const videoOffset = tags.MicroVideoOffset; @@ -582,7 +593,10 @@ export class MetadataService extends BaseService { ); } - private async applyTaggedFaces(asset: AssetEntity, tags: ImmichTags) { + private async applyTaggedFaces( + asset: { id: string; ownerId: string; faces: AssetFace[]; originalPath: string }, + tags: ImmichTags, + ) { if (!tags.RegionInfo?.AppliedToDimensions || tags.RegionInfo.RegionList.length === 0) { return; } @@ -649,7 +663,7 @@ export class MetadataService extends BaseService { } } - private getDates(asset: AssetEntity, exifTags: ImmichTags, stats: Stats) { + private getDates(asset: { id: string; originalPath: string }, exifTags: ImmichTags, stats: Stats) { const dateTime = firstDateTime(exifTags as Maybe, EXIF_DATE_TAGS); this.logger.verbose(`Date and time is ${dateTime} for asset ${asset.id}: ${asset.originalPath}`); diff --git a/server/test/fixtures/asset.stub.ts b/server/test/fixtures/asset.stub.ts index 34d75fc762..b5c2f4fe00 100644 --- a/server/test/fixtures/asset.stub.ts +++ b/server/test/fixtures/asset.stub.ts @@ -157,7 +157,7 @@ export const assetStub = { isOffline: false, }), - primaryImage: Object.freeze({ + primaryImage: Object.freeze({ id: 'primary-asset-id', status: AssetStatus.ACTIVE, deviceAssetId: 'device-asset-id', @@ -200,9 +200,10 @@ export const assetStub = { ]), duplicateId: null, isOffline: false, + libraryId: null, }), - image: Object.freeze({ + image: Object.freeze({ id: 'asset-id', status: AssetStatus.ACTIVE, deviceAssetId: 'device-asset-id', @@ -239,6 +240,7 @@ export const assetStub = { } as Exif, duplicateId: null, isOffline: false, + libraryId: null, }), trashed: Object.freeze({ @@ -470,7 +472,7 @@ export const assetStub = { isOffline: false, }), - video: Object.freeze({ + video: Object.freeze({ id: 'asset-id', status: AssetStatus.ACTIVE, originalFileName: 'asset-id.ext', @@ -507,6 +509,7 @@ export const assetStub = { deletedAt: null, duplicateId: null, isOffline: false, + libraryId: null, }), livePhotoMotionAsset: Object.freeze({ @@ -522,7 +525,8 @@ export const assetStub = { fileSizeInByte: 100_000, timeZone: `America/New_York`, }, - } as AssetEntity), + libraryId: null, + } as AssetEntity & { libraryId: string | null; files: AssetFile[]; exifInfo: Exif }), livePhotoStillAsset: Object.freeze({ id: 'live-photo-still-asset', @@ -539,7 +543,7 @@ export const assetStub = { timeZone: `America/New_York`, }, files, - } as AssetEntity), + } as AssetEntity & { libraryId: string | null }), livePhotoWithOriginalFileName: Object.freeze({ id: 'live-photo-still-asset', @@ -556,9 +560,10 @@ export const assetStub = { fileSizeInByte: 25_000, timeZone: `America/New_York`, }, - } as AssetEntity), + libraryId: null, + } as AssetEntity & { libraryId: string | null }), - withLocation: Object.freeze({ + withLocation: Object.freeze({ id: 'asset-with-favorite-id', status: AssetStatus.ACTIVE, deviceAssetId: 'device-asset-id', @@ -598,9 +603,10 @@ export const assetStub = { deletedAt: null, duplicateId: null, isOffline: false, + libraryId: null, }), - sidecar: Object.freeze({ + sidecar: Object.freeze({ id: 'asset-id', status: AssetStatus.ACTIVE, deviceAssetId: 'device-asset-id', @@ -632,6 +638,7 @@ export const assetStub = { deletedAt: null, duplicateId: null, isOffline: false, + libraryId: null, }), sidecarWithoutExt: Object.freeze({ @@ -743,7 +750,7 @@ export const assetStub = { isOffline: false, }), - imageDng: Object.freeze({ + imageDng: Object.freeze({ id: 'asset-id', status: AssetStatus.ACTIVE, deviceAssetId: 'device-asset-id', @@ -782,7 +789,7 @@ export const assetStub = { isOffline: false, }), - imageHif: Object.freeze({ + imageHif: Object.freeze({ id: 'asset-id', status: AssetStatus.ACTIVE, deviceAssetId: 'device-asset-id', diff --git a/server/test/fixtures/shared-link.stub.ts b/server/test/fixtures/shared-link.stub.ts index be69147e7a..4eba0de845 100644 --- a/server/test/fixtures/shared-link.stub.ts +++ b/server/test/fixtures/shared-link.stub.ts @@ -116,6 +116,8 @@ export const sharedLinkStub = { album: undefined, description: null, assets: [assetStub.image], + password: 'password', + albumId: null, } as SharedLinkEntity), valid: Object.freeze({ id: '123', diff --git a/server/test/medium/specs/metadata.service.spec.ts b/server/test/medium/specs/metadata.service.spec.ts index fbdb8b51ac..b25cce2724 100644 --- a/server/test/medium/specs/metadata.service.spec.ts +++ b/server/test/medium/specs/metadata.service.spec.ts @@ -2,7 +2,6 @@ import { Stats } from 'node:fs'; import { writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; -import { AssetEntity } from 'src/entities/asset.entity'; import { LoggingRepository } from 'src/repositories/logging.repository'; import { MetadataRepository } from 'src/repositories/metadata.repository'; import { MetadataService } from 'src/services/metadata.service'; @@ -119,7 +118,7 @@ describe(MetadataService.name, () => { process.env.TZ = serverTimeZone ?? undefined; const { filePath } = await createTestFile(exifData); - mocks.asset.getByIds.mockResolvedValue([{ id: 'asset-1', originalPath: filePath } as AssetEntity]); + mocks.assetJob.getForMetadataExtraction.mockResolvedValue({ id: 'asset-1', originalPath: filePath } as never); await sut.handleMetadataExtraction({ id: 'asset-1' });