mirror of
https://github.com/immich-app/immich.git
synced 2024-12-26 10:50:29 +02:00
refactor(server): Move metadata extraction to domain (#4243)
* use storageRepository in metadata extraction * move metadata extraction processor to domain * cleanup infra/domain --------- Co-authored-by: Jason Rasmussen <jrasm91@gmail.com>
This commit is contained in:
parent
9bada51d56
commit
3a44e8f8d3
@ -148,7 +148,7 @@
|
|||||||
"coverageDirectory": "./coverage",
|
"coverageDirectory": "./coverage",
|
||||||
"coverageThreshold": {
|
"coverageThreshold": {
|
||||||
"./src/domain/": {
|
"./src/domain/": {
|
||||||
"branches": 80,
|
"branches": 75,
|
||||||
"functions": 80,
|
"functions": 80,
|
||||||
"lines": 90,
|
"lines": 90,
|
||||||
"statements": 90
|
"statements": 90
|
||||||
|
@ -1,20 +0,0 @@
|
|||||||
import { InitOptions } from 'local-reverse-geocoder';
|
|
||||||
|
|
||||||
export const IGeocodingRepository = 'IGeocodingRepository';
|
|
||||||
|
|
||||||
export interface GeoPoint {
|
|
||||||
latitude: number;
|
|
||||||
longitude: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ReverseGeocodeResult {
|
|
||||||
country: string | null;
|
|
||||||
state: string | null;
|
|
||||||
city: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface IGeocodingRepository {
|
|
||||||
init(options: Partial<InitOptions>): Promise<void>;
|
|
||||||
reverseGeocode(point: GeoPoint): Promise<ReverseGeocodeResult>;
|
|
||||||
deleteCache(): Promise<void>;
|
|
||||||
}
|
|
@ -1,2 +1,2 @@
|
|||||||
export * from './geocoding.repository';
|
export * from './metadata.repository';
|
||||||
export * from './metadata.service';
|
export * from './metadata.service';
|
||||||
|
31
server/src/domain/metadata/metadata.repository.ts
Normal file
31
server/src/domain/metadata/metadata.repository.ts
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
import { Tags } from 'exiftool-vendored';
|
||||||
|
import { InitOptions } from 'local-reverse-geocoder';
|
||||||
|
|
||||||
|
export const IMetadataRepository = 'IMetadataRepository';
|
||||||
|
|
||||||
|
export interface GeoPoint {
|
||||||
|
latitude: number;
|
||||||
|
longitude: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReverseGeocodeResult {
|
||||||
|
country: string | null;
|
||||||
|
state: string | null;
|
||||||
|
city: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ImmichTags extends Tags {
|
||||||
|
ContentIdentifier?: string;
|
||||||
|
MotionPhoto?: number;
|
||||||
|
MotionPhotoVersion?: number;
|
||||||
|
MotionPhotoPresentationTimestampUs?: number;
|
||||||
|
MediaGroupUUID?: string;
|
||||||
|
ImagePixelDepth?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IMetadataRepository {
|
||||||
|
init(options: Partial<InitOptions>): Promise<void>;
|
||||||
|
reverseGeocode(point: GeoPoint): Promise<ReverseGeocodeResult>;
|
||||||
|
deleteCache(): Promise<void>;
|
||||||
|
getExifTags(path: string): Promise<ImmichTags | null>;
|
||||||
|
}
|
@ -1,22 +1,43 @@
|
|||||||
import { assetStub, newAssetRepositoryMock, newJobRepositoryMock, newStorageRepositoryMock } from '@test';
|
import {
|
||||||
|
assetStub,
|
||||||
|
newAlbumRepositoryMock,
|
||||||
|
newAssetRepositoryMock,
|
||||||
|
newCryptoRepositoryMock,
|
||||||
|
newJobRepositoryMock,
|
||||||
|
newMetadataRepositoryMock,
|
||||||
|
newStorageRepositoryMock,
|
||||||
|
newSystemConfigRepositoryMock,
|
||||||
|
} from '@test';
|
||||||
import { constants } from 'fs/promises';
|
import { constants } from 'fs/promises';
|
||||||
|
import { IAlbumRepository } from '../album';
|
||||||
import { IAssetRepository, WithProperty, WithoutProperty } from '../asset';
|
import { IAssetRepository, WithProperty, WithoutProperty } from '../asset';
|
||||||
|
import { ICryptoRepository } from '../crypto';
|
||||||
import { IJobRepository, JobName } from '../job';
|
import { IJobRepository, JobName } from '../job';
|
||||||
import { IStorageRepository } from '../storage';
|
import { IStorageRepository } from '../storage';
|
||||||
|
import { ISystemConfigRepository } from '../system-config';
|
||||||
|
import { IMetadataRepository } from './metadata.repository';
|
||||||
import { MetadataService } from './metadata.service';
|
import { MetadataService } from './metadata.service';
|
||||||
|
|
||||||
describe(MetadataService.name, () => {
|
describe(MetadataService.name, () => {
|
||||||
let sut: MetadataService;
|
let albumMock: jest.Mocked<IAlbumRepository>;
|
||||||
let assetMock: jest.Mocked<IAssetRepository>;
|
let assetMock: jest.Mocked<IAssetRepository>;
|
||||||
|
let configMock: jest.Mocked<ISystemConfigRepository>;
|
||||||
|
let cryptoRepository: jest.Mocked<ICryptoRepository>;
|
||||||
let jobMock: jest.Mocked<IJobRepository>;
|
let jobMock: jest.Mocked<IJobRepository>;
|
||||||
|
let metadataMock: jest.Mocked<IMetadataRepository>;
|
||||||
let storageMock: jest.Mocked<IStorageRepository>;
|
let storageMock: jest.Mocked<IStorageRepository>;
|
||||||
|
let sut: MetadataService;
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
|
albumMock = newAlbumRepositoryMock();
|
||||||
assetMock = newAssetRepositoryMock();
|
assetMock = newAssetRepositoryMock();
|
||||||
|
configMock = newSystemConfigRepositoryMock();
|
||||||
|
cryptoRepository = newCryptoRepositoryMock();
|
||||||
jobMock = newJobRepositoryMock();
|
jobMock = newJobRepositoryMock();
|
||||||
|
metadataMock = newMetadataRepositoryMock();
|
||||||
storageMock = newStorageRepositoryMock();
|
storageMock = newStorageRepositoryMock();
|
||||||
|
|
||||||
sut = new MetadataService(assetMock, jobMock, storageMock);
|
sut = new MetadataService(albumMock, assetMock, cryptoRepository, jobMock, metadataMock, storageMock, configMock);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should be defined', () => {
|
it('should be defined', () => {
|
||||||
|
@ -1,16 +1,148 @@
|
|||||||
import { Inject } from '@nestjs/common';
|
import { AssetEntity, AssetType, ExifEntity } from '@app/infra/entities';
|
||||||
|
import { Inject, Injectable, Logger } from '@nestjs/common';
|
||||||
|
import { ExifDateTime } from 'exiftool-vendored';
|
||||||
|
import { firstDateTime } from 'exiftool-vendored/dist/FirstDateTime';
|
||||||
import { constants } from 'fs/promises';
|
import { constants } from 'fs/promises';
|
||||||
import { IAssetRepository, WithoutProperty, WithProperty } from '../asset';
|
import { Duration } from 'luxon';
|
||||||
|
import { IAlbumRepository } from '../album';
|
||||||
|
import { IAssetRepository, WithProperty, WithoutProperty } from '../asset';
|
||||||
|
import { ICryptoRepository } from '../crypto';
|
||||||
import { usePagination } from '../domain.util';
|
import { usePagination } from '../domain.util';
|
||||||
import { IBaseJob, IEntityJob, IJobRepository, JobName, JOBS_ASSET_PAGINATION_SIZE } from '../job';
|
import { IBaseJob, IEntityJob, IJobRepository, JOBS_ASSET_PAGINATION_SIZE, JobName, QueueName } from '../job';
|
||||||
import { IStorageRepository } from '../storage';
|
import { IStorageRepository, StorageCore, StorageFolder } from '../storage';
|
||||||
|
import { FeatureFlag, ISystemConfigRepository, SystemConfigCore } from '../system-config';
|
||||||
|
import { IMetadataRepository, ImmichTags } from './metadata.repository';
|
||||||
|
|
||||||
|
interface DirectoryItem {
|
||||||
|
Length?: number;
|
||||||
|
Mime: string;
|
||||||
|
Padding?: number;
|
||||||
|
Semantic?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DirectoryEntry {
|
||||||
|
Item: DirectoryItem;
|
||||||
|
}
|
||||||
|
|
||||||
|
const exifDate = (dt: ExifDateTime | string | undefined) => (dt instanceof ExifDateTime ? dt?.toDate() : null);
|
||||||
|
// exiftool returns strings when it fails to parse non-string values, so this is used where a string is not expected
|
||||||
|
const validate = <T>(value: T): T | null => (typeof value === 'string' ? null : value ?? null);
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
export class MetadataService {
|
export class MetadataService {
|
||||||
|
private logger = new Logger(MetadataService.name);
|
||||||
|
private storageCore: StorageCore;
|
||||||
|
private configCore: SystemConfigCore;
|
||||||
|
private oldCities?: string;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
|
@Inject(IAlbumRepository) private albumRepository: IAlbumRepository,
|
||||||
@Inject(IAssetRepository) private assetRepository: IAssetRepository,
|
@Inject(IAssetRepository) private assetRepository: IAssetRepository,
|
||||||
|
@Inject(ICryptoRepository) private cryptoRepository: ICryptoRepository,
|
||||||
@Inject(IJobRepository) private jobRepository: IJobRepository,
|
@Inject(IJobRepository) private jobRepository: IJobRepository,
|
||||||
|
@Inject(IMetadataRepository) private repository: IMetadataRepository,
|
||||||
@Inject(IStorageRepository) private storageRepository: IStorageRepository,
|
@Inject(IStorageRepository) private storageRepository: IStorageRepository,
|
||||||
) {}
|
@Inject(ISystemConfigRepository) configRepository: ISystemConfigRepository,
|
||||||
|
) {
|
||||||
|
this.storageCore = new StorageCore(storageRepository);
|
||||||
|
this.configCore = new SystemConfigCore(configRepository);
|
||||||
|
this.configCore.config$.subscribe(() => this.init());
|
||||||
|
}
|
||||||
|
|
||||||
|
async init(deleteCache = false) {
|
||||||
|
const { reverseGeocoding } = await this.configCore.getConfig();
|
||||||
|
const { citiesFileOverride } = reverseGeocoding;
|
||||||
|
|
||||||
|
if (!reverseGeocoding.enabled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (deleteCache) {
|
||||||
|
await this.repository.deleteCache();
|
||||||
|
} else if (this.oldCities && this.oldCities === citiesFileOverride) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.jobRepository.pause(QueueName.METADATA_EXTRACTION);
|
||||||
|
await this.repository.init({ citiesFileOverride });
|
||||||
|
await this.jobRepository.resume(QueueName.METADATA_EXTRACTION);
|
||||||
|
|
||||||
|
this.logger.log(`Initialized local reverse geocoder with ${citiesFileOverride}`);
|
||||||
|
this.oldCities = citiesFileOverride;
|
||||||
|
} catch (error: Error | any) {
|
||||||
|
this.logger.error(`Unable to initialize reverse geocoding: ${error}`, error?.stack);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async handleLivePhotoLinking(job: IEntityJob) {
|
||||||
|
const { id } = job;
|
||||||
|
const [asset] = await this.assetRepository.getByIds([id]);
|
||||||
|
if (!asset?.exifInfo) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!asset.exifInfo.livePhotoCID) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const otherType = asset.type === AssetType.VIDEO ? AssetType.IMAGE : AssetType.VIDEO;
|
||||||
|
const match = await this.assetRepository.findLivePhotoMatch({
|
||||||
|
livePhotoCID: asset.exifInfo.livePhotoCID,
|
||||||
|
ownerId: asset.ownerId,
|
||||||
|
otherAssetId: asset.id,
|
||||||
|
type: otherType,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!match) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [photoAsset, motionAsset] = asset.type === AssetType.IMAGE ? [asset, match] : [match, asset];
|
||||||
|
|
||||||
|
await this.assetRepository.save({ id: photoAsset.id, livePhotoVideoId: motionAsset.id });
|
||||||
|
await this.assetRepository.save({ id: motionAsset.id, isVisible: false });
|
||||||
|
await this.albumRepository.removeAsset(motionAsset.id);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async handleQueueMetadataExtraction(job: IBaseJob) {
|
||||||
|
const { force } = job;
|
||||||
|
const assetPagination = usePagination(JOBS_ASSET_PAGINATION_SIZE, (pagination) => {
|
||||||
|
return force
|
||||||
|
? this.assetRepository.getAll(pagination)
|
||||||
|
: this.assetRepository.getWithout(pagination, WithoutProperty.EXIF);
|
||||||
|
});
|
||||||
|
|
||||||
|
for await (const assets of assetPagination) {
|
||||||
|
for (const asset of assets) {
|
||||||
|
await this.jobRepository.queue({ name: JobName.METADATA_EXTRACTION, data: { id: asset.id } });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async handleMetadataExtraction({ id }: IEntityJob) {
|
||||||
|
const [asset] = await this.assetRepository.getByIds([id]);
|
||||||
|
if (!asset || !asset.isVisible) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { exifData, tags } = await this.exifData(asset);
|
||||||
|
|
||||||
|
await this.applyMotionPhotos(asset, tags);
|
||||||
|
await this.applyReverseGeocoding(asset, exifData);
|
||||||
|
await this.assetRepository.upsertExif(exifData);
|
||||||
|
await this.assetRepository.save({
|
||||||
|
id: asset.id,
|
||||||
|
duration: tags.Duration ? this.getDuration(tags.Duration) : null,
|
||||||
|
fileCreatedAt: exifData.dateTimeOriginal ?? undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
async handleQueueSidecar(job: IBaseJob) {
|
async handleQueueSidecar(job: IBaseJob) {
|
||||||
const { force } = job;
|
const { force } = job;
|
||||||
@ -51,4 +183,156 @@ export class MetadataService {
|
|||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async applyReverseGeocoding(asset: AssetEntity, exifData: ExifEntity) {
|
||||||
|
const { latitude, longitude } = exifData;
|
||||||
|
if (!(await this.configCore.hasFeature(FeatureFlag.REVERSE_GEOCODING)) || !longitude || !latitude) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { city, state, country } = await this.repository.reverseGeocode({ latitude, longitude });
|
||||||
|
Object.assign(exifData, { city, state, country });
|
||||||
|
} catch (error: Error | any) {
|
||||||
|
this.logger.warn(
|
||||||
|
`Unable to run reverse geocoding due to ${error} for asset ${asset.id} at ${asset.originalPath}`,
|
||||||
|
error?.stack,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async applyMotionPhotos(asset: AssetEntity, tags: ImmichTags) {
|
||||||
|
if (asset.type !== AssetType.IMAGE || asset.livePhotoVideoId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rawDirectory = tags.Directory;
|
||||||
|
const isMotionPhoto = tags.MotionPhoto;
|
||||||
|
const isMicroVideo = tags.MicroVideo;
|
||||||
|
const videoOffset = tags.MicroVideoOffset;
|
||||||
|
const directory = Array.isArray(rawDirectory) ? (rawDirectory as DirectoryEntry[]) : null;
|
||||||
|
|
||||||
|
let length = 0;
|
||||||
|
let padding = 0;
|
||||||
|
|
||||||
|
if (isMotionPhoto && directory) {
|
||||||
|
for (const entry of directory) {
|
||||||
|
if (entry.Item.Semantic == 'MotionPhoto') {
|
||||||
|
length = entry.Item.Length ?? 0;
|
||||||
|
padding = entry.Item.Padding ?? 0;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isMicroVideo && typeof videoOffset === 'number') {
|
||||||
|
length = videoOffset;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.logger.debug(`Starting motion photo video extraction (${asset.id})`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const stat = await this.storageRepository.stat(asset.originalPath);
|
||||||
|
const position = stat.size - length - padding;
|
||||||
|
const video = await this.storageRepository.readFile(asset.originalPath, {
|
||||||
|
buffer: Buffer.alloc(length),
|
||||||
|
position,
|
||||||
|
length,
|
||||||
|
});
|
||||||
|
const checksum = await this.cryptoRepository.hashSha1(video);
|
||||||
|
|
||||||
|
let motionAsset = await this.assetRepository.getByChecksum(asset.ownerId, checksum);
|
||||||
|
if (!motionAsset) {
|
||||||
|
motionAsset = await this.assetRepository.save({
|
||||||
|
libraryId: asset.libraryId,
|
||||||
|
type: AssetType.VIDEO,
|
||||||
|
fileCreatedAt: asset.fileCreatedAt ?? asset.createdAt,
|
||||||
|
fileModifiedAt: asset.fileModifiedAt,
|
||||||
|
checksum,
|
||||||
|
ownerId: asset.ownerId,
|
||||||
|
originalPath: this.storageCore.ensurePath(StorageFolder.ENCODED_VIDEO, asset.ownerId, `${asset.id}-MP.mp4`),
|
||||||
|
originalFileName: asset.originalFileName,
|
||||||
|
isVisible: false,
|
||||||
|
isReadOnly: true,
|
||||||
|
deviceAssetId: 'NONE',
|
||||||
|
deviceId: 'NONE',
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.storageRepository.writeFile(asset.originalPath, video);
|
||||||
|
|
||||||
|
await this.jobRepository.queue({ name: JobName.METADATA_EXTRACTION, data: { id: motionAsset.id } });
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.assetRepository.save({ id: asset.id, livePhotoVideoId: motionAsset.id });
|
||||||
|
|
||||||
|
this.logger.debug(`Finished motion photo video extraction (${asset.id})`);
|
||||||
|
} catch (error: Error | any) {
|
||||||
|
this.logger.error(`Failed to extract live photo ${asset.originalPath}: ${error}`, error?.stack);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async exifData(asset: AssetEntity): Promise<{ exifData: ExifEntity; tags: ImmichTags }> {
|
||||||
|
const stats = await this.storageRepository.stat(asset.originalPath);
|
||||||
|
const mediaTags = await this.repository.getExifTags(asset.originalPath);
|
||||||
|
const sidecarTags = asset.sidecarPath ? await this.repository.getExifTags(asset.sidecarPath) : null;
|
||||||
|
const tags = { ...mediaTags, ...sidecarTags };
|
||||||
|
|
||||||
|
this.logger.verbose('Exif Tags', tags);
|
||||||
|
|
||||||
|
return {
|
||||||
|
exifData: <ExifEntity>{
|
||||||
|
// altitude: tags.GPSAltitude ?? null,
|
||||||
|
assetId: asset.id,
|
||||||
|
bitsPerSample: this.getBitsPerSample(tags),
|
||||||
|
colorspace: tags.ColorSpace ?? null,
|
||||||
|
dateTimeOriginal: exifDate(firstDateTime(tags)) ?? asset.fileCreatedAt,
|
||||||
|
exifImageHeight: validate(tags.ImageHeight),
|
||||||
|
exifImageWidth: validate(tags.ImageWidth),
|
||||||
|
exposureTime: tags.ExposureTime ?? null,
|
||||||
|
fileSizeInByte: stats.size,
|
||||||
|
fNumber: validate(tags.FNumber),
|
||||||
|
focalLength: validate(tags.FocalLength),
|
||||||
|
fps: validate(tags.VideoFrameRate),
|
||||||
|
iso: validate(tags.ISO),
|
||||||
|
latitude: validate(tags.GPSLatitude),
|
||||||
|
lensModel: tags.LensModel ?? null,
|
||||||
|
livePhotoCID: (asset.type === AssetType.VIDEO ? tags.ContentIdentifier : tags.MediaGroupUUID) ?? null,
|
||||||
|
longitude: validate(tags.GPSLongitude),
|
||||||
|
make: tags.Make ?? null,
|
||||||
|
model: tags.Model ?? null,
|
||||||
|
modifyDate: exifDate(tags.ModifyDate) ?? asset.fileModifiedAt,
|
||||||
|
orientation: validate(tags.Orientation)?.toString() ?? null,
|
||||||
|
profileDescription: tags.ProfileDescription || tags.ProfileName || null,
|
||||||
|
projectionType: tags.ProjectionType ? String(tags.ProjectionType).toUpperCase() : null,
|
||||||
|
timeZone: tags.tz,
|
||||||
|
},
|
||||||
|
tags,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
private getDuration(seconds?: number): string {
|
||||||
|
return Duration.fromObject({ seconds }).toFormat('hh:mm:ss.SSS');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
import { Stats } from 'fs';
|
import { Stats } from 'fs';
|
||||||
|
import { FileReadOptions } from 'fs/promises';
|
||||||
import { Readable } from 'stream';
|
import { Readable } from 'stream';
|
||||||
import { CrawlOptionsDto } from '../library';
|
import { CrawlOptionsDto } from '../library';
|
||||||
|
|
||||||
@ -24,6 +25,8 @@ export const IStorageRepository = 'IStorageRepository';
|
|||||||
export interface IStorageRepository {
|
export interface IStorageRepository {
|
||||||
createZipStream(): ImmichZipStream;
|
createZipStream(): ImmichZipStream;
|
||||||
createReadStream(filepath: string, mimeType?: string | null): Promise<ImmichReadStream>;
|
createReadStream(filepath: string, mimeType?: string | null): Promise<ImmichReadStream>;
|
||||||
|
readFile(filepath: string, options?: FileReadOptions<Buffer>): Promise<Buffer>;
|
||||||
|
writeFile(filepath: string, buffer: Buffer): Promise<void>;
|
||||||
unlink(filepath: string): Promise<void>;
|
unlink(filepath: string): Promise<void>;
|
||||||
unlinkDir(folder: string, options?: { recursive?: boolean; force?: boolean }): Promise<void>;
|
unlinkDir(folder: string, options?: { recursive?: boolean; force?: boolean }): Promise<void>;
|
||||||
removeEmptyDirs(folder: string, self?: boolean): Promise<void>;
|
removeEmptyDirs(folder: string, self?: boolean): Promise<void>;
|
||||||
|
@ -6,13 +6,12 @@ import {
|
|||||||
ICommunicationRepository,
|
ICommunicationRepository,
|
||||||
ICryptoRepository,
|
ICryptoRepository,
|
||||||
IFaceRepository,
|
IFaceRepository,
|
||||||
IGeocodingRepository,
|
|
||||||
IJobRepository,
|
IJobRepository,
|
||||||
IKeyRepository,
|
IKeyRepository,
|
||||||
ILibraryRepository,
|
ILibraryRepository,
|
||||||
IMachineLearningRepository,
|
IMachineLearningRepository,
|
||||||
IMediaRepository,
|
IMediaRepository,
|
||||||
immichAppConfig,
|
IMetadataRepository,
|
||||||
IPartnerRepository,
|
IPartnerRepository,
|
||||||
IPersonRepository,
|
IPersonRepository,
|
||||||
ISearchRepository,
|
ISearchRepository,
|
||||||
@ -23,6 +22,7 @@ import {
|
|||||||
ITagRepository,
|
ITagRepository,
|
||||||
IUserRepository,
|
IUserRepository,
|
||||||
IUserTokenRepository,
|
IUserTokenRepository,
|
||||||
|
immichAppConfig,
|
||||||
} from '@app/domain';
|
} from '@app/domain';
|
||||||
import { BullModule } from '@nestjs/bullmq';
|
import { BullModule } from '@nestjs/bullmq';
|
||||||
import { Global, Module, Provider } from '@nestjs/common';
|
import { Global, Module, Provider } from '@nestjs/common';
|
||||||
@ -33,20 +33,20 @@ import { databaseConfig } from './database.config';
|
|||||||
import { databaseEntities } from './entities';
|
import { databaseEntities } from './entities';
|
||||||
import { bullConfig, bullQueues } from './infra.config';
|
import { bullConfig, bullQueues } from './infra.config';
|
||||||
import {
|
import {
|
||||||
|
APIKeyRepository,
|
||||||
AccessRepository,
|
AccessRepository,
|
||||||
AlbumRepository,
|
AlbumRepository,
|
||||||
APIKeyRepository,
|
|
||||||
AssetRepository,
|
AssetRepository,
|
||||||
AuditRepository,
|
AuditRepository,
|
||||||
CommunicationRepository,
|
CommunicationRepository,
|
||||||
CryptoRepository,
|
CryptoRepository,
|
||||||
FaceRepository,
|
FaceRepository,
|
||||||
FilesystemProvider,
|
FilesystemProvider,
|
||||||
GeocodingRepository,
|
|
||||||
JobRepository,
|
JobRepository,
|
||||||
LibraryRepository,
|
LibraryRepository,
|
||||||
MachineLearningRepository,
|
MachineLearningRepository,
|
||||||
MediaRepository,
|
MediaRepository,
|
||||||
|
MetadataRepository,
|
||||||
PartnerRepository,
|
PartnerRepository,
|
||||||
PersonRepository,
|
PersonRepository,
|
||||||
SharedLinkRepository,
|
SharedLinkRepository,
|
||||||
@ -66,11 +66,11 @@ const providers: Provider[] = [
|
|||||||
{ provide: ICommunicationRepository, useClass: CommunicationRepository },
|
{ provide: ICommunicationRepository, useClass: CommunicationRepository },
|
||||||
{ provide: ICryptoRepository, useClass: CryptoRepository },
|
{ provide: ICryptoRepository, useClass: CryptoRepository },
|
||||||
{ provide: IFaceRepository, useClass: FaceRepository },
|
{ provide: IFaceRepository, useClass: FaceRepository },
|
||||||
{ provide: IGeocodingRepository, useClass: GeocodingRepository },
|
|
||||||
{ provide: IJobRepository, useClass: JobRepository },
|
{ provide: IJobRepository, useClass: JobRepository },
|
||||||
{ provide: ILibraryRepository, useClass: LibraryRepository },
|
{ provide: ILibraryRepository, useClass: LibraryRepository },
|
||||||
{ provide: IKeyRepository, useClass: APIKeyRepository },
|
{ provide: IKeyRepository, useClass: APIKeyRepository },
|
||||||
{ provide: IMachineLearningRepository, useClass: MachineLearningRepository },
|
{ provide: IMachineLearningRepository, useClass: MachineLearningRepository },
|
||||||
|
{ provide: IMetadataRepository, useClass: MetadataRepository },
|
||||||
{ provide: IPartnerRepository, useClass: PartnerRepository },
|
{ provide: IPartnerRepository, useClass: PartnerRepository },
|
||||||
{ provide: IPersonRepository, useClass: PersonRepository },
|
{ provide: IPersonRepository, useClass: PersonRepository },
|
||||||
{ provide: ISearchRepository, useClass: TypesenseRepository },
|
{ provide: ISearchRepository, useClass: TypesenseRepository },
|
||||||
|
@ -8,7 +8,7 @@ import {
|
|||||||
} from '@app/domain';
|
} from '@app/domain';
|
||||||
import archiver from 'archiver';
|
import archiver from 'archiver';
|
||||||
import { constants, createReadStream, existsSync, mkdirSync } from 'fs';
|
import { constants, createReadStream, existsSync, mkdirSync } from 'fs';
|
||||||
import fs, { readdir } from 'fs/promises';
|
import fs, { readdir, writeFile } from 'fs/promises';
|
||||||
import { glob } from 'glob';
|
import { glob } from 'glob';
|
||||||
import mv from 'mv';
|
import mv from 'mv';
|
||||||
import { promisify } from 'node:util';
|
import { promisify } from 'node:util';
|
||||||
@ -39,6 +39,18 @@ export class FilesystemProvider implements IStorageRepository {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async readFile(filepath: string, options?: fs.FileReadOptions<Buffer>): Promise<Buffer> {
|
||||||
|
const file = await fs.open(filepath);
|
||||||
|
try {
|
||||||
|
const { buffer } = await file.read(options);
|
||||||
|
return buffer;
|
||||||
|
} finally {
|
||||||
|
await file.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
writeFile = writeFile;
|
||||||
|
|
||||||
async moveFile(source: string, destination: string): Promise<void> {
|
async moveFile(source: string, destination: string): Promise<void> {
|
||||||
if (await this.checkFileExists(destination)) {
|
if (await this.checkFileExists(destination)) {
|
||||||
throw new Error(`Destination file already exists: ${destination}`);
|
throw new Error(`Destination file already exists: ${destination}`);
|
||||||
|
@ -7,11 +7,11 @@ export * from './communication.repository';
|
|||||||
export * from './crypto.repository';
|
export * from './crypto.repository';
|
||||||
export * from './face.repository';
|
export * from './face.repository';
|
||||||
export * from './filesystem.provider';
|
export * from './filesystem.provider';
|
||||||
export * from './geocoding.repository';
|
|
||||||
export * from './job.repository';
|
export * from './job.repository';
|
||||||
export * from './library.repository';
|
export * from './library.repository';
|
||||||
export * from './machine-learning.repository';
|
export * from './machine-learning.repository';
|
||||||
export * from './media.repository';
|
export * from './media.repository';
|
||||||
|
export * from './metadata.repository';
|
||||||
export * from './partner.repository';
|
export * from './partner.repository';
|
||||||
export * from './person.repository';
|
export * from './person.repository';
|
||||||
export * from './shared-link.repository';
|
export * from './shared-link.repository';
|
||||||
|
@ -1,7 +1,9 @@
|
|||||||
import { GeoPoint, IGeocodingRepository, ReverseGeocodeResult } from '@app/domain';
|
import { GeoPoint, IMetadataRepository, ImmichTags, ReverseGeocodeResult } from '@app/domain';
|
||||||
import { REVERSE_GEOCODING_DUMP_DIRECTORY } from '@app/infra';
|
import { REVERSE_GEOCODING_DUMP_DIRECTORY } from '@app/infra';
|
||||||
import { Injectable, Logger } from '@nestjs/common';
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
import { DefaultReadTaskOptions, exiftool } from 'exiftool-vendored';
|
||||||
import { readdir, rm } from 'fs/promises';
|
import { readdir, rm } from 'fs/promises';
|
||||||
|
import * as geotz from 'geo-tz';
|
||||||
import { getName } from 'i18n-iso-countries';
|
import { getName } from 'i18n-iso-countries';
|
||||||
import geocoder, { AddressObject, InitOptions } from 'local-reverse-geocoder';
|
import geocoder, { AddressObject, InitOptions } from 'local-reverse-geocoder';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
@ -21,8 +23,8 @@ export type GeoData = AddressObject & {
|
|||||||
const lookup = promisify<GeoPoint[], number, AddressObject[][]>(geocoder.lookUp).bind(geocoder);
|
const lookup = promisify<GeoPoint[], number, AddressObject[][]>(geocoder.lookUp).bind(geocoder);
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class GeocodingRepository implements IGeocodingRepository {
|
export class MetadataRepository implements IMetadataRepository {
|
||||||
private logger = new Logger(GeocodingRepository.name);
|
private logger = new Logger(MetadataRepository.name);
|
||||||
|
|
||||||
async init(options: Partial<InitOptions>): Promise<void> {
|
async init(options: Partial<InitOptions>): Promise<void> {
|
||||||
return new Promise<void>((resolve) => {
|
return new Promise<void>((resolve) => {
|
||||||
@ -69,4 +71,22 @@ export class GeocodingRepository implements IGeocodingRepository {
|
|||||||
|
|
||||||
return { country, state, city };
|
return { country, state, city };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getExifTags(path: string): Promise<ImmichTags | null> {
|
||||||
|
return exiftool
|
||||||
|
.read<ImmichTags>(path, undefined, {
|
||||||
|
...DefaultReadTaskOptions,
|
||||||
|
|
||||||
|
defaultVideosToUTC: true,
|
||||||
|
backfillTimezones: true,
|
||||||
|
inferTimezoneFromDatestamps: true,
|
||||||
|
useMWG: true,
|
||||||
|
numericTags: DefaultReadTaskOptions.numericTags.concat(['FocalLength']),
|
||||||
|
geoTz: (lat, lon) => geotz.find(lat, lon)[0],
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
this.logger.warn(`Error reading exif data (${path}): ${error}`, error?.stack);
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
@ -17,16 +17,12 @@ import {
|
|||||||
} from '@app/domain';
|
} from '@app/domain';
|
||||||
|
|
||||||
import { Injectable, Logger } from '@nestjs/common';
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
import { MetadataExtractionProcessor } from './processors/metadata-extraction.processor';
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class AppService {
|
export class AppService {
|
||||||
private logger = new Logger(AppService.name);
|
private logger = new Logger(AppService.name);
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
// TODO refactor to domain
|
|
||||||
private metadataProcessor: MetadataExtractionProcessor,
|
|
||||||
|
|
||||||
private facialRecognitionService: FacialRecognitionService,
|
private facialRecognitionService: FacialRecognitionService,
|
||||||
private jobService: JobService,
|
private jobService: JobService,
|
||||||
private mediaService: MediaService,
|
private mediaService: MediaService,
|
||||||
@ -73,9 +69,9 @@ export class AppService {
|
|||||||
[JobName.GENERATE_THUMBHASH_THUMBNAIL]: (data) => this.mediaService.handleGenerateThumbhashThumbnail(data),
|
[JobName.GENERATE_THUMBHASH_THUMBNAIL]: (data) => this.mediaService.handleGenerateThumbhashThumbnail(data),
|
||||||
[JobName.QUEUE_VIDEO_CONVERSION]: (data) => this.mediaService.handleQueueVideoConversion(data),
|
[JobName.QUEUE_VIDEO_CONVERSION]: (data) => this.mediaService.handleQueueVideoConversion(data),
|
||||||
[JobName.VIDEO_CONVERSION]: (data) => this.mediaService.handleVideoConversion(data),
|
[JobName.VIDEO_CONVERSION]: (data) => this.mediaService.handleVideoConversion(data),
|
||||||
[JobName.QUEUE_METADATA_EXTRACTION]: (data) => this.metadataProcessor.handleQueueMetadataExtraction(data),
|
[JobName.QUEUE_METADATA_EXTRACTION]: (data) => this.metadataService.handleQueueMetadataExtraction(data),
|
||||||
[JobName.METADATA_EXTRACTION]: (data) => this.metadataProcessor.handleMetadataExtraction(data),
|
[JobName.METADATA_EXTRACTION]: (data) => this.metadataService.handleMetadataExtraction(data),
|
||||||
[JobName.LINK_LIVE_PHOTOS]: (data) => this.metadataProcessor.handleLivePhotoLinking(data),
|
[JobName.LINK_LIVE_PHOTOS]: (data) => this.metadataService.handleLivePhotoLinking(data),
|
||||||
[JobName.QUEUE_RECOGNIZE_FACES]: (data) => this.facialRecognitionService.handleQueueRecognizeFaces(data),
|
[JobName.QUEUE_RECOGNIZE_FACES]: (data) => this.facialRecognitionService.handleQueueRecognizeFaces(data),
|
||||||
[JobName.RECOGNIZE_FACES]: (data) => this.facialRecognitionService.handleRecognizeFaces(data),
|
[JobName.RECOGNIZE_FACES]: (data) => this.facialRecognitionService.handleRecognizeFaces(data),
|
||||||
[JobName.GENERATE_PERSON_THUMBNAIL]: (data) => this.facialRecognitionService.handleGeneratePersonThumbnail(data),
|
[JobName.GENERATE_PERSON_THUMBNAIL]: (data) => this.facialRecognitionService.handleGeneratePersonThumbnail(data),
|
||||||
@ -99,10 +95,10 @@ export class AppService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
this.logger.warn('Geocoding csv parse error, trying again without cache...');
|
this.logger.warn('Geocoding csv parse error, trying again without cache...');
|
||||||
this.metadataProcessor.init(true);
|
this.metadataService.init(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
await this.metadataProcessor.init();
|
await this.metadataService.init();
|
||||||
await this.searchService.init();
|
await this.searchService.init();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -2,10 +2,9 @@ import { DomainModule } from '@app/domain';
|
|||||||
import { InfraModule } from '@app/infra';
|
import { InfraModule } from '@app/infra';
|
||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { AppService } from './app.service';
|
import { AppService } from './app.service';
|
||||||
import { MetadataExtractionProcessor } from './processors/metadata-extraction.processor';
|
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [DomainModule.register({ imports: [InfraModule] })],
|
imports: [DomainModule.register({ imports: [InfraModule] })],
|
||||||
providers: [MetadataExtractionProcessor, AppService],
|
providers: [AppService],
|
||||||
})
|
})
|
||||||
export class MicroservicesModule {}
|
export class MicroservicesModule {}
|
||||||
|
@ -1,345 +0,0 @@
|
|||||||
import {
|
|
||||||
FeatureFlag,
|
|
||||||
IAlbumRepository,
|
|
||||||
IAssetRepository,
|
|
||||||
IBaseJob,
|
|
||||||
ICryptoRepository,
|
|
||||||
IEntityJob,
|
|
||||||
IGeocodingRepository,
|
|
||||||
IJobRepository,
|
|
||||||
IStorageRepository,
|
|
||||||
ISystemConfigRepository,
|
|
||||||
JobName,
|
|
||||||
JOBS_ASSET_PAGINATION_SIZE,
|
|
||||||
QueueName,
|
|
||||||
StorageCore,
|
|
||||||
StorageFolder,
|
|
||||||
SystemConfigCore,
|
|
||||||
usePagination,
|
|
||||||
WithoutProperty,
|
|
||||||
} from '@app/domain';
|
|
||||||
import { AssetEntity, AssetType, ExifEntity } from '@app/infra/entities';
|
|
||||||
import { Inject, Logger } from '@nestjs/common';
|
|
||||||
import { DefaultReadTaskOptions, ExifDateTime, exiftool, ReadTaskOptions, Tags } from 'exiftool-vendored';
|
|
||||||
import { firstDateTime } from 'exiftool-vendored/dist/FirstDateTime';
|
|
||||||
import * as geotz from 'geo-tz';
|
|
||||||
import { Duration } from 'luxon';
|
|
||||||
import fs from 'node:fs/promises';
|
|
||||||
import path from 'node:path';
|
|
||||||
|
|
||||||
interface DirectoryItem {
|
|
||||||
Length?: number;
|
|
||||||
Mime: string;
|
|
||||||
Padding?: number;
|
|
||||||
Semantic?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface DirectoryEntry {
|
|
||||||
Item: DirectoryItem;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ImmichTags extends Tags {
|
|
||||||
ContentIdentifier?: string;
|
|
||||||
MotionPhoto?: number;
|
|
||||||
MotionPhotoVersion?: number;
|
|
||||||
MotionPhotoPresentationTimestampUs?: number;
|
|
||||||
MediaGroupUUID?: string;
|
|
||||||
ImagePixelDepth?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const exifDate = (dt: ExifDateTime | string | undefined) => (dt instanceof ExifDateTime ? dt?.toDate() : null);
|
|
||||||
// exiftool returns strings when it fails to parse non-string values, so this is used where a string is not expected
|
|
||||||
const validate = <T>(value: T): T | null => (typeof value === 'string' ? null : value ?? null);
|
|
||||||
|
|
||||||
export class MetadataExtractionProcessor {
|
|
||||||
private logger = new Logger(MetadataExtractionProcessor.name);
|
|
||||||
private storageCore: StorageCore;
|
|
||||||
private configCore: SystemConfigCore;
|
|
||||||
private oldCities?: string;
|
|
||||||
|
|
||||||
constructor(
|
|
||||||
@Inject(IAssetRepository) private assetRepository: IAssetRepository,
|
|
||||||
@Inject(IAlbumRepository) private albumRepository: IAlbumRepository,
|
|
||||||
@Inject(IJobRepository) private jobRepository: IJobRepository,
|
|
||||||
@Inject(IGeocodingRepository) private geocodingRepository: IGeocodingRepository,
|
|
||||||
@Inject(ICryptoRepository) private cryptoRepository: ICryptoRepository,
|
|
||||||
@Inject(IStorageRepository) private storageRepository: IStorageRepository,
|
|
||||||
@Inject(ISystemConfigRepository) configRepository: ISystemConfigRepository,
|
|
||||||
) {
|
|
||||||
this.storageCore = new StorageCore(storageRepository);
|
|
||||||
this.configCore = new SystemConfigCore(configRepository);
|
|
||||||
this.configCore.config$.subscribe(() => this.init());
|
|
||||||
}
|
|
||||||
|
|
||||||
async init(deleteCache = false) {
|
|
||||||
const { reverseGeocoding } = await this.configCore.getConfig();
|
|
||||||
const { citiesFileOverride } = reverseGeocoding;
|
|
||||||
|
|
||||||
if (!reverseGeocoding.enabled) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
if (deleteCache) {
|
|
||||||
await this.geocodingRepository.deleteCache();
|
|
||||||
} else if (this.oldCities && this.oldCities === citiesFileOverride) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.jobRepository.pause(QueueName.METADATA_EXTRACTION);
|
|
||||||
await this.geocodingRepository.init({ citiesFileOverride });
|
|
||||||
await this.jobRepository.resume(QueueName.METADATA_EXTRACTION);
|
|
||||||
|
|
||||||
this.logger.log(`Initialized local reverse geocoder with ${citiesFileOverride}`);
|
|
||||||
this.oldCities = citiesFileOverride;
|
|
||||||
} catch (error: Error | any) {
|
|
||||||
this.logger.error(`Unable to initialize reverse geocoding: ${error}`, error?.stack);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async handleLivePhotoLinking(job: IEntityJob) {
|
|
||||||
const { id } = job;
|
|
||||||
const [asset] = await this.assetRepository.getByIds([id]);
|
|
||||||
if (!asset?.exifInfo) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!asset.exifInfo.livePhotoCID) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
const otherType = asset.type === AssetType.VIDEO ? AssetType.IMAGE : AssetType.VIDEO;
|
|
||||||
const match = await this.assetRepository.findLivePhotoMatch({
|
|
||||||
livePhotoCID: asset.exifInfo.livePhotoCID,
|
|
||||||
ownerId: asset.ownerId,
|
|
||||||
otherAssetId: asset.id,
|
|
||||||
type: otherType,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!match) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
const [photoAsset, motionAsset] = asset.type === AssetType.IMAGE ? [asset, match] : [match, asset];
|
|
||||||
|
|
||||||
await this.assetRepository.save({ id: photoAsset.id, livePhotoVideoId: motionAsset.id });
|
|
||||||
await this.assetRepository.save({ id: motionAsset.id, isVisible: false });
|
|
||||||
await this.albumRepository.removeAsset(motionAsset.id);
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
async handleQueueMetadataExtraction(job: IBaseJob) {
|
|
||||||
const { force } = job;
|
|
||||||
const assetPagination = usePagination(JOBS_ASSET_PAGINATION_SIZE, (pagination) => {
|
|
||||||
return force
|
|
||||||
? this.assetRepository.getAll(pagination)
|
|
||||||
: this.assetRepository.getWithout(pagination, WithoutProperty.EXIF);
|
|
||||||
});
|
|
||||||
|
|
||||||
for await (const assets of assetPagination) {
|
|
||||||
for (const asset of assets) {
|
|
||||||
await this.jobRepository.queue({ name: JobName.METADATA_EXTRACTION, data: { id: asset.id } });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
async handleMetadataExtraction({ id }: IEntityJob) {
|
|
||||||
const [asset] = await this.assetRepository.getByIds([id]);
|
|
||||||
if (!asset || !asset.isVisible) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const [exifData, tags] = await this.exifData(asset);
|
|
||||||
|
|
||||||
await this.applyMotionPhotos(asset, tags);
|
|
||||||
await this.applyReverseGeocoding(asset, exifData);
|
|
||||||
await this.assetRepository.upsertExif(exifData);
|
|
||||||
await this.assetRepository.save({
|
|
||||||
id: asset.id,
|
|
||||||
duration: tags.Duration ? Duration.fromObject({ seconds: tags.Duration }).toFormat('hh:mm:ss.SSS') : null,
|
|
||||||
fileCreatedAt: exifData.dateTimeOriginal ?? undefined,
|
|
||||||
});
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async applyReverseGeocoding(asset: AssetEntity, exifData: ExifEntity) {
|
|
||||||
const { latitude, longitude } = exifData;
|
|
||||||
if (!(await this.configCore.hasFeature(FeatureFlag.REVERSE_GEOCODING)) || !longitude || !latitude) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const { city, state, country } = await this.geocodingRepository.reverseGeocode({ latitude, longitude });
|
|
||||||
Object.assign(exifData, { city, state, country });
|
|
||||||
} catch (error: Error | any) {
|
|
||||||
this.logger.warn(
|
|
||||||
`Unable to run reverse geocoding due to ${error} for asset ${asset.id} at ${asset.originalPath}`,
|
|
||||||
error?.stack,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async applyMotionPhotos(asset: AssetEntity, tags: ImmichTags) {
|
|
||||||
if (asset.type !== AssetType.IMAGE || asset.livePhotoVideoId) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const rawDirectory = tags.Directory;
|
|
||||||
const isMotionPhoto = tags.MotionPhoto;
|
|
||||||
const isMicroVideo = tags.MicroVideo;
|
|
||||||
const videoOffset = tags.MicroVideoOffset;
|
|
||||||
const directory = Array.isArray(rawDirectory) ? (rawDirectory as DirectoryEntry[]) : null;
|
|
||||||
|
|
||||||
let length = 0;
|
|
||||||
let padding = 0;
|
|
||||||
|
|
||||||
if (isMotionPhoto && directory) {
|
|
||||||
for (const entry of directory) {
|
|
||||||
if (entry.Item.Semantic == 'MotionPhoto') {
|
|
||||||
length = entry.Item.Length ?? 0;
|
|
||||||
padding = entry.Item.Padding ?? 0;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isMicroVideo && typeof videoOffset === 'number') {
|
|
||||||
length = videoOffset;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!length) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.logger.debug(`Starting motion photo video extraction (${asset.id})`);
|
|
||||||
|
|
||||||
let file = null;
|
|
||||||
try {
|
|
||||||
const encodedFolder = this.storageCore.getFolderLocation(StorageFolder.ENCODED_VIDEO, asset.ownerId);
|
|
||||||
const encodedFile = path.join(encodedFolder, path.parse(asset.originalPath).name + '.mp4');
|
|
||||||
this.storageRepository.mkdirSync(encodedFolder);
|
|
||||||
|
|
||||||
file = await fs.open(asset.originalPath);
|
|
||||||
|
|
||||||
const stat = await file.stat();
|
|
||||||
const position = stat.size - length - padding;
|
|
||||||
const video = await file.read({ buffer: Buffer.alloc(length), position, length });
|
|
||||||
const checksum = await this.cryptoRepository.hashSha1(video.buffer);
|
|
||||||
|
|
||||||
let motionAsset = await this.assetRepository.getByChecksum(asset.ownerId, checksum);
|
|
||||||
if (!motionAsset) {
|
|
||||||
motionAsset = await this.assetRepository.save({
|
|
||||||
libraryId: asset.libraryId,
|
|
||||||
type: AssetType.VIDEO,
|
|
||||||
fileCreatedAt: asset.fileCreatedAt ?? asset.createdAt,
|
|
||||||
fileModifiedAt: asset.fileModifiedAt,
|
|
||||||
checksum,
|
|
||||||
ownerId: asset.ownerId,
|
|
||||||
originalPath: encodedFile,
|
|
||||||
originalFileName: asset.originalFileName,
|
|
||||||
isVisible: false,
|
|
||||||
isReadOnly: true,
|
|
||||||
deviceAssetId: 'NONE',
|
|
||||||
deviceId: 'NONE',
|
|
||||||
});
|
|
||||||
|
|
||||||
await fs.writeFile(encodedFile, video.buffer);
|
|
||||||
|
|
||||||
await this.jobRepository.queue({ name: JobName.METADATA_EXTRACTION, data: { id: motionAsset.id } });
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.assetRepository.save({ id: asset.id, livePhotoVideoId: motionAsset.id });
|
|
||||||
|
|
||||||
this.logger.debug(`Finished motion photo video extraction (${asset.id})`);
|
|
||||||
} catch (error: Error | any) {
|
|
||||||
this.logger.error(`Failed to extract live photo ${asset.originalPath}: ${error}`, error?.stack);
|
|
||||||
} finally {
|
|
||||||
await file?.close();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async exifData(asset: AssetEntity): Promise<[ExifEntity, ImmichTags]> {
|
|
||||||
const readTaskOptions: ReadTaskOptions = {
|
|
||||||
...DefaultReadTaskOptions,
|
|
||||||
|
|
||||||
defaultVideosToUTC: true,
|
|
||||||
backfillTimezones: true,
|
|
||||||
inferTimezoneFromDatestamps: true,
|
|
||||||
useMWG: true,
|
|
||||||
numericTags: DefaultReadTaskOptions.numericTags.concat(['FocalLength']),
|
|
||||||
geoTz: (lat: number, lon: number): string => geotz.find(lat, lon)[0],
|
|
||||||
};
|
|
||||||
|
|
||||||
const mediaTags = await exiftool
|
|
||||||
.read<ImmichTags>(asset.originalPath, undefined, readTaskOptions)
|
|
||||||
.catch((error: any) => {
|
|
||||||
this.logger.warn(`error reading exif data (${asset.id} at ${asset.originalPath}): ${error}`, error?.stack);
|
|
||||||
return null;
|
|
||||||
});
|
|
||||||
|
|
||||||
const sidecarTags = asset.sidecarPath
|
|
||||||
? await exiftool.read<ImmichTags>(asset.sidecarPath, undefined, readTaskOptions).catch((error: any) => {
|
|
||||||
this.logger.warn(`error reading exif data (${asset.id} at ${asset.sidecarPath}): ${error}`, error?.stack);
|
|
||||||
return null;
|
|
||||||
})
|
|
||||||
: null;
|
|
||||||
|
|
||||||
const stats = await fs.stat(asset.originalPath);
|
|
||||||
|
|
||||||
const tags = { ...mediaTags, ...sidecarTags };
|
|
||||||
|
|
||||||
this.logger.verbose('Exif Tags', tags);
|
|
||||||
|
|
||||||
return [
|
|
||||||
<ExifEntity>{
|
|
||||||
// altitude: tags.GPSAltitude ?? null,
|
|
||||||
assetId: asset.id,
|
|
||||||
bitsPerSample: this.getBitsPerSample(tags),
|
|
||||||
colorspace: tags.ColorSpace ?? null,
|
|
||||||
dateTimeOriginal: exifDate(firstDateTime(tags)) ?? asset.fileCreatedAt,
|
|
||||||
exifImageHeight: validate(tags.ImageHeight),
|
|
||||||
exifImageWidth: validate(tags.ImageWidth),
|
|
||||||
exposureTime: tags.ExposureTime ?? null,
|
|
||||||
fileSizeInByte: stats.size,
|
|
||||||
fNumber: validate(tags.FNumber),
|
|
||||||
focalLength: validate(tags.FocalLength),
|
|
||||||
fps: validate(tags.VideoFrameRate),
|
|
||||||
iso: validate(tags.ISO),
|
|
||||||
latitude: validate(tags.GPSLatitude),
|
|
||||||
lensModel: tags.LensModel ?? null,
|
|
||||||
livePhotoCID: (asset.type === AssetType.VIDEO ? tags.ContentIdentifier : tags.MediaGroupUUID) ?? null,
|
|
||||||
longitude: validate(tags.GPSLongitude),
|
|
||||||
make: tags.Make ?? null,
|
|
||||||
model: tags.Model ?? null,
|
|
||||||
modifyDate: exifDate(tags.ModifyDate) ?? asset.fileModifiedAt,
|
|
||||||
orientation: validate(tags.Orientation)?.toString() ?? null,
|
|
||||||
profileDescription: tags.ProfileDescription || tags.ProfileName || null,
|
|
||||||
projectionType: tags.ProjectionType ? String(tags.ProjectionType).toUpperCase() : null,
|
|
||||||
timeZone: tags.tz,
|
|
||||||
},
|
|
||||||
tags,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
@ -10,6 +10,7 @@ export * from './job.repository.mock';
|
|||||||
export * from './library.repository.mock';
|
export * from './library.repository.mock';
|
||||||
export * from './machine-learning.repository.mock';
|
export * from './machine-learning.repository.mock';
|
||||||
export * from './media.repository.mock';
|
export * from './media.repository.mock';
|
||||||
|
export * from './metadata.repository.mock';
|
||||||
export * from './partner.repository.mock';
|
export * from './partner.repository.mock';
|
||||||
export * from './person.repository.mock';
|
export * from './person.repository.mock';
|
||||||
export * from './search.repository.mock';
|
export * from './search.repository.mock';
|
||||||
|
10
server/test/repositories/metadata.repository.mock.ts
Normal file
10
server/test/repositories/metadata.repository.mock.ts
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
import { IMetadataRepository } from '@app/domain';
|
||||||
|
|
||||||
|
export const newMetadataRepositoryMock = (): jest.Mocked<IMetadataRepository> => {
|
||||||
|
return {
|
||||||
|
deleteCache: jest.fn(),
|
||||||
|
getExifTags: jest.fn(),
|
||||||
|
init: jest.fn(),
|
||||||
|
reverseGeocode: jest.fn(),
|
||||||
|
};
|
||||||
|
};
|
@ -4,6 +4,8 @@ export const newStorageRepositoryMock = (): jest.Mocked<IStorageRepository> => {
|
|||||||
return {
|
return {
|
||||||
createZipStream: jest.fn(),
|
createZipStream: jest.fn(),
|
||||||
createReadStream: jest.fn(),
|
createReadStream: jest.fn(),
|
||||||
|
readFile: jest.fn(),
|
||||||
|
writeFile: jest.fn(),
|
||||||
unlink: jest.fn(),
|
unlink: jest.fn(),
|
||||||
unlinkDir: jest.fn().mockResolvedValue(true),
|
unlinkDir: jest.fn().mockResolvedValue(true),
|
||||||
removeEmptyDirs: jest.fn(),
|
removeEmptyDirs: jest.fn(),
|
||||||
|
Loading…
Reference in New Issue
Block a user