From 24d4ba609802595041697ddff784421e923ccb1c Mon Sep 17 00:00:00 2001 From: Jonathan Date: Tue, 14 Jul 2026 12:09:52 +0200 Subject: [PATCH] feat(web): AlbumModal follow everywhere + Settings polish (#10) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- web/src/app/api/library/route.ts | Bin 1142 -> 1204 bytes web/src/app/api/wanted/route.ts | 1 + .../app/artists/[id]/discography-client.tsx | 3 +- web/src/app/design.css | 9 +- web/src/app/library/library-client.tsx | 3 +- web/src/app/settings/settings-form.tsx | 100 ++++++++++++------ web/src/app/wanted/wanted-client.tsx | 2 + 7 files changed, 84 insertions(+), 34 deletions(-) diff --git a/web/src/app/api/library/route.ts b/web/src/app/api/library/route.ts index 7162466ec36b9d1f0b185375775547d78c954a4e..b060ee47d295bae2d426763886f3714810d31e73 100644 GIT binary patch delta 57 vcmeyyv4wL16OWEUVo^zEafxqIX3FMYjG0U_3P>VW3b{r0dI%MhgISaTPDvCO delta 15 WcmdnO`Hf=%(`F&2WTwdrS%d*BdIZ$~ diff --git a/web/src/app/api/wanted/route.ts b/web/src/app/api/wanted/route.ts index 2f87a0e..a556091 100644 --- a/web/src/app/api/wanted/route.ts +++ b/web/src/app/api/wanted/route.ts @@ -11,6 +11,7 @@ export async function GET() { wanted: items.map((r) => ({ id: r.id, artistName: r.artistName, + artistMbid: r.artistMbid, album: r.album, state: r.state, currentQualityClass: r.currentQualityClass, diff --git a/web/src/app/artists/[id]/discography-client.tsx b/web/src/app/artists/[id]/discography-client.tsx index e2f1903..8c0617c 100644 --- a/web/src/app/artists/[id]/discography-client.tsx +++ b/web/src/app/artists/[id]/discography-client.tsx @@ -17,7 +17,7 @@ type Release = { state: string; 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 } { 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, year: openAlbum.firstReleaseDate?.slice(0, 4) ?? null, type: openAlbum.primaryType, + artistMbid: detail.mbid, }} status={{badge(openAlbum).label}} actions={ diff --git a/web/src/app/design.css b/web/src/app/design.css index d6410c9..b53638c 100644 --- a/web/src/app/design.css +++ b/web/src/app/design.css @@ -149,6 +149,9 @@ nav.contents .sep { flex: 1; } .field { display: flex; flex-direction: column; gap: 6px; } .field > span { 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 { 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); } /* ── 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; } .subhead { 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; } .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-backdrop { diff --git a/web/src/app/library/library-client.tsx b/web/src/app/library/library-client.tsx index bd8c5b6..0da32b4 100644 --- a/web/src/app/library/library-client.tsx +++ b/web/src/app/library/library-client.tsx @@ -15,6 +15,7 @@ type Album = { rgMbid: string | null; year: string | null; primaryType: string | null; + artistMbid: string | null; }; export function LibraryClient() { @@ -74,7 +75,7 @@ export function LibraryClient() { 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={In library · {open.format}} /> ) : null} diff --git a/web/src/app/settings/settings-form.tsx b/web/src/app/settings/settings-form.tsx index 400a0cf..d2e56aa 100644 --- a/web/src/app/settings/settings-form.tsx +++ b/web/src/app/settings/settings-form.tsx @@ -34,8 +34,7 @@ const MONITOR_FIELDS: [string, string][] = [ export function SettingsForm() { const [tab, setTab] = useState("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 ? · set : 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 (
@@ -187,26 +192,12 @@ export function SettingsForm() { {tab === "Qobuz" ? (
-
Email / password
-
- - -
-
Auth token (more reliable; overrides email/password)
+

+ 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). +

+
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} @@ -244,7 +245,7 @@ export function SettingsForm() { slskd API key{secretHint(keySet)} {keySet ? ( - @@ -272,7 +273,7 @@ export function SettingsForm() { Last.fm API key{secretHint(lastfmKeySet)} {lastfmKeySet ? ( - @@ -299,6 +300,19 @@ export function SettingsForm() { 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.

+

+ 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]) => (
+

+ 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.{" "} + ListenBrainz base URL — leave blank for the default endpoint. +

{DISCOVERY_FIELDS.map(([key, label]) => (