Skip to content

Commit 0d13c97

Browse files
committed
Merge branch 'main' of github.com:immich-app/immich into better-info-in-asset-viewer
2 parents 3efbda8 + c15998e commit 0d13c97

19 files changed

+415
-193
lines changed

server/src/database.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -240,7 +240,7 @@ export type Session = {
240240
isPendingSyncReset: boolean;
241241
};
242242

243-
export type Exif = Omit<Selectable<AssetExifTable>, 'updatedAt' | 'updateId'>;
243+
export type Exif = Omit<Selectable<AssetExifTable>, 'updatedAt' | 'updateId' | 'lockedProperties'>;
244244

245245
export type Person = {
246246
createdAt: Date;
@@ -465,3 +465,13 @@ export const columns = {
465465
'plugin.updatedAt as updatedAt',
466466
],
467467
} as const;
468+
469+
export type LockableProperty = (typeof lockableProperties)[number];
470+
export const lockableProperties = [
471+
'description',
472+
'dateTimeOriginal',
473+
'latitude',
474+
'longitude',
475+
'rating',
476+
'timeZone',
477+
] as const;

server/src/queries/asset.job.repository.sql

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,9 +50,11 @@ select
5050
where
5151
"asset"."id" = "tag_asset"."assetId"
5252
) as agg
53-
) as "tags"
53+
) as "tags",
54+
to_json("asset_exif") as "exifInfo"
5455
from
5556
"asset"
57+
inner join "asset_exif" on "asset"."id" = "asset_exif"."assetId"
5658
where
5759
"asset"."id" = $2::uuid
5860
limit
@@ -224,6 +226,14 @@ from
224226
where
225227
"asset"."id" = $2
226228

229+
-- AssetJobRepository.getLockedPropertiesForMetadataExtraction
230+
select
231+
"asset_exif"."lockedProperties"
232+
from
233+
"asset_exif"
234+
where
235+
"asset_exif"."assetId" = $1
236+
227237
-- AssetJobRepository.getAlbumThumbnailFiles
228238
select
229239
"asset_file"."id",

server/src/queries/asset.repository.sql

Lines changed: 34 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,49 @@
11
-- NOTE: This file is auto generated by ./sql-generator
22

3+
-- AssetRepository.upsertExif
4+
insert into
5+
"asset_exif" ("dateTimeOriginal", "lockedProperties")
6+
values
7+
($1, $2)
8+
on conflict ("assetId") do update
9+
set
10+
"dateTimeOriginal" = "excluded"."dateTimeOriginal",
11+
"lockedProperties" = nullif(
12+
array(
13+
select distinct
14+
unnest("asset_exif"."lockedProperties" || $3)
15+
),
16+
'{}'
17+
)
18+
319
-- AssetRepository.updateAllExif
420
update "asset_exif"
521
set
6-
"model" = $1
22+
"model" = $1,
23+
"lockedProperties" = nullif(
24+
array(
25+
select distinct
26+
unnest("asset_exif"."lockedProperties" || $2)
27+
),
28+
'{}'
29+
)
730
where
8-
"assetId" in ($2)
31+
"assetId" in ($3)
932

1033
-- AssetRepository.updateDateTimeOriginal
1134
update "asset_exif"
1235
set
1336
"dateTimeOriginal" = "dateTimeOriginal" + $1::interval,
14-
"timeZone" = $2
37+
"timeZone" = $2,
38+
"lockedProperties" = nullif(
39+
array(
40+
select distinct
41+
unnest("asset_exif"."lockedProperties" || $3)
42+
),
43+
'{}'
44+
)
1545
where
16-
"assetId" in ($3)
46+
"assetId" in ($4)
1747
returning
1848
"assetId",
1949
"dateTimeOriginal",

server/src/repositories/asset-job.repository.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ export class AssetJobRepository {
5050
.whereRef('asset.id', '=', 'tag_asset.assetId'),
5151
).as('tags'),
5252
)
53+
.$call(withExifInner)
5354
.limit(1)
5455
.executeTakeFirst();
5556
}
@@ -128,6 +129,16 @@ export class AssetJobRepository {
128129
.executeTakeFirst();
129130
}
130131

132+
@GenerateSql({ params: [DummyValue.UUID] })
133+
async getLockedPropertiesForMetadataExtraction(assetId: string) {
134+
return this.db
135+
.selectFrom('asset_exif')
136+
.select('asset_exif.lockedProperties')
137+
.where('asset_exif.assetId', '=', assetId)
138+
.executeTakeFirst()
139+
.then((row) => row?.lockedProperties ?? []);
140+
}
141+
131142
@GenerateSql({ params: [DummyValue.UUID, AssetFileType.Thumbnail] })
132143
getAlbumThumbnailFiles(id: string, fileType?: AssetFileType) {
133144
return this.db

server/src/repositories/asset.repository.ts

Lines changed: 81 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
import { Injectable } from '@nestjs/common';
2-
import { Insertable, Kysely, NotNull, Selectable, sql, Updateable, UpdateResult } from 'kysely';
2+
import { ExpressionBuilder, Insertable, Kysely, NotNull, Selectable, sql, Updateable, UpdateResult } from 'kysely';
33
import { isEmpty, isUndefined, omitBy } from 'lodash';
44
import { InjectKysely } from 'nestjs-kysely';
5-
import { Stack } from 'src/database';
5+
import { LockableProperty, Stack } from 'src/database';
66
import { Chunked, ChunkedArray, DummyValue, GenerateSql } from 'src/decorators';
77
import { AuthDto } from 'src/dtos/auth.dto';
88
import { AssetFileType, AssetMetadataKey, AssetOrder, AssetStatus, AssetType, AssetVisibility } from 'src/enum';
@@ -113,51 +113,77 @@ interface GetByIdsRelations {
113113
tags?: boolean;
114114
}
115115

116+
const distinctLocked = <T extends LockableProperty[] | null>(eb: ExpressionBuilder<DB, 'asset_exif'>, columns: T) =>
117+
sql<T>`nullif(array(select distinct unnest(${eb.ref('asset_exif.lockedProperties')} || ${columns})), '{}')`;
118+
116119
@Injectable()
117120
export class AssetRepository {
118121
constructor(@InjectKysely() private db: Kysely<DB>) {}
119122

120-
async upsertExif(exif: Insertable<AssetExifTable>): Promise<void> {
121-
const value = { ...exif, assetId: asUuid(exif.assetId) };
123+
@GenerateSql({
124+
params: [
125+
{ dateTimeOriginal: DummyValue.DATE, lockedProperties: ['dateTimeOriginal'] },
126+
{ lockedPropertiesBehavior: 'append' },
127+
],
128+
})
129+
async upsertExif(
130+
exif: Insertable<AssetExifTable>,
131+
{ lockedPropertiesBehavior }: { lockedPropertiesBehavior: 'override' | 'append' | 'skip' },
132+
): Promise<void> {
122133
await this.db
123134
.insertInto('asset_exif')
124-
.values(value)
135+
.values(exif)
125136
.onConflict((oc) =>
126-
oc.column('assetId').doUpdateSet((eb) =>
127-
removeUndefinedKeys(
128-
{
129-
description: eb.ref('excluded.description'),
130-
exifImageWidth: eb.ref('excluded.exifImageWidth'),
131-
exifImageHeight: eb.ref('excluded.exifImageHeight'),
132-
fileSizeInByte: eb.ref('excluded.fileSizeInByte'),
133-
orientation: eb.ref('excluded.orientation'),
134-
dateTimeOriginal: eb.ref('excluded.dateTimeOriginal'),
135-
modifyDate: eb.ref('excluded.modifyDate'),
136-
timeZone: eb.ref('excluded.timeZone'),
137-
latitude: eb.ref('excluded.latitude'),
138-
longitude: eb.ref('excluded.longitude'),
139-
projectionType: eb.ref('excluded.projectionType'),
140-
city: eb.ref('excluded.city'),
141-
livePhotoCID: eb.ref('excluded.livePhotoCID'),
142-
autoStackId: eb.ref('excluded.autoStackId'),
143-
state: eb.ref('excluded.state'),
144-
country: eb.ref('excluded.country'),
145-
make: eb.ref('excluded.make'),
146-
model: eb.ref('excluded.model'),
147-
lensModel: eb.ref('excluded.lensModel'),
148-
fNumber: eb.ref('excluded.fNumber'),
149-
focalLength: eb.ref('excluded.focalLength'),
150-
iso: eb.ref('excluded.iso'),
151-
exposureTime: eb.ref('excluded.exposureTime'),
152-
profileDescription: eb.ref('excluded.profileDescription'),
153-
colorspace: eb.ref('excluded.colorspace'),
154-
bitsPerSample: eb.ref('excluded.bitsPerSample'),
155-
rating: eb.ref('excluded.rating'),
156-
fps: eb.ref('excluded.fps'),
157-
},
158-
value,
159-
),
160-
),
137+
oc.column('assetId').doUpdateSet((eb) => {
138+
const updateLocked = <T extends keyof AssetExifTable>(col: T) => eb.ref(`excluded.${col}`);
139+
const skipLocked = <T extends keyof AssetExifTable>(col: T) =>
140+
eb
141+
.case()
142+
.when(sql`${col}`, '=', eb.fn.any('asset_exif.lockedProperties'))
143+
.then(eb.ref(`asset_exif.${col}`))
144+
.else(eb.ref(`excluded.${col}`))
145+
.end();
146+
const ref = lockedPropertiesBehavior === 'skip' ? skipLocked : updateLocked;
147+
return {
148+
...removeUndefinedKeys(
149+
{
150+
description: ref('description'),
151+
exifImageWidth: ref('exifImageWidth'),
152+
exifImageHeight: ref('exifImageHeight'),
153+
fileSizeInByte: ref('fileSizeInByte'),
154+
orientation: ref('orientation'),
155+
dateTimeOriginal: ref('dateTimeOriginal'),
156+
modifyDate: ref('modifyDate'),
157+
timeZone: ref('timeZone'),
158+
latitude: ref('latitude'),
159+
longitude: ref('longitude'),
160+
projectionType: ref('projectionType'),
161+
city: ref('city'),
162+
livePhotoCID: ref('livePhotoCID'),
163+
autoStackId: ref('autoStackId'),
164+
state: ref('state'),
165+
country: ref('country'),
166+
make: ref('make'),
167+
model: ref('model'),
168+
lensModel: ref('lensModel'),
169+
fNumber: ref('fNumber'),
170+
focalLength: ref('focalLength'),
171+
iso: ref('iso'),
172+
exposureTime: ref('exposureTime'),
173+
profileDescription: ref('profileDescription'),
174+
colorspace: ref('colorspace'),
175+
bitsPerSample: ref('bitsPerSample'),
176+
rating: ref('rating'),
177+
fps: ref('fps'),
178+
lockedProperties:
179+
lockedPropertiesBehavior === 'append'
180+
? distinctLocked(eb, exif.lockedProperties ?? null)
181+
: ref('lockedProperties'),
182+
},
183+
exif,
184+
),
185+
};
186+
}),
161187
)
162188
.execute();
163189
}
@@ -169,19 +195,26 @@ export class AssetRepository {
169195
return;
170196
}
171197

172-
await this.db.updateTable('asset_exif').set(options).where('assetId', 'in', ids).execute();
198+
await this.db
199+
.updateTable('asset_exif')
200+
.set((eb) => ({
201+
...options,
202+
lockedProperties: distinctLocked(eb, Object.keys(options) as LockableProperty[]),
203+
}))
204+
.where('assetId', 'in', ids)
205+
.execute();
173206
}
174207

175208
@GenerateSql({ params: [[DummyValue.UUID], DummyValue.NUMBER, DummyValue.STRING] })
176209
@Chunked()
177-
async updateDateTimeOriginal(
178-
ids: string[],
179-
delta?: number,
180-
timeZone?: string,
181-
): Promise<{ assetId: string; dateTimeOriginal: Date | null; timeZone: string | null }[]> {
182-
return await this.db
210+
updateDateTimeOriginal(ids: string[], delta?: number, timeZone?: string) {
211+
return this.db
183212
.updateTable('asset_exif')
184-
.set({ dateTimeOriginal: sql`"dateTimeOriginal" + ${(delta ?? 0) + ' minute'}::interval`, timeZone })
213+
.set((eb) => ({
214+
dateTimeOriginal: sql`"dateTimeOriginal" + ${(delta ?? 0) + ' minute'}::interval`,
215+
timeZone,
216+
lockedProperties: distinctLocked(eb, ['dateTimeOriginal', 'timeZone']),
217+
}))
185218
.where('assetId', 'in', ids)
186219
.returning(['assetId', 'dateTimeOriginal', 'timeZone'])
187220
.execute();
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import { Kysely, sql } from 'kysely';
2+
3+
export async function up(db: Kysely<any>): Promise<void> {
4+
await sql`ALTER TABLE "asset_exif" ADD "lockedProperties" character varying[];`.execute(db);
5+
}
6+
7+
export async function down(db: Kysely<any>): Promise<void> {
8+
await sql`ALTER TABLE "asset_exif" DROP COLUMN "lockedProperties";`.execute(db);
9+
}

server/src/schema/tables/asset-exif.table.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { LockableProperty } from 'src/database';
12
import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators';
23
import { AssetTable } from 'src/schema/tables/asset.table';
34
import { Column, ForeignKeyColumn, Generated, Int8, Table, Timestamp, UpdateDateColumn } from 'src/sql-tools';
@@ -97,4 +98,7 @@ export class AssetExifTable {
9798

9899
@UpdateIdColumn({ index: true })
99100
updateId!: Generated<string>;
101+
102+
@Column({ type: 'character varying', array: true, nullable: true })
103+
lockedProperties!: Array<LockableProperty> | null;
100104
}

server/src/services/asset-media.service.ts

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -370,7 +370,10 @@ export class AssetMediaService extends BaseService {
370370
: this.assetRepository.deleteFile({ assetId, type: AssetFileType.Sidecar }));
371371

372372
await this.storageRepository.utimes(file.originalPath, new Date(), new Date(dto.fileModifiedAt));
373-
await this.assetRepository.upsertExif({ assetId, fileSizeInByte: file.size });
373+
await this.assetRepository.upsertExif(
374+
{ assetId, fileSizeInByte: file.size },
375+
{ lockedPropertiesBehavior: 'override' },
376+
);
374377
await this.jobRepository.queue({
375378
name: JobName.AssetExtractMetadata,
376379
data: { id: assetId, source: 'upload' },
@@ -399,7 +402,10 @@ export class AssetMediaService extends BaseService {
399402
});
400403

401404
const { size } = await this.storageRepository.stat(created.originalPath);
402-
await this.assetRepository.upsertExif({ assetId: created.id, fileSizeInByte: size });
405+
await this.assetRepository.upsertExif(
406+
{ assetId: created.id, fileSizeInByte: size },
407+
{ lockedPropertiesBehavior: 'override' },
408+
);
403409
await this.jobRepository.queue({ name: JobName.AssetExtractMetadata, data: { id: created.id, source: 'copy' } });
404410
return created;
405411
}
@@ -440,7 +446,10 @@ export class AssetMediaService extends BaseService {
440446
await this.storageRepository.utimes(sidecarFile.originalPath, new Date(), new Date(dto.fileModifiedAt));
441447
}
442448
await this.storageRepository.utimes(file.originalPath, new Date(), new Date(dto.fileModifiedAt));
443-
await this.assetRepository.upsertExif({ assetId: asset.id, fileSizeInByte: file.size });
449+
await this.assetRepository.upsertExif(
450+
{ assetId: asset.id, fileSizeInByte: file.size },
451+
{ lockedPropertiesBehavior: 'override' },
452+
);
444453

445454
await this.eventRepository.emit('AssetCreate', { asset });
446455

0 commit comments

Comments
 (0)