fix: queue assets missing fullsize files for thumbnail regeneration

This commit is contained in:
midzelis
2026-02-01 20:19:05 +00:00
parent 1436e2a75f
commit 388d175bdb
4 changed files with 120 additions and 2 deletions

View File

@@ -89,6 +89,14 @@ export class AssetJobRepository {
.where('asset_file.type', '=', AssetFileType.Thumbnail),
),
),
eb.not((eb) =>
eb.exists((qb) =>
qb
.selectFrom('asset_file')
.whereRef('assetId', '=', 'asset.id')
.where('asset_file.type', '=', AssetFileType.FullSize),
),
),
eb('asset.thumbhash', 'is', null),
]),
),

View File

@@ -165,6 +165,35 @@ describe(MediaService.name, () => {
expect(mocks.person.getAll).toHaveBeenCalledWith({ thumbnailPath: '' });
});
it('should queue all assets with missing fullsize when feature is enabled', async () => {
mocks.systemMetadata.get.mockResolvedValue({ image: { fullsize: { enabled: true } } });
mocks.assetJob.streamForThumbnailJob.mockReturnValue(makeStream([assetStub.noFullsize]));
mocks.person.getAll.mockReturnValue(makeStream());
await sut.handleQueueGenerateThumbnails({ force: false });
expect(mocks.assetJob.streamForThumbnailJob).toHaveBeenCalledWith(false);
expect(mocks.job.queueAll).toHaveBeenCalledWith([
{
name: JobName.AssetGenerateThumbnails,
data: { id: assetStub.image.id },
},
]);
expect(mocks.person.getAll).toHaveBeenCalledWith({ thumbnailPath: '' });
});
it('should not queue assets with missing fullsize when feature is disabled', async () => {
mocks.systemMetadata.get.mockResolvedValue({ image: { fullsize: { enabled: false } } });
mocks.assetJob.streamForThumbnailJob.mockReturnValue(makeStream([assetStub.noFullsize]));
mocks.person.getAll.mockReturnValue(makeStream());
await sut.handleQueueGenerateThumbnails({ force: false });
expect(mocks.assetJob.streamForThumbnailJob).toHaveBeenCalledWith(false);
expect(mocks.job.queueAll).toHaveBeenCalledWith([]);
expect(mocks.person.getAll).toHaveBeenCalledWith({ thumbnailPath: '' });
});
it('should queue assets with edits but missing edited thumbnails', async () => {
mocks.assetJob.streamForThumbnailJob.mockReturnValue(makeStream([assetStub.withCropEdit]));
mocks.person.getAll.mockReturnValue(makeStream());
@@ -181,6 +210,35 @@ describe(MediaService.name, () => {
expect(mocks.person.getAll).toHaveBeenCalledWith({ thumbnailPath: '' });
});
it('should not queue assets with missing edited fullsize when feature is disabled', async () => {
mocks.systemMetadata.get.mockResolvedValue({ image: { fullsize: { enabled: false } } });
mocks.assetJob.streamForThumbnailJob.mockReturnValue(makeStream([assetStub.withCropEdit]));
mocks.person.getAll.mockReturnValue(makeStream());
await sut.handleQueueGenerateThumbnails({ force: false });
expect(mocks.assetJob.streamForThumbnailJob).toHaveBeenCalledWith(false);
expect(mocks.job.queueAll).toHaveBeenCalledWith([]);
expect(mocks.person.getAll).toHaveBeenCalledWith({ thumbnailPath: '' });
});
it('should queue assets with missing fullsize when force is true, regardless of setting', async () => {
mocks.systemMetadata.get.mockResolvedValue({ image: { fullsize: { enabled: false } } });
mocks.assetJob.streamForThumbnailJob.mockReturnValue(makeStream([assetStub.noFullsize]));
mocks.person.getAll.mockReturnValue(makeStream());
await sut.handleQueueGenerateThumbnails({ force: true });
expect(mocks.assetJob.streamForThumbnailJob).toHaveBeenCalledWith(true);
expect(mocks.job.queueAll).toHaveBeenCalledWith([
{
name: JobName.AssetGenerateThumbnails,
data: { id: assetStub.image.id },
},
]);
expect(mocks.person.getAll).toHaveBeenCalled();
});
it('should queue both regular and edited thumbnails for assets with edits when force is true', async () => {
mocks.assetJob.streamForThumbnailJob.mockReturnValue(makeStream([assetStub.withCropEdit]));
mocks.person.getAll.mockReturnValue(makeStream());

View File

@@ -68,6 +68,7 @@ export class MediaService extends BaseService {
@OnJob({ name: JobName.AssetGenerateThumbnailsQueueAll, queue: QueueName.ThumbnailGeneration })
async handleQueueGenerateThumbnails({ force }: JobOf<JobName.AssetGenerateThumbnailsQueueAll>): Promise<JobStatus> {
const config = await this.getConfig({ withCache: true });
let jobs: JobItem[] = [];
const queueAll = async () => {
@@ -78,13 +79,22 @@ export class MediaService extends BaseService {
for await (const asset of this.assetJobRepository.streamForThumbnailJob(!!force)) {
const assetFiles = getAssetFiles(asset.files);
if (!assetFiles.previewFile || !assetFiles.thumbnailFile || !asset.thumbhash || force) {
if (
!assetFiles.previewFile ||
!assetFiles.thumbnailFile ||
(config.image.fullsize.enabled && !assetFiles.fullsizeFile) ||
!asset.thumbhash ||
force
) {
jobs.push({ name: JobName.AssetGenerateThumbnails, data: { id: asset.id } });
}
if (
asset.edits.length > 0 &&
(!assetFiles.editedPreviewFile || !assetFiles.editedThumbnailFile || !assetFiles.editedFullsizeFile || force)
(!assetFiles.editedPreviewFile ||
!assetFiles.editedThumbnailFile ||
(config.image.fullsize.enabled && !assetFiles.editedFullsizeFile) ||
force)
) {
jobs.push({ name: JobName.AssetEditThumbnailGeneration, data: { id: asset.id } });
}

View File

@@ -50,6 +50,8 @@ const editedFullsizeFile = factory.assetFile({
const files = [fullsizeFile, previewFile, thumbnailFile];
const filesWithoutFullsize = [previewFile, thumbnailFile];
const editedFiles = [
fullsizeFile,
previewFile,
@@ -213,6 +215,46 @@ export const assetStub = {
isEdited: false,
}),
noFullsize: Object.freeze({
id: 'asset-id',
status: AssetStatus.Active,
deviceAssetId: 'device-asset-id',
fileModifiedAt: new Date('2023-02-23T05:06:29.716Z'),
fileCreatedAt: new Date('2023-02-23T05:06:29.716Z'),
owner: userStub.user1,
ownerId: 'user-id',
deviceId: 'device-id',
originalPath: '/data/library/IMG_789.jpg',
files: filesWithoutFullsize,
checksum: Buffer.from('file hash', 'utf8'),
type: AssetType.Image,
thumbhash: Buffer.from('blablabla', 'base64'),
encodedVideoPath: null,
createdAt: new Date('2023-02-23T05:06:29.716Z'),
updatedAt: new Date('2023-02-23T05:06:29.716Z'),
localDateTime: new Date('2023-02-23T05:06:29.716Z'),
isFavorite: true,
duration: null,
isExternal: false,
livePhotoVideo: null,
livePhotoVideoId: null,
sharedLinks: [],
originalFileName: 'IMG_789.jpg',
faces: [],
deletedAt: null,
duplicateId: null,
isOffline: false,
libraryId: null,
stackId: null,
updateId: '42',
visibility: AssetVisibility.Timeline,
width: null,
height: null,
edits: [],
isEdited: false,
exifInfo: {} as Exif,
}),
primaryImage: Object.freeze({
id: 'primary-asset-id',
status: AssetStatus.Active,