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.machineLearning.enabled = false;
config.metadata.faces.import = false; config.metadata.faces.import = false;
config.machineLearning.clip.enabled = false; config.machineLearning.clip.enabled = false;
await updateConfig({ systemConfigDto: config }, { headers: asBearerAuth(admin.accessToken) }); await updateConfig({ adminConfigDto: config }, { headers: asBearerAuth(admin.accessToken) });
}); });
it('should queue metadata extraction for missing assets', async () => { it('should queue metadata extraction for missing assets', async () => {
+3 -3
View File
@@ -1,7 +1,7 @@
import { OAuthClient, OAuthUser, generateLogoutToken } from '@immich/e2e-auth-server'; import { OAuthClient, OAuthUser, generateLogoutToken } from '@immich/e2e-auth-server';
import { import {
AdminConfigOAuthDto,
LoginResponseDto, LoginResponseDto,
SystemConfigOAuthDto,
getConfigDefaults, getConfigDefaults,
getMyUser, getMyUser,
getSessions, getSessions,
@@ -70,7 +70,7 @@ const loginWithOAuth = async (sub: OAuthUser | string, redirectUri?: string) =>
return { url: redirectUrl, state, codeVerifier }; return { url: redirectUrl, state, codeVerifier };
}; };
const setupOAuth = async (token: string, dto: Partial<SystemConfigOAuthDto>) => { const setupOAuth = async (token: string, dto: Partial<AdminConfigOAuthDto>) => {
const options = { headers: asBearerAuth(token) }; const options = { headers: asBearerAuth(token) };
const defaults = await getConfigDefaults(options); const defaults = await getConfigDefaults(options);
const merged = { const merged = {
@@ -80,7 +80,7 @@ const setupOAuth = async (token: string, dto: Partial<SystemConfigOAuthDto>) =>
allowInsecureRequests: true, allowInsecureRequests: true,
...dto, ...dto,
}; };
await updateConfig({ systemConfigDto: { ...defaults, oauth: merged } }, options); await updateConfig({ adminConfigDto: { ...defaults, oauth: merged } }, options);
}; };
describe(`/oauth`, () => { describe(`/oauth`, () => {
+1 -1
View File
@@ -647,7 +647,7 @@ export const utils = {
resetAdminConfig: async (accessToken: string) => { resetAdminConfig: async (accessToken: string) => {
const defaultConfig = await getConfigDefaults({ headers: asBearerAuth(accessToken) }); const defaultConfig = await getConfigDefaults({ headers: asBearerAuth(accessToken) });
await updateConfig({ systemConfigDto: defaultConfig }, { headers: asBearerAuth(accessToken) }); await updateConfig({ adminConfigDto: defaultConfig }, { headers: asBearerAuth(accessToken) });
}, },
isQueueEmpty: async (accessToken: string, queue: keyof QueuesResponseLegacyDto) => { isQueueEmpty: async (accessToken: string, queue: keyof QueuesResponseLegacyDto) => {
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.Assets]: 'An asset is an image or video that has been uploaded to Immich.',
[ApiTag.Authentication]: 'Endpoints related to user authentication, including OAuth.', [ApiTag.Authentication]: 'Endpoints related to user authentication, including OAuth.',
[ApiTag.AuthenticationAdmin]: 'Administrative endpoints related to authentication.', [ApiTag.AuthenticationAdmin]: 'Administrative endpoints related to authentication.',
[ApiTag.ConfigUser]: 'The system configuration properties that are visible to logged in users.',
[ApiTag.ConfigAdmin]: 'Endpoints to view and modify the full system configuration.',
[ApiTag.ConfigPublic]: 'The system configuration properties that are visible to everyone.',
[ApiTag.DatabaseBackups]: 'Manage backups of the Immich database.', [ApiTag.DatabaseBackups]: 'Manage backups of the Immich database.',
[ApiTag.Deprecated]: 'Deprecated endpoints that are planned for removal in the next major release.', [ApiTag.Deprecated]: 'Deprecated endpoints that are planned for removal in the next major release.',
[ApiTag.Download]: 'Endpoints for downloading assets or collections of assets.', [ApiTag.Download]: 'Endpoints for downloading assets or collections of assets.',
@@ -0,0 +1,46 @@
import { Body, Controller, Get, Put } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { Endpoint, HistoryBuilder } from 'src/decorators';
import { AdminConfigDto } from 'src/dtos/config.dto';
import { ApiTag, Permission } from 'src/enum';
import { Authenticated } from 'src/middleware/auth.guard';
import { SystemConfigService } from 'src/services/system-config.service';
@ApiTags(ApiTag.ConfigAdmin)
@Controller('admin/config')
export class ConfigAdminController {
constructor(private service: SystemConfigService) {}
@Get()
@Authenticated({ permission: Permission.AdminConfigRead, admin: true })
@Endpoint({
summary: 'Get the admin configuration',
description: 'Retrieve admin configuration.',
history: new HistoryBuilder().added('v3.2.0').alpha('v3.2.0'),
})
getAdminConfig(): Promise<AdminConfigDto> {
return this.service.getAdminConfig();
}
@Get('defaults')
@Authenticated({ permission: Permission.AdminConfigRead, admin: true })
@Endpoint({
summary: 'Get the system configuration defaults',
description: 'Retrieve the default value of every system configuration property.',
history: new HistoryBuilder().added('v3.2.0').alpha('v3.2.0'),
})
getAdminConfigDefaults(): AdminConfigDto {
return this.service.getAdminConfigDefaults();
}
@Put()
@Authenticated({ permission: Permission.AdminConfigUpdate, admin: true })
@Endpoint({
summary: 'Update the system configuration',
description: 'Update the system configuration with a new system configuration.',
history: new HistoryBuilder().added('v3.2.0').alpha('v3.2.0'),
})
updateAdminConfig(@Body() dto: AdminConfigDto): Promise<AdminConfigDto> {
return this.service.updateAdminConfig(dto);
}
}
@@ -0,0 +1,35 @@
import { Controller, Get } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { Endpoint, HistoryBuilder } from 'src/decorators';
import { PublicConfigDto } from 'src/dtos/config.dto';
import { ApiTag } from 'src/enum';
import { Authenticated } from 'src/middleware/auth.guard';
import { SystemConfigService } from 'src/services/system-config.service';
@ApiTags(ApiTag.ConfigPublic)
@Controller('public/config')
export class ConfigPublicController {
constructor(private service: SystemConfigService) {}
@Get()
@Authenticated({ public: true })
@Endpoint({
summary: 'Get the public configuration',
description: 'Retrieve the system configuration properties that are visible to everyone.',
history: new HistoryBuilder().added('v3.2.0').alpha('v3.2.0'),
})
getPublicConfig(): Promise<PublicConfigDto> {
return this.service.getPublicConfig();
}
@Get('defaults')
@Authenticated({ public: true })
@Endpoint({
summary: 'Get the public configuration defaults',
description: 'Retrieve the default value of the configuration properties that are visible to everyone.',
history: new HistoryBuilder().added('v3.2.0').alpha('v3.2.0'),
})
getPublicConfigDefaults(): PublicConfigDto {
return this.service.getPublicConfigDefaults();
}
}
@@ -0,0 +1,35 @@
import { Controller, Get } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { Endpoint, HistoryBuilder } from 'src/decorators';
import { UserConfigDto } from 'src/dtos/config.dto';
import { ApiTag, Permission } from 'src/enum';
import { Authenticated } from 'src/middleware/auth.guard';
import { SystemConfigService } from 'src/services/system-config.service';
@ApiTags(ApiTag.ConfigUser)
@Controller('config')
export class ConfigUserController {
constructor(private service: SystemConfigService) {}
@Get()
@Authenticated({ permission: Permission.UserConfigRead })
@Endpoint({
summary: 'Get the configuration with user visibility',
description: 'Retrieve the system configuration properties that are visible to logged in users.',
history: new HistoryBuilder().added('v3.2.0').alpha('v3.2.0'),
})
getUserConfig(): Promise<UserConfigDto> {
return this.service.getUserConfig();
}
@Get('defaults')
@Authenticated({ permission: Permission.UserConfigRead })
@Endpoint({
summary: 'Get the default configuration with user visibility',
description: 'Retrieve the default value of the configuration properties that are visible to logged in users.',
history: new HistoryBuilder().added('v3.2.0').alpha('v3.2.0'),
})
getUserConfigDefaults(): UserConfigDto {
return this.service.getUserConfigDefaults();
}
}
@@ -0,0 +1,116 @@
import _ from 'lodash';
import { ConfigAdminController } from 'src/controllers/config-admin.controller';
import { ConfigPublicController } from 'src/controllers/config-public.controller';
import { ConfigUserController } from 'src/controllers/config-user.controller';
import { defaults, mapPublicConfig, mapUserConfig } from 'src/dtos/config.dto';
import { SystemConfigService } from 'src/services/system-config.service';
import request from 'supertest';
import { errorDto } from 'test/medium/responses';
import { ControllerContext, controllerSetup, mockBaseService } from 'test/utils';
/** Returns a full config that passes Zod validation (required URLs and min lengths). */
function validConfig() {
const config = _.cloneDeep(defaults) as typeof defaults & {
oauth: { mobileRedirectUri: string };
notifications: { smtp: { from: string; transport: { host: string } } };
server: { externalDomain: string };
};
config.oauth.mobileRedirectUri ||= 'https://example.com';
config.server.externalDomain ||= 'https://example.com';
config.notifications.smtp.from ||= 'noreply@example.com';
config.notifications.smtp.transport.host ||= 'localhost';
return config;
}
describe('config controllers', () => {
let ctx: ControllerContext;
const service = mockBaseService(SystemConfigService);
beforeAll(async () => {
ctx = await controllerSetup(
[ConfigAdminController, ConfigUserController, ConfigPublicController],
[{ provide: SystemConfigService, useValue: service }],
);
return () => ctx.close();
});
beforeEach(() => {
service.resetAllMocks();
ctx.reset();
});
describe('GET /admin/config', () => {
it('should return the full config', async () => {
service.getAdminConfig.mockResolvedValue(validConfig());
const { status, body } = await request(ctx.getHttpServer()).get('/admin/config');
expect(status).toBe(200);
expect(body.oauth.clientSecret).toBeDefined();
expect(body.job.thumbnailGeneration).toBeDefined();
});
});
describe('PUT /admin/config', () => {
it('should accept a valid config', async () => {
service.updateAdminConfig.mockImplementation((dto) => Promise.resolve(dto));
const { status } = await request(ctx.getHttpServer()).put('/admin/config').send(validConfig());
expect(status).toBe(200);
});
it('should reject an invalid config', async () => {
const config = validConfig();
config.nightlyTasks.startTime = 'invalid';
const { status, body } = await request(ctx.getHttpServer()).put('/admin/config').send(config);
expect(status).toBe(400);
expect(body).toEqual(
errorDto.validationError([
{
path: ['nightlyTasks', 'startTime'],
message: 'Invalid input: expected string in HH:MM format, received string',
},
]),
);
expect(service.updateAdminConfig).not.toHaveBeenCalled();
});
});
describe('GET /config', () => {
it('should return the properties visible to logged in users', async () => {
service.getUserConfig.mockResolvedValue(mapUserConfig(validConfig()));
const { status, body } = await request(ctx.getHttpServer()).get('/config');
expect(status).toBe(200);
expect(body.image).toEqual({
thumbnail: { size: defaults.image.thumbnail.size },
preview: { size: defaults.image.preview.size },
fullsize: { enabled: defaults.image.fullsize.enabled },
});
expect(body.oauth.clientSecret).toBeUndefined();
expect(body.job).toBeUndefined();
});
});
describe('GET /public/config', () => {
it('should return the properties visible to everyone', async () => {
service.getPublicConfig.mockResolvedValue(mapPublicConfig(validConfig()));
const { status, body } = await request(ctx.getHttpServer()).get('/public/config');
expect(status).toBe(200);
expect(body.server).toEqual({ loginPageMessage: defaults.server.loginPageMessage });
expect(body.oauth).toEqual({
autoLaunch: defaults.oauth.autoLaunch,
buttonText: defaults.oauth.buttonText,
enabled: defaults.oauth.enabled,
});
expect(body.image).toBeUndefined();
expect(body.trash).toBeUndefined();
});
});
});
+6
View File
@@ -6,6 +6,9 @@ import { AssetMediaController } from 'src/controllers/asset-media.controller';
import { AssetController } from 'src/controllers/asset.controller'; import { AssetController } from 'src/controllers/asset.controller';
import { AuthAdminController } from 'src/controllers/auth-admin.controller'; import { AuthAdminController } from 'src/controllers/auth-admin.controller';
import { AuthController } from 'src/controllers/auth.controller'; import { AuthController } from 'src/controllers/auth.controller';
import { ConfigAdminController } from 'src/controllers/config-admin.controller';
import { ConfigPublicController } from 'src/controllers/config-public.controller';
import { ConfigUserController } from 'src/controllers/config-user.controller';
import { DatabaseBackupController } from 'src/controllers/database-backup.controller'; import { DatabaseBackupController } from 'src/controllers/database-backup.controller';
import { DownloadController } from 'src/controllers/download.controller'; import { DownloadController } from 'src/controllers/download.controller';
import { DuplicateController } from 'src/controllers/duplicate.controller'; import { DuplicateController } from 'src/controllers/duplicate.controller';
@@ -49,6 +52,9 @@ export const controllers = [
AssetMediaController, AssetMediaController,
AuthController, AuthController,
AuthAdminController, AuthAdminController,
ConfigUserController,
ConfigAdminController,
ConfigPublicController,
DatabaseBackupController, DatabaseBackupController,
DownloadController, DownloadController,
DuplicateController, DuplicateController,
@@ -2,6 +2,7 @@ import { Body, Controller, HttpCode, HttpStatus, Param, Post } from '@nestjs/com
import { ApiTags } from '@nestjs/swagger'; import { ApiTags } from '@nestjs/swagger';
import { Endpoint, HistoryBuilder } from 'src/decorators'; import { Endpoint, HistoryBuilder } from 'src/decorators';
import { AuthDto } from 'src/dtos/auth.dto'; import { AuthDto } from 'src/dtos/auth.dto';
import { SystemConfigSmtpDto } from 'src/dtos/config.dto';
import { import {
NotificationCreateDto, NotificationCreateDto,
NotificationDto, NotificationDto,
@@ -9,7 +10,6 @@ import {
TemplateResponseDto, TemplateResponseDto,
TestEmailResponseDto, TestEmailResponseDto,
} from 'src/dtos/notification.dto'; } from 'src/dtos/notification.dto';
import { SystemConfigSmtpDto } from 'src/dtos/system-config.dto';
import { ApiTag } from 'src/enum'; import { ApiTag } from 'src/enum';
import { Auth, Authenticated } from 'src/middleware/auth.guard'; import { Auth, Authenticated } from 'src/middleware/auth.guard';
import { EmailTemplate } from 'src/repositories/email.repository'; import { EmailTemplate } from 'src/repositories/email.repository';
+10 -2
View File
@@ -101,7 +101,11 @@ export class ServerController {
@Endpoint({ @Endpoint({
summary: 'Get features', summary: 'Get features',
description: 'Retrieve available features supported by this server.', description: 'Retrieve available features supported by this server.',
history: new HistoryBuilder().added('v1').beta('v1').stable('v2'), history: new HistoryBuilder()
.added('v1')
.beta('v1')
.stable('v2')
.deprecated('v3.2.0', { replacementId: 'getPublicConfig' }),
}) })
getServerFeatures(): Promise<ServerFeaturesDto> { getServerFeatures(): Promise<ServerFeaturesDto> {
return this.service.getFeatures(); return this.service.getFeatures();
@@ -112,7 +116,11 @@ export class ServerController {
@Endpoint({ @Endpoint({
summary: 'Get config', summary: 'Get config',
description: 'Retrieve the current server configuration.', description: 'Retrieve the current server configuration.',
history: new HistoryBuilder().added('v1').beta('v1').stable('v2'), history: new HistoryBuilder()
.added('v1')
.beta('v1')
.stable('v2')
.deprecated('v3.2.0', { replacementId: 'getPublicConfig' }),
}) })
getServerConfig(): Promise<ServerConfigDto> { getServerConfig(): Promise<ServerConfigDto> {
return this.service.getSystemConfig(); return this.service.getSystemConfig();
@@ -1,6 +1,6 @@
import _ from 'lodash'; import _ from 'lodash';
import { defaults } from 'src/config';
import { SystemConfigController } from 'src/controllers/system-config.controller'; import { SystemConfigController } from 'src/controllers/system-config.controller';
import { defaults } from 'src/dtos/config.dto';
import { StorageTemplateService } from 'src/services/storage-template.service'; import { StorageTemplateService } from 'src/services/storage-template.service';
import { SystemConfigService } from 'src/services/system-config.service'; import { SystemConfigService } from 'src/services/system-config.service';
import request from 'supertest'; import request from 'supertest';
@@ -1,7 +1,7 @@
import { Body, Controller, Get, Put } from '@nestjs/common'; import { Body, Controller, Get, Put } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger'; import { ApiTags } from '@nestjs/swagger';
import { Endpoint, HistoryBuilder } from 'src/decorators'; import { Endpoint, HistoryBuilder } from 'src/decorators';
import { SystemConfigDto, SystemConfigTemplateStorageOptionDto } from 'src/dtos/system-config.dto'; import { AdminConfigDto, ConfigTemplateStorageOptionDto } from 'src/dtos/config.dto';
import { ApiTag, Permission } from 'src/enum'; import { ApiTag, Permission } from 'src/enum';
import { Authenticated } from 'src/middleware/auth.guard'; import { Authenticated } from 'src/middleware/auth.guard';
import { StorageTemplateService } from 'src/services/storage-template.service'; import { StorageTemplateService } from 'src/services/storage-template.service';
@@ -20,10 +20,14 @@ export class SystemConfigController {
@Endpoint({ @Endpoint({
summary: 'Get system configuration', summary: 'Get system configuration',
description: 'Retrieve the current system configuration.', description: 'Retrieve the current system configuration.',
history: new HistoryBuilder().added('v1').beta('v1').stable('v2'), history: new HistoryBuilder()
.added('v1')
.beta('v1')
.stable('v2')
.deprecated('v3.2.0', { replacementId: 'getAdminConfig' }),
}) })
getConfig(): Promise<SystemConfigDto> { getConfig(): Promise<AdminConfigDto> {
return this.service.getSystemConfig(); return this.service.getAdminConfig();
} }
@Get('defaults') @Get('defaults')
@@ -31,10 +35,14 @@ export class SystemConfigController {
@Endpoint({ @Endpoint({
summary: 'Get system configuration defaults', summary: 'Get system configuration defaults',
description: 'Retrieve the default values for the system configuration.', description: 'Retrieve the default values for the system configuration.',
history: new HistoryBuilder().added('v1').beta('v1').stable('v2'), history: new HistoryBuilder()
.added('v1')
.beta('v1')
.stable('v2')
.deprecated('v3.2.0', { replacementId: 'getAdminConfigDefaults' }),
}) })
getConfigDefaults(): SystemConfigDto { getConfigDefaults(): AdminConfigDto {
return this.service.getDefaults(); return this.service.getAdminConfigDefaults();
} }
@Put() @Put()
@@ -42,10 +50,14 @@ export class SystemConfigController {
@Endpoint({ @Endpoint({
summary: 'Update system configuration', summary: 'Update system configuration',
description: 'Update the system configuration with a new system configuration.', description: 'Update the system configuration with a new system configuration.',
history: new HistoryBuilder().added('v1').beta('v1').stable('v2'), history: new HistoryBuilder()
.added('v1')
.beta('v1')
.stable('v2')
.deprecated('v3.2.0', { replacementId: 'updateAdminConfig' }),
}) })
updateConfig(@Body() dto: SystemConfigDto): Promise<SystemConfigDto> { updateConfig(@Body() dto: AdminConfigDto): Promise<AdminConfigDto> {
return this.service.updateSystemConfig(dto); return this.service.updateAdminConfig(dto);
} }
@Get('storage-template-options') @Get('storage-template-options')
@@ -55,7 +67,7 @@ export class SystemConfigController {
description: 'Retrieve exemplary storage template options.', description: 'Retrieve exemplary storage template options.',
history: new HistoryBuilder().added('v1').beta('v1').stable('v2'), history: new HistoryBuilder().added('v1').beta('v1').stable('v2'),
}) })
getStorageTemplateOptions(): SystemConfigTemplateStorageOptionDto { getStorageTemplateOptions(): ConfigTemplateStorageOptionDto {
return this.storageTemplateService.getStorageTemplateOptions(); return this.storageTemplateService.getStorageTemplateOptions();
} }
} }
+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', BackupUpload = 'backup.upload',
BackupDelete = 'backup.delete', BackupDelete = 'backup.delete',
AdminConfigRead = 'adminConfig.read',
AdminConfigUpdate = 'adminConfig.update',
UserConfigRead = 'userConfig.read',
DuplicateRead = 'duplicate.read', DuplicateRead = 'duplicate.read',
DuplicateDelete = 'duplicate.delete', DuplicateDelete = 'duplicate.delete',
@@ -1165,12 +1170,25 @@ export const AssetVisibilitySchema = z
.describe('Asset visibility') .describe('Asset visibility')
.meta({ id: 'AssetVisibility' }); .meta({ id: 'AssetVisibility' });
export enum ReleaseChannel {
Stable = 'stable',
ReleaseCandidate = 'releaseCandidate',
}
export const ReleaseChannelSchema = z.enum(ReleaseChannel).describe('Release channel').meta({ id: 'ReleaseChannel' });
export enum CronJob { export enum CronJob {
LibraryScan = 'LibraryScan', LibraryScan = 'LibraryScan',
NightlyJobs = 'NightlyJobs', NightlyJobs = 'NightlyJobs',
VersionCheck = 'VersionCheck', VersionCheck = 'VersionCheck',
} }
export enum ConfigVisibility {
Public = 'Public',
User = 'User',
Admin = 'Admin',
}
export enum ApiTag { export enum ApiTag {
Activities = 'Activities', Activities = 'Activities',
Albums = 'Albums', Albums = 'Albums',
@@ -1178,6 +1196,9 @@ export enum ApiTag {
Authentication = 'Authentication', Authentication = 'Authentication',
AuthenticationAdmin = 'Authentication (admin)', AuthenticationAdmin = 'Authentication (admin)',
Assets = 'Assets', Assets = 'Assets',
ConfigUser = 'Config (user)',
ConfigAdmin = 'Config (admin)',
ConfigPublic = 'Config (public)',
DatabaseBackups = 'Database Backups (admin)', DatabaseBackups = 'Database Backups (admin)',
Deprecated = 'Deprecated', Deprecated = 'Deprecated',
Download = 'Download', Download = 'Download',
+1 -1
View File
@@ -2,10 +2,10 @@ import { Injectable } from '@nestjs/common';
import { ModuleRef, Reflector } from '@nestjs/core'; import { ModuleRef, Reflector } from '@nestjs/core';
import _ from 'lodash'; import _ from 'lodash';
import { Socket } from 'socket.io'; import { Socket } from 'socket.io';
import { SystemConfig } from 'src/config';
import { Asset } from 'src/database'; import { Asset } from 'src/database';
import { EventConfig } from 'src/decorators'; import { EventConfig } from 'src/decorators';
import { AuthDto } from 'src/dtos/auth.dto'; import { AuthDto } from 'src/dtos/auth.dto';
import { SystemConfig } from 'src/dtos/config.dto';
import { ImmichWorker, JobStatus, MetadataKey, QueueName, UserAvatarColor, UserStatus } from 'src/enum'; import { ImmichWorker, JobStatus, MetadataKey, QueueName, UserAvatarColor, UserStatus } from 'src/enum';
import { ConfigRepository } from 'src/repositories/config.repository'; import { ConfigRepository } from 'src/repositories/config.repository';
import { LoggingRepository } from 'src/repositories/logging.repository'; import { LoggingRepository } from 'src/repositories/logging.repository';
@@ -1,8 +1,7 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { Duration } from 'luxon'; import { Duration } from 'luxon';
import { readFile } from 'node:fs/promises'; import { readFile } from 'node:fs/promises';
import { MachineLearningConfig } from 'src/config'; import { MachineLearningConfig } from 'src/dtos/config.dto';
import { CLIPConfig } from 'src/dtos/model-config.dto';
import { LoggingRepository } from 'src/repositories/logging.repository'; import { LoggingRepository } from 'src/repositories/logging.repository';
export interface BoundingBox { export interface BoundingBox {
@@ -205,7 +204,7 @@ export class MachineLearningRepository {
}; };
} }
async encodeImage(imagePath: string, { modelName }: CLIPConfig) { async encodeImage(imagePath: string, { modelName }: MachineLearningConfig['clip']) {
const request = { [ModelTask.SEARCH]: { [ModelType.VISUAL]: { modelName } } }; const request = { [ModelTask.SEARCH]: { [ModelType.VISUAL]: { modelName } } };
const response = await this.predict<ClipVisualResponse>({ imagePath }, request); const response = await this.predict<ClipVisualResponse>({ imagePath }, request);
return response[ModelTask.SEARCH]; return response[ModelTask.SEARCH];
@@ -4,7 +4,7 @@ import { exec as execCallback } from 'node:child_process';
import { readFile } from 'node:fs/promises'; import { readFile } from 'node:fs/promises';
import { promisify } from 'node:util'; import { promisify } from 'node:util';
import sharp from 'sharp'; import sharp from 'sharp';
import { ReleaseChannel } from 'src/dtos/system-config.dto'; import { ReleaseChannel } from 'src/enum';
import { ConfigRepository } from 'src/repositories/config.repository'; import { ConfigRepository } from 'src/repositories/config.repository';
import { LoggingRepository } from 'src/repositories/logging.repository'; import { LoggingRepository } from 'src/repositories/logging.repository';
+1 -1
View File
@@ -1,10 +1,10 @@
import { BadRequestException, Injectable } from '@nestjs/common'; import { BadRequestException, Injectable } from '@nestjs/common';
import { Insertable } from 'kysely'; import { Insertable } from 'kysely';
import sanitize from 'sanitize-filename'; import sanitize from 'sanitize-filename';
import { SystemConfig } from 'src/config';
import { SALT_ROUNDS } from 'src/constants'; import { SALT_ROUNDS } from 'src/constants';
import { StorageCore } from 'src/cores/storage.core'; import { StorageCore } from 'src/cores/storage.core';
import { UserAdmin } from 'src/database'; import { UserAdmin } from 'src/database';
import { SystemConfig } from 'src/dtos/config.dto';
import { AccessRepository } from 'src/repositories/access.repository'; import { AccessRepository } from 'src/repositories/access.repository';
import { ActivityRepository } from 'src/repositories/activity.repository'; import { ActivityRepository } from 'src/repositories/activity.repository';
import { AlbumUserRepository } from 'src/repositories/album-user.repository'; import { AlbumUserRepository } from 'src/repositories/album-user.repository';
@@ -1,8 +1,8 @@
import { BadRequestException } from '@nestjs/common'; import { BadRequestException } from '@nestjs/common';
import { DateTime } from 'luxon'; import { DateTime } from 'luxon';
import { PassThrough, Readable } from 'node:stream'; import { PassThrough, Readable } from 'node:stream';
import { defaults, SystemConfig } from 'src/config';
import { StorageCore } from 'src/cores/storage.core'; import { StorageCore } from 'src/cores/storage.core';
import { defaults, SystemConfig } from 'src/dtos/config.dto';
import { ImmichWorker, JobStatus, StorageFolder } from 'src/enum'; import { ImmichWorker, JobStatus, StorageFolder } from 'src/enum';
import { MaintenanceHealthRepository } from 'src/maintenance/maintenance-health.repository'; import { MaintenanceHealthRepository } from 'src/maintenance/maintenance-health.repository';
import { DatabaseBackupService } from 'src/services/database-backup.service'; import { DatabaseBackupService } from 'src/services/database-backup.service';
+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 { StorageCore } from 'src/cores/storage.core';
import { OnEvent } from 'src/decorators'; import { OnEvent } from 'src/decorators';
import { AuthDto } from 'src/dtos/auth.dto'; import { AuthDto } from 'src/dtos/auth.dto';
import { SystemConfigFFmpegDto } from 'src/dtos/system-config.dto'; import { ConfigFFmpegDto } from 'src/dtos/config.dto';
import { CacheControl, ImmichWorker, Permission } from 'src/enum'; import { CacheControl, ImmichWorker, Permission } from 'src/enum';
import { ArgOf } from 'src/repositories/event.repository'; import { ArgOf } from 'src/repositories/event.repository';
import { BaseService } from 'src/services/base.service'; import { BaseService } from 'src/services/base.service';
@@ -124,7 +124,7 @@ export class HlsService extends BaseService {
this.websocketRepository.serverSend('HlsSessionEnd', { sessionId }); this.websocketRepository.serverSend('HlsSessionEnd', { sessionId });
} }
private generateMainPlaylist(sessionId: string, ffmpeg: SystemConfigFFmpegDto, asset: AssetWithStreamInfo) { private generateMainPlaylist(sessionId: string, ffmpeg: ConfigFFmpegDto, asset: AssetWithStreamInfo) {
const fps = (asset.packets.packetCount * asset.videoStream.timeBase) / asset.packets.totalDuration; const fps = (asset.packets.packetCount * asset.videoStream.timeBase) / asset.packets.totalDuration;
const roundedFps = fps.toFixed(3); const roundedFps = fps.toFixed(3);
const sourceResolution = Math.min(asset.videoStream.height, asset.videoStream.width); const sourceResolution = Math.min(asset.videoStream.height, asset.videoStream.width);
+1 -1
View File
@@ -1,7 +1,7 @@
import { BadRequestException } from '@nestjs/common'; import { BadRequestException } from '@nestjs/common';
import { Stats } from 'node:fs'; import { Stats } from 'node:fs';
import { defaults, SystemConfig } from 'src/config';
import { JOBS_LIBRARY_PAGINATION_SIZE } from 'src/constants'; import { JOBS_LIBRARY_PAGINATION_SIZE } from 'src/constants';
import { defaults, SystemConfig } from 'src/dtos/config.dto';
import { mapLibrary } from 'src/dtos/library.dto'; import { mapLibrary } from 'src/dtos/library.dto';
import { AssetType, CronJob, ImmichWorker, JobName, JobStatus } from 'src/enum'; import { AssetType, CronJob, ImmichWorker, JobName, JobStatus } from 'src/enum';
import { LibraryService } from 'src/services/library.service'; import { LibraryService } from 'src/services/library.service';
+1 -1
View File
@@ -1,7 +1,7 @@
import { ShallowDehydrateObject } from 'kysely'; import { ShallowDehydrateObject } from 'kysely';
import { OutputInfo } from 'sharp'; import { OutputInfo } from 'sharp';
import { SystemConfig } from 'src/config';
import { Exif } from 'src/database'; import { Exif } from 'src/database';
import { SystemConfig } from 'src/dtos/config.dto';
import { AssetEditAction } from 'src/dtos/editing.dto'; import { AssetEditAction } from 'src/dtos/editing.dto';
import { import {
AssetFileType, AssetFileType,
+5 -6
View File
@@ -1,11 +1,10 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { SystemConfig } from 'src/config';
import { FACE_THUMBNAIL_SIZE } from 'src/constants'; import { FACE_THUMBNAIL_SIZE } from 'src/constants';
import { ImagePathOptions, StorageCore, ThumbnailPathEntity } from 'src/cores/storage.core'; import { ImagePathOptions, StorageCore, ThumbnailPathEntity } from 'src/cores/storage.core';
import { AssetFile } from 'src/database'; import { AssetFile } from 'src/database';
import { OnEvent, OnJob } from 'src/decorators'; import { OnEvent, OnJob } from 'src/decorators';
import { ConfigFFmpegDto, SystemConfig } from 'src/dtos/config.dto';
import { AssetEditAction, CropParameters } from 'src/dtos/editing.dto'; import { AssetEditAction, CropParameters } from 'src/dtos/editing.dto';
import { SystemConfigFFmpegDto } from 'src/dtos/system-config.dto';
import { import {
AssetFileType, AssetFileType,
AssetType, AssetType,
@@ -626,7 +625,7 @@ export class MediaService extends BaseService {
} }
private getTranscodeTarget( private getTranscodeTarget(
config: SystemConfigFFmpegDto, config: ConfigFFmpegDto,
videoStream: VideoStreamInfo, videoStream: VideoStreamInfo,
audioStream?: AudioStreamInfo, audioStream?: AudioStreamInfo,
): TranscodeTarget { ): TranscodeTarget {
@@ -648,7 +647,7 @@ export class MediaService extends BaseService {
return TranscodeTarget.None; return TranscodeTarget.None;
} }
private isAudioTranscodeRequired(ffmpegConfig: SystemConfigFFmpegDto, stream?: AudioStreamInfo): boolean { private isAudioTranscodeRequired(ffmpegConfig: ConfigFFmpegDto, stream?: AudioStreamInfo): boolean {
if (!stream) { if (!stream) {
return false; return false;
} }
@@ -671,7 +670,7 @@ export class MediaService extends BaseService {
} }
} }
private isVideoTranscodeRequired(ffmpegConfig: SystemConfigFFmpegDto, stream: VideoStreamInfo): boolean { private isVideoTranscodeRequired(ffmpegConfig: ConfigFFmpegDto, stream: VideoStreamInfo): boolean {
const isScalingEnabled = ffmpegConfig.targetResolution !== 'original'; const isScalingEnabled = ffmpegConfig.targetResolution !== 'original';
const targetRes = Number.parseInt(ffmpegConfig.targetResolution); const targetRes = Number.parseInt(ffmpegConfig.targetResolution);
const isLargerThanTargetRes = isScalingEnabled && Math.min(stream.height, stream.width) > targetRes; const isLargerThanTargetRes = isScalingEnabled && Math.min(stream.height, stream.width) > targetRes;
@@ -703,7 +702,7 @@ export class MediaService extends BaseService {
} }
} }
private isRemuxRequired(ffmpegConfig: SystemConfigFFmpegDto, { formatName, formatLongName }: VideoFormat): boolean { private isRemuxRequired(ffmpegConfig: ConfigFFmpegDto, { formatName, formatLongName }: VideoFormat): boolean {
if (ffmpegConfig.transcode === TranscodePolicy.Disabled) { if (ffmpegConfig.transcode === TranscodePolicy.Disabled) {
return false; return false;
} }
+1 -1
View File
@@ -2,7 +2,7 @@ import { BinaryField, ExifDateTime } from 'exiftool-vendored';
import { DateTime } from 'luxon'; import { DateTime } from 'luxon';
import { randomBytes } from 'node:crypto'; import { randomBytes } from 'node:crypto';
import { Stats } from 'node:fs'; import { Stats } from 'node:fs';
import { defaults } from 'src/config'; import { defaults } from 'src/dtos/config.dto';
import { import {
AssetFileType, AssetFileType,
AssetType, AssetType,
@@ -1,4 +1,4 @@
import { defaults, SystemConfig } from 'src/config'; import { defaults, SystemConfig } from 'src/dtos/config.dto';
import { EmailTemplate } from 'src/repositories/email.repository'; import { EmailTemplate } from 'src/repositories/email.repository';
import { NotificationService } from 'src/services/notification.service'; import { NotificationService } from 'src/services/notification.service';
import { userStub } from 'test/fixtures/user.stub'; import { userStub } from 'test/fixtures/user.stub';
@@ -1,7 +1,7 @@
import { BadRequestException, Injectable } from '@nestjs/common'; import { BadRequestException, Injectable } from '@nestjs/common';
import { AuthDto } from 'src/dtos/auth.dto'; import { AuthDto } from 'src/dtos/auth.dto';
import { SystemConfigSmtpDto } from 'src/dtos/config.dto';
import { mapNotification, NotificationCreateDto } from 'src/dtos/notification.dto'; import { mapNotification, NotificationCreateDto } from 'src/dtos/notification.dto';
import { SystemConfigSmtpDto } from 'src/dtos/system-config.dto';
import { NotificationLevel, NotificationType } from 'src/enum'; import { NotificationLevel, NotificationType } from 'src/enum';
import { EmailTemplate } from 'src/repositories/email.repository'; import { EmailTemplate } from 'src/repositories/email.repository';
import { BaseService } from 'src/services/base.service'; import { BaseService } from 'src/services/base.service';
@@ -1,5 +1,4 @@
import { defaults, SystemConfig } from 'src/config'; import { AdminConfigDto, defaults, SystemConfig } from 'src/dtos/config.dto';
import { SystemConfigDto } from 'src/dtos/system-config.dto';
import { AssetFileType, JobName, JobStatus, UserMetadataKey } from 'src/enum'; import { AssetFileType, JobName, JobStatus, UserMetadataKey } from 'src/enum';
import { NotificationService } from 'src/services/notification.service'; import { NotificationService } from 'src/services/notification.service';
import { AlbumFactory } from 'test/factories/album.factory'; import { AlbumFactory } from 'test/factories/album.factory';
@@ -100,7 +99,7 @@ describe(NotificationService.name, () => {
it('skips smtp validation with DTO when there are no changes', async () => { it('skips smtp validation with DTO when there are no changes', async () => {
const oldConfig = { ...configs.smtpEnabled }; const oldConfig = { ...configs.smtpEnabled };
const newConfig = configs.smtpEnabled as SystemConfigDto; const newConfig = configs.smtpEnabled as AdminConfigDto;
await expect(sut.onConfigValidate({ oldConfig, newConfig })).resolves.not.toThrow(); await expect(sut.onConfigValidate({ oldConfig, newConfig })).resolves.not.toThrow();
expect(mocks.email.verifySmtp).not.toHaveBeenCalled(); expect(mocks.email.verifySmtp).not.toHaveBeenCalled();
+1 -1
View File
@@ -3,6 +3,7 @@ import { OnEvent, OnJob } from 'src/decorators';
import { MapAlbumDto } from 'src/dtos/album.dto'; import { MapAlbumDto } from 'src/dtos/album.dto';
import { mapAsset } from 'src/dtos/asset-response.dto'; import { mapAsset } from 'src/dtos/asset-response.dto';
import { AuthDto } from 'src/dtos/auth.dto'; import { AuthDto } from 'src/dtos/auth.dto';
import { SystemConfigSmtpDto } from 'src/dtos/config.dto';
import { import {
mapNotification, mapNotification,
NotificationDeleteAllDto, NotificationDeleteAllDto,
@@ -11,7 +12,6 @@ import {
NotificationUpdateAllDto, NotificationUpdateAllDto,
NotificationUpdateDto, NotificationUpdateDto,
} from 'src/dtos/notification.dto'; } from 'src/dtos/notification.dto';
import { SystemConfigSmtpDto } from 'src/dtos/system-config.dto';
import { import {
AssetFileType, AssetFileType,
JobName, JobName,
+1 -1
View File
@@ -1,5 +1,5 @@
import { BadRequestException } from '@nestjs/common'; import { BadRequestException } from '@nestjs/common';
import { defaults, SystemConfig } from 'src/config'; import { defaults, SystemConfig } from 'src/dtos/config.dto';
import { ImmichWorker, JobName, QueueCommand, QueueName } from 'src/enum'; import { ImmichWorker, JobName, QueueCommand, QueueName } from 'src/enum';
import { QueueService } from 'src/services/queue.service'; import { QueueService } from 'src/services/queue.service';
import { factory } from 'test/small.factory'; import { factory } from 'test/small.factory';
+1 -1
View File
@@ -1,7 +1,7 @@
import { BadRequestException, Injectable } from '@nestjs/common'; import { BadRequestException, Injectable } from '@nestjs/common';
import { SystemConfig } from 'src/config';
import { OnEvent } from 'src/decorators'; import { OnEvent } from 'src/decorators';
import { AuthDto } from 'src/dtos/auth.dto'; import { AuthDto } from 'src/dtos/auth.dto';
import { SystemConfig } from 'src/dtos/config.dto';
import { import {
mapQueueLegacy, mapQueueLegacy,
mapQueuesLegacy, mapQueuesLegacy,
@@ -1,4 +1,4 @@
import { SystemConfig } from 'src/config'; import { SystemConfig } from 'src/dtos/config.dto';
import { AssetFileType, AssetVisibility, ImmichWorker, JobName, JobStatus } from 'src/enum'; import { AssetFileType, AssetVisibility, ImmichWorker, JobName, JobStatus } from 'src/enum';
import { SmartInfoService } from 'src/services/smart-info.service'; import { SmartInfoService } from 'src/services/smart-info.service';
import { getCLIPModelInfo } from 'src/utils/misc'; import { getCLIPModelInfo } from 'src/utils/misc';
+1 -1
View File
@@ -1,5 +1,5 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { SystemConfig } from 'src/config'; import { SystemConfig } from 'src/dtos/config.dto';
import { OnEvent, OnJob } from 'src/decorators'; import { OnEvent, OnJob } from 'src/decorators';
import { AssetVisibility, DatabaseLock, ImmichWorker, JobName, JobStatus, QueueName } from 'src/enum'; import { AssetVisibility, DatabaseLock, ImmichWorker, JobName, JobStatus, QueueName } from 'src/enum';
@@ -1,5 +1,5 @@
import { Stats } from 'node:fs'; import { Stats } from 'node:fs';
import { defaults, SystemConfig } from 'src/config'; import { defaults, SystemConfig } from 'src/dtos/config.dto';
import { AssetPathType, AssetType, JobStatus } from 'src/enum'; import { AssetPathType, AssetType, JobStatus } from 'src/enum';
import { StorageTemplateService } from 'src/services/storage-template.service'; import { StorageTemplateService } from 'src/services/storage-template.service';
import { AlbumFactory } from 'test/factories/album.factory'; import { AlbumFactory } from 'test/factories/album.factory';
@@ -5,7 +5,7 @@ import path from 'node:path';
import sanitize from 'sanitize-filename'; import sanitize from 'sanitize-filename';
import { StorageCore } from 'src/cores/storage.core'; import { StorageCore } from 'src/cores/storage.core';
import { OnEvent, OnJob } from 'src/decorators'; import { OnEvent, OnJob } from 'src/decorators';
import { SystemConfigTemplateStorageOptionDto } from 'src/dtos/system-config.dto'; import { ConfigTemplateStorageOptionDto } from 'src/dtos/config.dto';
import { import {
AssetFileType, AssetFileType,
AssetPathType, AssetPathType,
@@ -129,7 +129,7 @@ export class StorageTemplateService extends BaseService {
} }
} }
getStorageTemplateOptions(): SystemConfigTemplateStorageOptionDto { getStorageTemplateOptions(): ConfigTemplateStorageOptionDto {
return { ...storageTokens, presetOptions: storagePresets }; return { ...storageTokens, presetOptions: storagePresets };
} }
@@ -1,6 +1,5 @@
import { BadRequestException } from '@nestjs/common'; import { BadRequestException } from '@nestjs/common';
import { defaults, SystemConfig } from 'src/config'; import { defaults, SystemConfig } from 'src/dtos/config.dto';
import { ReleaseChannel } from 'src/dtos/system-config.dto';
import { import {
AudioCodec, AudioCodec,
Colorspace, Colorspace,
@@ -10,6 +9,7 @@ import {
LogLevel, LogLevel,
OAuthTokenEndpointAuthMethod, OAuthTokenEndpointAuthMethod,
QueueName, QueueName,
ReleaseChannel,
ToneMapping, ToneMapping,
TranscodeHardwareAcceleration, TranscodeHardwareAcceleration,
TranscodePolicy, TranscodePolicy,
@@ -269,7 +269,7 @@ describe(SystemConfigService.name, () => {
it('should return the default config', () => { it('should return the default config', () => {
mocks.systemMetadata.get.mockResolvedValue(partialConfig); mocks.systemMetadata.get.mockResolvedValue(partialConfig);
expect(sut.getDefaults()).toEqual(defaults); expect(sut.getAdminConfigDefaults()).toEqual(defaults);
expect(mocks.systemMetadata.get).not.toHaveBeenCalled(); expect(mocks.systemMetadata.get).not.toHaveBeenCalled();
}); });
}); });
@@ -278,7 +278,7 @@ describe(SystemConfigService.name, () => {
it('should return the default config', async () => { it('should return the default config', async () => {
mocks.systemMetadata.get.mockResolvedValue({}); mocks.systemMetadata.get.mockResolvedValue({});
await expect(sut.getSystemConfig()).resolves.toEqual(defaults); await expect(sut.getAdminConfig()).resolves.toEqual(defaults);
}); });
it('should merge the overrides', async () => { it('should merge the overrides', async () => {
@@ -289,14 +289,14 @@ describe(SystemConfigService.name, () => {
user: { deleteDelay: 15 }, user: { deleteDelay: 15 },
}); });
await expect(sut.getSystemConfig()).resolves.toEqual(updatedConfig); await expect(sut.getAdminConfig()).resolves.toEqual(updatedConfig);
}); });
it('should load the config from a json file', async () => { it('should load the config from a json file', async () => {
mocks.config.getEnv.mockReturnValue(mockEnvData({ configFile: 'immich-config.json' })); mocks.config.getEnv.mockReturnValue(mockEnvData({ configFile: 'immich-config.json' }));
mocks.systemMetadata.readFile.mockResolvedValue(JSON.stringify(partialConfig)); mocks.systemMetadata.readFile.mockResolvedValue(JSON.stringify(partialConfig));
await expect(sut.getSystemConfig()).resolves.toEqual(updatedConfig); await expect(sut.getAdminConfig()).resolves.toEqual(updatedConfig);
expect(mocks.systemMetadata.readFile).toHaveBeenCalledWith('immich-config.json'); expect(mocks.systemMetadata.readFile).toHaveBeenCalledWith('immich-config.json');
}); });
@@ -305,7 +305,7 @@ describe(SystemConfigService.name, () => {
mocks.config.getEnv.mockReturnValue(mockEnvData({ configFile: 'immich-config.json' })); mocks.config.getEnv.mockReturnValue(mockEnvData({ configFile: 'immich-config.json' }));
mocks.systemMetadata.readFile.mockResolvedValue(JSON.stringify({ ffmpeg: { twoPass: 'false' } })); mocks.systemMetadata.readFile.mockResolvedValue(JSON.stringify({ ffmpeg: { twoPass: 'false' } }));
await expect(sut.getSystemConfig()).resolves.toMatchObject({ await expect(sut.getAdminConfig()).resolves.toMatchObject({
ffmpeg: expect.objectContaining({ twoPass: false }), ffmpeg: expect.objectContaining({ twoPass: false }),
}); });
}); });
@@ -314,7 +314,7 @@ describe(SystemConfigService.name, () => {
mocks.config.getEnv.mockReturnValue(mockEnvData({ configFile: 'immich-config.json' })); mocks.config.getEnv.mockReturnValue(mockEnvData({ configFile: 'immich-config.json' }));
mocks.systemMetadata.readFile.mockResolvedValue(JSON.stringify({ ffmpeg: { threads: '42' } })); mocks.systemMetadata.readFile.mockResolvedValue(JSON.stringify({ ffmpeg: { threads: '42' } }));
await expect(sut.getSystemConfig()).resolves.toMatchObject({ await expect(sut.getAdminConfig()).resolves.toMatchObject({
ffmpeg: expect.objectContaining({ threads: 42 }), ffmpeg: expect.objectContaining({ threads: 42 }),
}); });
}); });
@@ -325,7 +325,7 @@ describe(SystemConfigService.name, () => {
JSON.stringify({ library: { scan: { cronExpression: '0 0 */3 * *' } } }), JSON.stringify({ library: { scan: { cronExpression: '0 0 */3 * *' } } }),
); );
await expect(sut.getSystemConfig()).resolves.toMatchObject({ await expect(sut.getAdminConfig()).resolves.toMatchObject({
library: { library: {
scan: { scan: {
enabled: true, enabled: true,
@@ -339,7 +339,7 @@ describe(SystemConfigService.name, () => {
mocks.config.getEnv.mockReturnValue(mockEnvData({ configFile: 'immich-config.json' })); mocks.config.getEnv.mockReturnValue(mockEnvData({ configFile: 'immich-config.json' }));
mocks.systemMetadata.readFile.mockResolvedValue(JSON.stringify({ oauth: { issuerUrl: 'accounts.google.com' } })); mocks.systemMetadata.readFile.mockResolvedValue(JSON.stringify({ oauth: { issuerUrl: 'accounts.google.com' } }));
await expect(sut.getSystemConfig()).rejects.toThrow( await expect(sut.getAdminConfig()).rejects.toThrow(
'[oauth.issuerUrl] Issuer URL must be an empty string or a valid URL', '[oauth.issuerUrl] Issuer URL must be an empty string or a valid URL',
); );
}); });
@@ -348,7 +348,7 @@ describe(SystemConfigService.name, () => {
mocks.config.getEnv.mockReturnValue(mockEnvData({ configFile: 'immich-config.json' })); mocks.config.getEnv.mockReturnValue(mockEnvData({ configFile: 'immich-config.json' }));
mocks.systemMetadata.readFile.mockResolvedValue(JSON.stringify({ library: { scan: { cronExpression: 'foo' } } })); mocks.systemMetadata.readFile.mockResolvedValue(JSON.stringify({ library: { scan: { cronExpression: 'foo' } } }));
await expect(sut.getSystemConfig()).rejects.toThrow('[library.scan.cronExpression] Invalid cron expression'); await expect(sut.getAdminConfig()).rejects.toThrow('[library.scan.cronExpression] Invalid cron expression');
}); });
it('should log errors with the config file', async () => { it('should log errors with the config file', async () => {
@@ -356,7 +356,7 @@ describe(SystemConfigService.name, () => {
mocks.systemMetadata.readFile.mockResolvedValue(`{ "ffmpeg2": true, "ffmpeg2": true }`); mocks.systemMetadata.readFile.mockResolvedValue(`{ "ffmpeg2": true, "ffmpeg2": true }`);
await expect(sut.getSystemConfig()).rejects.toBeInstanceOf(Error); await expect(sut.getAdminConfig()).rejects.toBeInstanceOf(Error);
expect(mocks.systemMetadata.readFile).toHaveBeenCalledWith('immich-config.json'); expect(mocks.systemMetadata.readFile).toHaveBeenCalledWith('immich-config.json');
expect(mocks.logger.error).toHaveBeenCalledTimes(2); expect(mocks.logger.error).toHaveBeenCalledTimes(2);
@@ -380,7 +380,7 @@ describe(SystemConfigService.name, () => {
`; `;
mocks.systemMetadata.readFile.mockResolvedValue(partialConfig); mocks.systemMetadata.readFile.mockResolvedValue(partialConfig);
await expect(sut.getSystemConfig()).resolves.toEqual(updatedConfig); await expect(sut.getAdminConfig()).resolves.toEqual(updatedConfig);
expect(mocks.systemMetadata.readFile).toHaveBeenCalledWith('immich-config.yaml'); expect(mocks.systemMetadata.readFile).toHaveBeenCalledWith('immich-config.yaml');
}); });
@@ -389,7 +389,7 @@ describe(SystemConfigService.name, () => {
mocks.config.getEnv.mockReturnValue(mockEnvData({ configFile: 'immich-config.json' })); mocks.config.getEnv.mockReturnValue(mockEnvData({ configFile: 'immich-config.json' }));
mocks.systemMetadata.readFile.mockResolvedValue(JSON.stringify({})); mocks.systemMetadata.readFile.mockResolvedValue(JSON.stringify({}));
await expect(sut.getSystemConfig()).resolves.toEqual(defaults); await expect(sut.getAdminConfig()).resolves.toEqual(defaults);
expect(mocks.systemMetadata.readFile).toHaveBeenCalledWith('immich-config.json'); expect(mocks.systemMetadata.readFile).toHaveBeenCalledWith('immich-config.json');
}); });
@@ -399,7 +399,7 @@ describe(SystemConfigService.name, () => {
const partialConfig = { machineLearning: { urls: ['immich_machine_learning'] } }; const partialConfig = { machineLearning: { urls: ['immich_machine_learning'] } };
mocks.systemMetadata.readFile.mockResolvedValue(JSON.stringify(partialConfig)); mocks.systemMetadata.readFile.mockResolvedValue(JSON.stringify(partialConfig));
const config = await sut.getSystemConfig(); const config = await sut.getAdminConfig();
expect(config.machineLearning.urls).toEqual(['immich_machine_learning']); expect(config.machineLearning.urls).toEqual(['immich_machine_learning']);
}); });
@@ -420,7 +420,7 @@ describe(SystemConfigService.name, () => {
const partialConfig = { server: { externalDomain } }; const partialConfig = { server: { externalDomain } };
mocks.systemMetadata.readFile.mockResolvedValue(JSON.stringify(partialConfig)); mocks.systemMetadata.readFile.mockResolvedValue(JSON.stringify(partialConfig));
const config = await sut.getSystemConfig(); const config = await sut.getAdminConfig();
expect(config.server.externalDomain).toEqual(result ?? 'https://demo.immich.app'); expect(config.server.externalDomain).toEqual(result ?? 'https://demo.immich.app');
}); });
} }
@@ -432,7 +432,7 @@ describe(SystemConfigService.name, () => {
`; `;
mocks.systemMetadata.readFile.mockResolvedValue(partialConfig); mocks.systemMetadata.readFile.mockResolvedValue(partialConfig);
await sut.getSystemConfig(); await sut.getAdminConfig();
expect(mocks.logger.warn).toHaveBeenCalled(); expect(mocks.logger.warn).toHaveBeenCalled();
}); });
@@ -467,12 +467,12 @@ describe(SystemConfigService.name, () => {
mocks.systemMetadata.readFile.mockResolvedValue(JSON.stringify(test.config)); mocks.systemMetadata.readFile.mockResolvedValue(JSON.stringify(test.config));
if (test.throws) { if (test.throws) {
await expect(sut.getSystemConfig()).rejects.toThrow(test.throws); await expect(sut.getAdminConfig()).rejects.toThrow(test.throws);
} else if (test.warn) { } else if (test.warn) {
await sut.getSystemConfig(); await sut.getAdminConfig();
expect(mocks.logger.warn).toHaveBeenCalled(); expect(mocks.logger.warn).toHaveBeenCalled();
} else { } else {
const config = await sut.getSystemConfig(); const config = await sut.getAdminConfig();
test.check!(config); test.check!(config);
} }
}); });
@@ -482,7 +482,7 @@ describe(SystemConfigService.name, () => {
describe('updateConfig', () => { describe('updateConfig', () => {
it('should update the config and emit an event', async () => { it('should update the config and emit an event', async () => {
mocks.systemMetadata.get.mockResolvedValue(partialConfig); mocks.systemMetadata.get.mockResolvedValue(partialConfig);
await expect(sut.updateSystemConfig(updatedConfig)).resolves.toEqual(updatedConfig); await expect(sut.updateAdminConfig(updatedConfig)).resolves.toEqual(updatedConfig);
expect(mocks.event.emit).toHaveBeenCalledWith( expect(mocks.event.emit).toHaveBeenCalledWith(
'ConfigUpdate', 'ConfigUpdate',
expect.objectContaining({ oldConfig: expect.any(Object), newConfig: updatedConfig }), expect.objectContaining({ oldConfig: expect.any(Object), newConfig: updatedConfig }),
@@ -492,7 +492,7 @@ describe(SystemConfigService.name, () => {
it('should throw an error if a config file is in use', async () => { it('should throw an error if a config file is in use', async () => {
mocks.config.getEnv.mockReturnValue(mockEnvData({ configFile: 'immich-config.json' })); mocks.config.getEnv.mockReturnValue(mockEnvData({ configFile: 'immich-config.json' }));
mocks.systemMetadata.readFile.mockResolvedValue(JSON.stringify({})); mocks.systemMetadata.readFile.mockResolvedValue(JSON.stringify({}));
await expect(sut.updateSystemConfig(defaults)).rejects.toBeInstanceOf(BadRequestException); await expect(sut.updateAdminConfig(defaults)).rejects.toBeInstanceOf(BadRequestException);
expect(mocks.systemMetadata.set).not.toHaveBeenCalled(); expect(mocks.systemMetadata.set).not.toHaveBeenCalled();
}); });
}); });
+33 -8
View File
@@ -1,8 +1,15 @@
import { BadRequestException, Injectable } from '@nestjs/common'; import { BadRequestException, Injectable } from '@nestjs/common';
import _ from 'lodash'; import _ from 'lodash';
import { defaults } from 'src/config';
import { OnEvent } from 'src/decorators'; import { OnEvent } from 'src/decorators';
import { mapConfig, SystemConfigDto } from 'src/dtos/system-config.dto'; import {
AdminConfigDto,
defaults,
mapAdminConfig,
mapPublicConfig,
mapUserConfig,
PublicConfigDto,
UserConfigDto,
} from 'src/dtos/config.dto';
import { BootstrapEventPriority } from 'src/enum'; import { BootstrapEventPriority } from 'src/enum';
import { ArgOf } from 'src/repositories/event.repository'; import { ArgOf } from 'src/repositories/event.repository';
import { BaseService } from 'src/services/base.service'; import { BaseService } from 'src/services/base.service';
@@ -22,13 +29,31 @@ export class SystemConfigService extends BaseService {
this.machineLearningRepository.teardown(); this.machineLearningRepository.teardown();
} }
async getSystemConfig(): Promise<SystemConfigDto> { async getAdminConfig(): Promise<AdminConfigDto> {
const config = await this.getConfig({ withCache: false }); const config = await this.getConfig({ withCache: false });
return mapConfig(config); return mapAdminConfig(config);
} }
getDefaults(): SystemConfigDto { getAdminConfigDefaults(): AdminConfigDto {
return mapConfig(defaults); return mapAdminConfig(defaults);
}
async getUserConfig(): Promise<UserConfigDto> {
const config = await this.getConfig({ withCache: false });
return mapUserConfig(config);
}
getUserConfigDefaults(): UserConfigDto {
return mapUserConfig(defaults);
}
async getPublicConfig(): Promise<PublicConfigDto> {
const config = await this.getConfig({ withCache: false });
return mapPublicConfig(config);
}
getPublicConfigDefaults(): PublicConfigDto {
return mapPublicConfig(defaults);
} }
@OnEvent({ name: 'ConfigInit', priority: -100 }) @OnEvent({ name: 'ConfigInit', priority: -100 })
@@ -56,7 +81,7 @@ export class SystemConfigService extends BaseService {
} }
} }
async updateSystemConfig(dto: SystemConfigDto): Promise<SystemConfigDto> { async updateAdminConfig(dto: AdminConfigDto): Promise<AdminConfigDto> {
const { configFile } = this.configRepository.getEnv(); const { configFile } = this.configRepository.getEnv();
if (configFile) { if (configFile) {
throw new BadRequestException('Cannot update configuration while IMMICH_CONFIG_FILE is in use'); throw new BadRequestException('Cannot update configuration while IMMICH_CONFIG_FILE is in use');
@@ -75,7 +100,7 @@ export class SystemConfigService extends BaseService {
await this.eventRepository.emit('ConfigUpdate', { newConfig, oldConfig }); await this.eventRepository.emit('ConfigUpdate', { newConfig, oldConfig });
return mapConfig(newConfig); return mapAdminConfig(newConfig);
} }
async getCustomCss(): Promise<string> { async getCustomCss(): Promise<string> {
+2 -3
View File
@@ -1,8 +1,7 @@
import { DateTime } from 'luxon'; import { DateTime } from 'luxon';
import { SemVer } from 'semver'; import { SemVer } from 'semver';
import { defaults } from 'src/config'; import { defaults } from 'src/dtos/config.dto';
import { ReleaseChannel } from 'src/dtos/system-config.dto'; import { CronJob, JobName, JobStatus, ReleaseChannel, SystemMetadataKey } from 'src/enum';
import { CronJob, JobName, JobStatus, SystemMetadataKey } from 'src/enum';
import { VersionService } from 'src/services/version.service'; import { VersionService } from 'src/services/version.service';
import { factory } from 'test/small.factory'; import { factory } from 'test/small.factory';
import { newTestService, ServiceMocks } from 'test/utils'; import { newTestService, ServiceMocks } from 'test/utils';
+10 -2
View File
@@ -4,8 +4,16 @@ import semver, { SemVer } from 'semver';
import { serverVersion } from 'src/constants'; import { serverVersion } from 'src/constants';
import { OnEvent, OnJob } from 'src/decorators'; import { OnEvent, OnJob } from 'src/decorators';
import { ReleaseEventV1, ReleaseType, ServerVersionResponseDto } from 'src/dtos/server.dto'; import { ReleaseEventV1, ReleaseType, ServerVersionResponseDto } from 'src/dtos/server.dto';
import { ReleaseChannel } from 'src/dtos/system-config.dto'; import {
import { CronJob, DatabaseLock, ImmichWorker, JobName, JobStatus, QueueName, SystemMetadataKey } from 'src/enum'; CronJob,
DatabaseLock,
ImmichWorker,
JobName,
JobStatus,
QueueName,
ReleaseChannel,
SystemMetadataKey,
} from 'src/enum';
import { ArgOf } from 'src/repositories/event.repository'; import { ArgOf } from 'src/repositories/event.repository';
import { BaseService } from 'src/services/base.service'; import { BaseService } from 'src/services/base.service';
import { VersionCheckMetadata } from 'src/types'; import { VersionCheckMetadata } from 'src/types';
+5 -5
View File
@@ -1,9 +1,9 @@
import { ShallowDehydrateObject } from 'kysely'; import { ShallowDehydrateObject } from 'kysely';
import { SystemConfig } from 'src/config';
import { VECTOR_EXTENSIONS } from 'src/constants'; import { VECTOR_EXTENSIONS } from 'src/constants';
import { AssetFile } from 'src/database'; import { AssetFile } from 'src/database';
import { UploadFieldName } from 'src/dtos/asset-media.dto'; import { UploadFieldName } from 'src/dtos/asset-media.dto';
import { AuthDto } from 'src/dtos/auth.dto'; import { AuthDto } from 'src/dtos/auth.dto';
import { SystemConfig } from 'src/dtos/config.dto';
import { AssetEditActionItem } from 'src/dtos/editing.dto'; import { AssetEditActionItem } from 'src/dtos/editing.dto';
import { SetMaintenanceModeDto } from 'src/dtos/maintenance.dto'; import { SetMaintenanceModeDto } from 'src/dtos/maintenance.dto';
import { import {
@@ -34,10 +34,10 @@ import { Mocked } from 'vitest';
export type DeepPartial<T> = T extends Date export type DeepPartial<T> = T extends Date
? T ? T
: T extends Record<string, unknown> : T extends Array<infer R>
? { [K in keyof T]?: DeepPartial<T[K]> } ? DeepPartial<R>[]
: T extends Array<infer R> : T extends object
? DeepPartial<R>[] ? { [K in keyof T]?: DeepPartial<T[K]> }
: T; : T;
export type RepositoryInterface<T extends object> = Pick<T, keyof T>; export type RepositoryInterface<T extends object> = Pick<T, keyof T>;
+2 -3
View File
@@ -1,8 +1,7 @@
import AsyncLock from 'async-lock'; import AsyncLock from 'async-lock';
import { load as loadYaml } from 'js-yaml'; import { load as loadYaml } from 'js-yaml';
import * as _ from 'lodash'; import * as _ from 'lodash';
import { SystemConfig, defaults } from 'src/config'; import { AdminConfigDto, SystemConfig, defaults } from 'src/dtos/config.dto';
import { SystemConfigSchema } from 'src/dtos/system-config.dto';
import { DatabaseLock, SystemMetadataKey } from 'src/enum'; import { DatabaseLock, SystemMetadataKey } from 'src/enum';
import { ConfigRepository } from 'src/repositories/config.repository'; import { ConfigRepository } from 'src/repositories/config.repository';
import { LoggingRepository } from 'src/repositories/logging.repository'; import { LoggingRepository } from 'src/repositories/logging.repository';
@@ -100,7 +99,7 @@ const buildConfig = async (repos: RepoDeps) => {
} }
// validate with Zod schema // validate with Zod schema
const result = SystemConfigSchema.safeParse(rawConfig); const result = AdminConfigDto.schema.safeParse(rawConfig);
if (!result.success) { if (!result.success) {
const messages = ['Invalid system config: ']; const messages = ['Invalid system config: '];
for (const issue of result.error.issues) { for (const issue of result.error.issues) {
+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 { AUDIO_ENCODER, AV1_LEVELS, CodecLevel, H264_LEVELS, HEVC_LEVELS, SUPPORTED_HWA_CODECS } from 'src/constants';
import { SystemConfigFFmpegDto } from 'src/dtos/system-config.dto'; import { ConfigFFmpegDto } from 'src/dtos/config.dto';
import { import {
ColorMatrix, ColorMatrix,
ColorPrimaries, ColorPrimaries,
@@ -62,18 +62,18 @@ export const getCodecString = (codec: VideoCodec, width: number, height: number,
export class BaseConfig implements VideoCodecSWConfig { export class BaseConfig implements VideoCodecSWConfig {
readonly presets = ['veryslow', 'slower', 'slow', 'medium', 'fast', 'faster', 'veryfast', 'superfast', 'ultrafast']; readonly presets = ['veryslow', 'slower', 'slow', 'medium', 'fast', 'faster', 'veryfast', 'superfast', 'ultrafast'];
protected constructor( protected constructor(
protected config: SystemConfigFFmpegDto, protected config: ConfigFFmpegDto,
protected tune: VideoTuning = { strictGop: false, lowLatency: false }, protected tune: VideoTuning = { strictGop: false, lowLatency: false },
) {} ) {}
static create(config: SystemConfigFFmpegDto, interfaces: VideoInterfaces, tune?: VideoTuning) { static create(config: ConfigFFmpegDto, interfaces: VideoInterfaces, tune?: VideoTuning) {
if (config.accel === TranscodeHardwareAcceleration.Disabled) { if (config.accel === TranscodeHardwareAcceleration.Disabled) {
return BaseConfig.getSWCodecConfig(config, tune); return BaseConfig.getSWCodecConfig(config, tune);
} }
return BaseConfig.getHWCodecConfig(config, interfaces, tune); return BaseConfig.getHWCodecConfig(config, interfaces, tune);
} }
private static getSWCodecConfig(config: SystemConfigFFmpegDto, tune?: VideoTuning): VideoCodecSWConfig { private static getSWCodecConfig(config: ConfigFFmpegDto, tune?: VideoTuning): VideoCodecSWConfig {
switch (config.targetVideoCodec) { switch (config.targetVideoCodec) {
case VideoCodec.H264: { case VideoCodec.H264: {
return new H264Config(config, tune); return new H264Config(config, tune);
@@ -93,7 +93,7 @@ export class BaseConfig implements VideoCodecSWConfig {
} }
} }
private static getHWCodecConfig(config: SystemConfigFFmpegDto, interfaces: VideoInterfaces, tune?: VideoTuning) { private static getHWCodecConfig(config: ConfigFFmpegDto, interfaces: VideoInterfaces, tune?: VideoTuning) {
if (!SUPPORTED_HWA_CODECS[config.accel].includes(config.targetVideoCodec)) { if (!SUPPORTED_HWA_CODECS[config.accel].includes(config.targetVideoCodec)) {
throw new Error( throw new Error(
`${config.accel.toUpperCase()} acceleration does not support codec '${config.targetVideoCodec.toUpperCase()}'. Supported codecs: ${SUPPORTED_HWA_CODECS[config.accel]}`, `${config.accel.toUpperCase()} acceleration does not support codec '${config.targetVideoCodec.toUpperCase()}'. Supported codecs: ${SUPPORTED_HWA_CODECS[config.accel]}`,
@@ -424,7 +424,7 @@ export class BaseHWConfig extends BaseConfig {
protected device: string; protected device: string;
constructor( constructor(
protected config: SystemConfigFFmpegDto, protected config: ConfigFFmpegDto,
protected interfaces: VideoInterfaces, protected interfaces: VideoInterfaces,
tune?: VideoTuning, tune?: VideoTuning,
) { ) {
@@ -471,7 +471,7 @@ export class BaseHWConfig extends BaseConfig {
} }
export class ThumbnailConfig extends BaseConfig { export class ThumbnailConfig extends BaseConfig {
static create(config: SystemConfigFFmpegDto): VideoCodecSWConfig { static create(config: ConfigFFmpegDto): VideoCodecSWConfig {
return new ThumbnailConfig(config); return new ThumbnailConfig(config);
} }
+1 -1
View File
@@ -13,9 +13,9 @@ import { writeFileSync } from 'node:fs';
import path from 'node:path'; import path from 'node:path';
import picomatch from 'picomatch'; import picomatch from 'picomatch';
import parse from 'picomatch/lib/parse'; import parse from 'picomatch/lib/parse';
import { SystemConfig } from 'src/config';
import { CLIP_MODEL_INFO, JOBS_ASSET_PAGINATION_SIZE, endpointTags, serverVersion } from 'src/constants'; import { CLIP_MODEL_INFO, JOBS_ASSET_PAGINATION_SIZE, endpointTags, serverVersion } from 'src/constants';
import { extraModels } from 'src/decorators'; import { extraModels } from 'src/decorators';
import { SystemConfig } from 'src/dtos/config.dto';
import { ApiCustomExtension, ImmichCookie, ImmichHeader, MetadataKey } from 'src/enum'; import { ApiCustomExtension, ImmichCookie, ImmichHeader, MetadataKey } from 'src/enum';
import { LoggingRepository } from 'src/repositories/logging.repository'; import { LoggingRepository } from 'src/repositories/logging.repository';
+1 -1
View File
@@ -1,6 +1,6 @@
import { join } from 'node:path'; import { join } from 'node:path';
import { SystemConfig } from 'src/config';
import { StorageCore } from 'src/cores/storage.core'; import { StorageCore } from 'src/cores/storage.core';
import { SystemConfig } from 'src/dtos/config.dto';
import { StorageFolder } from 'src/enum'; import { StorageFolder } from 'src/enum';
import { CryptoRepository } from 'src/repositories/crypto.repository'; import { CryptoRepository } from 'src/repositories/crypto.repository';
import { MediaRepository } from 'src/repositories/media.repository'; import { MediaRepository } from 'src/repositories/media.repository';
+1 -1
View File
@@ -1,4 +1,4 @@
import { SystemConfig } from 'src/config'; import { SystemConfig } from 'src/dtos/config.dto';
import { DeepPartial } from 'src/types'; import { DeepPartial } from 'src/types';
export const systemConfigStub = { export const systemConfigStub = {
+1 -1
View File
@@ -4,9 +4,9 @@ import { createHash, randomBytes } from 'node:crypto';
import { Stats } from 'node:fs'; import { Stats } from 'node:fs';
import { resolve } from 'node:path'; import { resolve } from 'node:path';
import { Writable } from 'node:stream'; import { Writable } from 'node:stream';
import { SystemConfig } from 'src/config';
import { AssetFace } from 'src/database'; import { AssetFace } from 'src/database';
import { AuthDto, LoginResponseDto } from 'src/dtos/auth.dto'; import { AuthDto, LoginResponseDto } from 'src/dtos/auth.dto';
import { SystemConfig } from 'src/dtos/config.dto';
import { AssetEditActionItem, AssetEditsCreateDto } from 'src/dtos/editing.dto'; import { AssetEditActionItem, AssetEditsCreateDto } from 'src/dtos/editing.dto';
import { import {
AlbumUserRole, AlbumUserRole,
+4 -2
View File
@@ -95,7 +95,9 @@ export type ControllerContext = {
close: () => Promise<void>; close: () => Promise<void>;
}; };
export const controllerSetup = async (controller: new (...args: any[]) => unknown, providers: Provider[]) => { type ControllerClass = new (...args: any[]) => unknown;
export const controllerSetup = async (controller: ControllerClass | ControllerClass[], providers: Provider[]) => {
const noopInterceptor = { intercept: (ctx: never, next: CallHandler<unknown>) => next.handle() }; const noopInterceptor = { intercept: (ctx: never, next: CallHandler<unknown>) => next.handle() };
const upload = multer({ storage: multer.memoryStorage() }); const upload = multer({ storage: multer.memoryStorage() });
const memoryFileInterceptor = { const memoryFileInterceptor = {
@@ -117,7 +119,7 @@ export const controllerSetup = async (controller: new (...args: any[]) => unknow
}, },
}; };
const moduleRef = await Test.createTestingModule({ const moduleRef = await Test.createTestingModule({
controllers: [controller], controllers: Array.isArray(controller) ? controller : [controller],
providers: [ providers: [
{ provide: APP_FILTER, useClass: GlobalExceptionFilter }, { provide: APP_FILTER, useClass: GlobalExceptionFilter },
{ provide: APP_PIPE, useClass: ZodValidationPipe }, { provide: APP_PIPE, useClass: ZodValidationPipe },
@@ -1,15 +1,15 @@
<script lang="ts"> <script lang="ts">
import { systemConfigManager } from '$lib/managers/system-config-manager.svelte'; import { systemConfigManager } from '$lib/managers/system-config-manager.svelte';
import { handleSystemConfigSave } from '$lib/services/system-config.service'; import { handleSystemConfigSave } from '$lib/services/system-config.service';
import type { SystemConfigDto } from '@immich/sdk'; import type { AdminConfigDto } from '@immich/sdk';
import { Button, toastManager } from '@immich/ui'; import { Button, toastManager } from '@immich/ui';
import { isEqual, pick } from 'lodash-es'; import { isEqual, pick } from 'lodash-es';
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
type Props = { type Props = {
disabled?: boolean; disabled?: boolean;
keys: Array<keyof SystemConfigDto>; keys: Array<keyof AdminConfigDto>;
configToEdit: SystemConfigDto; configToEdit: AdminConfigDto;
onBeforeSave?: () => Promise<boolean>; onBeforeSave?: () => Promise<boolean>;
}; };
+2 -2
View File
@@ -11,7 +11,7 @@ import type {
QueueResponseDto, QueueResponseDto,
ReleaseEventV1, ReleaseEventV1,
SharedLinkResponseDto, SharedLinkResponseDto,
SystemConfigDto, AdminConfigDto,
TagResponseDto, TagResponseDto,
UserAdminResponseDto, UserAdminResponseDto,
WorkflowResponseDto, WorkflowResponseDto,
@@ -81,7 +81,7 @@ export type Events = {
SessionLocked: []; SessionLocked: [];
SessionDelete: []; SessionDelete: [];
SystemConfigUpdate: [SystemConfigDto]; SystemConfigUpdate: [AdminConfigDto];
IntegrityReportDeleteStatus: [{ type?: IntegrityReport; id?: string; isDeleting: boolean }]; IntegrityReportDeleteStatus: [{ type?: IntegrityReport; id?: string; isDeleting: boolean }];
IntegrityReportDeleted: [{ type?: IntegrityReport; id?: string }]; IntegrityReportDeleted: [{ type?: IntegrityReport; id?: string }];
@@ -1,10 +1,10 @@
import { getConfig, getConfigDefaults, type SystemConfigDto } from '@immich/sdk'; import { getConfig, getConfigDefaults, type AdminConfigDto } from '@immich/sdk';
import { cloneDeep } from 'lodash-es'; import { cloneDeep } from 'lodash-es';
import { eventManager } from '$lib/managers/event-manager.svelte'; import { eventManager } from '$lib/managers/event-manager.svelte';
class SystemConfigManager { class SystemConfigManager {
#value?: SystemConfigDto = $state(); #value?: AdminConfigDto = $state();
#defaultValue?: SystemConfigDto = $state(); #defaultValue?: AdminConfigDto = $state();
constructor() { constructor() {
eventManager.on({ eventManager.on({
@@ -25,7 +25,7 @@ class SystemConfigManager {
return this.#value; return this.#value;
} }
set value(config: SystemConfigDto) { set value(config: AdminConfigDto) {
this.#value = config; this.#value = config;
} }
@@ -1,4 +1,4 @@
import { getConfig, updateConfig, type ServerFeaturesDto, type SystemConfigDto } from '@immich/sdk'; import { getConfig, updateConfig, type ServerFeaturesDto, type AdminConfigDto } from '@immich/sdk';
import { toastManager, type ActionItem } from '@immich/ui'; import { toastManager, type ActionItem } from '@immich/ui';
import { mdiContentCopy, mdiDownload, mdiUpload } from '@mdi/js'; import { mdiContentCopy, mdiDownload, mdiUpload } from '@mdi/js';
import { isEqual } from 'lodash-es'; import { isEqual } from 'lodash-es';
@@ -11,7 +11,7 @@ import { getFormatter } from '$lib/utils/i18n';
export const getSystemConfigActions = ( export const getSystemConfigActions = (
$t: MessageFormatter, $t: MessageFormatter,
featureFlags: ServerFeaturesDto, featureFlags: ServerFeaturesDto,
config: SystemConfigDto, config: AdminConfigDto,
) => { ) => {
const CopyToClipboard: ActionItem = { const CopyToClipboard: ActionItem = {
title: $t('copy_to_clipboard'), title: $t('copy_to_clipboard'),
@@ -44,17 +44,17 @@ export const getSystemConfigActions = (
return { CopyToClipboard, Download, Upload }; return { CopyToClipboard, Download, Upload };
}; };
export const handleSystemConfigSave = async (update: Partial<SystemConfigDto>) => { export const handleSystemConfigSave = async (update: Partial<AdminConfigDto>) => {
const $t = await getFormatter(); const $t = await getFormatter();
const config = await getConfig(); const config = await getConfig();
const systemConfigDto = { ...config, ...update }; const adminConfigDto = { ...config, ...update };
if (isEqual(config, systemConfigDto)) { if (isEqual(config, adminConfigDto)) {
return; return;
} }
try { try {
const newConfig = await updateConfig({ systemConfigDto }); const newConfig = await updateConfig({ adminConfigDto });
eventManager.emit('SystemConfigUpdate', newConfig); eventManager.emit('SystemConfigUpdate', newConfig);
toastManager.primary($t('settings_saved')); toastManager.primary($t('settings_saved'));
@@ -4,7 +4,7 @@
import { SettingInputFieldType } from '$lib/constants'; import { SettingInputFieldType } from '$lib/constants';
import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte'; import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte';
import { systemConfigManager } from '$lib/managers/system-config-manager.svelte'; import { systemConfigManager } from '$lib/managers/system-config-manager.svelte';
import { QueueName, type SystemConfigJobDto } from '@immich/sdk'; import { QueueName, type AdminConfigJobDto } from '@immich/sdk';
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
import { fade } from 'svelte/transition'; import { fade } from 'svelte/transition';
@@ -26,7 +26,7 @@
QueueName.Ocr, QueueName.Ocr,
]; ];
function isSystemConfigJobDto(jobName: string): jobName is keyof SystemConfigJobDto { function isSystemConfigJobDto(jobName: string): jobName is keyof AdminConfigJobDto {
return Object.hasOwn(configToEdit.job, jobName); return Object.hasOwn(configToEdit.job, jobName);
} }
@@ -30,7 +30,7 @@
try { try {
await sendTestEmailAdmin({ await sendTestEmailAdmin({
systemConfigSmtpDto: { adminConfigSmtpDto: {
enabled: configToEdit.notifications.smtp.enabled, enabled: configToEdit.notifications.smtp.enabled,
transport: { transport: {
host: configToEdit.notifications.smtp.transport.host, host: configToEdit.notifications.smtp.transport.host,
@@ -5,14 +5,14 @@
import { systemConfigManager } from '$lib/managers/system-config-manager.svelte'; import { systemConfigManager } from '$lib/managers/system-config-manager.svelte';
import EmailTemplatePreviewModal from '$lib/modals/EmailTemplatePreviewModal.svelte'; import EmailTemplatePreviewModal from '$lib/modals/EmailTemplatePreviewModal.svelte';
import { handleError } from '$lib/utils/handle-error'; import { handleError } from '$lib/utils/handle-error';
import { type SystemConfigDto, type SystemConfigTemplateEmailsDto, getNotificationTemplateAdmin } from '@immich/sdk'; import { type AdminConfigDto, type AdminConfigTemplateEmailsDto, getNotificationTemplateAdmin } from '@immich/sdk';
import { Button, Icon, LoadingSpinner, modalManager } from '@immich/ui'; import { Button, Icon, LoadingSpinner, modalManager } from '@immich/ui';
import { mdiEyeOutline } from '@mdi/js'; import { mdiEyeOutline } from '@mdi/js';
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
import { fade } from 'svelte/transition'; import { fade } from 'svelte/transition';
interface Props { interface Props {
config: SystemConfigDto; config: AdminConfigDto;
} }
let { config = $bindable() }: Props = $props(); let { config = $bindable() }: Props = $props();
@@ -52,7 +52,7 @@
}, },
]; ];
const isEdited = (templateKey: keyof SystemConfigTemplateEmailsDto) => const isEdited = (templateKey: keyof AdminConfigTemplateEmailsDto) =>
config.templates.email[templateKey] !== systemConfigManager.value.templates.email[templateKey]; config.templates.email[templateKey] !== systemConfigManager.value.templates.email[templateKey];
const onsubmit = (event: Event) => { const onsubmit = (event: Event) => {