mirror of
https://github.com/immich-app/immich.git
synced 2026-02-04 08:49:01 +03:00
fix: tag update race condition (#25371)
This commit is contained in:
@@ -458,6 +458,7 @@ export const columns = {
|
|||||||
'asset_exif.projectionType',
|
'asset_exif.projectionType',
|
||||||
'asset_exif.rating',
|
'asset_exif.rating',
|
||||||
'asset_exif.state',
|
'asset_exif.state',
|
||||||
|
'asset_exif.tags',
|
||||||
'asset_exif.timeZone',
|
'asset_exif.timeZone',
|
||||||
],
|
],
|
||||||
plugin: [
|
plugin: [
|
||||||
@@ -481,4 +482,5 @@ export const lockableProperties = [
|
|||||||
'longitude',
|
'longitude',
|
||||||
'rating',
|
'rating',
|
||||||
'timeZone',
|
'timeZone',
|
||||||
|
'tags',
|
||||||
] as const;
|
] as const;
|
||||||
|
|||||||
@@ -38,20 +38,6 @@ select
|
|||||||
and "asset_file"."type" = $1
|
and "asset_file"."type" = $1
|
||||||
) as agg
|
) as agg
|
||||||
) as "files",
|
) as "files",
|
||||||
(
|
|
||||||
select
|
|
||||||
coalesce(json_agg(agg), '[]')
|
|
||||||
from
|
|
||||||
(
|
|
||||||
select
|
|
||||||
"tag"."value"
|
|
||||||
from
|
|
||||||
"tag"
|
|
||||||
inner join "tag_asset" on "tag"."id" = "tag_asset"."tagId"
|
|
||||||
where
|
|
||||||
"asset"."id" = "tag_asset"."assetId"
|
|
||||||
) as agg
|
|
||||||
) as "tags",
|
|
||||||
to_json("asset_exif") as "exifInfo"
|
to_json("asset_exif") as "exifInfo"
|
||||||
from
|
from
|
||||||
"asset"
|
"asset"
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ select
|
|||||||
"asset_exif"."projectionType",
|
"asset_exif"."projectionType",
|
||||||
"asset_exif"."rating",
|
"asset_exif"."rating",
|
||||||
"asset_exif"."state",
|
"asset_exif"."state",
|
||||||
|
"asset_exif"."tags",
|
||||||
"asset_exif"."timeZone"
|
"asset_exif"."timeZone"
|
||||||
from
|
from
|
||||||
"asset_exif"
|
"asset_exif"
|
||||||
@@ -127,6 +128,7 @@ select
|
|||||||
"asset_exif"."projectionType",
|
"asset_exif"."projectionType",
|
||||||
"asset_exif"."rating",
|
"asset_exif"."rating",
|
||||||
"asset_exif"."state",
|
"asset_exif"."state",
|
||||||
|
"asset_exif"."tags",
|
||||||
"asset_exif"."timeZone"
|
"asset_exif"."timeZone"
|
||||||
from
|
from
|
||||||
"asset_exif"
|
"asset_exif"
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { Kysely } from 'kysely';
|
import { Kysely } from 'kysely';
|
||||||
import { jsonArrayFrom } from 'kysely/helpers/postgres';
|
|
||||||
import { InjectKysely } from 'nestjs-kysely';
|
import { InjectKysely } from 'nestjs-kysely';
|
||||||
import { Asset, columns } from 'src/database';
|
import { Asset, columns } from 'src/database';
|
||||||
import { DummyValue, GenerateSql } from 'src/decorators';
|
import { DummyValue, GenerateSql } from 'src/decorators';
|
||||||
@@ -42,15 +41,6 @@ export class AssetJobRepository {
|
|||||||
.where('asset.id', '=', asUuid(id))
|
.where('asset.id', '=', asUuid(id))
|
||||||
.select(['id', 'originalPath'])
|
.select(['id', 'originalPath'])
|
||||||
.select((eb) => withFiles(eb, AssetFileType.Sidecar))
|
.select((eb) => withFiles(eb, AssetFileType.Sidecar))
|
||||||
.select((eb) =>
|
|
||||||
jsonArrayFrom(
|
|
||||||
eb
|
|
||||||
.selectFrom('tag')
|
|
||||||
.select(['tag.value'])
|
|
||||||
.innerJoin('tag_asset', 'tag.id', 'tag_asset.tagId')
|
|
||||||
.whereRef('asset.id', '=', 'tag_asset.assetId'),
|
|
||||||
).as('tags'),
|
|
||||||
)
|
|
||||||
.$call(withExifInner)
|
.$call(withExifInner)
|
||||||
.limit(1)
|
.limit(1)
|
||||||
.executeTakeFirst();
|
.executeTakeFirst();
|
||||||
|
|||||||
@@ -178,6 +178,7 @@ export class AssetRepository {
|
|||||||
bitsPerSample: ref('bitsPerSample'),
|
bitsPerSample: ref('bitsPerSample'),
|
||||||
rating: ref('rating'),
|
rating: ref('rating'),
|
||||||
fps: ref('fps'),
|
fps: ref('fps'),
|
||||||
|
tags: ref('tags'),
|
||||||
lockedProperties:
|
lockedProperties:
|
||||||
lockedPropertiesBehavior === 'append'
|
lockedPropertiesBehavior === 'append'
|
||||||
? distinctLocked(eb, exif.lockedProperties ?? null)
|
? distinctLocked(eb, exif.lockedProperties ?? null)
|
||||||
|
|||||||
@@ -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 "tags" character varying[];`.execute(db);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function down(db: Kysely<any>): Promise<void> {
|
||||||
|
await sql`ALTER TABLE "asset_exif" DROP COLUMN "tags";`.execute(db);
|
||||||
|
}
|
||||||
@@ -93,6 +93,9 @@ export class AssetExifTable {
|
|||||||
@Column({ type: 'integer', nullable: true })
|
@Column({ type: 'integer', nullable: true })
|
||||||
rating!: number | null;
|
rating!: number | null;
|
||||||
|
|
||||||
|
@Column({ type: 'character varying', array: true, nullable: true })
|
||||||
|
tags!: string[] | null;
|
||||||
|
|
||||||
@UpdateDateColumn({ default: () => 'clock_timestamp()' })
|
@UpdateDateColumn({ default: () => 'clock_timestamp()' })
|
||||||
updatedAt!: Generated<Date>;
|
updatedAt!: Generated<Date>;
|
||||||
|
|
||||||
|
|||||||
@@ -387,6 +387,7 @@ describe(MetadataService.name, () => {
|
|||||||
|
|
||||||
it('should extract tags from TagsList', async () => {
|
it('should extract tags from TagsList', async () => {
|
||||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(removeNonSidecarFiles(assetStub.image));
|
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(removeNonSidecarFiles(assetStub.image));
|
||||||
|
mocks.asset.getById.mockResolvedValue({ exifInfo: { tags: ['Parent'] } } as any);
|
||||||
mockReadTags({ TagsList: ['Parent'] });
|
mockReadTags({ TagsList: ['Parent'] });
|
||||||
mocks.tag.upsertValue.mockResolvedValue(tagStub.parentUpsert);
|
mocks.tag.upsertValue.mockResolvedValue(tagStub.parentUpsert);
|
||||||
|
|
||||||
@@ -397,6 +398,7 @@ describe(MetadataService.name, () => {
|
|||||||
|
|
||||||
it('should extract hierarchy from TagsList', async () => {
|
it('should extract hierarchy from TagsList', async () => {
|
||||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(removeNonSidecarFiles(assetStub.image));
|
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(removeNonSidecarFiles(assetStub.image));
|
||||||
|
mocks.asset.getById.mockResolvedValue({ exifInfo: { tags: ['Parent/Child'] } } as any);
|
||||||
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);
|
||||||
@@ -417,6 +419,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.assetJob.getForMetadataExtraction.mockResolvedValue(removeNonSidecarFiles(assetStub.image));
|
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(removeNonSidecarFiles(assetStub.image));
|
||||||
|
mocks.asset.getById.mockResolvedValue({ exifInfo: { tags: ['Parent'] } } as any);
|
||||||
mockReadTags({ Keywords: 'Parent' });
|
mockReadTags({ Keywords: 'Parent' });
|
||||||
mocks.tag.upsertValue.mockResolvedValue(tagStub.parentUpsert);
|
mocks.tag.upsertValue.mockResolvedValue(tagStub.parentUpsert);
|
||||||
|
|
||||||
@@ -427,6 +430,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.assetJob.getForMetadataExtraction.mockResolvedValue(removeNonSidecarFiles(assetStub.image));
|
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(removeNonSidecarFiles(assetStub.image));
|
||||||
|
mocks.asset.getById.mockResolvedValue({ exifInfo: { tags: ['Parent'] } } as any);
|
||||||
mockReadTags({ Keywords: ['Parent'] });
|
mockReadTags({ Keywords: ['Parent'] });
|
||||||
mocks.tag.upsertValue.mockResolvedValue(tagStub.parentUpsert);
|
mocks.tag.upsertValue.mockResolvedValue(tagStub.parentUpsert);
|
||||||
|
|
||||||
@@ -437,6 +441,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.assetJob.getForMetadataExtraction.mockResolvedValue(removeNonSidecarFiles(assetStub.image));
|
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(removeNonSidecarFiles(assetStub.image));
|
||||||
|
mocks.asset.getById.mockResolvedValue({ exifInfo: { tags: ['Parent', '2024'] } } as any);
|
||||||
mockReadTags({ Keywords: ['Parent', 2024] });
|
mockReadTags({ Keywords: ['Parent', 2024] });
|
||||||
mocks.tag.upsertValue.mockResolvedValue(tagStub.parentUpsert);
|
mocks.tag.upsertValue.mockResolvedValue(tagStub.parentUpsert);
|
||||||
|
|
||||||
@@ -448,6 +453,7 @@ describe(MetadataService.name, () => {
|
|||||||
|
|
||||||
it('should extract hierarchal tags from Keywords', async () => {
|
it('should extract hierarchal tags from Keywords', async () => {
|
||||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(removeNonSidecarFiles(assetStub.image));
|
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(removeNonSidecarFiles(assetStub.image));
|
||||||
|
mocks.asset.getById.mockResolvedValue({ exifInfo: { tags: ['Parent/Child'] } } as any);
|
||||||
mockReadTags({ Keywords: 'Parent/Child' });
|
mockReadTags({ Keywords: 'Parent/Child' });
|
||||||
mocks.tag.upsertValue.mockResolvedValue(tagStub.parentUpsert);
|
mocks.tag.upsertValue.mockResolvedValue(tagStub.parentUpsert);
|
||||||
|
|
||||||
@@ -467,6 +473,7 @@ describe(MetadataService.name, () => {
|
|||||||
|
|
||||||
it('should ignore Keywords when TagsList is present', async () => {
|
it('should ignore Keywords when TagsList is present', async () => {
|
||||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(removeNonSidecarFiles(assetStub.image));
|
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(removeNonSidecarFiles(assetStub.image));
|
||||||
|
mocks.asset.getById.mockResolvedValue({ exifInfo: { tags: ['Parent/Child', 'Child'] } } as any);
|
||||||
mockReadTags({ Keywords: 'Child', TagsList: ['Parent/Child'] });
|
mockReadTags({ Keywords: 'Child', TagsList: ['Parent/Child'] });
|
||||||
mocks.tag.upsertValue.mockResolvedValue(tagStub.parentUpsert);
|
mocks.tag.upsertValue.mockResolvedValue(tagStub.parentUpsert);
|
||||||
|
|
||||||
@@ -486,6 +493,7 @@ describe(MetadataService.name, () => {
|
|||||||
|
|
||||||
it('should extract hierarchy from HierarchicalSubject', async () => {
|
it('should extract hierarchy from HierarchicalSubject', async () => {
|
||||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(removeNonSidecarFiles(assetStub.image));
|
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(removeNonSidecarFiles(assetStub.image));
|
||||||
|
mocks.asset.getById.mockResolvedValue({ exifInfo: { tags: ['Parent/Child', 'TagA'] } } as any);
|
||||||
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);
|
||||||
@@ -507,6 +515,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.assetJob.getForMetadataExtraction.mockResolvedValue(removeNonSidecarFiles(assetStub.image));
|
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(removeNonSidecarFiles(assetStub.image));
|
||||||
|
mocks.asset.getById.mockResolvedValue({ exifInfo: { tags: ['Parent', '2024'] } } as any);
|
||||||
mockReadTags({ HierarchicalSubject: ['Parent', 2024] });
|
mockReadTags({ HierarchicalSubject: ['Parent', 2024] });
|
||||||
mocks.tag.upsertValue.mockResolvedValue(tagStub.parentUpsert);
|
mocks.tag.upsertValue.mockResolvedValue(tagStub.parentUpsert);
|
||||||
|
|
||||||
@@ -518,6 +527,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.assetJob.getForMetadataExtraction.mockResolvedValue(assetStub.image);
|
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(assetStub.image);
|
||||||
|
mocks.asset.getById.mockResolvedValue({ exifInfo: { tags: ['Mom|Dad'] } } as any);
|
||||||
mockReadTags({ HierarchicalSubject: ['Mom/Dad'] });
|
mockReadTags({ HierarchicalSubject: ['Mom/Dad'] });
|
||||||
mocks.tag.upsertValue.mockResolvedValueOnce(tagStub.parentUpsert);
|
mocks.tag.upsertValue.mockResolvedValueOnce(tagStub.parentUpsert);
|
||||||
|
|
||||||
@@ -532,6 +542,7 @@ describe(MetadataService.name, () => {
|
|||||||
|
|
||||||
it('should ignore HierarchicalSubject when TagsList is present', async () => {
|
it('should ignore HierarchicalSubject when TagsList is present', async () => {
|
||||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(assetStub.image);
|
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(assetStub.image);
|
||||||
|
mocks.asset.getById.mockResolvedValue({ exifInfo: { tags: ['Parent/Child', 'Parent2/Child2'] } } as any);
|
||||||
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);
|
||||||
|
|
||||||
@@ -896,6 +907,7 @@ describe(MetadataService.name, () => {
|
|||||||
ProfileDescription: 'extensive description',
|
ProfileDescription: 'extensive description',
|
||||||
ProjectionType: 'equirectangular',
|
ProjectionType: 'equirectangular',
|
||||||
tz: 'UTC-11:30',
|
tz: 'UTC-11:30',
|
||||||
|
TagsList: ['parent/child'],
|
||||||
Rating: 3,
|
Rating: 3,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -935,6 +947,7 @@ describe(MetadataService.name, () => {
|
|||||||
country: null,
|
country: null,
|
||||||
state: null,
|
state: null,
|
||||||
city: null,
|
city: null,
|
||||||
|
tags: ['parent/child'],
|
||||||
},
|
},
|
||||||
{ lockedPropertiesBehavior: 'skip' },
|
{ lockedPropertiesBehavior: 'skip' },
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -254,6 +254,8 @@ export class MetadataService extends BaseService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const tags = this.getTagList(exifTags);
|
||||||
|
|
||||||
const exifData: Insertable<AssetExifTable> = {
|
const exifData: Insertable<AssetExifTable> = {
|
||||||
assetId: asset.id,
|
assetId: asset.id,
|
||||||
|
|
||||||
@@ -296,6 +298,8 @@ export class MetadataService extends BaseService {
|
|||||||
// grouping
|
// grouping
|
||||||
livePhotoCID: (exifTags.ContentIdentifier || exifTags.MediaGroupUUID) ?? null,
|
livePhotoCID: (exifTags.ContentIdentifier || exifTags.MediaGroupUUID) ?? null,
|
||||||
autoStackId: this.getAutoStackId(exifTags),
|
autoStackId: this.getAutoStackId(exifTags),
|
||||||
|
|
||||||
|
tags: tags.length > 0 ? tags : null,
|
||||||
};
|
};
|
||||||
|
|
||||||
const isSidewards = exifTags.Orientation && this.isOrientationSidewards(exifTags.Orientation);
|
const isSidewards = exifTags.Orientation && this.isOrientationSidewards(exifTags.Orientation);
|
||||||
@@ -316,9 +320,10 @@ export class MetadataService extends BaseService {
|
|||||||
width: asset.width == null ? assetWidth : undefined,
|
width: asset.width == null ? assetWidth : undefined,
|
||||||
height: asset.height == null ? assetHeight : undefined,
|
height: asset.height == null ? assetHeight : undefined,
|
||||||
}),
|
}),
|
||||||
this.applyTagList(asset, exifTags),
|
|
||||||
];
|
];
|
||||||
|
|
||||||
|
await this.applyTagList(asset);
|
||||||
|
|
||||||
if (this.isMotionPhoto(asset, exifTags)) {
|
if (this.isMotionPhoto(asset, exifTags)) {
|
||||||
promises.push(this.applyMotionPhotos(asset, exifTags, dates, stats));
|
promises.push(this.applyMotionPhotos(asset, exifTags, dates, stats));
|
||||||
}
|
}
|
||||||
@@ -405,35 +410,35 @@ export class MetadataService extends BaseService {
|
|||||||
|
|
||||||
@OnEvent({ name: 'AssetTag' })
|
@OnEvent({ name: 'AssetTag' })
|
||||||
async handleTagAsset({ assetId }: ArgOf<'AssetTag'>) {
|
async handleTagAsset({ assetId }: ArgOf<'AssetTag'>) {
|
||||||
await this.jobRepository.queue({ name: JobName.SidecarWrite, data: { id: assetId, tags: true } });
|
await this.jobRepository.queue({ name: JobName.SidecarWrite, data: { id: assetId } });
|
||||||
}
|
}
|
||||||
|
|
||||||
@OnEvent({ name: 'AssetUntag' })
|
@OnEvent({ name: 'AssetUntag' })
|
||||||
async handleUntagAsset({ assetId }: ArgOf<'AssetUntag'>) {
|
async handleUntagAsset({ assetId }: ArgOf<'AssetUntag'>) {
|
||||||
await this.jobRepository.queue({ name: JobName.SidecarWrite, data: { id: assetId, tags: true } });
|
await this.jobRepository.queue({ name: JobName.SidecarWrite, data: { id: assetId } });
|
||||||
}
|
}
|
||||||
|
|
||||||
@OnJob({ name: JobName.SidecarWrite, queue: QueueName.Sidecar })
|
@OnJob({ name: JobName.SidecarWrite, queue: QueueName.Sidecar })
|
||||||
async handleSidecarWrite(job: JobOf<JobName.SidecarWrite>): Promise<JobStatus> {
|
async handleSidecarWrite(job: JobOf<JobName.SidecarWrite>): Promise<JobStatus> {
|
||||||
const { id, tags } = job;
|
const { id } = job;
|
||||||
const asset = await this.assetJobRepository.getForSidecarWriteJob(id);
|
const asset = await this.assetJobRepository.getForSidecarWriteJob(id);
|
||||||
if (!asset) {
|
if (!asset) {
|
||||||
return JobStatus.Failed;
|
return JobStatus.Failed;
|
||||||
}
|
}
|
||||||
|
|
||||||
const lockedProperties = await this.assetJobRepository.getLockedPropertiesForMetadataExtraction(id);
|
const lockedProperties = await this.assetJobRepository.getLockedPropertiesForMetadataExtraction(id);
|
||||||
const tagsList = (asset.tags || []).map((tag) => tag.value);
|
|
||||||
|
|
||||||
const { sidecarFile } = getAssetFiles(asset.files);
|
const { sidecarFile } = getAssetFiles(asset.files);
|
||||||
const sidecarPath = sidecarFile?.path || `${asset.originalPath}.xmp`;
|
const sidecarPath = sidecarFile?.path || `${asset.originalPath}.xmp`;
|
||||||
|
|
||||||
const { description, dateTimeOriginal, latitude, longitude, rating } = _.pick(
|
const { description, dateTimeOriginal, latitude, longitude, rating, tags } = _.pick(
|
||||||
{
|
{
|
||||||
description: asset.exifInfo.description,
|
description: asset.exifInfo.description,
|
||||||
dateTimeOriginal: asset.exifInfo.dateTimeOriginal,
|
dateTimeOriginal: asset.exifInfo.dateTimeOriginal,
|
||||||
latitude: asset.exifInfo.latitude,
|
latitude: asset.exifInfo.latitude,
|
||||||
longitude: asset.exifInfo.longitude,
|
longitude: asset.exifInfo.longitude,
|
||||||
rating: asset.exifInfo.rating,
|
rating: asset.exifInfo.rating,
|
||||||
|
tags: asset.exifInfo.tags,
|
||||||
},
|
},
|
||||||
lockedProperties,
|
lockedProperties,
|
||||||
);
|
);
|
||||||
@@ -446,7 +451,7 @@ export class MetadataService extends BaseService {
|
|||||||
GPSLatitude: latitude,
|
GPSLatitude: latitude,
|
||||||
GPSLongitude: longitude,
|
GPSLongitude: longitude,
|
||||||
Rating: rating,
|
Rating: rating,
|
||||||
TagsList: tags ? tagsList : undefined,
|
TagsList: tags?.length ? tags : undefined,
|
||||||
},
|
},
|
||||||
_.isUndefined,
|
_.isUndefined,
|
||||||
);
|
);
|
||||||
@@ -560,11 +565,14 @@ export class MetadataService extends BaseService {
|
|||||||
return tags;
|
return tags;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async applyTagList(asset: { id: string; ownerId: string }, exifTags: ImmichTags) {
|
private async applyTagList({ id, ownerId }: { id: string; ownerId: string }) {
|
||||||
const tags = this.getTagList(exifTags);
|
const asset = await this.assetRepository.getById(id, { exifInfo: true });
|
||||||
const results = await upsertTags(this.tagRepository, { userId: asset.ownerId, tags });
|
const results = await upsertTags(this.tagRepository, {
|
||||||
|
userId: ownerId,
|
||||||
|
tags: asset?.exifInfo?.tags ?? [],
|
||||||
|
});
|
||||||
await this.tagRepository.replaceAssetTags(
|
await this.tagRepository.replaceAssetTags(
|
||||||
asset.id,
|
id,
|
||||||
results.map((tag) => tag.id),
|
results.map((tag) => tag.id),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -191,6 +191,7 @@ describe(TagService.name, () => {
|
|||||||
it('should upsert records', async () => {
|
it('should upsert records', async () => {
|
||||||
mocks.access.tag.checkOwnerAccess.mockResolvedValue(new Set(['tag-1', 'tag-2']));
|
mocks.access.tag.checkOwnerAccess.mockResolvedValue(new Set(['tag-1', 'tag-2']));
|
||||||
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1', 'asset-2', 'asset-3']));
|
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1', 'asset-2', 'asset-3']));
|
||||||
|
mocks.asset.getById.mockResolvedValue({ tags: [{ value: 'tag-1' }, { value: 'tag-2' }] } as any);
|
||||||
mocks.tag.upsertAssetIds.mockResolvedValue([
|
mocks.tag.upsertAssetIds.mockResolvedValue([
|
||||||
{ tagId: 'tag-1', assetId: 'asset-1' },
|
{ tagId: 'tag-1', assetId: 'asset-1' },
|
||||||
{ tagId: 'tag-1', assetId: 'asset-2' },
|
{ tagId: 'tag-1', assetId: 'asset-2' },
|
||||||
@@ -204,6 +205,18 @@ describe(TagService.name, () => {
|
|||||||
).resolves.toEqual({
|
).resolves.toEqual({
|
||||||
count: 6,
|
count: 6,
|
||||||
});
|
});
|
||||||
|
expect(mocks.asset.upsertExif).toHaveBeenCalledWith(
|
||||||
|
{ assetId: 'asset-1', tags: ['tag-1', 'tag-2'] },
|
||||||
|
{ lockedPropertiesBehavior: 'append' },
|
||||||
|
);
|
||||||
|
expect(mocks.asset.upsertExif).toHaveBeenCalledWith(
|
||||||
|
{ assetId: 'asset-2', tags: ['tag-1', 'tag-2'] },
|
||||||
|
{ lockedPropertiesBehavior: 'append' },
|
||||||
|
);
|
||||||
|
expect(mocks.asset.upsertExif).toHaveBeenCalledWith(
|
||||||
|
{ assetId: 'asset-3', tags: ['tag-1', 'tag-2'] },
|
||||||
|
{ lockedPropertiesBehavior: 'append' },
|
||||||
|
);
|
||||||
expect(mocks.tag.upsertAssetIds).toHaveBeenCalledWith([
|
expect(mocks.tag.upsertAssetIds).toHaveBeenCalledWith([
|
||||||
{ tagId: 'tag-1', assetId: 'asset-1' },
|
{ tagId: 'tag-1', assetId: 'asset-1' },
|
||||||
{ tagId: 'tag-1', assetId: 'asset-2' },
|
{ tagId: 'tag-1', assetId: 'asset-2' },
|
||||||
@@ -229,6 +242,7 @@ describe(TagService.name, () => {
|
|||||||
mocks.tag.get.mockResolvedValue(tagStub.tag);
|
mocks.tag.get.mockResolvedValue(tagStub.tag);
|
||||||
mocks.tag.getAssetIds.mockResolvedValue(new Set(['asset-1']));
|
mocks.tag.getAssetIds.mockResolvedValue(new Set(['asset-1']));
|
||||||
mocks.tag.addAssetIds.mockResolvedValue();
|
mocks.tag.addAssetIds.mockResolvedValue();
|
||||||
|
mocks.asset.getById.mockResolvedValue({ tags: [{ value: 'tag-1' }] } as any);
|
||||||
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-2']));
|
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-2']));
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
@@ -240,6 +254,14 @@ describe(TagService.name, () => {
|
|||||||
{ id: 'asset-2', success: true },
|
{ id: 'asset-2', success: true },
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
expect(mocks.asset.upsertExif).not.toHaveBeenCalledWith(
|
||||||
|
{ assetId: 'asset-1', tags: ['tag-1'] },
|
||||||
|
{ lockedPropertiesBehavior: 'append' },
|
||||||
|
);
|
||||||
|
expect(mocks.asset.upsertExif).toHaveBeenCalledWith(
|
||||||
|
{ assetId: 'asset-2', tags: ['tag-1'] },
|
||||||
|
{ lockedPropertiesBehavior: 'append' },
|
||||||
|
);
|
||||||
expect(mocks.tag.getAssetIds).toHaveBeenCalledWith('tag-1', ['asset-1', 'asset-2']);
|
expect(mocks.tag.getAssetIds).toHaveBeenCalledWith('tag-1', ['asset-1', 'asset-2']);
|
||||||
expect(mocks.tag.addAssetIds).toHaveBeenCalledWith('tag-1', ['asset-2']);
|
expect(mocks.tag.addAssetIds).toHaveBeenCalledWith('tag-1', ['asset-2']);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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.assetId))) {
|
for (const assetId of new Set(results.map((item) => item.assetId))) {
|
||||||
|
await this.updateTags(assetId);
|
||||||
await this.eventRepository.emit('AssetTag', { assetId });
|
await this.eventRepository.emit('AssetTag', { 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.updateTags(assetId);
|
||||||
await this.eventRepository.emit('AssetTag', { assetId });
|
await this.eventRepository.emit('AssetTag', { 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.updateTags(assetId);
|
||||||
await this.eventRepository.emit('AssetUntag', { assetId });
|
await this.eventRepository.emit('AssetUntag', { assetId });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -145,4 +148,12 @@ export class TagService extends BaseService {
|
|||||||
}
|
}
|
||||||
return tag;
|
return tag;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async updateTags(assetId: string) {
|
||||||
|
const asset = await this.assetRepository.getById(assetId, { tags: true });
|
||||||
|
await this.assetRepository.upsertExif(
|
||||||
|
{ assetId, tags: asset?.tags?.map(({ value }) => value) ?? [] },
|
||||||
|
{ lockedPropertiesBehavior: 'append' },
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -318,7 +318,7 @@ export type JobItem =
|
|||||||
// Sidecar Scanning
|
// Sidecar Scanning
|
||||||
| { name: JobName.SidecarQueueAll; data: IBaseJob }
|
| { name: JobName.SidecarQueueAll; data: IBaseJob }
|
||||||
| { name: JobName.SidecarCheck; data: IEntityJob }
|
| { name: JobName.SidecarCheck; data: IEntityJob }
|
||||||
| { name: JobName.SidecarWrite; data: ISidecarWriteJob }
|
| { name: JobName.SidecarWrite; data: IEntityJob }
|
||||||
|
|
||||||
// Facial Recognition
|
// Facial Recognition
|
||||||
| { name: JobName.AssetDetectFacesQueueAll; data: IBaseJob }
|
| { name: JobName.AssetDetectFacesQueueAll; data: IBaseJob }
|
||||||
|
|||||||
1
server/test/fixtures/shared-link.stub.ts
vendored
1
server/test/fixtures/shared-link.stub.ts
vendored
@@ -147,6 +147,7 @@ export const sharedLinkStub = {
|
|||||||
visibility: AssetVisibility.Timeline,
|
visibility: AssetVisibility.Timeline,
|
||||||
width: 500,
|
width: 500,
|
||||||
height: 500,
|
height: 500,
|
||||||
|
tags: [],
|
||||||
},
|
},
|
||||||
sharedLinks: [],
|
sharedLinks: [],
|
||||||
faces: [],
|
faces: [],
|
||||||
|
|||||||
Reference in New Issue
Block a user