mirror of
				https://github.com/immich-app/immich.git
				synced 2025-11-03 19:17:11 -05:00 
			
		
		
		
	fix(server): extraction of Samsung Motionphoto videos (#6337)
* Fix extraction of samsung motionphoto videos * Refactor binary tag extraction to the repository to consolidate exiftool usage * format * fix linting and swap argument orders * Fix tag name and conditional order * Add unit test * Update server test assets submodule * Remove old motion photo video assets when a new one is extracted * delete first, then write * Include motion photo asset uuid's in the filename If the filenames are not uniquified, then we can't delete old/corrupt ones * Fix formatting and fix/add tests * chore: only use new uuid --------- Co-authored-by: Jason Rasmussen <jrasm91@gmail.com>
This commit is contained in:
		
							parent
							
								
									7b314f9435
								
							
						
					
					
						commit
						a972dd4060
					
				@ -36,7 +36,7 @@ describe(`${AssetController.name} (e2e)`, () => {
 | 
				
			|||||||
    await restoreTempFolder();
 | 
					    await restoreTempFolder();
 | 
				
			||||||
  });
 | 
					  });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  describe.only('should strip metadata of', () => {
 | 
					  describe('should strip metadata of', () => {
 | 
				
			||||||
    let assetWithLocation: AssetResponseDto;
 | 
					    let assetWithLocation: AssetResponseDto;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    beforeEach(async () => {
 | 
					    beforeEach(async () => {
 | 
				
			||||||
@ -84,4 +84,26 @@ describe(`${AssetController.name} (e2e)`, () => {
 | 
				
			|||||||
      expect(exifData).not.toHaveProperty('GPSLatitude');
 | 
					      expect(exifData).not.toHaveProperty('GPSLatitude');
 | 
				
			||||||
    });
 | 
					    });
 | 
				
			||||||
  });
 | 
					  });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  describe.each([
 | 
				
			||||||
 | 
					    // These hashes were created by copying the image files to a Samsung phone,
 | 
				
			||||||
 | 
					    // exporting the video from Samsung's stock Gallery app, and hashing them locally.
 | 
				
			||||||
 | 
					    // This ensures that immich+exiftool are extracting the videos the same way Samsung does.
 | 
				
			||||||
 | 
					    // DO NOT assume immich+exiftool are doing things correctly and just copy whatever hash it gives
 | 
				
			||||||
 | 
					    // into the test here.
 | 
				
			||||||
 | 
					    ['Samsung One UI 5.jpg', 'fr14niqCq6N20HB8rJYEvpsUVtI='],
 | 
				
			||||||
 | 
					    ['Samsung One UI 6.jpg', 'lT9Uviw/FFJYCjfIxAGPTjzAmmw='],
 | 
				
			||||||
 | 
					    ['Samsung One UI 6.heic', '/ejgzywvgvzvVhUYVfvkLzFBAF0='],
 | 
				
			||||||
 | 
					  ])('should extract motionphoto video', (file, checksum) => {
 | 
				
			||||||
 | 
					    itif(runAllTests)(`with checksum ${checksum} from ${file}`, async () => {
 | 
				
			||||||
 | 
					      const fileContent = await fs.promises.readFile(`${IMMICH_TEST_ASSET_PATH}/formats/motionphoto/${file}`);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      const response = await api.assetApi.upload(server, admin.accessToken, 'test-asset-id', { content: fileContent });
 | 
				
			||||||
 | 
					      const asset = await api.assetApi.get(server, admin.accessToken, response.id);
 | 
				
			||||||
 | 
					      expect(asset).toHaveProperty('livePhotoVideoId');
 | 
				
			||||||
 | 
					      const video = await api.assetApi.get(server, admin.accessToken, asset.livePhotoVideoId as string);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      expect(video.checksum).toStrictEqual(checksum);
 | 
				
			||||||
 | 
					    });
 | 
				
			||||||
 | 
					  });
 | 
				
			||||||
});
 | 
					});
 | 
				
			||||||
 | 
				
			|||||||
@ -1,6 +1,7 @@
 | 
				
			|||||||
import { AssetType, ExifEntity, SystemConfigKey } from '@app/infra/entities';
 | 
					import { AssetType, ExifEntity, SystemConfigKey } from '@app/infra/entities';
 | 
				
			||||||
import {
 | 
					import {
 | 
				
			||||||
  assetStub,
 | 
					  assetStub,
 | 
				
			||||||
 | 
					  fileStub,
 | 
				
			||||||
  newAlbumRepositoryMock,
 | 
					  newAlbumRepositoryMock,
 | 
				
			||||||
  newAssetRepositoryMock,
 | 
					  newAssetRepositoryMock,
 | 
				
			||||||
  newCommunicationRepositoryMock,
 | 
					  newCommunicationRepositoryMock,
 | 
				
			||||||
@ -16,6 +17,7 @@ import {
 | 
				
			|||||||
  probeStub,
 | 
					  probeStub,
 | 
				
			||||||
} from '@test';
 | 
					} from '@test';
 | 
				
			||||||
import { randomBytes } from 'crypto';
 | 
					import { randomBytes } from 'crypto';
 | 
				
			||||||
 | 
					import { BinaryField } from 'exiftool-vendored';
 | 
				
			||||||
import { Stats } from 'fs';
 | 
					import { Stats } from 'fs';
 | 
				
			||||||
import { constants } from 'fs/promises';
 | 
					import { constants } from 'fs/promises';
 | 
				
			||||||
import { when } from 'jest-when';
 | 
					import { when } from 'jest-when';
 | 
				
			||||||
@ -343,7 +345,66 @@ describe(MetadataService.name, () => {
 | 
				
			|||||||
      );
 | 
					      );
 | 
				
			||||||
    });
 | 
					    });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    it('should apply motion photos', async () => {
 | 
					    it('should extract the MotionPhotoVideo tag from Samsung HEIC motion photos', async () => {
 | 
				
			||||||
 | 
					      assetMock.getByIds.mockResolvedValue([{ ...assetStub.livePhotoStillAsset, livePhotoVideoId: null }]);
 | 
				
			||||||
 | 
					      metadataMock.readTags.mockResolvedValue({
 | 
				
			||||||
 | 
					        Directory: 'foo/bar/',
 | 
				
			||||||
 | 
					        MotionPhotoVideo: new BinaryField(0, ''),
 | 
				
			||||||
 | 
					        // The below two are included to ensure that the MotionPhotoVideo tag is extracted
 | 
				
			||||||
 | 
					        // instead of the EmbeddedVideoFile, since HEIC MotionPhotos include both
 | 
				
			||||||
 | 
					        EmbeddedVideoFile: new BinaryField(0, ''),
 | 
				
			||||||
 | 
					        EmbeddedVideoType: 'MotionPhoto_Data',
 | 
				
			||||||
 | 
					      });
 | 
				
			||||||
 | 
					      cryptoRepository.hashSha1.mockReturnValue(randomBytes(512));
 | 
				
			||||||
 | 
					      assetMock.getByChecksum.mockResolvedValue(null);
 | 
				
			||||||
 | 
					      assetMock.create.mockResolvedValue(assetStub.livePhotoMotionAsset);
 | 
				
			||||||
 | 
					      cryptoRepository.randomUUID.mockReturnValue(fileStub.livePhotoMotion.uuid);
 | 
				
			||||||
 | 
					      const video = randomBytes(512);
 | 
				
			||||||
 | 
					      metadataMock.extractBinaryTag.mockResolvedValue(video);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      await sut.handleMetadataExtraction({ id: assetStub.livePhotoStillAsset.id });
 | 
				
			||||||
 | 
					      expect(metadataMock.extractBinaryTag).toHaveBeenCalledWith(
 | 
				
			||||||
 | 
					        assetStub.livePhotoStillAsset.originalPath,
 | 
				
			||||||
 | 
					        'MotionPhotoVideo',
 | 
				
			||||||
 | 
					      );
 | 
				
			||||||
 | 
					      expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.livePhotoStillAsset.id]);
 | 
				
			||||||
 | 
					      expect(assetMock.create).toHaveBeenCalled(); // This could have arguments added
 | 
				
			||||||
 | 
					      expect(storageMock.writeFile).toHaveBeenCalledWith(assetStub.livePhotoMotionAsset.originalPath, video);
 | 
				
			||||||
 | 
					      expect(assetMock.save).toHaveBeenNthCalledWith(1, {
 | 
				
			||||||
 | 
					        id: assetStub.livePhotoStillAsset.id,
 | 
				
			||||||
 | 
					        livePhotoVideoId: fileStub.livePhotoMotion.uuid,
 | 
				
			||||||
 | 
					      });
 | 
				
			||||||
 | 
					    });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    it('should extract the EmbeddedVideo tag from Samsung JPEG motion photos', async () => {
 | 
				
			||||||
 | 
					      assetMock.getByIds.mockResolvedValue([{ ...assetStub.livePhotoStillAsset, livePhotoVideoId: null }]);
 | 
				
			||||||
 | 
					      metadataMock.readTags.mockResolvedValue({
 | 
				
			||||||
 | 
					        Directory: 'foo/bar/',
 | 
				
			||||||
 | 
					        EmbeddedVideoFile: new BinaryField(0, ''),
 | 
				
			||||||
 | 
					        EmbeddedVideoType: 'MotionPhoto_Data',
 | 
				
			||||||
 | 
					      });
 | 
				
			||||||
 | 
					      cryptoRepository.hashSha1.mockReturnValue(randomBytes(512));
 | 
				
			||||||
 | 
					      assetMock.getByChecksum.mockResolvedValue(null);
 | 
				
			||||||
 | 
					      assetMock.create.mockResolvedValue(assetStub.livePhotoMotionAsset);
 | 
				
			||||||
 | 
					      cryptoRepository.randomUUID.mockReturnValue(fileStub.livePhotoMotion.uuid);
 | 
				
			||||||
 | 
					      const video = randomBytes(512);
 | 
				
			||||||
 | 
					      metadataMock.extractBinaryTag.mockResolvedValue(video);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      await sut.handleMetadataExtraction({ id: assetStub.livePhotoStillAsset.id });
 | 
				
			||||||
 | 
					      expect(metadataMock.extractBinaryTag).toHaveBeenCalledWith(
 | 
				
			||||||
 | 
					        assetStub.livePhotoStillAsset.originalPath,
 | 
				
			||||||
 | 
					        'EmbeddedVideoFile',
 | 
				
			||||||
 | 
					      );
 | 
				
			||||||
 | 
					      expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.livePhotoStillAsset.id]);
 | 
				
			||||||
 | 
					      expect(assetMock.create).toHaveBeenCalled(); // This could have arguments added
 | 
				
			||||||
 | 
					      expect(storageMock.writeFile).toHaveBeenCalledWith(assetStub.livePhotoMotionAsset.originalPath, video);
 | 
				
			||||||
 | 
					      expect(assetMock.save).toHaveBeenNthCalledWith(1, {
 | 
				
			||||||
 | 
					        id: assetStub.livePhotoStillAsset.id,
 | 
				
			||||||
 | 
					        livePhotoVideoId: fileStub.livePhotoMotion.uuid,
 | 
				
			||||||
 | 
					      });
 | 
				
			||||||
 | 
					    });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    it('should extract the motion photo video from the XMP directory entry ', async () => {
 | 
				
			||||||
      assetMock.getByIds.mockResolvedValue([{ ...assetStub.livePhotoStillAsset, livePhotoVideoId: null }]);
 | 
					      assetMock.getByIds.mockResolvedValue([{ ...assetStub.livePhotoStillAsset, livePhotoVideoId: null }]);
 | 
				
			||||||
      metadataMock.readTags.mockResolvedValue({
 | 
					      metadataMock.readTags.mockResolvedValue({
 | 
				
			||||||
        Directory: 'foo/bar/',
 | 
					        Directory: 'foo/bar/',
 | 
				
			||||||
@ -351,53 +412,60 @@ describe(MetadataService.name, () => {
 | 
				
			|||||||
        MicroVideo: 1,
 | 
					        MicroVideo: 1,
 | 
				
			||||||
        MicroVideoOffset: 1,
 | 
					        MicroVideoOffset: 1,
 | 
				
			||||||
      });
 | 
					      });
 | 
				
			||||||
      storageMock.readFile.mockResolvedValue(randomBytes(512));
 | 
					      cryptoRepository.hashSha1.mockReturnValue(randomBytes(512));
 | 
				
			||||||
 | 
					      assetMock.getByChecksum.mockResolvedValue(null);
 | 
				
			||||||
 | 
					      assetMock.create.mockResolvedValue(assetStub.livePhotoMotionAsset);
 | 
				
			||||||
 | 
					      cryptoRepository.randomUUID.mockReturnValue(fileStub.livePhotoMotion.uuid);
 | 
				
			||||||
 | 
					      const video = randomBytes(512);
 | 
				
			||||||
 | 
					      storageMock.readFile.mockResolvedValue(video);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      await sut.handleMetadataExtraction({ id: assetStub.livePhotoStillAsset.id });
 | 
				
			||||||
 | 
					      expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.livePhotoStillAsset.id]);
 | 
				
			||||||
 | 
					      expect(storageMock.readFile).toHaveBeenCalledWith(assetStub.livePhotoStillAsset.originalPath, expect.any(Object));
 | 
				
			||||||
 | 
					      expect(assetMock.create).toHaveBeenCalled(); // This could have arguments added
 | 
				
			||||||
 | 
					      expect(storageMock.writeFile).toHaveBeenCalledWith(assetStub.livePhotoMotionAsset.originalPath, video);
 | 
				
			||||||
 | 
					      expect(assetMock.save).toHaveBeenNthCalledWith(1, {
 | 
				
			||||||
 | 
					        id: assetStub.livePhotoStillAsset.id,
 | 
				
			||||||
 | 
					        livePhotoVideoId: fileStub.livePhotoMotion.uuid,
 | 
				
			||||||
 | 
					      });
 | 
				
			||||||
 | 
					    });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    it('should delete old motion photo video assets if they do not match what is extracted', async () => {
 | 
				
			||||||
 | 
					      assetMock.getByIds.mockResolvedValue([assetStub.livePhotoStillAsset]);
 | 
				
			||||||
 | 
					      metadataMock.readTags.mockResolvedValue({
 | 
				
			||||||
 | 
					        Directory: 'foo/bar/',
 | 
				
			||||||
 | 
					        MotionPhoto: 1,
 | 
				
			||||||
 | 
					        MicroVideo: 1,
 | 
				
			||||||
 | 
					        MicroVideoOffset: 1,
 | 
				
			||||||
 | 
					      });
 | 
				
			||||||
 | 
					      cryptoRepository.hashSha1.mockReturnValue(randomBytes(512));
 | 
				
			||||||
 | 
					      assetMock.getByChecksum.mockResolvedValue(null);
 | 
				
			||||||
 | 
					      assetMock.create.mockResolvedValue(assetStub.livePhotoMotionAsset);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      await sut.handleMetadataExtraction({ id: assetStub.livePhotoStillAsset.id });
 | 
				
			||||||
 | 
					      expect(jobMock.queue).toHaveBeenNthCalledWith(2, {
 | 
				
			||||||
 | 
					        name: JobName.ASSET_DELETION,
 | 
				
			||||||
 | 
					        data: { id: assetStub.livePhotoStillAsset.livePhotoVideoId },
 | 
				
			||||||
 | 
					      });
 | 
				
			||||||
 | 
					    });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    it('should not create a new motionphoto video asset if the of the extracted video matches an existing asset', async () => {
 | 
				
			||||||
 | 
					      assetMock.getByIds.mockResolvedValue([assetStub.livePhotoStillAsset]);
 | 
				
			||||||
 | 
					      metadataMock.readTags.mockResolvedValue({
 | 
				
			||||||
 | 
					        Directory: 'foo/bar/',
 | 
				
			||||||
 | 
					        MotionPhoto: 1,
 | 
				
			||||||
 | 
					        MicroVideo: 1,
 | 
				
			||||||
 | 
					        MicroVideoOffset: 1,
 | 
				
			||||||
 | 
					      });
 | 
				
			||||||
      cryptoRepository.hashSha1.mockReturnValue(randomBytes(512));
 | 
					      cryptoRepository.hashSha1.mockReturnValue(randomBytes(512));
 | 
				
			||||||
      assetMock.getByChecksum.mockResolvedValue(assetStub.livePhotoMotionAsset);
 | 
					      assetMock.getByChecksum.mockResolvedValue(assetStub.livePhotoMotionAsset);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      await sut.handleMetadataExtraction({ id: assetStub.livePhotoStillAsset.id });
 | 
					      await sut.handleMetadataExtraction({ id: assetStub.livePhotoStillAsset.id });
 | 
				
			||||||
      expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.livePhotoStillAsset.id]);
 | 
					      expect(assetMock.create).toHaveBeenCalledTimes(0);
 | 
				
			||||||
      expect(storageMock.readFile).toHaveBeenCalledWith(assetStub.livePhotoStillAsset.originalPath, expect.any(Object));
 | 
					      expect(storageMock.writeFile).toHaveBeenCalledTimes(0);
 | 
				
			||||||
      expect(assetMock.save).toHaveBeenCalledWith({
 | 
					      // The still asset gets saved by handleMetadataExtraction, but not the video
 | 
				
			||||||
        id: assetStub.livePhotoStillAsset.id,
 | 
					      expect(assetMock.save).toHaveBeenCalledTimes(1);
 | 
				
			||||||
        livePhotoVideoId: assetStub.livePhotoMotionAsset.id,
 | 
					      expect(jobMock.queue).toHaveBeenCalledTimes(0);
 | 
				
			||||||
      });
 | 
					 | 
				
			||||||
    });
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    it('should create new motion asset if not found and link it with the photo', async () => {
 | 
					 | 
				
			||||||
      assetMock.getByIds.mockResolvedValue([{ ...assetStub.livePhotoStillAsset, livePhotoVideoId: null }]);
 | 
					 | 
				
			||||||
      metadataMock.readTags.mockResolvedValue({
 | 
					 | 
				
			||||||
        Directory: 'foo/bar/',
 | 
					 | 
				
			||||||
        MotionPhoto: 1,
 | 
					 | 
				
			||||||
        MicroVideo: 1,
 | 
					 | 
				
			||||||
        MicroVideoOffset: 1,
 | 
					 | 
				
			||||||
      });
 | 
					 | 
				
			||||||
      const video = randomBytes(512);
 | 
					 | 
				
			||||||
      storageMock.readFile.mockResolvedValue(video);
 | 
					 | 
				
			||||||
      cryptoRepository.hashSha1.mockReturnValue(randomBytes(512));
 | 
					 | 
				
			||||||
      assetMock.create.mockResolvedValueOnce(assetStub.livePhotoMotionAsset);
 | 
					 | 
				
			||||||
      assetMock.save.mockResolvedValueOnce(assetStub.livePhotoMotionAsset);
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
      await sut.handleMetadataExtraction({ id: assetStub.livePhotoStillAsset.id });
 | 
					 | 
				
			||||||
      expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.livePhotoStillAsset.id]);
 | 
					 | 
				
			||||||
      expect(storageMock.readFile).toHaveBeenCalledWith(assetStub.livePhotoStillAsset.originalPath, expect.any(Object));
 | 
					 | 
				
			||||||
      expect(assetMock.create).toHaveBeenCalledWith(
 | 
					 | 
				
			||||||
        expect.objectContaining({
 | 
					 | 
				
			||||||
          type: AssetType.VIDEO,
 | 
					 | 
				
			||||||
          originalFileName: assetStub.livePhotoStillAsset.originalFileName,
 | 
					 | 
				
			||||||
          isVisible: false,
 | 
					 | 
				
			||||||
          isReadOnly: false,
 | 
					 | 
				
			||||||
        }),
 | 
					 | 
				
			||||||
      );
 | 
					 | 
				
			||||||
      expect(assetMock.save).toHaveBeenCalledWith({
 | 
					 | 
				
			||||||
        id: assetStub.livePhotoStillAsset.id,
 | 
					 | 
				
			||||||
        livePhotoVideoId: assetStub.livePhotoMotionAsset.id,
 | 
					 | 
				
			||||||
      });
 | 
					 | 
				
			||||||
      expect(storageMock.writeFile).toHaveBeenCalledWith(assetStub.livePhotoMotionAsset.originalPath, video);
 | 
					 | 
				
			||||||
      expect(jobMock.queue).toHaveBeenCalledWith({
 | 
					 | 
				
			||||||
        name: JobName.METADATA_EXTRACTION,
 | 
					 | 
				
			||||||
        data: { id: assetStub.livePhotoMotionAsset.id },
 | 
					 | 
				
			||||||
      });
 | 
					 | 
				
			||||||
    });
 | 
					    });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    it('should save all metadata', async () => {
 | 
					    it('should save all metadata', async () => {
 | 
				
			||||||
 | 
				
			|||||||
@ -354,7 +354,7 @@ export class MetadataService {
 | 
				
			|||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  private async applyMotionPhotos(asset: AssetEntity, tags: ImmichTags) {
 | 
					  private async applyMotionPhotos(asset: AssetEntity, tags: ImmichTags) {
 | 
				
			||||||
    if (asset.type !== AssetType.IMAGE || asset.livePhotoVideoId) {
 | 
					    if (asset.type !== AssetType.IMAGE) {
 | 
				
			||||||
      return;
 | 
					      return;
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@ -362,6 +362,8 @@ export class MetadataService {
 | 
				
			|||||||
    const isMotionPhoto = tags.MotionPhoto;
 | 
					    const isMotionPhoto = tags.MotionPhoto;
 | 
				
			||||||
    const isMicroVideo = tags.MicroVideo;
 | 
					    const isMicroVideo = tags.MicroVideo;
 | 
				
			||||||
    const videoOffset = tags.MicroVideoOffset;
 | 
					    const videoOffset = tags.MicroVideoOffset;
 | 
				
			||||||
 | 
					    const hasMotionPhotoVideo = tags.MotionPhotoVideo;
 | 
				
			||||||
 | 
					    const hasEmbeddedVideoFile = tags.EmbeddedVideoType === 'MotionPhoto_Data' && tags.EmbeddedVideoFile;
 | 
				
			||||||
    const directory = Array.isArray(rawDirectory) ? (rawDirectory as DirectoryEntry[]) : null;
 | 
					    const directory = Array.isArray(rawDirectory) ? (rawDirectory as DirectoryEntry[]) : null;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    let length = 0;
 | 
					    let length = 0;
 | 
				
			||||||
@ -381,7 +383,7 @@ export class MetadataService {
 | 
				
			|||||||
      length = videoOffset;
 | 
					      length = videoOffset;
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    if (!length) {
 | 
					    if (!length && !hasEmbeddedVideoFile && !hasMotionPhotoVideo) {
 | 
				
			||||||
      return;
 | 
					      return;
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@ -390,20 +392,35 @@ export class MetadataService {
 | 
				
			|||||||
    try {
 | 
					    try {
 | 
				
			||||||
      const stat = await this.storageRepository.stat(asset.originalPath);
 | 
					      const stat = await this.storageRepository.stat(asset.originalPath);
 | 
				
			||||||
      const position = stat.size - length - padding;
 | 
					      const position = stat.size - length - padding;
 | 
				
			||||||
      const video = await this.storageRepository.readFile(asset.originalPath, {
 | 
					      let video: Buffer;
 | 
				
			||||||
 | 
					      // Samsung MotionPhoto video extraction
 | 
				
			||||||
 | 
					      //     HEIC-encoded
 | 
				
			||||||
 | 
					      if (hasMotionPhotoVideo) {
 | 
				
			||||||
 | 
					        video = await this.repository.extractBinaryTag(asset.originalPath, 'MotionPhotoVideo');
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					      //     JPEG-encoded; HEIC also contains these tags, so this conditional must come second
 | 
				
			||||||
 | 
					      else if (hasEmbeddedVideoFile) {
 | 
				
			||||||
 | 
					        video = await this.repository.extractBinaryTag(asset.originalPath, 'EmbeddedVideoFile');
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					      // Default video extraction
 | 
				
			||||||
 | 
					      else {
 | 
				
			||||||
 | 
					        video = await this.storageRepository.readFile(asset.originalPath, {
 | 
				
			||||||
          buffer: Buffer.alloc(length),
 | 
					          buffer: Buffer.alloc(length),
 | 
				
			||||||
          position,
 | 
					          position,
 | 
				
			||||||
          length,
 | 
					          length,
 | 
				
			||||||
        });
 | 
					        });
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
      const checksum = this.cryptoRepository.hashSha1(video);
 | 
					      const checksum = this.cryptoRepository.hashSha1(video);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      const motionPath = StorageCore.getAndroidMotionPath(asset);
 | 
					 | 
				
			||||||
      this.storageCore.ensureFolders(motionPath);
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
      let motionAsset = await this.assetRepository.getByChecksum(asset.ownerId, checksum);
 | 
					      let motionAsset = await this.assetRepository.getByChecksum(asset.ownerId, checksum);
 | 
				
			||||||
      if (!motionAsset) {
 | 
					      if (!motionAsset) {
 | 
				
			||||||
 | 
					        // We create a UUID in advance so that each extracted video can have a unique filename
 | 
				
			||||||
 | 
					        // (allowing us to delete old ones if necessary)
 | 
				
			||||||
 | 
					        const motionAssetId = this.cryptoRepository.randomUUID();
 | 
				
			||||||
 | 
					        const motionPath = StorageCore.getAndroidMotionPath(asset, motionAssetId);
 | 
				
			||||||
        const createdAt = asset.fileCreatedAt ?? asset.createdAt;
 | 
					        const createdAt = asset.fileCreatedAt ?? asset.createdAt;
 | 
				
			||||||
        motionAsset = await this.assetRepository.create({
 | 
					        motionAsset = await this.assetRepository.create({
 | 
				
			||||||
 | 
					          id: motionAssetId,
 | 
				
			||||||
          libraryId: asset.libraryId,
 | 
					          libraryId: asset.libraryId,
 | 
				
			||||||
          type: AssetType.VIDEO,
 | 
					          type: AssetType.VIDEO,
 | 
				
			||||||
          fileCreatedAt: createdAt,
 | 
					          fileCreatedAt: createdAt,
 | 
				
			||||||
@ -419,12 +436,26 @@ export class MetadataService {
 | 
				
			|||||||
          deviceId: 'NONE',
 | 
					          deviceId: 'NONE',
 | 
				
			||||||
        });
 | 
					        });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        this.storageCore.ensureFolders(motionPath);
 | 
				
			||||||
        await this.storageRepository.writeFile(motionAsset.originalPath, video);
 | 
					        await this.storageRepository.writeFile(motionAsset.originalPath, video);
 | 
				
			||||||
        await this.jobRepository.queue({ name: JobName.METADATA_EXTRACTION, data: { id: motionAsset.id } });
 | 
					        await this.jobRepository.queue({ name: JobName.METADATA_EXTRACTION, data: { id: motionAsset.id } });
 | 
				
			||||||
      }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        await this.assetRepository.save({ id: asset.id, livePhotoVideoId: motionAsset.id });
 | 
					        await this.assetRepository.save({ id: asset.id, livePhotoVideoId: motionAsset.id });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        // If the asset already had an associated livePhotoVideo, delete it, because
 | 
				
			||||||
 | 
					        // its checksum doesn't match the checksum of the motionAsset we just extracted
 | 
				
			||||||
 | 
					        // (if it did, getByChecksum() would've returned non-null)
 | 
				
			||||||
 | 
					        if (asset.livePhotoVideoId) {
 | 
				
			||||||
 | 
					          await this.jobRepository.queue({ name: JobName.ASSET_DELETION, data: { id: asset.livePhotoVideoId } });
 | 
				
			||||||
 | 
					          this.logger.log(`Removed old motion photo video asset (${asset.livePhotoVideoId})`);
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					      } else {
 | 
				
			||||||
 | 
					        this.logger.debug(
 | 
				
			||||||
 | 
					          `Asset ${asset.id}'s motion photo video with checksum ${checksum.toString(
 | 
				
			||||||
 | 
					            'base64',
 | 
				
			||||||
 | 
					          )} already exists in the repository`,
 | 
				
			||||||
 | 
					        );
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      this.logger.debug(`Finished motion photo video extraction (${asset.id})`);
 | 
					      this.logger.debug(`Finished motion photo video extraction (${asset.id})`);
 | 
				
			||||||
    } catch (error: Error | any) {
 | 
					    } catch (error: Error | any) {
 | 
				
			||||||
      this.logger.error(`Failed to extract live photo ${asset.originalPath}: ${error}`, error?.stack);
 | 
					      this.logger.error(`Failed to extract live photo ${asset.originalPath}: ${error}`, error?.stack);
 | 
				
			||||||
 | 
				
			|||||||
@ -1,4 +1,4 @@
 | 
				
			|||||||
import { Tags } from 'exiftool-vendored';
 | 
					import { BinaryField, Tags } from 'exiftool-vendored';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export const IMetadataRepository = 'IMetadataRepository';
 | 
					export const IMetadataRepository = 'IMetadataRepository';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@ -27,6 +27,9 @@ export interface ImmichTags extends Omit<Tags, 'FocalLength' | 'Duration'> {
 | 
				
			|||||||
  ImagePixelDepth?: string;
 | 
					  ImagePixelDepth?: string;
 | 
				
			||||||
  FocalLength?: number;
 | 
					  FocalLength?: number;
 | 
				
			||||||
  Duration?: number | ExifDuration;
 | 
					  Duration?: number | ExifDuration;
 | 
				
			||||||
 | 
					  EmbeddedVideoType?: string;
 | 
				
			||||||
 | 
					  EmbeddedVideoFile?: BinaryField;
 | 
				
			||||||
 | 
					  MotionPhotoVideo?: BinaryField;
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export interface IMetadataRepository {
 | 
					export interface IMetadataRepository {
 | 
				
			||||||
@ -35,4 +38,5 @@ export interface IMetadataRepository {
 | 
				
			|||||||
  reverseGeocode(point: GeoPoint): Promise<ReverseGeocodeResult | null>;
 | 
					  reverseGeocode(point: GeoPoint): Promise<ReverseGeocodeResult | null>;
 | 
				
			||||||
  readTags(path: string): Promise<ImmichTags | null>;
 | 
					  readTags(path: string): Promise<ImmichTags | null>;
 | 
				
			||||||
  writeTags(path: string, tags: Partial<Tags>): Promise<void>;
 | 
					  writeTags(path: string, tags: Partial<Tags>): Promise<void>;
 | 
				
			||||||
 | 
					  extractBinaryTag(tagName: string, path: string): Promise<Buffer>;
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
				
			|||||||
@ -103,8 +103,8 @@ export class StorageCore {
 | 
				
			|||||||
    return StorageCore.getNestedPath(StorageFolder.ENCODED_VIDEO, asset.ownerId, `${asset.id}.mp4`);
 | 
					    return StorageCore.getNestedPath(StorageFolder.ENCODED_VIDEO, asset.ownerId, `${asset.id}.mp4`);
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  static getAndroidMotionPath(asset: AssetEntity) {
 | 
					  static getAndroidMotionPath(asset: AssetEntity, uuid: string) {
 | 
				
			||||||
    return StorageCore.getNestedPath(StorageFolder.ENCODED_VIDEO, asset.ownerId, `${asset.id}-MP.mp4`);
 | 
					    return StorageCore.getNestedPath(StorageFolder.ENCODED_VIDEO, asset.ownerId, `${uuid}-MP.mp4`);
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  static isAndroidMotionPath(originalPath: string) {
 | 
					  static isAndroidMotionPath(originalPath: string) {
 | 
				
			||||||
 | 
				
			|||||||
@ -201,6 +201,10 @@ export class MetadataRepository implements IMetadataRepository {
 | 
				
			|||||||
      }) as Promise<ImmichTags | null>;
 | 
					      }) as Promise<ImmichTags | null>;
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  extractBinaryTag(path: string, tagName: string): Promise<Buffer> {
 | 
				
			||||||
 | 
					    return exiftool.extractBinaryTagToBuffer(tagName, path);
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  async writeTags(path: string, tags: Partial<Tags>): Promise<void> {
 | 
					  async writeTags(path: string, tags: Partial<Tags>): Promise<void> {
 | 
				
			||||||
    try {
 | 
					    try {
 | 
				
			||||||
      await exiftool.write(path, tags, ['-overwrite_original']);
 | 
					      await exiftool.write(path, tags, ['-overwrite_original']);
 | 
				
			||||||
 | 
				
			|||||||
@ -1 +1 @@
 | 
				
			|||||||
Subproject commit 948f353e3c9b66156c86c86cf078e0746ec1598e
 | 
					Subproject commit 61131e84ec91d316265aebe375b3155308baaa89
 | 
				
			||||||
							
								
								
									
										2
									
								
								server/test/fixtures/asset.stub.ts
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										2
									
								
								server/test/fixtures/asset.stub.ts
									
									
									
									
										vendored
									
									
								
							@ -401,7 +401,7 @@ export const assetStub = {
 | 
				
			|||||||
  }),
 | 
					  }),
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  livePhotoMotionAsset: Object.freeze({
 | 
					  livePhotoMotionAsset: Object.freeze({
 | 
				
			||||||
    id: 'live-photo-motion-asset',
 | 
					    id: fileStub.livePhotoMotion.uuid,
 | 
				
			||||||
    originalPath: fileStub.livePhotoMotion.originalPath,
 | 
					    originalPath: fileStub.livePhotoMotion.originalPath,
 | 
				
			||||||
    ownerId: authStub.user1.user.id,
 | 
					    ownerId: authStub.user1.user.id,
 | 
				
			||||||
    type: AssetType.VIDEO,
 | 
					    type: AssetType.VIDEO,
 | 
				
			||||||
 | 
				
			|||||||
							
								
								
									
										2
									
								
								server/test/fixtures/file.stub.ts
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										2
									
								
								server/test/fixtures/file.stub.ts
									
									
									
									
										vendored
									
									
								
							@ -7,7 +7,7 @@ export const fileStub = {
 | 
				
			|||||||
    size: 42,
 | 
					    size: 42,
 | 
				
			||||||
  }),
 | 
					  }),
 | 
				
			||||||
  livePhotoMotion: Object.freeze({
 | 
					  livePhotoMotion: Object.freeze({
 | 
				
			||||||
    uuid: 'random-uuid',
 | 
					    uuid: 'live-photo-motion-asset',
 | 
				
			||||||
    originalPath: 'fake_path/asset_1.mp4',
 | 
					    originalPath: 'fake_path/asset_1.mp4',
 | 
				
			||||||
    checksum: Buffer.from('live photo file hash', 'utf8'),
 | 
					    checksum: Buffer.from('live photo file hash', 'utf8'),
 | 
				
			||||||
    originalName: 'asset_1.mp4',
 | 
					    originalName: 'asset_1.mp4',
 | 
				
			||||||
 | 
				
			|||||||
@ -7,5 +7,6 @@ export const newMetadataRepositoryMock = (): jest.Mocked<IMetadataRepository> =>
 | 
				
			|||||||
    reverseGeocode: jest.fn(),
 | 
					    reverseGeocode: jest.fn(),
 | 
				
			||||||
    readTags: jest.fn(),
 | 
					    readTags: jest.fn(),
 | 
				
			||||||
    writeTags: jest.fn(),
 | 
					    writeTags: jest.fn(),
 | 
				
			||||||
 | 
					    extractBinaryTag: jest.fn(),
 | 
				
			||||||
  };
 | 
					  };
 | 
				
			||||||
};
 | 
					};
 | 
				
			||||||
 | 
				
			|||||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user