Refactor provider addressing to filesystem-like paths

Identifiers like node:tidal / node:playlist:<id> / track:<id> are
replaced by absolute, hierarchical paths that encode the position in
the library tree:

  /                                  global root
  /tidal                             provider root
  /tidal/playlists/<id>              playlist (tracks inside)
  /tidal/playlists/<id>/<track>      track
  /tidal/artists/<id>/<album>        album
  /tidal/artists/<id>/<album>/<t>    track

- proto: uuid -> path, uuids -> paths (same field tags, wire
  compatible); crabidy-core gains ROOT_PATH, parent_path, join_path,
  path_segments helpers with unit tests
- ProviderClient gains is_track_path; the orchestrator routes by path
  prefix and exposes a single ResolveTracks command (track path ->
  that track, node path -> flattened subtree), replacing the
  track:-prefix sniffing in the playback loop
- tidaldy parses paths into a typed TidalPath enum; node parents are
  derived from the request path, which removes the album.artist
  unwrap() panic; the network-dependent scratch test is #[ignore]d
- TUI navigates by paths

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
AI User 2026-07-19 21:53:12 +02:00
parent 6d8bc7f166
commit 56bc0b0d04
13 changed files with 421 additions and 387 deletions

View File

@ -17,7 +17,7 @@ use super::{
pub struct Library { pub struct Library {
title: String, title: String,
uuid: String, path: String,
list: Vec<UiItem>, list: Vec<UiItem>,
list_state: ListState, list_state: ListState,
parent: Option<String>, parent: Option<String>,
@ -29,7 +29,7 @@ impl Library {
pub fn new(tx: Sender<MessageFromUi>) -> Self { pub fn new(tx: Sender<MessageFromUi>) -> Self {
Self { Self {
title: "Library".to_string(), title: "Library".to_string(),
uuid: "node:/".to_string(), path: crabidy_core::ROOT_PATH.to_string(),
list: Vec::new(), list: Vec::new(),
list_state: ListState::default(), list_state: ListState::default(),
positions: HashMap::new(), positions: HashMap::new(),
@ -43,12 +43,12 @@ impl Library {
self.list self.list
.iter() .iter()
.filter(|i| i.marked) .filter(|i| i.marked)
.map(|i| i.uuid.to_string()) .map(|i| i.path.to_string())
.collect(), .collect(),
); );
} }
if let Some(idx) = self.list_state.selected() { 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 None
} }
@ -62,7 +62,7 @@ impl Library {
let item = &self.list[idx]; let item = &self.list[idx];
if let UiItemKind::Node = item.kind { if let UiItemKind::Node = item.kind {
self.tx 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 { 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) { pub fn toggle_mark(&mut self) {
if let Some(idx) = self.list_state.selected() { if let Some(idx) = self.list_state.selected() {
@ -124,7 +124,7 @@ impl Library {
} }
// if children empty and tracks empty return // if children empty and tracks empty return
self.uuid = node.uuid; self.path = node.path;
self.title = node.title; self.title = node.title;
self.parent = node.parent; self.parent = node.parent;
self.select(Some(self.prev_selected())); self.select(Some(self.prev_selected()));
@ -134,7 +134,7 @@ impl Library {
.tracks .tracks
.iter() .iter()
.map(|t| UiItem { .map(|t| UiItem {
uuid: t.uuid.clone(), path: t.path.clone(),
title: format!("{} - {}", t.artist, t.title), title: format!("{} - {}", t.artist, t.title),
kind: UiItemKind::Track, kind: UiItemKind::Track,
marked: false, marked: false,
@ -147,7 +147,7 @@ impl Library {
.children .children
.iter() .iter()
.map(|c| UiItem { .map(|c| UiItem {
uuid: c.uuid.clone(), path: c.path.clone(),
title: c.title.clone(), title: c.title.clone(),
kind: UiItemKind::Node, kind: UiItemKind::Node,
marked: false, marked: false,
@ -214,7 +214,7 @@ impl StatefulList for Library {
fn select(&mut self, idx: Option<usize>) { fn select(&mut self, idx: Option<usize>) {
if let Some(pos) = idx { if let Some(pos) = idx {
self.positions self.positions
.entry(self.uuid.clone()) .entry(self.path.clone())
.and_modify(|e| *e = pos) .and_modify(|e| *e = pos)
.or_insert(pos); .or_insert(pos);
} }

View File

@ -33,7 +33,7 @@ enum UiItemKind {
} }
struct UiItem { struct UiItem {
uuid: String, path: String,
title: String, title: String,
kind: UiItemKind, kind: UiItemKind,
marked: bool, marked: bool,

View File

@ -59,7 +59,7 @@ impl Queue {
.iter() .iter()
.enumerate() .enumerate()
.map(|(i, t)| UiItem { .map(|(i, t)| UiItem {
uuid: t.uuid.clone(), path: t.path.clone(),
title: format!("{} - {}", t.artist, t.title), title: format!("{} - {}", t.artist, t.title),
kind: UiItemKind::Track, kind: UiItemKind::Track,
marked: false, marked: false,

View File

@ -89,7 +89,7 @@ async fn orchestrate<'a>(
info!(address = config.server.address, "connecting to server"); info!(address = config.server.address, "connecting to server");
let mut rpc_client = rpc::RpcClient::connect(&config.server.address).await?; 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()))?; tx.send(MessageToUi::ReplaceLibraryNode(root_node.clone()))?;
} }
@ -112,8 +112,8 @@ async fn poll(
select! { select! {
Ok(msg) = &mut rx.recv_async() => { Ok(msg) = &mut rx.recv_async() => {
match msg { match msg {
MessageFromUi::GetLibraryNode(uuid) => { MessageFromUi::GetLibraryNode(path) => {
if let Some(node) = rpc_client.get_library_node(&uuid).await? { if let Some(node) = rpc_client.get_library_node(&path).await? {
tx.send(MessageToUi::ReplaceLibraryNode(node.clone())); tx.send(MessageToUi::ReplaceLibraryNode(node.clone()));
} }
}, },

View File

@ -75,13 +75,13 @@ impl RpcClient {
pub async fn get_library_node( pub async fn get_library_node(
&mut self, &mut self,
uuid: &str, path: &str,
) -> Result<Option<&LibraryNode>, Box<dyn Error>> { ) -> Result<Option<&LibraryNode>, Box<dyn Error>> {
if self.library_node_cache.contains_key(uuid) { if self.library_node_cache.contains_key(path) {
return Ok(self.library_node_cache.get(uuid)); return Ok(self.library_node_cache.get(path));
} }
let get_library_node_request = Request::new(GetLibraryNodeRequest { let get_library_node_request = Request::new(GetLibraryNodeRequest {
uuid: uuid.to_string(), path: path.to_string(),
}); });
let response = self let response = self
.client .client
@ -89,31 +89,31 @@ impl RpcClient {
.await?; .await?;
if let Some(library_node) = response.into_inner().node { if let Some(library_node) = response.into_inner().node {
self.library_node_cache self.library_node_cache
.insert(uuid.to_string(), library_node); .insert(path.to_string(), library_node);
return Ok(self.library_node_cache.get(uuid)); return Ok(self.library_node_cache.get(path));
} }
Err(Box::new(RpcClientError::NotFound)) Err(Box::new(RpcClientError::NotFound))
} }
pub async fn append_tracks(&mut self, uuids: Vec<String>) -> Result<(), Box<dyn Error>> { pub async fn append_tracks(&mut self, paths: Vec<String>) -> Result<(), Box<dyn Error>> {
let append_request = Request::new(AppendRequest { uuids }); let append_request = Request::new(AppendRequest { paths });
self.client.append(append_request).await?; self.client.append(append_request).await?;
Ok(()) Ok(())
} }
pub async fn queue_tracks(&mut self, uuids: Vec<String>) -> Result<(), Box<dyn Error>> { pub async fn queue_tracks(&mut self, paths: Vec<String>) -> Result<(), Box<dyn Error>> {
let queue_request = Request::new(QueueRequest { uuids }); let queue_request = Request::new(QueueRequest { paths });
self.client.queue(queue_request).await?; self.client.queue(queue_request).await?;
Ok(()) Ok(())
} }
pub async fn insert_tracks( pub async fn insert_tracks(
&mut self, &mut self,
uuids: Vec<String>, paths: Vec<String>,
pos: usize, pos: usize,
) -> Result<(), Box<dyn Error>> { ) -> Result<(), Box<dyn Error>> {
let insert_request = Request::new(InsertRequest { let insert_request = Request::new(InsertRequest {
uuids, paths,
position: pos as u32, position: pos as u32,
}); });
self.client.insert(insert_request).await?; self.client.insert(insert_request).await?;
@ -134,8 +134,8 @@ impl RpcClient {
Ok(()) Ok(())
} }
pub async fn replace_queue(&mut self, uuids: Vec<String>) -> Result<(), Box<dyn Error>> { pub async fn replace_queue(&mut self, paths: Vec<String>) -> Result<(), Box<dyn Error>> {
let replace_request = Request::new(ReplaceRequest { uuids }); let replace_request = Request::new(ReplaceRequest { paths });
self.client.replace(replace_request).await?; self.client.replace(replace_request).await?;
Ok(()) Ok(())
} }

View File

@ -45,7 +45,7 @@ message InitResponse {
// Library // Library
message GetLibraryNodeRequest { message GetLibraryNodeRequest {
string uuid = 1; string path = 1;
} }
message GetLibraryNodeResponse { message GetLibraryNodeResponse {
LibraryNode node = 1; LibraryNode node = 1;
@ -53,17 +53,17 @@ message GetLibraryNodeResponse {
// Queue // Queue
message QueueRequest { message QueueRequest {
repeated string uuids = 1; repeated string paths = 1;
} }
message QueueResponse {} message QueueResponse {}
message ReplaceRequest { message ReplaceRequest {
repeated string uuids = 1; repeated string paths = 1;
} }
message ReplaceResponse {} message ReplaceResponse {}
message AppendRequest { message AppendRequest {
repeated string uuids = 1; repeated string paths = 1;
} }
message AppendResponse {} message AppendResponse {}
@ -74,7 +74,7 @@ message RemoveResponse {}
message InsertRequest { message InsertRequest {
uint32 position = 1; uint32 position = 1;
repeated string uuids = 2; repeated string paths = 2;
} }
message InsertResponse {} message InsertResponse {}
@ -139,7 +139,7 @@ message RestartTrackResponse {}
// Data types // Data types
message LibraryNodeChild { message LibraryNodeChild {
string uuid = 1; string path = 1;
string title = 2; string title = 2;
bool is_queable = 3; bool is_queable = 3;
} }
@ -181,8 +181,8 @@ message Album {
} }
message Track { message Track {
// Including provider // Full library path including provider
string uuid = 1; string path = 1;
string artist = 2; string artist = 2;
string title = 3; string title = 3;
optional uint32 duration = 4; optional uint32 duration = 4;
@ -190,8 +190,8 @@ message Track {
} }
message LibraryNode { message LibraryNode {
// Including provider // Full library path including provider
string uuid = 1; string path = 1;
string title = 2; string title = 2;
repeated LibraryNodeChild children = 3; repeated LibraryNodeChild children = 3;
optional string parent = 4; optional string parent = 4;

View File

@ -10,16 +10,24 @@ use proto::crabidy::{LibraryNode, LibraryNodeChild, Track};
pub mod proto; 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/<id>/<track-id>`.
/// The path encodes the position in the library tree: ancestors are
/// obtained by trimming trailing segments.
#[async_trait] #[async_trait]
pub trait ProviderClient: std::fmt::Debug + Send + Sync { pub trait ProviderClient: std::fmt::Debug + Send + Sync {
async fn init(raw_toml_settings: &str) -> Result<Self, ProviderError> async fn init(raw_toml_settings: &str) -> Result<Self, ProviderError>
where where
Self: Sized; Self: Sized;
fn settings(&self) -> String; fn settings(&self) -> String;
async fn get_urls_for_track(&self, track_uuid: &str) -> Result<Vec<String>, ProviderError>; /// Whether the path addresses a single track (as opposed to a node).
async fn get_metadata_for_track(&self, track_uuid: &str) -> Result<Track, ProviderError>; fn is_track_path(&self, path: &str) -> bool;
async fn get_urls_for_track(&self, track_path: &str) -> Result<Vec<String>, ProviderError>;
async fn get_metadata_for_track(&self, track_path: &str) -> Result<Track, ProviderError>;
fn get_lib_root(&self) -> LibraryNode; fn get_lib_root(&self) -> LibraryNode;
async fn get_lib_node(&self, list_uuid: &str) -> Result<LibraryNode, ProviderError>; async fn get_lib_node(&self, path: &str) -> Result<LibraryNode, ProviderError>;
} }
#[derive(Clone, Debug, Hash)] #[derive(Clone, Debug, Hash)]
@ -28,7 +36,7 @@ pub enum ProviderError {
UnknownUser, UnknownUser,
CouldNotLogin, CouldNotLogin,
FetchError, FetchError,
MalformedUuid, MalformedPath,
InternalError, InternalError,
Other, Other,
} }
@ -41,10 +49,39 @@ impl std::fmt::Display for ProviderError {
impl std::error::Error 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 { impl LibraryNode {
pub fn new() -> Self { pub fn new() -> Self {
Self { Self {
uuid: "node:/".to_string(), path: ROOT_PATH.to_string(),
title: "/".to_string(), title: "/".to_string(),
children: Vec::new(), children: Vec::new(),
parent: None, parent: None,
@ -55,15 +92,46 @@ impl LibraryNode {
} }
impl LibraryNodeChild { 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 { Self {
uuid, path,
title, title,
is_queable, 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 { pub enum QueueError {
NotQueable, NotQueable,
} }

View File

@ -146,19 +146,17 @@ impl ProviderMessage {
#[derive(Debug)] #[derive(Debug)]
pub enum ProviderCommand { pub enum ProviderCommand {
GetLibraryNode { GetLibraryNode {
uuid: String, path: String,
result_tx: flume::Sender<Result<LibraryNode, ProviderError>>, result_tx: flume::Sender<Result<LibraryNode, ProviderError>>,
}, },
GetTrack {
uuid: String,
result_tx: flume::Sender<Result<Track, ProviderError>>,
},
GetTrackUrls { GetTrackUrls {
uuid: String, path: String,
result_tx: flume::Sender<Result<Vec<String>, ProviderError>>, result_tx: flume::Sender<Result<Vec<String>, ProviderError>>,
}, },
FlattenNode { /// Resolves a path into playable tracks: a track path yields that single
uuid: String, /// track, a node path yields all tracks reachable below it.
ResolveTracks {
path: String,
result_tx: flume::Sender<Vec<Track>>, result_tx: flume::Sender<Vec<Track>>,
}, },
} }
@ -167,9 +165,8 @@ impl ProviderCommand {
pub fn name(&self) -> &'static str { pub fn name(&self) -> &'static str {
match self { match self {
Self::GetLibraryNode { .. } => "get_library_node", Self::GetLibraryNode { .. } => "get_library_node",
Self::GetTrack { .. } => "get_track",
Self::GetTrackUrls { .. } => "get_track_urls", Self::GetTrackUrls { .. } => "get_track_urls",
Self::FlattenNode { .. } => "flatten_node", Self::ResolveTracks { .. } => "resolve_tracks",
} }
} }
} }
@ -196,20 +193,20 @@ pub enum PlaybackCommand {
result_tx: flume::Sender<InitResponse>, result_tx: flume::Sender<InitResponse>,
}, },
Replace { Replace {
uuids: Vec<String>, paths: Vec<String>,
}, },
Queue { Queue {
uuids: Vec<String>, paths: Vec<String>,
}, },
Append { Append {
uuids: Vec<String>, paths: Vec<String>,
}, },
Remove { Remove {
positions: Vec<u32>, positions: Vec<u32>,
}, },
Insert { Insert {
position: u32, position: u32,
uuids: Vec<String>, paths: Vec<String>,
}, },
Clear { Clear {
exclude_current: bool, exclude_current: bool,

View File

@ -96,8 +96,8 @@ impl Playback {
} }
} }
PlaybackCommand::Replace { uuids } => { PlaybackCommand::Replace { paths } => {
let all_tracks = self.resolve_tracks(uuids).await; let all_tracks = self.resolve_tracks(paths).await;
debug!(count = all_tracks.len(), "replacing queue"); debug!(count = all_tracks.len(), "replacing queue");
let current = { let current = {
let Ok(mut queue) = self.queue.lock() else { let Ok(mut queue) = self.queue.lock() else {
@ -111,8 +111,8 @@ impl Playback {
self.play(current).await; self.play(current).await;
} }
PlaybackCommand::Queue { uuids } => { PlaybackCommand::Queue { paths } => {
let all_tracks = self.resolve_tracks(uuids).await; let all_tracks = self.resolve_tracks(paths).await;
debug!(count = all_tracks.len(), "queueing after current"); debug!(count = all_tracks.len(), "queueing after current");
let track = { let track = {
let Ok(mut queue) = self.queue.lock() else { let Ok(mut queue) = self.queue.lock() else {
@ -126,8 +126,8 @@ impl Playback {
self.play_if_some(track).await; self.play_if_some(track).await;
} }
PlaybackCommand::Append { uuids } => { PlaybackCommand::Append { paths } => {
let all_tracks = self.resolve_tracks(uuids).await; let all_tracks = self.resolve_tracks(paths).await;
debug!(count = all_tracks.len(), "appending to queue"); debug!(count = all_tracks.len(), "appending to queue");
let track = { let track = {
let Ok(mut queue) = self.queue.lock() else { let Ok(mut queue) = self.queue.lock() else {
@ -171,8 +171,8 @@ impl Playback {
} }
} }
PlaybackCommand::Insert { position, uuids } => { PlaybackCommand::Insert { position, paths } => {
let all_tracks = self.resolve_tracks(uuids).await; let all_tracks = self.resolve_tracks(paths).await;
debug!(count = all_tracks.len(), position, "inserting into queue"); debug!(count = all_tracks.len(), position, "inserting into queue");
let track = { let track = {
let Ok(mut queue) = self.queue.lock() else { let Ok(mut queue) = self.queue.lock() else {
@ -294,7 +294,7 @@ impl Playback {
queue.next_track() queue.next_track()
}; };
debug!( 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" "advancing to next track"
); );
self.play_or_stop(track).await; self.play_or_stop(track).await;
@ -309,7 +309,7 @@ impl Playback {
queue.prev_track() queue.prev_track()
}; };
debug!( 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" "going back to previous track"
); );
self.play_or_stop(track).await; self.play_or_stop(track).await;
@ -360,67 +360,39 @@ impl Playback {
} }
} }
/// Resolves a mixed list of track and node identifiers into tracks by /// Resolves a mixed list of track and node paths into tracks by asking
/// asking the provider orchestrator. /// the provider orchestrator.
async fn resolve_tracks(&self, uuids: Vec<String>) -> Vec<Track> { async fn resolve_tracks(&self, paths: Vec<String>) -> Vec<Track> {
let mut all_tracks = Vec::new(); let mut all_tracks = Vec::new();
for uuid in uuids { for path in paths {
if is_track(&uuid) { let (result_tx, result_rx) = flume::bounded(1);
match self.get_track(&uuid).await { let message = ProviderMessage::new(ProviderCommand::ResolveTracks {
Ok(track) => all_tracks.push(track), path: path.clone(),
Err(err) => warn!(uuid, "failed to resolve track: {err}"), 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");
} }
} else {
let tracks = self.flatten_node(&uuid).await;
all_tracks.extend(tracks); all_tracks.extend(tracks);
} }
Err(err) => error!(path, "provider dropped resolve_tracks reply: {err}"),
}
} }
trace!(count = all_tracks.len(), "resolved tracks"); trace!(count = all_tracks.len(), "resolved tracks");
all_tracks all_tracks
} }
#[instrument(skip(self))] #[instrument(skip(self))]
async fn flatten_node(&self, uuid: &str) -> Vec<Track> { async fn get_urls_for_track(&self, path: &str) -> Result<Vec<String>, ProviderError> {
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<Track, ProviderError> {
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<Vec<String>, ProviderError> {
let (result_tx, result_rx) = flume::bounded(1); let (result_tx, result_rx) = flume::bounded(1);
let message = ProviderMessage::new(ProviderCommand::GetTrackUrls { let message = ProviderMessage::new(ProviderCommand::GetTrackUrls {
uuid: uuid.to_string(), path: path.to_string(),
result_tx, result_tx,
}); });
self.provider_tx self.provider_tx
@ -440,7 +412,7 @@ impl Playback {
} }
/// Plays the given track if there is one, otherwise stops the player. /// 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<Track>) { async fn play_or_stop(&self, track: Option<Track>) {
if track.is_some() { if track.is_some() {
self.play(track).await; self.play(track).await;
@ -459,18 +431,18 @@ impl Playback {
/// Starts playback of the given track. When fetching stream URLs fails /// Starts playback of the given track. When fetching stream URLs fails
/// the failing track is skipped and playback continues with the next /// the failing track is skipped and playback continues with the next
/// track in the queue. /// 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<Track>) { async fn play(&self, track: Option<Track>) {
let Some(track) = track else { let Some(track) = track else {
debug!("nothing to play"); debug!("nothing to play");
return; return;
}; };
let mut uuid = track.uuid.clone(); let mut path = track.path.clone();
let urls = loop { 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(urls) if !urls.is_empty() => break urls,
Ok(_) => warn!(uuid, "provider returned no stream urls, skipping track"), Ok(_) => warn!(path, "provider returned no stream urls, skipping track"),
Err(err) => warn!(uuid, "failed to fetch stream urls ({err}), skipping track"), Err(err) => warn!(path, "failed to fetch stream urls ({err}), skipping track"),
} }
let next = { let next = {
let Ok(mut queue) = self.queue.lock() else { let Ok(mut queue) = self.queue.lock() else {
@ -480,7 +452,7 @@ impl Playback {
queue.next_track() queue.next_track()
}; };
match next { match next {
Some(next_track) => uuid = next_track.uuid.clone(), Some(next_track) => path = next_track.path.clone(),
None => { None => {
error!("no playable track left in queue, stopping"); error!("no playable track left in queue, stopping");
self.stop_player().await; self.stop_player().await;
@ -504,7 +476,3 @@ impl Playback {
} }
} }
} }
fn is_track(uuid: &str) -> bool {
uuid.starts_with("track:")
}

View File

@ -28,53 +28,57 @@ impl ProviderOrchestrator {
async fn handle_command(&self, command: ProviderCommand) { async fn handle_command(&self, command: ProviderCommand) {
match command { match command {
ProviderCommand::GetLibraryNode { uuid, result_tx } => { ProviderCommand::GetLibraryNode { path, result_tx } => {
let result = self.get_lib_node(&uuid).await; let result = self.get_lib_node(&path).await;
if let Err(err) = result_tx.send_async(result).await { if let Err(err) = result_tx.send_async(result).await {
error!("failed to send get_library_node result: {err}"); error!("failed to send get_library_node result: {err}");
} }
} }
ProviderCommand::GetTrack { uuid, result_tx } => { ProviderCommand::GetTrackUrls { path, result_tx } => {
let result = self.get_metadata_for_track(&uuid).await; 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 result: {err}");
}
}
ProviderCommand::GetTrackUrls { uuid, result_tx } => {
let result = self.get_urls_for_track(&uuid).await;
if let Err(err) = result_tx.send_async(result).await { if let Err(err) = result_tx.send_async(result).await {
error!("failed to send get_track_urls result: {err}"); error!("failed to send get_track_urls result: {err}");
} }
} }
ProviderCommand::FlattenNode { uuid, result_tx } => { ProviderCommand::ResolveTracks { path, result_tx } => {
let result = self.flatten_node(&uuid).await; let result = self.resolve_tracks(&path).await;
if let Err(err) = result_tx.send_async(result).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 /// Resolves a path into playable tracks. A track path resolves to that
/// queueable descendants. /// single track; a node path is flattened by walking its queueable
/// descendants.
#[instrument(skip(self))] #[instrument(skip(self))]
async fn flatten_node(&self, node_uuid: &str) -> Vec<Track> { async fn resolve_tracks(&self, path: &str) -> Vec<Track> {
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 tracks = Vec::new();
let mut nodes_to_go = vec![node_uuid.to_string()]; let mut nodes_to_go = vec![path.to_string()];
while let Some(node_uuid) = nodes_to_go.pop() { while let Some(node_path) = nodes_to_go.pop() {
let node = match self.get_lib_node(&node_uuid).await { let node = match self.get_lib_node(&node_path).await {
Ok(node) => node, Ok(node) => node,
Err(err) => { Err(err) => {
warn!(node = node_uuid, "skipping unreadable node: {err}"); warn!(node = node_path, "skipping unreadable node: {err}");
continue; continue;
} }
}; };
if node.is_queable { if node.is_queable {
tracks.extend(node.tracks); 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 tracks
} }
} }
@ -119,33 +123,50 @@ impl ProviderClient for ProviderOrchestrator {
String::new() String::new()
} }
#[instrument(skip(self))] /// Routes to the provider that owns the path.
async fn get_urls_for_track(&self, track_uuid: &str) -> Result<Vec<String>, ProviderError> { fn is_track_path(&self, path: &str) -> bool {
self.tidal_client.get_urls_for_track(track_uuid).await if path == "/tidal" || path.starts_with("/tidal/") {
return self.tidal_client.is_track_path(path);
}
false
} }
#[instrument(skip(self))] #[instrument(skip(self))]
async fn get_metadata_for_track(&self, track_uuid: &str) -> Result<Track, ProviderError> { async fn get_urls_for_track(&self, track_path: &str) -> Result<Vec<String>, ProviderError> {
self.tidal_client.get_metadata_for_track(track_uuid).await 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<Track, ProviderError> {
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 { fn get_lib_root(&self) -> LibraryNode {
let mut root_node = LibraryNode::new(); 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.children.push(child);
root_node root_node
} }
#[instrument(skip(self))] #[instrument(skip(self))]
async fn get_lib_node(&self, uuid: &str) -> Result<LibraryNode, ProviderError> { async fn get_lib_node(&self, path: &str) -> Result<LibraryNode, ProviderError> {
if uuid == "node:/" { if path == crabidy_core::ROOT_PATH {
debug!("serving global library root"); debug!("serving global library root");
return Ok(self.get_lib_root()); return Ok(self.get_lib_root());
} }
if uuid == "node:tidal" { if path == tidaldy::PROVIDER_ROOT || path.starts_with("/tidal/") {
debug!("serving tidal library root"); return self.tidal_client.get_lib_node(path).await;
return Ok(self.tidal_client.get_lib_root());
} }
self.tidal_client.get_lib_node(uuid).await warn!(path, "no provider owns this path");
Err(ProviderError::MalformedPath)
} }
} }

View File

@ -67,18 +67,18 @@ impl CrabidyService for RpcService {
Ok(Response::new(response)) Ok(Response::new(response))
} }
#[instrument(skip(self, request), fields(uuid))] #[instrument(skip(self, request), fields(path))]
async fn get_library_node( async fn get_library_node(
&self, &self,
request: Request<GetLibraryNodeRequest>, request: Request<GetLibraryNodeRequest>,
) -> Result<Response<GetLibraryNodeResponse>, Status> { ) -> Result<Response<GetLibraryNodeResponse>, Status> {
let uuid = request.into_inner().uuid; let path = request.into_inner().path;
tracing::Span::current().record("uuid", uuid.as_str()); tracing::Span::current().record("path", path.as_str());
debug!("received get_library_node request"); debug!("received get_library_node request");
let (result_tx, result_rx) = flume::bounded(1); let (result_tx, result_rx) = flume::bounded(1);
self.provider_tx self.provider_tx
.send_async(ProviderMessage::new(ProviderCommand::GetLibraryNode { .send_async(ProviderMessage::new(ProviderCommand::GetLibraryNode {
uuid, path,
result_tx, result_tx,
})) }))
.await .await
@ -99,40 +99,40 @@ impl CrabidyService for RpcService {
} }
} }
#[instrument(skip(self, request), fields(uuids))] #[instrument(skip(self, request), fields(paths))]
async fn queue( async fn queue(
&self, &self,
request: Request<QueueRequest>, request: Request<QueueRequest>,
) -> Result<Response<QueueResponse>, Status> { ) -> Result<Response<QueueResponse>, Status> {
let uuids = request.into_inner().uuids; let paths = request.into_inner().paths;
tracing::Span::current().record("uuids", format!("{uuids:?}")); tracing::Span::current().record("paths", format!("{paths:?}"));
debug!("received queue request"); debug!("received queue request");
self.send_playback(PlaybackCommand::Queue { uuids }).await?; self.send_playback(PlaybackCommand::Queue { paths }).await?;
Ok(Response::new(QueueResponse {})) Ok(Response::new(QueueResponse {}))
} }
#[instrument(skip(self, request), fields(uuids))] #[instrument(skip(self, request), fields(paths))]
async fn replace( async fn replace(
&self, &self,
request: Request<ReplaceRequest>, request: Request<ReplaceRequest>,
) -> Result<Response<ReplaceResponse>, Status> { ) -> Result<Response<ReplaceResponse>, Status> {
let uuids = request.into_inner().uuids; let paths = request.into_inner().paths;
tracing::Span::current().record("uuids", format!("{uuids:?}")); tracing::Span::current().record("paths", format!("{paths:?}"));
debug!("received replace request"); debug!("received replace request");
self.send_playback(PlaybackCommand::Replace { uuids }) self.send_playback(PlaybackCommand::Replace { paths })
.await?; .await?;
Ok(Response::new(ReplaceResponse {})) Ok(Response::new(ReplaceResponse {}))
} }
#[instrument(skip(self, request), fields(uuids))] #[instrument(skip(self, request), fields(paths))]
async fn append( async fn append(
&self, &self,
request: Request<AppendRequest>, request: Request<AppendRequest>,
) -> Result<Response<AppendResponse>, Status> { ) -> Result<Response<AppendResponse>, Status> {
let uuids = request.into_inner().uuids; let paths = request.into_inner().paths;
tracing::Span::current().record("uuids", format!("{uuids:?}")); tracing::Span::current().record("paths", format!("{paths:?}"));
debug!("received append request"); debug!("received append request");
self.send_playback(PlaybackCommand::Append { uuids }) self.send_playback(PlaybackCommand::Append { paths })
.await?; .await?;
Ok(Response::new(AppendResponse {})) Ok(Response::new(AppendResponse {}))
} }
@ -150,18 +150,18 @@ impl CrabidyService for RpcService {
Ok(Response::new(RemoveResponse {})) Ok(Response::new(RemoveResponse {}))
} }
#[instrument(skip(self, request), fields(uuids, position))] #[instrument(skip(self, request), fields(paths, position))]
async fn insert( async fn insert(
&self, &self,
request: Request<InsertRequest>, request: Request<InsertRequest>,
) -> Result<Response<InsertResponse>, Status> { ) -> Result<Response<InsertResponse>, Status> {
let req = request.into_inner(); 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); tracing::Span::current().record("position", req.position);
debug!("received insert request"); debug!("received insert request");
self.send_playback(PlaybackCommand::Insert { self.send_playback(PlaybackCommand::Insert {
position: req.position, position: req.position,
uuids: req.uuids, paths: req.paths,
}) })
.await?; .await?;
Ok(Response::new(InsertResponse {})) Ok(Response::new(InsertResponse {}))

View File

@ -39,23 +39,30 @@ impl crabidy_core::ProviderClient for Client {
fn settings(&self) -> String { fn settings(&self) -> String {
toml::to_string_pretty(&self.settings).unwrap_or_default() 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))] #[instrument(skip(self))]
async fn get_urls_for_track( async fn get_urls_for_track(
&self, &self,
track_uuid: &str, track_path: &str,
) -> Result<Vec<String>, crabidy_core::ProviderError> { ) -> Result<Vec<String>, crabidy_core::ProviderError> {
let (_, track_uuid, _) = split_uuid(track_uuid); let track_id = track_id_from_path(track_path)?;
let playback = self.get_track_playback(&track_uuid).await.map_err(|err| { let playback = self.get_track_playback(track_id).await.map_err(|err| {
warn!(track = track_uuid, "failed to fetch playback info: {err}"); warn!(track = track_id, "failed to fetch playback info: {err}");
crabidy_core::ProviderError::FetchError crabidy_core::ProviderError::FetchError
})?; })?;
trace!(?playback, "got playback info"); trace!(?playback, "got playback info");
let manifest = playback.get_manifest().map_err(|err| { 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 crabidy_core::ProviderError::FetchError
})?; })?;
debug!( debug!(
track = track_uuid, track = track_id,
urls = manifest.urls.len(), urls = manifest.urls.len(),
"resolved stream urls" "resolved stream urls"
); );
@ -65,37 +72,38 @@ impl crabidy_core::ProviderClient for Client {
#[instrument(skip(self))] #[instrument(skip(self))]
async fn get_metadata_for_track( async fn get_metadata_for_track(
&self, &self,
track_uuid: &str, track_path: &str,
) -> Result<crabidy_core::proto::crabidy::Track, crabidy_core::ProviderError> { ) -> Result<crabidy_core::proto::crabidy::Track, crabidy_core::ProviderError> {
let track = self.get_track(track_uuid).await.map_err(|err| { let track_id = track_id_from_path(track_path)?;
warn!(track = track_uuid, "failed to fetch track metadata: {err}"); 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 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))] #[instrument(skip(self))]
fn get_lib_root(&self) -> crabidy_core::proto::crabidy::LibraryNode { fn get_lib_root(&self) -> crabidy_core::proto::crabidy::LibraryNode {
debug!("get_lib_root in tidaldy"); crabidy_core::proto::crabidy::LibraryNode {
let global_root = crabidy_core::proto::crabidy::LibraryNode::new(); path: PROVIDER_ROOT.to_string(),
let children = vec![ title: "tidal".to_string(),
parent: Some(crabidy_core::ROOT_PATH.to_string()),
tracks: Vec::new(),
children: vec![
crabidy_core::proto::crabidy::LibraryNodeChild::new( crabidy_core::proto::crabidy::LibraryNodeChild::new(
"node:userplaylists".to_string(), format!("{PROVIDER_ROOT}/playlists"),
"playlists".to_string(), "playlists".to_string(),
false, false,
), ),
crabidy_core::proto::crabidy::LibraryNodeChild::new( crabidy_core::proto::crabidy::LibraryNodeChild::new(
"node:userartists".to_string(), format!("{PROVIDER_ROOT}/artists"),
"artists".to_string(), "artists".to_string(),
false, false,
), ),
]; ],
crabidy_core::proto::crabidy::LibraryNode {
uuid: "node:tidal".to_string(),
title: "tidal".to_string(),
parent: Some(format!("{}", global_root.uuid)),
tracks: Vec::new(),
children,
is_queable: false, is_queable: false,
} }
} }
@ -103,19 +111,23 @@ impl crabidy_core::ProviderClient for Client {
#[instrument(skip(self))] #[instrument(skip(self))]
async fn get_lib_node( async fn get_lib_node(
&self, &self,
uuid: &str, path: &str,
) -> Result<crabidy_core::proto::crabidy::LibraryNode, crabidy_core::ProviderError> { ) -> Result<crabidy_core::proto::crabidy::LibraryNode, crabidy_core::ProviderError> {
let Some(user_id) = self.settings.login.user_id.clone() else { let Some(user_id) = self.settings.login.user_id.clone() else {
return Err(crabidy_core::ProviderError::UnknownUser); return Err(crabidy_core::ProviderError::UnknownUser);
}; };
let (_kind, module, uuid) = split_uuid(uuid); let parsed = parse_path(path)?;
debug!(module, uuid, "resolving library node"); debug!(?parsed, "resolving library node");
let node = match module.as_str() { let parent = crabidy_core::parent_path(path)
"userplaylists" => { .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 { let mut node = crabidy_core::proto::crabidy::LibraryNode {
uuid: "node:userplaylists".to_string(), path: path.to_string(),
title: "playlists".to_string(), title: "playlists".to_string(),
parent: Some("node:tidal".to_string()), parent: Some(parent),
tracks: Vec::new(), tracks: Vec::new(),
children: Vec::new(), children: Vec::new(),
is_queable: false, is_queable: false,
@ -124,88 +136,157 @@ impl crabidy_core::ProviderClient for Client {
.get_users_playlists_and_favorite_playlists(&user_id) .get_users_playlists_and_favorite_playlists(&user_id)
.await? .await?
{ {
let child = crabidy_core::proto::crabidy::LibraryNodeChild::new( node.children
format!("node:playlist:{}", playlist.playlist.uuid), .push(crabidy_core::proto::crabidy::LibraryNodeChild::new(
crabidy_core::join_path(path, &playlist.playlist.uuid),
playlist.playlist.title, playlist.playlist.title,
true, true,
); ));
node.children.push(child);
} }
node node
} }
"playlist" => { TidalPath::Playlist(playlist_id) => {
let mut node: crabidy_core::proto::crabidy::LibraryNode = let playlist = self.get_playlist(playlist_id).await?;
self.get_playlist(&uuid).await?.into(); let tracks = self
let tracks: Vec<crabidy_core::proto::crabidy::Track> = self .get_playlist_tracks(playlist_id)
.get_playlist_tracks(&uuid)
.await? .await?
.iter() .iter()
.map(|t| t.into()) .map(|t| t.to_proto(path))
.collect(); .collect();
node.tracks = tracks; crabidy_core::proto::crabidy::LibraryNode {
node.parent = Some("node:userplaylists".to_string()); path: path.to_string(),
node title: playlist.title,
parent: Some(parent),
tracks,
children: Vec::new(),
is_queable: true,
} }
"userartists" => { }
TidalPath::Artists => {
let mut node = crabidy_core::proto::crabidy::LibraryNode { let mut node = crabidy_core::proto::crabidy::LibraryNode {
uuid: "node:userartists".to_string(), path: path.to_string(),
title: "artists".to_string(), title: "artists".to_string(),
parent: Some("node:tidal".to_string()), parent: Some(parent),
tracks: Vec::new(), tracks: Vec::new(),
children: Vec::new(), children: Vec::new(),
is_queable: false, is_queable: false,
}; };
for artist in self.get_users_artists(&user_id).await? { for artist in self.get_users_artists(&user_id).await? {
let child = crabidy_core::proto::crabidy::LibraryNodeChild::new( node.children
format!("node:artist:{}", artist.item.id), .push(crabidy_core::proto::crabidy::LibraryNodeChild::new(
crabidy_core::join_path(path, &artist.item.id.to_string()),
artist.item.name, artist.item.name,
true, true,
); ));
node.children.push(child);
} }
node node
} }
"artist" => { TidalPath::Artist(artist_id) => {
let mut node: crabidy_core::proto::crabidy::LibraryNode = let artist = self.get_artist(artist_id).await?;
self.get_artist(&uuid).await?.into(); let children = self
let children: Vec<crabidy_core::proto::crabidy::LibraryNodeChild> = self .get_artist_albums(artist_id)
.get_artist_albums(&uuid)
.await? .await?
.iter() .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(); .collect();
node.children = children; crabidy_core::proto::crabidy::LibraryNode {
node.parent = Some("node:userartists".to_string()); path: path.to_string(),
node title: artist.name,
parent: Some(parent),
tracks: Vec::new(),
children,
is_queable: true,
} }
"album" => { }
let album = self.get_album(&uuid).await?; TidalPath::Album { album, .. } => {
let artis_id = album.artist.clone().unwrap().id; let album_data = self.get_album(album).await?;
let mut node: crabidy_core::proto::crabidy::LibraryNode = album.into(); let tracks = self
let tracks: Vec<crabidy_core::proto::crabidy::Track> = self .get_album_tracks(album)
.get_album_tracks(&uuid)
.await? .await?
.iter() .iter()
.map(|t| t.into()) .map(|t| t.to_proto(path))
.collect(); .collect();
node.tracks = tracks; crabidy_core::proto::crabidy::LibraryNode {
node.parent = Some(format!("node:artist:{}", artis_id)); path: path.to_string(),
node 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) Ok(node)
} }
} }
#[instrument] /// The root of this provider in the global library tree.
fn split_uuid(uuid: &str) -> (String, String, String) { pub const PROVIDER_ROOT: &str = "/tidal";
let mut split = uuid.splitn(3, ':');
( /// A parsed tidal library path. The position in the tree is fully encoded
split.next().unwrap_or("").to_string(), /// in the path itself.
split.next().unwrap_or("").to_string(), #[derive(Debug, Clone, Copy, PartialEq, Eq)]
split.next().unwrap_or("").to_string(), 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<TidalPath<'_>, 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 { impl Client {
@ -494,7 +575,6 @@ impl Client {
#[instrument(skip(self))] #[instrument(skip(self))]
pub async fn get_track(&self, track_id: &str) -> Result<Track, ClientError> { pub async fn get_track(&self, track_id: &str) -> Result<Track, ClientError> {
let (_, track_id, _) = split_uuid(track_id);
self.make_request(&format!("tracks/{}", track_id), None) self.make_request(&format!("tracks/{}", track_id), None)
.await .await
} }
@ -689,6 +769,7 @@ mod tests {
} }
#[tokio::test] #[tokio::test]
#[ignore = "requires a local tidal config and network access"]
async fn test() { async fn test() {
let client = setup().await; let client = setup().await;
let user = client.settings.login.user_id.clone().unwrap(); let user = client.settings.login.user_id.clone().unwrap();

View File

@ -1,7 +1,6 @@
use std::{str::FromStr, string::FromUtf8Error}; use std::{str::FromStr, string::FromUtf8Error};
use base64::Engine as _; use base64::Engine as _;
use crabidy_core::proto::crabidy::{LibraryNode, LibraryNodeChild};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use serde_json::Value; use serde_json::Value;
use thiserror::Error; use thiserror::Error;
@ -22,19 +21,6 @@ pub struct ArtistItem {
pub item: Artist, pub item: Artist,
} }
impl From<ArtistItem> 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)] #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct Artist { pub struct Artist {
@ -48,29 +34,6 @@ pub struct Artist {
pub mixes: Option<ArtistMixes>, pub mixes: Option<ArtistMixes>,
} }
impl From<Artist> 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<Artist> 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)] #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct ArtistRole { pub struct ArtistRole {
@ -230,36 +193,24 @@ pub struct Track {
pub album: Option<Album>, pub album: Option<Album>,
pub mixes: Option<TrackMixes>, pub mixes: Option<TrackMixes>,
} }
impl From<Track> 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 From<&Track> for crabidy_core::proto::crabidy::Track { impl Track {
fn from(track: &Track) -> Self { /// Converts to the wire representation, placing the track under the
Self { /// given parent node path.
uuid: format!("track:{}", track.id), pub fn to_proto(&self, parent_path: &str) -> crabidy_core::proto::crabidy::Track {
title: track.title.clone(), crabidy_core::proto::crabidy::Track {
artist: match track.artist.as_ref() { path: crabidy_core::join_path(parent_path, &self.id.to_string()),
Some(a) => a.name.clone(), title: self.title.clone(),
None => "".to_string(), artist: self
}, .artist
album: track.album.clone().map(|a| a.into()), .as_ref()
duration: track.duration.map(|d| d as u32 * 1000), .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),
} }
} }
} }
// #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)] // #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
// #[serde(rename_all = "camelCase")] // #[serde(rename_all = "camelCase")]
// pub struct Artist { // pub struct Artist {
@ -356,39 +307,6 @@ pub struct Album {
pub artists: Option<Vec<Artist>>, pub artists: Option<Vec<Artist>>,
} }
impl From<Album> 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<Album> 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<Album> for crabidy_core::proto::crabidy::Album { impl From<Album> for crabidy_core::proto::crabidy::Album {
fn from(album: Album) -> Self { fn from(album: Album) -> Self {
Self { Self {
@ -478,12 +396,6 @@ pub struct PlaylistAndFavorite {
pub playlist: Playlist, pub playlist: Playlist,
} }
impl From<PlaylistAndFavorite> for crabidy_core::proto::crabidy::LibraryNode {
fn from(a: PlaylistAndFavorite) -> Self {
a.playlist.into()
}
}
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)] #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct Playlist { pub struct Playlist {
@ -507,19 +419,6 @@ pub struct Playlist {
pub last_item_added_at: Option<String>, pub last_item_added_at: Option<String>,
} }
impl From<Playlist> 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)] #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct Creator { pub struct Creator {