Files
Lyra/web/src/app/settings/settings-form.tsx
T

378 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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> &nbsp; <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 cant be remounted from here.
</div>
</div>
<p className="muted-note">Scan the library to record whats 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 &amp; 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>
);
}