refactor(server): system config (#1353)

* refactor(server): system config

* fix: jest circular import

* chore: ignore migrations in coverage report

* chore: tests

* chore: tests

* chore: todo note

* chore: remove vite config backup

* chore: fix redis hostname
This commit is contained in:
Jason Rasmussen
2023-01-21 11:11:55 -05:00
committed by GitHub
parent 66cd7dd809
commit c0a6b3d5a3
92 changed files with 842 additions and 614 deletions

View File

@@ -1,19 +0,0 @@
import { SharedBullAsyncConfiguration } from '@nestjs/bull';
export const immichBullAsyncConfig: SharedBullAsyncConfiguration = {
useFactory: async () => ({
prefix: 'immich_bull',
redis: {
host: process.env.REDIS_HOSTNAME || 'immich_redis',
port: parseInt(process.env.REDIS_PORT || '6379'),
db: parseInt(process.env.REDIS_DBINDEX || '0'),
password: process.env.REDIS_PASSWORD || undefined,
path: process.env.REDIS_SOCKET || undefined,
},
defaultJobOptions: {
attempts: 3,
removeOnComplete: true,
removeOnFail: false,
},
}),
};

View File

@@ -1,2 +1 @@
export * from './app.config';
export * from './bull-queue.config';

View File

@@ -1,4 +1,4 @@
import { APIKeyEntity } from '@app/infra';
import { APIKeyEntity } from '@app/infra/db/entities';
export const IKeyRepository = 'IKeyRepository';

View File

@@ -1,4 +1,4 @@
import { APIKeyEntity } from '@app/infra';
import { APIKeyEntity } from '@app/infra/db/entities';
import { BadRequestException, UnauthorizedException } from '@nestjs/common';
import { authStub, entityStub, newCryptoRepositoryMock, newKeyRepositoryMock } from '../../test';
import { ICryptoRepository } from '../auth';

View File

@@ -1,4 +1,4 @@
import { UserEntity } from '@app/infra';
import { UserEntity } from '@app/infra/db/entities';
import { BadRequestException, Inject, Injectable, UnauthorizedException } from '@nestjs/common';
import { AuthUserDto, ICryptoRepository } from '../auth';
import { IKeyRepository } from './api-key.repository';

View File

@@ -1,4 +1,4 @@
import { APIKeyEntity } from '@app/infra';
import { APIKeyEntity } from '@app/infra/db/entities';
import { ApiProperty } from '@nestjs/swagger';
export class APIKeyResponseDto {

View File

@@ -1,11 +1,23 @@
import { DynamicModule, Global, Module, ModuleMetadata, Provider } from '@nestjs/common';
import { APIKeyService } from './api-key';
import { SystemConfigService } from './system-config';
import { UserService } from './user';
export const INITIAL_SYSTEM_CONFIG = 'INITIAL_SYSTEM_CONFIG';
const providers: Provider[] = [
//
APIKeyService,
SystemConfigService,
UserService,
{
provide: INITIAL_SYSTEM_CONFIG,
inject: [SystemConfigService],
useFactory: async (configService: SystemConfigService) => {
return configService.getConfig();
},
},
];
@Global()

View File

@@ -1,4 +1,6 @@
export * from './api-key';
export * from './auth';
export * from './domain.module';
export * from './job';
export * from './system-config';
export * from './user';

View File

@@ -0,0 +1,3 @@
export * from './interfaces';
export * from './job.constants';
export * from './job.repository';

View File

@@ -1,4 +1,4 @@
import { AssetEntity } from '@app/infra';
import { AssetEntity } from '@app/infra/db/entities';
export interface IAssetUploadedJob {
/**

View File

@@ -0,0 +1,5 @@
import { AssetEntity } from '@app/infra/db/entities';
export interface IDeleteFileOnDiskJob {
assets: AssetEntity[];
}

View File

@@ -0,0 +1,7 @@
export * from './asset-uploaded.interface';
export * from './background-task.interface';
export * from './machine-learning.interface';
export * from './metadata-extraction.interface';
export * from './thumbnail-generation.interface';
export * from './user-deletion.interface';
export * from './video-transcode.interface';

View File

@@ -1,4 +1,4 @@
import { AssetEntity } from '@app/infra';
import { AssetEntity } from '@app/infra/db/entities';
export interface IMachineLearningJob {
/**

View File

@@ -1,4 +1,4 @@
import { AssetEntity } from '@app/infra';
import { AssetEntity } from '@app/infra/db/entities';
export interface IExifExtractionProcessor {
/**

View File

@@ -1,4 +1,4 @@
import { AssetEntity } from '@app/infra';
import { AssetEntity } from '@app/infra/db/entities';
export interface JpegGeneratorProcessor {
/**

View File

@@ -1,4 +1,4 @@
import { UserEntity } from '@app/infra';
import { UserEntity } from '@app/infra/db/entities';
export interface IUserDeletionJob {
/**

View File

@@ -1,4 +1,4 @@
import { AssetEntity } from '@app/infra';
import { AssetEntity } from '@app/infra/db/entities';
export interface IMp4ConversionProcessor {
/**

View File

@@ -1,3 +1,15 @@
export enum QueueName {
THUMBNAIL_GENERATION = 'thumbnail-generation-queue',
METADATA_EXTRACTION = 'metadata-extraction-queue',
VIDEO_CONVERSION = 'video-conversion-queue',
CHECKSUM_GENERATION = 'generate-checksum-queue',
ASSET_UPLOADED = 'asset-uploaded-queue',
MACHINE_LEARNING = 'machine-learning-queue',
USER_DELETION = 'user-deletion-queue',
CONFIG = 'config-queue',
BACKGROUND_TASK = 'background-task',
}
export enum JobName {
ASSET_UPLOADED = 'asset-uploaded',
MP4_CONVERSION = 'mp4-conversion',

View File

@@ -0,0 +1,32 @@
import {
IAssetUploadedJob,
IDeleteFileOnDiskJob,
IExifExtractionProcessor,
IMachineLearningJob,
IMp4ConversionProcessor,
IReverseGeocodingProcessor,
IUserDeletionJob,
JpegGeneratorProcessor,
WebpGeneratorProcessor,
} from './interfaces';
import { JobName } from './job.constants';
export type JobItem =
| { name: JobName.ASSET_UPLOADED; data: IAssetUploadedJob }
| { name: JobName.MP4_CONVERSION; data: IMp4ConversionProcessor }
| { name: JobName.GENERATE_JPEG_THUMBNAIL; data: JpegGeneratorProcessor }
| { name: JobName.GENERATE_WEBP_THUMBNAIL; data: WebpGeneratorProcessor }
| { name: JobName.EXIF_EXTRACTION; data: IExifExtractionProcessor }
| { name: JobName.REVERSE_GEOCODING; data: IReverseGeocodingProcessor }
| { name: JobName.USER_DELETION; data: IUserDeletionJob }
| { name: JobName.TEMPLATE_MIGRATION }
| { name: JobName.CONFIG_CHANGE }
| { name: JobName.OBJECT_DETECTION; data: IMachineLearningJob }
| { name: JobName.IMAGE_TAGGING; data: IMachineLearningJob }
| { name: JobName.DELETE_FILE_ON_DISK; data: IDeleteFileOnDiskJob };
export const IJobRepository = 'IJobRepository';
export interface IJobRepository {
add(item: JobItem): Promise<void>;
}

View File

@@ -0,0 +1,5 @@
export * from './system-config-ffmpeg.dto';
export * from './system-config-oauth.dto';
export * from './system-config-password-login.dto';
export * from './system-config-storage-template.dto';
export * from './system-config.dto';

View File

@@ -0,0 +1,18 @@
import { IsString } from 'class-validator';
export class SystemConfigFFmpegDto {
@IsString()
crf!: string;
@IsString()
preset!: string;
@IsString()
targetVideoCodec!: string;
@IsString()
targetAudioCodec!: string;
@IsString()
targetScaling!: string;
}

View File

@@ -0,0 +1,43 @@
import { IsBoolean, IsNotEmpty, IsString, IsUrl, ValidateIf } from 'class-validator';
const isEnabled = (config: SystemConfigOAuthDto) => config.enabled;
const isOverrideEnabled = (config: SystemConfigOAuthDto) => config.mobileOverrideEnabled;
export class SystemConfigOAuthDto {
@IsBoolean()
enabled!: boolean;
@ValidateIf(isEnabled)
@IsNotEmpty()
@IsString()
issuerUrl!: string;
@ValidateIf(isEnabled)
@IsNotEmpty()
@IsString()
clientId!: string;
@ValidateIf(isEnabled)
@IsNotEmpty()
@IsString()
clientSecret!: string;
@IsString()
scope!: string;
@IsString()
buttonText!: string;
@IsBoolean()
autoRegister!: boolean;
@IsBoolean()
autoLaunch!: boolean;
@IsBoolean()
mobileOverrideEnabled!: boolean;
@ValidateIf(isOverrideEnabled)
@IsUrl()
mobileRedirectUri!: string;
}

View File

@@ -0,0 +1,6 @@
import { IsBoolean } from 'class-validator';
export class SystemConfigPasswordLoginDto {
@IsBoolean()
enabled!: boolean;
}

View File

@@ -0,0 +1,7 @@
import { IsNotEmpty, IsString } from 'class-validator';
export class SystemConfigStorageTemplateDto {
@IsNotEmpty()
@IsString()
template!: string;
}

View File

@@ -0,0 +1,24 @@
import { SystemConfig } from '@app/infra/db/entities';
import { ValidateNested } from 'class-validator';
import { SystemConfigFFmpegDto } from './system-config-ffmpeg.dto';
import { SystemConfigOAuthDto } from './system-config-oauth.dto';
import { SystemConfigPasswordLoginDto } from './system-config-password-login.dto';
import { SystemConfigStorageTemplateDto } from './system-config-storage-template.dto';
export class SystemConfigDto {
@ValidateNested()
ffmpeg!: SystemConfigFFmpegDto;
@ValidateNested()
oauth!: SystemConfigOAuthDto;
@ValidateNested()
passwordLogin!: SystemConfigPasswordLoginDto;
@ValidateNested()
storageTemplate!: SystemConfigStorageTemplateDto;
}
export function mapConfig(config: SystemConfig): SystemConfigDto {
return config;
}

View File

@@ -0,0 +1,5 @@
export * from './dto';
export * from './response-dto';
export * from './system-config.repository';
export * from './system-config.service';
export * from './system-config.datetime-variables';

View File

@@ -0,0 +1 @@
export * from './system-config-template-storage-option.dto';

View File

@@ -0,0 +1,9 @@
export class SystemConfigTemplateStorageOptionDto {
yearOptions!: string[];
monthOptions!: string[];
dayOptions!: string[];
hourOptions!: string[];
minuteOptions!: string[];
secondOptions!: string[];
presetOptions!: string[];
}

View File

@@ -1,9 +1,9 @@
import { SystemConfig, SystemConfigEntity, SystemConfigKey } from '@app/infra';
import { SystemConfig, SystemConfigEntity, SystemConfigKey } from '@app/infra/db/entities';
import { BadRequestException, Injectable, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import * as _ from 'lodash';
import { Subject } from 'rxjs';
import { DeepPartial, In, Repository } from 'typeorm';
import { DeepPartial } from 'typeorm';
import { ISystemConfigRepository } from './system-config.repository';
export type SystemConfigValidator = (config: SystemConfig) => void | Promise<void>;
@@ -37,16 +37,13 @@ const defaults: SystemConfig = Object.freeze({
});
@Injectable()
export class ImmichConfigService {
private logger = new Logger(ImmichConfigService.name);
export class SystemConfigCore {
private logger = new Logger(SystemConfigCore.name);
private validators: SystemConfigValidator[] = [];
public config$ = new Subject<SystemConfig>();
constructor(
@InjectRepository(SystemConfigEntity)
private systemConfigRepository: Repository<SystemConfigEntity>,
) {}
constructor(private repository: ISystemConfigRepository) {}
public getDefaults(): SystemConfig {
return defaults;
@@ -57,7 +54,7 @@ export class ImmichConfigService {
}
public async getConfig() {
const overrides = await this.systemConfigRepository.find();
const overrides = await this.repository.load();
const config: DeepPartial<SystemConfig> = {};
for (const { key, value } of overrides) {
// set via dot notation
@@ -95,11 +92,11 @@ export class ImmichConfigService {
}
if (updates.length > 0) {
await this.systemConfigRepository.save(updates);
await this.repository.saveAll(updates);
}
if (deletes.length > 0) {
await this.systemConfigRepository.delete({ key: In(deletes.map((item) => item.key)) });
await this.repository.deleteKeys(deletes.map((item) => item.key));
}
const newConfig = await this.getConfig();

View File

@@ -0,0 +1,9 @@
import { SystemConfigEntity } from '@app/infra/db/entities';
export const ISystemConfigRepository = 'ISystemConfigRepository';
export interface ISystemConfigRepository {
load(): Promise<SystemConfigEntity[]>;
saveAll(items: SystemConfigEntity[]): Promise<SystemConfigEntity[]>;
deleteKeys(keys: string[]): Promise<void>;
}

View File

@@ -0,0 +1,156 @@
import { SystemConfigEntity, SystemConfigKey } from '@app/infra';
import { BadRequestException } from '@nestjs/common';
import { newJobRepositoryMock, newSystemConfigRepositoryMock, systemConfigStub } from '../../test';
import { IJobRepository, JobName } from '../job';
import { SystemConfigValidator } from './system-config.core';
import { ISystemConfigRepository } from './system-config.repository';
import { SystemConfigService } from './system-config.service';
const updates: SystemConfigEntity[] = [
{ key: SystemConfigKey.FFMPEG_CRF, value: 'a new value' },
{ key: SystemConfigKey.OAUTH_AUTO_LAUNCH, value: true },
];
const updatedConfig = Object.freeze({
ffmpeg: {
crf: 'a new value',
preset: 'ultrafast',
targetAudioCodec: 'mp3',
targetScaling: '1280:-2',
targetVideoCodec: 'libx264',
},
oauth: {
autoLaunch: true,
autoRegister: true,
buttonText: 'Login with OAuth',
clientId: '',
clientSecret: '',
enabled: false,
issuerUrl: '',
mobileOverrideEnabled: false,
mobileRedirectUri: '',
scope: 'openid email profile',
},
passwordLogin: {
enabled: true,
},
storageTemplate: {
template: '{{y}}/{{y}}-{{MM}}-{{dd}}/{{filename}}',
},
});
describe(SystemConfigService.name, () => {
let sut: SystemConfigService;
let configMock: jest.Mocked<ISystemConfigRepository>;
let jobMock: jest.Mocked<IJobRepository>;
beforeEach(async () => {
configMock = newSystemConfigRepositoryMock();
jobMock = newJobRepositoryMock();
sut = new SystemConfigService(configMock, jobMock);
});
it('should work', () => {
expect(sut).toBeDefined();
});
describe('getDefaults', () => {
it('should return the default config', () => {
configMock.load.mockResolvedValue(updates);
expect(sut.getDefaults()).toEqual(systemConfigStub.defaults);
expect(configMock.load).not.toHaveBeenCalled();
});
});
describe('addValidator', () => {
it('should call the validator on config changes', async () => {
const validator: SystemConfigValidator = jest.fn();
sut.addValidator(validator);
await sut.updateConfig(systemConfigStub.defaults);
expect(validator).toHaveBeenCalledWith(systemConfigStub.defaults);
});
});
describe('getConfig', () => {
it('should return the default config', async () => {
configMock.load.mockResolvedValue([]);
await expect(sut.getConfig()).resolves.toEqual(systemConfigStub.defaults);
});
it('should merge the overrides', async () => {
configMock.load.mockResolvedValue([
{ key: SystemConfigKey.FFMPEG_CRF, value: 'a new value' },
{ key: SystemConfigKey.OAUTH_AUTO_LAUNCH, value: true },
]);
await expect(sut.getConfig()).resolves.toEqual(updatedConfig);
});
});
describe('getStorageTemplateOptions', () => {
it('should send back the datetime variables', () => {
expect(sut.getStorageTemplateOptions()).toEqual({
dayOptions: ['d', 'dd'],
hourOptions: ['h', 'hh', 'H', 'HH'],
minuteOptions: ['m', 'mm'],
monthOptions: ['M', 'MM', 'MMM', 'MMMM'],
presetOptions: [
'{{y}}/{{y}}-{{MM}}-{{dd}}/{{filename}}',
'{{y}}/{{MM}}-{{dd}}/{{filename}}',
'{{y}}/{{MMMM}}-{{dd}}/{{filename}}',
'{{y}}/{{MM}}/{{filename}}',
'{{y}}/{{MMM}}/{{filename}}',
'{{y}}/{{MMMM}}/{{filename}}',
'{{y}}/{{MM}}/{{dd}}/{{filename}}',
'{{y}}/{{MMMM}}/{{dd}}/{{filename}}',
'{{y}}/{{y}}-{{MM}}/{{y}}-{{MM}}-{{dd}}/{{filename}}',
'{{y}}-{{MM}}-{{dd}}/{{filename}}',
'{{y}}-{{MMM}}-{{dd}}/{{filename}}',
'{{y}}-{{MMMM}}-{{dd}}/{{filename}}',
],
secondOptions: ['s', 'ss'],
yearOptions: ['y', 'yy'],
});
});
});
describe('updateConfig', () => {
it('should notify the microservices process', async () => {
configMock.load.mockResolvedValue(updates);
await expect(sut.updateConfig(updatedConfig)).resolves.toEqual(updatedConfig);
expect(configMock.saveAll).toHaveBeenCalledWith(updates);
expect(jobMock.add).toHaveBeenCalledWith({ name: JobName.CONFIG_CHANGE });
});
it('should throw an error if the config is not valid', async () => {
const validator = jest.fn().mockRejectedValue('invalid config');
sut.addValidator(validator);
await expect(sut.updateConfig(updatedConfig)).rejects.toBeInstanceOf(BadRequestException);
expect(validator).toHaveBeenCalledWith(updatedConfig);
expect(configMock.saveAll).not.toHaveBeenCalled();
});
});
describe('refreshConfig', () => {
it('should notify the subscribers', async () => {
const changeMock = jest.fn();
const subscription = sut.config$.subscribe(changeMock);
await sut.refreshConfig();
expect(changeMock).toHaveBeenCalledWith(systemConfigStub.defaults);
subscription.unsubscribe();
});
});
});

View File

@@ -0,0 +1,68 @@
import { ISystemConfigRepository } from '../system-config';
import {
supportedDayTokens,
supportedHourTokens,
supportedMinuteTokens,
supportedMonthTokens,
supportedPresetTokens,
supportedSecondTokens,
supportedYearTokens,
} from './system-config.datetime-variables';
import { Inject, Injectable } from '@nestjs/common';
import { IJobRepository, JobName } from '../job';
import { mapConfig, SystemConfigDto } from './dto/system-config.dto';
import { SystemConfigTemplateStorageOptionDto } from './response-dto/system-config-template-storage-option.dto';
import { SystemConfigCore, SystemConfigValidator } from './system-config.core';
@Injectable()
export class SystemConfigService {
private core: SystemConfigCore;
constructor(
@Inject(ISystemConfigRepository) repository: ISystemConfigRepository,
@Inject(IJobRepository) private queue: IJobRepository,
) {
this.core = new SystemConfigCore(repository);
}
get config$() {
return this.core.config$;
}
async getConfig(): Promise<SystemConfigDto> {
const config = await this.core.getConfig();
return mapConfig(config);
}
getDefaults(): SystemConfigDto {
const config = this.core.getDefaults();
return mapConfig(config);
}
async updateConfig(dto: SystemConfigDto): Promise<SystemConfigDto> {
const config = await this.core.updateConfig(dto);
await this.queue.add({ name: JobName.CONFIG_CHANGE });
return mapConfig(config);
}
async refreshConfig() {
await this.core.refreshConfig();
}
addValidator(validator: SystemConfigValidator) {
this.core.addValidator(validator);
}
getStorageTemplateOptions(): SystemConfigTemplateStorageOptionDto {
const options = new SystemConfigTemplateStorageOptionDto();
options.dayOptions = supportedDayTokens;
options.monthOptions = supportedMonthTokens;
options.yearOptions = supportedYearTokens;
options.hourOptions = supportedHourTokens;
options.secondOptions = supportedSecondTokens;
options.minuteOptions = supportedMinuteTokens;
options.presetOptions = supportedPresetTokens;
return options;
}
}

View File

@@ -1,4 +1,4 @@
import { UserEntity } from '@app/infra';
import { UserEntity } from '@app/infra/db/entities';
export class UserResponseDto {
id!: string;

View File

@@ -1,4 +1,4 @@
import { UserEntity } from '@app/infra';
import { UserEntity } from '@app/infra/db/entities';
import {
BadRequestException,
ForbiddenException,

View File

@@ -1,4 +1,4 @@
import { UserEntity } from '@app/infra';
import { UserEntity } from '@app/infra/db/entities';
export interface UserListFilter {
excludeId?: string;

View File

@@ -1,5 +1,5 @@
import { IUserRepository } from '@app/domain';
import { UserEntity } from '@app/infra';
import { IUserRepository } from './user.repository';
import { UserEntity } from '@app/infra/db/entities';
import { BadRequestException, ForbiddenException, NotFoundException } from '@nestjs/common';
import { when } from 'jest-when';
import { newUserRepositoryMock } from '../../test';

View File

@@ -1,4 +1,4 @@
import { UserEntity } from '@app/infra';
import { SystemConfig, UserEntity } from '@app/infra/db/entities';
import { AuthUserDto } from '../src';
export const authStub = {
@@ -42,3 +42,33 @@ export const entityStub = {
tags: [],
}),
};
export const systemConfigStub = {
defaults: Object.freeze({
ffmpeg: {
crf: '23',
preset: 'ultrafast',
targetAudioCodec: 'mp3',
targetScaling: '1280:-2',
targetVideoCodec: 'libx264',
},
oauth: {
autoLaunch: false,
autoRegister: true,
buttonText: 'Login with OAuth',
clientId: '',
clientSecret: '',
enabled: false,
issuerUrl: '',
mobileOverrideEnabled: false,
mobileRedirectUri: '',
scope: 'openid email profile',
},
passwordLogin: {
enabled: true,
},
storageTemplate: {
template: '{{y}}/{{y}}-{{MM}}-{{dd}}/{{filename}}',
},
} as SystemConfig),
};

View File

@@ -1,4 +1,6 @@
export * from './api-key.repository.mock';
export * from './crypto.repository.mock';
export * from './fixtures';
export * from './job.repository.mock';
export * from './system-config.repository.mock';
export * from './user.repository.mock';

View File

@@ -0,0 +1,7 @@
import { IJobRepository } from '../src';
export const newJobRepositoryMock = (): jest.Mocked<IJobRepository> => {
return {
add: jest.fn().mockImplementation(() => Promise.resolve()),
};
};

View File

@@ -0,0 +1,9 @@
import { ISystemConfigRepository } from '../src';
export const newSystemConfigRepositoryMock = (): jest.Mocked<ISystemConfigRepository> => {
return {
load: jest.fn().mockResolvedValue([]),
saveAll: jest.fn().mockResolvedValue([]),
deleteKeys: jest.fn(),
};
};

View File

@@ -1,24 +0,0 @@
import { SystemConfigEntity } from '@app/infra';
import { Module, Provider } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { ImmichConfigService } from './immich-config.service';
export const INITIAL_SYSTEM_CONFIG = 'INITIAL_SYSTEM_CONFIG';
const providers: Provider[] = [
ImmichConfigService,
{
provide: INITIAL_SYSTEM_CONFIG,
inject: [ImmichConfigService],
useFactory: async (configService: ImmichConfigService) => {
return configService.getConfig();
},
},
];
@Module({
imports: [TypeOrmModule.forFeature([SystemConfigEntity])],
providers: [...providers],
exports: [...providers],
})
export class ImmichConfigModule {}

View File

@@ -1,2 +0,0 @@
export * from './immich-config.module';
export * from './immich-config.service';

View File

@@ -1,9 +0,0 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"declaration": true,
"outDir": "../../dist/libs/immich-config"
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist", "test", "**/*spec.ts"]
}

View File

@@ -0,0 +1,23 @@
import { ISystemConfigRepository } from '@app/domain';
import { InjectRepository } from '@nestjs/typeorm';
import { In, Repository } from 'typeorm';
import { SystemConfigEntity } from '../entities';
export class SystemConfigRepository implements ISystemConfigRepository {
constructor(
@InjectRepository(SystemConfigEntity)
private repository: Repository<SystemConfigEntity>,
) {}
load(): Promise<SystemConfigEntity<string | boolean>[]> {
return this.repository.find();
}
saveAll(items: SystemConfigEntity[]): Promise<SystemConfigEntity[]> {
return this.repository.save(items);
}
async deleteKeys(keys: string[]): Promise<void> {
await this.repository.delete({ key: In(keys) });
}
}

View File

@@ -1,26 +1,65 @@
import { ICryptoRepository, IKeyRepository, IUserRepository } from '@app/domain';
import {
ICryptoRepository,
IJobRepository,
IKeyRepository,
ISystemConfigRepository,
IUserRepository,
QueueName,
} from '@app/domain';
import { databaseConfig, UserEntity } from '@app/infra';
import { BullModule } from '@nestjs/bull';
import { Global, Module, Provider } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { cryptoRepository } from './auth/crypto.repository';
import { APIKeyEntity, UserRepository } from './db';
import { APIKeyEntity, SystemConfigEntity, UserRepository } from './db';
import { APIKeyRepository } from './db/repository';
import { SystemConfigRepository } from './db/repository/system-config.repository';
import { JobRepository } from './job';
const providers: Provider[] = [
//
{ provide: ICryptoRepository, useValue: cryptoRepository },
{ provide: IKeyRepository, useClass: APIKeyRepository },
{ provide: IJobRepository, useClass: JobRepository },
{ provide: ISystemConfigRepository, useClass: SystemConfigRepository },
{ provide: IUserRepository, useClass: UserRepository },
];
@Global()
@Module({
imports: [
//
TypeOrmModule.forRoot(databaseConfig),
TypeOrmModule.forFeature([APIKeyEntity, UserEntity]),
TypeOrmModule.forFeature([APIKeyEntity, UserEntity, SystemConfigEntity]),
BullModule.forRootAsync({
useFactory: async () => ({
prefix: 'immich_bull',
redis: {
host: process.env.REDIS_HOSTNAME || 'immich_redis',
port: parseInt(process.env.REDIS_PORT || '6379'),
db: parseInt(process.env.REDIS_DBINDEX || '0'),
password: process.env.REDIS_PASSWORD || undefined,
path: process.env.REDIS_SOCKET || undefined,
},
defaultJobOptions: {
attempts: 3,
removeOnComplete: true,
removeOnFail: false,
},
}),
}),
BullModule.registerQueue(
{ name: QueueName.USER_DELETION },
{ name: QueueName.THUMBNAIL_GENERATION },
{ name: QueueName.ASSET_UPLOADED },
{ name: QueueName.METADATA_EXTRACTION },
{ name: QueueName.VIDEO_CONVERSION },
{ name: QueueName.CHECKSUM_GENERATION },
{ name: QueueName.MACHINE_LEARNING },
{ name: QueueName.CONFIG },
{ name: QueueName.BACKGROUND_TASK },
),
],
providers: [...providers],
exports: [...providers],
exports: [...providers, BullModule],
})
export class InfraModule {}

View File

@@ -0,0 +1 @@
export * from './job.repository';

View File

@@ -0,0 +1,21 @@
import { IJobRepository, JobItem, JobName, QueueName } from '@app/domain';
import { InjectQueue } from '@nestjs/bull';
import { Logger } from '@nestjs/common';
import { Queue } from 'bull';
export class JobRepository implements IJobRepository {
private logger = new Logger(JobRepository.name);
constructor(@InjectQueue(QueueName.CONFIG) private configQueue: Queue) {}
async add(item: JobItem): Promise<void> {
switch (item.name) {
case JobName.CONFIG_CHANGE:
await this.configQueue.add(JobName.CONFIG_CHANGE, {});
break;
default:
// TODO inject remaining queues and map job to queue
this.logger.error('Invalid job', item);
}
}
}

View File

@@ -1,16 +0,0 @@
import { BullModuleOptions } from '@nestjs/bull';
import { QueueName } from './queue-name.constant';
/**
* Shared queues between apps and microservices
*/
export const immichSharedQueues: BullModuleOptions[] = [
{ name: QueueName.USER_DELETION },
{ name: QueueName.THUMBNAIL_GENERATION },
{ name: QueueName.ASSET_UPLOADED },
{ name: QueueName.METADATA_EXTRACTION },
{ name: QueueName.VIDEO_CONVERSION },
{ name: QueueName.CHECKSUM_GENERATION },
{ name: QueueName.MACHINE_LEARNING },
{ name: QueueName.CONFIG },
];

View File

@@ -1,11 +0,0 @@
export enum QueueName {
THUMBNAIL_GENERATION = 'thumbnail-generation-queue',
METADATA_EXTRACTION = 'metadata-extraction-queue',
VIDEO_CONVERSION = 'video-conversion-queue',
CHECKSUM_GENERATION = 'generate-checksum-queue',
ASSET_UPLOADED = 'asset-uploaded-queue',
MACHINE_LEARNING = 'machine-learning-queue',
USER_DELETION = 'user-deletion-queue',
CONFIG = 'config-queue',
BACKGROUND_TASK = 'background-task',
}

View File

@@ -1,7 +0,0 @@
export * from './interfaces/asset-uploaded.interface';
export * from './interfaces/metadata-extraction.interface';
export * from './interfaces/video-transcode.interface';
export * from './interfaces/thumbnail-generation.interface';
export * from './constants/job-name.constant';
export * from './constants/queue-name.constant';

View File

@@ -1,9 +0,0 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"declaration": true,
"outDir": "../../dist/libs/job"
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist", "test", "**/*spec.ts"]
}

View File

@@ -1,6 +0,0 @@
export interface IImmichStorage {
write(): Promise<void>;
read(): Promise<void>;
}
export enum IStorageType {}

View File

@@ -1,11 +1,10 @@
import { AssetEntity, SystemConfigEntity } from '@app/infra';
import { ImmichConfigModule } from '@app/immich-config';
import { AssetEntity } from '@app/infra';
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { StorageService } from './storage.service';
@Module({
imports: [TypeOrmModule.forFeature([AssetEntity, SystemConfigEntity]), ImmichConfigModule],
imports: [TypeOrmModule.forFeature([AssetEntity])],
providers: [StorageService],
exports: [StorageService],
})

View File

@@ -1,6 +1,6 @@
import { APP_UPLOAD_LOCATION } from '@app/common';
import { AssetEntity, AssetType, SystemConfig } from '@app/infra';
import { ImmichConfigService, INITIAL_SYSTEM_CONFIG } from '@app/immich-config';
import { SystemConfigService, INITIAL_SYSTEM_CONFIG } from '@app/domain';
import { Inject, Injectable, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import fsPromise from 'fs/promises';
@@ -19,7 +19,7 @@ import {
supportedMonthTokens,
supportedSecondTokens,
supportedYearTokens,
} from './constants/supported-datetime-template';
} from '@app/domain';
const moveFile = promisify<string, string, mv.Options>(mv);
@@ -32,14 +32,14 @@ export class StorageService {
constructor(
@InjectRepository(AssetEntity)
private assetRepository: Repository<AssetEntity>,
private immichConfigService: ImmichConfigService,
private systemConfigService: SystemConfigService,
@Inject(INITIAL_SYSTEM_CONFIG) config: SystemConfig,
) {
this.storageTemplate = this.compile(config.storageTemplate.template);
this.immichConfigService.addValidator((config) => this.validateConfig(config));
this.systemConfigService.addValidator((config) => this.validateConfig(config));
this.immichConfigService.config$.subscribe((config) => {
this.systemConfigService.config$.subscribe((config) => {
this.logger.debug(`Received new config, recompiling storage template: ${config.storageTemplate.template}`);
this.storageTemplate = this.compile(config.storageTemplate.template);
});