2023-03-02 21:47:08 -05:00
|
|
|
import {
|
2023-08-15 21:34:57 -04:00
|
|
|
IAlbumRepository,
|
2023-03-02 21:47:08 -05:00
|
|
|
IAssetRepository,
|
2023-03-20 11:55:28 -04:00
|
|
|
IBaseJob,
|
2023-07-22 13:52:59 +10:00
|
|
|
ICryptoRepository,
|
2023-05-26 15:43:24 -04:00
|
|
|
IEntityJob,
|
2023-04-04 18:23:07 -04:00
|
|
|
IGeocodingRepository,
|
2023-03-05 15:44:31 -05:00
|
|
|
IJobRepository,
|
2023-07-22 13:52:59 +10:00
|
|
|
IStorageRepository,
|
2023-03-02 21:47:08 -05:00
|
|
|
JobName,
|
2023-05-22 20:05:06 +02:00
|
|
|
JOBS_ASSET_PAGINATION_SIZE,
|
2023-03-02 21:47:08 -05:00
|
|
|
QueueName,
|
2023-07-22 13:52:59 +10:00
|
|
|
StorageCore,
|
|
|
|
|
StorageFolder,
|
2023-05-22 20:05:06 +02:00
|
|
|
usePagination,
|
2023-03-20 11:55:28 -04:00
|
|
|
WithoutProperty,
|
2023-03-02 21:47:08 -05:00
|
|
|
} from '@app/domain';
|
2023-04-11 08:53:42 -05:00
|
|
|
import { AssetEntity, AssetType, ExifEntity } from '@app/infra/entities';
|
2023-02-25 09:12:03 -05:00
|
|
|
import { Inject, Logger } from '@nestjs/common';
|
2022-09-13 12:09:57 -05:00
|
|
|
import { ConfigService } from '@nestjs/config';
|
2022-08-23 21:34:21 +07:00
|
|
|
import { InjectRepository } from '@nestjs/typeorm';
|
2023-04-04 18:23:07 -04:00
|
|
|
import tz_lookup from '@photostructure/tz-lookup';
|
2023-06-04 04:55:30 +02:00
|
|
|
import { exiftool, Tags } from 'exiftool-vendored';
|
2023-03-03 17:49:22 -05:00
|
|
|
import ffmpeg, { FfprobeData } from 'fluent-ffmpeg';
|
2023-03-23 15:27:29 +01:00
|
|
|
import { Duration } from 'luxon';
|
2023-03-02 21:47:08 -05:00
|
|
|
import fs from 'node:fs';
|
2023-07-22 13:52:59 +10:00
|
|
|
import path from 'node:path';
|
2022-08-29 03:43:31 +07:00
|
|
|
import sharp from 'sharp';
|
2022-08-23 21:34:21 +07:00
|
|
|
import { Repository } from 'typeorm/repository/Repository';
|
2023-03-03 17:49:22 -05:00
|
|
|
import { promisify } from 'util';
|
2023-06-04 04:55:30 +02:00
|
|
|
import { parseLatitude, parseLongitude } from '../utils/exif/coordinates';
|
|
|
|
|
import { exifTimeZone, exifToDate } from '../utils/exif/date-time';
|
|
|
|
|
import { parseISO } from '../utils/exif/iso';
|
|
|
|
|
import { toNumberOrNull } from '../utils/numbers';
|
2023-03-03 17:49:22 -05:00
|
|
|
|
|
|
|
|
const ffprobe = promisify<string, FfprobeData>(ffmpeg.ffprobe);
|
2023-02-16 02:41:51 -05:00
|
|
|
|
2023-07-22 13:52:59 +10:00
|
|
|
interface DirectoryItem {
|
|
|
|
|
Length?: number;
|
|
|
|
|
Mime: string;
|
|
|
|
|
Padding?: number;
|
|
|
|
|
Semantic?: string;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
interface DirectoryEntry {
|
|
|
|
|
Item: DirectoryItem;
|
|
|
|
|
}
|
|
|
|
|
|
2023-02-16 02:41:51 -05:00
|
|
|
interface ImmichTags extends Tags {
|
|
|
|
|
ContentIdentifier?: string;
|
2023-07-22 13:52:59 +10:00
|
|
|
MotionPhoto?: number;
|
|
|
|
|
MotionPhotoVersion?: number;
|
|
|
|
|
MotionPhotoPresentationTimestampUs?: number;
|
2023-02-16 02:41:51 -05:00
|
|
|
}
|
2022-07-02 21:06:36 -05:00
|
|
|
|
2022-06-11 16:12:06 -05:00
|
|
|
export class MetadataExtractionProcessor {
|
2023-01-13 09:23:12 -05:00
|
|
|
private logger = new Logger(MetadataExtractionProcessor.name);
|
2023-04-04 18:23:07 -04:00
|
|
|
private reverseGeocodingEnabled: boolean;
|
2023-07-22 13:52:59 +10:00
|
|
|
private storageCore = new StorageCore();
|
2023-03-02 21:47:08 -05:00
|
|
|
|
2022-06-11 16:12:06 -05:00
|
|
|
constructor(
|
2023-03-20 11:55:28 -04:00
|
|
|
@Inject(IAssetRepository) private assetRepository: IAssetRepository,
|
2023-08-15 21:34:57 -04:00
|
|
|
@Inject(IAlbumRepository) private albumRepository: IAlbumRepository,
|
2023-03-20 11:55:28 -04:00
|
|
|
@Inject(IJobRepository) private jobRepository: IJobRepository,
|
2023-04-04 18:23:07 -04:00
|
|
|
@Inject(IGeocodingRepository) private geocodingRepository: IGeocodingRepository,
|
2023-07-22 13:52:59 +10:00
|
|
|
@Inject(ICryptoRepository) private cryptoRepository: ICryptoRepository,
|
|
|
|
|
@Inject(IStorageRepository) private storageRepository: IStorageRepository,
|
2023-04-04 18:23:07 -04:00
|
|
|
@InjectRepository(ExifEntity) private exifRepository: Repository<ExifEntity>,
|
2022-06-11 16:12:06 -05:00
|
|
|
|
2023-01-13 09:23:12 -05:00
|
|
|
configService: ConfigService,
|
2022-06-11 16:12:06 -05:00
|
|
|
) {
|
2023-04-04 18:23:07 -04:00
|
|
|
this.reverseGeocodingEnabled = !configService.get('DISABLE_REVERSE_GEOCODING');
|
2022-06-11 16:12:06 -05:00
|
|
|
}
|
|
|
|
|
|
2023-05-23 21:36:36 -04:00
|
|
|
async init(deleteCache = false) {
|
2023-07-17 04:57:20 +02:00
|
|
|
this.logger.log(`Reverse geocoding is ${this.reverseGeocodingEnabled ? 'enabled' : 'disabled'}`);
|
2023-04-04 18:23:07 -04:00
|
|
|
if (!this.reverseGeocodingEnabled) {
|
|
|
|
|
return;
|
2022-10-07 09:15:05 -05:00
|
|
|
}
|
|
|
|
|
|
2023-04-04 18:23:07 -04:00
|
|
|
try {
|
2023-05-23 21:36:36 -04:00
|
|
|
if (deleteCache) {
|
2023-05-20 22:39:12 -04:00
|
|
|
await this.geocodingRepository.deleteCache();
|
|
|
|
|
}
|
2023-04-04 18:23:07 -04:00
|
|
|
this.logger.log('Initializing Reverse Geocoding');
|
2022-10-07 09:15:05 -05:00
|
|
|
|
2023-04-04 18:23:07 -04:00
|
|
|
await this.jobRepository.pause(QueueName.METADATA_EXTRACTION);
|
|
|
|
|
await this.geocodingRepository.init();
|
|
|
|
|
await this.jobRepository.resume(QueueName.METADATA_EXTRACTION);
|
2022-09-23 03:50:05 +01:00
|
|
|
|
2023-04-04 18:23:07 -04:00
|
|
|
this.logger.log('Reverse Geocoding Initialized');
|
|
|
|
|
} catch (error: any) {
|
|
|
|
|
this.logger.error(`Unable to initialize reverse geocoding: ${error}`, error?.stack);
|
|
|
|
|
}
|
2022-09-23 03:50:05 +01:00
|
|
|
}
|
|
|
|
|
|
2023-08-15 21:34:57 -04:00
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
2023-05-26 08:52:52 -04:00
|
|
|
async handleQueueMetadataExtraction(job: IBaseJob) {
|
2023-05-26 15:43:24 -04:00
|
|
|
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 } });
|
2023-03-20 11:55:28 -04:00
|
|
|
}
|
|
|
|
|
}
|
2023-05-26 15:43:24 -04:00
|
|
|
|
|
|
|
|
return true;
|
2023-03-20 11:55:28 -04:00
|
|
|
}
|
|
|
|
|
|
2023-05-26 15:43:24 -04:00
|
|
|
async handleMetadataExtraction({ id }: IEntityJob) {
|
|
|
|
|
const [asset] = await this.assetRepository.getByIds([id]);
|
|
|
|
|
if (!asset || !asset.isVisible) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
2023-04-11 08:53:42 -05:00
|
|
|
|
2023-05-26 15:43:24 -04:00
|
|
|
if (asset.type === AssetType.VIDEO) {
|
|
|
|
|
return this.handleVideoMetadataExtraction(asset);
|
|
|
|
|
} else {
|
|
|
|
|
return this.handlePhotoMetadataExtraction(asset);
|
|
|
|
|
}
|
|
|
|
|
}
|
2023-01-17 13:41:00 -06:00
|
|
|
|
2023-07-22 13:52:59 +10:00
|
|
|
async addExtractedLivePhoto(sourceAsset: AssetEntity, video: string, created: Date | null): Promise<AssetEntity> {
|
|
|
|
|
if (sourceAsset.livePhotoVideoId) {
|
|
|
|
|
const [liveAsset] = await this.assetRepository.getByIds([sourceAsset.livePhotoVideoId]);
|
|
|
|
|
// already exists so no need to generate ID.
|
|
|
|
|
if (liveAsset.originalPath == video) {
|
|
|
|
|
return liveAsset;
|
|
|
|
|
}
|
|
|
|
|
liveAsset.originalPath = video;
|
|
|
|
|
return this.assetRepository.save(liveAsset);
|
|
|
|
|
}
|
|
|
|
|
const liveAsset = await this.assetRepository.save({
|
|
|
|
|
ownerId: sourceAsset.ownerId,
|
|
|
|
|
owner: sourceAsset.owner,
|
|
|
|
|
|
|
|
|
|
checksum: await this.cryptoRepository.hashFile(video),
|
|
|
|
|
originalPath: video,
|
|
|
|
|
|
|
|
|
|
fileCreatedAt: created ?? sourceAsset.fileCreatedAt,
|
|
|
|
|
fileModifiedAt: sourceAsset.fileModifiedAt,
|
|
|
|
|
|
|
|
|
|
deviceAssetId: 'NONE',
|
|
|
|
|
deviceId: 'NONE',
|
|
|
|
|
|
|
|
|
|
type: AssetType.VIDEO,
|
|
|
|
|
isFavorite: false,
|
|
|
|
|
isArchived: sourceAsset.isArchived,
|
|
|
|
|
duration: null,
|
|
|
|
|
isVisible: false,
|
|
|
|
|
livePhotoVideo: null,
|
|
|
|
|
resizePath: null,
|
|
|
|
|
webpPath: null,
|
|
|
|
|
thumbhash: null,
|
|
|
|
|
encodedVideoPath: null,
|
|
|
|
|
tags: [],
|
|
|
|
|
sharedLinks: [],
|
|
|
|
|
originalFileName: path.parse(video).name,
|
|
|
|
|
faces: [],
|
|
|
|
|
sidecarPath: null,
|
|
|
|
|
isReadOnly: sourceAsset.isReadOnly,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
sourceAsset.livePhotoVideoId = liveAsset.id;
|
|
|
|
|
await this.assetRepository.save(sourceAsset);
|
|
|
|
|
return liveAsset;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private async extractNewPixelLivePhoto(
|
|
|
|
|
asset: AssetEntity,
|
|
|
|
|
directory: DirectoryEntry[],
|
|
|
|
|
fileCreatedAt: Date | null,
|
|
|
|
|
): Promise<AssetEntity | null> {
|
|
|
|
|
if (asset.livePhotoVideoId) {
|
|
|
|
|
// Already extracted, don't try again.
|
|
|
|
|
const [ret] = await this.assetRepository.getByIds([asset.livePhotoVideoId]);
|
|
|
|
|
this.logger.log(`Already extracted asset ${ret.originalPath}.`);
|
|
|
|
|
return ret;
|
|
|
|
|
}
|
|
|
|
|
let foundMotionPhoto = false;
|
|
|
|
|
let motionPhotoOffsetFromEnd = 0;
|
|
|
|
|
let motionPhotoLength = 0;
|
|
|
|
|
|
|
|
|
|
// Look for the directory entry with semantic label "MotionPhoto", which is the embedded video.
|
|
|
|
|
// Then, determine the length from the end of the file to the start of the embedded video.
|
|
|
|
|
for (const entry of directory) {
|
|
|
|
|
if (entry.Item.Semantic == 'MotionPhoto') {
|
|
|
|
|
if (foundMotionPhoto) {
|
|
|
|
|
this.logger.error(`Asset ${asset.originalPath} has more than one motion photo.`);
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
foundMotionPhoto = true;
|
|
|
|
|
motionPhotoLength = entry.Item.Length ?? 0;
|
|
|
|
|
}
|
|
|
|
|
if (foundMotionPhoto) {
|
|
|
|
|
motionPhotoOffsetFromEnd += entry.Item.Length ?? 0;
|
|
|
|
|
motionPhotoOffsetFromEnd += entry.Item.Padding ?? 0;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!foundMotionPhoto || motionPhotoLength == 0) {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
return this.extractEmbeddedVideo(asset, motionPhotoOffsetFromEnd, motionPhotoLength, fileCreatedAt);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private async extractEmbeddedVideo(
|
|
|
|
|
asset: AssetEntity,
|
|
|
|
|
offsetFromEnd: number,
|
|
|
|
|
length: number | null,
|
|
|
|
|
fileCreatedAt: Date | null,
|
|
|
|
|
) {
|
|
|
|
|
let file = null;
|
|
|
|
|
try {
|
|
|
|
|
file = await fs.promises.open(asset.originalPath);
|
|
|
|
|
let extracted = null;
|
|
|
|
|
// Read in embedded video.
|
|
|
|
|
const stat = await file.stat();
|
|
|
|
|
if (length == null) {
|
|
|
|
|
length = offsetFromEnd;
|
|
|
|
|
}
|
|
|
|
|
const offset = stat.size - offsetFromEnd;
|
|
|
|
|
extracted = await file.read({
|
|
|
|
|
buffer: Buffer.alloc(length),
|
|
|
|
|
position: offset,
|
|
|
|
|
length: length,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Write out extracted video, and add it to the asset repository.
|
|
|
|
|
const encodedVideoFolder = this.storageCore.getFolderLocation(StorageFolder.ENCODED_VIDEO, asset.ownerId);
|
|
|
|
|
this.storageRepository.mkdirSync(encodedVideoFolder);
|
|
|
|
|
const livePhotoPath = path.join(encodedVideoFolder, path.parse(asset.originalPath).name + '.mp4');
|
|
|
|
|
await fs.promises.writeFile(livePhotoPath, extracted.buffer);
|
|
|
|
|
|
|
|
|
|
const result = await this.addExtractedLivePhoto(asset, livePhotoPath, fileCreatedAt);
|
|
|
|
|
await this.handleMetadataExtraction({ id: result.id });
|
|
|
|
|
return result;
|
|
|
|
|
} catch (e) {
|
|
|
|
|
this.logger.error(`Failed to extract live photo ${asset.originalPath}: ${e}`);
|
|
|
|
|
return null;
|
|
|
|
|
} finally {
|
|
|
|
|
if (file) {
|
|
|
|
|
await file.close();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2023-05-26 15:43:24 -04:00
|
|
|
private async handlePhotoMetadataExtraction(asset: AssetEntity) {
|
|
|
|
|
const mediaExifData = await exiftool.read<ImmichTags>(asset.originalPath).catch((error: any) => {
|
|
|
|
|
this.logger.warn(
|
|
|
|
|
`The exifData parsing failed due to ${error} for asset ${asset.id} at ${asset.originalPath}`,
|
|
|
|
|
error?.stack,
|
|
|
|
|
);
|
|
|
|
|
return null;
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const sidecarExifData = asset.sidecarPath
|
|
|
|
|
? await exiftool.read<ImmichTags>(asset.sidecarPath).catch((error: any) => {
|
|
|
|
|
this.logger.warn(
|
|
|
|
|
`The exifData parsing failed due to ${error} for asset ${asset.id} at ${asset.originalPath}`,
|
|
|
|
|
error?.stack,
|
|
|
|
|
);
|
|
|
|
|
return null;
|
|
|
|
|
})
|
|
|
|
|
: {};
|
2023-01-17 13:41:00 -06:00
|
|
|
|
2023-06-04 04:55:30 +02:00
|
|
|
const getExifProperty = <T extends keyof ImmichTags>(
|
|
|
|
|
...properties: T[]
|
|
|
|
|
): NonNullable<ImmichTags[T]> | string | null => {
|
2023-05-26 15:43:24 -04:00
|
|
|
for (const property of properties) {
|
|
|
|
|
const value = sidecarExifData?.[property] ?? mediaExifData?.[property];
|
|
|
|
|
if (value !== null && value !== undefined) {
|
2023-06-04 04:55:30 +02:00
|
|
|
// Can also be string when the value cannot be parsed
|
2023-05-26 15:43:24 -04:00
|
|
|
return value;
|
2023-02-16 02:41:51 -05:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2023-05-26 15:43:24 -04:00
|
|
|
return null;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const timeZone = exifTimeZone(getExifProperty('DateTimeOriginal', 'CreateDate') ?? asset.fileCreatedAt);
|
|
|
|
|
const fileCreatedAt = exifToDate(getExifProperty('DateTimeOriginal', 'CreateDate') ?? asset.fileCreatedAt);
|
|
|
|
|
const fileModifiedAt = exifToDate(getExifProperty('ModifyDate') ?? asset.fileModifiedAt);
|
|
|
|
|
const fileStats = fs.statSync(asset.originalPath);
|
|
|
|
|
const fileSizeInBytes = fileStats.size;
|
|
|
|
|
|
|
|
|
|
const newExif = new ExifEntity();
|
|
|
|
|
newExif.assetId = asset.id;
|
|
|
|
|
newExif.fileSizeInByte = fileSizeInBytes;
|
|
|
|
|
newExif.make = getExifProperty('Make');
|
|
|
|
|
newExif.model = getExifProperty('Model');
|
2023-06-04 04:55:30 +02:00
|
|
|
newExif.exifImageHeight = toNumberOrNull(getExifProperty('ExifImageHeight', 'ImageHeight'));
|
|
|
|
|
newExif.exifImageWidth = toNumberOrNull(getExifProperty('ExifImageWidth', 'ImageWidth'));
|
2023-05-26 15:43:24 -04:00
|
|
|
newExif.exposureTime = getExifProperty('ExposureTime');
|
2023-06-04 04:55:30 +02:00
|
|
|
newExif.orientation = getExifProperty('Orientation')?.toString() ?? null;
|
2023-05-26 15:43:24 -04:00
|
|
|
newExif.dateTimeOriginal = fileCreatedAt;
|
|
|
|
|
newExif.modifyDate = fileModifiedAt;
|
|
|
|
|
newExif.timeZone = timeZone;
|
|
|
|
|
newExif.lensModel = getExifProperty('LensModel');
|
2023-06-04 04:55:30 +02:00
|
|
|
newExif.fNumber = toNumberOrNull(getExifProperty('FNumber'));
|
|
|
|
|
newExif.focalLength = toNumberOrNull(getExifProperty('FocalLength'));
|
2023-05-26 15:43:24 -04:00
|
|
|
|
2023-06-04 04:55:30 +02:00
|
|
|
// Handle array values by converting to string
|
|
|
|
|
const iso = getExifProperty('ISO')?.toString();
|
|
|
|
|
newExif.iso = iso ? parseISO(iso) : null;
|
|
|
|
|
|
|
|
|
|
const latitude = getExifProperty('GPSLatitude');
|
|
|
|
|
const longitude = getExifProperty('GPSLongitude');
|
2023-08-14 18:37:17 -07:00
|
|
|
const lat = parseLatitude(latitude);
|
|
|
|
|
const lon = parseLongitude(longitude);
|
|
|
|
|
|
|
|
|
|
if (lat === 0 && lon === 0) {
|
|
|
|
|
this.logger.warn(`Latitude & Longitude were on Null Island (${lat},${lon}), not assigning coordinates`);
|
|
|
|
|
} else {
|
|
|
|
|
newExif.latitude = lat;
|
|
|
|
|
newExif.longitude = lon;
|
|
|
|
|
}
|
|
|
|
|
|
2023-07-22 13:52:59 +10:00
|
|
|
if (getExifProperty('MotionPhoto')) {
|
|
|
|
|
// Seen on more recent Pixel phones: starting as early as Pixel 4a, possibly earlier.
|
|
|
|
|
const rawDirectory = getExifProperty('Directory');
|
|
|
|
|
if (Array.isArray(rawDirectory)) {
|
|
|
|
|
// exiftool-vendor thinks directory is a string, but actually it's an array of DirectoryEntry.
|
|
|
|
|
const directory = rawDirectory as DirectoryEntry[];
|
|
|
|
|
await this.extractNewPixelLivePhoto(asset, directory, fileCreatedAt);
|
|
|
|
|
} else {
|
|
|
|
|
this.logger.warn(`Failed to get Pixel motionPhoto information: directory: ${JSON.stringify(rawDirectory)}`);
|
|
|
|
|
}
|
|
|
|
|
} else if (getExifProperty('MicroVideo')) {
|
|
|
|
|
// Seen on earlier Pixel phones - Pixel 2 and earlier, possibly Pixel 3.
|
|
|
|
|
let offset = getExifProperty('MicroVideoOffset'); // offset from end of file.
|
|
|
|
|
if (typeof offset == 'string') {
|
|
|
|
|
offset = parseInt(offset);
|
|
|
|
|
}
|
|
|
|
|
if (Number.isNaN(offset) || offset == null) {
|
|
|
|
|
this.logger.warn(
|
|
|
|
|
`Failed to get MicroVideo information for ${asset.originalPath}, offset=${getExifProperty(
|
|
|
|
|
'MicroVideoOffset',
|
|
|
|
|
)}`,
|
|
|
|
|
);
|
|
|
|
|
} else {
|
|
|
|
|
await this.extractEmbeddedVideo(asset, offset, null, fileCreatedAt);
|
|
|
|
|
}
|
|
|
|
|
}
|
2023-07-28 06:29:09 +02:00
|
|
|
|
|
|
|
|
const projectionType = getExifProperty('ProjectionType');
|
|
|
|
|
if (projectionType) {
|
|
|
|
|
newExif.projectionType = String(projectionType).toUpperCase();
|
|
|
|
|
}
|
|
|
|
|
|
2023-06-04 04:55:30 +02:00
|
|
|
newExif.livePhotoCID = getExifProperty('MediaGroupUUID');
|
2023-05-26 15:43:24 -04:00
|
|
|
await this.applyReverseGeocoding(asset, newExif);
|
2022-08-29 03:43:31 +07:00
|
|
|
|
2023-05-26 15:43:24 -04:00
|
|
|
/**
|
|
|
|
|
* IF the EXIF doesn't contain the width and height of the image,
|
|
|
|
|
* We will use Sharpjs to get the information.
|
|
|
|
|
*/
|
|
|
|
|
if (!newExif.exifImageHeight || !newExif.exifImageWidth || !newExif.orientation) {
|
|
|
|
|
const metadata = await sharp(asset.originalPath).metadata();
|
2022-08-29 03:43:31 +07:00
|
|
|
|
2023-05-26 15:43:24 -04:00
|
|
|
if (newExif.exifImageHeight === null) {
|
|
|
|
|
newExif.exifImageHeight = metadata.height || null;
|
|
|
|
|
}
|
2022-08-29 03:43:31 +07:00
|
|
|
|
2023-05-26 15:43:24 -04:00
|
|
|
if (newExif.exifImageWidth === null) {
|
|
|
|
|
newExif.exifImageWidth = metadata.width || null;
|
2022-08-29 03:43:31 +07:00
|
|
|
}
|
|
|
|
|
|
2023-05-26 15:43:24 -04:00
|
|
|
if (newExif.orientation === null) {
|
|
|
|
|
newExif.orientation = metadata.orientation !== undefined ? `${metadata.orientation}` : null;
|
|
|
|
|
}
|
2022-06-11 16:12:06 -05:00
|
|
|
}
|
|
|
|
|
|
2023-05-26 15:43:24 -04:00
|
|
|
await this.exifRepository.upsert(newExif, { conflictPaths: ['assetId'] });
|
2023-05-29 16:05:14 +02:00
|
|
|
await this.assetRepository.save({ id: asset.id, fileCreatedAt: fileCreatedAt || undefined });
|
2022-06-19 08:16:35 -05:00
|
|
|
|
2023-05-26 15:43:24 -04:00
|
|
|
return true;
|
|
|
|
|
}
|
2023-01-30 11:14:13 -05:00
|
|
|
|
2023-05-26 15:43:24 -04:00
|
|
|
private async handleVideoMetadataExtraction(asset: AssetEntity) {
|
|
|
|
|
const data = await ffprobe(asset.originalPath);
|
|
|
|
|
const durationString = this.extractDuration(data.format.duration || asset.duration);
|
|
|
|
|
let fileCreatedAt = asset.fileCreatedAt;
|
|
|
|
|
|
|
|
|
|
const videoTags = data.format.tags;
|
|
|
|
|
if (videoTags) {
|
|
|
|
|
if (videoTags['com.apple.quicktime.creationdate']) {
|
2023-05-29 16:05:14 +02:00
|
|
|
fileCreatedAt = new Date(videoTags['com.apple.quicktime.creationdate']);
|
2023-05-26 15:43:24 -04:00
|
|
|
} else if (videoTags['creation_time']) {
|
2023-05-29 16:05:14 +02:00
|
|
|
fileCreatedAt = new Date(videoTags['creation_time']);
|
2022-08-21 06:31:37 +07:00
|
|
|
}
|
2023-05-26 15:43:24 -04:00
|
|
|
}
|
2022-06-19 08:16:35 -05:00
|
|
|
|
2023-05-26 15:43:24 -04:00
|
|
|
const exifData = await exiftool.read<ImmichTags>(asset.sidecarPath || asset.originalPath).catch((error: any) => {
|
|
|
|
|
this.logger.warn(
|
|
|
|
|
`The exifData parsing failed due to ${error} for asset ${asset.id} at ${asset.originalPath}`,
|
|
|
|
|
error?.stack,
|
|
|
|
|
);
|
|
|
|
|
return null;
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const newExif = new ExifEntity();
|
|
|
|
|
newExif.assetId = asset.id;
|
|
|
|
|
newExif.fileSizeInByte = data.format.size || null;
|
|
|
|
|
newExif.dateTimeOriginal = fileCreatedAt ? new Date(fileCreatedAt) : null;
|
|
|
|
|
newExif.modifyDate = null;
|
|
|
|
|
newExif.timeZone = null;
|
|
|
|
|
newExif.latitude = null;
|
|
|
|
|
newExif.longitude = null;
|
|
|
|
|
newExif.city = null;
|
|
|
|
|
newExif.state = null;
|
|
|
|
|
newExif.country = null;
|
|
|
|
|
newExif.fps = null;
|
|
|
|
|
newExif.livePhotoCID = exifData?.ContentIdentifier || null;
|
|
|
|
|
|
|
|
|
|
if (videoTags && videoTags['location']) {
|
|
|
|
|
const location = videoTags['location'] as string;
|
|
|
|
|
const locationRegex = /([+-][0-9]+\.[0-9]+)([+-][0-9]+\.[0-9]+)\/$/;
|
|
|
|
|
const match = location.match(locationRegex);
|
2022-08-21 06:31:37 +07:00
|
|
|
|
2023-05-26 15:43:24 -04:00
|
|
|
if (match?.length === 3) {
|
2023-06-02 18:29:12 +02:00
|
|
|
newExif.latitude = parseLatitude(match[1]);
|
|
|
|
|
newExif.longitude = parseLongitude(match[2]);
|
2023-05-26 15:43:24 -04:00
|
|
|
}
|
|
|
|
|
} else if (videoTags && videoTags['com.apple.quicktime.location.ISO6709']) {
|
|
|
|
|
const location = videoTags['com.apple.quicktime.location.ISO6709'] as string;
|
|
|
|
|
const locationRegex = /([+-][0-9]+\.[0-9]+)([+-][0-9]+\.[0-9]+)([+-][0-9]+\.[0-9]+)\/$/;
|
|
|
|
|
const match = location.match(locationRegex);
|
|
|
|
|
|
|
|
|
|
if (match?.length === 4) {
|
2023-06-02 18:29:12 +02:00
|
|
|
newExif.latitude = parseLatitude(match[1]);
|
|
|
|
|
newExif.longitude = parseLongitude(match[2]);
|
2022-08-21 06:31:37 +07:00
|
|
|
}
|
2023-05-26 15:43:24 -04:00
|
|
|
}
|
2022-06-19 08:16:35 -05:00
|
|
|
|
2023-05-26 15:43:24 -04:00
|
|
|
if (newExif.longitude && newExif.latitude) {
|
|
|
|
|
try {
|
|
|
|
|
newExif.timeZone = tz_lookup(newExif.latitude, newExif.longitude);
|
|
|
|
|
} catch (error: any) {
|
|
|
|
|
this.logger.warn(`Error while calculating timezone from gps coordinates: ${error}`, error?.stack);
|
2023-04-02 21:11:24 +02:00
|
|
|
}
|
2023-05-26 15:43:24 -04:00
|
|
|
}
|
2023-04-02 21:11:24 +02:00
|
|
|
|
2023-05-26 15:43:24 -04:00
|
|
|
await this.applyReverseGeocoding(asset, newExif);
|
2022-08-21 06:31:37 +07:00
|
|
|
|
2023-05-26 15:43:24 -04:00
|
|
|
for (const stream of data.streams) {
|
|
|
|
|
if (stream.codec_type === 'video') {
|
|
|
|
|
newExif.exifImageWidth = stream.width || null;
|
|
|
|
|
newExif.exifImageHeight = stream.height || null;
|
2022-08-21 06:31:37 +07:00
|
|
|
|
2023-05-26 15:43:24 -04:00
|
|
|
if (typeof stream.rotation === 'string') {
|
|
|
|
|
newExif.orientation = stream.rotation;
|
|
|
|
|
} else if (typeof stream.rotation === 'number') {
|
|
|
|
|
newExif.orientation = `${stream.rotation}`;
|
|
|
|
|
} else {
|
|
|
|
|
newExif.orientation = null;
|
|
|
|
|
}
|
2022-07-04 13:44:43 -05:00
|
|
|
|
2023-05-26 15:43:24 -04:00
|
|
|
if (stream.r_frame_rate) {
|
|
|
|
|
const fpsParts = stream.r_frame_rate.split('/');
|
2022-08-21 06:31:37 +07:00
|
|
|
|
2023-05-26 15:43:24 -04:00
|
|
|
if (fpsParts.length === 2) {
|
|
|
|
|
newExif.fps = Math.round(parseInt(fpsParts[0]) / parseInt(fpsParts[1]));
|
2022-08-21 06:31:37 +07:00
|
|
|
}
|
|
|
|
|
}
|
2022-06-19 08:16:35 -05:00
|
|
|
}
|
2022-08-21 06:31:37 +07:00
|
|
|
}
|
2023-05-26 15:43:24 -04:00
|
|
|
|
|
|
|
|
await this.exifRepository.upsert(newExif, { conflictPaths: ['assetId'] });
|
|
|
|
|
await this.assetRepository.save({ id: asset.id, duration: durationString, fileCreatedAt });
|
|
|
|
|
|
|
|
|
|
return true;
|
2022-06-19 08:16:35 -05:00
|
|
|
}
|
2022-07-04 13:44:43 -05:00
|
|
|
|
2023-04-11 08:53:42 -05:00
|
|
|
private async applyReverseGeocoding(asset: AssetEntity, newExif: ExifEntity) {
|
|
|
|
|
const { latitude, longitude } = newExif;
|
2023-04-04 18:23:07 -04:00
|
|
|
if (this.reverseGeocodingEnabled && longitude && latitude) {
|
|
|
|
|
try {
|
|
|
|
|
const { country, state, city } = await this.geocodingRepository.reverseGeocode({ latitude, longitude });
|
|
|
|
|
newExif.country = country;
|
|
|
|
|
newExif.state = state;
|
|
|
|
|
newExif.city = city;
|
|
|
|
|
} catch (error: any) {
|
2023-04-11 08:53:42 -05:00
|
|
|
this.logger.warn(
|
|
|
|
|
`Unable to run reverse geocoding due to ${error} for asset ${asset.id} at ${asset.originalPath}`,
|
|
|
|
|
error?.stack,
|
|
|
|
|
);
|
2023-04-04 18:23:07 -04:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2023-03-03 17:49:22 -05:00
|
|
|
private extractDuration(duration: number | string | null) {
|
|
|
|
|
const videoDurationInSecond = Number(duration);
|
|
|
|
|
if (!videoDurationInSecond) {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
2022-07-04 13:44:43 -05:00
|
|
|
|
2023-03-23 15:27:29 +01:00
|
|
|
return Duration.fromObject({ seconds: videoDurationInSecond }).toFormat('hh:mm:ss.SSS');
|
2022-07-04 13:44:43 -05:00
|
|
|
}
|
2022-06-11 16:12:06 -05:00
|
|
|
}
|