Skip to main content

koan_core/remote/
queue.rs

1use std::collections::{HashMap, HashSet, VecDeque};
2use std::panic::AssertUnwindSafe;
3use std::sync::{Arc, Mutex as StdMutex};
4
5use parking_lot::{Condvar, Mutex};
6
7use crate::config;
8use crate::player::commands::PlayerCommand;
9use crate::player::state::{LoadState, QueueItemId, SharedPlayerState};
10use crate::remote::client::SubsonicClient;
11
12use crate::helpers::download_track;
13
14/// Concurrent downloads the priority lane may run outside the worker pool.
15/// Small on purpose: its job is to get the track under the cursor playing, and
16/// every extra request competes with it for the same link.
17const PRIORITY_PERMITS: usize = 2;
18
19/// How often the cursor is sampled for priority reordering.
20const CURSOR_POLL: std::time::Duration = std::time::Duration::from_millis(30);
21
22/// Persistent download queue — lives for the app's lifetime.
23///
24/// Items are submitted via `enqueue()` and downloaded by a fixed pool of worker
25/// threads. Cursor changes reorder the queue so the current track downloads
26/// first, followed by same-album tracks for gapless playback; those jump the
27/// queue through a permit-limited priority lane rather than by spawning
28/// unbounded threads.
29#[derive(Clone)]
30pub struct DownloadQueue {
31    inner: Arc<Inner>,
32}
33
34struct Inner {
35    queue: Mutex<Queue>,
36    has_work: Condvar,
37    state: Arc<SharedPlayerState>,
38    cmd_tx: crossbeam_channel::Sender<PlayerCommand>,
39    log_buf: Arc<StdMutex<Vec<String>>>,
40    cfg: config::Config,
41    /// `None` when remote is not configured — nothing is downloadable.
42    client: Option<Arc<SubsonicClient>>,
43}
44
45/// Queue state and the in-flight bookkeeping that keeps a track from being
46/// downloaded by two threads at once.
47#[derive(Default)]
48struct Queue {
49    pending: VecDeque<(i64, QueueItemId)>,
50    /// Tracks being fetched, and every queue entry waiting on each.
51    ///
52    /// Keyed by track, because the track decides which file the download
53    /// writes. Keyed by queue entry it did not dedupe anything that mattered:
54    /// playing something a second time before it had arrived made a new entry
55    /// with a new id, so nothing matched and a second transfer started over
56    /// the first — two threads truncating and writing one `.part`, and
57    /// whichever finished first renaming it out from under the other.
58    in_flight: HashMap<i64, HashSet<QueueItemId>>,
59    priority_active: usize,
60}
61
62/// What a priority request should do, given the state of the lane.
63#[derive(Debug, PartialEq, Eq)]
64enum Dispatch {
65    /// A permit was taken and the item claimed — spawn a thread for it.
66    Spawn,
67    /// No permit free; the item now sits at the head of the work queue.
68    Requeued,
69    /// Some thread is already downloading it.
70    AlreadyRunning,
71}
72
73/// Claim `item` for the priority lane, or push it to the front of the queue if
74/// every permit is taken. On `Spawn` the caller owns the claim and must release
75/// it via `release_priority` when the download ends.
76fn claim_priority(q: &mut Queue, item: (i64, QueueItemId)) -> Dispatch {
77    let (db_id, queue_id) = item;
78    q.pending.retain(|(_, qid)| *qid != queue_id);
79
80    // Already being fetched: wait on it rather than fetch it again. The entry
81    // is remembered so it gets the answer when the one transfer lands.
82    if let Some(waiting) = q.in_flight.get_mut(&db_id) {
83        waiting.insert(queue_id);
84        return Dispatch::AlreadyRunning;
85    }
86    if q.priority_active >= PRIORITY_PERMITS {
87        q.pending.push_front(item);
88        return Dispatch::Requeued;
89    }
90    q.priority_active += 1;
91    q.in_flight.insert(db_id, HashSet::from([queue_id]));
92    Dispatch::Spawn
93}
94
95fn release_priority(q: &mut Queue, db_id: i64) {
96    q.in_flight.remove(&db_id);
97    q.priority_active = q.priority_active.saturating_sub(1);
98}
99
100/// Releases an in-flight claim however the download ends — including a panic.
101struct Claim {
102    inner: Arc<Inner>,
103    db_id: i64,
104    priority: bool,
105}
106
107impl Drop for Claim {
108    fn drop(&mut self) {
109        let mut q = self.inner.queue.lock();
110        if self.priority {
111            release_priority(&mut q, self.db_id);
112        } else {
113            q.in_flight.remove(&self.db_id);
114        }
115    }
116}
117
118impl DownloadQueue {
119    /// Spawn the download queue with persistent worker threads.
120    pub fn spawn(
121        cmd_tx: crossbeam_channel::Sender<PlayerCommand>,
122        state: Arc<SharedPlayerState>,
123        log_buf: Arc<StdMutex<Vec<String>>>,
124    ) -> Self {
125        let cfg = config::Config::load().unwrap_or_default();
126        let num_workers = cfg.remote.download_workers.max(1);
127        let client = crate::helpers::subsonic_client(&cfg);
128        if client.is_none() {
129            log::info!("remote not configured — download queue will idle");
130        }
131
132        let inner = Arc::new(Inner {
133            queue: Mutex::new(Queue::default()),
134            has_work: Condvar::new(),
135            state,
136            cmd_tx,
137            log_buf,
138            cfg,
139            client,
140        });
141
142        for i in 0..num_workers {
143            let inner = inner.clone();
144            if let Err(e) = std::thread::Builder::new()
145                .name(format!("koan-dl-{}", i))
146                .spawn(move || worker_loop(inner))
147            {
148                log::error!("failed to spawn download worker {}: {}", i, e);
149            }
150        }
151
152        let watcher_inner = inner.clone();
153        if let Err(e) = std::thread::Builder::new()
154            .name("koan-dl-watch".into())
155            .spawn(move || cursor_watcher(watcher_inner))
156        {
157            log::error!("failed to spawn download cursor watcher: {}", e);
158        }
159
160        Self { inner }
161    }
162
163    /// Add items to the download queue.
164    pub fn enqueue(&self, items: Vec<(i64, QueueItemId)>) {
165        if items.is_empty() {
166            return;
167        }
168        self.inner.queue.lock().pending.extend(items);
169        self.inner.has_work.notify_all();
170    }
171
172    /// Submit a single item for priority download (e.g. user clicked a Pending
173    /// track). Also bumps same-album pending tracks for gapless playback.
174    pub fn prioritize(&self, db_id: i64, queue_id: QueueItemId) {
175        dispatch_priority(&self.inner, (db_id, queue_id));
176
177        let album_mates = self.inner.state.same_album_item_ids(queue_id);
178        if !album_mates.is_empty() {
179            let mate_set: HashSet<QueueItemId> = album_mates.into_iter().collect();
180            bump_to_front(&mut self.inner.queue.lock().pending, &mate_set);
181            self.inner.has_work.notify_all();
182        }
183    }
184}
185
186/// Move every item whose id is in `ids` ahead of the rest, preserving order.
187fn bump_to_front(pending: &mut VecDeque<(i64, QueueItemId)>, ids: &HashSet<QueueItemId>) {
188    let (front, rest): (VecDeque<_>, VecDeque<_>) =
189        pending.drain(..).partition(|(_, qid)| ids.contains(qid));
190    *pending = front;
191    pending.extend(rest);
192}
193
194/// Start a priority download, or queue it at the front when the lane is full.
195fn dispatch_priority(inner: &Arc<Inner>, item: (i64, QueueItemId)) {
196    let dispatch = claim_priority(&mut inner.queue.lock(), item);
197    match dispatch {
198        Dispatch::AlreadyRunning => {}
199        Dispatch::Requeued => {
200            inner.has_work.notify_one();
201        }
202        Dispatch::Spawn => {
203            let spawn_inner = inner.clone();
204            let spawned = std::thread::Builder::new()
205                .name("koan-dl-prio".into())
206                .spawn(move || {
207                    let _claim = Claim {
208                        inner: spawn_inner.clone(),
209                        db_id: item.0,
210                        priority: true,
211                    };
212                    run_download(&spawn_inner, item);
213                });
214            if let Err(e) = spawned {
215                log::error!("failed to spawn priority download: {}", e);
216                let mut q = inner.queue.lock();
217                release_priority(&mut q, item.0);
218                q.pending.push_front(item);
219                drop(q);
220                inner.has_work.notify_one();
221            }
222        }
223    }
224}
225
226/// Hand every other entry waiting on this track the answer the transfer got.
227///
228/// Two entries for one track are one download and two things to tell. Without
229/// this the second sits `Pending` forever, waiting for a transfer that already
230/// finished and will not run again.
231fn settle_waiters(inner: &Arc<Inner>, db_id: i64, downloaded: QueueItemId) {
232    let waiting: Vec<QueueItemId> = {
233        let q = inner.queue.lock();
234        q.in_flight
235            .get(&db_id)
236            .map(|ids| ids.iter().copied().filter(|id| *id != downloaded).collect())
237            .unwrap_or_default()
238    };
239    if waiting.is_empty() {
240        return;
241    }
242    let Some(item) = inner.state.get_item(downloaded) else {
243        return;
244    };
245    for id in waiting {
246        inner.state.update_paths(&[(id, item.path.clone())]);
247        inner.state.update_item_state(id, item.state.clone());
248        // The player only wakes for this, so an entry the cursor is sitting on
249        // would otherwise wait on a download that has already happened.
250        if inner.state.is_cursor(id) {
251            inner.cmd_tx.send(PlayerCommand::TrackReady(id)).ok();
252        }
253    }
254}
255
256/// Run one download, containing any panic so the worker pool never shrinks.
257fn run_download(inner: &Arc<Inner>, (db_id, queue_id): (i64, QueueItemId)) {
258    let Some(client) = inner.client.as_ref() else {
259        // Failed, not left Pending: the player waits for Ready, so a queue of
260        // tracks that can never arrive would otherwise sit saying nothing.
261        crate::helpers::fail_track(
262            &inner.state,
263            &inner.cmd_tx,
264            queue_id,
265            crate::helpers::remote_unavailable(&inner.cfg),
266        );
267        return;
268    };
269
270    let outcome = std::panic::catch_unwind(AssertUnwindSafe(|| {
271        download_track(
272            db_id,
273            queue_id,
274            &inner.cmd_tx,
275            &inner.log_buf,
276            &inner.state,
277            &inner.cfg,
278            client,
279        );
280    }));
281
282    if outcome.is_err() {
283        log::error!("download panicked for {:?}", queue_id);
284        crate::helpers::fail_track(
285            &inner.state,
286            &inner.cmd_tx,
287            queue_id,
288            "download panicked".into(),
289        );
290    }
291
292    // Before the claim is released, while the waiting entries are still
293    // recorded against this track.
294    settle_waiters(inner, db_id, queue_id);
295}
296
297/// Worker loop: wait for work, download, repeat.
298fn worker_loop(inner: Arc<Inner>) {
299    loop {
300        let item = {
301            let mut q = inner.queue.lock();
302            loop {
303                match q.pending.pop_front() {
304                    Some(item) => {
305                        // Already being fetched: this entry waits on the one
306                        // transfer rather than starting a second over it.
307                        match q.in_flight.get_mut(&item.0) {
308                            Some(waiting) => {
309                                waiting.insert(item.1);
310                            }
311                            None => {
312                                q.in_flight.insert(item.0, HashSet::from([item.1]));
313                                break item;
314                            }
315                        }
316                    }
317                    None => inner.has_work.wait(&mut q),
318                }
319            }
320        };
321        let _claim = Claim {
322            inner: inner.clone(),
323            db_id: item.0,
324            priority: false,
325        };
326        run_download(&inner, item);
327    }
328}
329
330/// Cursor watcher: when the cursor moves to a pending track, hand it and the
331/// next track to the priority lane and bump same-album tracks to the front.
332fn cursor_watcher(inner: Arc<Inner>) {
333    let mut last_cursor: Option<QueueItemId> = None;
334    loop {
335        std::thread::sleep(CURSOR_POLL);
336
337        let current = inner.state.cursor();
338        if current == last_cursor {
339            continue;
340        }
341        last_cursor = current;
342
343        let Some(cursor_id) = current else {
344            continue;
345        };
346
347        let is_pending = inner
348            .state
349            .item_load_state(cursor_id)
350            .is_some_and(|s| matches!(s, LoadState::Pending));
351        if !is_pending {
352            continue;
353        }
354
355        let album_mate_ids: HashSet<QueueItemId> = inner
356            .state
357            .same_album_item_ids(cursor_id)
358            .into_iter()
359            .collect();
360
361        let mut priority_items = Vec::new();
362        {
363            let mut q = inner.queue.lock();
364            if let Some(pos) = q.pending.iter().position(|(_, qid)| *qid == cursor_id) {
365                priority_items.push(q.pending.remove(pos).expect("position just found"));
366
367                if !album_mate_ids.is_empty() {
368                    bump_to_front(&mut q.pending, &album_mate_ids);
369                }
370
371                // Grab the next track too, for gapless lookahead.
372                if let Some(next) = q.pending.pop_front() {
373                    priority_items.push(next);
374                }
375            }
376        }
377
378        for item in priority_items {
379            dispatch_priority(&inner, item);
380        }
381    }
382}
383
384/// The process's download queue.
385///
386/// One player means one pool, one priority lane and one cursor watcher; a
387/// second set would compete with the first for the same link and the same
388/// cursor. Every front end reaches downloads through here — the TUI directly,
389/// the FFI and the GraphQL server through `helpers::spawn_downloads`.
390///
391/// `log_buf` is only honoured by whoever initialises it, which is the TUI when
392/// it is running, since it is the only front end that shows the buffer.
393pub fn shared(
394    cmd_tx: &crossbeam_channel::Sender<PlayerCommand>,
395    state: &Arc<SharedPlayerState>,
396    log_buf: Option<Arc<StdMutex<Vec<String>>>>,
397) -> &'static DownloadQueue {
398    static QUEUE: std::sync::OnceLock<DownloadQueue> = std::sync::OnceLock::new();
399    QUEUE.get_or_init(|| {
400        DownloadQueue::spawn(
401            cmd_tx.clone(),
402            state.clone(),
403            log_buf.unwrap_or_else(|| Arc::new(StdMutex::new(Vec::new()))),
404        )
405    })
406}
407
408#[cfg(test)]
409mod tests {
410    use super::*;
411
412    fn qid() -> QueueItemId {
413        QueueItemId::new()
414    }
415
416    #[test]
417    fn priority_lane_never_exceeds_its_permits() {
418        let mut q = Queue::default();
419
420        // Rapid cursor movement: a fresh track lands on the lane every poll.
421        let mut spawned = 0;
422        for i in 0..500 {
423            if claim_priority(&mut q, (i, qid())) == Dispatch::Spawn {
424                spawned += 1;
425            }
426            assert!(
427                q.priority_active <= PRIORITY_PERMITS,
428                "priority lane over its permit count at iteration {}",
429                i
430            );
431        }
432
433        assert_eq!(spawned, PRIORITY_PERMITS, "only permitted claims may spawn");
434        assert_eq!(
435            q.pending.len(),
436            500 - PRIORITY_PERMITS,
437            "everything else must be queued, not dropped"
438        );
439    }
440
441    #[test]
442    fn released_permits_are_reusable() {
443        let mut q = Queue::default();
444        assert_eq!(claim_priority(&mut q, (1, qid())), Dispatch::Spawn);
445        assert_eq!(claim_priority(&mut q, (2, qid())), Dispatch::Spawn);
446        assert_eq!(claim_priority(&mut q, (3, qid())), Dispatch::Requeued);
447
448        release_priority(&mut q, 1);
449        assert_eq!(claim_priority(&mut q, (4, qid())), Dispatch::Spawn);
450        assert!(q.priority_active <= PRIORITY_PERMITS);
451    }
452
453    #[test]
454    fn an_in_flight_track_is_never_claimed_twice() {
455        let mut q = Queue::default();
456        let id = qid();
457        assert_eq!(claim_priority(&mut q, (1, id)), Dispatch::Spawn);
458        assert_eq!(claim_priority(&mut q, (1, id)), Dispatch::AlreadyRunning);
459        assert_eq!(q.priority_active, 1);
460        assert!(
461            q.pending.is_empty(),
462            "a duplicate request must not re-queue the track"
463        );
464    }
465
466    #[test]
467    fn playing_a_track_again_joins_the_transfer_already_running() {
468        // Playing something twice before it has arrived makes a second queue
469        // entry with an id of its own. The track is the same, and so is the
470        // file a download would write — two of them would truncate and write
471        // over one another, and whichever finished first would rename it away
472        // from the other.
473        let mut q = Queue::default();
474        let (first, again) = (qid(), qid());
475        assert_eq!(claim_priority(&mut q, (7, first)), Dispatch::Spawn);
476        assert_eq!(claim_priority(&mut q, (7, again)), Dispatch::AlreadyRunning);
477
478        assert_eq!(q.priority_active, 1, "one transfer, not two");
479        assert!(q.pending.is_empty());
480        assert_eq!(
481            q.in_flight.get(&7),
482            Some(&HashSet::from([first, again])),
483            "both entries wait on the one transfer"
484        );
485    }
486
487    #[test]
488    fn a_worker_picking_up_a_duplicate_waits_on_the_running_one() {
489        // The same, arriving through the queue rather than the priority lane.
490        let mut q = Queue::default();
491        let (running, queued) = (qid(), qid());
492        assert_eq!(claim_priority(&mut q, (7, running)), Dispatch::Spawn);
493
494        // What `worker_loop` does with the next pending item.
495        match q.in_flight.get_mut(&7) {
496            Some(waiting) => {
497                waiting.insert(queued);
498            }
499            None => panic!("the track should already be claimed"),
500        }
501
502        assert_eq!(
503            q.in_flight.get(&7),
504            Some(&HashSet::from([running, queued])),
505            "the queued entry waits rather than starting a second transfer"
506        );
507    }
508
509    #[test]
510    fn different_tracks_still_run_side_by_side() {
511        // Keying on the track must not serialise unrelated downloads.
512        let mut q = Queue::default();
513        assert_eq!(claim_priority(&mut q, (1, qid())), Dispatch::Spawn);
514        assert_eq!(claim_priority(&mut q, (2, qid())), Dispatch::Spawn);
515        assert_eq!(q.priority_active, 2);
516    }
517
518    #[test]
519    fn requeued_priority_item_goes_to_the_head_of_the_queue() {
520        let mut q = Queue::default();
521        q.pending.push_back((9, qid()));
522        for i in 0..PRIORITY_PERMITS {
523            claim_priority(&mut q, (i as i64, qid()));
524        }
525
526        let wanted = qid();
527        assert_eq!(claim_priority(&mut q, (7, wanted)), Dispatch::Requeued);
528        assert_eq!(q.pending.front().map(|(_, id)| *id), Some(wanted));
529    }
530
531    #[test]
532    fn claiming_removes_a_duplicate_queue_entry() {
533        let mut q = Queue::default();
534        let id = qid();
535        q.pending.push_back((1, id));
536        q.pending.push_back((2, qid()));
537
538        assert_eq!(claim_priority(&mut q, (1, id)), Dispatch::Spawn);
539        assert_eq!(
540            q.pending.len(),
541            1,
542            "the pool must not also pick up the claimed track"
543        );
544    }
545
546    #[test]
547    fn bump_to_front_preserves_relative_order() {
548        let (a, b, c, d) = (qid(), qid(), qid(), qid());
549        let mut pending: VecDeque<(i64, QueueItemId)> =
550            [(1, a), (2, b), (3, c), (4, d)].into_iter().collect();
551        let mates: HashSet<QueueItemId> = [b, d].into_iter().collect();
552
553        bump_to_front(&mut pending, &mates);
554
555        let order: Vec<QueueItemId> = pending.iter().map(|(_, id)| *id).collect();
556        assert_eq!(order, vec![b, d, a, c]);
557    }
558}