Skip to content

Commit

Permalink
chore: media service unit tests (immich-app#13382)
Browse files Browse the repository at this point in the history
  • Loading branch information
danieldietzler authored Oct 12, 2024
1 parent 0b48d46 commit 20b4d28
Show file tree
Hide file tree
Showing 2 changed files with 102 additions and 18 deletions.
108 changes: 100 additions & 8 deletions server/src/services/media.service.spec.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
import { Stats } from 'node:fs';
import { SystemConfig } from 'src/config';
import { AssetEntity } from 'src/entities/asset.entity';
import { ExifEntity } from 'src/entities/exif.entity';
import {
AssetFileType,
AssetPathType,
AssetType,
AudioCodec,
Colorspace,
Expand All @@ -12,9 +15,10 @@ import {
VideoCodec,
} from 'src/enum';
import { IAssetRepository, WithoutProperty } from 'src/interfaces/asset.interface';
import { IJobRepository, JobName, JobStatus } from 'src/interfaces/job.interface';
import { IJobRepository, JobCounts, JobName, JobStatus } from 'src/interfaces/job.interface';
import { ILoggerRepository } from 'src/interfaces/logger.interface';
import { IMediaRepository, RawImageInfo } from 'src/interfaces/media.interface';
import { IMoveRepository } from 'src/interfaces/move.interface';
import { IPersonRepository } from 'src/interfaces/person.interface';
import { IStorageRepository } from 'src/interfaces/storage.interface';
import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface';
Expand All @@ -33,12 +37,13 @@ describe(MediaService.name, () => {
let jobMock: Mocked<IJobRepository>;
let loggerMock: Mocked<ILoggerRepository>;
let mediaMock: Mocked<IMediaRepository>;
let moveMock: Mocked<IMoveRepository>;
let personMock: Mocked<IPersonRepository>;
let storageMock: Mocked<IStorageRepository>;
let systemMock: Mocked<ISystemMetadataRepository>;

beforeEach(() => {
({ sut, assetMock, jobMock, loggerMock, mediaMock, personMock, storageMock, systemMock } =
({ sut, assetMock, jobMock, loggerMock, mediaMock, moveMock, personMock, storageMock, systemMock } =
newTestService(MediaService));
});

Expand Down Expand Up @@ -134,10 +139,10 @@ describe(MediaService.name, () => {
hasNextPage: false,
});
personMock.getAll.mockResolvedValue({
items: [personStub.noThumbnail],
items: [personStub.noThumbnail, personStub.noThumbnail],
hasNextPage: false,
});
personMock.getRandomFace.mockResolvedValue(faceStub.face1);
personMock.getRandomFace.mockResolvedValueOnce(faceStub.face1);

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

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

expect(personMock.getAll).toHaveBeenCalledWith({ skip: 0, take: 1000 }, { where: { thumbnailPath: '' } });
expect(personMock.getRandomFace).toHaveBeenCalled();
expect(personMock.update).toHaveBeenCalledTimes(1);
expect(jobMock.queueAll).toHaveBeenCalledWith([
{
name: JobName.GENERATE_PERSON_THUMBNAIL,
Expand Down Expand Up @@ -229,6 +235,46 @@ describe(MediaService.name, () => {
});
});

describe('handleQueueMigration', () => {
it('should remove empty directories and queue jobs', async () => {
assetMock.getAll.mockResolvedValue({ hasNextPage: false, items: [assetStub.image] });
jobMock.getJobCounts.mockResolvedValue({ active: 1, waiting: 0 } as JobCounts);
personMock.getAll.mockResolvedValue({ hasNextPage: false, items: [personStub.withName] });

await expect(sut.handleQueueMigration()).resolves.toBe(JobStatus.SUCCESS);

expect(storageMock.removeEmptyDirs).toHaveBeenCalledTimes(2);
expect(jobMock.queueAll).toHaveBeenCalledWith([
{ name: JobName.MIGRATE_ASSET, data: { id: assetStub.image.id } },
]);
expect(jobMock.queueAll).toHaveBeenCalledWith([
{ name: JobName.MIGRATE_PERSON, data: { id: personStub.withName.id } },
]);
});
});

describe('handleAssetMigration', () => {
it('should fail if asset does not exist', async () => {
await expect(sut.handleAssetMigration({ id: assetStub.image.id })).resolves.toBe(JobStatus.FAILED);

expect(moveMock.getByEntity).not.toHaveBeenCalled();
});

it('should move asset files', async () => {
assetMock.getByIds.mockResolvedValue([assetStub.image]);
moveMock.create.mockResolvedValue({
entityId: assetStub.image.id,
id: 'move-id',
newPath: '/new/path',
oldPath: '/old/path',
pathType: AssetPathType.ORIGINAL,
});

await expect(sut.handleAssetMigration({ id: assetStub.image.id })).resolves.toBe(JobStatus.SUCCESS);
expect(moveMock.create).toHaveBeenCalledTimes(2);
});
});

describe('handleGenerateThumbnails', () => {
let rawBuffer: Buffer;
let rawInfo: RawImageInfo;
Expand All @@ -246,10 +292,19 @@ describe(MediaService.name, () => {
expect(assetMock.update).not.toHaveBeenCalledWith();
});

it('should skip thumbnail generation if asset type is unknown', async () => {
assetMock.getById.mockResolvedValue({ ...assetStub.image, type: 'foo' } as never as AssetEntity);

await expect(sut.handleGenerateThumbnails({ id: assetStub.image.id })).resolves.toBe(JobStatus.SKIPPED);
expect(mediaMock.probe).not.toHaveBeenCalled();
expect(mediaMock.generateThumbnail).not.toHaveBeenCalled();
expect(assetMock.update).not.toHaveBeenCalledWith();
});

it('should skip video thumbnail generation if no video stream', async () => {
mediaMock.probe.mockResolvedValue(probeStub.noVideoStreams);
assetMock.getByIds.mockResolvedValue([assetStub.video]);
await sut.handleGenerateThumbnails({ id: assetStub.image.id });
assetMock.getById.mockResolvedValue(assetStub.video);
await expect(sut.handleGenerateThumbnails({ id: assetStub.video.id })).rejects.toBeInstanceOf(Error);
expect(mediaMock.generateThumbnail).not.toHaveBeenCalled();
expect(assetMock.update).not.toHaveBeenCalledWith();
});
Expand Down Expand Up @@ -751,6 +806,27 @@ describe(MediaService.name, () => {
expect(mediaMock.transcode).not.toHaveBeenCalled();
});

it('should throw an error if an unknown transcode policy is configured', async () => {
mediaMock.probe.mockResolvedValue(probeStub.noAudioStreams);
systemMock.get.mockResolvedValue({ ffmpeg: { transcode: 'foo' } } as never as SystemConfig);
assetMock.getByIds.mockResolvedValue([assetStub.video]);

await expect(sut.handleVideoConversion({ id: assetStub.video.id })).rejects.toBeDefined();
expect(mediaMock.transcode).not.toHaveBeenCalled();
});

it('should throw an error if transcoding fails and hw acceleration is disabled', async () => {
mediaMock.probe.mockResolvedValue(probeStub.multipleVideoStreams);
systemMock.get.mockResolvedValue({
ffmpeg: { transcode: TranscodePolicy.ALL, accel: TranscodeHWAccel.DISABLED },
});
assetMock.getByIds.mockResolvedValue([assetStub.video]);
mediaMock.transcode.mockRejectedValue(new Error('Error transcoding video'));

await expect(sut.handleVideoConversion({ id: assetStub.video.id })).resolves.toBe(JobStatus.FAILED);
expect(mediaMock.transcode).toHaveBeenCalledTimes(1);
});

it('should transcode when set to all', async () => {
mediaMock.probe.mockResolvedValue(probeStub.multipleVideoStreams);
systemMock.get.mockResolvedValue({ ffmpeg: { transcode: TranscodePolicy.ALL } });
Expand Down Expand Up @@ -782,7 +858,7 @@ describe(MediaService.name, () => {
);
});

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

it('should transcode when max bitrate is not a number', async () => {
mediaMock.probe.mockResolvedValue(probeStub.videoStream40Mbps);
systemMock.get.mockResolvedValue({ ffmpeg: { transcode: TranscodePolicy.BITRATE, maxBitrate: 'foo' } });
await sut.handleVideoConversion({ id: assetStub.video.id });
expect(mediaMock.transcode).toHaveBeenCalledWith(
'/original/path.ext',
'upload/encoded-video/user-id/as/se/asset-id.mp4',
expect.objectContaining({
inputOptions: expect.any(Array),
outputOptions: expect.any(Array),
twoPass: false,
}),
);
});

it('should not scale resolution if no target resolution', async () => {
mediaMock.probe.mockResolvedValue(probeStub.videoStream2160p);
systemMock.get.mockResolvedValue({ ffmpeg: { transcode: TranscodePolicy.ALL, targetResolution: 'original' } });
Expand Down Expand Up @@ -1600,12 +1691,13 @@ describe(MediaService.name, () => {
});

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

it('should use hardware decoding for qsv if enabled', async () => {
Expand Down
12 changes: 2 additions & 10 deletions server/src/services/media.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -354,13 +354,9 @@ export class MediaService extends BaseService {

private getTranscodeTarget(
config: SystemConfigFFmpegDto,
videoStream?: VideoStreamInfo,
videoStream: VideoStreamInfo,
audioStream?: AudioStreamInfo,
): TranscodeTarget {
if (!videoStream && !audioStream) {
return TranscodeTarget.NONE;
}

const isAudioTranscodeRequired = this.isAudioTranscodeRequired(config, audioStream);
const isVideoTranscodeRequired = this.isVideoTranscodeRequired(config, videoStream);

Expand Down Expand Up @@ -402,11 +398,7 @@ export class MediaService extends BaseService {
}
}

private isVideoTranscodeRequired(ffmpegConfig: SystemConfigFFmpegDto, stream?: VideoStreamInfo): boolean {
if (!stream) {
return false;
}

private isVideoTranscodeRequired(ffmpegConfig: SystemConfigFFmpegDto, stream: VideoStreamInfo): boolean {
const scalingEnabled = ffmpegConfig.targetResolution !== 'original';
const targetRes = Number.parseInt(ffmpegConfig.targetResolution);
const isLargerThanTargetRes = scalingEnabled && Math.min(stream.height, stream.width) > targetRes;
Expand Down

0 comments on commit 20b4d28

Please sign in to comment.