mirror of
https://github.com/samsonjs/immich.git
synced 2026-04-27 15:07:45 +00:00
fix: queue assets missing fullsize files for thumbnail regeneration (#25794)
* fix: queue assets missing fullsize files for thumbnail regeneration * refactor: query --------- Co-authored-by: Jason Rasmussen <jason@rasm.me>
This commit is contained in:
parent
37e5968a7a
commit
092ebe01a5
5 changed files with 116 additions and 21 deletions
|
|
@ -132,6 +132,14 @@ where
|
||||||
"assetId" = "asset"."id"
|
"assetId" = "asset"."id"
|
||||||
and "asset_file"."type" = $3
|
and "asset_file"."type" = $3
|
||||||
)
|
)
|
||||||
|
or not exists (
|
||||||
|
select
|
||||||
|
from
|
||||||
|
"asset_file"
|
||||||
|
where
|
||||||
|
"assetId" = "asset"."id"
|
||||||
|
and "asset_file"."type" = $4
|
||||||
|
)
|
||||||
or "asset"."thumbhash" is null
|
or "asset"."thumbhash" is null
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -57,8 +57,8 @@ export class AssetJobRepository {
|
||||||
.executeTakeFirst();
|
.executeTakeFirst();
|
||||||
}
|
}
|
||||||
|
|
||||||
@GenerateSql({ params: [false], stream: true })
|
@GenerateSql({ params: [{ force: false, fullsizeEnabled: true }], stream: true })
|
||||||
streamForThumbnailJob(force: 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.thumbhash'])
|
||||||
|
|
@ -66,12 +66,12 @@ export class AssetJobRepository {
|
||||||
.select(withEdits)
|
.select(withEdits)
|
||||||
.where('asset.deletedAt', 'is', null)
|
.where('asset.deletedAt', 'is', null)
|
||||||
.where('asset.visibility', '!=', AssetVisibility.Hidden)
|
.where('asset.visibility', '!=', AssetVisibility.Hidden)
|
||||||
.$if(!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((eb) => {
|
||||||
eb.or([
|
const conditions = [
|
||||||
eb.not((eb) =>
|
eb.not((eb) =>
|
||||||
eb.exists((qb) =>
|
eb.exists((qb) =>
|
||||||
qb
|
qb
|
||||||
|
|
@ -88,9 +88,25 @@ export class AssetJobRepository {
|
||||||
.where('asset_file.type', '=', AssetFileType.Thumbnail),
|
.where('asset_file.type', '=', AssetFileType.Thumbnail),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
eb('asset.thumbhash', 'is', null),
|
];
|
||||||
]),
|
|
||||||
),
|
if (options.fullsizeEnabled) {
|
||||||
|
conditions.push(
|
||||||
|
eb.not((eb) =>
|
||||||
|
eb.exists((qb) =>
|
||||||
|
qb
|
||||||
|
.selectFrom('asset_file')
|
||||||
|
.whereRef('assetId', '=', 'asset.id')
|
||||||
|
.where('asset_file.type', '=', AssetFileType.FullSize),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
conditions.push(eb('asset.thumbhash', 'is', null));
|
||||||
|
|
||||||
|
return eb.or(conditions);
|
||||||
|
}),
|
||||||
)
|
)
|
||||||
.stream();
|
.stream();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -23,8 +23,14 @@ import { faceStub } from 'test/fixtures/face.stub';
|
||||||
import { probeStub } from 'test/fixtures/media.stub';
|
import { probeStub } from 'test/fixtures/media.stub';
|
||||||
import { personStub, personThumbnailStub } from 'test/fixtures/person.stub';
|
import { personStub, personThumbnailStub } from 'test/fixtures/person.stub';
|
||||||
import { systemConfigStub } from 'test/fixtures/system-config.stub';
|
import { systemConfigStub } from 'test/fixtures/system-config.stub';
|
||||||
|
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');
|
||||||
|
|
@ -49,7 +55,7 @@ describe(MediaService.name, () => {
|
||||||
|
|
||||||
await sut.handleQueueGenerateThumbnails({ force: true });
|
await sut.handleQueueGenerateThumbnails({ force: true });
|
||||||
|
|
||||||
expect(mocks.assetJob.streamForThumbnailJob).toHaveBeenCalledWith(true);
|
expect(mocks.assetJob.streamForThumbnailJob).toHaveBeenCalledWith({ force: true, fullsizeEnabled: false });
|
||||||
expect(mocks.job.queueAll).toHaveBeenCalledWith([
|
expect(mocks.job.queueAll).toHaveBeenCalledWith([
|
||||||
{
|
{
|
||||||
name: JobName.AssetGenerateThumbnails,
|
name: JobName.AssetGenerateThumbnails,
|
||||||
|
|
@ -72,7 +78,7 @@ describe(MediaService.name, () => {
|
||||||
|
|
||||||
await sut.handleQueueGenerateThumbnails({ force: true });
|
await sut.handleQueueGenerateThumbnails({ force: true });
|
||||||
|
|
||||||
expect(mocks.assetJob.streamForThumbnailJob).toHaveBeenCalledWith(true);
|
expect(mocks.assetJob.streamForThumbnailJob).toHaveBeenCalledWith({ force: true, fullsizeEnabled: false });
|
||||||
expect(mocks.job.queueAll).toHaveBeenCalledWith([
|
expect(mocks.job.queueAll).toHaveBeenCalledWith([
|
||||||
{
|
{
|
||||||
name: JobName.AssetGenerateThumbnails,
|
name: JobName.AssetGenerateThumbnails,
|
||||||
|
|
@ -87,7 +93,7 @@ describe(MediaService.name, () => {
|
||||||
|
|
||||||
await sut.handleQueueGenerateThumbnails({ force: true });
|
await sut.handleQueueGenerateThumbnails({ force: true });
|
||||||
|
|
||||||
expect(mocks.assetJob.streamForThumbnailJob).toHaveBeenCalledWith(true);
|
expect(mocks.assetJob.streamForThumbnailJob).toHaveBeenCalledWith({ force: true, fullsizeEnabled: false });
|
||||||
expect(mocks.job.queueAll).toHaveBeenCalledWith([
|
expect(mocks.job.queueAll).toHaveBeenCalledWith([
|
||||||
{
|
{
|
||||||
name: JobName.AssetGenerateThumbnails,
|
name: JobName.AssetGenerateThumbnails,
|
||||||
|
|
@ -103,7 +109,7 @@ describe(MediaService.name, () => {
|
||||||
|
|
||||||
await sut.handleQueueGenerateThumbnails({ force: false });
|
await sut.handleQueueGenerateThumbnails({ force: false });
|
||||||
|
|
||||||
expect(mocks.assetJob.streamForThumbnailJob).toHaveBeenCalledWith(false);
|
expect(mocks.assetJob.streamForThumbnailJob).toHaveBeenCalledWith({ force: false, fullsizeEnabled: false });
|
||||||
expect(mocks.person.getAll).toHaveBeenCalledWith({ thumbnailPath: '' });
|
expect(mocks.person.getAll).toHaveBeenCalledWith({ thumbnailPath: '' });
|
||||||
expect(mocks.person.getRandomFace).toHaveBeenCalled();
|
expect(mocks.person.getRandomFace).toHaveBeenCalled();
|
||||||
expect(mocks.person.update).toHaveBeenCalledTimes(1);
|
expect(mocks.person.update).toHaveBeenCalledTimes(1);
|
||||||
|
|
@ -122,7 +128,7 @@ describe(MediaService.name, () => {
|
||||||
mocks.person.getAll.mockReturnValue(makeStream());
|
mocks.person.getAll.mockReturnValue(makeStream());
|
||||||
await sut.handleQueueGenerateThumbnails({ force: false });
|
await sut.handleQueueGenerateThumbnails({ force: false });
|
||||||
|
|
||||||
expect(mocks.assetJob.streamForThumbnailJob).toHaveBeenCalledWith(false);
|
expect(mocks.assetJob.streamForThumbnailJob).toHaveBeenCalledWith({ force: false, fullsizeEnabled: false });
|
||||||
expect(mocks.job.queueAll).toHaveBeenCalledWith([
|
expect(mocks.job.queueAll).toHaveBeenCalledWith([
|
||||||
{
|
{
|
||||||
name: JobName.AssetGenerateThumbnails,
|
name: JobName.AssetGenerateThumbnails,
|
||||||
|
|
@ -138,7 +144,7 @@ describe(MediaService.name, () => {
|
||||||
mocks.person.getAll.mockReturnValue(makeStream());
|
mocks.person.getAll.mockReturnValue(makeStream());
|
||||||
await sut.handleQueueGenerateThumbnails({ force: false });
|
await sut.handleQueueGenerateThumbnails({ force: false });
|
||||||
|
|
||||||
expect(mocks.assetJob.streamForThumbnailJob).toHaveBeenCalledWith(false);
|
expect(mocks.assetJob.streamForThumbnailJob).toHaveBeenCalledWith({ force: false, fullsizeEnabled: false });
|
||||||
expect(mocks.job.queueAll).toHaveBeenCalledWith([
|
expect(mocks.job.queueAll).toHaveBeenCalledWith([
|
||||||
{
|
{
|
||||||
name: JobName.AssetGenerateThumbnails,
|
name: JobName.AssetGenerateThumbnails,
|
||||||
|
|
@ -154,7 +160,7 @@ describe(MediaService.name, () => {
|
||||||
mocks.person.getAll.mockReturnValue(makeStream());
|
mocks.person.getAll.mockReturnValue(makeStream());
|
||||||
await sut.handleQueueGenerateThumbnails({ force: false });
|
await sut.handleQueueGenerateThumbnails({ force: false });
|
||||||
|
|
||||||
expect(mocks.assetJob.streamForThumbnailJob).toHaveBeenCalledWith(false);
|
expect(mocks.assetJob.streamForThumbnailJob).toHaveBeenCalledWith({ force: false, fullsizeEnabled: false });
|
||||||
expect(mocks.job.queueAll).toHaveBeenCalledWith([
|
expect(mocks.job.queueAll).toHaveBeenCalledWith([
|
||||||
{
|
{
|
||||||
name: JobName.AssetGenerateThumbnails,
|
name: JobName.AssetGenerateThumbnails,
|
||||||
|
|
@ -165,12 +171,43 @@ describe(MediaService.name, () => {
|
||||||
expect(mocks.person.getAll).toHaveBeenCalledWith({ thumbnailPath: '' });
|
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 } } });
|
||||||
|
const asset = { id: factory.uuid(), thumbhash: factory.buffer(), edits: [], files: filesNoFullsize };
|
||||||
|
mocks.assetJob.streamForThumbnailJob.mockReturnValue(makeStream([asset]));
|
||||||
|
mocks.person.getAll.mockReturnValue(makeStream());
|
||||||
|
await sut.handleQueueGenerateThumbnails({ force: false });
|
||||||
|
|
||||||
|
expect(mocks.assetJob.streamForThumbnailJob).toHaveBeenCalledWith({ force: false, fullsizeEnabled: true });
|
||||||
|
expect(mocks.job.queueAll).toHaveBeenCalledWith([
|
||||||
|
{
|
||||||
|
name: JobName.AssetGenerateThumbnails,
|
||||||
|
data: { id: asset.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 } } });
|
||||||
|
const asset = { id: factory.uuid(), thumbhash: factory.buffer(), edits: [], files: filesNoFullsize };
|
||||||
|
mocks.assetJob.streamForThumbnailJob.mockReturnValue(makeStream([asset]));
|
||||||
|
mocks.person.getAll.mockReturnValue(makeStream());
|
||||||
|
await sut.handleQueueGenerateThumbnails({ force: false });
|
||||||
|
|
||||||
|
expect(mocks.assetJob.streamForThumbnailJob).toHaveBeenCalledWith({ force: false, fullsizeEnabled: false });
|
||||||
|
expect(mocks.job.queueAll).toHaveBeenCalledWith([]);
|
||||||
|
|
||||||
|
expect(mocks.person.getAll).toHaveBeenCalledWith({ thumbnailPath: '' });
|
||||||
|
});
|
||||||
|
|
||||||
it('should queue assets with edits but missing edited thumbnails', async () => {
|
it('should queue assets with edits but missing edited thumbnails', async () => {
|
||||||
mocks.assetJob.streamForThumbnailJob.mockReturnValue(makeStream([assetStub.withCropEdit]));
|
mocks.assetJob.streamForThumbnailJob.mockReturnValue(makeStream([assetStub.withCropEdit]));
|
||||||
mocks.person.getAll.mockReturnValue(makeStream());
|
mocks.person.getAll.mockReturnValue(makeStream());
|
||||||
await sut.handleQueueGenerateThumbnails({ force: false });
|
await sut.handleQueueGenerateThumbnails({ force: false });
|
||||||
|
|
||||||
expect(mocks.assetJob.streamForThumbnailJob).toHaveBeenCalledWith(false);
|
expect(mocks.assetJob.streamForThumbnailJob).toHaveBeenCalledWith({ force: false, fullsizeEnabled: false });
|
||||||
expect(mocks.job.queueAll).toHaveBeenCalledWith([
|
expect(mocks.job.queueAll).toHaveBeenCalledWith([
|
||||||
{
|
{
|
||||||
name: JobName.AssetEditThumbnailGeneration,
|
name: JobName.AssetEditThumbnailGeneration,
|
||||||
|
|
@ -181,12 +218,42 @@ describe(MediaService.name, () => {
|
||||||
expect(mocks.person.getAll).toHaveBeenCalledWith({ thumbnailPath: '' });
|
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({ force: false, fullsizeEnabled: 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 } } });
|
||||||
|
const asset = { id: factory.uuid(), thumbhash: Buffer.from('thumbhash'), edits: [], files: filesNoFullsize };
|
||||||
|
mocks.assetJob.streamForThumbnailJob.mockReturnValue(makeStream([asset]));
|
||||||
|
mocks.person.getAll.mockReturnValue(makeStream());
|
||||||
|
await sut.handleQueueGenerateThumbnails({ force: true });
|
||||||
|
|
||||||
|
expect(mocks.assetJob.streamForThumbnailJob).toHaveBeenCalledWith({ force: true, fullsizeEnabled: false });
|
||||||
|
expect(mocks.job.queueAll).toHaveBeenCalledWith([
|
||||||
|
{
|
||||||
|
name: JobName.AssetGenerateThumbnails,
|
||||||
|
data: { id: asset.id },
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(mocks.person.getAll).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
it('should queue both regular and edited thumbnails for assets with edits when force is true', async () => {
|
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.assetJob.streamForThumbnailJob.mockReturnValue(makeStream([assetStub.withCropEdit]));
|
||||||
mocks.person.getAll.mockReturnValue(makeStream());
|
mocks.person.getAll.mockReturnValue(makeStream());
|
||||||
await sut.handleQueueGenerateThumbnails({ force: true });
|
await sut.handleQueueGenerateThumbnails({ force: true });
|
||||||
|
|
||||||
expect(mocks.assetJob.streamForThumbnailJob).toHaveBeenCalledWith(true);
|
expect(mocks.assetJob.streamForThumbnailJob).toHaveBeenCalledWith({ force: true, fullsizeEnabled: false });
|
||||||
expect(mocks.job.queueAll).toHaveBeenCalledWith([
|
expect(mocks.job.queueAll).toHaveBeenCalledWith([
|
||||||
{
|
{
|
||||||
name: JobName.AssetGenerateThumbnails,
|
name: JobName.AssetGenerateThumbnails,
|
||||||
|
|
|
||||||
|
|
@ -68,6 +68,7 @@ export class MediaService extends BaseService {
|
||||||
|
|
||||||
@OnJob({ name: JobName.AssetGenerateThumbnailsQueueAll, queue: QueueName.ThumbnailGeneration })
|
@OnJob({ name: JobName.AssetGenerateThumbnailsQueueAll, queue: QueueName.ThumbnailGeneration })
|
||||||
async handleQueueGenerateThumbnails({ force }: JobOf<JobName.AssetGenerateThumbnailsQueueAll>): Promise<JobStatus> {
|
async handleQueueGenerateThumbnails({ force }: JobOf<JobName.AssetGenerateThumbnailsQueueAll>): Promise<JobStatus> {
|
||||||
|
const config = await this.getConfig({ withCache: true });
|
||||||
let jobs: JobItem[] = [];
|
let jobs: JobItem[] = [];
|
||||||
|
|
||||||
const queueAll = async () => {
|
const queueAll = async () => {
|
||||||
|
|
@ -75,16 +76,18 @@ export class MediaService extends BaseService {
|
||||||
jobs = [];
|
jobs = [];
|
||||||
};
|
};
|
||||||
|
|
||||||
for await (const asset of this.assetJobRepository.streamForThumbnailJob(!!force)) {
|
const fullsizeEnabled = config.image.fullsize.enabled;
|
||||||
const assetFiles = getAssetFiles(asset.files);
|
for await (const asset of this.assetJobRepository.streamForThumbnailJob({ force, fullsizeEnabled })) {
|
||||||
|
const { previewFile, thumbnailFile, fullsizeFile, editedPreviewFile, editedThumbnailFile, editedFullsizeFile } =
|
||||||
|
getAssetFiles(asset.files);
|
||||||
|
|
||||||
if (!assetFiles.previewFile || !assetFiles.thumbnailFile || !asset.thumbhash || force) {
|
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.edits.length > 0 &&
|
asset.edits.length > 0 &&
|
||||||
(!assetFiles.editedPreviewFile || !assetFiles.editedThumbnailFile || !assetFiles.editedFullsizeFile || force)
|
(force || !editedPreviewFile || !editedThumbnailFile || (fullsizeEnabled && !editedFullsizeFile))
|
||||||
) {
|
) {
|
||||||
jobs.push({ name: JobName.AssetEditThumbnailGeneration, data: { id: asset.id } });
|
jobs.push({ name: JobName.AssetEditThumbnailGeneration, data: { id: asset.id } });
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -533,6 +533,7 @@ export const factory = {
|
||||||
assetEdit: assetEditFactory,
|
assetEdit: assetEditFactory,
|
||||||
tag: tagFactory,
|
tag: tagFactory,
|
||||||
uuid: newUuid,
|
uuid: newUuid,
|
||||||
|
buffer: () => Buffer.from('this is a fake buffer'),
|
||||||
date: newDate,
|
date: newDate,
|
||||||
responses: {
|
responses: {
|
||||||
badRequest: (message: any = null) => ({
|
badRequest: (message: any = null) => ({
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue