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:
+3
-3
@@ -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
@@ -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
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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))
|
||||||
|
|||||||
@@ -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():
|
||||||
|
|||||||
@@ -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"));
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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}`])),
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user