mirror of
				https://github.com/immich-app/immich.git
				synced 2025-11-04 03:39:37 -05:00 
			
		
		
		
	feat(server): auto-link live photos (#1761)
* feat(server): auto-link live photos * fix: video extraction and linking
This commit is contained in:
		
							parent
							
								
									7a25d359b7
								
							
						
					
					
						commit
						36197cca98
					
				@ -1,4 +1,4 @@
 | 
				
			|||||||
import { AssetEntity, ExifEntity } from '@app/infra';
 | 
					import { AssetEntity, AssetType, ExifEntity } from '@app/infra';
 | 
				
			||||||
import {
 | 
					import {
 | 
				
			||||||
  IExifExtractionProcessor,
 | 
					  IExifExtractionProcessor,
 | 
				
			||||||
  IReverseGeocodingProcessor,
 | 
					  IReverseGeocodingProcessor,
 | 
				
			||||||
@ -18,7 +18,12 @@ import { Repository } from 'typeorm/repository/Repository';
 | 
				
			|||||||
import geocoder, { InitOptions } from 'local-reverse-geocoder';
 | 
					import geocoder, { InitOptions } from 'local-reverse-geocoder';
 | 
				
			||||||
import { getName } from 'i18n-iso-countries';
 | 
					import { getName } from 'i18n-iso-countries';
 | 
				
			||||||
import fs from 'node:fs';
 | 
					import fs from 'node:fs';
 | 
				
			||||||
import { ExifDateTime, exiftool } from 'exiftool-vendored';
 | 
					import { ExifDateTime, exiftool, Tags } from 'exiftool-vendored';
 | 
				
			||||||
 | 
					import { IsNull, Not } from 'typeorm';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					interface ImmichTags extends Tags {
 | 
				
			||||||
 | 
					  ContentIdentifier?: string;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
function geocoderInit(init: InitOptions) {
 | 
					function geocoderInit(init: InitOptions) {
 | 
				
			||||||
  return new Promise<void>(function (resolve) {
 | 
					  return new Promise<void>(function (resolve) {
 | 
				
			||||||
@ -139,7 +144,7 @@ export class MetadataExtractionProcessor {
 | 
				
			|||||||
  async extractExifInfo(job: Job<IExifExtractionProcessor>) {
 | 
					  async extractExifInfo(job: Job<IExifExtractionProcessor>) {
 | 
				
			||||||
    try {
 | 
					    try {
 | 
				
			||||||
      const { asset, fileName }: { asset: AssetEntity; fileName: string } = job.data;
 | 
					      const { asset, fileName }: { asset: AssetEntity; fileName: string } = job.data;
 | 
				
			||||||
      const exifData = await exiftool.read(asset.originalPath).catch((e) => {
 | 
					      const exifData = await exiftool.read<ImmichTags>(asset.originalPath).catch((e) => {
 | 
				
			||||||
        this.logger.warn(`The exifData parsing failed due to: ${e} on file ${asset.originalPath}`);
 | 
					        this.logger.warn(`The exifData parsing failed due to: ${e} on file ${asset.originalPath}`);
 | 
				
			||||||
        return null;
 | 
					        return null;
 | 
				
			||||||
      });
 | 
					      });
 | 
				
			||||||
@ -177,12 +182,33 @@ export class MetadataExtractionProcessor {
 | 
				
			|||||||
      newExif.iso = exifData?.ISO || null;
 | 
					      newExif.iso = exifData?.ISO || null;
 | 
				
			||||||
      newExif.latitude = exifData?.GPSLatitude || null;
 | 
					      newExif.latitude = exifData?.GPSLatitude || null;
 | 
				
			||||||
      newExif.longitude = exifData?.GPSLongitude || null;
 | 
					      newExif.longitude = exifData?.GPSLongitude || null;
 | 
				
			||||||
 | 
					      newExif.livePhotoCID = exifData?.MediaGroupUUID || null;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      await this.assetRepository.save({
 | 
					      await this.assetRepository.save({
 | 
				
			||||||
        id: asset.id,
 | 
					        id: asset.id,
 | 
				
			||||||
        createdAt: createdAt?.toISOString(),
 | 
					        createdAt: createdAt?.toISOString(),
 | 
				
			||||||
      });
 | 
					      });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      if (newExif.livePhotoCID && !asset.livePhotoVideoId) {
 | 
				
			||||||
 | 
					        const motionAsset = await this.assetRepository.findOne({
 | 
				
			||||||
 | 
					          where: {
 | 
				
			||||||
 | 
					            id: Not(asset.id),
 | 
				
			||||||
 | 
					            type: AssetType.VIDEO,
 | 
				
			||||||
 | 
					            exifInfo: {
 | 
				
			||||||
 | 
					              livePhotoCID: newExif.livePhotoCID,
 | 
				
			||||||
 | 
					            },
 | 
				
			||||||
 | 
					          },
 | 
				
			||||||
 | 
					          relations: {
 | 
				
			||||||
 | 
					            exifInfo: true,
 | 
				
			||||||
 | 
					          },
 | 
				
			||||||
 | 
					        });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        if (motionAsset) {
 | 
				
			||||||
 | 
					          await this.assetRepository.update(asset.id, { livePhotoVideoId: motionAsset.id });
 | 
				
			||||||
 | 
					          await this.assetRepository.update(motionAsset.id, { isVisible: false });
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      /**
 | 
					      /**
 | 
				
			||||||
       * Reverse Geocoding
 | 
					       * Reverse Geocoding
 | 
				
			||||||
       *
 | 
					       *
 | 
				
			||||||
@ -266,6 +292,11 @@ export class MetadataExtractionProcessor {
 | 
				
			|||||||
        createdAt = asset.createdAt;
 | 
					        createdAt = asset.createdAt;
 | 
				
			||||||
      }
 | 
					      }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      const exifData = await exiftool.read<ImmichTags>(asset.originalPath).catch((e) => {
 | 
				
			||||||
 | 
					        this.logger.warn(`The exifData parsing failed due to: ${e} on file ${asset.originalPath}`);
 | 
				
			||||||
 | 
					        return null;
 | 
				
			||||||
 | 
					      });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      const newExif = new ExifEntity();
 | 
					      const newExif = new ExifEntity();
 | 
				
			||||||
      newExif.assetId = asset.id;
 | 
					      newExif.assetId = asset.id;
 | 
				
			||||||
      newExif.description = '';
 | 
					      newExif.description = '';
 | 
				
			||||||
@ -279,6 +310,25 @@ export class MetadataExtractionProcessor {
 | 
				
			|||||||
      newExif.state = null;
 | 
					      newExif.state = null;
 | 
				
			||||||
      newExif.country = null;
 | 
					      newExif.country = null;
 | 
				
			||||||
      newExif.fps = null;
 | 
					      newExif.fps = null;
 | 
				
			||||||
 | 
					      newExif.livePhotoCID = exifData?.ContentIdentifier || null;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      if (newExif.livePhotoCID) {
 | 
				
			||||||
 | 
					        const photoAsset = await this.assetRepository.findOne({
 | 
				
			||||||
 | 
					          where: {
 | 
				
			||||||
 | 
					            id: Not(asset.id),
 | 
				
			||||||
 | 
					            type: AssetType.IMAGE,
 | 
				
			||||||
 | 
					            livePhotoVideoId: IsNull(),
 | 
				
			||||||
 | 
					            exifInfo: {
 | 
				
			||||||
 | 
					              livePhotoCID: newExif.livePhotoCID,
 | 
				
			||||||
 | 
					            },
 | 
				
			||||||
 | 
					          },
 | 
				
			||||||
 | 
					        });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        if (photoAsset) {
 | 
				
			||||||
 | 
					          await this.assetRepository.update(photoAsset.id, { livePhotoVideoId: asset.id });
 | 
				
			||||||
 | 
					          await this.assetRepository.update(asset.id, { isVisible: false });
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      if (videoTags && videoTags['location']) {
 | 
					      if (videoTags && videoTags['location']) {
 | 
				
			||||||
        const location = videoTags['location'] as string;
 | 
					        const location = videoTags['location'] as string;
 | 
				
			||||||
 | 
				
			|||||||
@ -378,6 +378,7 @@ export const sharedLinkStub = {
 | 
				
			|||||||
            isVisible: true,
 | 
					            isVisible: true,
 | 
				
			||||||
            livePhotoVideoId: null,
 | 
					            livePhotoVideoId: null,
 | 
				
			||||||
            exifInfo: {
 | 
					            exifInfo: {
 | 
				
			||||||
 | 
					              livePhotoCID: null,
 | 
				
			||||||
              id: 1,
 | 
					              id: 1,
 | 
				
			||||||
              assetId: 'id_1',
 | 
					              assetId: 'id_1',
 | 
				
			||||||
              description: 'description',
 | 
					              description: 'description',
 | 
				
			||||||
 | 
				
			|||||||
@ -44,6 +44,10 @@ export class ExifEntity {
 | 
				
			|||||||
  @Column({ type: 'varchar', nullable: true })
 | 
					  @Column({ type: 'varchar', nullable: true })
 | 
				
			||||||
  city!: string | null;
 | 
					  city!: string | null;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  @Index('IDX_live_photo_cid')
 | 
				
			||||||
 | 
					  @Column({ type: 'varchar', nullable: true })
 | 
				
			||||||
 | 
					  livePhotoCID!: string | null;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  @Column({ type: 'varchar', nullable: true })
 | 
					  @Column({ type: 'varchar', nullable: true })
 | 
				
			||||||
  state!: string | null;
 | 
					  state!: string | null;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
				
			|||||||
@ -0,0 +1,15 @@
 | 
				
			|||||||
 | 
					import { MigrationInterface, QueryRunner } from 'typeorm';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export class AppleContentIdentifier1676437878377 implements MigrationInterface {
 | 
				
			||||||
 | 
					  name = 'AppleContentIdentifier1676437878377';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  public async up(queryRunner: QueryRunner): Promise<void> {
 | 
				
			||||||
 | 
					    await queryRunner.query(`ALTER TABLE "exif" ADD "livePhotoCID" character varying`);
 | 
				
			||||||
 | 
					    await queryRunner.query(`CREATE INDEX "IDX_live_photo_cid" ON "exif" ("livePhotoCID") `);
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  public async down(queryRunner: QueryRunner): Promise<void> {
 | 
				
			||||||
 | 
					    await queryRunner.query(`DROP INDEX "public"."IDX_live_photo_cid"`);
 | 
				
			||||||
 | 
					    await queryRunner.query(`ALTER TABLE "exif" DROP COLUMN "livePhotoCID"`);
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user