mirror of
https://github.com/immich-app/immich.git
synced 2025-05-24 01:12:58 -04:00
refactor: dedicated queries for asset jobs (#17652)
This commit is contained in:
parent
8f8ff3adc0
commit
f50e5d006c
@ -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;
|
||||
|
||||
|
@ -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',
|
||||
|
@ -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>) {
|
||||
|
@ -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",
|
||||
|
@ -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')
|
||||
|
@ -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 });
|
||||
|
||||
|
@ -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) {
|
||||
|
@ -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 });
|
||||
|
@ -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}`);
|
||||
|
||||
|
27
server/test/fixtures/asset.stub.ts
vendored
27
server/test/fixtures/asset.stub.ts
vendored
@ -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',
|
||||
|
2
server/test/fixtures/shared-link.stub.ts
vendored
2
server/test/fixtures/shared-link.stub.ts
vendored
@ -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',
|
||||
|
@ -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' });
|
||||
|
||||
|
Loading…
x
Reference in New Issue
Block a user