mirror of
https://github.com/immich-app/immich.git
synced 2025-01-01 08:31:59 +00:00
chore: media service unit tests (#13382)
This commit is contained in:
parent
0b48d46402
commit
20b4d281bb
2 changed files with 102 additions and 18 deletions
|
@ -1,7 +1,10 @@
|
||||||
import { Stats } from 'node:fs';
|
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 { ExifEntity } from 'src/entities/exif.entity';
|
||||||
import {
|
import {
|
||||||
AssetFileType,
|
AssetFileType,
|
||||||
|
AssetPathType,
|
||||||
AssetType,
|
AssetType,
|
||||||
AudioCodec,
|
AudioCodec,
|
||||||
Colorspace,
|
Colorspace,
|
||||||
|
@ -12,9 +15,10 @@ import {
|
||||||
VideoCodec,
|
VideoCodec,
|
||||||
} from 'src/enum';
|
} from 'src/enum';
|
||||||
import { IAssetRepository, WithoutProperty } from 'src/interfaces/asset.interface';
|
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 { ILoggerRepository } from 'src/interfaces/logger.interface';
|
||||||
import { IMediaRepository, RawImageInfo } from 'src/interfaces/media.interface';
|
import { IMediaRepository, RawImageInfo } from 'src/interfaces/media.interface';
|
||||||
|
import { IMoveRepository } from 'src/interfaces/move.interface';
|
||||||
import { IPersonRepository } from 'src/interfaces/person.interface';
|
import { IPersonRepository } from 'src/interfaces/person.interface';
|
||||||
import { IStorageRepository } from 'src/interfaces/storage.interface';
|
import { IStorageRepository } from 'src/interfaces/storage.interface';
|
||||||
import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface';
|
import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface';
|
||||||
|
@ -33,12 +37,13 @@ describe(MediaService.name, () => {
|
||||||
let jobMock: Mocked<IJobRepository>;
|
let jobMock: Mocked<IJobRepository>;
|
||||||
let loggerMock: Mocked<ILoggerRepository>;
|
let loggerMock: Mocked<ILoggerRepository>;
|
||||||
let mediaMock: Mocked<IMediaRepository>;
|
let mediaMock: Mocked<IMediaRepository>;
|
||||||
|
let moveMock: Mocked<IMoveRepository>;
|
||||||
let personMock: Mocked<IPersonRepository>;
|
let personMock: Mocked<IPersonRepository>;
|
||||||
let storageMock: Mocked<IStorageRepository>;
|
let storageMock: Mocked<IStorageRepository>;
|
||||||
let systemMock: Mocked<ISystemMetadataRepository>;
|
let systemMock: Mocked<ISystemMetadataRepository>;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
({ sut, assetMock, jobMock, loggerMock, mediaMock, personMock, storageMock, systemMock } =
|
({ sut, assetMock, jobMock, loggerMock, mediaMock, moveMock, personMock, storageMock, systemMock } =
|
||||||
newTestService(MediaService));
|
newTestService(MediaService));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -134,10 +139,10 @@ describe(MediaService.name, () => {
|
||||||
hasNextPage: false,
|
hasNextPage: false,
|
||||||
});
|
});
|
||||||
personMock.getAll.mockResolvedValue({
|
personMock.getAll.mockResolvedValue({
|
||||||
items: [personStub.noThumbnail],
|
items: [personStub.noThumbnail, personStub.noThumbnail],
|
||||||
hasNextPage: false,
|
hasNextPage: false,
|
||||||
});
|
});
|
||||||
personMock.getRandomFace.mockResolvedValue(faceStub.face1);
|
personMock.getRandomFace.mockResolvedValueOnce(faceStub.face1);
|
||||||
|
|
||||||
await sut.handleQueueGenerateThumbnails({ force: false });
|
await sut.handleQueueGenerateThumbnails({ force: false });
|
||||||
|
|
||||||
|
@ -146,6 +151,7 @@ describe(MediaService.name, () => {
|
||||||
|
|
||||||
expect(personMock.getAll).toHaveBeenCalledWith({ skip: 0, take: 1000 }, { where: { thumbnailPath: '' } });
|
expect(personMock.getAll).toHaveBeenCalledWith({ skip: 0, take: 1000 }, { where: { thumbnailPath: '' } });
|
||||||
expect(personMock.getRandomFace).toHaveBeenCalled();
|
expect(personMock.getRandomFace).toHaveBeenCalled();
|
||||||
|
expect(personMock.update).toHaveBeenCalledTimes(1);
|
||||||
expect(jobMock.queueAll).toHaveBeenCalledWith([
|
expect(jobMock.queueAll).toHaveBeenCalledWith([
|
||||||
{
|
{
|
||||||
name: JobName.GENERATE_PERSON_THUMBNAIL,
|
name: JobName.GENERATE_PERSON_THUMBNAIL,
|
||||||
|
@ -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', () => {
|
describe('handleGenerateThumbnails', () => {
|
||||||
let rawBuffer: Buffer;
|
let rawBuffer: Buffer;
|
||||||
let rawInfo: RawImageInfo;
|
let rawInfo: RawImageInfo;
|
||||||
|
@ -246,10 +292,19 @@ describe(MediaService.name, () => {
|
||||||
expect(assetMock.update).not.toHaveBeenCalledWith();
|
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 () => {
|
it('should skip video thumbnail generation if no video stream', async () => {
|
||||||
mediaMock.probe.mockResolvedValue(probeStub.noVideoStreams);
|
mediaMock.probe.mockResolvedValue(probeStub.noVideoStreams);
|
||||||
assetMock.getByIds.mockResolvedValue([assetStub.video]);
|
assetMock.getById.mockResolvedValue(assetStub.video);
|
||||||
await sut.handleGenerateThumbnails({ id: assetStub.image.id });
|
await expect(sut.handleGenerateThumbnails({ id: assetStub.video.id })).rejects.toBeInstanceOf(Error);
|
||||||
expect(mediaMock.generateThumbnail).not.toHaveBeenCalled();
|
expect(mediaMock.generateThumbnail).not.toHaveBeenCalled();
|
||||||
expect(assetMock.update).not.toHaveBeenCalledWith();
|
expect(assetMock.update).not.toHaveBeenCalledWith();
|
||||||
});
|
});
|
||||||
|
@ -751,6 +806,27 @@ describe(MediaService.name, () => {
|
||||||
expect(mediaMock.transcode).not.toHaveBeenCalled();
|
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 () => {
|
it('should transcode when set to all', async () => {
|
||||||
mediaMock.probe.mockResolvedValue(probeStub.multipleVideoStreams);
|
mediaMock.probe.mockResolvedValue(probeStub.multipleVideoStreams);
|
||||||
systemMock.get.mockResolvedValue({ ffmpeg: { transcode: TranscodePolicy.ALL } });
|
systemMock.get.mockResolvedValue({ ffmpeg: { transcode: TranscodePolicy.ALL } });
|
||||||
|
@ -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);
|
mediaMock.probe.mockResolvedValue(probeStub.videoStream40Mbps);
|
||||||
systemMock.get.mockResolvedValue({ ffmpeg: { transcode: TranscodePolicy.BITRATE, maxBitrate: '30M' } });
|
systemMock.get.mockResolvedValue({ ffmpeg: { transcode: TranscodePolicy.BITRATE, maxBitrate: '30M' } });
|
||||||
await sut.handleVideoConversion({ id: assetStub.video.id });
|
await sut.handleVideoConversion({ id: assetStub.video.id });
|
||||||
|
@ -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 () => {
|
it('should not scale resolution if no target resolution', async () => {
|
||||||
mediaMock.probe.mockResolvedValue(probeStub.videoStream2160p);
|
mediaMock.probe.mockResolvedValue(probeStub.videoStream2160p);
|
||||||
systemMock.get.mockResolvedValue({ ffmpeg: { transcode: TranscodePolicy.ALL, targetResolution: 'original' } });
|
systemMock.get.mockResolvedValue({ ffmpeg: { transcode: TranscodePolicy.ALL, targetResolution: 'original' } });
|
||||||
|
@ -1600,12 +1691,13 @@ describe(MediaService.name, () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should fail for qsv if no hw devices', async () => {
|
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);
|
mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer);
|
||||||
systemMock.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.QSV } });
|
systemMock.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.QSV } });
|
||||||
assetMock.getByIds.mockResolvedValue([assetStub.video]);
|
assetMock.getByIds.mockResolvedValue([assetStub.video]);
|
||||||
await expect(sut.handleVideoConversion({ id: assetStub.video.id })).resolves.toBe(JobStatus.FAILED);
|
await expect(sut.handleVideoConversion({ id: assetStub.video.id })).resolves.toBe(JobStatus.FAILED);
|
||||||
expect(mediaMock.transcode).not.toHaveBeenCalled();
|
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 () => {
|
it('should use hardware decoding for qsv if enabled', async () => {
|
||||||
|
|
|
@ -354,13 +354,9 @@ export class MediaService extends BaseService {
|
||||||
|
|
||||||
private getTranscodeTarget(
|
private getTranscodeTarget(
|
||||||
config: SystemConfigFFmpegDto,
|
config: SystemConfigFFmpegDto,
|
||||||
videoStream?: VideoStreamInfo,
|
videoStream: VideoStreamInfo,
|
||||||
audioStream?: AudioStreamInfo,
|
audioStream?: AudioStreamInfo,
|
||||||
): TranscodeTarget {
|
): TranscodeTarget {
|
||||||
if (!videoStream && !audioStream) {
|
|
||||||
return TranscodeTarget.NONE;
|
|
||||||
}
|
|
||||||
|
|
||||||
const isAudioTranscodeRequired = this.isAudioTranscodeRequired(config, audioStream);
|
const isAudioTranscodeRequired = this.isAudioTranscodeRequired(config, audioStream);
|
||||||
const isVideoTranscodeRequired = this.isVideoTranscodeRequired(config, videoStream);
|
const isVideoTranscodeRequired = this.isVideoTranscodeRequired(config, videoStream);
|
||||||
|
|
||||||
|
@ -402,11 +398,7 @@ export class MediaService extends BaseService {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private isVideoTranscodeRequired(ffmpegConfig: SystemConfigFFmpegDto, stream?: VideoStreamInfo): boolean {
|
private isVideoTranscodeRequired(ffmpegConfig: SystemConfigFFmpegDto, stream: VideoStreamInfo): boolean {
|
||||||
if (!stream) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const scalingEnabled = ffmpegConfig.targetResolution !== 'original';
|
const scalingEnabled = ffmpegConfig.targetResolution !== 'original';
|
||||||
const targetRes = Number.parseInt(ffmpegConfig.targetResolution);
|
const targetRes = Number.parseInt(ffmpegConfig.targetResolution);
|
||||||
const isLargerThanTargetRes = scalingEnabled && Math.min(stream.height, stream.width) > targetRes;
|
const isLargerThanTargetRes = scalingEnabled && Math.min(stream.height, stream.width) > targetRes;
|
||||||
|
|
Loading…
Reference in a new issue