mirror of
				https://github.com/KevinMidboe/immich.git
				synced 2025-10-29 17:40:28 +00:00 
			
		
		
		
	fix(server): link live photos after metadata extraction finishes (#3702)
* fix(server): link live photos after metadata extraction finishes * chore: fix test --------- Co-authored-by: Alex <alex.tran1502@gmail.com>
This commit is contained in:
		| @@ -16,6 +16,8 @@ export interface IAlbumRepository { | ||||
|   getByIds(ids: string[]): Promise<AlbumEntity[]>; | ||||
|   getByAssetId(ownerId: string, assetId: string): Promise<AlbumEntity[]>; | ||||
|   hasAsset(id: string, assetId: string): Promise<boolean>; | ||||
|   /** Remove an asset from _all_ albums */ | ||||
|   removeAsset(id: string): Promise<void>; | ||||
|   getAssetCountForIds(ids: string[]): Promise<AlbumAssetCount[]>; | ||||
|   getInvalidThumbnail(): Promise<string[]>; | ||||
|   getOwned(ownerId: string): Promise<AlbumEntity[]>; | ||||
|   | ||||
| @@ -32,6 +32,7 @@ export enum JobName { | ||||
|   // metadata | ||||
|   QUEUE_METADATA_EXTRACTION = 'queue-metadata-extraction', | ||||
|   METADATA_EXTRACTION = 'metadata-extraction', | ||||
|   LINK_LIVE_PHOTOS = 'link-live-photos', | ||||
|  | ||||
|   // user deletion | ||||
|   USER_DELETION = 'user-deletion', | ||||
| @@ -98,6 +99,7 @@ export const JOBS_TO_QUEUE: Record<JobName, QueueName> = { | ||||
|   // metadata | ||||
|   [JobName.QUEUE_METADATA_EXTRACTION]: QueueName.METADATA_EXTRACTION, | ||||
|   [JobName.METADATA_EXTRACTION]: QueueName.METADATA_EXTRACTION, | ||||
|   [JobName.LINK_LIVE_PHOTOS]: QueueName.METADATA_EXTRACTION, | ||||
|  | ||||
|   // storage template | ||||
|   [JobName.STORAGE_TEMPLATE_MIGRATION]: QueueName.STORAGE_TEMPLATE_MIGRATION, | ||||
|   | ||||
| @@ -45,6 +45,7 @@ export type JobItem = | ||||
|   // Metadata Extraction | ||||
|   | { name: JobName.QUEUE_METADATA_EXTRACTION; data: IBaseJob } | ||||
|   | { name: JobName.METADATA_EXTRACTION; data: IEntityJob } | ||||
|   | { name: JobName.LINK_LIVE_PHOTOS; data: IEntityJob } | ||||
|  | ||||
|   // Sidecar Scanning | ||||
|   | { name: JobName.QUEUE_SIDECAR; data: IBaseJob } | ||||
|   | ||||
| @@ -252,6 +252,10 @@ describe(JobService.name, () => { | ||||
|       }, | ||||
|       { | ||||
|         item: { name: JobName.METADATA_EXTRACTION, data: { id: 'asset-1' } }, | ||||
|         jobs: [JobName.LINK_LIVE_PHOTOS], | ||||
|       }, | ||||
|       { | ||||
|         item: { name: JobName.LINK_LIVE_PHOTOS, data: { id: 'asset-1' } }, | ||||
|         jobs: [JobName.STORAGE_TEMPLATE_MIGRATION_SINGLE, JobName.SEARCH_INDEX_ASSET], | ||||
|       }, | ||||
|       { | ||||
|   | ||||
| @@ -149,6 +149,10 @@ export class JobService { | ||||
|         break; | ||||
|  | ||||
|       case JobName.METADATA_EXTRACTION: | ||||
|         await this.jobRepository.queue({ name: JobName.LINK_LIVE_PHOTOS, data: item.data }); | ||||
|         break; | ||||
|  | ||||
|       case JobName.LINK_LIVE_PHOTOS: | ||||
|         await this.jobRepository.queue({ name: JobName.STORAGE_TEMPLATE_MIGRATION_SINGLE, data: item.data }); | ||||
|         break; | ||||
|  | ||||
| @@ -186,7 +190,7 @@ export class JobService { | ||||
|       case JobName.CLASSIFY_IMAGE: | ||||
|       case JobName.ENCODE_CLIP: | ||||
|       case JobName.RECOGNIZE_FACES: | ||||
|       case JobName.METADATA_EXTRACTION: | ||||
|       case JobName.LINK_LIVE_PHOTOS: | ||||
|         await this.jobRepository.queue({ name: JobName.SEARCH_INDEX_ASSET, data: { ids: [item.data.id] } }); | ||||
|         break; | ||||
|     } | ||||
|   | ||||
| @@ -1,7 +1,7 @@ | ||||
| import { AlbumAssetCount, AlbumInfoOptions, IAlbumRepository } from '@app/domain'; | ||||
| import { Injectable } from '@nestjs/common'; | ||||
| import { InjectRepository } from '@nestjs/typeorm'; | ||||
| import { FindOptionsOrder, FindOptionsRelations, In, IsNull, Not, Repository } from 'typeorm'; | ||||
| import { InjectDataSource, InjectRepository } from '@nestjs/typeorm'; | ||||
| import { DataSource, FindOptionsOrder, FindOptionsRelations, In, IsNull, Not, Repository } from 'typeorm'; | ||||
| import { dataSource } from '../database.config'; | ||||
| import { AlbumEntity, AssetEntity } from '../entities'; | ||||
|  | ||||
| @@ -10,6 +10,7 @@ export class AlbumRepository implements IAlbumRepository { | ||||
|   constructor( | ||||
|     @InjectRepository(AssetEntity) private assetRepository: Repository<AssetEntity>, | ||||
|     @InjectRepository(AlbumEntity) private repository: Repository<AlbumEntity>, | ||||
|     @InjectDataSource() private dataSource: DataSource, | ||||
|   ) {} | ||||
|  | ||||
|   getById(id: string, options: AlbumInfoOptions): Promise<AlbumEntity | null> { | ||||
| @@ -84,7 +85,7 @@ export class AlbumRepository implements IAlbumRepository { | ||||
|    */ | ||||
|   async getInvalidThumbnail(): Promise<string[]> { | ||||
|     // Using dataSource, because there is no direct access to albums_assets_assets. | ||||
|     const albumHasAssets = dataSource | ||||
|     const albumHasAssets = this.dataSource | ||||
|       .createQueryBuilder() | ||||
|       .select('1') | ||||
|       .from('albums_assets_assets', 'albums_assets') | ||||
| @@ -150,6 +151,16 @@ export class AlbumRepository implements IAlbumRepository { | ||||
|     }); | ||||
|   } | ||||
|  | ||||
|   async removeAsset(assetId: string): Promise<void> { | ||||
|     // Using dataSource, because there is no direct access to albums_assets_assets. | ||||
|     await this.dataSource | ||||
|       .createQueryBuilder() | ||||
|       .delete() | ||||
|       .from('albums_assets_assets') | ||||
|       .where('"albums_assets_assets"."assetsId" = :assetId', { assetId }) | ||||
|       .execute(); | ||||
|   } | ||||
|  | ||||
|   hasAsset(id: string, assetId: string): Promise<boolean> { | ||||
|     return this.repository.exist({ | ||||
|       where: { | ||||
|   | ||||
| @@ -66,6 +66,7 @@ export class AppService { | ||||
|       [JobName.VIDEO_CONVERSION]: (data) => this.mediaService.handleVideoConversion(data), | ||||
|       [JobName.QUEUE_METADATA_EXTRACTION]: (data) => this.metadataProcessor.handleQueueMetadataExtraction(data), | ||||
|       [JobName.METADATA_EXTRACTION]: (data) => this.metadataProcessor.handleMetadataExtraction(data), | ||||
|       [JobName.LINK_LIVE_PHOTOS]: (data) => this.metadataProcessor.handleLivePhotoLinking(data), | ||||
|       [JobName.QUEUE_RECOGNIZE_FACES]: (data) => this.facialRecognitionService.handleQueueRecognizeFaces(data), | ||||
|       [JobName.RECOGNIZE_FACES]: (data) => this.facialRecognitionService.handleRecognizeFaces(data), | ||||
|       [JobName.GENERATE_FACE_THUMBNAIL]: (data) => this.facialRecognitionService.handleGenerateFaceThumbnail(data), | ||||
|   | ||||
| @@ -1,4 +1,5 @@ | ||||
| import { | ||||
|   IAlbumRepository, | ||||
|   IAssetRepository, | ||||
|   IBaseJob, | ||||
|   ICryptoRepository, | ||||
| @@ -59,6 +60,7 @@ export class MetadataExtractionProcessor { | ||||
|  | ||||
|   constructor( | ||||
|     @Inject(IAssetRepository) private assetRepository: IAssetRepository, | ||||
|     @Inject(IAlbumRepository) private albumRepository: IAlbumRepository, | ||||
|     @Inject(IJobRepository) private jobRepository: IJobRepository, | ||||
|     @Inject(IGeocodingRepository) private geocodingRepository: IGeocodingRepository, | ||||
|     @Inject(ICryptoRepository) private cryptoRepository: ICryptoRepository, | ||||
| @@ -92,6 +94,38 @@ export class MetadataExtractionProcessor { | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   async handleLivePhotoLinking(job: IEntityJob) { | ||||
|     const { id } = job; | ||||
|     const [asset] = await this.assetRepository.getByIds([id]); | ||||
|     if (!asset?.exifInfo) { | ||||
|       return false; | ||||
|     } | ||||
|  | ||||
|     if (!asset.exifInfo.livePhotoCID) { | ||||
|       return true; | ||||
|     } | ||||
|  | ||||
|     const otherType = asset.type === AssetType.VIDEO ? AssetType.IMAGE : AssetType.VIDEO; | ||||
|     const match = await this.assetRepository.findLivePhotoMatch({ | ||||
|       livePhotoCID: asset.exifInfo.livePhotoCID, | ||||
|       ownerId: asset.ownerId, | ||||
|       otherAssetId: asset.id, | ||||
|       type: otherType, | ||||
|     }); | ||||
|  | ||||
|     if (!match) { | ||||
|       return true; | ||||
|     } | ||||
|  | ||||
|     const [photoAsset, motionAsset] = asset.type === AssetType.IMAGE ? [asset, match] : [match, asset]; | ||||
|  | ||||
|     await this.assetRepository.save({ id: photoAsset.id, livePhotoVideoId: motionAsset.id }); | ||||
|     await this.assetRepository.save({ id: motionAsset.id, isVisible: false }); | ||||
|     await this.albumRepository.removeAsset(motionAsset.id); | ||||
|  | ||||
|     return true; | ||||
|   } | ||||
|  | ||||
|   async handleQueueMetadataExtraction(job: IBaseJob) { | ||||
|     const { force } = job; | ||||
|     const assetPagination = usePagination(JOBS_ASSET_PAGINATION_SIZE, (pagination) => { | ||||
| @@ -351,19 +385,6 @@ export class MetadataExtractionProcessor { | ||||
|     } | ||||
|  | ||||
|     newExif.livePhotoCID = getExifProperty('MediaGroupUUID'); | ||||
|     if (newExif.livePhotoCID && !asset.livePhotoVideoId) { | ||||
|       const motionAsset = await this.assetRepository.findLivePhotoMatch({ | ||||
|         livePhotoCID: newExif.livePhotoCID, | ||||
|         otherAssetId: asset.id, | ||||
|         ownerId: asset.ownerId, | ||||
|         type: AssetType.VIDEO, | ||||
|       }); | ||||
|       if (motionAsset) { | ||||
|         await this.assetRepository.save({ id: asset.id, livePhotoVideoId: motionAsset.id }); | ||||
|         await this.assetRepository.save({ id: motionAsset.id, isVisible: false }); | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     await this.applyReverseGeocoding(asset, newExif); | ||||
|  | ||||
|     /** | ||||
| @@ -428,19 +449,6 @@ export class MetadataExtractionProcessor { | ||||
|     newExif.fps = null; | ||||
|     newExif.livePhotoCID = exifData?.ContentIdentifier || null; | ||||
|  | ||||
|     if (newExif.livePhotoCID) { | ||||
|       const photoAsset = await this.assetRepository.findLivePhotoMatch({ | ||||
|         livePhotoCID: newExif.livePhotoCID, | ||||
|         ownerId: asset.ownerId, | ||||
|         otherAssetId: asset.id, | ||||
|         type: AssetType.IMAGE, | ||||
|       }); | ||||
|       if (photoAsset) { | ||||
|         await this.assetRepository.save({ id: photoAsset.id, livePhotoVideoId: asset.id }); | ||||
|         await this.assetRepository.save({ id: asset.id, isVisible: false }); | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     if (videoTags && videoTags['location']) { | ||||
|       const location = videoTags['location'] as string; | ||||
|       const locationRegex = /([+-][0-9]+\.[0-9]+)([+-][0-9]+\.[0-9]+)\/$/; | ||||
|   | ||||
| @@ -12,6 +12,7 @@ export const newAlbumRepositoryMock = (): jest.Mocked<IAlbumRepository> => { | ||||
|     getNotShared: jest.fn(), | ||||
|     deleteAll: jest.fn(), | ||||
|     getAll: jest.fn(), | ||||
|     removeAsset: jest.fn(), | ||||
|     hasAsset: jest.fn(), | ||||
|     create: jest.fn(), | ||||
|     update: jest.fn(), | ||||
|   | ||||
		Reference in New Issue
	
	Block a user