Skip to content

Commit

Permalink
feat(web, server): Ability to use config file instead of admin UI (im…
Browse files Browse the repository at this point in the history
…mich-app#3836)

* implement method to read config file

* getConfig returns config file if present

* return isConfigFile for http requests

* disable elements if config file is used, show message if config file is set, copy existing config to clipboard

* fix allowing partial configuration files

* add new env variable to docs

* fix tests

* minor refactoring, address review

* adapt config type in frontend

* remove unnecessary imports

* move config file reading to system-config repo

* add documentation

* fix code formatting in system settings page

* add validator for config file

* fix formatting in docs

* update generated files

* throw error when trying to update config. e.g. via cli or api

* switch to feature flags for isConfigFile

* refactoring

* refactor: config file

* chore: open api

* feat: always show copy/export buttons

* fix: default flags

* refactor: copy to clipboard

---------

Co-authored-by: Jason Rasmussen <[email protected]>
  • Loading branch information
danieldietzler and jrasm91 authored Aug 25, 2023
1 parent 20e0c03 commit 59bb727
Show file tree
Hide file tree
Showing 33 changed files with 359 additions and 84 deletions.
6 changes: 6 additions & 0 deletions cli/src/api/open-api/api.ts

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

91 changes: 91 additions & 0 deletions docs/docs/install/config-file.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
# Config File

A config file can be provided as an alternative to the UI configuration.

### Step 1 - Create a new config file

In JSON format, create a new config file (e.g. `immich.config`) and put it in a location that can be accessed by Immich.
The default configuration looks like this:

```json
{
"ffmpeg": {
"crf": 23,
"threads": 0,
"preset": "ultrafast",
"targetVideoCodec": "h264",
"targetAudioCodec": "aac",
"targetResolution": "720",
"maxBitrate": "0",
"twoPass": false,
"transcode": "required",
"tonemap": "hable",
"accel": "disabled"
},
"job": {
"backgroundTask": {
"concurrency": 5
},
"clipEncoding": {
"concurrency": 2
},
"metadataExtraction": {
"concurrency": 5
},
"objectTagging": {
"concurrency": 2
},
"recognizeFaces": {
"concurrency": 2
},
"search": {
"concurrency": 5
},
"sidecar": {
"concurrency": 5
},
"storageTemplateMigration": {
"concurrency": 5
},
"thumbnailGeneration": {
"concurrency": 5
},
"videoConversion": {
"concurrency": 1
}
},
"oauth": {
"enabled": false,
"issuerUrl": "",
"clientId": "",
"clientSecret": "",
"mobileOverrideEnabled": false,
"mobileRedirectUri": "",
"scope": "openid email profile",
"storageLabelClaim": "preferred_username",
"buttonText": "Login with OAuth",
"autoRegister": true,
"autoLaunch": false
},
"passwordLogin": {
"enabled": true
},
"storageTemplate": {
"template": "{{y}}/{{y}}-{{MM}}-{{dd}}/{{filename}}"
},
"thumbnail": {
"webpSize": 250,
"jpegSize": 1440
}
}
```

:::tip
In Administration > Settings is a button to copy the current configuration to your clipboard.
So you can just grab it from there, paste it into a file and you're pretty much good to go.
:::

### Step 2 - Specify the file location

In your `.env` file, set the variable `IMMICH_CONFIG_FILE` to the path of your config.
For more information, refer to the [Environment Variables](https://docs.immich.app/docs/install/environment-variables) section.
5 changes: 5 additions & 0 deletions docs/docs/install/environment-variables.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
---
sidebar_position: 90
---

# Environment Variables

## Docker Compose
Expand All @@ -22,6 +26,7 @@ These environment variables are used by the `docker-compose.yml` file and do **N
| `LOG_LEVEL` | Log Level (verbose, debug, log, warn, error) | `log` | server, microservices |
| `IMMICH_MEDIA_LOCATION` | Media Location | `./upload` | server, microservices |
| `PUBLIC_LOGIN_PAGE_MESSAGE` | Public Login Page Message | | web |
| `IMMICH_CONFIG_FILE` | Path to config file | | server |

:::tip

Expand Down
2 changes: 1 addition & 1 deletion docs/docs/install/post-install.mdx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
---
sidebar_position: 100
sidebar_position: 80
---

import RegisterAdminUser from '../partials/_register-admin.md';
Expand Down
1 change: 1 addition & 0 deletions mobile/openapi/doc/ServerFeaturesDto.md

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

10 changes: 9 additions & 1 deletion mobile/openapi/lib/model/server_features_dto.dart

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 5 additions & 0 deletions mobile/openapi/test/server_features_dto_test.dart

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 4 additions & 0 deletions server/immich-openapi-specs.json
Original file line number Diff line number Diff line change
Expand Up @@ -6478,6 +6478,9 @@
"clipEncode": {
"type": "boolean"
},
"configFile": {
"type": "boolean"
},
"facialRecognition": {
"type": "boolean"
},
Expand All @@ -6501,6 +6504,7 @@
}
},
"required": [
"configFile",
"clipEncode",
"facialRecognition",
"sidecar",
Expand Down
1 change: 1 addition & 0 deletions server/src/domain/server-info/server-info.dto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@ export class ServerMediaTypesResponseDto {
}

export class ServerFeaturesDto implements FeatureFlags {
configFile!: boolean;
clipEncode!: boolean;
facialRecognition!: boolean;
sidecar!: boolean;
Expand Down
1 change: 1 addition & 0 deletions server/src/domain/server-info/server-info.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,7 @@ describe(ServerInfoService.name, () => {
search: true,
sidecar: true,
tagImage: true,
configFile: false,
});
expect(configMock.load).toHaveBeenCalled();
});
Expand Down
63 changes: 53 additions & 10 deletions server/src/domain/system-config/system-config.core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,13 @@ import {
VideoCodec,
} from '@app/infra/entities';
import { BadRequestException, ForbiddenException, Injectable, Logger } from '@nestjs/common';
import { plainToClass } from 'class-transformer';
import { validate } from 'class-validator';
import * as _ from 'lodash';
import { Subject } from 'rxjs';
import { DeepPartial } from 'typeorm';
import { QueueName } from '../job/job.constants';
import { SystemConfigDto } from './dto';
import { ISystemConfigRepository } from './system-config.repository';

export type SystemConfigValidator = (config: SystemConfig) => void | Promise<void>;
Expand Down Expand Up @@ -87,6 +90,7 @@ export enum FeatureFlag {
OAUTH = 'oauth',
OAUTH_AUTO_LAUNCH = 'oauthAutoLaunch',
PASSWORD_LOGIN = 'passwordLogin',
CONFIG_FILE = 'configFile',
}

export type FeatureFlags = Record<FeatureFlag, boolean>;
Expand All @@ -97,6 +101,7 @@ const singleton = new Subject<SystemConfig>();
export class SystemConfigCore {
private logger = new Logger(SystemConfigCore.name);
private validators: SystemConfigValidator[] = [];
private configCache: SystemConfig | null = null;

public config$ = singleton;

Expand All @@ -120,6 +125,8 @@ export class SystemConfigCore {
throw new BadRequestException('OAuth is not enabled');
case FeatureFlag.PASSWORD_LOGIN:
throw new BadRequestException('Password login is not enabled');
case FeatureFlag.CONFIG_FILE:
throw new BadRequestException('Config file is not set');
default:
throw new ForbiddenException(`Missing required feature: ${feature}`);
}
Expand All @@ -146,6 +153,7 @@ export class SystemConfigCore {
[FeatureFlag.OAUTH]: config.oauth.enabled,
[FeatureFlag.OAUTH_AUTO_LAUNCH]: config.oauth.autoLaunch,
[FeatureFlag.PASSWORD_LOGIN]: config.passwordLogin.enabled,
[FeatureFlag.CONFIG_FILE]: !!process.env.IMMICH_CONFIG_FILE,
};
}

Expand All @@ -157,18 +165,16 @@ export class SystemConfigCore {
this.validators.push(validator);
}

public async getConfig() {
const overrides = await this.repository.load();
const config: DeepPartial<SystemConfig> = {};
for (const { key, value } of overrides) {
// set via dot notation
_.set(config, key, value);
}

return _.defaultsDeep(config, defaults) as SystemConfig;
public getConfig(force = false): Promise<SystemConfig> {
const configFilePath = process.env.IMMICH_CONFIG_FILE;
return configFilePath ? this.loadFromFile(configFilePath, force) : this.loadFromDatabase();
}

public async updateConfig(config: SystemConfig): Promise<SystemConfig> {
if (await this.hasFeature(FeatureFlag.CONFIG_FILE)) {
throw new BadRequestException('Cannot update configuration while IMMICH_CONFIG_FILE is in use');
}

try {
for (const validator of this.validators) {
await validator(config);
Expand Down Expand Up @@ -211,8 +217,45 @@ export class SystemConfigCore {
}

public async refreshConfig() {
const newConfig = await this.getConfig();
const newConfig = await this.getConfig(true);

this.config$.next(newConfig);
}

private async loadFromDatabase() {
const config: DeepPartial<SystemConfig> = {};
const overrides = await this.repository.load();
for (const { key, value } of overrides) {
// set via dot notation
_.set(config, key, value);
}

return _.defaultsDeep(config, defaults) as SystemConfig;
}

private async loadFromFile(filepath: string, force = false) {
if (force || !this.configCache) {
try {
const overrides = JSON.parse((await this.repository.readFile(filepath)).toString());
const config = plainToClass(SystemConfigDto, _.defaultsDeep(overrides, defaults));

const errors = await validate(config, {
whitelist: true,
forbidNonWhitelisted: true,
forbidUnknownValues: true,
});
if (errors.length > 0) {
this.logger.error('Validation error', errors);
throw new Error(`Invalid value(s) in file: ${errors}`);
}

this.configCache = config;
} catch (error: Error | any) {
this.logger.error(`Unable to load configuration file: ${filepath} due to ${error}`, error?.stack);
throw new Error('Invalid configuration file');
}
}

return this.configCache;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ export const ISystemConfigRepository = 'ISystemConfigRepository';

export interface ISystemConfigRepository {
load(): Promise<SystemConfigEntity[]>;
readFile(filename: string): Promise<Buffer>;
saveAll(items: SystemConfigEntity[]): Promise<SystemConfigEntity[]>;
deleteKeys(keys: string[]): Promise<void>;
}
Loading

0 comments on commit 59bb727

Please sign in to comment.