1
0
mirror of https://github.com/immich-app/immich.git synced 2024-12-25 10:43:13 +02:00

feat(server): Update XMP sidecar search to look for both photo.ext.xmp and photo.xmp (#7813)

* Add support for photo.xmp sidecars

* format

* Add comment

* Proper handling

* Handle mocking better

* Address PR feedback

* Add test coverage if both xmp files exist

* Update server/src/domain/metadata/metadata.service.ts

Co-authored-by: Jason Rasmussen <jrasm91@gmail.com>

* Update server/src/domain/metadata/metadata.service.ts

Co-authored-by: Jason Rasmussen <jrasm91@gmail.com>

* Update server/src/domain/metadata/metadata.service.ts

Co-authored-by: Jason Rasmussen <jrasm91@gmail.com>

---------

Co-authored-by: Alex <alex.tran1502@gmail.com>
Co-authored-by: Jason Rasmussen <jrasm91@gmail.com>
This commit is contained in:
Kokul Shanmugharajah 2024-03-13 10:14:26 -07:00 committed by GitHub
parent 37e5b91dc2
commit 29c3a826c5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 95 additions and 5 deletions

View File

@ -646,7 +646,7 @@ describe(MetadataService.name, () => {
expect(assetMock.save).not.toHaveBeenCalled(); expect(assetMock.save).not.toHaveBeenCalled();
}); });
it('should set sidecar path if exists', async () => { it('should set sidecar path if exists (sidecar named photo.ext.xmp)', async () => {
assetMock.getByIds.mockResolvedValue([assetStub.sidecar]); assetMock.getByIds.mockResolvedValue([assetStub.sidecar]);
storageMock.checkFileExists.mockResolvedValue(true); storageMock.checkFileExists.mockResolvedValue(true);
@ -658,6 +658,41 @@ describe(MetadataService.name, () => {
}); });
}); });
it('should set sidecar path if exists (sidecar named photo.xmp)', async () => {
assetMock.getByIds.mockResolvedValue([assetStub.sidecarWithoutExt]);
storageMock.checkFileExists.mockResolvedValueOnce(false);
storageMock.checkFileExists.mockResolvedValueOnce(true);
await expect(sut.handleSidecarSync({ id: assetStub.sidecarWithoutExt.id })).resolves.toBe(true);
expect(storageMock.checkFileExists).toHaveBeenNthCalledWith(
2,
assetStub.sidecarWithoutExt.sidecarPath,
constants.R_OK,
);
expect(assetMock.save).toHaveBeenCalledWith({
id: assetStub.sidecarWithoutExt.id,
sidecarPath: assetStub.sidecarWithoutExt.sidecarPath,
});
});
it('should set sidecar path if exists (two sidecars named photo.ext.xmp and photo.xmp, should pick photo.ext.xmp)', async () => {
assetMock.getByIds.mockResolvedValue([assetStub.sidecar]);
storageMock.checkFileExists.mockResolvedValueOnce(true);
storageMock.checkFileExists.mockResolvedValueOnce(true);
await expect(sut.handleSidecarSync({ id: assetStub.sidecar.id })).resolves.toBe(true);
expect(storageMock.checkFileExists).toHaveBeenNthCalledWith(1, assetStub.sidecar.sidecarPath, constants.R_OK);
expect(storageMock.checkFileExists).toHaveBeenNthCalledWith(
2,
assetStub.sidecarWithoutExt.sidecarPath,
constants.R_OK,
);
expect(assetMock.save).toHaveBeenCalledWith({
id: assetStub.sidecar.id,
sidecarPath: assetStub.sidecar.sidecarPath,
});
});
it('should unset sidecar path if file does not exist anymore', async () => { it('should unset sidecar path if file does not exist anymore', async () => {
assetMock.getByIds.mockResolvedValue([assetStub.sidecar]); assetMock.getByIds.mockResolvedValue([assetStub.sidecar]);
storageMock.checkFileExists.mockResolvedValue(false); storageMock.checkFileExists.mockResolvedValue(false);

View File

@ -6,6 +6,7 @@ import { firstDateTime } from 'exiftool-vendored/dist/FirstDateTime';
import _ from 'lodash'; import _ from 'lodash';
import { Duration } from 'luxon'; import { Duration } from 'luxon';
import { constants } from 'node:fs/promises'; import { constants } from 'node:fs/promises';
import path from 'node:path';
import { Subscription } from 'rxjs'; import { Subscription } from 'rxjs';
import { handlePromiseError, usePagination } from '../domain.util'; import { handlePromiseError, usePagination } from '../domain.util';
import { IBaseJob, IEntityJob, ISidecarWriteJob, JOBS_ASSET_PAGINATION_SIZE, JobName, QueueName } from '../job'; import { IBaseJob, IEntityJob, ISidecarWriteJob, JOBS_ASSET_PAGINATION_SIZE, JobName, QueueName } from '../job';
@ -566,9 +567,25 @@ export class MetadataService {
return false; return false;
} }
const sidecarPath = `${asset.originalPath}.xmp`; // XMP sidecars can come in two filename formats. For a photo named photo.ext, the filenames are photo.ext.xmp and photo.xmp
const exists = await this.storageRepository.checkFileExists(sidecarPath, constants.R_OK); const assetPath = path.parse(asset.originalPath);
if (exists) { const assetPathWithoutExt = path.join(assetPath.dir, assetPath.name);
const sidecarPathWithoutExt = `${assetPathWithoutExt}.xmp`;
const sidecarPathWithExt = `${asset.originalPath}.xmp`;
const [sidecarPathWithExtExists, sidecarPathWithoutExtExists] = await Promise.all([
this.storageRepository.checkFileExists(sidecarPathWithExt, constants.R_OK),
this.storageRepository.checkFileExists(sidecarPathWithoutExt, constants.R_OK),
]);
let sidecarPath = null;
if (sidecarPathWithExtExists) {
sidecarPath = sidecarPathWithExt;
} else if (sidecarPathWithoutExtExists) {
sidecarPath = sidecarPathWithoutExt;
}
if (sidecarPath) {
await this.assetRepository.save({ id: asset.id, sidecarPath }); await this.assetRepository.save({ id: asset.id, sidecarPath });
return true; return true;
} }
@ -577,7 +594,9 @@ export class MetadataService {
return false; return false;
} }
this.logger.debug(`Sidecar File '${sidecarPath}' was not found, removing sidecarPath for asset ${asset.id}`); this.logger.debug(
`Sidecar file was not found. Checked paths '${sidecarPathWithExt}' and '${sidecarPathWithoutExt}'. Removing sidecarPath for asset ${asset.id}`,
);
await this.assetRepository.save({ id: asset.id, sidecarPath: null }); await this.assetRepository.save({ id: asset.id, sidecarPath: null });
return true; return true;

View File

@ -524,6 +524,42 @@ export const assetStub = {
sidecarPath: '/original/path.ext.xmp', sidecarPath: '/original/path.ext.xmp',
deletedAt: null, deletedAt: null,
}), }),
sidecarWithoutExt: Object.freeze<AssetEntity>({
id: 'asset-id',
deviceAssetId: 'device-asset-id',
fileModifiedAt: new Date('2023-02-23T05:06:29.716Z'),
fileCreatedAt: new Date('2023-02-23T05:06:29.716Z'),
owner: userStub.user1,
ownerId: 'user-id',
deviceId: 'device-id',
originalPath: '/original/path.ext',
resizePath: '/uploads/user-id/thumbs/path.ext',
thumbhash: null,
checksum: Buffer.from('file hash', 'utf8'),
type: AssetType.IMAGE,
webpPath: null,
encodedVideoPath: null,
createdAt: new Date('2023-02-23T05:06:29.716Z'),
updatedAt: new Date('2023-02-23T05:06:29.716Z'),
localDateTime: new Date('2023-02-23T05:06:29.716Z'),
isFavorite: true,
isArchived: false,
isReadOnly: false,
isExternal: false,
isOffline: false,
libraryId: 'library-id',
library: libraryStub.uploadLibrary1,
duration: null,
isVisible: true,
livePhotoVideo: null,
livePhotoVideoId: null,
tags: [],
sharedLinks: [],
originalFileName: 'asset-id.ext',
faces: [],
sidecarPath: '/original/path.xmp',
deletedAt: null,
}),
readOnly: Object.freeze<AssetEntity>({ readOnly: Object.freeze<AssetEntity>({
id: 'read-only-asset', id: 'read-only-asset',