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
# 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]
resolver = "2"
members = [
"audio-player",
"cbd-tui",
"crabidy-core",
"crabidy-server",
"stream-download",
"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]
name = "audio-player"
version.workspace = true
edition.workspace = true
version = "0.1.0"
edition = "2021"
[dependencies]
anyhow.workspace = true
flume.workspace = true
rodio.workspace = true
stream-download.workspace = true
thiserror.workspace = true
tokio = { workspace = true, features = ["rt", "rt-multi-thread"] }
tracing.workspace = true
url.workspace = true
rodio = { version = "0.17.1", default-features = false, features = [
"symphonia-all",
] }
symphonia = { version = "0.5.3", features = ["all"] }
stream-download = { path = "../stream-download" }
anyhow = "1.0.71"
url = "2.4.0"
flume = "0.10.14"
thiserror = "1.0.40"
tracing = "0.1.37"
[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};
#[tokio::main]
@ -11,10 +13,7 @@ async fn main() {
loop {
match player.messages.recv_async().await {
Ok(PlayerMessage::Elapsed {
duration: _,
elapsed,
}) => {
Ok(PlayerMessage::Elapsed { duration, elapsed }) => {
println!("ELAPSED: {:?}", elapsed);
}
Ok(PlayerMessage::EndOfStream) => {
@ -31,10 +30,7 @@ async fn main() {
loop {
match player.messages.recv_async().await {
Ok(PlayerMessage::Elapsed {
duration: _,
elapsed,
}) => {
Ok(PlayerMessage::Elapsed { duration, elapsed }) => {
println!("ELAPSED: {:?}", elapsed);
}
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_engine;
pub use decoder::MediaInfo;
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 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 {}
@ -16,24 +20,82 @@ pub struct Player {
impl Default for Player {
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>) =
flume::bounded(16);
flume::bounded(10);
let tx_callbacks = 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();
let tx_decoder = tx_engine.clone();
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) => {
error!("Could not initialize player: {}", e);
return;
}
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 {
@ -47,96 +109,74 @@ impl Player {
pub async fn play(&self, source_str: &str) -> Result<MediaInfo> {
let (tx, rx) = flume::bounded(1);
self.tx_engine
.send_async(PlayerEngineCommand::Play(source_str.to_string(), tx))
.await?;
.send(PlayerEngineCommand::Play(source_str.to_string(), tx))?;
rx.recv_async().await?
}
pub async fn restart(&self) -> Result<MediaInfo> {
let (tx, rx) = flume::bounded(1);
self.tx_engine
.send_async(PlayerEngineCommand::Restart(tx))
.await?;
self.tx_engine.send(PlayerEngineCommand::Restart(tx))?;
rx.recv_async().await?
}
pub async fn elapsed(&self) -> Result<Duration> {
pub async fn elpased(&self) -> Result<Duration> {
let (tx, rx) = flume::bounded(1);
self.tx_engine
.send_async(PlayerEngineCommand::GetElapsed(tx))
.await?;
self.tx_engine.send(PlayerEngineCommand::GetElapsed(tx))?;
rx.recv_async().await?
}
pub async fn duration(&self) -> Result<Duration> {
let (tx, rx) = flume::bounded(1);
self.tx_engine
.send_async(PlayerEngineCommand::GetDuration(tx))
.await?;
self.tx_engine.send(PlayerEngineCommand::GetDuration(tx))?;
rx.recv_async().await?
}
pub async fn seek_to(&self, time: Duration) -> Result<Duration> {
let (tx, rx) = flume::bounded(1);
self.tx_engine
.send_async(PlayerEngineCommand::SeekTo(time, tx))
.await?;
self.tx_engine.send(PlayerEngineCommand::SeekTo(time, tx))?;
rx.recv_async().await?
}
pub async fn volume(&self) -> Result<f32> {
let (tx, rx) = flume::bounded(1);
self.tx_engine
.send_async(PlayerEngineCommand::GetVolume(tx))
.await?;
self.tx_engine.send(PlayerEngineCommand::GetVolume(tx))?;
Ok(rx.recv_async().await?)
}
pub async fn is_paused(&self) -> Result<bool> {
let (tx, rx) = flume::bounded(1);
self.tx_engine
.send_async(PlayerEngineCommand::GetPaused(tx))
.await?;
self.tx_engine.send(PlayerEngineCommand::GetPaused(tx))?;
rx.recv_async().await?
}
pub async fn set_volume(&self, volume: f32) -> Result<f32> {
let (tx, rx) = flume::bounded(1);
self.tx_engine
.send_async(PlayerEngineCommand::SetVolume(volume, tx))
.await?;
.send(PlayerEngineCommand::SetVolume(volume, tx))?;
Ok(rx.recv_async().await?)
}
pub async fn pause(&self) -> Result<()> {
let (tx, rx) = flume::bounded(1);
self.tx_engine
.send_async(PlayerEngineCommand::Pause(tx))
.await?;
self.tx_engine.send(PlayerEngineCommand::Pause(tx))?;
rx.recv_async().await?
}
pub async fn unpause(&self) -> Result<()> {
let (tx, rx) = flume::bounded(1);
self.tx_engine
.send_async(PlayerEngineCommand::Unpause(tx))
.await?;
self.tx_engine.send(PlayerEngineCommand::Unpause(tx))?;
rx.recv_async().await?
}
pub async fn toggle_play(&self) -> Result<bool> {
let (tx, rx) = flume::bounded(1);
self.tx_engine
.send_async(PlayerEngineCommand::TogglePlay(tx))
.await?;
self.tx_engine.send(PlayerEngineCommand::TogglePlay(tx))?;
rx.recv_async().await?
}
pub async fn stop(&self) -> Result<()> {
let (tx, rx) = flume::bounded(1);
self.tx_engine
.send_async(PlayerEngineCommand::Stop(tx))
.await?;
self.tx_engine.send(PlayerEngineCommand::Stop(tx))?;
rx.recv_async().await?
}
}

View File

@ -1,23 +1,19 @@
use std::fs::File;
use std::io::BufReader;
use flume::Sender;
use std::path::Path;
use std::sync::atomic::AtomicU64;
use std::thread;
use std::time::Duration;
use anyhow::{anyhow, Context, Result};
use flume::{Receiver, RecvTimeoutError, Sender};
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 std::{fs::File, sync::atomic::Ordering};
use symphonia::core::probe::Hint;
use tracing::{debug, warn};
use url::Url;
/// How long we wait for the initial prefetch of a network stream.
const STREAM_OPEN_TIMEOUT: Duration = Duration::from_secs(30);
/// Interval between elapsed-position updates while playing.
const TICK_INTERVAL: Duration = Duration::from_millis(250);
use crate::decoder::{MediaInfo, SymphoniaDecoder};
use anyhow::{anyhow, Result};
use rodio::{OutputStream, OutputStreamHandle, Sink, Source};
use stream_download::StreamDownload;
use symphonia::core::io::{MediaSource, MediaSourceStream, MediaSourceStreamOptions};
use thiserror::Error;
pub enum PlayerEngineCommand {
Play(String, Sender<Result<MediaInfo>>),
@ -32,10 +28,8 @@ pub enum PlayerEngineCommand {
SeekTo(Duration, Sender<Result<Duration>>),
GetVolume(Sender<f32>),
GetPaused(Sender<Result<bool>>),
/// End of stream for the source started by the given generation.
/// Stale generations are ignored so an old track finishing can never
/// interfere with a newly started one.
Eos(u64),
Eos,
SetElapsed(Duration),
}
pub enum PlayerMessage {
@ -52,10 +46,8 @@ pub enum PlayerMessage {
EndOfStream,
}
#[derive(Clone, Debug)]
pub struct MediaInfo {
pub duration: Option<Duration>,
}
// TODO:
// * Emit buffering
#[derive(Debug, Error)]
pub enum PlayerEngineError {
@ -63,195 +55,85 @@ pub enum PlayerEngineError {
NotPlaying,
}
// Used for seeking in the stream
static SEEK_TO: AtomicU64 = AtomicU64::new(0);
pub struct PlayerEngine {
elapsed: Duration,
current_source: Option<String>,
media_info: Option<MediaInfo>,
/// Monotonically increasing id for the currently playing source. Used to
/// discard end-of-stream callbacks from sources that were replaced.
generation: u64,
sink: rodio::Player,
// We need to keep the device sink around; audio stops when it's dropped.
_stream: MixerDeviceSink,
sink: Sink,
// We need to keep the stream around as it will stop playing when it's dropped
_stream: OutputStream,
_handle: OutputStreamHandle,
tx_engine: Sender<PlayerEngineCommand>,
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 {
pub fn init(
tx_engine: Sender<PlayerEngineCommand>,
tx_player: Sender<PlayerMessage>,
runtime: Option<tokio::runtime::Handle>,
) -> Result<Self> {
let stream =
DeviceSinkBuilder::open_default_sink().context("failed to open audio output device")?;
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");
let (_stream, handle) = OutputStream::try_default()?;
let sink = Sink::try_new(&handle)?;
Ok(Self {
current_source: None,
media_info: None,
generation: 0,
elapsed: Duration::default(),
sink,
_stream: stream,
_stream,
_handle: handle,
tx_engine,
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> {
let tx_player = self.tx_player.clone();
let tx_engine = self.tx_engine.clone();
self.reset();
let duration = self.start_source(source_str)?;
let media_info = MediaInfo { duration };
let (source, hint) = self.get_source(source_str)?;
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.notify(PlayerMessage::Duration {
duration: duration.unwrap_or_default(),
tx_player
.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.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
/// 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)
Ok(media_info_copy)
}
pub fn restart(&mut self) -> Result<MediaInfo> {
@ -266,7 +148,9 @@ impl PlayerEngine {
return Err(PlayerEngineError::NotPlaying.into());
}
self.sink.pause();
self.notify(PlayerMessage::Paused);
self.tx_player
.send(PlayerMessage::Paused)
.unwrap_or_else(|e| warn!("Send error {}", e));
Ok(())
}
@ -275,7 +159,9 @@ impl PlayerEngine {
return Err(PlayerEngineError::NotPlaying.into());
}
self.sink.play();
self.notify(PlayerMessage::Playing);
self.tx_player
.send(PlayerMessage::Playing)
.unwrap_or_else(|e| warn!("Send error {}", e));
Ok(())
}
@ -285,11 +171,9 @@ impl PlayerEngine {
}
if self.sink.is_paused() {
self.sink.play();
self.notify(PlayerMessage::Playing);
Ok(true)
} else {
self.sink.pause();
self.notify(PlayerMessage::Paused);
Ok(false)
}
}
@ -299,7 +183,9 @@ impl PlayerEngine {
return Err(PlayerEngineError::NotPlaying.into());
}
self.reset();
self.notify(PlayerMessage::Stopped);
self.tx_player
.send(PlayerMessage::Stopped)
.unwrap_or_else(|e| warn!("Send error {}", e));
Ok(())
}
@ -326,16 +212,18 @@ impl PlayerEngine {
if self.is_stopped() {
return Err(PlayerEngineError::NotPlaying.into());
}
Ok(self.sink.get_pos())
Ok(self.elapsed)
}
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);
self.sink
.try_seek(time)
.map_err(|err| anyhow!("seek failed: {err}"))?;
Ok(self.sink.get_pos())
SEEK_TO.store(time.as_secs(), Ordering::SeqCst);
// FIXME: ideally we would like to return once the seeking is successful
// then return the current elapsed time
// Cond-var might be needed to sleep this (seeking takes time)
Ok(time)
}
pub fn volume(&self) -> f32 {
@ -347,36 +235,54 @@ impl PlayerEngine {
self.sink.volume()
}
fn handle_eos(&mut self, generation: u64) {
if generation != self.generation {
debug!(
stale = generation,
current = self.generation,
"ignoring end-of-stream from replaced source"
);
return;
}
debug!("end of stream");
pub fn handle_eos(&mut self) {
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) {
self.elapsed = Duration::default();
self.current_source = None;
self.media_info = None;
self.generation += 1;
self.sink.pause();
self.sink.stop();
}
fn notify(&self, message: PlayerMessage) {
self.tx_player
.send(message)
.unwrap_or_else(|e| warn!("Send error {}", e));
fn get_source(&self, source_str: &str) -> Result<(Box<dyn MediaSource>, Hint)> {
match Url::parse(source_str) {
Ok(url) => {
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);
Ok((Box::new(reader), hint))
} else {
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 send_reply<T>(tx: Sender<T>, value: T) {
if tx.send(value).is_err() {
warn!("player engine reply receiver dropped");
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]
name = "cbd-tui"
version.workspace = true
edition.workspace = true
version = "0.1.0"
edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
crabidy-core.workspace = true
crossterm.workspace = true
dirs.workspace = true
flume.workspace = true
notify-rust.workspace = true
ratatui.workspace = true
serde.workspace = true
tokio = { workspace = true, features = ["full"] }
tokio-stream.workspace = true
tonic.workspace = true
tracing.workspace = true
tracing-appender.workspace = true
tracing-subscriber.workspace = true
crossterm = "0.26.1"
crabidy-core = { path = "../crabidy-core" }
flume = "0.10.14"
ratatui = "0.20.1"
tokio = { version = "1", features = ["full"] }
tokio-stream = "0.1"
tonic = "0.9"
notify-rust = "4.8.0"
serde = "1.0.164"

View File

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

View File

@ -5,6 +5,7 @@ mod queue;
use flume::Sender;
use ratatui::{
backend::Backend,
layout::{Constraint, Direction, Layout},
style::Color,
Frame,
@ -33,7 +34,7 @@ enum UiItemKind {
}
struct UiItem {
path: String,
uuid: String,
title: String,
kind: UiItemKind,
marked: bool,
@ -104,8 +105,8 @@ impl App {
};
}
pub fn render(&mut self, f: &mut Frame) {
let _full_screen = f.area();
pub fn render<B: Backend>(&mut self, f: &mut Frame<B>) {
let full_screen = f.size();
let library_focused = matches!(self.focus, UiFocus::Library);
let queue_focused = matches!(self.focus, UiFocus::Queue);
@ -113,7 +114,7 @@ impl App {
let main = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Percentage(50), Constraint::Percentage(50)].as_ref())
.split(f.area());
.split(f.size());
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 ratatui::{
backend::Backend,
layout::{Alignment, Constraint, Direction, Layout, Rect},
style::{Color, Modifier, Style},
text::{Line, Span},
text::{Span, Spans},
widgets::{Block, BorderType, Borders, LineGauge, Paragraph, Wrap},
Frame,
};
@ -56,22 +57,19 @@ impl NowPlaying {
} else {
format!("{} by {}", track.title, track.artist,)
};
// A missing notification daemon must not crash the TUI.
if let Err(err) = Notification::new()
Notification::new()
.summary("Now playing")
.body(&body)
.show()
{
tracing::debug!("could not show desktop notification: {err}");
}
.unwrap();
}
self.track = active;
}
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()
.direction(Direction::Vertical)
.constraints([Constraint::Max(8), Constraint::Max(1)])
@ -93,9 +91,9 @@ impl NowPlaying {
self.modifiers.shuffle, self.modifiers.repeat
);
vec![
Line::from(Span::raw(mods)),
Line::from(Span::raw(play_text)),
Line::from(vec![
Spans::from(Span::raw(mods)),
Spans::from(Span::raw(play_text)),
Spans::from(vec![
Span::styled(
track.title.to_string(),
Style::default().add_modifier(Modifier::BOLD),
@ -106,13 +104,13 @@ impl NowPlaying {
Style::default().add_modifier(Modifier::BOLD),
),
]),
Line::from(Span::raw(album_text)),
Spans::from(Span::raw(album_text)),
]
} else {
vec![
Line::from(Span::raw("")),
Line::from(Span::raw("")),
Line::from(Span::raw("No track playing")),
Spans::from(Span::raw("")),
Spans::from(Span::raw("")),
Spans::from(Span::raw("No track playing")),
]
};
@ -129,7 +127,7 @@ impl NowPlaying {
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)
{
let pos = position.as_secs();
@ -151,7 +149,7 @@ impl NowPlaying {
let progress = LineGauge::default()
.label("")
.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);
f.render_widget(progress, elapsed_layout[0]);
@ -175,7 +173,7 @@ impl NowPlaying {
};
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]);
}
}

View File

@ -1,5 +1,6 @@
use flume::Sender;
use ratatui::{
backend::Backend,
layout::Rect,
style::{Modifier, Style},
text::Span,
@ -30,14 +31,14 @@ impl Queue {
}
}
pub fn play_next(&self) {
let _ = self.tx.send(MessageFromUi::NextTrack);
self.tx.send(MessageFromUi::NextTrack);
}
pub fn play_prev(&self) {
let _ = self.tx.send(MessageFromUi::PrevTrack);
self.tx.send(MessageFromUi::PrevTrack);
}
pub fn play_selected(&self) {
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) {
@ -46,7 +47,7 @@ impl Queue {
pub fn remove_track(&mut self) {
if let Some(pos) = self.selected() {
// 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) {
@ -57,8 +58,9 @@ impl Queue {
self.list = queue
.tracks
.iter()
.map(|t| UiItem {
path: t.path.clone(),
.enumerate()
.map(|(i, t)| UiItem {
uuid: t.uuid.clone(),
title: format!("{} - {}", t.artist, t.title),
kind: UiItemKind::Track,
marked: false,
@ -69,7 +71,7 @@ impl Queue {
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
.list
.iter()

View File

@ -27,45 +27,11 @@ use tokio_stream::StreamExt;
use app::{App, MessageFromUi, MessageToUi, StatefulList, UiFocus};
use config::Config;
use rpc::RpcClient;
use tracing::{error, info, warn};
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]
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 (ui_tx, rx): (Sender<MessageFromUi>, Receiver<MessageFromUi>) = flume::unbounded();
@ -82,24 +48,22 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
Ok(())
}
async fn orchestrate(
async fn orchestrate<'a>(
config: &'static Config,
(tx, rx): (Sender<MessageToUi>, Receiver<MessageFromUi>),
) -> Result<(), Box<dyn Error>> {
info!(address = config.server.address, "connecting to server");
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()))?;
}
let init_data = rpc_client.init().await?;
info!("received initial state from server");
tx.send_async(MessageToUi::Init(init_data)).await?;
loop {
if let Err(err) = poll(&mut rpc_client, &rx, &tx).await {
error!("request to server failed: {err}");
if let Err(er) = poll(&mut rpc_client, &rx, &tx).await {
println!("ERROR");
}
}
}
@ -112,9 +76,9 @@ async fn poll(
select! {
Ok(msg) = &mut rx.recv_async() => {
match msg {
MessageFromUi::GetLibraryNode(path) => {
if let Some(node) = rpc_client.get_library_node(&path).await? {
let _ = tx.send(MessageToUi::ReplaceLibraryNode(node.clone()));
MessageFromUi::GetLibraryNode(uuid) => {
if let Some(node) = rpc_client.get_library_node(&uuid).await? {
tx.send(MessageToUi::ReplaceLibraryNode(node.clone()));
}
},
MessageFromUi::AppendTracks(uuids) => {
@ -171,10 +135,8 @@ async fn poll(
tx.send_async(MessageToUi::Update(update)).await?;
}
}
Err(err) => {
warn!("update stream broke, reconnecting: {err}");
Err(_) => {
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.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);
}
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::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);
}
}
@ -241,10 +203,7 @@ fn run_ui(tx: Sender<MessageFromUi>, rx: Receiver<MessageToUi>) {
}
}
if let Err(err) = terminal.draw(|f| app.render(f)) {
error!("failed to draw frame: {err}");
break;
}
terminal.draw(|f| app.render(f));
let timeout = tick_rate
.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::Char(' ')) => {
let _ = tx.send(MessageFromUi::TogglePlay);
tx.send(MessageFromUi::TogglePlay);
}
(_, KeyModifiers::NONE, KeyCode::Char('r')) => {
let _ = tx.send(MessageFromUi::RestartTrack);
tx.send(MessageFromUi::RestartTrack);
}
(_, KeyModifiers::SHIFT, KeyCode::Char('J')) => {
let _ = tx.send(MessageFromUi::ChangeVolume(-0.1));
tx.send(MessageFromUi::ChangeVolume(-0.1));
}
(_, KeyModifiers::SHIFT, KeyCode::Char('K')) => {
let _ = tx.send(MessageFromUi::ChangeVolume(0.1));
tx.send(MessageFromUi::ChangeVolume(0.1));
}
(_, KeyModifiers::NONE, KeyCode::Char('m')) => {
let _ = tx.send(MessageFromUi::ToggleMute);
tx.send(MessageFromUi::ToggleMute);
}
(_, KeyModifiers::NONE, KeyCode::Char('z')) => {
let _ = tx.send(MessageFromUi::ToggleShuffle);
tx.send(MessageFromUi::ToggleShuffle);
}
(_, KeyModifiers::NONE, KeyCode::Char('x')) => {
let _ = tx.send(MessageFromUi::ToggleRepeat);
tx.send(MessageFromUi::ToggleRepeat);
}
(_, KeyModifiers::CONTROL, KeyCode::Char('n')) => {
app.queue.play_next();
@ -354,10 +313,10 @@ fn run_ui(tx: Sender<MessageFromUi>, rx: Receiver<MessageToUi>) {
app.queue.remove_track();
}
(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')) => {
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(
&mut self,
path: &str,
uuid: &str,
) -> Result<Option<&LibraryNode>, Box<dyn Error>> {
if self.library_node_cache.contains_key(path) {
return Ok(self.library_node_cache.get(path));
if self.library_node_cache.contains_key(uuid) {
return Ok(self.library_node_cache.get(uuid));
}
let get_library_node_request = Request::new(GetLibraryNodeRequest {
path: path.to_string(),
uuid: uuid.to_string(),
});
let response = self
.client
@ -89,31 +89,31 @@ impl RpcClient {
.await?;
if let Some(library_node) = response.into_inner().node {
self.library_node_cache
.insert(path.to_string(), library_node);
return Ok(self.library_node_cache.get(path));
.insert(uuid.to_string(), library_node);
return Ok(self.library_node_cache.get(uuid));
}
Err(Box::new(RpcClientError::NotFound))
}
pub async fn append_tracks(&mut self, paths: Vec<String>) -> Result<(), Box<dyn Error>> {
let append_request = Request::new(AppendRequest { paths });
pub async fn append_tracks(&mut self, uuids: Vec<String>) -> Result<(), Box<dyn Error>> {
let append_request = Request::new(AppendRequest { uuids });
self.client.append(append_request).await?;
Ok(())
}
pub async fn queue_tracks(&mut self, paths: Vec<String>) -> Result<(), Box<dyn Error>> {
let queue_request = Request::new(QueueRequest { paths });
pub async fn queue_tracks(&mut self, uuids: Vec<String>) -> Result<(), Box<dyn Error>> {
let queue_request = Request::new(QueueRequest { uuids });
self.client.queue(queue_request).await?;
Ok(())
}
pub async fn insert_tracks(
&mut self,
paths: Vec<String>,
uuids: Vec<String>,
pos: usize,
) -> Result<(), Box<dyn Error>> {
let insert_request = Request::new(InsertRequest {
paths,
uuids,
position: pos as u32,
});
self.client.insert(insert_request).await?;
@ -134,8 +134,8 @@ impl RpcClient {
Ok(())
}
pub async fn replace_queue(&mut self, paths: Vec<String>) -> Result<(), Box<dyn Error>> {
let replace_request = Request::new(ReplaceRequest { paths });
pub async fn replace_queue(&mut self, uuids: Vec<String>) -> Result<(), Box<dyn Error>> {
let replace_request = Request::new(ReplaceRequest { uuids });
self.client.replace(replace_request).await?;
Ok(())
}

View File

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

View File

@ -1,4 +1,4 @@
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(())
}

View File

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

View File

@ -10,24 +10,16 @@ use proto::crabidy::{LibraryNode, LibraryNodeChild, Track};
pub mod proto;
/// A media provider addressed like a file system.
///
/// Every node and track has a `/`-separated absolute path whose first
/// segment names the provider, e.g. `/tidal/playlists/<id>/<track-id>`.
/// The path encodes the position in the library tree: ancestors are
/// obtained by trimming trailing segments.
#[async_trait]
pub trait ProviderClient: std::fmt::Debug + Send + Sync {
async fn init(raw_toml_settings: &str) -> Result<Self, ProviderError>
where
Self: Sized;
fn settings(&self) -> String;
/// Whether the path addresses a single track (as opposed to a node).
fn is_track_path(&self, path: &str) -> bool;
async fn get_urls_for_track(&self, track_path: &str) -> Result<Vec<String>, ProviderError>;
async fn get_metadata_for_track(&self, track_path: &str) -> Result<Track, ProviderError>;
async fn get_urls_for_track(&self, track_uuid: &str) -> Result<Vec<String>, ProviderError>;
async fn get_metadata_for_track(&self, track_uuid: &str) -> Result<Track, ProviderError>;
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)]
@ -36,7 +28,7 @@ pub enum ProviderError {
UnknownUser,
CouldNotLogin,
FetchError,
MalformedPath,
MalformedUuid,
InternalError,
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 {
pub fn new() -> Self {
Self {
path: ROOT_PATH.to_string(),
uuid: "node:/".to_string(),
title: "/".to_string(),
children: Vec::new(),
parent: None,
@ -92,9 +53,9 @@ impl LibraryNode {
}
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 {
path,
uuid,
title,
is_queable,
}
@ -133,34 +94,3 @@ where
}
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]
name = "crabidy-server"
version.workspace = true
edition.workspace = true
version = "0.1.0"
edition = "2021"
[[bin]]
name = "crabidy-server"
path = "src/main.rs"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
anyhow.workspace = true
async-trait.workspace = true
audio-player.workspace = true
crabidy-core.workspace = true
dirs.workspace = true
flume.workspace = true
futures.workspace = true
rand.workspace = true
tidaldy.workspace = true
tokio = { workspace = true, features = ["full"] }
tokio-stream = { workspace = true, features = ["sync"] }
tonic.workspace = true
tracing.workspace = true
tracing-appender.workspace = true
tracing-subscriber.workspace = true
anyhow = "1.0.71"
tokio = { version = "1.28.0", features = ["full"] }
tidaldy = { path = "../tidaldy" }
crabidy-core = { path = "../crabidy-core" }
audio-player = { path = "../audio-player" }
once_cell = "1.17.1"
serde_json = "1.0.96"
serde = "1.0.163"
flume = "0.10.14"
tonic = "0.9.2"
async-trait = "0.1.68"
futures = "0.3.28"
tokio-stream = { version = "0.1.14", features = ["sync"] }
dirs = "5.0.1"
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 rand::{rng, seq::SliceRandom};
use rand::{seq::SliceRandom, thread_rng};
use std::time::SystemTime;
use tracing::{debug, error};
@ -16,11 +16,10 @@ pub struct QueueManager {
impl From<QueueManager> for Queue {
fn from(queue_manager: QueueManager) -> Self {
Self {
// A clock step backwards must not panic the playback loop.
timestamp: queue_manager
.created_at
.elapsed()
.unwrap_or_default()
.expect("failed to get elapsed time")
.as_secs(),
current_position: queue_manager.current_position() as u32,
tracks: queue_manager.tracks,
@ -28,12 +27,6 @@ impl From<QueueManager> for Queue {
}
}
impl Default for QueueManager {
fn default() -> Self {
Self::new()
}
}
impl QueueManager {
pub fn new() -> Self {
Self {
@ -54,7 +47,7 @@ impl QueueManager {
}
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) {
@ -71,19 +64,15 @@ impl QueueManager {
}
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) {
if let Some(slice) = self.play_order.get_mut(..pos) {
slice.shuffle(&mut rng());
}
self.play_order[..pos].shuffle(&mut thread_rng());
}
pub fn shuffle_behind(&mut self, pos: usize) {
if let Some(slice) = self.play_order.get_mut(pos + 1..) {
slice.shuffle(&mut rng());
}
self.play_order[pos + 1..].shuffle(&mut thread_rng());
}
pub fn current_track(&self) -> Option<Track> {
@ -124,7 +113,7 @@ impl QueueManager {
pub fn prev_track(&mut self) -> Option<Track> {
if 0 < self.current_offset {
self.current_offset -= 1;
self.current_track()
Some(self.tracks[self.current_position()].clone())
} else {
None
}
@ -142,7 +131,7 @@ impl QueueManager {
else {
error!("invalid current position");
error!("queue: {:#?}", self);
return false;
return false
};
if self.shuffle {
self.play_order.swap(0, current_offset);
@ -188,37 +177,32 @@ impl QueueManager {
pub fn remove_tracks(&mut self, positions: &[u32]) -> Option<Track> {
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 {
if pos >= self.tracks.len() {
debug!(pos, len = self.tracks.len(), "ignoring out-of-range remove");
continue;
}
if pos == self.current_position() {
if (self.tracks.len() as u32) < *pos {
return None;
};
if *pos == self.current_position() as u32 {
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;
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 {
self.current_offset -= 1;
}
self.tracks.remove(pos);
self.tracks.remove(*pos as usize);
self.play_order.remove(offset);
self.play_order
.iter_mut()
.filter(|i| pos < **i)
.filter(|i| (*pos as usize) < **i)
.for_each(|i| *i -= 1);
}
if self.current_offset >= self.play_order.len() {
self.current_offset = 0;
}
if play_next {
self.current_track()
} else {
@ -231,36 +215,38 @@ impl QueueManager {
if len == 0 {
return self.replace_with_tracks(tracks);
}
let inserted = tracks.len();
let position = (position as usize).min(len - 1);
let order_additions: Vec<usize> = (len..len + inserted).collect();
let order_additions: Vec<usize> = (len..len + tracks.len()).collect();
self.play_order.extend(order_additions);
let tail: Vec<Track> = self
.tracks
.splice(position + 1.., tracks.to_vec())
.splice((position as usize + 1).., tracks.to_vec())
.collect();
self.tracks.extend(tail);
let mut changed: Vec<usize> = Vec::new();
// In shuffle mode we may already have played positions that are
// behind the insertion point; those shift by the number of inserted
// tracks.
// in shuffle mode, it might be that we played already postions which are behind
// the insertion point and which postions are shifted by the lenght of the inserted
// track
for i in self
.play_order
.iter_mut()
.take(self.current_offset)
.filter(|i| position < **i)
.filter(|i| (position as usize) < **i)
{
*i += inserted;
*i += len;
changed.push(*i);
}
// The freshly appended order entries need to swap with the shifted
// ones so every index stays unique.
if !self.shuffle {
// 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
.iter_mut()
.skip(self.current_offset)
.for_each(|i| {
if changed.contains(i) {
*i -= inserted;
*i -= len;
}
});
@ -279,126 +265,28 @@ impl QueueManager {
let current_track = self.current_track();
self.current_offset = 0;
self.tracks.clear();
self.play_order.clear();
if exclude_current {
if let Some(track) = current_track {
self.tracks.push(track);
self.play_order.push(0);
}
}
!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)]
mod tests {
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]
fn empty_queue_operations_do_not_panic() {
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);
}
fn random_delete_before() {}
#[test]
fn remove_out_of_range_is_ignored() {
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);
}
fn random_delete_track() {}
#[test]
fn remove_multiple_positions() {
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");
}
fn random_delete_after() {}
#[test]
fn remove_current_returns_successor() {
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>>());
}
fn random_select_track() {}
}

View File

@ -3,8 +3,8 @@ use crabidy_core::proto::crabidy::{
crabidy_service_server::CrabidyServiceServer, InitResponse, LibraryNode, PlayState, Track,
};
use crabidy_core::{ProviderClient, ProviderError};
use tracing::{debug, error, info, instrument, warn, Span};
use tracing_subscriber::{prelude::*, EnvFilter};
use tracing::{debug_span, error, info, instrument, level_filters, warn, Span};
use tracing_subscriber::{filter::Targets, prelude::*};
mod playback;
use playback::Playback;
@ -15,17 +15,34 @@ use rpc::RpcService;
use tonic::{transport::Server, Result};
const LISTEN_ADDR: &str = "0.0.0.0:50051";
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let _log_guard = init_tracing();
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 orchestrator = ProviderOrchestrator::init("").await.map_err(|err| {
error!("failed to init provider orchestrator: {err}");
err
})?;
let orchestrator = ProviderOrchestrator::init("")
.await
.expect("failed to init orchestrator");
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(|| {
poll_play_bus(player_msg, playback_tx);
});
info!("player message forwarder started");
info!("gstreamer bus handler started");
let crabidy_service = RpcService::new(
update_tx,
@ -47,8 +64,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
playback.run();
info!("playback started");
let addr = LISTEN_ADDR.parse()?;
info!(%addr, "grpc server listening");
let addr = "0.0.0.0:50051".parse()?;
Server::builder()
.add_service(CrabidyServiceServer::new(crabidy_service))
.serve(addr)
@ -57,210 +73,165 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
Ok(())
}
/// Installs the global tracing subscriber.
///
/// The filter honors `RUST_LOG`; without it, our own crates log at debug and
/// everything else at info. Returns the guard that flushes the non-blocking
/// writer on shutdown.
fn init_tracing() -> tracing_appender::non_blocking::WorkerGuard {
let (non_blocking, guard) = tracing_appender::non_blocking(std::io::stderr());
let env_filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| {
EnvFilter::new(
"info,crabidy_server=debug,crabidy_core=debug,tidaldy=debug,audio_player=debug",
)
});
let fmt_layer = tracing_subscriber::fmt::layer()
.with_writer(non_blocking)
.with_target(true)
.with_file(true)
.with_line_number(true);
// .init() also installs the log-to-tracing bridge for libraries that
// use the `log` crate (symphonia, cpal, ...).
tracing_subscriber::registry()
.with(env_filter)
.with(fmt_layer)
.init();
guard
}
/// Forwards player engine events into the playback message loop.
#[instrument(skip(rx, tx))]
fn poll_play_bus(rx: flume::Receiver<PlayerMessage>, tx: flume::Sender<PlaybackMessage>) {
for msg in rx.iter() {
let command = match msg {
let span = debug_span!("play-chan");
match msg {
PlayerMessage::EndOfStream => {
debug!("player reported end of stream");
PlaybackCommand::Next
if let Err(err) = tx.send(PlaybackMessage::Next { span }) {
error!("failed to send next message: {}", err);
}
PlayerMessage::Stopped => PlaybackCommand::StateChanged {
}
PlayerMessage::Stopped => {
if let Err(err) = tx.send(PlaybackMessage::StateChanged {
state: PlayState::Stopped,
},
PlayerMessage::Paused => PlaybackCommand::StateChanged {
span,
}) {
error!("failed to send stopped message: {}", err);
}
}
PlayerMessage::Paused => {
if let Err(err) = tx.send(PlaybackMessage::StateChanged {
state: PlayState::Paused,
},
PlayerMessage::Playing => PlaybackCommand::StateChanged {
span,
}) {
error!("failed to send paused message: {}", err);
}
}
PlayerMessage::Playing => {
if let Err(err) = tx.send(PlaybackMessage::StateChanged {
state: PlayState::Playing,
},
PlayerMessage::Elapsed { duration, elapsed } => PlaybackCommand::PositionChanged {
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,
},
PlayerMessage::Duration { duration } => PlaybackCommand::PositionChanged {
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,
},
};
if let Err(err) = tx.send(PlaybackMessage::new(command)) {
error!("failed to forward player message: {err}");
return;
span,
}) {
error!("failed to send duration message: {}", err);
}
}
warn!("player message channel closed");
}
/// A command for the provider orchestrator, tagged with the tracing span that
/// was current when it was sent so the handler can attribute its events to
/// the originating request.
#[derive(Debug)]
pub struct ProviderMessage {
pub span: Span,
pub command: ProviderCommand,
}
impl ProviderMessage {
pub fn new(command: ProviderCommand) -> Self {
Self {
span: Span::current(),
command,
}
}
}
#[derive(Debug)]
pub enum ProviderCommand {
pub enum ProviderMessage {
GetLibraryNode {
path: String,
uuid: String,
result_tx: flume::Sender<Result<LibraryNode, ProviderError>>,
span: Span,
},
GetTrack {
uuid: String,
result_tx: flume::Sender<Result<Track, ProviderError>>,
span: Span,
},
GetTrackUrls {
path: String,
uuid: String,
result_tx: flume::Sender<Result<Vec<String>, ProviderError>>,
span: Span,
},
/// Resolves a path into playable tracks: a track path yields that single
/// track, a node path yields all tracks reachable below it.
ResolveTracks {
path: String,
FlattenNode {
uuid: String,
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)]
pub struct PlaybackMessage {
pub span: Span,
pub command: PlaybackCommand,
}
impl PlaybackMessage {
pub fn new(command: PlaybackCommand) -> Self {
Self {
span: Span::current(),
command,
}
}
}
#[derive(Debug)]
pub enum PlaybackCommand {
pub enum PlaybackMessage {
Init {
result_tx: flume::Sender<InitResponse>,
span: Span,
},
Replace {
paths: Vec<String>,
uuids: Vec<String>,
span: Span,
},
Queue {
paths: Vec<String>,
uuids: Vec<String>,
span: Span,
},
Append {
paths: Vec<String>,
uuids: Vec<String>,
span: Span,
},
Remove {
positions: Vec<u32>,
span: Span,
},
Insert {
position: u32,
paths: Vec<String>,
uuids: Vec<String>,
span: Span,
},
Clear {
exclude_current: bool,
span: Span,
},
SetCurrent {
position: u32,
span: Span,
},
ToggleShuffle {
span: Span,
},
ToggleRepeat {
span: Span,
},
TogglePlay {
span: Span,
},
Stop {
span: Span,
},
ToggleShuffle,
ToggleRepeat,
TogglePlay,
Stop,
ChangeVolume {
delta: f32,
span: Span,
},
ToggleMute {
span: Span,
},
Next {
span: Span,
},
Prev {
span: Span,
},
RestartTrack {
span: Span,
},
ToggleMute,
Next,
Prev,
RestartTrack,
StateChanged {
state: PlayState,
span: Span,
},
VolumeChanged {
volume: f32,
span: Span,
},
MuteChanged {
muted: bool,
span: Span,
},
PositionChanged {
PostitionChanged {
duration: 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",
}
}
}

View File

@ -1,4 +1,5 @@
use crate::{PlaybackCommand, PlaybackMessage, ProviderCommand, ProviderMessage};
use crate::PlaybackMessage;
use crate::ProviderMessage;
use audio_player::Player;
use crabidy_core::proto::crabidy::QueueModifiers;
use crabidy_core::proto::crabidy::{
@ -8,7 +9,8 @@ use crabidy_core::proto::crabidy::{
use crabidy_core::ProviderError;
use crabidy_server::QueueManager;
use std::sync::Mutex;
use tracing::{debug, debug_span, error, instrument, trace, warn, Instrument};
use tracing::debug_span;
use tracing::{debug, error, instrument, trace, warn, Instrument};
pub struct Playback {
update_tx: tokio::sync::broadcast::Sender<StreamUpdate>,
@ -25,7 +27,7 @@ impl Playback {
update_tx: tokio::sync::broadcast::Sender<StreamUpdate>,
provider_tx: flume::Sender<ProviderMessage>,
) -> Self {
let (playback_tx, playback_rx) = flume::bounded(64);
let (playback_tx, playback_rx) = flume::bounded(10);
let queue = Mutex::new(QueueManager::new());
let state = Mutex::new(PlayState::Stopped);
let player = Player::default();
@ -42,41 +44,42 @@ impl Playback {
pub fn run(self) {
tokio::spawn(async move {
while let Ok(PlaybackMessage { span, command }) = self.playback_rx.recv_async().await {
// Attribute all handler events to a span that is a child of
// the span that was current when the command was sent.
let handler_span =
debug_span!(parent: &span, "playback_command", command = command.name());
self.handle_command(command).instrument(handler_span).await;
}
warn!("playback message channel closed, loop exiting");
});
}
async fn handle_command(&self, command: PlaybackCommand) {
trace!("handling playback command");
match command {
PlaybackCommand::Init { result_tx } => {
while let Ok(message) = self.playback_rx.recv_async().await {
match message {
PlaybackMessage::Init { result_tx, span } => {
let _e = span.enter();
let repeat;
let shuffle;
let response = {
let Ok(queue) = self.queue.lock() else {
error!("queue lock poisoned");
return;
error!("failed to get queue lock");
continue;
};
debug!("got queue lock");
repeat = queue.repeat;
shuffle = queue.shuffle;
let queue_track = QueueTrack {
queue_position: queue.current_position() as u32,
track: queue.current_track(),
};
trace!("queue_track {:?}", queue_track);
debug!("released queue_track lock");
let position = TrackPosition {
duration: 0,
position: 0,
};
trace!("position {:?}", position);
let play_state = {
debug!("getting play state lock");
let Ok(play_state) = self.state.lock() else {
error!("play state lock poisoned");
return;
error!("failed to get play state lock");
continue;
};
*play_state
};
trace!("play_state {:?}", play_state);
debug!("released play state lock");
InitResponse {
queue: Some(queue.clone().into()),
queue_track: Some(queue_track),
@ -84,395 +87,593 @@ impl Playback {
volume: 0.0,
mute: false,
position: Some(position),
mods: Some(QueueModifiers {
repeat: queue.repeat,
shuffle: queue.shuffle,
}),
mods: Some(QueueModifiers { repeat, shuffle }),
}
};
trace!(?response, "sending init response");
trace!("response {:?}", response);
if let Err(err) = result_tx.send(response) {
error!("failed to send init response: {err}");
error!("failed to send response: {:#?}", err);
}
}
PlaybackCommand::Replace { paths } => {
let all_tracks = self.resolve_tracks(paths).await;
debug!(count = all_tracks.len(), "replacing queue");
PlaybackMessage::Replace { uuids, span } => {
let _e = span.enter();
let mut all_tracks = Vec::new();
for uuid in uuids {
if is_track(&uuid) {
if let Ok(track) = self.get_track(&uuid).in_current_span().await {
all_tracks.push(track);
}
} else {
let tracks = self.flatten_node(&uuid).in_current_span().await;
all_tracks.extend(tracks);
}
debug!("uuid: {:?}", uuid);
}
trace!("got tracks {:?}", all_tracks);
let current = {
let Ok(mut queue) = self.queue.lock() else {
error!("queue lock poisoned");
return;
debug!("got queue lock");
continue;
};
debug!("got queue lock");
queue.replace_with_tracks(&all_tracks);
self.broadcast(StreamUpdate::Queue(queue.clone().into()));
let queue_update_tx = self.update_tx.clone();
let update = StreamUpdate::Queue(queue.clone().into());
if let Err(err) = queue_update_tx.send(update) {
trace!("{:?}", err)
};
queue.current_track()
};
self.play(current).await;
debug!("got current {:?}", current);
self.play(current).in_current_span().await;
}
PlaybackCommand::Queue { paths } => {
let all_tracks = self.resolve_tracks(paths).await;
debug!(count = all_tracks.len(), "queueing after current");
PlaybackMessage::Queue { uuids, span } => {
let _e = span.enter();
debug!("queing");
let mut all_tracks = Vec::new();
for uuid in uuids {
if is_track(&uuid) {
if let Ok(track) = self.get_track(&uuid).in_current_span().await {
all_tracks.push(track);
}
} else {
let tracks = self.flatten_node(&uuid).in_current_span().await;
all_tracks.extend(tracks);
}
}
trace!("got tracks {:?}", all_tracks);
let track = {
let Ok(mut queue) = self.queue.lock() else {
error!("queue lock poisoned");
return;
debug!("got queue lock");
continue;
};
debug!("got queue lock");
let track = queue.queue_tracks(&all_tracks);
self.broadcast(StreamUpdate::Queue(queue.clone().into()));
let queue_update_tx = self.update_tx.clone();
let update = StreamUpdate::Queue(queue.clone().into());
if let Err(err) = queue_update_tx.send(update) {
trace!("{:?}", err)
}
track
};
self.play_if_some(track).await;
debug!("que lock released");
self.play(track).in_current_span().await;
}
PlaybackCommand::Append { paths } => {
let all_tracks = self.resolve_tracks(paths).await;
debug!(count = all_tracks.len(), "appending to queue");
PlaybackMessage::Append { uuids, span } => {
let _e = span.enter();
debug!("appending");
let mut all_tracks = Vec::new();
for uuid in uuids {
if is_track(&uuid) {
if let Ok(track) = self.get_track(&uuid).in_current_span().await {
all_tracks.push(track);
}
} else {
let tracks = self.flatten_node(&uuid).in_current_span().await;
all_tracks.extend(tracks);
}
}
trace!("got tracks {:?}", all_tracks);
let track = {
let Ok(mut queue) = self.queue.lock() else {
error!("queue lock poisoned");
return;
debug!("got queue lock");
continue;
};
debug!("got queue lock");
let track = queue.append_tracks(&all_tracks);
self.broadcast(StreamUpdate::Queue(queue.clone().into()));
let queue_update_tx = self.update_tx.clone();
let update = StreamUpdate::Queue(queue.clone().into());
if let Err(err) = queue_update_tx.send(update) {
trace!("{:?}", err)
}
track
};
self.play_if_some(track).await;
debug!("queue lock released");
self.play(track).in_current_span().await;
}
PlaybackCommand::Remove { positions } => {
debug!(?positions, "removing tracks");
let (track, was_last) = {
PlaybackMessage::Remove { positions, span } => {
let _e = span.enter();
let is_last;
debug!("removing");
let track = {
let Ok(mut queue) = self.queue.lock() else {
error!("queue lock poisoned");
return;
debug!("got queue lock");
continue;
};
let was_last = queue.is_last_track();
debug!("got queue lock");
is_last = queue.is_last_track();
let track = queue.remove_tracks(&positions);
self.broadcast(StreamUpdate::Queue(queue.clone().into()));
(track, was_last)
let queue_update_tx = self.update_tx.clone();
let update = StreamUpdate::Queue(queue.clone().into());
if let Err(err) = queue_update_tx.send(update) {
trace!("{:?}", err)
};
track
};
debug!("queue lock released");
let state = {
let Ok(state) = self.state.lock() else {
error!("play state lock poisoned");
return;
error!("failed to get play state lock");
continue;
};
*state
};
if state == PlayState::Playing && track.is_some() {
// The playing track was removed: play its successor, or
// stop when it was the last one.
if was_last {
self.stop_player().await;
if state == PlayState::Playing {
if is_last {
if let Err(err) = self.player.stop().in_current_span().await {
error!("{:?}", err)
}
} else {
self.play(track).await;
self.play(track).in_current_span().await;
}
}
}
PlaybackCommand::Insert { position, paths } => {
let all_tracks = self.resolve_tracks(paths).await;
debug!(count = all_tracks.len(), position, "inserting into queue");
PlaybackMessage::Insert {
position,
uuids,
span,
} => {
let _e = span.enter();
debug!("inserting");
let mut all_tracks = Vec::new();
for uuid in uuids {
if is_track(&uuid) {
if let Ok(track) = self.get_track(&uuid).in_current_span().await {
all_tracks.push(track);
}
} else {
let tracks = self.flatten_node(&uuid).in_current_span().await;
all_tracks.extend(tracks);
}
}
trace!("got tracks {:?}", all_tracks);
let track = {
let Ok(mut queue) = self.queue.lock() else {
error!("queue lock poisoned");
return;
debug!("got queue lock");
continue;
};
debug!("got queue lock");
let track = queue.insert_tracks(position, &all_tracks);
self.broadcast(StreamUpdate::Queue(queue.clone().into()));
let queue_update_tx = self.update_tx.clone();
let update = StreamUpdate::Queue(queue.clone().into());
if let Err(err) = queue_update_tx.send(update) {
trace!("{:?}", err)
};
track
};
self.play_if_some(track).await;
debug!("queue lock released");
self.play(track).in_current_span().await;
}
PlaybackCommand::Clear { exclude_current } => {
debug!(exclude_current, "clearing queue");
PlaybackMessage::Clear {
exclude_current,
span,
} => {
let _e = span.enter();
debug!("clearing");
let should_stop = {
let Ok(mut queue) = self.queue.lock() else {
error!("queue lock poisoned");
return;
debug!("got queue lock");
continue;
};
debug!("got queue lock");
let should_stop = queue.clear(exclude_current);
self.broadcast(StreamUpdate::Queue(queue.clone().into()));
let queue_update_tx = self.update_tx.clone();
let update = StreamUpdate::Queue(queue.clone().into());
if let Err(err) = queue_update_tx.send(update) {
trace!("{:?}", err)
};
should_stop
};
debug!("queue lock released");
if should_stop {
self.stop_player().await;
if let Err(err) = self.player.stop().in_current_span().await {
error!("{:?}", err)
}
}
}
PlaybackCommand::SetCurrent { position } => {
debug!(position, "jumping to queue position");
PlaybackMessage::SetCurrent {
position: queue_position,
span,
} => {
let _e = span.enter();
debug!("setting current");
let track = {
let Ok(mut queue) = self.queue.lock() else {
error!("queue lock poisoned");
return;
debug!("got queue lock");
continue;
};
queue.set_current_position(position);
debug!("got queue lock");
queue.set_current_position(queue_position);
queue.current_track()
};
self.play(track).await;
debug!("quue lock released and got current {:?}", track);
self.play(track).in_current_span().await;
}
PlaybackCommand::ToggleShuffle => {
let (shuffle, repeat) = {
PlaybackMessage::ToggleShuffle { span } => {
let _e = span.enter();
debug!("toggling shuffle");
let shuffle;
let repeat;
{
let Ok(mut queue) = self.queue.lock() else {
error!("queue lock poisoned");
return;
debug!("got queue lock");
continue;
};
debug!("got queue lock");
repeat = queue.repeat;
if queue.shuffle {
queue.shuffle_off()
} else {
queue.shuffle_on()
}
(queue.shuffle, queue.repeat)
};
debug!(shuffle, "toggled shuffle");
self.broadcast(StreamUpdate::Mods(QueueModifiers { shuffle, repeat }));
shuffle = queue.shuffle;
}
debug!("queue lock released");
let queue_update_tx = self.update_tx.clone();
let update = StreamUpdate::Mods(QueueModifiers { shuffle, repeat });
if let Err(err) = queue_update_tx.send(update) {
trace!("{:?}", err)
}
}
PlaybackCommand::ToggleRepeat => {
let (shuffle, repeat) = {
PlaybackMessage::ToggleRepeat { span } => {
let _e = span.enter();
debug!("toggling repeat");
let shuffle;
let repeat;
{
let Ok(mut queue) = self.queue.lock() else {
error!("queue lock poisoned");
return;
debug!("got queue lock");
continue;
};
queue.repeat = !queue.repeat;
(queue.shuffle, queue.repeat)
};
debug!(repeat, "toggled repeat");
self.broadcast(StreamUpdate::Mods(QueueModifiers { shuffle, repeat }));
debug!("got queue lock");
shuffle = queue.shuffle;
if queue.repeat {
queue.repeat = false
} else {
queue.repeat = true
}
repeat = queue.repeat;
}
debug!("queue lock released");
let queue_update_tx = self.update_tx.clone();
let update = StreamUpdate::Mods(QueueModifiers { shuffle, repeat });
if let Err(err) = queue_update_tx.send(update) {
trace!("{:?}", err)
}
}
PlaybackCommand::TogglePlay => {
PlaybackMessage::TogglePlay { span } => {
let _e = span.enter();
debug!("toggling play");
{
let state = {
let Ok(state) = self.state.lock() else {
error!("play state lock poisoned");
return;
debug!("got state lock");
continue;
};
*state
};
debug!(?state, "toggling play");
debug!("got state lock");
if state == PlayState::Playing {
if let Err(err) = self.player.pause().await {
warn!("pause failed: {err:?}");
error!("{:?}", err)
}
} else if let Err(err) = self.player.unpause().await {
warn!("unpause failed: {err:?}");
error!("{:?}", err)
}
}
debug!("state lock released");
}
PlaybackMessage::Stop { span } => {
let _e = span.enter();
debug!("stopping");
if let Err(err) = self.player.stop().await {
error!("{:?}", err)
}
}
PlaybackCommand::Stop => {
debug!("stopping playback");
self.stop_player().await;
}
PlaybackCommand::ChangeVolume { delta } => {
match self.player.volume().await {
Ok(volume) => {
debug!(volume, delta, "changing volume");
PlaybackMessage::ChangeVolume { delta, span } => {
let _e = span.enter();
debug!("changing volume");
if let Ok(volume) = self.player.volume().await {
debug!("got volume {:?}", volume);
if let Err(err) = self.player.set_volume(volume + delta).await {
warn!("set_volume failed: {err:?}");
}
}
Err(err) => warn!("could not read volume: {err:?}"),
error!("{:?}", err)
};
}
PlaybackCommand::ToggleMute => {
// FIXME: implement mute in the player engine
debug!("toggle mute requested (not implemented)");
}
PlaybackCommand::Next => {
PlaybackMessage::ToggleMute { span } => {
let _e = span.enter();
debug!("toggling mute");
// let muted = self.player.is_muted();
// debug!("got muted {:?}", muted);
// self.player.set_mute(!muted);
}
PlaybackMessage::Next { span } => {
let _e = span.enter();
debug!("nexting");
let track = {
let Ok(mut queue) = self.queue.lock() else {
error!("queue lock poisoned");
return;
debug!("got queue lock");
continue;
};
debug!("got queue lock");
queue.next_track()
};
debug!(
track = track.as_ref().map(|t| t.path.as_str()),
"advancing to next track"
);
self.play_or_stop(track).await;
debug!("released queue lock and got track {:?}", track);
self.play_or_stop(track).in_current_span().await;
}
PlaybackCommand::Prev => {
PlaybackMessage::Prev { span } => {
let _e = span.enter();
debug!("preving");
let track = {
let Ok(mut queue) = self.queue.lock() else {
error!("queue lock poisoned");
return;
debug!("got queue lock");
continue;
};
debug!("got queue lock");
queue.prev_track()
};
debug!(
track = track.as_ref().map(|t| t.path.as_str()),
"going back to previous track"
);
self.play_or_stop(track).await;
debug!("released queue lock and got track {:?}", track);
self.play_or_stop(track).in_current_span().await;
}
PlaybackCommand::StateChanged { state } => {
PlaybackMessage::StateChanged { state, span } => {
let _e = span.enter();
debug!("state changed");
let play_state = {
let Ok(mut state_lock) = self.state.lock() else {
error!("play state lock poisoned");
return;
debug!("got state lock");
continue;
};
*state_lock = state;
state
};
debug!(?play_state, "player state changed");
self.broadcast(StreamUpdate::PlayState(play_state as i32));
debug!("released state lock and got play state {:?}", play_state);
let active_track_tx = self.update_tx.clone();
let update = StreamUpdate::PlayState(play_state as i32);
if let Err(err) = active_track_tx.send(update) {
trace!("{:?}", err)
};
}
PlaybackCommand::RestartTrack => {
debug!("restarting current track");
PlaybackMessage::RestartTrack { span } => {
let _e = span.enter();
debug!("restarting track");
if let Err(err) = self.player.restart().await {
warn!("restart failed: {err:?}");
error!("{:?}", err)
}
}
PlaybackCommand::VolumeChanged { volume } => {
trace!(volume, "volume changed");
self.broadcast(StreamUpdate::Volume(volume));
}
PlaybackCommand::MuteChanged { muted } => {
trace!(muted, "mute changed");
self.broadcast(StreamUpdate::Mute(muted));
}
PlaybackCommand::PositionChanged { duration, position } => {
trace!(duration, position, "position changed");
self.broadcast(StreamUpdate::Position(TrackPosition { duration, position }));
}
}
}
/// Sends an update to all connected clients. Having no subscribers is
/// normal and not an error.
fn broadcast(&self, update: StreamUpdate) {
if let Err(err) = self.update_tx.send(update) {
trace!("no update stream subscribers: {err}");
PlaybackMessage::VolumeChanged { volume, span } => {
let _e = span.enter();
trace!("volume changed");
let update_tx = self.update_tx.clone();
let update = StreamUpdate::Volume(volume);
if let Err(err) = update_tx.send(update) {
trace!("{:?}", err)
}
}
/// Resolves a mixed list of track and node paths into tracks by asking
/// the provider orchestrator.
async fn resolve_tracks(&self, paths: Vec<String>) -> Vec<Track> {
let mut all_tracks = Vec::new();
for path in paths {
let (result_tx, result_rx) = flume::bounded(1);
let message = ProviderMessage::new(ProviderCommand::ResolveTracks {
path: path.clone(),
result_tx,
PlaybackMessage::MuteChanged { muted, span } => {
let _e = span.enter();
trace!("mute changed");
let update_tx = self.update_tx.clone();
let update = StreamUpdate::Mute(muted);
if let Err(err) = update_tx.send(update) {
trace!("{:?}", err)
}
}
PlaybackMessage::PostitionChanged {
duration,
position,
span,
} => {
let _e = span.enter();
trace!("position changed");
let update_tx = self.update_tx.clone();
let update = StreamUpdate::Position(TrackPosition { duration, position });
if let Err(err) = update_tx.send(update) {
trace!("{:?}", err)
}
}
}
}
});
if let Err(err) = self.provider_tx.send_async(message).await {
error!("provider channel closed: {err}");
return all_tracks;
}
match result_rx.recv_async().await {
Ok(tracks) => {
if tracks.is_empty() {
warn!(path, "path resolved to no playable tracks");
}
all_tracks.extend(tracks);
}
Err(err) => error!(path, "provider dropped resolve_tracks reply: {err}"),
}
}
trace!(count = all_tracks.len(), "resolved tracks");
all_tracks
}
#[instrument(skip(self))]
async fn get_urls_for_track(&self, path: &str) -> Result<Vec<String>, ProviderError> {
async fn flatten_node(&self, uuid: &str) -> Vec<Track> {
debug!("flattening node");
let tx = self.provider_tx.clone();
let (result_tx, result_rx) = flume::bounded(1);
let message = ProviderMessage::new(ProviderCommand::GetTrackUrls {
path: path.to_string(),
let span = debug_span!("prov-chan");
let Ok(_) = tx.send_async(ProviderMessage::FlattenNode {
uuid: uuid.to_string(),
result_tx,
});
self.provider_tx
.send_async(message)
span,
}).in_current_span().await else {
return Vec::new();
};
let Ok(tracks) = result_rx
.recv_async()
.in_current_span()
.await else {
return Vec::new();
};
tracks
}
#[instrument(skip(self))]
async fn get_track(&self, uuid: &str) -> Result<Track, ProviderError> {
debug!("getting track");
let tx = self.provider_tx.clone();
let (result_tx, result_rx) = flume::bounded(1);
let span = tracing::trace_span!("prov-chan");
tx.send_async(ProviderMessage::GetTrack {
uuid: uuid.to_string(),
result_tx,
span,
})
.in_current_span()
.await
.map_err(|_| ProviderError::InternalError)?;
result_rx
.recv_async()
.in_current_span()
.await
.map_err(|_| ProviderError::InternalError)?
}
async fn stop_player(&self) {
if let Err(err) = self.player.stop().await {
debug!("stop had no effect: {err:?}");
}
#[instrument(skip(self))]
async fn get_urls_for_track(&self, uuid: &str) -> Result<Vec<String>, ProviderError> {
debug!("getting urls for track");
let tx = self.provider_tx.clone();
let (result_tx, result_rx) = flume::bounded(1);
let span = tracing::trace_span!("prov-chan");
tx.send_async(ProviderMessage::GetTrackUrls {
uuid: uuid.to_string(),
result_tx,
span,
})
.in_current_span()
.await
.map_err(|_| ProviderError::InternalError)?;
result_rx
.recv_async()
.in_current_span()
.await
.map_err(|_| ProviderError::InternalError)?
}
/// Plays the given track if there is one, otherwise stops the player.
#[instrument(skip(self, track), fields(track = track.as_ref().map(|t| t.path.as_str())))]
#[instrument(skip(self))]
async fn play_or_stop(&self, track: Option<Track>) {
if track.is_some() {
self.play(track).await;
} else {
self.stop_player().await;
}
}
/// Plays the given track if there is one; does nothing otherwise.
async fn play_if_some(&self, track: Option<Track>) {
if track.is_some() {
self.play(track).await;
}
}
/// Starts playback of the given track. When fetching stream URLs fails
/// the failing track is skipped and playback continues with the next
/// track in the queue.
#[instrument(skip(self, track), fields(track = track.as_ref().map(|t| t.path.as_str())))]
async fn play(&self, track: Option<Track>) {
let Some(track) = track else {
debug!("nothing to play");
return;
};
let mut path = track.path.clone();
debug!("play or stop");
if let Some(track) = track {
let mut uuid = track.uuid.clone();
let urls = loop {
match self.get_urls_for_track(&path).await {
Ok(urls) if !urls.is_empty() => break urls,
Ok(_) => warn!(path, "provider returned no stream urls, skipping track"),
Err(err) => warn!(path, "failed to fetch stream urls ({err}), skipping track"),
}
let next = {
match self.get_urls_for_track(&uuid).in_current_span().await {
Ok(urls) => break urls,
Err(err) => {
warn!("no urls found for track {:?}: {}", track.uuid, err);
uuid = {
let Ok(mut queue) = self.queue.lock() else {
error!("queue lock poisoned");
return;
debug!("got queue lock");
continue;
};
queue.next_track()
};
match next {
Some(next_track) => path = next_track.path.clone(),
None => {
error!("no playable track left in queue, stopping");
self.stop_player().await;
if let Some(track) = queue.next_track() {
track.uuid.clone()
} else {
return;
}
}
}
}
};
{
let Ok(queue) = self.queue.lock() else {
error!("queue lock poisoned");
error!("poisend queue lock");
return
};
let queue_update_tx = self.update_tx.clone();
let track = queue.current_track();
let update = StreamUpdate::QueueTrack(QueueTrack {
queue_position: queue.current_position() as u32,
track,
});
if let Err(err) = queue_update_tx.send(update) {
trace!("{:?}", err)
}
}
if let Err(err) = self.player.play(&urls[0]).await {
error!("{:?}", err)
};
} else if let Err(err) = self.player.stop().await {
error!("{:?}", err)
}
}
#[instrument(skip(self))]
async fn play(&self, track: Option<Track>) {
debug!("play");
if let Some(track) = track {
let mut uuid = track.uuid.clone();
let urls = loop {
match self.get_urls_for_track(&uuid).in_current_span().await {
Ok(urls) => break urls,
Err(err) => {
warn!("no urls found for track {:?}: {}", track.uuid, err);
uuid = {
let Ok(mut queue) = self.queue.lock() else {
debug!("poisend queue lock");
return;
};
self.broadcast(StreamUpdate::QueueTrack(QueueTrack {
if let Some(track) = queue.next_track() {
track.uuid.clone()
} else {
return;
}
}
}
}
};
{
let Ok(queue) = self.queue.lock() else {
error!("poisend queue lock");
return
};
let queue_update_tx = self.update_tx.clone();
let track = queue.current_track();
let update = StreamUpdate::QueueTrack(QueueTrack {
queue_position: queue.current_position() as u32,
track: queue.current_track(),
}));
track,
});
if let Err(err) = queue_update_tx.send(update) {
trace!("{:?}", err)
}
}
debug!(url_count = urls.len(), "starting player");
if let Err(err) = self.player.play(&urls[0]).await {
error!("player failed to start track: {err:?}");
error!("{:?}", err)
}
}
}
}
fn is_track(uuid: &str) -> bool {
uuid.starts_with("track:")
}

View File

@ -1,84 +1,88 @@
use crate::{ProviderCommand, ProviderMessage};
use crate::ProviderMessage;
use async_trait::async_trait;
use crabidy_core::{
proto::crabidy::{LibraryNode, LibraryNodeChild, Track},
ProviderClient, ProviderError,
};
use std::{fs, path::PathBuf, sync::Arc};
use tracing::{debug, debug_span, error, instrument, warn, Instrument};
use tracing::{debug, error, instrument, warn, Instrument};
#[derive(Debug)]
pub struct ProviderOrchestrator {
pub provider_tx: flume::Sender<ProviderMessage>,
provider_rx: flume::Receiver<ProviderMessage>,
// known_tracks: RwLock<HashMap<String, Track>>,
// known_nodes: RwLock<HashMap<String, LibraryNode>>,
tidal_client: Arc<tidaldy::Client>,
}
impl ProviderOrchestrator {
pub fn run(self) {
tokio::spawn(async move {
while let Ok(ProviderMessage { span, command }) = self.provider_rx.recv_async().await {
let handler_span =
debug_span!(parent: &span, "provider_command", command = command.name());
self.handle_command(command).instrument(handler_span).await;
while let Ok(msg) = self.provider_rx.recv_async().await {
match msg {
ProviderMessage::GetLibraryNode {
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))]
async fn resolve_tracks(&self, path: &str) -> Vec<Track> {
if self.is_track_path(path) {
return match self.get_metadata_for_track(path).await {
Ok(track) => vec![track],
Err(err) => {
warn!(path, "failed to resolve track: {err}");
Vec::new()
}
};
}
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;
}
async fn flatten_node(&self, node_uuid: &str) -> Vec<Track> {
let mut tracks = Vec::with_capacity(1000);
let mut nodes_to_go = Vec::with_capacity(100);
nodes_to_go.push(node_uuid.to_string());
while let Some(node_uuid) = nodes_to_go.pop() {
let Ok(node) = self.get_lib_node(&node_uuid).in_current_span().await else {
continue
};
if node.is_queable {
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
}
}
@ -91,25 +95,29 @@ impl ProviderClient for ProviderOrchestrator {
.map(|d| d.join("crabidy"))
.unwrap_or(PathBuf::from("/tmp"));
let dir_exists = tokio::fs::try_exists(&config_dir)
.in_current_span()
.await
.map_err(|e| ProviderError::Config(e.to_string()))?;
if !dir_exists {
tokio::fs::create_dir(&config_dir)
.in_current_span()
.await
.map_err(|e| ProviderError::Config(e.to_string()))?;
}
let config_file = config_dir.join("tidaly.toml");
debug!(config_file = %config_file.display(), "loading tidal config");
let raw_toml_settings = fs::read_to_string(&config_file).unwrap_or_default();
let tidal_client = Arc::new(tidaldy::Client::init(&raw_toml_settings).await.map_err(
|err| {
error!("failed to init tidal client: {err}");
err
},
)?);
let raw_toml_settings = fs::read_to_string(&config_file).unwrap_or("".to_owned());
let tidal_client = Arc::new(
tidaldy::Client::init(&raw_toml_settings)
.in_current_span()
.await
.expect("Failed to init Tidal clienta"),
);
let new_toml_config = tidal_client.settings();
if let Err(err) = tokio::fs::write(&config_file, new_toml_config).await {
error!("failed to write tidal config file: {err}");
if let Err(err) = tokio::fs::write(&config_file, new_toml_config)
.in_current_span()
.await
{
error!("Failed to write config file: {}", err);
};
let (provider_tx, provider_rx) = flume::bounded(100);
Ok(Self {
@ -118,55 +126,46 @@ impl ProviderClient for ProviderOrchestrator {
tidal_client,
})
}
#[instrument(skip(self))]
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))]
async fn get_urls_for_track(&self, track_path: &str) -> Result<Vec<String>, ProviderError> {
if track_path.starts_with("/tidal/") {
return self.tidal_client.get_urls_for_track(track_path).await;
async fn get_urls_for_track(&self, track_uuid: &str) -> Result<Vec<String>, ProviderError> {
debug!("get_urls_for_track");
self.tidal_client
.get_urls_for_track(track_uuid)
.in_current_span()
.await
}
warn!(path = track_path, "no provider owns this track path");
Err(ProviderError::MalformedPath)
}
#[instrument(skip(self))]
async fn get_metadata_for_track(&self, track_path: &str) -> Result<Track, ProviderError> {
if track_path.starts_with("/tidal/") {
return self.tidal_client.get_metadata_for_track(track_path).await;
async fn get_metadata_for_track(&self, track_uuid: &str) -> Result<Track, ProviderError> {
debug!("get_metadata_for_track");
self.tidal_client
.get_metadata_for_track(track_uuid)
.in_current_span()
.await
}
warn!(path = track_path, "no provider owns this track path");
Err(ProviderError::MalformedPath)
}
#[instrument(skip(self))]
fn get_lib_root(&self) -> LibraryNode {
debug!("get_lib_root in provider manager");
let mut root_node = LibraryNode::new();
let child =
LibraryNodeChild::new(tidaldy::PROVIDER_ROOT.to_owned(), "tidal".to_owned(), false);
let child = LibraryNodeChild::new("node:tidal".to_owned(), "tidal".to_owned(), false);
root_node.children.push(child);
root_node
}
#[instrument(skip(self))]
async fn get_lib_node(&self, path: &str) -> Result<LibraryNode, ProviderError> {
if path == crabidy_core::ROOT_PATH {
debug!("serving global library root");
async fn get_lib_node(&self, uuid: &str) -> Result<LibraryNode, ProviderError> {
debug!("get_lib_node in provider manager");
if uuid == "node:/" {
debug!("get global root");
return Ok(self.get_lib_root());
}
if path == tidaldy::PROVIDER_ROOT || path.starts_with("/tidal/") {
return self.tidal_client.get_lib_node(path).await;
if uuid == "node:tidal" {
debug!("get tidal root");
return Ok(self.tidal_client.get_lib_root());
}
warn!(path, "no provider owns this path");
Err(ProviderError::MalformedPath)
debug!("tidal node");
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::{
crabidy_service_server::CrabidyService, get_update_stream_response::Update as StreamUpdate,
AppendRequest, AppendResponse, ChangeVolumeRequest, ChangeVolumeResponse, ClearQueueRequest,
@ -10,10 +10,11 @@ use crabidy_core::proto::crabidy::{
StopResponse, ToggleMuteRequest, ToggleMuteResponse, TogglePlayRequest, TogglePlayResponse,
ToggleRepeatRequest, ToggleRepeatResponse, ToggleShuffleRequest, ToggleShuffleResponse,
};
use futures::TryStreamExt;
use std::pin::Pin;
use tokio_stream::StreamExt;
use tonic::{Request, Response, Status};
use tracing::{debug, error, instrument, trace};
use tracing::{debug, debug_span, error, instrument, trace, Instrument, Span};
#[derive(Debug)]
pub struct RpcService {
@ -24,29 +25,16 @@ pub struct RpcService {
impl RpcService {
pub fn new(
update_tx: tokio::sync::broadcast::Sender<StreamUpdate>,
update_rx: tokio::sync::broadcast::Sender<StreamUpdate>,
playback_tx: flume::Sender<PlaybackMessage>,
provider_tx: flume::Sender<ProviderMessage>,
) -> Self {
Self {
update_tx,
update_tx: update_rx,
playback_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]
@ -56,183 +44,267 @@ impl CrabidyService for RpcService {
#[instrument(skip(self, _request))]
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);
self.send_playback(PlaybackCommand::Init { result_tx })
.await?;
let response = result_rx.recv_async().await.map_err(|err| {
error!("no reply from playback loop: {err}");
Status::internal("playback loop did not reply")
let span = debug_span!("play-chan");
if let Err(err) = playback_tx
.send_async(PlaybackMessage::Init { result_tx, span })
.in_current_span()
.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))
}
#[instrument(skip(self, request), fields(path))]
#[instrument(skip(self, request), fields(uuid))]
async fn get_library_node(
&self,
request: Request<GetLibraryNodeRequest>,
) -> Result<Response<GetLibraryNodeResponse>, Status> {
let path = request.into_inner().path;
tracing::Span::current().record("path", path.as_str());
debug!("received get_library_node request");
let uuid = request.into_inner().uuid;
Span::current().record("uuid", &uuid);
debug!("Received get_library_node request");
let provider_tx = self.provider_tx.clone();
let (result_tx, result_rx) = flume::bounded(1);
self.provider_tx
.send_async(ProviderMessage::new(ProviderCommand::GetLibraryNode {
path,
let span = debug_span!("prov-chan");
provider_tx
.send_async(ProviderMessage::GetLibraryNode {
uuid,
result_tx,
}))
span,
})
.in_current_span()
.await
.map_err(|err| {
error!("provider channel closed: {err}");
Status::internal("provider unavailable")
})?;
let result = result_rx.recv_async().await.map_err(|err| {
error!("no reply from provider: {err}");
Status::internal("provider did not reply")
.map_err(|_| Status::internal("Failed to send request via channel"))?;
let result = result_rx
.recv_async()
.in_current_span()
.await
.map_err(|e| {
error!("{:?}", e);
Status::internal("Failed to receive response from provider channel")
})?;
match result {
Ok(node) => Ok(Response::new(GetLibraryNodeResponse { node: Some(node) })),
Err(err) => {
error!("get_library_node failed: {err}");
error!("{:?}", err);
Err(Status::internal(err.to_string()))
}
}
}
#[instrument(skip(self, request), fields(paths))]
#[instrument(skip(self, request), fields(uuids))]
async fn queue(
&self,
request: Request<QueueRequest>,
) -> Result<Response<QueueResponse>, Status> {
let paths = request.into_inner().paths;
tracing::Span::current().record("paths", format!("{paths:?}"));
debug!("received queue request");
self.send_playback(PlaybackCommand::Queue { paths }).await?;
Ok(Response::new(QueueResponse {}))
request: tonic::Request<QueueRequest>,
) -> std::result::Result<tonic::Response<QueueResponse>, tonic::Status> {
let uuids = request.into_inner().uuids.clone();
Span::current().record("uuids", format!("{:?}", uuids));
debug!("Received queue request");
let playback_tx = self.playback_tx.clone();
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(
&self,
request: Request<ReplaceRequest>,
) -> Result<Response<ReplaceResponse>, Status> {
let paths = request.into_inner().paths;
tracing::Span::current().record("paths", format!("{paths:?}"));
debug!("received replace request");
self.send_playback(PlaybackCommand::Replace { paths })
.await?;
Ok(Response::new(ReplaceResponse {}))
request: tonic::Request<ReplaceRequest>,
) -> std::result::Result<tonic::Response<ReplaceResponse>, tonic::Status> {
let uuids = request.into_inner().uuids.clone();
Span::current().record("uuids", format!("{:?}", uuids));
debug!("Received replace request");
let playback_tx = self.playback_tx.clone();
let span = debug_span!("play-chan");
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(
&self,
request: Request<AppendRequest>,
) -> Result<Response<AppendResponse>, Status> {
let paths = request.into_inner().paths;
tracing::Span::current().record("paths", format!("{paths:?}"));
debug!("received append request");
self.send_playback(PlaybackCommand::Append { paths })
.await?;
Ok(Response::new(AppendResponse {}))
request: tonic::Request<AppendRequest>,
) -> std::result::Result<tonic::Response<AppendResponse>, tonic::Status> {
let uuids = request.into_inner().uuids.clone();
Span::current().record("uuids", format!("{:?}", uuids));
debug!("Received append request");
let playback_tx = self.playback_tx.clone();
let span = debug_span!("play-chan");
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))]
async fn remove(
&self,
request: Request<RemoveRequest>,
) -> Result<Response<RemoveResponse>, Status> {
request: tonic::Request<RemoveRequest>,
) -> std::result::Result<tonic::Response<RemoveResponse>, tonic::Status> {
let positions = request.into_inner().positions;
tracing::Span::current().record("positions", format!("{positions:?}"));
debug!("received remove request");
self.send_playback(PlaybackCommand::Remove { positions })
.await?;
Ok(Response::new(RemoveResponse {}))
Span::current().record("positions", format!("{:?}", positions));
debug!("Received remove request");
let playback_tx = self.playback_tx.clone();
let span = debug_span!("play-chan");
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(
&self,
request: Request<InsertRequest>,
) -> Result<Response<InsertResponse>, Status> {
request: tonic::Request<InsertRequest>,
) -> std::result::Result<tonic::Response<InsertResponse>, tonic::Status> {
let req = request.into_inner();
tracing::Span::current().record("paths", format!("{:?}", req.paths));
tracing::Span::current().record("position", req.position);
debug!("received insert request");
self.send_playback(PlaybackCommand::Insert {
let uuids = req.uuids.clone();
let position = req.position;
Span::current().record("uuids", format!("{:?}", uuids));
Span::current().record("position", position);
debug!("Received insert request");
let playback_tx = self.playback_tx.clone();
let span = debug_span!("play-chan");
playback_tx
.send_async(PlaybackMessage::Insert {
position: req.position,
paths: req.paths,
uuids,
span,
})
.await?;
Ok(Response::new(InsertResponse {}))
.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))]
async fn clear_queue(
&self,
request: Request<ClearQueueRequest>,
) -> Result<Response<ClearQueueResponse>, Status> {
request: tonic::Request<ClearQueueRequest>,
) -> std::result::Result<tonic::Response<ClearQueueResponse>, tonic::Status> {
let exclude_current = request.into_inner().exclude_current;
tracing::Span::current().record("exclude_current", exclude_current);
debug!("received clear_queue request");
self.send_playback(PlaybackCommand::Clear { exclude_current })
.await?;
Ok(Response::new(ClearQueueResponse {}))
Span::current().record("exclude_current", exclude_current);
debug!("Received clear_queue request");
let playback_tx = self.playback_tx.clone();
let span = debug_span!("play-chan");
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))]
async fn set_current(
&self,
request: Request<SetCurrentRequest>,
) -> Result<Response<SetCurrentResponse>, Status> {
request: tonic::Request<SetCurrentRequest>,
) -> std::result::Result<tonic::Response<SetCurrentResponse>, tonic::Status> {
let position = request.into_inner().position;
tracing::Span::current().record("position", position);
debug!("received set_current request");
self.send_playback(PlaybackCommand::SetCurrent { position })
.await?;
Ok(Response::new(SetCurrentResponse {}))
Span::current().record("position", position);
debug!("Received set_current request");
let playback_tx = self.playback_tx.clone();
let span = debug_span!("play-chan");
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))]
async fn toggle_shuffle(
&self,
_request: Request<ToggleShuffleRequest>,
) -> Result<Response<ToggleShuffleResponse>, Status> {
debug!("received toggle_shuffle request");
self.send_playback(PlaybackCommand::ToggleShuffle).await?;
Ok(Response::new(ToggleShuffleResponse {}))
_request: tonic::Request<ToggleShuffleRequest>,
) -> std::result::Result<tonic::Response<ToggleShuffleResponse>, tonic::Status> {
debug!("Received toggle_shuffle request");
let playback_tx = self.playback_tx.clone();
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))]
async fn toggle_repeat(
&self,
_request: Request<ToggleRepeatRequest>,
) -> Result<Response<ToggleRepeatResponse>, Status> {
debug!("received toggle_repeat request");
self.send_playback(PlaybackCommand::ToggleRepeat).await?;
Ok(Response::new(ToggleRepeatResponse {}))
_request: tonic::Request<ToggleRepeatRequest>,
) -> std::result::Result<tonic::Response<ToggleRepeatResponse>, tonic::Status> {
debug!("Received toggle_repeat request");
let playback_tx = self.playback_tx.clone();
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))]
async fn get_update_stream(
&self,
_request: Request<GetUpdateStreamRequest>,
) -> Result<Response<Self::GetUpdateStreamStream>, Status> {
debug!("received get_update_stream request, subscribing client");
_request: tonic::Request<GetUpdateStreamRequest>,
) -> std::result::Result<tonic::Response<Self::GetUpdateStreamStream>, tonic::Status> {
debug!("Received get_update_stream request");
let update_rx = self.update_tx.subscribe();
let update_stream = tokio_stream::wrappers::BroadcastStream::new(update_rx);
let output_stream = update_stream.map(|update_result| {
trace!(?update_result, "forwarding update");
let output_stream = update_stream.into_stream().map(|update_result| {
trace!("Got update: {:?}", update_result);
match update_result {
Ok(update) => Ok(GetUpdateStreamResponse {
update: Some(update),
}),
Err(err) => {
// The client lagged too far behind the broadcast channel.
error!("update stream lagged: {err}");
Err(Status::data_loss("update stream lagged"))
}
Err(_) => Err(tonic::Status::new(
tonic::Code::Unknown,
"Internal channel error",
)),
}
});
@ -242,73 +314,146 @@ impl CrabidyService for RpcService {
#[instrument(skip(self, _request))]
async fn save_queue(
&self,
_request: Request<SaveQueueRequest>,
) -> Result<Response<SaveQueueResponse>, Status> {
debug!("received save_queue request (not implemented)");
Ok(Response::new(SaveQueueResponse {}))
_request: tonic::Request<SaveQueueRequest>,
) -> std::result::Result<tonic::Response<SaveQueueResponse>, tonic::Status> {
debug!("Received save_queue request");
let reply = SaveQueueResponse {};
Ok(Response::new(reply))
}
/// Playback
#[instrument(skip(self, _request))]
async fn toggle_play(
&self,
_request: Request<TogglePlayRequest>,
) -> Result<Response<TogglePlayResponse>, Status> {
debug!("received toggle_play request");
self.send_playback(PlaybackCommand::TogglePlay).await?;
Ok(Response::new(TogglePlayResponse {}))
_request: tonic::Request<TogglePlayRequest>,
) -> std::result::Result<tonic::Response<TogglePlayResponse>, tonic::Status> {
debug!("Received toggle_play request");
let playback_tx = self.playback_tx.clone();
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))]
async fn stop(&self, _request: Request<StopRequest>) -> Result<Response<StopResponse>, Status> {
debug!("received stop request");
self.send_playback(PlaybackCommand::Stop).await?;
Ok(Response::new(StopResponse {}))
async fn stop(
&self,
_request: tonic::Request<StopRequest>,
) -> 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))]
async fn change_volume(
&self,
request: Request<ChangeVolumeRequest>,
) -> Result<Response<ChangeVolumeResponse>, Status> {
request: tonic::Request<ChangeVolumeRequest>,
) -> std::result::Result<tonic::Response<ChangeVolumeResponse>, tonic::Status> {
let delta = request.into_inner().delta;
tracing::Span::current().record("delta", delta);
debug!("received change_volume request");
self.send_playback(PlaybackCommand::ChangeVolume { delta })
.await?;
Ok(Response::new(ChangeVolumeResponse {}))
Span::current().record("delta", delta);
debug!("Received change_volume request");
let playback_tx = self.playback_tx.clone();
let span = debug_span!("play-chan");
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))]
async fn toggle_mute(
&self,
_request: Request<ToggleMuteRequest>,
) -> Result<Response<ToggleMuteResponse>, Status> {
debug!("received toggle_mute request");
self.send_playback(PlaybackCommand::ToggleMute).await?;
Ok(Response::new(ToggleMuteResponse {}))
_request: tonic::Request<ToggleMuteRequest>,
) -> std::result::Result<tonic::Response<ToggleMuteResponse>, tonic::Status> {
debug!("Received toggle_mute request");
let playback_tx = self.playback_tx.clone();
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))]
async fn next(&self, _request: Request<NextRequest>) -> Result<Response<NextResponse>, Status> {
debug!("received next request");
self.send_playback(PlaybackCommand::Next).await?;
Ok(Response::new(NextResponse {}))
async fn next(
&self,
_request: tonic::Request<NextRequest>,
) -> 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))]
async fn prev(&self, _request: Request<PrevRequest>) -> Result<Response<PrevResponse>, Status> {
debug!("received prev request");
self.send_playback(PlaybackCommand::Prev).await?;
Ok(Response::new(PrevResponse {}))
async fn prev(
&self,
_request: tonic::Request<PrevRequest>,
) -> 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))]
async fn restart_track(
&self,
_request: Request<RestartTrackRequest>,
) -> Result<Response<RestartTrackResponse>, Status> {
debug!("received restart_track request");
self.send_playback(PlaybackCommand::RestartTrack).await?;
Ok(Response::new(RestartTrackResponse {}))
_request: tonic::Request<RestartTrackRequest>,
) -> std::result::Result<tonic::Response<RestartTrackResponse>, tonic::Status> {
debug!("Received restart_track request");
let playback_tx = self.playback_tx.clone();
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]
name = "tidaldy"
version.workspace = true
edition.workspace = true
version = "0.0.0"
edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
async-trait.workspace = true
base64.workspace = true
chrono.workspace = true
crabidy-core.workspace = true
reqwest.workspace = true
serde.workspace = true
serde_json.workspace = true
serde_urlencoded.workspace = true
thiserror.workspace = true
tokio = { workspace = true, features = ["time", "sync", "macros"] }
toml.workspace = true
tracing.workspace = true
async-trait = "0.1.68"
base64 = "0.21.0"
chrono = "0.4.24"
confique = "0.2.3"
crabidy-core = { path = "../crabidy-core" }
reqwest = { version = "0.11.17", features = ["json", "rustls-tls", "trust-dns"], default-features = false }
secrecy = { version = "0.8.0", features = ["serde"] }
serde = { version = "1.0.162", features = ["derive"] }
serde_json = "1.0.96"
serde_urlencoded = "0.7.1"
thiserror = "1.0.40"
tokio = { version = "1.28.1", features = ["full", "time"] }
toml = "0.7.4"
tracing = "0.1.37"
[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 thiserror::Error;
#[derive(Debug, Clone, Serialize, Deserialize)]
#[derive(Debug, Serialize, Deserialize)]
pub struct Settings {
pub base_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
/// https://github.com/MinisculeGirraffe/tdl
use reqwest::Client as HttpClient;
use serde::de::DeserializeOwned;
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 models;
use async_trait::async_trait;
@ -13,14 +15,8 @@ pub use models::*;
pub struct Client {
http_client: HttpClient,
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]
impl crabidy_core::ProviderClient for Client {
#[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) {
settings
} else {
warn!("could not parse toml settings, using defaults");
config::Settings::default()
let settings = config::Settings::default();
println!(
"could not parse toml settings: {:#?} using default settings instead: {:#?}",
raw_toml_settings, settings
);
settings
};
let mut client = Self::new(settings)?;
if client.login_config().await.is_ok() {
if let Ok(_) = client.login_config().await {
return Ok(client);
}
if client.login_web().await.is_ok() {
if let Ok(_) = client.login_web().await {
return Ok(client);
}
Err(crabidy_core::ProviderError::CouldNotLogin)
}
#[instrument(skip(self))]
fn settings(&self) -> String {
let mut settings = self.settings.clone();
settings.login = self.login_snapshot();
toml::to_string_pretty(&settings).unwrap_or_default()
toml::to_string_pretty(&self.settings).unwrap_or_default()
}
fn is_track_path(&self, path: &str) -> bool {
matches!(
parse_path(path),
Ok(TidalPath::PlaylistTrack { .. } | TidalPath::AlbumTrack { .. })
)
}
#[instrument(skip(self))]
async fn get_urls_for_track(
&self,
track_path: &str,
track_uuid: &str,
) -> Result<Vec<String>, crabidy_core::ProviderError> {
let track_id = track_id_from_path(track_path)?;
let playback = self.get_track_playback(track_id).await.map_err(|err| {
warn!(track = track_id, "failed to fetch playback info: {err}");
crabidy_core::ProviderError::FetchError
})?;
trace!(?playback, "got playback info");
let manifest = playback.get_manifest().map_err(|err| {
warn!(track = track_id, "failed to decode manifest: {err}");
crabidy_core::ProviderError::FetchError
})?;
debug!(
track = track_id,
urls = manifest.urls.len(),
"resolved stream urls"
);
debug!("get_urls_for_track {}", track_uuid);
let (_, track_uuid, _) = split_uuid(track_uuid);
let Ok(playback) = self.get_track_playback(&track_uuid).await else {
return Err(crabidy_core::ProviderError::FetchError)
};
debug!("playback {:?}", playback);
let Ok(manifest) = playback.get_manifest() else {
return Err(crabidy_core::ProviderError::FetchError)
};
debug!("manifest {:?}", manifest);
Ok(manifest.urls)
}
#[instrument(skip(self))]
async fn get_metadata_for_track(
&self,
track_path: &str,
track_uuid: &str,
) -> Result<crabidy_core::proto::crabidy::Track, crabidy_core::ProviderError> {
let track_id = track_id_from_path(track_path)?;
let track = self.get_track(track_id).await.map_err(|err| {
warn!(track = track_id, "failed to fetch track metadata: {err}");
crabidy_core::ProviderError::FetchError
})?;
let parent = crabidy_core::parent_path(track_path)
.unwrap_or(PROVIDER_ROOT)
.to_string();
Ok(track.to_proto(&parent))
debug!("get_metadata_for_track {}", track_uuid);
let Ok(track) = self.get_track(track_uuid).await else {
return Err(crabidy_core::ProviderError::FetchError)
};
Ok(track.into())
}
#[instrument(skip(self))]
fn get_lib_root(&self) -> crabidy_core::proto::crabidy::LibraryNode {
crabidy_core::proto::crabidy::LibraryNode {
path: PROVIDER_ROOT.to_string(),
title: "tidal".to_string(),
parent: Some(crabidy_core::ROOT_PATH.to_string()),
tracks: Vec::new(),
children: vec![
debug!("get_lib_root in tidaldy");
let global_root = crabidy_core::proto::crabidy::LibraryNode::new();
let children = vec![
crabidy_core::proto::crabidy::LibraryNodeChild::new(
format!("{PROVIDER_ROOT}/playlists"),
"node:userplaylists".to_string(),
"playlists".to_string(),
false,
),
crabidy_core::proto::crabidy::LibraryNodeChild::new(
format!("{PROVIDER_ROOT}/artists"),
"node:userartists".to_string(),
"artists".to_string(),
false,
),
],
];
crabidy_core::proto::crabidy::LibraryNode {
uuid: "node:tidal".to_string(),
title: "tidal".to_string(),
parent: Some(format!("{}", global_root.uuid)),
tracks: Vec::new(),
children,
is_queable: false,
}
}
@ -119,23 +104,20 @@ impl crabidy_core::ProviderClient for Client {
#[instrument(skip(self))]
async fn get_lib_node(
&self,
path: &str,
uuid: &str,
) -> Result<crabidy_core::proto::crabidy::LibraryNode, crabidy_core::ProviderError> {
let Some(user_id) = self.get_user_id() else {
return Err(crabidy_core::ProviderError::UnknownUser);
let Some(user_id) = self.settings.login.user_id.clone() else {
return Err(crabidy_core::ProviderError::UnknownUser)
};
let parsed = parse_path(path)?;
debug!(?parsed, "resolving library node");
let parent = crabidy_core::parent_path(path)
.unwrap_or(crabidy_core::ROOT_PATH)
.to_string();
let node = match parsed {
TidalPath::Root => self.get_lib_root(),
TidalPath::Playlists => {
debug!("get_lib_node in tidaldy{}", uuid);
let (_kind, module, uuid) = split_uuid(uuid);
error!("module:{},uuid: {}", module, uuid);
let node = match module.as_str() {
"userplaylists" => {
let mut node = crabidy_core::proto::crabidy::LibraryNode {
path: path.to_string(),
uuid: "node:userplaylists".to_string(),
title: "playlists".to_string(),
parent: Some(parent),
parent: Some("node:tidal".to_string()),
tracks: Vec::new(),
children: Vec::new(),
is_queable: false,
@ -144,251 +126,106 @@ impl crabidy_core::ProviderClient for Client {
.get_users_playlists_and_favorite_playlists(&user_id)
.await?
{
node.children
.push(crabidy_core::proto::crabidy::LibraryNodeChild::new(
crabidy_core::join_path(path, &playlist.playlist.uuid),
let child = crabidy_core::proto::crabidy::LibraryNodeChild::new(
format!("node:playlist:{}", playlist.playlist.uuid),
playlist.playlist.title,
true,
));
);
node.children.push(child);
}
node
}
TidalPath::Playlist(playlist_id) => {
let playlist = self.get_playlist(playlist_id).await?;
let tracks = self
.get_playlist_tracks(playlist_id)
"playlist" => {
let mut node: crabidy_core::proto::crabidy::LibraryNode =
self.get_playlist(&uuid).await?.into();
let tracks: Vec<crabidy_core::proto::crabidy::Track> = self
.get_playlist_tracks(&uuid)
.await?
.iter()
.map(|t| t.to_proto(path))
.map(|t| t.into())
.collect();
crabidy_core::proto::crabidy::LibraryNode {
path: path.to_string(),
title: playlist.title,
parent: Some(parent),
tracks,
children: Vec::new(),
is_queable: true,
node.tracks = tracks;
node.parent = Some("node:userplaylists".to_string());
node
}
}
TidalPath::Artists => {
"userartists" => {
let mut node = crabidy_core::proto::crabidy::LibraryNode {
path: path.to_string(),
uuid: "node:userartists".to_string(),
title: "artists".to_string(),
parent: Some(parent),
parent: Some("node:tidal".to_string()),
tracks: Vec::new(),
children: Vec::new(),
is_queable: false,
};
for artist in self.get_users_artists(&user_id).await? {
node.children
.push(crabidy_core::proto::crabidy::LibraryNodeChild::new(
crabidy_core::join_path(path, &artist.item.id.to_string()),
let child = crabidy_core::proto::crabidy::LibraryNodeChild::new(
format!("node:artist:{}", artist.item.id),
artist.item.name,
true,
));
);
node.children.push(child);
}
node
}
TidalPath::Artist(artist_id) => {
let artist = self.get_artist(artist_id).await?;
let children = self
.get_artist_albums(artist_id)
"artist" => {
info!("artist");
let mut node: crabidy_core::proto::crabidy::LibraryNode =
self.get_artist(&uuid).await?.into();
let children: Vec<crabidy_core::proto::crabidy::LibraryNodeChild> = self
.get_artist_albums(&uuid)
.await?
.iter()
.map(|album| {
crabidy_core::proto::crabidy::LibraryNodeChild::new(
crabidy_core::join_path(path, &album.id.to_string()),
album.title.clone(),
true,
)
})
.map(|t| t.into())
.collect();
crabidy_core::proto::crabidy::LibraryNode {
path: path.to_string(),
title: artist.name,
parent: Some(parent),
tracks: Vec::new(),
children,
is_queable: true,
node.children = children;
node.parent = Some("node:userartists".to_string());
node
}
}
TidalPath::Album { album, .. } => {
let album_data = self.get_album(album).await?;
let tracks = self
.get_album_tracks(album)
"album" => {
let album = self.get_album(&uuid).await?;
let artis_id = album.artist.clone().unwrap().id;
let mut node: crabidy_core::proto::crabidy::LibraryNode = album.into();
let tracks: Vec<crabidy_core::proto::crabidy::Track> = self
.get_album_tracks(&uuid)
.await?
.iter()
.map(|t| t.to_proto(path))
.map(|t| t.into())
.collect();
crabidy_core::proto::crabidy::LibraryNode {
path: path.to_string(),
title: album_data.title,
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);
node.tracks = tracks;
node.parent = Some(format!("node:artist:{}", artis_id));
node
}
_ => return Err(crabidy_core::ProviderError::MalformedUuid),
};
Ok(node)
}
}
/// The root of this provider in the global library tree.
pub const PROVIDER_ROOT: &str = "/tidal";
/// A parsed tidal library path. The position in the tree is fully encoded
/// in the path itself.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TidalPath<'a> {
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)
}
}
#[instrument]
fn split_uuid(uuid: &str) -> (String, String, String) {
let mut split = uuid.splitn(3, ':');
(
split.next().unwrap_or("").to_string(),
split.next().unwrap_or("").to_string(),
split.next().unwrap_or("").to_string(),
)
}
impl Client {
pub fn new(settings: config::Settings) -> Result<Self, ClientError> {
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")
.timeout(std::time::Duration::from_secs(30))
.build()?;
let login = std::sync::RwLock::new(settings.login.clone());
Ok(Self {
http_client,
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))]
pub fn get_user_id(&self) -> Option<String> {
self.login_snapshot().user_id
self.settings.login.user_id.clone()
}
#[instrument(skip(self))]
@ -397,24 +234,41 @@ impl Client {
uri: &str,
query: Option<&[(&str, String)]>,
) -> Result<T, ClientError> {
trace!(uri, "make_request");
self.ensure_fresh_token().await?;
let mut response = self.authed_get(uri, query).await?;
if response.status() == reqwest::StatusCode::UNAUTHORIZED {
// 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");
self.force_refresh_token().await?;
response = self.authed_get(uri, query).await?;
debug!("make_request {}", uri);
let Some(ref access_token) = self.settings.login.access_token.clone() else {
return Err(ClientError::AuthError(
"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);
}
if !response.status().is_success() {
warn!(uri, status = %response.status(), "tidal api request failed");
return Err(ClientError::ApiError(response.status().as_u16()));
}
response.json().await.map_err(|e| {
error!(uri, "failed to decode tidal api response: {e}");
ClientError::from(e)
})
let response: 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
})?;
Ok(response)
}
#[instrument(skip(self))]
@ -423,25 +277,72 @@ impl Client {
uri: &str,
query: Option<&[(&str, String)]>,
) -> Result<Vec<T>, ClientError> {
trace!(uri, "make_paginated_request");
let limit: usize = 50;
let mut offset: usize = 0;
let mut items = Vec::new();
loop {
let mut params: Vec<(&str, String)> =
vec![("limit", limit.to_string()), ("offset", offset.to_string())];
debug!("make_paginated_request {}", uri);
let Some(ref access_token) = self.settings.login.access_token.clone() else {
return Err(ClientError::AuthError(
"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 limit = 50;
let mut offset = 0;
let limit_param = ("limit", limit.to_string());
let mut params: Vec<&(&str, String)> = vec![&country_param, &limit_param];
if let Some(query) = query {
params.extend(query.iter().cloned());
params.extend(query);
}
let page: Page<T> = self.make_request(uri, Some(&params)).await?;
let fetched = page.items.len();
items.extend(page.items);
offset += fetched;
if fetched == 0 || offset >= page.total_number_of_items {
break;
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)
}
@ -451,16 +352,47 @@ impl Client {
uri: &str,
query: Option<&[(&str, String)]>,
) -> Result<(), ClientError> {
self.ensure_fresh_token().await?;
let response = self.authed_get(uri, query).await?.text().await?;
debug!(?response, "explorer response");
let Some(ref access_token) = self.settings.login.access_token.clone() else {
return Err(ClientError::AuthError(
"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(())
}
#[instrument(skip(self))]
pub async fn search(&self, query: &str) -> Result<(), ClientError> {
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?;
Ok(())
}
@ -470,32 +402,37 @@ impl Client {
&self,
playlist_uuid: &str,
) -> Result<Vec<Track>, ClientError> {
self.make_paginated_request(&format!("playlists/{}/tracks", playlist_uuid), None)
.await
Ok(self
.make_paginated_request(&format!("playlists/{}/tracks", playlist_uuid), None)
.await?)
}
#[instrument(skip(self))]
pub async fn get_playlist(&self, playlist_uuid: &str) -> Result<Playlist, ClientError> {
self.make_request(&format!("playlists/{}", playlist_uuid), None)
.await
Ok(self
.make_request(&format!("playlists/{}", playlist_uuid), None)
.await?)
}
#[instrument(skip(self))]
pub async fn get_artist(&self, artist_uuid: &str) -> Result<Artist, ClientError> {
self.make_request(&format!("artists/{}", artist_uuid), None)
.await
Ok(self
.make_request(&format!("artists/{}", artist_uuid), None)
.await?)
}
#[instrument(skip(self))]
pub async fn get_artist_albums(&self, artist_uuid: &str) -> Result<Vec<Album>, ClientError> {
self.make_paginated_request(&format!("artists/{}/albums", artist_uuid), None)
.await
Ok(self
.make_paginated_request(&format!("artists/{}/albums", artist_uuid), None)
.await?)
}
#[instrument(skip(self))]
pub async fn get_users_playlists(&self, user_id: u64) -> Result<Vec<Playlist>, ClientError> {
self.make_paginated_request(&format!("users/{}/playlists", user_id), None)
.await
Ok(self
.make_paginated_request(&format!("users/{}/playlists", user_id), None)
.await?)
}
#[instrument(skip(self))]
@ -503,11 +440,12 @@ impl Client {
&self,
user_id: &str,
) -> Result<Vec<PlaylistAndFavorite>, ClientError> {
self.make_paginated_request(
Ok(self
.make_paginated_request(
&format!("users/{}/playlistsAndFavoritePlaylists", user_id),
None,
)
.await
.await?)
}
#[instrument(skip(self))]
@ -523,12 +461,13 @@ impl Client {
#[instrument(skip(self))]
pub async fn get_users_artists(&self, user_id: &str) -> Result<Vec<ArtistItem>, ClientError> {
self.make_paginated_request(
Ok(self
.make_paginated_request(
&format!("users/{}/favorites/artists", user_id),
None,
// Some(&query),
)
.await
.await?)
}
#[instrument(skip(self))]
@ -570,6 +509,7 @@ impl Client {
#[instrument(skip(self))]
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)
.await
}
@ -578,13 +518,7 @@ impl Client {
pub async fn login_web(&mut self) -> Result<(), ClientError> {
let code_response = self.get_device_code().await?;
let now = Instant::now();
// The verification link must reach the user even without a log
// subscriber configured.
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 {
let login = self.check_auth_status(&code_response.device_code).await;
if login.is_err() {
@ -594,32 +528,26 @@ impl Client {
let timestamp = chrono::Utc::now().timestamp() as u64;
let login_results = login?;
{
let mut login = match self.login.write() {
Ok(login) => login,
Err(poisoned) => poisoned.into_inner(),
};
login.device_code = Some(code_response.device_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");
self.settings.login.device_code = Some(code_response.device_code);
self.settings.login.access_token = Some(login_results.access_token);
self.settings.login.refresh_token = login_results.refresh_token;
self.settings.login.expires_after = Some(login_results.expires_in + timestamp);
self.settings.login.user_id = Some(login_results.user.user_id.to_string());
self.settings.login.country_code = Some(login_results.user.country_code);
return Ok(());
}
warn!("device login attempt expired");
println!("login attempt expired");
Err(ClientError::ConnectionError)
}
#[instrument(skip(self))]
pub async fn login_config(&mut self) -> Result<(), 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(access_token) = self.settings.login.access_token.clone() else {
return Err(ClientError::AuthError(
"No access token found".to_string(),
))
};
// Return if our session is still valid.
//return if our session is still valid
if self
.http_client
.get(format!("{}/sessions", self.settings.base_url))
@ -627,24 +555,30 @@ impl Client {
.send()
.await
.map_err(|e| {
warn!("session check failed: {e}");
error!("{:?}", e);
e
})?
.status()
.is_success()
{
debug!("existing session still valid");
return Ok(());
}
// Otherwise refresh our token.
self.force_refresh_token().await
//otherwise refresh our token
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))]
pub async fn refresh_access_token(&self) -> Result<RefreshResponse, ClientError> {
let Some(refresh_token) = self.login_snapshot().refresh_token else {
return Err(ClientError::AuthError("No refresh token found".to_string()));
let Some(refresh_token) = self.settings.login.refresh_token.clone() else {
return Err(ClientError::AuthError(
"No refresh token found".to_string(),
))
};
let data = DeviceAuthRequest {
client_id: self.settings.oauth.client_id.clone(),
@ -691,7 +625,7 @@ impl Client {
.http_client
.post(format!(
"{}/device_authorization",
self.settings.oauth.base_url
&self.settings.oauth.base_url
))
.header("Content-Type", "application/x-www-form-urlencoded")
.body(payload)
@ -767,7 +701,6 @@ mod tests {
}
#[tokio::test]
#[ignore = "requires a local tidal config and network access"]
async fn test() {
let client = setup().await;
let user = client.settings.login.user_id.clone().unwrap();
@ -777,5 +710,6 @@ mod tests {
println!("{:?}", result);
let result = client.get_album("244167550").await.unwrap();
println!("{:?}", result);
assert!(false);
}
}

View File

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