fix(server): thumbnail queueing (#26077)

* fix thumbnail queueing

* add bmp

* other isEdited column
This commit is contained in:
Mert 2026-02-10 10:04:03 -05:00 committed by GitHub
parent c3730c8eab
commit 7fa6f617f5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 107 additions and 94 deletions

View file

@ -78,43 +78,13 @@ limit
-- AssetJobRepository.streamForThumbnailJob -- AssetJobRepository.streamForThumbnailJob
select select
"asset"."id", "asset"."id",
"asset"."thumbhash", "asset"."isEdited"
(
select
coalesce(json_agg(agg), '[]')
from
(
select
"asset_file"."id",
"asset_file"."path",
"asset_file"."type",
"asset_file"."isEdited"
from
"asset_file"
where
"asset_file"."assetId" = "asset"."id"
) as agg
) as "files",
(
select
coalesce(json_agg(agg), '[]')
from
(
select
"asset_edit"."action",
"asset_edit"."parameters"
from
"asset_edit"
where
"asset_edit"."assetId" = "asset"."id"
) as agg
) as "edits"
from from
"asset" "asset"
inner join "asset_job_status" on "asset_job_status"."assetId" = "asset"."id" inner join "asset_job_status" on "asset_job_status"."assetId" = "asset"."id"
where where
"asset"."deletedAt" is null "asset"."deletedAt" is null
and "asset"."visibility" != $1 and "asset"."visibility" != 'hidden'
and ( and (
not exists ( not exists (
select select
@ -122,7 +92,7 @@ where
"asset_file" "asset_file"
where where
"assetId" = "asset"."id" "assetId" = "asset"."id"
and "asset_file"."type" = $2 and "type" = 'thumbnail'
) )
or not exists ( or not exists (
select select
@ -130,17 +100,75 @@ where
"asset_file" "asset_file"
where where
"assetId" = "asset"."id" "assetId" = "asset"."id"
and "asset_file"."type" = $3 and "type" = 'preview'
) )
or not exists ( or (
select "asset"."isEdited" = true
from and not exists (
"asset_file" select
where from
"assetId" = "asset"."id" "asset_file"
and "asset_file"."type" = $4 where
"assetId" = "asset"."id"
and "type" = 'fullsize'
and "asset_file"."isEdited" = true
)
) )
or "asset"."thumbhash" is null or "asset"."thumbhash" is null
or (
not exists (
select
from
"asset_file"
where
"assetId" = "asset"."id"
and "type" = 'fullsize'
)
and f_unaccent (asset."originalFileName") like any (
array[
'%.3fr',
'%.ari',
'%.arw',
'%.cap',
'%.cin',
'%.cr2',
'%.cr3',
'%.crw',
'%.dcr',
'%.dng',
'%.erf',
'%.fff',
'%.iiq',
'%.k25',
'%.kdc',
'%.mrw',
'%.nef',
'%.nrw',
'%.orf',
'%.ori',
'%.pef',
'%.psd',
'%.raf',
'%.raw',
'%.rw2',
'%.rwl',
'%.sr2',
'%.srf',
'%.srw',
'%.x3f',
'%.heic',
'%.heif',
'%.hif',
'%.insp',
'%.jp2',
'%.jpe',
'%.jxl',
'%.svg',
'%.tif',
'%.tiff'
]::text[]
)
)
) )
-- AssetJobRepository.getForMigrationJob -- AssetJobRepository.getForMigrationJob

View file

@ -18,6 +18,7 @@ import {
withFilePath, withFilePath,
withFiles, withFiles,
} from 'src/utils/database'; } from 'src/utils/database';
import { mimeTypes } from 'src/utils/mime-types';
@Injectable() @Injectable()
export class AssetJobRepository { export class AssetJobRepository {
@ -61,51 +62,40 @@ export class AssetJobRepository {
streamForThumbnailJob(options: { force: boolean | undefined; fullsizeEnabled: boolean }) { streamForThumbnailJob(options: { force: boolean | undefined; fullsizeEnabled: boolean }) {
return this.db return this.db
.selectFrom('asset') .selectFrom('asset')
.select(['asset.id', 'asset.thumbhash']) .select(['asset.id', 'asset.isEdited'])
.select(withFiles)
.select(withEdits)
.where('asset.deletedAt', 'is', null) .where('asset.deletedAt', 'is', null)
.where('asset.visibility', '!=', AssetVisibility.Hidden) .where('asset.visibility', '!=', sql.lit(AssetVisibility.Hidden))
.$if(!options.force, (qb) => .$if(!options.force, (qb) =>
qb qb
// If there aren't any entries, metadata extraction hasn't run yet which is required for thumbnails // If there aren't any entries, metadata extraction hasn't run yet which is required for thumbnails
.innerJoin('asset_job_status', 'asset_job_status.assetId', 'asset.id') .innerJoin('asset_job_status', 'asset_job_status.assetId', 'asset.id')
.where((eb) => { .where(({ and, eb, exists, not, or, selectFrom }) => {
const file = (type: AssetFileType) =>
selectFrom('asset_file').whereRef('assetId', '=', 'asset.id').where('type', '=', sql.lit(type));
const conditions = [ const conditions = [
eb.not((eb) => not(exists(file(AssetFileType.Thumbnail))),
eb.exists((qb) => not(exists(file(AssetFileType.Preview))),
qb and([
.selectFrom('asset_file') eb('asset.isEdited', '=', sql.lit(true)),
.whereRef('assetId', '=', 'asset.id') not(exists(file(AssetFileType.FullSize).where('asset_file.isEdited', '=', sql.lit(true)))),
.where('asset_file.type', '=', AssetFileType.Preview), ]),
), eb('asset.thumbhash', 'is', null),
),
eb.not((eb) =>
eb.exists((qb) =>
qb
.selectFrom('asset_file')
.whereRef('assetId', '=', 'asset.id')
.where('asset_file.type', '=', AssetFileType.Thumbnail),
),
),
]; ];
if (options.fullsizeEnabled) { if (options.fullsizeEnabled) {
const isWebUnsupported = sql.join(
Object.keys(mimeTypes.webUnsupportedImage).map((ext) => sql.lit(`%${ext}`)),
);
conditions.push( conditions.push(
eb.not((eb) => and([
eb.exists((qb) => not(exists(file(AssetFileType.FullSize))),
qb eb(sql`f_unaccent(asset."originalFileName")`, 'like', sql`any(array[${isWebUnsupported}]::text[])`),
.selectFrom('asset_file') ]),
.whereRef('assetId', '=', 'asset.id')
.where('asset_file.type', '=', AssetFileType.FullSize),
),
),
); );
} }
conditions.push(eb('asset.thumbhash', 'is', null)); return or(conditions);
return eb.or(conditions);
}), }),
) )
.stream(); .stream();

View file

@ -27,11 +27,6 @@ import { systemConfigStub } from 'test/fixtures/system-config.stub';
import { factory } from 'test/small.factory'; import { factory } from 'test/small.factory';
import { makeStream, newTestService, ServiceMocks } from 'test/utils'; import { makeStream, newTestService, ServiceMocks } from 'test/utils';
const filesNoFullsize = [
factory.assetFile({ type: AssetFileType.Preview }),
factory.assetFile({ type: AssetFileType.Thumbnail }),
];
const fullsizeBuffer = Buffer.from('embedded image data'); const fullsizeBuffer = Buffer.from('embedded image data');
const rawBuffer = Buffer.from('raw image data'); const rawBuffer = Buffer.from('raw image data');
const extractedBuffer = Buffer.from('embedded image file'); const extractedBuffer = Buffer.from('embedded image file');
@ -171,7 +166,7 @@ describe(MediaService.name, () => {
it('should queue all assets with missing fullsize when feature is enabled', async () => { it('should queue all assets with missing fullsize when feature is enabled', async () => {
mocks.systemMetadata.get.mockResolvedValue({ image: { fullsize: { enabled: true } } }); mocks.systemMetadata.get.mockResolvedValue({ image: { fullsize: { enabled: true } } });
const asset = { id: factory.uuid(), thumbhash: factory.buffer(), edits: [], files: filesNoFullsize }; const asset = { id: factory.uuid(), isEdited: false };
mocks.assetJob.streamForThumbnailJob.mockReturnValue(makeStream([asset])); mocks.assetJob.streamForThumbnailJob.mockReturnValue(makeStream([asset]));
mocks.person.getAll.mockReturnValue(makeStream()); mocks.person.getAll.mockReturnValue(makeStream());
await sut.handleQueueGenerateThumbnails({ force: false }); await sut.handleQueueGenerateThumbnails({ force: false });
@ -189,7 +184,7 @@ describe(MediaService.name, () => {
it('should not queue assets with missing fullsize when feature is disabled', async () => { it('should not queue assets with missing fullsize when feature is disabled', async () => {
mocks.systemMetadata.get.mockResolvedValue({ image: { fullsize: { enabled: false } } }); mocks.systemMetadata.get.mockResolvedValue({ image: { fullsize: { enabled: false } } });
const asset = { id: factory.uuid(), thumbhash: factory.buffer(), edits: [], files: filesNoFullsize }; const asset = { id: factory.uuid(), isEdited: false };
mocks.assetJob.streamForThumbnailJob.mockReturnValue(makeStream([asset])); mocks.assetJob.streamForThumbnailJob.mockReturnValue(makeStream([asset]));
mocks.person.getAll.mockReturnValue(makeStream()); mocks.person.getAll.mockReturnValue(makeStream());
await sut.handleQueueGenerateThumbnails({ force: false }); await sut.handleQueueGenerateThumbnails({ force: false });
@ -230,7 +225,7 @@ describe(MediaService.name, () => {
it('should queue assets with missing fullsize when force is true, regardless of setting', async () => { it('should queue assets with missing fullsize when force is true, regardless of setting', async () => {
mocks.systemMetadata.get.mockResolvedValue({ image: { fullsize: { enabled: false } } }); mocks.systemMetadata.get.mockResolvedValue({ image: { fullsize: { enabled: false } } });
const asset = { id: factory.uuid(), thumbhash: Buffer.from('thumbhash'), edits: [], files: filesNoFullsize }; const asset = { id: factory.uuid(), isEdited: false };
mocks.assetJob.streamForThumbnailJob.mockReturnValue(makeStream([asset])); mocks.assetJob.streamForThumbnailJob.mockReturnValue(makeStream([asset]));
mocks.person.getAll.mockReturnValue(makeStream()); mocks.person.getAll.mockReturnValue(makeStream());
await sut.handleQueueGenerateThumbnails({ force: true }); await sut.handleQueueGenerateThumbnails({ force: true });

View file

@ -39,7 +39,7 @@ import {
VideoInterfaces, VideoInterfaces,
VideoStreamInfo, VideoStreamInfo,
} from 'src/types'; } from 'src/types';
import { getAssetFiles, getDimensions } from 'src/utils/asset.util'; import { getDimensions } from 'src/utils/asset.util';
import { checkFaceVisibility, checkOcrVisibility } from 'src/utils/editor'; import { checkFaceVisibility, checkOcrVisibility } from 'src/utils/editor';
import { BaseConfig, ThumbnailConfig } from 'src/utils/media'; import { BaseConfig, ThumbnailConfig } from 'src/utils/media';
import { mimeTypes } from 'src/utils/mime-types'; import { mimeTypes } from 'src/utils/mime-types';
@ -78,17 +78,11 @@ export class MediaService extends BaseService {
const fullsizeEnabled = config.image.fullsize.enabled; const fullsizeEnabled = config.image.fullsize.enabled;
for await (const asset of this.assetJobRepository.streamForThumbnailJob({ force, fullsizeEnabled })) { for await (const asset of this.assetJobRepository.streamForThumbnailJob({ force, fullsizeEnabled })) {
const { previewFile, thumbnailFile, fullsizeFile, editedPreviewFile, editedThumbnailFile, editedFullsizeFile } = if (force || !asset.isEdited) {
getAssetFiles(asset.files);
if (force || !previewFile || !thumbnailFile || !asset.thumbhash || (fullsizeEnabled && !fullsizeFile)) {
jobs.push({ name: JobName.AssetGenerateThumbnails, data: { id: asset.id } }); jobs.push({ name: JobName.AssetGenerateThumbnails, data: { id: asset.id } });
} }
if ( if (asset.isEdited) {
asset.edits.length > 0 &&
(force || !editedPreviewFile || !editedThumbnailFile || (fullsizeEnabled && !editedFullsizeFile))
) {
jobs.push({ name: JobName.AssetEditThumbnailGeneration, data: { id: asset.id } }); jobs.push({ name: JobName.AssetEditThumbnailGeneration, data: { id: asset.id } });
} }

View file

@ -1,7 +1,7 @@
import { extname } from 'node:path'; import { extname } from 'node:path';
import { AssetType } from 'src/enum'; import { AssetType } from 'src/enum';
const raw: Record<string, string[]> = { const raw = {
'.3fr': ['image/3fr', 'image/x-hasselblad-3fr'], '.3fr': ['image/3fr', 'image/x-hasselblad-3fr'],
'.ari': ['image/ari', 'image/x-arriflex-ari'], '.ari': ['image/ari', 'image/x-arriflex-ari'],
'.arw': ['image/arw', 'image/x-sony-arw'], '.arw': ['image/arw', 'image/x-sony-arw'],
@ -41,6 +41,7 @@ const raw: Record<string, string[]> = {
**/ **/
const webSupportedImage = { const webSupportedImage = {
'.avif': ['image/avif'], '.avif': ['image/avif'],
'.bmp': ['image/bmp'],
'.gif': ['image/gif'], '.gif': ['image/gif'],
'.jpeg': ['image/jpeg'], '.jpeg': ['image/jpeg'],
'.jpg': ['image/jpeg'], '.jpg': ['image/jpeg'],
@ -48,10 +49,8 @@ const webSupportedImage = {
'.webp': ['image/webp'], '.webp': ['image/webp'],
}; };
const image: Record<string, string[]> = { const webUnsupportedImage = {
...raw, ...raw,
...webSupportedImage,
'.bmp': ['image/bmp'],
'.heic': ['image/heic'], '.heic': ['image/heic'],
'.heif': ['image/heif'], '.heif': ['image/heif'],
'.hif': ['image/hif'], '.hif': ['image/hif'],
@ -64,6 +63,11 @@ const image: Record<string, string[]> = {
'.tiff': ['image/tiff'], '.tiff': ['image/tiff'],
}; };
const image: Record<string, string[]> = {
...webSupportedImage,
...webUnsupportedImage,
};
const possiblyAnimatedImageExtensions = new Set(['.avif', '.gif', '.heic', '.heif', '.jxl', '.png', '.webp']); const possiblyAnimatedImageExtensions = new Set(['.avif', '.gif', '.heic', '.heif', '.jxl', '.png', '.webp']);
const possiblyAnimatedImage: Record<string, string[]> = Object.fromEntries( const possiblyAnimatedImage: Record<string, string[]> = Object.fromEntries(
Object.entries(image).filter(([key]) => possiblyAnimatedImageExtensions.has(key)), Object.entries(image).filter(([key]) => possiblyAnimatedImageExtensions.has(key)),
@ -120,6 +124,7 @@ export const mimeTypes = {
sidecar, sidecar,
video, video,
raw, raw,
webUnsupportedImage,
isAsset: (filename: string) => isType(filename, image) || isType(filename, video), isAsset: (filename: string) => isType(filename, image) || isType(filename, video),
isImage: (filename: string) => isType(filename, image), isImage: (filename: string) => isType(filename, image),

View file

@ -295,6 +295,7 @@ export function getAssetRatio(asset: AssetResponseDto) {
const supportedImageMimeTypes = new Set([ const supportedImageMimeTypes = new Set([
'image/apng', 'image/apng',
'image/avif', 'image/avif',
'image/bmp',
'image/gif', 'image/gif',
'image/jpeg', 'image/jpeg',
'image/png', 'image/png',