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:
parent
6d8bc7f166
commit
56bc0b0d04
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -33,7 +33,7 @@ enum UiItemKind {
|
|||
}
|
||||
|
||||
struct UiItem {
|
||||
uuid: String,
|
||||
path: String,
|
||||
title: String,
|
||||
kind: UiItemKind,
|
||||
marked: bool,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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()));
|
||||
}
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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(())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,67 +360,39 @@ 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");
|
||||
}
|
||||
} 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");
|
||||
all_tracks
|
||||
}
|
||||
|
||||
#[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:")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {}))
|
||||
|
|
|
|||
|
|
@ -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::LibraryNode {
|
||||
path: PROVIDER_ROOT.to_string(),
|
||||
title: "tidal".to_string(),
|
||||
parent: Some(crabidy_core::ROOT_PATH.to_string()),
|
||||
tracks: Vec::new(),
|
||||
children: vec![
|
||||
crabidy_core::proto::crabidy::LibraryNodeChild::new(
|
||||
"node:userplaylists".to_string(),
|
||||
format!("{PROVIDER_ROOT}/playlists"),
|
||||
"playlists".to_string(),
|
||||
false,
|
||||
),
|
||||
crabidy_core::proto::crabidy::LibraryNodeChild::new(
|
||||
"node:userartists".to_string(),
|
||||
format!("{PROVIDER_ROOT}/artists"),
|
||||
"artists".to_string(),
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
|
@ -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),
|
||||
node.children
|
||||
.push(crabidy_core::proto::crabidy::LibraryNodeChild::new(
|
||||
crabidy_core::join_path(path, &playlist.playlist.uuid),
|
||||
playlist.playlist.title,
|
||||
true,
|
||||
);
|
||||
node.children.push(child);
|
||||
));
|
||||
}
|
||||
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),
|
||||
node.children
|
||||
.push(crabidy_core::proto::crabidy::LibraryNodeChild::new(
|
||||
crabidy_core::join_path(path, &artist.item.id.to_string()),
|
||||
artist.item.name,
|
||||
true,
|
||||
);
|
||||
node.children.push(child);
|
||||
));
|
||||
}
|
||||
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();
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Reference in New Issue