Files
netfelix-audio-fix/src/features/settings/SettingsPage.tsx
Felix Förtsch c22642630d
All checks were successful
Build and Push Docker Image / build (push) Successful in 2m48s
pipeline: live sleep countdown; settings: full-width fields, eye inside input
ProcessingColumn now anchors a local deadline when a 'sleeping' queue
status arrives and ticks a 1s timer. "Sleeping 60s between jobs"
becomes "Next job in 59s, 58s, …".

Settings: API key inputs now span the card's width (matching the URL
field), and the reveal affordance is a GNOME-style eye glyph sitting
inside the input's right edge. Uses an inline SVG so it inherits
currentColor and doesn't fight emoji rendering across OSes. When the
field is env-locked, the lock glyph takes the slot (eye hidden — no
edit possible anyway).

v2026.04.15.5

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 14:29:35 +02:00

629 lines
19 KiB
TypeScript

import { useCallback, useEffect, useRef, useState } from "react";
import { Button } from "~/shared/components/ui/button";
import { Input } from "~/shared/components/ui/input";
import { Select } from "~/shared/components/ui/select";
import { TimeInput } from "~/shared/components/ui/time-input";
import { api } from "~/shared/lib/api";
import { LANG_NAMES } from "~/shared/lib/lang";
import { MqttSection } from "./MqttSection";
interface ScheduleWindow {
enabled: boolean;
start: string;
end: string;
}
interface ScheduleConfig {
job_sleep_seconds: number;
scan: ScheduleWindow;
process: ScheduleWindow;
}
interface SettingsData {
config: Record<string, string>;
envLocked: string[];
}
/** Server response from /api/settings/{jellyfin,radarr,sonarr}. */
interface SaveResult {
ok: boolean; // connection test passed
saved?: boolean; // values were persisted (true even when test failed)
testError?: string;
error?: string; // validation error (URL/API key required)
}
let settingsCache: SettingsData | null = null;
const LANGUAGE_OPTIONS = Object.entries(LANG_NAMES).map(([code, label]) => ({ code, label }));
// ─── Locked input ─────────────────────────────────────────────────────────────
function LockedInput({ locked, ...props }: { locked: boolean } & React.InputHTMLAttributes<HTMLInputElement>) {
return (
<div className="relative">
<Input {...props} disabled={locked || props.disabled} className={locked ? "pr-9" : ""} />
{locked && (
<span
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-[0.9rem] opacity-40 pointer-events-none select-none"
title="Set via environment variable — edit your .env file to change this value"
>
🔒
</span>
)}
</div>
);
}
// Note: the section-header EnvBadge was removed — the per-input lock icon
// (LockedInput) already signals when a value is env-controlled, the badge
// was duplicate noise.
// ─── Secret input (password-masked with eye-icon reveal) ──────────────────────
function EyeIcon({ open }: { open: boolean }) {
// GNOME-style eye / crossed-eye glyphs as inline SVG so they inherit
// currentColor instead of fighting emoji rendering across OSes.
if (open) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94" />
<path d="M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19" />
<path d="M14.12 14.12a3 3 0 1 1-4.24-4.24" />
<line x1="1" y1="1" x2="23" y2="23" />
</svg>
);
}
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" />
<circle cx="12" cy="12" r="3" />
</svg>
);
}
/**
* Input for API keys / passwords. Shows "***" masked when the server returns
* a secret value (the raw key never reaches this component by default). Eye
* icon fetches the real value via /api/settings/reveal and shows it. Users
* can also type a new value directly — any edit clears the masked state.
*/
function SecretInput({
configKey,
locked,
value,
onChange,
placeholder,
className,
}: {
configKey: string;
locked: boolean;
value: string;
onChange: (next: string) => void;
placeholder?: string;
className?: string;
}) {
const [revealed, setRevealed] = useState(false);
const isMasked = value === "***";
const toggle = async () => {
if (revealed) {
setRevealed(false);
return;
}
if (isMasked) {
try {
const res = await api.get<{ value: string }>(`/api/settings/reveal?key=${encodeURIComponent(configKey)}`);
onChange(res.value);
} catch {
/* ignore — keep masked */
}
}
setRevealed(true);
};
return (
<div className={`relative ${className ?? ""}`}>
<Input
type={revealed || !isMasked ? "text" : "password"}
value={value}
disabled={locked}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder}
className="pr-9"
/>
{locked ? (
<span
className="absolute inset-y-0 right-0 flex items-center pr-2.5 text-[0.9rem] opacity-40 pointer-events-none select-none"
title="Set via environment variable — edit your .env file to change this value"
>
🔒
</span>
) : (
<button
type="button"
onClick={toggle}
tabIndex={-1}
className="absolute inset-y-0 right-0 flex items-center px-2.5 text-gray-400 hover:text-gray-700 focus:outline-none focus-visible:text-gray-700"
title={revealed ? "Hide" : "Reveal"}
aria-label={revealed ? "Hide secret" : "Reveal secret"}
>
<EyeIcon open={revealed} />
</button>
)}
</div>
);
}
// ─── Section card ──────────────────────────────────────────────────────────────
function SectionCard({
title,
subtitle,
children,
}: {
title: React.ReactNode;
subtitle?: React.ReactNode;
children: React.ReactNode;
}) {
return (
<div className="border border-gray-200 rounded-lg p-4 mb-4">
<div className="font-semibold text-sm mb-1">{title}</div>
{subtitle && <p className="text-gray-500 text-sm mb-3 mt-0">{subtitle}</p>}
{children}
</div>
);
}
// ─── Sortable language list ─────────────────────────────────────────────────────
function SortableLanguageList({
langs,
onChange,
disabled,
}: {
langs: string[];
onChange: (langs: string[]) => void;
disabled: boolean;
}) {
const available = LANGUAGE_OPTIONS.filter((o) => !langs.includes(o.code));
const move = (idx: number, dir: -1 | 1) => {
const next = [...langs];
const target = idx + dir;
if (target < 0 || target >= next.length) return;
[next[idx], next[target]] = [next[target], next[idx]];
onChange(next);
};
const remove = (idx: number) => onChange(langs.filter((_, i) => i !== idx));
const add = (code: string) => {
if (code && !langs.includes(code)) onChange([...langs, code]);
};
return (
<div>
{langs.length > 0 && (
<div className="space-y-1 mb-2">
{langs.map((code, i) => {
const label = LANG_NAMES[code] ?? code.toUpperCase();
return (
<div key={code} className="flex items-center gap-1.5 text-sm">
<button
type="button"
disabled={disabled || i === 0}
onClick={() => move(i, -1)}
aria-label={`Move ${label} up`}
className="w-6 h-6 flex items-center justify-center rounded border border-gray-200 bg-white text-gray-500 hover:bg-gray-50 disabled:opacity-30 disabled:cursor-not-allowed cursor-pointer text-xs"
>
</button>
<button
type="button"
disabled={disabled || i === langs.length - 1}
onClick={() => move(i, 1)}
aria-label={`Move ${label} down`}
className="w-6 h-6 flex items-center justify-center rounded border border-gray-200 bg-white text-gray-500 hover:bg-gray-50 disabled:opacity-30 disabled:cursor-not-allowed cursor-pointer text-xs"
>
</button>
<span className="min-w-[8rem]">
{label} <span className="text-gray-400 text-xs font-mono">({code})</span>
</span>
<button
type="button"
disabled={disabled}
onClick={() => remove(i)}
aria-label={`Remove ${label}`}
className="text-red-400 hover:text-red-600 text-sm border-0 bg-transparent cursor-pointer disabled:opacity-30 disabled:cursor-not-allowed"
>
</button>
</div>
);
})}
</div>
)}
{!disabled && available.length > 0 && (
<Select
value=""
onChange={(e) => {
add(e.target.value);
e.target.value = "";
}}
className="text-sm max-w-[14rem]"
>
<option value="">+ Add language</option>
{available.map(({ code, label }) => (
<option key={code} value={code}>
{label} ({code})
</option>
))}
</Select>
)}
</div>
);
}
// ─── Connection section ────────────────────────────────────────────────────────
function ConnSection({
title,
subtitle,
cfg,
locked,
urlKey,
apiKey: apiKeyProp,
urlPlaceholder,
onSave,
children,
}: {
title: React.ReactNode;
subtitle?: React.ReactNode;
cfg: Record<string, string>;
locked: Set<string>;
urlKey: string;
apiKey: string;
urlPlaceholder: string;
onSave: (url: string, apiKey: string) => Promise<SaveResult>;
children?: React.ReactNode;
}) {
const [url, setUrl] = useState(cfg[urlKey] ?? "");
const [key, setKey] = useState(cfg[apiKeyProp] ?? "");
const [status, setStatus] = useState<
| { kind: "saved" }
| { kind: "saved-test-failed"; error: string }
| { kind: "validation-error"; error: string }
| { kind: "request-error"; error: string }
| null
>(null);
const [saving, setSaving] = useState(false);
const save = async () => {
setSaving(true);
setStatus(null);
try {
const result = await onSave(url, key);
if (result.saved && result.ok) setStatus({ kind: "saved" });
else if (result.saved) setStatus({ kind: "saved-test-failed", error: result.testError ?? "Connection test failed" });
else setStatus({ kind: "validation-error", error: result.error ?? "Save failed" });
} catch (e) {
setStatus({ kind: "request-error", error: String(e) });
}
setSaving(false);
};
return (
<SectionCard title={title} subtitle={subtitle}>
<label className="block text-sm text-gray-700 mb-1">
URL
<LockedInput
locked={locked.has(urlKey)}
type="url"
value={url}
onChange={(e) => setUrl(e.target.value)}
placeholder={urlPlaceholder}
className="mt-0.5"
/>
</label>
<label className="block text-sm text-gray-700 mb-1 mt-3">
API Key
<SecretInput
configKey={apiKeyProp}
locked={locked.has(apiKeyProp)}
value={key}
onChange={setKey}
placeholder="your-api-key"
className="mt-0.5"
/>
</label>
<div className="flex items-center gap-2 mt-3">
<Button onClick={save} disabled={saving || (locked.has(urlKey) && locked.has(apiKeyProp))}>
{saving ? "Saving…" : "Test & Save"}
</Button>
{status?.kind === "saved" && <span className="text-sm text-green-700"> Saved &amp; connected</span>}
{status?.kind === "saved-test-failed" && (
<span className="text-sm text-amber-700" title={status.error}>
Saved, but connection test failed: {status.error}
</span>
)}
{status?.kind === "validation-error" && <span className="text-sm text-red-600"> {status.error}</span>}
{status?.kind === "request-error" && <span className="text-sm text-red-600"> {status.error}</span>}
</div>
{children && <div className="mt-5 pt-4 border-t border-gray-200">{children}</div>}
</SectionCard>
);
}
// ─── Schedule section ─────────────────────────────────────────────────────────
function WindowEditor({
label,
window,
onChange,
}: {
label: string;
window: ScheduleWindow;
onChange: (w: ScheduleWindow) => void;
}) {
return (
<div className="mb-3">
<label className="flex items-center gap-2 text-sm text-gray-700 mb-1.5">
<input
type="checkbox"
checked={window.enabled}
onChange={(e) => onChange({ ...window, enabled: e.target.checked })}
/>
{label}
</label>
{window.enabled && (
<div className="flex items-center gap-2 pl-5">
<TimeInput value={window.start} onChange={(next) => onChange({ ...window, start: next })} />
<span className="text-xs text-gray-500">to</span>
<TimeInput value={window.end} onChange={(next) => onChange({ ...window, end: next })} />
<span className="text-xs text-gray-500">(24h, overnight ranges wrap midnight)</span>
</div>
)}
</div>
);
}
function ScheduleSection() {
const [cfg, setCfg] = useState<ScheduleConfig | null>(null);
const [saved, setSaved] = useState("");
useEffect(() => {
api.get<ScheduleConfig>("/api/settings/schedule").then(setCfg);
}, []);
const save = async () => {
if (!cfg) return;
const next = await api.patch<ScheduleConfig>("/api/settings/schedule", cfg);
setCfg(next);
setSaved("Saved.");
setTimeout(() => setSaved(""), 2000);
};
if (!cfg) return null;
return (
<SectionCard
title="Schedule"
subtitle="Restrict when the app is allowed to scan Jellyfin and process files. Useful when this container runs as an always-on service but you only want it to do real work at night."
>
<WindowEditor label="Scan window" window={cfg.scan} onChange={(scan) => setCfg({ ...cfg, scan })} />
<WindowEditor label="Processing window" window={cfg.process} onChange={(process) => setCfg({ ...cfg, process })} />
<label className="block text-sm text-gray-700 mt-4">
Sleep between jobs (seconds)
<Input
type="number"
min={0}
value={cfg.job_sleep_seconds}
onChange={(e) => setCfg({ ...cfg, job_sleep_seconds: Number.parseInt(e.target.value, 10) || 0 })}
className="mt-0.5 w-28"
/>
</label>
<div className="flex items-center gap-2 mt-3">
<Button onClick={save}>Save</Button>
{saved && <span className="text-green-700 text-sm">{saved}</span>}
</div>
</SectionCard>
);
}
// ─── Setup page ───────────────────────────────────────────────────────────────
export function SettingsPage() {
const [data, setData] = useState<SettingsData | null>(settingsCache);
const [loading, setLoading] = useState(settingsCache === null);
const [clearStatus, setClearStatus] = useState("");
const [audLangs, setAudLangs] = useState<string[]>([]);
const [audSaved, setAudSaved] = useState("");
const langsLoadedRef = useRef(false);
const load = useCallback(() => {
if (!settingsCache) setLoading(true);
api
.get<SettingsData>("/api/settings")
.then((d) => {
settingsCache = d;
setData(d);
if (!langsLoadedRef.current) {
let parsed: string[] = [];
try {
const raw = JSON.parse(d.config.audio_languages ?? "[]");
if (Array.isArray(raw)) parsed = raw.filter((x): x is string => typeof x === "string");
} catch (e) {
console.warn("audio_languages config is not valid JSON, defaulting to []", e);
}
setAudLangs(parsed);
langsLoadedRef.current = true;
}
})
.finally(() => setLoading(false));
}, []);
useEffect(() => {
load();
}, [load]);
if (loading && !data) return <div className="text-gray-400 py-8 text-center">Loading</div>;
if (!data) return <div className="text-red-600">Failed to load settings.</div>;
const { config: cfg, envLocked: envLockedArr } = data;
const locked = new Set(envLockedArr);
const saveJellyfin = (url: string, apiKey: string) =>
api.post<SaveResult>("/api/settings/jellyfin", { url, api_key: apiKey });
const saveRadarr = (url: string, apiKey: string) =>
api.post<SaveResult>("/api/settings/radarr", { url, api_key: apiKey });
const saveSonarr = (url: string, apiKey: string) =>
api.post<SaveResult>("/api/settings/sonarr", { url, api_key: apiKey });
const saveAudioLangs = async () => {
await api.post("/api/settings/audio-languages", { langs: audLangs });
setAudSaved("Saved.");
setTimeout(() => setAudSaved(""), 2000);
};
const clearScan = async () => {
if (
!confirm(
"Delete all scanned data? This will remove all media items, stream decisions, review plans, and jobs. This cannot be undone.",
)
)
return;
await api.post("/api/settings/clear-scan");
setClearStatus("Cleared.");
};
const factoryReset = async () => {
if (
!confirm(
"Reset to first-run state? This wipes EVERYTHING — scan data, settings, languages, schedule, Jellyfin/Radarr/Sonarr credentials. You'll land back on the setup wizard. This cannot be undone.",
)
)
return;
if (!confirm("Really reset? Type-nothing-just-click to confirm.")) return;
await api.post("/api/settings/reset");
// Invalidate client-side caches and reload so the setup gate re-evaluates.
settingsCache = null;
window.location.href = "/";
};
return (
<div>
<div className="flex items-center gap-3 mb-4">
<h1 className="text-xl font-bold m-0">Settings</h1>
</div>
{/* Jellyfin (with nested MQTT webhook config) */}
<ConnSection
title="Jellyfin"
urlKey="jellyfin_url"
apiKey="jellyfin_api_key"
urlPlaceholder="http://192.168.1.100:8096"
cfg={cfg}
locked={locked}
onSave={saveJellyfin}
>
<MqttSection cfg={cfg} locked={locked} />
</ConnSection>
{/* Radarr */}
<ConnSection
title={
<>
Radarr <span className="text-gray-400 font-normal">(optional)</span>
</>
}
subtitle="Provides accurate original-language data for movies."
urlKey="radarr_url"
apiKey="radarr_api_key"
urlPlaceholder="http://192.168.1.100:7878"
cfg={cfg}
locked={locked}
onSave={saveRadarr}
/>
{/* Sonarr */}
<ConnSection
title={
<>
Sonarr <span className="text-gray-400 font-normal">(optional)</span>
</>
}
subtitle="Provides original-language data for TV series."
urlKey="sonarr_url"
apiKey="sonarr_api_key"
urlPlaceholder="http://192.168.1.100:8989"
cfg={cfg}
locked={locked}
onSave={saveSonarr}
/>
{/* Schedule */}
<ScheduleSection />
{/* Audio languages */}
<SectionCard
title="Audio Languages"
subtitle="Additional audio languages to keep alongside the original language. Order determines stream priority in the output file. The original language is always kept first."
>
<SortableLanguageList langs={audLangs} onChange={setAudLangs} disabled={locked.has("audio_languages")} />
<div className="flex items-center gap-2 mt-3">
<Button onClick={saveAudioLangs} disabled={locked.has("audio_languages")}>
Save
</Button>
{audSaved && <span className="text-green-700 text-sm">{audSaved}</span>}
</div>
</SectionCard>
{/* Danger zone */}
<div className="border border-red-400 rounded-lg p-4 mb-4">
<div className="font-semibold text-sm text-red-700 mb-1">Danger Zone</div>
<p className="text-gray-500 text-sm mb-3">
These actions are irreversible. Scan data can be regenerated by running a new scan.
</p>
<div className="flex items-center gap-4 mb-3">
<Button variant="danger" onClick={clearScan}>
Clear all scan data
</Button>
<span className="text-gray-400 text-sm">Removes all scanned items, review plans, and jobs. Keeps settings.</span>
</div>
<div className="flex items-center gap-4">
<Button variant="danger" onClick={factoryReset}>
Reset to first-run state
</Button>
<span className="text-gray-400 text-sm">
Wipes everything scan data and settings. Sends you back to the setup wizard.
</span>
</div>
{clearStatus && <p className="text-green-700 text-sm mt-2">{clearStatus}</p>}
</div>
</div>
);
}
import type React from "react";