From abfb6c0a0e61b488299b7cb67f806ba763335e45 Mon Sep 17 00:00:00 2001 From: Zack Pollard Date: Fri, 30 Oct 2026 15:31:03 +0000 Subject: [PATCH] fix(server): keep user motion files on embedded motion replace (#32079) (cherry picked from commit ddb799e0a44d112ecacc4867780f31bb8f71dc4f) --- server/src/services/metadata.service.spec.ts | 64 +++++++++++++------- server/src/services/metadata.service.ts | 5 +- 2 files changed, 47 insertions(+), 22 deletions(-) diff --git a/server/src/services/metadata.service.spec.ts b/server/src/services/metadata.service.spec.ts index 365ead9c4..97ab3300f 100644 --- a/server/src/services/metadata.service.spec.ts +++ b/server/src/services/metadata.service.spec.ts @@ -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 }); diff --git a/server/src/services/metadata.service.ts b/server/src/services/metadata.service.ts index a92a20616..334199415 100644 --- a/server/src/services/metadata.service.ts +++ b/server/src/services/metadata.service.ts @@ -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})`); }