378 lines
14 KiB
TypeScript
378 lines
14 KiB
TypeScript
"use client";
|
||
|
||
import { useEffect, useState } from "react";
|
||
import { PageHead } from "../_ui/page-head";
|
||
|
||
type Loaded = {
|
||
qobuzEmail: string;
|
||
qobuzUserId: string;
|
||
slskdUrl: string;
|
||
lastfmUsername: string;
|
||
qobuzPasswordSet: boolean;
|
||
qobuzTokenSet: boolean;
|
||
slskdApiKeySet: boolean;
|
||
lastfmApiKeySet: boolean;
|
||
};
|
||
|
||
type Tab = "Qobuz" | "Soulseek" | "Last.fm" | "Library" | "Monitor" | "Discovery";
|
||
const TABS: Tab[] = ["Qobuz", "Soulseek", "Last.fm", "Library", "Monitor", "Discovery"];
|
||
|
||
const DISCOVERY_FIELDS: [string, string][] = [
|
||
["discover.intervalHours", "Interval (hours)"],
|
||
["discover.chunkSize", "Seeds per chunk"],
|
||
["discover.similarPerSeed", "Similar per seed"],
|
||
["discover.albumsPerArtist", "Albums per artist"],
|
||
["discover.minScore", "Min score"],
|
||
];
|
||
|
||
const MONITOR_FIELDS: [string, string][] = [
|
||
["monitor.pollIntervalHours", "Poll interval (hours)"],
|
||
["monitor.retryIntervalHours", "Retry interval (hours)"],
|
||
["monitor.qualityCutoff", "Quality cutoff"],
|
||
["monitor.upgradeWindowDays", "Upgrade window (days)"],
|
||
];
|
||
|
||
export function SettingsForm() {
|
||
const [tab, setTab] = useState<Tab>("Qobuz");
|
||
const [qobuzEmail, setQobuzEmail] = useState("");
|
||
const [qobuzPassword, setQobuzPassword] = useState("");
|
||
const [qobuzUserId, setQobuzUserId] = useState("");
|
||
const [qobuzToken, setQobuzToken] = useState("");
|
||
const [slskdUrl, setSlskdUrl] = useState("");
|
||
const [slskdApiKey, setSlskdApiKey] = useState("");
|
||
const [lastfmUsername, setLastfmUsername] = useState("");
|
||
const [lastfmApiKey, setLastfmApiKey] = useState("");
|
||
const [pwSet, setPwSet] = useState(false);
|
||
const [tokenSet, setTokenSet] = useState(false);
|
||
const [keySet, setKeySet] = useState(false);
|
||
const [lastfmKeySet, setLastfmKeySet] = useState(false);
|
||
const [saved, setSaved] = useState(false);
|
||
const [scanResult, setScanResult] = useState<string | null>(null);
|
||
const [scanRequested, setScanRequested] = useState(false);
|
||
const [discoverCfg, setDiscoverCfg] = useState<Record<string, string>>({});
|
||
const [discoverSaved, setDiscoverSaved] = useState(false);
|
||
const [monitorCfg, setMonitorCfg] = useState<Record<string, string>>({});
|
||
const [monitorSaved, setMonitorSaved] = useState(false);
|
||
|
||
useEffect(() => {
|
||
fetch("/api/scan")
|
||
.then((r) => r.json())
|
||
.then((s: { result: string | null; requested: boolean }) => {
|
||
setScanResult(s.result);
|
||
setScanRequested(s.requested);
|
||
});
|
||
}, []);
|
||
|
||
async function requestScan() {
|
||
await fetch("/api/scan", { method: "POST" });
|
||
setScanRequested(true);
|
||
}
|
||
|
||
useEffect(() => {
|
||
if (!scanRequested) return;
|
||
const id = setInterval(async () => {
|
||
const s = await (await fetch("/api/scan")).json();
|
||
setScanResult(s.result);
|
||
if (!s.requested) setScanRequested(false);
|
||
}, 2000);
|
||
return () => clearInterval(id);
|
||
}, [scanRequested]);
|
||
|
||
useEffect(() => {
|
||
fetch("/api/config")
|
||
.then((r) => r.json())
|
||
.then((c: Loaded) => {
|
||
setQobuzEmail(c.qobuzEmail);
|
||
setQobuzUserId(c.qobuzUserId);
|
||
setSlskdUrl(c.slskdUrl);
|
||
setLastfmUsername(c.lastfmUsername);
|
||
setPwSet(c.qobuzPasswordSet);
|
||
setTokenSet(c.qobuzTokenSet);
|
||
setKeySet(c.slskdApiKeySet);
|
||
setLastfmKeySet(c.lastfmApiKeySet);
|
||
});
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
fetch("/api/discover/config")
|
||
.then((r) => r.json())
|
||
.then((c: Record<string, string>) => setDiscoverCfg(c));
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
fetch("/api/monitor/config")
|
||
.then((r) => r.json())
|
||
.then((c: Record<string, string>) => setMonitorCfg(c));
|
||
}, []);
|
||
|
||
async function saveCredentials(e: React.FormEvent) {
|
||
e.preventDefault();
|
||
await fetch("/api/config", {
|
||
method: "PUT",
|
||
headers: { "content-type": "application/json" },
|
||
body: JSON.stringify({
|
||
qobuzEmail,
|
||
qobuzPassword,
|
||
qobuzUserId,
|
||
qobuzToken,
|
||
slskdUrl,
|
||
slskdApiKey,
|
||
lastfmApiKey,
|
||
lastfmUsername,
|
||
}),
|
||
});
|
||
setQobuzPassword("");
|
||
setQobuzToken("");
|
||
setSlskdApiKey("");
|
||
setLastfmApiKey("");
|
||
setSaved(true);
|
||
setTimeout(() => setSaved(false), 1500);
|
||
setPwSet(pwSet || qobuzPassword !== "");
|
||
setTokenSet(tokenSet || qobuzToken !== "");
|
||
setKeySet(keySet || slskdApiKey !== "");
|
||
setLastfmKeySet(lastfmKeySet || lastfmApiKey !== "");
|
||
}
|
||
|
||
function setCfg(key: string, value: string) {
|
||
setDiscoverCfg((c) => ({ ...c, [key]: value }));
|
||
}
|
||
|
||
async function saveDiscovery(e: React.FormEvent) {
|
||
e.preventDefault();
|
||
await fetch("/api/discover/config", {
|
||
method: "PATCH",
|
||
headers: { "content-type": "application/json" },
|
||
body: JSON.stringify(discoverCfg),
|
||
});
|
||
setDiscoverSaved(true);
|
||
setTimeout(() => setDiscoverSaved(false), 1500);
|
||
}
|
||
|
||
function setMon(key: string, value: string) {
|
||
setMonitorCfg((c) => ({ ...c, [key]: value }));
|
||
}
|
||
|
||
async function saveMonitor(e: React.FormEvent) {
|
||
e.preventDefault();
|
||
await fetch("/api/monitor/config", {
|
||
method: "PATCH",
|
||
headers: { "content-type": "application/json" },
|
||
body: JSON.stringify(monitorCfg),
|
||
});
|
||
setMonitorSaved(true);
|
||
setTimeout(() => setMonitorSaved(false), 1500);
|
||
}
|
||
|
||
const secretHint = (set: boolean) => (set ? <span className="set"> · set</span> : null);
|
||
|
||
async function clearField(field: "qobuzPassword" | "qobuzToken" | "slskdApiKey" | "lastfmApiKey") {
|
||
await fetch(`/api/config?field=${field}`, { method: "DELETE" });
|
||
if (field === "qobuzPassword") { setPwSet(false); setQobuzPassword(""); }
|
||
if (field === "qobuzToken") { setTokenSet(false); setQobuzToken(""); }
|
||
if (field === "slskdApiKey") { setKeySet(false); setSlskdApiKey(""); }
|
||
if (field === "lastfmApiKey") { setLastfmKeySet(false); setLastfmApiKey(""); }
|
||
}
|
||
|
||
return (
|
||
<div>
|
||
<PageHead title="Settings" eyebrow="Credentials · library · monitor · discovery · last.fm" />
|
||
|
||
<div className="tabs">
|
||
{TABS.map((t) => (
|
||
<button key={t} className={`tab${tab === t ? " active" : ""}`} onClick={() => setTab(t)}>
|
||
{t}
|
||
</button>
|
||
))}
|
||
</div>
|
||
|
||
{tab === "Qobuz" ? (
|
||
<form className="settings-section" onSubmit={saveCredentials}>
|
||
<div className="subhead">Email / password</div>
|
||
<div className="field-grid">
|
||
<label className="field">
|
||
<span>Qobuz email</span>
|
||
<input aria-label="qobuz email" value={qobuzEmail} onChange={(e) => setQobuzEmail(e.target.value)} />
|
||
</label>
|
||
<label className="field">
|
||
<span>
|
||
Qobuz password{secretHint(pwSet)}
|
||
{pwSet ? (
|
||
<button type="button" className="btn ghost sm" style={{ marginLeft: 8 }}
|
||
aria-label="clear qobuz password" onClick={() => clearField("qobuzPassword")}>
|
||
Clear
|
||
</button>
|
||
) : null}
|
||
</span>
|
||
<input aria-label="qobuz password" type="password" placeholder={pwSet ? "•••• stored" : ""} value={qobuzPassword} onChange={(e) => setQobuzPassword(e.target.value)} />
|
||
</label>
|
||
</div>
|
||
<div className="subhead">Auth token (more reliable; overrides email/password)</div>
|
||
<div className="field-grid">
|
||
<label className="field">
|
||
<span>Qobuz user ID</span>
|
||
<input aria-label="qobuz user id" value={qobuzUserId} onChange={(e) => setQobuzUserId(e.target.value)} />
|
||
</label>
|
||
<label className="field">
|
||
<span>
|
||
Qobuz auth token{secretHint(tokenSet)}
|
||
{tokenSet ? (
|
||
<button type="button" className="btn ghost sm" style={{ marginLeft: 8 }}
|
||
aria-label="clear qobuz auth token" onClick={() => clearField("qobuzToken")}>
|
||
Clear
|
||
</button>
|
||
) : null}
|
||
</span>
|
||
<input aria-label="qobuz auth token" type="password" placeholder={tokenSet ? "•••• stored" : ""} value={qobuzToken} onChange={(e) => setQobuzToken(e.target.value)} />
|
||
</label>
|
||
</div>
|
||
<div className="save-row">
|
||
<button type="submit" className="btn accent">Save</button>
|
||
{saved ? <span className="saved-note">Saved</span> : null}
|
||
</div>
|
||
</form>
|
||
) : null}
|
||
|
||
{tab === "Soulseek" ? (
|
||
<form className="settings-section" onSubmit={saveCredentials}>
|
||
<div className="subhead">Soulseek (slskd)</div>
|
||
<div className="field-grid">
|
||
<label className="field">
|
||
<span>slskd URL</span>
|
||
<input aria-label="slskd url" value={slskdUrl} onChange={(e) => setSlskdUrl(e.target.value)} />
|
||
</label>
|
||
<label className="field">
|
||
<span>
|
||
slskd API key{secretHint(keySet)}
|
||
{keySet ? (
|
||
<button type="button" className="btn ghost sm" style={{ marginLeft: 8 }}
|
||
aria-label="clear slskd api key" onClick={() => clearField("slskdApiKey")}>
|
||
Clear
|
||
</button>
|
||
) : null}
|
||
</span>
|
||
<input aria-label="slskd api key" type="password" placeholder={keySet ? "•••• stored" : ""} value={slskdApiKey} onChange={(e) => setSlskdApiKey(e.target.value)} />
|
||
</label>
|
||
</div>
|
||
<div className="save-row">
|
||
<button type="submit" className="btn accent">Save</button>
|
||
{saved ? <span className="saved-note">Saved</span> : null}
|
||
</div>
|
||
</form>
|
||
) : null}
|
||
|
||
{tab === "Last.fm" ? (
|
||
<form className="settings-section" onSubmit={saveCredentials}>
|
||
<div className="subhead">Last.fm</div>
|
||
<div className="field-grid">
|
||
<label className="field">
|
||
<span>Last.fm username</span>
|
||
<input aria-label="lastfm username" value={lastfmUsername} onChange={(e) => setLastfmUsername(e.target.value)} />
|
||
</label>
|
||
<label className="field">
|
||
<span>
|
||
Last.fm API key{secretHint(lastfmKeySet)}
|
||
{lastfmKeySet ? (
|
||
<button type="button" className="btn ghost sm" style={{ marginLeft: 8 }}
|
||
aria-label="clear lastfm api key" onClick={() => clearField("lastfmApiKey")}>
|
||
Clear
|
||
</button>
|
||
) : null}
|
||
</span>
|
||
<input aria-label="lastfm api key" type="password" placeholder={lastfmKeySet ? "•••• stored" : ""} value={lastfmApiKey} onChange={(e) => setLastfmApiKey(e.target.value)} />
|
||
</label>
|
||
</div>
|
||
<div className="save-row">
|
||
<button type="submit" className="btn accent">Save</button>
|
||
{saved ? <span className="saved-note">Saved</span> : null}
|
||
</div>
|
||
</form>
|
||
) : null}
|
||
|
||
{tab === "Library" ? (
|
||
<section className="settings-section">
|
||
<div className="info-panel">
|
||
<div>
|
||
<span className="k">Music path</span> <code>/music</code>
|
||
</div>
|
||
<div className="muted-note" style={{ fontFamily: "inherit" }}>
|
||
Mounted read/write into the worker. To point Lyra at a different folder, set <code>MUSIC_DIR</code> in{" "}
|
||
<code>docker-compose.yml</code> and rebuild — it can’t be remounted from here.
|
||
</div>
|
||
</div>
|
||
<p className="muted-note">Scan the library to record what’s already on disk as owned + monitored.</p>
|
||
<div className="save-row">
|
||
<button type="button" className="btn" onClick={requestScan} disabled={scanRequested}>
|
||
{scanRequested ? "Scanning…" : "Scan library"}
|
||
</button>
|
||
{scanResult ? (
|
||
<span className="saved-note" aria-label="scan result" style={{ color: "var(--graphite)" }}>
|
||
Last scan: {scanResult}
|
||
</span>
|
||
) : null}
|
||
</div>
|
||
</section>
|
||
) : null}
|
||
|
||
{tab === "Monitor" ? (
|
||
<form className="settings-section" onSubmit={saveMonitor}>
|
||
<div className="save-row" style={{ margin: "6px 0 4px" }}>
|
||
<label className="toggle">
|
||
<input
|
||
type="checkbox"
|
||
aria-label="automatic monitoring"
|
||
checked={monitorCfg["monitor.enabled"] === "true"}
|
||
onChange={(e) => setMon("monitor.enabled", e.target.checked ? "true" : "false")}
|
||
/>
|
||
Automatic monitoring (grab & upgrade)
|
||
</label>
|
||
</div>
|
||
<div className="field-grid">
|
||
{MONITOR_FIELDS.map(([key, label]) => (
|
||
<label key={key} className="field">
|
||
<span>{label}</span>
|
||
<input type="number" step="any" value={monitorCfg[key] ?? ""} onChange={(e) => setMon(key, e.target.value)} />
|
||
</label>
|
||
))}
|
||
</div>
|
||
<div className="save-row">
|
||
<button type="submit" className="btn">Save monitor settings</button>
|
||
{monitorSaved ? <span className="saved-note">Saved</span> : null}
|
||
</div>
|
||
</form>
|
||
) : null}
|
||
|
||
{tab === "Discovery" ? (
|
||
<form className="settings-section" onSubmit={saveDiscovery}>
|
||
<div className="save-row" style={{ margin: "6px 0 4px" }}>
|
||
<label className="toggle">
|
||
<input
|
||
type="checkbox"
|
||
checked={discoverCfg["discover.enabled"] === "true"}
|
||
onChange={(e) => setCfg("discover.enabled", e.target.checked ? "true" : "false")}
|
||
/>
|
||
Scheduled discovery sweep
|
||
</label>
|
||
</div>
|
||
<div className="field-grid">
|
||
{DISCOVERY_FIELDS.map(([key, label]) => (
|
||
<label key={key} className="field">
|
||
<span>{label}</span>
|
||
<input type="number" step="any" value={discoverCfg[key] ?? ""} onChange={(e) => setCfg(key, e.target.value)} />
|
||
</label>
|
||
))}
|
||
</div>
|
||
<label className="field">
|
||
<span>ListenBrainz base URL (blank = default)</span>
|
||
<input value={discoverCfg["discover.listenBrainzUrl"] ?? ""}
|
||
onChange={(e) => setCfg("discover.listenBrainzUrl", e.target.value)} />
|
||
</label>
|
||
<div className="save-row">
|
||
<button type="submit" className="btn">Save discovery settings</button>
|
||
{discoverSaved ? <span className="saved-note">Saved</span> : null}
|
||
</div>
|
||
</form>
|
||
) : null}
|
||
</div>
|
||
);
|
||
}
|