diff --git a/server/src/database.ts b/server/src/database.ts index 7fd791c59c..f4e49f07ab 100644 --- a/server/src/database.ts +++ b/server/src/database.ts @@ -92,6 +92,13 @@ export type Asset = { type: AssetType; }; +export type SidecarWriteAsset = { + id: string; + sidecarPath: string | null; + originalPath: string; + tags: Array<{ value: string }>; +}; + export type AuthSharedLink = { id: string; expiresAt: Date | null; diff --git a/server/src/dtos/tag.dto.ts b/server/src/dtos/tag.dto.ts index e62cf21636..3c5e74647c 100644 --- a/server/src/dtos/tag.dto.ts +++ b/server/src/dtos/tag.dto.ts @@ -1,6 +1,5 @@ import { ApiProperty } from '@nestjs/swagger'; import { IsHexColor, IsNotEmpty, IsString } from 'class-validator'; -import { TagEntity } from 'src/entities/tag.entity'; import { TagItem } from 'src/types'; import { Optional, ValidateHexColor, ValidateUUID } from 'src/validation'; @@ -52,7 +51,7 @@ export class TagResponseDto { color?: string; } -export function mapTag(entity: TagItem | TagEntity): TagResponseDto { +export function mapTag(entity: TagItem): TagResponseDto { return { id: entity.id, parentId: entity.parentId ?? undefined, diff --git a/server/src/entities/asset.entity.ts b/server/src/entities/asset.entity.ts index e9f8bea4ca..50f2d6c5d1 100644 --- a/server/src/entities/asset.entity.ts +++ b/server/src/entities/asset.entity.ts @@ -8,11 +8,11 @@ import { AssetJobStatusEntity } from 'src/entities/asset-job-status.entity'; import { ExifEntity } from 'src/entities/exif.entity'; import { SharedLinkEntity } from 'src/entities/shared-link.entity'; import { StackEntity } from 'src/entities/stack.entity'; -import { TagEntity } from 'src/entities/tag.entity'; import { UserEntity } from 'src/entities/user.entity'; import { AssetFileType, AssetStatus, AssetType } from 'src/enum'; import { TimeBucketSize } from 'src/repositories/asset.repository'; import { AssetSearchBuilderOptions } from 'src/repositories/search.repository'; +import { TagItem } from 'src/types'; import { anyUuid, asUuid } from 'src/utils/database'; export const ASSET_CHECKSUM_CONSTRAINT = 'UQ_assets_owner_checksum'; @@ -49,7 +49,7 @@ export class AssetEntity { originalFileName!: string; sidecarPath!: string | null; exifInfo?: ExifEntity; - tags!: TagEntity[]; + tags?: TagItem[]; sharedLinks!: SharedLinkEntity[]; albums?: AlbumEntity[]; faces!: AssetFaceEntity[]; diff --git a/server/src/entities/tag.entity.ts b/server/src/entities/tag.entity.ts deleted file mode 100644 index 01235085a4..0000000000 --- a/server/src/entities/tag.entity.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { AssetEntity } from 'src/entities/asset.entity'; -import { UserEntity } from 'src/entities/user.entity'; - -export class TagEntity { - id!: string; - value!: string; - createdAt!: Date; - updatedAt!: Date; - updateId?: string; - color!: string | null; - parentId?: string; - parent?: TagEntity; - children?: TagEntity[]; - user?: UserEntity; - userId!: string; - assets?: AssetEntity[]; -} diff --git a/server/src/queries/asset.repository.sql b/server/src/queries/asset.repository.sql index a9dcb1f8bf..d840a7693c 100644 --- a/server/src/queries/asset.repository.sql +++ b/server/src/queries/asset.repository.sql @@ -210,6 +210,32 @@ where limit $3 +-- AssetRepository.getAssetForSidecarWriteJob +select + "id", + "sidecarPath", + "originalPath", + ( + select + coalesce(json_agg(agg), '[]') + from + ( + select + "tags"."value" + from + "tags" + inner join "tag_asset" on "tags"."id" = "tag_asset"."tagsId" + where + "assets"."id" = "tag_asset"."assetsId" + ) as agg + ) as "tags" +from + "assets" +where + "assets"."id" = $1::uuid +limit + $2 + -- AssetRepository.getById select "assets".* diff --git a/server/src/repositories/asset.repository.ts b/server/src/repositories/asset.repository.ts index 5f2a94cc80..3b71cf84fd 100644 --- a/server/src/repositories/asset.repository.ts +++ b/server/src/repositories/asset.repository.ts @@ -1,5 +1,6 @@ import { Injectable } from '@nestjs/common'; import { Insertable, Kysely, Selectable, UpdateResult, Updateable, sql } from 'kysely'; +import { jsonArrayFrom } from 'kysely/helpers/postgres'; import { isEmpty, isUndefined, omitBy } from 'lodash'; import { InjectKysely } from 'nestjs-kysely'; import { AssetFiles, AssetJobStatus, Assets, DB, Exif } from 'src/db'; @@ -495,6 +496,27 @@ export class AssetRepository { .executeTakeFirst(); } + @GenerateSql({ params: [DummyValue.UUID] }) + getAssetForSidecarWriteJob(id: string) { + return this.db + .selectFrom('assets') + .where('assets.id', '=', asUuid(id)) + .select((eb) => [ + 'id', + 'sidecarPath', + 'originalPath', + jsonArrayFrom( + eb + .selectFrom('tags') + .select(['tags.value']) + .innerJoin('tag_asset', 'tags.id', 'tag_asset.tagsId') + .whereRef('assets.id', '=', 'tag_asset.assetsId'), + ).as('tags'), + ]) + .limit(1) + .executeTakeFirst(); + } + @GenerateSql({ params: [DummyValue.UUID] }) getById( id: string, diff --git a/server/src/services/metadata.service.spec.ts b/server/src/services/metadata.service.spec.ts index a0d1cdb4b4..874a84e34b 100644 --- a/server/src/services/metadata.service.spec.ts +++ b/server/src/services/metadata.service.spec.ts @@ -15,6 +15,7 @@ import { probeStub } from 'test/fixtures/media.stub'; import { metadataStub } from 'test/fixtures/metadata.stub'; import { personStub } from 'test/fixtures/person.stub'; import { tagStub } from 'test/fixtures/tag.stub'; +import { factory } from 'test/small.factory'; import { newTestService, ServiceMocks } from 'test/utils'; describe(MetadataService.name, () => { @@ -1405,33 +1406,35 @@ describe(MetadataService.name, () => { describe('handleSidecarWrite', () => { it('should skip assets that do not exist anymore', async () => { - mocks.asset.getByIds.mockResolvedValue([]); + mocks.asset.getAssetForSidecarWriteJob.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 not metadata', async () => { - mocks.asset.getByIds.mockResolvedValue([assetStub.sidecar]); - await expect(sut.handleSidecarWrite({ id: assetStub.sidecar.id })).resolves.toBe(JobStatus.SKIPPED); + it('should skip jobs with no metadata', async () => { + const asset = factory.jobAssets.sidecarWrite(); + mocks.asset.getAssetForSidecarWriteJob.mockResolvedValue(asset); + await expect(sut.handleSidecarWrite({ id: asset.id })).resolves.toBe(JobStatus.SKIPPED); expect(mocks.metadata.writeTags).not.toHaveBeenCalled(); }); it('should write tags', async () => { + const asset = factory.jobAssets.sidecarWrite(); const description = 'this is a description'; const gps = 12; const date = '2023-11-22T04:56:12.196Z'; - mocks.asset.getByIds.mockResolvedValue([assetStub.sidecar]); + mocks.asset.getAssetForSidecarWriteJob.mockResolvedValue(asset); await expect( sut.handleSidecarWrite({ - id: assetStub.sidecar.id, + id: asset.id, description, latitude: gps, longitude: gps, dateTimeOriginal: date, }), ).resolves.toBe(JobStatus.SUCCESS); - expect(mocks.metadata.writeTags).toHaveBeenCalledWith(assetStub.sidecar.sidecarPath, { + expect(mocks.metadata.writeTags).toHaveBeenCalledWith(asset.sidecarPath, { Description: description, ImageDescription: description, DateTimeOriginal: date, diff --git a/server/src/services/metadata.service.ts b/server/src/services/metadata.service.ts index 402ccbbac7..824bf36c75 100644 --- a/server/src/services/metadata.service.ts +++ b/server/src/services/metadata.service.ts @@ -316,7 +316,7 @@ export class MetadataService extends BaseService { @OnJob({ name: JobName.SIDECAR_WRITE, queue: QueueName.SIDECAR }) async handleSidecarWrite(job: JobOf): Promise { const { id, description, dateTimeOriginal, latitude, longitude, rating, tags } = job; - const [asset] = await this.assetRepository.getByIds([id], { tags: true }); + const asset = await this.assetRepository.getAssetForSidecarWriteJob(id); if (!asset) { return JobStatus.FAILED; } diff --git a/server/test/fixtures/asset.stub.ts b/server/test/fixtures/asset.stub.ts index 5a140ce104..72016e9862 100644 --- a/server/test/fixtures/asset.stub.ts +++ b/server/test/fixtures/asset.stub.ts @@ -89,7 +89,6 @@ export const assetStub = { isVisible: true, livePhotoVideo: null, livePhotoVideoId: null, - tags: [], sharedLinks: [], faces: [], sidecarPath: null, @@ -123,7 +122,6 @@ export const assetStub = { isVisible: true, livePhotoVideo: null, livePhotoVideoId: null, - tags: [], sharedLinks: [], originalFileName: 'IMG_456.jpg', faces: [], @@ -162,7 +160,6 @@ export const assetStub = { isExternal: false, livePhotoVideo: null, livePhotoVideoId: null, - tags: [], sharedLinks: [], originalFileName: 'asset-id.ext', faces: [], @@ -197,7 +194,6 @@ export const assetStub = { isExternal: false, livePhotoVideo: null, livePhotoVideoId: null, - tags: [], sharedLinks: [], originalFileName: 'asset-id.jpg', faces: [], @@ -243,7 +239,6 @@ export const assetStub = { isExternal: false, livePhotoVideo: null, livePhotoVideoId: null, - tags: [], sharedLinks: [], originalFileName: 'asset-id.jpg', faces: [], @@ -283,7 +278,6 @@ export const assetStub = { isExternal: false, livePhotoVideo: null, livePhotoVideoId: null, - tags: [], sharedLinks: [], originalFileName: 'asset-id.jpg', faces: [], @@ -325,7 +319,6 @@ export const assetStub = { isExternal: false, livePhotoVideo: null, livePhotoVideoId: null, - tags: [], sharedLinks: [], originalFileName: 'asset-id.jpg', faces: [], @@ -363,7 +356,6 @@ export const assetStub = { isExternal: false, livePhotoVideo: null, livePhotoVideoId: null, - tags: [], sharedLinks: [], originalFileName: 'asset-id.jpg', faces: [], @@ -404,7 +396,6 @@ export const assetStub = { livePhotoVideo: null, livePhotoVideoId: null, libraryId: 'library-id', - tags: [], sharedLinks: [], originalFileName: 'asset-id.jpg', faces: [], @@ -443,7 +434,6 @@ export const assetStub = { livePhotoVideo: null, livePhotoVideoId: null, isExternal: false, - tags: [], sharedLinks: [], originalFileName: 'asset-id.ext', faces: [], @@ -480,7 +470,6 @@ export const assetStub = { isVisible: true, livePhotoVideo: null, livePhotoVideoId: null, - tags: [], sharedLinks: [], originalFileName: 'asset-id.ext', faces: [], @@ -519,7 +508,6 @@ export const assetStub = { isVisible: true, livePhotoVideo: null, livePhotoVideoId: null, - tags: [], sharedLinks: [], faces: [], sidecarPath: null, @@ -608,7 +596,6 @@ export const assetStub = { isVisible: true, livePhotoVideo: null, livePhotoVideoId: null, - tags: [], sharedLinks: [], originalFileName: 'asset-id.ext', faces: [], @@ -650,7 +637,6 @@ export const assetStub = { isVisible: true, livePhotoVideo: null, livePhotoVideoId: null, - tags: [], sharedLinks: [], originalFileName: 'asset-id.ext', faces: [], @@ -685,7 +671,6 @@ export const assetStub = { isVisible: true, livePhotoVideo: null, livePhotoVideoId: null, - tags: [], sharedLinks: [], originalFileName: 'asset-id.ext', faces: [], @@ -721,7 +706,6 @@ export const assetStub = { isVisible: true, livePhotoVideo: null, livePhotoVideoId: null, - tags: [], sharedLinks: [], faces: [], sidecarPath: null, @@ -759,7 +743,6 @@ export const assetStub = { livePhotoVideo: null, livePhotoVideoId: null, libraryId: 'library-id', - tags: [], sharedLinks: [], originalFileName: 'photo.jpg', faces: [], @@ -797,7 +780,6 @@ export const assetStub = { isExternal: false, livePhotoVideo: null, livePhotoVideoId: null, - tags: [], sharedLinks: [], originalFileName: 'asset-id.dng', faces: [], @@ -837,7 +819,6 @@ export const assetStub = { isExternal: false, livePhotoVideo: null, livePhotoVideoId: null, - tags: [], sharedLinks: [], originalFileName: 'asset-id.hif', faces: [], diff --git a/server/test/fixtures/shared-link.stub.ts b/server/test/fixtures/shared-link.stub.ts index 6ee31c0dea..739d6c5b93 100644 --- a/server/test/fixtures/shared-link.stub.ts +++ b/server/test/fixtures/shared-link.stub.ts @@ -241,7 +241,6 @@ export const sharedLinkStub = { autoStackId: null, rating: 3, }, - tags: [], sharedLinks: [], faces: [], sidecarPath: null, diff --git a/server/test/repositories/asset.repository.mock.ts b/server/test/repositories/asset.repository.mock.ts index 5e09e3e886..36fb298f7f 100644 --- a/server/test/repositories/asset.repository.mock.ts +++ b/server/test/repositories/asset.repository.mock.ts @@ -12,6 +12,7 @@ export const newAssetRepositoryMock = (): Mocked ({ version: '1.123.45', }); +const assetSidecarWriteFactory = (asset: Partial = {}) => ({ + id: newUuid(), + sidecarPath: '/path/to/original-path.jpg.xmp', + originalPath: '/path/to/original-path.jpg.xmp', + tags: [], + ...asset, +}); + export const factory = { activity: activityFactory, apiKey: apiKeyFactory, @@ -225,4 +243,7 @@ export const factory = { user: userFactory, userAdmin: userAdminFactory, versionHistory: versionHistoryFactory, + jobAssets: { + sidecarWrite: assetSidecarWriteFactory, + }, };