refactor: extract isEdited into its own column in asset_file (#25358)

This commit is contained in:
Daniel Dietzler
2026-01-21 09:08:21 -06:00
committed by GitHub
parent 8970566865
commit 1649d87360
18 changed files with 287 additions and 155 deletions

View File

@@ -1,7 +1,15 @@
import { randomUUID } from 'node:crypto'; import { randomUUID } from 'node:crypto';
import { dirname, join, resolve } from 'node:path'; import { dirname, join, resolve } from 'node:path';
import { StorageAsset } from 'src/database'; import { StorageAsset } from 'src/database';
import { AssetFileType, AssetPathType, ImageFormat, PathType, PersonPathType, StorageFolder } from 'src/enum'; import {
AssetFileType,
AssetPathType,
ImageFormat,
PathType,
PersonPathType,
RawExtractedFormat,
StorageFolder,
} from 'src/enum';
import { AssetRepository } from 'src/repositories/asset.repository'; import { AssetRepository } from 'src/repositories/asset.repository';
import { ConfigRepository } from 'src/repositories/config.repository'; import { ConfigRepository } from 'src/repositories/config.repository';
import { CryptoRepository } from 'src/repositories/crypto.repository'; import { CryptoRepository } from 'src/repositories/crypto.repository';
@@ -24,15 +32,6 @@ export interface MoveRequest {
}; };
} }
export type GeneratedImageType =
| AssetPathType.Preview
| AssetPathType.Thumbnail
| AssetPathType.FullSize
| AssetPathType.EditedPreview
| AssetPathType.EditedThumbnail
| AssetPathType.EditedFullSize;
export type GeneratedAssetType = GeneratedImageType | AssetPathType.EncodedVideo;
export type ThumbnailPathEntity = { id: string; ownerId: string }; export type ThumbnailPathEntity = { id: string; ownerId: string };
let instance: StorageCore | null; let instance: StorageCore | null;
@@ -111,8 +110,19 @@ export class StorageCore {
return StorageCore.getNestedPath(StorageFolder.Thumbnails, person.ownerId, `${person.id}.jpeg`); return StorageCore.getNestedPath(StorageFolder.Thumbnails, person.ownerId, `${person.id}.jpeg`);
} }
static getImagePath(asset: ThumbnailPathEntity, type: GeneratedImageType, format: 'jpeg' | 'webp') { static getImagePath(
return StorageCore.getNestedPath(StorageFolder.Thumbnails, asset.ownerId, `${asset.id}-${type}.${format}`); asset: ThumbnailPathEntity,
{
fileType,
format,
isEdited,
}: { fileType: AssetFileType; format: ImageFormat | RawExtractedFormat; isEdited: boolean },
) {
return StorageCore.getNestedPath(
StorageFolder.Thumbnails,
asset.ownerId,
`${asset.id}_${fileType}${isEdited ? '_edited' : ''}.${format}`,
);
} }
static getEncodedVideoPath(asset: ThumbnailPathEntity) { static getEncodedVideoPath(asset: ThumbnailPathEntity) {
@@ -137,14 +147,14 @@ export class StorageCore {
return normalizedPath.startsWith(normalizedAppMediaLocation); return normalizedPath.startsWith(normalizedAppMediaLocation);
} }
async moveAssetImage(asset: StorageAsset, pathType: GeneratedImageType, format: ImageFormat) { async moveAssetImage(asset: StorageAsset, fileType: AssetFileType, format: ImageFormat) {
const { id: entityId, files } = asset; const { id: entityId, files } = asset;
const oldFile = getAssetFile(files, pathType); const oldFile = getAssetFile(files, fileType, { isEdited: false });
return this.moveFile({ return this.moveFile({
entityId, entityId,
pathType, pathType: fileType,
oldPath: oldFile?.path || null, oldPath: oldFile?.path || null,
newPath: StorageCore.getImagePath(asset, pathType, format), newPath: StorageCore.getImagePath(asset, { fileType, format, isEdited: false }),
}); });
} }
@@ -298,19 +308,19 @@ export class StorageCore {
case AssetPathType.Original: { case AssetPathType.Original: {
return this.assetRepository.update({ id, originalPath: newPath }); return this.assetRepository.update({ id, originalPath: newPath });
} }
case AssetPathType.FullSize: { case AssetFileType.FullSize: {
return this.assetRepository.upsertFile({ assetId: id, type: AssetFileType.FullSize, path: newPath }); return this.assetRepository.upsertFile({ assetId: id, type: AssetFileType.FullSize, path: newPath });
} }
case AssetPathType.Preview: { case AssetFileType.Preview: {
return this.assetRepository.upsertFile({ assetId: id, type: AssetFileType.Preview, path: newPath }); return this.assetRepository.upsertFile({ assetId: id, type: AssetFileType.Preview, path: newPath });
} }
case AssetPathType.Thumbnail: { case AssetFileType.Thumbnail: {
return this.assetRepository.upsertFile({ assetId: id, type: AssetFileType.Thumbnail, path: newPath }); return this.assetRepository.upsertFile({ assetId: id, type: AssetFileType.Thumbnail, path: newPath });
} }
case AssetPathType.EncodedVideo: { case AssetPathType.EncodedVideo: {
return this.assetRepository.update({ id, encodedVideoPath: newPath }); return this.assetRepository.update({ id, encodedVideoPath: newPath });
} }
case AssetPathType.Sidecar: { case AssetFileType.Sidecar: {
return this.assetRepository.upsertFile({ assetId: id, type: AssetFileType.Sidecar, path: newPath }); return this.assetRepository.upsertFile({ assetId: id, type: AssetFileType.Sidecar, path: newPath });
} }
case PersonPathType.Face: { case PersonPathType.Face: {

View File

@@ -39,6 +39,7 @@ export type AssetFile = {
id: string; id: string;
type: AssetFileType; type: AssetFileType;
path: string; path: string;
isEdited: boolean;
}; };
export type Library = { export type Library = {
@@ -344,7 +345,7 @@ export const columns = {
'asset.width', 'asset.width',
'asset.height', 'asset.height',
], ],
assetFiles: ['asset_file.id', 'asset_file.path', 'asset_file.type'], assetFiles: ['asset_file.id', 'asset_file.path', 'asset_file.type', 'asset_file.isEdited'],
authUser: ['user.id', 'user.name', 'user.email', 'user.isAdmin', 'user.quotaUsageInBytes', 'user.quotaSizeInBytes'], authUser: ['user.id', 'user.name', 'user.email', 'user.isAdmin', 'user.quotaUsageInBytes', 'user.quotaSizeInBytes'],
authApiKey: ['api_key.id', 'api_key.permissions'], authApiKey: ['api_key.id', 'api_key.permissions'],
authSession: ['session.id', 'session.updatedAt', 'session.pinExpiresAt', 'session.appVersion'], authSession: ['session.id', 'session.updatedAt', 'session.pinExpiresAt', 'session.appVersion'],

View File

@@ -45,9 +45,6 @@ export enum AssetFileType {
Preview = 'preview', Preview = 'preview',
Thumbnail = 'thumbnail', Thumbnail = 'thumbnail',
Sidecar = 'sidecar', Sidecar = 'sidecar',
FullSizeEdited = 'fullsize_edited',
PreviewEdited = 'preview_edited',
ThumbnailEdited = 'thumbnail_edited',
} }
export enum AlbumUserRole { export enum AlbumUserRole {
@@ -369,14 +366,7 @@ export enum ManualJobName {
export enum AssetPathType { export enum AssetPathType {
Original = 'original', Original = 'original',
FullSize = 'fullsize',
Preview = 'preview',
EditedFullSize = 'edited_fullsize',
EditedPreview = 'edited_preview',
EditedThumbnail = 'edited_thumbnail',
Thumbnail = 'thumbnail',
EncodedVideo = 'encoded_video', EncodedVideo = 'encoded_video',
Sidecar = 'sidecar',
} }
export enum PersonPathType { export enum PersonPathType {
@@ -387,7 +377,7 @@ export enum UserPathType {
Profile = 'profile', Profile = 'profile',
} }
export type PathType = AssetPathType | PersonPathType | UserPathType; export type PathType = AssetFileType | AssetPathType | PersonPathType | UserPathType;
export enum TranscodePolicy { export enum TranscodePolicy {
All = 'all', All = 'all',

View File

@@ -29,7 +29,8 @@ select
select select
"asset_file"."id", "asset_file"."id",
"asset_file"."path", "asset_file"."path",
"asset_file"."type" "asset_file"."type",
"asset_file"."isEdited"
from from
"asset_file" "asset_file"
where where
@@ -72,7 +73,8 @@ select
select select
"asset_file"."id", "asset_file"."id",
"asset_file"."path", "asset_file"."path",
"asset_file"."type" "asset_file"."type",
"asset_file"."isEdited"
from from
"asset_file" "asset_file"
where where
@@ -99,7 +101,8 @@ select
select select
"asset_file"."id", "asset_file"."id",
"asset_file"."path", "asset_file"."path",
"asset_file"."type" "asset_file"."type",
"asset_file"."isEdited"
from from
"asset_file" "asset_file"
where where
@@ -145,7 +148,8 @@ select
select select
"asset_file"."id", "asset_file"."id",
"asset_file"."path", "asset_file"."path",
"asset_file"."type" "asset_file"."type",
"asset_file"."isEdited"
from from
"asset_file" "asset_file"
where where
@@ -174,7 +178,8 @@ select
select select
"asset_file"."id", "asset_file"."id",
"asset_file"."path", "asset_file"."path",
"asset_file"."type" "asset_file"."type",
"asset_file"."isEdited"
from from
"asset_file" "asset_file"
where where
@@ -244,7 +249,8 @@ select
select select
"asset_file"."id", "asset_file"."id",
"asset_file"."path", "asset_file"."path",
"asset_file"."type" "asset_file"."type",
"asset_file"."isEdited"
from from
"asset_file" "asset_file"
where where
@@ -269,7 +275,8 @@ where
select select
"asset_file"."id", "asset_file"."id",
"asset_file"."path", "asset_file"."path",
"asset_file"."type" "asset_file"."type",
"asset_file"."isEdited"
from from
"asset_file" "asset_file"
where where
@@ -318,7 +325,8 @@ select
select select
"asset_file"."id", "asset_file"."id",
"asset_file"."path", "asset_file"."path",
"asset_file"."type" "asset_file"."type",
"asset_file"."isEdited"
from from
"asset_file" "asset_file"
where where
@@ -357,7 +365,8 @@ select
select select
"asset_file"."id", "asset_file"."id",
"asset_file"."path", "asset_file"."path",
"asset_file"."type" "asset_file"."type",
"asset_file"."isEdited"
from from
"asset_file" "asset_file"
where where
@@ -444,7 +453,8 @@ select
select select
"asset_file"."id", "asset_file"."id",
"asset_file"."path", "asset_file"."path",
"asset_file"."type" "asset_file"."type",
"asset_file"."isEdited"
from from
"asset_file" "asset_file"
where where
@@ -536,7 +546,8 @@ select
select select
"asset_file"."id", "asset_file"."id",
"asset_file"."path", "asset_file"."path",
"asset_file"."type" "asset_file"."type",
"asset_file"."isEdited"
from from
"asset_file" "asset_file"
where where
@@ -575,7 +586,8 @@ select
select select
"asset_file"."id", "asset_file"."id",
"asset_file"."path", "asset_file"."path",
"asset_file"."type" "asset_file"."type",
"asset_file"."isEdited"
from from
"asset_file" "asset_file"
where where

View File

@@ -286,7 +286,8 @@ select
select select
"asset_file"."id", "asset_file"."id",
"asset_file"."path", "asset_file"."path",
"asset_file"."type" "asset_file"."type",
"asset_file"."isEdited"
from from
"asset_file" "asset_file"
where where

View File

@@ -903,20 +903,22 @@ export class AssetRepository {
.execute(); .execute();
} }
async upsertFile(file: Pick<Insertable<AssetFileTable>, 'assetId' | 'path' | 'type'>): Promise<void> { async upsertFile(file: Pick<Insertable<AssetFileTable>, 'assetId' | 'path' | 'type' | 'isEdited'>): Promise<void> {
const value = { ...file, assetId: asUuid(file.assetId) }; const value = { ...file, assetId: asUuid(file.assetId) };
await this.db await this.db
.insertInto('asset_file') .insertInto('asset_file')
.values(value) .values(value)
.onConflict((oc) => .onConflict((oc) =>
oc.columns(['assetId', 'type']).doUpdateSet((eb) => ({ oc.columns(['assetId', 'type', 'isEdited']).doUpdateSet((eb) => ({
path: eb.ref('excluded.path'), path: eb.ref('excluded.path'),
})), })),
) )
.execute(); .execute();
} }
async upsertFiles(files: Pick<Insertable<AssetFileTable>, 'assetId' | 'path' | 'type'>[]): Promise<void> { async upsertFiles(
files: Pick<Insertable<AssetFileTable>, 'assetId' | 'path' | 'type' | 'isEdited'>[],
): Promise<void> {
if (files.length === 0) { if (files.length === 0) {
return; return;
} }
@@ -926,7 +928,7 @@ export class AssetRepository {
.insertInto('asset_file') .insertInto('asset_file')
.values(values) .values(values)
.onConflict((oc) => .onConflict((oc) =>
oc.columns(['assetId', 'type']).doUpdateSet((eb) => ({ oc.columns(['assetId', 'type', 'isEdited']).doUpdateSet((eb) => ({
path: eb.ref('excluded.path'), path: eb.ref('excluded.path'),
})), })),
) )

View File

@@ -0,0 +1,13 @@
import { Kysely, sql } from 'kysely';
export async function up(db: Kysely<any>): Promise<void> {
await sql`ALTER TABLE "asset_file" DROP CONSTRAINT "asset_file_assetId_type_uq";`.execute(db);
await sql`ALTER TABLE "asset_file" ADD "isEdited" boolean NOT NULL DEFAULT false;`.execute(db);
await sql`ALTER TABLE "asset_file" ADD CONSTRAINT "asset_file_assetId_type_isEdited_uq" UNIQUE ("assetId", "type", "isEdited");`.execute(db);
}
export async function down(db: Kysely<any>): Promise<void> {
await sql`ALTER TABLE "asset_file" DROP CONSTRAINT "asset_file_assetId_type_isEdited_uq";`.execute(db);
await sql`ALTER TABLE "asset_file" ADD CONSTRAINT "asset_file_assetId_type_uq" UNIQUE ("assetId", "type");`.execute(db);
await sql`ALTER TABLE "asset_file" DROP COLUMN "isEdited";`.execute(db);
}

View File

@@ -14,7 +14,7 @@ import {
} from 'src/sql-tools'; } from 'src/sql-tools';
@Table('asset_file') @Table('asset_file')
@Unique({ columns: ['assetId', 'type'] }) @Unique({ columns: ['assetId', 'type', 'isEdited'] })
@UpdatedAtTrigger('asset_file_updatedAt') @UpdatedAtTrigger('asset_file_updatedAt')
export class AssetFileTable { export class AssetFileTable {
@PrimaryGeneratedColumn() @PrimaryGeneratedColumn()
@@ -37,4 +37,7 @@ export class AssetFileTable {
@UpdateIdColumn({ index: true }) @UpdateIdColumn({ index: true })
updateId!: Generated<string>; updateId!: Generated<string>;
@Column({ type: 'boolean', default: false })
isEdited!: Generated<boolean>;
} }

View File

@@ -529,9 +529,10 @@ describe(AssetMediaService.name, () => {
...assetStub.withCropEdit.files, ...assetStub.withCropEdit.files,
{ {
id: 'edited-file', id: 'edited-file',
type: AssetFileType.FullSizeEdited, type: AssetFileType.FullSize,
path: '/uploads/user-id/fullsize/edited.jpg', path: '/uploads/user-id/fullsize/edited.jpg',
} as AssetFile, isEdited: true,
},
], ],
}; };
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1'])); mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1']));
@@ -554,9 +555,10 @@ describe(AssetMediaService.name, () => {
...assetStub.withCropEdit.files, ...assetStub.withCropEdit.files,
{ {
id: 'edited-file', id: 'edited-file',
type: AssetFileType.FullSizeEdited, type: AssetFileType.FullSize,
path: '/uploads/user-id/fullsize/edited.jpg', path: '/uploads/user-id/fullsize/edited.jpg',
} as AssetFile, isEdited: true,
},
], ],
}; };
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1'])); mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1']));
@@ -579,9 +581,10 @@ describe(AssetMediaService.name, () => {
...assetStub.withCropEdit.files, ...assetStub.withCropEdit.files,
{ {
id: 'edited-file', id: 'edited-file',
type: AssetFileType.FullSizeEdited, type: AssetFileType.FullSize,
path: '/uploads/user-id/fullsize/edited.jpg', path: '/uploads/user-id/fullsize/edited.jpg',
} as AssetFile, isEdited: true,
},
], ],
}; };
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1'])); mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1']));
@@ -656,6 +659,7 @@ describe(AssetMediaService.name, () => {
id: '42', id: '42',
path: '/path/to/preview', path: '/path/to/preview',
type: AssetFileType.Thumbnail, type: AssetFileType.Thumbnail,
isEdited: false,
}, },
], ],
}); });
@@ -673,6 +677,7 @@ describe(AssetMediaService.name, () => {
id: '42', id: '42',
path: '/path/to/preview.jpg', path: '/path/to/preview.jpg',
type: AssetFileType.Preview, type: AssetFileType.Preview,
isEdited: false,
}, },
], ],
}); });

View File

@@ -4,7 +4,7 @@ import { DateTime, Duration } from 'luxon';
import { JOBS_ASSET_PAGINATION_SIZE } from 'src/constants'; import { JOBS_ASSET_PAGINATION_SIZE } from 'src/constants';
import { AssetFile } from 'src/database'; import { AssetFile } from 'src/database';
import { OnJob } from 'src/decorators'; import { OnJob } from 'src/decorators';
import { AssetResponseDto, MapAsset, SanitizedAssetResponseDto, mapAsset } from 'src/dtos/asset-response.dto'; import { AssetResponseDto, SanitizedAssetResponseDto, mapAsset } from 'src/dtos/asset-response.dto';
import { import {
AssetBulkDeleteDto, AssetBulkDeleteDto,
AssetBulkUpdateDto, AssetBulkUpdateDto,
@@ -112,7 +112,7 @@ export class AssetService extends BaseService {
const { description, dateTimeOriginal, latitude, longitude, rating, ...rest } = dto; const { description, dateTimeOriginal, latitude, longitude, rating, ...rest } = dto;
const repos = { asset: this.assetRepository, event: this.eventRepository }; const repos = { asset: this.assetRepository, event: this.eventRepository };
let previousMotion: MapAsset | null = null; let previousMotion: { id: string } | null = null;
if (rest.livePhotoVideoId) { if (rest.livePhotoVideoId) {
await onBeforeLink(repos, { userId: auth.user.id, livePhotoVideoId: rest.livePhotoVideoId }); await onBeforeLink(repos, { userId: auth.user.id, livePhotoVideoId: rest.livePhotoVideoId });
} else if (rest.livePhotoVideoId === null) { } else if (rest.livePhotoVideoId === null) {

View File

@@ -241,21 +241,21 @@ describe(MediaService.name, () => {
await expect(sut.handleAssetMigration({ id: assetStub.image.id })).resolves.toBe(JobStatus.Success); await expect(sut.handleAssetMigration({ id: assetStub.image.id })).resolves.toBe(JobStatus.Success);
expect(mocks.move.create).toHaveBeenCalledWith({ expect(mocks.move.create).toHaveBeenCalledWith({
entityId: assetStub.image.id, entityId: assetStub.image.id,
pathType: AssetPathType.FullSize, pathType: AssetFileType.FullSize,
oldPath: '/uploads/user-id/fullsize/path.webp', oldPath: '/uploads/user-id/fullsize/path.webp',
newPath: expect.stringContaining('/data/thumbs/user-id/as/se/asset-id-fullsize.jpeg'), newPath: expect.stringContaining('/data/thumbs/user-id/as/se/asset-id_fullsize.jpeg'),
}); });
expect(mocks.move.create).toHaveBeenCalledWith({ expect(mocks.move.create).toHaveBeenCalledWith({
entityId: assetStub.image.id, entityId: assetStub.image.id,
pathType: AssetPathType.Preview, pathType: AssetFileType.Preview,
oldPath: '/uploads/user-id/thumbs/path.jpg', oldPath: '/uploads/user-id/thumbs/path.jpg',
newPath: expect.stringContaining('/data/thumbs/user-id/as/se/asset-id-preview.jpeg'), newPath: expect.stringContaining('/data/thumbs/user-id/as/se/asset-id_preview.jpeg'),
}); });
expect(mocks.move.create).toHaveBeenCalledWith({ expect(mocks.move.create).toHaveBeenCalledWith({
entityId: assetStub.image.id, entityId: assetStub.image.id,
pathType: AssetPathType.Thumbnail, pathType: AssetFileType.Thumbnail,
oldPath: '/uploads/user-id/webp/path.ext', oldPath: '/uploads/user-id/webp/path.ext',
newPath: expect.stringContaining('/data/thumbs/user-id/as/se/asset-id-thumbnail.webp'), newPath: expect.stringContaining('/data/thumbs/user-id/as/se/asset-id_thumbnail.webp'),
}); });
expect(mocks.move.create).toHaveBeenCalledTimes(3); expect(mocks.move.create).toHaveBeenCalledTimes(3);
}); });
@@ -385,11 +385,13 @@ describe(MediaService.name, () => {
assetId: 'asset-id', assetId: 'asset-id',
type: AssetFileType.Preview, type: AssetFileType.Preview,
path: expect.any(String), path: expect.any(String),
isEdited: false,
}, },
{ {
assetId: 'asset-id', assetId: 'asset-id',
type: AssetFileType.Thumbnail, type: AssetFileType.Thumbnail,
path: expect.any(String), path: expect.any(String),
isEdited: false,
}, },
]); ]);
expect(mocks.asset.update).toHaveBeenCalledWith({ id: 'asset-id', thumbhash: thumbhashBuffer }); expect(mocks.asset.update).toHaveBeenCalledWith({ id: 'asset-id', thumbhash: thumbhashBuffer });
@@ -421,11 +423,13 @@ describe(MediaService.name, () => {
assetId: 'asset-id', assetId: 'asset-id',
type: AssetFileType.Preview, type: AssetFileType.Preview,
path: expect.any(String), path: expect.any(String),
isEdited: false,
}, },
{ {
assetId: 'asset-id', assetId: 'asset-id',
type: AssetFileType.Thumbnail, type: AssetFileType.Thumbnail,
path: expect.any(String), path: expect.any(String),
isEdited: false,
}, },
]); ]);
}); });
@@ -456,11 +460,13 @@ describe(MediaService.name, () => {
assetId: 'asset-id', assetId: 'asset-id',
type: AssetFileType.Preview, type: AssetFileType.Preview,
path: expect.any(String), path: expect.any(String),
isEdited: false,
}, },
{ {
assetId: 'asset-id', assetId: 'asset-id',
type: AssetFileType.Thumbnail, type: AssetFileType.Thumbnail,
path: expect.any(String), path: expect.any(String),
isEdited: false,
}, },
]); ]);
}); });
@@ -548,8 +554,8 @@ describe(MediaService.name, () => {
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(assetStub.image); mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(assetStub.image);
const thumbhashBuffer = Buffer.from('a thumbhash', 'utf8'); const thumbhashBuffer = Buffer.from('a thumbhash', 'utf8');
mocks.media.generateThumbhash.mockResolvedValue(thumbhashBuffer); mocks.media.generateThumbhash.mockResolvedValue(thumbhashBuffer);
const previewPath = `/data/thumbs/user-id/as/se/asset-id-preview.${format}`; const previewPath = `/data/thumbs/user-id/as/se/asset-id_preview.${format}`;
const thumbnailPath = `/data/thumbs/user-id/as/se/asset-id-thumbnail.webp`; const thumbnailPath = `/data/thumbs/user-id/as/se/asset-id_thumbnail.webp`;
await sut.handleGenerateThumbnails({ id: assetStub.image.id }); await sut.handleGenerateThumbnails({ id: assetStub.image.id });
@@ -595,8 +601,8 @@ describe(MediaService.name, () => {
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(assetStub.image); mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(assetStub.image);
const thumbhashBuffer = Buffer.from('a thumbhash', 'utf8'); const thumbhashBuffer = Buffer.from('a thumbhash', 'utf8');
mocks.media.generateThumbhash.mockResolvedValue(thumbhashBuffer); mocks.media.generateThumbhash.mockResolvedValue(thumbhashBuffer);
const previewPath = expect.stringContaining(`/data/thumbs/user-id/as/se/asset-id-preview.jpeg`); const previewPath = expect.stringContaining(`/data/thumbs/user-id/as/se/asset-id_preview.jpeg`);
const thumbnailPath = expect.stringContaining(`/data/thumbs/user-id/as/se/asset-id-thumbnail.${format}`); const thumbnailPath = expect.stringContaining(`/data/thumbs/user-id/as/se/asset-id_thumbnail.${format}`);
await sut.handleGenerateThumbnails({ id: assetStub.image.id }); await sut.handleGenerateThumbnails({ id: assetStub.image.id });
@@ -1026,9 +1032,9 @@ describe(MediaService.name, () => {
expect(mocks.asset.upsertFiles).toHaveBeenCalledWith( expect(mocks.asset.upsertFiles).toHaveBeenCalledWith(
expect.arrayContaining([ expect.arrayContaining([
expect.objectContaining({ type: AssetFileType.FullSizeEdited }), expect.objectContaining({ type: AssetFileType.FullSize, isEdited: true }),
expect.objectContaining({ type: AssetFileType.PreviewEdited }), expect.objectContaining({ type: AssetFileType.Preview, isEdited: true }),
expect.objectContaining({ type: AssetFileType.ThumbnailEdited }), expect.objectContaining({ type: AssetFileType.Thumbnail, isEdited: true }),
]), ]),
); );
}); });
@@ -1098,17 +1104,17 @@ describe(MediaService.name, () => {
expect(mocks.media.generateThumbnail).toHaveBeenCalledWith( expect(mocks.media.generateThumbnail).toHaveBeenCalledWith(
rawBuffer, rawBuffer,
expect.anything(), expect.anything(),
expect.stringContaining('edited_preview.jpeg'), expect.stringContaining('preview_edited.jpeg'),
); );
expect(mocks.media.generateThumbnail).toHaveBeenCalledWith( expect(mocks.media.generateThumbnail).toHaveBeenCalledWith(
rawBuffer, rawBuffer,
expect.anything(), expect.anything(),
expect.stringContaining('edited_thumbnail.webp'), expect.stringContaining('thumbnail_edited.webp'),
); );
expect(mocks.media.generateThumbnail).toHaveBeenCalledWith( expect(mocks.media.generateThumbnail).toHaveBeenCalledWith(
rawBuffer, rawBuffer,
expect.anything(), expect.anything(),
expect.stringContaining('edited_fullsize.jpeg'), expect.stringContaining('fullsize_edited.jpeg'),
); );
}); });
@@ -3254,13 +3260,13 @@ describe(MediaService.name, () => {
}; };
await sut['syncFiles'](asset, [ await sut['syncFiles'](asset, [
{ type: AssetFileType.Preview, newPath: '/new/preview.jpg' }, { type: AssetFileType.Preview, newPath: '/new/preview.jpg', isEdited: false },
{ type: AssetFileType.Thumbnail, newPath: '/new/thumbnail.jpg' }, { type: AssetFileType.Thumbnail, newPath: '/new/thumbnail.jpg', isEdited: false },
]); ]);
expect(mocks.asset.upsertFiles).toHaveBeenCalledWith([ expect(mocks.asset.upsertFiles).toHaveBeenCalledWith([
{ assetId: 'asset-id', path: '/new/preview.jpg', type: AssetFileType.Preview }, { assetId: 'asset-id', path: '/new/preview.jpg', type: AssetFileType.Preview, isEdited: false },
{ assetId: 'asset-id', path: '/new/thumbnail.jpg', type: AssetFileType.Thumbnail }, { assetId: 'asset-id', path: '/new/thumbnail.jpg', type: AssetFileType.Thumbnail, isEdited: false },
]); ]);
expect(mocks.asset.deleteFiles).not.toHaveBeenCalled(); expect(mocks.asset.deleteFiles).not.toHaveBeenCalled();
expect(mocks.job.queue).not.toHaveBeenCalled(); expect(mocks.job.queue).not.toHaveBeenCalled();
@@ -3270,19 +3276,31 @@ describe(MediaService.name, () => {
const asset = { const asset = {
id: 'asset-id', id: 'asset-id',
files: [ files: [
{ id: 'file-1', assetId: 'asset-id', type: AssetFileType.Preview, path: '/old/preview.jpg' }, {
{ id: 'file-2', assetId: 'asset-id', type: AssetFileType.Thumbnail, path: '/old/thumbnail.jpg' }, id: 'file-1',
assetId: 'asset-id',
type: AssetFileType.Preview,
path: '/old/preview.jpg',
isEdited: false,
},
{
id: 'file-2',
assetId: 'asset-id',
type: AssetFileType.Thumbnail,
path: '/old/thumbnail.jpg',
isEdited: false,
},
], ],
}; };
await sut['syncFiles'](asset, [ await sut['syncFiles'](asset, [
{ type: AssetFileType.Preview, newPath: '/new/preview.jpg' }, { type: AssetFileType.Preview, newPath: '/new/preview.jpg', isEdited: false },
{ type: AssetFileType.Thumbnail, newPath: '/new/thumbnail.jpg' }, { type: AssetFileType.Thumbnail, newPath: '/new/thumbnail.jpg', isEdited: false },
]); ]);
expect(mocks.asset.upsertFiles).toHaveBeenCalledWith([ expect(mocks.asset.upsertFiles).toHaveBeenCalledWith([
{ assetId: 'asset-id', path: '/new/preview.jpg', type: AssetFileType.Preview }, { assetId: 'asset-id', path: '/new/preview.jpg', type: AssetFileType.Preview, isEdited: false },
{ assetId: 'asset-id', path: '/new/thumbnail.jpg', type: AssetFileType.Thumbnail }, { assetId: 'asset-id', path: '/new/thumbnail.jpg', type: AssetFileType.Thumbnail, isEdited: false },
]); ]);
expect(mocks.asset.deleteFiles).not.toHaveBeenCalled(); expect(mocks.asset.deleteFiles).not.toHaveBeenCalled();
expect(mocks.job.queue).toHaveBeenCalledWith({ expect(mocks.job.queue).toHaveBeenCalledWith({
@@ -3295,17 +3313,38 @@ describe(MediaService.name, () => {
const asset = { const asset = {
id: 'asset-id', id: 'asset-id',
files: [ files: [
{ id: 'file-1', assetId: 'asset-id', type: AssetFileType.Preview, path: '/old/preview.jpg' }, {
{ id: 'file-2', assetId: 'asset-id', type: AssetFileType.Thumbnail, path: '/old/thumbnail.jpg' }, id: 'file-1',
assetId: 'asset-id',
type: AssetFileType.Preview,
path: '/old/preview.jpg',
isEdited: false,
},
{
id: 'file-2',
assetId: 'asset-id',
type: AssetFileType.Thumbnail,
path: '/old/thumbnail.jpg',
isEdited: false,
},
], ],
}; };
await sut['syncFiles'](asset, [{ type: AssetFileType.Preview }, { type: AssetFileType.Thumbnail }]); await sut['syncFiles'](asset, [
{ type: AssetFileType.Preview, isEdited: false },
{ type: AssetFileType.Thumbnail, isEdited: false },
]);
expect(mocks.asset.upsertFiles).not.toHaveBeenCalled(); expect(mocks.asset.upsertFiles).not.toHaveBeenCalled();
expect(mocks.asset.deleteFiles).toHaveBeenCalledWith([ expect(mocks.asset.deleteFiles).toHaveBeenCalledWith([
{ id: 'file-1', assetId: 'asset-id', type: AssetFileType.Preview, path: '/old/preview.jpg' }, { id: 'file-1', assetId: 'asset-id', type: AssetFileType.Preview, path: '/old/preview.jpg', isEdited: false },
{ id: 'file-2', assetId: 'asset-id', type: AssetFileType.Thumbnail, path: '/old/thumbnail.jpg' }, {
id: 'file-2',
assetId: 'asset-id',
type: AssetFileType.Thumbnail,
path: '/old/thumbnail.jpg',
isEdited: false,
},
]); ]);
expect(mocks.job.queue).toHaveBeenCalledWith({ expect(mocks.job.queue).toHaveBeenCalledWith({
name: JobName.FileDelete, name: JobName.FileDelete,
@@ -3317,14 +3356,26 @@ describe(MediaService.name, () => {
const asset = { const asset = {
id: 'asset-id', id: 'asset-id',
files: [ files: [
{ id: 'file-1', assetId: 'asset-id', type: AssetFileType.Preview, path: '/same/preview.jpg' }, {
{ id: 'file-2', assetId: 'asset-id', type: AssetFileType.Thumbnail, path: '/same/thumbnail.jpg' }, id: 'file-1',
assetId: 'asset-id',
type: AssetFileType.Preview,
path: '/same/preview.jpg',
isEdited: false,
},
{
id: 'file-2',
assetId: 'asset-id',
type: AssetFileType.Thumbnail,
path: '/same/thumbnail.jpg',
isEdited: false,
},
], ],
}; };
await sut['syncFiles'](asset, [ await sut['syncFiles'](asset, [
{ type: AssetFileType.Preview, newPath: '/same/preview.jpg' }, { type: AssetFileType.Preview, newPath: '/same/preview.jpg', isEdited: false },
{ type: AssetFileType.Thumbnail, newPath: '/same/thumbnail.jpg' }, { type: AssetFileType.Thumbnail, newPath: '/same/thumbnail.jpg', isEdited: false },
]); ]);
expect(mocks.asset.upsertFiles).not.toHaveBeenCalled(); expect(mocks.asset.upsertFiles).not.toHaveBeenCalled();
@@ -3336,23 +3387,41 @@ describe(MediaService.name, () => {
const asset = { const asset = {
id: 'asset-id', id: 'asset-id',
files: [ files: [
{ id: 'file-1', assetId: 'asset-id', type: AssetFileType.Preview, path: '/old/preview.jpg' }, {
{ id: 'file-2', assetId: 'asset-id', type: AssetFileType.Thumbnail, path: '/old/thumbnail.jpg' }, id: 'file-1',
assetId: 'asset-id',
type: AssetFileType.Preview,
path: '/old/preview.jpg',
isEdited: false,
},
{
id: 'file-2',
assetId: 'asset-id',
type: AssetFileType.Thumbnail,
path: '/old/thumbnail.jpg',
isEdited: false,
},
], ],
}; };
await sut['syncFiles'](asset, [ await sut['syncFiles'](asset, [
{ type: AssetFileType.Preview, newPath: '/new/preview.jpg' }, // replace { type: AssetFileType.Preview, newPath: '/new/preview.jpg', isEdited: false }, // replace
{ type: AssetFileType.Thumbnail }, // delete { type: AssetFileType.Thumbnail, isEdited: false }, // delete
{ type: AssetFileType.FullSize, newPath: '/new/fullsize.jpg' }, // new { type: AssetFileType.FullSize, newPath: '/new/fullsize.jpg', isEdited: false }, // new
]); ]);
expect(mocks.asset.upsertFiles).toHaveBeenCalledWith([ expect(mocks.asset.upsertFiles).toHaveBeenCalledWith([
{ assetId: 'asset-id', path: '/new/preview.jpg', type: AssetFileType.Preview }, { assetId: 'asset-id', path: '/new/preview.jpg', type: AssetFileType.Preview, isEdited: false },
{ assetId: 'asset-id', path: '/new/fullsize.jpg', type: AssetFileType.FullSize }, { assetId: 'asset-id', path: '/new/fullsize.jpg', type: AssetFileType.FullSize, isEdited: false },
]); ]);
expect(mocks.asset.deleteFiles).toHaveBeenCalledWith([ expect(mocks.asset.deleteFiles).toHaveBeenCalledWith([
{ id: 'file-2', assetId: 'asset-id', type: AssetFileType.Thumbnail, path: '/old/thumbnail.jpg' }, {
id: 'file-2',
assetId: 'asset-id',
type: AssetFileType.Thumbnail,
path: '/old/thumbnail.jpg',
isEdited: false,
},
]); ]);
expect(mocks.job.queue).toHaveBeenCalledWith({ expect(mocks.job.queue).toHaveBeenCalledWith({
name: JobName.FileDelete, name: JobName.FileDelete,
@@ -3376,11 +3445,19 @@ describe(MediaService.name, () => {
it('should delete non-existent file types when newPath is not provided', async () => { it('should delete non-existent file types when newPath is not provided', async () => {
const asset = { const asset = {
id: 'asset-id', id: 'asset-id',
files: [{ id: 'file-1', assetId: 'asset-id', type: AssetFileType.Preview, path: '/old/preview.jpg' }], files: [
{
id: 'file-1',
assetId: 'asset-id',
type: AssetFileType.Preview,
path: '/old/preview.jpg',
isEdited: false,
},
],
}; };
await sut['syncFiles'](asset, [ await sut['syncFiles'](asset, [
{ type: AssetFileType.Thumbnail }, // file doesn't exist, newPath not provided { type: AssetFileType.Thumbnail, isEdited: false }, // file doesn't exist, newPath not provided
]); ]);
expect(mocks.asset.upsertFiles).not.toHaveBeenCalled(); expect(mocks.asset.upsertFiles).not.toHaveBeenCalled();

View File

@@ -8,7 +8,6 @@ import { AssetEditAction, CropParameters } from 'src/dtos/editing.dto';
import { SystemConfigFFmpegDto } from 'src/dtos/system-config.dto'; import { SystemConfigFFmpegDto } from 'src/dtos/system-config.dto';
import { import {
AssetFileType, AssetFileType,
AssetPathType,
AssetType, AssetType,
AssetVisibility, AssetVisibility,
AudioCodec, AudioCodec,
@@ -50,6 +49,7 @@ interface UpsertFileOptions {
assetId: string; assetId: string;
type: AssetFileType; type: AssetFileType;
path: string; path: string;
isEdited: boolean;
} }
type ThumbnailAsset = NonNullable<Awaited<ReturnType<AssetJobRepository['getForGenerateThumbnailJob']>>>; type ThumbnailAsset = NonNullable<Awaited<ReturnType<AssetJobRepository['getForGenerateThumbnailJob']>>>;
@@ -160,9 +160,9 @@ export class MediaService extends BaseService {
return JobStatus.Failed; return JobStatus.Failed;
} }
await this.storageCore.moveAssetImage(asset, AssetPathType.FullSize, image.fullsize.format); await this.storageCore.moveAssetImage(asset, AssetFileType.FullSize, image.fullsize.format);
await this.storageCore.moveAssetImage(asset, AssetPathType.Preview, image.preview.format); await this.storageCore.moveAssetImage(asset, AssetFileType.Preview, image.preview.format);
await this.storageCore.moveAssetImage(asset, AssetPathType.Thumbnail, image.thumbnail.format); await this.storageCore.moveAssetImage(asset, AssetFileType.Thumbnail, image.thumbnail.format);
await this.storageCore.moveAssetVideo(asset); await this.storageCore.moveAssetVideo(asset);
return JobStatus.Success; return JobStatus.Success;
@@ -236,9 +236,9 @@ export class MediaService extends BaseService {
} }
await this.syncFiles(asset, [ await this.syncFiles(asset, [
{ type: AssetFileType.Preview, newPath: generated.previewPath }, { type: AssetFileType.Preview, newPath: generated.previewPath, isEdited: false },
{ type: AssetFileType.Thumbnail, newPath: generated.thumbnailPath }, { type: AssetFileType.Thumbnail, newPath: generated.thumbnailPath, isEdited: false },
{ type: AssetFileType.FullSize, newPath: generated.fullsizePath }, { type: AssetFileType.FullSize, newPath: generated.fullsizePath, isEdited: false },
]); ]);
const editiedGenerated = await this.generateEditedThumbnails(asset); const editiedGenerated = await this.generateEditedThumbnails(asset);
@@ -307,16 +307,16 @@ export class MediaService extends BaseService {
private async generateImageThumbnails(asset: ThumbnailAsset, useEdits: boolean = false) { private async generateImageThumbnails(asset: ThumbnailAsset, useEdits: boolean = false) {
const { image } = await this.getConfig({ withCache: true }); const { image } = await this.getConfig({ withCache: true });
const previewPath = StorageCore.getImagePath( const previewPath = StorageCore.getImagePath(asset, {
asset, fileType: AssetFileType.Preview,
useEdits ? AssetPathType.EditedPreview : AssetPathType.Preview, isEdited: useEdits,
image.preview.format, format: image.preview.format,
); });
const thumbnailPath = StorageCore.getImagePath( const thumbnailPath = StorageCore.getImagePath(asset, {
asset, fileType: AssetFileType.Thumbnail,
useEdits ? AssetPathType.EditedThumbnail : AssetPathType.Thumbnail, isEdited: useEdits,
image.thumbnail.format, format: image.thumbnail.format,
); });
this.storageCore.ensureFolders(previewPath); this.storageCore.ensureFolders(previewPath);
// Handle embedded preview extraction for RAW files // Handle embedded preview extraction for RAW files
@@ -343,11 +343,11 @@ export class MediaService extends BaseService {
if (convertFullsize) { if (convertFullsize) {
// convert a new fullsize image from the same source as the thumbnail // convert a new fullsize image from the same source as the thumbnail
fullsizePath = StorageCore.getImagePath( fullsizePath = StorageCore.getImagePath(asset, {
asset, fileType: AssetFileType.FullSize,
useEdits ? AssetPathType.EditedFullSize : AssetPathType.FullSize, isEdited: useEdits,
image.fullsize.format, format: image.fullsize.format,
); });
const fullsizeOptions = { const fullsizeOptions = {
format: image.fullsize.format, format: image.fullsize.format,
quality: image.fullsize.quality, quality: image.fullsize.quality,
@@ -355,7 +355,11 @@ export class MediaService extends BaseService {
}; };
promises.push(this.mediaRepository.generateThumbnail(data, fullsizeOptions, fullsizePath)); promises.push(this.mediaRepository.generateThumbnail(data, fullsizeOptions, fullsizePath));
} else if (generateFullsize && extracted && extracted.format === RawExtractedFormat.Jpeg) { } else if (generateFullsize && extracted && extracted.format === RawExtractedFormat.Jpeg) {
fullsizePath = StorageCore.getImagePath(asset, AssetPathType.FullSize, extracted.format); fullsizePath = StorageCore.getImagePath(asset, {
fileType: AssetFileType.FullSize,
format: extracted.format,
isEdited: false,
});
this.storageCore.ensureFolders(fullsizePath); this.storageCore.ensureFolders(fullsizePath);
// Write the buffer to disk with essential EXIF data // Write the buffer to disk with essential EXIF data
@@ -489,8 +493,16 @@ 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, {
const thumbnailPath = StorageCore.getImagePath(asset, AssetPathType.Thumbnail, image.thumbnail.format); fileType: AssetFileType.Preview,
format: image.preview.format,
isEdited: false,
});
const thumbnailPath = StorageCore.getImagePath(asset, {
fileType: AssetFileType.Thumbnail,
format: image.thumbnail.format,
isEdited: false,
});
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(asset.originalPath);
@@ -779,18 +791,18 @@ export class MediaService extends BaseService {
private async syncFiles( private async syncFiles(
asset: { id: string; files: AssetFile[] }, asset: { id: string; files: AssetFile[] },
files: { type: AssetFileType; newPath?: string }[], files: { type: AssetFileType; newPath?: string; isEdited: boolean }[],
) { ) {
const toUpsert: UpsertFileOptions[] = []; const toUpsert: UpsertFileOptions[] = [];
const pathsToDelete: string[] = []; const pathsToDelete: string[] = [];
const toDelete: AssetFile[] = []; const toDelete: AssetFile[] = [];
for (const { type, newPath } of files) { for (const { type, newPath, isEdited } of files) {
const existingFile = asset.files.find((file) => file.type === type); const existingFile = asset.files.find((file) => file.type === type && file.isEdited === isEdited);
// upsert new file path // upsert new file path
if (newPath && existingFile?.path !== newPath) { if (newPath && existingFile?.path !== newPath) {
toUpsert.push({ assetId: asset.id, path: newPath, type }); toUpsert.push({ assetId: asset.id, path: newPath, type, isEdited });
// delete old file from disk // delete old file from disk
if (existingFile) { if (existingFile) {
@@ -829,9 +841,9 @@ export class MediaService extends BaseService {
const generated = asset.edits.length > 0 ? await this.generateImageThumbnails(asset, true) : undefined; const generated = asset.edits.length > 0 ? await this.generateImageThumbnails(asset, true) : undefined;
await this.syncFiles(asset, [ await this.syncFiles(asset, [
{ type: AssetFileType.PreviewEdited, newPath: generated?.previewPath }, { type: AssetFileType.Preview, newPath: generated?.previewPath, isEdited: true },
{ type: AssetFileType.ThumbnailEdited, newPath: generated?.thumbnailPath }, { type: AssetFileType.Thumbnail, newPath: generated?.thumbnailPath, isEdited: true },
{ type: AssetFileType.FullSizeEdited, newPath: generated?.fullsizePath }, { type: AssetFileType.FullSize, newPath: generated?.fullsizePath, isEdited: true },
]); ]);
const crop = asset.edits.find((e) => e.action === AssetEditAction.Crop); const crop = asset.edits.find((e) => e.action === AssetEditAction.Crop);

View File

@@ -35,7 +35,7 @@ const forSidecarJob = (
asset: { asset: {
id?: string; id?: string;
originalPath?: string; originalPath?: string;
files?: { id: string; type: AssetFileType; path: string }[]; files?: { id: string; type: AssetFileType; path: string; isEdited: boolean }[];
} = {}, } = {},
) => { ) => {
return { return {
@@ -1084,6 +1084,7 @@ describe(MetadataService.name, () => {
id: 'some-id', id: 'some-id',
type: AssetFileType.Sidecar, type: AssetFileType.Sidecar,
path: '/path/to/something', path: '/path/to/something',
isEdited: false,
}, },
], ],
}); });
@@ -1691,7 +1692,7 @@ describe(MetadataService.name, () => {
it('should unset sidecar path if file no longer exist', async () => { it('should unset sidecar path if file no longer exist', async () => {
const asset = forSidecarJob({ const asset = forSidecarJob({
originalPath: '/path/to/IMG_123.jpg', originalPath: '/path/to/IMG_123.jpg',
files: [{ id: 'sidecar', path: '/path/to/IMG_123.jpg.xmp', type: AssetFileType.Sidecar }], files: [{ id: 'sidecar', path: '/path/to/IMG_123.jpg.xmp', type: AssetFileType.Sidecar, isEdited: false }],
}); });
mocks.assetJob.getForSidecarCheckJob.mockResolvedValue(asset); mocks.assetJob.getForSidecarCheckJob.mockResolvedValue(asset);
mocks.storage.checkFileExists.mockResolvedValue(false); mocks.storage.checkFileExists.mockResolvedValue(false);
@@ -1704,7 +1705,7 @@ describe(MetadataService.name, () => {
it('should do nothing if the sidecar file still exists', async () => { it('should do nothing if the sidecar file still exists', async () => {
const asset = forSidecarJob({ const asset = forSidecarJob({
originalPath: '/path/to/IMG_123.jpg', originalPath: '/path/to/IMG_123.jpg',
files: [{ id: 'sidecar', path: '/path/to/IMG_123.jpg.xmp', type: AssetFileType.Sidecar }], files: [{ id: 'sidecar', path: '/path/to/IMG_123.jpg.xmp', type: AssetFileType.Sidecar, isEdited: false }],
}); });
mocks.assetJob.getForSidecarCheckJob.mockResolvedValue(asset); mocks.assetJob.getForSidecarCheckJob.mockResolvedValue(asset);

View File

@@ -372,7 +372,7 @@ describe(NotificationService.name, () => {
mocks.notification.create.mockResolvedValue(notificationStub.albumEvent); mocks.notification.create.mockResolvedValue(notificationStub.albumEvent);
mocks.email.renderEmail.mockResolvedValue({ html: '', text: '' }); mocks.email.renderEmail.mockResolvedValue({ html: '', text: '' });
mocks.assetJob.getAlbumThumbnailFiles.mockResolvedValue([ mocks.assetJob.getAlbumThumbnailFiles.mockResolvedValue([
{ id: '1', type: AssetFileType.Thumbnail, path: 'path-to-thumb.jpg' }, { id: '1', type: AssetFileType.Thumbnail, path: 'path-to-thumb.jpg', isEdited: false },
]); ]);
await expect(sut.handleAlbumInvite({ id: '', recipientId: '' })).resolves.toBe(JobStatus.Success); await expect(sut.handleAlbumInvite({ id: '', recipientId: '' })).resolves.toBe(JobStatus.Success);
@@ -403,7 +403,7 @@ describe(NotificationService.name, () => {
mocks.systemMetadata.get.mockResolvedValue({ server: {} }); mocks.systemMetadata.get.mockResolvedValue({ server: {} });
mocks.notification.create.mockResolvedValue(notificationStub.albumEvent); mocks.notification.create.mockResolvedValue(notificationStub.albumEvent);
mocks.email.renderEmail.mockResolvedValue({ html: '', text: '' }); mocks.email.renderEmail.mockResolvedValue({ html: '', text: '' });
mocks.assetJob.getAlbumThumbnailFiles.mockResolvedValue([assetStub.image.files[2]]); mocks.assetJob.getAlbumThumbnailFiles.mockResolvedValue([{ ...assetStub.image.files[2], isEdited: false }]);
await expect(sut.handleAlbumInvite({ id: '', recipientId: '' })).resolves.toBe(JobStatus.Success); await expect(sut.handleAlbumInvite({ id: '', recipientId: '' })).resolves.toBe(JobStatus.Success);
expect(mocks.assetJob.getAlbumThumbnailFiles).toHaveBeenCalledWith( expect(mocks.assetJob.getAlbumThumbnailFiles).toHaveBeenCalledWith(

View File

@@ -240,11 +240,11 @@ export class StorageTemplateService extends BaseService {
assetInfo: { sizeInBytes: fileSizeInByte, checksum }, assetInfo: { sizeInBytes: fileSizeInByte, checksum },
}); });
const sidecarPath = getAssetFile(asset.files, AssetFileType.Sidecar)?.path; const sidecarPath = getAssetFile(asset.files, AssetFileType.Sidecar, { isEdited: false })?.path;
if (sidecarPath) { if (sidecarPath) {
await this.storageCore.moveFile({ await this.storageCore.moveFile({
entityId: id, entityId: id,
pathType: AssetPathType.Sidecar, pathType: AssetFileType.Sidecar,
oldPath: sidecarPath, oldPath: sidecarPath,
newPath: `${newPath}.xmp`, newPath: `${newPath}.xmp`,
}); });

View File

@@ -1,5 +1,5 @@
import { BadRequestException } from '@nestjs/common'; import { BadRequestException } from '@nestjs/common';
import { GeneratedImageType, StorageCore } from 'src/cores/storage.core'; import { StorageCore } from 'src/cores/storage.core';
import { AssetFile, Exif } from 'src/database'; import { AssetFile, Exif } from 'src/database';
import { BulkIdErrorReason, BulkIdResponseDto } from 'src/dtos/asset-ids.response.dto'; import { BulkIdErrorReason, BulkIdResponseDto } from 'src/dtos/asset-ids.response.dto';
import { UploadFieldName } from 'src/dtos/asset-media.dto'; import { UploadFieldName } from 'src/dtos/asset-media.dto';
@@ -14,19 +14,19 @@ import { PartnerRepository } from 'src/repositories/partner.repository';
import { IBulkAsset, ImmichFile, UploadFile, UploadRequest } from 'src/types'; import { IBulkAsset, ImmichFile, UploadFile, UploadRequest } from 'src/types';
import { checkAccess } from 'src/utils/access'; import { checkAccess } from 'src/utils/access';
export const getAssetFile = (files: AssetFile[], type: AssetFileType | GeneratedImageType) => { export const getAssetFile = (files: AssetFile[], type: AssetFileType, { isEdited }: { isEdited: boolean }) => {
return files.find((file) => file.type === type); return files.find((file) => file.type === type && file.isEdited === isEdited);
}; };
export const getAssetFiles = (files: AssetFile[]) => ({ export const getAssetFiles = (files: AssetFile[]) => ({
fullsizeFile: getAssetFile(files, AssetFileType.FullSize), fullsizeFile: getAssetFile(files, AssetFileType.FullSize, { isEdited: false }),
previewFile: getAssetFile(files, AssetFileType.Preview), previewFile: getAssetFile(files, AssetFileType.Preview, { isEdited: false }),
thumbnailFile: getAssetFile(files, AssetFileType.Thumbnail), thumbnailFile: getAssetFile(files, AssetFileType.Thumbnail, { isEdited: false }),
sidecarFile: getAssetFile(files, AssetFileType.Sidecar), sidecarFile: getAssetFile(files, AssetFileType.Sidecar, { isEdited: false }),
editedFullsizeFile: getAssetFile(files, AssetFileType.FullSizeEdited), editedFullsizeFile: getAssetFile(files, AssetFileType.FullSize, { isEdited: true }),
editedPreviewFile: getAssetFile(files, AssetFileType.PreviewEdited), editedPreviewFile: getAssetFile(files, AssetFileType.Preview, { isEdited: true }),
editedThumbnailFile: getAssetFile(files, AssetFileType.ThumbnailEdited), editedThumbnailFile: getAssetFile(files, AssetFileType.Preview, { isEdited: true }),
}); });
export const addAssets = async ( export const addAssets = async (

View File

@@ -31,18 +31,21 @@ const sidecarFileWithoutExt = factory.assetFile({
}); });
const editedPreviewFile = factory.assetFile({ const editedPreviewFile = factory.assetFile({
type: AssetFileType.PreviewEdited, type: AssetFileType.Preview,
path: '/uploads/user-id/preview/path_edited.jpg', path: '/uploads/user-id/preview/path_edited.jpg',
isEdited: true,
}); });
const editedThumbnailFile = factory.assetFile({ const editedThumbnailFile = factory.assetFile({
type: AssetFileType.ThumbnailEdited, type: AssetFileType.Thumbnail,
path: '/uploads/user-id/thumbnail/path_edited.jpg', path: '/uploads/user-id/thumbnail/path_edited.jpg',
isEdited: true,
}); });
const editedFullsizeFile = factory.assetFile({ const editedFullsizeFile = factory.assetFile({
type: AssetFileType.FullSizeEdited, type: AssetFileType.FullSize,
path: '/uploads/user-id/fullsize/path_edited.jpg', path: '/uploads/user-id/fullsize/path_edited.jpg',
isEdited: true,
}); });
const files: AssetFile[] = [fullsizeFile, previewFile, thumbnailFile]; const files: AssetFile[] = [fullsizeFile, previewFile, thumbnailFile];

View File

@@ -335,6 +335,7 @@ const assetSidecarWriteFactory = () => {
id: newUuid(), id: newUuid(),
path: '/path/to/original-path.jpg.xmp', path: '/path/to/original-path.jpg.xmp',
type: AssetFileType.Sidecar, type: AssetFileType.Sidecar,
isEdited: false,
}, },
], ],
exifInfo: { exifInfo: {
@@ -386,6 +387,7 @@ const assetFileFactory = (file: Partial<AssetFile> = {}): AssetFile => ({
id: newUuid(), id: newUuid(),
type: AssetFileType.Preview, type: AssetFileType.Preview,
path: '/uploads/user-id/thumbs/path.jpg', path: '/uploads/user-id/thumbs/path.jpg',
isEdited: false,
...file, ...file,
}); });