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

1
README.md Normal file
View File

@ -0,0 +1 @@
# crabidy

View File

@ -1,19 +1,17 @@
[package]
name = "audio-player"
version = "0.1.0"
edition = "2021"
version.workspace = true
edition.workspace = true
[dependencies]
rodio = { version = "0.17.1", default-features = false, features = [
"symphonia-all",
] }
symphonia = { version = "0.5.3", features = ["all"] }
stream-download = { path = "../stream-download" }
anyhow = "1.0.71"
url = "2.4.0"
flume = "0.10.14"
thiserror = "1.0.40"
tracing = "0.1.37"
anyhow.workspace = true
flume.workspace = true
rodio.workspace = true
stream-download.workspace = true
thiserror.workspace = true
tokio = { workspace = true, features = ["rt", "rt-multi-thread"] }
tracing.workspace = true
url.workspace = true
[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};
#[tokio::main]
@ -13,7 +11,10 @@ async fn main() {
loop {
match player.messages.recv_async().await {
Ok(PlayerMessage::Elapsed { duration, elapsed }) => {
Ok(PlayerMessage::Elapsed {
duration: _,
elapsed,
}) => {
println!("ELAPSED: {:?}", elapsed);
}
Ok(PlayerMessage::EndOfStream) => {
@ -30,7 +31,10 @@ async fn main() {
loop {
match player.messages.recv_async().await {
Ok(PlayerMessage::Elapsed { duration, elapsed }) => {
Ok(PlayerMessage::Elapsed {
duration: _,
elapsed,
}) => {
println!("ELAPSED: {:?}", elapsed);
}
Ok(PlayerMessage::EndOfStream) => {

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_engine;
pub use decoder::MediaInfo;
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 flume::{Receiver, Sender};
use tracing::{error, warn};
use tracing::error;
use crate::decoder::MediaInfo;
use crate::player_engine::{PlayerEngine, PlayerEngineCommand, PlayerMessage};
// TODO:
// * Emit buffering
use crate::player_engine::{MediaInfo, PlayerEngine, PlayerEngineCommand, PlayerMessage};
pub enum PlayerError {}
@ -20,82 +16,24 @@ pub struct Player {
impl Default for Player {
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>) =
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 || {
let mut player = match PlayerEngine::init(tx_decoder, tx_player) {
let engine = match PlayerEngine::init(tx_callbacks, tx_player, runtime) {
Err(e) => {
error!("Could not initialize player: {}", e);
return;
}
Ok(engine) => 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);
}
}
}
engine.run(rx_engine);
});
Self {
@ -109,74 +47,96 @@ impl Player {
pub async fn play(&self, source_str: &str) -> Result<MediaInfo> {
let (tx, rx) = flume::bounded(1);
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?
}
pub async fn restart(&self) -> Result<MediaInfo> {
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?
}
pub async fn elpased(&self) -> Result<Duration> {
pub async fn elapsed(&self) -> Result<Duration> {
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?
}
pub async fn duration(&self) -> Result<Duration> {
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?
}
pub async fn seek_to(&self, time: Duration) -> Result<Duration> {
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?
}
pub async fn volume(&self) -> Result<f32> {
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?)
}
pub async fn is_paused(&self) -> Result<bool> {
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?
}
pub async fn set_volume(&self, volume: f32) -> Result<f32> {
let (tx, rx) = flume::bounded(1);
self.tx_engine
.send(PlayerEngineCommand::SetVolume(volume, tx))?;
.send_async(PlayerEngineCommand::SetVolume(volume, tx))
.await?;
Ok(rx.recv_async().await?)
}
pub async fn pause(&self) -> Result<()> {
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?
}
pub async fn unpause(&self) -> Result<()> {
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?
}
pub async fn toggle_play(&self) -> Result<bool> {
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?
}
pub async fn stop(&self) -> Result<()> {
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?
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -27,11 +27,45 @@ use tokio_stream::StreamExt;
use app::{App, MessageFromUi, MessageToUi, StatefulList, UiFocus};
use config::Config;
use rpc::RpcClient;
use tracing::{error, info, warn};
static CONFIG: OnceLock<Config> = OnceLock::new();
/// Logs to a file: the terminal is owned by the TUI, so writing log lines to
/// stdout/stderr would corrupt the interface.
fn init_tracing() -> Option<tracing_appender::non_blocking::WorkerGuard> {
use tracing_subscriber::{prelude::*, EnvFilter};
let log_dir = dirs::state_dir()
.or_else(dirs::cache_dir)
.unwrap_or_else(std::env::temp_dir)
.join("crabidy");
if let Err(err) = std::fs::create_dir_all(&log_dir) {
eprintln!(
"could not create log directory {}: {err}",
log_dir.display()
);
return None;
}
let file_appender = tracing_appender::rolling::daily(&log_dir, "cbd-tui.log");
let (non_blocking, guard) = tracing_appender::non_blocking(file_appender);
let env_filter = EnvFilter::try_from_default_env()
.unwrap_or_else(|_| EnvFilter::new("info,cbd_tui=debug,crabidy_core=debug"));
tracing_subscriber::registry()
.with(env_filter)
.with(
tracing_subscriber::fmt::layer()
.with_writer(non_blocking)
.with_ansi(false)
.with_target(true),
)
.init();
Some(guard)
}
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let _log_guard = init_tracing();
let config = CONFIG.get_or_init(|| crabidy_core::init_config("cbd-tui.toml"));
let (ui_tx, rx): (Sender<MessageFromUi>, Receiver<MessageFromUi>) = flume::unbounded();
@ -48,22 +82,24 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
Ok(())
}
async fn orchestrate<'a>(
async fn orchestrate(
config: &'static Config,
(tx, rx): (Sender<MessageToUi>, Receiver<MessageFromUi>),
) -> Result<(), Box<dyn Error>> {
info!(address = config.server.address, "connecting to server");
let mut rpc_client = rpc::RpcClient::connect(&config.server.address).await?;
if let Some(root_node) = rpc_client.get_library_node("node:/").await? {
if let Some(root_node) = rpc_client.get_library_node(crabidy_core::ROOT_PATH).await? {
tx.send(MessageToUi::ReplaceLibraryNode(root_node.clone()))?;
}
let init_data = rpc_client.init().await?;
info!("received initial state from server");
tx.send_async(MessageToUi::Init(init_data)).await?;
loop {
if let Err(er) = poll(&mut rpc_client, &rx, &tx).await {
println!("ERROR");
if let Err(err) = poll(&mut rpc_client, &rx, &tx).await {
error!("request to server failed: {err}");
}
}
}
@ -76,9 +112,9 @@ async fn poll(
select! {
Ok(msg) = &mut rx.recv_async() => {
match msg {
MessageFromUi::GetLibraryNode(uuid) => {
if let Some(node) = rpc_client.get_library_node(&uuid).await? {
tx.send(MessageToUi::ReplaceLibraryNode(node.clone()));
MessageFromUi::GetLibraryNode(path) => {
if let Some(node) = rpc_client.get_library_node(&path).await? {
let _ = tx.send(MessageToUi::ReplaceLibraryNode(node.clone()));
}
},
MessageFromUi::AppendTracks(uuids) => {
@ -135,8 +171,10 @@ async fn poll(
tx.send_async(MessageToUi::Update(update)).await?;
}
}
Err(_) => {
Err(err) => {
warn!("update stream broke, reconnecting: {err}");
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.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);
}
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::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);
}
}
@ -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
.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::Char(' ')) => {
tx.send(MessageFromUi::TogglePlay);
let _ = tx.send(MessageFromUi::TogglePlay);
}
(_, KeyModifiers::NONE, KeyCode::Char('r')) => {
tx.send(MessageFromUi::RestartTrack);
let _ = tx.send(MessageFromUi::RestartTrack);
}
(_, KeyModifiers::SHIFT, KeyCode::Char('J')) => {
tx.send(MessageFromUi::ChangeVolume(-0.1));
let _ = tx.send(MessageFromUi::ChangeVolume(-0.1));
}
(_, KeyModifiers::SHIFT, KeyCode::Char('K')) => {
tx.send(MessageFromUi::ChangeVolume(0.1));
let _ = tx.send(MessageFromUi::ChangeVolume(0.1));
}
(_, KeyModifiers::NONE, KeyCode::Char('m')) => {
tx.send(MessageFromUi::ToggleMute);
let _ = tx.send(MessageFromUi::ToggleMute);
}
(_, KeyModifiers::NONE, KeyCode::Char('z')) => {
tx.send(MessageFromUi::ToggleShuffle);
let _ = tx.send(MessageFromUi::ToggleShuffle);
}
(_, KeyModifiers::NONE, KeyCode::Char('x')) => {
tx.send(MessageFromUi::ToggleRepeat);
let _ = tx.send(MessageFromUi::ToggleRepeat);
}
(_, KeyModifiers::CONTROL, KeyCode::Char('n')) => {
app.queue.play_next();
@ -313,10 +354,10 @@ fn run_ui(tx: Sender<MessageFromUi>, rx: Receiver<MessageToUi>) {
app.queue.remove_track();
}
(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')) => {
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(
&mut self,
uuid: &str,
path: &str,
) -> Result<Option<&LibraryNode>, Box<dyn Error>> {
if self.library_node_cache.contains_key(uuid) {
return Ok(self.library_node_cache.get(uuid));
if self.library_node_cache.contains_key(path) {
return Ok(self.library_node_cache.get(path));
}
let get_library_node_request = Request::new(GetLibraryNodeRequest {
uuid: uuid.to_string(),
path: path.to_string(),
});
let response = self
.client
@ -89,31 +89,31 @@ impl RpcClient {
.await?;
if let Some(library_node) = response.into_inner().node {
self.library_node_cache
.insert(uuid.to_string(), library_node);
return Ok(self.library_node_cache.get(uuid));
.insert(path.to_string(), library_node);
return Ok(self.library_node_cache.get(path));
}
Err(Box::new(RpcClientError::NotFound))
}
pub async fn append_tracks(&mut self, uuids: Vec<String>) -> Result<(), Box<dyn Error>> {
let append_request = Request::new(AppendRequest { uuids });
pub async fn append_tracks(&mut self, paths: Vec<String>) -> Result<(), Box<dyn Error>> {
let append_request = Request::new(AppendRequest { paths });
self.client.append(append_request).await?;
Ok(())
}
pub async fn queue_tracks(&mut self, uuids: Vec<String>) -> Result<(), Box<dyn Error>> {
let queue_request = Request::new(QueueRequest { uuids });
pub async fn queue_tracks(&mut self, paths: Vec<String>) -> Result<(), Box<dyn Error>> {
let queue_request = Request::new(QueueRequest { paths });
self.client.queue(queue_request).await?;
Ok(())
}
pub async fn insert_tracks(
&mut self,
uuids: Vec<String>,
paths: Vec<String>,
pos: usize,
) -> Result<(), Box<dyn Error>> {
let insert_request = Request::new(InsertRequest {
uuids,
paths,
position: pos as u32,
});
self.client.insert(insert_request).await?;
@ -134,8 +134,8 @@ impl RpcClient {
Ok(())
}
pub async fn replace_queue(&mut self, uuids: Vec<String>) -> Result<(), Box<dyn Error>> {
let replace_request = Request::new(ReplaceRequest { uuids });
pub async fn replace_queue(&mut self, paths: Vec<String>) -> Result<(), Box<dyn Error>> {
let replace_request = Request::new(ReplaceRequest { paths });
self.client.replace(replace_request).await?;
Ok(())
}

View File

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

View File

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

View File

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

View File

@ -10,16 +10,24 @@ use proto::crabidy::{LibraryNode, LibraryNodeChild, Track};
pub mod proto;
/// A media provider addressed like a file system.
///
/// Every node and track has a `/`-separated absolute path whose first
/// segment names the provider, e.g. `/tidal/playlists/<id>/<track-id>`.
/// The path encodes the position in the library tree: ancestors are
/// obtained by trimming trailing segments.
#[async_trait]
pub trait ProviderClient: std::fmt::Debug + Send + Sync {
async fn init(raw_toml_settings: &str) -> Result<Self, ProviderError>
where
Self: Sized;
fn settings(&self) -> String;
async fn get_urls_for_track(&self, track_uuid: &str) -> Result<Vec<String>, ProviderError>;
async fn get_metadata_for_track(&self, track_uuid: &str) -> Result<Track, ProviderError>;
/// Whether the path addresses a single track (as opposed to a node).
fn is_track_path(&self, path: &str) -> bool;
async fn get_urls_for_track(&self, track_path: &str) -> Result<Vec<String>, ProviderError>;
async fn get_metadata_for_track(&self, track_path: &str) -> Result<Track, ProviderError>;
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)]
@ -28,7 +36,7 @@ pub enum ProviderError {
UnknownUser,
CouldNotLogin,
FetchError,
MalformedUuid,
MalformedPath,
InternalError,
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 {
pub fn new() -> Self {
Self {
uuid: "node:/".to_string(),
path: ROOT_PATH.to_string(),
title: "/".to_string(),
children: Vec::new(),
parent: None,
@ -53,9 +92,9 @@ impl LibraryNode {
}
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 {
uuid,
path,
title,
is_queable,
}
@ -94,3 +133,34 @@ where
}
T::default().merge_clap()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parent_path_walks_up_to_root() {
assert_eq!(
parent_path("/tidal/playlists/abc"),
Some("/tidal/playlists")
);
assert_eq!(parent_path("/tidal/playlists"), Some("/tidal"));
assert_eq!(parent_path("/tidal"), Some("/"));
assert_eq!(parent_path("/"), None);
}
#[test]
fn join_path_appends_segments() {
assert_eq!(join_path("/", "tidal"), "/tidal");
assert_eq!(join_path("/tidal", "playlists"), "/tidal/playlists");
}
#[test]
fn path_segments_splits() {
assert_eq!(path_segments("/"), Vec::<&str>::new());
assert_eq!(
path_segments("/tidal/artists/1/2"),
vec!["tidal", "artists", "1", "2"]
);
}
}

View File

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

View File

@ -1,5 +1,5 @@
use crabidy_core::proto::crabidy::{Queue, Track};
use rand::{seq::SliceRandom, thread_rng};
use rand::{rng, seq::SliceRandom};
use std::time::SystemTime;
use tracing::{debug, error};
@ -16,10 +16,11 @@ pub struct QueueManager {
impl From<QueueManager> for Queue {
fn from(queue_manager: QueueManager) -> Self {
Self {
// A clock step backwards must not panic the playback loop.
timestamp: queue_manager
.created_at
.elapsed()
.expect("failed to get elapsed time")
.unwrap_or_default()
.as_secs(),
current_position: queue_manager.current_position() as u32,
tracks: queue_manager.tracks,
@ -27,6 +28,12 @@ impl From<QueueManager> for Queue {
}
}
impl Default for QueueManager {
fn default() -> Self {
Self::new()
}
}
impl QueueManager {
pub fn new() -> Self {
Self {
@ -47,7 +54,7 @@ impl QueueManager {
}
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) {
@ -64,15 +71,19 @@ impl QueueManager {
}
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) {
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) {
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> {
@ -113,7 +124,7 @@ impl QueueManager {
pub fn prev_track(&mut self) -> Option<Track> {
if 0 < self.current_offset {
self.current_offset -= 1;
Some(self.tracks[self.current_position()].clone())
self.current_track()
} else {
None
}
@ -128,11 +139,11 @@ impl QueueManager {
.play_order
.iter()
.position(|&i| i == current_position as usize)
else {
error!("invalid current position");
error!("queue: {:#?}", self);
return false
};
else {
error!("invalid current position");
error!("queue: {:#?}", self);
return false;
};
if self.shuffle {
self.play_order.swap(0, current_offset);
self.current_offset = 0;
@ -177,32 +188,37 @@ impl QueueManager {
pub fn remove_tracks(&mut self, positions: &[u32]) -> Option<Track> {
let mut play_next = false;
// Remove highest positions first so earlier removals don't shift the
// positions that are still to be removed.
let mut positions: Vec<usize> = positions.iter().map(|p| *p as usize).collect();
positions.sort_unstable_by(|a, b| b.cmp(a));
positions.dedup();
for pos in positions {
if (self.tracks.len() as u32) < *pos {
return None;
};
if *pos == self.current_position() as u32 {
if pos >= self.tracks.len() {
debug!(pos, len = self.tracks.len(), "ignoring out-of-range remove");
continue;
}
if pos == self.current_position() {
play_next = true;
}
let Some(offset) = self
.play_order
.iter()
.position(|&i| i == *pos as usize)
else {
error!("invalid current position");
error!("queue: {:#?}", self);
return None
};
let Some(offset) = self.play_order.iter().position(|&i| i == pos) else {
error!(pos, "track position missing from play order, rebuilding");
self.rebuild_play_order();
return None;
};
if offset < self.current_offset {
self.current_offset -= 1;
}
self.tracks.remove(*pos as usize);
self.tracks.remove(pos);
self.play_order.remove(offset);
self.play_order
.iter_mut()
.filter(|i| (*pos as usize) < **i)
.filter(|i| pos < **i)
.for_each(|i| *i -= 1);
}
if self.current_offset >= self.play_order.len() {
self.current_offset = 0;
}
if play_next {
self.current_track()
} else {
@ -215,38 +231,36 @@ impl QueueManager {
if len == 0 {
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);
let tail: Vec<Track> = self
.tracks
.splice((position as usize + 1).., tracks.to_vec())
.splice(position + 1.., tracks.to_vec())
.collect();
self.tracks.extend(tail);
let mut changed: Vec<usize> = Vec::new();
// in shuffle mode, it might be that we played already postions which are behind
// the insertion point and which postions are shifted by the lenght of the inserted
// track
// In shuffle mode we may already have played positions that are
// behind the insertion point; those shift by the number of inserted
// tracks.
for i in self
.play_order
.iter_mut()
.take(self.current_offset)
.filter(|i| (position as usize) < **i)
.filter(|i| position < **i)
{
*i += len;
*i += inserted;
changed.push(*i);
}
if !self.shuffle {
// if we don't shuffle, there should be no positions alredy played behind the
// current track
assert!(changed.is_empty());
}
// the newly inserted indices need to replaced with the ones that we already handled
// The freshly appended order entries need to swap with the shifted
// ones so every index stays unique.
self.play_order
.iter_mut()
.skip(self.current_offset)
.for_each(|i| {
if changed.contains(i) {
*i -= len;
*i -= inserted;
}
});
@ -265,28 +279,126 @@ impl QueueManager {
let current_track = self.current_track();
self.current_offset = 0;
self.tracks.clear();
self.play_order.clear();
if exclude_current {
if let Some(track) = current_track {
self.tracks.push(track);
self.play_order.push(0);
}
}
!exclude_current
}
/// Restores play_order to a consistent state after an inconsistency was
/// detected. Loses shuffle history but keeps the queue playable.
fn rebuild_play_order(&mut self) {
self.play_order = (0..self.tracks.len()).collect();
self.current_offset = 0;
}
}
#[cfg(test)]
mod tests {
use super::*;
fn track(id: usize) -> Track {
Track {
path: format!("/tidal/playlists/p/{id}"),
artist: "artist".to_string(),
title: format!("track {id}"),
duration: None,
album: None,
}
}
fn queue_with(n: usize) -> QueueManager {
let mut q = QueueManager::new();
let tracks: Vec<Track> = (0..n).map(track).collect();
q.replace_with_tracks(&tracks);
q
}
#[test]
fn random_delete_before() {}
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 random_delete_track() {}
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 random_delete_after() {}
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 random_select_track() {}
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,
};
use crabidy_core::{ProviderClient, ProviderError};
use tracing::{debug_span, error, info, instrument, level_filters, warn, Span};
use tracing_subscriber::{filter::Targets, prelude::*};
use tracing::{debug, error, info, instrument, warn, Span};
use tracing_subscriber::{prelude::*, EnvFilter};
mod playback;
use playback::Playback;
@ -15,34 +15,17 @@ use rpc::RpcService;
use tonic::{transport::Server, Result};
const LISTEN_ADDR: &str = "0.0.0.0:50051";
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
if let Err(err) = tracing_log::LogTracer::init_with_filter(log::LevelFilter::Debug) {
println!("Failed to initialize log tracer: {}", err);
}
let (non_blocking, _guard) = tracing_appender::non_blocking(std::io::stderr());
let targets_filter = Targets::new()
.with_target("crabidy_server", tracing::level_filters::LevelFilter::DEBUG)
.with_target("tidaldy", level_filters::LevelFilter::DEBUG);
let subscriber = tracing_subscriber::fmt::layer()
.with_writer(non_blocking)
.with_file(true)
.with_line_number(true);
let registry = tracing_subscriber::registry()
.with(targets_filter)
.with(subscriber);
tracing::subscriber::set_global_default(registry)
.expect("Setting the default tracing subscriber failed");
info!("audio player started initialized");
let _log_guard = init_tracing();
let (update_tx, _) = tokio::sync::broadcast::channel(2048);
let orchestrator = ProviderOrchestrator::init("")
.await
.expect("failed to init orchestrator");
let orchestrator = ProviderOrchestrator::init("").await.map_err(|err| {
error!("failed to init provider orchestrator: {err}");
err
})?;
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(|| {
poll_play_bus(player_msg, playback_tx);
});
info!("gstreamer bus handler started");
info!("player message forwarder started");
let crabidy_service = RpcService::new(
update_tx,
@ -64,7 +47,8 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
playback.run();
info!("playback started");
let addr = "0.0.0.0:50051".parse()?;
let addr = LISTEN_ADDR.parse()?;
info!(%addr, "grpc server listening");
Server::builder()
.add_service(CrabidyServiceServer::new(crabidy_service))
.serve(addr)
@ -73,165 +57,210 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
Ok(())
}
/// Installs the global tracing subscriber.
///
/// The filter honors `RUST_LOG`; without it, our own crates log at debug and
/// everything else at info. Returns the guard that flushes the non-blocking
/// writer on shutdown.
fn init_tracing() -> tracing_appender::non_blocking::WorkerGuard {
let (non_blocking, guard) = tracing_appender::non_blocking(std::io::stderr());
let env_filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| {
EnvFilter::new(
"info,crabidy_server=debug,crabidy_core=debug,tidaldy=debug,audio_player=debug",
)
});
let fmt_layer = tracing_subscriber::fmt::layer()
.with_writer(non_blocking)
.with_target(true)
.with_file(true)
.with_line_number(true);
// .init() also installs the log-to-tracing bridge for libraries that
// use the `log` crate (symphonia, cpal, ...).
tracing_subscriber::registry()
.with(env_filter)
.with(fmt_layer)
.init();
guard
}
/// Forwards player engine events into the playback message loop.
#[instrument(skip(rx, tx))]
fn poll_play_bus(rx: flume::Receiver<PlayerMessage>, tx: flume::Sender<PlaybackMessage>) {
for msg in rx.iter() {
let span = debug_span!("play-chan");
match msg {
let command = match msg {
PlayerMessage::EndOfStream => {
if let Err(err) = tx.send(PlaybackMessage::Next { span }) {
error!("failed to send next message: {}", err);
}
}
PlayerMessage::Stopped => {
if let Err(err) = tx.send(PlaybackMessage::StateChanged {
state: PlayState::Stopped,
span,
}) {
error!("failed to send stopped message: {}", err);
}
}
PlayerMessage::Paused => {
if let Err(err) = tx.send(PlaybackMessage::StateChanged {
state: PlayState::Paused,
span,
}) {
error!("failed to send paused message: {}", err);
}
}
PlayerMessage::Playing => {
if let Err(err) = tx.send(PlaybackMessage::StateChanged {
state: PlayState::Playing,
span,
}) {
error!("failed to send playing message: {}", err);
}
}
PlayerMessage::Elapsed { duration, elapsed } => {
if let Err(err) = tx.send(PlaybackMessage::PostitionChanged {
duration: duration.as_millis() as u32,
position: elapsed.as_millis() as u32,
span,
}) {
error!("failed to send elapsed message: {}", err);
}
}
PlayerMessage::Duration { duration } => {
if let Err(err) = tx.send(PlaybackMessage::PostitionChanged {
duration: duration.as_millis() as u32,
position: 0,
span,
}) {
error!("failed to send duration message: {}", err);
}
debug!("player reported end of stream");
PlaybackCommand::Next
}
PlayerMessage::Stopped => PlaybackCommand::StateChanged {
state: PlayState::Stopped,
},
PlayerMessage::Paused => PlaybackCommand::StateChanged {
state: PlayState::Paused,
},
PlayerMessage::Playing => PlaybackCommand::StateChanged {
state: PlayState::Playing,
},
PlayerMessage::Elapsed { duration, elapsed } => PlaybackCommand::PositionChanged {
duration: duration.as_millis() as u32,
position: elapsed.as_millis() as u32,
},
PlayerMessage::Duration { duration } => PlaybackCommand::PositionChanged {
duration: duration.as_millis() as u32,
position: 0,
},
};
if let Err(err) = tx.send(PlaybackMessage::new(command)) {
error!("failed to forward player message: {err}");
return;
}
}
warn!("player message channel closed");
}
/// A command for the provider orchestrator, tagged with the tracing span that
/// was current when it was sent so the handler can attribute its events to
/// the originating request.
#[derive(Debug)]
pub struct ProviderMessage {
pub span: Span,
pub command: ProviderCommand,
}
impl ProviderMessage {
pub fn new(command: ProviderCommand) -> Self {
Self {
span: Span::current(),
command,
}
}
}
#[derive(Debug)]
pub enum ProviderMessage {
pub enum ProviderCommand {
GetLibraryNode {
uuid: String,
path: String,
result_tx: flume::Sender<Result<LibraryNode, ProviderError>>,
span: Span,
},
GetTrack {
uuid: String,
result_tx: flume::Sender<Result<Track, ProviderError>>,
span: Span,
},
GetTrackUrls {
uuid: String,
path: String,
result_tx: flume::Sender<Result<Vec<String>, ProviderError>>,
span: Span,
},
FlattenNode {
uuid: String,
/// Resolves a path into playable tracks: a track path yields that single
/// track, a node path yields all tracks reachable below it.
ResolveTracks {
path: String,
result_tx: flume::Sender<Vec<Track>>,
span: Span,
},
}
impl ProviderCommand {
pub fn name(&self) -> &'static str {
match self {
Self::GetLibraryNode { .. } => "get_library_node",
Self::GetTrackUrls { .. } => "get_track_urls",
Self::ResolveTracks { .. } => "resolve_tracks",
}
}
}
/// A command for the playback loop, tagged like [`ProviderMessage`].
#[derive(Debug)]
pub struct PlaybackMessage {
pub span: Span,
pub command: PlaybackCommand,
}
impl PlaybackMessage {
pub fn new(command: PlaybackCommand) -> Self {
Self {
span: Span::current(),
command,
}
}
}
#[derive(Debug)]
pub enum PlaybackMessage {
pub enum PlaybackCommand {
Init {
result_tx: flume::Sender<InitResponse>,
span: Span,
},
Replace {
uuids: Vec<String>,
span: Span,
paths: Vec<String>,
},
Queue {
uuids: Vec<String>,
span: Span,
paths: Vec<String>,
},
Append {
uuids: Vec<String>,
span: Span,
paths: Vec<String>,
},
Remove {
positions: Vec<u32>,
span: Span,
},
Insert {
position: u32,
uuids: Vec<String>,
span: Span,
paths: Vec<String>,
},
Clear {
exclude_current: bool,
span: Span,
},
SetCurrent {
position: u32,
span: Span,
},
ToggleShuffle {
span: Span,
},
ToggleRepeat {
span: Span,
},
TogglePlay {
span: Span,
},
Stop {
span: Span,
},
ToggleShuffle,
ToggleRepeat,
TogglePlay,
Stop,
ChangeVolume {
delta: f32,
span: Span,
},
ToggleMute {
span: Span,
},
Next {
span: Span,
},
Prev {
span: Span,
},
RestartTrack {
span: Span,
},
ToggleMute,
Next,
Prev,
RestartTrack,
StateChanged {
state: PlayState,
span: Span,
},
VolumeChanged {
volume: f32,
span: Span,
},
MuteChanged {
muted: bool,
span: Span,
},
PostitionChanged {
PositionChanged {
duration: u32,
position: u32,
span: Span,
},
}
impl PlaybackCommand {
pub fn name(&self) -> &'static str {
match self {
Self::Init { .. } => "init",
Self::Replace { .. } => "replace",
Self::Queue { .. } => "queue",
Self::Append { .. } => "append",
Self::Remove { .. } => "remove",
Self::Insert { .. } => "insert",
Self::Clear { .. } => "clear",
Self::SetCurrent { .. } => "set_current",
Self::ToggleShuffle => "toggle_shuffle",
Self::ToggleRepeat => "toggle_repeat",
Self::TogglePlay => "toggle_play",
Self::Stop => "stop",
Self::ChangeVolume { .. } => "change_volume",
Self::ToggleMute => "toggle_mute",
Self::Next => "next",
Self::Prev => "prev",
Self::RestartTrack => "restart_track",
Self::StateChanged { .. } => "state_changed",
Self::VolumeChanged { .. } => "volume_changed",
Self::MuteChanged { .. } => "mute_changed",
Self::PositionChanged { .. } => "position_changed",
}
}
}

File diff suppressed because it is too large Load Diff

View File

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

View File

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

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

View File

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