Fix intermittent playback stops and harden the queue
Root causes found and fixed: - QueueManager could panic and kill the playback task permanently: is_last_track() underflowed on an empty queue, remove_tracks accepted pos == len (Vec::remove panic) and corrupted positions when removing multiple tracks (indices shifted mid-loop), shuffle_behind indexed out of range on an empty play order, insert_tracks shifted play-order entries by the queue length instead of the inserted count and then assert!()ed on the resulting inconsistency, and clear() left play_order stale. All mutation methods are now guarded, multi-remove works highest-position-first, and an inconsistent play order is rebuilt instead of panicking. Regression tests cover these cases. - The tidal access token was only obtained at startup and never refreshed, so long-running sessions ended with every track fetch failing (playback just stopped at the next track boundary). Login state now lives behind a lock; tokens are refreshed proactively before expiry (5 min margin) and once reactively on a 401, and all API responses are status-checked (new ClientError::ApiError) instead of being fed to the JSON decoder blind. The http client also got a 30s timeout so a hung connection cannot wedge the provider loop. - (from the rodio rewrite, same bug class) end of stream used to be detected by string-comparing an io::Error message; any other decode or network error ended the stream silently without an EndOfStream message, so playback never advanced. EOS is now a guaranteed callback with a generation counter. Plus workspace-wide clippy cleanup (zero warnings), cargo-machete cleanup, and fmt. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
56bc0b0d04
commit
d504ebc85f
|
|
@ -721,7 +721,6 @@ name = "crabidy-core"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"clap",
|
|
||||||
"clap-serde-derive",
|
"clap-serde-derive",
|
||||||
"dirs",
|
"dirs",
|
||||||
"prost",
|
"prost",
|
||||||
|
|
@ -743,7 +742,6 @@ dependencies = [
|
||||||
"dirs",
|
"dirs",
|
||||||
"flume",
|
"flume",
|
||||||
"futures",
|
"futures",
|
||||||
"log",
|
|
||||||
"rand 0.10.2",
|
"rand 0.10.2",
|
||||||
"tidaldy",
|
"tidaldy",
|
||||||
"tokio",
|
"tokio",
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,6 @@ crossterm = "0.29"
|
||||||
dirs = "6"
|
dirs = "6"
|
||||||
flume = "0.12"
|
flume = "0.12"
|
||||||
futures = "0.3"
|
futures = "0.3"
|
||||||
log = "0.4"
|
|
||||||
notify-rust = "4"
|
notify-rust = "4"
|
||||||
prost = "0.14"
|
prost = "0.14"
|
||||||
rand = "0.10"
|
rand = "0.10"
|
||||||
|
|
|
||||||
|
|
@ -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) => {
|
||||||
|
|
|
||||||
|
|
@ -54,14 +54,15 @@ impl Library {
|
||||||
}
|
}
|
||||||
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
|
||||||
|
.tx
|
||||||
.send(MessageFromUi::GetLibraryNode(item.path.clone()));
|
.send(MessageFromUi::GetLibraryNode(item.path.clone()));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -103,7 +104,7 @@ impl Library {
|
||||||
}
|
}
|
||||||
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;
|
||||||
}
|
}
|
||||||
|
|
@ -176,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();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -105,7 +105,7 @@ impl App {
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn render(&mut self, f: &mut Frame) {
|
pub fn render(&mut self, f: &mut Frame) {
|
||||||
let full_screen = f.area();
|
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);
|
||||||
|
|
|
||||||
|
|
@ -68,7 +68,7 @@ impl NowPlaying {
|
||||||
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(&self, f: &mut Frame, area: Rect) {
|
pub fn render(&self, f: &mut Frame, area: Rect) {
|
||||||
|
|
@ -129,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();
|
||||||
|
|
@ -151,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]);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -30,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) {
|
||||||
|
|
@ -46,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) {
|
||||||
|
|
@ -57,8 +57,7 @@ impl Queue {
|
||||||
self.list = queue
|
self.list = queue
|
||||||
.tracks
|
.tracks
|
||||||
.iter()
|
.iter()
|
||||||
.enumerate()
|
.map(|t| UiItem {
|
||||||
.map(|(i, t)| UiItem {
|
|
||||||
path: t.path.clone(),
|
path: t.path.clone(),
|
||||||
title: format!("{} - {}", t.artist, t.title),
|
title: format!("{} - {}", t.artist, t.title),
|
||||||
kind: UiItemKind::Track,
|
kind: UiItemKind::Track,
|
||||||
|
|
|
||||||
|
|
@ -82,7 +82,7 @@ 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>> {
|
||||||
|
|
@ -114,7 +114,7 @@ async fn poll(
|
||||||
match msg {
|
match msg {
|
||||||
MessageFromUi::GetLibraryNode(path) => {
|
MessageFromUi::GetLibraryNode(path) => {
|
||||||
if let Some(node) = rpc_client.get_library_node(&path).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) => {
|
||||||
|
|
@ -211,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::try_from(init_data.play_state).ok() {
|
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 {
|
||||||
|
|
@ -228,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::try_from(play_state).ok() {
|
if let Ok(ps) = PlayState::try_from(play_state) {
|
||||||
app.now_playing.update_play_state(ps);
|
app.now_playing.update_play_state(ps);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -241,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())
|
||||||
|
|
@ -256,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();
|
||||||
|
|
@ -351,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));
|
||||||
}
|
}
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,6 @@ edition.workspace = true
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
async-trait.workspace = true
|
async-trait.workspace = true
|
||||||
clap.workspace = true
|
|
||||||
clap-serde-derive.workspace = true
|
clap-serde-derive.workspace = true
|
||||||
dirs.workspace = true
|
dirs.workspace = true
|
||||||
prost.workspace = true
|
prost.workspace = true
|
||||||
|
|
@ -16,3 +15,8 @@ tonic-prost.workspace = true
|
||||||
|
|
||||||
[build-dependencies]
|
[build-dependencies]
|
||||||
tonic-prost-build.workspace = true
|
tonic-prost-build.workspace = true
|
||||||
|
|
||||||
|
# prost and tonic-prost are used by the code generated from the proto files,
|
||||||
|
# which cargo-machete cannot see.
|
||||||
|
[package.metadata.cargo-machete]
|
||||||
|
ignored = ["prost", "tonic-prost"]
|
||||||
|
|
|
||||||
|
|
@ -101,37 +101,6 @@ impl LibraryNodeChild {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[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"]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub enum QueueError {
|
pub enum QueueError {
|
||||||
NotQueable,
|
NotQueable,
|
||||||
}
|
}
|
||||||
|
|
@ -164,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"]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,6 @@ crabidy-core.workspace = true
|
||||||
dirs.workspace = true
|
dirs.workspace = true
|
||||||
flume.workspace = true
|
flume.workspace = true
|
||||||
futures.workspace = true
|
futures.workspace = true
|
||||||
log.workspace = true
|
|
||||||
rand.workspace = true
|
rand.workspace = true
|
||||||
tidaldy.workspace = true
|
tidaldy.workspace = true
|
||||||
tokio = { workspace = true, features = ["full"] }
|
tokio = { workspace = true, features = ["full"] }
|
||||||
|
|
|
||||||
|
|
@ -28,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 {
|
||||||
|
|
@ -48,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) {
|
||||||
|
|
@ -69,11 +75,15 @@ impl QueueManager {
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn shuffle_before(&mut self, pos: usize) {
|
pub fn shuffle_before(&mut self, pos: usize) {
|
||||||
self.play_order[..pos].shuffle(&mut 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 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> {
|
||||||
|
|
@ -114,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
|
||||||
}
|
}
|
||||||
|
|
@ -178,28 +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.play_order.iter().position(|&i| i == *pos as usize) else {
|
let Some(offset) = self.play_order.iter().position(|&i| i == pos) else {
|
||||||
error!("invalid current position");
|
error!(pos, "track position missing from play order, rebuilding");
|
||||||
error!("queue: {:#?}", self);
|
self.rebuild_play_order();
|
||||||
return None;
|
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 {
|
||||||
|
|
@ -212,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;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -262,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::*;
|
||||||
|
|
||||||
|
fn track(id: usize) -> Track {
|
||||||
|
Track {
|
||||||
|
path: format!("/tidal/playlists/p/{id}"),
|
||||||
|
artist: "artist".to_string(),
|
||||||
|
title: format!("track {id}"),
|
||||||
|
duration: None,
|
||||||
|
album: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn queue_with(n: usize) -> QueueManager {
|
||||||
|
let mut q = QueueManager::new();
|
||||||
|
let tracks: Vec<Track> = (0..n).map(track).collect();
|
||||||
|
q.replace_with_tracks(&tracks);
|
||||||
|
q
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn random_delete_before() {}
|
fn empty_queue_operations_do_not_panic() {
|
||||||
|
let mut q = QueueManager::new();
|
||||||
|
assert!(!q.is_last_track());
|
||||||
|
assert!(q.current_track().is_none());
|
||||||
|
assert!(q.next_track().is_none());
|
||||||
|
assert!(q.prev_track().is_none());
|
||||||
|
assert!(q.remove_tracks(&[0]).is_none());
|
||||||
|
q.shuffle_on();
|
||||||
|
q.shuffle_off();
|
||||||
|
q.clear(true);
|
||||||
|
q.clear(false);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn random_delete_track() {}
|
fn remove_out_of_range_is_ignored() {
|
||||||
|
let mut q = queue_with(2);
|
||||||
|
assert!(q.remove_tracks(&[5]).is_none());
|
||||||
|
assert_eq!(q.tracks.len(), 2);
|
||||||
|
// pos == len used to panic via Vec::remove
|
||||||
|
assert!(q.remove_tracks(&[2]).is_none());
|
||||||
|
assert_eq!(q.tracks.len(), 2);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn random_delete_after() {}
|
fn remove_multiple_positions() {
|
||||||
|
let mut q = queue_with(4);
|
||||||
|
q.remove_tracks(&[1, 3]);
|
||||||
|
assert_eq!(q.tracks.len(), 2);
|
||||||
|
assert_eq!(q.play_order.len(), 2);
|
||||||
|
assert_eq!(q.current_track().unwrap().title, "track 0");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn random_select_track() {}
|
fn remove_current_returns_successor() {
|
||||||
|
let mut q = queue_with(3);
|
||||||
|
let next = q.remove_tracks(&[0]);
|
||||||
|
assert_eq!(next.unwrap().title, "track 1");
|
||||||
|
assert_eq!(q.tracks.len(), 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn clear_keeps_play_order_consistent() {
|
||||||
|
let mut q = queue_with(3);
|
||||||
|
q.next_track();
|
||||||
|
q.clear(true);
|
||||||
|
assert_eq!(q.tracks.len(), 1);
|
||||||
|
assert_eq!(q.play_order.len(), 1);
|
||||||
|
assert!(q.current_track().is_some());
|
||||||
|
assert!(q.next_track().is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn next_track_advances_and_repeats() {
|
||||||
|
let mut q = queue_with(2);
|
||||||
|
assert_eq!(q.next_track().unwrap().title, "track 1");
|
||||||
|
assert!(q.next_track().is_none());
|
||||||
|
q.repeat = true;
|
||||||
|
assert_eq!(q.next_track().unwrap().title, "track 0");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn insert_past_end_appends() {
|
||||||
|
let mut q = queue_with(2);
|
||||||
|
q.insert_tracks(99, &[track(2)]);
|
||||||
|
assert_eq!(q.tracks.len(), 3);
|
||||||
|
assert_eq!(q.play_order.len(), 3);
|
||||||
|
assert_eq!(q.tracks.last().unwrap().title, "track 2");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn shuffle_insert_keeps_order_unique() {
|
||||||
|
let mut q = queue_with(5);
|
||||||
|
q.shuffle_on();
|
||||||
|
q.next_track();
|
||||||
|
q.next_track();
|
||||||
|
q.queue_tracks(&[track(5), track(6)]);
|
||||||
|
let mut order = q.play_order.clone();
|
||||||
|
order.sort_unstable();
|
||||||
|
assert_eq!(order, (0..7).collect::<Vec<usize>>());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -13,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))]
|
||||||
|
|
@ -27,17 +33,19 @@ impl crabidy_core::ProviderClient for Client {
|
||||||
};
|
};
|
||||||
|
|
||||||
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 {
|
fn is_track_path(&self, path: &str) -> bool {
|
||||||
matches!(
|
matches!(
|
||||||
|
|
@ -113,7 +121,7 @@ impl crabidy_core::ProviderClient for Client {
|
||||||
&self,
|
&self,
|
||||||
path: &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);
|
||||||
};
|
};
|
||||||
let parsed = parse_path(path)?;
|
let parsed = parse_path(path)?;
|
||||||
|
|
@ -293,17 +301,94 @@ 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))]
|
||||||
|
|
@ -312,37 +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("No access token found".to_string()));
|
let mut response = self.authed_get(uri, query).await?;
|
||||||
};
|
if response.status() == reqwest::StatusCode::UNAUTHORIZED {
|
||||||
let Some(country_code) = self.settings.login.country_code.clone() else {
|
// The token may have been revoked or the clock may be off:
|
||||||
return Err(ClientError::AuthError("No country code found".to_string()));
|
// refresh once and retry (GETs are idempotent).
|
||||||
};
|
info!(uri, "got 401, refreshing access token and retrying once");
|
||||||
let country_param = ("countryCode", country_code);
|
self.force_refresh_token().await?;
|
||||||
let mut params: Vec<&(&str, String)> = vec![&country_param];
|
response = self.authed_get(uri, query).await?;
|
||||||
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))]
|
||||||
|
|
@ -351,68 +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("No access token found".to_string()));
|
let mut offset: usize = 0;
|
||||||
};
|
let mut items = Vec::new();
|
||||||
let Some(country_code) = self.settings.login.country_code.clone() else {
|
loop {
|
||||||
return Err(ClientError::AuthError("No country code found".to_string()));
|
let mut params: Vec<(&str, String)> =
|
||||||
};
|
vec![("limit", limit.to_string()), ("offset", offset.to_string())];
|
||||||
let country_param = ("countryCode", country_code);
|
|
||||||
let limit = 50;
|
|
||||||
let mut offset = 0;
|
|
||||||
let limit_param = ("limit", limit.to_string());
|
|
||||||
let mut params: Vec<&(&str, String)> = vec![&country_param, &limit_param];
|
|
||||||
if let Some(query) = query {
|
|
||||||
params.extend(query);
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut response: Page<T> = self
|
|
||||||
.http_client
|
|
||||||
.get(format!("{}/{}", self.settings.hifi_url, uri))
|
|
||||||
.bearer_auth(access_token)
|
|
||||||
.query(¶ms)
|
|
||||||
.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 {
|
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 fetched = page.items.len();
|
||||||
|
items.extend(page.items);
|
||||||
|
offset += fetched;
|
||||||
|
if fetched == 0 || offset >= page.total_number_of_items {
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
response = self
|
|
||||||
.http_client
|
|
||||||
.get(format!("{}/{}", self.settings.hifi_url, uri))
|
|
||||||
.bearer_auth(access_token)
|
|
||||||
.query(¶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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -422,35 +451,8 @@ 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("No access token found".to_string()));
|
let response = self.authed_get(uri, query).await?.text().await?;
|
||||||
};
|
|
||||||
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
|
|
||||||
})?;
|
|
||||||
debug!(?response, "explorer response");
|
debug!(?response, "explorer response");
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
@ -458,7 +460,7 @@ impl Client {
|
||||||
#[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(())
|
||||||
}
|
}
|
||||||
|
|
@ -468,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))]
|
||||||
|
|
@ -506,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))]
|
||||||
|
|
@ -527,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))]
|
||||||
|
|
@ -599,12 +594,18 @@ 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");
|
info!("device login succeeded");
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
@ -614,10 +615,11 @@ impl Client {
|
||||||
|
|
||||||
#[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();
|
||||||
|
let Some(access_token) = login.access_token else {
|
||||||
return Err(ClientError::AuthError("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))
|
||||||
|
|
@ -625,27 +627,23 @@ 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("No refresh token found".to_string()));
|
return Err(ClientError::AuthError("No refresh token found".to_string()));
|
||||||
};
|
};
|
||||||
let data = DeviceAuthRequest {
|
let data = DeviceAuthRequest {
|
||||||
|
|
@ -693,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)
|
||||||
|
|
@ -779,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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -51,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")]
|
||||||
|
|
@ -65,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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue