mirror of
https://github.com/immich-app/immich.git
synced 2025-12-16 09:13:13 +03:00
Compare commits
15 Commits
fix/all-pe
...
chore/orig
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
173904e387 | ||
|
|
42854cad56 | ||
|
|
93ec8b7ecf | ||
|
|
bf1d409be1 | ||
|
|
857816bccc | ||
|
|
e19467eddd | ||
|
|
0cb96837d0 | ||
|
|
cbdfe08344 | ||
|
|
6229f9feb4 | ||
|
|
20f5a14d03 | ||
|
|
7ade8ad69a | ||
|
|
6b91b31dbc | ||
|
|
65f63be564 | ||
|
|
430af9a145 | ||
|
|
f3b0e8a5e6 |
@@ -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 });
|
||||||
|
|||||||
@@ -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',
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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}`);
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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...');
|
||||||
|
|||||||
@@ -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');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user