feat(qbzd): add lyrics verb + GET /api/lyrics (CONSOLE)

- qbz-core: thin QbzCore::get_lyrics wrapper over QobuzClient::get_lyrics
  (additive; lets qbzd avoid the client() escape hatch).
- api/lyrics.rs: GET /api/lyrics?id=<TRACK_ID|current>; flattens the tagged
  QobuzLyricsDocument union to {track_id, synced, lines:[{time_ms?, text}]}.
  No-lyrics = 200 with empty lines, never 404. Auth-gated.
- cli/lyrics.rs: `qbzd lyrics [TRACK_ID] [--synced] [--json]`; --synced prefixes
  each line with its [mm:ss.cc] LRC timestamp.
- api/mod.rs: P1_ROUTES 24->25; route + test. main.rs: Lyrics subcommand.

Verified: cargo test -p qbzd --bin qbzd -> 276 passed, 0 failed.

cc
This commit is contained in:
vicrodh committed 2026-07-19 19:04:14 -06:00
1 parent 7fc292de27
commit 620af9d75d
6 files changed
+203 -1

No files matched your search

+92
View File
@@ -0,0 +1,92 @@
// crates/qbzd/src/api/lyrics.rs — GET /api/lyrics?id=<TRACK_ID|current>
// (02 §2.3 CONSOLE). Qobuz lyrics for a track, normalized to a stable shape:
// {"track_id": N, "synced": bool, "lines": [{"time_ms"?: N, "text": "..."}]}
// The raw QobuzLyricsDocument is a tagged union (synced with per-line/word
// timestamps vs plain text); this flattens it to lines the CLI (and any client)
// can render without knowing the union. `id=current` resolves the queue cursor.
// Auth-gated; a track with no lyrics is a 200 with an empty `lines`, never a 404.
use std::io::Cursor;
use serde_json::Value;
use tiny_http::Response;
use qbz_qobuz::lyrics::{QobuzLyricsContent, QobuzLyricsDocument};
use crate::state::AuthState;
use super::{err_json, json, ApiState};
pub fn lyrics(state: &ApiState, query: &str) -> Response<Cursor<Vec<u8>>> {
if let Some(resp) = auth_gate(state) {
return resp;
}
let id = match resolve_id(state, query) {
Ok(id) => id,
Err(resp) => return resp,
};
match state.rt.block_on(state.runtime.core().get_lyrics(id)) {
Ok(Some(doc)) => json(200, normalize(id, &doc)),
Ok(None) => json(200, serde_json::json!({"track_id": id, "synced": false, "lines": []})),
Err(_) => err_json(502, "lyrics_failed", "lyrics request to Qobuz failed", "try again in a moment"),
}
}
// ============================ internals ============================
/// Flatten the tagged lyrics union into `{track_id, synced, lines:[{time_ms?, text}]}`.
fn normalize(id: u64, doc: &QobuzLyricsDocument) -> Value {
let (synced, lines): (bool, Vec<Value>) = match &doc.original {
Some(QobuzLyricsContent::Synced { lines, .. }) => (
true,
lines
.iter()
.map(|l| match l.start {
Some(ms) => serde_json::json!({"time_ms": ms, "text": l.line}),
None => serde_json::json!({"text": l.line}),
})
.collect(),
),
Some(QobuzLyricsContent::Plain { lines, .. }) => (
false,
lines.iter().map(|l| serde_json::json!({"text": l.line})).collect(),
),
None => (false, Vec::new()),
};
serde_json::json!({"track_id": id, "synced": synced, "lines": lines})
}
/// `?id=<u64>` or `?id=current` (the queue cursor); default `current`.
fn resolve_id(state: &ApiState, query: &str) -> Result<u64, Response<Cursor<Vec<u8>>>> {
let raw = query
.split('&')
.filter_map(|p| {
let mut kv = p.splitn(2, '=');
(kv.next()? == "id").then(|| kv.next().unwrap_or(""))
})
.next()
.unwrap_or("current");
if raw.is_empty() || raw == "current" {
return match state.rt.block_on(state.runtime.core().get_queue_state()).current_track {
Some(t) => Ok(t.id),
None => Err(err_json(404, "not_found", "nothing is playing", "give a track id: qbzd lyrics <TRACK_ID>")),
};
}
raw.parse::<u64>()
.map_err(|_| err_json(400, "bad_request", "lyrics id must be a track id or 'current'", "usage: qbzd lyrics [TRACK_ID]"))
}
fn auth_gate(state: &ApiState) -> Option<Response<Cursor<Vec<u8>>>> {
let needs_auth = state
.shared
.lock()
.map(|s| s.auth == AuthState::NeedsAuth)
.unwrap_or(false);
if needs_auth {
Some(err_json(409, "needs_auth", "not logged in to Qobuz", "run: qbzd login"))
} else {
None
}
}
+5 -1
View File
@@ -17,6 +17,7 @@
pub mod browse;
pub mod discover;
pub mod fav;
pub mod lyrics;
pub mod play;
pub mod playback;
pub mod playlist;
@@ -82,6 +83,7 @@ pub const P1_ROUTES: &[(&str, &str)] = &[
("GET", "/api/similar"),
("GET", "/api/suggest"),
("GET", "/api/discover"),
("GET", "/api/lyrics"),
("POST", "/api/radio"),
("POST", "/api/reco/playlist"),
("POST", "/api/playback/shuffle"),
@@ -318,6 +320,7 @@ fn route(state: &ApiState, req: &mut Request) -> Response<Cursor<Vec<u8>>> {
("GET", "/api/similar") => browse::similar(state, &query),
("GET", "/api/suggest") => browse::suggest(state, &query),
("GET", "/api/discover") => discover::discover(state, &query),
("GET", "/api/lyrics") => lyrics::lyrics(state, &query),
("POST", "/api/radio") => {
let body = read_json_body(req);
radio::radio(state, &body)
@@ -536,8 +539,9 @@ mod tests {
// §3.1.4 HARD RULE, applied to the content-verb door). Row 19:
// GET /api/search — caller: `qbzd search`. Count is pinned so a route
// with no caller cannot creep in; P1 must never overlap P0.
assert_eq!(P1_ROUTES.len(), 24);
assert_eq!(P1_ROUTES.len(), 25);
assert!(P1_ROUTES.contains(&("GET", "/api/discover")));
assert!(P1_ROUTES.contains(&("GET", "/api/lyrics")));
assert!(P1_ROUTES.contains(&("POST", "/api/reco/playlist")));
assert!(P1_ROUTES.contains(&("GET", "/api/favorites")));
assert!(P1_ROUTES.contains(&("POST", "/api/favorites/add")));
+86
View File
@@ -0,0 +1,86 @@
// crates/qbzd/src/cli/lyrics.rs — the `qbzd lyrics [TRACK_ID]` verb (02 §2.3).
// Bare = the current track. Default prints plain text lines; `--synced` prefixes
// each line with its `[mm:ss.cc]` timestamp (when the doc is time-synced);
// `--json` is the raw {track_id, synced, lines} payload.
use serde_json::Value;
use crate::cli::client::ApiClient;
use crate::paths::ProfileRoots;
pub async fn lyrics(host: Option<String>, track_id: Option<u64>, synced: bool, json: bool, roots: &ProfileRoots) -> i32 {
let id = track_id.map(|n| n.to_string()).unwrap_or_else(|| "current".to_string());
let client = ApiClient::new(host, roots);
match client.get(&format!("/api/lyrics?id={id}")).await {
Ok(v) => {
if json {
println!("{}", serde_json::to_string(&v).unwrap_or_default());
} else {
print!("{}", render(&v, synced));
}
0
}
Err(e) => {
eprintln!("{e}");
e.exit_code()
}
}
}
// ============================ internals ============================
fn render(v: &Value, want_synced: bool) -> String {
let is_synced = v.get("synced").and_then(|s| s.as_bool()).unwrap_or(false);
let lines = v.get("lines").and_then(|l| l.as_array());
match lines {
Some(a) if !a.is_empty() => {
let mut out = String::new();
for line in a {
let text = line.get("text").and_then(|t| t.as_str()).unwrap_or("");
if want_synced && is_synced {
match line.get("time_ms").and_then(|t| t.as_i64()) {
Some(ms) => out.push_str(&format!("[{}] {text}\n", fmt_ts(ms))),
None => out.push_str(&format!("{text}\n")),
}
} else {
out.push_str(&format!("{text}\n"));
}
}
out
}
_ => "no lyrics\n".to_string(),
}
}
/// `mm:ss.cc` (centiseconds) from milliseconds — the LRC timestamp form.
fn fmt_ts(ms: i64) -> String {
let ms = ms.max(0);
let secs = ms / 1000;
format!("{:02}:{:02}.{:02}", secs / 60, secs % 60, (ms % 1000) / 10)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn fmt_ts_formats_lrc_timestamps() {
assert_eq!(fmt_ts(12_400), "00:12.40");
assert_eq!(fmt_ts(75_020), "01:15.02");
assert_eq!(fmt_ts(0), "00:00.00");
}
#[test]
fn render_plain_and_synced() {
let synced = serde_json::json!({"synced": true, "lines": [
{"time_ms": 12400, "text": "You're everything"},
{"time_ms": 19850, "text": "From the start"}
]});
assert_eq!(render(&synced, true), "[00:12.40] You're everything\n[00:19.85] From the start\n");
// without --synced, timestamps are dropped.
assert_eq!(render(&synced, false), "You're everything\nFrom the start\n");
// plain doc ignores --synced.
let plain = serde_json::json!({"synced": false, "lines": [{"text": "Line one"}]});
assert_eq!(render(&plain, true), "Line one\n");
assert_eq!(render(&serde_json::json!({"lines": []}), false), "no lyrics\n");
}
}
+1
View File
@@ -9,6 +9,7 @@ pub mod client;
pub mod copy;
pub mod discover;
pub mod fav;
pub mod lyrics;
pub mod mode;
pub mod play;
pub mod playlist;
+6
View File
@@ -109,6 +109,8 @@ enum Cmd {
Repeat { mode: String },
/// Seed-and-go radio: artist:ID | track:ID | album:ID
Radio { seed: String },
/// Lyrics for a track (bare = current); --synced adds [mm:ss.cc] timestamps
Lyrics { track_id: Option<u64>, #[arg(long)] synced: bool, #[arg(long)] json: bool },
/// Current-track cover art — prints the URL, or --save PATH downloads it
Art { #[arg(long)] save: Option<String> },
/// Resolve a Qobuz URL to a kind:ID token (pure, no daemon)
@@ -389,6 +391,10 @@ async fn main() {
let roots = paths::ProfileRoots::resolve(None, None);
cli::radio::radio(cli.host, seed, &roots).await
}
Cmd::Lyrics { track_id, synced, json } => {
let roots = paths::ProfileRoots::resolve(None, None);
cli::lyrics::lyrics(cli.host, track_id, synced, json, &roots).await
}
Cmd::Art { save } => {
let roots = paths::ProfileRoots::resolve(None, None);
cli::art::art(cli.host, save, &roots).await