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:
Binary file not shown.
@@ -11,6 +11,7 @@ export async function GET() {
|
|||||||
wanted: items.map((r) => ({
|
wanted: items.map((r) => ({
|
||||||
id: r.id,
|
id: r.id,
|
||||||
artistName: r.artistName,
|
artistName: r.artistName,
|
||||||
|
artistMbid: r.artistMbid,
|
||||||
album: r.album,
|
album: r.album,
|
||||||
state: r.state,
|
state: r.state,
|
||||||
currentQualityClass: r.currentQualityClass,
|
currentQualityClass: r.currentQualityClass,
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ type Release = {
|
|||||||
state: string;
|
state: string;
|
||||||
currentQualityClass: number | null;
|
currentQualityClass: number | null;
|
||||||
};
|
};
|
||||||
type Detail = { id: string; name: string; autoMonitorFuture: boolean; showAllTypes: boolean; releases: Release[] };
|
type Detail = { id: string; mbid: string; name: string; autoMonitorFuture: boolean; showAllTypes: boolean; releases: Release[] };
|
||||||
|
|
||||||
function badge(r: Release): { label: string; cls: string } {
|
function badge(r: Release): { label: string; cls: string } {
|
||||||
if (r.currentQualityClass !== null) return { label: `Have q${r.currentQualityClass}`, cls: "chip done" };
|
if (r.currentQualityClass !== null) return { label: `Have q${r.currentQualityClass}`, cls: "chip done" };
|
||||||
@@ -127,6 +127,7 @@ export function DiscographyClient({ id }: { id: string }) {
|
|||||||
artist: detail.name,
|
artist: detail.name,
|
||||||
year: openAlbum.firstReleaseDate?.slice(0, 4) ?? null,
|
year: openAlbum.firstReleaseDate?.slice(0, 4) ?? null,
|
||||||
type: openAlbum.primaryType,
|
type: openAlbum.primaryType,
|
||||||
|
artistMbid: detail.mbid,
|
||||||
}}
|
}}
|
||||||
status={<span className={badge(openAlbum).cls}>{badge(openAlbum).label}</span>}
|
status={<span className={badge(openAlbum).cls}>{badge(openAlbum).label}</span>}
|
||||||
actions={
|
actions={
|
||||||
|
|||||||
@@ -149,6 +149,9 @@ nav.contents .sep { flex: 1; }
|
|||||||
.field { display: flex; flex-direction: column; gap: 6px; }
|
.field { display: flex; flex-direction: column; gap: 6px; }
|
||||||
.field > span {
|
.field > span {
|
||||||
font-family: var(--mono); font-size: 0.64rem; letter-spacing: 0.14em; text-transform: uppercase; color: var(--graphite);
|
font-family: var(--mono); font-size: 0.64rem; letter-spacing: 0.14em; text-transform: uppercase; color: var(--graphite);
|
||||||
|
/* keep the label row a consistent height + hold an inline Clear affordance on one line,
|
||||||
|
so a set-secret field lines up with a plain sibling field (align-items:end below). */
|
||||||
|
display: flex; align-items: center; gap: 8px; min-height: 1.5em;
|
||||||
}
|
}
|
||||||
.field input, .field select {
|
.field input, .field select {
|
||||||
font-family: var(--mono); font-size: 0.9rem; padding: 8px 2px; background: transparent;
|
font-family: var(--mono); font-size: 0.9rem; padding: 8px 2px; background: transparent;
|
||||||
@@ -252,7 +255,7 @@ nav.contents .sep { flex: 1; }
|
|||||||
.tracks li .tname { color: var(--ink); }
|
.tracks li .tname { color: var(--ink); }
|
||||||
|
|
||||||
/* ── Phase 3: settings ────────────────────────────────── */
|
/* ── Phase 3: settings ────────────────────────────────── */
|
||||||
.field-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); gap: 18px 24px; margin: 16px 0 8px; }
|
.field-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); gap: 18px 24px; margin: 16px 0 8px; align-items: end; }
|
||||||
.field.wide { grid-column: 1 / -1; }
|
.field.wide { grid-column: 1 / -1; }
|
||||||
.subhead {
|
.subhead {
|
||||||
font-family: var(--mono); font-size: 0.66rem; letter-spacing: 0.16em; text-transform: uppercase;
|
font-family: var(--mono); font-size: 0.66rem; letter-spacing: 0.16em; text-transform: uppercase;
|
||||||
@@ -270,6 +273,10 @@ nav.contents .sep { flex: 1; }
|
|||||||
}
|
}
|
||||||
.save-row { display: flex; align-items: center; gap: 14px; margin-top: 8px; }
|
.save-row { display: flex; align-items: center; gap: 14px; margin-top: 8px; }
|
||||||
.settings-section { margin-bottom: 30px; }
|
.settings-section { margin-bottom: 30px; }
|
||||||
|
.help { font-size: 0.8rem; color: var(--graphite); line-height: 1.5; margin: 4px 0 14px; max-width: 62ch; }
|
||||||
|
.help b { color: var(--ink); font-weight: 600; }
|
||||||
|
.help code { font-family: var(--mono); font-size: 0.85em; }
|
||||||
|
.info-panel + .info-panel { margin-top: 14px; }
|
||||||
|
|
||||||
/* ── Modal ────────────────────────────────────────────── */
|
/* ── Modal ────────────────────────────────────────────── */
|
||||||
.modal-backdrop {
|
.modal-backdrop {
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ type Album = {
|
|||||||
rgMbid: string | null;
|
rgMbid: string | null;
|
||||||
year: string | null;
|
year: string | null;
|
||||||
primaryType: string | null;
|
primaryType: string | null;
|
||||||
|
artistMbid: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function LibraryClient() {
|
export function LibraryClient() {
|
||||||
@@ -74,7 +75,7 @@ export function LibraryClient() {
|
|||||||
<AlbumModal
|
<AlbumModal
|
||||||
open
|
open
|
||||||
onClose={() => setOpen(null)}
|
onClose={() => setOpen(null)}
|
||||||
album={{ rgMbid: open.rgMbid, album: open.album, artist: open.artist, year: open.year, type: open.primaryType }}
|
album={{ rgMbid: open.rgMbid, album: open.album, artist: open.artist, year: open.year, type: open.primaryType, artistMbid: open.artistMbid }}
|
||||||
status={<span className="chip done">In library · {open.format}</span>}
|
status={<span className="chip done">In library · {open.format}</span>}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
@@ -34,8 +34,7 @@ const MONITOR_FIELDS: [string, string][] = [
|
|||||||
|
|
||||||
export function SettingsForm() {
|
export function SettingsForm() {
|
||||||
const [tab, setTab] = useState<Tab>("Qobuz");
|
const [tab, setTab] = useState<Tab>("Qobuz");
|
||||||
const [qobuzEmail, setQobuzEmail] = useState("");
|
const [qobuzEmail, setQobuzEmail] = useState(""); // legacy; no input, kept to detect stored creds
|
||||||
const [qobuzPassword, setQobuzPassword] = useState("");
|
|
||||||
const [qobuzUserId, setQobuzUserId] = useState("");
|
const [qobuzUserId, setQobuzUserId] = useState("");
|
||||||
const [qobuzToken, setQobuzToken] = useState("");
|
const [qobuzToken, setQobuzToken] = useState("");
|
||||||
const [slskdUrl, setSlskdUrl] = useState("");
|
const [slskdUrl, setSlskdUrl] = useState("");
|
||||||
@@ -111,8 +110,6 @@ export function SettingsForm() {
|
|||||||
method: "PUT",
|
method: "PUT",
|
||||||
headers: { "content-type": "application/json" },
|
headers: { "content-type": "application/json" },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
qobuzEmail,
|
|
||||||
qobuzPassword,
|
|
||||||
qobuzUserId,
|
qobuzUserId,
|
||||||
qobuzToken,
|
qobuzToken,
|
||||||
slskdUrl,
|
slskdUrl,
|
||||||
@@ -121,13 +118,11 @@ export function SettingsForm() {
|
|||||||
lastfmUsername,
|
lastfmUsername,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
setQobuzPassword("");
|
|
||||||
setQobuzToken("");
|
setQobuzToken("");
|
||||||
setSlskdApiKey("");
|
setSlskdApiKey("");
|
||||||
setLastfmApiKey("");
|
setLastfmApiKey("");
|
||||||
setSaved(true);
|
setSaved(true);
|
||||||
setTimeout(() => setSaved(false), 1500);
|
setTimeout(() => setSaved(false), 1500);
|
||||||
setPwSet(pwSet || qobuzPassword !== "");
|
|
||||||
setTokenSet(tokenSet || qobuzToken !== "");
|
setTokenSet(tokenSet || qobuzToken !== "");
|
||||||
setKeySet(keySet || slskdApiKey !== "");
|
setKeySet(keySet || slskdApiKey !== "");
|
||||||
setLastfmKeySet(lastfmKeySet || lastfmApiKey !== "");
|
setLastfmKeySet(lastfmKeySet || lastfmApiKey !== "");
|
||||||
@@ -165,14 +160,24 @@ export function SettingsForm() {
|
|||||||
|
|
||||||
const secretHint = (set: boolean) => (set ? <span className="set"> · set</span> : null);
|
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" });
|
await fetch(`/api/config?field=${field}`, { method: "DELETE" });
|
||||||
if (field === "qobuzPassword") { setPwSet(false); setQobuzPassword(""); }
|
|
||||||
if (field === "qobuzToken") { setTokenSet(false); setQobuzToken(""); }
|
if (field === "qobuzToken") { setTokenSet(false); setQobuzToken(""); }
|
||||||
if (field === "slskdApiKey") { setKeySet(false); setSlskdApiKey(""); }
|
if (field === "slskdApiKey") { setKeySet(false); setSlskdApiKey(""); }
|
||||||
if (field === "lastfmApiKey") { setLastfmKeySet(false); setLastfmApiKey(""); }
|
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 (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<PageHead title="Settings" eyebrow="Credentials · library · monitor · discovery · last.fm" />
|
<PageHead title="Settings" eyebrow="Credentials · library · monitor · discovery · last.fm" />
|
||||||
@@ -187,26 +192,12 @@ export function SettingsForm() {
|
|||||||
|
|
||||||
{tab === "Qobuz" ? (
|
{tab === "Qobuz" ? (
|
||||||
<form className="settings-section" onSubmit={saveCredentials}>
|
<form className="settings-section" onSubmit={saveCredentials}>
|
||||||
<div className="subhead">Email / password</div>
|
<p className="help">
|
||||||
<div className="field-grid">
|
Lyra signs in to Qobuz with a <b>user ID + auth token</b> — the download engine
|
||||||
<label className="field">
|
treats these as more reliable than email/password, which Qobuz often rejects. Find
|
||||||
<span>Qobuz email</span>
|
them in your logged-in Qobuz web session (bundle/app secret + user auth token).
|
||||||
<input aria-label="qobuz email" value={qobuzEmail} onChange={(e) => setQobuzEmail(e.target.value)} />
|
</p>
|
||||||
</label>
|
<div className="subhead">Auth token</div>
|
||||||
<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">
|
<div className="field-grid">
|
||||||
<label className="field">
|
<label className="field">
|
||||||
<span>Qobuz user ID</span>
|
<span>Qobuz user ID</span>
|
||||||
@@ -216,7 +207,7 @@ export function SettingsForm() {
|
|||||||
<span>
|
<span>
|
||||||
Qobuz auth token{secretHint(tokenSet)}
|
Qobuz auth token{secretHint(tokenSet)}
|
||||||
{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")}>
|
aria-label="clear qobuz auth token" onClick={() => clearField("qobuzToken")}>
|
||||||
Clear
|
Clear
|
||||||
</button>
|
</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)} />
|
<input aria-label="qobuz auth token" type="password" placeholder={tokenSet ? "•••• stored" : ""} value={qobuzToken} onChange={(e) => setQobuzToken(e.target.value)} />
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</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">
|
<div className="save-row">
|
||||||
<button type="submit" className="btn accent">Save</button>
|
<button type="submit" className="btn accent">Save</button>
|
||||||
{saved ? <span className="saved-note">Saved</span> : null}
|
{saved ? <span className="saved-note">Saved</span> : null}
|
||||||
@@ -244,7 +245,7 @@ export function SettingsForm() {
|
|||||||
<span>
|
<span>
|
||||||
slskd API key{secretHint(keySet)}
|
slskd API key{secretHint(keySet)}
|
||||||
{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")}>
|
aria-label="clear slskd api key" onClick={() => clearField("slskdApiKey")}>
|
||||||
Clear
|
Clear
|
||||||
</button>
|
</button>
|
||||||
@@ -272,7 +273,7 @@ export function SettingsForm() {
|
|||||||
<span>
|
<span>
|
||||||
Last.fm API key{secretHint(lastfmKeySet)}
|
Last.fm API key{secretHint(lastfmKeySet)}
|
||||||
{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")}>
|
aria-label="clear lastfm api key" onClick={() => clearField("lastfmApiKey")}>
|
||||||
Clear
|
Clear
|
||||||
</button>
|
</button>
|
||||||
@@ -299,6 +300,19 @@ export function SettingsForm() {
|
|||||||
<code>docker-compose.yml</code> and rebuild — it can’t be remounted from here.
|
<code>docker-compose.yml</code> and rebuild — it can’t be remounted from here.
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="info-panel">
|
||||||
|
<div>
|
||||||
|
<span className="k">Staging path</span> <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
|
||||||
|
doesn’t 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 what’s already on disk as owned + monitored.</p>
|
<p className="muted-note">Scan the library to record what’s already on disk as owned + monitored.</p>
|
||||||
<div className="save-row">
|
<div className="save-row">
|
||||||
<button type="button" className="btn" onClick={requestScan} disabled={scanRequested}>
|
<button type="button" className="btn" onClick={requestScan} disabled={scanRequested}>
|
||||||
@@ -326,6 +340,19 @@ export function SettingsForm() {
|
|||||||
Automatic monitoring (grab & upgrade)
|
Automatic monitoring (grab & upgrade)
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</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 it’s ≥ the cutoff; otherwise a better copy is chased. Range 1–3.
|
||||||
|
<br />
|
||||||
|
<b>Poll interval</b> — hours between new-release checks. <b>Retry interval</b> — hours
|
||||||
|
before re-attempting a release that couldn’t 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">
|
<div className="field-grid">
|
||||||
{MONITOR_FIELDS.map(([key, label]) => (
|
{MONITOR_FIELDS.map(([key, label]) => (
|
||||||
<label key={key} className="field">
|
<label key={key} className="field">
|
||||||
@@ -353,6 +380,17 @@ export function SettingsForm() {
|
|||||||
Scheduled discovery sweep
|
Scheduled discovery sweep
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</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">
|
<div className="field-grid">
|
||||||
{DISCOVERY_FIELDS.map(([key, label]) => (
|
{DISCOVERY_FIELDS.map(([key, label]) => (
|
||||||
<label key={key} className="field">
|
<label key={key} className="field">
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { toast } from "../_ui/toast";
|
|||||||
type Wanted = {
|
type Wanted = {
|
||||||
id: string;
|
id: string;
|
||||||
artistName: string;
|
artistName: string;
|
||||||
|
artistMbid: string | null;
|
||||||
album: string;
|
album: string;
|
||||||
state: string;
|
state: string;
|
||||||
currentQualityClass: number | null;
|
currentQualityClass: number | null;
|
||||||
@@ -124,6 +125,7 @@ export function WantedClient() {
|
|||||||
artist: open.artistName,
|
artist: open.artistName,
|
||||||
year: open.firstReleaseDate?.slice(0, 4) ?? null,
|
year: open.firstReleaseDate?.slice(0, 4) ?? null,
|
||||||
type: open.primaryType,
|
type: open.primaryType,
|
||||||
|
artistMbid: open.artistMbid,
|
||||||
}}
|
}}
|
||||||
status={<span className="chip working">{stateLabel(open)}</span>}
|
status={<span className="chip working">{stateLabel(open)}</span>}
|
||||||
actions={
|
actions={
|
||||||
|
|||||||
Reference in New Issue
Block a user