Skip to main content

koan_core/player/
state.rs

1use std::fmt;
2use std::path::PathBuf;
3use std::sync::Arc;
4use std::sync::atomic::{AtomicBool, AtomicU8, AtomicU64, Ordering};
5
6use uuid::Uuid;
7
8/// Stable identity for a queue entry. UUIDv7 — time-ordered, unique across duplicates.
9#[derive(Clone, Copy, PartialEq, Eq, Hash)]
10pub struct QueueItemId(pub Uuid);
11
12impl QueueItemId {
13    pub fn new() -> Self {
14        Self(Uuid::now_v7())
15    }
16}
17
18impl Default for QueueItemId {
19    fn default() -> Self {
20        Self::new()
21    }
22}
23
24impl fmt::Debug for QueueItemId {
25    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
26        // Short form for logs: first 8 hex chars.
27        write!(f, "QId({})", &self.0.to_string()[..8])
28    }
29}
30
31/// Playback state.
32#[derive(Debug, Clone, Copy, PartialEq, Eq)]
33#[repr(u8)]
34pub enum PlaybackState {
35    Stopped = 0,
36    Playing = 1,
37    Paused = 2,
38}
39
40impl PlaybackState {
41    pub fn from_u8(v: u8) -> Self {
42        match v {
43            1 => Self::Playing,
44            2 => Self::Paused,
45            _ => Self::Stopped,
46        }
47    }
48}
49
50/// Audio format info for the currently playing track.
51#[derive(Debug, Clone)]
52pub struct TrackInfo {
53    pub id: QueueItemId,
54    pub path: PathBuf,
55    pub codec: String,
56    pub sample_rate: u32,
57    pub bit_depth: Option<u16>,
58    pub bitrate_kbps: Option<u32>,
59    pub channels: u16,
60    pub duration_ms: u64,
61}
62
63// --- Playlist data model (single source of truth) ---
64
65/// Minimum bytes written before streaming playback can begin.
66pub const STREAM_THRESHOLD: u64 = 256 * 1024; // 256 KB
67
68/// Load state of a playlist item — tracks download lifecycle.
69#[derive(Debug, Clone)]
70pub enum LoadState {
71    Pending,
72    Downloading {
73        /// Total bytes expected, or 0 when the server sent no Content-Length.
74        total: u64,
75        /// How many bytes have landed. The download thread writes it per chunk
76        /// without taking the playlist lock — which is the point: progress
77        /// moves far too often to be worth a queue mutation each time.
78        bytes_written: Arc<AtomicU64>,
79    },
80    Ready,
81    Failed(String),
82}
83
84/// Resolved playback source for a playlist item.
85pub enum PlaybackSource {
86    /// File fully downloaded — play from path.
87    Ready(PathBuf),
88    /// File being downloaded — enough data buffered to start streaming.
89    Streaming {
90        path: PathBuf,
91        bytes_written: Arc<AtomicU64>,
92        total: u64,
93    },
94}
95
96/// A single item in the playlist. Replaces QueueEntry + QueueEntryMeta + pending entries
97/// as the canonical data. Created once when tracks are added to the playlist.
98#[derive(Debug, Clone)]
99pub struct PlaylistItem {
100    pub id: QueueItemId,
101    /// Database track ID — set for tracks loaded from DB, used for downloads.
102    pub db_id: Option<i64>,
103    pub path: PathBuf,
104    pub title: String,
105    pub artist: String,
106    pub album_artist: String,
107    pub album: String,
108    pub year: Option<String>,
109    pub codec: Option<String>,
110    pub track_number: Option<i64>,
111    pub disc: Option<i64>,
112    pub duration_ms: Option<u64>,
113    pub load_state: LoadState,
114}
115
116/// The playlist — one flat array, one cursor. Everything else derived.
117#[derive(Debug, Clone, Default)]
118pub struct Playlist {
119    pub items: Vec<PlaylistItem>,
120    pub cursor: Option<QueueItemId>,
121}
122
123// --- UI view types (kept for TUI compat) ---
124
125/// Status of a track in the queue — for UI display.
126#[derive(Debug, Clone, Copy, PartialEq, Eq)]
127pub enum QueueEntryStatus {
128    Queued,
129    Playing,
130    Played,
131    Downloading,
132    /// User double-clicked — this track is priority, will play when ready.
133    PriorityPending,
134    Failed,
135}
136
137/// A single entry in the UI-visible queue snapshot.
138#[derive(Debug, Clone)]
139pub struct QueueEntry {
140    pub id: QueueItemId,
141    /// Database track ID — set for tracks loaded from DB, used for downloads.
142    pub db_id: Option<i64>,
143    pub path: PathBuf,
144    pub title: String,
145    pub artist: String,
146    pub album_artist: String,
147    pub album: String,
148    pub year: Option<String>,
149    pub codec: Option<String>,
150    pub track_number: Option<i64>,
151    pub disc: Option<i64>,
152    pub duration_ms: Option<u64>,
153    pub status: QueueEntryStatus,
154    pub download_progress: Option<(u64, u64)>,
155    /// Why this entry cannot play, when `status` is `Failed`.
156    pub error: Option<String>,
157}
158
159/// Pre-built visible queue — single atomic snapshot for the UI.
160#[derive(Debug, Clone, Default)]
161pub struct VisibleQueueSnapshot {
162    pub entries: Vec<QueueEntry>,
163    pub finished_count: usize,
164    pub has_playing: bool,
165    pub queue_count: usize,
166}
167
168/// Shared player state — atomics for lock-free reads from UI thread.
169///
170/// The engine writes these, the UI reads them. No mutexes in the hot path.
171#[derive(Debug)]
172pub struct SharedPlayerState {
173    state: AtomicU8,
174    position_ms: AtomicU64,
175    track_info: parking_lot::RwLock<Option<TrackInfo>>,
176
177    /// THE playlist + cursor — one lock, one truth.
178    playlist: parking_lot::RwLock<Playlist>,
179
180    /// Bumped on every playlist mutation so UI can skip redundant redraws.
181    playlist_version: AtomicU64,
182
183    /// Set by external signals (e.g. souvlaki Quit event) to request clean shutdown.
184    quit_requested: AtomicBool,
185
186    /// Set when metadata has been refreshed (e.g. download completed while streaming).
187    /// The UI loop checks this to force a souvlaki/cover-art update without a track change.
188    metadata_refresh_pending: AtomicBool,
189
190    /// The rate the output device settled at for the current track, or 0 when
191    /// nothing has played yet. Compared against the source rate, it is the one
192    /// thing koan can say for certain about the path to the DAC: whether it
193    /// handed the device the samples as they are, or something had to resample
194    /// to reach it. Everything past that — other clients, the volume stage — is
195    /// the system's, and not ours to claim.
196    output_sample_rate: AtomicU64,
197
198    /// Radio mode — automatically queue similar tracks when the queue runs low.
199    /// Shared so GQL/MCP can toggle it without going through the TUI.
200    radio_mode: AtomicBool,
201}
202
203impl SharedPlayerState {
204    pub fn new() -> Arc<Self> {
205        Arc::new(Self {
206            state: AtomicU8::new(PlaybackState::Stopped as u8),
207            position_ms: AtomicU64::new(0),
208            track_info: parking_lot::RwLock::new(None),
209            playlist: parking_lot::RwLock::new(Playlist::default()),
210            playlist_version: AtomicU64::new(0),
211            quit_requested: AtomicBool::new(false),
212            metadata_refresh_pending: AtomicBool::new(false),
213            output_sample_rate: AtomicU64::new(0),
214            radio_mode: AtomicBool::new(false),
215        })
216    }
217
218    // --- Playback state ---
219
220    pub fn playback_state(&self) -> PlaybackState {
221        PlaybackState::from_u8(self.state.load(Ordering::Acquire))
222    }
223
224    pub fn set_playback_state(&self, state: PlaybackState) {
225        self.state.store(state as u8, Ordering::Release);
226    }
227
228    pub fn position_ms(&self) -> u64 {
229        self.position_ms.load(Ordering::Acquire)
230    }
231
232    pub fn set_position_ms(&self, pos: u64) {
233        self.position_ms.store(pos, Ordering::Release);
234    }
235
236    pub fn track_info(&self) -> Option<TrackInfo> {
237        self.track_info.read().clone()
238    }
239
240    pub fn set_track_info(&self, info: Option<TrackInfo>) {
241        *self.track_info.write() = info;
242    }
243
244    /// Download fraction (0.0..1.0) for the currently playing track, if streaming.
245    /// Returns `None` for fully-downloaded or non-playing tracks.
246    pub fn current_download_fraction(&self) -> Option<f64> {
247        // Released before the playlist lock is taken: derive_visible_queue takes
248        // these two in the opposite order, so holding both would close a cycle.
249        let id = self.track_info.read().as_ref()?.id;
250        let pl = self.playlist.read();
251        pl.items
252            .iter()
253            .find(|item| item.id == id)
254            .and_then(|item| match &item.load_state {
255                LoadState::Downloading {
256                    bytes_written,
257                    total,
258                    ..
259                } => {
260                    let written = bytes_written.load(Ordering::Acquire);
261                    if *total > 0 {
262                        Some((written as f64 / *total as f64).min(1.0))
263                    } else {
264                        None
265                    }
266                }
267                _ => None,
268            })
269    }
270
271    // --- Quit ---
272
273    pub fn request_quit(&self) {
274        self.quit_requested.store(true, Ordering::Release);
275    }
276
277    pub fn quit_requested(&self) -> bool {
278        self.quit_requested.load(Ordering::Acquire)
279    }
280
281    // --- Metadata refresh (progressive enhancement) ---
282
283    /// Signal that metadata has been refreshed mid-stream (e.g. download completed).
284    /// The UI loop calls `take_metadata_refresh()` to consume this flag and
285    /// force a souvlaki/cover-art update without waiting for a track change.
286    pub fn signal_metadata_refresh(&self) {
287        self.metadata_refresh_pending.store(true, Ordering::Release);
288    }
289
290    /// Returns true and clears the flag if a metadata refresh is pending.
291    pub fn take_metadata_refresh(&self) -> bool {
292        self.metadata_refresh_pending
293            .compare_exchange(true, false, Ordering::AcqRel, Ordering::Acquire)
294            .is_ok()
295    }
296
297    // --- Radio mode ---
298
299    pub fn radio_mode(&self) -> bool {
300        self.radio_mode.load(Ordering::Acquire)
301    }
302
303    pub fn set_radio_mode(&self, enabled: bool) {
304        self.radio_mode.store(enabled, Ordering::Release);
305    }
306
307    // --- Output device rate ---
308
309    /// `None` until a track has started and the device rate is known.
310    pub fn output_sample_rate(&self) -> Option<u32> {
311        match self.output_sample_rate.load(Ordering::Acquire) {
312            0 => None,
313            rate => Some(rate as u32),
314        }
315    }
316
317    pub fn set_output_sample_rate(&self, rate: u32) {
318        self.output_sample_rate
319            .store(u64::from(rate), Ordering::Release);
320    }
321
322    // --- Playlist version ---
323
324    pub fn playlist_version(&self) -> u64 {
325        self.playlist_version.load(Ordering::Acquire)
326    }
327
328    fn bump_version(&self) {
329        self.playlist_version.fetch_add(1, Ordering::AcqRel);
330    }
331
332    // --- Playlist mutations (called from player thread via commands) ---
333
334    /// Append items to the playlist.
335    pub fn add_items(&self, items: Vec<PlaylistItem>) {
336        let mut pl = self.playlist.write();
337        pl.items.extend(items);
338        drop(pl);
339        self.bump_version();
340    }
341
342    /// Insert items after a specific queue item.
343    pub fn insert_items_after(&self, items: Vec<PlaylistItem>, after: QueueItemId) {
344        let mut pl = self.playlist.write();
345        let insert_at = match pl.items.iter().position(|item| item.id == after) {
346            Some(pos) => pos + 1,
347            None => pl.items.len(), // fallback: append
348        };
349        for (i, item) in items.into_iter().enumerate() {
350            pl.items.insert(insert_at + i, item);
351        }
352        drop(pl);
353        self.bump_version();
354    }
355
356    /// Update file paths for playlist items (after organize moves files).
357    pub fn update_paths(&self, updates: &[(QueueItemId, PathBuf)]) {
358        let mut pl = self.playlist.write();
359        for (id, new_path) in updates {
360            if let Some(item) = pl.items.iter_mut().find(|item| item.id == *id) {
361                item.path = new_path.clone();
362            }
363        }
364        drop(pl);
365        self.bump_version();
366    }
367
368    /// Remove an item by ID.
369    pub fn remove_item(&self, id: QueueItemId) {
370        let mut pl = self.playlist.write();
371        pl.items.retain(|item| item.id != id);
372        // If cursor was on removed item, clear it (caller handles next_track).
373        if pl.cursor == Some(id) {
374            pl.cursor = None;
375        }
376        drop(pl);
377        self.bump_version();
378    }
379
380    /// Move an item relative to another entry.
381    pub fn move_item(&self, id: QueueItemId, target: QueueItemId, after: bool) {
382        let mut pl = self.playlist.write();
383        let Some(from) = pl.items.iter().position(|item| item.id == id) else {
384            return;
385        };
386        let item = pl.items.remove(from);
387        let Some(to) = pl.items.iter().position(|item| item.id == target) else {
388            // Target gone — put it back.
389            let pos = from.min(pl.items.len());
390            pl.items.insert(pos, item);
391            return;
392        };
393        let insert_at = if after { to + 1 } else { to };
394        pl.items.insert(insert_at, item);
395        drop(pl);
396        self.bump_version();
397    }
398
399    /// Batch move: extract items by ID, reinsert them at `target` position.
400    /// Preserves the relative order of the moved items.
401    pub fn move_items(&self, ids: &[QueueItemId], target: QueueItemId, after: bool) {
402        use std::collections::HashSet;
403        let id_set: HashSet<QueueItemId> = ids.iter().copied().collect();
404
405        let mut pl = self.playlist.write();
406
407        // Partition: extract moved items, keep the rest.
408        let mut remaining = Vec::with_capacity(pl.items.len());
409        let mut moved = Vec::with_capacity(ids.len());
410        for item in pl.items.drain(..) {
411            if id_set.contains(&item.id) {
412                moved.push(item);
413            } else {
414                remaining.push(item);
415            }
416        }
417
418        // Find target in the remaining items.
419        let insert_at = match remaining.iter().position(|item| item.id == target) {
420            Some(pos) => {
421                if after {
422                    pos + 1
423                } else {
424                    pos
425                }
426            }
427            None => remaining.len(),
428        };
429
430        // Splice moved items in at the target position.
431        for (i, item) in moved.into_iter().enumerate() {
432            remaining.insert(insert_at + i, item);
433        }
434
435        pl.items = remaining;
436        drop(pl);
437        self.bump_version();
438    }
439
440    /// Set the cursor (what's playing / should play).
441    pub fn set_cursor(&self, id: Option<QueueItemId>) {
442        let mut pl = self.playlist.write();
443        pl.cursor = id;
444        drop(pl);
445        self.bump_version();
446    }
447
448    /// Get the current cursor ID.
449    pub fn cursor(&self) -> Option<QueueItemId> {
450        self.playlist.read().cursor
451    }
452
453    /// Clear the entire playlist + cursor.
454    pub fn clear_playlist(&self) {
455        let mut pl = self.playlist.write();
456        pl.items.clear();
457        pl.cursor = None;
458        drop(pl);
459        self.bump_version();
460    }
461
462    // --- Called from decode thread (gapless) ---
463
464    /// Move the cursor to the next item that can still play — the first item
465    /// after the cursor that is not `Failed` — and return its ID.
466    ///
467    /// An item that is still downloading parks the cursor rather than being
468    /// skipped, so playback resumes from it when its data lands. Skipping it
469    /// would drop it from the queue for good.
470    ///
471    /// With no cursor set, starts from the top. A cursor pointing at an item
472    /// that is no longer in the playlist yields `None` — restarting from the
473    /// top would silently replay the queue.
474    pub fn advance_cursor_loadable(&self) -> Option<QueueItemId> {
475        let mut pl = self.playlist.write();
476        let start = match pl.cursor {
477            Some(cid) => pl.items.iter().position(|item| item.id == cid)? + 1,
478            None => 0,
479        };
480
481        let next = pl
482            .items
483            .get(start..)?
484            .iter()
485            .find(|item| !matches!(item.load_state, LoadState::Failed(_)))
486            .map(|item| item.id)?;
487
488        pl.cursor = Some(next);
489        drop(pl);
490        self.bump_version();
491        Some(next)
492    }
493
494    /// Peek at the next Ready item after a given item ID WITHOUT moving the cursor.
495    /// Used by the decode thread for gapless lookahead — the cursor is moved
496    /// later by update_playback_state when playback actually reaches the track.
497    pub fn peek_next_ready_after(&self, after_id: QueueItemId) -> Option<(QueueItemId, PathBuf)> {
498        let pl = self.playlist.read();
499        // A reference item that has been removed means the lookahead has nothing
500        // to follow; starting from the top would gaplessly replay the queue.
501        let start = pl.items.iter().position(|item| item.id == after_id)? + 1;
502
503        for i in start..pl.items.len() {
504            if matches!(pl.items[i].load_state, LoadState::Ready) {
505                let item = &pl.items[i];
506                return Some((item.id, item.path.clone()));
507            }
508        }
509        None
510    }
511
512    /// Retreat cursor to the previous item. Returns (id, path) if found.
513    /// For prev_track — goes to the item before cursor regardless of load state.
514    pub fn retreat_cursor(&self) -> Option<(QueueItemId, PathBuf)> {
515        let mut pl = self.playlist.write();
516        let cursor_pos = match pl.cursor {
517            Some(cid) => pl.items.iter().position(|item| item.id == cid),
518            None => None,
519        };
520
521        let prev_pos = cursor_pos.and_then(|p| p.checked_sub(1));
522
523        match prev_pos {
524            Some(pos) => {
525                let item = &pl.items[pos];
526                let result = (item.id, item.path.clone());
527                pl.cursor = Some(item.id);
528                drop(pl);
529                self.bump_version();
530                Some(result)
531            }
532            None => None,
533        }
534    }
535
536    // --- Called from resolve thread ---
537
538    /// Update the load state of a playlist item. Safe — just a field update under lock.
539    pub fn update_load_state(&self, id: QueueItemId, new_state: LoadState) {
540        let mut pl = self.playlist.write();
541        if let Some(item) = pl.items.iter_mut().find(|item| item.id == id) {
542            item.load_state = new_state;
543        }
544        drop(pl);
545        self.bump_version();
546    }
547
548    /// Update playlist item metadata after a full download completes.
549    /// Used for progressive enhancement: streaming started with partial Symphonia tags,
550    /// now the full file is available so we can refresh with complete lofty metadata.
551    pub fn update_item_metadata(
552        &self,
553        id: QueueItemId,
554        title: String,
555        artist: String,
556        album_artist: String,
557        album: String,
558        duration_ms: Option<u64>,
559    ) {
560        let mut pl = self.playlist.write();
561        if let Some(item) = pl.items.iter_mut().find(|item| item.id == id) {
562            item.title = title;
563            item.artist = artist;
564            item.album_artist = album_artist;
565            item.album = album;
566            if let Some(dur) = duration_ms {
567                item.duration_ms = Some(dur);
568            }
569        }
570        drop(pl);
571        self.bump_version();
572    }
573
574    /// Get the playback source for an item if it's ready to play.
575    /// Returns `None` if not enough data is available yet.
576    pub fn item_playback_source(&self, id: QueueItemId) -> Option<PlaybackSource> {
577        let pl = self.playlist.read();
578        pl.items
579            .iter()
580            .find(|item| item.id == id)
581            .and_then(|item| match &item.load_state {
582                LoadState::Ready => Some(PlaybackSource::Ready(item.path.clone())),
583                LoadState::Downloading {
584                    total,
585                    bytes_written,
586                    ..
587                } => {
588                    let written = bytes_written.load(Ordering::Acquire);
589                    if written >= STREAM_THRESHOLD {
590                        Some(PlaybackSource::Streaming {
591                            path: item.path.clone(),
592                            bytes_written: bytes_written.clone(),
593                            total: *total,
594                        })
595                    } else {
596                        None
597                    }
598                }
599                _ => None,
600            })
601    }
602
603    /// Get the path of an item if it's Ready (legacy convenience — use item_playback_source for streaming).
604    pub fn item_path_if_ready(&self, id: QueueItemId) -> Option<PathBuf> {
605        let pl = self.playlist.read();
606        pl.items.iter().find(|item| item.id == id).and_then(|item| {
607            if matches!(item.load_state, LoadState::Ready) {
608                Some(item.path.clone())
609            } else {
610                None
611            }
612        })
613    }
614
615    /// Check if the cursor is on the given item.
616    pub fn is_cursor(&self, id: QueueItemId) -> bool {
617        self.playlist.read().cursor == Some(id)
618    }
619
620    /// Get QueueItemIds of all playlist items sharing the same album as the given item.
621    /// Matches on both album name and album artist to avoid false positives
622    /// (e.g. two different "Greatest Hits" by different artists).
623    pub fn same_album_item_ids(&self, id: QueueItemId) -> Vec<QueueItemId> {
624        let pl = self.playlist.read();
625        let Some(cursor) = pl.items.iter().find(|item| item.id == id) else {
626            return vec![];
627        };
628        let album = cursor.album.clone();
629        let album_artist = cursor.album_artist.clone();
630        pl.items
631            .iter()
632            .filter(|item| {
633                item.id != id && item.album == album && item.album_artist == album_artist
634            })
635            .map(|item| item.id)
636            .collect()
637    }
638
639    /// Get all playlist items that are Pending and have a db_id.
640    /// Returns `(db_id, QueueItemId)` pairs suitable for the download queue.
641    pub fn pending_downloads(&self) -> Vec<(i64, QueueItemId)> {
642        let pl = self.playlist.read();
643        pl.items
644            .iter()
645            .filter(|item| matches!(item.load_state, LoadState::Pending))
646            .filter_map(|item| item.db_id.map(|db_id| (db_id, item.id)))
647            .collect()
648    }
649
650    /// Every item mid-transfer, with the bytes it has and the bytes it expects.
651    ///
652    /// Progress moves without the playlist version moving — the download thread
653    /// writes the byte counter directly — so anything following the version
654    /// alone shows a frozen bar. This is how a watcher sees it move.
655    pub fn downloads_in_flight(&self) -> Vec<(QueueItemId, u64, u64)> {
656        let pl = self.playlist.read();
657        pl.items
658            .iter()
659            .filter_map(|item| match &item.load_state {
660                LoadState::Downloading {
661                    total,
662                    bytes_written,
663                } => Some((item.id, bytes_written.load(Ordering::Relaxed), *total)),
664                _ => None,
665            })
666            .collect()
667    }
668
669    /// Get the db_id for a specific playlist item.
670    pub fn item_db_id(&self, id: QueueItemId) -> Option<i64> {
671        let pl = self.playlist.read();
672        pl.items
673            .iter()
674            .find(|item| item.id == id)
675            .and_then(|item| item.db_id)
676    }
677
678    /// Get the load state of a specific playlist item.
679    pub fn item_load_state(&self, id: QueueItemId) -> Option<LoadState> {
680        let pl = self.playlist.read();
681        pl.items
682            .iter()
683            .find(|item| item.id == id)
684            .map(|item| item.load_state.clone())
685    }
686
687    // --- Snapshot helpers for undo ---
688
689    /// Get the full playlist snapshot (items + cursor) for undo of ClearPlaylist.
690    pub fn snapshot_playlist(&self) -> (Vec<PlaylistItem>, Option<QueueItemId>) {
691        let pl = self.playlist.read();
692        (pl.items.clone(), pl.cursor)
693    }
694
695    /// Get an item by ID (for undo of RemoveFromPlaylist).
696    pub fn get_item(&self, id: QueueItemId) -> Option<PlaylistItem> {
697        let pl = self.playlist.read();
698        pl.items.iter().find(|item| item.id == id).cloned()
699    }
700
701    /// Get the ID of the item immediately before the given ID (None if first).
702    pub fn item_before(&self, id: QueueItemId) -> Option<QueueItemId> {
703        let pl = self.playlist.read();
704        let pos = pl.items.iter().position(|item| item.id == id)?;
705        if pos == 0 {
706            None
707        } else {
708            Some(pl.items[pos - 1].id)
709        }
710    }
711
712    /// For each ID, the ID of the item before it (or None if first), returned in
713    /// playlist order regardless of the order `ids` arrives in.
714    ///
715    /// Undo replays these left to right, so an item whose recorded predecessor is
716    /// also in `ids` must come after it — otherwise the predecessor is missing at
717    /// replay time and the item lands at the end of the playlist instead.
718    pub fn items_before(&self, ids: &[QueueItemId]) -> Vec<(QueueItemId, Option<QueueItemId>)> {
719        use std::collections::HashSet;
720        let wanted: HashSet<QueueItemId> = ids.iter().copied().collect();
721        let pl = self.playlist.read();
722        pl.items
723            .iter()
724            .enumerate()
725            .filter(|(_, item)| wanted.contains(&item.id))
726            .map(|(pos, item)| {
727                let before = if pos == 0 {
728                    None
729                } else {
730                    Some(pl.items[pos - 1].id)
731                };
732                (item.id, before)
733            })
734            .collect()
735    }
736
737    /// The nearest item before `id` that is not itself being removed — where
738    /// playback resumes from after a batch delete that takes out the cursor.
739    /// `None` means resume from the top of what survives.
740    pub fn surviving_item_before(
741        &self,
742        id: QueueItemId,
743        removed: &[QueueItemId],
744    ) -> Option<QueueItemId> {
745        use std::collections::HashSet;
746        let removed: HashSet<QueueItemId> = removed.iter().copied().collect();
747        let pl = self.playlist.read();
748        let pos = pl.items.iter().position(|item| item.id == id)?;
749        pl.items[..pos]
750            .iter()
751            .rev()
752            .find(|item| !removed.contains(&item.id))
753            .map(|item| item.id)
754    }
755
756    /// Restore a full playlist from snapshot (for redo of ClearPlaylist undo).
757    pub fn restore_playlist(&self, items: Vec<PlaylistItem>, cursor: Option<QueueItemId>) {
758        let mut pl = self.playlist.write();
759        pl.items = items;
760        pl.cursor = cursor;
761        drop(pl);
762        self.bump_version();
763    }
764
765    /// Remove multiple items by IDs.
766    pub fn remove_items(&self, ids: &[QueueItemId]) {
767        use std::collections::HashSet;
768        let id_set: HashSet<QueueItemId> = ids.iter().copied().collect();
769        let mut pl = self.playlist.write();
770        pl.items.retain(|item| !id_set.contains(&item.id));
771        if let Some(cursor) = pl.cursor
772            && id_set.contains(&cursor)
773        {
774            pl.cursor = None;
775        }
776        drop(pl);
777        self.bump_version();
778    }
779
780    /// Insert a single item after a given ID (or at front if None).
781    pub fn insert_item_at(&self, item: PlaylistItem, after: Option<QueueItemId>) {
782        let mut pl = self.playlist.write();
783        let insert_at = match after {
784            Some(after_id) => {
785                match pl.items.iter().position(|i| i.id == after_id) {
786                    Some(pos) => pos + 1,
787                    None => pl.items.len(), // fallback
788                }
789            }
790            None => 0,
791        };
792        pl.items.insert(insert_at, item);
793        drop(pl);
794        self.bump_version();
795    }
796
797    /// Move a single item to after `after` (or to front if None).
798    pub fn move_item_to(&self, id: QueueItemId, after: Option<QueueItemId>) {
799        let mut pl = self.playlist.write();
800        let Some(from) = pl.items.iter().position(|item| item.id == id) else {
801            return;
802        };
803        let item = pl.items.remove(from);
804        let insert_at = match after {
805            Some(after_id) => match pl.items.iter().position(|i| i.id == after_id) {
806                Some(pos) => pos + 1,
807                None => pl.items.len(),
808            },
809            None => 0,
810        };
811        pl.items.insert(insert_at, item);
812        drop(pl);
813        self.bump_version();
814    }
815
816    /// Batch move: reposition each item to after its given predecessor.
817    /// Processes in order so earlier insertions don't corrupt later positions.
818    pub fn move_items_to(&self, entries: &[(QueueItemId, Option<QueueItemId>)]) {
819        for &(id, after) in entries {
820            self.move_item_to(id, after);
821        }
822    }
823
824    // --- Called from UI thread (read lock) ---
825
826    /// Derive the visible queue from the playlist + cursor. O(n).
827    /// Called once per UI tick.
828    pub fn derive_visible_queue(&self) -> VisibleQueueSnapshot {
829        // Read before the playlist lock — see current_download_fraction.
830        let playing_duration_ms = self.track_info.read().as_ref().map(|ti| ti.duration_ms);
831        let pl = self.playlist.read();
832
833        let cursor_pos = match pl.cursor {
834            Some(cid) => pl.items.iter().position(|item| item.id == cid),
835            None => None,
836        };
837
838        let mut entries = Vec::with_capacity(pl.items.len());
839        let mut finished_count = 0;
840        let mut has_playing = false;
841        let mut queue_count = 0;
842
843        for (i, item) in pl.items.iter().enumerate() {
844            let is_cursor = cursor_pos == Some(i);
845            let is_before_cursor = cursor_pos.is_some_and(|cp| i < cp);
846
847            // Byte count comes from the shared atomic, not from the load
848            // state's own copy: the download thread writes it per chunk
849            // without taking the playlist lock, which is what keeps a
850            // transfer from bumping the playlist version a thousand times.
851            let dl_progress = match &item.load_state {
852                LoadState::Downloading {
853                    total,
854                    bytes_written,
855                    ..
856                } => Some((bytes_written.load(Ordering::Relaxed), *total)),
857                _ => None,
858            };
859
860            let status = if is_cursor {
861                has_playing = true;
862                match &item.load_state {
863                    LoadState::Ready => QueueEntryStatus::Playing,
864                    LoadState::Downloading { .. } => QueueEntryStatus::PriorityPending,
865                    LoadState::Pending => QueueEntryStatus::PriorityPending,
866                    LoadState::Failed(_) => QueueEntryStatus::Failed,
867                }
868            } else if is_before_cursor {
869                finished_count += 1;
870                match &item.load_state {
871                    LoadState::Ready => QueueEntryStatus::Played,
872                    LoadState::Downloading { .. } => QueueEntryStatus::Downloading,
873                    LoadState::Pending => QueueEntryStatus::Downloading,
874                    LoadState::Failed(_) => QueueEntryStatus::Failed,
875                }
876            } else {
877                queue_count += 1;
878                match &item.load_state {
879                    LoadState::Ready => QueueEntryStatus::Queued,
880                    LoadState::Downloading { .. } => QueueEntryStatus::Downloading,
881                    LoadState::Pending => QueueEntryStatus::Downloading,
882                    LoadState::Failed(_) => QueueEntryStatus::Failed,
883                }
884            };
885
886            // Override duration from TrackInfo if we have it and this is playing.
887            let duration_ms =
888                if has_playing && status == QueueEntryStatus::Playing && item.duration_ms.is_none()
889                {
890                    playing_duration_ms
891                } else {
892                    item.duration_ms
893                };
894
895            entries.push(QueueEntry {
896                id: item.id,
897                db_id: item.db_id,
898                path: item.path.clone(),
899                title: item.title.clone(),
900                artist: item.artist.clone(),
901                album_artist: item.album_artist.clone(),
902                album: item.album.clone(),
903                year: item.year.clone(),
904                codec: item.codec.clone(),
905                track_number: item.track_number,
906                disc: item.disc,
907                duration_ms,
908                status,
909                download_progress: dl_progress,
910                error: match &item.load_state {
911                    LoadState::Failed(reason) => Some(reason.clone()),
912                    _ => None,
913                },
914            });
915        }
916
917        VisibleQueueSnapshot {
918            entries,
919            finished_count,
920            has_playing,
921            queue_count,
922        }
923    }
924}
925
926#[cfg(test)]
927mod tests {
928    use super::*;
929
930    // --- helpers ---
931
932    fn make_item(title: &str, load_state: LoadState) -> PlaylistItem {
933        PlaylistItem {
934            id: QueueItemId::new(),
935            db_id: None,
936            path: PathBuf::from(format!("/music/{title}.flac")),
937            title: title.to_string(),
938            artist: "Artist".to_string(),
939            album_artist: "Artist".to_string(),
940            album: "Album".to_string(),
941            year: None,
942            codec: Some("FLAC".to_string()),
943            track_number: None,
944            disc: None,
945            duration_ms: Some(200_000),
946            load_state,
947        }
948    }
949
950    fn ready_item(title: &str) -> PlaylistItem {
951        make_item(title, LoadState::Ready)
952    }
953
954    fn pending_item(title: &str) -> PlaylistItem {
955        make_item(title, LoadState::Pending)
956    }
957
958    fn failed_item(title: &str) -> PlaylistItem {
959        make_item(title, LoadState::Failed("nope".into()))
960    }
961
962    // --- advance_cursor_loadable ---
963
964    #[test]
965    fn advance_parks_on_a_still_downloading_track() {
966        // A track that has not arrived yet must hold the cursor, not be skipped:
967        // skipping it drops it from the queue for good, and it is the item whose
968        // TrackReady has to resume playback.
969        let state = SharedPlayerState::new();
970        let item0 = ready_item("track-0");
971        let item1 = pending_item("track-1");
972        let item2 = ready_item("track-2");
973        let (id0, id1) = (item0.id, item1.id);
974
975        state.add_items(vec![item0, item1, item2]);
976
977        assert_eq!(state.advance_cursor_loadable(), Some(id0));
978        assert_eq!(state.advance_cursor_loadable(), Some(id1));
979        assert_eq!(state.cursor(), Some(id1));
980    }
981
982    #[test]
983    fn advance_skips_failed_items() {
984        let state = SharedPlayerState::new();
985        let item0 = ready_item("track-0");
986        let item1 = failed_item("track-1");
987        let item2 = ready_item("track-2");
988        let (id0, id2) = (item0.id, item2.id);
989
990        state.add_items(vec![item0, item1, item2]);
991        state.set_cursor(Some(id0));
992
993        assert_eq!(state.advance_cursor_loadable(), Some(id2));
994    }
995
996    #[test]
997    fn advance_stops_at_end_of_playlist() {
998        let state = SharedPlayerState::new();
999        let item0 = ready_item("track-0");
1000        let item1 = ready_item("track-1");
1001        let id1 = item1.id;
1002
1003        state.add_items(vec![item0, item1]);
1004        state.set_cursor(Some(id1));
1005
1006        assert_eq!(state.advance_cursor_loadable(), None);
1007        assert_eq!(
1008            state.cursor(),
1009            Some(id1),
1010            "cursor unchanged on a failed advance"
1011        );
1012    }
1013
1014    #[test]
1015    fn advance_with_only_failed_items_returns_none() {
1016        let state = SharedPlayerState::new();
1017        state.add_items(vec![failed_item("bad-0"), failed_item("bad-1")]);
1018
1019        assert_eq!(state.advance_cursor_loadable(), None);
1020    }
1021
1022    #[test]
1023    fn advance_from_a_vanished_cursor_does_not_restart_the_queue() {
1024        let state = SharedPlayerState::new();
1025        let item0 = ready_item("track-0");
1026        let item1 = ready_item("track-1");
1027        let id0 = item0.id;
1028
1029        state.add_items(vec![item0, item1]);
1030        let ghost = QueueItemId::new();
1031        state.set_cursor(Some(ghost));
1032
1033        assert_eq!(state.advance_cursor_loadable(), None);
1034        assert_ne!(state.cursor(), Some(id0));
1035    }
1036
1037    // --- peek_next_ready_after ---
1038
1039    #[test]
1040    fn peek_after_a_removed_item_returns_none() {
1041        // The decode thread's lookahead runs seconds ahead of what is audible.
1042        // Removing the track it is pre-decoding must end the lookahead, not send
1043        // it back to the top of the queue.
1044        let state = SharedPlayerState::new();
1045        let item0 = ready_item("track-0");
1046        let item1 = ready_item("track-1");
1047        let item2 = ready_item("track-2");
1048        let (id0, id1, id2) = (item0.id, item1.id, item2.id);
1049
1050        state.add_items(vec![item0, item1, item2]);
1051        assert_eq!(
1052            state.peek_next_ready_after(id1).map(|(id, _)| id),
1053            Some(id2)
1054        );
1055
1056        state.remove_item(id2);
1057        assert!(
1058            state.peek_next_ready_after(id2).is_none(),
1059            "a vanished reference must not resolve to the head of the queue"
1060        );
1061        assert_ne!(
1062            state.peek_next_ready_after(id2).map(|(id, _)| id),
1063            Some(id0)
1064        );
1065    }
1066
1067    // --- surviving_item_before ---
1068
1069    #[test]
1070    fn surviving_predecessor_skips_items_being_removed() {
1071        let state = SharedPlayerState::new();
1072        let items: Vec<_> = (0..4).map(|i| ready_item(&format!("track-{i}"))).collect();
1073        let ids: Vec<_> = items.iter().map(|i| i.id).collect();
1074        state.add_items(items);
1075
1076        // Deleting 1..=3 leaves 0 as the resume point for a cursor on 3.
1077        assert_eq!(
1078            state.surviving_item_before(ids[3], &ids[1..4]),
1079            Some(ids[0])
1080        );
1081        // Deleting everything from the top leaves nothing to resume after.
1082        assert_eq!(state.surviving_item_before(ids[2], &ids), None);
1083    }
1084
1085    // --- retreat_cursor ---
1086
1087    #[test]
1088    fn test_retreat_cursor_goes_to_previous_item() {
1089        let state = SharedPlayerState::new();
1090        let item0 = ready_item("track-0");
1091        let item1 = ready_item("track-1");
1092        let id0 = item0.id;
1093        let id1 = item1.id;
1094
1095        state.add_items(vec![item0, item1]);
1096        state.set_cursor(Some(id1));
1097
1098        let result = state.retreat_cursor();
1099        assert!(result.is_some(), "expected to retreat to previous item");
1100        assert_eq!(result.unwrap().0, id0, "should retreat to first item");
1101        assert_eq!(state.cursor(), Some(id0));
1102    }
1103
1104    #[test]
1105    fn test_retreat_cursor_returns_none_when_at_first_item() {
1106        let state = SharedPlayerState::new();
1107        let item0 = ready_item("only-track");
1108        let id0 = item0.id;
1109
1110        state.add_items(vec![item0]);
1111        state.set_cursor(Some(id0));
1112
1113        let result = state.retreat_cursor();
1114        assert!(result.is_none(), "cannot retreat before the first item");
1115        // Cursor stays on the first item.
1116        assert_eq!(state.cursor(), Some(id0));
1117    }
1118
1119    #[test]
1120    fn test_retreat_cursor_returns_none_when_cursor_is_unset() {
1121        let state = SharedPlayerState::new();
1122        state.add_items(vec![ready_item("track-0")]);
1123
1124        let result = state.retreat_cursor();
1125        assert!(
1126            result.is_none(),
1127            "retreat with no cursor should return None"
1128        );
1129    }
1130
1131    // --- derive_visible_queue ---
1132
1133    #[test]
1134    fn test_derive_visible_queue_statuses() {
1135        // playlist: [played, playing, queued]
1136        let state = SharedPlayerState::new();
1137        let item0 = ready_item("played-track");
1138        let item1 = ready_item("playing-track");
1139        let item2 = ready_item("queued-track");
1140        let id1 = item1.id;
1141
1142        state.add_items(vec![item0, item1, item2]);
1143        state.set_cursor(Some(id1));
1144
1145        let snap = state.derive_visible_queue();
1146
1147        assert_eq!(snap.entries.len(), 3);
1148        assert_eq!(snap.entries[0].status, QueueEntryStatus::Played);
1149        assert_eq!(snap.entries[1].status, QueueEntryStatus::Playing);
1150        assert_eq!(snap.entries[2].status, QueueEntryStatus::Queued);
1151        assert!(snap.has_playing);
1152        assert_eq!(snap.finished_count, 1);
1153        assert_eq!(snap.queue_count, 1);
1154    }
1155
1156    #[test]
1157    fn test_derive_visible_queue_downloading_statuses() {
1158        // A Downloading item at cursor → PriorityPending; after cursor → Downloading.
1159        let state = SharedPlayerState::new();
1160        let bytes_cursor = Arc::new(AtomicU64::new(0));
1161        let bytes_queued = Arc::new(AtomicU64::new(0));
1162        let dl_cursor = make_item(
1163            "downloading-at-cursor",
1164            LoadState::Downloading {
1165                total: 1_000_000,
1166                bytes_written: bytes_cursor.clone(),
1167            },
1168        );
1169        let dl_queued = make_item(
1170            "downloading-queued",
1171            LoadState::Downloading {
1172                total: 500_000,
1173                bytes_written: bytes_queued.clone(),
1174            },
1175        );
1176        let id_cursor = dl_cursor.id;
1177
1178        state.add_items(vec![dl_cursor, dl_queued]);
1179        state.set_cursor(Some(id_cursor));
1180
1181        let snap = state.derive_visible_queue();
1182
1183        assert_eq!(snap.entries[0].status, QueueEntryStatus::PriorityPending);
1184        assert_eq!(snap.entries[1].status, QueueEntryStatus::Downloading);
1185    }
1186
1187    #[test]
1188    fn progress_follows_the_counter_without_touching_the_playlist() {
1189        // The download thread writes bytes and nothing else. A queue derived
1190        // afterwards must see them — the version has not moved, and the load
1191        // state it was given is the one it still holds.
1192        let state = SharedPlayerState::new();
1193        let bytes = Arc::new(AtomicU64::new(0));
1194        let item = make_item(
1195            "downloading",
1196            LoadState::Downloading {
1197                total: 1_000,
1198                bytes_written: bytes.clone(),
1199            },
1200        );
1201        state.add_items(vec![item]);
1202
1203        let version = state.playlist_version();
1204        bytes.store(250, Ordering::Release);
1205
1206        let snap = state.derive_visible_queue();
1207        assert_eq!(snap.entries[0].download_progress, Some((250, 1_000)));
1208        assert_eq!(
1209            state.playlist_version(),
1210            version,
1211            "progress must not read as a queue mutation"
1212        );
1213        assert_eq!(
1214            state.downloads_in_flight(),
1215            vec![(snap.entries[0].id, 250, 1_000)]
1216        );
1217    }
1218
1219    #[test]
1220    fn test_derive_visible_queue_no_cursor_all_queued() {
1221        let state = SharedPlayerState::new();
1222        state.add_items(vec![ready_item("a"), ready_item("b"), ready_item("c")]);
1223
1224        let snap = state.derive_visible_queue();
1225
1226        assert_eq!(snap.entries.len(), 3);
1227        for entry in &snap.entries {
1228            assert_eq!(entry.status, QueueEntryStatus::Queued);
1229        }
1230        assert!(!snap.has_playing);
1231        assert_eq!(snap.finished_count, 0);
1232        assert_eq!(snap.queue_count, 3);
1233    }
1234
1235    // --- same_album_item_ids ---
1236
1237    fn make_album_item(title: &str, album: &str, album_artist: &str) -> PlaylistItem {
1238        PlaylistItem {
1239            id: QueueItemId::new(),
1240            db_id: None,
1241            path: PathBuf::from(format!("/music/{title}.flac")),
1242            title: title.to_string(),
1243            artist: "Artist".to_string(),
1244            album_artist: album_artist.to_string(),
1245            album: album.to_string(),
1246            year: None,
1247            codec: Some("FLAC".to_string()),
1248            track_number: None,
1249            disc: None,
1250            duration_ms: Some(200_000),
1251            load_state: LoadState::Ready,
1252        }
1253    }
1254
1255    #[test]
1256    fn test_same_album_item_ids_returns_album_mates() {
1257        let state = SharedPlayerState::new();
1258        let a1 = make_album_item("A1", "Album A", "Artist A");
1259        let a2 = make_album_item("A2", "Album A", "Artist A");
1260        let b1 = make_album_item("B1", "Album B", "Artist B");
1261        let a3 = make_album_item("A3", "Album A", "Artist A");
1262
1263        let id_a1 = a1.id;
1264        let id_a2 = a2.id;
1265        let id_a3 = a3.id;
1266
1267        state.add_items(vec![a1, a2, b1, a3]);
1268
1269        let mates = state.same_album_item_ids(id_a1);
1270        assert_eq!(mates.len(), 2);
1271        assert!(mates.contains(&id_a2));
1272        assert!(mates.contains(&id_a3));
1273    }
1274
1275    #[test]
1276    fn test_same_album_item_ids_distinguishes_album_artists() {
1277        // Two albums named the same but by different artists — should NOT match.
1278        let state = SharedPlayerState::new();
1279        let a1 = make_album_item("A1", "Greatest Hits", "Artist A");
1280        let b1 = make_album_item("B1", "Greatest Hits", "Artist B");
1281
1282        let id_a1 = a1.id;
1283
1284        state.add_items(vec![a1, b1]);
1285
1286        let mates = state.same_album_item_ids(id_a1);
1287        assert!(mates.is_empty(), "different album_artist should not match");
1288    }
1289
1290    #[test]
1291    fn test_same_album_item_ids_unknown_id_returns_empty() {
1292        let state = SharedPlayerState::new();
1293        state.add_items(vec![ready_item("track-0")]);
1294
1295        let bogus = QueueItemId::new();
1296        let mates = state.same_album_item_ids(bogus);
1297        assert!(mates.is_empty());
1298    }
1299
1300    // --- move_item_to ---
1301
1302    #[test]
1303    fn test_move_item_to_reorders_playlist() {
1304        // Start: [A, B, C]. Move C to after A → [A, C, B].
1305        let state = SharedPlayerState::new();
1306        let item_a = ready_item("A");
1307        let item_b = ready_item("B");
1308        let item_c = ready_item("C");
1309        let id_a = item_a.id;
1310        let id_b = item_b.id;
1311        let id_c = item_c.id;
1312
1313        state.add_items(vec![item_a, item_b, item_c]);
1314        state.move_item_to(id_c, Some(id_a));
1315
1316        let (items, _) = state.snapshot_playlist();
1317        let titles: Vec<&str> = items.iter().map(|i| i.title.as_str()).collect();
1318        assert_eq!(titles, vec!["A", "C", "B"]);
1319        assert_eq!(items[0].id, id_a);
1320        assert_eq!(items[1].id, id_c);
1321        assert_eq!(items[2].id, id_b);
1322    }
1323
1324    #[test]
1325    fn test_move_item_to_front_when_after_is_none() {
1326        // Start: [A, B, C]. Move C to front (after=None) → [C, A, B].
1327        let state = SharedPlayerState::new();
1328        let item_a = ready_item("A");
1329        let item_b = ready_item("B");
1330        let item_c = ready_item("C");
1331        let id_c = item_c.id;
1332
1333        state.add_items(vec![item_a, item_b, item_c]);
1334        state.move_item_to(id_c, None);
1335
1336        let (items, _) = state.snapshot_playlist();
1337        let titles: Vec<&str> = items.iter().map(|i| i.title.as_str()).collect();
1338        assert_eq!(titles, vec!["C", "A", "B"]);
1339    }
1340
1341    // --- move_items (batch) ---
1342
1343    #[test]
1344    fn test_move_items_batch_preserves_relative_order() {
1345        // Start: [A, B, C, D]. Move [A, C] after D → [B, D, A, C].
1346        let state = SharedPlayerState::new();
1347        let item_a = ready_item("A");
1348        let item_b = ready_item("B");
1349        let item_c = ready_item("C");
1350        let item_d = ready_item("D");
1351        let id_a = item_a.id;
1352        let id_b = item_b.id;
1353        let id_c = item_c.id;
1354        let id_d = item_d.id;
1355
1356        state.add_items(vec![item_a, item_b, item_c, item_d]);
1357        state.move_items(&[id_a, id_c], id_d, true);
1358
1359        let (items, _) = state.snapshot_playlist();
1360        let titles: Vec<&str> = items.iter().map(|i| i.title.as_str()).collect();
1361        assert_eq!(titles, vec!["B", "D", "A", "C"]);
1362        assert_eq!(items[0].id, id_b);
1363        assert_eq!(items[1].id, id_d);
1364        assert_eq!(items[2].id, id_a);
1365        assert_eq!(items[3].id, id_c);
1366    }
1367
1368    // --- pending_downloads ---
1369
1370    #[test]
1371    fn test_pending_downloads_collects_pending_with_db_id() {
1372        let state = SharedPlayerState::new();
1373        let mut item_a = ready_item("local");
1374        item_a.db_id = None;
1375
1376        let mut item_b = pending_item("remote-1");
1377        item_b.db_id = Some(10);
1378        let id_b = item_b.id;
1379
1380        let mut item_c = ready_item("cached");
1381        item_c.db_id = Some(20);
1382
1383        let mut item_d = pending_item("remote-2");
1384        item_d.db_id = Some(30);
1385        let id_d = item_d.id;
1386
1387        // Pending without db_id — should NOT appear (no way to download).
1388        let item_e = pending_item("orphan");
1389
1390        state.add_items(vec![item_a, item_b, item_c, item_d, item_e]);
1391
1392        let pending = state.pending_downloads();
1393        assert_eq!(pending.len(), 2);
1394        assert_eq!(pending[0], (10, id_b));
1395        assert_eq!(pending[1], (30, id_d));
1396    }
1397
1398    #[test]
1399    fn test_item_db_id_and_load_state() {
1400        let state = SharedPlayerState::new();
1401        let mut item = pending_item("track");
1402        item.db_id = Some(42);
1403        let id = item.id;
1404        state.add_items(vec![item]);
1405
1406        assert_eq!(state.item_db_id(id), Some(42));
1407        assert!(matches!(
1408            state.item_load_state(id),
1409            Some(LoadState::Pending)
1410        ));
1411
1412        state.update_load_state(id, LoadState::Ready);
1413        assert!(matches!(state.item_load_state(id), Some(LoadState::Ready)));
1414    }
1415}