Skip to main content

koan_core/player/
state.rs

1use std::fmt;
2use std::path::PathBuf;
3use std::sync::Arc;
4use std::sync::atomic::{AtomicBool, AtomicU8, AtomicU64, Ordering};
5
6use uuid::Uuid;
7
8/// Stable identity for a queue entry. UUIDv7 — time-ordered, unique across duplicates.
9#[derive(Clone, Copy, PartialEq, Eq, Hash)]
10pub struct QueueItemId(pub Uuid);
11
12impl QueueItemId {
13    pub fn new() -> Self {
14        Self(Uuid::now_v7())
15    }
16}
17
18impl Default for QueueItemId {
19    fn default() -> Self {
20        Self::new()
21    }
22}
23
24impl fmt::Debug for QueueItemId {
25    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
26        // Short form for logs: first 8 hex chars.
27        write!(f, "QId({})", &self.0.to_string()[..8])
28    }
29}
30
31/// Playback state.
32#[derive(Debug, Clone, Copy, PartialEq, Eq)]
33#[repr(u8)]
34pub enum PlaybackState {
35    Stopped = 0,
36    Playing = 1,
37    Paused = 2,
38}
39
40impl PlaybackState {
41    pub fn from_u8(v: u8) -> Self {
42        match v {
43            1 => Self::Playing,
44            2 => Self::Paused,
45            _ => Self::Stopped,
46        }
47    }
48}
49
50/// Audio format info for the currently playing track.
51#[derive(Debug, Clone)]
52pub struct TrackInfo {
53    pub id: QueueItemId,
54    pub path: PathBuf,
55    pub codec: String,
56    pub sample_rate: u32,
57    pub bit_depth: Option<u16>,
58    pub bitrate_kbps: Option<u32>,
59    pub channels: u16,
60    pub duration_ms: u64,
61}
62
63// --- Playlist data model (single source of truth) ---
64
65/// Minimum bytes written before streaming playback can begin.
66pub const STREAM_THRESHOLD: u64 = 256 * 1024; // 256 KB
67
68/// Held back from the seekable extent of a downloading track.
69///
70/// Bytes are converted to time at the average bitrate, so on VBR the estimate
71/// wanders either side of the truth; landing short of the write head costs a
72/// couple of seconds of reach and landing past it costs a stall.
73pub const SEEK_SAFETY_MS: u64 = 2_000;
74
75/// What a playlist item can say about itself.
76///
77/// Only what is true of the item regardless of any transfer: whether the bytes
78/// at its path can be played. Whether one is *arriving* is the download store's
79/// business, and asking the item would mean two accounts of one fact that have
80/// to be kept in step — which they were not. Read [`LoadState`] for the two
81/// together.
82#[derive(Debug, Clone, Default, PartialEq, Eq)]
83pub enum ItemState {
84    /// Nothing has resolved this yet.
85    #[default]
86    Pending,
87    /// The file at `path` is there and playable.
88    Ready,
89    /// It cannot be made playable, and this is why. Not only download
90    /// failures: a track with no local file and no remote copy fails here
91    /// without a transfer ever being attempted.
92    Failed(String),
93}
94
95/// An item's state, and any transfer against it, as one answer.
96///
97/// Derived rather than stored. `Downloading` carries the store's own figures —
98/// the very same counter the downloader writes — so there is nothing to copy
99/// and nothing that can drift.
100#[derive(Debug, Clone)]
101pub enum LoadState {
102    Pending,
103    Downloading {
104        /// Where the bytes are going: the in-progress `.part` file, not the
105        /// destination it is renamed to at the end.
106        path: PathBuf,
107        /// Total bytes expected, or 0 when the server sent no Content-Length.
108        total: u64,
109        /// How many bytes have landed. The download thread writes it per chunk
110        /// without taking any lock the player holds.
111        bytes_written: Arc<AtomicU64>,
112    },
113    Ready,
114    Failed(String),
115}
116
117impl LoadState {
118    /// An item's state, with whatever the download store says about it.
119    ///
120    /// The store wins while a transfer is live, because it is the thing being
121    /// told. Once one has settled the item's own state stands: a finished
122    /// transfer leaves a file, and a file is what playback cares about.
123    pub fn of(item: &PlaylistItem) -> Self {
124        use crate::remote::downloads::{DownloadState, store};
125
126        if let Some(transfer) = store().get(item.id) {
127            match transfer.state {
128                DownloadState::Queued | DownloadState::Running => {
129                    return Self::Downloading {
130                        path: transfer.source,
131                        total: transfer.total,
132                        bytes_written: transfer.written,
133                    };
134                }
135                // A transfer that failed explains an item that cannot play,
136                // but only while the item has not since been resolved some
137                // other way — a retry, or a copy found on disk.
138                DownloadState::Failed(reason) if item.state == ItemState::Pending => {
139                    return Self::Failed(reason);
140                }
141                DownloadState::Failed(_) | DownloadState::Done => {}
142            }
143        }
144
145        match &item.state {
146            ItemState::Pending => Self::Pending,
147            ItemState::Ready => Self::Ready,
148            ItemState::Failed(reason) => Self::Failed(reason.clone()),
149        }
150    }
151}
152
153/// Resolved playback source for a playlist item.
154pub enum PlaybackSource {
155    /// File fully downloaded — play from path.
156    Ready(PathBuf),
157    /// File being downloaded — enough data buffered to start streaming.
158    Streaming {
159        path: PathBuf,
160        bytes_written: Arc<AtomicU64>,
161        total: u64,
162    },
163}
164
165/// A single item in the playlist. Replaces QueueEntry + QueueEntryMeta + pending entries
166/// as the canonical data. Created once when tracks are added to the playlist.
167#[derive(Debug, Clone)]
168pub struct PlaylistItem {
169    pub id: QueueItemId,
170    /// Database track ID — set for tracks loaded from DB, used for downloads.
171    pub db_id: Option<i64>,
172    /// The playlist entry this came from, when it came from a playlist.
173    ///
174    /// A playlist may hold the same track twice, and two copies are two queue
175    /// items. Without this a playlist row can only ask "is my *track* playing?"
176    /// and both copies answer yes. The entry id is the one thing that tells
177    /// them apart, so the queue carries it.
178    pub playlist_entry_id: Option<i64>,
179    pub path: PathBuf,
180    pub title: String,
181    pub artist: String,
182    pub album_artist: String,
183    pub album: String,
184    pub year: Option<String>,
185    pub codec: Option<String>,
186    pub track_number: Option<i64>,
187    pub disc: Option<i64>,
188    pub duration_ms: Option<u64>,
189    /// What the item can say about itself. Ask [`SharedPlayerState::load_state`]
190    /// for this together with any transfer against it.
191    pub state: ItemState,
192}
193
194/// The playlist — one flat array, one cursor. Everything else derived.
195#[derive(Debug, Clone, Default)]
196pub struct Playlist {
197    pub items: Vec<PlaylistItem>,
198    pub cursor: Option<QueueItemId>,
199}
200
201// --- UI view types (kept for TUI compat) ---
202
203/// Status of a track in the queue — for UI display.
204#[derive(Debug, Clone, Copy, PartialEq, Eq)]
205pub enum QueueEntryStatus {
206    Queued,
207    Playing,
208    Played,
209    Downloading,
210    /// User double-clicked — this track is priority, will play when ready.
211    PriorityPending,
212    Failed,
213}
214
215/// A single entry in the UI-visible queue snapshot.
216#[derive(Debug, Clone)]
217pub struct QueueEntry {
218    pub id: QueueItemId,
219    /// Database track ID — set for tracks loaded from DB, used for downloads.
220    pub db_id: Option<i64>,
221    /// The playlist row this came from — see `PlaylistItem::playlist_entry_id`.
222    pub playlist_entry_id: Option<i64>,
223    pub path: PathBuf,
224    pub title: String,
225    pub artist: String,
226    pub album_artist: String,
227    pub album: String,
228    pub year: Option<String>,
229    pub codec: Option<String>,
230    pub track_number: Option<i64>,
231    pub disc: Option<i64>,
232    pub duration_ms: Option<u64>,
233    pub status: QueueEntryStatus,
234    pub download_progress: Option<(u64, u64)>,
235    /// Why this entry cannot play, when `status` is `Failed`.
236    pub error: Option<String>,
237}
238
239/// Pre-built visible queue — single atomic snapshot for the UI.
240#[derive(Debug, Clone, Default)]
241pub struct VisibleQueueSnapshot {
242    pub entries: Vec<QueueEntry>,
243    pub finished_count: usize,
244    pub has_playing: bool,
245    pub queue_count: usize,
246}
247
248/// Shared player state — atomics for lock-free reads from UI thread.
249///
250/// The engine writes these, the UI reads them. No mutexes in the hot path.
251#[derive(Debug)]
252pub struct SharedPlayerState {
253    state: AtomicU8,
254    position_ms: AtomicU64,
255    track_info: parking_lot::RwLock<Option<TrackInfo>>,
256
257    /// THE playlist + cursor — one lock, one truth.
258    playlist: parking_lot::RwLock<Playlist>,
259
260    /// Bumped on every playlist mutation so UI can skip redundant redraws.
261    playlist_version: AtomicU64,
262
263    /// Set by external signals (e.g. souvlaki Quit event) to request clean shutdown.
264    quit_requested: AtomicBool,
265
266    /// Set when metadata has been refreshed (e.g. download completed while streaming).
267    /// The UI loop checks this to force a souvlaki/cover-art update without a track change.
268    metadata_refresh_pending: AtomicBool,
269
270    /// The rate the output device settled at for the current track, or 0 when
271    /// nothing has played yet. Compared against the source rate, it is the one
272    /// thing koan can say for certain about the path to the DAC: whether it
273    /// handed the device the samples as they are, or something had to resample
274    /// to reach it. Everything past that — other clients, the volume stage — is
275    /// the system's, and not ours to claim.
276    output_sample_rate: AtomicU64,
277
278    /// Radio mode — automatically queue similar tracks when the queue runs low.
279    /// Shared so GQL/MCP can toggle it without going through the TUI.
280    radio_mode: AtomicBool,
281}
282
283impl SharedPlayerState {
284    pub fn new() -> Arc<Self> {
285        Arc::new(Self {
286            state: AtomicU8::new(PlaybackState::Stopped as u8),
287            position_ms: AtomicU64::new(0),
288            track_info: parking_lot::RwLock::new(None),
289            playlist: parking_lot::RwLock::new(Playlist::default()),
290            playlist_version: AtomicU64::new(0),
291            quit_requested: AtomicBool::new(false),
292            metadata_refresh_pending: AtomicBool::new(false),
293            output_sample_rate: AtomicU64::new(0),
294            radio_mode: AtomicBool::new(false),
295        })
296    }
297
298    // --- Playback state ---
299
300    pub fn playback_state(&self) -> PlaybackState {
301        PlaybackState::from_u8(self.state.load(Ordering::Acquire))
302    }
303
304    pub fn set_playback_state(&self, state: PlaybackState) {
305        self.state.store(state as u8, Ordering::Release);
306    }
307
308    pub fn position_ms(&self) -> u64 {
309        self.position_ms.load(Ordering::Acquire)
310    }
311
312    pub fn set_position_ms(&self, pos: u64) {
313        self.position_ms.store(pos, Ordering::Release);
314    }
315
316    pub fn track_info(&self) -> Option<TrackInfo> {
317        self.track_info.read().clone()
318    }
319
320    pub fn set_track_info(&self, info: Option<TrackInfo>) {
321        *self.track_info.write() = info;
322    }
323
324    /// How far into the currently playing track a seek can land.
325    ///
326    /// A track on disk is seekable end to end. One still downloading is
327    /// seekable only as far as its bytes reach: bytes map to time by the
328    /// average bitrate, exact for lossless and CBR and drifting on VBR, which
329    /// is what `SEEK_SAFETY_MS` covers. Zero when nothing is playing.
330    ///
331    /// The one value both the clamp in `Player::seek` and the extent front ends
332    /// draw on the seek bar come from — a bar that shows a reachable position
333    /// the player then refuses is worse than no bar.
334    pub fn seekable_ms(&self) -> u64 {
335        let Some(info) = self.track_info.read().clone() else {
336            return 0;
337        };
338
339        // Released before the playlist lock is taken: derive_visible_queue takes
340        // these two in the opposite order, so holding both would close a cycle.
341        let pl = self.playlist.read();
342        let Some(item) = pl.items.iter().find(|item| item.id == info.id) else {
343            return info.duration_ms;
344        };
345
346        let LoadState::Downloading {
347            total,
348            bytes_written,
349            ..
350        } = LoadState::of(item)
351        else {
352            return info.duration_ms;
353        };
354
355        // A container that could not describe itself from the bytes downloaded
356        // states no duration, and cannot be seeked at all until the rest of it
357        // lands — there is no index to seek against and no end to seek within.
358        // Ogg is the one that does this; it keeps its duration in its last page.
359        if info.duration_ms == 0 {
360            return 0;
361        }
362
363        let written = bytes_written.load(Ordering::Acquire);
364        let reached = if total > 0 && info.duration_ms > 0 {
365            ((written as f64 / total as f64) * info.duration_ms as f64) as u64
366        } else if let Some(kbps) = info.bitrate_kbps.filter(|k| *k > 0) {
367            // No Content-Length. Bytes still say how much audio has arrived,
368            // given what the probe measured the bitrate to be: 1 kbps is
369            // 1 bit per ms, so bits divided by kbps is milliseconds.
370            written.saturating_mul(8) / kbps as u64
371        } else {
372            // Nothing to derive a position from — forward seeking would be a
373            // guess, so allow only what has already been played.
374            return self.position_ms();
375        };
376
377        reached.saturating_sub(SEEK_SAFETY_MS).min(info.duration_ms)
378    }
379
380    /// The duration to show for what is playing.
381    ///
382    /// The container's own answer wherever it gave one. A partial file that
383    /// could not be read far enough to state a duration has none, and the
384    /// library's figure stands in — it came from the server, it is right, and
385    /// a transport that reads 0:00 for nine hours of music is worse than one
386    /// reading a figure the container has not caught up with yet.
387    pub fn duration_ms(&self) -> u64 {
388        let Some(info) = self.track_info.read().clone() else {
389            return 0;
390        };
391        if info.duration_ms > 0 {
392            return info.duration_ms;
393        }
394        // Released before the playlist lock, as everywhere else here.
395        self.playlist
396            .read()
397            .items
398            .iter()
399            .find(|item| item.id == info.id)
400            .and_then(|item| item.duration_ms)
401            .unwrap_or(0)
402    }
403
404    /// `seekable_ms`, but `None` when the whole track is reachable — which is
405    /// every track that is not mid-download. What a front end draws a boundary
406    /// from: no boundary is the normal case and should cost no mark.
407    pub fn seek_ceiling_ms(&self) -> Option<u64> {
408        let duration = self.duration_ms();
409        if duration == 0 {
410            return None;
411        }
412        let seekable = self.seekable_ms();
413        (seekable < duration).then_some(seekable)
414    }
415
416    /// Download fraction (0.0..1.0) for the currently playing track, if streaming.
417    /// Returns `None` for fully-downloaded or non-playing tracks.
418    pub fn current_download_fraction(&self) -> Option<f64> {
419        // Released before the playlist lock is taken: derive_visible_queue takes
420        // these two in the opposite order, so holding both would close a cycle.
421        let id = self.track_info.read().as_ref()?.id;
422        let pl = self.playlist.read();
423        pl.items
424            .iter()
425            .find(|item| item.id == id)
426            .and_then(|item| match LoadState::of(item) {
427                LoadState::Downloading {
428                    bytes_written,
429                    total,
430                    ..
431                } => {
432                    let written = bytes_written.load(Ordering::Acquire);
433                    (total > 0).then(|| (written as f64 / total as f64).min(1.0))
434                }
435                _ => None,
436            })
437    }
438
439    // --- Quit ---
440
441    pub fn request_quit(&self) {
442        self.quit_requested.store(true, Ordering::Release);
443    }
444
445    pub fn quit_requested(&self) -> bool {
446        self.quit_requested.load(Ordering::Acquire)
447    }
448
449    // --- Metadata refresh (progressive enhancement) ---
450
451    /// Signal that metadata has been refreshed mid-stream (e.g. download completed).
452    /// The UI loop calls `take_metadata_refresh()` to consume this flag and
453    /// force a souvlaki/cover-art update without waiting for a track change.
454    pub fn signal_metadata_refresh(&self) {
455        self.metadata_refresh_pending.store(true, Ordering::Release);
456    }
457
458    /// Returns true and clears the flag if a metadata refresh is pending.
459    pub fn take_metadata_refresh(&self) -> bool {
460        self.metadata_refresh_pending
461            .compare_exchange(true, false, Ordering::AcqRel, Ordering::Acquire)
462            .is_ok()
463    }
464
465    // --- Radio mode ---
466
467    pub fn radio_mode(&self) -> bool {
468        self.radio_mode.load(Ordering::Acquire)
469    }
470
471    pub fn set_radio_mode(&self, enabled: bool) {
472        self.radio_mode.store(enabled, Ordering::Release);
473    }
474
475    // --- Output device rate ---
476
477    /// `None` until a track has started and the device rate is known.
478    pub fn output_sample_rate(&self) -> Option<u32> {
479        match self.output_sample_rate.load(Ordering::Acquire) {
480            0 => None,
481            rate => Some(rate as u32),
482        }
483    }
484
485    pub fn set_output_sample_rate(&self, rate: u32) {
486        self.output_sample_rate
487            .store(u64::from(rate), Ordering::Release);
488    }
489
490    /// Back to "not known yet", for the window where the device is between
491    /// rates. A switch takes as long as the hardware needs to reclock — the
492    /// better part of a second on USB — and the previous track's rate is not
493    /// an answer for this one.
494    pub fn clear_output_sample_rate(&self) {
495        self.output_sample_rate.store(0, Ordering::Release);
496    }
497
498    // --- Playlist version ---
499
500    pub fn playlist_version(&self) -> u64 {
501        self.playlist_version.load(Ordering::Acquire)
502    }
503
504    fn bump_version(&self) {
505        self.playlist_version.fetch_add(1, Ordering::AcqRel);
506    }
507
508    // --- Playlist mutations (called from player thread via commands) ---
509
510    /// Append items to the playlist.
511    pub fn add_items(&self, items: Vec<PlaylistItem>) {
512        let mut pl = self.playlist.write();
513        pl.items.extend(items);
514        drop(pl);
515        self.bump_version();
516    }
517
518    /// Insert items after a specific queue item.
519    pub fn insert_items_after(&self, items: Vec<PlaylistItem>, after: QueueItemId) {
520        let mut pl = self.playlist.write();
521        let insert_at = match pl.items.iter().position(|item| item.id == after) {
522            Some(pos) => pos + 1,
523            None => pl.items.len(), // fallback: append
524        };
525        for (i, item) in items.into_iter().enumerate() {
526            pl.items.insert(insert_at + i, item);
527        }
528        drop(pl);
529        self.bump_version();
530    }
531
532    /// Update file paths for playlist items (after organize moves files).
533    pub fn update_paths(&self, updates: &[(QueueItemId, PathBuf)]) {
534        let mut pl = self.playlist.write();
535        for (id, new_path) in updates {
536            if let Some(item) = pl.items.iter_mut().find(|item| item.id == *id) {
537                item.path = new_path.clone();
538            }
539        }
540        drop(pl);
541        self.bump_version();
542    }
543
544    /// Remove an item by ID.
545    pub fn remove_item(&self, id: QueueItemId) {
546        let mut pl = self.playlist.write();
547        pl.items.retain(|item| item.id != id);
548        // If cursor was on removed item, clear it (caller handles next_track).
549        if pl.cursor == Some(id) {
550            pl.cursor = None;
551        }
552        drop(pl);
553        self.bump_version();
554    }
555
556    /// Move an item relative to another entry.
557    pub fn move_item(&self, id: QueueItemId, target: QueueItemId, after: bool) {
558        let mut pl = self.playlist.write();
559        let Some(from) = pl.items.iter().position(|item| item.id == id) else {
560            return;
561        };
562        let item = pl.items.remove(from);
563        let Some(to) = pl.items.iter().position(|item| item.id == target) else {
564            // Target gone — put it back.
565            let pos = from.min(pl.items.len());
566            pl.items.insert(pos, item);
567            return;
568        };
569        let insert_at = if after { to + 1 } else { to };
570        pl.items.insert(insert_at, item);
571        drop(pl);
572        self.bump_version();
573    }
574
575    /// Batch move: extract items by ID, reinsert them at `target` position.
576    /// Preserves the relative order of the moved items.
577    pub fn move_items(&self, ids: &[QueueItemId], target: QueueItemId, after: bool) {
578        use std::collections::HashSet;
579        let id_set: HashSet<QueueItemId> = ids.iter().copied().collect();
580
581        let mut pl = self.playlist.write();
582
583        // Partition: extract moved items, keep the rest.
584        let mut remaining = Vec::with_capacity(pl.items.len());
585        let mut moved = Vec::with_capacity(ids.len());
586        for item in pl.items.drain(..) {
587            if id_set.contains(&item.id) {
588                moved.push(item);
589            } else {
590                remaining.push(item);
591            }
592        }
593
594        // Find target in the remaining items.
595        let insert_at = match remaining.iter().position(|item| item.id == target) {
596            Some(pos) => {
597                if after {
598                    pos + 1
599                } else {
600                    pos
601                }
602            }
603            None => remaining.len(),
604        };
605
606        // Splice moved items in at the target position.
607        for (i, item) in moved.into_iter().enumerate() {
608            remaining.insert(insert_at + i, item);
609        }
610
611        pl.items = remaining;
612        drop(pl);
613        self.bump_version();
614    }
615
616    /// Set the cursor (what's playing / should play).
617    pub fn set_cursor(&self, id: Option<QueueItemId>) {
618        let mut pl = self.playlist.write();
619        pl.cursor = id;
620        drop(pl);
621        self.bump_version();
622    }
623
624    /// Get the current cursor ID.
625    pub fn cursor(&self) -> Option<QueueItemId> {
626        self.playlist.read().cursor
627    }
628
629    /// Clear the entire playlist + cursor.
630    pub fn clear_playlist(&self) {
631        let mut pl = self.playlist.write();
632        pl.items.clear();
633        pl.cursor = None;
634        drop(pl);
635        self.bump_version();
636    }
637
638    // --- Called from decode thread (gapless) ---
639
640    /// Move the cursor to the next item that can still play — the first item
641    /// after the cursor that is not `Failed` — and return its ID.
642    ///
643    /// An item that is still downloading parks the cursor rather than being
644    /// skipped, so playback resumes from it when its data lands. Skipping it
645    /// would drop it from the queue for good.
646    ///
647    /// With no cursor set, starts from the top. A cursor pointing at an item
648    /// that is no longer in the playlist yields `None` — restarting from the
649    /// top would silently replay the queue.
650    pub fn advance_cursor_loadable(&self) -> Option<QueueItemId> {
651        let mut pl = self.playlist.write();
652        let start = match pl.cursor {
653            Some(cid) => pl.items.iter().position(|item| item.id == cid)? + 1,
654            None => 0,
655        };
656
657        let next = pl
658            .items
659            .get(start..)?
660            .iter()
661            .find(|item| !matches!(item.state, ItemState::Failed(_)))
662            .map(|item| item.id)?;
663
664        pl.cursor = Some(next);
665        drop(pl);
666        self.bump_version();
667        Some(next)
668    }
669
670    /// Peek at the next Ready item after a given item ID WITHOUT moving the cursor.
671    /// Used by the decode thread for gapless lookahead — the cursor is moved
672    /// later by update_playback_state when playback actually reaches the track.
673    pub fn peek_next_ready_after(&self, after_id: QueueItemId) -> Option<(QueueItemId, PathBuf)> {
674        let pl = self.playlist.read();
675        // A reference item that has been removed means the lookahead has nothing
676        // to follow; starting from the top would gaplessly replay the queue.
677        let start = pl.items.iter().position(|item| item.id == after_id)? + 1;
678
679        for i in start..pl.items.len() {
680            if matches!(pl.items[i].state, ItemState::Ready) {
681                let item = &pl.items[i];
682                return Some((item.id, item.path.clone()));
683            }
684        }
685        None
686    }
687
688    /// Retreat cursor to the previous item. Returns (id, path) if found.
689    /// For prev_track — goes to the item before cursor regardless of load state.
690    pub fn retreat_cursor(&self) -> Option<(QueueItemId, PathBuf)> {
691        let mut pl = self.playlist.write();
692        let cursor_pos = match pl.cursor {
693            Some(cid) => pl.items.iter().position(|item| item.id == cid),
694            None => None,
695        };
696
697        let prev_pos = cursor_pos.and_then(|p| p.checked_sub(1));
698
699        match prev_pos {
700            Some(pos) => {
701                let item = &pl.items[pos];
702                let result = (item.id, item.path.clone());
703                pl.cursor = Some(item.id);
704                drop(pl);
705                self.bump_version();
706                Some(result)
707            }
708            None => None,
709        }
710    }
711
712    // --- Called from resolve thread ---
713
714    /// Update the load state of a playlist item. Safe — just a field update under lock.
715    pub fn update_item_state(&self, id: QueueItemId, new_state: ItemState) {
716        let mut pl = self.playlist.write();
717        if let Some(item) = pl.items.iter_mut().find(|item| item.id == id) {
718            item.state = new_state;
719        }
720        drop(pl);
721        self.bump_version();
722    }
723
724    /// Take what a finished download's own tags can add.
725    ///
726    /// Streaming starts on partial Symphonia tags, so an item with nothing
727    /// behind it takes the lot once the whole file is there. An item that came
728    /// out of the library does not: the record is what the queue was built
729    /// from and what every other track on it carries, and a file whose tags
730    /// disagree — a server album titled one way, the file inside titled
731    /// another — would split its album in two the moment it finished
732    /// downloading. The duration is the file's to know either way.
733    pub fn update_item_metadata(
734        &self,
735        id: QueueItemId,
736        title: String,
737        artist: String,
738        album_artist: String,
739        album: String,
740        duration_ms: Option<u64>,
741    ) {
742        let mut pl = self.playlist.write();
743        if let Some(item) = pl.items.iter_mut().find(|item| item.id == id) {
744            if item.db_id.is_none() {
745                item.title = title;
746                item.artist = artist;
747                item.album_artist = album_artist;
748                item.album = album;
749            }
750            if let Some(dur) = duration_ms {
751                item.duration_ms = Some(dur);
752            }
753        }
754        drop(pl);
755        self.bump_version();
756    }
757
758    /// Get the playback source for an item if it's ready to play.
759    /// Returns `None` if not enough data is available yet.
760    pub fn item_playback_source(&self, id: QueueItemId) -> Option<PlaybackSource> {
761        let pl = self.playlist.read();
762        pl.items
763            .iter()
764            .find(|item| item.id == id)
765            .and_then(|item| match LoadState::of(item) {
766                LoadState::Ready => Some(PlaybackSource::Ready(item.path.clone())),
767                LoadState::Downloading {
768                    path,
769                    total,
770                    bytes_written,
771                } => {
772                    let written = bytes_written.load(Ordering::Acquire);
773                    (written >= STREAM_THRESHOLD).then_some(PlaybackSource::Streaming {
774                        path,
775                        bytes_written,
776                        total,
777                    })
778                }
779                _ => None,
780            })
781    }
782
783    /// Put back to `Pending` every queue item whose file has gone, and say
784    /// which they were so they can be fetched again.
785    ///
786    /// The queue holds paths, and clearing downloads deletes the files under
787    /// them. An item left claiming `Ready` opens nothing when it is played —
788    /// it is not broken, it is a remote track that has to be fetched a second
789    /// time. Only items with a database row behind them: one without has
790    /// nowhere to be fetched from, and parking the cursor on it would be worse
791    /// than letting it fail honestly.
792    pub fn reset_items_with_missing_files(&self) -> Vec<(i64, QueueItemId)> {
793        let mut pl = self.playlist.write();
794        let mut reset = Vec::new();
795        for item in pl.items.iter_mut() {
796            let Some(db_id) = item.db_id else { continue };
797            if !matches!(item.state, ItemState::Ready) {
798                continue;
799            }
800            if item.path.exists() {
801                continue;
802            }
803            item.state = ItemState::Pending;
804            reset.push((db_id, item.id));
805        }
806        drop(pl);
807        if !reset.is_empty() {
808            self.bump_version();
809        }
810        reset
811    }
812
813    /// Get the path of an item if it's Ready (legacy convenience — use item_playback_source for streaming).
814    pub fn item_path_if_ready(&self, id: QueueItemId) -> Option<PathBuf> {
815        let pl = self.playlist.read();
816        pl.items.iter().find(|item| item.id == id).and_then(|item| {
817            if matches!(item.state, ItemState::Ready) {
818                Some(item.path.clone())
819            } else {
820                None
821            }
822        })
823    }
824
825    /// Check if the cursor is on the given item.
826    pub fn is_cursor(&self, id: QueueItemId) -> bool {
827        self.playlist.read().cursor == Some(id)
828    }
829
830    /// Get QueueItemIds of all playlist items sharing the same album as the given item.
831    /// Matches on both album name and album artist to avoid false positives
832    /// (e.g. two different "Greatest Hits" by different artists).
833    pub fn same_album_item_ids(&self, id: QueueItemId) -> Vec<QueueItemId> {
834        let pl = self.playlist.read();
835        let Some(cursor) = pl.items.iter().find(|item| item.id == id) else {
836            return vec![];
837        };
838        let album = cursor.album.clone();
839        let album_artist = cursor.album_artist.clone();
840        pl.items
841            .iter()
842            .filter(|item| {
843                item.id != id && item.album == album && item.album_artist == album_artist
844            })
845            .map(|item| item.id)
846            .collect()
847    }
848
849    /// Get all playlist items that are Pending and have a db_id.
850    /// Returns `(db_id, QueueItemId)` pairs suitable for the download queue.
851    pub fn pending_downloads(&self) -> Vec<(i64, QueueItemId)> {
852        let pl = self.playlist.read();
853        pl.items
854            .iter()
855            .filter(|item| matches!(item.state, ItemState::Pending))
856            .filter_map(|item| item.db_id.map(|db_id| (db_id, item.id)))
857            .collect()
858    }
859
860    /// Every item mid-transfer, with the bytes it has and the bytes it expects.
861    ///
862    /// Progress moves without the playlist version moving — the download thread
863    /// writes the byte counter directly — so anything following the version
864    /// alone shows a frozen bar. This is how a watcher sees it move.
865    pub fn downloads_in_flight(&self) -> Vec<(QueueItemId, u64, u64)> {
866        crate::remote::downloads::store()
867            .all()
868            .iter()
869            .filter(|d| !d.state.is_settled())
870            .map(|d| (d.id, d.bytes_written(), d.total))
871            .collect()
872    }
873
874    /// Get the db_id for a specific playlist item.
875    pub fn item_db_id(&self, id: QueueItemId) -> Option<i64> {
876        let pl = self.playlist.read();
877        pl.items
878            .iter()
879            .find(|item| item.id == id)
880            .and_then(|item| item.db_id)
881    }
882
883    /// Get the load state of a specific playlist item.
884    pub fn item_load_state(&self, id: QueueItemId) -> Option<LoadState> {
885        let pl = self.playlist.read();
886        pl.items
887            .iter()
888            .find(|item| item.id == id)
889            .map(LoadState::of)
890    }
891
892    // --- Snapshot helpers for undo ---
893
894    /// Get the full playlist snapshot (items + cursor) for undo of ClearPlaylist.
895    pub fn snapshot_playlist(&self) -> (Vec<PlaylistItem>, Option<QueueItemId>) {
896        let pl = self.playlist.read();
897        (pl.items.clone(), pl.cursor)
898    }
899
900    /// Get an item by ID (for undo of RemoveFromPlaylist).
901    pub fn get_item(&self, id: QueueItemId) -> Option<PlaylistItem> {
902        let pl = self.playlist.read();
903        pl.items.iter().find(|item| item.id == id).cloned()
904    }
905
906    /// Get the ID of the item immediately before the given ID (None if first).
907    pub fn item_before(&self, id: QueueItemId) -> Option<QueueItemId> {
908        let pl = self.playlist.read();
909        let pos = pl.items.iter().position(|item| item.id == id)?;
910        if pos == 0 {
911            None
912        } else {
913            Some(pl.items[pos - 1].id)
914        }
915    }
916
917    /// For each ID, the ID of the item before it (or None if first), returned in
918    /// playlist order regardless of the order `ids` arrives in.
919    ///
920    /// Undo replays these left to right, so an item whose recorded predecessor is
921    /// also in `ids` must come after it — otherwise the predecessor is missing at
922    /// replay time and the item lands at the end of the playlist instead.
923    /// Put the items in exactly this order.
924    ///
925    /// Items not named keep their relative order and follow at the end, so a
926    /// stale order cannot lose anything. The items themselves are moved, not
927    /// rebuilt: their ids, load states and download progress are what the rest
928    /// of the player is holding on to.
929    pub fn reorder_to(&self, order: &[QueueItemId]) {
930        let mut pl = self.playlist.write();
931        let mut taken: Vec<Option<PlaylistItem>> = pl.items.drain(..).map(Some).collect();
932        let mut sorted = Vec::with_capacity(taken.len());
933        for id in order {
934            if let Some(slot) = taken
935                .iter_mut()
936                .find(|i| i.as_ref().is_some_and(|i| i.id == *id))
937                && let Some(item) = slot.take()
938            {
939                sorted.push(item);
940            }
941        }
942        sorted.extend(taken.into_iter().flatten());
943        pl.items = sorted;
944        drop(pl);
945        self.bump_version();
946    }
947
948    pub fn items_before(&self, ids: &[QueueItemId]) -> Vec<(QueueItemId, Option<QueueItemId>)> {
949        use std::collections::HashSet;
950        let wanted: HashSet<QueueItemId> = ids.iter().copied().collect();
951        let pl = self.playlist.read();
952        pl.items
953            .iter()
954            .enumerate()
955            .filter(|(_, item)| wanted.contains(&item.id))
956            .map(|(pos, item)| {
957                let before = if pos == 0 {
958                    None
959                } else {
960                    Some(pl.items[pos - 1].id)
961                };
962                (item.id, before)
963            })
964            .collect()
965    }
966
967    /// The nearest item before `id` that is not itself being removed — where
968    /// playback resumes from after a batch delete that takes out the cursor.
969    /// `None` means resume from the top of what survives.
970    pub fn surviving_item_before(
971        &self,
972        id: QueueItemId,
973        removed: &[QueueItemId],
974    ) -> Option<QueueItemId> {
975        use std::collections::HashSet;
976        let removed: HashSet<QueueItemId> = removed.iter().copied().collect();
977        let pl = self.playlist.read();
978        let pos = pl.items.iter().position(|item| item.id == id)?;
979        pl.items[..pos]
980            .iter()
981            .rev()
982            .find(|item| !removed.contains(&item.id))
983            .map(|item| item.id)
984    }
985
986    /// Restore a full playlist from snapshot (for redo of ClearPlaylist undo).
987    pub fn restore_playlist(&self, items: Vec<PlaylistItem>, cursor: Option<QueueItemId>) {
988        let mut pl = self.playlist.write();
989        pl.items = items;
990        pl.cursor = cursor;
991        drop(pl);
992        self.bump_version();
993    }
994
995    /// Remove multiple items by IDs.
996    pub fn remove_items(&self, ids: &[QueueItemId]) {
997        use std::collections::HashSet;
998        let id_set: HashSet<QueueItemId> = ids.iter().copied().collect();
999        let mut pl = self.playlist.write();
1000        pl.items.retain(|item| !id_set.contains(&item.id));
1001        if let Some(cursor) = pl.cursor
1002            && id_set.contains(&cursor)
1003        {
1004            pl.cursor = None;
1005        }
1006        drop(pl);
1007        self.bump_version();
1008    }
1009
1010    /// Insert a single item after a given ID (or at front if None).
1011    pub fn insert_item_at(&self, item: PlaylistItem, after: Option<QueueItemId>) {
1012        let mut pl = self.playlist.write();
1013        let insert_at = match after {
1014            Some(after_id) => {
1015                match pl.items.iter().position(|i| i.id == after_id) {
1016                    Some(pos) => pos + 1,
1017                    None => pl.items.len(), // fallback
1018                }
1019            }
1020            None => 0,
1021        };
1022        pl.items.insert(insert_at, item);
1023        drop(pl);
1024        self.bump_version();
1025    }
1026
1027    /// Move a single item to after `after` (or to front if None).
1028    pub fn move_item_to(&self, id: QueueItemId, after: Option<QueueItemId>) {
1029        let mut pl = self.playlist.write();
1030        let Some(from) = pl.items.iter().position(|item| item.id == id) else {
1031            return;
1032        };
1033        let item = pl.items.remove(from);
1034        let insert_at = match after {
1035            Some(after_id) => match pl.items.iter().position(|i| i.id == after_id) {
1036                Some(pos) => pos + 1,
1037                None => pl.items.len(),
1038            },
1039            None => 0,
1040        };
1041        pl.items.insert(insert_at, item);
1042        drop(pl);
1043        self.bump_version();
1044    }
1045
1046    /// Batch move: reposition each item to after its given predecessor.
1047    /// Processes in order so earlier insertions don't corrupt later positions.
1048    pub fn move_items_to(&self, entries: &[(QueueItemId, Option<QueueItemId>)]) {
1049        for &(id, after) in entries {
1050            self.move_item_to(id, after);
1051        }
1052    }
1053
1054    // --- Called from UI thread (read lock) ---
1055
1056    /// Derive the visible queue from the playlist + cursor. O(n).
1057    /// Called once per UI tick.
1058    pub fn derive_visible_queue(&self) -> VisibleQueueSnapshot {
1059        // Read before the playlist lock — see current_download_fraction.
1060        let playing_duration_ms = self.track_info.read().as_ref().map(|ti| ti.duration_ms);
1061        let pl = self.playlist.read();
1062
1063        let cursor_pos = match pl.cursor {
1064            Some(cid) => pl.items.iter().position(|item| item.id == cid),
1065            None => None,
1066        };
1067
1068        let mut entries = Vec::with_capacity(pl.items.len());
1069        let mut finished_count = 0;
1070        let mut has_playing = false;
1071        let mut queue_count = 0;
1072
1073        for (i, item) in pl.items.iter().enumerate() {
1074            let is_cursor = cursor_pos == Some(i);
1075            let is_before_cursor = cursor_pos.is_some_and(|cp| i < cp);
1076
1077            // Byte count comes from the shared atomic, not from the load
1078            // state's own copy: the download thread writes it per chunk
1079            // without taking the playlist lock, which is what keeps a
1080            // transfer from bumping the playlist version a thousand times.
1081            // Once per row, because it is the item's state and any transfer
1082            // against it as one answer, and every branch below wants both.
1083            let load_state = LoadState::of(item);
1084
1085            let dl_progress = match &load_state {
1086                LoadState::Downloading {
1087                    total,
1088                    bytes_written,
1089                    ..
1090                } => Some((bytes_written.load(Ordering::Relaxed), *total)),
1091                _ => None,
1092            };
1093
1094            let status = if is_cursor {
1095                has_playing = true;
1096                match &load_state {
1097                    LoadState::Ready => QueueEntryStatus::Playing,
1098                    LoadState::Downloading { .. } => QueueEntryStatus::PriorityPending,
1099                    LoadState::Pending => QueueEntryStatus::PriorityPending,
1100                    LoadState::Failed(_) => QueueEntryStatus::Failed,
1101                }
1102            } else if is_before_cursor {
1103                finished_count += 1;
1104                match &load_state {
1105                    LoadState::Ready => QueueEntryStatus::Played,
1106                    LoadState::Downloading { .. } => QueueEntryStatus::Downloading,
1107                    LoadState::Pending => QueueEntryStatus::Downloading,
1108                    LoadState::Failed(_) => QueueEntryStatus::Failed,
1109                }
1110            } else {
1111                queue_count += 1;
1112                match &load_state {
1113                    LoadState::Ready => QueueEntryStatus::Queued,
1114                    LoadState::Downloading { .. } => QueueEntryStatus::Downloading,
1115                    LoadState::Pending => QueueEntryStatus::Downloading,
1116                    LoadState::Failed(_) => QueueEntryStatus::Failed,
1117                }
1118            };
1119
1120            // Override duration from TrackInfo if we have it and this is playing.
1121            let duration_ms =
1122                if has_playing && status == QueueEntryStatus::Playing && item.duration_ms.is_none()
1123                {
1124                    playing_duration_ms
1125                } else {
1126                    item.duration_ms
1127                };
1128
1129            entries.push(QueueEntry {
1130                id: item.id,
1131                db_id: item.db_id,
1132                playlist_entry_id: item.playlist_entry_id,
1133                path: item.path.clone(),
1134                title: item.title.clone(),
1135                artist: item.artist.clone(),
1136                album_artist: item.album_artist.clone(),
1137                album: item.album.clone(),
1138                year: item.year.clone(),
1139                codec: item.codec.clone(),
1140                track_number: item.track_number,
1141                disc: item.disc,
1142                duration_ms,
1143                status,
1144                download_progress: dl_progress,
1145                error: match &load_state {
1146                    LoadState::Failed(reason) => Some(reason.clone()),
1147                    _ => None,
1148                },
1149            });
1150        }
1151
1152        VisibleQueueSnapshot {
1153            entries,
1154            finished_count,
1155            has_playing,
1156            queue_count,
1157        }
1158    }
1159}
1160
1161#[cfg(test)]
1162mod tests {
1163    use super::*;
1164
1165    // --- helpers ---
1166
1167    fn make_item(title: &str, state: ItemState) -> PlaylistItem {
1168        PlaylistItem {
1169            playlist_entry_id: None,
1170            id: QueueItemId::new(),
1171            db_id: None,
1172            path: PathBuf::from(format!("/music/{title}.flac")),
1173            title: title.to_string(),
1174            artist: "Artist".to_string(),
1175            album_artist: "Artist".to_string(),
1176            album: "Album".to_string(),
1177            year: None,
1178            codec: Some("FLAC".to_string()),
1179            track_number: None,
1180            disc: None,
1181            duration_ms: Some(200_000),
1182            state,
1183        }
1184    }
1185
1186    /// An item with a transfer running against it, told to the store the way
1187    /// the downloader tells it.
1188    fn downloading_item(title: &str, total: u64, written: Arc<AtomicU64>) -> PlaylistItem {
1189        let item = make_item(title, ItemState::Pending);
1190        let store = crate::remote::downloads::store();
1191        store.queued(crate::remote::downloads::Download {
1192            id: item.id,
1193            track_id: 1,
1194            title: title.into(),
1195            artist: String::new(),
1196            source: PathBuf::from(format!("/cache/{title}.flac.part")),
1197            dest: PathBuf::from(format!("/cache/{title}.flac")),
1198            total,
1199            written: written.clone(),
1200            state: crate::remote::downloads::DownloadState::Queued,
1201            bytes_per_second: 0,
1202        });
1203        store.started(item.id, total, written);
1204        item
1205    }
1206
1207    fn ready_item(title: &str) -> PlaylistItem {
1208        make_item(title, ItemState::Ready)
1209    }
1210
1211    fn pending_item(title: &str) -> PlaylistItem {
1212        make_item(title, ItemState::Pending)
1213    }
1214
1215    fn failed_item(title: &str) -> PlaylistItem {
1216        make_item(title, ItemState::Failed("nope".into()))
1217    }
1218
1219    const DURATION_MS: u64 = 32_523_787;
1220
1221    /// A nine-hour track under the cursor, `downloaded` bytes of `total` in.
1222    /// `total` of 0 stands for a server that sent no Content-Length.
1223    fn streaming_state(
1224        downloaded: u64,
1225        total: u64,
1226        bitrate_kbps: Option<u32>,
1227    ) -> Arc<SharedPlayerState> {
1228        streaming_state_with_duration(downloaded, total, bitrate_kbps, DURATION_MS)
1229    }
1230
1231    /// The same, but saying what the container managed to state about itself.
1232    /// A partial Ogg states nothing, which is zero here.
1233    fn streaming_state_with_duration(
1234        downloaded: u64,
1235        total: u64,
1236        bitrate_kbps: Option<u32>,
1237        container_duration_ms: u64,
1238    ) -> Arc<SharedPlayerState> {
1239        let written = Arc::new(AtomicU64::new(downloaded));
1240        let item = make_item("train", ItemState::Pending);
1241        let id = item.id;
1242        let path = item.path.clone();
1243
1244        // The transfer goes where transfers go. Ids are unique per item, so
1245        // tests sharing the process store never see each other's.
1246        let store = crate::remote::downloads::store();
1247        store.queued(crate::remote::downloads::Download {
1248            id,
1249            track_id: 1,
1250            title: "train".into(),
1251            artist: String::new(),
1252            source: PathBuf::from("/cache/train.opus.part"),
1253            dest: PathBuf::from("/cache/train.opus"),
1254            total,
1255            written: written.clone(),
1256            state: crate::remote::downloads::DownloadState::Queued,
1257            bytes_per_second: 0,
1258        });
1259        store.started(id, total, written);
1260
1261        let state = SharedPlayerState::new();
1262        state.add_items(vec![item]);
1263        state.set_cursor(Some(id));
1264        state.set_track_info(Some(TrackInfo {
1265            id,
1266            path,
1267            codec: "Opus".into(),
1268            sample_rate: 48_000,
1269            bit_depth: None,
1270            bitrate_kbps,
1271            channels: 2,
1272            duration_ms: container_duration_ms,
1273        }));
1274        state
1275    }
1276
1277    // --- seekable_ms ---
1278
1279    #[test]
1280    fn a_track_on_disk_is_seekable_end_to_end() {
1281        let item = ready_item("done");
1282        let id = item.id;
1283        let path = item.path.clone();
1284        let state = SharedPlayerState::new();
1285        state.add_items(vec![item]);
1286        state.set_cursor(Some(id));
1287        state.set_track_info(Some(TrackInfo {
1288            id,
1289            path,
1290            codec: "FLAC".into(),
1291            sample_rate: 44_100,
1292            bit_depth: Some(16),
1293            bitrate_kbps: None,
1294            channels: 2,
1295            duration_ms: 200_000,
1296        }));
1297
1298        assert_eq!(state.seekable_ms(), 200_000);
1299        // Nothing to draw a boundary for, so front ends are told there isn't one.
1300        assert_eq!(state.seek_ceiling_ms(), None);
1301    }
1302
1303    #[test]
1304    fn a_downloading_track_is_seekable_as_far_as_its_bytes_reach() {
1305        // A quarter of a nine-hour file in: a quarter of the way through it,
1306        // less the margin the byte-to-time estimate is worth.
1307        let state = streaming_state(100, 400, None);
1308        assert_eq!(state.seekable_ms(), 32_523_787 / 4 - SEEK_SAFETY_MS);
1309        assert_eq!(
1310            state.seek_ceiling_ms(),
1311            Some(32_523_787 / 4 - SEEK_SAFETY_MS)
1312        );
1313    }
1314
1315    #[test]
1316    fn a_transfer_without_a_content_length_falls_back_to_bitrate() {
1317        // No total to take a fraction of. 128 kbps is 128 bits per ms, so a
1318        // megabyte is 8 388 608 bits and a little over 65 seconds.
1319        let state = streaming_state(1024 * 1024, 0, Some(128));
1320        assert_eq!(state.seekable_ms(), 1024 * 1024 * 8 / 128 - SEEK_SAFETY_MS);
1321    }
1322
1323    #[test]
1324    fn nothing_to_estimate_from_allows_no_forward_seek() {
1325        // Neither a length nor a bitrate: anywhere past the playhead is a
1326        // guess, and a guess that lands past the write head is a stall.
1327        let state = streaming_state(1024 * 1024, 0, None);
1328        state.set_position_ms(12_000);
1329        assert_eq!(state.seekable_ms(), 12_000);
1330    }
1331
1332    #[test]
1333    fn the_seekable_extent_never_exceeds_the_track() {
1334        // A download reporting more bytes than it advertised must not offer a
1335        // seek past the end of the music.
1336        let state = streaming_state(500, 400, None);
1337        assert_eq!(state.seekable_ms(), 32_523_787);
1338    }
1339
1340    #[test]
1341    fn nothing_playing_is_seekable_nowhere() {
1342        assert_eq!(SharedPlayerState::new().seekable_ms(), 0);
1343        assert_eq!(SharedPlayerState::new().seek_ceiling_ms(), None);
1344    }
1345
1346    #[test]
1347    fn a_container_that_cannot_state_its_duration_cannot_be_seeked() {
1348        // A partial Ogg keeps its duration in a last page that has not arrived,
1349        // so it opens and plays but has nothing to seek against. Half the bytes
1350        // being present does not change that.
1351        let state = streaming_state_with_duration(200, 400, Some(128), 0);
1352        assert_eq!(state.seekable_ms(), 0);
1353    }
1354
1355    #[test]
1356    fn the_library_duration_stands_in_for_a_silent_container() {
1357        // What is shown on the transport, so nine hours of music does not read
1358        // as 0:00 while it caches.
1359        let state = streaming_state_with_duration(200, 400, Some(128), 0);
1360        assert_eq!(state.duration_ms(), 200_000, "the item's own figure");
1361        // And it is a display figure only — it grants no seeking.
1362        assert_eq!(state.seekable_ms(), 0);
1363        assert_eq!(state.seek_ceiling_ms(), Some(0));
1364    }
1365
1366    #[test]
1367    fn the_container_duration_wins_where_there_is_one() {
1368        let state = streaming_state(200, 400, None);
1369        assert_eq!(state.duration_ms(), DURATION_MS);
1370    }
1371
1372    #[test]
1373    fn the_download_landing_restores_seeking() {
1374        // The sequence the whole design turns on: a track that opened without a
1375        // duration gets one when the finished file is re-read, and is seekable
1376        // end to end from that moment — no restart, no handover.
1377        let state = streaming_state_with_duration(400, 400, Some(128), 0);
1378        assert_eq!(state.seekable_ms(), 0);
1379
1380        // What the downloader does when the bytes land: settle the transfer,
1381        // then say the file is playable. In that order — while the store still
1382        // says a transfer is running, it is.
1383        let id = state.cursor().expect("cursor");
1384        crate::remote::downloads::store().finished(id);
1385        state.update_item_state(id, ItemState::Ready);
1386        let info = state.track_info().expect("track info");
1387        state.set_track_info(Some(TrackInfo {
1388            duration_ms: DURATION_MS,
1389            ..info
1390        }));
1391
1392        assert_eq!(state.seekable_ms(), DURATION_MS);
1393        assert_eq!(state.seek_ceiling_ms(), None, "no boundary left to draw");
1394    }
1395
1396    // --- advance_cursor_loadable ---
1397
1398    #[test]
1399    fn advance_parks_on_a_still_downloading_track() {
1400        // A track that has not arrived yet must hold the cursor, not be skipped:
1401        // skipping it drops it from the queue for good, and it is the item whose
1402        // TrackReady has to resume playback.
1403        let state = SharedPlayerState::new();
1404        let item0 = ready_item("track-0");
1405        let item1 = pending_item("track-1");
1406        let item2 = ready_item("track-2");
1407        let (id0, id1) = (item0.id, item1.id);
1408
1409        state.add_items(vec![item0, item1, item2]);
1410
1411        assert_eq!(state.advance_cursor_loadable(), Some(id0));
1412        assert_eq!(state.advance_cursor_loadable(), Some(id1));
1413        assert_eq!(state.cursor(), Some(id1));
1414    }
1415
1416    #[test]
1417    fn advance_skips_failed_items() {
1418        let state = SharedPlayerState::new();
1419        let item0 = ready_item("track-0");
1420        let item1 = failed_item("track-1");
1421        let item2 = ready_item("track-2");
1422        let (id0, id2) = (item0.id, item2.id);
1423
1424        state.add_items(vec![item0, item1, item2]);
1425        state.set_cursor(Some(id0));
1426
1427        assert_eq!(state.advance_cursor_loadable(), Some(id2));
1428    }
1429
1430    #[test]
1431    fn advance_stops_at_end_of_playlist() {
1432        let state = SharedPlayerState::new();
1433        let item0 = ready_item("track-0");
1434        let item1 = ready_item("track-1");
1435        let id1 = item1.id;
1436
1437        state.add_items(vec![item0, item1]);
1438        state.set_cursor(Some(id1));
1439
1440        assert_eq!(state.advance_cursor_loadable(), None);
1441        assert_eq!(
1442            state.cursor(),
1443            Some(id1),
1444            "cursor unchanged on a failed advance"
1445        );
1446    }
1447
1448    #[test]
1449    fn advance_with_only_failed_items_returns_none() {
1450        let state = SharedPlayerState::new();
1451        state.add_items(vec![failed_item("bad-0"), failed_item("bad-1")]);
1452
1453        assert_eq!(state.advance_cursor_loadable(), None);
1454    }
1455
1456    #[test]
1457    fn advance_from_a_vanished_cursor_does_not_restart_the_queue() {
1458        let state = SharedPlayerState::new();
1459        let item0 = ready_item("track-0");
1460        let item1 = ready_item("track-1");
1461        let id0 = item0.id;
1462
1463        state.add_items(vec![item0, item1]);
1464        let ghost = QueueItemId::new();
1465        state.set_cursor(Some(ghost));
1466
1467        assert_eq!(state.advance_cursor_loadable(), None);
1468        assert_ne!(state.cursor(), Some(id0));
1469    }
1470
1471    // --- peek_next_ready_after ---
1472
1473    #[test]
1474    fn peek_after_a_removed_item_returns_none() {
1475        // The decode thread's lookahead runs seconds ahead of what is audible.
1476        // Removing the track it is pre-decoding must end the lookahead, not send
1477        // it back to the top of the queue.
1478        let state = SharedPlayerState::new();
1479        let item0 = ready_item("track-0");
1480        let item1 = ready_item("track-1");
1481        let item2 = ready_item("track-2");
1482        let (id0, id1, id2) = (item0.id, item1.id, item2.id);
1483
1484        state.add_items(vec![item0, item1, item2]);
1485        assert_eq!(
1486            state.peek_next_ready_after(id1).map(|(id, _)| id),
1487            Some(id2)
1488        );
1489
1490        state.remove_item(id2);
1491        assert!(
1492            state.peek_next_ready_after(id2).is_none(),
1493            "a vanished reference must not resolve to the head of the queue"
1494        );
1495        assert_ne!(
1496            state.peek_next_ready_after(id2).map(|(id, _)| id),
1497            Some(id0)
1498        );
1499    }
1500
1501    // --- surviving_item_before ---
1502
1503    #[test]
1504    fn surviving_predecessor_skips_items_being_removed() {
1505        let state = SharedPlayerState::new();
1506        let items: Vec<_> = (0..4).map(|i| ready_item(&format!("track-{i}"))).collect();
1507        let ids: Vec<_> = items.iter().map(|i| i.id).collect();
1508        state.add_items(items);
1509
1510        // Deleting 1..=3 leaves 0 as the resume point for a cursor on 3.
1511        assert_eq!(
1512            state.surviving_item_before(ids[3], &ids[1..4]),
1513            Some(ids[0])
1514        );
1515        // Deleting everything from the top leaves nothing to resume after.
1516        assert_eq!(state.surviving_item_before(ids[2], &ids), None);
1517    }
1518
1519    // --- retreat_cursor ---
1520
1521    #[test]
1522    fn test_retreat_cursor_goes_to_previous_item() {
1523        let state = SharedPlayerState::new();
1524        let item0 = ready_item("track-0");
1525        let item1 = ready_item("track-1");
1526        let id0 = item0.id;
1527        let id1 = item1.id;
1528
1529        state.add_items(vec![item0, item1]);
1530        state.set_cursor(Some(id1));
1531
1532        let result = state.retreat_cursor();
1533        assert!(result.is_some(), "expected to retreat to previous item");
1534        assert_eq!(result.unwrap().0, id0, "should retreat to first item");
1535        assert_eq!(state.cursor(), Some(id0));
1536    }
1537
1538    #[test]
1539    fn test_retreat_cursor_returns_none_when_at_first_item() {
1540        let state = SharedPlayerState::new();
1541        let item0 = ready_item("only-track");
1542        let id0 = item0.id;
1543
1544        state.add_items(vec![item0]);
1545        state.set_cursor(Some(id0));
1546
1547        let result = state.retreat_cursor();
1548        assert!(result.is_none(), "cannot retreat before the first item");
1549        // Cursor stays on the first item.
1550        assert_eq!(state.cursor(), Some(id0));
1551    }
1552
1553    #[test]
1554    fn test_retreat_cursor_returns_none_when_cursor_is_unset() {
1555        let state = SharedPlayerState::new();
1556        state.add_items(vec![ready_item("track-0")]);
1557
1558        let result = state.retreat_cursor();
1559        assert!(
1560            result.is_none(),
1561            "retreat with no cursor should return None"
1562        );
1563    }
1564
1565    // --- derive_visible_queue ---
1566
1567    #[test]
1568    fn test_derive_visible_queue_statuses() {
1569        // playlist: [played, playing, queued]
1570        let state = SharedPlayerState::new();
1571        let item0 = ready_item("played-track");
1572        let item1 = ready_item("playing-track");
1573        let item2 = ready_item("queued-track");
1574        let id1 = item1.id;
1575
1576        state.add_items(vec![item0, item1, item2]);
1577        state.set_cursor(Some(id1));
1578
1579        let snap = state.derive_visible_queue();
1580
1581        assert_eq!(snap.entries.len(), 3);
1582        assert_eq!(snap.entries[0].status, QueueEntryStatus::Played);
1583        assert_eq!(snap.entries[1].status, QueueEntryStatus::Playing);
1584        assert_eq!(snap.entries[2].status, QueueEntryStatus::Queued);
1585        assert!(snap.has_playing);
1586        assert_eq!(snap.finished_count, 1);
1587        assert_eq!(snap.queue_count, 1);
1588    }
1589
1590    #[test]
1591    fn test_derive_visible_queue_downloading_statuses() {
1592        // A Downloading item at cursor → PriorityPending; after cursor → Downloading.
1593        let state = SharedPlayerState::new();
1594        let bytes_cursor = Arc::new(AtomicU64::new(0));
1595        let bytes_queued = Arc::new(AtomicU64::new(0));
1596        let dl_cursor = downloading_item("downloading-at-cursor", 1_000_000, bytes_cursor.clone());
1597        let dl_queued = downloading_item("downloading-queued", 500_000, bytes_queued.clone());
1598        let id_cursor = dl_cursor.id;
1599
1600        state.add_items(vec![dl_cursor, dl_queued]);
1601        state.set_cursor(Some(id_cursor));
1602
1603        let snap = state.derive_visible_queue();
1604
1605        assert_eq!(snap.entries[0].status, QueueEntryStatus::PriorityPending);
1606        assert_eq!(snap.entries[1].status, QueueEntryStatus::Downloading);
1607    }
1608
1609    #[test]
1610    fn progress_follows_the_counter_without_touching_the_playlist() {
1611        // The download thread writes bytes and nothing else. A queue derived
1612        // afterwards must see them — the version has not moved, and the load
1613        // state it was given is the one it still holds.
1614        let state = SharedPlayerState::new();
1615        let bytes = Arc::new(AtomicU64::new(0));
1616        let item = downloading_item("downloading", 1_000, bytes.clone());
1617        state.add_items(vec![item]);
1618
1619        let version = state.playlist_version();
1620        bytes.store(250, Ordering::Release);
1621
1622        let snap = state.derive_visible_queue();
1623        assert_eq!(snap.entries[0].download_progress, Some((250, 1_000)));
1624        assert_eq!(
1625            state.playlist_version(),
1626            version,
1627            "progress must not read as a queue mutation"
1628        );
1629        // Every transfer the process knows about, not just this playlist's —
1630        // a fetch with no queue item behind it is still a transfer, and the
1631        // store is what is asked. Other tests share it, so this looks for its
1632        // own rather than asserting the whole list.
1633        assert!(
1634            state
1635                .downloads_in_flight()
1636                .contains(&(snap.entries[0].id, 250, 1_000)),
1637            "the counter should be visible through the store"
1638        );
1639    }
1640
1641    #[test]
1642    fn test_derive_visible_queue_no_cursor_all_queued() {
1643        let state = SharedPlayerState::new();
1644        state.add_items(vec![ready_item("a"), ready_item("b"), ready_item("c")]);
1645
1646        let snap = state.derive_visible_queue();
1647
1648        assert_eq!(snap.entries.len(), 3);
1649        for entry in &snap.entries {
1650            assert_eq!(entry.status, QueueEntryStatus::Queued);
1651        }
1652        assert!(!snap.has_playing);
1653        assert_eq!(snap.finished_count, 0);
1654        assert_eq!(snap.queue_count, 3);
1655    }
1656
1657    // --- same_album_item_ids ---
1658
1659    fn make_album_item(title: &str, album: &str, album_artist: &str) -> PlaylistItem {
1660        PlaylistItem {
1661            playlist_entry_id: None,
1662            id: QueueItemId::new(),
1663            db_id: None,
1664            path: PathBuf::from(format!("/music/{title}.flac")),
1665            title: title.to_string(),
1666            artist: "Artist".to_string(),
1667            album_artist: album_artist.to_string(),
1668            album: album.to_string(),
1669            year: None,
1670            codec: Some("FLAC".to_string()),
1671            track_number: None,
1672            disc: None,
1673            duration_ms: Some(200_000),
1674            state: ItemState::Ready,
1675        }
1676    }
1677
1678    #[test]
1679    fn test_same_album_item_ids_returns_album_mates() {
1680        let state = SharedPlayerState::new();
1681        let a1 = make_album_item("A1", "Album A", "Artist A");
1682        let a2 = make_album_item("A2", "Album A", "Artist A");
1683        let b1 = make_album_item("B1", "Album B", "Artist B");
1684        let a3 = make_album_item("A3", "Album A", "Artist A");
1685
1686        let id_a1 = a1.id;
1687        let id_a2 = a2.id;
1688        let id_a3 = a3.id;
1689
1690        state.add_items(vec![a1, a2, b1, a3]);
1691
1692        let mates = state.same_album_item_ids(id_a1);
1693        assert_eq!(mates.len(), 2);
1694        assert!(mates.contains(&id_a2));
1695        assert!(mates.contains(&id_a3));
1696    }
1697
1698    #[test]
1699    fn test_same_album_item_ids_distinguishes_album_artists() {
1700        // Two albums named the same but by different artists — should NOT match.
1701        let state = SharedPlayerState::new();
1702        let a1 = make_album_item("A1", "Greatest Hits", "Artist A");
1703        let b1 = make_album_item("B1", "Greatest Hits", "Artist B");
1704
1705        let id_a1 = a1.id;
1706
1707        state.add_items(vec![a1, b1]);
1708
1709        let mates = state.same_album_item_ids(id_a1);
1710        assert!(mates.is_empty(), "different album_artist should not match");
1711    }
1712
1713    #[test]
1714    fn test_same_album_item_ids_unknown_id_returns_empty() {
1715        let state = SharedPlayerState::new();
1716        state.add_items(vec![ready_item("track-0")]);
1717
1718        let bogus = QueueItemId::new();
1719        let mates = state.same_album_item_ids(bogus);
1720        assert!(mates.is_empty());
1721    }
1722
1723    // --- update_item_metadata ---
1724
1725    #[test]
1726    fn test_update_item_metadata_leaves_library_tags_alone() {
1727        let state = SharedPlayerState::new();
1728        let mut item = make_album_item("A1", "Nite Versions (mixed)", "Soulwax");
1729        item.db_id = Some(29615);
1730        let id = item.id;
1731        state.add_items(vec![item]);
1732
1733        state.update_item_metadata(
1734            id,
1735            "[unknown]".into(),
1736            "Soulwax".into(),
1737            "Soulwax".into(),
1738            "Nite Versions".into(),
1739            Some(54_000),
1740        );
1741
1742        let pl = state.playlist.read();
1743        assert_eq!(pl.items[0].album, "Nite Versions (mixed)");
1744        assert_eq!(pl.items[0].title, "A1");
1745        assert_eq!(pl.items[0].duration_ms, Some(54_000));
1746    }
1747
1748    #[test]
1749    fn test_update_item_metadata_fills_in_an_item_with_nothing_behind_it() {
1750        let state = SharedPlayerState::new();
1751        let item = make_album_item("A1", "", "");
1752        let id = item.id;
1753        state.add_items(vec![item]);
1754
1755        state.update_item_metadata(
1756            id,
1757            "Teachers".into(),
1758            "Soulwax".into(),
1759            "Soulwax".into(),
1760            "Nite Versions".into(),
1761            Some(148_000),
1762        );
1763
1764        let pl = state.playlist.read();
1765        assert_eq!(pl.items[0].title, "Teachers");
1766        assert_eq!(pl.items[0].album, "Nite Versions");
1767        assert_eq!(pl.items[0].duration_ms, Some(148_000));
1768    }
1769
1770    // --- move_item_to ---
1771
1772    #[test]
1773    fn test_move_item_to_reorders_playlist() {
1774        // Start: [A, B, C]. Move C to after A → [A, C, B].
1775        let state = SharedPlayerState::new();
1776        let item_a = ready_item("A");
1777        let item_b = ready_item("B");
1778        let item_c = ready_item("C");
1779        let id_a = item_a.id;
1780        let id_b = item_b.id;
1781        let id_c = item_c.id;
1782
1783        state.add_items(vec![item_a, item_b, item_c]);
1784        state.move_item_to(id_c, Some(id_a));
1785
1786        let (items, _) = state.snapshot_playlist();
1787        let titles: Vec<&str> = items.iter().map(|i| i.title.as_str()).collect();
1788        assert_eq!(titles, vec!["A", "C", "B"]);
1789        assert_eq!(items[0].id, id_a);
1790        assert_eq!(items[1].id, id_c);
1791        assert_eq!(items[2].id, id_b);
1792    }
1793
1794    #[test]
1795    fn test_move_item_to_front_when_after_is_none() {
1796        // Start: [A, B, C]. Move C to front (after=None) → [C, A, B].
1797        let state = SharedPlayerState::new();
1798        let item_a = ready_item("A");
1799        let item_b = ready_item("B");
1800        let item_c = ready_item("C");
1801        let id_c = item_c.id;
1802
1803        state.add_items(vec![item_a, item_b, item_c]);
1804        state.move_item_to(id_c, None);
1805
1806        let (items, _) = state.snapshot_playlist();
1807        let titles: Vec<&str> = items.iter().map(|i| i.title.as_str()).collect();
1808        assert_eq!(titles, vec!["C", "A", "B"]);
1809    }
1810
1811    // --- move_items (batch) ---
1812
1813    #[test]
1814    fn test_move_items_batch_preserves_relative_order() {
1815        // Start: [A, B, C, D]. Move [A, C] after D → [B, D, A, C].
1816        let state = SharedPlayerState::new();
1817        let item_a = ready_item("A");
1818        let item_b = ready_item("B");
1819        let item_c = ready_item("C");
1820        let item_d = ready_item("D");
1821        let id_a = item_a.id;
1822        let id_b = item_b.id;
1823        let id_c = item_c.id;
1824        let id_d = item_d.id;
1825
1826        state.add_items(vec![item_a, item_b, item_c, item_d]);
1827        state.move_items(&[id_a, id_c], id_d, true);
1828
1829        let (items, _) = state.snapshot_playlist();
1830        let titles: Vec<&str> = items.iter().map(|i| i.title.as_str()).collect();
1831        assert_eq!(titles, vec!["B", "D", "A", "C"]);
1832        assert_eq!(items[0].id, id_b);
1833        assert_eq!(items[1].id, id_d);
1834        assert_eq!(items[2].id, id_a);
1835        assert_eq!(items[3].id, id_c);
1836    }
1837
1838    // --- pending_downloads ---
1839
1840    #[test]
1841    fn test_pending_downloads_collects_pending_with_db_id() {
1842        let state = SharedPlayerState::new();
1843        let mut item_a = ready_item("local");
1844        item_a.db_id = None;
1845
1846        let mut item_b = pending_item("remote-1");
1847        item_b.db_id = Some(10);
1848        let id_b = item_b.id;
1849
1850        let mut item_c = ready_item("cached");
1851        item_c.db_id = Some(20);
1852
1853        let mut item_d = pending_item("remote-2");
1854        item_d.db_id = Some(30);
1855        let id_d = item_d.id;
1856
1857        // Pending without db_id — should NOT appear (no way to download).
1858        let item_e = pending_item("orphan");
1859
1860        state.add_items(vec![item_a, item_b, item_c, item_d, item_e]);
1861
1862        let pending = state.pending_downloads();
1863        assert_eq!(pending.len(), 2);
1864        assert_eq!(pending[0], (10, id_b));
1865        assert_eq!(pending[1], (30, id_d));
1866    }
1867
1868    #[test]
1869    fn test_item_db_id_and_load_state() {
1870        let state = SharedPlayerState::new();
1871        let mut item = pending_item("track");
1872        item.db_id = Some(42);
1873        let id = item.id;
1874        state.add_items(vec![item]);
1875
1876        assert_eq!(state.item_db_id(id), Some(42));
1877        assert!(matches!(
1878            state.item_load_state(id),
1879            Some(LoadState::Pending)
1880        ));
1881
1882        state.update_item_state(id, ItemState::Ready);
1883        assert!(matches!(state.item_load_state(id), Some(LoadState::Ready)));
1884    }
1885}