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
6 changed files with 107 additions and 94 deletions

View File

@@ -78,43 +78,13 @@ limit
-- AssetJobRepository.streamForThumbnailJob
select
"asset"."id",
"asset"."thumbhash",
(
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"
"asset"."isEdited"
from
"asset"
inner join "asset_job_status" on "asset_job_status"."assetId" = "asset"."id"
where
"asset"."deletedAt" is null
and "asset"."visibility" != $1
and "asset"."visibility" != 'hidden'
and (
not exists (
select
@@ -122,7 +92,7 @@ where
"asset_file"
where
"assetId" = "asset"."id"
and "asset_file"."type" = $2
and "type" = 'thumbnail'
)
or not exists (
select
@@ -130,17 +100,75 @@ where
"asset_file"
where
"assetId" = "asset"."id"
and "asset_file"."type" = $3
and "type" = 'preview'
)
or not exists (
select
from
"asset_file"
where
"assetId" = "asset"."id"
and "asset_file"."type" = $4
or (
"asset"."isEdited" = true
and not exists (
select
from
"asset_file"
where
"assetId" = "asset"."id"
and "type" = 'fullsize'
and "asset_file"."isEdited" = true
)
)
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

View File

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

View File

@@ -27,11 +27,6 @@ import { systemConfigStub } from 'test/fixtures/system-config.stub';
import { factory } from 'test/small.factory';
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 rawBuffer = Buffer.from('raw image data');
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 () => {
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.person.getAll.mockReturnValue(makeStream());
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 () => {
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.person.getAll.mockReturnValue(makeStream());
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 () => {
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.person.getAll.mockReturnValue(makeStream());
await sut.handleQueueGenerateThumbnails({ force: true });

View File

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

View File

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

View File

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