fix: asset update race condition

This commit is contained in:
Daniel Dietzler 2025-03-16 13:17:55 +01:00
parent 9a4495eb5b
commit 75d1d21cc6
No known key found for this signature in database
GPG Key ID: A1C0B97CD8E18DFF
13 changed files with 172 additions and 114 deletions

1
server/src/db.d.ts vendored
View File

@ -143,6 +143,7 @@ export interface Assets {
isFavorite: Generated<boolean>; isFavorite: Generated<boolean>;
isOffline: Generated<boolean>; isOffline: Generated<boolean>;
isVisible: Generated<boolean>; isVisible: Generated<boolean>;
isDirty: Generated<boolean>;
libraryId: string | null; libraryId: string | null;
livePhotoVideoId: string | null; livePhotoVideoId: string | null;
localDateTime: Timestamp | null; localDateTime: Timestamp | null;

View File

@ -125,6 +125,9 @@ export class AssetEntity {
@Column({ type: 'boolean', default: false }) @Column({ type: 'boolean', default: false })
isOffline!: boolean; isOffline!: boolean;
@Column({ type: 'boolean', default: false })
isDirty!: boolean;
@Column({ type: 'bytea' }) @Column({ type: 'bytea' })
@Index() @Index()
checksum!: Buffer; // sha1 checksum checksum!: Buffer; // sha1 checksum

View File

@ -0,0 +1,11 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class AddAssetIsDirty1742127949957 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "assets" ADD "isDirty" boolean NOT NULL DEFAULT false`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "assets" DROP COLUMN "isDirty"`);
}
}

View File

@ -264,7 +264,7 @@ describe(AssetService.name, () => {
await sut.update(authStub.admin, 'asset-1', { isFavorite: true }); await sut.update(authStub.admin, 'asset-1', { isFavorite: true });
expect(mocks.asset.update).toHaveBeenCalledWith({ id: 'asset-1', isFavorite: true }); expect(mocks.asset.update).toHaveBeenCalledWith({ id: 'asset-1', isFavorite: true, isDirty: true });
}); });
it('should update the exif description', async () => { it('should update the exif description', async () => {
@ -371,6 +371,7 @@ describe(AssetService.name, () => {
expect(mocks.asset.update).toHaveBeenCalledWith({ expect(mocks.asset.update).toHaveBeenCalledWith({
id: assetStub.livePhotoStillAsset.id, id: assetStub.livePhotoStillAsset.id,
livePhotoVideoId: assetStub.livePhotoMotionAsset.id, livePhotoVideoId: assetStub.livePhotoMotionAsset.id,
isDirty: true,
}); });
}); });
@ -392,6 +393,7 @@ describe(AssetService.name, () => {
expect(mocks.asset.update).toHaveBeenCalledWith({ expect(mocks.asset.update).toHaveBeenCalledWith({
id: assetStub.livePhotoStillAsset.id, id: assetStub.livePhotoStillAsset.id,
livePhotoVideoId: null, livePhotoVideoId: null,
isDirty: true,
}); });
expect(mocks.asset.update).toHaveBeenCalledWith({ id: assetStub.livePhotoMotionAsset.id, isVisible: true }); expect(mocks.asset.update).toHaveBeenCalledWith({ id: assetStub.livePhotoMotionAsset.id, isVisible: true });
expect(mocks.event.emit).toHaveBeenCalledWith('asset.show', { expect(mocks.event.emit).toHaveBeenCalledWith('asset.show', {
@ -429,7 +431,7 @@ describe(AssetService.name, () => {
await sut.updateAll(authStub.admin, { ids: ['asset-1', 'asset-2'], isArchived: true }); await sut.updateAll(authStub.admin, { ids: ['asset-1', 'asset-2'], isArchived: true });
expect(mocks.asset.updateAll).toHaveBeenCalledWith(['asset-1', 'asset-2'], { isArchived: true }); expect(mocks.asset.updateAll).toHaveBeenCalledWith(['asset-1', 'asset-2'], { isArchived: true, isDirty: true });
}); });
it('should not update Assets table if no relevant fields are provided', async () => { it('should not update Assets table if no relevant fields are provided', async () => {

View File

@ -117,7 +117,7 @@ export class AssetService extends BaseService {
await this.updateMetadata({ id, description, dateTimeOriginal, latitude, longitude, rating }); await this.updateMetadata({ id, description, dateTimeOriginal, latitude, longitude, rating });
const asset = await this.assetRepository.update({ id, ...rest }); const asset = await this.assetRepository.update({ id, isDirty: true, ...rest });
if (previousMotion) { if (previousMotion) {
await onAfterUnlink(repos, { userId: auth.user.id, livePhotoVideoId: previousMotion.id }); await onAfterUnlink(repos, { userId: auth.user.id, livePhotoVideoId: previousMotion.id });
@ -144,7 +144,7 @@ export class AssetService extends BaseService {
options.duplicateId != undefined || options.duplicateId != undefined ||
options.rating != undefined options.rating != undefined
) { ) {
await this.assetRepository.updateAll(ids, options); await this.assetRepository.updateAll(ids, { isDirty: true, ...options });
} }
} }

View File

@ -117,7 +117,7 @@ describe(MetadataService.name, () => {
it('should handle an asset that could not be found', async () => { it('should handle an asset that could not be found', async () => {
await expect(sut.handleMetadataExtraction({ id: assetStub.image.id })).resolves.toBe(JobStatus.FAILED); 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.asset.getById).toHaveBeenCalledWith(assetStub.image.id, { faces: { person: false } });
expect(mocks.asset.upsertExif).not.toHaveBeenCalled(); expect(mocks.asset.upsertExif).not.toHaveBeenCalled();
expect(mocks.asset.update).not.toHaveBeenCalled(); expect(mocks.asset.update).not.toHaveBeenCalled();
}); });
@ -125,11 +125,11 @@ describe(MetadataService.name, () => {
it('should handle a date in a sidecar file', async () => { it('should handle a date in a sidecar file', async () => {
const originalDate = new Date('2023-11-21T16:13:17.517Z'); const originalDate = new Date('2023-11-21T16:13:17.517Z');
const sidecarDate = new Date('2022-01-01T00:00:00.000Z'); const sidecarDate = new Date('2022-01-01T00:00:00.000Z');
mocks.asset.getByIds.mockResolvedValue([assetStub.sidecar]); mocks.asset.getById.mockResolvedValue(assetStub.sidecar);
mockReadTags({ CreationDate: originalDate.toISOString() }, { CreationDate: sidecarDate.toISOString() }); mockReadTags({ CreationDate: originalDate.toISOString() }, { CreationDate: sidecarDate.toISOString() });
await sut.handleMetadataExtraction({ id: assetStub.image.id }); await sut.handleMetadataExtraction({ id: assetStub.image.id });
expect(mocks.asset.getByIds).toHaveBeenCalledWith([assetStub.sidecar.id], { faces: { person: false } }); expect(mocks.asset.getById).toHaveBeenCalledWith(assetStub.sidecar.id, { faces: { person: false } });
expect(mocks.asset.upsertExif).toHaveBeenCalledWith(expect.objectContaining({ dateTimeOriginal: sidecarDate })); expect(mocks.asset.upsertExif).toHaveBeenCalledWith(expect.objectContaining({ dateTimeOriginal: sidecarDate }));
expect(mocks.asset.update).toHaveBeenCalledWith( expect(mocks.asset.update).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({
@ -144,14 +144,14 @@ describe(MetadataService.name, () => {
it('should take the file modification date when missing exif and earlier than creation date', async () => { 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 fileCreatedAt = new Date('2022-01-01T00:00:00.000Z');
const fileModifiedAt = new Date('2021-01-01T00:00:00.000Z'); const fileModifiedAt = new Date('2021-01-01T00:00:00.000Z');
mocks.asset.getByIds.mockResolvedValue([assetStub.image]); mocks.asset.getById.mockResolvedValue(assetStub.image);
mockReadTags({ mockReadTags({
FileCreateDate: fileCreatedAt.toISOString(), FileCreateDate: fileCreatedAt.toISOString(),
FileModifyDate: fileModifiedAt.toISOString(), FileModifyDate: fileModifiedAt.toISOString(),
}); });
await sut.handleMetadataExtraction({ id: assetStub.image.id }); await sut.handleMetadataExtraction({ id: assetStub.image.id });
expect(mocks.asset.getByIds).toHaveBeenCalledWith([assetStub.image.id], { faces: { person: false } }); expect(mocks.asset.getById).toHaveBeenCalledWith(assetStub.image.id, { faces: { person: false } });
expect(mocks.asset.upsertExif).toHaveBeenCalledWith( expect(mocks.asset.upsertExif).toHaveBeenCalledWith(
expect.objectContaining({ dateTimeOriginal: fileModifiedAt }), expect.objectContaining({ dateTimeOriginal: fileModifiedAt }),
); );
@ -167,14 +167,14 @@ describe(MetadataService.name, () => {
it('should take the file creation date when missing exif and earlier than modification date', async () => { 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 fileCreatedAt = new Date('2021-01-01T00:00:00.000Z');
const fileModifiedAt = new Date('2022-01-01T00:00:00.000Z'); const fileModifiedAt = new Date('2022-01-01T00:00:00.000Z');
mocks.asset.getByIds.mockResolvedValue([assetStub.image]); mocks.asset.getById.mockResolvedValue(assetStub.image);
mockReadTags({ mockReadTags({
FileCreateDate: fileCreatedAt.toISOString(), FileCreateDate: fileCreatedAt.toISOString(),
FileModifyDate: fileModifiedAt.toISOString(), FileModifyDate: fileModifiedAt.toISOString(),
}); });
await sut.handleMetadataExtraction({ id: assetStub.image.id }); await sut.handleMetadataExtraction({ id: assetStub.image.id });
expect(mocks.asset.getByIds).toHaveBeenCalledWith([assetStub.image.id], { faces: { person: false } }); expect(mocks.asset.getById).toHaveBeenCalledWith(assetStub.image.id, { faces: { person: false } });
expect(mocks.asset.upsertExif).toHaveBeenCalledWith(expect.objectContaining({ dateTimeOriginal: fileCreatedAt })); expect(mocks.asset.upsertExif).toHaveBeenCalledWith(expect.objectContaining({ dateTimeOriginal: fileCreatedAt }));
expect(mocks.asset.update).toHaveBeenCalledWith({ expect(mocks.asset.update).toHaveBeenCalledWith({
id: assetStub.image.id, id: assetStub.image.id,
@ -187,7 +187,7 @@ describe(MetadataService.name, () => {
it('should account for the server being in a non-UTC timezone', async () => { it('should account for the server being in a non-UTC timezone', async () => {
process.env.TZ = 'America/Los_Angeles'; process.env.TZ = 'America/Los_Angeles';
mocks.asset.getByIds.mockResolvedValue([assetStub.sidecar]); mocks.asset.getById.mockResolvedValue(assetStub.sidecar);
mockReadTags({ DateTimeOriginal: '2022:01:01 00:00:00' }); mockReadTags({ DateTimeOriginal: '2022:01:01 00:00:00' });
await sut.handleMetadataExtraction({ id: assetStub.image.id }); await sut.handleMetadataExtraction({ id: assetStub.image.id });
@ -205,7 +205,7 @@ describe(MetadataService.name, () => {
}); });
it('should handle lists of numbers', async () => { it('should handle lists of numbers', async () => {
mocks.asset.getByIds.mockResolvedValue([assetStub.image]); mocks.asset.getById.mockResolvedValue(assetStub.image);
mockReadTags({ mockReadTags({
ISO: [160], ISO: [160],
FileCreateDate: assetStub.image.fileCreatedAt.toISOString(), FileCreateDate: assetStub.image.fileCreatedAt.toISOString(),
@ -213,7 +213,7 @@ describe(MetadataService.name, () => {
}); });
await sut.handleMetadataExtraction({ id: assetStub.image.id }); await sut.handleMetadataExtraction({ id: assetStub.image.id });
expect(mocks.asset.getByIds).toHaveBeenCalledWith([assetStub.image.id], { faces: { person: false } }); expect(mocks.asset.getById).toHaveBeenCalledWith(assetStub.image.id, { faces: { person: false } });
expect(mocks.asset.upsertExif).toHaveBeenCalledWith(expect.objectContaining({ iso: 160 })); expect(mocks.asset.upsertExif).toHaveBeenCalledWith(expect.objectContaining({ iso: 160 }));
expect(mocks.asset.update).toHaveBeenCalledWith({ expect(mocks.asset.update).toHaveBeenCalledWith({
id: assetStub.image.id, id: assetStub.image.id,
@ -225,7 +225,7 @@ describe(MetadataService.name, () => {
}); });
it('should apply reverse geocoding', async () => { it('should apply reverse geocoding', async () => {
mocks.asset.getByIds.mockResolvedValue([assetStub.withLocation]); mocks.asset.getById.mockResolvedValue(assetStub.withLocation);
mocks.systemMetadata.get.mockResolvedValue({ reverseGeocoding: { enabled: true } }); mocks.systemMetadata.get.mockResolvedValue({ reverseGeocoding: { enabled: true } });
mocks.map.reverseGeocode.mockResolvedValue({ city: 'City', state: 'State', country: 'Country' }); mocks.map.reverseGeocode.mockResolvedValue({ city: 'City', state: 'State', country: 'Country' });
mockReadTags({ mockReadTags({
@ -236,7 +236,7 @@ describe(MetadataService.name, () => {
}); });
await sut.handleMetadataExtraction({ id: assetStub.image.id }); await sut.handleMetadataExtraction({ id: assetStub.image.id });
expect(mocks.asset.getByIds).toHaveBeenCalledWith([assetStub.image.id], { faces: { person: false } }); expect(mocks.asset.getById).toHaveBeenCalledWith(assetStub.image.id, { faces: { person: false } });
expect(mocks.asset.upsertExif).toHaveBeenCalledWith( expect(mocks.asset.upsertExif).toHaveBeenCalledWith(
expect.objectContaining({ city: 'City', state: 'State', country: 'Country' }), expect.objectContaining({ city: 'City', state: 'State', country: 'Country' }),
); );
@ -250,19 +250,19 @@ describe(MetadataService.name, () => {
}); });
it('should discard latitude and longitude on null island', async () => { it('should discard latitude and longitude on null island', async () => {
mocks.asset.getByIds.mockResolvedValue([assetStub.withLocation]); mocks.asset.getById.mockResolvedValue(assetStub.withLocation);
mockReadTags({ mockReadTags({
GPSLatitude: 0, GPSLatitude: 0,
GPSLongitude: 0, GPSLongitude: 0,
}); });
await sut.handleMetadataExtraction({ id: assetStub.image.id }); await sut.handleMetadataExtraction({ id: assetStub.image.id });
expect(mocks.asset.getByIds).toHaveBeenCalledWith([assetStub.image.id], { faces: { person: false } }); expect(mocks.asset.getById).toHaveBeenCalledWith(assetStub.image.id, { faces: { person: false } });
expect(mocks.asset.upsertExif).toHaveBeenCalledWith(expect.objectContaining({ latitude: null, longitude: null })); expect(mocks.asset.upsertExif).toHaveBeenCalledWith(expect.objectContaining({ latitude: null, longitude: null }));
}); });
it('should extract tags from TagsList', async () => { it('should extract tags from TagsList', async () => {
mocks.asset.getByIds.mockResolvedValue([assetStub.image]); mocks.asset.getById.mockResolvedValue(assetStub.image);
mockReadTags({ TagsList: ['Parent'] }); mockReadTags({ TagsList: ['Parent'] });
mocks.tag.upsertValue.mockResolvedValue(tagStub.parentUpsert); mocks.tag.upsertValue.mockResolvedValue(tagStub.parentUpsert);
@ -272,7 +272,7 @@ describe(MetadataService.name, () => {
}); });
it('should extract hierarchy from TagsList', async () => { it('should extract hierarchy from TagsList', async () => {
mocks.asset.getByIds.mockResolvedValue([assetStub.image]); mocks.asset.getById.mockResolvedValue(assetStub.image);
mockReadTags({ TagsList: ['Parent/Child'] }); mockReadTags({ TagsList: ['Parent/Child'] });
mocks.tag.upsertValue.mockResolvedValueOnce(tagStub.parentUpsert); mocks.tag.upsertValue.mockResolvedValueOnce(tagStub.parentUpsert);
mocks.tag.upsertValue.mockResolvedValueOnce(tagStub.childUpsert); mocks.tag.upsertValue.mockResolvedValueOnce(tagStub.childUpsert);
@ -292,7 +292,7 @@ describe(MetadataService.name, () => {
}); });
it('should extract tags from Keywords as a string', async () => { it('should extract tags from Keywords as a string', async () => {
mocks.asset.getByIds.mockResolvedValue([assetStub.image]); mocks.asset.getById.mockResolvedValue(assetStub.image);
mockReadTags({ Keywords: 'Parent' }); mockReadTags({ Keywords: 'Parent' });
mocks.tag.upsertValue.mockResolvedValue(tagStub.parentUpsert); mocks.tag.upsertValue.mockResolvedValue(tagStub.parentUpsert);
@ -302,7 +302,7 @@ describe(MetadataService.name, () => {
}); });
it('should extract tags from Keywords as a list', async () => { it('should extract tags from Keywords as a list', async () => {
mocks.asset.getByIds.mockResolvedValue([assetStub.image]); mocks.asset.getById.mockResolvedValue(assetStub.image);
mockReadTags({ Keywords: ['Parent'] }); mockReadTags({ Keywords: ['Parent'] });
mocks.tag.upsertValue.mockResolvedValue(tagStub.parentUpsert); mocks.tag.upsertValue.mockResolvedValue(tagStub.parentUpsert);
@ -312,7 +312,7 @@ describe(MetadataService.name, () => {
}); });
it('should extract tags from Keywords as a list with a number', async () => { it('should extract tags from Keywords as a list with a number', async () => {
mocks.asset.getByIds.mockResolvedValue([assetStub.image]); mocks.asset.getById.mockResolvedValue(assetStub.image);
mockReadTags({ Keywords: ['Parent', 2024] }); mockReadTags({ Keywords: ['Parent', 2024] });
mocks.tag.upsertValue.mockResolvedValue(tagStub.parentUpsert); mocks.tag.upsertValue.mockResolvedValue(tagStub.parentUpsert);
@ -323,7 +323,7 @@ describe(MetadataService.name, () => {
}); });
it('should extract hierarchal tags from Keywords', async () => { it('should extract hierarchal tags from Keywords', async () => {
mocks.asset.getByIds.mockResolvedValue([assetStub.image]); mocks.asset.getById.mockResolvedValue(assetStub.image);
mockReadTags({ Keywords: 'Parent/Child' }); mockReadTags({ Keywords: 'Parent/Child' });
mocks.tag.upsertValue.mockResolvedValue(tagStub.parentUpsert); mocks.tag.upsertValue.mockResolvedValue(tagStub.parentUpsert);
@ -342,7 +342,7 @@ describe(MetadataService.name, () => {
}); });
it('should ignore Keywords when TagsList is present', async () => { it('should ignore Keywords when TagsList is present', async () => {
mocks.asset.getByIds.mockResolvedValue([assetStub.image]); mocks.asset.getById.mockResolvedValue(assetStub.image);
mockReadTags({ Keywords: 'Child', TagsList: ['Parent/Child'] }); mockReadTags({ Keywords: 'Child', TagsList: ['Parent/Child'] });
mocks.tag.upsertValue.mockResolvedValue(tagStub.parentUpsert); mocks.tag.upsertValue.mockResolvedValue(tagStub.parentUpsert);
@ -361,7 +361,7 @@ describe(MetadataService.name, () => {
}); });
it('should extract hierarchy from HierarchicalSubject', async () => { it('should extract hierarchy from HierarchicalSubject', async () => {
mocks.asset.getByIds.mockResolvedValue([assetStub.image]); mocks.asset.getById.mockResolvedValue(assetStub.image);
mockReadTags({ HierarchicalSubject: ['Parent|Child', 'TagA'] }); mockReadTags({ HierarchicalSubject: ['Parent|Child', 'TagA'] });
mocks.tag.upsertValue.mockResolvedValueOnce(tagStub.parentUpsert); mocks.tag.upsertValue.mockResolvedValueOnce(tagStub.parentUpsert);
mocks.tag.upsertValue.mockResolvedValueOnce(tagStub.childUpsert); mocks.tag.upsertValue.mockResolvedValueOnce(tagStub.childUpsert);
@ -382,7 +382,7 @@ describe(MetadataService.name, () => {
}); });
it('should extract tags from HierarchicalSubject as a list with a number', async () => { it('should extract tags from HierarchicalSubject as a list with a number', async () => {
mocks.asset.getByIds.mockResolvedValue([assetStub.image]); mocks.asset.getById.mockResolvedValue(assetStub.image);
mockReadTags({ HierarchicalSubject: ['Parent', 2024] }); mockReadTags({ HierarchicalSubject: ['Parent', 2024] });
mocks.tag.upsertValue.mockResolvedValue(tagStub.parentUpsert); mocks.tag.upsertValue.mockResolvedValue(tagStub.parentUpsert);
@ -393,7 +393,7 @@ describe(MetadataService.name, () => {
}); });
it('should extract ignore / characters in a HierarchicalSubject tag', async () => { it('should extract ignore / characters in a HierarchicalSubject tag', async () => {
mocks.asset.getByIds.mockResolvedValue([assetStub.image]); mocks.asset.getById.mockResolvedValue(assetStub.image);
mockReadTags({ HierarchicalSubject: ['Mom/Dad'] }); mockReadTags({ HierarchicalSubject: ['Mom/Dad'] });
mocks.tag.upsertValue.mockResolvedValueOnce(tagStub.parentUpsert); mocks.tag.upsertValue.mockResolvedValueOnce(tagStub.parentUpsert);
@ -407,7 +407,7 @@ describe(MetadataService.name, () => {
}); });
it('should ignore HierarchicalSubject when TagsList is present', async () => { it('should ignore HierarchicalSubject when TagsList is present', async () => {
mocks.asset.getByIds.mockResolvedValue([assetStub.image]); mocks.asset.getById.mockResolvedValue(assetStub.image);
mockReadTags({ HierarchicalSubject: ['Parent2|Child2'], TagsList: ['Parent/Child'] }); mockReadTags({ HierarchicalSubject: ['Parent2|Child2'], TagsList: ['Parent/Child'] });
mocks.tag.upsertValue.mockResolvedValue(tagStub.parentUpsert); mocks.tag.upsertValue.mockResolvedValue(tagStub.parentUpsert);
@ -426,7 +426,7 @@ describe(MetadataService.name, () => {
}); });
it('should remove existing tags', async () => { it('should remove existing tags', async () => {
mocks.asset.getByIds.mockResolvedValue([assetStub.image]); mocks.asset.getById.mockResolvedValue(assetStub.image);
mockReadTags({}); mockReadTags({});
await sut.handleMetadataExtraction({ id: assetStub.image.id }); await sut.handleMetadataExtraction({ id: assetStub.image.id });
@ -435,11 +435,11 @@ describe(MetadataService.name, () => {
}); });
it('should not apply motion photos if asset is video', async () => { it('should not apply motion photos if asset is video', async () => {
mocks.asset.getByIds.mockResolvedValue([{ ...assetStub.livePhotoMotionAsset, isVisible: true }]); mocks.asset.getById.mockResolvedValue({ ...assetStub.livePhotoMotionAsset, isVisible: true });
mocks.media.probe.mockResolvedValue(probeStub.matroskaContainer); mocks.media.probe.mockResolvedValue(probeStub.matroskaContainer);
await sut.handleMetadataExtraction({ id: assetStub.livePhotoMotionAsset.id }); await sut.handleMetadataExtraction({ id: assetStub.livePhotoMotionAsset.id });
expect(mocks.asset.getByIds).toHaveBeenCalledWith([assetStub.livePhotoMotionAsset.id], { expect(mocks.asset.getById).toHaveBeenCalledWith(assetStub.livePhotoMotionAsset.id, {
faces: { person: false }, faces: { person: false },
}); });
expect(mocks.storage.createOrOverwriteFile).not.toHaveBeenCalled(); expect(mocks.storage.createOrOverwriteFile).not.toHaveBeenCalled();
@ -451,7 +451,7 @@ describe(MetadataService.name, () => {
}); });
it('should handle an invalid Directory Item', async () => { it('should handle an invalid Directory Item', async () => {
mocks.asset.getByIds.mockResolvedValue([assetStub.image]); mocks.asset.getById.mockResolvedValue(assetStub.image);
mockReadTags({ mockReadTags({
MotionPhoto: 1, MotionPhoto: 1,
ContainerDirectory: [{ Foo: 100 }], ContainerDirectory: [{ Foo: 100 }],
@ -461,20 +461,20 @@ describe(MetadataService.name, () => {
}); });
it('should extract the correct video orientation', async () => { it('should extract the correct video orientation', async () => {
mocks.asset.getByIds.mockResolvedValue([assetStub.video]); mocks.asset.getById.mockResolvedValue(assetStub.video);
mocks.media.probe.mockResolvedValue(probeStub.videoStreamVertical2160p); mocks.media.probe.mockResolvedValue(probeStub.videoStreamVertical2160p);
mockReadTags({}); mockReadTags({});
await sut.handleMetadataExtraction({ id: assetStub.video.id }); await sut.handleMetadataExtraction({ id: assetStub.video.id });
expect(mocks.asset.getByIds).toHaveBeenCalledWith([assetStub.video.id], { faces: { person: false } }); expect(mocks.asset.getById).toHaveBeenCalledWith(assetStub.video.id, { faces: { person: false } });
expect(mocks.asset.upsertExif).toHaveBeenCalledWith( expect(mocks.asset.upsertExif).toHaveBeenCalledWith(
expect.objectContaining({ orientation: ExifOrientation.Rotate270CW.toString() }), expect.objectContaining({ orientation: ExifOrientation.Rotate270CW.toString() }),
); );
}); });
it('should extract the MotionPhotoVideo tag from Samsung HEIC motion photos', async () => { it('should extract the MotionPhotoVideo tag from Samsung HEIC motion photos', async () => {
mocks.asset.getByIds.mockResolvedValue([{ ...assetStub.livePhotoWithOriginalFileName, livePhotoVideoId: null }]); mocks.asset.getById.mockResolvedValue({ ...assetStub.livePhotoWithOriginalFileName, livePhotoVideoId: null });
mockReadTags({ mockReadTags({
Directory: 'foo/bar/', Directory: 'foo/bar/',
MotionPhoto: 1, MotionPhoto: 1,
@ -497,7 +497,7 @@ describe(MetadataService.name, () => {
assetStub.livePhotoWithOriginalFileName.originalPath, assetStub.livePhotoWithOriginalFileName.originalPath,
'MotionPhotoVideo', 'MotionPhotoVideo',
); );
expect(mocks.asset.getByIds).toHaveBeenCalledWith([assetStub.livePhotoWithOriginalFileName.id], { expect(mocks.asset.getById).toHaveBeenCalledWith(assetStub.livePhotoWithOriginalFileName.id, {
faces: { person: false }, faces: { person: false },
}); });
expect(mocks.asset.create).toHaveBeenCalledWith({ expect(mocks.asset.create).toHaveBeenCalledWith({
@ -525,7 +525,7 @@ describe(MetadataService.name, () => {
}); });
it('should extract the EmbeddedVideo tag from Samsung JPEG motion photos', async () => { it('should extract the EmbeddedVideo tag from Samsung JPEG motion photos', async () => {
mocks.asset.getByIds.mockResolvedValue([{ ...assetStub.livePhotoWithOriginalFileName, livePhotoVideoId: null }]); mocks.asset.getById.mockResolvedValue({ ...assetStub.livePhotoWithOriginalFileName, livePhotoVideoId: null });
mockReadTags({ mockReadTags({
Directory: 'foo/bar/', Directory: 'foo/bar/',
EmbeddedVideoFile: new BinaryField(0, ''), EmbeddedVideoFile: new BinaryField(0, ''),
@ -545,7 +545,7 @@ describe(MetadataService.name, () => {
assetStub.livePhotoWithOriginalFileName.originalPath, assetStub.livePhotoWithOriginalFileName.originalPath,
'EmbeddedVideoFile', 'EmbeddedVideoFile',
); );
expect(mocks.asset.getByIds).toHaveBeenCalledWith([assetStub.livePhotoWithOriginalFileName.id], { expect(mocks.asset.getById).toHaveBeenCalledWith(assetStub.livePhotoWithOriginalFileName.id, {
faces: { person: false }, faces: { person: false },
}); });
expect(mocks.asset.create).toHaveBeenCalledWith({ expect(mocks.asset.create).toHaveBeenCalledWith({
@ -573,7 +573,7 @@ describe(MetadataService.name, () => {
}); });
it('should extract the motion photo video from the XMP directory entry ', async () => { it('should extract the motion photo video from the XMP directory entry ', async () => {
mocks.asset.getByIds.mockResolvedValue([{ ...assetStub.livePhotoWithOriginalFileName, livePhotoVideoId: null }]); mocks.asset.getById.mockResolvedValue({ ...assetStub.livePhotoWithOriginalFileName, livePhotoVideoId: null });
mockReadTags({ mockReadTags({
Directory: 'foo/bar/', Directory: 'foo/bar/',
MotionPhoto: 1, MotionPhoto: 1,
@ -589,7 +589,7 @@ describe(MetadataService.name, () => {
mocks.storage.readFile.mockResolvedValue(video); mocks.storage.readFile.mockResolvedValue(video);
await sut.handleMetadataExtraction({ id: assetStub.livePhotoWithOriginalFileName.id }); await sut.handleMetadataExtraction({ id: assetStub.livePhotoWithOriginalFileName.id });
expect(mocks.asset.getByIds).toHaveBeenCalledWith([assetStub.livePhotoWithOriginalFileName.id], { expect(mocks.asset.getById).toHaveBeenCalledWith(assetStub.livePhotoWithOriginalFileName.id, {
faces: { person: false }, faces: { person: false },
}); });
expect(mocks.storage.readFile).toHaveBeenCalledWith( expect(mocks.storage.readFile).toHaveBeenCalledWith(
@ -621,7 +621,7 @@ describe(MetadataService.name, () => {
}); });
it('should delete old motion photo video assets if they do not match what is extracted', async () => { it('should delete old motion photo video assets if they do not match what is extracted', async () => {
mocks.asset.getByIds.mockResolvedValue([assetStub.livePhotoWithOriginalFileName]); mocks.asset.getById.mockResolvedValue(assetStub.livePhotoWithOriginalFileName);
mockReadTags({ mockReadTags({
Directory: 'foo/bar/', Directory: 'foo/bar/',
MotionPhoto: 1, MotionPhoto: 1,
@ -647,7 +647,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 () => { 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.asset.getById.mockResolvedValue(assetStub.livePhotoStillAsset);
mockReadTags({ mockReadTags({
Directory: 'foo/bar/', Directory: 'foo/bar/',
MotionPhoto: 1, MotionPhoto: 1,
@ -669,7 +669,7 @@ 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 () => { 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.asset.getById.mockResolvedValue({ ...assetStub.livePhotoStillAsset, livePhotoVideoId: null });
mockReadTags({ mockReadTags({
Directory: 'foo/bar/', Directory: 'foo/bar/',
MotionPhoto: 1, MotionPhoto: 1,
@ -694,9 +694,11 @@ describe(MetadataService.name, () => {
}); });
it('should not update storage usage if motion photo is external', async () => { it('should not update storage usage if motion photo is external', async () => {
mocks.asset.getByIds.mockResolvedValue([ mocks.asset.getById.mockResolvedValue({
{ ...assetStub.livePhotoStillAsset, livePhotoVideoId: null, isExternal: true }, ...assetStub.livePhotoStillAsset,
]); livePhotoVideoId: null,
isExternal: true,
});
mockReadTags({ mockReadTags({
Directory: 'foo/bar/', Directory: 'foo/bar/',
MotionPhoto: 1, MotionPhoto: 1,
@ -738,11 +740,11 @@ describe(MetadataService.name, () => {
tz: 'UTC-11:30', tz: 'UTC-11:30',
Rating: 3, Rating: 3,
}; };
mocks.asset.getByIds.mockResolvedValue([assetStub.image]); mocks.asset.getById.mockResolvedValue(assetStub.image);
mockReadTags(tags); mockReadTags(tags);
await sut.handleMetadataExtraction({ id: assetStub.image.id }); await sut.handleMetadataExtraction({ id: assetStub.image.id });
expect(mocks.asset.getByIds).toHaveBeenCalledWith([assetStub.image.id], { faces: { person: false } }); expect(mocks.asset.getById).toHaveBeenCalledWith(assetStub.image.id, { faces: { person: false } });
expect(mocks.asset.upsertExif).toHaveBeenCalledWith({ expect(mocks.asset.upsertExif).toHaveBeenCalledWith({
assetId: assetStub.image.id, assetId: assetStub.image.id,
bitsPerSample: expect.any(Number), bitsPerSample: expect.any(Number),
@ -798,11 +800,11 @@ describe(MetadataService.name, () => {
DateTimeOriginal: ExifDateTime.fromISO(someDate + '+00:00'), DateTimeOriginal: ExifDateTime.fromISO(someDate + '+00:00'),
tz: undefined, tz: undefined,
}; };
mocks.asset.getByIds.mockResolvedValue([assetStub.image]); mocks.asset.getById.mockResolvedValue(assetStub.image);
mockReadTags(tags); mockReadTags(tags);
await sut.handleMetadataExtraction({ id: assetStub.image.id }); await sut.handleMetadataExtraction({ id: assetStub.image.id });
expect(mocks.asset.getByIds).toHaveBeenCalledWith([assetStub.image.id], { faces: { person: false } }); expect(mocks.asset.getById).toHaveBeenCalledWith(assetStub.image.id, { faces: { person: false } });
expect(mocks.asset.upsertExif).toHaveBeenCalledWith( expect(mocks.asset.upsertExif).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({
timeZone: 'UTC+0', timeZone: 'UTC+0',
@ -811,7 +813,7 @@ describe(MetadataService.name, () => {
}); });
it('should extract duration', async () => { it('should extract duration', async () => {
mocks.asset.getByIds.mockResolvedValue([{ ...assetStub.video }]); mocks.asset.getById.mockResolvedValue({ ...assetStub.video });
mocks.media.probe.mockResolvedValue({ mocks.media.probe.mockResolvedValue({
...probeStub.videoStreamH264, ...probeStub.videoStreamH264,
format: { format: {
@ -822,7 +824,7 @@ describe(MetadataService.name, () => {
await sut.handleMetadataExtraction({ id: assetStub.video.id }); await sut.handleMetadataExtraction({ id: assetStub.video.id });
expect(mocks.asset.getByIds).toHaveBeenCalledWith([assetStub.video.id], { faces: { person: false } }); expect(mocks.asset.getById).toHaveBeenCalledWith(assetStub.video.id, { faces: { person: false } });
expect(mocks.asset.upsertExif).toHaveBeenCalled(); expect(mocks.asset.upsertExif).toHaveBeenCalled();
expect(mocks.asset.update).toHaveBeenCalledWith( expect(mocks.asset.update).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({
@ -833,7 +835,7 @@ describe(MetadataService.name, () => {
}); });
it('should only extract duration for videos', async () => { it('should only extract duration for videos', async () => {
mocks.asset.getByIds.mockResolvedValue([{ ...assetStub.image }]); mocks.asset.getById.mockResolvedValue({ ...assetStub.image });
mocks.media.probe.mockResolvedValue({ mocks.media.probe.mockResolvedValue({
...probeStub.videoStreamH264, ...probeStub.videoStreamH264,
format: { format: {
@ -843,7 +845,7 @@ describe(MetadataService.name, () => {
}); });
await sut.handleMetadataExtraction({ id: assetStub.image.id }); await sut.handleMetadataExtraction({ id: assetStub.image.id });
expect(mocks.asset.getByIds).toHaveBeenCalledWith([assetStub.image.id], { faces: { person: false } }); expect(mocks.asset.getById).toHaveBeenCalledWith(assetStub.image.id, { faces: { person: false } });
expect(mocks.asset.upsertExif).toHaveBeenCalled(); expect(mocks.asset.upsertExif).toHaveBeenCalled();
expect(mocks.asset.update).toHaveBeenCalledWith( expect(mocks.asset.update).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({
@ -854,7 +856,7 @@ describe(MetadataService.name, () => {
}); });
it('should omit duration of zero', async () => { it('should omit duration of zero', async () => {
mocks.asset.getByIds.mockResolvedValue([{ ...assetStub.video }]); mocks.asset.getById.mockResolvedValue({ ...assetStub.video });
mocks.media.probe.mockResolvedValue({ mocks.media.probe.mockResolvedValue({
...probeStub.videoStreamH264, ...probeStub.videoStreamH264,
format: { format: {
@ -865,7 +867,7 @@ describe(MetadataService.name, () => {
await sut.handleMetadataExtraction({ id: assetStub.video.id }); await sut.handleMetadataExtraction({ id: assetStub.video.id });
expect(mocks.asset.getByIds).toHaveBeenCalledWith([assetStub.video.id], { faces: { person: false } }); expect(mocks.asset.getById).toHaveBeenCalledWith(assetStub.video.id, { faces: { person: false } });
expect(mocks.asset.upsertExif).toHaveBeenCalled(); expect(mocks.asset.upsertExif).toHaveBeenCalled();
expect(mocks.asset.update).toHaveBeenCalledWith( expect(mocks.asset.update).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({
@ -876,7 +878,7 @@ describe(MetadataService.name, () => {
}); });
it('should a handle duration of 1 week', async () => { it('should a handle duration of 1 week', async () => {
mocks.asset.getByIds.mockResolvedValue([{ ...assetStub.video }]); mocks.asset.getById.mockResolvedValue({ ...assetStub.video });
mocks.media.probe.mockResolvedValue({ mocks.media.probe.mockResolvedValue({
...probeStub.videoStreamH264, ...probeStub.videoStreamH264,
format: { format: {
@ -887,7 +889,7 @@ describe(MetadataService.name, () => {
await sut.handleMetadataExtraction({ id: assetStub.video.id }); await sut.handleMetadataExtraction({ id: assetStub.video.id });
expect(mocks.asset.getByIds).toHaveBeenCalledWith([assetStub.video.id], { faces: { person: false } }); expect(mocks.asset.getById).toHaveBeenCalledWith(assetStub.video.id, { faces: { person: false } });
expect(mocks.asset.upsertExif).toHaveBeenCalled(); expect(mocks.asset.upsertExif).toHaveBeenCalled();
expect(mocks.asset.update).toHaveBeenCalledWith( expect(mocks.asset.update).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({
@ -898,7 +900,7 @@ describe(MetadataService.name, () => {
}); });
it('should ignore duration from exif data', async () => { it('should ignore duration from exif data', async () => {
mocks.asset.getByIds.mockResolvedValue([assetStub.image]); mocks.asset.getById.mockResolvedValue(assetStub.image);
mockReadTags({}, { Duration: { Value: 123 } }); mockReadTags({}, { Duration: { Value: 123 } });
await sut.handleMetadataExtraction({ id: assetStub.image.id }); await sut.handleMetadataExtraction({ id: assetStub.image.id });
@ -906,7 +908,7 @@ describe(MetadataService.name, () => {
}); });
it('should trim whitespace from description', async () => { it('should trim whitespace from description', async () => {
mocks.asset.getByIds.mockResolvedValue([assetStub.image]); mocks.asset.getById.mockResolvedValue(assetStub.image);
mockReadTags({ Description: '\t \v \f \n \r' }); mockReadTags({ Description: '\t \v \f \n \r' });
await sut.handleMetadataExtraction({ id: assetStub.image.id }); await sut.handleMetadataExtraction({ id: assetStub.image.id });
@ -926,7 +928,7 @@ describe(MetadataService.name, () => {
}); });
it('should handle a numeric description', async () => { it('should handle a numeric description', async () => {
mocks.asset.getByIds.mockResolvedValue([assetStub.image]); mocks.asset.getById.mockResolvedValue(assetStub.image);
mockReadTags({ Description: 1000 }); mockReadTags({ Description: 1000 });
await sut.handleMetadataExtraction({ id: assetStub.image.id }); await sut.handleMetadataExtraction({ id: assetStub.image.id });
@ -938,7 +940,7 @@ describe(MetadataService.name, () => {
}); });
it('should skip importing metadata when the feature is disabled', async () => { it('should skip importing metadata when the feature is disabled', async () => {
mocks.asset.getByIds.mockResolvedValue([assetStub.primaryImage]); mocks.asset.getById.mockResolvedValue(assetStub.primaryImage);
mocks.systemMetadata.get.mockResolvedValue({ metadata: { faces: { import: false } } }); mocks.systemMetadata.get.mockResolvedValue({ metadata: { faces: { import: false } } });
mockReadTags(metadataStub.withFace); mockReadTags(metadataStub.withFace);
await sut.handleMetadataExtraction({ id: assetStub.image.id }); await sut.handleMetadataExtraction({ id: assetStub.image.id });
@ -946,7 +948,7 @@ describe(MetadataService.name, () => {
}); });
it('should skip importing metadata face for assets without tags.RegionInfo', async () => { it('should skip importing metadata face for assets without tags.RegionInfo', async () => {
mocks.asset.getByIds.mockResolvedValue([assetStub.primaryImage]); mocks.asset.getById.mockResolvedValue(assetStub.primaryImage);
mocks.systemMetadata.get.mockResolvedValue({ metadata: { faces: { import: true } } }); mocks.systemMetadata.get.mockResolvedValue({ metadata: { faces: { import: true } } });
mockReadTags(metadataStub.empty); mockReadTags(metadataStub.empty);
await sut.handleMetadataExtraction({ id: assetStub.image.id }); await sut.handleMetadataExtraction({ id: assetStub.image.id });
@ -954,7 +956,7 @@ describe(MetadataService.name, () => {
}); });
it('should skip importing faces without name', async () => { it('should skip importing faces without name', async () => {
mocks.asset.getByIds.mockResolvedValue([assetStub.primaryImage]); mocks.asset.getById.mockResolvedValue(assetStub.primaryImage);
mocks.systemMetadata.get.mockResolvedValue({ metadata: { faces: { import: true } } }); mocks.systemMetadata.get.mockResolvedValue({ metadata: { faces: { import: true } } });
mockReadTags(metadataStub.withFaceNoName); mockReadTags(metadataStub.withFaceNoName);
mocks.person.getDistinctNames.mockResolvedValue([]); mocks.person.getDistinctNames.mockResolvedValue([]);
@ -966,7 +968,7 @@ describe(MetadataService.name, () => {
}); });
it('should skip importing faces with empty name', async () => { it('should skip importing faces with empty name', async () => {
mocks.asset.getByIds.mockResolvedValue([assetStub.primaryImage]); mocks.asset.getById.mockResolvedValue(assetStub.primaryImage);
mocks.systemMetadata.get.mockResolvedValue({ metadata: { faces: { import: true } } }); mocks.systemMetadata.get.mockResolvedValue({ metadata: { faces: { import: true } } });
mockReadTags(metadataStub.withFaceEmptyName); mockReadTags(metadataStub.withFaceEmptyName);
mocks.person.getDistinctNames.mockResolvedValue([]); mocks.person.getDistinctNames.mockResolvedValue([]);
@ -978,14 +980,14 @@ describe(MetadataService.name, () => {
}); });
it('should apply metadata face tags creating new persons', async () => { it('should apply metadata face tags creating new persons', async () => {
mocks.asset.getByIds.mockResolvedValue([assetStub.primaryImage]); mocks.asset.getById.mockResolvedValue(assetStub.primaryImage);
mocks.systemMetadata.get.mockResolvedValue({ metadata: { faces: { import: true } } }); mocks.systemMetadata.get.mockResolvedValue({ metadata: { faces: { import: true } } });
mockReadTags(metadataStub.withFace); mockReadTags(metadataStub.withFace);
mocks.person.getDistinctNames.mockResolvedValue([]); mocks.person.getDistinctNames.mockResolvedValue([]);
mocks.person.createAll.mockResolvedValue([personStub.withName.id]); mocks.person.createAll.mockResolvedValue([personStub.withName.id]);
mocks.person.update.mockResolvedValue(personStub.withName); mocks.person.update.mockResolvedValue(personStub.withName);
await sut.handleMetadataExtraction({ id: assetStub.primaryImage.id }); await sut.handleMetadataExtraction({ id: assetStub.primaryImage.id });
expect(mocks.asset.getByIds).toHaveBeenCalledWith([assetStub.primaryImage.id], { faces: { person: false } }); expect(mocks.asset.getById).toHaveBeenCalledWith(assetStub.primaryImage.id, { faces: { person: false } });
expect(mocks.person.getDistinctNames).toHaveBeenCalledWith(assetStub.primaryImage.ownerId, { withHidden: true }); expect(mocks.person.getDistinctNames).toHaveBeenCalledWith(assetStub.primaryImage.ownerId, { withHidden: true });
expect(mocks.person.createAll).toHaveBeenCalledWith([ expect(mocks.person.createAll).toHaveBeenCalledWith([
expect.objectContaining({ name: personStub.withName.name }), expect.objectContaining({ name: personStub.withName.name }),
@ -1019,14 +1021,14 @@ describe(MetadataService.name, () => {
}); });
it('should assign metadata face tags to existing persons', async () => { it('should assign metadata face tags to existing persons', async () => {
mocks.asset.getByIds.mockResolvedValue([assetStub.primaryImage]); mocks.asset.getById.mockResolvedValue(assetStub.primaryImage);
mocks.systemMetadata.get.mockResolvedValue({ metadata: { faces: { import: true } } }); mocks.systemMetadata.get.mockResolvedValue({ metadata: { faces: { import: true } } });
mockReadTags(metadataStub.withFace); mockReadTags(metadataStub.withFace);
mocks.person.getDistinctNames.mockResolvedValue([{ id: personStub.withName.id, name: personStub.withName.name }]); mocks.person.getDistinctNames.mockResolvedValue([{ id: personStub.withName.id, name: personStub.withName.name }]);
mocks.person.createAll.mockResolvedValue([]); mocks.person.createAll.mockResolvedValue([]);
mocks.person.update.mockResolvedValue(personStub.withName); mocks.person.update.mockResolvedValue(personStub.withName);
await sut.handleMetadataExtraction({ id: assetStub.primaryImage.id }); await sut.handleMetadataExtraction({ id: assetStub.primaryImage.id });
expect(mocks.asset.getByIds).toHaveBeenCalledWith([assetStub.primaryImage.id], { faces: { person: false } }); expect(mocks.asset.getById).toHaveBeenCalledWith(assetStub.primaryImage.id, { faces: { person: false } });
expect(mocks.person.getDistinctNames).toHaveBeenCalledWith(assetStub.primaryImage.ownerId, { withHidden: true }); expect(mocks.person.getDistinctNames).toHaveBeenCalledWith(assetStub.primaryImage.ownerId, { withHidden: true });
expect(mocks.person.createAll).not.toHaveBeenCalled(); expect(mocks.person.createAll).not.toHaveBeenCalled();
expect(mocks.person.refreshFaces).toHaveBeenCalledWith( expect(mocks.person.refreshFaces).toHaveBeenCalledWith(
@ -1051,7 +1053,7 @@ describe(MetadataService.name, () => {
}); });
it('should handle invalid modify date', async () => { it('should handle invalid modify date', async () => {
mocks.asset.getByIds.mockResolvedValue([assetStub.image]); mocks.asset.getById.mockResolvedValue(assetStub.image);
mockReadTags({ ModifyDate: '00:00:00.000' }); mockReadTags({ ModifyDate: '00:00:00.000' });
await sut.handleMetadataExtraction({ id: assetStub.image.id }); await sut.handleMetadataExtraction({ id: assetStub.image.id });
@ -1063,7 +1065,7 @@ describe(MetadataService.name, () => {
}); });
it('should handle invalid rating value', async () => { it('should handle invalid rating value', async () => {
mocks.asset.getByIds.mockResolvedValue([assetStub.image]); mocks.asset.getById.mockResolvedValue(assetStub.image);
mockReadTags({ Rating: 6 }); mockReadTags({ Rating: 6 });
await sut.handleMetadataExtraction({ id: assetStub.image.id }); await sut.handleMetadataExtraction({ id: assetStub.image.id });
@ -1075,7 +1077,7 @@ describe(MetadataService.name, () => {
}); });
it('should handle valid rating value', async () => { it('should handle valid rating value', async () => {
mocks.asset.getByIds.mockResolvedValue([assetStub.image]); mocks.asset.getById.mockResolvedValue(assetStub.image);
mockReadTags({ Rating: 5 }); mockReadTags({ Rating: 5 });
await sut.handleMetadataExtraction({ id: assetStub.image.id }); await sut.handleMetadataExtraction({ id: assetStub.image.id });
@ -1087,7 +1089,7 @@ describe(MetadataService.name, () => {
}); });
it('should handle valid negative rating value', async () => { it('should handle valid negative rating value', async () => {
mocks.asset.getByIds.mockResolvedValue([assetStub.image]); mocks.asset.getById.mockResolvedValue(assetStub.image);
mockReadTags({ Rating: -1 }); mockReadTags({ Rating: -1 });
await sut.handleMetadataExtraction({ id: assetStub.image.id }); await sut.handleMetadataExtraction({ id: assetStub.image.id });
@ -1099,11 +1101,11 @@ describe(MetadataService.name, () => {
}); });
it('should handle livePhotoCID not set', async () => { it('should handle livePhotoCID not set', async () => {
mocks.asset.getByIds.mockResolvedValue([assetStub.image]); mocks.asset.getById.mockResolvedValue(assetStub.image);
await expect(sut.handleMetadataExtraction({ id: assetStub.image.id })).resolves.toBe(JobStatus.SUCCESS); 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.asset.getById).toHaveBeenCalledWith(assetStub.image.id, { faces: { person: false } });
expect(mocks.asset.findLivePhotoMatch).not.toHaveBeenCalled(); expect(mocks.asset.findLivePhotoMatch).not.toHaveBeenCalled();
expect(mocks.asset.update).not.toHaveBeenCalledWith(expect.objectContaining({ isVisible: false })); expect(mocks.asset.update).not.toHaveBeenCalledWith(expect.objectContaining({ isVisible: false }));
expect(mocks.album.removeAsset).not.toHaveBeenCalled(); expect(mocks.album.removeAsset).not.toHaveBeenCalled();
@ -1111,14 +1113,14 @@ describe(MetadataService.name, () => {
it('should handle not finding a match', async () => { it('should handle not finding a match', async () => {
mocks.media.probe.mockResolvedValue(probeStub.videoStreamVertical2160p); mocks.media.probe.mockResolvedValue(probeStub.videoStreamVertical2160p);
mocks.asset.getByIds.mockResolvedValue([assetStub.livePhotoMotionAsset]); mocks.asset.getById.mockResolvedValue(assetStub.livePhotoMotionAsset);
mockReadTags({ ContentIdentifier: 'CID' }); mockReadTags({ ContentIdentifier: 'CID' });
await expect(sut.handleMetadataExtraction({ id: assetStub.livePhotoMotionAsset.id })).resolves.toBe( await expect(sut.handleMetadataExtraction({ id: assetStub.livePhotoMotionAsset.id })).resolves.toBe(
JobStatus.SUCCESS, JobStatus.SUCCESS,
); );
expect(mocks.asset.getByIds).toHaveBeenCalledWith([assetStub.livePhotoMotionAsset.id], { expect(mocks.asset.getById).toHaveBeenCalledWith(assetStub.livePhotoMotionAsset.id, {
faces: { person: false }, faces: { person: false },
}); });
expect(mocks.asset.findLivePhotoMatch).toHaveBeenCalledWith({ expect(mocks.asset.findLivePhotoMatch).toHaveBeenCalledWith({
@ -1132,7 +1134,7 @@ describe(MetadataService.name, () => {
}); });
it('should link photo and video', async () => { it('should link photo and video', async () => {
mocks.asset.getByIds.mockResolvedValue([assetStub.livePhotoStillAsset]); mocks.asset.getById.mockResolvedValue(assetStub.livePhotoStillAsset);
mocks.asset.findLivePhotoMatch.mockResolvedValue(assetStub.livePhotoMotionAsset); mocks.asset.findLivePhotoMatch.mockResolvedValue(assetStub.livePhotoMotionAsset);
mockReadTags({ ContentIdentifier: 'CID' }); mockReadTags({ ContentIdentifier: 'CID' });
@ -1140,7 +1142,7 @@ describe(MetadataService.name, () => {
JobStatus.SUCCESS, JobStatus.SUCCESS,
); );
expect(mocks.asset.getByIds).toHaveBeenCalledWith([assetStub.livePhotoStillAsset.id], { expect(mocks.asset.getById).toHaveBeenCalledWith(assetStub.livePhotoStillAsset.id, {
faces: { person: false }, faces: { person: false },
}); });
expect(mocks.asset.findLivePhotoMatch).toHaveBeenCalledWith({ expect(mocks.asset.findLivePhotoMatch).toHaveBeenCalledWith({
@ -1158,12 +1160,10 @@ describe(MetadataService.name, () => {
}); });
it('should notify clients on live photo link', async () => { it('should notify clients on live photo link', async () => {
mocks.asset.getByIds.mockResolvedValue([ mocks.asset.getById.mockResolvedValue({
{ ...assetStub.livePhotoStillAsset,
...assetStub.livePhotoStillAsset, exifInfo: { livePhotoCID: assetStub.livePhotoMotionAsset.id } as ExifEntity,
exifInfo: { livePhotoCID: assetStub.livePhotoMotionAsset.id } as ExifEntity, });
},
]);
mocks.asset.findLivePhotoMatch.mockResolvedValue(assetStub.livePhotoMotionAsset); mocks.asset.findLivePhotoMatch.mockResolvedValue(assetStub.livePhotoMotionAsset);
mockReadTags({ ContentIdentifier: 'CID' }); mockReadTags({ ContentIdentifier: 'CID' });
@ -1178,12 +1178,10 @@ describe(MetadataService.name, () => {
}); });
it('should search by libraryId', async () => { it('should search by libraryId', async () => {
mocks.asset.getByIds.mockResolvedValue([ mocks.asset.getById.mockResolvedValue({
{ ...assetStub.livePhotoStillAsset,
...assetStub.livePhotoStillAsset, libraryId: 'library-id',
libraryId: 'library-id', });
},
]);
mockReadTags({ ContentIdentifier: 'CID' }); mockReadTags({ ContentIdentifier: 'CID' });
await expect(sut.handleMetadataExtraction({ id: assetStub.livePhotoStillAsset.id })).resolves.toBe( await expect(sut.handleMetadataExtraction({ id: assetStub.livePhotoStillAsset.id })).resolves.toBe(
@ -1204,7 +1202,7 @@ describe(MetadataService.name, () => {
{ Device: { Manufacturer: '1', ModelName: '2' }, AndroidMake: '3', AndroidModel: '4' }, { Device: { Manufacturer: '1', ModelName: '2' }, AndroidMake: '3', AndroidModel: '4' },
{ AndroidMake: '1', AndroidModel: '2' }, { AndroidMake: '1', AndroidModel: '2' },
])('should read camera make and model correct place %s', async (metaData) => { ])('should read camera make and model correct place %s', async (metaData) => {
mocks.asset.getByIds.mockResolvedValue([assetStub.image]); mocks.asset.getById.mockResolvedValue(assetStub.image);
mockReadTags(metaData); mockReadTags(metaData);
await sut.handleMetadataExtraction({ id: assetStub.image.id }); await sut.handleMetadataExtraction({ id: assetStub.image.id });
@ -1251,19 +1249,19 @@ describe(MetadataService.name, () => {
describe('handleSidecarSync', () => { describe('handleSidecarSync', () => {
it('should do nothing if asset could not be found', async () => { it('should do nothing if asset could not be found', async () => {
mocks.asset.getByIds.mockResolvedValue([]); mocks.asset.getById.mockResolvedValue(void 0);
await expect(sut.handleSidecarSync({ id: assetStub.image.id })).resolves.toBe(JobStatus.FAILED); await expect(sut.handleSidecarSync({ id: assetStub.image.id })).resolves.toBe(JobStatus.FAILED);
expect(mocks.asset.update).not.toHaveBeenCalled(); expect(mocks.asset.update).not.toHaveBeenCalled();
}); });
it('should do nothing if asset has no sidecar path', async () => { it('should do nothing if asset has no sidecar path', async () => {
mocks.asset.getByIds.mockResolvedValue([assetStub.image]); mocks.asset.getById.mockResolvedValue(assetStub.image);
await expect(sut.handleSidecarSync({ id: assetStub.image.id })).resolves.toBe(JobStatus.FAILED); await expect(sut.handleSidecarSync({ id: assetStub.image.id })).resolves.toBe(JobStatus.FAILED);
expect(mocks.asset.update).not.toHaveBeenCalled(); expect(mocks.asset.update).not.toHaveBeenCalled();
}); });
it('should set sidecar path if exists (sidecar named photo.ext.xmp)', async () => { it('should set sidecar path if exists (sidecar named photo.ext.xmp)', async () => {
mocks.asset.getByIds.mockResolvedValue([assetStub.sidecar]); mocks.asset.getById.mockResolvedValue(assetStub.sidecar);
mocks.storage.checkFileExists.mockResolvedValue(true); mocks.storage.checkFileExists.mockResolvedValue(true);
await expect(sut.handleSidecarSync({ id: assetStub.sidecar.id })).resolves.toBe(JobStatus.SUCCESS); await expect(sut.handleSidecarSync({ id: assetStub.sidecar.id })).resolves.toBe(JobStatus.SUCCESS);
@ -1278,7 +1276,7 @@ describe(MetadataService.name, () => {
}); });
it('should set sidecar path if exists (sidecar named photo.xmp)', async () => { it('should set sidecar path if exists (sidecar named photo.xmp)', async () => {
mocks.asset.getByIds.mockResolvedValue([assetStub.sidecarWithoutExt]); mocks.asset.getById.mockResolvedValue(assetStub.sidecarWithoutExt);
mocks.storage.checkFileExists.mockResolvedValueOnce(false); mocks.storage.checkFileExists.mockResolvedValueOnce(false);
mocks.storage.checkFileExists.mockResolvedValueOnce(true); mocks.storage.checkFileExists.mockResolvedValueOnce(true);
@ -1295,7 +1293,7 @@ describe(MetadataService.name, () => {
}); });
it('should set sidecar path if exists (two sidecars named photo.ext.xmp and photo.xmp, should pick photo.ext.xmp)', async () => { it('should set sidecar path if exists (two sidecars named photo.ext.xmp and photo.xmp, should pick photo.ext.xmp)', async () => {
mocks.asset.getByIds.mockResolvedValue([assetStub.sidecar]); mocks.asset.getById.mockResolvedValue(assetStub.sidecar);
mocks.storage.checkFileExists.mockResolvedValueOnce(true); mocks.storage.checkFileExists.mockResolvedValueOnce(true);
mocks.storage.checkFileExists.mockResolvedValueOnce(true); mocks.storage.checkFileExists.mockResolvedValueOnce(true);
@ -1313,7 +1311,7 @@ describe(MetadataService.name, () => {
}); });
it('should unset sidecar path if file does not exist anymore', async () => { it('should unset sidecar path if file does not exist anymore', async () => {
mocks.asset.getByIds.mockResolvedValue([assetStub.sidecar]); mocks.asset.getById.mockResolvedValue(assetStub.sidecar);
mocks.storage.checkFileExists.mockResolvedValue(false); mocks.storage.checkFileExists.mockResolvedValue(false);
await expect(sut.handleSidecarSync({ id: assetStub.sidecar.id })).resolves.toBe(JobStatus.SUCCESS); await expect(sut.handleSidecarSync({ id: assetStub.sidecar.id })).resolves.toBe(JobStatus.SUCCESS);
@ -1330,26 +1328,26 @@ describe(MetadataService.name, () => {
describe('handleSidecarDiscovery', () => { describe('handleSidecarDiscovery', () => {
it('should skip hidden assets', async () => { it('should skip hidden assets', async () => {
mocks.asset.getByIds.mockResolvedValue([assetStub.livePhotoMotionAsset]); mocks.asset.getById.mockResolvedValue(assetStub.livePhotoMotionAsset);
await sut.handleSidecarDiscovery({ id: assetStub.livePhotoMotionAsset.id }); await sut.handleSidecarDiscovery({ id: assetStub.livePhotoMotionAsset.id });
expect(mocks.storage.checkFileExists).not.toHaveBeenCalled(); expect(mocks.storage.checkFileExists).not.toHaveBeenCalled();
}); });
it('should skip assets with a sidecar path', async () => { it('should skip assets with a sidecar path', async () => {
mocks.asset.getByIds.mockResolvedValue([assetStub.sidecar]); mocks.asset.getById.mockResolvedValue(assetStub.sidecar);
await sut.handleSidecarDiscovery({ id: assetStub.sidecar.id }); await sut.handleSidecarDiscovery({ id: assetStub.sidecar.id });
expect(mocks.storage.checkFileExists).not.toHaveBeenCalled(); expect(mocks.storage.checkFileExists).not.toHaveBeenCalled();
}); });
it('should do nothing when a sidecar is not found ', async () => { it('should do nothing when a sidecar is not found ', async () => {
mocks.asset.getByIds.mockResolvedValue([assetStub.image]); mocks.asset.getById.mockResolvedValue(assetStub.image);
mocks.storage.checkFileExists.mockResolvedValue(false); mocks.storage.checkFileExists.mockResolvedValue(false);
await sut.handleSidecarDiscovery({ id: assetStub.image.id }); await sut.handleSidecarDiscovery({ id: assetStub.image.id });
expect(mocks.asset.update).not.toHaveBeenCalled(); expect(mocks.asset.update).not.toHaveBeenCalled();
}); });
it('should update a image asset when a sidecar is found', async () => { it('should update a image asset when a sidecar is found', async () => {
mocks.asset.getByIds.mockResolvedValue([assetStub.image]); mocks.asset.getById.mockResolvedValue(assetStub.image);
mocks.storage.checkFileExists.mockResolvedValue(true); mocks.storage.checkFileExists.mockResolvedValue(true);
await sut.handleSidecarDiscovery({ id: assetStub.image.id }); await sut.handleSidecarDiscovery({ id: assetStub.image.id });
expect(mocks.storage.checkFileExists).toHaveBeenCalledWith('/original/path.jpg.xmp', constants.R_OK); expect(mocks.storage.checkFileExists).toHaveBeenCalledWith('/original/path.jpg.xmp', constants.R_OK);
@ -1360,7 +1358,7 @@ describe(MetadataService.name, () => {
}); });
it('should update a video asset when a sidecar is found', async () => { it('should update a video asset when a sidecar is found', async () => {
mocks.asset.getByIds.mockResolvedValue([assetStub.video]); mocks.asset.getById.mockResolvedValue(assetStub.video);
mocks.storage.checkFileExists.mockResolvedValue(true); mocks.storage.checkFileExists.mockResolvedValue(true);
await sut.handleSidecarDiscovery({ id: assetStub.video.id }); await sut.handleSidecarDiscovery({ id: assetStub.video.id });
expect(mocks.storage.checkFileExists).toHaveBeenCalledWith('/original/path.ext.xmp', constants.R_OK); expect(mocks.storage.checkFileExists).toHaveBeenCalledWith('/original/path.ext.xmp', constants.R_OK);
@ -1373,13 +1371,13 @@ describe(MetadataService.name, () => {
describe('handleSidecarWrite', () => { describe('handleSidecarWrite', () => {
it('should skip assets that do not exist anymore', async () => { it('should skip assets that do not exist anymore', async () => {
mocks.asset.getByIds.mockResolvedValue([]); mocks.asset.getById.mockResolvedValue(void 0);
await expect(sut.handleSidecarWrite({ id: 'asset-123' })).resolves.toBe(JobStatus.FAILED); await expect(sut.handleSidecarWrite({ id: 'asset-123' })).resolves.toBe(JobStatus.FAILED);
expect(mocks.metadata.writeTags).not.toHaveBeenCalled(); expect(mocks.metadata.writeTags).not.toHaveBeenCalled();
}); });
it('should skip jobs with not metadata', async () => { it('should skip jobs with not metadata', async () => {
mocks.asset.getByIds.mockResolvedValue([assetStub.sidecar]); mocks.asset.getById.mockResolvedValue(assetStub.sidecar);
await expect(sut.handleSidecarWrite({ id: assetStub.sidecar.id })).resolves.toBe(JobStatus.SKIPPED); await expect(sut.handleSidecarWrite({ id: assetStub.sidecar.id })).resolves.toBe(JobStatus.SKIPPED);
expect(mocks.metadata.writeTags).not.toHaveBeenCalled(); expect(mocks.metadata.writeTags).not.toHaveBeenCalled();
}); });
@ -1389,7 +1387,7 @@ describe(MetadataService.name, () => {
const gps = 12; const gps = 12;
const date = '2023-11-22T04:56:12.196Z'; const date = '2023-11-22T04:56:12.196Z';
mocks.asset.getByIds.mockResolvedValue([assetStub.sidecar]); mocks.asset.getById.mockResolvedValue(assetStub.sidecar);
await expect( await expect(
sut.handleSidecarWrite({ sut.handleSidecarWrite({
id: assetStub.sidecar.id, id: assetStub.sidecar.id,

View File

@ -29,6 +29,7 @@ import { ReverseGeocodeResult } from 'src/repositories/map.repository';
import { ImmichTags } from 'src/repositories/metadata.repository'; import { ImmichTags } from 'src/repositories/metadata.repository';
import { BaseService } from 'src/services/base.service'; import { BaseService } from 'src/services/base.service';
import { JobOf } from 'src/types'; import { JobOf } from 'src/types';
import { getSidecarPath } from 'src/utils/asset.util';
import { isFaceImportEnabled } from 'src/utils/misc'; import { isFaceImportEnabled } from 'src/utils/misc';
import { usePagination } from 'src/utils/pagination'; import { usePagination } from 'src/utils/pagination';
import { upsertTags } from 'src/utils/tag'; import { upsertTags } from 'src/utils/tag';
@ -162,15 +163,29 @@ export class MetadataService extends BaseService {
@OnJob({ name: JobName.METADATA_EXTRACTION, queue: QueueName.METADATA_EXTRACTION }) @OnJob({ name: JobName.METADATA_EXTRACTION, queue: QueueName.METADATA_EXTRACTION })
async handleMetadataExtraction(data: JobOf<JobName.METADATA_EXTRACTION>): Promise<JobStatus> { 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.getConfig({ withCache: true }),
this.assetRepository.getByIds([data.id], { faces: { person: false } }), this.assetRepository.getById(data.id, { faces: { person: false } }),
]); ]);
if (!asset) { if (!asset) {
return JobStatus.FAILED; return JobStatus.FAILED;
} }
if (asset.isDirty) {
const { exifInfo } = (await this.assetRepository.getById(asset.id, { exifInfo: true })) || {};
await this.handleSidecarWrite({
id: asset.id,
description: exifInfo?.description,
dateTimeOriginal: exifInfo?.dateTimeOriginal?.toISOString(),
latitude: exifInfo?.latitude ?? undefined,
longitude: exifInfo?.longitude ?? undefined,
rating: exifInfo?.rating ?? undefined,
tags: true,
});
asset.sidecarPath = asset.sidecarPath || getSidecarPath(asset);
}
const exifTags = await this.getExifTags(asset); const exifTags = await this.getExifTags(asset);
if (!exifTags.FileCreateDate || !exifTags.FileModifyDate || exifTags.FileSize === undefined) { if (!exifTags.FileCreateDate || !exifTags.FileModifyDate || exifTags.FileSize === undefined) {
this.logger.warn(`Missing file creation or modification date for asset ${asset.id}: ${asset.originalPath}`); this.logger.warn(`Missing file creation or modification date for asset ${asset.id}: ${asset.originalPath}`);
@ -314,14 +329,14 @@ export class MetadataService extends BaseService {
@OnJob({ name: JobName.SIDECAR_WRITE, queue: QueueName.SIDECAR }) @OnJob({ name: JobName.SIDECAR_WRITE, queue: QueueName.SIDECAR })
async handleSidecarWrite(job: JobOf<JobName.SIDECAR_WRITE>): Promise<JobStatus> { async handleSidecarWrite(job: JobOf<JobName.SIDECAR_WRITE>): Promise<JobStatus> {
const { id, description, dateTimeOriginal, latitude, longitude, rating, tags } = job; const { id, description, dateTimeOriginal, latitude, longitude, rating, tags } = job;
const [asset] = await this.assetRepository.getByIds([id], { tags: true }); const asset = await this.assetRepository.getById(id, { tags: true });
if (!asset) { if (!asset) {
return JobStatus.FAILED; return JobStatus.FAILED;
} }
const tagsList = (asset.tags || []).map((tag) => tag.value); const tagsList = (asset.tags || []).map((tag) => tag.value);
const sidecarPath = asset.sidecarPath || `${asset.originalPath}.xmp`; const sidecarPath = asset.sidecarPath || getSidecarPath(asset);
const exif = _.omitBy( const exif = _.omitBy(
<Tags>{ <Tags>{
Description: description, Description: description,
@ -342,7 +357,7 @@ export class MetadataService extends BaseService {
await this.metadataRepository.writeTags(sidecarPath, exif); await this.metadataRepository.writeTags(sidecarPath, exif);
if (!asset.sidecarPath) { if (!asset.sidecarPath) {
await this.assetRepository.update({ id, sidecarPath }); await this.assetRepository.update({ id, sidecarPath, isDirty: false });
} }
return JobStatus.SUCCESS; return JobStatus.SUCCESS;
@ -754,7 +769,7 @@ export class MetadataService extends BaseService {
} }
private async processSidecar(id: string, isSync: boolean): Promise<JobStatus> { private async processSidecar(id: string, isSync: boolean): Promise<JobStatus> {
const [asset] = await this.assetRepository.getByIds([id]); const asset = await this.assetRepository.getById(id);
if (!asset) { if (!asset) {
return JobStatus.FAILED; return JobStatus.FAILED;

View File

@ -90,6 +90,7 @@ export class TagService extends BaseService {
const results = await this.tagRepository.upsertAssetIds(items); const results = await this.tagRepository.upsertAssetIds(items);
for (const assetId of new Set(results.map((item) => item.assetsId))) { for (const assetId of new Set(results.map((item) => item.assetsId))) {
await this.assetRepository.update({ id: assetId, isDirty: true });
await this.eventRepository.emit('asset.tag', { assetId }); await this.eventRepository.emit('asset.tag', { assetId });
} }
@ -107,6 +108,7 @@ export class TagService extends BaseService {
for (const { id: assetId, success } of results) { for (const { id: assetId, success } of results) {
if (success) { if (success) {
await this.assetRepository.update({ id: assetId, isDirty: true });
await this.eventRepository.emit('asset.tag', { assetId }); await this.eventRepository.emit('asset.tag', { assetId });
} }
} }
@ -125,6 +127,7 @@ export class TagService extends BaseService {
for (const { id: assetId, success } of results) { for (const { id: assetId, success } of results) {
if (success) { if (success) {
await this.assetRepository.update({ id: assetId, isDirty: true });
await this.eventRepository.emit('asset.untag', { assetId }); await this.eventRepository.emit('asset.untag', { assetId });
} }
} }

View File

@ -4,6 +4,7 @@ import { BulkIdErrorReason, BulkIdResponseDto } from 'src/dtos/asset-ids.respons
import { UploadFieldName } from 'src/dtos/asset-media.dto'; import { UploadFieldName } from 'src/dtos/asset-media.dto';
import { AuthDto } from 'src/dtos/auth.dto'; import { AuthDto } from 'src/dtos/auth.dto';
import { AssetFileEntity } from 'src/entities/asset-files.entity'; import { AssetFileEntity } from 'src/entities/asset-files.entity';
import { AssetEntity } from 'src/entities/asset.entity';
import { AssetFileType, AssetType, Permission } from 'src/enum'; import { AssetFileType, AssetType, Permission } from 'src/enum';
import { AuthRequest } from 'src/middleware/auth.guard'; import { AuthRequest } from 'src/middleware/auth.guard';
import { AccessRepository } from 'src/repositories/access.repository'; import { AccessRepository } from 'src/repositories/access.repository';
@ -17,6 +18,8 @@ const getFileByType = (files: AssetFileEntity[] | undefined, type: AssetFileType
return (files || []).find((file) => file.type === type); return (files || []).find((file) => file.type === type);
}; };
export const getSidecarPath = (asset: AssetEntity) => `${asset.originalPath}.xmp`;
export const getAssetFiles = (files?: AssetFileEntity[]) => ({ export const getAssetFiles = (files?: AssetFileEntity[]) => ({
previewFile: getFileByType(files, AssetFileType.PREVIEW), previewFile: getFileByType(files, AssetFileType.PREVIEW),
thumbnailFile: getFileByType(files, AssetFileType.THUMBNAIL), thumbnailFile: getFileByType(files, AssetFileType.THUMBNAIL),

View File

@ -88,6 +88,7 @@ export const assetStub = {
isExternal: false, isExternal: false,
duplicateId: null, duplicateId: null,
isOffline: false, isOffline: false,
isDirty: false,
}), }),
noWebpPath: Object.freeze<AssetEntity>({ noWebpPath: Object.freeze<AssetEntity>({
@ -126,6 +127,7 @@ export const assetStub = {
deletedAt: null, deletedAt: null,
duplicateId: null, duplicateId: null,
isOffline: false, isOffline: false,
isDirty: false,
}), }),
noThumbhash: Object.freeze<AssetEntity>({ noThumbhash: Object.freeze<AssetEntity>({
@ -161,6 +163,7 @@ export const assetStub = {
deletedAt: null, deletedAt: null,
duplicateId: null, duplicateId: null,
isOffline: false, isOffline: false,
isDirty: false,
}), }),
primaryImage: Object.freeze<AssetEntity>({ primaryImage: Object.freeze<AssetEntity>({
@ -207,6 +210,7 @@ export const assetStub = {
]), ]),
duplicateId: null, duplicateId: null,
isOffline: false, isOffline: false,
isDirty: false,
}), }),
image: Object.freeze<AssetEntity>({ image: Object.freeze<AssetEntity>({
@ -247,6 +251,7 @@ export const assetStub = {
} as ExifEntity, } as ExifEntity,
duplicateId: null, duplicateId: null,
isOffline: false, isOffline: false,
isDirty: false,
}), }),
trashed: Object.freeze<AssetEntity>({ trashed: Object.freeze<AssetEntity>({
@ -287,6 +292,7 @@ export const assetStub = {
duplicateId: null, duplicateId: null,
isOffline: false, isOffline: false,
status: AssetStatus.TRASHED, status: AssetStatus.TRASHED,
isDirty: false,
}), }),
trashedOffline: Object.freeze<AssetEntity>({ trashedOffline: Object.freeze<AssetEntity>({
@ -328,6 +334,7 @@ export const assetStub = {
} as ExifEntity, } as ExifEntity,
duplicateId: null, duplicateId: null,
isOffline: true, isOffline: true,
isDirty: false,
}), }),
archived: Object.freeze<AssetEntity>({ archived: Object.freeze<AssetEntity>({
id: 'asset-id', id: 'asset-id',
@ -367,6 +374,7 @@ export const assetStub = {
} as ExifEntity, } as ExifEntity,
duplicateId: null, duplicateId: null,
isOffline: false, isOffline: false,
isDirty: false,
}), }),
external: Object.freeze<AssetEntity>({ external: Object.freeze<AssetEntity>({
@ -406,6 +414,7 @@ export const assetStub = {
} as ExifEntity, } as ExifEntity,
duplicateId: null, duplicateId: null,
isOffline: false, isOffline: false,
isDirty: false,
}), }),
image1: Object.freeze<AssetEntity>({ image1: Object.freeze<AssetEntity>({
@ -444,6 +453,7 @@ export const assetStub = {
} as ExifEntity, } as ExifEntity,
duplicateId: null, duplicateId: null,
isOffline: false, isOffline: false,
isDirty: false,
}), }),
imageFrom2015: Object.freeze<AssetEntity>({ imageFrom2015: Object.freeze<AssetEntity>({
@ -482,6 +492,7 @@ export const assetStub = {
deletedAt: null, deletedAt: null,
duplicateId: null, duplicateId: null,
isOffline: false, isOffline: false,
isDirty: false,
}), }),
video: Object.freeze<AssetEntity>({ video: Object.freeze<AssetEntity>({
@ -522,6 +533,7 @@ export const assetStub = {
deletedAt: null, deletedAt: null,
duplicateId: null, duplicateId: null,
isOffline: false, isOffline: false,
isDirty: false,
}), }),
livePhotoMotionAsset: Object.freeze({ livePhotoMotionAsset: Object.freeze({
@ -613,6 +625,7 @@ export const assetStub = {
deletedAt: null, deletedAt: null,
duplicateId: null, duplicateId: null,
isOffline: false, isOffline: false,
isDirty: false,
}), }),
sidecar: Object.freeze<AssetEntity>({ sidecar: Object.freeze<AssetEntity>({
@ -648,6 +661,7 @@ export const assetStub = {
deletedAt: null, deletedAt: null,
duplicateId: null, duplicateId: null,
isOffline: false, isOffline: false,
isDirty: false,
}), }),
sidecarWithoutExt: Object.freeze<AssetEntity>({ sidecarWithoutExt: Object.freeze<AssetEntity>({
@ -683,6 +697,7 @@ export const assetStub = {
deletedAt: null, deletedAt: null,
duplicateId: null, duplicateId: null,
isOffline: false, isOffline: false,
isDirty: false,
}), }),
hasEncodedVideo: Object.freeze<AssetEntity>({ hasEncodedVideo: Object.freeze<AssetEntity>({
@ -721,6 +736,7 @@ export const assetStub = {
deletedAt: null, deletedAt: null,
duplicateId: null, duplicateId: null,
isOffline: false, isOffline: false,
isDirty: false,
}), }),
hasFileExtension: Object.freeze<AssetEntity>({ hasFileExtension: Object.freeze<AssetEntity>({
@ -760,6 +776,7 @@ export const assetStub = {
} as ExifEntity, } as ExifEntity,
duplicateId: null, duplicateId: null,
isOffline: false, isOffline: false,
isDirty: false,
}), }),
imageDng: Object.freeze<AssetEntity>({ imageDng: Object.freeze<AssetEntity>({
@ -800,6 +817,7 @@ export const assetStub = {
} as ExifEntity, } as ExifEntity,
duplicateId: null, duplicateId: null,
isOffline: false, isOffline: false,
isDirty: false,
}), }),
hasEmbedding: Object.freeze<AssetEntity>({ hasEmbedding: Object.freeze<AssetEntity>({
@ -842,6 +860,7 @@ export const assetStub = {
embedding: '[1, 2, 3, 4]', embedding: '[1, 2, 3, 4]',
}, },
isOffline: false, isOffline: false,
isDirty: false,
}), }),
hasDupe: Object.freeze<AssetEntity>({ hasDupe: Object.freeze<AssetEntity>({
@ -884,5 +903,6 @@ export const assetStub = {
embedding: '[1, 2, 3, 4]', embedding: '[1, 2, 3, 4]',
}, },
isOffline: false, isOffline: false,
isDirty: false,
}), }),
}; };

View File

@ -247,6 +247,7 @@ export const sharedLinkStub = {
sidecarPath: null, sidecarPath: null,
deletedAt: null, deletedAt: null,
duplicateId: null, duplicateId: null,
isDirty: false,
}, },
], ],
}, },

View File

@ -123,7 +123,7 @@ describe(MetadataService.name, () => {
process.env.TZ = serverTimeZone ?? undefined; process.env.TZ = serverTimeZone ?? undefined;
const { filePath } = await createTestFile(exifData); const { filePath } = await createTestFile(exifData);
mocks.asset.getByIds.mockResolvedValue([{ id: 'asset-1', originalPath: filePath } as AssetEntity]); mocks.asset.getById.mockResolvedValue({ id: 'asset-1', originalPath: filePath } as AssetEntity);
await sut.handleMetadataExtraction({ id: 'asset-1' }); await sut.handleMetadataExtraction({ id: 'asset-1' });

View File

@ -105,6 +105,7 @@ const assetFactory = (asset: Partial<Asset> = {}) => ({
isFavorite: false, isFavorite: false,
isOffline: false, isOffline: false,
isVisible: true, isVisible: true,
isDirty: false,
libraryId: null, libraryId: null,
livePhotoVideoId: null, livePhotoVideoId: null,
localDateTime: newDate(), localDateTime: newDate(),