feat(qbzd): GET /api/artwork/current 302 + route qbzd art through it
Add a stable current-cover redirect so a daemon-only client (a plasmoid, a bar applet, an `<img src>` in a dashboard) can point at `http://<host>/api/artwork/current` and always get the live cover — no need to know the Qobuz CDN URL. It reads queue state and 302s to the art URL already stamped on the current track (unauthenticated CDN link, no Qobuz session), or 404s when nothing is playing. `qbzd art` becomes the route's shipped caller (§3.1.4): it now issues a redirect-none request and reads the 302 Location (bare `art` prints it; `--save` follows to download), instead of parsing `/api/now-playing`. It no longer depends on auth state, so its exit set drops the needs-auth 4 (now 0 · 3 · 6). P1_ROUTES → 27 with the route-table test updated. 283 tests green. This completes the CONSOLE surface (search/play/browse/discover/ radio/fav/playlist/queue/shuffle/repeat/art/lyrics/resolve/reco + scrobbling + MPRIS + watch/SSE + artwork). cc
This commit is contained in:
3 files changed
+104
-23
No files matched your search
@@ -0,0 +1,38 @@
|
||||
// crates/qbzd/src/api/artwork.rs — GET /api/artwork/current (CONSOLE ext).
|
||||
//
|
||||
// A stable redirect to the CURRENT track's cover art: a daemon-only client (a
|
||||
// plasmoid, a bar applet, an `<img src>` in a dashboard) can point at
|
||||
// `http://<host>/api/artwork/current` and always get the live cover without
|
||||
// knowing the Qobuz CDN URL. The art URL is already stamped on the queue track
|
||||
// (an unauthenticated CDN link), so this reads queue state and 302s to it — no
|
||||
// Qobuz session required. `qbzd art` is the shipped CLI caller (§3.1.4).
|
||||
use std::io::Cursor;
|
||||
|
||||
use tiny_http::{Header, Response};
|
||||
|
||||
use super::{err_json, ApiState};
|
||||
|
||||
/// `GET /api/artwork/current` → `302` to the current cover, or `404` when
|
||||
/// nothing is playing / the track carries no art.
|
||||
pub fn current(state: &ApiState) -> Response<Cursor<Vec<u8>>> {
|
||||
let queue = state.rt.block_on(state.runtime.core().get_queue_state());
|
||||
let url = queue
|
||||
.current_track
|
||||
.as_ref()
|
||||
.and_then(|t| t.artwork_url.clone())
|
||||
.filter(|u| !u.is_empty());
|
||||
|
||||
match url {
|
||||
Some(u) => match Header::from_bytes(&b"Location"[..], u.as_bytes()) {
|
||||
Ok(location) => Response::from_data(Vec::new()).with_status_code(302).with_header(location),
|
||||
// A non-header-safe URL (control bytes) — never expected from the CDN.
|
||||
Err(_) => err_json(500, "internal", "artwork url is not a valid redirect target", "check: qbzd now"),
|
||||
},
|
||||
None => err_json(
|
||||
404,
|
||||
"not_found",
|
||||
"no artwork for the current track",
|
||||
"is something playing? qbzd now",
|
||||
),
|
||||
}
|
||||
}
|
||||
@@ -25,6 +25,7 @@ pub mod queue;
|
||||
pub mod radio;
|
||||
pub mod reco;
|
||||
pub mod search;
|
||||
pub mod artwork;
|
||||
pub mod settings;
|
||||
pub mod sse;
|
||||
pub mod status;
|
||||
@@ -105,6 +106,7 @@ pub const P1_ROUTES: &[(&str, &str)] = &[
|
||||
("POST", "/api/playlist/tracks/add"),
|
||||
("POST", "/api/playlist/tracks/remove"),
|
||||
("GET", "/api/events"),
|
||||
("GET", "/api/artwork/current"),
|
||||
];
|
||||
|
||||
/// A socket bound at boot step 5, not yet serving. Wraps the tiny_http server
|
||||
@@ -354,6 +356,7 @@ fn route(state: &ApiState, req: &mut Request) -> Response<Cursor<Vec<u8>>> {
|
||||
("GET", "/api/suggest") => browse::suggest(state, &query),
|
||||
("GET", "/api/discover") => discover::discover(state, &query),
|
||||
("GET", "/api/lyrics") => lyrics::lyrics(state, &query),
|
||||
("GET", "/api/artwork/current") => artwork::current(state),
|
||||
("POST", "/api/radio") => {
|
||||
let body = read_json_body(req);
|
||||
radio::radio(state, &body)
|
||||
@@ -572,8 +575,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(), 26);
|
||||
assert_eq!(P1_ROUTES.len(), 27);
|
||||
assert!(P1_ROUTES.contains(&("GET", "/api/events"))); // caller: `qbzd watch`
|
||||
assert!(P1_ROUTES.contains(&("GET", "/api/artwork/current"))); // caller: `qbzd art`
|
||||
assert!(P1_ROUTES.contains(&("GET", "/api/discover")));
|
||||
assert!(P1_ROUTES.contains(&("GET", "/api/lyrics")));
|
||||
assert!(P1_ROUTES.contains(&("POST", "/api/reco/playlist")));
|
||||
|
||||
+61
-22
@@ -1,34 +1,73 @@
|
||||
// crates/qbzd/src/cli/art.rs — the `qbzd art` verb (02 §2.3). Current-track
|
||||
// cover art. Reads the artwork_url already carried on the now-playing track
|
||||
// (GET /api/now-playing, stamped from the Qobuz image CDN — an unauthenticated
|
||||
// URL), so it needs no dedicated route in this slice: bare `art` prints the
|
||||
// URL (pipe into a viewer), `--save PATH` downloads it (notification icons).
|
||||
// A daemon-served GET /api/artwork/current (302) is deferred until a client
|
||||
// that can only speak to the daemon needs it.
|
||||
use crate::cli::client::ApiClient;
|
||||
// cover art, resolved through the daemon's own GET /api/artwork/current (302 to
|
||||
// the Qobuz CDN link stamped on the queue track). This is the shipped caller
|
||||
// that anchors that route (§3.1.4): a redirect-none request reads the 302
|
||||
// `Location` (bare `art` prints it, pipe into a viewer), and `--save PATH`
|
||||
// follows it to download the image (notification icons). No Qobuz session is
|
||||
// needed — the art URL is unauthenticated and already on the queue track.
|
||||
use std::time::Duration;
|
||||
|
||||
use crate::cli::client::{resolve_host, resolve_token, CliError};
|
||||
use crate::paths::ProfileRoots;
|
||||
|
||||
/// `qbzd art [--save PATH]`. Exit 0 · 3 · 4 · 6 (nothing playing / no art).
|
||||
/// `qbzd art [--save PATH]`. Exit 0 · 3 · 6 (nothing playing / no art).
|
||||
pub async fn art(host: Option<String>, save: Option<String>, roots: &ProfileRoots) -> i32 {
|
||||
let client = ApiClient::new(host, roots);
|
||||
let np = match client.get("/api/now-playing").await {
|
||||
Ok(v) => v,
|
||||
let target = resolve_host(host);
|
||||
let token = resolve_token(&target, roots);
|
||||
let base = format!("http://{}", target.addr);
|
||||
|
||||
// Redirect-none so we can read the 302 Location (the canonical current-art
|
||||
// URL) instead of transparently following it to the CDN.
|
||||
let client = match reqwest::Client::builder()
|
||||
.connect_timeout(Duration::from_secs(2))
|
||||
.timeout(Duration::from_secs(10))
|
||||
.redirect(reqwest::redirect::Policy::none())
|
||||
.build()
|
||||
{
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
eprintln!("{e}");
|
||||
return e.exit_code();
|
||||
eprintln!("error: {e}");
|
||||
return 1;
|
||||
}
|
||||
};
|
||||
let url = np
|
||||
.get("track")
|
||||
.and_then(|t| t.get("artwork_url"))
|
||||
.and_then(|x| x.as_str())
|
||||
.filter(|u| !u.is_empty());
|
||||
let url = match url {
|
||||
|
||||
let mut req = client.get(format!("{base}/api/artwork/current"));
|
||||
if let Some(t) = &token {
|
||||
req = req.bearer_auth(t);
|
||||
}
|
||||
let resp = match req.send().await {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
let err = if e.is_connect() || e.is_timeout() {
|
||||
CliError::Unreachable(target.addr.clone())
|
||||
} else {
|
||||
CliError::Runtime(format!("request failed: {e}"))
|
||||
};
|
||||
eprintln!("{err}");
|
||||
return err.exit_code();
|
||||
}
|
||||
};
|
||||
|
||||
let status = resp.status().as_u16();
|
||||
if status == 404 {
|
||||
eprintln!("error: no artwork for the current track");
|
||||
eprintln!(" → is something playing? qbzd now");
|
||||
return 6;
|
||||
}
|
||||
if status != 302 {
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
eprintln!("error: daemon returned {status}: {}", body.trim());
|
||||
return 1;
|
||||
}
|
||||
let url = match resp
|
||||
.headers()
|
||||
.get(reqwest::header::LOCATION)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
{
|
||||
Some(u) => u.to_string(),
|
||||
None => {
|
||||
eprintln!("error: no artwork for the current track");
|
||||
eprintln!(" → is something playing? qbzd now");
|
||||
return 6;
|
||||
eprintln!("error: daemon redirected without a Location");
|
||||
return 1;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
Reference in new issue
Block a user