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:
6 files changed
+203
-1
No files matched your search
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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")));
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user