From 42854cad56a382841439ab03156e7d26749915d4 Mon Sep 17 00:00:00 2001 From: Jonathan Jogenfors Date: Mon, 1 Dec 2025 23:22:09 +0100 Subject: [PATCH] wip --- server/src/cores/storage.core.ts | 2 +- server/src/database.ts | 2 - server/src/dtos/asset-response.dto.ts | 1 - server/src/enum.ts | 1 + .../src/repositories/asset-job.repository.ts | 34 +++++----- server/src/repositories/asset.repository.ts | 49 ++++++++++----- server/src/repositories/view-repository.ts | 42 +++++++------ server/src/schema/tables/asset.table.ts | 3 - server/src/services/metadata.service.ts | 63 +++++++++++++------ server/src/utils/asset.util.ts | 10 ++- server/src/utils/database.ts | 16 ++++- 11 files changed, 144 insertions(+), 79 deletions(-) diff --git a/server/src/cores/storage.core.ts b/server/src/cores/storage.core.ts index 96623092f1..66ac881b29 100644 --- a/server/src/cores/storage.core.ts +++ b/server/src/cores/storage.core.ts @@ -290,7 +290,7 @@ export class StorageCore { private savePath(pathType: PathType, id: string, newPath: string) { switch (pathType) { case AssetPathType.Original: { - return this.assetRepository.update({ id, originalPath: newPath }); + return this.assetRepository.upsertFile({ assetId: id, type: AssetFileType.Original, path: newPath }); } case AssetPathType.FullSize: { return this.assetRepository.upsertFile({ assetId: id, type: AssetFileType.FullSize, path: newPath }); diff --git a/server/src/database.ts b/server/src/database.ts index 52faa361f9..afb46d7834 100644 --- a/server/src/database.ts +++ b/server/src/database.ts @@ -120,7 +120,6 @@ export type Asset = { livePhotoVideoId: string | null; localDateTime: Date; originalFileName: string; - originalPath: string; ownerId: string; type: AssetType; }; @@ -344,7 +343,6 @@ export const columns = { 'asset.livePhotoVideoId', 'asset.localDateTime', 'asset.originalFileName', - 'asset.originalPath', 'asset.ownerId', 'asset.type', ], diff --git a/server/src/dtos/asset-response.dto.ts b/server/src/dtos/asset-response.dto.ts index e228cd8f9f..3fa01f593d 100644 --- a/server/src/dtos/asset-response.dto.ts +++ b/server/src/dtos/asset-response.dto.ts @@ -121,7 +121,6 @@ export type MapAsset = { livePhotoVideoId: string | null; localDateTime: Date; originalFileName: string; - originalPath: string; owner?: User | null; ownerId: string; stack?: Stack | null; diff --git a/server/src/enum.ts b/server/src/enum.ts index 9d0a2c0426..13a23b7554 100644 --- a/server/src/enum.ts +++ b/server/src/enum.ts @@ -38,6 +38,7 @@ export enum AssetType { } export enum AssetFileType { + Original = 'original', /** * An full/large-size image extracted/converted from RAW photos */ diff --git a/server/src/repositories/asset-job.repository.ts b/server/src/repositories/asset-job.repository.ts index 8e3cba5cc6..d092d70d4f 100644 --- a/server/src/repositories/asset-job.repository.ts +++ b/server/src/repositories/asset-job.repository.ts @@ -18,6 +18,8 @@ import { withFacesAndPeople, withFilePath, withFiles, + withOriginals, + withSidecars, } from 'src/utils/database'; @Injectable() @@ -40,8 +42,9 @@ export class AssetJobRepository { return this.db .selectFrom('asset') .where('asset.id', '=', asUuid(id)) - .select(['id', 'originalPath']) - .select((eb) => withFiles(eb, AssetFileType.Sidecar)) + .select('id') + .select(withSidecars) + .select(withOriginals) .select((eb) => jsonArrayFrom( eb @@ -60,8 +63,9 @@ export class AssetJobRepository { return this.db .selectFrom('asset') .where('asset.id', '=', asUuid(id)) - .select(['id', 'originalPath']) - .select((eb) => withFiles(eb, AssetFileType.Sidecar)) + .select('id') + .select(withSidecars) + .select(withOriginals) .limit(1) .executeTakeFirst(); } @@ -107,7 +111,6 @@ export class AssetJobRepository { 'asset.id', 'asset.visibility', 'asset.originalFileName', - 'asset.originalPath', 'asset.ownerId', 'asset.thumbhash', 'asset.type', @@ -124,7 +127,8 @@ export class AssetJobRepository { .selectFrom('asset') .select(columns.asset) .select(withFaces) - .select((eb) => withFiles(eb, AssetFileType.Sidecar)) + .select(withOriginals) + .select(withSidecars) .where('asset.id', '=', id) .executeTakeFirst(); } @@ -209,14 +213,8 @@ export class AssetJobRepository { getForSyncAssets(ids: string[]) { return this.db .selectFrom('asset') - .select([ - 'asset.id', - 'asset.isOffline', - 'asset.libraryId', - 'asset.originalPath', - 'asset.status', - 'asset.fileModifiedAt', - ]) + .select(['asset.id', 'asset.isOffline', 'asset.libraryId', 'asset.status', 'asset.fileModifiedAt']) + .select(withOriginals) .where('asset.id', '=', anyUuid(ids)) .execute(); } @@ -232,7 +230,6 @@ export class AssetJobRepository { 'asset.ownerId', 'asset.livePhotoVideoId', 'asset.encodedVideoPath', - 'asset.originalPath', ]) .$call(withExif) .select(withFacesAndPeople) @@ -275,7 +272,8 @@ export class AssetJobRepository { getForVideoConversion(id: string) { return this.db .selectFrom('asset') - .select(['asset.id', 'asset.ownerId', 'asset.originalPath', 'asset.encodedVideoPath']) + .select(['asset.id', 'asset.ownerId', 'asset.encodedVideoPath']) + .select(withOriginals) .where('asset.id', '=', id) .where('asset.type', '=', AssetType.Video) .executeTakeFirst(); @@ -306,7 +304,6 @@ export class AssetJobRepository { 'asset.ownerId', 'asset.type', 'asset.checksum', - 'asset.originalPath', 'asset.isExternal', 'asset.originalFileName', 'asset.livePhotoVideoId', @@ -320,8 +317,9 @@ export class AssetJobRepository { .whereRef('asset_file.assetId', '=', 'asset.id') .where('asset_file.type', '=', AssetFileType.Sidecar) .limit(1) - .as('sidecarPath'), + .as('sidecarPath'), // TODO: change to withSidecars ]) + .select(withOriginals) .where('asset.deletedAt', 'is', null); } diff --git a/server/src/repositories/asset.repository.ts b/server/src/repositories/asset.repository.ts index 95c97734f1..06b0e7d607 100644 --- a/server/src/repositories/asset.repository.ts +++ b/server/src/repositories/asset.repository.ts @@ -25,7 +25,9 @@ import { withFacesAndPeople, withFiles, withLibrary, + withOriginals, withOwner, + withSidecars, withSmartSearch, withTagId, withTags, @@ -112,6 +114,8 @@ interface GetByIdsRelations { smartSearch?: boolean; stack?: { assets?: boolean }; tags?: boolean; + originals?: boolean; + sidecars?: boolean; } @Injectable() @@ -355,8 +359,10 @@ export class AssetRepository { return this.db .selectFrom('asset') .selectAll('asset') - .where('libraryId', '=', asUuid(libraryId)) - .where('originalPath', '=', originalPath) + .innerJoin('asset_file', 'asset.id', 'asset_file.assetId') + .where('asset.libraryId', '=', asUuid(libraryId)) + .where('asset_file.path', '=', originalPath) + .where('asset_file.type', '=', AssetFileType.Original) .limit(1) .executeTakeFirst(); } @@ -398,7 +404,10 @@ export class AssetRepository { } @GenerateSql({ params: [DummyValue.UUID] }) - getById(id: string, { exifInfo, faces, files, library, owner, smartSearch, stack, tags }: GetByIdsRelations = {}) { + getById( + id: string, + { exifInfo, faces, files, library, owner, smartSearch, stack, tags, sidecars, originals }: GetByIdsRelations = {}, + ) { return this.db .selectFrom('asset') .selectAll('asset') @@ -434,6 +443,8 @@ export class AssetRepository { ), ) .$if(!!files, (qb) => qb.select(withFiles)) + .$if(!!sidecars, (qb) => qb.select(withSidecars)) + .$if(!!originals, (qb) => qb.select(withOriginals)) .$if(!!tags, (qb) => qb.select(withTags)) .limit(1) .executeTakeFirst(); @@ -877,14 +888,22 @@ export class AssetRepository { isOffline: true, deletedAt: new Date(), }) - .where('isOffline', '=', false) - .where('isExternal', '=', true) - .where('libraryId', '=', asUuid(libraryId)) + .where('asset.isOffline', '=', false) + .where('asset.isExternal', '=', true) + .where('asset.libraryId', '=', asUuid(libraryId)) .where((eb) => - eb.or([ - eb.not(eb.or(paths.map((path) => eb('originalPath', 'like', path)))), - eb.or(exclusions.map((path) => eb('originalPath', 'like', path))), - ]), + eb.exists( + eb + .selectFrom('asset_file') + .whereRef('asset_file.assetId', '=', 'asset.id') + .where('asset_file.type', '=', AssetFileType.Original) + .where((eb) => + eb.or([ + eb.not(eb.or(paths.map((path) => eb('asset_file.path', 'like', path)))), + eb.or(exclusions.map((path) => eb('asset_file.path', 'like', path))), + ]), + ), + ), ) .executeTakeFirstOrThrow(); } @@ -899,10 +918,12 @@ export class AssetRepository { eb.exists( this.db .selectFrom('asset') - .select('originalPath') - .whereRef('asset.originalPath', '=', eb.ref('path')) - .where('libraryId', '=', asUuid(libraryId)) - .where('isExternal', '=', true), + .innerJoin('asset_file', 'asset.id', 'asset_file.assetId') + .select('asset_file.path') + .whereRef('asset_file.path', '=', eb.ref('path')) + .where('asset_file.type', '=', AssetFileType.Original) + .where('asset.libraryId', '=', asUuid(libraryId)) + .where('asset.isExternal', '=', true), ), ), ) diff --git a/server/src/repositories/view-repository.ts b/server/src/repositories/view-repository.ts index ceab79f6eb..353f636cb4 100644 --- a/server/src/repositories/view-repository.ts +++ b/server/src/repositories/view-repository.ts @@ -1,7 +1,7 @@ import { Kysely } from 'kysely'; import { InjectKysely } from 'nestjs-kysely'; import { DummyValue, GenerateSql } from 'src/decorators'; -import { AssetVisibility } from 'src/enum'; +import { AssetFileType, AssetVisibility } from 'src/enum'; import { DB } from 'src/schema'; import { asUuid, withExif } from 'src/utils/database'; @@ -12,14 +12,18 @@ export class ViewRepository { async getUniqueOriginalPaths(userId: string) { const results = await this.db .selectFrom('asset') - .select((eb) => eb.fn('substring', ['asset.originalPath', eb.val('^(.*/)[^/]*$')]).as('directoryPath')) + .innerJoin('asset_file', 'asset.id', 'asset_file.assetId') + .select((eb) => + eb.fn('substring', [eb.ref('asset_file.path'), eb.val('^(.*/)[^/]*$')]).as('directoryPath'), + ) .distinct() - .where('ownerId', '=', asUuid(userId)) - .where('visibility', '=', AssetVisibility.Timeline) - .where('deletedAt', 'is', null) - .where('fileCreatedAt', 'is not', null) - .where('fileModifiedAt', 'is not', null) - .where('localDateTime', 'is not', null) + .where('asset.ownerId', '=', asUuid(userId)) + .where('asset.visibility', '=', AssetVisibility.Timeline) + .where('asset.deletedAt', 'is', null) + .where('asset.fileCreatedAt', 'is not', null) + .where('asset.fileModifiedAt', 'is not', null) + .where('asset.localDateTime', 'is not', null) + .where((eb) => eb(eb.ref('asset_file.type'), '=', AssetFileType.Original)) .orderBy('directoryPath', 'asc') .execute(); @@ -32,20 +36,22 @@ export class ViewRepository { return this.db .selectFrom('asset') + .innerJoin('asset_file', 'asset.id', 'asset_file.assetId') .selectAll('asset') - .$call(withExif) - .where('ownerId', '=', asUuid(userId)) - .where('visibility', '=', AssetVisibility.Timeline) - .where('deletedAt', 'is', null) - .where('fileCreatedAt', 'is not', null) - .where('fileModifiedAt', 'is not', null) - .where('localDateTime', 'is not', null) - .where('originalPath', 'like', `%${normalizedPath}/%`) - .where('originalPath', 'not like', `%${normalizedPath}/%/%`) + .where('asset.ownerId', '=', asUuid(userId)) + .where('asset.visibility', '=', AssetVisibility.Timeline) + .where('asset.deletedAt', 'is', null) + .where('asset.fileCreatedAt', 'is not', null) + .where('asset.fileModifiedAt', 'is not', null) + .where('asset.localDateTime', 'is not', null) + .where((eb) => eb(eb.ref('asset_file.type'), '=', AssetFileType.Original)) + .where((eb) => eb(eb.ref('asset_file.path'), 'like', `%${normalizedPath}/%`)) + .where((eb) => eb(eb.ref('asset_file.path'), 'not like', `%${normalizedPath}/%/%`)) .orderBy( - (eb) => eb.fn('regexp_replace', ['asset.originalPath', eb.val('.*/(.+)'), eb.val(String.raw`\1`)]), + (eb) => eb.fn('regexp_replace', [eb.ref('asset_file.path'), eb.val('.*/(.+)'), eb.val(String.raw`\\1`)]), 'asc', ) + .$call(withExif) .execute(); } } diff --git a/server/src/schema/tables/asset.table.ts b/server/src/schema/tables/asset.table.ts index b28fc99e4a..c0e3bfdd2d 100644 --- a/server/src/schema/tables/asset.table.ts +++ b/server/src/schema/tables/asset.table.ts @@ -72,9 +72,6 @@ export class AssetTable { @Column() type!: AssetType; - @Column() - originalPath!: string; - @Column({ type: 'timestamp with time zone', index: true }) fileCreatedAt!: Timestamp; diff --git a/server/src/services/metadata.service.ts b/server/src/services/metadata.service.ts index 032b5c3eef..6e6d7daf8a 100644 --- a/server/src/services/metadata.service.ts +++ b/server/src/services/metadata.service.ts @@ -224,13 +224,19 @@ export class MetadataService extends BaseService { return; } + const originalFile = asset.files?.find((file) => file.type === AssetFileType.Original) ?? null; + if (!originalFile) { + this.logger.warn(`Asset ${asset.id} has no original file, skipping metadata extraction`); + return; + } + const [exifTags, stats] = await Promise.all([ this.getExifTags(asset), - this.storageRepository.stat(asset.originalPath), + this.storageRepository.stat(originalFile.path), ]); this.logger.verbose('Exif Tags', exifTags); - const dates = this.getDates(asset, exifTags, stats); + const dates = this.getDates(asset, originalFile.path, exifTags, stats); const { width, height } = this.getImageDimensions(exifTags); let geo: ReverseGeocodeResult = { country: null, state: null, city: null }, @@ -351,7 +357,7 @@ export class MetadataService extends BaseService { } let sidecarPath = null; - for (const candidate of this.getSidecarCandidates(asset)) { + for (const candidate of this.getSidecarCandidates(asset.files)) { const exists = await this.storageRepository.checkFileExists(candidate, constants.R_OK); if (!exists) { continue; @@ -400,7 +406,14 @@ export class MetadataService extends BaseService { const tagsList = (asset.tags || []).map((tag) => tag.value); - const sidecarPath = asset.files[0]?.path || `${asset.originalPath}.xmp`; + const existingSidecar = asset.files?.find((file) => file.type === AssetFileType.Sidecar) ?? null; + const original = asset.files?.find((file) => file.type === AssetFileType.Original) ?? null; + + if (!original) { + throw new Error(`Asset ${asset.id} has no original file`); + } + + const sidecarPath = existingSidecar?.path || `${original.path}.xmp`; // prefer file.jpg.xmp by default const exif = _.omitBy( { Description: description, @@ -427,7 +440,12 @@ export class MetadataService extends BaseService { return JobStatus.Success; } - private getSidecarCandidates({ files, originalPath }: { files: AssetFile[] | null; originalPath: string }) { + private getSidecarCandidates(files: AssetFile[] | null) { + const original = files?.find((file) => file.type === AssetFileType.Original); + if (!original) { + return []; + } + const candidates: string[] = []; const existingSidecar = files?.find((file) => file.type === AssetFileType.Sidecar); @@ -436,11 +454,11 @@ export class MetadataService extends BaseService { candidates.push(existingSidecar.path); } - const assetPath = parse(originalPath); + const assetPath = parse(original.path); candidates.push( // IMG_123.jpg.xmp - `${originalPath}.xmp`, + `${assetPath}.xmp`, // IMG_123.xmp `${join(assetPath.dir, assetPath.name)}.xmp`, ); @@ -464,25 +482,31 @@ export class MetadataService extends BaseService { return { width, height }; } - private async getExifTags(asset: { originalPath: string; files: AssetFile[]; type: AssetType }): Promise { + private async getExifTags(asset: { id; files: AssetFile[]; type: AssetType }): Promise { + const originalFile = asset.files?.find((file) => file.type === AssetFileType.Original) ?? null; + + if (!originalFile) { + throw new Error(`Asset ${asset.id} has no original file`); + } + if (asset.type === AssetType.Image) { const hasSidecar = asset.files?.some(({ type }) => type === AssetFileType.Sidecar); if (!hasSidecar) { - return this.metadataRepository.readTags(asset.originalPath); + return this.metadataRepository.readTags(originalFile.path); } } if (asset.files && asset.files.length > 1) { - throw new Error(`Asset ${asset.originalPath} has multiple sidecar files`); + throw new Error(`Asset ${originalFile.path} has multiple sidecar files`); } const sidecarFile = asset.files ? getAssetFiles(asset.files).sidecarFile : undefined; const [mediaTags, sidecarTags, videoTags] = await Promise.all([ - this.metadataRepository.readTags(asset.originalPath), + this.metadataRepository.readTags(originalFile.path), sidecarFile ? this.metadataRepository.readTags(sidecarFile.path) : null, - asset.type === AssetType.Video ? this.getVideoTags(asset.originalPath) : null, + asset.type === AssetType.Video ? this.getVideoTags(originalFile.path) : null, ]); // prefer dates from sidecar tags @@ -546,7 +570,7 @@ export class MetadataService extends BaseService { return asset.type === AssetType.Image && !!(tags.MotionPhoto || tags.MicroVideo); } - private async applyMotionPhotos(asset: Asset, tags: ImmichTags, dates: Dates, stats: Stats) { + private async applyMotionPhotos(asset: Asset, originalPath: string, tags: ImmichTags, dates: Dates, stats: Stats) { const isMotionPhoto = tags.MotionPhoto; const isMicroVideo = tags.MicroVideo; const videoOffset = tags.MicroVideoOffset; @@ -577,7 +601,7 @@ export class MetadataService extends BaseService { return; } - this.logger.debug(`Starting motion photo video extraction for asset ${asset.id}: ${asset.originalPath}`); + this.logger.debug(`Starting motion photo video extraction for asset ${asset.id}: ${originalPath}`); try { const position = stats.size - length - padding; @@ -585,15 +609,15 @@ export class MetadataService extends BaseService { // Samsung MotionPhoto video extraction // HEIC-encoded if (hasMotionPhotoVideo) { - video = await this.metadataRepository.extractBinaryTag(asset.originalPath, 'MotionPhotoVideo'); + video = await this.metadataRepository.extractBinaryTag(originalPath, 'MotionPhotoVideo'); } // JPEG-encoded; HEIC also contains these tags, so this conditional must come second else if (hasEmbeddedVideoFile) { - video = await this.metadataRepository.extractBinaryTag(asset.originalPath, 'EmbeddedVideoFile'); + video = await this.metadataRepository.extractBinaryTag(originalPath, 'EmbeddedVideoFile'); } // Default video extraction else { - video = await this.storageRepository.readFile(asset.originalPath, { + video = await this.storageRepository.readFile(originalPath, { buffer: Buffer.alloc(length), position, length, @@ -617,7 +641,7 @@ export class MetadataService extends BaseService { localDateTime: dates.localDateTime, checksum, ownerId: asset.ownerId, - originalPath: StorageCore.getAndroidMotionPath(asset, motionAssetId), + files: [{ type: AssetFileType.Original, path: StorageCore.getAndroidMotionPath(asset, motionAssetId) }], originalFileName: `${parse(asset.originalFileName).name}.mp4`, visibility: AssetVisibility.Hidden, deviceAssetId: 'NONE', @@ -848,7 +872,8 @@ export class MetadataService extends BaseService { } private getDates( - asset: { id: string; originalPath: string; fileCreatedAt: Date }, + asset: { id: string; fileCreatedAt: Date }, + originalPath: string, exifTags: ImmichTags, stats: Stats, ) { diff --git a/server/src/utils/asset.util.ts b/server/src/utils/asset.util.ts index f3f807c829..f5a578392f 100644 --- a/server/src/utils/asset.util.ts +++ b/server/src/utils/asset.util.ts @@ -161,12 +161,18 @@ export const onBeforeUnlink = async ( { asset: assetRepository }: AssetHookRepositories, { livePhotoVideoId }: { livePhotoVideoId: string }, ) => { - const motion = await assetRepository.getById(livePhotoVideoId); + const motion = await assetRepository.getById(livePhotoVideoId, { files: true }); if (!motion) { return null; } - if (StorageCore.isAndroidMotionPath(motion.originalPath)) { + const motionPath = motion.files?.find((file) => file.type === AssetFileType.Original)?.path; + + if (!motionPath) { + throw new BadRequestException('Live photo video original file not found'); + } + + if (StorageCore.isAndroidMotionPath(motionPath)) { throw new BadRequestException('Cannot unlink Android motion photos'); } diff --git a/server/src/utils/database.ts b/server/src/utils/database.ts index 0cc3788f1a..daec63f8cb 100644 --- a/server/src/utils/database.ts +++ b/server/src/utils/database.ts @@ -191,13 +191,23 @@ export function withFaces(eb: ExpressionBuilder, withDeletedFace?: } export function withFiles(eb: ExpressionBuilder, type?: AssetFileType) { - return jsonArrayFrom( + const files = jsonArrayFrom( eb .selectFrom('asset_file') .select(columns.assetFiles) .whereRef('asset_file.assetId', '=', 'asset.id') .$if(!!type, (qb) => qb.where('asset_file.type', '=', type!)), ).as('files'); + + return files; +} + +export function withSidecars(eb: ExpressionBuilder, type?: AssetFileType) { + return withFiles(eb, AssetFileType.Sidecar); +} + +export function withOriginals(eb: ExpressionBuilder, type?: AssetFileType) { + return withFiles(eb, AssetFileType.Original); } export function withFilePath(eb: ExpressionBuilder, type: AssetFileType) { @@ -208,6 +218,10 @@ export function withFilePath(eb: ExpressionBuilder, type: AssetFile .where('asset_file.type', '=', type); } +export function withOriginalPath(eb: ExpressionBuilder) { + return withFilePath(eb, AssetFileType.Original).as('originalPath'); +} + export function withFacesAndPeople(eb: ExpressionBuilder, withDeletedFace?: boolean) { return jsonArrayFrom( eb