use std::fmt;
use std::path::PathBuf;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, AtomicU8, AtomicU64, Ordering};
use uuid::Uuid;
#[derive(Clone, Copy, PartialEq, Eq, Hash)]
pub struct QueueItemId(pub Uuid);
impl QueueItemId {
pub fn new() -> Self {
Self(Uuid::now_v7())
}
}
impl Default for QueueItemId {
fn default() -> Self {
Self::new()
}
}
impl fmt::Debug for QueueItemId {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "QId({})", &self.0.to_string()[..8])
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[repr(u8)]
pub enum PlaybackState {
Stopped = 0,
Playing = 1,
Paused = 2,
}
impl PlaybackState {
pub fn from_u8(v: u8) -> Self {
match v {
1 => Self::Playing,
2 => Self::Paused,
_ => Self::Stopped,
}
}
}
#[derive(Debug, Clone)]
pub struct TrackInfo {
pub id: QueueItemId,
pub path: PathBuf,
pub codec: String,
pub sample_rate: u32,
pub bit_depth: Option<u16>,
pub channels: u16,
pub duration_ms: u64,
}
pub const STREAM_THRESHOLD: u64 = 256 * 1024;
#[derive(Debug, Clone)]
pub enum LoadState {
Pending,
Downloading {
downloaded: u64,
total: u64,
bytes_written: Arc<AtomicU64>,
},
Ready,
Failed(String),
}
pub enum PlaybackSource {
Ready(PathBuf),
Streaming {
path: PathBuf,
bytes_written: Arc<AtomicU64>,
total: u64,
},
}
#[derive(Debug, Clone)]
pub struct PlaylistItem {
pub id: QueueItemId,
pub db_id: Option<i64>,
pub path: PathBuf,
pub title: String,
pub artist: String,
pub album_artist: String,
pub album: String,
pub year: Option<String>,
pub codec: Option<String>,
pub track_number: Option<i64>,
pub disc: Option<i64>,
pub duration_ms: Option<u64>,
pub load_state: LoadState,
}
#[derive(Debug, Clone, Default)]
pub struct Playlist {
pub items: Vec<PlaylistItem>,
pub cursor: Option<QueueItemId>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum QueueEntryStatus {
Queued,
Playing,
Played,
Downloading,
PriorityPending,
Failed,
}
#[derive(Debug, Clone)]
pub struct QueueEntry {
pub id: QueueItemId,
pub db_id: Option<i64>,
pub path: PathBuf,
pub title: String,
pub artist: String,
pub album_artist: String,
pub album: String,
pub year: Option<String>,
pub codec: Option<String>,
pub track_number: Option<i64>,
pub disc: Option<i64>,
pub duration_ms: Option<u64>,
pub status: QueueEntryStatus,
pub download_progress: Option<(u64, u64)>,
}
#[derive(Debug, Clone, Default)]
pub struct VisibleQueueSnapshot {
pub entries: Vec<QueueEntry>,
pub finished_count: usize,
pub has_playing: bool,
pub queue_count: usize,
}
#[derive(Debug)]
pub struct SharedPlayerState {
state: AtomicU8,
position_ms: AtomicU64,
track_info: parking_lot::RwLock<Option<TrackInfo>>,
playlist: parking_lot::RwLock<Playlist>,
playlist_version: AtomicU64,
playback_generation: AtomicU64,
quit_requested: AtomicBool,
metadata_refresh_pending: AtomicBool,
radio_mode: AtomicBool,
}
impl SharedPlayerState {
pub fn new() -> Arc<Self> {
Arc::new(Self {
state: AtomicU8::new(PlaybackState::Stopped as u8),
position_ms: AtomicU64::new(0),
track_info: parking_lot::RwLock::new(None),
playlist: parking_lot::RwLock::new(Playlist::default()),
playlist_version: AtomicU64::new(0),
playback_generation: AtomicU64::new(0),
quit_requested: AtomicBool::new(false),
metadata_refresh_pending: AtomicBool::new(false),
radio_mode: AtomicBool::new(false),
})
}
pub fn playback_state(&self) -> PlaybackState {
PlaybackState::from_u8(self.state.load(Ordering::Acquire))
}
pub fn set_playback_state(&self, state: PlaybackState) {
self.state.store(state as u8, Ordering::Release);
}
pub fn position_ms(&self) -> u64 {
self.position_ms.load(Ordering::Acquire)
}
pub fn set_position_ms(&self, pos: u64) {
self.position_ms.store(pos, Ordering::Release);
}
pub fn track_info(&self) -> Option<TrackInfo> {
self.track_info.read().clone()
}
pub fn set_track_info(&self, info: Option<TrackInfo>) {
*self.track_info.write() = info;
}
pub fn current_download_fraction(&self) -> Option<f64> {
let track_info = self.track_info.read();
let id = track_info.as_ref()?.id;
let pl = self.playlist.read();
pl.items
.iter()
.find(|item| item.id == id)
.and_then(|item| match &item.load_state {
LoadState::Downloading {
bytes_written,
total,
..
} => {
let written = bytes_written.load(Ordering::Acquire);
if *total > 0 {
Some((written as f64 / *total as f64).min(1.0))
} else {
None
}
}
_ => None,
})
}
pub fn bump_generation(&self) -> u64 {
self.playback_generation.fetch_add(1, Ordering::AcqRel) + 1
}
pub fn generation(&self) -> u64 {
self.playback_generation.load(Ordering::Acquire)
}
pub fn request_quit(&self) {
self.quit_requested.store(true, Ordering::Release);
}
pub fn quit_requested(&self) -> bool {
self.quit_requested.load(Ordering::Acquire)
}
pub fn signal_metadata_refresh(&self) {
self.metadata_refresh_pending.store(true, Ordering::Release);
}
pub fn take_metadata_refresh(&self) -> bool {
self.metadata_refresh_pending
.compare_exchange(true, false, Ordering::AcqRel, Ordering::Acquire)
.is_ok()
}
pub fn radio_mode(&self) -> bool {
self.radio_mode.load(Ordering::Acquire)
}
pub fn set_radio_mode(&self, enabled: bool) {
self.radio_mode.store(enabled, Ordering::Release);
}
pub fn playlist_version(&self) -> u64 {
self.playlist_version.load(Ordering::Acquire)
}
fn bump_version(&self) {
self.playlist_version.fetch_add(1, Ordering::AcqRel);
}
pub fn add_items(&self, items: Vec<PlaylistItem>) {
let mut pl = self.playlist.write();
pl.items.extend(items);
drop(pl);
self.bump_version();
}
pub fn insert_items_after(&self, items: Vec<PlaylistItem>, after: QueueItemId) {
let mut pl = self.playlist.write();
let insert_at = match pl.items.iter().position(|item| item.id == after) {
Some(pos) => pos + 1,
None => pl.items.len(), };
for (i, item) in items.into_iter().enumerate() {
pl.items.insert(insert_at + i, item);
}
drop(pl);
self.bump_version();
}
pub fn update_paths(&self, updates: &[(QueueItemId, PathBuf)]) {
let mut pl = self.playlist.write();
for (id, new_path) in updates {
if let Some(item) = pl.items.iter_mut().find(|item| item.id == *id) {
item.path = new_path.clone();
}
}
drop(pl);
self.bump_version();
}
pub fn remove_item(&self, id: QueueItemId) {
let mut pl = self.playlist.write();
pl.items.retain(|item| item.id != id);
if pl.cursor == Some(id) {
pl.cursor = None;
}
drop(pl);
self.bump_version();
}
pub fn move_item(&self, id: QueueItemId, target: QueueItemId, after: bool) {
let mut pl = self.playlist.write();
let Some(from) = pl.items.iter().position(|item| item.id == id) else {
return;
};
let item = pl.items.remove(from);
let Some(to) = pl.items.iter().position(|item| item.id == target) else {
let pos = from.min(pl.items.len());
pl.items.insert(pos, item);
return;
};
let insert_at = if after { to + 1 } else { to };
pl.items.insert(insert_at, item);
drop(pl);
self.bump_version();
}
pub fn move_items(&self, ids: &[QueueItemId], target: QueueItemId, after: bool) {
use std::collections::HashSet;
let id_set: HashSet<QueueItemId> = ids.iter().copied().collect();
let mut pl = self.playlist.write();
let mut remaining = Vec::with_capacity(pl.items.len());
let mut moved = Vec::with_capacity(ids.len());
for item in pl.items.drain(..) {
if id_set.contains(&item.id) {
moved.push(item);
} else {
remaining.push(item);
}
}
let insert_at = match remaining.iter().position(|item| item.id == target) {
Some(pos) => {
if after {
pos + 1
} else {
pos
}
}
None => remaining.len(),
};
for (i, item) in moved.into_iter().enumerate() {
remaining.insert(insert_at + i, item);
}
pl.items = remaining;
drop(pl);
self.bump_version();
}
pub fn set_cursor(&self, id: Option<QueueItemId>) {
let mut pl = self.playlist.write();
pl.cursor = id;
drop(pl);
self.bump_version();
}
pub fn cursor(&self) -> Option<QueueItemId> {
self.playlist.read().cursor
}
pub fn clear_playlist(&self) {
let mut pl = self.playlist.write();
pl.items.clear();
pl.cursor = None;
drop(pl);
self.bump_version();
}
pub fn advance_cursor(&self) -> Option<(QueueItemId, PathBuf)> {
let mut pl = self.playlist.write();
let cursor_pos = match pl.cursor {
Some(cid) => pl.items.iter().position(|item| item.id == cid),
None => None,
};
let start = match cursor_pos {
Some(pos) => pos + 1,
None => 0,
};
for i in start..pl.items.len() {
if matches!(pl.items[i].load_state, LoadState::Ready) {
let item = &pl.items[i];
let result = (item.id, item.path.clone());
pl.cursor = Some(item.id);
drop(pl);
self.bump_version();
return Some(result);
}
}
None
}
pub fn peek_next_ready_after(&self, after_id: QueueItemId) -> Option<(QueueItemId, PathBuf)> {
let pl = self.playlist.read();
let pos = pl.items.iter().position(|item| item.id == after_id);
let start = match pos {
Some(p) => p + 1,
None => 0,
};
for i in start..pl.items.len() {
if matches!(pl.items[i].load_state, LoadState::Ready) {
let item = &pl.items[i];
return Some((item.id, item.path.clone()));
}
}
None
}
pub fn retreat_cursor(&self) -> Option<(QueueItemId, PathBuf)> {
let mut pl = self.playlist.write();
let cursor_pos = match pl.cursor {
Some(cid) => pl.items.iter().position(|item| item.id == cid),
None => None,
};
let prev_pos = cursor_pos.and_then(|p| p.checked_sub(1));
match prev_pos {
Some(pos) => {
let item = &pl.items[pos];
let result = (item.id, item.path.clone());
pl.cursor = Some(item.id);
drop(pl);
self.bump_version();
Some(result)
}
None => None,
}
}
pub fn update_load_state(&self, id: QueueItemId, new_state: LoadState) {
let mut pl = self.playlist.write();
if let Some(item) = pl.items.iter_mut().find(|item| item.id == id) {
item.load_state = new_state;
}
drop(pl);
self.bump_version();
}
pub fn update_item_metadata(
&self,
id: QueueItemId,
title: String,
artist: String,
album_artist: String,
album: String,
duration_ms: Option<u64>,
) {
let mut pl = self.playlist.write();
if let Some(item) = pl.items.iter_mut().find(|item| item.id == id) {
item.title = title;
item.artist = artist;
item.album_artist = album_artist;
item.album = album;
if let Some(dur) = duration_ms {
item.duration_ms = Some(dur);
}
}
drop(pl);
self.bump_version();
}
pub fn item_playback_source(&self, id: QueueItemId) -> Option<PlaybackSource> {
let pl = self.playlist.read();
pl.items
.iter()
.find(|item| item.id == id)
.and_then(|item| match &item.load_state {
LoadState::Ready => Some(PlaybackSource::Ready(item.path.clone())),
LoadState::Downloading {
total,
bytes_written,
..
} => {
let written = bytes_written.load(Ordering::Acquire);
if written >= STREAM_THRESHOLD {
Some(PlaybackSource::Streaming {
path: item.path.clone(),
bytes_written: bytes_written.clone(),
total: *total,
})
} else {
None
}
}
_ => None,
})
}
pub fn item_path_if_ready(&self, id: QueueItemId) -> Option<PathBuf> {
let pl = self.playlist.read();
pl.items.iter().find(|item| item.id == id).and_then(|item| {
if matches!(item.load_state, LoadState::Ready) {
Some(item.path.clone())
} else {
None
}
})
}
pub fn is_cursor(&self, id: QueueItemId) -> bool {
self.playlist.read().cursor == Some(id)
}
pub fn same_album_item_ids(&self, id: QueueItemId) -> Vec<QueueItemId> {
let pl = self.playlist.read();
let Some(cursor) = pl.items.iter().find(|item| item.id == id) else {
return vec![];
};
let album = cursor.album.clone();
let album_artist = cursor.album_artist.clone();
pl.items
.iter()
.filter(|item| {
item.id != id && item.album == album && item.album_artist == album_artist
})
.map(|item| item.id)
.collect()
}
pub fn pending_downloads(&self) -> Vec<(i64, QueueItemId)> {
let pl = self.playlist.read();
pl.items
.iter()
.filter(|item| matches!(item.load_state, LoadState::Pending))
.filter_map(|item| item.db_id.map(|db_id| (db_id, item.id)))
.collect()
}
pub fn item_db_id(&self, id: QueueItemId) -> Option<i64> {
let pl = self.playlist.read();
pl.items
.iter()
.find(|item| item.id == id)
.and_then(|item| item.db_id)
}
pub fn item_load_state(&self, id: QueueItemId) -> Option<LoadState> {
let pl = self.playlist.read();
pl.items
.iter()
.find(|item| item.id == id)
.map(|item| item.load_state.clone())
}
pub fn snapshot_playlist(&self) -> (Vec<PlaylistItem>, Option<QueueItemId>) {
let pl = self.playlist.read();
(pl.items.clone(), pl.cursor)
}
pub fn get_item(&self, id: QueueItemId) -> Option<PlaylistItem> {
let pl = self.playlist.read();
pl.items.iter().find(|item| item.id == id).cloned()
}
pub fn item_before(&self, id: QueueItemId) -> Option<QueueItemId> {
let pl = self.playlist.read();
let pos = pl.items.iter().position(|item| item.id == id)?;
if pos == 0 {
None
} else {
Some(pl.items[pos - 1].id)
}
}
pub fn items_before(&self, ids: &[QueueItemId]) -> Vec<(QueueItemId, Option<QueueItemId>)> {
let pl = self.playlist.read();
ids.iter()
.filter_map(|&id| {
let pos = pl.items.iter().position(|item| item.id == id)?;
let before = if pos == 0 {
None
} else {
Some(pl.items[pos - 1].id)
};
Some((id, before))
})
.collect()
}
pub fn restore_playlist(&self, items: Vec<PlaylistItem>, cursor: Option<QueueItemId>) {
let mut pl = self.playlist.write();
pl.items = items;
pl.cursor = cursor;
drop(pl);
self.bump_version();
}
pub fn remove_items(&self, ids: &[QueueItemId]) {
use std::collections::HashSet;
let id_set: HashSet<QueueItemId> = ids.iter().copied().collect();
let mut pl = self.playlist.write();
pl.items.retain(|item| !id_set.contains(&item.id));
if let Some(cursor) = pl.cursor
&& id_set.contains(&cursor)
{
pl.cursor = None;
}
drop(pl);
self.bump_version();
}
pub fn insert_item_at(&self, item: PlaylistItem, after: Option<QueueItemId>) {
let mut pl = self.playlist.write();
let insert_at = match after {
Some(after_id) => {
match pl.items.iter().position(|i| i.id == after_id) {
Some(pos) => pos + 1,
None => pl.items.len(), }
}
None => 0,
};
pl.items.insert(insert_at, item);
drop(pl);
self.bump_version();
}
pub fn move_item_to(&self, id: QueueItemId, after: Option<QueueItemId>) {
let mut pl = self.playlist.write();
let Some(from) = pl.items.iter().position(|item| item.id == id) else {
return;
};
let item = pl.items.remove(from);
let insert_at = match after {
Some(after_id) => match pl.items.iter().position(|i| i.id == after_id) {
Some(pos) => pos + 1,
None => pl.items.len(),
},
None => 0,
};
pl.items.insert(insert_at, item);
drop(pl);
self.bump_version();
}
pub fn move_items_to(&self, entries: &[(QueueItemId, Option<QueueItemId>)]) {
for &(id, after) in entries {
self.move_item_to(id, after);
}
}
pub fn derive_visible_queue(&self) -> VisibleQueueSnapshot {
let pl = self.playlist.read();
let track_info = self.track_info.read();
let cursor_pos = match pl.cursor {
Some(cid) => pl.items.iter().position(|item| item.id == cid),
None => None,
};
let mut entries = Vec::with_capacity(pl.items.len());
let mut finished_count = 0;
let mut has_playing = false;
let mut queue_count = 0;
for (i, item) in pl.items.iter().enumerate() {
let is_cursor = cursor_pos == Some(i);
let is_before_cursor = cursor_pos.is_some_and(|cp| i < cp);
let dl_progress = match &item.load_state {
LoadState::Downloading {
downloaded, total, ..
} => Some((*downloaded, *total)),
_ => None,
};
let status = if is_cursor {
has_playing = true;
match &item.load_state {
LoadState::Ready => QueueEntryStatus::Playing,
LoadState::Downloading { .. } => QueueEntryStatus::PriorityPending,
LoadState::Pending => QueueEntryStatus::PriorityPending,
LoadState::Failed(_) => QueueEntryStatus::Failed,
}
} else if is_before_cursor {
finished_count += 1;
match &item.load_state {
LoadState::Ready => QueueEntryStatus::Played,
LoadState::Downloading { .. } => QueueEntryStatus::Downloading,
LoadState::Pending => QueueEntryStatus::Downloading,
LoadState::Failed(_) => QueueEntryStatus::Failed,
}
} else {
queue_count += 1;
match &item.load_state {
LoadState::Ready => QueueEntryStatus::Queued,
LoadState::Downloading { .. } => QueueEntryStatus::Downloading,
LoadState::Pending => QueueEntryStatus::Downloading,
LoadState::Failed(_) => QueueEntryStatus::Failed,
}
};
let duration_ms =
if has_playing && status == QueueEntryStatus::Playing && item.duration_ms.is_none()
{
track_info.as_ref().map(|ti| ti.duration_ms)
} else {
item.duration_ms
};
entries.push(QueueEntry {
id: item.id,
db_id: item.db_id,
path: item.path.clone(),
title: item.title.clone(),
artist: item.artist.clone(),
album_artist: item.album_artist.clone(),
album: item.album.clone(),
year: item.year.clone(),
codec: item.codec.clone(),
track_number: item.track_number,
disc: item.disc,
duration_ms,
status,
download_progress: dl_progress,
});
}
VisibleQueueSnapshot {
entries,
finished_count,
has_playing,
queue_count,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn make_item(title: &str, load_state: LoadState) -> PlaylistItem {
PlaylistItem {
id: QueueItemId::new(),
db_id: None,
path: PathBuf::from(format!("/music/{title}.flac")),
title: title.to_string(),
artist: "Artist".to_string(),
album_artist: "Artist".to_string(),
album: "Album".to_string(),
year: None,
codec: Some("FLAC".to_string()),
track_number: None,
disc: None,
duration_ms: Some(200_000),
load_state,
}
}
fn ready_item(title: &str) -> PlaylistItem {
make_item(title, LoadState::Ready)
}
fn pending_item(title: &str) -> PlaylistItem {
make_item(title, LoadState::Pending)
}
#[test]
fn test_advance_cursor_skips_non_ready() {
let state = SharedPlayerState::new();
let item0 = ready_item("track-0");
let item1 = pending_item("track-1");
let item2 = ready_item("track-2");
let id0 = item0.id;
let id2 = item2.id;
state.add_items(vec![item0, item1, item2]);
let result = state.advance_cursor();
assert!(result.is_some(), "expected to find first Ready item");
assert_eq!(result.unwrap().0, id0, "should land on first Ready item");
let result = state.advance_cursor();
assert!(
result.is_some(),
"expected to find next Ready item after skipping Pending"
);
assert_eq!(
result.unwrap().0,
id2,
"should skip Pending and land on third item"
);
}
#[test]
fn test_advance_cursor_stops_at_end_of_playlist() {
let state = SharedPlayerState::new();
let item0 = ready_item("track-0");
let item1 = ready_item("track-1");
let id1 = item1.id;
state.add_items(vec![item0, item1]);
state.set_cursor(Some(id1));
let result = state.advance_cursor();
assert!(
result.is_none(),
"advance past last item should return None"
);
assert_eq!(state.cursor(), Some(id1));
}
#[test]
fn test_advance_cursor_with_no_ready_items_returns_none() {
let state = SharedPlayerState::new();
state.add_items(vec![pending_item("pending-0"), pending_item("pending-1")]);
let result = state.advance_cursor();
assert!(
result.is_none(),
"should return None when no Ready items exist"
);
}
#[test]
fn test_retreat_cursor_goes_to_previous_item() {
let state = SharedPlayerState::new();
let item0 = ready_item("track-0");
let item1 = ready_item("track-1");
let id0 = item0.id;
let id1 = item1.id;
state.add_items(vec![item0, item1]);
state.set_cursor(Some(id1));
let result = state.retreat_cursor();
assert!(result.is_some(), "expected to retreat to previous item");
assert_eq!(result.unwrap().0, id0, "should retreat to first item");
assert_eq!(state.cursor(), Some(id0));
}
#[test]
fn test_retreat_cursor_returns_none_when_at_first_item() {
let state = SharedPlayerState::new();
let item0 = ready_item("only-track");
let id0 = item0.id;
state.add_items(vec![item0]);
state.set_cursor(Some(id0));
let result = state.retreat_cursor();
assert!(result.is_none(), "cannot retreat before the first item");
assert_eq!(state.cursor(), Some(id0));
}
#[test]
fn test_retreat_cursor_returns_none_when_cursor_is_unset() {
let state = SharedPlayerState::new();
state.add_items(vec![ready_item("track-0")]);
let result = state.retreat_cursor();
assert!(
result.is_none(),
"retreat with no cursor should return None"
);
}
#[test]
fn test_derive_visible_queue_statuses() {
let state = SharedPlayerState::new();
let item0 = ready_item("played-track");
let item1 = ready_item("playing-track");
let item2 = ready_item("queued-track");
let id1 = item1.id;
state.add_items(vec![item0, item1, item2]);
state.set_cursor(Some(id1));
let snap = state.derive_visible_queue();
assert_eq!(snap.entries.len(), 3);
assert_eq!(snap.entries[0].status, QueueEntryStatus::Played);
assert_eq!(snap.entries[1].status, QueueEntryStatus::Playing);
assert_eq!(snap.entries[2].status, QueueEntryStatus::Queued);
assert!(snap.has_playing);
assert_eq!(snap.finished_count, 1);
assert_eq!(snap.queue_count, 1);
}
#[test]
fn test_derive_visible_queue_downloading_statuses() {
let state = SharedPlayerState::new();
let bytes_cursor = Arc::new(AtomicU64::new(0));
let bytes_queued = Arc::new(AtomicU64::new(0));
let dl_cursor = make_item(
"downloading-at-cursor",
LoadState::Downloading {
downloaded: 0,
total: 1_000_000,
bytes_written: bytes_cursor.clone(),
},
);
let dl_queued = make_item(
"downloading-queued",
LoadState::Downloading {
downloaded: 0,
total: 500_000,
bytes_written: bytes_queued.clone(),
},
);
let id_cursor = dl_cursor.id;
state.add_items(vec![dl_cursor, dl_queued]);
state.set_cursor(Some(id_cursor));
let snap = state.derive_visible_queue();
assert_eq!(snap.entries[0].status, QueueEntryStatus::PriorityPending);
assert_eq!(snap.entries[1].status, QueueEntryStatus::Downloading);
}
#[test]
fn test_derive_visible_queue_no_cursor_all_queued() {
let state = SharedPlayerState::new();
state.add_items(vec![ready_item("a"), ready_item("b"), ready_item("c")]);
let snap = state.derive_visible_queue();
assert_eq!(snap.entries.len(), 3);
for entry in &snap.entries {
assert_eq!(entry.status, QueueEntryStatus::Queued);
}
assert!(!snap.has_playing);
assert_eq!(snap.finished_count, 0);
assert_eq!(snap.queue_count, 3);
}
fn make_album_item(title: &str, album: &str, album_artist: &str) -> PlaylistItem {
PlaylistItem {
id: QueueItemId::new(),
db_id: None,
path: PathBuf::from(format!("/music/{title}.flac")),
title: title.to_string(),
artist: "Artist".to_string(),
album_artist: album_artist.to_string(),
album: album.to_string(),
year: None,
codec: Some("FLAC".to_string()),
track_number: None,
disc: None,
duration_ms: Some(200_000),
load_state: LoadState::Ready,
}
}
#[test]
fn test_same_album_item_ids_returns_album_mates() {
let state = SharedPlayerState::new();
let a1 = make_album_item("A1", "Album A", "Artist A");
let a2 = make_album_item("A2", "Album A", "Artist A");
let b1 = make_album_item("B1", "Album B", "Artist B");
let a3 = make_album_item("A3", "Album A", "Artist A");
let id_a1 = a1.id;
let id_a2 = a2.id;
let id_a3 = a3.id;
state.add_items(vec![a1, a2, b1, a3]);
let mates = state.same_album_item_ids(id_a1);
assert_eq!(mates.len(), 2);
assert!(mates.contains(&id_a2));
assert!(mates.contains(&id_a3));
}
#[test]
fn test_same_album_item_ids_distinguishes_album_artists() {
let state = SharedPlayerState::new();
let a1 = make_album_item("A1", "Greatest Hits", "Artist A");
let b1 = make_album_item("B1", "Greatest Hits", "Artist B");
let id_a1 = a1.id;
state.add_items(vec![a1, b1]);
let mates = state.same_album_item_ids(id_a1);
assert!(mates.is_empty(), "different album_artist should not match");
}
#[test]
fn test_same_album_item_ids_unknown_id_returns_empty() {
let state = SharedPlayerState::new();
state.add_items(vec![ready_item("track-0")]);
let bogus = QueueItemId::new();
let mates = state.same_album_item_ids(bogus);
assert!(mates.is_empty());
}
#[test]
fn test_move_item_to_reorders_playlist() {
let state = SharedPlayerState::new();
let item_a = ready_item("A");
let item_b = ready_item("B");
let item_c = ready_item("C");
let id_a = item_a.id;
let id_b = item_b.id;
let id_c = item_c.id;
state.add_items(vec![item_a, item_b, item_c]);
state.move_item_to(id_c, Some(id_a));
let (items, _) = state.snapshot_playlist();
let titles: Vec<&str> = items.iter().map(|i| i.title.as_str()).collect();
assert_eq!(titles, vec!["A", "C", "B"]);
assert_eq!(items[0].id, id_a);
assert_eq!(items[1].id, id_c);
assert_eq!(items[2].id, id_b);
}
#[test]
fn test_move_item_to_front_when_after_is_none() {
let state = SharedPlayerState::new();
let item_a = ready_item("A");
let item_b = ready_item("B");
let item_c = ready_item("C");
let id_c = item_c.id;
state.add_items(vec![item_a, item_b, item_c]);
state.move_item_to(id_c, None);
let (items, _) = state.snapshot_playlist();
let titles: Vec<&str> = items.iter().map(|i| i.title.as_str()).collect();
assert_eq!(titles, vec!["C", "A", "B"]);
}
#[test]
fn test_move_items_batch_preserves_relative_order() {
let state = SharedPlayerState::new();
let item_a = ready_item("A");
let item_b = ready_item("B");
let item_c = ready_item("C");
let item_d = ready_item("D");
let id_a = item_a.id;
let id_b = item_b.id;
let id_c = item_c.id;
let id_d = item_d.id;
state.add_items(vec![item_a, item_b, item_c, item_d]);
state.move_items(&[id_a, id_c], id_d, true);
let (items, _) = state.snapshot_playlist();
let titles: Vec<&str> = items.iter().map(|i| i.title.as_str()).collect();
assert_eq!(titles, vec!["B", "D", "A", "C"]);
assert_eq!(items[0].id, id_b);
assert_eq!(items[1].id, id_d);
assert_eq!(items[2].id, id_a);
assert_eq!(items[3].id, id_c);
}
#[test]
fn test_pending_downloads_collects_pending_with_db_id() {
let state = SharedPlayerState::new();
let mut item_a = ready_item("local");
item_a.db_id = None;
let mut item_b = pending_item("remote-1");
item_b.db_id = Some(10);
let id_b = item_b.id;
let mut item_c = ready_item("cached");
item_c.db_id = Some(20);
let mut item_d = pending_item("remote-2");
item_d.db_id = Some(30);
let id_d = item_d.id;
let item_e = pending_item("orphan");
state.add_items(vec![item_a, item_b, item_c, item_d, item_e]);
let pending = state.pending_downloads();
assert_eq!(pending.len(), 2);
assert_eq!(pending[0], (10, id_b));
assert_eq!(pending[1], (30, id_d));
}
#[test]
fn test_item_db_id_and_load_state() {
let state = SharedPlayerState::new();
let mut item = pending_item("track");
item.db_id = Some(42);
let id = item.id;
state.add_items(vec![item]);
assert_eq!(state.item_db_id(id), Some(42));
assert!(matches!(
state.item_load_state(id),
Some(LoadState::Pending)
));
state.update_load_state(id, LoadState::Ready);
assert!(matches!(state.item_load_state(id), Some(LoadState::Ready)));
}
}