351 lines
11 KiB
Rust
351 lines
11 KiB
Rust
use audio_player::PlayerMessage;
|
||
use crabidy_core::proto::crabidy::{
|
||
crabidy_service_server::CrabidyServiceServer, InitResponse, LibraryNode, PlayState, Track,
|
||
};
|
||
use crabidy_core::{ProviderClient, ProviderError};
|
||
use tracing::{debug, error, info, instrument, warn, Span};
|
||
use tracing_subscriber::{prelude::*, EnvFilter};
|
||
|
||
mod playback;
|
||
use playback::Playback;
|
||
mod provider;
|
||
use provider::ProviderOrchestrator;
|
||
mod rpc;
|
||
use rpc::RpcService;
|
||
|
||
use tonic::{transport::Server, Result};
|
||
|
||
const LISTEN_ADDR: &str = "0.0.0.0:50051";
|
||
|
||
#[tokio::main]
|
||
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||
let _log_guard = init_tracing();
|
||
|
||
let (update_tx, _) = tokio::sync::broadcast::channel(2048);
|
||
let orchestrator = ProviderOrchestrator::init("").await.map_err(|err| {
|
||
error!("failed to init provider orchestrator: {err}");
|
||
err
|
||
})?;
|
||
|
||
// Queue persistence is optional: without a usable queues directory the
|
||
// server runs with an in-memory queue only.
|
||
let queue_store = match crabidy_server::queue_store::queues_dir() {
|
||
Some(dir) => match crabidy_server::queue_store::QueueStore::open(dir).await {
|
||
Ok(store) => Some(std::sync::Arc::new(store)),
|
||
Err(err) => {
|
||
warn!("queue persistence disabled: {err}");
|
||
None
|
||
}
|
||
},
|
||
None => {
|
||
warn!("queue persistence disabled: no config directory");
|
||
None
|
||
}
|
||
};
|
||
|
||
let playback = Playback::new(
|
||
update_tx.clone(),
|
||
orchestrator.provider_tx.clone(),
|
||
queue_store,
|
||
);
|
||
// Reload the persisted current queue before anything can observe or
|
||
// mutate state; never starts playback.
|
||
playback.restore_current().await;
|
||
|
||
let playback_tx = playback.playback_tx.clone();
|
||
let player_msg = playback.player.messages.clone();
|
||
|
||
std::thread::spawn(|| {
|
||
poll_play_bus(player_msg, playback_tx);
|
||
});
|
||
info!("player message forwarder started");
|
||
|
||
let crabidy_service = RpcService::new(
|
||
update_tx,
|
||
playback.playback_tx.clone(),
|
||
orchestrator.provider_tx.clone(),
|
||
);
|
||
orchestrator.run();
|
||
info!("provider orchestrator started");
|
||
playback.run();
|
||
info!("playback started");
|
||
|
||
let addr = LISTEN_ADDR.parse()?;
|
||
info!(%addr, "grpc server listening");
|
||
Server::builder()
|
||
.add_service(CrabidyServiceServer::new(crabidy_service))
|
||
.serve(addr)
|
||
.await?;
|
||
|
||
Ok(())
|
||
}
|
||
|
||
/// Installs the global tracing subscriber.
|
||
///
|
||
/// The filter honors `RUST_LOG`; without it, our own crates log at debug and
|
||
/// everything else at info. Returns the guard that flushes the non-blocking
|
||
/// writer on shutdown.
|
||
fn init_tracing() -> tracing_appender::non_blocking::WorkerGuard {
|
||
let (non_blocking, guard) = tracing_appender::non_blocking(std::io::stderr());
|
||
|
||
let env_filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| {
|
||
EnvFilter::new(
|
||
"info,crabidy_server=debug,crabidy_core=debug,tidaldy=debug,audio_player=debug",
|
||
)
|
||
});
|
||
|
||
let fmt_layer = tracing_subscriber::fmt::layer()
|
||
.with_writer(non_blocking)
|
||
.with_target(true)
|
||
.with_file(true)
|
||
.with_line_number(true);
|
||
|
||
// .init() also installs the log-to-tracing bridge for libraries that
|
||
// use the `log` crate (symphonia, cpal, ...).
|
||
tracing_subscriber::registry()
|
||
.with(env_filter)
|
||
.with(fmt_layer)
|
||
.init();
|
||
|
||
guard
|
||
}
|
||
|
||
/// Forwards player engine events into the playback message loop.
|
||
#[instrument(skip(rx, tx))]
|
||
fn poll_play_bus(rx: flume::Receiver<PlayerMessage>, tx: flume::Sender<PlaybackMessage>) {
|
||
for msg in rx.iter() {
|
||
let command = match msg {
|
||
PlayerMessage::EndOfStream => {
|
||
debug!("player reported end of stream");
|
||
PlaybackCommand::Next
|
||
}
|
||
PlayerMessage::Stopped => PlaybackCommand::StateChanged {
|
||
state: PlayState::Stopped,
|
||
},
|
||
PlayerMessage::Paused => PlaybackCommand::StateChanged {
|
||
state: PlayState::Paused,
|
||
},
|
||
PlayerMessage::Playing => PlaybackCommand::StateChanged {
|
||
state: PlayState::Playing,
|
||
},
|
||
PlayerMessage::Elapsed { duration, elapsed } => PlaybackCommand::PositionChanged {
|
||
duration: duration.as_millis() as u32,
|
||
position: elapsed.as_millis() as u32,
|
||
},
|
||
PlayerMessage::Duration { duration } => PlaybackCommand::PositionChanged {
|
||
duration: duration.as_millis() as u32,
|
||
position: 0,
|
||
},
|
||
};
|
||
if let Err(err) = tx.send(PlaybackMessage::new(command)) {
|
||
error!("failed to forward player message: {err}");
|
||
return;
|
||
}
|
||
}
|
||
warn!("player message channel closed");
|
||
}
|
||
|
||
/// A command for the provider orchestrator, tagged with the tracing span that
|
||
/// was current when it was sent so the handler can attribute its events to
|
||
/// the originating request.
|
||
#[derive(Debug)]
|
||
pub struct ProviderMessage {
|
||
pub span: Span,
|
||
pub command: ProviderCommand,
|
||
}
|
||
|
||
impl ProviderMessage {
|
||
pub fn new(command: ProviderCommand) -> Self {
|
||
Self {
|
||
span: Span::current(),
|
||
command,
|
||
}
|
||
}
|
||
}
|
||
|
||
#[derive(Debug)]
|
||
pub enum ProviderCommand {
|
||
GetLibraryNode {
|
||
path: String,
|
||
result_tx: flume::Sender<Result<LibraryNode, ProviderError>>,
|
||
},
|
||
GetTrackUrls {
|
||
path: String,
|
||
result_tx: flume::Sender<Result<Vec<String>, ProviderError>>,
|
||
},
|
||
/// Resolves a path into playable tracks: a track path yields that single
|
||
/// track, a node path yields all tracks reachable below it. Streamed:
|
||
/// zero or more in-order chunks arrive on `chunk_tx`, the sender is
|
||
/// dropped when resolution finishes, and dropping the receiver cancels
|
||
/// it (see `ProviderClient::resolve_tracks_into`). The orchestrator
|
||
/// handles this command on a spawned task so its loop stays free for
|
||
/// other commands (notably `GetTrackUrls` for the first chunk's track).
|
||
ResolveTracks {
|
||
path: String,
|
||
chunk_tx: flume::Sender<Vec<Track>>,
|
||
},
|
||
/// Creates a child under a creatable node (see
|
||
/// `ProviderClient::create_lib_node`); replies with the created node.
|
||
CreateLibraryNode {
|
||
parent_path: String,
|
||
title: String,
|
||
result_tx: flume::Sender<Result<LibraryNode, ProviderError>>,
|
||
},
|
||
/// Renames an editable node (see `ProviderClient::rename_lib_node`);
|
||
/// replies with the renamed node at its new path.
|
||
RenameLibraryNode {
|
||
path: String,
|
||
new_title: String,
|
||
result_tx: flume::Sender<Result<LibraryNode, ProviderError>>,
|
||
},
|
||
/// Deletes a deletable node (see `ProviderClient::delete_lib_node`);
|
||
/// replies with the refreshed parent node.
|
||
DeleteLibraryNode {
|
||
path: String,
|
||
result_tx: flume::Sender<Result<LibraryNode, ProviderError>>,
|
||
},
|
||
/// Captures the queueable subtree at `path` as the bookmark `name`
|
||
/// (see `architecture/bookmarks.md` D1–D3). Handled on a spawned task —
|
||
/// a large walk must not block the orchestrator loop.
|
||
CaptureLibraryNode {
|
||
path: String,
|
||
name: String,
|
||
result_tx: flume::Sender<Result<(), crabidy_server::bookmark_store::CaptureError>>,
|
||
},
|
||
}
|
||
|
||
impl ProviderCommand {
|
||
pub fn name(&self) -> &'static str {
|
||
match self {
|
||
Self::GetLibraryNode { .. } => "get_library_node",
|
||
Self::GetTrackUrls { .. } => "get_track_urls",
|
||
Self::ResolveTracks { .. } => "resolve_tracks",
|
||
Self::CreateLibraryNode { .. } => "create_library_node",
|
||
Self::RenameLibraryNode { .. } => "rename_library_node",
|
||
Self::DeleteLibraryNode { .. } => "delete_library_node",
|
||
Self::CaptureLibraryNode { .. } => "capture_library_node",
|
||
}
|
||
}
|
||
}
|
||
|
||
/// A command for the playback loop, tagged like [`ProviderMessage`].
|
||
#[derive(Debug)]
|
||
pub struct PlaybackMessage {
|
||
pub span: Span,
|
||
pub command: PlaybackCommand,
|
||
}
|
||
|
||
impl PlaybackMessage {
|
||
pub fn new(command: PlaybackCommand) -> Self {
|
||
Self {
|
||
span: Span::current(),
|
||
command,
|
||
}
|
||
}
|
||
}
|
||
|
||
#[derive(Debug)]
|
||
pub enum PlaybackCommand {
|
||
Init {
|
||
result_tx: flume::Sender<InitResponse>,
|
||
},
|
||
Replace {
|
||
paths: Vec<String>,
|
||
},
|
||
Queue {
|
||
paths: Vec<String>,
|
||
},
|
||
Append {
|
||
paths: Vec<String>,
|
||
},
|
||
Remove {
|
||
positions: Vec<u32>,
|
||
},
|
||
Insert {
|
||
position: u32,
|
||
paths: Vec<String>,
|
||
},
|
||
/// Internal: a resolved chunk of tracks for the pending queue operation
|
||
/// `op_id`, sent by that operation's forwarder task. Chunks for an
|
||
/// unknown (finished or cancelled) op are dropped silently.
|
||
ApplyResolvedChunk {
|
||
op_id: u64,
|
||
tracks: Vec<Track>,
|
||
},
|
||
/// Internal: the forwarder task for `op_id` has seen the provider drop
|
||
/// its chunk sender — the operation is complete and the `resolving`
|
||
/// flag clears once no pending operations remain.
|
||
ResolveFinished {
|
||
op_id: u64,
|
||
},
|
||
Clear {
|
||
exclude_current: bool,
|
||
},
|
||
SetCurrent {
|
||
position: u32,
|
||
},
|
||
/// Saves the current queue under a name (see
|
||
/// `architecture/queue-persistence.md` D6). Handled on the loop so the
|
||
/// snapshot is consistent; the disk write happens on a spawned task and
|
||
/// reports through `result_tx`.
|
||
SaveQueue {
|
||
name: String,
|
||
result_tx: flume::Sender<Result<(), crabidy_server::queue_store::SaveQueueError>>,
|
||
},
|
||
ToggleShuffle,
|
||
ToggleRepeat,
|
||
TogglePlay,
|
||
Stop,
|
||
ChangeVolume {
|
||
delta: f32,
|
||
},
|
||
ToggleMute,
|
||
Next,
|
||
Prev,
|
||
RestartTrack,
|
||
StateChanged {
|
||
state: PlayState,
|
||
},
|
||
VolumeChanged {
|
||
volume: f32,
|
||
},
|
||
MuteChanged {
|
||
muted: bool,
|
||
},
|
||
PositionChanged {
|
||
duration: u32,
|
||
position: u32,
|
||
},
|
||
}
|
||
|
||
impl PlaybackCommand {
|
||
pub fn name(&self) -> &'static str {
|
||
match self {
|
||
Self::Init { .. } => "init",
|
||
Self::Replace { .. } => "replace",
|
||
Self::Queue { .. } => "queue",
|
||
Self::Append { .. } => "append",
|
||
Self::Remove { .. } => "remove",
|
||
Self::Insert { .. } => "insert",
|
||
Self::ApplyResolvedChunk { .. } => "apply_resolved_chunk",
|
||
Self::ResolveFinished { .. } => "resolve_finished",
|
||
Self::Clear { .. } => "clear",
|
||
Self::SetCurrent { .. } => "set_current",
|
||
Self::SaveQueue { .. } => "save_queue",
|
||
Self::ToggleShuffle => "toggle_shuffle",
|
||
Self::ToggleRepeat => "toggle_repeat",
|
||
Self::TogglePlay => "toggle_play",
|
||
Self::Stop => "stop",
|
||
Self::ChangeVolume { .. } => "change_volume",
|
||
Self::ToggleMute => "toggle_mute",
|
||
Self::Next => "next",
|
||
Self::Prev => "prev",
|
||
Self::RestartTrack => "restart_track",
|
||
Self::StateChanged { .. } => "state_changed",
|
||
Self::VolumeChanged { .. } => "volume_changed",
|
||
Self::MuteChanged { .. } => "mute_changed",
|
||
Self::PositionChanged { .. } => "position_changed",
|
||
}
|
||
}
|
||
}
|