Merge branch 'main' into feature/readonly-sharing
# Conflicts: # server/src/services/album.service.ts
This commit is contained in:
@@ -175,6 +175,7 @@ describe(AlbumService.name, () => {
|
||||
it('creates album', async () => {
|
||||
albumMock.create.mockResolvedValue(albumStub.empty);
|
||||
userMock.get.mockResolvedValue(userStub.user1);
|
||||
accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['123']));
|
||||
|
||||
await sut.create(authStub.admin, {
|
||||
albumName: 'Empty album',
|
||||
@@ -193,6 +194,7 @@ describe(AlbumService.name, () => {
|
||||
});
|
||||
|
||||
expect(userMock.get).toHaveBeenCalledWith('user-id', {});
|
||||
expect(accessMock.asset.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['123']));
|
||||
});
|
||||
|
||||
it('should require valid userIds', async () => {
|
||||
@@ -206,6 +208,31 @@ describe(AlbumService.name, () => {
|
||||
expect(userMock.get).toHaveBeenCalledWith('user-3', {});
|
||||
expect(albumMock.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should only add assets the user is allowed to access', async () => {
|
||||
userMock.get.mockResolvedValue(userStub.user1);
|
||||
albumMock.create.mockResolvedValue(albumStub.oneAsset);
|
||||
accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1']));
|
||||
|
||||
await sut.create(authStub.admin, {
|
||||
albumName: 'Test album',
|
||||
description: '',
|
||||
assetIds: ['asset-1', 'asset-2'],
|
||||
});
|
||||
|
||||
expect(albumMock.create).toHaveBeenCalledWith({
|
||||
ownerId: authStub.admin.user.id,
|
||||
albumName: 'Test album',
|
||||
description: '',
|
||||
sharedUsers: [],
|
||||
assets: [{ id: 'asset-1' }],
|
||||
albumThumbnailAssetId: 'asset-1',
|
||||
});
|
||||
expect(accessMock.asset.checkOwnerAccess).toHaveBeenCalledWith(
|
||||
authStub.admin.user.id,
|
||||
new Set(['asset-1', 'asset-2']),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('update', () => {
|
||||
|
||||
@@ -121,14 +121,17 @@ export class AlbumService {
|
||||
}
|
||||
}
|
||||
|
||||
const allowedAssetIdsSet = await this.access.checkAccess(auth, Permission.ASSET_SHARE, new Set(dto.assetIds));
|
||||
const assets = [...allowedAssetIdsSet].map((id) => ({ id }) as AssetEntity);
|
||||
|
||||
const album = await this.albumRepository.create({
|
||||
ownerId: auth.user.id,
|
||||
albumName: dto.albumName,
|
||||
description: dto.description,
|
||||
albumPermissions:
|
||||
dto.sharedWithUserIds?.map((userId) => ({ users: { id: userId } }) as AlbumPermissionEntity) ?? [],
|
||||
assets: (dto.assetIds || []).map((id) => ({ id }) as AssetEntity),
|
||||
albumThumbnailAssetId: dto.assetIds?.[0] || null,
|
||||
assets,
|
||||
albumThumbnailAssetId: assets[0]?.id || null,
|
||||
});
|
||||
|
||||
return mapAlbumWithAssets(album);
|
||||
|
||||
@@ -695,7 +695,7 @@ describe(AssetService.name, () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('should not schedule delete-files job for readonly assets', async () => {
|
||||
it('should only delete generated files for readonly assets', async () => {
|
||||
when(assetMock.getById)
|
||||
.calledWith(assetStub.readOnly.id, {
|
||||
faces: {
|
||||
@@ -709,7 +709,20 @@ describe(AssetService.name, () => {
|
||||
|
||||
await sut.handleAssetDeletion({ id: assetStub.readOnly.id });
|
||||
|
||||
expect(jobMock.queue.mock.calls).toEqual([]);
|
||||
expect(jobMock.queue.mock.calls).toEqual([
|
||||
[
|
||||
{
|
||||
name: JobName.DELETE_FILES,
|
||||
data: {
|
||||
files: [
|
||||
assetStub.readOnly.thumbnailPath,
|
||||
assetStub.readOnly.previewPath,
|
||||
assetStub.readOnly.encodedVideoPath,
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
]);
|
||||
|
||||
expect(assetMock.remove).toHaveBeenCalledWith(assetStub.readOnly);
|
||||
});
|
||||
@@ -748,7 +761,6 @@ describe(AssetService.name, () => {
|
||||
assetStub.external.thumbnailPath,
|
||||
assetStub.external.previewPath,
|
||||
assetStub.external.encodedVideoPath,
|
||||
assetStub.external.sidecarPath,
|
||||
],
|
||||
},
|
||||
},
|
||||
|
||||
@@ -399,14 +399,12 @@ export class AssetService {
|
||||
await this.jobRepository.queue({ name: JobName.ASSET_DELETION, data: { id: asset.livePhotoVideoId } });
|
||||
}
|
||||
|
||||
const files = [asset.thumbnailPath, asset.previewPath, asset.encodedVideoPath, asset.sidecarPath];
|
||||
if (!fromExternal) {
|
||||
files.push(asset.originalPath);
|
||||
const files = [asset.thumbnailPath, asset.previewPath, asset.encodedVideoPath];
|
||||
if (!(asset.isExternal || asset.isReadOnly)) {
|
||||
files.push(asset.sidecarPath, asset.originalPath);
|
||||
}
|
||||
|
||||
if (!asset.isReadOnly) {
|
||||
await this.jobRepository.queue({ name: JobName.DELETE_FILES, data: { files } });
|
||||
}
|
||||
await this.jobRepository.queue({ name: JobName.DELETE_FILES, data: { files } });
|
||||
|
||||
return JobStatus.SUCCESS;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
import { FeatureFlag, SystemConfigCore } from 'src/cores/system-config.core';
|
||||
import { SystemConfig, SystemConfigKey } from 'src/entities/system-config.entity';
|
||||
import { SystemConfig, SystemConfigKey, SystemConfigKeyPaths } from 'src/entities/system-config.entity';
|
||||
import { IAssetRepository } from 'src/interfaces/asset.interface';
|
||||
import { IEventRepository } from 'src/interfaces/event.interface';
|
||||
import {
|
||||
@@ -360,7 +360,7 @@ describe(JobService.name, () => {
|
||||
});
|
||||
}
|
||||
|
||||
const featureTests: Array<{ queue: QueueName; feature: FeatureFlag; configKey: SystemConfigKey }> = [
|
||||
const featureTests: Array<{ queue: QueueName; feature: FeatureFlag; configKey: SystemConfigKeyPaths }> = [
|
||||
{
|
||||
queue: QueueName.SMART_SEARCH,
|
||||
feature: FeatureFlag.SMART_SEARCH,
|
||||
|
||||
@@ -1058,14 +1058,6 @@ describe(LibraryService.name, () => {
|
||||
|
||||
expect(libraryMock.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not create watched', async () => {
|
||||
await expect(
|
||||
sut.create({ ownerId: authStub.admin.user.id, type: LibraryType.UPLOAD, isWatched: true }),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
|
||||
expect(storageMock.watch).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -266,9 +266,6 @@ export class LibraryService extends EventEmitter {
|
||||
if (dto.exclusionPatterns && dto.exclusionPatterns.length > 0) {
|
||||
throw new BadRequestException('Upload libraries cannot have exclusion patterns');
|
||||
}
|
||||
if (dto.isWatched) {
|
||||
throw new BadRequestException('Upload libraries cannot be watched');
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -210,25 +210,21 @@ describe(MediaService.name, () => {
|
||||
expect(assetMock.update).not.toHaveBeenCalledWith();
|
||||
});
|
||||
|
||||
it('should generate a thumbnail for an image', async () => {
|
||||
it.each(Object.values(ImageFormat))('should generate a %s preview for an image when specified', async (format) => {
|
||||
configMock.load.mockResolvedValue([{ key: SystemConfigKey.IMAGE_PREVIEW_FORMAT, value: format }]);
|
||||
assetMock.getByIds.mockResolvedValue([assetStub.image]);
|
||||
const previewPath = `upload/thumbs/user-id/as/se/asset-id-preview.${format}`;
|
||||
|
||||
await sut.handleGeneratePreview({ id: assetStub.image.id });
|
||||
|
||||
expect(storageMock.mkdirSync).toHaveBeenCalledWith('upload/thumbs/user-id/as/se');
|
||||
expect(mediaMock.resize).toHaveBeenCalledWith(
|
||||
'/original/path.jpg',
|
||||
'upload/thumbs/user-id/as/se/asset-id-preview.jpeg',
|
||||
{
|
||||
size: 1440,
|
||||
format: ImageFormat.JPEG,
|
||||
quality: 80,
|
||||
colorspace: Colorspace.SRGB,
|
||||
},
|
||||
);
|
||||
expect(assetMock.update).toHaveBeenCalledWith({
|
||||
id: 'asset-id',
|
||||
previewPath: 'upload/thumbs/user-id/as/se/asset-id-preview.jpeg',
|
||||
expect(mediaMock.resize).toHaveBeenCalledWith('/original/path.jpg', previewPath, {
|
||||
size: 1440,
|
||||
format,
|
||||
quality: 80,
|
||||
colorspace: Colorspace.SRGB,
|
||||
});
|
||||
expect(assetMock.update).toHaveBeenCalledWith({ id: 'asset-id', previewPath });
|
||||
});
|
||||
|
||||
it('should generate a P3 thumbnail for a wide gamut image', async () => {
|
||||
@@ -342,25 +338,25 @@ describe(MediaService.name, () => {
|
||||
expect(assetMock.update).not.toHaveBeenCalledWith();
|
||||
});
|
||||
|
||||
it('should generate a thumbnail', async () => {
|
||||
assetMock.getByIds.mockResolvedValue([assetStub.image]);
|
||||
await sut.handleGenerateThumbnail({ id: assetStub.image.id });
|
||||
it.each(Object.values(ImageFormat))(
|
||||
'should generate a %s thumbnail for an image when specified',
|
||||
async (format) => {
|
||||
configMock.load.mockResolvedValue([{ key: SystemConfigKey.IMAGE_THUMBNAIL_FORMAT, value: format }]);
|
||||
assetMock.getByIds.mockResolvedValue([assetStub.image]);
|
||||
const thumbnailPath = `upload/thumbs/user-id/as/se/asset-id-thumbnail.${format}`;
|
||||
|
||||
expect(mediaMock.resize).toHaveBeenCalledWith(
|
||||
'/original/path.jpg',
|
||||
'upload/thumbs/user-id/as/se/asset-id-thumbnail.webp',
|
||||
{
|
||||
format: ImageFormat.WEBP,
|
||||
await sut.handleGenerateThumbnail({ id: assetStub.image.id });
|
||||
|
||||
expect(storageMock.mkdirSync).toHaveBeenCalledWith('upload/thumbs/user-id/as/se');
|
||||
expect(mediaMock.resize).toHaveBeenCalledWith('/original/path.jpg', thumbnailPath, {
|
||||
size: 250,
|
||||
format,
|
||||
quality: 80,
|
||||
colorspace: Colorspace.SRGB,
|
||||
},
|
||||
);
|
||||
expect(assetMock.update).toHaveBeenCalledWith({
|
||||
id: 'asset-id',
|
||||
thumbnailPath: 'upload/thumbs/user-id/as/se/asset-id-thumbnail.webp',
|
||||
});
|
||||
});
|
||||
});
|
||||
expect(assetMock.update).toHaveBeenCalledWith({ id: 'asset-id', thumbnailPath });
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it('should generate a P3 thumbnail for a wide gamut image', async () => {
|
||||
@@ -747,6 +743,67 @@ describe(MediaService.name, () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should not include hevc tag when target is hevc and video stream is copied from a different codec', async () => {
|
||||
mediaMock.probe.mockResolvedValue(probeStub.videoStreamH264);
|
||||
configMock.load.mockResolvedValue([
|
||||
{ key: SystemConfigKey.FFMPEG_TARGET_VIDEO_CODEC, value: VideoCodec.HEVC },
|
||||
{ key: SystemConfigKey.FFMPEG_ACCEPTED_VIDEO_CODECS, value: [VideoCodec.H264, VideoCodec.HEVC] },
|
||||
{ key: SystemConfigKey.FFMPEG_ACCEPTED_AUDIO_CODECS, value: [AudioCodec.AAC] },
|
||||
]);
|
||||
assetMock.getByIds.mockResolvedValue([assetStub.video]);
|
||||
await sut.handleVideoConversion({ id: assetStub.video.id });
|
||||
expect(mediaMock.transcode).toHaveBeenCalledWith(
|
||||
'/original/path.ext',
|
||||
'upload/encoded-video/user-id/as/se/asset-id.mp4',
|
||||
{
|
||||
inputOptions: [],
|
||||
outputOptions: [
|
||||
'-c:v copy',
|
||||
'-c:a aac',
|
||||
'-movflags faststart',
|
||||
'-fps_mode passthrough',
|
||||
'-map 0:0',
|
||||
'-map 0:1',
|
||||
'-v verbose',
|
||||
'-preset ultrafast',
|
||||
'-crf 23',
|
||||
],
|
||||
twoPass: false,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it('should include hevc tag when target is hevc and copying hevc video stream', async () => {
|
||||
mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer);
|
||||
configMock.load.mockResolvedValue([
|
||||
{ key: SystemConfigKey.FFMPEG_TARGET_VIDEO_CODEC, value: VideoCodec.HEVC },
|
||||
{ key: SystemConfigKey.FFMPEG_ACCEPTED_VIDEO_CODECS, value: [VideoCodec.H264, VideoCodec.HEVC] },
|
||||
{ key: SystemConfigKey.FFMPEG_ACCEPTED_AUDIO_CODECS, value: [AudioCodec.AAC] },
|
||||
]);
|
||||
assetMock.getByIds.mockResolvedValue([assetStub.video]);
|
||||
await sut.handleVideoConversion({ id: assetStub.video.id });
|
||||
expect(mediaMock.transcode).toHaveBeenCalledWith(
|
||||
'/original/path.ext',
|
||||
'upload/encoded-video/user-id/as/se/asset-id.mp4',
|
||||
{
|
||||
inputOptions: [],
|
||||
outputOptions: [
|
||||
'-c:v copy',
|
||||
'-c:a aac',
|
||||
'-movflags faststart',
|
||||
'-fps_mode passthrough',
|
||||
'-map 0:0',
|
||||
'-map 0:1',
|
||||
'-tag:v hvc1',
|
||||
'-v verbose',
|
||||
'-preset ultrafast',
|
||||
'-crf 23',
|
||||
],
|
||||
twoPass: false,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it('should copy audio stream when audio matches target', async () => {
|
||||
mediaMock.probe.mockResolvedValue(probeStub.audioStreamAac);
|
||||
configMock.load.mockResolvedValue([{ key: SystemConfigKey.FFMPEG_TRANSCODE, value: TranscodePolicy.OPTIMAL }]);
|
||||
@@ -1091,9 +1148,9 @@ describe(MediaService.name, () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should disable thread pooling for h264 if thread limit is above 0', async () => {
|
||||
it('should disable thread pooling for h264 if thread limit is 1', async () => {
|
||||
mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer);
|
||||
configMock.load.mockResolvedValue([{ key: SystemConfigKey.FFMPEG_THREADS, value: 2 }]);
|
||||
configMock.load.mockResolvedValue([{ key: SystemConfigKey.FFMPEG_THREADS, value: 1 }]);
|
||||
assetMock.getByIds.mockResolvedValue([assetStub.video]);
|
||||
await sut.handleVideoConversion({ id: assetStub.video.id });
|
||||
expect(mediaMock.transcode).toHaveBeenCalledWith(
|
||||
@@ -1111,9 +1168,8 @@ describe(MediaService.name, () => {
|
||||
'-v verbose',
|
||||
'-vf scale=-2:720,format=yuv420p',
|
||||
'-preset ultrafast',
|
||||
'-threads 2',
|
||||
'-x264-params "pools=none"',
|
||||
'-x264-params "frame-threads=2"',
|
||||
'-threads 1',
|
||||
'-x264-params frame-threads=1:pools=none',
|
||||
'-crf 23',
|
||||
],
|
||||
twoPass: false,
|
||||
@@ -1148,10 +1204,10 @@ describe(MediaService.name, () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should disable thread pooling for hevc if thread limit is above 0', async () => {
|
||||
it('should disable thread pooling for hevc if thread limit is 1', async () => {
|
||||
mediaMock.probe.mockResolvedValue(probeStub.videoStreamVp9);
|
||||
configMock.load.mockResolvedValue([
|
||||
{ key: SystemConfigKey.FFMPEG_THREADS, value: 2 },
|
||||
{ key: SystemConfigKey.FFMPEG_THREADS, value: 1 },
|
||||
{ key: SystemConfigKey.FFMPEG_TARGET_VIDEO_CODEC, value: VideoCodec.HEVC },
|
||||
]);
|
||||
assetMock.getByIds.mockResolvedValue([assetStub.video]);
|
||||
@@ -1172,9 +1228,8 @@ describe(MediaService.name, () => {
|
||||
'-v verbose',
|
||||
'-vf scale=-2:720,format=yuv420p',
|
||||
'-preset ultrafast',
|
||||
'-threads 2',
|
||||
'-x265-params "pools=none"',
|
||||
'-x265-params "frame-threads=2"',
|
||||
'-threads 1',
|
||||
'-x265-params frame-threads=1:pools=none',
|
||||
'-crf 23',
|
||||
],
|
||||
twoPass: false,
|
||||
@@ -1213,6 +1268,157 @@ describe(MediaService.name, () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should use av1 if specified', async () => {
|
||||
mediaMock.probe.mockResolvedValue(probeStub.videoStreamVp9);
|
||||
configMock.load.mockResolvedValue([{ key: SystemConfigKey.FFMPEG_TARGET_VIDEO_CODEC, value: VideoCodec.AV1 }]);
|
||||
assetMock.getByIds.mockResolvedValue([assetStub.video]);
|
||||
await sut.handleVideoConversion({ id: assetStub.video.id });
|
||||
expect(mediaMock.transcode).toHaveBeenCalledWith(
|
||||
'/original/path.ext',
|
||||
'upload/encoded-video/user-id/as/se/asset-id.mp4',
|
||||
{
|
||||
inputOptions: [],
|
||||
outputOptions: [
|
||||
'-c:v av1',
|
||||
'-c:a copy',
|
||||
'-movflags faststart',
|
||||
'-fps_mode passthrough',
|
||||
'-map 0:0',
|
||||
'-map 0:1',
|
||||
'-v verbose',
|
||||
'-vf scale=-2:720,format=yuv420p',
|
||||
'-preset 12',
|
||||
'-crf 23',
|
||||
],
|
||||
twoPass: false,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it('should map `veryslow` preset to 4 for av1', async () => {
|
||||
mediaMock.probe.mockResolvedValue(probeStub.videoStreamVp9);
|
||||
configMock.load.mockResolvedValue([
|
||||
{ key: SystemConfigKey.FFMPEG_TARGET_VIDEO_CODEC, value: VideoCodec.AV1 },
|
||||
{ key: SystemConfigKey.FFMPEG_PRESET, value: 'veryslow' },
|
||||
]);
|
||||
assetMock.getByIds.mockResolvedValue([assetStub.video]);
|
||||
await sut.handleVideoConversion({ id: assetStub.video.id });
|
||||
expect(mediaMock.transcode).toHaveBeenCalledWith(
|
||||
'/original/path.ext',
|
||||
'upload/encoded-video/user-id/as/se/asset-id.mp4',
|
||||
{
|
||||
inputOptions: [],
|
||||
outputOptions: [
|
||||
'-c:v av1',
|
||||
'-c:a copy',
|
||||
'-movflags faststart',
|
||||
'-fps_mode passthrough',
|
||||
'-map 0:0',
|
||||
'-map 0:1',
|
||||
'-v verbose',
|
||||
'-vf scale=-2:720,format=yuv420p',
|
||||
'-preset 4',
|
||||
'-crf 23',
|
||||
],
|
||||
twoPass: false,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it('should set max bitrate for av1 if specified', async () => {
|
||||
mediaMock.probe.mockResolvedValue(probeStub.videoStreamVp9);
|
||||
configMock.load.mockResolvedValue([
|
||||
{ key: SystemConfigKey.FFMPEG_TARGET_VIDEO_CODEC, value: VideoCodec.AV1 },
|
||||
{ key: SystemConfigKey.FFMPEG_MAX_BITRATE, value: '2M' },
|
||||
]);
|
||||
assetMock.getByIds.mockResolvedValue([assetStub.video]);
|
||||
await sut.handleVideoConversion({ id: assetStub.video.id });
|
||||
expect(mediaMock.transcode).toHaveBeenCalledWith(
|
||||
'/original/path.ext',
|
||||
'upload/encoded-video/user-id/as/se/asset-id.mp4',
|
||||
{
|
||||
inputOptions: [],
|
||||
outputOptions: [
|
||||
'-c:v av1',
|
||||
'-c:a copy',
|
||||
'-movflags faststart',
|
||||
'-fps_mode passthrough',
|
||||
'-map 0:0',
|
||||
'-map 0:1',
|
||||
'-v verbose',
|
||||
'-vf scale=-2:720,format=yuv420p',
|
||||
'-preset 12',
|
||||
'-crf 23',
|
||||
'-svtav1-params mbr=2M',
|
||||
],
|
||||
twoPass: false,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it('should set threads for av1 if specified', async () => {
|
||||
mediaMock.probe.mockResolvedValue(probeStub.videoStreamVp9);
|
||||
configMock.load.mockResolvedValue([
|
||||
{ key: SystemConfigKey.FFMPEG_TARGET_VIDEO_CODEC, value: VideoCodec.AV1 },
|
||||
{ key: SystemConfigKey.FFMPEG_THREADS, value: 4 },
|
||||
]);
|
||||
assetMock.getByIds.mockResolvedValue([assetStub.video]);
|
||||
await sut.handleVideoConversion({ id: assetStub.video.id });
|
||||
expect(mediaMock.transcode).toHaveBeenCalledWith(
|
||||
'/original/path.ext',
|
||||
'upload/encoded-video/user-id/as/se/asset-id.mp4',
|
||||
{
|
||||
inputOptions: [],
|
||||
outputOptions: [
|
||||
'-c:v av1',
|
||||
'-c:a copy',
|
||||
'-movflags faststart',
|
||||
'-fps_mode passthrough',
|
||||
'-map 0:0',
|
||||
'-map 0:1',
|
||||
'-v verbose',
|
||||
'-vf scale=-2:720,format=yuv420p',
|
||||
'-preset 12',
|
||||
'-crf 23',
|
||||
'-svtav1-params lp=4',
|
||||
],
|
||||
twoPass: false,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it('should set both bitrate and threads for av1 if specified', async () => {
|
||||
mediaMock.probe.mockResolvedValue(probeStub.videoStreamVp9);
|
||||
configMock.load.mockResolvedValue([
|
||||
{ key: SystemConfigKey.FFMPEG_TARGET_VIDEO_CODEC, value: VideoCodec.AV1 },
|
||||
{ key: SystemConfigKey.FFMPEG_THREADS, value: 4 },
|
||||
{ key: SystemConfigKey.FFMPEG_MAX_BITRATE, value: '2M' },
|
||||
]);
|
||||
assetMock.getByIds.mockResolvedValue([assetStub.video]);
|
||||
await sut.handleVideoConversion({ id: assetStub.video.id });
|
||||
expect(mediaMock.transcode).toHaveBeenCalledWith(
|
||||
'/original/path.ext',
|
||||
'upload/encoded-video/user-id/as/se/asset-id.mp4',
|
||||
{
|
||||
inputOptions: [],
|
||||
outputOptions: [
|
||||
'-c:v av1',
|
||||
'-c:a copy',
|
||||
'-movflags faststart',
|
||||
'-fps_mode passthrough',
|
||||
'-map 0:0',
|
||||
'-map 0:1',
|
||||
'-v verbose',
|
||||
'-vf scale=-2:720,format=yuv420p',
|
||||
'-preset 12',
|
||||
'-crf 23',
|
||||
'-svtav1-params lp=4:mbr=2M',
|
||||
],
|
||||
twoPass: false,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it('should skip transcoding for audioless videos with optimal policy if video codec is correct', async () => {
|
||||
mediaMock.probe.mockResolvedValue(probeStub.noAudioStreams);
|
||||
configMock.load.mockResolvedValue([
|
||||
|
||||
@@ -32,6 +32,7 @@ import { IStorageRepository } from 'src/interfaces/storage.interface';
|
||||
import { ISystemConfigRepository } from 'src/interfaces/system-config.interface';
|
||||
import { ImmichLogger } from 'src/utils/logger';
|
||||
import {
|
||||
AV1Config,
|
||||
H264Config,
|
||||
HEVCConfig,
|
||||
NVENCConfig,
|
||||
@@ -167,12 +168,15 @@ export class MediaService {
|
||||
}
|
||||
|
||||
async handleGeneratePreview({ id }: IEntityJob): Promise<JobStatus> {
|
||||
const [asset] = await this.assetRepository.getByIds([id], { exifInfo: true });
|
||||
const [{ image }, [asset]] = await Promise.all([
|
||||
this.configCore.getConfig(),
|
||||
this.assetRepository.getByIds([id], { exifInfo: true }),
|
||||
]);
|
||||
if (!asset) {
|
||||
return JobStatus.FAILED;
|
||||
}
|
||||
|
||||
const previewPath = await this.generateThumbnail(asset, AssetPathType.PREVIEW, ImageFormat.JPEG);
|
||||
const previewPath = await this.generateThumbnail(asset, AssetPathType.PREVIEW, image.previewFormat);
|
||||
await this.assetRepository.update({ id: asset.id, previewPath });
|
||||
return JobStatus.SUCCESS;
|
||||
}
|
||||
@@ -210,18 +214,21 @@ export class MediaService {
|
||||
}
|
||||
}
|
||||
this.logger.log(
|
||||
`Successfully generated ${format.toUpperCase()} ${asset.type.toLowerCase()} thumbnail for asset ${asset.id}`,
|
||||
`Successfully generated ${format.toUpperCase()} ${asset.type.toLowerCase()} ${type} for asset ${asset.id}`,
|
||||
);
|
||||
return path;
|
||||
}
|
||||
|
||||
async handleGenerateThumbnail({ id }: IEntityJob): Promise<JobStatus> {
|
||||
const [asset] = await this.assetRepository.getByIds([id], { exifInfo: true });
|
||||
const [{ image }, [asset]] = await Promise.all([
|
||||
this.configCore.getConfig(),
|
||||
this.assetRepository.getByIds([id], { exifInfo: true }),
|
||||
]);
|
||||
if (!asset) {
|
||||
return JobStatus.FAILED;
|
||||
}
|
||||
|
||||
const thumbnailPath = await this.generateThumbnail(asset, AssetPathType.THUMBNAIL, ImageFormat.WEBP);
|
||||
const thumbnailPath = await this.generateThumbnail(asset, AssetPathType.THUMBNAIL, image.thumbnailFormat);
|
||||
await this.assetRepository.update({ id: asset.id, thumbnailPath });
|
||||
return JobStatus.SUCCESS;
|
||||
}
|
||||
@@ -433,6 +440,9 @@ export class MediaService {
|
||||
case VideoCodec.VP9: {
|
||||
return new VP9Config(config);
|
||||
}
|
||||
case VideoCodec.AV1: {
|
||||
return new AV1Config(config);
|
||||
}
|
||||
default: {
|
||||
throw new UnsupportedMediaTypeException(`Codec '${config.targetVideoCodec}' is unsupported`);
|
||||
}
|
||||
|
||||
@@ -439,7 +439,7 @@ describe(MetadataService.name, () => {
|
||||
});
|
||||
cryptoRepository.hashSha1.mockReturnValue(randomBytes(512));
|
||||
assetMock.getByChecksum.mockResolvedValue(null);
|
||||
assetMock.create.mockResolvedValue(assetStub.livePhotoMotionAsset);
|
||||
assetMock.create.mockImplementation((asset) => Promise.resolve({ ...assetStub.livePhotoMotionAsset, ...asset }));
|
||||
|
||||
await sut.handleMetadataExtraction({ id: assetStub.livePhotoStillAsset.id });
|
||||
expect(jobMock.queue).toHaveBeenNthCalledWith(2, {
|
||||
@@ -467,6 +467,30 @@ describe(MetadataService.name, () => {
|
||||
expect(jobMock.queue).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
|
||||
it('should link and hide motion video asset to still asset if the hash of the extracted video matches an existing asset', async () => {
|
||||
assetMock.getByIds.mockResolvedValue([{ ...assetStub.livePhotoStillAsset, livePhotoVideoId: null }]);
|
||||
metadataMock.readTags.mockResolvedValue({
|
||||
Directory: 'foo/bar/',
|
||||
MotionPhoto: 1,
|
||||
MicroVideo: 1,
|
||||
MicroVideoOffset: 1,
|
||||
});
|
||||
cryptoRepository.hashSha1.mockReturnValue(randomBytes(512));
|
||||
assetMock.getByChecksum.mockResolvedValue({ ...assetStub.livePhotoMotionAsset, isVisible: true });
|
||||
const video = randomBytes(512);
|
||||
storageMock.readFile.mockResolvedValue(video);
|
||||
|
||||
await sut.handleMetadataExtraction({ id: assetStub.livePhotoStillAsset.id });
|
||||
expect(assetMock.update).toHaveBeenNthCalledWith(1, {
|
||||
id: assetStub.livePhotoMotionAsset.id,
|
||||
isVisible: false,
|
||||
});
|
||||
expect(assetMock.update).toHaveBeenNthCalledWith(2, {
|
||||
id: assetStub.livePhotoStillAsset.id,
|
||||
livePhotoVideoId: assetStub.livePhotoMotionAsset.id,
|
||||
});
|
||||
});
|
||||
|
||||
it('should save all metadata', async () => {
|
||||
const tags: ImmichTags = {
|
||||
BitsPerSample: 1,
|
||||
|
||||
@@ -405,13 +405,19 @@ export class MetadataService {
|
||||
}
|
||||
const checksum = this.cryptoRepository.hashSha1(video);
|
||||
|
||||
let motionAsset = await this.assetRepository.getByChecksum(asset.ownerId, checksum);
|
||||
let motionAsset = await this.assetRepository.getByChecksum(asset.libraryId, checksum);
|
||||
if (motionAsset) {
|
||||
this.logger.debug(
|
||||
`Asset ${asset.id}'s motion photo video with checksum ${checksum.toString(
|
||||
'base64',
|
||||
)} already exists in the repository`,
|
||||
);
|
||||
|
||||
// Hide the motion photo video asset if it's not already hidden to prepare for linking
|
||||
if (motionAsset.isVisible) {
|
||||
await this.assetRepository.update({ id: motionAsset.id, isVisible: false });
|
||||
this.logger.log(`Hid unlinked motion photo video asset (${motionAsset.id})`);
|
||||
}
|
||||
} else {
|
||||
// We create a UUID in advance so that each extracted video can have a unique filename
|
||||
// (allowing us to delete old ones if necessary)
|
||||
@@ -438,11 +444,14 @@ export class MetadataService {
|
||||
this.storageCore.ensureFolders(motionPath);
|
||||
await this.storageRepository.writeFile(motionAsset.originalPath, video);
|
||||
await this.jobRepository.queue({ name: JobName.METADATA_EXTRACTION, data: { id: motionAsset.id } });
|
||||
await this.assetRepository.update({ id: asset.id, livePhotoVideoId: motionAsset.id });
|
||||
}
|
||||
|
||||
if (asset.livePhotoVideoId !== motionAsset.id) {
|
||||
await this.assetRepository.update({ id: asset.id, livePhotoVideoId: motionAsset.id });
|
||||
// If the asset already had an associated livePhotoVideo, delete it, because
|
||||
// its checksum doesn't match the checksum of the motionAsset we just extracted
|
||||
// (if it did, getByChecksum() would've returned non-null)
|
||||
// (if it did, getByChecksum() would've returned a motionAsset with the same ID as livePhotoVideoId)
|
||||
// note asset.livePhotoVideoId is not motionAsset.id yet
|
||||
if (asset.livePhotoVideoId) {
|
||||
await this.jobRepository.queue({ name: JobName.ASSET_DELETION, data: { id: asset.livePhotoVideoId } });
|
||||
this.logger.log(`Removed old motion photo video asset (${asset.livePhotoVideoId})`);
|
||||
|
||||
@@ -346,19 +346,6 @@ describe(SystemConfigService.name, () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('refreshConfig', () => {
|
||||
it('should notify the subscribers', async () => {
|
||||
const changeMock = jest.fn();
|
||||
const subscription = sut.config$.subscribe(changeMock);
|
||||
|
||||
await sut.refreshConfig();
|
||||
|
||||
expect(changeMock).toHaveBeenCalledWith(defaults);
|
||||
|
||||
subscription.unsubscribe();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getCustomCss', () => {
|
||||
it('should return the default theme', async () => {
|
||||
await expect(sut.getCustomCss()).resolves.toEqual(defaults.theme.customCss);
|
||||
|
||||
@@ -90,13 +90,6 @@ export class SystemConfigService {
|
||||
return mapConfig(newConfig);
|
||||
}
|
||||
|
||||
// this is only used by the cli on config change, and it's not actually needed anymore
|
||||
async refreshConfig() {
|
||||
this.eventRepository.serverSend(ServerEvent.CONFIG_UPDATE, null);
|
||||
await this.core.refreshConfig();
|
||||
return true;
|
||||
}
|
||||
|
||||
getStorageTemplateOptions(): SystemConfigTemplateStorageOptionDto {
|
||||
const options = new SystemConfigTemplateStorageOptionDto();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user