2023-12-14 11:55:40 -05:00
|
|
|
import { Inject, Injectable } from '@nestjs/common';
|
2024-09-07 13:39:10 -04:00
|
|
|
import { ContainerDirectoryItem, ExifDateTime, Maybe, 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';
|
2024-03-13 10:14:26 -07:00
|
|
|
import path from 'node:path';
|
2024-06-27 15:54:20 -04:00
|
|
|
import { SystemConfig } from 'src/config';
|
2024-03-20 21:20:38 +01:00
|
|
|
import { StorageCore } from 'src/cores/storage.core';
|
2024-09-30 10:35:11 -04:00
|
|
|
import { OnEvent } from 'src/decorators';
|
2024-09-05 00:23:58 +02:00
|
|
|
import { AssetFaceEntity } from 'src/entities/asset-face.entity';
|
2024-08-15 06:57:01 -04:00
|
|
|
import { AssetEntity } from 'src/entities/asset.entity';
|
2024-09-26 14:32:10 -04:00
|
|
|
import { ExifEntity } from 'src/entities/exif.entity';
|
2024-09-05 00:23:58 +02:00
|
|
|
import { PersonEntity } from 'src/entities/person.entity';
|
|
|
|
|
import { AssetType, SourceType } from 'src/enum';
|
2024-03-21 12:59:49 +01:00
|
|
|
import { IAlbumRepository } from 'src/interfaces/album.interface';
|
|
|
|
|
import { IAssetRepository, WithoutProperty } from 'src/interfaces/asset.interface';
|
2024-10-01 16:03:55 -04:00
|
|
|
import { IConfigRepository } from 'src/interfaces/config.interface';
|
2024-03-21 12:59:49 +01:00
|
|
|
import { ICryptoRepository } from 'src/interfaces/crypto.interface';
|
|
|
|
|
import { DatabaseLock, IDatabaseRepository } from 'src/interfaces/database.interface';
|
2024-09-10 08:51:11 -04:00
|
|
|
import { ArgOf, IEventRepository } from 'src/interfaces/event.interface';
|
2024-03-20 22:15:09 -05:00
|
|
|
import {
|
|
|
|
|
IBaseJob,
|
|
|
|
|
IEntityJob,
|
|
|
|
|
IJobRepository,
|
|
|
|
|
ISidecarWriteJob,
|
|
|
|
|
JobName,
|
2024-08-29 12:14:03 -04:00
|
|
|
JOBS_ASSET_PAGINATION_SIZE,
|
2024-03-20 22:15:09 -05:00
|
|
|
JobStatus,
|
|
|
|
|
QueueName,
|
2024-03-21 12:59:49 +01:00
|
|
|
} from 'src/interfaces/job.interface';
|
2024-04-17 03:00:31 +05:30
|
|
|
import { ILoggerRepository } from 'src/interfaces/logger.interface';
|
2024-09-07 13:39:10 -04:00
|
|
|
import { IMapRepository, ReverseGeocodeResult } from 'src/interfaces/map.interface';
|
2024-03-21 12:59:49 +01:00
|
|
|
import { IMediaRepository } from 'src/interfaces/media.interface';
|
|
|
|
|
import { IMetadataRepository, ImmichTags } from 'src/interfaces/metadata.interface';
|
|
|
|
|
import { IMoveRepository } from 'src/interfaces/move.interface';
|
|
|
|
|
import { IPersonRepository } from 'src/interfaces/person.interface';
|
|
|
|
|
import { IStorageRepository } from 'src/interfaces/storage.interface';
|
2024-05-15 18:58:23 -04:00
|
|
|
import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface';
|
2024-08-29 12:14:03 -04:00
|
|
|
import { ITagRepository } from 'src/interfaces/tag.interface';
|
2024-04-16 20:04:59 -07:00
|
|
|
import { IUserRepository } from 'src/interfaces/user.interface';
|
2024-09-30 17:31:21 -04:00
|
|
|
import { BaseService } from 'src/services/base.service';
|
2024-09-05 00:23:58 +02:00
|
|
|
import { isFaceImportEnabled } from 'src/utils/misc';
|
2024-03-20 22:15:09 -05:00
|
|
|
import { usePagination } from 'src/utils/pagination';
|
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) */
|
|
|
|
|
const EXIF_DATE_TAGS: Array<keyof Tags> = [
|
|
|
|
|
'SubSecDateTimeOriginal',
|
|
|
|
|
'DateTimeOriginal',
|
|
|
|
|
'SubSecCreateDate',
|
|
|
|
|
'CreationDate',
|
|
|
|
|
'CreateDate',
|
|
|
|
|
'SubSecMediaCreateDate',
|
|
|
|
|
'MediaCreateDate',
|
|
|
|
|
'DateTimeCreated',
|
|
|
|
|
];
|
|
|
|
|
|
2023-12-03 23:34:23 +01:00
|
|
|
export enum Orientation {
|
2024-09-07 13:39:10 -04:00
|
|
|
Horizontal = 1,
|
|
|
|
|
MirrorHorizontal = 2,
|
|
|
|
|
Rotate180 = 3,
|
|
|
|
|
MirrorVertical = 4,
|
|
|
|
|
MirrorHorizontalRotate270CW = 5,
|
|
|
|
|
Rotate90CW = 6,
|
|
|
|
|
MirrorHorizontalRotate90CW = 7,
|
|
|
|
|
Rotate270CW = 8,
|
2023-12-03 23:34:23 +01:00
|
|
|
}
|
|
|
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return val;
|
|
|
|
|
};
|
|
|
|
|
|
2023-09-27 20:44:51 +02:00
|
|
|
@Injectable()
|
2024-09-30 17:31:21 -04:00
|
|
|
export class MetadataService extends BaseService {
|
2023-09-27 20:44:51 +02:00
|
|
|
private storageCore: StorageCore;
|
|
|
|
|
|
2023-05-26 08:52:52 -04:00
|
|
|
constructor(
|
2023-09-27 20:44:51 +02:00
|
|
|
@Inject(IAlbumRepository) private albumRepository: IAlbumRepository,
|
2023-05-26 08:52:52 -04:00
|
|
|
@Inject(IAssetRepository) private assetRepository: IAssetRepository,
|
2024-10-01 16:03:55 -04:00
|
|
|
@Inject(IConfigRepository) configRepository: IConfigRepository,
|
2023-09-27 20:44:51 +02:00
|
|
|
@Inject(ICryptoRepository) private cryptoRepository: ICryptoRepository,
|
2023-12-27 18:36:51 -05:00
|
|
|
@Inject(IDatabaseRepository) private databaseRepository: IDatabaseRepository,
|
2024-10-01 16:03:55 -04:00
|
|
|
@Inject(IEventRepository) private eventRepository: IEventRepository,
|
2023-05-26 08:52:52 -04:00
|
|
|
@Inject(IJobRepository) private jobRepository: IJobRepository,
|
2024-05-29 17:51:01 +02:00
|
|
|
@Inject(IMapRepository) private mapRepository: IMapRepository,
|
2023-12-03 23:34:23 +01:00
|
|
|
@Inject(IMediaRepository) private mediaRepository: IMediaRepository,
|
2023-12-27 18:36:51 -05:00
|
|
|
@Inject(IMetadataRepository) private repository: IMetadataRepository,
|
2023-10-11 04:14:44 +02:00
|
|
|
@Inject(IMoveRepository) moveRepository: IMoveRepository,
|
2024-09-05 00:23:58 +02:00
|
|
|
@Inject(IPersonRepository) private personRepository: IPersonRepository,
|
2023-12-27 18:36:51 -05:00
|
|
|
@Inject(IStorageRepository) private storageRepository: IStorageRepository,
|
2024-05-15 18:58:23 -04:00
|
|
|
@Inject(ISystemMetadataRepository) systemMetadataRepository: ISystemMetadataRepository,
|
2024-08-29 12:14:03 -04:00
|
|
|
@Inject(ITagRepository) private tagRepository: ITagRepository,
|
2024-04-16 20:04:59 -07:00
|
|
|
@Inject(IUserRepository) private userRepository: IUserRepository,
|
2024-09-30 17:31:21 -04:00
|
|
|
@Inject(ILoggerRepository) logger: ILoggerRepository,
|
2023-09-27 20:44:51 +02:00
|
|
|
) {
|
2024-10-01 16:03:55 -04:00
|
|
|
super(configRepository, systemMetadataRepository, logger);
|
2024-04-17 03:00:31 +05:30
|
|
|
this.logger.setContext(MetadataService.name);
|
2023-12-29 18:41:33 +00:00
|
|
|
this.storageCore = StorageCore.create(
|
|
|
|
|
assetRepository,
|
2024-10-01 16:03:55 -04:00
|
|
|
configRepository,
|
2024-04-17 03:00:31 +05:30
|
|
|
cryptoRepository,
|
2023-12-29 18:41:33 +00:00
|
|
|
moveRepository,
|
|
|
|
|
personRepository,
|
|
|
|
|
storageRepository,
|
2024-05-15 18:58:23 -04:00
|
|
|
systemMetadataRepository,
|
2024-04-17 03:00:31 +05:30
|
|
|
this.logger,
|
2023-12-29 18:41:33 +00:00
|
|
|
);
|
2023-09-27 20:44:51 +02:00
|
|
|
}
|
|
|
|
|
|
2024-09-30 10:35:11 -04:00
|
|
|
@OnEvent({ name: 'app.bootstrap' })
|
2024-08-27 18:06:50 -04:00
|
|
|
async onBootstrap(app: ArgOf<'app.bootstrap'>) {
|
2024-06-27 15:54:20 -04:00
|
|
|
if (app !== 'microservices') {
|
|
|
|
|
return;
|
2023-10-31 23:08:21 -04:00
|
|
|
}
|
2024-09-30 17:31:21 -04:00
|
|
|
const config = await this.getConfig({ withCache: false });
|
2024-06-27 15:54:20 -04:00
|
|
|
await this.init(config);
|
|
|
|
|
}
|
|
|
|
|
|
2024-09-30 10:35:11 -04:00
|
|
|
@OnEvent({ name: 'app.shutdown' })
|
|
|
|
|
async onShutdown() {
|
|
|
|
|
await this.repository.teardown();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@OnEvent({ name: 'config.update' })
|
2024-08-27 18:06:50 -04:00
|
|
|
async onConfigUpdate({ newConfig }: ArgOf<'config.update'>) {
|
2024-06-27 15:54:20 -04:00
|
|
|
await this.init(newConfig);
|
|
|
|
|
}
|
2023-10-31 23:08:21 -04:00
|
|
|
|
2024-06-27 15:54:20 -04:00
|
|
|
private async init({ reverseGeocoding }: SystemConfig) {
|
2023-11-25 18:53:30 +00:00
|
|
|
const { enabled } = reverseGeocoding;
|
2023-09-27 20:44:51 +02:00
|
|
|
|
2023-11-25 18:53:30 +00:00
|
|
|
if (!enabled) {
|
2023-09-27 20:44:51 +02:00
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
await this.jobRepository.pause(QueueName.METADATA_EXTRACTION);
|
2024-05-29 17:51:01 +02:00
|
|
|
await this.databaseRepository.withLock(DatabaseLock.GeodataImport, () => this.mapRepository.init());
|
2023-09-27 20:44:51 +02:00
|
|
|
await this.jobRepository.resume(QueueName.METADATA_EXTRACTION);
|
|
|
|
|
|
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);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2024-03-15 14:16:54 +01:00
|
|
|
async handleLivePhotoLinking(job: IEntityJob): Promise<JobStatus> {
|
2023-09-27 20:44:51 +02:00
|
|
|
const { id } = job;
|
2024-03-14 01:58:09 -04:00
|
|
|
const [asset] = await this.assetRepository.getByIds([id], { exifInfo: true });
|
2023-09-27 20:44:51 +02:00
|
|
|
if (!asset?.exifInfo) {
|
2024-03-15 14:16:54 +01:00
|
|
|
return JobStatus.FAILED;
|
2023-09-27 20:44:51 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!asset.exifInfo.livePhotoCID) {
|
2024-03-15 14:16:54 +01:00
|
|
|
return JobStatus.SKIPPED;
|
2023-09-27 20:44:51 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const otherType = asset.type === AssetType.VIDEO ? AssetType.IMAGE : AssetType.VIDEO;
|
|
|
|
|
const match = await this.assetRepository.findLivePhotoMatch({
|
|
|
|
|
livePhotoCID: asset.exifInfo.livePhotoCID,
|
|
|
|
|
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) {
|
2024-03-15 14:16:54 +01:00
|
|
|
return JobStatus.SKIPPED;
|
2023-09-27 20:44:51 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const [photoAsset, motionAsset] = asset.type === AssetType.IMAGE ? [asset, match] : [match, asset];
|
|
|
|
|
|
2024-03-19 22:42:10 -04:00
|
|
|
await this.assetRepository.update({ id: photoAsset.id, livePhotoVideoId: motionAsset.id });
|
|
|
|
|
await this.assetRepository.update({ id: motionAsset.id, isVisible: false });
|
2023-09-27 20:44:51 +02:00
|
|
|
await this.albumRepository.removeAsset(motionAsset.id);
|
|
|
|
|
|
2024-09-10 08:51:11 -04:00
|
|
|
await this.eventRepository.emit('asset.hide', { assetId: motionAsset.id, userId: motionAsset.ownerId });
|
2023-12-06 14:56:09 +00:00
|
|
|
|
2024-03-15 14:16:54 +01:00
|
|
|
return JobStatus.SUCCESS;
|
2023-09-27 20:44:51 +02:00
|
|
|
}
|
|
|
|
|
|
2024-03-15 14:16:54 +01:00
|
|
|
async handleQueueMetadataExtraction(job: IBaseJob): Promise<JobStatus> {
|
2023-09-27 20:44:51 +02: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) {
|
2024-01-01 15:45:42 -05:00
|
|
|
await this.jobRepository.queueAll(
|
|
|
|
|
assets.map((asset) => ({ name: JobName.METADATA_EXTRACTION, data: { id: asset.id } })),
|
|
|
|
|
);
|
2023-09-27 20:44:51 +02:00
|
|
|
}
|
|
|
|
|
|
2024-03-15 14:16:54 +01:00
|
|
|
return JobStatus.SUCCESS;
|
2023-09-27 20:44:51 +02:00
|
|
|
}
|
|
|
|
|
|
2024-03-15 14:16:54 +01:00
|
|
|
async handleMetadataExtraction({ id }: IEntityJob): Promise<JobStatus> {
|
2024-09-30 17:31:21 -04:00
|
|
|
const { metadata, reverseGeocoding } = await this.getConfig({ withCache: true });
|
2023-09-27 20:44:51 +02:00
|
|
|
const [asset] = await this.assetRepository.getByIds([id]);
|
2023-12-29 18:41:33 +00:00
|
|
|
if (!asset) {
|
2024-03-15 14:16:54 +01:00
|
|
|
return JobStatus.FAILED;
|
2023-09-27 20:44:51 +02:00
|
|
|
}
|
|
|
|
|
|
2024-09-07 13:39:10 -04:00
|
|
|
const stats = await this.storageRepository.stat(asset.originalPath);
|
2023-09-27 20:44:51 +02:00
|
|
|
|
2024-09-07 13:39:10 -04:00
|
|
|
const exifTags = await this.getExifTags(asset);
|
2023-12-03 23:34:23 +01:00
|
|
|
|
2024-09-07 13:39:10 -04:00
|
|
|
this.logger.verbose('Exif Tags', exifTags);
|
2024-08-29 12:14:03 -04:00
|
|
|
|
2024-09-07 13:39:10 -04:00
|
|
|
const { dateTimeOriginal, localDateTime, timeZone, modifyDate } = this.getDates(asset, exifTags);
|
|
|
|
|
const { latitude, longitude, country, state, city } = await this.getGeo(exifTags, reverseGeocoding);
|
2023-10-04 18:11:11 -04:00
|
|
|
|
2024-09-26 14:32:10 -04:00
|
|
|
const exifData: Partial<ExifEntity> = {
|
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
|
|
|
|
|
dateTimeOriginal,
|
|
|
|
|
modifyDate,
|
|
|
|
|
timeZone,
|
2023-10-04 18:11:11 -04:00
|
|
|
|
2024-09-07 13:39:10 -04:00
|
|
|
// gps
|
|
|
|
|
latitude,
|
|
|
|
|
longitude,
|
|
|
|
|
country,
|
|
|
|
|
state,
|
|
|
|
|
city,
|
|
|
|
|
|
|
|
|
|
// image/file
|
|
|
|
|
fileSizeInByte: stats.size,
|
|
|
|
|
exifImageHeight: validate(exifTags.ImageHeight),
|
|
|
|
|
exifImageWidth: validate(exifTags.ImageWidth),
|
|
|
|
|
orientation: validate(exifTags.Orientation)?.toString() ?? null,
|
|
|
|
|
projectionType: exifTags.ProjectionType ? String(exifTags.ProjectionType).toUpperCase() : null,
|
|
|
|
|
bitsPerSample: this.getBitsPerSample(exifTags),
|
|
|
|
|
colorspace: exifTags.ColorSpace ?? null,
|
|
|
|
|
|
|
|
|
|
// camera
|
|
|
|
|
make: exifTags.Make ?? null,
|
|
|
|
|
model: exifTags.Model ?? null,
|
|
|
|
|
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,
|
|
|
|
|
lensModel: exifTags.LensModel ?? null,
|
|
|
|
|
fNumber: validate(exifTags.FNumber),
|
|
|
|
|
focalLength: validate(exifTags.FocalLength),
|
|
|
|
|
|
|
|
|
|
// comments
|
|
|
|
|
description: String(exifTags.ImageDescription || exifTags.Description || '').trim(),
|
|
|
|
|
profileDescription: exifTags.ProfileDescription || null,
|
2024-09-23 08:50:18 +01:00
|
|
|
rating: validateRange(exifTags.Rating, 0, 5),
|
2024-09-07 13:39:10 -04:00
|
|
|
|
|
|
|
|
// grouping
|
|
|
|
|
livePhotoCID: (exifTags.ContentIdentifier || exifTags.MediaGroupUUID) ?? null,
|
|
|
|
|
autoStackId: this.getAutoStackId(exifTags),
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
await this.applyTagList(asset, exifTags);
|
|
|
|
|
await this.applyMotionPhotos(asset, exifTags);
|
|
|
|
|
|
|
|
|
|
await this.assetRepository.upsertExif(exifData);
|
2024-08-29 12:14:03 -04:00
|
|
|
|
2024-03-19 22:42:10 -04:00
|
|
|
await this.assetRepository.update({
|
2023-09-27 20:44:51 +02:00
|
|
|
id: asset.id,
|
2024-09-07 13:39:10 -04:00
|
|
|
duration: exifTags.Duration?.toString() ?? null,
|
2023-10-04 18:11:11 -04:00
|
|
|
localDateTime,
|
2023-09-27 20:44:51 +02:00
|
|
|
fileCreatedAt: exifData.dateTimeOriginal ?? undefined,
|
|
|
|
|
});
|
|
|
|
|
|
2024-01-12 19:39:45 -05:00
|
|
|
await this.assetRepository.upsertJobStatus({
|
|
|
|
|
assetId: asset.id,
|
|
|
|
|
metadataExtractedAt: new Date(),
|
|
|
|
|
});
|
|
|
|
|
|
2024-09-05 00:23:58 +02:00
|
|
|
if (isFaceImportEnabled(metadata)) {
|
|
|
|
|
await this.applyTaggedFaces(asset, exifTags);
|
|
|
|
|
}
|
|
|
|
|
|
2024-03-15 14:16:54 +01:00
|
|
|
return JobStatus.SUCCESS;
|
2023-09-27 20:44:51 +02:00
|
|
|
}
|
2023-05-26 08:52:52 -04:00
|
|
|
|
2024-03-15 14:16:54 +01:00
|
|
|
async handleQueueSidecar(job: IBaseJob): Promise<JobStatus> {
|
2023-05-26 15:43:24 -04: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 15:43:24 -04:00
|
|
|
: this.assetRepository.getWithout(pagination, WithoutProperty.SIDECAR);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
for await (const assets of assetPagination) {
|
2024-01-01 15:45:42 -05:00
|
|
|
await this.jobRepository.queueAll(
|
|
|
|
|
assets.map((asset) => ({
|
|
|
|
|
name: force ? JobName.SIDECAR_SYNC : JobName.SIDECAR_DISCOVERY,
|
|
|
|
|
data: { id: asset.id },
|
|
|
|
|
})),
|
|
|
|
|
);
|
2023-05-26 08:52:52 -04:00
|
|
|
}
|
2023-05-26 15:43:24 -04:00
|
|
|
|
2024-03-15 14:16:54 +01:00
|
|
|
return JobStatus.SUCCESS;
|
2023-05-26 08:52:52 -04:00
|
|
|
}
|
|
|
|
|
|
2024-03-15 14:16:54 +01:00
|
|
|
handleSidecarSync({ id }: IEntityJob): Promise<JobStatus> {
|
2024-02-07 18:30:38 +01:00
|
|
|
return this.processSidecar(id, true);
|
2023-05-26 15:43:24 -04:00
|
|
|
}
|
2023-05-26 08:52:52 -04:00
|
|
|
|
2024-03-15 14:16:54 +01:00
|
|
|
handleSidecarDiscovery({ id }: IEntityJob): Promise<JobStatus> {
|
2024-02-07 18:30:38 +01:00
|
|
|
return this.processSidecar(id, false);
|
2023-05-26 08:52:52 -04:00
|
|
|
}
|
2023-09-27 20:44:51 +02:00
|
|
|
|
2024-09-30 10:35:11 -04:00
|
|
|
@OnEvent({ name: 'asset.tag' })
|
2024-08-29 12:14:03 -04:00
|
|
|
async handleTagAsset({ assetId }: ArgOf<'asset.tag'>) {
|
|
|
|
|
await this.jobRepository.queue({ name: JobName.SIDECAR_WRITE, data: { id: assetId, tags: true } });
|
|
|
|
|
}
|
|
|
|
|
|
2024-09-30 10:35:11 -04:00
|
|
|
@OnEvent({ name: 'asset.untag' })
|
2024-08-29 12:14:03 -04:00
|
|
|
async handleUntagAsset({ assetId }: ArgOf<'asset.untag'>) {
|
|
|
|
|
await this.jobRepository.queue({ name: JobName.SIDECAR_WRITE, data: { id: assetId, tags: true } });
|
|
|
|
|
}
|
|
|
|
|
|
2024-03-15 14:16:54 +01:00
|
|
|
async handleSidecarWrite(job: ISidecarWriteJob): Promise<JobStatus> {
|
2024-08-29 12:14:03 -04:00
|
|
|
const { id, description, dateTimeOriginal, latitude, longitude, rating, tags } = job;
|
|
|
|
|
const [asset] = await this.assetRepository.getByIds([id], { tags: true });
|
2023-11-30 04:52:28 +01:00
|
|
|
if (!asset) {
|
2024-03-15 14:16:54 +01: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) {
|
2024-03-15 14:16:54 +01:00
|
|
|
return JobStatus.SKIPPED;
|
2023-11-30 04:52:28 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
await this.repository.writeTags(sidecarPath, exif);
|
|
|
|
|
|
|
|
|
|
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
|
|
|
}
|
|
|
|
|
|
2024-03-15 14:16:54 +01:00
|
|
|
return JobStatus.SUCCESS;
|
2023-11-30 04:52:28 +01:00
|
|
|
}
|
|
|
|
|
|
2024-09-07 13:39:10 -04:00
|
|
|
private async getExifTags(asset: AssetEntity): Promise<ImmichTags> {
|
|
|
|
|
const mediaTags = await this.repository.readTags(asset.originalPath);
|
|
|
|
|
const sidecarTags = asset.sidecarPath ? await this.repository.readTags(asset.sidecarPath) : {};
|
|
|
|
|
const videoTags = asset.type === AssetType.VIDEO ? await this.getVideoTags(asset.originalPath) : {};
|
2023-09-27 20:44:51 +02:00
|
|
|
|
2024-09-07 13:39:10 -04:00
|
|
|
// make sure dates comes from sidecar
|
|
|
|
|
const sidecarDate = firstDateTime(sidecarTags as Tags, EXIF_DATE_TAGS);
|
|
|
|
|
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
|
|
|
|
|
|
|
|
return { ...mediaTags, ...videoTags, ...sidecarTags };
|
2023-09-27 20:44:51 +02:00
|
|
|
}
|
|
|
|
|
|
2024-08-29 12:14:03 -04:00
|
|
|
private async applyTagList(asset: AssetEntity, exifTags: ImmichTags) {
|
2024-09-26 14:32:10 -04:00
|
|
|
const tags: string[] = [];
|
2024-08-29 12:14:03 -04:00
|
|
|
if (exifTags.TagsList) {
|
2024-09-26 14:32:10 -04:00
|
|
|
tags.push(...exifTags.TagsList.map(String));
|
2024-09-03 18:25:09 -04:00
|
|
|
} else if (exifTags.HierarchicalSubject) {
|
|
|
|
|
tags.push(
|
2024-09-09 14:28:14 -04:00
|
|
|
...exifTags.HierarchicalSubject.map((tag) =>
|
2024-09-26 14:32:10 -04:00
|
|
|
String(tag)
|
2024-09-03 18:25:09 -04:00
|
|
|
// convert | to /
|
|
|
|
|
.replaceAll('/', '<PLACEHOLDER>')
|
|
|
|
|
.replaceAll('|', '/')
|
|
|
|
|
.replaceAll('<PLACEHOLDER>', '|'),
|
|
|
|
|
),
|
|
|
|
|
);
|
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];
|
|
|
|
|
}
|
2024-09-26 14:32:10 -04:00
|
|
|
tags.push(...keywords.map(String));
|
2024-08-29 12:14:03 -04:00
|
|
|
}
|
|
|
|
|
|
2024-09-26 14:32:10 -04:00
|
|
|
const results = await upsertTags(this.tagRepository, { userId: asset.ownerId, tags });
|
2024-09-05 09:24:10 -04:00
|
|
|
await this.tagRepository.upsertAssetTags({ assetId: asset.id, tagIds: results.map((tag) => tag.id) });
|
2024-08-29 12:14:03 -04:00
|
|
|
}
|
|
|
|
|
|
2023-09-27 20:44:51 +02:00
|
|
|
private async applyMotionPhotos(asset: AssetEntity, tags: ImmichTags) {
|
2024-01-22 10:04:45 -08:00
|
|
|
if (asset.type !== AssetType.IMAGE) {
|
2023-09-27 20:44:51 +02:00
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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) {
|
2024-09-09 23:54:24 -04: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;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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 10:04:45 -08: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
|
|
|
|
2024-05-31 13:44:04 -04:00
|
|
|
let motionAsset = await this.assetRepository.getByChecksum({
|
|
|
|
|
ownerId: asset.ownerId,
|
|
|
|
|
libraryId: asset.libraryId ?? undefined,
|
|
|
|
|
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`,
|
|
|
|
|
);
|
2024-04-11 06:49:21 -07:00
|
|
|
|
|
|
|
|
// Hide the motion photo video asset if it's not already hidden to prepare for linking
|
|
|
|
|
if (motionAsset.isVisible) {
|
|
|
|
|
await this.assetRepository.update({ id: motionAsset.id, isVisible: false });
|
|
|
|
|
this.logger.log(`Hid unlinked motion photo video asset (${motionAsset.id})`);
|
|
|
|
|
}
|
2024-02-02 04:18:00 +01:00
|
|
|
} else {
|
2024-01-22 10:04:45 -08:00
|
|
|
const motionAssetId = this.cryptoRepository.randomUUID();
|
2023-10-04 18:11:11 -04:00
|
|
|
const createdAt = asset.fileCreatedAt ?? asset.createdAt;
|
|
|
|
|
motionAsset = await this.assetRepository.create({
|
2024-01-22 10:04:45 -08:00
|
|
|
id: motionAssetId,
|
2023-09-27 20:44:51 +02:00
|
|
|
libraryId: asset.libraryId,
|
|
|
|
|
type: AssetType.VIDEO,
|
2023-10-04 18:11:11 -04:00
|
|
|
fileCreatedAt: createdAt,
|
2023-09-27 20:44:51 +02:00
|
|
|
fileModifiedAt: asset.fileModifiedAt,
|
2023-10-04 18:11:11 -04:00
|
|
|
localDateTime: createdAt,
|
2023-09-27 20:44:51 +02:00
|
|
|
checksum,
|
|
|
|
|
ownerId: asset.ownerId,
|
2024-05-13 16:38:11 -04:00
|
|
|
originalPath: StorageCore.getAndroidMotionPath(asset, motionAssetId),
|
2024-05-24 22:38:18 +02:00
|
|
|
originalFileName: `${path.parse(asset.originalFileName).name}.mp4`,
|
2023-09-27 20:44:51 +02:00
|
|
|
isVisible: false,
|
|
|
|
|
deviceAssetId: 'NONE',
|
|
|
|
|
deviceId: 'NONE',
|
|
|
|
|
});
|
|
|
|
|
|
2024-04-16 20:04:59 -07:00
|
|
|
if (!asset.isExternal) {
|
|
|
|
|
await this.userRepository.updateUsage(asset.ownerId, video.byteLength);
|
|
|
|
|
}
|
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({
|
|
|
|
|
name: JobName.ASSET_DELETION,
|
|
|
|
|
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}`);
|
|
|
|
|
await this.jobRepository.queue({ name: JobName.METADATA_EXTRACTION, data: { id: motionAsset.id } });
|
|
|
|
|
}
|
|
|
|
|
|
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);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2024-09-05 00:23:58 +02:00
|
|
|
private async applyTaggedFaces(asset: AssetEntity, tags: ImmichTags) {
|
|
|
|
|
if (!tags.RegionInfo?.AppliedToDimensions || tags.RegionInfo.RegionList.length === 0) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const discoveredFaces: Partial<AssetFaceEntity>[] = [];
|
|
|
|
|
const existingNames = await this.personRepository.getDistinctNames(asset.ownerId, { withHidden: true });
|
|
|
|
|
const existingNameMap = new Map(existingNames.map(({ id, name }) => [name.toLowerCase(), id]));
|
|
|
|
|
const missing: Partial<PersonEntity>[] = [];
|
|
|
|
|
const missingWithFaceAsset: Partial<PersonEntity>[] = [];
|
|
|
|
|
for (const region of tags.RegionInfo.RegionList) {
|
|
|
|
|
if (!region.Name) {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const imageWidth = tags.RegionInfo.AppliedToDimensions.W;
|
|
|
|
|
const imageHeight = tags.RegionInfo.AppliedToDimensions.H;
|
|
|
|
|
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),
|
|
|
|
|
sourceType: SourceType.EXIF,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
discoveredFaces.push(face);
|
|
|
|
|
if (!existingNameMap.has(loweredName)) {
|
|
|
|
|
missing.push({ id: personId, ownerId: asset.ownerId, name: region.Name });
|
|
|
|
|
missingWithFaceAsset.push({ id: personId, faceAssetId: face.id });
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (missing.length > 0) {
|
|
|
|
|
this.logger.debug(`Creating missing persons: ${missing.map((p) => `${p.name}/${p.id}`)}`);
|
|
|
|
|
}
|
|
|
|
|
|
2024-09-10 09:48:29 -04:00
|
|
|
const newPersonIds = await this.personRepository.createAll(missing);
|
2024-09-05 00:23:58 +02:00
|
|
|
|
|
|
|
|
const faceIds = await this.personRepository.replaceFaces(asset.id, discoveredFaces, SourceType.EXIF);
|
|
|
|
|
this.logger.debug(`Created ${faceIds.length} faces for asset ${asset.id}`);
|
|
|
|
|
|
2024-09-10 09:48:29 -04:00
|
|
|
await this.personRepository.updateAll(missingWithFaceAsset);
|
2024-09-05 00:23:58 +02:00
|
|
|
|
|
|
|
|
await this.jobRepository.queueAll(
|
2024-09-10 09:48:29 -04:00
|
|
|
newPersonIds.map((id) => ({ name: JobName.GENERATE_PERSON_THUMBNAIL, data: { id } })),
|
2024-09-05 00:23:58 +02:00
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2024-09-07 13:39:10 -04:00
|
|
|
private getDates(asset: AssetEntity, exifTags: ImmichTags) {
|
|
|
|
|
const dateTime = firstDateTime(exifTags as Maybe<Tags>, EXIF_DATE_TAGS);
|
|
|
|
|
this.logger.debug(`Asset ${asset.id} date time is ${dateTime}`);
|
2023-11-21 17:58:56 +01:00
|
|
|
|
2024-09-07 13:39:10 -04:00
|
|
|
// created
|
|
|
|
|
let dateTimeOriginal = dateTime?.toDate();
|
|
|
|
|
if (!dateTimeOriginal) {
|
|
|
|
|
this.logger.warn(`Asset ${asset.id} has no valid date (${dateTime}), falling back to asset.fileCreatedAt`);
|
|
|
|
|
dateTimeOriginal = asset.fileCreatedAt;
|
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) {
|
|
|
|
|
this.logger.debug(`Asset ${asset.id} timezone is ${timeZone} (via ${exifTags.tzSource})`);
|
|
|
|
|
} else {
|
|
|
|
|
this.logger.warn(`Asset ${asset.id} has no time zone information`);
|
|
|
|
|
}
|
2023-09-27 20:44:51 +02:00
|
|
|
|
2024-09-07 13:39:10 -04:00
|
|
|
// offset minutes
|
|
|
|
|
const offsetMinutes = dateTime?.tzoffsetMinutes || 0;
|
|
|
|
|
let localDateTime = dateTimeOriginal;
|
|
|
|
|
if (offsetMinutes) {
|
|
|
|
|
localDateTime = new Date(dateTimeOriginal.getTime() + offsetMinutes * 60_000);
|
|
|
|
|
this.logger.debug(`Asset ${asset.id} local time is offset by ${offsetMinutes} minutes`);
|
|
|
|
|
}
|
2024-09-04 16:27:04 +02:00
|
|
|
|
2024-09-13 22:30:06 -05:00
|
|
|
let modifyDate = asset.fileModifiedAt;
|
|
|
|
|
try {
|
|
|
|
|
modifyDate = (exifTags.ModifyDate as ExifDateTime)?.toDate() ?? modifyDate;
|
|
|
|
|
} catch {}
|
|
|
|
|
|
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,
|
2024-09-13 22:30:06 -05:00
|
|
|
modifyDate,
|
2023-09-27 20:44:51 +02:00
|
|
|
};
|
2024-09-07 13:39:10 -04:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private async getGeo(tags: ImmichTags, reverseGeocoding: SystemConfig['reverseGeocoding']) {
|
|
|
|
|
let latitude = validate(tags.GPSLatitude);
|
|
|
|
|
let longitude = validate(tags.GPSLongitude);
|
|
|
|
|
|
|
|
|
|
// TODO take ref into account
|
|
|
|
|
|
|
|
|
|
if (latitude === 0 && longitude === 0) {
|
|
|
|
|
this.logger.warn('Latitude and longitude of 0, setting to null');
|
|
|
|
|
latitude = null;
|
|
|
|
|
longitude = null;
|
|
|
|
|
}
|
2023-12-11 07:00:23 -08:00
|
|
|
|
2024-09-07 13:39:10 -04:00
|
|
|
let result: ReverseGeocodeResult = { country: null, state: null, city: null };
|
|
|
|
|
if (reverseGeocoding.enabled && longitude && latitude) {
|
|
|
|
|
result = await this.mapRepository.reverseGeocode({ latitude, longitude });
|
2023-12-11 07:00:23 -08:00
|
|
|
}
|
|
|
|
|
|
2024-09-07 13:39:10 -04:00
|
|
|
return { ...result, latitude, longitude };
|
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-09-07 13:39:10 -04:00
|
|
|
tags.Orientation = Orientation.Rotate90CW;
|
2024-08-06 18:27:05 +02:00
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
case 0: {
|
2024-09-07 13:39:10 -04:00
|
|
|
tags.Orientation = Orientation.Horizontal;
|
2024-08-06 18:27:05 +02:00
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
case 90: {
|
2024-09-07 13:39:10 -04:00
|
|
|
tags.Orientation = Orientation.Rotate270CW;
|
2024-08-06 18:27:05 +02:00
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
case 180: {
|
2024-09-07 13:39:10 -04:00
|
|
|
tags.Orientation = Orientation.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
|
|
|
}
|
2024-02-07 18:30:38 +01:00
|
|
|
|
2024-03-15 14:16:54 +01:00
|
|
|
private async processSidecar(id: string, isSync: boolean): Promise<JobStatus> {
|
2024-02-07 18:30:38 +01:00
|
|
|
const [asset] = await this.assetRepository.getByIds([id]);
|
|
|
|
|
|
|
|
|
|
if (!asset) {
|
2024-03-15 14:16:54 +01:00
|
|
|
return JobStatus.FAILED;
|
2024-02-07 18:30:38 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (isSync && !asset.sidecarPath) {
|
2024-03-15 14:16:54 +01:00
|
|
|
return JobStatus.FAILED;
|
2024-02-07 18:30:38 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!isSync && (!asset.isVisible || asset.sidecarPath)) {
|
2024-03-15 14:16:54 +01:00
|
|
|
return JobStatus.FAILED;
|
2024-02-07 18:30:38 +01:00
|
|
|
}
|
|
|
|
|
|
2024-03-13 10:14:26 -07:00
|
|
|
// XMP sidecars can come in two filename formats. For a photo named photo.ext, the filenames are photo.ext.xmp and photo.xmp
|
|
|
|
|
const assetPath = path.parse(asset.originalPath);
|
|
|
|
|
const assetPathWithoutExt = path.join(assetPath.dir, assetPath.name);
|
|
|
|
|
const sidecarPathWithoutExt = `${assetPathWithoutExt}.xmp`;
|
|
|
|
|
const sidecarPathWithExt = `${asset.originalPath}.xmp`;
|
|
|
|
|
|
|
|
|
|
const [sidecarPathWithExtExists, sidecarPathWithoutExtExists] = await Promise.all([
|
|
|
|
|
this.storageRepository.checkFileExists(sidecarPathWithExt, constants.R_OK),
|
|
|
|
|
this.storageRepository.checkFileExists(sidecarPathWithoutExt, constants.R_OK),
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
let sidecarPath = null;
|
|
|
|
|
if (sidecarPathWithExtExists) {
|
|
|
|
|
sidecarPath = sidecarPathWithExt;
|
|
|
|
|
} else if (sidecarPathWithoutExtExists) {
|
|
|
|
|
sidecarPath = sidecarPathWithoutExt;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (sidecarPath) {
|
2024-03-19 22:42:10 -04:00
|
|
|
await this.assetRepository.update({ id: asset.id, sidecarPath });
|
2024-03-15 14:16:54 +01:00
|
|
|
return JobStatus.SUCCESS;
|
2024-02-07 18:30:38 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!isSync) {
|
2024-03-15 14:16:54 +01:00
|
|
|
return JobStatus.FAILED;
|
2024-02-07 18:30:38 +01:00
|
|
|
}
|
|
|
|
|
|
2024-03-13 10:14:26 -07:00
|
|
|
this.logger.debug(
|
|
|
|
|
`Sidecar file was not found. Checked paths '${sidecarPathWithExt}' and '${sidecarPathWithoutExt}'. Removing sidecarPath for asset ${asset.id}`,
|
|
|
|
|
);
|
2024-03-19 22:42:10 -04:00
|
|
|
await this.assetRepository.update({ id: asset.id, sidecarPath: null });
|
2024-02-07 18:30:38 +01:00
|
|
|
|
2024-03-15 14:16:54 +01:00
|
|
|
return JobStatus.SUCCESS;
|
2024-02-07 18:30:38 +01:00
|
|
|
}
|
2023-05-26 08:52:52 -04:00
|
|
|
}
|