1
0
Fork 0
mirror of https://github.com/immich-app/immich.git synced 2024-12-29 15:11:58 +00:00

optimize metadata extraction

This commit is contained in:
mertalev 2024-11-21 01:43:48 -05:00
parent 6ec94f94f1
commit 03dffc8414
No known key found for this signature in database
GPG key ID: 3A2B5BFC678DBC80
2 changed files with 115 additions and 82 deletions

View file

@ -240,7 +240,6 @@ export class JobService extends BaseService {
this.eventRepository.clientSend('on_asset_update', asset.ownerId, mapAsset(asset)); this.eventRepository.clientSend('on_asset_update', asset.ownerId, mapAsset(asset));
} }
} }
await this.jobRepository.queue({ name: JobName.LINK_LIVE_PHOTOS, data: item.data });
break; break;
} }

View file

@ -5,7 +5,6 @@ import _ from 'lodash';
import { Duration } from 'luxon'; import { Duration } from 'luxon';
import { constants } from 'node:fs/promises'; import { constants } from 'node:fs/promises';
import path from 'node:path'; import path from 'node:path';
import { SystemConfig } from 'src/config';
import { StorageCore } from 'src/cores/storage.core'; import { StorageCore } from 'src/cores/storage.core';
import { OnEvent, OnJob } from 'src/decorators'; import { OnEvent, OnJob } from 'src/decorators';
import { AssetFaceEntity } from 'src/entities/asset-face.entity'; import { AssetFaceEntity } from 'src/entities/asset-face.entity';
@ -66,6 +65,8 @@ const validateRange = (value: number | undefined, min: number, max: number): Non
return val; return val;
}; };
type ImmichTagsWithFaces = ImmichTags & { RegionInfo: NonNullable<ImmichTags['RegionInfo']> };
@Injectable() @Injectable()
export class MetadataService extends BaseService { export class MetadataService extends BaseService {
@OnEvent({ name: 'app.bootstrap', workers: [ImmichWorker.MICROSERVICES] }) @OnEvent({ name: 'app.bootstrap', workers: [ImmichWorker.MICROSERVICES] })
@ -101,13 +102,17 @@ export class MetadataService extends BaseService {
return JobStatus.FAILED; return JobStatus.FAILED;
} }
if (!asset.exifInfo.livePhotoCID) { return this.linkLivePhotos(asset, asset.exifInfo);
}
private async linkLivePhotos(asset: AssetEntity, exifInfo: ExifEntity): Promise<JobStatus> {
if (!exifInfo.livePhotoCID) {
return JobStatus.SKIPPED; return JobStatus.SKIPPED;
} }
const otherType = asset.type === AssetType.VIDEO ? AssetType.IMAGE : AssetType.VIDEO; const otherType = asset.type === AssetType.VIDEO ? AssetType.IMAGE : AssetType.VIDEO;
const match = await this.assetRepository.findLivePhotoMatch({ const match = await this.assetRepository.findLivePhotoMatch({
livePhotoCID: asset.exifInfo.livePhotoCID, livePhotoCID: exifInfo.livePhotoCID,
ownerId: asset.ownerId, ownerId: asset.ownerId,
libraryId: asset.libraryId, libraryId: asset.libraryId,
otherAssetId: asset.id, otherAssetId: asset.id,
@ -119,10 +124,11 @@ export class MetadataService extends BaseService {
} }
const [photoAsset, motionAsset] = asset.type === AssetType.IMAGE ? [asset, match] : [match, asset]; const [photoAsset, motionAsset] = asset.type === AssetType.IMAGE ? [asset, match] : [match, asset];
await Promise.all([
await this.assetRepository.update({ id: photoAsset.id, livePhotoVideoId: motionAsset.id }); this.assetRepository.update({ id: photoAsset.id, livePhotoVideoId: motionAsset.id }),
await this.assetRepository.update({ id: motionAsset.id, isVisible: false }); this.assetRepository.update({ id: motionAsset.id, isVisible: false }),
await this.albumRepository.removeAsset(motionAsset.id); this.albumRepository.removeAsset(motionAsset.id),
]);
await this.eventRepository.emit('asset.hide', { assetId: motionAsset.id, userId: motionAsset.ownerId }); await this.eventRepository.emit('asset.hide', { assetId: motionAsset.id, userId: motionAsset.ownerId });
@ -148,25 +154,37 @@ export class MetadataService extends BaseService {
} }
@OnJob({ name: JobName.METADATA_EXTRACTION, queue: QueueName.METADATA_EXTRACTION }) @OnJob({ name: JobName.METADATA_EXTRACTION, queue: QueueName.METADATA_EXTRACTION })
async handleMetadataExtraction({ id }: JobOf<JobName.METADATA_EXTRACTION>): Promise<JobStatus> { async handleMetadataExtraction(data: JobOf<JobName.METADATA_EXTRACTION>): Promise<JobStatus> {
const { metadata, reverseGeocoding } = await this.getConfig({ withCache: true }); const [{ metadata, reverseGeocoding }, [asset]] = await Promise.all([
const [asset] = await this.assetRepository.getByIds([id], { faces: { person: false } }); this.getConfig({ withCache: true }),
this.assetRepository.getByIds([data.id], { faces: { person: false } }),
]);
if (!asset) { if (!asset) {
return JobStatus.FAILED; return JobStatus.FAILED;
} }
const stats = await this.storageRepository.stat(asset.originalPath); const [stats, exifTags] = await Promise.all([
this.storageRepository.stat(asset.originalPath),
const exifTags = await this.getExifTags(asset); this.getExifTags(asset),
]);
this.logger.verbose('Exif Tags', exifTags); this.logger.verbose('Exif Tags', exifTags);
const { dateTimeOriginal, localDateTime, timeZone, modifyDate } = this.getDates(asset, exifTags); const { dateTimeOriginal, localDateTime, timeZone, modifyDate } = this.getDates(asset, exifTags);
const { latitude, longitude, country, state, city } = await this.getGeo(exifTags, reverseGeocoding);
const { width, height } = this.getImageDimensions(exifTags); const { width, height } = this.getImageDimensions(exifTags);
const exifData: Partial<ExifEntity> = { let geo: ReverseGeocodeResult = { country: null, state: null, city: null };
let latitude: number | null = null;
let longitude: number | null = null;
if (reverseGeocoding.enabled && this.hasGeo(exifTags)) {
latitude = exifTags.GPSLatitude;
longitude = exifTags.GPSLongitude;
geo = await this.mapRepository.reverseGeocode({ latitude, longitude });
}
const livePhotoCID = (exifTags.ContentIdentifier || exifTags.MediaGroupUUID) ?? null;
const exifData: ExifEntity = {
assetId: asset.id, assetId: asset.id,
// dates // dates
@ -175,11 +193,11 @@ export class MetadataService extends BaseService {
timeZone, timeZone,
// gps // gps
latitude, latitude: exifTags.GPSLatitude ?? null,
longitude, longitude: exifTags.GPSLongitude ?? null,
country, country: geo.country,
state, state: geo.state,
city, city: geo.city,
// image/file // image/file
fileSizeInByte: stats.size, fileSizeInByte: stats.size,
@ -206,31 +224,42 @@ export class MetadataService extends BaseService {
rating: validateRange(exifTags.Rating, 0, 5), rating: validateRange(exifTags.Rating, 0, 5),
// grouping // grouping
livePhotoCID: (exifTags.ContentIdentifier || exifTags.MediaGroupUUID) ?? null, livePhotoCID,
autoStackId: this.getAutoStackId(exifTags), autoStackId: this.getAutoStackId(exifTags),
}; };
await this.applyTagList(asset, exifTags); const promises: Promise<unknown>[] = [
await this.applyMotionPhotos(asset, exifTags); this.assetRepository.upsertExif(exifData),
this.assetRepository.update({
id: asset.id,
duration: exifTags.Duration?.toString() ?? null,
localDateTime,
fileCreatedAt: exifData.dateTimeOriginal ?? undefined,
}),
];
await this.assetRepository.upsertExif(exifData); if (this.hasTagList(exifTags)) {
promises.push(this.applyTagList(asset, exifTags));
await this.assetRepository.update({
id: asset.id,
duration: exifTags.Duration?.toString() ?? null,
localDateTime,
fileCreatedAt: exifData.dateTimeOriginal ?? undefined,
});
await this.assetRepository.upsertJobStatus({
assetId: asset.id,
metadataExtractedAt: new Date(),
});
if (isFaceImportEnabled(metadata)) {
await this.applyTaggedFaces(asset, exifTags);
} }
if (asset.type === AssetType.IMAGE) {
promises.push(this.applyMotionPhotos(asset, exifTags));
}
if (isFaceImportEnabled(metadata) && this.hasTaggedFaces(exifTags)) {
promises.push(this.applyTaggedFaces(asset, exifTags));
}
if (livePhotoCID) {
promises.push(this.linkLivePhotos(asset, exifData));
}
await Promise.all(promises);
await Promise.all([
this.assetRepository.upsertJobStatus({ assetId: asset.id, metadataExtractedAt: new Date() }),
this.jobRepository.queue({ name: JobName.STORAGE_TEMPLATE_MIGRATION_SINGLE, data }),
]);
return JobStatus.SUCCESS; return JobStatus.SUCCESS;
} }
@ -326,45 +355,60 @@ export class MetadataService extends BaseService {
} }
private async getExifTags(asset: AssetEntity): Promise<ImmichTags> { private async getExifTags(asset: AssetEntity): Promise<ImmichTags> {
const mediaTags = await this.metadataRepository.readTags(asset.originalPath); const promises = [this.metadataRepository.readTags(asset.originalPath)];
const sidecarTags = asset.sidecarPath ? await this.metadataRepository.readTags(asset.sidecarPath) : {}; if (asset.sidecarPath) {
const videoTags = asset.type === AssetType.VIDEO ? await this.getVideoTags(asset.originalPath) : {}; promises.push(this.metadataRepository.readTags(asset.sidecarPath));
}
if (asset.type === AssetType.VIDEO) {
promises.push(this.getVideoTags(asset.originalPath));
}
const [mediaTags, sidecarTags, videoTags] = await Promise.all(promises);
if (!sidecarTags && !videoTags) {
return mediaTags;
}
// prefer dates from sidecar tags // prefer dates from sidecar tags
const sidecarDate = firstDateTime(sidecarTags as Tags, EXIF_DATE_TAGS); if (sidecarTags) {
if (sidecarDate) { const sidecarDate = firstDateTime(sidecarTags as Tags, EXIF_DATE_TAGS);
for (const tag of EXIF_DATE_TAGS) { if (sidecarDate) {
delete mediaTags[tag]; for (const tag of EXIF_DATE_TAGS) {
delete mediaTags[tag];
}
} }
} }
// prefer duration from video tags // prefer duration from video tags
delete mediaTags.Duration; delete mediaTags.Duration;
delete sidecarTags.Duration; delete sidecarTags?.Duration;
return { ...mediaTags, ...videoTags, ...sidecarTags }; return { ...mediaTags, ...videoTags, ...sidecarTags };
} }
private hasTagList(tags: ImmichTags): tags is ImmichTags & { TagsList: string[] } {
return tags.TagsList !== undefined || tags.HierarchicalSubject !== undefined || tags.Keywords !== undefined;
}
private async applyTagList(asset: AssetEntity, exifTags: ImmichTags) { private async applyTagList(asset: AssetEntity, exifTags: ImmichTags) {
const tags: string[] = []; let tags: string[] = [];
if (exifTags.TagsList) { if (exifTags.TagsList) {
tags.push(...exifTags.TagsList.map(String)); tags = exifTags.TagsList.map(String);
} else if (exifTags.HierarchicalSubject) { } else if (exifTags.HierarchicalSubject) {
tags.push( tags = exifTags.HierarchicalSubject.map((tag) =>
...exifTags.HierarchicalSubject.map((tag) => typeof tag === 'number'
String(tag) ? String(tag)
// convert | to / : tag
.replaceAll('/', '<PLACEHOLDER>') // convert | to /
.replaceAll('|', '/') .replaceAll('/', '<PLACEHOLDER>')
.replaceAll('<PLACEHOLDER>', '|'), .replaceAll('|', '/')
), .replaceAll('<PLACEHOLDER>', '|'),
); );
} else if (exifTags.Keywords) { } else if (exifTags.Keywords) {
let keywords = exifTags.Keywords; let keywords = exifTags.Keywords;
if (!Array.isArray(keywords)) { if (!Array.isArray(keywords)) {
keywords = [keywords]; keywords = [keywords];
} }
tags.push(...keywords.map(String)); tags = keywords.map(String);
} }
const results = await upsertTags(this.tagRepository, { userId: asset.ownerId, tags }); const results = await upsertTags(this.tagRepository, { userId: asset.ownerId, tags });
@ -503,11 +547,13 @@ export class MetadataService extends BaseService {
} }
} }
private async applyTaggedFaces(asset: AssetEntity, tags: ImmichTags) { private hasTaggedFaces(tags: ImmichTags): tags is ImmichTagsWithFaces {
if (!tags.RegionInfo?.AppliedToDimensions || tags.RegionInfo.RegionList.length === 0) { return (
return; tags.RegionInfo !== undefined && tags.RegionInfo.AppliedToDimensions && tags.RegionInfo.RegionList.length > 0
} );
}
private async applyTaggedFaces(asset: AssetEntity, tags: ImmichTagsWithFaces) {
const facesToAdd: Partial<AssetFaceEntity>[] = []; const facesToAdd: Partial<AssetFaceEntity>[] = [];
const existingNames = await this.personRepository.getDistinctNames(asset.ownerId, { withHidden: true }); const existingNames = await this.personRepository.getDistinctNames(asset.ownerId, { withHidden: true });
const existingNameMap = new Map(existingNames.map(({ id, name }) => [name.toLowerCase(), id])); const existingNameMap = new Map(existingNames.map(({ id, name }) => [name.toLowerCase(), id]));
@ -609,24 +655,12 @@ export class MetadataService extends BaseService {
}; };
} }
private async getGeo(tags: ImmichTags, reverseGeocoding: SystemConfig['reverseGeocoding']) { private hasGeo(tags: ImmichTags): tags is ImmichTags & { GPSLatitude: number; GPSLongitude: number } {
let latitude = validate(tags.GPSLatitude); return (
let longitude = validate(tags.GPSLongitude); tags.GPSLatitude !== undefined &&
tags.GPSLongitude !== undefined &&
// TODO take ref into account (tags.GPSLatitude !== 0 || tags.GPSLatitude !== 0)
);
if (latitude === 0 && longitude === 0) {
this.logger.warn('Latitude and longitude of 0, setting to null');
latitude = null;
longitude = null;
}
let result: ReverseGeocodeResult = { country: null, state: null, city: null };
if (reverseGeocoding.enabled && longitude && latitude) {
result = await this.mapRepository.reverseGeocode({ latitude, longitude });
}
return { ...result, latitude, longitude };
} }
private getAutoStackId(tags: ImmichTags | null): string | null { private getAutoStackId(tags: ImmichTags | null): string | null {