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:
@@ -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 { PageToolbar } from "./ui/PageShell";
|
||||
import { Switch } from "./ui/form";
|
||||
import SegmentedToggle from "./ui/SegmentedToggle";
|
||||
import FilterSelect from "./ui/FilterSelect";
|
||||
import { DraftSaveBar, type SaveState } from "./ui/DraftSaveBar";
|
||||
import { LS } from "../lib/storage";
|
||||
import { notifyActionError } from "../lib/actionErrors";
|
||||
|
||||
// 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
|
||||
// applied together via one Save/Discard bar; nothing hits
|
||||
// the server until Save. Group order is fixed where known so logically related settings (e.g.
|
||||
// Email/SMTP) stay together.
|
||||
const GROUP_ORDER = [
|
||||
"access",
|
||||
"email",
|
||||
"youtube",
|
||||
"google",
|
||||
"quota",
|
||||
"backfill",
|
||||
"shorts",
|
||||
"batch",
|
||||
"maintenance",
|
||||
"downloads",
|
||||
"audit",
|
||||
"plex",
|
||||
"tmdb",
|
||||
];
|
||||
// applied together via one Save/Discard bar; nothing hits the server until Save. The tabs follow the
|
||||
// registry's own order (`sysconfig.SPECS`), so a new group lands where the backend puts it instead of
|
||||
// needing a second, hand-kept order list here.
|
||||
|
||||
export default function ConfigPanel() {
|
||||
const { t } = useTranslation();
|
||||
@@ -171,9 +158,7 @@ export default function ConfigPanel() {
|
||||
return <LoadingState label={t("config.loading")} />;
|
||||
}
|
||||
|
||||
const groupKeys = Object.keys(data.groups).sort(
|
||||
(a, b) => (GROUP_ORDER.indexOf(a) + 1 || 99) - (GROUP_ORDER.indexOf(b) + 1 || 99),
|
||||
);
|
||||
const groupKeys = Object.keys(data.groups);
|
||||
// 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
|
||||
// never silently lost behind the global Save bar.
|
||||
@@ -330,6 +315,7 @@ function Field({
|
||||
const { t } = useTranslation();
|
||||
const isNum = item.type === "int";
|
||||
const isBool = item.type === "bool";
|
||||
const label = t(`config.fields.${item.key}.label`, item.key);
|
||||
const secretDisabled = item.secret && !secretsManageable;
|
||||
|
||||
// Status line: never reveal a secret's value.
|
||||
@@ -356,7 +342,7 @@ function Field({
|
||||
return (
|
||||
<div className="flex items-start justify-between gap-3 py-3">
|
||||
<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">
|
||||
{t(`config.fields.${item.key}.hint`, "")}
|
||||
</p>
|
||||
@@ -366,10 +352,12 @@ function Field({
|
||||
<div className="flex flex-col items-end gap-1.5 shrink-0">
|
||||
{isBool ? (
|
||||
<Switch
|
||||
label={t(`config.fields.${item.key}.label`, item.key)}
|
||||
label={label}
|
||||
checked={value === "true"}
|
||||
onChange={(v) => onChange(v ? "true" : "false")}
|
||||
/>
|
||||
) : item.type === "choice" ? (
|
||||
<ChoiceField item={item} label={label} value={value} onChange={onChange} />
|
||||
) : (
|
||||
<input
|
||||
type={item.secret ? "password" : isNum ? "number" : "text"}
|
||||
@@ -379,7 +367,7 @@ function Field({
|
||||
min={isNum && item.min != null ? item.min : undefined}
|
||||
max={isNum && item.max != null ? item.max : undefined}
|
||||
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"
|
||||
/>
|
||||
)}
|
||||
@@ -400,3 +388,47 @@ function Field({
|
||||
</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 {
|
||||
/** Visible group name shown as the trigger prefix (`Label: value`) and the accessible name. */
|
||||
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[];
|
||||
selected: string[];
|
||||
onChange: (next: string[]) => void;
|
||||
@@ -39,6 +42,7 @@ export default function FilterSelect({
|
||||
onChange,
|
||||
multiple = false,
|
||||
requireOne = true,
|
||||
hideLabel = false,
|
||||
testId,
|
||||
}: FilterSelectProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
@@ -47,6 +51,14 @@ export default function FilterSelect({
|
||||
onOpenChange: setOpen,
|
||||
mode: "menu",
|
||||
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;
|
||||
@@ -88,7 +100,7 @@ export default function FilterSelect({
|
||||
open ? "border-accent" : "border-border"
|
||||
}`}
|
||||
>
|
||||
<span className="text-muted">{label}:</span>
|
||||
{!hideLabel && <span className="text-muted">{label}:</span>}
|
||||
<span className="text-fg">{summary}</span>
|
||||
<ChevronDown className="h-3.5 w-3.5 shrink-0 text-muted" aria-hidden="true" />
|
||||
</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
|
||||
// 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}`])),
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -48,13 +48,14 @@ export interface PendingAccessRow extends AdminUserRow {
|
||||
// Admin Configuration page: one DB-overridable setting (registry entry on the backend).
|
||||
export interface ConfigItem {
|
||||
key: string;
|
||||
type: "int" | "str" | "bool";
|
||||
type: "int" | "str" | "bool" | "choice";
|
||||
group: string;
|
||||
secret: boolean;
|
||||
min: number | null;
|
||||
max: number | null;
|
||||
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
|
||||
choices: string[]; // a "choice" key's options (the backend Literal); [] for any other type
|
||||
value?: string | number | boolean; // non-secret: effective value
|
||||
default?: string | number | boolean; // non-secret: env/config default
|
||||
default_is_set?: boolean; // secret: whether an env default exists
|
||||
|
||||
Reference in New Issue
Block a user