Merge branch 'lighter_buckets_web' into lighter_buckets_server

This commit is contained in:
Min Idzelis
2025-04-29 01:58:00 +00:00
328 changed files with 6090 additions and 2169 deletions

View File

@@ -565,7 +565,7 @@ describe(AssetService.name, () => {
it('should remove faces', async () => {
const assetWithFace = { ...assetStub.image, faces: [faceStub.face1, faceStub.mergeFace1] };
mocks.asset.getById.mockResolvedValue(assetWithFace);
mocks.assetJob.getForAssetDeletion.mockResolvedValue(assetWithFace);
await sut.handleAssetDeletion({ id: assetWithFace.id, deleteOnDisk: true });
@@ -592,7 +592,7 @@ describe(AssetService.name, () => {
it('should update stack primary asset if deleted asset was primary asset in a stack', async () => {
mocks.stack.update.mockResolvedValue(factory.stack() as any);
mocks.asset.getById.mockResolvedValue(assetStub.primaryImage);
mocks.assetJob.getForAssetDeletion.mockResolvedValue(assetStub.primaryImage);
await sut.handleAssetDeletion({ id: assetStub.primaryImage.id, deleteOnDisk: true });
@@ -604,7 +604,7 @@ describe(AssetService.name, () => {
it('should delete the entire stack if deleted asset was the primary asset and the stack would only contain one asset afterwards', async () => {
mocks.stack.delete.mockResolvedValue();
mocks.asset.getById.mockResolvedValue({
mocks.assetJob.getForAssetDeletion.mockResolvedValue({
...assetStub.primaryImage,
stack: { ...assetStub.primaryImage.stack, assets: assetStub.primaryImage.stack!.assets.slice(0, 2) },
});
@@ -615,7 +615,7 @@ describe(AssetService.name, () => {
});
it('should delete a live photo', async () => {
mocks.asset.getById.mockResolvedValue(assetStub.livePhotoStillAsset);
mocks.assetJob.getForAssetDeletion.mockResolvedValue(assetStub.livePhotoStillAsset as any);
mocks.asset.getLivePhotoCount.mockResolvedValue(0);
await sut.handleAssetDeletion({
@@ -653,7 +653,7 @@ describe(AssetService.name, () => {
it('should not delete a live motion part if it is being used by another asset', async () => {
mocks.asset.getLivePhotoCount.mockResolvedValue(2);
mocks.asset.getById.mockResolvedValue(assetStub.livePhotoStillAsset);
mocks.assetJob.getForAssetDeletion.mockResolvedValue(assetStub.livePhotoStillAsset as any);
await sut.handleAssetDeletion({
id: assetStub.livePhotoStillAsset.id,
@@ -680,12 +680,13 @@ describe(AssetService.name, () => {
});
it('should update usage', async () => {
mocks.asset.getById.mockResolvedValue(assetStub.image);
mocks.assetJob.getForAssetDeletion.mockResolvedValue(assetStub.image);
await sut.handleAssetDeletion({ id: assetStub.image.id, deleteOnDisk: true });
expect(mocks.user.updateUsage).toHaveBeenCalledWith(assetStub.image.ownerId, -5000);
});
it('should fail if asset could not be found', async () => {
mocks.assetJob.getForAssetDeletion.mockResolvedValue(void 0);
await expect(sut.handleAssetDeletion({ id: assetStub.image.id, deleteOnDisk: true })).resolves.toBe(
JobStatus.FAILED,
);

View File

@@ -189,13 +189,7 @@ export class AssetService extends BaseService {
async handleAssetDeletion(job: JobOf<JobName.ASSET_DELETION>): Promise<JobStatus> {
const { id, deleteOnDisk } = job;
const asset = await this.assetRepository.getById(id, {
faces: { person: true },
library: true,
stack: { assets: true },
exifInfo: true,
files: true,
});
const asset = await this.assetJobRepository.getForAssetDeletion(id);
if (!asset) {
return JobStatus.FAILED;

View File

@@ -142,52 +142,55 @@ describe(BackupService.name, () => {
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.backupEnabled);
mocks.storage.createWriteStream.mockReturnValue(new PassThrough());
});
it('should run a database backup successfully', async () => {
const result = await sut.handleBackupDatabase();
expect(result).toBe(JobStatus.SUCCESS);
expect(mocks.storage.createWriteStream).toHaveBeenCalled();
});
it('should rename file on success', async () => {
const result = await sut.handleBackupDatabase();
expect(result).toBe(JobStatus.SUCCESS);
expect(mocks.storage.rename).toHaveBeenCalled();
});
it('should fail if pg_dumpall fails', async () => {
mocks.process.spawn.mockReturnValueOnce(mockSpawn(1, '', 'error'));
const result = await sut.handleBackupDatabase();
expect(result).toBe(JobStatus.FAILED);
await expect(sut.handleBackupDatabase()).rejects.toThrow('Backup failed with code 1');
});
it('should not rename file if pgdump fails and gzip succeeds', async () => {
mocks.process.spawn.mockReturnValueOnce(mockSpawn(1, '', 'error'));
const result = await sut.handleBackupDatabase();
expect(result).toBe(JobStatus.FAILED);
await expect(sut.handleBackupDatabase()).rejects.toThrow('Backup failed with code 1');
expect(mocks.storage.rename).not.toHaveBeenCalled();
});
it('should fail if gzip fails', async () => {
mocks.process.spawn.mockReturnValueOnce(mockSpawn(0, 'data', ''));
mocks.process.spawn.mockReturnValueOnce(mockSpawn(1, '', 'error'));
const result = await sut.handleBackupDatabase();
expect(result).toBe(JobStatus.FAILED);
await expect(sut.handleBackupDatabase()).rejects.toThrow('Gzip failed with code 1');
});
it('should fail if write stream fails', async () => {
mocks.storage.createWriteStream.mockImplementation(() => {
throw new Error('error');
});
const result = await sut.handleBackupDatabase();
expect(result).toBe(JobStatus.FAILED);
await expect(sut.handleBackupDatabase()).rejects.toThrow('error');
});
it('should fail if rename fails', async () => {
mocks.storage.rename.mockRejectedValue(new Error('error'));
const result = await sut.handleBackupDatabase();
expect(result).toBe(JobStatus.FAILED);
await expect(sut.handleBackupDatabase()).rejects.toThrow('error');
});
it('should ignore unlink failing and still return failed job status', async () => {
mocks.process.spawn.mockReturnValueOnce(mockSpawn(1, '', 'error'));
mocks.storage.unlink.mockRejectedValue(new Error('error'));
const result = await sut.handleBackupDatabase();
await expect(sut.handleBackupDatabase()).rejects.toThrow('Backup failed with code 1');
expect(mocks.storage.unlink).toHaveBeenCalled();
expect(result).toBe(JobStatus.FAILED);
});
it.each`
postgresVersion | expectedVersion
${'14.10'} | ${14}

View File

@@ -70,7 +70,7 @@ export class BackupService extends BaseService {
async handleBackupDatabase(): Promise<JobStatus> {
this.logger.debug(`Database Backup Started`);
const { database } = this.configRepository.getEnv();
const config = database.config.typeorm;
const config = database.config;
const isUrlConnection = config.connectionType === 'url';
@@ -174,7 +174,7 @@ export class BackupService extends BaseService {
await this.storageRepository
.unlink(backupFilePath)
.catch((error) => this.logger.error('Failed to delete failed backup file', error));
return JobStatus.FAILED;
throw error;
}
this.logger.log(`Database Backup Success`);

View File

@@ -29,6 +29,7 @@ import { MediaRepository } from 'src/repositories/media.repository';
import { MemoryRepository } from 'src/repositories/memory.repository';
import { MetadataRepository } from 'src/repositories/metadata.repository';
import { MoveRepository } from 'src/repositories/move.repository';
import { NotificationRepository } from 'src/repositories/notification.repository';
import { OAuthRepository } from 'src/repositories/oauth.repository';
import { PartnerRepository } from 'src/repositories/partner.repository';
import { PersonRepository } from 'src/repositories/person.repository';
@@ -80,6 +81,7 @@ export class BaseService {
protected memoryRepository: MemoryRepository,
protected metadataRepository: MetadataRepository,
protected moveRepository: MoveRepository,
protected notificationRepository: NotificationRepository,
protected oauthRepository: OAuthRepository,
protected partnerRepository: PartnerRepository,
protected personRepository: PersonRepository,

View File

@@ -53,22 +53,12 @@ describe(DatabaseService.name, () => {
mockEnvData({
database: {
config: {
kysely: {
host: 'database',
port: 5432,
user: 'postgres',
password: 'postgres',
database: 'immich',
},
typeorm: {
connectionType: 'parts',
type: 'postgres',
host: 'database',
port: 5432,
username: 'postgres',
password: 'postgres',
database: 'immich',
},
connectionType: 'parts',
host: 'database',
port: 5432,
username: 'postgres',
password: 'postgres',
database: 'immich',
},
skipMigrations: false,
vectorExtension: extension,
@@ -292,22 +282,12 @@ describe(DatabaseService.name, () => {
mockEnvData({
database: {
config: {
kysely: {
host: 'database',
port: 5432,
user: 'postgres',
password: 'postgres',
database: 'immich',
},
typeorm: {
connectionType: 'parts',
type: 'postgres',
host: 'database',
port: 5432,
username: 'postgres',
password: 'postgres',
database: 'immich',
},
connectionType: 'parts',
host: 'database',
port: 5432,
username: 'postgres',
password: 'postgres',
database: 'immich',
},
skipMigrations: true,
vectorExtension: DatabaseExtension.VECTORS,
@@ -325,22 +305,12 @@ describe(DatabaseService.name, () => {
mockEnvData({
database: {
config: {
kysely: {
host: 'database',
port: 5432,
user: 'postgres',
password: 'postgres',
database: 'immich',
},
typeorm: {
connectionType: 'parts',
type: 'postgres',
host: 'database',
port: 5432,
username: 'postgres',
password: 'postgres',
database: 'immich',
},
connectionType: 'parts',
host: 'database',
port: 5432,
username: 'postgres',
password: 'postgres',
database: 'immich',
},
skipMigrations: true,
vectorExtension: DatabaseExtension.VECTOR,

View File

@@ -33,7 +33,7 @@ export class DownloadService extends BaseService {
const targetSize = dto.archiveSize || HumanReadableSize.GiB * 4;
const metadata = await this.userRepository.getMetadata(auth.user.id);
const preferences = getPreferences(auth.user.email, metadata);
const preferences = getPreferences(metadata);
const motionIds = new Set<string>();
const archives: DownloadArchiveInfo[] = [];
let archive: DownloadArchiveInfo = { size: 0, assetIds: [] };

View File

@@ -17,6 +17,7 @@ import { MapService } from 'src/services/map.service';
import { MediaService } from 'src/services/media.service';
import { MemoryService } from 'src/services/memory.service';
import { MetadataService } from 'src/services/metadata.service';
import { NotificationAdminService } from 'src/services/notification-admin.service';
import { NotificationService } from 'src/services/notification.service';
import { PartnerService } from 'src/services/partner.service';
import { PersonService } from 'src/services/person.service';
@@ -60,6 +61,7 @@ export const services = [
MemoryService,
MetadataService,
NotificationService,
NotificationAdminService,
PartnerService,
PersonService,
SearchService,

View File

@@ -215,11 +215,7 @@ export class JobService extends BaseService {
await this.onDone(job);
}
} catch (error: Error | any) {
this.logger.error(
`Unable to run job handler (${queueName}/${job.name}): ${error}`,
error?.stack,
JSON.stringify(job.data),
);
await this.eventRepository.emit('job.failed', { job, error });
} finally {
this.telemetryRepository.jobs.addToGauge(queueMetric, -1);
}

View File

@@ -1,7 +1,6 @@
import { OutputInfo } from 'sharp';
import { SystemConfig } from 'src/config';
import { Exif } from 'src/database';
import { AssetMediaSize } from 'src/dtos/asset-media.dto';
import {
AssetFileType,
AssetPathType,
@@ -11,11 +10,11 @@ import {
ImageFormat,
JobName,
JobStatus,
RawExtractedFormat,
TranscodeHWAccel,
TranscodePolicy,
VideoCodec,
} from 'src/enum';
import { WithoutProperty } from 'src/repositories/asset.repository';
import { MediaService } from 'src/services/media.service';
import { JobCounts, RawImageInfo } from 'src/types';
import { assetStub } from 'test/fixtures/asset.stub';
@@ -232,17 +231,19 @@ describe(MediaService.name, () => {
describe('handleGenerateThumbnails', () => {
let rawBuffer: Buffer;
let fullsizeBuffer: Buffer;
let extractedBuffer: Buffer;
let rawInfo: RawImageInfo;
beforeEach(() => {
fullsizeBuffer = Buffer.from('embedded image data');
rawBuffer = Buffer.from('image data');
rawBuffer = Buffer.from('raw image data');
extractedBuffer = Buffer.from('embedded image file');
rawInfo = { width: 100, height: 100, channels: 3 };
mocks.media.decodeImage.mockImplementation((path) =>
mocks.media.decodeImage.mockImplementation((input) =>
Promise.resolve(
path.includes(AssetMediaSize.FULLSIZE)
? { data: fullsizeBuffer, info: rawInfo as OutputInfo }
: { data: rawBuffer, info: rawInfo as OutputInfo },
typeof input === 'string'
? { data: rawBuffer, info: rawInfo as OutputInfo } // string implies original file
: { data: fullsizeBuffer, info: rawInfo as OutputInfo }, // buffer implies embedded image extracted
),
);
});
@@ -585,16 +586,15 @@ describe(MediaService.name, () => {
});
it('should extract embedded image if enabled and available', async () => {
mocks.media.extract.mockResolvedValue(true);
mocks.media.extract.mockResolvedValue({ buffer: extractedBuffer, format: RawExtractedFormat.JPEG });
mocks.media.getImageDimensions.mockResolvedValue({ width: 3840, height: 2160 });
mocks.systemMetadata.get.mockResolvedValue({ image: { extractEmbedded: true } });
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(assetStub.imageDng);
await sut.handleGenerateThumbnails({ id: assetStub.image.id });
const convertedPath = mocks.media.extract.mock.lastCall?.[1].toString();
expect(mocks.media.decodeImage).toHaveBeenCalledOnce();
expect(mocks.media.decodeImage).toHaveBeenCalledWith(convertedPath, {
expect(mocks.media.decodeImage).toHaveBeenCalledWith(extractedBuffer, {
colorspace: Colorspace.P3,
processInvalidImages: false,
size: 1440,
@@ -602,16 +602,13 @@ describe(MediaService.name, () => {
});
it('should resize original image if embedded image is too small', async () => {
mocks.media.extract.mockResolvedValue(true);
mocks.media.extract.mockResolvedValue({ buffer: extractedBuffer, format: RawExtractedFormat.JPEG });
mocks.media.getImageDimensions.mockResolvedValue({ width: 1000, height: 1000 });
mocks.systemMetadata.get.mockResolvedValue({ image: { extractEmbedded: true } });
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(assetStub.imageDng);
await sut.handleGenerateThumbnails({ id: assetStub.image.id });
const extractedPath = mocks.media.extract.mock.lastCall?.[1].toString();
expect(extractedPath).toMatch(/-fullsize\.jpeg$/);
expect(mocks.media.decodeImage).toHaveBeenCalledWith(assetStub.imageDng.originalPath, {
colorspace: Colorspace.P3,
processInvalidImages: false,
@@ -666,38 +663,40 @@ describe(MediaService.name, () => {
expect(mocks.media.generateThumbnail).toHaveBeenCalledTimes(2);
expect(mocks.media.generateThumbnail).toHaveBeenCalledWith(
rawBuffer,
expect.objectContaining({ processInvalidImages: true }),
expect.objectContaining({ processInvalidImages: false }),
'upload/thumbs/user-id/as/se/asset-id-preview.jpeg',
);
expect(mocks.media.generateThumbnail).toHaveBeenCalledWith(
rawBuffer,
expect.objectContaining({ processInvalidImages: true }),
expect.objectContaining({ processInvalidImages: false }),
'upload/thumbs/user-id/as/se/asset-id-thumbnail.webp',
);
expect(mocks.media.generateThumbhash).toHaveBeenCalledOnce();
expect(mocks.media.generateThumbhash).toHaveBeenCalledWith(
rawBuffer,
expect.objectContaining({ processInvalidImages: true }),
expect.objectContaining({ processInvalidImages: false }),
);
expect(mocks.media.getImageDimensions).not.toHaveBeenCalled();
vi.unstubAllEnvs();
});
it('should generate full-size preview using embedded JPEG from RAW images when extractEmbedded is true', async () => {
mocks.systemMetadata.get.mockResolvedValue({ image: { fullsize: { enabled: true }, extractEmbedded: true } });
mocks.media.extract.mockResolvedValue(true);
it('should extract full-size JPEG preview from RAW', async () => {
mocks.systemMetadata.get.mockResolvedValue({
image: { fullsize: { enabled: true, format: ImageFormat.WEBP }, extractEmbedded: true },
});
mocks.media.extract.mockResolvedValue({ buffer: extractedBuffer, format: RawExtractedFormat.JPEG });
mocks.media.getImageDimensions.mockResolvedValue({ width: 3840, height: 2160 });
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(assetStub.imageDng);
await sut.handleGenerateThumbnails({ id: assetStub.image.id });
const extractedPath = mocks.media.extract.mock.lastCall?.[1].toString();
expect(mocks.media.decodeImage).toHaveBeenCalledOnce();
expect(mocks.media.decodeImage).toHaveBeenCalledWith(extractedPath, {
expect(mocks.media.decodeImage).toHaveBeenCalledWith(extractedBuffer, {
colorspace: Colorspace.P3,
processInvalidImages: false,
size: 1440, // capped to preview size as fullsize conversion is skipped
});
expect(mocks.media.generateThumbnail).toHaveBeenCalledTimes(2);
@@ -715,9 +714,51 @@ describe(MediaService.name, () => {
);
});
it('should convert full-size WEBP preview from JXL preview of RAW', async () => {
mocks.systemMetadata.get.mockResolvedValue({
image: { fullsize: { enabled: true, format: ImageFormat.WEBP }, extractEmbedded: true },
});
mocks.media.extract.mockResolvedValue({ buffer: extractedBuffer, format: RawExtractedFormat.JXL });
mocks.media.getImageDimensions.mockResolvedValue({ width: 3840, height: 2160 });
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(assetStub.imageDng);
await sut.handleGenerateThumbnails({ id: assetStub.image.id });
expect(mocks.media.decodeImage).toHaveBeenCalledOnce();
expect(mocks.media.decodeImage).toHaveBeenCalledWith(extractedBuffer, {
colorspace: Colorspace.P3,
processInvalidImages: false,
});
expect(mocks.media.generateThumbnail).toHaveBeenCalledTimes(3);
expect(mocks.media.generateThumbnail).toHaveBeenCalledWith(
fullsizeBuffer,
{
colorspace: Colorspace.P3,
format: ImageFormat.WEBP,
quality: 80,
processInvalidImages: false,
raw: rawInfo,
},
'upload/thumbs/user-id/as/se/asset-id-fullsize.webp',
);
expect(mocks.media.generateThumbnail).toHaveBeenCalledWith(
fullsizeBuffer,
{
colorspace: Colorspace.P3,
format: ImageFormat.JPEG,
size: 1440,
quality: 80,
processInvalidImages: false,
raw: rawInfo,
},
'upload/thumbs/user-id/as/se/asset-id-preview.jpeg',
);
});
it('should generate full-size preview directly from RAW images when extractEmbedded is false', async () => {
mocks.systemMetadata.get.mockResolvedValue({ image: { fullsize: { enabled: true }, extractEmbedded: false } });
mocks.media.extract.mockResolvedValue(true);
mocks.media.extract.mockResolvedValue({ buffer: extractedBuffer, format: RawExtractedFormat.JPEG });
mocks.media.getImageDimensions.mockResolvedValue({ width: 3840, height: 2160 });
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(assetStub.imageDng);
@@ -757,7 +798,7 @@ describe(MediaService.name, () => {
it('should generate full-size preview from non-web-friendly images', async () => {
mocks.systemMetadata.get.mockResolvedValue({ image: { fullsize: { enabled: true } } });
mocks.media.extract.mockResolvedValue(true);
mocks.media.extract.mockResolvedValue({ buffer: extractedBuffer, format: RawExtractedFormat.JPEG });
mocks.media.getImageDimensions.mockResolvedValue({ width: 3840, height: 2160 });
// HEIF/HIF image taken by cameras are not web-friendly, only has limited support on Safari.
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(assetStub.imageHif);
@@ -786,7 +827,7 @@ describe(MediaService.name, () => {
it('should skip generating full-size preview for web-friendly images', async () => {
mocks.systemMetadata.get.mockResolvedValue({ image: { fullsize: { enabled: true } } });
mocks.media.extract.mockResolvedValue(true);
mocks.media.extract.mockResolvedValue({ buffer: extractedBuffer, format: RawExtractedFormat.JPEG });
mocks.media.getImageDimensions.mockResolvedValue({ width: 3840, height: 2160 });
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(assetStub.image);
@@ -811,7 +852,7 @@ describe(MediaService.name, () => {
mocks.systemMetadata.get.mockResolvedValue({
image: { fullsize: { enabled: true, format: ImageFormat.WEBP, quality: 90 } },
});
mocks.media.extract.mockResolvedValue(true);
mocks.media.extract.mockResolvedValue({ buffer: extractedBuffer, format: RawExtractedFormat.JPEG });
mocks.media.getImageDimensions.mockResolvedValue({ width: 3840, height: 2160 });
// HEIF/HIF image taken by cameras are not web-friendly, only has limited support on Safari.
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(assetStub.imageHif);
@@ -841,16 +882,12 @@ describe(MediaService.name, () => {
describe('handleQueueVideoConversion', () => {
it('should queue all video assets', async () => {
mocks.asset.getAll.mockResolvedValue({
items: [assetStub.video],
hasNextPage: false,
});
mocks.assetJob.streamForVideoConversion.mockReturnValue(makeStream([assetStub.video]));
mocks.person.getAll.mockReturnValue(makeStream());
await sut.handleQueueVideoConversion({ force: true });
expect(mocks.asset.getAll).toHaveBeenCalledWith({ skip: 0, take: 1000 }, { type: AssetType.VIDEO });
expect(mocks.asset.getWithout).not.toHaveBeenCalled();
expect(mocks.assetJob.streamForVideoConversion).toHaveBeenCalledWith(true);
expect(mocks.job.queueAll).toHaveBeenCalledWith([
{
name: JobName.VIDEO_CONVERSION,
@@ -860,15 +897,11 @@ describe(MediaService.name, () => {
});
it('should queue all video assets without encoded videos', async () => {
mocks.asset.getWithout.mockResolvedValue({
items: [assetStub.video],
hasNextPage: false,
});
mocks.assetJob.streamForVideoConversion.mockReturnValue(makeStream([assetStub.video]));
await sut.handleQueueVideoConversion({});
expect(mocks.asset.getAll).not.toHaveBeenCalled();
expect(mocks.asset.getWithout).toHaveBeenCalledWith({ skip: 0, take: 1000 }, WithoutProperty.ENCODED_VIDEO);
expect(mocks.assetJob.streamForVideoConversion).toHaveBeenCalledWith(void 0);
expect(mocks.job.queueAll).toHaveBeenCalledWith([
{
name: JobName.VIDEO_CONVERSION,
@@ -880,26 +913,18 @@ describe(MediaService.name, () => {
describe('handleVideoConversion', () => {
beforeEach(() => {
mocks.asset.getByIds.mockResolvedValue([assetStub.video]);
mocks.assetJob.getForVideoConversion.mockResolvedValue(assetStub.video);
sut.videoInterfaces = { dri: ['renderD128'], mali: true };
});
it('should skip transcoding if asset not found', async () => {
mocks.asset.getByIds.mockResolvedValue([]);
mocks.assetJob.getForVideoConversion.mockResolvedValue(void 0);
await sut.handleVideoConversion({ id: assetStub.video.id });
expect(mocks.media.probe).not.toHaveBeenCalled();
expect(mocks.media.transcode).not.toHaveBeenCalled();
});
it('should skip transcoding if non-video asset', async () => {
mocks.asset.getByIds.mockResolvedValue([assetStub.image]);
await sut.handleVideoConversion({ id: assetStub.image.id });
expect(mocks.media.probe).not.toHaveBeenCalled();
expect(mocks.media.transcode).not.toHaveBeenCalled();
});
it('should transcode the longest stream', async () => {
mocks.asset.getByIds.mockResolvedValue([assetStub.video]);
mocks.logger.isLevelEnabled.mockReturnValue(false);
mocks.media.probe.mockResolvedValue(probeStub.multipleVideoStreams);
@@ -921,14 +946,12 @@ describe(MediaService.name, () => {
it('should skip a video without any streams', async () => {
mocks.media.probe.mockResolvedValue(probeStub.noVideoStreams);
mocks.asset.getByIds.mockResolvedValue([assetStub.video]);
await sut.handleVideoConversion({ id: assetStub.video.id });
expect(mocks.media.transcode).not.toHaveBeenCalled();
});
it('should skip a video without any height', async () => {
mocks.media.probe.mockResolvedValue(probeStub.noHeight);
mocks.asset.getByIds.mockResolvedValue([assetStub.video]);
await sut.handleVideoConversion({ id: assetStub.video.id });
expect(mocks.media.transcode).not.toHaveBeenCalled();
});
@@ -936,7 +959,6 @@ describe(MediaService.name, () => {
it('should throw an error if an unknown transcode policy is configured', async () => {
mocks.media.probe.mockResolvedValue(probeStub.noAudioStreams);
mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { transcode: 'foo' } } as never as SystemConfig);
mocks.asset.getByIds.mockResolvedValue([assetStub.video]);
await expect(sut.handleVideoConversion({ id: assetStub.video.id })).rejects.toThrowError();
expect(mocks.media.transcode).not.toHaveBeenCalled();
@@ -947,7 +969,6 @@ describe(MediaService.name, () => {
mocks.systemMetadata.get.mockResolvedValue({
ffmpeg: { transcode: TranscodePolicy.ALL, accel: TranscodeHWAccel.DISABLED },
});
mocks.asset.getByIds.mockResolvedValue([assetStub.video]);
mocks.media.transcode.mockRejectedValue(new Error('Error transcoding video'));
await expect(sut.handleVideoConversion({ id: assetStub.video.id })).resolves.toBe(JobStatus.FAILED);
@@ -957,7 +978,6 @@ describe(MediaService.name, () => {
it('should transcode when set to all', async () => {
mocks.media.probe.mockResolvedValue(probeStub.multipleVideoStreams);
mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { transcode: TranscodePolicy.ALL } });
mocks.asset.getByIds.mockResolvedValue([assetStub.video]);
await sut.handleVideoConversion({ id: assetStub.video.id });
expect(mocks.media.transcode).toHaveBeenCalledWith(
'/original/path.ext',
@@ -1035,7 +1055,6 @@ describe(MediaService.name, () => {
it('should scale horizontally when video is horizontal', async () => {
mocks.media.probe.mockResolvedValue(probeStub.videoStream2160p);
mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { transcode: TranscodePolicy.OPTIMAL } });
mocks.asset.getByIds.mockResolvedValue([assetStub.video]);
await sut.handleVideoConversion({ id: assetStub.video.id });
expect(mocks.media.transcode).toHaveBeenCalledWith(
'/original/path.ext',
@@ -1051,7 +1070,6 @@ describe(MediaService.name, () => {
it('should scale vertically when video is vertical', async () => {
mocks.media.probe.mockResolvedValue(probeStub.videoStreamVertical2160p);
mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { transcode: TranscodePolicy.OPTIMAL } });
mocks.asset.getByIds.mockResolvedValue([assetStub.video]);
await sut.handleVideoConversion({ id: assetStub.video.id });
expect(mocks.media.transcode).toHaveBeenCalledWith(
'/original/path.ext',
@@ -1069,7 +1087,6 @@ describe(MediaService.name, () => {
mocks.systemMetadata.get.mockResolvedValue({
ffmpeg: { transcode: TranscodePolicy.ALL, targetResolution: 'original' },
});
mocks.asset.getByIds.mockResolvedValue([assetStub.video]);
await sut.handleVideoConversion({ id: assetStub.video.id });
expect(mocks.media.transcode).toHaveBeenCalledWith(
'/original/path.ext',
@@ -1087,7 +1104,6 @@ describe(MediaService.name, () => {
mocks.systemMetadata.get.mockResolvedValue({
ffmpeg: { transcode: TranscodePolicy.ALL, targetResolution: 'original' },
});
mocks.asset.getByIds.mockResolvedValue([assetStub.video]);
await sut.handleVideoConversion({ id: assetStub.video.id });
expect(mocks.media.transcode).toHaveBeenCalledWith(
'/original/path.ext',
@@ -1105,7 +1121,6 @@ describe(MediaService.name, () => {
mocks.systemMetadata.get.mockResolvedValue({
ffmpeg: { targetVideoCodec: VideoCodec.HEVC, acceptedAudioCodecs: [AudioCodec.AAC] },
});
mocks.asset.getByIds.mockResolvedValue([assetStub.video]);
await sut.handleVideoConversion({ id: assetStub.video.id });
expect(mocks.media.transcode).toHaveBeenCalledWith(
'/original/path.ext',
@@ -1127,7 +1142,6 @@ describe(MediaService.name, () => {
acceptedAudioCodecs: [AudioCodec.AAC],
},
});
mocks.asset.getByIds.mockResolvedValue([assetStub.video]);
await sut.handleVideoConversion({ id: assetStub.video.id });
expect(mocks.media.transcode).toHaveBeenCalledWith(
'/original/path.ext',
@@ -1149,7 +1163,6 @@ describe(MediaService.name, () => {
acceptedAudioCodecs: [AudioCodec.AAC],
},
});
mocks.asset.getByIds.mockResolvedValue([assetStub.video]);
await sut.handleVideoConversion({ id: assetStub.video.id });
expect(mocks.media.transcode).toHaveBeenCalledWith(
'/original/path.ext',
@@ -1165,7 +1178,6 @@ describe(MediaService.name, () => {
it('should copy audio stream when audio matches target', async () => {
mocks.media.probe.mockResolvedValue(probeStub.audioStreamAac);
mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { transcode: TranscodePolicy.OPTIMAL } });
mocks.asset.getByIds.mockResolvedValue([assetStub.video]);
await sut.handleVideoConversion({ id: assetStub.video.id });
expect(mocks.media.transcode).toHaveBeenCalledWith(
'/original/path.ext',
@@ -1180,7 +1192,6 @@ describe(MediaService.name, () => {
it('should remux when input is not an accepted container', async () => {
mocks.media.probe.mockResolvedValue(probeStub.videoStreamAvi);
mocks.asset.getByIds.mockResolvedValue([assetStub.video]);
await sut.handleVideoConversion({ id: assetStub.video.id });
expect(mocks.media.transcode).toHaveBeenCalledWith(
'/original/path.ext',
@@ -1204,7 +1215,6 @@ describe(MediaService.name, () => {
it('should not transcode if transcoding is disabled', async () => {
mocks.media.probe.mockResolvedValue(probeStub.videoStream2160p);
mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { transcode: TranscodePolicy.DISABLED } });
mocks.asset.getByIds.mockResolvedValue([assetStub.video]);
await sut.handleVideoConversion({ id: assetStub.video.id });
expect(mocks.media.transcode).not.toHaveBeenCalled();
});
@@ -1212,7 +1222,6 @@ describe(MediaService.name, () => {
it('should not remux when input is not an accepted container and transcoding is disabled', async () => {
mocks.media.probe.mockResolvedValue(probeStub.matroskaContainer);
mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { transcode: TranscodePolicy.DISABLED } });
mocks.asset.getByIds.mockResolvedValue([assetStub.video]);
await sut.handleVideoConversion({ id: assetStub.video.id });
expect(mocks.media.transcode).not.toHaveBeenCalled();
});
@@ -1220,7 +1229,6 @@ describe(MediaService.name, () => {
it('should not transcode if target codec is invalid', async () => {
mocks.media.probe.mockResolvedValue(probeStub.videoStream2160p);
mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { targetVideoCodec: 'invalid' as any } });
mocks.asset.getByIds.mockResolvedValue([assetStub.video]);
await sut.handleVideoConversion({ id: assetStub.video.id });
expect(mocks.media.transcode).not.toHaveBeenCalled();
});
@@ -1229,7 +1237,7 @@ describe(MediaService.name, () => {
const asset = assetStub.hasEncodedVideo;
mocks.media.probe.mockResolvedValue(probeStub.videoStream2160p);
mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { transcode: TranscodePolicy.DISABLED } });
mocks.asset.getByIds.mockResolvedValue([asset]);
mocks.assetJob.getForVideoConversion.mockResolvedValue(asset);
await sut.handleVideoConversion({ id: asset.id });
@@ -1243,7 +1251,6 @@ describe(MediaService.name, () => {
it('should set max bitrate if above 0', async () => {
mocks.media.probe.mockResolvedValue(probeStub.matroskaContainer);
mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { maxBitrate: '4500k' } });
mocks.asset.getByIds.mockResolvedValue([assetStub.video]);
await sut.handleVideoConversion({ id: assetStub.video.id });
expect(mocks.media.transcode).toHaveBeenCalledWith(
'/original/path.ext',
@@ -1259,7 +1266,6 @@ describe(MediaService.name, () => {
it('should default max bitrate to kbps if no unit is provided', async () => {
mocks.media.probe.mockResolvedValue(probeStub.matroskaContainer);
mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { maxBitrate: '4500' } });
mocks.asset.getByIds.mockResolvedValue([assetStub.video]);
await sut.handleVideoConversion({ id: assetStub.video.id });
expect(mocks.media.transcode).toHaveBeenCalledWith(
'/original/path.ext',
@@ -1275,7 +1281,6 @@ describe(MediaService.name, () => {
it('should transcode in two passes for h264/h265 when enabled and max bitrate is above 0', async () => {
mocks.media.probe.mockResolvedValue(probeStub.matroskaContainer);
mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { twoPass: true, maxBitrate: '4500k' } });
mocks.asset.getByIds.mockResolvedValue([assetStub.video]);
await sut.handleVideoConversion({ id: assetStub.video.id });
expect(mocks.media.transcode).toHaveBeenCalledWith(
'/original/path.ext',
@@ -1291,7 +1296,6 @@ describe(MediaService.name, () => {
it('should fallback to one pass for h264/h265 if two-pass is enabled but no max bitrate is set', async () => {
mocks.media.probe.mockResolvedValue(probeStub.matroskaContainer);
mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { twoPass: true } });
mocks.asset.getByIds.mockResolvedValue([assetStub.video]);
await sut.handleVideoConversion({ id: assetStub.video.id });
expect(mocks.media.transcode).toHaveBeenCalledWith(
'/original/path.ext',
@@ -1313,7 +1317,6 @@ describe(MediaService.name, () => {
targetVideoCodec: VideoCodec.VP9,
},
});
mocks.asset.getByIds.mockResolvedValue([assetStub.video]);
await sut.handleVideoConversion({ id: assetStub.video.id });
expect(mocks.media.transcode).toHaveBeenCalledWith(
'/original/path.ext',
@@ -1335,7 +1338,6 @@ describe(MediaService.name, () => {
targetVideoCodec: VideoCodec.VP9,
},
});
mocks.asset.getByIds.mockResolvedValue([assetStub.video]);
await sut.handleVideoConversion({ id: assetStub.video.id });
expect(mocks.media.transcode).toHaveBeenCalledWith(
'/original/path.ext',
@@ -1351,7 +1353,6 @@ describe(MediaService.name, () => {
it('should configure preset for vp9', async () => {
mocks.media.probe.mockResolvedValue(probeStub.matroskaContainer);
mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { targetVideoCodec: VideoCodec.VP9, preset: 'slow' } });
mocks.asset.getByIds.mockResolvedValue([assetStub.video]);
await sut.handleVideoConversion({ id: assetStub.video.id });
expect(mocks.media.transcode).toHaveBeenCalledWith(
'/original/path.ext',
@@ -1367,7 +1368,6 @@ describe(MediaService.name, () => {
it('should not configure preset for vp9 if invalid', async () => {
mocks.media.probe.mockResolvedValue(probeStub.matroskaContainer);
mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { preset: 'invalid', targetVideoCodec: VideoCodec.VP9 } });
mocks.asset.getByIds.mockResolvedValue([assetStub.video]);
await sut.handleVideoConversion({ id: assetStub.video.id });
expect(mocks.media.transcode).toHaveBeenCalledWith(
'/original/path.ext',
@@ -1383,7 +1383,6 @@ describe(MediaService.name, () => {
it('should configure threads if above 0', async () => {
mocks.media.probe.mockResolvedValue(probeStub.matroskaContainer);
mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { targetVideoCodec: VideoCodec.VP9, threads: 2 } });
mocks.asset.getByIds.mockResolvedValue([assetStub.video]);
await sut.handleVideoConversion({ id: assetStub.video.id });
expect(mocks.media.transcode).toHaveBeenCalledWith(
'/original/path.ext',
@@ -1399,7 +1398,6 @@ describe(MediaService.name, () => {
it('should disable thread pooling for h264 if thread limit is 1', async () => {
mocks.media.probe.mockResolvedValue(probeStub.matroskaContainer);
mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { threads: 1 } });
mocks.asset.getByIds.mockResolvedValue([assetStub.video]);
await sut.handleVideoConversion({ id: assetStub.video.id });
expect(mocks.media.transcode).toHaveBeenCalledWith(
'/original/path.ext',
@@ -1415,7 +1413,6 @@ describe(MediaService.name, () => {
it('should omit thread flags for h264 if thread limit is at or below 0', async () => {
mocks.media.probe.mockResolvedValue(probeStub.matroskaContainer);
mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { threads: 0 } });
mocks.asset.getByIds.mockResolvedValue([assetStub.video]);
await sut.handleVideoConversion({ id: assetStub.video.id });
expect(mocks.media.transcode).toHaveBeenCalledWith(
'/original/path.ext',
@@ -1431,7 +1428,6 @@ describe(MediaService.name, () => {
it('should disable thread pooling for hevc if thread limit is 1', async () => {
mocks.media.probe.mockResolvedValue(probeStub.videoStreamVp9);
mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { threads: 1, targetVideoCodec: VideoCodec.HEVC } });
mocks.asset.getByIds.mockResolvedValue([assetStub.video]);
await sut.handleVideoConversion({ id: assetStub.video.id });
expect(mocks.media.transcode).toHaveBeenCalledWith(
'/original/path.ext',
@@ -1447,7 +1443,6 @@ describe(MediaService.name, () => {
it('should omit thread flags for hevc if thread limit is at or below 0', async () => {
mocks.media.probe.mockResolvedValue(probeStub.videoStreamVp9);
mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { threads: 0, targetVideoCodec: VideoCodec.HEVC } });
mocks.asset.getByIds.mockResolvedValue([assetStub.video]);
await sut.handleVideoConversion({ id: assetStub.video.id });
expect(mocks.media.transcode).toHaveBeenCalledWith(
'/original/path.ext',
@@ -1463,7 +1458,6 @@ describe(MediaService.name, () => {
it('should use av1 if specified', async () => {
mocks.media.probe.mockResolvedValue(probeStub.videoStreamVp9);
mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { targetVideoCodec: VideoCodec.AV1 } });
mocks.asset.getByIds.mockResolvedValue([assetStub.video]);
await sut.handleVideoConversion({ id: assetStub.video.id });
expect(mocks.media.transcode).toHaveBeenCalledWith(
'/original/path.ext',
@@ -1489,7 +1483,6 @@ describe(MediaService.name, () => {
it('should map `veryslow` preset to 4 for av1', async () => {
mocks.media.probe.mockResolvedValue(probeStub.videoStreamVp9);
mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { targetVideoCodec: VideoCodec.AV1, preset: 'veryslow' } });
mocks.asset.getByIds.mockResolvedValue([assetStub.video]);
await sut.handleVideoConversion({ id: assetStub.video.id });
expect(mocks.media.transcode).toHaveBeenCalledWith(
'/original/path.ext',
@@ -1505,7 +1498,6 @@ describe(MediaService.name, () => {
it('should set max bitrate for av1 if specified', async () => {
mocks.media.probe.mockResolvedValue(probeStub.videoStreamVp9);
mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { targetVideoCodec: VideoCodec.AV1, maxBitrate: '2M' } });
mocks.asset.getByIds.mockResolvedValue([assetStub.video]);
await sut.handleVideoConversion({ id: assetStub.video.id });
expect(mocks.media.transcode).toHaveBeenCalledWith(
'/original/path.ext',
@@ -1521,7 +1513,6 @@ describe(MediaService.name, () => {
it('should set threads for av1 if specified', async () => {
mocks.media.probe.mockResolvedValue(probeStub.videoStreamVp9);
mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { targetVideoCodec: VideoCodec.AV1, threads: 4 } });
mocks.asset.getByIds.mockResolvedValue([assetStub.video]);
await sut.handleVideoConversion({ id: assetStub.video.id });
expect(mocks.media.transcode).toHaveBeenCalledWith(
'/original/path.ext',
@@ -1539,7 +1530,6 @@ describe(MediaService.name, () => {
mocks.systemMetadata.get.mockResolvedValue({
ffmpeg: { targetVideoCodec: VideoCodec.AV1, threads: 4, maxBitrate: '2M' },
});
mocks.asset.getByIds.mockResolvedValue([assetStub.video]);
await sut.handleVideoConversion({ id: assetStub.video.id });
expect(mocks.media.transcode).toHaveBeenCalledWith(
'/original/path.ext',
@@ -1561,7 +1551,6 @@ describe(MediaService.name, () => {
targetResolution: '1080p',
},
});
mocks.asset.getByIds.mockResolvedValue([assetStub.video]);
await sut.handleVideoConversion({ id: assetStub.video.id });
expect(mocks.media.transcode).not.toHaveBeenCalled();
});
@@ -1571,7 +1560,6 @@ describe(MediaService.name, () => {
mocks.systemMetadata.get.mockResolvedValue({
ffmpeg: { accel: TranscodeHWAccel.NVENC, targetVideoCodec: VideoCodec.VP9 },
});
mocks.asset.getByIds.mockResolvedValue([assetStub.video]);
await expect(sut.handleVideoConversion({ id: assetStub.video.id })).rejects.toThrowError();
expect(mocks.media.transcode).not.toHaveBeenCalled();
});
@@ -1579,7 +1567,6 @@ describe(MediaService.name, () => {
it('should fail if hwaccel option is invalid', async () => {
mocks.media.probe.mockResolvedValue(probeStub.matroskaContainer);
mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { accel: 'invalid' as any } });
mocks.asset.getByIds.mockResolvedValue([assetStub.video]);
await expect(sut.handleVideoConversion({ id: assetStub.video.id })).rejects.toThrowError();
expect(mocks.media.transcode).not.toHaveBeenCalled();
});
@@ -1587,7 +1574,6 @@ describe(MediaService.name, () => {
it('should set options for nvenc', async () => {
mocks.media.probe.mockResolvedValue(probeStub.matroskaContainer);
mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.NVENC } });
mocks.asset.getByIds.mockResolvedValue([assetStub.video]);
await sut.handleVideoConversion({ id: assetStub.video.id });
expect(mocks.media.transcode).toHaveBeenCalledWith(
'/original/path.ext',
@@ -1625,7 +1611,6 @@ describe(MediaService.name, () => {
twoPass: true,
},
});
mocks.asset.getByIds.mockResolvedValue([assetStub.video]);
await sut.handleVideoConversion({ id: assetStub.video.id });
expect(mocks.media.transcode).toHaveBeenCalledWith(
'/original/path.ext',
@@ -1641,7 +1626,6 @@ describe(MediaService.name, () => {
it('should set vbr options for nvenc when max bitrate is enabled', async () => {
mocks.media.probe.mockResolvedValue(probeStub.matroskaContainer);
mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.NVENC, maxBitrate: '10000k' } });
mocks.asset.getByIds.mockResolvedValue([assetStub.video]);
await sut.handleVideoConversion({ id: assetStub.video.id });
expect(mocks.media.transcode).toHaveBeenCalledWith(
'/original/path.ext',
@@ -1657,7 +1641,6 @@ describe(MediaService.name, () => {
it('should set cq options for nvenc when max bitrate is disabled', async () => {
mocks.media.probe.mockResolvedValue(probeStub.matroskaContainer);
mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.NVENC, maxBitrate: '10000k' } });
mocks.asset.getByIds.mockResolvedValue([assetStub.video]);
await sut.handleVideoConversion({ id: assetStub.video.id });
expect(mocks.media.transcode).toHaveBeenCalledWith(
'/original/path.ext',
@@ -1673,7 +1656,6 @@ describe(MediaService.name, () => {
it('should omit preset for nvenc if invalid', async () => {
mocks.media.probe.mockResolvedValue(probeStub.matroskaContainer);
mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.NVENC, preset: 'invalid' } });
mocks.asset.getByIds.mockResolvedValue([assetStub.video]);
await sut.handleVideoConversion({ id: assetStub.video.id });
expect(mocks.media.transcode).toHaveBeenCalledWith(
'/original/path.ext',
@@ -1689,7 +1671,6 @@ describe(MediaService.name, () => {
it('should ignore two pass for nvenc if max bitrate is disabled', async () => {
mocks.media.probe.mockResolvedValue(probeStub.matroskaContainer);
mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.NVENC } });
mocks.asset.getByIds.mockResolvedValue([assetStub.video]);
await sut.handleVideoConversion({ id: assetStub.video.id });
expect(mocks.media.transcode).toHaveBeenCalledWith(
'/original/path.ext',
@@ -1707,7 +1688,6 @@ describe(MediaService.name, () => {
mocks.systemMetadata.get.mockResolvedValue({
ffmpeg: { accel: TranscodeHWAccel.NVENC, accelDecode: true },
});
mocks.asset.getByIds.mockResolvedValue([assetStub.video]);
await sut.handleVideoConversion({ id: assetStub.video.id });
expect(mocks.media.transcode).toHaveBeenCalledWith(
'/original/path.ext',
@@ -1730,7 +1710,6 @@ describe(MediaService.name, () => {
mocks.systemMetadata.get.mockResolvedValue({
ffmpeg: { accel: TranscodeHWAccel.NVENC, accelDecode: true },
});
mocks.asset.getByIds.mockResolvedValue([assetStub.video]);
await sut.handleVideoConversion({ id: assetStub.video.id });
expect(mocks.media.transcode).toHaveBeenCalledWith(
'/original/path.ext',
@@ -1752,7 +1731,6 @@ describe(MediaService.name, () => {
mocks.systemMetadata.get.mockResolvedValue({
ffmpeg: { accel: TranscodeHWAccel.NVENC, accelDecode: true },
});
mocks.asset.getByIds.mockResolvedValue([assetStub.video]);
await sut.handleVideoConversion({ id: assetStub.video.id });
expect(mocks.media.transcode).toHaveBeenCalledWith(
'/original/path.ext',
@@ -1768,7 +1746,6 @@ describe(MediaService.name, () => {
it('should set options for qsv', async () => {
mocks.media.probe.mockResolvedValue(probeStub.matroskaContainer);
mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.QSV, maxBitrate: '10000k' } });
mocks.asset.getByIds.mockResolvedValue([assetStub.video]);
await sut.handleVideoConversion({ id: assetStub.video.id });
expect(mocks.media.transcode).toHaveBeenCalledWith(
'/original/path.ext',
@@ -1809,7 +1786,6 @@ describe(MediaService.name, () => {
preferredHwDevice: '/dev/dri/renderD128',
},
});
mocks.asset.getByIds.mockResolvedValue([assetStub.video]);
await sut.handleVideoConversion({ id: assetStub.video.id });
expect(mocks.media.transcode).toHaveBeenCalledWith(
'/original/path.ext',
@@ -1828,7 +1804,6 @@ describe(MediaService.name, () => {
it('should omit preset for qsv if invalid', async () => {
mocks.media.probe.mockResolvedValue(probeStub.matroskaContainer);
mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.QSV, preset: 'invalid' } });
mocks.asset.getByIds.mockResolvedValue([assetStub.video]);
await sut.handleVideoConversion({ id: assetStub.video.id });
expect(mocks.media.transcode).toHaveBeenCalledWith(
'/original/path.ext',
@@ -1849,7 +1824,6 @@ describe(MediaService.name, () => {
mocks.systemMetadata.get.mockResolvedValue({
ffmpeg: { accel: TranscodeHWAccel.QSV, targetVideoCodec: VideoCodec.VP9 },
});
mocks.asset.getByIds.mockResolvedValue([assetStub.video]);
await sut.handleVideoConversion({ id: assetStub.video.id });
expect(mocks.media.transcode).toHaveBeenCalledWith(
'/original/path.ext',
@@ -1869,7 +1843,6 @@ describe(MediaService.name, () => {
sut.videoInterfaces = { dri: [], mali: false };
mocks.media.probe.mockResolvedValue(probeStub.matroskaContainer);
mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.QSV } });
mocks.asset.getByIds.mockResolvedValue([assetStub.video]);
await expect(sut.handleVideoConversion({ id: assetStub.video.id })).rejects.toThrowError();
@@ -1880,7 +1853,6 @@ describe(MediaService.name, () => {
sut.videoInterfaces = { dri: ['card1', 'renderD129', 'card0', 'renderD128'], mali: false };
mocks.media.probe.mockResolvedValue(probeStub.matroskaContainer);
mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.QSV } });
mocks.asset.getByIds.mockResolvedValue([assetStub.video]);
await sut.handleVideoConversion({ id: assetStub.video.id });
expect(mocks.media.transcode).toHaveBeenCalledWith(
'/original/path.ext',
@@ -1901,7 +1873,6 @@ describe(MediaService.name, () => {
mocks.systemMetadata.get.mockResolvedValue({
ffmpeg: { accel: TranscodeHWAccel.QSV, accelDecode: true },
});
mocks.asset.getByIds.mockResolvedValue([assetStub.video]);
await sut.handleVideoConversion({ id: assetStub.video.id });
@@ -1928,7 +1899,6 @@ describe(MediaService.name, () => {
mocks.systemMetadata.get.mockResolvedValue({
ffmpeg: { accel: TranscodeHWAccel.QSV, accelDecode: true },
});
mocks.asset.getByIds.mockResolvedValue([assetStub.video]);
await sut.handleVideoConversion({ id: assetStub.video.id });
@@ -1958,7 +1928,6 @@ describe(MediaService.name, () => {
mocks.systemMetadata.get.mockResolvedValue({
ffmpeg: { accel: TranscodeHWAccel.QSV, accelDecode: true, preferredHwDevice: 'renderD129' },
});
mocks.asset.getByIds.mockResolvedValue([assetStub.video]);
await sut.handleVideoConversion({ id: assetStub.video.id });
expect(mocks.media.transcode).toHaveBeenCalledWith(
@@ -1977,7 +1946,6 @@ describe(MediaService.name, () => {
mocks.systemMetadata.get.mockResolvedValue({
ffmpeg: { accel: TranscodeHWAccel.QSV, accelDecode: true },
});
mocks.asset.getByIds.mockResolvedValue([assetStub.video]);
await sut.handleVideoConversion({ id: assetStub.video.id });
@@ -2000,7 +1968,6 @@ describe(MediaService.name, () => {
it('should set options for vaapi', async () => {
mocks.media.probe.mockResolvedValue(probeStub.matroskaContainer);
mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.VAAPI } });
mocks.asset.getByIds.mockResolvedValue([assetStub.video]);
await sut.handleVideoConversion({ id: assetStub.video.id });
expect(mocks.media.transcode).toHaveBeenCalledWith(
'/original/path.ext',
@@ -2031,7 +1998,6 @@ describe(MediaService.name, () => {
it('should set vbr options for vaapi when max bitrate is enabled', async () => {
mocks.media.probe.mockResolvedValue(probeStub.matroskaContainer);
mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.VAAPI, maxBitrate: '10000k' } });
mocks.asset.getByIds.mockResolvedValue([assetStub.video]);
await sut.handleVideoConversion({ id: assetStub.video.id });
expect(mocks.media.transcode).toHaveBeenCalledWith(
'/original/path.ext',
@@ -2056,7 +2022,6 @@ describe(MediaService.name, () => {
it('should set cq options for vaapi when max bitrate is disabled', async () => {
mocks.media.probe.mockResolvedValue(probeStub.matroskaContainer);
mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.VAAPI } });
mocks.asset.getByIds.mockResolvedValue([assetStub.video]);
await sut.handleVideoConversion({ id: assetStub.video.id });
expect(mocks.media.transcode).toHaveBeenCalledWith(
'/original/path.ext',
@@ -2081,7 +2046,6 @@ describe(MediaService.name, () => {
it('should omit preset for vaapi if invalid', async () => {
mocks.media.probe.mockResolvedValue(probeStub.matroskaContainer);
mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.VAAPI, preset: 'invalid' } });
mocks.asset.getByIds.mockResolvedValue([assetStub.video]);
await sut.handleVideoConversion({ id: assetStub.video.id });
expect(mocks.media.transcode).toHaveBeenCalledWith(
'/original/path.ext',
@@ -2101,7 +2065,6 @@ describe(MediaService.name, () => {
sut.videoInterfaces = { dri: ['card1', 'renderD129', 'card0', 'renderD128'], mali: false };
mocks.media.probe.mockResolvedValue(probeStub.matroskaContainer);
mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.VAAPI } });
mocks.asset.getByIds.mockResolvedValue([assetStub.video]);
await sut.handleVideoConversion({ id: assetStub.video.id });
expect(mocks.media.transcode).toHaveBeenCalledWith(
'/original/path.ext',
@@ -2123,7 +2086,6 @@ describe(MediaService.name, () => {
mocks.systemMetadata.get.mockResolvedValue({
ffmpeg: { accel: TranscodeHWAccel.VAAPI, preferredHwDevice: '/dev/dri/renderD128' },
});
mocks.asset.getByIds.mockResolvedValue([assetStub.video]);
await sut.handleVideoConversion({ id: assetStub.video.id });
expect(mocks.media.transcode).toHaveBeenCalledWith(
'/original/path.ext',
@@ -2144,7 +2106,6 @@ describe(MediaService.name, () => {
mocks.systemMetadata.get.mockResolvedValue({
ffmpeg: { accel: TranscodeHWAccel.VAAPI, accelDecode: true },
});
mocks.asset.getByIds.mockResolvedValue([assetStub.video]);
await sut.handleVideoConversion({ id: assetStub.video.id });
@@ -2170,7 +2131,6 @@ describe(MediaService.name, () => {
mocks.systemMetadata.get.mockResolvedValue({
ffmpeg: { accel: TranscodeHWAccel.VAAPI, accelDecode: true },
});
mocks.asset.getByIds.mockResolvedValue([assetStub.video]);
await sut.handleVideoConversion({ id: assetStub.video.id });
@@ -2194,7 +2154,6 @@ describe(MediaService.name, () => {
mocks.systemMetadata.get.mockResolvedValue({
ffmpeg: { accel: TranscodeHWAccel.VAAPI, accelDecode: true },
});
mocks.asset.getByIds.mockResolvedValue([assetStub.video]);
await sut.handleVideoConversion({ id: assetStub.video.id });
@@ -2215,7 +2174,6 @@ describe(MediaService.name, () => {
mocks.systemMetadata.get.mockResolvedValue({
ffmpeg: { accel: TranscodeHWAccel.VAAPI, accelDecode: true, preferredHwDevice: 'renderD129' },
});
mocks.asset.getByIds.mockResolvedValue([assetStub.video]);
await sut.handleVideoConversion({ id: assetStub.video.id });
expect(mocks.media.transcode).toHaveBeenCalledWith(
@@ -2232,7 +2190,6 @@ describe(MediaService.name, () => {
it('should fallback to hw encoding and sw decoding if hw transcoding fails and hw decoding is enabled', async () => {
mocks.media.probe.mockResolvedValue(probeStub.matroskaContainer);
mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.VAAPI, accelDecode: true } });
mocks.asset.getByIds.mockResolvedValue([assetStub.video]);
mocks.media.transcode.mockRejectedValueOnce(new Error('error'));
await sut.handleVideoConversion({ id: assetStub.video.id });
expect(mocks.media.transcode).toHaveBeenCalledTimes(2);
@@ -2253,7 +2210,6 @@ describe(MediaService.name, () => {
it('should fallback to sw decoding if fallback to sw decoding + hw encoding fails', async () => {
mocks.media.probe.mockResolvedValue(probeStub.matroskaContainer);
mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.VAAPI, accelDecode: true } });
mocks.asset.getByIds.mockResolvedValue([assetStub.video]);
mocks.media.transcode.mockRejectedValueOnce(new Error('error'));
mocks.media.transcode.mockRejectedValueOnce(new Error('error'));
await sut.handleVideoConversion({ id: assetStub.video.id });
@@ -2272,7 +2228,6 @@ describe(MediaService.name, () => {
it('should fallback to sw transcoding if hw transcoding fails and hw decoding is disabled', async () => {
mocks.media.probe.mockResolvedValue(probeStub.matroskaContainer);
mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.VAAPI } });
mocks.asset.getByIds.mockResolvedValue([assetStub.video]);
mocks.media.transcode.mockRejectedValueOnce(new Error('error'));
await sut.handleVideoConversion({ id: assetStub.video.id });
expect(mocks.media.transcode).toHaveBeenCalledTimes(2);
@@ -2291,7 +2246,6 @@ describe(MediaService.name, () => {
sut.videoInterfaces = { dri: [], mali: true };
mocks.media.probe.mockResolvedValue(probeStub.matroskaContainer);
mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.VAAPI } });
mocks.asset.getByIds.mockResolvedValue([assetStub.video]);
await expect(sut.handleVideoConversion({ id: assetStub.video.id })).rejects.toThrowError();
expect(mocks.media.transcode).not.toHaveBeenCalled();
});
@@ -2299,7 +2253,6 @@ describe(MediaService.name, () => {
it('should set options for rkmpp', async () => {
mocks.media.probe.mockResolvedValue(probeStub.matroskaContainer);
mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.RKMPP, accelDecode: true } });
mocks.asset.getByIds.mockResolvedValue([assetStub.video]);
await sut.handleVideoConversion({ id: assetStub.video.id });
expect(mocks.media.transcode).toHaveBeenCalledWith(
'/original/path.ext',
@@ -2340,7 +2293,6 @@ describe(MediaService.name, () => {
targetVideoCodec: VideoCodec.HEVC,
},
});
mocks.asset.getByIds.mockResolvedValue([assetStub.video]);
await sut.handleVideoConversion({ id: assetStub.video.id });
expect(mocks.media.transcode).toHaveBeenCalledWith(
'/original/path.ext',
@@ -2358,7 +2310,6 @@ describe(MediaService.name, () => {
mocks.systemMetadata.get.mockResolvedValue({
ffmpeg: { accel: TranscodeHWAccel.RKMPP, accelDecode: true, crf: 30, maxBitrate: '0' },
});
mocks.asset.getByIds.mockResolvedValue([assetStub.video]);
await sut.handleVideoConversion({ id: assetStub.video.id });
expect(mocks.media.transcode).toHaveBeenCalledWith(
'/original/path.ext',
@@ -2376,7 +2327,6 @@ describe(MediaService.name, () => {
mocks.systemMetadata.get.mockResolvedValue({
ffmpeg: { accel: TranscodeHWAccel.RKMPP, accelDecode: true, crf: 30, maxBitrate: '0' },
});
mocks.asset.getByIds.mockResolvedValue([assetStub.video]);
await sut.handleVideoConversion({ id: assetStub.video.id });
expect(mocks.media.transcode).toHaveBeenCalledWith(
'/original/path.ext',
@@ -2399,7 +2349,6 @@ describe(MediaService.name, () => {
mocks.systemMetadata.get.mockResolvedValue({
ffmpeg: { accel: TranscodeHWAccel.RKMPP, accelDecode: true, crf: 30, maxBitrate: '0' },
});
mocks.asset.getByIds.mockResolvedValue([assetStub.video]);
await sut.handleVideoConversion({ id: assetStub.video.id });
expect(mocks.media.transcode).toHaveBeenCalledWith(
'/original/path.ext',
@@ -2419,7 +2368,6 @@ describe(MediaService.name, () => {
mocks.systemMetadata.get.mockResolvedValue({
ffmpeg: { accel: TranscodeHWAccel.RKMPP, accelDecode: false, crf: 30, maxBitrate: '0' },
});
mocks.asset.getByIds.mockResolvedValue([assetStub.video]);
await sut.handleVideoConversion({ id: assetStub.video.id });
expect(mocks.media.transcode).toHaveBeenCalledWith(
'/original/path.ext',
@@ -2442,7 +2390,6 @@ describe(MediaService.name, () => {
mocks.systemMetadata.get.mockResolvedValue({
ffmpeg: { accel: TranscodeHWAccel.RKMPP, accelDecode: true, crf: 30, maxBitrate: '0' },
});
mocks.asset.getByIds.mockResolvedValue([assetStub.video]);
await sut.handleVideoConversion({ id: assetStub.video.id });
expect(mocks.media.transcode).toHaveBeenCalledWith(
'/original/path.ext',
@@ -2462,7 +2409,6 @@ describe(MediaService.name, () => {
it('should tonemap when policy is required and video is hdr', async () => {
mocks.media.probe.mockResolvedValue(probeStub.videoStreamHDR);
mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { transcode: TranscodePolicy.REQUIRED } });
mocks.asset.getByIds.mockResolvedValue([assetStub.video]);
await sut.handleVideoConversion({ id: assetStub.video.id });
expect(mocks.media.transcode).toHaveBeenCalledWith(
'/original/path.ext',
@@ -2482,7 +2428,6 @@ describe(MediaService.name, () => {
it('should tonemap when policy is optimal and video is hdr', async () => {
mocks.media.probe.mockResolvedValue(probeStub.videoStreamHDR);
mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { transcode: TranscodePolicy.OPTIMAL } });
mocks.asset.getByIds.mockResolvedValue([assetStub.video]);
await sut.handleVideoConversion({ id: assetStub.video.id });
expect(mocks.media.transcode).toHaveBeenCalledWith(
'/original/path.ext',
@@ -2502,7 +2447,6 @@ describe(MediaService.name, () => {
it('should transcode when policy is required and video is not yuv420p', async () => {
mocks.media.probe.mockResolvedValue(probeStub.videoStream10Bit);
mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { transcode: TranscodePolicy.REQUIRED } });
mocks.asset.getByIds.mockResolvedValue([assetStub.video]);
await sut.handleVideoConversion({ id: assetStub.video.id });
expect(mocks.media.transcode).toHaveBeenCalledWith(
'/original/path.ext',
@@ -2518,7 +2462,6 @@ describe(MediaService.name, () => {
it('should convert to yuv420p when scaling without tone-mapping', async () => {
mocks.media.probe.mockResolvedValue(probeStub.videoStream4K10Bit);
mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { transcode: TranscodePolicy.REQUIRED } });
mocks.asset.getByIds.mockResolvedValue([assetStub.video]);
await sut.handleVideoConversion({ id: assetStub.video.id });
expect(mocks.media.transcode).toHaveBeenCalledWith(
'/original/path.ext',
@@ -2534,7 +2477,6 @@ describe(MediaService.name, () => {
it('should count frames for progress when log level is debug', async () => {
mocks.media.probe.mockResolvedValue(probeStub.matroskaContainer);
mocks.logger.isLevelEnabled.mockReturnValue(true);
mocks.asset.getByIds.mockResolvedValue([assetStub.video]);
await sut.handleVideoConversion({ id: assetStub.video.id });
@@ -2557,7 +2499,6 @@ describe(MediaService.name, () => {
it('should not count frames for progress when log level is not debug', async () => {
mocks.media.probe.mockResolvedValue(probeStub.videoStream2160p);
mocks.logger.isLevelEnabled.mockReturnValue(false);
mocks.asset.getByIds.mockResolvedValue([assetStub.video]);
await sut.handleVideoConversion({ id: assetStub.video.id });
expect(mocks.media.probe).toHaveBeenCalledWith(assetStub.video.originalPath, { countFrames: false });
@@ -2582,48 +2523,39 @@ describe(MediaService.name, () => {
describe('isSRGB', () => {
it('should return true for srgb colorspace', () => {
const asset = { ...assetStub.image, exifInfo: { colorspace: 'sRGB' } as Exif };
expect(sut.isSRGB(asset)).toEqual(true);
expect(sut.isSRGB({ colorspace: 'sRGB' } as Exif)).toEqual(true);
});
it('should return true for srgb profile description', () => {
const asset = { ...assetStub.image, exifInfo: { profileDescription: 'sRGB v1.31' } as Exif };
expect(sut.isSRGB(asset)).toEqual(true);
expect(sut.isSRGB({ profileDescription: 'sRGB v1.31' } as Exif)).toEqual(true);
});
it('should return true for 8-bit image with no colorspace metadata', () => {
const asset = { ...assetStub.image, exifInfo: { bitsPerSample: 8 } as Exif };
expect(sut.isSRGB(asset)).toEqual(true);
expect(sut.isSRGB({ bitsPerSample: 8 } as Exif)).toEqual(true);
});
it('should return true for image with no colorspace or bit depth metadata', () => {
const asset = { ...assetStub.image, exifInfo: {} as Exif };
expect(sut.isSRGB(asset)).toEqual(true);
expect(sut.isSRGB({} as Exif)).toEqual(true);
});
it('should return false for non-srgb colorspace', () => {
const asset = { ...assetStub.image, exifInfo: { colorspace: 'Adobe RGB' } as Exif };
expect(sut.isSRGB(asset)).toEqual(false);
expect(sut.isSRGB({ colorspace: 'Adobe RGB' } as Exif)).toEqual(false);
});
it('should return false for non-srgb profile description', () => {
const asset = { ...assetStub.image, exifInfo: { profileDescription: 'sP3C' } as Exif };
expect(sut.isSRGB(asset)).toEqual(false);
expect(sut.isSRGB({ profileDescription: 'sP3C' } as Exif)).toEqual(false);
});
it('should return false for 16-bit image with no colorspace metadata', () => {
const asset = { ...assetStub.image, exifInfo: { bitsPerSample: 16 } as Exif };
expect(sut.isSRGB(asset)).toEqual(false);
expect(sut.isSRGB({ bitsPerSample: 16 } as Exif)).toEqual(false);
});
it('should return true for 16-bit image with sRGB colorspace', () => {
const asset = { ...assetStub.image, exifInfo: { colorspace: 'sRGB', bitsPerSample: 16 } as Exif };
expect(sut.isSRGB(asset)).toEqual(true);
expect(sut.isSRGB({ colorspace: 'sRGB', bitsPerSample: 16 } as Exif)).toEqual(true);
});
it('should return true for 16-bit image with sRGB profile', () => {
const asset = { ...assetStub.image, exifInfo: { profileDescription: 'sRGB', bitsPerSample: 16 } as Exif };
expect(sut.isSRGB(asset)).toEqual(true);
expect(sut.isSRGB({ profileDescription: 'sRGB', bitsPerSample: 16 } as Exif)).toEqual(true);
});
});
});

View File

@@ -10,11 +10,11 @@ import {
AssetType,
AudioCodec,
Colorspace,
ImageFormat,
JobName,
JobStatus,
LogLevel,
QueueName,
RawExtractedFormat,
StorageFolder,
TranscodeHWAccel,
TranscodePolicy,
@@ -22,12 +22,11 @@ import {
VideoCodec,
VideoContainer,
} from 'src/enum';
import { UpsertFileOptions, WithoutProperty } from 'src/repositories/asset.repository';
import { UpsertFileOptions } from 'src/repositories/asset.repository';
import { BaseService } from 'src/services/base.service';
import {
AudioStreamInfo,
DecodeToBufferOptions,
GenerateThumbnailOptions,
JobItem,
JobOf,
VideoFormat,
@@ -213,6 +212,29 @@ export class MediaService extends BaseService {
return JobStatus.SUCCESS;
}
private async extractImage(originalPath: string, minSize: number) {
let extracted = await this.mediaRepository.extract(originalPath);
if (extracted && !(await this.shouldUseExtractedImage(extracted.buffer, minSize))) {
extracted = null;
}
return extracted;
}
private async decodeImage(thumbSource: string | Buffer, exifInfo: Exif, targetSize?: number) {
const { image } = await this.getConfig({ withCache: true });
const colorspace = this.isSRGB(exifInfo) ? Colorspace.SRGB : image.colorspace;
const decodeOptions: DecodeToBufferOptions = {
colorspace,
processInvalidImages: process.env.IMMICH_PROCESS_INVALID_IMAGES === 'true',
size: targetSize,
orientation: exifInfo.orientation ? Number(exifInfo.orientation) : undefined,
};
const { info, data } = await this.mediaRepository.decodeImage(thumbSource, decodeOptions);
return { info, data, colorspace };
}
private async generateImageThumbnails(asset: {
id: string;
ownerId: string;
@@ -225,68 +247,48 @@ export class MediaService extends BaseService {
const thumbnailPath = StorageCore.getImagePath(asset, AssetPathType.THUMBNAIL, image.thumbnail.format);
this.storageCore.ensureFolders(previewPath);
const processInvalidImages = process.env.IMMICH_PROCESS_INVALID_IMAGES === 'true';
const colorspace = this.isSRGB(asset) ? Colorspace.SRGB : image.colorspace;
// Handle embedded preview extraction for RAW files
const extractEmbedded = image.extractEmbedded && mimeTypes.isRaw(asset.originalFileName);
const extracted = extractEmbedded ? await this.extractImage(asset.originalPath, image.preview.size) : null;
const generateFullsize = image.fullsize.enabled && !mimeTypes.isWebSupportedImage(asset.originalPath);
const convertFullsize = generateFullsize && (!extracted || !mimeTypes.isWebSupportedImage(` .${extracted.format}`));
// prevents this extra "enabled" from leaking into fullsizeOptions later
const { enabled: imageFullsizeEnabled, ...imageFullsizeConfig } = image.fullsize;
const { info, data, colorspace } = await this.decodeImage(
extracted ? extracted.buffer : asset.originalPath,
asset.exifInfo,
convertFullsize ? undefined : image.preview.size,
);
const shouldConvertFullsize = imageFullsizeEnabled && !mimeTypes.isWebSupportedImage(asset.originalFileName);
const shouldExtractEmbedded = image.extractEmbedded && mimeTypes.isRaw(asset.originalFileName);
const decodeOptions: DecodeToBufferOptions = { colorspace, processInvalidImages, size: image.preview.size };
let useExtracted = false;
let decodeInputPath: string = asset.originalPath;
// Converted or extracted image from non-web-supported formats (e.g. RAW)
let fullsizePath: string | undefined;
if (shouldConvertFullsize) {
// unset size to decode fullsize image
decodeOptions.size = undefined;
fullsizePath = StorageCore.getImagePath(asset, AssetPathType.FULLSIZE, image.fullsize.format);
}
if (shouldExtractEmbedded) {
// For RAW files, try extracting embedded preview first
// Assume extracted image from RAW always in JPEG format, as implied from the `jpgFromRaw` tag name
const extractedPath = StorageCore.getImagePath(asset, AssetPathType.FULLSIZE, ImageFormat.JPEG);
const didExtract = await this.mediaRepository.extract(asset.originalPath, extractedPath);
useExtracted = didExtract && (await this.shouldUseExtractedImage(extractedPath, image.preview.size));
if (useExtracted) {
if (shouldConvertFullsize) {
// skip re-encoding and directly use extracted as fullsize preview
// as usually the extracted image is already heavily compressed, no point doing lossy conversion again
fullsizePath = extractedPath;
}
// use this as origin of preview and thumbnail
decodeInputPath = extractedPath;
if (asset.exifInfo) {
// write essential orientation and colorspace EXIF for correct fullsize preview and subsequent processing
const exif = { orientation: asset.exifInfo.orientation, colorspace: asset.exifInfo.colorspace };
await this.mediaRepository.writeExif(exif, extractedPath);
}
}
}
const { info, data } = await this.mediaRepository.decodeImage(decodeInputPath, decodeOptions);
const thumbnailOptions = { colorspace, processInvalidImages, raw: info };
// generate final images
const thumbnailOptions = { colorspace, processInvalidImages: false, raw: info };
const promises = [
this.mediaRepository.generateThumbhash(data, thumbnailOptions),
this.mediaRepository.generateThumbnail(data, { ...image.thumbnail, ...thumbnailOptions }, thumbnailPath),
this.mediaRepository.generateThumbnail(data, { ...image.preview, ...thumbnailOptions }, previewPath),
];
// did not extract a usable image from RAW
if (fullsizePath && !useExtracted) {
const fullsizeOptions: GenerateThumbnailOptions = {
...imageFullsizeConfig,
...thumbnailOptions,
size: undefined,
};
let fullsizePath: string | undefined;
if (convertFullsize) {
// convert a new fullsize image from the same source as the thumbnail
fullsizePath = StorageCore.getImagePath(asset, AssetPathType.FULLSIZE, image.fullsize.format);
const fullsizeOptions = { format: image.fullsize.format, quality: image.fullsize.quality, ...thumbnailOptions };
promises.push(this.mediaRepository.generateThumbnail(data, fullsizeOptions, fullsizePath));
} else if (generateFullsize && extracted && extracted.format === RawExtractedFormat.JPEG) {
fullsizePath = StorageCore.getImagePath(asset, AssetPathType.FULLSIZE, extracted.format);
this.storageCore.ensureFolders(fullsizePath);
// Write the buffer to disk with essential EXIF data
await this.storageRepository.createOrOverwriteFile(fullsizePath, extracted.buffer);
await this.mediaRepository.writeExif(
{
orientation: asset.exifInfo.orientation,
colorspace: asset.exifInfo.colorspace,
},
fullsizePath,
);
}
const outputs = await Promise.all(promises);
return { previewPath, thumbnailPath, fullsizePath, thumbhash: outputs[0] as Buffer };
@@ -330,25 +332,25 @@ export class MediaService extends BaseService {
async handleQueueVideoConversion(job: JobOf<JobName.QUEUE_VIDEO_CONVERSION>): Promise<JobStatus> {
const { force } = job;
const assetPagination = usePagination(JOBS_ASSET_PAGINATION_SIZE, (pagination) => {
return force
? this.assetRepository.getAll(pagination, { type: AssetType.VIDEO })
: this.assetRepository.getWithout(pagination, WithoutProperty.ENCODED_VIDEO);
});
let queue: { name: JobName.VIDEO_CONVERSION; data: { id: string } }[] = [];
for await (const asset of this.assetJobRepository.streamForVideoConversion(force)) {
queue.push({ name: JobName.VIDEO_CONVERSION, data: { id: asset.id } });
for await (const assets of assetPagination) {
await this.jobRepository.queueAll(
assets.map((asset) => ({ name: JobName.VIDEO_CONVERSION, data: { id: asset.id } })),
);
if (queue.length >= JOBS_ASSET_PAGINATION_SIZE) {
await this.jobRepository.queueAll(queue);
queue = [];
}
}
await this.jobRepository.queueAll(queue);
return JobStatus.SUCCESS;
}
@OnJob({ name: JobName.VIDEO_CONVERSION, queue: QueueName.VIDEO_CONVERSION })
async handleVideoConversion({ id }: JobOf<JobName.VIDEO_CONVERSION>): Promise<JobStatus> {
const [asset] = await this.assetRepository.getByIds([id]);
if (!asset || asset.type !== AssetType.VIDEO) {
const asset = await this.assetJobRepository.getForVideoConversion(id);
if (!asset) {
return JobStatus.FAILED;
}
@@ -521,8 +523,7 @@ export class MediaService extends BaseService {
return name !== VideoContainer.MP4 && !ffmpegConfig.acceptedContainers.includes(name);
}
isSRGB(asset: { exifInfo: Exif }): boolean {
const { colorspace, profileDescription, bitsPerSample } = asset.exifInfo;
isSRGB({ colorspace, profileDescription, bitsPerSample }: Exif): boolean {
if (colorspace || profileDescription) {
return [colorspace, profileDescription].some((s) => s?.toLowerCase().includes('srgb'));
} else if (bitsPerSample) {
@@ -550,10 +551,9 @@ export class MediaService extends BaseService {
}
}
private async shouldUseExtractedImage(extractedPath: string, targetSize: number) {
const { width, height } = await this.mediaRepository.getImageDimensions(extractedPath);
private async shouldUseExtractedImage(extractedPathOrBuffer: string | Buffer, targetSize: number) {
const { width, height } = await this.mediaRepository.getImageDimensions(extractedPathOrBuffer);
const extractedSize = Math.min(width, height);
return extractedSize >= targetSize;
}

View File

@@ -14,7 +14,7 @@ import { probeStub } from 'test/fixtures/media.stub';
import { personStub } from 'test/fixtures/person.stub';
import { tagStub } from 'test/fixtures/tag.stub';
import { factory } from 'test/small.factory';
import { newTestService, ServiceMocks } from 'test/utils';
import { makeStream, newTestService, ServiceMocks } from 'test/utils';
const makeFaceTags = (face: Partial<{ Name: string }> = {}) => ({
RegionInfo: {
@@ -104,10 +104,10 @@ describe(MetadataService.name, () => {
describe('handleQueueMetadataExtraction', () => {
it('should queue metadata extraction for all assets without exif values', async () => {
mocks.asset.getWithout.mockResolvedValue({ items: [assetStub.image], hasNextPage: false });
mocks.assetJob.streamForMetadataExtraction.mockReturnValue(makeStream([assetStub.image]));
await expect(sut.handleQueueMetadataExtraction({ force: false })).resolves.toBe(JobStatus.SUCCESS);
expect(mocks.asset.getWithout).toHaveBeenCalled();
expect(mocks.assetJob.streamForMetadataExtraction).toHaveBeenCalledWith(false);
expect(mocks.job.queueAll).toHaveBeenCalledWith([
{
name: JobName.METADATA_EXTRACTION,
@@ -117,10 +117,10 @@ describe(MetadataService.name, () => {
});
it('should queue metadata extraction for all assets', async () => {
mocks.asset.getAll.mockResolvedValue({ items: [assetStub.image], hasNextPage: false });
mocks.assetJob.streamForMetadataExtraction.mockReturnValue(makeStream([assetStub.image]));
await expect(sut.handleQueueMetadataExtraction({ force: true })).resolves.toBe(JobStatus.SUCCESS);
expect(mocks.asset.getAll).toHaveBeenCalled();
expect(mocks.assetJob.streamForMetadataExtraction).toHaveBeenCalledWith(true);
expect(mocks.job.queueAll).toHaveBeenCalledWith([
{
name: JobName.METADATA_EXTRACTION,

View File

@@ -168,18 +168,18 @@ export class MetadataService extends BaseService {
@OnJob({ name: JobName.QUEUE_METADATA_EXTRACTION, queue: QueueName.METADATA_EXTRACTION })
async handleQueueMetadataExtraction(job: JobOf<JobName.QUEUE_METADATA_EXTRACTION>): Promise<JobStatus> {
const { force } = job;
const assetPagination = usePagination(JOBS_ASSET_PAGINATION_SIZE, (pagination) => {
return force
? this.assetRepository.getAll(pagination)
: this.assetRepository.getWithout(pagination, WithoutProperty.EXIF);
});
for await (const assets of assetPagination) {
await this.jobRepository.queueAll(
assets.map((asset) => ({ name: JobName.METADATA_EXTRACTION, data: { id: asset.id } })),
);
let queue: { name: JobName.METADATA_EXTRACTION; data: { id: string } }[] = [];
for await (const asset of this.assetJobRepository.streamForMetadataExtraction(force)) {
queue.push({ name: JobName.METADATA_EXTRACTION, data: { id: asset.id } });
if (queue.length >= JOBS_ASSET_PAGINATION_SIZE) {
await this.jobRepository.queueAll(queue);
queue = [];
}
}
await this.jobRepository.queueAll(queue);
return JobStatus.SUCCESS;
}

View File

@@ -0,0 +1,111 @@
import { defaults, SystemConfig } from 'src/config';
import { EmailTemplate } from 'src/repositories/email.repository';
import { NotificationService } from 'src/services/notification.service';
import { userStub } from 'test/fixtures/user.stub';
import { newTestService, ServiceMocks } from 'test/utils';
const smtpTransport = Object.freeze<SystemConfig>({
...defaults,
notifications: {
smtp: {
...defaults.notifications.smtp,
enabled: true,
transport: {
ignoreCert: false,
host: 'localhost',
port: 587,
username: 'test',
password: 'test',
},
},
},
});
describe(NotificationService.name, () => {
let sut: NotificationService;
let mocks: ServiceMocks;
beforeEach(() => {
({ sut, mocks } = newTestService(NotificationService));
});
it('should work', () => {
expect(sut).toBeDefined();
});
describe('sendTestEmail', () => {
it('should throw error if user could not be found', async () => {
await expect(sut.sendTestEmail('', smtpTransport.notifications.smtp)).rejects.toThrow('User not found');
});
it('should throw error if smtp validation fails', async () => {
mocks.user.get.mockResolvedValue(userStub.admin);
mocks.email.verifySmtp.mockRejectedValue('');
await expect(sut.sendTestEmail('', smtpTransport.notifications.smtp)).rejects.toThrow(
'Failed to verify SMTP configuration',
);
});
it('should send email to default domain', async () => {
mocks.user.get.mockResolvedValue(userStub.admin);
mocks.email.verifySmtp.mockResolvedValue(true);
mocks.email.renderEmail.mockResolvedValue({ html: '', text: '' });
mocks.email.sendEmail.mockResolvedValue({ messageId: 'message-1', response: '' });
await expect(sut.sendTestEmail('', smtpTransport.notifications.smtp)).resolves.not.toThrow();
expect(mocks.email.renderEmail).toHaveBeenCalledWith({
template: EmailTemplate.TEST_EMAIL,
data: { baseUrl: 'https://my.immich.app', displayName: userStub.admin.name },
});
expect(mocks.email.sendEmail).toHaveBeenCalledWith(
expect.objectContaining({
subject: 'Test email from Immich',
smtp: smtpTransport.notifications.smtp.transport,
}),
);
});
it('should send email to external domain', async () => {
mocks.user.get.mockResolvedValue(userStub.admin);
mocks.email.verifySmtp.mockResolvedValue(true);
mocks.email.renderEmail.mockResolvedValue({ html: '', text: '' });
mocks.systemMetadata.get.mockResolvedValue({ server: { externalDomain: 'https://demo.immich.app' } });
mocks.email.sendEmail.mockResolvedValue({ messageId: 'message-1', response: '' });
await expect(sut.sendTestEmail('', smtpTransport.notifications.smtp)).resolves.not.toThrow();
expect(mocks.email.renderEmail).toHaveBeenCalledWith({
template: EmailTemplate.TEST_EMAIL,
data: { baseUrl: 'https://demo.immich.app', displayName: userStub.admin.name },
});
expect(mocks.email.sendEmail).toHaveBeenCalledWith(
expect.objectContaining({
subject: 'Test email from Immich',
smtp: smtpTransport.notifications.smtp.transport,
}),
);
});
it('should send email with replyTo', async () => {
mocks.user.get.mockResolvedValue(userStub.admin);
mocks.email.verifySmtp.mockResolvedValue(true);
mocks.email.renderEmail.mockResolvedValue({ html: '', text: '' });
mocks.email.sendEmail.mockResolvedValue({ messageId: 'message-1', response: '' });
await expect(
sut.sendTestEmail('', { ...smtpTransport.notifications.smtp, replyTo: 'demo@immich.app' }),
).resolves.not.toThrow();
expect(mocks.email.renderEmail).toHaveBeenCalledWith({
template: EmailTemplate.TEST_EMAIL,
data: { baseUrl: 'https://my.immich.app', displayName: userStub.admin.name },
});
expect(mocks.email.sendEmail).toHaveBeenCalledWith(
expect.objectContaining({
subject: 'Test email from Immich',
smtp: smtpTransport.notifications.smtp.transport,
replyTo: 'demo@immich.app',
}),
);
});
});
});

View File

@@ -0,0 +1,120 @@
import { BadRequestException, Injectable } from '@nestjs/common';
import { AuthDto } from 'src/dtos/auth.dto';
import { mapNotification, NotificationCreateDto } from 'src/dtos/notification.dto';
import { SystemConfigSmtpDto } from 'src/dtos/system-config.dto';
import { NotificationLevel, NotificationType } from 'src/enum';
import { EmailTemplate } from 'src/repositories/email.repository';
import { BaseService } from 'src/services/base.service';
import { getExternalDomain } from 'src/utils/misc';
@Injectable()
export class NotificationAdminService extends BaseService {
async create(auth: AuthDto, dto: NotificationCreateDto) {
const item = await this.notificationRepository.create({
userId: dto.userId,
type: dto.type ?? NotificationType.Custom,
level: dto.level ?? NotificationLevel.Info,
title: dto.title,
description: dto.description,
data: dto.data,
});
return mapNotification(item);
}
async sendTestEmail(id: string, dto: SystemConfigSmtpDto, tempTemplate?: string) {
const user = await this.userRepository.get(id, { withDeleted: false });
if (!user) {
throw new Error('User not found');
}
try {
await this.emailRepository.verifySmtp(dto.transport);
} catch (error) {
throw new BadRequestException('Failed to verify SMTP configuration', { cause: error });
}
const { server } = await this.getConfig({ withCache: false });
const { html, text } = await this.emailRepository.renderEmail({
template: EmailTemplate.TEST_EMAIL,
data: {
baseUrl: getExternalDomain(server),
displayName: user.name,
},
customTemplate: tempTemplate!,
});
const { messageId } = await this.emailRepository.sendEmail({
to: user.email,
subject: 'Test email from Immich',
html,
text,
from: dto.from,
replyTo: dto.replyTo || dto.from,
smtp: dto.transport,
});
return { messageId };
}
async getTemplate(name: EmailTemplate, customTemplate: string) {
const { server, templates } = await this.getConfig({ withCache: false });
let templateResponse = '';
switch (name) {
case EmailTemplate.WELCOME: {
const { html: _welcomeHtml } = await this.emailRepository.renderEmail({
template: EmailTemplate.WELCOME,
data: {
baseUrl: getExternalDomain(server),
displayName: 'John Doe',
username: 'john@doe.com',
password: 'thisIsAPassword123',
},
customTemplate: customTemplate || templates.email.welcomeTemplate,
});
templateResponse = _welcomeHtml;
break;
}
case EmailTemplate.ALBUM_UPDATE: {
const { html: _updateAlbumHtml } = await this.emailRepository.renderEmail({
template: EmailTemplate.ALBUM_UPDATE,
data: {
baseUrl: getExternalDomain(server),
albumId: '1',
albumName: 'Favorite Photos',
recipientName: 'Jane Doe',
cid: undefined,
},
customTemplate: customTemplate || templates.email.albumInviteTemplate,
});
templateResponse = _updateAlbumHtml;
break;
}
case EmailTemplate.ALBUM_INVITE: {
const { html } = await this.emailRepository.renderEmail({
template: EmailTemplate.ALBUM_INVITE,
data: {
baseUrl: getExternalDomain(server),
albumId: '1',
albumName: "John Doe's Favorites",
senderName: 'John Doe',
recipientName: 'Jane Doe',
cid: undefined,
},
customTemplate: customTemplate || templates.email.albumInviteTemplate,
});
templateResponse = html;
break;
}
default: {
templateResponse = '';
break;
}
}
return { name, html: templateResponse };
}
}

View File

@@ -3,7 +3,6 @@ import { defaults, SystemConfig } from 'src/config';
import { AlbumUser } from 'src/database';
import { SystemConfigDto } from 'src/dtos/system-config.dto';
import { AssetFileType, JobName, JobStatus, UserMetadataKey } from 'src/enum';
import { EmailTemplate } from 'src/repositories/email.repository';
import { NotificationService } from 'src/services/notification.service';
import { INotifyAlbumUpdateJob } from 'src/types';
import { albumStub } from 'test/fixtures/album.stub';
@@ -241,82 +240,6 @@ describe(NotificationService.name, () => {
});
});
describe('sendTestEmail', () => {
it('should throw error if user could not be found', async () => {
await expect(sut.sendTestEmail('', configs.smtpTransport.notifications.smtp)).rejects.toThrow('User not found');
});
it('should throw error if smtp validation fails', async () => {
mocks.user.get.mockResolvedValue(userStub.admin);
mocks.email.verifySmtp.mockRejectedValue('');
await expect(sut.sendTestEmail('', configs.smtpTransport.notifications.smtp)).rejects.toThrow(
'Failed to verify SMTP configuration',
);
});
it('should send email to default domain', async () => {
mocks.user.get.mockResolvedValue(userStub.admin);
mocks.email.verifySmtp.mockResolvedValue(true);
mocks.email.renderEmail.mockResolvedValue({ html: '', text: '' });
mocks.email.sendEmail.mockResolvedValue({ messageId: 'message-1', response: '' });
await expect(sut.sendTestEmail('', configs.smtpTransport.notifications.smtp)).resolves.not.toThrow();
expect(mocks.email.renderEmail).toHaveBeenCalledWith({
template: EmailTemplate.TEST_EMAIL,
data: { baseUrl: 'https://my.immich.app', displayName: userStub.admin.name },
});
expect(mocks.email.sendEmail).toHaveBeenCalledWith(
expect.objectContaining({
subject: 'Test email from Immich',
smtp: configs.smtpTransport.notifications.smtp.transport,
}),
);
});
it('should send email to external domain', async () => {
mocks.user.get.mockResolvedValue(userStub.admin);
mocks.email.verifySmtp.mockResolvedValue(true);
mocks.email.renderEmail.mockResolvedValue({ html: '', text: '' });
mocks.systemMetadata.get.mockResolvedValue({ server: { externalDomain: 'https://demo.immich.app' } });
mocks.email.sendEmail.mockResolvedValue({ messageId: 'message-1', response: '' });
await expect(sut.sendTestEmail('', configs.smtpTransport.notifications.smtp)).resolves.not.toThrow();
expect(mocks.email.renderEmail).toHaveBeenCalledWith({
template: EmailTemplate.TEST_EMAIL,
data: { baseUrl: 'https://demo.immich.app', displayName: userStub.admin.name },
});
expect(mocks.email.sendEmail).toHaveBeenCalledWith(
expect.objectContaining({
subject: 'Test email from Immich',
smtp: configs.smtpTransport.notifications.smtp.transport,
}),
);
});
it('should send email with replyTo', async () => {
mocks.user.get.mockResolvedValue(userStub.admin);
mocks.email.verifySmtp.mockResolvedValue(true);
mocks.email.renderEmail.mockResolvedValue({ html: '', text: '' });
mocks.email.sendEmail.mockResolvedValue({ messageId: 'message-1', response: '' });
await expect(
sut.sendTestEmail('', { ...configs.smtpTransport.notifications.smtp, replyTo: 'demo@immich.app' }),
).resolves.not.toThrow();
expect(mocks.email.renderEmail).toHaveBeenCalledWith({
template: EmailTemplate.TEST_EMAIL,
data: { baseUrl: 'https://my.immich.app', displayName: userStub.admin.name },
});
expect(mocks.email.sendEmail).toHaveBeenCalledWith(
expect.objectContaining({
subject: 'Test email from Immich',
smtp: configs.smtpTransport.notifications.smtp.transport,
replyTo: 'demo@immich.app',
}),
);
});
});
describe('handleUserSignup', () => {
it('should skip if user could not be found', async () => {
await expect(sut.handleUserSignup({ id: '' })).resolves.toBe(JobStatus.SKIPPED);

View File

@@ -1,7 +1,24 @@
import { BadRequestException, Injectable } from '@nestjs/common';
import { OnEvent, OnJob } from 'src/decorators';
import { AuthDto } from 'src/dtos/auth.dto';
import {
mapNotification,
NotificationDeleteAllDto,
NotificationDto,
NotificationSearchDto,
NotificationUpdateAllDto,
NotificationUpdateDto,
} from 'src/dtos/notification.dto';
import { SystemConfigSmtpDto } from 'src/dtos/system-config.dto';
import { AssetFileType, JobName, JobStatus, QueueName } from 'src/enum';
import {
AssetFileType,
JobName,
JobStatus,
NotificationLevel,
NotificationType,
Permission,
QueueName,
} from 'src/enum';
import { EmailTemplate } from 'src/repositories/email.repository';
import { ArgOf } from 'src/repositories/event.repository';
import { BaseService } from 'src/services/base.service';
@@ -15,6 +32,80 @@ import { getPreferences } from 'src/utils/preferences';
export class NotificationService extends BaseService {
private static albumUpdateEmailDelayMs = 300_000;
async search(auth: AuthDto, dto: NotificationSearchDto): Promise<NotificationDto[]> {
const items = await this.notificationRepository.search(auth.user.id, dto);
return items.map((item) => mapNotification(item));
}
async updateAll(auth: AuthDto, dto: NotificationUpdateAllDto) {
await this.requireAccess({ auth, ids: dto.ids, permission: Permission.NOTIFICATION_UPDATE });
await this.notificationRepository.updateAll(dto.ids, {
readAt: dto.readAt,
});
}
async deleteAll(auth: AuthDto, dto: NotificationDeleteAllDto) {
await this.requireAccess({ auth, ids: dto.ids, permission: Permission.NOTIFICATION_DELETE });
await this.notificationRepository.deleteAll(dto.ids);
}
async get(auth: AuthDto, id: string) {
await this.requireAccess({ auth, ids: [id], permission: Permission.NOTIFICATION_READ });
const item = await this.notificationRepository.get(id);
if (!item) {
throw new BadRequestException('Notification not found');
}
return mapNotification(item);
}
async update(auth: AuthDto, id: string, dto: NotificationUpdateDto) {
await this.requireAccess({ auth, ids: [id], permission: Permission.NOTIFICATION_UPDATE });
const item = await this.notificationRepository.update(id, {
readAt: dto.readAt,
});
return mapNotification(item);
}
async delete(auth: AuthDto, id: string) {
await this.requireAccess({ auth, ids: [id], permission: Permission.NOTIFICATION_DELETE });
await this.notificationRepository.delete(id);
}
@OnJob({ name: JobName.NOTIFICATIONS_CLEANUP, queue: QueueName.BACKGROUND_TASK })
async onNotificationsCleanup() {
await this.notificationRepository.cleanup();
}
@OnEvent({ name: 'job.failed' })
async onJobFailed({ job, error }: ArgOf<'job.failed'>) {
const admin = await this.userRepository.getAdmin();
if (!admin) {
return;
}
this.logger.error(`Unable to run job handler (${job.name}): ${error}`, error?.stack, JSON.stringify(job.data));
switch (job.name) {
case JobName.BACKUP_DATABASE: {
const errorMessage = error instanceof Error ? error.message : error;
const item = await this.notificationRepository.create({
userId: admin.id,
type: NotificationType.JobFailed,
level: NotificationLevel.Error,
title: 'Job Failed',
description: `Job ${[job.name]} failed with error: ${errorMessage}`,
});
this.eventRepository.clientSend('on_notification', admin.id, mapNotification(item));
break;
}
default: {
return;
}
}
}
@OnEvent({ name: 'config.update' })
onConfigUpdate({ oldConfig, newConfig }: ArgOf<'config.update'>) {
this.eventRepository.clientBroadcast('on_config_update');
@@ -271,7 +362,7 @@ export class NotificationService extends BaseService {
return JobStatus.SKIPPED;
}
const { emailNotifications } = getPreferences(recipient.email, recipient.metadata);
const { emailNotifications } = getPreferences(recipient.metadata);
if (!emailNotifications.enabled || !emailNotifications.albumInvite) {
return JobStatus.SKIPPED;
@@ -333,7 +424,7 @@ export class NotificationService extends BaseService {
continue;
}
const { emailNotifications } = getPreferences(user.email, user.metadata);
const { emailNotifications } = getPreferences(user.metadata);
if (!emailNotifications.enabled || !emailNotifications.albumUpdate) {
continue;

View File

@@ -106,21 +106,19 @@ export class UserAdminService extends BaseService {
}
async getPreferences(auth: AuthDto, id: string): Promise<UserPreferencesResponseDto> {
const { email } = await this.findOrFail(id, { withDeleted: true });
await this.findOrFail(id, { withDeleted: true });
const metadata = await this.userRepository.getMetadata(id);
const preferences = getPreferences(email, metadata);
return mapPreferences(preferences);
return mapPreferences(getPreferences(metadata));
}
async updatePreferences(auth: AuthDto, id: string, dto: UserPreferencesUpdateDto) {
const { email } = await this.findOrFail(id, { withDeleted: false });
await this.findOrFail(id, { withDeleted: false });
const metadata = await this.userRepository.getMetadata(id);
const preferences = getPreferences(email, metadata);
const newPreferences = mergePreferences(preferences, dto);
const newPreferences = mergePreferences(getPreferences(metadata), dto);
await this.userRepository.upsertMetadata(id, {
key: UserMetadataKey.PREFERENCES,
value: getPreferencesPartial({ email }, newPreferences),
value: getPreferencesPartial(newPreferences),
});
return mapPreferences(newPreferences);

View File

@@ -53,6 +53,7 @@ export class UserService extends BaseService {
const update: Updateable<UserTable> = {
email: dto.email,
name: dto.name,
avatarColor: dto.avatarColor,
};
if (dto.password) {
@@ -68,18 +69,16 @@ export class UserService extends BaseService {
async getMyPreferences(auth: AuthDto): Promise<UserPreferencesResponseDto> {
const metadata = await this.userRepository.getMetadata(auth.user.id);
const preferences = getPreferences(auth.user.email, metadata);
return mapPreferences(preferences);
return mapPreferences(getPreferences(metadata));
}
async updateMyPreferences(auth: AuthDto, dto: UserPreferencesUpdateDto) {
const metadata = await this.userRepository.getMetadata(auth.user.id);
const current = getPreferences(auth.user.email, metadata);
const updated = mergePreferences(current, dto);
const updated = mergePreferences(getPreferences(metadata), dto);
await this.userRepository.upsertMetadata(auth.user.id, {
key: UserMetadataKey.PREFERENCES,
value: getPreferencesPartial(auth.user, updated),
value: getPreferencesPartial(updated),
});
return mapPreferences(updated);