167 lines
4.9 KiB
Rust
167 lines
4.9 KiB
Rust
use std::{
|
|
fs::{create_dir_all, read_to_string, File},
|
|
io::Write,
|
|
path::Path,
|
|
};
|
|
|
|
use async_trait::async_trait;
|
|
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>;
|
|
}
|
|
|
|
#[derive(Clone, Debug, Hash)]
|
|
pub enum ProviderError {
|
|
Config(String),
|
|
UnknownUser,
|
|
CouldNotLogin,
|
|
FetchError,
|
|
MalformedPath,
|
|
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()
|
|
}
|
|
|
|
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,
|
|
}
|
|
}
|
|
}
|
|
|
|
impl LibraryNodeChild {
|
|
pub fn new(path: String, title: String, is_queable: bool) -> Self {
|
|
Self {
|
|
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,
|
|
}
|
|
|
|
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()
|
|
}
|