|
|
|
|
@ -1,7 +1,6 @@
|
|
|
|
|
import { BinaryField, ExifDateTime } from 'exiftool-vendored';
|
|
|
|
|
import { randomBytes } from 'node:crypto';
|
|
|
|
|
import { Stats } from 'node:fs';
|
|
|
|
|
import { constants } from 'node:fs/promises';
|
|
|
|
|
import { defaults } from 'src/config';
|
|
|
|
|
import { MapAsset } from 'src/dtos/asset-response.dto';
|
|
|
|
|
import { AssetType, AssetVisibility, ExifOrientation, ImmichWorker, JobName, JobStatus, SourceType } from 'src/enum';
|
|
|
|
|
@ -15,6 +14,21 @@ import { tagStub } from 'test/fixtures/tag.stub';
|
|
|
|
|
import { factory } from 'test/small.factory';
|
|
|
|
|
import { makeStream, newTestService, ServiceMocks } from 'test/utils';
|
|
|
|
|
|
|
|
|
|
const forSidecarJob = (
|
|
|
|
|
asset: {
|
|
|
|
|
id?: string;
|
|
|
|
|
originalPath?: string;
|
|
|
|
|
sidecarPath?: string | null;
|
|
|
|
|
} = {},
|
|
|
|
|
) => {
|
|
|
|
|
return {
|
|
|
|
|
id: factory.uuid(),
|
|
|
|
|
originalPath: '/path/to/IMG_123.jpg',
|
|
|
|
|
sidecarPath: null,
|
|
|
|
|
...asset,
|
|
|
|
|
};
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const makeFaceTags = (face: Partial<{ Name: string }> = {}, orientation?: ImmichTags['Orientation']) => ({
|
|
|
|
|
Orientation: orientation,
|
|
|
|
|
RegionInfo: {
|
|
|
|
|
@ -1457,7 +1471,7 @@ describe(MetadataService.name, () => {
|
|
|
|
|
|
|
|
|
|
expect(mocks.job.queueAll).toHaveBeenCalledWith([
|
|
|
|
|
{
|
|
|
|
|
name: JobName.SidecarSync,
|
|
|
|
|
name: JobName.SidecarCheck,
|
|
|
|
|
data: { id: assetStub.sidecar.id },
|
|
|
|
|
},
|
|
|
|
|
]);
|
|
|
|
|
@ -1471,132 +1485,64 @@ describe(MetadataService.name, () => {
|
|
|
|
|
expect(mocks.assetJob.streamForSidecar).toHaveBeenCalledWith(false);
|
|
|
|
|
expect(mocks.job.queueAll).toHaveBeenCalledWith([
|
|
|
|
|
{
|
|
|
|
|
name: JobName.SidecarDiscovery,
|
|
|
|
|
name: JobName.SidecarCheck,
|
|
|
|
|
data: { id: assetStub.image.id },
|
|
|
|
|
},
|
|
|
|
|
]);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
describe('handleSidecarSync', () => {
|
|
|
|
|
describe('handleSidecarCheck', () => {
|
|
|
|
|
it('should do nothing if asset could not be found', async () => {
|
|
|
|
|
mocks.asset.getByIds.mockResolvedValue([]);
|
|
|
|
|
await expect(sut.handleSidecarSync({ id: assetStub.image.id })).resolves.toBe(JobStatus.Failed);
|
|
|
|
|
expect(mocks.asset.update).not.toHaveBeenCalled();
|
|
|
|
|
});
|
|
|
|
|
mocks.assetJob.getForSidecarCheckJob.mockResolvedValue(void 0);
|
|
|
|
|
|
|
|
|
|
await expect(sut.handleSidecarCheck({ id: assetStub.image.id })).resolves.toBeUndefined();
|
|
|
|
|
|
|
|
|
|
it('should do nothing if asset has no sidecar path', async () => {
|
|
|
|
|
mocks.asset.getByIds.mockResolvedValue([assetStub.image]);
|
|
|
|
|
await expect(sut.handleSidecarSync({ id: assetStub.image.id })).resolves.toBe(JobStatus.Failed);
|
|
|
|
|
expect(mocks.asset.update).not.toHaveBeenCalled();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('should set sidecar path if exists (sidecar named photo.ext.xmp)', async () => {
|
|
|
|
|
mocks.asset.getByIds.mockResolvedValue([assetStub.sidecar]);
|
|
|
|
|
mocks.storage.checkFileExists.mockResolvedValue(true);
|
|
|
|
|
|
|
|
|
|
await expect(sut.handleSidecarSync({ id: assetStub.sidecar.id })).resolves.toBe(JobStatus.Success);
|
|
|
|
|
expect(mocks.storage.checkFileExists).toHaveBeenCalledWith(
|
|
|
|
|
`${assetStub.sidecar.originalPath}.xmp`,
|
|
|
|
|
constants.R_OK,
|
|
|
|
|
);
|
|
|
|
|
expect(mocks.asset.update).toHaveBeenCalledWith({
|
|
|
|
|
id: assetStub.sidecar.id,
|
|
|
|
|
sidecarPath: assetStub.sidecar.sidecarPath,
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
it('should detect a new sidecar at .jpg.xmp', async () => {
|
|
|
|
|
const asset = forSidecarJob({ originalPath: '/path/to/IMG_123.jpg' });
|
|
|
|
|
|
|
|
|
|
it('should set sidecar path if exists (sidecar named photo.xmp)', async () => {
|
|
|
|
|
mocks.asset.getByIds.mockResolvedValue([assetStub.sidecarWithoutExt as any]);
|
|
|
|
|
mocks.storage.checkFileExists.mockResolvedValueOnce(false);
|
|
|
|
|
mocks.assetJob.getForSidecarCheckJob.mockResolvedValue(asset);
|
|
|
|
|
mocks.storage.checkFileExists.mockResolvedValueOnce(true);
|
|
|
|
|
|
|
|
|
|
await expect(sut.handleSidecarSync({ id: assetStub.sidecarWithoutExt.id })).resolves.toBe(JobStatus.Success);
|
|
|
|
|
expect(mocks.storage.checkFileExists).toHaveBeenNthCalledWith(
|
|
|
|
|
2,
|
|
|
|
|
assetStub.sidecarWithoutExt.sidecarPath,
|
|
|
|
|
constants.R_OK,
|
|
|
|
|
);
|
|
|
|
|
expect(mocks.asset.update).toHaveBeenCalledWith({
|
|
|
|
|
id: assetStub.sidecarWithoutExt.id,
|
|
|
|
|
sidecarPath: assetStub.sidecarWithoutExt.sidecarPath,
|
|
|
|
|
});
|
|
|
|
|
await expect(sut.handleSidecarCheck({ id: asset.id })).resolves.toBe(JobStatus.Success);
|
|
|
|
|
|
|
|
|
|
expect(mocks.asset.update).toHaveBeenCalledWith({ id: asset.id, sidecarPath: `/path/to/IMG_123.jpg.xmp` });
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('should set sidecar path if exists (two sidecars named photo.ext.xmp and photo.xmp, should pick photo.ext.xmp)', async () => {
|
|
|
|
|
mocks.asset.getByIds.mockResolvedValue([assetStub.sidecar]);
|
|
|
|
|
mocks.storage.checkFileExists.mockResolvedValueOnce(true);
|
|
|
|
|
it('should detect a new sidecar at .xmp', async () => {
|
|
|
|
|
const asset = forSidecarJob({ originalPath: '/path/to/IMG_123.jpg' });
|
|
|
|
|
|
|
|
|
|
mocks.assetJob.getForSidecarCheckJob.mockResolvedValue(asset);
|
|
|
|
|
mocks.storage.checkFileExists.mockResolvedValueOnce(false);
|
|
|
|
|
mocks.storage.checkFileExists.mockResolvedValueOnce(true);
|
|
|
|
|
|
|
|
|
|
await expect(sut.handleSidecarSync({ id: assetStub.sidecar.id })).resolves.toBe(JobStatus.Success);
|
|
|
|
|
expect(mocks.storage.checkFileExists).toHaveBeenNthCalledWith(1, assetStub.sidecar.sidecarPath, constants.R_OK);
|
|
|
|
|
expect(mocks.storage.checkFileExists).toHaveBeenNthCalledWith(
|
|
|
|
|
2,
|
|
|
|
|
assetStub.sidecarWithoutExt.sidecarPath,
|
|
|
|
|
constants.R_OK,
|
|
|
|
|
);
|
|
|
|
|
expect(mocks.asset.update).toHaveBeenCalledWith({
|
|
|
|
|
id: assetStub.sidecar.id,
|
|
|
|
|
sidecarPath: assetStub.sidecar.sidecarPath,
|
|
|
|
|
});
|
|
|
|
|
await expect(sut.handleSidecarCheck({ id: asset.id })).resolves.toBe(JobStatus.Success);
|
|
|
|
|
|
|
|
|
|
expect(mocks.asset.update).toHaveBeenCalledWith({ id: asset.id, sidecarPath: '/path/to/IMG_123.xmp' });
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('should unset sidecar path if file does not exist anymore', async () => {
|
|
|
|
|
mocks.asset.getByIds.mockResolvedValue([assetStub.sidecar]);
|
|
|
|
|
const asset = forSidecarJob({ originalPath: '/path/to/IMG_123.jpg', sidecarPath: '/path/to/IMG_123.jpg.xmp' });
|
|
|
|
|
mocks.assetJob.getForSidecarCheckJob.mockResolvedValue(asset);
|
|
|
|
|
mocks.storage.checkFileExists.mockResolvedValue(false);
|
|
|
|
|
|
|
|
|
|
await expect(sut.handleSidecarSync({ id: assetStub.sidecar.id })).resolves.toBe(JobStatus.Success);
|
|
|
|
|
expect(mocks.storage.checkFileExists).toHaveBeenCalledWith(
|
|
|
|
|
`${assetStub.sidecar.originalPath}.xmp`,
|
|
|
|
|
constants.R_OK,
|
|
|
|
|
);
|
|
|
|
|
expect(mocks.asset.update).toHaveBeenCalledWith({
|
|
|
|
|
id: assetStub.sidecar.id,
|
|
|
|
|
sidecarPath: null,
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
await expect(sut.handleSidecarCheck({ id: asset.id })).resolves.toBe(JobStatus.Success);
|
|
|
|
|
|
|
|
|
|
describe('handleSidecarDiscovery', () => {
|
|
|
|
|
it('should skip hidden assets', async () => {
|
|
|
|
|
mocks.asset.getByIds.mockResolvedValue([assetStub.livePhotoMotionAsset as any]);
|
|
|
|
|
await sut.handleSidecarDiscovery({ id: assetStub.livePhotoMotionAsset.id });
|
|
|
|
|
expect(mocks.storage.checkFileExists).not.toHaveBeenCalled();
|
|
|
|
|
expect(mocks.asset.update).toHaveBeenCalledWith({ id: asset.id, sidecarPath: null });
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('should skip assets with a sidecar path', async () => {
|
|
|
|
|
mocks.asset.getByIds.mockResolvedValue([assetStub.sidecar]);
|
|
|
|
|
await sut.handleSidecarDiscovery({ id: assetStub.sidecar.id });
|
|
|
|
|
expect(mocks.storage.checkFileExists).not.toHaveBeenCalled();
|
|
|
|
|
});
|
|
|
|
|
it('should do nothing if the sidecar file still exists', async () => {
|
|
|
|
|
const asset = forSidecarJob({ originalPath: '/path/to/IMG_123.jpg', sidecarPath: '/path/to/IMG_123.jpg' });
|
|
|
|
|
|
|
|
|
|
it('should do nothing when a sidecar is not found ', async () => {
|
|
|
|
|
mocks.asset.getByIds.mockResolvedValue([assetStub.image]);
|
|
|
|
|
mocks.storage.checkFileExists.mockResolvedValue(false);
|
|
|
|
|
await sut.handleSidecarDiscovery({ id: assetStub.image.id });
|
|
|
|
|
expect(mocks.asset.update).not.toHaveBeenCalled();
|
|
|
|
|
});
|
|
|
|
|
mocks.assetJob.getForSidecarCheckJob.mockResolvedValue(asset);
|
|
|
|
|
mocks.storage.checkFileExists.mockResolvedValueOnce(true);
|
|
|
|
|
|
|
|
|
|
it('should update a image asset when a sidecar is found', async () => {
|
|
|
|
|
mocks.asset.getByIds.mockResolvedValue([assetStub.image]);
|
|
|
|
|
mocks.storage.checkFileExists.mockResolvedValue(true);
|
|
|
|
|
await sut.handleSidecarDiscovery({ id: assetStub.image.id });
|
|
|
|
|
expect(mocks.storage.checkFileExists).toHaveBeenCalledWith('/original/path.jpg.xmp', constants.R_OK);
|
|
|
|
|
expect(mocks.asset.update).toHaveBeenCalledWith({
|
|
|
|
|
id: assetStub.image.id,
|
|
|
|
|
sidecarPath: '/original/path.jpg.xmp',
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
await expect(sut.handleSidecarCheck({ id: asset.id })).resolves.toBe(JobStatus.Skipped);
|
|
|
|
|
|
|
|
|
|
it('should update a video asset when a sidecar is found', async () => {
|
|
|
|
|
mocks.asset.getByIds.mockResolvedValue([assetStub.video]);
|
|
|
|
|
mocks.storage.checkFileExists.mockResolvedValue(true);
|
|
|
|
|
await sut.handleSidecarDiscovery({ id: assetStub.video.id });
|
|
|
|
|
expect(mocks.storage.checkFileExists).toHaveBeenCalledWith('/original/path.ext.xmp', constants.R_OK);
|
|
|
|
|
expect(mocks.asset.update).toHaveBeenCalledWith({
|
|
|
|
|
id: assetStub.image.id,
|
|
|
|
|
sidecarPath: '/original/path.ext.xmp',
|
|
|
|
|
});
|
|
|
|
|
expect(mocks.asset.update).not.toHaveBeenCalled();
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|