"use client"; import { useEffect, useState } from "react"; import { PageHead } from "../_ui/page-head"; import { HelpButton } from "../_ui/help-button"; 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)"], ["floor.jobDelaySeconds", "Delay between downloads (seconds)"], ]; export function SettingsForm() { const [tab, setTab] = useState("Qobuz"); const [qobuzEmail, setQobuzEmail] = useState(""); // legacy; no input, kept to detect stored creds 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(null); const [scanRequested, setScanRequested] = useState(false); const [discoverCfg, setDiscoverCfg] = useState>({}); const [discoverSaved, setDiscoverSaved] = useState(false); const [monitorCfg, setMonitorCfg] = useState>({}); const [monitorSaved, setMonitorSaved] = useState(false); const [pacingCfg, setPacingCfg] = useState>({}); const [pacingSaved, setPacingSaved] = 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) => setDiscoverCfg(c)); }, []); useEffect(() => { fetch("/api/monitor/config") .then((r) => r.json()) .then((c: Record) => setMonitorCfg(c)); }, []); useEffect(() => { fetch("/api/qobuz/pacing") .then((r) => r.json()) .then((c: Record) => setPacingCfg(c)); }, []); async function saveCredentials(e: React.FormEvent) { e.preventDefault(); await fetch("/api/config", { method: "PUT", headers: { "content-type": "application/json" }, body: JSON.stringify({ qobuzUserId, qobuzToken, slskdUrl, slskdApiKey, lastfmApiKey, lastfmUsername, }), }); setQobuzToken(""); setSlskdApiKey(""); setLastfmApiKey(""); setSaved(true); setTimeout(() => setSaved(false), 1500); 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); } function setPacing(key: string, value: string) { setPacingCfg((c) => ({ ...c, [key]: value })); } async function savePacing(e: React.FormEvent) { e.preventDefault(); await fetch("/api/qobuz/pacing", { method: "PATCH", headers: { "content-type": "application/json" }, body: JSON.stringify(pacingCfg), }); setPacingSaved(true); setTimeout(() => setPacingSaved(false), 1500); } const secretHint = (set: boolean) => (set ? · set : null); async function clearField(field: "qobuzToken" | "slskdApiKey" | "lastfmApiKey") { await fetch(`/api/config?field=${field}`, { method: "DELETE" }); if (field === "qobuzToken") { setTokenSet(false); setQobuzToken(""); } if (field === "slskdApiKey") { setKeySet(false); setSlskdApiKey(""); } if (field === "lastfmApiKey") { setLastfmKeySet(false); setLastfmApiKey(""); } } // Purge the legacy email/password login (kept working for backward-compat, but Qobuz // often rejects it — the token path is preferred). async function clearLegacyQobuz() { await Promise.all([ fetch("/api/config?field=qobuzPassword", { method: "DELETE" }), fetch("/api/config?field=qobuzEmail", { method: "DELETE" }), ]); setPwSet(false); setQobuzEmail(""); } return (
{TABS.map((t) => ( ))}
{tab === "Qobuz" ? ( <>
Auth token Lyra signs in to Qobuz with a user ID + auth token — the download engine treats these as more reliable than email/password, which Qobuz often rejects. Find them in your logged-in Qobuz web session (bundle/app secret + user auth token).
{pwSet || qobuzEmail ? (

A legacy Qobuz email/password login is still stored (Qobuz often rejects it — the token above takes precedence).{" "}

) : null}
{saved ? Saved : null}
Pacing Throttles Qobuz to a human-looking volume and cadence so the account isn't flagged for bulk downloading. Within active hours it downloads up to the daily cap, spaced out with randomized gaps; when throttled, albums fall through to Soulseek/YouTube and upgrade to Qobuz hi-res later. The daily cap ramps up over the first two weeks from the new-account start date.
{pacingSaved ? Saved : null}
) : null} {tab === "Soulseek" ? (
Soulseek (slskd)
{saved ? Saved : null}
) : null} {tab === "Last.fm" ? (
Last.fm
{saved ? Saved : null}
) : null} {tab === "Library" ? (
Music path   /music
Mounted read/write into the worker. To point Lyra at a different folder, set MUSIC_DIR in{" "} docker-compose.yml and rebuild — it can’t be remounted from here.
Staging path   /staging
Where each download is assembled + verified before being promoted into the library. Set by STAGING_DIR in docker-compose.yml (defaults to{" "} MUSIC_DIR/.staging). When your library is on a network share (SMB/NFS), point STAGING_DIR at a fast local disk so temporary download I/O doesn’t hammer the share — the finished album is copied onto the library volume and swapped in atomically. Rebuild to change.

Scan the library to record what’s already on disk as owned + monitored.

{scanResult ? ( Last scan: {scanResult} ) : null}
) : null} {tab === "Monitor" ? (
When on, the worker periodically checks your followed artists: it grabs new releases as they appear and upgrades owned albums that fall below the quality cutoff.

Quality cutoff — the minimum acceptable quality class: 1 = lossy,{" "} 2 = CD-lossless (FLAC 16/44.1), 3 = hi-res. An owned album is kept as-is when it’s ≥ the cutoff; otherwise a better copy is chased. Range 1–3.

Poll interval — hours between new-release checks. Retry interval — hours before re-attempting a release that couldn’t be found yet. Upgrade window — how many days after first acquiring an album to keep looking for a better copy.
{MONITOR_FIELDS.map(([key, label]) => ( ))}
{monitorSaved ? Saved : null}
) : null} {tab === "Discovery" ? (
When on, the worker periodically sweeps your followed artists for similar artists + their new releases (via ListenBrainz and, if configured, Last.fm) and lists them on{" "} Discover. Nothing is downloaded automatically — you Follow/Want from there.

Interval — hours between sweeps. Seeds per chunk — how many followed artists are processed per worker cycle (keeps job-claiming responsive on big rosters). Similar per seed — candidates pulled per artist. Albums per artist — releases surfaced per suggested artist. Min score — drops weak suggestions.{" "} Albums only — suggest full-length albums only, hiding singles and EPs.{" "} Seed from library — also find artists similar to ones you own albums by, not just the artists you follow.{" "} ListenBrainz base URL — leave blank for the default endpoint.
{DISCOVERY_FIELDS.map(([key, label]) => ( ))}
{discoverSaved ? Saved : null}
) : null}
); }