2024-10-02 10:54:35 -04:00
import { Injectable } from '@nestjs/common' ;
2025-06-26 11:18:40 -04:00
import { ContainerDirectoryItem , ExifDateTime , Tags } from 'exiftool-vendored' ;
2025-01-09 11:15:41 -05:00
import { Insertable } from 'kysely' ;
2023-11-30 04:52:28 +01:00
import _ from 'lodash' ;
2023-09-27 20:44:51 +02:00
import { Duration } from 'luxon' ;
2025-04-01 18:24:07 -04:00
import { Stats } from 'node:fs' ;
2024-02-02 04:18:00 +01:00
import { constants } from 'node:fs/promises' ;
2025-09-04 12:23:58 -04:00
import { join , parse } from 'node:path' ;
2025-02-11 17:15:56 -05:00
import { JOBS_ASSET_PAGINATION_SIZE } from 'src/constants' ;
2024-03-20 21:20:38 +01:00
import { StorageCore } from 'src/cores/storage.core' ;
2025-04-16 20:08:49 +02:00
import { Asset , AssetFace } from 'src/database' ;
2024-10-31 13:42:58 -04:00
import { OnEvent , OnJob } from 'src/decorators' ;
2025-02-11 17:15:56 -05:00
import {
AssetType ,
2025-05-06 12:12:48 -05:00
AssetVisibility ,
2025-02-11 17:15:56 -05:00
DatabaseLock ,
ExifOrientation ,
ImmichWorker ,
JobName ,
JobStatus ,
QueueName ,
SourceType ,
} from 'src/enum' ;
2025-02-11 15:12:31 -05:00
import { ArgOf } from 'src/repositories/event.repository' ;
2025-01-23 18:10:17 -05:00
import { ReverseGeocodeResult } from 'src/repositories/map.repository' ;
import { ImmichTags } from 'src/repositories/metadata.repository' ;
2025-07-14 10:13:06 -04:00
import { AssetExifTable } from 'src/schema/tables/asset-exif.table' ;
2025-06-30 13:19:16 -04:00
import { AssetFaceTable } from 'src/schema/tables/asset-face.table' ;
import { PersonTable } from 'src/schema/tables/person.table' ;
2024-09-30 17:31:21 -04:00
import { BaseService } from 'src/services/base.service' ;
2025-04-30 10:53:51 -05:00
import { JobItem , JobOf } from 'src/types' ;
2025-08-27 15:10:55 -04:00
import { isAssetChecksumConstraint } from 'src/utils/database' ;
2024-09-05 00:23:58 +02:00
import { isFaceImportEnabled } from 'src/utils/misc' ;
2024-08-29 12:14:03 -04:00
import { upsertTags } from 'src/utils/tag' ;
2023-05-26 08:52:52 -04:00
2023-11-21 17:58:56 +01:00
/** look for a date from these tags (in order) */
2025-06-26 11:18:40 -04:00
const EXIF_DATE_TAGS : Array < keyof ImmichTags > = [
2023-11-21 17:58:56 +01:00
'SubSecDateTimeOriginal' ,
'SubSecCreateDate' ,
'SubSecMediaCreateDate' ,
2025-06-26 11:18:40 -04:00
'DateTimeOriginal' ,
'CreateDate' ,
2023-11-21 17:58:56 +01:00
'MediaCreateDate' ,
2025-06-26 11:18:40 -04:00
'CreationDate' ,
2023-11-21 17:58:56 +01:00
'DateTimeCreated' ,
2025-06-26 11:18:40 -04:00
'GPSDateTime' ,
'DateTimeUTC' ,
'SonyDateTime2' ,
2025-04-14 05:44:18 +02:00
// Undocumented, non-standard tag from insta360 in xmp.GPano namespace
2025-06-26 11:18:40 -04:00
'SourceImageCreateTime' as keyof ImmichTags ,
2023-11-21 17:58:56 +01:00
] ;
2025-06-26 11:18:40 -04:00
export function firstDateTime ( tags : ImmichTags ) {
for ( const tag of EXIF_DATE_TAGS ) {
const tagValue = tags ? . [ tag ] ;
if ( tagValue instanceof ExifDateTime ) {
return {
tag ,
dateTime : tagValue ,
} ;
}
if ( typeof tagValue !== 'string' ) {
continue ;
}
const exifDateTime = ExifDateTime . fromEXIF ( tagValue ) ;
if ( exifDateTime ) {
return {
tag ,
dateTime : exifDateTime ,
} ;
}
}
}
2023-09-27 15:17:18 -04:00
const validate = < T > ( value : T ) : NonNullable < T > | null = > {
2023-09-29 11:42:33 -04:00
// handle lists of numbers
if ( Array . isArray ( value ) ) {
value = value [ 0 ] ;
}
2023-09-27 15:17:18 -04:00
if ( typeof value === 'string' ) {
// string means a failure to parse a number, throw out result
return null ;
}
2024-02-02 04:18:00 +01:00
if ( typeof value === 'number' && ( Number . isNaN ( value ) || ! Number . isFinite ( value ) ) ) {
2023-09-27 15:17:18 -04:00
return null ;
}
return value ? ? null ;
} ;
2023-09-27 20:44:51 +02:00
2024-09-23 08:50:18 +01:00
const validateRange = ( value : number | undefined , min : number , max : number ) : NonNullable < number > | null = > {
// reutilizes the validate function
const val = validate ( value ) ;
// check if the value is within the range
if ( val == null || val < min || val > max ) {
return null ;
}
2025-08-04 14:29:51 -05:00
return Math . round ( val ) ;
2024-09-23 08:50:18 +01:00
} ;
2025-04-10 12:02:41 -05:00
const getLensModel = ( exifTags : ImmichTags ) : string | null = > {
const lensModel = String (
exifTags . LensID ? ? exifTags . LensType ? ? exifTags . LensSpec ? ? exifTags . LensModel ? ? '' ,
) . trim ( ) ;
if ( lensModel === '----' ) {
return null ;
}
if ( lensModel . startsWith ( 'Unknown' ) ) {
return null ;
}
return lensModel || null ;
} ;
2025-03-06 09:56:35 -05:00
type ImmichTagsWithFaces = ImmichTags & { RegionInfo : NonNullable < ImmichTags [ 'RegionInfo' ] > } ;
2025-04-01 18:24:07 -04:00
type Dates = {
dateTimeOriginal : Date ;
localDateTime : Date ;
} ;
2023-09-27 20:44:51 +02:00
@Injectable ( )
2024-09-30 17:31:21 -04:00
export class MetadataService extends BaseService {
2025-07-15 14:50:13 -04:00
@OnEvent ( { name : 'AppBootstrap' , workers : [ ImmichWorker . Microservices ] } )
2024-11-05 16:30:56 +00:00
async onBootstrap() {
this . logger . log ( 'Bootstrapping metadata service' ) ;
await this . init ( ) ;
2024-06-27 15:54:20 -04:00
}
2025-07-15 13:41:19 -04:00
@OnEvent ( { name : 'AppShutdown' } )
2024-09-30 10:35:11 -04:00
async onShutdown() {
2024-10-02 10:54:35 -04:00
await this . metadataRepository . teardown ( ) ;
2024-09-30 10:35:11 -04:00
}
2025-07-15 14:50:13 -04:00
@OnEvent ( { name : 'ConfigInit' , workers : [ ImmichWorker . Microservices ] } )
2025-07-15 13:41:19 -04:00
onConfigInit ( { newConfig } : ArgOf < 'ConfigInit' > ) {
2025-02-27 17:24:40 +03:00
this . metadataRepository . setMaxConcurrency ( newConfig . job . metadataExtraction . concurrency ) ;
}
2025-07-15 14:50:13 -04:00
@OnEvent ( { name : 'ConfigUpdate' , workers : [ ImmichWorker . Microservices ] , server : true } )
2025-07-15 13:41:19 -04:00
onConfigUpdate ( { newConfig } : ArgOf < 'ConfigUpdate' > ) {
2025-02-27 17:24:40 +03:00
this . metadataRepository . setMaxConcurrency ( newConfig . job . metadataExtraction . concurrency ) ;
}
2024-11-05 16:30:56 +00:00
private async init() {
this . logger . log ( 'Initializing metadata service' ) ;
2023-09-27 20:44:51 +02:00
try {
2025-07-15 14:50:13 -04:00
await this . jobRepository . pause ( QueueName . MetadataExtraction ) ;
2024-05-29 17:51:01 +02:00
await this . databaseRepository . withLock ( DatabaseLock . GeodataImport , ( ) = > this . mapRepository . init ( ) ) ;
2025-07-15 14:50:13 -04:00
await this . jobRepository . resume ( QueueName . MetadataExtraction ) ;
2023-09-27 20:44:51 +02:00
2023-11-25 18:53:30 +00:00
this . logger . log ( ` Initialized local reverse geocoder ` ) ;
2023-09-27 20:44:51 +02:00
} catch ( error : Error | any ) {
this . logger . error ( ` Unable to initialize reverse geocoding: ${ error } ` , error ? . stack ) ;
2025-04-03 12:35:39 +01:00
throw new Error ( ` Metadata service init failed ` ) ;
2023-09-27 20:44:51 +02:00
}
}
2025-04-16 20:08:49 +02:00
private async linkLivePhotos (
asset : { id : string ; type : AssetType ; ownerId : string ; libraryId : string | null } ,
2025-07-14 10:13:06 -04:00
exifInfo : Insertable < AssetExifTable > ,
2025-04-16 20:08:49 +02:00
) : Promise < void > {
2025-03-03 17:19:36 +03:00
if ( ! exifInfo . livePhotoCID ) {
return ;
2023-09-27 20:44:51 +02:00
}
2025-07-15 14:50:13 -04:00
const otherType = asset . type === AssetType . Video ? AssetType.Image : AssetType.Video ;
2023-09-27 20:44:51 +02:00
const match = await this . assetRepository . findLivePhotoMatch ( {
2025-03-03 17:19:36 +03:00
livePhotoCID : exifInfo.livePhotoCID ,
2023-09-27 20:44:51 +02:00
ownerId : asset.ownerId ,
2024-08-20 21:23:50 -04:00
libraryId : asset.libraryId ,
2023-09-27 20:44:51 +02:00
otherAssetId : asset.id ,
type : otherType ,
} ) ;
if ( ! match ) {
2025-03-03 17:19:36 +03:00
return ;
2023-09-27 20:44:51 +02:00
}
2025-07-15 14:50:13 -04:00
const [ photoAsset , motionAsset ] = asset . type === AssetType . Image ? [ asset , match ] : [ match , asset ] ;
2025-03-03 17:19:36 +03:00
await Promise . all ( [
this . assetRepository . update ( { id : photoAsset.id , livePhotoVideoId : motionAsset.id } ) ,
2025-07-15 14:50:13 -04:00
this . assetRepository . update ( { id : motionAsset.id , visibility : AssetVisibility.Hidden } ) ,
2025-05-15 09:35:21 -06:00
this . albumRepository . removeAssetsFromAll ( [ motionAsset . id ] ) ,
2025-03-03 17:19:36 +03:00
] ) ;
2023-09-27 20:44:51 +02:00
2025-07-15 13:41:19 -04:00
await this . eventRepository . emit ( 'AssetHide' , { assetId : motionAsset.id , userId : motionAsset.ownerId } ) ;
2023-09-27 20:44:51 +02:00
}
2025-07-15 18:39:00 -04:00
@OnJob ( { name : JobName.AssetExtractMetadataQueueAll , queue : QueueName.MetadataExtraction } )
async handleQueueMetadataExtraction ( job : JobOf < JobName.AssetExtractMetadataQueueAll > ) : Promise < JobStatus > {
2023-09-27 20:44:51 +02:00
const { force } = job ;
2025-07-15 18:39:00 -04:00
let queue : { name : JobName.AssetExtractMetadata ; data : { id : string } } [ ] = [ ] ;
2025-04-29 00:03:20 +02:00
for await ( const asset of this . assetJobRepository . streamForMetadataExtraction ( force ) ) {
2025-07-15 18:39:00 -04:00
queue . push ( { name : JobName.AssetExtractMetadata , data : { id : asset.id } } ) ;
2025-04-29 00:03:20 +02:00
if ( queue . length >= JOBS_ASSET_PAGINATION_SIZE ) {
await this . jobRepository . queueAll ( queue ) ;
queue = [ ] ;
}
2023-09-27 20:44:51 +02:00
}
2025-04-29 00:03:20 +02:00
await this . jobRepository . queueAll ( queue ) ;
2025-07-15 14:50:13 -04:00
return JobStatus . Success ;
2023-09-27 20:44:51 +02:00
}
2025-07-15 18:39:00 -04:00
@OnJob ( { name : JobName.AssetExtractMetadata , queue : QueueName.MetadataExtraction } )
async handleMetadataExtraction ( data : JobOf < JobName.AssetExtractMetadata > ) {
2025-04-16 20:08:49 +02:00
const [ { metadata , reverseGeocoding } , asset ] = await Promise . all ( [
2025-03-06 09:56:35 -05:00
this . getConfig ( { withCache : true } ) ,
2025-04-16 20:08:49 +02:00
this . assetJobRepository . getForMetadataExtraction ( data . id ) ,
2025-03-06 09:56:35 -05:00
] ) ;
2023-12-29 18:41:33 +00:00
if ( ! asset ) {
2025-04-30 17:02:53 -04:00
return ;
2023-09-27 20:44:51 +02:00
}
2025-04-01 18:24:07 -04:00
const [ exifTags , stats ] = await Promise . all ( [
this . getExifTags ( asset ) ,
this . storageRepository . stat ( asset . originalPath ) ,
] ) ;
2025-03-06 11:47:12 -05:00
this . logger . verbose ( 'Exif Tags' , exifTags ) ;
2025-02-13 22:30:12 +01:00
2025-04-01 18:24:07 -04:00
const dates = this . getDates ( asset , exifTags , stats ) ;
2023-10-04 18:11:11 -04:00
2024-11-01 15:34:34 +01:00
const { width , height } = this . getImageDimensions ( exifTags ) ;
2025-04-14 11:43:46 +02:00
let geo : ReverseGeocodeResult = { country : null , state : null , city : null } ,
latitude : number | null = null ,
longitude : number | null = null ;
if ( this . hasGeo ( exifTags ) ) {
2025-03-06 09:56:35 -05:00
latitude = exifTags . GPSLatitude ;
longitude = exifTags . GPSLongitude ;
2025-04-14 11:43:46 +02:00
if ( reverseGeocoding . enabled ) {
geo = await this . mapRepository . reverseGeocode ( { latitude , longitude } ) ;
}
2025-03-06 09:56:35 -05:00
}
2024-11-01 15:34:34 +01:00
2025-07-14 10:13:06 -04:00
const exifData : Insertable < AssetExifTable > = {
2024-09-07 13:39:10 -04:00
assetId : asset.id ,
2023-10-06 08:12:09 -04:00
2024-09-07 13:39:10 -04:00
// dates
2025-04-01 18:24:07 -04:00
dateTimeOriginal : dates.dateTimeOriginal ,
modifyDate : stats.mtime ,
timeZone : dates.timeZone ,
2023-10-04 18:11:11 -04:00
2024-09-07 13:39:10 -04:00
// gps
latitude ,
longitude ,
2025-03-06 09:56:35 -05:00
country : geo.country ,
state : geo.state ,
city : geo.city ,
2024-09-07 13:39:10 -04:00
// image/file
2025-04-01 18:24:07 -04:00
fileSizeInByte : stats.size ,
2024-11-01 15:34:34 +01:00
exifImageHeight : validate ( height ) ,
exifImageWidth : validate ( width ) ,
2024-09-07 13:39:10 -04:00
orientation : validate ( exifTags . Orientation ) ? . toString ( ) ? ? null ,
projectionType : exifTags.ProjectionType ? String ( exifTags . ProjectionType ) . toUpperCase ( ) : null ,
bitsPerSample : this.getBitsPerSample ( exifTags ) ,
colorspace : exifTags.ColorSpace ? ? null ,
// camera
2025-03-09 22:20:11 -05:00
make : exifTags.Make ? ? exifTags ? . Device ? . Manufacturer ? ? exifTags . AndroidMake ? ? null ,
model : exifTags.Model ? ? exifTags ? . Device ? . ModelName ? ? exifTags . AndroidModel ? ? null ,
2024-09-07 13:39:10 -04:00
fps : validate ( Number . parseFloat ( exifTags . VideoFrameRate ! ) ) ,
2024-09-26 14:32:10 -04:00
iso : validate ( exifTags . ISO ) as number ,
2024-09-07 13:39:10 -04:00
exposureTime : exifTags.ExposureTime ? ? null ,
2025-04-10 12:02:41 -05:00
lensModel : getLensModel ( exifTags ) ,
2024-09-07 13:39:10 -04:00
fNumber : validate ( exifTags . FNumber ) ,
focalLength : validate ( exifTags . FocalLength ) ,
// comments
description : String ( exifTags . ImageDescription || exifTags . Description || '' ) . trim ( ) ,
profileDescription : exifTags.ProfileDescription || null ,
2025-01-28 04:54:29 +01:00
rating : validateRange ( exifTags . Rating , - 1 , 5 ) ,
2024-09-07 13:39:10 -04:00
// grouping
livePhotoCID : ( exifTags . ContentIdentifier || exifTags . MediaGroupUUID ) ? ? null ,
autoStackId : this.getAutoStackId ( exifTags ) ,
} ;
2025-03-06 09:56:35 -05:00
const promises : Promise < unknown > [ ] = [
this . assetRepository . upsertExif ( exifData ) ,
this . assetRepository . update ( {
id : asset.id ,
duration : exifTags.Duration?.toString ( ) ? ? null ,
2025-04-01 18:24:07 -04:00
localDateTime : dates.localDateTime ,
fileCreatedAt : dates.dateTimeOriginal ? ? undefined ,
fileModifiedAt : stats.mtime ,
2025-03-06 09:56:35 -05:00
} ) ,
this . applyTagList ( asset , exifTags ) ,
] ;
2024-09-07 13:39:10 -04:00
2025-03-06 09:56:35 -05:00
if ( this . isMotionPhoto ( asset , exifTags ) ) {
2025-04-18 23:10:34 +02:00
promises . push ( this . applyMotionPhotos ( asset , exifTags , dates , stats ) ) ;
2025-03-06 09:56:35 -05:00
}
2024-08-29 12:14:03 -04:00
2025-03-06 09:56:35 -05:00
if ( isFaceImportEnabled ( metadata ) && this . hasTaggedFaces ( exifTags ) ) {
promises . push ( this . applyTaggedFaces ( asset , exifTags ) ) ;
}
2023-09-27 20:44:51 +02:00
2025-03-06 09:56:35 -05:00
await Promise . all ( promises ) ;
2025-03-03 17:19:36 +03:00
if ( exifData . livePhotoCID ) {
await this . linkLivePhotos ( asset , exifData ) ;
}
2024-01-12 19:39:45 -05:00
2025-03-03 17:19:36 +03:00
await this . assetRepository . upsertJobStatus ( { assetId : asset.id , metadataExtractedAt : new Date ( ) } ) ;
2025-07-15 13:41:19 -04:00
await this . eventRepository . emit ( 'AssetMetadataExtracted' , {
2025-04-30 17:02:53 -04:00
assetId : asset.id ,
userId : asset.ownerId ,
source : data.source ,
} ) ;
2023-09-27 20:44:51 +02:00
}
2023-05-26 08:52:52 -04:00
2025-07-15 18:39:00 -04:00
@OnJob ( { name : JobName.SidecarQueueAll , queue : QueueName.Sidecar } )
async handleQueueSidecar ( { force } : JobOf < JobName.SidecarQueueAll > ) : Promise < JobStatus > {
2025-04-30 10:53:51 -05:00
let jobs : JobItem [ ] = [ ] ;
const queueAll = async ( ) = > {
await this . jobRepository . queueAll ( jobs ) ;
jobs = [ ] ;
} ;
2023-05-26 15:43:24 -04:00
2025-04-30 10:53:51 -05:00
const assets = this . assetJobRepository . streamForSidecar ( force ) ;
for await ( const asset of assets ) {
2025-09-04 12:23:58 -04:00
jobs . push ( { name : JobName.SidecarCheck , data : { id : asset.id } } ) ;
2025-04-30 10:53:51 -05:00
if ( jobs . length >= JOBS_ASSET_PAGINATION_SIZE ) {
await queueAll ( ) ;
}
2023-05-26 08:52:52 -04:00
}
2023-05-26 15:43:24 -04:00
2025-04-30 10:53:51 -05:00
await queueAll ( ) ;
2025-07-15 14:50:13 -04:00
return JobStatus . Success ;
2023-05-26 08:52:52 -04:00
}
2025-09-04 12:23:58 -04:00
@OnJob ( { name : JobName.SidecarCheck , queue : QueueName.Sidecar } )
async handleSidecarCheck ( { id } : JobOf < JobName.SidecarCheck > ) : Promise < JobStatus | undefined > {
const asset = await this . assetJobRepository . getForSidecarCheckJob ( id ) ;
if ( ! asset ) {
return ;
}
let sidecarPath = null ;
for ( const candidate of this . getSidecarCandidates ( asset ) ) {
const exists = await this . storageRepository . checkFileExists ( candidate , constants . R_OK ) ;
if ( ! exists ) {
continue ;
}
2023-05-26 08:52:52 -04:00
2025-09-04 12:23:58 -04:00
sidecarPath = candidate ;
break ;
}
const isChanged = sidecarPath !== asset . sidecarPath ;
this . logger . debug (
` Sidecar check found old= ${ asset . sidecarPath } , new= ${ sidecarPath } will ${ isChanged ? 'update' : 'do nothing for' } asset ${ asset . id } : ${ asset . originalPath } ` ,
) ;
if ( ! isChanged ) {
return JobStatus . Skipped ;
}
await this . assetRepository . update ( { id : asset.id , sidecarPath } ) ;
return JobStatus . Success ;
2023-05-26 08:52:52 -04:00
}
2023-09-27 20:44:51 +02:00
2025-07-15 13:41:19 -04:00
@OnEvent ( { name : 'AssetTag' } )
async handleTagAsset ( { assetId } : ArgOf < 'AssetTag' > ) {
2025-07-15 14:50:13 -04:00
await this . jobRepository . queue ( { name : JobName.SidecarWrite , data : { id : assetId , tags : true } } ) ;
2024-08-29 12:14:03 -04:00
}
2025-07-15 13:41:19 -04:00
@OnEvent ( { name : 'AssetUntag' } )
async handleUntagAsset ( { assetId } : ArgOf < 'AssetUntag' > ) {
2025-07-15 14:50:13 -04:00
await this . jobRepository . queue ( { name : JobName.SidecarWrite , data : { id : assetId , tags : true } } ) ;
2024-08-29 12:14:03 -04:00
}
2025-07-15 14:50:13 -04:00
@OnJob ( { name : JobName.SidecarWrite , queue : QueueName.Sidecar } )
async handleSidecarWrite ( job : JobOf < JobName.SidecarWrite > ) : Promise < JobStatus > {
2024-08-29 12:14:03 -04:00
const { id , description , dateTimeOriginal , latitude , longitude , rating , tags } = job ;
2025-04-15 10:24:51 -04:00
const asset = await this . assetJobRepository . getForSidecarWriteJob ( id ) ;
2023-11-30 04:52:28 +01:00
if ( ! asset ) {
2025-07-15 14:50:13 -04:00
return JobStatus . Failed ;
2023-11-30 04:52:28 +01:00
}
2024-08-29 12:14:03 -04:00
const tagsList = ( asset . tags || [ ] ) . map ( ( tag ) = > tag . value ) ;
2023-11-30 04:52:28 +01:00
const sidecarPath = asset . sidecarPath || ` ${ asset . originalPath } .xmp ` ;
2024-08-29 12:14:03 -04:00
const exif = _ . omitBy (
< Tags > {
2024-07-22 01:01:14 +02:00
Description : description ,
2023-11-30 04:52:28 +01:00
ImageDescription : description ,
2024-07-23 13:59:46 +02:00
DateTimeOriginal : dateTimeOriginal ,
2023-11-30 04:52:28 +01:00
GPSLatitude : latitude ,
GPSLongitude : longitude ,
2024-08-09 19:45:52 +02:00
Rating : rating ,
2024-08-29 12:14:03 -04:00
TagsList : tags ? tagsList : undefined ,
2023-11-30 04:52:28 +01:00
} ,
_ . isUndefined ,
) ;
if ( Object . keys ( exif ) . length === 0 ) {
2025-07-15 14:50:13 -04:00
return JobStatus . Skipped ;
2023-11-30 04:52:28 +01:00
}
2024-10-02 10:54:35 -04:00
await this . metadataRepository . writeTags ( sidecarPath , exif ) ;
2023-11-30 04:52:28 +01:00
if ( ! asset . sidecarPath ) {
2024-03-19 22:42:10 -04:00
await this . assetRepository . update ( { id , sidecarPath } ) ;
2023-11-30 04:52:28 +01:00
}
2025-07-15 14:50:13 -04:00
return JobStatus . Success ;
2023-11-30 04:52:28 +01:00
}
2025-09-04 12:23:58 -04:00
private getSidecarCandidates ( { sidecarPath , originalPath } : { sidecarPath : string | null ; originalPath : string } ) {
const candidates : string [ ] = [ ] ;
if ( sidecarPath ) {
candidates . push ( sidecarPath ) ;
}
const assetPath = parse ( originalPath ) ;
candidates . push (
// IMG_123.jpg.xmp
` ${ originalPath } .xmp ` ,
// IMG_123.xmp
` ${ join ( assetPath . dir , assetPath . name ) } .xmp ` ,
) ;
return candidates ;
}
2024-11-01 15:34:34 +01:00
private getImageDimensions ( exifTags : ImmichTags ) : { width? : number ; height? : number } {
/ *
* The "true" values for width and height are a bit hidden , depending on the camera model and file format .
* For RAW images in the CR2 or RAF format , the "ImageSize" value seems to be correct ,
* but ImageWidth and ImageHeight are not correct ( they contain the dimensions of the preview image ) .
* /
let [ width , height ] = exifTags . ImageSize ? . split ( 'x' ) . map ( ( dim ) = > Number . parseInt ( dim ) || undefined ) || [ ] ;
if ( ! width || ! height ) {
[ width , height ] = [ exifTags . ImageWidth , exifTags . ImageHeight ] ;
}
return { width , height } ;
}
2025-04-16 20:08:49 +02:00
private getExifTags ( asset : {
originalPath : string ;
sidecarPath : string | null ;
type : AssetType ;
} ) : Promise < ImmichTags > {
2025-07-15 14:50:13 -04:00
if ( ! asset . sidecarPath && asset . type === AssetType . Image ) {
2025-03-06 09:56:35 -05:00
return this . metadataRepository . readTags ( asset . originalPath ) ;
}
return this . mergeExifTags ( asset ) ;
}
2025-04-16 20:08:49 +02:00
private async mergeExifTags ( asset : {
originalPath : string ;
sidecarPath : string | null ;
type : AssetType ;
} ) : Promise < ImmichTags > {
2025-03-06 09:56:35 -05:00
const [ mediaTags , sidecarTags , videoTags ] = await Promise . all ( [
this . metadataRepository . readTags ( asset . originalPath ) ,
asset . sidecarPath ? this . metadataRepository . readTags ( asset . sidecarPath ) : null ,
2025-07-15 14:50:13 -04:00
asset . type === AssetType . Video ? this . getVideoTags ( asset . originalPath ) : null ,
2025-03-06 09:56:35 -05:00
] ) ;
2023-09-27 20:44:51 +02:00
2024-10-16 18:20:44 -04:00
// prefer dates from sidecar tags
2025-03-06 09:56:35 -05:00
if ( sidecarTags ) {
2025-06-26 11:18:40 -04:00
const result = firstDateTime ( sidecarTags ) ;
const sidecarDate = result ? . dateTime ;
2025-03-06 09:56:35 -05:00
if ( sidecarDate ) {
for ( const tag of EXIF_DATE_TAGS ) {
delete mediaTags [ tag ] ;
}
2023-11-28 15:09:20 -05:00
}
2023-09-27 20:44:51 +02:00
}
2024-09-07 13:39:10 -04:00
2024-10-16 18:20:44 -04:00
// prefer duration from video tags
delete mediaTags . Duration ;
2025-03-06 09:56:35 -05:00
delete sidecarTags ? . Duration ;
2024-10-16 18:20:44 -04:00
2024-09-07 13:39:10 -04:00
return { . . . mediaTags , . . . videoTags , . . . sidecarTags } ;
2023-09-27 20:44:51 +02:00
}
2025-03-06 09:56:35 -05:00
private getTagList ( exifTags : ImmichTags ) : string [ ] {
let tags : string [ ] ;
2024-08-29 12:14:03 -04:00
if ( exifTags . TagsList ) {
2025-03-06 09:56:35 -05:00
tags = exifTags . TagsList . map ( String ) ;
2024-09-03 18:25:09 -04:00
} else if ( exifTags . HierarchicalSubject ) {
2025-03-06 09:56:35 -05:00
tags = exifTags . HierarchicalSubject . map ( ( tag ) = >
// convert | to /
typeof tag === 'number'
? String ( tag )
: tag
. split ( '|' )
. map ( ( tag ) = > tag . replaceAll ( '/' , '|' ) )
. join ( '/' ) ,
2024-09-03 18:25:09 -04:00
) ;
2024-09-03 17:36:27 -04:00
} else if ( exifTags . Keywords ) {
2024-08-29 12:14:03 -04:00
let keywords = exifTags . Keywords ;
2024-08-30 17:33:42 -04:00
if ( ! Array . isArray ( keywords ) ) {
2024-08-29 12:14:03 -04:00
keywords = [ keywords ] ;
}
2025-03-06 09:56:35 -05:00
tags = keywords . map ( String ) ;
} else {
tags = [ ] ;
2024-08-29 12:14:03 -04:00
}
2025-03-06 09:56:35 -05:00
return tags ;
}
2024-08-29 12:14:03 -04:00
2025-04-16 20:08:49 +02:00
private async applyTagList ( asset : { id : string ; ownerId : string } , exifTags : ImmichTags ) {
2025-03-06 09:56:35 -05:00
const tags = this . getTagList ( exifTags ) ;
2024-09-26 14:32:10 -04:00
const results = await upsertTags ( this . tagRepository , { userId : asset.ownerId , tags } ) ;
2025-03-03 13:41:19 -05:00
await this . tagRepository . replaceAssetTags (
asset . id ,
results . map ( ( tag ) = > tag . id ) ,
) ;
2024-08-29 12:14:03 -04:00
}
2025-04-16 20:08:49 +02:00
private isMotionPhoto ( asset : { type : AssetType } , tags : ImmichTags ) : boolean {
2025-07-15 14:50:13 -04:00
return asset . type === AssetType . Image && ! ! ( tags . MotionPhoto || tags . MicroVideo ) ;
2025-03-06 09:56:35 -05:00
}
2023-09-27 20:44:51 +02:00
2025-04-16 20:08:49 +02:00
private async applyMotionPhotos ( asset : Asset , tags : ImmichTags , dates : Dates , stats : Stats ) {
2023-09-27 20:44:51 +02:00
const isMotionPhoto = tags . MotionPhoto ;
const isMicroVideo = tags . MicroVideo ;
const videoOffset = tags . MicroVideoOffset ;
2024-01-22 10:04:45 -08:00
const hasMotionPhotoVideo = tags . MotionPhotoVideo ;
const hasEmbeddedVideoFile = tags . EmbeddedVideoType === 'MotionPhoto_Data' && tags . EmbeddedVideoFile ;
2024-07-24 17:38:22 -04:00
const directory = Array . isArray ( tags . ContainerDirectory )
? ( tags . ContainerDirectory as ContainerDirectoryItem [ ] )
: null ;
2023-09-27 20:44:51 +02:00
let length = 0 ;
let padding = 0 ;
if ( isMotionPhoto && directory ) {
for ( const entry of directory ) {
2025-03-06 09:56:35 -05:00
if ( entry ? . Item ? . Semantic === 'MotionPhoto' ) {
2023-09-27 20:44:51 +02:00
length = entry . Item . Length ? ? 0 ;
padding = entry . Item . Padding ? ? 0 ;
break ;
}
}
}
if ( isMicroVideo && typeof videoOffset === 'number' ) {
length = videoOffset ;
}
2024-01-22 10:04:45 -08:00
if ( ! length && ! hasEmbeddedVideoFile && ! hasMotionPhotoVideo ) {
2023-09-27 20:44:51 +02:00
return ;
}
2025-02-20 16:46:18 +01:00
this . logger . debug ( ` Starting motion photo video extraction for asset ${ asset . id } : ${ asset . originalPath } ` ) ;
2023-09-27 20:44:51 +02:00
try {
2025-04-01 18:24:07 -04:00
const position = stats . size - length - padding ;
2024-01-22 10:04:45 -08:00
let video : Buffer ;
// Samsung MotionPhoto video extraction
// HEIC-encoded
if ( hasMotionPhotoVideo ) {
2024-10-02 10:54:35 -04:00
video = await this . metadataRepository . extractBinaryTag ( asset . originalPath , 'MotionPhotoVideo' ) ;
2024-01-22 10:04:45 -08:00
}
// JPEG-encoded; HEIC also contains these tags, so this conditional must come second
else if ( hasEmbeddedVideoFile ) {
2024-10-02 10:54:35 -04:00
video = await this . metadataRepository . extractBinaryTag ( asset . originalPath , 'EmbeddedVideoFile' ) ;
2024-01-22 10:04:45 -08:00
}
// Default video extraction
else {
video = await this . storageRepository . readFile ( asset . originalPath , {
buffer : Buffer.alloc ( length ) ,
position ,
length ,
} ) ;
}
2023-09-29 23:25:45 +02:00
const checksum = this . cryptoRepository . hashSha1 ( video ) ;
2025-08-27 15:10:55 -04:00
const checksumQuery = { ownerId : asset.ownerId , libraryId : asset.libraryId ? ? undefined , checksum } ;
let motionAsset = await this . assetRepository . getByChecksum ( checksumQuery ) ;
let isNewMotionAsset = false ;
if ( ! motionAsset ) {
try {
const motionAssetId = this . cryptoRepository . randomUUID ( ) ;
motionAsset = await this . assetRepository . create ( {
id : motionAssetId ,
libraryId : asset.libraryId ,
type : AssetType . Video ,
fileCreatedAt : dates.dateTimeOriginal ,
fileModifiedAt : stats.mtime ,
localDateTime : dates.localDateTime ,
checksum ,
ownerId : asset.ownerId ,
originalPath : StorageCore.getAndroidMotionPath ( asset , motionAssetId ) ,
2025-09-04 12:23:58 -04:00
originalFileName : ` ${ parse ( asset . originalFileName ) . name } .mp4 ` ,
2025-08-27 15:10:55 -04:00
visibility : AssetVisibility.Hidden ,
deviceAssetId : 'NONE' ,
deviceId : 'NONE' ,
} ) ;
isNewMotionAsset = true ;
if ( ! asset . isExternal ) {
await this . userRepository . updateUsage ( asset . ownerId , video . byteLength ) ;
}
} catch ( error ) {
if ( ! isAssetChecksumConstraint ( error ) ) {
throw error ;
}
motionAsset = await this . assetRepository . getByChecksum ( checksumQuery ) ;
if ( ! motionAsset ) {
this . logger . warn ( ` Unable to find existing motion video asset for ${ asset . id } : ${ asset . originalPath } ` ) ;
return ;
}
}
}
2023-09-27 20:44:51 +02:00
2025-08-27 15:10:55 -04:00
if ( ! isNewMotionAsset ) {
2025-03-06 09:56:35 -05:00
this . logger . debugFn ( ( ) = > {
const base64Checksum = checksum . toString ( 'base64' ) ;
return ` Motion asset with checksum ${ base64Checksum } already exists for asset ${ asset . id } : ${ asset . originalPath } ` ;
} ) ;
2025-08-27 15:10:55 -04:00
}
2024-04-11 06:49:21 -07:00
2025-08-27 15:10:55 -04:00
// Hide the motion photo video asset if it's not already hidden to prepare for linking
if ( motionAsset . visibility === AssetVisibility . Timeline ) {
await this . assetRepository . update ( {
id : motionAsset.id ,
2025-07-15 14:50:13 -04:00
visibility : AssetVisibility.Hidden ,
2023-09-27 20:44:51 +02:00
} ) ;
2025-08-27 15:10:55 -04:00
this . logger . log ( ` Hid unlinked motion photo video asset ( ${ motionAsset . id } ) ` ) ;
2024-04-11 06:49:21 -07:00
}
2024-01-22 10:04:45 -08:00
2024-04-11 06:49:21 -07:00
if ( asset . livePhotoVideoId !== motionAsset . id ) {
await this . assetRepository . update ( { id : asset.id , livePhotoVideoId : motionAsset.id } ) ;
2024-04-16 20:04:59 -07:00
2024-01-22 10:04:45 -08:00
// If the asset already had an associated livePhotoVideo, delete it, because
// its checksum doesn't match the checksum of the motionAsset we just extracted
2024-04-11 06:49:21 -07:00
// (if it did, getByChecksum() would've returned a motionAsset with the same ID as livePhotoVideoId)
// note asset.livePhotoVideoId is not motionAsset.id yet
2024-01-22 10:04:45 -08:00
if ( asset . livePhotoVideoId ) {
2024-06-10 13:04:34 -04:00
await this . jobRepository . queue ( {
2025-07-15 18:39:00 -04:00
name : JobName.AssetDelete ,
2024-06-10 13:04:34 -04:00
data : { id : asset.livePhotoVideoId , deleteOnDisk : true } ,
} ) ;
2024-01-22 10:04:45 -08:00
this . logger . log ( ` Removed old motion photo video asset ( ${ asset . livePhotoVideoId } ) ` ) ;
}
2023-09-27 20:44:51 +02:00
}
2024-05-13 16:38:11 -04:00
// write extracted motion video to disk, especially if the encoded-video folder has been deleted
const existsOnDisk = await this . storageRepository . checkFileExists ( motionAsset . originalPath ) ;
if ( ! existsOnDisk ) {
this . storageCore . ensureFolders ( motionAsset . originalPath ) ;
2024-09-21 00:16:53 +01:00
await this . storageRepository . createFile ( motionAsset . originalPath , video ) ;
2024-05-13 16:38:11 -04:00
this . logger . log ( ` Wrote motion photo video to ${ motionAsset . originalPath } ` ) ;
2025-04-08 16:07:10 -05:00
await this . handleMetadataExtraction ( { id : motionAsset.id } ) ;
2025-07-15 18:39:00 -04:00
await this . jobRepository . queue ( { name : JobName.AssetEncodeVideo , data : { id : motionAsset.id } } ) ;
2024-05-13 16:38:11 -04:00
}
2025-02-20 16:46:18 +01:00
this . logger . debug ( ` Finished motion photo video extraction for asset ${ asset . id } : ${ asset . originalPath } ` ) ;
2023-09-27 20:44:51 +02:00
} catch ( error : Error | any ) {
2025-02-20 16:46:18 +01:00
this . logger . error (
` Failed to extract motion video for ${ asset . id } : ${ asset . originalPath } : ${ error } ` ,
error ? . stack ,
) ;
2023-09-27 20:44:51 +02:00
}
}
2025-03-06 09:56:35 -05:00
private hasTaggedFaces ( tags : ImmichTags ) : tags is ImmichTagsWithFaces {
return (
tags . RegionInfo !== undefined && tags . RegionInfo . AppliedToDimensions && tags . RegionInfo . RegionList . length > 0
) ;
}
2025-05-05 18:11:21 +03:00
private orientRegionInfo (
regionInfo : ImmichTagsWithFaces [ 'RegionInfo' ] ,
orientation : ExifOrientation | undefined ,
) : ImmichTagsWithFaces [ 'RegionInfo' ] {
// skip default Orientation
if ( orientation === undefined || orientation === ExifOrientation . Horizontal ) {
return regionInfo ;
}
const isSidewards = [
ExifOrientation . MirrorHorizontalRotate270CW ,
ExifOrientation . Rotate90CW ,
ExifOrientation . MirrorHorizontalRotate90CW ,
ExifOrientation . Rotate270CW ,
] . includes ( orientation ) ;
// swap image dimensions in AppliedToDimensions if orientation is sidewards
const adjustedAppliedToDimensions = isSidewards
? {
. . . regionInfo . AppliedToDimensions ,
W : regionInfo.AppliedToDimensions.H ,
H : regionInfo.AppliedToDimensions.W ,
}
: regionInfo . AppliedToDimensions ;
// update area coordinates and dimensions in RegionList assuming "normalized" unit as per MWG guidelines
const adjustedRegionList = regionInfo . RegionList . map ( ( region ) = > {
let { X , Y , W , H } = region . Area ;
switch ( orientation ) {
case ExifOrientation . MirrorHorizontal : {
X = 1 - X ;
break ;
}
case ExifOrientation . Rotate180 : {
[ X , Y ] = [ 1 - X , 1 - Y ] ;
break ;
}
case ExifOrientation . MirrorVertical : {
Y = 1 - Y ;
break ;
}
case ExifOrientation . MirrorHorizontalRotate270CW : {
[ X , Y ] = [ Y , X ] ;
break ;
}
case ExifOrientation . Rotate90CW : {
[ X , Y ] = [ 1 - Y , X ] ;
break ;
}
case ExifOrientation . MirrorHorizontalRotate90CW : {
[ X , Y ] = [ 1 - Y , 1 - X ] ;
break ;
}
case ExifOrientation . Rotate270CW : {
[ X , Y ] = [ Y , 1 - X ] ;
break ;
}
}
if ( isSidewards ) {
[ W , H ] = [ H , W ] ;
}
return {
. . . region ,
Area : { . . . region . Area , X , Y , W , H } ,
} ;
} ) ;
return {
. . . regionInfo ,
AppliedToDimensions : adjustedAppliedToDimensions ,
RegionList : adjustedRegionList ,
} ;
}
2025-04-16 20:08:49 +02:00
private async applyTaggedFaces (
asset : { id : string ; ownerId : string ; faces : AssetFace [ ] ; originalPath : string } ,
tags : ImmichTags ,
) {
2024-09-05 00:23:58 +02:00
if ( ! tags . RegionInfo ? . AppliedToDimensions || tags . RegionInfo . RegionList . length === 0 ) {
return ;
}
2025-06-30 13:19:16 -04:00
const facesToAdd : ( Insertable < AssetFaceTable > & { assetId : string } ) [ ] = [ ] ;
2024-09-05 00:23:58 +02:00
const existingNames = await this . personRepository . getDistinctNames ( asset . ownerId , { withHidden : true } ) ;
const existingNameMap = new Map ( existingNames . map ( ( { id , name } ) = > [ name . toLowerCase ( ) , id ] ) ) ;
2025-06-30 13:19:16 -04:00
const missing : ( Insertable < PersonTable > & { ownerId : string } ) [ ] = [ ] ;
2025-01-21 19:12:28 +01:00
const missingWithFaceAsset : { id : string ; ownerId : string ; faceAssetId : string } [ ] = [ ] ;
2025-05-05 18:11:21 +03:00
const adjustedRegionInfo = this . orientRegionInfo ( tags . RegionInfo , tags . Orientation ) ;
const imageWidth = adjustedRegionInfo . AppliedToDimensions . W ;
const imageHeight = adjustedRegionInfo . AppliedToDimensions . H ;
for ( const region of adjustedRegionInfo . RegionList ) {
2024-09-05 00:23:58 +02:00
if ( ! region . Name ) {
continue ;
}
const loweredName = region . Name . toLowerCase ( ) ;
const personId = existingNameMap . get ( loweredName ) || this . cryptoRepository . randomUUID ( ) ;
const face = {
id : this.cryptoRepository.randomUUID ( ) ,
personId ,
assetId : asset.id ,
imageWidth ,
imageHeight ,
boundingBoxX1 : Math.floor ( ( region . Area . X - region . Area . W / 2 ) * imageWidth ) ,
boundingBoxY1 : Math.floor ( ( region . Area . Y - region . Area . H / 2 ) * imageHeight ) ,
boundingBoxX2 : Math.floor ( ( region . Area . X + region . Area . W / 2 ) * imageWidth ) ,
boundingBoxY2 : Math.floor ( ( region . Area . Y + region . Area . H / 2 ) * imageHeight ) ,
2025-07-15 14:50:13 -04:00
sourceType : SourceType.Exif ,
2024-09-05 00:23:58 +02:00
} ;
2024-10-03 21:58:28 -04:00
facesToAdd . push ( face ) ;
2024-09-05 00:23:58 +02:00
if ( ! existingNameMap . has ( loweredName ) ) {
missing . push ( { id : personId , ownerId : asset.ownerId , name : region.Name } ) ;
2025-01-21 19:12:28 +01:00
missingWithFaceAsset . push ( { id : personId , ownerId : asset.ownerId , faceAssetId : face.id } ) ;
2024-09-05 00:23:58 +02:00
}
}
if ( missing . length > 0 ) {
2025-03-06 09:56:35 -05:00
this . logger . debugFn ( ( ) = > ` Creating missing persons: ${ missing . map ( ( p ) = > ` ${ p . name } / ${ p . id } ` ) } ` ) ;
2024-10-03 21:58:28 -04:00
const newPersonIds = await this . personRepository . createAll ( missing ) ;
2025-07-15 18:39:00 -04:00
const jobs = newPersonIds . map ( ( id ) = > ( { name : JobName.PersonGenerateThumbnail , data : { id } } ) as const ) ;
2024-10-03 21:58:28 -04:00
await this . jobRepository . queueAll ( jobs ) ;
2024-09-05 00:23:58 +02:00
}
2025-07-15 14:50:13 -04:00
const facesToRemove = asset . faces . filter ( ( face ) = > face . sourceType === SourceType . Exif ) . map ( ( face ) = > face . id ) ;
2024-10-03 21:58:28 -04:00
if ( facesToRemove . length > 0 ) {
2025-02-20 16:46:18 +01:00
this . logger . debug ( ` Removing ${ facesToRemove . length } faces for asset ${ asset . id } : ${ asset . originalPath } ` ) ;
2024-10-03 21:58:28 -04:00
}
2024-09-05 00:23:58 +02:00
2024-10-03 21:58:28 -04:00
if ( facesToAdd . length > 0 ) {
2025-02-20 16:46:18 +01:00
this . logger . debug (
` Creating ${ facesToAdd . length } faces from metadata for asset ${ asset . id } : ${ asset . originalPath } ` ,
) ;
2024-10-03 21:58:28 -04:00
}
2024-09-05 00:23:58 +02:00
2024-10-03 21:58:28 -04:00
if ( facesToRemove . length > 0 || facesToAdd . length > 0 ) {
await this . personRepository . refreshFaces ( facesToAdd , facesToRemove ) ;
}
2024-09-05 00:23:58 +02:00
2024-10-03 21:58:28 -04:00
if ( missingWithFaceAsset . length > 0 ) {
await this . personRepository . updateAll ( missingWithFaceAsset ) ;
}
2024-09-05 00:23:58 +02:00
}
2025-09-04 21:18:12 +02:00
private getDates (
asset : { id : string ; originalPath : string ; fileCreatedAt : Date } ,
exifTags : ImmichTags ,
stats : Stats ,
) {
2025-06-26 11:18:40 -04:00
const result = firstDateTime ( exifTags ) ;
const tag = result ? . tag ;
const dateTime = result ? . dateTime ;
this . logger . verbose (
` Date and time is ${ dateTime } using exifTag ${ tag } for asset ${ asset . id } : ${ asset . originalPath } ` ,
) ;
2023-11-21 17:58:56 +01:00
2024-09-07 13:39:10 -04:00
// timezone
let timeZone = exifTags . tz ? ? null ;
if ( timeZone == null && dateTime ? . rawValue ? . endsWith ( '+00:00' ) ) {
// exiftool-vendored returns "no timezone" information even though "+00:00" might be set explicitly
// https://github.com/photostructure/exiftool-vendored.js/issues/203
timeZone = 'UTC+0' ;
}
2023-09-27 20:44:51 +02:00
2024-09-07 13:39:10 -04:00
if ( timeZone ) {
2025-02-20 16:46:18 +01:00
this . logger . verbose (
` Found timezone ${ timeZone } via ${ exifTags . tzSource } for asset ${ asset . id } : ${ asset . originalPath } ` ,
) ;
2024-09-07 13:39:10 -04:00
} else {
2025-02-20 16:46:18 +01:00
this . logger . debug ( ` No timezone information found for asset ${ asset . id } : ${ asset . originalPath } ` ) ;
2024-09-07 13:39:10 -04:00
}
2023-09-27 20:44:51 +02:00
2024-10-08 12:10:52 -04:00
let dateTimeOriginal = dateTime ? . toDate ( ) ;
let localDateTime = dateTime ? . toDateTime ( ) . setZone ( 'UTC' , { keepLocalTime : true } ) . toJSDate ( ) ;
if ( ! localDateTime || ! dateTimeOriginal ) {
2025-04-01 18:24:07 -04:00
// FileCreateDate is not available on linux, likely because exiftool hasn't integrated the statx syscall yet
// birthtime is not available in Docker on macOS, so it appears as 0
2025-09-04 21:18:12 +02:00
const earliestDate = new Date (
Math . min (
asset . fileCreatedAt . getTime ( ) ,
stats . birthtimeMs ? Math . min ( stats . mtimeMs , stats . birthtimeMs ) : stats . mtime . getTime ( ) ,
) ,
) ;
2025-01-02 05:50:15 +01:00
this . logger . debug (
2025-04-01 18:24:07 -04:00
` No exif date time found, falling back on ${ earliestDate . toISOString ( ) } , earliest of file creation and modification for asset ${ asset . id } : ${ asset . originalPath } ` ,
2025-01-02 05:50:15 +01:00
) ;
2025-04-01 18:24:07 -04:00
dateTimeOriginal = localDateTime = earliestDate ;
2024-09-07 13:39:10 -04:00
}
2024-09-04 16:27:04 +02:00
2025-02-20 16:46:18 +01:00
this . logger . verbose (
` Found local date time ${ localDateTime . toISOString ( ) } for asset ${ asset . id } : ${ asset . originalPath } ` ,
) ;
2024-10-08 12:10:52 -04:00
2024-09-07 13:39:10 -04:00
return {
2024-09-04 16:27:04 +02:00
dateTimeOriginal ,
timeZone ,
2024-09-07 13:39:10 -04:00
localDateTime ,
2023-09-27 20:44:51 +02:00
} ;
2024-09-07 13:39:10 -04:00
}
2025-03-06 09:56:35 -05:00
private hasGeo ( tags : ImmichTags ) : tags is ImmichTags & { GPSLatitude : number ; GPSLongitude : number } {
return (
tags . GPSLatitude !== undefined &&
tags . GPSLongitude !== undefined &&
( tags . GPSLatitude !== 0 || tags . GPSLatitude !== 0 )
) ;
2023-09-27 20:44:51 +02:00
}
2024-01-27 18:52:14 +00:00
private getAutoStackId ( tags : ImmichTags | null ) : string | null {
if ( ! tags ) {
return null ;
}
return tags . BurstID ? ? tags . BurstUUID ? ? tags . CameraBurstID ? ? tags . MediaUniqueID ? ? null ;
}
2023-09-27 20:44:51 +02:00
private getBitsPerSample ( tags : ImmichTags ) : number | null {
const bitDepthTags = [
tags . BitsPerSample ,
tags . ComponentBitDepth ,
tags . ImagePixelDepth ,
tags . BitDepth ,
tags . ColorBitDepth ,
// `numericTags` doesn't parse values like '12 12 12'
] . map ( ( tag ) = > ( typeof tag === 'string' ? Number . parseInt ( tag ) : tag ) ) ;
let bitsPerSample = bitDepthTags . find ( ( tag ) = > typeof tag === 'number' && ! Number . isNaN ( tag ) ) ? ? null ;
if ( bitsPerSample && bitsPerSample >= 24 && bitsPerSample % 3 === 0 ) {
bitsPerSample /= 3 ; // converts per-pixel bit depth to per-channel
}
return bitsPerSample ;
}
2024-09-07 13:39:10 -04:00
private async getVideoTags ( originalPath : string ) {
const { videoStreams , format } = await this . mediaRepository . probe ( originalPath ) ;
const tags : Pick < ImmichTags , 'Duration' | 'Orientation' > = { } ;
2024-02-02 14:58:13 -06:00
2024-08-06 18:27:05 +02:00
if ( videoStreams [ 0 ] ) {
switch ( videoStreams [ 0 ] . rotation ) {
case - 90 : {
2024-11-08 01:30:59 -05:00
tags . Orientation = ExifOrientation . Rotate90CW ;
2024-08-06 18:27:05 +02:00
break ;
}
case 0 : {
2024-11-08 01:30:59 -05:00
tags . Orientation = ExifOrientation . Horizontal ;
2024-08-06 18:27:05 +02:00
break ;
}
case 90 : {
2024-11-08 01:30:59 -05:00
tags . Orientation = ExifOrientation . Rotate270CW ;
2024-08-06 18:27:05 +02:00
break ;
}
case 180 : {
2024-11-08 01:30:59 -05:00
tags . Orientation = ExifOrientation . Rotate180 ;
2024-08-06 18:27:05 +02:00
break ;
}
}
2023-10-19 14:51:56 -04:00
}
2024-02-02 14:58:13 -06:00
2024-08-06 18:27:05 +02:00
if ( format . duration ) {
2024-09-07 13:39:10 -04:00
tags . Duration = Duration . fromObject ( { seconds : format.duration } ) . toFormat ( 'hh:mm:ss.SSS' ) ;
2024-08-06 18:27:05 +02:00
}
2024-09-07 13:39:10 -04:00
return tags ;
2023-09-27 20:44:51 +02:00
}
2023-05-26 08:52:52 -04:00
}