diff --git a/server/src/repositories/asset-job.repository.ts b/server/src/repositories/asset-job.repository.ts index 1608f7b6f6..8b88958251 100644 --- a/server/src/repositories/asset-job.repository.ts +++ b/server/src/repositories/asset-job.repository.ts @@ -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), ]), ), diff --git a/server/src/services/media.service.spec.ts b/server/src/services/media.service.spec.ts index fa2607faa9..4c424bfd8b 100644 --- a/server/src/services/media.service.spec.ts +++ b/server/src/services/media.service.spec.ts @@ -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()); diff --git a/server/src/services/media.service.ts b/server/src/services/media.service.ts index b9b8d74737..82343d6486 100644 --- a/server/src/services/media.service.ts +++ b/server/src/services/media.service.ts @@ -68,6 +68,7 @@ export class MediaService extends BaseService { @OnJob({ name: JobName.AssetGenerateThumbnailsQueueAll, queue: QueueName.ThumbnailGeneration }) async handleQueueGenerateThumbnails({ force }: JobOf): Promise { + 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 } }); } diff --git a/server/test/fixtures/asset.stub.ts b/server/test/fixtures/asset.stub.ts index 05219c92e7..8848baf0a9 100644 --- a/server/test/fixtures/asset.stub.ts +++ b/server/test/fixtures/asset.stub.ts @@ -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,