//! The shapes the backend serves, and the calls that fetch them. //! //! Mirrors `python-backend/musicmouse/services/web/schemas.py`, by way of //! `web/src/api/types.ts` - the three are hand-kept in step. Only the music player's //! share of the API is here; settings, the IR remote, Home Assistant and the typing //! game are all reachable and all deliberately absent. //! //! Every field the backend can omit is modelled with `#[serde(default)]` rather than a //! required `Option`, so a backend that grows a field does not break this client and //! one that loses a field fails at the one call site that cared. use serde::Deserialize; /// Default for a development run; `--server` overrides it. pub const DEFAULT_BASE_URL: &str = "http://127.0.0.1:8080"; #[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize)] #[serde(rename_all = "lowercase")] pub enum AlbumKind { #[default] Music, Book, } impl AlbumKind { /// Shape is how you tell the two apart without reading anything: albums square, /// audiobooks taller than wide, everywhere they appear. Nothing else in this app /// may set a cover's aspect ratio. Ported from `web/src/lib/covers.ts`. pub fn aspect(self) -> f32 { match self { Self::Music => 1.0, Self::Book => 0.82, } } } #[derive(Debug, Clone, Deserialize)] pub struct Track { pub title: String, /// Seconds, read from the file's tags at scan time. `0.0` when it carries none. #[serde(default)] pub duration: f64, /// A typing-reward track not yet earned. This front-end does not host the typing /// game, but it still has to not show what the game is withholding. #[serde(default)] pub locked: bool, } // `series`, and the parts of `PlayerState` the UI does not read, are kept because these // structs are the record of what the backend serves - dropping a field would make this // file a list of what this front-end happens to use today, which is a much less useful // thing to read next to `schemas.py`. #[allow(dead_code)] #[derive(Debug, Clone, Deserialize)] pub struct Album { pub id: String, /// One of the four fixed shelf folder names: `Figuren`, `Musik`, `Hörbücher`, /// `Kinderpodcasts`. What `Group::of` reads to spot a podcast. pub section: String, #[serde(default)] pub kind: AlbumKind, pub title: String, #[serde(default)] pub artist: String, #[serde(default)] pub series: Option, /// The figurine whose folder this is, when it has one. #[serde(default)] pub figure: Option, /// Series for audiobooks and podcasts, artist for music. What browsing groups by. #[serde(default)] pub category: String, /// Exactly three `#rrggbb`: primary, secondary, accent, pulled out of the cover art /// or synthesised from the id. The generated stand-in cover is painted from these. #[serde(default)] pub colors: Vec, #[serde(default)] pub has_cover: bool, #[serde(default)] pub duration: f64, #[serde(default)] pub tracks: Vec, #[serde(default)] pub locked: bool, } impl Album { pub fn is_book(&self) -> bool { self.kind == AlbumKind::Book } /// `"Album · Artist"`, unless a podcast makes those the same string. pub fn line(&self) -> String { let prefix = if self.is_book() { "📖 " } else { "" }; if !self.artist.is_empty() && self.artist != self.title { format!("{prefix}{} · {}", self.title, self.artist) } else { format!("{prefix}{}", self.title) } } /// `colors` padded out to the three the painter wants, with the same fallbacks the /// web client uses, so a library entry missing them looks the same in both. pub fn palette(&self) -> [&str; 3] { [ self.colors.first().map_or("#4a6fa5", String::as_str), self.colors.get(1).map_or("#6a8fc5", String::as_str), self.colors.get(2).map_or("#a5804a", String::as_str), ] } } #[derive(Debug, Clone, Deserialize)] struct LibraryOut { albums: Vec, } #[allow(dead_code)] #[derive(Debug, Clone, Default, Deserialize)] pub struct Connection { #[serde(default)] pub firmware: bool, #[serde(default)] pub mqtt: bool, #[serde(default)] pub lirc: bool, } /// What is playing. Owned entirely by the backend: the buttons on the mouse, a figurine /// on the reader and Home Assistant all move it too, so this client follows it rather /// than assuming it is in charge. #[allow(dead_code)] // see the note above `Album` #[derive(Debug, Clone, Default, Deserialize)] pub struct PlayerState { #[serde(default)] pub playing: bool, #[serde(default)] pub album_id: Option, #[serde(default)] pub album_title: Option, #[serde(default)] pub artist: Option, #[serde(default)] pub kind: Option, #[serde(default)] pub track_index: i32, #[serde(default)] pub track_title: Option, #[serde(default)] pub track_count: i32, #[serde(default)] pub position: f64, #[serde(default)] pub duration: f64, /// Percent, 0..100. The device's configured range never leaves the backend. #[serde(default)] pub volume: i32, #[serde(default)] pub active_figure: Option, #[serde(default)] pub connected: Connection, } /// The push-only websocket's frames. `library` carries no payload - it is a bare "your /// index is stale, fetch it again" after a rescan. #[derive(Debug, Clone, Deserialize)] #[serde(tag = "type", rename_all = "lowercase")] pub enum ServerMessage { State { state: PlayerState, }, Position { position: f64, #[serde(default)] duration: f64, }, Library, /// A frame this build does not know. Ignored rather than fatal, so the backend can /// grow a message type without this client falling over on it. #[serde(other)] Unknown, } // ------------------------------------------------------------------------- client -- #[derive(Debug, Clone)] pub struct Client { base: String, agent: ureq::Agent, } /// Enough of an error type to put a cause on screen. Nothing here is recoverable in a /// way the caller could branch on - the UI either has a library or says it cannot reach /// the mouse - so the variants are not worth splitting. pub type Error = String; impl Client { pub fn new(base_url: &str) -> Self { Self { base: base_url.trim_end_matches('/').to_string(), agent: ureq::AgentBuilder::new() .timeout_connect(std::time::Duration::from_secs(4)) .timeout_read(std::time::Duration::from_secs(30)) .build(), } } /// `http://host:port` -> `ws://host:port/api/ws`. The device serves plain HTTP, but /// deriving the scheme rather than hardcoding it keeps a TLS proxy in front working. pub fn websocket_url(&self) -> String { let ws = if let Some(rest) = self.base.strip_prefix("https://") { format!("wss://{rest}") } else if let Some(rest) = self.base.strip_prefix("http://") { format!("ws://{rest}") } else { format!("ws://{}", self.base) }; format!("{ws}/api/ws") } pub fn cover_url(&self, album_id: &str) -> String { format!("{}/api/albums/{album_id}/cover", self.base) } /// A GET that decodes JSON, with the status code kept in the error text so a /// caller that cares about 404 - several do, because "not configured" is a normal /// answer here - can tell it from a connection failure. pub(crate) fn get_json(&self, path: &str) -> Result { self.agent .get(&format!("{}/api{path}", self.base)) .call() .map_err(|e| format!("GET {path}: {e}"))? .into_json() .map_err(|e| format!("GET {path}: bad JSON: {e}")) } /// A POST whose response body, if any, is not worth reading. Home Assistant's /// relayed replies are the only ones with a body, and nothing here reads it. pub(crate) fn post_json(&self, path: &str, body: serde_json::Value) -> Result<(), Error> { self.agent .post(&format!("{}/api{path}", self.base)) .send_json(body) .map(|_| ()) .map_err(|e| format!("POST {path}: {e}")) } pub fn library(&self) -> Result, Error> { let out: LibraryOut = self .agent .get(&format!("{}/api/library", self.base)) .call() .map_err(|e| format!("GET /api/library: {e}"))? .into_json() .map_err(|e| format!("GET /api/library: bad JSON: {e}"))?; Ok(out.albums) } pub fn state(&self) -> Result { self.agent .get(&format!("{}/api/state", self.base)) .call() .map_err(|e| format!("GET /api/state: {e}"))? .into_json() .map_err(|e| format!("GET /api/state: bad JSON: {e}")) } /// Raw cover bytes, or `Ok(None)` for a 404 - which is a normal answer, not a /// failure: `has_cover` says so in advance and the client paints the album's own /// colours instead. pub fn cover(&self, album_id: &str) -> Result>, Error> { let response = match self.agent.get(&self.cover_url(album_id)).call() { Ok(response) => response, Err(ureq::Error::Status(404, _)) => return Ok(None), Err(e) => return Err(format!("GET cover {album_id}: {e}")), }; let mut bytes = Vec::new(); std::io::Read::read_to_end(&mut response.into_reader(), &mut bytes) .map_err(|e| format!("GET cover {album_id}: {e}"))?; Ok(Some(bytes)) } // -------------------------------------------------------------------- commands -- // // Every one of these emits the same *intent* the physical buttons emit, so this UI // has no privileged path and no way to get out of step with a figure someone puts // on the reader. They all answer 204 with no body - nothing to parse. fn command(&self, path: &str, body: serde_json::Value) -> Result<(), Error> { let request = self.agent.post(&format!("{}/api{path}", self.base)); let result = if body.is_null() { request.call() } else { request.send_json(body) }; result.map(|_| ()).map_err(|e| format!("POST {path}: {e}")) } pub fn play(&self, album_id: &str, track_index: i32) -> Result<(), Error> { self.command( "/play", serde_json::json!({ "album_id": album_id, "track_index": track_index.max(0) }), ) } pub fn resume(&self) -> Result<(), Error> { self.command("/resume", serde_json::Value::Null) } pub fn pause(&self) -> Result<(), Error> { self.command("/pause", serde_json::Value::Null) } pub fn next(&self) -> Result<(), Error> { self.command("/next", serde_json::Value::Null) } pub fn previous(&self) -> Result<(), Error> { self.command("/previous", serde_json::Value::Null) } pub fn seek(&self, position: f64) -> Result<(), Error> { self.command( "/seek", serde_json::json!({ "position": position.max(0.0) }), ) } pub fn volume(&self, percent: i32) -> Result<(), Error> { self.command( "/volume", serde_json::json!({ "percent": percent.clamp(0, 100) }), ) } }