diff --git a/server/src/cores/storage.core.ts b/server/src/cores/storage.core.ts index d688857de1..cb40d3eec4 100644 --- a/server/src/cores/storage.core.ts +++ b/server/src/cores/storage.core.ts @@ -1,7 +1,15 @@ import { randomUUID } from 'node:crypto'; import { dirname, join, resolve } from 'node:path'; 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 { ConfigRepository } from 'src/repositories/config.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 }; let instance: StorageCore | null; @@ -111,8 +110,19 @@ export class StorageCore { return StorageCore.getNestedPath(StorageFolder.Thumbnails, person.ownerId, `${person.id}.jpeg`); } - static getImagePath(asset: ThumbnailPathEntity, type: GeneratedImageType, format: 'jpeg' | 'webp') { - return StorageCore.getNestedPath(StorageFolder.Thumbnails, asset.ownerId, `${asset.id}-${type}.${format}`); + static getImagePath( + 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) { @@ -137,14 +147,14 @@ export class StorageCore { 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 oldFile = getAssetFile(files, pathType); + const oldFile = getAssetFile(files, fileType, { isEdited: false }); return this.moveFile({ entityId, - pathType, + pathType: fileType, 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: { return this.assetRepository.update({ id, originalPath: newPath }); } - case AssetPathType.FullSize: { + case AssetFileType.FullSize: { 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 }); } - case AssetPathType.Thumbnail: { + case AssetFileType.Thumbnail: { return this.assetRepository.upsertFile({ assetId: id, type: AssetFileType.Thumbnail, path: newPath }); } case AssetPathType.EncodedVideo: { return this.assetRepository.update({ id, encodedVideoPath: newPath }); } - case AssetPathType.Sidecar: { + case AssetFileType.Sidecar: { return this.assetRepository.upsertFile({ assetId: id, type: AssetFileType.Sidecar, path: newPath }); } case PersonPathType.Face: { diff --git a/server/src/database.ts b/server/src/database.ts index 7a64eb06ba..5f9d5aac29 100644 --- a/server/src/database.ts +++ b/server/src/database.ts @@ -39,6 +39,7 @@ export type AssetFile = { id: string; type: AssetFileType; path: string; + isEdited: boolean; }; export type Library = { @@ -344,7 +345,7 @@ export const columns = { 'asset.width', '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'], authApiKey: ['api_key.id', 'api_key.permissions'], authSession: ['session.id', 'session.updatedAt', 'session.pinExpiresAt', 'session.appVersion'], diff --git a/server/src/enum.ts b/server/src/enum.ts index 29718b0a8b..8a7e1dc789 100644 --- a/server/src/enum.ts +++ b/server/src/enum.ts @@ -45,9 +45,6 @@ export enum AssetFileType { Preview = 'preview', Thumbnail = 'thumbnail', Sidecar = 'sidecar', - FullSizeEdited = 'fullsize_edited', - PreviewEdited = 'preview_edited', - ThumbnailEdited = 'thumbnail_edited', } export enum AlbumUserRole { @@ -369,14 +366,7 @@ export enum ManualJobName { export enum AssetPathType { Original = 'original', - FullSize = 'fullsize', - Preview = 'preview', - EditedFullSize = 'edited_fullsize', - EditedPreview = 'edited_preview', - EditedThumbnail = 'edited_thumbnail', - Thumbnail = 'thumbnail', EncodedVideo = 'encoded_video', - Sidecar = 'sidecar', } export enum PersonPathType { @@ -387,7 +377,7 @@ export enum UserPathType { Profile = 'profile', } -export type PathType = AssetPathType | PersonPathType | UserPathType; +export type PathType = AssetFileType | AssetPathType | PersonPathType | UserPathType; export enum TranscodePolicy { All = 'all', diff --git a/server/src/queries/asset.job.repository.sql b/server/src/queries/asset.job.repository.sql index ccd90680bb..fa910eb352 100644 --- a/server/src/queries/asset.job.repository.sql +++ b/server/src/queries/asset.job.repository.sql @@ -29,7 +29,8 @@ select select "asset_file"."id", "asset_file"."path", - "asset_file"."type" + "asset_file"."type", + "asset_file"."isEdited" from "asset_file" where @@ -72,7 +73,8 @@ select select "asset_file"."id", "asset_file"."path", - "asset_file"."type" + "asset_file"."type", + "asset_file"."isEdited" from "asset_file" where @@ -99,7 +101,8 @@ select select "asset_file"."id", "asset_file"."path", - "asset_file"."type" + "asset_file"."type", + "asset_file"."isEdited" from "asset_file" where @@ -145,7 +148,8 @@ select select "asset_file"."id", "asset_file"."path", - "asset_file"."type" + "asset_file"."type", + "asset_file"."isEdited" from "asset_file" where @@ -174,7 +178,8 @@ select select "asset_file"."id", "asset_file"."path", - "asset_file"."type" + "asset_file"."type", + "asset_file"."isEdited" from "asset_file" where @@ -244,7 +249,8 @@ select select "asset_file"."id", "asset_file"."path", - "asset_file"."type" + "asset_file"."type", + "asset_file"."isEdited" from "asset_file" where @@ -269,7 +275,8 @@ where select "asset_file"."id", "asset_file"."path", - "asset_file"."type" + "asset_file"."type", + "asset_file"."isEdited" from "asset_file" where @@ -318,7 +325,8 @@ select select "asset_file"."id", "asset_file"."path", - "asset_file"."type" + "asset_file"."type", + "asset_file"."isEdited" from "asset_file" where @@ -357,7 +365,8 @@ select select "asset_file"."id", "asset_file"."path", - "asset_file"."type" + "asset_file"."type", + "asset_file"."isEdited" from "asset_file" where @@ -444,7 +453,8 @@ select select "asset_file"."id", "asset_file"."path", - "asset_file"."type" + "asset_file"."type", + "asset_file"."isEdited" from "asset_file" where @@ -536,7 +546,8 @@ select select "asset_file"."id", "asset_file"."path", - "asset_file"."type" + "asset_file"."type", + "asset_file"."isEdited" from "asset_file" where @@ -575,7 +586,8 @@ select select "asset_file"."id", "asset_file"."path", - "asset_file"."type" + "asset_file"."type", + "asset_file"."isEdited" from "asset_file" where diff --git a/server/src/queries/asset.repository.sql b/server/src/queries/asset.repository.sql index 666f41eb09..abaae84036 100644 --- a/server/src/queries/asset.repository.sql +++ b/server/src/queries/asset.repository.sql @@ -286,7 +286,8 @@ select select "asset_file"."id", "asset_file"."path", - "asset_file"."type" + "asset_file"."type", + "asset_file"."isEdited" from "asset_file" where diff --git a/server/src/repositories/asset.repository.ts b/server/src/repositories/asset.repository.ts index 325835b965..f8b3c8613a 100644 --- a/server/src/repositories/asset.repository.ts +++ b/server/src/repositories/asset.repository.ts @@ -903,20 +903,22 @@ export class AssetRepository { .execute(); } - async upsertFile(file: Pick, 'assetId' | 'path' | 'type'>): Promise { + async upsertFile(file: Pick, 'assetId' | 'path' | 'type' | 'isEdited'>): Promise { const value = { ...file, assetId: asUuid(file.assetId) }; await this.db .insertInto('asset_file') .values(value) .onConflict((oc) => - oc.columns(['assetId', 'type']).doUpdateSet((eb) => ({ + oc.columns(['assetId', 'type', 'isEdited']).doUpdateSet((eb) => ({ path: eb.ref('excluded.path'), })), ) .execute(); } - async upsertFiles(files: Pick, 'assetId' | 'path' | 'type'>[]): Promise { + async upsertFiles( + files: Pick, 'assetId' | 'path' | 'type' | 'isEdited'>[], + ): Promise { if (files.length === 0) { return; } @@ -926,7 +928,7 @@ export class AssetRepository { .insertInto('asset_file') .values(values) .onConflict((oc) => - oc.columns(['assetId', 'type']).doUpdateSet((eb) => ({ + oc.columns(['assetId', 'type', 'isEdited']).doUpdateSet((eb) => ({ path: eb.ref('excluded.path'), })), ) diff --git a/server/src/schema/migrations/1768828334807-AddIsEditedToAssetFile.ts b/server/src/schema/migrations/1768828334807-AddIsEditedToAssetFile.ts new file mode 100644 index 0000000000..b1daa3d72f --- /dev/null +++ b/server/src/schema/migrations/1768828334807-AddIsEditedToAssetFile.ts @@ -0,0 +1,13 @@ +import { Kysely, sql } from 'kysely'; + +export async function up(db: Kysely): Promise { + 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): Promise { + 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); +} diff --git a/server/src/schema/tables/asset-file.table.ts b/server/src/schema/tables/asset-file.table.ts index 6456d1d535..41301f41b7 100644 --- a/server/src/schema/tables/asset-file.table.ts +++ b/server/src/schema/tables/asset-file.table.ts @@ -14,7 +14,7 @@ import { } from 'src/sql-tools'; @Table('asset_file') -@Unique({ columns: ['assetId', 'type'] }) +@Unique({ columns: ['assetId', 'type', 'isEdited'] }) @UpdatedAtTrigger('asset_file_updatedAt') export class AssetFileTable { @PrimaryGeneratedColumn() @@ -37,4 +37,7 @@ export class AssetFileTable { @UpdateIdColumn({ index: true }) updateId!: Generated; + + @Column({ type: 'boolean', default: false }) + isEdited!: Generated; } diff --git a/server/src/services/asset-media.service.spec.ts b/server/src/services/asset-media.service.spec.ts index c19a1ad92e..0d099598e5 100644 --- a/server/src/services/asset-media.service.spec.ts +++ b/server/src/services/asset-media.service.spec.ts @@ -529,9 +529,10 @@ describe(AssetMediaService.name, () => { ...assetStub.withCropEdit.files, { id: 'edited-file', - type: AssetFileType.FullSizeEdited, + type: AssetFileType.FullSize, path: '/uploads/user-id/fullsize/edited.jpg', - } as AssetFile, + isEdited: true, + }, ], }; mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1'])); @@ -554,9 +555,10 @@ describe(AssetMediaService.name, () => { ...assetStub.withCropEdit.files, { id: 'edited-file', - type: AssetFileType.FullSizeEdited, + type: AssetFileType.FullSize, path: '/uploads/user-id/fullsize/edited.jpg', - } as AssetFile, + isEdited: true, + }, ], }; mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1'])); @@ -579,9 +581,10 @@ describe(AssetMediaService.name, () => { ...assetStub.withCropEdit.files, { id: 'edited-file', - type: AssetFileType.FullSizeEdited, + type: AssetFileType.FullSize, path: '/uploads/user-id/fullsize/edited.jpg', - } as AssetFile, + isEdited: true, + }, ], }; mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1'])); @@ -656,6 +659,7 @@ describe(AssetMediaService.name, () => { id: '42', path: '/path/to/preview', type: AssetFileType.Thumbnail, + isEdited: false, }, ], }); @@ -673,6 +677,7 @@ describe(AssetMediaService.name, () => { id: '42', path: '/path/to/preview.jpg', type: AssetFileType.Preview, + isEdited: false, }, ], }); diff --git a/server/src/services/asset.service.ts b/server/src/services/asset.service.ts index b92c339aab..2616a6baf5 100644 --- a/server/src/services/asset.service.ts +++ b/server/src/services/asset.service.ts @@ -4,7 +4,7 @@ import { DateTime, Duration } from 'luxon'; import { JOBS_ASSET_PAGINATION_SIZE } from 'src/constants'; import { AssetFile } from 'src/database'; 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 { AssetBulkDeleteDto, AssetBulkUpdateDto, @@ -112,7 +112,7 @@ export class AssetService extends BaseService { const { description, dateTimeOriginal, latitude, longitude, rating, ...rest } = dto; const repos = { asset: this.assetRepository, event: this.eventRepository }; - let previousMotion: MapAsset | null = null; + let previousMotion: { id: string } | null = null; if (rest.livePhotoVideoId) { await onBeforeLink(repos, { userId: auth.user.id, livePhotoVideoId: rest.livePhotoVideoId }); } else if (rest.livePhotoVideoId === null) { diff --git a/server/src/services/media.service.spec.ts b/server/src/services/media.service.spec.ts index b94c5843ad..4310f678ab 100644 --- a/server/src/services/media.service.spec.ts +++ b/server/src/services/media.service.spec.ts @@ -241,21 +241,21 @@ describe(MediaService.name, () => { await expect(sut.handleAssetMigration({ id: assetStub.image.id })).resolves.toBe(JobStatus.Success); expect(mocks.move.create).toHaveBeenCalledWith({ entityId: assetStub.image.id, - pathType: AssetPathType.FullSize, + pathType: AssetFileType.FullSize, 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({ entityId: assetStub.image.id, - pathType: AssetPathType.Preview, + pathType: AssetFileType.Preview, 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({ entityId: assetStub.image.id, - pathType: AssetPathType.Thumbnail, + pathType: AssetFileType.Thumbnail, 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); }); @@ -385,11 +385,13 @@ describe(MediaService.name, () => { assetId: 'asset-id', type: AssetFileType.Preview, path: expect.any(String), + isEdited: false, }, { assetId: 'asset-id', type: AssetFileType.Thumbnail, path: expect.any(String), + isEdited: false, }, ]); expect(mocks.asset.update).toHaveBeenCalledWith({ id: 'asset-id', thumbhash: thumbhashBuffer }); @@ -421,11 +423,13 @@ describe(MediaService.name, () => { assetId: 'asset-id', type: AssetFileType.Preview, path: expect.any(String), + isEdited: false, }, { assetId: 'asset-id', type: AssetFileType.Thumbnail, path: expect.any(String), + isEdited: false, }, ]); }); @@ -456,11 +460,13 @@ describe(MediaService.name, () => { assetId: 'asset-id', type: AssetFileType.Preview, path: expect.any(String), + isEdited: false, }, { assetId: 'asset-id', type: AssetFileType.Thumbnail, path: expect.any(String), + isEdited: false, }, ]); }); @@ -548,8 +554,8 @@ describe(MediaService.name, () => { mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(assetStub.image); const thumbhashBuffer = Buffer.from('a thumbhash', 'utf8'); mocks.media.generateThumbhash.mockResolvedValue(thumbhashBuffer); - 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 previewPath = `/data/thumbs/user-id/as/se/asset-id_preview.${format}`; + const thumbnailPath = `/data/thumbs/user-id/as/se/asset-id_thumbnail.webp`; await sut.handleGenerateThumbnails({ id: assetStub.image.id }); @@ -595,8 +601,8 @@ describe(MediaService.name, () => { mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(assetStub.image); const thumbhashBuffer = Buffer.from('a thumbhash', 'utf8'); mocks.media.generateThumbhash.mockResolvedValue(thumbhashBuffer); - 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 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}`); await sut.handleGenerateThumbnails({ id: assetStub.image.id }); @@ -1026,9 +1032,9 @@ describe(MediaService.name, () => { expect(mocks.asset.upsertFiles).toHaveBeenCalledWith( expect.arrayContaining([ - expect.objectContaining({ type: AssetFileType.FullSizeEdited }), - expect.objectContaining({ type: AssetFileType.PreviewEdited }), - expect.objectContaining({ type: AssetFileType.ThumbnailEdited }), + expect.objectContaining({ type: AssetFileType.FullSize, isEdited: true }), + expect.objectContaining({ type: AssetFileType.Preview, isEdited: true }), + expect.objectContaining({ type: AssetFileType.Thumbnail, isEdited: true }), ]), ); }); @@ -1098,17 +1104,17 @@ describe(MediaService.name, () => { expect(mocks.media.generateThumbnail).toHaveBeenCalledWith( rawBuffer, expect.anything(), - expect.stringContaining('edited_preview.jpeg'), + expect.stringContaining('preview_edited.jpeg'), ); expect(mocks.media.generateThumbnail).toHaveBeenCalledWith( rawBuffer, expect.anything(), - expect.stringContaining('edited_thumbnail.webp'), + expect.stringContaining('thumbnail_edited.webp'), ); expect(mocks.media.generateThumbnail).toHaveBeenCalledWith( rawBuffer, expect.anything(), - expect.stringContaining('edited_fullsize.jpeg'), + expect.stringContaining('fullsize_edited.jpeg'), ); }); @@ -3254,13 +3260,13 @@ describe(MediaService.name, () => { }; await sut['syncFiles'](asset, [ - { type: AssetFileType.Preview, newPath: '/new/preview.jpg' }, - { type: AssetFileType.Thumbnail, newPath: '/new/thumbnail.jpg' }, + { type: AssetFileType.Preview, newPath: '/new/preview.jpg', isEdited: false }, + { type: AssetFileType.Thumbnail, newPath: '/new/thumbnail.jpg', isEdited: false }, ]); expect(mocks.asset.upsertFiles).toHaveBeenCalledWith([ - { assetId: 'asset-id', path: '/new/preview.jpg', type: AssetFileType.Preview }, - { assetId: 'asset-id', path: '/new/thumbnail.jpg', type: AssetFileType.Thumbnail }, + { assetId: 'asset-id', path: '/new/preview.jpg', type: AssetFileType.Preview, isEdited: false }, + { assetId: 'asset-id', path: '/new/thumbnail.jpg', type: AssetFileType.Thumbnail, isEdited: false }, ]); expect(mocks.asset.deleteFiles).not.toHaveBeenCalled(); expect(mocks.job.queue).not.toHaveBeenCalled(); @@ -3270,19 +3276,31 @@ describe(MediaService.name, () => { const asset = { id: 'asset-id', 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, newPath: '/new/preview.jpg' }, - { type: AssetFileType.Thumbnail, newPath: '/new/thumbnail.jpg' }, + { type: AssetFileType.Preview, newPath: '/new/preview.jpg', isEdited: false }, + { type: AssetFileType.Thumbnail, newPath: '/new/thumbnail.jpg', isEdited: false }, ]); expect(mocks.asset.upsertFiles).toHaveBeenCalledWith([ - { assetId: 'asset-id', path: '/new/preview.jpg', type: AssetFileType.Preview }, - { assetId: 'asset-id', path: '/new/thumbnail.jpg', type: AssetFileType.Thumbnail }, + { assetId: 'asset-id', path: '/new/preview.jpg', type: AssetFileType.Preview, isEdited: false }, + { assetId: 'asset-id', path: '/new/thumbnail.jpg', type: AssetFileType.Thumbnail, isEdited: false }, ]); expect(mocks.asset.deleteFiles).not.toHaveBeenCalled(); expect(mocks.job.queue).toHaveBeenCalledWith({ @@ -3295,17 +3313,38 @@ describe(MediaService.name, () => { const asset = { id: 'asset-id', 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.deleteFiles).toHaveBeenCalledWith([ - { 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, + }, ]); expect(mocks.job.queue).toHaveBeenCalledWith({ name: JobName.FileDelete, @@ -3317,14 +3356,26 @@ describe(MediaService.name, () => { const asset = { id: 'asset-id', 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, [ - { type: AssetFileType.Preview, newPath: '/same/preview.jpg' }, - { type: AssetFileType.Thumbnail, newPath: '/same/thumbnail.jpg' }, + { type: AssetFileType.Preview, newPath: '/same/preview.jpg', isEdited: false }, + { type: AssetFileType.Thumbnail, newPath: '/same/thumbnail.jpg', isEdited: false }, ]); expect(mocks.asset.upsertFiles).not.toHaveBeenCalled(); @@ -3336,23 +3387,41 @@ describe(MediaService.name, () => { const asset = { id: 'asset-id', 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, newPath: '/new/preview.jpg' }, // replace - { type: AssetFileType.Thumbnail }, // delete - { type: AssetFileType.FullSize, newPath: '/new/fullsize.jpg' }, // new + { type: AssetFileType.Preview, newPath: '/new/preview.jpg', isEdited: false }, // replace + { type: AssetFileType.Thumbnail, isEdited: false }, // delete + { type: AssetFileType.FullSize, newPath: '/new/fullsize.jpg', isEdited: false }, // new ]); expect(mocks.asset.upsertFiles).toHaveBeenCalledWith([ - { assetId: 'asset-id', path: '/new/preview.jpg', type: AssetFileType.Preview }, - { assetId: 'asset-id', path: '/new/fullsize.jpg', type: AssetFileType.FullSize }, + { assetId: 'asset-id', path: '/new/preview.jpg', type: AssetFileType.Preview, isEdited: false }, + { assetId: 'asset-id', path: '/new/fullsize.jpg', type: AssetFileType.FullSize, isEdited: false }, ]); 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({ name: JobName.FileDelete, @@ -3376,11 +3445,19 @@ describe(MediaService.name, () => { it('should delete non-existent file types when newPath is not provided', async () => { const asset = { 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, [ - { 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(); diff --git a/server/src/services/media.service.ts b/server/src/services/media.service.ts index f66cbbaa0b..6d282004a3 100644 --- a/server/src/services/media.service.ts +++ b/server/src/services/media.service.ts @@ -8,7 +8,6 @@ import { AssetEditAction, CropParameters } from 'src/dtos/editing.dto'; import { SystemConfigFFmpegDto } from 'src/dtos/system-config.dto'; import { AssetFileType, - AssetPathType, AssetType, AssetVisibility, AudioCodec, @@ -50,6 +49,7 @@ interface UpsertFileOptions { assetId: string; type: AssetFileType; path: string; + isEdited: boolean; } type ThumbnailAsset = NonNullable>>; @@ -160,9 +160,9 @@ export class MediaService extends BaseService { return JobStatus.Failed; } - await this.storageCore.moveAssetImage(asset, AssetPathType.FullSize, image.fullsize.format); - await this.storageCore.moveAssetImage(asset, AssetPathType.Preview, image.preview.format); - await this.storageCore.moveAssetImage(asset, AssetPathType.Thumbnail, image.thumbnail.format); + await this.storageCore.moveAssetImage(asset, AssetFileType.FullSize, image.fullsize.format); + await this.storageCore.moveAssetImage(asset, AssetFileType.Preview, image.preview.format); + await this.storageCore.moveAssetImage(asset, AssetFileType.Thumbnail, image.thumbnail.format); await this.storageCore.moveAssetVideo(asset); return JobStatus.Success; @@ -236,9 +236,9 @@ export class MediaService extends BaseService { } await this.syncFiles(asset, [ - { type: AssetFileType.Preview, newPath: generated.previewPath }, - { type: AssetFileType.Thumbnail, newPath: generated.thumbnailPath }, - { type: AssetFileType.FullSize, newPath: generated.fullsizePath }, + { type: AssetFileType.Preview, newPath: generated.previewPath, isEdited: false }, + { type: AssetFileType.Thumbnail, newPath: generated.thumbnailPath, isEdited: false }, + { type: AssetFileType.FullSize, newPath: generated.fullsizePath, isEdited: false }, ]); const editiedGenerated = await this.generateEditedThumbnails(asset); @@ -307,16 +307,16 @@ export class MediaService extends BaseService { private async generateImageThumbnails(asset: ThumbnailAsset, useEdits: boolean = false) { const { image } = await this.getConfig({ withCache: true }); - const previewPath = StorageCore.getImagePath( - asset, - useEdits ? AssetPathType.EditedPreview : AssetPathType.Preview, - image.preview.format, - ); - const thumbnailPath = StorageCore.getImagePath( - asset, - useEdits ? AssetPathType.EditedThumbnail : AssetPathType.Thumbnail, - image.thumbnail.format, - ); + const previewPath = StorageCore.getImagePath(asset, { + fileType: AssetFileType.Preview, + isEdited: useEdits, + format: image.preview.format, + }); + const thumbnailPath = StorageCore.getImagePath(asset, { + fileType: AssetFileType.Thumbnail, + isEdited: useEdits, + format: image.thumbnail.format, + }); this.storageCore.ensureFolders(previewPath); // Handle embedded preview extraction for RAW files @@ -343,11 +343,11 @@ export class MediaService extends BaseService { if (convertFullsize) { // convert a new fullsize image from the same source as the thumbnail - fullsizePath = StorageCore.getImagePath( - asset, - useEdits ? AssetPathType.EditedFullSize : AssetPathType.FullSize, - image.fullsize.format, - ); + fullsizePath = StorageCore.getImagePath(asset, { + fileType: AssetFileType.FullSize, + isEdited: useEdits, + format: image.fullsize.format, + }); const fullsizeOptions = { format: image.fullsize.format, quality: image.fullsize.quality, @@ -355,7 +355,11 @@ export class MediaService extends BaseService { }; promises.push(this.mediaRepository.generateThumbnail(data, fullsizeOptions, fullsizePath)); } 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); // 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 }) { const { image, ffmpeg } = await this.getConfig({ withCache: true }); - const previewPath = StorageCore.getImagePath(asset, AssetPathType.Preview, image.preview.format); - const thumbnailPath = StorageCore.getImagePath(asset, AssetPathType.Thumbnail, image.thumbnail.format); + const previewPath = StorageCore.getImagePath(asset, { + 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); const { format, audioStreams, videoStreams } = await this.mediaRepository.probe(asset.originalPath); @@ -779,18 +791,18 @@ export class MediaService extends BaseService { private async syncFiles( asset: { id: string; files: AssetFile[] }, - files: { type: AssetFileType; newPath?: string }[], + files: { type: AssetFileType; newPath?: string; isEdited: boolean }[], ) { const toUpsert: UpsertFileOptions[] = []; const pathsToDelete: string[] = []; const toDelete: AssetFile[] = []; - for (const { type, newPath } of files) { - const existingFile = asset.files.find((file) => file.type === type); + for (const { type, newPath, isEdited } of files) { + const existingFile = asset.files.find((file) => file.type === type && file.isEdited === isEdited); // upsert new file path 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 if (existingFile) { @@ -829,9 +841,9 @@ export class MediaService extends BaseService { const generated = asset.edits.length > 0 ? await this.generateImageThumbnails(asset, true) : undefined; await this.syncFiles(asset, [ - { type: AssetFileType.PreviewEdited, newPath: generated?.previewPath }, - { type: AssetFileType.ThumbnailEdited, newPath: generated?.thumbnailPath }, - { type: AssetFileType.FullSizeEdited, newPath: generated?.fullsizePath }, + { type: AssetFileType.Preview, newPath: generated?.previewPath, isEdited: true }, + { type: AssetFileType.Thumbnail, newPath: generated?.thumbnailPath, isEdited: true }, + { type: AssetFileType.FullSize, newPath: generated?.fullsizePath, isEdited: true }, ]); const crop = asset.edits.find((e) => e.action === AssetEditAction.Crop); diff --git a/server/src/services/metadata.service.spec.ts b/server/src/services/metadata.service.spec.ts index b10325998e..09c8e673a6 100644 --- a/server/src/services/metadata.service.spec.ts +++ b/server/src/services/metadata.service.spec.ts @@ -35,7 +35,7 @@ const forSidecarJob = ( asset: { id?: string; originalPath?: string; - files?: { id: string; type: AssetFileType; path: string }[]; + files?: { id: string; type: AssetFileType; path: string; isEdited: boolean }[]; } = {}, ) => { return { @@ -1084,6 +1084,7 @@ describe(MetadataService.name, () => { id: 'some-id', type: AssetFileType.Sidecar, path: '/path/to/something', + isEdited: false, }, ], }); @@ -1691,7 +1692,7 @@ describe(MetadataService.name, () => { it('should unset sidecar path if file no longer exist', async () => { const asset = forSidecarJob({ 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.storage.checkFileExists.mockResolvedValue(false); @@ -1704,7 +1705,7 @@ describe(MetadataService.name, () => { it('should do nothing if the sidecar file still exists', async () => { const asset = forSidecarJob({ 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); diff --git a/server/src/services/notification.service.spec.ts b/server/src/services/notification.service.spec.ts index daa3f221ae..6627ffea8a 100644 --- a/server/src/services/notification.service.spec.ts +++ b/server/src/services/notification.service.spec.ts @@ -372,7 +372,7 @@ describe(NotificationService.name, () => { mocks.notification.create.mockResolvedValue(notificationStub.albumEvent); mocks.email.renderEmail.mockResolvedValue({ html: '', text: '' }); 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); @@ -403,7 +403,7 @@ describe(NotificationService.name, () => { mocks.systemMetadata.get.mockResolvedValue({ server: {} }); mocks.notification.create.mockResolvedValue(notificationStub.albumEvent); 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); expect(mocks.assetJob.getAlbumThumbnailFiles).toHaveBeenCalledWith( diff --git a/server/src/services/storage-template.service.ts b/server/src/services/storage-template.service.ts index cd641d7036..d5020a9c5e 100644 --- a/server/src/services/storage-template.service.ts +++ b/server/src/services/storage-template.service.ts @@ -240,11 +240,11 @@ export class StorageTemplateService extends BaseService { assetInfo: { sizeInBytes: fileSizeInByte, checksum }, }); - const sidecarPath = getAssetFile(asset.files, AssetFileType.Sidecar)?.path; + const sidecarPath = getAssetFile(asset.files, AssetFileType.Sidecar, { isEdited: false })?.path; if (sidecarPath) { await this.storageCore.moveFile({ entityId: id, - pathType: AssetPathType.Sidecar, + pathType: AssetFileType.Sidecar, oldPath: sidecarPath, newPath: `${newPath}.xmp`, }); diff --git a/server/src/utils/asset.util.ts b/server/src/utils/asset.util.ts index 94f7f231a8..f8fb3d215d 100644 --- a/server/src/utils/asset.util.ts +++ b/server/src/utils/asset.util.ts @@ -1,5 +1,5 @@ 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 { BulkIdErrorReason, BulkIdResponseDto } from 'src/dtos/asset-ids.response.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 { checkAccess } from 'src/utils/access'; -export const getAssetFile = (files: AssetFile[], type: AssetFileType | GeneratedImageType) => { - return files.find((file) => file.type === type); +export const getAssetFile = (files: AssetFile[], type: AssetFileType, { isEdited }: { isEdited: boolean }) => { + return files.find((file) => file.type === type && file.isEdited === isEdited); }; export const getAssetFiles = (files: AssetFile[]) => ({ - fullsizeFile: getAssetFile(files, AssetFileType.FullSize), - previewFile: getAssetFile(files, AssetFileType.Preview), - thumbnailFile: getAssetFile(files, AssetFileType.Thumbnail), - sidecarFile: getAssetFile(files, AssetFileType.Sidecar), + fullsizeFile: getAssetFile(files, AssetFileType.FullSize, { isEdited: false }), + previewFile: getAssetFile(files, AssetFileType.Preview, { isEdited: false }), + thumbnailFile: getAssetFile(files, AssetFileType.Thumbnail, { isEdited: false }), + sidecarFile: getAssetFile(files, AssetFileType.Sidecar, { isEdited: false }), - editedFullsizeFile: getAssetFile(files, AssetFileType.FullSizeEdited), - editedPreviewFile: getAssetFile(files, AssetFileType.PreviewEdited), - editedThumbnailFile: getAssetFile(files, AssetFileType.ThumbnailEdited), + editedFullsizeFile: getAssetFile(files, AssetFileType.FullSize, { isEdited: true }), + editedPreviewFile: getAssetFile(files, AssetFileType.Preview, { isEdited: true }), + editedThumbnailFile: getAssetFile(files, AssetFileType.Preview, { isEdited: true }), }); export const addAssets = async ( diff --git a/server/test/fixtures/asset.stub.ts b/server/test/fixtures/asset.stub.ts index 0a6108a653..920b82f6e5 100644 --- a/server/test/fixtures/asset.stub.ts +++ b/server/test/fixtures/asset.stub.ts @@ -31,18 +31,21 @@ const sidecarFileWithoutExt = factory.assetFile({ }); const editedPreviewFile = factory.assetFile({ - type: AssetFileType.PreviewEdited, + type: AssetFileType.Preview, path: '/uploads/user-id/preview/path_edited.jpg', + isEdited: true, }); const editedThumbnailFile = factory.assetFile({ - type: AssetFileType.ThumbnailEdited, + type: AssetFileType.Thumbnail, path: '/uploads/user-id/thumbnail/path_edited.jpg', + isEdited: true, }); const editedFullsizeFile = factory.assetFile({ - type: AssetFileType.FullSizeEdited, + type: AssetFileType.FullSize, path: '/uploads/user-id/fullsize/path_edited.jpg', + isEdited: true, }); const files: AssetFile[] = [fullsizeFile, previewFile, thumbnailFile]; diff --git a/server/test/small.factory.ts b/server/test/small.factory.ts index 83d3a4777b..900296d040 100644 --- a/server/test/small.factory.ts +++ b/server/test/small.factory.ts @@ -335,6 +335,7 @@ const assetSidecarWriteFactory = () => { id: newUuid(), path: '/path/to/original-path.jpg.xmp', type: AssetFileType.Sidecar, + isEdited: false, }, ], exifInfo: { @@ -386,6 +387,7 @@ const assetFileFactory = (file: Partial = {}): AssetFile => ({ id: newUuid(), type: AssetFileType.Preview, path: '/uploads/user-id/thumbs/path.jpg', + isEdited: false, ...file, });