Compare commits
6 Commits
a1987869c2
...
21e1b10c0b
| Author | SHA1 | Date |
|---|---|---|
|
|
21e1b10c0b | |
|
|
d504ebc85f | |
|
|
56bc0b0d04 | |
|
|
6d8bc7f166 | |
|
|
6eb5a87b15 | |
|
|
91716daf84 |
|
|
@ -1 +1,25 @@
|
||||||
target
|
target
|
||||||
|
|
||||||
|
/target
|
||||||
|
|
||||||
|
# devenv
|
||||||
|
.devenv*
|
||||||
|
devenv.local.nix
|
||||||
|
devenv.local.yaml
|
||||||
|
|
||||||
|
# direnv
|
||||||
|
.direnv
|
||||||
|
|
||||||
|
# pre-commit / git-hooks
|
||||||
|
.pre-commit-config.yaml
|
||||||
|
.vim
|
||||||
|
AGENTS.md
|
||||||
|
CLAUDE.md
|
||||||
|
.mcp.json
|
||||||
|
.codex/
|
||||||
|
opencode.json
|
||||||
|
.claude/
|
||||||
|
.agents/
|
||||||
|
.opencode/
|
||||||
|
.explained/
|
||||||
|
*.kickstart-new
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
59
Cargo.toml
59
Cargo.toml
|
|
@ -1,9 +1,66 @@
|
||||||
[workspace]
|
[workspace]
|
||||||
|
resolver = "2"
|
||||||
members = [
|
members = [
|
||||||
"audio-player",
|
"audio-player",
|
||||||
"cbd-tui",
|
"cbd-tui",
|
||||||
"crabidy-core",
|
"crabidy-core",
|
||||||
"crabidy-server",
|
"crabidy-server",
|
||||||
"stream-download",
|
|
||||||
"tidaldy",
|
"tidaldy",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[workspace.package]
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
|
||||||
|
[workspace.dependencies]
|
||||||
|
anyhow = "1"
|
||||||
|
async-trait = "0.1"
|
||||||
|
base64 = "0.22"
|
||||||
|
chrono = { version = "0.4", default-features = false, features = ["clock"] }
|
||||||
|
clap = { version = "4", features = ["derive"] }
|
||||||
|
clap-serde-derive = "0.2"
|
||||||
|
crossterm = "0.29"
|
||||||
|
dirs = "6"
|
||||||
|
flume = "0.12"
|
||||||
|
futures = "0.3"
|
||||||
|
notify-rust = "4"
|
||||||
|
prost = "0.14"
|
||||||
|
rand = "0.10"
|
||||||
|
ratatui = "0.30"
|
||||||
|
reqwest = { version = "0.13", default-features = false, features = [
|
||||||
|
"json",
|
||||||
|
"query",
|
||||||
|
"rustls",
|
||||||
|
"webpki-roots",
|
||||||
|
"http2",
|
||||||
|
"hickory-dns",
|
||||||
|
"stream",
|
||||||
|
] }
|
||||||
|
rodio = { version = "0.22", default-features = false, features = [
|
||||||
|
"playback",
|
||||||
|
"symphonia-all",
|
||||||
|
] }
|
||||||
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
serde_json = "1"
|
||||||
|
serde_urlencoded = "0.7"
|
||||||
|
stream-download = { version = "0.24", default-features = false, features = [
|
||||||
|
"reqwest",
|
||||||
|
"reqwest-rustls",
|
||||||
|
"temp-storage",
|
||||||
|
] }
|
||||||
|
thiserror = "2"
|
||||||
|
tokio = "1"
|
||||||
|
tokio-stream = "0.1"
|
||||||
|
toml = "1"
|
||||||
|
tonic = "0.14"
|
||||||
|
tonic-prost = "0.14"
|
||||||
|
tonic-prost-build = "0.14"
|
||||||
|
tracing = "0.1"
|
||||||
|
tracing-appender = "0.2"
|
||||||
|
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||||
|
url = "2"
|
||||||
|
|
||||||
|
# Local crates
|
||||||
|
audio-player = { path = "audio-player" }
|
||||||
|
crabidy-core = { path = "crabidy-core" }
|
||||||
|
tidaldy = { path = "tidaldy" }
|
||||||
|
|
|
||||||
|
|
@ -1,19 +1,17 @@
|
||||||
[package]
|
[package]
|
||||||
name = "audio-player"
|
name = "audio-player"
|
||||||
version = "0.1.0"
|
version.workspace = true
|
||||||
edition = "2021"
|
edition.workspace = true
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
rodio = { version = "0.17.1", default-features = false, features = [
|
anyhow.workspace = true
|
||||||
"symphonia-all",
|
flume.workspace = true
|
||||||
] }
|
rodio.workspace = true
|
||||||
symphonia = { version = "0.5.3", features = ["all"] }
|
stream-download.workspace = true
|
||||||
stream-download = { path = "../stream-download" }
|
thiserror.workspace = true
|
||||||
anyhow = "1.0.71"
|
tokio = { workspace = true, features = ["rt", "rt-multi-thread"] }
|
||||||
url = "2.4.0"
|
tracing.workspace = true
|
||||||
flume = "0.10.14"
|
url.workspace = true
|
||||||
thiserror = "1.0.40"
|
|
||||||
tracing = "0.1.37"
|
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
tokio = { version = "1", features = ["full"] }
|
tokio = { workspace = true, features = ["full"] }
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,3 @@
|
||||||
use std::{thread, time::Duration};
|
|
||||||
|
|
||||||
use audio_player::{Player, PlayerMessage};
|
use audio_player::{Player, PlayerMessage};
|
||||||
|
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
|
|
@ -13,7 +11,10 @@ async fn main() {
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
match player.messages.recv_async().await {
|
match player.messages.recv_async().await {
|
||||||
Ok(PlayerMessage::Elapsed { duration, elapsed }) => {
|
Ok(PlayerMessage::Elapsed {
|
||||||
|
duration: _,
|
||||||
|
elapsed,
|
||||||
|
}) => {
|
||||||
println!("ELAPSED: {:?}", elapsed);
|
println!("ELAPSED: {:?}", elapsed);
|
||||||
}
|
}
|
||||||
Ok(PlayerMessage::EndOfStream) => {
|
Ok(PlayerMessage::EndOfStream) => {
|
||||||
|
|
@ -30,7 +31,10 @@ async fn main() {
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
match player.messages.recv_async().await {
|
match player.messages.recv_async().await {
|
||||||
Ok(PlayerMessage::Elapsed { duration, elapsed }) => {
|
Ok(PlayerMessage::Elapsed {
|
||||||
|
duration: _,
|
||||||
|
elapsed,
|
||||||
|
}) => {
|
||||||
println!("ELAPSED: {:?}", elapsed);
|
println!("ELAPSED: {:?}", elapsed);
|
||||||
}
|
}
|
||||||
Ok(PlayerMessage::EndOfStream) => {
|
Ok(PlayerMessage::EndOfStream) => {
|
||||||
|
|
|
||||||
|
|
@ -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(¤t_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 {}
|
|
||||||
|
|
@ -1,7 +1,5 @@
|
||||||
mod decoder;
|
|
||||||
mod player;
|
mod player;
|
||||||
mod player_engine;
|
mod player_engine;
|
||||||
|
|
||||||
pub use decoder::MediaInfo;
|
|
||||||
pub use player::{Player, PlayerError};
|
pub use player::{Player, PlayerError};
|
||||||
pub use player_engine::PlayerMessage;
|
pub use player_engine::{MediaInfo, PlayerMessage};
|
||||||
|
|
|
||||||
|
|
@ -3,13 +3,9 @@ use std::time::Duration;
|
||||||
|
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use flume::{Receiver, Sender};
|
use flume::{Receiver, Sender};
|
||||||
use tracing::{error, warn};
|
use tracing::error;
|
||||||
|
|
||||||
use crate::decoder::MediaInfo;
|
use crate::player_engine::{MediaInfo, PlayerEngine, PlayerEngineCommand, PlayerMessage};
|
||||||
use crate::player_engine::{PlayerEngine, PlayerEngineCommand, PlayerMessage};
|
|
||||||
|
|
||||||
// TODO:
|
|
||||||
// * Emit buffering
|
|
||||||
|
|
||||||
pub enum PlayerError {}
|
pub enum PlayerError {}
|
||||||
|
|
||||||
|
|
@ -20,82 +16,24 @@ pub struct Player {
|
||||||
|
|
||||||
impl Default for Player {
|
impl Default for Player {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
let (tx_engine, rx_engine) = flume::bounded(10);
|
let (tx_engine, rx_engine) = flume::bounded(16);
|
||||||
let (tx_player, messages): (Sender<PlayerMessage>, Receiver<PlayerMessage>) =
|
let (tx_player, messages): (Sender<PlayerMessage>, Receiver<PlayerMessage>) =
|
||||||
flume::bounded(10);
|
flume::bounded(16);
|
||||||
|
|
||||||
let tx_decoder = tx_engine.clone();
|
let tx_callbacks = tx_engine.clone();
|
||||||
|
// Capture the runtime handle here: the engine thread itself is not a
|
||||||
|
// tokio context but needs one to create http streams.
|
||||||
|
let runtime = tokio::runtime::Handle::try_current().ok();
|
||||||
|
|
||||||
thread::spawn(move || {
|
thread::spawn(move || {
|
||||||
let mut player = match PlayerEngine::init(tx_decoder, tx_player) {
|
let engine = match PlayerEngine::init(tx_callbacks, tx_player, runtime) {
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
error!("Could not initialize player: {}", e);
|
error!("Could not initialize player: {}", e);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
Ok(engine) => engine,
|
Ok(engine) => engine,
|
||||||
};
|
};
|
||||||
|
engine.run(rx_engine);
|
||||||
loop {
|
|
||||||
match rx_engine.recv() {
|
|
||||||
Ok(PlayerEngineCommand::Play(source_str, tx)) => {
|
|
||||||
tx.send(player.play(&source_str))
|
|
||||||
.unwrap_or_else(|e| warn!("Send error {}", e));
|
|
||||||
}
|
|
||||||
Ok(PlayerEngineCommand::Pause(tx)) => {
|
|
||||||
tx.send(player.pause())
|
|
||||||
.unwrap_or_else(|e| warn!("Send error {}", e));
|
|
||||||
}
|
|
||||||
Ok(PlayerEngineCommand::Unpause(tx)) => {
|
|
||||||
tx.send(player.unpause())
|
|
||||||
.unwrap_or_else(|e| warn!("Send error {}", e));
|
|
||||||
}
|
|
||||||
Ok(PlayerEngineCommand::Stop(tx)) => {
|
|
||||||
tx.send(player.stop())
|
|
||||||
.unwrap_or_else(|e| warn!("Send error {}", e));
|
|
||||||
}
|
|
||||||
Ok(PlayerEngineCommand::TogglePlay(tx)) => {
|
|
||||||
tx.send(player.toggle_play())
|
|
||||||
.unwrap_or_else(|e| warn!("Send error {}", e));
|
|
||||||
}
|
|
||||||
Ok(PlayerEngineCommand::Restart(tx)) => {
|
|
||||||
tx.send(player.restart())
|
|
||||||
.unwrap_or_else(|e| warn!("Send error {}", e));
|
|
||||||
}
|
|
||||||
Ok(PlayerEngineCommand::GetDuration(tx)) => {
|
|
||||||
tx.send(player.duration())
|
|
||||||
.unwrap_or_else(|e| warn!("Send error {}", e));
|
|
||||||
}
|
|
||||||
Ok(PlayerEngineCommand::GetElapsed(tx)) => {
|
|
||||||
tx.send(player.elapsed())
|
|
||||||
.unwrap_or_else(|e| warn!("Send error {}", e));
|
|
||||||
}
|
|
||||||
Ok(PlayerEngineCommand::SeekTo(time, tx)) => {
|
|
||||||
tx.send(player.seek_to(time))
|
|
||||||
.unwrap_or_else(|e| warn!("Send error {}", e));
|
|
||||||
}
|
|
||||||
Ok(PlayerEngineCommand::GetVolume(tx)) => {
|
|
||||||
tx.send(player.volume())
|
|
||||||
.unwrap_or_else(|e| warn!("Send error {}", e));
|
|
||||||
}
|
|
||||||
Ok(PlayerEngineCommand::GetPaused(tx)) => {
|
|
||||||
tx.send(player.is_paused())
|
|
||||||
.unwrap_or_else(|e| warn!("Send error {}", e));
|
|
||||||
}
|
|
||||||
Ok(PlayerEngineCommand::SetVolume(volume, tx)) => {
|
|
||||||
tx.send(player.set_volume(volume))
|
|
||||||
.unwrap_or_else(|e| warn!("Send error {}", e));
|
|
||||||
}
|
|
||||||
Ok(PlayerEngineCommand::SetElapsed(elapsed)) => {
|
|
||||||
player.handle_elapsed(elapsed);
|
|
||||||
}
|
|
||||||
Ok(PlayerEngineCommand::Eos) => {
|
|
||||||
player.handle_eos();
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
warn!("Recv error {}", e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
Self {
|
Self {
|
||||||
|
|
@ -109,74 +47,96 @@ impl Player {
|
||||||
pub async fn play(&self, source_str: &str) -> Result<MediaInfo> {
|
pub async fn play(&self, source_str: &str) -> Result<MediaInfo> {
|
||||||
let (tx, rx) = flume::bounded(1);
|
let (tx, rx) = flume::bounded(1);
|
||||||
self.tx_engine
|
self.tx_engine
|
||||||
.send(PlayerEngineCommand::Play(source_str.to_string(), tx))?;
|
.send_async(PlayerEngineCommand::Play(source_str.to_string(), tx))
|
||||||
|
.await?;
|
||||||
rx.recv_async().await?
|
rx.recv_async().await?
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn restart(&self) -> Result<MediaInfo> {
|
pub async fn restart(&self) -> Result<MediaInfo> {
|
||||||
let (tx, rx) = flume::bounded(1);
|
let (tx, rx) = flume::bounded(1);
|
||||||
self.tx_engine.send(PlayerEngineCommand::Restart(tx))?;
|
self.tx_engine
|
||||||
|
.send_async(PlayerEngineCommand::Restart(tx))
|
||||||
|
.await?;
|
||||||
rx.recv_async().await?
|
rx.recv_async().await?
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn elpased(&self) -> Result<Duration> {
|
pub async fn elapsed(&self) -> Result<Duration> {
|
||||||
let (tx, rx) = flume::bounded(1);
|
let (tx, rx) = flume::bounded(1);
|
||||||
self.tx_engine.send(PlayerEngineCommand::GetElapsed(tx))?;
|
self.tx_engine
|
||||||
|
.send_async(PlayerEngineCommand::GetElapsed(tx))
|
||||||
|
.await?;
|
||||||
rx.recv_async().await?
|
rx.recv_async().await?
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn duration(&self) -> Result<Duration> {
|
pub async fn duration(&self) -> Result<Duration> {
|
||||||
let (tx, rx) = flume::bounded(1);
|
let (tx, rx) = flume::bounded(1);
|
||||||
self.tx_engine.send(PlayerEngineCommand::GetDuration(tx))?;
|
self.tx_engine
|
||||||
|
.send_async(PlayerEngineCommand::GetDuration(tx))
|
||||||
|
.await?;
|
||||||
rx.recv_async().await?
|
rx.recv_async().await?
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn seek_to(&self, time: Duration) -> Result<Duration> {
|
pub async fn seek_to(&self, time: Duration) -> Result<Duration> {
|
||||||
let (tx, rx) = flume::bounded(1);
|
let (tx, rx) = flume::bounded(1);
|
||||||
self.tx_engine.send(PlayerEngineCommand::SeekTo(time, tx))?;
|
self.tx_engine
|
||||||
|
.send_async(PlayerEngineCommand::SeekTo(time, tx))
|
||||||
|
.await?;
|
||||||
rx.recv_async().await?
|
rx.recv_async().await?
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn volume(&self) -> Result<f32> {
|
pub async fn volume(&self) -> Result<f32> {
|
||||||
let (tx, rx) = flume::bounded(1);
|
let (tx, rx) = flume::bounded(1);
|
||||||
self.tx_engine.send(PlayerEngineCommand::GetVolume(tx))?;
|
self.tx_engine
|
||||||
|
.send_async(PlayerEngineCommand::GetVolume(tx))
|
||||||
|
.await?;
|
||||||
Ok(rx.recv_async().await?)
|
Ok(rx.recv_async().await?)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn is_paused(&self) -> Result<bool> {
|
pub async fn is_paused(&self) -> Result<bool> {
|
||||||
let (tx, rx) = flume::bounded(1);
|
let (tx, rx) = flume::bounded(1);
|
||||||
self.tx_engine.send(PlayerEngineCommand::GetPaused(tx))?;
|
self.tx_engine
|
||||||
|
.send_async(PlayerEngineCommand::GetPaused(tx))
|
||||||
|
.await?;
|
||||||
rx.recv_async().await?
|
rx.recv_async().await?
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn set_volume(&self, volume: f32) -> Result<f32> {
|
pub async fn set_volume(&self, volume: f32) -> Result<f32> {
|
||||||
let (tx, rx) = flume::bounded(1);
|
let (tx, rx) = flume::bounded(1);
|
||||||
self.tx_engine
|
self.tx_engine
|
||||||
.send(PlayerEngineCommand::SetVolume(volume, tx))?;
|
.send_async(PlayerEngineCommand::SetVolume(volume, tx))
|
||||||
|
.await?;
|
||||||
Ok(rx.recv_async().await?)
|
Ok(rx.recv_async().await?)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn pause(&self) -> Result<()> {
|
pub async fn pause(&self) -> Result<()> {
|
||||||
let (tx, rx) = flume::bounded(1);
|
let (tx, rx) = flume::bounded(1);
|
||||||
self.tx_engine.send(PlayerEngineCommand::Pause(tx))?;
|
self.tx_engine
|
||||||
|
.send_async(PlayerEngineCommand::Pause(tx))
|
||||||
|
.await?;
|
||||||
rx.recv_async().await?
|
rx.recv_async().await?
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn unpause(&self) -> Result<()> {
|
pub async fn unpause(&self) -> Result<()> {
|
||||||
let (tx, rx) = flume::bounded(1);
|
let (tx, rx) = flume::bounded(1);
|
||||||
self.tx_engine.send(PlayerEngineCommand::Unpause(tx))?;
|
self.tx_engine
|
||||||
|
.send_async(PlayerEngineCommand::Unpause(tx))
|
||||||
|
.await?;
|
||||||
rx.recv_async().await?
|
rx.recv_async().await?
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn toggle_play(&self) -> Result<bool> {
|
pub async fn toggle_play(&self) -> Result<bool> {
|
||||||
let (tx, rx) = flume::bounded(1);
|
let (tx, rx) = flume::bounded(1);
|
||||||
self.tx_engine.send(PlayerEngineCommand::TogglePlay(tx))?;
|
self.tx_engine
|
||||||
|
.send_async(PlayerEngineCommand::TogglePlay(tx))
|
||||||
|
.await?;
|
||||||
rx.recv_async().await?
|
rx.recv_async().await?
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn stop(&self) -> Result<()> {
|
pub async fn stop(&self) -> Result<()> {
|
||||||
let (tx, rx) = flume::bounded(1);
|
let (tx, rx) = flume::bounded(1);
|
||||||
self.tx_engine.send(PlayerEngineCommand::Stop(tx))?;
|
self.tx_engine
|
||||||
|
.send_async(PlayerEngineCommand::Stop(tx))
|
||||||
|
.await?;
|
||||||
rx.recv_async().await?
|
rx.recv_async().await?
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,19 +1,23 @@
|
||||||
use flume::Sender;
|
use std::fs::File;
|
||||||
|
use std::io::BufReader;
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
use std::sync::atomic::AtomicU64;
|
|
||||||
use std::thread;
|
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
use std::{fs::File, sync::atomic::Ordering};
|
|
||||||
use symphonia::core::probe::Hint;
|
use anyhow::{anyhow, Context, Result};
|
||||||
use tracing::{debug, warn};
|
use flume::{Receiver, RecvTimeoutError, Sender};
|
||||||
|
use rodio::source::EmptyCallback;
|
||||||
|
use rodio::stream::{DeviceSinkBuilder, MixerDeviceSink};
|
||||||
|
use rodio::{Decoder, Source};
|
||||||
|
use stream_download::storage::temp::TempStorageProvider;
|
||||||
|
use stream_download::{Settings, StreamDownload};
|
||||||
|
use thiserror::Error;
|
||||||
|
use tracing::{debug, info, instrument, trace, warn};
|
||||||
use url::Url;
|
use url::Url;
|
||||||
|
|
||||||
use crate::decoder::{MediaInfo, SymphoniaDecoder};
|
/// How long we wait for the initial prefetch of a network stream.
|
||||||
use anyhow::{anyhow, Result};
|
const STREAM_OPEN_TIMEOUT: Duration = Duration::from_secs(30);
|
||||||
use rodio::{OutputStream, OutputStreamHandle, Sink, Source};
|
/// Interval between elapsed-position updates while playing.
|
||||||
use stream_download::StreamDownload;
|
const TICK_INTERVAL: Duration = Duration::from_millis(250);
|
||||||
use symphonia::core::io::{MediaSource, MediaSourceStream, MediaSourceStreamOptions};
|
|
||||||
use thiserror::Error;
|
|
||||||
|
|
||||||
pub enum PlayerEngineCommand {
|
pub enum PlayerEngineCommand {
|
||||||
Play(String, Sender<Result<MediaInfo>>),
|
Play(String, Sender<Result<MediaInfo>>),
|
||||||
|
|
@ -28,8 +32,10 @@ pub enum PlayerEngineCommand {
|
||||||
SeekTo(Duration, Sender<Result<Duration>>),
|
SeekTo(Duration, Sender<Result<Duration>>),
|
||||||
GetVolume(Sender<f32>),
|
GetVolume(Sender<f32>),
|
||||||
GetPaused(Sender<Result<bool>>),
|
GetPaused(Sender<Result<bool>>),
|
||||||
Eos,
|
/// End of stream for the source started by the given generation.
|
||||||
SetElapsed(Duration),
|
/// Stale generations are ignored so an old track finishing can never
|
||||||
|
/// interfere with a newly started one.
|
||||||
|
Eos(u64),
|
||||||
}
|
}
|
||||||
|
|
||||||
pub enum PlayerMessage {
|
pub enum PlayerMessage {
|
||||||
|
|
@ -46,8 +52,10 @@ pub enum PlayerMessage {
|
||||||
EndOfStream,
|
EndOfStream,
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO:
|
#[derive(Clone, Debug)]
|
||||||
// * Emit buffering
|
pub struct MediaInfo {
|
||||||
|
pub duration: Option<Duration>,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Error)]
|
#[derive(Debug, Error)]
|
||||||
pub enum PlayerEngineError {
|
pub enum PlayerEngineError {
|
||||||
|
|
@ -55,85 +63,195 @@ pub enum PlayerEngineError {
|
||||||
NotPlaying,
|
NotPlaying,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Used for seeking in the stream
|
|
||||||
static SEEK_TO: AtomicU64 = AtomicU64::new(0);
|
|
||||||
|
|
||||||
pub struct PlayerEngine {
|
pub struct PlayerEngine {
|
||||||
elapsed: Duration,
|
|
||||||
current_source: Option<String>,
|
current_source: Option<String>,
|
||||||
media_info: Option<MediaInfo>,
|
media_info: Option<MediaInfo>,
|
||||||
sink: Sink,
|
/// Monotonically increasing id for the currently playing source. Used to
|
||||||
// We need to keep the stream around as it will stop playing when it's dropped
|
/// discard end-of-stream callbacks from sources that were replaced.
|
||||||
_stream: OutputStream,
|
generation: u64,
|
||||||
_handle: OutputStreamHandle,
|
sink: rodio::Player,
|
||||||
|
// We need to keep the device sink around; audio stops when it's dropped.
|
||||||
|
_stream: MixerDeviceSink,
|
||||||
tx_engine: Sender<PlayerEngineCommand>,
|
tx_engine: Sender<PlayerEngineCommand>,
|
||||||
tx_player: Sender<PlayerMessage>,
|
tx_player: Sender<PlayerMessage>,
|
||||||
|
runtime: tokio::runtime::Handle,
|
||||||
|
// Present when the engine had to bring its own runtime because the
|
||||||
|
// creating thread was not inside a tokio context.
|
||||||
|
_owned_runtime: Option<tokio::runtime::Runtime>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl PlayerEngine {
|
impl PlayerEngine {
|
||||||
pub fn init(
|
pub fn init(
|
||||||
tx_engine: Sender<PlayerEngineCommand>,
|
tx_engine: Sender<PlayerEngineCommand>,
|
||||||
tx_player: Sender<PlayerMessage>,
|
tx_player: Sender<PlayerMessage>,
|
||||||
|
runtime: Option<tokio::runtime::Handle>,
|
||||||
) -> Result<Self> {
|
) -> Result<Self> {
|
||||||
let (_stream, handle) = OutputStream::try_default()?;
|
let stream =
|
||||||
let sink = Sink::try_new(&handle)?;
|
DeviceSinkBuilder::open_default_sink().context("failed to open audio output device")?;
|
||||||
|
let sink = rodio::Player::connect_new(stream.mixer());
|
||||||
|
let (runtime, owned_runtime) = match runtime {
|
||||||
|
Some(handle) => (handle, None),
|
||||||
|
None => {
|
||||||
|
let rt = tokio::runtime::Builder::new_multi_thread()
|
||||||
|
.worker_threads(1)
|
||||||
|
.enable_all()
|
||||||
|
.build()
|
||||||
|
.context("failed to create tokio runtime for the player engine")?;
|
||||||
|
(rt.handle().clone(), Some(rt))
|
||||||
|
}
|
||||||
|
};
|
||||||
|
info!("audio output device opened");
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
current_source: None,
|
current_source: None,
|
||||||
media_info: None,
|
media_info: None,
|
||||||
elapsed: Duration::default(),
|
generation: 0,
|
||||||
sink,
|
sink,
|
||||||
_stream,
|
_stream: stream,
|
||||||
_handle: handle,
|
|
||||||
tx_engine,
|
tx_engine,
|
||||||
tx_player,
|
tx_player,
|
||||||
|
runtime,
|
||||||
|
_owned_runtime: owned_runtime,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn play(&mut self, source_str: &str) -> Result<MediaInfo> {
|
/// Drives the engine until all command senders are dropped.
|
||||||
let tx_player = self.tx_player.clone();
|
pub fn run(mut self, rx_engine: Receiver<PlayerEngineCommand>) {
|
||||||
let tx_engine = self.tx_engine.clone();
|
loop {
|
||||||
|
match rx_engine.recv_timeout(TICK_INTERVAL) {
|
||||||
|
Ok(command) => self.handle_command(command),
|
||||||
|
Err(RecvTimeoutError::Timeout) => self.tick(),
|
||||||
|
Err(RecvTimeoutError::Disconnected) => {
|
||||||
|
debug!("player engine channel closed, shutting down");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn handle_command(&mut self, command: PlayerEngineCommand) {
|
||||||
|
match command {
|
||||||
|
PlayerEngineCommand::Play(source_str, tx) => {
|
||||||
|
send_reply(tx, self.play(&source_str));
|
||||||
|
}
|
||||||
|
PlayerEngineCommand::Pause(tx) => send_reply(tx, self.pause()),
|
||||||
|
PlayerEngineCommand::Unpause(tx) => send_reply(tx, self.unpause()),
|
||||||
|
PlayerEngineCommand::Stop(tx) => send_reply(tx, self.stop()),
|
||||||
|
PlayerEngineCommand::TogglePlay(tx) => send_reply(tx, self.toggle_play()),
|
||||||
|
PlayerEngineCommand::Restart(tx) => send_reply(tx, self.restart()),
|
||||||
|
PlayerEngineCommand::GetDuration(tx) => send_reply(tx, self.duration()),
|
||||||
|
PlayerEngineCommand::GetElapsed(tx) => send_reply(tx, self.elapsed()),
|
||||||
|
PlayerEngineCommand::SeekTo(time, tx) => send_reply(tx, self.seek_to(time)),
|
||||||
|
PlayerEngineCommand::SetVolume(volume, tx) => {
|
||||||
|
send_reply(tx, self.set_volume(volume));
|
||||||
|
}
|
||||||
|
PlayerEngineCommand::GetVolume(tx) => send_reply(tx, self.volume()),
|
||||||
|
PlayerEngineCommand::GetPaused(tx) => send_reply(tx, self.is_paused()),
|
||||||
|
PlayerEngineCommand::Eos(generation) => self.handle_eos(generation),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Emits an elapsed-position update while a source is playing.
|
||||||
|
fn tick(&self) {
|
||||||
|
if self.sink.empty() || self.sink.is_paused() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let duration = self
|
||||||
|
.media_info
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|m| m.duration)
|
||||||
|
.unwrap_or_default();
|
||||||
|
// Dropping a tick when the channel is full is harmless.
|
||||||
|
let _ = self.tx_player.try_send(PlayerMessage::Elapsed {
|
||||||
|
duration,
|
||||||
|
elapsed: self.sink.get_pos(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#[instrument(skip(self))]
|
||||||
|
pub fn play(&mut self, source_str: &str) -> Result<MediaInfo> {
|
||||||
self.reset();
|
self.reset();
|
||||||
|
|
||||||
let (source, hint) = self.get_source(source_str)?;
|
let duration = self.start_source(source_str)?;
|
||||||
let mss = MediaSourceStream::new(source, MediaSourceStreamOptions::default());
|
let media_info = MediaInfo { duration };
|
||||||
let decoder = SymphoniaDecoder::new(mss, hint, self.tx_engine.clone())?;
|
|
||||||
|
|
||||||
let media_info = decoder.media_info();
|
self.media_info = Some(media_info.clone());
|
||||||
let media_info_copy = media_info.clone();
|
|
||||||
let duration = media_info.duration.unwrap_or_default();
|
|
||||||
|
|
||||||
self.media_info = Some(media_info);
|
|
||||||
self.current_source = Some(source_str.to_string());
|
self.current_source = Some(source_str.to_string());
|
||||||
|
|
||||||
tx_player
|
self.notify(PlayerMessage::Duration {
|
||||||
.send(PlayerMessage::Duration { duration })
|
duration: duration.unwrap_or_default(),
|
||||||
.unwrap_or_else(|e| warn!("Send error {}", e));
|
|
||||||
|
|
||||||
// FIXME: regularly update metadata revision
|
|
||||||
let decoder = decoder.periodic_access(Duration::from_millis(250), move |src| {
|
|
||||||
let seek = SEEK_TO.load(Ordering::SeqCst);
|
|
||||||
if seek > 0 {
|
|
||||||
src.seek(Duration::from_secs(seek));
|
|
||||||
SEEK_TO.store(0, Ordering::SeqCst);
|
|
||||||
}
|
|
||||||
let elapsed = src.elapsed();
|
|
||||||
tx_engine
|
|
||||||
.send(PlayerEngineCommand::SetElapsed(elapsed))
|
|
||||||
.unwrap_or_else(|e| warn!("Send error {}", e));
|
|
||||||
tx_player
|
|
||||||
.send(PlayerMessage::Elapsed { elapsed, duration })
|
|
||||||
.unwrap_or_else(|e| warn!("Send error {}", e));
|
|
||||||
});
|
});
|
||||||
|
|
||||||
self.sink.append(decoder);
|
|
||||||
self.sink.play();
|
self.sink.play();
|
||||||
|
self.notify(PlayerMessage::Playing);
|
||||||
|
debug!(duration = ?duration, "started playback");
|
||||||
|
|
||||||
self.tx_player
|
Ok(media_info)
|
||||||
.send(PlayerMessage::Playing)
|
}
|
||||||
.unwrap_or_else(|e| warn!("Send error {}", e));
|
|
||||||
|
|
||||||
Ok(media_info_copy)
|
/// Decodes the source and appends it (plus an end-of-stream callback) to
|
||||||
|
/// the sink. Returns the total duration if known.
|
||||||
|
fn start_source(&mut self, source_str: &str) -> Result<Option<Duration>> {
|
||||||
|
self.generation += 1;
|
||||||
|
let duration = match Url::parse(source_str) {
|
||||||
|
Ok(url) if matches!(url.scheme(), "http" | "https") => {
|
||||||
|
trace!(%url, "opening network stream");
|
||||||
|
let reader = self.runtime.block_on(async {
|
||||||
|
tokio::time::timeout(
|
||||||
|
STREAM_OPEN_TIMEOUT,
|
||||||
|
StreamDownload::new_http(
|
||||||
|
url.clone(),
|
||||||
|
TempStorageProvider::new(),
|
||||||
|
Settings::default(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(|_| anyhow!("timed out opening stream after {STREAM_OPEN_TIMEOUT:?}"))?
|
||||||
|
.context("failed to open http stream")
|
||||||
|
})?;
|
||||||
|
let mut builder = Decoder::builder().with_data(reader).with_seekable(true);
|
||||||
|
if let Some(extension) = Path::new(url.path()).extension().and_then(|e| e.to_str())
|
||||||
|
{
|
||||||
|
builder = builder.with_hint(extension);
|
||||||
|
}
|
||||||
|
let decoder = builder.build().context("failed to decode http stream")?;
|
||||||
|
let duration = decoder.total_duration();
|
||||||
|
self.sink.append(decoder);
|
||||||
|
duration
|
||||||
|
}
|
||||||
|
Ok(url) => return Err(anyhow!("Not a valid URL scheme: {}", url.scheme())),
|
||||||
|
Err(_) => {
|
||||||
|
trace!(path = source_str, "opening local file");
|
||||||
|
let file = File::open(source_str)
|
||||||
|
.with_context(|| format!("failed to open file {source_str}"))?;
|
||||||
|
let byte_len = file.metadata().ok().map(|m| m.len());
|
||||||
|
let mut builder = Decoder::builder()
|
||||||
|
.with_data(BufReader::new(file))
|
||||||
|
.with_seekable(true);
|
||||||
|
if let Some(len) = byte_len {
|
||||||
|
builder = builder.with_byte_len(len);
|
||||||
|
}
|
||||||
|
if let Some(extension) = Path::new(source_str).extension().and_then(|e| e.to_str())
|
||||||
|
{
|
||||||
|
builder = builder.with_hint(extension);
|
||||||
|
}
|
||||||
|
let decoder = builder.build().context("failed to decode file")?;
|
||||||
|
let duration = decoder.total_duration();
|
||||||
|
self.sink.append(decoder);
|
||||||
|
duration
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Fires only when the decoder ahead of it finished naturally; a
|
||||||
|
// stop/replace clears the queue before this source is ever played.
|
||||||
|
let tx_engine = self.tx_engine.clone();
|
||||||
|
let generation = self.generation;
|
||||||
|
self.sink.append(EmptyCallback::new(Box::new(move || {
|
||||||
|
if let Err(err) = tx_engine.try_send(PlayerEngineCommand::Eos(generation)) {
|
||||||
|
warn!("failed to send end-of-stream signal: {err}");
|
||||||
|
}
|
||||||
|
})));
|
||||||
|
|
||||||
|
Ok(duration)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn restart(&mut self) -> Result<MediaInfo> {
|
pub fn restart(&mut self) -> Result<MediaInfo> {
|
||||||
|
|
@ -148,9 +266,7 @@ impl PlayerEngine {
|
||||||
return Err(PlayerEngineError::NotPlaying.into());
|
return Err(PlayerEngineError::NotPlaying.into());
|
||||||
}
|
}
|
||||||
self.sink.pause();
|
self.sink.pause();
|
||||||
self.tx_player
|
self.notify(PlayerMessage::Paused);
|
||||||
.send(PlayerMessage::Paused)
|
|
||||||
.unwrap_or_else(|e| warn!("Send error {}", e));
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -159,9 +275,7 @@ impl PlayerEngine {
|
||||||
return Err(PlayerEngineError::NotPlaying.into());
|
return Err(PlayerEngineError::NotPlaying.into());
|
||||||
}
|
}
|
||||||
self.sink.play();
|
self.sink.play();
|
||||||
self.tx_player
|
self.notify(PlayerMessage::Playing);
|
||||||
.send(PlayerMessage::Playing)
|
|
||||||
.unwrap_or_else(|e| warn!("Send error {}", e));
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -171,9 +285,11 @@ impl PlayerEngine {
|
||||||
}
|
}
|
||||||
if self.sink.is_paused() {
|
if self.sink.is_paused() {
|
||||||
self.sink.play();
|
self.sink.play();
|
||||||
|
self.notify(PlayerMessage::Playing);
|
||||||
Ok(true)
|
Ok(true)
|
||||||
} else {
|
} else {
|
||||||
self.sink.pause();
|
self.sink.pause();
|
||||||
|
self.notify(PlayerMessage::Paused);
|
||||||
Ok(false)
|
Ok(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -183,9 +299,7 @@ impl PlayerEngine {
|
||||||
return Err(PlayerEngineError::NotPlaying.into());
|
return Err(PlayerEngineError::NotPlaying.into());
|
||||||
}
|
}
|
||||||
self.reset();
|
self.reset();
|
||||||
self.tx_player
|
self.notify(PlayerMessage::Stopped);
|
||||||
.send(PlayerMessage::Stopped)
|
|
||||||
.unwrap_or_else(|e| warn!("Send error {}", e));
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -212,18 +326,16 @@ impl PlayerEngine {
|
||||||
if self.is_stopped() {
|
if self.is_stopped() {
|
||||||
return Err(PlayerEngineError::NotPlaying.into());
|
return Err(PlayerEngineError::NotPlaying.into());
|
||||||
}
|
}
|
||||||
Ok(self.elapsed)
|
Ok(self.sink.get_pos())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn seek_to(&self, time: Duration) -> Result<Duration> {
|
pub fn seek_to(&self, time: Duration) -> Result<Duration> {
|
||||||
// We can seek between 1 second and the total duration of the track
|
let duration = self.duration().unwrap_or_else(|_| self.sink.get_pos());
|
||||||
let duration = self.duration().unwrap_or(self.elapsed);
|
|
||||||
let time = time.clamp(Duration::from_secs(1), duration);
|
let time = time.clamp(Duration::from_secs(1), duration);
|
||||||
SEEK_TO.store(time.as_secs(), Ordering::SeqCst);
|
self.sink
|
||||||
// FIXME: ideally we would like to return once the seeking is successful
|
.try_seek(time)
|
||||||
// then return the current elapsed time
|
.map_err(|err| anyhow!("seek failed: {err}"))?;
|
||||||
// Cond-var might be needed to sleep this (seeking takes time)
|
Ok(self.sink.get_pos())
|
||||||
Ok(time)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn volume(&self) -> f32 {
|
pub fn volume(&self) -> f32 {
|
||||||
|
|
@ -235,54 +347,36 @@ impl PlayerEngine {
|
||||||
self.sink.volume()
|
self.sink.volume()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn handle_eos(&mut self) {
|
fn handle_eos(&mut self, generation: u64) {
|
||||||
self.reset();
|
if generation != self.generation {
|
||||||
self.tx_player
|
debug!(
|
||||||
.send(PlayerMessage::EndOfStream)
|
stale = generation,
|
||||||
.unwrap_or_else(|e| warn!("Send error {}", e));
|
current = self.generation,
|
||||||
|
"ignoring end-of-stream from replaced source"
|
||||||
|
);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
debug!("end of stream");
|
||||||
pub fn handle_elapsed(&mut self, elapsed: Duration) {
|
self.reset();
|
||||||
self.elapsed = elapsed;
|
self.notify(PlayerMessage::EndOfStream);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn reset(&mut self) {
|
fn reset(&mut self) {
|
||||||
self.elapsed = Duration::default();
|
|
||||||
self.current_source = None;
|
self.current_source = None;
|
||||||
self.sink.pause();
|
self.media_info = None;
|
||||||
|
self.generation += 1;
|
||||||
self.sink.stop();
|
self.sink.stop();
|
||||||
}
|
}
|
||||||
|
|
||||||
fn get_source(&self, source_str: &str) -> Result<(Box<dyn MediaSource>, Hint)> {
|
fn notify(&self, message: PlayerMessage) {
|
||||||
match Url::parse(source_str) {
|
self.tx_player
|
||||||
Ok(url) => {
|
.send(message)
|
||||||
if let "http" | "https" = url.scheme() {
|
.unwrap_or_else(|e| warn!("Send error {}", e));
|
||||||
let reader = StreamDownload::new_http(source_str.parse().unwrap());
|
|
||||||
let path = Path::new(url.path());
|
|
||||||
let hint = self.get_hint(path);
|
|
||||||
|
|
||||||
Ok((Box::new(reader), hint))
|
|
||||||
} else {
|
|
||||||
Err(anyhow!("Not a valid URL scheme: {}", url.scheme()))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(_) => {
|
|
||||||
let path = Path::new(source_str);
|
|
||||||
let hint = self.get_hint(path);
|
|
||||||
Ok((Box::new(File::open(path)?), hint))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn get_hint(&self, path: &Path) -> Hint {
|
fn send_reply<T>(tx: Sender<T>, value: T) {
|
||||||
// Create a hint to help the format registry guess what format reader is appropriate.
|
if tx.send(value).is_err() {
|
||||||
let mut hint = Hint::new();
|
warn!("player engine reply receiver dropped");
|
||||||
// Provide the file extension as a hint.
|
|
||||||
if let Some(extension) = path.extension() {
|
|
||||||
if let Some(extension_str) = extension.to_str() {
|
|
||||||
hint.with_extension(extension_str);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
hint
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,19 @@
|
||||||
[package]
|
[package]
|
||||||
name = "cbd-tui"
|
name = "cbd-tui"
|
||||||
version = "0.1.0"
|
version.workspace = true
|
||||||
edition = "2021"
|
edition.workspace = true
|
||||||
|
|
||||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
crossterm = "0.26.1"
|
crabidy-core.workspace = true
|
||||||
crabidy-core = { path = "../crabidy-core" }
|
crossterm.workspace = true
|
||||||
flume = "0.10.14"
|
dirs.workspace = true
|
||||||
ratatui = "0.20.1"
|
flume.workspace = true
|
||||||
tokio = { version = "1", features = ["full"] }
|
notify-rust.workspace = true
|
||||||
tokio-stream = "0.1"
|
ratatui.workspace = true
|
||||||
tonic = "0.9"
|
serde.workspace = true
|
||||||
notify-rust = "4.8.0"
|
tokio = { workspace = true, features = ["full"] }
|
||||||
serde = "1.0.164"
|
tokio-stream.workspace = true
|
||||||
|
tonic.workspace = true
|
||||||
|
tracing.workspace = true
|
||||||
|
tracing-appender.workspace = true
|
||||||
|
tracing-subscriber.workspace = true
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,6 @@ use std::collections::HashMap;
|
||||||
|
|
||||||
use flume::Sender;
|
use flume::Sender;
|
||||||
use ratatui::{
|
use ratatui::{
|
||||||
backend::Backend,
|
|
||||||
layout::Rect,
|
layout::Rect,
|
||||||
style::{Modifier, Style},
|
style::{Modifier, Style},
|
||||||
text::Span,
|
text::Span,
|
||||||
|
|
@ -18,7 +17,7 @@ use super::{
|
||||||
|
|
||||||
pub struct Library {
|
pub struct Library {
|
||||||
title: String,
|
title: String,
|
||||||
uuid: String,
|
path: String,
|
||||||
list: Vec<UiItem>,
|
list: Vec<UiItem>,
|
||||||
list_state: ListState,
|
list_state: ListState,
|
||||||
parent: Option<String>,
|
parent: Option<String>,
|
||||||
|
|
@ -30,7 +29,7 @@ impl Library {
|
||||||
pub fn new(tx: Sender<MessageFromUi>) -> Self {
|
pub fn new(tx: Sender<MessageFromUi>) -> Self {
|
||||||
Self {
|
Self {
|
||||||
title: "Library".to_string(),
|
title: "Library".to_string(),
|
||||||
uuid: "node:/".to_string(),
|
path: crabidy_core::ROOT_PATH.to_string(),
|
||||||
list: Vec::new(),
|
list: Vec::new(),
|
||||||
list_state: ListState::default(),
|
list_state: ListState::default(),
|
||||||
positions: HashMap::new(),
|
positions: HashMap::new(),
|
||||||
|
|
@ -44,26 +43,27 @@ impl Library {
|
||||||
self.list
|
self.list
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|i| i.marked)
|
.filter(|i| i.marked)
|
||||||
.map(|i| i.uuid.to_string())
|
.map(|i| i.path.to_string())
|
||||||
.collect(),
|
.collect(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if let Some(idx) = self.list_state.selected() {
|
if let Some(idx) = self.list_state.selected() {
|
||||||
return Some(vec![self.list[idx].uuid.to_string()]);
|
return Some(vec![self.list[idx].path.to_string()]);
|
||||||
}
|
}
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
pub fn ascend(&mut self) {
|
pub fn ascend(&mut self) {
|
||||||
if let Some(parent) = self.parent.as_ref() {
|
if let Some(parent) = self.parent.as_ref() {
|
||||||
self.tx.send(MessageFromUi::GetLibraryNode(parent.clone()));
|
let _ = self.tx.send(MessageFromUi::GetLibraryNode(parent.clone()));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
pub fn dive(&mut self) {
|
pub fn dive(&mut self) {
|
||||||
if let Some(idx) = self.list_state.selected() {
|
if let Some(idx) = self.list_state.selected() {
|
||||||
let item = &self.list[idx];
|
let item = &self.list[idx];
|
||||||
if let UiItemKind::Node = item.kind {
|
if let UiItemKind::Node = item.kind {
|
||||||
self.tx
|
let _ = self
|
||||||
.send(MessageFromUi::GetLibraryNode(item.uuid.clone()));
|
.tx
|
||||||
|
.send(MessageFromUi::GetLibraryNode(item.path.clone()));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -100,11 +100,11 @@ impl Library {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
pub fn prev_selected(&self) -> usize {
|
pub fn prev_selected(&self) -> usize {
|
||||||
*self.positions.get(&self.uuid).unwrap_or(&0)
|
*self.positions.get(&self.path).unwrap_or(&0)
|
||||||
}
|
}
|
||||||
pub fn toggle_mark(&mut self) {
|
pub fn toggle_mark(&mut self) {
|
||||||
if let Some(idx) = self.list_state.selected() {
|
if let Some(idx) = self.list_state.selected() {
|
||||||
let mut item = &mut self.list[idx];
|
let item = &mut self.list[idx];
|
||||||
if !item.is_queable {
|
if !item.is_queable {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -125,7 +125,7 @@ impl Library {
|
||||||
}
|
}
|
||||||
|
|
||||||
// if children empty and tracks empty return
|
// if children empty and tracks empty return
|
||||||
self.uuid = node.uuid;
|
self.path = node.path;
|
||||||
self.title = node.title;
|
self.title = node.title;
|
||||||
self.parent = node.parent;
|
self.parent = node.parent;
|
||||||
self.select(Some(self.prev_selected()));
|
self.select(Some(self.prev_selected()));
|
||||||
|
|
@ -135,7 +135,7 @@ impl Library {
|
||||||
.tracks
|
.tracks
|
||||||
.iter()
|
.iter()
|
||||||
.map(|t| UiItem {
|
.map(|t| UiItem {
|
||||||
uuid: t.uuid.clone(),
|
path: t.path.clone(),
|
||||||
title: format!("{} - {}", t.artist, t.title),
|
title: format!("{} - {}", t.artist, t.title),
|
||||||
kind: UiItemKind::Track,
|
kind: UiItemKind::Track,
|
||||||
marked: false,
|
marked: false,
|
||||||
|
|
@ -148,7 +148,7 @@ impl Library {
|
||||||
.children
|
.children
|
||||||
.iter()
|
.iter()
|
||||||
.map(|c| UiItem {
|
.map(|c| UiItem {
|
||||||
uuid: c.uuid.clone(),
|
path: c.path.clone(),
|
||||||
title: c.title.clone(),
|
title: c.title.clone(),
|
||||||
kind: UiItemKind::Node,
|
kind: UiItemKind::Node,
|
||||||
marked: false,
|
marked: false,
|
||||||
|
|
@ -160,7 +160,7 @@ impl Library {
|
||||||
self.update_selection();
|
self.update_selection();
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn render<B: Backend>(&mut self, f: &mut Frame<B>, area: Rect, focused: bool) {
|
pub fn render(&mut self, f: &mut Frame, area: Rect, focused: bool) {
|
||||||
let library_items: Vec<ListItem> = self
|
let library_items: Vec<ListItem> = self
|
||||||
.list
|
.list
|
||||||
.iter()
|
.iter()
|
||||||
|
|
@ -177,7 +177,7 @@ impl Library {
|
||||||
} else {
|
} else {
|
||||||
Style::default()
|
Style::default()
|
||||||
};
|
};
|
||||||
return ListItem::new(Span::from(text)).style(style);
|
ListItem::new(Span::from(text)).style(style)
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
|
|
@ -215,7 +215,7 @@ impl StatefulList for Library {
|
||||||
fn select(&mut self, idx: Option<usize>) {
|
fn select(&mut self, idx: Option<usize>) {
|
||||||
if let Some(pos) = idx {
|
if let Some(pos) = idx {
|
||||||
self.positions
|
self.positions
|
||||||
.entry(self.uuid.clone())
|
.entry(self.path.clone())
|
||||||
.and_modify(|e| *e = pos)
|
.and_modify(|e| *e = pos)
|
||||||
.or_insert(pos);
|
.or_insert(pos);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,3 @@
|
||||||
pub use ratatui::widgets::ListState;
|
|
||||||
|
|
||||||
// FIXME: Move marking stuff here, to be able to use it in queue as well
|
// FIXME: Move marking stuff here, to be able to use it in queue as well
|
||||||
pub trait StatefulList {
|
pub trait StatefulList {
|
||||||
fn get_size(&self) -> usize;
|
fn get_size(&self) -> usize;
|
||||||
|
|
@ -72,10 +70,6 @@ pub trait StatefulList {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn is_selected(&self) -> bool {
|
|
||||||
self.selected().is_some()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn is_empty(&self) -> bool {
|
fn is_empty(&self) -> bool {
|
||||||
self.get_size() == 0
|
self.get_size() == 0
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,6 @@ mod queue;
|
||||||
|
|
||||||
use flume::Sender;
|
use flume::Sender;
|
||||||
use ratatui::{
|
use ratatui::{
|
||||||
backend::Backend,
|
|
||||||
layout::{Constraint, Direction, Layout},
|
layout::{Constraint, Direction, Layout},
|
||||||
style::Color,
|
style::Color,
|
||||||
Frame,
|
Frame,
|
||||||
|
|
@ -34,7 +33,7 @@ enum UiItemKind {
|
||||||
}
|
}
|
||||||
|
|
||||||
struct UiItem {
|
struct UiItem {
|
||||||
uuid: String,
|
path: String,
|
||||||
title: String,
|
title: String,
|
||||||
kind: UiItemKind,
|
kind: UiItemKind,
|
||||||
marked: bool,
|
marked: bool,
|
||||||
|
|
@ -105,8 +104,8 @@ impl App {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn render<B: Backend>(&mut self, f: &mut Frame<B>) {
|
pub fn render(&mut self, f: &mut Frame) {
|
||||||
let full_screen = f.size();
|
let _full_screen = f.area();
|
||||||
|
|
||||||
let library_focused = matches!(self.focus, UiFocus::Library);
|
let library_focused = matches!(self.focus, UiFocus::Library);
|
||||||
let queue_focused = matches!(self.focus, UiFocus::Queue);
|
let queue_focused = matches!(self.focus, UiFocus::Queue);
|
||||||
|
|
@ -114,7 +113,7 @@ impl App {
|
||||||
let main = Layout::default()
|
let main = Layout::default()
|
||||||
.direction(Direction::Horizontal)
|
.direction(Direction::Horizontal)
|
||||||
.constraints([Constraint::Percentage(50), Constraint::Percentage(50)].as_ref())
|
.constraints([Constraint::Percentage(50), Constraint::Percentage(50)].as_ref())
|
||||||
.split(f.size());
|
.split(f.area());
|
||||||
|
|
||||||
self.library.render(f, main[0], library_focused);
|
self.library.render(f, main[0], library_focused);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,10 +5,9 @@ use notify_rust::Notification;
|
||||||
use crabidy_core::proto::crabidy::{PlayState, QueueModifiers, Track, TrackPosition};
|
use crabidy_core::proto::crabidy::{PlayState, QueueModifiers, Track, TrackPosition};
|
||||||
|
|
||||||
use ratatui::{
|
use ratatui::{
|
||||||
backend::Backend,
|
|
||||||
layout::{Alignment, Constraint, Direction, Layout, Rect},
|
layout::{Alignment, Constraint, Direction, Layout, Rect},
|
||||||
style::{Color, Modifier, Style},
|
style::{Color, Modifier, Style},
|
||||||
text::{Span, Spans},
|
text::{Line, Span},
|
||||||
widgets::{Block, BorderType, Borders, LineGauge, Paragraph, Wrap},
|
widgets::{Block, BorderType, Borders, LineGauge, Paragraph, Wrap},
|
||||||
Frame,
|
Frame,
|
||||||
};
|
};
|
||||||
|
|
@ -57,19 +56,22 @@ impl NowPlaying {
|
||||||
} else {
|
} else {
|
||||||
format!("{} by {}", track.title, track.artist,)
|
format!("{} by {}", track.title, track.artist,)
|
||||||
};
|
};
|
||||||
Notification::new()
|
// A missing notification daemon must not crash the TUI.
|
||||||
|
if let Err(err) = Notification::new()
|
||||||
.summary("Now playing")
|
.summary("Now playing")
|
||||||
.body(&body)
|
.body(&body)
|
||||||
.show()
|
.show()
|
||||||
.unwrap();
|
{
|
||||||
|
tracing::debug!("could not show desktop notification: {err}");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
self.track = active;
|
self.track = active;
|
||||||
}
|
}
|
||||||
pub fn update_modifiers(&mut self, mods: &QueueModifiers) {
|
pub fn update_modifiers(&mut self, mods: &QueueModifiers) {
|
||||||
self.modifiers = mods.clone();
|
self.modifiers = *mods;
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn render<B: Backend>(&self, f: &mut Frame<B>, area: Rect) {
|
pub fn render(&self, f: &mut Frame, area: Rect) {
|
||||||
let now_playing_layout = Layout::default()
|
let now_playing_layout = Layout::default()
|
||||||
.direction(Direction::Vertical)
|
.direction(Direction::Vertical)
|
||||||
.constraints([Constraint::Max(8), Constraint::Max(1)])
|
.constraints([Constraint::Max(8), Constraint::Max(1)])
|
||||||
|
|
@ -91,9 +93,9 @@ impl NowPlaying {
|
||||||
self.modifiers.shuffle, self.modifiers.repeat
|
self.modifiers.shuffle, self.modifiers.repeat
|
||||||
);
|
);
|
||||||
vec![
|
vec![
|
||||||
Spans::from(Span::raw(mods)),
|
Line::from(Span::raw(mods)),
|
||||||
Spans::from(Span::raw(play_text)),
|
Line::from(Span::raw(play_text)),
|
||||||
Spans::from(vec![
|
Line::from(vec![
|
||||||
Span::styled(
|
Span::styled(
|
||||||
track.title.to_string(),
|
track.title.to_string(),
|
||||||
Style::default().add_modifier(Modifier::BOLD),
|
Style::default().add_modifier(Modifier::BOLD),
|
||||||
|
|
@ -104,13 +106,13 @@ impl NowPlaying {
|
||||||
Style::default().add_modifier(Modifier::BOLD),
|
Style::default().add_modifier(Modifier::BOLD),
|
||||||
),
|
),
|
||||||
]),
|
]),
|
||||||
Spans::from(Span::raw(album_text)),
|
Line::from(Span::raw(album_text)),
|
||||||
]
|
]
|
||||||
} else {
|
} else {
|
||||||
vec![
|
vec![
|
||||||
Spans::from(Span::raw("")),
|
Line::from(Span::raw("")),
|
||||||
Spans::from(Span::raw("")),
|
Line::from(Span::raw("")),
|
||||||
Spans::from(Span::raw("No track playing")),
|
Line::from(Span::raw("No track playing")),
|
||||||
]
|
]
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -127,7 +129,7 @@ impl NowPlaying {
|
||||||
|
|
||||||
f.render_widget(media_info_p, now_playing_layout[0]);
|
f.render_widget(media_info_p, now_playing_layout[0]);
|
||||||
|
|
||||||
if let (Some(position), Some(duration), Some(track)) =
|
if let (Some(position), Some(duration), Some(_track)) =
|
||||||
(self.position, self.duration, &self.track)
|
(self.position, self.duration, &self.track)
|
||||||
{
|
{
|
||||||
let pos = position.as_secs();
|
let pos = position.as_secs();
|
||||||
|
|
@ -149,7 +151,7 @@ impl NowPlaying {
|
||||||
let progress = LineGauge::default()
|
let progress = LineGauge::default()
|
||||||
.label("")
|
.label("")
|
||||||
.block(Block::default().borders(Borders::NONE))
|
.block(Block::default().borders(Borders::NONE))
|
||||||
.gauge_style(Style::default().fg(COLOR_SECONDARY).bg(Color::Black))
|
.filled_style(Style::default().fg(COLOR_SECONDARY).bg(Color::Black))
|
||||||
.ratio(ratio);
|
.ratio(ratio);
|
||||||
f.render_widget(progress, elapsed_layout[0]);
|
f.render_widget(progress, elapsed_layout[0]);
|
||||||
|
|
||||||
|
|
@ -173,7 +175,7 @@ impl NowPlaying {
|
||||||
};
|
};
|
||||||
|
|
||||||
let time_text = Span::raw(completion_text);
|
let time_text = Span::raw(completion_text);
|
||||||
let time_p = Paragraph::new(Spans::from(time_text));
|
let time_p = Paragraph::new(Line::from(time_text));
|
||||||
f.render_widget(time_p, elapsed_layout[1]);
|
f.render_widget(time_p, elapsed_layout[1]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
use flume::Sender;
|
use flume::Sender;
|
||||||
use ratatui::{
|
use ratatui::{
|
||||||
backend::Backend,
|
|
||||||
layout::Rect,
|
layout::Rect,
|
||||||
style::{Modifier, Style},
|
style::{Modifier, Style},
|
||||||
text::Span,
|
text::Span,
|
||||||
|
|
@ -31,14 +30,14 @@ impl Queue {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
pub fn play_next(&self) {
|
pub fn play_next(&self) {
|
||||||
self.tx.send(MessageFromUi::NextTrack);
|
let _ = self.tx.send(MessageFromUi::NextTrack);
|
||||||
}
|
}
|
||||||
pub fn play_prev(&self) {
|
pub fn play_prev(&self) {
|
||||||
self.tx.send(MessageFromUi::PrevTrack);
|
let _ = self.tx.send(MessageFromUi::PrevTrack);
|
||||||
}
|
}
|
||||||
pub fn play_selected(&self) {
|
pub fn play_selected(&self) {
|
||||||
if let Some(pos) = self.selected() {
|
if let Some(pos) = self.selected() {
|
||||||
self.tx.send(MessageFromUi::SetCurrentTrack(pos));
|
let _ = self.tx.send(MessageFromUi::SetCurrentTrack(pos));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
pub fn select_current(&mut self) {
|
pub fn select_current(&mut self) {
|
||||||
|
|
@ -47,7 +46,7 @@ impl Queue {
|
||||||
pub fn remove_track(&mut self) {
|
pub fn remove_track(&mut self) {
|
||||||
if let Some(pos) = self.selected() {
|
if let Some(pos) = self.selected() {
|
||||||
// FIXME: mark multiple tracks on queue and remove them
|
// FIXME: mark multiple tracks on queue and remove them
|
||||||
self.tx.send(MessageFromUi::RemoveTracks(vec![pos]));
|
let _ = self.tx.send(MessageFromUi::RemoveTracks(vec![pos]));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
pub fn update_position(&mut self, pos: usize) {
|
pub fn update_position(&mut self, pos: usize) {
|
||||||
|
|
@ -58,9 +57,8 @@ impl Queue {
|
||||||
self.list = queue
|
self.list = queue
|
||||||
.tracks
|
.tracks
|
||||||
.iter()
|
.iter()
|
||||||
.enumerate()
|
.map(|t| UiItem {
|
||||||
.map(|(i, t)| UiItem {
|
path: t.path.clone(),
|
||||||
uuid: t.uuid.clone(),
|
|
||||||
title: format!("{} - {}", t.artist, t.title),
|
title: format!("{} - {}", t.artist, t.title),
|
||||||
kind: UiItemKind::Track,
|
kind: UiItemKind::Track,
|
||||||
marked: false,
|
marked: false,
|
||||||
|
|
@ -71,7 +69,7 @@ impl Queue {
|
||||||
self.update_selection();
|
self.update_selection();
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn render<B: Backend>(&mut self, f: &mut Frame<B>, area: Rect, focused: bool) {
|
pub fn render(&mut self, f: &mut Frame, area: Rect, focused: bool) {
|
||||||
let queue_items: Vec<ListItem> = self
|
let queue_items: Vec<ListItem> = self
|
||||||
.list
|
.list
|
||||||
.iter()
|
.iter()
|
||||||
|
|
|
||||||
|
|
@ -27,11 +27,45 @@ use tokio_stream::StreamExt;
|
||||||
use app::{App, MessageFromUi, MessageToUi, StatefulList, UiFocus};
|
use app::{App, MessageFromUi, MessageToUi, StatefulList, UiFocus};
|
||||||
use config::Config;
|
use config::Config;
|
||||||
use rpc::RpcClient;
|
use rpc::RpcClient;
|
||||||
|
use tracing::{error, info, warn};
|
||||||
|
|
||||||
static CONFIG: OnceLock<Config> = OnceLock::new();
|
static CONFIG: OnceLock<Config> = OnceLock::new();
|
||||||
|
|
||||||
|
/// Logs to a file: the terminal is owned by the TUI, so writing log lines to
|
||||||
|
/// stdout/stderr would corrupt the interface.
|
||||||
|
fn init_tracing() -> Option<tracing_appender::non_blocking::WorkerGuard> {
|
||||||
|
use tracing_subscriber::{prelude::*, EnvFilter};
|
||||||
|
|
||||||
|
let log_dir = dirs::state_dir()
|
||||||
|
.or_else(dirs::cache_dir)
|
||||||
|
.unwrap_or_else(std::env::temp_dir)
|
||||||
|
.join("crabidy");
|
||||||
|
if let Err(err) = std::fs::create_dir_all(&log_dir) {
|
||||||
|
eprintln!(
|
||||||
|
"could not create log directory {}: {err}",
|
||||||
|
log_dir.display()
|
||||||
|
);
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let file_appender = tracing_appender::rolling::daily(&log_dir, "cbd-tui.log");
|
||||||
|
let (non_blocking, guard) = tracing_appender::non_blocking(file_appender);
|
||||||
|
let env_filter = EnvFilter::try_from_default_env()
|
||||||
|
.unwrap_or_else(|_| EnvFilter::new("info,cbd_tui=debug,crabidy_core=debug"));
|
||||||
|
tracing_subscriber::registry()
|
||||||
|
.with(env_filter)
|
||||||
|
.with(
|
||||||
|
tracing_subscriber::fmt::layer()
|
||||||
|
.with_writer(non_blocking)
|
||||||
|
.with_ansi(false)
|
||||||
|
.with_target(true),
|
||||||
|
)
|
||||||
|
.init();
|
||||||
|
Some(guard)
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
let _log_guard = init_tracing();
|
||||||
let config = CONFIG.get_or_init(|| crabidy_core::init_config("cbd-tui.toml"));
|
let config = CONFIG.get_or_init(|| crabidy_core::init_config("cbd-tui.toml"));
|
||||||
|
|
||||||
let (ui_tx, rx): (Sender<MessageFromUi>, Receiver<MessageFromUi>) = flume::unbounded();
|
let (ui_tx, rx): (Sender<MessageFromUi>, Receiver<MessageFromUi>) = flume::unbounded();
|
||||||
|
|
@ -48,22 +82,24 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn orchestrate<'a>(
|
async fn orchestrate(
|
||||||
config: &'static Config,
|
config: &'static Config,
|
||||||
(tx, rx): (Sender<MessageToUi>, Receiver<MessageFromUi>),
|
(tx, rx): (Sender<MessageToUi>, Receiver<MessageFromUi>),
|
||||||
) -> Result<(), Box<dyn Error>> {
|
) -> Result<(), Box<dyn Error>> {
|
||||||
|
info!(address = config.server.address, "connecting to server");
|
||||||
let mut rpc_client = rpc::RpcClient::connect(&config.server.address).await?;
|
let mut rpc_client = rpc::RpcClient::connect(&config.server.address).await?;
|
||||||
|
|
||||||
if let Some(root_node) = rpc_client.get_library_node("node:/").await? {
|
if let Some(root_node) = rpc_client.get_library_node(crabidy_core::ROOT_PATH).await? {
|
||||||
tx.send(MessageToUi::ReplaceLibraryNode(root_node.clone()))?;
|
tx.send(MessageToUi::ReplaceLibraryNode(root_node.clone()))?;
|
||||||
}
|
}
|
||||||
|
|
||||||
let init_data = rpc_client.init().await?;
|
let init_data = rpc_client.init().await?;
|
||||||
|
info!("received initial state from server");
|
||||||
tx.send_async(MessageToUi::Init(init_data)).await?;
|
tx.send_async(MessageToUi::Init(init_data)).await?;
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
if let Err(er) = poll(&mut rpc_client, &rx, &tx).await {
|
if let Err(err) = poll(&mut rpc_client, &rx, &tx).await {
|
||||||
println!("ERROR");
|
error!("request to server failed: {err}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -76,9 +112,9 @@ async fn poll(
|
||||||
select! {
|
select! {
|
||||||
Ok(msg) = &mut rx.recv_async() => {
|
Ok(msg) = &mut rx.recv_async() => {
|
||||||
match msg {
|
match msg {
|
||||||
MessageFromUi::GetLibraryNode(uuid) => {
|
MessageFromUi::GetLibraryNode(path) => {
|
||||||
if let Some(node) = rpc_client.get_library_node(&uuid).await? {
|
if let Some(node) = rpc_client.get_library_node(&path).await? {
|
||||||
tx.send(MessageToUi::ReplaceLibraryNode(node.clone()));
|
let _ = tx.send(MessageToUi::ReplaceLibraryNode(node.clone()));
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
MessageFromUi::AppendTracks(uuids) => {
|
MessageFromUi::AppendTracks(uuids) => {
|
||||||
|
|
@ -135,8 +171,10 @@ async fn poll(
|
||||||
tx.send_async(MessageToUi::Update(update)).await?;
|
tx.send_async(MessageToUi::Update(update)).await?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Err(_) => {
|
Err(err) => {
|
||||||
|
warn!("update stream broke, reconnecting: {err}");
|
||||||
rpc_client.reconnect_update_stream().await;
|
rpc_client.reconnect_update_stream().await;
|
||||||
|
info!("update stream reconnected");
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
@ -173,7 +211,7 @@ fn run_ui(tx: Sender<MessageFromUi>, rx: Receiver<MessageToUi>) {
|
||||||
app.now_playing.update_track(track.track);
|
app.now_playing.update_track(track.track);
|
||||||
app.queue.update_position(track.queue_position as usize);
|
app.queue.update_position(track.queue_position as usize);
|
||||||
}
|
}
|
||||||
if let Some(ps) = PlayState::from_i32(init_data.play_state) {
|
if let Ok(ps) = PlayState::try_from(init_data.play_state) {
|
||||||
app.now_playing.update_play_state(ps);
|
app.now_playing.update_play_state(ps);
|
||||||
}
|
}
|
||||||
if let Some(mods) = init_data.mods {
|
if let Some(mods) = init_data.mods {
|
||||||
|
|
@ -190,7 +228,7 @@ fn run_ui(tx: Sender<MessageFromUi>, rx: Receiver<MessageToUi>) {
|
||||||
}
|
}
|
||||||
StreamUpdate::Position(pos) => app.now_playing.update_position(pos),
|
StreamUpdate::Position(pos) => app.now_playing.update_position(pos),
|
||||||
StreamUpdate::PlayState(play_state) => {
|
StreamUpdate::PlayState(play_state) => {
|
||||||
if let Some(ps) = PlayState::from_i32(play_state) {
|
if let Ok(ps) = PlayState::try_from(play_state) {
|
||||||
app.now_playing.update_play_state(ps);
|
app.now_playing.update_play_state(ps);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -203,7 +241,10 @@ fn run_ui(tx: Sender<MessageFromUi>, rx: Receiver<MessageToUi>) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
terminal.draw(|f| app.render(f));
|
if let Err(err) = terminal.draw(|f| app.render(f)) {
|
||||||
|
error!("failed to draw frame: {err}");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
let timeout = tick_rate
|
let timeout = tick_rate
|
||||||
.checked_sub(last_tick.elapsed())
|
.checked_sub(last_tick.elapsed())
|
||||||
|
|
@ -218,25 +259,25 @@ fn run_ui(tx: Sender<MessageFromUi>, rx: Receiver<MessageToUi>) {
|
||||||
}
|
}
|
||||||
(_, KeyModifiers::NONE, KeyCode::Tab) => app.cycle_active(),
|
(_, KeyModifiers::NONE, KeyCode::Tab) => app.cycle_active(),
|
||||||
(_, KeyModifiers::NONE, KeyCode::Char(' ')) => {
|
(_, KeyModifiers::NONE, KeyCode::Char(' ')) => {
|
||||||
tx.send(MessageFromUi::TogglePlay);
|
let _ = tx.send(MessageFromUi::TogglePlay);
|
||||||
}
|
}
|
||||||
(_, KeyModifiers::NONE, KeyCode::Char('r')) => {
|
(_, KeyModifiers::NONE, KeyCode::Char('r')) => {
|
||||||
tx.send(MessageFromUi::RestartTrack);
|
let _ = tx.send(MessageFromUi::RestartTrack);
|
||||||
}
|
}
|
||||||
(_, KeyModifiers::SHIFT, KeyCode::Char('J')) => {
|
(_, KeyModifiers::SHIFT, KeyCode::Char('J')) => {
|
||||||
tx.send(MessageFromUi::ChangeVolume(-0.1));
|
let _ = tx.send(MessageFromUi::ChangeVolume(-0.1));
|
||||||
}
|
}
|
||||||
(_, KeyModifiers::SHIFT, KeyCode::Char('K')) => {
|
(_, KeyModifiers::SHIFT, KeyCode::Char('K')) => {
|
||||||
tx.send(MessageFromUi::ChangeVolume(0.1));
|
let _ = tx.send(MessageFromUi::ChangeVolume(0.1));
|
||||||
}
|
}
|
||||||
(_, KeyModifiers::NONE, KeyCode::Char('m')) => {
|
(_, KeyModifiers::NONE, KeyCode::Char('m')) => {
|
||||||
tx.send(MessageFromUi::ToggleMute);
|
let _ = tx.send(MessageFromUi::ToggleMute);
|
||||||
}
|
}
|
||||||
(_, KeyModifiers::NONE, KeyCode::Char('z')) => {
|
(_, KeyModifiers::NONE, KeyCode::Char('z')) => {
|
||||||
tx.send(MessageFromUi::ToggleShuffle);
|
let _ = tx.send(MessageFromUi::ToggleShuffle);
|
||||||
}
|
}
|
||||||
(_, KeyModifiers::NONE, KeyCode::Char('x')) => {
|
(_, KeyModifiers::NONE, KeyCode::Char('x')) => {
|
||||||
tx.send(MessageFromUi::ToggleRepeat);
|
let _ = tx.send(MessageFromUi::ToggleRepeat);
|
||||||
}
|
}
|
||||||
(_, KeyModifiers::CONTROL, KeyCode::Char('n')) => {
|
(_, KeyModifiers::CONTROL, KeyCode::Char('n')) => {
|
||||||
app.queue.play_next();
|
app.queue.play_next();
|
||||||
|
|
@ -313,10 +354,10 @@ fn run_ui(tx: Sender<MessageFromUi>, rx: Receiver<MessageToUi>) {
|
||||||
app.queue.remove_track();
|
app.queue.remove_track();
|
||||||
}
|
}
|
||||||
(UiFocus::Queue, KeyModifiers::NONE, KeyCode::Char('c')) => {
|
(UiFocus::Queue, KeyModifiers::NONE, KeyCode::Char('c')) => {
|
||||||
tx.send(MessageFromUi::ClearQueue(true));
|
let _ = tx.send(MessageFromUi::ClearQueue(true));
|
||||||
}
|
}
|
||||||
(UiFocus::Queue, KeyModifiers::SHIFT, KeyCode::Char('C')) => {
|
(UiFocus::Queue, KeyModifiers::SHIFT, KeyCode::Char('C')) => {
|
||||||
tx.send(MessageFromUi::ClearQueue(false));
|
let _ = tx.send(MessageFromUi::ClearQueue(false));
|
||||||
}
|
}
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -75,13 +75,13 @@ impl RpcClient {
|
||||||
|
|
||||||
pub async fn get_library_node(
|
pub async fn get_library_node(
|
||||||
&mut self,
|
&mut self,
|
||||||
uuid: &str,
|
path: &str,
|
||||||
) -> Result<Option<&LibraryNode>, Box<dyn Error>> {
|
) -> Result<Option<&LibraryNode>, Box<dyn Error>> {
|
||||||
if self.library_node_cache.contains_key(uuid) {
|
if self.library_node_cache.contains_key(path) {
|
||||||
return Ok(self.library_node_cache.get(uuid));
|
return Ok(self.library_node_cache.get(path));
|
||||||
}
|
}
|
||||||
let get_library_node_request = Request::new(GetLibraryNodeRequest {
|
let get_library_node_request = Request::new(GetLibraryNodeRequest {
|
||||||
uuid: uuid.to_string(),
|
path: path.to_string(),
|
||||||
});
|
});
|
||||||
let response = self
|
let response = self
|
||||||
.client
|
.client
|
||||||
|
|
@ -89,31 +89,31 @@ impl RpcClient {
|
||||||
.await?;
|
.await?;
|
||||||
if let Some(library_node) = response.into_inner().node {
|
if let Some(library_node) = response.into_inner().node {
|
||||||
self.library_node_cache
|
self.library_node_cache
|
||||||
.insert(uuid.to_string(), library_node);
|
.insert(path.to_string(), library_node);
|
||||||
return Ok(self.library_node_cache.get(uuid));
|
return Ok(self.library_node_cache.get(path));
|
||||||
}
|
}
|
||||||
Err(Box::new(RpcClientError::NotFound))
|
Err(Box::new(RpcClientError::NotFound))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn append_tracks(&mut self, uuids: Vec<String>) -> Result<(), Box<dyn Error>> {
|
pub async fn append_tracks(&mut self, paths: Vec<String>) -> Result<(), Box<dyn Error>> {
|
||||||
let append_request = Request::new(AppendRequest { uuids });
|
let append_request = Request::new(AppendRequest { paths });
|
||||||
self.client.append(append_request).await?;
|
self.client.append(append_request).await?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn queue_tracks(&mut self, uuids: Vec<String>) -> Result<(), Box<dyn Error>> {
|
pub async fn queue_tracks(&mut self, paths: Vec<String>) -> Result<(), Box<dyn Error>> {
|
||||||
let queue_request = Request::new(QueueRequest { uuids });
|
let queue_request = Request::new(QueueRequest { paths });
|
||||||
self.client.queue(queue_request).await?;
|
self.client.queue(queue_request).await?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn insert_tracks(
|
pub async fn insert_tracks(
|
||||||
&mut self,
|
&mut self,
|
||||||
uuids: Vec<String>,
|
paths: Vec<String>,
|
||||||
pos: usize,
|
pos: usize,
|
||||||
) -> Result<(), Box<dyn Error>> {
|
) -> Result<(), Box<dyn Error>> {
|
||||||
let insert_request = Request::new(InsertRequest {
|
let insert_request = Request::new(InsertRequest {
|
||||||
uuids,
|
paths,
|
||||||
position: pos as u32,
|
position: pos as u32,
|
||||||
});
|
});
|
||||||
self.client.insert(insert_request).await?;
|
self.client.insert(insert_request).await?;
|
||||||
|
|
@ -134,8 +134,8 @@ impl RpcClient {
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn replace_queue(&mut self, uuids: Vec<String>) -> Result<(), Box<dyn Error>> {
|
pub async fn replace_queue(&mut self, paths: Vec<String>) -> Result<(), Box<dyn Error>> {
|
||||||
let replace_request = Request::new(ReplaceRequest { uuids });
|
let replace_request = Request::new(ReplaceRequest { paths });
|
||||||
self.client.replace(replace_request).await?;
|
self.client.replace(replace_request).await?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,21 +1,22 @@
|
||||||
[package]
|
[package]
|
||||||
name = "crabidy-core"
|
name = "crabidy-core"
|
||||||
version = "0.1.0"
|
version.workspace = true
|
||||||
edition = "2021"
|
edition.workspace = true
|
||||||
|
|
||||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
async-trait = "0.1.68"
|
async-trait.workspace = true
|
||||||
clap = "4.3.3"
|
clap-serde-derive.workspace = true
|
||||||
clap-serde-derive = "0.2.0"
|
dirs.workspace = true
|
||||||
dirs = "5.0.1"
|
prost.workspace = true
|
||||||
prost = "0.11"
|
serde.workspace = true
|
||||||
serde = "1.0.163"
|
toml.workspace = true
|
||||||
toml = "0.7.4"
|
tonic.workspace = true
|
||||||
tonic = "0.9"
|
tonic-prost.workspace = true
|
||||||
|
|
||||||
[build-dependencies]
|
[build-dependencies]
|
||||||
async-trait = "0.1.68"
|
tonic-prost-build.workspace = true
|
||||||
serde = "1.0.163"
|
|
||||||
tonic-build = "0.9"
|
# prost and tonic-prost are used by the code generated from the proto files,
|
||||||
|
# which cargo-machete cannot see.
|
||||||
|
[package.metadata.cargo-machete]
|
||||||
|
ignored = ["prost", "tonic-prost"]
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
tonic_build::compile_protos("crabidy/v1/crabidy.proto")?;
|
tonic_prost_build::compile_protos("crabidy/v1/crabidy.proto")?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -45,7 +45,7 @@ message InitResponse {
|
||||||
|
|
||||||
// Library
|
// Library
|
||||||
message GetLibraryNodeRequest {
|
message GetLibraryNodeRequest {
|
||||||
string uuid = 1;
|
string path = 1;
|
||||||
}
|
}
|
||||||
message GetLibraryNodeResponse {
|
message GetLibraryNodeResponse {
|
||||||
LibraryNode node = 1;
|
LibraryNode node = 1;
|
||||||
|
|
@ -53,17 +53,17 @@ message GetLibraryNodeResponse {
|
||||||
|
|
||||||
// Queue
|
// Queue
|
||||||
message QueueRequest {
|
message QueueRequest {
|
||||||
repeated string uuids = 1;
|
repeated string paths = 1;
|
||||||
}
|
}
|
||||||
message QueueResponse {}
|
message QueueResponse {}
|
||||||
|
|
||||||
message ReplaceRequest {
|
message ReplaceRequest {
|
||||||
repeated string uuids = 1;
|
repeated string paths = 1;
|
||||||
}
|
}
|
||||||
message ReplaceResponse {}
|
message ReplaceResponse {}
|
||||||
|
|
||||||
message AppendRequest {
|
message AppendRequest {
|
||||||
repeated string uuids = 1;
|
repeated string paths = 1;
|
||||||
}
|
}
|
||||||
message AppendResponse {}
|
message AppendResponse {}
|
||||||
|
|
||||||
|
|
@ -74,7 +74,7 @@ message RemoveResponse {}
|
||||||
|
|
||||||
message InsertRequest {
|
message InsertRequest {
|
||||||
uint32 position = 1;
|
uint32 position = 1;
|
||||||
repeated string uuids = 2;
|
repeated string paths = 2;
|
||||||
}
|
}
|
||||||
message InsertResponse {}
|
message InsertResponse {}
|
||||||
|
|
||||||
|
|
@ -139,7 +139,7 @@ message RestartTrackResponse {}
|
||||||
|
|
||||||
// Data types
|
// Data types
|
||||||
message LibraryNodeChild {
|
message LibraryNodeChild {
|
||||||
string uuid = 1;
|
string path = 1;
|
||||||
string title = 2;
|
string title = 2;
|
||||||
bool is_queable = 3;
|
bool is_queable = 3;
|
||||||
}
|
}
|
||||||
|
|
@ -181,8 +181,8 @@ message Album {
|
||||||
}
|
}
|
||||||
|
|
||||||
message Track {
|
message Track {
|
||||||
// Including provider
|
// Full library path including provider
|
||||||
string uuid = 1;
|
string path = 1;
|
||||||
string artist = 2;
|
string artist = 2;
|
||||||
string title = 3;
|
string title = 3;
|
||||||
optional uint32 duration = 4;
|
optional uint32 duration = 4;
|
||||||
|
|
@ -190,8 +190,8 @@ message Track {
|
||||||
}
|
}
|
||||||
|
|
||||||
message LibraryNode {
|
message LibraryNode {
|
||||||
// Including provider
|
// Full library path including provider
|
||||||
string uuid = 1;
|
string path = 1;
|
||||||
string title = 2;
|
string title = 2;
|
||||||
repeated LibraryNodeChild children = 3;
|
repeated LibraryNodeChild children = 3;
|
||||||
optional string parent = 4;
|
optional string parent = 4;
|
||||||
|
|
|
||||||
|
|
@ -10,16 +10,24 @@ use proto::crabidy::{LibraryNode, LibraryNodeChild, Track};
|
||||||
|
|
||||||
pub mod proto;
|
pub mod proto;
|
||||||
|
|
||||||
|
/// A media provider addressed like a file system.
|
||||||
|
///
|
||||||
|
/// Every node and track has a `/`-separated absolute path whose first
|
||||||
|
/// segment names the provider, e.g. `/tidal/playlists/<id>/<track-id>`.
|
||||||
|
/// The path encodes the position in the library tree: ancestors are
|
||||||
|
/// obtained by trimming trailing segments.
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
pub trait ProviderClient: std::fmt::Debug + Send + Sync {
|
pub trait ProviderClient: std::fmt::Debug + Send + Sync {
|
||||||
async fn init(raw_toml_settings: &str) -> Result<Self, ProviderError>
|
async fn init(raw_toml_settings: &str) -> Result<Self, ProviderError>
|
||||||
where
|
where
|
||||||
Self: Sized;
|
Self: Sized;
|
||||||
fn settings(&self) -> String;
|
fn settings(&self) -> String;
|
||||||
async fn get_urls_for_track(&self, track_uuid: &str) -> Result<Vec<String>, ProviderError>;
|
/// Whether the path addresses a single track (as opposed to a node).
|
||||||
async fn get_metadata_for_track(&self, track_uuid: &str) -> Result<Track, ProviderError>;
|
fn is_track_path(&self, path: &str) -> bool;
|
||||||
|
async fn get_urls_for_track(&self, track_path: &str) -> Result<Vec<String>, ProviderError>;
|
||||||
|
async fn get_metadata_for_track(&self, track_path: &str) -> Result<Track, ProviderError>;
|
||||||
fn get_lib_root(&self) -> LibraryNode;
|
fn get_lib_root(&self) -> LibraryNode;
|
||||||
async fn get_lib_node(&self, list_uuid: &str) -> Result<LibraryNode, ProviderError>;
|
async fn get_lib_node(&self, path: &str) -> Result<LibraryNode, ProviderError>;
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, Hash)]
|
#[derive(Clone, Debug, Hash)]
|
||||||
|
|
@ -28,7 +36,7 @@ pub enum ProviderError {
|
||||||
UnknownUser,
|
UnknownUser,
|
||||||
CouldNotLogin,
|
CouldNotLogin,
|
||||||
FetchError,
|
FetchError,
|
||||||
MalformedUuid,
|
MalformedPath,
|
||||||
InternalError,
|
InternalError,
|
||||||
Other,
|
Other,
|
||||||
}
|
}
|
||||||
|
|
@ -39,10 +47,41 @@ impl std::fmt::Display for ProviderError {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl std::error::Error for ProviderError {}
|
||||||
|
|
||||||
|
/// The path of the global library root.
|
||||||
|
pub const ROOT_PATH: &str = "/";
|
||||||
|
|
||||||
|
/// Returns the parent path, or `None` when at the root.
|
||||||
|
///
|
||||||
|
/// `/tidal/playlists/abc` -> `/tidal/playlists` -> `/tidal` -> `/`.
|
||||||
|
pub fn parent_path(path: &str) -> Option<&str> {
|
||||||
|
let trimmed = path.trim_end_matches('/');
|
||||||
|
if trimmed.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
match trimmed.rfind('/') {
|
||||||
|
Some(0) => Some(ROOT_PATH),
|
||||||
|
Some(idx) => Some(&trimmed[..idx]),
|
||||||
|
None => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Appends a segment to a path.
|
||||||
|
pub fn join_path(base: &str, segment: &str) -> String {
|
||||||
|
let base = base.trim_end_matches('/');
|
||||||
|
format!("{base}/{segment}")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Splits a path into its segments: `/tidal/playlists/x` -> ["tidal", "playlists", "x"].
|
||||||
|
pub fn path_segments(path: &str) -> Vec<&str> {
|
||||||
|
path.split('/').filter(|s| !s.is_empty()).collect()
|
||||||
|
}
|
||||||
|
|
||||||
impl LibraryNode {
|
impl LibraryNode {
|
||||||
pub fn new() -> Self {
|
pub fn new() -> Self {
|
||||||
Self {
|
Self {
|
||||||
uuid: "node:/".to_string(),
|
path: ROOT_PATH.to_string(),
|
||||||
title: "/".to_string(),
|
title: "/".to_string(),
|
||||||
children: Vec::new(),
|
children: Vec::new(),
|
||||||
parent: None,
|
parent: None,
|
||||||
|
|
@ -53,9 +92,9 @@ impl LibraryNode {
|
||||||
}
|
}
|
||||||
|
|
||||||
impl LibraryNodeChild {
|
impl LibraryNodeChild {
|
||||||
pub fn new(uuid: String, title: String, is_queable: bool) -> Self {
|
pub fn new(path: String, title: String, is_queable: bool) -> Self {
|
||||||
Self {
|
Self {
|
||||||
uuid,
|
path,
|
||||||
title,
|
title,
|
||||||
is_queable,
|
is_queable,
|
||||||
}
|
}
|
||||||
|
|
@ -94,3 +133,34 @@ where
|
||||||
}
|
}
|
||||||
T::default().merge_clap()
|
T::default().merge_clap()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parent_path_walks_up_to_root() {
|
||||||
|
assert_eq!(
|
||||||
|
parent_path("/tidal/playlists/abc"),
|
||||||
|
Some("/tidal/playlists")
|
||||||
|
);
|
||||||
|
assert_eq!(parent_path("/tidal/playlists"), Some("/tidal"));
|
||||||
|
assert_eq!(parent_path("/tidal"), Some("/"));
|
||||||
|
assert_eq!(parent_path("/"), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn join_path_appends_segments() {
|
||||||
|
assert_eq!(join_path("/", "tidal"), "/tidal");
|
||||||
|
assert_eq!(join_path("/tidal", "playlists"), "/tidal/playlists");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn path_segments_splits() {
|
||||||
|
assert_eq!(path_segments("/"), Vec::<&str>::new());
|
||||||
|
assert_eq!(
|
||||||
|
path_segments("/tidal/artists/1/2"),
|
||||||
|
vec!["tidal", "artists", "1", "2"]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,32 +1,25 @@
|
||||||
[package]
|
[package]
|
||||||
name = "crabidy-server"
|
name = "crabidy-server"
|
||||||
version = "0.1.0"
|
version.workspace = true
|
||||||
edition = "2021"
|
edition.workspace = true
|
||||||
|
|
||||||
[[bin]]
|
[[bin]]
|
||||||
name = "crabidy-server"
|
name = "crabidy-server"
|
||||||
path = "src/main.rs"
|
path = "src/main.rs"
|
||||||
|
|
||||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
anyhow = "1.0.71"
|
anyhow.workspace = true
|
||||||
tokio = { version = "1.28.0", features = ["full"] }
|
async-trait.workspace = true
|
||||||
tidaldy = { path = "../tidaldy" }
|
audio-player.workspace = true
|
||||||
crabidy-core = { path = "../crabidy-core" }
|
crabidy-core.workspace = true
|
||||||
audio-player = { path = "../audio-player" }
|
dirs.workspace = true
|
||||||
once_cell = "1.17.1"
|
flume.workspace = true
|
||||||
serde_json = "1.0.96"
|
futures.workspace = true
|
||||||
serde = "1.0.163"
|
rand.workspace = true
|
||||||
flume = "0.10.14"
|
tidaldy.workspace = true
|
||||||
tonic = "0.9.2"
|
tokio = { workspace = true, features = ["full"] }
|
||||||
async-trait = "0.1.68"
|
tokio-stream = { workspace = true, features = ["sync"] }
|
||||||
futures = "0.3.28"
|
tonic.workspace = true
|
||||||
tokio-stream = { version = "0.1.14", features = ["sync"] }
|
tracing.workspace = true
|
||||||
dirs = "5.0.1"
|
tracing-appender.workspace = true
|
||||||
tracing = "0.1.37"
|
tracing-subscriber.workspace = true
|
||||||
tracing-subscriber = "0.3.17"
|
|
||||||
tracing-appender = "0.2.2"
|
|
||||||
tracing-log = "0.1.3"
|
|
||||||
log = "0.4.18"
|
|
||||||
rand = "0.8.5"
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
use crabidy_core::proto::crabidy::{Queue, Track};
|
use crabidy_core::proto::crabidy::{Queue, Track};
|
||||||
use rand::{seq::SliceRandom, thread_rng};
|
use rand::{rng, seq::SliceRandom};
|
||||||
use std::time::SystemTime;
|
use std::time::SystemTime;
|
||||||
use tracing::{debug, error};
|
use tracing::{debug, error};
|
||||||
|
|
||||||
|
|
@ -16,10 +16,11 @@ pub struct QueueManager {
|
||||||
impl From<QueueManager> for Queue {
|
impl From<QueueManager> for Queue {
|
||||||
fn from(queue_manager: QueueManager) -> Self {
|
fn from(queue_manager: QueueManager) -> Self {
|
||||||
Self {
|
Self {
|
||||||
|
// A clock step backwards must not panic the playback loop.
|
||||||
timestamp: queue_manager
|
timestamp: queue_manager
|
||||||
.created_at
|
.created_at
|
||||||
.elapsed()
|
.elapsed()
|
||||||
.expect("failed to get elapsed time")
|
.unwrap_or_default()
|
||||||
.as_secs(),
|
.as_secs(),
|
||||||
current_position: queue_manager.current_position() as u32,
|
current_position: queue_manager.current_position() as u32,
|
||||||
tracks: queue_manager.tracks,
|
tracks: queue_manager.tracks,
|
||||||
|
|
@ -27,6 +28,12 @@ impl From<QueueManager> for Queue {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl Default for QueueManager {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl QueueManager {
|
impl QueueManager {
|
||||||
pub fn new() -> Self {
|
pub fn new() -> Self {
|
||||||
Self {
|
Self {
|
||||||
|
|
@ -47,7 +54,7 @@ impl QueueManager {
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn is_last_track(&self) -> bool {
|
pub fn is_last_track(&self) -> bool {
|
||||||
self.current_position() == self.tracks.len() - 1
|
!self.tracks.is_empty() && self.current_position() == self.tracks.len() - 1
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn shuffle_on(&mut self) {
|
pub fn shuffle_on(&mut self) {
|
||||||
|
|
@ -64,15 +71,19 @@ impl QueueManager {
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn shuffle_all(&mut self) {
|
pub fn shuffle_all(&mut self) {
|
||||||
self.play_order.shuffle(&mut thread_rng());
|
self.play_order.shuffle(&mut rng());
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn shuffle_before(&mut self, pos: usize) {
|
pub fn shuffle_before(&mut self, pos: usize) {
|
||||||
self.play_order[..pos].shuffle(&mut thread_rng());
|
if let Some(slice) = self.play_order.get_mut(..pos) {
|
||||||
|
slice.shuffle(&mut rng());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn shuffle_behind(&mut self, pos: usize) {
|
pub fn shuffle_behind(&mut self, pos: usize) {
|
||||||
self.play_order[pos + 1..].shuffle(&mut thread_rng());
|
if let Some(slice) = self.play_order.get_mut(pos + 1..) {
|
||||||
|
slice.shuffle(&mut rng());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn current_track(&self) -> Option<Track> {
|
pub fn current_track(&self) -> Option<Track> {
|
||||||
|
|
@ -113,7 +124,7 @@ impl QueueManager {
|
||||||
pub fn prev_track(&mut self) -> Option<Track> {
|
pub fn prev_track(&mut self) -> Option<Track> {
|
||||||
if 0 < self.current_offset {
|
if 0 < self.current_offset {
|
||||||
self.current_offset -= 1;
|
self.current_offset -= 1;
|
||||||
Some(self.tracks[self.current_position()].clone())
|
self.current_track()
|
||||||
} else {
|
} else {
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
|
|
@ -131,7 +142,7 @@ impl QueueManager {
|
||||||
else {
|
else {
|
||||||
error!("invalid current position");
|
error!("invalid current position");
|
||||||
error!("queue: {:#?}", self);
|
error!("queue: {:#?}", self);
|
||||||
return false
|
return false;
|
||||||
};
|
};
|
||||||
if self.shuffle {
|
if self.shuffle {
|
||||||
self.play_order.swap(0, current_offset);
|
self.play_order.swap(0, current_offset);
|
||||||
|
|
@ -177,32 +188,37 @@ impl QueueManager {
|
||||||
|
|
||||||
pub fn remove_tracks(&mut self, positions: &[u32]) -> Option<Track> {
|
pub fn remove_tracks(&mut self, positions: &[u32]) -> Option<Track> {
|
||||||
let mut play_next = false;
|
let mut play_next = false;
|
||||||
|
// Remove highest positions first so earlier removals don't shift the
|
||||||
|
// positions that are still to be removed.
|
||||||
|
let mut positions: Vec<usize> = positions.iter().map(|p| *p as usize).collect();
|
||||||
|
positions.sort_unstable_by(|a, b| b.cmp(a));
|
||||||
|
positions.dedup();
|
||||||
for pos in positions {
|
for pos in positions {
|
||||||
if (self.tracks.len() as u32) < *pos {
|
if pos >= self.tracks.len() {
|
||||||
return None;
|
debug!(pos, len = self.tracks.len(), "ignoring out-of-range remove");
|
||||||
};
|
continue;
|
||||||
if *pos == self.current_position() as u32 {
|
}
|
||||||
|
if pos == self.current_position() {
|
||||||
play_next = true;
|
play_next = true;
|
||||||
}
|
}
|
||||||
let Some(offset) = self
|
let Some(offset) = self.play_order.iter().position(|&i| i == pos) else {
|
||||||
.play_order
|
error!(pos, "track position missing from play order, rebuilding");
|
||||||
.iter()
|
self.rebuild_play_order();
|
||||||
.position(|&i| i == *pos as usize)
|
return None;
|
||||||
else {
|
|
||||||
error!("invalid current position");
|
|
||||||
error!("queue: {:#?}", self);
|
|
||||||
return None
|
|
||||||
};
|
};
|
||||||
if offset < self.current_offset {
|
if offset < self.current_offset {
|
||||||
self.current_offset -= 1;
|
self.current_offset -= 1;
|
||||||
}
|
}
|
||||||
self.tracks.remove(*pos as usize);
|
self.tracks.remove(pos);
|
||||||
self.play_order.remove(offset);
|
self.play_order.remove(offset);
|
||||||
self.play_order
|
self.play_order
|
||||||
.iter_mut()
|
.iter_mut()
|
||||||
.filter(|i| (*pos as usize) < **i)
|
.filter(|i| pos < **i)
|
||||||
.for_each(|i| *i -= 1);
|
.for_each(|i| *i -= 1);
|
||||||
}
|
}
|
||||||
|
if self.current_offset >= self.play_order.len() {
|
||||||
|
self.current_offset = 0;
|
||||||
|
}
|
||||||
if play_next {
|
if play_next {
|
||||||
self.current_track()
|
self.current_track()
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -215,38 +231,36 @@ impl QueueManager {
|
||||||
if len == 0 {
|
if len == 0 {
|
||||||
return self.replace_with_tracks(tracks);
|
return self.replace_with_tracks(tracks);
|
||||||
}
|
}
|
||||||
let order_additions: Vec<usize> = (len..len + tracks.len()).collect();
|
let inserted = tracks.len();
|
||||||
|
let position = (position as usize).min(len - 1);
|
||||||
|
let order_additions: Vec<usize> = (len..len + inserted).collect();
|
||||||
self.play_order.extend(order_additions);
|
self.play_order.extend(order_additions);
|
||||||
let tail: Vec<Track> = self
|
let tail: Vec<Track> = self
|
||||||
.tracks
|
.tracks
|
||||||
.splice((position as usize + 1).., tracks.to_vec())
|
.splice(position + 1.., tracks.to_vec())
|
||||||
.collect();
|
.collect();
|
||||||
self.tracks.extend(tail);
|
self.tracks.extend(tail);
|
||||||
let mut changed: Vec<usize> = Vec::new();
|
let mut changed: Vec<usize> = Vec::new();
|
||||||
// in shuffle mode, it might be that we played already postions which are behind
|
// In shuffle mode we may already have played positions that are
|
||||||
// the insertion point and which postions are shifted by the lenght of the inserted
|
// behind the insertion point; those shift by the number of inserted
|
||||||
// track
|
// tracks.
|
||||||
for i in self
|
for i in self
|
||||||
.play_order
|
.play_order
|
||||||
.iter_mut()
|
.iter_mut()
|
||||||
.take(self.current_offset)
|
.take(self.current_offset)
|
||||||
.filter(|i| (position as usize) < **i)
|
.filter(|i| position < **i)
|
||||||
{
|
{
|
||||||
*i += len;
|
*i += inserted;
|
||||||
changed.push(*i);
|
changed.push(*i);
|
||||||
}
|
}
|
||||||
if !self.shuffle {
|
// The freshly appended order entries need to swap with the shifted
|
||||||
// if we don't shuffle, there should be no positions alredy played behind the
|
// ones so every index stays unique.
|
||||||
// current track
|
|
||||||
assert!(changed.is_empty());
|
|
||||||
}
|
|
||||||
// the newly inserted indices need to replaced with the ones that we already handled
|
|
||||||
self.play_order
|
self.play_order
|
||||||
.iter_mut()
|
.iter_mut()
|
||||||
.skip(self.current_offset)
|
.skip(self.current_offset)
|
||||||
.for_each(|i| {
|
.for_each(|i| {
|
||||||
if changed.contains(i) {
|
if changed.contains(i) {
|
||||||
*i -= len;
|
*i -= inserted;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -265,28 +279,126 @@ impl QueueManager {
|
||||||
let current_track = self.current_track();
|
let current_track = self.current_track();
|
||||||
self.current_offset = 0;
|
self.current_offset = 0;
|
||||||
self.tracks.clear();
|
self.tracks.clear();
|
||||||
|
self.play_order.clear();
|
||||||
|
|
||||||
if exclude_current {
|
if exclude_current {
|
||||||
if let Some(track) = current_track {
|
if let Some(track) = current_track {
|
||||||
self.tracks.push(track);
|
self.tracks.push(track);
|
||||||
|
self.play_order.push(0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
!exclude_current
|
!exclude_current
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Restores play_order to a consistent state after an inconsistency was
|
||||||
|
/// detected. Loses shuffle history but keeps the queue playable.
|
||||||
|
fn rebuild_play_order(&mut self) {
|
||||||
|
self.play_order = (0..self.tracks.len()).collect();
|
||||||
|
self.current_offset = 0;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
#[test]
|
fn track(id: usize) -> Track {
|
||||||
fn random_delete_before() {}
|
Track {
|
||||||
#[test]
|
path: format!("/tidal/playlists/p/{id}"),
|
||||||
fn random_delete_track() {}
|
artist: "artist".to_string(),
|
||||||
#[test]
|
title: format!("track {id}"),
|
||||||
fn random_delete_after() {}
|
duration: None,
|
||||||
#[test]
|
album: None,
|
||||||
fn random_select_track() {}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn queue_with(n: usize) -> QueueManager {
|
||||||
|
let mut q = QueueManager::new();
|
||||||
|
let tracks: Vec<Track> = (0..n).map(track).collect();
|
||||||
|
q.replace_with_tracks(&tracks);
|
||||||
|
q
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn empty_queue_operations_do_not_panic() {
|
||||||
|
let mut q = QueueManager::new();
|
||||||
|
assert!(!q.is_last_track());
|
||||||
|
assert!(q.current_track().is_none());
|
||||||
|
assert!(q.next_track().is_none());
|
||||||
|
assert!(q.prev_track().is_none());
|
||||||
|
assert!(q.remove_tracks(&[0]).is_none());
|
||||||
|
q.shuffle_on();
|
||||||
|
q.shuffle_off();
|
||||||
|
q.clear(true);
|
||||||
|
q.clear(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn remove_out_of_range_is_ignored() {
|
||||||
|
let mut q = queue_with(2);
|
||||||
|
assert!(q.remove_tracks(&[5]).is_none());
|
||||||
|
assert_eq!(q.tracks.len(), 2);
|
||||||
|
// pos == len used to panic via Vec::remove
|
||||||
|
assert!(q.remove_tracks(&[2]).is_none());
|
||||||
|
assert_eq!(q.tracks.len(), 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn remove_multiple_positions() {
|
||||||
|
let mut q = queue_with(4);
|
||||||
|
q.remove_tracks(&[1, 3]);
|
||||||
|
assert_eq!(q.tracks.len(), 2);
|
||||||
|
assert_eq!(q.play_order.len(), 2);
|
||||||
|
assert_eq!(q.current_track().unwrap().title, "track 0");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn remove_current_returns_successor() {
|
||||||
|
let mut q = queue_with(3);
|
||||||
|
let next = q.remove_tracks(&[0]);
|
||||||
|
assert_eq!(next.unwrap().title, "track 1");
|
||||||
|
assert_eq!(q.tracks.len(), 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn clear_keeps_play_order_consistent() {
|
||||||
|
let mut q = queue_with(3);
|
||||||
|
q.next_track();
|
||||||
|
q.clear(true);
|
||||||
|
assert_eq!(q.tracks.len(), 1);
|
||||||
|
assert_eq!(q.play_order.len(), 1);
|
||||||
|
assert!(q.current_track().is_some());
|
||||||
|
assert!(q.next_track().is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn next_track_advances_and_repeats() {
|
||||||
|
let mut q = queue_with(2);
|
||||||
|
assert_eq!(q.next_track().unwrap().title, "track 1");
|
||||||
|
assert!(q.next_track().is_none());
|
||||||
|
q.repeat = true;
|
||||||
|
assert_eq!(q.next_track().unwrap().title, "track 0");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn insert_past_end_appends() {
|
||||||
|
let mut q = queue_with(2);
|
||||||
|
q.insert_tracks(99, &[track(2)]);
|
||||||
|
assert_eq!(q.tracks.len(), 3);
|
||||||
|
assert_eq!(q.play_order.len(), 3);
|
||||||
|
assert_eq!(q.tracks.last().unwrap().title, "track 2");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn shuffle_insert_keeps_order_unique() {
|
||||||
|
let mut q = queue_with(5);
|
||||||
|
q.shuffle_on();
|
||||||
|
q.next_track();
|
||||||
|
q.next_track();
|
||||||
|
q.queue_tracks(&[track(5), track(6)]);
|
||||||
|
let mut order = q.play_order.clone();
|
||||||
|
order.sort_unstable();
|
||||||
|
assert_eq!(order, (0..7).collect::<Vec<usize>>());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,8 +3,8 @@ use crabidy_core::proto::crabidy::{
|
||||||
crabidy_service_server::CrabidyServiceServer, InitResponse, LibraryNode, PlayState, Track,
|
crabidy_service_server::CrabidyServiceServer, InitResponse, LibraryNode, PlayState, Track,
|
||||||
};
|
};
|
||||||
use crabidy_core::{ProviderClient, ProviderError};
|
use crabidy_core::{ProviderClient, ProviderError};
|
||||||
use tracing::{debug_span, error, info, instrument, level_filters, warn, Span};
|
use tracing::{debug, error, info, instrument, warn, Span};
|
||||||
use tracing_subscriber::{filter::Targets, prelude::*};
|
use tracing_subscriber::{prelude::*, EnvFilter};
|
||||||
|
|
||||||
mod playback;
|
mod playback;
|
||||||
use playback::Playback;
|
use playback::Playback;
|
||||||
|
|
@ -15,34 +15,17 @@ use rpc::RpcService;
|
||||||
|
|
||||||
use tonic::{transport::Server, Result};
|
use tonic::{transport::Server, Result};
|
||||||
|
|
||||||
|
const LISTEN_ADDR: &str = "0.0.0.0:50051";
|
||||||
|
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
if let Err(err) = tracing_log::LogTracer::init_with_filter(log::LevelFilter::Debug) {
|
let _log_guard = init_tracing();
|
||||||
println!("Failed to initialize log tracer: {}", err);
|
|
||||||
}
|
|
||||||
let (non_blocking, _guard) = tracing_appender::non_blocking(std::io::stderr());
|
|
||||||
|
|
||||||
let targets_filter = Targets::new()
|
|
||||||
.with_target("crabidy_server", tracing::level_filters::LevelFilter::DEBUG)
|
|
||||||
.with_target("tidaldy", level_filters::LevelFilter::DEBUG);
|
|
||||||
let subscriber = tracing_subscriber::fmt::layer()
|
|
||||||
.with_writer(non_blocking)
|
|
||||||
.with_file(true)
|
|
||||||
.with_line_number(true);
|
|
||||||
|
|
||||||
let registry = tracing_subscriber::registry()
|
|
||||||
.with(targets_filter)
|
|
||||||
.with(subscriber);
|
|
||||||
|
|
||||||
tracing::subscriber::set_global_default(registry)
|
|
||||||
.expect("Setting the default tracing subscriber failed");
|
|
||||||
|
|
||||||
info!("audio player started initialized");
|
|
||||||
|
|
||||||
let (update_tx, _) = tokio::sync::broadcast::channel(2048);
|
let (update_tx, _) = tokio::sync::broadcast::channel(2048);
|
||||||
let orchestrator = ProviderOrchestrator::init("")
|
let orchestrator = ProviderOrchestrator::init("").await.map_err(|err| {
|
||||||
.await
|
error!("failed to init provider orchestrator: {err}");
|
||||||
.expect("failed to init orchestrator");
|
err
|
||||||
|
})?;
|
||||||
|
|
||||||
let playback = Playback::new(update_tx.clone(), orchestrator.provider_tx.clone());
|
let playback = Playback::new(update_tx.clone(), orchestrator.provider_tx.clone());
|
||||||
|
|
||||||
|
|
@ -52,7 +35,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
std::thread::spawn(|| {
|
std::thread::spawn(|| {
|
||||||
poll_play_bus(player_msg, playback_tx);
|
poll_play_bus(player_msg, playback_tx);
|
||||||
});
|
});
|
||||||
info!("gstreamer bus handler started");
|
info!("player message forwarder started");
|
||||||
|
|
||||||
let crabidy_service = RpcService::new(
|
let crabidy_service = RpcService::new(
|
||||||
update_tx,
|
update_tx,
|
||||||
|
|
@ -64,7 +47,8 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
playback.run();
|
playback.run();
|
||||||
info!("playback started");
|
info!("playback started");
|
||||||
|
|
||||||
let addr = "0.0.0.0:50051".parse()?;
|
let addr = LISTEN_ADDR.parse()?;
|
||||||
|
info!(%addr, "grpc server listening");
|
||||||
Server::builder()
|
Server::builder()
|
||||||
.add_service(CrabidyServiceServer::new(crabidy_service))
|
.add_service(CrabidyServiceServer::new(crabidy_service))
|
||||||
.serve(addr)
|
.serve(addr)
|
||||||
|
|
@ -73,165 +57,210 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Installs the global tracing subscriber.
|
||||||
|
///
|
||||||
|
/// The filter honors `RUST_LOG`; without it, our own crates log at debug and
|
||||||
|
/// everything else at info. Returns the guard that flushes the non-blocking
|
||||||
|
/// writer on shutdown.
|
||||||
|
fn init_tracing() -> tracing_appender::non_blocking::WorkerGuard {
|
||||||
|
let (non_blocking, guard) = tracing_appender::non_blocking(std::io::stderr());
|
||||||
|
|
||||||
|
let env_filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| {
|
||||||
|
EnvFilter::new(
|
||||||
|
"info,crabidy_server=debug,crabidy_core=debug,tidaldy=debug,audio_player=debug",
|
||||||
|
)
|
||||||
|
});
|
||||||
|
|
||||||
|
let fmt_layer = tracing_subscriber::fmt::layer()
|
||||||
|
.with_writer(non_blocking)
|
||||||
|
.with_target(true)
|
||||||
|
.with_file(true)
|
||||||
|
.with_line_number(true);
|
||||||
|
|
||||||
|
// .init() also installs the log-to-tracing bridge for libraries that
|
||||||
|
// use the `log` crate (symphonia, cpal, ...).
|
||||||
|
tracing_subscriber::registry()
|
||||||
|
.with(env_filter)
|
||||||
|
.with(fmt_layer)
|
||||||
|
.init();
|
||||||
|
|
||||||
|
guard
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Forwards player engine events into the playback message loop.
|
||||||
#[instrument(skip(rx, tx))]
|
#[instrument(skip(rx, tx))]
|
||||||
fn poll_play_bus(rx: flume::Receiver<PlayerMessage>, tx: flume::Sender<PlaybackMessage>) {
|
fn poll_play_bus(rx: flume::Receiver<PlayerMessage>, tx: flume::Sender<PlaybackMessage>) {
|
||||||
for msg in rx.iter() {
|
for msg in rx.iter() {
|
||||||
let span = debug_span!("play-chan");
|
let command = match msg {
|
||||||
match msg {
|
|
||||||
PlayerMessage::EndOfStream => {
|
PlayerMessage::EndOfStream => {
|
||||||
if let Err(err) = tx.send(PlaybackMessage::Next { span }) {
|
debug!("player reported end of stream");
|
||||||
error!("failed to send next message: {}", err);
|
PlaybackCommand::Next
|
||||||
}
|
}
|
||||||
}
|
PlayerMessage::Stopped => PlaybackCommand::StateChanged {
|
||||||
PlayerMessage::Stopped => {
|
|
||||||
if let Err(err) = tx.send(PlaybackMessage::StateChanged {
|
|
||||||
state: PlayState::Stopped,
|
state: PlayState::Stopped,
|
||||||
span,
|
},
|
||||||
}) {
|
PlayerMessage::Paused => PlaybackCommand::StateChanged {
|
||||||
error!("failed to send stopped message: {}", err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
PlayerMessage::Paused => {
|
|
||||||
if let Err(err) = tx.send(PlaybackMessage::StateChanged {
|
|
||||||
state: PlayState::Paused,
|
state: PlayState::Paused,
|
||||||
span,
|
},
|
||||||
}) {
|
PlayerMessage::Playing => PlaybackCommand::StateChanged {
|
||||||
error!("failed to send paused message: {}", err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
PlayerMessage::Playing => {
|
|
||||||
if let Err(err) = tx.send(PlaybackMessage::StateChanged {
|
|
||||||
state: PlayState::Playing,
|
state: PlayState::Playing,
|
||||||
span,
|
},
|
||||||
}) {
|
PlayerMessage::Elapsed { duration, elapsed } => PlaybackCommand::PositionChanged {
|
||||||
error!("failed to send playing message: {}", err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
PlayerMessage::Elapsed { duration, elapsed } => {
|
|
||||||
if let Err(err) = tx.send(PlaybackMessage::PostitionChanged {
|
|
||||||
duration: duration.as_millis() as u32,
|
duration: duration.as_millis() as u32,
|
||||||
position: elapsed.as_millis() as u32,
|
position: elapsed.as_millis() as u32,
|
||||||
span,
|
},
|
||||||
}) {
|
PlayerMessage::Duration { duration } => PlaybackCommand::PositionChanged {
|
||||||
error!("failed to send elapsed message: {}", err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
PlayerMessage::Duration { duration } => {
|
|
||||||
if let Err(err) = tx.send(PlaybackMessage::PostitionChanged {
|
|
||||||
duration: duration.as_millis() as u32,
|
duration: duration.as_millis() as u32,
|
||||||
position: 0,
|
position: 0,
|
||||||
span,
|
},
|
||||||
}) {
|
};
|
||||||
error!("failed to send duration message: {}", err);
|
if let Err(err) = tx.send(PlaybackMessage::new(command)) {
|
||||||
|
error!("failed to forward player message: {err}");
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
warn!("player message channel closed");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A command for the provider orchestrator, tagged with the tracing span that
|
||||||
|
/// was current when it was sent so the handler can attribute its events to
|
||||||
|
/// the originating request.
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct ProviderMessage {
|
||||||
|
pub span: Span,
|
||||||
|
pub command: ProviderCommand,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ProviderMessage {
|
||||||
|
pub fn new(command: ProviderCommand) -> Self {
|
||||||
|
Self {
|
||||||
|
span: Span::current(),
|
||||||
|
command,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub enum ProviderMessage {
|
pub enum ProviderCommand {
|
||||||
GetLibraryNode {
|
GetLibraryNode {
|
||||||
uuid: String,
|
path: String,
|
||||||
result_tx: flume::Sender<Result<LibraryNode, ProviderError>>,
|
result_tx: flume::Sender<Result<LibraryNode, ProviderError>>,
|
||||||
span: Span,
|
|
||||||
},
|
|
||||||
GetTrack {
|
|
||||||
uuid: String,
|
|
||||||
result_tx: flume::Sender<Result<Track, ProviderError>>,
|
|
||||||
span: Span,
|
|
||||||
},
|
},
|
||||||
GetTrackUrls {
|
GetTrackUrls {
|
||||||
uuid: String,
|
path: String,
|
||||||
result_tx: flume::Sender<Result<Vec<String>, ProviderError>>,
|
result_tx: flume::Sender<Result<Vec<String>, ProviderError>>,
|
||||||
span: Span,
|
|
||||||
},
|
},
|
||||||
FlattenNode {
|
/// Resolves a path into playable tracks: a track path yields that single
|
||||||
uuid: String,
|
/// track, a node path yields all tracks reachable below it.
|
||||||
|
ResolveTracks {
|
||||||
|
path: String,
|
||||||
result_tx: flume::Sender<Vec<Track>>,
|
result_tx: flume::Sender<Vec<Track>>,
|
||||||
span: Span,
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl ProviderCommand {
|
||||||
|
pub fn name(&self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::GetLibraryNode { .. } => "get_library_node",
|
||||||
|
Self::GetTrackUrls { .. } => "get_track_urls",
|
||||||
|
Self::ResolveTracks { .. } => "resolve_tracks",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A command for the playback loop, tagged like [`ProviderMessage`].
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub enum PlaybackMessage {
|
pub struct PlaybackMessage {
|
||||||
|
pub span: Span,
|
||||||
|
pub command: PlaybackCommand,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PlaybackMessage {
|
||||||
|
pub fn new(command: PlaybackCommand) -> Self {
|
||||||
|
Self {
|
||||||
|
span: Span::current(),
|
||||||
|
command,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub enum PlaybackCommand {
|
||||||
Init {
|
Init {
|
||||||
result_tx: flume::Sender<InitResponse>,
|
result_tx: flume::Sender<InitResponse>,
|
||||||
span: Span,
|
|
||||||
},
|
},
|
||||||
Replace {
|
Replace {
|
||||||
uuids: Vec<String>,
|
paths: Vec<String>,
|
||||||
span: Span,
|
|
||||||
},
|
},
|
||||||
Queue {
|
Queue {
|
||||||
uuids: Vec<String>,
|
paths: Vec<String>,
|
||||||
span: Span,
|
|
||||||
},
|
},
|
||||||
Append {
|
Append {
|
||||||
uuids: Vec<String>,
|
paths: Vec<String>,
|
||||||
span: Span,
|
|
||||||
},
|
},
|
||||||
Remove {
|
Remove {
|
||||||
positions: Vec<u32>,
|
positions: Vec<u32>,
|
||||||
span: Span,
|
|
||||||
},
|
},
|
||||||
Insert {
|
Insert {
|
||||||
position: u32,
|
position: u32,
|
||||||
uuids: Vec<String>,
|
paths: Vec<String>,
|
||||||
span: Span,
|
|
||||||
},
|
},
|
||||||
Clear {
|
Clear {
|
||||||
exclude_current: bool,
|
exclude_current: bool,
|
||||||
span: Span,
|
|
||||||
},
|
},
|
||||||
SetCurrent {
|
SetCurrent {
|
||||||
position: u32,
|
position: u32,
|
||||||
span: Span,
|
|
||||||
},
|
|
||||||
ToggleShuffle {
|
|
||||||
span: Span,
|
|
||||||
},
|
|
||||||
ToggleRepeat {
|
|
||||||
span: Span,
|
|
||||||
},
|
|
||||||
TogglePlay {
|
|
||||||
span: Span,
|
|
||||||
},
|
|
||||||
Stop {
|
|
||||||
span: Span,
|
|
||||||
},
|
},
|
||||||
|
ToggleShuffle,
|
||||||
|
ToggleRepeat,
|
||||||
|
TogglePlay,
|
||||||
|
Stop,
|
||||||
ChangeVolume {
|
ChangeVolume {
|
||||||
delta: f32,
|
delta: f32,
|
||||||
span: Span,
|
|
||||||
},
|
|
||||||
ToggleMute {
|
|
||||||
span: Span,
|
|
||||||
},
|
|
||||||
Next {
|
|
||||||
span: Span,
|
|
||||||
},
|
|
||||||
Prev {
|
|
||||||
span: Span,
|
|
||||||
},
|
|
||||||
RestartTrack {
|
|
||||||
span: Span,
|
|
||||||
},
|
},
|
||||||
|
ToggleMute,
|
||||||
|
Next,
|
||||||
|
Prev,
|
||||||
|
RestartTrack,
|
||||||
StateChanged {
|
StateChanged {
|
||||||
state: PlayState,
|
state: PlayState,
|
||||||
span: Span,
|
|
||||||
},
|
},
|
||||||
VolumeChanged {
|
VolumeChanged {
|
||||||
volume: f32,
|
volume: f32,
|
||||||
span: Span,
|
|
||||||
},
|
},
|
||||||
|
|
||||||
MuteChanged {
|
MuteChanged {
|
||||||
muted: bool,
|
muted: bool,
|
||||||
span: Span,
|
|
||||||
},
|
},
|
||||||
PostitionChanged {
|
PositionChanged {
|
||||||
duration: u32,
|
duration: u32,
|
||||||
position: u32,
|
position: u32,
|
||||||
span: Span,
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl PlaybackCommand {
|
||||||
|
pub fn name(&self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::Init { .. } => "init",
|
||||||
|
Self::Replace { .. } => "replace",
|
||||||
|
Self::Queue { .. } => "queue",
|
||||||
|
Self::Append { .. } => "append",
|
||||||
|
Self::Remove { .. } => "remove",
|
||||||
|
Self::Insert { .. } => "insert",
|
||||||
|
Self::Clear { .. } => "clear",
|
||||||
|
Self::SetCurrent { .. } => "set_current",
|
||||||
|
Self::ToggleShuffle => "toggle_shuffle",
|
||||||
|
Self::ToggleRepeat => "toggle_repeat",
|
||||||
|
Self::TogglePlay => "toggle_play",
|
||||||
|
Self::Stop => "stop",
|
||||||
|
Self::ChangeVolume { .. } => "change_volume",
|
||||||
|
Self::ToggleMute => "toggle_mute",
|
||||||
|
Self::Next => "next",
|
||||||
|
Self::Prev => "prev",
|
||||||
|
Self::RestartTrack => "restart_track",
|
||||||
|
Self::StateChanged { .. } => "state_changed",
|
||||||
|
Self::VolumeChanged { .. } => "volume_changed",
|
||||||
|
Self::MuteChanged { .. } => "mute_changed",
|
||||||
|
Self::PositionChanged { .. } => "position_changed",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
use crate::PlaybackMessage;
|
use crate::{PlaybackCommand, PlaybackMessage, ProviderCommand, ProviderMessage};
|
||||||
use crate::ProviderMessage;
|
|
||||||
use audio_player::Player;
|
use audio_player::Player;
|
||||||
use crabidy_core::proto::crabidy::QueueModifiers;
|
use crabidy_core::proto::crabidy::QueueModifiers;
|
||||||
use crabidy_core::proto::crabidy::{
|
use crabidy_core::proto::crabidy::{
|
||||||
|
|
@ -9,8 +8,7 @@ use crabidy_core::proto::crabidy::{
|
||||||
use crabidy_core::ProviderError;
|
use crabidy_core::ProviderError;
|
||||||
use crabidy_server::QueueManager;
|
use crabidy_server::QueueManager;
|
||||||
use std::sync::Mutex;
|
use std::sync::Mutex;
|
||||||
use tracing::debug_span;
|
use tracing::{debug, debug_span, error, instrument, trace, warn, Instrument};
|
||||||
use tracing::{debug, error, instrument, trace, warn, Instrument};
|
|
||||||
|
|
||||||
pub struct Playback {
|
pub struct Playback {
|
||||||
update_tx: tokio::sync::broadcast::Sender<StreamUpdate>,
|
update_tx: tokio::sync::broadcast::Sender<StreamUpdate>,
|
||||||
|
|
@ -27,7 +25,7 @@ impl Playback {
|
||||||
update_tx: tokio::sync::broadcast::Sender<StreamUpdate>,
|
update_tx: tokio::sync::broadcast::Sender<StreamUpdate>,
|
||||||
provider_tx: flume::Sender<ProviderMessage>,
|
provider_tx: flume::Sender<ProviderMessage>,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
let (playback_tx, playback_rx) = flume::bounded(10);
|
let (playback_tx, playback_rx) = flume::bounded(64);
|
||||||
let queue = Mutex::new(QueueManager::new());
|
let queue = Mutex::new(QueueManager::new());
|
||||||
let state = Mutex::new(PlayState::Stopped);
|
let state = Mutex::new(PlayState::Stopped);
|
||||||
let player = Player::default();
|
let player = Player::default();
|
||||||
|
|
@ -44,42 +42,41 @@ impl Playback {
|
||||||
|
|
||||||
pub fn run(self) {
|
pub fn run(self) {
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
while let Ok(message) = self.playback_rx.recv_async().await {
|
while let Ok(PlaybackMessage { span, command }) = self.playback_rx.recv_async().await {
|
||||||
match message {
|
// Attribute all handler events to a span that is a child of
|
||||||
PlaybackMessage::Init { result_tx, span } => {
|
// the span that was current when the command was sent.
|
||||||
let _e = span.enter();
|
let handler_span =
|
||||||
let repeat;
|
debug_span!(parent: &span, "playback_command", command = command.name());
|
||||||
let shuffle;
|
self.handle_command(command).instrument(handler_span).await;
|
||||||
|
}
|
||||||
|
warn!("playback message channel closed, loop exiting");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn handle_command(&self, command: PlaybackCommand) {
|
||||||
|
trace!("handling playback command");
|
||||||
|
match command {
|
||||||
|
PlaybackCommand::Init { result_tx } => {
|
||||||
let response = {
|
let response = {
|
||||||
let Ok(queue) = self.queue.lock() else {
|
let Ok(queue) = self.queue.lock() else {
|
||||||
error!("failed to get queue lock");
|
error!("queue lock poisoned");
|
||||||
continue;
|
return;
|
||||||
};
|
};
|
||||||
debug!("got queue lock");
|
|
||||||
repeat = queue.repeat;
|
|
||||||
shuffle = queue.shuffle;
|
|
||||||
let queue_track = QueueTrack {
|
let queue_track = QueueTrack {
|
||||||
queue_position: queue.current_position() as u32,
|
queue_position: queue.current_position() as u32,
|
||||||
track: queue.current_track(),
|
track: queue.current_track(),
|
||||||
};
|
};
|
||||||
trace!("queue_track {:?}", queue_track);
|
|
||||||
debug!("released queue_track lock");
|
|
||||||
|
|
||||||
let position = TrackPosition {
|
let position = TrackPosition {
|
||||||
duration: 0,
|
duration: 0,
|
||||||
position: 0,
|
position: 0,
|
||||||
};
|
};
|
||||||
trace!("position {:?}", position);
|
|
||||||
let play_state = {
|
let play_state = {
|
||||||
debug!("getting play state lock");
|
|
||||||
let Ok(play_state) = self.state.lock() else {
|
let Ok(play_state) = self.state.lock() else {
|
||||||
error!("failed to get play state lock");
|
error!("play state lock poisoned");
|
||||||
continue;
|
return;
|
||||||
};
|
};
|
||||||
*play_state
|
*play_state
|
||||||
};
|
};
|
||||||
trace!("play_state {:?}", play_state);
|
|
||||||
debug!("released play state lock");
|
|
||||||
InitResponse {
|
InitResponse {
|
||||||
queue: Some(queue.clone().into()),
|
queue: Some(queue.clone().into()),
|
||||||
queue_track: Some(queue_track),
|
queue_track: Some(queue_track),
|
||||||
|
|
@ -87,593 +84,395 @@ impl Playback {
|
||||||
volume: 0.0,
|
volume: 0.0,
|
||||||
mute: false,
|
mute: false,
|
||||||
position: Some(position),
|
position: Some(position),
|
||||||
mods: Some(QueueModifiers { repeat, shuffle }),
|
mods: Some(QueueModifiers {
|
||||||
|
repeat: queue.repeat,
|
||||||
|
shuffle: queue.shuffle,
|
||||||
|
}),
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
trace!("response {:?}", response);
|
trace!(?response, "sending init response");
|
||||||
if let Err(err) = result_tx.send(response) {
|
if let Err(err) = result_tx.send(response) {
|
||||||
error!("failed to send response: {:#?}", err);
|
error!("failed to send init response: {err}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
PlaybackMessage::Replace { uuids, span } => {
|
|
||||||
let _e = span.enter();
|
PlaybackCommand::Replace { paths } => {
|
||||||
let mut all_tracks = Vec::new();
|
let all_tracks = self.resolve_tracks(paths).await;
|
||||||
for uuid in uuids {
|
debug!(count = all_tracks.len(), "replacing queue");
|
||||||
if is_track(&uuid) {
|
|
||||||
if let Ok(track) = self.get_track(&uuid).in_current_span().await {
|
|
||||||
all_tracks.push(track);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
let tracks = self.flatten_node(&uuid).in_current_span().await;
|
|
||||||
all_tracks.extend(tracks);
|
|
||||||
}
|
|
||||||
debug!("uuid: {:?}", uuid);
|
|
||||||
}
|
|
||||||
trace!("got tracks {:?}", all_tracks);
|
|
||||||
let current = {
|
let current = {
|
||||||
let Ok(mut queue) = self.queue.lock() else {
|
let Ok(mut queue) = self.queue.lock() else {
|
||||||
debug!("got queue lock");
|
error!("queue lock poisoned");
|
||||||
continue;
|
return;
|
||||||
};
|
};
|
||||||
debug!("got queue lock");
|
|
||||||
queue.replace_with_tracks(&all_tracks);
|
queue.replace_with_tracks(&all_tracks);
|
||||||
let queue_update_tx = self.update_tx.clone();
|
self.broadcast(StreamUpdate::Queue(queue.clone().into()));
|
||||||
let update = StreamUpdate::Queue(queue.clone().into());
|
|
||||||
if let Err(err) = queue_update_tx.send(update) {
|
|
||||||
trace!("{:?}", err)
|
|
||||||
};
|
|
||||||
queue.current_track()
|
queue.current_track()
|
||||||
};
|
};
|
||||||
debug!("got current {:?}", current);
|
self.play(current).await;
|
||||||
self.play(current).in_current_span().await;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
PlaybackMessage::Queue { uuids, span } => {
|
PlaybackCommand::Queue { paths } => {
|
||||||
let _e = span.enter();
|
let all_tracks = self.resolve_tracks(paths).await;
|
||||||
debug!("queing");
|
debug!(count = all_tracks.len(), "queueing after current");
|
||||||
let mut all_tracks = Vec::new();
|
|
||||||
for uuid in uuids {
|
|
||||||
if is_track(&uuid) {
|
|
||||||
if let Ok(track) = self.get_track(&uuid).in_current_span().await {
|
|
||||||
all_tracks.push(track);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
let tracks = self.flatten_node(&uuid).in_current_span().await;
|
|
||||||
all_tracks.extend(tracks);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
trace!("got tracks {:?}", all_tracks);
|
|
||||||
let track = {
|
let track = {
|
||||||
let Ok(mut queue) = self.queue.lock() else {
|
let Ok(mut queue) = self.queue.lock() else {
|
||||||
debug!("got queue lock");
|
error!("queue lock poisoned");
|
||||||
continue;
|
return;
|
||||||
};
|
};
|
||||||
debug!("got queue lock");
|
|
||||||
let track = queue.queue_tracks(&all_tracks);
|
let track = queue.queue_tracks(&all_tracks);
|
||||||
let queue_update_tx = self.update_tx.clone();
|
self.broadcast(StreamUpdate::Queue(queue.clone().into()));
|
||||||
let update = StreamUpdate::Queue(queue.clone().into());
|
|
||||||
if let Err(err) = queue_update_tx.send(update) {
|
|
||||||
trace!("{:?}", err)
|
|
||||||
}
|
|
||||||
track
|
track
|
||||||
};
|
};
|
||||||
debug!("que lock released");
|
self.play_if_some(track).await;
|
||||||
self.play(track).in_current_span().await;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
PlaybackMessage::Append { uuids, span } => {
|
PlaybackCommand::Append { paths } => {
|
||||||
let _e = span.enter();
|
let all_tracks = self.resolve_tracks(paths).await;
|
||||||
debug!("appending");
|
debug!(count = all_tracks.len(), "appending to queue");
|
||||||
let mut all_tracks = Vec::new();
|
|
||||||
for uuid in uuids {
|
|
||||||
if is_track(&uuid) {
|
|
||||||
if let Ok(track) = self.get_track(&uuid).in_current_span().await {
|
|
||||||
all_tracks.push(track);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
let tracks = self.flatten_node(&uuid).in_current_span().await;
|
|
||||||
all_tracks.extend(tracks);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
trace!("got tracks {:?}", all_tracks);
|
|
||||||
let track = {
|
let track = {
|
||||||
let Ok(mut queue) = self.queue.lock() else {
|
let Ok(mut queue) = self.queue.lock() else {
|
||||||
debug!("got queue lock");
|
error!("queue lock poisoned");
|
||||||
continue;
|
return;
|
||||||
};
|
};
|
||||||
debug!("got queue lock");
|
|
||||||
let track = queue.append_tracks(&all_tracks);
|
let track = queue.append_tracks(&all_tracks);
|
||||||
let queue_update_tx = self.update_tx.clone();
|
self.broadcast(StreamUpdate::Queue(queue.clone().into()));
|
||||||
let update = StreamUpdate::Queue(queue.clone().into());
|
|
||||||
if let Err(err) = queue_update_tx.send(update) {
|
|
||||||
trace!("{:?}", err)
|
|
||||||
}
|
|
||||||
track
|
track
|
||||||
};
|
};
|
||||||
debug!("queue lock released");
|
self.play_if_some(track).await;
|
||||||
self.play(track).in_current_span().await;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
PlaybackMessage::Remove { positions, span } => {
|
PlaybackCommand::Remove { positions } => {
|
||||||
let _e = span.enter();
|
debug!(?positions, "removing tracks");
|
||||||
let is_last;
|
let (track, was_last) = {
|
||||||
debug!("removing");
|
|
||||||
let track = {
|
|
||||||
let Ok(mut queue) = self.queue.lock() else {
|
let Ok(mut queue) = self.queue.lock() else {
|
||||||
debug!("got queue lock");
|
error!("queue lock poisoned");
|
||||||
continue;
|
return;
|
||||||
};
|
};
|
||||||
debug!("got queue lock");
|
let was_last = queue.is_last_track();
|
||||||
is_last = queue.is_last_track();
|
|
||||||
let track = queue.remove_tracks(&positions);
|
let track = queue.remove_tracks(&positions);
|
||||||
let queue_update_tx = self.update_tx.clone();
|
self.broadcast(StreamUpdate::Queue(queue.clone().into()));
|
||||||
let update = StreamUpdate::Queue(queue.clone().into());
|
(track, was_last)
|
||||||
if let Err(err) = queue_update_tx.send(update) {
|
|
||||||
trace!("{:?}", err)
|
|
||||||
};
|
};
|
||||||
track
|
|
||||||
};
|
|
||||||
debug!("queue lock released");
|
|
||||||
let state = {
|
let state = {
|
||||||
let Ok(state) = self.state.lock() else {
|
let Ok(state) = self.state.lock() else {
|
||||||
error!("failed to get play state lock");
|
error!("play state lock poisoned");
|
||||||
continue;
|
return;
|
||||||
};
|
};
|
||||||
*state
|
*state
|
||||||
};
|
};
|
||||||
if state == PlayState::Playing {
|
if state == PlayState::Playing && track.is_some() {
|
||||||
if is_last {
|
// The playing track was removed: play its successor, or
|
||||||
if let Err(err) = self.player.stop().in_current_span().await {
|
// stop when it was the last one.
|
||||||
error!("{:?}", err)
|
if was_last {
|
||||||
}
|
self.stop_player().await;
|
||||||
} else {
|
} else {
|
||||||
self.play(track).in_current_span().await;
|
self.play(track).await;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
PlaybackMessage::Insert {
|
PlaybackCommand::Insert { position, paths } => {
|
||||||
position,
|
let all_tracks = self.resolve_tracks(paths).await;
|
||||||
uuids,
|
debug!(count = all_tracks.len(), position, "inserting into queue");
|
||||||
span,
|
|
||||||
} => {
|
|
||||||
let _e = span.enter();
|
|
||||||
debug!("inserting");
|
|
||||||
let mut all_tracks = Vec::new();
|
|
||||||
for uuid in uuids {
|
|
||||||
if is_track(&uuid) {
|
|
||||||
if let Ok(track) = self.get_track(&uuid).in_current_span().await {
|
|
||||||
all_tracks.push(track);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
let tracks = self.flatten_node(&uuid).in_current_span().await;
|
|
||||||
all_tracks.extend(tracks);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
trace!("got tracks {:?}", all_tracks);
|
|
||||||
let track = {
|
let track = {
|
||||||
let Ok(mut queue) = self.queue.lock() else {
|
let Ok(mut queue) = self.queue.lock() else {
|
||||||
debug!("got queue lock");
|
error!("queue lock poisoned");
|
||||||
continue;
|
return;
|
||||||
};
|
};
|
||||||
debug!("got queue lock");
|
|
||||||
let track = queue.insert_tracks(position, &all_tracks);
|
let track = queue.insert_tracks(position, &all_tracks);
|
||||||
let queue_update_tx = self.update_tx.clone();
|
self.broadcast(StreamUpdate::Queue(queue.clone().into()));
|
||||||
let update = StreamUpdate::Queue(queue.clone().into());
|
|
||||||
if let Err(err) = queue_update_tx.send(update) {
|
|
||||||
trace!("{:?}", err)
|
|
||||||
};
|
|
||||||
track
|
track
|
||||||
};
|
};
|
||||||
debug!("queue lock released");
|
self.play_if_some(track).await;
|
||||||
self.play(track).in_current_span().await;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
PlaybackMessage::Clear {
|
PlaybackCommand::Clear { exclude_current } => {
|
||||||
exclude_current,
|
debug!(exclude_current, "clearing queue");
|
||||||
span,
|
|
||||||
} => {
|
|
||||||
let _e = span.enter();
|
|
||||||
debug!("clearing");
|
|
||||||
let should_stop = {
|
let should_stop = {
|
||||||
let Ok(mut queue) = self.queue.lock() else {
|
let Ok(mut queue) = self.queue.lock() else {
|
||||||
debug!("got queue lock");
|
error!("queue lock poisoned");
|
||||||
continue;
|
return;
|
||||||
};
|
};
|
||||||
debug!("got queue lock");
|
|
||||||
let should_stop = queue.clear(exclude_current);
|
let should_stop = queue.clear(exclude_current);
|
||||||
let queue_update_tx = self.update_tx.clone();
|
self.broadcast(StreamUpdate::Queue(queue.clone().into()));
|
||||||
let update = StreamUpdate::Queue(queue.clone().into());
|
|
||||||
if let Err(err) = queue_update_tx.send(update) {
|
|
||||||
trace!("{:?}", err)
|
|
||||||
};
|
|
||||||
should_stop
|
should_stop
|
||||||
};
|
};
|
||||||
debug!("queue lock released");
|
|
||||||
if should_stop {
|
if should_stop {
|
||||||
if let Err(err) = self.player.stop().in_current_span().await {
|
self.stop_player().await;
|
||||||
error!("{:?}", err)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
PlaybackMessage::SetCurrent {
|
PlaybackCommand::SetCurrent { position } => {
|
||||||
position: queue_position,
|
debug!(position, "jumping to queue position");
|
||||||
span,
|
|
||||||
} => {
|
|
||||||
let _e = span.enter();
|
|
||||||
debug!("setting current");
|
|
||||||
let track = {
|
let track = {
|
||||||
let Ok(mut queue) = self.queue.lock() else {
|
let Ok(mut queue) = self.queue.lock() else {
|
||||||
debug!("got queue lock");
|
error!("queue lock poisoned");
|
||||||
continue;
|
return;
|
||||||
};
|
};
|
||||||
debug!("got queue lock");
|
queue.set_current_position(position);
|
||||||
queue.set_current_position(queue_position);
|
|
||||||
queue.current_track()
|
queue.current_track()
|
||||||
};
|
};
|
||||||
debug!("quue lock released and got current {:?}", track);
|
self.play(track).await;
|
||||||
self.play(track).in_current_span().await;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
PlaybackMessage::ToggleShuffle { span } => {
|
PlaybackCommand::ToggleShuffle => {
|
||||||
let _e = span.enter();
|
let (shuffle, repeat) = {
|
||||||
debug!("toggling shuffle");
|
|
||||||
let shuffle;
|
|
||||||
let repeat;
|
|
||||||
{
|
|
||||||
let Ok(mut queue) = self.queue.lock() else {
|
let Ok(mut queue) = self.queue.lock() else {
|
||||||
debug!("got queue lock");
|
error!("queue lock poisoned");
|
||||||
continue;
|
return;
|
||||||
};
|
};
|
||||||
debug!("got queue lock");
|
|
||||||
repeat = queue.repeat;
|
|
||||||
if queue.shuffle {
|
if queue.shuffle {
|
||||||
queue.shuffle_off()
|
queue.shuffle_off()
|
||||||
} else {
|
} else {
|
||||||
queue.shuffle_on()
|
queue.shuffle_on()
|
||||||
}
|
}
|
||||||
shuffle = queue.shuffle;
|
(queue.shuffle, queue.repeat)
|
||||||
}
|
|
||||||
debug!("queue lock released");
|
|
||||||
let queue_update_tx = self.update_tx.clone();
|
|
||||||
let update = StreamUpdate::Mods(QueueModifiers { shuffle, repeat });
|
|
||||||
if let Err(err) = queue_update_tx.send(update) {
|
|
||||||
trace!("{:?}", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
PlaybackMessage::ToggleRepeat { span } => {
|
|
||||||
let _e = span.enter();
|
|
||||||
debug!("toggling repeat");
|
|
||||||
let shuffle;
|
|
||||||
let repeat;
|
|
||||||
{
|
|
||||||
let Ok(mut queue) = self.queue.lock() else {
|
|
||||||
debug!("got queue lock");
|
|
||||||
continue;
|
|
||||||
};
|
};
|
||||||
debug!("got queue lock");
|
debug!(shuffle, "toggled shuffle");
|
||||||
shuffle = queue.shuffle;
|
self.broadcast(StreamUpdate::Mods(QueueModifiers { shuffle, repeat }));
|
||||||
if queue.repeat {
|
|
||||||
queue.repeat = false
|
|
||||||
} else {
|
|
||||||
queue.repeat = true
|
|
||||||
}
|
|
||||||
repeat = queue.repeat;
|
|
||||||
}
|
|
||||||
debug!("queue lock released");
|
|
||||||
let queue_update_tx = self.update_tx.clone();
|
|
||||||
let update = StreamUpdate::Mods(QueueModifiers { shuffle, repeat });
|
|
||||||
if let Err(err) = queue_update_tx.send(update) {
|
|
||||||
trace!("{:?}", err)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
PlaybackMessage::TogglePlay { span } => {
|
PlaybackCommand::ToggleRepeat => {
|
||||||
let _e = span.enter();
|
let (shuffle, repeat) = {
|
||||||
debug!("toggling play");
|
let Ok(mut queue) = self.queue.lock() else {
|
||||||
{
|
error!("queue lock poisoned");
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
queue.repeat = !queue.repeat;
|
||||||
|
(queue.shuffle, queue.repeat)
|
||||||
|
};
|
||||||
|
debug!(repeat, "toggled repeat");
|
||||||
|
self.broadcast(StreamUpdate::Mods(QueueModifiers { shuffle, repeat }));
|
||||||
|
}
|
||||||
|
|
||||||
|
PlaybackCommand::TogglePlay => {
|
||||||
let state = {
|
let state = {
|
||||||
let Ok(state) = self.state.lock() else {
|
let Ok(state) = self.state.lock() else {
|
||||||
debug!("got state lock");
|
error!("play state lock poisoned");
|
||||||
continue;
|
return;
|
||||||
};
|
};
|
||||||
*state
|
*state
|
||||||
};
|
};
|
||||||
debug!("got state lock");
|
debug!(?state, "toggling play");
|
||||||
if state == PlayState::Playing {
|
if state == PlayState::Playing {
|
||||||
if let Err(err) = self.player.pause().await {
|
if let Err(err) = self.player.pause().await {
|
||||||
error!("{:?}", err)
|
warn!("pause failed: {err:?}");
|
||||||
}
|
}
|
||||||
} else if let Err(err) = self.player.unpause().await {
|
} else if let Err(err) = self.player.unpause().await {
|
||||||
error!("{:?}", err)
|
warn!("unpause failed: {err:?}");
|
||||||
}
|
|
||||||
}
|
|
||||||
debug!("state lock released");
|
|
||||||
}
|
|
||||||
|
|
||||||
PlaybackMessage::Stop { span } => {
|
|
||||||
let _e = span.enter();
|
|
||||||
debug!("stopping");
|
|
||||||
if let Err(err) = self.player.stop().await {
|
|
||||||
error!("{:?}", err)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
PlaybackMessage::ChangeVolume { delta, span } => {
|
PlaybackCommand::Stop => {
|
||||||
let _e = span.enter();
|
debug!("stopping playback");
|
||||||
debug!("changing volume");
|
self.stop_player().await;
|
||||||
if let Ok(volume) = self.player.volume().await {
|
}
|
||||||
debug!("got volume {:?}", volume);
|
|
||||||
|
PlaybackCommand::ChangeVolume { delta } => {
|
||||||
|
match self.player.volume().await {
|
||||||
|
Ok(volume) => {
|
||||||
|
debug!(volume, delta, "changing volume");
|
||||||
if let Err(err) = self.player.set_volume(volume + delta).await {
|
if let Err(err) = self.player.set_volume(volume + delta).await {
|
||||||
error!("{:?}", err)
|
warn!("set_volume failed: {err:?}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(err) => warn!("could not read volume: {err:?}"),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
PlaybackCommand::ToggleMute => {
|
||||||
|
// FIXME: implement mute in the player engine
|
||||||
|
debug!("toggle mute requested (not implemented)");
|
||||||
}
|
}
|
||||||
|
|
||||||
PlaybackMessage::ToggleMute { span } => {
|
PlaybackCommand::Next => {
|
||||||
let _e = span.enter();
|
|
||||||
debug!("toggling mute");
|
|
||||||
// let muted = self.player.is_muted();
|
|
||||||
// debug!("got muted {:?}", muted);
|
|
||||||
// self.player.set_mute(!muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
PlaybackMessage::Next { span } => {
|
|
||||||
let _e = span.enter();
|
|
||||||
debug!("nexting");
|
|
||||||
let track = {
|
let track = {
|
||||||
let Ok(mut queue) = self.queue.lock() else {
|
let Ok(mut queue) = self.queue.lock() else {
|
||||||
debug!("got queue lock");
|
error!("queue lock poisoned");
|
||||||
continue;
|
return;
|
||||||
};
|
};
|
||||||
debug!("got queue lock");
|
|
||||||
queue.next_track()
|
queue.next_track()
|
||||||
};
|
};
|
||||||
debug!("released queue lock and got track {:?}", track);
|
debug!(
|
||||||
|
track = track.as_ref().map(|t| t.path.as_str()),
|
||||||
self.play_or_stop(track).in_current_span().await;
|
"advancing to next track"
|
||||||
|
);
|
||||||
|
self.play_or_stop(track).await;
|
||||||
}
|
}
|
||||||
|
|
||||||
PlaybackMessage::Prev { span } => {
|
PlaybackCommand::Prev => {
|
||||||
let _e = span.enter();
|
|
||||||
debug!("preving");
|
|
||||||
let track = {
|
let track = {
|
||||||
let Ok(mut queue) = self.queue.lock() else {
|
let Ok(mut queue) = self.queue.lock() else {
|
||||||
debug!("got queue lock");
|
error!("queue lock poisoned");
|
||||||
continue;
|
return;
|
||||||
};
|
};
|
||||||
debug!("got queue lock");
|
|
||||||
queue.prev_track()
|
queue.prev_track()
|
||||||
};
|
};
|
||||||
debug!("released queue lock and got track {:?}", track);
|
debug!(
|
||||||
self.play_or_stop(track).in_current_span().await;
|
track = track.as_ref().map(|t| t.path.as_str()),
|
||||||
|
"going back to previous track"
|
||||||
|
);
|
||||||
|
self.play_or_stop(track).await;
|
||||||
}
|
}
|
||||||
|
|
||||||
PlaybackMessage::StateChanged { state, span } => {
|
PlaybackCommand::StateChanged { state } => {
|
||||||
let _e = span.enter();
|
|
||||||
debug!("state changed");
|
|
||||||
|
|
||||||
let play_state = {
|
let play_state = {
|
||||||
let Ok(mut state_lock) = self.state.lock() else {
|
let Ok(mut state_lock) = self.state.lock() else {
|
||||||
debug!("got state lock");
|
error!("play state lock poisoned");
|
||||||
continue;
|
return;
|
||||||
};
|
};
|
||||||
*state_lock = state;
|
*state_lock = state;
|
||||||
state
|
state
|
||||||
};
|
};
|
||||||
debug!("released state lock and got play state {:?}", play_state);
|
debug!(?play_state, "player state changed");
|
||||||
let active_track_tx = self.update_tx.clone();
|
self.broadcast(StreamUpdate::PlayState(play_state as i32));
|
||||||
let update = StreamUpdate::PlayState(play_state as i32);
|
|
||||||
if let Err(err) = active_track_tx.send(update) {
|
|
||||||
trace!("{:?}", err)
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
PlaybackMessage::RestartTrack { span } => {
|
PlaybackCommand::RestartTrack => {
|
||||||
let _e = span.enter();
|
debug!("restarting current track");
|
||||||
debug!("restarting track");
|
|
||||||
if let Err(err) = self.player.restart().await {
|
if let Err(err) = self.player.restart().await {
|
||||||
error!("{:?}", err)
|
warn!("restart failed: {err:?}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
PlaybackMessage::VolumeChanged { volume, span } => {
|
PlaybackCommand::VolumeChanged { volume } => {
|
||||||
let _e = span.enter();
|
trace!(volume, "volume changed");
|
||||||
trace!("volume changed");
|
self.broadcast(StreamUpdate::Volume(volume));
|
||||||
let update_tx = self.update_tx.clone();
|
}
|
||||||
let update = StreamUpdate::Volume(volume);
|
|
||||||
if let Err(err) = update_tx.send(update) {
|
PlaybackCommand::MuteChanged { muted } => {
|
||||||
trace!("{:?}", err)
|
trace!(muted, "mute changed");
|
||||||
|
self.broadcast(StreamUpdate::Mute(muted));
|
||||||
|
}
|
||||||
|
|
||||||
|
PlaybackCommand::PositionChanged { duration, position } => {
|
||||||
|
trace!(duration, position, "position changed");
|
||||||
|
self.broadcast(StreamUpdate::Position(TrackPosition { duration, position }));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
PlaybackMessage::MuteChanged { muted, span } => {
|
/// Sends an update to all connected clients. Having no subscribers is
|
||||||
let _e = span.enter();
|
/// normal and not an error.
|
||||||
trace!("mute changed");
|
fn broadcast(&self, update: StreamUpdate) {
|
||||||
let update_tx = self.update_tx.clone();
|
if let Err(err) = self.update_tx.send(update) {
|
||||||
let update = StreamUpdate::Mute(muted);
|
trace!("no update stream subscribers: {err}");
|
||||||
if let Err(err) = update_tx.send(update) {
|
|
||||||
trace!("{:?}", err)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
PlaybackMessage::PostitionChanged {
|
/// Resolves a mixed list of track and node paths into tracks by asking
|
||||||
duration,
|
/// the provider orchestrator.
|
||||||
position,
|
async fn resolve_tracks(&self, paths: Vec<String>) -> Vec<Track> {
|
||||||
span,
|
let mut all_tracks = Vec::new();
|
||||||
} => {
|
for path in paths {
|
||||||
let _e = span.enter();
|
let (result_tx, result_rx) = flume::bounded(1);
|
||||||
trace!("position changed");
|
let message = ProviderMessage::new(ProviderCommand::ResolveTracks {
|
||||||
let update_tx = self.update_tx.clone();
|
path: path.clone(),
|
||||||
let update = StreamUpdate::Position(TrackPosition { duration, position });
|
result_tx,
|
||||||
if let Err(err) = update_tx.send(update) {
|
|
||||||
trace!("{:?}", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
if let Err(err) = self.provider_tx.send_async(message).await {
|
||||||
|
error!("provider channel closed: {err}");
|
||||||
|
return all_tracks;
|
||||||
|
}
|
||||||
|
match result_rx.recv_async().await {
|
||||||
|
Ok(tracks) => {
|
||||||
|
if tracks.is_empty() {
|
||||||
|
warn!(path, "path resolved to no playable tracks");
|
||||||
|
}
|
||||||
|
all_tracks.extend(tracks);
|
||||||
|
}
|
||||||
|
Err(err) => error!(path, "provider dropped resolve_tracks reply: {err}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
trace!(count = all_tracks.len(), "resolved tracks");
|
||||||
|
all_tracks
|
||||||
}
|
}
|
||||||
|
|
||||||
#[instrument(skip(self))]
|
#[instrument(skip(self))]
|
||||||
async fn flatten_node(&self, uuid: &str) -> Vec<Track> {
|
async fn get_urls_for_track(&self, path: &str) -> Result<Vec<String>, ProviderError> {
|
||||||
debug!("flattening node");
|
|
||||||
let tx = self.provider_tx.clone();
|
|
||||||
let (result_tx, result_rx) = flume::bounded(1);
|
let (result_tx, result_rx) = flume::bounded(1);
|
||||||
let span = debug_span!("prov-chan");
|
let message = ProviderMessage::new(ProviderCommand::GetTrackUrls {
|
||||||
let Ok(_) = tx.send_async(ProviderMessage::FlattenNode {
|
path: path.to_string(),
|
||||||
uuid: uuid.to_string(),
|
|
||||||
result_tx,
|
result_tx,
|
||||||
span,
|
});
|
||||||
}).in_current_span().await else {
|
self.provider_tx
|
||||||
return Vec::new();
|
.send_async(message)
|
||||||
};
|
|
||||||
let Ok(tracks) = result_rx
|
|
||||||
.recv_async()
|
|
||||||
.in_current_span()
|
|
||||||
.await else {
|
|
||||||
return Vec::new();
|
|
||||||
};
|
|
||||||
tracks
|
|
||||||
}
|
|
||||||
|
|
||||||
#[instrument(skip(self))]
|
|
||||||
async fn get_track(&self, uuid: &str) -> Result<Track, ProviderError> {
|
|
||||||
debug!("getting track");
|
|
||||||
let tx = self.provider_tx.clone();
|
|
||||||
let (result_tx, result_rx) = flume::bounded(1);
|
|
||||||
let span = tracing::trace_span!("prov-chan");
|
|
||||||
tx.send_async(ProviderMessage::GetTrack {
|
|
||||||
uuid: uuid.to_string(),
|
|
||||||
result_tx,
|
|
||||||
span,
|
|
||||||
})
|
|
||||||
.in_current_span()
|
|
||||||
.await
|
.await
|
||||||
.map_err(|_| ProviderError::InternalError)?;
|
.map_err(|_| ProviderError::InternalError)?;
|
||||||
result_rx
|
result_rx
|
||||||
.recv_async()
|
.recv_async()
|
||||||
.in_current_span()
|
|
||||||
.await
|
.await
|
||||||
.map_err(|_| ProviderError::InternalError)?
|
.map_err(|_| ProviderError::InternalError)?
|
||||||
}
|
}
|
||||||
|
|
||||||
#[instrument(skip(self))]
|
async fn stop_player(&self) {
|
||||||
async fn get_urls_for_track(&self, uuid: &str) -> Result<Vec<String>, ProviderError> {
|
if let Err(err) = self.player.stop().await {
|
||||||
debug!("getting urls for track");
|
debug!("stop had no effect: {err:?}");
|
||||||
let tx = self.provider_tx.clone();
|
}
|
||||||
let (result_tx, result_rx) = flume::bounded(1);
|
|
||||||
let span = tracing::trace_span!("prov-chan");
|
|
||||||
tx.send_async(ProviderMessage::GetTrackUrls {
|
|
||||||
uuid: uuid.to_string(),
|
|
||||||
result_tx,
|
|
||||||
span,
|
|
||||||
})
|
|
||||||
.in_current_span()
|
|
||||||
.await
|
|
||||||
.map_err(|_| ProviderError::InternalError)?;
|
|
||||||
result_rx
|
|
||||||
.recv_async()
|
|
||||||
.in_current_span()
|
|
||||||
.await
|
|
||||||
.map_err(|_| ProviderError::InternalError)?
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[instrument(skip(self))]
|
/// Plays the given track if there is one, otherwise stops the player.
|
||||||
|
#[instrument(skip(self, track), fields(track = track.as_ref().map(|t| t.path.as_str())))]
|
||||||
async fn play_or_stop(&self, track: Option<Track>) {
|
async fn play_or_stop(&self, track: Option<Track>) {
|
||||||
debug!("play or stop");
|
if track.is_some() {
|
||||||
if let Some(track) = track {
|
self.play(track).await;
|
||||||
let mut uuid = track.uuid.clone();
|
|
||||||
let urls = loop {
|
|
||||||
match self.get_urls_for_track(&uuid).in_current_span().await {
|
|
||||||
Ok(urls) => break urls,
|
|
||||||
Err(err) => {
|
|
||||||
warn!("no urls found for track {:?}: {}", track.uuid, err);
|
|
||||||
uuid = {
|
|
||||||
let Ok(mut queue) = self.queue.lock() else {
|
|
||||||
debug!("got queue lock");
|
|
||||||
continue;
|
|
||||||
};
|
|
||||||
if let Some(track) = queue.next_track() {
|
|
||||||
track.uuid.clone()
|
|
||||||
} else {
|
} else {
|
||||||
return;
|
self.stop_player().await;
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
{
|
|
||||||
let Ok(queue) = self.queue.lock() else {
|
|
||||||
error!("poisend queue lock");
|
|
||||||
return
|
|
||||||
};
|
|
||||||
let queue_update_tx = self.update_tx.clone();
|
|
||||||
let track = queue.current_track();
|
|
||||||
let update = StreamUpdate::QueueTrack(QueueTrack {
|
|
||||||
queue_position: queue.current_position() as u32,
|
|
||||||
track,
|
|
||||||
});
|
|
||||||
if let Err(err) = queue_update_tx.send(update) {
|
|
||||||
trace!("{:?}", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if let Err(err) = self.player.play(&urls[0]).await {
|
|
||||||
error!("{:?}", err)
|
|
||||||
};
|
|
||||||
} else if let Err(err) = self.player.stop().await {
|
|
||||||
error!("{:?}", err)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[instrument(skip(self))]
|
/// Plays the given track if there is one; does nothing otherwise.
|
||||||
|
async fn play_if_some(&self, track: Option<Track>) {
|
||||||
|
if track.is_some() {
|
||||||
|
self.play(track).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Starts playback of the given track. When fetching stream URLs fails
|
||||||
|
/// the failing track is skipped and playback continues with the next
|
||||||
|
/// track in the queue.
|
||||||
|
#[instrument(skip(self, track), fields(track = track.as_ref().map(|t| t.path.as_str())))]
|
||||||
async fn play(&self, track: Option<Track>) {
|
async fn play(&self, track: Option<Track>) {
|
||||||
debug!("play");
|
let Some(track) = track else {
|
||||||
if let Some(track) = track {
|
debug!("nothing to play");
|
||||||
let mut uuid = track.uuid.clone();
|
|
||||||
let urls = loop {
|
|
||||||
match self.get_urls_for_track(&uuid).in_current_span().await {
|
|
||||||
Ok(urls) => break urls,
|
|
||||||
Err(err) => {
|
|
||||||
warn!("no urls found for track {:?}: {}", track.uuid, err);
|
|
||||||
uuid = {
|
|
||||||
let Ok(mut queue) = self.queue.lock() else {
|
|
||||||
debug!("poisend queue lock");
|
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
if let Some(track) = queue.next_track() {
|
let mut path = track.path.clone();
|
||||||
track.uuid.clone()
|
let urls = loop {
|
||||||
} else {
|
match self.get_urls_for_track(&path).await {
|
||||||
|
Ok(urls) if !urls.is_empty() => break urls,
|
||||||
|
Ok(_) => warn!(path, "provider returned no stream urls, skipping track"),
|
||||||
|
Err(err) => warn!(path, "failed to fetch stream urls ({err}), skipping track"),
|
||||||
|
}
|
||||||
|
let next = {
|
||||||
|
let Ok(mut queue) = self.queue.lock() else {
|
||||||
|
error!("queue lock poisoned");
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
queue.next_track()
|
||||||
|
};
|
||||||
|
match next {
|
||||||
|
Some(next_track) => path = next_track.path.clone(),
|
||||||
|
None => {
|
||||||
|
error!("no playable track left in queue, stopping");
|
||||||
|
self.stop_player().await;
|
||||||
return;
|
return;
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
{
|
{
|
||||||
let Ok(queue) = self.queue.lock() else {
|
let Ok(queue) = self.queue.lock() else {
|
||||||
error!("poisend queue lock");
|
error!("queue lock poisoned");
|
||||||
return
|
return;
|
||||||
};
|
};
|
||||||
let queue_update_tx = self.update_tx.clone();
|
self.broadcast(StreamUpdate::QueueTrack(QueueTrack {
|
||||||
let track = queue.current_track();
|
|
||||||
let update = StreamUpdate::QueueTrack(QueueTrack {
|
|
||||||
queue_position: queue.current_position() as u32,
|
queue_position: queue.current_position() as u32,
|
||||||
track,
|
track: queue.current_track(),
|
||||||
});
|
}));
|
||||||
if let Err(err) = queue_update_tx.send(update) {
|
|
||||||
trace!("{:?}", err)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
debug!(url_count = urls.len(), "starting player");
|
||||||
if let Err(err) = self.player.play(&urls[0]).await {
|
if let Err(err) = self.player.play(&urls[0]).await {
|
||||||
error!("{:?}", err)
|
error!("player failed to start track: {err:?}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
fn is_track(uuid: &str) -> bool {
|
|
||||||
uuid.starts_with("track:")
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -1,88 +1,84 @@
|
||||||
use crate::ProviderMessage;
|
use crate::{ProviderCommand, ProviderMessage};
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use crabidy_core::{
|
use crabidy_core::{
|
||||||
proto::crabidy::{LibraryNode, LibraryNodeChild, Track},
|
proto::crabidy::{LibraryNode, LibraryNodeChild, Track},
|
||||||
ProviderClient, ProviderError,
|
ProviderClient, ProviderError,
|
||||||
};
|
};
|
||||||
use std::{fs, path::PathBuf, sync::Arc};
|
use std::{fs, path::PathBuf, sync::Arc};
|
||||||
use tracing::{debug, error, instrument, warn, Instrument};
|
use tracing::{debug, debug_span, error, instrument, warn, Instrument};
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub struct ProviderOrchestrator {
|
pub struct ProviderOrchestrator {
|
||||||
pub provider_tx: flume::Sender<ProviderMessage>,
|
pub provider_tx: flume::Sender<ProviderMessage>,
|
||||||
provider_rx: flume::Receiver<ProviderMessage>,
|
provider_rx: flume::Receiver<ProviderMessage>,
|
||||||
// known_tracks: RwLock<HashMap<String, Track>>,
|
|
||||||
// known_nodes: RwLock<HashMap<String, LibraryNode>>,
|
|
||||||
tidal_client: Arc<tidaldy::Client>,
|
tidal_client: Arc<tidaldy::Client>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ProviderOrchestrator {
|
impl ProviderOrchestrator {
|
||||||
pub fn run(self) {
|
pub fn run(self) {
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
while let Ok(msg) = self.provider_rx.recv_async().await {
|
while let Ok(ProviderMessage { span, command }) = self.provider_rx.recv_async().await {
|
||||||
match msg {
|
let handler_span =
|
||||||
ProviderMessage::GetLibraryNode {
|
debug_span!(parent: &span, "provider_command", command = command.name());
|
||||||
uuid,
|
self.handle_command(command).instrument(handler_span).await;
|
||||||
result_tx,
|
|
||||||
span,
|
|
||||||
} => {
|
|
||||||
let _e = span.enter();
|
|
||||||
let result = self.get_lib_node(&uuid).in_current_span().await;
|
|
||||||
if let Err(err) = result_tx.send_async(result).in_current_span().await {
|
|
||||||
error!("failed to send result: {}", err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
ProviderMessage::GetTrack {
|
|
||||||
uuid,
|
|
||||||
result_tx,
|
|
||||||
span,
|
|
||||||
} => {
|
|
||||||
let _e = span.enter();
|
|
||||||
let result = self.get_metadata_for_track(&uuid).in_current_span().await;
|
|
||||||
if let Err(err) = result_tx.send_async(result).in_current_span().await {
|
|
||||||
error!("failed to send result: {}", err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
ProviderMessage::GetTrackUrls {
|
|
||||||
uuid,
|
|
||||||
result_tx,
|
|
||||||
span,
|
|
||||||
} => {
|
|
||||||
let _e = span.enter();
|
|
||||||
let result = self.get_urls_for_track(&uuid).in_current_span().await;
|
|
||||||
if let Err(err) = result_tx.send_async(result).in_current_span().await {
|
|
||||||
error!("failed to send result: {}", err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
ProviderMessage::FlattenNode {
|
|
||||||
uuid,
|
|
||||||
result_tx,
|
|
||||||
span,
|
|
||||||
} => {
|
|
||||||
let _e = span.enter();
|
|
||||||
let result = self.flatten_node(&uuid).in_current_span().await;
|
|
||||||
if let Err(err) = result_tx.send_async(result).in_current_span().await {
|
|
||||||
error!("failed to send result: {}", err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
warn!("provider message channel closed, loop exiting");
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn handle_command(&self, command: ProviderCommand) {
|
||||||
|
match command {
|
||||||
|
ProviderCommand::GetLibraryNode { path, result_tx } => {
|
||||||
|
let result = self.get_lib_node(&path).await;
|
||||||
|
if let Err(err) = result_tx.send_async(result).await {
|
||||||
|
error!("failed to send get_library_node result: {err}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ProviderCommand::GetTrackUrls { path, result_tx } => {
|
||||||
|
let result = self.get_urls_for_track(&path).await;
|
||||||
|
if let Err(err) = result_tx.send_async(result).await {
|
||||||
|
error!("failed to send get_track_urls result: {err}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ProviderCommand::ResolveTracks { path, result_tx } => {
|
||||||
|
let result = self.resolve_tracks(&path).await;
|
||||||
|
if let Err(err) = result_tx.send_async(result).await {
|
||||||
|
error!("failed to send resolve_tracks result: {err}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolves a path into playable tracks. A track path resolves to that
|
||||||
|
/// single track; a node path is flattened by walking its queueable
|
||||||
|
/// descendants.
|
||||||
#[instrument(skip(self))]
|
#[instrument(skip(self))]
|
||||||
async fn flatten_node(&self, node_uuid: &str) -> Vec<Track> {
|
async fn resolve_tracks(&self, path: &str) -> Vec<Track> {
|
||||||
let mut tracks = Vec::with_capacity(1000);
|
if self.is_track_path(path) {
|
||||||
let mut nodes_to_go = Vec::with_capacity(100);
|
return match self.get_metadata_for_track(path).await {
|
||||||
nodes_to_go.push(node_uuid.to_string());
|
Ok(track) => vec![track],
|
||||||
while let Some(node_uuid) = nodes_to_go.pop() {
|
Err(err) => {
|
||||||
let Ok(node) = self.get_lib_node(&node_uuid).in_current_span().await else {
|
warn!(path, "failed to resolve track: {err}");
|
||||||
continue
|
Vec::new()
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
let mut tracks = Vec::new();
|
||||||
|
let mut nodes_to_go = vec![path.to_string()];
|
||||||
|
while let Some(node_path) = nodes_to_go.pop() {
|
||||||
|
let node = match self.get_lib_node(&node_path).await {
|
||||||
|
Ok(node) => node,
|
||||||
|
Err(err) => {
|
||||||
|
warn!(node = node_path, "skipping unreadable node: {err}");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
if node.is_queable {
|
if node.is_queable {
|
||||||
tracks.extend(node.tracks);
|
tracks.extend(node.tracks);
|
||||||
nodes_to_go.extend(node.children.into_iter().map(|c| c.uuid))
|
nodes_to_go.extend(node.children.into_iter().map(|c| c.path))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
debug!(count = tracks.len(), "resolved path into tracks");
|
||||||
tracks
|
tracks
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -95,29 +91,25 @@ impl ProviderClient for ProviderOrchestrator {
|
||||||
.map(|d| d.join("crabidy"))
|
.map(|d| d.join("crabidy"))
|
||||||
.unwrap_or(PathBuf::from("/tmp"));
|
.unwrap_or(PathBuf::from("/tmp"));
|
||||||
let dir_exists = tokio::fs::try_exists(&config_dir)
|
let dir_exists = tokio::fs::try_exists(&config_dir)
|
||||||
.in_current_span()
|
|
||||||
.await
|
.await
|
||||||
.map_err(|e| ProviderError::Config(e.to_string()))?;
|
.map_err(|e| ProviderError::Config(e.to_string()))?;
|
||||||
if !dir_exists {
|
if !dir_exists {
|
||||||
tokio::fs::create_dir(&config_dir)
|
tokio::fs::create_dir(&config_dir)
|
||||||
.in_current_span()
|
|
||||||
.await
|
.await
|
||||||
.map_err(|e| ProviderError::Config(e.to_string()))?;
|
.map_err(|e| ProviderError::Config(e.to_string()))?;
|
||||||
}
|
}
|
||||||
let config_file = config_dir.join("tidaly.toml");
|
let config_file = config_dir.join("tidaly.toml");
|
||||||
let raw_toml_settings = fs::read_to_string(&config_file).unwrap_or("".to_owned());
|
debug!(config_file = %config_file.display(), "loading tidal config");
|
||||||
let tidal_client = Arc::new(
|
let raw_toml_settings = fs::read_to_string(&config_file).unwrap_or_default();
|
||||||
tidaldy::Client::init(&raw_toml_settings)
|
let tidal_client = Arc::new(tidaldy::Client::init(&raw_toml_settings).await.map_err(
|
||||||
.in_current_span()
|
|err| {
|
||||||
.await
|
error!("failed to init tidal client: {err}");
|
||||||
.expect("Failed to init Tidal clienta"),
|
err
|
||||||
);
|
},
|
||||||
|
)?);
|
||||||
let new_toml_config = tidal_client.settings();
|
let new_toml_config = tidal_client.settings();
|
||||||
if let Err(err) = tokio::fs::write(&config_file, new_toml_config)
|
if let Err(err) = tokio::fs::write(&config_file, new_toml_config).await {
|
||||||
.in_current_span()
|
error!("failed to write tidal config file: {err}");
|
||||||
.await
|
|
||||||
{
|
|
||||||
error!("Failed to write config file: {}", err);
|
|
||||||
};
|
};
|
||||||
let (provider_tx, provider_rx) = flume::bounded(100);
|
let (provider_tx, provider_rx) = flume::bounded(100);
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
|
|
@ -126,46 +118,55 @@ impl ProviderClient for ProviderOrchestrator {
|
||||||
tidal_client,
|
tidal_client,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
#[instrument(skip(self))]
|
|
||||||
fn settings(&self) -> String {
|
fn settings(&self) -> String {
|
||||||
"".to_owned()
|
String::new()
|
||||||
}
|
}
|
||||||
#[instrument(skip(self))]
|
|
||||||
async fn get_urls_for_track(&self, track_uuid: &str) -> Result<Vec<String>, ProviderError> {
|
/// Routes to the provider that owns the path.
|
||||||
debug!("get_urls_for_track");
|
fn is_track_path(&self, path: &str) -> bool {
|
||||||
self.tidal_client
|
if path == "/tidal" || path.starts_with("/tidal/") {
|
||||||
.get_urls_for_track(track_uuid)
|
return self.tidal_client.is_track_path(path);
|
||||||
.in_current_span()
|
|
||||||
.await
|
|
||||||
}
|
}
|
||||||
#[instrument(skip(self))]
|
false
|
||||||
async fn get_metadata_for_track(&self, track_uuid: &str) -> Result<Track, ProviderError> {
|
|
||||||
debug!("get_metadata_for_track");
|
|
||||||
self.tidal_client
|
|
||||||
.get_metadata_for_track(track_uuid)
|
|
||||||
.in_current_span()
|
|
||||||
.await
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[instrument(skip(self))]
|
#[instrument(skip(self))]
|
||||||
|
async fn get_urls_for_track(&self, track_path: &str) -> Result<Vec<String>, ProviderError> {
|
||||||
|
if track_path.starts_with("/tidal/") {
|
||||||
|
return self.tidal_client.get_urls_for_track(track_path).await;
|
||||||
|
}
|
||||||
|
warn!(path = track_path, "no provider owns this track path");
|
||||||
|
Err(ProviderError::MalformedPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[instrument(skip(self))]
|
||||||
|
async fn get_metadata_for_track(&self, track_path: &str) -> Result<Track, ProviderError> {
|
||||||
|
if track_path.starts_with("/tidal/") {
|
||||||
|
return self.tidal_client.get_metadata_for_track(track_path).await;
|
||||||
|
}
|
||||||
|
warn!(path = track_path, "no provider owns this track path");
|
||||||
|
Err(ProviderError::MalformedPath)
|
||||||
|
}
|
||||||
|
|
||||||
fn get_lib_root(&self) -> LibraryNode {
|
fn get_lib_root(&self) -> LibraryNode {
|
||||||
debug!("get_lib_root in provider manager");
|
|
||||||
let mut root_node = LibraryNode::new();
|
let mut root_node = LibraryNode::new();
|
||||||
let child = LibraryNodeChild::new("node:tidal".to_owned(), "tidal".to_owned(), false);
|
let child =
|
||||||
|
LibraryNodeChild::new(tidaldy::PROVIDER_ROOT.to_owned(), "tidal".to_owned(), false);
|
||||||
root_node.children.push(child);
|
root_node.children.push(child);
|
||||||
root_node
|
root_node
|
||||||
}
|
}
|
||||||
|
|
||||||
#[instrument(skip(self))]
|
#[instrument(skip(self))]
|
||||||
async fn get_lib_node(&self, uuid: &str) -> Result<LibraryNode, ProviderError> {
|
async fn get_lib_node(&self, path: &str) -> Result<LibraryNode, ProviderError> {
|
||||||
debug!("get_lib_node in provider manager");
|
if path == crabidy_core::ROOT_PATH {
|
||||||
if uuid == "node:/" {
|
debug!("serving global library root");
|
||||||
debug!("get global root");
|
|
||||||
return Ok(self.get_lib_root());
|
return Ok(self.get_lib_root());
|
||||||
}
|
}
|
||||||
if uuid == "node:tidal" {
|
if path == tidaldy::PROVIDER_ROOT || path.starts_with("/tidal/") {
|
||||||
debug!("get tidal root");
|
return self.tidal_client.get_lib_node(path).await;
|
||||||
return Ok(self.tidal_client.get_lib_root());
|
|
||||||
}
|
}
|
||||||
debug!("tidal node");
|
warn!(path, "no provider owns this path");
|
||||||
self.tidal_client.get_lib_node(uuid).in_current_span().await
|
Err(ProviderError::MalformedPath)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
use crate::{PlaybackMessage, ProviderMessage};
|
use crate::{PlaybackCommand, PlaybackMessage, ProviderCommand, ProviderMessage};
|
||||||
use crabidy_core::proto::crabidy::{
|
use crabidy_core::proto::crabidy::{
|
||||||
crabidy_service_server::CrabidyService, get_update_stream_response::Update as StreamUpdate,
|
crabidy_service_server::CrabidyService, get_update_stream_response::Update as StreamUpdate,
|
||||||
AppendRequest, AppendResponse, ChangeVolumeRequest, ChangeVolumeResponse, ClearQueueRequest,
|
AppendRequest, AppendResponse, ChangeVolumeRequest, ChangeVolumeResponse, ClearQueueRequest,
|
||||||
|
|
@ -10,11 +10,10 @@ use crabidy_core::proto::crabidy::{
|
||||||
StopResponse, ToggleMuteRequest, ToggleMuteResponse, TogglePlayRequest, TogglePlayResponse,
|
StopResponse, ToggleMuteRequest, ToggleMuteResponse, TogglePlayRequest, TogglePlayResponse,
|
||||||
ToggleRepeatRequest, ToggleRepeatResponse, ToggleShuffleRequest, ToggleShuffleResponse,
|
ToggleRepeatRequest, ToggleRepeatResponse, ToggleShuffleRequest, ToggleShuffleResponse,
|
||||||
};
|
};
|
||||||
use futures::TryStreamExt;
|
|
||||||
use std::pin::Pin;
|
use std::pin::Pin;
|
||||||
use tokio_stream::StreamExt;
|
use tokio_stream::StreamExt;
|
||||||
use tonic::{Request, Response, Status};
|
use tonic::{Request, Response, Status};
|
||||||
use tracing::{debug, debug_span, error, instrument, trace, Instrument, Span};
|
use tracing::{debug, error, instrument, trace};
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub struct RpcService {
|
pub struct RpcService {
|
||||||
|
|
@ -25,16 +24,29 @@ pub struct RpcService {
|
||||||
|
|
||||||
impl RpcService {
|
impl RpcService {
|
||||||
pub fn new(
|
pub fn new(
|
||||||
update_rx: tokio::sync::broadcast::Sender<StreamUpdate>,
|
update_tx: tokio::sync::broadcast::Sender<StreamUpdate>,
|
||||||
playback_tx: flume::Sender<PlaybackMessage>,
|
playback_tx: flume::Sender<PlaybackMessage>,
|
||||||
provider_tx: flume::Sender<ProviderMessage>,
|
provider_tx: flume::Sender<ProviderMessage>,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
Self {
|
Self {
|
||||||
update_tx: update_rx,
|
update_tx,
|
||||||
playback_tx,
|
playback_tx,
|
||||||
provider_tx,
|
provider_tx,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Sends a command to the playback loop, mapping channel failure to an
|
||||||
|
/// internal error status.
|
||||||
|
async fn send_playback(&self, command: PlaybackCommand) -> Result<(), Status> {
|
||||||
|
let name = command.name();
|
||||||
|
self.playback_tx
|
||||||
|
.send_async(PlaybackMessage::new(command))
|
||||||
|
.await
|
||||||
|
.map_err(|err| {
|
||||||
|
error!(command = name, "playback channel closed: {err}");
|
||||||
|
Status::internal("playback loop unavailable")
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tonic::async_trait]
|
#[tonic::async_trait]
|
||||||
|
|
@ -44,267 +56,183 @@ impl CrabidyService for RpcService {
|
||||||
|
|
||||||
#[instrument(skip(self, _request))]
|
#[instrument(skip(self, _request))]
|
||||||
async fn init(&self, _request: Request<InitRequest>) -> Result<Response<InitResponse>, Status> {
|
async fn init(&self, _request: Request<InitRequest>) -> Result<Response<InitResponse>, Status> {
|
||||||
debug!("Received init request");
|
debug!("received init request");
|
||||||
let playback_tx = self.playback_tx.clone();
|
|
||||||
let (result_tx, result_rx) = flume::bounded(1);
|
let (result_tx, result_rx) = flume::bounded(1);
|
||||||
let span = debug_span!("play-chan");
|
self.send_playback(PlaybackCommand::Init { result_tx })
|
||||||
if let Err(err) = playback_tx
|
.await?;
|
||||||
.send_async(PlaybackMessage::Init { result_tx, span })
|
let response = result_rx.recv_async().await.map_err(|err| {
|
||||||
.in_current_span()
|
error!("no reply from playback loop: {err}");
|
||||||
.await
|
Status::internal("playback loop did not reply")
|
||||||
{
|
|
||||||
error!("{:?}", err);
|
|
||||||
return Err(Status::internal("Sending Init via internal channel failed"));
|
|
||||||
}
|
|
||||||
let response = result_rx
|
|
||||||
.recv_async()
|
|
||||||
.in_current_span()
|
|
||||||
.await
|
|
||||||
.map_err(|e| {
|
|
||||||
error!("{:?}", e);
|
|
||||||
Status::internal("Failed to receive response from provider channel")
|
|
||||||
})?;
|
})?;
|
||||||
Ok(Response::new(response))
|
Ok(Response::new(response))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[instrument(skip(self, request), fields(uuid))]
|
#[instrument(skip(self, request), fields(path))]
|
||||||
async fn get_library_node(
|
async fn get_library_node(
|
||||||
&self,
|
&self,
|
||||||
request: Request<GetLibraryNodeRequest>,
|
request: Request<GetLibraryNodeRequest>,
|
||||||
) -> Result<Response<GetLibraryNodeResponse>, Status> {
|
) -> Result<Response<GetLibraryNodeResponse>, Status> {
|
||||||
let uuid = request.into_inner().uuid;
|
let path = request.into_inner().path;
|
||||||
Span::current().record("uuid", &uuid);
|
tracing::Span::current().record("path", path.as_str());
|
||||||
debug!("Received get_library_node request");
|
debug!("received get_library_node request");
|
||||||
let provider_tx = self.provider_tx.clone();
|
|
||||||
let (result_tx, result_rx) = flume::bounded(1);
|
let (result_tx, result_rx) = flume::bounded(1);
|
||||||
let span = debug_span!("prov-chan");
|
self.provider_tx
|
||||||
provider_tx
|
.send_async(ProviderMessage::new(ProviderCommand::GetLibraryNode {
|
||||||
.send_async(ProviderMessage::GetLibraryNode {
|
path,
|
||||||
uuid,
|
|
||||||
result_tx,
|
result_tx,
|
||||||
span,
|
}))
|
||||||
})
|
|
||||||
.in_current_span()
|
|
||||||
.await
|
.await
|
||||||
.map_err(|_| Status::internal("Failed to send request via channel"))?;
|
.map_err(|err| {
|
||||||
let result = result_rx
|
error!("provider channel closed: {err}");
|
||||||
.recv_async()
|
Status::internal("provider unavailable")
|
||||||
.in_current_span()
|
})?;
|
||||||
.await
|
let result = result_rx.recv_async().await.map_err(|err| {
|
||||||
.map_err(|e| {
|
error!("no reply from provider: {err}");
|
||||||
error!("{:?}", e);
|
Status::internal("provider did not reply")
|
||||||
Status::internal("Failed to receive response from provider channel")
|
|
||||||
})?;
|
})?;
|
||||||
match result {
|
match result {
|
||||||
Ok(node) => Ok(Response::new(GetLibraryNodeResponse { node: Some(node) })),
|
Ok(node) => Ok(Response::new(GetLibraryNodeResponse { node: Some(node) })),
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
error!("{:?}", err);
|
error!("get_library_node failed: {err}");
|
||||||
Err(Status::internal(err.to_string()))
|
Err(Status::internal(err.to_string()))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[instrument(skip(self, request), fields(uuids))]
|
#[instrument(skip(self, request), fields(paths))]
|
||||||
async fn queue(
|
async fn queue(
|
||||||
&self,
|
&self,
|
||||||
request: tonic::Request<QueueRequest>,
|
request: Request<QueueRequest>,
|
||||||
) -> std::result::Result<tonic::Response<QueueResponse>, tonic::Status> {
|
) -> Result<Response<QueueResponse>, Status> {
|
||||||
let uuids = request.into_inner().uuids.clone();
|
let paths = request.into_inner().paths;
|
||||||
Span::current().record("uuids", format!("{:?}", uuids));
|
tracing::Span::current().record("paths", format!("{paths:?}"));
|
||||||
debug!("Received queue request");
|
debug!("received queue request");
|
||||||
let playback_tx = self.playback_tx.clone();
|
self.send_playback(PlaybackCommand::Queue { paths }).await?;
|
||||||
let span = debug_span!("play-chan");
|
Ok(Response::new(QueueResponse {}))
|
||||||
playback_tx
|
|
||||||
.send_async(PlaybackMessage::Queue { uuids, span })
|
|
||||||
.in_current_span()
|
|
||||||
.await
|
|
||||||
.map_err(|_| Status::internal("Failed to send request via channel"))?;
|
|
||||||
|
|
||||||
let reply = QueueResponse {};
|
|
||||||
Ok(Response::new(reply))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[instrument(skip(self, request), fields(uuids))]
|
#[instrument(skip(self, request), fields(paths))]
|
||||||
async fn replace(
|
async fn replace(
|
||||||
&self,
|
&self,
|
||||||
request: tonic::Request<ReplaceRequest>,
|
request: Request<ReplaceRequest>,
|
||||||
) -> std::result::Result<tonic::Response<ReplaceResponse>, tonic::Status> {
|
) -> Result<Response<ReplaceResponse>, Status> {
|
||||||
let uuids = request.into_inner().uuids.clone();
|
let paths = request.into_inner().paths;
|
||||||
Span::current().record("uuids", format!("{:?}", uuids));
|
tracing::Span::current().record("paths", format!("{paths:?}"));
|
||||||
debug!("Received replace request");
|
debug!("received replace request");
|
||||||
let playback_tx = self.playback_tx.clone();
|
self.send_playback(PlaybackCommand::Replace { paths })
|
||||||
let span = debug_span!("play-chan");
|
.await?;
|
||||||
playback_tx
|
Ok(Response::new(ReplaceResponse {}))
|
||||||
.send_async(PlaybackMessage::Replace { uuids, span })
|
|
||||||
.in_current_span()
|
|
||||||
.await
|
|
||||||
.map_err(|_| Status::internal("Failed to send request via channel"))?;
|
|
||||||
let reply = ReplaceResponse {};
|
|
||||||
Ok(Response::new(reply))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[instrument(skip(self, request), fields(uuids))]
|
#[instrument(skip(self, request), fields(paths))]
|
||||||
async fn append(
|
async fn append(
|
||||||
&self,
|
&self,
|
||||||
request: tonic::Request<AppendRequest>,
|
request: Request<AppendRequest>,
|
||||||
) -> std::result::Result<tonic::Response<AppendResponse>, tonic::Status> {
|
) -> Result<Response<AppendResponse>, Status> {
|
||||||
let uuids = request.into_inner().uuids.clone();
|
let paths = request.into_inner().paths;
|
||||||
Span::current().record("uuids", format!("{:?}", uuids));
|
tracing::Span::current().record("paths", format!("{paths:?}"));
|
||||||
debug!("Received append request");
|
debug!("received append request");
|
||||||
let playback_tx = self.playback_tx.clone();
|
self.send_playback(PlaybackCommand::Append { paths })
|
||||||
let span = debug_span!("play-chan");
|
.await?;
|
||||||
playback_tx
|
Ok(Response::new(AppendResponse {}))
|
||||||
.send_async(PlaybackMessage::Append { uuids, span })
|
|
||||||
.in_current_span()
|
|
||||||
.await
|
|
||||||
.map_err(|_| Status::internal("Failed to send request via channel"))?;
|
|
||||||
let reply = AppendResponse {};
|
|
||||||
Ok(Response::new(reply))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[instrument(skip(self, request), fields(positions))]
|
#[instrument(skip(self, request), fields(positions))]
|
||||||
async fn remove(
|
async fn remove(
|
||||||
&self,
|
&self,
|
||||||
request: tonic::Request<RemoveRequest>,
|
request: Request<RemoveRequest>,
|
||||||
) -> std::result::Result<tonic::Response<RemoveResponse>, tonic::Status> {
|
) -> Result<Response<RemoveResponse>, Status> {
|
||||||
let positions = request.into_inner().positions;
|
let positions = request.into_inner().positions;
|
||||||
Span::current().record("positions", format!("{:?}", positions));
|
tracing::Span::current().record("positions", format!("{positions:?}"));
|
||||||
debug!("Received remove request");
|
debug!("received remove request");
|
||||||
let playback_tx = self.playback_tx.clone();
|
self.send_playback(PlaybackCommand::Remove { positions })
|
||||||
let span = debug_span!("play-chan");
|
.await?;
|
||||||
playback_tx
|
Ok(Response::new(RemoveResponse {}))
|
||||||
.send_async(PlaybackMessage::Remove { positions, span })
|
|
||||||
.in_current_span()
|
|
||||||
.await
|
|
||||||
.map_err(|_| Status::internal("Failed to send request via channel"))?;
|
|
||||||
let reply = RemoveResponse {};
|
|
||||||
Ok(Response::new(reply))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[instrument(skip(self, request), fields(uuids, position))]
|
#[instrument(skip(self, request), fields(paths, position))]
|
||||||
async fn insert(
|
async fn insert(
|
||||||
&self,
|
&self,
|
||||||
request: tonic::Request<InsertRequest>,
|
request: Request<InsertRequest>,
|
||||||
) -> std::result::Result<tonic::Response<InsertResponse>, tonic::Status> {
|
) -> Result<Response<InsertResponse>, Status> {
|
||||||
let req = request.into_inner();
|
let req = request.into_inner();
|
||||||
let uuids = req.uuids.clone();
|
tracing::Span::current().record("paths", format!("{:?}", req.paths));
|
||||||
let position = req.position;
|
tracing::Span::current().record("position", req.position);
|
||||||
Span::current().record("uuids", format!("{:?}", uuids));
|
debug!("received insert request");
|
||||||
Span::current().record("position", position);
|
self.send_playback(PlaybackCommand::Insert {
|
||||||
debug!("Received insert request");
|
|
||||||
let playback_tx = self.playback_tx.clone();
|
|
||||||
let span = debug_span!("play-chan");
|
|
||||||
playback_tx
|
|
||||||
.send_async(PlaybackMessage::Insert {
|
|
||||||
position: req.position,
|
position: req.position,
|
||||||
uuids,
|
paths: req.paths,
|
||||||
span,
|
|
||||||
})
|
})
|
||||||
.in_current_span()
|
.await?;
|
||||||
.await
|
Ok(Response::new(InsertResponse {}))
|
||||||
.map_err(|_| Status::internal("Failed to send request via channel"))?;
|
|
||||||
let reply = InsertResponse {};
|
|
||||||
Ok(Response::new(reply))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[instrument(skip(self, request), fields(exclude_current))]
|
#[instrument(skip(self, request), fields(exclude_current))]
|
||||||
async fn clear_queue(
|
async fn clear_queue(
|
||||||
&self,
|
&self,
|
||||||
request: tonic::Request<ClearQueueRequest>,
|
request: Request<ClearQueueRequest>,
|
||||||
) -> std::result::Result<tonic::Response<ClearQueueResponse>, tonic::Status> {
|
) -> Result<Response<ClearQueueResponse>, Status> {
|
||||||
let exclude_current = request.into_inner().exclude_current;
|
let exclude_current = request.into_inner().exclude_current;
|
||||||
Span::current().record("exclude_current", exclude_current);
|
tracing::Span::current().record("exclude_current", exclude_current);
|
||||||
debug!("Received clear_queue request");
|
debug!("received clear_queue request");
|
||||||
let playback_tx = self.playback_tx.clone();
|
self.send_playback(PlaybackCommand::Clear { exclude_current })
|
||||||
let span = debug_span!("play-chan");
|
.await?;
|
||||||
playback_tx
|
Ok(Response::new(ClearQueueResponse {}))
|
||||||
.send_async(PlaybackMessage::Clear {
|
|
||||||
exclude_current,
|
|
||||||
span,
|
|
||||||
})
|
|
||||||
.in_current_span()
|
|
||||||
.await
|
|
||||||
.map_err(|_| Status::internal("Failed to send request via channel"))?;
|
|
||||||
let reply = ClearQueueResponse {};
|
|
||||||
Ok(Response::new(reply))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[instrument(skip(self, request), fields(position))]
|
#[instrument(skip(self, request), fields(position))]
|
||||||
async fn set_current(
|
async fn set_current(
|
||||||
&self,
|
&self,
|
||||||
request: tonic::Request<SetCurrentRequest>,
|
request: Request<SetCurrentRequest>,
|
||||||
) -> std::result::Result<tonic::Response<SetCurrentResponse>, tonic::Status> {
|
) -> Result<Response<SetCurrentResponse>, Status> {
|
||||||
let position = request.into_inner().position;
|
let position = request.into_inner().position;
|
||||||
Span::current().record("position", position);
|
tracing::Span::current().record("position", position);
|
||||||
debug!("Received set_current request");
|
debug!("received set_current request");
|
||||||
let playback_tx = self.playback_tx.clone();
|
self.send_playback(PlaybackCommand::SetCurrent { position })
|
||||||
let span = debug_span!("play-chan");
|
.await?;
|
||||||
playback_tx
|
Ok(Response::new(SetCurrentResponse {}))
|
||||||
.send_async(PlaybackMessage::SetCurrent { position, span })
|
|
||||||
.in_current_span()
|
|
||||||
.await
|
|
||||||
.map_err(|_| Status::internal("Failed to send request via channel"))?;
|
|
||||||
let reply = SetCurrentResponse {};
|
|
||||||
Ok(Response::new(reply))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[instrument(skip(self, _request))]
|
#[instrument(skip(self, _request))]
|
||||||
async fn toggle_shuffle(
|
async fn toggle_shuffle(
|
||||||
&self,
|
&self,
|
||||||
_request: tonic::Request<ToggleShuffleRequest>,
|
_request: Request<ToggleShuffleRequest>,
|
||||||
) -> std::result::Result<tonic::Response<ToggleShuffleResponse>, tonic::Status> {
|
) -> Result<Response<ToggleShuffleResponse>, Status> {
|
||||||
debug!("Received toggle_shuffle request");
|
debug!("received toggle_shuffle request");
|
||||||
let playback_tx = self.playback_tx.clone();
|
self.send_playback(PlaybackCommand::ToggleShuffle).await?;
|
||||||
let span = debug_span!("play-chan");
|
Ok(Response::new(ToggleShuffleResponse {}))
|
||||||
if let Err(err) = playback_tx
|
|
||||||
.send_async(PlaybackMessage::ToggleShuffle { span })
|
|
||||||
.in_current_span()
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
error!("Failed to send request via channel: {}", err);
|
|
||||||
}
|
|
||||||
let reply = ToggleShuffleResponse {};
|
|
||||||
Ok(Response::new(reply))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[instrument(skip(self, _request))]
|
#[instrument(skip(self, _request))]
|
||||||
async fn toggle_repeat(
|
async fn toggle_repeat(
|
||||||
&self,
|
&self,
|
||||||
_request: tonic::Request<ToggleRepeatRequest>,
|
_request: Request<ToggleRepeatRequest>,
|
||||||
) -> std::result::Result<tonic::Response<ToggleRepeatResponse>, tonic::Status> {
|
) -> Result<Response<ToggleRepeatResponse>, Status> {
|
||||||
debug!("Received toggle_repeat request");
|
debug!("received toggle_repeat request");
|
||||||
let playback_tx = self.playback_tx.clone();
|
self.send_playback(PlaybackCommand::ToggleRepeat).await?;
|
||||||
let span = debug_span!("play-chan");
|
Ok(Response::new(ToggleRepeatResponse {}))
|
||||||
if let Err(err) = playback_tx
|
|
||||||
.send_async(PlaybackMessage::ToggleRepeat { span })
|
|
||||||
.in_current_span()
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
error!("Failed to send request via channel: {}", err);
|
|
||||||
}
|
|
||||||
let reply = ToggleRepeatResponse {};
|
|
||||||
Ok(Response::new(reply))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[instrument(skip(self, _request))]
|
#[instrument(skip(self, _request))]
|
||||||
async fn get_update_stream(
|
async fn get_update_stream(
|
||||||
&self,
|
&self,
|
||||||
_request: tonic::Request<GetUpdateStreamRequest>,
|
_request: Request<GetUpdateStreamRequest>,
|
||||||
) -> std::result::Result<tonic::Response<Self::GetUpdateStreamStream>, tonic::Status> {
|
) -> Result<Response<Self::GetUpdateStreamStream>, Status> {
|
||||||
debug!("Received get_update_stream request");
|
debug!("received get_update_stream request, subscribing client");
|
||||||
let update_rx = self.update_tx.subscribe();
|
let update_rx = self.update_tx.subscribe();
|
||||||
let update_stream = tokio_stream::wrappers::BroadcastStream::new(update_rx);
|
let update_stream = tokio_stream::wrappers::BroadcastStream::new(update_rx);
|
||||||
|
|
||||||
let output_stream = update_stream.into_stream().map(|update_result| {
|
let output_stream = update_stream.map(|update_result| {
|
||||||
trace!("Got update: {:?}", update_result);
|
trace!(?update_result, "forwarding update");
|
||||||
match update_result {
|
match update_result {
|
||||||
Ok(update) => Ok(GetUpdateStreamResponse {
|
Ok(update) => Ok(GetUpdateStreamResponse {
|
||||||
update: Some(update),
|
update: Some(update),
|
||||||
}),
|
}),
|
||||||
Err(_) => Err(tonic::Status::new(
|
Err(err) => {
|
||||||
tonic::Code::Unknown,
|
// The client lagged too far behind the broadcast channel.
|
||||||
"Internal channel error",
|
error!("update stream lagged: {err}");
|
||||||
)),
|
Err(Status::data_loss("update stream lagged"))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -314,146 +242,73 @@ impl CrabidyService for RpcService {
|
||||||
#[instrument(skip(self, _request))]
|
#[instrument(skip(self, _request))]
|
||||||
async fn save_queue(
|
async fn save_queue(
|
||||||
&self,
|
&self,
|
||||||
_request: tonic::Request<SaveQueueRequest>,
|
_request: Request<SaveQueueRequest>,
|
||||||
) -> std::result::Result<tonic::Response<SaveQueueResponse>, tonic::Status> {
|
) -> Result<Response<SaveQueueResponse>, Status> {
|
||||||
debug!("Received save_queue request");
|
debug!("received save_queue request (not implemented)");
|
||||||
let reply = SaveQueueResponse {};
|
Ok(Response::new(SaveQueueResponse {}))
|
||||||
Ok(Response::new(reply))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Playback
|
|
||||||
#[instrument(skip(self, _request))]
|
#[instrument(skip(self, _request))]
|
||||||
async fn toggle_play(
|
async fn toggle_play(
|
||||||
&self,
|
&self,
|
||||||
_request: tonic::Request<TogglePlayRequest>,
|
_request: Request<TogglePlayRequest>,
|
||||||
) -> std::result::Result<tonic::Response<TogglePlayResponse>, tonic::Status> {
|
) -> Result<Response<TogglePlayResponse>, Status> {
|
||||||
debug!("Received toggle_play request");
|
debug!("received toggle_play request");
|
||||||
let playback_tx = self.playback_tx.clone();
|
self.send_playback(PlaybackCommand::TogglePlay).await?;
|
||||||
let span = debug_span!("play-chan");
|
Ok(Response::new(TogglePlayResponse {}))
|
||||||
if let Err(err) = playback_tx
|
|
||||||
.send_async(PlaybackMessage::TogglePlay { span })
|
|
||||||
.in_current_span()
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
error!("Failed to send request via channel: {}", err);
|
|
||||||
}
|
|
||||||
let reply = TogglePlayResponse {};
|
|
||||||
Ok(Response::new(reply))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[instrument(skip(self, _request))]
|
#[instrument(skip(self, _request))]
|
||||||
async fn stop(
|
async fn stop(&self, _request: Request<StopRequest>) -> Result<Response<StopResponse>, Status> {
|
||||||
&self,
|
debug!("received stop request");
|
||||||
_request: tonic::Request<StopRequest>,
|
self.send_playback(PlaybackCommand::Stop).await?;
|
||||||
) -> std::result::Result<tonic::Response<StopResponse>, tonic::Status> {
|
Ok(Response::new(StopResponse {}))
|
||||||
debug!("Received stop request");
|
|
||||||
let playback_tx = self.playback_tx.clone();
|
|
||||||
let span = debug_span!("play-chan");
|
|
||||||
if let Err(err) = playback_tx
|
|
||||||
.send_async(PlaybackMessage::Stop { span })
|
|
||||||
.in_current_span()
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
error!("Failed to send request via channel: {}", err);
|
|
||||||
}
|
|
||||||
let reply = StopResponse {};
|
|
||||||
Ok(Response::new(reply))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[instrument(skip(self, request), fields(delta))]
|
#[instrument(skip(self, request), fields(delta))]
|
||||||
async fn change_volume(
|
async fn change_volume(
|
||||||
&self,
|
&self,
|
||||||
request: tonic::Request<ChangeVolumeRequest>,
|
request: Request<ChangeVolumeRequest>,
|
||||||
) -> std::result::Result<tonic::Response<ChangeVolumeResponse>, tonic::Status> {
|
) -> Result<Response<ChangeVolumeResponse>, Status> {
|
||||||
let delta = request.into_inner().delta;
|
let delta = request.into_inner().delta;
|
||||||
Span::current().record("delta", delta);
|
tracing::Span::current().record("delta", delta);
|
||||||
debug!("Received change_volume request");
|
debug!("received change_volume request");
|
||||||
let playback_tx = self.playback_tx.clone();
|
self.send_playback(PlaybackCommand::ChangeVolume { delta })
|
||||||
let span = debug_span!("play-chan");
|
.await?;
|
||||||
if let Err(err) = playback_tx
|
Ok(Response::new(ChangeVolumeResponse {}))
|
||||||
.send_async(PlaybackMessage::ChangeVolume { delta, span })
|
|
||||||
.in_current_span()
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
error!("Failed to send request via channel: {}", err);
|
|
||||||
}
|
|
||||||
let reply = ChangeVolumeResponse {};
|
|
||||||
Ok(Response::new(reply))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[instrument(skip(self, _request))]
|
#[instrument(skip(self, _request))]
|
||||||
async fn toggle_mute(
|
async fn toggle_mute(
|
||||||
&self,
|
&self,
|
||||||
_request: tonic::Request<ToggleMuteRequest>,
|
_request: Request<ToggleMuteRequest>,
|
||||||
) -> std::result::Result<tonic::Response<ToggleMuteResponse>, tonic::Status> {
|
) -> Result<Response<ToggleMuteResponse>, Status> {
|
||||||
debug!("Received toggle_mute request");
|
debug!("received toggle_mute request");
|
||||||
let playback_tx = self.playback_tx.clone();
|
self.send_playback(PlaybackCommand::ToggleMute).await?;
|
||||||
let span = debug_span!("play-chan");
|
Ok(Response::new(ToggleMuteResponse {}))
|
||||||
if let Err(err) = playback_tx
|
|
||||||
.send_async(PlaybackMessage::ToggleMute { span })
|
|
||||||
.in_current_span()
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
error!("Failed to send request via channel: {}", err);
|
|
||||||
}
|
|
||||||
let reply = ToggleMuteResponse {};
|
|
||||||
Ok(Response::new(reply))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[instrument(skip(self, _request))]
|
#[instrument(skip(self, _request))]
|
||||||
async fn next(
|
async fn next(&self, _request: Request<NextRequest>) -> Result<Response<NextResponse>, Status> {
|
||||||
&self,
|
debug!("received next request");
|
||||||
_request: tonic::Request<NextRequest>,
|
self.send_playback(PlaybackCommand::Next).await?;
|
||||||
) -> std::result::Result<tonic::Response<NextResponse>, tonic::Status> {
|
Ok(Response::new(NextResponse {}))
|
||||||
debug!("Received next request");
|
|
||||||
let playback_tx = self.playback_tx.clone();
|
|
||||||
let span = debug_span!("play-chan");
|
|
||||||
if let Err(err) = playback_tx
|
|
||||||
.send_async(PlaybackMessage::Next { span })
|
|
||||||
.in_current_span()
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
error!("Failed to send request via channel: {}", err);
|
|
||||||
}
|
|
||||||
let reply = NextResponse {};
|
|
||||||
Ok(Response::new(reply))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[instrument(skip(self, _request))]
|
#[instrument(skip(self, _request))]
|
||||||
async fn prev(
|
async fn prev(&self, _request: Request<PrevRequest>) -> Result<Response<PrevResponse>, Status> {
|
||||||
&self,
|
debug!("received prev request");
|
||||||
_request: tonic::Request<PrevRequest>,
|
self.send_playback(PlaybackCommand::Prev).await?;
|
||||||
) -> std::result::Result<tonic::Response<PrevResponse>, tonic::Status> {
|
Ok(Response::new(PrevResponse {}))
|
||||||
debug!("Received prev request");
|
|
||||||
let playback_tx = self.playback_tx.clone();
|
|
||||||
let span = debug_span!("play-chan");
|
|
||||||
if let Err(err) = playback_tx
|
|
||||||
.send_async(PlaybackMessage::Prev { span })
|
|
||||||
.in_current_span()
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
error!("Failed to send request via channel: {}", err);
|
|
||||||
}
|
|
||||||
let reply = PrevResponse {};
|
|
||||||
Ok(Response::new(reply))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[instrument(skip(self, _request))]
|
#[instrument(skip(self, _request))]
|
||||||
async fn restart_track(
|
async fn restart_track(
|
||||||
&self,
|
&self,
|
||||||
_request: tonic::Request<RestartTrackRequest>,
|
_request: Request<RestartTrackRequest>,
|
||||||
) -> std::result::Result<tonic::Response<RestartTrackResponse>, tonic::Status> {
|
) -> Result<Response<RestartTrackResponse>, Status> {
|
||||||
debug!("Received restart_track request");
|
debug!("received restart_track request");
|
||||||
let playback_tx = self.playback_tx.clone();
|
self.send_playback(PlaybackCommand::RestartTrack).await?;
|
||||||
let span = debug_span!("play-chan");
|
Ok(Response::new(RestartTrackResponse {}))
|
||||||
if let Err(err) = playback_tx
|
|
||||||
.send_async(PlaybackMessage::RestartTrack { span })
|
|
||||||
.in_current_span()
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
error!("Failed to send request via channel: {}", err);
|
|
||||||
}
|
|
||||||
let reply = RestartTrackResponse {};
|
|
||||||
Ok(Response::new(reply))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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/
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
|
@ -0,0 +1,2 @@
|
||||||
|
[toolchain]
|
||||||
|
channel = "stable"
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -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"] }
|
|
||||||
|
|
@ -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.
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
# stream-download-rs
|
|
||||||
|
|
@ -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();
|
|
||||||
}
|
|
||||||
|
|
@ -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();
|
|
||||||
}
|
|
||||||
|
|
@ -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();
|
|
||||||
}
|
|
||||||
|
|
@ -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");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,25 +1,21 @@
|
||||||
[package]
|
[package]
|
||||||
name = "tidaldy"
|
name = "tidaldy"
|
||||||
version = "0.0.0"
|
version.workspace = true
|
||||||
edition = "2021"
|
edition.workspace = true
|
||||||
|
|
||||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
async-trait = "0.1.68"
|
async-trait.workspace = true
|
||||||
base64 = "0.21.0"
|
base64.workspace = true
|
||||||
chrono = "0.4.24"
|
chrono.workspace = true
|
||||||
confique = "0.2.3"
|
crabidy-core.workspace = true
|
||||||
crabidy-core = { path = "../crabidy-core" }
|
reqwest.workspace = true
|
||||||
reqwest = { version = "0.11.17", features = ["json", "rustls-tls", "trust-dns"], default-features = false }
|
serde.workspace = true
|
||||||
secrecy = { version = "0.8.0", features = ["serde"] }
|
serde_json.workspace = true
|
||||||
serde = { version = "1.0.162", features = ["derive"] }
|
serde_urlencoded.workspace = true
|
||||||
serde_json = "1.0.96"
|
thiserror.workspace = true
|
||||||
serde_urlencoded = "0.7.1"
|
tokio = { workspace = true, features = ["time", "sync", "macros"] }
|
||||||
thiserror = "1.0.40"
|
toml.workspace = true
|
||||||
tokio = { version = "1.28.1", features = ["full", "time"] }
|
tracing.workspace = true
|
||||||
toml = "0.7.4"
|
|
||||||
tracing = "0.1.37"
|
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
tokio = { version = "1.28.1", features = ["full"] }
|
tokio = { workspace = true, features = ["full"] }
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ use serde::{Deserialize, Serialize};
|
||||||
use std::iter::zip;
|
use std::iter::zip;
|
||||||
use thiserror::Error;
|
use thiserror::Error;
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct Settings {
|
pub struct Settings {
|
||||||
pub base_url: String,
|
pub base_url: String,
|
||||||
pub hifi_url: String,
|
pub hifi_url: String,
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,9 @@
|
||||||
use std::fmt::format;
|
|
||||||
|
|
||||||
/// Lots of stuff and especially the auth handling is shamelessly copied from
|
/// Lots of stuff and especially the auth handling is shamelessly copied from
|
||||||
/// https://github.com/MinisculeGirraffe/tdl
|
/// https://github.com/MinisculeGirraffe/tdl
|
||||||
use reqwest::Client as HttpClient;
|
use reqwest::Client as HttpClient;
|
||||||
use serde::de::DeserializeOwned;
|
use serde::de::DeserializeOwned;
|
||||||
use tokio::time::{sleep, Duration, Instant};
|
use tokio::time::{sleep, Duration, Instant};
|
||||||
use tracing::{debug, error, info, instrument};
|
use tracing::{debug, error, info, instrument, trace, warn};
|
||||||
pub mod config;
|
pub mod config;
|
||||||
pub mod models;
|
pub mod models;
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
|
|
@ -15,8 +13,14 @@ pub use models::*;
|
||||||
pub struct Client {
|
pub struct Client {
|
||||||
http_client: HttpClient,
|
http_client: HttpClient,
|
||||||
settings: config::Settings,
|
settings: config::Settings,
|
||||||
|
/// Login state changes at runtime when tokens are refreshed, while the
|
||||||
|
/// client is shared immutably, hence the lock. Never held across awaits.
|
||||||
|
login: std::sync::RwLock<config::LoginConfig>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Refresh the access token this long before it actually expires.
|
||||||
|
const TOKEN_REFRESH_MARGIN_SECS: u64 = 300;
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl crabidy_core::ProviderClient for Client {
|
impl crabidy_core::ProviderClient for Client {
|
||||||
#[instrument(skip(raw_toml_settings))]
|
#[instrument(skip(raw_toml_settings))]
|
||||||
|
|
@ -24,79 +28,90 @@ impl crabidy_core::ProviderClient for Client {
|
||||||
let settings: config::Settings = if let Ok(settings) = toml::from_str(raw_toml_settings) {
|
let settings: config::Settings = if let Ok(settings) = toml::from_str(raw_toml_settings) {
|
||||||
settings
|
settings
|
||||||
} else {
|
} else {
|
||||||
let settings = config::Settings::default();
|
warn!("could not parse toml settings, using defaults");
|
||||||
println!(
|
config::Settings::default()
|
||||||
"could not parse toml settings: {:#?} using default settings instead: {:#?}",
|
|
||||||
raw_toml_settings, settings
|
|
||||||
);
|
|
||||||
settings
|
|
||||||
};
|
};
|
||||||
|
|
||||||
let mut client = Self::new(settings)?;
|
let mut client = Self::new(settings)?;
|
||||||
if let Ok(_) = client.login_config().await {
|
if client.login_config().await.is_ok() {
|
||||||
return Ok(client);
|
return Ok(client);
|
||||||
}
|
}
|
||||||
if let Ok(_) = client.login_web().await {
|
if client.login_web().await.is_ok() {
|
||||||
return Ok(client);
|
return Ok(client);
|
||||||
}
|
}
|
||||||
Err(crabidy_core::ProviderError::CouldNotLogin)
|
Err(crabidy_core::ProviderError::CouldNotLogin)
|
||||||
}
|
}
|
||||||
#[instrument(skip(self))]
|
#[instrument(skip(self))]
|
||||||
fn settings(&self) -> String {
|
fn settings(&self) -> String {
|
||||||
toml::to_string_pretty(&self.settings).unwrap_or_default()
|
let mut settings = self.settings.clone();
|
||||||
|
settings.login = self.login_snapshot();
|
||||||
|
toml::to_string_pretty(&settings).unwrap_or_default()
|
||||||
}
|
}
|
||||||
|
fn is_track_path(&self, path: &str) -> bool {
|
||||||
|
matches!(
|
||||||
|
parse_path(path),
|
||||||
|
Ok(TidalPath::PlaylistTrack { .. } | TidalPath::AlbumTrack { .. })
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
#[instrument(skip(self))]
|
#[instrument(skip(self))]
|
||||||
async fn get_urls_for_track(
|
async fn get_urls_for_track(
|
||||||
&self,
|
&self,
|
||||||
track_uuid: &str,
|
track_path: &str,
|
||||||
) -> Result<Vec<String>, crabidy_core::ProviderError> {
|
) -> Result<Vec<String>, crabidy_core::ProviderError> {
|
||||||
debug!("get_urls_for_track {}", track_uuid);
|
let track_id = track_id_from_path(track_path)?;
|
||||||
let (_, track_uuid, _) = split_uuid(track_uuid);
|
let playback = self.get_track_playback(track_id).await.map_err(|err| {
|
||||||
let Ok(playback) = self.get_track_playback(&track_uuid).await else {
|
warn!(track = track_id, "failed to fetch playback info: {err}");
|
||||||
return Err(crabidy_core::ProviderError::FetchError)
|
crabidy_core::ProviderError::FetchError
|
||||||
};
|
})?;
|
||||||
debug!("playback {:?}", playback);
|
trace!(?playback, "got playback info");
|
||||||
let Ok(manifest) = playback.get_manifest() else {
|
let manifest = playback.get_manifest().map_err(|err| {
|
||||||
return Err(crabidy_core::ProviderError::FetchError)
|
warn!(track = track_id, "failed to decode manifest: {err}");
|
||||||
};
|
crabidy_core::ProviderError::FetchError
|
||||||
debug!("manifest {:?}", manifest);
|
})?;
|
||||||
|
debug!(
|
||||||
|
track = track_id,
|
||||||
|
urls = manifest.urls.len(),
|
||||||
|
"resolved stream urls"
|
||||||
|
);
|
||||||
Ok(manifest.urls)
|
Ok(manifest.urls)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[instrument(skip(self))]
|
#[instrument(skip(self))]
|
||||||
async fn get_metadata_for_track(
|
async fn get_metadata_for_track(
|
||||||
&self,
|
&self,
|
||||||
track_uuid: &str,
|
track_path: &str,
|
||||||
) -> Result<crabidy_core::proto::crabidy::Track, crabidy_core::ProviderError> {
|
) -> Result<crabidy_core::proto::crabidy::Track, crabidy_core::ProviderError> {
|
||||||
debug!("get_metadata_for_track {}", track_uuid);
|
let track_id = track_id_from_path(track_path)?;
|
||||||
let Ok(track) = self.get_track(track_uuid).await else {
|
let track = self.get_track(track_id).await.map_err(|err| {
|
||||||
return Err(crabidy_core::ProviderError::FetchError)
|
warn!(track = track_id, "failed to fetch track metadata: {err}");
|
||||||
};
|
crabidy_core::ProviderError::FetchError
|
||||||
Ok(track.into())
|
})?;
|
||||||
|
let parent = crabidy_core::parent_path(track_path)
|
||||||
|
.unwrap_or(PROVIDER_ROOT)
|
||||||
|
.to_string();
|
||||||
|
Ok(track.to_proto(&parent))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[instrument(skip(self))]
|
#[instrument(skip(self))]
|
||||||
fn get_lib_root(&self) -> crabidy_core::proto::crabidy::LibraryNode {
|
fn get_lib_root(&self) -> crabidy_core::proto::crabidy::LibraryNode {
|
||||||
debug!("get_lib_root in tidaldy");
|
crabidy_core::proto::crabidy::LibraryNode {
|
||||||
let global_root = crabidy_core::proto::crabidy::LibraryNode::new();
|
path: PROVIDER_ROOT.to_string(),
|
||||||
let children = vec![
|
title: "tidal".to_string(),
|
||||||
|
parent: Some(crabidy_core::ROOT_PATH.to_string()),
|
||||||
|
tracks: Vec::new(),
|
||||||
|
children: vec![
|
||||||
crabidy_core::proto::crabidy::LibraryNodeChild::new(
|
crabidy_core::proto::crabidy::LibraryNodeChild::new(
|
||||||
"node:userplaylists".to_string(),
|
format!("{PROVIDER_ROOT}/playlists"),
|
||||||
"playlists".to_string(),
|
"playlists".to_string(),
|
||||||
false,
|
false,
|
||||||
),
|
),
|
||||||
crabidy_core::proto::crabidy::LibraryNodeChild::new(
|
crabidy_core::proto::crabidy::LibraryNodeChild::new(
|
||||||
"node:userartists".to_string(),
|
format!("{PROVIDER_ROOT}/artists"),
|
||||||
"artists".to_string(),
|
"artists".to_string(),
|
||||||
false,
|
false,
|
||||||
),
|
),
|
||||||
];
|
],
|
||||||
crabidy_core::proto::crabidy::LibraryNode {
|
|
||||||
uuid: "node:tidal".to_string(),
|
|
||||||
title: "tidal".to_string(),
|
|
||||||
parent: Some(format!("{}", global_root.uuid)),
|
|
||||||
tracks: Vec::new(),
|
|
||||||
children,
|
|
||||||
is_queable: false,
|
is_queable: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -104,20 +119,23 @@ impl crabidy_core::ProviderClient for Client {
|
||||||
#[instrument(skip(self))]
|
#[instrument(skip(self))]
|
||||||
async fn get_lib_node(
|
async fn get_lib_node(
|
||||||
&self,
|
&self,
|
||||||
uuid: &str,
|
path: &str,
|
||||||
) -> Result<crabidy_core::proto::crabidy::LibraryNode, crabidy_core::ProviderError> {
|
) -> Result<crabidy_core::proto::crabidy::LibraryNode, crabidy_core::ProviderError> {
|
||||||
let Some(user_id) = self.settings.login.user_id.clone() else {
|
let Some(user_id) = self.get_user_id() else {
|
||||||
return Err(crabidy_core::ProviderError::UnknownUser)
|
return Err(crabidy_core::ProviderError::UnknownUser);
|
||||||
};
|
};
|
||||||
debug!("get_lib_node in tidaldy{}", uuid);
|
let parsed = parse_path(path)?;
|
||||||
let (_kind, module, uuid) = split_uuid(uuid);
|
debug!(?parsed, "resolving library node");
|
||||||
error!("module:{},uuid: {}", module, uuid);
|
let parent = crabidy_core::parent_path(path)
|
||||||
let node = match module.as_str() {
|
.unwrap_or(crabidy_core::ROOT_PATH)
|
||||||
"userplaylists" => {
|
.to_string();
|
||||||
|
let node = match parsed {
|
||||||
|
TidalPath::Root => self.get_lib_root(),
|
||||||
|
TidalPath::Playlists => {
|
||||||
let mut node = crabidy_core::proto::crabidy::LibraryNode {
|
let mut node = crabidy_core::proto::crabidy::LibraryNode {
|
||||||
uuid: "node:userplaylists".to_string(),
|
path: path.to_string(),
|
||||||
title: "playlists".to_string(),
|
title: "playlists".to_string(),
|
||||||
parent: Some("node:tidal".to_string()),
|
parent: Some(parent),
|
||||||
tracks: Vec::new(),
|
tracks: Vec::new(),
|
||||||
children: Vec::new(),
|
children: Vec::new(),
|
||||||
is_queable: false,
|
is_queable: false,
|
||||||
|
|
@ -126,106 +144,251 @@ impl crabidy_core::ProviderClient for Client {
|
||||||
.get_users_playlists_and_favorite_playlists(&user_id)
|
.get_users_playlists_and_favorite_playlists(&user_id)
|
||||||
.await?
|
.await?
|
||||||
{
|
{
|
||||||
let child = crabidy_core::proto::crabidy::LibraryNodeChild::new(
|
node.children
|
||||||
format!("node:playlist:{}", playlist.playlist.uuid),
|
.push(crabidy_core::proto::crabidy::LibraryNodeChild::new(
|
||||||
|
crabidy_core::join_path(path, &playlist.playlist.uuid),
|
||||||
playlist.playlist.title,
|
playlist.playlist.title,
|
||||||
true,
|
true,
|
||||||
);
|
));
|
||||||
node.children.push(child);
|
|
||||||
}
|
}
|
||||||
node
|
node
|
||||||
}
|
}
|
||||||
"playlist" => {
|
TidalPath::Playlist(playlist_id) => {
|
||||||
let mut node: crabidy_core::proto::crabidy::LibraryNode =
|
let playlist = self.get_playlist(playlist_id).await?;
|
||||||
self.get_playlist(&uuid).await?.into();
|
let tracks = self
|
||||||
let tracks: Vec<crabidy_core::proto::crabidy::Track> = self
|
.get_playlist_tracks(playlist_id)
|
||||||
.get_playlist_tracks(&uuid)
|
|
||||||
.await?
|
.await?
|
||||||
.iter()
|
.iter()
|
||||||
.map(|t| t.into())
|
.map(|t| t.to_proto(path))
|
||||||
.collect();
|
.collect();
|
||||||
node.tracks = tracks;
|
crabidy_core::proto::crabidy::LibraryNode {
|
||||||
node.parent = Some("node:userplaylists".to_string());
|
path: path.to_string(),
|
||||||
node
|
title: playlist.title,
|
||||||
|
parent: Some(parent),
|
||||||
|
tracks,
|
||||||
|
children: Vec::new(),
|
||||||
|
is_queable: true,
|
||||||
}
|
}
|
||||||
"userartists" => {
|
}
|
||||||
|
TidalPath::Artists => {
|
||||||
let mut node = crabidy_core::proto::crabidy::LibraryNode {
|
let mut node = crabidy_core::proto::crabidy::LibraryNode {
|
||||||
uuid: "node:userartists".to_string(),
|
path: path.to_string(),
|
||||||
title: "artists".to_string(),
|
title: "artists".to_string(),
|
||||||
parent: Some("node:tidal".to_string()),
|
parent: Some(parent),
|
||||||
tracks: Vec::new(),
|
tracks: Vec::new(),
|
||||||
children: Vec::new(),
|
children: Vec::new(),
|
||||||
is_queable: false,
|
is_queable: false,
|
||||||
};
|
};
|
||||||
for artist in self.get_users_artists(&user_id).await? {
|
for artist in self.get_users_artists(&user_id).await? {
|
||||||
let child = crabidy_core::proto::crabidy::LibraryNodeChild::new(
|
node.children
|
||||||
format!("node:artist:{}", artist.item.id),
|
.push(crabidy_core::proto::crabidy::LibraryNodeChild::new(
|
||||||
|
crabidy_core::join_path(path, &artist.item.id.to_string()),
|
||||||
artist.item.name,
|
artist.item.name,
|
||||||
true,
|
true,
|
||||||
);
|
));
|
||||||
node.children.push(child);
|
|
||||||
}
|
}
|
||||||
node
|
node
|
||||||
}
|
}
|
||||||
"artist" => {
|
TidalPath::Artist(artist_id) => {
|
||||||
info!("artist");
|
let artist = self.get_artist(artist_id).await?;
|
||||||
let mut node: crabidy_core::proto::crabidy::LibraryNode =
|
let children = self
|
||||||
self.get_artist(&uuid).await?.into();
|
.get_artist_albums(artist_id)
|
||||||
let children: Vec<crabidy_core::proto::crabidy::LibraryNodeChild> = self
|
|
||||||
.get_artist_albums(&uuid)
|
|
||||||
.await?
|
.await?
|
||||||
.iter()
|
.iter()
|
||||||
.map(|t| t.into())
|
.map(|album| {
|
||||||
|
crabidy_core::proto::crabidy::LibraryNodeChild::new(
|
||||||
|
crabidy_core::join_path(path, &album.id.to_string()),
|
||||||
|
album.title.clone(),
|
||||||
|
true,
|
||||||
|
)
|
||||||
|
})
|
||||||
.collect();
|
.collect();
|
||||||
node.children = children;
|
crabidy_core::proto::crabidy::LibraryNode {
|
||||||
node.parent = Some("node:userartists".to_string());
|
path: path.to_string(),
|
||||||
node
|
title: artist.name,
|
||||||
|
parent: Some(parent),
|
||||||
|
tracks: Vec::new(),
|
||||||
|
children,
|
||||||
|
is_queable: true,
|
||||||
}
|
}
|
||||||
"album" => {
|
}
|
||||||
let album = self.get_album(&uuid).await?;
|
TidalPath::Album { album, .. } => {
|
||||||
let artis_id = album.artist.clone().unwrap().id;
|
let album_data = self.get_album(album).await?;
|
||||||
let mut node: crabidy_core::proto::crabidy::LibraryNode = album.into();
|
let tracks = self
|
||||||
let tracks: Vec<crabidy_core::proto::crabidy::Track> = self
|
.get_album_tracks(album)
|
||||||
.get_album_tracks(&uuid)
|
|
||||||
.await?
|
.await?
|
||||||
.iter()
|
.iter()
|
||||||
.map(|t| t.into())
|
.map(|t| t.to_proto(path))
|
||||||
.collect();
|
.collect();
|
||||||
node.tracks = tracks;
|
crabidy_core::proto::crabidy::LibraryNode {
|
||||||
node.parent = Some(format!("node:artist:{}", artis_id));
|
path: path.to_string(),
|
||||||
node
|
title: album_data.title,
|
||||||
|
parent: Some(parent),
|
||||||
|
tracks,
|
||||||
|
children: Vec::new(),
|
||||||
|
is_queable: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
TidalPath::PlaylistTrack { .. } | TidalPath::AlbumTrack { .. } => {
|
||||||
|
warn!(path, "get_lib_node called with a track path");
|
||||||
|
return Err(crabidy_core::ProviderError::MalformedPath);
|
||||||
}
|
}
|
||||||
_ => return Err(crabidy_core::ProviderError::MalformedUuid),
|
|
||||||
};
|
};
|
||||||
Ok(node)
|
Ok(node)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[instrument]
|
/// The root of this provider in the global library tree.
|
||||||
fn split_uuid(uuid: &str) -> (String, String, String) {
|
pub const PROVIDER_ROOT: &str = "/tidal";
|
||||||
let mut split = uuid.splitn(3, ':');
|
|
||||||
(
|
/// A parsed tidal library path. The position in the tree is fully encoded
|
||||||
split.next().unwrap_or("").to_string(),
|
/// in the path itself.
|
||||||
split.next().unwrap_or("").to_string(),
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
split.next().unwrap_or("").to_string(),
|
pub enum TidalPath<'a> {
|
||||||
)
|
Root,
|
||||||
|
Playlists,
|
||||||
|
Playlist(&'a str),
|
||||||
|
PlaylistTrack {
|
||||||
|
playlist: &'a str,
|
||||||
|
track: &'a str,
|
||||||
|
},
|
||||||
|
Artists,
|
||||||
|
Artist(&'a str),
|
||||||
|
Album {
|
||||||
|
artist: &'a str,
|
||||||
|
album: &'a str,
|
||||||
|
},
|
||||||
|
AlbumTrack {
|
||||||
|
artist: &'a str,
|
||||||
|
album: &'a str,
|
||||||
|
track: &'a str,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn parse_path(path: &str) -> Result<TidalPath<'_>, crabidy_core::ProviderError> {
|
||||||
|
let segments = crabidy_core::path_segments(path);
|
||||||
|
match segments.as_slice() {
|
||||||
|
["tidal"] => Ok(TidalPath::Root),
|
||||||
|
["tidal", "playlists"] => Ok(TidalPath::Playlists),
|
||||||
|
["tidal", "playlists", playlist] => Ok(TidalPath::Playlist(playlist)),
|
||||||
|
["tidal", "playlists", playlist, track] => Ok(TidalPath::PlaylistTrack { playlist, track }),
|
||||||
|
["tidal", "artists"] => Ok(TidalPath::Artists),
|
||||||
|
["tidal", "artists", artist] => Ok(TidalPath::Artist(artist)),
|
||||||
|
["tidal", "artists", artist, album] => Ok(TidalPath::Album { artist, album }),
|
||||||
|
["tidal", "artists", artist, album, track] => Ok(TidalPath::AlbumTrack {
|
||||||
|
artist,
|
||||||
|
album,
|
||||||
|
track,
|
||||||
|
}),
|
||||||
|
_ => {
|
||||||
|
warn!(path, "malformed tidal path");
|
||||||
|
Err(crabidy_core::ProviderError::MalformedPath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn track_id_from_path(path: &str) -> Result<&str, crabidy_core::ProviderError> {
|
||||||
|
match parse_path(path)? {
|
||||||
|
TidalPath::PlaylistTrack { track, .. } | TidalPath::AlbumTrack { track, .. } => Ok(track),
|
||||||
|
_ => {
|
||||||
|
warn!(path, "expected a track path");
|
||||||
|
Err(crabidy_core::ProviderError::MalformedPath)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Client {
|
impl Client {
|
||||||
pub fn new(settings: config::Settings) -> Result<Self, ClientError> {
|
pub fn new(settings: config::Settings) -> Result<Self, ClientError> {
|
||||||
let http_client = HttpClient::builder()
|
let http_client = HttpClient::builder()
|
||||||
.user_agent("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36 Edg/91.0.864.59")
|
.user_agent("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36 Edg/91.0.864.59")
|
||||||
|
.timeout(std::time::Duration::from_secs(30))
|
||||||
.build()?;
|
.build()?;
|
||||||
|
|
||||||
|
let login = std::sync::RwLock::new(settings.login.clone());
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
http_client,
|
http_client,
|
||||||
settings,
|
settings,
|
||||||
|
login,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A consistent copy of the current login state.
|
||||||
|
fn login_snapshot(&self) -> config::LoginConfig {
|
||||||
|
match self.login.read() {
|
||||||
|
Ok(login) => login.clone(),
|
||||||
|
Err(poisoned) => poisoned.into_inner().clone(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn store_refresh(&self, refresh: RefreshResponse) {
|
||||||
|
let now = chrono::Utc::now().timestamp() as u64;
|
||||||
|
let mut login = match self.login.write() {
|
||||||
|
Ok(login) => login,
|
||||||
|
Err(poisoned) => poisoned.into_inner(),
|
||||||
|
};
|
||||||
|
login.expires_after = Some(now + refresh.expires_in);
|
||||||
|
login.access_token = Some(refresh.access_token);
|
||||||
|
if let Some(refresh_token) = refresh.refresh_token {
|
||||||
|
login.refresh_token = Some(refresh_token);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Refreshes the access token unconditionally and stores the result.
|
||||||
|
async fn force_refresh_token(&self) -> Result<(), ClientError> {
|
||||||
|
let refresh = self.refresh_access_token().await?;
|
||||||
|
self.store_refresh(refresh);
|
||||||
|
info!("access token refreshed");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Refreshes the access token if it is expired or about to expire.
|
||||||
|
/// Without this, long-running sessions ended up with an expired token
|
||||||
|
/// and every track fetch failed, silently stopping playback.
|
||||||
|
async fn ensure_fresh_token(&self) -> Result<(), ClientError> {
|
||||||
|
let login = self.login_snapshot();
|
||||||
|
let Some(expires_after) = login.expires_after else {
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
let now = chrono::Utc::now().timestamp() as u64;
|
||||||
|
if now + TOKEN_REFRESH_MARGIN_SECS < expires_after {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
info!("access token expired or expiring soon");
|
||||||
|
self.force_refresh_token().await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Performs an authenticated GET against the hifi API.
|
||||||
|
async fn authed_get(
|
||||||
|
&self,
|
||||||
|
uri: &str,
|
||||||
|
query: Option<&[(&str, String)]>,
|
||||||
|
) -> Result<reqwest::Response, ClientError> {
|
||||||
|
let login = self.login_snapshot();
|
||||||
|
let Some(access_token) = login.access_token else {
|
||||||
|
return Err(ClientError::AuthError("No access token found".to_string()));
|
||||||
|
};
|
||||||
|
let Some(country_code) = login.country_code else {
|
||||||
|
return Err(ClientError::AuthError("No country code found".to_string()));
|
||||||
|
};
|
||||||
|
let mut params: Vec<(&str, String)> = vec![("countryCode", country_code)];
|
||||||
|
if let Some(query) = query {
|
||||||
|
params.extend(query.iter().cloned());
|
||||||
|
}
|
||||||
|
self.http_client
|
||||||
|
.get(format!("{}/{}", self.settings.hifi_url, uri))
|
||||||
|
.bearer_auth(access_token)
|
||||||
|
.query(¶ms)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| {
|
||||||
|
warn!(uri, "tidal api request failed: {e}");
|
||||||
|
ClientError::from(e)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
#[instrument(skip(self))]
|
#[instrument(skip(self))]
|
||||||
pub fn get_user_id(&self) -> Option<String> {
|
pub fn get_user_id(&self) -> Option<String> {
|
||||||
self.settings.login.user_id.clone()
|
self.login_snapshot().user_id
|
||||||
}
|
}
|
||||||
|
|
||||||
#[instrument(skip(self))]
|
#[instrument(skip(self))]
|
||||||
|
|
@ -234,41 +397,24 @@ impl Client {
|
||||||
uri: &str,
|
uri: &str,
|
||||||
query: Option<&[(&str, String)]>,
|
query: Option<&[(&str, String)]>,
|
||||||
) -> Result<T, ClientError> {
|
) -> Result<T, ClientError> {
|
||||||
debug!("make_request {}", uri);
|
trace!(uri, "make_request");
|
||||||
let Some(ref access_token) = self.settings.login.access_token.clone() else {
|
self.ensure_fresh_token().await?;
|
||||||
return Err(ClientError::AuthError(
|
let mut response = self.authed_get(uri, query).await?;
|
||||||
"No access token found".to_string(),
|
if response.status() == reqwest::StatusCode::UNAUTHORIZED {
|
||||||
))
|
// The token may have been revoked or the clock may be off:
|
||||||
};
|
// refresh once and retry (GETs are idempotent).
|
||||||
let Some(country_code) = self.settings.login.country_code.clone() else {
|
info!(uri, "got 401, refreshing access token and retrying once");
|
||||||
return Err(ClientError::AuthError(
|
self.force_refresh_token().await?;
|
||||||
"No country code found".to_string(),
|
response = self.authed_get(uri, query).await?;
|
||||||
))
|
|
||||||
};
|
|
||||||
let country_param = ("countryCode", country_code);
|
|
||||||
let mut params: Vec<&(&str, String)> = vec![&country_param];
|
|
||||||
if let Some(query) = query {
|
|
||||||
params.extend(query);
|
|
||||||
}
|
}
|
||||||
|
if !response.status().is_success() {
|
||||||
let response: T = self
|
warn!(uri, status = %response.status(), "tidal api request failed");
|
||||||
.http_client
|
return Err(ClientError::ApiError(response.status().as_u16()));
|
||||||
.get(format!("{}/{}", self.settings.hifi_url, uri))
|
}
|
||||||
.bearer_auth(access_token)
|
response.json().await.map_err(|e| {
|
||||||
.query(¶ms)
|
error!(uri, "failed to decode tidal api response: {e}");
|
||||||
.send()
|
ClientError::from(e)
|
||||||
.await
|
})
|
||||||
.map_err(|e| {
|
|
||||||
error!("{:?}", e);
|
|
||||||
e
|
|
||||||
})?
|
|
||||||
.json()
|
|
||||||
.await
|
|
||||||
.map_err(|e| {
|
|
||||||
error!("{:?}", e);
|
|
||||||
e
|
|
||||||
})?;
|
|
||||||
Ok(response)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[instrument(skip(self))]
|
#[instrument(skip(self))]
|
||||||
|
|
@ -277,72 +423,25 @@ impl Client {
|
||||||
uri: &str,
|
uri: &str,
|
||||||
query: Option<&[(&str, String)]>,
|
query: Option<&[(&str, String)]>,
|
||||||
) -> Result<Vec<T>, ClientError> {
|
) -> Result<Vec<T>, ClientError> {
|
||||||
debug!("make_paginated_request {}", uri);
|
trace!(uri, "make_paginated_request");
|
||||||
let Some(ref access_token) = self.settings.login.access_token.clone() else {
|
let limit: usize = 50;
|
||||||
return Err(ClientError::AuthError(
|
let mut offset: usize = 0;
|
||||||
"No access token found".to_string(),
|
let mut items = Vec::new();
|
||||||
))
|
loop {
|
||||||
};
|
let mut params: Vec<(&str, String)> =
|
||||||
let Some(country_code) = self.settings.login.country_code.clone() else {
|
vec![("limit", limit.to_string()), ("offset", offset.to_string())];
|
||||||
return Err(ClientError::AuthError(
|
|
||||||
"No country code found".to_string(),
|
|
||||||
))
|
|
||||||
};
|
|
||||||
let country_param = ("countryCode", country_code);
|
|
||||||
let limit = 50;
|
|
||||||
let mut offset = 0;
|
|
||||||
let limit_param = ("limit", limit.to_string());
|
|
||||||
let mut params: Vec<&(&str, String)> = vec![&country_param, &limit_param];
|
|
||||||
if let Some(query) = query {
|
if let Some(query) = query {
|
||||||
params.extend(query);
|
params.extend(query.iter().cloned());
|
||||||
}
|
}
|
||||||
|
let page: Page<T> = self.make_request(uri, Some(¶ms)).await?;
|
||||||
let mut response: Page<T> = self
|
let fetched = page.items.len();
|
||||||
.http_client
|
items.extend(page.items);
|
||||||
.get(format!("{}/{}", self.settings.hifi_url, uri))
|
offset += fetched;
|
||||||
.bearer_auth(access_token)
|
if fetched == 0 || offset >= page.total_number_of_items {
|
||||||
.query(¶ms)
|
break;
|
||||||
.send()
|
|
||||||
.await
|
|
||||||
.map_err(|e| {
|
|
||||||
error!("{:?}", e);
|
|
||||||
e
|
|
||||||
})?
|
|
||||||
.json()
|
|
||||||
.await
|
|
||||||
.map_err(|e| {
|
|
||||||
error!("{:?}", e);
|
|
||||||
e
|
|
||||||
})?;
|
|
||||||
let mut items = Vec::with_capacity(response.total_number_of_items);
|
|
||||||
items.extend(response.items);
|
|
||||||
while response.offset + limit < response.total_number_of_items {
|
|
||||||
offset += limit;
|
|
||||||
let offset_param = ("offset", offset.to_string());
|
|
||||||
let mut params: Vec<&(&str, String)> =
|
|
||||||
vec![&country_param, &limit_param, &offset_param];
|
|
||||||
if let Some(query) = query {
|
|
||||||
params.extend(query);
|
|
||||||
}
|
}
|
||||||
response = self
|
|
||||||
.http_client
|
|
||||||
.get(format!("{}/{}", self.settings.hifi_url, uri))
|
|
||||||
.bearer_auth(access_token)
|
|
||||||
.query(¶ms)
|
|
||||||
.send()
|
|
||||||
.await
|
|
||||||
.map_err(|e| {
|
|
||||||
error!("{:?}", e);
|
|
||||||
e
|
|
||||||
})?
|
|
||||||
.json()
|
|
||||||
.await
|
|
||||||
.map_err(|e| {
|
|
||||||
error!("{:?}", e);
|
|
||||||
e
|
|
||||||
})?;
|
|
||||||
items.extend(response.items);
|
|
||||||
}
|
}
|
||||||
|
debug!(uri, count = items.len(), "fetched paginated collection");
|
||||||
Ok(items)
|
Ok(items)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -352,47 +451,16 @@ impl Client {
|
||||||
uri: &str,
|
uri: &str,
|
||||||
query: Option<&[(&str, String)]>,
|
query: Option<&[(&str, String)]>,
|
||||||
) -> Result<(), ClientError> {
|
) -> Result<(), ClientError> {
|
||||||
let Some(ref access_token) = self.settings.login.access_token.clone() else {
|
self.ensure_fresh_token().await?;
|
||||||
return Err(ClientError::AuthError(
|
let response = self.authed_get(uri, query).await?.text().await?;
|
||||||
"No access token found".to_string(),
|
debug!(?response, "explorer response");
|
||||||
))
|
|
||||||
};
|
|
||||||
let Some(country_code) = self.settings.login.country_code.clone() else {
|
|
||||||
return Err(ClientError::AuthError(
|
|
||||||
"No country code found".to_string(),
|
|
||||||
))
|
|
||||||
};
|
|
||||||
let country_param = ("countryCode", country_code);
|
|
||||||
let mut params: Vec<&(&str, String)> = vec![&country_param];
|
|
||||||
if let Some(query) = query {
|
|
||||||
params.extend(query);
|
|
||||||
}
|
|
||||||
|
|
||||||
let response = self
|
|
||||||
.http_client
|
|
||||||
.get(format!("{}/{}", self.settings.hifi_url, uri))
|
|
||||||
.bearer_auth(access_token)
|
|
||||||
.query(¶ms)
|
|
||||||
.send()
|
|
||||||
.await
|
|
||||||
.map_err(|e| {
|
|
||||||
error!("{:?}", e);
|
|
||||||
e
|
|
||||||
})?
|
|
||||||
.text()
|
|
||||||
.await
|
|
||||||
.map_err(|e| {
|
|
||||||
error!("{:?}", e);
|
|
||||||
e
|
|
||||||
})?;
|
|
||||||
println!("{:?}", response);
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
#[instrument(skip(self))]
|
#[instrument(skip(self))]
|
||||||
pub async fn search(&self, query: &str) -> Result<(), ClientError> {
|
pub async fn search(&self, query: &str) -> Result<(), ClientError> {
|
||||||
let query = vec![("query", query.to_string())];
|
let query = vec![("query", query.to_string())];
|
||||||
self.make_explorer_request(&format!("search/artists"), Some(&query))
|
self.make_explorer_request("search/artists", Some(&query))
|
||||||
.await?;
|
.await?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
@ -402,37 +470,32 @@ impl Client {
|
||||||
&self,
|
&self,
|
||||||
playlist_uuid: &str,
|
playlist_uuid: &str,
|
||||||
) -> Result<Vec<Track>, ClientError> {
|
) -> Result<Vec<Track>, ClientError> {
|
||||||
Ok(self
|
self.make_paginated_request(&format!("playlists/{}/tracks", playlist_uuid), None)
|
||||||
.make_paginated_request(&format!("playlists/{}/tracks", playlist_uuid), None)
|
.await
|
||||||
.await?)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[instrument(skip(self))]
|
#[instrument(skip(self))]
|
||||||
pub async fn get_playlist(&self, playlist_uuid: &str) -> Result<Playlist, ClientError> {
|
pub async fn get_playlist(&self, playlist_uuid: &str) -> Result<Playlist, ClientError> {
|
||||||
Ok(self
|
self.make_request(&format!("playlists/{}", playlist_uuid), None)
|
||||||
.make_request(&format!("playlists/{}", playlist_uuid), None)
|
.await
|
||||||
.await?)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[instrument(skip(self))]
|
#[instrument(skip(self))]
|
||||||
pub async fn get_artist(&self, artist_uuid: &str) -> Result<Artist, ClientError> {
|
pub async fn get_artist(&self, artist_uuid: &str) -> Result<Artist, ClientError> {
|
||||||
Ok(self
|
self.make_request(&format!("artists/{}", artist_uuid), None)
|
||||||
.make_request(&format!("artists/{}", artist_uuid), None)
|
.await
|
||||||
.await?)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[instrument(skip(self))]
|
#[instrument(skip(self))]
|
||||||
pub async fn get_artist_albums(&self, artist_uuid: &str) -> Result<Vec<Album>, ClientError> {
|
pub async fn get_artist_albums(&self, artist_uuid: &str) -> Result<Vec<Album>, ClientError> {
|
||||||
Ok(self
|
self.make_paginated_request(&format!("artists/{}/albums", artist_uuid), None)
|
||||||
.make_paginated_request(&format!("artists/{}/albums", artist_uuid), None)
|
.await
|
||||||
.await?)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[instrument(skip(self))]
|
#[instrument(skip(self))]
|
||||||
pub async fn get_users_playlists(&self, user_id: u64) -> Result<Vec<Playlist>, ClientError> {
|
pub async fn get_users_playlists(&self, user_id: u64) -> Result<Vec<Playlist>, ClientError> {
|
||||||
Ok(self
|
self.make_paginated_request(&format!("users/{}/playlists", user_id), None)
|
||||||
.make_paginated_request(&format!("users/{}/playlists", user_id), None)
|
.await
|
||||||
.await?)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[instrument(skip(self))]
|
#[instrument(skip(self))]
|
||||||
|
|
@ -440,12 +503,11 @@ impl Client {
|
||||||
&self,
|
&self,
|
||||||
user_id: &str,
|
user_id: &str,
|
||||||
) -> Result<Vec<PlaylistAndFavorite>, ClientError> {
|
) -> Result<Vec<PlaylistAndFavorite>, ClientError> {
|
||||||
Ok(self
|
self.make_paginated_request(
|
||||||
.make_paginated_request(
|
|
||||||
&format!("users/{}/playlistsAndFavoritePlaylists", user_id),
|
&format!("users/{}/playlistsAndFavoritePlaylists", user_id),
|
||||||
None,
|
None,
|
||||||
)
|
)
|
||||||
.await?)
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
#[instrument(skip(self))]
|
#[instrument(skip(self))]
|
||||||
|
|
@ -461,13 +523,12 @@ impl Client {
|
||||||
|
|
||||||
#[instrument(skip(self))]
|
#[instrument(skip(self))]
|
||||||
pub async fn get_users_artists(&self, user_id: &str) -> Result<Vec<ArtistItem>, ClientError> {
|
pub async fn get_users_artists(&self, user_id: &str) -> Result<Vec<ArtistItem>, ClientError> {
|
||||||
Ok(self
|
self.make_paginated_request(
|
||||||
.make_paginated_request(
|
|
||||||
&format!("users/{}/favorites/artists", user_id),
|
&format!("users/{}/favorites/artists", user_id),
|
||||||
None,
|
None,
|
||||||
// Some(&query),
|
// Some(&query),
|
||||||
)
|
)
|
||||||
.await?)
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
#[instrument(skip(self))]
|
#[instrument(skip(self))]
|
||||||
|
|
@ -509,7 +570,6 @@ impl Client {
|
||||||
|
|
||||||
#[instrument(skip(self))]
|
#[instrument(skip(self))]
|
||||||
pub async fn get_track(&self, track_id: &str) -> Result<Track, ClientError> {
|
pub async fn get_track(&self, track_id: &str) -> Result<Track, ClientError> {
|
||||||
let (_, track_id, _) = split_uuid(track_id);
|
|
||||||
self.make_request(&format!("tracks/{}", track_id), None)
|
self.make_request(&format!("tracks/{}", track_id), None)
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
@ -518,7 +578,13 @@ impl Client {
|
||||||
pub async fn login_web(&mut self) -> Result<(), ClientError> {
|
pub async fn login_web(&mut self) -> Result<(), ClientError> {
|
||||||
let code_response = self.get_device_code().await?;
|
let code_response = self.get_device_code().await?;
|
||||||
let now = Instant::now();
|
let now = Instant::now();
|
||||||
|
// The verification link must reach the user even without a log
|
||||||
|
// subscriber configured.
|
||||||
println!("https://{}", code_response.verification_uri_complete);
|
println!("https://{}", code_response.verification_uri_complete);
|
||||||
|
info!(
|
||||||
|
"waiting for device login at https://{}",
|
||||||
|
code_response.verification_uri_complete
|
||||||
|
);
|
||||||
while now.elapsed().as_secs() <= code_response.expires_in {
|
while now.elapsed().as_secs() <= code_response.expires_in {
|
||||||
let login = self.check_auth_status(&code_response.device_code).await;
|
let login = self.check_auth_status(&code_response.device_code).await;
|
||||||
if login.is_err() {
|
if login.is_err() {
|
||||||
|
|
@ -528,26 +594,32 @@ impl Client {
|
||||||
let timestamp = chrono::Utc::now().timestamp() as u64;
|
let timestamp = chrono::Utc::now().timestamp() as u64;
|
||||||
|
|
||||||
let login_results = login?;
|
let login_results = login?;
|
||||||
self.settings.login.device_code = Some(code_response.device_code);
|
{
|
||||||
self.settings.login.access_token = Some(login_results.access_token);
|
let mut login = match self.login.write() {
|
||||||
self.settings.login.refresh_token = login_results.refresh_token;
|
Ok(login) => login,
|
||||||
self.settings.login.expires_after = Some(login_results.expires_in + timestamp);
|
Err(poisoned) => poisoned.into_inner(),
|
||||||
self.settings.login.user_id = Some(login_results.user.user_id.to_string());
|
};
|
||||||
self.settings.login.country_code = Some(login_results.user.country_code);
|
login.device_code = Some(code_response.device_code);
|
||||||
|
login.access_token = Some(login_results.access_token);
|
||||||
|
login.refresh_token = login_results.refresh_token;
|
||||||
|
login.expires_after = Some(login_results.expires_in + timestamp);
|
||||||
|
login.user_id = Some(login_results.user.user_id.to_string());
|
||||||
|
login.country_code = Some(login_results.user.country_code);
|
||||||
|
}
|
||||||
|
info!("device login succeeded");
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
println!("login attempt expired");
|
warn!("device login attempt expired");
|
||||||
Err(ClientError::ConnectionError)
|
Err(ClientError::ConnectionError)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[instrument(skip(self))]
|
#[instrument(skip(self))]
|
||||||
pub async fn login_config(&mut self) -> Result<(), ClientError> {
|
pub async fn login_config(&mut self) -> Result<(), ClientError> {
|
||||||
let Some(access_token) = self.settings.login.access_token.clone() else {
|
let login = self.login_snapshot();
|
||||||
return Err(ClientError::AuthError(
|
let Some(access_token) = login.access_token else {
|
||||||
"No access token found".to_string(),
|
return Err(ClientError::AuthError("No access token found".to_string()));
|
||||||
))
|
|
||||||
};
|
};
|
||||||
//return if our session is still valid
|
// Return if our session is still valid.
|
||||||
if self
|
if self
|
||||||
.http_client
|
.http_client
|
||||||
.get(format!("{}/sessions", self.settings.base_url))
|
.get(format!("{}/sessions", self.settings.base_url))
|
||||||
|
|
@ -555,30 +627,24 @@ impl Client {
|
||||||
.send()
|
.send()
|
||||||
.await
|
.await
|
||||||
.map_err(|e| {
|
.map_err(|e| {
|
||||||
error!("{:?}", e);
|
warn!("session check failed: {e}");
|
||||||
e
|
e
|
||||||
})?
|
})?
|
||||||
.status()
|
.status()
|
||||||
.is_success()
|
.is_success()
|
||||||
{
|
{
|
||||||
|
debug!("existing session still valid");
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
//otherwise refresh our token
|
// Otherwise refresh our token.
|
||||||
let refresh = self.refresh_access_token().await?;
|
self.force_refresh_token().await
|
||||||
let now = chrono::Utc::now().timestamp() as u64;
|
|
||||||
|
|
||||||
self.settings.login.expires_after = Some(refresh.expires_in + now);
|
|
||||||
self.settings.login.access_token = Some(refresh.access_token);
|
|
||||||
Ok(())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[instrument(skip(self))]
|
#[instrument(skip(self))]
|
||||||
pub async fn refresh_access_token(&self) -> Result<RefreshResponse, ClientError> {
|
pub async fn refresh_access_token(&self) -> Result<RefreshResponse, ClientError> {
|
||||||
let Some(refresh_token) = self.settings.login.refresh_token.clone() else {
|
let Some(refresh_token) = self.login_snapshot().refresh_token else {
|
||||||
return Err(ClientError::AuthError(
|
return Err(ClientError::AuthError("No refresh token found".to_string()));
|
||||||
"No refresh token found".to_string(),
|
|
||||||
))
|
|
||||||
};
|
};
|
||||||
let data = DeviceAuthRequest {
|
let data = DeviceAuthRequest {
|
||||||
client_id: self.settings.oauth.client_id.clone(),
|
client_id: self.settings.oauth.client_id.clone(),
|
||||||
|
|
@ -625,7 +691,7 @@ impl Client {
|
||||||
.http_client
|
.http_client
|
||||||
.post(format!(
|
.post(format!(
|
||||||
"{}/device_authorization",
|
"{}/device_authorization",
|
||||||
&self.settings.oauth.base_url
|
self.settings.oauth.base_url
|
||||||
))
|
))
|
||||||
.header("Content-Type", "application/x-www-form-urlencoded")
|
.header("Content-Type", "application/x-www-form-urlencoded")
|
||||||
.body(payload)
|
.body(payload)
|
||||||
|
|
@ -701,6 +767,7 @@ mod tests {
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
|
#[ignore = "requires a local tidal config and network access"]
|
||||||
async fn test() {
|
async fn test() {
|
||||||
let client = setup().await;
|
let client = setup().await;
|
||||||
let user = client.settings.login.user_id.clone().unwrap();
|
let user = client.settings.login.user_id.clone().unwrap();
|
||||||
|
|
@ -710,6 +777,5 @@ mod tests {
|
||||||
println!("{:?}", result);
|
println!("{:?}", result);
|
||||||
let result = client.get_album("244167550").await.unwrap();
|
let result = client.get_album("244167550").await.unwrap();
|
||||||
println!("{:?}", result);
|
println!("{:?}", result);
|
||||||
assert!(false);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
use std::{str::FromStr, string::FromUtf8Error};
|
use std::{str::FromStr, string::FromUtf8Error};
|
||||||
|
|
||||||
use crabidy_core::proto::crabidy::{LibraryNode, LibraryNodeChild};
|
use base64::Engine as _;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
use thiserror::Error;
|
use thiserror::Error;
|
||||||
|
|
@ -21,19 +21,6 @@ pub struct ArtistItem {
|
||||||
pub item: Artist,
|
pub item: Artist,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl From<ArtistItem> for LibraryNode {
|
|
||||||
fn from(item: ArtistItem) -> Self {
|
|
||||||
Self {
|
|
||||||
uuid: format!("artist:{}", item.item.id),
|
|
||||||
title: item.item.name,
|
|
||||||
children: Vec::new(),
|
|
||||||
parent: None,
|
|
||||||
tracks: Vec::new(),
|
|
||||||
is_queable: true,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
|
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct Artist {
|
pub struct Artist {
|
||||||
|
|
@ -47,29 +34,6 @@ pub struct Artist {
|
||||||
pub mixes: Option<ArtistMixes>,
|
pub mixes: Option<ArtistMixes>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl From<Artist> for LibraryNode {
|
|
||||||
fn from(artist: Artist) -> Self {
|
|
||||||
Self {
|
|
||||||
uuid: format!("node:artist:{}", artist.id),
|
|
||||||
title: artist.name,
|
|
||||||
children: Vec::new(),
|
|
||||||
parent: None,
|
|
||||||
tracks: Vec::new(),
|
|
||||||
is_queable: true,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl From<Artist> for LibraryNodeChild {
|
|
||||||
fn from(artist: Artist) -> Self {
|
|
||||||
Self {
|
|
||||||
uuid: format!("node:artist:{}", artist.id),
|
|
||||||
title: artist.name,
|
|
||||||
is_queable: true,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
|
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct ArtistRole {
|
pub struct ArtistRole {
|
||||||
|
|
@ -87,6 +51,8 @@ pub enum ClientError {
|
||||||
SerdeUrlError(#[from] serde_urlencoded::ser::Error),
|
SerdeUrlError(#[from] serde_urlencoded::ser::Error),
|
||||||
#[error("authentication failed")]
|
#[error("authentication failed")]
|
||||||
AuthError(String),
|
AuthError(String),
|
||||||
|
#[error("tidal api returned status {0}")]
|
||||||
|
ApiError(u16),
|
||||||
#[error("base64 decoding failed")]
|
#[error("base64 decoding failed")]
|
||||||
Base64DecodeError(#[from] base64::DecodeError),
|
Base64DecodeError(#[from] base64::DecodeError),
|
||||||
#[error("utf8 decoding failed")]
|
#[error("utf8 decoding failed")]
|
||||||
|
|
@ -101,6 +67,7 @@ impl From<ClientError> for crabidy_core::ProviderError {
|
||||||
ClientError::ConnectionError => Self::FetchError,
|
ClientError::ConnectionError => Self::FetchError,
|
||||||
ClientError::HttpClientError(_) => Self::FetchError,
|
ClientError::HttpClientError(_) => Self::FetchError,
|
||||||
ClientError::SerdeUrlError(_) => Self::FetchError,
|
ClientError::SerdeUrlError(_) => Self::FetchError,
|
||||||
|
ClientError::ApiError(_) => Self::FetchError,
|
||||||
_ => Self::Other,
|
_ => Self::Other,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -229,36 +196,24 @@ pub struct Track {
|
||||||
pub album: Option<Album>,
|
pub album: Option<Album>,
|
||||||
pub mixes: Option<TrackMixes>,
|
pub mixes: Option<TrackMixes>,
|
||||||
}
|
}
|
||||||
impl From<Track> for crabidy_core::proto::crabidy::Track {
|
|
||||||
fn from(track: Track) -> Self {
|
|
||||||
Self {
|
|
||||||
uuid: format!("track:{}", track.id),
|
|
||||||
title: track.title,
|
|
||||||
artist: match track.artist {
|
|
||||||
Some(a) => a.name.clone(),
|
|
||||||
None => "".to_string(),
|
|
||||||
},
|
|
||||||
album: track.album.map(|a| a.into()),
|
|
||||||
duration: track.duration.map(|d| d as u32 * 1000),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl From<&Track> for crabidy_core::proto::crabidy::Track {
|
impl Track {
|
||||||
fn from(track: &Track) -> Self {
|
/// Converts to the wire representation, placing the track under the
|
||||||
Self {
|
/// given parent node path.
|
||||||
uuid: format!("track:{}", track.id),
|
pub fn to_proto(&self, parent_path: &str) -> crabidy_core::proto::crabidy::Track {
|
||||||
title: track.title.clone(),
|
crabidy_core::proto::crabidy::Track {
|
||||||
artist: match track.artist.as_ref() {
|
path: crabidy_core::join_path(parent_path, &self.id.to_string()),
|
||||||
Some(a) => a.name.clone(),
|
title: self.title.clone(),
|
||||||
None => "".to_string(),
|
artist: self
|
||||||
},
|
.artist
|
||||||
album: track.album.clone().map(|a| a.into()),
|
.as_ref()
|
||||||
duration: track.duration.map(|d| d as u32 * 1000),
|
.map(|a| a.name.clone())
|
||||||
|
.unwrap_or_default(),
|
||||||
|
album: self.album.clone().map(|a| a.into()),
|
||||||
|
duration: self.duration.map(|d| d as u32 * 1000),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
|
// #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||||
// #[serde(rename_all = "camelCase")]
|
// #[serde(rename_all = "camelCase")]
|
||||||
// pub struct Artist {
|
// pub struct Artist {
|
||||||
|
|
@ -355,39 +310,6 @@ pub struct Album {
|
||||||
pub artists: Option<Vec<Artist>>,
|
pub artists: Option<Vec<Artist>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl From<Album> for crabidy_core::proto::crabidy::LibraryNode {
|
|
||||||
fn from(album: Album) -> Self {
|
|
||||||
Self {
|
|
||||||
uuid: format!("node:album:{}", album.id),
|
|
||||||
title: album.title,
|
|
||||||
children: Vec::new(),
|
|
||||||
parent: None,
|
|
||||||
tracks: Vec::new(),
|
|
||||||
is_queable: true,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl From<Album> for crabidy_core::proto::crabidy::LibraryNodeChild {
|
|
||||||
fn from(album: Album) -> Self {
|
|
||||||
Self {
|
|
||||||
uuid: format!("node:album:{}", album.id),
|
|
||||||
title: album.title,
|
|
||||||
is_queable: true,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl From<&Album> for crabidy_core::proto::crabidy::LibraryNodeChild {
|
|
||||||
fn from(album: &Album) -> Self {
|
|
||||||
Self {
|
|
||||||
uuid: format!("node:album:{}", album.id),
|
|
||||||
title: album.title.clone(),
|
|
||||||
is_queable: true,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl From<Album> for crabidy_core::proto::crabidy::Album {
|
impl From<Album> for crabidy_core::proto::crabidy::Album {
|
||||||
fn from(album: Album) -> Self {
|
fn from(album: Album) -> Self {
|
||||||
Self {
|
Self {
|
||||||
|
|
@ -432,7 +354,7 @@ pub struct PlaybackManifest {
|
||||||
impl FromStr for PlaybackManifest {
|
impl FromStr for PlaybackManifest {
|
||||||
type Err = ClientError;
|
type Err = ClientError;
|
||||||
fn from_str(input: &str) -> Result<PlaybackManifest, Self::Err> {
|
fn from_str(input: &str) -> Result<PlaybackManifest, Self::Err> {
|
||||||
let decode = base64::decode(input)?;
|
let decode = base64::engine::general_purpose::STANDARD.decode(input)?;
|
||||||
let json = String::from_utf8(decode)?;
|
let json = String::from_utf8(decode)?;
|
||||||
let parsed: PlaybackManifest = serde_json::from_str(&json)?;
|
let parsed: PlaybackManifest = serde_json::from_str(&json)?;
|
||||||
Ok(parsed)
|
Ok(parsed)
|
||||||
|
|
@ -477,12 +399,6 @@ pub struct PlaylistAndFavorite {
|
||||||
pub playlist: Playlist,
|
pub playlist: Playlist,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl From<PlaylistAndFavorite> for crabidy_core::proto::crabidy::LibraryNode {
|
|
||||||
fn from(a: PlaylistAndFavorite) -> Self {
|
|
||||||
a.playlist.into()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
|
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct Playlist {
|
pub struct Playlist {
|
||||||
|
|
@ -506,19 +422,6 @@ pub struct Playlist {
|
||||||
pub last_item_added_at: Option<String>,
|
pub last_item_added_at: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl From<Playlist> for crabidy_core::proto::crabidy::LibraryNode {
|
|
||||||
fn from(a: Playlist) -> Self {
|
|
||||||
crabidy_core::proto::crabidy::LibraryNode {
|
|
||||||
title: a.title,
|
|
||||||
uuid: format!("node:playlist:{}", a.uuid),
|
|
||||||
tracks: Vec::new(),
|
|
||||||
parent: None,
|
|
||||||
children: Vec::new(),
|
|
||||||
is_queable: true,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
|
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct Creator {
|
pub struct Creator {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue