feat: new config endpoints (#30881)

* refactor: config endpoints

* chore: pr feedback
This commit is contained in:
Jason Rasmussen
2026-08-20 11:46:17 -04:00
committed by GitHub
parent 47c5a3dbf6
commit e529557160
60 changed files with 3974 additions and 2755 deletions
+1 -1
View File
@@ -45,7 +45,7 @@ describe('/jobs', () => {
config.machineLearning.enabled = false;
config.metadata.faces.import = 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 () => {
+3 -3
View File
@@ -1,7 +1,7 @@
import { OAuthClient, OAuthUser, generateLogoutToken } from '@immich/e2e-auth-server';
import {
AdminConfigOAuthDto,
LoginResponseDto,
SystemConfigOAuthDto,
getConfigDefaults,
getMyUser,
getSessions,
@@ -70,7 +70,7 @@ const loginWithOAuth = async (sub: OAuthUser | string, redirectUri?: string) =>
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 defaults = await getConfigDefaults(options);
const merged = {
@@ -80,7 +80,7 @@ const setupOAuth = async (token: string, dto: Partial<SystemConfigOAuthDto>) =>
allowInsecureRequests: true,
...dto,
};
await updateConfig({ systemConfigDto: { ...defaults, oauth: merged } }, options);
await updateConfig({ adminConfigDto: { ...defaults, oauth: merged } }, options);
};
describe(`/oauth`, () => {
+1 -1
View File
@@ -647,7 +647,7 @@ export const utils = {
resetAdminConfig: async (accessToken: string) => {
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) => {
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
-449
View File
@@ -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,
},
});
+3
View File
@@ -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.Authentication]: 'Endpoints related to user authentication, including OAuth.',
[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.Deprecated]: 'Deprecated endpoints that are planned for removal in the next major release.',
[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
View File
@@ -6,6 +6,9 @@ import { AssetMediaController } from 'src/controllers/asset-media.controller';
import { AssetController } from 'src/controllers/asset.controller';
import { AuthAdminController } from 'src/controllers/auth-admin.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 { DownloadController } from 'src/controllers/download.controller';
import { DuplicateController } from 'src/controllers/duplicate.controller';
@@ -49,6 +52,9 @@ export const controllers = [
AssetMediaController,
AuthController,
AuthAdminController,
ConfigUserController,
ConfigAdminController,
ConfigPublicController,
DatabaseBackupController,
DownloadController,
DuplicateController,
@@ -2,6 +2,7 @@ import { Body, Controller, HttpCode, HttpStatus, Param, Post } from '@nestjs/com
import { ApiTags } from '@nestjs/swagger';
import { Endpoint, HistoryBuilder } from 'src/decorators';
import { AuthDto } from 'src/dtos/auth.dto';
import { SystemConfigSmtpDto } from 'src/dtos/config.dto';
import {
NotificationCreateDto,
NotificationDto,
@@ -9,7 +10,6 @@ import {
TemplateResponseDto,
TestEmailResponseDto,
} from 'src/dtos/notification.dto';
import { SystemConfigSmtpDto } from 'src/dtos/system-config.dto';
import { ApiTag } from 'src/enum';
import { Auth, Authenticated } from 'src/middleware/auth.guard';
import { EmailTemplate } from 'src/repositories/email.repository';
+10 -2
View File
@@ -101,7 +101,11 @@ export class ServerController {
@Endpoint({
summary: 'Get features',
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> {
return this.service.getFeatures();
@@ -112,7 +116,11 @@ export class ServerController {
@Endpoint({
summary: 'Get config',
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> {
return this.service.getSystemConfig();
@@ -1,6 +1,6 @@
import _ from 'lodash';
import { defaults } from 'src/config';
import { SystemConfigController } from 'src/controllers/system-config.controller';
import { defaults } from 'src/dtos/config.dto';
import { StorageTemplateService } from 'src/services/storage-template.service';
import { SystemConfigService } from 'src/services/system-config.service';
import request from 'supertest';
@@ -1,7 +1,7 @@
import { Body, Controller, Get, Put } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
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 { Authenticated } from 'src/middleware/auth.guard';
import { StorageTemplateService } from 'src/services/storage-template.service';
@@ -20,10 +20,14 @@ export class SystemConfigController {
@Endpoint({
summary: 'Get 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> {
return this.service.getSystemConfig();
getConfig(): Promise<AdminConfigDto> {
return this.service.getAdminConfig();
}
@Get('defaults')
@@ -31,10 +35,14 @@ export class SystemConfigController {
@Endpoint({
summary: 'Get system configuration defaults',
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 {
return this.service.getDefaults();
getConfigDefaults(): AdminConfigDto {
return this.service.getAdminConfigDefaults();
}
@Put()
@@ -42,10 +50,14 @@ export class SystemConfigController {
@Endpoint({
summary: 'Update 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> {
return this.service.updateSystemConfig(dto);
updateConfig(@Body() dto: AdminConfigDto): Promise<AdminConfigDto> {
return this.service.updateAdminConfig(dto);
}
@Get('storage-template-options')
@@ -55,7 +67,7 @@ export class SystemConfigController {
description: 'Retrieve exemplary storage template options.',
history: new HistoryBuilder().added('v1').beta('v1').stable('v2'),
})
getStorageTemplateOptions(): SystemConfigTemplateStorageOptionDto {
getStorageTemplateOptions(): ConfigTemplateStorageOptionDto {
return this.storageTemplateService.getStorageTemplateOptions();
}
}
+68
View File
@@ -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');
}
});
});
+773
View File
@@ -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,
},
});
-57
View File
@@ -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) {}
-444
View File
@@ -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;
}
+21
View File
@@ -161,6 +161,11 @@ export enum Permission {
BackupUpload = 'backup.upload',
BackupDelete = 'backup.delete',
AdminConfigRead = 'adminConfig.read',
AdminConfigUpdate = 'adminConfig.update',
UserConfigRead = 'userConfig.read',
DuplicateRead = 'duplicate.read',
DuplicateDelete = 'duplicate.delete',
@@ -1165,12 +1170,25 @@ export const AssetVisibilitySchema = z
.describe('Asset visibility')
.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 {
LibraryScan = 'LibraryScan',
NightlyJobs = 'NightlyJobs',
VersionCheck = 'VersionCheck',
}
export enum ConfigVisibility {
Public = 'Public',
User = 'User',
Admin = 'Admin',
}
export enum ApiTag {
Activities = 'Activities',
Albums = 'Albums',
@@ -1178,6 +1196,9 @@ export enum ApiTag {
Authentication = 'Authentication',
AuthenticationAdmin = 'Authentication (admin)',
Assets = 'Assets',
ConfigUser = 'Config (user)',
ConfigAdmin = 'Config (admin)',
ConfigPublic = 'Config (public)',
DatabaseBackups = 'Database Backups (admin)',
Deprecated = 'Deprecated',
Download = 'Download',
+1 -1
View File
@@ -2,10 +2,10 @@ import { Injectable } from '@nestjs/common';
import { ModuleRef, Reflector } from '@nestjs/core';
import _ from 'lodash';
import { Socket } from 'socket.io';
import { SystemConfig } from 'src/config';
import { Asset } from 'src/database';
import { EventConfig } from 'src/decorators';
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 { ConfigRepository } from 'src/repositories/config.repository';
import { LoggingRepository } from 'src/repositories/logging.repository';
@@ -1,8 +1,7 @@
import { Injectable } from '@nestjs/common';
import { Duration } from 'luxon';
import { readFile } from 'node:fs/promises';
import { MachineLearningConfig } from 'src/config';
import { CLIPConfig } from 'src/dtos/model-config.dto';
import { MachineLearningConfig } from 'src/dtos/config.dto';
import { LoggingRepository } from 'src/repositories/logging.repository';
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 response = await this.predict<ClipVisualResponse>({ imagePath }, request);
return response[ModelTask.SEARCH];
@@ -4,7 +4,7 @@ import { exec as execCallback } from 'node:child_process';
import { readFile } from 'node:fs/promises';
import { promisify } from 'node:util';
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 { LoggingRepository } from 'src/repositories/logging.repository';
+1 -1
View File
@@ -1,10 +1,10 @@
import { BadRequestException, Injectable } from '@nestjs/common';
import { Insertable } from 'kysely';
import sanitize from 'sanitize-filename';
import { SystemConfig } from 'src/config';
import { SALT_ROUNDS } from 'src/constants';
import { StorageCore } from 'src/cores/storage.core';
import { UserAdmin } from 'src/database';
import { SystemConfig } from 'src/dtos/config.dto';
import { AccessRepository } from 'src/repositories/access.repository';
import { ActivityRepository } from 'src/repositories/activity.repository';
import { AlbumUserRepository } from 'src/repositories/album-user.repository';
@@ -1,8 +1,8 @@
import { BadRequestException } from '@nestjs/common';
import { DateTime } from 'luxon';
import { PassThrough, Readable } from 'node:stream';
import { defaults, SystemConfig } from 'src/config';
import { StorageCore } from 'src/cores/storage.core';
import { defaults, SystemConfig } from 'src/dtos/config.dto';
import { ImmichWorker, JobStatus, StorageFolder } from 'src/enum';
import { MaintenanceHealthRepository } from 'src/maintenance/maintenance-health.repository';
import { DatabaseBackupService } from 'src/services/database-backup.service';
+2 -2
View File
@@ -5,7 +5,7 @@ import { HLS_SEGMENT_DURATION, HLS_SEGMENT_FILENAME_REGEX, HLS_VARIANTS, HLS_VER
import { StorageCore } from 'src/cores/storage.core';
import { OnEvent } from 'src/decorators';
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 { ArgOf } from 'src/repositories/event.repository';
import { BaseService } from 'src/services/base.service';
@@ -124,7 +124,7 @@ export class HlsService extends BaseService {
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 roundedFps = fps.toFixed(3);
const sourceResolution = Math.min(asset.videoStream.height, asset.videoStream.width);
+1 -1
View File
@@ -1,7 +1,7 @@
import { BadRequestException } from '@nestjs/common';
import { Stats } from 'node:fs';
import { defaults, SystemConfig } from 'src/config';
import { JOBS_LIBRARY_PAGINATION_SIZE } from 'src/constants';
import { defaults, SystemConfig } from 'src/dtos/config.dto';
import { mapLibrary } from 'src/dtos/library.dto';
import { AssetType, CronJob, ImmichWorker, JobName, JobStatus } from 'src/enum';
import { LibraryService } from 'src/services/library.service';
+1 -1
View File
@@ -1,7 +1,7 @@
import { ShallowDehydrateObject } from 'kysely';
import { OutputInfo } from 'sharp';
import { SystemConfig } from 'src/config';
import { Exif } from 'src/database';
import { SystemConfig } from 'src/dtos/config.dto';
import { AssetEditAction } from 'src/dtos/editing.dto';
import {
AssetFileType,
+5 -6
View File
@@ -1,11 +1,10 @@
import { Injectable } from '@nestjs/common';
import { SystemConfig } from 'src/config';
import { FACE_THUMBNAIL_SIZE } from 'src/constants';
import { ImagePathOptions, StorageCore, ThumbnailPathEntity } from 'src/cores/storage.core';
import { AssetFile } from 'src/database';
import { OnEvent, OnJob } from 'src/decorators';
import { ConfigFFmpegDto, SystemConfig } from 'src/dtos/config.dto';
import { AssetEditAction, CropParameters } from 'src/dtos/editing.dto';
import { SystemConfigFFmpegDto } from 'src/dtos/system-config.dto';
import {
AssetFileType,
AssetType,
@@ -626,7 +625,7 @@ export class MediaService extends BaseService {
}
private getTranscodeTarget(
config: SystemConfigFFmpegDto,
config: ConfigFFmpegDto,
videoStream: VideoStreamInfo,
audioStream?: AudioStreamInfo,
): TranscodeTarget {
@@ -648,7 +647,7 @@ export class MediaService extends BaseService {
return TranscodeTarget.None;
}
private isAudioTranscodeRequired(ffmpegConfig: SystemConfigFFmpegDto, stream?: AudioStreamInfo): boolean {
private isAudioTranscodeRequired(ffmpegConfig: ConfigFFmpegDto, stream?: AudioStreamInfo): boolean {
if (!stream) {
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 targetRes = Number.parseInt(ffmpegConfig.targetResolution);
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) {
return false;
}
+1 -1
View File
@@ -2,7 +2,7 @@ import { BinaryField, ExifDateTime } from 'exiftool-vendored';
import { DateTime } from 'luxon';
import { randomBytes } from 'node:crypto';
import { Stats } from 'node:fs';
import { defaults } from 'src/config';
import { defaults } from 'src/dtos/config.dto';
import {
AssetFileType,
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 { NotificationService } from 'src/services/notification.service';
import { userStub } from 'test/fixtures/user.stub';
@@ -1,7 +1,7 @@
import { BadRequestException, Injectable } from '@nestjs/common';
import { AuthDto } from 'src/dtos/auth.dto';
import { SystemConfigSmtpDto } from 'src/dtos/config.dto';
import { mapNotification, NotificationCreateDto } from 'src/dtos/notification.dto';
import { SystemConfigSmtpDto } from 'src/dtos/system-config.dto';
import { NotificationLevel, NotificationType } from 'src/enum';
import { EmailTemplate } from 'src/repositories/email.repository';
import { BaseService } from 'src/services/base.service';
@@ -1,5 +1,4 @@
import { defaults, SystemConfig } from 'src/config';
import { SystemConfigDto } from 'src/dtos/system-config.dto';
import { AdminConfigDto, defaults, SystemConfig } from 'src/dtos/config.dto';
import { AssetFileType, JobName, JobStatus, UserMetadataKey } from 'src/enum';
import { NotificationService } from 'src/services/notification.service';
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 () => {
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();
expect(mocks.email.verifySmtp).not.toHaveBeenCalled();
+1 -1
View File
@@ -3,6 +3,7 @@ import { OnEvent, OnJob } from 'src/decorators';
import { MapAlbumDto } from 'src/dtos/album.dto';
import { mapAsset } from 'src/dtos/asset-response.dto';
import { AuthDto } from 'src/dtos/auth.dto';
import { SystemConfigSmtpDto } from 'src/dtos/config.dto';
import {
mapNotification,
NotificationDeleteAllDto,
@@ -11,7 +12,6 @@ import {
NotificationUpdateAllDto,
NotificationUpdateDto,
} from 'src/dtos/notification.dto';
import { SystemConfigSmtpDto } from 'src/dtos/system-config.dto';
import {
AssetFileType,
JobName,
+1 -1
View File
@@ -1,5 +1,5 @@
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 { QueueService } from 'src/services/queue.service';
import { factory } from 'test/small.factory';
+1 -1
View File
@@ -1,7 +1,7 @@
import { BadRequestException, Injectable } from '@nestjs/common';
import { SystemConfig } from 'src/config';
import { OnEvent } from 'src/decorators';
import { AuthDto } from 'src/dtos/auth.dto';
import { SystemConfig } from 'src/dtos/config.dto';
import {
mapQueueLegacy,
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 { SmartInfoService } from 'src/services/smart-info.service';
import { getCLIPModelInfo } from 'src/utils/misc';
+1 -1
View File
@@ -1,5 +1,5 @@
import { Injectable } from '@nestjs/common';
import { SystemConfig } from 'src/config';
import { SystemConfig } from 'src/dtos/config.dto';
import { OnEvent, OnJob } from 'src/decorators';
import { AssetVisibility, DatabaseLock, ImmichWorker, JobName, JobStatus, QueueName } from 'src/enum';
@@ -1,5 +1,5 @@
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 { StorageTemplateService } from 'src/services/storage-template.service';
import { AlbumFactory } from 'test/factories/album.factory';
@@ -5,7 +5,7 @@ import path from 'node:path';
import sanitize from 'sanitize-filename';
import { StorageCore } from 'src/cores/storage.core';
import { OnEvent, OnJob } from 'src/decorators';
import { SystemConfigTemplateStorageOptionDto } from 'src/dtos/system-config.dto';
import { ConfigTemplateStorageOptionDto } from 'src/dtos/config.dto';
import {
AssetFileType,
AssetPathType,
@@ -129,7 +129,7 @@ export class StorageTemplateService extends BaseService {
}
}
getStorageTemplateOptions(): SystemConfigTemplateStorageOptionDto {
getStorageTemplateOptions(): ConfigTemplateStorageOptionDto {
return { ...storageTokens, presetOptions: storagePresets };
}
@@ -1,6 +1,5 @@
import { BadRequestException } from '@nestjs/common';
import { defaults, SystemConfig } from 'src/config';
import { ReleaseChannel } from 'src/dtos/system-config.dto';
import { defaults, SystemConfig } from 'src/dtos/config.dto';
import {
AudioCodec,
Colorspace,
@@ -10,6 +9,7 @@ import {
LogLevel,
OAuthTokenEndpointAuthMethod,
QueueName,
ReleaseChannel,
ToneMapping,
TranscodeHardwareAcceleration,
TranscodePolicy,
@@ -269,7 +269,7 @@ describe(SystemConfigService.name, () => {
it('should return the default config', () => {
mocks.systemMetadata.get.mockResolvedValue(partialConfig);
expect(sut.getDefaults()).toEqual(defaults);
expect(sut.getAdminConfigDefaults()).toEqual(defaults);
expect(mocks.systemMetadata.get).not.toHaveBeenCalled();
});
});
@@ -278,7 +278,7 @@ describe(SystemConfigService.name, () => {
it('should return the default config', async () => {
mocks.systemMetadata.get.mockResolvedValue({});
await expect(sut.getSystemConfig()).resolves.toEqual(defaults);
await expect(sut.getAdminConfig()).resolves.toEqual(defaults);
});
it('should merge the overrides', async () => {
@@ -289,14 +289,14 @@ describe(SystemConfigService.name, () => {
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 () => {
mocks.config.getEnv.mockReturnValue(mockEnvData({ configFile: 'immich-config.json' }));
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');
});
@@ -305,7 +305,7 @@ describe(SystemConfigService.name, () => {
mocks.config.getEnv.mockReturnValue(mockEnvData({ configFile: 'immich-config.json' }));
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 }),
});
});
@@ -314,7 +314,7 @@ describe(SystemConfigService.name, () => {
mocks.config.getEnv.mockReturnValue(mockEnvData({ configFile: 'immich-config.json' }));
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 }),
});
});
@@ -325,7 +325,7 @@ describe(SystemConfigService.name, () => {
JSON.stringify({ library: { scan: { cronExpression: '0 0 */3 * *' } } }),
);
await expect(sut.getSystemConfig()).resolves.toMatchObject({
await expect(sut.getAdminConfig()).resolves.toMatchObject({
library: {
scan: {
enabled: true,
@@ -339,7 +339,7 @@ describe(SystemConfigService.name, () => {
mocks.config.getEnv.mockReturnValue(mockEnvData({ configFile: 'immich-config.json' }));
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',
);
});
@@ -348,7 +348,7 @@ describe(SystemConfigService.name, () => {
mocks.config.getEnv.mockReturnValue(mockEnvData({ configFile: 'immich-config.json' }));
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 () => {
@@ -356,7 +356,7 @@ describe(SystemConfigService.name, () => {
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.logger.error).toHaveBeenCalledTimes(2);
@@ -380,7 +380,7 @@ describe(SystemConfigService.name, () => {
`;
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');
});
@@ -389,7 +389,7 @@ describe(SystemConfigService.name, () => {
mocks.config.getEnv.mockReturnValue(mockEnvData({ configFile: 'immich-config.json' }));
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');
});
@@ -399,7 +399,7 @@ describe(SystemConfigService.name, () => {
const partialConfig = { machineLearning: { urls: ['immich_machine_learning'] } };
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']);
});
@@ -420,7 +420,7 @@ describe(SystemConfigService.name, () => {
const partialConfig = { server: { externalDomain } };
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');
});
}
@@ -432,7 +432,7 @@ describe(SystemConfigService.name, () => {
`;
mocks.systemMetadata.readFile.mockResolvedValue(partialConfig);
await sut.getSystemConfig();
await sut.getAdminConfig();
expect(mocks.logger.warn).toHaveBeenCalled();
});
@@ -467,12 +467,12 @@ describe(SystemConfigService.name, () => {
mocks.systemMetadata.readFile.mockResolvedValue(JSON.stringify(test.config));
if (test.throws) {
await expect(sut.getSystemConfig()).rejects.toThrow(test.throws);
await expect(sut.getAdminConfig()).rejects.toThrow(test.throws);
} else if (test.warn) {
await sut.getSystemConfig();
await sut.getAdminConfig();
expect(mocks.logger.warn).toHaveBeenCalled();
} else {
const config = await sut.getSystemConfig();
const config = await sut.getAdminConfig();
test.check!(config);
}
});
@@ -482,7 +482,7 @@ describe(SystemConfigService.name, () => {
describe('updateConfig', () => {
it('should update the config and emit an event', async () => {
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(
'ConfigUpdate',
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 () => {
mocks.config.getEnv.mockReturnValue(mockEnvData({ configFile: 'immich-config.json' }));
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();
});
});
+33 -8
View File
@@ -1,8 +1,15 @@
import { BadRequestException, Injectable } from '@nestjs/common';
import _ from 'lodash';
import { defaults } from 'src/config';
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 { ArgOf } from 'src/repositories/event.repository';
import { BaseService } from 'src/services/base.service';
@@ -22,13 +29,31 @@ export class SystemConfigService extends BaseService {
this.machineLearningRepository.teardown();
}
async getSystemConfig(): Promise<SystemConfigDto> {
async getAdminConfig(): Promise<AdminConfigDto> {
const config = await this.getConfig({ withCache: false });
return mapConfig(config);
return mapAdminConfig(config);
}
getDefaults(): SystemConfigDto {
return mapConfig(defaults);
getAdminConfigDefaults(): AdminConfigDto {
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 })
@@ -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();
if (configFile) {
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 });
return mapConfig(newConfig);
return mapAdminConfig(newConfig);
}
async getCustomCss(): Promise<string> {
+2 -3
View File
@@ -1,8 +1,7 @@
import { DateTime } from 'luxon';
import { SemVer } from 'semver';
import { defaults } from 'src/config';
import { ReleaseChannel } from 'src/dtos/system-config.dto';
import { CronJob, JobName, JobStatus, SystemMetadataKey } from 'src/enum';
import { defaults } from 'src/dtos/config.dto';
import { CronJob, JobName, JobStatus, ReleaseChannel, SystemMetadataKey } from 'src/enum';
import { VersionService } from 'src/services/version.service';
import { factory } from 'test/small.factory';
import { newTestService, ServiceMocks } from 'test/utils';
+10 -2
View File
@@ -4,8 +4,16 @@ import semver, { SemVer } from 'semver';
import { serverVersion } from 'src/constants';
import { OnEvent, OnJob } from 'src/decorators';
import { ReleaseEventV1, ReleaseType, ServerVersionResponseDto } from 'src/dtos/server.dto';
import { ReleaseChannel } from 'src/dtos/system-config.dto';
import { CronJob, DatabaseLock, ImmichWorker, JobName, JobStatus, QueueName, SystemMetadataKey } from 'src/enum';
import {
CronJob,
DatabaseLock,
ImmichWorker,
JobName,
JobStatus,
QueueName,
ReleaseChannel,
SystemMetadataKey,
} from 'src/enum';
import { ArgOf } from 'src/repositories/event.repository';
import { BaseService } from 'src/services/base.service';
import { VersionCheckMetadata } from 'src/types';
+5 -5
View File
@@ -1,9 +1,9 @@
import { ShallowDehydrateObject } from 'kysely';
import { SystemConfig } from 'src/config';
import { VECTOR_EXTENSIONS } from 'src/constants';
import { AssetFile } from 'src/database';
import { UploadFieldName } from 'src/dtos/asset-media.dto';
import { AuthDto } from 'src/dtos/auth.dto';
import { SystemConfig } from 'src/dtos/config.dto';
import { AssetEditActionItem } from 'src/dtos/editing.dto';
import { SetMaintenanceModeDto } from 'src/dtos/maintenance.dto';
import {
@@ -34,10 +34,10 @@ import { Mocked } from 'vitest';
export type DeepPartial<T> = T extends Date
? T
: T extends Record<string, unknown>
? { [K in keyof T]?: DeepPartial<T[K]> }
: T extends Array<infer R>
? DeepPartial<R>[]
: T extends Array<infer R>
? DeepPartial<R>[]
: T extends object
? { [K in keyof T]?: DeepPartial<T[K]> }
: T;
export type RepositoryInterface<T extends object> = Pick<T, keyof T>;
+2 -3
View File
@@ -1,8 +1,7 @@
import AsyncLock from 'async-lock';
import { load as loadYaml } from 'js-yaml';
import * as _ from 'lodash';
import { SystemConfig, defaults } from 'src/config';
import { SystemConfigSchema } from 'src/dtos/system-config.dto';
import { AdminConfigDto, SystemConfig, defaults } from 'src/dtos/config.dto';
import { DatabaseLock, SystemMetadataKey } from 'src/enum';
import { ConfigRepository } from 'src/repositories/config.repository';
import { LoggingRepository } from 'src/repositories/logging.repository';
@@ -100,7 +99,7 @@ const buildConfig = async (repos: RepoDeps) => {
}
// validate with Zod schema
const result = SystemConfigSchema.safeParse(rawConfig);
const result = AdminConfigDto.schema.safeParse(rawConfig);
if (!result.success) {
const messages = ['Invalid system config: '];
for (const issue of result.error.issues) {
+7 -7
View File
@@ -1,5 +1,5 @@
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 {
ColorMatrix,
ColorPrimaries,
@@ -62,18 +62,18 @@ export const getCodecString = (codec: VideoCodec, width: number, height: number,
export class BaseConfig implements VideoCodecSWConfig {
readonly presets = ['veryslow', 'slower', 'slow', 'medium', 'fast', 'faster', 'veryfast', 'superfast', 'ultrafast'];
protected constructor(
protected config: SystemConfigFFmpegDto,
protected config: ConfigFFmpegDto,
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) {
return BaseConfig.getSWCodecConfig(config, 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) {
case VideoCodec.H264: {
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)) {
throw new Error(
`${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;
constructor(
protected config: SystemConfigFFmpegDto,
protected config: ConfigFFmpegDto,
protected interfaces: VideoInterfaces,
tune?: VideoTuning,
) {
@@ -471,7 +471,7 @@ export class BaseHWConfig extends BaseConfig {
}
export class ThumbnailConfig extends BaseConfig {
static create(config: SystemConfigFFmpegDto): VideoCodecSWConfig {
static create(config: ConfigFFmpegDto): VideoCodecSWConfig {
return new ThumbnailConfig(config);
}
+1 -1
View File
@@ -13,9 +13,9 @@ import { writeFileSync } from 'node:fs';
import path from 'node:path';
import picomatch from 'picomatch';
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 { extraModels } from 'src/decorators';
import { SystemConfig } from 'src/dtos/config.dto';
import { ApiCustomExtension, ImmichCookie, ImmichHeader, MetadataKey } from 'src/enum';
import { LoggingRepository } from 'src/repositories/logging.repository';
+1 -1
View File
@@ -1,6 +1,6 @@
import { join } from 'node:path';
import { SystemConfig } from 'src/config';
import { StorageCore } from 'src/cores/storage.core';
import { SystemConfig } from 'src/dtos/config.dto';
import { StorageFolder } from 'src/enum';
import { CryptoRepository } from 'src/repositories/crypto.repository';
import { MediaRepository } from 'src/repositories/media.repository';
+1 -1
View File
@@ -1,4 +1,4 @@
import { SystemConfig } from 'src/config';
import { SystemConfig } from 'src/dtos/config.dto';
import { DeepPartial } from 'src/types';
export const systemConfigStub = {
+1 -1
View File
@@ -4,9 +4,9 @@ import { createHash, randomBytes } from 'node:crypto';
import { Stats } from 'node:fs';
import { resolve } from 'node:path';
import { Writable } from 'node:stream';
import { SystemConfig } from 'src/config';
import { AssetFace } from 'src/database';
import { AuthDto, LoginResponseDto } from 'src/dtos/auth.dto';
import { SystemConfig } from 'src/dtos/config.dto';
import { AssetEditActionItem, AssetEditsCreateDto } from 'src/dtos/editing.dto';
import {
AlbumUserRole,
+4 -2
View File
@@ -95,7 +95,9 @@ export type ControllerContext = {
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 upload = multer({ storage: multer.memoryStorage() });
const memoryFileInterceptor = {
@@ -117,7 +119,7 @@ export const controllerSetup = async (controller: new (...args: any[]) => unknow
},
};
const moduleRef = await Test.createTestingModule({
controllers: [controller],
controllers: Array.isArray(controller) ? controller : [controller],
providers: [
{ provide: APP_FILTER, useClass: GlobalExceptionFilter },
{ provide: APP_PIPE, useClass: ZodValidationPipe },
@@ -1,15 +1,15 @@
<script lang="ts">
import { systemConfigManager } from '$lib/managers/system-config-manager.svelte';
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 { isEqual, pick } from 'lodash-es';
import { t } from 'svelte-i18n';
type Props = {
disabled?: boolean;
keys: Array<keyof SystemConfigDto>;
configToEdit: SystemConfigDto;
keys: Array<keyof AdminConfigDto>;
configToEdit: AdminConfigDto;
onBeforeSave?: () => Promise<boolean>;
};
+2 -2
View File
@@ -11,7 +11,7 @@ import type {
QueueResponseDto,
ReleaseEventV1,
SharedLinkResponseDto,
SystemConfigDto,
AdminConfigDto,
TagResponseDto,
UserAdminResponseDto,
WorkflowResponseDto,
@@ -81,7 +81,7 @@ export type Events = {
SessionLocked: [];
SessionDelete: [];
SystemConfigUpdate: [SystemConfigDto];
SystemConfigUpdate: [AdminConfigDto];
IntegrityReportDeleteStatus: [{ type?: IntegrityReport; id?: string; isDeleting: boolean }];
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 { eventManager } from '$lib/managers/event-manager.svelte';
class SystemConfigManager {
#value?: SystemConfigDto = $state();
#defaultValue?: SystemConfigDto = $state();
#value?: AdminConfigDto = $state();
#defaultValue?: AdminConfigDto = $state();
constructor() {
eventManager.on({
@@ -25,7 +25,7 @@ class SystemConfigManager {
return this.#value;
}
set value(config: SystemConfigDto) {
set value(config: AdminConfigDto) {
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 { mdiContentCopy, mdiDownload, mdiUpload } from '@mdi/js';
import { isEqual } from 'lodash-es';
@@ -11,7 +11,7 @@ import { getFormatter } from '$lib/utils/i18n';
export const getSystemConfigActions = (
$t: MessageFormatter,
featureFlags: ServerFeaturesDto,
config: SystemConfigDto,
config: AdminConfigDto,
) => {
const CopyToClipboard: ActionItem = {
title: $t('copy_to_clipboard'),
@@ -44,17 +44,17 @@ export const getSystemConfigActions = (
return { CopyToClipboard, Download, Upload };
};
export const handleSystemConfigSave = async (update: Partial<SystemConfigDto>) => {
export const handleSystemConfigSave = async (update: Partial<AdminConfigDto>) => {
const $t = await getFormatter();
const config = await getConfig();
const systemConfigDto = { ...config, ...update };
const adminConfigDto = { ...config, ...update };
if (isEqual(config, systemConfigDto)) {
if (isEqual(config, adminConfigDto)) {
return;
}
try {
const newConfig = await updateConfig({ systemConfigDto });
const newConfig = await updateConfig({ adminConfigDto });
eventManager.emit('SystemConfigUpdate', newConfig);
toastManager.primary($t('settings_saved'));
@@ -4,7 +4,7 @@
import { SettingInputFieldType } from '$lib/constants';
import { featureFlagsManager } from '$lib/managers/feature-flags-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 { fade } from 'svelte/transition';
@@ -26,7 +26,7 @@
QueueName.Ocr,
];
function isSystemConfigJobDto(jobName: string): jobName is keyof SystemConfigJobDto {
function isSystemConfigJobDto(jobName: string): jobName is keyof AdminConfigJobDto {
return Object.hasOwn(configToEdit.job, jobName);
}
@@ -30,7 +30,7 @@
try {
await sendTestEmailAdmin({
systemConfigSmtpDto: {
adminConfigSmtpDto: {
enabled: configToEdit.notifications.smtp.enabled,
transport: {
host: configToEdit.notifications.smtp.transport.host,
@@ -5,14 +5,14 @@
import { systemConfigManager } from '$lib/managers/system-config-manager.svelte';
import EmailTemplatePreviewModal from '$lib/modals/EmailTemplatePreviewModal.svelte';
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 { mdiEyeOutline } from '@mdi/js';
import { t } from 'svelte-i18n';
import { fade } from 'svelte/transition';
interface Props {
config: SystemConfigDto;
config: AdminConfigDto;
}
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];
const onsubmit = (event: Event) => {