#[cfg(not(target_arch = "wasm32"))] use std::{ fs::{create_dir_all, read_to_string, File}, io::Write, path::Path, }; use async_trait::async_trait; #[cfg(not(target_arch = "wasm32"))] pub use clap_serde_derive::{self, clap, serde, ClapSerde}; use proto::crabidy::{LibraryNode, LibraryNodeChild, Track}; pub mod proto; /// A media provider addressed like a file system. /// /// Every node and track has a `/`-separated absolute path whose first /// segment names the provider, e.g. `/tidal/playlists//`. /// The path encodes the position in the library tree: ancestors are /// obtained by trimming trailing segments. #[async_trait] pub trait ProviderClient: std::fmt::Debug + Send + Sync { async fn init(raw_toml_settings: &str) -> Result where Self: Sized; fn settings(&self) -> String; /// Whether the path addresses a single track (as opposed to a node). fn is_track_path(&self, path: &str) -> bool; async fn get_urls_for_track(&self, track_path: &str) -> Result, ProviderError>; async fn get_metadata_for_track(&self, track_path: &str) -> Result; fn get_lib_root(&self) -> LibraryNode; async fn get_lib_node(&self, path: &str) -> Result; /// Creates a child node under a creatable parent (`LibraryNode.is_creatable`). /// /// What creation means is provider-defined; under `/tidal/search` the /// `title` is a search term and the created node holds its results. /// Idempotent: an existing title returns the existing node. Errors: /// [`ProviderError::NotSupported`] when the parent is not creatable, /// [`ProviderError::InvalidInput`] when the title is empty or /// whitespace-only. async fn create_lib_node( &self, parent_path: &str, title: &str, ) -> Result; /// Renames a node advertised as editable (`LibraryNodeChild.is_editable`). /// /// For a search term the title is the query, so a rename re-runs the /// search; the node's path changes with the title. Renaming onto an /// existing sibling title merges with it (that node is returned). /// Errors: [`ProviderError::NotSupported`] when the path is not /// editable, [`ProviderError::InvalidInput`] when the new title is empty /// or whitespace-only. async fn rename_lib_node( &self, path: &str, new_title: &str, ) -> Result; /// Deletes a node advertised as deletable (`LibraryNodeChild.is_deletable`). /// /// Idempotent: deleting an already-gone node succeeds. Returns the /// refreshed parent node (what a client should display next). Errors: /// [`ProviderError::NotSupported`] when the path is not deletable. async fn delete_lib_node(&self, path: &str) -> Result; /// Streams the playable tracks under `path` into `chunk_tx`, in playback /// order. /// /// This is a local bounded channel used as a stream, and its delivery /// semantics are the contract: /// /// - Zero or more non-empty chunks are sent, in playback order. /// - Resolution is finished when the **sender** is dropped (this method /// returning). There is no end-of-stream marker. /// - Dropping the **receiver** cancels resolution: the provider stops /// fetching at the next send and returns `Ok`. /// - An unreadable node inside the walk is skipped with a warning; only /// a `path` that cannot be resolved at all is an `Err`. /// /// A track path yields exactly one single-track chunk. The default /// implementation walks the node's queueable descendants depth-first in /// pre-order and emits one chunk per node holding tracks; providers /// should override it when they can produce finer-grained chunks (e.g. /// one per fetched page of a large collection). async fn resolve_tracks_into( &self, path: &str, chunk_tx: flume::Sender>, ) -> Result<(), ProviderError> { if self.is_track_path(path) { match self.get_metadata_for_track(path).await { Ok(track) => { let _ = chunk_tx.send_async(vec![track]).await; } Err(err) => tracing::warn!(path, "failed to resolve track: {err}"), } return Ok(()); } // Depth-first pre-order so tracks arrive in listing order; children // are pushed reversed because the worklist pops from the back. let mut nodes_to_go = vec![path.to_string()]; let mut at_root = true; while let Some(node_path) = nodes_to_go.pop() { let node = match self.get_lib_node(&node_path).await { Ok(node) => node, Err(err) if at_root => return Err(err), Err(err) => { tracing::warn!(node = node_path, "skipping unreadable node: {err}"); continue; } }; at_root = false; if !node.is_queable { continue; } if !node.tracks.is_empty() && chunk_tx.send_async(node.tracks).await.is_err() { // Receiver gone: the consumer cancelled, stop fetching. return Ok(()); } nodes_to_go.extend(node.children.into_iter().rev().map(|c| c.path)); } Ok(()) } } #[derive(Clone, Debug, Hash, PartialEq, Eq)] pub enum ProviderError { Config(String), UnknownUser, CouldNotLogin, FetchError, MalformedPath, /// The operation is not supported at this path (e.g. creating a node /// under a parent that is not creatable). NotSupported, /// User-supplied input was rejected (e.g. an empty node title). InvalidInput, InternalError, Other, } impl std::fmt::Display for ProviderError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!(f, "{:?}", self) } } 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() } /// Percent-encodes arbitrary user text into a single path segment. /// /// `/`, `%`, whitespace and every other character that would confuse /// `path_segments` or a URL are escaped: `AC/DC` -> `AC%2FDC`. The result is /// never empty for non-empty input and round-trips through /// [`decode_segment`]. pub fn encode_segment(text: &str) -> String { /// Everything except ASCII alphanumerics and `-`, `_`, `.`, `~` is /// escaped — the URL "unreserved" set. Notably `/`, `%` and whitespace. const SEGMENT: &percent_encoding::AsciiSet = &percent_encoding::NON_ALPHANUMERIC .remove(b'-') .remove(b'_') .remove(b'.') .remove(b'~'); percent_encoding::utf8_percent_encode(text, SEGMENT).to_string() } /// Decodes a segment produced by [`encode_segment`] back to the original /// text. Invalid or lone percent escapes decode lossily (the raw bytes are /// kept) rather than erroring: paths come from clients and must not panic. pub fn decode_segment(segment: &str) -> String { percent_encoding::percent_decode_str(segment) .decode_utf8_lossy() .into_owned() } impl LibraryNode { pub fn new() -> Self { Self { path: ROOT_PATH.to_string(), title: "/".to_string(), children: Vec::new(), parent: None, tracks: Vec::new(), is_queable: false, is_creatable: false, is_downloadable: false, tracks_deletable: false, is_captured: false, } } } impl LibraryNodeChild { /// A regular, non-creatable, immutable child. Creatable/editable/ /// deletable/downloadable children (e.g. the search node and its /// terms, or Tidal's queueable subtrees) set the capability flags /// explicitly via struct update. pub fn new(path: String, title: String, is_queable: bool) -> Self { Self { path, title, is_queable, is_creatable: false, is_editable: false, is_deletable: false, is_downloadable: false, is_captured: false, } } } pub enum QueueError { NotQueable, } #[cfg(not(target_arch = "wasm32"))] pub fn init_config(config_file_name: &str) -> T where T: Default + ClapSerde + serde::Serialize + std::fmt::Debug, { if let Some(config_dir) = dirs::config_dir() { let dir = Path::new(&config_dir).join("crabidy"); if !dir.is_dir() { create_dir_all(&dir).expect("Could not create crabidy config directory"); } let config_file_path = dir.join(config_file_name); if !config_file_path.is_file() { let config = T::default().merge_clap(); let content = toml::to_string_pretty(&config).expect("Could not serialize config"); let mut config_file = File::create(config_file_path).expect("Could not open config file for writing"); config_file .write_all(content.as_bytes()) .expect("Failed to write to file"); return config; } else { let content = read_to_string(config_file_path).expect("Could not read config file"); let parsed = toml::from_str::<::Opt>(&content).unwrap(); let config: T = T::from(parsed).merge_clap(); return config; } } T::default().merge_clap() } #[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"] ); } #[test] fn encode_segment_round_trips_arbitrary_text() { for term in [ "AC/DC", "100% wrong", "Björk", "hello world", "a%2Fb", "?!#&=", ] { let encoded = encode_segment(term); assert_eq!(decode_segment(&encoded), term, "round trip of {term:?}"); } } #[test] fn encoded_segments_are_path_safe() { for term in ["AC/DC", "a/b/c", "//", "term with spaces"] { let encoded = encode_segment(term); assert!(!encoded.is_empty()); assert!(!encoded.contains('/'), "{encoded:?} must be one segment"); // Joining under a parent yields exactly one extra segment. let path = join_path("/tidal/search", &encoded); assert_eq!(path_segments(&path).len(), 3, "path {path:?}"); assert_eq!(parent_path(&path), Some("/tidal/search")); } } #[test] fn decode_segment_is_lossy_not_panicky() { // Invalid or truncated escapes must never panic — paths come from // clients. Exact output is unspecified, only totality matters. for bad in ["%", "%2", "%zz", "abc%", "%%25"] { let _ = decode_segment(bad); } } /// A scripted in-memory provider for exercising the default /// `resolve_tracks_into` walk. Node lookups are recorded so tests can /// assert what was (not) fetched. #[derive(Debug, Default)] struct FakeProvider { nodes: std::collections::HashMap>, track_paths: Vec, fetched: std::sync::Mutex>, } impl FakeProvider { fn node(path: &str, tracks: &[&str], children: &[&str], is_queable: bool) -> LibraryNode { LibraryNode { path: path.to_string(), title: path.to_string(), children: children .iter() .map(|c| LibraryNodeChild::new(c.to_string(), c.to_string(), true)) .collect(), parent: None, tracks: tracks .iter() .map(|t| Track { path: t.to_string(), artist: "artist".to_string(), title: t.to_string(), duration: None, album: None, is_skipped: false, provider_item_id: String::new(), is_captured: false, }) .collect(), is_queable, is_creatable: false, is_downloadable: false, tracks_deletable: false, is_captured: false, } } fn fetched(&self) -> Vec { self.fetched.lock().map(|f| f.clone()).unwrap_or_default() } } #[async_trait] impl ProviderClient for FakeProvider { async fn init(_: &str) -> Result { Ok(Self::default()) } fn settings(&self) -> String { String::new() } fn is_track_path(&self, path: &str) -> bool { self.track_paths.iter().any(|p| p == path) } async fn get_urls_for_track(&self, _: &str) -> Result, ProviderError> { Err(ProviderError::NotSupported) } async fn get_metadata_for_track(&self, path: &str) -> Result { Ok(Track { path: path.to_string(), artist: "artist".to_string(), title: path.to_string(), duration: None, album: None, is_skipped: false, provider_item_id: String::new(), is_captured: false, }) } fn get_lib_root(&self) -> LibraryNode { LibraryNode::new() } async fn get_lib_node(&self, path: &str) -> Result { if let Ok(mut fetched) = self.fetched.lock() { fetched.push(path.to_string()); } self.nodes .get(path) .cloned() .unwrap_or(Err(ProviderError::MalformedPath)) } async fn create_lib_node(&self, _: &str, _: &str) -> Result { Err(ProviderError::NotSupported) } async fn rename_lib_node(&self, _: &str, _: &str) -> Result { Err(ProviderError::NotSupported) } async fn delete_lib_node(&self, _: &str) -> Result { Err(ProviderError::NotSupported) } } /// Runs the default resolve against the fake and collects the chunks it /// streamed. The channel is bounded but larger than any test tree, so /// the resolve never blocks on a full buffer here. async fn resolve_chunks(provider: &FakeProvider, path: &str) -> Vec> { let (chunk_tx, chunk_rx) = flume::bounded(32); provider .resolve_tracks_into(path, chunk_tx) .await .expect("resolve failed"); chunk_rx .into_iter() .map(|chunk| chunk.into_iter().map(|t| t.path).collect()) .collect() } #[tokio::test] async fn default_resolve_streams_chunks_per_node_in_preorder() { let mut provider = FakeProvider::default(); // artist -> [album1, album2], each album carries tracks; the artist // node itself has none. Pre-order and *listing order*: album1's // tracks must come before album2's (the old walk popped LIFO and // reversed siblings). provider.nodes.insert( "/p/artist".into(), Ok(FakeProvider::node( "/p/artist", &[], &["/p/artist/al1", "/p/artist/al2"], true, )), ); provider.nodes.insert( "/p/artist/al1".into(), Ok(FakeProvider::node( "/p/artist/al1", &["/p/artist/al1/t1", "/p/artist/al1/t2"], &[], true, )), ); provider.nodes.insert( "/p/artist/al2".into(), Ok(FakeProvider::node( "/p/artist/al2", &["/p/artist/al2/t3"], &[], true, )), ); let chunks = resolve_chunks(&provider, "/p/artist").await; // One chunk per track-bearing node; the trackless artist node adds // no empty chunk. assert_eq!( chunks, vec![ vec!["/p/artist/al1/t1".to_string(), "/p/artist/al1/t2".into()], vec!["/p/artist/al2/t3".to_string()], ] ); } #[tokio::test] async fn default_resolve_yields_one_chunk_for_track_paths() { let mut provider = FakeProvider::default(); provider.track_paths.push("/p/al/t9".into()); let chunks = resolve_chunks(&provider, "/p/al/t9").await; assert_eq!(chunks, vec![vec!["/p/al/t9".to_string()]]); assert!( provider.fetched().is_empty(), "a track path must not fetch nodes" ); } #[tokio::test] async fn default_resolve_skips_unreadable_nodes_and_unqueable_subtrees() { let mut provider = FakeProvider::default(); provider.nodes.insert( "/p/root".into(), Ok(FakeProvider::node( "/p/root", &["/p/root/t0"], &["/p/root/broken", "/p/root/private", "/p/root/ok"], true, )), ); provider .nodes .insert("/p/root/broken".into(), Err(ProviderError::FetchError)); provider.nodes.insert( "/p/root/private".into(), Ok(FakeProvider::node( "/p/root/private", &["/p/root/private/hidden"], &[], false, )), ); provider.nodes.insert( "/p/root/ok".into(), Ok(FakeProvider::node( "/p/root/ok", &["/p/root/ok/t1"], &[], true, )), ); let chunks = resolve_chunks(&provider, "/p/root").await; // The broken sibling is skipped, the non-queueable subtree // contributes nothing, the rest still resolves in order. assert_eq!( chunks, vec![ vec!["/p/root/t0".to_string()], vec!["/p/root/ok/t1".to_string()], ] ); } #[tokio::test] async fn default_resolve_stops_fetching_once_the_receiver_is_gone() { let mut provider = FakeProvider::default(); provider.nodes.insert( "/p/a".into(), Ok(FakeProvider::node( "/p/a", &["/p/a/t1"], &["/p/a/b", "/p/a/c"], true, )), ); provider.nodes.insert( "/p/a/b".into(), Ok(FakeProvider::node("/p/a/b", &["/p/a/b/t2"], &[], true)), ); provider.nodes.insert( "/p/a/c".into(), Ok(FakeProvider::node("/p/a/c", &["/p/a/c/t3"], &[], true)), ); let (chunk_tx, chunk_rx) = flume::bounded(32); drop(chunk_rx); // A dropped receiver is cancellation, not an error ... provider .resolve_tracks_into("/p/a", chunk_tx) .await .expect("cancellation must not be an error"); // ... and the walk stops fetching instead of draining the tree. assert_eq!(provider.fetched(), vec!["/p/a".to_string()]); } #[tokio::test] async fn default_resolve_errors_only_for_an_unresolvable_root() { let provider = FakeProvider::default(); let (chunk_tx, _chunk_rx) = flume::bounded::>(1); let result = provider.resolve_tracks_into("/p/unknown", chunk_tx).await; assert!(result.is_err(), "an unreadable root path is an error"); } #[test] fn child_new_defaults_all_capability_flags_off() { // Wire contract: plain children are immutable; providers opt into // capabilities explicitly via struct update. let child = LibraryNodeChild::new("/tidal/x".to_string(), "x".to_string(), true); assert!(!child.is_creatable); assert!(!child.is_editable); assert!(!child.is_deletable); } }