Compare commits

..

6 Commits

Author SHA1 Message Date
AI User 21e1b10c0b Drop redundant log-to-tracing bridge init
tracing-subscriber .init() already installs the LogTracer via its
default tracing-log feature; the explicit init only produced a startup
warning. Remove it and the direct tracing-log dependency.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 22:01:22 +02:00
AI User d504ebc85f Fix intermittent playback stops and harden the queue
Root causes found and fixed:

- QueueManager could panic and kill the playback task permanently:
  is_last_track() underflowed on an empty queue, remove_tracks accepted
  pos == len (Vec::remove panic) and corrupted positions when removing
  multiple tracks (indices shifted mid-loop), shuffle_behind indexed
  out of range on an empty play order, insert_tracks shifted play-order
  entries by the queue length instead of the inserted count and then
  assert!()ed on the resulting inconsistency, and clear() left
  play_order stale. All mutation methods are now guarded, multi-remove
  works highest-position-first, and an inconsistent play order is
  rebuilt instead of panicking. Regression tests cover these cases.

- The tidal access token was only obtained at startup and never
  refreshed, so long-running sessions ended with every track fetch
  failing (playback just stopped at the next track boundary). Login
  state now lives behind a lock; tokens are refreshed proactively
  before expiry (5 min margin) and once reactively on a 401, and all
  API responses are status-checked (new ClientError::ApiError) instead
  of being fed to the JSON decoder blind. The http client also got a
  30s timeout so a hung connection cannot wedge the provider loop.

- (from the rodio rewrite, same bug class) end of stream used to be
  detected by string-comparing an io::Error message; any other decode
  or network error ended the stream silently without an EndOfStream
  message, so playback never advanced. EOS is now a guaranteed
  callback with a generation counter.

Plus workspace-wide clippy cleanup (zero warnings), cargo-machete
cleanup, and fmt.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 22:00:22 +02:00
AI User 56bc0b0d04 Refactor provider addressing to filesystem-like paths
Identifiers like node:tidal / node:playlist:<id> / track:<id> are
replaced by absolute, hierarchical paths that encode the position in
the library tree:

  /                                  global root
  /tidal                             provider root
  /tidal/playlists/<id>              playlist (tracks inside)
  /tidal/playlists/<id>/<track>      track
  /tidal/artists/<id>/<album>        album
  /tidal/artists/<id>/<album>/<t>    track

- proto: uuid -> path, uuids -> paths (same field tags, wire
  compatible); crabidy-core gains ROOT_PATH, parent_path, join_path,
  path_segments helpers with unit tests
- ProviderClient gains is_track_path; the orchestrator routes by path
  prefix and exposes a single ResolveTracks command (track path ->
  that track, node path -> flattened subtree), replacing the
  track:-prefix sniffing in the playback loop
- tidaldy parses paths into a typed TidalPath enum; node parents are
  derived from the request path, which removes the album.artist
  unwrap() panic; the network-dependent scratch test is #[ignore]d
- TUI navigates by paths

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 21:53:12 +02:00
AI User 6d8bc7f166 Overhaul tracing: fix span misattribution, broaden coverage
The old pattern passed a Span in every channel message and entered it
with a guard that was held across await points, which misattributed
events from interleaved tasks. Messages are now {span, command} pairs:
the span is captured automatically at send time (Span::current) and the
consumer instruments the whole handler future with a child span
(playback_command/provider_command with a command name field), so events
are attributed correctly across the queue boundary and all the manual
in_current_span() plumbing is gone.

Also:
- server: EnvFilter with RUST_LOG support (default: own crates at
  debug, rest at info), log-crate bridge for symphonia/cpal
- cbd-tui: logs to a file under the state dir (the terminal belongs to
  the TUI), EnvFilter, no more println into the alternate screen
- tidaldy: fix misused levels (error->debug), structured fields,
  payload dumps moved to trace, login flow at info/warn
- no panic on missing notification daemon in the TUI
- no panic on backwards clock steps in QueueManager
- provider init errors propagate instead of expect()

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 21:48:00 +02:00
AI User 6eb5a87b15 Update all dependencies to current versions
- tonic 0.9 -> 0.14 (tonic-prost/tonic-prost-build split), prost 0.14
- ratatui 0.20 -> 0.30 (Frame no longer generic, Line instead of Spans),
  crossterm 0.29
- rodio 0.17 -> 0.22: replace the custom symphonia decoder with rodio's
  built-in decoder, seeking (try_seek) and position tracking (get_pos);
  end-of-stream is now signalled via an EmptyCallback source with a
  generation counter so a replaced track can never emit a stale EOS
- replace the vendored stream-download crate with the published
  stream-download 0.24 (rustls), with a 30s open timeout
- reqwest 0.12->0.13 (rustls/webpki-roots/query features), base64 0.22
  Engine API, rand 0.10, flume 0.12, thiserror 2, dirs 6, toml 1
- unify everything under [workspace.dependencies]; drop unused deps
  (once_cell, serde_json in server; confique, secrecy in tidaldy)
- devenv: add protobuf (protoc) for prost-build

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 21:41:10 +02:00
AI User 91716daf84 Add devenv-based dev environment and README
Adds devenv.nix (with alsa-lib and pkg-config for rodio/cpal builds),
rust-toolchain.toml pinning stable, and updated .gitignore.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 21:29:23 +02:00
47 changed files with 5126 additions and 4306 deletions

24
.gitignore vendored
View File

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

4208
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

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

1
README.md Normal file
View File

@ -0,0 +1 @@
# crabidy

View File

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

View File

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

View File

@ -1,318 +0,0 @@
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,7 +1,5 @@
mod decoder;
mod player; mod player;
mod player_engine; mod player_engine;
pub use decoder::MediaInfo;
pub use player::{Player, PlayerError}; pub use player::{Player, PlayerError};
pub use player_engine::PlayerMessage; pub use player_engine::{MediaInfo, PlayerMessage};

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,21 +1,22 @@
[package] [package]
name = "crabidy-core" name = "crabidy-core"
version = "0.1.0" version.workspace = true
edition = "2021" edition.workspace = true
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies] [dependencies]
async-trait = "0.1.68" async-trait.workspace = true
clap = "4.3.3" clap-serde-derive.workspace = true
clap-serde-derive = "0.2.0" dirs.workspace = true
dirs = "5.0.1" prost.workspace = true
prost = "0.11" serde.workspace = true
serde = "1.0.163" toml.workspace = true
toml = "0.7.4" tonic.workspace = true
tonic = "0.9" tonic-prost.workspace = true
[build-dependencies] [build-dependencies]
async-trait = "0.1.68" tonic-prost-build.workspace = true
serde = "1.0.163"
tonic-build = "0.9" # 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"]

View File

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

View File

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

View File

@ -10,16 +10,24 @@ use proto::crabidy::{LibraryNode, LibraryNodeChild, Track};
pub mod proto; pub mod proto;
/// A media provider addressed like a file system.
///
/// Every node and track has a `/`-separated absolute path whose first
/// segment names the provider, e.g. `/tidal/playlists/<id>/<track-id>`.
/// The path encodes the position in the library tree: ancestors are
/// obtained by trimming trailing segments.
#[async_trait] #[async_trait]
pub trait ProviderClient: std::fmt::Debug + Send + Sync { pub trait ProviderClient: std::fmt::Debug + Send + Sync {
async fn init(raw_toml_settings: &str) -> Result<Self, ProviderError> async fn init(raw_toml_settings: &str) -> Result<Self, ProviderError>
where where
Self: Sized; Self: Sized;
fn settings(&self) -> String; fn settings(&self) -> String;
async fn get_urls_for_track(&self, track_uuid: &str) -> Result<Vec<String>, ProviderError>; /// Whether the path addresses a single track (as opposed to a node).
async fn get_metadata_for_track(&self, track_uuid: &str) -> Result<Track, ProviderError>; fn is_track_path(&self, path: &str) -> bool;
async fn get_urls_for_track(&self, track_path: &str) -> Result<Vec<String>, ProviderError>;
async fn get_metadata_for_track(&self, track_path: &str) -> Result<Track, ProviderError>;
fn get_lib_root(&self) -> LibraryNode; fn get_lib_root(&self) -> LibraryNode;
async fn get_lib_node(&self, list_uuid: &str) -> Result<LibraryNode, ProviderError>; async fn get_lib_node(&self, path: &str) -> Result<LibraryNode, ProviderError>;
} }
#[derive(Clone, Debug, Hash)] #[derive(Clone, Debug, Hash)]
@ -28,7 +36,7 @@ pub enum ProviderError {
UnknownUser, UnknownUser,
CouldNotLogin, CouldNotLogin,
FetchError, FetchError,
MalformedUuid, MalformedPath,
InternalError, InternalError,
Other, Other,
} }
@ -39,10 +47,41 @@ impl std::fmt::Display for ProviderError {
} }
} }
impl std::error::Error for ProviderError {}
/// The path of the global library root.
pub const ROOT_PATH: &str = "/";
/// Returns the parent path, or `None` when at the root.
///
/// `/tidal/playlists/abc` -> `/tidal/playlists` -> `/tidal` -> `/`.
pub fn parent_path(path: &str) -> Option<&str> {
let trimmed = path.trim_end_matches('/');
if trimmed.is_empty() {
return None;
}
match trimmed.rfind('/') {
Some(0) => Some(ROOT_PATH),
Some(idx) => Some(&trimmed[..idx]),
None => None,
}
}
/// Appends a segment to a path.
pub fn join_path(base: &str, segment: &str) -> String {
let base = base.trim_end_matches('/');
format!("{base}/{segment}")
}
/// Splits a path into its segments: `/tidal/playlists/x` -> ["tidal", "playlists", "x"].
pub fn path_segments(path: &str) -> Vec<&str> {
path.split('/').filter(|s| !s.is_empty()).collect()
}
impl LibraryNode { impl LibraryNode {
pub fn new() -> Self { pub fn new() -> Self {
Self { Self {
uuid: "node:/".to_string(), path: ROOT_PATH.to_string(),
title: "/".to_string(), title: "/".to_string(),
children: Vec::new(), children: Vec::new(),
parent: None, parent: None,
@ -53,9 +92,9 @@ impl LibraryNode {
} }
impl LibraryNodeChild { impl LibraryNodeChild {
pub fn new(uuid: String, title: String, is_queable: bool) -> Self { pub fn new(path: String, title: String, is_queable: bool) -> Self {
Self { Self {
uuid, path,
title, title,
is_queable, is_queable,
} }
@ -94,3 +133,34 @@ where
} }
T::default().merge_clap() T::default().merge_clap()
} }
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parent_path_walks_up_to_root() {
assert_eq!(
parent_path("/tidal/playlists/abc"),
Some("/tidal/playlists")
);
assert_eq!(parent_path("/tidal/playlists"), Some("/tidal"));
assert_eq!(parent_path("/tidal"), Some("/"));
assert_eq!(parent_path("/"), None);
}
#[test]
fn join_path_appends_segments() {
assert_eq!(join_path("/", "tidal"), "/tidal");
assert_eq!(join_path("/tidal", "playlists"), "/tidal/playlists");
}
#[test]
fn path_segments_splits() {
assert_eq!(path_segments("/"), Vec::<&str>::new());
assert_eq!(
path_segments("/tidal/artists/1/2"),
vec!["tidal", "artists", "1", "2"]
);
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

61
devenv-rust.nix Normal file
View File

@ -0,0 +1,61 @@
{ 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;
};
}

141
devenv.lock Normal file
View File

@ -0,0 +1,141 @@
{
"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
}

73
devenv.nix Normal file
View File

@ -0,0 +1,73 @@
{
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/
}

16
devenv.yaml Normal file
View File

@ -0,0 +1,16 @@
# 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

2
rust-toolchain.toml Normal file
View File

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

View File

@ -1,10 +0,0 @@
# 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

@ -1,32 +0,0 @@
[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"] }

View File

@ -1,21 +0,0 @@
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

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

View File

@ -1,18 +0,0 @@
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

@ -1,17 +0,0 @@
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

@ -1,18 +0,0 @@
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

@ -1,81 +0,0 @@
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");
}
}

View File

@ -1,166 +0,0 @@
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

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

View File

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

View File

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

View File

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