From d0bb59fac31f198051f1b65ccedf2b6cd6e5d643 Mon Sep 17 00:00:00 2001 From: Jonathan Jogenfors Date: Thu, 19 Dec 2024 14:39:56 +0100 Subject: [PATCH] handle sidecars in external libraries --- e2e/src/api/specs/library.e2e-spec.ts | 84 ++++++++++++++++++++- server/src/interfaces/job.interface.ts | 2 +- server/src/services/job.service.ts | 4 +- server/src/services/library.service.spec.ts | 49 ------------ server/src/services/library.service.ts | 13 +--- server/src/services/metadata.service.ts | 13 +++- 6 files changed, 102 insertions(+), 63 deletions(-) diff --git a/e2e/src/api/specs/library.e2e-spec.ts b/e2e/src/api/specs/library.e2e-spec.ts index 3f910fa1e3756..4994ed070dfd8 100644 --- a/e2e/src/api/specs/library.e2e-spec.ts +++ b/e2e/src/api/specs/library.e2e-spec.ts @@ -1,5 +1,5 @@ import { LibraryResponseDto, LoginResponseDto, getAllLibraries, scanLibrary } from '@immich/sdk'; -import { cpSync, existsSync } from 'node:fs'; +import { cpSync, existsSync, unlinkSync } from 'node:fs'; import { Socket } from 'socket.io-client'; import { userDto, uuidDto } from 'src/fixtures'; import { errorDto } from 'src/responses'; @@ -601,6 +601,88 @@ describe('/libraries', () => { expect(assets).toEqual(assetsBefore); }); + + describe('xmp metadata', async () => { + it('should import metadata from file.xmp', async () => { + const library = await utils.createLibrary(admin.accessToken, { + ownerId: admin.userId, + importPaths: [`${testAssetDirInternal}/temp/xmp`], + }); + + cpSync(`${testAssetDir}/metadata/xmp/dates/2000.xmp`, `${testAssetDir}/temp/xmp/glarus.xmp`); + cpSync(`${testAssetDir}/formats/raw/Nikon/D80/glarus.nef`, `${testAssetDir}/temp/xmp/glarus.nef`); + + await scan(admin.accessToken, library.id); + + await utils.waitForQueueFinish(admin.accessToken, 'library'); + await utils.waitForQueueFinish(admin.accessToken, 'metadataExtraction'); + + const { assets: newAssets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id }); + + expect(newAssets.items).toEqual([ + expect.objectContaining({ + originalFileName: 'glarus.nef', + fileCreatedAt: '2000-09-27T12:35:33.000Z', // This time comes from the xmp file, not from the image + }), + ]); + + unlinkSync(`${testAssetDir}/temp/xmp/glarus.xmp`); + unlinkSync(`${testAssetDir}/temp/xmp/glarus.nef`); + }); + + it('should import metadata from file.ext.xmp', async () => { + const library = await utils.createLibrary(admin.accessToken, { + ownerId: admin.userId, + importPaths: [`${testAssetDirInternal}/temp/xmp`], + }); + + cpSync(`${testAssetDir}/metadata/xmp/dates/2000.xmp`, `${testAssetDir}/temp/xmp/glarus.nef.xmp`); + cpSync(`${testAssetDir}/formats/raw/Nikon/D80/glarus.nef`, `${testAssetDir}/temp/xmp/glarus.nef`); + + await scan(admin.accessToken, library.id); + await utils.waitForQueueFinish(admin.accessToken, 'library'); + await utils.waitForQueueFinish(admin.accessToken, 'metadataExtraction'); + + const { assets: newAssets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id }); + + expect(newAssets.items).toEqual([ + expect.objectContaining({ + originalFileName: 'glarus.nef', + fileCreatedAt: '2000-09-27T12:35:33.000Z', // This time comes from the xmp file, not from the image + }), + ]); + + unlinkSync(`${testAssetDir}/temp/xmp/glarus.nef.xmp`); + unlinkSync(`${testAssetDir}/temp/xmp/glarus.nef`); + }); + + it('should import metadata in file.ext.xmp before file.xmp if both exist', async () => { + const library = await utils.createLibrary(admin.accessToken, { + ownerId: admin.userId, + importPaths: [`${testAssetDirInternal}/temp/xmp`], + }); + + cpSync(`${testAssetDir}/metadata/xmp/dates/2000.xmp`, `${testAssetDir}/temp/xmp/glarus.nef.xmp`); + cpSync(`${testAssetDir}/metadata/xmp/dates/2010.xmp`, `${testAssetDir}/temp/xmp/glarus.xmp`); + cpSync(`${testAssetDir}/formats/raw/Nikon/D80/glarus.nef`, `${testAssetDir}/temp/xmp/glarus.nef`); + + await scan(admin.accessToken, library.id); + await utils.waitForQueueFinish(admin.accessToken, 'library'); + await utils.waitForQueueFinish(admin.accessToken, 'metadataExtraction'); + + const { assets: newAssets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id }); + + expect(newAssets.items).toEqual([ + expect.objectContaining({ + originalFileName: 'glarus.nef', + fileCreatedAt: '2000-09-27T12:35:33.000Z', // This time comes from the xmp file, not from the image + }), + ]); + + unlinkSync(`${testAssetDir}/temp/xmp/glarus.nef.xmp`); + unlinkSync(`${testAssetDir}/temp/xmp/glarus.nef`); + }); + }); }); describe('POST /libraries/:id/validate', () => { diff --git a/server/src/interfaces/job.interface.ts b/server/src/interfaces/job.interface.ts index 7976f813022ff..270f282b829e1 100644 --- a/server/src/interfaces/job.interface.ts +++ b/server/src/interfaces/job.interface.ts @@ -135,7 +135,7 @@ export interface IDelayedJob extends IBaseJob { export interface IEntityJob extends IBaseJob { id: string; - source?: 'upload' | 'sidecar-write' | 'copy'; + source?: 'upload' | 'library-import' | 'sidecar-write' | 'copy'; notify?: boolean; } diff --git a/server/src/services/job.service.ts b/server/src/services/job.service.ts index 2faed0a51666a..021179338287f 100644 --- a/server/src/services/job.service.ts +++ b/server/src/services/job.service.ts @@ -250,7 +250,7 @@ export class JobService extends BaseService { } case JobName.STORAGE_TEMPLATE_MIGRATION_SINGLE: { - if (item.data.source === 'upload' || item.data.source === 'copy') { + if (item.data.source === 'upload' || item.data.source === 'copy' || item.data.source === 'library-import') { await this.jobRepository.queue({ name: JobName.GENERATE_THUMBNAILS, data: item.data }); } break; @@ -266,7 +266,7 @@ export class JobService extends BaseService { } case JobName.GENERATE_THUMBNAILS: { - if (!item.data.notify && item.data.source !== 'upload') { + if (!item.data.notify && item.data.source !== 'upload' && item.data.source === 'library-import') { break; } diff --git a/server/src/services/library.service.spec.ts b/server/src/services/library.service.spec.ts index 43d6662d659e5..8e21378ce67b1 100644 --- a/server/src/services/library.service.spec.ts +++ b/server/src/services/library.service.spec.ts @@ -414,54 +414,6 @@ describe(LibraryService.name, () => { localDateTime: expect.any(Date), type: AssetType.IMAGE, originalFileName: 'photo.jpg', - sidecarPath: null, - isExternal: true, - }, - ], - ]); - - expect(jobMock.queue.mock.calls).toEqual([ - [ - { - name: JobName.METADATA_EXTRACTION, - data: { - id: assetStub.image.id, - source: 'upload', - }, - }, - ], - ]); - }); - - it('should import a new asset with sidecar', async () => { - const mockLibraryJob: ILibraryFileJob = { - id: libraryStub.externalLibrary1.id, - ownerId: mockUser.id, - assetPath: '/data/user1/photo.jpg', - }; - - assetMock.getByLibraryIdAndOriginalPath.mockResolvedValue(null); - assetMock.create.mockResolvedValue(assetStub.image); - storageMock.checkFileExists.mockResolvedValue(true); - libraryMock.get.mockResolvedValue(libraryStub.externalLibrary1); - - await expect(sut.handleSyncFile(mockLibraryJob)).resolves.toBe(JobStatus.SUCCESS); - - expect(assetMock.create.mock.calls).toEqual([ - [ - { - ownerId: mockUser.id, - libraryId: libraryStub.externalLibrary1.id, - checksum: expect.any(Buffer), - originalPath: '/data/user1/photo.jpg', - deviceAssetId: expect.any(String), - deviceId: 'Library Import', - fileCreatedAt: expect.any(Date), - fileModifiedAt: expect.any(Date), - localDateTime: expect.any(Date), - type: AssetType.IMAGE, - originalFileName: 'photo.jpg', - sidecarPath: '/data/user1/photo.jpg.xmp', isExternal: true, }, ], @@ -507,7 +459,6 @@ describe(LibraryService.name, () => { localDateTime: expect.any(Date), type: AssetType.VIDEO, originalFileName: 'video.mp4', - sidecarPath: null, isExternal: true, }, ], diff --git a/server/src/services/library.service.ts b/server/src/services/library.service.ts index c0d24fea9e19d..435c11508def1 100644 --- a/server/src/services/library.service.ts +++ b/server/src/services/library.service.ts @@ -396,12 +396,6 @@ export class LibraryService extends BaseService { const pathHash = this.cryptoRepository.hashSha1(`path:${assetPath}`); - // TODO: doesn't xmp replace the file extension? Will need investigation - let sidecarPath: string | null = null; - if (await this.storageRepository.checkFileExists(`${assetPath}.xmp`, R_OK)) { - sidecarPath = `${assetPath}.xmp`; - } - const assetType = mimeTypes.isVideo(assetPath) ? AssetType.VIDEO : AssetType.IMAGE; const mtime = stat.mtime; @@ -418,8 +412,6 @@ export class LibraryService extends BaseService { localDateTime: mtime, type: assetType, originalFileName: parse(assetPath).base, - - sidecarPath, isExternal: true, }); @@ -431,7 +423,10 @@ export class LibraryService extends BaseService { async queuePostSyncJobs(asset: AssetEntity) { this.logger.debug(`Queueing metadata extraction for: ${asset.originalPath}`); - await this.jobRepository.queue({ name: JobName.METADATA_EXTRACTION, data: { id: asset.id, source: 'upload' } }); + await this.jobRepository.queue({ + name: JobName.METADATA_EXTRACTION, + data: { id: asset.id, source: 'library-import' }, + }); } async queueScan(id: string) { diff --git a/server/src/services/metadata.service.ts b/server/src/services/metadata.service.ts index 79a7d519d601e..b9e6af67d1837 100644 --- a/server/src/services/metadata.service.ts +++ b/server/src/services/metadata.service.ts @@ -148,13 +148,23 @@ export class MetadataService extends BaseService { } @OnJob({ name: JobName.METADATA_EXTRACTION, queue: QueueName.METADATA_EXTRACTION }) - async handleMetadataExtraction({ id }: JobOf): Promise { + async handleMetadataExtraction({ id, source }: JobOf): Promise { + this.logger.verbose(`Extracting metadata for asset ${id}`); + const { metadata, reverseGeocoding } = await this.getConfig({ withCache: true }); + + if (source === 'library-import') { + await this.processSidecar(id, false); + } + const [asset] = await this.assetRepository.getByIds([id], { faces: { person: false } }); + if (!asset) { return JobStatus.FAILED; } + this.logger.verbose(`Sidecar path: ${asset.sidecarPath}`); + const stats = await this.storageRepository.stat(asset.originalPath); const exifTags = await this.getExifTags(asset); @@ -722,6 +732,7 @@ export class MetadataService extends BaseService { if (sidecarPath) { await this.assetRepository.update({ id: asset.id, sidecarPath }); + this.logger.verbose(`Sidecar discovered at ${sidecarPath} for asset ${asset.id} at `); return JobStatus.SUCCESS; }