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