refactor(server): add isProgressive column (#25537)

* add isProgressive column

* don't select isProgressive by default

* linting

* exclude sidecars
This commit is contained in:
Mert 2026-01-26 17:05:25 -05:00 committed by GitHub
parent b5c3d87290
commit 9506398153
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 398 additions and 152 deletions

View file

@ -34,6 +34,8 @@ export interface MoveRequest {
export type ThumbnailPathEntity = { id: string; ownerId: string }; export type ThumbnailPathEntity = { id: string; ownerId: string };
export type ImagePathOptions = { fileType: AssetFileType; format: ImageFormat | RawExtractedFormat; isEdited: boolean };
let instance: StorageCore | null; let instance: StorageCore | null;
let mediaLocation: string | undefined; let mediaLocation: string | undefined;
@ -110,14 +112,7 @@ 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( static getImagePath(asset: ThumbnailPathEntity, { fileType, format, isEdited }: ImagePathOptions) {
asset: ThumbnailPathEntity,
{
fileType,
format,
isEdited,
}: { fileType: AssetFileType; format: ImageFormat | RawExtractedFormat; isEdited: boolean },
) {
return StorageCore.getNestedPath( return StorageCore.getNestedPath(
StorageFolder.Thumbnails, StorageFolder.Thumbnails,
asset.ownerId, asset.ownerId,

View file

@ -346,6 +346,13 @@ export const columns = {
'asset.height', 'asset.height',
], ],
assetFiles: ['asset_file.id', 'asset_file.path', 'asset_file.type', 'asset_file.isEdited'], assetFiles: ['asset_file.id', 'asset_file.path', 'asset_file.type', 'asset_file.isEdited'],
assetFilesForThumbnail: [
'asset_file.id',
'asset_file.path',
'asset_file.type',
'asset_file.isEdited',
'asset_file.isProgressive',
],
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

@ -165,11 +165,13 @@ select
"asset_file"."id", "asset_file"."id",
"asset_file"."path", "asset_file"."path",
"asset_file"."type", "asset_file"."type",
"asset_file"."isEdited" "asset_file"."isEdited",
"asset_file"."isProgressive"
from from
"asset_file" "asset_file"
where where
"asset_file"."assetId" = "asset"."id" "asset_file"."assetId" = "asset"."id"
and "asset_file"."type" in ($1, $2, $3)
) as agg ) as agg
) as "files", ) as "files",
( (
@ -191,7 +193,7 @@ 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"."id" = $1 "asset"."id" = $4
-- AssetJobRepository.getForMetadataExtraction -- AssetJobRepository.getForMetadataExtraction
select select

View file

@ -1,5 +1,6 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { Kysely } from 'kysely'; import { Kysely } from 'kysely';
import { jsonArrayFrom } from 'kysely/helpers/postgres';
import { InjectKysely } from 'nestjs-kysely'; import { InjectKysely } from 'nestjs-kysely';
import { Asset, columns } from 'src/database'; import { Asset, columns } from 'src/database';
import { DummyValue, GenerateSql } from 'src/decorators'; import { DummyValue, GenerateSql } from 'src/decorators';
@ -104,7 +105,15 @@ export class AssetJobRepository {
'asset.thumbhash', 'asset.thumbhash',
'asset.type', 'asset.type',
]) ])
.select(withFiles) .select((eb) =>
jsonArrayFrom(
eb
.selectFrom('asset_file')
.select(columns.assetFilesForThumbnail)
.whereRef('asset_file.assetId', '=', 'asset.id')
.where('asset_file.type', 'in', [AssetFileType.Thumbnail, AssetFileType.Preview, AssetFileType.FullSize]),
).as('files'),
)
.select(withEdits) .select(withEdits)
.$call(withExifInner) .$call(withExifInner)
.where('asset.id', '=', id) .where('asset.id', '=', id)

View file

@ -904,11 +904,12 @@ export class AssetRepository {
.execute(); .execute();
} }
async upsertFile(file: Pick<Insertable<AssetFileTable>, 'assetId' | 'path' | 'type' | 'isEdited'>): Promise<void> { async upsertFile(
const value = { ...file, assetId: asUuid(file.assetId) }; file: Pick<Insertable<AssetFileTable>, 'assetId' | 'path' | 'type' | 'isEdited' | 'isProgressive'>,
): Promise<void> {
await this.db await this.db
.insertInto('asset_file') .insertInto('asset_file')
.values(value) .values(file)
.onConflict((oc) => .onConflict((oc) =>
oc.columns(['assetId', 'type', 'isEdited']).doUpdateSet((eb) => ({ oc.columns(['assetId', 'type', 'isEdited']).doUpdateSet((eb) => ({
path: eb.ref('excluded.path'), path: eb.ref('excluded.path'),
@ -918,19 +919,19 @@ export class AssetRepository {
} }
async upsertFiles( async upsertFiles(
files: Pick<Insertable<AssetFileTable>, 'assetId' | 'path' | 'type' | 'isEdited'>[], files: Pick<Insertable<AssetFileTable>, 'assetId' | 'path' | 'type' | 'isEdited' | 'isProgressive'>[],
): Promise<void> { ): Promise<void> {
if (files.length === 0) { if (files.length === 0) {
return; return;
} }
const values = files.map((row) => ({ ...row, assetId: asUuid(row.assetId) }));
await this.db await this.db
.insertInto('asset_file') .insertInto('asset_file')
.values(values) .values(files)
.onConflict((oc) => .onConflict((oc) =>
oc.columns(['assetId', 'type', 'isEdited']).doUpdateSet((eb) => ({ oc.columns(['assetId', 'type', 'isEdited']).doUpdateSet((eb) => ({
path: eb.ref('excluded.path'), path: eb.ref('excluded.path'),
isProgressive: eb.ref('excluded.isProgressive'),
})), })),
) )
.execute(); .execute();

View file

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

View file

@ -40,4 +40,7 @@ export class AssetFileTable {
@Column({ type: 'boolean', default: false }) @Column({ type: 'boolean', default: false })
isEdited!: Generated<boolean>; isEdited!: Generated<boolean>;
@Column({ type: 'boolean', default: false })
isProgressive!: Generated<boolean>;
} }

View file

@ -388,12 +388,14 @@ describe(MediaService.name, () => {
type: AssetFileType.Preview, type: AssetFileType.Preview,
path: expect.any(String), path: expect.any(String),
isEdited: false, isEdited: false,
isProgressive: false,
}, },
{ {
assetId: 'asset-id', assetId: 'asset-id',
type: AssetFileType.Thumbnail, type: AssetFileType.Thumbnail,
path: expect.any(String), path: expect.any(String),
isEdited: false, isEdited: false,
isProgressive: false,
}, },
]); ]);
expect(mocks.asset.update).toHaveBeenCalledWith({ id: 'asset-id', thumbhash: thumbhashBuffer }); expect(mocks.asset.update).toHaveBeenCalledWith({ id: 'asset-id', thumbhash: thumbhashBuffer });
@ -426,12 +428,14 @@ describe(MediaService.name, () => {
type: AssetFileType.Preview, type: AssetFileType.Preview,
path: expect.any(String), path: expect.any(String),
isEdited: false, isEdited: false,
isProgressive: false,
}, },
{ {
assetId: 'asset-id', assetId: 'asset-id',
type: AssetFileType.Thumbnail, type: AssetFileType.Thumbnail,
path: expect.any(String), path: expect.any(String),
isEdited: false, isEdited: false,
isProgressive: false,
}, },
]); ]);
}); });
@ -463,12 +467,14 @@ describe(MediaService.name, () => {
type: AssetFileType.Preview, type: AssetFileType.Preview,
path: expect.any(String), path: expect.any(String),
isEdited: false, isEdited: false,
isProgressive: false,
}, },
{ {
assetId: 'asset-id', assetId: 'asset-id',
type: AssetFileType.Thumbnail, type: AssetFileType.Thumbnail,
path: expect.any(String), path: expect.any(String),
isEdited: false, isEdited: false,
isProgressive: false,
}, },
]); ]);
}); });
@ -673,6 +679,16 @@ describe(MediaService.name, () => {
}), }),
expect.stringContaining('thumbnail.webp'), expect.stringContaining('thumbnail.webp'),
); );
expect(mocks.asset.upsertFiles).toHaveBeenCalledWith([
expect.objectContaining({
type: AssetFileType.Preview,
isProgressive: true,
}),
expect.objectContaining({
type: AssetFileType.Thumbnail,
isProgressive: false,
}),
]);
}); });
it('should generate progressive JPEG for thumbnail when enabled', async () => { it('should generate progressive JPEG for thumbnail when enabled', async () => {
@ -699,6 +715,37 @@ describe(MediaService.name, () => {
}), }),
expect.stringContaining('thumbnail.jpeg'), expect.stringContaining('thumbnail.jpeg'),
); );
expect(mocks.asset.upsertFiles).toHaveBeenCalledWith([
expect.objectContaining({
type: AssetFileType.Preview,
isProgressive: false,
}),
expect.objectContaining({
type: AssetFileType.Thumbnail,
isProgressive: true,
}),
]);
});
it('should never set isProgressive for videos', async () => {
mocks.media.probe.mockResolvedValue(probeStub.videoStreamHDR);
mocks.systemMetadata.get.mockResolvedValue({
image: { preview: { progressive: true }, thumbnail: { progressive: true } },
});
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(assetStub.video);
await sut.handleGenerateThumbnails({ id: assetStub.video.id });
expect(mocks.asset.upsertFiles).toHaveBeenCalledWith([
expect.objectContaining({
type: AssetFileType.Preview,
isProgressive: false,
}),
expect.objectContaining({
type: AssetFileType.Thumbnail,
isProgressive: false,
}),
]);
}); });
it('should delete previous thumbnail if different path', async () => { it('should delete previous thumbnail if different path', async () => {
@ -3353,14 +3400,38 @@ describe(MediaService.name, () => {
files: [], files: [],
}; };
await sut['syncFiles'](asset, [ await sut['syncFiles'](asset.files, [
{ type: AssetFileType.Preview, newPath: '/new/preview.jpg', isEdited: false }, {
{ type: AssetFileType.Thumbnail, newPath: '/new/thumbnail.jpg', isEdited: false }, assetId: asset.id,
type: AssetFileType.Preview,
path: '/new/preview.jpg',
isEdited: false,
isProgressive: false,
},
{
assetId: asset.id,
type: AssetFileType.Thumbnail,
path: '/new/thumbnail.jpg',
isEdited: false,
isProgressive: false,
},
]); ]);
expect(mocks.asset.upsertFiles).toHaveBeenCalledWith([ expect(mocks.asset.upsertFiles).toHaveBeenCalledWith([
{ assetId: 'asset-id', path: '/new/preview.jpg', type: AssetFileType.Preview, isEdited: false }, {
{ assetId: 'asset-id', path: '/new/thumbnail.jpg', type: AssetFileType.Thumbnail, isEdited: false }, assetId: 'asset-id',
path: '/new/preview.jpg',
type: AssetFileType.Preview,
isEdited: false,
isProgressive: false,
},
{
assetId: 'asset-id',
path: '/new/thumbnail.jpg',
type: AssetFileType.Thumbnail,
isEdited: false,
isProgressive: 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();
@ -3376,6 +3447,7 @@ describe(MediaService.name, () => {
type: AssetFileType.Preview, type: AssetFileType.Preview,
path: '/old/preview.jpg', path: '/old/preview.jpg',
isEdited: false, isEdited: false,
isProgressive: false,
}, },
{ {
id: 'file-2', id: 'file-2',
@ -3383,18 +3455,43 @@ describe(MediaService.name, () => {
type: AssetFileType.Thumbnail, type: AssetFileType.Thumbnail,
path: '/old/thumbnail.jpg', path: '/old/thumbnail.jpg',
isEdited: false, isEdited: false,
isProgressive: false,
}, },
], ],
}; };
await sut['syncFiles'](asset, [ await sut['syncFiles'](asset.files, [
{ type: AssetFileType.Preview, newPath: '/new/preview.jpg', isEdited: false }, {
{ type: AssetFileType.Thumbnail, newPath: '/new/thumbnail.jpg', isEdited: false }, assetId: asset.id,
type: AssetFileType.Preview,
path: '/new/preview.jpg',
isEdited: false,
isProgressive: false,
},
{
assetId: asset.id,
type: AssetFileType.Thumbnail,
path: '/new/thumbnail.jpg',
isEdited: false,
isProgressive: false,
},
]); ]);
expect(mocks.asset.upsertFiles).toHaveBeenCalledWith([ expect(mocks.asset.upsertFiles).toHaveBeenCalledWith([
{ assetId: 'asset-id', path: '/new/preview.jpg', type: AssetFileType.Preview, isEdited: false }, {
{ assetId: 'asset-id', path: '/new/thumbnail.jpg', type: AssetFileType.Thumbnail, isEdited: false }, assetId: 'asset-id',
path: '/new/preview.jpg',
type: AssetFileType.Preview,
isEdited: false,
isProgressive: false,
},
{
assetId: 'asset-id',
path: '/new/thumbnail.jpg',
type: AssetFileType.Thumbnail,
isEdited: false,
isProgressive: false,
},
]); ]);
expect(mocks.asset.deleteFiles).not.toHaveBeenCalled(); expect(mocks.asset.deleteFiles).not.toHaveBeenCalled();
expect(mocks.job.queue).toHaveBeenCalledWith({ expect(mocks.job.queue).toHaveBeenCalledWith({
@ -3413,6 +3510,7 @@ describe(MediaService.name, () => {
type: AssetFileType.Preview, type: AssetFileType.Preview,
path: '/old/preview.jpg', path: '/old/preview.jpg',
isEdited: false, isEdited: false,
isProgressive: false,
}, },
{ {
id: 'file-2', id: 'file-2',
@ -3420,24 +3518,30 @@ describe(MediaService.name, () => {
type: AssetFileType.Thumbnail, type: AssetFileType.Thumbnail,
path: '/old/thumbnail.jpg', path: '/old/thumbnail.jpg',
isEdited: false, isEdited: false,
isProgressive: false,
}, },
], ],
}; };
await sut['syncFiles'](asset, [ await sut['syncFiles'](asset.files, []);
{ 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', isEdited: false }, {
id: 'file-1',
assetId: 'asset-id',
type: AssetFileType.Preview,
path: '/old/preview.jpg',
isEdited: false,
isProgressive: false,
},
{ {
id: 'file-2', id: 'file-2',
assetId: 'asset-id', assetId: 'asset-id',
type: AssetFileType.Thumbnail, type: AssetFileType.Thumbnail,
path: '/old/thumbnail.jpg', path: '/old/thumbnail.jpg',
isEdited: false, isEdited: false,
isProgressive: false,
}, },
]); ]);
expect(mocks.job.queue).toHaveBeenCalledWith({ expect(mocks.job.queue).toHaveBeenCalledWith({
@ -3456,6 +3560,7 @@ describe(MediaService.name, () => {
type: AssetFileType.Preview, type: AssetFileType.Preview,
path: '/same/preview.jpg', path: '/same/preview.jpg',
isEdited: false, isEdited: false,
isProgressive: false,
}, },
{ {
id: 'file-2', id: 'file-2',
@ -3463,13 +3568,26 @@ describe(MediaService.name, () => {
type: AssetFileType.Thumbnail, type: AssetFileType.Thumbnail,
path: '/same/thumbnail.jpg', path: '/same/thumbnail.jpg',
isEdited: false, isEdited: false,
isProgressive: false,
}, },
], ],
}; };
await sut['syncFiles'](asset, [ await sut['syncFiles'](asset.files, [
{ type: AssetFileType.Preview, newPath: '/same/preview.jpg', isEdited: false }, {
{ type: AssetFileType.Thumbnail, newPath: '/same/thumbnail.jpg', isEdited: false }, assetId: asset.id,
type: AssetFileType.Preview,
path: '/same/preview.jpg',
isEdited: false,
isProgressive: false,
},
{
assetId: asset.id,
type: AssetFileType.Thumbnail,
path: '/same/thumbnail.jpg',
isEdited: false,
isProgressive: false,
},
]); ]);
expect(mocks.asset.upsertFiles).not.toHaveBeenCalled(); expect(mocks.asset.upsertFiles).not.toHaveBeenCalled();
@ -3487,6 +3605,7 @@ describe(MediaService.name, () => {
type: AssetFileType.Preview, type: AssetFileType.Preview,
path: '/old/preview.jpg', path: '/old/preview.jpg',
isEdited: false, isEdited: false,
isProgressive: false,
}, },
{ {
id: 'file-2', id: 'file-2',
@ -3494,19 +3613,43 @@ describe(MediaService.name, () => {
type: AssetFileType.Thumbnail, type: AssetFileType.Thumbnail,
path: '/old/thumbnail.jpg', path: '/old/thumbnail.jpg',
isEdited: false, isEdited: false,
isProgressive: false,
}, },
], ],
}; };
await sut['syncFiles'](asset, [ await sut['syncFiles'](asset.files, [
{ type: AssetFileType.Preview, newPath: '/new/preview.jpg', isEdited: false }, // replace {
{ type: AssetFileType.Thumbnail, isEdited: false }, // delete assetId: asset.id,
{ type: AssetFileType.FullSize, newPath: '/new/fullsize.jpg', isEdited: false }, // new type: AssetFileType.Preview,
path: '/new/preview.jpg',
isEdited: false,
isProgressive: false,
}, // replace
{
assetId: asset.id,
type: AssetFileType.FullSize,
path: '/new/fullsize.jpg',
isEdited: false,
isProgressive: false,
}, // new
]); ]);
expect(mocks.asset.upsertFiles).toHaveBeenCalledWith([ expect(mocks.asset.upsertFiles).toHaveBeenCalledWith([
{ assetId: 'asset-id', path: '/new/preview.jpg', type: AssetFileType.Preview, isEdited: false }, {
{ assetId: 'asset-id', path: '/new/fullsize.jpg', type: AssetFileType.FullSize, isEdited: false }, assetId: 'asset-id',
path: '/new/preview.jpg',
type: AssetFileType.Preview,
isEdited: false,
isProgressive: false,
},
{
assetId: 'asset-id',
path: '/new/fullsize.jpg',
type: AssetFileType.FullSize,
isEdited: false,
isProgressive: false,
},
]); ]);
expect(mocks.asset.deleteFiles).toHaveBeenCalledWith([ expect(mocks.asset.deleteFiles).toHaveBeenCalledWith([
{ {
@ -3515,6 +3658,7 @@ describe(MediaService.name, () => {
type: AssetFileType.Thumbnail, type: AssetFileType.Thumbnail,
path: '/old/thumbnail.jpg', path: '/old/thumbnail.jpg',
isEdited: false, isEdited: false,
isProgressive: false,
}, },
]); ]);
expect(mocks.job.queue).toHaveBeenCalledWith({ expect(mocks.job.queue).toHaveBeenCalledWith({
@ -3529,7 +3673,7 @@ describe(MediaService.name, () => {
files: [], files: [],
}; };
await sut['syncFiles'](asset, []); await sut['syncFiles'](asset.files, []);
expect(mocks.asset.upsertFiles).not.toHaveBeenCalled(); expect(mocks.asset.upsertFiles).not.toHaveBeenCalled();
expect(mocks.asset.deleteFiles).not.toHaveBeenCalled(); expect(mocks.asset.deleteFiles).not.toHaveBeenCalled();
@ -3546,15 +3690,79 @@ describe(MediaService.name, () => {
type: AssetFileType.Preview, type: AssetFileType.Preview,
path: '/old/preview.jpg', path: '/old/preview.jpg',
isEdited: false, isEdited: false,
isProgressive: false,
}, },
], ],
}; };
await sut['syncFiles'](asset, [ await sut['syncFiles'](asset.files, []);
{ type: AssetFileType.Thumbnail, isEdited: false }, // file doesn't exist, newPath not provided
]);
expect(mocks.asset.upsertFiles).not.toHaveBeenCalled(); expect(mocks.asset.upsertFiles).not.toHaveBeenCalled();
expect(mocks.asset.deleteFiles).toHaveBeenCalledWith([
{
id: 'file-1',
assetId: 'asset-id',
type: AssetFileType.Preview,
path: '/old/preview.jpg',
isEdited: false,
isProgressive: false,
},
]);
expect(mocks.job.queue).toHaveBeenCalledWith({
name: JobName.FileDelete,
data: { files: ['/old/preview.jpg'] },
});
});
it('should update database when isProgressive changes', async () => {
const asset = {
id: 'asset-id',
files: [
{
id: 'file-1',
assetId: 'asset-id',
type: AssetFileType.Preview,
path: '/old/preview.jpg',
isEdited: false,
isProgressive: false,
},
{
id: 'file-2',
assetId: 'asset-id',
type: AssetFileType.Thumbnail,
path: '/old/thumbnail.jpg',
isEdited: false,
isProgressive: false,
},
],
};
await sut['syncFiles'](asset.files, [
{
assetId: asset.id,
type: AssetFileType.Preview,
path: '/old/preview.jpg',
isEdited: false,
isProgressive: true,
},
{
assetId: asset.id,
type: AssetFileType.Thumbnail,
path: '/old/thumbnail.jpg',
isEdited: false,
isProgressive: false,
},
]);
expect(mocks.asset.upsertFiles).toHaveBeenCalledWith([
{
assetId: 'asset-id',
path: '/old/preview.jpg',
type: AssetFileType.Preview,
isEdited: false,
isProgressive: true,
},
]);
expect(mocks.asset.deleteFiles).not.toHaveBeenCalled(); expect(mocks.asset.deleteFiles).not.toHaveBeenCalled();
expect(mocks.job.queue).not.toHaveBeenCalled(); expect(mocks.job.queue).not.toHaveBeenCalled();
}); });

View file

@ -1,7 +1,7 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { SystemConfig } from 'src/config'; import { SystemConfig } from 'src/config';
import { FACE_THUMBNAIL_SIZE, JOBS_ASSET_PAGINATION_SIZE } from 'src/constants'; import { FACE_THUMBNAIL_SIZE, JOBS_ASSET_PAGINATION_SIZE } from 'src/constants';
import { StorageCore, ThumbnailPathEntity } from 'src/cores/storage.core'; import { ImagePathOptions, StorageCore, ThumbnailPathEntity } from 'src/cores/storage.core';
import { AssetFile, Exif } from 'src/database'; import { AssetFile, Exif } from 'src/database';
import { OnEvent, OnJob } from 'src/decorators'; import { OnEvent, OnJob } from 'src/decorators';
import { AssetEditAction, CropParameters } from 'src/dtos/editing.dto'; import { AssetEditAction, CropParameters } from 'src/dtos/editing.dto';
@ -45,11 +45,13 @@ import { BaseConfig, ThumbnailConfig } from 'src/utils/media';
import { mimeTypes } from 'src/utils/mime-types'; import { mimeTypes } from 'src/utils/mime-types';
import { clamp, isFaceImportEnabled, isFacialRecognitionEnabled } from 'src/utils/misc'; import { clamp, isFaceImportEnabled, isFacialRecognitionEnabled } from 'src/utils/misc';
import { getOutputDimensions } from 'src/utils/transform'; import { getOutputDimensions } from 'src/utils/transform';
interface UpsertFileOptions { interface UpsertFileOptions {
assetId: string; assetId: string;
type: AssetFileType; type: AssetFileType;
path: string; path: string;
isEdited: boolean; isEdited: boolean;
isProgressive: boolean;
} }
type ThumbnailAsset = NonNullable<Awaited<ReturnType<AssetJobRepository['getForGenerateThumbnailJob']>>>; type ThumbnailAsset = NonNullable<Awaited<ReturnType<AssetJobRepository['getForGenerateThumbnailJob']>>>;
@ -171,18 +173,22 @@ export class MediaService extends BaseService {
@OnJob({ name: JobName.AssetEditThumbnailGeneration, queue: QueueName.Editor }) @OnJob({ name: JobName.AssetEditThumbnailGeneration, queue: QueueName.Editor })
async handleAssetEditThumbnailGeneration({ id }: JobOf<JobName.AssetEditThumbnailGeneration>): Promise<JobStatus> { async handleAssetEditThumbnailGeneration({ id }: JobOf<JobName.AssetEditThumbnailGeneration>): Promise<JobStatus> {
const asset = await this.assetJobRepository.getForGenerateThumbnailJob(id); const asset = await this.assetJobRepository.getForGenerateThumbnailJob(id);
const config = await this.getConfig({ withCache: true });
if (!asset) { if (!asset) {
this.logger.warn(`Thumbnail generation failed for asset ${id}: not found in database or missing metadata`); this.logger.warn(`Thumbnail generation failed for asset ${id}: not found in database or missing metadata`);
return JobStatus.Failed; return JobStatus.Failed;
} }
const generated = await this.generateEditedThumbnails(asset); const generated = await this.generateEditedThumbnails(asset, config);
await this.syncFiles(
asset.files.filter((asset) => asset.isEdited),
generated?.files ?? [],
);
let thumbhash: Buffer | undefined = generated?.thumbhash; let thumbhash: Buffer | undefined = generated?.thumbhash;
if (!thumbhash) { if (!thumbhash) {
const { image } = await this.getConfig({ withCache: true }); const extractedImage = await this.extractOriginalImage(asset, config.image);
const extractedImage = await this.extractOriginalImage(asset, image);
const { info, data, colorspace } = extractedImage; const { info, data, colorspace } = extractedImage;
thumbhash = await this.mediaRepository.generateThumbhash(data, { thumbhash = await this.mediaRepository.generateThumbhash(data, {
@ -206,6 +212,7 @@ export class MediaService extends BaseService {
@OnJob({ name: JobName.AssetGenerateThumbnails, queue: QueueName.ThumbnailGeneration }) @OnJob({ name: JobName.AssetGenerateThumbnails, queue: QueueName.ThumbnailGeneration })
async handleGenerateThumbnails({ id }: JobOf<JobName.AssetGenerateThumbnails>): Promise<JobStatus> { async handleGenerateThumbnails({ id }: JobOf<JobName.AssetGenerateThumbnails>): Promise<JobStatus> {
const asset = await this.assetJobRepository.getForGenerateThumbnailJob(id); const asset = await this.assetJobRepository.getForGenerateThumbnailJob(id);
const config = await this.getConfig({ withCache: true });
if (!asset) { if (!asset) {
this.logger.warn(`Thumbnail generation failed for asset ${id}: not found in database or missing metadata`); this.logger.warn(`Thumbnail generation failed for asset ${id}: not found in database or missing metadata`);
@ -217,32 +224,25 @@ export class MediaService extends BaseService {
return JobStatus.Skipped; return JobStatus.Skipped;
} }
let generated: { let generated: Awaited<ReturnType<MediaService['generateImageThumbnails']>>;
previewPath: string;
thumbnailPath: string;
fullsizePath?: string;
thumbhash: Buffer;
fullsizeDimensions?: ImageDimensions;
};
if (asset.type === AssetType.Video || asset.originalFileName.toLowerCase().endsWith('.gif')) { if (asset.type === AssetType.Video || asset.originalFileName.toLowerCase().endsWith('.gif')) {
this.logger.verbose(`Thumbnail generation for video ${id} ${asset.originalPath}`); this.logger.verbose(`Thumbnail generation for video ${id} ${asset.originalPath}`);
generated = await this.generateVideoThumbnails(asset); generated = await this.generateVideoThumbnails(asset, config);
} else if (asset.type === AssetType.Image) { } else if (asset.type === AssetType.Image) {
this.logger.verbose(`Thumbnail generation for image ${id} ${asset.originalPath}`); this.logger.verbose(`Thumbnail generation for image ${id} ${asset.originalPath}`);
generated = await this.generateImageThumbnails(asset); generated = await this.generateImageThumbnails(asset, config);
} else { } else {
this.logger.warn(`Skipping thumbnail generation for asset ${id}: ${asset.type} is not an image or video`); this.logger.warn(`Skipping thumbnail generation for asset ${id}: ${asset.type} is not an image or video`);
return JobStatus.Skipped; return JobStatus.Skipped;
} }
await this.syncFiles(asset, [ const editedGenerated = await this.generateEditedThumbnails(asset, config);
{ type: AssetFileType.Preview, newPath: generated.previewPath, isEdited: false }, if (editedGenerated) {
{ type: AssetFileType.Thumbnail, newPath: generated.thumbnailPath, isEdited: false }, generated.files.push(...editedGenerated.files);
{ type: AssetFileType.FullSize, newPath: generated.fullsizePath, isEdited: false }, }
]);
const editiedGenerated = await this.generateEditedThumbnails(asset); await this.syncFiles(asset.files, generated.files);
const thumbhash = editiedGenerated?.thumbhash || generated.thumbhash; const thumbhash = editedGenerated?.thumbhash || generated.thumbhash;
if (!asset.thumbhash || Buffer.compare(asset.thumbhash, thumbhash) !== 0) { if (!asset.thumbhash || Buffer.compare(asset.thumbhash, thumbhash) !== 0) {
await this.assetRepository.update({ id: asset.id, thumbhash }); await this.assetRepository.update({ id: asset.id, thumbhash });
@ -274,11 +274,7 @@ export class MediaService extends BaseService {
return { info, data, colorspace }; return { info, data, colorspace };
} }
private async extractOriginalImage( private async extractOriginalImage(asset: ThumbnailAsset, image: SystemConfig['image'], useEdits = false) {
asset: NonNullable<ThumbnailAsset>,
image: SystemConfig['image'],
useEdits = false,
) {
const extractEmbedded = image.extractEmbedded && mimeTypes.isRaw(asset.originalFileName); const extractEmbedded = image.extractEmbedded && mimeTypes.isRaw(asset.originalFileName);
const extracted = extractEmbedded ? await this.extractImage(asset.originalPath, image.preview.size) : null; const extracted = extractEmbedded ? await this.extractImage(asset.originalPath, image.preview.size) : null;
const generateFullsize = const generateFullsize =
@ -305,19 +301,21 @@ export class MediaService extends BaseService {
}; };
} }
private async generateImageThumbnails(asset: ThumbnailAsset, useEdits: boolean = false) { private async generateImageThumbnails(asset: ThumbnailAsset, { image }: SystemConfig, useEdits: boolean = false) {
const { image } = await this.getConfig({ withCache: true }); const previewFile = this.getImageFile(asset, {
const previewPath = StorageCore.getImagePath(asset, {
fileType: AssetFileType.Preview, fileType: AssetFileType.Preview,
isEdited: useEdits,
format: image.preview.format, format: image.preview.format,
});
const thumbnailPath = StorageCore.getImagePath(asset, {
fileType: AssetFileType.Thumbnail,
isEdited: useEdits, isEdited: useEdits,
format: image.thumbnail.format, isProgressive: !!image.preview.progressive && image.preview.format !== ImageFormat.Webp,
}); });
this.storageCore.ensureFolders(previewPath); previewFile.isProgressive = !!image.preview.progressive && image.preview.format !== ImageFormat.Webp;
const thumbnailFile = this.getImageFile(asset, {
fileType: AssetFileType.Thumbnail,
format: image.thumbnail.format,
isEdited: useEdits,
isProgressive: !!image.thumbnail.progressive && image.thumbnail.format !== ImageFormat.Webp,
});
this.storageCore.ensureFolders(previewFile.path);
// Handle embedded preview extraction for RAW files // Handle embedded preview extraction for RAW files
const extractedImage = await this.extractOriginalImage(asset, image, useEdits); const extractedImage = await this.extractOriginalImage(asset, image, useEdits);
@ -327,26 +325,18 @@ export class MediaService extends BaseService {
const thumbnailOptions = { colorspace, processInvalidImages: false, raw: info, edits: useEdits ? asset.edits : [] }; const thumbnailOptions = { colorspace, processInvalidImages: false, raw: info, edits: useEdits ? asset.edits : [] };
const promises = [ const promises = [
this.mediaRepository.generateThumbhash(data, thumbnailOptions), this.mediaRepository.generateThumbhash(data, thumbnailOptions),
this.mediaRepository.generateThumbnail( this.mediaRepository.generateThumbnail(data, { ...image.thumbnail, ...thumbnailOptions }, thumbnailFile.path),
data, this.mediaRepository.generateThumbnail(data, { ...image.preview, ...thumbnailOptions }, previewFile.path),
{ ...image.thumbnail, ...thumbnailOptions, edits: useEdits ? asset.edits : [] },
thumbnailPath,
),
this.mediaRepository.generateThumbnail(
data,
{ ...image.preview, ...thumbnailOptions, edits: useEdits ? asset.edits : [] },
previewPath,
),
]; ];
let fullsizePath: string | undefined; let fullsizeFile: UpsertFileOptions | undefined;
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(asset, { fullsizeFile = this.getImageFile(asset, {
fileType: AssetFileType.FullSize, fileType: AssetFileType.FullSize,
isEdited: useEdits,
format: image.fullsize.format, format: image.fullsize.format,
isEdited: useEdits,
isProgressive: !!image.fullsize.progressive && image.fullsize.format !== ImageFormat.Webp,
}); });
const fullsizeOptions = { const fullsizeOptions = {
format: image.fullsize.format, format: image.fullsize.format,
@ -354,23 +344,25 @@ export class MediaService extends BaseService {
progressive: image.fullsize.progressive, progressive: image.fullsize.progressive,
...thumbnailOptions, ...thumbnailOptions,
}; };
promises.push(this.mediaRepository.generateThumbnail(data, fullsizeOptions, fullsizePath)); promises.push(this.mediaRepository.generateThumbnail(data, fullsizeOptions, fullsizeFile.path));
} else if (generateFullsize && extracted && extracted.format === RawExtractedFormat.Jpeg) { } else if (generateFullsize && extracted && extracted.format === RawExtractedFormat.Jpeg) {
fullsizePath = StorageCore.getImagePath(asset, { fullsizeFile = this.getImageFile(asset, {
fileType: AssetFileType.FullSize, fileType: AssetFileType.FullSize,
format: extracted.format, format: extracted.format,
isEdited: false, isEdited: useEdits,
isProgressive: !!image.fullsize.progressive && image.fullsize.format !== ImageFormat.Webp,
}); });
this.storageCore.ensureFolders(fullsizePath); fullsizeFile.isProgressive = !!image.fullsize.progressive && image.fullsize.format !== ImageFormat.Webp;
this.storageCore.ensureFolders(fullsizeFile.path);
// Write the buffer to disk with essential EXIF data // Write the buffer to disk with essential EXIF data
await this.storageRepository.createOrOverwriteFile(fullsizePath, extracted.buffer); await this.storageRepository.createOrOverwriteFile(fullsizeFile.path, extracted.buffer);
await this.mediaRepository.writeExif( await this.mediaRepository.writeExif(
{ {
orientation: asset.exifInfo.orientation, orientation: asset.exifInfo.orientation,
colorspace: asset.exifInfo.colorspace, colorspace: asset.exifInfo.colorspace,
}, },
fullsizePath, fullsizeFile.path,
); );
} }
@ -378,9 +370,9 @@ export class MediaService extends BaseService {
if (asset.exifInfo.projectionType === 'EQUIRECTANGULAR') { if (asset.exifInfo.projectionType === 'EQUIRECTANGULAR') {
const promises = [ const promises = [
this.mediaRepository.copyTagGroup('XMP-GPano', asset.originalPath, previewPath), this.mediaRepository.copyTagGroup('XMP-GPano', asset.originalPath, previewFile.path),
fullsizePath fullsizeFile
? this.mediaRepository.copyTagGroup('XMP-GPano', asset.originalPath, fullsizePath) ? this.mediaRepository.copyTagGroup('XMP-GPano', asset.originalPath, fullsizeFile.path)
: Promise.resolve(), : Promise.resolve(),
]; ];
await Promise.all(promises); await Promise.all(promises);
@ -389,7 +381,11 @@ export class MediaService extends BaseService {
const decodedDimensions = { width: info.width, height: info.height }; const decodedDimensions = { width: info.width, height: info.height };
const fullsizeDimensions = useEdits ? getOutputDimensions(asset.edits, decodedDimensions) : decodedDimensions; const fullsizeDimensions = useEdits ? getOutputDimensions(asset.edits, decodedDimensions) : decodedDimensions;
return { previewPath, thumbnailPath, fullsizePath, thumbhash: outputs[0] as Buffer, fullsizeDimensions }; return {
files: fullsizeFile ? [previewFile, thumbnailFile, fullsizeFile] : [previewFile, thumbnailFile],
thumbhash: outputs[0] as Buffer,
fullsizeDimensions,
};
} }
@OnJob({ name: JobName.PersonGenerateThumbnail, queue: QueueName.ThumbnailGeneration }) @OnJob({ name: JobName.PersonGenerateThumbnail, queue: QueueName.ThumbnailGeneration })
@ -493,19 +489,23 @@ export class MediaService extends BaseService {
}; };
} }
private async generateVideoThumbnails(asset: ThumbnailPathEntity & { originalPath: string }) { private async generateVideoThumbnails(
const { image, ffmpeg } = await this.getConfig({ withCache: true }); asset: ThumbnailPathEntity & { originalPath: string },
const previewPath = StorageCore.getImagePath(asset, { { ffmpeg, image }: SystemConfig,
) {
const previewFile = this.getImageFile(asset, {
fileType: AssetFileType.Preview, fileType: AssetFileType.Preview,
format: image.preview.format, format: image.preview.format,
isEdited: false, isEdited: false,
isProgressive: false,
}); });
const thumbnailPath = StorageCore.getImagePath(asset, { const thumbnailFile = this.getImageFile(asset, {
fileType: AssetFileType.Thumbnail, fileType: AssetFileType.Thumbnail,
format: image.thumbnail.format, format: image.thumbnail.format,
isEdited: false, isEdited: false,
isProgressive: false,
}); });
this.storageCore.ensureFolders(previewPath); this.storageCore.ensureFolders(previewFile.path);
const { format, audioStreams, videoStreams } = await this.mediaRepository.probe(asset.originalPath); const { format, audioStreams, videoStreams } = await this.mediaRepository.probe(asset.originalPath);
const mainVideoStream = this.getMainStream(videoStreams); const mainVideoStream = this.getMainStream(videoStreams);
@ -524,17 +524,16 @@ export class MediaService extends BaseService {
format, format,
); );
await this.mediaRepository.transcode(asset.originalPath, previewPath, previewOptions); await this.mediaRepository.transcode(asset.originalPath, previewFile.path, previewOptions);
await this.mediaRepository.transcode(asset.originalPath, thumbnailPath, thumbnailOptions); await this.mediaRepository.transcode(asset.originalPath, thumbnailFile.path, thumbnailOptions);
const thumbhash = await this.mediaRepository.generateThumbhash(previewPath, { const thumbhash = await this.mediaRepository.generateThumbhash(previewFile.path, {
colorspace: image.colorspace, colorspace: image.colorspace,
processInvalidImages: process.env.IMMICH_PROCESS_INVALID_IMAGES === 'true', processInvalidImages: process.env.IMMICH_PROCESS_INVALID_IMAGES === 'true',
}); });
return { return {
previewPath, files: [previewFile, thumbnailFile],
thumbnailPath,
thumbhash, thumbhash,
fullsizeDimensions: { width: mainVideoStream.width, height: mainVideoStream.height }, fullsizeDimensions: { width: mainVideoStream.width, height: mainVideoStream.height },
}; };
@ -791,34 +790,28 @@ export class MediaService extends BaseService {
} }
} }
private async syncFiles( private async syncFiles(oldFiles: (AssetFile & { isProgressive: boolean })[], newFiles: UpsertFileOptions[]) {
asset: { id: string; files: AssetFile[] },
files: { type: AssetFileType; newPath?: string; isEdited: boolean }[],
) {
const toUpsert: UpsertFileOptions[] = []; const toUpsert: UpsertFileOptions[] = [];
const pathsToDelete: string[] = []; const pathsToDelete: string[] = [];
const toDelete: AssetFile[] = []; const toDelete = new Set(oldFiles);
for (const { type, newPath, isEdited } of files) { for (const newFile of newFiles) {
const existingFile = asset.files.find((file) => file.type === type && file.isEdited === isEdited); const existingFile = oldFiles.find((file) => file.type === newFile.type && file.isEdited === newFile.isEdited);
if (existingFile) {
// upsert new file path toDelete.delete(existingFile);
if (newPath && existingFile?.path !== newPath) {
toUpsert.push({ assetId: asset.id, path: newPath, type, isEdited });
// delete old file from disk
if (existingFile) {
this.logger.debug(`Deleting old ${type} image for asset ${asset.id} in favor of a replacement`);
pathsToDelete.push(existingFile.path);
}
} }
// delete old file from disk and database // upsert new file path
if (!newPath && existingFile) { if (existingFile?.path !== newFile.path || existingFile.isProgressive !== newFile.isProgressive) {
this.logger.debug(`Deleting old ${type} image for asset ${asset.id}`); toUpsert.push(newFile);
pathsToDelete.push(existingFile.path); // delete old file from disk
toDelete.push(existingFile); if (existingFile && existingFile.path !== newFile.path) {
this.logger.debug(
`Deleting old ${newFile.type} image for asset ${newFile.assetId} in favor of a replacement`,
);
pathsToDelete.push(existingFile.path);
}
} }
} }
@ -826,8 +819,12 @@ export class MediaService extends BaseService {
await this.assetRepository.upsertFiles(toUpsert); await this.assetRepository.upsertFiles(toUpsert);
} }
if (toDelete.length > 0) { if (toDelete.size > 0) {
await this.assetRepository.deleteFiles(toDelete); const toDeleteArray = [...toDelete];
for (const file of toDeleteArray) {
pathsToDelete.push(file.path);
}
await this.assetRepository.deleteFiles(toDeleteArray);
} }
if (pathsToDelete.length > 0) { if (pathsToDelete.length > 0) {
@ -835,18 +832,12 @@ export class MediaService extends BaseService {
} }
} }
private async generateEditedThumbnails(asset: ThumbnailAsset) { private async generateEditedThumbnails(asset: ThumbnailAsset, config: SystemConfig) {
if (asset.type !== AssetType.Image) { if (asset.type !== AssetType.Image || (asset.files.length === 0 && asset.edits.length === 0)) {
return; return;
} }
const generated = asset.edits.length > 0 ? await this.generateImageThumbnails(asset, true) : undefined; const generated = asset.edits.length > 0 ? await this.generateImageThumbnails(asset, config, true) : undefined;
await this.syncFiles(asset, [
{ 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); const crop = asset.edits.find((e) => e.action === AssetEditAction.Crop);
const cropBox = crop const cropBox = crop
@ -870,4 +861,15 @@ export class MediaService extends BaseService {
return generated; return generated;
} }
private getImageFile(asset: ThumbnailPathEntity, options: ImagePathOptions & { isProgressive: boolean }) {
const path = StorageCore.getImagePath(asset, options);
return {
assetId: asset.id,
type: options.fileType,
path,
isEdited: options.isEdited,
isProgressive: options.isProgressive,
};
}
} }

View file

@ -48,9 +48,9 @@ const editedFullsizeFile = factory.assetFile({
isEdited: true, isEdited: true,
}); });
const files: AssetFile[] = [fullsizeFile, previewFile, thumbnailFile]; const files = [fullsizeFile, previewFile, thumbnailFile];
const editedFiles: AssetFile[] = [ const editedFiles = [
fullsizeFile, fullsizeFile,
previewFile, previewFile,
thumbnailFile, thumbnailFile,
@ -624,14 +624,19 @@ export const assetStub = {
fileSizeInByte: 100_000, fileSizeInByte: 100_000,
timeZone: `America/New_York`, timeZone: `America/New_York`,
}, },
files: [] as AssetFile[], files: [],
libraryId: null, libraryId: null,
visibility: AssetVisibility.Hidden, visibility: AssetVisibility.Hidden,
width: null, width: null,
height: null, height: null,
edits: [] as AssetEditActionItem[], edits: [] as AssetEditActionItem[],
isEdited: false, isEdited: false,
} as MapAsset & { faces: AssetFace[]; files: AssetFile[]; exifInfo: Exif; edits: AssetEditActionItem[] }), } as unknown as MapAsset & {
faces: AssetFace[];
files: (AssetFile & { isProgressive: boolean })[];
exifInfo: Exif;
edits: AssetEditActionItem[];
}),
livePhotoStillAsset: Object.freeze({ livePhotoStillAsset: Object.freeze({
id: 'live-photo-still-asset', id: 'live-photo-still-asset',
@ -653,7 +658,11 @@ export const assetStub = {
height: null, height: null,
edits: [] as AssetEditActionItem[], edits: [] as AssetEditActionItem[],
isEdited: false, isEdited: false,
} as MapAsset & { faces: AssetFace[]; files: AssetFile[]; edits: AssetEditActionItem[] }), } as unknown as MapAsset & {
faces: AssetFace[];
files: (AssetFile & { isProgressive: boolean })[];
edits: AssetEditActionItem[];
}),
livePhotoWithOriginalFileName: Object.freeze({ livePhotoWithOriginalFileName: Object.freeze({
id: 'live-photo-still-asset', id: 'live-photo-still-asset',

View file

@ -400,11 +400,12 @@ const assetOcrFactory = (
...ocr, ...ocr,
}); });
const assetFileFactory = (file: Partial<AssetFile> = {}): AssetFile => ({ const assetFileFactory = (file: Partial<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, isEdited: false,
isProgressive: false,
...file, ...file,
}); });