1
0
forked from Cutlery/immich
immich-quadlet/server/src/microservices/processors/metadata-extraction.processor.ts
Jonathan Jogenfors acdc66413c
feat(server,web): libraries (#3124)
* feat: libraries

Co-authored-by: Jason Rasmussen <jrasm91@gmail.com>
Co-authored-by: Alex <alex.tran1502@gmail.com>
2023-09-20 13:16:33 +02:00

314 lines
10 KiB
TypeScript

import {
IAlbumRepository,
IAssetRepository,
IBaseJob,
ICryptoRepository,
IEntityJob,
IGeocodingRepository,
IJobRepository,
IStorageRepository,
JobName,
JOBS_ASSET_PAGINATION_SIZE,
QueueName,
StorageCore,
StorageFolder,
usePagination,
WithoutProperty,
} from '@app/domain';
import { AssetEntity, AssetType, ExifEntity } from '@app/infra/entities';
import { Inject, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { DefaultReadTaskOptions, ExifDateTime, exiftool, ReadTaskOptions, Tags } from 'exiftool-vendored';
import { firstDateTime } from 'exiftool-vendored/dist/FirstDateTime';
import * as geotz from 'geo-tz';
import { Duration } from 'luxon';
import fs from 'node:fs/promises';
import path from 'node:path';
interface DirectoryItem {
Length?: number;
Mime: string;
Padding?: number;
Semantic?: string;
}
interface DirectoryEntry {
Item: DirectoryItem;
}
interface ImmichTags extends Tags {
ContentIdentifier?: string;
MotionPhoto?: number;
MotionPhotoVersion?: number;
MotionPhotoPresentationTimestampUs?: number;
MediaGroupUUID?: string;
}
const exifDate = (dt: ExifDateTime | string | undefined) => (dt instanceof ExifDateTime ? dt?.toDate() : null);
const validate = <T>(value: T): T | null => (typeof value === 'string' ? null : value ?? null);
export class MetadataExtractionProcessor {
private logger = new Logger(MetadataExtractionProcessor.name);
private reverseGeocodingEnabled: boolean;
private storageCore = new StorageCore();
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,
@Inject(IStorageRepository) private storageRepository: IStorageRepository,
configService: ConfigService,
) {
this.reverseGeocodingEnabled = !configService.get('DISABLE_REVERSE_GEOCODING');
}
async init(deleteCache = false) {
this.logger.log(`Reverse geocoding is ${this.reverseGeocodingEnabled ? 'enabled' : 'disabled'}`);
if (!this.reverseGeocodingEnabled) {
return;
}
try {
if (deleteCache) {
await this.geocodingRepository.deleteCache();
}
this.logger.log('Initializing Reverse Geocoding');
await this.jobRepository.pause(QueueName.METADATA_EXTRACTION);
await this.geocodingRepository.init();
await this.jobRepository.resume(QueueName.METADATA_EXTRACTION);
this.logger.log('Reverse Geocoding Initialized');
} catch (error: any) {
this.logger.error(`Unable to initialize reverse geocoding: ${error}`, error?.stack);
}
}
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) => {
return force
? this.assetRepository.getAll(pagination)
: this.assetRepository.getWithout(pagination, WithoutProperty.EXIF);
});
for await (const assets of assetPagination) {
for (const asset of assets) {
await this.jobRepository.queue({ name: JobName.METADATA_EXTRACTION, data: { id: asset.id } });
}
}
return true;
}
async handleMetadataExtraction({ id }: IEntityJob) {
const [asset] = await this.assetRepository.getByIds([id]);
if (!asset || !asset.isVisible) {
return false;
}
const [exifData, tags] = await this.exifData(asset);
await this.applyMotionPhotos(asset, tags);
await this.applyReverseGeocoding(asset, exifData);
await this.assetRepository.upsertExif(exifData);
await this.assetRepository.save({
id: asset.id,
duration: tags.Duration ? Duration.fromObject({ seconds: tags.Duration }).toFormat('hh:mm:ss.SSS') : null,
fileCreatedAt: exifData.dateTimeOriginal ?? undefined,
});
return true;
}
private async applyReverseGeocoding(asset: AssetEntity, exifData: ExifEntity) {
const { latitude, longitude } = exifData;
if (!this.reverseGeocodingEnabled || !longitude || !latitude) {
return;
}
try {
const { city, state, country } = await this.geocodingRepository.reverseGeocode({ latitude, longitude });
Object.assign(exifData, { city, state, country });
} catch (error: Error | any) {
this.logger.warn(
`Unable to run reverse geocoding due to ${error} for asset ${asset.id} at ${asset.originalPath}`,
error?.stack,
);
}
}
private async applyMotionPhotos(asset: AssetEntity, tags: ImmichTags) {
if (asset.type !== AssetType.IMAGE || asset.livePhotoVideoId) {
return;
}
const rawDirectory = tags.Directory;
const isMotionPhoto = tags.MotionPhoto;
const isMicroVideo = tags.MicroVideo;
const videoOffset = tags.MicroVideoOffset;
const directory = Array.isArray(rawDirectory) ? (rawDirectory as DirectoryEntry[]) : null;
let length = 0;
let padding = 0;
if (isMotionPhoto && directory) {
for (const entry of directory) {
if (entry.Item.Semantic == 'MotionPhoto') {
length = entry.Item.Length ?? 0;
padding = entry.Item.Padding ?? 0;
break;
}
}
}
if (isMicroVideo && typeof videoOffset === 'number') {
length = videoOffset;
}
if (!length) {
return;
}
this.logger.debug(`Starting motion photo video extraction (${asset.id})`);
let file = null;
try {
const encodedFolder = this.storageCore.getFolderLocation(StorageFolder.ENCODED_VIDEO, asset.ownerId);
const encodedFile = path.join(encodedFolder, path.parse(asset.originalPath).name + '.mp4');
this.storageRepository.mkdirSync(encodedFolder);
file = await fs.open(asset.originalPath);
const stat = await file.stat();
const position = stat.size - length - padding;
const video = await file.read({ buffer: Buffer.alloc(length), position, length });
const checksum = await this.cryptoRepository.hashSha1(video.buffer);
let motionAsset = await this.assetRepository.getByChecksum(asset.ownerId, checksum);
if (!motionAsset) {
motionAsset = await this.assetRepository.save({
type: AssetType.VIDEO,
fileCreatedAt: asset.fileCreatedAt ?? asset.createdAt,
fileModifiedAt: asset.fileModifiedAt,
checksum,
ownerId: asset.ownerId,
originalPath: encodedFile,
originalFileName: asset.originalFileName,
isVisible: false,
isReadOnly: true,
deviceAssetId: 'NONE',
deviceId: 'NONE',
});
await fs.writeFile(encodedFile, video.buffer);
await this.jobRepository.queue({ name: JobName.METADATA_EXTRACTION, data: { id: motionAsset.id } });
}
await this.assetRepository.save({ id: asset.id, livePhotoVideoId: motionAsset.id });
this.logger.debug(`Finished motion photo video extraction (${asset.id})`);
} catch (error: Error | any) {
this.logger.error(`Failed to extract live photo ${asset.originalPath}: ${error}`, error?.stack);
} finally {
await file?.close();
}
}
private async exifData(asset: AssetEntity): Promise<[ExifEntity, ImmichTags]> {
const readTaskOptions: ReadTaskOptions = {
...DefaultReadTaskOptions,
defaultVideosToUTC: true,
backfillTimezones: true,
inferTimezoneFromDatestamps: true,
useMWG: true,
numericTags: DefaultReadTaskOptions.numericTags.concat(['FocalLength']),
geoTz: (lat: number, lon: number): string => geotz.find(lat, lon)[0],
};
const mediaTags = await exiftool
.read<ImmichTags>(asset.originalPath, undefined, readTaskOptions)
.catch((error: any) => {
this.logger.warn(`error reading exif data (${asset.id} at ${asset.originalPath}): ${error}`, error?.stack);
return null;
});
const sidecarTags = asset.sidecarPath
? await exiftool.read<ImmichTags>(asset.sidecarPath, undefined, readTaskOptions).catch((error: any) => {
this.logger.warn(`error reading exif data (${asset.id} at ${asset.sidecarPath}): ${error}`, error?.stack);
return null;
})
: null;
const stats = await fs.stat(asset.originalPath);
const tags = { ...mediaTags, ...sidecarTags };
this.logger.verbose('Exif Tags', tags);
return [
<ExifEntity>{
// altitude: tags.GPSAltitude ?? null,
assetId: asset.id,
dateTimeOriginal: exifDate(firstDateTime(tags)) ?? asset.fileCreatedAt,
exifImageHeight: validate(tags.ImageHeight),
exifImageWidth: validate(tags.ImageWidth),
exposureTime: tags.ExposureTime ?? null,
fileSizeInByte: stats.size,
fNumber: validate(tags.FNumber),
focalLength: validate(tags.FocalLength),
fps: validate(tags.VideoFrameRate),
iso: validate(tags.ISO),
latitude: validate(tags.GPSLatitude),
lensModel: tags.LensModel ?? null,
livePhotoCID: (asset.type === AssetType.VIDEO ? tags.ContentIdentifier : tags.MediaGroupUUID) ?? null,
longitude: validate(tags.GPSLongitude),
make: tags.Make ?? null,
model: tags.Model ?? null,
modifyDate: exifDate(tags.ModifyDate) ?? asset.fileModifiedAt,
orientation: validate(tags.Orientation)?.toString() ?? null,
projectionType: tags.ProjectionType ? String(tags.ProjectionType).toUpperCase() : null,
timeZone: tags.tz,
},
tags,
];
}
}