Compare commits

..

No commits in common. "21e1b10c0bc9255b5e97f33400f787b71657ce99" and "a1987869c28b6cf4d5a70a74104eb3bdf0174cc3" have entirely different histories.

47 changed files with 4256 additions and 5076 deletions

24
.gitignore vendored
View File

@ -1,25 +1 @@
target target
/target
# devenv
.devenv*
devenv.local.nix
devenv.local.yaml
# direnv
.direnv
# pre-commit / git-hooks
.pre-commit-config.yaml
.vim
AGENTS.md
CLAUDE.md
.mcp.json
.codex/
opencode.json
.claude/
.agents/
.opencode/
.explained/
*.kickstart-new

4114
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -1,66 +1,9 @@
[workspace] [workspace]
resolver = "2"
members = [ members = [
"audio-player", "audio-player",
"cbd-tui", "cbd-tui",
"crabidy-core", "crabidy-core",
"crabidy-server", "crabidy-server",
"stream-download",
"tidaldy", "tidaldy",
] ]
[workspace.package]
version = "0.1.0"
edition = "2021"
[workspace.dependencies]
anyhow = "1"
async-trait = "0.1"
base64 = "0.22"
chrono = { version = "0.4", default-features = false, features = ["clock"] }
clap = { version = "4", features = ["derive"] }
clap-serde-derive = "0.2"
crossterm = "0.29"
dirs = "6"
flume = "0.12"
futures = "0.3"
notify-rust = "4"
prost = "0.14"
rand = "0.10"
ratatui = "0.30"
reqwest = { version = "0.13", default-features = false, features = [
"json",
"query",
"rustls",
"webpki-roots",
"http2",
"hickory-dns",
"stream",
] }
rodio = { version = "0.22", default-features = false, features = [
"playback",
"symphonia-all",
] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
serde_urlencoded = "0.7"
stream-download = { version = "0.24", default-features = false, features = [
"reqwest",
"reqwest-rustls",
"temp-storage",
] }
thiserror = "2"
tokio = "1"
tokio-stream = "0.1"
toml = "1"
tonic = "0.14"
tonic-prost = "0.14"
tonic-prost-build = "0.14"
tracing = "0.1"
tracing-appender = "0.2"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
url = "2"
# Local crates
audio-player = { path = "audio-player" }
crabidy-core = { path = "crabidy-core" }
tidaldy = { path = "tidaldy" }

View File

@ -1 +0,0 @@
# crabidy

View File

@ -1,17 +1,19 @@
[package] [package]
name = "audio-player" name = "audio-player"
version.workspace = true version = "0.1.0"
edition.workspace = true edition = "2021"
[dependencies] [dependencies]
anyhow.workspace = true rodio = { version = "0.17.1", default-features = false, features = [
flume.workspace = true "symphonia-all",
rodio.workspace = true ] }
stream-download.workspace = true symphonia = { version = "0.5.3", features = ["all"] }
thiserror.workspace = true stream-download = { path = "../stream-download" }
tokio = { workspace = true, features = ["rt", "rt-multi-thread"] } anyhow = "1.0.71"
tracing.workspace = true url = "2.4.0"
url.workspace = true flume = "0.10.14"
thiserror = "1.0.40"
tracing = "0.1.37"
[dev-dependencies] [dev-dependencies]
tokio = { workspace = true, features = ["full"] } tokio = { version = "1", features = ["full"] }

View File

@ -1,3 +1,5 @@
use std::{thread, time::Duration};
use audio_player::{Player, PlayerMessage}; use audio_player::{Player, PlayerMessage};
#[tokio::main] #[tokio::main]
@ -11,10 +13,7 @@ async fn main() {
loop { loop {
match player.messages.recv_async().await { match player.messages.recv_async().await {
Ok(PlayerMessage::Elapsed { Ok(PlayerMessage::Elapsed { duration, elapsed }) => {
duration: _,
elapsed,
}) => {
println!("ELAPSED: {:?}", elapsed); println!("ELAPSED: {:?}", elapsed);
} }
Ok(PlayerMessage::EndOfStream) => { Ok(PlayerMessage::EndOfStream) => {
@ -31,10 +30,7 @@ async fn main() {
loop { loop {
match player.messages.recv_async().await { match player.messages.recv_async().await {
Ok(PlayerMessage::Elapsed { Ok(PlayerMessage::Elapsed { duration, elapsed }) => {
duration: _,
elapsed,
}) => {
println!("ELAPSED: {:?}", elapsed); println!("ELAPSED: {:?}", elapsed);
} }
Ok(PlayerMessage::EndOfStream) => { Ok(PlayerMessage::EndOfStream) => {

318
audio-player/src/decoder.rs Normal file
View File

@ -0,0 +1,318 @@
use std::error::Error;
use std::fmt;
use std::time::Duration;
use flume::Sender;
use rodio::Source;
use symphonia::{
core::{
audio::{AudioBufferRef, SampleBuffer, SignalSpec},
codecs::{Decoder, DecoderOptions},
errors::Error as SymphoniaError,
formats::{FormatOptions, FormatReader, SeekMode, SeekTo, Track},
io::MediaSourceStream,
meta::{MetadataOptions, MetadataRevision},
probe::Hint,
units::{Time, TimeBase},
},
default::get_probe,
};
use tracing::warn;
use crate::player_engine::PlayerEngineCommand;
// Decoder errors are not considered fatal.
// The correct action is to just get a new packet and try again.
// But a decode error in more than 3 consecutive packets is fatal.
const MAX_DECODE_ERRORS: usize = 3;
#[derive(Clone)]
pub struct MediaInfo {
pub duration: Option<Duration>,
pub metadata: Option<MetadataRevision>,
pub track: Track,
}
pub struct SymphoniaDecoder {
decoder: Box<dyn Decoder>,
current_frame_offset: usize,
format: Box<dyn FormatReader>,
buffer: SampleBuffer<i16>,
spec: SignalSpec,
time_base: Option<TimeBase>,
duration: u64,
elapsed: u64,
metadata: Option<MetadataRevision>,
track: Track,
tx: Sender<PlayerEngineCommand>,
}
impl SymphoniaDecoder {
pub fn new(
mss: MediaSourceStream,
hint: Hint,
tx: Sender<PlayerEngineCommand>,
) -> Result<Self, DecoderError> {
match SymphoniaDecoder::init(mss, hint, tx) {
Err(e) => match e {
SymphoniaError::IoError(e) => Err(DecoderError::IoError(e.to_string())),
SymphoniaError::DecodeError(e) => Err(DecoderError::DecodeError(e)),
SymphoniaError::SeekError(_) => {
unreachable!("Seek errors should not occur during initialization")
}
SymphoniaError::Unsupported(_) => Err(DecoderError::UnrecognizedFormat),
SymphoniaError::LimitError(e) => Err(DecoderError::LimitError(e)),
SymphoniaError::ResetRequired => Err(DecoderError::ResetRequired),
},
Ok(Some(decoder)) => Ok(decoder),
Ok(None) => Err(DecoderError::NoStreams),
}
}
fn init(
mss: MediaSourceStream,
hint: Hint,
tx: Sender<PlayerEngineCommand>,
) -> symphonia::core::errors::Result<Option<SymphoniaDecoder>> {
let format_opts: FormatOptions = FormatOptions {
enable_gapless: true,
..Default::default()
};
let metadata_opts: MetadataOptions = Default::default();
let mut probed = get_probe().format(&hint, mss, &format_opts, &metadata_opts)?;
let track = match probed.format.default_track() {
Some(stream) => stream,
None => return Ok(None),
}
.clone();
let time_base = track.codec_params.time_base;
let duration = track
.codec_params
.n_frames
.map(|frames| track.codec_params.start_ts + frames)
.unwrap_or_default();
let mut _elapsed = 0;
let mut decoder = symphonia::default::get_codecs()
.make(&track.codec_params, &DecoderOptions { verify: true })?;
let mut decode_errors: usize = 0;
let decoded = loop {
let current_frame = probed.format.next_packet()?;
_elapsed = current_frame.ts();
match decoder.decode(&current_frame) {
Ok(decoded) => break decoded,
Err(e) => match e {
SymphoniaError::DecodeError(_) => {
decode_errors += 1;
if decode_errors > MAX_DECODE_ERRORS {
return Err(e);
} else {
continue;
}
}
_ => return Err(e),
},
}
};
let spec = decoded.spec().to_owned();
let buffer = SymphoniaDecoder::get_buffer(decoded, &spec);
// Prefer metadata that's provided in the container format, over other tags found during the
// probe operation.
let metadata = probed.format.metadata().current().cloned().or_else(|| {
probed
.metadata
.get()
.as_ref()
.and_then(|m| m.current().cloned())
});
Ok(Some(SymphoniaDecoder {
decoder,
current_frame_offset: 0,
format: probed.format,
buffer,
spec,
time_base,
duration,
elapsed: _elapsed,
metadata,
track,
tx,
}))
}
#[inline]
pub fn media_info(&self) -> MediaInfo {
MediaInfo {
duration: self.total_duration(),
metadata: self.metadata.clone(),
track: self.track.clone(),
}
}
#[inline]
pub fn elapsed(&self) -> Duration {
if let Some(tb) = self.time_base {
let time = tb.calc_time(self.elapsed);
return Duration::from_secs_f64(time.seconds as f64 + time.frac);
};
Duration::default()
}
#[inline]
pub fn seek(&mut self, time: Duration) -> Option<Duration> {
let nanos_per_sec = 1_000_000_000.0;
match self.format.seek(
SeekMode::Coarse,
SeekTo::Time {
time: Time::new(
time.as_secs(),
f64::from(time.subsec_nanos()) / nanos_per_sec,
),
track_id: None,
},
) {
Ok(seeked_to) => {
let base = TimeBase::new(1, self.sample_rate());
let time = base.calc_time(seeked_to.actual_ts);
Some(Duration::from_millis(
time.seconds * 1000 + ((time.frac * 60. * 1000.).round() as u64),
))
}
Err(_) => None,
}
}
#[inline]
fn get_buffer(decoded: AudioBufferRef, spec: &SignalSpec) -> SampleBuffer<i16> {
let duration = decoded.capacity() as u64;
let mut buffer = SampleBuffer::<i16>::new(duration, *spec);
buffer.copy_interleaved_ref(decoded);
buffer
}
}
impl Source for SymphoniaDecoder {
#[inline]
fn current_frame_len(&self) -> Option<usize> {
Some(self.buffer.samples().len())
}
#[inline]
fn channels(&self) -> u16 {
self.spec.channels.count() as u16
}
#[inline]
fn sample_rate(&self) -> u32 {
self.spec.rate
}
#[inline]
fn total_duration(&self) -> Option<Duration> {
match self.time_base {
Some(tb) => {
let time = tb.calc_time(self.duration);
Some(Duration::from_secs_f64(time.seconds as f64 + time.frac))
}
None => None,
}
}
}
impl Iterator for SymphoniaDecoder {
type Item = i16;
#[inline]
fn next(&mut self) -> Option<i16> {
if self.current_frame_offset == self.buffer.len() {
let mut decode_errors: usize = 0;
let decoded = loop {
match self.format.next_packet() {
Ok(packet) => {
self.elapsed = packet.ts();
match self.decoder.decode(&packet) {
Ok(decoded) => break decoded,
Err(e) => match e {
SymphoniaError::DecodeError(_) => {
decode_errors += 1;
if decode_errors > MAX_DECODE_ERRORS {
return None;
} else {
continue;
}
}
_ => return None,
},
}
}
Err(SymphoniaError::IoError(err)) => {
if err.kind() == std::io::ErrorKind::UnexpectedEof
&& err.to_string() == "end of stream"
{
self.tx
.send(PlayerEngineCommand::Eos)
.unwrap_or_else(|e| warn!("Send error {}", e));
return None;
}
}
Err(_) => return None,
}
};
self.spec = decoded.spec().to_owned();
self.buffer = SymphoniaDecoder::get_buffer(decoded, &self.spec);
self.current_frame_offset = 0;
}
let sample = *self.buffer.samples().get(self.current_frame_offset)?;
self.current_frame_offset += 1;
Some(sample)
}
}
/// Error that can happen when creating a decoder.
#[derive(Debug, Clone)]
pub enum DecoderError {
/// The format of the data has not been recognized.
UnrecognizedFormat,
/// An IO error occurred while reading, writing, or seeking the stream.
IoError(String),
/// The stream contained malformed data and could not be decoded or demuxed.
DecodeError(&'static str),
/// A default or user-defined limit was reached while decoding or demuxing the stream. Limits
/// are used to prevent denial-of-service attacks from malicious streams.
LimitError(&'static str),
/// The demuxer or decoder needs to be reset before continuing.
ResetRequired,
/// No streams were found by the decoder
NoStreams,
}
impl fmt::Display for DecoderError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let text = match self {
DecoderError::UnrecognizedFormat => "Unrecognized format",
DecoderError::IoError(msg) => &msg[..],
DecoderError::DecodeError(msg) => msg,
DecoderError::LimitError(msg) => msg,
DecoderError::ResetRequired => "Reset required",
DecoderError::NoStreams => "No streams",
};
write!(f, "{}", text)
}
}
impl Error for DecoderError {}

View File

@ -1,5 +1,7 @@
mod decoder;
mod player; mod player;
mod player_engine; mod player_engine;
pub use decoder::MediaInfo;
pub use player::{Player, PlayerError}; pub use player::{Player, PlayerError};
pub use player_engine::{MediaInfo, PlayerMessage}; pub use player_engine::PlayerMessage;

View File

@ -3,9 +3,13 @@ use std::time::Duration;
use anyhow::Result; use anyhow::Result;
use flume::{Receiver, Sender}; use flume::{Receiver, Sender};
use tracing::error; use tracing::{error, warn};
use crate::player_engine::{MediaInfo, PlayerEngine, PlayerEngineCommand, PlayerMessage}; use crate::decoder::MediaInfo;
use crate::player_engine::{PlayerEngine, PlayerEngineCommand, PlayerMessage};
// TODO:
// * Emit buffering
pub enum PlayerError {} pub enum PlayerError {}
@ -16,24 +20,82 @@ pub struct Player {
impl Default for Player { impl Default for Player {
fn default() -> Self { fn default() -> Self {
let (tx_engine, rx_engine) = flume::bounded(16); let (tx_engine, rx_engine) = flume::bounded(10);
let (tx_player, messages): (Sender<PlayerMessage>, Receiver<PlayerMessage>) = let (tx_player, messages): (Sender<PlayerMessage>, Receiver<PlayerMessage>) =
flume::bounded(16); flume::bounded(10);
let tx_callbacks = tx_engine.clone(); let tx_decoder = tx_engine.clone();
// Capture the runtime handle here: the engine thread itself is not a
// tokio context but needs one to create http streams.
let runtime = tokio::runtime::Handle::try_current().ok();
thread::spawn(move || { thread::spawn(move || {
let engine = match PlayerEngine::init(tx_callbacks, tx_player, runtime) { let mut player = match PlayerEngine::init(tx_decoder, tx_player) {
Err(e) => { Err(e) => {
error!("Could not initialize player: {}", e); error!("Could not initialize player: {}", e);
return; return;
} }
Ok(engine) => engine, Ok(engine) => engine,
}; };
engine.run(rx_engine);
loop {
match rx_engine.recv() {
Ok(PlayerEngineCommand::Play(source_str, tx)) => {
tx.send(player.play(&source_str))
.unwrap_or_else(|e| warn!("Send error {}", e));
}
Ok(PlayerEngineCommand::Pause(tx)) => {
tx.send(player.pause())
.unwrap_or_else(|e| warn!("Send error {}", e));
}
Ok(PlayerEngineCommand::Unpause(tx)) => {
tx.send(player.unpause())
.unwrap_or_else(|e| warn!("Send error {}", e));
}
Ok(PlayerEngineCommand::Stop(tx)) => {
tx.send(player.stop())
.unwrap_or_else(|e| warn!("Send error {}", e));
}
Ok(PlayerEngineCommand::TogglePlay(tx)) => {
tx.send(player.toggle_play())
.unwrap_or_else(|e| warn!("Send error {}", e));
}
Ok(PlayerEngineCommand::Restart(tx)) => {
tx.send(player.restart())
.unwrap_or_else(|e| warn!("Send error {}", e));
}
Ok(PlayerEngineCommand::GetDuration(tx)) => {
tx.send(player.duration())
.unwrap_or_else(|e| warn!("Send error {}", e));
}
Ok(PlayerEngineCommand::GetElapsed(tx)) => {
tx.send(player.elapsed())
.unwrap_or_else(|e| warn!("Send error {}", e));
}
Ok(PlayerEngineCommand::SeekTo(time, tx)) => {
tx.send(player.seek_to(time))
.unwrap_or_else(|e| warn!("Send error {}", e));
}
Ok(PlayerEngineCommand::GetVolume(tx)) => {
tx.send(player.volume())
.unwrap_or_else(|e| warn!("Send error {}", e));
}
Ok(PlayerEngineCommand::GetPaused(tx)) => {
tx.send(player.is_paused())
.unwrap_or_else(|e| warn!("Send error {}", e));
}
Ok(PlayerEngineCommand::SetVolume(volume, tx)) => {
tx.send(player.set_volume(volume))
.unwrap_or_else(|e| warn!("Send error {}", e));
}
Ok(PlayerEngineCommand::SetElapsed(elapsed)) => {
player.handle_elapsed(elapsed);
}
Ok(PlayerEngineCommand::Eos) => {
player.handle_eos();
}
Err(e) => {
warn!("Recv error {}", e);
}
}
}
}); });
Self { Self {
@ -47,96 +109,74 @@ impl Player {
pub async fn play(&self, source_str: &str) -> Result<MediaInfo> { pub async fn play(&self, source_str: &str) -> Result<MediaInfo> {
let (tx, rx) = flume::bounded(1); let (tx, rx) = flume::bounded(1);
self.tx_engine self.tx_engine
.send_async(PlayerEngineCommand::Play(source_str.to_string(), tx)) .send(PlayerEngineCommand::Play(source_str.to_string(), tx))?;
.await?;
rx.recv_async().await? rx.recv_async().await?
} }
pub async fn restart(&self) -> Result<MediaInfo> { pub async fn restart(&self) -> Result<MediaInfo> {
let (tx, rx) = flume::bounded(1); let (tx, rx) = flume::bounded(1);
self.tx_engine self.tx_engine.send(PlayerEngineCommand::Restart(tx))?;
.send_async(PlayerEngineCommand::Restart(tx))
.await?;
rx.recv_async().await? rx.recv_async().await?
} }
pub async fn elapsed(&self) -> Result<Duration> { pub async fn elpased(&self) -> Result<Duration> {
let (tx, rx) = flume::bounded(1); let (tx, rx) = flume::bounded(1);
self.tx_engine self.tx_engine.send(PlayerEngineCommand::GetElapsed(tx))?;
.send_async(PlayerEngineCommand::GetElapsed(tx))
.await?;
rx.recv_async().await? rx.recv_async().await?
} }
pub async fn duration(&self) -> Result<Duration> { pub async fn duration(&self) -> Result<Duration> {
let (tx, rx) = flume::bounded(1); let (tx, rx) = flume::bounded(1);
self.tx_engine self.tx_engine.send(PlayerEngineCommand::GetDuration(tx))?;
.send_async(PlayerEngineCommand::GetDuration(tx))
.await?;
rx.recv_async().await? rx.recv_async().await?
} }
pub async fn seek_to(&self, time: Duration) -> Result<Duration> { pub async fn seek_to(&self, time: Duration) -> Result<Duration> {
let (tx, rx) = flume::bounded(1); let (tx, rx) = flume::bounded(1);
self.tx_engine self.tx_engine.send(PlayerEngineCommand::SeekTo(time, tx))?;
.send_async(PlayerEngineCommand::SeekTo(time, tx))
.await?;
rx.recv_async().await? rx.recv_async().await?
} }
pub async fn volume(&self) -> Result<f32> { pub async fn volume(&self) -> Result<f32> {
let (tx, rx) = flume::bounded(1); let (tx, rx) = flume::bounded(1);
self.tx_engine self.tx_engine.send(PlayerEngineCommand::GetVolume(tx))?;
.send_async(PlayerEngineCommand::GetVolume(tx))
.await?;
Ok(rx.recv_async().await?) Ok(rx.recv_async().await?)
} }
pub async fn is_paused(&self) -> Result<bool> { pub async fn is_paused(&self) -> Result<bool> {
let (tx, rx) = flume::bounded(1); let (tx, rx) = flume::bounded(1);
self.tx_engine self.tx_engine.send(PlayerEngineCommand::GetPaused(tx))?;
.send_async(PlayerEngineCommand::GetPaused(tx))
.await?;
rx.recv_async().await? rx.recv_async().await?
} }
pub async fn set_volume(&self, volume: f32) -> Result<f32> { pub async fn set_volume(&self, volume: f32) -> Result<f32> {
let (tx, rx) = flume::bounded(1); let (tx, rx) = flume::bounded(1);
self.tx_engine self.tx_engine
.send_async(PlayerEngineCommand::SetVolume(volume, tx)) .send(PlayerEngineCommand::SetVolume(volume, tx))?;
.await?;
Ok(rx.recv_async().await?) Ok(rx.recv_async().await?)
} }
pub async fn pause(&self) -> Result<()> { pub async fn pause(&self) -> Result<()> {
let (tx, rx) = flume::bounded(1); let (tx, rx) = flume::bounded(1);
self.tx_engine self.tx_engine.send(PlayerEngineCommand::Pause(tx))?;
.send_async(PlayerEngineCommand::Pause(tx))
.await?;
rx.recv_async().await? rx.recv_async().await?
} }
pub async fn unpause(&self) -> Result<()> { pub async fn unpause(&self) -> Result<()> {
let (tx, rx) = flume::bounded(1); let (tx, rx) = flume::bounded(1);
self.tx_engine self.tx_engine.send(PlayerEngineCommand::Unpause(tx))?;
.send_async(PlayerEngineCommand::Unpause(tx))
.await?;
rx.recv_async().await? rx.recv_async().await?
} }
pub async fn toggle_play(&self) -> Result<bool> { pub async fn toggle_play(&self) -> Result<bool> {
let (tx, rx) = flume::bounded(1); let (tx, rx) = flume::bounded(1);
self.tx_engine self.tx_engine.send(PlayerEngineCommand::TogglePlay(tx))?;
.send_async(PlayerEngineCommand::TogglePlay(tx))
.await?;
rx.recv_async().await? rx.recv_async().await?
} }
pub async fn stop(&self) -> Result<()> { pub async fn stop(&self) -> Result<()> {
let (tx, rx) = flume::bounded(1); let (tx, rx) = flume::bounded(1);
self.tx_engine self.tx_engine.send(PlayerEngineCommand::Stop(tx))?;
.send_async(PlayerEngineCommand::Stop(tx))
.await?;
rx.recv_async().await? rx.recv_async().await?
} }
} }

View File

@ -1,23 +1,19 @@
use std::fs::File; use flume::Sender;
use std::io::BufReader;
use std::path::Path; use std::path::Path;
use std::sync::atomic::AtomicU64;
use std::thread;
use std::time::Duration; use std::time::Duration;
use std::{fs::File, sync::atomic::Ordering};
use anyhow::{anyhow, Context, Result}; use symphonia::core::probe::Hint;
use flume::{Receiver, RecvTimeoutError, Sender}; use tracing::{debug, warn};
use rodio::source::EmptyCallback;
use rodio::stream::{DeviceSinkBuilder, MixerDeviceSink};
use rodio::{Decoder, Source};
use stream_download::storage::temp::TempStorageProvider;
use stream_download::{Settings, StreamDownload};
use thiserror::Error;
use tracing::{debug, info, instrument, trace, warn};
use url::Url; use url::Url;
/// How long we wait for the initial prefetch of a network stream. use crate::decoder::{MediaInfo, SymphoniaDecoder};
const STREAM_OPEN_TIMEOUT: Duration = Duration::from_secs(30); use anyhow::{anyhow, Result};
/// Interval between elapsed-position updates while playing. use rodio::{OutputStream, OutputStreamHandle, Sink, Source};
const TICK_INTERVAL: Duration = Duration::from_millis(250); use stream_download::StreamDownload;
use symphonia::core::io::{MediaSource, MediaSourceStream, MediaSourceStreamOptions};
use thiserror::Error;
pub enum PlayerEngineCommand { pub enum PlayerEngineCommand {
Play(String, Sender<Result<MediaInfo>>), Play(String, Sender<Result<MediaInfo>>),
@ -32,10 +28,8 @@ pub enum PlayerEngineCommand {
SeekTo(Duration, Sender<Result<Duration>>), SeekTo(Duration, Sender<Result<Duration>>),
GetVolume(Sender<f32>), GetVolume(Sender<f32>),
GetPaused(Sender<Result<bool>>), GetPaused(Sender<Result<bool>>),
/// End of stream for the source started by the given generation. Eos,
/// Stale generations are ignored so an old track finishing can never SetElapsed(Duration),
/// interfere with a newly started one.
Eos(u64),
} }
pub enum PlayerMessage { pub enum PlayerMessage {
@ -52,10 +46,8 @@ pub enum PlayerMessage {
EndOfStream, EndOfStream,
} }
#[derive(Clone, Debug)] // TODO:
pub struct MediaInfo { // * Emit buffering
pub duration: Option<Duration>,
}
#[derive(Debug, Error)] #[derive(Debug, Error)]
pub enum PlayerEngineError { pub enum PlayerEngineError {
@ -63,195 +55,85 @@ pub enum PlayerEngineError {
NotPlaying, NotPlaying,
} }
// Used for seeking in the stream
static SEEK_TO: AtomicU64 = AtomicU64::new(0);
pub struct PlayerEngine { pub struct PlayerEngine {
elapsed: Duration,
current_source: Option<String>, current_source: Option<String>,
media_info: Option<MediaInfo>, media_info: Option<MediaInfo>,
/// Monotonically increasing id for the currently playing source. Used to sink: Sink,
/// discard end-of-stream callbacks from sources that were replaced. // We need to keep the stream around as it will stop playing when it's dropped
generation: u64, _stream: OutputStream,
sink: rodio::Player, _handle: OutputStreamHandle,
// We need to keep the device sink around; audio stops when it's dropped.
_stream: MixerDeviceSink,
tx_engine: Sender<PlayerEngineCommand>, tx_engine: Sender<PlayerEngineCommand>,
tx_player: Sender<PlayerMessage>, tx_player: Sender<PlayerMessage>,
runtime: tokio::runtime::Handle,
// Present when the engine had to bring its own runtime because the
// creating thread was not inside a tokio context.
_owned_runtime: Option<tokio::runtime::Runtime>,
} }
impl PlayerEngine { impl PlayerEngine {
pub fn init( pub fn init(
tx_engine: Sender<PlayerEngineCommand>, tx_engine: Sender<PlayerEngineCommand>,
tx_player: Sender<PlayerMessage>, tx_player: Sender<PlayerMessage>,
runtime: Option<tokio::runtime::Handle>,
) -> Result<Self> { ) -> Result<Self> {
let stream = let (_stream, handle) = OutputStream::try_default()?;
DeviceSinkBuilder::open_default_sink().context("failed to open audio output device")?; let sink = Sink::try_new(&handle)?;
let sink = rodio::Player::connect_new(stream.mixer());
let (runtime, owned_runtime) = match runtime {
Some(handle) => (handle, None),
None => {
let rt = tokio::runtime::Builder::new_multi_thread()
.worker_threads(1)
.enable_all()
.build()
.context("failed to create tokio runtime for the player engine")?;
(rt.handle().clone(), Some(rt))
}
};
info!("audio output device opened");
Ok(Self { Ok(Self {
current_source: None, current_source: None,
media_info: None, media_info: None,
generation: 0, elapsed: Duration::default(),
sink, sink,
_stream: stream, _stream,
_handle: handle,
tx_engine, tx_engine,
tx_player, tx_player,
runtime,
_owned_runtime: owned_runtime,
}) })
} }
/// Drives the engine until all command senders are dropped.
pub fn run(mut self, rx_engine: Receiver<PlayerEngineCommand>) {
loop {
match rx_engine.recv_timeout(TICK_INTERVAL) {
Ok(command) => self.handle_command(command),
Err(RecvTimeoutError::Timeout) => self.tick(),
Err(RecvTimeoutError::Disconnected) => {
debug!("player engine channel closed, shutting down");
break;
}
}
}
}
fn handle_command(&mut self, command: PlayerEngineCommand) {
match command {
PlayerEngineCommand::Play(source_str, tx) => {
send_reply(tx, self.play(&source_str));
}
PlayerEngineCommand::Pause(tx) => send_reply(tx, self.pause()),
PlayerEngineCommand::Unpause(tx) => send_reply(tx, self.unpause()),
PlayerEngineCommand::Stop(tx) => send_reply(tx, self.stop()),
PlayerEngineCommand::TogglePlay(tx) => send_reply(tx, self.toggle_play()),
PlayerEngineCommand::Restart(tx) => send_reply(tx, self.restart()),
PlayerEngineCommand::GetDuration(tx) => send_reply(tx, self.duration()),
PlayerEngineCommand::GetElapsed(tx) => send_reply(tx, self.elapsed()),
PlayerEngineCommand::SeekTo(time, tx) => send_reply(tx, self.seek_to(time)),
PlayerEngineCommand::SetVolume(volume, tx) => {
send_reply(tx, self.set_volume(volume));
}
PlayerEngineCommand::GetVolume(tx) => send_reply(tx, self.volume()),
PlayerEngineCommand::GetPaused(tx) => send_reply(tx, self.is_paused()),
PlayerEngineCommand::Eos(generation) => self.handle_eos(generation),
}
}
/// Emits an elapsed-position update while a source is playing.
fn tick(&self) {
if self.sink.empty() || self.sink.is_paused() {
return;
}
let duration = self
.media_info
.as_ref()
.and_then(|m| m.duration)
.unwrap_or_default();
// Dropping a tick when the channel is full is harmless.
let _ = self.tx_player.try_send(PlayerMessage::Elapsed {
duration,
elapsed: self.sink.get_pos(),
});
}
#[instrument(skip(self))]
pub fn play(&mut self, source_str: &str) -> Result<MediaInfo> { pub fn play(&mut self, source_str: &str) -> Result<MediaInfo> {
let tx_player = self.tx_player.clone();
let tx_engine = self.tx_engine.clone();
self.reset(); self.reset();
let duration = self.start_source(source_str)?; let (source, hint) = self.get_source(source_str)?;
let media_info = MediaInfo { duration }; let mss = MediaSourceStream::new(source, MediaSourceStreamOptions::default());
let decoder = SymphoniaDecoder::new(mss, hint, self.tx_engine.clone())?;
self.media_info = Some(media_info.clone()); let media_info = decoder.media_info();
let media_info_copy = media_info.clone();
let duration = media_info.duration.unwrap_or_default();
self.media_info = Some(media_info);
self.current_source = Some(source_str.to_string()); self.current_source = Some(source_str.to_string());
self.notify(PlayerMessage::Duration { tx_player
duration: duration.unwrap_or_default(), .send(PlayerMessage::Duration { duration })
.unwrap_or_else(|e| warn!("Send error {}", e));
// FIXME: regularly update metadata revision
let decoder = decoder.periodic_access(Duration::from_millis(250), move |src| {
let seek = SEEK_TO.load(Ordering::SeqCst);
if seek > 0 {
src.seek(Duration::from_secs(seek));
SEEK_TO.store(0, Ordering::SeqCst);
}
let elapsed = src.elapsed();
tx_engine
.send(PlayerEngineCommand::SetElapsed(elapsed))
.unwrap_or_else(|e| warn!("Send error {}", e));
tx_player
.send(PlayerMessage::Elapsed { elapsed, duration })
.unwrap_or_else(|e| warn!("Send error {}", e));
}); });
self.sink.append(decoder);
self.sink.play(); self.sink.play();
self.notify(PlayerMessage::Playing);
debug!(duration = ?duration, "started playback");
Ok(media_info) self.tx_player
} .send(PlayerMessage::Playing)
.unwrap_or_else(|e| warn!("Send error {}", e));
/// Decodes the source and appends it (plus an end-of-stream callback) to Ok(media_info_copy)
/// the sink. Returns the total duration if known.
fn start_source(&mut self, source_str: &str) -> Result<Option<Duration>> {
self.generation += 1;
let duration = match Url::parse(source_str) {
Ok(url) if matches!(url.scheme(), "http" | "https") => {
trace!(%url, "opening network stream");
let reader = self.runtime.block_on(async {
tokio::time::timeout(
STREAM_OPEN_TIMEOUT,
StreamDownload::new_http(
url.clone(),
TempStorageProvider::new(),
Settings::default(),
),
)
.await
.map_err(|_| anyhow!("timed out opening stream after {STREAM_OPEN_TIMEOUT:?}"))?
.context("failed to open http stream")
})?;
let mut builder = Decoder::builder().with_data(reader).with_seekable(true);
if let Some(extension) = Path::new(url.path()).extension().and_then(|e| e.to_str())
{
builder = builder.with_hint(extension);
}
let decoder = builder.build().context("failed to decode http stream")?;
let duration = decoder.total_duration();
self.sink.append(decoder);
duration
}
Ok(url) => return Err(anyhow!("Not a valid URL scheme: {}", url.scheme())),
Err(_) => {
trace!(path = source_str, "opening local file");
let file = File::open(source_str)
.with_context(|| format!("failed to open file {source_str}"))?;
let byte_len = file.metadata().ok().map(|m| m.len());
let mut builder = Decoder::builder()
.with_data(BufReader::new(file))
.with_seekable(true);
if let Some(len) = byte_len {
builder = builder.with_byte_len(len);
}
if let Some(extension) = Path::new(source_str).extension().and_then(|e| e.to_str())
{
builder = builder.with_hint(extension);
}
let decoder = builder.build().context("failed to decode file")?;
let duration = decoder.total_duration();
self.sink.append(decoder);
duration
}
};
// Fires only when the decoder ahead of it finished naturally; a
// stop/replace clears the queue before this source is ever played.
let tx_engine = self.tx_engine.clone();
let generation = self.generation;
self.sink.append(EmptyCallback::new(Box::new(move || {
if let Err(err) = tx_engine.try_send(PlayerEngineCommand::Eos(generation)) {
warn!("failed to send end-of-stream signal: {err}");
}
})));
Ok(duration)
} }
pub fn restart(&mut self) -> Result<MediaInfo> { pub fn restart(&mut self) -> Result<MediaInfo> {
@ -266,7 +148,9 @@ impl PlayerEngine {
return Err(PlayerEngineError::NotPlaying.into()); return Err(PlayerEngineError::NotPlaying.into());
} }
self.sink.pause(); self.sink.pause();
self.notify(PlayerMessage::Paused); self.tx_player
.send(PlayerMessage::Paused)
.unwrap_or_else(|e| warn!("Send error {}", e));
Ok(()) Ok(())
} }
@ -275,7 +159,9 @@ impl PlayerEngine {
return Err(PlayerEngineError::NotPlaying.into()); return Err(PlayerEngineError::NotPlaying.into());
} }
self.sink.play(); self.sink.play();
self.notify(PlayerMessage::Playing); self.tx_player
.send(PlayerMessage::Playing)
.unwrap_or_else(|e| warn!("Send error {}", e));
Ok(()) Ok(())
} }
@ -285,11 +171,9 @@ impl PlayerEngine {
} }
if self.sink.is_paused() { if self.sink.is_paused() {
self.sink.play(); self.sink.play();
self.notify(PlayerMessage::Playing);
Ok(true) Ok(true)
} else { } else {
self.sink.pause(); self.sink.pause();
self.notify(PlayerMessage::Paused);
Ok(false) Ok(false)
} }
} }
@ -299,7 +183,9 @@ impl PlayerEngine {
return Err(PlayerEngineError::NotPlaying.into()); return Err(PlayerEngineError::NotPlaying.into());
} }
self.reset(); self.reset();
self.notify(PlayerMessage::Stopped); self.tx_player
.send(PlayerMessage::Stopped)
.unwrap_or_else(|e| warn!("Send error {}", e));
Ok(()) Ok(())
} }
@ -326,16 +212,18 @@ impl PlayerEngine {
if self.is_stopped() { if self.is_stopped() {
return Err(PlayerEngineError::NotPlaying.into()); return Err(PlayerEngineError::NotPlaying.into());
} }
Ok(self.sink.get_pos()) Ok(self.elapsed)
} }
pub fn seek_to(&self, time: Duration) -> Result<Duration> { pub fn seek_to(&self, time: Duration) -> Result<Duration> {
let duration = self.duration().unwrap_or_else(|_| self.sink.get_pos()); // We can seek between 1 second and the total duration of the track
let duration = self.duration().unwrap_or(self.elapsed);
let time = time.clamp(Duration::from_secs(1), duration); let time = time.clamp(Duration::from_secs(1), duration);
self.sink SEEK_TO.store(time.as_secs(), Ordering::SeqCst);
.try_seek(time) // FIXME: ideally we would like to return once the seeking is successful
.map_err(|err| anyhow!("seek failed: {err}"))?; // then return the current elapsed time
Ok(self.sink.get_pos()) // Cond-var might be needed to sleep this (seeking takes time)
Ok(time)
} }
pub fn volume(&self) -> f32 { pub fn volume(&self) -> f32 {
@ -347,36 +235,54 @@ impl PlayerEngine {
self.sink.volume() self.sink.volume()
} }
fn handle_eos(&mut self, generation: u64) { pub fn handle_eos(&mut self) {
if generation != self.generation {
debug!(
stale = generation,
current = self.generation,
"ignoring end-of-stream from replaced source"
);
return;
}
debug!("end of stream");
self.reset(); self.reset();
self.notify(PlayerMessage::EndOfStream); self.tx_player
.send(PlayerMessage::EndOfStream)
.unwrap_or_else(|e| warn!("Send error {}", e));
}
pub fn handle_elapsed(&mut self, elapsed: Duration) {
self.elapsed = elapsed;
} }
fn reset(&mut self) { fn reset(&mut self) {
self.elapsed = Duration::default();
self.current_source = None; self.current_source = None;
self.media_info = None; self.sink.pause();
self.generation += 1;
self.sink.stop(); self.sink.stop();
} }
fn notify(&self, message: PlayerMessage) { fn get_source(&self, source_str: &str) -> Result<(Box<dyn MediaSource>, Hint)> {
self.tx_player match Url::parse(source_str) {
.send(message) Ok(url) => {
.unwrap_or_else(|e| warn!("Send error {}", e)); if let "http" | "https" = url.scheme() {
} let reader = StreamDownload::new_http(source_str.parse().unwrap());
} let path = Path::new(url.path());
let hint = self.get_hint(path);
fn send_reply<T>(tx: Sender<T>, value: T) { Ok((Box::new(reader), hint))
if tx.send(value).is_err() { } else {
warn!("player engine reply receiver dropped"); Err(anyhow!("Not a valid URL scheme: {}", url.scheme()))
}
}
Err(_) => {
let path = Path::new(source_str);
let hint = self.get_hint(path);
Ok((Box::new(File::open(path)?), hint))
}
}
}
fn get_hint(&self, path: &Path) -> Hint {
// Create a hint to help the format registry guess what format reader is appropriate.
let mut hint = Hint::new();
// Provide the file extension as a hint.
if let Some(extension) = path.extension() {
if let Some(extension_str) = extension.to_str() {
hint.with_extension(extension_str);
}
}
hint
} }
} }

View File

@ -1,19 +1,17 @@
[package] [package]
name = "cbd-tui" name = "cbd-tui"
version.workspace = true version = "0.1.0"
edition.workspace = true edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies] [dependencies]
crabidy-core.workspace = true crossterm = "0.26.1"
crossterm.workspace = true crabidy-core = { path = "../crabidy-core" }
dirs.workspace = true flume = "0.10.14"
flume.workspace = true ratatui = "0.20.1"
notify-rust.workspace = true tokio = { version = "1", features = ["full"] }
ratatui.workspace = true tokio-stream = "0.1"
serde.workspace = true tonic = "0.9"
tokio = { workspace = true, features = ["full"] } notify-rust = "4.8.0"
tokio-stream.workspace = true serde = "1.0.164"
tonic.workspace = true
tracing.workspace = true
tracing-appender.workspace = true
tracing-subscriber.workspace = true

View File

@ -2,6 +2,7 @@ use std::collections::HashMap;
use flume::Sender; use flume::Sender;
use ratatui::{ use ratatui::{
backend::Backend,
layout::Rect, layout::Rect,
style::{Modifier, Style}, style::{Modifier, Style},
text::Span, text::Span,
@ -17,7 +18,7 @@ use super::{
pub struct Library { pub struct Library {
title: String, title: String,
path: String, uuid: String,
list: Vec<UiItem>, list: Vec<UiItem>,
list_state: ListState, list_state: ListState,
parent: Option<String>, parent: Option<String>,
@ -29,7 +30,7 @@ impl Library {
pub fn new(tx: Sender<MessageFromUi>) -> Self { pub fn new(tx: Sender<MessageFromUi>) -> Self {
Self { Self {
title: "Library".to_string(), title: "Library".to_string(),
path: crabidy_core::ROOT_PATH.to_string(), uuid: "node:/".to_string(),
list: Vec::new(), list: Vec::new(),
list_state: ListState::default(), list_state: ListState::default(),
positions: HashMap::new(), positions: HashMap::new(),
@ -43,27 +44,26 @@ impl Library {
self.list self.list
.iter() .iter()
.filter(|i| i.marked) .filter(|i| i.marked)
.map(|i| i.path.to_string()) .map(|i| i.uuid.to_string())
.collect(), .collect(),
); );
} }
if let Some(idx) = self.list_state.selected() { if let Some(idx) = self.list_state.selected() {
return Some(vec![self.list[idx].path.to_string()]); return Some(vec![self.list[idx].uuid.to_string()]);
} }
None None
} }
pub fn ascend(&mut self) { pub fn ascend(&mut self) {
if let Some(parent) = self.parent.as_ref() { if let Some(parent) = self.parent.as_ref() {
let _ = self.tx.send(MessageFromUi::GetLibraryNode(parent.clone())); self.tx.send(MessageFromUi::GetLibraryNode(parent.clone()));
} }
} }
pub fn dive(&mut self) { pub fn dive(&mut self) {
if let Some(idx) = self.list_state.selected() { if let Some(idx) = self.list_state.selected() {
let item = &self.list[idx]; let item = &self.list[idx];
if let UiItemKind::Node = item.kind { if let UiItemKind::Node = item.kind {
let _ = self self.tx
.tx .send(MessageFromUi::GetLibraryNode(item.uuid.clone()));
.send(MessageFromUi::GetLibraryNode(item.path.clone()));
} }
} }
} }
@ -100,11 +100,11 @@ impl Library {
} }
} }
pub fn prev_selected(&self) -> usize { pub fn prev_selected(&self) -> usize {
*self.positions.get(&self.path).unwrap_or(&0) *self.positions.get(&self.uuid).unwrap_or(&0)
} }
pub fn toggle_mark(&mut self) { pub fn toggle_mark(&mut self) {
if let Some(idx) = self.list_state.selected() { if let Some(idx) = self.list_state.selected() {
let item = &mut self.list[idx]; let mut item = &mut self.list[idx];
if !item.is_queable { if !item.is_queable {
return; return;
} }
@ -125,7 +125,7 @@ impl Library {
} }
// if children empty and tracks empty return // if children empty and tracks empty return
self.path = node.path; self.uuid = node.uuid;
self.title = node.title; self.title = node.title;
self.parent = node.parent; self.parent = node.parent;
self.select(Some(self.prev_selected())); self.select(Some(self.prev_selected()));
@ -135,7 +135,7 @@ impl Library {
.tracks .tracks
.iter() .iter()
.map(|t| UiItem { .map(|t| UiItem {
path: t.path.clone(), uuid: t.uuid.clone(),
title: format!("{} - {}", t.artist, t.title), title: format!("{} - {}", t.artist, t.title),
kind: UiItemKind::Track, kind: UiItemKind::Track,
marked: false, marked: false,
@ -148,7 +148,7 @@ impl Library {
.children .children
.iter() .iter()
.map(|c| UiItem { .map(|c| UiItem {
path: c.path.clone(), uuid: c.uuid.clone(),
title: c.title.clone(), title: c.title.clone(),
kind: UiItemKind::Node, kind: UiItemKind::Node,
marked: false, marked: false,
@ -160,7 +160,7 @@ impl Library {
self.update_selection(); self.update_selection();
} }
pub fn render(&mut self, f: &mut Frame, area: Rect, focused: bool) { pub fn render<B: Backend>(&mut self, f: &mut Frame<B>, area: Rect, focused: bool) {
let library_items: Vec<ListItem> = self let library_items: Vec<ListItem> = self
.list .list
.iter() .iter()
@ -177,7 +177,7 @@ impl Library {
} else { } else {
Style::default() Style::default()
}; };
ListItem::new(Span::from(text)).style(style) return ListItem::new(Span::from(text)).style(style);
}) })
.collect(); .collect();
@ -215,7 +215,7 @@ impl StatefulList for Library {
fn select(&mut self, idx: Option<usize>) { fn select(&mut self, idx: Option<usize>) {
if let Some(pos) = idx { if let Some(pos) = idx {
self.positions self.positions
.entry(self.path.clone()) .entry(self.uuid.clone())
.and_modify(|e| *e = pos) .and_modify(|e| *e = pos)
.or_insert(pos); .or_insert(pos);
} }

View File

@ -1,3 +1,5 @@
pub use ratatui::widgets::ListState;
// FIXME: Move marking stuff here, to be able to use it in queue as well // FIXME: Move marking stuff here, to be able to use it in queue as well
pub trait StatefulList { pub trait StatefulList {
fn get_size(&self) -> usize; fn get_size(&self) -> usize;
@ -70,6 +72,10 @@ pub trait StatefulList {
} }
} }
fn is_selected(&self) -> bool {
self.selected().is_some()
}
fn is_empty(&self) -> bool { fn is_empty(&self) -> bool {
self.get_size() == 0 self.get_size() == 0
} }

View File

@ -5,6 +5,7 @@ mod queue;
use flume::Sender; use flume::Sender;
use ratatui::{ use ratatui::{
backend::Backend,
layout::{Constraint, Direction, Layout}, layout::{Constraint, Direction, Layout},
style::Color, style::Color,
Frame, Frame,
@ -33,7 +34,7 @@ enum UiItemKind {
} }
struct UiItem { struct UiItem {
path: String, uuid: String,
title: String, title: String,
kind: UiItemKind, kind: UiItemKind,
marked: bool, marked: bool,
@ -104,8 +105,8 @@ impl App {
}; };
} }
pub fn render(&mut self, f: &mut Frame) { pub fn render<B: Backend>(&mut self, f: &mut Frame<B>) {
let _full_screen = f.area(); let full_screen = f.size();
let library_focused = matches!(self.focus, UiFocus::Library); let library_focused = matches!(self.focus, UiFocus::Library);
let queue_focused = matches!(self.focus, UiFocus::Queue); let queue_focused = matches!(self.focus, UiFocus::Queue);
@ -113,7 +114,7 @@ impl App {
let main = Layout::default() let main = Layout::default()
.direction(Direction::Horizontal) .direction(Direction::Horizontal)
.constraints([Constraint::Percentage(50), Constraint::Percentage(50)].as_ref()) .constraints([Constraint::Percentage(50), Constraint::Percentage(50)].as_ref())
.split(f.area()); .split(f.size());
self.library.render(f, main[0], library_focused); self.library.render(f, main[0], library_focused);

View File

@ -5,9 +5,10 @@ use notify_rust::Notification;
use crabidy_core::proto::crabidy::{PlayState, QueueModifiers, Track, TrackPosition}; use crabidy_core::proto::crabidy::{PlayState, QueueModifiers, Track, TrackPosition};
use ratatui::{ use ratatui::{
backend::Backend,
layout::{Alignment, Constraint, Direction, Layout, Rect}, layout::{Alignment, Constraint, Direction, Layout, Rect},
style::{Color, Modifier, Style}, style::{Color, Modifier, Style},
text::{Line, Span}, text::{Span, Spans},
widgets::{Block, BorderType, Borders, LineGauge, Paragraph, Wrap}, widgets::{Block, BorderType, Borders, LineGauge, Paragraph, Wrap},
Frame, Frame,
}; };
@ -56,22 +57,19 @@ impl NowPlaying {
} else { } else {
format!("{} by {}", track.title, track.artist,) format!("{} by {}", track.title, track.artist,)
}; };
// A missing notification daemon must not crash the TUI. Notification::new()
if let Err(err) = Notification::new()
.summary("Now playing") .summary("Now playing")
.body(&body) .body(&body)
.show() .show()
{ .unwrap();
tracing::debug!("could not show desktop notification: {err}");
}
} }
self.track = active; self.track = active;
} }
pub fn update_modifiers(&mut self, mods: &QueueModifiers) { pub fn update_modifiers(&mut self, mods: &QueueModifiers) {
self.modifiers = *mods; self.modifiers = mods.clone();
} }
pub fn render(&self, f: &mut Frame, area: Rect) { pub fn render<B: Backend>(&self, f: &mut Frame<B>, area: Rect) {
let now_playing_layout = Layout::default() let now_playing_layout = Layout::default()
.direction(Direction::Vertical) .direction(Direction::Vertical)
.constraints([Constraint::Max(8), Constraint::Max(1)]) .constraints([Constraint::Max(8), Constraint::Max(1)])
@ -93,9 +91,9 @@ impl NowPlaying {
self.modifiers.shuffle, self.modifiers.repeat self.modifiers.shuffle, self.modifiers.repeat
); );
vec![ vec![
Line::from(Span::raw(mods)), Spans::from(Span::raw(mods)),
Line::from(Span::raw(play_text)), Spans::from(Span::raw(play_text)),
Line::from(vec![ Spans::from(vec![
Span::styled( Span::styled(
track.title.to_string(), track.title.to_string(),
Style::default().add_modifier(Modifier::BOLD), Style::default().add_modifier(Modifier::BOLD),
@ -106,13 +104,13 @@ impl NowPlaying {
Style::default().add_modifier(Modifier::BOLD), Style::default().add_modifier(Modifier::BOLD),
), ),
]), ]),
Line::from(Span::raw(album_text)), Spans::from(Span::raw(album_text)),
] ]
} else { } else {
vec![ vec![
Line::from(Span::raw("")), Spans::from(Span::raw("")),
Line::from(Span::raw("")), Spans::from(Span::raw("")),
Line::from(Span::raw("No track playing")), Spans::from(Span::raw("No track playing")),
] ]
}; };
@ -129,7 +127,7 @@ impl NowPlaying {
f.render_widget(media_info_p, now_playing_layout[0]); f.render_widget(media_info_p, now_playing_layout[0]);
if let (Some(position), Some(duration), Some(_track)) = if let (Some(position), Some(duration), Some(track)) =
(self.position, self.duration, &self.track) (self.position, self.duration, &self.track)
{ {
let pos = position.as_secs(); let pos = position.as_secs();
@ -151,7 +149,7 @@ impl NowPlaying {
let progress = LineGauge::default() let progress = LineGauge::default()
.label("") .label("")
.block(Block::default().borders(Borders::NONE)) .block(Block::default().borders(Borders::NONE))
.filled_style(Style::default().fg(COLOR_SECONDARY).bg(Color::Black)) .gauge_style(Style::default().fg(COLOR_SECONDARY).bg(Color::Black))
.ratio(ratio); .ratio(ratio);
f.render_widget(progress, elapsed_layout[0]); f.render_widget(progress, elapsed_layout[0]);
@ -175,7 +173,7 @@ impl NowPlaying {
}; };
let time_text = Span::raw(completion_text); let time_text = Span::raw(completion_text);
let time_p = Paragraph::new(Line::from(time_text)); let time_p = Paragraph::new(Spans::from(time_text));
f.render_widget(time_p, elapsed_layout[1]); f.render_widget(time_p, elapsed_layout[1]);
} }
} }

View File

@ -1,5 +1,6 @@
use flume::Sender; use flume::Sender;
use ratatui::{ use ratatui::{
backend::Backend,
layout::Rect, layout::Rect,
style::{Modifier, Style}, style::{Modifier, Style},
text::Span, text::Span,
@ -30,14 +31,14 @@ impl Queue {
} }
} }
pub fn play_next(&self) { pub fn play_next(&self) {
let _ = self.tx.send(MessageFromUi::NextTrack); self.tx.send(MessageFromUi::NextTrack);
} }
pub fn play_prev(&self) { pub fn play_prev(&self) {
let _ = self.tx.send(MessageFromUi::PrevTrack); self.tx.send(MessageFromUi::PrevTrack);
} }
pub fn play_selected(&self) { pub fn play_selected(&self) {
if let Some(pos) = self.selected() { if let Some(pos) = self.selected() {
let _ = self.tx.send(MessageFromUi::SetCurrentTrack(pos)); self.tx.send(MessageFromUi::SetCurrentTrack(pos));
} }
} }
pub fn select_current(&mut self) { pub fn select_current(&mut self) {
@ -46,7 +47,7 @@ impl Queue {
pub fn remove_track(&mut self) { pub fn remove_track(&mut self) {
if let Some(pos) = self.selected() { if let Some(pos) = self.selected() {
// FIXME: mark multiple tracks on queue and remove them // FIXME: mark multiple tracks on queue and remove them
let _ = self.tx.send(MessageFromUi::RemoveTracks(vec![pos])); self.tx.send(MessageFromUi::RemoveTracks(vec![pos]));
} }
} }
pub fn update_position(&mut self, pos: usize) { pub fn update_position(&mut self, pos: usize) {
@ -57,8 +58,9 @@ impl Queue {
self.list = queue self.list = queue
.tracks .tracks
.iter() .iter()
.map(|t| UiItem { .enumerate()
path: t.path.clone(), .map(|(i, t)| UiItem {
uuid: t.uuid.clone(),
title: format!("{} - {}", t.artist, t.title), title: format!("{} - {}", t.artist, t.title),
kind: UiItemKind::Track, kind: UiItemKind::Track,
marked: false, marked: false,
@ -69,7 +71,7 @@ impl Queue {
self.update_selection(); self.update_selection();
} }
pub fn render(&mut self, f: &mut Frame, area: Rect, focused: bool) { pub fn render<B: Backend>(&mut self, f: &mut Frame<B>, area: Rect, focused: bool) {
let queue_items: Vec<ListItem> = self let queue_items: Vec<ListItem> = self
.list .list
.iter() .iter()

View File

@ -27,45 +27,11 @@ use tokio_stream::StreamExt;
use app::{App, MessageFromUi, MessageToUi, StatefulList, UiFocus}; use app::{App, MessageFromUi, MessageToUi, StatefulList, UiFocus};
use config::Config; use config::Config;
use rpc::RpcClient; use rpc::RpcClient;
use tracing::{error, info, warn};
static CONFIG: OnceLock<Config> = OnceLock::new(); static CONFIG: OnceLock<Config> = OnceLock::new();
/// Logs to a file: the terminal is owned by the TUI, so writing log lines to
/// stdout/stderr would corrupt the interface.
fn init_tracing() -> Option<tracing_appender::non_blocking::WorkerGuard> {
use tracing_subscriber::{prelude::*, EnvFilter};
let log_dir = dirs::state_dir()
.or_else(dirs::cache_dir)
.unwrap_or_else(std::env::temp_dir)
.join("crabidy");
if let Err(err) = std::fs::create_dir_all(&log_dir) {
eprintln!(
"could not create log directory {}: {err}",
log_dir.display()
);
return None;
}
let file_appender = tracing_appender::rolling::daily(&log_dir, "cbd-tui.log");
let (non_blocking, guard) = tracing_appender::non_blocking(file_appender);
let env_filter = EnvFilter::try_from_default_env()
.unwrap_or_else(|_| EnvFilter::new("info,cbd_tui=debug,crabidy_core=debug"));
tracing_subscriber::registry()
.with(env_filter)
.with(
tracing_subscriber::fmt::layer()
.with_writer(non_blocking)
.with_ansi(false)
.with_target(true),
)
.init();
Some(guard)
}
#[tokio::main] #[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> { async fn main() -> Result<(), Box<dyn std::error::Error>> {
let _log_guard = init_tracing();
let config = CONFIG.get_or_init(|| crabidy_core::init_config("cbd-tui.toml")); let config = CONFIG.get_or_init(|| crabidy_core::init_config("cbd-tui.toml"));
let (ui_tx, rx): (Sender<MessageFromUi>, Receiver<MessageFromUi>) = flume::unbounded(); let (ui_tx, rx): (Sender<MessageFromUi>, Receiver<MessageFromUi>) = flume::unbounded();
@ -82,24 +48,22 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
Ok(()) Ok(())
} }
async fn orchestrate( async fn orchestrate<'a>(
config: &'static Config, config: &'static Config,
(tx, rx): (Sender<MessageToUi>, Receiver<MessageFromUi>), (tx, rx): (Sender<MessageToUi>, Receiver<MessageFromUi>),
) -> Result<(), Box<dyn Error>> { ) -> Result<(), Box<dyn Error>> {
info!(address = config.server.address, "connecting to server");
let mut rpc_client = rpc::RpcClient::connect(&config.server.address).await?; let mut rpc_client = rpc::RpcClient::connect(&config.server.address).await?;
if let Some(root_node) = rpc_client.get_library_node(crabidy_core::ROOT_PATH).await? { if let Some(root_node) = rpc_client.get_library_node("node:/").await? {
tx.send(MessageToUi::ReplaceLibraryNode(root_node.clone()))?; tx.send(MessageToUi::ReplaceLibraryNode(root_node.clone()))?;
} }
let init_data = rpc_client.init().await?; let init_data = rpc_client.init().await?;
info!("received initial state from server");
tx.send_async(MessageToUi::Init(init_data)).await?; tx.send_async(MessageToUi::Init(init_data)).await?;
loop { loop {
if let Err(err) = poll(&mut rpc_client, &rx, &tx).await { if let Err(er) = poll(&mut rpc_client, &rx, &tx).await {
error!("request to server failed: {err}"); println!("ERROR");
} }
} }
} }
@ -112,9 +76,9 @@ async fn poll(
select! { select! {
Ok(msg) = &mut rx.recv_async() => { Ok(msg) = &mut rx.recv_async() => {
match msg { match msg {
MessageFromUi::GetLibraryNode(path) => { MessageFromUi::GetLibraryNode(uuid) => {
if let Some(node) = rpc_client.get_library_node(&path).await? { if let Some(node) = rpc_client.get_library_node(&uuid).await? {
let _ = tx.send(MessageToUi::ReplaceLibraryNode(node.clone())); tx.send(MessageToUi::ReplaceLibraryNode(node.clone()));
} }
}, },
MessageFromUi::AppendTracks(uuids) => { MessageFromUi::AppendTracks(uuids) => {
@ -171,10 +135,8 @@ async fn poll(
tx.send_async(MessageToUi::Update(update)).await?; tx.send_async(MessageToUi::Update(update)).await?;
} }
} }
Err(err) => { Err(_) => {
warn!("update stream broke, reconnecting: {err}");
rpc_client.reconnect_update_stream().await; rpc_client.reconnect_update_stream().await;
info!("update stream reconnected");
} }
} }
@ -211,7 +173,7 @@ fn run_ui(tx: Sender<MessageFromUi>, rx: Receiver<MessageToUi>) {
app.now_playing.update_track(track.track); app.now_playing.update_track(track.track);
app.queue.update_position(track.queue_position as usize); app.queue.update_position(track.queue_position as usize);
} }
if let Ok(ps) = PlayState::try_from(init_data.play_state) { if let Some(ps) = PlayState::from_i32(init_data.play_state) {
app.now_playing.update_play_state(ps); app.now_playing.update_play_state(ps);
} }
if let Some(mods) = init_data.mods { if let Some(mods) = init_data.mods {
@ -228,7 +190,7 @@ fn run_ui(tx: Sender<MessageFromUi>, rx: Receiver<MessageToUi>) {
} }
StreamUpdate::Position(pos) => app.now_playing.update_position(pos), StreamUpdate::Position(pos) => app.now_playing.update_position(pos),
StreamUpdate::PlayState(play_state) => { StreamUpdate::PlayState(play_state) => {
if let Ok(ps) = PlayState::try_from(play_state) { if let Some(ps) = PlayState::from_i32(play_state) {
app.now_playing.update_play_state(ps); app.now_playing.update_play_state(ps);
} }
} }
@ -241,10 +203,7 @@ fn run_ui(tx: Sender<MessageFromUi>, rx: Receiver<MessageToUi>) {
} }
} }
if let Err(err) = terminal.draw(|f| app.render(f)) { terminal.draw(|f| app.render(f));
error!("failed to draw frame: {err}");
break;
}
let timeout = tick_rate let timeout = tick_rate
.checked_sub(last_tick.elapsed()) .checked_sub(last_tick.elapsed())
@ -259,25 +218,25 @@ fn run_ui(tx: Sender<MessageFromUi>, rx: Receiver<MessageToUi>) {
} }
(_, KeyModifiers::NONE, KeyCode::Tab) => app.cycle_active(), (_, KeyModifiers::NONE, KeyCode::Tab) => app.cycle_active(),
(_, KeyModifiers::NONE, KeyCode::Char(' ')) => { (_, KeyModifiers::NONE, KeyCode::Char(' ')) => {
let _ = tx.send(MessageFromUi::TogglePlay); tx.send(MessageFromUi::TogglePlay);
} }
(_, KeyModifiers::NONE, KeyCode::Char('r')) => { (_, KeyModifiers::NONE, KeyCode::Char('r')) => {
let _ = tx.send(MessageFromUi::RestartTrack); tx.send(MessageFromUi::RestartTrack);
} }
(_, KeyModifiers::SHIFT, KeyCode::Char('J')) => { (_, KeyModifiers::SHIFT, KeyCode::Char('J')) => {
let _ = tx.send(MessageFromUi::ChangeVolume(-0.1)); tx.send(MessageFromUi::ChangeVolume(-0.1));
} }
(_, KeyModifiers::SHIFT, KeyCode::Char('K')) => { (_, KeyModifiers::SHIFT, KeyCode::Char('K')) => {
let _ = tx.send(MessageFromUi::ChangeVolume(0.1)); tx.send(MessageFromUi::ChangeVolume(0.1));
} }
(_, KeyModifiers::NONE, KeyCode::Char('m')) => { (_, KeyModifiers::NONE, KeyCode::Char('m')) => {
let _ = tx.send(MessageFromUi::ToggleMute); tx.send(MessageFromUi::ToggleMute);
} }
(_, KeyModifiers::NONE, KeyCode::Char('z')) => { (_, KeyModifiers::NONE, KeyCode::Char('z')) => {
let _ = tx.send(MessageFromUi::ToggleShuffle); tx.send(MessageFromUi::ToggleShuffle);
} }
(_, KeyModifiers::NONE, KeyCode::Char('x')) => { (_, KeyModifiers::NONE, KeyCode::Char('x')) => {
let _ = tx.send(MessageFromUi::ToggleRepeat); tx.send(MessageFromUi::ToggleRepeat);
} }
(_, KeyModifiers::CONTROL, KeyCode::Char('n')) => { (_, KeyModifiers::CONTROL, KeyCode::Char('n')) => {
app.queue.play_next(); app.queue.play_next();
@ -354,10 +313,10 @@ fn run_ui(tx: Sender<MessageFromUi>, rx: Receiver<MessageToUi>) {
app.queue.remove_track(); app.queue.remove_track();
} }
(UiFocus::Queue, KeyModifiers::NONE, KeyCode::Char('c')) => { (UiFocus::Queue, KeyModifiers::NONE, KeyCode::Char('c')) => {
let _ = tx.send(MessageFromUi::ClearQueue(true)); tx.send(MessageFromUi::ClearQueue(true));
} }
(UiFocus::Queue, KeyModifiers::SHIFT, KeyCode::Char('C')) => { (UiFocus::Queue, KeyModifiers::SHIFT, KeyCode::Char('C')) => {
let _ = tx.send(MessageFromUi::ClearQueue(false)); tx.send(MessageFromUi::ClearQueue(false));
} }
_ => {} _ => {}
} }

View File

@ -75,13 +75,13 @@ impl RpcClient {
pub async fn get_library_node( pub async fn get_library_node(
&mut self, &mut self,
path: &str, uuid: &str,
) -> Result<Option<&LibraryNode>, Box<dyn Error>> { ) -> Result<Option<&LibraryNode>, Box<dyn Error>> {
if self.library_node_cache.contains_key(path) { if self.library_node_cache.contains_key(uuid) {
return Ok(self.library_node_cache.get(path)); return Ok(self.library_node_cache.get(uuid));
} }
let get_library_node_request = Request::new(GetLibraryNodeRequest { let get_library_node_request = Request::new(GetLibraryNodeRequest {
path: path.to_string(), uuid: uuid.to_string(),
}); });
let response = self let response = self
.client .client
@ -89,31 +89,31 @@ impl RpcClient {
.await?; .await?;
if let Some(library_node) = response.into_inner().node { if let Some(library_node) = response.into_inner().node {
self.library_node_cache self.library_node_cache
.insert(path.to_string(), library_node); .insert(uuid.to_string(), library_node);
return Ok(self.library_node_cache.get(path)); return Ok(self.library_node_cache.get(uuid));
} }
Err(Box::new(RpcClientError::NotFound)) Err(Box::new(RpcClientError::NotFound))
} }
pub async fn append_tracks(&mut self, paths: Vec<String>) -> Result<(), Box<dyn Error>> { pub async fn append_tracks(&mut self, uuids: Vec<String>) -> Result<(), Box<dyn Error>> {
let append_request = Request::new(AppendRequest { paths }); let append_request = Request::new(AppendRequest { uuids });
self.client.append(append_request).await?; self.client.append(append_request).await?;
Ok(()) Ok(())
} }
pub async fn queue_tracks(&mut self, paths: Vec<String>) -> Result<(), Box<dyn Error>> { pub async fn queue_tracks(&mut self, uuids: Vec<String>) -> Result<(), Box<dyn Error>> {
let queue_request = Request::new(QueueRequest { paths }); let queue_request = Request::new(QueueRequest { uuids });
self.client.queue(queue_request).await?; self.client.queue(queue_request).await?;
Ok(()) Ok(())
} }
pub async fn insert_tracks( pub async fn insert_tracks(
&mut self, &mut self,
paths: Vec<String>, uuids: Vec<String>,
pos: usize, pos: usize,
) -> Result<(), Box<dyn Error>> { ) -> Result<(), Box<dyn Error>> {
let insert_request = Request::new(InsertRequest { let insert_request = Request::new(InsertRequest {
paths, uuids,
position: pos as u32, position: pos as u32,
}); });
self.client.insert(insert_request).await?; self.client.insert(insert_request).await?;
@ -134,8 +134,8 @@ impl RpcClient {
Ok(()) Ok(())
} }
pub async fn replace_queue(&mut self, paths: Vec<String>) -> Result<(), Box<dyn Error>> { pub async fn replace_queue(&mut self, uuids: Vec<String>) -> Result<(), Box<dyn Error>> {
let replace_request = Request::new(ReplaceRequest { paths }); let replace_request = Request::new(ReplaceRequest { uuids });
self.client.replace(replace_request).await?; self.client.replace(replace_request).await?;
Ok(()) Ok(())
} }

View File

@ -1,22 +1,21 @@
[package] [package]
name = "crabidy-core" name = "crabidy-core"
version.workspace = true version = "0.1.0"
edition.workspace = true edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies] [dependencies]
async-trait.workspace = true async-trait = "0.1.68"
clap-serde-derive.workspace = true clap = "4.3.3"
dirs.workspace = true clap-serde-derive = "0.2.0"
prost.workspace = true dirs = "5.0.1"
serde.workspace = true prost = "0.11"
toml.workspace = true serde = "1.0.163"
tonic.workspace = true toml = "0.7.4"
tonic-prost.workspace = true tonic = "0.9"
[build-dependencies] [build-dependencies]
tonic-prost-build.workspace = true async-trait = "0.1.68"
serde = "1.0.163"
# prost and tonic-prost are used by the code generated from the proto files, tonic-build = "0.9"
# which cargo-machete cannot see.
[package.metadata.cargo-machete]
ignored = ["prost", "tonic-prost"]

View File

@ -1,4 +1,4 @@
fn main() -> Result<(), Box<dyn std::error::Error>> { fn main() -> Result<(), Box<dyn std::error::Error>> {
tonic_prost_build::compile_protos("crabidy/v1/crabidy.proto")?; tonic_build::compile_protos("crabidy/v1/crabidy.proto")?;
Ok(()) Ok(())
} }

View File

@ -45,7 +45,7 @@ message InitResponse {
// Library // Library
message GetLibraryNodeRequest { message GetLibraryNodeRequest {
string path = 1; string uuid = 1;
} }
message GetLibraryNodeResponse { message GetLibraryNodeResponse {
LibraryNode node = 1; LibraryNode node = 1;
@ -53,17 +53,17 @@ message GetLibraryNodeResponse {
// Queue // Queue
message QueueRequest { message QueueRequest {
repeated string paths = 1; repeated string uuids = 1;
} }
message QueueResponse {} message QueueResponse {}
message ReplaceRequest { message ReplaceRequest {
repeated string paths = 1; repeated string uuids = 1;
} }
message ReplaceResponse {} message ReplaceResponse {}
message AppendRequest { message AppendRequest {
repeated string paths = 1; repeated string uuids = 1;
} }
message AppendResponse {} message AppendResponse {}
@ -74,7 +74,7 @@ message RemoveResponse {}
message InsertRequest { message InsertRequest {
uint32 position = 1; uint32 position = 1;
repeated string paths = 2; repeated string uuids = 2;
} }
message InsertResponse {} message InsertResponse {}
@ -139,7 +139,7 @@ message RestartTrackResponse {}
// Data types // Data types
message LibraryNodeChild { message LibraryNodeChild {
string path = 1; string uuid = 1;
string title = 2; string title = 2;
bool is_queable = 3; bool is_queable = 3;
} }
@ -181,8 +181,8 @@ message Album {
} }
message Track { message Track {
// Full library path including provider // Including provider
string path = 1; string uuid = 1;
string artist = 2; string artist = 2;
string title = 3; string title = 3;
optional uint32 duration = 4; optional uint32 duration = 4;
@ -190,8 +190,8 @@ message Track {
} }
message LibraryNode { message LibraryNode {
// Full library path including provider // Including provider
string path = 1; string uuid = 1;
string title = 2; string title = 2;
repeated LibraryNodeChild children = 3; repeated LibraryNodeChild children = 3;
optional string parent = 4; optional string parent = 4;

View File

@ -10,24 +10,16 @@ use proto::crabidy::{LibraryNode, LibraryNodeChild, Track};
pub mod proto; 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] #[async_trait]
pub trait ProviderClient: std::fmt::Debug + Send + Sync { pub trait ProviderClient: std::fmt::Debug + Send + Sync {
async fn init(raw_toml_settings: &str) -> Result<Self, ProviderError> async fn init(raw_toml_settings: &str) -> Result<Self, ProviderError>
where where
Self: Sized; Self: Sized;
fn settings(&self) -> String; fn settings(&self) -> String;
/// Whether the path addresses a single track (as opposed to a node). async fn get_urls_for_track(&self, track_uuid: &str) -> Result<Vec<String>, ProviderError>;
fn is_track_path(&self, path: &str) -> bool; async fn get_metadata_for_track(&self, track_uuid: &str) -> Result<Track, ProviderError>;
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; fn get_lib_root(&self) -> LibraryNode;
async fn get_lib_node(&self, path: &str) -> Result<LibraryNode, ProviderError>; async fn get_lib_node(&self, list_uuid: &str) -> Result<LibraryNode, ProviderError>;
} }
#[derive(Clone, Debug, Hash)] #[derive(Clone, Debug, Hash)]
@ -36,7 +28,7 @@ pub enum ProviderError {
UnknownUser, UnknownUser,
CouldNotLogin, CouldNotLogin,
FetchError, FetchError,
MalformedPath, MalformedUuid,
InternalError, InternalError,
Other, Other,
} }
@ -47,41 +39,10 @@ impl std::fmt::Display for ProviderError {
} }
} }
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 { impl LibraryNode {
pub fn new() -> Self { pub fn new() -> Self {
Self { Self {
path: ROOT_PATH.to_string(), uuid: "node:/".to_string(),
title: "/".to_string(), title: "/".to_string(),
children: Vec::new(), children: Vec::new(),
parent: None, parent: None,
@ -92,9 +53,9 @@ impl LibraryNode {
} }
impl LibraryNodeChild { impl LibraryNodeChild {
pub fn new(path: String, title: String, is_queable: bool) -> Self { pub fn new(uuid: String, title: String, is_queable: bool) -> Self {
Self { Self {
path, uuid,
title, title,
is_queable, is_queable,
} }
@ -133,34 +94,3 @@ where
} }
T::default().merge_clap() 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"]
);
}
}

View File

@ -1,25 +1,32 @@
[package] [package]
name = "crabidy-server" name = "crabidy-server"
version.workspace = true version = "0.1.0"
edition.workspace = true edition = "2021"
[[bin]] [[bin]]
name = "crabidy-server" name = "crabidy-server"
path = "src/main.rs" path = "src/main.rs"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies] [dependencies]
anyhow.workspace = true anyhow = "1.0.71"
async-trait.workspace = true tokio = { version = "1.28.0", features = ["full"] }
audio-player.workspace = true tidaldy = { path = "../tidaldy" }
crabidy-core.workspace = true crabidy-core = { path = "../crabidy-core" }
dirs.workspace = true audio-player = { path = "../audio-player" }
flume.workspace = true once_cell = "1.17.1"
futures.workspace = true serde_json = "1.0.96"
rand.workspace = true serde = "1.0.163"
tidaldy.workspace = true flume = "0.10.14"
tokio = { workspace = true, features = ["full"] } tonic = "0.9.2"
tokio-stream = { workspace = true, features = ["sync"] } async-trait = "0.1.68"
tonic.workspace = true futures = "0.3.28"
tracing.workspace = true tokio-stream = { version = "0.1.14", features = ["sync"] }
tracing-appender.workspace = true dirs = "5.0.1"
tracing-subscriber.workspace = true tracing = "0.1.37"
tracing-subscriber = "0.3.17"
tracing-appender = "0.2.2"
tracing-log = "0.1.3"
log = "0.4.18"
rand = "0.8.5"

View File

@ -1,5 +1,5 @@
use crabidy_core::proto::crabidy::{Queue, Track}; use crabidy_core::proto::crabidy::{Queue, Track};
use rand::{rng, seq::SliceRandom}; use rand::{seq::SliceRandom, thread_rng};
use std::time::SystemTime; use std::time::SystemTime;
use tracing::{debug, error}; use tracing::{debug, error};
@ -16,11 +16,10 @@ pub struct QueueManager {
impl From<QueueManager> for Queue { impl From<QueueManager> for Queue {
fn from(queue_manager: QueueManager) -> Self { fn from(queue_manager: QueueManager) -> Self {
Self { Self {
// A clock step backwards must not panic the playback loop.
timestamp: queue_manager timestamp: queue_manager
.created_at .created_at
.elapsed() .elapsed()
.unwrap_or_default() .expect("failed to get elapsed time")
.as_secs(), .as_secs(),
current_position: queue_manager.current_position() as u32, current_position: queue_manager.current_position() as u32,
tracks: queue_manager.tracks, tracks: queue_manager.tracks,
@ -28,12 +27,6 @@ impl From<QueueManager> for Queue {
} }
} }
impl Default for QueueManager {
fn default() -> Self {
Self::new()
}
}
impl QueueManager { impl QueueManager {
pub fn new() -> Self { pub fn new() -> Self {
Self { Self {
@ -54,7 +47,7 @@ impl QueueManager {
} }
pub fn is_last_track(&self) -> bool { pub fn is_last_track(&self) -> bool {
!self.tracks.is_empty() && self.current_position() == self.tracks.len() - 1 self.current_position() == self.tracks.len() - 1
} }
pub fn shuffle_on(&mut self) { pub fn shuffle_on(&mut self) {
@ -71,19 +64,15 @@ impl QueueManager {
} }
pub fn shuffle_all(&mut self) { pub fn shuffle_all(&mut self) {
self.play_order.shuffle(&mut rng()); self.play_order.shuffle(&mut thread_rng());
} }
pub fn shuffle_before(&mut self, pos: usize) { pub fn shuffle_before(&mut self, pos: usize) {
if let Some(slice) = self.play_order.get_mut(..pos) { self.play_order[..pos].shuffle(&mut thread_rng());
slice.shuffle(&mut rng());
}
} }
pub fn shuffle_behind(&mut self, pos: usize) { pub fn shuffle_behind(&mut self, pos: usize) {
if let Some(slice) = self.play_order.get_mut(pos + 1..) { self.play_order[pos + 1..].shuffle(&mut thread_rng());
slice.shuffle(&mut rng());
}
} }
pub fn current_track(&self) -> Option<Track> { pub fn current_track(&self) -> Option<Track> {
@ -124,7 +113,7 @@ impl QueueManager {
pub fn prev_track(&mut self) -> Option<Track> { pub fn prev_track(&mut self) -> Option<Track> {
if 0 < self.current_offset { if 0 < self.current_offset {
self.current_offset -= 1; self.current_offset -= 1;
self.current_track() Some(self.tracks[self.current_position()].clone())
} else { } else {
None None
} }
@ -139,11 +128,11 @@ impl QueueManager {
.play_order .play_order
.iter() .iter()
.position(|&i| i == current_position as usize) .position(|&i| i == current_position as usize)
else { else {
error!("invalid current position"); error!("invalid current position");
error!("queue: {:#?}", self); error!("queue: {:#?}", self);
return false; return false
}; };
if self.shuffle { if self.shuffle {
self.play_order.swap(0, current_offset); self.play_order.swap(0, current_offset);
self.current_offset = 0; self.current_offset = 0;
@ -188,37 +177,32 @@ impl QueueManager {
pub fn remove_tracks(&mut self, positions: &[u32]) -> Option<Track> { pub fn remove_tracks(&mut self, positions: &[u32]) -> Option<Track> {
let mut play_next = false; let mut play_next = false;
// Remove highest positions first so earlier removals don't shift the
// positions that are still to be removed.
let mut positions: Vec<usize> = positions.iter().map(|p| *p as usize).collect();
positions.sort_unstable_by(|a, b| b.cmp(a));
positions.dedup();
for pos in positions { for pos in positions {
if pos >= self.tracks.len() { if (self.tracks.len() as u32) < *pos {
debug!(pos, len = self.tracks.len(), "ignoring out-of-range remove");
continue;
}
if pos == self.current_position() {
play_next = true;
}
let Some(offset) = self.play_order.iter().position(|&i| i == pos) else {
error!(pos, "track position missing from play order, rebuilding");
self.rebuild_play_order();
return None; return None;
}; };
if *pos == self.current_position() as u32 {
play_next = true;
}
let Some(offset) = self
.play_order
.iter()
.position(|&i| i == *pos as usize)
else {
error!("invalid current position");
error!("queue: {:#?}", self);
return None
};
if offset < self.current_offset { if offset < self.current_offset {
self.current_offset -= 1; self.current_offset -= 1;
} }
self.tracks.remove(pos); self.tracks.remove(*pos as usize);
self.play_order.remove(offset); self.play_order.remove(offset);
self.play_order self.play_order
.iter_mut() .iter_mut()
.filter(|i| pos < **i) .filter(|i| (*pos as usize) < **i)
.for_each(|i| *i -= 1); .for_each(|i| *i -= 1);
} }
if self.current_offset >= self.play_order.len() {
self.current_offset = 0;
}
if play_next { if play_next {
self.current_track() self.current_track()
} else { } else {
@ -231,36 +215,38 @@ impl QueueManager {
if len == 0 { if len == 0 {
return self.replace_with_tracks(tracks); return self.replace_with_tracks(tracks);
} }
let inserted = tracks.len(); let order_additions: Vec<usize> = (len..len + tracks.len()).collect();
let position = (position as usize).min(len - 1);
let order_additions: Vec<usize> = (len..len + inserted).collect();
self.play_order.extend(order_additions); self.play_order.extend(order_additions);
let tail: Vec<Track> = self let tail: Vec<Track> = self
.tracks .tracks
.splice(position + 1.., tracks.to_vec()) .splice((position as usize + 1).., tracks.to_vec())
.collect(); .collect();
self.tracks.extend(tail); self.tracks.extend(tail);
let mut changed: Vec<usize> = Vec::new(); let mut changed: Vec<usize> = Vec::new();
// In shuffle mode we may already have played positions that are // in shuffle mode, it might be that we played already postions which are behind
// behind the insertion point; those shift by the number of inserted // the insertion point and which postions are shifted by the lenght of the inserted
// tracks. // track
for i in self for i in self
.play_order .play_order
.iter_mut() .iter_mut()
.take(self.current_offset) .take(self.current_offset)
.filter(|i| position < **i) .filter(|i| (position as usize) < **i)
{ {
*i += inserted; *i += len;
changed.push(*i); changed.push(*i);
} }
// The freshly appended order entries need to swap with the shifted if !self.shuffle {
// ones so every index stays unique. // if we don't shuffle, there should be no positions alredy played behind the
// current track
assert!(changed.is_empty());
}
// the newly inserted indices need to replaced with the ones that we already handled
self.play_order self.play_order
.iter_mut() .iter_mut()
.skip(self.current_offset) .skip(self.current_offset)
.for_each(|i| { .for_each(|i| {
if changed.contains(i) { if changed.contains(i) {
*i -= inserted; *i -= len;
} }
}); });
@ -279,126 +265,28 @@ impl QueueManager {
let current_track = self.current_track(); let current_track = self.current_track();
self.current_offset = 0; self.current_offset = 0;
self.tracks.clear(); self.tracks.clear();
self.play_order.clear();
if exclude_current { if exclude_current {
if let Some(track) = current_track { if let Some(track) = current_track {
self.tracks.push(track); self.tracks.push(track);
self.play_order.push(0);
} }
} }
!exclude_current !exclude_current
} }
/// Restores play_order to a consistent state after an inconsistency was
/// detected. Loses shuffle history but keeps the queue playable.
fn rebuild_play_order(&mut self) {
self.play_order = (0..self.tracks.len()).collect();
self.current_offset = 0;
}
} }
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
fn track(id: usize) -> Track {
Track {
path: format!("/tidal/playlists/p/{id}"),
artist: "artist".to_string(),
title: format!("track {id}"),
duration: None,
album: None,
}
}
fn queue_with(n: usize) -> QueueManager {
let mut q = QueueManager::new();
let tracks: Vec<Track> = (0..n).map(track).collect();
q.replace_with_tracks(&tracks);
q
}
#[test] #[test]
fn empty_queue_operations_do_not_panic() { fn random_delete_before() {}
let mut q = QueueManager::new();
assert!(!q.is_last_track());
assert!(q.current_track().is_none());
assert!(q.next_track().is_none());
assert!(q.prev_track().is_none());
assert!(q.remove_tracks(&[0]).is_none());
q.shuffle_on();
q.shuffle_off();
q.clear(true);
q.clear(false);
}
#[test] #[test]
fn remove_out_of_range_is_ignored() { fn random_delete_track() {}
let mut q = queue_with(2);
assert!(q.remove_tracks(&[5]).is_none());
assert_eq!(q.tracks.len(), 2);
// pos == len used to panic via Vec::remove
assert!(q.remove_tracks(&[2]).is_none());
assert_eq!(q.tracks.len(), 2);
}
#[test] #[test]
fn remove_multiple_positions() { fn random_delete_after() {}
let mut q = queue_with(4);
q.remove_tracks(&[1, 3]);
assert_eq!(q.tracks.len(), 2);
assert_eq!(q.play_order.len(), 2);
assert_eq!(q.current_track().unwrap().title, "track 0");
}
#[test] #[test]
fn remove_current_returns_successor() { fn random_select_track() {}
let mut q = queue_with(3);
let next = q.remove_tracks(&[0]);
assert_eq!(next.unwrap().title, "track 1");
assert_eq!(q.tracks.len(), 2);
}
#[test]
fn clear_keeps_play_order_consistent() {
let mut q = queue_with(3);
q.next_track();
q.clear(true);
assert_eq!(q.tracks.len(), 1);
assert_eq!(q.play_order.len(), 1);
assert!(q.current_track().is_some());
assert!(q.next_track().is_none());
}
#[test]
fn next_track_advances_and_repeats() {
let mut q = queue_with(2);
assert_eq!(q.next_track().unwrap().title, "track 1");
assert!(q.next_track().is_none());
q.repeat = true;
assert_eq!(q.next_track().unwrap().title, "track 0");
}
#[test]
fn insert_past_end_appends() {
let mut q = queue_with(2);
q.insert_tracks(99, &[track(2)]);
assert_eq!(q.tracks.len(), 3);
assert_eq!(q.play_order.len(), 3);
assert_eq!(q.tracks.last().unwrap().title, "track 2");
}
#[test]
fn shuffle_insert_keeps_order_unique() {
let mut q = queue_with(5);
q.shuffle_on();
q.next_track();
q.next_track();
q.queue_tracks(&[track(5), track(6)]);
let mut order = q.play_order.clone();
order.sort_unstable();
assert_eq!(order, (0..7).collect::<Vec<usize>>());
}
} }

View File

@ -3,8 +3,8 @@ use crabidy_core::proto::crabidy::{
crabidy_service_server::CrabidyServiceServer, InitResponse, LibraryNode, PlayState, Track, crabidy_service_server::CrabidyServiceServer, InitResponse, LibraryNode, PlayState, Track,
}; };
use crabidy_core::{ProviderClient, ProviderError}; use crabidy_core::{ProviderClient, ProviderError};
use tracing::{debug, error, info, instrument, warn, Span}; use tracing::{debug_span, error, info, instrument, level_filters, warn, Span};
use tracing_subscriber::{prelude::*, EnvFilter}; use tracing_subscriber::{filter::Targets, prelude::*};
mod playback; mod playback;
use playback::Playback; use playback::Playback;
@ -15,17 +15,34 @@ use rpc::RpcService;
use tonic::{transport::Server, Result}; use tonic::{transport::Server, Result};
const LISTEN_ADDR: &str = "0.0.0.0:50051";
#[tokio::main] #[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> { async fn main() -> Result<(), Box<dyn std::error::Error>> {
let _log_guard = init_tracing(); if let Err(err) = tracing_log::LogTracer::init_with_filter(log::LevelFilter::Debug) {
println!("Failed to initialize log tracer: {}", err);
}
let (non_blocking, _guard) = tracing_appender::non_blocking(std::io::stderr());
let targets_filter = Targets::new()
.with_target("crabidy_server", tracing::level_filters::LevelFilter::DEBUG)
.with_target("tidaldy", level_filters::LevelFilter::DEBUG);
let subscriber = tracing_subscriber::fmt::layer()
.with_writer(non_blocking)
.with_file(true)
.with_line_number(true);
let registry = tracing_subscriber::registry()
.with(targets_filter)
.with(subscriber);
tracing::subscriber::set_global_default(registry)
.expect("Setting the default tracing subscriber failed");
info!("audio player started initialized");
let (update_tx, _) = tokio::sync::broadcast::channel(2048); let (update_tx, _) = tokio::sync::broadcast::channel(2048);
let orchestrator = ProviderOrchestrator::init("").await.map_err(|err| { let orchestrator = ProviderOrchestrator::init("")
error!("failed to init provider orchestrator: {err}"); .await
err .expect("failed to init orchestrator");
})?;
let playback = Playback::new(update_tx.clone(), orchestrator.provider_tx.clone()); let playback = Playback::new(update_tx.clone(), orchestrator.provider_tx.clone());
@ -35,7 +52,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
std::thread::spawn(|| { std::thread::spawn(|| {
poll_play_bus(player_msg, playback_tx); poll_play_bus(player_msg, playback_tx);
}); });
info!("player message forwarder started"); info!("gstreamer bus handler started");
let crabidy_service = RpcService::new( let crabidy_service = RpcService::new(
update_tx, update_tx,
@ -47,8 +64,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
playback.run(); playback.run();
info!("playback started"); info!("playback started");
let addr = LISTEN_ADDR.parse()?; let addr = "0.0.0.0:50051".parse()?;
info!(%addr, "grpc server listening");
Server::builder() Server::builder()
.add_service(CrabidyServiceServer::new(crabidy_service)) .add_service(CrabidyServiceServer::new(crabidy_service))
.serve(addr) .serve(addr)
@ -57,210 +73,165 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
Ok(()) 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))] #[instrument(skip(rx, tx))]
fn poll_play_bus(rx: flume::Receiver<PlayerMessage>, tx: flume::Sender<PlaybackMessage>) { fn poll_play_bus(rx: flume::Receiver<PlayerMessage>, tx: flume::Sender<PlaybackMessage>) {
for msg in rx.iter() { for msg in rx.iter() {
let command = match msg { let span = debug_span!("play-chan");
match msg {
PlayerMessage::EndOfStream => { PlayerMessage::EndOfStream => {
debug!("player reported end of stream"); if let Err(err) = tx.send(PlaybackMessage::Next { span }) {
PlaybackCommand::Next error!("failed to send next message: {}", err);
}
}
PlayerMessage::Stopped => {
if let Err(err) = tx.send(PlaybackMessage::StateChanged {
state: PlayState::Stopped,
span,
}) {
error!("failed to send stopped message: {}", err);
}
}
PlayerMessage::Paused => {
if let Err(err) = tx.send(PlaybackMessage::StateChanged {
state: PlayState::Paused,
span,
}) {
error!("failed to send paused message: {}", err);
}
}
PlayerMessage::Playing => {
if let Err(err) = tx.send(PlaybackMessage::StateChanged {
state: PlayState::Playing,
span,
}) {
error!("failed to send playing message: {}", err);
}
}
PlayerMessage::Elapsed { duration, elapsed } => {
if let Err(err) = tx.send(PlaybackMessage::PostitionChanged {
duration: duration.as_millis() as u32,
position: elapsed.as_millis() as u32,
span,
}) {
error!("failed to send elapsed message: {}", err);
}
}
PlayerMessage::Duration { duration } => {
if let Err(err) = tx.send(PlaybackMessage::PostitionChanged {
duration: duration.as_millis() as u32,
position: 0,
span,
}) {
error!("failed to send duration message: {}", err);
}
} }
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)] #[derive(Debug)]
pub enum ProviderCommand { pub enum ProviderMessage {
GetLibraryNode { GetLibraryNode {
path: String, uuid: String,
result_tx: flume::Sender<Result<LibraryNode, ProviderError>>, result_tx: flume::Sender<Result<LibraryNode, ProviderError>>,
span: Span,
},
GetTrack {
uuid: String,
result_tx: flume::Sender<Result<Track, ProviderError>>,
span: Span,
}, },
GetTrackUrls { GetTrackUrls {
path: String, uuid: String,
result_tx: flume::Sender<Result<Vec<String>, ProviderError>>, result_tx: flume::Sender<Result<Vec<String>, ProviderError>>,
span: Span,
}, },
/// Resolves a path into playable tracks: a track path yields that single FlattenNode {
/// track, a node path yields all tracks reachable below it. uuid: String,
ResolveTracks {
path: String,
result_tx: flume::Sender<Vec<Track>>, result_tx: flume::Sender<Vec<Track>>,
span: Span,
}, },
} }
impl ProviderCommand {
pub fn name(&self) -> &'static str {
match self {
Self::GetLibraryNode { .. } => "get_library_node",
Self::GetTrackUrls { .. } => "get_track_urls",
Self::ResolveTracks { .. } => "resolve_tracks",
}
}
}
/// A command for the playback loop, tagged like [`ProviderMessage`].
#[derive(Debug)] #[derive(Debug)]
pub struct PlaybackMessage { pub enum 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 { Init {
result_tx: flume::Sender<InitResponse>, result_tx: flume::Sender<InitResponse>,
span: Span,
}, },
Replace { Replace {
paths: Vec<String>, uuids: Vec<String>,
span: Span,
}, },
Queue { Queue {
paths: Vec<String>, uuids: Vec<String>,
span: Span,
}, },
Append { Append {
paths: Vec<String>, uuids: Vec<String>,
span: Span,
}, },
Remove { Remove {
positions: Vec<u32>, positions: Vec<u32>,
span: Span,
}, },
Insert { Insert {
position: u32, position: u32,
paths: Vec<String>, uuids: Vec<String>,
span: Span,
}, },
Clear { Clear {
exclude_current: bool, exclude_current: bool,
span: Span,
}, },
SetCurrent { SetCurrent {
position: u32, position: u32,
span: Span,
},
ToggleShuffle {
span: Span,
},
ToggleRepeat {
span: Span,
},
TogglePlay {
span: Span,
},
Stop {
span: Span,
}, },
ToggleShuffle,
ToggleRepeat,
TogglePlay,
Stop,
ChangeVolume { ChangeVolume {
delta: f32, delta: f32,
span: Span,
},
ToggleMute {
span: Span,
},
Next {
span: Span,
},
Prev {
span: Span,
},
RestartTrack {
span: Span,
}, },
ToggleMute,
Next,
Prev,
RestartTrack,
StateChanged { StateChanged {
state: PlayState, state: PlayState,
span: Span,
}, },
VolumeChanged { VolumeChanged {
volume: f32, volume: f32,
span: Span,
}, },
MuteChanged { MuteChanged {
muted: bool, muted: bool,
span: Span,
}, },
PositionChanged { PostitionChanged {
duration: u32, duration: u32,
position: u32, position: u32,
span: Span,
}, },
} }
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::Clear { .. } => "clear",
Self::SetCurrent { .. } => "set_current",
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",
}
}
}

File diff suppressed because it is too large Load Diff

View File

@ -1,84 +1,88 @@
use crate::{ProviderCommand, ProviderMessage}; use crate::ProviderMessage;
use async_trait::async_trait; use async_trait::async_trait;
use crabidy_core::{ use crabidy_core::{
proto::crabidy::{LibraryNode, LibraryNodeChild, Track}, proto::crabidy::{LibraryNode, LibraryNodeChild, Track},
ProviderClient, ProviderError, ProviderClient, ProviderError,
}; };
use std::{fs, path::PathBuf, sync::Arc}; use std::{fs, path::PathBuf, sync::Arc};
use tracing::{debug, debug_span, error, instrument, warn, Instrument}; use tracing::{debug, error, instrument, warn, Instrument};
#[derive(Debug)] #[derive(Debug)]
pub struct ProviderOrchestrator { pub struct ProviderOrchestrator {
pub provider_tx: flume::Sender<ProviderMessage>, pub provider_tx: flume::Sender<ProviderMessage>,
provider_rx: flume::Receiver<ProviderMessage>, provider_rx: flume::Receiver<ProviderMessage>,
// known_tracks: RwLock<HashMap<String, Track>>,
// known_nodes: RwLock<HashMap<String, LibraryNode>>,
tidal_client: Arc<tidaldy::Client>, tidal_client: Arc<tidaldy::Client>,
} }
impl ProviderOrchestrator { impl ProviderOrchestrator {
pub fn run(self) { pub fn run(self) {
tokio::spawn(async move { tokio::spawn(async move {
while let Ok(ProviderMessage { span, command }) = self.provider_rx.recv_async().await { while let Ok(msg) = self.provider_rx.recv_async().await {
let handler_span = match msg {
debug_span!(parent: &span, "provider_command", command = command.name()); ProviderMessage::GetLibraryNode {
self.handle_command(command).instrument(handler_span).await; uuid,
result_tx,
span,
} => {
let _e = span.enter();
let result = self.get_lib_node(&uuid).in_current_span().await;
if let Err(err) = result_tx.send_async(result).in_current_span().await {
error!("failed to send result: {}", err);
}
}
ProviderMessage::GetTrack {
uuid,
result_tx,
span,
} => {
let _e = span.enter();
let result = self.get_metadata_for_track(&uuid).in_current_span().await;
if let Err(err) = result_tx.send_async(result).in_current_span().await {
error!("failed to send result: {}", err);
}
}
ProviderMessage::GetTrackUrls {
uuid,
result_tx,
span,
} => {
let _e = span.enter();
let result = self.get_urls_for_track(&uuid).in_current_span().await;
if let Err(err) = result_tx.send_async(result).in_current_span().await {
error!("failed to send result: {}", err);
}
}
ProviderMessage::FlattenNode {
uuid,
result_tx,
span,
} => {
let _e = span.enter();
let result = self.flatten_node(&uuid).in_current_span().await;
if let Err(err) = result_tx.send_async(result).in_current_span().await {
error!("failed to send result: {}", err);
}
}
}
} }
warn!("provider message channel closed, loop exiting");
}); });
} }
async fn handle_command(&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, result_tx } => {
let result = self.resolve_tracks(&path).await;
if let Err(err) = result_tx.send_async(result).await {
error!("failed to send resolve_tracks result: {err}");
}
}
}
}
/// Resolves a path into playable tracks. A track path resolves to that
/// single track; a node path is flattened by walking its queueable
/// descendants.
#[instrument(skip(self))] #[instrument(skip(self))]
async fn resolve_tracks(&self, path: &str) -> Vec<Track> { async fn flatten_node(&self, node_uuid: &str) -> Vec<Track> {
if self.is_track_path(path) { let mut tracks = Vec::with_capacity(1000);
return match self.get_metadata_for_track(path).await { let mut nodes_to_go = Vec::with_capacity(100);
Ok(track) => vec![track], nodes_to_go.push(node_uuid.to_string());
Err(err) => { while let Some(node_uuid) = nodes_to_go.pop() {
warn!(path, "failed to resolve track: {err}"); let Ok(node) = self.get_lib_node(&node_uuid).in_current_span().await else {
Vec::new() continue
} };
};
}
let mut tracks = Vec::new();
let mut nodes_to_go = vec![path.to_string()];
while let Some(node_path) = nodes_to_go.pop() {
let node = match self.get_lib_node(&node_path).await {
Ok(node) => node,
Err(err) => {
warn!(node = node_path, "skipping unreadable node: {err}");
continue;
}
};
if node.is_queable { if node.is_queable {
tracks.extend(node.tracks); tracks.extend(node.tracks);
nodes_to_go.extend(node.children.into_iter().map(|c| c.path)) nodes_to_go.extend(node.children.into_iter().map(|c| c.uuid))
} }
} }
debug!(count = tracks.len(), "resolved path into tracks");
tracks tracks
} }
} }
@ -91,25 +95,29 @@ impl ProviderClient for ProviderOrchestrator {
.map(|d| d.join("crabidy")) .map(|d| d.join("crabidy"))
.unwrap_or(PathBuf::from("/tmp")); .unwrap_or(PathBuf::from("/tmp"));
let dir_exists = tokio::fs::try_exists(&config_dir) let dir_exists = tokio::fs::try_exists(&config_dir)
.in_current_span()
.await .await
.map_err(|e| ProviderError::Config(e.to_string()))?; .map_err(|e| ProviderError::Config(e.to_string()))?;
if !dir_exists { if !dir_exists {
tokio::fs::create_dir(&config_dir) tokio::fs::create_dir(&config_dir)
.in_current_span()
.await .await
.map_err(|e| ProviderError::Config(e.to_string()))?; .map_err(|e| ProviderError::Config(e.to_string()))?;
} }
let config_file = config_dir.join("tidaly.toml"); 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("".to_owned());
let raw_toml_settings = fs::read_to_string(&config_file).unwrap_or_default(); let tidal_client = Arc::new(
let tidal_client = Arc::new(tidaldy::Client::init(&raw_toml_settings).await.map_err( tidaldy::Client::init(&raw_toml_settings)
|err| { .in_current_span()
error!("failed to init tidal client: {err}"); .await
err .expect("Failed to init Tidal clienta"),
}, );
)?);
let new_toml_config = tidal_client.settings(); let new_toml_config = tidal_client.settings();
if let Err(err) = tokio::fs::write(&config_file, new_toml_config).await { if let Err(err) = tokio::fs::write(&config_file, new_toml_config)
error!("failed to write tidal config file: {err}"); .in_current_span()
.await
{
error!("Failed to write config file: {}", err);
}; };
let (provider_tx, provider_rx) = flume::bounded(100); let (provider_tx, provider_rx) = flume::bounded(100);
Ok(Self { Ok(Self {
@ -118,55 +126,46 @@ impl ProviderClient for ProviderOrchestrator {
tidal_client, tidal_client,
}) })
} }
#[instrument(skip(self))]
fn settings(&self) -> String { fn settings(&self) -> String {
String::new() "".to_owned()
} }
/// 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);
}
false
}
#[instrument(skip(self))] #[instrument(skip(self))]
async fn get_urls_for_track(&self, track_path: &str) -> Result<Vec<String>, ProviderError> { async fn get_urls_for_track(&self, track_uuid: &str) -> Result<Vec<String>, ProviderError> {
if track_path.starts_with("/tidal/") { debug!("get_urls_for_track");
return self.tidal_client.get_urls_for_track(track_path).await; self.tidal_client
} .get_urls_for_track(track_uuid)
warn!(path = track_path, "no provider owns this track path"); .in_current_span()
Err(ProviderError::MalformedPath) .await
} }
#[instrument(skip(self))] #[instrument(skip(self))]
async fn get_metadata_for_track(&self, track_path: &str) -> Result<Track, ProviderError> { async fn get_metadata_for_track(&self, track_uuid: &str) -> Result<Track, ProviderError> {
if track_path.starts_with("/tidal/") { debug!("get_metadata_for_track");
return self.tidal_client.get_metadata_for_track(track_path).await; self.tidal_client
} .get_metadata_for_track(track_uuid)
warn!(path = track_path, "no provider owns this track path"); .in_current_span()
Err(ProviderError::MalformedPath) .await
} }
#[instrument(skip(self))]
fn get_lib_root(&self) -> LibraryNode { fn get_lib_root(&self) -> LibraryNode {
debug!("get_lib_root in provider manager");
let mut root_node = LibraryNode::new(); let mut root_node = LibraryNode::new();
let child = let child = LibraryNodeChild::new("node:tidal".to_owned(), "tidal".to_owned(), false);
LibraryNodeChild::new(tidaldy::PROVIDER_ROOT.to_owned(), "tidal".to_owned(), false);
root_node.children.push(child); root_node.children.push(child);
root_node root_node
} }
#[instrument(skip(self))] #[instrument(skip(self))]
async fn get_lib_node(&self, path: &str) -> Result<LibraryNode, ProviderError> { async fn get_lib_node(&self, uuid: &str) -> Result<LibraryNode, ProviderError> {
if path == crabidy_core::ROOT_PATH { debug!("get_lib_node in provider manager");
debug!("serving global library root"); if uuid == "node:/" {
debug!("get global root");
return Ok(self.get_lib_root()); return Ok(self.get_lib_root());
} }
if path == tidaldy::PROVIDER_ROOT || path.starts_with("/tidal/") { if uuid == "node:tidal" {
return self.tidal_client.get_lib_node(path).await; debug!("get tidal root");
return Ok(self.tidal_client.get_lib_root());
} }
warn!(path, "no provider owns this path"); debug!("tidal node");
Err(ProviderError::MalformedPath) self.tidal_client.get_lib_node(uuid).in_current_span().await
} }
} }

View File

@ -1,4 +1,4 @@
use crate::{PlaybackCommand, PlaybackMessage, ProviderCommand, ProviderMessage}; use crate::{PlaybackMessage, ProviderMessage};
use crabidy_core::proto::crabidy::{ use crabidy_core::proto::crabidy::{
crabidy_service_server::CrabidyService, get_update_stream_response::Update as StreamUpdate, crabidy_service_server::CrabidyService, get_update_stream_response::Update as StreamUpdate,
AppendRequest, AppendResponse, ChangeVolumeRequest, ChangeVolumeResponse, ClearQueueRequest, AppendRequest, AppendResponse, ChangeVolumeRequest, ChangeVolumeResponse, ClearQueueRequest,
@ -10,10 +10,11 @@ use crabidy_core::proto::crabidy::{
StopResponse, ToggleMuteRequest, ToggleMuteResponse, TogglePlayRequest, TogglePlayResponse, StopResponse, ToggleMuteRequest, ToggleMuteResponse, TogglePlayRequest, TogglePlayResponse,
ToggleRepeatRequest, ToggleRepeatResponse, ToggleShuffleRequest, ToggleShuffleResponse, ToggleRepeatRequest, ToggleRepeatResponse, ToggleShuffleRequest, ToggleShuffleResponse,
}; };
use futures::TryStreamExt;
use std::pin::Pin; use std::pin::Pin;
use tokio_stream::StreamExt; use tokio_stream::StreamExt;
use tonic::{Request, Response, Status}; use tonic::{Request, Response, Status};
use tracing::{debug, error, instrument, trace}; use tracing::{debug, debug_span, error, instrument, trace, Instrument, Span};
#[derive(Debug)] #[derive(Debug)]
pub struct RpcService { pub struct RpcService {
@ -24,29 +25,16 @@ pub struct RpcService {
impl RpcService { impl RpcService {
pub fn new( pub fn new(
update_tx: tokio::sync::broadcast::Sender<StreamUpdate>, update_rx: tokio::sync::broadcast::Sender<StreamUpdate>,
playback_tx: flume::Sender<PlaybackMessage>, playback_tx: flume::Sender<PlaybackMessage>,
provider_tx: flume::Sender<ProviderMessage>, provider_tx: flume::Sender<ProviderMessage>,
) -> Self { ) -> Self {
Self { Self {
update_tx, update_tx: update_rx,
playback_tx, playback_tx,
provider_tx, provider_tx,
} }
} }
/// Sends a command to the playback loop, mapping channel failure to an
/// internal error status.
async fn send_playback(&self, command: PlaybackCommand) -> Result<(), Status> {
let name = command.name();
self.playback_tx
.send_async(PlaybackMessage::new(command))
.await
.map_err(|err| {
error!(command = name, "playback channel closed: {err}");
Status::internal("playback loop unavailable")
})
}
} }
#[tonic::async_trait] #[tonic::async_trait]
@ -56,183 +44,267 @@ impl CrabidyService for RpcService {
#[instrument(skip(self, _request))] #[instrument(skip(self, _request))]
async fn init(&self, _request: Request<InitRequest>) -> Result<Response<InitResponse>, Status> { async fn init(&self, _request: Request<InitRequest>) -> Result<Response<InitResponse>, Status> {
debug!("received init request"); debug!("Received init request");
let playback_tx = self.playback_tx.clone();
let (result_tx, result_rx) = flume::bounded(1); let (result_tx, result_rx) = flume::bounded(1);
self.send_playback(PlaybackCommand::Init { result_tx }) let span = debug_span!("play-chan");
.await?; if let Err(err) = playback_tx
let response = result_rx.recv_async().await.map_err(|err| { .send_async(PlaybackMessage::Init { result_tx, span })
error!("no reply from playback loop: {err}"); .in_current_span()
Status::internal("playback loop did not reply") .await
})?; {
error!("{:?}", err);
return Err(Status::internal("Sending Init via internal channel failed"));
}
let response = result_rx
.recv_async()
.in_current_span()
.await
.map_err(|e| {
error!("{:?}", e);
Status::internal("Failed to receive response from provider channel")
})?;
Ok(Response::new(response)) Ok(Response::new(response))
} }
#[instrument(skip(self, request), fields(path))] #[instrument(skip(self, request), fields(uuid))]
async fn get_library_node( async fn get_library_node(
&self, &self,
request: Request<GetLibraryNodeRequest>, request: Request<GetLibraryNodeRequest>,
) -> Result<Response<GetLibraryNodeResponse>, Status> { ) -> Result<Response<GetLibraryNodeResponse>, Status> {
let path = request.into_inner().path; let uuid = request.into_inner().uuid;
tracing::Span::current().record("path", path.as_str()); Span::current().record("uuid", &uuid);
debug!("received get_library_node request"); debug!("Received get_library_node request");
let provider_tx = self.provider_tx.clone();
let (result_tx, result_rx) = flume::bounded(1); let (result_tx, result_rx) = flume::bounded(1);
self.provider_tx let span = debug_span!("prov-chan");
.send_async(ProviderMessage::new(ProviderCommand::GetLibraryNode { provider_tx
path, .send_async(ProviderMessage::GetLibraryNode {
uuid,
result_tx, result_tx,
})) span,
})
.in_current_span()
.await .await
.map_err(|err| { .map_err(|_| Status::internal("Failed to send request via channel"))?;
error!("provider channel closed: {err}"); let result = result_rx
Status::internal("provider unavailable") .recv_async()
.in_current_span()
.await
.map_err(|e| {
error!("{:?}", e);
Status::internal("Failed to receive response from provider channel")
})?; })?;
let result = result_rx.recv_async().await.map_err(|err| {
error!("no reply from provider: {err}");
Status::internal("provider did not reply")
})?;
match result { match result {
Ok(node) => Ok(Response::new(GetLibraryNodeResponse { node: Some(node) })), Ok(node) => Ok(Response::new(GetLibraryNodeResponse { node: Some(node) })),
Err(err) => { Err(err) => {
error!("get_library_node failed: {err}"); error!("{:?}", err);
Err(Status::internal(err.to_string())) Err(Status::internal(err.to_string()))
} }
} }
} }
#[instrument(skip(self, request), fields(paths))] #[instrument(skip(self, request), fields(uuids))]
async fn queue( async fn queue(
&self, &self,
request: Request<QueueRequest>, request: tonic::Request<QueueRequest>,
) -> Result<Response<QueueResponse>, Status> { ) -> std::result::Result<tonic::Response<QueueResponse>, tonic::Status> {
let paths = request.into_inner().paths; let uuids = request.into_inner().uuids.clone();
tracing::Span::current().record("paths", format!("{paths:?}")); Span::current().record("uuids", format!("{:?}", uuids));
debug!("received queue request"); debug!("Received queue request");
self.send_playback(PlaybackCommand::Queue { paths }).await?; let playback_tx = self.playback_tx.clone();
Ok(Response::new(QueueResponse {})) let span = debug_span!("play-chan");
playback_tx
.send_async(PlaybackMessage::Queue { uuids, span })
.in_current_span()
.await
.map_err(|_| Status::internal("Failed to send request via channel"))?;
let reply = QueueResponse {};
Ok(Response::new(reply))
} }
#[instrument(skip(self, request), fields(paths))] #[instrument(skip(self, request), fields(uuids))]
async fn replace( async fn replace(
&self, &self,
request: Request<ReplaceRequest>, request: tonic::Request<ReplaceRequest>,
) -> Result<Response<ReplaceResponse>, Status> { ) -> std::result::Result<tonic::Response<ReplaceResponse>, tonic::Status> {
let paths = request.into_inner().paths; let uuids = request.into_inner().uuids.clone();
tracing::Span::current().record("paths", format!("{paths:?}")); Span::current().record("uuids", format!("{:?}", uuids));
debug!("received replace request"); debug!("Received replace request");
self.send_playback(PlaybackCommand::Replace { paths }) let playback_tx = self.playback_tx.clone();
.await?; let span = debug_span!("play-chan");
Ok(Response::new(ReplaceResponse {})) playback_tx
.send_async(PlaybackMessage::Replace { uuids, span })
.in_current_span()
.await
.map_err(|_| Status::internal("Failed to send request via channel"))?;
let reply = ReplaceResponse {};
Ok(Response::new(reply))
} }
#[instrument(skip(self, request), fields(paths))] #[instrument(skip(self, request), fields(uuids))]
async fn append( async fn append(
&self, &self,
request: Request<AppendRequest>, request: tonic::Request<AppendRequest>,
) -> Result<Response<AppendResponse>, Status> { ) -> std::result::Result<tonic::Response<AppendResponse>, tonic::Status> {
let paths = request.into_inner().paths; let uuids = request.into_inner().uuids.clone();
tracing::Span::current().record("paths", format!("{paths:?}")); Span::current().record("uuids", format!("{:?}", uuids));
debug!("received append request"); debug!("Received append request");
self.send_playback(PlaybackCommand::Append { paths }) let playback_tx = self.playback_tx.clone();
.await?; let span = debug_span!("play-chan");
Ok(Response::new(AppendResponse {})) playback_tx
.send_async(PlaybackMessage::Append { uuids, span })
.in_current_span()
.await
.map_err(|_| Status::internal("Failed to send request via channel"))?;
let reply = AppendResponse {};
Ok(Response::new(reply))
} }
#[instrument(skip(self, request), fields(positions))] #[instrument(skip(self, request), fields(positions))]
async fn remove( async fn remove(
&self, &self,
request: Request<RemoveRequest>, request: tonic::Request<RemoveRequest>,
) -> Result<Response<RemoveResponse>, Status> { ) -> std::result::Result<tonic::Response<RemoveResponse>, tonic::Status> {
let positions = request.into_inner().positions; let positions = request.into_inner().positions;
tracing::Span::current().record("positions", format!("{positions:?}")); Span::current().record("positions", format!("{:?}", positions));
debug!("received remove request"); debug!("Received remove request");
self.send_playback(PlaybackCommand::Remove { positions }) let playback_tx = self.playback_tx.clone();
.await?; let span = debug_span!("play-chan");
Ok(Response::new(RemoveResponse {})) playback_tx
.send_async(PlaybackMessage::Remove { positions, span })
.in_current_span()
.await
.map_err(|_| Status::internal("Failed to send request via channel"))?;
let reply = RemoveResponse {};
Ok(Response::new(reply))
} }
#[instrument(skip(self, request), fields(paths, position))] #[instrument(skip(self, request), fields(uuids, position))]
async fn insert( async fn insert(
&self, &self,
request: Request<InsertRequest>, request: tonic::Request<InsertRequest>,
) -> Result<Response<InsertResponse>, Status> { ) -> std::result::Result<tonic::Response<InsertResponse>, tonic::Status> {
let req = request.into_inner(); let req = request.into_inner();
tracing::Span::current().record("paths", format!("{:?}", req.paths)); let uuids = req.uuids.clone();
tracing::Span::current().record("position", req.position); let position = req.position;
debug!("received insert request"); Span::current().record("uuids", format!("{:?}", uuids));
self.send_playback(PlaybackCommand::Insert { Span::current().record("position", position);
position: req.position, debug!("Received insert request");
paths: req.paths, let playback_tx = self.playback_tx.clone();
}) let span = debug_span!("play-chan");
.await?; playback_tx
Ok(Response::new(InsertResponse {})) .send_async(PlaybackMessage::Insert {
position: req.position,
uuids,
span,
})
.in_current_span()
.await
.map_err(|_| Status::internal("Failed to send request via channel"))?;
let reply = InsertResponse {};
Ok(Response::new(reply))
} }
#[instrument(skip(self, request), fields(exclude_current))] #[instrument(skip(self, request), fields(exclude_current))]
async fn clear_queue( async fn clear_queue(
&self, &self,
request: Request<ClearQueueRequest>, request: tonic::Request<ClearQueueRequest>,
) -> Result<Response<ClearQueueResponse>, Status> { ) -> std::result::Result<tonic::Response<ClearQueueResponse>, tonic::Status> {
let exclude_current = request.into_inner().exclude_current; let exclude_current = request.into_inner().exclude_current;
tracing::Span::current().record("exclude_current", exclude_current); Span::current().record("exclude_current", exclude_current);
debug!("received clear_queue request"); debug!("Received clear_queue request");
self.send_playback(PlaybackCommand::Clear { exclude_current }) let playback_tx = self.playback_tx.clone();
.await?; let span = debug_span!("play-chan");
Ok(Response::new(ClearQueueResponse {})) playback_tx
.send_async(PlaybackMessage::Clear {
exclude_current,
span,
})
.in_current_span()
.await
.map_err(|_| Status::internal("Failed to send request via channel"))?;
let reply = ClearQueueResponse {};
Ok(Response::new(reply))
} }
#[instrument(skip(self, request), fields(position))] #[instrument(skip(self, request), fields(position))]
async fn set_current( async fn set_current(
&self, &self,
request: Request<SetCurrentRequest>, request: tonic::Request<SetCurrentRequest>,
) -> Result<Response<SetCurrentResponse>, Status> { ) -> std::result::Result<tonic::Response<SetCurrentResponse>, tonic::Status> {
let position = request.into_inner().position; let position = request.into_inner().position;
tracing::Span::current().record("position", position); Span::current().record("position", position);
debug!("received set_current request"); debug!("Received set_current request");
self.send_playback(PlaybackCommand::SetCurrent { position }) let playback_tx = self.playback_tx.clone();
.await?; let span = debug_span!("play-chan");
Ok(Response::new(SetCurrentResponse {})) playback_tx
.send_async(PlaybackMessage::SetCurrent { position, span })
.in_current_span()
.await
.map_err(|_| Status::internal("Failed to send request via channel"))?;
let reply = SetCurrentResponse {};
Ok(Response::new(reply))
} }
#[instrument(skip(self, _request))] #[instrument(skip(self, _request))]
async fn toggle_shuffle( async fn toggle_shuffle(
&self, &self,
_request: Request<ToggleShuffleRequest>, _request: tonic::Request<ToggleShuffleRequest>,
) -> Result<Response<ToggleShuffleResponse>, Status> { ) -> std::result::Result<tonic::Response<ToggleShuffleResponse>, tonic::Status> {
debug!("received toggle_shuffle request"); debug!("Received toggle_shuffle request");
self.send_playback(PlaybackCommand::ToggleShuffle).await?; let playback_tx = self.playback_tx.clone();
Ok(Response::new(ToggleShuffleResponse {})) let span = debug_span!("play-chan");
if let Err(err) = playback_tx
.send_async(PlaybackMessage::ToggleShuffle { span })
.in_current_span()
.await
{
error!("Failed to send request via channel: {}", err);
}
let reply = ToggleShuffleResponse {};
Ok(Response::new(reply))
} }
#[instrument(skip(self, _request))] #[instrument(skip(self, _request))]
async fn toggle_repeat( async fn toggle_repeat(
&self, &self,
_request: Request<ToggleRepeatRequest>, _request: tonic::Request<ToggleRepeatRequest>,
) -> Result<Response<ToggleRepeatResponse>, Status> { ) -> std::result::Result<tonic::Response<ToggleRepeatResponse>, tonic::Status> {
debug!("received toggle_repeat request"); debug!("Received toggle_repeat request");
self.send_playback(PlaybackCommand::ToggleRepeat).await?; let playback_tx = self.playback_tx.clone();
Ok(Response::new(ToggleRepeatResponse {})) let span = debug_span!("play-chan");
if let Err(err) = playback_tx
.send_async(PlaybackMessage::ToggleRepeat { span })
.in_current_span()
.await
{
error!("Failed to send request via channel: {}", err);
}
let reply = ToggleRepeatResponse {};
Ok(Response::new(reply))
} }
#[instrument(skip(self, _request))] #[instrument(skip(self, _request))]
async fn get_update_stream( async fn get_update_stream(
&self, &self,
_request: Request<GetUpdateStreamRequest>, _request: tonic::Request<GetUpdateStreamRequest>,
) -> Result<Response<Self::GetUpdateStreamStream>, Status> { ) -> std::result::Result<tonic::Response<Self::GetUpdateStreamStream>, tonic::Status> {
debug!("received get_update_stream request, subscribing client"); debug!("Received get_update_stream request");
let update_rx = self.update_tx.subscribe(); let update_rx = self.update_tx.subscribe();
let update_stream = tokio_stream::wrappers::BroadcastStream::new(update_rx); let update_stream = tokio_stream::wrappers::BroadcastStream::new(update_rx);
let output_stream = update_stream.map(|update_result| { let output_stream = update_stream.into_stream().map(|update_result| {
trace!(?update_result, "forwarding update"); trace!("Got update: {:?}", update_result);
match update_result { match update_result {
Ok(update) => Ok(GetUpdateStreamResponse { Ok(update) => Ok(GetUpdateStreamResponse {
update: Some(update), update: Some(update),
}), }),
Err(err) => { Err(_) => Err(tonic::Status::new(
// The client lagged too far behind the broadcast channel. tonic::Code::Unknown,
error!("update stream lagged: {err}"); "Internal channel error",
Err(Status::data_loss("update stream lagged")) )),
}
} }
}); });
@ -242,73 +314,146 @@ impl CrabidyService for RpcService {
#[instrument(skip(self, _request))] #[instrument(skip(self, _request))]
async fn save_queue( async fn save_queue(
&self, &self,
_request: Request<SaveQueueRequest>, _request: tonic::Request<SaveQueueRequest>,
) -> Result<Response<SaveQueueResponse>, Status> { ) -> std::result::Result<tonic::Response<SaveQueueResponse>, tonic::Status> {
debug!("received save_queue request (not implemented)"); debug!("Received save_queue request");
Ok(Response::new(SaveQueueResponse {})) let reply = SaveQueueResponse {};
Ok(Response::new(reply))
} }
/// Playback
#[instrument(skip(self, _request))] #[instrument(skip(self, _request))]
async fn toggle_play( async fn toggle_play(
&self, &self,
_request: Request<TogglePlayRequest>, _request: tonic::Request<TogglePlayRequest>,
) -> Result<Response<TogglePlayResponse>, Status> { ) -> std::result::Result<tonic::Response<TogglePlayResponse>, tonic::Status> {
debug!("received toggle_play request"); debug!("Received toggle_play request");
self.send_playback(PlaybackCommand::TogglePlay).await?; let playback_tx = self.playback_tx.clone();
Ok(Response::new(TogglePlayResponse {})) let span = debug_span!("play-chan");
if let Err(err) = playback_tx
.send_async(PlaybackMessage::TogglePlay { span })
.in_current_span()
.await
{
error!("Failed to send request via channel: {}", err);
}
let reply = TogglePlayResponse {};
Ok(Response::new(reply))
} }
#[instrument(skip(self, _request))] #[instrument(skip(self, _request))]
async fn stop(&self, _request: Request<StopRequest>) -> Result<Response<StopResponse>, Status> { async fn stop(
debug!("received stop request"); &self,
self.send_playback(PlaybackCommand::Stop).await?; _request: tonic::Request<StopRequest>,
Ok(Response::new(StopResponse {})) ) -> std::result::Result<tonic::Response<StopResponse>, tonic::Status> {
debug!("Received stop request");
let playback_tx = self.playback_tx.clone();
let span = debug_span!("play-chan");
if let Err(err) = playback_tx
.send_async(PlaybackMessage::Stop { span })
.in_current_span()
.await
{
error!("Failed to send request via channel: {}", err);
}
let reply = StopResponse {};
Ok(Response::new(reply))
} }
#[instrument(skip(self, request), fields(delta))] #[instrument(skip(self, request), fields(delta))]
async fn change_volume( async fn change_volume(
&self, &self,
request: Request<ChangeVolumeRequest>, request: tonic::Request<ChangeVolumeRequest>,
) -> Result<Response<ChangeVolumeResponse>, Status> { ) -> std::result::Result<tonic::Response<ChangeVolumeResponse>, tonic::Status> {
let delta = request.into_inner().delta; let delta = request.into_inner().delta;
tracing::Span::current().record("delta", delta); Span::current().record("delta", delta);
debug!("received change_volume request"); debug!("Received change_volume request");
self.send_playback(PlaybackCommand::ChangeVolume { delta }) let playback_tx = self.playback_tx.clone();
.await?; let span = debug_span!("play-chan");
Ok(Response::new(ChangeVolumeResponse {})) if let Err(err) = playback_tx
.send_async(PlaybackMessage::ChangeVolume { delta, span })
.in_current_span()
.await
{
error!("Failed to send request via channel: {}", err);
}
let reply = ChangeVolumeResponse {};
Ok(Response::new(reply))
} }
#[instrument(skip(self, _request))] #[instrument(skip(self, _request))]
async fn toggle_mute( async fn toggle_mute(
&self, &self,
_request: Request<ToggleMuteRequest>, _request: tonic::Request<ToggleMuteRequest>,
) -> Result<Response<ToggleMuteResponse>, Status> { ) -> std::result::Result<tonic::Response<ToggleMuteResponse>, tonic::Status> {
debug!("received toggle_mute request"); debug!("Received toggle_mute request");
self.send_playback(PlaybackCommand::ToggleMute).await?; let playback_tx = self.playback_tx.clone();
Ok(Response::new(ToggleMuteResponse {})) let span = debug_span!("play-chan");
if let Err(err) = playback_tx
.send_async(PlaybackMessage::ToggleMute { span })
.in_current_span()
.await
{
error!("Failed to send request via channel: {}", err);
}
let reply = ToggleMuteResponse {};
Ok(Response::new(reply))
} }
#[instrument(skip(self, _request))] #[instrument(skip(self, _request))]
async fn next(&self, _request: Request<NextRequest>) -> Result<Response<NextResponse>, Status> { async fn next(
debug!("received next request"); &self,
self.send_playback(PlaybackCommand::Next).await?; _request: tonic::Request<NextRequest>,
Ok(Response::new(NextResponse {})) ) -> std::result::Result<tonic::Response<NextResponse>, tonic::Status> {
debug!("Received next request");
let playback_tx = self.playback_tx.clone();
let span = debug_span!("play-chan");
if let Err(err) = playback_tx
.send_async(PlaybackMessage::Next { span })
.in_current_span()
.await
{
error!("Failed to send request via channel: {}", err);
}
let reply = NextResponse {};
Ok(Response::new(reply))
} }
#[instrument(skip(self, _request))] #[instrument(skip(self, _request))]
async fn prev(&self, _request: Request<PrevRequest>) -> Result<Response<PrevResponse>, Status> { async fn prev(
debug!("received prev request"); &self,
self.send_playback(PlaybackCommand::Prev).await?; _request: tonic::Request<PrevRequest>,
Ok(Response::new(PrevResponse {})) ) -> std::result::Result<tonic::Response<PrevResponse>, tonic::Status> {
debug!("Received prev request");
let playback_tx = self.playback_tx.clone();
let span = debug_span!("play-chan");
if let Err(err) = playback_tx
.send_async(PlaybackMessage::Prev { span })
.in_current_span()
.await
{
error!("Failed to send request via channel: {}", err);
}
let reply = PrevResponse {};
Ok(Response::new(reply))
} }
#[instrument(skip(self, _request))] #[instrument(skip(self, _request))]
async fn restart_track( async fn restart_track(
&self, &self,
_request: Request<RestartTrackRequest>, _request: tonic::Request<RestartTrackRequest>,
) -> Result<Response<RestartTrackResponse>, Status> { ) -> std::result::Result<tonic::Response<RestartTrackResponse>, tonic::Status> {
debug!("received restart_track request"); debug!("Received restart_track request");
self.send_playback(PlaybackCommand::RestartTrack).await?; let playback_tx = self.playback_tx.clone();
Ok(Response::new(RestartTrackResponse {})) let span = debug_span!("play-chan");
if let Err(err) = playback_tx
.send_async(PlaybackMessage::RestartTrack { span })
.in_current_span()
.await
{
error!("Failed to send request via channel: {}", err);
}
let reply = RestartTrackResponse {};
Ok(Response::new(reply))
} }
} }

View File

@ -1,61 +0,0 @@
{ pkgs, lib, ... }:
{
packages = with pkgs; [
bacon
cargo-deny
cargo-duplicates
cargo-expand
cargo-features-manager
cargo-flamegraph
cargo-geiger
cargo-generate
cargo-hack
cargo-insta
cargo-llvm-cov
cargo-machete
cargo-mutants
cargo-nextest
cargo-rr
cargo-udeps
cargo-vet
cargo-workspaces
git
sccache
tokio-console
vulkan-tools
];
env.RUSTC_WRAPPER = "sccache";
# https://devenv.sh/languages/
languages.rust = {
enable = true;
channel = "stable";
clangLinker.enable = true;
mold.enable = true;
rustflags = lib.concatStringsSep " " [
];
components = [
"rustc"
"cargo"
"clippy"
"rustfmt"
"rust-analyzer"
];
};
tasks."devenv:git-hooks:prepare-shared-hooks-rust" = {
exec = ''
if hooks_dir="$(git rev-parse --git-path hooks 2>/dev/null)"; then
mkdir -p "$hooks_dir"
chmod u+rwx,g+rwx "$hooks_dir" 2>/dev/null || true
find "$hooks_dir" -maxdepth 1 -type f ! -user "$(id -un)" ! -name '*.sample' -delete 2>/dev/null || true
fi
'';
after = [ "devenv:files" ];
before = [ "devenv:git-hooks:install" ];
};
git-hooks.hooks = {
rustfmt.enable = true;
clippy.enable = true;
};
}

View File

@ -1,141 +0,0 @@
{
"nodes": {
"devenv": {
"locked": {
"dir": "src/modules",
"lastModified": 1784325378,
"narHash": "sha256-Gof6j4d43yX2qSLLp78JILke346IggDFIxTgl3ecVQE=",
"owner": "cachix",
"repo": "devenv",
"rev": "5f1cf17be0fc48689bd0ecb810de6d2e06d259a1",
"type": "github"
},
"original": {
"dir": "src/modules",
"owner": "cachix",
"repo": "devenv",
"type": "github"
}
},
"flake-compat": {
"flake": false,
"locked": {
"lastModified": 1767039857,
"narHash": "sha256-vNpUSpF5Nuw8xvDLj2KCwwksIbjua2LZCqhV1LNRDns=",
"owner": "NixOS",
"repo": "flake-compat",
"rev": "5edf11c44bc78a0d334f6334cdaf7d60d732daab",
"type": "github"
},
"original": {
"owner": "NixOS",
"repo": "flake-compat",
"type": "github"
}
},
"git-hooks": {
"inputs": {
"flake-compat": "flake-compat",
"nixpkgs": [
"nixpkgs"
]
},
"locked": {
"lastModified": 1784288435,
"narHash": "sha256-ReRHaLgr/uVqdD8afFSn+myXIfpHeOhP0yYe0TJqAA8=",
"owner": "cachix",
"repo": "git-hooks.nix",
"rev": "43b3c1ab9d40fb1dbb008f451988a91e375825e9",
"type": "github"
},
"original": {
"owner": "cachix",
"repo": "git-hooks.nix",
"type": "github"
}
},
"nixpkgs": {
"inputs": {
"nixpkgs-src": "nixpkgs-src"
},
"locked": {
"lastModified": 1783345554,
"narHash": "sha256-LZhOm4kqjHUnwP4CNHpaqqEVcumGNd1PvOEOad8LkS0=",
"owner": "cachix",
"repo": "devenv-nixpkgs",
"rev": "6004ea8c229fe9d41b21c6f4c76bf6c2e10771dd",
"type": "github"
},
"original": {
"owner": "cachix",
"ref": "rolling",
"repo": "devenv-nixpkgs",
"type": "github"
}
},
"nixpkgs-src": {
"flake": false,
"locked": {
"lastModified": 1783279667,
"narHash": "sha256-/NAkDSsve+GNM0Bt6tleJdCGfsTlK89nPjkVOzZMo0s=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "f205b5574fd0cb7da5b702a2da51507b7f4fdd1b",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixpkgs-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"nixpkgs-unstable": {
"locked": {
"lastModified": 1784453100,
"narHash": "sha256-zpGZb8FYui+i8KLtC0nlcmb0voR2zB80Qn8pe7lzIbk=",
"owner": "nixos",
"repo": "nixpkgs",
"rev": "b471514bed69eff5255c8e63c1f80e5fe56c616f",
"type": "github"
},
"original": {
"owner": "nixos",
"ref": "nixpkgs-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"devenv": "devenv",
"git-hooks": "git-hooks",
"nixpkgs": "nixpkgs",
"nixpkgs-unstable": "nixpkgs-unstable",
"rust-overlay": "rust-overlay"
}
},
"rust-overlay": {
"inputs": {
"nixpkgs": [
"nixpkgs"
]
},
"locked": {
"lastModified": 1784438913,
"narHash": "sha256-NYF7ZM5ip0u+w1pBFDpIGEbrbgN/wpnLFAmBkWkYMXw=",
"owner": "oxalica",
"repo": "rust-overlay",
"rev": "afacd6819d3765a05814ee8e3de74c77d42ac799",
"type": "github"
},
"original": {
"owner": "oxalica",
"repo": "rust-overlay",
"type": "github"
}
}
},
"root": "root",
"version": 7
}

View File

@ -1,73 +0,0 @@
{
pkgs,
lib,
config,
inputs,
...
}:
let
pkgs-unstable = import inputs.nixpkgs-unstable { system = pkgs.stdenv.system; };
commonLibs = with pkgs; [ alsa-lib ];
extraPackages = with pkgs; [
pkg-config
protobuf
];
in
{
imports = [ ./devenv-rust.nix ];
env = {
LD_LIBRARY_PATH = pkgs.lib.makeLibraryPath commonLibs;
};
# https://devenv.sh/packages/
packages = commonLibs ++ extraPackages;
# https://devenv.sh/processes/
# processes.cargo-watch.exec = "cargo-watch";
# https://devenv.sh/services/
# services.postgres.enable = true;
# https://devenv.sh/scripts/
scripts.hello.exec = ''
echo Welcome to rust devenv
'';
enterShell = "";
# https://devenv.sh/tasks/
# tasks = {
# "myproj:setup".exec = "mytool build";
# "devenv:enterShell".after = [ "myproj:setup" ];
# };
# https://devenv.sh/tests/
enterTest = ''
echo "Running tests"
'';
# https://devenv.sh/git-hooks/
git-hooks.hooks = {
nixfmt.enable = true;
taplo.enable = true;
markdownlint.enable = true;
shellcheck.enable = true;
};
# https://devenv.sh/integrations/claude-code/
# Claude Code integration: writes .claude/{settings,commands,agents}. We keep
# ownership of .mcp.json (skills wiring), so stop devenv from writing it.
claude.code.enable = true;
claude.code.mcpServers = lib.mkForce { };
claude.code.hooks.git-hooks-run.command = ''
f=$(${pkgs.jq}/bin/jq -r '.tool_input.file_path // empty')
if [ -n "$f" ]; then
cd "''${CLAUDE_PROJECT_DIR:-.}"
${lib.getExe config.git-hooks.package} run --files "$f"
fi
'';
# See full reference at https://devenv.sh/reference/options/
}

View File

@ -1,16 +0,0 @@
# yaml-language-server: $schema=https://devenv.sh/devenv.schema.json
inputs:
nixpkgs:
url: github:cachix/devenv-nixpkgs/rolling
nixpkgs-unstable:
url: github:nixos/nixpkgs/nixpkgs-unstable
rust-overlay:
url: github:oxalica/rust-overlay
inputs:
nixpkgs:
follows: nixpkgs
git-hooks:
url: github:cachix/git-hooks.nix
inputs:
nixpkgs:
follows: nixpkgs

View File

@ -1,2 +0,0 @@
[toolchain]
channel = "stable"

10
stream-download/.gitignore vendored Normal file
View File

@ -0,0 +1,10 @@
# Generated by Cargo
# will have compiled files and executables
/target/
# Remove Cargo.lock from gitignore if creating an executable, leave it for libraries
# More information here https://doc.rust-lang.org/cargo/guide/cargo-toml-vs-cargo-lock.html
Cargo.lock
# These are backup files generated by rustfmt
**/*.rs.bk

View File

@ -0,0 +1,32 @@
[package]
edition = "2021"
name = "stream-download"
version = "0.1.0"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
async-trait = "0.1"
bytes = "1"
futures = "0.3"
futures-util = "0.3"
parking_lot = "0.12"
rangemap = "1"
reqwest = { version = "0.11", features = [
"rustls-tls",
"trust-dns",
"stream",
], default-features = false, optional = true }
symphonia = "0.5"
tempfile = "3"
tokio = { version = "1", features = ["sync", "macros"] }
tracing = "0.1"
[features]
default = ["http"]
http = ["reqwest"]
[dev-dependencies]
rodio = "0.17.1"
tracing-subscriber = "0.3.16"
tokio = { version = "1", features = ["sync", "macros", "rt-multi-thread"] }

21
stream-download/LICENSE Normal file
View File

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2023 Austin Schey
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@ -0,0 +1 @@
# stream-download-rs

View File

@ -0,0 +1,18 @@
use stream_download::StreamDownload;
#[tokio::main]
async fn main() {
tracing_subscriber::fmt().init();
let (_stream, handle) = rodio::OutputStream::try_default().unwrap();
let sink = rodio::Sink::try_new(&handle).unwrap();
let reader = StreamDownload::new_http(
"https://dl.espressif.com/dl/audio/ff-16b-2c-44100hz.flac"
.parse()
.unwrap(),
);
sink.append(rodio::Decoder::new(reader).unwrap());
sink.sleep_until_end();
}

View File

@ -0,0 +1,17 @@
use stream_download::StreamDownload;
fn main() {
tracing_subscriber::fmt().init();
let (_stream, handle) = rodio::OutputStream::try_default().unwrap();
let sink = rodio::Sink::try_new(&handle).unwrap();
let reader = StreamDownload::new_http(
"https://dl.espressif.com/dl/audio/ff-16b-2c-44100hz.flac"
.parse()
.unwrap(),
);
sink.append(rodio::Decoder::new(reader).unwrap());
sink.sleep_until_end();
}

View File

@ -0,0 +1,18 @@
use stream_download::StreamDownload;
#[tokio::main]
async fn main() {
tracing_subscriber::fmt().init();
let (_stream, handle) = rodio::OutputStream::try_default().unwrap();
let sink = rodio::Sink::try_new(&handle).unwrap();
let reader = StreamDownload::new_http(
"https://uk1.internet-radio.com/proxy/pinknoise?mp=/stream"
.parse()
.unwrap(),
);
sink.append(rodio::Decoder::new(reader).unwrap());
sink.sleep_until_end();
}

View File

@ -0,0 +1,81 @@
use async_trait::async_trait;
use bytes::Bytes;
use futures::Stream;
use reqwest::Client;
use std::{
pin::Pin,
str::FromStr,
task::{self, Poll},
};
use tracing::{info, warn};
use crate::source::SourceStream;
pub struct HttpStream {
stream: Box<dyn Stream<Item = Result<Bytes, reqwest::Error>> + Unpin + Send + Sync>,
client: Client,
content_length: Option<u64>,
url: reqwest::Url,
}
impl Stream for HttpStream {
type Item = Result<Bytes, reqwest::Error>;
fn poll_next(mut self: Pin<&mut Self>, cx: &mut task::Context<'_>) -> Poll<Option<Self::Item>> {
Pin::new(&mut self.stream).poll_next(cx)
}
}
#[async_trait]
impl SourceStream for HttpStream {
type Url = reqwest::Url;
type Error = reqwest::Error;
async fn create(url: Self::Url) -> Self {
let client = Client::new();
info!("Requesting content length");
let response = client.get(url.as_str()).send().await.unwrap();
let mut content_length = None;
if let Some(length) = response.headers().get(reqwest::header::CONTENT_LENGTH) {
let length = u64::from_str(length.to_str().unwrap()).unwrap();
info!("Got content length {length}");
content_length = Some(length);
} else {
warn!("Content length header missing");
}
let stream = response.bytes_stream();
Self {
stream: Box::new(stream),
client,
content_length,
url,
}
}
async fn content_length(&self) -> Option<u64> {
self.content_length
}
async fn seek(&mut self, pos: u64) {
info!("Seeking");
self.stream = Box::new(
self.client
.get(self.url.as_str())
.header(
"Range",
format!(
"bytes={pos}-{}",
self.content_length
.map(|l| l.to_string())
.unwrap_or_default()
),
)
.send()
.await
.unwrap()
.bytes_stream(),
);
info!("Done seeking");
}
}

166
stream-download/src/lib.rs Normal file
View File

@ -0,0 +1,166 @@
use source::{Source, SourceHandle, SourceStream};
use std::{
io::{self, BufReader, Read, Seek, SeekFrom},
thread,
};
use symphonia::core::io::MediaSource;
use tempfile::NamedTempFile;
use tracing::debug;
#[cfg(feature = "http")]
pub mod http;
pub mod source;
#[derive(Debug)]
pub struct StreamDownload {
output_reader: BufReader<NamedTempFile>,
handle: SourceHandle,
read_position: u64,
}
impl StreamDownload {
#[cfg(feature = "http")]
pub fn new_http(url: reqwest::Url) -> Self {
Self::new::<http::HttpStream>(url)
}
pub fn new<S: SourceStream>(url: S::Url) -> Self {
let tempfile = tempfile::Builder::new().tempfile().unwrap();
let source = Source::new(tempfile.reopen().unwrap());
let handle = source.source_handle();
if let Ok(handle) = tokio::runtime::Handle::try_current() {
handle.spawn(async move {
let stream = S::create(url).await;
source.download(stream).await;
});
} else {
thread::spawn(move || {
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.unwrap();
rt.block_on(async move {
let stream = S::create(url).await;
source.download(stream).await;
});
});
};
Self {
output_reader: BufReader::new(tempfile),
read_position: 0,
handle,
}
}
pub fn from_stream<S: SourceStream>(stream: S) -> Self {
let tempfile = tempfile::Builder::new().tempfile().unwrap();
let source = Source::new(tempfile.reopen().unwrap());
let handle = source.source_handle();
if let Ok(handle) = tokio::runtime::Handle::try_current() {
handle.spawn(async move {
source.download(stream).await;
});
} else {
thread::spawn(move || {
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.unwrap();
rt.block_on(async move {
source.download(stream).await;
});
});
};
Self {
output_reader: BufReader::new(tempfile),
handle,
read_position: 0,
}
}
}
impl Read for StreamDownload {
fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
debug!("Read request buf len: {}", buf.len());
let requested_position = self.read_position + buf.len() as u64;
debug!(
"read: current position: {} requested position: {requested_position}",
self.read_position
);
if let Some(closest_set) = self.handle.downloaded().get(&self.read_position) {
debug!("Already downloaded {closest_set:?}");
if closest_set.end >= requested_position {
let read_len = self.output_reader.read(buf);
if let Ok(read_len) = read_len {
self.read_position += read_len as u64;
}
return read_len;
}
}
self.handle.request_position(requested_position);
debug!("waiting for position");
self.handle.wait_for_requested_position();
debug!("reached requested position {requested_position}");
self.output_reader.read(buf)
}
}
impl Seek for StreamDownload {
fn seek(&mut self, pos: SeekFrom) -> io::Result<u64> {
let seek_pos = match pos {
SeekFrom::Start(pos) => pos,
SeekFrom::End(pos) => {
if let Some(length) = self.handle.content_length() {
(length as i64 + pos) as u64
} else {
return Err(io::Error::new(
io::ErrorKind::Unsupported,
"Cannot seek from end when content length is unknown",
));
}
}
SeekFrom::Current(pos) => (self.read_position as i64 + pos) as u64,
};
if let Some(closest_set) = self.handle.downloaded().get(&seek_pos) {
if closest_set.end >= seek_pos {
let new_pos = self.output_reader.seek(pos);
if let Ok(new_pos) = new_pos {
self.read_position = new_pos;
}
}
}
self.handle.request_position(seek_pos);
debug!(
"seek: current position {seek_pos} requested position {:?}. waiting",
seek_pos
);
self.handle.seek(seek_pos);
self.handle.wait_for_requested_position();
debug!("reached seek position");
self.output_reader.seek(pos)
}
}
impl MediaSource for StreamDownload {
fn is_seekable(&self) -> bool {
true
}
// FIXME: Can this be implemented?
fn byte_len(&self) -> Option<u64> {
None
}
}

View File

@ -0,0 +1,228 @@
use async_trait::async_trait;
use bytes::Bytes;
use futures::{Stream, StreamExt};
use parking_lot::{Condvar, Mutex, RwLock, RwLockReadGuard};
use rangemap::RangeSet;
use std::{
error::Error,
fs::File,
io::{BufWriter, Seek, SeekFrom, Write},
sync::{
atomic::{AtomicI64, Ordering},
Arc,
},
};
use tokio::sync::mpsc;
use tracing::{debug, info, trace};
#[async_trait]
pub trait SourceStream:
Stream<Item = Result<Bytes, Self::Error>> + Unpin + Send + Sync + 'static
{
type Url: Send;
type Error: Error + Send;
async fn create(url: Self::Url) -> Self;
async fn content_length(&self) -> Option<u64>;
async fn seek(&mut self, position: u64);
}
#[derive(Debug, Clone)]
pub struct SourceHandle {
downloaded: Arc<RwLock<RangeSet<u64>>>,
requested_position: Arc<AtomicI64>,
position_reached: Arc<(Mutex<Waiter>, Condvar)>,
content_length_retrieved: Arc<(Mutex<bool>, Condvar)>,
content_length: Arc<AtomicI64>,
seek_tx: mpsc::Sender<u64>,
}
impl SourceHandle {
pub fn downloaded(&self) -> RwLockReadGuard<rangemap::RangeSet<u64>> {
self.downloaded.read()
}
pub fn request_position(&self, position: u64) {
self.requested_position
.store(position as i64, Ordering::SeqCst);
}
pub fn wait_for_requested_position(&self) {
let (mutex, cvar) = &*self.position_reached;
let mut waiter = mutex.lock();
if !waiter.stream_done {
debug!("Waiting for requested position");
cvar.wait_while(&mut waiter, |waiter| {
!waiter.stream_done && !waiter.position_reached
});
if !waiter.stream_done {
waiter.position_reached = false;
}
debug!("Position reached");
}
}
pub fn seek(&self, position: u64) {
self.seek_tx.try_send(position).ok();
}
pub fn content_length(&self) -> Option<u64> {
let (mutex, cvar) = &*self.content_length_retrieved;
let mut done = mutex.lock();
if !*done {
cvar.wait_while(&mut done, |done| !*done);
}
let length = self.content_length.load(Ordering::SeqCst);
if length > -1 {
Some(length as u64)
} else {
None
}
}
}
#[derive(Default, Debug)]
struct Waiter {
position_reached: bool,
stream_done: bool,
}
pub struct Source {
writer: BufWriter<File>,
downloaded: Arc<RwLock<RangeSet<u64>>>,
position: u64,
requested_position: Arc<AtomicI64>,
position_reached: Arc<(Mutex<Waiter>, Condvar)>,
content_length_retrieved: Arc<(Mutex<bool>, Condvar)>,
content_length: Arc<AtomicI64>,
seek_tx: mpsc::Sender<u64>,
seek_rx: mpsc::Receiver<u64>,
}
const PREFETCH_BYTES: u64 = 1024 * 256;
impl Source {
pub fn new(tempfile: File) -> Self {
let (seek_tx, seek_rx) = mpsc::channel(32);
Self {
writer: BufWriter::new(tempfile),
downloaded: Default::default(),
position: Default::default(),
requested_position: Arc::new(AtomicI64::new(-1)),
position_reached: Default::default(),
content_length_retrieved: Default::default(),
seek_tx,
seek_rx,
content_length: Default::default(),
}
}
pub async fn download<S: SourceStream>(mut self, mut stream: S) {
info!("Starting file download");
let content_length = stream.content_length().await;
if let Some(content_length) = content_length {
self.content_length
.swap(content_length as i64, Ordering::SeqCst);
} else {
self.content_length.swap(-1, Ordering::SeqCst);
}
{
let (mutex, cvar) = &*self.content_length_retrieved;
*mutex.lock() = true;
cvar.notify_all();
}
let mut initial_buffer = 0;
loop {
if let Some(bytes) = stream.next().await {
let bytes = bytes.unwrap();
self.writer.write_all(&bytes).unwrap();
initial_buffer += bytes.len() as u64;
trace!("Prefetch: {}/{} bytes", initial_buffer, PREFETCH_BYTES);
if initial_buffer >= PREFETCH_BYTES {
self.position += initial_buffer;
self.downloaded.write().insert(0..initial_buffer);
break;
}
} else {
info!("File shorter than prefetch length");
self.writer.flush().unwrap();
self.position += initial_buffer;
self.downloaded.write().insert(0..initial_buffer);
let (mutex, cvar) = &*self.position_reached;
(mutex.lock()).stream_done = true;
cvar.notify_all();
return;
}
}
info!("Prefetch complete");
loop {
tokio::select! {
bytes = stream.next() => {
if let Some(bytes) = bytes {
let bytes = bytes.unwrap();
let chunk_len = bytes.len() as u64;
self.writer.write_all(&bytes).unwrap();
let new_position = self.position + chunk_len;
trace!("Received response chunk. position={}", new_position);
self.downloaded.write().insert(self.position..new_position);
let requested = self.requested_position.load(Ordering::SeqCst);
if requested > -1 {
debug!("downloader: requested {requested} current {}", new_position);
}
if requested > -1 && new_position as i64 >= requested {
info!("Notifying");
self.requested_position.store(-1, Ordering::SeqCst);
let (mutex, cvar) = &*self.position_reached;
(mutex.lock()).position_reached = true;
cvar.notify_all();
}
self.position = new_position;
} else {
info!("Stream finished downloading");
self.writer.flush().unwrap();
let (mutex, cvar) = &*self.position_reached;
(mutex.lock()).stream_done = true;
cvar.notify_all();
return;
}
},
pos = self.seek_rx.recv() => {
if let Some(pos) = pos {
debug!("Received seek position {pos}");
let do_seek = {
let downloaded = self.downloaded.read();
if let Some(range) = downloaded.get(&pos) {
!range.contains(&self.position)
} else {
true
}
};
if do_seek {
stream.seek(pos).await;
self.writer.seek(SeekFrom::Start(pos)).unwrap();
self.position = pos;
}
}
}
}
}
}
pub fn source_handle(&self) -> SourceHandle {
SourceHandle {
downloaded: self.downloaded.clone(),
requested_position: self.requested_position.clone(),
position_reached: self.position_reached.clone(),
seek_tx: self.seek_tx.clone(),
content_length_retrieved: self.content_length_retrieved.clone(),
content_length: self.content_length.clone(),
}
}
}

View File

@ -1,21 +1,25 @@
[package] [package]
name = "tidaldy" name = "tidaldy"
version.workspace = true version = "0.0.0"
edition.workspace = true edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies] [dependencies]
async-trait.workspace = true async-trait = "0.1.68"
base64.workspace = true base64 = "0.21.0"
chrono.workspace = true chrono = "0.4.24"
crabidy-core.workspace = true confique = "0.2.3"
reqwest.workspace = true crabidy-core = { path = "../crabidy-core" }
serde.workspace = true reqwest = { version = "0.11.17", features = ["json", "rustls-tls", "trust-dns"], default-features = false }
serde_json.workspace = true secrecy = { version = "0.8.0", features = ["serde"] }
serde_urlencoded.workspace = true serde = { version = "1.0.162", features = ["derive"] }
thiserror.workspace = true serde_json = "1.0.96"
tokio = { workspace = true, features = ["time", "sync", "macros"] } serde_urlencoded = "0.7.1"
toml.workspace = true thiserror = "1.0.40"
tracing.workspace = true tokio = { version = "1.28.1", features = ["full", "time"] }
toml = "0.7.4"
tracing = "0.1.37"
[dev-dependencies] [dev-dependencies]
tokio = { workspace = true, features = ["full"] } tokio = { version = "1.28.1", features = ["full"] }

View File

@ -2,7 +2,7 @@ use serde::{Deserialize, Serialize};
use std::iter::zip; use std::iter::zip;
use thiserror::Error; use thiserror::Error;
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Serialize, Deserialize)]
pub struct Settings { pub struct Settings {
pub base_url: String, pub base_url: String,
pub hifi_url: String, pub hifi_url: String,

View File

@ -1,9 +1,11 @@
use std::fmt::format;
/// Lots of stuff and especially the auth handling is shamelessly copied from /// Lots of stuff and especially the auth handling is shamelessly copied from
/// https://github.com/MinisculeGirraffe/tdl /// https://github.com/MinisculeGirraffe/tdl
use reqwest::Client as HttpClient; use reqwest::Client as HttpClient;
use serde::de::DeserializeOwned; use serde::de::DeserializeOwned;
use tokio::time::{sleep, Duration, Instant}; use tokio::time::{sleep, Duration, Instant};
use tracing::{debug, error, info, instrument, trace, warn}; use tracing::{debug, error, info, instrument};
pub mod config; pub mod config;
pub mod models; pub mod models;
use async_trait::async_trait; use async_trait::async_trait;
@ -13,14 +15,8 @@ pub use models::*;
pub struct Client { pub struct Client {
http_client: HttpClient, http_client: HttpClient,
settings: config::Settings, settings: config::Settings,
/// Login state changes at runtime when tokens are refreshed, while the
/// client is shared immutably, hence the lock. Never held across awaits.
login: std::sync::RwLock<config::LoginConfig>,
} }
/// Refresh the access token this long before it actually expires.
const TOKEN_REFRESH_MARGIN_SECS: u64 = 300;
#[async_trait] #[async_trait]
impl crabidy_core::ProviderClient for Client { impl crabidy_core::ProviderClient for Client {
#[instrument(skip(raw_toml_settings))] #[instrument(skip(raw_toml_settings))]
@ -28,90 +24,79 @@ impl crabidy_core::ProviderClient for Client {
let settings: config::Settings = if let Ok(settings) = toml::from_str(raw_toml_settings) { let settings: config::Settings = if let Ok(settings) = toml::from_str(raw_toml_settings) {
settings settings
} else { } else {
warn!("could not parse toml settings, using defaults"); let settings = config::Settings::default();
config::Settings::default() println!(
"could not parse toml settings: {:#?} using default settings instead: {:#?}",
raw_toml_settings, settings
);
settings
}; };
let mut client = Self::new(settings)?; let mut client = Self::new(settings)?;
if client.login_config().await.is_ok() { if let Ok(_) = client.login_config().await {
return Ok(client); return Ok(client);
} }
if client.login_web().await.is_ok() { if let Ok(_) = client.login_web().await {
return Ok(client); return Ok(client);
} }
Err(crabidy_core::ProviderError::CouldNotLogin) Err(crabidy_core::ProviderError::CouldNotLogin)
} }
#[instrument(skip(self))] #[instrument(skip(self))]
fn settings(&self) -> String { fn settings(&self) -> String {
let mut settings = self.settings.clone(); toml::to_string_pretty(&self.settings).unwrap_or_default()
settings.login = self.login_snapshot();
toml::to_string_pretty(&settings).unwrap_or_default()
} }
fn is_track_path(&self, path: &str) -> bool {
matches!(
parse_path(path),
Ok(TidalPath::PlaylistTrack { .. } | TidalPath::AlbumTrack { .. })
)
}
#[instrument(skip(self))] #[instrument(skip(self))]
async fn get_urls_for_track( async fn get_urls_for_track(
&self, &self,
track_path: &str, track_uuid: &str,
) -> Result<Vec<String>, crabidy_core::ProviderError> { ) -> Result<Vec<String>, crabidy_core::ProviderError> {
let track_id = track_id_from_path(track_path)?; debug!("get_urls_for_track {}", track_uuid);
let playback = self.get_track_playback(track_id).await.map_err(|err| { let (_, track_uuid, _) = split_uuid(track_uuid);
warn!(track = track_id, "failed to fetch playback info: {err}"); let Ok(playback) = self.get_track_playback(&track_uuid).await else {
crabidy_core::ProviderError::FetchError return Err(crabidy_core::ProviderError::FetchError)
})?; };
trace!(?playback, "got playback info"); debug!("playback {:?}", playback);
let manifest = playback.get_manifest().map_err(|err| { let Ok(manifest) = playback.get_manifest() else {
warn!(track = track_id, "failed to decode manifest: {err}"); return Err(crabidy_core::ProviderError::FetchError)
crabidy_core::ProviderError::FetchError };
})?; debug!("manifest {:?}", manifest);
debug!(
track = track_id,
urls = manifest.urls.len(),
"resolved stream urls"
);
Ok(manifest.urls) Ok(manifest.urls)
} }
#[instrument(skip(self))] #[instrument(skip(self))]
async fn get_metadata_for_track( async fn get_metadata_for_track(
&self, &self,
track_path: &str, track_uuid: &str,
) -> Result<crabidy_core::proto::crabidy::Track, crabidy_core::ProviderError> { ) -> Result<crabidy_core::proto::crabidy::Track, crabidy_core::ProviderError> {
let track_id = track_id_from_path(track_path)?; debug!("get_metadata_for_track {}", track_uuid);
let track = self.get_track(track_id).await.map_err(|err| { let Ok(track) = self.get_track(track_uuid).await else {
warn!(track = track_id, "failed to fetch track metadata: {err}"); return Err(crabidy_core::ProviderError::FetchError)
crabidy_core::ProviderError::FetchError };
})?; Ok(track.into())
let parent = crabidy_core::parent_path(track_path)
.unwrap_or(PROVIDER_ROOT)
.to_string();
Ok(track.to_proto(&parent))
} }
#[instrument(skip(self))] #[instrument(skip(self))]
fn get_lib_root(&self) -> crabidy_core::proto::crabidy::LibraryNode { fn get_lib_root(&self) -> crabidy_core::proto::crabidy::LibraryNode {
debug!("get_lib_root in tidaldy");
let global_root = crabidy_core::proto::crabidy::LibraryNode::new();
let children = vec![
crabidy_core::proto::crabidy::LibraryNodeChild::new(
"node:userplaylists".to_string(),
"playlists".to_string(),
false,
),
crabidy_core::proto::crabidy::LibraryNodeChild::new(
"node:userartists".to_string(),
"artists".to_string(),
false,
),
];
crabidy_core::proto::crabidy::LibraryNode { crabidy_core::proto::crabidy::LibraryNode {
path: PROVIDER_ROOT.to_string(), uuid: "node:tidal".to_string(),
title: "tidal".to_string(), title: "tidal".to_string(),
parent: Some(crabidy_core::ROOT_PATH.to_string()), parent: Some(format!("{}", global_root.uuid)),
tracks: Vec::new(), tracks: Vec::new(),
children: vec![ children,
crabidy_core::proto::crabidy::LibraryNodeChild::new(
format!("{PROVIDER_ROOT}/playlists"),
"playlists".to_string(),
false,
),
crabidy_core::proto::crabidy::LibraryNodeChild::new(
format!("{PROVIDER_ROOT}/artists"),
"artists".to_string(),
false,
),
],
is_queable: false, is_queable: false,
} }
} }
@ -119,23 +104,20 @@ impl crabidy_core::ProviderClient for Client {
#[instrument(skip(self))] #[instrument(skip(self))]
async fn get_lib_node( async fn get_lib_node(
&self, &self,
path: &str, uuid: &str,
) -> Result<crabidy_core::proto::crabidy::LibraryNode, crabidy_core::ProviderError> { ) -> Result<crabidy_core::proto::crabidy::LibraryNode, crabidy_core::ProviderError> {
let Some(user_id) = self.get_user_id() else { let Some(user_id) = self.settings.login.user_id.clone() else {
return Err(crabidy_core::ProviderError::UnknownUser); return Err(crabidy_core::ProviderError::UnknownUser)
}; };
let parsed = parse_path(path)?; debug!("get_lib_node in tidaldy{}", uuid);
debug!(?parsed, "resolving library node"); let (_kind, module, uuid) = split_uuid(uuid);
let parent = crabidy_core::parent_path(path) error!("module:{},uuid: {}", module, uuid);
.unwrap_or(crabidy_core::ROOT_PATH) let node = match module.as_str() {
.to_string(); "userplaylists" => {
let node = match parsed {
TidalPath::Root => self.get_lib_root(),
TidalPath::Playlists => {
let mut node = crabidy_core::proto::crabidy::LibraryNode { let mut node = crabidy_core::proto::crabidy::LibraryNode {
path: path.to_string(), uuid: "node:userplaylists".to_string(),
title: "playlists".to_string(), title: "playlists".to_string(),
parent: Some(parent), parent: Some("node:tidal".to_string()),
tracks: Vec::new(), tracks: Vec::new(),
children: Vec::new(), children: Vec::new(),
is_queable: false, is_queable: false,
@ -144,251 +126,106 @@ impl crabidy_core::ProviderClient for Client {
.get_users_playlists_and_favorite_playlists(&user_id) .get_users_playlists_and_favorite_playlists(&user_id)
.await? .await?
{ {
node.children let child = crabidy_core::proto::crabidy::LibraryNodeChild::new(
.push(crabidy_core::proto::crabidy::LibraryNodeChild::new( format!("node:playlist:{}", playlist.playlist.uuid),
crabidy_core::join_path(path, &playlist.playlist.uuid), playlist.playlist.title,
playlist.playlist.title, true,
true, );
)); node.children.push(child);
} }
node node
} }
TidalPath::Playlist(playlist_id) => { "playlist" => {
let playlist = self.get_playlist(playlist_id).await?; let mut node: crabidy_core::proto::crabidy::LibraryNode =
let tracks = self self.get_playlist(&uuid).await?.into();
.get_playlist_tracks(playlist_id) let tracks: Vec<crabidy_core::proto::crabidy::Track> = self
.get_playlist_tracks(&uuid)
.await? .await?
.iter() .iter()
.map(|t| t.to_proto(path)) .map(|t| t.into())
.collect(); .collect();
crabidy_core::proto::crabidy::LibraryNode { node.tracks = tracks;
path: path.to_string(), node.parent = Some("node:userplaylists".to_string());
title: playlist.title, node
parent: Some(parent),
tracks,
children: Vec::new(),
is_queable: true,
}
} }
TidalPath::Artists => { "userartists" => {
let mut node = crabidy_core::proto::crabidy::LibraryNode { let mut node = crabidy_core::proto::crabidy::LibraryNode {
path: path.to_string(), uuid: "node:userartists".to_string(),
title: "artists".to_string(), title: "artists".to_string(),
parent: Some(parent), parent: Some("node:tidal".to_string()),
tracks: Vec::new(), tracks: Vec::new(),
children: Vec::new(), children: Vec::new(),
is_queable: false, is_queable: false,
}; };
for artist in self.get_users_artists(&user_id).await? { for artist in self.get_users_artists(&user_id).await? {
node.children let child = crabidy_core::proto::crabidy::LibraryNodeChild::new(
.push(crabidy_core::proto::crabidy::LibraryNodeChild::new( format!("node:artist:{}", artist.item.id),
crabidy_core::join_path(path, &artist.item.id.to_string()), artist.item.name,
artist.item.name, true,
true, );
)); node.children.push(child);
} }
node node
} }
TidalPath::Artist(artist_id) => { "artist" => {
let artist = self.get_artist(artist_id).await?; info!("artist");
let children = self let mut node: crabidy_core::proto::crabidy::LibraryNode =
.get_artist_albums(artist_id) self.get_artist(&uuid).await?.into();
let children: Vec<crabidy_core::proto::crabidy::LibraryNodeChild> = self
.get_artist_albums(&uuid)
.await? .await?
.iter() .iter()
.map(|album| { .map(|t| t.into())
crabidy_core::proto::crabidy::LibraryNodeChild::new(
crabidy_core::join_path(path, &album.id.to_string()),
album.title.clone(),
true,
)
})
.collect(); .collect();
crabidy_core::proto::crabidy::LibraryNode { node.children = children;
path: path.to_string(), node.parent = Some("node:userartists".to_string());
title: artist.name, node
parent: Some(parent),
tracks: Vec::new(),
children,
is_queable: true,
}
} }
TidalPath::Album { album, .. } => { "album" => {
let album_data = self.get_album(album).await?; let album = self.get_album(&uuid).await?;
let tracks = self let artis_id = album.artist.clone().unwrap().id;
.get_album_tracks(album) let mut node: crabidy_core::proto::crabidy::LibraryNode = album.into();
let tracks: Vec<crabidy_core::proto::crabidy::Track> = self
.get_album_tracks(&uuid)
.await? .await?
.iter() .iter()
.map(|t| t.to_proto(path)) .map(|t| t.into())
.collect(); .collect();
crabidy_core::proto::crabidy::LibraryNode { node.tracks = tracks;
path: path.to_string(), node.parent = Some(format!("node:artist:{}", artis_id));
title: album_data.title, node
parent: Some(parent),
tracks,
children: Vec::new(),
is_queable: true,
}
}
TidalPath::PlaylistTrack { .. } | TidalPath::AlbumTrack { .. } => {
warn!(path, "get_lib_node called with a track path");
return Err(crabidy_core::ProviderError::MalformedPath);
} }
_ => return Err(crabidy_core::ProviderError::MalformedUuid),
}; };
Ok(node) Ok(node)
} }
} }
/// The root of this provider in the global library tree. #[instrument]
pub const PROVIDER_ROOT: &str = "/tidal"; fn split_uuid(uuid: &str) -> (String, String, String) {
let mut split = uuid.splitn(3, ':');
/// A parsed tidal library path. The position in the tree is fully encoded (
/// in the path itself. split.next().unwrap_or("").to_string(),
#[derive(Debug, Clone, Copy, PartialEq, Eq)] split.next().unwrap_or("").to_string(),
pub enum TidalPath<'a> { split.next().unwrap_or("").to_string(),
Root, )
Playlists,
Playlist(&'a str),
PlaylistTrack {
playlist: &'a str,
track: &'a str,
},
Artists,
Artist(&'a str),
Album {
artist: &'a str,
album: &'a str,
},
AlbumTrack {
artist: &'a str,
album: &'a str,
track: &'a str,
},
}
pub fn parse_path(path: &str) -> Result<TidalPath<'_>, crabidy_core::ProviderError> {
let segments = crabidy_core::path_segments(path);
match segments.as_slice() {
["tidal"] => Ok(TidalPath::Root),
["tidal", "playlists"] => Ok(TidalPath::Playlists),
["tidal", "playlists", playlist] => Ok(TidalPath::Playlist(playlist)),
["tidal", "playlists", playlist, track] => Ok(TidalPath::PlaylistTrack { playlist, track }),
["tidal", "artists"] => Ok(TidalPath::Artists),
["tidal", "artists", artist] => Ok(TidalPath::Artist(artist)),
["tidal", "artists", artist, album] => Ok(TidalPath::Album { artist, album }),
["tidal", "artists", artist, album, track] => Ok(TidalPath::AlbumTrack {
artist,
album,
track,
}),
_ => {
warn!(path, "malformed tidal path");
Err(crabidy_core::ProviderError::MalformedPath)
}
}
}
fn track_id_from_path(path: &str) -> Result<&str, crabidy_core::ProviderError> {
match parse_path(path)? {
TidalPath::PlaylistTrack { track, .. } | TidalPath::AlbumTrack { track, .. } => Ok(track),
_ => {
warn!(path, "expected a track path");
Err(crabidy_core::ProviderError::MalformedPath)
}
}
} }
impl Client { impl Client {
pub fn new(settings: config::Settings) -> Result<Self, ClientError> { pub fn new(settings: config::Settings) -> Result<Self, ClientError> {
let http_client = HttpClient::builder() let http_client = HttpClient::builder()
.user_agent("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36 Edg/91.0.864.59") .user_agent("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36 Edg/91.0.864.59")
.timeout(std::time::Duration::from_secs(30))
.build()?; .build()?;
let login = std::sync::RwLock::new(settings.login.clone());
Ok(Self { Ok(Self {
http_client, http_client,
settings, settings,
login,
}) })
} }
/// A consistent copy of the current login state.
fn login_snapshot(&self) -> config::LoginConfig {
match self.login.read() {
Ok(login) => login.clone(),
Err(poisoned) => poisoned.into_inner().clone(),
}
}
fn store_refresh(&self, refresh: RefreshResponse) {
let now = chrono::Utc::now().timestamp() as u64;
let mut login = match self.login.write() {
Ok(login) => login,
Err(poisoned) => poisoned.into_inner(),
};
login.expires_after = Some(now + refresh.expires_in);
login.access_token = Some(refresh.access_token);
if let Some(refresh_token) = refresh.refresh_token {
login.refresh_token = Some(refresh_token);
}
}
/// Refreshes the access token unconditionally and stores the result.
async fn force_refresh_token(&self) -> Result<(), ClientError> {
let refresh = self.refresh_access_token().await?;
self.store_refresh(refresh);
info!("access token refreshed");
Ok(())
}
/// Refreshes the access token if it is expired or about to expire.
/// Without this, long-running sessions ended up with an expired token
/// and every track fetch failed, silently stopping playback.
async fn ensure_fresh_token(&self) -> Result<(), ClientError> {
let login = self.login_snapshot();
let Some(expires_after) = login.expires_after else {
return Ok(());
};
let now = chrono::Utc::now().timestamp() as u64;
if now + TOKEN_REFRESH_MARGIN_SECS < expires_after {
return Ok(());
}
info!("access token expired or expiring soon");
self.force_refresh_token().await
}
/// Performs an authenticated GET against the hifi API.
async fn authed_get(
&self,
uri: &str,
query: Option<&[(&str, String)]>,
) -> Result<reqwest::Response, ClientError> {
let login = self.login_snapshot();
let Some(access_token) = login.access_token else {
return Err(ClientError::AuthError("No access token found".to_string()));
};
let Some(country_code) = login.country_code else {
return Err(ClientError::AuthError("No country code found".to_string()));
};
let mut params: Vec<(&str, String)> = vec![("countryCode", country_code)];
if let Some(query) = query {
params.extend(query.iter().cloned());
}
self.http_client
.get(format!("{}/{}", self.settings.hifi_url, uri))
.bearer_auth(access_token)
.query(&params)
.send()
.await
.map_err(|e| {
warn!(uri, "tidal api request failed: {e}");
ClientError::from(e)
})
}
#[instrument(skip(self))] #[instrument(skip(self))]
pub fn get_user_id(&self) -> Option<String> { pub fn get_user_id(&self) -> Option<String> {
self.login_snapshot().user_id self.settings.login.user_id.clone()
} }
#[instrument(skip(self))] #[instrument(skip(self))]
@ -397,24 +234,41 @@ impl Client {
uri: &str, uri: &str,
query: Option<&[(&str, String)]>, query: Option<&[(&str, String)]>,
) -> Result<T, ClientError> { ) -> Result<T, ClientError> {
trace!(uri, "make_request"); debug!("make_request {}", uri);
self.ensure_fresh_token().await?; let Some(ref access_token) = self.settings.login.access_token.clone() else {
let mut response = self.authed_get(uri, query).await?; return Err(ClientError::AuthError(
if response.status() == reqwest::StatusCode::UNAUTHORIZED { "No access token found".to_string(),
// The token may have been revoked or the clock may be off: ))
// refresh once and retry (GETs are idempotent). };
info!(uri, "got 401, refreshing access token and retrying once"); let Some(country_code) = self.settings.login.country_code.clone() else {
self.force_refresh_token().await?; return Err(ClientError::AuthError(
response = self.authed_get(uri, query).await?; "No country code found".to_string(),
))
};
let country_param = ("countryCode", country_code);
let mut params: Vec<&(&str, String)> = vec![&country_param];
if let Some(query) = query {
params.extend(query);
} }
if !response.status().is_success() {
warn!(uri, status = %response.status(), "tidal api request failed"); let response: T = self
return Err(ClientError::ApiError(response.status().as_u16())); .http_client
} .get(format!("{}/{}", self.settings.hifi_url, uri))
response.json().await.map_err(|e| { .bearer_auth(access_token)
error!(uri, "failed to decode tidal api response: {e}"); .query(&params)
ClientError::from(e) .send()
}) .await
.map_err(|e| {
error!("{:?}", e);
e
})?
.json()
.await
.map_err(|e| {
error!("{:?}", e);
e
})?;
Ok(response)
} }
#[instrument(skip(self))] #[instrument(skip(self))]
@ -423,25 +277,72 @@ impl Client {
uri: &str, uri: &str,
query: Option<&[(&str, String)]>, query: Option<&[(&str, String)]>,
) -> Result<Vec<T>, ClientError> { ) -> Result<Vec<T>, ClientError> {
trace!(uri, "make_paginated_request"); debug!("make_paginated_request {}", uri);
let limit: usize = 50; let Some(ref access_token) = self.settings.login.access_token.clone() else {
let mut offset: usize = 0; return Err(ClientError::AuthError(
let mut items = Vec::new(); "No access token found".to_string(),
loop { ))
let mut params: Vec<(&str, String)> = };
vec![("limit", limit.to_string()), ("offset", offset.to_string())]; let Some(country_code) = self.settings.login.country_code.clone() else {
if let Some(query) = query { return Err(ClientError::AuthError(
params.extend(query.iter().cloned()); "No country code found".to_string(),
} ))
let page: Page<T> = self.make_request(uri, Some(&params)).await?; };
let fetched = page.items.len(); let country_param = ("countryCode", country_code);
items.extend(page.items); let limit = 50;
offset += fetched; let mut offset = 0;
if fetched == 0 || offset >= page.total_number_of_items { let limit_param = ("limit", limit.to_string());
break; let mut params: Vec<&(&str, String)> = vec![&country_param, &limit_param];
} if let Some(query) = query {
params.extend(query);
}
let mut response: Page<T> = self
.http_client
.get(format!("{}/{}", self.settings.hifi_url, uri))
.bearer_auth(access_token)
.query(&params)
.send()
.await
.map_err(|e| {
error!("{:?}", e);
e
})?
.json()
.await
.map_err(|e| {
error!("{:?}", e);
e
})?;
let mut items = Vec::with_capacity(response.total_number_of_items);
items.extend(response.items);
while response.offset + limit < response.total_number_of_items {
offset += limit;
let offset_param = ("offset", offset.to_string());
let mut params: Vec<&(&str, String)> =
vec![&country_param, &limit_param, &offset_param];
if let Some(query) = query {
params.extend(query);
}
response = self
.http_client
.get(format!("{}/{}", self.settings.hifi_url, uri))
.bearer_auth(access_token)
.query(&params)
.send()
.await
.map_err(|e| {
error!("{:?}", e);
e
})?
.json()
.await
.map_err(|e| {
error!("{:?}", e);
e
})?;
items.extend(response.items);
} }
debug!(uri, count = items.len(), "fetched paginated collection");
Ok(items) Ok(items)
} }
@ -451,16 +352,47 @@ impl Client {
uri: &str, uri: &str,
query: Option<&[(&str, String)]>, query: Option<&[(&str, String)]>,
) -> Result<(), ClientError> { ) -> Result<(), ClientError> {
self.ensure_fresh_token().await?; let Some(ref access_token) = self.settings.login.access_token.clone() else {
let response = self.authed_get(uri, query).await?.text().await?; return Err(ClientError::AuthError(
debug!(?response, "explorer response"); "No access token found".to_string(),
))
};
let Some(country_code) = self.settings.login.country_code.clone() else {
return Err(ClientError::AuthError(
"No country code found".to_string(),
))
};
let country_param = ("countryCode", country_code);
let mut params: Vec<&(&str, String)> = vec![&country_param];
if let Some(query) = query {
params.extend(query);
}
let response = self
.http_client
.get(format!("{}/{}", self.settings.hifi_url, uri))
.bearer_auth(access_token)
.query(&params)
.send()
.await
.map_err(|e| {
error!("{:?}", e);
e
})?
.text()
.await
.map_err(|e| {
error!("{:?}", e);
e
})?;
println!("{:?}", response);
Ok(()) Ok(())
} }
#[instrument(skip(self))] #[instrument(skip(self))]
pub async fn search(&self, query: &str) -> Result<(), ClientError> { pub async fn search(&self, query: &str) -> Result<(), ClientError> {
let query = vec![("query", query.to_string())]; let query = vec![("query", query.to_string())];
self.make_explorer_request("search/artists", Some(&query)) self.make_explorer_request(&format!("search/artists"), Some(&query))
.await?; .await?;
Ok(()) Ok(())
} }
@ -470,32 +402,37 @@ impl Client {
&self, &self,
playlist_uuid: &str, playlist_uuid: &str,
) -> Result<Vec<Track>, ClientError> { ) -> Result<Vec<Track>, ClientError> {
self.make_paginated_request(&format!("playlists/{}/tracks", playlist_uuid), None) Ok(self
.await .make_paginated_request(&format!("playlists/{}/tracks", playlist_uuid), None)
.await?)
} }
#[instrument(skip(self))] #[instrument(skip(self))]
pub async fn get_playlist(&self, playlist_uuid: &str) -> Result<Playlist, ClientError> { pub async fn get_playlist(&self, playlist_uuid: &str) -> Result<Playlist, ClientError> {
self.make_request(&format!("playlists/{}", playlist_uuid), None) Ok(self
.await .make_request(&format!("playlists/{}", playlist_uuid), None)
.await?)
} }
#[instrument(skip(self))] #[instrument(skip(self))]
pub async fn get_artist(&self, artist_uuid: &str) -> Result<Artist, ClientError> { pub async fn get_artist(&self, artist_uuid: &str) -> Result<Artist, ClientError> {
self.make_request(&format!("artists/{}", artist_uuid), None) Ok(self
.await .make_request(&format!("artists/{}", artist_uuid), None)
.await?)
} }
#[instrument(skip(self))] #[instrument(skip(self))]
pub async fn get_artist_albums(&self, artist_uuid: &str) -> Result<Vec<Album>, ClientError> { pub async fn get_artist_albums(&self, artist_uuid: &str) -> Result<Vec<Album>, ClientError> {
self.make_paginated_request(&format!("artists/{}/albums", artist_uuid), None) Ok(self
.await .make_paginated_request(&format!("artists/{}/albums", artist_uuid), None)
.await?)
} }
#[instrument(skip(self))] #[instrument(skip(self))]
pub async fn get_users_playlists(&self, user_id: u64) -> Result<Vec<Playlist>, ClientError> { pub async fn get_users_playlists(&self, user_id: u64) -> Result<Vec<Playlist>, ClientError> {
self.make_paginated_request(&format!("users/{}/playlists", user_id), None) Ok(self
.await .make_paginated_request(&format!("users/{}/playlists", user_id), None)
.await?)
} }
#[instrument(skip(self))] #[instrument(skip(self))]
@ -503,11 +440,12 @@ impl Client {
&self, &self,
user_id: &str, user_id: &str,
) -> Result<Vec<PlaylistAndFavorite>, ClientError> { ) -> Result<Vec<PlaylistAndFavorite>, ClientError> {
self.make_paginated_request( Ok(self
&format!("users/{}/playlistsAndFavoritePlaylists", user_id), .make_paginated_request(
None, &format!("users/{}/playlistsAndFavoritePlaylists", user_id),
) None,
.await )
.await?)
} }
#[instrument(skip(self))] #[instrument(skip(self))]
@ -523,12 +461,13 @@ impl Client {
#[instrument(skip(self))] #[instrument(skip(self))]
pub async fn get_users_artists(&self, user_id: &str) -> Result<Vec<ArtistItem>, ClientError> { pub async fn get_users_artists(&self, user_id: &str) -> Result<Vec<ArtistItem>, ClientError> {
self.make_paginated_request( Ok(self
&format!("users/{}/favorites/artists", user_id), .make_paginated_request(
None, &format!("users/{}/favorites/artists", user_id),
// Some(&query), None,
) // Some(&query),
.await )
.await?)
} }
#[instrument(skip(self))] #[instrument(skip(self))]
@ -570,6 +509,7 @@ impl Client {
#[instrument(skip(self))] #[instrument(skip(self))]
pub async fn get_track(&self, track_id: &str) -> Result<Track, ClientError> { pub async fn get_track(&self, track_id: &str) -> Result<Track, ClientError> {
let (_, track_id, _) = split_uuid(track_id);
self.make_request(&format!("tracks/{}", track_id), None) self.make_request(&format!("tracks/{}", track_id), None)
.await .await
} }
@ -578,13 +518,7 @@ impl Client {
pub async fn login_web(&mut self) -> Result<(), ClientError> { pub async fn login_web(&mut self) -> Result<(), ClientError> {
let code_response = self.get_device_code().await?; let code_response = self.get_device_code().await?;
let now = Instant::now(); let now = Instant::now();
// The verification link must reach the user even without a log
// subscriber configured.
println!("https://{}", code_response.verification_uri_complete); println!("https://{}", code_response.verification_uri_complete);
info!(
"waiting for device login at https://{}",
code_response.verification_uri_complete
);
while now.elapsed().as_secs() <= code_response.expires_in { while now.elapsed().as_secs() <= code_response.expires_in {
let login = self.check_auth_status(&code_response.device_code).await; let login = self.check_auth_status(&code_response.device_code).await;
if login.is_err() { if login.is_err() {
@ -594,32 +528,26 @@ impl Client {
let timestamp = chrono::Utc::now().timestamp() as u64; let timestamp = chrono::Utc::now().timestamp() as u64;
let login_results = login?; let login_results = login?;
{ self.settings.login.device_code = Some(code_response.device_code);
let mut login = match self.login.write() { self.settings.login.access_token = Some(login_results.access_token);
Ok(login) => login, self.settings.login.refresh_token = login_results.refresh_token;
Err(poisoned) => poisoned.into_inner(), self.settings.login.expires_after = Some(login_results.expires_in + timestamp);
}; self.settings.login.user_id = Some(login_results.user.user_id.to_string());
login.device_code = Some(code_response.device_code); self.settings.login.country_code = Some(login_results.user.country_code);
login.access_token = Some(login_results.access_token);
login.refresh_token = login_results.refresh_token;
login.expires_after = Some(login_results.expires_in + timestamp);
login.user_id = Some(login_results.user.user_id.to_string());
login.country_code = Some(login_results.user.country_code);
}
info!("device login succeeded");
return Ok(()); return Ok(());
} }
warn!("device login attempt expired"); println!("login attempt expired");
Err(ClientError::ConnectionError) Err(ClientError::ConnectionError)
} }
#[instrument(skip(self))] #[instrument(skip(self))]
pub async fn login_config(&mut self) -> Result<(), ClientError> { pub async fn login_config(&mut self) -> Result<(), ClientError> {
let login = self.login_snapshot(); let Some(access_token) = self.settings.login.access_token.clone() else {
let Some(access_token) = login.access_token else { return Err(ClientError::AuthError(
return Err(ClientError::AuthError("No access token found".to_string())); "No access token found".to_string(),
))
}; };
// Return if our session is still valid. //return if our session is still valid
if self if self
.http_client .http_client
.get(format!("{}/sessions", self.settings.base_url)) .get(format!("{}/sessions", self.settings.base_url))
@ -627,25 +555,31 @@ impl Client {
.send() .send()
.await .await
.map_err(|e| { .map_err(|e| {
warn!("session check failed: {e}"); error!("{:?}", e);
e e
})? })?
.status() .status()
.is_success() .is_success()
{ {
debug!("existing session still valid");
return Ok(()); return Ok(());
} }
// Otherwise refresh our token. //otherwise refresh our token
self.force_refresh_token().await let refresh = self.refresh_access_token().await?;
let now = chrono::Utc::now().timestamp() as u64;
self.settings.login.expires_after = Some(refresh.expires_in + now);
self.settings.login.access_token = Some(refresh.access_token);
Ok(())
} }
#[instrument(skip(self))] #[instrument(skip(self))]
pub async fn refresh_access_token(&self) -> Result<RefreshResponse, ClientError> { pub async fn refresh_access_token(&self) -> Result<RefreshResponse, ClientError> {
let Some(refresh_token) = self.login_snapshot().refresh_token else { let Some(refresh_token) = self.settings.login.refresh_token.clone() else {
return Err(ClientError::AuthError("No refresh token found".to_string())); return Err(ClientError::AuthError(
}; "No refresh token found".to_string(),
))
};
let data = DeviceAuthRequest { let data = DeviceAuthRequest {
client_id: self.settings.oauth.client_id.clone(), client_id: self.settings.oauth.client_id.clone(),
client_secret: Some(self.settings.oauth.client_secret.clone()), client_secret: Some(self.settings.oauth.client_secret.clone()),
@ -691,7 +625,7 @@ impl Client {
.http_client .http_client
.post(format!( .post(format!(
"{}/device_authorization", "{}/device_authorization",
self.settings.oauth.base_url &self.settings.oauth.base_url
)) ))
.header("Content-Type", "application/x-www-form-urlencoded") .header("Content-Type", "application/x-www-form-urlencoded")
.body(payload) .body(payload)
@ -767,7 +701,6 @@ mod tests {
} }
#[tokio::test] #[tokio::test]
#[ignore = "requires a local tidal config and network access"]
async fn test() { async fn test() {
let client = setup().await; let client = setup().await;
let user = client.settings.login.user_id.clone().unwrap(); let user = client.settings.login.user_id.clone().unwrap();
@ -777,5 +710,6 @@ mod tests {
println!("{:?}", result); println!("{:?}", result);
let result = client.get_album("244167550").await.unwrap(); let result = client.get_album("244167550").await.unwrap();
println!("{:?}", result); println!("{:?}", result);
assert!(false);
} }
} }

View File

@ -1,6 +1,6 @@
use std::{str::FromStr, string::FromUtf8Error}; use std::{str::FromStr, string::FromUtf8Error};
use base64::Engine as _; use crabidy_core::proto::crabidy::{LibraryNode, LibraryNodeChild};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use serde_json::Value; use serde_json::Value;
use thiserror::Error; use thiserror::Error;
@ -21,6 +21,19 @@ pub struct ArtistItem {
pub item: Artist, pub item: Artist,
} }
impl From<ArtistItem> for LibraryNode {
fn from(item: ArtistItem) -> Self {
Self {
uuid: format!("artist:{}", item.item.id),
title: item.item.name,
children: Vec::new(),
parent: None,
tracks: Vec::new(),
is_queable: true,
}
}
}
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)] #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct Artist { pub struct Artist {
@ -34,6 +47,29 @@ pub struct Artist {
pub mixes: Option<ArtistMixes>, pub mixes: Option<ArtistMixes>,
} }
impl From<Artist> for LibraryNode {
fn from(artist: Artist) -> Self {
Self {
uuid: format!("node:artist:{}", artist.id),
title: artist.name,
children: Vec::new(),
parent: None,
tracks: Vec::new(),
is_queable: true,
}
}
}
impl From<Artist> for LibraryNodeChild {
fn from(artist: Artist) -> Self {
Self {
uuid: format!("node:artist:{}", artist.id),
title: artist.name,
is_queable: true,
}
}
}
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)] #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct ArtistRole { pub struct ArtistRole {
@ -51,8 +87,6 @@ pub enum ClientError {
SerdeUrlError(#[from] serde_urlencoded::ser::Error), SerdeUrlError(#[from] serde_urlencoded::ser::Error),
#[error("authentication failed")] #[error("authentication failed")]
AuthError(String), AuthError(String),
#[error("tidal api returned status {0}")]
ApiError(u16),
#[error("base64 decoding failed")] #[error("base64 decoding failed")]
Base64DecodeError(#[from] base64::DecodeError), Base64DecodeError(#[from] base64::DecodeError),
#[error("utf8 decoding failed")] #[error("utf8 decoding failed")]
@ -67,7 +101,6 @@ impl From<ClientError> for crabidy_core::ProviderError {
ClientError::ConnectionError => Self::FetchError, ClientError::ConnectionError => Self::FetchError,
ClientError::HttpClientError(_) => Self::FetchError, ClientError::HttpClientError(_) => Self::FetchError,
ClientError::SerdeUrlError(_) => Self::FetchError, ClientError::SerdeUrlError(_) => Self::FetchError,
ClientError::ApiError(_) => Self::FetchError,
_ => Self::Other, _ => Self::Other,
} }
} }
@ -196,24 +229,36 @@ pub struct Track {
pub album: Option<Album>, pub album: Option<Album>,
pub mixes: Option<TrackMixes>, pub mixes: Option<TrackMixes>,
} }
impl From<Track> for crabidy_core::proto::crabidy::Track {
impl Track { fn from(track: Track) -> Self {
/// Converts to the wire representation, placing the track under the Self {
/// given parent node path. uuid: format!("track:{}", track.id),
pub fn to_proto(&self, parent_path: &str) -> crabidy_core::proto::crabidy::Track { title: track.title,
crabidy_core::proto::crabidy::Track { artist: match track.artist {
path: crabidy_core::join_path(parent_path, &self.id.to_string()), Some(a) => a.name.clone(),
title: self.title.clone(), None => "".to_string(),
artist: self },
.artist album: track.album.map(|a| a.into()),
.as_ref() duration: track.duration.map(|d| d as u32 * 1000),
.map(|a| a.name.clone())
.unwrap_or_default(),
album: self.album.clone().map(|a| a.into()),
duration: self.duration.map(|d| d as u32 * 1000),
} }
} }
} }
impl From<&Track> for crabidy_core::proto::crabidy::Track {
fn from(track: &Track) -> Self {
Self {
uuid: format!("track:{}", track.id),
title: track.title.clone(),
artist: match track.artist.as_ref() {
Some(a) => a.name.clone(),
None => "".to_string(),
},
album: track.album.clone().map(|a| a.into()),
duration: track.duration.map(|d| d as u32 * 1000),
}
}
}
// #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)] // #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
// #[serde(rename_all = "camelCase")] // #[serde(rename_all = "camelCase")]
// pub struct Artist { // pub struct Artist {
@ -310,6 +355,39 @@ pub struct Album {
pub artists: Option<Vec<Artist>>, pub artists: Option<Vec<Artist>>,
} }
impl From<Album> for crabidy_core::proto::crabidy::LibraryNode {
fn from(album: Album) -> Self {
Self {
uuid: format!("node:album:{}", album.id),
title: album.title,
children: Vec::new(),
parent: None,
tracks: Vec::new(),
is_queable: true,
}
}
}
impl From<Album> for crabidy_core::proto::crabidy::LibraryNodeChild {
fn from(album: Album) -> Self {
Self {
uuid: format!("node:album:{}", album.id),
title: album.title,
is_queable: true,
}
}
}
impl From<&Album> for crabidy_core::proto::crabidy::LibraryNodeChild {
fn from(album: &Album) -> Self {
Self {
uuid: format!("node:album:{}", album.id),
title: album.title.clone(),
is_queable: true,
}
}
}
impl From<Album> for crabidy_core::proto::crabidy::Album { impl From<Album> for crabidy_core::proto::crabidy::Album {
fn from(album: Album) -> Self { fn from(album: Album) -> Self {
Self { Self {
@ -354,7 +432,7 @@ pub struct PlaybackManifest {
impl FromStr for PlaybackManifest { impl FromStr for PlaybackManifest {
type Err = ClientError; type Err = ClientError;
fn from_str(input: &str) -> Result<PlaybackManifest, Self::Err> { fn from_str(input: &str) -> Result<PlaybackManifest, Self::Err> {
let decode = base64::engine::general_purpose::STANDARD.decode(input)?; let decode = base64::decode(input)?;
let json = String::from_utf8(decode)?; let json = String::from_utf8(decode)?;
let parsed: PlaybackManifest = serde_json::from_str(&json)?; let parsed: PlaybackManifest = serde_json::from_str(&json)?;
Ok(parsed) Ok(parsed)
@ -399,6 +477,12 @@ pub struct PlaylistAndFavorite {
pub playlist: Playlist, pub playlist: Playlist,
} }
impl From<PlaylistAndFavorite> for crabidy_core::proto::crabidy::LibraryNode {
fn from(a: PlaylistAndFavorite) -> Self {
a.playlist.into()
}
}
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)] #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct Playlist { pub struct Playlist {
@ -422,6 +506,19 @@ pub struct Playlist {
pub last_item_added_at: Option<String>, pub last_item_added_at: Option<String>,
} }
impl From<Playlist> for crabidy_core::proto::crabidy::LibraryNode {
fn from(a: Playlist) -> Self {
crabidy_core::proto::crabidy::LibraryNode {
title: a.title,
uuid: format!("node:playlist:{}", a.uuid),
tracks: Vec::new(),
parent: None,
children: Vec::new(),
is_queable: true,
}
}
}
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)] #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct Creator { pub struct Creator {