fix(server): keep user motion files on embedded motion replace (#32079)

(cherry picked from commit ddb799e0a44d112ecacc4867780f31bb8f71dc4f)
This commit is contained in:
Zack Pollard
2026-10-31 07:22:45 +03:00
committed by Codex
parent 91e416b104
commit abfb6c0a0e
2 changed files with 47 additions and 22 deletions
+43 -21
View File
@@ -2,6 +2,7 @@ import { BinaryField, ExifDateTime } from 'exiftool-vendored';
import { DateTime } from 'luxon';
import { randomBytes } from 'node:crypto';
import { Stats } from 'node:fs';
import { StorageCore, ThumbnailPathEntity } from 'src/cores/storage.core.js';
import { defaults } from 'src/dtos/config.dto.js';
import {
AssetFileType,
@@ -13,6 +14,7 @@ import {
JobName,
JobStatus,
SourceType,
StorageFolder,
} from 'src/enum.js';
import { ImmichTags } from 'src/repositories/metadata.repository.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 { videoInfoStub } from 'test/fixtures/media.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 { 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 () => {
const motionAsset = AssetFactory.create({ type: AssetType.Video, visibility: AssetVisibility.Hidden });
const asset = AssetFactory.create({ livePhotoVideoId: motionAsset.id });
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(getForMetadataExtraction(asset));
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);
it.each([
{
motion: 'an extracted motion video',
getOriginalPath: (motion: ThumbnailPathEntity) => StorageCore.getAndroidMotionPath(motion, motion.id),
deleteOnDisk: true,
},
{
motion: 'an uploaded motion video',
getOriginalPath: (motion: ThumbnailPathEntity) =>
StorageCore.getNestedPath(StorageFolder.Upload, motion.ownerId, `${motion.id}.mov`),
deleteOnDisk: false,
},
{ motion: 'an external motion video', getOriginalPath: () => '/mnt/photos/IMG_0001.mov', deleteOnDisk: false },
{ 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 });
expect(mocks.job.queue).toHaveBeenNthCalledWith(1, {
name: JobName.AssetDelete,
data: { id: asset.livePhotoVideoId, deleteOnDisk: true },
});
});
await sut.handleMetadataExtraction({ id: asset.id });
expect(mocks.job.queue).toHaveBeenNthCalledWith(1, {
name: JobName.AssetDelete,
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 () => {
const motionAsset = AssetFactory.create({ type: AssetType.Video, visibility: AssetVisibility.Hidden });
+4 -1
View File
@@ -789,9 +789,12 @@ export class MetadataService extends BaseService {
// (if it did, getByChecksum() would've returned a motionAsset with the same ID as livePhotoVideoId)
// note asset.livePhotoVideoId is not motionAsset.id yet
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({
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})`);
}