feat(web, server): Ability to use config file instead of admin UI (#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 <jrasm91@gmail.com>
This commit is contained in:
Daniel Dietzler 2023-08-25 19:44:52 +02:00 committed by GitHub
parent 20e0c03b39
commit 59bb727636
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
33 changed files with 359 additions and 84 deletions

View file

@ -2173,6 +2173,12 @@ export interface ServerFeaturesDto {
* @memberof ServerFeaturesDto
*/
'clipEncode': boolean;
/**
*
* @type {boolean}
* @memberof ServerFeaturesDto
*/
'configFile': boolean;
/**
*
* @type {boolean}

View file

@ -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.

View file

@ -1,3 +1,7 @@
---
sidebar_position: 90
---
# Environment Variables
## Docker Compose
@ -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

View file

@ -1,5 +1,5 @@
---
sidebar_position: 100
sidebar_position: 80
---
import RegisterAdminUser from '../partials/_register-admin.md';

View file

@ -9,6 +9,7 @@ import 'package:openapi/api.dart';
Name | Type | Description | Notes
------------ | ------------- | ------------- | -------------
**clipEncode** | **bool** | |
**configFile** | **bool** | |
**facialRecognition** | **bool** | |
**oauth** | **bool** | |
**oauthAutoLaunch** | **bool** | |

View file

@ -14,6 +14,7 @@ class ServerFeaturesDto {
/// Returns a new [ServerFeaturesDto] instance.
ServerFeaturesDto({
required this.clipEncode,
required this.configFile,
required this.facialRecognition,
required this.oauth,
required this.oauthAutoLaunch,
@ -25,6 +26,8 @@ class ServerFeaturesDto {
bool clipEncode;
bool configFile;
bool facialRecognition;
bool oauth;
@ -42,6 +45,7 @@ class ServerFeaturesDto {
@override
bool operator ==(Object other) => identical(this, other) || other is ServerFeaturesDto &&
other.clipEncode == clipEncode &&
other.configFile == configFile &&
other.facialRecognition == facialRecognition &&
other.oauth == oauth &&
other.oauthAutoLaunch == oauthAutoLaunch &&
@ -54,6 +58,7 @@ class ServerFeaturesDto {
int get hashCode =>
// ignore: unnecessary_parenthesis
(clipEncode.hashCode) +
(configFile.hashCode) +
(facialRecognition.hashCode) +
(oauth.hashCode) +
(oauthAutoLaunch.hashCode) +
@ -63,11 +68,12 @@ class ServerFeaturesDto {
(tagImage.hashCode);
@override
String toString() => 'ServerFeaturesDto[clipEncode=$clipEncode, facialRecognition=$facialRecognition, oauth=$oauth, oauthAutoLaunch=$oauthAutoLaunch, passwordLogin=$passwordLogin, search=$search, sidecar=$sidecar, tagImage=$tagImage]';
String toString() => 'ServerFeaturesDto[clipEncode=$clipEncode, configFile=$configFile, facialRecognition=$facialRecognition, oauth=$oauth, oauthAutoLaunch=$oauthAutoLaunch, passwordLogin=$passwordLogin, search=$search, sidecar=$sidecar, tagImage=$tagImage]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
json[r'clipEncode'] = this.clipEncode;
json[r'configFile'] = this.configFile;
json[r'facialRecognition'] = this.facialRecognition;
json[r'oauth'] = this.oauth;
json[r'oauthAutoLaunch'] = this.oauthAutoLaunch;
@ -87,6 +93,7 @@ class ServerFeaturesDto {
return ServerFeaturesDto(
clipEncode: mapValueOfType<bool>(json, r'clipEncode')!,
configFile: mapValueOfType<bool>(json, r'configFile')!,
facialRecognition: mapValueOfType<bool>(json, r'facialRecognition')!,
oauth: mapValueOfType<bool>(json, r'oauth')!,
oauthAutoLaunch: mapValueOfType<bool>(json, r'oauthAutoLaunch')!,
@ -142,6 +149,7 @@ class ServerFeaturesDto {
/// The list of required keys that must be present in a JSON.
static const requiredKeys = <String>{
'clipEncode',
'configFile',
'facialRecognition',
'oauth',
'oauthAutoLaunch',

View file

@ -21,6 +21,11 @@ void main() {
// TODO
});
// bool configFile
test('to test the property `configFile`', () async {
// TODO
});
// bool facialRecognition
test('to test the property `facialRecognition`', () async {
// TODO

View file

@ -6478,6 +6478,9 @@
"clipEncode": {
"type": "boolean"
},
"configFile": {
"type": "boolean"
},
"facialRecognition": {
"type": "boolean"
},
@ -6501,6 +6504,7 @@
}
},
"required": [
"configFile",
"clipEncode",
"facialRecognition",
"sidecar",

View file

@ -80,6 +80,7 @@ export class ServerMediaTypesResponseDto {
}
export class ServerFeaturesDto implements FeatureFlags {
configFile!: boolean;
clipEncode!: boolean;
facialRecognition!: boolean;
sidecar!: boolean;

View file

@ -155,6 +155,7 @@ describe(ServerInfoService.name, () => {
search: true,
sidecar: true,
tagImage: true,
configFile: false,
});
expect(configMock.load).toHaveBeenCalled();
});

View file

@ -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>;
@ -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>;
@ -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;
@ -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}`);
}
@ -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,
};
}
@ -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);
@ -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;
}
}

View file

@ -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>;
}

View file

@ -84,6 +84,7 @@ describe(SystemConfigService.name, () => {
let jobMock: jest.Mocked<IJobRepository>;
beforeEach(async () => {
delete process.env.IMMICH_CONFIG_FILE;
configMock = newSystemConfigRepositoryMock();
jobMock = newJobRepositoryMock();
sut = new SystemConfigService(configMock, jobMock);
@ -126,6 +127,43 @@ describe(SystemConfigService.name, () => {
await expect(sut.getConfig()).resolves.toEqual(updatedConfig);
});
it('should load the config from a file', async () => {
process.env.IMMICH_CONFIG_FILE = 'immich-config.json';
const partialConfig = { ffmpeg: { crf: 30 }, oauth: { autoLaunch: true } };
configMock.readFile.mockResolvedValue(Buffer.from(JSON.stringify(partialConfig)));
await expect(sut.getConfig()).resolves.toEqual(updatedConfig);
expect(configMock.readFile).toHaveBeenCalledWith('immich-config.json');
});
it('should accept an empty configuration file', async () => {
process.env.IMMICH_CONFIG_FILE = 'immich-config.json';
configMock.readFile.mockResolvedValue(Buffer.from(JSON.stringify({})));
await expect(sut.getConfig()).resolves.toEqual(defaults);
expect(configMock.readFile).toHaveBeenCalledWith('immich-config.json');
});
const tests = [
{ should: 'validate numbers', config: { ffmpeg: { crf: 'not-a-number' } } },
{ should: 'validate booleans', config: { oauth: { enabled: 'invalid' } } },
{ should: 'validate enums', config: { ffmpeg: { transcode: 'unknown' } } },
{ should: 'validate top level unknown options', config: { unknownOption: true } },
{ should: 'validate nested unknown options', config: { ffmpeg: { unknownOption: true } } },
{ should: 'validate required oauth fields', config: { oauth: { enabled: true } } },
];
for (const test of tests) {
it(`should ${test.should}`, async () => {
process.env.IMMICH_CONFIG_FILE = 'immich-config.json';
configMock.readFile.mockResolvedValue(Buffer.from(JSON.stringify(test.config)));
await expect(sut.getConfig()).rejects.toBeInstanceOf(Error);
});
}
});
describe('getStorageTemplateOptions', () => {
@ -176,6 +214,13 @@ describe(SystemConfigService.name, () => {
expect(validator).toHaveBeenCalledWith(updatedConfig);
expect(configMock.saveAll).not.toHaveBeenCalled();
});
it('should throw an error if a config file is in use', async () => {
process.env.IMMICH_CONFIG_FILE = 'immich-config.json';
configMock.readFile.mockResolvedValue(Buffer.from(JSON.stringify({})));
await expect(sut.updateConfig(defaults)).rejects.toBeInstanceOf(BadRequestException);
expect(configMock.saveAll).not.toHaveBeenCalled();
});
});
describe('refreshConfig', () => {

View file

@ -1,5 +1,6 @@
import { ISystemConfigRepository } from '@app/domain';
import { InjectRepository } from '@nestjs/typeorm';
import { readFile } from 'fs/promises';
import { In, Repository } from 'typeorm';
import { SystemConfigEntity } from '../entities';
@ -13,6 +14,8 @@ export class SystemConfigRepository implements ISystemConfigRepository {
return this.repository.find();
}
readFile = readFile;
saveAll(items: SystemConfigEntity[]): Promise<SystemConfigEntity[]> {
return this.repository.save(items);
}

View file

@ -3,6 +3,7 @@ import { ISystemConfigRepository } from '@app/domain';
export const newSystemConfigRepositoryMock = (): jest.Mocked<ISystemConfigRepository> => {
return {
load: jest.fn().mockResolvedValue([]),
readFile: jest.fn(),
saveAll: jest.fn().mockResolvedValue([]),
deleteKeys: jest.fn(),
};

View file

@ -2173,6 +2173,12 @@ export interface ServerFeaturesDto {
* @memberof ServerFeaturesDto
*/
'clipEncode': boolean;
/**
*
* @type {boolean}
* @memberof ServerFeaturesDto
*/
'configFile': boolean;
/**
*
* @type {boolean}

View file

@ -1,9 +1,23 @@
import type { AxiosError, AxiosPromise } from 'axios';
import {
notificationController,
NotificationType,
} from '../lib/components/shared-components/notification/notification';
import { handleError } from '../lib/utils/handle-error';
import { api } from './api';
import type { UserResponseDto } from './open-api';
export type ApiError = AxiosError<{ message: string }>;
export const copyToClipboard = async (secret: string) => {
try {
await navigator.clipboard.writeText(secret);
notificationController.show({ message: 'Copied to clipboard!', type: NotificationType.Info });
} catch (error) {
handleError(error, 'Cannot copy to clipboard, make sure you are accessing the page through https');
}
};
export const oauth = {
isCallback: (location: Location) => {
const search = location.search;

View file

@ -20,6 +20,7 @@
import { fade } from 'svelte/transition';
export let ffmpegConfig: SystemConfigFFmpegDto; // this is the config that is being edited
export let disabled = false;
let savedConfig: SystemConfigFFmpegDto;
let defaultConfig: SystemConfigFFmpegDto;
@ -90,6 +91,7 @@
<div class="ml-4 mt-4 flex flex-col gap-4">
<SettingInputField
inputType={SettingInputFieldType.NUMBER}
{disabled}
label="CONSTANT RATE FACTOR (-crf)"
desc="Video quality level. Typical values are 23 for H.264, 28 for HEVC, and 31 for VP9. Lower is better, but takes longer to encode and produces larger files."
bind:value={ffmpegConfig.crf}
@ -99,6 +101,7 @@
<SettingSelect
label="PRESET (-preset)"
{disabled}
desc="Compression speed. Slower presets produce smaller files, and increase quality when targeting a certain bitrate. VP9 ignores speeds above `faster`."
bind:value={ffmpegConfig.preset}
name="preset"
@ -118,6 +121,7 @@
<SettingSelect
label="AUDIO CODEC"
{disabled}
desc="Opus is the highest quality option, but has lower compatibility with old devices or software."
bind:value={ffmpegConfig.targetAudioCodec}
options={[
@ -131,6 +135,7 @@
<SettingSelect
label="VIDEO CODEC"
{disabled}
desc="VP9 has high efficiency and web compatibility, but takes longer to transcode. HEVC performs similarly, but has lower web compatibility. H.264 is widely compatible and quick to transcode, but produces much larger files."
bind:value={ffmpegConfig.targetVideoCodec}
options={[
@ -144,6 +149,7 @@
<SettingSelect
label="TARGET RESOLUTION"
{disabled}
desc="Higher resolutions can preserve more detail but take longer to encode, have larger file sizes, and can reduce app responsiveness."
bind:value={ffmpegConfig.targetResolution}
options={[
@ -160,6 +166,7 @@
<SettingInputField
inputType={SettingInputFieldType.TEXT}
{disabled}
label="MAX BITRATE"
desc="Setting a max bitrate can make file sizes more predictable at a minor cost to quality. At 720p, typical values are 2600k for VP9 or HEVC, or 4500k for H.264. Disabled if set to 0."
bind:value={ffmpegConfig.maxBitrate}
@ -168,6 +175,7 @@
<SettingInputField
inputType={SettingInputFieldType.NUMBER}
{disabled}
label="THREADS"
desc="Higher values lead to faster encoding, but leave less room for the server to process other tasks while active. This value should not be more than the number of CPU cores. Maximizes utilization if set to 0."
bind:value={ffmpegConfig.threads}
@ -176,6 +184,7 @@
<SettingSelect
label="TRANSCODE POLICY"
{disabled}
desc="Policy for when a video should be transcoded."
bind:value={ffmpegConfig.transcode}
name="transcode"
@ -199,6 +208,7 @@
<SettingSelect
label="HARDWARE ACCELERATION"
{disabled}
desc="Experimental. Much faster, but will have lower quality at the same bitrate. This setting is 'best effort': it will fallback to software transcoding on failure. VP9 may or may not work depending on your hardware."
bind:value={ffmpegConfig.accel}
name="accel"
@ -222,6 +232,7 @@
<SettingSelect
label="TONE-MAPPING"
{disabled}
desc="Attempts to preserve the appearance of HDR videos when converted to SDR. Each algorithm makes different tradeoffs for color, detail and brightness. Hable preserves detail, Mobius preserves color, and Reinhard preserves brightness."
bind:value={ffmpegConfig.tonemap}
name="tonemap"
@ -248,6 +259,7 @@
<SettingSwitch
title="TWO-PASS ENCODING"
{disabled}
subtitle="Transcode in two passes to produce better encoded videos. When max bitrate is enabled (required for it to work with H.264 and HEVC), this mode uses a bitrate range based on the max bitrate and ignores CRF. For VP9, CRF can be used if max bitrate is disabled."
bind:checked={ffmpegConfig.twoPass}
isEdited={!(ffmpegConfig.twoPass === savedConfig.twoPass)}
@ -260,6 +272,7 @@
on:save={saveSetting}
on:reset-to-default={resetToDefault}
showResetToDefault={!isEqual(savedConfig, defaultConfig)}
{disabled}
/>
</div>
</form>

View file

@ -11,6 +11,7 @@
import SettingInputField, { SettingInputFieldType } from '../setting-input-field.svelte';
export let jobConfig: SystemConfigJobDto; // this is the config that is being edited
export let disabled = false;
let savedConfig: SystemConfigJobDto;
let defaultConfig: SystemConfigJobDto;
@ -78,6 +79,7 @@
<div class="ml-4 mt-4 flex flex-col gap-4">
<SettingInputField
inputType={SettingInputFieldType.NUMBER}
{disabled}
label="{api.getJobName(jobName)} Concurrency"
desc=""
bind:value={jobConfig[jobName].concurrency}
@ -93,6 +95,7 @@
on:save={saveSetting}
on:reset-to-default={resetToDefault}
showResetToDefault={!isEqual(savedConfig, defaultConfig)}
{disabled}
/>
</div>
</form>

View file

@ -11,6 +11,8 @@
import SettingInputField, { SettingInputFieldType } from '../setting-input-field.svelte';
import SettingSwitch from '../setting-switch.svelte';
export let disabled = false;
let config: SystemConfigDto;
let defaultConfig: SystemConfigDto;
@ -56,6 +58,7 @@
<SettingSwitch
title="Enabled"
subtitle="Use machine learning features"
{disabled}
bind:checked={config.machineLearning.enabled}
/>
@ -67,7 +70,7 @@
desc="URL of machine learning server"
bind:value={config.machineLearning.url}
required={true}
disabled={!config.machineLearning.enabled}
disabled={disabled || !config.machineLearning.enabled}
isEdited={!(config.machineLearning.url === config.machineLearning.url)}
/>
@ -75,20 +78,20 @@
title="SMART SEARCH"
subtitle="Extract CLIP embeddings for smart search"
bind:checked={config.machineLearning.clipEncodeEnabled}
disabled={!config.machineLearning.enabled}
disabled={disabled || !config.machineLearning.enabled}
/>
<SettingSwitch
title="FACIAL RECOGNITION"
subtitle="Recognize and group faces in photos"
disabled={!config.machineLearning.enabled}
disabled={disabled || !config.machineLearning.enabled}
bind:checked={config.machineLearning.facialRecognitionEnabled}
/>
<SettingSwitch
title="IMAGE TAGGING"
subtitle="Tag and classify images"
disabled={!config.machineLearning.enabled}
disabled={disabled || !config.machineLearning.enabled}
bind:checked={config.machineLearning.tagImageEnabled}
/>
@ -97,6 +100,7 @@
on:save={saveSetting}
on:reset-to-default={resetToDefault}
showResetToDefault={!isEqual(config, defaultConfig)}
{disabled}
/>
</form>
</div>

View file

@ -13,6 +13,7 @@
import SettingSwitch from '../setting-switch.svelte';
export let oauthConfig: SystemConfigOAuthDto;
export let disabled = false;
let savedConfig: SystemConfigOAuthDto;
let defaultConfig: SystemConfigOAuthDto;
@ -117,14 +118,14 @@
>.
</p>
<SettingSwitch title="ENABLE" bind:checked={oauthConfig.enabled} />
<SettingSwitch {disabled} title="ENABLE" bind:checked={oauthConfig.enabled} />
<hr />
<SettingInputField
inputType={SettingInputFieldType.TEXT}
label="ISSUER URL"
bind:value={oauthConfig.issuerUrl}
required={true}
disabled={!oauthConfig.enabled}
disabled={disabled || !oauthConfig.enabled}
isEdited={!(oauthConfig.issuerUrl == savedConfig.issuerUrl)}
/>
@ -133,7 +134,7 @@
label="CLIENT ID"
bind:value={oauthConfig.clientId}
required={true}
disabled={!oauthConfig.enabled}
disabled={disabled || !oauthConfig.enabled}
isEdited={!(oauthConfig.clientId == savedConfig.clientId)}
/>
@ -142,7 +143,7 @@
label="CLIENT SECRET"
bind:value={oauthConfig.clientSecret}
required={true}
disabled={!oauthConfig.enabled}
disabled={disabled || !oauthConfig.enabled}
isEdited={!(oauthConfig.clientSecret == savedConfig.clientSecret)}
/>
@ -151,7 +152,7 @@
label="SCOPE"
bind:value={oauthConfig.scope}
required={true}
disabled={!oauthConfig.enabled}
disabled={disabled || !oauthConfig.enabled}
isEdited={!(oauthConfig.scope == savedConfig.scope)}
/>
@ -161,7 +162,7 @@
desc="Automatically set the user's storage label to the value of this claim."
bind:value={oauthConfig.storageLabelClaim}
required={true}
disabled={!oauthConfig.storageLabelClaim}
disabled={disabled || !oauthConfig.storageLabelClaim}
isEdited={!(oauthConfig.storageLabelClaim == savedConfig.storageLabelClaim)}
/>
@ -170,7 +171,7 @@
label="BUTTON TEXT"
bind:value={oauthConfig.buttonText}
required={false}
disabled={!oauthConfig.enabled}
disabled={disabled || !oauthConfig.enabled}
isEdited={!(oauthConfig.buttonText == savedConfig.buttonText)}
/>
@ -178,20 +179,20 @@
title="AUTO REGISTER"
subtitle="Automatically register new users after signing in with OAuth"
bind:checked={oauthConfig.autoRegister}
disabled={!oauthConfig.enabled}
disabled={disabled || !oauthConfig.enabled}
/>
<SettingSwitch
title="AUTO LAUNCH"
subtitle="Start the OAuth login flow automatically upon navigating to the login page"
disabled={!oauthConfig.enabled}
disabled={disabled || !oauthConfig.enabled}
bind:checked={oauthConfig.autoLaunch}
/>
<SettingSwitch
title="MOBILE REDIRECT URI OVERRIDE"
subtitle="Enable when `app.immich:/` is an invalid redirect URI."
disabled={!oauthConfig.enabled}
disabled={disabled || !oauthConfig.enabled}
on:click={() => handleToggleOverride()}
bind:checked={oauthConfig.mobileOverrideEnabled}
/>
@ -202,7 +203,7 @@
label="MOBILE REDIRECT URI"
bind:value={oauthConfig.mobileRedirectUri}
required={true}
disabled={!oauthConfig.enabled}
disabled={disabled || !oauthConfig.enabled}
isEdited={!(oauthConfig.mobileRedirectUri == savedConfig.mobileRedirectUri)}
/>
{/if}
@ -212,6 +213,7 @@
on:save={saveSetting}
on:reset-to-default={resetToDefault}
showResetToDefault={!isEqual(savedConfig, defaultConfig)}
{disabled}
/>
</form>
</div>

View file

@ -12,6 +12,7 @@
import SettingSwitch from '../setting-switch.svelte';
export let passwordLoginConfig: SystemConfigPasswordLoginDto; // this is the config that is being edited
export let disabled = false;
let savedConfig: SystemConfigPasswordLoginDto;
let defaultConfig: SystemConfigPasswordLoginDto;
@ -100,6 +101,7 @@
<div class="ml-4">
<SettingSwitch
title="ENABLED"
{disabled}
subtitle="Login with email and password"
bind:checked={passwordLoginConfig.enabled}
/>
@ -109,6 +111,7 @@
on:save={saveSetting}
on:reset-to-default={resetToDefault}
showResetToDefault={!isEqual(savedConfig, defaultConfig)}
{disabled}
/>
</div>
</div>

View file

@ -5,6 +5,7 @@
const dispatch = createEventDispatcher();
export let showResetToDefault = true;
export let disabled = false;
</script>
<div class="mt-8 flex justify-between gap-2">
@ -20,7 +21,7 @@
</div>
<div class="right">
<Button size="sm" color="gray" on:click={() => dispatch('reset')}>Reset</Button>
<Button size="sm" on:click={() => dispatch('save')}>Save</Button>
<Button {disabled} size="sm" color="gray" on:click={() => dispatch('reset')}>Reset</Button>
<Button {disabled} size="sm" on:click={() => dispatch('save')}>Save</Button>
</div>
</div>

View file

@ -9,6 +9,7 @@
export let name = '';
export let isEdited = false;
export let number = false;
export let disabled = false;
const handleChange = (e: Event) => {
value = (e.target as HTMLInputElement).value;
@ -40,6 +41,7 @@
<select
class="immich-form-input w-full pb-2"
{disabled}
aria-describedby={desc ? `${name}-desc` : undefined}
{name}
id="{name}-select"

View file

@ -16,6 +16,7 @@
export let storageConfig: SystemConfigStorageTemplateDto;
export let user: UserResponseDto;
export let disabled = false;
let savedConfig: SystemConfigStorageTemplateDto;
let defaultConfig: SystemConfigStorageTemplateDto;
@ -178,6 +179,7 @@
<label class="text-xs" for="preset-select">PRESET</label>
<select
class="mt-2 rounded-lg bg-slate-200 p-2 text-sm hover:cursor-pointer dark:bg-gray-600"
{disabled}
name="presets"
id="preset-select"
bind:value={selectedPreset}
@ -191,6 +193,7 @@
<div class="flex gap-2 align-bottom">
<SettingInputField
label="TEMPLATE"
{disabled}
required
inputType={SettingInputFieldType.TEXT}
bind:value={storageConfig.template}
@ -216,6 +219,7 @@
on:save={saveSetting}
on:reset-to-default={resetToDefault}
showResetToDefault={!isEqual(savedConfig, defaultConfig)}
{disabled}
/>
</form>
</div>

View file

@ -10,6 +10,7 @@
} from '$lib/components/shared-components/notification/notification';
export let thumbnailConfig: SystemConfigThumbnailDto; // this is the config that is being edited
export let disabled = false;
let savedConfig: SystemConfigThumbnailDto;
let defaultConfig: SystemConfigThumbnailDto;
@ -91,6 +92,7 @@
]}
name="resolution"
isEdited={!(thumbnailConfig.webpSize === savedConfig.webpSize)}
{disabled}
/>
<SettingSelect
@ -104,6 +106,7 @@
]}
name="resolution"
isEdited={!(thumbnailConfig.jpegSize === savedConfig.jpegSize)}
{disabled}
/>
</div>
@ -113,6 +116,7 @@
on:save={saveSetting}
on:reset-to-default={resetToDefault}
showResetToDefault={!isEqual(savedConfig, defaultConfig)}
{disabled}
/>
</div>
</form>

View file

@ -1,10 +1,9 @@
<script lang="ts">
import { createEventDispatcher, onMount } from 'svelte';
import KeyVariant from 'svelte-material-icons/KeyVariant.svelte';
import { handleError } from '../../utils/handle-error';
import FullScreenModal from '../shared-components/full-screen-modal.svelte';
import { notificationController, NotificationType } from '../shared-components/notification/notification';
import { copyToClipboard } from '@api';
import Button from '../elements/buttons/button.svelte';
import FullScreenModal from '../shared-components/full-screen-modal.svelte';
export let secret = '';
@ -16,17 +15,6 @@
const module = await import('copy-image-clipboard');
canCopyImagesToClipboard = module.canCopyImagesToClipboard();
});
const handleCopy = async () => {
try {
await navigator.clipboard.writeText(secret);
notificationController.show({
message: 'Copied to clipboard!',
type: NotificationType.Info,
});
} catch (error) {
handleError(error, 'Unable to copy to clipboard');
}
};
</script>
<FullScreenModal>
@ -51,7 +39,7 @@
<div class="mt-8 flex w-full gap-4 px-4">
{#if canCopyImagesToClipboard}
<Button on:click={() => handleCopy()} fullwidth>Copy to Clipboard</Button>
<Button on:click={() => copyToClipboard(secret)} fullwidth>Copy to Clipboard</Button>
{/if}
<Button on:click={() => handleDone()} fullwidth>Done</Button>
</div>

View file

@ -5,7 +5,7 @@
import SettingSwitch from '$lib/components/admin-page/settings/setting-switch.svelte';
import Button from '$lib/components/elements/buttons/button.svelte';
import { handleError } from '$lib/utils/handle-error';
import { api, SharedLinkResponseDto, SharedLinkType } from '@api';
import { api, copyToClipboard, SharedLinkResponseDto, SharedLinkType } from '@api';
import { createEventDispatcher, onMount } from 'svelte';
import Link from 'svelte-material-icons/Link.svelte';
import BaseModal from '../base-modal.svelte';
@ -80,12 +80,7 @@
return;
}
try {
await navigator.clipboard.writeText(sharedLink);
notificationController.show({ message: 'Copied to clipboard!', type: NotificationType.Info });
} catch (e) {
handleError(e, 'Cannot copy to clipboard, make sure you are accessing the page through https');
}
await copyToClipboard(sharedLink);
};
const getExpirationTimeInMillisecond = () => {

View file

@ -12,6 +12,7 @@ export const featureFlags = writable<FeatureFlags>({
oauth: true,
oauthAutoLaunch: true,
passwordLogin: true,
configFile: false,
});
export const loadFeatureFlags = async () => {

View file

@ -20,7 +20,7 @@ export const addAssetsToAlbum = async (albumId: string, assetIds: Array<string>)
return results;
});
const downloadBlob = (data: Blob, filename: string) => {
export const downloadBlob = (data: Blob, filename: string) => {
const url = URL.createObjectURL(data);
const anchor = document.createElement('a');

View file

@ -1,7 +1,7 @@
<script lang="ts">
import ControlAppBar from '$lib/components/shared-components/control-app-bar.svelte';
import ArrowLeft from 'svelte-material-icons/ArrowLeft.svelte';
import { api, SharedLinkResponseDto } from '@api';
import { api, copyToClipboard, SharedLinkResponseDto } from '@api';
import { goto } from '$app/navigation';
import SharedLinkCard from '$lib/components/sharedlinks-page/shared-link-card.svelte';
import {
@ -49,12 +49,7 @@
};
const handleCopyLink = async (key: string) => {
const link = `${window.location.origin}/share/${key}`;
await navigator.clipboard.writeText(link);
notificationController.show({
message: 'Link copied to clipboard',
type: NotificationType.Info,
});
await copyToClipboard(`${window.location.origin}/share/${key}`);
};
</script>

View file

@ -1,15 +1,11 @@
<script>
import { page } from '$app/stores';
import Message from 'svelte-material-icons/Message.svelte';
import PartyPopper from 'svelte-material-icons/PartyPopper.svelte';
import ImmichLogo from '$lib/components/shared-components/immich-logo.svelte';
import CodeTags from 'svelte-material-icons/CodeTags.svelte';
import ContentCopy from 'svelte-material-icons/ContentCopy.svelte';
import {
notificationController,
NotificationType,
} from '$lib/components/shared-components/notification/notification';
import { handleError } from '$lib/utils/handle-error';
import ImmichLogo from '$lib/components/shared-components/immich-logo.svelte';
import Message from 'svelte-material-icons/Message.svelte';
import PartyPopper from 'svelte-material-icons/PartyPopper.svelte';
import { copyToClipboard } from '../api/utils';
const handleCopy = async () => {
//
@ -18,15 +14,7 @@
return;
}
try {
await navigator.clipboard.writeText(`${error.message} - ${error.code}\n${error.stack}`);
notificationController.show({
type: NotificationType.Info,
message: 'Copied error to clipboard',
});
} catch (error) {
handleError(error, 'Unable to copy to clipboard');
}
await copyToClipboard(`${error.message} - ${error.code}\n${error.stack}`);
};
</script>

View file

@ -8,8 +8,15 @@
import SettingAccordion from '$lib/components/admin-page/settings/setting-accordion.svelte';
import StorageTemplateSettings from '$lib/components/admin-page/settings/storage-template/storage-template-settings.svelte';
import ThumbnailSettings from '$lib/components/admin-page/settings/thumbnail/thumbnail-settings.svelte';
import Button from '$lib/components/elements/buttons/button.svelte';
import LoadingSpinner from '$lib/components/shared-components/loading-spinner.svelte';
import { api } from '@api';
import { downloadManager } from '$lib/stores/download';
import { featureFlags } from '$lib/stores/feature-flags.store';
import { downloadBlob } from '$lib/utils/asset-utils';
import { SystemConfigDto, api, copyToClipboard } from '@api';
import Alert from 'svelte-material-icons/Alert.svelte';
import ContentCopy from 'svelte-material-icons/ContentCopy.svelte';
import Download from 'svelte-material-icons/Download.svelte';
import type { PageData } from './$types';
export let data: PageData;
@ -18,21 +25,47 @@
const { data } = await api.systemConfigApi.getConfig();
return data;
};
const downloadConfig = (configs: SystemConfigDto) => {
const blob = new Blob([JSON.stringify(configs, null, 2)], { type: 'application/json' });
const downloadKey = 'immich-config.json';
downloadManager.add(downloadKey, blob.size);
downloadManager.update(downloadKey, blob.size);
downloadBlob(blob, downloadKey);
setTimeout(() => downloadManager.clear(downloadKey), 5_000);
};
</script>
{#if $featureFlags.configFile}
<div class="mb-8 flex flex-row items-center gap-2 rounded-md bg-gray-100 p-3 dark:bg-gray-800">
<Alert class="text-yellow-400" size={18} />
<h2 class="text-md text-immich-primary dark:text-immich-dark-primary">Config is currently set by a config file</h2>
</div>
{/if}
<section class="">
{#await getConfig()}
<LoadingSpinner />
{:then configs}
<div class="flex justify-end gap-2">
<Button size="sm" on:click={() => copyToClipboard(JSON.stringify(configs, null, 2))}>
<ContentCopy size="18" />
<span class="pl-2">Copy to Clipboard</span>
</Button>
<Button size="sm" on:click={() => downloadConfig(configs)}>
<Download size="18" />
<span class="pl-2">Export as JSON</span>
</Button>
</div>
<SettingAccordion title="Thumbnail Settings" subtitle="Manage the resolution of thumbnail sizes">
<ThumbnailSettings thumbnailConfig={configs.thumbnail} />
<ThumbnailSettings disabled={$featureFlags.configFile} thumbnailConfig={configs.thumbnail} />
</SettingAccordion>
<SettingAccordion
title="FFmpeg Settings"
subtitle="Manage the resolution and encoding information of the video files"
>
<FFmpegSettings ffmpegConfig={configs.ffmpeg} />
<FFmpegSettings disabled={$featureFlags.configFile} ffmpegConfig={configs.ffmpeg} />
</SettingAccordion>
<SettingAccordion
@ -40,19 +73,19 @@
subtitle="Manage job concurrency"
isOpen={$page.url.searchParams.get('open') === 'job-settings'}
>
<JobSettings jobConfig={configs.job} />
<JobSettings disabled={$featureFlags.configFile} jobConfig={configs.job} />
</SettingAccordion>
<SettingAccordion title="Password Authentication" subtitle="Manage login with password settings">
<PasswordLoginSettings passwordLoginConfig={configs.passwordLogin} />
<PasswordLoginSettings disabled={$featureFlags.configFile} passwordLoginConfig={configs.passwordLogin} />
</SettingAccordion>
<SettingAccordion title="OAuth Authentication" subtitle="Manage the login with OAuth settings">
<OAuthSettings oauthConfig={configs.oauth} />
<OAuthSettings disabled={$featureFlags.configFile} oauthConfig={configs.oauth} />
</SettingAccordion>
<SettingAccordion title="Machine Learning" subtitle="Manage machine learning settings">
<MachineLearningSettings />
<MachineLearningSettings disabled={$featureFlags.configFile} />
</SettingAccordion>
<SettingAccordion
@ -60,7 +93,11 @@
subtitle="Manage the folder structure and file name of the upload asset"
isOpen={$page.url.searchParams.get('open') === 'storage-template'}
>
<StorageTemplateSettings storageConfig={configs.storageTemplate} user={data.user} />
<StorageTemplateSettings
disabled={$featureFlags.configFile}
storageConfig={configs.storageTemplate}
user={data.user}
/>
</SettingAccordion>
{/await}
</section>