Skip to content

Commit

Permalink
handle sidecars in external libraries
Browse files Browse the repository at this point in the history
  • Loading branch information
etnoy committed Dec 19, 2024
1 parent e0fc873 commit 7af5081
Show file tree
Hide file tree
Showing 5 changed files with 103 additions and 14 deletions.
85 changes: 84 additions & 1 deletion e2e/src/api/specs/library.e2e-spec.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { LibraryResponseDto, LoginResponseDto, getAllLibraries, scanLibrary } from '@immich/sdk';
import { cpSync, existsSync } from 'node:fs';
import { cpSync, existsSync, unlink, unlinkSync } from 'node:fs';

Check failure on line 2 in e2e/src/api/specs/library.e2e-spec.ts

View workflow job for this annotation

GitHub Actions / End-to-End Lint

'unlink' is defined but never used
import { setTimeout as setAsyncTimeout } from 'node:timers/promises';

Check failure on line 3 in e2e/src/api/specs/library.e2e-spec.ts

View workflow job for this annotation

GitHub Actions / End-to-End Lint

'setAsyncTimeout' is defined but never used
import { Socket } from 'socket.io-client';
import { userDto, uuidDto } from 'src/fixtures';
import { errorDto } from 'src/responses';
Expand Down Expand Up @@ -601,6 +602,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`);

Check failure on line 613 in e2e/src/api/specs/library.e2e-spec.ts

View workflow job for this annotation

GitHub Actions / End-to-End Tests (Server & CLI)

src/api/specs/library.e2e-spec.ts > /libraries > POST /libraries/:id/scan > xmp metadata > should import metadata from file.xmp

Error: ENOENT: no such file or directory, lstat '/home/runner/_work/immich/immich/e2e/test-assets/metadata/xmp/dates/2000.xmp' ❯ src/api/specs/library.e2e-spec.ts:613:9 ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯ Serialized Error: { errno: -2, code: 'ENOENT', syscall: 'lstat', path: '/home/runner/_work/immich/immich/e2e/test-assets/metadata/xmp/dates/2000.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`);

Check failure on line 640 in e2e/src/api/specs/library.e2e-spec.ts

View workflow job for this annotation

GitHub Actions / End-to-End Tests (Server & CLI)

src/api/specs/library.e2e-spec.ts > /libraries > POST /libraries/:id/scan > xmp metadata > should import metadata from file.ext.xmp

Error: ENOENT: no such file or directory, lstat '/home/runner/_work/immich/immich/e2e/test-assets/metadata/xmp/dates/2000.xmp' ❯ src/api/specs/library.e2e-spec.ts:640:9 ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯ Serialized Error: { errno: -2, code: 'ENOENT', syscall: 'lstat', path: '/home/runner/_work/immich/immich/e2e/test-assets/metadata/xmp/dates/2000.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`);

Check failure on line 666 in e2e/src/api/specs/library.e2e-spec.ts

View workflow job for this annotation

GitHub Actions / End-to-End Tests (Server & CLI)

src/api/specs/library.e2e-spec.ts > /libraries > POST /libraries/:id/scan > xmp metadata > should import metadata in file.ext.xmp before file.xmp if both exist

Error: ENOENT: no such file or directory, lstat '/home/runner/_work/immich/immich/e2e/test-assets/metadata/xmp/dates/2000.xmp' ❯ src/api/specs/library.e2e-spec.ts:666:9 ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯ Serialized Error: { errno: -2, code: 'ENOENT', syscall: 'lstat', path: '/home/runner/_work/immich/immich/e2e/test-assets/metadata/xmp/dates/2000.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', () => {
Expand Down
2 changes: 1 addition & 1 deletion server/src/interfaces/job.interface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down
4 changes: 2 additions & 2 deletions server/src/services/job.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
}

Expand Down
13 changes: 4 additions & 9 deletions server/src/services/library.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -418,8 +412,6 @@ export class LibraryService extends BaseService {
localDateTime: mtime,
type: assetType,
originalFileName: parse(assetPath).base,

sidecarPath,
isExternal: true,
});

Expand All @@ -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) {
Expand Down
13 changes: 12 additions & 1 deletion server/src/services/metadata.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -148,13 +148,23 @@ export class MetadataService extends BaseService {
}

@OnJob({ name: JobName.METADATA_EXTRACTION, queue: QueueName.METADATA_EXTRACTION })
async handleMetadataExtraction({ id }: JobOf<JobName.METADATA_EXTRACTION>): Promise<JobStatus> {
async handleMetadataExtraction({ id, source }: JobOf<JobName.METADATA_EXTRACTION>): Promise<JobStatus> {
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);
Expand Down Expand Up @@ -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;
}

Expand Down

0 comments on commit 7af5081

Please sign in to comment.