feat: new config endpoints (#30881)
* refactor: config endpoints * chore: pr feedback
This commit is contained in:
@@ -45,7 +45,7 @@ describe('/jobs', () => {
|
|||||||
config.machineLearning.enabled = false;
|
config.machineLearning.enabled = false;
|
||||||
config.metadata.faces.import = false;
|
config.metadata.faces.import = false;
|
||||||
config.machineLearning.clip.enabled = false;
|
config.machineLearning.clip.enabled = false;
|
||||||
await updateConfig({ systemConfigDto: config }, { headers: asBearerAuth(admin.accessToken) });
|
await updateConfig({ adminConfigDto: config }, { headers: asBearerAuth(admin.accessToken) });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should queue metadata extraction for missing assets', async () => {
|
it('should queue metadata extraction for missing assets', async () => {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { OAuthClient, OAuthUser, generateLogoutToken } from '@immich/e2e-auth-server';
|
import { OAuthClient, OAuthUser, generateLogoutToken } from '@immich/e2e-auth-server';
|
||||||
import {
|
import {
|
||||||
|
AdminConfigOAuthDto,
|
||||||
LoginResponseDto,
|
LoginResponseDto,
|
||||||
SystemConfigOAuthDto,
|
|
||||||
getConfigDefaults,
|
getConfigDefaults,
|
||||||
getMyUser,
|
getMyUser,
|
||||||
getSessions,
|
getSessions,
|
||||||
@@ -70,7 +70,7 @@ const loginWithOAuth = async (sub: OAuthUser | string, redirectUri?: string) =>
|
|||||||
return { url: redirectUrl, state, codeVerifier };
|
return { url: redirectUrl, state, codeVerifier };
|
||||||
};
|
};
|
||||||
|
|
||||||
const setupOAuth = async (token: string, dto: Partial<SystemConfigOAuthDto>) => {
|
const setupOAuth = async (token: string, dto: Partial<AdminConfigOAuthDto>) => {
|
||||||
const options = { headers: asBearerAuth(token) };
|
const options = { headers: asBearerAuth(token) };
|
||||||
const defaults = await getConfigDefaults(options);
|
const defaults = await getConfigDefaults(options);
|
||||||
const merged = {
|
const merged = {
|
||||||
@@ -80,7 +80,7 @@ const setupOAuth = async (token: string, dto: Partial<SystemConfigOAuthDto>) =>
|
|||||||
allowInsecureRequests: true,
|
allowInsecureRequests: true,
|
||||||
...dto,
|
...dto,
|
||||||
};
|
};
|
||||||
await updateConfig({ systemConfigDto: { ...defaults, oauth: merged } }, options);
|
await updateConfig({ adminConfigDto: { ...defaults, oauth: merged } }, options);
|
||||||
};
|
};
|
||||||
|
|
||||||
describe(`/oauth`, () => {
|
describe(`/oauth`, () => {
|
||||||
|
|||||||
+1
-1
@@ -647,7 +647,7 @@ export const utils = {
|
|||||||
|
|
||||||
resetAdminConfig: async (accessToken: string) => {
|
resetAdminConfig: async (accessToken: string) => {
|
||||||
const defaultConfig = await getConfigDefaults({ headers: asBearerAuth(accessToken) });
|
const defaultConfig = await getConfigDefaults({ headers: asBearerAuth(accessToken) });
|
||||||
await updateConfig({ systemConfigDto: defaultConfig }, { headers: asBearerAuth(accessToken) });
|
await updateConfig({ adminConfigDto: defaultConfig }, { headers: asBearerAuth(accessToken) });
|
||||||
},
|
},
|
||||||
|
|
||||||
isQueueEmpty: async (accessToken: string, queue: keyof QueuesResponseLegacyDto) => {
|
isQueueEmpty: async (accessToken: string, queue: keyof QueuesResponseLegacyDto) => {
|
||||||
|
|||||||
+2017
-1220
File diff suppressed because it is too large
Load Diff
+676
-457
File diff suppressed because it is too large
Load Diff
@@ -1,449 +0,0 @@
|
|||||||
import { CronExpression } from '@nestjs/schedule';
|
|
||||||
import { ReleaseChannel } from 'src/dtos/system-config.dto';
|
|
||||||
import {
|
|
||||||
AudioCodec,
|
|
||||||
Colorspace,
|
|
||||||
CQMode,
|
|
||||||
HlsVideoResolution,
|
|
||||||
ImageFormat,
|
|
||||||
LogLevel,
|
|
||||||
OAuthTokenEndpointAuthMethod,
|
|
||||||
QueueName,
|
|
||||||
ToneMapping,
|
|
||||||
TranscodeHardwareAcceleration,
|
|
||||||
TranscodePolicy,
|
|
||||||
VideoCodec,
|
|
||||||
VideoContainer,
|
|
||||||
} from 'src/enum';
|
|
||||||
import { ConcurrentQueueName, FullsizeImageOptions, ImageOptions } from 'src/types';
|
|
||||||
|
|
||||||
export type SystemConfig = {
|
|
||||||
backup: {
|
|
||||||
database: {
|
|
||||||
enabled: boolean;
|
|
||||||
cronExpression: string;
|
|
||||||
keepLastAmount: number;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
ffmpeg: {
|
|
||||||
crf: number;
|
|
||||||
threads: number;
|
|
||||||
preset: string;
|
|
||||||
targetVideoCodec: VideoCodec;
|
|
||||||
acceptedVideoCodecs: VideoCodec[];
|
|
||||||
targetAudioCodec: AudioCodec;
|
|
||||||
acceptedAudioCodecs: AudioCodec[];
|
|
||||||
acceptedContainers: VideoContainer[];
|
|
||||||
targetResolution: string;
|
|
||||||
maxBitrate: string;
|
|
||||||
bframes: number;
|
|
||||||
refs: number;
|
|
||||||
gopSize: number;
|
|
||||||
temporalAQ: boolean;
|
|
||||||
cqMode: CQMode;
|
|
||||||
twoPass: boolean;
|
|
||||||
preferredHwDevice: string;
|
|
||||||
transcode: TranscodePolicy;
|
|
||||||
accel: TranscodeHardwareAcceleration;
|
|
||||||
accelDecode: boolean;
|
|
||||||
tonemap: ToneMapping;
|
|
||||||
realtime: {
|
|
||||||
enabled: boolean;
|
|
||||||
videoCodecs: VideoCodec[];
|
|
||||||
resolutions: HlsVideoResolution[];
|
|
||||||
};
|
|
||||||
};
|
|
||||||
integrityChecks: {
|
|
||||||
missingFiles: {
|
|
||||||
enabled: boolean;
|
|
||||||
cronExpression: string;
|
|
||||||
};
|
|
||||||
untrackedFiles: {
|
|
||||||
enabled: boolean;
|
|
||||||
cronExpression: string;
|
|
||||||
};
|
|
||||||
checksumFiles: {
|
|
||||||
enabled: boolean;
|
|
||||||
cronExpression: string;
|
|
||||||
timeLimit: number;
|
|
||||||
percentageLimit: number;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
job: Record<ConcurrentQueueName, { concurrency: number }>;
|
|
||||||
logging: {
|
|
||||||
enabled: boolean;
|
|
||||||
level: LogLevel;
|
|
||||||
};
|
|
||||||
machineLearning: {
|
|
||||||
enabled: boolean;
|
|
||||||
urls: string[];
|
|
||||||
availabilityChecks: {
|
|
||||||
enabled: boolean;
|
|
||||||
timeout: number;
|
|
||||||
interval: number;
|
|
||||||
};
|
|
||||||
clip: {
|
|
||||||
enabled: boolean;
|
|
||||||
modelName: string;
|
|
||||||
};
|
|
||||||
duplicateDetection: {
|
|
||||||
enabled: boolean;
|
|
||||||
maxDistance: number;
|
|
||||||
};
|
|
||||||
facialRecognition: {
|
|
||||||
enabled: boolean;
|
|
||||||
modelName: string;
|
|
||||||
minScore: number;
|
|
||||||
minFaces: number;
|
|
||||||
maxDistance: number;
|
|
||||||
};
|
|
||||||
ocr: {
|
|
||||||
enabled: boolean;
|
|
||||||
modelName: string;
|
|
||||||
minDetectionScore: number;
|
|
||||||
minRecognitionScore: number;
|
|
||||||
maxResolution: number;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
map: {
|
|
||||||
enabled: boolean;
|
|
||||||
lightStyle: string;
|
|
||||||
darkStyle: string;
|
|
||||||
};
|
|
||||||
reverseGeocoding: {
|
|
||||||
enabled: boolean;
|
|
||||||
};
|
|
||||||
metadata: {
|
|
||||||
faces: {
|
|
||||||
import: boolean;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
oauth: {
|
|
||||||
autoLaunch: boolean;
|
|
||||||
autoRegister: boolean;
|
|
||||||
buttonText: string;
|
|
||||||
clientId: string;
|
|
||||||
clientSecret: string;
|
|
||||||
defaultStorageQuota: number | null;
|
|
||||||
enabled: boolean;
|
|
||||||
issuerUrl: string;
|
|
||||||
endSessionEndpoint: string;
|
|
||||||
mobileOverrideEnabled: boolean;
|
|
||||||
mobileRedirectUri: string;
|
|
||||||
prompt: string;
|
|
||||||
scope: string;
|
|
||||||
signingAlgorithm: string;
|
|
||||||
profileSigningAlgorithm: string;
|
|
||||||
tokenEndpointAuthMethod: OAuthTokenEndpointAuthMethod;
|
|
||||||
timeout: number;
|
|
||||||
allowInsecureRequests: boolean;
|
|
||||||
storageLabelClaim: string;
|
|
||||||
storageQuotaClaim: string;
|
|
||||||
roleClaim: string;
|
|
||||||
};
|
|
||||||
passwordLogin: {
|
|
||||||
enabled: boolean;
|
|
||||||
};
|
|
||||||
storageTemplate: {
|
|
||||||
enabled: boolean;
|
|
||||||
hashVerificationEnabled: boolean;
|
|
||||||
template: string;
|
|
||||||
};
|
|
||||||
image: {
|
|
||||||
thumbnail: ImageOptions;
|
|
||||||
preview: ImageOptions;
|
|
||||||
colorspace: Colorspace;
|
|
||||||
extractEmbedded: boolean;
|
|
||||||
fullsize: FullsizeImageOptions;
|
|
||||||
};
|
|
||||||
newVersionCheck: {
|
|
||||||
enabled: boolean;
|
|
||||||
channel: ReleaseChannel;
|
|
||||||
};
|
|
||||||
nightlyTasks: {
|
|
||||||
startTime: string;
|
|
||||||
databaseCleanup: boolean;
|
|
||||||
missingThumbnails: boolean;
|
|
||||||
clusterNewFaces: boolean;
|
|
||||||
generateMemories: boolean;
|
|
||||||
syncQuotaUsage: boolean;
|
|
||||||
};
|
|
||||||
trash: {
|
|
||||||
enabled: boolean;
|
|
||||||
days: number;
|
|
||||||
};
|
|
||||||
theme: {
|
|
||||||
customCss: string;
|
|
||||||
};
|
|
||||||
library: {
|
|
||||||
scan: {
|
|
||||||
enabled: boolean;
|
|
||||||
cronExpression: string;
|
|
||||||
};
|
|
||||||
watch: {
|
|
||||||
enabled: boolean;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
notifications: {
|
|
||||||
smtp: {
|
|
||||||
enabled: boolean;
|
|
||||||
from: string;
|
|
||||||
replyTo: string;
|
|
||||||
transport: {
|
|
||||||
ignoreCert: boolean;
|
|
||||||
host: string;
|
|
||||||
port: number;
|
|
||||||
secure: boolean;
|
|
||||||
username: string;
|
|
||||||
password: string;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
};
|
|
||||||
templates: {
|
|
||||||
email: {
|
|
||||||
welcomeTemplate: string;
|
|
||||||
albumInviteTemplate: string;
|
|
||||||
albumUpdateTemplate: string;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
server: {
|
|
||||||
externalDomain: string;
|
|
||||||
loginPageMessage: string;
|
|
||||||
publicUsers: boolean;
|
|
||||||
};
|
|
||||||
user: {
|
|
||||||
deleteDelay: number;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
export type MachineLearningConfig = SystemConfig['machineLearning'];
|
|
||||||
|
|
||||||
export const defaults = Object.freeze<SystemConfig>({
|
|
||||||
backup: {
|
|
||||||
database: {
|
|
||||||
enabled: true,
|
|
||||||
cronExpression: CronExpression.EVERY_DAY_AT_2AM,
|
|
||||||
keepLastAmount: 14,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
ffmpeg: {
|
|
||||||
crf: 23,
|
|
||||||
threads: 0,
|
|
||||||
preset: 'ultrafast',
|
|
||||||
targetVideoCodec: VideoCodec.H264,
|
|
||||||
acceptedVideoCodecs: [VideoCodec.H264],
|
|
||||||
targetAudioCodec: AudioCodec.Aac,
|
|
||||||
acceptedAudioCodecs: [AudioCodec.Aac, AudioCodec.Mp3, AudioCodec.Opus],
|
|
||||||
acceptedContainers: [VideoContainer.Mov, VideoContainer.Ogg, VideoContainer.Webm],
|
|
||||||
targetResolution: '720',
|
|
||||||
maxBitrate: '0',
|
|
||||||
bframes: -1,
|
|
||||||
refs: 0,
|
|
||||||
gopSize: 0,
|
|
||||||
temporalAQ: false,
|
|
||||||
cqMode: CQMode.Auto,
|
|
||||||
twoPass: false,
|
|
||||||
preferredHwDevice: 'auto',
|
|
||||||
transcode: TranscodePolicy.Required,
|
|
||||||
tonemap: ToneMapping.Hable,
|
|
||||||
accel: TranscodeHardwareAcceleration.Disabled,
|
|
||||||
accelDecode: true,
|
|
||||||
realtime: {
|
|
||||||
enabled: false,
|
|
||||||
videoCodecs: [VideoCodec.H264, VideoCodec.Hevc],
|
|
||||||
resolutions: [HlsVideoResolution.p480, HlsVideoResolution.p720, HlsVideoResolution.p1080],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
integrityChecks: {
|
|
||||||
missingFiles: {
|
|
||||||
enabled: true,
|
|
||||||
cronExpression: CronExpression.EVERY_DAY_AT_3AM,
|
|
||||||
},
|
|
||||||
untrackedFiles: {
|
|
||||||
enabled: true,
|
|
||||||
cronExpression: CronExpression.EVERY_DAY_AT_3AM,
|
|
||||||
},
|
|
||||||
checksumFiles: {
|
|
||||||
enabled: true,
|
|
||||||
cronExpression: CronExpression.EVERY_DAY_AT_3AM,
|
|
||||||
timeLimit: 60 * 60 * 1000, // 1 hour
|
|
||||||
percentageLimit: 1, // 100% of assets
|
|
||||||
},
|
|
||||||
},
|
|
||||||
job: {
|
|
||||||
[QueueName.BackgroundTask]: { concurrency: 5 },
|
|
||||||
[QueueName.SmartSearch]: { concurrency: 2 },
|
|
||||||
[QueueName.MetadataExtraction]: { concurrency: 5 },
|
|
||||||
[QueueName.FaceDetection]: { concurrency: 2 },
|
|
||||||
[QueueName.Search]: { concurrency: 5 },
|
|
||||||
[QueueName.Sidecar]: { concurrency: 5 },
|
|
||||||
[QueueName.Library]: { concurrency: 5 },
|
|
||||||
[QueueName.Migration]: { concurrency: 5 },
|
|
||||||
[QueueName.ThumbnailGeneration]: { concurrency: 3 },
|
|
||||||
[QueueName.VideoConversion]: { concurrency: 1 },
|
|
||||||
[QueueName.Notification]: { concurrency: 5 },
|
|
||||||
[QueueName.Ocr]: { concurrency: 1 },
|
|
||||||
[QueueName.Workflow]: { concurrency: 5 },
|
|
||||||
[QueueName.IntegrityCheck]: { concurrency: 1 },
|
|
||||||
[QueueName.Editor]: { concurrency: 2 },
|
|
||||||
},
|
|
||||||
logging: {
|
|
||||||
enabled: true,
|
|
||||||
level: LogLevel.Log,
|
|
||||||
},
|
|
||||||
machineLearning: {
|
|
||||||
enabled: process.env.IMMICH_MACHINE_LEARNING_ENABLED !== 'false',
|
|
||||||
urls: [process.env.IMMICH_MACHINE_LEARNING_URL || 'http://immich-machine-learning:3003'],
|
|
||||||
availabilityChecks: {
|
|
||||||
enabled: true,
|
|
||||||
timeout: 2000,
|
|
||||||
interval: 30_000,
|
|
||||||
},
|
|
||||||
clip: {
|
|
||||||
enabled: true,
|
|
||||||
modelName: 'ViT-B-32__openai',
|
|
||||||
},
|
|
||||||
duplicateDetection: {
|
|
||||||
enabled: true,
|
|
||||||
maxDistance: 0.01,
|
|
||||||
},
|
|
||||||
facialRecognition: {
|
|
||||||
enabled: true,
|
|
||||||
modelName: 'buffalo_l',
|
|
||||||
minScore: 0.7,
|
|
||||||
maxDistance: 0.5,
|
|
||||||
minFaces: 3,
|
|
||||||
},
|
|
||||||
ocr: {
|
|
||||||
enabled: true,
|
|
||||||
modelName: 'PP-OCRv5_mobile',
|
|
||||||
minDetectionScore: 0.5,
|
|
||||||
minRecognitionScore: 0.8,
|
|
||||||
maxResolution: 736,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
map: {
|
|
||||||
enabled: true,
|
|
||||||
lightStyle: 'https://tiles.immich.cloud/v1/style/light.json',
|
|
||||||
darkStyle: 'https://tiles.immich.cloud/v1/style/dark.json',
|
|
||||||
},
|
|
||||||
reverseGeocoding: {
|
|
||||||
enabled: true,
|
|
||||||
},
|
|
||||||
metadata: {
|
|
||||||
faces: {
|
|
||||||
import: false,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
oauth: {
|
|
||||||
autoLaunch: false,
|
|
||||||
autoRegister: true,
|
|
||||||
buttonText: 'Login with OAuth',
|
|
||||||
clientId: '',
|
|
||||||
clientSecret: '',
|
|
||||||
defaultStorageQuota: null,
|
|
||||||
enabled: false,
|
|
||||||
issuerUrl: '',
|
|
||||||
endSessionEndpoint: '',
|
|
||||||
mobileOverrideEnabled: false,
|
|
||||||
mobileRedirectUri: '',
|
|
||||||
prompt: '',
|
|
||||||
scope: 'openid email profile',
|
|
||||||
signingAlgorithm: 'RS256',
|
|
||||||
profileSigningAlgorithm: 'none',
|
|
||||||
storageLabelClaim: 'preferred_username',
|
|
||||||
storageQuotaClaim: 'immich_quota',
|
|
||||||
roleClaim: 'immich_role',
|
|
||||||
tokenEndpointAuthMethod: OAuthTokenEndpointAuthMethod.ClientSecretPost,
|
|
||||||
timeout: 30_000,
|
|
||||||
allowInsecureRequests: false,
|
|
||||||
},
|
|
||||||
passwordLogin: {
|
|
||||||
enabled: true,
|
|
||||||
},
|
|
||||||
storageTemplate: {
|
|
||||||
enabled: false,
|
|
||||||
hashVerificationEnabled: true,
|
|
||||||
template: '{{y}}/{{y}}-{{MM}}-{{dd}}/{{filename}}',
|
|
||||||
},
|
|
||||||
image: {
|
|
||||||
thumbnail: {
|
|
||||||
format: ImageFormat.Webp,
|
|
||||||
size: 250,
|
|
||||||
quality: 80,
|
|
||||||
progressive: false,
|
|
||||||
},
|
|
||||||
preview: {
|
|
||||||
format: ImageFormat.Jpeg,
|
|
||||||
size: 1440,
|
|
||||||
quality: 80,
|
|
||||||
progressive: false,
|
|
||||||
},
|
|
||||||
colorspace: Colorspace.P3,
|
|
||||||
extractEmbedded: false,
|
|
||||||
fullsize: {
|
|
||||||
enabled: false,
|
|
||||||
format: ImageFormat.Jpeg,
|
|
||||||
quality: 80,
|
|
||||||
progressive: false,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
newVersionCheck: {
|
|
||||||
enabled: true,
|
|
||||||
channel: ReleaseChannel.Stable,
|
|
||||||
},
|
|
||||||
nightlyTasks: {
|
|
||||||
startTime: '00:00',
|
|
||||||
databaseCleanup: true,
|
|
||||||
generateMemories: true,
|
|
||||||
syncQuotaUsage: true,
|
|
||||||
missingThumbnails: true,
|
|
||||||
clusterNewFaces: true,
|
|
||||||
},
|
|
||||||
trash: {
|
|
||||||
enabled: true,
|
|
||||||
days: 30,
|
|
||||||
},
|
|
||||||
theme: {
|
|
||||||
customCss: '',
|
|
||||||
},
|
|
||||||
library: {
|
|
||||||
scan: {
|
|
||||||
enabled: true,
|
|
||||||
cronExpression: CronExpression.EVERY_DAY_AT_MIDNIGHT,
|
|
||||||
},
|
|
||||||
watch: {
|
|
||||||
enabled: false,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
server: {
|
|
||||||
externalDomain: '',
|
|
||||||
loginPageMessage: '',
|
|
||||||
publicUsers: true,
|
|
||||||
},
|
|
||||||
notifications: {
|
|
||||||
smtp: {
|
|
||||||
enabled: false,
|
|
||||||
from: '',
|
|
||||||
replyTo: '',
|
|
||||||
transport: {
|
|
||||||
ignoreCert: false,
|
|
||||||
host: '',
|
|
||||||
port: 587,
|
|
||||||
secure: false,
|
|
||||||
username: '',
|
|
||||||
password: '',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
templates: {
|
|
||||||
email: {
|
|
||||||
welcomeTemplate: '',
|
|
||||||
albumInviteTemplate: '',
|
|
||||||
albumUpdateTemplate: '',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
user: {
|
|
||||||
deleteDelay: 7,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
@@ -150,6 +150,9 @@ export const endpointTags: Record<ApiTag, string> = {
|
|||||||
[ApiTag.Assets]: 'An asset is an image or video that has been uploaded to Immich.',
|
[ApiTag.Assets]: 'An asset is an image or video that has been uploaded to Immich.',
|
||||||
[ApiTag.Authentication]: 'Endpoints related to user authentication, including OAuth.',
|
[ApiTag.Authentication]: 'Endpoints related to user authentication, including OAuth.',
|
||||||
[ApiTag.AuthenticationAdmin]: 'Administrative endpoints related to authentication.',
|
[ApiTag.AuthenticationAdmin]: 'Administrative endpoints related to authentication.',
|
||||||
|
[ApiTag.ConfigUser]: 'The system configuration properties that are visible to logged in users.',
|
||||||
|
[ApiTag.ConfigAdmin]: 'Endpoints to view and modify the full system configuration.',
|
||||||
|
[ApiTag.ConfigPublic]: 'The system configuration properties that are visible to everyone.',
|
||||||
[ApiTag.DatabaseBackups]: 'Manage backups of the Immich database.',
|
[ApiTag.DatabaseBackups]: 'Manage backups of the Immich database.',
|
||||||
[ApiTag.Deprecated]: 'Deprecated endpoints that are planned for removal in the next major release.',
|
[ApiTag.Deprecated]: 'Deprecated endpoints that are planned for removal in the next major release.',
|
||||||
[ApiTag.Download]: 'Endpoints for downloading assets or collections of assets.',
|
[ApiTag.Download]: 'Endpoints for downloading assets or collections of assets.',
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { Body, Controller, Get, Put } from '@nestjs/common';
|
||||||
|
import { ApiTags } from '@nestjs/swagger';
|
||||||
|
import { Endpoint, HistoryBuilder } from 'src/decorators';
|
||||||
|
import { AdminConfigDto } from 'src/dtos/config.dto';
|
||||||
|
import { ApiTag, Permission } from 'src/enum';
|
||||||
|
import { Authenticated } from 'src/middleware/auth.guard';
|
||||||
|
import { SystemConfigService } from 'src/services/system-config.service';
|
||||||
|
|
||||||
|
@ApiTags(ApiTag.ConfigAdmin)
|
||||||
|
@Controller('admin/config')
|
||||||
|
export class ConfigAdminController {
|
||||||
|
constructor(private service: SystemConfigService) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
@Authenticated({ permission: Permission.AdminConfigRead, admin: true })
|
||||||
|
@Endpoint({
|
||||||
|
summary: 'Get the admin configuration',
|
||||||
|
description: 'Retrieve admin configuration.',
|
||||||
|
history: new HistoryBuilder().added('v3.2.0').alpha('v3.2.0'),
|
||||||
|
})
|
||||||
|
getAdminConfig(): Promise<AdminConfigDto> {
|
||||||
|
return this.service.getAdminConfig();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('defaults')
|
||||||
|
@Authenticated({ permission: Permission.AdminConfigRead, admin: true })
|
||||||
|
@Endpoint({
|
||||||
|
summary: 'Get the system configuration defaults',
|
||||||
|
description: 'Retrieve the default value of every system configuration property.',
|
||||||
|
history: new HistoryBuilder().added('v3.2.0').alpha('v3.2.0'),
|
||||||
|
})
|
||||||
|
getAdminConfigDefaults(): AdminConfigDto {
|
||||||
|
return this.service.getAdminConfigDefaults();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Put()
|
||||||
|
@Authenticated({ permission: Permission.AdminConfigUpdate, admin: true })
|
||||||
|
@Endpoint({
|
||||||
|
summary: 'Update the system configuration',
|
||||||
|
description: 'Update the system configuration with a new system configuration.',
|
||||||
|
history: new HistoryBuilder().added('v3.2.0').alpha('v3.2.0'),
|
||||||
|
})
|
||||||
|
updateAdminConfig(@Body() dto: AdminConfigDto): Promise<AdminConfigDto> {
|
||||||
|
return this.service.updateAdminConfig(dto);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { Controller, Get } from '@nestjs/common';
|
||||||
|
import { ApiTags } from '@nestjs/swagger';
|
||||||
|
import { Endpoint, HistoryBuilder } from 'src/decorators';
|
||||||
|
import { PublicConfigDto } from 'src/dtos/config.dto';
|
||||||
|
import { ApiTag } from 'src/enum';
|
||||||
|
import { Authenticated } from 'src/middleware/auth.guard';
|
||||||
|
import { SystemConfigService } from 'src/services/system-config.service';
|
||||||
|
|
||||||
|
@ApiTags(ApiTag.ConfigPublic)
|
||||||
|
@Controller('public/config')
|
||||||
|
export class ConfigPublicController {
|
||||||
|
constructor(private service: SystemConfigService) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
@Authenticated({ public: true })
|
||||||
|
@Endpoint({
|
||||||
|
summary: 'Get the public configuration',
|
||||||
|
description: 'Retrieve the system configuration properties that are visible to everyone.',
|
||||||
|
history: new HistoryBuilder().added('v3.2.0').alpha('v3.2.0'),
|
||||||
|
})
|
||||||
|
getPublicConfig(): Promise<PublicConfigDto> {
|
||||||
|
return this.service.getPublicConfig();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('defaults')
|
||||||
|
@Authenticated({ public: true })
|
||||||
|
@Endpoint({
|
||||||
|
summary: 'Get the public configuration defaults',
|
||||||
|
description: 'Retrieve the default value of the configuration properties that are visible to everyone.',
|
||||||
|
history: new HistoryBuilder().added('v3.2.0').alpha('v3.2.0'),
|
||||||
|
})
|
||||||
|
getPublicConfigDefaults(): PublicConfigDto {
|
||||||
|
return this.service.getPublicConfigDefaults();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { Controller, Get } from '@nestjs/common';
|
||||||
|
import { ApiTags } from '@nestjs/swagger';
|
||||||
|
import { Endpoint, HistoryBuilder } from 'src/decorators';
|
||||||
|
import { UserConfigDto } from 'src/dtos/config.dto';
|
||||||
|
import { ApiTag, Permission } from 'src/enum';
|
||||||
|
import { Authenticated } from 'src/middleware/auth.guard';
|
||||||
|
import { SystemConfigService } from 'src/services/system-config.service';
|
||||||
|
|
||||||
|
@ApiTags(ApiTag.ConfigUser)
|
||||||
|
@Controller('config')
|
||||||
|
export class ConfigUserController {
|
||||||
|
constructor(private service: SystemConfigService) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
@Authenticated({ permission: Permission.UserConfigRead })
|
||||||
|
@Endpoint({
|
||||||
|
summary: 'Get the configuration with user visibility',
|
||||||
|
description: 'Retrieve the system configuration properties that are visible to logged in users.',
|
||||||
|
history: new HistoryBuilder().added('v3.2.0').alpha('v3.2.0'),
|
||||||
|
})
|
||||||
|
getUserConfig(): Promise<UserConfigDto> {
|
||||||
|
return this.service.getUserConfig();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('defaults')
|
||||||
|
@Authenticated({ permission: Permission.UserConfigRead })
|
||||||
|
@Endpoint({
|
||||||
|
summary: 'Get the default configuration with user visibility',
|
||||||
|
description: 'Retrieve the default value of the configuration properties that are visible to logged in users.',
|
||||||
|
history: new HistoryBuilder().added('v3.2.0').alpha('v3.2.0'),
|
||||||
|
})
|
||||||
|
getUserConfigDefaults(): UserConfigDto {
|
||||||
|
return this.service.getUserConfigDefaults();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
import _ from 'lodash';
|
||||||
|
import { ConfigAdminController } from 'src/controllers/config-admin.controller';
|
||||||
|
import { ConfigPublicController } from 'src/controllers/config-public.controller';
|
||||||
|
import { ConfigUserController } from 'src/controllers/config-user.controller';
|
||||||
|
import { defaults, mapPublicConfig, mapUserConfig } from 'src/dtos/config.dto';
|
||||||
|
import { SystemConfigService } from 'src/services/system-config.service';
|
||||||
|
import request from 'supertest';
|
||||||
|
import { errorDto } from 'test/medium/responses';
|
||||||
|
import { ControllerContext, controllerSetup, mockBaseService } from 'test/utils';
|
||||||
|
|
||||||
|
/** Returns a full config that passes Zod validation (required URLs and min lengths). */
|
||||||
|
function validConfig() {
|
||||||
|
const config = _.cloneDeep(defaults) as typeof defaults & {
|
||||||
|
oauth: { mobileRedirectUri: string };
|
||||||
|
notifications: { smtp: { from: string; transport: { host: string } } };
|
||||||
|
server: { externalDomain: string };
|
||||||
|
};
|
||||||
|
config.oauth.mobileRedirectUri ||= 'https://example.com';
|
||||||
|
config.server.externalDomain ||= 'https://example.com';
|
||||||
|
config.notifications.smtp.from ||= 'noreply@example.com';
|
||||||
|
config.notifications.smtp.transport.host ||= 'localhost';
|
||||||
|
return config;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('config controllers', () => {
|
||||||
|
let ctx: ControllerContext;
|
||||||
|
const service = mockBaseService(SystemConfigService);
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
ctx = await controllerSetup(
|
||||||
|
[ConfigAdminController, ConfigUserController, ConfigPublicController],
|
||||||
|
[{ provide: SystemConfigService, useValue: service }],
|
||||||
|
);
|
||||||
|
return () => ctx.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
service.resetAllMocks();
|
||||||
|
ctx.reset();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('GET /admin/config', () => {
|
||||||
|
it('should return the full config', async () => {
|
||||||
|
service.getAdminConfig.mockResolvedValue(validConfig());
|
||||||
|
|
||||||
|
const { status, body } = await request(ctx.getHttpServer()).get('/admin/config');
|
||||||
|
|
||||||
|
expect(status).toBe(200);
|
||||||
|
expect(body.oauth.clientSecret).toBeDefined();
|
||||||
|
expect(body.job.thumbnailGeneration).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('PUT /admin/config', () => {
|
||||||
|
it('should accept a valid config', async () => {
|
||||||
|
service.updateAdminConfig.mockImplementation((dto) => Promise.resolve(dto));
|
||||||
|
|
||||||
|
const { status } = await request(ctx.getHttpServer()).put('/admin/config').send(validConfig());
|
||||||
|
|
||||||
|
expect(status).toBe(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reject an invalid config', async () => {
|
||||||
|
const config = validConfig();
|
||||||
|
config.nightlyTasks.startTime = 'invalid';
|
||||||
|
|
||||||
|
const { status, body } = await request(ctx.getHttpServer()).put('/admin/config').send(config);
|
||||||
|
|
||||||
|
expect(status).toBe(400);
|
||||||
|
expect(body).toEqual(
|
||||||
|
errorDto.validationError([
|
||||||
|
{
|
||||||
|
path: ['nightlyTasks', 'startTime'],
|
||||||
|
message: 'Invalid input: expected string in HH:MM format, received string',
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
expect(service.updateAdminConfig).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('GET /config', () => {
|
||||||
|
it('should return the properties visible to logged in users', async () => {
|
||||||
|
service.getUserConfig.mockResolvedValue(mapUserConfig(validConfig()));
|
||||||
|
|
||||||
|
const { status, body } = await request(ctx.getHttpServer()).get('/config');
|
||||||
|
|
||||||
|
expect(status).toBe(200);
|
||||||
|
expect(body.image).toEqual({
|
||||||
|
thumbnail: { size: defaults.image.thumbnail.size },
|
||||||
|
preview: { size: defaults.image.preview.size },
|
||||||
|
fullsize: { enabled: defaults.image.fullsize.enabled },
|
||||||
|
});
|
||||||
|
expect(body.oauth.clientSecret).toBeUndefined();
|
||||||
|
expect(body.job).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('GET /public/config', () => {
|
||||||
|
it('should return the properties visible to everyone', async () => {
|
||||||
|
service.getPublicConfig.mockResolvedValue(mapPublicConfig(validConfig()));
|
||||||
|
|
||||||
|
const { status, body } = await request(ctx.getHttpServer()).get('/public/config');
|
||||||
|
|
||||||
|
expect(status).toBe(200);
|
||||||
|
expect(body.server).toEqual({ loginPageMessage: defaults.server.loginPageMessage });
|
||||||
|
expect(body.oauth).toEqual({
|
||||||
|
autoLaunch: defaults.oauth.autoLaunch,
|
||||||
|
buttonText: defaults.oauth.buttonText,
|
||||||
|
enabled: defaults.oauth.enabled,
|
||||||
|
});
|
||||||
|
expect(body.image).toBeUndefined();
|
||||||
|
expect(body.trash).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -6,6 +6,9 @@ import { AssetMediaController } from 'src/controllers/asset-media.controller';
|
|||||||
import { AssetController } from 'src/controllers/asset.controller';
|
import { AssetController } from 'src/controllers/asset.controller';
|
||||||
import { AuthAdminController } from 'src/controllers/auth-admin.controller';
|
import { AuthAdminController } from 'src/controllers/auth-admin.controller';
|
||||||
import { AuthController } from 'src/controllers/auth.controller';
|
import { AuthController } from 'src/controllers/auth.controller';
|
||||||
|
import { ConfigAdminController } from 'src/controllers/config-admin.controller';
|
||||||
|
import { ConfigPublicController } from 'src/controllers/config-public.controller';
|
||||||
|
import { ConfigUserController } from 'src/controllers/config-user.controller';
|
||||||
import { DatabaseBackupController } from 'src/controllers/database-backup.controller';
|
import { DatabaseBackupController } from 'src/controllers/database-backup.controller';
|
||||||
import { DownloadController } from 'src/controllers/download.controller';
|
import { DownloadController } from 'src/controllers/download.controller';
|
||||||
import { DuplicateController } from 'src/controllers/duplicate.controller';
|
import { DuplicateController } from 'src/controllers/duplicate.controller';
|
||||||
@@ -49,6 +52,9 @@ export const controllers = [
|
|||||||
AssetMediaController,
|
AssetMediaController,
|
||||||
AuthController,
|
AuthController,
|
||||||
AuthAdminController,
|
AuthAdminController,
|
||||||
|
ConfigUserController,
|
||||||
|
ConfigAdminController,
|
||||||
|
ConfigPublicController,
|
||||||
DatabaseBackupController,
|
DatabaseBackupController,
|
||||||
DownloadController,
|
DownloadController,
|
||||||
DuplicateController,
|
DuplicateController,
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { Body, Controller, HttpCode, HttpStatus, Param, Post } from '@nestjs/com
|
|||||||
import { ApiTags } from '@nestjs/swagger';
|
import { ApiTags } from '@nestjs/swagger';
|
||||||
import { Endpoint, HistoryBuilder } from 'src/decorators';
|
import { Endpoint, HistoryBuilder } from 'src/decorators';
|
||||||
import { AuthDto } from 'src/dtos/auth.dto';
|
import { AuthDto } from 'src/dtos/auth.dto';
|
||||||
|
import { SystemConfigSmtpDto } from 'src/dtos/config.dto';
|
||||||
import {
|
import {
|
||||||
NotificationCreateDto,
|
NotificationCreateDto,
|
||||||
NotificationDto,
|
NotificationDto,
|
||||||
@@ -9,7 +10,6 @@ import {
|
|||||||
TemplateResponseDto,
|
TemplateResponseDto,
|
||||||
TestEmailResponseDto,
|
TestEmailResponseDto,
|
||||||
} from 'src/dtos/notification.dto';
|
} from 'src/dtos/notification.dto';
|
||||||
import { SystemConfigSmtpDto } from 'src/dtos/system-config.dto';
|
|
||||||
import { ApiTag } from 'src/enum';
|
import { ApiTag } from 'src/enum';
|
||||||
import { Auth, Authenticated } from 'src/middleware/auth.guard';
|
import { Auth, Authenticated } from 'src/middleware/auth.guard';
|
||||||
import { EmailTemplate } from 'src/repositories/email.repository';
|
import { EmailTemplate } from 'src/repositories/email.repository';
|
||||||
|
|||||||
@@ -101,7 +101,11 @@ export class ServerController {
|
|||||||
@Endpoint({
|
@Endpoint({
|
||||||
summary: 'Get features',
|
summary: 'Get features',
|
||||||
description: 'Retrieve available features supported by this server.',
|
description: 'Retrieve available features supported by this server.',
|
||||||
history: new HistoryBuilder().added('v1').beta('v1').stable('v2'),
|
history: new HistoryBuilder()
|
||||||
|
.added('v1')
|
||||||
|
.beta('v1')
|
||||||
|
.stable('v2')
|
||||||
|
.deprecated('v3.2.0', { replacementId: 'getPublicConfig' }),
|
||||||
})
|
})
|
||||||
getServerFeatures(): Promise<ServerFeaturesDto> {
|
getServerFeatures(): Promise<ServerFeaturesDto> {
|
||||||
return this.service.getFeatures();
|
return this.service.getFeatures();
|
||||||
@@ -112,7 +116,11 @@ export class ServerController {
|
|||||||
@Endpoint({
|
@Endpoint({
|
||||||
summary: 'Get config',
|
summary: 'Get config',
|
||||||
description: 'Retrieve the current server configuration.',
|
description: 'Retrieve the current server configuration.',
|
||||||
history: new HistoryBuilder().added('v1').beta('v1').stable('v2'),
|
history: new HistoryBuilder()
|
||||||
|
.added('v1')
|
||||||
|
.beta('v1')
|
||||||
|
.stable('v2')
|
||||||
|
.deprecated('v3.2.0', { replacementId: 'getPublicConfig' }),
|
||||||
})
|
})
|
||||||
getServerConfig(): Promise<ServerConfigDto> {
|
getServerConfig(): Promise<ServerConfigDto> {
|
||||||
return this.service.getSystemConfig();
|
return this.service.getSystemConfig();
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import _ from 'lodash';
|
import _ from 'lodash';
|
||||||
import { defaults } from 'src/config';
|
|
||||||
import { SystemConfigController } from 'src/controllers/system-config.controller';
|
import { SystemConfigController } from 'src/controllers/system-config.controller';
|
||||||
|
import { defaults } from 'src/dtos/config.dto';
|
||||||
import { StorageTemplateService } from 'src/services/storage-template.service';
|
import { StorageTemplateService } from 'src/services/storage-template.service';
|
||||||
import { SystemConfigService } from 'src/services/system-config.service';
|
import { SystemConfigService } from 'src/services/system-config.service';
|
||||||
import request from 'supertest';
|
import request from 'supertest';
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Body, Controller, Get, Put } from '@nestjs/common';
|
import { Body, Controller, Get, Put } from '@nestjs/common';
|
||||||
import { ApiTags } from '@nestjs/swagger';
|
import { ApiTags } from '@nestjs/swagger';
|
||||||
import { Endpoint, HistoryBuilder } from 'src/decorators';
|
import { Endpoint, HistoryBuilder } from 'src/decorators';
|
||||||
import { SystemConfigDto, SystemConfigTemplateStorageOptionDto } from 'src/dtos/system-config.dto';
|
import { AdminConfigDto, ConfigTemplateStorageOptionDto } from 'src/dtos/config.dto';
|
||||||
import { ApiTag, Permission } from 'src/enum';
|
import { ApiTag, Permission } from 'src/enum';
|
||||||
import { Authenticated } from 'src/middleware/auth.guard';
|
import { Authenticated } from 'src/middleware/auth.guard';
|
||||||
import { StorageTemplateService } from 'src/services/storage-template.service';
|
import { StorageTemplateService } from 'src/services/storage-template.service';
|
||||||
@@ -20,10 +20,14 @@ export class SystemConfigController {
|
|||||||
@Endpoint({
|
@Endpoint({
|
||||||
summary: 'Get system configuration',
|
summary: 'Get system configuration',
|
||||||
description: 'Retrieve the current system configuration.',
|
description: 'Retrieve the current system configuration.',
|
||||||
history: new HistoryBuilder().added('v1').beta('v1').stable('v2'),
|
history: new HistoryBuilder()
|
||||||
|
.added('v1')
|
||||||
|
.beta('v1')
|
||||||
|
.stable('v2')
|
||||||
|
.deprecated('v3.2.0', { replacementId: 'getAdminConfig' }),
|
||||||
})
|
})
|
||||||
getConfig(): Promise<SystemConfigDto> {
|
getConfig(): Promise<AdminConfigDto> {
|
||||||
return this.service.getSystemConfig();
|
return this.service.getAdminConfig();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('defaults')
|
@Get('defaults')
|
||||||
@@ -31,10 +35,14 @@ export class SystemConfigController {
|
|||||||
@Endpoint({
|
@Endpoint({
|
||||||
summary: 'Get system configuration defaults',
|
summary: 'Get system configuration defaults',
|
||||||
description: 'Retrieve the default values for the system configuration.',
|
description: 'Retrieve the default values for the system configuration.',
|
||||||
history: new HistoryBuilder().added('v1').beta('v1').stable('v2'),
|
history: new HistoryBuilder()
|
||||||
|
.added('v1')
|
||||||
|
.beta('v1')
|
||||||
|
.stable('v2')
|
||||||
|
.deprecated('v3.2.0', { replacementId: 'getAdminConfigDefaults' }),
|
||||||
})
|
})
|
||||||
getConfigDefaults(): SystemConfigDto {
|
getConfigDefaults(): AdminConfigDto {
|
||||||
return this.service.getDefaults();
|
return this.service.getAdminConfigDefaults();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Put()
|
@Put()
|
||||||
@@ -42,10 +50,14 @@ export class SystemConfigController {
|
|||||||
@Endpoint({
|
@Endpoint({
|
||||||
summary: 'Update system configuration',
|
summary: 'Update system configuration',
|
||||||
description: 'Update the system configuration with a new system configuration.',
|
description: 'Update the system configuration with a new system configuration.',
|
||||||
history: new HistoryBuilder().added('v1').beta('v1').stable('v2'),
|
history: new HistoryBuilder()
|
||||||
|
.added('v1')
|
||||||
|
.beta('v1')
|
||||||
|
.stable('v2')
|
||||||
|
.deprecated('v3.2.0', { replacementId: 'updateAdminConfig' }),
|
||||||
})
|
})
|
||||||
updateConfig(@Body() dto: SystemConfigDto): Promise<SystemConfigDto> {
|
updateConfig(@Body() dto: AdminConfigDto): Promise<AdminConfigDto> {
|
||||||
return this.service.updateSystemConfig(dto);
|
return this.service.updateAdminConfig(dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('storage-template-options')
|
@Get('storage-template-options')
|
||||||
@@ -55,7 +67,7 @@ export class SystemConfigController {
|
|||||||
description: 'Retrieve exemplary storage template options.',
|
description: 'Retrieve exemplary storage template options.',
|
||||||
history: new HistoryBuilder().added('v1').beta('v1').stable('v2'),
|
history: new HistoryBuilder().added('v1').beta('v1').stable('v2'),
|
||||||
})
|
})
|
||||||
getStorageTemplateOptions(): SystemConfigTemplateStorageOptionDto {
|
getStorageTemplateOptions(): ConfigTemplateStorageOptionDto {
|
||||||
return this.storageTemplateService.getStorageTemplateOptions();
|
return this.storageTemplateService.getStorageTemplateOptions();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import {
|
||||||
|
AdminConfigDto,
|
||||||
|
defaults,
|
||||||
|
mapPublicConfig,
|
||||||
|
mapUserConfig,
|
||||||
|
PublicConfigDto,
|
||||||
|
UserConfigDto,
|
||||||
|
} from 'src/dtos/config.dto';
|
||||||
|
import { getKeysDeep } from 'src/utils/misc';
|
||||||
|
import z from 'zod';
|
||||||
|
|
||||||
|
const PUBLIC_PROPERTIES = [
|
||||||
|
'oauth.autoLaunch',
|
||||||
|
'oauth.buttonText',
|
||||||
|
'oauth.enabled',
|
||||||
|
'passwordLogin.enabled',
|
||||||
|
'server.loginPageMessage',
|
||||||
|
'theme.customCss',
|
||||||
|
];
|
||||||
|
|
||||||
|
describe('config visibility', () => {
|
||||||
|
it('should expose every property to admins', () => {
|
||||||
|
const paths = getKeysDeep(defaults);
|
||||||
|
|
||||||
|
expect(paths).toEqual(expect.arrayContaining(PUBLIC_PROPERTIES));
|
||||||
|
expect(paths).toContain('oauth.clientSecret');
|
||||||
|
expect(paths.length).toBeGreaterThan(100);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should expose the public properties to everyone', () => {
|
||||||
|
expect(getKeysDeep(mapPublicConfig(defaults)).sort()).toEqual(PUBLIC_PROPERTIES);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should expose everything public to logged in users as well', () => {
|
||||||
|
expect(getKeysDeep(mapUserConfig(defaults))).toEqual(expect.arrayContaining(PUBLIC_PROPERTIES));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should accept the defaults with the admin schema', () => {
|
||||||
|
expect(AdminConfigDto.schema.safeParse(defaults)).toEqual(expect.objectContaining({ success: true }));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should map the defaults onto the user and public schemas', () => {
|
||||||
|
expect(UserConfigDto.schema.safeParse(mapUserConfig(defaults))).toEqual(expect.objectContaining({ success: true }));
|
||||||
|
expect(PublicConfigDto.schema.safeParse(mapPublicConfig(defaults))).toEqual(
|
||||||
|
expect.objectContaining({ success: true }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not leak admin properties into the public config', () => {
|
||||||
|
const config = mapPublicConfig(defaults) as Record<string, any>;
|
||||||
|
|
||||||
|
expect(config.oauth).toEqual({
|
||||||
|
autoLaunch: defaults.oauth.autoLaunch,
|
||||||
|
buttonText: defaults.oauth.buttonText,
|
||||||
|
enabled: defaults.oauth.enabled,
|
||||||
|
});
|
||||||
|
expect(config.job).toBeUndefined();
|
||||||
|
expect(config.image).toBeUndefined();
|
||||||
|
expect(config.notifications).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should keep the visibility metadata out of the schemas', () => {
|
||||||
|
for (const schema of [AdminConfigDto.schema, UserConfigDto.schema, PublicConfigDto.schema]) {
|
||||||
|
const json = JSON.stringify(z.toJSONSchema(schema, { io: 'input', unrepresentable: 'any' }));
|
||||||
|
expect(json).not.toContain('visibility');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,773 @@
|
|||||||
|
import { CronExpression } from '@nestjs/schedule';
|
||||||
|
import { validateCronExpression } from 'cron';
|
||||||
|
import { createZodDto } from 'nestjs-zod';
|
||||||
|
import {
|
||||||
|
AudioCodec,
|
||||||
|
AudioCodecSchema,
|
||||||
|
Colorspace,
|
||||||
|
ColorspaceSchema,
|
||||||
|
ConfigVisibility,
|
||||||
|
CQMode,
|
||||||
|
CQModeSchema,
|
||||||
|
HlsVideoResolution,
|
||||||
|
HlsVideoResolutionSchema,
|
||||||
|
ImageFormat,
|
||||||
|
ImageFormatSchema,
|
||||||
|
LogLevel,
|
||||||
|
LogLevelSchema,
|
||||||
|
OAuthTokenEndpointAuthMethod,
|
||||||
|
OAuthTokenEndpointAuthMethodSchema,
|
||||||
|
ReleaseChannel,
|
||||||
|
ReleaseChannelSchema,
|
||||||
|
ToneMapping,
|
||||||
|
ToneMappingSchema,
|
||||||
|
TranscodeHardwareAcceleration,
|
||||||
|
TranscodeHardwareAccelerationSchema,
|
||||||
|
TranscodePolicy,
|
||||||
|
TranscodePolicySchema,
|
||||||
|
VideoCodec,
|
||||||
|
VideoCodecSchema,
|
||||||
|
VideoContainer,
|
||||||
|
VideoContainerSchema,
|
||||||
|
} from 'src/enum';
|
||||||
|
import { DeepPartial } from 'src/types';
|
||||||
|
import z from 'zod';
|
||||||
|
|
||||||
|
const { Admin, User, Public } = ConfigVisibility;
|
||||||
|
|
||||||
|
const configBool = z
|
||||||
|
.preprocess((val) => {
|
||||||
|
if (val === 'true') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (val === 'false') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return val;
|
||||||
|
}, z.boolean())
|
||||||
|
.meta({ type: 'boolean' });
|
||||||
|
|
||||||
|
const cronExpressionSchema = z
|
||||||
|
.string()
|
||||||
|
.superRefine((value, ctx) => {
|
||||||
|
const validated = validateCronExpression(value);
|
||||||
|
if (!validated.valid) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: 'custom',
|
||||||
|
message: `Invalid cron expression. ${validated.error?.message ?? ''}`,
|
||||||
|
input: value,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.describe('Cron expression');
|
||||||
|
|
||||||
|
const emptyOrUrl = (error: string) =>
|
||||||
|
z.string().refine((url) => url.length === 0 || z.url().safeParse(url).success, { error });
|
||||||
|
|
||||||
|
const AdminConfigIntegrityJobSchema = z
|
||||||
|
.object({
|
||||||
|
enabled: z.boolean().describe('Enabled'),
|
||||||
|
cronExpression: cronExpressionSchema.describe('Cron expression for when the integrity check should run'),
|
||||||
|
})
|
||||||
|
.describe('Integrity job config')
|
||||||
|
.meta({ id: 'AdminConfigIntegrityJobDto' });
|
||||||
|
|
||||||
|
const AdminConfigJobSettingsSchema = z
|
||||||
|
.object({ concurrency: z.int().min(1).describe('Concurrency') })
|
||||||
|
.meta({ id: 'AdminConfigJobSettingsDto' });
|
||||||
|
|
||||||
|
const AdminConfigMachineLearningTaskSchema = z.object({
|
||||||
|
enabled: z.boolean().describe('Whether the task is enabled').meta({ visibility: User }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const AdminConfigMachineLearningModelSchema = AdminConfigMachineLearningTaskSchema.extend({
|
||||||
|
modelName: z.string().describe('Name of the model to use'),
|
||||||
|
});
|
||||||
|
|
||||||
|
const AdminConfigGeneratedImageSchema = z
|
||||||
|
.object({
|
||||||
|
format: ImageFormatSchema,
|
||||||
|
quality: z.int().min(1).max(100).describe('Quality'),
|
||||||
|
size: z.int().min(1).describe('Size').meta({ visibility: User }),
|
||||||
|
progressive: configBool.default(false).optional().describe('Progressive'),
|
||||||
|
})
|
||||||
|
.meta({ id: 'AdminConfigGeneratedImageDto' });
|
||||||
|
|
||||||
|
const AdminConfigFFmpegSchema = z
|
||||||
|
.object({
|
||||||
|
crf: z.coerce.number().int().min(0).max(51).describe('CRF'),
|
||||||
|
threads: z.coerce.number().int().min(0).describe('Threads'),
|
||||||
|
preset: z.string().describe('Preset'),
|
||||||
|
targetVideoCodec: VideoCodecSchema,
|
||||||
|
acceptedVideoCodecs: z.array(VideoCodecSchema).describe('Accepted video codecs'),
|
||||||
|
targetAudioCodec: AudioCodecSchema,
|
||||||
|
acceptedAudioCodecs: z.array(AudioCodecSchema).describe('Accepted audio codecs'),
|
||||||
|
acceptedContainers: z.array(VideoContainerSchema).describe('Accepted containers'),
|
||||||
|
targetResolution: z.string().describe('Target resolution'),
|
||||||
|
maxBitrate: z.string().describe('Max bitrate'),
|
||||||
|
bframes: z.coerce.number().int().min(-1).max(16).describe('B-frames'),
|
||||||
|
refs: z.coerce.number().int().min(0).max(6).describe('References'),
|
||||||
|
gopSize: z.coerce.number().int().min(0).describe('GOP size'),
|
||||||
|
temporalAQ: configBool.describe('Temporal AQ'),
|
||||||
|
cqMode: CQModeSchema,
|
||||||
|
twoPass: configBool.describe('Two pass'),
|
||||||
|
preferredHwDevice: z.string().describe('Preferred hardware device'),
|
||||||
|
transcode: TranscodePolicySchema,
|
||||||
|
accel: TranscodeHardwareAccelerationSchema,
|
||||||
|
accelDecode: configBool.describe('Accelerated decode'),
|
||||||
|
tonemap: ToneMappingSchema,
|
||||||
|
realtime: z
|
||||||
|
.object({
|
||||||
|
enabled: configBool.describe('Enable real-time HLS transcoding (alpha)').meta({ visibility: User }),
|
||||||
|
videoCodecs: z
|
||||||
|
.array(VideoCodecSchema)
|
||||||
|
.describe('Video codecs to use for real-time HLS transcoding')
|
||||||
|
.meta({ visibility: User }),
|
||||||
|
resolutions: z
|
||||||
|
.array(HlsVideoResolutionSchema)
|
||||||
|
.describe('Resolutions to use for real-time HLS transcoding')
|
||||||
|
.meta({ visibility: User }),
|
||||||
|
})
|
||||||
|
.meta({ id: 'AdminConfigFFmpegRealtimeDto' }),
|
||||||
|
})
|
||||||
|
.meta({ id: 'AdminConfigFFmpegDto' });
|
||||||
|
|
||||||
|
const AdminConfigSmtpSchema = z
|
||||||
|
.object({
|
||||||
|
enabled: configBool.describe('Whether SMTP email notifications are enabled'),
|
||||||
|
from: z.string().describe('Email address to send from'),
|
||||||
|
replyTo: z.string().describe('Email address for replies'),
|
||||||
|
transport: z
|
||||||
|
.object({
|
||||||
|
ignoreCert: configBool.describe('Whether to ignore SSL certificate errors'),
|
||||||
|
host: z.string().describe('SMTP server hostname'),
|
||||||
|
port: z.int().min(0).max(65_535).describe('SMTP server port'),
|
||||||
|
secure: configBool.describe('Whether to use secure connection (TLS/SSL)'),
|
||||||
|
username: z.string().describe('SMTP username'),
|
||||||
|
password: z.string().describe('SMTP password'),
|
||||||
|
})
|
||||||
|
.meta({ id: 'AdminConfigSmtpTransportDto' }),
|
||||||
|
})
|
||||||
|
.meta({ id: 'AdminConfigSmtpDto' });
|
||||||
|
|
||||||
|
const AdminConfigSchemaWithVisibility = z
|
||||||
|
.object({
|
||||||
|
backup: z
|
||||||
|
.object({
|
||||||
|
database: z
|
||||||
|
.object({
|
||||||
|
enabled: configBool.describe('Enabled'),
|
||||||
|
cronExpression: cronExpressionSchema,
|
||||||
|
keepLastAmount: z.int().min(1).describe('Keep last amount'),
|
||||||
|
})
|
||||||
|
.meta({ id: 'AdminConfigDatabaseBackupDto' }),
|
||||||
|
})
|
||||||
|
.meta({ id: 'AdminConfigBackupsDto' }),
|
||||||
|
ffmpeg: AdminConfigFFmpegSchema,
|
||||||
|
integrityChecks: z
|
||||||
|
.object({
|
||||||
|
missingFiles: AdminConfigIntegrityJobSchema,
|
||||||
|
untrackedFiles: AdminConfigIntegrityJobSchema,
|
||||||
|
checksumFiles: AdminConfigIntegrityJobSchema.extend({
|
||||||
|
timeLimit: z.int().nonnegative().describe('How long the integrity checksum job may run for'),
|
||||||
|
percentageLimit: z
|
||||||
|
.float32()
|
||||||
|
.nonnegative()
|
||||||
|
.max(1)
|
||||||
|
.describe('Percentage limit of the integrity checksum job')
|
||||||
|
.meta({ format: 'double' }),
|
||||||
|
})
|
||||||
|
.describe('Integrity checksum job config')
|
||||||
|
.meta({ id: 'AdminConfigIntegrityChecksumJobDto' }),
|
||||||
|
})
|
||||||
|
.describe('Integrity checks config')
|
||||||
|
.meta({ id: 'AdminConfigIntegrityChecksDto' }),
|
||||||
|
job: z
|
||||||
|
.object({
|
||||||
|
thumbnailGeneration: AdminConfigJobSettingsSchema,
|
||||||
|
metadataExtraction: AdminConfigJobSettingsSchema,
|
||||||
|
videoConversion: AdminConfigJobSettingsSchema,
|
||||||
|
faceDetection: AdminConfigJobSettingsSchema,
|
||||||
|
smartSearch: AdminConfigJobSettingsSchema,
|
||||||
|
backgroundTask: AdminConfigJobSettingsSchema,
|
||||||
|
migration: AdminConfigJobSettingsSchema,
|
||||||
|
search: AdminConfigJobSettingsSchema,
|
||||||
|
sidecar: AdminConfigJobSettingsSchema,
|
||||||
|
library: AdminConfigJobSettingsSchema,
|
||||||
|
notifications: AdminConfigJobSettingsSchema,
|
||||||
|
ocr: AdminConfigJobSettingsSchema,
|
||||||
|
workflow: AdminConfigJobSettingsSchema,
|
||||||
|
editor: AdminConfigJobSettingsSchema,
|
||||||
|
integrityCheck: AdminConfigJobSettingsSchema,
|
||||||
|
})
|
||||||
|
.meta({ id: 'AdminConfigJobDto' }),
|
||||||
|
logging: z
|
||||||
|
.object({
|
||||||
|
enabled: configBool.describe('Enabled'),
|
||||||
|
level: LogLevelSchema,
|
||||||
|
})
|
||||||
|
.meta({ id: 'AdminConfigLoggingDto' }),
|
||||||
|
machineLearning: z
|
||||||
|
.object({
|
||||||
|
enabled: configBool.describe('Enabled').meta({ visibility: User }),
|
||||||
|
urls: z.array(z.string()).min(1).describe('ML service URLs'),
|
||||||
|
availabilityChecks: z
|
||||||
|
.object({
|
||||||
|
enabled: configBool.describe('Enabled'),
|
||||||
|
timeout: z.int(),
|
||||||
|
interval: z.int(),
|
||||||
|
})
|
||||||
|
.meta({ id: 'AdminConfigMachineLearningAvailabilityChecksDto' }),
|
||||||
|
clip: AdminConfigMachineLearningModelSchema.meta({ id: 'AdminConfigClipDto' }),
|
||||||
|
duplicateDetection: AdminConfigMachineLearningTaskSchema.extend({
|
||||||
|
maxDistance: z
|
||||||
|
.number()
|
||||||
|
.min(0.001)
|
||||||
|
.max(0.1)
|
||||||
|
.describe('Maximum distance threshold for duplicate detection')
|
||||||
|
.meta({ format: 'double' }),
|
||||||
|
}).meta({ id: 'AdminConfigDuplicateDetectionDto' }),
|
||||||
|
facialRecognition: AdminConfigMachineLearningModelSchema.extend({
|
||||||
|
minScore: z
|
||||||
|
.number()
|
||||||
|
.min(0.1)
|
||||||
|
.max(1)
|
||||||
|
.describe('Minimum confidence score for face detection')
|
||||||
|
.meta({ format: 'double' }),
|
||||||
|
maxDistance: z
|
||||||
|
.number()
|
||||||
|
.min(0.1)
|
||||||
|
.max(2)
|
||||||
|
.describe('Maximum distance threshold for face recognition')
|
||||||
|
.meta({ format: 'double' }),
|
||||||
|
minFaces: z
|
||||||
|
.int()
|
||||||
|
.min(1)
|
||||||
|
.describe('Minimum number of faces required for recognition')
|
||||||
|
.meta({ visibility: User }),
|
||||||
|
}).meta({ id: 'AdminConfigFacialRecognitionDto' }),
|
||||||
|
ocr: AdminConfigMachineLearningModelSchema.extend({
|
||||||
|
maxResolution: z.int().min(1).describe('Maximum resolution for OCR processing'),
|
||||||
|
minDetectionScore: z
|
||||||
|
.number()
|
||||||
|
.min(0.1)
|
||||||
|
.max(1)
|
||||||
|
.describe('Minimum confidence score for text detection')
|
||||||
|
.meta({ format: 'double' }),
|
||||||
|
minRecognitionScore: z
|
||||||
|
.number()
|
||||||
|
.min(0.1)
|
||||||
|
.max(1)
|
||||||
|
.describe('Minimum confidence score for text recognition')
|
||||||
|
.meta({ format: 'double' }),
|
||||||
|
}).meta({ id: 'AdminConfigOcrDto' }),
|
||||||
|
})
|
||||||
|
.meta({ id: 'AdminConfigMachineLearningDto' }),
|
||||||
|
map: z
|
||||||
|
.object({
|
||||||
|
enabled: configBool.describe('Enabled').meta({ visibility: User }),
|
||||||
|
lightStyle: z.url().describe('Light map style URL').meta({ visibility: User }),
|
||||||
|
darkStyle: z.url().describe('Dark map style URL').meta({ visibility: User }),
|
||||||
|
})
|
||||||
|
.meta({ id: 'AdminConfigMapDto' }),
|
||||||
|
reverseGeocoding: z
|
||||||
|
.object({ enabled: configBool.describe('Enabled').meta({ visibility: User }) })
|
||||||
|
.meta({ id: 'AdminConfigReverseGeocodingDto' }),
|
||||||
|
metadata: z
|
||||||
|
.object({
|
||||||
|
faces: z.object({ import: configBool.describe('Import') }).meta({ id: 'AdminConfigFacesDto' }),
|
||||||
|
})
|
||||||
|
.meta({ id: 'AdminConfigMetadataDto' }),
|
||||||
|
oauth: z
|
||||||
|
.object({
|
||||||
|
autoLaunch: configBool.describe('Auto launch').meta({ visibility: Public }),
|
||||||
|
autoRegister: configBool.describe('Auto register'),
|
||||||
|
buttonText: z.string().describe('Button text').meta({ visibility: Public }),
|
||||||
|
clientId: z.string().describe('Client ID'),
|
||||||
|
clientSecret: z.string().describe('Client secret'),
|
||||||
|
tokenEndpointAuthMethod: OAuthTokenEndpointAuthMethodSchema,
|
||||||
|
timeout: z.int().min(1).describe('Timeout'),
|
||||||
|
allowInsecureRequests: configBool.describe('Allow insecure requests'),
|
||||||
|
defaultStorageQuota: z.int().min(0).nullable().describe('Default storage quota'),
|
||||||
|
enabled: configBool.describe('Enabled').meta({ visibility: Public }),
|
||||||
|
issuerUrl: emptyOrUrl('Issuer URL must be an empty string or a valid URL').describe('Issuer URL'),
|
||||||
|
scope: z.string().describe('Scope'),
|
||||||
|
prompt: z.string().describe('OAuth prompt parameter (e.g. select_account, login, consent)'),
|
||||||
|
endSessionEndpoint: emptyOrUrl('endSessionEndpoint must be an empty string or a valid URL').describe(
|
||||||
|
'End session endpoint',
|
||||||
|
),
|
||||||
|
signingAlgorithm: z.string().describe('Signing algorithm'),
|
||||||
|
profileSigningAlgorithm: z.string().describe('Profile signing algorithm'),
|
||||||
|
storageLabelClaim: z.string().describe('Storage label claim'),
|
||||||
|
storageQuotaClaim: z.string().describe('Storage quota claim'),
|
||||||
|
roleClaim: z.string().describe('Role claim'),
|
||||||
|
mobileOverrideEnabled: configBool.describe('Mobile override enabled'),
|
||||||
|
mobileRedirectUri: z.string().describe('Mobile redirect URI (set to empty string to disable)'),
|
||||||
|
})
|
||||||
|
.transform((value, ctx) => {
|
||||||
|
if (!value.mobileOverrideEnabled || value.mobileRedirectUri === '') {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!z.url().safeParse(value.mobileRedirectUri).success) {
|
||||||
|
ctx.issues.push({
|
||||||
|
code: 'custom',
|
||||||
|
message: 'Mobile redirect URI must be an empty string or a valid URL',
|
||||||
|
input: value.mobileRedirectUri,
|
||||||
|
});
|
||||||
|
return z.NEVER;
|
||||||
|
}
|
||||||
|
|
||||||
|
return value;
|
||||||
|
})
|
||||||
|
.meta({ id: 'AdminConfigOAuthDto' }),
|
||||||
|
passwordLogin: z
|
||||||
|
.object({ enabled: configBool.describe('Enabled').meta({ visibility: Public }) })
|
||||||
|
.meta({ id: 'AdminConfigPasswordLoginDto' }),
|
||||||
|
storageTemplate: z
|
||||||
|
.object({
|
||||||
|
enabled: configBool.describe('Enabled'),
|
||||||
|
hashVerificationEnabled: configBool.describe('Hash verification enabled'),
|
||||||
|
template: z.string().describe('Template'),
|
||||||
|
})
|
||||||
|
.meta({ id: 'AdminConfigStorageTemplateDto' }),
|
||||||
|
image: z
|
||||||
|
.object({
|
||||||
|
thumbnail: AdminConfigGeneratedImageSchema,
|
||||||
|
preview: AdminConfigGeneratedImageSchema,
|
||||||
|
fullsize: z
|
||||||
|
.object({
|
||||||
|
enabled: configBool.describe('Enabled').meta({ visibility: User }),
|
||||||
|
format: ImageFormatSchema,
|
||||||
|
quality: z.int().min(1).max(100).describe('Quality'),
|
||||||
|
progressive: configBool.default(false).optional().describe('Progressive'),
|
||||||
|
})
|
||||||
|
.meta({ id: 'AdminConfigGeneratedFullsizeImageDto' }),
|
||||||
|
colorspace: ColorspaceSchema,
|
||||||
|
extractEmbedded: configBool.describe('Extract embedded'),
|
||||||
|
})
|
||||||
|
.meta({ id: 'AdminConfigImageDto' }),
|
||||||
|
newVersionCheck: z
|
||||||
|
.object({ enabled: configBool.describe('Enabled'), channel: ReleaseChannelSchema })
|
||||||
|
.meta({ id: 'AdminConfigNewVersionCheckDto' }),
|
||||||
|
nightlyTasks: z
|
||||||
|
.object({
|
||||||
|
startTime: z.iso
|
||||||
|
.time({
|
||||||
|
precision: -1,
|
||||||
|
error: (iss) => `Invalid input: expected string in HH:MM format, received ${typeof iss.input}`,
|
||||||
|
})
|
||||||
|
.describe('Start time (HH:MM)'),
|
||||||
|
databaseCleanup: configBool.describe('Database cleanup'),
|
||||||
|
missingThumbnails: configBool.describe('Missing thumbnails'),
|
||||||
|
clusterNewFaces: configBool.describe('Cluster new faces'),
|
||||||
|
generateMemories: configBool.describe('Generate memories'),
|
||||||
|
syncQuotaUsage: configBool.describe('Sync quota usage'),
|
||||||
|
})
|
||||||
|
.meta({ id: 'AdminConfigNightlyTasksDto' }),
|
||||||
|
trash: z
|
||||||
|
.object({
|
||||||
|
enabled: configBool.describe('Enabled').meta({ visibility: User }),
|
||||||
|
days: z.int().min(0).describe('Days').meta({ visibility: User }),
|
||||||
|
})
|
||||||
|
.meta({ id: 'AdminConfigTrashDto' }),
|
||||||
|
theme: z
|
||||||
|
.object({ customCss: z.string().describe('Custom CSS for theming').meta({ visibility: Public }) })
|
||||||
|
.meta({ id: 'AdminConfigThemeDto' }),
|
||||||
|
library: z
|
||||||
|
.object({
|
||||||
|
scan: z
|
||||||
|
.object({
|
||||||
|
enabled: configBool.describe('Enabled'),
|
||||||
|
cronExpression: cronExpressionSchema,
|
||||||
|
})
|
||||||
|
.meta({ id: 'AdminConfigLibraryScanDto' }),
|
||||||
|
watch: z.object({ enabled: configBool.describe('Enabled') }).meta({ id: 'AdminConfigLibraryWatchDto' }),
|
||||||
|
})
|
||||||
|
.meta({ id: 'AdminConfigLibraryDto' }),
|
||||||
|
notifications: z.object({ smtp: AdminConfigSmtpSchema }).meta({ id: 'AdminConfigNotificationsDto' }),
|
||||||
|
templates: z
|
||||||
|
.object({
|
||||||
|
email: z
|
||||||
|
.object({
|
||||||
|
welcomeTemplate: z.string().describe('Welcome template'),
|
||||||
|
albumInviteTemplate: z.string().describe('Album invite template'),
|
||||||
|
albumUpdateTemplate: z.string().describe('Album update template'),
|
||||||
|
})
|
||||||
|
.meta({ id: 'AdminConfigTemplateEmailsDto' }),
|
||||||
|
})
|
||||||
|
.meta({ id: 'AdminConfigTemplatesDto' }),
|
||||||
|
server: z
|
||||||
|
.object({
|
||||||
|
externalDomain: emptyOrUrl('External domain must be an empty string or a valid URL')
|
||||||
|
.describe('External domain')
|
||||||
|
.meta({ visibility: User }),
|
||||||
|
loginPageMessage: z.string().describe('Login page message').meta({ visibility: Public }),
|
||||||
|
publicUsers: configBool.describe('Public users').meta({ visibility: User }),
|
||||||
|
})
|
||||||
|
.meta({ id: 'AdminConfigServerDto' }),
|
||||||
|
user: z
|
||||||
|
.object({ deleteDelay: z.int().min(1).describe('Delete delay').meta({ visibility: User }) })
|
||||||
|
.meta({ id: 'AdminConfigUserDto' }),
|
||||||
|
})
|
||||||
|
.describe('Configuration properties that are visible to the admin')
|
||||||
|
.meta({ id: 'AdminConfigDto' });
|
||||||
|
|
||||||
|
export type SystemConfig = z.infer<typeof AdminConfigSchemaWithVisibility>;
|
||||||
|
export type MachineLearningConfig = SystemConfig['machineLearning'];
|
||||||
|
|
||||||
|
const visibilities = [Public, User, Admin];
|
||||||
|
|
||||||
|
const isVisible = (property: ConfigVisibility, visibility: ConfigVisibility) =>
|
||||||
|
visibilities.indexOf(property) <= visibilities.indexOf(visibility);
|
||||||
|
|
||||||
|
const getMeta = (schema: z.ZodType) =>
|
||||||
|
(z.globalRegistry.get(schema) ?? {}) as { id?: string; description?: string; visibility?: ConfigVisibility };
|
||||||
|
|
||||||
|
const unwrap = (schema: z.ZodType) => (schema instanceof z.ZodPipe ? (schema.def.in as z.ZodType) : schema);
|
||||||
|
|
||||||
|
const visibleSchemas = new Map<z.ZodType, Map<ConfigVisibility, z.ZodType | undefined>>();
|
||||||
|
|
||||||
|
const applyVisibility = (visibility: ConfigVisibility): z.ZodType | undefined => {
|
||||||
|
const map: Record<ConfigVisibility, string> = {
|
||||||
|
[Admin]: 'Configuration properties that are visible to the admin',
|
||||||
|
[User]: 'Configuration properties that are visible to a logged user',
|
||||||
|
[Public]: 'Configuration properties that are visible to everyone',
|
||||||
|
};
|
||||||
|
|
||||||
|
return applyVisibilityRecursive(AdminConfigSchemaWithVisibility, visibility, map[visibility]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const applyVisibilityRecursive = (
|
||||||
|
schema: z.ZodType,
|
||||||
|
visibility: ConfigVisibility,
|
||||||
|
override?: string,
|
||||||
|
): z.ZodType | undefined => {
|
||||||
|
const object = unwrap(schema);
|
||||||
|
const { id, description, visibility: property } = getMeta(schema);
|
||||||
|
|
||||||
|
if (!(object instanceof z.ZodObject)) {
|
||||||
|
return isVisible(property ?? Admin, visibility) ? schema : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
let cache = visibleSchemas.get(schema);
|
||||||
|
if (!cache) {
|
||||||
|
cache = new Map();
|
||||||
|
visibleSchemas.set(schema, cache);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cache.has(visibility)) {
|
||||||
|
return cache.get(visibility);
|
||||||
|
}
|
||||||
|
|
||||||
|
const shape: Record<string, z.ZodType> = {};
|
||||||
|
for (const [key, value] of Object.entries(object.shape as Record<string, z.ZodType>)) {
|
||||||
|
const visible = applyVisibilityRecursive(value, visibility);
|
||||||
|
if (visible) {
|
||||||
|
shape[key] = visible;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let visible: z.ZodType | undefined;
|
||||||
|
if (Object.keys(shape).length > 0) {
|
||||||
|
visible = z.object(shape).meta({
|
||||||
|
...(id && { id: `${visibility}${id.slice(Admin.length)}` }),
|
||||||
|
...((override ?? description) && { description: override ?? description }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
cache.set(visibility, visible);
|
||||||
|
|
||||||
|
return visible;
|
||||||
|
};
|
||||||
|
|
||||||
|
const stripVisibilityMetadata = <T extends z.ZodType>(schema: T): T => {
|
||||||
|
const object = unwrap(schema);
|
||||||
|
if (object instanceof z.ZodObject) {
|
||||||
|
for (const value of Object.values(object.shape as Record<string, z.ZodType>)) {
|
||||||
|
stripVisibilityMetadata(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
return schema;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { visibility, ...meta } = getMeta(schema);
|
||||||
|
if (visibility) {
|
||||||
|
z.globalRegistry.add(schema, meta);
|
||||||
|
}
|
||||||
|
|
||||||
|
return schema;
|
||||||
|
};
|
||||||
|
|
||||||
|
const AdminConfigSchema = applyVisibility(Admin)! as z.ZodType<SystemConfig>;
|
||||||
|
const UserConfigSchema = applyVisibility(User)! as z.ZodType<DeepPartial<SystemConfig>>;
|
||||||
|
const PublicConfigSchema = applyVisibility(Public)! as z.ZodType<DeepPartial<SystemConfig>>;
|
||||||
|
|
||||||
|
// prevent visibility metadata from leaking to openapi spec
|
||||||
|
// eslint-disable-next-line unicorn/no-top-level-side-effects
|
||||||
|
stripVisibilityMetadata(AdminConfigSchemaWithVisibility);
|
||||||
|
|
||||||
|
const ConfigTemplateStorageOptionSchema = z
|
||||||
|
.object({
|
||||||
|
yearOptions: z.array(z.string()).describe('Available year format options for storage template'),
|
||||||
|
monthOptions: z.array(z.string()).describe('Available month format options for storage template'),
|
||||||
|
weekOptions: z.array(z.string()).describe('Available week format options for storage template'),
|
||||||
|
dayOptions: z.array(z.string()).describe('Available day format options for storage template'),
|
||||||
|
hourOptions: z.array(z.string()).describe('Available hour format options for storage template'),
|
||||||
|
minuteOptions: z.array(z.string()).describe('Available minute format options for storage template'),
|
||||||
|
secondOptions: z.array(z.string()).describe('Available second format options for storage template'),
|
||||||
|
presetOptions: z.array(z.string()).describe('Available preset template options'),
|
||||||
|
})
|
||||||
|
.meta({ id: 'SystemConfigTemplateStorageOptionDto' });
|
||||||
|
|
||||||
|
export class AdminConfigDto extends createZodDto(AdminConfigSchema) {}
|
||||||
|
export class UserConfigDto extends createZodDto(UserConfigSchema) {}
|
||||||
|
export class PublicConfigDto extends createZodDto(PublicConfigSchema) {}
|
||||||
|
export class ConfigFFmpegDto extends createZodDto(AdminConfigFFmpegSchema) {}
|
||||||
|
export class ConfigSmtpDto extends createZodDto(AdminConfigSmtpSchema) {}
|
||||||
|
export class ConfigTemplateStorageOptionDto extends createZodDto(ConfigTemplateStorageOptionSchema) {}
|
||||||
|
|
||||||
|
/** @deprecated the `/system-config` endpoints these are named after are on their way out */
|
||||||
|
export { AdminConfigDto as SystemConfigDto, ConfigSmtpDto as SystemConfigSmtpDto };
|
||||||
|
|
||||||
|
export function mapAdminConfig(config: SystemConfig): AdminConfigDto {
|
||||||
|
return config;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mapUserConfig(config: SystemConfig): UserConfigDto {
|
||||||
|
return UserConfigSchema.parse(config);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mapPublicConfig(config: SystemConfig): PublicConfigDto {
|
||||||
|
return PublicConfigSchema.parse(config);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const defaults = Object.freeze<SystemConfig>({
|
||||||
|
backup: {
|
||||||
|
database: {
|
||||||
|
enabled: true,
|
||||||
|
cronExpression: CronExpression.EVERY_DAY_AT_2AM,
|
||||||
|
keepLastAmount: 14,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
ffmpeg: {
|
||||||
|
crf: 23,
|
||||||
|
threads: 0,
|
||||||
|
preset: 'ultrafast',
|
||||||
|
targetVideoCodec: VideoCodec.H264,
|
||||||
|
acceptedVideoCodecs: [VideoCodec.H264],
|
||||||
|
targetAudioCodec: AudioCodec.Aac,
|
||||||
|
acceptedAudioCodecs: [AudioCodec.Aac, AudioCodec.Mp3, AudioCodec.Opus],
|
||||||
|
acceptedContainers: [VideoContainer.Mov, VideoContainer.Ogg, VideoContainer.Webm],
|
||||||
|
targetResolution: '720',
|
||||||
|
maxBitrate: '0',
|
||||||
|
bframes: -1,
|
||||||
|
refs: 0,
|
||||||
|
gopSize: 0,
|
||||||
|
temporalAQ: false,
|
||||||
|
cqMode: CQMode.Auto,
|
||||||
|
twoPass: false,
|
||||||
|
preferredHwDevice: 'auto',
|
||||||
|
transcode: TranscodePolicy.Required,
|
||||||
|
tonemap: ToneMapping.Hable,
|
||||||
|
accel: TranscodeHardwareAcceleration.Disabled,
|
||||||
|
accelDecode: true,
|
||||||
|
realtime: {
|
||||||
|
enabled: false,
|
||||||
|
videoCodecs: [VideoCodec.H264, VideoCodec.Hevc],
|
||||||
|
resolutions: [HlsVideoResolution.p480, HlsVideoResolution.p720, HlsVideoResolution.p1080],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
integrityChecks: {
|
||||||
|
missingFiles: {
|
||||||
|
enabled: true,
|
||||||
|
cronExpression: CronExpression.EVERY_DAY_AT_3AM,
|
||||||
|
},
|
||||||
|
untrackedFiles: {
|
||||||
|
enabled: true,
|
||||||
|
cronExpression: CronExpression.EVERY_DAY_AT_3AM,
|
||||||
|
},
|
||||||
|
checksumFiles: {
|
||||||
|
enabled: true,
|
||||||
|
cronExpression: CronExpression.EVERY_DAY_AT_3AM,
|
||||||
|
timeLimit: 60 * 60 * 1000, // 1 hour
|
||||||
|
percentageLimit: 1, // 100% of assets
|
||||||
|
},
|
||||||
|
},
|
||||||
|
job: {
|
||||||
|
thumbnailGeneration: { concurrency: 3 },
|
||||||
|
metadataExtraction: { concurrency: 5 },
|
||||||
|
videoConversion: { concurrency: 1 },
|
||||||
|
faceDetection: { concurrency: 2 },
|
||||||
|
smartSearch: { concurrency: 2 },
|
||||||
|
backgroundTask: { concurrency: 5 },
|
||||||
|
migration: { concurrency: 5 },
|
||||||
|
search: { concurrency: 5 },
|
||||||
|
sidecar: { concurrency: 5 },
|
||||||
|
library: { concurrency: 5 },
|
||||||
|
notifications: { concurrency: 5 },
|
||||||
|
ocr: { concurrency: 1 },
|
||||||
|
workflow: { concurrency: 5 },
|
||||||
|
editor: { concurrency: 2 },
|
||||||
|
integrityCheck: { concurrency: 1 },
|
||||||
|
},
|
||||||
|
logging: {
|
||||||
|
enabled: true,
|
||||||
|
level: LogLevel.Log,
|
||||||
|
},
|
||||||
|
machineLearning: {
|
||||||
|
enabled: process.env.IMMICH_MACHINE_LEARNING_ENABLED !== 'false',
|
||||||
|
urls: [process.env.IMMICH_MACHINE_LEARNING_URL || 'http://immich-machine-learning:3003'],
|
||||||
|
availabilityChecks: {
|
||||||
|
enabled: true,
|
||||||
|
timeout: 2000,
|
||||||
|
interval: 30_000,
|
||||||
|
},
|
||||||
|
clip: {
|
||||||
|
enabled: true,
|
||||||
|
modelName: 'ViT-B-32__openai',
|
||||||
|
},
|
||||||
|
duplicateDetection: {
|
||||||
|
enabled: true,
|
||||||
|
maxDistance: 0.01,
|
||||||
|
},
|
||||||
|
facialRecognition: {
|
||||||
|
enabled: true,
|
||||||
|
modelName: 'buffalo_l',
|
||||||
|
minScore: 0.7,
|
||||||
|
maxDistance: 0.5,
|
||||||
|
minFaces: 3,
|
||||||
|
},
|
||||||
|
ocr: {
|
||||||
|
enabled: true,
|
||||||
|
modelName: 'PP-OCRv5_mobile',
|
||||||
|
minDetectionScore: 0.5,
|
||||||
|
minRecognitionScore: 0.8,
|
||||||
|
maxResolution: 736,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
map: {
|
||||||
|
enabled: true,
|
||||||
|
lightStyle: 'https://tiles.immich.cloud/v1/style/light.json',
|
||||||
|
darkStyle: 'https://tiles.immich.cloud/v1/style/dark.json',
|
||||||
|
},
|
||||||
|
reverseGeocoding: {
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
metadata: {
|
||||||
|
faces: {
|
||||||
|
import: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
oauth: {
|
||||||
|
autoLaunch: false,
|
||||||
|
autoRegister: true,
|
||||||
|
buttonText: 'Login with OAuth',
|
||||||
|
clientId: '',
|
||||||
|
clientSecret: '',
|
||||||
|
defaultStorageQuota: null,
|
||||||
|
enabled: false,
|
||||||
|
issuerUrl: '',
|
||||||
|
endSessionEndpoint: '',
|
||||||
|
mobileOverrideEnabled: false,
|
||||||
|
mobileRedirectUri: '',
|
||||||
|
prompt: '',
|
||||||
|
scope: 'openid email profile',
|
||||||
|
signingAlgorithm: 'RS256',
|
||||||
|
profileSigningAlgorithm: 'none',
|
||||||
|
storageLabelClaim: 'preferred_username',
|
||||||
|
storageQuotaClaim: 'immich_quota',
|
||||||
|
roleClaim: 'immich_role',
|
||||||
|
tokenEndpointAuthMethod: OAuthTokenEndpointAuthMethod.ClientSecretPost,
|
||||||
|
timeout: 30_000,
|
||||||
|
allowInsecureRequests: false,
|
||||||
|
},
|
||||||
|
passwordLogin: {
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
storageTemplate: {
|
||||||
|
enabled: false,
|
||||||
|
hashVerificationEnabled: true,
|
||||||
|
template: '{{y}}/{{y}}-{{MM}}-{{dd}}/{{filename}}',
|
||||||
|
},
|
||||||
|
image: {
|
||||||
|
thumbnail: {
|
||||||
|
format: ImageFormat.Webp,
|
||||||
|
size: 250,
|
||||||
|
quality: 80,
|
||||||
|
progressive: false,
|
||||||
|
},
|
||||||
|
preview: {
|
||||||
|
format: ImageFormat.Jpeg,
|
||||||
|
size: 1440,
|
||||||
|
quality: 80,
|
||||||
|
progressive: false,
|
||||||
|
},
|
||||||
|
colorspace: Colorspace.P3,
|
||||||
|
extractEmbedded: false,
|
||||||
|
fullsize: {
|
||||||
|
enabled: false,
|
||||||
|
format: ImageFormat.Jpeg,
|
||||||
|
quality: 80,
|
||||||
|
progressive: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
newVersionCheck: {
|
||||||
|
enabled: true,
|
||||||
|
channel: ReleaseChannel.Stable,
|
||||||
|
},
|
||||||
|
nightlyTasks: {
|
||||||
|
startTime: '00:00',
|
||||||
|
databaseCleanup: true,
|
||||||
|
generateMemories: true,
|
||||||
|
syncQuotaUsage: true,
|
||||||
|
missingThumbnails: true,
|
||||||
|
clusterNewFaces: true,
|
||||||
|
},
|
||||||
|
trash: {
|
||||||
|
enabled: true,
|
||||||
|
days: 30,
|
||||||
|
},
|
||||||
|
theme: {
|
||||||
|
customCss: '',
|
||||||
|
},
|
||||||
|
library: {
|
||||||
|
scan: {
|
||||||
|
enabled: true,
|
||||||
|
cronExpression: CronExpression.EVERY_DAY_AT_MIDNIGHT,
|
||||||
|
},
|
||||||
|
watch: {
|
||||||
|
enabled: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
externalDomain: '',
|
||||||
|
loginPageMessage: '',
|
||||||
|
publicUsers: true,
|
||||||
|
},
|
||||||
|
notifications: {
|
||||||
|
smtp: {
|
||||||
|
enabled: false,
|
||||||
|
from: '',
|
||||||
|
replyTo: '',
|
||||||
|
transport: {
|
||||||
|
ignoreCert: false,
|
||||||
|
host: '',
|
||||||
|
port: 587,
|
||||||
|
secure: false,
|
||||||
|
username: '',
|
||||||
|
password: '',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
templates: {
|
||||||
|
email: {
|
||||||
|
welcomeTemplate: '',
|
||||||
|
albumInviteTemplate: '',
|
||||||
|
albumUpdateTemplate: '',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
user: {
|
||||||
|
deleteDelay: 7,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -1,57 +0,0 @@
|
|||||||
import { createZodDto } from 'nestjs-zod';
|
|
||||||
import z from 'zod';
|
|
||||||
|
|
||||||
const TaskConfigSchema = z
|
|
||||||
.object({
|
|
||||||
enabled: z.boolean().describe('Whether the task is enabled'),
|
|
||||||
})
|
|
||||||
.meta({ id: 'TaskConfig' });
|
|
||||||
|
|
||||||
const ModelConfigSchema = TaskConfigSchema.extend({
|
|
||||||
modelName: z.string().describe('Name of the model to use'),
|
|
||||||
});
|
|
||||||
|
|
||||||
export const CLIPConfigSchema = ModelConfigSchema.meta({ id: 'CLIPConfig' });
|
|
||||||
|
|
||||||
export const DuplicateDetectionConfigSchema = TaskConfigSchema.extend({
|
|
||||||
maxDistance: z
|
|
||||||
.number()
|
|
||||||
.meta({ format: 'double' })
|
|
||||||
.min(0.001)
|
|
||||||
.max(0.1)
|
|
||||||
.describe('Maximum distance threshold for duplicate detection'),
|
|
||||||
}).meta({ id: 'DuplicateDetectionConfig' });
|
|
||||||
|
|
||||||
export const FacialRecognitionConfigSchema = ModelConfigSchema.extend({
|
|
||||||
minScore: z
|
|
||||||
.number()
|
|
||||||
.meta({ format: 'double' })
|
|
||||||
.min(0.1)
|
|
||||||
.max(1)
|
|
||||||
.describe('Minimum confidence score for face detection'),
|
|
||||||
maxDistance: z
|
|
||||||
.number()
|
|
||||||
.meta({ format: 'double' })
|
|
||||||
.min(0.1)
|
|
||||||
.max(2)
|
|
||||||
.describe('Maximum distance threshold for face recognition'),
|
|
||||||
minFaces: z.int().min(1).describe('Minimum number of faces required for recognition'),
|
|
||||||
}).meta({ id: 'FacialRecognitionConfig' });
|
|
||||||
|
|
||||||
export const OcrConfigSchema = ModelConfigSchema.extend({
|
|
||||||
maxResolution: z.int().min(1).describe('Maximum resolution for OCR processing'),
|
|
||||||
minDetectionScore: z
|
|
||||||
.number()
|
|
||||||
.meta({ format: 'double' })
|
|
||||||
.min(0.1)
|
|
||||||
.max(1)
|
|
||||||
.describe('Minimum confidence score for text detection'),
|
|
||||||
minRecognitionScore: z
|
|
||||||
.number()
|
|
||||||
.meta({ format: 'double' })
|
|
||||||
.min(0.1)
|
|
||||||
.max(1)
|
|
||||||
.describe('Minimum confidence score for text recognition'),
|
|
||||||
}).meta({ id: 'OcrConfig' });
|
|
||||||
|
|
||||||
export class CLIPConfig extends createZodDto(CLIPConfigSchema) {}
|
|
||||||
@@ -1,444 +0,0 @@
|
|||||||
import { validateCronExpression } from 'cron';
|
|
||||||
import { createZodDto } from 'nestjs-zod';
|
|
||||||
import { SystemConfig } from 'src/config';
|
|
||||||
import {
|
|
||||||
CLIPConfigSchema,
|
|
||||||
DuplicateDetectionConfigSchema,
|
|
||||||
FacialRecognitionConfigSchema,
|
|
||||||
OcrConfigSchema,
|
|
||||||
} from 'src/dtos/model-config.dto';
|
|
||||||
import {
|
|
||||||
AudioCodecSchema,
|
|
||||||
ColorspaceSchema,
|
|
||||||
CQModeSchema,
|
|
||||||
HlsVideoResolutionSchema,
|
|
||||||
ImageFormatSchema,
|
|
||||||
LogLevelSchema,
|
|
||||||
OAuthTokenEndpointAuthMethodSchema,
|
|
||||||
ToneMappingSchema,
|
|
||||||
TranscodeHardwareAccelerationSchema,
|
|
||||||
TranscodePolicySchema,
|
|
||||||
VideoCodecSchema,
|
|
||||||
VideoContainerSchema,
|
|
||||||
} from 'src/enum';
|
|
||||||
import z from 'zod';
|
|
||||||
|
|
||||||
/** Coerces 'true'/'false' strings to boolean, but also allows booleans. */
|
|
||||||
const configBool = z
|
|
||||||
.preprocess((val) => {
|
|
||||||
if (val === 'true') {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
if (val === 'false') {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return val;
|
|
||||||
}, z.boolean())
|
|
||||||
.meta({ type: 'boolean' });
|
|
||||||
|
|
||||||
const JobSettingsSchema = z
|
|
||||||
.object({
|
|
||||||
concurrency: z.int().min(1).describe('Concurrency'),
|
|
||||||
})
|
|
||||||
.meta({ id: 'JobSettingsDto' });
|
|
||||||
|
|
||||||
const cronExpressionSchema = z
|
|
||||||
.string()
|
|
||||||
.superRefine((value, ctx) => {
|
|
||||||
const validated = validateCronExpression(value);
|
|
||||||
if (!validated.valid) {
|
|
||||||
ctx.addIssue({
|
|
||||||
code: 'custom',
|
|
||||||
message: `Invalid cron expression. ${validated.error?.message ?? ''}`,
|
|
||||||
input: value,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.describe('Cron expression');
|
|
||||||
|
|
||||||
const DatabaseBackupSchema = z
|
|
||||||
.object({
|
|
||||||
enabled: configBool.describe('Enabled'),
|
|
||||||
cronExpression: cronExpressionSchema,
|
|
||||||
keepLastAmount: z.int().min(1).describe('Keep last amount'),
|
|
||||||
})
|
|
||||||
.meta({ id: 'DatabaseBackupConfig' });
|
|
||||||
|
|
||||||
const SystemConfigIntegrityJobSchema = z
|
|
||||||
.object({
|
|
||||||
enabled: z.boolean().describe('Enabled'),
|
|
||||||
cronExpression: cronExpressionSchema.describe('Cron expression for when the integrity check should run'),
|
|
||||||
})
|
|
||||||
.describe('Integrity job config')
|
|
||||||
.meta({ id: 'SystemConfigIntegrityJob' });
|
|
||||||
|
|
||||||
const SystemConfigIntegrityChecksumJobSchema = SystemConfigIntegrityJobSchema.extend({
|
|
||||||
timeLimit: z.int().nonnegative().describe('How long the integrity checksum job may run for'),
|
|
||||||
percentageLimit: z
|
|
||||||
.float32()
|
|
||||||
.nonnegative()
|
|
||||||
.max(1)
|
|
||||||
.describe('Percentage limit of the integrity checksum job')
|
|
||||||
.meta({ format: 'double' }),
|
|
||||||
})
|
|
||||||
.describe('Integrity checksum job config')
|
|
||||||
.meta({ id: 'SystemConfigIntegrityChecksumJob' });
|
|
||||||
|
|
||||||
const SystemConfigIntegrityChecksSchema = z
|
|
||||||
.object({
|
|
||||||
missingFiles: SystemConfigIntegrityJobSchema,
|
|
||||||
untrackedFiles: SystemConfigIntegrityJobSchema,
|
|
||||||
checksumFiles: SystemConfigIntegrityChecksumJobSchema,
|
|
||||||
})
|
|
||||||
.describe('Integrity checks config')
|
|
||||||
.meta({ id: 'SystemConfigIntegrityChecks' });
|
|
||||||
|
|
||||||
const SystemConfigBackupsSchema = z.object({ database: DatabaseBackupSchema }).meta({ id: 'SystemConfigBackupsDto' });
|
|
||||||
|
|
||||||
const SystemConfigFFmpegSchema = z
|
|
||||||
.object({
|
|
||||||
crf: z.coerce.number().int().min(0).max(51).describe('CRF'),
|
|
||||||
threads: z.coerce.number().int().min(0).describe('Threads'),
|
|
||||||
preset: z.string().describe('Preset'),
|
|
||||||
targetVideoCodec: VideoCodecSchema,
|
|
||||||
acceptedVideoCodecs: z.array(VideoCodecSchema).describe('Accepted video codecs'),
|
|
||||||
targetAudioCodec: AudioCodecSchema,
|
|
||||||
acceptedAudioCodecs: z.array(AudioCodecSchema).describe('Accepted audio codecs'),
|
|
||||||
acceptedContainers: z.array(VideoContainerSchema).describe('Accepted containers'),
|
|
||||||
targetResolution: z.string().describe('Target resolution'),
|
|
||||||
maxBitrate: z.string().describe('Max bitrate'),
|
|
||||||
bframes: z.coerce.number().int().min(-1).max(16).describe('B-frames'),
|
|
||||||
refs: z.coerce.number().int().min(0).max(6).describe('References'),
|
|
||||||
gopSize: z.coerce.number().int().min(0).describe('GOP size'),
|
|
||||||
temporalAQ: configBool.describe('Temporal AQ'),
|
|
||||||
cqMode: CQModeSchema,
|
|
||||||
twoPass: configBool.describe('Two pass'),
|
|
||||||
preferredHwDevice: z.string().describe('Preferred hardware device'),
|
|
||||||
transcode: TranscodePolicySchema,
|
|
||||||
accel: TranscodeHardwareAccelerationSchema,
|
|
||||||
accelDecode: configBool.describe('Accelerated decode'),
|
|
||||||
tonemap: ToneMappingSchema,
|
|
||||||
realtime: z
|
|
||||||
.object({
|
|
||||||
enabled: configBool.describe('Enable real-time HLS transcoding (alpha)'),
|
|
||||||
videoCodecs: z.array(VideoCodecSchema).describe('Video codecs to use for real-time HLS transcoding'),
|
|
||||||
resolutions: z.array(HlsVideoResolutionSchema).describe('Resolutions to use for real-time HLS transcoding'),
|
|
||||||
})
|
|
||||||
.meta({ id: 'SystemConfigFFmpegRealtimeDto' }),
|
|
||||||
})
|
|
||||||
.meta({ id: 'SystemConfigFFmpegDto' });
|
|
||||||
|
|
||||||
const SystemConfigJobSchema = z
|
|
||||||
.object({
|
|
||||||
thumbnailGeneration: JobSettingsSchema,
|
|
||||||
metadataExtraction: JobSettingsSchema,
|
|
||||||
videoConversion: JobSettingsSchema,
|
|
||||||
faceDetection: JobSettingsSchema,
|
|
||||||
smartSearch: JobSettingsSchema,
|
|
||||||
backgroundTask: JobSettingsSchema,
|
|
||||||
migration: JobSettingsSchema,
|
|
||||||
search: JobSettingsSchema,
|
|
||||||
sidecar: JobSettingsSchema,
|
|
||||||
library: JobSettingsSchema,
|
|
||||||
notifications: JobSettingsSchema,
|
|
||||||
ocr: JobSettingsSchema,
|
|
||||||
workflow: JobSettingsSchema,
|
|
||||||
editor: JobSettingsSchema,
|
|
||||||
integrityCheck: JobSettingsSchema,
|
|
||||||
})
|
|
||||||
.meta({ id: 'SystemConfigJobDto' });
|
|
||||||
|
|
||||||
const SystemConfigLibraryScanSchema = z
|
|
||||||
.object({
|
|
||||||
enabled: configBool.describe('Enabled'),
|
|
||||||
cronExpression: cronExpressionSchema,
|
|
||||||
})
|
|
||||||
.meta({ id: 'SystemConfigLibraryScanDto' });
|
|
||||||
|
|
||||||
const SystemConfigLibraryWatchSchema = z
|
|
||||||
.object({ enabled: configBool.describe('Enabled') })
|
|
||||||
.meta({ id: 'SystemConfigLibraryWatchDto' });
|
|
||||||
|
|
||||||
const SystemConfigLibrarySchema = z
|
|
||||||
.object({ scan: SystemConfigLibraryScanSchema, watch: SystemConfigLibraryWatchSchema })
|
|
||||||
.meta({ id: 'SystemConfigLibraryDto' });
|
|
||||||
|
|
||||||
const SystemConfigLoggingSchema = z
|
|
||||||
.object({
|
|
||||||
enabled: configBool.describe('Enabled'),
|
|
||||||
level: LogLevelSchema,
|
|
||||||
})
|
|
||||||
.meta({ id: 'SystemConfigLoggingDto' });
|
|
||||||
|
|
||||||
const MachineLearningAvailabilityChecksSchema = z
|
|
||||||
.object({
|
|
||||||
enabled: configBool.describe('Enabled'),
|
|
||||||
timeout: z.int(),
|
|
||||||
interval: z.int(),
|
|
||||||
})
|
|
||||||
.meta({ id: 'MachineLearningAvailabilityChecksDto' });
|
|
||||||
|
|
||||||
const SystemConfigMachineLearningSchema = z
|
|
||||||
.object({
|
|
||||||
enabled: configBool.describe('Enabled'),
|
|
||||||
urls: z.array(z.string()).min(1).describe('ML service URLs'),
|
|
||||||
availabilityChecks: MachineLearningAvailabilityChecksSchema,
|
|
||||||
clip: CLIPConfigSchema,
|
|
||||||
duplicateDetection: DuplicateDetectionConfigSchema,
|
|
||||||
facialRecognition: FacialRecognitionConfigSchema,
|
|
||||||
ocr: OcrConfigSchema,
|
|
||||||
})
|
|
||||||
.meta({ id: 'SystemConfigMachineLearningDto' });
|
|
||||||
|
|
||||||
const SystemConfigMapSchema = z
|
|
||||||
.object({
|
|
||||||
enabled: configBool.describe('Enabled'),
|
|
||||||
lightStyle: z.url().describe('Light map style URL'),
|
|
||||||
darkStyle: z.url().describe('Dark map style URL'),
|
|
||||||
})
|
|
||||||
.meta({ id: 'SystemConfigMapDto' });
|
|
||||||
|
|
||||||
export enum ReleaseChannel {
|
|
||||||
Stable = 'stable',
|
|
||||||
ReleaseCandidate = 'releaseCandidate',
|
|
||||||
}
|
|
||||||
|
|
||||||
const ReleaseChannelSchema = z.enum(ReleaseChannel).describe('Release channel').meta({ id: 'ReleaseChannel' });
|
|
||||||
|
|
||||||
const SystemConfigNewVersionCheckSchema = z
|
|
||||||
.object({ enabled: configBool.describe('Enabled'), channel: ReleaseChannelSchema })
|
|
||||||
.meta({ id: 'SystemConfigNewVersionCheckDto' });
|
|
||||||
|
|
||||||
const SystemConfigNightlyTasksSchema = z
|
|
||||||
.object({
|
|
||||||
startTime: z.iso
|
|
||||||
.time({
|
|
||||||
precision: -1,
|
|
||||||
error: (iss) => `Invalid input: expected string in HH:MM format, received ${typeof iss.input}`,
|
|
||||||
})
|
|
||||||
.describe('Start time (HH:MM)'),
|
|
||||||
databaseCleanup: configBool.describe('Database cleanup'),
|
|
||||||
missingThumbnails: configBool.describe('Missing thumbnails'),
|
|
||||||
clusterNewFaces: configBool.describe('Cluster new faces'),
|
|
||||||
generateMemories: configBool.describe('Generate memories'),
|
|
||||||
syncQuotaUsage: configBool.describe('Sync quota usage'),
|
|
||||||
})
|
|
||||||
.meta({ id: 'SystemConfigNightlyTasksDto' });
|
|
||||||
|
|
||||||
const SystemConfigOAuthSchema = z
|
|
||||||
.object({
|
|
||||||
autoLaunch: configBool.describe('Auto launch'),
|
|
||||||
autoRegister: configBool.describe('Auto register'),
|
|
||||||
buttonText: z.string().describe('Button text'),
|
|
||||||
clientId: z.string().describe('Client ID'),
|
|
||||||
clientSecret: z.string().describe('Client secret'),
|
|
||||||
tokenEndpointAuthMethod: OAuthTokenEndpointAuthMethodSchema,
|
|
||||||
timeout: z.int().min(1).describe('Timeout'),
|
|
||||||
allowInsecureRequests: configBool.describe('Allow insecure requests'),
|
|
||||||
defaultStorageQuota: z.int().min(0).nullable().describe('Default storage quota'),
|
|
||||||
enabled: configBool.describe('Enabled'),
|
|
||||||
issuerUrl: z
|
|
||||||
.string()
|
|
||||||
.refine((url) => url.length === 0 || z.url().safeParse(url).success, {
|
|
||||||
error: 'Issuer URL must be an empty string or a valid URL',
|
|
||||||
})
|
|
||||||
.describe('Issuer URL'),
|
|
||||||
scope: z.string().describe('Scope'),
|
|
||||||
prompt: z.string().describe('OAuth prompt parameter (e.g. select_account, login, consent)'),
|
|
||||||
endSessionEndpoint: z
|
|
||||||
.string()
|
|
||||||
.refine((url) => url.length === 0 || z.url().safeParse(url).success, {
|
|
||||||
error: 'endSessionEndpoint must be an empty string or a valid URL',
|
|
||||||
})
|
|
||||||
.describe('End session endpoint'),
|
|
||||||
signingAlgorithm: z.string().describe('Signing algorithm'),
|
|
||||||
profileSigningAlgorithm: z.string().describe('Profile signing algorithm'),
|
|
||||||
storageLabelClaim: z.string().describe('Storage label claim'),
|
|
||||||
storageQuotaClaim: z.string().describe('Storage quota claim'),
|
|
||||||
roleClaim: z.string().describe('Role claim'),
|
|
||||||
mobileOverrideEnabled: configBool.describe('Mobile override enabled'),
|
|
||||||
mobileRedirectUri: z.string().describe('Mobile redirect URI (set to empty string to disable)'),
|
|
||||||
})
|
|
||||||
.transform((value, ctx) => {
|
|
||||||
if (!value.mobileOverrideEnabled || value.mobileRedirectUri === '') {
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!z.url().safeParse(value.mobileRedirectUri).success) {
|
|
||||||
ctx.issues.push({
|
|
||||||
code: 'custom',
|
|
||||||
message: 'Mobile redirect URI must be an empty string or a valid URL',
|
|
||||||
input: value.mobileRedirectUri,
|
|
||||||
});
|
|
||||||
return z.NEVER;
|
|
||||||
}
|
|
||||||
|
|
||||||
return value;
|
|
||||||
})
|
|
||||||
.meta({
|
|
||||||
id: 'SystemConfigOAuthDto',
|
|
||||||
});
|
|
||||||
|
|
||||||
const SystemConfigPasswordLoginSchema = z
|
|
||||||
.object({ enabled: configBool.describe('Enabled') })
|
|
||||||
.meta({ id: 'SystemConfigPasswordLoginDto' });
|
|
||||||
|
|
||||||
const SystemConfigReverseGeocodingSchema = z
|
|
||||||
.object({ enabled: configBool.describe('Enabled') })
|
|
||||||
.meta({ id: 'SystemConfigReverseGeocodingDto' });
|
|
||||||
|
|
||||||
const SystemConfigFacesSchema = z
|
|
||||||
.object({ import: configBool.describe('Import') })
|
|
||||||
.meta({ id: 'SystemConfigFacesDto' });
|
|
||||||
const SystemConfigMetadataSchema = z.object({ faces: SystemConfigFacesSchema }).meta({ id: 'SystemConfigMetadataDto' });
|
|
||||||
|
|
||||||
const SystemConfigServerSchema = z
|
|
||||||
.object({
|
|
||||||
externalDomain: z
|
|
||||||
.string()
|
|
||||||
.refine((url) => url.length === 0 || z.url().safeParse(url).success, {
|
|
||||||
error: 'External domain must be an empty string or a valid URL',
|
|
||||||
})
|
|
||||||
.describe('External domain'),
|
|
||||||
loginPageMessage: z.string().describe('Login page message'),
|
|
||||||
publicUsers: configBool.describe('Public users'),
|
|
||||||
})
|
|
||||||
.meta({ id: 'SystemConfigServerDto' });
|
|
||||||
|
|
||||||
const SystemConfigSmtpTransportSchema = z
|
|
||||||
.object({
|
|
||||||
ignoreCert: configBool.describe('Whether to ignore SSL certificate errors'),
|
|
||||||
host: z.string().describe('SMTP server hostname'),
|
|
||||||
port: z.int().min(0).max(65_535).describe('SMTP server port'),
|
|
||||||
secure: configBool.describe('Whether to use secure connection (TLS/SSL)'),
|
|
||||||
username: z.string().describe('SMTP username'),
|
|
||||||
password: z.string().describe('SMTP password'),
|
|
||||||
})
|
|
||||||
.meta({ id: 'SystemConfigSmtpTransportDto' });
|
|
||||||
|
|
||||||
const SystemConfigSmtpSchema = z
|
|
||||||
.object({
|
|
||||||
enabled: configBool.describe('Whether SMTP email notifications are enabled'),
|
|
||||||
from: z.string().describe('Email address to send from'),
|
|
||||||
replyTo: z.string().describe('Email address for replies'),
|
|
||||||
transport: SystemConfigSmtpTransportSchema,
|
|
||||||
})
|
|
||||||
.meta({ id: 'SystemConfigSmtpDto' });
|
|
||||||
|
|
||||||
const SystemConfigNotificationsSchema = z
|
|
||||||
.object({ smtp: SystemConfigSmtpSchema })
|
|
||||||
.meta({ id: 'SystemConfigNotificationsDto' });
|
|
||||||
|
|
||||||
const SystemConfigTemplateEmailsSchema = z
|
|
||||||
.object({
|
|
||||||
albumInviteTemplate: z.string().describe('Album invite template'),
|
|
||||||
welcomeTemplate: z.string().describe('Welcome template'),
|
|
||||||
albumUpdateTemplate: z.string().describe('Album update template'),
|
|
||||||
})
|
|
||||||
.meta({ id: 'SystemConfigTemplateEmailsDto' });
|
|
||||||
const SystemConfigTemplatesSchema = z
|
|
||||||
.object({ email: SystemConfigTemplateEmailsSchema })
|
|
||||||
.meta({ id: 'SystemConfigTemplatesDto' });
|
|
||||||
|
|
||||||
const SystemConfigStorageTemplateSchema = z
|
|
||||||
.object({
|
|
||||||
enabled: configBool.describe('Enabled'),
|
|
||||||
hashVerificationEnabled: configBool.describe('Hash verification enabled'),
|
|
||||||
template: z.string().describe('Template'),
|
|
||||||
})
|
|
||||||
.meta({ id: 'SystemConfigStorageTemplateDto' });
|
|
||||||
|
|
||||||
const SystemConfigTemplateStorageOptionSchema = z
|
|
||||||
.object({
|
|
||||||
yearOptions: z.array(z.string()).describe('Available year format options for storage template'),
|
|
||||||
monthOptions: z.array(z.string()).describe('Available month format options for storage template'),
|
|
||||||
weekOptions: z.array(z.string()).describe('Available week format options for storage template'),
|
|
||||||
dayOptions: z.array(z.string()).describe('Available day format options for storage template'),
|
|
||||||
hourOptions: z.array(z.string()).describe('Available hour format options for storage template'),
|
|
||||||
minuteOptions: z.array(z.string()).describe('Available minute format options for storage template'),
|
|
||||||
secondOptions: z.array(z.string()).describe('Available second format options for storage template'),
|
|
||||||
presetOptions: z.array(z.string()).describe('Available preset template options'),
|
|
||||||
})
|
|
||||||
.meta({ id: 'SystemConfigTemplateStorageOptionDto' });
|
|
||||||
|
|
||||||
const SystemConfigThemeSchema = z
|
|
||||||
.object({ customCss: z.string().describe('Custom CSS for theming') })
|
|
||||||
.meta({ id: 'SystemConfigThemeDto' });
|
|
||||||
|
|
||||||
const SystemConfigGeneratedImageSchema = z
|
|
||||||
.object({
|
|
||||||
format: ImageFormatSchema,
|
|
||||||
quality: z.int().min(1).max(100).describe('Quality'),
|
|
||||||
size: z.int().min(1).describe('Size'),
|
|
||||||
progressive: configBool.default(false).optional().describe('Progressive'),
|
|
||||||
})
|
|
||||||
.meta({ id: 'SystemConfigGeneratedImageDto' });
|
|
||||||
|
|
||||||
const SystemConfigGeneratedFullsizeImageSchema = z
|
|
||||||
.object({
|
|
||||||
enabled: configBool.describe('Enabled'),
|
|
||||||
format: ImageFormatSchema,
|
|
||||||
quality: z.int().min(1).max(100).describe('Quality'),
|
|
||||||
progressive: configBool.default(false).optional().describe('Progressive'),
|
|
||||||
})
|
|
||||||
.meta({ id: 'SystemConfigGeneratedFullsizeImageDto' });
|
|
||||||
|
|
||||||
const SystemConfigImageSchema = z
|
|
||||||
.object({
|
|
||||||
thumbnail: SystemConfigGeneratedImageSchema,
|
|
||||||
preview: SystemConfigGeneratedImageSchema,
|
|
||||||
fullsize: SystemConfigGeneratedFullsizeImageSchema,
|
|
||||||
colorspace: ColorspaceSchema,
|
|
||||||
extractEmbedded: configBool.describe('Extract embedded'),
|
|
||||||
})
|
|
||||||
.meta({ id: 'SystemConfigImageDto' });
|
|
||||||
|
|
||||||
const SystemConfigTrashSchema = z
|
|
||||||
.object({
|
|
||||||
enabled: configBool.describe('Enabled'),
|
|
||||||
days: z.int().min(0).describe('Days'),
|
|
||||||
})
|
|
||||||
.meta({ id: 'SystemConfigTrashDto' });
|
|
||||||
|
|
||||||
const SystemConfigUserSchema = z
|
|
||||||
.object({
|
|
||||||
deleteDelay: z.int().min(1).describe('Delete delay'),
|
|
||||||
})
|
|
||||||
.meta({ id: 'SystemConfigUserDto' });
|
|
||||||
|
|
||||||
export const SystemConfigSchema = z
|
|
||||||
.object({
|
|
||||||
backup: SystemConfigBackupsSchema,
|
|
||||||
ffmpeg: SystemConfigFFmpegSchema,
|
|
||||||
logging: SystemConfigLoggingSchema,
|
|
||||||
machineLearning: SystemConfigMachineLearningSchema,
|
|
||||||
map: SystemConfigMapSchema,
|
|
||||||
newVersionCheck: SystemConfigNewVersionCheckSchema,
|
|
||||||
nightlyTasks: SystemConfigNightlyTasksSchema,
|
|
||||||
oauth: SystemConfigOAuthSchema,
|
|
||||||
passwordLogin: SystemConfigPasswordLoginSchema,
|
|
||||||
reverseGeocoding: SystemConfigReverseGeocodingSchema,
|
|
||||||
metadata: SystemConfigMetadataSchema,
|
|
||||||
storageTemplate: SystemConfigStorageTemplateSchema,
|
|
||||||
job: SystemConfigJobSchema,
|
|
||||||
image: SystemConfigImageSchema,
|
|
||||||
trash: SystemConfigTrashSchema,
|
|
||||||
theme: SystemConfigThemeSchema,
|
|
||||||
library: SystemConfigLibrarySchema,
|
|
||||||
notifications: SystemConfigNotificationsSchema,
|
|
||||||
templates: SystemConfigTemplatesSchema,
|
|
||||||
server: SystemConfigServerSchema,
|
|
||||||
user: SystemConfigUserSchema,
|
|
||||||
integrityChecks: SystemConfigIntegrityChecksSchema,
|
|
||||||
})
|
|
||||||
.describe('System configuration')
|
|
||||||
.meta({ id: 'SystemConfigDto' });
|
|
||||||
|
|
||||||
export class SystemConfigFFmpegDto extends createZodDto(SystemConfigFFmpegSchema) {}
|
|
||||||
export class SystemConfigSmtpDto extends createZodDto(SystemConfigSmtpSchema) {}
|
|
||||||
export class SystemConfigTemplateStorageOptionDto extends createZodDto(SystemConfigTemplateStorageOptionSchema) {}
|
|
||||||
export class SystemConfigDto extends createZodDto(SystemConfigSchema) {}
|
|
||||||
|
|
||||||
export function mapConfig(config: SystemConfig): SystemConfigDto {
|
|
||||||
return config;
|
|
||||||
}
|
|
||||||
@@ -161,6 +161,11 @@ export enum Permission {
|
|||||||
BackupUpload = 'backup.upload',
|
BackupUpload = 'backup.upload',
|
||||||
BackupDelete = 'backup.delete',
|
BackupDelete = 'backup.delete',
|
||||||
|
|
||||||
|
AdminConfigRead = 'adminConfig.read',
|
||||||
|
AdminConfigUpdate = 'adminConfig.update',
|
||||||
|
|
||||||
|
UserConfigRead = 'userConfig.read',
|
||||||
|
|
||||||
DuplicateRead = 'duplicate.read',
|
DuplicateRead = 'duplicate.read',
|
||||||
DuplicateDelete = 'duplicate.delete',
|
DuplicateDelete = 'duplicate.delete',
|
||||||
|
|
||||||
@@ -1165,12 +1170,25 @@ export const AssetVisibilitySchema = z
|
|||||||
.describe('Asset visibility')
|
.describe('Asset visibility')
|
||||||
.meta({ id: 'AssetVisibility' });
|
.meta({ id: 'AssetVisibility' });
|
||||||
|
|
||||||
|
export enum ReleaseChannel {
|
||||||
|
Stable = 'stable',
|
||||||
|
ReleaseCandidate = 'releaseCandidate',
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ReleaseChannelSchema = z.enum(ReleaseChannel).describe('Release channel').meta({ id: 'ReleaseChannel' });
|
||||||
|
|
||||||
export enum CronJob {
|
export enum CronJob {
|
||||||
LibraryScan = 'LibraryScan',
|
LibraryScan = 'LibraryScan',
|
||||||
NightlyJobs = 'NightlyJobs',
|
NightlyJobs = 'NightlyJobs',
|
||||||
VersionCheck = 'VersionCheck',
|
VersionCheck = 'VersionCheck',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export enum ConfigVisibility {
|
||||||
|
Public = 'Public',
|
||||||
|
User = 'User',
|
||||||
|
Admin = 'Admin',
|
||||||
|
}
|
||||||
|
|
||||||
export enum ApiTag {
|
export enum ApiTag {
|
||||||
Activities = 'Activities',
|
Activities = 'Activities',
|
||||||
Albums = 'Albums',
|
Albums = 'Albums',
|
||||||
@@ -1178,6 +1196,9 @@ export enum ApiTag {
|
|||||||
Authentication = 'Authentication',
|
Authentication = 'Authentication',
|
||||||
AuthenticationAdmin = 'Authentication (admin)',
|
AuthenticationAdmin = 'Authentication (admin)',
|
||||||
Assets = 'Assets',
|
Assets = 'Assets',
|
||||||
|
ConfigUser = 'Config (user)',
|
||||||
|
ConfigAdmin = 'Config (admin)',
|
||||||
|
ConfigPublic = 'Config (public)',
|
||||||
DatabaseBackups = 'Database Backups (admin)',
|
DatabaseBackups = 'Database Backups (admin)',
|
||||||
Deprecated = 'Deprecated',
|
Deprecated = 'Deprecated',
|
||||||
Download = 'Download',
|
Download = 'Download',
|
||||||
|
|||||||
@@ -2,10 +2,10 @@ import { Injectable } from '@nestjs/common';
|
|||||||
import { ModuleRef, Reflector } from '@nestjs/core';
|
import { ModuleRef, Reflector } from '@nestjs/core';
|
||||||
import _ from 'lodash';
|
import _ from 'lodash';
|
||||||
import { Socket } from 'socket.io';
|
import { Socket } from 'socket.io';
|
||||||
import { SystemConfig } from 'src/config';
|
|
||||||
import { Asset } from 'src/database';
|
import { Asset } from 'src/database';
|
||||||
import { EventConfig } from 'src/decorators';
|
import { EventConfig } from 'src/decorators';
|
||||||
import { AuthDto } from 'src/dtos/auth.dto';
|
import { AuthDto } from 'src/dtos/auth.dto';
|
||||||
|
import { SystemConfig } from 'src/dtos/config.dto';
|
||||||
import { ImmichWorker, JobStatus, MetadataKey, QueueName, UserAvatarColor, UserStatus } from 'src/enum';
|
import { ImmichWorker, JobStatus, MetadataKey, QueueName, UserAvatarColor, UserStatus } from 'src/enum';
|
||||||
import { ConfigRepository } from 'src/repositories/config.repository';
|
import { ConfigRepository } from 'src/repositories/config.repository';
|
||||||
import { LoggingRepository } from 'src/repositories/logging.repository';
|
import { LoggingRepository } from 'src/repositories/logging.repository';
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { Duration } from 'luxon';
|
import { Duration } from 'luxon';
|
||||||
import { readFile } from 'node:fs/promises';
|
import { readFile } from 'node:fs/promises';
|
||||||
import { MachineLearningConfig } from 'src/config';
|
import { MachineLearningConfig } from 'src/dtos/config.dto';
|
||||||
import { CLIPConfig } from 'src/dtos/model-config.dto';
|
|
||||||
import { LoggingRepository } from 'src/repositories/logging.repository';
|
import { LoggingRepository } from 'src/repositories/logging.repository';
|
||||||
|
|
||||||
export interface BoundingBox {
|
export interface BoundingBox {
|
||||||
@@ -205,7 +204,7 @@ export class MachineLearningRepository {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async encodeImage(imagePath: string, { modelName }: CLIPConfig) {
|
async encodeImage(imagePath: string, { modelName }: MachineLearningConfig['clip']) {
|
||||||
const request = { [ModelTask.SEARCH]: { [ModelType.VISUAL]: { modelName } } };
|
const request = { [ModelTask.SEARCH]: { [ModelType.VISUAL]: { modelName } } };
|
||||||
const response = await this.predict<ClipVisualResponse>({ imagePath }, request);
|
const response = await this.predict<ClipVisualResponse>({ imagePath }, request);
|
||||||
return response[ModelTask.SEARCH];
|
return response[ModelTask.SEARCH];
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { exec as execCallback } from 'node:child_process';
|
|||||||
import { readFile } from 'node:fs/promises';
|
import { readFile } from 'node:fs/promises';
|
||||||
import { promisify } from 'node:util';
|
import { promisify } from 'node:util';
|
||||||
import sharp from 'sharp';
|
import sharp from 'sharp';
|
||||||
import { ReleaseChannel } from 'src/dtos/system-config.dto';
|
import { ReleaseChannel } from 'src/enum';
|
||||||
import { ConfigRepository } from 'src/repositories/config.repository';
|
import { ConfigRepository } from 'src/repositories/config.repository';
|
||||||
import { LoggingRepository } from 'src/repositories/logging.repository';
|
import { LoggingRepository } from 'src/repositories/logging.repository';
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import { BadRequestException, Injectable } from '@nestjs/common';
|
import { BadRequestException, Injectable } from '@nestjs/common';
|
||||||
import { Insertable } from 'kysely';
|
import { Insertable } from 'kysely';
|
||||||
import sanitize from 'sanitize-filename';
|
import sanitize from 'sanitize-filename';
|
||||||
import { SystemConfig } from 'src/config';
|
|
||||||
import { SALT_ROUNDS } from 'src/constants';
|
import { SALT_ROUNDS } from 'src/constants';
|
||||||
import { StorageCore } from 'src/cores/storage.core';
|
import { StorageCore } from 'src/cores/storage.core';
|
||||||
import { UserAdmin } from 'src/database';
|
import { UserAdmin } from 'src/database';
|
||||||
|
import { SystemConfig } from 'src/dtos/config.dto';
|
||||||
import { AccessRepository } from 'src/repositories/access.repository';
|
import { AccessRepository } from 'src/repositories/access.repository';
|
||||||
import { ActivityRepository } from 'src/repositories/activity.repository';
|
import { ActivityRepository } from 'src/repositories/activity.repository';
|
||||||
import { AlbumUserRepository } from 'src/repositories/album-user.repository';
|
import { AlbumUserRepository } from 'src/repositories/album-user.repository';
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { BadRequestException } from '@nestjs/common';
|
import { BadRequestException } from '@nestjs/common';
|
||||||
import { DateTime } from 'luxon';
|
import { DateTime } from 'luxon';
|
||||||
import { PassThrough, Readable } from 'node:stream';
|
import { PassThrough, Readable } from 'node:stream';
|
||||||
import { defaults, SystemConfig } from 'src/config';
|
|
||||||
import { StorageCore } from 'src/cores/storage.core';
|
import { StorageCore } from 'src/cores/storage.core';
|
||||||
|
import { defaults, SystemConfig } from 'src/dtos/config.dto';
|
||||||
import { ImmichWorker, JobStatus, StorageFolder } from 'src/enum';
|
import { ImmichWorker, JobStatus, StorageFolder } from 'src/enum';
|
||||||
import { MaintenanceHealthRepository } from 'src/maintenance/maintenance-health.repository';
|
import { MaintenanceHealthRepository } from 'src/maintenance/maintenance-health.repository';
|
||||||
import { DatabaseBackupService } from 'src/services/database-backup.service';
|
import { DatabaseBackupService } from 'src/services/database-backup.service';
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { HLS_SEGMENT_DURATION, HLS_SEGMENT_FILENAME_REGEX, HLS_VARIANTS, HLS_VER
|
|||||||
import { StorageCore } from 'src/cores/storage.core';
|
import { StorageCore } from 'src/cores/storage.core';
|
||||||
import { OnEvent } from 'src/decorators';
|
import { OnEvent } from 'src/decorators';
|
||||||
import { AuthDto } from 'src/dtos/auth.dto';
|
import { AuthDto } from 'src/dtos/auth.dto';
|
||||||
import { SystemConfigFFmpegDto } from 'src/dtos/system-config.dto';
|
import { ConfigFFmpegDto } from 'src/dtos/config.dto';
|
||||||
import { CacheControl, ImmichWorker, Permission } from 'src/enum';
|
import { CacheControl, ImmichWorker, Permission } from 'src/enum';
|
||||||
import { ArgOf } from 'src/repositories/event.repository';
|
import { ArgOf } from 'src/repositories/event.repository';
|
||||||
import { BaseService } from 'src/services/base.service';
|
import { BaseService } from 'src/services/base.service';
|
||||||
@@ -124,7 +124,7 @@ export class HlsService extends BaseService {
|
|||||||
this.websocketRepository.serverSend('HlsSessionEnd', { sessionId });
|
this.websocketRepository.serverSend('HlsSessionEnd', { sessionId });
|
||||||
}
|
}
|
||||||
|
|
||||||
private generateMainPlaylist(sessionId: string, ffmpeg: SystemConfigFFmpegDto, asset: AssetWithStreamInfo) {
|
private generateMainPlaylist(sessionId: string, ffmpeg: ConfigFFmpegDto, asset: AssetWithStreamInfo) {
|
||||||
const fps = (asset.packets.packetCount * asset.videoStream.timeBase) / asset.packets.totalDuration;
|
const fps = (asset.packets.packetCount * asset.videoStream.timeBase) / asset.packets.totalDuration;
|
||||||
const roundedFps = fps.toFixed(3);
|
const roundedFps = fps.toFixed(3);
|
||||||
const sourceResolution = Math.min(asset.videoStream.height, asset.videoStream.width);
|
const sourceResolution = Math.min(asset.videoStream.height, asset.videoStream.width);
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { BadRequestException } from '@nestjs/common';
|
import { BadRequestException } from '@nestjs/common';
|
||||||
import { Stats } from 'node:fs';
|
import { Stats } from 'node:fs';
|
||||||
import { defaults, SystemConfig } from 'src/config';
|
|
||||||
import { JOBS_LIBRARY_PAGINATION_SIZE } from 'src/constants';
|
import { JOBS_LIBRARY_PAGINATION_SIZE } from 'src/constants';
|
||||||
|
import { defaults, SystemConfig } from 'src/dtos/config.dto';
|
||||||
import { mapLibrary } from 'src/dtos/library.dto';
|
import { mapLibrary } from 'src/dtos/library.dto';
|
||||||
import { AssetType, CronJob, ImmichWorker, JobName, JobStatus } from 'src/enum';
|
import { AssetType, CronJob, ImmichWorker, JobName, JobStatus } from 'src/enum';
|
||||||
import { LibraryService } from 'src/services/library.service';
|
import { LibraryService } from 'src/services/library.service';
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { ShallowDehydrateObject } from 'kysely';
|
import { ShallowDehydrateObject } from 'kysely';
|
||||||
import { OutputInfo } from 'sharp';
|
import { OutputInfo } from 'sharp';
|
||||||
import { SystemConfig } from 'src/config';
|
|
||||||
import { Exif } from 'src/database';
|
import { Exif } from 'src/database';
|
||||||
|
import { SystemConfig } from 'src/dtos/config.dto';
|
||||||
import { AssetEditAction } from 'src/dtos/editing.dto';
|
import { AssetEditAction } from 'src/dtos/editing.dto';
|
||||||
import {
|
import {
|
||||||
AssetFileType,
|
AssetFileType,
|
||||||
|
|||||||
@@ -1,11 +1,10 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { SystemConfig } from 'src/config';
|
|
||||||
import { FACE_THUMBNAIL_SIZE } from 'src/constants';
|
import { FACE_THUMBNAIL_SIZE } from 'src/constants';
|
||||||
import { ImagePathOptions, StorageCore, ThumbnailPathEntity } from 'src/cores/storage.core';
|
import { ImagePathOptions, StorageCore, ThumbnailPathEntity } from 'src/cores/storage.core';
|
||||||
import { AssetFile } from 'src/database';
|
import { AssetFile } from 'src/database';
|
||||||
import { OnEvent, OnJob } from 'src/decorators';
|
import { OnEvent, OnJob } from 'src/decorators';
|
||||||
|
import { ConfigFFmpegDto, SystemConfig } from 'src/dtos/config.dto';
|
||||||
import { AssetEditAction, CropParameters } from 'src/dtos/editing.dto';
|
import { AssetEditAction, CropParameters } from 'src/dtos/editing.dto';
|
||||||
import { SystemConfigFFmpegDto } from 'src/dtos/system-config.dto';
|
|
||||||
import {
|
import {
|
||||||
AssetFileType,
|
AssetFileType,
|
||||||
AssetType,
|
AssetType,
|
||||||
@@ -626,7 +625,7 @@ export class MediaService extends BaseService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private getTranscodeTarget(
|
private getTranscodeTarget(
|
||||||
config: SystemConfigFFmpegDto,
|
config: ConfigFFmpegDto,
|
||||||
videoStream: VideoStreamInfo,
|
videoStream: VideoStreamInfo,
|
||||||
audioStream?: AudioStreamInfo,
|
audioStream?: AudioStreamInfo,
|
||||||
): TranscodeTarget {
|
): TranscodeTarget {
|
||||||
@@ -648,7 +647,7 @@ export class MediaService extends BaseService {
|
|||||||
return TranscodeTarget.None;
|
return TranscodeTarget.None;
|
||||||
}
|
}
|
||||||
|
|
||||||
private isAudioTranscodeRequired(ffmpegConfig: SystemConfigFFmpegDto, stream?: AudioStreamInfo): boolean {
|
private isAudioTranscodeRequired(ffmpegConfig: ConfigFFmpegDto, stream?: AudioStreamInfo): boolean {
|
||||||
if (!stream) {
|
if (!stream) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -671,7 +670,7 @@ export class MediaService extends BaseService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private isVideoTranscodeRequired(ffmpegConfig: SystemConfigFFmpegDto, stream: VideoStreamInfo): boolean {
|
private isVideoTranscodeRequired(ffmpegConfig: ConfigFFmpegDto, stream: VideoStreamInfo): boolean {
|
||||||
const isScalingEnabled = ffmpegConfig.targetResolution !== 'original';
|
const isScalingEnabled = ffmpegConfig.targetResolution !== 'original';
|
||||||
const targetRes = Number.parseInt(ffmpegConfig.targetResolution);
|
const targetRes = Number.parseInt(ffmpegConfig.targetResolution);
|
||||||
const isLargerThanTargetRes = isScalingEnabled && Math.min(stream.height, stream.width) > targetRes;
|
const isLargerThanTargetRes = isScalingEnabled && Math.min(stream.height, stream.width) > targetRes;
|
||||||
@@ -703,7 +702,7 @@ export class MediaService extends BaseService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private isRemuxRequired(ffmpegConfig: SystemConfigFFmpegDto, { formatName, formatLongName }: VideoFormat): boolean {
|
private isRemuxRequired(ffmpegConfig: ConfigFFmpegDto, { formatName, formatLongName }: VideoFormat): boolean {
|
||||||
if (ffmpegConfig.transcode === TranscodePolicy.Disabled) {
|
if (ffmpegConfig.transcode === TranscodePolicy.Disabled) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { BinaryField, ExifDateTime } from 'exiftool-vendored';
|
|||||||
import { DateTime } from 'luxon';
|
import { DateTime } from 'luxon';
|
||||||
import { randomBytes } from 'node:crypto';
|
import { randomBytes } from 'node:crypto';
|
||||||
import { Stats } from 'node:fs';
|
import { Stats } from 'node:fs';
|
||||||
import { defaults } from 'src/config';
|
import { defaults } from 'src/dtos/config.dto';
|
||||||
import {
|
import {
|
||||||
AssetFileType,
|
AssetFileType,
|
||||||
AssetType,
|
AssetType,
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { defaults, SystemConfig } from 'src/config';
|
import { defaults, SystemConfig } from 'src/dtos/config.dto';
|
||||||
import { EmailTemplate } from 'src/repositories/email.repository';
|
import { EmailTemplate } from 'src/repositories/email.repository';
|
||||||
import { NotificationService } from 'src/services/notification.service';
|
import { NotificationService } from 'src/services/notification.service';
|
||||||
import { userStub } from 'test/fixtures/user.stub';
|
import { userStub } from 'test/fixtures/user.stub';
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { BadRequestException, Injectable } from '@nestjs/common';
|
import { BadRequestException, Injectable } from '@nestjs/common';
|
||||||
import { AuthDto } from 'src/dtos/auth.dto';
|
import { AuthDto } from 'src/dtos/auth.dto';
|
||||||
|
import { SystemConfigSmtpDto } from 'src/dtos/config.dto';
|
||||||
import { mapNotification, NotificationCreateDto } from 'src/dtos/notification.dto';
|
import { mapNotification, NotificationCreateDto } from 'src/dtos/notification.dto';
|
||||||
import { SystemConfigSmtpDto } from 'src/dtos/system-config.dto';
|
|
||||||
import { NotificationLevel, NotificationType } from 'src/enum';
|
import { NotificationLevel, NotificationType } from 'src/enum';
|
||||||
import { EmailTemplate } from 'src/repositories/email.repository';
|
import { EmailTemplate } from 'src/repositories/email.repository';
|
||||||
import { BaseService } from 'src/services/base.service';
|
import { BaseService } from 'src/services/base.service';
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { defaults, SystemConfig } from 'src/config';
|
import { AdminConfigDto, defaults, SystemConfig } from 'src/dtos/config.dto';
|
||||||
import { SystemConfigDto } from 'src/dtos/system-config.dto';
|
|
||||||
import { AssetFileType, JobName, JobStatus, UserMetadataKey } from 'src/enum';
|
import { AssetFileType, JobName, JobStatus, UserMetadataKey } from 'src/enum';
|
||||||
import { NotificationService } from 'src/services/notification.service';
|
import { NotificationService } from 'src/services/notification.service';
|
||||||
import { AlbumFactory } from 'test/factories/album.factory';
|
import { AlbumFactory } from 'test/factories/album.factory';
|
||||||
@@ -100,7 +99,7 @@ describe(NotificationService.name, () => {
|
|||||||
|
|
||||||
it('skips smtp validation with DTO when there are no changes', async () => {
|
it('skips smtp validation with DTO when there are no changes', async () => {
|
||||||
const oldConfig = { ...configs.smtpEnabled };
|
const oldConfig = { ...configs.smtpEnabled };
|
||||||
const newConfig = configs.smtpEnabled as SystemConfigDto;
|
const newConfig = configs.smtpEnabled as AdminConfigDto;
|
||||||
|
|
||||||
await expect(sut.onConfigValidate({ oldConfig, newConfig })).resolves.not.toThrow();
|
await expect(sut.onConfigValidate({ oldConfig, newConfig })).resolves.not.toThrow();
|
||||||
expect(mocks.email.verifySmtp).not.toHaveBeenCalled();
|
expect(mocks.email.verifySmtp).not.toHaveBeenCalled();
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { OnEvent, OnJob } from 'src/decorators';
|
|||||||
import { MapAlbumDto } from 'src/dtos/album.dto';
|
import { MapAlbumDto } from 'src/dtos/album.dto';
|
||||||
import { mapAsset } from 'src/dtos/asset-response.dto';
|
import { mapAsset } from 'src/dtos/asset-response.dto';
|
||||||
import { AuthDto } from 'src/dtos/auth.dto';
|
import { AuthDto } from 'src/dtos/auth.dto';
|
||||||
|
import { SystemConfigSmtpDto } from 'src/dtos/config.dto';
|
||||||
import {
|
import {
|
||||||
mapNotification,
|
mapNotification,
|
||||||
NotificationDeleteAllDto,
|
NotificationDeleteAllDto,
|
||||||
@@ -11,7 +12,6 @@ import {
|
|||||||
NotificationUpdateAllDto,
|
NotificationUpdateAllDto,
|
||||||
NotificationUpdateDto,
|
NotificationUpdateDto,
|
||||||
} from 'src/dtos/notification.dto';
|
} from 'src/dtos/notification.dto';
|
||||||
import { SystemConfigSmtpDto } from 'src/dtos/system-config.dto';
|
|
||||||
import {
|
import {
|
||||||
AssetFileType,
|
AssetFileType,
|
||||||
JobName,
|
JobName,
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { BadRequestException } from '@nestjs/common';
|
import { BadRequestException } from '@nestjs/common';
|
||||||
import { defaults, SystemConfig } from 'src/config';
|
import { defaults, SystemConfig } from 'src/dtos/config.dto';
|
||||||
import { ImmichWorker, JobName, QueueCommand, QueueName } from 'src/enum';
|
import { ImmichWorker, JobName, QueueCommand, QueueName } from 'src/enum';
|
||||||
import { QueueService } from 'src/services/queue.service';
|
import { QueueService } from 'src/services/queue.service';
|
||||||
import { factory } from 'test/small.factory';
|
import { factory } from 'test/small.factory';
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { BadRequestException, Injectable } from '@nestjs/common';
|
import { BadRequestException, Injectable } from '@nestjs/common';
|
||||||
import { SystemConfig } from 'src/config';
|
|
||||||
import { OnEvent } from 'src/decorators';
|
import { OnEvent } from 'src/decorators';
|
||||||
import { AuthDto } from 'src/dtos/auth.dto';
|
import { AuthDto } from 'src/dtos/auth.dto';
|
||||||
|
import { SystemConfig } from 'src/dtos/config.dto';
|
||||||
import {
|
import {
|
||||||
mapQueueLegacy,
|
mapQueueLegacy,
|
||||||
mapQueuesLegacy,
|
mapQueuesLegacy,
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { SystemConfig } from 'src/config';
|
import { SystemConfig } from 'src/dtos/config.dto';
|
||||||
import { AssetFileType, AssetVisibility, ImmichWorker, JobName, JobStatus } from 'src/enum';
|
import { AssetFileType, AssetVisibility, ImmichWorker, JobName, JobStatus } from 'src/enum';
|
||||||
import { SmartInfoService } from 'src/services/smart-info.service';
|
import { SmartInfoService } from 'src/services/smart-info.service';
|
||||||
import { getCLIPModelInfo } from 'src/utils/misc';
|
import { getCLIPModelInfo } from 'src/utils/misc';
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { SystemConfig } from 'src/config';
|
import { SystemConfig } from 'src/dtos/config.dto';
|
||||||
|
|
||||||
import { OnEvent, OnJob } from 'src/decorators';
|
import { OnEvent, OnJob } from 'src/decorators';
|
||||||
import { AssetVisibility, DatabaseLock, ImmichWorker, JobName, JobStatus, QueueName } from 'src/enum';
|
import { AssetVisibility, DatabaseLock, ImmichWorker, JobName, JobStatus, QueueName } from 'src/enum';
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Stats } from 'node:fs';
|
import { Stats } from 'node:fs';
|
||||||
import { defaults, SystemConfig } from 'src/config';
|
import { defaults, SystemConfig } from 'src/dtos/config.dto';
|
||||||
import { AssetPathType, AssetType, JobStatus } from 'src/enum';
|
import { AssetPathType, AssetType, JobStatus } from 'src/enum';
|
||||||
import { StorageTemplateService } from 'src/services/storage-template.service';
|
import { StorageTemplateService } from 'src/services/storage-template.service';
|
||||||
import { AlbumFactory } from 'test/factories/album.factory';
|
import { AlbumFactory } from 'test/factories/album.factory';
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import path from 'node:path';
|
|||||||
import sanitize from 'sanitize-filename';
|
import sanitize from 'sanitize-filename';
|
||||||
import { StorageCore } from 'src/cores/storage.core';
|
import { StorageCore } from 'src/cores/storage.core';
|
||||||
import { OnEvent, OnJob } from 'src/decorators';
|
import { OnEvent, OnJob } from 'src/decorators';
|
||||||
import { SystemConfigTemplateStorageOptionDto } from 'src/dtos/system-config.dto';
|
import { ConfigTemplateStorageOptionDto } from 'src/dtos/config.dto';
|
||||||
import {
|
import {
|
||||||
AssetFileType,
|
AssetFileType,
|
||||||
AssetPathType,
|
AssetPathType,
|
||||||
@@ -129,7 +129,7 @@ export class StorageTemplateService extends BaseService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
getStorageTemplateOptions(): SystemConfigTemplateStorageOptionDto {
|
getStorageTemplateOptions(): ConfigTemplateStorageOptionDto {
|
||||||
return { ...storageTokens, presetOptions: storagePresets };
|
return { ...storageTokens, presetOptions: storagePresets };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { BadRequestException } from '@nestjs/common';
|
import { BadRequestException } from '@nestjs/common';
|
||||||
import { defaults, SystemConfig } from 'src/config';
|
import { defaults, SystemConfig } from 'src/dtos/config.dto';
|
||||||
import { ReleaseChannel } from 'src/dtos/system-config.dto';
|
|
||||||
import {
|
import {
|
||||||
AudioCodec,
|
AudioCodec,
|
||||||
Colorspace,
|
Colorspace,
|
||||||
@@ -10,6 +9,7 @@ import {
|
|||||||
LogLevel,
|
LogLevel,
|
||||||
OAuthTokenEndpointAuthMethod,
|
OAuthTokenEndpointAuthMethod,
|
||||||
QueueName,
|
QueueName,
|
||||||
|
ReleaseChannel,
|
||||||
ToneMapping,
|
ToneMapping,
|
||||||
TranscodeHardwareAcceleration,
|
TranscodeHardwareAcceleration,
|
||||||
TranscodePolicy,
|
TranscodePolicy,
|
||||||
@@ -269,7 +269,7 @@ describe(SystemConfigService.name, () => {
|
|||||||
it('should return the default config', () => {
|
it('should return the default config', () => {
|
||||||
mocks.systemMetadata.get.mockResolvedValue(partialConfig);
|
mocks.systemMetadata.get.mockResolvedValue(partialConfig);
|
||||||
|
|
||||||
expect(sut.getDefaults()).toEqual(defaults);
|
expect(sut.getAdminConfigDefaults()).toEqual(defaults);
|
||||||
expect(mocks.systemMetadata.get).not.toHaveBeenCalled();
|
expect(mocks.systemMetadata.get).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -278,7 +278,7 @@ describe(SystemConfigService.name, () => {
|
|||||||
it('should return the default config', async () => {
|
it('should return the default config', async () => {
|
||||||
mocks.systemMetadata.get.mockResolvedValue({});
|
mocks.systemMetadata.get.mockResolvedValue({});
|
||||||
|
|
||||||
await expect(sut.getSystemConfig()).resolves.toEqual(defaults);
|
await expect(sut.getAdminConfig()).resolves.toEqual(defaults);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should merge the overrides', async () => {
|
it('should merge the overrides', async () => {
|
||||||
@@ -289,14 +289,14 @@ describe(SystemConfigService.name, () => {
|
|||||||
user: { deleteDelay: 15 },
|
user: { deleteDelay: 15 },
|
||||||
});
|
});
|
||||||
|
|
||||||
await expect(sut.getSystemConfig()).resolves.toEqual(updatedConfig);
|
await expect(sut.getAdminConfig()).resolves.toEqual(updatedConfig);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should load the config from a json file', async () => {
|
it('should load the config from a json file', async () => {
|
||||||
mocks.config.getEnv.mockReturnValue(mockEnvData({ configFile: 'immich-config.json' }));
|
mocks.config.getEnv.mockReturnValue(mockEnvData({ configFile: 'immich-config.json' }));
|
||||||
mocks.systemMetadata.readFile.mockResolvedValue(JSON.stringify(partialConfig));
|
mocks.systemMetadata.readFile.mockResolvedValue(JSON.stringify(partialConfig));
|
||||||
|
|
||||||
await expect(sut.getSystemConfig()).resolves.toEqual(updatedConfig);
|
await expect(sut.getAdminConfig()).resolves.toEqual(updatedConfig);
|
||||||
|
|
||||||
expect(mocks.systemMetadata.readFile).toHaveBeenCalledWith('immich-config.json');
|
expect(mocks.systemMetadata.readFile).toHaveBeenCalledWith('immich-config.json');
|
||||||
});
|
});
|
||||||
@@ -305,7 +305,7 @@ describe(SystemConfigService.name, () => {
|
|||||||
mocks.config.getEnv.mockReturnValue(mockEnvData({ configFile: 'immich-config.json' }));
|
mocks.config.getEnv.mockReturnValue(mockEnvData({ configFile: 'immich-config.json' }));
|
||||||
mocks.systemMetadata.readFile.mockResolvedValue(JSON.stringify({ ffmpeg: { twoPass: 'false' } }));
|
mocks.systemMetadata.readFile.mockResolvedValue(JSON.stringify({ ffmpeg: { twoPass: 'false' } }));
|
||||||
|
|
||||||
await expect(sut.getSystemConfig()).resolves.toMatchObject({
|
await expect(sut.getAdminConfig()).resolves.toMatchObject({
|
||||||
ffmpeg: expect.objectContaining({ twoPass: false }),
|
ffmpeg: expect.objectContaining({ twoPass: false }),
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -314,7 +314,7 @@ describe(SystemConfigService.name, () => {
|
|||||||
mocks.config.getEnv.mockReturnValue(mockEnvData({ configFile: 'immich-config.json' }));
|
mocks.config.getEnv.mockReturnValue(mockEnvData({ configFile: 'immich-config.json' }));
|
||||||
mocks.systemMetadata.readFile.mockResolvedValue(JSON.stringify({ ffmpeg: { threads: '42' } }));
|
mocks.systemMetadata.readFile.mockResolvedValue(JSON.stringify({ ffmpeg: { threads: '42' } }));
|
||||||
|
|
||||||
await expect(sut.getSystemConfig()).resolves.toMatchObject({
|
await expect(sut.getAdminConfig()).resolves.toMatchObject({
|
||||||
ffmpeg: expect.objectContaining({ threads: 42 }),
|
ffmpeg: expect.objectContaining({ threads: 42 }),
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -325,7 +325,7 @@ describe(SystemConfigService.name, () => {
|
|||||||
JSON.stringify({ library: { scan: { cronExpression: '0 0 */3 * *' } } }),
|
JSON.stringify({ library: { scan: { cronExpression: '0 0 */3 * *' } } }),
|
||||||
);
|
);
|
||||||
|
|
||||||
await expect(sut.getSystemConfig()).resolves.toMatchObject({
|
await expect(sut.getAdminConfig()).resolves.toMatchObject({
|
||||||
library: {
|
library: {
|
||||||
scan: {
|
scan: {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
@@ -339,7 +339,7 @@ describe(SystemConfigService.name, () => {
|
|||||||
mocks.config.getEnv.mockReturnValue(mockEnvData({ configFile: 'immich-config.json' }));
|
mocks.config.getEnv.mockReturnValue(mockEnvData({ configFile: 'immich-config.json' }));
|
||||||
mocks.systemMetadata.readFile.mockResolvedValue(JSON.stringify({ oauth: { issuerUrl: 'accounts.google.com' } }));
|
mocks.systemMetadata.readFile.mockResolvedValue(JSON.stringify({ oauth: { issuerUrl: 'accounts.google.com' } }));
|
||||||
|
|
||||||
await expect(sut.getSystemConfig()).rejects.toThrow(
|
await expect(sut.getAdminConfig()).rejects.toThrow(
|
||||||
'[oauth.issuerUrl] Issuer URL must be an empty string or a valid URL',
|
'[oauth.issuerUrl] Issuer URL must be an empty string or a valid URL',
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -348,7 +348,7 @@ describe(SystemConfigService.name, () => {
|
|||||||
mocks.config.getEnv.mockReturnValue(mockEnvData({ configFile: 'immich-config.json' }));
|
mocks.config.getEnv.mockReturnValue(mockEnvData({ configFile: 'immich-config.json' }));
|
||||||
mocks.systemMetadata.readFile.mockResolvedValue(JSON.stringify({ library: { scan: { cronExpression: 'foo' } } }));
|
mocks.systemMetadata.readFile.mockResolvedValue(JSON.stringify({ library: { scan: { cronExpression: 'foo' } } }));
|
||||||
|
|
||||||
await expect(sut.getSystemConfig()).rejects.toThrow('[library.scan.cronExpression] Invalid cron expression');
|
await expect(sut.getAdminConfig()).rejects.toThrow('[library.scan.cronExpression] Invalid cron expression');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should log errors with the config file', async () => {
|
it('should log errors with the config file', async () => {
|
||||||
@@ -356,7 +356,7 @@ describe(SystemConfigService.name, () => {
|
|||||||
|
|
||||||
mocks.systemMetadata.readFile.mockResolvedValue(`{ "ffmpeg2": true, "ffmpeg2": true }`);
|
mocks.systemMetadata.readFile.mockResolvedValue(`{ "ffmpeg2": true, "ffmpeg2": true }`);
|
||||||
|
|
||||||
await expect(sut.getSystemConfig()).rejects.toBeInstanceOf(Error);
|
await expect(sut.getAdminConfig()).rejects.toBeInstanceOf(Error);
|
||||||
|
|
||||||
expect(mocks.systemMetadata.readFile).toHaveBeenCalledWith('immich-config.json');
|
expect(mocks.systemMetadata.readFile).toHaveBeenCalledWith('immich-config.json');
|
||||||
expect(mocks.logger.error).toHaveBeenCalledTimes(2);
|
expect(mocks.logger.error).toHaveBeenCalledTimes(2);
|
||||||
@@ -380,7 +380,7 @@ describe(SystemConfigService.name, () => {
|
|||||||
`;
|
`;
|
||||||
mocks.systemMetadata.readFile.mockResolvedValue(partialConfig);
|
mocks.systemMetadata.readFile.mockResolvedValue(partialConfig);
|
||||||
|
|
||||||
await expect(sut.getSystemConfig()).resolves.toEqual(updatedConfig);
|
await expect(sut.getAdminConfig()).resolves.toEqual(updatedConfig);
|
||||||
|
|
||||||
expect(mocks.systemMetadata.readFile).toHaveBeenCalledWith('immich-config.yaml');
|
expect(mocks.systemMetadata.readFile).toHaveBeenCalledWith('immich-config.yaml');
|
||||||
});
|
});
|
||||||
@@ -389,7 +389,7 @@ describe(SystemConfigService.name, () => {
|
|||||||
mocks.config.getEnv.mockReturnValue(mockEnvData({ configFile: 'immich-config.json' }));
|
mocks.config.getEnv.mockReturnValue(mockEnvData({ configFile: 'immich-config.json' }));
|
||||||
mocks.systemMetadata.readFile.mockResolvedValue(JSON.stringify({}));
|
mocks.systemMetadata.readFile.mockResolvedValue(JSON.stringify({}));
|
||||||
|
|
||||||
await expect(sut.getSystemConfig()).resolves.toEqual(defaults);
|
await expect(sut.getAdminConfig()).resolves.toEqual(defaults);
|
||||||
|
|
||||||
expect(mocks.systemMetadata.readFile).toHaveBeenCalledWith('immich-config.json');
|
expect(mocks.systemMetadata.readFile).toHaveBeenCalledWith('immich-config.json');
|
||||||
});
|
});
|
||||||
@@ -399,7 +399,7 @@ describe(SystemConfigService.name, () => {
|
|||||||
const partialConfig = { machineLearning: { urls: ['immich_machine_learning'] } };
|
const partialConfig = { machineLearning: { urls: ['immich_machine_learning'] } };
|
||||||
mocks.systemMetadata.readFile.mockResolvedValue(JSON.stringify(partialConfig));
|
mocks.systemMetadata.readFile.mockResolvedValue(JSON.stringify(partialConfig));
|
||||||
|
|
||||||
const config = await sut.getSystemConfig();
|
const config = await sut.getAdminConfig();
|
||||||
expect(config.machineLearning.urls).toEqual(['immich_machine_learning']);
|
expect(config.machineLearning.urls).toEqual(['immich_machine_learning']);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -420,7 +420,7 @@ describe(SystemConfigService.name, () => {
|
|||||||
const partialConfig = { server: { externalDomain } };
|
const partialConfig = { server: { externalDomain } };
|
||||||
mocks.systemMetadata.readFile.mockResolvedValue(JSON.stringify(partialConfig));
|
mocks.systemMetadata.readFile.mockResolvedValue(JSON.stringify(partialConfig));
|
||||||
|
|
||||||
const config = await sut.getSystemConfig();
|
const config = await sut.getAdminConfig();
|
||||||
expect(config.server.externalDomain).toEqual(result ?? 'https://demo.immich.app');
|
expect(config.server.externalDomain).toEqual(result ?? 'https://demo.immich.app');
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -432,7 +432,7 @@ describe(SystemConfigService.name, () => {
|
|||||||
`;
|
`;
|
||||||
mocks.systemMetadata.readFile.mockResolvedValue(partialConfig);
|
mocks.systemMetadata.readFile.mockResolvedValue(partialConfig);
|
||||||
|
|
||||||
await sut.getSystemConfig();
|
await sut.getAdminConfig();
|
||||||
expect(mocks.logger.warn).toHaveBeenCalled();
|
expect(mocks.logger.warn).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -467,12 +467,12 @@ describe(SystemConfigService.name, () => {
|
|||||||
mocks.systemMetadata.readFile.mockResolvedValue(JSON.stringify(test.config));
|
mocks.systemMetadata.readFile.mockResolvedValue(JSON.stringify(test.config));
|
||||||
|
|
||||||
if (test.throws) {
|
if (test.throws) {
|
||||||
await expect(sut.getSystemConfig()).rejects.toThrow(test.throws);
|
await expect(sut.getAdminConfig()).rejects.toThrow(test.throws);
|
||||||
} else if (test.warn) {
|
} else if (test.warn) {
|
||||||
await sut.getSystemConfig();
|
await sut.getAdminConfig();
|
||||||
expect(mocks.logger.warn).toHaveBeenCalled();
|
expect(mocks.logger.warn).toHaveBeenCalled();
|
||||||
} else {
|
} else {
|
||||||
const config = await sut.getSystemConfig();
|
const config = await sut.getAdminConfig();
|
||||||
test.check!(config);
|
test.check!(config);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -482,7 +482,7 @@ describe(SystemConfigService.name, () => {
|
|||||||
describe('updateConfig', () => {
|
describe('updateConfig', () => {
|
||||||
it('should update the config and emit an event', async () => {
|
it('should update the config and emit an event', async () => {
|
||||||
mocks.systemMetadata.get.mockResolvedValue(partialConfig);
|
mocks.systemMetadata.get.mockResolvedValue(partialConfig);
|
||||||
await expect(sut.updateSystemConfig(updatedConfig)).resolves.toEqual(updatedConfig);
|
await expect(sut.updateAdminConfig(updatedConfig)).resolves.toEqual(updatedConfig);
|
||||||
expect(mocks.event.emit).toHaveBeenCalledWith(
|
expect(mocks.event.emit).toHaveBeenCalledWith(
|
||||||
'ConfigUpdate',
|
'ConfigUpdate',
|
||||||
expect.objectContaining({ oldConfig: expect.any(Object), newConfig: updatedConfig }),
|
expect.objectContaining({ oldConfig: expect.any(Object), newConfig: updatedConfig }),
|
||||||
@@ -492,7 +492,7 @@ describe(SystemConfigService.name, () => {
|
|||||||
it('should throw an error if a config file is in use', async () => {
|
it('should throw an error if a config file is in use', async () => {
|
||||||
mocks.config.getEnv.mockReturnValue(mockEnvData({ configFile: 'immich-config.json' }));
|
mocks.config.getEnv.mockReturnValue(mockEnvData({ configFile: 'immich-config.json' }));
|
||||||
mocks.systemMetadata.readFile.mockResolvedValue(JSON.stringify({}));
|
mocks.systemMetadata.readFile.mockResolvedValue(JSON.stringify({}));
|
||||||
await expect(sut.updateSystemConfig(defaults)).rejects.toBeInstanceOf(BadRequestException);
|
await expect(sut.updateAdminConfig(defaults)).rejects.toBeInstanceOf(BadRequestException);
|
||||||
expect(mocks.systemMetadata.set).not.toHaveBeenCalled();
|
expect(mocks.systemMetadata.set).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,8 +1,15 @@
|
|||||||
import { BadRequestException, Injectable } from '@nestjs/common';
|
import { BadRequestException, Injectable } from '@nestjs/common';
|
||||||
import _ from 'lodash';
|
import _ from 'lodash';
|
||||||
import { defaults } from 'src/config';
|
|
||||||
import { OnEvent } from 'src/decorators';
|
import { OnEvent } from 'src/decorators';
|
||||||
import { mapConfig, SystemConfigDto } from 'src/dtos/system-config.dto';
|
import {
|
||||||
|
AdminConfigDto,
|
||||||
|
defaults,
|
||||||
|
mapAdminConfig,
|
||||||
|
mapPublicConfig,
|
||||||
|
mapUserConfig,
|
||||||
|
PublicConfigDto,
|
||||||
|
UserConfigDto,
|
||||||
|
} from 'src/dtos/config.dto';
|
||||||
import { BootstrapEventPriority } from 'src/enum';
|
import { BootstrapEventPriority } from 'src/enum';
|
||||||
import { ArgOf } from 'src/repositories/event.repository';
|
import { ArgOf } from 'src/repositories/event.repository';
|
||||||
import { BaseService } from 'src/services/base.service';
|
import { BaseService } from 'src/services/base.service';
|
||||||
@@ -22,13 +29,31 @@ export class SystemConfigService extends BaseService {
|
|||||||
this.machineLearningRepository.teardown();
|
this.machineLearningRepository.teardown();
|
||||||
}
|
}
|
||||||
|
|
||||||
async getSystemConfig(): Promise<SystemConfigDto> {
|
async getAdminConfig(): Promise<AdminConfigDto> {
|
||||||
const config = await this.getConfig({ withCache: false });
|
const config = await this.getConfig({ withCache: false });
|
||||||
return mapConfig(config);
|
return mapAdminConfig(config);
|
||||||
}
|
}
|
||||||
|
|
||||||
getDefaults(): SystemConfigDto {
|
getAdminConfigDefaults(): AdminConfigDto {
|
||||||
return mapConfig(defaults);
|
return mapAdminConfig(defaults);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getUserConfig(): Promise<UserConfigDto> {
|
||||||
|
const config = await this.getConfig({ withCache: false });
|
||||||
|
return mapUserConfig(config);
|
||||||
|
}
|
||||||
|
|
||||||
|
getUserConfigDefaults(): UserConfigDto {
|
||||||
|
return mapUserConfig(defaults);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getPublicConfig(): Promise<PublicConfigDto> {
|
||||||
|
const config = await this.getConfig({ withCache: false });
|
||||||
|
return mapPublicConfig(config);
|
||||||
|
}
|
||||||
|
|
||||||
|
getPublicConfigDefaults(): PublicConfigDto {
|
||||||
|
return mapPublicConfig(defaults);
|
||||||
}
|
}
|
||||||
|
|
||||||
@OnEvent({ name: 'ConfigInit', priority: -100 })
|
@OnEvent({ name: 'ConfigInit', priority: -100 })
|
||||||
@@ -56,7 +81,7 @@ export class SystemConfigService extends BaseService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async updateSystemConfig(dto: SystemConfigDto): Promise<SystemConfigDto> {
|
async updateAdminConfig(dto: AdminConfigDto): Promise<AdminConfigDto> {
|
||||||
const { configFile } = this.configRepository.getEnv();
|
const { configFile } = this.configRepository.getEnv();
|
||||||
if (configFile) {
|
if (configFile) {
|
||||||
throw new BadRequestException('Cannot update configuration while IMMICH_CONFIG_FILE is in use');
|
throw new BadRequestException('Cannot update configuration while IMMICH_CONFIG_FILE is in use');
|
||||||
@@ -75,7 +100,7 @@ export class SystemConfigService extends BaseService {
|
|||||||
|
|
||||||
await this.eventRepository.emit('ConfigUpdate', { newConfig, oldConfig });
|
await this.eventRepository.emit('ConfigUpdate', { newConfig, oldConfig });
|
||||||
|
|
||||||
return mapConfig(newConfig);
|
return mapAdminConfig(newConfig);
|
||||||
}
|
}
|
||||||
|
|
||||||
async getCustomCss(): Promise<string> {
|
async getCustomCss(): Promise<string> {
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
import { DateTime } from 'luxon';
|
import { DateTime } from 'luxon';
|
||||||
import { SemVer } from 'semver';
|
import { SemVer } from 'semver';
|
||||||
import { defaults } from 'src/config';
|
import { defaults } from 'src/dtos/config.dto';
|
||||||
import { ReleaseChannel } from 'src/dtos/system-config.dto';
|
import { CronJob, JobName, JobStatus, ReleaseChannel, SystemMetadataKey } from 'src/enum';
|
||||||
import { CronJob, JobName, JobStatus, SystemMetadataKey } from 'src/enum';
|
|
||||||
import { VersionService } from 'src/services/version.service';
|
import { VersionService } from 'src/services/version.service';
|
||||||
import { factory } from 'test/small.factory';
|
import { factory } from 'test/small.factory';
|
||||||
import { newTestService, ServiceMocks } from 'test/utils';
|
import { newTestService, ServiceMocks } from 'test/utils';
|
||||||
|
|||||||
@@ -4,8 +4,16 @@ import semver, { SemVer } from 'semver';
|
|||||||
import { serverVersion } from 'src/constants';
|
import { serverVersion } from 'src/constants';
|
||||||
import { OnEvent, OnJob } from 'src/decorators';
|
import { OnEvent, OnJob } from 'src/decorators';
|
||||||
import { ReleaseEventV1, ReleaseType, ServerVersionResponseDto } from 'src/dtos/server.dto';
|
import { ReleaseEventV1, ReleaseType, ServerVersionResponseDto } from 'src/dtos/server.dto';
|
||||||
import { ReleaseChannel } from 'src/dtos/system-config.dto';
|
import {
|
||||||
import { CronJob, DatabaseLock, ImmichWorker, JobName, JobStatus, QueueName, SystemMetadataKey } from 'src/enum';
|
CronJob,
|
||||||
|
DatabaseLock,
|
||||||
|
ImmichWorker,
|
||||||
|
JobName,
|
||||||
|
JobStatus,
|
||||||
|
QueueName,
|
||||||
|
ReleaseChannel,
|
||||||
|
SystemMetadataKey,
|
||||||
|
} from 'src/enum';
|
||||||
import { ArgOf } from 'src/repositories/event.repository';
|
import { ArgOf } from 'src/repositories/event.repository';
|
||||||
import { BaseService } from 'src/services/base.service';
|
import { BaseService } from 'src/services/base.service';
|
||||||
import { VersionCheckMetadata } from 'src/types';
|
import { VersionCheckMetadata } from 'src/types';
|
||||||
|
|||||||
+5
-5
@@ -1,9 +1,9 @@
|
|||||||
import { ShallowDehydrateObject } from 'kysely';
|
import { ShallowDehydrateObject } from 'kysely';
|
||||||
import { SystemConfig } from 'src/config';
|
|
||||||
import { VECTOR_EXTENSIONS } from 'src/constants';
|
import { VECTOR_EXTENSIONS } from 'src/constants';
|
||||||
import { AssetFile } from 'src/database';
|
import { AssetFile } from 'src/database';
|
||||||
import { UploadFieldName } from 'src/dtos/asset-media.dto';
|
import { UploadFieldName } from 'src/dtos/asset-media.dto';
|
||||||
import { AuthDto } from 'src/dtos/auth.dto';
|
import { AuthDto } from 'src/dtos/auth.dto';
|
||||||
|
import { SystemConfig } from 'src/dtos/config.dto';
|
||||||
import { AssetEditActionItem } from 'src/dtos/editing.dto';
|
import { AssetEditActionItem } from 'src/dtos/editing.dto';
|
||||||
import { SetMaintenanceModeDto } from 'src/dtos/maintenance.dto';
|
import { SetMaintenanceModeDto } from 'src/dtos/maintenance.dto';
|
||||||
import {
|
import {
|
||||||
@@ -34,10 +34,10 @@ import { Mocked } from 'vitest';
|
|||||||
|
|
||||||
export type DeepPartial<T> = T extends Date
|
export type DeepPartial<T> = T extends Date
|
||||||
? T
|
? T
|
||||||
: T extends Record<string, unknown>
|
: T extends Array<infer R>
|
||||||
? { [K in keyof T]?: DeepPartial<T[K]> }
|
? DeepPartial<R>[]
|
||||||
: T extends Array<infer R>
|
: T extends object
|
||||||
? DeepPartial<R>[]
|
? { [K in keyof T]?: DeepPartial<T[K]> }
|
||||||
: T;
|
: T;
|
||||||
|
|
||||||
export type RepositoryInterface<T extends object> = Pick<T, keyof T>;
|
export type RepositoryInterface<T extends object> = Pick<T, keyof T>;
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
import AsyncLock from 'async-lock';
|
import AsyncLock from 'async-lock';
|
||||||
import { load as loadYaml } from 'js-yaml';
|
import { load as loadYaml } from 'js-yaml';
|
||||||
import * as _ from 'lodash';
|
import * as _ from 'lodash';
|
||||||
import { SystemConfig, defaults } from 'src/config';
|
import { AdminConfigDto, SystemConfig, defaults } from 'src/dtos/config.dto';
|
||||||
import { SystemConfigSchema } from 'src/dtos/system-config.dto';
|
|
||||||
import { DatabaseLock, SystemMetadataKey } from 'src/enum';
|
import { DatabaseLock, SystemMetadataKey } from 'src/enum';
|
||||||
import { ConfigRepository } from 'src/repositories/config.repository';
|
import { ConfigRepository } from 'src/repositories/config.repository';
|
||||||
import { LoggingRepository } from 'src/repositories/logging.repository';
|
import { LoggingRepository } from 'src/repositories/logging.repository';
|
||||||
@@ -100,7 +99,7 @@ const buildConfig = async (repos: RepoDeps) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// validate with Zod schema
|
// validate with Zod schema
|
||||||
const result = SystemConfigSchema.safeParse(rawConfig);
|
const result = AdminConfigDto.schema.safeParse(rawConfig);
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
const messages = ['Invalid system config: '];
|
const messages = ['Invalid system config: '];
|
||||||
for (const issue of result.error.issues) {
|
for (const issue of result.error.issues) {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { AUDIO_ENCODER, AV1_LEVELS, CodecLevel, H264_LEVELS, HEVC_LEVELS, SUPPORTED_HWA_CODECS } from 'src/constants';
|
import { AUDIO_ENCODER, AV1_LEVELS, CodecLevel, H264_LEVELS, HEVC_LEVELS, SUPPORTED_HWA_CODECS } from 'src/constants';
|
||||||
import { SystemConfigFFmpegDto } from 'src/dtos/system-config.dto';
|
import { ConfigFFmpegDto } from 'src/dtos/config.dto';
|
||||||
import {
|
import {
|
||||||
ColorMatrix,
|
ColorMatrix,
|
||||||
ColorPrimaries,
|
ColorPrimaries,
|
||||||
@@ -62,18 +62,18 @@ export const getCodecString = (codec: VideoCodec, width: number, height: number,
|
|||||||
export class BaseConfig implements VideoCodecSWConfig {
|
export class BaseConfig implements VideoCodecSWConfig {
|
||||||
readonly presets = ['veryslow', 'slower', 'slow', 'medium', 'fast', 'faster', 'veryfast', 'superfast', 'ultrafast'];
|
readonly presets = ['veryslow', 'slower', 'slow', 'medium', 'fast', 'faster', 'veryfast', 'superfast', 'ultrafast'];
|
||||||
protected constructor(
|
protected constructor(
|
||||||
protected config: SystemConfigFFmpegDto,
|
protected config: ConfigFFmpegDto,
|
||||||
protected tune: VideoTuning = { strictGop: false, lowLatency: false },
|
protected tune: VideoTuning = { strictGop: false, lowLatency: false },
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
static create(config: SystemConfigFFmpegDto, interfaces: VideoInterfaces, tune?: VideoTuning) {
|
static create(config: ConfigFFmpegDto, interfaces: VideoInterfaces, tune?: VideoTuning) {
|
||||||
if (config.accel === TranscodeHardwareAcceleration.Disabled) {
|
if (config.accel === TranscodeHardwareAcceleration.Disabled) {
|
||||||
return BaseConfig.getSWCodecConfig(config, tune);
|
return BaseConfig.getSWCodecConfig(config, tune);
|
||||||
}
|
}
|
||||||
return BaseConfig.getHWCodecConfig(config, interfaces, tune);
|
return BaseConfig.getHWCodecConfig(config, interfaces, tune);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static getSWCodecConfig(config: SystemConfigFFmpegDto, tune?: VideoTuning): VideoCodecSWConfig {
|
private static getSWCodecConfig(config: ConfigFFmpegDto, tune?: VideoTuning): VideoCodecSWConfig {
|
||||||
switch (config.targetVideoCodec) {
|
switch (config.targetVideoCodec) {
|
||||||
case VideoCodec.H264: {
|
case VideoCodec.H264: {
|
||||||
return new H264Config(config, tune);
|
return new H264Config(config, tune);
|
||||||
@@ -93,7 +93,7 @@ export class BaseConfig implements VideoCodecSWConfig {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static getHWCodecConfig(config: SystemConfigFFmpegDto, interfaces: VideoInterfaces, tune?: VideoTuning) {
|
private static getHWCodecConfig(config: ConfigFFmpegDto, interfaces: VideoInterfaces, tune?: VideoTuning) {
|
||||||
if (!SUPPORTED_HWA_CODECS[config.accel].includes(config.targetVideoCodec)) {
|
if (!SUPPORTED_HWA_CODECS[config.accel].includes(config.targetVideoCodec)) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`${config.accel.toUpperCase()} acceleration does not support codec '${config.targetVideoCodec.toUpperCase()}'. Supported codecs: ${SUPPORTED_HWA_CODECS[config.accel]}`,
|
`${config.accel.toUpperCase()} acceleration does not support codec '${config.targetVideoCodec.toUpperCase()}'. Supported codecs: ${SUPPORTED_HWA_CODECS[config.accel]}`,
|
||||||
@@ -424,7 +424,7 @@ export class BaseHWConfig extends BaseConfig {
|
|||||||
protected device: string;
|
protected device: string;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
protected config: SystemConfigFFmpegDto,
|
protected config: ConfigFFmpegDto,
|
||||||
protected interfaces: VideoInterfaces,
|
protected interfaces: VideoInterfaces,
|
||||||
tune?: VideoTuning,
|
tune?: VideoTuning,
|
||||||
) {
|
) {
|
||||||
@@ -471,7 +471,7 @@ export class BaseHWConfig extends BaseConfig {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export class ThumbnailConfig extends BaseConfig {
|
export class ThumbnailConfig extends BaseConfig {
|
||||||
static create(config: SystemConfigFFmpegDto): VideoCodecSWConfig {
|
static create(config: ConfigFFmpegDto): VideoCodecSWConfig {
|
||||||
return new ThumbnailConfig(config);
|
return new ThumbnailConfig(config);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,9 +13,9 @@ import { writeFileSync } from 'node:fs';
|
|||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import picomatch from 'picomatch';
|
import picomatch from 'picomatch';
|
||||||
import parse from 'picomatch/lib/parse';
|
import parse from 'picomatch/lib/parse';
|
||||||
import { SystemConfig } from 'src/config';
|
|
||||||
import { CLIP_MODEL_INFO, JOBS_ASSET_PAGINATION_SIZE, endpointTags, serverVersion } from 'src/constants';
|
import { CLIP_MODEL_INFO, JOBS_ASSET_PAGINATION_SIZE, endpointTags, serverVersion } from 'src/constants';
|
||||||
import { extraModels } from 'src/decorators';
|
import { extraModels } from 'src/decorators';
|
||||||
|
import { SystemConfig } from 'src/dtos/config.dto';
|
||||||
import { ApiCustomExtension, ImmichCookie, ImmichHeader, MetadataKey } from 'src/enum';
|
import { ApiCustomExtension, ImmichCookie, ImmichHeader, MetadataKey } from 'src/enum';
|
||||||
import { LoggingRepository } from 'src/repositories/logging.repository';
|
import { LoggingRepository } from 'src/repositories/logging.repository';
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { join } from 'node:path';
|
import { join } from 'node:path';
|
||||||
import { SystemConfig } from 'src/config';
|
|
||||||
import { StorageCore } from 'src/cores/storage.core';
|
import { StorageCore } from 'src/cores/storage.core';
|
||||||
|
import { SystemConfig } from 'src/dtos/config.dto';
|
||||||
import { StorageFolder } from 'src/enum';
|
import { StorageFolder } from 'src/enum';
|
||||||
import { CryptoRepository } from 'src/repositories/crypto.repository';
|
import { CryptoRepository } from 'src/repositories/crypto.repository';
|
||||||
import { MediaRepository } from 'src/repositories/media.repository';
|
import { MediaRepository } from 'src/repositories/media.repository';
|
||||||
|
|||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
import { SystemConfig } from 'src/config';
|
import { SystemConfig } from 'src/dtos/config.dto';
|
||||||
import { DeepPartial } from 'src/types';
|
import { DeepPartial } from 'src/types';
|
||||||
|
|
||||||
export const systemConfigStub = {
|
export const systemConfigStub = {
|
||||||
|
|||||||
@@ -4,9 +4,9 @@ import { createHash, randomBytes } from 'node:crypto';
|
|||||||
import { Stats } from 'node:fs';
|
import { Stats } from 'node:fs';
|
||||||
import { resolve } from 'node:path';
|
import { resolve } from 'node:path';
|
||||||
import { Writable } from 'node:stream';
|
import { Writable } from 'node:stream';
|
||||||
import { SystemConfig } from 'src/config';
|
|
||||||
import { AssetFace } from 'src/database';
|
import { AssetFace } from 'src/database';
|
||||||
import { AuthDto, LoginResponseDto } from 'src/dtos/auth.dto';
|
import { AuthDto, LoginResponseDto } from 'src/dtos/auth.dto';
|
||||||
|
import { SystemConfig } from 'src/dtos/config.dto';
|
||||||
import { AssetEditActionItem, AssetEditsCreateDto } from 'src/dtos/editing.dto';
|
import { AssetEditActionItem, AssetEditsCreateDto } from 'src/dtos/editing.dto';
|
||||||
import {
|
import {
|
||||||
AlbumUserRole,
|
AlbumUserRole,
|
||||||
|
|||||||
@@ -95,7 +95,9 @@ export type ControllerContext = {
|
|||||||
close: () => Promise<void>;
|
close: () => Promise<void>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const controllerSetup = async (controller: new (...args: any[]) => unknown, providers: Provider[]) => {
|
type ControllerClass = new (...args: any[]) => unknown;
|
||||||
|
|
||||||
|
export const controllerSetup = async (controller: ControllerClass | ControllerClass[], providers: Provider[]) => {
|
||||||
const noopInterceptor = { intercept: (ctx: never, next: CallHandler<unknown>) => next.handle() };
|
const noopInterceptor = { intercept: (ctx: never, next: CallHandler<unknown>) => next.handle() };
|
||||||
const upload = multer({ storage: multer.memoryStorage() });
|
const upload = multer({ storage: multer.memoryStorage() });
|
||||||
const memoryFileInterceptor = {
|
const memoryFileInterceptor = {
|
||||||
@@ -117,7 +119,7 @@ export const controllerSetup = async (controller: new (...args: any[]) => unknow
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
const moduleRef = await Test.createTestingModule({
|
const moduleRef = await Test.createTestingModule({
|
||||||
controllers: [controller],
|
controllers: Array.isArray(controller) ? controller : [controller],
|
||||||
providers: [
|
providers: [
|
||||||
{ provide: APP_FILTER, useClass: GlobalExceptionFilter },
|
{ provide: APP_FILTER, useClass: GlobalExceptionFilter },
|
||||||
{ provide: APP_PIPE, useClass: ZodValidationPipe },
|
{ provide: APP_PIPE, useClass: ZodValidationPipe },
|
||||||
|
|||||||
@@ -1,15 +1,15 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { systemConfigManager } from '$lib/managers/system-config-manager.svelte';
|
import { systemConfigManager } from '$lib/managers/system-config-manager.svelte';
|
||||||
import { handleSystemConfigSave } from '$lib/services/system-config.service';
|
import { handleSystemConfigSave } from '$lib/services/system-config.service';
|
||||||
import type { SystemConfigDto } from '@immich/sdk';
|
import type { AdminConfigDto } from '@immich/sdk';
|
||||||
import { Button, toastManager } from '@immich/ui';
|
import { Button, toastManager } from '@immich/ui';
|
||||||
import { isEqual, pick } from 'lodash-es';
|
import { isEqual, pick } from 'lodash-es';
|
||||||
import { t } from 'svelte-i18n';
|
import { t } from 'svelte-i18n';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
keys: Array<keyof SystemConfigDto>;
|
keys: Array<keyof AdminConfigDto>;
|
||||||
configToEdit: SystemConfigDto;
|
configToEdit: AdminConfigDto;
|
||||||
onBeforeSave?: () => Promise<boolean>;
|
onBeforeSave?: () => Promise<boolean>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import type {
|
|||||||
QueueResponseDto,
|
QueueResponseDto,
|
||||||
ReleaseEventV1,
|
ReleaseEventV1,
|
||||||
SharedLinkResponseDto,
|
SharedLinkResponseDto,
|
||||||
SystemConfigDto,
|
AdminConfigDto,
|
||||||
TagResponseDto,
|
TagResponseDto,
|
||||||
UserAdminResponseDto,
|
UserAdminResponseDto,
|
||||||
WorkflowResponseDto,
|
WorkflowResponseDto,
|
||||||
@@ -81,7 +81,7 @@ export type Events = {
|
|||||||
SessionLocked: [];
|
SessionLocked: [];
|
||||||
SessionDelete: [];
|
SessionDelete: [];
|
||||||
|
|
||||||
SystemConfigUpdate: [SystemConfigDto];
|
SystemConfigUpdate: [AdminConfigDto];
|
||||||
|
|
||||||
IntegrityReportDeleteStatus: [{ type?: IntegrityReport; id?: string; isDeleting: boolean }];
|
IntegrityReportDeleteStatus: [{ type?: IntegrityReport; id?: string; isDeleting: boolean }];
|
||||||
IntegrityReportDeleted: [{ type?: IntegrityReport; id?: string }];
|
IntegrityReportDeleted: [{ type?: IntegrityReport; id?: string }];
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import { getConfig, getConfigDefaults, type SystemConfigDto } from '@immich/sdk';
|
import { getConfig, getConfigDefaults, type AdminConfigDto } from '@immich/sdk';
|
||||||
import { cloneDeep } from 'lodash-es';
|
import { cloneDeep } from 'lodash-es';
|
||||||
import { eventManager } from '$lib/managers/event-manager.svelte';
|
import { eventManager } from '$lib/managers/event-manager.svelte';
|
||||||
|
|
||||||
class SystemConfigManager {
|
class SystemConfigManager {
|
||||||
#value?: SystemConfigDto = $state();
|
#value?: AdminConfigDto = $state();
|
||||||
#defaultValue?: SystemConfigDto = $state();
|
#defaultValue?: AdminConfigDto = $state();
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
eventManager.on({
|
eventManager.on({
|
||||||
@@ -25,7 +25,7 @@ class SystemConfigManager {
|
|||||||
return this.#value;
|
return this.#value;
|
||||||
}
|
}
|
||||||
|
|
||||||
set value(config: SystemConfigDto) {
|
set value(config: AdminConfigDto) {
|
||||||
this.#value = config;
|
this.#value = config;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { getConfig, updateConfig, type ServerFeaturesDto, type SystemConfigDto } from '@immich/sdk';
|
import { getConfig, updateConfig, type ServerFeaturesDto, type AdminConfigDto } from '@immich/sdk';
|
||||||
import { toastManager, type ActionItem } from '@immich/ui';
|
import { toastManager, type ActionItem } from '@immich/ui';
|
||||||
import { mdiContentCopy, mdiDownload, mdiUpload } from '@mdi/js';
|
import { mdiContentCopy, mdiDownload, mdiUpload } from '@mdi/js';
|
||||||
import { isEqual } from 'lodash-es';
|
import { isEqual } from 'lodash-es';
|
||||||
@@ -11,7 +11,7 @@ import { getFormatter } from '$lib/utils/i18n';
|
|||||||
export const getSystemConfigActions = (
|
export const getSystemConfigActions = (
|
||||||
$t: MessageFormatter,
|
$t: MessageFormatter,
|
||||||
featureFlags: ServerFeaturesDto,
|
featureFlags: ServerFeaturesDto,
|
||||||
config: SystemConfigDto,
|
config: AdminConfigDto,
|
||||||
) => {
|
) => {
|
||||||
const CopyToClipboard: ActionItem = {
|
const CopyToClipboard: ActionItem = {
|
||||||
title: $t('copy_to_clipboard'),
|
title: $t('copy_to_clipboard'),
|
||||||
@@ -44,17 +44,17 @@ export const getSystemConfigActions = (
|
|||||||
return { CopyToClipboard, Download, Upload };
|
return { CopyToClipboard, Download, Upload };
|
||||||
};
|
};
|
||||||
|
|
||||||
export const handleSystemConfigSave = async (update: Partial<SystemConfigDto>) => {
|
export const handleSystemConfigSave = async (update: Partial<AdminConfigDto>) => {
|
||||||
const $t = await getFormatter();
|
const $t = await getFormatter();
|
||||||
const config = await getConfig();
|
const config = await getConfig();
|
||||||
const systemConfigDto = { ...config, ...update };
|
const adminConfigDto = { ...config, ...update };
|
||||||
|
|
||||||
if (isEqual(config, systemConfigDto)) {
|
if (isEqual(config, adminConfigDto)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const newConfig = await updateConfig({ systemConfigDto });
|
const newConfig = await updateConfig({ adminConfigDto });
|
||||||
|
|
||||||
eventManager.emit('SystemConfigUpdate', newConfig);
|
eventManager.emit('SystemConfigUpdate', newConfig);
|
||||||
toastManager.primary($t('settings_saved'));
|
toastManager.primary($t('settings_saved'));
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
import { SettingInputFieldType } from '$lib/constants';
|
import { SettingInputFieldType } from '$lib/constants';
|
||||||
import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte';
|
import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte';
|
||||||
import { systemConfigManager } from '$lib/managers/system-config-manager.svelte';
|
import { systemConfigManager } from '$lib/managers/system-config-manager.svelte';
|
||||||
import { QueueName, type SystemConfigJobDto } from '@immich/sdk';
|
import { QueueName, type AdminConfigJobDto } from '@immich/sdk';
|
||||||
import { t } from 'svelte-i18n';
|
import { t } from 'svelte-i18n';
|
||||||
import { fade } from 'svelte/transition';
|
import { fade } from 'svelte/transition';
|
||||||
|
|
||||||
@@ -26,7 +26,7 @@
|
|||||||
QueueName.Ocr,
|
QueueName.Ocr,
|
||||||
];
|
];
|
||||||
|
|
||||||
function isSystemConfigJobDto(jobName: string): jobName is keyof SystemConfigJobDto {
|
function isSystemConfigJobDto(jobName: string): jobName is keyof AdminConfigJobDto {
|
||||||
return Object.hasOwn(configToEdit.job, jobName);
|
return Object.hasOwn(configToEdit.job, jobName);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -30,7 +30,7 @@
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
await sendTestEmailAdmin({
|
await sendTestEmailAdmin({
|
||||||
systemConfigSmtpDto: {
|
adminConfigSmtpDto: {
|
||||||
enabled: configToEdit.notifications.smtp.enabled,
|
enabled: configToEdit.notifications.smtp.enabled,
|
||||||
transport: {
|
transport: {
|
||||||
host: configToEdit.notifications.smtp.transport.host,
|
host: configToEdit.notifications.smtp.transport.host,
|
||||||
|
|||||||
@@ -5,14 +5,14 @@
|
|||||||
import { systemConfigManager } from '$lib/managers/system-config-manager.svelte';
|
import { systemConfigManager } from '$lib/managers/system-config-manager.svelte';
|
||||||
import EmailTemplatePreviewModal from '$lib/modals/EmailTemplatePreviewModal.svelte';
|
import EmailTemplatePreviewModal from '$lib/modals/EmailTemplatePreviewModal.svelte';
|
||||||
import { handleError } from '$lib/utils/handle-error';
|
import { handleError } from '$lib/utils/handle-error';
|
||||||
import { type SystemConfigDto, type SystemConfigTemplateEmailsDto, getNotificationTemplateAdmin } from '@immich/sdk';
|
import { type AdminConfigDto, type AdminConfigTemplateEmailsDto, getNotificationTemplateAdmin } from '@immich/sdk';
|
||||||
import { Button, Icon, LoadingSpinner, modalManager } from '@immich/ui';
|
import { Button, Icon, LoadingSpinner, modalManager } from '@immich/ui';
|
||||||
import { mdiEyeOutline } from '@mdi/js';
|
import { mdiEyeOutline } from '@mdi/js';
|
||||||
import { t } from 'svelte-i18n';
|
import { t } from 'svelte-i18n';
|
||||||
import { fade } from 'svelte/transition';
|
import { fade } from 'svelte/transition';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
config: SystemConfigDto;
|
config: AdminConfigDto;
|
||||||
}
|
}
|
||||||
|
|
||||||
let { config = $bindable() }: Props = $props();
|
let { config = $bindable() }: Props = $props();
|
||||||
@@ -52,7 +52,7 @@
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const isEdited = (templateKey: keyof SystemConfigTemplateEmailsDto) =>
|
const isEdited = (templateKey: keyof AdminConfigTemplateEmailsDto) =>
|
||||||
config.templates.email[templateKey] !== systemConfigManager.value.templates.email[templateKey];
|
config.templates.email[templateKey] !== systemConfigManager.value.templates.email[templateKey];
|
||||||
|
|
||||||
const onsubmit = (event: Event) => {
|
const onsubmit = (event: Event) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user