crabidy/crabidy-server/src/lib.rs

552 lines
18 KiB
Rust

use crabidy_core::proto::crabidy::{Queue, Track};
use rand::{rng, seq::SliceRandom};
use std::sync::{atomic::AtomicBool, Arc};
use std::time::SystemTime;
use tracing::{debug, error};
/// How a pending queue operation places its resolved chunks.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ResolveKind {
/// First chunk replaces the whole queue, later chunks append.
Replace,
/// Every chunk appends at the end.
Append,
/// Chunks insert after the given position, each advancing the cursor so
/// the resolved collection stays contiguous and in order. `Queue`
/// (play-after-current) is an `InsertAfter` at the current position.
InsertAfter(u32),
}
/// The playback loop's bookkeeping for one in-flight resolve operation.
///
/// Created when a `Replace`/`Queue`/`Append`/`Insert` command arrives,
/// dropped when its forwarder reports completion or a `Replace`/`Clear`
/// cancels it. Chunk application happens exclusively on the playback loop,
/// which keeps the loop the single writer of queue state.
#[derive(Debug)]
pub struct PendingResolve {
kind: ResolveKind,
/// Tracks applied so far; an op finishing at zero is worth a warning.
applied: usize,
/// Shared with the op's forwarder task: set on cancellation so the
/// forwarder drops the chunk receiver, which stops the provider fetch.
cancelled: Arc<AtomicBool>,
}
impl PendingResolve {
pub fn new(kind: ResolveKind) -> Self {
Self {
kind,
applied: 0,
cancelled: Arc::new(AtomicBool::new(false)),
}
}
/// The cancellation flag to hand to this op's forwarder task.
pub fn cancel_flag(&self) -> Arc<AtomicBool> {
Arc::clone(&self.cancelled)
}
/// Marks the op cancelled so its forwarder stops feeding chunks.
pub fn cancel(&self) {
self.cancelled
.store(true, std::sync::atomic::Ordering::Relaxed);
}
/// Total tracks applied by this op so far.
pub fn applied(&self) -> usize {
self.applied
}
/// Applies one resolved chunk to the queue and advances this op's
/// cursor. Returns the track that should start playing, if this chunk
/// made one current (first chunk of a replace, or any chunk landing in
/// an empty queue) — later chunks of the same op never restart playback.
pub fn apply_chunk(&mut self, queue: &mut QueueManager, tracks: &[Track]) -> Option<Track> {
self.applied += tracks.len();
match self.kind {
ResolveKind::Replace => {
// Only the first chunk replaces; the rest of this op
// extends the fresh queue.
self.kind = ResolveKind::Append;
queue.replace_with_tracks(tracks)
}
ResolveKind::Append => queue.append_tracks(tracks),
ResolveKind::InsertAfter(position) => {
// Advance the cursor so this op's next chunk lands right
// behind this one, keeping the collection contiguous.
// `insert_tracks` clamps positions past the end.
self.kind = ResolveKind::InsertAfter(position + tracks.len() as u32);
queue.insert_tracks(position, tracks)
}
}
}
}
#[derive(Clone, Debug)]
pub struct QueueManager {
created_at: SystemTime,
current_offset: usize,
play_order: Vec<usize>,
tracks: Vec<Track>,
pub repeat: bool,
pub shuffle: bool,
}
impl From<QueueManager> for Queue {
fn from(queue_manager: QueueManager) -> Self {
Self {
// A clock step backwards must not panic the playback loop.
timestamp: queue_manager
.created_at
.elapsed()
.unwrap_or_default()
.as_secs(),
current_position: queue_manager.current_position() as u32,
tracks: queue_manager.tracks,
// The manager cannot know about in-flight resolves; the
// playback loop's broadcast path sets this from its pending-op
// map (see `Playback::broadcast_queue`).
resolving: false,
}
}
}
impl Default for QueueManager {
fn default() -> Self {
Self::new()
}
}
impl QueueManager {
pub fn new() -> Self {
Self {
created_at: SystemTime::now(),
current_offset: 0,
play_order: Vec::new(),
tracks: Vec::new(),
repeat: false,
shuffle: false,
}
}
pub fn current_position(&self) -> usize {
if self.current_offset < self.play_order.len() {
self.play_order[self.current_offset]
} else {
0
}
}
pub fn is_last_track(&self) -> bool {
!self.tracks.is_empty() && self.current_position() == self.tracks.len() - 1
}
pub fn shuffle_on(&mut self) {
self.shuffle = true;
self.shuffle_before(self.current_offset);
self.shuffle_behind(self.current_offset);
}
pub fn shuffle_off(&mut self) {
self.shuffle = false;
let pos = self.current_position();
self.current_offset = pos;
self.play_order = (0..self.tracks.len()).collect();
}
pub fn shuffle_all(&mut self) {
self.play_order.shuffle(&mut rng());
}
pub fn shuffle_before(&mut self, pos: usize) {
if let Some(slice) = self.play_order.get_mut(..pos) {
slice.shuffle(&mut rng());
}
}
pub fn shuffle_behind(&mut self, pos: usize) {
if let Some(slice) = self.play_order.get_mut(pos + 1..) {
slice.shuffle(&mut rng());
}
}
pub fn current_track(&self) -> Option<Track> {
if self.current_position() < self.tracks.len() {
Some(self.tracks[self.current_position()].clone())
} else {
None
}
}
pub fn next_track(&mut self) -> Option<Track> {
let len = self.tracks.len();
if len == 0 {
return None;
};
if self.current_offset < len - 1 {
self.current_offset += 1;
let current_pos = self.current_position();
if current_pos < len {
Some(self.tracks[current_pos].clone())
} else {
None
}
} else {
debug!("no more tracks");
if self.repeat {
debug!("repeat");
self.current_offset = 0;
if self.shuffle {
self.shuffle_all();
}
return self.current_track();
}
None
}
}
pub fn prev_track(&mut self) -> Option<Track> {
if 0 < self.current_offset {
self.current_offset -= 1;
self.current_track()
} else {
None
}
}
pub fn set_current_position(&mut self, current_position: u32) -> bool {
if current_position < self.tracks.len() as u32 {
if self.shuffle {
self.shuffle_all();
}
let Some(current_offset) = self
.play_order
.iter()
.position(|&i| i == current_position as usize)
else {
error!("invalid current position");
error!("queue: {:#?}", self);
return false;
};
if self.shuffle {
self.play_order.swap(0, current_offset);
self.current_offset = 0;
} else {
self.current_offset = current_offset;
}
true
} else {
false
}
}
pub fn replace_with_tracks(&mut self, tracks: &[Track]) -> Option<Track> {
self.current_offset = 0;
self.tracks = tracks.to_vec();
self.play_order = (0..self.tracks.len()).collect();
if self.shuffle {
self.shuffle_all();
}
if 0 < self.tracks.len() as u32 {
Some(self.tracks[self.current_position()].clone())
} else {
None
}
}
pub fn append_tracks(&mut self, tracks: &[Track]) -> Option<Track> {
let len = self.tracks.len();
let is_empty = len == 0;
let order_additions: Vec<usize> = (len..len + tracks.len()).collect();
self.play_order.extend(order_additions);
self.tracks.extend(tracks.iter().cloned());
if self.shuffle {
self.shuffle_behind(self.current_offset);
}
if is_empty {
self.current_track()
} else {
None
}
}
pub fn remove_tracks(&mut self, positions: &[u32]) -> Option<Track> {
let mut play_next = false;
// Remove highest positions first so earlier removals don't shift the
// positions that are still to be removed.
let mut positions: Vec<usize> = positions.iter().map(|p| *p as usize).collect();
positions.sort_unstable_by(|a, b| b.cmp(a));
positions.dedup();
for pos in positions {
if pos >= self.tracks.len() {
debug!(pos, len = self.tracks.len(), "ignoring out-of-range remove");
continue;
}
if pos == self.current_position() {
play_next = true;
}
let Some(offset) = self.play_order.iter().position(|&i| i == pos) else {
error!(pos, "track position missing from play order, rebuilding");
self.rebuild_play_order();
return None;
};
if offset < self.current_offset {
self.current_offset -= 1;
}
self.tracks.remove(pos);
self.play_order.remove(offset);
self.play_order
.iter_mut()
.filter(|i| pos < **i)
.for_each(|i| *i -= 1);
}
if self.current_offset >= self.play_order.len() {
self.current_offset = 0;
}
if play_next {
self.current_track()
} else {
None
}
}
pub fn insert_tracks(&mut self, position: u32, tracks: &[Track]) -> Option<Track> {
let len = self.tracks.len();
if len == 0 {
return self.replace_with_tracks(tracks);
}
let inserted = tracks.len();
let position = (position as usize).min(len - 1);
let order_additions: Vec<usize> = (len..len + inserted).collect();
self.play_order.extend(order_additions);
let tail: Vec<Track> = self
.tracks
.splice(position + 1.., tracks.to_vec())
.collect();
self.tracks.extend(tail);
let mut changed: Vec<usize> = Vec::new();
// In shuffle mode we may already have played positions that are
// behind the insertion point; those shift by the number of inserted
// tracks.
for i in self
.play_order
.iter_mut()
.take(self.current_offset)
.filter(|i| position < **i)
{
*i += inserted;
changed.push(*i);
}
// The freshly appended order entries need to swap with the shifted
// ones so every index stays unique.
self.play_order
.iter_mut()
.skip(self.current_offset)
.for_each(|i| {
if changed.contains(i) {
*i -= inserted;
}
});
if self.shuffle {
self.shuffle_behind(self.current_offset);
}
None
}
pub fn queue_tracks(&mut self, tracks: &[Track]) -> Option<Track> {
let pos = self.current_position();
self.insert_tracks(pos as u32, tracks)
}
pub fn clear(&mut self, exclude_current: bool) -> bool {
let current_track = self.current_track();
self.current_offset = 0;
self.tracks.clear();
self.play_order.clear();
if exclude_current {
if let Some(track) = current_track {
self.tracks.push(track);
self.play_order.push(0);
}
}
!exclude_current
}
/// Restores play_order to a consistent state after an inconsistency was
/// detected. Loses shuffle history but keeps the queue playable.
fn rebuild_play_order(&mut self) {
self.play_order = (0..self.tracks.len()).collect();
self.current_offset = 0;
}
}
#[cfg(test)]
mod tests {
use super::*;
fn track(id: usize) -> Track {
Track {
path: format!("/tidal/playlists/p/{id}"),
artist: "artist".to_string(),
title: format!("track {id}"),
duration: None,
album: None,
}
}
fn queue_with(n: usize) -> QueueManager {
let mut q = QueueManager::new();
let tracks: Vec<Track> = (0..n).map(track).collect();
q.replace_with_tracks(&tracks);
q
}
#[test]
fn empty_queue_operations_do_not_panic() {
let mut q = QueueManager::new();
assert!(!q.is_last_track());
assert!(q.current_track().is_none());
assert!(q.next_track().is_none());
assert!(q.prev_track().is_none());
assert!(q.remove_tracks(&[0]).is_none());
q.shuffle_on();
q.shuffle_off();
q.clear(true);
q.clear(false);
}
#[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");
}
fn titles(q: &QueueManager) -> Vec<String> {
q.tracks.iter().map(|t| t.title.clone()).collect()
}
#[test]
fn replace_op_replaces_first_then_appends_and_plays_once() {
let mut q = queue_with(2);
let mut op = PendingResolve::new(ResolveKind::Replace);
let first = op.apply_chunk(&mut q, &[track(10), track(11)]);
// The first chunk resets the queue and names the track to start.
assert_eq!(first.unwrap().title, "track 10");
assert_eq!(titles(&q), vec!["track 10", "track 11"]);
let second = op.apply_chunk(&mut q, &[track(12)]);
// Later chunks extend the same replace without restarting playback.
assert!(second.is_none());
assert_eq!(titles(&q), vec!["track 10", "track 11", "track 12"]);
assert_eq!(op.applied(), 3);
}
#[test]
fn insert_after_op_keeps_chunks_contiguous_and_in_order() {
let mut q = queue_with(3); // playing track 0
let mut op = PendingResolve::new(ResolveKind::InsertAfter(0));
assert!(op.apply_chunk(&mut q, &[track(10), track(11)]).is_none());
assert!(op.apply_chunk(&mut q, &[track(12)]).is_none());
// Both chunks sit as one contiguous run right after the current
// track, in arrival order — not interleaved with the old tail.
assert_eq!(
titles(&q),
vec!["track 0", "track 10", "track 11", "track 12", "track 1", "track 2"]
);
}
#[test]
fn insert_after_op_clamps_past_the_end() {
let mut q = queue_with(1);
let mut op = PendingResolve::new(ResolveKind::InsertAfter(99));
assert!(op.apply_chunk(&mut q, &[track(10)]).is_none());
assert!(op.apply_chunk(&mut q, &[track(11)]).is_none());
assert_eq!(titles(&q), vec!["track 0", "track 10", "track 11"]);
}
#[test]
fn append_op_starts_playback_only_into_an_empty_queue() {
let mut q = QueueManager::new();
let mut op = PendingResolve::new(ResolveKind::Append);
let first = op.apply_chunk(&mut q, &[track(10)]);
// Landing in an empty queue makes the track current: play it.
assert_eq!(first.unwrap().title, "track 10");
assert!(op.apply_chunk(&mut q, &[track(11)]).is_none());
assert_eq!(titles(&q), vec!["track 10", "track 11"]);
assert_eq!(op.applied(), 2);
}
#[test]
fn cancel_flag_is_shared_with_the_forwarder() {
let op = PendingResolve::new(ResolveKind::Append);
let flag = op.cancel_flag();
assert!(!flag.load(std::sync::atomic::Ordering::Relaxed));
op.cancel();
assert!(flag.load(std::sync::atomic::Ordering::Relaxed));
}
#[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>>());
}
}