diff --git a/server/libs/domain/src/media/media.service.spec.ts b/server/libs/domain/src/media/media.service.spec.ts new file mode 100644 index 0000000000..979b37c7d3 --- /dev/null +++ b/server/libs/domain/src/media/media.service.spec.ts @@ -0,0 +1,173 @@ +import _ from 'lodash'; +import { + assetEntityStub, + newAssetRepositoryMock, + newCommunicationRepositoryMock, + newJobRepositoryMock, + newMediaRepositoryMock, + newStorageRepositoryMock, +} from '../../test'; +import { IAssetRepository, WithoutProperty } from '../asset'; +import { ICommunicationRepository } from '../communication'; +import { IJobRepository, JobName } from '../job'; +import { IStorageRepository } from '../storage'; +import { IMediaRepository } from './media.repository'; +import { MediaService } from './media.service'; + +describe(MediaService.name, () => { + let sut: MediaService; + let assetMock: jest.Mocked; + let communicationMock: jest.Mocked; + let jobMock: jest.Mocked; + let mediaMock: jest.Mocked; + let storageMock: jest.Mocked; + + beforeEach(async () => { + assetMock = newAssetRepositoryMock(); + communicationMock = newCommunicationRepositoryMock(); + jobMock = newJobRepositoryMock(); + mediaMock = newMediaRepositoryMock(); + storageMock = newStorageRepositoryMock(); + sut = new MediaService(assetMock, communicationMock, jobMock, mediaMock, storageMock); + }); + + it('should be defined', () => { + expect(sut).toBeDefined(); + }); + + describe('handleQueueGenerateThumbnails', () => { + it('should queue all assets', async () => { + assetMock.getAll.mockResolvedValue([assetEntityStub.image]); + + await sut.handleQueueGenerateThumbnails({ force: true }); + + expect(assetMock.getAll).toHaveBeenCalled(); + expect(assetMock.getWithout).not.toHaveBeenCalled(); + expect(jobMock.queue).toHaveBeenCalledWith({ + name: JobName.GENERATE_JPEG_THUMBNAIL, + data: { asset: assetEntityStub.image }, + }); + }); + + it('should queue all assets with missing thumbnails', async () => { + assetMock.getWithout.mockResolvedValue([assetEntityStub.image]); + + await sut.handleQueueGenerateThumbnails({ force: false }); + + expect(assetMock.getAll).not.toHaveBeenCalled(); + expect(assetMock.getWithout).toHaveBeenCalledWith(WithoutProperty.THUMBNAIL); + expect(jobMock.queue).toHaveBeenCalledWith({ + name: JobName.GENERATE_JPEG_THUMBNAIL, + data: { asset: assetEntityStub.image }, + }); + }); + + it('should log an error', async () => { + assetMock.getAll.mockRejectedValue(new Error('database unavailable')); + + await sut.handleQueueGenerateThumbnails({ force: true }); + + expect(assetMock.getAll).toHaveBeenCalled(); + }); + }); + + describe('handleGenerateJpegThumbnail', () => { + it('should generate a thumbnail for an image', async () => { + await sut.handleGenerateJpegThumbnail({ asset: _.cloneDeep(assetEntityStub.image) }); + + expect(storageMock.mkdirSync).toHaveBeenCalledWith('upload/user-id/thumb/device-id'); + expect(mediaMock.resize).toHaveBeenCalledWith( + '/original/path.ext', + 'upload/user-id/thumb/device-id/asset-id.jpeg', + { size: 1440, format: 'jpeg' }, + ); + expect(mediaMock.extractThumbnailFromExif).not.toHaveBeenCalled(); + expect(assetMock.save).toHaveBeenCalledWith({ + id: 'asset-id', + resizePath: 'upload/user-id/thumb/device-id/asset-id.jpeg', + }); + }); + + it('should generate a thumbnail for an image from exif', async () => { + mediaMock.resize.mockRejectedValue(new Error('unsupported format')); + + await sut.handleGenerateJpegThumbnail({ asset: _.cloneDeep(assetEntityStub.image) }); + + expect(storageMock.mkdirSync).toHaveBeenCalledWith('upload/user-id/thumb/device-id'); + expect(mediaMock.resize).toHaveBeenCalledWith( + '/original/path.ext', + 'upload/user-id/thumb/device-id/asset-id.jpeg', + { size: 1440, format: 'jpeg' }, + ); + expect(mediaMock.extractThumbnailFromExif).toHaveBeenCalledWith( + '/original/path.ext', + 'upload/user-id/thumb/device-id/asset-id.jpeg', + ); + expect(assetMock.save).toHaveBeenCalledWith({ + id: 'asset-id', + resizePath: 'upload/user-id/thumb/device-id/asset-id.jpeg', + }); + }); + + it('should generate a thumbnail for a video', async () => { + await sut.handleGenerateJpegThumbnail({ asset: _.cloneDeep(assetEntityStub.video) }); + + expect(storageMock.mkdirSync).toHaveBeenCalledWith('upload/user-id/thumb/device-id'); + expect(mediaMock.extractVideoThumbnail).toHaveBeenCalledWith( + '/original/path.ext', + 'upload/user-id/thumb/device-id/asset-id.jpeg', + ); + expect(assetMock.save).toHaveBeenCalledWith({ + id: 'asset-id', + resizePath: 'upload/user-id/thumb/device-id/asset-id.jpeg', + }); + }); + + it('should queue some jobs', async () => { + const asset = _.cloneDeep(assetEntityStub.image); + + await sut.handleGenerateJpegThumbnail({ asset }); + + expect(jobMock.queue).toHaveBeenCalledWith({ name: JobName.GENERATE_WEBP_THUMBNAIL, data: { asset } }); + expect(jobMock.queue).toHaveBeenCalledWith({ name: JobName.CLASSIFY_IMAGE, data: { asset } }); + expect(jobMock.queue).toHaveBeenCalledWith({ name: JobName.DETECT_OBJECTS, data: { asset } }); + expect(jobMock.queue).toHaveBeenCalledWith({ name: JobName.ENCODE_CLIP, data: { asset } }); + }); + + it('should log an error', async () => { + mediaMock.resize.mockRejectedValue(new Error('unsupported format')); + mediaMock.extractThumbnailFromExif.mockRejectedValue(new Error('unsupported format')); + + await sut.handleGenerateJpegThumbnail({ asset: assetEntityStub.image }); + + expect(assetMock.save).not.toHaveBeenCalled(); + }); + }); + + describe('handleGenerateWebpThumbnail', () => { + it('should skip thumbnail generate if resize path is missing', async () => { + await sut.handleGenerateWepbThumbnail({ asset: assetEntityStub.noResizePath }); + + expect(mediaMock.resize).not.toHaveBeenCalled(); + }); + + it('should generate a thumbnail', async () => { + await sut.handleGenerateWepbThumbnail({ asset: assetEntityStub.image }); + + expect(mediaMock.resize).toHaveBeenCalledWith( + '/uploads/user-id/thumbs/path.ext', + '/uploads/user-id/thumbs/path.ext', + { format: 'webp', size: 250 }, + ); + expect(assetMock.save).toHaveBeenCalledWith({ id: 'asset-id', webpPath: '/uploads/user-id/thumbs/path.ext' }); + }); + + it('should log an error', async () => { + mediaMock.resize.mockRejectedValue(new Error('service unavailable')); + + await sut.handleGenerateWepbThumbnail({ asset: assetEntityStub.image }); + + expect(mediaMock.resize).toHaveBeenCalled(); + }); + }); +}); diff --git a/server/libs/domain/src/media/media.service.ts b/server/libs/domain/src/media/media.service.ts index 483db502d5..1da60ad2a3 100644 --- a/server/libs/domain/src/media/media.service.ts +++ b/server/libs/domain/src/media/media.service.ts @@ -40,31 +40,32 @@ export class MediaService { async handleGenerateJpegThumbnail(data: IAssetJob): Promise { const { asset } = data; - const basePath = APP_UPLOAD_LOCATION; - const sanitizedDeviceId = sanitize(String(asset.deviceId)); - const resizePath = join(basePath, asset.ownerId, 'thumb', sanitizedDeviceId); - const jpegThumbnailPath = join(resizePath, `${asset.id}.jpeg`); + try { + const basePath = APP_UPLOAD_LOCATION; + const sanitizedDeviceId = sanitize(String(asset.deviceId)); + const resizePath = join(basePath, asset.ownerId, 'thumb', sanitizedDeviceId); + const jpegThumbnailPath = join(resizePath, `${asset.id}.jpeg`); + this.storageRepository.mkdirSync(resizePath); - this.storageRepository.mkdirSync(resizePath); - - if (asset.type == AssetType.IMAGE) { - try { - await this.mediaRepository - .resize(asset.originalPath, jpegThumbnailPath, { size: 1440, format: 'jpeg' }) - .catch(() => { - this.logger.warn( - 'Failed to generate jpeg thumbnail for asset: ' + - asset.id + - ' using sharp, failing over to exiftool-vendored', - ); - return this.mediaRepository.extractThumbnailFromExif(asset.originalPath, jpegThumbnailPath); - }); - await this.assetRepository.save({ id: asset.id, resizePath: jpegThumbnailPath }); - } catch (error: any) { - this.logger.error('Failed to generate jpeg thumbnail for asset: ' + asset.id, error.stack); + if (asset.type == AssetType.IMAGE) { + try { + await this.mediaRepository.resize(asset.originalPath, jpegThumbnailPath, { size: 1440, format: 'jpeg' }); + } catch (error) { + this.logger.warn( + `Failed to generate jpeg thumbnail using sharp, trying with exiftool-vendored (asset=${asset.id})`, + ); + await this.mediaRepository.extractThumbnailFromExif(asset.originalPath, jpegThumbnailPath); + } } - // Update resize path to send to generate webp queue + if (asset.type == AssetType.VIDEO) { + this.logger.log('Start Generating Video Thumbnail'); + await this.mediaRepository.extractVideoThumbnail(asset.originalPath, jpegThumbnailPath); + this.logger.log(`Generating Video Thumbnail Success ${asset.id}`); + } + + await this.assetRepository.save({ id: asset.id, resizePath: jpegThumbnailPath }); + asset.resizePath = jpegThumbnailPath; await this.jobRepository.queue({ name: JobName.GENERATE_WEBP_THUMBNAIL, data: { asset } }); @@ -73,28 +74,8 @@ export class MediaService { await this.jobRepository.queue({ name: JobName.ENCODE_CLIP, data: { asset } }); this.communicationRepository.send(CommunicationEvent.UPLOAD_SUCCESS, asset.ownerId, mapAsset(asset)); - } - - if (asset.type == AssetType.VIDEO) { - try { - this.logger.log('Start Generating Video Thumbnail'); - await this.mediaRepository.extractVideoThumbnail(asset.originalPath, jpegThumbnailPath); - this.logger.log(`Generating Video Thumbnail Success ${asset.id}`); - - await this.assetRepository.save({ id: asset.id, resizePath: jpegThumbnailPath }); - - // Update resize path to send to generate webp queue - asset.resizePath = jpegThumbnailPath; - - await this.jobRepository.queue({ name: JobName.GENERATE_WEBP_THUMBNAIL, data: { asset } }); - await this.jobRepository.queue({ name: JobName.CLASSIFY_IMAGE, data: { asset } }); - await this.jobRepository.queue({ name: JobName.DETECT_OBJECTS, data: { asset } }); - await this.jobRepository.queue({ name: JobName.ENCODE_CLIP, data: { asset } }); - - this.communicationRepository.send(CommunicationEvent.UPLOAD_SUCCESS, asset.ownerId, mapAsset(asset)); - } catch (error: any) { - this.logger.error(`Cannot Generate Video Thumbnail: ${asset.id}`, error?.stack); - } + } catch (error: any) { + this.logger.error(`Failed to generate thumbnail for asset: ${asset.id}/${asset.type}`, error.stack); } } diff --git a/server/libs/domain/test/communication.repository.mock.ts b/server/libs/domain/test/communication.repository.mock.ts new file mode 100644 index 0000000000..083da97c39 --- /dev/null +++ b/server/libs/domain/test/communication.repository.mock.ts @@ -0,0 +1,7 @@ +import { ICommunicationRepository } from '../src'; + +export const newCommunicationRepositoryMock = (): jest.Mocked => { + return { + send: jest.fn(), + }; +}; diff --git a/server/libs/domain/test/fixtures.ts b/server/libs/domain/test/fixtures.ts index b9d9a6cbe1..82b0729feb 100644 --- a/server/libs/domain/test/fixtures.ts +++ b/server/libs/domain/test/fixtures.ts @@ -111,7 +111,7 @@ export const fileStub = { }; export const assetEntityStub = { - image: Object.freeze({ + noResizePath: Object.freeze({ id: 'asset-id', deviceAssetId: 'device-asset-id', fileModifiedAt: '2023-02-23T05:06:29.716Z', @@ -135,6 +135,54 @@ export const assetEntityStub = { tags: [], sharedLinks: [], }), + image: Object.freeze({ + id: 'asset-id', + deviceAssetId: 'device-asset-id', + fileModifiedAt: '2023-02-23T05:06:29.716Z', + fileCreatedAt: '2023-02-23T05:06:29.716Z', + owner: userEntityStub.user1, + ownerId: 'user-id', + deviceId: 'device-id', + originalPath: '/original/path.ext', + resizePath: '/uploads/user-id/thumbs/path.ext', + type: AssetType.IMAGE, + webpPath: null, + encodedVideoPath: null, + createdAt: '2023-02-23T05:06:29.716Z', + updatedAt: '2023-02-23T05:06:29.716Z', + mimeType: null, + isFavorite: true, + duration: null, + isVisible: true, + livePhotoVideo: null, + livePhotoVideoId: null, + tags: [], + sharedLinks: [], + }), + video: Object.freeze({ + id: 'asset-id', + deviceAssetId: 'device-asset-id', + fileModifiedAt: '2023-02-23T05:06:29.716Z', + fileCreatedAt: '2023-02-23T05:06:29.716Z', + owner: userEntityStub.user1, + ownerId: 'user-id', + deviceId: 'device-id', + originalPath: '/original/path.ext', + resizePath: '/uploads/user-id/thumbs/path.ext', + type: AssetType.VIDEO, + webpPath: null, + encodedVideoPath: null, + createdAt: '2023-02-23T05:06:29.716Z', + updatedAt: '2023-02-23T05:06:29.716Z', + mimeType: null, + isFavorite: true, + duration: null, + isVisible: true, + livePhotoVideo: null, + livePhotoVideoId: null, + tags: [], + sharedLinks: [], + }), livePhotoMotionAsset: Object.freeze({ id: 'live-photo-motion-asset', originalPath: fileStub.livePhotoMotion.originalPath, diff --git a/server/libs/domain/test/index.ts b/server/libs/domain/test/index.ts index 45f5825464..36b6fd2e65 100644 --- a/server/libs/domain/test/index.ts +++ b/server/libs/domain/test/index.ts @@ -1,11 +1,13 @@ export * from './album.repository.mock'; export * from './api-key.repository.mock'; export * from './asset.repository.mock'; +export * from './communication.repository.mock'; export * from './crypto.repository.mock'; export * from './device-info.repository.mock'; export * from './fixtures'; export * from './job.repository.mock'; export * from './machine-learning.repository.mock'; +export * from './media.repository.mock'; export * from './search.repository.mock'; export * from './shared-link.repository.mock'; export * from './smart-info.repository.mock'; diff --git a/server/libs/domain/test/media.repository.mock.ts b/server/libs/domain/test/media.repository.mock.ts new file mode 100644 index 0000000000..606509764a --- /dev/null +++ b/server/libs/domain/test/media.repository.mock.ts @@ -0,0 +1,9 @@ +import { IMediaRepository } from '../src'; + +export const newMediaRepositoryMock = (): jest.Mocked => { + return { + extractThumbnailFromExif: jest.fn(), + extractVideoThumbnail: jest.fn(), + resize: jest.fn(), + }; +};