Compare commits

...

6 Commits

Author SHA1 Message Date
mertalev
59fe119c69 fix none handling 2025-12-05 13:17:12 -05:00
mertalev
63c9097376 missed one 2025-12-05 13:09:06 -05:00
mertalev
7b1cc2b705 update sql 2025-12-05 13:03:55 -05:00
mertalev
e414d43eee single statement 2025-12-05 13:00:35 -05:00
Daniel Dietzler
75889f992e fix: asset update race condition 2025-12-05 12:58:26 -05:00
Daniel Dietzler
29827f6f08 fix: asset update race condition 2025-12-05 18:52:59 +01:00
17 changed files with 306 additions and 133 deletions

View File

@@ -240,7 +240,7 @@ export type Session = {
isPendingSyncReset: boolean;
};
export type Exif = Omit<Selectable<AssetExifTable>, 'updatedAt' | 'updateId'>;
export type Exif = Omit<Selectable<AssetExifTable>, 'updatedAt' | 'updateId' | 'lockedProperties'>;
export type Person = {
createdAt: Date;

View File

@@ -50,9 +50,11 @@ select
where
"asset"."id" = "tag_asset"."assetId"
) as agg
) as "tags"
) as "tags",
to_json("asset_exif") as "exifInfo"
from
"asset"
inner join "asset_exif" on "asset"."id" = "asset_exif"."assetId"
where
"asset"."id" = $2::uuid
limit
@@ -224,6 +226,14 @@ from
where
"asset"."id" = $2
-- AssetJobRepository.getLockedPropertiesForMetadataExtraction
select
"asset_exif"."lockedProperties"
from
"asset_exif"
where
"asset_exif"."assetId" = $1
-- AssetJobRepository.getAlbumThumbnailFiles
select
"asset_file"."id",

View File

@@ -3,17 +3,31 @@
-- AssetRepository.updateAllExif
update "asset_exif"
set
"model" = $1
"model" = $1,
"lockedProperties" = nullif(
array(
select distinct
unnest("asset_exif"."lockedProperties" || $2)
),
'{}'
)
where
"assetId" in ($2)
"assetId" in ($3)
-- AssetRepository.updateDateTimeOriginal
update "asset_exif"
set
"dateTimeOriginal" = "dateTimeOriginal" + $1::interval,
"timeZone" = $2
"timeZone" = $2,
"lockedProperties" = nullif(
array(
select distinct
unnest("asset_exif"."lockedProperties" || $3)
),
'{}'
)
where
"assetId" in ($3)
"assetId" in ($4)
returning
"assetId",
"dateTimeOriginal",

View File

@@ -50,6 +50,7 @@ export class AssetJobRepository {
.whereRef('asset.id', '=', 'tag_asset.assetId'),
).as('tags'),
)
.$call(withExifInner)
.limit(1)
.executeTakeFirst();
}
@@ -128,6 +129,16 @@ export class AssetJobRepository {
.executeTakeFirst();
}
@GenerateSql({ params: [DummyValue.UUID] })
async getLockedPropertiesForMetadataExtraction(assetId: string) {
return this.db
.selectFrom('asset_exif')
.select('asset_exif.lockedProperties')
.where('asset_exif.assetId', '=', assetId)
.executeTakeFirst()
.then((row) => row?.lockedProperties ?? []);
}
@GenerateSql({ params: [DummyValue.UUID, AssetFileType.Thumbnail] })
getAlbumThumbnailFiles(id: string, fileType?: AssetFileType) {
return this.db

View File

@@ -1,5 +1,5 @@
import { Injectable } from '@nestjs/common';
import { Insertable, Kysely, NotNull, Selectable, sql, Updateable, UpdateResult } from 'kysely';
import { ExpressionBuilder, Insertable, Kysely, NotNull, Selectable, sql, Updateable, UpdateResult } from 'kysely';
import { isEmpty, isUndefined, omitBy } from 'lodash';
import { InjectKysely } from 'nestjs-kysely';
import { Stack } from 'src/database';
@@ -7,7 +7,7 @@ import { Chunked, ChunkedArray, DummyValue, GenerateSql } from 'src/decorators';
import { AuthDto } from 'src/dtos/auth.dto';
import { AssetFileType, AssetMetadataKey, AssetOrder, AssetStatus, AssetType, AssetVisibility } from 'src/enum';
import { DB } from 'src/schema';
import { AssetExifTable } from 'src/schema/tables/asset-exif.table';
import { AssetExifTable, LockableProperty } from 'src/schema/tables/asset-exif.table';
import { AssetFileTable } from 'src/schema/tables/asset-file.table';
import { AssetJobStatusTable } from 'src/schema/tables/asset-job-status.table';
import { AssetTable } from 'src/schema/tables/asset.table';
@@ -113,51 +113,69 @@ interface GetByIdsRelations {
tags?: boolean;
}
const distinctLocked = <T extends LockableProperty[] | null>(eb: ExpressionBuilder<DB, 'asset_exif'>, columns: T) =>
sql<T>`nullif(array(select distinct unnest(${eb.ref('asset_exif.lockedProperties')} || ${columns})), '{}')`;
@Injectable()
export class AssetRepository {
constructor(@InjectKysely() private db: Kysely<DB>) {}
async upsertExif(exif: Insertable<AssetExifTable>): Promise<void> {
const value = { ...exif, assetId: asUuid(exif.assetId) };
async upsertExif(
exif: Insertable<AssetExifTable>,
{ lockedPropertiesBehavior }: { lockedPropertiesBehavior: 'none' | 'update' | 'skip' },
): Promise<void> {
await this.db
.insertInto('asset_exif')
.values(value)
.values(exif)
.onConflict((oc) =>
oc.column('assetId').doUpdateSet((eb) =>
removeUndefinedKeys(
oc.column('assetId').doUpdateSet((eb) => {
const updateLocked = <T extends keyof AssetExifTable>(col: T) => eb.ref(`excluded.${col}`);
const skipLocked = <T extends keyof AssetExifTable>(col: T) =>
eb
.case()
.when(sql`${col}`, '=', eb.fn.any('asset_exif.lockedProperties'))
.then(eb.ref(`asset_exif.${col}`))
.else(eb.ref(`excluded.${col}`))
.end();
const ref = lockedPropertiesBehavior === 'update' ? updateLocked : skipLocked;
return removeUndefinedKeys(
{
description: eb.ref('excluded.description'),
exifImageWidth: eb.ref('excluded.exifImageWidth'),
exifImageHeight: eb.ref('excluded.exifImageHeight'),
fileSizeInByte: eb.ref('excluded.fileSizeInByte'),
orientation: eb.ref('excluded.orientation'),
dateTimeOriginal: eb.ref('excluded.dateTimeOriginal'),
modifyDate: eb.ref('excluded.modifyDate'),
timeZone: eb.ref('excluded.timeZone'),
latitude: eb.ref('excluded.latitude'),
longitude: eb.ref('excluded.longitude'),
projectionType: eb.ref('excluded.projectionType'),
city: eb.ref('excluded.city'),
livePhotoCID: eb.ref('excluded.livePhotoCID'),
autoStackId: eb.ref('excluded.autoStackId'),
state: eb.ref('excluded.state'),
country: eb.ref('excluded.country'),
make: eb.ref('excluded.make'),
model: eb.ref('excluded.model'),
lensModel: eb.ref('excluded.lensModel'),
fNumber: eb.ref('excluded.fNumber'),
focalLength: eb.ref('excluded.focalLength'),
iso: eb.ref('excluded.iso'),
exposureTime: eb.ref('excluded.exposureTime'),
profileDescription: eb.ref('excluded.profileDescription'),
colorspace: eb.ref('excluded.colorspace'),
bitsPerSample: eb.ref('excluded.bitsPerSample'),
rating: eb.ref('excluded.rating'),
fps: eb.ref('excluded.fps'),
description: ref('description'),
exifImageWidth: ref('exifImageWidth'),
exifImageHeight: ref('exifImageHeight'),
fileSizeInByte: ref('fileSizeInByte'),
orientation: ref('orientation'),
dateTimeOriginal: ref('dateTimeOriginal'),
modifyDate: ref('modifyDate'),
timeZone: ref('timeZone'),
latitude: ref('latitude'),
longitude: ref('longitude'),
projectionType: ref('projectionType'),
city: ref('city'),
livePhotoCID: ref('livePhotoCID'),
autoStackId: ref('autoStackId'),
state: ref('state'),
country: ref('country'),
make: ref('make'),
model: ref('model'),
lensModel: ref('lensModel'),
fNumber: ref('fNumber'),
focalLength: ref('focalLength'),
iso: ref('iso'),
exposureTime: ref('exposureTime'),
profileDescription: ref('profileDescription'),
colorspace: ref('colorspace'),
bitsPerSample: ref('bitsPerSample'),
rating: ref('rating'),
fps: ref('fps'),
lockedProperties:
exif.lockedProperties !== undefined && lockedPropertiesBehavior !== 'none'
? distinctLocked(eb, exif.lockedProperties)
: exif.lockedProperties,
},
value,
),
),
exif,
);
}),
)
.execute();
}
@@ -169,19 +187,30 @@ export class AssetRepository {
return;
}
await this.db.updateTable('asset_exif').set(options).where('assetId', 'in', ids).execute();
await this.db
.updateTable('asset_exif')
.set((eb) => ({
...options,
lockedProperties: distinctLocked(eb, Object.keys(options) as LockableProperty[]),
}))
.where('assetId', 'in', ids)
.execute();
}
@GenerateSql({ params: [[DummyValue.UUID], DummyValue.NUMBER, DummyValue.STRING] })
@Chunked()
async updateDateTimeOriginal(
ids: string[],
delta?: number,
timeZone?: string,
): Promise<{ assetId: string; dateTimeOriginal: Date | null; timeZone: string | null }[]> {
return await this.db
updateDateTimeOriginal(ids: string[], delta?: number, timeZone?: string) {
if (ids.length === 0) {
return;
}
return this.db
.updateTable('asset_exif')
.set({ dateTimeOriginal: sql`"dateTimeOriginal" + ${(delta ?? 0) + ' minute'}::interval`, timeZone })
.set((eb) => ({
dateTimeOriginal: sql`"dateTimeOriginal" + ${(delta ?? 0) + ' minute'}::interval`,
timeZone,
lockedProperties: distinctLocked(eb, ['dateTimeOriginal', 'timeZone']),
}))
.where('assetId', 'in', ids)
.returning(['assetId', 'dateTimeOriginal', 'timeZone'])
.execute();

View File

@@ -0,0 +1,9 @@
import { Kysely, sql } from 'kysely';
export async function up(db: Kysely<any>): Promise<void> {
await sql`ALTER TABLE "asset_exif" ADD "lockedProperties" character varying[];`.execute(db);
}
export async function down(db: Kysely<any>): Promise<void> {
await sql`ALTER TABLE "asset_exif" DROP COLUMN "lockedProperties";`.execute(db);
}

View File

@@ -2,6 +2,16 @@ import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators';
import { AssetTable } from 'src/schema/tables/asset.table';
import { Column, ForeignKeyColumn, Generated, Int8, Table, Timestamp, UpdateDateColumn } from 'src/sql-tools';
export type LockableProperty = (typeof lockableProperties)[number];
export const lockableProperties = [
'description',
'dateTimeOriginal',
'latitude',
'longitude',
'rating',
'timeZone',
] as const;
@Table('asset_exif')
@UpdatedAtTrigger('asset_exif_updatedAt')
export class AssetExifTable {
@@ -97,4 +107,7 @@ export class AssetExifTable {
@UpdateIdColumn({ index: true })
updateId!: Generated<string>;
@Column({ type: 'character varying', array: true, nullable: true })
lockedProperties!: Array<LockableProperty> | null;
}

View File

@@ -370,7 +370,7 @@ export class AssetMediaService extends BaseService {
: this.assetRepository.deleteFile({ assetId, type: AssetFileType.Sidecar }));
await this.storageRepository.utimes(file.originalPath, new Date(), new Date(dto.fileModifiedAt));
await this.assetRepository.upsertExif({ assetId, fileSizeInByte: file.size });
await this.assetRepository.upsertExif({ assetId, fileSizeInByte: file.size }, { lockedPropertiesBehavior: 'none' });
await this.jobRepository.queue({
name: JobName.AssetExtractMetadata,
data: { id: assetId, source: 'upload' },
@@ -399,7 +399,10 @@ export class AssetMediaService extends BaseService {
});
const { size } = await this.storageRepository.stat(created.originalPath);
await this.assetRepository.upsertExif({ assetId: created.id, fileSizeInByte: size });
await this.assetRepository.upsertExif(
{ assetId: created.id, fileSizeInByte: size },
{ lockedPropertiesBehavior: 'none' },
);
await this.jobRepository.queue({ name: JobName.AssetExtractMetadata, data: { id: created.id, source: 'copy' } });
return created;
}
@@ -440,7 +443,10 @@ export class AssetMediaService extends BaseService {
await this.storageRepository.utimes(sidecarFile.originalPath, new Date(), new Date(dto.fileModifiedAt));
}
await this.storageRepository.utimes(file.originalPath, new Date(), new Date(dto.fileModifiedAt));
await this.assetRepository.upsertExif({ assetId: asset.id, fileSizeInByte: file.size });
await this.assetRepository.upsertExif(
{ assetId: asset.id, fileSizeInByte: file.size },
{ lockedPropertiesBehavior: 'none' },
);
await this.eventRepository.emit('AssetCreate', { asset });

View File

@@ -225,7 +225,10 @@ describe(AssetService.name, () => {
await sut.update(authStub.admin, 'asset-1', { description: 'Test description' });
expect(mocks.asset.upsertExif).toHaveBeenCalledWith({ assetId: 'asset-1', description: 'Test description' });
expect(mocks.asset.upsertExif).toHaveBeenCalledWith(
{ assetId: 'asset-1', description: 'Test description', lockedProperties: ['description'] },
{ lockedPropertiesBehavior: 'update' },
);
});
it('should update the exif rating', async () => {
@@ -235,7 +238,14 @@ describe(AssetService.name, () => {
await sut.update(authStub.admin, 'asset-1', { rating: 3 });
expect(mocks.asset.upsertExif).toHaveBeenCalledWith({ assetId: 'asset-1', rating: 3 });
expect(mocks.asset.upsertExif).toHaveBeenCalledWith(
{
assetId: 'asset-1',
rating: 3,
lockedProperties: ['rating'],
},
{ lockedPropertiesBehavior: 'update' },
);
});
it('should fail linking a live video if the motion part could not be found', async () => {

View File

@@ -30,7 +30,7 @@ import {
QueueName,
} from 'src/enum';
import { BaseService } from 'src/services/base.service';
import { ISidecarWriteJob, JobItem, JobOf } from 'src/types';
import { JobItem, JobOf } from 'src/types';
import { requireElevatedPermission } from 'src/utils/access';
import { getAssetFiles, getMyPartnerIds, onAfterUnlink, onBeforeLink, onBeforeUnlink } from 'src/utils/asset.util';
@@ -143,9 +143,9 @@ export class AssetService extends BaseService {
await this.requireAccess({ auth, permission: Permission.AssetUpdate, ids });
const assetDto = { isFavorite, visibility, duplicateId };
const exifDto = { latitude, longitude, rating, description, dateTimeOriginal };
const exifDto = _.omitBy({ latitude, longitude, rating, description, dateTimeOriginal }, _.isUndefined);
const isExifChanged = Object.values(exifDto).some((v) => v !== undefined);
const isExifChanged = Object.keys(exifDto).length > 0;
if (isExifChanged) {
await this.assetRepository.updateAllExif(ids, exifDto);
}
@@ -456,12 +456,25 @@ export class AssetService extends BaseService {
return asset;
}
private async updateExif(dto: ISidecarWriteJob) {
private async updateExif(dto: {
id: string;
description?: string;
dateTimeOriginal?: string;
latitude?: number;
longitude?: number;
rating?: number;
}) {
const { id, description, dateTimeOriginal, latitude, longitude, rating } = dto;
const writes = _.omitBy({ description, dateTimeOriginal, latitude, longitude, rating }, _.isUndefined);
if (Object.keys(writes).length > 0) {
await this.assetRepository.upsertExif({ assetId: id, ...writes });
await this.jobRepository.queue({ name: JobName.SidecarWrite, data: { id, ...writes } });
await this.assetRepository.upsertExif(
{
assetId: id,
...writes,
},
{ lockedPropertiesBehavior: 'update' },
);
await this.jobRepository.queue({ name: JobName.SidecarWrite, data: { id } });
}
}
}

View File

@@ -187,7 +187,9 @@ describe(MetadataService.name, () => {
await sut.handleMetadataExtraction({ id: assetStub.image.id });
expect(mocks.assetJob.getForMetadataExtraction).toHaveBeenCalledWith(assetStub.sidecar.id);
expect(mocks.asset.upsertExif).toHaveBeenCalledWith(expect.objectContaining({ dateTimeOriginal: sidecarDate }));
expect(mocks.asset.upsertExif).toHaveBeenCalledWith(expect.objectContaining({ dateTimeOriginal: sidecarDate }), {
lockedPropertiesBehavior: 'skip',
});
expect(mocks.asset.update).toHaveBeenCalledWith(
expect.objectContaining({
id: assetStub.image.id,
@@ -214,6 +216,7 @@ describe(MetadataService.name, () => {
expect(mocks.assetJob.getForMetadataExtraction).toHaveBeenCalledWith(assetStub.image.id);
expect(mocks.asset.upsertExif).toHaveBeenCalledWith(
expect.objectContaining({ dateTimeOriginal: fileModifiedAt }),
{ lockedPropertiesBehavior: 'skip' },
);
expect(mocks.asset.update).toHaveBeenCalledWith({
id: assetStub.image.id,
@@ -238,7 +241,10 @@ describe(MetadataService.name, () => {
await sut.handleMetadataExtraction({ id: assetStub.image.id });
expect(mocks.assetJob.getForMetadataExtraction).toHaveBeenCalledWith(assetStub.image.id);
expect(mocks.asset.upsertExif).toHaveBeenCalledWith(expect.objectContaining({ dateTimeOriginal: fileCreatedAt }));
expect(mocks.asset.upsertExif).toHaveBeenCalledWith(
expect.objectContaining({ dateTimeOriginal: fileCreatedAt }),
{ lockedPropertiesBehavior: 'skip' },
);
expect(mocks.asset.update).toHaveBeenCalledWith({
id: assetStub.image.id,
duration: null,
@@ -258,6 +264,7 @@ describe(MetadataService.name, () => {
expect.objectContaining({
dateTimeOriginal: new Date('2022-01-01T00:00:00.000Z'),
}),
{ lockedPropertiesBehavior: 'skip' },
);
expect(mocks.asset.update).toHaveBeenCalledWith(
@@ -281,7 +288,9 @@ describe(MetadataService.name, () => {
await sut.handleMetadataExtraction({ id: assetStub.image.id });
expect(mocks.assetJob.getForMetadataExtraction).toHaveBeenCalledWith(assetStub.image.id);
expect(mocks.asset.upsertExif).toHaveBeenCalledWith(expect.objectContaining({ iso: 160 }));
expect(mocks.asset.upsertExif).toHaveBeenCalledWith(expect.objectContaining({ iso: 160 }), {
lockedPropertiesBehavior: 'skip',
});
expect(mocks.asset.update).toHaveBeenCalledWith({
id: assetStub.image.id,
duration: null,
@@ -310,6 +319,7 @@ describe(MetadataService.name, () => {
expect(mocks.assetJob.getForMetadataExtraction).toHaveBeenCalledWith(assetStub.image.id);
expect(mocks.asset.upsertExif).toHaveBeenCalledWith(
expect.objectContaining({ city: null, state: null, country: null }),
{ lockedPropertiesBehavior: 'skip' },
);
expect(mocks.asset.update).toHaveBeenCalledWith({
id: assetStub.withLocation.id,
@@ -339,6 +349,7 @@ describe(MetadataService.name, () => {
expect(mocks.assetJob.getForMetadataExtraction).toHaveBeenCalledWith(assetStub.image.id);
expect(mocks.asset.upsertExif).toHaveBeenCalledWith(
expect.objectContaining({ city: 'City', state: 'State', country: 'Country' }),
{ lockedPropertiesBehavior: 'skip' },
);
expect(mocks.asset.update).toHaveBeenCalledWith({
id: assetStub.withLocation.id,
@@ -358,7 +369,10 @@ describe(MetadataService.name, () => {
await sut.handleMetadataExtraction({ id: assetStub.image.id });
expect(mocks.assetJob.getForMetadataExtraction).toHaveBeenCalledWith(assetStub.image.id);
expect(mocks.asset.upsertExif).toHaveBeenCalledWith(expect.objectContaining({ latitude: null, longitude: null }));
expect(mocks.asset.upsertExif).toHaveBeenCalledWith(
expect.objectContaining({ latitude: null, longitude: null }),
{ lockedPropertiesBehavior: 'skip' },
);
});
it('should extract tags from TagsList', async () => {
@@ -571,6 +585,7 @@ describe(MetadataService.name, () => {
expect(mocks.assetJob.getForMetadataExtraction).toHaveBeenCalledWith(assetStub.video.id);
expect(mocks.asset.upsertExif).toHaveBeenCalledWith(
expect.objectContaining({ orientation: ExifOrientation.Rotate270CW.toString() }),
{ lockedPropertiesBehavior: 'skip' },
);
});
@@ -879,37 +894,40 @@ describe(MetadataService.name, () => {
await sut.handleMetadataExtraction({ id: assetStub.image.id });
expect(mocks.assetJob.getForMetadataExtraction).toHaveBeenCalledWith(assetStub.image.id);
expect(mocks.asset.upsertExif).toHaveBeenCalledWith({
assetId: assetStub.image.id,
bitsPerSample: expect.any(Number),
autoStackId: null,
colorspace: tags.ColorSpace,
dateTimeOriginal: dateForTest,
description: tags.ImageDescription,
exifImageHeight: null,
exifImageWidth: null,
exposureTime: tags.ExposureTime,
fNumber: null,
fileSizeInByte: 123_456,
focalLength: tags.FocalLength,
fps: null,
iso: tags.ISO,
latitude: null,
lensModel: tags.LensModel,
livePhotoCID: tags.MediaGroupUUID,
longitude: null,
make: tags.Make,
model: tags.Model,
modifyDate: expect.any(Date),
orientation: tags.Orientation?.toString(),
profileDescription: tags.ProfileDescription,
projectionType: 'EQUIRECTANGULAR',
timeZone: tags.tz,
rating: tags.Rating,
country: null,
state: null,
city: null,
});
expect(mocks.asset.upsertExif).toHaveBeenCalledWith(
{
assetId: assetStub.image.id,
bitsPerSample: expect.any(Number),
autoStackId: null,
colorspace: tags.ColorSpace,
dateTimeOriginal: dateForTest,
description: tags.ImageDescription,
exifImageHeight: null,
exifImageWidth: null,
exposureTime: tags.ExposureTime,
fNumber: null,
fileSizeInByte: 123_456,
focalLength: tags.FocalLength,
fps: null,
iso: tags.ISO,
latitude: null,
lensModel: tags.LensModel,
livePhotoCID: tags.MediaGroupUUID,
longitude: null,
make: tags.Make,
model: tags.Model,
modifyDate: expect.any(Date),
orientation: tags.Orientation?.toString(),
profileDescription: tags.ProfileDescription,
projectionType: 'EQUIRECTANGULAR',
timeZone: tags.tz,
rating: tags.Rating,
country: null,
state: null,
city: null,
},
{ lockedPropertiesBehavior: 'skip' },
);
expect(mocks.asset.update).toHaveBeenCalledWith(
expect.objectContaining({
id: assetStub.image.id,
@@ -943,6 +961,7 @@ describe(MetadataService.name, () => {
expect.objectContaining({
timeZone: 'UTC+0',
}),
{ lockedPropertiesBehavior: 'skip' },
);
});
@@ -1089,6 +1108,7 @@ describe(MetadataService.name, () => {
expect.objectContaining({
description: '',
}),
{ lockedPropertiesBehavior: 'skip' },
);
mockReadTags({ ImageDescription: ' my\n description' });
@@ -1097,6 +1117,7 @@ describe(MetadataService.name, () => {
expect.objectContaining({
description: 'my\n description',
}),
{ lockedPropertiesBehavior: 'skip' },
);
});
@@ -1109,6 +1130,7 @@ describe(MetadataService.name, () => {
expect.objectContaining({
description: '1000',
}),
{ lockedPropertiesBehavior: 'skip' },
);
});
@@ -1332,6 +1354,7 @@ describe(MetadataService.name, () => {
expect.objectContaining({
modifyDate: expect.any(Date),
}),
{ lockedPropertiesBehavior: 'skip' },
);
});
@@ -1344,6 +1367,7 @@ describe(MetadataService.name, () => {
expect.objectContaining({
rating: null,
}),
{ lockedPropertiesBehavior: 'skip' },
);
});
@@ -1356,6 +1380,7 @@ describe(MetadataService.name, () => {
expect.objectContaining({
rating: 5,
}),
{ lockedPropertiesBehavior: 'skip' },
);
});
@@ -1368,6 +1393,7 @@ describe(MetadataService.name, () => {
expect.objectContaining({
rating: -1,
}),
{ lockedPropertiesBehavior: 'skip' },
);
});
@@ -1489,7 +1515,9 @@ describe(MetadataService.name, () => {
mockReadTags(exif);
await sut.handleMetadataExtraction({ id: assetStub.image.id });
expect(mocks.asset.upsertExif).toHaveBeenCalledWith(expect.objectContaining(expected));
expect(mocks.asset.upsertExif).toHaveBeenCalledWith(expect.objectContaining(expected), {
lockedPropertiesBehavior: 'skip',
});
});
it.each([
@@ -1515,6 +1543,7 @@ describe(MetadataService.name, () => {
expect.objectContaining({
lensModel: expected,
}),
{ lockedPropertiesBehavior: 'skip' },
);
});
});
@@ -1623,12 +1652,14 @@ describe(MetadataService.name, () => {
describe('handleSidecarWrite', () => {
it('should skip assets that no longer exist', async () => {
mocks.assetJob.getLockedPropertiesForMetadataExtraction.mockResolvedValue([]);
mocks.assetJob.getForSidecarWriteJob.mockResolvedValue(void 0);
await expect(sut.handleSidecarWrite({ id: 'asset-123' })).resolves.toBe(JobStatus.Failed);
expect(mocks.metadata.writeTags).not.toHaveBeenCalled();
});
it('should skip jobs with no metadata', async () => {
mocks.assetJob.getLockedPropertiesForMetadataExtraction.mockResolvedValue([]);
const asset = factory.jobAssets.sidecarWrite();
mocks.assetJob.getForSidecarWriteJob.mockResolvedValue(asset);
await expect(sut.handleSidecarWrite({ id: asset.id })).resolves.toBe(JobStatus.Skipped);
@@ -1641,20 +1672,22 @@ describe(MetadataService.name, () => {
const gps = 12;
const date = '2023-11-22T04:56:12.196Z';
mocks.assetJob.getLockedPropertiesForMetadataExtraction.mockResolvedValue([
'description',
'latitude',
'longitude',
'dateTimeOriginal',
]);
mocks.assetJob.getForSidecarWriteJob.mockResolvedValue(asset);
await expect(
sut.handleSidecarWrite({
id: asset.id,
description,
latitude: gps,
longitude: gps,
dateTimeOriginal: date,
}),
).resolves.toBe(JobStatus.Success);
expect(mocks.metadata.writeTags).toHaveBeenCalledWith(asset.files[0].path, {
DateTimeOriginal: date,
Description: description,
ImageDescription: description,
DateTimeOriginal: date,
GPSLatitude: gps,
GPSLongitude: gps,
});

View File

@@ -289,7 +289,7 @@ export class MetadataService extends BaseService {
};
const promises: Promise<unknown>[] = [
this.assetRepository.upsertExif(exifData),
this.assetRepository.upsertExif(exifData, { lockedPropertiesBehavior: 'skip' }),
this.assetRepository.update({
id: asset.id,
duration: this.getDuration(exifTags),
@@ -392,22 +392,34 @@ export class MetadataService extends BaseService {
@OnJob({ name: JobName.SidecarWrite, queue: QueueName.Sidecar })
async handleSidecarWrite(job: JobOf<JobName.SidecarWrite>): Promise<JobStatus> {
const { id, description, dateTimeOriginal, latitude, longitude, rating, tags } = job;
const { id, tags } = job;
const asset = await this.assetJobRepository.getForSidecarWriteJob(id);
if (!asset) {
return JobStatus.Failed;
}
const lockedProperties = await this.assetJobRepository.getLockedPropertiesForMetadataExtraction(id);
const tagsList = (asset.tags || []).map((tag) => tag.value);
const { sidecarFile } = getAssetFiles(asset.files);
const sidecarPath = sidecarFile?.path || `${asset.originalPath}.xmp`;
const { description, dateTimeOriginal, latitude, longitude, rating } = _.pick(
{
description: asset.exifInfo.description,
dateTimeOriginal: asset.exifInfo.dateTimeOriginal,
latitude: asset.exifInfo.latitude,
longitude: asset.exifInfo.longitude,
rating: asset.exifInfo.rating,
},
lockedProperties,
);
const exif = _.omitBy(
<Tags>{
Description: description,
ImageDescription: description,
DateTimeOriginal: dateTimeOriginal,
DateTimeOriginal: dateTimeOriginal?.toISOString(),
GPSLatitude: latitude,
GPSLongitude: longitude,
Rating: rating,

View File

@@ -222,11 +222,6 @@ export interface IDeleteFilesJob extends IBaseJob {
}
export interface ISidecarWriteJob extends IEntityJob {
description?: string;
dateTimeOriginal?: string;
latitude?: number;
longitude?: number;
rating?: number;
tags?: true;
}

View File

@@ -202,7 +202,7 @@ export class MediumTestContext<S extends BaseService = BaseService> {
}
async newExif(dto: Insertable<AssetExifTable>) {
const result = await this.get(AssetRepository).upsertExif(dto);
const result = await this.get(AssetRepository).upsertExif(dto, { lockedPropertiesBehavior: 'none' });
return { result };
}

View File

@@ -95,6 +95,7 @@ describe(MetadataService.name, () => {
dateTimeOriginal: new Date(expected.dateTimeOriginal),
timeZone: expected.timeZone,
}),
{ lockedPropertiesBehavior: 'skip' },
);
expect(mocks.asset.update).toHaveBeenCalledWith(

View File

@@ -288,10 +288,13 @@ describe(SyncRequestType.AlbumAssetExifsV1, () => {
// update the asset
const assetRepository = ctx.get(AssetRepository);
await assetRepository.upsertExif({
assetId: asset.id,
city: 'New City',
});
await assetRepository.upsertExif(
{
assetId: asset.id,
city: 'New City',
},
{ lockedPropertiesBehavior: 'update' },
);
await expect(ctx.syncStream(auth, [SyncRequestType.AlbumAssetExifsV1])).resolves.toEqual([
{
@@ -346,10 +349,13 @@ describe(SyncRequestType.AlbumAssetExifsV1, () => {
// update the asset
const assetRepository = ctx.get(AssetRepository);
await assetRepository.upsertExif({
assetId: assetDelayedExif.id,
city: 'Delayed Exif',
});
await assetRepository.upsertExif(
{
assetId: assetDelayedExif.id,
city: 'Delayed Exif',
},
{ lockedPropertiesBehavior: 'update' },
);
await expect(ctx.syncStream(auth, [SyncRequestType.AlbumAssetExifsV1])).resolves.toEqual([
{

View File

@@ -4,6 +4,7 @@ import {
AuthApiKey,
AuthSharedLink,
AuthUser,
Exif,
Library,
Memory,
Partner,
@@ -319,18 +320,28 @@ const versionHistoryFactory = () => ({
version: '1.123.45',
});
const assetSidecarWriteFactory = () => ({
id: newUuid(),
originalPath: '/path/to/original-path.jpg.xmp',
tags: [],
files: [
{
id: newUuid(),
path: '/path/to/original-path.jpg.xmp',
type: AssetFileType.Sidecar,
},
],
});
const assetSidecarWriteFactory = () => {
const id = newUuid();
return {
id,
originalPath: '/path/to/original-path.jpg.xmp',
tags: [],
files: [
{
id: newUuid(),
path: '/path/to/original-path.jpg.xmp',
type: AssetFileType.Sidecar,
},
],
exifInfo: {
assetId: id,
description: 'this is a description',
latitude: 12,
longitude: 12,
dateTimeOriginal: '2023-11-22T04:56:12.196Z',
} as unknown as Exif,
};
};
const assetOcrFactory = (
ocr: {