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:
parent
20e0c03b39
commit
59bb727636
33 changed files with 359 additions and 84 deletions
6
cli/src/api/open-api/api.ts
generated
6
cli/src/api/open-api/api.ts
generated
|
@ -2173,6 +2173,12 @@ export interface ServerFeaturesDto {
|
|||
* @memberof ServerFeaturesDto
|
||||
*/
|
||||
'clipEncode': boolean;
|
||||
/**
|
||||
*
|
||||
* @type {boolean}
|
||||
* @memberof ServerFeaturesDto
|
||||
*/
|
||||
'configFile': boolean;
|
||||
/**
|
||||
*
|
||||
* @type {boolean}
|
||||
|
|
91
docs/docs/install/config-file.md
Normal file
91
docs/docs/install/config-file.md
Normal 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.
|
|
@ -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
|
||||
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
---
|
||||
sidebar_position: 100
|
||||
sidebar_position: 80
|
||||
---
|
||||
|
||||
import RegisterAdminUser from '../partials/_register-admin.md';
|
||||
|
|
1
mobile/openapi/doc/ServerFeaturesDto.md
generated
1
mobile/openapi/doc/ServerFeaturesDto.md
generated
|
@ -9,6 +9,7 @@ import 'package:openapi/api.dart';
|
|||
Name | Type | Description | Notes
|
||||
------------ | ------------- | ------------- | -------------
|
||||
**clipEncode** | **bool** | |
|
||||
**configFile** | **bool** | |
|
||||
**facialRecognition** | **bool** | |
|
||||
**oauth** | **bool** | |
|
||||
**oauthAutoLaunch** | **bool** | |
|
||||
|
|
10
mobile/openapi/lib/model/server_features_dto.dart
generated
10
mobile/openapi/lib/model/server_features_dto.dart
generated
|
@ -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',
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -6478,6 +6478,9 @@
|
|||
"clipEncode": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"configFile": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"facialRecognition": {
|
||||
"type": "boolean"
|
||||
},
|
||||
|
@ -6501,6 +6504,7 @@
|
|||
}
|
||||
},
|
||||
"required": [
|
||||
"configFile",
|
||||
"clipEncode",
|
||||
"facialRecognition",
|
||||
"sidecar",
|
||||
|
|
|
@ -80,6 +80,7 @@ export class ServerMediaTypesResponseDto {
|
|||
}
|
||||
|
||||
export class ServerFeaturesDto implements FeatureFlags {
|
||||
configFile!: boolean;
|
||||
clipEncode!: boolean;
|
||||
facialRecognition!: boolean;
|
||||
sidecar!: boolean;
|
||||
|
|
|
@ -155,6 +155,7 @@ describe(ServerInfoService.name, () => {
|
|||
search: true,
|
||||
sidecar: true,
|
||||
tagImage: true,
|
||||
configFile: false,
|
||||
});
|
||||
expect(configMock.load).toHaveBeenCalled();
|
||||
});
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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>;
|
||||
}
|
||||
|
|
|
@ -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', () => {
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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(),
|
||||
};
|
||||
|
|
6
web/src/api/open-api/api.ts
generated
6
web/src/api/open-api/api.ts
generated
|
@ -2173,6 +2173,12 @@ export interface ServerFeaturesDto {
|
|||
* @memberof ServerFeaturesDto
|
||||
*/
|
||||
'clipEncode': boolean;
|
||||
/**
|
||||
*
|
||||
* @type {boolean}
|
||||
* @memberof ServerFeaturesDto
|
||||
*/
|
||||
'configFile': boolean;
|
||||
/**
|
||||
*
|
||||
* @type {boolean}
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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 = () => {
|
||||
|
|
|
@ -12,6 +12,7 @@ export const featureFlags = writable<FeatureFlags>({
|
|||
oauth: true,
|
||||
oauthAutoLaunch: true,
|
||||
passwordLogin: true,
|
||||
configFile: false,
|
||||
});
|
||||
|
||||
export const loadFeatureFlags = async () => {
|
||||
|
|
|
@ -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');
|
||||
|
|
|
@ -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>
|
||||
|
||||
|
|
|
@ -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>
|
||||
|
||||
|
|
|
@ -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>
|
||||
|
|
Loading…
Reference in a new issue