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