1
0
Fork 0
mirror of https://github.com/immich-app/immich.git synced 2025-01-01 08:31:59 +00:00

refactor(server): system config (#9517)

This commit is contained in:
Jason Rasmussen 2024-05-15 18:58:23 -04:00 committed by GitHub
parent 7f0f016f2e
commit 984aa8fb41
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
46 changed files with 599 additions and 770 deletions

View file

@ -96,7 +96,7 @@ SELECT * FROM "users";
## System Config ## System Config
```sql title="Custom settings" ```sql title="Custom settings"
SELECT "key", "value" FROM "system_config"; SELECT "key", "value" FROM "system_metadata" WHERE "key" = 'system-config';
``` ```
(Only used when not using the [config file](/docs/install/config-file)) (Only used when not using the [config file](/docs/install/config-file))

View file

@ -145,7 +145,6 @@ export const utils = {
'sessions', 'sessions',
'users', 'users',
'system_metadata', 'system_metadata',
'system_config',
]; ];
const sql: string[] = []; const sql: string[] = [];

View file

@ -12,7 +12,7 @@ import { ILoggerRepository } from 'src/interfaces/logger.interface';
import { IMoveRepository } from 'src/interfaces/move.interface'; import { IMoveRepository } from 'src/interfaces/move.interface';
import { IPersonRepository } from 'src/interfaces/person.interface'; import { IPersonRepository } from 'src/interfaces/person.interface';
import { IStorageRepository } from 'src/interfaces/storage.interface'; import { IStorageRepository } from 'src/interfaces/storage.interface';
import { ISystemConfigRepository } from 'src/interfaces/system-config.interface'; import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface';
export enum StorageFolder { export enum StorageFolder {
ENCODED_VIDEO = 'encoded-video', ENCODED_VIDEO = 'encoded-video',
@ -49,10 +49,10 @@ export class StorageCore {
private moveRepository: IMoveRepository, private moveRepository: IMoveRepository,
private personRepository: IPersonRepository, private personRepository: IPersonRepository,
private storageRepository: IStorageRepository, private storageRepository: IStorageRepository,
systemConfigRepository: ISystemConfigRepository, systemMetadataRepository: ISystemMetadataRepository,
private logger: ILoggerRepository, private logger: ILoggerRepository,
) { ) {
this.configCore = SystemConfigCore.create(systemConfigRepository, this.logger); this.configCore = SystemConfigCore.create(systemMetadataRepository, this.logger);
} }
static create( static create(
@ -61,7 +61,7 @@ export class StorageCore {
moveRepository: IMoveRepository, moveRepository: IMoveRepository,
personRepository: IPersonRepository, personRepository: IPersonRepository,
storageRepository: IStorageRepository, storageRepository: IStorageRepository,
systemConfigRepository: ISystemConfigRepository, systemMetadataRepository: ISystemMetadataRepository,
logger: ILoggerRepository, logger: ILoggerRepository,
) { ) {
if (!instance) { if (!instance) {
@ -71,7 +71,7 @@ export class StorageCore {
moveRepository, moveRepository,
personRepository, personRepository,
storageRepository, storageRepository,
systemConfigRepository, systemMetadataRepository,
logger, logger,
); );
} }

View file

@ -7,10 +7,12 @@ import * as _ from 'lodash';
import { Subject } from 'rxjs'; import { Subject } from 'rxjs';
import { SystemConfig, defaults } from 'src/config'; import { SystemConfig, defaults } from 'src/config';
import { SystemConfigDto } from 'src/dtos/system-config.dto'; import { SystemConfigDto } from 'src/dtos/system-config.dto';
import { SystemConfigEntity, SystemConfigKey, SystemConfigValue } from 'src/entities/system-config.entity'; import { SystemMetadataKey } from 'src/entities/system-metadata.entity';
import { DatabaseLock } from 'src/interfaces/database.interface'; import { DatabaseLock } from 'src/interfaces/database.interface';
import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { ILoggerRepository } from 'src/interfaces/logger.interface';
import { ISystemConfigRepository } from 'src/interfaces/system-config.interface'; import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface';
import { getKeysDeep, unsetDeep } from 'src/utils/misc';
import { DeepPartial } from 'typeorm';
export type SystemConfigValidator = (config: SystemConfig, newConfig: SystemConfig) => void | Promise<void>; export type SystemConfigValidator = (config: SystemConfig, newConfig: SystemConfig) => void | Promise<void>;
@ -25,11 +27,11 @@ export class SystemConfigCore {
config$ = new Subject<SystemConfig>(); config$ = new Subject<SystemConfig>();
private constructor( private constructor(
private repository: ISystemConfigRepository, private repository: ISystemMetadataRepository,
private logger: ILoggerRepository, private logger: ILoggerRepository,
) {} ) {}
static create(repository: ISystemConfigRepository, logger: ILoggerRepository) { static create(repository: ISystemMetadataRepository, logger: ILoggerRepository) {
if (!instance) { if (!instance) {
instance = new SystemConfigCore(repository, logger); instance = new SystemConfigCore(repository, logger);
} }
@ -55,41 +57,25 @@ export class SystemConfigCore {
} }
async updateConfig(newConfig: SystemConfig): Promise<SystemConfig> { async updateConfig(newConfig: SystemConfig): Promise<SystemConfig> {
const updates: SystemConfigEntity[] = []; // get the difference between the new config and the default config
const deletes: SystemConfigEntity[] = []; const partialConfig: DeepPartial<SystemConfig> = {};
for (const property of getKeysDeep(defaults)) {
const newValue = _.get(newConfig, property);
const isEmpty = newValue === undefined || newValue === null || newValue === '';
const defaultValue = _.get(defaults, property);
const isEqual = newValue === defaultValue || _.isEqual(newValue, defaultValue);
for (const key of Object.values(SystemConfigKey)) { if (isEmpty || isEqual) {
// get via dot notation
const item = { key, value: _.get(newConfig, key) as SystemConfigValue };
const defaultValue = _.get(defaults, key);
const isMissing = !_.has(newConfig, key);
if (
isMissing ||
item.value === null ||
item.value === '' ||
item.value === defaultValue ||
_.isEqual(item.value, defaultValue)
) {
deletes.push(item);
continue; continue;
} }
updates.push(item); _.set(partialConfig, property, newValue);
} }
if (updates.length > 0) { await this.repository.set(SystemMetadataKey.SYSTEM_CONFIG, partialConfig);
await this.repository.saveAll(updates);
}
if (deletes.length > 0) {
await this.repository.deleteKeys(deletes.map((item) => item.key));
}
const config = await this.getConfig(true); const config = await this.getConfig(true);
this.config$.next(config); this.config$.next(config);
return config; return config;
} }
@ -103,16 +89,28 @@ export class SystemConfigCore {
} }
private async buildConfig() { private async buildConfig() {
const config = _.cloneDeep(defaults); // load partial
const overrides = this.isUsingConfigFile() const partial = this.isUsingConfigFile()
? await this.loadFromFile(process.env.IMMICH_CONFIG_FILE as string) ? await this.loadFromFile(process.env.IMMICH_CONFIG_FILE as string)
: await this.repository.load(); : await this.repository.get(SystemMetadataKey.SYSTEM_CONFIG);
for (const { key, value } of overrides) { // merge with defaults
// set via dot notation const config = _.cloneDeep(defaults);
_.set(config, key, value); for (const property of getKeysDeep(partial)) {
_.set(config, property, _.get(partial, property));
} }
// check for extra properties
const unknownKeys = _.cloneDeep(config);
for (const property of getKeysDeep(defaults)) {
unsetDeep(unknownKeys, property);
}
if (!_.isEmpty(unknownKeys)) {
this.logger.warn(`Unknown keys found: ${JSON.stringify(unknownKeys, null, 2)}`);
}
// validate full config
const errors = await validate(plainToInstance(SystemConfigDto, config)); const errors = await validate(plainToInstance(SystemConfigDto, config));
if (errors.length > 0) { if (errors.length > 0) {
if (this.isUsingConfigFile()) { if (this.isUsingConfigFile()) {
@ -136,36 +134,10 @@ export class SystemConfigCore {
private async loadFromFile(filepath: string) { private async loadFromFile(filepath: string) {
try { try {
const file = await this.repository.readFile(filepath); const file = await this.repository.readFile(filepath);
const config = loadYaml(file.toString()) as any; return loadYaml(file.toString()) as unknown;
const overrides: SystemConfigEntity<SystemConfigValue>[] = [];
for (const key of Object.values(SystemConfigKey)) {
const value = _.get(config, key);
this.unsetDeep(config, key);
if (value !== undefined) {
overrides.push({ key, value });
}
}
if (!_.isEmpty(config)) {
this.logger.warn(`Unknown keys found: ${JSON.stringify(config, null, 2)}`);
}
return overrides;
} catch (error: Error | any) { } catch (error: Error | any) {
this.logger.error(`Unable to load configuration file: ${filepath}`); this.logger.error(`Unable to load configuration file: ${filepath}`);
throw error; throw error;
} }
} }
private unsetDeep(object: object, key: string) {
_.unset(object, key);
const path = key.split('.');
while (path.pop()) {
if (!_.isEmpty(_.get(object, path))) {
return;
}
_.unset(object, path);
}
}
} }

View file

@ -18,7 +18,6 @@ import { SessionEntity } from 'src/entities/session.entity';
import { SharedLinkEntity } from 'src/entities/shared-link.entity'; import { SharedLinkEntity } from 'src/entities/shared-link.entity';
import { SmartInfoEntity } from 'src/entities/smart-info.entity'; import { SmartInfoEntity } from 'src/entities/smart-info.entity';
import { SmartSearchEntity } from 'src/entities/smart-search.entity'; import { SmartSearchEntity } from 'src/entities/smart-search.entity';
import { SystemConfigEntity } from 'src/entities/system-config.entity';
import { SystemMetadataEntity } from 'src/entities/system-metadata.entity'; import { SystemMetadataEntity } from 'src/entities/system-metadata.entity';
import { TagEntity } from 'src/entities/tag.entity'; import { TagEntity } from 'src/entities/tag.entity';
import { UserEntity } from 'src/entities/user.entity'; import { UserEntity } from 'src/entities/user.entity';
@ -42,7 +41,6 @@ export const entities = [
SharedLinkEntity, SharedLinkEntity,
SmartInfoEntity, SmartInfoEntity,
SmartSearchEntity, SmartSearchEntity,
SystemConfigEntity,
SystemMetadataEntity, SystemMetadataEntity,
TagEntity, TagEntity,
UserEntity, UserEntity,

View file

@ -1,145 +0,0 @@
import { SystemConfig } from 'src/config';
import { Column, Entity, PrimaryColumn } from 'typeorm';
export type SystemConfigValue = string | string[] | number | boolean;
// https://stackoverflow.com/a/47058976
// https://stackoverflow.com/a/70692231
type PathsToStringProps<T> = T extends SystemConfigValue
? []
: {
[K in keyof T]: [K, ...PathsToStringProps<T[K]>];
}[keyof T];
type Join<T extends string[], D extends string> = T extends []
? never
: T extends [infer F]
? F
: T extends [infer F, ...infer R]
? F extends string
? `${F}${D}${Join<Extract<R, string[]>, D>}`
: never
: string;
// dot notation matches path in `SystemConfig`
// TODO: migrate to key value per section
export const SystemConfigKey = {
FFMPEG_CRF: 'ffmpeg.crf',
FFMPEG_THREADS: 'ffmpeg.threads',
FFMPEG_PRESET: 'ffmpeg.preset',
FFMPEG_TARGET_VIDEO_CODEC: 'ffmpeg.targetVideoCodec',
FFMPEG_ACCEPTED_VIDEO_CODECS: 'ffmpeg.acceptedVideoCodecs',
FFMPEG_TARGET_AUDIO_CODEC: 'ffmpeg.targetAudioCodec',
FFMPEG_ACCEPTED_AUDIO_CODECS: 'ffmpeg.acceptedAudioCodecs',
FFMPEG_TARGET_RESOLUTION: 'ffmpeg.targetResolution',
FFMPEG_MAX_BITRATE: 'ffmpeg.maxBitrate',
FFMPEG_BFRAMES: 'ffmpeg.bframes',
FFMPEG_REFS: 'ffmpeg.refs',
FFMPEG_GOP_SIZE: 'ffmpeg.gopSize',
FFMPEG_NPL: 'ffmpeg.npl',
FFMPEG_TEMPORAL_AQ: 'ffmpeg.temporalAQ',
FFMPEG_CQ_MODE: 'ffmpeg.cqMode',
FFMPEG_TWO_PASS: 'ffmpeg.twoPass',
FFMPEG_PREFERRED_HW_DEVICE: 'ffmpeg.preferredHwDevice',
FFMPEG_TRANSCODE: 'ffmpeg.transcode',
FFMPEG_ACCEL: 'ffmpeg.accel',
FFMPEG_TONEMAP: 'ffmpeg.tonemap',
JOB_THUMBNAIL_GENERATION_CONCURRENCY: 'job.thumbnailGeneration.concurrency',
JOB_METADATA_EXTRACTION_CONCURRENCY: 'job.metadataExtraction.concurrency',
JOB_VIDEO_CONVERSION_CONCURRENCY: 'job.videoConversion.concurrency',
JOB_FACE_DETECTION_CONCURRENCY: 'job.faceDetection.concurrency',
JOB_CLIP_ENCODING_CONCURRENCY: 'job.smartSearch.concurrency',
JOB_BACKGROUND_TASK_CONCURRENCY: 'job.backgroundTask.concurrency',
JOB_SEARCH_CONCURRENCY: 'job.search.concurrency',
JOB_SIDECAR_CONCURRENCY: 'job.sidecar.concurrency',
JOB_LIBRARY_CONCURRENCY: 'job.library.concurrency',
JOB_MIGRATION_CONCURRENCY: 'job.migration.concurrency',
LIBRARY_SCAN_ENABLED: 'library.scan.enabled',
LIBRARY_SCAN_CRON_EXPRESSION: 'library.scan.cronExpression',
LIBRARY_WATCH_ENABLED: 'library.watch.enabled',
LOGGING_ENABLED: 'logging.enabled',
LOGGING_LEVEL: 'logging.level',
MACHINE_LEARNING_ENABLED: 'machineLearning.enabled',
MACHINE_LEARNING_URL: 'machineLearning.url',
MACHINE_LEARNING_CLIP_ENABLED: 'machineLearning.clip.enabled',
MACHINE_LEARNING_CLIP_MODEL_NAME: 'machineLearning.clip.modelName',
MACHINE_LEARNING_FACIAL_RECOGNITION_ENABLED: 'machineLearning.facialRecognition.enabled',
MACHINE_LEARNING_FACIAL_RECOGNITION_MODEL_NAME: 'machineLearning.facialRecognition.modelName',
MACHINE_LEARNING_FACIAL_RECOGNITION_MIN_SCORE: 'machineLearning.facialRecognition.minScore',
MACHINE_LEARNING_FACIAL_RECOGNITION_MAX_DISTANCE: 'machineLearning.facialRecognition.maxDistance',
MACHINE_LEARNING_FACIAL_RECOGNITION_MIN_FACES: 'machineLearning.facialRecognition.minFaces',
MAP_ENABLED: 'map.enabled',
MAP_LIGHT_STYLE: 'map.lightStyle',
MAP_DARK_STYLE: 'map.darkStyle',
NOTIFICATIONS_SMTP_ENABLED: 'notifications.smtp.enabled',
NOTIFICATIONS_SMTP_FROM: 'notifications.smtp.from',
NOTIFICATIONS_SMTP_REPLY_TO: 'notifications.smtp.replyTo',
NOTIFICATIONS_SMTP_TRANSPORT_IGNORE_CERT: 'notifications.smtp.transport.ignoreCert',
NOTIFICATIONS_SMTP_TRANSPORT_HOST: 'notifications.smtp.transport.host',
NOTIFICATIONS_SMTP_TRANSPORT_PORT: 'notifications.smtp.transport.port',
NOTIFICATIONS_SMTP_TRANSPORT_USERNAME: 'notifications.smtp.transport.username',
NOTIFICATIONS_SMTP_TRANSPORT_PASSWORD: 'notifications.smtp.transport.password',
REVERSE_GEOCODING_ENABLED: 'reverseGeocoding.enabled',
NEW_VERSION_CHECK_ENABLED: 'newVersionCheck.enabled',
OAUTH_AUTO_LAUNCH: 'oauth.autoLaunch',
OAUTH_AUTO_REGISTER: 'oauth.autoRegister',
OAUTH_BUTTON_TEXT: 'oauth.buttonText',
OAUTH_CLIENT_ID: 'oauth.clientId',
OAUTH_CLIENT_SECRET: 'oauth.clientSecret',
OAUTH_DEFAULT_STORAGE_QUOTA: 'oauth.defaultStorageQuota',
OAUTH_ENABLED: 'oauth.enabled',
OAUTH_ISSUER_URL: 'oauth.issuerUrl',
OAUTH_MOBILE_OVERRIDE_ENABLED: 'oauth.mobileOverrideEnabled',
OAUTH_MOBILE_REDIRECT_URI: 'oauth.mobileRedirectUri',
OAUTH_SCOPE: 'oauth.scope',
OAUTH_SIGNING_ALGORITHM: 'oauth.signingAlgorithm',
OAUTH_STORAGE_LABEL_CLAIM: 'oauth.storageLabelClaim',
OAUTH_STORAGE_QUOTA_CLAIM: 'oauth.storageQuotaClaim',
PASSWORD_LOGIN_ENABLED: 'passwordLogin.enabled',
SERVER_EXTERNAL_DOMAIN: 'server.externalDomain',
SERVER_LOGIN_PAGE_MESSAGE: 'server.loginPageMessage',
STORAGE_TEMPLATE_ENABLED: 'storageTemplate.enabled',
STORAGE_TEMPLATE_HASH_VERIFICATION_ENABLED: 'storageTemplate.hashVerificationEnabled',
STORAGE_TEMPLATE: 'storageTemplate.template',
IMAGE_THUMBNAIL_FORMAT: 'image.thumbnailFormat',
IMAGE_THUMBNAIL_SIZE: 'image.thumbnailSize',
IMAGE_PREVIEW_FORMAT: 'image.previewFormat',
IMAGE_PREVIEW_SIZE: 'image.previewSize',
IMAGE_QUALITY: 'image.quality',
IMAGE_COLORSPACE: 'image.colorspace',
IMAGE_EXTRACT_EMBEDDED: 'image.extractEmbedded',
TRASH_ENABLED: 'trash.enabled',
TRASH_DAYS: 'trash.days',
THEME_CUSTOM_CSS: 'theme.customCss',
USER_DELETE_DELAY: 'user.deleteDelay',
} as const satisfies Record<string, Join<PathsToStringProps<SystemConfig>, '.'>>;
export type SystemConfigKeyPaths = (typeof SystemConfigKey)[keyof typeof SystemConfigKey];
@Entity('system_config')
export class SystemConfigEntity<T = SystemConfigValue> {
@PrimaryColumn({ type: 'varchar' })
key!: SystemConfigKeyPaths;
@Column({ type: 'varchar', nullable: true, transformer: { to: JSON.stringify, from: JSON.parse } })
value!: T;
}

View file

@ -1,20 +1,23 @@
import { Column, Entity, PrimaryColumn } from 'typeorm'; import { SystemConfig } from 'src/config';
import { Column, DeepPartial, Entity, PrimaryColumn } from 'typeorm';
@Entity('system_metadata') @Entity('system_metadata')
export class SystemMetadataEntity { export class SystemMetadataEntity<T extends keyof SystemMetadata = SystemMetadataKey> {
@PrimaryColumn() @PrimaryColumn({ type: 'varchar' })
key!: string; key!: T;
@Column({ type: 'jsonb', default: '{}', transformer: { to: JSON.stringify, from: JSON.parse } }) @Column({ type: 'jsonb', default: '{}', transformer: { to: JSON.stringify, from: JSON.parse } })
value!: { [key: string]: unknown }; value!: SystemMetadata[T];
} }
export enum SystemMetadataKey { export enum SystemMetadataKey {
REVERSE_GEOCODING_STATE = 'reverse-geocoding-state', REVERSE_GEOCODING_STATE = 'reverse-geocoding-state',
ADMIN_ONBOARDING = 'admin-onboarding', ADMIN_ONBOARDING = 'admin-onboarding',
SYSTEM_CONFIG = 'system-config',
} }
export interface SystemMetadata extends Record<SystemMetadataKey, { [key: string]: unknown }> { export interface SystemMetadata extends Record<SystemMetadataKey, Record<string, any>> {
[SystemMetadataKey.REVERSE_GEOCODING_STATE]: { lastUpdate?: string; lastImportFileName?: string }; [SystemMetadataKey.REVERSE_GEOCODING_STATE]: { lastUpdate?: string; lastImportFileName?: string };
[SystemMetadataKey.ADMIN_ONBOARDING]: { isOnboarded: boolean }; [SystemMetadataKey.ADMIN_ONBOARDING]: { isOnboarded: boolean };
[SystemMetadataKey.SYSTEM_CONFIG]: DeepPartial<SystemConfig>;
} }

View file

@ -1,11 +0,0 @@
import { SystemConfigEntity } from 'src/entities/system-config.entity';
export const ISystemConfigRepository = 'ISystemConfigRepository';
export interface ISystemConfigRepository {
fetchStyle(url: string): Promise<any>;
load(): Promise<SystemConfigEntity[]>;
readFile(filename: string): Promise<string>;
saveAll(items: SystemConfigEntity[]): Promise<SystemConfigEntity[]>;
deleteKeys(keys: string[]): Promise<void>;
}

View file

@ -5,4 +5,6 @@ export const ISystemMetadataRepository = 'ISystemMetadataRepository';
export interface ISystemMetadataRepository { export interface ISystemMetadataRepository {
get<T extends keyof SystemMetadata>(key: T): Promise<SystemMetadata[T] | null>; get<T extends keyof SystemMetadata>(key: T): Promise<SystemMetadata[T] | null>;
set<T extends keyof SystemMetadata>(key: T, value: SystemMetadata[T]): Promise<void>; set<T extends keyof SystemMetadata>(key: T, value: SystemMetadata[T]): Promise<void>;
fetchStyle(url: string): Promise<any>;
readFile(filename: string): Promise<string>;
} }

View file

@ -0,0 +1,31 @@
import _ from 'lodash';
import { MigrationInterface, QueryRunner } from 'typeorm';
export class RemoveSystemConfigTable1715787369686 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
const overrides = await queryRunner.query('SELECT "key", "value" FROM "system_config"');
if (overrides.length === 0) {
return;
}
const config = {};
for (const { key, value } of overrides) {
_.set(config, key, JSON.parse(value));
}
await queryRunner.query(`INSERT INTO "system_metadata" ("key", "value") VALUES ($1, $2)`, [
'system-config',
// yup, we're double-stringifying it
JSON.stringify(JSON.stringify(config)),
]);
await queryRunner.query(`DROP TABLE "system_config"`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
// no data restore, you just get the table back
await queryRunner.query(
`CREATE TABLE "system_config" ("key" character varying NOT NULL, "value" character varying, CONSTRAINT "PK_aab69295b445016f56731f4d535" PRIMARY KEY ("key"))`,
);
}
}

View file

@ -1,13 +0,0 @@
-- NOTE: This file is auto generated by ./sql-generator
-- SystemConfigRepository.load
SELECT
"SystemConfigEntity"."key" AS "SystemConfigEntity_key",
"SystemConfigEntity"."value" AS "SystemConfigEntity_value"
FROM
"system_config" "SystemConfigEntity"
-- SystemConfigRepository.deleteKeys
DELETE FROM "system_config"
WHERE
"key" IN ($1, $2, $3, $4, $5, $6, $7, $8, $9)

View file

@ -27,7 +27,6 @@ import { IServerInfoRepository } from 'src/interfaces/server-info.interface';
import { ISessionRepository } from 'src/interfaces/session.interface'; import { ISessionRepository } from 'src/interfaces/session.interface';
import { ISharedLinkRepository } from 'src/interfaces/shared-link.interface'; import { ISharedLinkRepository } from 'src/interfaces/shared-link.interface';
import { IStorageRepository } from 'src/interfaces/storage.interface'; import { IStorageRepository } from 'src/interfaces/storage.interface';
import { ISystemConfigRepository } from 'src/interfaces/system-config.interface';
import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface';
import { ITagRepository } from 'src/interfaces/tag.interface'; import { ITagRepository } from 'src/interfaces/tag.interface';
import { IUserRepository } from 'src/interfaces/user.interface'; import { IUserRepository } from 'src/interfaces/user.interface';
@ -60,7 +59,6 @@ import { ServerInfoRepository } from 'src/repositories/server-info.repository';
import { SessionRepository } from 'src/repositories/session.repository'; import { SessionRepository } from 'src/repositories/session.repository';
import { SharedLinkRepository } from 'src/repositories/shared-link.repository'; import { SharedLinkRepository } from 'src/repositories/shared-link.repository';
import { StorageRepository } from 'src/repositories/storage.repository'; import { StorageRepository } from 'src/repositories/storage.repository';
import { SystemConfigRepository } from 'src/repositories/system-config.repository';
import { SystemMetadataRepository } from 'src/repositories/system-metadata.repository'; import { SystemMetadataRepository } from 'src/repositories/system-metadata.repository';
import { TagRepository } from 'src/repositories/tag.repository'; import { TagRepository } from 'src/repositories/tag.repository';
import { UserRepository } from 'src/repositories/user.repository'; import { UserRepository } from 'src/repositories/user.repository';
@ -94,7 +92,6 @@ export const repositories = [
{ provide: ISearchRepository, useClass: SearchRepository }, { provide: ISearchRepository, useClass: SearchRepository },
{ provide: ISessionRepository, useClass: SessionRepository }, { provide: ISessionRepository, useClass: SessionRepository },
{ provide: IStorageRepository, useClass: StorageRepository }, { provide: IStorageRepository, useClass: StorageRepository },
{ provide: ISystemConfigRepository, useClass: SystemConfigRepository },
{ provide: ISystemMetadataRepository, useClass: SystemMetadataRepository }, { provide: ISystemMetadataRepository, useClass: SystemMetadataRepository },
{ provide: ITagRepository, useClass: TagRepository }, { provide: ITagRepository, useClass: TagRepository },
{ provide: IMediaRepository, useClass: MediaRepository }, { provide: IMediaRepository, useClass: MediaRepository },

View file

@ -1,50 +0,0 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { readFile } from 'node:fs/promises';
import { Chunked, DummyValue, GenerateSql } from 'src/decorators';
import { SystemConfigEntity } from 'src/entities/system-config.entity';
import { ISystemConfigRepository } from 'src/interfaces/system-config.interface';
import { Instrumentation } from 'src/utils/instrumentation';
import { In, Repository } from 'typeorm';
@Instrumentation()
@Injectable()
export class SystemConfigRepository implements ISystemConfigRepository {
constructor(
@InjectRepository(SystemConfigEntity)
private repository: Repository<SystemConfigEntity>,
) {}
async fetchStyle(url: string) {
try {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`Failed to fetch data from ${url} with status ${response.status}: ${await response.text()}`);
}
return response.json();
} catch (error) {
throw new Error(`Failed to fetch data from ${url}: ${error}`);
}
}
@GenerateSql()
load(): Promise<SystemConfigEntity[]> {
return this.repository.find();
}
readFile(filename: string): Promise<string> {
return readFile(filename, { encoding: 'utf8' });
}
saveAll(items: SystemConfigEntity[]): Promise<SystemConfigEntity[]> {
return this.repository.save(items);
}
@GenerateSql({ params: [DummyValue.STRING] })
@Chunked()
async deleteKeys(keys: string[]): Promise<void> {
await this.repository.delete({ key: In(keys) });
}
}

View file

@ -1,5 +1,6 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm'; import { InjectRepository } from '@nestjs/typeorm';
import { readFile } from 'node:fs/promises';
import { SystemMetadata, SystemMetadataEntity } from 'src/entities/system-metadata.entity'; import { SystemMetadata, SystemMetadataEntity } from 'src/entities/system-metadata.entity';
import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface';
import { Instrumentation } from 'src/utils/instrumentation'; import { Instrumentation } from 'src/utils/instrumentation';
@ -24,4 +25,22 @@ export class SystemMetadataRepository implements ISystemMetadataRepository {
async set<T extends keyof SystemMetadata>(key: T, value: SystemMetadata[T]): Promise<void> { async set<T extends keyof SystemMetadata>(key: T, value: SystemMetadata[T]): Promise<void> {
await this.repository.upsert({ key, value }, { conflictPaths: { key: true } }); await this.repository.upsert({ key, value }, { conflictPaths: { key: true } });
} }
async fetchStyle(url: string) {
try {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`Failed to fetch data from ${url} with status ${response.status}: ${await response.text()}`);
}
return response.json();
} catch (error) {
throw new Error(`Failed to fetch data from ${url}: ${error}`);
}
}
readFile(filename: string): Promise<string> {
return readFile(filename, { encoding: 'utf8' });
}
} }

View file

@ -10,7 +10,7 @@ import { IJobRepository, JobName } from 'src/interfaces/job.interface';
import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { ILoggerRepository } from 'src/interfaces/logger.interface';
import { IPartnerRepository } from 'src/interfaces/partner.interface'; import { IPartnerRepository } from 'src/interfaces/partner.interface';
import { IStorageRepository } from 'src/interfaces/storage.interface'; import { IStorageRepository } from 'src/interfaces/storage.interface';
import { ISystemConfigRepository } from 'src/interfaces/system-config.interface'; import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface';
import { IUserRepository } from 'src/interfaces/user.interface'; import { IUserRepository } from 'src/interfaces/user.interface';
import { AssetService } from 'src/services/asset.service'; import { AssetService } from 'src/services/asset.service';
import { assetStackStub, assetStub } from 'test/fixtures/asset.stub'; import { assetStackStub, assetStub } from 'test/fixtures/asset.stub';
@ -27,7 +27,7 @@ import { newJobRepositoryMock } from 'test/repositories/job.repository.mock';
import { newLoggerRepositoryMock } from 'test/repositories/logger.repository.mock'; import { newLoggerRepositoryMock } from 'test/repositories/logger.repository.mock';
import { newPartnerRepositoryMock } from 'test/repositories/partner.repository.mock'; import { newPartnerRepositoryMock } from 'test/repositories/partner.repository.mock';
import { newStorageRepositoryMock } from 'test/repositories/storage.repository.mock'; import { newStorageRepositoryMock } from 'test/repositories/storage.repository.mock';
import { newSystemConfigRepositoryMock } from 'test/repositories/system-config.repository.mock'; import { newSystemMetadataRepositoryMock } from 'test/repositories/system-metadata.repository.mock';
import { newUserRepositoryMock } from 'test/repositories/user.repository.mock'; import { newUserRepositoryMock } from 'test/repositories/user.repository.mock';
import { Mocked, vitest } from 'vitest'; import { Mocked, vitest } from 'vitest';
@ -159,7 +159,7 @@ describe(AssetService.name, () => {
let storageMock: Mocked<IStorageRepository>; let storageMock: Mocked<IStorageRepository>;
let userMock: Mocked<IUserRepository>; let userMock: Mocked<IUserRepository>;
let eventMock: Mocked<IEventRepository>; let eventMock: Mocked<IEventRepository>;
let configMock: Mocked<ISystemConfigRepository>; let systemMock: Mocked<ISystemMetadataRepository>;
let partnerMock: Mocked<IPartnerRepository>; let partnerMock: Mocked<IPartnerRepository>;
let assetStackMock: Mocked<IAssetStackRepository>; let assetStackMock: Mocked<IAssetStackRepository>;
let albumMock: Mocked<IAlbumRepository>; let albumMock: Mocked<IAlbumRepository>;
@ -182,7 +182,7 @@ describe(AssetService.name, () => {
jobMock = newJobRepositoryMock(); jobMock = newJobRepositoryMock();
storageMock = newStorageRepositoryMock(); storageMock = newStorageRepositoryMock();
userMock = newUserRepositoryMock(); userMock = newUserRepositoryMock();
configMock = newSystemConfigRepositoryMock(); systemMock = newSystemMetadataRepositoryMock();
partnerMock = newPartnerRepositoryMock(); partnerMock = newPartnerRepositoryMock();
assetStackMock = newAssetStackRepositoryMock(); assetStackMock = newAssetStackRepositoryMock();
albumMock = newAlbumRepositoryMock(); albumMock = newAlbumRepositoryMock();
@ -192,7 +192,7 @@ describe(AssetService.name, () => {
accessMock, accessMock,
assetMock, assetMock,
jobMock, jobMock,
configMock, systemMock,
storageMock, storageMock,
userMock, userMock,
eventMock, eventMock,

View file

@ -45,7 +45,7 @@ import {
import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { ILoggerRepository } from 'src/interfaces/logger.interface';
import { IPartnerRepository } from 'src/interfaces/partner.interface'; import { IPartnerRepository } from 'src/interfaces/partner.interface';
import { IStorageRepository } from 'src/interfaces/storage.interface'; import { IStorageRepository } from 'src/interfaces/storage.interface';
import { ISystemConfigRepository } from 'src/interfaces/system-config.interface'; import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface';
import { IUserRepository } from 'src/interfaces/user.interface'; import { IUserRepository } from 'src/interfaces/user.interface';
import { mimeTypes } from 'src/utils/mime-types'; import { mimeTypes } from 'src/utils/mime-types';
import { usePagination } from 'src/utils/pagination'; import { usePagination } from 'src/utils/pagination';
@ -73,7 +73,7 @@ export class AssetService {
@Inject(IAccessRepository) accessRepository: IAccessRepository, @Inject(IAccessRepository) accessRepository: IAccessRepository,
@Inject(IAssetRepository) private assetRepository: IAssetRepository, @Inject(IAssetRepository) private assetRepository: IAssetRepository,
@Inject(IJobRepository) private jobRepository: IJobRepository, @Inject(IJobRepository) private jobRepository: IJobRepository,
@Inject(ISystemConfigRepository) configRepository: ISystemConfigRepository, @Inject(ISystemMetadataRepository) systemMetadataRepository: ISystemMetadataRepository,
@Inject(IStorageRepository) private storageRepository: IStorageRepository, @Inject(IStorageRepository) private storageRepository: IStorageRepository,
@Inject(IUserRepository) private userRepository: IUserRepository, @Inject(IUserRepository) private userRepository: IUserRepository,
@Inject(IEventRepository) private eventRepository: IEventRepository, @Inject(IEventRepository) private eventRepository: IEventRepository,
@ -84,7 +84,7 @@ export class AssetService {
) { ) {
this.logger.setContext(AssetService.name); this.logger.setContext(AssetService.name);
this.access = AccessCore.create(accessRepository); this.access = AccessCore.create(accessRepository);
this.configCore = SystemConfigCore.create(configRepository, this.logger); this.configCore = SystemConfigCore.create(systemMetadataRepository, this.logger);
} }
async getUploadAssetIdByChecksum(auth: AuthDto, checksum?: string): Promise<AssetFileUploadResponseDto | undefined> { async getUploadAssetIdByChecksum(auth: AuthDto, checksum?: string): Promise<AssetFileUploadResponseDto | undefined> {

View file

@ -11,7 +11,7 @@ import { ILibraryRepository } from 'src/interfaces/library.interface';
import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { ILoggerRepository } from 'src/interfaces/logger.interface';
import { ISessionRepository } from 'src/interfaces/session.interface'; import { ISessionRepository } from 'src/interfaces/session.interface';
import { ISharedLinkRepository } from 'src/interfaces/shared-link.interface'; import { ISharedLinkRepository } from 'src/interfaces/shared-link.interface';
import { ISystemConfigRepository } from 'src/interfaces/system-config.interface'; import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface';
import { IUserRepository } from 'src/interfaces/user.interface'; import { IUserRepository } from 'src/interfaces/user.interface';
import { AuthService } from 'src/services/auth.service'; import { AuthService } from 'src/services/auth.service';
import { keyStub } from 'test/fixtures/api-key.stub'; import { keyStub } from 'test/fixtures/api-key.stub';
@ -27,7 +27,7 @@ import { newLibraryRepositoryMock } from 'test/repositories/library.repository.m
import { newLoggerRepositoryMock } from 'test/repositories/logger.repository.mock'; import { newLoggerRepositoryMock } from 'test/repositories/logger.repository.mock';
import { newSessionRepositoryMock } from 'test/repositories/session.repository.mock'; import { newSessionRepositoryMock } from 'test/repositories/session.repository.mock';
import { newSharedLinkRepositoryMock } from 'test/repositories/shared-link.repository.mock'; import { newSharedLinkRepositoryMock } from 'test/repositories/shared-link.repository.mock';
import { newSystemConfigRepositoryMock } from 'test/repositories/system-config.repository.mock'; import { newSystemMetadataRepositoryMock } from 'test/repositories/system-metadata.repository.mock';
import { newUserRepositoryMock } from 'test/repositories/user.repository.mock'; import { newUserRepositoryMock } from 'test/repositories/user.repository.mock';
import { Mock, Mocked, vitest } from 'vitest'; import { Mock, Mocked, vitest } from 'vitest';
@ -64,7 +64,7 @@ describe('AuthService', () => {
let userMock: Mocked<IUserRepository>; let userMock: Mocked<IUserRepository>;
let libraryMock: Mocked<ILibraryRepository>; let libraryMock: Mocked<ILibraryRepository>;
let loggerMock: Mocked<ILoggerRepository>; let loggerMock: Mocked<ILoggerRepository>;
let configMock: Mocked<ISystemConfigRepository>; let systemMock: Mocked<ISystemMetadataRepository>;
let sessionMock: Mocked<ISessionRepository>; let sessionMock: Mocked<ISessionRepository>;
let shareMock: Mocked<ISharedLinkRepository>; let shareMock: Mocked<ISharedLinkRepository>;
let keyMock: Mocked<IKeyRepository>; let keyMock: Mocked<IKeyRepository>;
@ -97,7 +97,7 @@ describe('AuthService', () => {
userMock = newUserRepositoryMock(); userMock = newUserRepositoryMock();
libraryMock = newLibraryRepositoryMock(); libraryMock = newLibraryRepositoryMock();
loggerMock = newLoggerRepositoryMock(); loggerMock = newLoggerRepositoryMock();
configMock = newSystemConfigRepositoryMock(); systemMock = newSystemMetadataRepositoryMock();
sessionMock = newSessionRepositoryMock(); sessionMock = newSessionRepositoryMock();
shareMock = newSharedLinkRepositoryMock(); shareMock = newSharedLinkRepositoryMock();
keyMock = newKeyRepositoryMock(); keyMock = newKeyRepositoryMock();
@ -105,7 +105,7 @@ describe('AuthService', () => {
sut = new AuthService( sut = new AuthService(
accessMock, accessMock,
cryptoMock, cryptoMock,
configMock, systemMock,
libraryMock, libraryMock,
loggerMock, loggerMock,
userMock, userMock,
@ -121,7 +121,7 @@ describe('AuthService', () => {
describe('login', () => { describe('login', () => {
it('should throw an error if password login is disabled', async () => { it('should throw an error if password login is disabled', async () => {
configMock.load.mockResolvedValue(systemConfigStub.disabled); systemMock.get.mockResolvedValue(systemConfigStub.disabled);
await expect(sut.login(fixtures.login, loginDetails)).rejects.toBeInstanceOf(UnauthorizedException); await expect(sut.login(fixtures.login, loginDetails)).rejects.toBeInstanceOf(UnauthorizedException);
}); });
@ -199,7 +199,7 @@ describe('AuthService', () => {
describe('logout', () => { describe('logout', () => {
it('should return the end session endpoint', async () => { it('should return the end session endpoint', async () => {
configMock.load.mockResolvedValue(systemConfigStub.enabled); systemMock.get.mockResolvedValue(systemConfigStub.enabled);
const auth = { user: { id: '123' } } as AuthDto; const auth = { user: { id: '123' } } as AuthDto;
await expect(sut.logout(auth, AuthType.OAUTH)).resolves.toEqual({ await expect(sut.logout(auth, AuthType.OAUTH)).resolves.toEqual({
successful: true, successful: true,
@ -377,7 +377,7 @@ describe('AuthService', () => {
}); });
it('should not allow auto registering', async () => { it('should not allow auto registering', async () => {
configMock.load.mockResolvedValue(systemConfigStub.noAutoRegister); systemMock.get.mockResolvedValue(systemConfigStub.noAutoRegister);
userMock.getByEmail.mockResolvedValue(null); userMock.getByEmail.mockResolvedValue(null);
await expect(sut.callback({ url: 'http://immich/auth/login?code=abc123' }, loginDetails)).rejects.toBeInstanceOf( await expect(sut.callback({ url: 'http://immich/auth/login?code=abc123' }, loginDetails)).rejects.toBeInstanceOf(
BadRequestException, BadRequestException,
@ -386,7 +386,7 @@ describe('AuthService', () => {
}); });
it('should link an existing user', async () => { it('should link an existing user', async () => {
configMock.load.mockResolvedValue(systemConfigStub.noAutoRegister); systemMock.get.mockResolvedValue(systemConfigStub.noAutoRegister);
userMock.getByEmail.mockResolvedValue(userStub.user1); userMock.getByEmail.mockResolvedValue(userStub.user1);
userMock.update.mockResolvedValue(userStub.user1); userMock.update.mockResolvedValue(userStub.user1);
sessionMock.create.mockResolvedValue(sessionStub.valid); sessionMock.create.mockResolvedValue(sessionStub.valid);
@ -400,7 +400,7 @@ describe('AuthService', () => {
}); });
it('should allow auto registering by default', async () => { it('should allow auto registering by default', async () => {
configMock.load.mockResolvedValue(systemConfigStub.enabled); systemMock.get.mockResolvedValue(systemConfigStub.enabled);
userMock.getByEmail.mockResolvedValue(null); userMock.getByEmail.mockResolvedValue(null);
userMock.getAdmin.mockResolvedValue(userStub.user1); userMock.getAdmin.mockResolvedValue(userStub.user1);
userMock.create.mockResolvedValue(userStub.user1); userMock.create.mockResolvedValue(userStub.user1);
@ -415,7 +415,7 @@ describe('AuthService', () => {
}); });
it('should use the mobile redirect override', async () => { it('should use the mobile redirect override', async () => {
configMock.load.mockResolvedValue(systemConfigStub.override); systemMock.get.mockResolvedValue(systemConfigStub.override);
userMock.getByOAuthId.mockResolvedValue(userStub.user1); userMock.getByOAuthId.mockResolvedValue(userStub.user1);
sessionMock.create.mockResolvedValue(sessionStub.valid); sessionMock.create.mockResolvedValue(sessionStub.valid);
@ -425,7 +425,7 @@ describe('AuthService', () => {
}); });
it('should use the mobile redirect override for ios urls with multiple slashes', async () => { it('should use the mobile redirect override for ios urls with multiple slashes', async () => {
configMock.load.mockResolvedValue(systemConfigStub.override); systemMock.get.mockResolvedValue(systemConfigStub.override);
userMock.getByOAuthId.mockResolvedValue(userStub.user1); userMock.getByOAuthId.mockResolvedValue(userStub.user1);
sessionMock.create.mockResolvedValue(sessionStub.valid); sessionMock.create.mockResolvedValue(sessionStub.valid);
@ -435,7 +435,7 @@ describe('AuthService', () => {
}); });
it('should use the default quota', async () => { it('should use the default quota', async () => {
configMock.load.mockResolvedValue(systemConfigStub.withDefaultStorageQuota); systemMock.get.mockResolvedValue(systemConfigStub.withDefaultStorageQuota);
userMock.getByEmail.mockResolvedValue(null); userMock.getByEmail.mockResolvedValue(null);
userMock.getAdmin.mockResolvedValue(userStub.user1); userMock.getAdmin.mockResolvedValue(userStub.user1);
userMock.create.mockResolvedValue(userStub.user1); userMock.create.mockResolvedValue(userStub.user1);
@ -448,7 +448,7 @@ describe('AuthService', () => {
}); });
it('should ignore an invalid storage quota', async () => { it('should ignore an invalid storage quota', async () => {
configMock.load.mockResolvedValue(systemConfigStub.withDefaultStorageQuota); systemMock.get.mockResolvedValue(systemConfigStub.withDefaultStorageQuota);
userMock.getByEmail.mockResolvedValue(null); userMock.getByEmail.mockResolvedValue(null);
userMock.getAdmin.mockResolvedValue(userStub.user1); userMock.getAdmin.mockResolvedValue(userStub.user1);
userMock.create.mockResolvedValue(userStub.user1); userMock.create.mockResolvedValue(userStub.user1);
@ -462,7 +462,7 @@ describe('AuthService', () => {
}); });
it('should ignore a negative quota', async () => { it('should ignore a negative quota', async () => {
configMock.load.mockResolvedValue(systemConfigStub.withDefaultStorageQuota); systemMock.get.mockResolvedValue(systemConfigStub.withDefaultStorageQuota);
userMock.getByEmail.mockResolvedValue(null); userMock.getByEmail.mockResolvedValue(null);
userMock.getAdmin.mockResolvedValue(userStub.user1); userMock.getAdmin.mockResolvedValue(userStub.user1);
userMock.create.mockResolvedValue(userStub.user1); userMock.create.mockResolvedValue(userStub.user1);
@ -476,7 +476,7 @@ describe('AuthService', () => {
}); });
it('should not set quota for 0 quota', async () => { it('should not set quota for 0 quota', async () => {
configMock.load.mockResolvedValue(systemConfigStub.withDefaultStorageQuota); systemMock.get.mockResolvedValue(systemConfigStub.withDefaultStorageQuota);
userMock.getByEmail.mockResolvedValue(null); userMock.getByEmail.mockResolvedValue(null);
userMock.getAdmin.mockResolvedValue(userStub.user1); userMock.getAdmin.mockResolvedValue(userStub.user1);
userMock.create.mockResolvedValue(userStub.user1); userMock.create.mockResolvedValue(userStub.user1);
@ -496,7 +496,7 @@ describe('AuthService', () => {
}); });
it('should use a valid storage quota', async () => { it('should use a valid storage quota', async () => {
configMock.load.mockResolvedValue(systemConfigStub.withDefaultStorageQuota); systemMock.get.mockResolvedValue(systemConfigStub.withDefaultStorageQuota);
userMock.getByEmail.mockResolvedValue(null); userMock.getByEmail.mockResolvedValue(null);
userMock.getAdmin.mockResolvedValue(userStub.user1); userMock.getAdmin.mockResolvedValue(userStub.user1);
userMock.create.mockResolvedValue(userStub.user1); userMock.create.mockResolvedValue(userStub.user1);
@ -518,7 +518,7 @@ describe('AuthService', () => {
describe('link', () => { describe('link', () => {
it('should link an account', async () => { it('should link an account', async () => {
configMock.load.mockResolvedValue(systemConfigStub.enabled); systemMock.get.mockResolvedValue(systemConfigStub.enabled);
userMock.update.mockResolvedValue(userStub.user1); userMock.update.mockResolvedValue(userStub.user1);
await sut.link(authStub.user1, { url: 'http://immich/user-settings?code=abc123' }); await sut.link(authStub.user1, { url: 'http://immich/user-settings?code=abc123' });
@ -527,7 +527,7 @@ describe('AuthService', () => {
}); });
it('should not link an already linked oauth.sub', async () => { it('should not link an already linked oauth.sub', async () => {
configMock.load.mockResolvedValue(systemConfigStub.enabled); systemMock.get.mockResolvedValue(systemConfigStub.enabled);
userMock.getByOAuthId.mockResolvedValue({ id: 'other-user' } as UserEntity); userMock.getByOAuthId.mockResolvedValue({ id: 'other-user' } as UserEntity);
await expect(sut.link(authStub.user1, { url: 'http://immich/user-settings?code=abc123' })).rejects.toBeInstanceOf( await expect(sut.link(authStub.user1, { url: 'http://immich/user-settings?code=abc123' })).rejects.toBeInstanceOf(
@ -540,7 +540,7 @@ describe('AuthService', () => {
describe('unlink', () => { describe('unlink', () => {
it('should unlink an account', async () => { it('should unlink an account', async () => {
configMock.load.mockResolvedValue(systemConfigStub.enabled); systemMock.get.mockResolvedValue(systemConfigStub.enabled);
userMock.update.mockResolvedValue(userStub.user1); userMock.update.mockResolvedValue(userStub.user1);
await sut.unlink(authStub.user1); await sut.unlink(authStub.user1);

View file

@ -37,7 +37,7 @@ import { ILibraryRepository } from 'src/interfaces/library.interface';
import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { ILoggerRepository } from 'src/interfaces/logger.interface';
import { ISessionRepository } from 'src/interfaces/session.interface'; import { ISessionRepository } from 'src/interfaces/session.interface';
import { ISharedLinkRepository } from 'src/interfaces/shared-link.interface'; import { ISharedLinkRepository } from 'src/interfaces/shared-link.interface';
import { ISystemConfigRepository } from 'src/interfaces/system-config.interface'; import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface';
import { IUserRepository } from 'src/interfaces/user.interface'; import { IUserRepository } from 'src/interfaces/user.interface';
import { HumanReadableSize } from 'src/utils/bytes'; import { HumanReadableSize } from 'src/utils/bytes';
@ -67,7 +67,7 @@ export class AuthService {
constructor( constructor(
@Inject(IAccessRepository) accessRepository: IAccessRepository, @Inject(IAccessRepository) accessRepository: IAccessRepository,
@Inject(ICryptoRepository) private cryptoRepository: ICryptoRepository, @Inject(ICryptoRepository) private cryptoRepository: ICryptoRepository,
@Inject(ISystemConfigRepository) configRepository: ISystemConfigRepository, @Inject(ISystemMetadataRepository) systemMetadataRepository: ISystemMetadataRepository,
@Inject(ILibraryRepository) libraryRepository: ILibraryRepository, @Inject(ILibraryRepository) libraryRepository: ILibraryRepository,
@Inject(ILoggerRepository) private logger: ILoggerRepository, @Inject(ILoggerRepository) private logger: ILoggerRepository,
@Inject(IUserRepository) private userRepository: IUserRepository, @Inject(IUserRepository) private userRepository: IUserRepository,
@ -77,7 +77,7 @@ export class AuthService {
) { ) {
this.logger.setContext(AuthService.name); this.logger.setContext(AuthService.name);
this.access = AccessCore.create(accessRepository); this.access = AccessCore.create(accessRepository);
this.configCore = SystemConfigCore.create(configRepository, logger); this.configCore = SystemConfigCore.create(systemMetadataRepository, logger);
this.userCore = UserCore.create(cryptoRepository, libraryRepository, userRepository); this.userCore = UserCore.create(cryptoRepository, libraryRepository, userRepository);
custom.setHttpOptionsDefaults({ timeout: 30_000 }); custom.setHttpOptionsDefaults({ timeout: 30_000 });

View file

@ -15,7 +15,7 @@ import {
import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { ILoggerRepository } from 'src/interfaces/logger.interface';
import { IMetricRepository } from 'src/interfaces/metric.interface'; import { IMetricRepository } from 'src/interfaces/metric.interface';
import { IPersonRepository } from 'src/interfaces/person.interface'; import { IPersonRepository } from 'src/interfaces/person.interface';
import { ISystemConfigRepository } from 'src/interfaces/system-config.interface'; import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface';
import { JobService } from 'src/services/job.service'; import { JobService } from 'src/services/job.service';
import { assetStub } from 'test/fixtures/asset.stub'; import { assetStub } from 'test/fixtures/asset.stub';
import { newAssetRepositoryMock } from 'test/repositories/asset.repository.mock'; import { newAssetRepositoryMock } from 'test/repositories/asset.repository.mock';
@ -24,7 +24,7 @@ import { newJobRepositoryMock } from 'test/repositories/job.repository.mock';
import { newLoggerRepositoryMock } from 'test/repositories/logger.repository.mock'; import { newLoggerRepositoryMock } from 'test/repositories/logger.repository.mock';
import { newMetricRepositoryMock } from 'test/repositories/metric.repository.mock'; import { newMetricRepositoryMock } from 'test/repositories/metric.repository.mock';
import { newPersonRepositoryMock } from 'test/repositories/person.repository.mock'; import { newPersonRepositoryMock } from 'test/repositories/person.repository.mock';
import { newSystemConfigRepositoryMock } from 'test/repositories/system-config.repository.mock'; import { newSystemMetadataRepositoryMock } from 'test/repositories/system-metadata.repository.mock';
import { Mocked, vitest } from 'vitest'; import { Mocked, vitest } from 'vitest';
const makeMockHandlers = (status: JobStatus) => { const makeMockHandlers = (status: JobStatus) => {
@ -38,22 +38,22 @@ const makeMockHandlers = (status: JobStatus) => {
describe(JobService.name, () => { describe(JobService.name, () => {
let sut: JobService; let sut: JobService;
let assetMock: Mocked<IAssetRepository>; let assetMock: Mocked<IAssetRepository>;
let configMock: Mocked<ISystemConfigRepository>;
let eventMock: Mocked<IEventRepository>; let eventMock: Mocked<IEventRepository>;
let jobMock: Mocked<IJobRepository>; let jobMock: Mocked<IJobRepository>;
let personMock: Mocked<IPersonRepository>; let personMock: Mocked<IPersonRepository>;
let metricMock: Mocked<IMetricRepository>; let metricMock: Mocked<IMetricRepository>;
let systemMock: Mocked<ISystemMetadataRepository>;
let loggerMock: Mocked<ILoggerRepository>; let loggerMock: Mocked<ILoggerRepository>;
beforeEach(() => { beforeEach(() => {
assetMock = newAssetRepositoryMock(); assetMock = newAssetRepositoryMock();
configMock = newSystemConfigRepositoryMock(); systemMock = newSystemMetadataRepositoryMock();
eventMock = newEventRepositoryMock(); eventMock = newEventRepositoryMock();
jobMock = newJobRepositoryMock(); jobMock = newJobRepositoryMock();
personMock = newPersonRepositoryMock(); personMock = newPersonRepositoryMock();
metricMock = newMetricRepositoryMock(); metricMock = newMetricRepositoryMock();
loggerMock = newLoggerRepositoryMock(); loggerMock = newLoggerRepositoryMock();
sut = new JobService(assetMock, eventMock, jobMock, configMock, personMock, metricMock, loggerMock); sut = new JobService(assetMock, eventMock, jobMock, systemMock, personMock, metricMock, loggerMock);
}); });
it('should work', () => { it('should work', () => {
@ -234,14 +234,14 @@ describe(JobService.name, () => {
describe('init', () => { describe('init', () => {
it('should register a handler for each queue', async () => { it('should register a handler for each queue', async () => {
await sut.init(makeMockHandlers(JobStatus.SUCCESS)); await sut.init(makeMockHandlers(JobStatus.SUCCESS));
expect(configMock.load).toHaveBeenCalled(); expect(systemMock.get).toHaveBeenCalled();
expect(jobMock.addHandler).toHaveBeenCalledTimes(Object.keys(QueueName).length); expect(jobMock.addHandler).toHaveBeenCalledTimes(Object.keys(QueueName).length);
}); });
it('should subscribe to config changes', async () => { it('should subscribe to config changes', async () => {
await sut.init(makeMockHandlers(JobStatus.FAILED)); await sut.init(makeMockHandlers(JobStatus.FAILED));
SystemConfigCore.create(newSystemConfigRepositoryMock(false), newLoggerRepositoryMock()).config$.next({ SystemConfigCore.create(newSystemMetadataRepositoryMock(false), newLoggerRepositoryMock()).config$.next({
job: { job: {
[QueueName.BACKGROUND_TASK]: { concurrency: 10 }, [QueueName.BACKGROUND_TASK]: { concurrency: 10 },
[QueueName.SMART_SEARCH]: { concurrency: 10 }, [QueueName.SMART_SEARCH]: { concurrency: 10 },

View file

@ -20,7 +20,7 @@ import {
import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { ILoggerRepository } from 'src/interfaces/logger.interface';
import { IMetricRepository } from 'src/interfaces/metric.interface'; import { IMetricRepository } from 'src/interfaces/metric.interface';
import { IPersonRepository } from 'src/interfaces/person.interface'; import { IPersonRepository } from 'src/interfaces/person.interface';
import { ISystemConfigRepository } from 'src/interfaces/system-config.interface'; import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface';
@Injectable() @Injectable()
export class JobService { export class JobService {
@ -30,13 +30,13 @@ export class JobService {
@Inject(IAssetRepository) private assetRepository: IAssetRepository, @Inject(IAssetRepository) private assetRepository: IAssetRepository,
@Inject(IEventRepository) private eventRepository: IEventRepository, @Inject(IEventRepository) private eventRepository: IEventRepository,
@Inject(IJobRepository) private jobRepository: IJobRepository, @Inject(IJobRepository) private jobRepository: IJobRepository,
@Inject(ISystemConfigRepository) configRepository: ISystemConfigRepository, @Inject(ISystemMetadataRepository) systemMetadataRepository: ISystemMetadataRepository,
@Inject(IPersonRepository) private personRepository: IPersonRepository, @Inject(IPersonRepository) private personRepository: IPersonRepository,
@Inject(IMetricRepository) private metricRepository: IMetricRepository, @Inject(IMetricRepository) private metricRepository: IMetricRepository,
@Inject(ILoggerRepository) private logger: ILoggerRepository, @Inject(ILoggerRepository) private logger: ILoggerRepository,
) { ) {
this.logger.setContext(JobService.name); this.logger.setContext(JobService.name);
this.configCore = SystemConfigCore.create(configRepository, logger); this.configCore = SystemConfigCore.create(systemMetadataRepository, logger);
} }
async handleCommand(queueName: QueueName, dto: JobCommandDto): Promise<JobStatusDto> { async handleCommand(queueName: QueueName, dto: JobCommandDto): Promise<JobStatusDto> {

View file

@ -5,7 +5,6 @@ import { SystemConfigCore } from 'src/cores/system-config.core';
import { mapLibrary } from 'src/dtos/library.dto'; import { mapLibrary } from 'src/dtos/library.dto';
import { AssetType } from 'src/entities/asset.entity'; import { AssetType } from 'src/entities/asset.entity';
import { LibraryType } from 'src/entities/library.entity'; import { LibraryType } from 'src/entities/library.entity';
import { SystemConfigKey } from 'src/entities/system-config.entity';
import { UserEntity } from 'src/entities/user.entity'; import { UserEntity } from 'src/entities/user.entity';
import { IAssetRepository } from 'src/interfaces/asset.interface'; import { IAssetRepository } from 'src/interfaces/asset.interface';
import { ICryptoRepository } from 'src/interfaces/crypto.interface'; import { ICryptoRepository } from 'src/interfaces/crypto.interface';
@ -14,7 +13,7 @@ import { IJobRepository, ILibraryFileJob, ILibraryRefreshJob, JobName, JobStatus
import { ILibraryRepository } from 'src/interfaces/library.interface'; import { ILibraryRepository } from 'src/interfaces/library.interface';
import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { ILoggerRepository } from 'src/interfaces/logger.interface';
import { IStorageRepository } from 'src/interfaces/storage.interface'; import { IStorageRepository } from 'src/interfaces/storage.interface';
import { ISystemConfigRepository } from 'src/interfaces/system-config.interface'; import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface';
import { LibraryService } from 'src/services/library.service'; import { LibraryService } from 'src/services/library.service';
import { assetStub } from 'test/fixtures/asset.stub'; import { assetStub } from 'test/fixtures/asset.stub';
import { authStub } from 'test/fixtures/auth.stub'; import { authStub } from 'test/fixtures/auth.stub';
@ -28,14 +27,14 @@ import { newJobRepositoryMock } from 'test/repositories/job.repository.mock';
import { newLibraryRepositoryMock } from 'test/repositories/library.repository.mock'; import { newLibraryRepositoryMock } from 'test/repositories/library.repository.mock';
import { newLoggerRepositoryMock } from 'test/repositories/logger.repository.mock'; import { newLoggerRepositoryMock } from 'test/repositories/logger.repository.mock';
import { makeMockWatcher, newStorageRepositoryMock } from 'test/repositories/storage.repository.mock'; import { makeMockWatcher, newStorageRepositoryMock } from 'test/repositories/storage.repository.mock';
import { newSystemConfigRepositoryMock } from 'test/repositories/system-config.repository.mock'; import { newSystemMetadataRepositoryMock } from 'test/repositories/system-metadata.repository.mock';
import { Mocked, vitest } from 'vitest'; import { Mocked, vitest } from 'vitest';
describe(LibraryService.name, () => { describe(LibraryService.name, () => {
let sut: LibraryService; let sut: LibraryService;
let assetMock: Mocked<IAssetRepository>; let assetMock: Mocked<IAssetRepository>;
let configMock: Mocked<ISystemConfigRepository>; let systemMock: Mocked<ISystemMetadataRepository>;
let cryptoMock: Mocked<ICryptoRepository>; let cryptoMock: Mocked<ICryptoRepository>;
let jobMock: Mocked<IJobRepository>; let jobMock: Mocked<IJobRepository>;
let libraryMock: Mocked<ILibraryRepository>; let libraryMock: Mocked<ILibraryRepository>;
@ -44,7 +43,7 @@ describe(LibraryService.name, () => {
let loggerMock: Mocked<ILoggerRepository>; let loggerMock: Mocked<ILoggerRepository>;
beforeEach(() => { beforeEach(() => {
configMock = newSystemConfigRepositoryMock(); systemMock = newSystemMetadataRepositoryMock();
libraryMock = newLibraryRepositoryMock(); libraryMock = newLibraryRepositoryMock();
assetMock = newAssetRepositoryMock(); assetMock = newAssetRepositoryMock();
jobMock = newJobRepositoryMock(); jobMock = newJobRepositoryMock();
@ -55,7 +54,7 @@ describe(LibraryService.name, () => {
sut = new LibraryService( sut = new LibraryService(
assetMock, assetMock,
configMock, systemMock,
cryptoMock, cryptoMock,
jobMock, jobMock,
libraryMock, libraryMock,
@ -73,16 +72,13 @@ describe(LibraryService.name, () => {
describe('init', () => { describe('init', () => {
it('should init cron job and subscribe to config changes', async () => { it('should init cron job and subscribe to config changes', async () => {
configMock.load.mockResolvedValue([ systemMock.get.mockResolvedValue(systemConfigStub.libraryScan);
{ key: SystemConfigKey.LIBRARY_SCAN_ENABLED, value: true },
{ key: SystemConfigKey.LIBRARY_SCAN_CRON_EXPRESSION, value: '0 0 * * *' },
]);
await sut.init(); await sut.init();
expect(configMock.load).toHaveBeenCalled(); expect(systemMock.get).toHaveBeenCalled();
expect(jobMock.addCronJob).toHaveBeenCalled(); expect(jobMock.addCronJob).toHaveBeenCalled();
SystemConfigCore.create(newSystemConfigRepositoryMock(false), newLoggerRepositoryMock()).config$.next({ SystemConfigCore.create(newSystemMetadataRepositoryMock(false), newLoggerRepositoryMock()).config$.next({
library: { library: {
scan: { scan: {
enabled: true, enabled: true,
@ -101,7 +97,7 @@ describe(LibraryService.name, () => {
libraryStub.externalLibraryWithImportPaths2, libraryStub.externalLibraryWithImportPaths2,
]); ]);
configMock.load.mockResolvedValue(systemConfigStub.libraryWatchEnabled); systemMock.get.mockResolvedValue(systemConfigStub.libraryWatchEnabled);
libraryMock.get.mockImplementation((id) => libraryMock.get.mockImplementation((id) =>
Promise.resolve( Promise.resolve(
[libraryStub.externalLibraryWithImportPaths1, libraryStub.externalLibraryWithImportPaths2].find( [libraryStub.externalLibraryWithImportPaths1, libraryStub.externalLibraryWithImportPaths2].find(
@ -121,7 +117,7 @@ describe(LibraryService.name, () => {
}); });
it('should not initialize watcher when watching is disabled', async () => { it('should not initialize watcher when watching is disabled', async () => {
configMock.load.mockResolvedValue(systemConfigStub.libraryWatchDisabled); systemMock.get.mockResolvedValue(systemConfigStub.libraryWatchDisabled);
await sut.init(); await sut.init();
@ -129,7 +125,7 @@ describe(LibraryService.name, () => {
}); });
it('should not initialize watcher when lock is taken', async () => { it('should not initialize watcher when lock is taken', async () => {
configMock.load.mockResolvedValue(systemConfigStub.libraryWatchEnabled); systemMock.get.mockResolvedValue(systemConfigStub.libraryWatchEnabled);
databaseMock.tryLock.mockResolvedValue(false); databaseMock.tryLock.mockResolvedValue(false);
await sut.init(); await sut.init();
@ -757,7 +753,7 @@ describe(LibraryService.name, () => {
libraryMock.get.mockResolvedValue(libraryStub.externalLibraryWithImportPaths1); libraryMock.get.mockResolvedValue(libraryStub.externalLibraryWithImportPaths1);
libraryMock.getAll.mockResolvedValue([libraryStub.externalLibraryWithImportPaths1]); libraryMock.getAll.mockResolvedValue([libraryStub.externalLibraryWithImportPaths1]);
configMock.load.mockResolvedValue(systemConfigStub.libraryWatchEnabled); systemMock.get.mockResolvedValue(systemConfigStub.libraryWatchEnabled);
const mockClose = vitest.fn(); const mockClose = vitest.fn();
storageMock.watch.mockImplementation(makeMockWatcher({ close: mockClose })); storageMock.watch.mockImplementation(makeMockWatcher({ close: mockClose }));
@ -897,7 +893,7 @@ describe(LibraryService.name, () => {
}); });
it('should create watched with import paths', async () => { it('should create watched with import paths', async () => {
configMock.load.mockResolvedValue(systemConfigStub.libraryWatchEnabled); systemMock.get.mockResolvedValue(systemConfigStub.libraryWatchEnabled);
libraryMock.create.mockResolvedValue(libraryStub.externalLibraryWithImportPaths1); libraryMock.create.mockResolvedValue(libraryStub.externalLibraryWithImportPaths1);
libraryMock.get.mockResolvedValue(libraryStub.externalLibraryWithImportPaths1); libraryMock.get.mockResolvedValue(libraryStub.externalLibraryWithImportPaths1);
libraryMock.getAll.mockResolvedValue([]); libraryMock.getAll.mockResolvedValue([]);
@ -1041,7 +1037,7 @@ describe(LibraryService.name, () => {
describe('update', () => { describe('update', () => {
beforeEach(async () => { beforeEach(async () => {
configMock.load.mockResolvedValue(systemConfigStub.libraryWatchEnabled); systemMock.get.mockResolvedValue(systemConfigStub.libraryWatchEnabled);
libraryMock.getAll.mockResolvedValue([]); libraryMock.getAll.mockResolvedValue([]);
await sut.init(); await sut.init();
@ -1058,7 +1054,7 @@ describe(LibraryService.name, () => {
describe('watchAll', () => { describe('watchAll', () => {
describe('watching disabled', () => { describe('watching disabled', () => {
beforeEach(async () => { beforeEach(async () => {
configMock.load.mockResolvedValue(systemConfigStub.libraryWatchDisabled); systemMock.get.mockResolvedValue(systemConfigStub.libraryWatchDisabled);
await sut.init(); await sut.init();
}); });
@ -1074,7 +1070,7 @@ describe(LibraryService.name, () => {
describe('watching enabled', () => { describe('watching enabled', () => {
beforeEach(async () => { beforeEach(async () => {
configMock.load.mockResolvedValue(systemConfigStub.libraryWatchEnabled); systemMock.get.mockResolvedValue(systemConfigStub.libraryWatchEnabled);
libraryMock.getAll.mockResolvedValue([]); libraryMock.getAll.mockResolvedValue([]);
await sut.init(); await sut.init();
}); });
@ -1229,7 +1225,7 @@ describe(LibraryService.name, () => {
libraryStub.externalLibraryWithImportPaths2, libraryStub.externalLibraryWithImportPaths2,
]); ]);
configMock.load.mockResolvedValue(systemConfigStub.libraryWatchEnabled); systemMock.get.mockResolvedValue(systemConfigStub.libraryWatchEnabled);
libraryMock.get.mockResolvedValue(libraryStub.externalLibrary1); libraryMock.get.mockResolvedValue(libraryStub.externalLibrary1);
libraryMock.get.mockImplementation((id) => libraryMock.get.mockImplementation((id) =>

View file

@ -38,7 +38,7 @@ import {
import { ILibraryRepository } from 'src/interfaces/library.interface'; import { ILibraryRepository } from 'src/interfaces/library.interface';
import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { ILoggerRepository } from 'src/interfaces/logger.interface';
import { IStorageRepository } from 'src/interfaces/storage.interface'; import { IStorageRepository } from 'src/interfaces/storage.interface';
import { ISystemConfigRepository } from 'src/interfaces/system-config.interface'; import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface';
import { mimeTypes } from 'src/utils/mime-types'; import { mimeTypes } from 'src/utils/mime-types';
import { handlePromiseError } from 'src/utils/misc'; import { handlePromiseError } from 'src/utils/misc';
import { usePagination } from 'src/utils/pagination'; import { usePagination } from 'src/utils/pagination';
@ -55,7 +55,7 @@ export class LibraryService {
constructor( constructor(
@Inject(IAssetRepository) private assetRepository: IAssetRepository, @Inject(IAssetRepository) private assetRepository: IAssetRepository,
@Inject(ISystemConfigRepository) configRepository: ISystemConfigRepository, @Inject(ISystemMetadataRepository) systemMetadataRepository: ISystemMetadataRepository,
@Inject(ICryptoRepository) private cryptoRepository: ICryptoRepository, @Inject(ICryptoRepository) private cryptoRepository: ICryptoRepository,
@Inject(IJobRepository) private jobRepository: IJobRepository, @Inject(IJobRepository) private jobRepository: IJobRepository,
@Inject(ILibraryRepository) private repository: ILibraryRepository, @Inject(ILibraryRepository) private repository: ILibraryRepository,
@ -64,7 +64,7 @@ export class LibraryService {
@Inject(ILoggerRepository) private logger: ILoggerRepository, @Inject(ILoggerRepository) private logger: ILoggerRepository,
) { ) {
this.logger.setContext(LibraryService.name); this.logger.setContext(LibraryService.name);
this.configCore = SystemConfigCore.create(configRepository, this.logger); this.configCore = SystemConfigCore.create(systemMetadataRepository, this.logger);
} }
async init() { async init() {

View file

@ -10,7 +10,6 @@ import {
} from 'src/config'; } from 'src/config';
import { AssetType } from 'src/entities/asset.entity'; import { AssetType } from 'src/entities/asset.entity';
import { ExifEntity } from 'src/entities/exif.entity'; import { ExifEntity } from 'src/entities/exif.entity';
import { SystemConfigKey } from 'src/entities/system-config.entity';
import { IAssetRepository, WithoutProperty } from 'src/interfaces/asset.interface'; import { IAssetRepository, WithoutProperty } from 'src/interfaces/asset.interface';
import { ICryptoRepository } from 'src/interfaces/crypto.interface'; import { ICryptoRepository } from 'src/interfaces/crypto.interface';
import { IJobRepository, JobName, JobStatus } from 'src/interfaces/job.interface'; import { IJobRepository, JobName, JobStatus } from 'src/interfaces/job.interface';
@ -19,7 +18,7 @@ import { IMediaRepository } from 'src/interfaces/media.interface';
import { IMoveRepository } from 'src/interfaces/move.interface'; import { IMoveRepository } from 'src/interfaces/move.interface';
import { IPersonRepository } from 'src/interfaces/person.interface'; import { IPersonRepository } from 'src/interfaces/person.interface';
import { IStorageRepository } from 'src/interfaces/storage.interface'; import { IStorageRepository } from 'src/interfaces/storage.interface';
import { ISystemConfigRepository } from 'src/interfaces/system-config.interface'; import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface';
import { MediaService } from 'src/services/media.service'; import { MediaService } from 'src/services/media.service';
import { assetStub } from 'test/fixtures/asset.stub'; import { assetStub } from 'test/fixtures/asset.stub';
import { faceStub } from 'test/fixtures/face.stub'; import { faceStub } from 'test/fixtures/face.stub';
@ -33,24 +32,24 @@ import { newMediaRepositoryMock } from 'test/repositories/media.repository.mock'
import { newMoveRepositoryMock } from 'test/repositories/move.repository.mock'; import { newMoveRepositoryMock } from 'test/repositories/move.repository.mock';
import { newPersonRepositoryMock } from 'test/repositories/person.repository.mock'; import { newPersonRepositoryMock } from 'test/repositories/person.repository.mock';
import { newStorageRepositoryMock } from 'test/repositories/storage.repository.mock'; import { newStorageRepositoryMock } from 'test/repositories/storage.repository.mock';
import { newSystemConfigRepositoryMock } from 'test/repositories/system-config.repository.mock'; import { newSystemMetadataRepositoryMock } from 'test/repositories/system-metadata.repository.mock';
import { Mocked } from 'vitest'; import { Mocked } from 'vitest';
describe(MediaService.name, () => { describe(MediaService.name, () => {
let sut: MediaService; let sut: MediaService;
let assetMock: Mocked<IAssetRepository>; let assetMock: Mocked<IAssetRepository>;
let configMock: Mocked<ISystemConfigRepository>;
let jobMock: Mocked<IJobRepository>; let jobMock: Mocked<IJobRepository>;
let mediaMock: Mocked<IMediaRepository>; let mediaMock: Mocked<IMediaRepository>;
let moveMock: Mocked<IMoveRepository>; let moveMock: Mocked<IMoveRepository>;
let personMock: Mocked<IPersonRepository>; let personMock: Mocked<IPersonRepository>;
let storageMock: Mocked<IStorageRepository>; let storageMock: Mocked<IStorageRepository>;
let systemMock: Mocked<ISystemMetadataRepository>;
let cryptoMock: Mocked<ICryptoRepository>; let cryptoMock: Mocked<ICryptoRepository>;
let loggerMock: Mocked<ILoggerRepository>; let loggerMock: Mocked<ILoggerRepository>;
beforeEach(() => { beforeEach(() => {
assetMock = newAssetRepositoryMock(); assetMock = newAssetRepositoryMock();
configMock = newSystemConfigRepositoryMock(); systemMock = newSystemMetadataRepositoryMock();
jobMock = newJobRepositoryMock(); jobMock = newJobRepositoryMock();
mediaMock = newMediaRepositoryMock(); mediaMock = newMediaRepositoryMock();
moveMock = newMoveRepositoryMock(); moveMock = newMoveRepositoryMock();
@ -65,7 +64,7 @@ describe(MediaService.name, () => {
jobMock, jobMock,
mediaMock, mediaMock,
storageMock, storageMock,
configMock, systemMock,
moveMock, moveMock,
cryptoMock, cryptoMock,
loggerMock, loggerMock,
@ -235,7 +234,7 @@ describe(MediaService.name, () => {
}); });
it.each(Object.values(ImageFormat))('should generate a %s preview for an image when specified', async (format) => { 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 }]); systemMock.get.mockResolvedValue({ image: { previewFormat: format } });
assetMock.getByIds.mockResolvedValue([assetStub.image]); assetMock.getByIds.mockResolvedValue([assetStub.image]);
const previewPath = `upload/thumbs/user-id/as/se/asset-id-preview.${format}`; const previewPath = `upload/thumbs/user-id/as/se/asset-id-preview.${format}`;
@ -254,7 +253,7 @@ describe(MediaService.name, () => {
it('should delete previous preview if different path', async () => { it('should delete previous preview if different path', async () => {
const previousPreviewPath = assetStub.image.previewPath; const previousPreviewPath = assetStub.image.previewPath;
configMock.load.mockResolvedValue([{ key: SystemConfigKey.IMAGE_THUMBNAIL_FORMAT, value: ImageFormat.WEBP }]); systemMock.get.mockResolvedValue({ image: { thumbnailFormat: ImageFormat.WEBP } });
assetMock.getByIds.mockResolvedValue([assetStub.image]); assetMock.getByIds.mockResolvedValue([assetStub.image]);
await sut.handleGeneratePreview({ id: assetStub.image.id }); await sut.handleGeneratePreview({ id: assetStub.image.id });
@ -337,10 +336,9 @@ describe(MediaService.name, () => {
it('should always generate video thumbnail in one pass', async () => { it('should always generate video thumbnail in one pass', async () => {
mediaMock.probe.mockResolvedValue(probeStub.videoStreamHDR); mediaMock.probe.mockResolvedValue(probeStub.videoStreamHDR);
configMock.load.mockResolvedValue([ systemMock.get.mockResolvedValue({
{ key: SystemConfigKey.FFMPEG_TWO_PASS, value: true }, ffmpeg: { twoPass: true, maxBitrate: '5000k' },
{ key: SystemConfigKey.FFMPEG_MAX_BITRATE, value: '5000k' }, });
]);
assetMock.getByIds.mockResolvedValue([assetStub.video]); assetMock.getByIds.mockResolvedValue([assetStub.video]);
await sut.handleGeneratePreview({ id: assetStub.video.id }); await sut.handleGeneratePreview({ id: assetStub.video.id });
@ -385,7 +383,7 @@ describe(MediaService.name, () => {
it.each(Object.values(ImageFormat))( it.each(Object.values(ImageFormat))(
'should generate a %s thumbnail for an image when specified', 'should generate a %s thumbnail for an image when specified',
async (format) => { async (format) => {
configMock.load.mockResolvedValue([{ key: SystemConfigKey.IMAGE_THUMBNAIL_FORMAT, value: format }]); systemMock.get.mockResolvedValue({ image: { thumbnailFormat: format } });
assetMock.getByIds.mockResolvedValue([assetStub.image]); assetMock.getByIds.mockResolvedValue([assetStub.image]);
const thumbnailPath = `upload/thumbs/user-id/as/se/asset-id-thumbnail.${format}`; const thumbnailPath = `upload/thumbs/user-id/as/se/asset-id-thumbnail.${format}`;
@ -405,7 +403,7 @@ describe(MediaService.name, () => {
it('should delete previous thumbnail if different path', async () => { it('should delete previous thumbnail if different path', async () => {
const previousThumbnailPath = assetStub.image.thumbnailPath; const previousThumbnailPath = assetStub.image.thumbnailPath;
configMock.load.mockResolvedValue([{ key: SystemConfigKey.IMAGE_THUMBNAIL_FORMAT, value: ImageFormat.WEBP }]); systemMock.get.mockResolvedValue({ image: { thumbnailFormat: ImageFormat.WEBP } });
assetMock.getByIds.mockResolvedValue([assetStub.image]); assetMock.getByIds.mockResolvedValue([assetStub.image]);
await sut.handleGenerateThumbnail({ id: assetStub.image.id }); await sut.handleGenerateThumbnail({ id: assetStub.image.id });
@ -438,7 +436,7 @@ describe(MediaService.name, () => {
it('should extract embedded image if enabled and available', async () => { it('should extract embedded image if enabled and available', async () => {
mediaMock.extract.mockResolvedValue(true); mediaMock.extract.mockResolvedValue(true);
mediaMock.getImageDimensions.mockResolvedValue({ width: 3840, height: 2160 }); mediaMock.getImageDimensions.mockResolvedValue({ width: 3840, height: 2160 });
configMock.load.mockResolvedValue([{ key: SystemConfigKey.IMAGE_EXTRACT_EMBEDDED, value: true }]); systemMock.get.mockResolvedValue({ image: { extractEmbedded: true } });
assetMock.getByIds.mockResolvedValue([assetStub.imageDng]); assetMock.getByIds.mockResolvedValue([assetStub.imageDng]);
await sut.handleGenerateThumbnail({ id: assetStub.image.id }); await sut.handleGenerateThumbnail({ id: assetStub.image.id });
@ -463,7 +461,7 @@ describe(MediaService.name, () => {
it('should resize original image if embedded image is too small', async () => { it('should resize original image if embedded image is too small', async () => {
mediaMock.extract.mockResolvedValue(true); mediaMock.extract.mockResolvedValue(true);
mediaMock.getImageDimensions.mockResolvedValue({ width: 1000, height: 1000 }); mediaMock.getImageDimensions.mockResolvedValue({ width: 1000, height: 1000 });
configMock.load.mockResolvedValue([{ key: SystemConfigKey.IMAGE_EXTRACT_EMBEDDED, value: true }]); systemMock.get.mockResolvedValue({ image: { extractEmbedded: true } });
assetMock.getByIds.mockResolvedValue([assetStub.imageDng]); assetMock.getByIds.mockResolvedValue([assetStub.imageDng]);
await sut.handleGenerateThumbnail({ id: assetStub.image.id }); await sut.handleGenerateThumbnail({ id: assetStub.image.id });
@ -486,7 +484,7 @@ describe(MediaService.name, () => {
}); });
it('should resize original image if embedded image not found', async () => { it('should resize original image if embedded image not found', async () => {
configMock.load.mockResolvedValue([{ key: SystemConfigKey.IMAGE_EXTRACT_EMBEDDED, value: true }]); systemMock.get.mockResolvedValue({ image: { extractEmbedded: true } });
assetMock.getByIds.mockResolvedValue([assetStub.imageDng]); assetMock.getByIds.mockResolvedValue([assetStub.imageDng]);
await sut.handleGenerateThumbnail({ id: assetStub.image.id }); await sut.handleGenerateThumbnail({ id: assetStub.image.id });
@ -505,7 +503,7 @@ describe(MediaService.name, () => {
}); });
it('should resize original image if embedded image extraction is not enabled', async () => { it('should resize original image if embedded image extraction is not enabled', async () => {
configMock.load.mockResolvedValue([{ key: SystemConfigKey.IMAGE_EXTRACT_EMBEDDED, value: false }]); systemMock.get.mockResolvedValue({ image: { extractEmbedded: false } });
assetMock.getByIds.mockResolvedValue([assetStub.imageDng]); assetMock.getByIds.mockResolvedValue([assetStub.imageDng]);
await sut.handleGenerateThumbnail({ id: assetStub.image.id }); await sut.handleGenerateThumbnail({ id: assetStub.image.id });
@ -626,7 +624,7 @@ describe(MediaService.name, () => {
await sut.handleVideoConversion({ id: assetStub.video.id }); await sut.handleVideoConversion({ id: assetStub.video.id });
expect(mediaMock.probe).toHaveBeenCalledWith('/original/path.ext'); expect(mediaMock.probe).toHaveBeenCalledWith('/original/path.ext');
expect(configMock.load).toHaveBeenCalled(); expect(systemMock.get).toHaveBeenCalled();
expect(storageMock.mkdirSync).toHaveBeenCalled(); expect(storageMock.mkdirSync).toHaveBeenCalled();
expect(mediaMock.transcode).toHaveBeenCalledWith( expect(mediaMock.transcode).toHaveBeenCalledWith(
'/original/path.ext', '/original/path.ext',
@ -655,7 +653,7 @@ describe(MediaService.name, () => {
it('should transcode when set to all', async () => { it('should transcode when set to all', async () => {
mediaMock.probe.mockResolvedValue(probeStub.multipleVideoStreams); mediaMock.probe.mockResolvedValue(probeStub.multipleVideoStreams);
configMock.load.mockResolvedValue([{ key: SystemConfigKey.FFMPEG_TRANSCODE, value: TranscodePolicy.ALL }]); systemMock.get.mockResolvedValue({ ffmpeg: { transcode: TranscodePolicy.ALL } });
assetMock.getByIds.mockResolvedValue([assetStub.video]); assetMock.getByIds.mockResolvedValue([assetStub.video]);
await sut.handleVideoConversion({ id: assetStub.video.id }); await sut.handleVideoConversion({ id: assetStub.video.id });
expect(mediaMock.transcode).toHaveBeenCalledWith( expect(mediaMock.transcode).toHaveBeenCalledWith(
@ -671,7 +669,7 @@ describe(MediaService.name, () => {
it('should transcode when optimal and too big', async () => { it('should transcode when optimal and too big', async () => {
mediaMock.probe.mockResolvedValue(probeStub.videoStream2160p); mediaMock.probe.mockResolvedValue(probeStub.videoStream2160p);
configMock.load.mockResolvedValue([{ key: SystemConfigKey.FFMPEG_TRANSCODE, value: TranscodePolicy.OPTIMAL }]); systemMock.get.mockResolvedValue({ ffmpeg: { transcode: TranscodePolicy.OPTIMAL } });
await sut.handleVideoConversion({ id: assetStub.video.id }); await sut.handleVideoConversion({ id: assetStub.video.id });
expect(mediaMock.transcode).toHaveBeenCalledWith( expect(mediaMock.transcode).toHaveBeenCalledWith(
'/original/path.ext', '/original/path.ext',
@ -686,10 +684,7 @@ describe(MediaService.name, () => {
it('should transcode when policy Bitrate and bitrate higher than max bitrate', async () => { it('should transcode when policy Bitrate and bitrate higher than max bitrate', async () => {
mediaMock.probe.mockResolvedValue(probeStub.videoStream40Mbps); mediaMock.probe.mockResolvedValue(probeStub.videoStream40Mbps);
configMock.load.mockResolvedValue([ systemMock.get.mockResolvedValue({ ffmpeg: { transcode: TranscodePolicy.BITRATE, maxBitrate: '30M' } });
{ key: SystemConfigKey.FFMPEG_TRANSCODE, value: TranscodePolicy.BITRATE },
{ key: SystemConfigKey.FFMPEG_MAX_BITRATE, value: '30M' },
]);
await sut.handleVideoConversion({ id: assetStub.video.id }); await sut.handleVideoConversion({ id: assetStub.video.id });
expect(mediaMock.transcode).toHaveBeenCalledWith( expect(mediaMock.transcode).toHaveBeenCalledWith(
'/original/path.ext', '/original/path.ext',
@ -704,10 +699,7 @@ describe(MediaService.name, () => {
it('should not scale resolution if no target resolution', async () => { it('should not scale resolution if no target resolution', async () => {
mediaMock.probe.mockResolvedValue(probeStub.videoStream2160p); mediaMock.probe.mockResolvedValue(probeStub.videoStream2160p);
configMock.load.mockResolvedValue([ systemMock.get.mockResolvedValue({ ffmpeg: { transcode: TranscodePolicy.ALL, targetResolution: 'original' } });
{ key: SystemConfigKey.FFMPEG_TRANSCODE, value: TranscodePolicy.ALL },
{ key: SystemConfigKey.FFMPEG_TARGET_RESOLUTION, value: 'original' },
]);
await sut.handleVideoConversion({ id: assetStub.video.id }); await sut.handleVideoConversion({ id: assetStub.video.id });
expect(mediaMock.transcode).toHaveBeenCalledWith( expect(mediaMock.transcode).toHaveBeenCalledWith(
'/original/path.ext', '/original/path.ext',
@ -722,7 +714,7 @@ describe(MediaService.name, () => {
it('should scale horizontally when video is horizontal', async () => { it('should scale horizontally when video is horizontal', async () => {
mediaMock.probe.mockResolvedValue(probeStub.videoStream2160p); mediaMock.probe.mockResolvedValue(probeStub.videoStream2160p);
configMock.load.mockResolvedValue([{ key: SystemConfigKey.FFMPEG_TRANSCODE, value: TranscodePolicy.OPTIMAL }]); systemMock.get.mockResolvedValue({ ffmpeg: { transcode: TranscodePolicy.OPTIMAL } });
assetMock.getByIds.mockResolvedValue([assetStub.video]); assetMock.getByIds.mockResolvedValue([assetStub.video]);
await sut.handleVideoConversion({ id: assetStub.video.id }); await sut.handleVideoConversion({ id: assetStub.video.id });
expect(mediaMock.transcode).toHaveBeenCalledWith( expect(mediaMock.transcode).toHaveBeenCalledWith(
@ -738,7 +730,7 @@ describe(MediaService.name, () => {
it('should scale vertically when video is vertical', async () => { it('should scale vertically when video is vertical', async () => {
mediaMock.probe.mockResolvedValue(probeStub.videoStreamVertical2160p); mediaMock.probe.mockResolvedValue(probeStub.videoStreamVertical2160p);
configMock.load.mockResolvedValue([{ key: SystemConfigKey.FFMPEG_TRANSCODE, value: TranscodePolicy.OPTIMAL }]); systemMock.get.mockResolvedValue({ ffmpeg: { transcode: TranscodePolicy.OPTIMAL } });
assetMock.getByIds.mockResolvedValue([assetStub.video]); assetMock.getByIds.mockResolvedValue([assetStub.video]);
await sut.handleVideoConversion({ id: assetStub.video.id }); await sut.handleVideoConversion({ id: assetStub.video.id });
expect(mediaMock.transcode).toHaveBeenCalledWith( expect(mediaMock.transcode).toHaveBeenCalledWith(
@ -754,10 +746,7 @@ describe(MediaService.name, () => {
it('should always scale video if height is uneven', async () => { it('should always scale video if height is uneven', async () => {
mediaMock.probe.mockResolvedValue(probeStub.videoStreamOddHeight); mediaMock.probe.mockResolvedValue(probeStub.videoStreamOddHeight);
configMock.load.mockResolvedValue([ systemMock.get.mockResolvedValue({ ffmpeg: { transcode: TranscodePolicy.ALL, targetResolution: 'original' } });
{ key: SystemConfigKey.FFMPEG_TRANSCODE, value: TranscodePolicy.ALL },
{ key: SystemConfigKey.FFMPEG_TARGET_RESOLUTION, value: 'original' },
]);
assetMock.getByIds.mockResolvedValue([assetStub.video]); assetMock.getByIds.mockResolvedValue([assetStub.video]);
await sut.handleVideoConversion({ id: assetStub.video.id }); await sut.handleVideoConversion({ id: assetStub.video.id });
expect(mediaMock.transcode).toHaveBeenCalledWith( expect(mediaMock.transcode).toHaveBeenCalledWith(
@ -773,10 +762,7 @@ describe(MediaService.name, () => {
it('should always scale video if width is uneven', async () => { it('should always scale video if width is uneven', async () => {
mediaMock.probe.mockResolvedValue(probeStub.videoStreamOddWidth); mediaMock.probe.mockResolvedValue(probeStub.videoStreamOddWidth);
configMock.load.mockResolvedValue([ systemMock.get.mockResolvedValue({ ffmpeg: { transcode: TranscodePolicy.ALL, targetResolution: 'original' } });
{ key: SystemConfigKey.FFMPEG_TRANSCODE, value: TranscodePolicy.ALL },
{ key: SystemConfigKey.FFMPEG_TARGET_RESOLUTION, value: 'original' },
]);
assetMock.getByIds.mockResolvedValue([assetStub.video]); assetMock.getByIds.mockResolvedValue([assetStub.video]);
await sut.handleVideoConversion({ id: assetStub.video.id }); await sut.handleVideoConversion({ id: assetStub.video.id });
expect(mediaMock.transcode).toHaveBeenCalledWith( expect(mediaMock.transcode).toHaveBeenCalledWith(
@ -792,10 +778,9 @@ describe(MediaService.name, () => {
it('should copy video stream when video matches target', async () => { it('should copy video stream when video matches target', async () => {
mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer); mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer);
configMock.load.mockResolvedValue([ systemMock.get.mockResolvedValue({
{ key: SystemConfigKey.FFMPEG_TARGET_VIDEO_CODEC, value: VideoCodec.HEVC }, ffmpeg: { targetVideoCodec: VideoCodec.HEVC, acceptedAudioCodecs: [AudioCodec.AAC] },
{ key: SystemConfigKey.FFMPEG_ACCEPTED_AUDIO_CODECS, value: [AudioCodec.AAC] }, });
]);
assetMock.getByIds.mockResolvedValue([assetStub.video]); assetMock.getByIds.mockResolvedValue([assetStub.video]);
await sut.handleVideoConversion({ id: assetStub.video.id }); await sut.handleVideoConversion({ id: assetStub.video.id });
expect(mediaMock.transcode).toHaveBeenCalledWith( expect(mediaMock.transcode).toHaveBeenCalledWith(
@ -811,11 +796,13 @@ describe(MediaService.name, () => {
it('should not include hevc tag when target is hevc and video stream is copied from a different codec', async () => { 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); mediaMock.probe.mockResolvedValue(probeStub.videoStreamH264);
configMock.load.mockResolvedValue([ systemMock.get.mockResolvedValue({
{ key: SystemConfigKey.FFMPEG_TARGET_VIDEO_CODEC, value: VideoCodec.HEVC }, ffmpeg: {
{ key: SystemConfigKey.FFMPEG_ACCEPTED_VIDEO_CODECS, value: [VideoCodec.H264, VideoCodec.HEVC] }, targetVideoCodec: VideoCodec.HEVC,
{ key: SystemConfigKey.FFMPEG_ACCEPTED_AUDIO_CODECS, value: [AudioCodec.AAC] }, acceptedVideoCodecs: [VideoCodec.H264, VideoCodec.HEVC],
]); acceptedAudioCodecs: [AudioCodec.AAC],
},
});
assetMock.getByIds.mockResolvedValue([assetStub.video]); assetMock.getByIds.mockResolvedValue([assetStub.video]);
await sut.handleVideoConversion({ id: assetStub.video.id }); await sut.handleVideoConversion({ id: assetStub.video.id });
expect(mediaMock.transcode).toHaveBeenCalledWith( expect(mediaMock.transcode).toHaveBeenCalledWith(
@ -831,11 +818,13 @@ describe(MediaService.name, () => {
it('should include hevc tag when target is hevc and copying hevc video stream', async () => { it('should include hevc tag when target is hevc and copying hevc video stream', async () => {
mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer); mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer);
configMock.load.mockResolvedValue([ systemMock.get.mockResolvedValue({
{ key: SystemConfigKey.FFMPEG_TARGET_VIDEO_CODEC, value: VideoCodec.HEVC }, ffmpeg: {
{ key: SystemConfigKey.FFMPEG_ACCEPTED_VIDEO_CODECS, value: [VideoCodec.H264, VideoCodec.HEVC] }, targetVideoCodec: VideoCodec.HEVC,
{ key: SystemConfigKey.FFMPEG_ACCEPTED_AUDIO_CODECS, value: [AudioCodec.AAC] }, acceptedVideoCodecs: [VideoCodec.H264, VideoCodec.HEVC],
]); acceptedAudioCodecs: [AudioCodec.AAC],
},
});
assetMock.getByIds.mockResolvedValue([assetStub.video]); assetMock.getByIds.mockResolvedValue([assetStub.video]);
await sut.handleVideoConversion({ id: assetStub.video.id }); await sut.handleVideoConversion({ id: assetStub.video.id });
expect(mediaMock.transcode).toHaveBeenCalledWith( expect(mediaMock.transcode).toHaveBeenCalledWith(
@ -851,7 +840,7 @@ describe(MediaService.name, () => {
it('should copy audio stream when audio matches target', async () => { it('should copy audio stream when audio matches target', async () => {
mediaMock.probe.mockResolvedValue(probeStub.audioStreamAac); mediaMock.probe.mockResolvedValue(probeStub.audioStreamAac);
configMock.load.mockResolvedValue([{ key: SystemConfigKey.FFMPEG_TRANSCODE, value: TranscodePolicy.OPTIMAL }]); systemMock.get.mockResolvedValue({ ffmpeg: { transcode: TranscodePolicy.OPTIMAL } });
assetMock.getByIds.mockResolvedValue([assetStub.video]); assetMock.getByIds.mockResolvedValue([assetStub.video]);
await sut.handleVideoConversion({ id: assetStub.video.id }); await sut.handleVideoConversion({ id: assetStub.video.id });
expect(mediaMock.transcode).toHaveBeenCalledWith( expect(mediaMock.transcode).toHaveBeenCalledWith(
@ -867,7 +856,7 @@ describe(MediaService.name, () => {
it('should throw an exception if transcode value is invalid', async () => { it('should throw an exception if transcode value is invalid', async () => {
mediaMock.probe.mockResolvedValue(probeStub.videoStream2160p); mediaMock.probe.mockResolvedValue(probeStub.videoStream2160p);
configMock.load.mockResolvedValue([{ key: SystemConfigKey.FFMPEG_TRANSCODE, value: 'invalid' }]); systemMock.get.mockResolvedValue({ ffmpeg: { transcode: 'invalid' as any } });
await expect(sut.handleVideoConversion({ id: assetStub.video.id })).rejects.toThrow(); await expect(sut.handleVideoConversion({ id: assetStub.video.id })).rejects.toThrow();
expect(mediaMock.transcode).not.toHaveBeenCalled(); expect(mediaMock.transcode).not.toHaveBeenCalled();
@ -875,7 +864,7 @@ describe(MediaService.name, () => {
it('should not transcode if transcoding is disabled', async () => { it('should not transcode if transcoding is disabled', async () => {
mediaMock.probe.mockResolvedValue(probeStub.videoStream2160p); mediaMock.probe.mockResolvedValue(probeStub.videoStream2160p);
configMock.load.mockResolvedValue([{ key: SystemConfigKey.FFMPEG_TRANSCODE, value: TranscodePolicy.DISABLED }]); systemMock.get.mockResolvedValue({ ffmpeg: { transcode: TranscodePolicy.DISABLED } });
assetMock.getByIds.mockResolvedValue([assetStub.video]); assetMock.getByIds.mockResolvedValue([assetStub.video]);
await sut.handleVideoConversion({ id: assetStub.video.id }); await sut.handleVideoConversion({ id: assetStub.video.id });
expect(mediaMock.transcode).not.toHaveBeenCalled(); expect(mediaMock.transcode).not.toHaveBeenCalled();
@ -883,7 +872,7 @@ describe(MediaService.name, () => {
it('should not transcode if target codec is invalid', async () => { it('should not transcode if target codec is invalid', async () => {
mediaMock.probe.mockResolvedValue(probeStub.videoStream2160p); mediaMock.probe.mockResolvedValue(probeStub.videoStream2160p);
configMock.load.mockResolvedValue([{ key: SystemConfigKey.FFMPEG_TARGET_VIDEO_CODEC, value: 'invalid' }]); systemMock.get.mockResolvedValue({ ffmpeg: { targetVideoCodec: 'invalid' as any } });
assetMock.getByIds.mockResolvedValue([assetStub.video]); assetMock.getByIds.mockResolvedValue([assetStub.video]);
await sut.handleVideoConversion({ id: assetStub.video.id }); await sut.handleVideoConversion({ id: assetStub.video.id });
expect(mediaMock.transcode).not.toHaveBeenCalled(); expect(mediaMock.transcode).not.toHaveBeenCalled();
@ -892,7 +881,7 @@ describe(MediaService.name, () => {
it('should delete existing transcode if current policy does not require transcoding', async () => { it('should delete existing transcode if current policy does not require transcoding', async () => {
const asset = assetStub.hasEncodedVideo; const asset = assetStub.hasEncodedVideo;
mediaMock.probe.mockResolvedValue(probeStub.videoStream2160p); mediaMock.probe.mockResolvedValue(probeStub.videoStream2160p);
configMock.load.mockResolvedValue([{ key: SystemConfigKey.FFMPEG_TRANSCODE, value: TranscodePolicy.DISABLED }]); systemMock.get.mockResolvedValue({ ffmpeg: { transcode: TranscodePolicy.DISABLED } });
assetMock.getByIds.mockResolvedValue([asset]); assetMock.getByIds.mockResolvedValue([asset]);
await sut.handleVideoConversion({ id: asset.id }); await sut.handleVideoConversion({ id: asset.id });
@ -906,7 +895,7 @@ describe(MediaService.name, () => {
it('should set max bitrate if above 0', async () => { it('should set max bitrate if above 0', async () => {
mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer); mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer);
configMock.load.mockResolvedValue([{ key: SystemConfigKey.FFMPEG_MAX_BITRATE, value: '4500k' }]); systemMock.get.mockResolvedValue({ ffmpeg: { maxBitrate: '4500k' } });
assetMock.getByIds.mockResolvedValue([assetStub.video]); assetMock.getByIds.mockResolvedValue([assetStub.video]);
await sut.handleVideoConversion({ id: assetStub.video.id }); await sut.handleVideoConversion({ id: assetStub.video.id });
expect(mediaMock.transcode).toHaveBeenCalledWith( expect(mediaMock.transcode).toHaveBeenCalledWith(
@ -922,10 +911,7 @@ describe(MediaService.name, () => {
it('should transcode in two passes for h264/h265 when enabled and max bitrate is above 0', async () => { it('should transcode in two passes for h264/h265 when enabled and max bitrate is above 0', async () => {
mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer); mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer);
configMock.load.mockResolvedValue([ systemMock.get.mockResolvedValue({ ffmpeg: { twoPass: true, maxBitrate: '4500k' } });
{ key: SystemConfigKey.FFMPEG_MAX_BITRATE, value: '4500k' },
{ key: SystemConfigKey.FFMPEG_TWO_PASS, value: true },
]);
assetMock.getByIds.mockResolvedValue([assetStub.video]); assetMock.getByIds.mockResolvedValue([assetStub.video]);
await sut.handleVideoConversion({ id: assetStub.video.id }); await sut.handleVideoConversion({ id: assetStub.video.id });
expect(mediaMock.transcode).toHaveBeenCalledWith( expect(mediaMock.transcode).toHaveBeenCalledWith(
@ -941,7 +927,7 @@ describe(MediaService.name, () => {
it('should fallback to one pass for h264/h265 if two-pass is enabled but no max bitrate is set', async () => { it('should fallback to one pass for h264/h265 if two-pass is enabled but no max bitrate is set', async () => {
mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer); mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer);
configMock.load.mockResolvedValue([{ key: SystemConfigKey.FFMPEG_TWO_PASS, value: true }]); systemMock.get.mockResolvedValue({ ffmpeg: { twoPass: true } });
assetMock.getByIds.mockResolvedValue([assetStub.video]); assetMock.getByIds.mockResolvedValue([assetStub.video]);
await sut.handleVideoConversion({ id: assetStub.video.id }); await sut.handleVideoConversion({ id: assetStub.video.id });
expect(mediaMock.transcode).toHaveBeenCalledWith( expect(mediaMock.transcode).toHaveBeenCalledWith(
@ -957,11 +943,13 @@ describe(MediaService.name, () => {
it('should transcode by bitrate in two passes for vp9 when two pass mode and max bitrate are enabled', async () => { it('should transcode by bitrate in two passes for vp9 when two pass mode and max bitrate are enabled', async () => {
mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer); mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer);
configMock.load.mockResolvedValue([ systemMock.get.mockResolvedValue({
{ key: SystemConfigKey.FFMPEG_MAX_BITRATE, value: '4500k' }, ffmpeg: {
{ key: SystemConfigKey.FFMPEG_TWO_PASS, value: true }, maxBitrate: '4500k',
{ key: SystemConfigKey.FFMPEG_TARGET_VIDEO_CODEC, value: VideoCodec.VP9 }, twoPass: true,
]); targetVideoCodec: VideoCodec.VP9,
},
});
assetMock.getByIds.mockResolvedValue([assetStub.video]); assetMock.getByIds.mockResolvedValue([assetStub.video]);
await sut.handleVideoConversion({ id: assetStub.video.id }); await sut.handleVideoConversion({ id: assetStub.video.id });
expect(mediaMock.transcode).toHaveBeenCalledWith( expect(mediaMock.transcode).toHaveBeenCalledWith(
@ -977,11 +965,13 @@ describe(MediaService.name, () => {
it('should transcode by crf in two passes for vp9 when two pass mode is enabled and max bitrate is disabled', async () => { it('should transcode by crf in two passes for vp9 when two pass mode is enabled and max bitrate is disabled', async () => {
mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer); mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer);
configMock.load.mockResolvedValue([ systemMock.get.mockResolvedValue({
{ key: SystemConfigKey.FFMPEG_MAX_BITRATE, value: '0' }, ffmpeg: {
{ key: SystemConfigKey.FFMPEG_TWO_PASS, value: true }, maxBitrate: '0',
{ key: SystemConfigKey.FFMPEG_TARGET_VIDEO_CODEC, value: VideoCodec.VP9 }, twoPass: true,
]); targetVideoCodec: VideoCodec.VP9,
},
});
assetMock.getByIds.mockResolvedValue([assetStub.video]); assetMock.getByIds.mockResolvedValue([assetStub.video]);
await sut.handleVideoConversion({ id: assetStub.video.id }); await sut.handleVideoConversion({ id: assetStub.video.id });
expect(mediaMock.transcode).toHaveBeenCalledWith( expect(mediaMock.transcode).toHaveBeenCalledWith(
@ -997,10 +987,7 @@ describe(MediaService.name, () => {
it('should configure preset for vp9', async () => { it('should configure preset for vp9', async () => {
mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer); mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer);
configMock.load.mockResolvedValue([ systemMock.get.mockResolvedValue({ ffmpeg: { targetVideoCodec: VideoCodec.VP9, preset: 'slow' } });
{ key: SystemConfigKey.FFMPEG_TARGET_VIDEO_CODEC, value: VideoCodec.VP9 },
{ key: SystemConfigKey.FFMPEG_PRESET, value: 'slow' },
]);
assetMock.getByIds.mockResolvedValue([assetStub.video]); assetMock.getByIds.mockResolvedValue([assetStub.video]);
await sut.handleVideoConversion({ id: assetStub.video.id }); await sut.handleVideoConversion({ id: assetStub.video.id });
expect(mediaMock.transcode).toHaveBeenCalledWith( expect(mediaMock.transcode).toHaveBeenCalledWith(
@ -1016,10 +1003,7 @@ describe(MediaService.name, () => {
it('should not configure preset for vp9 if invalid', async () => { it('should not configure preset for vp9 if invalid', async () => {
mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer); mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer);
configMock.load.mockResolvedValue([ systemMock.get.mockResolvedValue({ ffmpeg: { preset: 'invalid', targetVideoCodec: VideoCodec.VP9 } });
{ key: SystemConfigKey.FFMPEG_TARGET_VIDEO_CODEC, value: VideoCodec.VP9 },
{ key: SystemConfigKey.FFMPEG_PRESET, value: 'invalid' },
]);
assetMock.getByIds.mockResolvedValue([assetStub.video]); assetMock.getByIds.mockResolvedValue([assetStub.video]);
await sut.handleVideoConversion({ id: assetStub.video.id }); await sut.handleVideoConversion({ id: assetStub.video.id });
expect(mediaMock.transcode).toHaveBeenCalledWith( expect(mediaMock.transcode).toHaveBeenCalledWith(
@ -1035,10 +1019,7 @@ describe(MediaService.name, () => {
it('should configure threads if above 0', async () => { it('should configure threads if above 0', async () => {
mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer); mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer);
configMock.load.mockResolvedValue([ systemMock.get.mockResolvedValue({ ffmpeg: { targetVideoCodec: VideoCodec.VP9, threads: 2 } });
{ key: SystemConfigKey.FFMPEG_TARGET_VIDEO_CODEC, value: VideoCodec.VP9 },
{ key: SystemConfigKey.FFMPEG_THREADS, value: 2 },
]);
assetMock.getByIds.mockResolvedValue([assetStub.video]); assetMock.getByIds.mockResolvedValue([assetStub.video]);
await sut.handleVideoConversion({ id: assetStub.video.id }); await sut.handleVideoConversion({ id: assetStub.video.id });
expect(mediaMock.transcode).toHaveBeenCalledWith( expect(mediaMock.transcode).toHaveBeenCalledWith(
@ -1054,7 +1035,7 @@ describe(MediaService.name, () => {
it('should disable thread pooling for h264 if thread limit is 1', async () => { it('should disable thread pooling for h264 if thread limit is 1', async () => {
mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer); mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer);
configMock.load.mockResolvedValue([{ key: SystemConfigKey.FFMPEG_THREADS, value: 1 }]); systemMock.get.mockResolvedValue({ ffmpeg: { threads: 1 } });
assetMock.getByIds.mockResolvedValue([assetStub.video]); assetMock.getByIds.mockResolvedValue([assetStub.video]);
await sut.handleVideoConversion({ id: assetStub.video.id }); await sut.handleVideoConversion({ id: assetStub.video.id });
expect(mediaMock.transcode).toHaveBeenCalledWith( expect(mediaMock.transcode).toHaveBeenCalledWith(
@ -1070,7 +1051,7 @@ describe(MediaService.name, () => {
it('should omit thread flags for h264 if thread limit is at or below 0', async () => { it('should omit thread flags for h264 if thread limit is at or below 0', async () => {
mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer); mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer);
configMock.load.mockResolvedValue([{ key: SystemConfigKey.FFMPEG_THREADS, value: 0 }]); systemMock.get.mockResolvedValue({ ffmpeg: { threads: 0 } });
assetMock.getByIds.mockResolvedValue([assetStub.video]); assetMock.getByIds.mockResolvedValue([assetStub.video]);
await sut.handleVideoConversion({ id: assetStub.video.id }); await sut.handleVideoConversion({ id: assetStub.video.id });
expect(mediaMock.transcode).toHaveBeenCalledWith( expect(mediaMock.transcode).toHaveBeenCalledWith(
@ -1086,10 +1067,7 @@ describe(MediaService.name, () => {
it('should disable thread pooling for hevc if thread limit is 1', async () => { it('should disable thread pooling for hevc if thread limit is 1', async () => {
mediaMock.probe.mockResolvedValue(probeStub.videoStreamVp9); mediaMock.probe.mockResolvedValue(probeStub.videoStreamVp9);
configMock.load.mockResolvedValue([ systemMock.get.mockResolvedValue({ ffmpeg: { threads: 1, targetVideoCodec: VideoCodec.HEVC } });
{ key: SystemConfigKey.FFMPEG_THREADS, value: 1 },
{ key: SystemConfigKey.FFMPEG_TARGET_VIDEO_CODEC, value: VideoCodec.HEVC },
]);
assetMock.getByIds.mockResolvedValue([assetStub.video]); assetMock.getByIds.mockResolvedValue([assetStub.video]);
await sut.handleVideoConversion({ id: assetStub.video.id }); await sut.handleVideoConversion({ id: assetStub.video.id });
expect(mediaMock.transcode).toHaveBeenCalledWith( expect(mediaMock.transcode).toHaveBeenCalledWith(
@ -1105,10 +1083,7 @@ describe(MediaService.name, () => {
it('should omit thread flags for hevc if thread limit is at or below 0', async () => { it('should omit thread flags for hevc if thread limit is at or below 0', async () => {
mediaMock.probe.mockResolvedValue(probeStub.videoStreamVp9); mediaMock.probe.mockResolvedValue(probeStub.videoStreamVp9);
configMock.load.mockResolvedValue([ systemMock.get.mockResolvedValue({ ffmpeg: { threads: 0, targetVideoCodec: VideoCodec.HEVC } });
{ key: SystemConfigKey.FFMPEG_THREADS, value: 0 },
{ key: SystemConfigKey.FFMPEG_TARGET_VIDEO_CODEC, value: VideoCodec.HEVC },
]);
assetMock.getByIds.mockResolvedValue([assetStub.video]); assetMock.getByIds.mockResolvedValue([assetStub.video]);
await sut.handleVideoConversion({ id: assetStub.video.id }); await sut.handleVideoConversion({ id: assetStub.video.id });
expect(mediaMock.transcode).toHaveBeenCalledWith( expect(mediaMock.transcode).toHaveBeenCalledWith(
@ -1124,7 +1099,7 @@ describe(MediaService.name, () => {
it('should use av1 if specified', async () => { it('should use av1 if specified', async () => {
mediaMock.probe.mockResolvedValue(probeStub.videoStreamVp9); mediaMock.probe.mockResolvedValue(probeStub.videoStreamVp9);
configMock.load.mockResolvedValue([{ key: SystemConfigKey.FFMPEG_TARGET_VIDEO_CODEC, value: VideoCodec.AV1 }]); systemMock.get.mockResolvedValue({ ffmpeg: { targetVideoCodec: VideoCodec.AV1 } });
assetMock.getByIds.mockResolvedValue([assetStub.video]); assetMock.getByIds.mockResolvedValue([assetStub.video]);
await sut.handleVideoConversion({ id: assetStub.video.id }); await sut.handleVideoConversion({ id: assetStub.video.id });
expect(mediaMock.transcode).toHaveBeenCalledWith( expect(mediaMock.transcode).toHaveBeenCalledWith(
@ -1150,10 +1125,7 @@ describe(MediaService.name, () => {
it('should map `veryslow` preset to 4 for av1', async () => { it('should map `veryslow` preset to 4 for av1', async () => {
mediaMock.probe.mockResolvedValue(probeStub.videoStreamVp9); mediaMock.probe.mockResolvedValue(probeStub.videoStreamVp9);
configMock.load.mockResolvedValue([ systemMock.get.mockResolvedValue({ ffmpeg: { targetVideoCodec: VideoCodec.AV1, preset: 'veryslow' } });
{ key: SystemConfigKey.FFMPEG_TARGET_VIDEO_CODEC, value: VideoCodec.AV1 },
{ key: SystemConfigKey.FFMPEG_PRESET, value: 'veryslow' },
]);
assetMock.getByIds.mockResolvedValue([assetStub.video]); assetMock.getByIds.mockResolvedValue([assetStub.video]);
await sut.handleVideoConversion({ id: assetStub.video.id }); await sut.handleVideoConversion({ id: assetStub.video.id });
expect(mediaMock.transcode).toHaveBeenCalledWith( expect(mediaMock.transcode).toHaveBeenCalledWith(
@ -1169,10 +1141,7 @@ describe(MediaService.name, () => {
it('should set max bitrate for av1 if specified', async () => { it('should set max bitrate for av1 if specified', async () => {
mediaMock.probe.mockResolvedValue(probeStub.videoStreamVp9); mediaMock.probe.mockResolvedValue(probeStub.videoStreamVp9);
configMock.load.mockResolvedValue([ systemMock.get.mockResolvedValue({ ffmpeg: { targetVideoCodec: VideoCodec.AV1, maxBitrate: '2M' } });
{ key: SystemConfigKey.FFMPEG_TARGET_VIDEO_CODEC, value: VideoCodec.AV1 },
{ key: SystemConfigKey.FFMPEG_MAX_BITRATE, value: '2M' },
]);
assetMock.getByIds.mockResolvedValue([assetStub.video]); assetMock.getByIds.mockResolvedValue([assetStub.video]);
await sut.handleVideoConversion({ id: assetStub.video.id }); await sut.handleVideoConversion({ id: assetStub.video.id });
expect(mediaMock.transcode).toHaveBeenCalledWith( expect(mediaMock.transcode).toHaveBeenCalledWith(
@ -1188,10 +1157,7 @@ describe(MediaService.name, () => {
it('should set threads for av1 if specified', async () => { it('should set threads for av1 if specified', async () => {
mediaMock.probe.mockResolvedValue(probeStub.videoStreamVp9); mediaMock.probe.mockResolvedValue(probeStub.videoStreamVp9);
configMock.load.mockResolvedValue([ systemMock.get.mockResolvedValue({ ffmpeg: { targetVideoCodec: VideoCodec.AV1, threads: 4 } });
{ key: SystemConfigKey.FFMPEG_TARGET_VIDEO_CODEC, value: VideoCodec.AV1 },
{ key: SystemConfigKey.FFMPEG_THREADS, value: 4 },
]);
assetMock.getByIds.mockResolvedValue([assetStub.video]); assetMock.getByIds.mockResolvedValue([assetStub.video]);
await sut.handleVideoConversion({ id: assetStub.video.id }); await sut.handleVideoConversion({ id: assetStub.video.id });
expect(mediaMock.transcode).toHaveBeenCalledWith( expect(mediaMock.transcode).toHaveBeenCalledWith(
@ -1207,11 +1173,7 @@ describe(MediaService.name, () => {
it('should set both bitrate and threads for av1 if specified', async () => { it('should set both bitrate and threads for av1 if specified', async () => {
mediaMock.probe.mockResolvedValue(probeStub.videoStreamVp9); mediaMock.probe.mockResolvedValue(probeStub.videoStreamVp9);
configMock.load.mockResolvedValue([ systemMock.get.mockResolvedValue({ ffmpeg: { targetVideoCodec: VideoCodec.AV1, threads: 4, maxBitrate: '2M' } });
{ 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]); assetMock.getByIds.mockResolvedValue([assetStub.video]);
await sut.handleVideoConversion({ id: assetStub.video.id }); await sut.handleVideoConversion({ id: assetStub.video.id });
expect(mediaMock.transcode).toHaveBeenCalledWith( expect(mediaMock.transcode).toHaveBeenCalledWith(
@ -1227,11 +1189,13 @@ describe(MediaService.name, () => {
it('should skip transcoding for audioless videos with optimal policy if video codec is correct', async () => { it('should skip transcoding for audioless videos with optimal policy if video codec is correct', async () => {
mediaMock.probe.mockResolvedValue(probeStub.noAudioStreams); mediaMock.probe.mockResolvedValue(probeStub.noAudioStreams);
configMock.load.mockResolvedValue([ systemMock.get.mockResolvedValue({
{ key: SystemConfigKey.FFMPEG_TARGET_VIDEO_CODEC, value: VideoCodec.HEVC }, ffmpeg: {
{ key: SystemConfigKey.FFMPEG_TRANSCODE, value: TranscodePolicy.OPTIMAL }, targetVideoCodec: VideoCodec.HEVC,
{ key: SystemConfigKey.FFMPEG_TARGET_RESOLUTION, value: '1080p' }, transcode: TranscodePolicy.OPTIMAL,
]); targetResolution: '1080p',
},
});
assetMock.getByIds.mockResolvedValue([assetStub.video]); assetMock.getByIds.mockResolvedValue([assetStub.video]);
await sut.handleVideoConversion({ id: assetStub.video.id }); await sut.handleVideoConversion({ id: assetStub.video.id });
expect(mediaMock.transcode).not.toHaveBeenCalled(); expect(mediaMock.transcode).not.toHaveBeenCalled();
@ -1239,10 +1203,7 @@ describe(MediaService.name, () => {
it('should fail if hwaccel is enabled for an unsupported codec', async () => { it('should fail if hwaccel is enabled for an unsupported codec', async () => {
mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer); mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer);
configMock.load.mockResolvedValue([ systemMock.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.NVENC, targetVideoCodec: VideoCodec.VP9 } });
{ key: SystemConfigKey.FFMPEG_ACCEL, value: TranscodeHWAccel.NVENC },
{ key: SystemConfigKey.FFMPEG_TARGET_VIDEO_CODEC, value: VideoCodec.VP9 },
]);
assetMock.getByIds.mockResolvedValue([assetStub.video]); assetMock.getByIds.mockResolvedValue([assetStub.video]);
await expect(sut.handleVideoConversion({ id: assetStub.video.id })).resolves.toBe(JobStatus.FAILED); await expect(sut.handleVideoConversion({ id: assetStub.video.id })).resolves.toBe(JobStatus.FAILED);
expect(mediaMock.transcode).not.toHaveBeenCalled(); expect(mediaMock.transcode).not.toHaveBeenCalled();
@ -1250,7 +1211,7 @@ describe(MediaService.name, () => {
it('should fail if hwaccel option is invalid', async () => { it('should fail if hwaccel option is invalid', async () => {
mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer); mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer);
configMock.load.mockResolvedValue([{ key: SystemConfigKey.FFMPEG_ACCEL, value: 'invalid' }]); systemMock.get.mockResolvedValue({ ffmpeg: { accel: 'invalid' as any } });
assetMock.getByIds.mockResolvedValue([assetStub.video]); assetMock.getByIds.mockResolvedValue([assetStub.video]);
await expect(sut.handleVideoConversion({ id: assetStub.video.id })).resolves.toBe(JobStatus.FAILED); await expect(sut.handleVideoConversion({ id: assetStub.video.id })).resolves.toBe(JobStatus.FAILED);
expect(mediaMock.transcode).not.toHaveBeenCalled(); expect(mediaMock.transcode).not.toHaveBeenCalled();
@ -1258,7 +1219,7 @@ describe(MediaService.name, () => {
it('should set options for nvenc', async () => { it('should set options for nvenc', async () => {
mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer); mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer);
configMock.load.mockResolvedValue([{ key: SystemConfigKey.FFMPEG_ACCEL, value: TranscodeHWAccel.NVENC }]); systemMock.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.NVENC } });
assetMock.getByIds.mockResolvedValue([assetStub.video]); assetMock.getByIds.mockResolvedValue([assetStub.video]);
await sut.handleVideoConversion({ id: assetStub.video.id }); await sut.handleVideoConversion({ id: assetStub.video.id });
expect(mediaMock.transcode).toHaveBeenCalledWith( expect(mediaMock.transcode).toHaveBeenCalledWith(
@ -1290,11 +1251,13 @@ describe(MediaService.name, () => {
it('should set two pass options for nvenc when enabled', async () => { it('should set two pass options for nvenc when enabled', async () => {
mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer); mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer);
configMock.load.mockResolvedValue([ systemMock.get.mockResolvedValue({
{ key: SystemConfigKey.FFMPEG_ACCEL, value: TranscodeHWAccel.NVENC }, ffmpeg: {
{ key: SystemConfigKey.FFMPEG_MAX_BITRATE, value: '10000k' }, accel: TranscodeHWAccel.NVENC,
{ key: SystemConfigKey.FFMPEG_TWO_PASS, value: true }, maxBitrate: '10000k',
]); twoPass: true,
},
});
assetMock.getByIds.mockResolvedValue([assetStub.video]); assetMock.getByIds.mockResolvedValue([assetStub.video]);
await sut.handleVideoConversion({ id: assetStub.video.id }); await sut.handleVideoConversion({ id: assetStub.video.id });
expect(mediaMock.transcode).toHaveBeenCalledWith( expect(mediaMock.transcode).toHaveBeenCalledWith(
@ -1310,10 +1273,7 @@ describe(MediaService.name, () => {
it('should set vbr options for nvenc when max bitrate is enabled', async () => { it('should set vbr options for nvenc when max bitrate is enabled', async () => {
mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer); mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer);
configMock.load.mockResolvedValue([ systemMock.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.NVENC, maxBitrate: '10000k' } });
{ key: SystemConfigKey.FFMPEG_ACCEL, value: TranscodeHWAccel.NVENC },
{ key: SystemConfigKey.FFMPEG_MAX_BITRATE, value: '10000k' },
]);
assetMock.getByIds.mockResolvedValue([assetStub.video]); assetMock.getByIds.mockResolvedValue([assetStub.video]);
await sut.handleVideoConversion({ id: assetStub.video.id }); await sut.handleVideoConversion({ id: assetStub.video.id });
expect(mediaMock.transcode).toHaveBeenCalledWith( expect(mediaMock.transcode).toHaveBeenCalledWith(
@ -1329,10 +1289,7 @@ describe(MediaService.name, () => {
it('should set cq options for nvenc when max bitrate is disabled', async () => { it('should set cq options for nvenc when max bitrate is disabled', async () => {
mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer); mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer);
configMock.load.mockResolvedValue([ systemMock.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.NVENC, maxBitrate: '10000k' } });
{ key: SystemConfigKey.FFMPEG_ACCEL, value: TranscodeHWAccel.NVENC },
{ key: SystemConfigKey.FFMPEG_MAX_BITRATE, value: '10000k' },
]);
assetMock.getByIds.mockResolvedValue([assetStub.video]); assetMock.getByIds.mockResolvedValue([assetStub.video]);
await sut.handleVideoConversion({ id: assetStub.video.id }); await sut.handleVideoConversion({ id: assetStub.video.id });
expect(mediaMock.transcode).toHaveBeenCalledWith( expect(mediaMock.transcode).toHaveBeenCalledWith(
@ -1348,10 +1305,7 @@ describe(MediaService.name, () => {
it('should omit preset for nvenc if invalid', async () => { it('should omit preset for nvenc if invalid', async () => {
mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer); mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer);
configMock.load.mockResolvedValue([ systemMock.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.NVENC, preset: 'invalid' } });
{ key: SystemConfigKey.FFMPEG_ACCEL, value: TranscodeHWAccel.NVENC },
{ key: SystemConfigKey.FFMPEG_PRESET, value: 'invalid' },
]);
assetMock.getByIds.mockResolvedValue([assetStub.video]); assetMock.getByIds.mockResolvedValue([assetStub.video]);
await sut.handleVideoConversion({ id: assetStub.video.id }); await sut.handleVideoConversion({ id: assetStub.video.id });
expect(mediaMock.transcode).toHaveBeenCalledWith( expect(mediaMock.transcode).toHaveBeenCalledWith(
@ -1367,7 +1321,7 @@ describe(MediaService.name, () => {
it('should ignore two pass for nvenc if max bitrate is disabled', async () => { it('should ignore two pass for nvenc if max bitrate is disabled', async () => {
mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer); mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer);
configMock.load.mockResolvedValue([{ key: SystemConfigKey.FFMPEG_ACCEL, value: TranscodeHWAccel.NVENC }]); systemMock.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.NVENC } });
assetMock.getByIds.mockResolvedValue([assetStub.video]); assetMock.getByIds.mockResolvedValue([assetStub.video]);
await sut.handleVideoConversion({ id: assetStub.video.id }); await sut.handleVideoConversion({ id: assetStub.video.id });
expect(mediaMock.transcode).toHaveBeenCalledWith( expect(mediaMock.transcode).toHaveBeenCalledWith(
@ -1384,10 +1338,7 @@ describe(MediaService.name, () => {
it('should set options for qsv', async () => { it('should set options for qsv', async () => {
storageMock.readdir.mockResolvedValue(['renderD128']); storageMock.readdir.mockResolvedValue(['renderD128']);
mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer); mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer);
configMock.load.mockResolvedValue([ systemMock.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.QSV, maxBitrate: '10000k' } });
{ key: SystemConfigKey.FFMPEG_ACCEL, value: TranscodeHWAccel.QSV },
{ key: SystemConfigKey.FFMPEG_MAX_BITRATE, value: '10000k' },
]);
assetMock.getByIds.mockResolvedValue([assetStub.video]); assetMock.getByIds.mockResolvedValue([assetStub.video]);
await sut.handleVideoConversion({ id: assetStub.video.id }); await sut.handleVideoConversion({ id: assetStub.video.id });
expect(mediaMock.transcode).toHaveBeenCalledWith( expect(mediaMock.transcode).toHaveBeenCalledWith(
@ -1420,11 +1371,13 @@ describe(MediaService.name, () => {
it('should set options for qsv with custom dri node', async () => { it('should set options for qsv with custom dri node', async () => {
storageMock.readdir.mockResolvedValue(['renderD128']); storageMock.readdir.mockResolvedValue(['renderD128']);
mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer); mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer);
configMock.load.mockResolvedValue([ systemMock.get.mockResolvedValue({
{ key: SystemConfigKey.FFMPEG_ACCEL, value: TranscodeHWAccel.QSV }, ffmpeg: {
{ key: SystemConfigKey.FFMPEG_MAX_BITRATE, value: '10000k' }, accel: TranscodeHWAccel.QSV,
{ key: SystemConfigKey.FFMPEG_PREFERRED_HW_DEVICE, value: '/dev/dri/renderD128' }, maxBitrate: '10000k',
]); preferredHwDevice: '/dev/dri/renderD128',
},
});
assetMock.getByIds.mockResolvedValue([assetStub.video]); assetMock.getByIds.mockResolvedValue([assetStub.video]);
await sut.handleVideoConversion({ id: assetStub.video.id }); await sut.handleVideoConversion({ id: assetStub.video.id });
expect(mediaMock.transcode).toHaveBeenCalledWith( expect(mediaMock.transcode).toHaveBeenCalledWith(
@ -1444,10 +1397,7 @@ describe(MediaService.name, () => {
it('should omit preset for qsv if invalid', async () => { it('should omit preset for qsv if invalid', async () => {
storageMock.readdir.mockResolvedValue(['renderD128']); storageMock.readdir.mockResolvedValue(['renderD128']);
mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer); mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer);
configMock.load.mockResolvedValue([ systemMock.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.QSV, preset: 'invalid' } });
{ key: SystemConfigKey.FFMPEG_ACCEL, value: TranscodeHWAccel.QSV },
{ key: SystemConfigKey.FFMPEG_PRESET, value: 'invalid' },
]);
assetMock.getByIds.mockResolvedValue([assetStub.video]); assetMock.getByIds.mockResolvedValue([assetStub.video]);
await sut.handleVideoConversion({ id: assetStub.video.id }); await sut.handleVideoConversion({ id: assetStub.video.id });
expect(mediaMock.transcode).toHaveBeenCalledWith( expect(mediaMock.transcode).toHaveBeenCalledWith(
@ -1464,10 +1414,7 @@ describe(MediaService.name, () => {
it('should set low power mode for qsv if target video codec is vp9', async () => { it('should set low power mode for qsv if target video codec is vp9', async () => {
storageMock.readdir.mockResolvedValue(['renderD128']); storageMock.readdir.mockResolvedValue(['renderD128']);
mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer); mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer);
configMock.load.mockResolvedValue([ systemMock.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.QSV, targetVideoCodec: VideoCodec.VP9 } });
{ key: SystemConfigKey.FFMPEG_ACCEL, value: TranscodeHWAccel.QSV },
{ key: SystemConfigKey.FFMPEG_TARGET_VIDEO_CODEC, value: VideoCodec.VP9 },
]);
assetMock.getByIds.mockResolvedValue([assetStub.video]); assetMock.getByIds.mockResolvedValue([assetStub.video]);
await sut.handleVideoConversion({ id: assetStub.video.id }); await sut.handleVideoConversion({ id: assetStub.video.id });
expect(mediaMock.transcode).toHaveBeenCalledWith( expect(mediaMock.transcode).toHaveBeenCalledWith(
@ -1484,7 +1431,7 @@ describe(MediaService.name, () => {
it('should fail for qsv if no hw devices', async () => { it('should fail for qsv if no hw devices', async () => {
storageMock.readdir.mockResolvedValue([]); storageMock.readdir.mockResolvedValue([]);
mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer); mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer);
configMock.load.mockResolvedValue([{ key: SystemConfigKey.FFMPEG_ACCEL, value: TranscodeHWAccel.QSV }]); systemMock.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.QSV } });
assetMock.getByIds.mockResolvedValue([assetStub.video]); assetMock.getByIds.mockResolvedValue([assetStub.video]);
await expect(sut.handleVideoConversion({ id: assetStub.video.id })).resolves.toBe(JobStatus.FAILED); await expect(sut.handleVideoConversion({ id: assetStub.video.id })).resolves.toBe(JobStatus.FAILED);
expect(mediaMock.transcode).not.toHaveBeenCalled(); expect(mediaMock.transcode).not.toHaveBeenCalled();
@ -1493,7 +1440,7 @@ describe(MediaService.name, () => {
it('should set options for vaapi', async () => { it('should set options for vaapi', async () => {
storageMock.readdir.mockResolvedValue(['renderD128']); storageMock.readdir.mockResolvedValue(['renderD128']);
mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer); mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer);
configMock.load.mockResolvedValue([{ key: SystemConfigKey.FFMPEG_ACCEL, value: TranscodeHWAccel.VAAPI }]); systemMock.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.VAAPI } });
assetMock.getByIds.mockResolvedValue([assetStub.video]); assetMock.getByIds.mockResolvedValue([assetStub.video]);
await sut.handleVideoConversion({ id: assetStub.video.id }); await sut.handleVideoConversion({ id: assetStub.video.id });
expect(mediaMock.transcode).toHaveBeenCalledWith( expect(mediaMock.transcode).toHaveBeenCalledWith(
@ -1525,10 +1472,7 @@ describe(MediaService.name, () => {
it('should set vbr options for vaapi when max bitrate is enabled', async () => { it('should set vbr options for vaapi when max bitrate is enabled', async () => {
storageMock.readdir.mockResolvedValue(['renderD128']); storageMock.readdir.mockResolvedValue(['renderD128']);
mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer); mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer);
configMock.load.mockResolvedValue([ systemMock.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.VAAPI, maxBitrate: '10000k' } });
{ key: SystemConfigKey.FFMPEG_ACCEL, value: TranscodeHWAccel.VAAPI },
{ key: SystemConfigKey.FFMPEG_MAX_BITRATE, value: '10000k' },
]);
assetMock.getByIds.mockResolvedValue([assetStub.video]); assetMock.getByIds.mockResolvedValue([assetStub.video]);
await sut.handleVideoConversion({ id: assetStub.video.id }); await sut.handleVideoConversion({ id: assetStub.video.id });
expect(mediaMock.transcode).toHaveBeenCalledWith( expect(mediaMock.transcode).toHaveBeenCalledWith(
@ -1554,7 +1498,7 @@ describe(MediaService.name, () => {
it('should set cq options for vaapi when max bitrate is disabled', async () => { it('should set cq options for vaapi when max bitrate is disabled', async () => {
storageMock.readdir.mockResolvedValue(['renderD128']); storageMock.readdir.mockResolvedValue(['renderD128']);
mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer); mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer);
configMock.load.mockResolvedValue([{ key: SystemConfigKey.FFMPEG_ACCEL, value: TranscodeHWAccel.VAAPI }]); systemMock.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.VAAPI } });
assetMock.getByIds.mockResolvedValue([assetStub.video]); assetMock.getByIds.mockResolvedValue([assetStub.video]);
await sut.handleVideoConversion({ id: assetStub.video.id }); await sut.handleVideoConversion({ id: assetStub.video.id });
expect(mediaMock.transcode).toHaveBeenCalledWith( expect(mediaMock.transcode).toHaveBeenCalledWith(
@ -1580,10 +1524,7 @@ describe(MediaService.name, () => {
it('should omit preset for vaapi if invalid', async () => { it('should omit preset for vaapi if invalid', async () => {
storageMock.readdir.mockResolvedValue(['renderD128']); storageMock.readdir.mockResolvedValue(['renderD128']);
mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer); mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer);
configMock.load.mockResolvedValue([ systemMock.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.VAAPI, preset: 'invalid' } });
{ key: SystemConfigKey.FFMPEG_ACCEL, value: TranscodeHWAccel.VAAPI },
{ key: SystemConfigKey.FFMPEG_PRESET, value: 'invalid' },
]);
assetMock.getByIds.mockResolvedValue([assetStub.video]); assetMock.getByIds.mockResolvedValue([assetStub.video]);
await sut.handleVideoConversion({ id: assetStub.video.id }); await sut.handleVideoConversion({ id: assetStub.video.id });
expect(mediaMock.transcode).toHaveBeenCalledWith( expect(mediaMock.transcode).toHaveBeenCalledWith(
@ -1603,7 +1544,7 @@ describe(MediaService.name, () => {
it('should prefer gpu for vaapi if available', async () => { it('should prefer gpu for vaapi if available', async () => {
storageMock.readdir.mockResolvedValue(['renderD129', 'card1', 'card0', 'renderD128']); storageMock.readdir.mockResolvedValue(['renderD129', 'card1', 'card0', 'renderD128']);
mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer); mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer);
configMock.load.mockResolvedValue([{ key: SystemConfigKey.FFMPEG_ACCEL, value: TranscodeHWAccel.VAAPI }]); systemMock.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.VAAPI } });
assetMock.getByIds.mockResolvedValue([assetStub.video]); assetMock.getByIds.mockResolvedValue([assetStub.video]);
await sut.handleVideoConversion({ id: assetStub.video.id }); await sut.handleVideoConversion({ id: assetStub.video.id });
expect(mediaMock.transcode).toHaveBeenCalledWith( expect(mediaMock.transcode).toHaveBeenCalledWith(
@ -1623,7 +1564,7 @@ describe(MediaService.name, () => {
it('should prefer higher index gpu node', async () => { it('should prefer higher index gpu node', async () => {
storageMock.readdir.mockResolvedValue(['renderD129', 'renderD130', 'renderD128']); storageMock.readdir.mockResolvedValue(['renderD129', 'renderD130', 'renderD128']);
mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer); mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer);
configMock.load.mockResolvedValue([{ key: SystemConfigKey.FFMPEG_ACCEL, value: TranscodeHWAccel.VAAPI }]); systemMock.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.VAAPI } });
assetMock.getByIds.mockResolvedValue([assetStub.video]); assetMock.getByIds.mockResolvedValue([assetStub.video]);
await sut.handleVideoConversion({ id: assetStub.video.id }); await sut.handleVideoConversion({ id: assetStub.video.id });
expect(mediaMock.transcode).toHaveBeenCalledWith( expect(mediaMock.transcode).toHaveBeenCalledWith(
@ -1643,10 +1584,9 @@ describe(MediaService.name, () => {
it('should select specific gpu node if selected', async () => { it('should select specific gpu node if selected', async () => {
storageMock.readdir.mockResolvedValue(['renderD129', 'card1', 'card0', 'renderD128']); storageMock.readdir.mockResolvedValue(['renderD129', 'card1', 'card0', 'renderD128']);
mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer); mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer);
configMock.load.mockResolvedValue([ systemMock.get.mockResolvedValue({
{ key: SystemConfigKey.FFMPEG_ACCEL, value: TranscodeHWAccel.VAAPI }, ffmpeg: { accel: TranscodeHWAccel.VAAPI, preferredHwDevice: '/dev/dri/renderD128' },
{ key: SystemConfigKey.FFMPEG_PREFERRED_HW_DEVICE, value: '/dev/dri/renderD128' }, });
]);
assetMock.getByIds.mockResolvedValue([assetStub.video]); assetMock.getByIds.mockResolvedValue([assetStub.video]);
await sut.handleVideoConversion({ id: assetStub.video.id }); await sut.handleVideoConversion({ id: assetStub.video.id });
expect(mediaMock.transcode).toHaveBeenCalledWith( expect(mediaMock.transcode).toHaveBeenCalledWith(
@ -1666,7 +1606,7 @@ describe(MediaService.name, () => {
it('should fallback to sw transcoding if hw transcoding fails', async () => { it('should fallback to sw transcoding if hw transcoding fails', async () => {
storageMock.readdir.mockResolvedValue(['renderD128']); storageMock.readdir.mockResolvedValue(['renderD128']);
mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer); mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer);
configMock.load.mockResolvedValue([{ key: SystemConfigKey.FFMPEG_ACCEL, value: TranscodeHWAccel.VAAPI }]); systemMock.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.VAAPI } });
assetMock.getByIds.mockResolvedValue([assetStub.video]); assetMock.getByIds.mockResolvedValue([assetStub.video]);
mediaMock.transcode.mockRejectedValueOnce(new Error('error')); mediaMock.transcode.mockRejectedValueOnce(new Error('error'));
await sut.handleVideoConversion({ id: assetStub.video.id }); await sut.handleVideoConversion({ id: assetStub.video.id });
@ -1685,7 +1625,7 @@ describe(MediaService.name, () => {
it('should fail for vaapi if no hw devices', async () => { it('should fail for vaapi if no hw devices', async () => {
storageMock.readdir.mockResolvedValue([]); storageMock.readdir.mockResolvedValue([]);
mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer); mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer);
configMock.load.mockResolvedValue([{ key: SystemConfigKey.FFMPEG_ACCEL, value: TranscodeHWAccel.VAAPI }]); systemMock.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.VAAPI } });
assetMock.getByIds.mockResolvedValue([assetStub.video]); assetMock.getByIds.mockResolvedValue([assetStub.video]);
await expect(sut.handleVideoConversion({ id: assetStub.video.id })).resolves.toBe(JobStatus.FAILED); await expect(sut.handleVideoConversion({ id: assetStub.video.id })).resolves.toBe(JobStatus.FAILED);
expect(mediaMock.transcode).not.toHaveBeenCalled(); expect(mediaMock.transcode).not.toHaveBeenCalled();
@ -1694,7 +1634,7 @@ describe(MediaService.name, () => {
it('should set options for rkmpp', async () => { it('should set options for rkmpp', async () => {
storageMock.readdir.mockResolvedValue(['renderD128']); storageMock.readdir.mockResolvedValue(['renderD128']);
mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer); mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer);
configMock.load.mockResolvedValue([{ key: SystemConfigKey.FFMPEG_ACCEL, value: TranscodeHWAccel.RKMPP }]); systemMock.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.RKMPP } });
assetMock.getByIds.mockResolvedValue([assetStub.video]); assetMock.getByIds.mockResolvedValue([assetStub.video]);
await sut.handleVideoConversion({ id: assetStub.video.id }); await sut.handleVideoConversion({ id: assetStub.video.id });
expect(mediaMock.transcode).toHaveBeenCalledWith( expect(mediaMock.transcode).toHaveBeenCalledWith(
@ -1724,11 +1664,13 @@ describe(MediaService.name, () => {
it('should set vbr options for rkmpp when max bitrate is enabled', async () => { it('should set vbr options for rkmpp when max bitrate is enabled', async () => {
storageMock.readdir.mockResolvedValue(['renderD128']); storageMock.readdir.mockResolvedValue(['renderD128']);
mediaMock.probe.mockResolvedValue(probeStub.videoStreamVp9); mediaMock.probe.mockResolvedValue(probeStub.videoStreamVp9);
configMock.load.mockResolvedValue([ systemMock.get.mockResolvedValue({
{ key: SystemConfigKey.FFMPEG_ACCEL, value: TranscodeHWAccel.RKMPP }, ffmpeg: {
{ key: SystemConfigKey.FFMPEG_MAX_BITRATE, value: '10000k' }, accel: TranscodeHWAccel.RKMPP,
{ key: SystemConfigKey.FFMPEG_TARGET_VIDEO_CODEC, value: VideoCodec.HEVC }, maxBitrate: '10000k',
]); targetVideoCodec: VideoCodec.HEVC,
},
});
assetMock.getByIds.mockResolvedValue([assetStub.video]); assetMock.getByIds.mockResolvedValue([assetStub.video]);
await sut.handleVideoConversion({ id: assetStub.video.id }); await sut.handleVideoConversion({ id: assetStub.video.id });
expect(mediaMock.transcode).toHaveBeenCalledWith( expect(mediaMock.transcode).toHaveBeenCalledWith(
@ -1745,11 +1687,9 @@ describe(MediaService.name, () => {
it('should set cqp options for rkmpp when max bitrate is disabled', async () => { it('should set cqp options for rkmpp when max bitrate is disabled', async () => {
storageMock.readdir.mockResolvedValue(['renderD128']); storageMock.readdir.mockResolvedValue(['renderD128']);
mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer); mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer);
configMock.load.mockResolvedValue([ systemMock.get.mockResolvedValue({
{ key: SystemConfigKey.FFMPEG_ACCEL, value: TranscodeHWAccel.RKMPP }, ffmpeg: { accel: TranscodeHWAccel.RKMPP, crf: 30, maxBitrate: '0' },
{ key: SystemConfigKey.FFMPEG_CRF, value: 30 }, });
{ key: SystemConfigKey.FFMPEG_MAX_BITRATE, value: '0' },
]);
assetMock.getByIds.mockResolvedValue([assetStub.video]); assetMock.getByIds.mockResolvedValue([assetStub.video]);
await sut.handleVideoConversion({ id: assetStub.video.id }); await sut.handleVideoConversion({ id: assetStub.video.id });
expect(mediaMock.transcode).toHaveBeenCalledWith( expect(mediaMock.transcode).toHaveBeenCalledWith(
@ -1767,11 +1707,7 @@ describe(MediaService.name, () => {
storageMock.readdir.mockResolvedValue(['renderD128']); storageMock.readdir.mockResolvedValue(['renderD128']);
storageMock.stat.mockResolvedValue({ ...new Stats(), isFile: () => true, isCharacterDevice: () => true }); storageMock.stat.mockResolvedValue({ ...new Stats(), isFile: () => true, isCharacterDevice: () => true });
mediaMock.probe.mockResolvedValue(probeStub.videoStreamHDR); mediaMock.probe.mockResolvedValue(probeStub.videoStreamHDR);
configMock.load.mockResolvedValue([ systemMock.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.RKMPP, crf: 30, maxBitrate: '0' } });
{ key: SystemConfigKey.FFMPEG_ACCEL, value: TranscodeHWAccel.RKMPP },
{ key: SystemConfigKey.FFMPEG_CRF, value: 30 },
{ key: SystemConfigKey.FFMPEG_MAX_BITRATE, value: '0' },
]);
assetMock.getByIds.mockResolvedValue([assetStub.video]); assetMock.getByIds.mockResolvedValue([assetStub.video]);
await sut.handleVideoConversion({ id: assetStub.video.id }); await sut.handleVideoConversion({ id: assetStub.video.id });
expect(mediaMock.transcode).toHaveBeenCalledWith( expect(mediaMock.transcode).toHaveBeenCalledWith(
@ -1792,7 +1728,7 @@ describe(MediaService.name, () => {
it('should tonemap when policy is required and video is hdr', async () => { it('should tonemap when policy is required and video is hdr', async () => {
mediaMock.probe.mockResolvedValue(probeStub.videoStreamHDR); mediaMock.probe.mockResolvedValue(probeStub.videoStreamHDR);
configMock.load.mockResolvedValue([{ key: SystemConfigKey.FFMPEG_TRANSCODE, value: TranscodePolicy.REQUIRED }]); systemMock.get.mockResolvedValue({ ffmpeg: { transcode: TranscodePolicy.REQUIRED } });
assetMock.getByIds.mockResolvedValue([assetStub.video]); assetMock.getByIds.mockResolvedValue([assetStub.video]);
await sut.handleVideoConversion({ id: assetStub.video.id }); await sut.handleVideoConversion({ id: assetStub.video.id });
expect(mediaMock.transcode).toHaveBeenCalledWith( expect(mediaMock.transcode).toHaveBeenCalledWith(
@ -1812,7 +1748,7 @@ describe(MediaService.name, () => {
it('should tonemap when policy is optimal and video is hdr', async () => { it('should tonemap when policy is optimal and video is hdr', async () => {
mediaMock.probe.mockResolvedValue(probeStub.videoStreamHDR); mediaMock.probe.mockResolvedValue(probeStub.videoStreamHDR);
configMock.load.mockResolvedValue([{ key: SystemConfigKey.FFMPEG_TRANSCODE, value: TranscodePolicy.OPTIMAL }]); systemMock.get.mockResolvedValue({ ffmpeg: { transcode: TranscodePolicy.OPTIMAL } });
assetMock.getByIds.mockResolvedValue([assetStub.video]); assetMock.getByIds.mockResolvedValue([assetStub.video]);
await sut.handleVideoConversion({ id: assetStub.video.id }); await sut.handleVideoConversion({ id: assetStub.video.id });
expect(mediaMock.transcode).toHaveBeenCalledWith( expect(mediaMock.transcode).toHaveBeenCalledWith(
@ -1832,7 +1768,7 @@ describe(MediaService.name, () => {
it('should set npl to 250 for reinhard and mobius tone-mapping algorithms', async () => { it('should set npl to 250 for reinhard and mobius tone-mapping algorithms', async () => {
mediaMock.probe.mockResolvedValue(probeStub.videoStreamHDR); mediaMock.probe.mockResolvedValue(probeStub.videoStreamHDR);
configMock.load.mockResolvedValue([{ key: SystemConfigKey.FFMPEG_TONEMAP, value: ToneMapping.MOBIUS }]); systemMock.get.mockResolvedValue({ ffmpeg: { tonemap: ToneMapping.MOBIUS } });
assetMock.getByIds.mockResolvedValue([assetStub.video]); assetMock.getByIds.mockResolvedValue([assetStub.video]);
await sut.handleVideoConversion({ id: assetStub.video.id }); await sut.handleVideoConversion({ id: assetStub.video.id });
expect(mediaMock.transcode).toHaveBeenCalledWith( expect(mediaMock.transcode).toHaveBeenCalledWith(

View file

@ -31,7 +31,7 @@ import { AudioStreamInfo, IMediaRepository, VideoCodecHWConfig, VideoStreamInfo
import { IMoveRepository } from 'src/interfaces/move.interface'; import { IMoveRepository } from 'src/interfaces/move.interface';
import { IPersonRepository } from 'src/interfaces/person.interface'; import { IPersonRepository } from 'src/interfaces/person.interface';
import { IStorageRepository } from 'src/interfaces/storage.interface'; import { IStorageRepository } from 'src/interfaces/storage.interface';
import { ISystemConfigRepository } from 'src/interfaces/system-config.interface'; import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface';
import { import {
AV1Config, AV1Config,
H264Config, H264Config,
@ -59,20 +59,20 @@ export class MediaService {
@Inject(IJobRepository) private jobRepository: IJobRepository, @Inject(IJobRepository) private jobRepository: IJobRepository,
@Inject(IMediaRepository) private mediaRepository: IMediaRepository, @Inject(IMediaRepository) private mediaRepository: IMediaRepository,
@Inject(IStorageRepository) private storageRepository: IStorageRepository, @Inject(IStorageRepository) private storageRepository: IStorageRepository,
@Inject(ISystemConfigRepository) configRepository: ISystemConfigRepository, @Inject(ISystemMetadataRepository) systemMetadataRepository: ISystemMetadataRepository,
@Inject(IMoveRepository) moveRepository: IMoveRepository, @Inject(IMoveRepository) moveRepository: IMoveRepository,
@Inject(ICryptoRepository) cryptoRepository: ICryptoRepository, @Inject(ICryptoRepository) cryptoRepository: ICryptoRepository,
@Inject(ILoggerRepository) private logger: ILoggerRepository, @Inject(ILoggerRepository) private logger: ILoggerRepository,
) { ) {
this.logger.setContext(MediaService.name); this.logger.setContext(MediaService.name);
this.configCore = SystemConfigCore.create(configRepository, this.logger); this.configCore = SystemConfigCore.create(systemMetadataRepository, this.logger);
this.storageCore = StorageCore.create( this.storageCore = StorageCore.create(
assetRepository, assetRepository,
cryptoRepository, cryptoRepository,
moveRepository, moveRepository,
personRepository, personRepository,
storageRepository, storageRepository,
configRepository, systemMetadataRepository,
this.logger, this.logger,
); );
} }
@ -329,7 +329,6 @@ export class MediaService {
} }
const { ffmpeg: config } = await this.configCore.getConfig(); const { ffmpeg: config } = await this.configCore.getConfig();
const target = this.getTranscodeTarget(config, mainVideoStream, mainAudioStream); const target = this.getTranscodeTarget(config, mainVideoStream, mainAudioStream);
if (target === TranscodeTarget.NONE) { if (target === TranscodeTarget.NONE) {
if (asset.encodedVideoPath) { if (asset.encodedVideoPath) {

View file

@ -4,7 +4,6 @@ import { Stats } from 'node:fs';
import { constants } from 'node:fs/promises'; import { constants } from 'node:fs/promises';
import { AssetType } from 'src/entities/asset.entity'; import { AssetType } from 'src/entities/asset.entity';
import { ExifEntity } from 'src/entities/exif.entity'; import { ExifEntity } from 'src/entities/exif.entity';
import { SystemConfigKey } from 'src/entities/system-config.entity';
import { IAlbumRepository } from 'src/interfaces/album.interface'; import { IAlbumRepository } from 'src/interfaces/album.interface';
import { IAssetRepository, WithoutProperty } from 'src/interfaces/asset.interface'; import { IAssetRepository, WithoutProperty } from 'src/interfaces/asset.interface';
import { ICryptoRepository } from 'src/interfaces/crypto.interface'; import { ICryptoRepository } from 'src/interfaces/crypto.interface';
@ -17,7 +16,7 @@ import { IMetadataRepository, ImmichTags } from 'src/interfaces/metadata.interfa
import { IMoveRepository } from 'src/interfaces/move.interface'; import { IMoveRepository } from 'src/interfaces/move.interface';
import { IPersonRepository } from 'src/interfaces/person.interface'; import { IPersonRepository } from 'src/interfaces/person.interface';
import { IStorageRepository } from 'src/interfaces/storage.interface'; import { IStorageRepository } from 'src/interfaces/storage.interface';
import { ISystemConfigRepository } from 'src/interfaces/system-config.interface'; import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface';
import { IUserRepository } from 'src/interfaces/user.interface'; import { IUserRepository } from 'src/interfaces/user.interface';
import { MetadataService, Orientation } from 'src/services/metadata.service'; import { MetadataService, Orientation } from 'src/services/metadata.service';
import { assetStub } from 'test/fixtures/asset.stub'; import { assetStub } from 'test/fixtures/asset.stub';
@ -35,14 +34,14 @@ import { newMetadataRepositoryMock } from 'test/repositories/metadata.repository
import { newMoveRepositoryMock } from 'test/repositories/move.repository.mock'; import { newMoveRepositoryMock } from 'test/repositories/move.repository.mock';
import { newPersonRepositoryMock } from 'test/repositories/person.repository.mock'; import { newPersonRepositoryMock } from 'test/repositories/person.repository.mock';
import { newStorageRepositoryMock } from 'test/repositories/storage.repository.mock'; import { newStorageRepositoryMock } from 'test/repositories/storage.repository.mock';
import { newSystemConfigRepositoryMock } from 'test/repositories/system-config.repository.mock'; import { newSystemMetadataRepositoryMock } from 'test/repositories/system-metadata.repository.mock';
import { newUserRepositoryMock } from 'test/repositories/user.repository.mock'; import { newUserRepositoryMock } from 'test/repositories/user.repository.mock';
import { Mocked } from 'vitest'; import { Mocked } from 'vitest';
describe(MetadataService.name, () => { describe(MetadataService.name, () => {
let albumMock: Mocked<IAlbumRepository>; let albumMock: Mocked<IAlbumRepository>;
let assetMock: Mocked<IAssetRepository>; let assetMock: Mocked<IAssetRepository>;
let configMock: Mocked<ISystemConfigRepository>; let systemMock: Mocked<ISystemMetadataRepository>;
let cryptoRepository: Mocked<ICryptoRepository>; let cryptoRepository: Mocked<ICryptoRepository>;
let jobMock: Mocked<IJobRepository>; let jobMock: Mocked<IJobRepository>;
let metadataMock: Mocked<IMetadataRepository>; let metadataMock: Mocked<IMetadataRepository>;
@ -59,7 +58,6 @@ describe(MetadataService.name, () => {
beforeEach(() => { beforeEach(() => {
albumMock = newAlbumRepositoryMock(); albumMock = newAlbumRepositoryMock();
assetMock = newAssetRepositoryMock(); assetMock = newAssetRepositoryMock();
configMock = newSystemConfigRepositoryMock();
cryptoRepository = newCryptoRepositoryMock(); cryptoRepository = newCryptoRepositoryMock();
jobMock = newJobRepositoryMock(); jobMock = newJobRepositoryMock();
metadataMock = newMetadataRepositoryMock(); metadataMock = newMetadataRepositoryMock();
@ -67,6 +65,7 @@ describe(MetadataService.name, () => {
personMock = newPersonRepositoryMock(); personMock = newPersonRepositoryMock();
eventMock = newEventRepositoryMock(); eventMock = newEventRepositoryMock();
storageMock = newStorageRepositoryMock(); storageMock = newStorageRepositoryMock();
systemMock = newSystemMetadataRepositoryMock();
mediaMock = newMediaRepositoryMock(); mediaMock = newMediaRepositoryMock();
databaseMock = newDatabaseRepositoryMock(); databaseMock = newDatabaseRepositoryMock();
userMock = newUserRepositoryMock(); userMock = newUserRepositoryMock();
@ -84,7 +83,7 @@ describe(MetadataService.name, () => {
moveMock, moveMock,
personMock, personMock,
storageMock, storageMock,
configMock, systemMock,
userMock, userMock,
loggerMock, loggerMock,
); );
@ -108,7 +107,7 @@ describe(MetadataService.name, () => {
}); });
it('should return if reverse geocoding is disabled', async () => { it('should return if reverse geocoding is disabled', async () => {
configMock.load.mockResolvedValue([{ key: SystemConfigKey.REVERSE_GEOCODING_ENABLED, value: false }]); systemMock.get.mockResolvedValue({ reverseGeocoding: { enabled: false } });
await sut.init(); await sut.init();
@ -297,7 +296,7 @@ describe(MetadataService.name, () => {
it('should apply reverse geocoding', async () => { it('should apply reverse geocoding', async () => {
assetMock.getByIds.mockResolvedValue([assetStub.withLocation]); assetMock.getByIds.mockResolvedValue([assetStub.withLocation]);
configMock.load.mockResolvedValue([{ key: SystemConfigKey.REVERSE_GEOCODING_ENABLED, value: true }]); systemMock.get.mockResolvedValue({ reverseGeocoding: { enabled: true } });
metadataMock.reverseGeocode.mockResolvedValue({ city: 'City', state: 'State', country: 'Country' }); metadataMock.reverseGeocode.mockResolvedValue({ city: 'City', state: 'State', country: 'Country' });
metadataMock.readTags.mockResolvedValue({ metadataMock.readTags.mockResolvedValue({
GPSLatitude: assetStub.withLocation.exifInfo!.latitude!, GPSLatitude: assetStub.withLocation.exifInfo!.latitude!,

View file

@ -31,7 +31,7 @@ import { IMetadataRepository, ImmichTags } from 'src/interfaces/metadata.interfa
import { IMoveRepository } from 'src/interfaces/move.interface'; import { IMoveRepository } from 'src/interfaces/move.interface';
import { IPersonRepository } from 'src/interfaces/person.interface'; import { IPersonRepository } from 'src/interfaces/person.interface';
import { IStorageRepository } from 'src/interfaces/storage.interface'; import { IStorageRepository } from 'src/interfaces/storage.interface';
import { ISystemConfigRepository } from 'src/interfaces/system-config.interface'; import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface';
import { IUserRepository } from 'src/interfaces/user.interface'; import { IUserRepository } from 'src/interfaces/user.interface';
import { handlePromiseError } from 'src/utils/misc'; import { handlePromiseError } from 'src/utils/misc';
import { usePagination } from 'src/utils/pagination'; import { usePagination } from 'src/utils/pagination';
@ -113,19 +113,19 @@ export class MetadataService {
@Inject(IMoveRepository) moveRepository: IMoveRepository, @Inject(IMoveRepository) moveRepository: IMoveRepository,
@Inject(IPersonRepository) personRepository: IPersonRepository, @Inject(IPersonRepository) personRepository: IPersonRepository,
@Inject(IStorageRepository) private storageRepository: IStorageRepository, @Inject(IStorageRepository) private storageRepository: IStorageRepository,
@Inject(ISystemConfigRepository) configRepository: ISystemConfigRepository, @Inject(ISystemMetadataRepository) systemMetadataRepository: ISystemMetadataRepository,
@Inject(IUserRepository) private userRepository: IUserRepository, @Inject(IUserRepository) private userRepository: IUserRepository,
@Inject(ILoggerRepository) private logger: ILoggerRepository, @Inject(ILoggerRepository) private logger: ILoggerRepository,
) { ) {
this.logger.setContext(MetadataService.name); this.logger.setContext(MetadataService.name);
this.configCore = SystemConfigCore.create(configRepository, this.logger); this.configCore = SystemConfigCore.create(systemMetadataRepository, this.logger);
this.storageCore = StorageCore.create( this.storageCore = StorageCore.create(
assetRepository, assetRepository,
cryptoRepository, cryptoRepository,
moveRepository, moveRepository,
personRepository, personRepository,
storageRepository, storageRepository,
configRepository, systemMetadataRepository,
this.logger, this.logger,
); );
} }

View file

@ -5,7 +5,7 @@ import { ServerAsyncEvent, ServerAsyncEventMap } from 'src/interfaces/event.inte
import { IEmailJob, IJobRepository, INotifySignupJob, JobName, JobStatus } from 'src/interfaces/job.interface'; import { IEmailJob, IJobRepository, INotifySignupJob, JobName, JobStatus } from 'src/interfaces/job.interface';
import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { ILoggerRepository } from 'src/interfaces/logger.interface';
import { EmailTemplate, INotificationRepository } from 'src/interfaces/notification.interface'; import { EmailTemplate, INotificationRepository } from 'src/interfaces/notification.interface';
import { ISystemConfigRepository } from 'src/interfaces/system-config.interface'; import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface';
import { IUserRepository } from 'src/interfaces/user.interface'; import { IUserRepository } from 'src/interfaces/user.interface';
@Injectable() @Injectable()
@ -13,14 +13,14 @@ export class NotificationService {
private configCore: SystemConfigCore; private configCore: SystemConfigCore;
constructor( constructor(
@Inject(ISystemConfigRepository) configRepository: ISystemConfigRepository, @Inject(ISystemMetadataRepository) systemMetadataRepository: ISystemMetadataRepository,
@Inject(INotificationRepository) private notificationRepository: INotificationRepository, @Inject(INotificationRepository) private notificationRepository: INotificationRepository,
@Inject(IUserRepository) private userRepository: IUserRepository, @Inject(IUserRepository) private userRepository: IUserRepository,
@Inject(IJobRepository) private jobRepository: IJobRepository, @Inject(IJobRepository) private jobRepository: IJobRepository,
@Inject(ILoggerRepository) private logger: ILoggerRepository, @Inject(ILoggerRepository) private logger: ILoggerRepository,
) { ) {
this.logger.setContext(NotificationService.name); this.logger.setContext(NotificationService.name);
this.configCore = SystemConfigCore.create(configRepository, logger); this.configCore = SystemConfigCore.create(systemMetadataRepository, logger);
} }
init() { init() {

View file

@ -3,7 +3,6 @@ import { Colorspace } from 'src/config';
import { BulkIdErrorReason } from 'src/dtos/asset-ids.response.dto'; import { BulkIdErrorReason } from 'src/dtos/asset-ids.response.dto';
import { PersonResponseDto, mapFaces, mapPerson } from 'src/dtos/person.dto'; import { PersonResponseDto, mapFaces, mapPerson } from 'src/dtos/person.dto';
import { AssetFaceEntity } from 'src/entities/asset-face.entity'; import { AssetFaceEntity } from 'src/entities/asset-face.entity';
import { SystemConfigKey } from 'src/entities/system-config.entity';
import { IAssetRepository, WithoutProperty } from 'src/interfaces/asset.interface'; import { IAssetRepository, WithoutProperty } from 'src/interfaces/asset.interface';
import { ICryptoRepository } from 'src/interfaces/crypto.interface'; import { ICryptoRepository } from 'src/interfaces/crypto.interface';
import { IJobRepository, JobName, JobStatus } from 'src/interfaces/job.interface'; import { IJobRepository, JobName, JobStatus } from 'src/interfaces/job.interface';
@ -14,13 +13,14 @@ import { IMoveRepository } from 'src/interfaces/move.interface';
import { IPersonRepository } from 'src/interfaces/person.interface'; import { IPersonRepository } from 'src/interfaces/person.interface';
import { FaceSearchResult, ISearchRepository } from 'src/interfaces/search.interface'; import { FaceSearchResult, ISearchRepository } from 'src/interfaces/search.interface';
import { IStorageRepository } from 'src/interfaces/storage.interface'; import { IStorageRepository } from 'src/interfaces/storage.interface';
import { ISystemConfigRepository } from 'src/interfaces/system-config.interface'; import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface';
import { PersonService } from 'src/services/person.service'; import { PersonService } from 'src/services/person.service';
import { CacheControl, ImmichFileResponse } from 'src/utils/file'; import { CacheControl, ImmichFileResponse } from 'src/utils/file';
import { assetStub } from 'test/fixtures/asset.stub'; import { assetStub } from 'test/fixtures/asset.stub';
import { authStub } from 'test/fixtures/auth.stub'; import { authStub } from 'test/fixtures/auth.stub';
import { faceStub } from 'test/fixtures/face.stub'; import { faceStub } from 'test/fixtures/face.stub';
import { personStub } from 'test/fixtures/person.stub'; import { personStub } from 'test/fixtures/person.stub';
import { systemConfigStub } from 'test/fixtures/system-config.stub';
import { IAccessRepositoryMock, newAccessRepositoryMock } from 'test/repositories/access.repository.mock'; import { IAccessRepositoryMock, newAccessRepositoryMock } from 'test/repositories/access.repository.mock';
import { newAssetRepositoryMock } from 'test/repositories/asset.repository.mock'; import { newAssetRepositoryMock } from 'test/repositories/asset.repository.mock';
import { newCryptoRepositoryMock } from 'test/repositories/crypto.repository.mock'; import { newCryptoRepositoryMock } from 'test/repositories/crypto.repository.mock';
@ -32,7 +32,7 @@ import { newMoveRepositoryMock } from 'test/repositories/move.repository.mock';
import { newPersonRepositoryMock } from 'test/repositories/person.repository.mock'; import { newPersonRepositoryMock } from 'test/repositories/person.repository.mock';
import { newSearchRepositoryMock } from 'test/repositories/search.repository.mock'; import { newSearchRepositoryMock } from 'test/repositories/search.repository.mock';
import { newStorageRepositoryMock } from 'test/repositories/storage.repository.mock'; import { newStorageRepositoryMock } from 'test/repositories/storage.repository.mock';
import { newSystemConfigRepositoryMock } from 'test/repositories/system-config.repository.mock'; import { newSystemMetadataRepositoryMock } from 'test/repositories/system-metadata.repository.mock';
import { IsNull } from 'typeorm'; import { IsNull } from 'typeorm';
import { Mocked } from 'vitest'; import { Mocked } from 'vitest';
@ -64,7 +64,7 @@ const detectFaceMock = {
describe(PersonService.name, () => { describe(PersonService.name, () => {
let accessMock: IAccessRepositoryMock; let accessMock: IAccessRepositoryMock;
let assetMock: Mocked<IAssetRepository>; let assetMock: Mocked<IAssetRepository>;
let configMock: Mocked<ISystemConfigRepository>; let systemMock: Mocked<ISystemMetadataRepository>;
let jobMock: Mocked<IJobRepository>; let jobMock: Mocked<IJobRepository>;
let machineLearningMock: Mocked<IMachineLearningRepository>; let machineLearningMock: Mocked<IMachineLearningRepository>;
let mediaMock: Mocked<IMediaRepository>; let mediaMock: Mocked<IMediaRepository>;
@ -79,7 +79,7 @@ describe(PersonService.name, () => {
beforeEach(() => { beforeEach(() => {
accessMock = newAccessRepositoryMock(); accessMock = newAccessRepositoryMock();
assetMock = newAssetRepositoryMock(); assetMock = newAssetRepositoryMock();
configMock = newSystemConfigRepositoryMock(); systemMock = newSystemMetadataRepositoryMock();
jobMock = newJobRepositoryMock(); jobMock = newJobRepositoryMock();
machineLearningMock = newMachineLearningRepositoryMock(); machineLearningMock = newMachineLearningRepositoryMock();
moveMock = newMoveRepositoryMock(); moveMock = newMoveRepositoryMock();
@ -96,7 +96,7 @@ describe(PersonService.name, () => {
moveMock, moveMock,
mediaMock, mediaMock,
personMock, personMock,
configMock, systemMock,
storageMock, storageMock,
jobMock, jobMock,
searchMock, searchMock,
@ -451,12 +451,12 @@ describe(PersonService.name, () => {
describe('handleQueueDetectFaces', () => { describe('handleQueueDetectFaces', () => {
it('should skip if machine learning is disabled', async () => { it('should skip if machine learning is disabled', async () => {
configMock.load.mockResolvedValue([{ key: SystemConfigKey.MACHINE_LEARNING_ENABLED, value: false }]); systemMock.get.mockResolvedValue(systemConfigStub.machineLearningDisabled);
await expect(sut.handleQueueDetectFaces({})).resolves.toBe(JobStatus.SKIPPED); await expect(sut.handleQueueDetectFaces({})).resolves.toBe(JobStatus.SKIPPED);
expect(jobMock.queue).not.toHaveBeenCalled(); expect(jobMock.queue).not.toHaveBeenCalled();
expect(jobMock.queueAll).not.toHaveBeenCalled(); expect(jobMock.queueAll).not.toHaveBeenCalled();
expect(configMock.load).toHaveBeenCalled(); expect(systemMock.get).toHaveBeenCalled();
}); });
it('should queue missing assets', async () => { it('should queue missing assets', async () => {
@ -528,11 +528,11 @@ describe(PersonService.name, () => {
describe('handleQueueRecognizeFaces', () => { describe('handleQueueRecognizeFaces', () => {
it('should skip if machine learning is disabled', async () => { it('should skip if machine learning is disabled', async () => {
jobMock.getJobCounts.mockResolvedValue({ active: 1, waiting: 0, paused: 0, completed: 0, failed: 0, delayed: 0 }); jobMock.getJobCounts.mockResolvedValue({ active: 1, waiting: 0, paused: 0, completed: 0, failed: 0, delayed: 0 });
configMock.load.mockResolvedValue([{ key: SystemConfigKey.MACHINE_LEARNING_ENABLED, value: false }]); systemMock.get.mockResolvedValue(systemConfigStub.machineLearningDisabled);
await expect(sut.handleQueueRecognizeFaces({})).resolves.toBe(JobStatus.SKIPPED); await expect(sut.handleQueueRecognizeFaces({})).resolves.toBe(JobStatus.SKIPPED);
expect(jobMock.queueAll).not.toHaveBeenCalled(); expect(jobMock.queueAll).not.toHaveBeenCalled();
expect(configMock.load).toHaveBeenCalled(); expect(systemMock.get).toHaveBeenCalled();
}); });
it('should skip if recognition jobs are already queued', async () => { it('should skip if recognition jobs are already queued', async () => {
@ -609,11 +609,11 @@ describe(PersonService.name, () => {
describe('handleDetectFaces', () => { describe('handleDetectFaces', () => {
it('should skip if machine learning is disabled', async () => { it('should skip if machine learning is disabled', async () => {
configMock.load.mockResolvedValue([{ key: SystemConfigKey.MACHINE_LEARNING_ENABLED, value: false }]); systemMock.get.mockResolvedValue(systemConfigStub.machineLearningDisabled);
await expect(sut.handleDetectFaces({ id: 'foo' })).resolves.toBe(JobStatus.SKIPPED); await expect(sut.handleDetectFaces({ id: 'foo' })).resolves.toBe(JobStatus.SKIPPED);
expect(assetMock.getByIds).not.toHaveBeenCalled(); expect(assetMock.getByIds).not.toHaveBeenCalled();
expect(configMock.load).toHaveBeenCalled(); expect(systemMock.get).toHaveBeenCalled();
}); });
it('should skip when no resize path', async () => { it('should skip when no resize path', async () => {
@ -740,9 +740,7 @@ describe(PersonService.name, () => {
{ face: faceStub.face1, distance: 0.4 }, { face: faceStub.face1, distance: 0.4 },
] as FaceSearchResult[]; ] as FaceSearchResult[];
configMock.load.mockResolvedValue([ systemMock.get.mockResolvedValue({ machineLearning: { facialRecognition: { minFaces: 1 } } });
{ key: SystemConfigKey.MACHINE_LEARNING_FACIAL_RECOGNITION_MIN_FACES, value: 1 },
]);
searchMock.searchFaces.mockResolvedValue(faces); searchMock.searchFaces.mockResolvedValue(faces);
personMock.getFaceByIdWithAssets.mockResolvedValue(faceStub.noPerson1); personMock.getFaceByIdWithAssets.mockResolvedValue(faceStub.noPerson1);
personMock.create.mockResolvedValue(faceStub.primaryFace1.person); personMock.create.mockResolvedValue(faceStub.primaryFace1.person);
@ -767,9 +765,7 @@ describe(PersonService.name, () => {
{ face: faceStub.noPerson2, distance: 0.3 }, { face: faceStub.noPerson2, distance: 0.3 },
] as FaceSearchResult[]; ] as FaceSearchResult[];
configMock.load.mockResolvedValue([ systemMock.get.mockResolvedValue({ machineLearning: { facialRecognition: { minFaces: 1 } } });
{ key: SystemConfigKey.MACHINE_LEARNING_FACIAL_RECOGNITION_MIN_FACES, value: 1 },
]);
searchMock.searchFaces.mockResolvedValue(faces); searchMock.searchFaces.mockResolvedValue(faces);
personMock.getFaceByIdWithAssets.mockResolvedValue(faceStub.noPerson1); personMock.getFaceByIdWithAssets.mockResolvedValue(faceStub.noPerson1);
personMock.create.mockResolvedValue(personStub.withName); personMock.create.mockResolvedValue(personStub.withName);
@ -807,9 +803,7 @@ describe(PersonService.name, () => {
{ face: faceStub.noPerson2, distance: 0.4 }, { face: faceStub.noPerson2, distance: 0.4 },
] as FaceSearchResult[]; ] as FaceSearchResult[];
configMock.load.mockResolvedValue([ systemMock.get.mockResolvedValue({ machineLearning: { facialRecognition: { minFaces: 3 } } });
{ key: SystemConfigKey.MACHINE_LEARNING_FACIAL_RECOGNITION_MIN_FACES, value: 3 },
]);
searchMock.searchFaces.mockResolvedValue(faces); searchMock.searchFaces.mockResolvedValue(faces);
personMock.getFaceByIdWithAssets.mockResolvedValue(faceStub.noPerson1); personMock.getFaceByIdWithAssets.mockResolvedValue(faceStub.noPerson1);
personMock.create.mockResolvedValue(personStub.withName); personMock.create.mockResolvedValue(personStub.withName);
@ -831,9 +825,7 @@ describe(PersonService.name, () => {
{ face: faceStub.noPerson2, distance: 0.4 }, { face: faceStub.noPerson2, distance: 0.4 },
] as FaceSearchResult[]; ] as FaceSearchResult[];
configMock.load.mockResolvedValue([ systemMock.get.mockResolvedValue({ machineLearning: { facialRecognition: { minFaces: 3 } } });
{ key: SystemConfigKey.MACHINE_LEARNING_FACIAL_RECOGNITION_MIN_FACES, value: 3 },
]);
searchMock.searchFaces.mockResolvedValueOnce(faces).mockResolvedValueOnce([]); searchMock.searchFaces.mockResolvedValueOnce(faces).mockResolvedValueOnce([]);
personMock.getFaceByIdWithAssets.mockResolvedValue(faceStub.noPerson1); personMock.getFaceByIdWithAssets.mockResolvedValue(faceStub.noPerson1);
personMock.create.mockResolvedValue(personStub.withName); personMock.create.mockResolvedValue(personStub.withName);
@ -849,11 +841,11 @@ describe(PersonService.name, () => {
describe('handleGeneratePersonThumbnail', () => { describe('handleGeneratePersonThumbnail', () => {
it('should skip if machine learning is disabled', async () => { it('should skip if machine learning is disabled', async () => {
configMock.load.mockResolvedValue([{ key: SystemConfigKey.MACHINE_LEARNING_ENABLED, value: false }]); systemMock.get.mockResolvedValue(systemConfigStub.machineLearningDisabled);
await expect(sut.handleGeneratePersonThumbnail({ id: 'person-1' })).resolves.toBe(JobStatus.SKIPPED); await expect(sut.handleGeneratePersonThumbnail({ id: 'person-1' })).resolves.toBe(JobStatus.SKIPPED);
expect(assetMock.getByIds).not.toHaveBeenCalled(); expect(assetMock.getByIds).not.toHaveBeenCalled();
expect(configMock.load).toHaveBeenCalled(); expect(systemMock.get).toHaveBeenCalled();
}); });
it('should skip a person not found', async () => { it('should skip a person not found', async () => {

View file

@ -45,7 +45,7 @@ import { IMoveRepository } from 'src/interfaces/move.interface';
import { IPersonRepository, UpdateFacesData } from 'src/interfaces/person.interface'; import { IPersonRepository, UpdateFacesData } from 'src/interfaces/person.interface';
import { ISearchRepository } from 'src/interfaces/search.interface'; import { ISearchRepository } from 'src/interfaces/search.interface';
import { IStorageRepository } from 'src/interfaces/storage.interface'; import { IStorageRepository } from 'src/interfaces/storage.interface';
import { ISystemConfigRepository } from 'src/interfaces/system-config.interface'; import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface';
import { Orientation } from 'src/services/metadata.service'; import { Orientation } from 'src/services/metadata.service';
import { CacheControl, ImmichFileResponse } from 'src/utils/file'; import { CacheControl, ImmichFileResponse } from 'src/utils/file';
import { mimeTypes } from 'src/utils/mime-types'; import { mimeTypes } from 'src/utils/mime-types';
@ -66,7 +66,7 @@ export class PersonService {
@Inject(IMoveRepository) moveRepository: IMoveRepository, @Inject(IMoveRepository) moveRepository: IMoveRepository,
@Inject(IMediaRepository) private mediaRepository: IMediaRepository, @Inject(IMediaRepository) private mediaRepository: IMediaRepository,
@Inject(IPersonRepository) private repository: IPersonRepository, @Inject(IPersonRepository) private repository: IPersonRepository,
@Inject(ISystemConfigRepository) configRepository: ISystemConfigRepository, @Inject(ISystemMetadataRepository) systemMetadataRepository: ISystemMetadataRepository,
@Inject(IStorageRepository) private storageRepository: IStorageRepository, @Inject(IStorageRepository) private storageRepository: IStorageRepository,
@Inject(IJobRepository) private jobRepository: IJobRepository, @Inject(IJobRepository) private jobRepository: IJobRepository,
@Inject(ISearchRepository) private smartInfoRepository: ISearchRepository, @Inject(ISearchRepository) private smartInfoRepository: ISearchRepository,
@ -75,14 +75,14 @@ export class PersonService {
) { ) {
this.access = AccessCore.create(accessRepository); this.access = AccessCore.create(accessRepository);
this.logger.setContext(PersonService.name); this.logger.setContext(PersonService.name);
this.configCore = SystemConfigCore.create(configRepository, this.logger); this.configCore = SystemConfigCore.create(systemMetadataRepository, this.logger);
this.storageCore = StorageCore.create( this.storageCore = StorageCore.create(
assetRepository, assetRepository,
cryptoRepository, cryptoRepository,
moveRepository, moveRepository,
repository, repository,
storageRepository, storageRepository,
configRepository, systemMetadataRepository,
this.logger, this.logger,
); );
} }

View file

@ -6,7 +6,7 @@ import { IMetadataRepository } from 'src/interfaces/metadata.interface';
import { IPartnerRepository } from 'src/interfaces/partner.interface'; import { IPartnerRepository } from 'src/interfaces/partner.interface';
import { IPersonRepository } from 'src/interfaces/person.interface'; import { IPersonRepository } from 'src/interfaces/person.interface';
import { ISearchRepository } from 'src/interfaces/search.interface'; import { ISearchRepository } from 'src/interfaces/search.interface';
import { ISystemConfigRepository } from 'src/interfaces/system-config.interface'; import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface';
import { SearchService } from 'src/services/search.service'; import { SearchService } from 'src/services/search.service';
import { assetStub } from 'test/fixtures/asset.stub'; import { assetStub } from 'test/fixtures/asset.stub';
import { authStub } from 'test/fixtures/auth.stub'; import { authStub } from 'test/fixtures/auth.stub';
@ -18,7 +18,7 @@ import { newMetadataRepositoryMock } from 'test/repositories/metadata.repository
import { newPartnerRepositoryMock } from 'test/repositories/partner.repository.mock'; import { newPartnerRepositoryMock } from 'test/repositories/partner.repository.mock';
import { newPersonRepositoryMock } from 'test/repositories/person.repository.mock'; import { newPersonRepositoryMock } from 'test/repositories/person.repository.mock';
import { newSearchRepositoryMock } from 'test/repositories/search.repository.mock'; import { newSearchRepositoryMock } from 'test/repositories/search.repository.mock';
import { newSystemConfigRepositoryMock } from 'test/repositories/system-config.repository.mock'; import { newSystemMetadataRepositoryMock } from 'test/repositories/system-metadata.repository.mock';
import { Mocked, vitest } from 'vitest'; import { Mocked, vitest } from 'vitest';
vitest.useFakeTimers(); vitest.useFakeTimers();
@ -26,7 +26,7 @@ vitest.useFakeTimers();
describe(SearchService.name, () => { describe(SearchService.name, () => {
let sut: SearchService; let sut: SearchService;
let assetMock: Mocked<IAssetRepository>; let assetMock: Mocked<IAssetRepository>;
let configMock: Mocked<ISystemConfigRepository>; let systemMock: Mocked<ISystemMetadataRepository>;
let machineMock: Mocked<IMachineLearningRepository>; let machineMock: Mocked<IMachineLearningRepository>;
let personMock: Mocked<IPersonRepository>; let personMock: Mocked<IPersonRepository>;
let searchMock: Mocked<ISearchRepository>; let searchMock: Mocked<ISearchRepository>;
@ -36,7 +36,7 @@ describe(SearchService.name, () => {
beforeEach(() => { beforeEach(() => {
assetMock = newAssetRepositoryMock(); assetMock = newAssetRepositoryMock();
configMock = newSystemConfigRepositoryMock(); systemMock = newSystemMetadataRepositoryMock();
machineMock = newMachineLearningRepositoryMock(); machineMock = newMachineLearningRepositoryMock();
personMock = newPersonRepositoryMock(); personMock = newPersonRepositoryMock();
searchMock = newSearchRepositoryMock(); searchMock = newSearchRepositoryMock();
@ -45,7 +45,7 @@ describe(SearchService.name, () => {
loggerMock = newLoggerRepositoryMock(); loggerMock = newLoggerRepositoryMock();
sut = new SearchService( sut = new SearchService(
configMock, systemMock,
machineMock, machineMock,
personMock, personMock,
searchMock, searchMock,

View file

@ -23,7 +23,7 @@ import { IMetadataRepository } from 'src/interfaces/metadata.interface';
import { IPartnerRepository } from 'src/interfaces/partner.interface'; import { IPartnerRepository } from 'src/interfaces/partner.interface';
import { IPersonRepository } from 'src/interfaces/person.interface'; import { IPersonRepository } from 'src/interfaces/person.interface';
import { ISearchRepository, SearchExploreItem } from 'src/interfaces/search.interface'; import { ISearchRepository, SearchExploreItem } from 'src/interfaces/search.interface';
import { ISystemConfigRepository } from 'src/interfaces/system-config.interface'; import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface';
import { isSmartSearchEnabled } from 'src/utils/misc'; import { isSmartSearchEnabled } from 'src/utils/misc';
@Injectable() @Injectable()
@ -31,7 +31,7 @@ export class SearchService {
private configCore: SystemConfigCore; private configCore: SystemConfigCore;
constructor( constructor(
@Inject(ISystemConfigRepository) configRepository: ISystemConfigRepository, @Inject(ISystemMetadataRepository) systemMetadataRepository: ISystemMetadataRepository,
@Inject(IMachineLearningRepository) private machineLearning: IMachineLearningRepository, @Inject(IMachineLearningRepository) private machineLearning: IMachineLearningRepository,
@Inject(IPersonRepository) private personRepository: IPersonRepository, @Inject(IPersonRepository) private personRepository: IPersonRepository,
@Inject(ISearchRepository) private searchRepository: ISearchRepository, @Inject(ISearchRepository) private searchRepository: ISearchRepository,
@ -41,7 +41,7 @@ export class SearchService {
@Inject(ILoggerRepository) private logger: ILoggerRepository, @Inject(ILoggerRepository) private logger: ILoggerRepository,
) { ) {
this.logger.setContext(SearchService.name); this.logger.setContext(SearchService.name);
this.configCore = SystemConfigCore.create(configRepository, logger); this.configCore = SystemConfigCore.create(systemMetadataRepository, logger);
} }
async searchPerson(auth: AuthDto, dto: SearchPeopleDto): Promise<PersonResponseDto[]> { async searchPerson(auth: AuthDto, dto: SearchPeopleDto): Promise<PersonResponseDto[]> {

View file

@ -3,14 +3,12 @@ import { IEventRepository } from 'src/interfaces/event.interface';
import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { ILoggerRepository } from 'src/interfaces/logger.interface';
import { IServerInfoRepository } from 'src/interfaces/server-info.interface'; import { IServerInfoRepository } from 'src/interfaces/server-info.interface';
import { IStorageRepository } from 'src/interfaces/storage.interface'; import { IStorageRepository } from 'src/interfaces/storage.interface';
import { ISystemConfigRepository } from 'src/interfaces/system-config.interface';
import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface';
import { IUserRepository } from 'src/interfaces/user.interface'; import { IUserRepository } from 'src/interfaces/user.interface';
import { ServerInfoService } from 'src/services/server-info.service'; import { ServerInfoService } from 'src/services/server-info.service';
import { newEventRepositoryMock } from 'test/repositories/event.repository.mock'; import { newEventRepositoryMock } from 'test/repositories/event.repository.mock';
import { newLoggerRepositoryMock } from 'test/repositories/logger.repository.mock'; import { newLoggerRepositoryMock } from 'test/repositories/logger.repository.mock';
import { newStorageRepositoryMock } from 'test/repositories/storage.repository.mock'; import { newStorageRepositoryMock } from 'test/repositories/storage.repository.mock';
import { newSystemConfigRepositoryMock } from 'test/repositories/system-config.repository.mock';
import { newServerInfoRepositoryMock } from 'test/repositories/system-info.repository.mock'; import { newServerInfoRepositoryMock } from 'test/repositories/system-info.repository.mock';
import { newSystemMetadataRepositoryMock } from 'test/repositories/system-metadata.repository.mock'; import { newSystemMetadataRepositoryMock } from 'test/repositories/system-metadata.repository.mock';
import { newUserRepositoryMock } from 'test/repositories/user.repository.mock'; import { newUserRepositoryMock } from 'test/repositories/user.repository.mock';
@ -19,31 +17,21 @@ import { Mocked } from 'vitest';
describe(ServerInfoService.name, () => { describe(ServerInfoService.name, () => {
let sut: ServerInfoService; let sut: ServerInfoService;
let eventMock: Mocked<IEventRepository>; let eventMock: Mocked<IEventRepository>;
let configMock: Mocked<ISystemConfigRepository>;
let serverInfoMock: Mocked<IServerInfoRepository>; let serverInfoMock: Mocked<IServerInfoRepository>;
let storageMock: Mocked<IStorageRepository>; let storageMock: Mocked<IStorageRepository>;
let userMock: Mocked<IUserRepository>; let userMock: Mocked<IUserRepository>;
let systemMetadataMock: Mocked<ISystemMetadataRepository>; let systemMock: Mocked<ISystemMetadataRepository>;
let loggerMock: Mocked<ILoggerRepository>; let loggerMock: Mocked<ILoggerRepository>;
beforeEach(() => { beforeEach(() => {
configMock = newSystemConfigRepositoryMock();
eventMock = newEventRepositoryMock(); eventMock = newEventRepositoryMock();
serverInfoMock = newServerInfoRepositoryMock(); serverInfoMock = newServerInfoRepositoryMock();
storageMock = newStorageRepositoryMock(); storageMock = newStorageRepositoryMock();
userMock = newUserRepositoryMock(); userMock = newUserRepositoryMock();
systemMetadataMock = newSystemMetadataRepositoryMock(); systemMock = newSystemMetadataRepositoryMock();
loggerMock = newLoggerRepositoryMock(); loggerMock = newLoggerRepositoryMock();
sut = new ServerInfoService( sut = new ServerInfoService(eventMock, userMock, serverInfoMock, storageMock, systemMock, loggerMock);
eventMock,
configMock,
userMock,
serverInfoMock,
storageMock,
systemMetadataMock,
loggerMock,
);
}); });
it('should work', () => { it('should work', () => {
@ -188,7 +176,7 @@ describe(ServerInfoService.name, () => {
trash: true, trash: true,
email: false, email: false,
}); });
expect(configMock.load).toHaveBeenCalled(); expect(systemMock.get).toHaveBeenCalled();
}); });
}); });
@ -203,7 +191,7 @@ describe(ServerInfoService.name, () => {
isOnboarded: false, isOnboarded: false,
externalDomain: '', externalDomain: '',
}); });
expect(configMock.load).toHaveBeenCalled(); expect(systemMock.get).toHaveBeenCalled();
}); });
}); });

View file

@ -18,7 +18,6 @@ import { ClientEvent, IEventRepository, ServerEvent, ServerEventMap } from 'src/
import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { ILoggerRepository } from 'src/interfaces/logger.interface';
import { IServerInfoRepository } from 'src/interfaces/server-info.interface'; import { IServerInfoRepository } from 'src/interfaces/server-info.interface';
import { IStorageRepository } from 'src/interfaces/storage.interface'; import { IStorageRepository } from 'src/interfaces/storage.interface';
import { ISystemConfigRepository } from 'src/interfaces/system-config.interface';
import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface';
import { IUserRepository, UserStatsQueryResponse } from 'src/interfaces/user.interface'; import { IUserRepository, UserStatsQueryResponse } from 'src/interfaces/user.interface';
import { asHumanReadable } from 'src/utils/bytes'; import { asHumanReadable } from 'src/utils/bytes';
@ -34,7 +33,6 @@ export class ServerInfoService {
constructor( constructor(
@Inject(IEventRepository) private eventRepository: IEventRepository, @Inject(IEventRepository) private eventRepository: IEventRepository,
@Inject(ISystemConfigRepository) configRepository: ISystemConfigRepository,
@Inject(IUserRepository) private userRepository: IUserRepository, @Inject(IUserRepository) private userRepository: IUserRepository,
@Inject(IServerInfoRepository) private repository: IServerInfoRepository, @Inject(IServerInfoRepository) private repository: IServerInfoRepository,
@Inject(IStorageRepository) private storageRepository: IStorageRepository, @Inject(IStorageRepository) private storageRepository: IStorageRepository,
@ -42,7 +40,7 @@ export class ServerInfoService {
@Inject(ILoggerRepository) private logger: ILoggerRepository, @Inject(ILoggerRepository) private logger: ILoggerRepository,
) { ) {
this.logger.setContext(ServerInfoService.name); this.logger.setContext(ServerInfoService.name);
this.configCore = SystemConfigCore.create(configRepository, this.logger); this.configCore = SystemConfigCore.create(systemMetadataRepository, this.logger);
} }
onConnect() {} onConnect() {}

View file

@ -1,27 +1,27 @@
import { SystemConfigKey } from 'src/entities/system-config.entity';
import { IAssetRepository, WithoutProperty } from 'src/interfaces/asset.interface'; import { IAssetRepository, WithoutProperty } from 'src/interfaces/asset.interface';
import { IDatabaseRepository } from 'src/interfaces/database.interface'; import { IDatabaseRepository } from 'src/interfaces/database.interface';
import { IJobRepository, JobName, JobStatus } from 'src/interfaces/job.interface'; import { IJobRepository, JobName, JobStatus } from 'src/interfaces/job.interface';
import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { ILoggerRepository } from 'src/interfaces/logger.interface';
import { IMachineLearningRepository } from 'src/interfaces/machine-learning.interface'; import { IMachineLearningRepository } from 'src/interfaces/machine-learning.interface';
import { ISearchRepository } from 'src/interfaces/search.interface'; import { ISearchRepository } from 'src/interfaces/search.interface';
import { ISystemConfigRepository } from 'src/interfaces/system-config.interface'; import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface';
import { SmartInfoService } from 'src/services/smart-info.service'; import { SmartInfoService } from 'src/services/smart-info.service';
import { getCLIPModelInfo } from 'src/utils/misc'; import { getCLIPModelInfo } from 'src/utils/misc';
import { assetStub } from 'test/fixtures/asset.stub'; import { assetStub } from 'test/fixtures/asset.stub';
import { systemConfigStub } from 'test/fixtures/system-config.stub';
import { newAssetRepositoryMock } from 'test/repositories/asset.repository.mock'; import { newAssetRepositoryMock } from 'test/repositories/asset.repository.mock';
import { newDatabaseRepositoryMock } from 'test/repositories/database.repository.mock'; import { newDatabaseRepositoryMock } from 'test/repositories/database.repository.mock';
import { newJobRepositoryMock } from 'test/repositories/job.repository.mock'; import { newJobRepositoryMock } from 'test/repositories/job.repository.mock';
import { newLoggerRepositoryMock } from 'test/repositories/logger.repository.mock'; import { newLoggerRepositoryMock } from 'test/repositories/logger.repository.mock';
import { newMachineLearningRepositoryMock } from 'test/repositories/machine-learning.repository.mock'; import { newMachineLearningRepositoryMock } from 'test/repositories/machine-learning.repository.mock';
import { newSearchRepositoryMock } from 'test/repositories/search.repository.mock'; import { newSearchRepositoryMock } from 'test/repositories/search.repository.mock';
import { newSystemConfigRepositoryMock } from 'test/repositories/system-config.repository.mock'; import { newSystemMetadataRepositoryMock } from 'test/repositories/system-metadata.repository.mock';
import { Mocked } from 'vitest'; import { Mocked } from 'vitest';
describe(SmartInfoService.name, () => { describe(SmartInfoService.name, () => {
let sut: SmartInfoService; let sut: SmartInfoService;
let assetMock: Mocked<IAssetRepository>; let assetMock: Mocked<IAssetRepository>;
let configMock: Mocked<ISystemConfigRepository>; let systemMock: Mocked<ISystemMetadataRepository>;
let jobMock: Mocked<IJobRepository>; let jobMock: Mocked<IJobRepository>;
let searchMock: Mocked<ISearchRepository>; let searchMock: Mocked<ISearchRepository>;
let machineMock: Mocked<IMachineLearningRepository>; let machineMock: Mocked<IMachineLearningRepository>;
@ -30,13 +30,13 @@ describe(SmartInfoService.name, () => {
beforeEach(() => { beforeEach(() => {
assetMock = newAssetRepositoryMock(); assetMock = newAssetRepositoryMock();
configMock = newSystemConfigRepositoryMock(); systemMock = newSystemMetadataRepositoryMock();
searchMock = newSearchRepositoryMock(); searchMock = newSearchRepositoryMock();
jobMock = newJobRepositoryMock(); jobMock = newJobRepositoryMock();
machineMock = newMachineLearningRepositoryMock(); machineMock = newMachineLearningRepositoryMock();
databaseMock = newDatabaseRepositoryMock(); databaseMock = newDatabaseRepositoryMock();
loggerMock = newLoggerRepositoryMock(); loggerMock = newLoggerRepositoryMock();
sut = new SmartInfoService(assetMock, databaseMock, jobMock, machineMock, searchMock, configMock, loggerMock); sut = new SmartInfoService(assetMock, databaseMock, jobMock, machineMock, searchMock, systemMock, loggerMock);
assetMock.getByIds.mockResolvedValue([assetStub.image]); assetMock.getByIds.mockResolvedValue([assetStub.image]);
}); });
@ -47,7 +47,7 @@ describe(SmartInfoService.name, () => {
describe('handleQueueEncodeClip', () => { describe('handleQueueEncodeClip', () => {
it('should do nothing if machine learning is disabled', async () => { it('should do nothing if machine learning is disabled', async () => {
configMock.load.mockResolvedValue([{ key: SystemConfigKey.MACHINE_LEARNING_ENABLED, value: false }]); systemMock.get.mockResolvedValue(systemConfigStub.machineLearningDisabled);
await sut.handleQueueEncodeClip({}); await sut.handleQueueEncodeClip({});
@ -84,7 +84,7 @@ describe(SmartInfoService.name, () => {
describe('handleEncodeClip', () => { describe('handleEncodeClip', () => {
it('should do nothing if machine learning is disabled', async () => { it('should do nothing if machine learning is disabled', async () => {
configMock.load.mockResolvedValue([{ key: SystemConfigKey.MACHINE_LEARNING_ENABLED, value: false }]); systemMock.get.mockResolvedValue(systemConfigStub.machineLearningDisabled);
expect(await sut.handleEncodeClip({ id: '123' })).toEqual(JobStatus.SKIPPED); expect(await sut.handleEncodeClip({ id: '123' })).toEqual(JobStatus.SKIPPED);

View file

@ -14,7 +14,7 @@ import {
import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { ILoggerRepository } from 'src/interfaces/logger.interface';
import { IMachineLearningRepository } from 'src/interfaces/machine-learning.interface'; import { IMachineLearningRepository } from 'src/interfaces/machine-learning.interface';
import { ISearchRepository } from 'src/interfaces/search.interface'; import { ISearchRepository } from 'src/interfaces/search.interface';
import { ISystemConfigRepository } from 'src/interfaces/system-config.interface'; import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface';
import { isSmartSearchEnabled } from 'src/utils/misc'; import { isSmartSearchEnabled } from 'src/utils/misc';
import { usePagination } from 'src/utils/pagination'; import { usePagination } from 'src/utils/pagination';
@ -28,11 +28,11 @@ export class SmartInfoService {
@Inject(IJobRepository) private jobRepository: IJobRepository, @Inject(IJobRepository) private jobRepository: IJobRepository,
@Inject(IMachineLearningRepository) private machineLearning: IMachineLearningRepository, @Inject(IMachineLearningRepository) private machineLearning: IMachineLearningRepository,
@Inject(ISearchRepository) private repository: ISearchRepository, @Inject(ISearchRepository) private repository: ISearchRepository,
@Inject(ISystemConfigRepository) configRepository: ISystemConfigRepository, @Inject(ISystemMetadataRepository) systemMetadataRepository: ISystemMetadataRepository,
@Inject(ILoggerRepository) private logger: ILoggerRepository, @Inject(ILoggerRepository) private logger: ILoggerRepository,
) { ) {
this.logger.setContext(SmartInfoService.name); this.logger.setContext(SmartInfoService.name);
this.configCore = SystemConfigCore.create(configRepository, this.logger); this.configCore = SystemConfigCore.create(systemMetadataRepository, this.logger);
} }
async init() { async init() {

View file

@ -3,7 +3,6 @@ import { SystemConfig, defaults } from 'src/config';
import { SystemConfigCore } from 'src/cores/system-config.core'; import { SystemConfigCore } from 'src/cores/system-config.core';
import { AssetEntity } from 'src/entities/asset.entity'; import { AssetEntity } from 'src/entities/asset.entity';
import { AssetPathType } from 'src/entities/move.entity'; import { AssetPathType } from 'src/entities/move.entity';
import { SystemConfigKey } from 'src/entities/system-config.entity';
import { IAlbumRepository } from 'src/interfaces/album.interface'; import { IAlbumRepository } from 'src/interfaces/album.interface';
import { IAssetRepository } from 'src/interfaces/asset.interface'; import { IAssetRepository } from 'src/interfaces/asset.interface';
import { ICryptoRepository } from 'src/interfaces/crypto.interface'; import { ICryptoRepository } from 'src/interfaces/crypto.interface';
@ -13,7 +12,7 @@ import { ILoggerRepository } from 'src/interfaces/logger.interface';
import { IMoveRepository } from 'src/interfaces/move.interface'; import { IMoveRepository } from 'src/interfaces/move.interface';
import { IPersonRepository } from 'src/interfaces/person.interface'; import { IPersonRepository } from 'src/interfaces/person.interface';
import { IStorageRepository } from 'src/interfaces/storage.interface'; import { IStorageRepository } from 'src/interfaces/storage.interface';
import { ISystemConfigRepository } from 'src/interfaces/system-config.interface'; import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface';
import { IUserRepository } from 'src/interfaces/user.interface'; import { IUserRepository } from 'src/interfaces/user.interface';
import { StorageTemplateService } from 'src/services/storage-template.service'; import { StorageTemplateService } from 'src/services/storage-template.service';
import { assetStub } from 'test/fixtures/asset.stub'; import { assetStub } from 'test/fixtures/asset.stub';
@ -26,7 +25,7 @@ import { newLoggerRepositoryMock } from 'test/repositories/logger.repository.moc
import { newMoveRepositoryMock } from 'test/repositories/move.repository.mock'; import { newMoveRepositoryMock } from 'test/repositories/move.repository.mock';
import { newPersonRepositoryMock } from 'test/repositories/person.repository.mock'; import { newPersonRepositoryMock } from 'test/repositories/person.repository.mock';
import { newStorageRepositoryMock } from 'test/repositories/storage.repository.mock'; import { newStorageRepositoryMock } from 'test/repositories/storage.repository.mock';
import { newSystemConfigRepositoryMock } from 'test/repositories/system-config.repository.mock'; import { newSystemMetadataRepositoryMock } from 'test/repositories/system-metadata.repository.mock';
import { newUserRepositoryMock } from 'test/repositories/user.repository.mock'; import { newUserRepositoryMock } from 'test/repositories/user.repository.mock';
import { Mocked } from 'vitest'; import { Mocked } from 'vitest';
@ -34,13 +33,13 @@ describe(StorageTemplateService.name, () => {
let sut: StorageTemplateService; let sut: StorageTemplateService;
let albumMock: Mocked<IAlbumRepository>; let albumMock: Mocked<IAlbumRepository>;
let assetMock: Mocked<IAssetRepository>; let assetMock: Mocked<IAssetRepository>;
let configMock: Mocked<ISystemConfigRepository>; let cryptoMock: Mocked<ICryptoRepository>;
let databaseMock: Mocked<IDatabaseRepository>;
let moveMock: Mocked<IMoveRepository>; let moveMock: Mocked<IMoveRepository>;
let personMock: Mocked<IPersonRepository>; let personMock: Mocked<IPersonRepository>;
let storageMock: Mocked<IStorageRepository>; let storageMock: Mocked<IStorageRepository>;
let systemMock: Mocked<ISystemMetadataRepository>;
let userMock: Mocked<IUserRepository>; let userMock: Mocked<IUserRepository>;
let cryptoMock: Mocked<ICryptoRepository>;
let databaseMock: Mocked<IDatabaseRepository>;
let loggerMock: Mocked<ILoggerRepository>; let loggerMock: Mocked<ILoggerRepository>;
it('should work', () => { it('should work', () => {
@ -48,23 +47,23 @@ describe(StorageTemplateService.name, () => {
}); });
beforeEach(() => { beforeEach(() => {
configMock = newSystemConfigRepositoryMock();
assetMock = newAssetRepositoryMock(); assetMock = newAssetRepositoryMock();
albumMock = newAlbumRepositoryMock(); albumMock = newAlbumRepositoryMock();
cryptoMock = newCryptoRepositoryMock();
databaseMock = newDatabaseRepositoryMock();
moveMock = newMoveRepositoryMock(); moveMock = newMoveRepositoryMock();
personMock = newPersonRepositoryMock(); personMock = newPersonRepositoryMock();
storageMock = newStorageRepositoryMock(); storageMock = newStorageRepositoryMock();
systemMock = newSystemMetadataRepositoryMock();
userMock = newUserRepositoryMock(); userMock = newUserRepositoryMock();
cryptoMock = newCryptoRepositoryMock();
databaseMock = newDatabaseRepositoryMock();
loggerMock = newLoggerRepositoryMock(); loggerMock = newLoggerRepositoryMock();
configMock.load.mockResolvedValue([{ key: SystemConfigKey.STORAGE_TEMPLATE_ENABLED, value: true }]); systemMock.get.mockResolvedValue({ storageTemplate: { enabled: true } });
sut = new StorageTemplateService( sut = new StorageTemplateService(
albumMock, albumMock,
assetMock, assetMock,
configMock, systemMock,
moveMock, moveMock,
personMock, personMock,
storageMock, storageMock,
@ -74,7 +73,7 @@ describe(StorageTemplateService.name, () => {
loggerMock, loggerMock,
); );
SystemConfigCore.create(configMock, loggerMock).config$.next(defaults); SystemConfigCore.create(systemMock, loggerMock).config$.next(defaults);
}); });
describe('onValidateConfig', () => { describe('onValidateConfig', () => {
@ -108,7 +107,7 @@ describe(StorageTemplateService.name, () => {
describe('handleMigrationSingle', () => { describe('handleMigrationSingle', () => {
it('should skip when storage template is disabled', async () => { it('should skip when storage template is disabled', async () => {
configMock.load.mockResolvedValue([{ key: SystemConfigKey.STORAGE_TEMPLATE_ENABLED, value: false }]); systemMock.get.mockResolvedValue({ storageTemplate: { enabled: false } });
await expect(sut.handleMigrationSingle({ id: assetStub.image.id })).resolves.toBe(JobStatus.SKIPPED); await expect(sut.handleMigrationSingle({ id: assetStub.image.id })).resolves.toBe(JobStatus.SKIPPED);
expect(assetMock.getByIds).not.toHaveBeenCalled(); expect(assetMock.getByIds).not.toHaveBeenCalled();
expect(storageMock.checkFileExists).not.toHaveBeenCalled(); expect(storageMock.checkFileExists).not.toHaveBeenCalled();

View file

@ -28,7 +28,7 @@ import { ILoggerRepository } from 'src/interfaces/logger.interface';
import { IMoveRepository } from 'src/interfaces/move.interface'; import { IMoveRepository } from 'src/interfaces/move.interface';
import { IPersonRepository } from 'src/interfaces/person.interface'; import { IPersonRepository } from 'src/interfaces/person.interface';
import { IStorageRepository } from 'src/interfaces/storage.interface'; import { IStorageRepository } from 'src/interfaces/storage.interface';
import { ISystemConfigRepository } from 'src/interfaces/system-config.interface'; import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface';
import { IUserRepository } from 'src/interfaces/user.interface'; import { IUserRepository } from 'src/interfaces/user.interface';
import { getLivePhotoMotionFilename } from 'src/utils/file'; import { getLivePhotoMotionFilename } from 'src/utils/file';
import { usePagination } from 'src/utils/pagination'; import { usePagination } from 'src/utils/pagination';
@ -65,7 +65,7 @@ export class StorageTemplateService {
constructor( constructor(
@Inject(IAlbumRepository) private albumRepository: IAlbumRepository, @Inject(IAlbumRepository) private albumRepository: IAlbumRepository,
@Inject(IAssetRepository) private assetRepository: IAssetRepository, @Inject(IAssetRepository) private assetRepository: IAssetRepository,
@Inject(ISystemConfigRepository) configRepository: ISystemConfigRepository, @Inject(ISystemMetadataRepository) systemMetadataRepository: ISystemMetadataRepository,
@Inject(IMoveRepository) moveRepository: IMoveRepository, @Inject(IMoveRepository) moveRepository: IMoveRepository,
@Inject(IPersonRepository) personRepository: IPersonRepository, @Inject(IPersonRepository) personRepository: IPersonRepository,
@Inject(IStorageRepository) private storageRepository: IStorageRepository, @Inject(IStorageRepository) private storageRepository: IStorageRepository,
@ -75,7 +75,7 @@ export class StorageTemplateService {
@Inject(ILoggerRepository) private logger: ILoggerRepository, @Inject(ILoggerRepository) private logger: ILoggerRepository,
) { ) {
this.logger.setContext(StorageTemplateService.name); this.logger.setContext(StorageTemplateService.name);
this.configCore = SystemConfigCore.create(configRepository, this.logger); this.configCore = SystemConfigCore.create(systemMetadataRepository, this.logger);
this.configCore.config$.subscribe((config) => this.onConfig(config)); this.configCore.config$.subscribe((config) => this.onConfig(config));
this.storageCore = StorageCore.create( this.storageCore = StorageCore.create(
assetRepository, assetRepository,
@ -83,7 +83,7 @@ export class StorageTemplateService {
moveRepository, moveRepository,
personRepository, personRepository,
storageRepository, storageRepository,
configRepository, systemMetadataRepository,
this.logger, this.logger,
); );
} }

View file

@ -12,24 +12,25 @@ import {
VideoCodec, VideoCodec,
defaults, defaults,
} from 'src/config'; } from 'src/config';
import { SystemConfigEntity, SystemConfigKey } from 'src/entities/system-config.entity'; import { SystemMetadataKey } from 'src/entities/system-metadata.entity';
import { IEventRepository, ServerEvent } from 'src/interfaces/event.interface'; import { IEventRepository, ServerEvent } from 'src/interfaces/event.interface';
import { QueueName } from 'src/interfaces/job.interface'; import { QueueName } from 'src/interfaces/job.interface';
import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { ILoggerRepository } from 'src/interfaces/logger.interface';
import { ISearchRepository } from 'src/interfaces/search.interface'; import { ISearchRepository } from 'src/interfaces/search.interface';
import { ISystemConfigRepository } from 'src/interfaces/system-config.interface'; import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface';
import { SystemConfigService } from 'src/services/system-config.service'; import { SystemConfigService } from 'src/services/system-config.service';
import { newEventRepositoryMock } from 'test/repositories/event.repository.mock'; import { newEventRepositoryMock } from 'test/repositories/event.repository.mock';
import { newLoggerRepositoryMock } from 'test/repositories/logger.repository.mock'; import { newLoggerRepositoryMock } from 'test/repositories/logger.repository.mock';
import { newSystemConfigRepositoryMock } from 'test/repositories/system-config.repository.mock'; import { newSystemMetadataRepositoryMock } from 'test/repositories/system-metadata.repository.mock';
import { DeepPartial } from 'typeorm';
import { Mocked } from 'vitest'; import { Mocked } from 'vitest';
const updates: SystemConfigEntity[] = [ const partialConfig = {
{ key: SystemConfigKey.FFMPEG_CRF, value: 30 }, ffmpeg: { crf: 30 },
{ key: SystemConfigKey.OAUTH_AUTO_LAUNCH, value: true }, oauth: { autoLaunch: true },
{ key: SystemConfigKey.TRASH_DAYS, value: 10 }, trash: { days: 10 },
{ key: SystemConfigKey.USER_DELETE_DELAY, value: 15 }, user: { deleteDelay: 15 },
]; } satisfies DeepPartial<SystemConfig>;
const updatedConfig = Object.freeze<SystemConfig>({ const updatedConfig = Object.freeze<SystemConfig>({
job: { job: {
@ -171,17 +172,17 @@ const updatedConfig = Object.freeze<SystemConfig>({
describe(SystemConfigService.name, () => { describe(SystemConfigService.name, () => {
let sut: SystemConfigService; let sut: SystemConfigService;
let configMock: Mocked<ISystemConfigRepository>; let systemMock: Mocked<ISystemMetadataRepository>;
let eventMock: Mocked<IEventRepository>; let eventMock: Mocked<IEventRepository>;
let loggerMock: Mocked<ILoggerRepository>; let loggerMock: Mocked<ILoggerRepository>;
let smartInfoMock: Mocked<ISearchRepository>; let smartInfoMock: Mocked<ISearchRepository>;
beforeEach(() => { beforeEach(() => {
delete process.env.IMMICH_CONFIG_FILE; delete process.env.IMMICH_CONFIG_FILE;
configMock = newSystemConfigRepositoryMock(); systemMock = newSystemMetadataRepositoryMock();
eventMock = newEventRepositoryMock(); eventMock = newEventRepositoryMock();
loggerMock = newLoggerRepositoryMock(); loggerMock = newLoggerRepositoryMock();
sut = new SystemConfigService(configMock, eventMock, loggerMock, smartInfoMock); sut = new SystemConfigService(systemMock, eventMock, loggerMock, smartInfoMock);
}); });
it('should work', () => { it('should work', () => {
@ -190,44 +191,39 @@ describe(SystemConfigService.name, () => {
describe('getDefaults', () => { describe('getDefaults', () => {
it('should return the default config', () => { it('should return the default config', () => {
configMock.load.mockResolvedValue(updates); systemMock.get.mockResolvedValue(partialConfig);
expect(sut.getDefaults()).toEqual(defaults); expect(sut.getDefaults()).toEqual(defaults);
expect(configMock.load).not.toHaveBeenCalled(); expect(systemMock.get).not.toHaveBeenCalled();
}); });
}); });
describe('getConfig', () => { describe('getConfig', () => {
it('should return the default config', async () => { it('should return the default config', async () => {
configMock.load.mockResolvedValue([]); systemMock.get.mockResolvedValue({});
await expect(sut.getConfig()).resolves.toEqual(defaults); await expect(sut.getConfig()).resolves.toEqual(defaults);
}); });
it('should merge the overrides', async () => { it('should merge the overrides', async () => {
configMock.load.mockResolvedValue([ systemMock.get.mockResolvedValue({
{ key: SystemConfigKey.FFMPEG_CRF, value: 30 }, ffmpeg: { crf: 30 },
{ key: SystemConfigKey.OAUTH_AUTO_LAUNCH, value: true }, oauth: { autoLaunch: true },
{ key: SystemConfigKey.TRASH_DAYS, value: 10 }, trash: { days: 10 },
{ key: SystemConfigKey.USER_DELETE_DELAY, value: 15 }, user: { deleteDelay: 15 },
]); });
await expect(sut.getConfig()).resolves.toEqual(updatedConfig); await expect(sut.getConfig()).resolves.toEqual(updatedConfig);
}); });
it('should load the config from a json file', async () => { it('should load the config from a json file', async () => {
process.env.IMMICH_CONFIG_FILE = 'immich-config.json'; process.env.IMMICH_CONFIG_FILE = 'immich-config.json';
const partialConfig = {
ffmpeg: { crf: 30 }, systemMock.readFile.mockResolvedValue(JSON.stringify(partialConfig));
oauth: { autoLaunch: true },
trash: { days: 10 },
user: { deleteDelay: 15 },
};
configMock.readFile.mockResolvedValue(JSON.stringify(partialConfig));
await expect(sut.getConfig()).resolves.toEqual(updatedConfig); await expect(sut.getConfig()).resolves.toEqual(updatedConfig);
expect(configMock.readFile).toHaveBeenCalledWith('immich-config.json'); expect(systemMock.readFile).toHaveBeenCalledWith('immich-config.json');
}); });
it('should load the config from a yaml file', async () => { it('should load the config from a yaml file', async () => {
@ -242,26 +238,26 @@ describe(SystemConfigService.name, () => {
user: user:
deleteDelay: 15 deleteDelay: 15
`; `;
configMock.readFile.mockResolvedValue(partialConfig); systemMock.readFile.mockResolvedValue(partialConfig);
await expect(sut.getConfig()).resolves.toEqual(updatedConfig); await expect(sut.getConfig()).resolves.toEqual(updatedConfig);
expect(configMock.readFile).toHaveBeenCalledWith('immich-config.yaml'); expect(systemMock.readFile).toHaveBeenCalledWith('immich-config.yaml');
}); });
it('should accept an empty configuration file', async () => { it('should accept an empty configuration file', async () => {
process.env.IMMICH_CONFIG_FILE = 'immich-config.json'; process.env.IMMICH_CONFIG_FILE = 'immich-config.json';
configMock.readFile.mockResolvedValue(JSON.stringify({})); systemMock.readFile.mockResolvedValue(JSON.stringify({}));
await expect(sut.getConfig()).resolves.toEqual(defaults); await expect(sut.getConfig()).resolves.toEqual(defaults);
expect(configMock.readFile).toHaveBeenCalledWith('immich-config.json'); expect(systemMock.readFile).toHaveBeenCalledWith('immich-config.json');
}); });
it('should allow underscores in the machine learning url', async () => { it('should allow underscores in the machine learning url', async () => {
process.env.IMMICH_CONFIG_FILE = 'immich-config.json'; process.env.IMMICH_CONFIG_FILE = 'immich-config.json';
const partialConfig = { machineLearning: { url: 'immich_machine_learning' } }; const partialConfig = { machineLearning: { url: 'immich_machine_learning' } };
configMock.readFile.mockResolvedValue(JSON.stringify(partialConfig)); systemMock.readFile.mockResolvedValue(JSON.stringify(partialConfig));
const config = await sut.getConfig(); const config = await sut.getConfig();
expect(config.machineLearning.url).toEqual('immich_machine_learning'); expect(config.machineLearning.url).toEqual('immich_machine_learning');
@ -272,7 +268,7 @@ describe(SystemConfigService.name, () => {
const partialConfig = ` const partialConfig = `
unknownOption: true unknownOption: true
`; `;
configMock.readFile.mockResolvedValue(partialConfig); systemMock.readFile.mockResolvedValue(partialConfig);
await sut.getConfig(); await sut.getConfig();
expect(loggerMock.warn).toHaveBeenCalled(); expect(loggerMock.warn).toHaveBeenCalled();
@ -290,7 +286,7 @@ describe(SystemConfigService.name, () => {
for (const test of tests) { for (const test of tests) {
it(`should ${test.should}`, async () => { it(`should ${test.should}`, async () => {
process.env.IMMICH_CONFIG_FILE = 'immich-config.json'; process.env.IMMICH_CONFIG_FILE = 'immich-config.json';
configMock.readFile.mockResolvedValue(JSON.stringify(test.config)); systemMock.readFile.mockResolvedValue(JSON.stringify(test.config));
if (test.warn) { if (test.warn) {
await sut.getConfig(); await sut.getConfig();
@ -338,20 +334,20 @@ describe(SystemConfigService.name, () => {
describe('updateConfig', () => { describe('updateConfig', () => {
it('should update the config and emit client and server events', async () => { it('should update the config and emit client and server events', async () => {
configMock.load.mockResolvedValue(updates); systemMock.get.mockResolvedValue(partialConfig);
await expect(sut.updateConfig(updatedConfig)).resolves.toEqual(updatedConfig); await expect(sut.updateConfig(updatedConfig)).resolves.toEqual(updatedConfig);
expect(eventMock.clientBroadcast).toHaveBeenCalled(); expect(eventMock.clientBroadcast).toHaveBeenCalled();
expect(eventMock.serverSend).toHaveBeenCalledWith(ServerEvent.CONFIG_UPDATE, null); expect(eventMock.serverSend).toHaveBeenCalledWith(ServerEvent.CONFIG_UPDATE, null);
expect(configMock.saveAll).toHaveBeenCalledWith(updates); expect(systemMock.set).toHaveBeenCalledWith(SystemMetadataKey.SYSTEM_CONFIG, partialConfig);
}); });
it('should throw an error if a config file is in use', async () => { it('should throw an error if a config file is in use', async () => {
process.env.IMMICH_CONFIG_FILE = 'immich-config.json'; process.env.IMMICH_CONFIG_FILE = 'immich-config.json';
configMock.readFile.mockResolvedValue(JSON.stringify({})); systemMock.readFile.mockResolvedValue(JSON.stringify({}));
await expect(sut.updateConfig(defaults)).rejects.toBeInstanceOf(BadRequestException); await expect(sut.updateConfig(defaults)).rejects.toBeInstanceOf(BadRequestException);
expect(configMock.saveAll).not.toHaveBeenCalled(); expect(systemMock.set).not.toHaveBeenCalled();
}); });
}); });

View file

@ -24,14 +24,14 @@ import {
} from 'src/interfaces/event.interface'; } from 'src/interfaces/event.interface';
import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { ILoggerRepository } from 'src/interfaces/logger.interface';
import { ISearchRepository } from 'src/interfaces/search.interface'; import { ISearchRepository } from 'src/interfaces/search.interface';
import { ISystemConfigRepository } from 'src/interfaces/system-config.interface'; import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface';
@Injectable() @Injectable()
export class SystemConfigService { export class SystemConfigService {
private core: SystemConfigCore; private core: SystemConfigCore;
constructor( constructor(
@Inject(ISystemConfigRepository) private repository: ISystemConfigRepository, @Inject(ISystemMetadataRepository) private repository: ISystemMetadataRepository,
@Inject(IEventRepository) private eventRepository: IEventRepository, @Inject(IEventRepository) private eventRepository: IEventRepository,
@Inject(ILoggerRepository) private logger: ILoggerRepository, @Inject(ILoggerRepository) private logger: ILoggerRepository,
@Inject(ISearchRepository) private smartInfoRepository: ISearchRepository, @Inject(ISearchRepository) private smartInfoRepository: ISearchRepository,

View file

@ -12,7 +12,7 @@ import { IJobRepository, JobName } from 'src/interfaces/job.interface';
import { ILibraryRepository } from 'src/interfaces/library.interface'; import { ILibraryRepository } from 'src/interfaces/library.interface';
import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { ILoggerRepository } from 'src/interfaces/logger.interface';
import { IStorageRepository } from 'src/interfaces/storage.interface'; import { IStorageRepository } from 'src/interfaces/storage.interface';
import { ISystemConfigRepository } from 'src/interfaces/system-config.interface'; import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface';
import { IUserRepository } from 'src/interfaces/user.interface'; import { IUserRepository } from 'src/interfaces/user.interface';
import { UserService } from 'src/services/user.service'; import { UserService } from 'src/services/user.service';
import { CacheControl, ImmichFileResponse } from 'src/utils/file'; import { CacheControl, ImmichFileResponse } from 'src/utils/file';
@ -25,7 +25,7 @@ import { newJobRepositoryMock } from 'test/repositories/job.repository.mock';
import { newLibraryRepositoryMock } from 'test/repositories/library.repository.mock'; import { newLibraryRepositoryMock } from 'test/repositories/library.repository.mock';
import { newLoggerRepositoryMock } from 'test/repositories/logger.repository.mock'; import { newLoggerRepositoryMock } from 'test/repositories/logger.repository.mock';
import { newStorageRepositoryMock } from 'test/repositories/storage.repository.mock'; import { newStorageRepositoryMock } from 'test/repositories/storage.repository.mock';
import { newSystemConfigRepositoryMock } from 'test/repositories/system-config.repository.mock'; import { newSystemMetadataRepositoryMock } from 'test/repositories/system-metadata.repository.mock';
import { newUserRepositoryMock } from 'test/repositories/user.repository.mock'; import { newUserRepositoryMock } from 'test/repositories/user.repository.mock';
import { Mocked, vitest } from 'vitest'; import { Mocked, vitest } from 'vitest';
@ -44,12 +44,12 @@ describe(UserService.name, () => {
let jobMock: Mocked<IJobRepository>; let jobMock: Mocked<IJobRepository>;
let libraryMock: Mocked<ILibraryRepository>; let libraryMock: Mocked<ILibraryRepository>;
let storageMock: Mocked<IStorageRepository>; let storageMock: Mocked<IStorageRepository>;
let configMock: Mocked<ISystemConfigRepository>; let systemMock: Mocked<ISystemMetadataRepository>;
let loggerMock: Mocked<ILoggerRepository>; let loggerMock: Mocked<ILoggerRepository>;
beforeEach(() => { beforeEach(() => {
albumMock = newAlbumRepositoryMock(); albumMock = newAlbumRepositoryMock();
configMock = newSystemConfigRepositoryMock(); systemMock = newSystemMetadataRepositoryMock();
cryptoRepositoryMock = newCryptoRepositoryMock(); cryptoRepositoryMock = newCryptoRepositoryMock();
jobMock = newJobRepositoryMock(); jobMock = newJobRepositoryMock();
libraryMock = newLibraryRepositoryMock(); libraryMock = newLibraryRepositoryMock();
@ -63,7 +63,7 @@ describe(UserService.name, () => {
jobMock, jobMock,
libraryMock, libraryMock,
storageMock, storageMock,
configMock, systemMock,
userMock, userMock,
loggerMock, loggerMock,
); );
@ -486,7 +486,7 @@ describe(UserService.name, () => {
}); });
it('should skip users not ready for deletion - deleteDelay30', async () => { it('should skip users not ready for deletion - deleteDelay30', async () => {
configMock.load.mockResolvedValue(systemConfigStub.deleteDelay30); systemMock.get.mockResolvedValue(systemConfigStub.deleteDelay30);
userMock.getDeletedUsers.mockResolvedValue([ userMock.getDeletedUsers.mockResolvedValue([
{}, {},
{ deletedAt: undefined }, { deletedAt: undefined },

View file

@ -13,7 +13,7 @@ import { IEntityJob, IJobRepository, JobName, JobStatus } from 'src/interfaces/j
import { ILibraryRepository } from 'src/interfaces/library.interface'; import { ILibraryRepository } from 'src/interfaces/library.interface';
import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { ILoggerRepository } from 'src/interfaces/logger.interface';
import { IStorageRepository } from 'src/interfaces/storage.interface'; import { IStorageRepository } from 'src/interfaces/storage.interface';
import { ISystemConfigRepository } from 'src/interfaces/system-config.interface'; import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface';
import { IUserRepository, UserFindOptions } from 'src/interfaces/user.interface'; import { IUserRepository, UserFindOptions } from 'src/interfaces/user.interface';
import { CacheControl, ImmichFileResponse } from 'src/utils/file'; import { CacheControl, ImmichFileResponse } from 'src/utils/file';
@ -28,13 +28,13 @@ export class UserService {
@Inject(IJobRepository) private jobRepository: IJobRepository, @Inject(IJobRepository) private jobRepository: IJobRepository,
@Inject(ILibraryRepository) libraryRepository: ILibraryRepository, @Inject(ILibraryRepository) libraryRepository: ILibraryRepository,
@Inject(IStorageRepository) private storageRepository: IStorageRepository, @Inject(IStorageRepository) private storageRepository: IStorageRepository,
@Inject(ISystemConfigRepository) configRepository: ISystemConfigRepository, @Inject(ISystemMetadataRepository) systemMetadataRepository: ISystemMetadataRepository,
@Inject(IUserRepository) private userRepository: IUserRepository, @Inject(IUserRepository) private userRepository: IUserRepository,
@Inject(ILoggerRepository) private logger: ILoggerRepository, @Inject(ILoggerRepository) private logger: ILoggerRepository,
) { ) {
this.userCore = UserCore.create(cryptoRepository, libraryRepository, userRepository); this.userCore = UserCore.create(cryptoRepository, libraryRepository, userRepository);
this.logger.setContext(UserService.name); this.logger.setContext(UserService.name);
this.configCore = SystemConfigCore.create(configRepository, this.logger); this.configCore = SystemConfigCore.create(systemMetadataRepository, this.logger);
} }
async listUsers(): Promise<UserResponseDto[]> { async listUsers(): Promise<UserResponseDto[]> {

View file

@ -0,0 +1,52 @@
import { getKeysDeep, unsetDeep } from 'src/utils/misc';
import { describe, expect, it } from 'vitest';
describe('getKeysDeep', () => {
it('should handle an empty object', () => {
expect(getKeysDeep({})).toEqual([]);
});
it('should list properties', () => {
expect(
getKeysDeep({
foo: 'bar',
flag: true,
count: 42,
}),
).toEqual(['foo', 'flag', 'count']);
});
it('should skip undefined properties', () => {
expect(getKeysDeep({ foo: 'bar', hello: undefined })).toEqual(['foo']);
});
it('should skip array indices', () => {
expect(getKeysDeep({ foo: 'bar', hello: ['foo', 'bar'] })).toEqual(['foo', 'hello']);
expect(getKeysDeep({ foo: 'bar', nested: { hello: ['foo', 'bar'] } })).toEqual(['foo', 'nested.hello']);
});
it('should list nested properties', () => {
expect(getKeysDeep({ foo: 'bar', hello: { world: true } })).toEqual(['foo', 'hello.world']);
});
});
describe('unsetDeep', () => {
it('should remove a property', () => {
expect(unsetDeep({ hello: 'world', foo: 'bar' }, 'foo')).toEqual({ hello: 'world' });
});
it('should remove the last property', () => {
expect(unsetDeep({ foo: 'bar' }, 'foo')).toBeUndefined();
});
it('should remove a nested property', () => {
expect(unsetDeep({ foo: 'bar', nested: { enabled: true, count: 42 } }, 'nested.enabled')).toEqual({
foo: 'bar',
nested: { count: 42 },
});
});
it('should clean up an empty property', () => {
expect(unsetDeep({ foo: 'bar', nested: { enabled: true } }, 'nested.enabled')).toEqual({ foo: 'bar' });
});
});

View file

@ -16,6 +16,47 @@ import { ImmichCookie, ImmichHeader } from 'src/dtos/auth.dto';
import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { ILoggerRepository } from 'src/interfaces/logger.interface';
import { Metadata } from 'src/middleware/auth.guard'; import { Metadata } from 'src/middleware/auth.guard';
/**
* @returns a list of strings representing the keys of the object in dot notation
*/
export const getKeysDeep = (target: unknown, path: string[] = []) => {
if (!target || typeof target !== 'object') {
return [];
}
const obj = target as object;
const properties: string[] = [];
for (const key of Object.keys(obj as object)) {
const value = obj[key as keyof object];
if (value === undefined) {
continue;
}
if (_.isObject(value) && !_.isArray(value)) {
properties.push(...getKeysDeep(value, [...path, key]));
continue;
}
properties.push([...path, key].join('.'));
}
return properties;
};
export const unsetDeep = (object: unknown, key: string) => {
const parts = key.split('.');
while (parts.length > 0) {
_.unset(object, parts);
parts.pop();
if (!_.isEmpty(_.get(object, parts))) {
break;
}
}
return _.isEmpty(object) ? undefined : object;
};
const isMachineLearningEnabled = (machineLearning: SystemConfig['machineLearning']) => machineLearning.enabled; const isMachineLearningEnabled = (machineLearning: SystemConfig['machineLearning']) => machineLearning.enabled;
export const isSmartSearchEnabled = (machineLearning: SystemConfig['machineLearning']) => export const isSmartSearchEnabled = (machineLearning: SystemConfig['machineLearning']) =>
isMachineLearningEnabled(machineLearning) && machineLearning.clip.enabled; isMachineLearningEnabled(machineLearning) && machineLearning.clip.enabled;

View file

@ -1,33 +1,74 @@
import { SystemConfigEntity, SystemConfigKey } from 'src/entities/system-config.entity'; import { SystemConfig } from 'src/config';
import { DeepPartial } from 'typeorm';
export const systemConfigStub: Record<string, SystemConfigEntity[]> = { export const systemConfigStub = {
defaults: [], enabled: {
enabled: [ oauth: {
{ key: SystemConfigKey.OAUTH_ENABLED, value: true }, enabled: true,
{ key: SystemConfigKey.OAUTH_AUTO_REGISTER, value: true }, autoRegister: true,
{ key: SystemConfigKey.OAUTH_AUTO_LAUNCH, value: false }, autoLaunch: false,
{ key: SystemConfigKey.OAUTH_BUTTON_TEXT, value: 'OAuth' }, buttonText: 'OAuth',
], },
disabled: [{ key: SystemConfigKey.PASSWORD_LOGIN_ENABLED, value: false }], },
noAutoRegister: [ disabled: {
{ key: SystemConfigKey.OAUTH_ENABLED, value: true }, passwordLogin: {
{ key: SystemConfigKey.OAUTH_AUTO_LAUNCH, value: false }, enabled: false,
{ key: SystemConfigKey.OAUTH_AUTO_REGISTER, value: false }, },
{ key: SystemConfigKey.OAUTH_BUTTON_TEXT, value: 'OAuth' }, },
], noAutoRegister: {
override: [ oauth: {
{ key: SystemConfigKey.OAUTH_ENABLED, value: true }, enabled: true,
{ key: SystemConfigKey.OAUTH_AUTO_REGISTER, value: true }, autoRegister: false,
{ key: SystemConfigKey.OAUTH_MOBILE_OVERRIDE_ENABLED, value: true }, autoLaunch: false,
{ key: SystemConfigKey.OAUTH_MOBILE_REDIRECT_URI, value: 'http://mobile-redirect' }, buttonText: 'OAuth',
{ key: SystemConfigKey.OAUTH_BUTTON_TEXT, value: 'OAuth' }, },
], },
withDefaultStorageQuota: [ override: {
{ key: SystemConfigKey.OAUTH_ENABLED, value: true }, oauth: {
{ key: SystemConfigKey.OAUTH_AUTO_REGISTER, value: true }, enabled: true,
{ key: SystemConfigKey.OAUTH_DEFAULT_STORAGE_QUOTA, value: 1 }, autoRegister: true,
], mobileOverrideEnabled: true,
deleteDelay30: [{ key: SystemConfigKey.USER_DELETE_DELAY, value: 30 }], mobileRedirectUri: 'http://mobile-redirect',
libraryWatchEnabled: [{ key: SystemConfigKey.LIBRARY_WATCH_ENABLED, value: true }], buttonText: 'OAuth',
libraryWatchDisabled: [{ key: SystemConfigKey.LIBRARY_WATCH_ENABLED, value: false }], },
}; },
withDefaultStorageQuota: {
oauth: {
enabled: true,
autoRegister: true,
defaultStorageQuota: 1,
},
},
deleteDelay30: {
user: {
deleteDelay: 30,
},
},
libraryWatchEnabled: {
library: {
watch: {
enabled: true,
},
},
},
libraryWatchDisabled: {
library: {
watch: {
enabled: false,
},
},
},
libraryScan: {
library: {
scan: {
enabled: true,
cronExpression: '0 0 * * *',
},
},
},
machineLearningDisabled: {
machineLearning: {
enabled: false,
},
},
} satisfies Record<string, DeepPartial<SystemConfig>>;

View file

@ -1,17 +0,0 @@
import { SystemConfigCore } from 'src/cores/system-config.core';
import { ISystemConfigRepository } from 'src/interfaces/system-config.interface';
import { Mocked, vitest } from 'vitest';
export const newSystemConfigRepositoryMock = (reset = true): Mocked<ISystemConfigRepository> => {
if (reset) {
SystemConfigCore.reset();
}
return {
fetchStyle: vitest.fn(),
load: vitest.fn().mockResolvedValue([]),
readFile: vitest.fn(),
saveAll: vitest.fn().mockResolvedValue([]),
deleteKeys: vitest.fn(),
};
};

View file

@ -1,9 +1,16 @@
import { SystemConfigCore } from 'src/cores/system-config.core';
import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface';
import { Mocked, vitest } from 'vitest'; import { Mocked, vitest } from 'vitest';
export const newSystemMetadataRepositoryMock = (): Mocked<ISystemMetadataRepository> => { export const newSystemMetadataRepositoryMock = (reset = true): Mocked<ISystemMetadataRepository> => {
if (reset) {
SystemConfigCore.reset();
}
return { return {
get: vitest.fn() as any, get: vitest.fn() as any,
set: vitest.fn(), set: vitest.fn(),
readFile: vitest.fn(),
fetchStyle: vitest.fn(),
}; };
}; };