feat: move sidecars to asset_files
This commit is contained in:
@@ -1006,7 +1006,7 @@ describe('/libraries', () => {
|
|||||||
rmSync(`${testAssetDir}/temp/xmp`, { recursive: true, force: true });
|
rmSync(`${testAssetDir}/temp/xmp`, { recursive: true, force: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should switch from using file metadata to file.xmp metadata when asset refreshes', async () => {
|
it('should switch from using file metadata to file.ext.xmp metadata when asset refreshes', async () => {
|
||||||
const library = await utils.createLibrary(admin.accessToken, {
|
const library = await utils.createLibrary(admin.accessToken, {
|
||||||
ownerId: admin.userId,
|
ownerId: admin.userId,
|
||||||
importPaths: [`${testAssetDirInternal}/temp/xmp`],
|
importPaths: [`${testAssetDirInternal}/temp/xmp`],
|
||||||
|
|||||||
@@ -303,7 +303,7 @@ export class StorageCore {
|
|||||||
return this.assetRepository.update({ id, encodedVideoPath: newPath });
|
return this.assetRepository.update({ id, encodedVideoPath: newPath });
|
||||||
}
|
}
|
||||||
case AssetPathType.Sidecar: {
|
case AssetPathType.Sidecar: {
|
||||||
return this.assetRepository.update({ id, sidecarPath: newPath });
|
return this.assetRepository.upsertFile({ assetId: id, type: AssetFileType.Sidecar, path: newPath });
|
||||||
}
|
}
|
||||||
case PersonPathType.Face: {
|
case PersonPathType.Face: {
|
||||||
return this.personRepository.update({ id, thumbnailPath: newPath });
|
return this.personRepository.update({ id, thumbnailPath: newPath });
|
||||||
|
|||||||
@@ -117,7 +117,6 @@ export type Asset = {
|
|||||||
originalFileName: string;
|
originalFileName: string;
|
||||||
originalPath: string;
|
originalPath: string;
|
||||||
ownerId: string;
|
ownerId: string;
|
||||||
sidecarPath: string | null;
|
|
||||||
type: AssetType;
|
type: AssetType;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -302,7 +301,6 @@ export const columns = {
|
|||||||
'asset.originalFileName',
|
'asset.originalFileName',
|
||||||
'asset.originalPath',
|
'asset.originalPath',
|
||||||
'asset.ownerId',
|
'asset.ownerId',
|
||||||
'asset.sidecarPath',
|
|
||||||
'asset.type',
|
'asset.type',
|
||||||
],
|
],
|
||||||
assetFiles: ['asset_file.id', 'asset_file.path', 'asset_file.type'],
|
assetFiles: ['asset_file.id', 'asset_file.path', 'asset_file.type'],
|
||||||
|
|||||||
@@ -117,7 +117,6 @@ export type MapAsset = {
|
|||||||
originalPath: string;
|
originalPath: string;
|
||||||
owner?: User | null;
|
owner?: User | null;
|
||||||
ownerId: string;
|
ownerId: string;
|
||||||
sidecarPath: string | null;
|
|
||||||
stack?: Stack | null;
|
stack?: Stack | null;
|
||||||
stackId: string | null;
|
stackId: string | null;
|
||||||
tags?: Tag[];
|
tags?: Tag[];
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ export enum AssetFileType {
|
|||||||
FullSize = 'fullsize',
|
FullSize = 'fullsize',
|
||||||
Preview = 'preview',
|
Preview = 'preview',
|
||||||
Thumbnail = 'thumbnail',
|
Thumbnail = 'thumbnail',
|
||||||
|
Sidecar = 'sidecar',
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum AlbumUserRole {
|
export enum AlbumUserRole {
|
||||||
|
|||||||
@@ -20,7 +20,6 @@ limit
|
|||||||
-- AssetJobRepository.getForSidecarWriteJob
|
-- AssetJobRepository.getForSidecarWriteJob
|
||||||
select
|
select
|
||||||
"id",
|
"id",
|
||||||
"sidecarPath",
|
|
||||||
"originalPath",
|
"originalPath",
|
||||||
(
|
(
|
||||||
select
|
select
|
||||||
@@ -35,13 +34,29 @@ select
|
|||||||
where
|
where
|
||||||
"asset"."id" = "tag_asset"."assetsId"
|
"asset"."id" = "tag_asset"."assetsId"
|
||||||
) as agg
|
) as agg
|
||||||
) as "tags"
|
) as "tags",
|
||||||
|
(
|
||||||
|
select
|
||||||
|
coalesce(json_agg(agg), '[]')
|
||||||
|
from
|
||||||
|
(
|
||||||
|
select
|
||||||
|
"asset_file"."id",
|
||||||
|
"asset_file"."path",
|
||||||
|
"asset_file"."type"
|
||||||
|
from
|
||||||
|
"asset_file"
|
||||||
|
where
|
||||||
|
"asset_file"."assetId" = "asset"."id"
|
||||||
|
and "asset_file"."type" = $1
|
||||||
|
) as agg
|
||||||
|
) as "files"
|
||||||
from
|
from
|
||||||
"asset"
|
"asset"
|
||||||
where
|
where
|
||||||
"asset"."id" = $1::uuid
|
"asset"."id" = $2::uuid
|
||||||
limit
|
limit
|
||||||
$2
|
$3
|
||||||
|
|
||||||
-- AssetJobRepository.streamForThumbnailJob
|
-- AssetJobRepository.streamForThumbnailJob
|
||||||
select
|
select
|
||||||
@@ -146,7 +161,6 @@ select
|
|||||||
"asset"."originalFileName",
|
"asset"."originalFileName",
|
||||||
"asset"."originalPath",
|
"asset"."originalPath",
|
||||||
"asset"."ownerId",
|
"asset"."ownerId",
|
||||||
"asset"."sidecarPath",
|
|
||||||
"asset"."type",
|
"asset"."type",
|
||||||
(
|
(
|
||||||
select
|
select
|
||||||
@@ -161,11 +175,27 @@ select
|
|||||||
"asset_face"."assetId" = "asset"."id"
|
"asset_face"."assetId" = "asset"."id"
|
||||||
and "asset_face"."deletedAt" is null
|
and "asset_face"."deletedAt" is null
|
||||||
) as agg
|
) as agg
|
||||||
) as "faces"
|
) as "faces",
|
||||||
|
(
|
||||||
|
select
|
||||||
|
coalesce(json_agg(agg), '[]')
|
||||||
|
from
|
||||||
|
(
|
||||||
|
select
|
||||||
|
"asset_file"."id",
|
||||||
|
"asset_file"."path",
|
||||||
|
"asset_file"."type"
|
||||||
|
from
|
||||||
|
"asset_file"
|
||||||
|
where
|
||||||
|
"asset_file"."assetId" = "asset"."id"
|
||||||
|
and "asset_file"."type" = $1
|
||||||
|
) as agg
|
||||||
|
) as "files"
|
||||||
from
|
from
|
||||||
"asset"
|
"asset"
|
||||||
where
|
where
|
||||||
"asset"."id" = $1
|
"asset"."id" = $2
|
||||||
|
|
||||||
-- AssetJobRepository.getAlbumThumbnailFiles
|
-- AssetJobRepository.getAlbumThumbnailFiles
|
||||||
select
|
select
|
||||||
@@ -293,7 +323,6 @@ select
|
|||||||
"asset"."libraryId",
|
"asset"."libraryId",
|
||||||
"asset"."ownerId",
|
"asset"."ownerId",
|
||||||
"asset"."livePhotoVideoId",
|
"asset"."livePhotoVideoId",
|
||||||
"asset"."sidecarPath",
|
|
||||||
"asset"."encodedVideoPath",
|
"asset"."encodedVideoPath",
|
||||||
"asset"."originalPath",
|
"asset"."originalPath",
|
||||||
to_json("asset_exif") as "exifInfo",
|
to_json("asset_exif") as "exifInfo",
|
||||||
@@ -404,18 +433,28 @@ select
|
|||||||
"asset"."checksum",
|
"asset"."checksum",
|
||||||
"asset"."originalPath",
|
"asset"."originalPath",
|
||||||
"asset"."isExternal",
|
"asset"."isExternal",
|
||||||
"asset"."sidecarPath",
|
|
||||||
"asset"."originalFileName",
|
"asset"."originalFileName",
|
||||||
"asset"."livePhotoVideoId",
|
"asset"."livePhotoVideoId",
|
||||||
"asset"."fileCreatedAt",
|
"asset"."fileCreatedAt",
|
||||||
"asset_exif"."timeZone",
|
"asset_exif"."timeZone",
|
||||||
"asset_exif"."fileSizeInByte"
|
"asset_exif"."fileSizeInByte",
|
||||||
|
(
|
||||||
|
select
|
||||||
|
"asset_file"."path"
|
||||||
|
from
|
||||||
|
"asset_file"
|
||||||
|
where
|
||||||
|
"asset_file"."assetId" = "asset"."id"
|
||||||
|
and "asset_file"."type" = $1
|
||||||
|
limit
|
||||||
|
$2
|
||||||
|
) as "sidecarPath"
|
||||||
from
|
from
|
||||||
"asset"
|
"asset"
|
||||||
inner join "asset_exif" on "asset"."id" = "asset_exif"."assetId"
|
inner join "asset_exif" on "asset"."id" = "asset_exif"."assetId"
|
||||||
where
|
where
|
||||||
"asset"."deletedAt" is null
|
"asset"."deletedAt" is null
|
||||||
and "asset"."id" = $1
|
and "asset"."id" = $3
|
||||||
|
|
||||||
-- AssetJobRepository.streamForStorageTemplateJob
|
-- AssetJobRepository.streamForStorageTemplateJob
|
||||||
select
|
select
|
||||||
@@ -425,12 +464,22 @@ select
|
|||||||
"asset"."checksum",
|
"asset"."checksum",
|
||||||
"asset"."originalPath",
|
"asset"."originalPath",
|
||||||
"asset"."isExternal",
|
"asset"."isExternal",
|
||||||
"asset"."sidecarPath",
|
|
||||||
"asset"."originalFileName",
|
"asset"."originalFileName",
|
||||||
"asset"."livePhotoVideoId",
|
"asset"."livePhotoVideoId",
|
||||||
"asset"."fileCreatedAt",
|
"asset"."fileCreatedAt",
|
||||||
"asset_exif"."timeZone",
|
"asset_exif"."timeZone",
|
||||||
"asset_exif"."fileSizeInByte"
|
"asset_exif"."fileSizeInByte",
|
||||||
|
(
|
||||||
|
select
|
||||||
|
"asset_file"."path"
|
||||||
|
from
|
||||||
|
"asset_file"
|
||||||
|
where
|
||||||
|
"asset_file"."assetId" = "asset"."id"
|
||||||
|
and "asset_file"."type" = $1
|
||||||
|
limit
|
||||||
|
$2
|
||||||
|
) as "sidecarPath"
|
||||||
from
|
from
|
||||||
"asset"
|
"asset"
|
||||||
inner join "asset_exif" on "asset"."id" = "asset_exif"."assetId"
|
inner join "asset_exif" on "asset"."id" = "asset_exif"."assetId"
|
||||||
@@ -452,11 +501,15 @@ select
|
|||||||
from
|
from
|
||||||
"asset"
|
"asset"
|
||||||
where
|
where
|
||||||
(
|
not exists (
|
||||||
"asset"."sidecarPath" = $1
|
select
|
||||||
or "asset"."sidecarPath" is null
|
"asset_file"."id"
|
||||||
|
from
|
||||||
|
"asset_file"
|
||||||
|
where
|
||||||
|
"asset_file"."assetId" = "asset"."id"
|
||||||
|
and "asset_file"."type" = $1
|
||||||
)
|
)
|
||||||
and "asset"."visibility" != $2
|
|
||||||
|
|
||||||
-- AssetJobRepository.streamForDetectFacesJob
|
-- AssetJobRepository.streamForDetectFacesJob
|
||||||
select
|
select
|
||||||
|
|||||||
@@ -41,7 +41,6 @@ export class AssetJobRepository {
|
|||||||
.where('asset.id', '=', asUuid(id))
|
.where('asset.id', '=', asUuid(id))
|
||||||
.select((eb) => [
|
.select((eb) => [
|
||||||
'id',
|
'id',
|
||||||
'sidecarPath',
|
|
||||||
'originalPath',
|
'originalPath',
|
||||||
jsonArrayFrom(
|
jsonArrayFrom(
|
||||||
eb
|
eb
|
||||||
@@ -51,6 +50,7 @@ export class AssetJobRepository {
|
|||||||
.whereRef('asset.id', '=', 'tag_asset.assetsId'),
|
.whereRef('asset.id', '=', 'tag_asset.assetsId'),
|
||||||
).as('tags'),
|
).as('tags'),
|
||||||
])
|
])
|
||||||
|
.select((eb) => withFiles(eb, AssetFileType.Sidecar))
|
||||||
.limit(1)
|
.limit(1)
|
||||||
.executeTakeFirst();
|
.executeTakeFirst();
|
||||||
}
|
}
|
||||||
@@ -113,6 +113,7 @@ export class AssetJobRepository {
|
|||||||
.selectFrom('asset')
|
.selectFrom('asset')
|
||||||
.select(columns.asset)
|
.select(columns.asset)
|
||||||
.select(withFaces)
|
.select(withFaces)
|
||||||
|
.select((eb) => withFiles(eb, AssetFileType.Sidecar))
|
||||||
.where('asset.id', '=', id)
|
.where('asset.id', '=', id)
|
||||||
.executeTakeFirst();
|
.executeTakeFirst();
|
||||||
}
|
}
|
||||||
@@ -210,7 +211,6 @@ export class AssetJobRepository {
|
|||||||
'asset.libraryId',
|
'asset.libraryId',
|
||||||
'asset.ownerId',
|
'asset.ownerId',
|
||||||
'asset.livePhotoVideoId',
|
'asset.livePhotoVideoId',
|
||||||
'asset.sidecarPath',
|
|
||||||
'asset.encodedVideoPath',
|
'asset.encodedVideoPath',
|
||||||
'asset.originalPath',
|
'asset.originalPath',
|
||||||
])
|
])
|
||||||
@@ -288,12 +288,19 @@ export class AssetJobRepository {
|
|||||||
'asset.checksum',
|
'asset.checksum',
|
||||||
'asset.originalPath',
|
'asset.originalPath',
|
||||||
'asset.isExternal',
|
'asset.isExternal',
|
||||||
'asset.sidecarPath',
|
|
||||||
'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'),
|
||||||
])
|
])
|
||||||
.where('asset.deletedAt', 'is', null);
|
.where('asset.deletedAt', 'is', null);
|
||||||
}
|
}
|
||||||
@@ -325,9 +332,18 @@ export class AssetJobRepository {
|
|||||||
.selectFrom('asset')
|
.selectFrom('asset')
|
||||||
.select(['asset.id'])
|
.select(['asset.id'])
|
||||||
.$if(!force, (qb) =>
|
.$if(!force, (qb) =>
|
||||||
qb.where((eb) => eb.or([eb('asset.sidecarPath', '=', ''), eb('asset.sidecarPath', 'is', null)])),
|
qb.where((eb) =>
|
||||||
|
eb.not(
|
||||||
|
eb.exists(
|
||||||
|
eb
|
||||||
|
.selectFrom('asset_file')
|
||||||
|
.select('asset_file.id')
|
||||||
|
.whereRef('asset_file.assetId', '=', 'asset.id')
|
||||||
|
.where('asset_file.type', '=', AssetFileType.Sidecar),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
.where('asset.visibility', '!=', AssetVisibility.Hidden)
|
|
||||||
.stream();
|
.stream();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -794,6 +794,14 @@ export class AssetRepository {
|
|||||||
.execute();
|
.execute();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async deleteFile(file: Pick<Selectable<AssetFileTable>, 'assetId' | 'type'>): Promise<void> {
|
||||||
|
await this.db
|
||||||
|
.deleteFrom('asset_file')
|
||||||
|
.where('assetId', '=', asUuid(file.assetId))
|
||||||
|
.where('type', '=', file.type)
|
||||||
|
.execute();
|
||||||
|
}
|
||||||
|
|
||||||
async deleteFiles(files: Pick<Selectable<AssetFileTable>, 'id'>[]): Promise<void> {
|
async deleteFiles(files: Pick<Selectable<AssetFileTable>, 'id'>[]): Promise<void> {
|
||||||
if (files.length === 0) {
|
if (files.length === 0) {
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -414,7 +414,6 @@ export class DatabaseRepository {
|
|||||||
.set((eb) => ({
|
.set((eb) => ({
|
||||||
originalPath: eb.fn('REGEXP_REPLACE', ['originalPath', source, target]),
|
originalPath: eb.fn('REGEXP_REPLACE', ['originalPath', source, target]),
|
||||||
encodedVideoPath: eb.fn('REGEXP_REPLACE', ['encodedVideoPath', source, target]),
|
encodedVideoPath: eb.fn('REGEXP_REPLACE', ['encodedVideoPath', source, target]),
|
||||||
sidecarPath: eb.fn('REGEXP_REPLACE', ['sidecarPath', source, target]),
|
|
||||||
}))
|
}))
|
||||||
.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";
|
||||||
|
`.execute(db);
|
||||||
|
|
||||||
|
await sql`DELETE FROM asset_file WHERE type = 'sidecar';`.execute(db);
|
||||||
|
}
|
||||||
@@ -105,9 +105,6 @@ export class AssetTable {
|
|||||||
@Column({ index: true })
|
@Column({ index: true })
|
||||||
originalFileName!: string;
|
originalFileName!: string;
|
||||||
|
|
||||||
@Column({ nullable: true })
|
|
||||||
sidecarPath!: string | null;
|
|
||||||
|
|
||||||
@Column({ type: 'bytea', nullable: true })
|
@Column({ type: 'bytea', nullable: true })
|
||||||
thumbhash!: Buffer | null;
|
thumbhash!: Buffer | null;
|
||||||
|
|
||||||
|
|||||||
@@ -171,7 +171,6 @@ const assetEntity = Object.freeze({
|
|||||||
longitude: 10.703_075,
|
longitude: 10.703_075,
|
||||||
},
|
},
|
||||||
livePhotoVideoId: null,
|
livePhotoVideoId: null,
|
||||||
sidecarPath: null,
|
|
||||||
} as MapAsset);
|
} as MapAsset);
|
||||||
|
|
||||||
const existingAsset = Object.freeze({
|
const existingAsset = Object.freeze({
|
||||||
@@ -709,18 +708,22 @@ describe(AssetMediaService.name, () => {
|
|||||||
expect(mocks.asset.update).toHaveBeenCalledWith(
|
expect(mocks.asset.update).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
id: existingAsset.id,
|
id: existingAsset.id,
|
||||||
sidecarPath: null,
|
|
||||||
originalFileName: 'photo1.jpeg',
|
originalFileName: 'photo1.jpeg',
|
||||||
originalPath: 'fake_path/photo1.jpeg',
|
originalPath: 'fake_path/photo1.jpeg',
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
expect(mocks.asset.create).toHaveBeenCalledWith(
|
expect(mocks.asset.create).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
sidecarPath: null,
|
|
||||||
originalFileName: 'existing-filename.jpeg',
|
originalFileName: 'existing-filename.jpeg',
|
||||||
originalPath: 'fake_path/asset_1.jpeg',
|
originalPath: 'fake_path/asset_1.jpeg',
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
expect(mocks.asset.deleteFile).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
assetId: existingAsset.id,
|
||||||
|
type: AssetFileType.Sidecar,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
expect(mocks.asset.updateAll).toHaveBeenCalledWith([copiedAsset.id], {
|
expect(mocks.asset.updateAll).toHaveBeenCalledWith([copiedAsset.id], {
|
||||||
deletedAt: expect.any(Date),
|
deletedAt: expect.any(Date),
|
||||||
@@ -757,6 +760,13 @@ describe(AssetMediaService.name, () => {
|
|||||||
deletedAt: expect.any(Date),
|
deletedAt: expect.any(Date),
|
||||||
status: AssetStatus.Trashed,
|
status: AssetStatus.Trashed,
|
||||||
});
|
});
|
||||||
|
expect(mocks.asset.upsertFile).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
assetId: existingAsset.id,
|
||||||
|
path: sidecarFile.originalPath,
|
||||||
|
type: AssetFileType.Sidecar,
|
||||||
|
}),
|
||||||
|
);
|
||||||
expect(mocks.user.updateUsage).toHaveBeenCalledWith(authStub.user1.user.id, updatedFile.size);
|
expect(mocks.user.updateUsage).toHaveBeenCalledWith(authStub.user1.user.id, updatedFile.size);
|
||||||
expect(mocks.storage.utimes).toHaveBeenCalledWith(
|
expect(mocks.storage.utimes).toHaveBeenCalledWith(
|
||||||
updatedFile.originalPath,
|
updatedFile.originalPath,
|
||||||
@@ -786,6 +796,12 @@ describe(AssetMediaService.name, () => {
|
|||||||
deletedAt: expect.any(Date),
|
deletedAt: expect.any(Date),
|
||||||
status: AssetStatus.Trashed,
|
status: AssetStatus.Trashed,
|
||||||
});
|
});
|
||||||
|
expect(mocks.asset.deleteFile).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
assetId: existingAsset.id,
|
||||||
|
type: AssetFileType.Sidecar,
|
||||||
|
}),
|
||||||
|
);
|
||||||
expect(mocks.user.updateUsage).toHaveBeenCalledWith(authStub.user1.user.id, updatedFile.size);
|
expect(mocks.user.updateUsage).toHaveBeenCalledWith(authStub.user1.user.id, updatedFile.size);
|
||||||
expect(mocks.storage.utimes).toHaveBeenCalledWith(
|
expect(mocks.storage.utimes).toHaveBeenCalledWith(
|
||||||
updatedFile.originalPath,
|
updatedFile.originalPath,
|
||||||
@@ -815,6 +831,9 @@ describe(AssetMediaService.name, () => {
|
|||||||
|
|
||||||
expect(mocks.asset.create).not.toHaveBeenCalled();
|
expect(mocks.asset.create).not.toHaveBeenCalled();
|
||||||
expect(mocks.asset.updateAll).not.toHaveBeenCalled();
|
expect(mocks.asset.updateAll).not.toHaveBeenCalled();
|
||||||
|
expect(mocks.asset.upsertFile).not.toHaveBeenCalled();
|
||||||
|
expect(mocks.asset.deleteFile).not.toHaveBeenCalled();
|
||||||
|
|
||||||
expect(mocks.job.queue).toHaveBeenCalledWith({
|
expect(mocks.job.queue).toHaveBeenCalledWith({
|
||||||
name: JobName.FileDelete,
|
name: JobName.FileDelete,
|
||||||
data: { files: [updatedFile.originalPath, undefined] },
|
data: { files: [updatedFile.originalPath, undefined] },
|
||||||
|
|||||||
@@ -21,7 +21,16 @@ import {
|
|||||||
UploadFieldName,
|
UploadFieldName,
|
||||||
} from 'src/dtos/asset-media.dto';
|
} from 'src/dtos/asset-media.dto';
|
||||||
import { AuthDto } from 'src/dtos/auth.dto';
|
import { AuthDto } from 'src/dtos/auth.dto';
|
||||||
import { AssetStatus, AssetType, AssetVisibility, CacheControl, JobName, Permission, StorageFolder } from 'src/enum';
|
import {
|
||||||
|
AssetFileType,
|
||||||
|
AssetStatus,
|
||||||
|
AssetType,
|
||||||
|
AssetVisibility,
|
||||||
|
CacheControl,
|
||||||
|
JobName,
|
||||||
|
Permission,
|
||||||
|
StorageFolder,
|
||||||
|
} from 'src/enum';
|
||||||
import { AuthRequest } from 'src/middleware/auth.guard';
|
import { AuthRequest } from 'src/middleware/auth.guard';
|
||||||
import { BaseService } from 'src/services/base.service';
|
import { BaseService } from 'src/services/base.service';
|
||||||
import { UploadFile } from 'src/types';
|
import { UploadFile } from 'src/types';
|
||||||
@@ -360,9 +369,12 @@ export class AssetMediaService extends BaseService {
|
|||||||
duration: dto.duration || null,
|
duration: dto.duration || null,
|
||||||
|
|
||||||
livePhotoVideoId: null,
|
livePhotoVideoId: null,
|
||||||
sidecarPath: sidecarPath || null,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await (sidecarPath
|
||||||
|
? this.assetRepository.upsertFile({ assetId, path: sidecarPath, type: AssetFileType.Sidecar })
|
||||||
|
: this.assetRepository.deleteFile({ assetId, type: AssetFileType.Sidecar }));
|
||||||
|
|
||||||
await this.storageRepository.utimes(file.originalPath, new Date(), new Date(dto.fileModifiedAt));
|
await this.storageRepository.utimes(file.originalPath, new Date(), new Date(dto.fileModifiedAt));
|
||||||
await this.assetRepository.upsertExif({ assetId, fileSizeInByte: file.size });
|
await this.assetRepository.upsertExif({ assetId, fileSizeInByte: file.size });
|
||||||
await this.jobRepository.queue({
|
await this.jobRepository.queue({
|
||||||
@@ -390,7 +402,6 @@ export class AssetMediaService extends BaseService {
|
|||||||
localDateTime: asset.localDateTime,
|
localDateTime: asset.localDateTime,
|
||||||
fileModifiedAt: asset.fileModifiedAt,
|
fileModifiedAt: asset.fileModifiedAt,
|
||||||
livePhotoVideoId: asset.livePhotoVideoId,
|
livePhotoVideoId: asset.livePhotoVideoId,
|
||||||
sidecarPath: asset.sidecarPath,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const { size } = await this.storageRepository.stat(created.originalPath);
|
const { size } = await this.storageRepository.stat(created.originalPath);
|
||||||
@@ -420,10 +431,14 @@ export class AssetMediaService extends BaseService {
|
|||||||
visibility: dto.visibility ?? AssetVisibility.Timeline,
|
visibility: dto.visibility ?? AssetVisibility.Timeline,
|
||||||
livePhotoVideoId: dto.livePhotoVideoId,
|
livePhotoVideoId: dto.livePhotoVideoId,
|
||||||
originalFileName: dto.filename || file.originalName,
|
originalFileName: dto.filename || file.originalName,
|
||||||
sidecarPath: sidecarFile?.originalPath,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (sidecarFile) {
|
if (sidecarFile) {
|
||||||
|
await this.assetRepository.upsertFile({
|
||||||
|
assetId: asset.id,
|
||||||
|
path: sidecarFile.originalPath,
|
||||||
|
type: AssetFileType.Sidecar,
|
||||||
|
});
|
||||||
await this.storageRepository.utimes(sidecarFile.originalPath, new Date(), new Date(dto.fileModifiedAt));
|
await this.storageRepository.utimes(sidecarFile.originalPath, new Date(), new Date(dto.fileModifiedAt));
|
||||||
}
|
}
|
||||||
await this.storageRepository.utimes(file.originalPath, new Date(), new Date(dto.fileModifiedAt));
|
await this.storageRepository.utimes(file.originalPath, new Date(), new Date(dto.fileModifiedAt));
|
||||||
|
|||||||
@@ -585,8 +585,8 @@ describe(AssetService.name, () => {
|
|||||||
'/uploads/user-id/webp/path.ext',
|
'/uploads/user-id/webp/path.ext',
|
||||||
'/uploads/user-id/thumbs/path.jpg',
|
'/uploads/user-id/thumbs/path.jpg',
|
||||||
'/uploads/user-id/fullsize/path.webp',
|
'/uploads/user-id/fullsize/path.webp',
|
||||||
assetWithFace.encodedVideoPath,
|
assetWithFace.encodedVideoPath, // this value is null
|
||||||
assetWithFace.sidecarPath,
|
undefined, // no sidecar path
|
||||||
assetWithFace.originalPath,
|
assetWithFace.originalPath,
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -247,11 +247,11 @@ export class AssetService extends BaseService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const { fullsizeFile, previewFile, thumbnailFile } = getAssetFiles(asset.files ?? []);
|
const { fullsizeFile, previewFile, thumbnailFile, sidecarFile } = getAssetFiles(asset.files ?? []);
|
||||||
const files = [thumbnailFile?.path, previewFile?.path, fullsizeFile?.path, asset.encodedVideoPath];
|
const files = [thumbnailFile?.path, previewFile?.path, fullsizeFile?.path, asset.encodedVideoPath];
|
||||||
|
|
||||||
if (deleteOnDisk) {
|
if (deleteOnDisk) {
|
||||||
files.push(asset.sidecarPath, asset.originalPath);
|
files.push(sidecarFile?.path, asset.originalPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.jobRepository.queue({ name: JobName.FileDelete, data: { files } });
|
await this.jobRepository.queue({ name: JobName.FileDelete, data: { files } });
|
||||||
|
|||||||
@@ -4,7 +4,16 @@ import { Stats } from 'node:fs';
|
|||||||
import { constants } from 'node:fs/promises';
|
import { constants } from 'node:fs/promises';
|
||||||
import { defaults } from 'src/config';
|
import { defaults } from 'src/config';
|
||||||
import { MapAsset } from 'src/dtos/asset-response.dto';
|
import { MapAsset } from 'src/dtos/asset-response.dto';
|
||||||
import { AssetType, AssetVisibility, ExifOrientation, ImmichWorker, JobName, JobStatus, SourceType } from 'src/enum';
|
import {
|
||||||
|
AssetFileType,
|
||||||
|
AssetType,
|
||||||
|
AssetVisibility,
|
||||||
|
ExifOrientation,
|
||||||
|
ImmichWorker,
|
||||||
|
JobName,
|
||||||
|
JobStatus,
|
||||||
|
SourceType,
|
||||||
|
} from 'src/enum';
|
||||||
import { ImmichTags } from 'src/repositories/metadata.repository';
|
import { ImmichTags } from 'src/repositories/metadata.repository';
|
||||||
import { firstDateTime, MetadataService } from 'src/services/metadata.service';
|
import { firstDateTime, MetadataService } from 'src/services/metadata.service';
|
||||||
import { assetStub } from 'test/fixtures/asset.stub';
|
import { assetStub } from 'test/fixtures/asset.stub';
|
||||||
@@ -35,6 +44,13 @@ const makeFaceTags = (face: Partial<{ Name: string }> = {}, orientation?: Immich
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function removeNonSidecarFiles(asset: any) {
|
||||||
|
return {
|
||||||
|
...asset,
|
||||||
|
files: asset.files.filter((file: any) => file.type === AssetFileType.Sidecar),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
describe(MetadataService.name, () => {
|
describe(MetadataService.name, () => {
|
||||||
let sut: MetadataService;
|
let sut: MetadataService;
|
||||||
let mocks: ServiceMocks;
|
let mocks: ServiceMocks;
|
||||||
@@ -151,7 +167,7 @@ describe(MetadataService.name, () => {
|
|||||||
it('should handle a date in a sidecar file', async () => {
|
it('should handle a date in a sidecar file', async () => {
|
||||||
const originalDate = new Date('2023-11-21T16:13:17.517Z');
|
const originalDate = new Date('2023-11-21T16:13:17.517Z');
|
||||||
const sidecarDate = new Date('2022-01-01T00:00:00.000Z');
|
const sidecarDate = new Date('2022-01-01T00:00:00.000Z');
|
||||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(assetStub.sidecar);
|
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(removeNonSidecarFiles(assetStub.sidecar));
|
||||||
mockReadTags({ CreationDate: originalDate.toISOString() }, { CreationDate: sidecarDate.toISOString() });
|
mockReadTags({ CreationDate: originalDate.toISOString() }, { CreationDate: sidecarDate.toISOString() });
|
||||||
|
|
||||||
await sut.handleMetadataExtraction({ id: assetStub.image.id });
|
await sut.handleMetadataExtraction({ id: assetStub.image.id });
|
||||||
@@ -170,7 +186,7 @@ describe(MetadataService.name, () => {
|
|||||||
it('should take the file modification date when missing exif and earlier than creation date', async () => {
|
it('should take the file modification date when missing exif and earlier than creation date', async () => {
|
||||||
const fileCreatedAt = new Date('2022-01-01T00:00:00.000Z');
|
const fileCreatedAt = new Date('2022-01-01T00:00:00.000Z');
|
||||||
const fileModifiedAt = new Date('2021-01-01T00:00:00.000Z');
|
const fileModifiedAt = new Date('2021-01-01T00:00:00.000Z');
|
||||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(assetStub.image);
|
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(removeNonSidecarFiles(assetStub.image));
|
||||||
mocks.storage.stat.mockResolvedValue({
|
mocks.storage.stat.mockResolvedValue({
|
||||||
size: 123_456,
|
size: 123_456,
|
||||||
mtime: fileModifiedAt,
|
mtime: fileModifiedAt,
|
||||||
@@ -196,7 +212,7 @@ describe(MetadataService.name, () => {
|
|||||||
it('should take the file creation date when missing exif and earlier than modification date', async () => {
|
it('should take the file creation date when missing exif and earlier than modification date', async () => {
|
||||||
const fileCreatedAt = new Date('2021-01-01T00:00:00.000Z');
|
const fileCreatedAt = new Date('2021-01-01T00:00:00.000Z');
|
||||||
const fileModifiedAt = new Date('2022-01-01T00:00:00.000Z');
|
const fileModifiedAt = new Date('2022-01-01T00:00:00.000Z');
|
||||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(assetStub.image);
|
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(removeNonSidecarFiles(assetStub.image));
|
||||||
mocks.storage.stat.mockResolvedValue({
|
mocks.storage.stat.mockResolvedValue({
|
||||||
size: 123_456,
|
size: 123_456,
|
||||||
mtime: fileModifiedAt,
|
mtime: fileModifiedAt,
|
||||||
@@ -219,7 +235,7 @@ describe(MetadataService.name, () => {
|
|||||||
|
|
||||||
it('should account for the server being in a non-UTC timezone', async () => {
|
it('should account for the server being in a non-UTC timezone', async () => {
|
||||||
process.env.TZ = 'America/Los_Angeles';
|
process.env.TZ = 'America/Los_Angeles';
|
||||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(assetStub.sidecar);
|
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(removeNonSidecarFiles(assetStub.sidecar));
|
||||||
mockReadTags({ DateTimeOriginal: '2022:01:01 00:00:00' });
|
mockReadTags({ DateTimeOriginal: '2022:01:01 00:00:00' });
|
||||||
|
|
||||||
await sut.handleMetadataExtraction({ id: assetStub.image.id });
|
await sut.handleMetadataExtraction({ id: assetStub.image.id });
|
||||||
@@ -237,7 +253,7 @@ describe(MetadataService.name, () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should handle lists of numbers', async () => {
|
it('should handle lists of numbers', async () => {
|
||||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(assetStub.image);
|
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(removeNonSidecarFiles(assetStub.image));
|
||||||
mocks.storage.stat.mockResolvedValue({
|
mocks.storage.stat.mockResolvedValue({
|
||||||
size: 123_456,
|
size: 123_456,
|
||||||
mtime: assetStub.image.fileModifiedAt,
|
mtime: assetStub.image.fileModifiedAt,
|
||||||
@@ -290,7 +306,7 @@ describe(MetadataService.name, () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should apply reverse geocoding', async () => {
|
it('should apply reverse geocoding', async () => {
|
||||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(assetStub.withLocation);
|
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(removeNonSidecarFiles(assetStub.withLocation));
|
||||||
mocks.systemMetadata.get.mockResolvedValue({ reverseGeocoding: { enabled: true } });
|
mocks.systemMetadata.get.mockResolvedValue({ reverseGeocoding: { enabled: true } });
|
||||||
mocks.map.reverseGeocode.mockResolvedValue({ city: 'City', state: 'State', country: 'Country' });
|
mocks.map.reverseGeocode.mockResolvedValue({ city: 'City', state: 'State', country: 'Country' });
|
||||||
mocks.storage.stat.mockResolvedValue({
|
mocks.storage.stat.mockResolvedValue({
|
||||||
@@ -319,7 +335,7 @@ describe(MetadataService.name, () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should discard latitude and longitude on null island', async () => {
|
it('should discard latitude and longitude on null island', async () => {
|
||||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(assetStub.withLocation);
|
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(removeNonSidecarFiles(assetStub.withLocation));
|
||||||
mockReadTags({
|
mockReadTags({
|
||||||
GPSLatitude: 0,
|
GPSLatitude: 0,
|
||||||
GPSLongitude: 0,
|
GPSLongitude: 0,
|
||||||
@@ -331,7 +347,7 @@ describe(MetadataService.name, () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should extract tags from TagsList', async () => {
|
it('should extract tags from TagsList', async () => {
|
||||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(assetStub.image);
|
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(removeNonSidecarFiles(assetStub.image));
|
||||||
mockReadTags({ TagsList: ['Parent'] });
|
mockReadTags({ TagsList: ['Parent'] });
|
||||||
mocks.tag.upsertValue.mockResolvedValue(tagStub.parentUpsert);
|
mocks.tag.upsertValue.mockResolvedValue(tagStub.parentUpsert);
|
||||||
|
|
||||||
@@ -341,7 +357,7 @@ describe(MetadataService.name, () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should extract hierarchy from TagsList', async () => {
|
it('should extract hierarchy from TagsList', async () => {
|
||||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(assetStub.image);
|
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(removeNonSidecarFiles(assetStub.image));
|
||||||
mockReadTags({ TagsList: ['Parent/Child'] });
|
mockReadTags({ TagsList: ['Parent/Child'] });
|
||||||
mocks.tag.upsertValue.mockResolvedValueOnce(tagStub.parentUpsert);
|
mocks.tag.upsertValue.mockResolvedValueOnce(tagStub.parentUpsert);
|
||||||
mocks.tag.upsertValue.mockResolvedValueOnce(tagStub.childUpsert);
|
mocks.tag.upsertValue.mockResolvedValueOnce(tagStub.childUpsert);
|
||||||
@@ -361,7 +377,7 @@ describe(MetadataService.name, () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should extract tags from Keywords as a string', async () => {
|
it('should extract tags from Keywords as a string', async () => {
|
||||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(assetStub.image);
|
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(removeNonSidecarFiles(assetStub.image));
|
||||||
mockReadTags({ Keywords: 'Parent' });
|
mockReadTags({ Keywords: 'Parent' });
|
||||||
mocks.tag.upsertValue.mockResolvedValue(tagStub.parentUpsert);
|
mocks.tag.upsertValue.mockResolvedValue(tagStub.parentUpsert);
|
||||||
|
|
||||||
@@ -371,7 +387,7 @@ describe(MetadataService.name, () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should extract tags from Keywords as a list', async () => {
|
it('should extract tags from Keywords as a list', async () => {
|
||||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(assetStub.image);
|
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(removeNonSidecarFiles(assetStub.image));
|
||||||
mockReadTags({ Keywords: ['Parent'] });
|
mockReadTags({ Keywords: ['Parent'] });
|
||||||
mocks.tag.upsertValue.mockResolvedValue(tagStub.parentUpsert);
|
mocks.tag.upsertValue.mockResolvedValue(tagStub.parentUpsert);
|
||||||
|
|
||||||
@@ -381,7 +397,7 @@ describe(MetadataService.name, () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should extract tags from Keywords as a list with a number', async () => {
|
it('should extract tags from Keywords as a list with a number', async () => {
|
||||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(assetStub.image);
|
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(removeNonSidecarFiles(assetStub.image));
|
||||||
mockReadTags({ Keywords: ['Parent', 2024] });
|
mockReadTags({ Keywords: ['Parent', 2024] });
|
||||||
mocks.tag.upsertValue.mockResolvedValue(tagStub.parentUpsert);
|
mocks.tag.upsertValue.mockResolvedValue(tagStub.parentUpsert);
|
||||||
|
|
||||||
@@ -392,7 +408,7 @@ describe(MetadataService.name, () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should extract hierarchal tags from Keywords', async () => {
|
it('should extract hierarchal tags from Keywords', async () => {
|
||||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(assetStub.image);
|
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(removeNonSidecarFiles(assetStub.image));
|
||||||
mockReadTags({ Keywords: 'Parent/Child' });
|
mockReadTags({ Keywords: 'Parent/Child' });
|
||||||
mocks.tag.upsertValue.mockResolvedValue(tagStub.parentUpsert);
|
mocks.tag.upsertValue.mockResolvedValue(tagStub.parentUpsert);
|
||||||
|
|
||||||
@@ -411,7 +427,7 @@ describe(MetadataService.name, () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should ignore Keywords when TagsList is present', async () => {
|
it('should ignore Keywords when TagsList is present', async () => {
|
||||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(assetStub.image);
|
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(removeNonSidecarFiles(assetStub.image));
|
||||||
mockReadTags({ Keywords: 'Child', TagsList: ['Parent/Child'] });
|
mockReadTags({ Keywords: 'Child', TagsList: ['Parent/Child'] });
|
||||||
mocks.tag.upsertValue.mockResolvedValue(tagStub.parentUpsert);
|
mocks.tag.upsertValue.mockResolvedValue(tagStub.parentUpsert);
|
||||||
|
|
||||||
@@ -430,7 +446,7 @@ describe(MetadataService.name, () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should extract hierarchy from HierarchicalSubject', async () => {
|
it('should extract hierarchy from HierarchicalSubject', async () => {
|
||||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(assetStub.image);
|
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(removeNonSidecarFiles(assetStub.image));
|
||||||
mockReadTags({ HierarchicalSubject: ['Parent|Child', 'TagA'] });
|
mockReadTags({ HierarchicalSubject: ['Parent|Child', 'TagA'] });
|
||||||
mocks.tag.upsertValue.mockResolvedValueOnce(tagStub.parentUpsert);
|
mocks.tag.upsertValue.mockResolvedValueOnce(tagStub.parentUpsert);
|
||||||
mocks.tag.upsertValue.mockResolvedValueOnce(tagStub.childUpsert);
|
mocks.tag.upsertValue.mockResolvedValueOnce(tagStub.childUpsert);
|
||||||
@@ -451,7 +467,7 @@ describe(MetadataService.name, () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should extract tags from HierarchicalSubject as a list with a number', async () => {
|
it('should extract tags from HierarchicalSubject as a list with a number', async () => {
|
||||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(assetStub.image);
|
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(removeNonSidecarFiles(assetStub.image));
|
||||||
mockReadTags({ HierarchicalSubject: ['Parent', 2024] });
|
mockReadTags({ HierarchicalSubject: ['Parent', 2024] });
|
||||||
mocks.tag.upsertValue.mockResolvedValue(tagStub.parentUpsert);
|
mocks.tag.upsertValue.mockResolvedValue(tagStub.parentUpsert);
|
||||||
|
|
||||||
@@ -1479,20 +1495,20 @@ describe(MetadataService.name, () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('handleSidecarSync', () => {
|
describe('handleSidecarSync', () => {
|
||||||
it('should do nothing if asset could not be found', async () => {
|
it("should do nothing if asset isn't found", async () => {
|
||||||
mocks.asset.getByIds.mockResolvedValue([]);
|
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(void 0);
|
||||||
await expect(sut.handleSidecarSync({ id: assetStub.image.id })).resolves.toBe(JobStatus.Failed);
|
await expect(sut.handleSidecarSync({ id: assetStub.image.id })).resolves.toBe(JobStatus.Failed);
|
||||||
expect(mocks.asset.update).not.toHaveBeenCalled();
|
expect(mocks.asset.update).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should do nothing if asset has no sidecar path', async () => {
|
it('should do nothing if asset has no sidecar file', async () => {
|
||||||
mocks.asset.getByIds.mockResolvedValue([assetStub.image]);
|
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(removeNonSidecarFiles(assetStub.image));
|
||||||
await expect(sut.handleSidecarSync({ id: assetStub.image.id })).resolves.toBe(JobStatus.Failed);
|
await expect(sut.handleSidecarSync({ id: assetStub.image.id })).resolves.toBe(JobStatus.Failed);
|
||||||
expect(mocks.asset.update).not.toHaveBeenCalled();
|
expect(mocks.asset.update).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should set sidecar path if exists (sidecar named photo.ext.xmp)', async () => {
|
it('should set sidecar path if exists (sidecar named photo.ext.xmp)', async () => {
|
||||||
mocks.asset.getByIds.mockResolvedValue([assetStub.sidecar]);
|
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(removeNonSidecarFiles(assetStub.sidecar));
|
||||||
mocks.storage.checkFileExists.mockResolvedValue(true);
|
mocks.storage.checkFileExists.mockResolvedValue(true);
|
||||||
|
|
||||||
await expect(sut.handleSidecarSync({ id: assetStub.sidecar.id })).resolves.toBe(JobStatus.Success);
|
await expect(sut.handleSidecarSync({ id: assetStub.sidecar.id })).resolves.toBe(JobStatus.Success);
|
||||||
@@ -1500,49 +1516,54 @@ describe(MetadataService.name, () => {
|
|||||||
`${assetStub.sidecar.originalPath}.xmp`,
|
`${assetStub.sidecar.originalPath}.xmp`,
|
||||||
constants.R_OK,
|
constants.R_OK,
|
||||||
);
|
);
|
||||||
expect(mocks.asset.update).toHaveBeenCalledWith({
|
expect(mocks.asset.upsertFile).toHaveBeenCalledWith({
|
||||||
id: assetStub.sidecar.id,
|
assetId: assetStub.sidecar.id,
|
||||||
sidecarPath: assetStub.sidecar.sidecarPath,
|
path: assetStub.sidecar.files[1].path,
|
||||||
|
type: AssetFileType.Sidecar,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should set sidecar path if exists (sidecar named photo.xmp)', async () => {
|
it('should set sidecar path if exists (sidecar named photo.xmp)', async () => {
|
||||||
mocks.asset.getByIds.mockResolvedValue([assetStub.sidecarWithoutExt as any]);
|
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(
|
||||||
|
removeNonSidecarFiles(assetStub.sidecarWithoutExt as any),
|
||||||
|
);
|
||||||
mocks.storage.checkFileExists.mockResolvedValueOnce(false);
|
mocks.storage.checkFileExists.mockResolvedValueOnce(false);
|
||||||
mocks.storage.checkFileExists.mockResolvedValueOnce(true);
|
mocks.storage.checkFileExists.mockResolvedValueOnce(true);
|
||||||
|
|
||||||
await expect(sut.handleSidecarSync({ id: assetStub.sidecarWithoutExt.id })).resolves.toBe(JobStatus.Success);
|
await expect(sut.handleSidecarSync({ id: assetStub.sidecarWithoutExt.id })).resolves.toBe(JobStatus.Success);
|
||||||
expect(mocks.storage.checkFileExists).toHaveBeenNthCalledWith(
|
expect(mocks.storage.checkFileExists).toHaveBeenNthCalledWith(
|
||||||
2,
|
2,
|
||||||
assetStub.sidecarWithoutExt.sidecarPath,
|
assetStub.sidecarWithoutExt.files[1].path,
|
||||||
constants.R_OK,
|
constants.R_OK,
|
||||||
);
|
);
|
||||||
expect(mocks.asset.update).toHaveBeenCalledWith({
|
expect(mocks.asset.upsertFile).toHaveBeenCalledWith({
|
||||||
id: assetStub.sidecarWithoutExt.id,
|
assetId: assetStub.sidecarWithoutExt.id,
|
||||||
sidecarPath: assetStub.sidecarWithoutExt.sidecarPath,
|
path: assetStub.sidecarWithoutExt.files[1].path,
|
||||||
|
type: AssetFileType.Sidecar,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should set sidecar path if exists (two sidecars named photo.ext.xmp and photo.xmp, should pick photo.ext.xmp)', async () => {
|
it('should set sidecar path if exists (two sidecars named photo.ext.xmp and photo.xmp, should pick photo.ext.xmp)', async () => {
|
||||||
mocks.asset.getByIds.mockResolvedValue([assetStub.sidecar]);
|
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(removeNonSidecarFiles(assetStub.sidecar));
|
||||||
mocks.storage.checkFileExists.mockResolvedValueOnce(true);
|
mocks.storage.checkFileExists.mockResolvedValueOnce(true);
|
||||||
mocks.storage.checkFileExists.mockResolvedValueOnce(true);
|
mocks.storage.checkFileExists.mockResolvedValueOnce(true);
|
||||||
|
|
||||||
await expect(sut.handleSidecarSync({ id: assetStub.sidecar.id })).resolves.toBe(JobStatus.Success);
|
await expect(sut.handleSidecarSync({ id: assetStub.sidecar.id })).resolves.toBe(JobStatus.Success);
|
||||||
expect(mocks.storage.checkFileExists).toHaveBeenNthCalledWith(1, assetStub.sidecar.sidecarPath, constants.R_OK);
|
expect(mocks.storage.checkFileExists).toHaveBeenNthCalledWith(1, assetStub.sidecar.files[1].path, constants.R_OK);
|
||||||
expect(mocks.storage.checkFileExists).toHaveBeenNthCalledWith(
|
expect(mocks.storage.checkFileExists).toHaveBeenNthCalledWith(
|
||||||
2,
|
2,
|
||||||
assetStub.sidecarWithoutExt.sidecarPath,
|
assetStub.sidecarWithoutExt.files[1].path,
|
||||||
constants.R_OK,
|
constants.R_OK,
|
||||||
);
|
);
|
||||||
expect(mocks.asset.update).toHaveBeenCalledWith({
|
expect(mocks.asset.upsertFile).toHaveBeenCalledWith({
|
||||||
id: assetStub.sidecar.id,
|
assetId: assetStub.sidecar.id,
|
||||||
sidecarPath: assetStub.sidecar.sidecarPath,
|
path: assetStub.sidecar.files[1].path,
|
||||||
|
type: AssetFileType.Sidecar,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should unset sidecar path if file does not exist anymore', async () => {
|
it('should unset sidecar path if file does not exist anymore', async () => {
|
||||||
mocks.asset.getByIds.mockResolvedValue([assetStub.sidecar]);
|
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(removeNonSidecarFiles(assetStub.sidecar));
|
||||||
mocks.storage.checkFileExists.mockResolvedValue(false);
|
mocks.storage.checkFileExists.mockResolvedValue(false);
|
||||||
|
|
||||||
await expect(sut.handleSidecarSync({ id: assetStub.sidecar.id })).resolves.toBe(JobStatus.Success);
|
await expect(sut.handleSidecarSync({ id: assetStub.sidecar.id })).resolves.toBe(JobStatus.Success);
|
||||||
@@ -1550,58 +1571,79 @@ describe(MetadataService.name, () => {
|
|||||||
`${assetStub.sidecar.originalPath}.xmp`,
|
`${assetStub.sidecar.originalPath}.xmp`,
|
||||||
constants.R_OK,
|
constants.R_OK,
|
||||||
);
|
);
|
||||||
expect(mocks.asset.update).toHaveBeenCalledWith({
|
expect(mocks.asset.deleteFile).toHaveBeenCalledWith({
|
||||||
id: assetStub.sidecar.id,
|
assetId: assetStub.sidecar.id,
|
||||||
sidecarPath: null,
|
type: AssetFileType.Sidecar,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('handleSidecarDiscovery', () => {
|
describe('handleSidecarDiscovery', () => {
|
||||||
it('should skip hidden assets', async () => {
|
it('should skip hidden assets', async () => {
|
||||||
mocks.asset.getByIds.mockResolvedValue([assetStub.livePhotoMotionAsset as any]);
|
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(assetStub.livePhotoMotionAsset as any);
|
||||||
await sut.handleSidecarDiscovery({ id: assetStub.livePhotoMotionAsset.id });
|
await expect(sut.handleSidecarDiscovery({ id: assetStub.livePhotoMotionAsset.id })).resolves.toBe(
|
||||||
expect(mocks.storage.checkFileExists).not.toHaveBeenCalled();
|
JobStatus.Skipped,
|
||||||
});
|
);
|
||||||
|
|
||||||
it('should skip assets with a sidecar path', async () => {
|
|
||||||
mocks.asset.getByIds.mockResolvedValue([assetStub.sidecar]);
|
|
||||||
await sut.handleSidecarDiscovery({ id: assetStub.sidecar.id });
|
|
||||||
expect(mocks.storage.checkFileExists).not.toHaveBeenCalled();
|
expect(mocks.storage.checkFileExists).not.toHaveBeenCalled();
|
||||||
});
|
|
||||||
|
|
||||||
it('should do nothing when a sidecar is not found ', async () => {
|
|
||||||
mocks.asset.getByIds.mockResolvedValue([assetStub.image]);
|
|
||||||
mocks.storage.checkFileExists.mockResolvedValue(false);
|
|
||||||
await sut.handleSidecarDiscovery({ id: assetStub.image.id });
|
|
||||||
expect(mocks.asset.update).not.toHaveBeenCalled();
|
expect(mocks.asset.update).not.toHaveBeenCalled();
|
||||||
|
expect(mocks.asset.upsertFile).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should update a image asset when a sidecar is found', async () => {
|
it('should skip assets that already have a known sidecar', async () => {
|
||||||
mocks.asset.getByIds.mockResolvedValue([assetStub.image]);
|
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(assetStub.sidecar);
|
||||||
mocks.storage.checkFileExists.mockResolvedValue(true);
|
await expect(sut.handleSidecarDiscovery({ id: assetStub.sidecar.id })).resolves.toBe(JobStatus.Skipped);
|
||||||
await sut.handleSidecarDiscovery({ id: assetStub.image.id });
|
|
||||||
|
expect(mocks.storage.checkFileExists).not.toHaveBeenCalled();
|
||||||
|
expect(mocks.asset.update).not.toHaveBeenCalled();
|
||||||
|
expect(mocks.asset.upsertFile).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should do nothing when no sidecar is found on disk', async () => {
|
||||||
|
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(removeNonSidecarFiles(assetStub.image));
|
||||||
|
mocks.storage.checkFileExists.mockResolvedValue(false);
|
||||||
|
await expect(sut.handleSidecarDiscovery({ id: assetStub.image.id })).resolves.toBe(JobStatus.Success);
|
||||||
|
|
||||||
expect(mocks.storage.checkFileExists).toHaveBeenCalledWith('/original/path.jpg.xmp', constants.R_OK);
|
expect(mocks.storage.checkFileExists).toHaveBeenCalledWith('/original/path.jpg.xmp', constants.R_OK);
|
||||||
expect(mocks.asset.update).toHaveBeenCalledWith({
|
expect(mocks.storage.checkFileExists).toHaveBeenCalledWith('/original/path.xmp', constants.R_OK);
|
||||||
id: assetStub.image.id,
|
|
||||||
sidecarPath: '/original/path.jpg.xmp',
|
expect(mocks.asset.update).not.toHaveBeenCalled();
|
||||||
|
expect(mocks.asset.upsertFile).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should update a image asset when a sidecar is found on disk', async () => {
|
||||||
|
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(removeNonSidecarFiles(assetStub.image));
|
||||||
|
mocks.storage.checkFileExists.mockResolvedValue(true);
|
||||||
|
await expect(sut.handleSidecarDiscovery({ id: assetStub.image.id })).resolves.toBe(JobStatus.Success);
|
||||||
|
|
||||||
|
expect(mocks.storage.checkFileExists).toHaveBeenCalledWith('/original/path.jpg.xmp', constants.R_OK);
|
||||||
|
expect(mocks.storage.checkFileExists).toHaveBeenCalledWith('/original/path.xmp', constants.R_OK);
|
||||||
|
|
||||||
|
expect(mocks.asset.upsertFile).toHaveBeenCalledWith({
|
||||||
|
assetId: assetStub.image.id,
|
||||||
|
path: '/original/path.jpg.xmp',
|
||||||
|
type: AssetFileType.Sidecar,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should update a video asset when a sidecar is found', async () => {
|
it('should update a video asset when a sidecar is found', async () => {
|
||||||
mocks.asset.getByIds.mockResolvedValue([assetStub.video]);
|
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(assetStub.video);
|
||||||
mocks.storage.checkFileExists.mockResolvedValue(true);
|
mocks.storage.checkFileExists.mockResolvedValue(true);
|
||||||
await sut.handleSidecarDiscovery({ id: assetStub.video.id });
|
await expect(sut.handleSidecarDiscovery({ id: assetStub.video.id })).resolves.toBe(JobStatus.Success);
|
||||||
|
|
||||||
expect(mocks.storage.checkFileExists).toHaveBeenCalledWith('/original/path.ext.xmp', constants.R_OK);
|
expect(mocks.storage.checkFileExists).toHaveBeenCalledWith('/original/path.ext.xmp', constants.R_OK);
|
||||||
expect(mocks.asset.update).toHaveBeenCalledWith({
|
expect(mocks.storage.checkFileExists).toHaveBeenCalledWith('/original/path.xmp', constants.R_OK);
|
||||||
id: assetStub.image.id,
|
|
||||||
sidecarPath: '/original/path.ext.xmp',
|
expect(mocks.asset.upsertFile).toHaveBeenCalledWith({
|
||||||
|
assetId: assetStub.image.id,
|
||||||
|
path: '/original/path.ext.xmp',
|
||||||
|
type: AssetFileType.Sidecar,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('handleSidecarWrite', () => {
|
describe('handleSidecarWrite', () => {
|
||||||
it('should skip assets that do not exist anymore', async () => {
|
it('should skip assets that no longer exist', async () => {
|
||||||
mocks.assetJob.getForSidecarWriteJob.mockResolvedValue(void 0);
|
mocks.assetJob.getForSidecarWriteJob.mockResolvedValue(void 0);
|
||||||
await expect(sut.handleSidecarWrite({ id: 'asset-123' })).resolves.toBe(JobStatus.Failed);
|
await expect(sut.handleSidecarWrite({ id: 'asset-123' })).resolves.toBe(JobStatus.Failed);
|
||||||
expect(mocks.metadata.writeTags).not.toHaveBeenCalled();
|
expect(mocks.metadata.writeTags).not.toHaveBeenCalled();
|
||||||
|
|||||||
@@ -8,9 +8,10 @@ import { constants } from 'node:fs/promises';
|
|||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { JOBS_ASSET_PAGINATION_SIZE } from 'src/constants';
|
import { JOBS_ASSET_PAGINATION_SIZE } from 'src/constants';
|
||||||
import { StorageCore } from 'src/cores/storage.core';
|
import { StorageCore } from 'src/cores/storage.core';
|
||||||
import { Asset, AssetFace } from 'src/database';
|
import { Asset, AssetFace, AssetFile } from 'src/database';
|
||||||
import { OnEvent, OnJob } from 'src/decorators';
|
import { OnEvent, OnJob } from 'src/decorators';
|
||||||
import {
|
import {
|
||||||
|
AssetFileType,
|
||||||
AssetType,
|
AssetType,
|
||||||
AssetVisibility,
|
AssetVisibility,
|
||||||
DatabaseLock,
|
DatabaseLock,
|
||||||
@@ -371,7 +372,7 @@ export class MetadataService extends BaseService {
|
|||||||
|
|
||||||
const tagsList = (asset.tags || []).map((tag) => tag.value);
|
const tagsList = (asset.tags || []).map((tag) => tag.value);
|
||||||
|
|
||||||
const sidecarPath = asset.sidecarPath || `${asset.originalPath}.xmp`;
|
const sidecarPath = asset.files[0]?.path || `${asset.originalPath}.xmp`;
|
||||||
const exif = _.omitBy(
|
const exif = _.omitBy(
|
||||||
<Tags>{
|
<Tags>{
|
||||||
Description: description,
|
Description: description,
|
||||||
@@ -391,8 +392,8 @@ export class MetadataService extends BaseService {
|
|||||||
|
|
||||||
await this.metadataRepository.writeTags(sidecarPath, exif);
|
await this.metadataRepository.writeTags(sidecarPath, exif);
|
||||||
|
|
||||||
if (!asset.sidecarPath) {
|
if (asset.files.length === 0) {
|
||||||
await this.assetRepository.update({ id, sidecarPath });
|
await this.assetRepository.upsertFile({ assetId: id, type: AssetFileType.Sidecar, path: sidecarPath });
|
||||||
}
|
}
|
||||||
|
|
||||||
return JobStatus.Success;
|
return JobStatus.Success;
|
||||||
@@ -411,13 +412,17 @@ export class MetadataService extends BaseService {
|
|||||||
return { width, height };
|
return { width, height };
|
||||||
}
|
}
|
||||||
|
|
||||||
private getExifTags(asset: {
|
private getExifTags(asset: { originalPath: string; files: AssetFile[]; type: AssetType }): Promise<ImmichTags> {
|
||||||
originalPath: string;
|
if (asset.type === AssetType.Image) {
|
||||||
sidecarPath: string | null;
|
let hasSidecar = false;
|
||||||
type: AssetType;
|
|
||||||
}): Promise<ImmichTags> {
|
if (asset.files && asset.files.length > 0) {
|
||||||
if (!asset.sidecarPath && asset.type === AssetType.Image) {
|
hasSidecar = asset.files.some((file) => file.type === AssetFileType.Sidecar);
|
||||||
return this.metadataRepository.readTags(asset.originalPath);
|
}
|
||||||
|
|
||||||
|
if (!hasSidecar) {
|
||||||
|
return this.metadataRepository.readTags(asset.originalPath);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.mergeExifTags(asset);
|
return this.mergeExifTags(asset);
|
||||||
@@ -425,12 +430,16 @@ export class MetadataService extends BaseService {
|
|||||||
|
|
||||||
private async mergeExifTags(asset: {
|
private async mergeExifTags(asset: {
|
||||||
originalPath: string;
|
originalPath: string;
|
||||||
sidecarPath: string | null;
|
files: AssetFile[];
|
||||||
type: AssetType;
|
type: AssetType;
|
||||||
}): Promise<ImmichTags> {
|
}): Promise<ImmichTags> {
|
||||||
|
if (asset.files && asset.files.length > 1) {
|
||||||
|
throw new Error(`Asset ${asset.originalPath} has multiple sidecar files`);
|
||||||
|
}
|
||||||
|
|
||||||
const [mediaTags, sidecarTags, videoTags] = await Promise.all([
|
const [mediaTags, sidecarTags, videoTags] = await Promise.all([
|
||||||
this.metadataRepository.readTags(asset.originalPath),
|
this.metadataRepository.readTags(asset.originalPath),
|
||||||
asset.sidecarPath ? this.metadataRepository.readTags(asset.sidecarPath) : null,
|
asset.files && asset.files.length > 0 ? this.metadataRepository.readTags(asset.files[0].path) : null,
|
||||||
asset.type === AssetType.Video ? this.getVideoTags(asset.originalPath) : null,
|
asset.type === AssetType.Video ? this.getVideoTags(asset.originalPath) : null,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -891,18 +900,38 @@ export class MetadataService extends BaseService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async processSidecar(id: string, isSync: boolean): Promise<JobStatus> {
|
private async processSidecar(id: string, isSync: boolean): Promise<JobStatus> {
|
||||||
const [asset] = await this.assetRepository.getByIds([id]);
|
const asset = await this.assetJobRepository.getForMetadataExtraction(id);
|
||||||
|
|
||||||
if (!asset) {
|
if (!asset) {
|
||||||
return JobStatus.Failed;
|
return JobStatus.Failed;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isSync && !asset.sidecarPath) {
|
if (isSync) {
|
||||||
return JobStatus.Failed;
|
// We are performing a sidecar sync
|
||||||
|
if (asset.files.length === 0) {
|
||||||
|
return JobStatus.Failed;
|
||||||
|
}
|
||||||
|
} else if (!asset.isExternal) {
|
||||||
|
// We are performing a sidecar discovery and not on an external library
|
||||||
|
if (asset.visibility === AssetVisibility.Hidden) {
|
||||||
|
// Skip hidden assets
|
||||||
|
return JobStatus.Skipped;
|
||||||
|
} else if (asset.files.length > 1) {
|
||||||
|
// Skip assets that already have a sidecar
|
||||||
|
return JobStatus.Skipped;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isSync && (asset.visibility === AssetVisibility.Hidden || asset.sidecarPath) && !asset.isExternal) {
|
let currentSidecar: AssetFile | null;
|
||||||
return JobStatus.Failed;
|
|
||||||
|
if (asset.files.length === 0) {
|
||||||
|
currentSidecar = null;
|
||||||
|
} else if (asset.files.length === 1) {
|
||||||
|
currentSidecar = asset.files[0];
|
||||||
|
} else {
|
||||||
|
throw new Error(
|
||||||
|
`Multiple sidecar files found for asset ${asset.id}: ${asset.files.map((file) => file.path).join(', ')}`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// XMP sidecars can come in two filename formats. For a photo named photo.ext, the filenames are photo.ext.xmp and photo.xmp
|
// XMP sidecars can come in two filename formats. For a photo named photo.ext, the filenames are photo.ext.xmp and photo.xmp
|
||||||
@@ -918,30 +947,30 @@ export class MetadataService extends BaseService {
|
|||||||
|
|
||||||
let sidecarPath = null;
|
let sidecarPath = null;
|
||||||
if (sidecarPathWithExtExists) {
|
if (sidecarPathWithExtExists) {
|
||||||
|
// Sidecars with the extension have precedence over those without
|
||||||
sidecarPath = sidecarPathWithExt;
|
sidecarPath = sidecarPathWithExt;
|
||||||
} else if (sidecarPathWithoutExtExists) {
|
} else if (sidecarPathWithoutExtExists) {
|
||||||
sidecarPath = sidecarPathWithoutExt;
|
sidecarPath = sidecarPathWithoutExt;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (asset.isExternal) {
|
if (asset.isExternal) {
|
||||||
if (sidecarPath !== asset.sidecarPath) {
|
if (sidecarPath !== currentSidecar?.path) {
|
||||||
await this.assetRepository.update({ id: asset.id, sidecarPath });
|
await (sidecarPath
|
||||||
|
? this.assetRepository.upsertFile({ assetId: asset.id, path: sidecarPath, type: AssetFileType.Sidecar })
|
||||||
|
: this.assetRepository.deleteFile({ assetId: asset.id, type: AssetFileType.Sidecar }));
|
||||||
}
|
}
|
||||||
|
|
||||||
return JobStatus.Success;
|
return JobStatus.Success;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (sidecarPath) {
|
if (sidecarPath) {
|
||||||
this.logger.debug(`Detected sidecar at '${sidecarPath}' for asset ${asset.id}: ${asset.originalPath}`);
|
this.logger.debug(`Detected sidecar at '${sidecarPath}' for asset ${asset.id}: ${asset.originalPath}`);
|
||||||
await this.assetRepository.update({ id: asset.id, sidecarPath });
|
await this.assetRepository.upsertFile({ assetId: asset.id, path: sidecarPath, type: AssetFileType.Sidecar });
|
||||||
return JobStatus.Success;
|
return JobStatus.Success;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isSync) {
|
|
||||||
return JobStatus.Failed;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.logger.debug(`No sidecar found for asset ${asset.id}: ${asset.originalPath}`);
|
this.logger.debug(`No sidecar found for asset ${asset.id}: ${asset.originalPath}`);
|
||||||
await this.assetRepository.update({ id: asset.id, sidecarPath: null });
|
await this.assetRepository.deleteFile({ assetId: asset.id, type: AssetFileType.Sidecar });
|
||||||
|
|
||||||
return JobStatus.Success;
|
return JobStatus.Success;
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -435,7 +435,7 @@ export type StorageAsset = {
|
|||||||
fileCreatedAt: Date;
|
fileCreatedAt: Date;
|
||||||
originalPath: string;
|
originalPath: string;
|
||||||
originalFileName: string;
|
originalFileName: string;
|
||||||
sidecarPath: string | null;
|
sidecarPath?: string | null;
|
||||||
fileSizeInByte: number | null;
|
fileSizeInByte: number | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ export const getAssetFiles = (files: AssetFile[]) => ({
|
|||||||
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),
|
||||||
|
sidecarFile: getAssetFile(files, AssetFileType.Sidecar),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const addAssets = async (
|
export const addAssets = async (
|
||||||
|
|||||||
Vendored
+16
-24
@@ -24,6 +24,18 @@ const fullsizeFile: AssetFile = {
|
|||||||
path: '/uploads/user-id/fullsize/path.webp',
|
path: '/uploads/user-id/fullsize/path.webp',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const sidecarFileWithExt: AssetFile = {
|
||||||
|
id: 'sidecar-with-ext',
|
||||||
|
type: AssetFileType.Sidecar,
|
||||||
|
path: '/original/path.ext.xmp',
|
||||||
|
};
|
||||||
|
|
||||||
|
const sidecarFileWithoutExt: AssetFile = {
|
||||||
|
id: 'sidecar-without-ext',
|
||||||
|
type: AssetFileType.Sidecar,
|
||||||
|
path: '/original/path.xmp',
|
||||||
|
};
|
||||||
|
|
||||||
const files: AssetFile[] = [fullsizeFile, previewFile, thumbnailFile];
|
const files: AssetFile[] = [fullsizeFile, previewFile, thumbnailFile];
|
||||||
|
|
||||||
export const stackStub = (stackId: string, assets: (MapAsset & { exifInfo: Exif })[]) => {
|
export const stackStub = (stackId: string, assets: (MapAsset & { exifInfo: Exif })[]) => {
|
||||||
@@ -51,7 +63,6 @@ export const assetStub = {
|
|||||||
fileCreatedAt: new Date('2022-06-19T23:41:36.910Z'),
|
fileCreatedAt: new Date('2022-06-19T23:41:36.910Z'),
|
||||||
originalPath: '/original/path.jpg',
|
originalPath: '/original/path.jpg',
|
||||||
originalFileName: 'IMG_123.jpg',
|
originalFileName: 'IMG_123.jpg',
|
||||||
sidecarPath: null,
|
|
||||||
fileSizeInByte: 12_345,
|
fileSizeInByte: 12_345,
|
||||||
...asset,
|
...asset,
|
||||||
}),
|
}),
|
||||||
@@ -81,7 +92,6 @@ export const assetStub = {
|
|||||||
sharedLinks: [],
|
sharedLinks: [],
|
||||||
faces: [],
|
faces: [],
|
||||||
exifInfo: {} as Exif,
|
exifInfo: {} as Exif,
|
||||||
sidecarPath: null,
|
|
||||||
deletedAt: null,
|
deletedAt: null,
|
||||||
isExternal: false,
|
isExternal: false,
|
||||||
duplicateId: null,
|
duplicateId: null,
|
||||||
@@ -117,7 +127,6 @@ export const assetStub = {
|
|||||||
sharedLinks: [],
|
sharedLinks: [],
|
||||||
originalFileName: 'IMG_456.jpg',
|
originalFileName: 'IMG_456.jpg',
|
||||||
faces: [],
|
faces: [],
|
||||||
sidecarPath: null,
|
|
||||||
isExternal: false,
|
isExternal: false,
|
||||||
exifInfo: {
|
exifInfo: {
|
||||||
fileSizeInByte: 123_000,
|
fileSizeInByte: 123_000,
|
||||||
@@ -157,7 +166,6 @@ export const assetStub = {
|
|||||||
sharedLinks: [],
|
sharedLinks: [],
|
||||||
originalFileName: 'asset-id.ext',
|
originalFileName: 'asset-id.ext',
|
||||||
faces: [],
|
faces: [],
|
||||||
sidecarPath: null,
|
|
||||||
deletedAt: null,
|
deletedAt: null,
|
||||||
duplicateId: null,
|
duplicateId: null,
|
||||||
isOffline: false,
|
isOffline: false,
|
||||||
@@ -194,7 +202,6 @@ export const assetStub = {
|
|||||||
originalFileName: 'asset-id.jpg',
|
originalFileName: 'asset-id.jpg',
|
||||||
faces: [],
|
faces: [],
|
||||||
deletedAt: null,
|
deletedAt: null,
|
||||||
sidecarPath: null,
|
|
||||||
exifInfo: {
|
exifInfo: {
|
||||||
fileSizeInByte: 5000,
|
fileSizeInByte: 5000,
|
||||||
exifImageHeight: 1000,
|
exifImageHeight: 1000,
|
||||||
@@ -243,7 +250,6 @@ export const assetStub = {
|
|||||||
originalFileName: 'asset-id.jpg',
|
originalFileName: 'asset-id.jpg',
|
||||||
faces: [],
|
faces: [],
|
||||||
deletedAt: null,
|
deletedAt: null,
|
||||||
sidecarPath: null,
|
|
||||||
exifInfo: {
|
exifInfo: {
|
||||||
fileSizeInByte: 5000,
|
fileSizeInByte: 5000,
|
||||||
exifImageHeight: 3840,
|
exifImageHeight: 3840,
|
||||||
@@ -285,7 +291,6 @@ export const assetStub = {
|
|||||||
sharedLinks: [],
|
sharedLinks: [],
|
||||||
originalFileName: 'asset-id.jpg',
|
originalFileName: 'asset-id.jpg',
|
||||||
faces: [],
|
faces: [],
|
||||||
sidecarPath: null,
|
|
||||||
exifInfo: {
|
exifInfo: {
|
||||||
fileSizeInByte: 5000,
|
fileSizeInByte: 5000,
|
||||||
exifImageHeight: 3840,
|
exifImageHeight: 3840,
|
||||||
@@ -328,7 +333,6 @@ export const assetStub = {
|
|||||||
sharedLinks: [],
|
sharedLinks: [],
|
||||||
originalFileName: 'asset-id.jpg',
|
originalFileName: 'asset-id.jpg',
|
||||||
faces: [],
|
faces: [],
|
||||||
sidecarPath: null,
|
|
||||||
exifInfo: {
|
exifInfo: {
|
||||||
fileSizeInByte: 5000,
|
fileSizeInByte: 5000,
|
||||||
exifImageHeight: 3840,
|
exifImageHeight: 3840,
|
||||||
@@ -367,7 +371,6 @@ export const assetStub = {
|
|||||||
originalFileName: 'asset-id.jpg',
|
originalFileName: 'asset-id.jpg',
|
||||||
faces: [],
|
faces: [],
|
||||||
deletedAt: null,
|
deletedAt: null,
|
||||||
sidecarPath: null,
|
|
||||||
exifInfo: {
|
exifInfo: {
|
||||||
fileSizeInByte: 5000,
|
fileSizeInByte: 5000,
|
||||||
exifImageHeight: 3840,
|
exifImageHeight: 3840,
|
||||||
@@ -409,7 +412,6 @@ export const assetStub = {
|
|||||||
originalFileName: 'asset-id.jpg',
|
originalFileName: 'asset-id.jpg',
|
||||||
faces: [],
|
faces: [],
|
||||||
deletedAt: null,
|
deletedAt: null,
|
||||||
sidecarPath: null,
|
|
||||||
exifInfo: {
|
exifInfo: {
|
||||||
fileSizeInByte: 5000,
|
fileSizeInByte: 5000,
|
||||||
} as Exif,
|
} as Exif,
|
||||||
@@ -448,7 +450,6 @@ export const assetStub = {
|
|||||||
sharedLinks: [],
|
sharedLinks: [],
|
||||||
originalFileName: 'asset-id.ext',
|
originalFileName: 'asset-id.ext',
|
||||||
faces: [],
|
faces: [],
|
||||||
sidecarPath: null,
|
|
||||||
exifInfo: {
|
exifInfo: {
|
||||||
fileSizeInByte: 5000,
|
fileSizeInByte: 5000,
|
||||||
} as Exif,
|
} as Exif,
|
||||||
@@ -490,7 +491,6 @@ export const assetStub = {
|
|||||||
sharedLinks: [],
|
sharedLinks: [],
|
||||||
originalFileName: 'asset-id.ext',
|
originalFileName: 'asset-id.ext',
|
||||||
faces: [],
|
faces: [],
|
||||||
sidecarPath: null,
|
|
||||||
exifInfo: {
|
exifInfo: {
|
||||||
fileSizeInByte: 5000,
|
fileSizeInByte: 5000,
|
||||||
} as Exif,
|
} as Exif,
|
||||||
@@ -526,7 +526,6 @@ export const assetStub = {
|
|||||||
livePhotoVideoId: null,
|
livePhotoVideoId: null,
|
||||||
sharedLinks: [],
|
sharedLinks: [],
|
||||||
faces: [],
|
faces: [],
|
||||||
sidecarPath: null,
|
|
||||||
exifInfo: {
|
exifInfo: {
|
||||||
fileSizeInByte: 100_000,
|
fileSizeInByte: 100_000,
|
||||||
exifImageHeight: 2160,
|
exifImageHeight: 2160,
|
||||||
@@ -573,7 +572,7 @@ export const assetStub = {
|
|||||||
files,
|
files,
|
||||||
faces: [] as AssetFace[],
|
faces: [] as AssetFace[],
|
||||||
visibility: AssetVisibility.Timeline,
|
visibility: AssetVisibility.Timeline,
|
||||||
} as MapAsset & { faces: AssetFace[] }),
|
} as MapAsset & { faces: AssetFace[]; files: AssetFile[] }),
|
||||||
|
|
||||||
livePhotoWithOriginalFileName: Object.freeze({
|
livePhotoWithOriginalFileName: Object.freeze({
|
||||||
id: 'live-photo-still-asset',
|
id: 'live-photo-still-asset',
|
||||||
@@ -592,7 +591,7 @@ export const assetStub = {
|
|||||||
libraryId: null,
|
libraryId: null,
|
||||||
faces: [] as AssetFace[],
|
faces: [] as AssetFace[],
|
||||||
visibility: AssetVisibility.Timeline,
|
visibility: AssetVisibility.Timeline,
|
||||||
} as MapAsset & { faces: AssetFace[] }),
|
} as MapAsset & { faces: AssetFace[]; files: AssetFile[] }),
|
||||||
|
|
||||||
withLocation: Object.freeze({
|
withLocation: Object.freeze({
|
||||||
id: 'asset-with-favorite-id',
|
id: 'asset-with-favorite-id',
|
||||||
@@ -605,7 +604,6 @@ export const assetStub = {
|
|||||||
deviceId: 'device-id',
|
deviceId: 'device-id',
|
||||||
checksum: Buffer.from('file hash', 'utf8'),
|
checksum: Buffer.from('file hash', 'utf8'),
|
||||||
originalPath: '/original/path.ext',
|
originalPath: '/original/path.ext',
|
||||||
sidecarPath: null,
|
|
||||||
type: AssetType.Image,
|
type: AssetType.Image,
|
||||||
files: [previewFile],
|
files: [previewFile],
|
||||||
thumbhash: null,
|
thumbhash: null,
|
||||||
@@ -652,7 +650,7 @@ export const assetStub = {
|
|||||||
thumbhash: null,
|
thumbhash: null,
|
||||||
checksum: Buffer.from('file hash', 'utf8'),
|
checksum: Buffer.from('file hash', 'utf8'),
|
||||||
type: AssetType.Image,
|
type: AssetType.Image,
|
||||||
files: [previewFile],
|
files: [previewFile, sidecarFileWithExt],
|
||||||
encodedVideoPath: null,
|
encodedVideoPath: null,
|
||||||
createdAt: new Date('2023-02-23T05:06:29.716Z'),
|
createdAt: new Date('2023-02-23T05:06:29.716Z'),
|
||||||
updatedAt: new Date('2023-02-23T05:06:29.716Z'),
|
updatedAt: new Date('2023-02-23T05:06:29.716Z'),
|
||||||
@@ -665,7 +663,6 @@ export const assetStub = {
|
|||||||
sharedLinks: [],
|
sharedLinks: [],
|
||||||
originalFileName: 'asset-id.ext',
|
originalFileName: 'asset-id.ext',
|
||||||
faces: [],
|
faces: [],
|
||||||
sidecarPath: '/original/path.ext.xmp',
|
|
||||||
deletedAt: null,
|
deletedAt: null,
|
||||||
duplicateId: null,
|
duplicateId: null,
|
||||||
isOffline: false,
|
isOffline: false,
|
||||||
@@ -688,7 +685,7 @@ export const assetStub = {
|
|||||||
thumbhash: null,
|
thumbhash: null,
|
||||||
checksum: Buffer.from('file hash', 'utf8'),
|
checksum: Buffer.from('file hash', 'utf8'),
|
||||||
type: AssetType.Image,
|
type: AssetType.Image,
|
||||||
files: [previewFile],
|
files: [previewFile, sidecarFileWithoutExt],
|
||||||
encodedVideoPath: null,
|
encodedVideoPath: null,
|
||||||
createdAt: new Date('2023-02-23T05:06:29.716Z'),
|
createdAt: new Date('2023-02-23T05:06:29.716Z'),
|
||||||
updatedAt: new Date('2023-02-23T05:06:29.716Z'),
|
updatedAt: new Date('2023-02-23T05:06:29.716Z'),
|
||||||
@@ -701,7 +698,6 @@ export const assetStub = {
|
|||||||
sharedLinks: [],
|
sharedLinks: [],
|
||||||
originalFileName: 'asset-id.ext',
|
originalFileName: 'asset-id.ext',
|
||||||
faces: [],
|
faces: [],
|
||||||
sidecarPath: '/original/path.xmp',
|
|
||||||
deletedAt: null,
|
deletedAt: null,
|
||||||
duplicateId: null,
|
duplicateId: null,
|
||||||
isOffline: false,
|
isOffline: false,
|
||||||
@@ -734,7 +730,6 @@ export const assetStub = {
|
|||||||
livePhotoVideoId: null,
|
livePhotoVideoId: null,
|
||||||
sharedLinks: [],
|
sharedLinks: [],
|
||||||
faces: [],
|
faces: [],
|
||||||
sidecarPath: null,
|
|
||||||
exifInfo: {
|
exifInfo: {
|
||||||
fileSizeInByte: 100_000,
|
fileSizeInByte: 100_000,
|
||||||
} as Exif,
|
} as Exif,
|
||||||
@@ -776,7 +771,6 @@ export const assetStub = {
|
|||||||
originalFileName: 'photo.jpg',
|
originalFileName: 'photo.jpg',
|
||||||
faces: [],
|
faces: [],
|
||||||
deletedAt: null,
|
deletedAt: null,
|
||||||
sidecarPath: null,
|
|
||||||
exifInfo: {
|
exifInfo: {
|
||||||
fileSizeInByte: 5000,
|
fileSizeInByte: 5000,
|
||||||
} as Exif,
|
} as Exif,
|
||||||
@@ -812,7 +806,6 @@ export const assetStub = {
|
|||||||
originalFileName: 'asset-id.dng',
|
originalFileName: 'asset-id.dng',
|
||||||
faces: [],
|
faces: [],
|
||||||
deletedAt: null,
|
deletedAt: null,
|
||||||
sidecarPath: null,
|
|
||||||
exifInfo: {
|
exifInfo: {
|
||||||
fileSizeInByte: 5000,
|
fileSizeInByte: 5000,
|
||||||
profileDescription: 'Adobe RGB',
|
profileDescription: 'Adobe RGB',
|
||||||
@@ -853,7 +846,6 @@ export const assetStub = {
|
|||||||
originalFileName: 'asset-id.hif',
|
originalFileName: 'asset-id.hif',
|
||||||
faces: [],
|
faces: [],
|
||||||
deletedAt: null,
|
deletedAt: null,
|
||||||
sidecarPath: null,
|
|
||||||
exifInfo: {
|
exifInfo: {
|
||||||
fileSizeInByte: 5000,
|
fileSizeInByte: 5000,
|
||||||
profileDescription: 'Adobe RGB',
|
profileDescription: 'Adobe RGB',
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ export const newAssetRepositoryMock = (): Mocked<RepositoryInterface<AssetReposi
|
|||||||
getChangedDeltaSync: vitest.fn(),
|
getChangedDeltaSync: vitest.fn(),
|
||||||
upsertFile: vitest.fn(),
|
upsertFile: vitest.fn(),
|
||||||
upsertFiles: vitest.fn(),
|
upsertFiles: vitest.fn(),
|
||||||
|
deleteFile: vitest.fn(),
|
||||||
deleteFiles: vitest.fn(),
|
deleteFiles: vitest.fn(),
|
||||||
detectOfflineExternalAssets: vitest.fn(),
|
detectOfflineExternalAssets: vitest.fn(),
|
||||||
filterNewExternalAssetPaths: vitest.fn(),
|
filterNewExternalAssetPaths: vitest.fn(),
|
||||||
|
|||||||
@@ -15,7 +15,16 @@ import {
|
|||||||
} from 'src/database';
|
} from 'src/database';
|
||||||
import { MapAsset } from 'src/dtos/asset-response.dto';
|
import { MapAsset } from 'src/dtos/asset-response.dto';
|
||||||
import { AuthDto } from 'src/dtos/auth.dto';
|
import { AuthDto } from 'src/dtos/auth.dto';
|
||||||
import { AssetStatus, AssetType, AssetVisibility, MemoryType, Permission, UserMetadataKey, UserStatus } from 'src/enum';
|
import {
|
||||||
|
AssetFileType,
|
||||||
|
AssetStatus,
|
||||||
|
AssetType,
|
||||||
|
AssetVisibility,
|
||||||
|
MemoryType,
|
||||||
|
Permission,
|
||||||
|
UserMetadataKey,
|
||||||
|
UserStatus,
|
||||||
|
} from 'src/enum';
|
||||||
import { OnThisDayData, UserMetadataItem } from 'src/types';
|
import { OnThisDayData, UserMetadataItem } from 'src/types';
|
||||||
|
|
||||||
export const newUuid = () => randomUUID() as string;
|
export const newUuid = () => randomUUID() as string;
|
||||||
@@ -305,6 +314,13 @@ const assetSidecarWriteFactory = (asset: Partial<SidecarWriteAsset> = {}) => ({
|
|||||||
sidecarPath: '/path/to/original-path.jpg.xmp',
|
sidecarPath: '/path/to/original-path.jpg.xmp',
|
||||||
originalPath: '/path/to/original-path.jpg.xmp',
|
originalPath: '/path/to/original-path.jpg.xmp',
|
||||||
tags: [],
|
tags: [],
|
||||||
|
files: [
|
||||||
|
{
|
||||||
|
id: newUuid(),
|
||||||
|
path: '/path/to/original-path.jpg.xmp',
|
||||||
|
type: AssetFileType.Sidecar,
|
||||||
|
},
|
||||||
|
],
|
||||||
...asset,
|
...asset,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user