diff --git a/cbd-tui/src/app/library.rs b/cbd-tui/src/app/library.rs index 0cd8022..e6c2436 100644 --- a/cbd-tui/src/app/library.rs +++ b/cbd-tui/src/app/library.rs @@ -17,7 +17,7 @@ use super::{ pub struct Library { title: String, - uuid: String, + path: String, list: Vec, list_state: ListState, parent: Option, @@ -29,7 +29,7 @@ impl Library { pub fn new(tx: Sender) -> Self { Self { title: "Library".to_string(), - uuid: "node:/".to_string(), + path: crabidy_core::ROOT_PATH.to_string(), list: Vec::new(), list_state: ListState::default(), positions: HashMap::new(), @@ -43,12 +43,12 @@ impl Library { self.list .iter() .filter(|i| i.marked) - .map(|i| i.uuid.to_string()) + .map(|i| i.path.to_string()) .collect(), ); } if let Some(idx) = self.list_state.selected() { - return Some(vec![self.list[idx].uuid.to_string()]); + return Some(vec![self.list[idx].path.to_string()]); } None } @@ -62,7 +62,7 @@ impl Library { let item = &self.list[idx]; if let UiItemKind::Node = item.kind { self.tx - .send(MessageFromUi::GetLibraryNode(item.uuid.clone())); + .send(MessageFromUi::GetLibraryNode(item.path.clone())); } } } @@ -99,7 +99,7 @@ impl Library { } } pub fn prev_selected(&self) -> usize { - *self.positions.get(&self.uuid).unwrap_or(&0) + *self.positions.get(&self.path).unwrap_or(&0) } pub fn toggle_mark(&mut self) { if let Some(idx) = self.list_state.selected() { @@ -124,7 +124,7 @@ impl Library { } // if children empty and tracks empty return - self.uuid = node.uuid; + self.path = node.path; self.title = node.title; self.parent = node.parent; self.select(Some(self.prev_selected())); @@ -134,7 +134,7 @@ impl Library { .tracks .iter() .map(|t| UiItem { - uuid: t.uuid.clone(), + path: t.path.clone(), title: format!("{} - {}", t.artist, t.title), kind: UiItemKind::Track, marked: false, @@ -147,7 +147,7 @@ impl Library { .children .iter() .map(|c| UiItem { - uuid: c.uuid.clone(), + path: c.path.clone(), title: c.title.clone(), kind: UiItemKind::Node, marked: false, @@ -214,7 +214,7 @@ impl StatefulList for Library { fn select(&mut self, idx: Option) { if let Some(pos) = idx { self.positions - .entry(self.uuid.clone()) + .entry(self.path.clone()) .and_modify(|e| *e = pos) .or_insert(pos); } diff --git a/cbd-tui/src/app/mod.rs b/cbd-tui/src/app/mod.rs index c937a14..58b758e 100644 --- a/cbd-tui/src/app/mod.rs +++ b/cbd-tui/src/app/mod.rs @@ -33,7 +33,7 @@ enum UiItemKind { } struct UiItem { - uuid: String, + path: String, title: String, kind: UiItemKind, marked: bool, diff --git a/cbd-tui/src/app/queue.rs b/cbd-tui/src/app/queue.rs index 4d13177..db59489 100644 --- a/cbd-tui/src/app/queue.rs +++ b/cbd-tui/src/app/queue.rs @@ -59,7 +59,7 @@ impl Queue { .iter() .enumerate() .map(|(i, t)| UiItem { - uuid: t.uuid.clone(), + path: t.path.clone(), title: format!("{} - {}", t.artist, t.title), kind: UiItemKind::Track, marked: false, diff --git a/cbd-tui/src/main.rs b/cbd-tui/src/main.rs index 3d1c034..7df8af2 100644 --- a/cbd-tui/src/main.rs +++ b/cbd-tui/src/main.rs @@ -89,7 +89,7 @@ async fn orchestrate<'a>( info!(address = config.server.address, "connecting to server"); let mut rpc_client = rpc::RpcClient::connect(&config.server.address).await?; - if let Some(root_node) = rpc_client.get_library_node("node:/").await? { + if let Some(root_node) = rpc_client.get_library_node(crabidy_core::ROOT_PATH).await? { tx.send(MessageToUi::ReplaceLibraryNode(root_node.clone()))?; } @@ -112,8 +112,8 @@ async fn poll( select! { Ok(msg) = &mut rx.recv_async() => { match msg { - MessageFromUi::GetLibraryNode(uuid) => { - if let Some(node) = rpc_client.get_library_node(&uuid).await? { + MessageFromUi::GetLibraryNode(path) => { + if let Some(node) = rpc_client.get_library_node(&path).await? { tx.send(MessageToUi::ReplaceLibraryNode(node.clone())); } }, diff --git a/cbd-tui/src/rpc.rs b/cbd-tui/src/rpc.rs index 20f6e67..a8d5657 100644 --- a/cbd-tui/src/rpc.rs +++ b/cbd-tui/src/rpc.rs @@ -75,13 +75,13 @@ impl RpcClient { pub async fn get_library_node( &mut self, - uuid: &str, + path: &str, ) -> Result, Box> { - if self.library_node_cache.contains_key(uuid) { - return Ok(self.library_node_cache.get(uuid)); + if self.library_node_cache.contains_key(path) { + return Ok(self.library_node_cache.get(path)); } let get_library_node_request = Request::new(GetLibraryNodeRequest { - uuid: uuid.to_string(), + path: path.to_string(), }); let response = self .client @@ -89,31 +89,31 @@ impl RpcClient { .await?; if let Some(library_node) = response.into_inner().node { self.library_node_cache - .insert(uuid.to_string(), library_node); - return Ok(self.library_node_cache.get(uuid)); + .insert(path.to_string(), library_node); + return Ok(self.library_node_cache.get(path)); } Err(Box::new(RpcClientError::NotFound)) } - pub async fn append_tracks(&mut self, uuids: Vec) -> Result<(), Box> { - let append_request = Request::new(AppendRequest { uuids }); + pub async fn append_tracks(&mut self, paths: Vec) -> Result<(), Box> { + let append_request = Request::new(AppendRequest { paths }); self.client.append(append_request).await?; Ok(()) } - pub async fn queue_tracks(&mut self, uuids: Vec) -> Result<(), Box> { - let queue_request = Request::new(QueueRequest { uuids }); + pub async fn queue_tracks(&mut self, paths: Vec) -> Result<(), Box> { + let queue_request = Request::new(QueueRequest { paths }); self.client.queue(queue_request).await?; Ok(()) } pub async fn insert_tracks( &mut self, - uuids: Vec, + paths: Vec, pos: usize, ) -> Result<(), Box> { let insert_request = Request::new(InsertRequest { - uuids, + paths, position: pos as u32, }); self.client.insert(insert_request).await?; @@ -134,8 +134,8 @@ impl RpcClient { Ok(()) } - pub async fn replace_queue(&mut self, uuids: Vec) -> Result<(), Box> { - let replace_request = Request::new(ReplaceRequest { uuids }); + pub async fn replace_queue(&mut self, paths: Vec) -> Result<(), Box> { + let replace_request = Request::new(ReplaceRequest { paths }); self.client.replace(replace_request).await?; Ok(()) } diff --git a/crabidy-core/crabidy/v1/crabidy.proto b/crabidy-core/crabidy/v1/crabidy.proto index 8cc39c5..db35bf6 100644 --- a/crabidy-core/crabidy/v1/crabidy.proto +++ b/crabidy-core/crabidy/v1/crabidy.proto @@ -45,7 +45,7 @@ message InitResponse { // Library message GetLibraryNodeRequest { - string uuid = 1; + string path = 1; } message GetLibraryNodeResponse { LibraryNode node = 1; @@ -53,17 +53,17 @@ message GetLibraryNodeResponse { // Queue message QueueRequest { - repeated string uuids = 1; + repeated string paths = 1; } message QueueResponse {} message ReplaceRequest { - repeated string uuids = 1; + repeated string paths = 1; } message ReplaceResponse {} message AppendRequest { - repeated string uuids = 1; + repeated string paths = 1; } message AppendResponse {} @@ -74,7 +74,7 @@ message RemoveResponse {} message InsertRequest { uint32 position = 1; - repeated string uuids = 2; + repeated string paths = 2; } message InsertResponse {} @@ -139,7 +139,7 @@ message RestartTrackResponse {} // Data types message LibraryNodeChild { - string uuid = 1; + string path = 1; string title = 2; bool is_queable = 3; } @@ -181,8 +181,8 @@ message Album { } message Track { - // Including provider - string uuid = 1; + // Full library path including provider + string path = 1; string artist = 2; string title = 3; optional uint32 duration = 4; @@ -190,8 +190,8 @@ message Track { } message LibraryNode { - // Including provider - string uuid = 1; + // Full library path including provider + string path = 1; string title = 2; repeated LibraryNodeChild children = 3; optional string parent = 4; diff --git a/crabidy-core/src/lib.rs b/crabidy-core/src/lib.rs index 94c0ece..cb68ea6 100644 --- a/crabidy-core/src/lib.rs +++ b/crabidy-core/src/lib.rs @@ -10,16 +10,24 @@ use proto::crabidy::{LibraryNode, LibraryNodeChild, Track}; pub mod proto; +/// A media provider addressed like a file system. +/// +/// Every node and track has a `/`-separated absolute path whose first +/// segment names the provider, e.g. `/tidal/playlists//`. +/// The path encodes the position in the library tree: ancestors are +/// obtained by trimming trailing segments. #[async_trait] pub trait ProviderClient: std::fmt::Debug + Send + Sync { async fn init(raw_toml_settings: &str) -> Result where Self: Sized; fn settings(&self) -> String; - async fn get_urls_for_track(&self, track_uuid: &str) -> Result, ProviderError>; - async fn get_metadata_for_track(&self, track_uuid: &str) -> Result; + /// Whether the path addresses a single track (as opposed to a node). + fn is_track_path(&self, path: &str) -> bool; + async fn get_urls_for_track(&self, track_path: &str) -> Result, ProviderError>; + async fn get_metadata_for_track(&self, track_path: &str) -> Result; fn get_lib_root(&self) -> LibraryNode; - async fn get_lib_node(&self, list_uuid: &str) -> Result; + async fn get_lib_node(&self, path: &str) -> Result; } #[derive(Clone, Debug, Hash)] @@ -28,7 +36,7 @@ pub enum ProviderError { UnknownUser, CouldNotLogin, FetchError, - MalformedUuid, + MalformedPath, InternalError, Other, } @@ -41,10 +49,39 @@ impl std::fmt::Display for ProviderError { impl std::error::Error for ProviderError {} +/// The path of the global library root. +pub const ROOT_PATH: &str = "/"; + +/// Returns the parent path, or `None` when at the root. +/// +/// `/tidal/playlists/abc` -> `/tidal/playlists` -> `/tidal` -> `/`. +pub fn parent_path(path: &str) -> Option<&str> { + let trimmed = path.trim_end_matches('/'); + if trimmed.is_empty() { + return None; + } + match trimmed.rfind('/') { + Some(0) => Some(ROOT_PATH), + Some(idx) => Some(&trimmed[..idx]), + None => None, + } +} + +/// Appends a segment to a path. +pub fn join_path(base: &str, segment: &str) -> String { + let base = base.trim_end_matches('/'); + format!("{base}/{segment}") +} + +/// Splits a path into its segments: `/tidal/playlists/x` -> ["tidal", "playlists", "x"]. +pub fn path_segments(path: &str) -> Vec<&str> { + path.split('/').filter(|s| !s.is_empty()).collect() +} + impl LibraryNode { pub fn new() -> Self { Self { - uuid: "node:/".to_string(), + path: ROOT_PATH.to_string(), title: "/".to_string(), children: Vec::new(), parent: None, @@ -55,15 +92,46 @@ impl LibraryNode { } impl LibraryNodeChild { - pub fn new(uuid: String, title: String, is_queable: bool) -> Self { + pub fn new(path: String, title: String, is_queable: bool) -> Self { Self { - uuid, + path, title, is_queable, } } } +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parent_path_walks_up_to_root() { + assert_eq!( + parent_path("/tidal/playlists/abc"), + Some("/tidal/playlists") + ); + assert_eq!(parent_path("/tidal/playlists"), Some("/tidal")); + assert_eq!(parent_path("/tidal"), Some("/")); + assert_eq!(parent_path("/"), None); + } + + #[test] + fn join_path_appends_segments() { + assert_eq!(join_path("/", "tidal"), "/tidal"); + assert_eq!(join_path("/tidal", "playlists"), "/tidal/playlists"); + } + + #[test] + fn path_segments_splits() { + assert_eq!(path_segments("/"), Vec::<&str>::new()); + assert_eq!( + path_segments("/tidal/artists/1/2"), + vec!["tidal", "artists", "1", "2"] + ); + } +} + pub enum QueueError { NotQueable, } diff --git a/crabidy-server/src/main.rs b/crabidy-server/src/main.rs index e1ba0cc..f78111f 100644 --- a/crabidy-server/src/main.rs +++ b/crabidy-server/src/main.rs @@ -146,19 +146,17 @@ impl ProviderMessage { #[derive(Debug)] pub enum ProviderCommand { GetLibraryNode { - uuid: String, + path: String, result_tx: flume::Sender>, }, - GetTrack { - uuid: String, - result_tx: flume::Sender>, - }, GetTrackUrls { - uuid: String, + path: String, result_tx: flume::Sender, ProviderError>>, }, - FlattenNode { - uuid: String, + /// Resolves a path into playable tracks: a track path yields that single + /// track, a node path yields all tracks reachable below it. + ResolveTracks { + path: String, result_tx: flume::Sender>, }, } @@ -167,9 +165,8 @@ impl ProviderCommand { pub fn name(&self) -> &'static str { match self { Self::GetLibraryNode { .. } => "get_library_node", - Self::GetTrack { .. } => "get_track", Self::GetTrackUrls { .. } => "get_track_urls", - Self::FlattenNode { .. } => "flatten_node", + Self::ResolveTracks { .. } => "resolve_tracks", } } } @@ -196,20 +193,20 @@ pub enum PlaybackCommand { result_tx: flume::Sender, }, Replace { - uuids: Vec, + paths: Vec, }, Queue { - uuids: Vec, + paths: Vec, }, Append { - uuids: Vec, + paths: Vec, }, Remove { positions: Vec, }, Insert { position: u32, - uuids: Vec, + paths: Vec, }, Clear { exclude_current: bool, diff --git a/crabidy-server/src/playback.rs b/crabidy-server/src/playback.rs index aab26d8..1ac2582 100644 --- a/crabidy-server/src/playback.rs +++ b/crabidy-server/src/playback.rs @@ -96,8 +96,8 @@ impl Playback { } } - PlaybackCommand::Replace { uuids } => { - let all_tracks = self.resolve_tracks(uuids).await; + PlaybackCommand::Replace { paths } => { + let all_tracks = self.resolve_tracks(paths).await; debug!(count = all_tracks.len(), "replacing queue"); let current = { let Ok(mut queue) = self.queue.lock() else { @@ -111,8 +111,8 @@ impl Playback { self.play(current).await; } - PlaybackCommand::Queue { uuids } => { - let all_tracks = self.resolve_tracks(uuids).await; + PlaybackCommand::Queue { paths } => { + let all_tracks = self.resolve_tracks(paths).await; debug!(count = all_tracks.len(), "queueing after current"); let track = { let Ok(mut queue) = self.queue.lock() else { @@ -126,8 +126,8 @@ impl Playback { self.play_if_some(track).await; } - PlaybackCommand::Append { uuids } => { - let all_tracks = self.resolve_tracks(uuids).await; + PlaybackCommand::Append { paths } => { + let all_tracks = self.resolve_tracks(paths).await; debug!(count = all_tracks.len(), "appending to queue"); let track = { let Ok(mut queue) = self.queue.lock() else { @@ -171,8 +171,8 @@ impl Playback { } } - PlaybackCommand::Insert { position, uuids } => { - let all_tracks = self.resolve_tracks(uuids).await; + PlaybackCommand::Insert { position, paths } => { + let all_tracks = self.resolve_tracks(paths).await; debug!(count = all_tracks.len(), position, "inserting into queue"); let track = { let Ok(mut queue) = self.queue.lock() else { @@ -294,7 +294,7 @@ impl Playback { queue.next_track() }; debug!( - track = track.as_ref().map(|t| t.uuid.as_str()), + track = track.as_ref().map(|t| t.path.as_str()), "advancing to next track" ); self.play_or_stop(track).await; @@ -309,7 +309,7 @@ impl Playback { queue.prev_track() }; debug!( - track = track.as_ref().map(|t| t.uuid.as_str()), + track = track.as_ref().map(|t| t.path.as_str()), "going back to previous track" ); self.play_or_stop(track).await; @@ -360,19 +360,28 @@ impl Playback { } } - /// Resolves a mixed list of track and node identifiers into tracks by - /// asking the provider orchestrator. - async fn resolve_tracks(&self, uuids: Vec) -> Vec { + /// Resolves a mixed list of track and node paths into tracks by asking + /// the provider orchestrator. + async fn resolve_tracks(&self, paths: Vec) -> Vec { let mut all_tracks = Vec::new(); - for uuid in uuids { - if is_track(&uuid) { - match self.get_track(&uuid).await { - Ok(track) => all_tracks.push(track), - Err(err) => warn!(uuid, "failed to resolve track: {err}"), + for path in paths { + let (result_tx, result_rx) = flume::bounded(1); + let message = ProviderMessage::new(ProviderCommand::ResolveTracks { + path: path.clone(), + result_tx, + }); + if let Err(err) = self.provider_tx.send_async(message).await { + error!("provider channel closed: {err}"); + return all_tracks; + } + match result_rx.recv_async().await { + Ok(tracks) => { + if tracks.is_empty() { + warn!(path, "path resolved to no playable tracks"); + } + all_tracks.extend(tracks); } - } else { - let tracks = self.flatten_node(&uuid).await; - all_tracks.extend(tracks); + Err(err) => error!(path, "provider dropped resolve_tracks reply: {err}"), } } trace!(count = all_tracks.len(), "resolved tracks"); @@ -380,47 +389,10 @@ impl Playback { } #[instrument(skip(self))] - async fn flatten_node(&self, uuid: &str) -> Vec { - let (result_tx, result_rx) = flume::bounded(1); - let message = ProviderMessage::new(ProviderCommand::FlattenNode { - uuid: uuid.to_string(), - result_tx, - }); - if let Err(err) = self.provider_tx.send_async(message).await { - error!("provider channel closed: {err}"); - return Vec::new(); - } - match result_rx.recv_async().await { - Ok(tracks) => tracks, - Err(err) => { - error!("provider dropped flatten_node reply: {err}"); - Vec::new() - } - } - } - - #[instrument(skip(self))] - async fn get_track(&self, uuid: &str) -> Result { - let (result_tx, result_rx) = flume::bounded(1); - let message = ProviderMessage::new(ProviderCommand::GetTrack { - uuid: uuid.to_string(), - result_tx, - }); - self.provider_tx - .send_async(message) - .await - .map_err(|_| ProviderError::InternalError)?; - result_rx - .recv_async() - .await - .map_err(|_| ProviderError::InternalError)? - } - - #[instrument(skip(self))] - async fn get_urls_for_track(&self, uuid: &str) -> Result, ProviderError> { + async fn get_urls_for_track(&self, path: &str) -> Result, ProviderError> { let (result_tx, result_rx) = flume::bounded(1); let message = ProviderMessage::new(ProviderCommand::GetTrackUrls { - uuid: uuid.to_string(), + path: path.to_string(), result_tx, }); self.provider_tx @@ -440,7 +412,7 @@ impl Playback { } /// Plays the given track if there is one, otherwise stops the player. - #[instrument(skip(self, track), fields(track = track.as_ref().map(|t| t.uuid.as_str())))] + #[instrument(skip(self, track), fields(track = track.as_ref().map(|t| t.path.as_str())))] async fn play_or_stop(&self, track: Option) { if track.is_some() { self.play(track).await; @@ -459,18 +431,18 @@ impl Playback { /// Starts playback of the given track. When fetching stream URLs fails /// the failing track is skipped and playback continues with the next /// track in the queue. - #[instrument(skip(self, track), fields(track = track.as_ref().map(|t| t.uuid.as_str())))] + #[instrument(skip(self, track), fields(track = track.as_ref().map(|t| t.path.as_str())))] async fn play(&self, track: Option) { let Some(track) = track else { debug!("nothing to play"); return; }; - let mut uuid = track.uuid.clone(); + let mut path = track.path.clone(); let urls = loop { - match self.get_urls_for_track(&uuid).await { + match self.get_urls_for_track(&path).await { Ok(urls) if !urls.is_empty() => break urls, - Ok(_) => warn!(uuid, "provider returned no stream urls, skipping track"), - Err(err) => warn!(uuid, "failed to fetch stream urls ({err}), skipping track"), + Ok(_) => warn!(path, "provider returned no stream urls, skipping track"), + Err(err) => warn!(path, "failed to fetch stream urls ({err}), skipping track"), } let next = { let Ok(mut queue) = self.queue.lock() else { @@ -480,7 +452,7 @@ impl Playback { queue.next_track() }; match next { - Some(next_track) => uuid = next_track.uuid.clone(), + Some(next_track) => path = next_track.path.clone(), None => { error!("no playable track left in queue, stopping"); self.stop_player().await; @@ -504,7 +476,3 @@ impl Playback { } } } - -fn is_track(uuid: &str) -> bool { - uuid.starts_with("track:") -} diff --git a/crabidy-server/src/provider.rs b/crabidy-server/src/provider.rs index fe25115..966185b 100644 --- a/crabidy-server/src/provider.rs +++ b/crabidy-server/src/provider.rs @@ -28,53 +28,57 @@ impl ProviderOrchestrator { async fn handle_command(&self, command: ProviderCommand) { match command { - ProviderCommand::GetLibraryNode { uuid, result_tx } => { - let result = self.get_lib_node(&uuid).await; + ProviderCommand::GetLibraryNode { path, result_tx } => { + let result = self.get_lib_node(&path).await; if let Err(err) = result_tx.send_async(result).await { error!("failed to send get_library_node result: {err}"); } } - ProviderCommand::GetTrack { uuid, result_tx } => { - let result = self.get_metadata_for_track(&uuid).await; - if let Err(err) = result_tx.send_async(result).await { - error!("failed to send get_track result: {err}"); - } - } - ProviderCommand::GetTrackUrls { uuid, result_tx } => { - let result = self.get_urls_for_track(&uuid).await; + ProviderCommand::GetTrackUrls { path, result_tx } => { + let result = self.get_urls_for_track(&path).await; if let Err(err) = result_tx.send_async(result).await { error!("failed to send get_track_urls result: {err}"); } } - ProviderCommand::FlattenNode { uuid, result_tx } => { - let result = self.flatten_node(&uuid).await; + ProviderCommand::ResolveTracks { path, result_tx } => { + let result = self.resolve_tracks(&path).await; if let Err(err) = result_tx.send_async(result).await { - error!("failed to send flatten_node result: {err}"); + error!("failed to send resolve_tracks result: {err}"); } } } } - /// Collects all tracks reachable from the given node by walking its - /// queueable descendants. + /// Resolves a path into playable tracks. A track path resolves to that + /// single track; a node path is flattened by walking its queueable + /// descendants. #[instrument(skip(self))] - async fn flatten_node(&self, node_uuid: &str) -> Vec { + async fn resolve_tracks(&self, path: &str) -> Vec { + if self.is_track_path(path) { + return match self.get_metadata_for_track(path).await { + Ok(track) => vec![track], + Err(err) => { + warn!(path, "failed to resolve track: {err}"); + Vec::new() + } + }; + } let mut tracks = Vec::new(); - let mut nodes_to_go = vec![node_uuid.to_string()]; - while let Some(node_uuid) = nodes_to_go.pop() { - let node = match self.get_lib_node(&node_uuid).await { + let mut nodes_to_go = vec![path.to_string()]; + while let Some(node_path) = nodes_to_go.pop() { + let node = match self.get_lib_node(&node_path).await { Ok(node) => node, Err(err) => { - warn!(node = node_uuid, "skipping unreadable node: {err}"); + warn!(node = node_path, "skipping unreadable node: {err}"); continue; } }; if node.is_queable { tracks.extend(node.tracks); - nodes_to_go.extend(node.children.into_iter().map(|c| c.uuid)) + nodes_to_go.extend(node.children.into_iter().map(|c| c.path)) } } - debug!(count = tracks.len(), "flattened node into tracks"); + debug!(count = tracks.len(), "resolved path into tracks"); tracks } } @@ -119,33 +123,50 @@ impl ProviderClient for ProviderOrchestrator { String::new() } - #[instrument(skip(self))] - async fn get_urls_for_track(&self, track_uuid: &str) -> Result, ProviderError> { - self.tidal_client.get_urls_for_track(track_uuid).await + /// Routes to the provider that owns the path. + fn is_track_path(&self, path: &str) -> bool { + if path == "/tidal" || path.starts_with("/tidal/") { + return self.tidal_client.is_track_path(path); + } + false } #[instrument(skip(self))] - async fn get_metadata_for_track(&self, track_uuid: &str) -> Result { - self.tidal_client.get_metadata_for_track(track_uuid).await + async fn get_urls_for_track(&self, track_path: &str) -> Result, ProviderError> { + if track_path.starts_with("/tidal/") { + return self.tidal_client.get_urls_for_track(track_path).await; + } + warn!(path = track_path, "no provider owns this track path"); + Err(ProviderError::MalformedPath) + } + + #[instrument(skip(self))] + async fn get_metadata_for_track(&self, track_path: &str) -> Result { + if track_path.starts_with("/tidal/") { + return self.tidal_client.get_metadata_for_track(track_path).await; + } + warn!(path = track_path, "no provider owns this track path"); + Err(ProviderError::MalformedPath) } fn get_lib_root(&self) -> LibraryNode { let mut root_node = LibraryNode::new(); - let child = LibraryNodeChild::new("node:tidal".to_owned(), "tidal".to_owned(), false); + let child = + LibraryNodeChild::new(tidaldy::PROVIDER_ROOT.to_owned(), "tidal".to_owned(), false); root_node.children.push(child); root_node } #[instrument(skip(self))] - async fn get_lib_node(&self, uuid: &str) -> Result { - if uuid == "node:/" { + async fn get_lib_node(&self, path: &str) -> Result { + if path == crabidy_core::ROOT_PATH { debug!("serving global library root"); return Ok(self.get_lib_root()); } - if uuid == "node:tidal" { - debug!("serving tidal library root"); - return Ok(self.tidal_client.get_lib_root()); + if path == tidaldy::PROVIDER_ROOT || path.starts_with("/tidal/") { + return self.tidal_client.get_lib_node(path).await; } - self.tidal_client.get_lib_node(uuid).await + warn!(path, "no provider owns this path"); + Err(ProviderError::MalformedPath) } } diff --git a/crabidy-server/src/rpc.rs b/crabidy-server/src/rpc.rs index a4de109..4c323fd 100644 --- a/crabidy-server/src/rpc.rs +++ b/crabidy-server/src/rpc.rs @@ -67,18 +67,18 @@ impl CrabidyService for RpcService { Ok(Response::new(response)) } - #[instrument(skip(self, request), fields(uuid))] + #[instrument(skip(self, request), fields(path))] async fn get_library_node( &self, request: Request, ) -> Result, Status> { - let uuid = request.into_inner().uuid; - tracing::Span::current().record("uuid", uuid.as_str()); + let path = request.into_inner().path; + tracing::Span::current().record("path", path.as_str()); debug!("received get_library_node request"); let (result_tx, result_rx) = flume::bounded(1); self.provider_tx .send_async(ProviderMessage::new(ProviderCommand::GetLibraryNode { - uuid, + path, result_tx, })) .await @@ -99,40 +99,40 @@ impl CrabidyService for RpcService { } } - #[instrument(skip(self, request), fields(uuids))] + #[instrument(skip(self, request), fields(paths))] async fn queue( &self, request: Request, ) -> Result, Status> { - let uuids = request.into_inner().uuids; - tracing::Span::current().record("uuids", format!("{uuids:?}")); + let paths = request.into_inner().paths; + tracing::Span::current().record("paths", format!("{paths:?}")); debug!("received queue request"); - self.send_playback(PlaybackCommand::Queue { uuids }).await?; + self.send_playback(PlaybackCommand::Queue { paths }).await?; Ok(Response::new(QueueResponse {})) } - #[instrument(skip(self, request), fields(uuids))] + #[instrument(skip(self, request), fields(paths))] async fn replace( &self, request: Request, ) -> Result, Status> { - let uuids = request.into_inner().uuids; - tracing::Span::current().record("uuids", format!("{uuids:?}")); + let paths = request.into_inner().paths; + tracing::Span::current().record("paths", format!("{paths:?}")); debug!("received replace request"); - self.send_playback(PlaybackCommand::Replace { uuids }) + self.send_playback(PlaybackCommand::Replace { paths }) .await?; Ok(Response::new(ReplaceResponse {})) } - #[instrument(skip(self, request), fields(uuids))] + #[instrument(skip(self, request), fields(paths))] async fn append( &self, request: Request, ) -> Result, Status> { - let uuids = request.into_inner().uuids; - tracing::Span::current().record("uuids", format!("{uuids:?}")); + let paths = request.into_inner().paths; + tracing::Span::current().record("paths", format!("{paths:?}")); debug!("received append request"); - self.send_playback(PlaybackCommand::Append { uuids }) + self.send_playback(PlaybackCommand::Append { paths }) .await?; Ok(Response::new(AppendResponse {})) } @@ -150,18 +150,18 @@ impl CrabidyService for RpcService { Ok(Response::new(RemoveResponse {})) } - #[instrument(skip(self, request), fields(uuids, position))] + #[instrument(skip(self, request), fields(paths, position))] async fn insert( &self, request: Request, ) -> Result, Status> { let req = request.into_inner(); - tracing::Span::current().record("uuids", format!("{:?}", req.uuids)); + tracing::Span::current().record("paths", format!("{:?}", req.paths)); tracing::Span::current().record("position", req.position); debug!("received insert request"); self.send_playback(PlaybackCommand::Insert { position: req.position, - uuids: req.uuids, + paths: req.paths, }) .await?; Ok(Response::new(InsertResponse {})) diff --git a/tidaldy/src/lib.rs b/tidaldy/src/lib.rs index 4dd49c9..c1df883 100644 --- a/tidaldy/src/lib.rs +++ b/tidaldy/src/lib.rs @@ -39,23 +39,30 @@ impl crabidy_core::ProviderClient for Client { fn settings(&self) -> String { toml::to_string_pretty(&self.settings).unwrap_or_default() } + fn is_track_path(&self, path: &str) -> bool { + matches!( + parse_path(path), + Ok(TidalPath::PlaylistTrack { .. } | TidalPath::AlbumTrack { .. }) + ) + } + #[instrument(skip(self))] async fn get_urls_for_track( &self, - track_uuid: &str, + track_path: &str, ) -> Result, crabidy_core::ProviderError> { - let (_, track_uuid, _) = split_uuid(track_uuid); - let playback = self.get_track_playback(&track_uuid).await.map_err(|err| { - warn!(track = track_uuid, "failed to fetch playback info: {err}"); + let track_id = track_id_from_path(track_path)?; + let playback = self.get_track_playback(track_id).await.map_err(|err| { + warn!(track = track_id, "failed to fetch playback info: {err}"); crabidy_core::ProviderError::FetchError })?; trace!(?playback, "got playback info"); let manifest = playback.get_manifest().map_err(|err| { - warn!(track = track_uuid, "failed to decode manifest: {err}"); + warn!(track = track_id, "failed to decode manifest: {err}"); crabidy_core::ProviderError::FetchError })?; debug!( - track = track_uuid, + track = track_id, urls = manifest.urls.len(), "resolved stream urls" ); @@ -65,37 +72,38 @@ impl crabidy_core::ProviderClient for Client { #[instrument(skip(self))] async fn get_metadata_for_track( &self, - track_uuid: &str, + track_path: &str, ) -> Result { - let track = self.get_track(track_uuid).await.map_err(|err| { - warn!(track = track_uuid, "failed to fetch track metadata: {err}"); + let track_id = track_id_from_path(track_path)?; + let track = self.get_track(track_id).await.map_err(|err| { + warn!(track = track_id, "failed to fetch track metadata: {err}"); crabidy_core::ProviderError::FetchError })?; - Ok(track.into()) + let parent = crabidy_core::parent_path(track_path) + .unwrap_or(PROVIDER_ROOT) + .to_string(); + Ok(track.to_proto(&parent)) } #[instrument(skip(self))] fn get_lib_root(&self) -> crabidy_core::proto::crabidy::LibraryNode { - debug!("get_lib_root in tidaldy"); - let global_root = crabidy_core::proto::crabidy::LibraryNode::new(); - let children = vec![ - crabidy_core::proto::crabidy::LibraryNodeChild::new( - "node:userplaylists".to_string(), - "playlists".to_string(), - false, - ), - crabidy_core::proto::crabidy::LibraryNodeChild::new( - "node:userartists".to_string(), - "artists".to_string(), - false, - ), - ]; crabidy_core::proto::crabidy::LibraryNode { - uuid: "node:tidal".to_string(), + path: PROVIDER_ROOT.to_string(), title: "tidal".to_string(), - parent: Some(format!("{}", global_root.uuid)), + parent: Some(crabidy_core::ROOT_PATH.to_string()), tracks: Vec::new(), - children, + children: vec![ + crabidy_core::proto::crabidy::LibraryNodeChild::new( + format!("{PROVIDER_ROOT}/playlists"), + "playlists".to_string(), + false, + ), + crabidy_core::proto::crabidy::LibraryNodeChild::new( + format!("{PROVIDER_ROOT}/artists"), + "artists".to_string(), + false, + ), + ], is_queable: false, } } @@ -103,19 +111,23 @@ impl crabidy_core::ProviderClient for Client { #[instrument(skip(self))] async fn get_lib_node( &self, - uuid: &str, + path: &str, ) -> Result { let Some(user_id) = self.settings.login.user_id.clone() else { return Err(crabidy_core::ProviderError::UnknownUser); }; - let (_kind, module, uuid) = split_uuid(uuid); - debug!(module, uuid, "resolving library node"); - let node = match module.as_str() { - "userplaylists" => { + let parsed = parse_path(path)?; + debug!(?parsed, "resolving library node"); + let parent = crabidy_core::parent_path(path) + .unwrap_or(crabidy_core::ROOT_PATH) + .to_string(); + let node = match parsed { + TidalPath::Root => self.get_lib_root(), + TidalPath::Playlists => { let mut node = crabidy_core::proto::crabidy::LibraryNode { - uuid: "node:userplaylists".to_string(), + path: path.to_string(), title: "playlists".to_string(), - parent: Some("node:tidal".to_string()), + parent: Some(parent), tracks: Vec::new(), children: Vec::new(), is_queable: false, @@ -124,88 +136,157 @@ impl crabidy_core::ProviderClient for Client { .get_users_playlists_and_favorite_playlists(&user_id) .await? { - let child = crabidy_core::proto::crabidy::LibraryNodeChild::new( - format!("node:playlist:{}", playlist.playlist.uuid), - playlist.playlist.title, - true, - ); - node.children.push(child); + node.children + .push(crabidy_core::proto::crabidy::LibraryNodeChild::new( + crabidy_core::join_path(path, &playlist.playlist.uuid), + playlist.playlist.title, + true, + )); } node } - "playlist" => { - let mut node: crabidy_core::proto::crabidy::LibraryNode = - self.get_playlist(&uuid).await?.into(); - let tracks: Vec = self - .get_playlist_tracks(&uuid) + TidalPath::Playlist(playlist_id) => { + let playlist = self.get_playlist(playlist_id).await?; + let tracks = self + .get_playlist_tracks(playlist_id) .await? .iter() - .map(|t| t.into()) + .map(|t| t.to_proto(path)) .collect(); - node.tracks = tracks; - node.parent = Some("node:userplaylists".to_string()); - node + crabidy_core::proto::crabidy::LibraryNode { + path: path.to_string(), + title: playlist.title, + parent: Some(parent), + tracks, + children: Vec::new(), + is_queable: true, + } } - "userartists" => { + TidalPath::Artists => { let mut node = crabidy_core::proto::crabidy::LibraryNode { - uuid: "node:userartists".to_string(), + path: path.to_string(), title: "artists".to_string(), - parent: Some("node:tidal".to_string()), + parent: Some(parent), tracks: Vec::new(), children: Vec::new(), is_queable: false, }; for artist in self.get_users_artists(&user_id).await? { - let child = crabidy_core::proto::crabidy::LibraryNodeChild::new( - format!("node:artist:{}", artist.item.id), - artist.item.name, - true, - ); - node.children.push(child); + node.children + .push(crabidy_core::proto::crabidy::LibraryNodeChild::new( + crabidy_core::join_path(path, &artist.item.id.to_string()), + artist.item.name, + true, + )); } node } - "artist" => { - let mut node: crabidy_core::proto::crabidy::LibraryNode = - self.get_artist(&uuid).await?.into(); - let children: Vec = self - .get_artist_albums(&uuid) + TidalPath::Artist(artist_id) => { + let artist = self.get_artist(artist_id).await?; + let children = self + .get_artist_albums(artist_id) .await? .iter() - .map(|t| t.into()) + .map(|album| { + crabidy_core::proto::crabidy::LibraryNodeChild::new( + crabidy_core::join_path(path, &album.id.to_string()), + album.title.clone(), + true, + ) + }) .collect(); - node.children = children; - node.parent = Some("node:userartists".to_string()); - node + crabidy_core::proto::crabidy::LibraryNode { + path: path.to_string(), + title: artist.name, + parent: Some(parent), + tracks: Vec::new(), + children, + is_queable: true, + } } - "album" => { - let album = self.get_album(&uuid).await?; - let artis_id = album.artist.clone().unwrap().id; - let mut node: crabidy_core::proto::crabidy::LibraryNode = album.into(); - let tracks: Vec = self - .get_album_tracks(&uuid) + TidalPath::Album { album, .. } => { + let album_data = self.get_album(album).await?; + let tracks = self + .get_album_tracks(album) .await? .iter() - .map(|t| t.into()) + .map(|t| t.to_proto(path)) .collect(); - node.tracks = tracks; - node.parent = Some(format!("node:artist:{}", artis_id)); - node + crabidy_core::proto::crabidy::LibraryNode { + path: path.to_string(), + title: album_data.title, + parent: Some(parent), + tracks, + children: Vec::new(), + is_queable: true, + } + } + TidalPath::PlaylistTrack { .. } | TidalPath::AlbumTrack { .. } => { + warn!(path, "get_lib_node called with a track path"); + return Err(crabidy_core::ProviderError::MalformedPath); } - _ => return Err(crabidy_core::ProviderError::MalformedUuid), }; Ok(node) } } -#[instrument] -fn split_uuid(uuid: &str) -> (String, String, String) { - let mut split = uuid.splitn(3, ':'); - ( - split.next().unwrap_or("").to_string(), - split.next().unwrap_or("").to_string(), - split.next().unwrap_or("").to_string(), - ) +/// The root of this provider in the global library tree. +pub const PROVIDER_ROOT: &str = "/tidal"; + +/// A parsed tidal library path. The position in the tree is fully encoded +/// in the path itself. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum TidalPath<'a> { + Root, + Playlists, + Playlist(&'a str), + PlaylistTrack { + playlist: &'a str, + track: &'a str, + }, + Artists, + Artist(&'a str), + Album { + artist: &'a str, + album: &'a str, + }, + AlbumTrack { + artist: &'a str, + album: &'a str, + track: &'a str, + }, +} + +pub fn parse_path(path: &str) -> Result, crabidy_core::ProviderError> { + let segments = crabidy_core::path_segments(path); + match segments.as_slice() { + ["tidal"] => Ok(TidalPath::Root), + ["tidal", "playlists"] => Ok(TidalPath::Playlists), + ["tidal", "playlists", playlist] => Ok(TidalPath::Playlist(playlist)), + ["tidal", "playlists", playlist, track] => Ok(TidalPath::PlaylistTrack { playlist, track }), + ["tidal", "artists"] => Ok(TidalPath::Artists), + ["tidal", "artists", artist] => Ok(TidalPath::Artist(artist)), + ["tidal", "artists", artist, album] => Ok(TidalPath::Album { artist, album }), + ["tidal", "artists", artist, album, track] => Ok(TidalPath::AlbumTrack { + artist, + album, + track, + }), + _ => { + warn!(path, "malformed tidal path"); + Err(crabidy_core::ProviderError::MalformedPath) + } + } +} + +fn track_id_from_path(path: &str) -> Result<&str, crabidy_core::ProviderError> { + match parse_path(path)? { + TidalPath::PlaylistTrack { track, .. } | TidalPath::AlbumTrack { track, .. } => Ok(track), + _ => { + warn!(path, "expected a track path"); + Err(crabidy_core::ProviderError::MalformedPath) + } + } } impl Client { @@ -494,7 +575,6 @@ impl Client { #[instrument(skip(self))] pub async fn get_track(&self, track_id: &str) -> Result { - let (_, track_id, _) = split_uuid(track_id); self.make_request(&format!("tracks/{}", track_id), None) .await } @@ -689,6 +769,7 @@ mod tests { } #[tokio::test] + #[ignore = "requires a local tidal config and network access"] async fn test() { let client = setup().await; let user = client.settings.login.user_id.clone().unwrap(); diff --git a/tidaldy/src/models.rs b/tidaldy/src/models.rs index 689ca5a..a44c593 100644 --- a/tidaldy/src/models.rs +++ b/tidaldy/src/models.rs @@ -1,7 +1,6 @@ use std::{str::FromStr, string::FromUtf8Error}; use base64::Engine as _; -use crabidy_core::proto::crabidy::{LibraryNode, LibraryNodeChild}; use serde::{Deserialize, Serialize}; use serde_json::Value; use thiserror::Error; @@ -22,19 +21,6 @@ pub struct ArtistItem { pub item: Artist, } -impl From for LibraryNode { - fn from(item: ArtistItem) -> Self { - Self { - uuid: format!("artist:{}", item.item.id), - title: item.item.name, - children: Vec::new(), - parent: None, - tracks: Vec::new(), - is_queable: true, - } - } -} - #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct Artist { @@ -48,29 +34,6 @@ pub struct Artist { pub mixes: Option, } -impl From for LibraryNode { - fn from(artist: Artist) -> Self { - Self { - uuid: format!("node:artist:{}", artist.id), - title: artist.name, - children: Vec::new(), - parent: None, - tracks: Vec::new(), - is_queable: true, - } - } -} - -impl From for LibraryNodeChild { - fn from(artist: Artist) -> Self { - Self { - uuid: format!("node:artist:{}", artist.id), - title: artist.name, - is_queable: true, - } - } -} - #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct ArtistRole { @@ -230,36 +193,24 @@ pub struct Track { pub album: Option, pub mixes: Option, } -impl From for crabidy_core::proto::crabidy::Track { - fn from(track: Track) -> Self { - Self { - uuid: format!("track:{}", track.id), - title: track.title, - artist: match track.artist { - Some(a) => a.name.clone(), - None => "".to_string(), - }, - album: track.album.map(|a| a.into()), - duration: track.duration.map(|d| d as u32 * 1000), + +impl Track { + /// Converts to the wire representation, placing the track under the + /// given parent node path. + pub fn to_proto(&self, parent_path: &str) -> crabidy_core::proto::crabidy::Track { + crabidy_core::proto::crabidy::Track { + path: crabidy_core::join_path(parent_path, &self.id.to_string()), + title: self.title.clone(), + artist: self + .artist + .as_ref() + .map(|a| a.name.clone()) + .unwrap_or_default(), + album: self.album.clone().map(|a| a.into()), + duration: self.duration.map(|d| d as u32 * 1000), } } } - -impl From<&Track> for crabidy_core::proto::crabidy::Track { - fn from(track: &Track) -> Self { - Self { - uuid: format!("track:{}", track.id), - title: track.title.clone(), - artist: match track.artist.as_ref() { - Some(a) => a.name.clone(), - None => "".to_string(), - }, - album: track.album.clone().map(|a| a.into()), - duration: track.duration.map(|d| d as u32 * 1000), - } - } -} - // #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)] // #[serde(rename_all = "camelCase")] // pub struct Artist { @@ -356,39 +307,6 @@ pub struct Album { pub artists: Option>, } -impl From for crabidy_core::proto::crabidy::LibraryNode { - fn from(album: Album) -> Self { - Self { - uuid: format!("node:album:{}", album.id), - title: album.title, - children: Vec::new(), - parent: None, - tracks: Vec::new(), - is_queable: true, - } - } -} - -impl From for crabidy_core::proto::crabidy::LibraryNodeChild { - fn from(album: Album) -> Self { - Self { - uuid: format!("node:album:{}", album.id), - title: album.title, - is_queable: true, - } - } -} - -impl From<&Album> for crabidy_core::proto::crabidy::LibraryNodeChild { - fn from(album: &Album) -> Self { - Self { - uuid: format!("node:album:{}", album.id), - title: album.title.clone(), - is_queable: true, - } - } -} - impl From for crabidy_core::proto::crabidy::Album { fn from(album: Album) -> Self { Self { @@ -478,12 +396,6 @@ pub struct PlaylistAndFavorite { pub playlist: Playlist, } -impl From for crabidy_core::proto::crabidy::LibraryNode { - fn from(a: PlaylistAndFavorite) -> Self { - a.playlist.into() - } -} - #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct Playlist { @@ -507,19 +419,6 @@ pub struct Playlist { pub last_item_added_at: Option, } -impl From for crabidy_core::proto::crabidy::LibraryNode { - fn from(a: Playlist) -> Self { - crabidy_core::proto::crabidy::LibraryNode { - title: a.title, - uuid: format!("node:playlist:{}", a.uuid), - tracks: Vec::new(), - parent: None, - children: Vec::new(), - is_queable: true, - } - } -} - #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct Creator {