feat(plex): P0 admin Config UI — connection test + library picker

- ConfigPanel 'plex' group auto-renders; adds a Test-connection button + a
  library-picker (checked sections write plex_libraries, applied on Save)
- api.testPlex + PlexTestResult/PlexSection types
- i18n config namespace: plex group + 7 fields + test strings (en/hu/de);
  also fills the previously-missing 'downloads' group label
This commit is contained in:
npeter83 2026-07-05 01:35:08 +02:00
parent a88254c841
commit 5684d6c406
5 changed files with 215 additions and 9 deletions

View file

@ -1,8 +1,8 @@
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { RotateCcw, Send } from "lucide-react"; import { Plug, RotateCcw, Send } from "lucide-react";
import { api, type ConfigItem } from "../lib/api"; import { api, type ConfigItem, type PlexTestResult } from "../lib/api";
import { notify } from "../lib/notifications"; import { notify } from "../lib/notifications";
import Tooltip from "./Tooltip"; import Tooltip from "./Tooltip";
import Tabs, { usePersistedTab } from "./Tabs"; import Tabs, { usePersistedTab } from "./Tabs";
@ -14,7 +14,7 @@ import { DraftSaveBar } from "./ui/DraftSaveBar";
// applied together via one Save/Discard bar (consistent with the Settings page); nothing hits // applied together via one Save/Discard bar (consistent with the Settings page); nothing hits
// the server until Save. Group order is fixed where known so logically related settings (e.g. // the server until Save. Group order is fixed where known so logically related settings (e.g.
// Email/SMTP) stay together. // Email/SMTP) stay together.
const GROUP_ORDER = ["access", "email", "youtube", "google", "quota", "backfill", "shorts", "batch"]; const GROUP_ORDER = ["access", "email", "youtube", "google", "quota", "backfill", "shorts", "batch", "downloads", "plex"];
type SaveState = "idle" | "saving" | "saved" | "error"; type SaveState = "idle" | "saving" | "saved" | "error";
export default function ConfigPanel() { export default function ConfigPanel() {
@ -93,6 +93,29 @@ export default function ConfigPanel() {
onError: () => notify({ level: "error", message: t("config.testFailed") }), onError: () => notify({ level: "error", message: t("config.testFailed") }),
}); });
// Plex "Test connection": verifies the server URL + token and returns the library sections,
// which drive the library-picker below (checked sections are written into the plex_libraries
// draft as a comma-separated list of section keys, applied on Save).
const [plexResult, setPlexResult] = useState<PlexTestResult | null>(null);
const testPlex = useMutation({
mutationFn: () => api.testPlex(),
onSuccess: (r) => {
setPlexResult(r);
notify({ level: "success", message: t("config.plexTestOk", { name: r.server_name }) });
},
onError: () => {
setPlexResult(null);
notify({ level: "error", message: t("config.plexTestFailed") });
},
});
const plexLibs = (draft["plex_libraries"] ?? "").split(",").map((s) => s.trim()).filter(Boolean);
const togglePlexLib = (key: string) => {
const next = plexLibs.includes(key)
? plexLibs.filter((k) => k !== key)
: [...plexLibs, key];
setDraft((d) => ({ ...d, plex_libraries: next.join(",") }));
};
if (q.isLoading || !data) { if (q.isLoading || !data) {
return <div className="p-4 max-w-3xl mx-auto text-muted">{t("config.loading")}</div>; return <div className="p-4 max-w-3xl mx-auto text-muted">{t("config.loading")}</div>;
} }
@ -149,6 +172,54 @@ export default function ConfigPanel() {
</Tooltip> </Tooltip>
</div> </div>
)} )}
{activeGroup === "plex" && (
<div className="mt-3 pt-3 border-t border-border/60">
<Tooltip hint={t("config.plexTestHint")}>
<button
onClick={() => testPlex.mutate()}
disabled={testPlex.isPending || dirty}
className="glass-card glass-hover inline-flex items-center gap-2 px-3 py-2 rounded-xl text-sm disabled:opacity-50 transition"
>
<Plug className={`w-4 h-4 ${testPlex.isPending ? "animate-pulse" : ""}`} />
{t("config.plexTest")}
</button>
</Tooltip>
{dirty && <p className="text-[11px] text-muted mt-2">{t("config.plexTestDirty")}</p>}
{plexResult && (
<div className="mt-3">
<p className="text-xs text-muted mb-2">
{t("config.plexConnected", {
name: plexResult.server_name,
version: plexResult.version ?? "",
})}
</p>
<p className="text-[11px] text-muted mb-1.5">{t("config.plexPickLibraries")}</p>
<div className="flex flex-col gap-1.5">
{plexResult.sections.map((s) => (
<label
key={s.key}
className="inline-flex items-center gap-2 text-sm cursor-pointer"
>
<input
type="checkbox"
checked={plexLibs.length === 0 || plexLibs.includes(s.key)}
onChange={() => togglePlexLib(s.key)}
className="accent-accent"
/>
<span>{s.title}</span>
<span className="text-[11px] text-muted">
{s.type === "movie" ? t("config.plexMovies") : t("config.plexShows")}
{s.count != null ? ` · ${s.count}` : ""}
</span>
</label>
))}
</div>
<p className="text-[11px] text-muted mt-2">{t("config.plexPickHint")}</p>
</div>
)}
</div>
)}
</div> </div>
)} )}

View file

@ -10,7 +10,9 @@
"backfill": "Nachladen (Backfill)", "backfill": "Nachladen (Backfill)",
"shorts": "Shorts-Prüfung", "shorts": "Shorts-Prüfung",
"batch": "Stapelgrößen", "batch": "Stapelgrößen",
"explore": "Kanal erkunden" "explore": "Kanal erkunden",
"downloads": "Downloads",
"plex": "Plex"
}, },
"fields": { "fields": {
"smtp_host": { "smtp_host": {
@ -100,6 +102,34 @@
"search_grace_days": { "search_grace_days": {
"label": "Suchergebnisse — Karenzzeit (Tage)", "label": "Suchergebnisse — Karenzzeit (Tage)",
"hint": "Wie lange ein nicht behaltenes Live-Suchergebnis-Video (niemand hat es angesehen/gespeichert/abonniert) behalten wird, bevor die Entdeckungs-Aufräumaufgabe es entfernt." "hint": "Wie lange ein nicht behaltenes Live-Suchergebnis-Video (niemand hat es angesehen/gespeichert/abonniert) behalten wird, bevor die Entdeckungs-Aufräumaufgabe es entfernt."
},
"plex_enabled": {
"label": "Plex aktivieren",
"hint": "Zeigt die Plex-Bibliothek als Feed-Quelle mit einem umfangreichen Player. Erfordert, dass der Plex-Server von diesem Host erreichbar ist und die Medien auf einem lokalen Mount liegen (siehe Pfad-Zuordnung)."
},
"plex_server_url": {
"label": "Plex-Server-URL",
"hint": "Von diesem Host erreichbare Basis-URL, z. B. http://192.168.1.112:32400."
},
"plex_server_token": {
"label": "Plex-Server-Token",
"hint": "Der Admin-X-Plex-Token des Servers. Nur serverseitig genutzt (Metadaten + Bilder); wird nie an den Browser gesendet. Verschlüsselt gespeichert; nur schreibbar."
},
"plex_path_map": {
"label": "Medien-Pfad-Zuordnung",
"hint": "Ordnet die Plex-Dateipfade dem lokalen Mount dieses Hosts zu, damit Siftlode die physische Datei abspielen kann. Durch Zeilenumbruch/Komma getrennte plexPrefix=localPrefix-Paare, z. B. /data=/plex-media. Leer = auf beiden Seiten identisch."
},
"plex_libraries": {
"label": "Angezeigte Bibliotheken",
"hint": "Durch Kommas getrennte Plex-Sektionsschlüssel. Leer = alle Sektionen. Nutze nach dem Verbindungstest die Bibliotheksauswahl unten."
},
"plex_sync_interval_min": {
"label": "Sync-Intervall (Minuten)",
"hint": "Wie oft die Katalog-Sync-Aufgabe die Plex-Metadaten spiegelt."
},
"plex_max_transcodes": {
"label": "Max. gleichzeitige Transcodes",
"hint": "Obergrenze für gleichzeitige CPU-Transcodes inkompatibler Dateien (späterer Fallback). Direkt abspielbare Dateien sind nicht begrenzt."
} }
}, },
"save": "Speichern", "save": "Speichern",
@ -121,5 +151,15 @@
"testEmail": "Test-E-Mail senden", "testEmail": "Test-E-Mail senden",
"testEmailHint": "Sende eine Test-E-Mail an deine eigene Adresse mit den obigen Einstellungen, um zu prüfen, ob sie funktionieren.", "testEmailHint": "Sende eine Test-E-Mail an deine eigene Adresse mit den obigen Einstellungen, um zu prüfen, ob sie funktionieren.",
"testSent": "Test-E-Mail gesendet an {{to}}", "testSent": "Test-E-Mail gesendet an {{to}}",
"testFailed": "Test-E-Mail fehlgeschlagen — prüfe die Einstellungen" "testFailed": "Test-E-Mail fehlgeschlagen — prüfe die Einstellungen",
"plexTest": "Verbindung testen",
"plexTestHint": "Prüft die Plex-Server-URL + den Token und lädt die Bibliotheksliste. Speichere zuerst deine Änderungen.",
"plexTestDirty": "Speichere deine Änderungen vor dem Verbindungstest.",
"plexTestOk": "Verbunden mit {{name}}",
"plexTestFailed": "Plex-Verbindung fehlgeschlagen — prüfe URL und Token",
"plexConnected": "Verbunden mit {{name}} {{version}}",
"plexPickLibraries": "Anzuzeigende Bibliotheken:",
"plexPickHint": "Keine auszuwählen entspricht dem Anzeigen aller Bibliotheken. Nicht vergessen zu speichern.",
"plexMovies": "Filme",
"plexShows": "Serien"
} }

View file

@ -10,7 +10,9 @@
"backfill": "Backfill", "backfill": "Backfill",
"shorts": "Shorts probe", "shorts": "Shorts probe",
"batch": "Batch sizes", "batch": "Batch sizes",
"explore": "Channel explore" "explore": "Channel explore",
"downloads": "Downloads",
"plex": "Plex"
}, },
"fields": { "fields": {
"smtp_host": { "smtp_host": {
@ -100,6 +102,34 @@
"search_grace_days": { "search_grace_days": {
"label": "Search results — grace period (days)", "label": "Search results — grace period (days)",
"hint": "How long an un-kept live-search result video (nobody watched / saved / subscribed to) is kept before the discovery-cleanup job removes it." "hint": "How long an un-kept live-search result video (nobody watched / saved / subscribed to) is kept before the discovery-cleanup job removes it."
},
"plex_enabled": {
"label": "Enable Plex",
"hint": "Expose the Plex library as a feed source with a rich player. Requires the Plex server to be reachable from this host and its media on a local mount (see the path map)."
},
"plex_server_url": {
"label": "Plex server URL",
"hint": "Base URL reachable from this host, e.g. http://192.168.1.112:32400."
},
"plex_server_token": {
"label": "Plex server token",
"hint": "The server admin X-Plex-Token. Used server-side only (metadata + images); never sent to the browser. Stored encrypted; write-only."
},
"plex_path_map": {
"label": "Media path map",
"hint": "Maps Plex's on-disk paths to this host's local mount so Siftlode can play the physical file. Newline/comma-separated plexPrefix=localPrefix pairs, e.g. /data=/plex-media. Empty = identical on both sides."
},
"plex_libraries": {
"label": "Exposed libraries",
"hint": "Comma-separated Plex section keys to expose. Empty = all sections. Use the library picker below after testing the connection."
},
"plex_sync_interval_min": {
"label": "Sync interval (minutes)",
"hint": "How often the catalog sync job mirrors Plex metadata."
},
"plex_max_transcodes": {
"label": "Max concurrent transcodes",
"hint": "Cap on simultaneous CPU transcodes for incompatible files (a later fallback). Direct-playable files aren't limited."
} }
}, },
"save": "Save", "save": "Save",
@ -121,5 +151,15 @@
"testEmail": "Send test email", "testEmail": "Send test email",
"testEmailHint": "Send a test email to your own address using the settings above, to confirm they work.", "testEmailHint": "Send a test email to your own address using the settings above, to confirm they work.",
"testSent": "Test email sent to {{to}}", "testSent": "Test email sent to {{to}}",
"testFailed": "Test email failed — check the settings" "testFailed": "Test email failed — check the settings",
"plexTest": "Test connection",
"plexTestHint": "Verify the Plex server URL + token and load the library list. Save any edits first.",
"plexTestDirty": "Save your changes before testing the connection.",
"plexTestOk": "Connected to {{name}}",
"plexTestFailed": "Plex connection failed — check the URL and token",
"plexConnected": "Connected to {{name}} {{version}}",
"plexPickLibraries": "Libraries to expose:",
"plexPickHint": "Unchecking all is the same as exposing every library. Remember to Save.",
"plexMovies": "Movies",
"plexShows": "TV Shows"
} }

View file

@ -10,7 +10,9 @@
"backfill": "Letöltés (backfill)", "backfill": "Letöltés (backfill)",
"shorts": "Shorts-vizsgálat", "shorts": "Shorts-vizsgálat",
"batch": "Kötegméretek", "batch": "Kötegméretek",
"explore": "Csatorna-felfedezés" "explore": "Csatorna-felfedezés",
"downloads": "Letöltések",
"plex": "Plex"
}, },
"fields": { "fields": {
"smtp_host": { "smtp_host": {
@ -100,6 +102,34 @@
"search_grace_days": { "search_grace_days": {
"label": "Keresési találatok — türelmi idő (nap)", "label": "Keresési találatok — türelmi idő (nap)",
"hint": "Meddig marad meg egy meg nem tartott YouTube-keresési találat (amit senki nem nézett meg / mentett el / iratkozott fel rá), mielőtt a felfedezés-takarító feladat törli." "hint": "Meddig marad meg egy meg nem tartott YouTube-keresési találat (amit senki nem nézett meg / mentett el / iratkozott fel rá), mielőtt a felfedezés-takarító feladat törli."
},
"plex_enabled": {
"label": "Plex engedélyezése",
"hint": "A Plex-könyvtár megjelenítése forrásként, gazdag lejátszóval. Ehhez a Plex-szervernek elérhetőnek kell lennie erről a gépről, a média pedig helyi mounton (lásd az útvonal-leképezést)."
},
"plex_server_url": {
"label": "Plex-szerver URL",
"hint": "Erről a gépről elérhető alap-URL, pl. http://192.168.1.112:32400."
},
"plex_server_token": {
"label": "Plex-szerver token",
"hint": "A szerver admin X-Plex-Tokenje. Csak szerveroldalon használjuk (metaadat + képek); soha nem kerül a böngészőbe. Titkosítva tárolva; csak írható."
},
"plex_path_map": {
"label": "Média-útvonal leképezés",
"hint": "A Plex lemezes útvonalait képezi le erre a gépre, hogy a Siftlode lejátszhassa a fizikai fájlt. Új sorral/vesszővel elválasztott plexPrefix=localPrefix párok, pl. /data=/plex-media. Üres = mindkét oldalon azonos."
},
"plex_libraries": {
"label": "Megjelenített könyvtárak",
"hint": "Vesszővel elválasztott Plex-szekciókulcsok. Üres = minden szekció. Kapcsolat-teszt után használd az alábbi könyvtár-választót."
},
"plex_sync_interval_min": {
"label": "Szinkron gyakorisága (perc)",
"hint": "Milyen gyakran tükrözi a katalógus-szinkron a Plex metaadatait."
},
"plex_max_transcodes": {
"label": "Max párhuzamos transcode",
"hint": "A nem kompatibilis fájlok egyidejű CPU-transcode-jainak felső korlátja (későbbi tartalék). A direktben játszható fájlokra nincs korlát."
} }
}, },
"save": "Mentés", "save": "Mentés",
@ -121,5 +151,15 @@
"testEmail": "Teszt-email küldése", "testEmail": "Teszt-email küldése",
"testEmailHint": "Teszt-email küldése a saját címedre a fenti beállításokkal, hogy ellenőrizd, működnek-e.", "testEmailHint": "Teszt-email küldése a saját címedre a fenti beállításokkal, hogy ellenőrizd, működnek-e.",
"testSent": "Teszt-email elküldve ide: {{to}}", "testSent": "Teszt-email elküldve ide: {{to}}",
"testFailed": "A teszt-email sikertelen — ellenőrizd a beállításokat" "testFailed": "A teszt-email sikertelen — ellenőrizd a beállításokat",
"plexTest": "Kapcsolat tesztelése",
"plexTestHint": "Ellenőrzi a Plex-szerver URL-t és tokent, és betölti a könyvtárlistát. Előbb mentsd a módosításokat.",
"plexTestDirty": "Mentsd a változásokat a kapcsolat tesztelése előtt.",
"plexTestOk": "Kapcsolódva: {{name}}",
"plexTestFailed": "A Plex-kapcsolat sikertelen — ellenőrizd az URL-t és a tokent",
"plexConnected": "Kapcsolódva: {{name}} {{version}}",
"plexPickLibraries": "Megjelenítendő könyvtárak:",
"plexPickHint": "Ha egyiket sem jelölöd be, az az összes könyvtár megjelenítésével egyenértékű. Ne feledj menteni.",
"plexMovies": "Filmek",
"plexShows": "Sorozatok"
} }

View file

@ -609,6 +609,20 @@ export interface SystemConfigData {
secrets_manageable: boolean; secrets_manageable: boolean;
} }
// Admin: result of testing the Plex server connection (also drives the library-picker).
export interface PlexSection {
key: string;
title: string;
type: string; // "movie" | "show"
count?: number;
}
export interface PlexTestResult {
ok: boolean;
server_name: string;
version?: string;
sections: PlexSection[];
}
// Admin Users & roles page. // Admin Users & roles page.
export interface AdminUserRow { export interface AdminUserRow {
id: number; id: number;
@ -921,6 +935,7 @@ export const api = {
req(`/api/admin/config/${key}`, { method: "DELETE" }), req(`/api/admin/config/${key}`, { method: "DELETE" }),
testEmail: (): Promise<{ sent: boolean; to: string }> => testEmail: (): Promise<{ sent: boolean; to: string }> =>
req("/api/admin/config/test-email", { method: "POST" }), req("/api/admin/config/test-email", { method: "POST" }),
testPlex: (): Promise<PlexTestResult> => req("/api/plex/test", { method: "POST" }),
// --- admin: users & roles --- // --- admin: users & roles ---
adminUsers: (): Promise<AdminUserRow[]> => req("/api/admin/users"), adminUsers: (): Promise<AdminUserRow[]> => req("/api/admin/users"),
setUserRole: (id: number, role: "user" | "admin"): Promise<AdminUserRow> => setUserRole: (id: number, role: "user" | "admin"): Promise<AdminUserRow> =>