refactor: dedicated queries for asset jobs (#17652)

This commit is contained in:
Daniel Dietzler 2025-04-16 20:08:49 +02:00 committed by GitHub
parent 8f8ff3adc0
commit f50e5d006c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 310 additions and 163 deletions

View File

@ -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;

View File

@ -263,6 +263,24 @@ export type AssetJobStatus = Selectable<DatabaseAssetJobStatus> & {
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',

View File

@ -56,13 +56,13 @@ export class AssetEntity {
export function withExif<O>(qb: SelectQueryBuilder<DB, 'assets', O>) {
return qb
.leftJoin('exif', 'assets.id', 'exif.assetId')
.select((eb) => eb.fn.toJson(eb.table('exif')).$castTo<Exif>().as('exifInfo'));
.select((eb) => eb.fn.toJson(eb.table('exif')).$castTo<Exif | null>().as('exifInfo'));
}
export function withExifInner<O>(qb: SelectQueryBuilder<DB, 'assets', O>) {
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<Exif>().as('exifInfo'));
}
export function withSmartSearch<O>(qb: SelectQueryBuilder<DB, 'assets', O>) {

View File

@ -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",

View File

@ -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')

View File

@ -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 });

View File

@ -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<JobName.GENERATE_THUMBNAILS>): Promise<JobStatus> {
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) {

View File

@ -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 });

View File

@ -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<Exif>): Promise<void> {
private async linkLivePhotos(
asset: { id: string; type: AssetType; ownerId: string; libraryId: string | null },
exifInfo: Insertable<Exif>,
): Promise<void> {
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<JobName.METADATA_EXTRACTION>): Promise<JobStatus> {
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<ImmichTags> {
private getExifTags(asset: {
originalPath: string;
sidecarPath: string | null;
type: AssetType;
}): Promise<ImmichTags> {
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<ImmichTags> {
private async mergeExifTags(asset: {
originalPath: string;
sidecarPath: string | null;
type: AssetType;
}): Promise<ImmichTags> {
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<Tags>, EXIF_DATE_TAGS);
this.logger.verbose(`Date and time is ${dateTime} for asset ${asset.id}: ${asset.originalPath}`);

View File

@ -157,7 +157,7 @@ export const assetStub = {
isOffline: false,
}),
primaryImage: Object.freeze<AssetEntity>({
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<AssetEntity>({
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<AssetEntity>({
@ -470,7 +472,7 @@ export const assetStub = {
isOffline: false,
}),
video: Object.freeze<AssetEntity>({
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<AssetEntity>({
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<AssetEntity>({
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<AssetEntity>({
@ -743,7 +750,7 @@ export const assetStub = {
isOffline: false,
}),
imageDng: Object.freeze<AssetEntity>({
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<AssetEntity>({
imageHif: Object.freeze({
id: 'asset-id',
status: AssetStatus.ACTIVE,
deviceAssetId: 'device-asset-id',

View File

@ -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',

View File

@ -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' });