mirror of
https://github.com/immich-app/immich
synced 2025-11-14 17:36:12 +00:00
feat(server): xmp sidecar metadata (#2466)
* initial commit for XMP sidecar support * Added support for 'missing' metadata files to include those without sidecar files, now detects sidecar files in the filesystem for media already ingested but the sidecar was created afterwards * didn't mean to commit default log level during testing * new sidecar logic for video metadata as well * Added xml mimetype for sidecars only * don't need capture group for this regex * wrong default value reverted * simplified the move here - keep it in the same try catch since the outcome is to move the media back anyway * simplified setter logic Co-authored-by: Jason Rasmussen <jrasm91@gmail.com> * simplified logic per suggestions * sidecar is now its own queue with a discover and sync, updated UI for the new job queueing * queue a sidecar job for every asset based on discovery or sync, though the logic is almost identical aside from linking the sidecar * now queue sidecar jobs for each assset, though logic is mostly the same between discovery and sync * simplified logic of filename extraction and asset instantiation * not sure how that got deleted.. * updated code per suggestions and comments in the PR * stat was not being used, removed the variable set * better type checking, using in-scope variables for exif getter instead of passing in every time * removed commented out test * ran and resolved all lints, formats, checks, and tests * resolved suggested change in PR * made getExifProperty more dynamic with multiple possible args for fallbacks, fixed typo, used generic in function for better type checking * better error handling and moving files back to positions on move or save failure * regenerated api * format fixes * Added XMP documentation * documentation typo * Merged in main * missed merge conflict * more changes due to a merge * Resolving conflicts * added icon for sidecar jobs --------- Co-authored-by: Jason Rasmussen <jrasm91@gmail.com> Co-authored-by: Alex Tran <alex.tran1502@gmail.com>
This commit is contained in:
parent
1b54c4f8e7
commit
7c1dae918d
35 changed files with 371 additions and 48 deletions
|
|
@ -13,7 +13,7 @@ import {
|
|||
ThumbnailGeneratorProcessor,
|
||||
VideoTranscodeProcessor,
|
||||
} from './processors';
|
||||
import { MetadataExtractionProcessor } from './processors/metadata-extraction.processor';
|
||||
import { MetadataExtractionProcessor, SidecarProcessor } from './processors/metadata-extraction.processor';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
|
|
@ -31,6 +31,7 @@ import { MetadataExtractionProcessor } from './processors/metadata-extraction.pr
|
|||
BackgroundTaskProcessor,
|
||||
SearchIndexProcessor,
|
||||
FacialRecognitionProcessor,
|
||||
SidecarProcessor,
|
||||
],
|
||||
})
|
||||
export class MicroservicesModule {}
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import {
|
|||
QueueName,
|
||||
usePagination,
|
||||
WithoutProperty,
|
||||
WithProperty,
|
||||
} from '@app/domain';
|
||||
import { AssetEntity, AssetType, ExifEntity } from '@app/infra/entities';
|
||||
import { Process, Processor } from '@nestjs/bull';
|
||||
|
|
@ -98,13 +99,22 @@ export class MetadataExtractionProcessor {
|
|||
let asset = job.data.asset;
|
||||
|
||||
try {
|
||||
const exifData = await exiftool.read<ImmichTags>(asset.originalPath).catch((error: any) => {
|
||||
const mediaExifData = await exiftool.read<ImmichTags>(asset.originalPath).catch((error: any) => {
|
||||
this.logger.warn(
|
||||
`The exifData parsing failed due to ${error} for asset ${asset.id} at ${asset.originalPath}`,
|
||||
error?.stack,
|
||||
);
|
||||
return null;
|
||||
});
|
||||
const sidecarExifData = asset.sidecarPath
|
||||
? await exiftool.read<ImmichTags>(asset.sidecarPath).catch((error: any) => {
|
||||
this.logger.warn(
|
||||
`The exifData parsing failed due to ${error} for asset ${asset.id} at ${asset.originalPath}`,
|
||||
error?.stack,
|
||||
);
|
||||
return null;
|
||||
})
|
||||
: {};
|
||||
|
||||
const exifToDate = (exifDate: string | ExifDateTime | undefined) => {
|
||||
if (!exifDate) return null;
|
||||
|
|
@ -126,31 +136,46 @@ export class MetadataExtractionProcessor {
|
|||
return exifDate.zone ?? null;
|
||||
};
|
||||
|
||||
const timeZone = exifTimeZone(exifData?.DateTimeOriginal ?? exifData?.CreateDate ?? asset.fileCreatedAt);
|
||||
const fileCreatedAt = exifToDate(exifData?.DateTimeOriginal ?? exifData?.CreateDate ?? asset.fileCreatedAt);
|
||||
const fileModifiedAt = exifToDate(exifData?.ModifyDate ?? asset.fileModifiedAt);
|
||||
const getExifProperty = <T extends keyof ImmichTags>(...properties: T[]): any | null => {
|
||||
for (const property of properties) {
|
||||
const value = sidecarExifData?.[property] ?? mediaExifData?.[property];
|
||||
if (value !== null && value !== undefined) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const timeZone = exifTimeZone(getExifProperty('DateTimeOriginal', 'CreateDate') ?? asset.fileCreatedAt);
|
||||
const fileCreatedAt = exifToDate(getExifProperty('DateTimeOriginal', 'CreateDate') ?? asset.fileCreatedAt);
|
||||
const fileModifiedAt = exifToDate(getExifProperty('ModifyDate') ?? asset.fileModifiedAt);
|
||||
const fileStats = fs.statSync(asset.originalPath);
|
||||
const fileSizeInBytes = fileStats.size;
|
||||
|
||||
const newExif = new ExifEntity();
|
||||
newExif.assetId = asset.id;
|
||||
newExif.fileSizeInByte = fileSizeInBytes;
|
||||
newExif.make = exifData?.Make || null;
|
||||
newExif.model = exifData?.Model || null;
|
||||
newExif.exifImageHeight = exifData?.ExifImageHeight || exifData?.ImageHeight || null;
|
||||
newExif.exifImageWidth = exifData?.ExifImageWidth || exifData?.ImageWidth || null;
|
||||
newExif.exposureTime = exifData?.ExposureTime || null;
|
||||
newExif.orientation = exifData?.Orientation?.toString() || null;
|
||||
newExif.make = getExifProperty('Make');
|
||||
newExif.model = getExifProperty('Model');
|
||||
newExif.exifImageHeight = getExifProperty('ExifImageHeight', 'ImageHeight');
|
||||
newExif.exifImageWidth = getExifProperty('ExifImageWidth', 'ImageWidth');
|
||||
newExif.exposureTime = getExifProperty('ExposureTime');
|
||||
newExif.orientation = getExifProperty('Orientation')?.toString();
|
||||
newExif.dateTimeOriginal = fileCreatedAt;
|
||||
newExif.modifyDate = fileModifiedAt;
|
||||
newExif.timeZone = timeZone;
|
||||
newExif.lensModel = exifData?.LensModel || null;
|
||||
newExif.fNumber = exifData?.FNumber || null;
|
||||
newExif.focalLength = exifData?.FocalLength ? parseFloat(exifData.FocalLength) : null;
|
||||
newExif.iso = exifData?.ISO || null;
|
||||
newExif.latitude = exifData?.GPSLatitude || null;
|
||||
newExif.longitude = exifData?.GPSLongitude || null;
|
||||
newExif.livePhotoCID = exifData?.MediaGroupUUID || null;
|
||||
newExif.lensModel = getExifProperty('LensModel');
|
||||
newExif.fNumber = getExifProperty('FNumber');
|
||||
const focalLength = getExifProperty('FocalLength');
|
||||
newExif.focalLength = focalLength ? parseFloat(focalLength) : null;
|
||||
// This is unusual - exifData.ISO should return a number, but experienced that sidecar XMP
|
||||
// files MAY return an array of numbers instead.
|
||||
const iso = getExifProperty('ISO');
|
||||
newExif.iso = Array.isArray(iso) ? iso[0] : iso || null;
|
||||
newExif.latitude = getExifProperty('GPSLatitude');
|
||||
newExif.longitude = getExifProperty('GPSLongitude');
|
||||
newExif.livePhotoCID = getExifProperty('MediaGroupUUID');
|
||||
|
||||
if (newExif.livePhotoCID && !asset.livePhotoVideoId) {
|
||||
const motionAsset = await this.assetCore.findLivePhotoMatch({
|
||||
|
|
@ -220,7 +245,7 @@ export class MetadataExtractionProcessor {
|
|||
}
|
||||
}
|
||||
|
||||
const exifData = await exiftool.read<ImmichTags>(asset.originalPath).catch((error: any) => {
|
||||
const exifData = await exiftool.read<ImmichTags>(asset.sidecarPath || asset.originalPath).catch((error: any) => {
|
||||
this.logger.warn(
|
||||
`The exifData parsing failed due to ${error} for asset ${asset.id} at ${asset.originalPath}`,
|
||||
error?.stack,
|
||||
|
|
@ -345,3 +370,83 @@ export class MetadataExtractionProcessor {
|
|||
return Duration.fromObject({ seconds: videoDurationInSecond }).toFormat('hh:mm:ss.SSS');
|
||||
}
|
||||
}
|
||||
|
||||
@Processor(QueueName.SIDECAR)
|
||||
export class SidecarProcessor {
|
||||
private logger = new Logger(SidecarProcessor.name);
|
||||
private assetCore: AssetCore;
|
||||
|
||||
constructor(
|
||||
@Inject(IAssetRepository) private assetRepository: IAssetRepository,
|
||||
@Inject(IJobRepository) private jobRepository: IJobRepository,
|
||||
) {
|
||||
this.assetCore = new AssetCore(assetRepository, jobRepository);
|
||||
}
|
||||
|
||||
@Process(JobName.QUEUE_SIDECAR)
|
||||
async handleQueueSidecar(job: Job<IBaseJob>) {
|
||||
try {
|
||||
const { force } = job.data;
|
||||
const assetPagination = usePagination(JOBS_ASSET_PAGINATION_SIZE, (pagination) => {
|
||||
return force
|
||||
? this.assetRepository.getWith(pagination, WithProperty.SIDECAR)
|
||||
: this.assetRepository.getWithout(pagination, WithoutProperty.SIDECAR);
|
||||
});
|
||||
|
||||
for await (const assets of assetPagination) {
|
||||
for (const asset of assets) {
|
||||
const name = force ? JobName.SIDECAR_SYNC : JobName.SIDECAR_DISCOVERY;
|
||||
await this.jobRepository.queue({ name, data: { asset } });
|
||||
}
|
||||
}
|
||||
} catch (error: any) {
|
||||
this.logger.error(`Unable to queue sidecar scanning`, error?.stack);
|
||||
}
|
||||
}
|
||||
|
||||
@Process(JobName.SIDECAR_SYNC)
|
||||
async handleSidecarSync(job: Job<IAssetJob>) {
|
||||
const { asset } = job.data;
|
||||
if (!asset.isVisible) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const name = asset.type === AssetType.VIDEO ? JobName.EXTRACT_VIDEO_METADATA : JobName.EXIF_EXTRACTION;
|
||||
await this.jobRepository.queue({ name, data: { asset } });
|
||||
} catch (error: any) {
|
||||
this.logger.error(`Unable to queue metadata extraction`, error?.stack);
|
||||
}
|
||||
}
|
||||
|
||||
@Process(JobName.SIDECAR_DISCOVERY)
|
||||
async handleSidecarDiscovery(job: Job<IAssetJob>) {
|
||||
let { asset } = job.data;
|
||||
if (!asset.isVisible) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (asset.sidecarPath) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await fs.promises.access(`${asset.originalPath}.xmp`, fs.constants.W_OK);
|
||||
|
||||
try {
|
||||
asset = await this.assetCore.save({ id: asset.id, sidecarPath: `${asset.originalPath}.xmp` });
|
||||
// TODO: optimize to only queue assets with recent xmp changes
|
||||
const name = asset.type === AssetType.VIDEO ? JobName.EXTRACT_VIDEO_METADATA : JobName.EXIF_EXTRACTION;
|
||||
await this.jobRepository.queue({ name, data: { asset } });
|
||||
} catch (error: any) {
|
||||
this.logger.error(`Unable to sync sidecar`, error?.stack);
|
||||
}
|
||||
} catch (error: any) {
|
||||
if (error.code == 'EACCES') {
|
||||
this.logger.error(`Unable to queue metadata extraction, file is not writable`, error?.stack);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue