feat(web): AlbumModal follow everywhere + Settings polish (#10)

Follow affordance in ALL album modals (user request): thread artistMbid through
the remaining callers — Library, Wanted, and artist Discography. /api/library
and /api/wanted now expose artistMbid (from the joined MonitoredRelease); the
artist-detail route already exposed mbid. So every album modal now links the
artist name to their page + shows a Follow/Following button.

Settings polish (#10):
- Qobuz tab: drop the email/password inputs (the engine prefers the more
  reliable user-id + auth-token path); a "Clear legacy login" affordance appears
  only when old email/password creds are still stored, and the PUT no longer
  sends them.
- Clear-button alignment fixed: .field label is a flex row and .field-grid
  bottom-aligns inputs, so a set-secret field (with its · set + Clear) lines up
  with a plain sibling field.
- Library tab gains a read-only Staging path panel (/staging, STAGING_DIR, why
  fast local disk, rebuild-to-change).
- Monitor + Discovery tabs gain grounded helptext (quality-class 1/2/3 cutoff,
  poll/retry/upgrade-window; sweep cadence + seeds-per-chunk + ListenBrainz URL).

web 152 tests, tsc + build clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jonathan
2026-07-14 12:09:52 +02:00
parent 17bed331a9
commit 24d4ba6098
7 changed files with 84 additions and 34 deletions
+69 -31
View File
@@ -34,8 +34,7 @@ const MONITOR_FIELDS: [string, string][] = [
export function SettingsForm() {
const [tab, setTab] = useState<Tab>("Qobuz");
const [qobuzEmail, setQobuzEmail] = useState("");
const [qobuzPassword, setQobuzPassword] = useState("");
const [qobuzEmail, setQobuzEmail] = useState(""); // legacy; no input, kept to detect stored creds
const [qobuzUserId, setQobuzUserId] = useState("");
const [qobuzToken, setQobuzToken] = useState("");
const [slskdUrl, setSlskdUrl] = useState("");
@@ -111,8 +110,6 @@ export function SettingsForm() {
method: "PUT",
headers: { "content-type": "application/json" },
body: JSON.stringify({
qobuzEmail,
qobuzPassword,
qobuzUserId,
qobuzToken,
slskdUrl,
@@ -121,13 +118,11 @@ export function SettingsForm() {
lastfmUsername,
}),
});
setQobuzPassword("");
setQobuzToken("");
setSlskdApiKey("");
setLastfmApiKey("");
setSaved(true);
setTimeout(() => setSaved(false), 1500);
setPwSet(pwSet || qobuzPassword !== "");
setTokenSet(tokenSet || qobuzToken !== "");
setKeySet(keySet || slskdApiKey !== "");
setLastfmKeySet(lastfmKeySet || lastfmApiKey !== "");
@@ -165,14 +160,24 @@ export function SettingsForm() {
const secretHint = (set: boolean) => (set ? <span className="set"> · set</span> : null);
async function clearField(field: "qobuzPassword" | "qobuzToken" | "slskdApiKey" | "lastfmApiKey") {
async function clearField(field: "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(""); }
}
// 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 (
<div>
<PageHead title="Settings" eyebrow="Credentials · library · monitor · discovery · last.fm" />
@@ -187,26 +192,12 @@ export function SettingsForm() {
{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>
<p className="help">
Lyra signs in to Qobuz with a <b>user ID + auth token</b> 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).
</p>
<div className="subhead">Auth token</div>
<div className="field-grid">
<label className="field">
<span>Qobuz user ID</span>
@@ -216,7 +207,7 @@ export function SettingsForm() {
<span>
Qobuz auth token{secretHint(tokenSet)}
{tokenSet ? (
<button type="button" className="btn ghost sm" style={{ marginLeft: 8 }}
<button type="button" className="btn ghost sm"
aria-label="clear qobuz auth token" onClick={() => clearField("qobuzToken")}>
Clear
</button>
@@ -225,6 +216,16 @@ export function SettingsForm() {
<input aria-label="qobuz auth token" type="password" placeholder={tokenSet ? "•••• stored" : ""} value={qobuzToken} onChange={(e) => setQobuzToken(e.target.value)} />
</label>
</div>
{pwSet || qobuzEmail ? (
<p className="help">
A legacy Qobuz email/password login is still stored (Qobuz often rejects it
the token above takes precedence).{" "}
<button type="button" className="btn ghost sm" aria-label="clear legacy qobuz login"
onClick={clearLegacyQobuz}>
Clear legacy login
</button>
</p>
) : null}
<div className="save-row">
<button type="submit" className="btn accent">Save</button>
{saved ? <span className="saved-note">Saved</span> : null}
@@ -244,7 +245,7 @@ export function SettingsForm() {
<span>
slskd API key{secretHint(keySet)}
{keySet ? (
<button type="button" className="btn ghost sm" style={{ marginLeft: 8 }}
<button type="button" className="btn ghost sm"
aria-label="clear slskd api key" onClick={() => clearField("slskdApiKey")}>
Clear
</button>
@@ -272,7 +273,7 @@ export function SettingsForm() {
<span>
Last.fm API key{secretHint(lastfmKeySet)}
{lastfmKeySet ? (
<button type="button" className="btn ghost sm" style={{ marginLeft: 8 }}
<button type="button" className="btn ghost sm"
aria-label="clear lastfm api key" onClick={() => clearField("lastfmApiKey")}>
Clear
</button>
@@ -299,6 +300,19 @@ export function SettingsForm() {
<code>docker-compose.yml</code> and rebuild it cant be remounted from here.
</div>
</div>
<div className="info-panel">
<div>
<span className="k">Staging path</span> &nbsp; <code>/staging</code>
</div>
<div className="muted-note" style={{ fontFamily: "inherit" }}>
Where each download is assembled + verified before being promoted into the library.
Set by <code>STAGING_DIR</code> in <code>docker-compose.yml</code> (defaults to{" "}
<code>MUSIC_DIR/.staging</code>). When your library is on a network share (SMB/NFS),
point <code>STAGING_DIR</code> at a <b>fast local disk</b> so temporary download I/O
doesnt hammer the share the finished album is copied onto the library volume and
swapped in atomically. Rebuild to change.
</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}>
@@ -326,6 +340,19 @@ export function SettingsForm() {
Automatic monitoring (grab &amp; upgrade)
</label>
</div>
<p className="help">
When on, the worker periodically checks your followed artists: it <b>grabs new
releases</b> as they appear and <b>upgrades</b> owned albums that fall below the
quality cutoff.
<br />
<b>Quality cutoff</b> the minimum acceptable quality class: <code>1</code> = lossy,{" "}
<code>2</code> = CD-lossless (FLAC 16/44.1), <code>3</code> = hi-res. An owned album is
kept as-is when its the cutoff; otherwise a better copy is chased. Range 13.
<br />
<b>Poll interval</b> hours between new-release checks. <b>Retry interval</b> hours
before re-attempting a release that couldnt be found yet. <b>Upgrade window</b> how
many days after first acquiring an album to keep looking for a better copy.
</p>
<div className="field-grid">
{MONITOR_FIELDS.map(([key, label]) => (
<label key={key} className="field">
@@ -353,6 +380,17 @@ export function SettingsForm() {
Scheduled discovery sweep
</label>
</div>
<p className="help">
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{" "}
<b>Discover</b>. Nothing is downloaded automatically you Follow/Want from there.
<br />
<b>Interval</b> hours between sweeps. <b>Seeds per chunk</b> how many followed
artists are processed per worker cycle (keeps job-claiming responsive on big rosters).
<b> Similar per seed</b> candidates pulled per artist. <b>Albums per artist</b>
releases surfaced per suggested artist. <b>Min score</b> drops weak suggestions.{" "}
<b>ListenBrainz base URL</b> leave blank for the default endpoint.
</p>
<div className="field-grid">
{DISCOVERY_FIELDS.map(([key, label]) => (
<label key={key} className="field">