309 lines
12 KiB
Rust
309 lines
12 KiB
Rust
use crate::{ProviderCommand, ProviderMessage};
|
|
use async_trait::async_trait;
|
|
use crabidy_core::{
|
|
proto::crabidy::{LibraryNode, LibraryNodeChild, Track},
|
|
ProviderClient, ProviderError,
|
|
};
|
|
use std::{fs, path::PathBuf, sync::Arc};
|
|
use tracing::{debug, debug_span, error, instrument, warn, Instrument};
|
|
|
|
#[derive(Debug)]
|
|
pub struct ProviderOrchestrator {
|
|
pub provider_tx: flume::Sender<ProviderMessage>,
|
|
provider_rx: flume::Receiver<ProviderMessage>,
|
|
tidal_client: Arc<tidaldy::Client>,
|
|
/// `None` when the filesystem provider failed to initialize — the
|
|
/// server runs without `/fs` instead of dying (architecture D5).
|
|
fs_client: Option<Arc<fsdy::Client>>,
|
|
}
|
|
|
|
/// Whether a path belongs to the filesystem provider.
|
|
fn fs_owns(path: &str) -> bool {
|
|
path == fsdy::PROVIDER_ROOT || path.starts_with("/fs/")
|
|
}
|
|
|
|
impl ProviderOrchestrator {
|
|
/// The fs client, or `MalformedPath` (with a warning) when the
|
|
/// provider is disabled — a `/fs` path then has no owner.
|
|
fn fs_provider(&self) -> Result<&fsdy::Client, ProviderError> {
|
|
self.fs_client.as_deref().ok_or_else(|| {
|
|
warn!("filesystem provider is disabled");
|
|
ProviderError::MalformedPath
|
|
})
|
|
}
|
|
pub fn run(self) {
|
|
tokio::spawn(async move {
|
|
// Behind an Arc so long-running resolves can be spawned onto
|
|
// their own tasks while the loop keeps serving commands.
|
|
let this = Arc::new(self);
|
|
while let Ok(ProviderMessage { span, command }) = this.provider_rx.recv_async().await {
|
|
let handler_span =
|
|
debug_span!(parent: &span, "provider_command", command = command.name());
|
|
Arc::clone(&this)
|
|
.handle_command(command)
|
|
.instrument(handler_span)
|
|
.await;
|
|
}
|
|
warn!("provider message channel closed, loop exiting");
|
|
});
|
|
}
|
|
|
|
async fn handle_command(self: Arc<Self>, command: ProviderCommand) {
|
|
match command {
|
|
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::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::ResolveTracks { path, chunk_tx } => {
|
|
// Spawned: a large resolve must not block this loop, or the
|
|
// playback side deadlocks waiting for `GetTrackUrls` while
|
|
// chunks back up. Dropping `chunk_tx` at the end of the
|
|
// task is the completion signal; there is no reply channel.
|
|
let this = Arc::clone(&self);
|
|
tokio::spawn(
|
|
async move {
|
|
if let Err(err) = this.resolve_tracks_into(&path, chunk_tx).await {
|
|
warn!(path, "resolve produced no tracks: {err}");
|
|
}
|
|
}
|
|
.in_current_span(),
|
|
);
|
|
}
|
|
ProviderCommand::CreateLibraryNode {
|
|
parent_path,
|
|
title,
|
|
result_tx,
|
|
} => {
|
|
let result = self.create_lib_node(&parent_path, &title).await;
|
|
if let Err(err) = result_tx.send_async(result).await {
|
|
error!("failed to send create_library_node result: {err}");
|
|
}
|
|
}
|
|
ProviderCommand::RenameLibraryNode {
|
|
path,
|
|
new_title,
|
|
result_tx,
|
|
} => {
|
|
let result = self.rename_lib_node(&path, &new_title).await;
|
|
if let Err(err) = result_tx.send_async(result).await {
|
|
error!("failed to send rename_library_node result: {err}");
|
|
}
|
|
}
|
|
ProviderCommand::DeleteLibraryNode { path, result_tx } => {
|
|
let result = self.delete_lib_node(&path).await;
|
|
if let Err(err) = result_tx.send_async(result).await {
|
|
error!("failed to send delete_library_node result: {err}");
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
#[async_trait]
|
|
impl ProviderClient for ProviderOrchestrator {
|
|
#[instrument(skip(_s))]
|
|
async fn init(_s: &str) -> Result<Self, ProviderError> {
|
|
let config_dir = dirs::config_dir()
|
|
.map(|d| d.join("crabidy"))
|
|
.unwrap_or(PathBuf::from("/tmp"));
|
|
let dir_exists = tokio::fs::try_exists(&config_dir)
|
|
.await
|
|
.map_err(|e| ProviderError::Config(e.to_string()))?;
|
|
if !dir_exists {
|
|
tokio::fs::create_dir(&config_dir)
|
|
.await
|
|
.map_err(|e| ProviderError::Config(e.to_string()))?;
|
|
}
|
|
let config_file = config_dir.join("tidaly.toml");
|
|
debug!(config_file = %config_file.display(), "loading tidal config");
|
|
let raw_toml_settings = fs::read_to_string(&config_file).unwrap_or_default();
|
|
let tidal_client = Arc::new(tidaldy::Client::init(&raw_toml_settings).await.map_err(
|
|
|err| {
|
|
error!("failed to init tidal client: {err}");
|
|
err
|
|
},
|
|
)?);
|
|
let new_toml_config = tidal_client.settings();
|
|
if let Err(err) = tokio::fs::write(&config_file, new_toml_config).await {
|
|
error!("failed to write tidal config file: {err}");
|
|
};
|
|
// The filesystem provider is optional: a broken local config only
|
|
// costs the `/fs` subtree, never the server.
|
|
let fs_config_file = config_dir.join("fsdy.toml");
|
|
debug!(config_file = %fs_config_file.display(), "loading fs config");
|
|
let raw_fs_settings = fs::read_to_string(&fs_config_file).unwrap_or_default();
|
|
let fs_client = match fsdy::Client::init(&raw_fs_settings).await {
|
|
Ok(client) => {
|
|
if let Err(err) = tokio::fs::write(&fs_config_file, client.settings()).await {
|
|
error!("failed to write fsdy config file: {err}");
|
|
}
|
|
Some(Arc::new(client))
|
|
}
|
|
Err(err) => {
|
|
warn!("filesystem provider disabled: {err}");
|
|
None
|
|
}
|
|
};
|
|
let (provider_tx, provider_rx) = flume::bounded(100);
|
|
Ok(Self {
|
|
provider_rx,
|
|
provider_tx,
|
|
tidal_client,
|
|
fs_client,
|
|
})
|
|
}
|
|
|
|
fn settings(&self) -> String {
|
|
String::new()
|
|
}
|
|
|
|
/// 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);
|
|
}
|
|
if fs_owns(path) {
|
|
return self
|
|
.fs_client
|
|
.as_ref()
|
|
.is_some_and(|fs| fs.is_track_path(path));
|
|
}
|
|
false
|
|
}
|
|
|
|
#[instrument(skip(self))]
|
|
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;
|
|
}
|
|
if fs_owns(track_path) {
|
|
return self.fs_provider()?.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;
|
|
}
|
|
if fs_owns(track_path) {
|
|
return self.fs_provider()?.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(tidaldy::PROVIDER_ROOT.to_owned(), "tidal".to_owned(), false);
|
|
root_node.children.push(child);
|
|
if self.fs_client.is_some() {
|
|
let child =
|
|
LibraryNodeChild::new(fsdy::PROVIDER_ROOT.to_owned(), "fs".to_owned(), false);
|
|
root_node.children.push(child);
|
|
}
|
|
root_node
|
|
}
|
|
|
|
#[instrument(skip(self))]
|
|
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 path == tidaldy::PROVIDER_ROOT || path.starts_with("/tidal/") {
|
|
return self.tidal_client.get_lib_node(path).await;
|
|
}
|
|
if fs_owns(path) {
|
|
return self.fs_provider()?.get_lib_node(path).await;
|
|
}
|
|
warn!(path, "no provider owns this path");
|
|
Err(ProviderError::MalformedPath)
|
|
}
|
|
|
|
/// Routes to the provider that owns the parent path. The synthetic root
|
|
/// itself is not creatable.
|
|
#[instrument(skip(self))]
|
|
async fn create_lib_node(
|
|
&self,
|
|
parent_path: &str,
|
|
title: &str,
|
|
) -> Result<LibraryNode, ProviderError> {
|
|
if parent_path == tidaldy::PROVIDER_ROOT || parent_path.starts_with("/tidal/") {
|
|
return self.tidal_client.create_lib_node(parent_path, title).await;
|
|
}
|
|
if fs_owns(parent_path) {
|
|
return self
|
|
.fs_provider()?
|
|
.create_lib_node(parent_path, title)
|
|
.await;
|
|
}
|
|
warn!(parent_path, "no provider supports creating nodes here");
|
|
Err(ProviderError::NotSupported)
|
|
}
|
|
|
|
/// Routes to the provider that owns the path. The synthetic root's own
|
|
/// children are fixed and never editable.
|
|
#[instrument(skip(self))]
|
|
async fn rename_lib_node(
|
|
&self,
|
|
path: &str,
|
|
new_title: &str,
|
|
) -> Result<LibraryNode, ProviderError> {
|
|
if path == tidaldy::PROVIDER_ROOT || path.starts_with("/tidal/") {
|
|
return self.tidal_client.rename_lib_node(path, new_title).await;
|
|
}
|
|
if fs_owns(path) {
|
|
return self.fs_provider()?.rename_lib_node(path, new_title).await;
|
|
}
|
|
warn!(path, "no provider supports renaming this node");
|
|
Err(ProviderError::NotSupported)
|
|
}
|
|
|
|
/// Routes to the provider that owns the path. The synthetic root is not
|
|
/// queueable, so only provider-owned paths can resolve.
|
|
#[instrument(skip(self, chunk_tx))]
|
|
async fn resolve_tracks_into(
|
|
&self,
|
|
path: &str,
|
|
chunk_tx: flume::Sender<Vec<Track>>,
|
|
) -> Result<(), ProviderError> {
|
|
if path == tidaldy::PROVIDER_ROOT || path.starts_with("/tidal/") {
|
|
return self.tidal_client.resolve_tracks_into(path, chunk_tx).await;
|
|
}
|
|
if fs_owns(path) {
|
|
return self
|
|
.fs_provider()?
|
|
.resolve_tracks_into(path, chunk_tx)
|
|
.await;
|
|
}
|
|
warn!(path, "no provider owns this path");
|
|
Err(ProviderError::MalformedPath)
|
|
}
|
|
|
|
/// Routes to the provider that owns the path. The synthetic root's own
|
|
/// children are fixed and never deletable.
|
|
#[instrument(skip(self))]
|
|
async fn delete_lib_node(&self, path: &str) -> Result<LibraryNode, ProviderError> {
|
|
if path == tidaldy::PROVIDER_ROOT || path.starts_with("/tidal/") {
|
|
return self.tidal_client.delete_lib_node(path).await;
|
|
}
|
|
if fs_owns(path) {
|
|
return self.fs_provider()?.delete_lib_node(path).await;
|
|
}
|
|
warn!(path, "no provider supports deleting this node");
|
|
Err(ProviderError::NotSupported)
|
|
}
|
|
}
|