crabidy/crabidy-core/src/lib.rs

603 lines
22 KiB
Rust

#[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/<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;
/// 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, path: &str) -> Result<LibraryNode, ProviderError>;
/// 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<LibraryNode, ProviderError>;
/// 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<LibraryNode, ProviderError>;
/// 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<LibraryNode, ProviderError>;
/// 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<Vec<Track>>,
) -> 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<T>(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::<<T as ClapSerde>::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<String, Result<LibraryNode, ProviderError>>,
track_paths: Vec<String>,
fetched: std::sync::Mutex<Vec<String>>,
}
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<String> {
self.fetched.lock().map(|f| f.clone()).unwrap_or_default()
}
}
#[async_trait]
impl ProviderClient for FakeProvider {
async fn init(_: &str) -> Result<Self, ProviderError> {
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<Vec<String>, ProviderError> {
Err(ProviderError::NotSupported)
}
async fn get_metadata_for_track(&self, path: &str) -> Result<Track, ProviderError> {
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<LibraryNode, ProviderError> {
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<LibraryNode, ProviderError> {
Err(ProviderError::NotSupported)
}
async fn rename_lib_node(&self, _: &str, _: &str) -> Result<LibraryNode, ProviderError> {
Err(ProviderError::NotSupported)
}
async fn delete_lib_node(&self, _: &str) -> Result<LibraryNode, ProviderError> {
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<Vec<String>> {
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::<Vec<Track>>(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);
}
}