Compare commits

...

15 Commits

Author SHA1 Message Date
Jonathan Jogenfors
173904e387 Merge branch 'main' of https://github.com/immich-app/immich into chore/originals-in-asset-files 2025-12-04 11:30:35 +01:00
Jonathan Jogenfors
42854cad56 wip 2025-12-02 19:46:59 +01:00
Jonathan Jogenfors
93ec8b7ecf Merge branch 'main' of https://github.com/immich-app/immich into feat/sidecar-asset-files 2025-11-28 01:22:36 +01:00
Jonathan Jogenfors
bf1d409be1 Merge branch 'main' of https://github.com/immich-app/immich into feat/sidecar-asset-files 2025-11-28 00:30:42 +01:00
Jonathan Jogenfors
857816bccc Merge branch 'fix/asset-service-argument-order' of https://github.com/immich-app/immich into feat/sidecar-asset-files 2025-10-31 01:00:09 +01:00
Jonathan Jogenfors
e19467eddd fix(server): improved method signatures for stack and sidecar copying 2025-10-31 00:31:03 +01:00
Jonathan Jogenfors
0cb96837d0 Merge branch 'fix/asset-service-argument-order' of https://github.com/immich-app/immich into feat/sidecar-asset-files 2025-10-31 00:27:33 +01:00
Jonathan Jogenfors
cbdfe08344 fix(server): improved method signatures for stack and sidecar copying 2025-10-31 00:14:12 +01:00
Jonathan Jogenfors
6229f9feb4 Merge branch 'main' of https://github.com/immich-app/immich into feat/sidecar-asset-files 2025-10-31 00:03:32 +01:00
Jonathan Jogenfors
20f5a14d03 Merge branch 'main' of https://github.com/immich-app/immich into feat/sidecar-asset-files 2025-10-30 23:19:07 +01:00
Jonathan Jogenfors
7ade8ad69a Merge branch 'main' of https://github.com/immich-app/immich into feat/sidecar-asset-files 2025-10-04 00:01:53 +02:00
Jonathan Jogenfors
6b91b31dbc feat: combine with handleSidecarCheck 2025-09-07 21:36:02 +02:00
Jonathan Jogenfors
65f63be564 Merge branch 'fix/sidecar-check-job' of https://github.com/immich-app/immich into feat/sidecar-asset-files 2025-08-27 13:51:14 +02:00
Jonathan Jogenfors
430af9a145 feat: move sidecars to asset_files 2025-08-27 13:02:03 +02:00
Jason Rasmussen
f3b0e8a5e6 fix: sidecar check job 2025-08-26 11:51:21 -04:00
14 changed files with 244 additions and 138 deletions

View File

@@ -290,7 +290,7 @@ export class StorageCore {
private savePath(pathType: PathType, id: string, newPath: string) { private savePath(pathType: PathType, id: string, newPath: string) {
switch (pathType) { switch (pathType) {
case AssetPathType.Original: { case AssetPathType.Original: {
return this.assetRepository.update({ id, originalPath: newPath }); return this.assetRepository.upsertFile({ assetId: id, type: AssetFileType.Original, path: newPath });
} }
case AssetPathType.FullSize: { case AssetPathType.FullSize: {
return this.assetRepository.upsertFile({ assetId: id, type: AssetFileType.FullSize, path: newPath }); return this.assetRepository.upsertFile({ assetId: id, type: AssetFileType.FullSize, path: newPath });

View File

@@ -120,7 +120,6 @@ export type Asset = {
livePhotoVideoId: string | null; livePhotoVideoId: string | null;
localDateTime: Date; localDateTime: Date;
originalFileName: string; originalFileName: string;
originalPath: string;
ownerId: string; ownerId: string;
type: AssetType; type: AssetType;
}; };
@@ -337,7 +336,6 @@ export const columns = {
'asset.livePhotoVideoId', 'asset.livePhotoVideoId',
'asset.localDateTime', 'asset.localDateTime',
'asset.originalFileName', 'asset.originalFileName',
'asset.originalPath',
'asset.ownerId', 'asset.ownerId',
'asset.type', 'asset.type',
], ],

View File

@@ -121,7 +121,6 @@ export type MapAsset = {
livePhotoVideoId: string | null; livePhotoVideoId: string | null;
localDateTime: Date; localDateTime: Date;
originalFileName: string; originalFileName: string;
originalPath: string;
owner?: User | null; owner?: User | null;
ownerId: string; ownerId: string;
stack?: Stack | null; stack?: Stack | null;

View File

@@ -38,6 +38,7 @@ export enum AssetType {
} }
export enum AssetFileType { export enum AssetFileType {
Original = 'original',
/** /**
* An full/large-size image extracted/converted from RAW photos * An full/large-size image extracted/converted from RAW photos
*/ */

View File

@@ -17,6 +17,8 @@ import {
withFacesAndPeople, withFacesAndPeople,
withFilePath, withFilePath,
withFiles, withFiles,
withOriginals,
withSidecars,
} from 'src/utils/database'; } from 'src/utils/database';
@Injectable() @Injectable()
@@ -39,8 +41,9 @@ export class AssetJobRepository {
return this.db return this.db
.selectFrom('asset') .selectFrom('asset')
.where('asset.id', '=', asUuid(id)) .where('asset.id', '=', asUuid(id))
.select(['id', 'originalPath']) .select('id')
.select((eb) => withFiles(eb, AssetFileType.Sidecar)) .select(withSidecars)
.select(withOriginals)
.select((eb) => .select((eb) =>
jsonArrayFrom( jsonArrayFrom(
eb eb
@@ -59,8 +62,9 @@ export class AssetJobRepository {
return this.db return this.db
.selectFrom('asset') .selectFrom('asset')
.where('asset.id', '=', asUuid(id)) .where('asset.id', '=', asUuid(id))
.select(['id', 'originalPath']) .select('id')
.select((eb) => withFiles(eb, AssetFileType.Sidecar)) .select(withSidecars)
.select(withOriginals)
.limit(1) .limit(1)
.executeTakeFirst(); .executeTakeFirst();
} }
@@ -106,7 +110,6 @@ export class AssetJobRepository {
'asset.id', 'asset.id',
'asset.visibility', 'asset.visibility',
'asset.originalFileName', 'asset.originalFileName',
'asset.originalPath',
'asset.ownerId', 'asset.ownerId',
'asset.thumbhash', 'asset.thumbhash',
'asset.type', 'asset.type',
@@ -123,7 +126,8 @@ export class AssetJobRepository {
.selectFrom('asset') .selectFrom('asset')
.select(columns.asset) .select(columns.asset)
.select(withFaces) .select(withFaces)
.select((eb) => withFiles(eb, AssetFileType.Sidecar)) .select(withOriginals)
.select(withSidecars)
.where('asset.id', '=', id) .where('asset.id', '=', id)
.executeTakeFirst(); .executeTakeFirst();
} }
@@ -208,14 +212,8 @@ export class AssetJobRepository {
getForSyncAssets(ids: string[]) { getForSyncAssets(ids: string[]) {
return this.db return this.db
.selectFrom('asset') .selectFrom('asset')
.select([ .select(['asset.id', 'asset.isOffline', 'asset.libraryId', 'asset.status', 'asset.fileModifiedAt'])
'asset.id', .select(withOriginals)
'asset.isOffline',
'asset.libraryId',
'asset.originalPath',
'asset.status',
'asset.fileModifiedAt',
])
.where('asset.id', '=', anyUuid(ids)) .where('asset.id', '=', anyUuid(ids))
.execute(); .execute();
} }
@@ -231,7 +229,6 @@ export class AssetJobRepository {
'asset.ownerId', 'asset.ownerId',
'asset.livePhotoVideoId', 'asset.livePhotoVideoId',
'asset.encodedVideoPath', 'asset.encodedVideoPath',
'asset.originalPath',
]) ])
.$call(withExif) .$call(withExif)
.select(withFacesAndPeople) .select(withFacesAndPeople)
@@ -274,7 +271,8 @@ export class AssetJobRepository {
getForVideoConversion(id: string) { getForVideoConversion(id: string) {
return this.db return this.db
.selectFrom('asset') .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.id', '=', id)
.where('asset.type', '=', AssetType.Video) .where('asset.type', '=', AssetType.Video)
.executeTakeFirst(); .executeTakeFirst();
@@ -305,15 +303,22 @@ export class AssetJobRepository {
'asset.ownerId', 'asset.ownerId',
'asset.type', 'asset.type',
'asset.checksum', 'asset.checksum',
'asset.originalPath',
'asset.isExternal', 'asset.isExternal',
'asset.originalFileName', 'asset.originalFileName',
'asset.livePhotoVideoId', 'asset.livePhotoVideoId',
'asset.fileCreatedAt', 'asset.fileCreatedAt',
'asset_exif.timeZone', 'asset_exif.timeZone',
'asset_exif.fileSizeInByte', 'asset_exif.fileSizeInByte',
(eb) =>
eb
.selectFrom('asset_file')
.select('asset_file.path')
.whereRef('asset_file.assetId', '=', 'asset.id')
.where('asset_file.type', '=', AssetFileType.Sidecar)
.limit(1)
.as('sidecarPath'), // TODO: change to withSidecars
]) ])
.select((eb) => withFiles(eb, AssetFileType.Sidecar)) .select(withOriginals)
.where('asset.deletedAt', 'is', null); .where('asset.deletedAt', 'is', null);
} }

View File

@@ -24,7 +24,9 @@ import {
withFacesAndPeople, withFacesAndPeople,
withFiles, withFiles,
withLibrary, withLibrary,
withOriginals,
withOwner, withOwner,
withSidecars,
withSmartSearch, withSmartSearch,
withTagId, withTagId,
withTags, withTags,
@@ -111,6 +113,8 @@ interface GetByIdsRelations {
smartSearch?: boolean; smartSearch?: boolean;
stack?: { assets?: boolean }; stack?: { assets?: boolean };
tags?: boolean; tags?: boolean;
originals?: boolean;
sidecars?: boolean;
} }
@Injectable() @Injectable()
@@ -251,8 +255,23 @@ export class AssetRepository {
await this.db.deleteFrom('asset_metadata').where('assetId', '=', id).where('key', '=', key).execute(); await this.db.deleteFrom('asset_metadata').where('assetId', '=', id).where('key', '=', key).execute();
} }
create(asset: Insertable<AssetTable>) { create(asset: Insertable<AssetTable>, files?: Insertable<AssetFileTable>[]) {
return this.db.insertInto('asset').values(asset).returningAll().executeTakeFirstOrThrow(); return this.db.transaction().execute(async (trx) => {
const createdAsset = await trx.insertInto('asset').values(asset).returningAll().executeTakeFirstOrThrow();
if (files && files.length > 0) {
const values = files.map((f) => ({ ...f, assetId: createdAsset.id }));
await trx.insertInto('asset_file').values(values).returningAll().execute();
}
const assetWithFiles = await trx
.selectFrom('asset')
.selectAll('asset')
.select(withOriginals)
.where('asset.id', '=', asUuid(createdAsset.id))
.executeTakeFirstOrThrow();
return assetWithFiles;
});
} }
createAll(assets: Insertable<AssetTable>[]) { createAll(assets: Insertable<AssetTable>[]) {
@@ -354,8 +373,10 @@ export class AssetRepository {
return this.db return this.db
.selectFrom('asset') .selectFrom('asset')
.selectAll('asset') .selectAll('asset')
.where('libraryId', '=', asUuid(libraryId)) .innerJoin('asset_file', 'asset.id', 'asset_file.assetId')
.where('originalPath', '=', originalPath) .where('asset.libraryId', '=', asUuid(libraryId))
.where('asset_file.path', '=', originalPath)
.where('asset_file.type', '=', AssetFileType.Original)
.limit(1) .limit(1)
.executeTakeFirst(); .executeTakeFirst();
} }
@@ -396,11 +417,10 @@ export class AssetRepository {
return this.db.selectFrom('asset_file').select(['assetId', 'path']).limit(sql.lit(3)).execute(); return this.db.selectFrom('asset_file').select(['assetId', 'path']).limit(sql.lit(3)).execute();
} }
@GenerateSql({ params: [DummyValue.UUID] })
getForCopy(id: string) { getForCopy(id: string) {
return this.db return this.db
.selectFrom('asset') .selectFrom('asset')
.select(['id', 'stackId', 'originalPath', 'isFavorite']) .select(['id', 'stackId', 'isFavorite'])
.select(withFiles) .select(withFiles)
.where('id', '=', asUuid(id)) .where('id', '=', asUuid(id))
.limit(1) .limit(1)
@@ -408,7 +428,10 @@ export class AssetRepository {
} }
@GenerateSql({ params: [DummyValue.UUID] }) @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 return this.db
.selectFrom('asset') .selectFrom('asset')
.selectAll('asset') .selectAll('asset')
@@ -444,6 +467,8 @@ export class AssetRepository {
), ),
) )
.$if(!!files, (qb) => qb.select(withFiles)) .$if(!!files, (qb) => qb.select(withFiles))
.$if(!!sidecars, (qb) => qb.select(withSidecars))
.$if(!!originals, (qb) => qb.select(withOriginals))
.$if(!!tags, (qb) => qb.select(withTags)) .$if(!!tags, (qb) => qb.select(withTags))
.limit(1) .limit(1)
.executeTakeFirst(); .executeTakeFirst();
@@ -487,6 +512,7 @@ export class AssetRepository {
return this.db return this.db
.selectFrom('asset') .selectFrom('asset')
.selectAll('asset') .selectAll('asset')
.select(withOriginals)
.where('ownerId', '=', asUuid(ownerId)) .where('ownerId', '=', asUuid(ownerId))
.where('checksum', '=', checksum) .where('checksum', '=', checksum)
.$call((qb) => (libraryId ? qb.where('libraryId', '=', asUuid(libraryId)) : qb.where('libraryId', 'is', null))) .$call((qb) => (libraryId ? qb.where('libraryId', '=', asUuid(libraryId)) : qb.where('libraryId', 'is', null)))
@@ -883,14 +909,22 @@ export class AssetRepository {
isOffline: true, isOffline: true,
deletedAt: new Date(), deletedAt: new Date(),
}) })
.where('isOffline', '=', false) .where('asset.isOffline', '=', false)
.where('isExternal', '=', true) .where('asset.isExternal', '=', true)
.where('libraryId', '=', asUuid(libraryId)) .where('asset.libraryId', '=', asUuid(libraryId))
.where((eb) => .where((eb) =>
eb.or([ eb.exists(
eb.not(eb.or(paths.map((path) => eb('originalPath', 'like', path)))), eb
eb.or(exclusions.map((path) => eb('originalPath', 'like', path))), .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(); .executeTakeFirstOrThrow();
} }
@@ -905,10 +939,12 @@ export class AssetRepository {
eb.exists( eb.exists(
this.db this.db
.selectFrom('asset') .selectFrom('asset')
.select('originalPath') .innerJoin('asset_file', 'asset.id', 'asset_file.assetId')
.whereRef('asset.originalPath', '=', eb.ref('path')) .select('asset_file.path')
.where('libraryId', '=', asUuid(libraryId)) .whereRef('asset_file.path', '=', eb.ref('path'))
.where('isExternal', '=', true), .where('asset_file.type', '=', AssetFileType.Original)
.where('asset.libraryId', '=', asUuid(libraryId))
.where('asset.isExternal', '=', true),
), ),
), ),
) )

View File

@@ -1,7 +1,7 @@
import { Kysely } from 'kysely'; import { Kysely } from 'kysely';
import { InjectKysely } from 'nestjs-kysely'; import { InjectKysely } from 'nestjs-kysely';
import { DummyValue, GenerateSql } from 'src/decorators'; import { DummyValue, GenerateSql } from 'src/decorators';
import { AssetVisibility } from 'src/enum'; import { AssetFileType, AssetVisibility } from 'src/enum';
import { DB } from 'src/schema'; import { DB } from 'src/schema';
import { asUuid, withExif } from 'src/utils/database'; import { asUuid, withExif } from 'src/utils/database';
@@ -12,14 +12,18 @@ export class ViewRepository {
async getUniqueOriginalPaths(userId: string) { async getUniqueOriginalPaths(userId: string) {
const results = await this.db const results = await this.db
.selectFrom('asset') .selectFrom('asset')
.select((eb) => eb.fn<string>('substring', ['asset.originalPath', eb.val('^(.*/)[^/]*$')]).as('directoryPath')) .innerJoin('asset_file', 'asset.id', 'asset_file.assetId')
.select((eb) =>
eb.fn<string>('substring', [eb.ref('asset_file.path'), eb.val('^(.*/)[^/]*$')]).as('directoryPath'),
)
.distinct() .distinct()
.where('ownerId', '=', asUuid(userId)) .where('asset.ownerId', '=', asUuid(userId))
.where('visibility', '=', AssetVisibility.Timeline) .where('asset.visibility', '=', AssetVisibility.Timeline)
.where('deletedAt', 'is', null) .where('asset.deletedAt', 'is', null)
.where('fileCreatedAt', 'is not', null) .where('asset.fileCreatedAt', 'is not', null)
.where('fileModifiedAt', 'is not', null) .where('asset.fileModifiedAt', 'is not', null)
.where('localDateTime', 'is not', null) .where('asset.localDateTime', 'is not', null)
.where((eb) => eb(eb.ref('asset_file.type'), '=', AssetFileType.Original))
.orderBy('directoryPath', 'asc') .orderBy('directoryPath', 'asc')
.execute(); .execute();
@@ -32,20 +36,22 @@ export class ViewRepository {
return this.db return this.db
.selectFrom('asset') .selectFrom('asset')
.innerJoin('asset_file', 'asset.id', 'asset_file.assetId')
.selectAll('asset') .selectAll('asset')
.$call(withExif) .where('asset.ownerId', '=', asUuid(userId))
.where('ownerId', '=', asUuid(userId)) .where('asset.visibility', '=', AssetVisibility.Timeline)
.where('visibility', '=', AssetVisibility.Timeline) .where('asset.deletedAt', 'is', null)
.where('deletedAt', 'is', null) .where('asset.fileCreatedAt', 'is not', null)
.where('fileCreatedAt', 'is not', null) .where('asset.fileModifiedAt', 'is not', null)
.where('fileModifiedAt', 'is not', null) .where('asset.localDateTime', 'is not', null)
.where('localDateTime', 'is not', null) .where((eb) => eb(eb.ref('asset_file.type'), '=', AssetFileType.Original))
.where('originalPath', 'like', `%${normalizedPath}/%`) .where((eb) => eb(eb.ref('asset_file.path'), 'like', `%${normalizedPath}/%`))
.where('originalPath', 'not like', `%${normalizedPath}/%/%`) .where((eb) => eb(eb.ref('asset_file.path'), 'not like', `%${normalizedPath}/%/%`))
.orderBy( .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', 'asc',
) )
.$call(withExif)
.execute(); .execute();
} }
} }

View File

@@ -0,0 +1,24 @@
import { Kysely, sql } from 'kysely';
export async function up(db: Kysely<any>): Promise<void> {
await sql`INSERT INTO asset_file ("assetId", path, type)
SELECT
id, "sidecarPath", 'sidecar'
FROM asset
WHERE "sidecarPath" IS NOT NULL;`.execute(db);
await sql`ALTER TABLE "asset" DROP COLUMN "sidecarPath";`.execute(db);
}
export async function down(db: Kysely<any>): Promise<void> {
await sql`ALTER TABLE "asset" ADD "sidecarPath" character varying;`.execute(db);
await sql`
UPDATE asset
SET "sidecarPath" = asset_file.path
FROM asset_file
WHERE asset.id = asset_file."assetId" AND asset_file.type = 'sidecar';
`.execute(db);
await sql`DELETE FROM asset_file WHERE type = 'sidecar';`.execute(db);
}

View File

@@ -72,9 +72,6 @@ export class AssetTable {
@Column() @Column()
type!: AssetType; type!: AssetType;
@Column()
originalPath!: string;
@Column({ type: 'timestamp with time zone', index: true }) @Column({ type: 'timestamp with time zone', index: true })
fileCreatedAt!: Timestamp; fileCreatedAt!: Timestamp;

View File

@@ -167,6 +167,8 @@ export class MediaService extends BaseService {
return JobStatus.Skipped; return JobStatus.Skipped;
} }
const { originalFile } = getAssetFiles(asset.files);
let generated: { let generated: {
previewPath: string; previewPath: string;
thumbnailPath: string; thumbnailPath: string;
@@ -174,11 +176,11 @@ export class MediaService extends BaseService {
thumbhash: Buffer; thumbhash: Buffer;
}; };
if (asset.type === AssetType.Video || asset.originalFileName.toLowerCase().endsWith('.gif')) { if (asset.type === AssetType.Video || asset.originalFileName.toLowerCase().endsWith('.gif')) {
this.logger.verbose(`Thumbnail generation for video ${id} ${asset.originalPath}`); this.logger.verbose(`Thumbnail generation for video ${id} ${originalFile.path}`);
generated = await this.generateVideoThumbnails(asset); generated = await this.generateVideoThumbnails(asset, originalFile.path);
} else if (asset.type === AssetType.Image) { } else if (asset.type === AssetType.Image) {
this.logger.verbose(`Thumbnail generation for image ${id} ${asset.originalPath}`); this.logger.verbose(`Thumbnail generation for image ${id} ${originalFile.path}`);
generated = await this.generateImageThumbnails(asset); generated = await this.generateImageThumbnails(asset, originalFile.path);
} else { } else {
this.logger.warn(`Skipping thumbnail generation for asset ${id}: ${asset.type} is not an image or video`); this.logger.warn(`Skipping thumbnail generation for asset ${id}: ${asset.type} is not an image or video`);
return JobStatus.Skipped; return JobStatus.Skipped;
@@ -423,13 +425,13 @@ export class MediaService extends BaseService {
}; };
} }
private async generateVideoThumbnails(asset: ThumbnailPathEntity & { originalPath: string }) { private async generateVideoThumbnails(asset: ThumbnailPathEntity, originalPath: string) {
const { image, ffmpeg } = await this.getConfig({ withCache: true }); const { image, ffmpeg } = await this.getConfig({ withCache: true });
const previewPath = StorageCore.getImagePath(asset, AssetPathType.Preview, image.preview.format); const previewPath = StorageCore.getImagePath(asset, AssetPathType.Preview, image.preview.format);
const thumbnailPath = StorageCore.getImagePath(asset, AssetPathType.Thumbnail, image.thumbnail.format); const thumbnailPath = StorageCore.getImagePath(asset, AssetPathType.Thumbnail, image.thumbnail.format);
this.storageCore.ensureFolders(previewPath); this.storageCore.ensureFolders(previewPath);
const { format, audioStreams, videoStreams } = await this.mediaRepository.probe(asset.originalPath); const { format, audioStreams, videoStreams } = await this.mediaRepository.probe(originalPath);
const mainVideoStream = this.getMainStream(videoStreams); const mainVideoStream = this.getMainStream(videoStreams);
if (!mainVideoStream) { if (!mainVideoStream) {
throw new Error(`No video streams found for asset ${asset.id}`); throw new Error(`No video streams found for asset ${asset.id}`);

View File

@@ -224,13 +224,15 @@ export class MetadataService extends BaseService {
return; return;
} }
const { originalFile } = getAssetFiles(asset.files);
const [exifTags, stats] = await Promise.all([ const [exifTags, stats] = await Promise.all([
this.getExifTags(asset), this.getExifTags(asset),
this.storageRepository.stat(asset.originalPath), this.storageRepository.stat(originalFile.path),
]); ]);
this.logger.verbose('Exif Tags', exifTags); 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); const { width, height } = this.getImageDimensions(exifTags);
let geo: ReverseGeocodeResult = { country: null, state: null, city: null }, let geo: ReverseGeocodeResult = { country: null, state: null, city: null },
@@ -301,11 +303,11 @@ export class MetadataService extends BaseService {
]; ];
if (this.isMotionPhoto(asset, exifTags)) { if (this.isMotionPhoto(asset, exifTags)) {
promises.push(this.applyMotionPhotos(asset, exifTags, dates, stats)); promises.push(this.applyMotionPhotos(asset, originalFile.path, exifTags, dates, stats));
} }
if (isFaceImportEnabled(metadata) && this.hasTaggedFaces(exifTags)) { if (isFaceImportEnabled(metadata) && this.hasTaggedFaces(exifTags)) {
promises.push(this.applyTaggedFaces(asset, exifTags)); promises.push(this.applyTaggedFaces(asset, originalFile.path, exifTags));
} }
await Promise.all(promises); await Promise.all(promises);
@@ -361,12 +363,12 @@ export class MetadataService extends BaseService {
break; break;
} }
const { sidecarFile } = getAssetFiles(asset.files); const { originalFile, sidecarFile } = getAssetFiles(asset.files);
const isChanged = sidecarPath !== sidecarFile?.path; const isChanged = sidecarPath !== sidecarFile?.path;
this.logger.debug( this.logger.debug(
`Sidecar check found old=${sidecarFile?.path}, new=${sidecarPath} will ${isChanged ? 'update' : 'do nothing for'} asset ${asset.id}: ${asset.originalPath}`, `Sidecar check found old=${sidecarFile?.path}, new=${sidecarPath} will ${isChanged ? 'update' : 'do nothing for'} asset ${asset.id}: ${originalFile.path}`,
); );
if (!isChanged) { if (!isChanged) {
@@ -400,9 +402,9 @@ export class MetadataService extends BaseService {
const tagsList = (asset.tags || []).map((tag) => tag.value); const tagsList = (asset.tags || []).map((tag) => tag.value);
const { sidecarFile } = getAssetFiles(asset.files); const { originalFile, sidecarFile } = getAssetFiles(asset.files);
const sidecarPath = sidecarFile?.path || `${asset.originalPath}.xmp`;
const sidecarPath = sidecarFile?.path || `${originalFile.path}.xmp`; // prefer file.jpg.xmp by default
const exif = _.omitBy( const exif = _.omitBy(
<Tags>{ <Tags>{
Description: description, Description: description,
@@ -429,19 +431,20 @@ export class MetadataService extends BaseService {
return JobStatus.Success; return JobStatus.Success;
} }
private getSidecarCandidates({ files, originalPath }: { files: AssetFile[]; originalPath: string }) { private getSidecarCandidates({ id, files }: { id: string; files: AssetFile[] }) {
const candidates: string[] = []; const candidates: string[] = [];
const { sidecarFile } = getAssetFiles(files); const { originalFile, sidecarFile } = getAssetFiles(files);
if (sidecarFile?.path) { if (sidecarFile?.path) {
candidates.push(sidecarFile.path); candidates.push(sidecarFile.path);
} }
const assetPath = parse(originalPath); const assetPath = parse(originalFile.path);
candidates.push( candidates.push(
// IMG_123.jpg.xmp // IMG_123.jpg.xmp
`${originalPath}.xmp`, `${assetPath}.xmp`,
// IMG_123.xmp // IMG_123.xmp
`${join(assetPath.dir, assetPath.name)}.xmp`, `${join(assetPath.dir, assetPath.name)}.xmp`,
); );
@@ -465,13 +468,13 @@ export class MetadataService extends BaseService {
return { width, height }; return { width, height };
} }
private async getExifTags(asset: { originalPath: string; files: AssetFile[]; type: AssetType }): Promise<ImmichTags> { private async getExifTags(asset: { files: AssetFile[]; type: AssetType }): Promise<ImmichTags> {
const { sidecarFile } = getAssetFiles(asset.files); const { originalFile, sidecarFile } = getAssetFiles(asset.files);
const [mediaTags, sidecarTags, videoTags] = await Promise.all([ const [mediaTags, sidecarTags, videoTags] = await Promise.all([
this.metadataRepository.readTags(asset.originalPath), this.metadataRepository.readTags(originalFile.path),
sidecarFile ? this.metadataRepository.readTags(sidecarFile.path) : null, 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 // prefer dates from sidecar tags
@@ -535,7 +538,7 @@ export class MetadataService extends BaseService {
return asset.type === AssetType.Image && !!(tags.MotionPhoto || tags.MicroVideo); 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 isMotionPhoto = tags.MotionPhoto;
const isMicroVideo = tags.MicroVideo; const isMicroVideo = tags.MicroVideo;
const videoOffset = tags.MicroVideoOffset; const videoOffset = tags.MicroVideoOffset;
@@ -566,7 +569,7 @@ export class MetadataService extends BaseService {
return; 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 { try {
const position = stats.size - length - padding; const position = stats.size - length - padding;
@@ -574,15 +577,15 @@ export class MetadataService extends BaseService {
// Samsung MotionPhoto video extraction // Samsung MotionPhoto video extraction
// HEIC-encoded // HEIC-encoded
if (hasMotionPhotoVideo) { 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 // JPEG-encoded; HEIC also contains these tags, so this conditional must come second
else if (hasEmbeddedVideoFile) { else if (hasEmbeddedVideoFile) {
video = await this.metadataRepository.extractBinaryTag(asset.originalPath, 'EmbeddedVideoFile'); video = await this.metadataRepository.extractBinaryTag(originalPath, 'EmbeddedVideoFile');
} }
// Default video extraction // Default video extraction
else { else {
video = await this.storageRepository.readFile(asset.originalPath, { video = await this.storageRepository.readFile(originalPath, {
buffer: Buffer.alloc(length), buffer: Buffer.alloc(length),
position, position,
length, length,
@@ -597,21 +600,29 @@ export class MetadataService extends BaseService {
if (!motionAsset) { if (!motionAsset) {
try { try {
const motionAssetId = this.cryptoRepository.randomUUID(); const motionAssetId = this.cryptoRepository.randomUUID();
motionAsset = await this.assetRepository.create({ motionAsset = await this.assetRepository.create(
id: motionAssetId, {
libraryId: asset.libraryId, id: motionAssetId,
type: AssetType.Video, libraryId: asset.libraryId,
fileCreatedAt: dates.dateTimeOriginal, type: AssetType.Video,
fileModifiedAt: stats.mtime, fileCreatedAt: dates.dateTimeOriginal,
localDateTime: dates.localDateTime, fileModifiedAt: stats.mtime,
checksum, localDateTime: dates.localDateTime,
ownerId: asset.ownerId, checksum,
originalPath: StorageCore.getAndroidMotionPath(asset, motionAssetId), ownerId: asset.ownerId,
originalFileName: `${parse(asset.originalFileName).name}.mp4`, originalFileName: `${parse(originalPath).name}.mp4`,
visibility: AssetVisibility.Hidden, visibility: AssetVisibility.Hidden,
deviceAssetId: 'NONE', deviceAssetId: 'NONE',
deviceId: 'NONE', deviceId: 'NONE',
}); },
[
{
type: AssetFileType.Original,
assetId: motionAssetId,
path: StorageCore.getAndroidMotionPath(asset, motionAssetId),
},
],
);
isNewMotionAsset = true; isNewMotionAsset = true;
@@ -625,16 +636,18 @@ export class MetadataService extends BaseService {
motionAsset = await this.assetRepository.getByChecksum(checksumQuery); motionAsset = await this.assetRepository.getByChecksum(checksumQuery);
if (!motionAsset) { if (!motionAsset) {
this.logger.warn(`Unable to find existing motion video asset for ${asset.id}: ${asset.originalPath}`); this.logger.warn(`Unable to find existing motion video asset for ${asset.id}: ${originalPath}`);
return; return;
} }
} }
} }
const { originalFile: originalMotionFile } = getAssetFiles(motionAsset.files);
if (!isNewMotionAsset) { if (!isNewMotionAsset) {
this.logger.debugFn(() => { this.logger.debugFn(() => {
const base64Checksum = checksum.toString('base64'); const base64Checksum = checksum.toString('base64');
return `Motion asset with checksum ${base64Checksum} already exists for asset ${asset.id}: ${asset.originalPath}`; return `Motion asset with checksum ${base64Checksum} already exists for asset ${asset.id}: ${originalPath}`;
}); });
} }
@@ -664,22 +677,18 @@ export class MetadataService extends BaseService {
} }
// write extracted motion video to disk, especially if the encoded-video folder has been deleted // write extracted motion video to disk, especially if the encoded-video folder has been deleted
const existsOnDisk = await this.storageRepository.checkFileExists(motionAsset.originalPath); const existsOnDisk = await this.storageRepository.checkFileExists(originalMotionFile.path);
if (!existsOnDisk) { if (!existsOnDisk) {
this.storageCore.ensureFolders(motionAsset.originalPath); this.storageCore.ensureFolders(originalMotionFile.path);
await this.storageRepository.createFile(motionAsset.originalPath, video); await this.storageRepository.createFile(originalMotionFile.path, video);
this.logger.log(`Wrote motion photo video to ${motionAsset.originalPath}`); this.logger.log(`Wrote motion photo video to ${originalMotionFile.path}`);
await this.handleMetadataExtraction({ id: motionAsset.id }); await this.handleMetadataExtraction({ id: motionAsset.id });
await this.jobRepository.queue({ name: JobName.AssetEncodeVideo, data: { id: motionAsset.id } }); await this.jobRepository.queue({ name: JobName.AssetEncodeVideo, data: { id: motionAsset.id } });
} }
this.logger.debug(`Finished motion photo video extraction for asset ${asset.id}: ${asset.originalPath}`); this.logger.debug(`Finished motion photo video extraction for asset ${asset.id}: ${originalPath}`);
} catch (error: Error | any) { } catch (error: Error | any) {
this.logger.error( this.logger.error(`Failed to extract motion video for ${asset.id}: ${originalPath}: ${error}`, error?.stack);
`Failed to extract motion video for ${asset.id}: ${asset.originalPath}: ${error}`,
error?.stack,
);
} }
} }
@@ -764,7 +773,8 @@ export class MetadataService extends BaseService {
} }
private async applyTaggedFaces( private async applyTaggedFaces(
asset: { id: string; ownerId: string; faces: AssetFace[]; originalPath: string }, asset: { id: string; ownerId: string; faces: AssetFace[] },
originalPath: string,
tags: ImmichTags, tags: ImmichTags,
) { ) {
if (!tags.RegionInfo?.AppliedToDimensions || tags.RegionInfo.RegionList.length === 0) { if (!tags.RegionInfo?.AppliedToDimensions || tags.RegionInfo.RegionList.length === 0) {
@@ -818,13 +828,11 @@ export class MetadataService extends BaseService {
const facesToRemove = asset.faces.filter((face) => face.sourceType === SourceType.Exif).map((face) => face.id); const facesToRemove = asset.faces.filter((face) => face.sourceType === SourceType.Exif).map((face) => face.id);
if (facesToRemove.length > 0) { if (facesToRemove.length > 0) {
this.logger.debug(`Removing ${facesToRemove.length} faces for asset ${asset.id}: ${asset.originalPath}`); this.logger.debug(`Removing ${facesToRemove.length} faces for asset ${asset.id}: ${originalPath}`);
} }
if (facesToAdd.length > 0) { if (facesToAdd.length > 0) {
this.logger.debug( this.logger.debug(`Creating ${facesToAdd.length} faces from metadata for asset ${asset.id}: ${originalPath}`);
`Creating ${facesToAdd.length} faces from metadata for asset ${asset.id}: ${asset.originalPath}`,
);
} }
if (facesToRemove.length > 0 || facesToAdd.length > 0) { if (facesToRemove.length > 0 || facesToAdd.length > 0) {
@@ -837,16 +845,15 @@ export class MetadataService extends BaseService {
} }
private getDates( private getDates(
asset: { id: string; originalPath: string; fileCreatedAt: Date }, asset: { id: string; fileCreatedAt: Date },
originalPath: string,
exifTags: ImmichTags, exifTags: ImmichTags,
stats: Stats, stats: Stats,
) { ) {
const result = firstDateTime(exifTags); const result = firstDateTime(exifTags);
const tag = result?.tag; const tag = result?.tag;
const dateTime = result?.dateTime; const dateTime = result?.dateTime;
this.logger.verbose( this.logger.verbose(`Date and time is ${dateTime} using exifTag ${tag} for asset ${asset.id}: ${originalPath}`);
`Date and time is ${dateTime} using exifTag ${tag} for asset ${asset.id}: ${asset.originalPath}`,
);
// timezone // timezone
let timeZone = exifTags.tz ?? null; let timeZone = exifTags.tz ?? null;
@@ -857,11 +864,9 @@ export class MetadataService extends BaseService {
} }
if (timeZone) { if (timeZone) {
this.logger.verbose( this.logger.verbose(`Found timezone ${timeZone} via ${exifTags.tzSource} for asset ${asset.id}: ${originalPath}`);
`Found timezone ${timeZone} via ${exifTags.tzSource} for asset ${asset.id}: ${asset.originalPath}`,
);
} else { } else {
this.logger.debug(`No timezone information found for asset ${asset.id}: ${asset.originalPath}`); this.logger.debug(`No timezone information found for asset ${asset.id}: ${originalPath}`);
} }
let dateTimeOriginal = dateTime?.toDateTime(); let dateTimeOriginal = dateTime?.toDateTime();
@@ -887,12 +892,12 @@ export class MetadataService extends BaseService {
), ),
); );
this.logger.debug( this.logger.debug(
`No exif date time found, falling back on ${earliestDate.toISO()}, earliest of file creation and modification for asset ${asset.id}: ${asset.originalPath}`, `No exif date time found, falling back on ${earliestDate.toISO()}, earliest of file creation and modification for asset ${asset.id}: ${originalPath}`,
); );
dateTimeOriginal = localDateTime = earliestDate; dateTimeOriginal = localDateTime = earliestDate;
} }
this.logger.verbose(`Found local date time ${localDateTime.toISO()} for asset ${asset.id}: ${asset.originalPath}`); this.logger.verbose(`Found local date time ${localDateTime.toISO()} for asset ${asset.id}: ${originalPath}`);
return { return {
timeZone, timeZone,

View File

@@ -19,7 +19,7 @@ import {
import { ArgOf } from 'src/repositories/event.repository'; import { ArgOf } from 'src/repositories/event.repository';
import { BaseService } from 'src/services/base.service'; import { BaseService } from 'src/services/base.service';
import { JobOf, StorageAsset } from 'src/types'; import { JobOf, StorageAsset } from 'src/types';
import { getAssetFile } from 'src/utils/asset.util'; import { getAssetFile, getAssetFiles } from 'src/utils/asset.util';
import { getLivePhotoMotionFilename } from 'src/utils/file'; import { getLivePhotoMotionFilename } from 'src/utils/file';
const storageTokens = { const storageTokens = {
@@ -147,7 +147,8 @@ export class StorageTemplateService extends BaseService {
const user = await this.userRepository.get(asset.ownerId, {}); const user = await this.userRepository.get(asset.ownerId, {});
const storageLabel = user?.storageLabel || null; const storageLabel = user?.storageLabel || null;
const filename = asset.originalFileName || asset.id; const filename = asset.originalFileName || asset.id;
await this.moveAsset(asset, { storageLabel, filename }); const { originalFile } = getAssetFiles(asset.files);
await this.moveAsset({ originalPath: originalFile.path, ...asset }, { storageLabel, filename });
// move motion part of live photo // move motion part of live photo
if (asset.livePhotoVideoId) { if (asset.livePhotoVideoId) {
@@ -155,8 +156,12 @@ export class StorageTemplateService extends BaseService {
if (!livePhotoVideo) { if (!livePhotoVideo) {
return JobStatus.Failed; return JobStatus.Failed;
} }
const motionFilename = getLivePhotoMotionFilename(filename, livePhotoVideo.originalPath); const { originalFile: livePhotoOriginalFile } = getAssetFiles(livePhotoVideo.files);
await this.moveAsset(livePhotoVideo, { storageLabel, filename: motionFilename }); const motionFilename = getLivePhotoMotionFilename(filename, livePhotoOriginalFile.path);
await this.moveAsset(
{ originalPath: livePhotoOriginalFile.path, ...livePhotoVideo },
{ storageLabel, filename: motionFilename },
);
} }
return JobStatus.Success; return JobStatus.Success;
} }
@@ -180,7 +185,8 @@ export class StorageTemplateService extends BaseService {
const user = users.find((user) => user.id === asset.ownerId); const user = users.find((user) => user.id === asset.ownerId);
const storageLabel = user?.storageLabel || null; const storageLabel = user?.storageLabel || null;
const filename = asset.originalFileName || asset.id; const filename = asset.originalFileName || asset.id;
await this.moveAsset(asset, { storageLabel, filename }); const { originalFile } = getAssetFiles(asset.files);
await this.moveAsset({ originalPath: originalFile.path, ...asset }, { storageLabel, filename });
} }
this.logger.debug('Cleaning up empty directories...'); this.logger.debug('Cleaning up empty directories...');

View File

@@ -18,6 +18,13 @@ export const getAssetFile = (files: AssetFile[], type: AssetFileType | Generated
}; };
export const getAssetFiles = (files: AssetFile[]) => ({ export const getAssetFiles = (files: AssetFile[]) => ({
originalFile: (() => {
const file = getAssetFile(files, AssetFileType.Original);
if (!file?.path) {
throw new BadRequestException(`Asset has no original file`); // TODO: should we throw a specific error here that can be caught higher up?
}
return file;
})(),
fullsizeFile: getAssetFile(files, AssetFileType.FullSize), fullsizeFile: getAssetFile(files, AssetFileType.FullSize),
previewFile: getAssetFile(files, AssetFileType.Preview), previewFile: getAssetFile(files, AssetFileType.Preview),
thumbnailFile: getAssetFile(files, AssetFileType.Thumbnail), thumbnailFile: getAssetFile(files, AssetFileType.Thumbnail),
@@ -161,12 +168,18 @@ export const onBeforeUnlink = async (
{ asset: assetRepository }: AssetHookRepositories, { asset: assetRepository }: AssetHookRepositories,
{ livePhotoVideoId }: { livePhotoVideoId: string }, { livePhotoVideoId }: { livePhotoVideoId: string },
) => { ) => {
const motion = await assetRepository.getById(livePhotoVideoId); const motion = await assetRepository.getById(livePhotoVideoId, { files: true });
if (!motion) { if (!motion) {
return null; 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'); throw new BadRequestException('Cannot unlink Android motion photos');
} }

View File

@@ -191,13 +191,23 @@ export function withFaces(eb: ExpressionBuilder<DB, 'asset'>, withDeletedFace?:
} }
export function withFiles(eb: ExpressionBuilder<DB, 'asset'>, type?: AssetFileType) { export function withFiles(eb: ExpressionBuilder<DB, 'asset'>, type?: AssetFileType) {
return jsonArrayFrom( const files = jsonArrayFrom(
eb eb
.selectFrom('asset_file') .selectFrom('asset_file')
.select(columns.assetFiles) .select(columns.assetFiles)
.whereRef('asset_file.assetId', '=', 'asset.id') .whereRef('asset_file.assetId', '=', 'asset.id')
.$if(!!type, (qb) => qb.where('asset_file.type', '=', type!)), .$if(!!type, (qb) => qb.where('asset_file.type', '=', type!)),
).as('files'); ).as('files');
return files;
}
export function withSidecars(eb: ExpressionBuilder<DB, 'asset'>, type?: AssetFileType) {
return withFiles(eb, AssetFileType.Sidecar);
}
export function withOriginals(eb: ExpressionBuilder<DB, 'asset'>, type?: AssetFileType) {
return withFiles(eb, AssetFileType.Original);
} }
export function withFilePath(eb: ExpressionBuilder<DB, 'asset'>, type: AssetFileType) { export function withFilePath(eb: ExpressionBuilder<DB, 'asset'>, type: AssetFileType) {
@@ -208,6 +218,10 @@ export function withFilePath(eb: ExpressionBuilder<DB, 'asset'>, type: AssetFile
.where('asset_file.type', '=', type); .where('asset_file.type', '=', type);
} }
export function withOriginalPath(eb: ExpressionBuilder<DB, 'asset'>) {
return withFilePath(eb, AssetFileType.Original).as('originalPath');
}
export function withFacesAndPeople(eb: ExpressionBuilder<DB, 'asset'>, withDeletedFace?: boolean) { export function withFacesAndPeople(eb: ExpressionBuilder<DB, 'asset'>, withDeletedFace?: boolean) {
return jsonArrayFrom( return jsonArrayFrom(
eb eb