2023-09-27 20:44:51 +02:00
|
|
|
import { AssetEntity, AssetType, ExifEntity } from '@app/infra/entities';
|
2023-12-14 17:55:40 +01:00
|
|
|
import { ImmichLogger } from '@app/infra/logger';
|
|
|
|
import { Inject, Injectable } from '@nestjs/common';
|
2023-09-27 21:17:18 +02:00
|
|
|
import { ExifDateTime, Tags } from 'exiftool-vendored';
|
2023-09-27 20:44:51 +02:00
|
|
|
import { firstDateTime } from 'exiftool-vendored/dist/FirstDateTime';
|
2023-11-30 04:52:28 +01:00
|
|
|
import _ from 'lodash';
|
2023-09-27 20:44:51 +02:00
|
|
|
import { Duration } from 'luxon';
|
2024-02-02 04:18:00 +01:00
|
|
|
import { constants } from 'node:fs/promises';
|
2023-11-01 04:08:21 +01:00
|
|
|
import { Subscription } from 'rxjs';
|
2023-05-26 14:52:52 +02:00
|
|
|
import { usePagination } from '../domain.util';
|
2023-11-30 04:52:28 +01:00
|
|
|
import { IBaseJob, IEntityJob, ISidecarWriteJob, JOBS_ASSET_PAGINATION_SIZE, JobName, QueueName } from '../job';
|
2023-10-09 16:25:03 +02:00
|
|
|
import {
|
2023-12-13 18:23:51 +01:00
|
|
|
ClientEvent,
|
2023-12-28 00:36:51 +01:00
|
|
|
DatabaseLock,
|
2023-10-09 16:25:03 +02:00
|
|
|
IAlbumRepository,
|
|
|
|
IAssetRepository,
|
2023-12-06 15:56:09 +01:00
|
|
|
ICommunicationRepository,
|
2023-10-09 16:25:03 +02:00
|
|
|
ICryptoRepository,
|
2023-12-28 00:36:51 +01:00
|
|
|
IDatabaseRepository,
|
2023-10-09 16:25:03 +02:00
|
|
|
IJobRepository,
|
2023-12-03 23:34:23 +01:00
|
|
|
IMediaRepository,
|
2023-10-09 16:25:03 +02:00
|
|
|
IMetadataRepository,
|
2023-10-11 04:14:44 +02:00
|
|
|
IMoveRepository,
|
|
|
|
IPersonRepository,
|
2023-10-09 16:25:03 +02:00
|
|
|
IStorageRepository,
|
|
|
|
ISystemConfigRepository,
|
|
|
|
ImmichTags,
|
|
|
|
WithoutProperty,
|
|
|
|
} from '../repositories';
|
2023-10-11 04:14:44 +02:00
|
|
|
import { StorageCore } from '../storage';
|
2023-10-09 16:25:03 +02:00
|
|
|
import { FeatureFlag, SystemConfigCore } from '../system-config';
|
2023-05-26 14:52:52 +02:00
|
|
|
|
2023-11-21 17:58:56 +01:00
|
|
|
/** look for a date from these tags (in order) */
|
|
|
|
const EXIF_DATE_TAGS: Array<keyof Tags> = [
|
|
|
|
'SubSecDateTimeOriginal',
|
|
|
|
'DateTimeOriginal',
|
|
|
|
'SubSecCreateDate',
|
|
|
|
'CreationDate',
|
|
|
|
'CreateDate',
|
|
|
|
'SubSecMediaCreateDate',
|
|
|
|
'MediaCreateDate',
|
|
|
|
'DateTimeCreated',
|
|
|
|
];
|
|
|
|
|
2023-09-27 20:44:51 +02:00
|
|
|
interface DirectoryItem {
|
|
|
|
Length?: number;
|
|
|
|
Mime: string;
|
|
|
|
Padding?: number;
|
|
|
|
Semantic?: string;
|
|
|
|
}
|
|
|
|
|
|
|
|
interface DirectoryEntry {
|
|
|
|
Item: DirectoryItem;
|
|
|
|
}
|
|
|
|
|
2023-12-03 23:34:23 +01:00
|
|
|
export enum Orientation {
|
|
|
|
Horizontal = '1',
|
|
|
|
MirrorHorizontal = '2',
|
|
|
|
Rotate180 = '3',
|
|
|
|
MirrorVertical = '4',
|
|
|
|
MirrorHorizontalRotate270CW = '5',
|
|
|
|
Rotate90CW = '6',
|
|
|
|
MirrorHorizontalRotate90CW = '7',
|
|
|
|
Rotate270CW = '8',
|
|
|
|
}
|
|
|
|
|
2023-09-27 21:17:18 +02:00
|
|
|
type ExifEntityWithoutGeocodeAndTypeOrm = Omit<
|
|
|
|
ExifEntity,
|
|
|
|
'city' | 'state' | 'country' | 'description' | 'exifTextSearchableColumn'
|
2023-11-20 23:26:53 +01:00
|
|
|
> & { dateTimeOriginal: Date };
|
2023-09-27 21:17:18 +02:00
|
|
|
|
2023-09-27 20:44:51 +02:00
|
|
|
const exifDate = (dt: ExifDateTime | string | undefined) => (dt instanceof ExifDateTime ? dt?.toDate() : null);
|
2023-10-05 00:11:11 +02:00
|
|
|
const tzOffset = (dt: ExifDateTime | string | undefined) => (dt instanceof ExifDateTime ? dt?.tzoffsetMinutes : null);
|
2023-09-27 21:17:18 +02:00
|
|
|
|
|
|
|
const validate = <T>(value: T): NonNullable<T> | null => {
|
2023-09-29 17:42:33 +02:00
|
|
|
// handle lists of numbers
|
|
|
|
if (Array.isArray(value)) {
|
|
|
|
value = value[0];
|
|
|
|
}
|
|
|
|
|
2023-09-27 21:17:18 +02: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 21:17:18 +02:00
|
|
|
return null;
|
|
|
|
}
|
|
|
|
|
|
|
|
return value ?? null;
|
|
|
|
};
|
2023-09-27 20:44:51 +02:00
|
|
|
|
|
|
|
@Injectable()
|
2023-05-26 14:52:52 +02:00
|
|
|
export class MetadataService {
|
2023-12-14 17:55:40 +01:00
|
|
|
private logger = new ImmichLogger(MetadataService.name);
|
2023-09-27 20:44:51 +02:00
|
|
|
private storageCore: StorageCore;
|
|
|
|
private configCore: SystemConfigCore;
|
2023-11-01 04:08:21 +01:00
|
|
|
private subscription: Subscription | null = null;
|
2023-09-27 20:44:51 +02:00
|
|
|
|
2023-05-26 14:52:52 +02:00
|
|
|
constructor(
|
2023-09-27 20:44:51 +02:00
|
|
|
@Inject(IAlbumRepository) private albumRepository: IAlbumRepository,
|
2023-05-26 14:52:52 +02:00
|
|
|
@Inject(IAssetRepository) private assetRepository: IAssetRepository,
|
2023-12-28 00:36:51 +01:00
|
|
|
@Inject(ICommunicationRepository) private communicationRepository: ICommunicationRepository,
|
2023-09-27 20:44:51 +02:00
|
|
|
@Inject(ICryptoRepository) private cryptoRepository: ICryptoRepository,
|
2023-12-28 00:36:51 +01:00
|
|
|
@Inject(IDatabaseRepository) private databaseRepository: IDatabaseRepository,
|
2023-05-26 14:52:52 +02:00
|
|
|
@Inject(IJobRepository) private jobRepository: IJobRepository,
|
2023-12-03 23:34:23 +01:00
|
|
|
@Inject(IMediaRepository) private mediaRepository: IMediaRepository,
|
2023-12-28 00:36:51 +01:00
|
|
|
@Inject(IMetadataRepository) private repository: IMetadataRepository,
|
2023-10-11 04:14:44 +02:00
|
|
|
@Inject(IMoveRepository) moveRepository: IMoveRepository,
|
|
|
|
@Inject(IPersonRepository) personRepository: IPersonRepository,
|
2023-12-28 00:36:51 +01:00
|
|
|
@Inject(IStorageRepository) private storageRepository: IStorageRepository,
|
|
|
|
@Inject(ISystemConfigRepository) configRepository: ISystemConfigRepository,
|
2023-09-27 20:44:51 +02:00
|
|
|
) {
|
2023-10-09 02:51:03 +02:00
|
|
|
this.configCore = SystemConfigCore.create(configRepository);
|
2023-12-29 19:41:33 +01:00
|
|
|
this.storageCore = StorageCore.create(
|
|
|
|
assetRepository,
|
|
|
|
moveRepository,
|
|
|
|
personRepository,
|
|
|
|
cryptoRepository,
|
|
|
|
configRepository,
|
|
|
|
storageRepository,
|
|
|
|
);
|
2023-09-27 20:44:51 +02:00
|
|
|
}
|
|
|
|
|
2023-11-25 19:53:30 +01:00
|
|
|
async init() {
|
2023-11-01 04:08:21 +01:00
|
|
|
if (!this.subscription) {
|
|
|
|
this.subscription = this.configCore.config$.subscribe(() => this.init());
|
|
|
|
}
|
|
|
|
|
2023-09-27 20:44:51 +02:00
|
|
|
const { reverseGeocoding } = await this.configCore.getConfig();
|
2023-11-25 19:53:30 +01:00
|
|
|
const { enabled } = reverseGeocoding;
|
2023-09-27 20:44:51 +02:00
|
|
|
|
2023-11-25 19:53:30 +01:00
|
|
|
if (!enabled) {
|
2023-09-27 20:44:51 +02:00
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
|
|
|
try {
|
|
|
|
await this.jobRepository.pause(QueueName.METADATA_EXTRACTION);
|
2023-12-28 00:36:51 +01:00
|
|
|
await this.databaseRepository.withLock(DatabaseLock.GeodataImport, () => this.repository.init());
|
2023-09-27 20:44:51 +02:00
|
|
|
await this.jobRepository.resume(QueueName.METADATA_EXTRACTION);
|
|
|
|
|
2023-11-25 19:53:30 +01: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);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2023-10-19 00:02:42 +02:00
|
|
|
async teardown() {
|
2023-11-01 04:08:21 +01:00
|
|
|
this.subscription?.unsubscribe();
|
2023-10-19 00:02:42 +02:00
|
|
|
await this.repository.teardown();
|
|
|
|
}
|
|
|
|
|
2023-09-27 20:44:51 +02: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);
|
|
|
|
|
2023-12-06 15:56:09 +01:00
|
|
|
// Notify clients to hide the linked live photo asset
|
2023-12-13 18:23:51 +01:00
|
|
|
this.communicationRepository.send(ClientEvent.ASSET_HIDDEN, motionAsset.ownerId, motionAsset.id);
|
2023-12-06 15:56:09 +01:00
|
|
|
|
2023-09-27 20:44:51 +02:00
|
|
|
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) {
|
2024-01-01 21:45:42 +01:00
|
|
|
await this.jobRepository.queueAll(
|
|
|
|
assets.map((asset) => ({ name: JobName.METADATA_EXTRACTION, data: { id: asset.id } })),
|
|
|
|
);
|
2023-09-27 20:44:51 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
return true;
|
|
|
|
}
|
|
|
|
|
|
|
|
async handleMetadataExtraction({ id }: IEntityJob) {
|
|
|
|
const [asset] = await this.assetRepository.getByIds([id]);
|
2023-12-29 19:41:33 +01:00
|
|
|
if (!asset) {
|
2023-09-27 20:44:51 +02:00
|
|
|
return false;
|
|
|
|
}
|
|
|
|
|
|
|
|
const { exifData, tags } = await this.exifData(asset);
|
|
|
|
|
2023-12-03 23:34:23 +01:00
|
|
|
if (asset.type === AssetType.VIDEO) {
|
|
|
|
const { videoStreams } = await this.mediaRepository.probe(asset.originalPath);
|
|
|
|
|
|
|
|
if (videoStreams[0]) {
|
|
|
|
switch (videoStreams[0].rotation) {
|
2024-02-02 04:18:00 +01:00
|
|
|
case -90: {
|
2023-12-03 23:34:23 +01:00
|
|
|
exifData.orientation = Orientation.Rotate90CW;
|
|
|
|
break;
|
2024-02-02 04:18:00 +01:00
|
|
|
}
|
|
|
|
case 0: {
|
2023-12-03 23:34:23 +01:00
|
|
|
exifData.orientation = Orientation.Horizontal;
|
|
|
|
break;
|
2024-02-02 04:18:00 +01:00
|
|
|
}
|
|
|
|
case 90: {
|
2023-12-03 23:34:23 +01:00
|
|
|
exifData.orientation = Orientation.Rotate270CW;
|
|
|
|
break;
|
2024-02-02 04:18:00 +01:00
|
|
|
}
|
|
|
|
case 180: {
|
2023-12-03 23:34:23 +01:00
|
|
|
exifData.orientation = Orientation.Rotate180;
|
|
|
|
break;
|
2024-02-02 04:18:00 +01:00
|
|
|
}
|
2023-12-03 23:34:23 +01:00
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2023-09-27 20:44:51 +02:00
|
|
|
await this.applyMotionPhotos(asset, tags);
|
|
|
|
await this.applyReverseGeocoding(asset, exifData);
|
|
|
|
await this.assetRepository.upsertExif(exifData);
|
2023-10-05 00:11:11 +02:00
|
|
|
|
2023-11-20 23:26:53 +01:00
|
|
|
const dateTimeOriginal = exifData.dateTimeOriginal;
|
2023-10-06 14:12:09 +02:00
|
|
|
let localDateTime = dateTimeOriginal ?? undefined;
|
|
|
|
|
2023-10-05 00:11:11 +02:00
|
|
|
const timeZoneOffset = tzOffset(firstDateTime(tags as Tags)) ?? 0;
|
|
|
|
|
|
|
|
if (dateTimeOriginal && timeZoneOffset) {
|
2024-02-02 04:18:00 +01:00
|
|
|
localDateTime = new Date(dateTimeOriginal.getTime() + timeZoneOffset * 60_000);
|
2023-10-05 00:11:11 +02:00
|
|
|
}
|
2023-09-27 20:44:51 +02:00
|
|
|
await this.assetRepository.save({
|
|
|
|
id: asset.id,
|
|
|
|
duration: tags.Duration ? this.getDuration(tags.Duration) : null,
|
2023-10-05 00:11:11 +02:00
|
|
|
localDateTime,
|
2023-09-27 20:44:51 +02:00
|
|
|
fileCreatedAt: exifData.dateTimeOriginal ?? undefined,
|
|
|
|
});
|
|
|
|
|
2024-01-13 01:39:45 +01:00
|
|
|
await this.assetRepository.upsertJobStatus({
|
|
|
|
assetId: asset.id,
|
|
|
|
metadataExtractedAt: new Date(),
|
|
|
|
});
|
|
|
|
|
2023-09-27 20:44:51 +02:00
|
|
|
return true;
|
|
|
|
}
|
2023-05-26 14:52:52 +02:00
|
|
|
|
|
|
|
async handleQueueSidecar(job: IBaseJob) {
|
2023-05-26 21:43:24 +02:00
|
|
|
const { force } = job;
|
|
|
|
const assetPagination = usePagination(JOBS_ASSET_PAGINATION_SIZE, (pagination) => {
|
|
|
|
return force
|
2024-02-07 18:30:38 +01:00
|
|
|
? this.assetRepository.getAll(pagination)
|
2023-05-26 21:43:24 +02:00
|
|
|
: this.assetRepository.getWithout(pagination, WithoutProperty.SIDECAR);
|
|
|
|
});
|
|
|
|
|
|
|
|
for await (const assets of assetPagination) {
|
2024-01-01 21:45:42 +01:00
|
|
|
await this.jobRepository.queueAll(
|
|
|
|
assets.map((asset) => ({
|
|
|
|
name: force ? JobName.SIDECAR_SYNC : JobName.SIDECAR_DISCOVERY,
|
|
|
|
data: { id: asset.id },
|
|
|
|
})),
|
|
|
|
);
|
2023-05-26 14:52:52 +02:00
|
|
|
}
|
2023-05-26 21:43:24 +02:00
|
|
|
|
|
|
|
return true;
|
2023-05-26 14:52:52 +02:00
|
|
|
}
|
|
|
|
|
2024-02-07 18:30:38 +01:00
|
|
|
handleSidecarSync({ id }: IEntityJob) {
|
|
|
|
return this.processSidecar(id, true);
|
2023-05-26 21:43:24 +02:00
|
|
|
}
|
2023-05-26 14:52:52 +02:00
|
|
|
|
2024-02-07 18:30:38 +01:00
|
|
|
handleSidecarDiscovery({ id }: IEntityJob) {
|
|
|
|
return this.processSidecar(id, false);
|
2023-05-26 14:52:52 +02:00
|
|
|
}
|
2023-09-27 20:44:51 +02:00
|
|
|
|
2023-11-30 04:52:28 +01:00
|
|
|
async handleSidecarWrite(job: ISidecarWriteJob) {
|
|
|
|
const { id, description, dateTimeOriginal, latitude, longitude } = job;
|
|
|
|
const [asset] = await this.assetRepository.getByIds([id]);
|
|
|
|
if (!asset) {
|
|
|
|
return false;
|
|
|
|
}
|
|
|
|
|
|
|
|
const sidecarPath = asset.sidecarPath || `${asset.originalPath}.xmp`;
|
|
|
|
const exif = _.omitBy<Tags>(
|
|
|
|
{
|
|
|
|
ImageDescription: description,
|
|
|
|
CreationDate: dateTimeOriginal,
|
|
|
|
GPSLatitude: latitude,
|
|
|
|
GPSLongitude: longitude,
|
|
|
|
},
|
|
|
|
_.isUndefined,
|
|
|
|
);
|
|
|
|
|
|
|
|
if (Object.keys(exif).length === 0) {
|
|
|
|
return true;
|
|
|
|
}
|
|
|
|
|
|
|
|
await this.repository.writeTags(sidecarPath, exif);
|
|
|
|
|
|
|
|
if (!asset.sidecarPath) {
|
|
|
|
await this.assetRepository.save({ id, sidecarPath });
|
|
|
|
}
|
|
|
|
|
|
|
|
return true;
|
|
|
|
}
|
|
|
|
|
2023-09-27 21:17:18 +02:00
|
|
|
private async applyReverseGeocoding(asset: AssetEntity, exifData: ExifEntityWithoutGeocodeAndTypeOrm) {
|
2023-09-27 20:44:51 +02:00
|
|
|
const { latitude, longitude } = exifData;
|
|
|
|
if (!(await this.configCore.hasFeature(FeatureFlag.REVERSE_GEOCODING)) || !longitude || !latitude) {
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
|
|
|
try {
|
2023-11-25 19:53:30 +01:00
|
|
|
const reverseGeocode = await this.repository.reverseGeocode({ latitude, longitude });
|
2023-11-28 21:09:20 +01:00
|
|
|
if (!reverseGeocode) {
|
|
|
|
return;
|
|
|
|
}
|
2023-11-25 19:53:30 +01:00
|
|
|
Object.assign(exifData, reverseGeocode);
|
2023-09-27 20:44:51 +02:00
|
|
|
} 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) {
|
2024-01-22 19:04:45 +01:00
|
|
|
if (asset.type !== AssetType.IMAGE) {
|
2023-09-27 20:44:51 +02:00
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
|
|
|
const rawDirectory = tags.Directory;
|
|
|
|
const isMotionPhoto = tags.MotionPhoto;
|
|
|
|
const isMicroVideo = tags.MicroVideo;
|
|
|
|
const videoOffset = tags.MicroVideoOffset;
|
2024-01-22 19:04:45 +01:00
|
|
|
const hasMotionPhotoVideo = tags.MotionPhotoVideo;
|
|
|
|
const hasEmbeddedVideoFile = tags.EmbeddedVideoType === 'MotionPhoto_Data' && tags.EmbeddedVideoFile;
|
2023-09-27 20:44:51 +02:00
|
|
|
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;
|
|
|
|
}
|
|
|
|
|
2024-01-22 19:04:45 +01:00
|
|
|
if (!length && !hasEmbeddedVideoFile && !hasMotionPhotoVideo) {
|
2023-09-27 20:44:51 +02:00
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
|
|
|
this.logger.debug(`Starting motion photo video extraction (${asset.id})`);
|
|
|
|
|
|
|
|
try {
|
|
|
|
const stat = await this.storageRepository.stat(asset.originalPath);
|
|
|
|
const position = stat.size - length - padding;
|
2024-01-22 19:04:45 +01:00
|
|
|
let video: Buffer;
|
|
|
|
// Samsung MotionPhoto video extraction
|
|
|
|
// HEIC-encoded
|
|
|
|
if (hasMotionPhotoVideo) {
|
|
|
|
video = await this.repository.extractBinaryTag(asset.originalPath, 'MotionPhotoVideo');
|
|
|
|
}
|
|
|
|
// JPEG-encoded; HEIC also contains these tags, so this conditional must come second
|
|
|
|
else if (hasEmbeddedVideoFile) {
|
|
|
|
video = await this.repository.extractBinaryTag(asset.originalPath, 'EmbeddedVideoFile');
|
|
|
|
}
|
|
|
|
// 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);
|
2023-09-27 20:44:51 +02:00
|
|
|
|
|
|
|
let motionAsset = await this.assetRepository.getByChecksum(asset.ownerId, checksum);
|
2024-02-02 04:18:00 +01:00
|
|
|
if (motionAsset) {
|
|
|
|
this.logger.debug(
|
|
|
|
`Asset ${asset.id}'s motion photo video with checksum ${checksum.toString(
|
|
|
|
'base64',
|
|
|
|
)} already exists in the repository`,
|
|
|
|
);
|
|
|
|
} else {
|
2024-01-22 19:04:45 +01:00
|
|
|
// We create a UUID in advance so that each extracted video can have a unique filename
|
|
|
|
// (allowing us to delete old ones if necessary)
|
|
|
|
const motionAssetId = this.cryptoRepository.randomUUID();
|
|
|
|
const motionPath = StorageCore.getAndroidMotionPath(asset, motionAssetId);
|
2023-10-05 00:11:11 +02:00
|
|
|
const createdAt = asset.fileCreatedAt ?? asset.createdAt;
|
|
|
|
motionAsset = await this.assetRepository.create({
|
2024-01-22 19:04:45 +01:00
|
|
|
id: motionAssetId,
|
2023-09-27 20:44:51 +02:00
|
|
|
libraryId: asset.libraryId,
|
|
|
|
type: AssetType.VIDEO,
|
2023-10-05 00:11:11 +02:00
|
|
|
fileCreatedAt: createdAt,
|
2023-09-27 20:44:51 +02:00
|
|
|
fileModifiedAt: asset.fileModifiedAt,
|
2023-10-05 00:11:11 +02:00
|
|
|
localDateTime: createdAt,
|
2023-09-27 20:44:51 +02:00
|
|
|
checksum,
|
|
|
|
ownerId: asset.ownerId,
|
2023-10-14 19:12:59 +02:00
|
|
|
originalPath: motionPath,
|
2023-09-27 20:44:51 +02:00
|
|
|
originalFileName: asset.originalFileName,
|
|
|
|
isVisible: false,
|
2023-10-09 03:36:02 +02:00
|
|
|
isReadOnly: false,
|
2023-09-27 20:44:51 +02:00
|
|
|
deviceAssetId: 'NONE',
|
|
|
|
deviceId: 'NONE',
|
|
|
|
});
|
|
|
|
|
2024-01-22 19:04:45 +01:00
|
|
|
this.storageCore.ensureFolders(motionPath);
|
2023-09-27 22:27:08 +02:00
|
|
|
await this.storageRepository.writeFile(motionAsset.originalPath, video);
|
2023-09-27 20:44:51 +02:00
|
|
|
await this.jobRepository.queue({ name: JobName.METADATA_EXTRACTION, data: { id: motionAsset.id } });
|
2024-01-22 19:04:45 +01:00
|
|
|
await this.assetRepository.save({ id: asset.id, livePhotoVideoId: motionAsset.id });
|
|
|
|
|
|
|
|
// If the asset already had an associated livePhotoVideo, delete it, because
|
|
|
|
// its checksum doesn't match the checksum of the motionAsset we just extracted
|
|
|
|
// (if it did, getByChecksum() would've returned non-null)
|
|
|
|
if (asset.livePhotoVideoId) {
|
|
|
|
await this.jobRepository.queue({ name: JobName.ASSET_DELETION, data: { id: asset.livePhotoVideoId } });
|
|
|
|
this.logger.log(`Removed old motion photo video asset (${asset.livePhotoVideoId})`);
|
|
|
|
}
|
2023-09-27 20:44:51 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
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);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2023-09-27 21:17:18 +02:00
|
|
|
private async exifData(
|
|
|
|
asset: AssetEntity,
|
|
|
|
): Promise<{ exifData: ExifEntityWithoutGeocodeAndTypeOrm; tags: ImmichTags }> {
|
2023-09-27 20:44:51 +02:00
|
|
|
const stats = await this.storageRepository.stat(asset.originalPath);
|
2023-11-30 04:52:28 +01:00
|
|
|
const mediaTags = await this.repository.readTags(asset.originalPath);
|
|
|
|
const sidecarTags = asset.sidecarPath ? await this.repository.readTags(asset.sidecarPath) : null;
|
2023-11-21 17:58:56 +01:00
|
|
|
|
|
|
|
// ensure date from sidecar is used if present
|
|
|
|
const hasDateOverride = !!this.getDateTimeOriginal(sidecarTags);
|
|
|
|
if (mediaTags && hasDateOverride) {
|
|
|
|
for (const tag of EXIF_DATE_TAGS) {
|
|
|
|
delete mediaTags[tag];
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2023-09-27 20:44:51 +02:00
|
|
|
const tags = { ...mediaTags, ...sidecarTags };
|
|
|
|
|
|
|
|
this.logger.verbose('Exif Tags', tags);
|
|
|
|
|
2023-12-11 16:00:23 +01:00
|
|
|
const exifData = {
|
|
|
|
// altitude: tags.GPSAltitude ?? null,
|
|
|
|
assetId: asset.id,
|
|
|
|
bitsPerSample: this.getBitsPerSample(tags),
|
|
|
|
colorspace: tags.ColorSpace ?? null,
|
|
|
|
dateTimeOriginal: this.getDateTimeOriginal(tags) ?? asset.fileCreatedAt,
|
2024-01-15 17:19:41 +01:00
|
|
|
description: (tags.ImageDescription || tags.Description) ?? '',
|
2023-12-11 16:00:23 +01:00
|
|
|
exifImageHeight: validate(tags.ImageHeight),
|
|
|
|
exifImageWidth: validate(tags.ImageWidth),
|
|
|
|
exposureTime: tags.ExposureTime ?? null,
|
|
|
|
fileSizeInByte: stats.size,
|
|
|
|
fNumber: validate(tags.FNumber),
|
|
|
|
focalLength: validate(tags.FocalLength),
|
2024-02-02 04:18:00 +01:00
|
|
|
fps: validate(Number.parseFloat(tags.VideoFrameRate!)),
|
2023-12-11 16:00:23 +01:00
|
|
|
iso: validate(tags.ISO),
|
|
|
|
latitude: validate(tags.GPSLatitude),
|
|
|
|
lensModel: tags.LensModel ?? null,
|
|
|
|
livePhotoCID: (tags.ContentIdentifier || tags.MediaGroupUUID) ?? null,
|
2024-01-27 19:52:14 +01:00
|
|
|
autoStackId: this.getAutoStackId(tags),
|
2023-12-11 16:00:23 +01:00
|
|
|
longitude: validate(tags.GPSLongitude),
|
|
|
|
make: tags.Make ?? null,
|
|
|
|
model: tags.Model ?? null,
|
|
|
|
modifyDate: exifDate(tags.ModifyDate) ?? asset.fileModifiedAt,
|
|
|
|
orientation: validate(tags.Orientation)?.toString() ?? null,
|
2024-02-21 14:26:13 +01:00
|
|
|
profileDescription: tags.ProfileDescription || null,
|
2023-12-11 16:00:23 +01:00
|
|
|
projectionType: tags.ProjectionType ? String(tags.ProjectionType).toUpperCase() : null,
|
|
|
|
timeZone: tags.tz ?? null,
|
2023-09-27 20:44:51 +02:00
|
|
|
};
|
2023-12-11 16:00:23 +01:00
|
|
|
|
|
|
|
if (exifData.latitude === 0 && exifData.longitude === 0) {
|
2023-12-13 18:23:51 +01:00
|
|
|
this.logger.warn('Exif data has latitude and longitude of 0, setting to null');
|
2023-12-11 16:00:23 +01:00
|
|
|
exifData.latitude = null;
|
|
|
|
exifData.longitude = null;
|
|
|
|
}
|
|
|
|
|
|
|
|
return { exifData, tags };
|
2023-09-27 20:44:51 +02:00
|
|
|
}
|
|
|
|
|
2024-01-27 19:52:14 +01:00
|
|
|
private getAutoStackId(tags: ImmichTags | null): string | null {
|
|
|
|
if (!tags) {
|
|
|
|
return null;
|
|
|
|
}
|
|
|
|
return tags.BurstID ?? tags.BurstUUID ?? tags.CameraBurstID ?? tags.MediaUniqueID ?? null;
|
|
|
|
}
|
|
|
|
|
2023-11-21 17:58:56 +01:00
|
|
|
private getDateTimeOriginal(tags: ImmichTags | Tags | null) {
|
|
|
|
if (!tags) {
|
|
|
|
return null;
|
|
|
|
}
|
|
|
|
return exifDate(firstDateTime(tags as Tags, EXIF_DATE_TAGS));
|
|
|
|
}
|
|
|
|
|
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-02-02 21:58:13 +01:00
|
|
|
private getDuration(seconds?: ImmichTags['Duration']): string {
|
2023-10-19 20:51:56 +02:00
|
|
|
let _seconds = seconds as number;
|
2024-02-02 21:58:13 +01:00
|
|
|
|
2023-10-19 20:51:56 +02:00
|
|
|
if (typeof seconds === 'object') {
|
|
|
|
_seconds = seconds.Value * (seconds?.Scale || 1);
|
2024-02-02 21:58:13 +01:00
|
|
|
} else if (typeof seconds === 'string') {
|
|
|
|
_seconds = Duration.fromISOTime(seconds).as('seconds');
|
2023-10-19 20:51:56 +02:00
|
|
|
}
|
2024-02-02 21:58:13 +01:00
|
|
|
|
2023-10-19 20:51:56 +02:00
|
|
|
return Duration.fromObject({ seconds: _seconds }).toFormat('hh:mm:ss.SSS');
|
2023-09-27 20:44:51 +02:00
|
|
|
}
|
2024-02-07 18:30:38 +01:00
|
|
|
|
|
|
|
private async processSidecar(id: string, isSync: boolean) {
|
|
|
|
const [asset] = await this.assetRepository.getByIds([id]);
|
|
|
|
|
|
|
|
if (!asset) {
|
|
|
|
return false;
|
|
|
|
}
|
|
|
|
|
|
|
|
if (isSync && !asset.sidecarPath) {
|
|
|
|
return false;
|
|
|
|
}
|
|
|
|
|
|
|
|
if (!isSync && (!asset.isVisible || asset.sidecarPath)) {
|
|
|
|
return false;
|
|
|
|
}
|
|
|
|
|
|
|
|
const sidecarPath = `${asset.originalPath}.xmp`;
|
|
|
|
const exists = await this.storageRepository.checkFileExists(sidecarPath, constants.R_OK);
|
|
|
|
if (exists) {
|
|
|
|
await this.assetRepository.save({ id: asset.id, sidecarPath });
|
|
|
|
return true;
|
|
|
|
}
|
|
|
|
|
|
|
|
if (!isSync) {
|
|
|
|
return false;
|
|
|
|
}
|
|
|
|
|
|
|
|
this.logger.debug(`Sidecar File '${sidecarPath}' was not found, removing sidecarPath for asset ${asset.id}`);
|
|
|
|
await this.assetRepository.save({ id: asset.id, sidecarPath: null });
|
|
|
|
|
|
|
|
return true;
|
|
|
|
}
|
2023-05-26 14:52:52 +02:00
|
|
|
}
|