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
+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
// 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:
// - `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>`
// - 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
@@ -42,8 +43,18 @@ const keysOf = (lang: string): Set<string> => {
};
const specs = [
...backend("sysconfig.py").matchAll(/ConfigSpec\(\s*"(\w+)",\s*"\w+",\s*"(\w+)"/g),
].map((m) => ({ key: m[1]!, group: m[2]! }));
...backend("sysconfig.py").matchAll(/ConfigSpec\(\s*"(\w+)",\s*"(\w+)",\s*"(\w+)",\s*"(\w+)"/g),
].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 = [
...(backend("scheduler.py").split("JOB_INTERVALS")[1] ?? "")
.split("}")[0]!
@@ -86,6 +97,10 @@ const idsUnder = (keys: Set<string>, prefix: string) =>
describe("backend ids have their names in both languages", () => {
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"]));
// 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(auditActions).toEqual(expect.arrayContaining(["config_set", "audit_clear"]));
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([]);
});
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`, () => {
expect(
missing(jobs.flatMap((j) => [`scheduler.jobs.${j}`, `scheduler.jobDesc.${j}`])),
+37 -4
View File
@@ -53,7 +53,7 @@
},
"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": {
"label": "Recent backfill — max videos",
@@ -141,7 +141,7 @@
},
"download_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": {
"label": "Worker concurrency",
@@ -189,11 +189,11 @@
},
"plex_transcode_hw": {
"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": {
"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": {
"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."
}
},
"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",
"saving": "Saving…",
"saved": "Saved",
+37 -4
View File
@@ -53,7 +53,7 @@
},
"search_source": {
"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": {
"label": "Friss letöltés — max videó",
@@ -141,7 +141,7 @@
},
"download_layout": {
"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": {
"label": "Worker párhuzamosság",
@@ -189,11 +189,11 @@
},
"plex_transcode_hw": {
"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": {
"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": {
"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."
}
},
"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",
"saving": "Mentés…",
"saved": "Elmentve",