chore: rebase and clean-up
This commit is contained in:
@@ -8,7 +8,7 @@ import { AccessCore, Permission } from '../access';
|
||||
import { AuthUserDto } from '../auth';
|
||||
import { mimeTypes } from '../domain.constant';
|
||||
import { HumanReadableSize, usePagination } from '../domain.util';
|
||||
import { IAssetDeletionJob, JOBS_ASSET_PAGINATION_SIZE, JobName } from '../job';
|
||||
import { IAssetDeletionJob, ISidecarWriteJob, JOBS_ASSET_PAGINATION_SIZE, JobName } from '../job';
|
||||
import {
|
||||
CommunicationEvent,
|
||||
IAccessRepository,
|
||||
@@ -389,10 +389,8 @@ export class AssetService {
|
||||
async update(authUser: AuthUserDto, id: string, dto: UpdateAssetDto): Promise<AssetResponseDto> {
|
||||
await this.access.requirePermission(authUser, Permission.ASSET_UPDATE, id);
|
||||
|
||||
const { description, ...rest } = dto;
|
||||
if (description !== undefined) {
|
||||
await this.assetRepository.upsertExif({ assetId: id, description });
|
||||
}
|
||||
const { description, dateTimeOriginal, latitude, longitude, ...rest } = dto;
|
||||
await this.updateMetadata({ id, description, dateTimeOriginal, latitude, longitude });
|
||||
|
||||
const asset = await this.assetRepository.save({ id, ...rest });
|
||||
await this.jobRepository.queue({ name: JobName.SEARCH_INDEX_ASSET, data: { ids: [id] } });
|
||||
@@ -400,7 +398,7 @@ export class AssetService {
|
||||
}
|
||||
|
||||
async updateAll(authUser: AuthUserDto, dto: AssetBulkUpdateDto): Promise<void> {
|
||||
const { ids, removeParent, ...options } = dto;
|
||||
const { ids, removeParent, dateTimeOriginal, latitude, longitude, ...options } = dto;
|
||||
await this.access.requirePermission(authUser, Permission.ASSET_UPDATE, ids);
|
||||
|
||||
if (removeParent) {
|
||||
@@ -420,6 +418,10 @@ export class AssetService {
|
||||
await this.assetRepository.updateAll([options.stackParentId], { stackParentId: null });
|
||||
}
|
||||
|
||||
for (const id of ids) {
|
||||
await this.updateMetadata({ id, dateTimeOriginal, latitude, longitude });
|
||||
}
|
||||
|
||||
await this.jobRepository.queue({ name: JobName.SEARCH_INDEX_ASSET, data: { ids } });
|
||||
await this.assetRepository.updateAll(ids, options);
|
||||
this.communicationRepository.send(CommunicationEvent.ASSET_UPDATE, authUser.id, ids);
|
||||
@@ -583,4 +585,17 @@ export class AssetService {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async updateMetadata(dto: ISidecarWriteJob & { description?: string }) {
|
||||
const { id, description, dateTimeOriginal, latitude, longitude } = dto;
|
||||
|
||||
if (description !== undefined) {
|
||||
await this.assetRepository.upsertExif({ assetId: id, description });
|
||||
}
|
||||
|
||||
const writes = _.omitBy({ dateTimeOriginal, latitude, longitude }, _.isUndefined);
|
||||
if (Object.keys(writes).length > 0) {
|
||||
await this.jobRepository.queue({ name: JobName.SIDECAR_WRITE, data: { id, ...writes } });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,19 @@
|
||||
import { AssetType } from '@app/infra/entities';
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { Type } from 'class-transformer';
|
||||
import { IsBoolean, IsEnum, IsInt, IsPositive, IsString, Min } from 'class-validator';
|
||||
import {
|
||||
IsBoolean,
|
||||
IsDateString,
|
||||
IsEnum,
|
||||
IsInt,
|
||||
IsLatitude,
|
||||
IsLongitude,
|
||||
IsNotEmpty,
|
||||
IsPositive,
|
||||
IsString,
|
||||
Min,
|
||||
ValidateIf,
|
||||
} from 'class-validator';
|
||||
import { Optional, QueryBoolean, QueryDate, ValidateUUID } from '../../domain.util';
|
||||
import { BulkIdsDto } from '../response-dto';
|
||||
|
||||
@@ -10,6 +22,10 @@ export enum AssetOrder {
|
||||
DESC = 'desc',
|
||||
}
|
||||
|
||||
const hasGPS = (o: { latitude: undefined; longitude: undefined }) =>
|
||||
o.latitude !== undefined || o.longitude !== undefined;
|
||||
const ValidateGPS = () => ValidateIf(hasGPS);
|
||||
|
||||
export class AssetSearchDto {
|
||||
@ValidateUUID({ optional: true })
|
||||
id?: string;
|
||||
@@ -172,6 +188,20 @@ export class AssetBulkUpdateDto extends BulkIdsDto {
|
||||
@Optional()
|
||||
@IsBoolean()
|
||||
removeParent?: boolean;
|
||||
|
||||
@Optional()
|
||||
@IsDateString()
|
||||
dateTimeOriginal?: string;
|
||||
|
||||
@ValidateGPS()
|
||||
@IsLatitude()
|
||||
@IsNotEmpty()
|
||||
latitude?: number;
|
||||
|
||||
@ValidateGPS()
|
||||
@IsLongitude()
|
||||
@IsNotEmpty()
|
||||
longitude?: number;
|
||||
}
|
||||
|
||||
export class UpdateAssetDto {
|
||||
@@ -186,6 +216,20 @@ export class UpdateAssetDto {
|
||||
@Optional()
|
||||
@IsString()
|
||||
description?: string;
|
||||
|
||||
@Optional()
|
||||
@IsDateString()
|
||||
dateTimeOriginal?: string;
|
||||
|
||||
@ValidateGPS()
|
||||
@IsLatitude()
|
||||
@IsNotEmpty()
|
||||
latitude?: number;
|
||||
|
||||
@ValidateGPS()
|
||||
@IsLongitude()
|
||||
@IsNotEmpty()
|
||||
longitude?: number;
|
||||
}
|
||||
|
||||
export class RandomAssetsDto {
|
||||
|
||||
@@ -96,6 +96,7 @@ export enum JobName {
|
||||
QUEUE_SIDECAR = 'queue-sidecar',
|
||||
SIDECAR_DISCOVERY = 'sidecar-discovery',
|
||||
SIDECAR_SYNC = 'sidecar-sync',
|
||||
SIDECAR_WRITE = 'sidecar-write',
|
||||
}
|
||||
|
||||
export const JOBS_ASSET_PAGINATION_SIZE = 1000;
|
||||
@@ -168,6 +169,7 @@ export const JOBS_TO_QUEUE: Record<JobName, QueueName> = {
|
||||
[JobName.QUEUE_SIDECAR]: QueueName.SIDECAR,
|
||||
[JobName.SIDECAR_DISCOVERY]: QueueName.SIDECAR,
|
||||
[JobName.SIDECAR_SYNC]: QueueName.SIDECAR,
|
||||
[JobName.SIDECAR_WRITE]: QueueName.SIDECAR,
|
||||
|
||||
// Library management
|
||||
[JobName.LIBRARY_SCAN_ASSET]: QueueName.LIBRARY,
|
||||
|
||||
@@ -33,3 +33,9 @@ export interface IBulkEntityJob extends IBaseJob {
|
||||
export interface IDeleteFilesJob extends IBaseJob {
|
||||
files: Array<string | null | undefined>;
|
||||
}
|
||||
|
||||
export interface ISidecarWriteJob extends IEntityJob {
|
||||
dateTimeOriginal?: string;
|
||||
latitude?: number;
|
||||
longitude?: number;
|
||||
}
|
||||
|
||||
@@ -253,11 +253,11 @@ describe(MetadataService.name, () => {
|
||||
const originalDate = new Date('2023-11-21T16:13:17.517Z');
|
||||
const sidecarDate = new Date('2022-01-01T00:00:00.000Z');
|
||||
assetMock.getByIds.mockResolvedValue([assetStub.sidecar]);
|
||||
when(metadataMock.getExifTags)
|
||||
when(metadataMock.readTags)
|
||||
.calledWith(assetStub.sidecar.originalPath)
|
||||
// higher priority tag
|
||||
.mockResolvedValue({ CreationDate: originalDate.toISOString() });
|
||||
when(metadataMock.getExifTags)
|
||||
when(metadataMock.readTags)
|
||||
.calledWith(assetStub.sidecar.sidecarPath as string)
|
||||
// lower priority tag, but in sidecar
|
||||
.mockResolvedValue({ CreateDate: sidecarDate.toISOString() });
|
||||
@@ -275,7 +275,7 @@ describe(MetadataService.name, () => {
|
||||
|
||||
it('should handle lists of numbers', async () => {
|
||||
assetMock.getByIds.mockResolvedValue([assetStub.image]);
|
||||
metadataMock.getExifTags.mockResolvedValue({ ISO: [160] as any });
|
||||
metadataMock.readTags.mockResolvedValue({ ISO: [160] as any });
|
||||
|
||||
await sut.handleMetadataExtraction({ id: assetStub.image.id });
|
||||
expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.image.id]);
|
||||
@@ -292,7 +292,7 @@ describe(MetadataService.name, () => {
|
||||
assetMock.getByIds.mockResolvedValue([assetStub.withLocation]);
|
||||
configMock.load.mockResolvedValue([{ key: SystemConfigKey.REVERSE_GEOCODING_ENABLED, value: true }]);
|
||||
metadataMock.reverseGeocode.mockResolvedValue({ city: 'City', state: 'State', country: 'Country' });
|
||||
metadataMock.getExifTags.mockResolvedValue({
|
||||
metadataMock.readTags.mockResolvedValue({
|
||||
GPSLatitude: assetStub.withLocation.exifInfo!.latitude!,
|
||||
GPSLongitude: assetStub.withLocation.exifInfo!.longitude!,
|
||||
});
|
||||
@@ -324,7 +324,7 @@ describe(MetadataService.name, () => {
|
||||
|
||||
it('should apply motion photos', async () => {
|
||||
assetMock.getByIds.mockResolvedValue([{ ...assetStub.livePhotoStillAsset, livePhotoVideoId: null }]);
|
||||
metadataMock.getExifTags.mockResolvedValue({
|
||||
metadataMock.readTags.mockResolvedValue({
|
||||
Directory: 'foo/bar/',
|
||||
MotionPhoto: 1,
|
||||
MicroVideo: 1,
|
||||
@@ -345,7 +345,7 @@ describe(MetadataService.name, () => {
|
||||
|
||||
it('should create new motion asset if not found and link it with the photo', async () => {
|
||||
assetMock.getByIds.mockResolvedValue([{ ...assetStub.livePhotoStillAsset, livePhotoVideoId: null }]);
|
||||
metadataMock.getExifTags.mockResolvedValue({
|
||||
metadataMock.readTags.mockResolvedValue({
|
||||
Directory: 'foo/bar/',
|
||||
MotionPhoto: 1,
|
||||
MicroVideo: 1,
|
||||
@@ -402,7 +402,7 @@ describe(MetadataService.name, () => {
|
||||
tz: '+02:00',
|
||||
};
|
||||
assetMock.getByIds.mockResolvedValue([assetStub.image]);
|
||||
metadataMock.getExifTags.mockResolvedValue(tags);
|
||||
metadataMock.readTags.mockResolvedValue(tags);
|
||||
|
||||
await sut.handleMetadataExtraction({ id: assetStub.image.id });
|
||||
expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.image.id]);
|
||||
@@ -441,7 +441,7 @@ describe(MetadataService.name, () => {
|
||||
|
||||
it('should handle duration', async () => {
|
||||
assetMock.getByIds.mockResolvedValue([assetStub.image]);
|
||||
metadataMock.getExifTags.mockResolvedValue({ Duration: 6.21 });
|
||||
metadataMock.readTags.mockResolvedValue({ Duration: 6.21 });
|
||||
|
||||
await sut.handleMetadataExtraction({ id: assetStub.image.id });
|
||||
|
||||
@@ -457,7 +457,7 @@ describe(MetadataService.name, () => {
|
||||
|
||||
it('should handle duration as an object without Scale', async () => {
|
||||
assetMock.getByIds.mockResolvedValue([assetStub.image]);
|
||||
metadataMock.getExifTags.mockResolvedValue({ Duration: { Value: 6.2 } });
|
||||
metadataMock.readTags.mockResolvedValue({ Duration: { Value: 6.2 } });
|
||||
|
||||
await sut.handleMetadataExtraction({ id: assetStub.image.id });
|
||||
|
||||
@@ -473,7 +473,7 @@ describe(MetadataService.name, () => {
|
||||
|
||||
it('should handle duration with scale', async () => {
|
||||
assetMock.getByIds.mockResolvedValue([assetStub.image]);
|
||||
metadataMock.getExifTags.mockResolvedValue({ Duration: { Scale: 1.11111111111111e-5, Value: 558720 } });
|
||||
metadataMock.readTags.mockResolvedValue({ Duration: { Scale: 1.11111111111111e-5, Value: 558720 } });
|
||||
|
||||
await sut.handleMetadataExtraction({ id: assetStub.image.id });
|
||||
|
||||
|
||||
@@ -3,10 +3,11 @@ import { Inject, Injectable, Logger } from '@nestjs/common';
|
||||
import { ExifDateTime, Tags } from 'exiftool-vendored';
|
||||
import { firstDateTime } from 'exiftool-vendored/dist/FirstDateTime';
|
||||
import { constants } from 'fs/promises';
|
||||
import _ from 'lodash';
|
||||
import { Duration } from 'luxon';
|
||||
import { Subscription } from 'rxjs';
|
||||
import { usePagination } from '../domain.util';
|
||||
import { IBaseJob, IEntityJob, JOBS_ASSET_PAGINATION_SIZE, JobName, QueueName } from '../job';
|
||||
import { IBaseJob, IEntityJob, ISidecarWriteJob, JOBS_ASSET_PAGINATION_SIZE, JobName, QueueName } from '../job';
|
||||
import {
|
||||
ExifDuration,
|
||||
IAlbumRepository,
|
||||
@@ -251,6 +252,38 @@ export class MetadataService {
|
||||
return true;
|
||||
}
|
||||
|
||||
async handleSidecarWrite(job: ISidecarWriteJob) {
|
||||
const { id, dateTimeOriginal, latitude, longitude } = job;
|
||||
const asset = await this.assetRepository.getById(id);
|
||||
if (!asset) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const sidecarPath = asset.sidecarPath || `${asset.originalPath}.xmp`;
|
||||
const exif = _.omitBy(
|
||||
{
|
||||
CreationDate: dateTimeOriginal,
|
||||
GPSLatitude: latitude,
|
||||
GPSLongitude: longitude,
|
||||
},
|
||||
_.isUndefined,
|
||||
);
|
||||
|
||||
if (Object.keys(exif).length === 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
await this.repository.writeTags(sidecarPath, exif);
|
||||
|
||||
if (!asset.sidecarPath) {
|
||||
await this.assetRepository.save({ id, sidecarPath });
|
||||
}
|
||||
|
||||
await this.jobRepository.queue({ name: JobName.METADATA_EXTRACTION, data: { id: id } });
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private async applyReverseGeocoding(asset: AssetEntity, exifData: ExifEntityWithoutGeocodeAndTypeOrm) {
|
||||
const { latitude, longitude } = exifData;
|
||||
if (!(await this.configCore.hasFeature(FeatureFlag.REVERSE_GEOCODING)) || !longitude || !latitude) {
|
||||
@@ -350,8 +383,8 @@ export class MetadataService {
|
||||
asset: AssetEntity,
|
||||
): Promise<{ exifData: ExifEntityWithoutGeocodeAndTypeOrm; 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 mediaTags = await this.repository.readTags(asset.originalPath);
|
||||
const sidecarTags = asset.sidecarPath ? await this.repository.readTags(asset.sidecarPath) : null;
|
||||
|
||||
// ensure date from sidecar is used if present
|
||||
const hasDateOverride = !!this.getDateTimeOriginal(sidecarTags);
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
IEntityJob,
|
||||
ILibraryFileJob,
|
||||
ILibraryRefreshJob,
|
||||
ISidecarWriteJob,
|
||||
} from '../job/job.interface';
|
||||
|
||||
export interface JobCounts {
|
||||
@@ -54,7 +55,7 @@ export type JobItem =
|
||||
| { name: JobName.QUEUE_METADATA_EXTRACTION; data: IBaseJob }
|
||||
| { name: JobName.METADATA_EXTRACTION; data: IEntityJob }
|
||||
| { name: JobName.LINK_LIVE_PHOTOS; data: IEntityJob }
|
||||
|
||||
| { name: JobName.SIDECAR_WRITE; data: ISidecarWriteJob }
|
||||
// Sidecar Scanning
|
||||
| { name: JobName.QUEUE_SIDECAR; data: IBaseJob }
|
||||
| { name: JobName.SIDECAR_DISCOVERY; data: IEntityJob }
|
||||
|
||||
@@ -35,5 +35,6 @@ export interface IMetadataRepository {
|
||||
teardown(): Promise<void>;
|
||||
reverseGeocode(point: GeoPoint): Promise<ReverseGeocodeResult>;
|
||||
deleteCache(): Promise<void>;
|
||||
getExifTags(path: string): Promise<ImmichTags | null>;
|
||||
readTags(path: string): Promise<ImmichTags | null>;
|
||||
writeTags(path: string, tags: Partial<Tags>): Promise<void>;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { GeoPoint, IMetadataRepository, ImmichTags, ReverseGeocodeResult } from '@app/domain';
|
||||
import { REVERSE_GEOCODING_DUMP_DIRECTORY } from '@app/infra';
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { DefaultReadTaskOptions, exiftool } from 'exiftool-vendored';
|
||||
import { DefaultReadTaskOptions, Tags, exiftool } from 'exiftool-vendored';
|
||||
import { readdir, rm } from 'fs/promises';
|
||||
import * as geotz from 'geo-tz';
|
||||
import { getName } from 'i18n-iso-countries';
|
||||
@@ -76,7 +76,7 @@ export class MetadataRepository implements IMetadataRepository {
|
||||
return { country, state, city };
|
||||
}
|
||||
|
||||
getExifTags(path: string): Promise<ImmichTags | null> {
|
||||
readTags(path: string): Promise<ImmichTags | null> {
|
||||
return exiftool
|
||||
.read(path, undefined, {
|
||||
...DefaultReadTaskOptions,
|
||||
@@ -93,4 +93,8 @@ export class MetadataRepository implements IMetadataRepository {
|
||||
return null;
|
||||
}) as Promise<ImmichTags | null>;
|
||||
}
|
||||
|
||||
async writeTags(path: string, tags: Partial<Tags>): Promise<void> {
|
||||
await exiftool.write(path, tags, ['-overwrite_original']);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -84,6 +84,7 @@ export class AppService {
|
||||
[JobName.QUEUE_SIDECAR]: (data) => this.metadataService.handleQueueSidecar(data),
|
||||
[JobName.SIDECAR_DISCOVERY]: (data) => this.metadataService.handleSidecarDiscovery(data),
|
||||
[JobName.SIDECAR_SYNC]: () => this.metadataService.handleSidecarSync(),
|
||||
[JobName.SIDECAR_WRITE]: (data) => this.metadataService.handleSidecarWrite(data),
|
||||
[JobName.LIBRARY_SCAN_ASSET]: (data) => this.libraryService.handleAssetRefresh(data),
|
||||
[JobName.LIBRARY_SCAN]: (data) => this.libraryService.handleQueueAssetRefresh(data),
|
||||
[JobName.LIBRARY_DELETE]: (data) => this.libraryService.handleDeleteLibrary(data),
|
||||
|
||||
Reference in New Issue
Block a user