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