feat: closed-set admin knobs are pickers, not free text

log_level, search_source, download_layout and the transcode hw/preset
were typed by hand, and every consumer carried its own guess at what a
typo meant. The value set is now a Literal in config.py: pydantic
rejects a bad env value at boot (any letter case accepted), sysconfig
refuses a bad override and derives the admin picker's options from the
same Literal, and the consumers read the value as-is.

The Configuration page renders a choice key as a segmented bar, or a
dropdown past six options, with translated option names. Its tabs now
follow the registry's order instead of a hand-kept copy that had
already dropped the explore group.

A single-choice FilterSelect now opens on the option in use: in the
browser it could land on the first row instead.
This commit is contained in:
2026-09-17 00:03:56 +02:00
parent 6c63df6704
commit 07766c43e8
15 changed files with 496 additions and 93 deletions
+3 -3
View File
@@ -30,9 +30,9 @@ ADMIN_EMAILS=
# Logging (E22). LOG_LEVEL is the BOOTSTRAP level — the level a process starts at and the only one # Logging (E22). LOG_LEVEL is the BOOTSTRAP level — the level a process starts at and the only one
# the worker/download child read; the API's running level is the admin Configuration page's # the worker/download child read; the API's running level is the admin Configuration page's
# `log_level` knob, which takes effect without a restart. TRACE | DEBUG | INFO | WARNING | ERROR # `log_level` knob, which takes effect without a restart. TRACE | DEBUG | INFO | WARNING | ERROR, in
# (an unknown value falls back to INFO). LOG_FORMAT is "json" (one object per line — what a log # any letter case; any other value stops the boot with an error. LOG_FORMAT is "json" (one object per
# collector ingests) or "text" (a readable line for a local terminal). # line — what a log collector ingests) or "text" (a readable line for a local terminal).
LOG_LEVEL=INFO LOG_LEVEL=INFO
LOG_FORMAT=json LOG_FORMAT=json
+39 -7
View File
@@ -1,6 +1,7 @@
from pathlib import Path from pathlib import Path
from typing import Literal, get_args, get_origin
from pydantic import model_validator from pydantic import ValidationInfo, field_validator, model_validator
from pydantic_settings import BaseSettings, SettingsConfigDict from pydantic_settings import BaseSettings, SettingsConfigDict
# Shipped placeholder; production must override it. Used by the startup guard below. # Shipped placeholder; production must override it. Used by the startup guard below.
@@ -20,6 +21,25 @@ def _version_from_file() -> str:
return "dev" return "dev"
# Closed value sets. A setting typed with one of these is a CHOICE: pydantic rejects anything else at
# boot, and sysconfig derives the admin picker's options from the same Literal — so the list lives in
# exactly one place and a consumer never has to guess what a typo should fall back to.
SearchSource = Literal["scrape", "api"]
DownloadLayout = Literal["plex", "flat"]
LogLevel = Literal["TRACE", "DEBUG", "INFO", "WARNING", "ERROR"]
LogFormat = Literal["json", "text"]
TranscodeHw = Literal["auto", "on", "off"]
# libx264's presets, fastest first (`placebo` left out on purpose: no measurable gain for its cost).
TranscodePreset = Literal[
"ultrafast", "superfast", "veryfast", "faster", "fast", "medium", "slow", "slower", "veryslow"
]
def choices_of(annotation: object) -> tuple[str, ...]:
"""The allowed values of a choice-typed setting, or () for any other type."""
return tuple(get_args(annotation)) if get_origin(annotation) is Literal else ()
class Settings(BaseSettings): class Settings(BaseSettings):
model_config = SettingsConfigDict(env_file=".env", extra="ignore") model_config = SettingsConfigDict(env_file=".env", extra="ignore")
@@ -85,7 +105,7 @@ class Settings(BaseSettings):
search_daily_limit_per_user: int = 70 search_daily_limit_per_user: int = 70
# Source for live YouTube search: "scrape" (InnerTube, no API quota) or "api" (search.list, # Source for live YouTube search: "scrape" (InnerTube, no API quota) or "api" (search.list,
# 100 units/page). Scrape is the default; flip to "api" if scraping is blocked/breaks. # 100 units/page). Scrape is the default; flip to "api" if scraping is blocked/breaks.
search_source: str = "scrape" search_source: SearchSource = "scrape"
# Recent-first backfill: how far back to fetch on the first pass per channel. # Recent-first backfill: how far back to fetch on the first pass per channel.
backfill_recent_max_videos: int = 100 backfill_recent_max_videos: int = 100
backfill_recent_max_days: int = 365 backfill_recent_max_days: int = 365
@@ -221,7 +241,7 @@ class Settings(BaseSettings):
audit_retention_days: int = 180 audit_retention_days: int = 180
audit_gc_minutes: int = 1440 audit_gc_minutes: int = 1440
# On-disk layout: "plex" (Channel/Season YYYY/… [id].ext + .nfo/poster) or "flat". # On-disk layout: "plex" (Channel/Season YYYY/… [id].ext + .nfo/poster) or "flat".
download_layout: str = "plex" download_layout: DownloadLayout = "plex"
# Default SponsorBlock (skip sponsor segments) for new custom profiles. # Default SponsorBlock (skip sponsor segments) for new custom profiles.
sponsorblock_default: bool = False sponsorblock_default: bool = False
@@ -230,10 +250,10 @@ class Settings(BaseSettings):
# startup and on change without a restart. This is what a process logs at before it can reach # startup and on change without a restart. This is what a process logs at before it can reach
# the database — and the only knob the worker/runner children read, since they are short-lived. # the database — and the only knob the worker/runner children read, since they are short-lived.
# TRACE (below DEBUG) is the "Teljes" diagnostics level; see app/logs.py. # TRACE (below DEBUG) is the "Teljes" diagnostics level; see app/logs.py.
log_level: str = "INFO" log_level: LogLevel = "INFO"
# "json" (one object per line — what Loki and the log_events sink ingest) or "text" (a readable # "json" (one object per line — what Loki and the log_events sink ingest) or "text" (a readable
# line for a local terminal). Redaction applies to both. # line for a local terminal). Redaction applies to both.
log_format: str = "json" log_format: LogFormat = "json"
# --- Plex integration (optional) --- # --- Plex integration (optional) ---
# The Plex module plays the LOCAL physical media file directly (our own ffmpeg/direct-serve); # The Plex module plays the LOCAL physical media file directly (our own ffmpeg/direct-serve);
@@ -276,7 +296,7 @@ class Settings(BaseSettings):
# Hardware-encoder policy: "auto" = VAAPI (h264_vaapi) when the iGPU render node is present AND the # Hardware-encoder policy: "auto" = VAAPI (h264_vaapi) when the iGPU render node is present AND the
# source codec is hardware-decodable, else libx264 software; "on" = force VAAPI wherever the render # source codec is hardware-decodable, else libx264 software; "on" = force VAAPI wherever the render
# node exists (skip the decodability gate); "off" = always software. See plex/stream.py `_select_encode`. # node exists (skip the decodability gate); "off" = always software. See plex/stream.py `_select_encode`.
plex_transcode_hw: str = "auto" plex_transcode_hw: TranscodeHw = "auto"
# Software-fallback libx264 tuning (IGNORED on the VAAPI path). `preset` trades CPU for size; `crf` # Software-fallback libx264 tuning (IGNORED on the VAAPI path). `preset` trades CPU for size; `crf`
# trades quality for size (lower = better + bigger). `max_height` caps the SOFTWARE transcode's output # trades quality for size (lower = better + bigger). `max_height` caps the SOFTWARE transcode's output
# height (0 = keep source) so a CPU re-encode of a tall source stays affordable — the GPU path always # height (0 = keep source) so a CPU re-encode of a tall source stays affordable — the GPU path always
@@ -286,7 +306,7 @@ class Settings(BaseSettings):
# call, not a fix. Only affects files that go through remux/transcode — a direct-played file is # call, not a fix. Only affects files that go through remux/transcode — a direct-played file is
# served untouched, but those are browser-native stereo anyway, not the 5.1 mixes this is for. # served untouched, but those are browser-native stereo anyway, not the 5.1 mixes this is for.
plex_audio_loudnorm: bool = False plex_audio_loudnorm: bool = False
plex_transcode_preset: str = "veryfast" plex_transcode_preset: TranscodePreset = "veryfast"
plex_transcode_crf: int = 20 plex_transcode_crf: int = 20
plex_transcode_max_height: int = 1080 plex_transcode_max_height: int = 1080
# Where on-the-fly HLS remux segments are written (transient, reaped). Empty → a `.plex-hls` # Where on-the-fly HLS remux segments are written (transient, reaped). Empty → a `.plex-hls`
@@ -335,6 +355,18 @@ class Settings(BaseSettings):
https OAUTH_REDIRECT_URL as the signal for 'real deployment' vs local dev.""" https OAUTH_REDIRECT_URL as the signal for 'real deployment' vs local dev."""
return self.oauth_redirect_url.lower().startswith("https://") return self.oauth_redirect_url.lower().startswith("https://")
@field_validator("*", mode="before")
@classmethod
def _canonical_choice(cls, value: object, info: ValidationInfo) -> object:
"""A choice setting accepts its value in any letter case (`LOG_LEVEL=debug`) and stores the
canonical spelling; an unknown value still fails the boot with pydantic's own message."""
field = cls.model_fields.get(info.field_name or "")
options = choices_of(field.annotation) if field else ()
if options and isinstance(value, str):
folded = value.strip().casefold()
return next((o for o in options if o.casefold() == folded), value)
return value
@model_validator(mode="after") @model_validator(mode="after")
def _enforce_production_secrets(self) -> "Settings": def _enforce_production_secrets(self) -> "Settings":
"""Fail fast rather than boot a public instance with the shipped placeholder """Fail fast rather than boot a public instance with the shipped placeholder
+13 -13
View File
@@ -295,20 +295,20 @@ _THIRD_PARTY_LEVELS = {
} }
def resolve_level(raw: str | None) -> int: def resolve_level(name: str) -> int:
"""Level name (case-insensitive) → number, falling back to INFO for an unknown value. A typo in """Level name → number. Both ways in (the `LOG_LEVEL` env and the `log_level` admin knob) are
a config knob must not silence the app.""" already held to the `LogLevel` choices, so an unknown name here is a programming error."""
if not raw: value = logging.getLevelName(name)
return logging.INFO if not isinstance(value, int):
value = logging.getLevelName(raw.strip().upper()) raise ValueError(f"unknown log level {name!r}")
return value if isinstance(value, int) else logging.INFO return value
def configure_logging(source: str, *, level: str | None = None) -> None: def configure_logging(source: str) -> None:
"""Configure logging for THIS process. Call once, first thing, in every entry point: """Configure logging for THIS process. Call once, first thing, in every entry point:
`main.py` (api), `worker.main`, `downloads/runner.main`, `alembic/env.py`. `main.py` (api), `worker.main`, `downloads/runner.main`, `alembic/env.py`.
`source` names the process in every line. `level` overrides `LOG_LEVEL` (the bootstrap default); `source` names the process in every line. The level starts at `LOG_LEVEL` (the bootstrap default);
the runtime admin knob goes through `set_level()` instead, so changing it needs no restart. the runtime admin knob goes through `set_level()` instead, so changing it needs no restart.
Our loggers deliberately keep `propagate=True` and own no handler: everything reaches the single Our loggers deliberately keep `propagate=True` and own no handler: everything reaches the single
@@ -316,7 +316,7 @@ def configure_logging(source: str, *, level: str | None = None) -> None:
is configured the moment it is created.""" is configured the moment it is created."""
global _source global _source
_source = source _source = source
formatter = "json" if (settings.log_format or "json").lower() != "text" else "text" formatter = settings.log_format
logging.config.dictConfig( logging.config.dictConfig(
{ {
"version": 1, "version": 1,
@@ -336,16 +336,16 @@ def configure_logging(source: str, *, level: str | None = None) -> None:
"filters": ["context"], "filters": ["context"],
} }
}, },
"root": {"level": resolve_level(level or settings.log_level), "handlers": ["stdout"]}, "root": {"level": resolve_level(settings.log_level), "handlers": ["stdout"]},
"loggers": {name: {"level": lvl} for name, lvl in _THIRD_PARTY_LEVELS.items()}, "loggers": {name: {"level": lvl} for name, lvl in _THIRD_PARTY_LEVELS.items()},
} }
) )
def set_level(raw: str | None) -> None: def set_level(name: str) -> None:
"""Change the running process's level (the `log_level` admin knob). Third-party loggers keep """Change the running process's level (the `log_level` admin knob). Third-party loggers keep
their pinned levels, so turning on DEBUG shows OUR detail, not SQLAlchemy's.""" their pinned levels, so turning on DEBUG shows OUR detail, not SQLAlchemy's."""
logging.getLogger().setLevel(resolve_level(raw)) logging.getLogger().setLevel(resolve_level(name))
def current_level() -> str: def current_level() -> str:
+2 -2
View File
@@ -404,7 +404,7 @@ def _select_encode(db: DbSession, item: PlexItem, transcode: bool, src: Path) ->
source that has an OpenCL runtime, while a 10-bit SDR source drops to software.""" source that has an OpenCL runtime, while a 10-bit SDR source drops to software."""
if not transcode: if not transcode:
return (False, False) return (False, False)
mode = (sysconfig.get_str(db, "plex_transcode_hw") or "auto").lower() mode = sysconfig.get_str(db, "plex_transcode_hw")
if mode == "off" or not _render_node_present(): if mode == "off" or not _render_node_present():
return (False, False) return (False, False)
if mode == "on": if mode == "on":
@@ -664,7 +664,7 @@ def start_session(
# remux ignore preset/crf/max_height, and remux must not touch the DB for them). # remux ignore preset/crf/max_height, and remux must not touch the DB for them).
preset, crf, max_height = "veryfast", 20, 0 preset, crf, max_height = "veryfast", 20, 0
if transcode and not hw: if transcode and not hw:
preset = sysconfig.get_str(db, "plex_transcode_preset") or "veryfast" preset = sysconfig.get_str(db, "plex_transcode_preset")
crf = sysconfig.get_int(db, "plex_transcode_crf") crf = sysconfig.get_int(db, "plex_transcode_crf")
max_height = sysconfig.get_int(db, "plex_transcode_max_height") max_height = sysconfig.get_int(db, "plex_transcode_max_height")
# Nice the SOFTWARE transcode child (not the HW path, and not remux) so a CPU-bound re-encode # Nice the SOFTWARE transcode child (not the HW path, and not remux) so a CPU-bound re-encode
+1 -3
View File
@@ -231,9 +231,7 @@ def search_youtube(
limit = max(1, min(limit, 100)) limit = max(1, min(limit, 100))
# Source: "scrape" (InnerTube, no API quota) or "api" (search.list, 100 units/page). # Source: "scrape" (InnerTube, no API quota) or "api" (search.list, 100 units/page).
source = (sysconfig.get_str(db, "search_source") or "scrape").lower() source = sysconfig.get_str(db, "search_source")
if source != "scrape":
source = "api"
# Per-user daily cap applies to both sources (anti-abuse); with scrape it's the only guard # Per-user daily cap applies to both sources (anti-abuse); with scrape it's the only guard
# since the search itself spends no quota. # since the search itself spends no quota.
+45 -23
View File
@@ -14,7 +14,7 @@ from dataclasses import dataclass
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app import security from app import security
from app.config import settings from app.config import Settings, choices_of, settings
from app.models import SystemConfig from app.models import SystemConfig
log = logging.getLogger("siftlode.config") log = logging.getLogger("siftlode.config")
@@ -27,7 +27,7 @@ _decrypt_warned: set[str] = set()
@dataclass(frozen=True) @dataclass(frozen=True)
class ConfigSpec: class ConfigSpec:
key: str key: str
type: str # "int" | "str" | "bool" type: str # "int" | "str" | "bool" | "choice"
group: str # admin-UI grouping (logically related keys stay together) group: str # admin-UI grouping (logically related keys stay together)
default_attr: str # attribute on `settings` holding the env/config default default_attr: str # attribute on `settings` holding the env/config default
secret: bool = False secret: bool = False
@@ -35,16 +35,33 @@ class ConfigSpec:
max: int | None = None max: int | None = None
allow_empty: bool = False # str keys: a stored "" is an explicit empty value, not "unset" allow_empty: bool = False # str keys: a stored "" is an explicit empty value, not "unset"
@property
def choices(self) -> tuple[str, ...]:
"""A choice key's allowed values, read from its setting's Literal type in config.py — the one
place the list is written. The admin page renders them as a picker."""
return choices_of(Settings.model_fields[self.default_attr].annotation)
# Registry of DB-overridable keys. Add a key here + wire its read through get_*() to move it # Registry of DB-overridable keys. Add a key here + wire its read through get_*() to move it
# off env into the admin UI; the admin page renders it automatically from this list. # off env into the admin UI; the admin page renders it automatically from this list. The ORDER is the
# admin page's tab order (first appearance of each group), so keep a group's keys together.
SPECS: tuple[ConfigSpec, ...] = ( SPECS: tuple[ConfigSpec, ...] = (
# --- Access / registration ---
ConfigSpec("allow_registration", "bool", "access", "allow_registration"),
# --- Email / SMTP (one logical group; the password is the only secret) --- # --- Email / SMTP (one logical group; the password is the only secret) ---
ConfigSpec("smtp_host", "str", "email", "smtp_host"), ConfigSpec("smtp_host", "str", "email", "smtp_host"),
ConfigSpec("smtp_port", "int", "email", "smtp_port", min=1, max=65535), ConfigSpec("smtp_port", "int", "email", "smtp_port", min=1, max=65535),
ConfigSpec("smtp_user", "str", "email", "smtp_user", allow_empty=True), ConfigSpec("smtp_user", "str", "email", "smtp_user", allow_empty=True),
ConfigSpec("smtp_from", "str", "email", "smtp_from"), ConfigSpec("smtp_from", "str", "email", "smtp_from"),
ConfigSpec("smtp_password", "str", "email", "smtp_password", secret=True), ConfigSpec("smtp_password", "str", "email", "smtp_password", secret=True),
# --- YouTube Data API key (secret; optional — public reads prefer it over OAuth) ---
ConfigSpec("youtube_api_key", "str", "youtube", "youtube_api_key", secret=True),
# Optional egress proxy for YouTube API calls (fixed-IP host for an IP-restricted key).
ConfigSpec("youtube_api_proxy", "str", "youtube", "youtube_api_proxy", allow_empty=True),
# --- Google OAuth client (Sign in with Google; both encrypted, env fallback). Set from the
# install wizard / Configuration page so no restart is needed when the creds change. ---
ConfigSpec("google_client_id", "str", "google", "google_client_id", secret=True),
ConfigSpec("google_client_secret", "str", "google", "google_client_secret", secret=True),
# --- Quota (shared YouTube Data API daily budget) --- # --- Quota (shared YouTube Data API daily budget) ---
ConfigSpec("quota_daily_budget", "int", "quota", "quota_daily_budget", min=1, max=1_000_000), ConfigSpec("quota_daily_budget", "int", "quota", "quota_daily_budget", min=1, max=1_000_000),
ConfigSpec( ConfigSpec(
@@ -59,7 +76,7 @@ SPECS: tuple[ConfigSpec, ...] = (
max=100_000, max=100_000,
), ),
# Live-search source: "scrape" (InnerTube, zero API quota) or "api" (search.list, 100u/page). # Live-search source: "scrape" (InnerTube, zero API quota) or "api" (search.list, 100u/page).
ConfigSpec("search_source", "str", "quota", "search_source"), ConfigSpec("search_source", "choice", "quota", "search_source"),
# --- Backfill (recent-first first pass per channel) --- # --- Backfill (recent-first first pass per channel) ---
ConfigSpec( ConfigSpec(
"backfill_recent_max_videos", "backfill_recent_max_videos",
@@ -96,16 +113,6 @@ SPECS: tuple[ConfigSpec, ...] = (
ConfigSpec("explore_grace_days", "int", "explore", "explore_grace_days", min=0, max=3_650), ConfigSpec("explore_grace_days", "int", "explore", "explore_grace_days", min=0, max=3_650),
# Days an un-kept live-search result video is kept before the cleanup job removes it. # Days an un-kept live-search result video is kept before the cleanup job removes it.
ConfigSpec("search_grace_days", "int", "explore", "search_grace_days", min=0, max=3_650), ConfigSpec("search_grace_days", "int", "explore", "search_grace_days", min=0, max=3_650),
# --- YouTube Data API key (secret; optional — public reads prefer it over OAuth) ---
ConfigSpec("youtube_api_key", "str", "youtube", "youtube_api_key", secret=True),
# Optional egress proxy for YouTube API calls (fixed-IP host for an IP-restricted key).
ConfigSpec("youtube_api_proxy", "str", "youtube", "youtube_api_proxy", allow_empty=True),
# --- Google OAuth client (Sign in with Google; both encrypted, env fallback). Set from the
# install wizard / Configuration page so no restart is needed when the creds change. ---
ConfigSpec("google_client_id", "str", "google", "google_client_id", secret=True),
ConfigSpec("google_client_secret", "str", "google", "google_client_secret", secret=True),
# --- Access / registration ---
ConfigSpec("allow_registration", "bool", "access", "allow_registration"),
# --- Download center (yt-dlp) — per-user defaults + retention/GC + layout --- # --- Download center (yt-dlp) — per-user defaults + retention/GC + layout ---
ConfigSpec("download_total_max_bytes", "int", "downloads", "download_total_max_bytes", min=0), ConfigSpec("download_total_max_bytes", "int", "downloads", "download_total_max_bytes", min=0),
ConfigSpec( ConfigSpec(
@@ -137,7 +144,7 @@ SPECS: tuple[ConfigSpec, ...] = (
ConfigSpec( ConfigSpec(
"download_gc_grace_days", "int", "downloads", "download_gc_grace_days", min=0, max=3_650 "download_gc_grace_days", "int", "downloads", "download_gc_grace_days", min=0, max=3_650
), ),
ConfigSpec("download_layout", "str", "downloads", "download_layout"), ConfigSpec("download_layout", "choice", "downloads", "download_layout"),
ConfigSpec( ConfigSpec(
"download_worker_concurrency", "download_worker_concurrency",
"int", "int",
@@ -151,10 +158,9 @@ SPECS: tuple[ConfigSpec, ...] = (
# Days to keep admin audit-log rows before the audit_gc job prunes them (0 = keep forever). # Days to keep admin audit-log rows before the audit_gc job prunes them (0 = keep forever).
ConfigSpec("audit_retention_days", "int", "audit", "audit_retention_days", min=0, max=3650), ConfigSpec("audit_retention_days", "int", "audit", "audit_retention_days", min=0, max=3650),
# --- Diagnostics (E22) --- # --- Diagnostics (E22) ---
# Runtime log level for the API process: TRACE | DEBUG | INFO | WARNING | ERROR. Free-text like # Runtime log level for the API process. Applied without a restart — see routes/config.py and
# search_source (an unknown value falls back to INFO in logs.resolve_level, so a typo can't # main.py's lifespan.
# silence the app). Applied without a restart — see routes/config.py and main.py's lifespan. ConfigSpec("log_level", "choice", "diagnostics", "log_level"),
ConfigSpec("log_level", "str", "diagnostics", "log_level"),
# --- Plex integration (optional; local-file playback + Plex metadata) --- # --- Plex integration (optional; local-file playback + Plex metadata) ---
ConfigSpec("plex_enabled", "bool", "plex", "plex_enabled"), ConfigSpec("plex_enabled", "bool", "plex", "plex_enabled"),
ConfigSpec("plex_server_url", "str", "plex", "plex_server_url"), ConfigSpec("plex_server_url", "str", "plex", "plex_server_url"),
@@ -164,10 +170,10 @@ SPECS: tuple[ConfigSpec, ...] = (
ConfigSpec("plex_overlay_map", "str", "plex", "plex_overlay_map"), ConfigSpec("plex_overlay_map", "str", "plex", "plex_overlay_map"),
# (plex_sync_interval_min is tuned from the Scheduler dashboard, not here — it's a scheduler job.) # (plex_sync_interval_min is tuned from the Scheduler dashboard, not here — it's a scheduler job.)
ConfigSpec("plex_max_transcodes", "int", "plex", "plex_max_transcodes", min=0, max=16), ConfigSpec("plex_max_transcodes", "int", "plex", "plex_max_transcodes", min=0, max=16),
# Transcode re-encode tuning (E6 S1). hw = "auto"|"on"|"off" (free-text, like search_source); the # Transcode re-encode tuning (E6 S1). The libx264 knobs apply only to the software fallback.
# libx264 knobs apply only to the software fallback. crf 0–51 (x264 range); max_height 0 = source. # crf 0–51 (x264 range); max_height 0 = source.
ConfigSpec("plex_transcode_hw", "str", "plex", "plex_transcode_hw"), ConfigSpec("plex_transcode_hw", "choice", "plex", "plex_transcode_hw"),
ConfigSpec("plex_transcode_preset", "str", "plex", "plex_transcode_preset"), ConfigSpec("plex_transcode_preset", "choice", "plex", "plex_transcode_preset"),
ConfigSpec("plex_transcode_crf", "int", "plex", "plex_transcode_crf", min=0, max=51), ConfigSpec("plex_transcode_crf", "int", "plex", "plex_transcode_crf", min=0, max=51),
ConfigSpec( ConfigSpec(
"plex_transcode_max_height", "int", "plex", "plex_transcode_max_height", min=0, max=4320 "plex_transcode_max_height", "int", "plex", "plex_transcode_max_height", min=0, max=4320
@@ -199,11 +205,24 @@ def _default(s: ConfigSpec):
return getattr(settings, s.default_attr) return getattr(settings, s.default_attr)
def _choice(s: ConfigSpec, raw: object) -> str:
"""The canonical spelling of `raw` among the key's choices (any letter case), or ValueError."""
folded = str(raw).strip().casefold()
for option in s.choices:
if option.casefold() == folded:
return option
raise ValueError(f"{s.key} must be one of: {', '.join(s.choices)}")
def _coerce(s: ConfigSpec, raw: str): def _coerce(s: ConfigSpec, raw: str):
if s.type == "int": if s.type == "int":
return int(raw) return int(raw)
if s.type == "bool": if s.type == "bool":
return raw == "true" return raw == "true"
if s.type == "choice":
# A stored value that is no longer a choice (the list shrank) reads as the default, like a
# non-numeric int override does — see get().
return _choice(s, raw)
return raw return raw
@@ -279,6 +298,8 @@ def set_value(db: Session, key: str, value) -> None:
raw = str(v) raw = str(v)
elif s.type == "bool": elif s.type == "bool":
raw = "true" if value else "false" raw = "true" if value else "false"
elif s.type == "choice":
raw = _choice(s, value)
else: else:
raw = "" if value is None else str(value) raw = "" if value is None else str(value)
@@ -321,6 +342,7 @@ def describe(db: Session) -> dict:
"max": s.max, "max": s.max,
"is_set": is_set, # an override row exists "is_set": is_set, # an override row exists
"allow_empty": s.allow_empty, # if true, the UI stores "" rather than deleting the row "allow_empty": s.allow_empty, # if true, the UI stores "" rather than deleting the row
"choices": list(s.choices), # the picker's options; [] for any other type
} }
if s.secret: if s.secret:
item["default_is_set"] = bool(_default(s)) item["default_is_set"] = bool(_default(s))
+12 -5
View File
@@ -17,6 +17,7 @@ from fastapi import FastAPI
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
from app import logs from app import logs
from app.config import LogLevel, choices_of
class _Sink(logging.Handler): class _Sink(logging.Handler):
@@ -161,11 +162,17 @@ def test_bound_correlation_appears_and_is_released(sink):
assert "job_id" not in sink.objects()[-1] assert "job_id" not in sink.objects()[-1]
def test_an_unknown_level_name_falls_back_to_info_instead_of_silencing(): def test_every_selectable_level_resolves_to_a_real_logging_level():
assert logs.resolve_level("nonsense") == logging.INFO # The picker's options come from `LogLevel`; each must be a level `logging` knows, TRACE included.
assert logs.resolve_level(None) == logging.INFO assert [logs.resolve_level(n) for n in choices_of(LogLevel)] == [
assert logs.resolve_level("trace") == logs.TRACE logs.TRACE,
assert logs.resolve_level(" debug ") == logging.DEBUG logging.DEBUG,
logging.INFO,
logging.WARNING,
logging.ERROR,
]
with pytest.raises(ValueError):
logs.resolve_level("nonsense")
def test_configure_logging_silences_the_uvicorn_access_log_and_keeps_ours_propagating(): def test_configure_logging_silences_the_uvicorn_access_log_and_keeps_ours_propagating():
+93
View File
@@ -0,0 +1,93 @@
"""The sysconfig registry: its order is the admin page's tab order, and a closed-set knob is a CHOICE.
The admin Configuration page used to keep its own hand-written group order, which had already drifted
(`explore` was missing, so its tab sorted last). The page now follows SPECS, so SPECS has to read in
tab order: one run of keys per group.
A closed-set admin knob is a CHOICE, not free text (E22 S2 follow-up).
`log_level`, `search_source`, `download_layout` and the transcode `hw`/`preset` used to be free-text
fields: the admin could type anything and every consumer carried its own `or "auto").lower()` guess
at what a typo meant. Now the value set is a `Literal` in config.py, pydantic holds the env to it at
boot, sysconfig holds a stored override to it, and the admin page renders a picker from the same list.
The DB lane (`db` fixture) covers the store; the rest is pure logic.
"""
import pytest
from pydantic import ValidationError
from app import sysconfig
from app.config import Settings, choices_of
CHOICE_KEYS = {s.key for s in sysconfig.SPECS if s.type == "choice"}
def test_each_group_is_one_run_of_keys_so_the_registry_reads_in_tab_order():
runs = [
s.group
for i, s in enumerate(sysconfig.SPECS)
if i == 0 or sysconfig.SPECS[i - 1].group != s.group
]
assert len(runs) == len(set(runs)), runs
assert runs[:2] == ["access", "email"]
def test_every_closed_set_knob_is_a_choice_whose_options_come_from_config():
assert CHOICE_KEYS == {
"search_source",
"download_layout",
"log_level",
"plex_transcode_hw",
"plex_transcode_preset",
}
for s in sysconfig.SPECS:
field = Settings.model_fields[s.default_attr]
if s.type == "choice":
assert s.choices, s.key
assert field.default in s.choices, s.key
else:
# A Literal-typed setting exposed as "str" would be a free-text field again.
assert s.choices == () and choices_of(field.annotation) == (), s.key
def test_the_env_accepts_any_letter_case_and_rejects_an_unknown_value():
assert Settings(log_level="debug").log_level == "DEBUG"
assert Settings(search_source=" API ").search_source == "api"
with pytest.raises(ValidationError):
Settings(log_level="verbose")
class _Row:
def __init__(self, value):
self.value, self.encrypted = value, False
class _FakeDb:
"""Just enough of a Session for sysconfig's reads: one stored override row per key."""
def __init__(self, **rows):
self.rows = {k: _Row(v) for k, v in rows.items()}
def get(self, _model, key):
return self.rows.get(key)
def test_a_stored_value_reads_back_canonical_and_a_stale_one_falls_back_to_the_default():
assert sysconfig.get_str(_FakeDb(plex_transcode_hw="OFF"), "plex_transcode_hw") == "off"
# A value that is no longer (or never was) a choice is not passed through to the consumer.
assert sysconfig.get_str(_FakeDb(plex_transcode_hw="maybe"), "plex_transcode_hw") == "auto"
def test_describe_hands_the_picker_its_options():
items = {i["key"]: i for g in sysconfig.describe(_FakeDb())["groups"].values() for i in g}
assert items["log_level"]["choices"] == ["TRACE", "DEBUG", "INFO", "WARNING", "ERROR"]
assert items["log_level"]["value"] == "INFO"
assert items["quota_daily_budget"]["choices"] == []
def test_set_value_stores_the_canonical_spelling_and_refuses_anything_else(db):
sysconfig.set_value(db, "search_source", "API")
assert sysconfig.get_str(db, "search_source") == "api"
with pytest.raises(ValueError, match="scrape, api"):
sysconfig.set_value(db, "search_source", "bing")
db.rollback()
@@ -0,0 +1,118 @@
// @vitest-environment jsdom
//
// A closed-set config key is picked, not typed: a short list renders as a segmented bar, a long one as
// a dropdown, and the pick is what Save sends. A cleared draft (reset to the default) shows the default
// as the active option rather than no option at all.
//
// i18n is a key-passthrough; the admin config API is stubbed.
import { afterEach, describe, expect, it, vi } from "vitest";
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { ConfigItem } from "../lib/api";
vi.mock("react-i18next", async (importOriginal) => {
const actual = await importOriginal<typeof import("react-i18next")>();
return {
...actual,
useTranslation: () => ({ t: (key: string) => key, i18n: { language: "en" } }),
};
});
const choice = (key: string, choices: string[], value: string, is_set = false): ConfigItem => ({
key,
type: "choice",
group: "g",
secret: false,
min: null,
max: null,
is_set,
allow_empty: false,
choices,
value,
default: choices[0],
});
const PRESETS = ["ultrafast", "superfast", "veryfast", "faster", "fast", "medium", "slow"];
const setConfig = vi.fn((_key: string, _value: unknown) => Promise.resolve({}));
const resetConfig = vi.fn((_key: string) => Promise.resolve({}));
vi.mock("../lib/api", async (importOriginal) => {
const actual = await importOriginal<typeof import("../lib/api")>();
return {
...actual,
api: {
...actual.api,
adminConfig: () =>
Promise.resolve({
groups: {
g: [
choice("plex_transcode_hw", ["auto", "on", "off"], "on", true),
choice("plex_transcode_preset", PRESETS, "veryfast"),
],
},
secrets_manageable: true,
}),
setConfig: (key: string, value: unknown) => setConfig(key, value),
resetConfig: (key: string) => resetConfig(key),
},
};
});
vi.mock("../lib/useMe", () => ({ useMe: () => ({ email_configured: true }) }));
import ConfigPanel from "./ConfigPanel";
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
const mount = () =>
render(
<QueryClientProvider client={new QueryClient()}>
<ConfigPanel />
</QueryClientProvider>,
);
describe("ConfigPanel choice keys", () => {
it("renders a short list as a segmented bar and saves the pick", async () => {
mount();
const bar = await screen.findByRole("group", { name: "config.fields.plex_transcode_hw.label" });
const pressed = () =>
[...bar.querySelectorAll('[aria-pressed="true"]')].map((b) => b.textContent);
expect(pressed()).toEqual(["config.choices.plex_transcode_hw.on"]);
expect(screen.queryByRole("textbox")).toBeNull(); // no free-text field for a choice key
fireEvent.click(screen.getByRole("button", { name: "config.choices.plex_transcode_hw.off" }));
fireEvent.click(await screen.findByRole("button", { name: "config.save" }));
await waitFor(() => expect(setConfig).toHaveBeenCalledWith("plex_transcode_hw", "off"));
});
it("shows the default as active once the override is reset, and Save resets it", async () => {
mount();
await screen.findByRole("group", { name: "config.fields.plex_transcode_hw.label" });
fireEvent.click(screen.getByRole("button", { name: /config\.reset$/ }));
expect(
screen
.getByRole("button", { name: "config.choices.plex_transcode_hw.auto" })
.getAttribute("aria-pressed"),
).toBe("true");
fireEvent.click(await screen.findByRole("button", { name: "config.save" }));
await waitFor(() => expect(resetConfig).toHaveBeenCalledWith("plex_transcode_hw"));
});
it("renders a long list as a dropdown showing only the value", async () => {
mount();
const trigger = await screen.findByRole("button", {
name: "config.fields.plex_transcode_preset.label: config.choices.plex_transcode_preset.veryfast",
});
expect(trigger.textContent).toBe("config.choices.plex_transcode_preset.veryfast");
fireEvent.click(trigger);
// The popover rows are queried as FilterSelect.dom.test does (`hidden: true`, picked by text).
const rows = screen.getAllByRole("menuitemradio", { hidden: true });
fireEvent.click(
rows.find((r) => r.textContent === "config.choices.plex_transcode_preset.slow")!,
);
fireEvent.click(await screen.findByRole("button", { name: "config.save" }));
await waitFor(() => expect(setConfig).toHaveBeenCalledWith("plex_transcode_preset", "slow"));
});
});
+56 -24
View File
@@ -12,30 +12,17 @@ import Tabs, { usePersistedTab } from "./ui/Tabs";
import { LoadingState, StateMessage } from "./ui/QueryState"; import { LoadingState, StateMessage } from "./ui/QueryState";
import { PageToolbar } from "./ui/PageShell"; import { PageToolbar } from "./ui/PageShell";
import { Switch } from "./ui/form"; import { Switch } from "./ui/form";
import SegmentedToggle from "./ui/SegmentedToggle";
import FilterSelect from "./ui/FilterSelect";
import { DraftSaveBar, type SaveState } from "./ui/DraftSaveBar"; import { DraftSaveBar, type SaveState } from "./ui/DraftSaveBar";
import { LS } from "../lib/storage"; import { LS } from "../lib/storage";
import { notifyActionError } from "../lib/actionErrors"; import { notifyActionError } from "../lib/actionErrors";
// Admin Configuration page: edit DB-overridable operational settings (registry-driven from the // Admin Configuration page: edit DB-overridable operational settings (registry-driven from the
// backend — adding a key there makes it appear here automatically). Edits are drafted and // backend — adding a key there makes it appear here automatically). Edits are drafted and
// applied together via one Save/Discard bar; nothing hits // applied together via one Save/Discard bar; nothing hits the server until Save. The tabs follow the
// the server until Save. Group order is fixed where known so logically related settings (e.g. // registry's own order (`sysconfig.SPECS`), so a new group lands where the backend puts it instead of
// Email/SMTP) stay together. // needing a second, hand-kept order list here.
const GROUP_ORDER = [
"access",
"email",
"youtube",
"google",
"quota",
"backfill",
"shorts",
"batch",
"maintenance",
"downloads",
"audit",
"plex",
"tmdb",
];
export default function ConfigPanel() { export default function ConfigPanel() {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -171,9 +158,7 @@ export default function ConfigPanel() {
return <LoadingState label={t("config.loading")} />; return <LoadingState label={t("config.loading")} />;
} }
const groupKeys = Object.keys(data.groups).sort( const groupKeys = Object.keys(data.groups);
(a, b) => (GROUP_ORDER.indexOf(a) + 1 || 99) - (GROUP_ORDER.indexOf(b) + 1 || 99),
);
// Clamp the persisted tab to a real group (the registry can change between sessions). The // Clamp the persisted tab to a real group (the registry can change between sessions). The
// tab pill carries a dot when that group has unsaved edits, so a draft in a hidden tab is // tab pill carries a dot when that group has unsaved edits, so a draft in a hidden tab is
// never silently lost behind the global Save bar. // never silently lost behind the global Save bar.
@@ -330,6 +315,7 @@ function Field({
const { t } = useTranslation(); const { t } = useTranslation();
const isNum = item.type === "int"; const isNum = item.type === "int";
const isBool = item.type === "bool"; const isBool = item.type === "bool";
const label = t(`config.fields.${item.key}.label`, item.key);
const secretDisabled = item.secret && !secretsManageable; const secretDisabled = item.secret && !secretsManageable;
// Status line: never reveal a secret's value. // Status line: never reveal a secret's value.
@@ -356,7 +342,7 @@ function Field({
return ( return (
<div className="flex items-start justify-between gap-3 py-3"> <div className="flex items-start justify-between gap-3 py-3">
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<div className="text-sm font-medium">{t(`config.fields.${item.key}.label`, item.key)}</div> <div className="text-sm font-medium">{label}</div>
<p className="text-xs text-muted leading-relaxed mt-0.5"> <p className="text-xs text-muted leading-relaxed mt-0.5">
{t(`config.fields.${item.key}.hint`, "")} {t(`config.fields.${item.key}.hint`, "")}
</p> </p>
@@ -366,10 +352,12 @@ function Field({
<div className="flex flex-col items-end gap-1.5 shrink-0"> <div className="flex flex-col items-end gap-1.5 shrink-0">
{isBool ? ( {isBool ? (
<Switch <Switch
label={t(`config.fields.${item.key}.label`, item.key)} label={label}
checked={value === "true"} checked={value === "true"}
onChange={(v) => onChange(v ? "true" : "false")} onChange={(v) => onChange(v ? "true" : "false")}
/> />
) : item.type === "choice" ? (
<ChoiceField item={item} label={label} value={value} onChange={onChange} />
) : ( ) : (
<input <input
type={item.secret ? "password" : isNum ? "number" : "text"} type={item.secret ? "password" : isNum ? "number" : "text"}
@@ -379,7 +367,7 @@ function Field({
min={isNum && item.min != null ? item.min : undefined} min={isNum && item.min != null ? item.min : undefined}
max={isNum && item.max != null ? item.max : undefined} max={isNum && item.max != null ? item.max : undefined}
placeholder={item.secret ? "••••••••" : String(item.default ?? "")} placeholder={item.secret ? "••••••••" : String(item.default ?? "")}
aria-label={t(`config.fields.${item.key}.label`, item.key)} aria-label={label}
className="w-52 bg-surface border border-border rounded-xl px-3 py-1.5 text-sm focus:outline-hidden focus:ring-2 focus:ring-accent/40 disabled:opacity-50" className="w-52 bg-surface border border-border rounded-xl px-3 py-1.5 text-sm focus:outline-hidden focus:ring-2 focus:ring-accent/40 disabled:opacity-50"
/> />
)} )}
@@ -400,3 +388,47 @@ function Field({
</div> </div>
); );
} }
// A closed-set key picks from its options instead of taking free text: a segmented bar for a short
// list, a dropdown past six (the house rule for chip vs. select). A cleared draft ("" = reset to the
// default on Save) shows the default as the active option, as a text field shows it as placeholder.
const MAX_SEGMENTS = 6;
function ChoiceField({
item,
label,
value,
onChange,
}: {
item: ConfigItem;
label: string;
value: string;
onChange: (v: string) => void;
}) {
const { t } = useTranslation();
const options = item.choices.map((c) => ({
value: c,
label: t(`config.choices.${item.key}.${c}`, c),
}));
const current = value || String(item.default ?? "");
if (options.length <= MAX_SEGMENTS) {
return (
<SegmentedToggle
options={options}
value={current}
onChange={onChange}
ariaLabel={label}
size="md"
/>
);
}
return (
<FilterSelect
label={label}
hideLabel
options={options}
selected={[current]}
onChange={([next]) => next && onChange(next)}
/>
);
}
+13 -1
View File
@@ -21,6 +21,9 @@ export interface FilterSelectOption {
export interface FilterSelectProps { export interface FilterSelectProps {
/** Visible group name shown as the trigger prefix (`Label: value`) and the accessible name. */ /** Visible group name shown as the trigger prefix (`Label: value`) and the accessible name. */
label: string; label: string;
/** Show only the value on the trigger — for a form row whose own caption already names the field.
* The label still names the control and its menu for assistive tech. */
hideLabel?: boolean;
options: readonly FilterSelectOption[]; options: readonly FilterSelectOption[];
selected: string[]; selected: string[];
onChange: (next: string[]) => void; onChange: (next: string[]) => void;
@@ -39,6 +42,7 @@ export default function FilterSelect({
onChange, onChange,
multiple = false, multiple = false,
requireOne = true, requireOne = true,
hideLabel = false,
testId, testId,
}: FilterSelectProps) { }: FilterSelectProps) {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
@@ -47,6 +51,14 @@ export default function FilterSelect({
onOpenChange: setOpen, onOpenChange: setOpen,
mode: "menu", mode: "menu",
placement: "bottom-start", placement: "bottom-start",
// Single choice: opening lands on the option in use, not the first row. A multi-select toggle
// list has no one "current" row, so it keeps the default.
selectedIndex: multiple
? null
: Math.max(
0,
options.findIndex((o) => o.value === selected[0]),
),
}); });
const labelOf = (v: string) => options.find((o) => o.value === v)?.label ?? v; const labelOf = (v: string) => options.find((o) => o.value === v)?.label ?? v;
@@ -88,7 +100,7 @@ export default function FilterSelect({
open ? "border-accent" : "border-border" open ? "border-accent" : "border-border"
}`} }`}
> >
<span className="text-muted">{label}:</span> {!hideLabel && <span className="text-muted">{label}:</span>}
<span className="text-fg">{summary}</span> <span className="text-fg">{summary}</span>
<ChevronDown className="h-3.5 w-3.5 shrink-0 text-muted" aria-hidden="true" /> <ChevronDown className="h-3.5 w-3.5 shrink-0 text-muted" aria-hidden="true" />
</button> </button>
+25 -3
View File
@@ -7,7 +7,8 @@ import { describe, expect, it } from "vitest";
// `plex_overlay_map`… as bare identifiers, for months, and the i18n parity test stayed green because EN // `plex_overlay_map`… as bare identifiers, for months, and the i18n parity test stayed green because EN
// and HU were missing the SAME keys (owner report, E11 S5). The ids live in Python, so the check reads // and HU were missing the SAME keys (owner report, E11 S5). The ids live in Python, so the check reads
// the registries straight from the backend sources: // the registries straight from the backend sources:
// - `sysconfig.SPECS` → `config.groups.<group>`, `config.fields.<key>.label` + `.hint` // - `sysconfig.SPECS` → `config.groups.<group>`, `config.fields.<key>.label` + `.hint`, and for a
// "choice" key `config.choices.<key>.<option>` — the options read from its Literal in config.py
// - `scheduler.JOB_INTERVALS` → `scheduler.jobs.<id>` + `scheduler.jobDesc.<id>` // - `scheduler.JOB_INTERVALS` → `scheduler.jobs.<id>` + `scheduler.jobDesc.<id>`
// - the `audit` action enum → `auditActions.<id>`; the `quota` action enum → `quotaActions.<id>` // - the `audit` action enum → `auditActions.<id>`; the `quota` action enum → `quotaActions.<id>`
// and the other way round: a name left behind for an id the backend dropped is dead weight that reads // and the other way round: a name left behind for an id the backend dropped is dead weight that reads
@@ -42,8 +43,18 @@ const keysOf = (lang: string): Set<string> => {
}; };
const specs = [ const specs = [
...backend("sysconfig.py").matchAll(/ConfigSpec\(\s*"(\w+)",\s*"\w+",\s*"(\w+)"/g), ...backend("sysconfig.py").matchAll(/ConfigSpec\(\s*"(\w+)",\s*"(\w+)",\s*"(\w+)",\s*"(\w+)"/g),
].map((m) => ({ key: m[1]!, group: m[2]! })); ].map((m) => ({ key: m[1]!, type: m[2]!, group: m[3]!, attr: m[4]! }));
// A choice key's options, as config.py spells them: `<attr>: <Alias> = …` → `<Alias> = Literal[…]`.
const configPy = backend("config.py");
const choicesOf = (attr: string): string[] => {
const alias = configPy.match(new RegExp(`^\\s+${attr}: (\\w+) =`, "m"))?.[1];
const body = alias && configPy.match(new RegExp(`^${alias} = Literal\\[([^\\]]*)\\]`, "m"))?.[1];
return [...(body ?? "").matchAll(/"(\w+)"/g)].map((m) => m[1]!);
};
const choiceIds = specs
.filter((s) => s.type === "choice")
.flatMap((s) => choicesOf(s.attr).map((c) => `config.choices.${s.key}.${c}`));
const jobs = [ const jobs = [
...(backend("scheduler.py").split("JOB_INTERVALS")[1] ?? "") ...(backend("scheduler.py").split("JOB_INTERVALS")[1] ?? "")
.split("}")[0]! .split("}")[0]!
@@ -86,6 +97,10 @@ const idsUnder = (keys: Set<string>, prefix: string) =>
describe("backend ids have their names in both languages", () => { describe("backend ids have their names in both languages", () => {
it("read the registries (a scan that matches nothing is green over nothing)", () => { it("read the registries (a scan that matches nothing is green over nothing)", () => {
expect(specs.map((s) => s.key)).toEqual(expect.arrayContaining(["smtp_host", "tmdb_language"])); expect(specs.map((s) => s.key)).toEqual(expect.arrayContaining(["smtp_host", "tmdb_language"]));
// Every choice key resolved its options (a Literal the scan can't read would check nothing).
for (const s of specs.filter((x) => x.type === "choice"))
expect(choicesOf(s.attr).length, s.key).toBeGreaterThan(1);
expect(choiceIds).toContain("config.choices.log_level.TRACE");
expect(jobs).toEqual(expect.arrayContaining(["rss_poll", "audit_gc"])); expect(jobs).toEqual(expect.arrayContaining(["rss_poll", "audit_gc"]));
expect(auditActions).toEqual(expect.arrayContaining(["config_set", "audit_clear"])); expect(auditActions).toEqual(expect.arrayContaining(["config_set", "audit_clear"]));
expect(quotaActions.length).toBeGreaterThan(0); expect(quotaActions.length).toBeGreaterThan(0);
@@ -112,6 +127,13 @@ describe("backend ids have their names in both languages", () => {
expect([...idsUnder(keys, "config.groups")].filter((g) => !groups.has(g))).toEqual([]); expect([...idsUnder(keys, "config.groups")].filter((g) => !groups.has(g))).toEqual([]);
}); });
it(`${lang}: every option of every choice key, and none for an option that is gone`, () => {
expect(missing(choiceIds)).toEqual([]);
expect(
[...keys].filter((k) => k.startsWith("config.choices.") && !choiceIds.includes(k)),
).toEqual([]);
});
it(`${lang}: every scheduler job`, () => { it(`${lang}: every scheduler job`, () => {
expect( expect(
missing(jobs.flatMap((j) => [`scheduler.jobs.${j}`, `scheduler.jobDesc.${j}`])), missing(jobs.flatMap((j) => [`scheduler.jobs.${j}`, `scheduler.jobDesc.${j}`])),
+37 -4
View File
@@ -53,7 +53,7 @@
}, },
"search_source": { "search_source": {
"label": "Live search source", "label": "Live search source",
"hint": "Where live YouTube search results come from. \"scrape\" uses YouTube's internal endpoint and spends no API quota (recommended). \"api\" uses the official search.list (100 units/page). Switch to \"api\" if scraping ever breaks or is blocked." "hint": "Where live YouTube search results come from. \"Website\" reads YouTube's own search endpoint and spends no API quota (recommended). \"Official API\" uses search.list (100 units/page) — switch to it if the website source ever breaks or is blocked."
}, },
"backfill_recent_max_videos": { "backfill_recent_max_videos": {
"label": "Recent backfill — max videos", "label": "Recent backfill — max videos",
@@ -141,7 +141,7 @@
}, },
"download_layout": { "download_layout": {
"label": "On-disk layout", "label": "On-disk layout",
"hint": "\"plex\" = a Channel/Season YYYY/… tree with .nfo + poster (Plex-indexable); \"flat\" = a single folder." "hint": "\"Plex\" = a Channel/Season YYYY/… tree with .nfo + poster (Plex-indexable); \"Flat\" = a single folder."
}, },
"download_worker_concurrency": { "download_worker_concurrency": {
"label": "Worker concurrency", "label": "Worker concurrency",
@@ -189,11 +189,11 @@
}, },
"plex_transcode_hw": { "plex_transcode_hw": {
"label": "Hardware transcode", "label": "Hardware transcode",
"hint": "GPU (VAAPI) encoding policy: auto (GPU when available and the source is decodable, else software), on (force GPU), off (always software)." "hint": "GPU (VAAPI) encoding policy. Auto uses the GPU when it is available and can decode the source, otherwise software; GPU forces it; Software never uses it."
}, },
"plex_transcode_preset": { "plex_transcode_preset": {
"label": "Software preset", "label": "Software preset",
"hint": "libx264 preset for the software fallback (e.g. veryfast, faster, medium). Slower presets shrink the stream but cost more CPU. Ignored on the GPU path." "hint": "libx264 preset for the software fallback, fastest first. Slower presets shrink the stream but cost more CPU. Ignored on the GPU path."
}, },
"plex_transcode_crf": { "plex_transcode_crf": {
"label": "Software quality (CRF)", "label": "Software quality (CRF)",
@@ -216,6 +216,39 @@
"hint": "Language of the data fetched from TMDB (keywords, overviews), as a TMDB locale code such as en-US or hu-HU. Default en-US." "hint": "Language of the data fetched from TMDB (keywords, overviews), as a TMDB locale code such as en-US or hu-HU. Default en-US."
} }
}, },
"choices": {
"search_source": {
"scrape": "Website",
"api": "Official API"
},
"download_layout": {
"plex": "Plex",
"flat": "Flat"
},
"log_level": {
"TRACE": "TRACE",
"DEBUG": "DEBUG",
"INFO": "INFO",
"WARNING": "WARNING",
"ERROR": "ERROR"
},
"plex_transcode_hw": {
"auto": "Auto",
"on": "GPU",
"off": "Software"
},
"plex_transcode_preset": {
"ultrafast": "ultrafast",
"superfast": "superfast",
"veryfast": "veryfast",
"faster": "faster",
"fast": "fast",
"medium": "medium",
"slow": "slow",
"slower": "slower",
"veryslow": "veryslow"
}
},
"save": "Save", "save": "Save",
"saving": "Saving…", "saving": "Saving…",
"saved": "Saved", "saved": "Saved",
+37 -4
View File
@@ -53,7 +53,7 @@
}, },
"search_source": { "search_source": {
"label": "Élő keresés forrása", "label": "Élő keresés forrása",
"hint": "Honnan jönnek az élő YouTube-keresés találatai. A „scrape” a YouTube belső végpontját használja és nem fogyaszt API-kvótát (ajánlott). Az „api” a hivatalos search.list-et (100 egység/oldal). Válts „api”-ra, ha a scrape valaha elromlik vagy letiltják." "hint": "Honnan jönnek az élő YouTube-keresés találatai. A „Weboldal” a YouTube saját keresővégpontját olvassa, és nem fogyaszt API-kvótát (ajánlott). A „Hivatalos API” a search.list-et használja (100 egység/oldal) — válts rá, ha a weboldalas forrás elromlik vagy letiltják."
}, },
"backfill_recent_max_videos": { "backfill_recent_max_videos": {
"label": "Friss letöltés — max videó", "label": "Friss letöltés — max videó",
@@ -141,7 +141,7 @@
}, },
"download_layout": { "download_layout": {
"label": "Lemezes elrendezés", "label": "Lemezes elrendezés",
"hint": "„plex” = Csatorna/Évad ÉÉÉÉ/… fastruktúra .nfo + poszter fájlokkal (Plexben indexelhető); „flat” = egyetlen mappa." "hint": "„Plex” = Csatorna/Évad ÉÉÉÉ/… fastruktúra .nfo + poszter fájlokkal (Plexben indexelhető); „Egy mappa” = minden egyetlen mappában."
}, },
"download_worker_concurrency": { "download_worker_concurrency": {
"label": "Worker párhuzamosság", "label": "Worker párhuzamosság",
@@ -189,11 +189,11 @@
}, },
"plex_transcode_hw": { "plex_transcode_hw": {
"label": "Hardveres transcode", "label": "Hardveres transcode",
"hint": "GPU (VAAPI) kódolási házirend: auto (GPU, ha elérhető és a forrás dekódolható, különben szoftver), on (GPU kényszerítése), off (mindig szoftver)." "hint": "GPU-s (VAAPI) kódolási házirend. Az Auto a GPU-t használja, ha elérhető és dekódolni tudja a forrást, különben szoftvert; a GPU kikényszeríti; a Szoftver sosem használja."
}, },
"plex_transcode_preset": { "plex_transcode_preset": {
"label": "Szoftveres preset", "label": "Szoftveres preset",
"hint": "libx264 preset a szoftveres tartalékhoz (pl. veryfast, faster, medium). A lassabb preset kisebb streamet ad, de több CPU-t eszik. A GPU-úton figyelmen kívül marad." "hint": "libx264 preset a szoftveres tartalékhoz, a leggyorsabbtól a leglassabbig. A lassabb preset kisebb streamet ad, de több CPU-t eszik. A GPU-úton figyelmen kívül marad."
}, },
"plex_transcode_crf": { "plex_transcode_crf": {
"label": "Szoftveres minőség (CRF)", "label": "Szoftveres minőség (CRF)",
@@ -216,6 +216,39 @@
"hint": "A TMDB-ből lekért adatok (kulcsszavak, leírások) nyelve, TMDB nyelvkóddal, pl. en-US vagy hu-HU. Alapérték en-US." "hint": "A TMDB-ből lekért adatok (kulcsszavak, leírások) nyelve, TMDB nyelvkóddal, pl. en-US vagy hu-HU. Alapérték en-US."
} }
}, },
"choices": {
"search_source": {
"scrape": "Weboldal",
"api": "Hivatalos API"
},
"download_layout": {
"plex": "Plex",
"flat": "Egy mappa"
},
"log_level": {
"TRACE": "TRACE",
"DEBUG": "DEBUG",
"INFO": "INFO",
"WARNING": "WARNING",
"ERROR": "ERROR"
},
"plex_transcode_hw": {
"auto": "Auto",
"on": "GPU",
"off": "Szoftver"
},
"plex_transcode_preset": {
"ultrafast": "ultrafast",
"superfast": "superfast",
"veryfast": "veryfast",
"faster": "faster",
"fast": "fast",
"medium": "medium",
"slow": "slow",
"slower": "slower",
"veryslow": "veryslow"
}
},
"save": "Mentés", "save": "Mentés",
"saving": "Mentés…", "saving": "Mentés…",
"saved": "Elmentve", "saved": "Elmentve",
+2 -1
View File
@@ -48,13 +48,14 @@ export interface PendingAccessRow extends AdminUserRow {
// Admin Configuration page: one DB-overridable setting (registry entry on the backend). // Admin Configuration page: one DB-overridable setting (registry entry on the backend).
export interface ConfigItem { export interface ConfigItem {
key: string; key: string;
type: "int" | "str" | "bool"; type: "int" | "str" | "bool" | "choice";
group: string; group: string;
secret: boolean; secret: boolean;
min: number | null; min: number | null;
max: number | null; max: number | null;
is_set: boolean; // a DB override row exists (else using the env/config default) is_set: boolean; // a DB override row exists (else using the env/config default)
allow_empty?: boolean; // if true, a blank field stores "" (explicit empty) rather than resetting allow_empty?: boolean; // if true, a blank field stores "" (explicit empty) rather than resetting
choices: string[]; // a "choice" key's options (the backend Literal); [] for any other type
value?: string | number | boolean; // non-secret: effective value value?: string | number | boolean; // non-secret: effective value
default?: string | number | boolean; // non-secret: env/config default default?: string | number | boolean; // non-secret: env/config default
default_is_set?: boolean; // secret: whether an env default exists default_is_set?: boolean; // secret: whether an env default exists