Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions server/src/queries/asset.job.repository.sql
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,14 @@ where
"assetId" = "asset"."id"
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
)

Expand Down
32 changes: 24 additions & 8 deletions server/src/repositories/asset-job.repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,21 +58,21 @@ export class AssetJobRepository {
.executeTakeFirst();
}

@GenerateSql({ params: [false], stream: true })
streamForThumbnailJob(force: boolean) {
@GenerateSql({ params: [{ force: false, fullsizeEnabled: true }], stream: true })
streamForThumbnailJob(options: { force: boolean | undefined; fullsizeEnabled: boolean }) {
return this.db
.selectFrom('asset')
.select(['asset.id', 'asset.thumbhash'])
.select(withFiles)
.select(withEdits)
.where('asset.deletedAt', 'is', null)
.where('asset.visibility', '!=', AssetVisibility.Hidden)
.$if(!force, (qb) =>
.$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) =>
eb.or([
.where((eb) => {
const conditions = [
eb.not((eb) =>
eb.exists((qb) =>
qb
Expand All @@ -89,9 +89,25 @@ export class AssetJobRepository {
.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();
}
Expand Down
85 changes: 76 additions & 9 deletions server/src/services/media.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,14 @@ import { faceStub } from 'test/fixtures/face.stub';
import { probeStub } from 'test/fixtures/media.stub';
import { personStub, personThumbnailStub } from 'test/fixtures/person.stub';
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');
Expand All @@ -49,7 +55,7 @@ describe(MediaService.name, () => {

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([
{
name: JobName.AssetGenerateThumbnails,
Expand All @@ -72,7 +78,7 @@ describe(MediaService.name, () => {

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([
{
name: JobName.AssetGenerateThumbnails,
Expand All @@ -87,7 +93,7 @@ describe(MediaService.name, () => {

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([
{
name: JobName.AssetGenerateThumbnails,
Expand All @@ -103,7 +109,7 @@ describe(MediaService.name, () => {

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.getRandomFace).toHaveBeenCalled();
expect(mocks.person.update).toHaveBeenCalledTimes(1);
Expand All @@ -122,7 +128,7 @@ describe(MediaService.name, () => {
mocks.person.getAll.mockReturnValue(makeStream());
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([
{
name: JobName.AssetGenerateThumbnails,
Expand All @@ -138,7 +144,7 @@ describe(MediaService.name, () => {
mocks.person.getAll.mockReturnValue(makeStream());
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([
{
name: JobName.AssetGenerateThumbnails,
Expand All @@ -154,7 +160,7 @@ describe(MediaService.name, () => {
mocks.person.getAll.mockReturnValue(makeStream());
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([
{
name: JobName.AssetGenerateThumbnails,
Expand All @@ -165,12 +171,43 @@ 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 } } });
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 () => {
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.assetJob.streamForThumbnailJob).toHaveBeenCalledWith({ force: false, fullsizeEnabled: false });
expect(mocks.job.queueAll).toHaveBeenCalledWith([
{
name: JobName.AssetEditThumbnailGeneration,
Expand All @@ -181,12 +218,42 @@ 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({ 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 () => {
mocks.assetJob.streamForThumbnailJob.mockReturnValue(makeStream([assetStub.withCropEdit]));
mocks.person.getAll.mockReturnValue(makeStream());
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([
{
name: JobName.AssetGenerateThumbnails,
Expand Down
11 changes: 7 additions & 4 deletions server/src/services/media.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,23 +68,26 @@ 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 () => {
await this.jobRepository.queueAll(jobs);
jobs = [];
};

for await (const asset of this.assetJobRepository.streamForThumbnailJob(!!force)) {
const assetFiles = getAssetFiles(asset.files);
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 (!assetFiles.previewFile || !assetFiles.thumbnailFile || !asset.thumbhash || force) {
if (force || !previewFile || !thumbnailFile || !asset.thumbhash || (fullsizeEnabled && !fullsizeFile)) {
jobs.push({ name: JobName.AssetGenerateThumbnails, data: { id: asset.id } });
}

if (
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 } });
}
Expand Down
1 change: 1 addition & 0 deletions server/test/small.factory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -532,6 +532,7 @@ export const factory = {
assetEdit: assetEditFactory,
tag: tagFactory,
uuid: newUuid,
buffer: () => Buffer.from('this is a fake buffer'),
date: newDate,
responses: {
badRequest: (message: any = null) => ({
Expand Down
Loading