Files
silo-server/internal/config/db_loader.go
T
a17529fd6f feat(config): live admin settings + truthful restart-required banner (#128)
* feat(nodeconfig): harden config watcher for integrated-mode use

- RequestReload(): non-blocking, coalescing reload nudge that runs on the
  poll goroutine, so concurrent requests can never swap a stale snapshot
  over a newer one (unlike ForceReload from request handlers)
- Skip OnChange callbacks when the reloaded config is deep-equal to the
  previous one, so the 60s poll doesn't fire rebuild/log callbacks on
  no-op reloads
- Add RedisURL to BootstrapOverrides; previously a reload clobbered an
  env-provided Redis URL in the live config
- Split reload into fetchSettings/applySettings and add unit tests

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(config): hot-reload config watcher in integrated mode

Start nodeconfig.Watcher in integrated/api mode (previously only proxy/
transcode worker modes hot-reloaded). Expose the live config to the API
and jellycompat routers via func-typed LiveConfig/OnConfigChange fields
with nil fallbacks to the startup snapshot, and wire the admin settings
update hook to RequestReload so same-process changes apply immediately
even without Redis.

No consumer reads the live config yet — conversions land separately.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(admin): truthful restart-required banner for settings saves

The settings UI showed 'restart required' after every save regardless of
the key. The backend now classifies each key via a central registry
(internal/config/restart_keys.go) and PUT /admin/settings/{key} reports
restart_required per key; useSettingsForm only raises the banner when a
saved key actually needs a restart (and keeps it raised until restart).

The registry is conservative: every currently startup-frozen key is
marked restart-required; subsequent hot-reload conversions shrink it.
Settings read live from the settings repo (branding, overlays, markers,
download.*, ...) default to no-restart. DownloadSettings/OverlaySettings
drop their hardcoded restartRequired={false} special-casing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(logging): hot-reload server.log_level and server.log_quiet

Share one slog.LevelVar across the handler chain and make
logfilter.Handler's quiet-prefix list an atomic pointer shared with
WithAttrs/WithGroup clones (New previously returned the inner handler
unwrapped when the quiet list was empty, leaving nothing to update).
The integrated-mode config watcher now applies both settings live;
their keys leave the restart-required registry.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(auth): hot-reload access/refresh token expiries

JWTService stores expiries as atomics with a SetExpiries hook; all three
instances (main API, ABS compat, jellycompat) re-apply them on config
reload. Applies to newly issued tokens; outstanding tokens keep their
original expiry. The JWT secret stays fixed for the process lifetime.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(playback): read transcode config live at session start

The playback and stream handlers pull ffmpeg path / hwaccel / transcode
dir from the live config when starting a transcode or extracting
subtitles, instead of values frozen at router construction. Each session
snapshots the config once so its output dir and binary stay consistent.

Also fixes a real bug: playback.hw_device was parsed into the config but
never wired into the integrated-mode handler, so local transcodes always
ran with an empty HWDevice while transcode nodes honored it.

playback.transcode_dir leaves the restart-required registry (the handler
is its only consumer); ffmpeg_path/hw_accel stay restart-required until
scanner/chapterthumbs/audiobook consumers convert.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(jellycompat): read compat identity settings live per request

System/Auth handlers take a config provider instead of the startup
snapshot, so jellyfin_compat.public_url, .server_name, and
.emulated_server_version apply without restart. server_id stays
restart-required (generate-once, baked into the resource mapper), as do
the session-store TTLs.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(scanner,metadata,mdblist): hot-reload worker pools and API key

scanner.workers, matcher.workers/batch_size, metadata.cache_images, and
mdblist.api_key convert to atomic fields with setters wired to the
config watcher. Worker counts apply on the next scan/match cycle (the
loops read them per cycle); the MDBList key applies to the next request.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(ai): hot-reload AI connection, models, toggles, and quotas

The shared llm.Client holds its config behind an atomic pointer
(UpdateConfig; each request snapshots once), and the subtitle/metadata
AI services gain UpdateConfig plus setters on the translator (batching)
and Whisper transcriber (ffmpeg path, chunk seconds). The router derives
their configs from shared helpers used both at construction and in
OnConfigChange callbacks, re-evaluating the chat-only-gateway transcribe
guard on each reload and warning only when it newly fires.

Everything on the AI Services page now applies without restart except
ai.max_concurrent_jobs (fixed-capacity dispatch semaphore).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(playback): wire transcode_enabled; remove dead playback/scanner knobs

playback.transcode_enabled was parsed into the config but the resolver
always received a hardcoded true — the admin toggle did nothing. It now
reads the live config per playback start, so disabling transcodes
applies without restart.

Remove settings that were wired to nothing so 'save + restart' stops
pretending: playback.allow_hevc_encoding (resolver field never
assigned), playback.transcode_ahead_segments and
playback.segment_duration (parsed, never consumed — segment duration is
per-session from the client), scanner.file_removal_grace (DeleteMissing
is never called). UI fields removed and the config struct fields pruned
so they don't resurrect; YAML import still tolerates the legacy keys.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 19:25:07 -04:00

543 lines
20 KiB
Go

package config
import (
"fmt"
"log/slog"
"strconv"
"time"
"github.com/google/uuid"
subtitleai "github.com/Silo-Server/silo-server/internal/subtitles/ai"
)
// stringOr returns the value from the map for the given key, or the fallback if absent/empty.
func stringOr(m map[string]string, key, fallback string) string {
if v, ok := m[key]; ok && v != "" {
return v
}
return fallback
}
func firstConfiguredString(m map[string]string, fallback string, keys ...string) string {
for _, key := range keys {
if v, ok := m[key]; ok && v != "" {
return v
}
}
return fallback
}
// intOr returns the parsed integer value from the map for the given key, or the fallback if absent/empty.
// Returns an error if the value is present but cannot be parsed.
func intOr(m map[string]string, key string, fallback int) (int, error) {
v, ok := m[key]
if !ok || v == "" {
return fallback, nil
}
n, err := strconv.Atoi(v)
if err != nil {
return 0, fmt.Errorf("invalid int for %q: %w", key, err)
}
return n, nil
}
// boolOr returns the parsed boolean value from the map for the given key, or the fallback if absent/empty.
// Returns an error if the value is present but cannot be parsed.
func boolOr(m map[string]string, key string, fallback bool) (bool, error) {
v, ok := m[key]
if !ok || v == "" {
return fallback, nil
}
b, err := strconv.ParseBool(v)
if err != nil {
return false, fmt.Errorf("invalid bool for %q: %w", key, err)
}
return b, nil
}
func firstConfiguredBool(m map[string]string, fallback bool, keys ...string) (bool, error) {
for _, key := range keys {
v, ok := m[key]
if !ok || v == "" {
continue
}
b, err := strconv.ParseBool(v)
if err != nil {
return false, fmt.Errorf("invalid bool for %q: %w", key, err)
}
return b, nil
}
return fallback, nil
}
// int64Or returns the parsed int64 value from the map for the given key, or the fallback if absent/empty.
func int64Or(m map[string]string, key string, fallback int64) (int64, error) {
v, ok := m[key]
if !ok || v == "" {
return fallback, nil
}
n, err := strconv.ParseInt(v, 10, 64)
if err != nil {
return 0, fmt.Errorf("invalid int64 for %q: %w", key, err)
}
return n, nil
}
func firstConfiguredInt(m map[string]string, fallback int, keys ...string) (int, error) {
for _, key := range keys {
v, ok := m[key]
if !ok || v == "" {
continue
}
n, err := strconv.Atoi(v)
if err != nil {
return 0, fmt.Errorf("invalid int for %q: %w", key, err)
}
return n, nil
}
return fallback, nil
}
// floatOr returns the parsed float64 value from the map for the given key, or the fallback if absent/empty.
func floatOr(m map[string]string, key string, fallback float64) (float64, error) {
v, ok := m[key]
if !ok || v == "" {
return fallback, nil
}
f, err := strconv.ParseFloat(v, 64)
if err != nil {
return 0, fmt.Errorf("invalid float for %q: %w", key, err)
}
return f, nil
}
// durationOr returns the parsed duration value from the map for the given key, or the fallback if absent/empty.
// Returns an error if the value is present but cannot be parsed.
func durationOr(m map[string]string, key string, fallback time.Duration) (time.Duration, error) {
v, ok := m[key]
if !ok || v == "" {
return fallback, nil
}
d, err := parseDuration(v)
if err != nil {
return 0, fmt.Errorf("invalid duration for %q: %w", key, err)
}
return d, nil
}
// defaultJellyfinCompatServerIDFromDB is the computed default server ID used when
// no server_id is set in the database settings.
var defaultJellyfinCompatServerIDFromDB = uuid.NewSHA1(
uuid.NameSpaceURL,
[]byte("https://silo.local/jellycompat"),
).String()
// LoadFromDB builds a Config from a map of server_settings key-value pairs.
// The map keys use dot-notation (e.g., "auth.jwt_secret"). Missing or empty
// keys fall back to the same defaults as setDefaults().
func LoadFromDB(m map[string]string) (*Config, error) {
cfg := &Config{}
// Server
cfg.Server.Listen = stringOr(m, "server.listen", ":8080")
cfg.Server.Mode = stringOr(m, "server.mode", "integrated")
cfg.Server.LogLevel = stringOr(m, "server.log_level", "info")
cfg.Server.LogFormat = stringOr(m, "server.log_format", "text")
cfg.Server.LogQuiet = stringOr(m, "server.log_quiet", "")
// Database
cfg.Database.URL = stringOr(m, "database.url", "")
maxConn, err := intOr(m, "database.max_connections", 20)
if err != nil {
return nil, err
}
cfg.Database.MaxConnections = maxConn
// S3 — Public assets
cfg.S3.Public.Endpoint = firstConfiguredString(m, "", "s3.public_endpoint", "s3.operational_endpoint")
cfg.S3.Public.ReadEndpoint = firstConfiguredString(m, "", "s3.public_read_endpoint", "s3.operational_public_endpoint")
cfg.S3.Public.Region = firstConfiguredString(m, "", "s3.public_region", "s3.operational_region")
publicPathStyle, err := firstConfiguredBool(m, true, "s3.public_path_style", "s3.operational_path_style")
if err != nil {
return nil, err
}
cfg.S3.Public.PathStyle = publicPathStyle
cfg.S3.Public.Bucket = firstConfiguredString(m, "", "s3.public_bucket", "s3.operational_bucket")
cfg.S3.Public.KeyPrefix = firstConfiguredString(m, "", "s3.public_key_prefix", "s3.operational_key_prefix")
cfg.S3.Public.AccessKey = firstConfiguredString(m, "", "s3.public_access_key", "s3.operational_access_key")
cfg.S3.Public.SecretKey = firstConfiguredString(m, "", "s3.public_secret_key", "s3.operational_secret_key")
cfg.S3.Public.URLAuth = firstConfiguredString(m, "", "s3.public_url_auth", "s3.operational_url_auth")
cfg.S3.Public.TokenSecret = firstConfiguredString(m, "", "s3.public_token_secret", "s3.operational_token_secret")
cfg.S3.Public.TokenParam = firstConfiguredString(m, "", "s3.public_token_param", "s3.operational_token_param")
publicTokenTTL, err := firstConfiguredInt(m, 0, "s3.public_token_ttl", "s3.operational_token_ttl")
if err != nil {
return nil, err
}
cfg.S3.Public.TokenTTL = publicTokenTTL
metadataPresignExpiry, err := durationOr(m, "s3.metadata_presign_expiry", 4*time.Hour)
if err != nil {
return nil, err
}
cfg.S3.MetadataPresignExpiry = metadataPresignExpiry
// S3 — Private internal
cfg.S3.Private.Endpoint = firstConfiguredString(m, "", "s3.private_endpoint", "s3.operational_endpoint")
cfg.S3.Private.Region = firstConfiguredString(m, "", "s3.private_region", "s3.operational_region")
privatePathStyle, err := firstConfiguredBool(m, true, "s3.private_path_style", "s3.operational_path_style")
if err != nil {
return nil, err
}
cfg.S3.Private.PathStyle = privatePathStyle
cfg.S3.Private.Bucket = firstConfiguredString(m, "", "s3.private_bucket", "s3.operational_bucket")
cfg.S3.Private.KeyPrefix = firstConfiguredString(m, "", "s3.private_key_prefix", "s3.operational_key_prefix")
cfg.S3.Private.AccessKey = firstConfiguredString(m, "", "s3.private_access_key", "s3.operational_access_key")
cfg.S3.Private.SecretKey = firstConfiguredString(m, "", "s3.private_secret_key", "s3.operational_secret_key")
// S3 — User DB
cfg.S3.UserDB.Endpoint = stringOr(m, "s3.user_db_endpoint", "")
cfg.S3.UserDB.Region = stringOr(m, "s3.user_db_region", "")
userDBPathStyle, err := boolOr(m, "s3.user_db_path_style", true)
if err != nil {
return nil, err
}
cfg.S3.UserDB.PathStyle = userDBPathStyle
cfg.S3.UserDB.Bucket = stringOr(m, "s3.user_db_bucket", "")
cfg.S3.UserDB.KeyPrefix = stringOr(m, "s3.user_db_key_prefix", "")
cfg.S3.UserDB.AccessKey = stringOr(m, "s3.user_db_access_key", "")
cfg.S3.UserDB.SecretKey = stringOr(m, "s3.user_db_secret_key", "")
// TMDB collection presets (independent of metadata providers)
cfg.TMDBAPIKey = stringOr(m, "tmdb.api_key", "")
// MDBList list search/browse (lists themselves are public; only discovery
// endpoints require an apikey).
cfg.MDBListAPIKey = stringOr(m, "mdblist.api_key", "")
// UserDB
cfg.UserDB.Backend = stringOr(m, "userdb.backend", "postgres")
poolMaxOpen, err := intOr(m, "userdb.pool_max_open", 500)
if err != nil {
return nil, err
}
cfg.UserDB.PoolMaxOpen = poolMaxOpen
idleTimeout, err := durationOr(m, "userdb.idle_timeout", 12*time.Hour)
if err != nil {
return nil, err
}
cfg.UserDB.IdleTimeout = idleTimeout
litestreamSync, err := durationOr(m, "userdb.litestream_sync", 1*time.Second)
if err != nil {
return nil, err
}
cfg.UserDB.LitestreamSync = litestreamSync
staleGrace, err := intOr(m, "userdb.stale_grace_seconds", 120)
if err != nil {
return nil, err
}
cfg.UserDB.StaleGraceSeconds = staleGrace
// Scanner (schedule is now managed by the task manager, not config)
scannerWorkers, err := intOr(m, "scanner.workers", 8)
if err != nil {
return nil, err
}
cfg.Scanner.Workers = scannerWorkers
maxConcurrentLibraries, err := intOr(m, "scanner.max_concurrent_libraries", 1)
if err != nil {
return nil, err
}
cfg.Scanner.MaxConcurrentLibraries = maxConcurrentLibraries
maxConcurrentScoped, err := intOr(m, "scanner.max_concurrent_scoped", 2)
if err != nil {
return nil, err
}
cfg.Scanner.MaxConcurrentScoped = maxConcurrentScoped
emptyTrash, err := boolOr(m, "scanner.empty_trash_after_scan", true)
if err != nil {
return nil, err
}
cfg.Scanner.EmptyTrashAfterScan = emptyTrash
// Matcher
matcherWorkers, err := intOr(m, "matcher.workers", 8)
if err != nil {
return nil, err
}
cfg.Matcher.Workers = matcherWorkers
batchSize, err := intOr(m, "matcher.batch_size", 500)
if err != nil {
return nil, err
}
cfg.Matcher.BatchSize = batchSize
enableTVSeriesRootQueue, err := boolOr(m, "matcher.enable_tv_series_root_queue", true)
if err != nil {
return nil, err
}
if !enableTVSeriesRootQueue {
enableTVSeriesRootQueue, err = boolOr(m, "matcher.enable_tv_series_group_queue", false)
if err != nil {
return nil, err
}
}
cfg.Matcher.EnableTVSeriesRootQueue = enableTVSeriesRootQueue
// Metadata
cacheImages, err := boolOr(m, "metadata.cache_images", false)
if err != nil {
return nil, err
}
cfg.Metadata.CacheImages = cacheImages
// Playback
cfg.Playback.FFmpegPath = stringOr(m, "playback.ffmpeg_path", "/usr/lib/jellyfin-ffmpeg/ffmpeg")
cfg.Playback.TranscodeDir = stringOr(m, "playback.transcode_dir", "/tmp/silo-transcode")
cfg.Playback.HWAccel = stringOr(m, "playback.hw_accel", "auto")
cfg.Playback.HWDevice = stringOr(m, "playback.hw_device", "")
chapterThumbnailWorkers, err := intOr(m, "playback.chapter_thumbnail_workers", 1)
if err != nil {
return nil, err
}
cfg.Playback.ChapterThumbnailWorkers = chapterThumbnailWorkers
cfg.Playback.ChapterThumbnailExecution = stringOr(m, "playback.chapter_thumbnail_execution", "local")
chapterThumbnailNodeCapacity, err := intOr(m, "playback.chapter_thumbnail_node_capacity", 1)
if err != nil {
return nil, err
}
cfg.Playback.ChapterThumbnailNodeCapacity = chapterThumbnailNodeCapacity
transcodeEnabled, err := boolOr(m, "playback.transcode_enabled", true)
if err != nil {
return nil, err
}
cfg.Playback.TranscodeEnabled = transcodeEnabled
// Redis
cfg.Redis.URL = stringOr(m, "redis.url", "")
cfg.Redis.SentinelMaster = stringOr(m, "redis.sentinel_master", "")
cfg.Redis.SentinelPassword = stringOr(m, "redis.sentinel_password", "")
// SentinelAddresses loaded from YAML only (slice not suitable for key-value settings)
// Rate Limiting
rateLimitEnabled, err := boolOr(m, "ratelimit.enabled", true)
if err != nil {
return nil, err
}
cfg.RateLimit.Enabled = rateLimitEnabled
cfg.RateLimit.Backend = stringOr(m, "ratelimit.backend", "memory")
// Auth
cfg.Auth.JWTSecret = stringOr(m, "auth.jwt_secret", "")
accessTokenExpiry, err := durationOr(m, "auth.access_token_expiry", 8*time.Hour)
if err != nil {
return nil, err
}
cfg.Auth.AccessTokenExpiry = accessTokenExpiry
refreshTokenExpiry, err := durationOr(m, "auth.refresh_token_expiry", 30*24*time.Hour)
if err != nil {
return nil, err
}
cfg.Auth.RefreshTokenExpiry = refreshTokenExpiry
// AudiobookshelfCompat — dedicated listener for ABS client apps. Docker
// deployments publish :13378, so listen by default unless disabled.
absCompatEnabled, err := boolOr(m, "audiobookshelf_compat.enabled", true)
if err != nil {
return nil, err
}
if absCompatEnabled {
cfg.AudiobookshelfCompat.Listen = ":13378"
}
// JellyfinCompat
cfg.JellyfinCompat.Listen = stringOr(m, "jellyfin_compat.listen", ":8096")
cfg.JellyfinCompat.PublicURL = stringOr(m, "jellyfin_compat.public_url", "http://127.0.0.1:8096")
cfg.JellyfinCompat.EmulatedServerVersion = stringOr(m, "jellyfin_compat.emulated_server_version", "10.12.0")
cfg.JellyfinCompat.ServerID = stringOr(m, "jellyfin_compat.server_id", defaultJellyfinCompatServerIDFromDB)
cfg.JellyfinCompat.ServerName = stringOr(m, "jellyfin_compat.server_name", "Silo")
cfg.JellyfinCompat.WebVersion = stringOr(m, "jellyfin_compat.web_version", DefaultJellyfinWebVersion)
cfg.JellyfinCompat.WebDir = stringOr(m, "jellyfin_compat.web_dir", DefaultBundledJellyfinWebDir)
sessionTTL, err := durationOr(m, "jellyfin_compat.session_ttl", 87600*time.Hour)
if err != nil {
return nil, err
}
cfg.JellyfinCompat.SessionTTL = sessionTTL
playbackSessionTTL, err := durationOr(m, "jellyfin_compat.playback_session_ttl", 6*time.Hour)
if err != nil {
return nil, err
}
cfg.JellyfinCompat.PlaybackSessionTTL = playbackSessionTTL
// Recommendations
recsEnabled, err := boolOr(m, "recommendations.enabled", false)
if err != nil {
return nil, err
}
cfg.Recommendations.Enabled = recsEnabled
legacyOpenAIConfigured := m["recommendations.openai_model"] != "" || m["recommendations.openai_api_key"] != "" || m["recommendations.embedding_provider"] == "openai"
cfg.Recommendations.EmbeddingBaseURL = stringOr(m, "recommendations.embedding_base_url", func() string {
if legacyOpenAIConfigured {
return "https://api.openai.com"
}
return "http://ollama:11434"
}())
cfg.Recommendations.EmbeddingModel = stringOr(m, "recommendations.embedding_model", func() string {
if model := m["recommendations.openai_model"]; model != "" {
return model
}
if m["recommendations.embedding_provider"] == "openai" {
return "text-embedding-3-small"
}
return "all-minilm"
}())
cfg.Recommendations.EmbeddingAuthToken = stringOr(m, "recommendations.embedding_auth_token", stringOr(m, "recommendations.openai_api_key", ""))
cfg.Recommendations.EmbeddingsCron = stringOr(m, "recommendations.embeddings_cron", "0 3 * * *")
cfg.Recommendations.TasteProfilesCron = stringOr(m, "recommendations.taste_profiles_cron", "0 4 * * *")
cfg.Recommendations.RecommendationsCron = stringOr(m, "recommendations.recommendations_cron", "0 5 * * *")
tasteDecayHalfLife, err := floatOr(m, "recommendations.taste_decay_half_life_days", 180)
if err != nil {
return nil, err
}
cfg.Recommendations.TasteDecayHalfLifeDays = tasteDecayHalfLife
diversityLambda, err := floatOr(m, "recommendations.diversity_lambda", 0.7)
if err != nil {
return nil, err
}
cfg.Recommendations.DiversityLambda = diversityLambda
cfg.Recommendations.CowatchCron = stringOr(m, "recommendations.cowatch_cron", "30 4 * * *")
// Shared AI endpoint (subtitle translation, metadata translation, Whisper
// ASR). The connection settings read ai.* with a fallback to the legacy
// subtitle_ai.* rows; the legacy rows are never renamed in SQL because
// encrypted values are GCM-bound to their setting key.
cfg.AI.BaseURL = stringOr(m, "ai.base_url", stringOr(m, "subtitle_ai.base_url", "https://api.openai.com"))
cfg.AI.APIKey = stringOr(m, "ai.api_key", stringOr(m, "subtitle_ai.api_key", ""))
cfg.AI.ChatModel = stringOr(m, "ai.chat_model", stringOr(m, "subtitle_ai.chat_model", "gpt-4o-mini"))
cfg.AI.ASRBaseURL = stringOr(m, "ai.asr_base_url", "")
cfg.AI.ASRAPIKey = stringOr(m, "ai.asr_api_key", "")
cfg.AI.ASRModel = stringOr(m, "ai.asr_model", "whisper-1")
aiMaxConcurrent, err := intOr(m, "ai.max_concurrent_jobs", 0)
if err != nil {
return nil, err
}
if aiMaxConcurrent <= 0 {
if aiMaxConcurrent, err = intOr(m, "subtitle_ai.max_concurrent_jobs", 2); err != nil {
return nil, err
}
}
cfg.AI.MaxConcurrentJobs = aiMaxConcurrent
// Subtitle AI feature toggles and tuning.
subtitleAIEnabled, err := boolOr(m, "subtitle_ai.enabled", false)
if err != nil {
return nil, err
}
cfg.SubtitleAI.Enabled = subtitleAIEnabled
subtitleAITranscribe, err := boolOr(m, "subtitle_ai.transcribe_enabled", false)
if err != nil {
return nil, err
}
cfg.SubtitleAI.TranscribeEnabled = subtitleAITranscribe
subtitleAIBatchSize, err := intOr(m, "subtitle_ai.batch_size", 40)
if err != nil {
return nil, err
}
cfg.SubtitleAI.BatchSize = subtitleAIBatchSize
subtitleAIContextNeighbors, err := intOr(m, "subtitle_ai.context_neighbors", 2)
if err != nil {
return nil, err
}
cfg.SubtitleAI.ContextNeighbors = subtitleAIContextNeighbors
subtitleAIChunkSeconds, err := intOr(m, "subtitle_ai.asr_chunk_seconds", 600)
if err != nil {
return nil, err
}
cfg.SubtitleAI.ASRChunkSeconds = subtitleAIChunkSeconds
// A bad quota row must not block startup: fall back to "no quota" /
// the daily window with a warning instead of failing the config load.
transcribeQuotaJobs, err := intOr(m, "subtitle_ai.transcribe_quota_jobs", 0)
if err != nil || transcribeQuotaJobs < 0 {
slog.Warn("invalid subtitle_ai.transcribe_quota_jobs setting; quota disabled",
"value", m["subtitle_ai.transcribe_quota_jobs"], "error", err)
transcribeQuotaJobs = 0
}
cfg.SubtitleAI.TranscribeQuotaJobs = transcribeQuotaJobs
period := stringOr(m, "subtitle_ai.transcribe_quota_period", subtitleai.QuotaPeriodDay)
if !subtitleai.ValidQuotaPeriod(period) {
slog.Warn("invalid subtitle_ai.transcribe_quota_period setting; using day", "value", period)
period = subtitleai.QuotaPeriodDay
}
cfg.SubtitleAI.TranscribeQuotaPeriod = period
// Metadata AI translation feature toggles.
metadataAIEnabled, err := boolOr(m, "metadata_ai.enabled", false)
if err != nil {
return nil, err
}
cfg.MetadataAI.Enabled = metadataAIEnabled
switch onView := stringOr(m, "metadata_ai.on_view", "off"); onView {
case "off", "button", "auto":
cfg.MetadataAI.OnView = onView
default:
// A bad row must not block startup; the feature just stays off.
slog.Warn("invalid metadata_ai.on_view setting; using off", "value", onView)
cfg.MetadataAI.OnView = "off"
}
// Download
downloadEnabled, err := boolOr(m, "download.enabled", false)
if err != nil {
return nil, err
}
cfg.Download.Enabled = downloadEnabled
serverBandwidthMbps, err := int64Or(m, "download.server_bandwidth_mbps", 0)
if err != nil {
return nil, err
}
userBandwidthMbps, err := int64Or(m, "download.user_bandwidth_mbps", 0)
if err != nil {
return nil, err
}
maxConcurrent, err := intOr(m, "download.max_concurrent_per_user", 3)
if err != nil {
return nil, err
}
maxPerPeriod, err := intOr(m, "download.max_per_period", 0)
if err != nil {
return nil, err
}
periodDuration, err := durationOr(m, "download.period_duration", 24*time.Hour)
if err != nil {
return nil, err
}
// Validate download config values.
if serverBandwidthMbps < 0 {
return nil, fmt.Errorf("invalid value for %q: must be non-negative", "download.server_bandwidth_mbps")
}
if userBandwidthMbps < 0 {
return nil, fmt.Errorf("invalid value for %q: must be non-negative", "download.user_bandwidth_mbps")
}
const maxMbps int64 = 73_786_976_294_838 // math.MaxInt64 / 125000
if serverBandwidthMbps > maxMbps {
return nil, fmt.Errorf("invalid value for %q: exceeds maximum", "download.server_bandwidth_mbps")
}
if userBandwidthMbps > maxMbps {
return nil, fmt.Errorf("invalid value for %q: exceeds maximum", "download.user_bandwidth_mbps")
}
if maxConcurrent < 0 {
return nil, fmt.Errorf("invalid value for %q: must be non-negative", "download.max_concurrent_per_user")
}
if maxPerPeriod < 0 {
return nil, fmt.Errorf("invalid value for %q: must be non-negative", "download.max_per_period")
}
if maxPerPeriod > 0 && periodDuration <= 0 {
return nil, fmt.Errorf("invalid config: %q requires a positive %q", "download.max_per_period", "download.period_duration")
}
cfg.Download.ServerBandwidthBPS = serverBandwidthMbps * 125000 // Mbps → bytes/sec
cfg.Download.UserBandwidthBPS = userBandwidthMbps * 125000 // Mbps → bytes/sec
cfg.Download.MaxConcurrentPerUser = maxConcurrent
cfg.Download.MaxPerPeriod = maxPerPeriod
cfg.Download.PeriodDuration = periodDuration
return cfg, nil
}