Skip to content

Commit 20b4d28

Browse files
chore: media service unit tests (immich-app#13382)
1 parent 0b48d46 commit 20b4d28

File tree

2 files changed

+102
-18
lines changed

2 files changed

+102
-18
lines changed

server/src/services/media.service.spec.ts

+100-8
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
11
import { Stats } from 'node:fs';
2+
import { SystemConfig } from 'src/config';
3+
import { AssetEntity } from 'src/entities/asset.entity';
24
import { ExifEntity } from 'src/entities/exif.entity';
35
import {
46
AssetFileType,
7+
AssetPathType,
58
AssetType,
69
AudioCodec,
710
Colorspace,
@@ -12,9 +15,10 @@ import {
1215
VideoCodec,
1316
} from 'src/enum';
1417
import { IAssetRepository, WithoutProperty } from 'src/interfaces/asset.interface';
15-
import { IJobRepository, JobName, JobStatus } from 'src/interfaces/job.interface';
18+
import { IJobRepository, JobCounts, JobName, JobStatus } from 'src/interfaces/job.interface';
1619
import { ILoggerRepository } from 'src/interfaces/logger.interface';
1720
import { IMediaRepository, RawImageInfo } from 'src/interfaces/media.interface';
21+
import { IMoveRepository } from 'src/interfaces/move.interface';
1822
import { IPersonRepository } from 'src/interfaces/person.interface';
1923
import { IStorageRepository } from 'src/interfaces/storage.interface';
2024
import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface';
@@ -33,12 +37,13 @@ describe(MediaService.name, () => {
3337
let jobMock: Mocked<IJobRepository>;
3438
let loggerMock: Mocked<ILoggerRepository>;
3539
let mediaMock: Mocked<IMediaRepository>;
40+
let moveMock: Mocked<IMoveRepository>;
3641
let personMock: Mocked<IPersonRepository>;
3742
let storageMock: Mocked<IStorageRepository>;
3843
let systemMock: Mocked<ISystemMetadataRepository>;
3944

4045
beforeEach(() => {
41-
({ sut, assetMock, jobMock, loggerMock, mediaMock, personMock, storageMock, systemMock } =
46+
({ sut, assetMock, jobMock, loggerMock, mediaMock, moveMock, personMock, storageMock, systemMock } =
4247
newTestService(MediaService));
4348
});
4449

@@ -134,10 +139,10 @@ describe(MediaService.name, () => {
134139
hasNextPage: false,
135140
});
136141
personMock.getAll.mockResolvedValue({
137-
items: [personStub.noThumbnail],
142+
items: [personStub.noThumbnail, personStub.noThumbnail],
138143
hasNextPage: false,
139144
});
140-
personMock.getRandomFace.mockResolvedValue(faceStub.face1);
145+
personMock.getRandomFace.mockResolvedValueOnce(faceStub.face1);
141146

142147
await sut.handleQueueGenerateThumbnails({ force: false });
143148

@@ -146,6 +151,7 @@ describe(MediaService.name, () => {
146151

147152
expect(personMock.getAll).toHaveBeenCalledWith({ skip: 0, take: 1000 }, { where: { thumbnailPath: '' } });
148153
expect(personMock.getRandomFace).toHaveBeenCalled();
154+
expect(personMock.update).toHaveBeenCalledTimes(1);
149155
expect(jobMock.queueAll).toHaveBeenCalledWith([
150156
{
151157
name: JobName.GENERATE_PERSON_THUMBNAIL,
@@ -229,6 +235,46 @@ describe(MediaService.name, () => {
229235
});
230236
});
231237

238+
describe('handleQueueMigration', () => {
239+
it('should remove empty directories and queue jobs', async () => {
240+
assetMock.getAll.mockResolvedValue({ hasNextPage: false, items: [assetStub.image] });
241+
jobMock.getJobCounts.mockResolvedValue({ active: 1, waiting: 0 } as JobCounts);
242+
personMock.getAll.mockResolvedValue({ hasNextPage: false, items: [personStub.withName] });
243+
244+
await expect(sut.handleQueueMigration()).resolves.toBe(JobStatus.SUCCESS);
245+
246+
expect(storageMock.removeEmptyDirs).toHaveBeenCalledTimes(2);
247+
expect(jobMock.queueAll).toHaveBeenCalledWith([
248+
{ name: JobName.MIGRATE_ASSET, data: { id: assetStub.image.id } },
249+
]);
250+
expect(jobMock.queueAll).toHaveBeenCalledWith([
251+
{ name: JobName.MIGRATE_PERSON, data: { id: personStub.withName.id } },
252+
]);
253+
});
254+
});
255+
256+
describe('handleAssetMigration', () => {
257+
it('should fail if asset does not exist', async () => {
258+
await expect(sut.handleAssetMigration({ id: assetStub.image.id })).resolves.toBe(JobStatus.FAILED);
259+
260+
expect(moveMock.getByEntity).not.toHaveBeenCalled();
261+
});
262+
263+
it('should move asset files', async () => {
264+
assetMock.getByIds.mockResolvedValue([assetStub.image]);
265+
moveMock.create.mockResolvedValue({
266+
entityId: assetStub.image.id,
267+
id: 'move-id',
268+
newPath: '/new/path',
269+
oldPath: '/old/path',
270+
pathType: AssetPathType.ORIGINAL,
271+
});
272+
273+
await expect(sut.handleAssetMigration({ id: assetStub.image.id })).resolves.toBe(JobStatus.SUCCESS);
274+
expect(moveMock.create).toHaveBeenCalledTimes(2);
275+
});
276+
});
277+
232278
describe('handleGenerateThumbnails', () => {
233279
let rawBuffer: Buffer;
234280
let rawInfo: RawImageInfo;
@@ -246,10 +292,19 @@ describe(MediaService.name, () => {
246292
expect(assetMock.update).not.toHaveBeenCalledWith();
247293
});
248294

295+
it('should skip thumbnail generation if asset type is unknown', async () => {
296+
assetMock.getById.mockResolvedValue({ ...assetStub.image, type: 'foo' } as never as AssetEntity);
297+
298+
await expect(sut.handleGenerateThumbnails({ id: assetStub.image.id })).resolves.toBe(JobStatus.SKIPPED);
299+
expect(mediaMock.probe).not.toHaveBeenCalled();
300+
expect(mediaMock.generateThumbnail).not.toHaveBeenCalled();
301+
expect(assetMock.update).not.toHaveBeenCalledWith();
302+
});
303+
249304
it('should skip video thumbnail generation if no video stream', async () => {
250305
mediaMock.probe.mockResolvedValue(probeStub.noVideoStreams);
251-
assetMock.getByIds.mockResolvedValue([assetStub.video]);
252-
await sut.handleGenerateThumbnails({ id: assetStub.image.id });
306+
assetMock.getById.mockResolvedValue(assetStub.video);
307+
await expect(sut.handleGenerateThumbnails({ id: assetStub.video.id })).rejects.toBeInstanceOf(Error);
253308
expect(mediaMock.generateThumbnail).not.toHaveBeenCalled();
254309
expect(assetMock.update).not.toHaveBeenCalledWith();
255310
});
@@ -751,6 +806,27 @@ describe(MediaService.name, () => {
751806
expect(mediaMock.transcode).not.toHaveBeenCalled();
752807
});
753808

809+
it('should throw an error if an unknown transcode policy is configured', async () => {
810+
mediaMock.probe.mockResolvedValue(probeStub.noAudioStreams);
811+
systemMock.get.mockResolvedValue({ ffmpeg: { transcode: 'foo' } } as never as SystemConfig);
812+
assetMock.getByIds.mockResolvedValue([assetStub.video]);
813+
814+
await expect(sut.handleVideoConversion({ id: assetStub.video.id })).rejects.toBeDefined();
815+
expect(mediaMock.transcode).not.toHaveBeenCalled();
816+
});
817+
818+
it('should throw an error if transcoding fails and hw acceleration is disabled', async () => {
819+
mediaMock.probe.mockResolvedValue(probeStub.multipleVideoStreams);
820+
systemMock.get.mockResolvedValue({
821+
ffmpeg: { transcode: TranscodePolicy.ALL, accel: TranscodeHWAccel.DISABLED },
822+
});
823+
assetMock.getByIds.mockResolvedValue([assetStub.video]);
824+
mediaMock.transcode.mockRejectedValue(new Error('Error transcoding video'));
825+
826+
await expect(sut.handleVideoConversion({ id: assetStub.video.id })).resolves.toBe(JobStatus.FAILED);
827+
expect(mediaMock.transcode).toHaveBeenCalledTimes(1);
828+
});
829+
754830
it('should transcode when set to all', async () => {
755831
mediaMock.probe.mockResolvedValue(probeStub.multipleVideoStreams);
756832
systemMock.get.mockResolvedValue({ ffmpeg: { transcode: TranscodePolicy.ALL } });
@@ -782,7 +858,7 @@ describe(MediaService.name, () => {
782858
);
783859
});
784860

785-
it('should transcode when policy Bitrate and bitrate higher than max bitrate', async () => {
861+
it('should transcode when policy bitrate and bitrate higher than max bitrate', async () => {
786862
mediaMock.probe.mockResolvedValue(probeStub.videoStream40Mbps);
787863
systemMock.get.mockResolvedValue({ ffmpeg: { transcode: TranscodePolicy.BITRATE, maxBitrate: '30M' } });
788864
await sut.handleVideoConversion({ id: assetStub.video.id });
@@ -797,6 +873,21 @@ describe(MediaService.name, () => {
797873
);
798874
});
799875

876+
it('should transcode when max bitrate is not a number', async () => {
877+
mediaMock.probe.mockResolvedValue(probeStub.videoStream40Mbps);
878+
systemMock.get.mockResolvedValue({ ffmpeg: { transcode: TranscodePolicy.BITRATE, maxBitrate: 'foo' } });
879+
await sut.handleVideoConversion({ id: assetStub.video.id });
880+
expect(mediaMock.transcode).toHaveBeenCalledWith(
881+
'/original/path.ext',
882+
'upload/encoded-video/user-id/as/se/asset-id.mp4',
883+
expect.objectContaining({
884+
inputOptions: expect.any(Array),
885+
outputOptions: expect.any(Array),
886+
twoPass: false,
887+
}),
888+
);
889+
});
890+
800891
it('should not scale resolution if no target resolution', async () => {
801892
mediaMock.probe.mockResolvedValue(probeStub.videoStream2160p);
802893
systemMock.get.mockResolvedValue({ ffmpeg: { transcode: TranscodePolicy.ALL, targetResolution: 'original' } });
@@ -1600,12 +1691,13 @@ describe(MediaService.name, () => {
16001691
});
16011692

16021693
it('should fail for qsv if no hw devices', async () => {
1603-
storageMock.readdir.mockResolvedValue([]);
1694+
storageMock.readdir.mockRejectedValue(new Error('Could not read directory'));
16041695
mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer);
16051696
systemMock.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.QSV } });
16061697
assetMock.getByIds.mockResolvedValue([assetStub.video]);
16071698
await expect(sut.handleVideoConversion({ id: assetStub.video.id })).resolves.toBe(JobStatus.FAILED);
16081699
expect(mediaMock.transcode).not.toHaveBeenCalled();
1700+
expect(loggerMock.debug).toHaveBeenCalledWith('No devices found in /dev/dri.');
16091701
});
16101702

16111703
it('should use hardware decoding for qsv if enabled', async () => {

server/src/services/media.service.ts

+2-10
Original file line numberDiff line numberDiff line change
@@ -354,13 +354,9 @@ export class MediaService extends BaseService {
354354

355355
private getTranscodeTarget(
356356
config: SystemConfigFFmpegDto,
357-
videoStream?: VideoStreamInfo,
357+
videoStream: VideoStreamInfo,
358358
audioStream?: AudioStreamInfo,
359359
): TranscodeTarget {
360-
if (!videoStream && !audioStream) {
361-
return TranscodeTarget.NONE;
362-
}
363-
364360
const isAudioTranscodeRequired = this.isAudioTranscodeRequired(config, audioStream);
365361
const isVideoTranscodeRequired = this.isVideoTranscodeRequired(config, videoStream);
366362

@@ -402,11 +398,7 @@ export class MediaService extends BaseService {
402398
}
403399
}
404400

405-
private isVideoTranscodeRequired(ffmpegConfig: SystemConfigFFmpegDto, stream?: VideoStreamInfo): boolean {
406-
if (!stream) {
407-
return false;
408-
}
409-
401+
private isVideoTranscodeRequired(ffmpegConfig: SystemConfigFFmpegDto, stream: VideoStreamInfo): boolean {
410402
const scalingEnabled = ffmpegConfig.targetResolution !== 'original';
411403
const targetRes = Number.parseInt(ffmpegConfig.targetResolution);
412404
const isLargerThanTargetRes = scalingEnabled && Math.min(stream.height, stream.width) > targetRes;

0 commit comments

Comments
 (0)