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 {
title: String,
uuid: String,
path: String,
list: Vec<UiItem>,
list_state: ListState,
parent: Option<String>,
@ -29,7 +29,7 @@ impl Library {
pub fn new(tx: Sender<MessageFromUi>) -> 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<usize>) {
if let Some(pos) = idx {
self.positions
.entry(self.uuid.clone())
.entry(self.path.clone())
.and_modify(|e| *e = pos)
.or_insert(pos);
}

View File

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

View File

@ -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,

View File

@ -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()));
}
},

View File

@ -75,13 +75,13 @@ impl RpcClient {
pub async fn get_library_node(
&mut self,
uuid: &str,
path: &str,
) -> Result<Option<&LibraryNode>, Box<dyn Error>> {
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<String>) -> Result<(), Box<dyn Error>> {
let append_request = Request::new(AppendRequest { uuids });
pub async fn append_tracks(&mut self, paths: Vec<String>) -> Result<(), Box<dyn Error>> {
let append_request = Request::new(AppendRequest { paths });
self.client.append(append_request).await?;
Ok(())
}
pub async fn queue_tracks(&mut self, uuids: Vec<String>) -> Result<(), Box<dyn Error>> {
let queue_request = Request::new(QueueRequest { uuids });
pub async fn queue_tracks(&mut self, paths: Vec<String>) -> Result<(), Box<dyn Error>> {
let queue_request = Request::new(QueueRequest { paths });
self.client.queue(queue_request).await?;
Ok(())
}
pub async fn insert_tracks(
&mut self,
uuids: Vec<String>,
paths: Vec<String>,
pos: usize,
) -> Result<(), Box<dyn Error>> {
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<String>) -> Result<(), Box<dyn Error>> {
let replace_request = Request::new(ReplaceRequest { uuids });
pub async fn replace_queue(&mut self, paths: Vec<String>) -> Result<(), Box<dyn Error>> {
let replace_request = Request::new(ReplaceRequest { paths });
self.client.replace(replace_request).await?;
Ok(())
}

View File

@ -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;

View File

@ -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/<id>/<track-id>`.
/// 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<Self, ProviderError>
where
Self: Sized;
fn settings(&self) -> String;
async fn get_urls_for_track(&self, track_uuid: &str) -> Result<Vec<String>, ProviderError>;
async fn get_metadata_for_track(&self, track_uuid: &str) -> Result<Track, ProviderError>;
/// 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<Vec<String>, ProviderError>;
async fn get_metadata_for_track(&self, track_path: &str) -> Result<Track, ProviderError>;
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)]
@ -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,
}

View File

@ -146,19 +146,17 @@ impl ProviderMessage {
#[derive(Debug)]
pub enum ProviderCommand {
GetLibraryNode {
uuid: String,
path: String,
result_tx: flume::Sender<Result<LibraryNode, ProviderError>>,
},
GetTrack {
uuid: String,
result_tx: flume::Sender<Result<Track, ProviderError>>,
},
GetTrackUrls {
uuid: String,
path: String,
result_tx: flume::Sender<Result<Vec<String>, 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<Vec<Track>>,
},
}
@ -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<InitResponse>,
},
Replace {
uuids: Vec<String>,
paths: Vec<String>,
},
Queue {
uuids: Vec<String>,
paths: Vec<String>,
},
Append {
uuids: Vec<String>,
paths: Vec<String>,
},
Remove {
positions: Vec<u32>,
},
Insert {
position: u32,
uuids: Vec<String>,
paths: Vec<String>,
},
Clear {
exclude_current: bool,

View File

@ -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<String>) -> Vec<Track> {
/// Resolves a mixed list of track and node paths into tracks by asking
/// the provider orchestrator.
async fn resolve_tracks(&self, paths: Vec<String>) -> Vec<Track> {
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<Track> {
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> {
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::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<Track>) {
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<Track>) {
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:")
}

View File

@ -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<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 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<Vec<String>, 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<Track, ProviderError> {
self.tidal_client.get_metadata_for_track(track_uuid).await
async fn get_urls_for_track(&self, track_path: &str) -> Result<Vec<String>, 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<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 {
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<LibraryNode, ProviderError> {
if uuid == "node:/" {
async fn get_lib_node(&self, path: &str) -> Result<LibraryNode, ProviderError> {
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)
}
}

View File

@ -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<GetLibraryNodeRequest>,
) -> Result<Response<GetLibraryNodeResponse>, 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<QueueRequest>,
) -> Result<Response<QueueResponse>, 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<ReplaceRequest>,
) -> Result<Response<ReplaceResponse>, 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<AppendRequest>,
) -> Result<Response<AppendResponse>, 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<InsertRequest>,
) -> Result<Response<InsertResponse>, 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 {}))

View File

@ -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<Vec<String>, 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<crabidy_core::proto::crabidy::Track, crabidy_core::ProviderError> {
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<crabidy_core::proto::crabidy::LibraryNode, crabidy_core::ProviderError> {
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<crabidy_core::proto::crabidy::Track> = 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<crabidy_core::proto::crabidy::LibraryNodeChild> = 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<crabidy_core::proto::crabidy::Track> = 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<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 {
@ -494,7 +575,6 @@ impl Client {
#[instrument(skip(self))]
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)
.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();

View File

@ -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<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)]
#[serde(rename_all = "camelCase")]
pub struct Artist {
@ -48,29 +34,6 @@ pub struct Artist {
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)]
#[serde(rename_all = "camelCase")]
pub struct ArtistRole {
@ -230,36 +193,24 @@ pub struct Track {
pub album: Option<Album>,
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 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<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 {
fn from(album: Album) -> Self {
Self {
@ -478,12 +396,6 @@ pub struct PlaylistAndFavorite {
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)]
#[serde(rename_all = "camelCase")]
pub struct Playlist {
@ -507,19 +419,6 @@ pub struct Playlist {
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)]
#[serde(rename_all = "camelCase")]
pub struct Creator {