fix(server): keep user motion files on embedded motion replace (#32079)
(cherry picked from commit ddb799e0a44d112ecacc4867780f31bb8f71dc4f)
This commit is contained in:
@@ -2,6 +2,7 @@ import { BinaryField, ExifDateTime } from 'exiftool-vendored';
|
|||||||
import { DateTime } from 'luxon';
|
import { DateTime } from 'luxon';
|
||||||
import { randomBytes } from 'node:crypto';
|
import { randomBytes } from 'node:crypto';
|
||||||
import { Stats } from 'node:fs';
|
import { Stats } from 'node:fs';
|
||||||
|
import { StorageCore, ThumbnailPathEntity } from 'src/cores/storage.core.js';
|
||||||
import { defaults } from 'src/dtos/config.dto.js';
|
import { defaults } from 'src/dtos/config.dto.js';
|
||||||
import {
|
import {
|
||||||
AssetFileType,
|
AssetFileType,
|
||||||
@@ -13,6 +14,7 @@ import {
|
|||||||
JobName,
|
JobName,
|
||||||
JobStatus,
|
JobStatus,
|
||||||
SourceType,
|
SourceType,
|
||||||
|
StorageFolder,
|
||||||
} from 'src/enum.js';
|
} from 'src/enum.js';
|
||||||
import { ImmichTags } from 'src/repositories/metadata.repository.js';
|
import { ImmichTags } from 'src/repositories/metadata.repository.js';
|
||||||
import { MetadataService, firstDateTime } from 'src/services/metadata.service.js';
|
import { MetadataService, firstDateTime } from 'src/services/metadata.service.js';
|
||||||
@@ -21,7 +23,7 @@ import { PersonGroupFactory } from 'test/factories/person-group.factory.js';
|
|||||||
import { PersonFactory } from 'test/factories/person.factory.js';
|
import { PersonFactory } from 'test/factories/person.factory.js';
|
||||||
import { videoInfoStub } from 'test/fixtures/media.stub.js';
|
import { videoInfoStub } from 'test/fixtures/media.stub.js';
|
||||||
import { tagStub } from 'test/fixtures/tag.stub.js';
|
import { tagStub } from 'test/fixtures/tag.stub.js';
|
||||||
import { getForMetadataExtraction, getForSidecarWrite } from 'test/mappers.js';
|
import { getForAsset, getForMetadataExtraction, getForSidecarWrite } from 'test/mappers.js';
|
||||||
import { factory } from 'test/small.factory.js';
|
import { factory } from 'test/small.factory.js';
|
||||||
import { ServiceMocks, makeStream, newTestService } from 'test/utils.js';
|
import { ServiceMocks, makeStream, newTestService } from 'test/utils.js';
|
||||||
|
|
||||||
@@ -953,27 +955,47 @@ describe(MetadataService.name, () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should delete old motion photo video assets if they do not match what is extracted', async () => {
|
it.each([
|
||||||
const motionAsset = AssetFactory.create({ type: AssetType.Video, visibility: AssetVisibility.Hidden });
|
{
|
||||||
const asset = AssetFactory.create({ livePhotoVideoId: motionAsset.id });
|
motion: 'an extracted motion video',
|
||||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(getForMetadataExtraction(asset));
|
getOriginalPath: (motion: ThumbnailPathEntity) => StorageCore.getAndroidMotionPath(motion, motion.id),
|
||||||
mockReadTags({
|
deleteOnDisk: true,
|
||||||
Directory: 'foo/bar/',
|
},
|
||||||
MotionPhoto: 1,
|
{
|
||||||
MicroVideo: 1,
|
motion: 'an uploaded motion video',
|
||||||
MicroVideoOffset: 1,
|
getOriginalPath: (motion: ThumbnailPathEntity) =>
|
||||||
});
|
StorageCore.getNestedPath(StorageFolder.Upload, motion.ownerId, `${motion.id}.mov`),
|
||||||
mocks.crypto.hashSha1.mockReturnValue(randomBytes(512));
|
deleteOnDisk: false,
|
||||||
mocks.asset.create.mockResolvedValue(AssetFactory.create({ type: AssetType.Video }));
|
},
|
||||||
const video = randomBytes(512);
|
{ motion: 'an external motion video', getOriginalPath: () => '/mnt/photos/IMG_0001.mov', deleteOnDisk: false },
|
||||||
mocks.storage.readFile.mockResolvedValue(video);
|
{ motion: 'a missing motion video', getOriginalPath: undefined, deleteOnDisk: false },
|
||||||
|
])(
|
||||||
|
'should delete $motion that does not match what is extracted (deleteOnDisk: $deleteOnDisk)',
|
||||||
|
async ({ getOriginalPath, deleteOnDisk }) => {
|
||||||
|
const motionAsset = AssetFactory.create({ type: AssetType.Video, visibility: AssetVisibility.Hidden });
|
||||||
|
const asset = AssetFactory.create({ livePhotoVideoId: motionAsset.id });
|
||||||
|
const oldMotion =
|
||||||
|
getOriginalPath && getForAsset({ ...motionAsset, originalPath: getOriginalPath(motionAsset) });
|
||||||
|
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(getForMetadataExtraction(asset));
|
||||||
|
mocks.asset.getById.mockImplementation((id) => Promise.resolve(id === motionAsset.id ? oldMotion : undefined));
|
||||||
|
mockReadTags({
|
||||||
|
Directory: 'foo/bar/',
|
||||||
|
MotionPhoto: 1,
|
||||||
|
MicroVideo: 1,
|
||||||
|
MicroVideoOffset: 1,
|
||||||
|
});
|
||||||
|
mocks.crypto.hashSha1.mockReturnValue(randomBytes(512));
|
||||||
|
mocks.asset.create.mockResolvedValue(AssetFactory.create({ type: AssetType.Video }));
|
||||||
|
const video = randomBytes(512);
|
||||||
|
mocks.storage.readFile.mockResolvedValue(video);
|
||||||
|
|
||||||
await sut.handleMetadataExtraction({ id: asset.id });
|
await sut.handleMetadataExtraction({ id: asset.id });
|
||||||
expect(mocks.job.queue).toHaveBeenNthCalledWith(1, {
|
expect(mocks.job.queue).toHaveBeenNthCalledWith(1, {
|
||||||
name: JobName.AssetDelete,
|
name: JobName.AssetDelete,
|
||||||
data: { id: asset.livePhotoVideoId, deleteOnDisk: true },
|
data: { id: motionAsset.id, deleteOnDisk },
|
||||||
});
|
});
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|
||||||
it('should not create a new motion photo video asset if the hash of the extracted video matches an existing asset', async () => {
|
it('should not create a new motion photo video asset if the hash of the extracted video matches an existing asset', async () => {
|
||||||
const motionAsset = AssetFactory.create({ type: AssetType.Video, visibility: AssetVisibility.Hidden });
|
const motionAsset = AssetFactory.create({ type: AssetType.Video, visibility: AssetVisibility.Hidden });
|
||||||
|
|||||||
@@ -789,9 +789,12 @@ export class MetadataService extends BaseService {
|
|||||||
// (if it did, getByChecksum() would've returned a motionAsset with the same ID as livePhotoVideoId)
|
// (if it did, getByChecksum() would've returned a motionAsset with the same ID as livePhotoVideoId)
|
||||||
// note asset.livePhotoVideoId is not motionAsset.id yet
|
// note asset.livePhotoVideoId is not motionAsset.id yet
|
||||||
if (asset.livePhotoVideoId) {
|
if (asset.livePhotoVideoId) {
|
||||||
|
// the old motion may be the user's own file (linked by livePhotoCID or manually), only remove ones Immich extracted
|
||||||
|
const oldMotion = await this.assetRepository.getById(asset.livePhotoVideoId);
|
||||||
|
const deleteOnDisk = !!oldMotion && StorageCore.isAndroidMotionPath(oldMotion.originalPath);
|
||||||
await this.jobRepository.queue({
|
await this.jobRepository.queue({
|
||||||
name: JobName.AssetDelete,
|
name: JobName.AssetDelete,
|
||||||
data: { id: asset.livePhotoVideoId, deleteOnDisk: true },
|
data: { id: asset.livePhotoVideoId, deleteOnDisk },
|
||||||
});
|
});
|
||||||
this.logger.log(`Removed old motion photo video asset (${asset.livePhotoVideoId})`);
|
this.logger.log(`Removed old motion photo video asset (${asset.livePhotoVideoId})`);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user