Skip to main content

koan_core/remote/
queue.rs

1use std::collections::{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    in_flight: HashSet<QueueItemId>,
51    priority_active: usize,
52}
53
54/// What a priority request should do, given the state of the lane.
55#[derive(Debug, PartialEq, Eq)]
56enum Dispatch {
57    /// A permit was taken and the item claimed — spawn a thread for it.
58    Spawn,
59    /// No permit free; the item now sits at the head of the work queue.
60    Requeued,
61    /// Some thread is already downloading it.
62    AlreadyRunning,
63}
64
65/// Claim `item` for the priority lane, or push it to the front of the queue if
66/// every permit is taken. On `Spawn` the caller owns the claim and must release
67/// it via `release_priority` when the download ends.
68fn claim_priority(q: &mut Queue, item: (i64, QueueItemId)) -> Dispatch {
69    q.pending.retain(|(_, qid)| *qid != item.1);
70
71    if q.in_flight.contains(&item.1) {
72        return Dispatch::AlreadyRunning;
73    }
74    if q.priority_active >= PRIORITY_PERMITS {
75        q.pending.push_front(item);
76        return Dispatch::Requeued;
77    }
78    q.priority_active += 1;
79    q.in_flight.insert(item.1);
80    Dispatch::Spawn
81}
82
83fn release_priority(q: &mut Queue, id: QueueItemId) {
84    q.in_flight.remove(&id);
85    q.priority_active = q.priority_active.saturating_sub(1);
86}
87
88/// Releases an in-flight claim however the download ends — including a panic.
89struct Claim {
90    inner: Arc<Inner>,
91    id: QueueItemId,
92    priority: bool,
93}
94
95impl Drop for Claim {
96    fn drop(&mut self) {
97        let mut q = self.inner.queue.lock();
98        if self.priority {
99            release_priority(&mut q, self.id);
100        } else {
101            q.in_flight.remove(&self.id);
102        }
103    }
104}
105
106impl DownloadQueue {
107    /// Spawn the download queue with persistent worker threads.
108    pub fn spawn(
109        cmd_tx: crossbeam_channel::Sender<PlayerCommand>,
110        state: Arc<SharedPlayerState>,
111        log_buf: Arc<StdMutex<Vec<String>>>,
112    ) -> Self {
113        let cfg = config::Config::load().unwrap_or_default();
114        let num_workers = cfg.remote.download_workers.max(1);
115        let client = crate::helpers::subsonic_client(&cfg);
116        if client.is_none() {
117            log::info!("remote not configured — download queue will idle");
118        }
119
120        let inner = Arc::new(Inner {
121            queue: Mutex::new(Queue::default()),
122            has_work: Condvar::new(),
123            state,
124            cmd_tx,
125            log_buf,
126            cfg,
127            client,
128        });
129
130        for i in 0..num_workers {
131            let inner = inner.clone();
132            if let Err(e) = std::thread::Builder::new()
133                .name(format!("koan-dl-{}", i))
134                .spawn(move || worker_loop(inner))
135            {
136                log::error!("failed to spawn download worker {}: {}", i, e);
137            }
138        }
139
140        let watcher_inner = inner.clone();
141        if let Err(e) = std::thread::Builder::new()
142            .name("koan-dl-watch".into())
143            .spawn(move || cursor_watcher(watcher_inner))
144        {
145            log::error!("failed to spawn download cursor watcher: {}", e);
146        }
147
148        Self { inner }
149    }
150
151    /// Add items to the download queue.
152    pub fn enqueue(&self, items: Vec<(i64, QueueItemId)>) {
153        if items.is_empty() {
154            return;
155        }
156        self.inner.queue.lock().pending.extend(items);
157        self.inner.has_work.notify_all();
158    }
159
160    /// Submit a single item for priority download (e.g. user clicked a Pending
161    /// track). Also bumps same-album pending tracks for gapless playback.
162    pub fn prioritize(&self, db_id: i64, queue_id: QueueItemId) {
163        dispatch_priority(&self.inner, (db_id, queue_id));
164
165        let album_mates = self.inner.state.same_album_item_ids(queue_id);
166        if !album_mates.is_empty() {
167            let mate_set: HashSet<QueueItemId> = album_mates.into_iter().collect();
168            bump_to_front(&mut self.inner.queue.lock().pending, &mate_set);
169            self.inner.has_work.notify_all();
170        }
171    }
172}
173
174/// Move every item whose id is in `ids` ahead of the rest, preserving order.
175fn bump_to_front(pending: &mut VecDeque<(i64, QueueItemId)>, ids: &HashSet<QueueItemId>) {
176    let (front, rest): (VecDeque<_>, VecDeque<_>) =
177        pending.drain(..).partition(|(_, qid)| ids.contains(qid));
178    *pending = front;
179    pending.extend(rest);
180}
181
182/// Start a priority download, or queue it at the front when the lane is full.
183fn dispatch_priority(inner: &Arc<Inner>, item: (i64, QueueItemId)) {
184    let dispatch = claim_priority(&mut inner.queue.lock(), item);
185    match dispatch {
186        Dispatch::AlreadyRunning => {}
187        Dispatch::Requeued => {
188            inner.has_work.notify_one();
189        }
190        Dispatch::Spawn => {
191            let spawn_inner = inner.clone();
192            let spawned = std::thread::Builder::new()
193                .name("koan-dl-prio".into())
194                .spawn(move || {
195                    let _claim = Claim {
196                        inner: spawn_inner.clone(),
197                        id: item.1,
198                        priority: true,
199                    };
200                    run_download(&spawn_inner, item);
201                });
202            if let Err(e) = spawned {
203                log::error!("failed to spawn priority download: {}", e);
204                let mut q = inner.queue.lock();
205                release_priority(&mut q, item.1);
206                q.pending.push_front(item);
207                drop(q);
208                inner.has_work.notify_one();
209            }
210        }
211    }
212}
213
214/// Run one download, containing any panic so the worker pool never shrinks.
215fn run_download(inner: &Arc<Inner>, (db_id, queue_id): (i64, QueueItemId)) {
216    let Some(client) = inner.client.as_ref() else {
217        // Failed, not left Pending: the player waits for Ready, so a queue of
218        // tracks that can never arrive would otherwise sit saying nothing.
219        crate::helpers::fail_track(
220            &inner.state,
221            &inner.cmd_tx,
222            queue_id,
223            crate::helpers::remote_unavailable(&inner.cfg),
224        );
225        return;
226    };
227
228    let outcome = std::panic::catch_unwind(AssertUnwindSafe(|| {
229        download_track(
230            db_id,
231            queue_id,
232            &inner.cmd_tx,
233            &inner.log_buf,
234            &inner.state,
235            &inner.cfg,
236            client,
237        );
238    }));
239
240    if outcome.is_err() {
241        log::error!("download panicked for {:?}", queue_id);
242        crate::helpers::fail_track(
243            &inner.state,
244            &inner.cmd_tx,
245            queue_id,
246            "download panicked".into(),
247        );
248    }
249}
250
251/// Worker loop: wait for work, download, repeat.
252fn worker_loop(inner: Arc<Inner>) {
253    loop {
254        let item = {
255            let mut q = inner.queue.lock();
256            loop {
257                match q.pending.pop_front() {
258                    Some(item) => {
259                        // A duplicate entry for a track already downloading is dropped.
260                        if q.in_flight.insert(item.1) {
261                            break item;
262                        }
263                    }
264                    None => inner.has_work.wait(&mut q),
265                }
266            }
267        };
268        let _claim = Claim {
269            inner: inner.clone(),
270            id: item.1,
271            priority: false,
272        };
273        run_download(&inner, item);
274    }
275}
276
277/// Cursor watcher: when the cursor moves to a pending track, hand it and the
278/// next track to the priority lane and bump same-album tracks to the front.
279fn cursor_watcher(inner: Arc<Inner>) {
280    let mut last_cursor: Option<QueueItemId> = None;
281    loop {
282        std::thread::sleep(CURSOR_POLL);
283
284        let current = inner.state.cursor();
285        if current == last_cursor {
286            continue;
287        }
288        last_cursor = current;
289
290        let Some(cursor_id) = current else {
291            continue;
292        };
293
294        let is_pending = inner
295            .state
296            .item_load_state(cursor_id)
297            .is_some_and(|s| matches!(s, LoadState::Pending));
298        if !is_pending {
299            continue;
300        }
301
302        let album_mate_ids: HashSet<QueueItemId> = inner
303            .state
304            .same_album_item_ids(cursor_id)
305            .into_iter()
306            .collect();
307
308        let mut priority_items = Vec::new();
309        {
310            let mut q = inner.queue.lock();
311            if let Some(pos) = q.pending.iter().position(|(_, qid)| *qid == cursor_id) {
312                priority_items.push(q.pending.remove(pos).expect("position just found"));
313
314                if !album_mate_ids.is_empty() {
315                    bump_to_front(&mut q.pending, &album_mate_ids);
316                }
317
318                // Grab the next track too, for gapless lookahead.
319                if let Some(next) = q.pending.pop_front() {
320                    priority_items.push(next);
321                }
322            }
323        }
324
325        for item in priority_items {
326            dispatch_priority(&inner, item);
327        }
328    }
329}
330
331/// The process's download queue.
332///
333/// One player means one pool, one priority lane and one cursor watcher; a
334/// second set would compete with the first for the same link and the same
335/// cursor. Every front end reaches downloads through here — the TUI directly,
336/// the FFI and the GraphQL server through `helpers::spawn_downloads`.
337///
338/// `log_buf` is only honoured by whoever initialises it, which is the TUI when
339/// it is running, since it is the only front end that shows the buffer.
340pub fn shared(
341    cmd_tx: &crossbeam_channel::Sender<PlayerCommand>,
342    state: &Arc<SharedPlayerState>,
343    log_buf: Option<Arc<StdMutex<Vec<String>>>>,
344) -> &'static DownloadQueue {
345    static QUEUE: std::sync::OnceLock<DownloadQueue> = std::sync::OnceLock::new();
346    QUEUE.get_or_init(|| {
347        DownloadQueue::spawn(
348            cmd_tx.clone(),
349            state.clone(),
350            log_buf.unwrap_or_else(|| Arc::new(StdMutex::new(Vec::new()))),
351        )
352    })
353}
354
355#[cfg(test)]
356mod tests {
357    use super::*;
358
359    fn qid() -> QueueItemId {
360        QueueItemId::new()
361    }
362
363    #[test]
364    fn priority_lane_never_exceeds_its_permits() {
365        let mut q = Queue::default();
366
367        // Rapid cursor movement: a fresh track lands on the lane every poll.
368        let mut spawned = 0;
369        for i in 0..500 {
370            if claim_priority(&mut q, (i, qid())) == Dispatch::Spawn {
371                spawned += 1;
372            }
373            assert!(
374                q.priority_active <= PRIORITY_PERMITS,
375                "priority lane over its permit count at iteration {}",
376                i
377            );
378        }
379
380        assert_eq!(spawned, PRIORITY_PERMITS, "only permitted claims may spawn");
381        assert_eq!(
382            q.pending.len(),
383            500 - PRIORITY_PERMITS,
384            "everything else must be queued, not dropped"
385        );
386    }
387
388    #[test]
389    fn released_permits_are_reusable() {
390        let mut q = Queue::default();
391        let a = qid();
392        assert_eq!(claim_priority(&mut q, (1, a)), Dispatch::Spawn);
393        assert_eq!(claim_priority(&mut q, (2, qid())), Dispatch::Spawn);
394        assert_eq!(claim_priority(&mut q, (3, qid())), Dispatch::Requeued);
395
396        release_priority(&mut q, a);
397        assert_eq!(claim_priority(&mut q, (4, qid())), Dispatch::Spawn);
398        assert!(q.priority_active <= PRIORITY_PERMITS);
399    }
400
401    #[test]
402    fn an_in_flight_track_is_never_claimed_twice() {
403        let mut q = Queue::default();
404        let id = qid();
405        assert_eq!(claim_priority(&mut q, (1, id)), Dispatch::Spawn);
406        assert_eq!(claim_priority(&mut q, (1, id)), Dispatch::AlreadyRunning);
407        assert_eq!(q.priority_active, 1);
408        assert!(
409            q.pending.is_empty(),
410            "a duplicate request must not re-queue the track"
411        );
412    }
413
414    #[test]
415    fn requeued_priority_item_goes_to_the_head_of_the_queue() {
416        let mut q = Queue::default();
417        q.pending.push_back((9, qid()));
418        for i in 0..PRIORITY_PERMITS {
419            claim_priority(&mut q, (i as i64, qid()));
420        }
421
422        let wanted = qid();
423        assert_eq!(claim_priority(&mut q, (7, wanted)), Dispatch::Requeued);
424        assert_eq!(q.pending.front().map(|(_, id)| *id), Some(wanted));
425    }
426
427    #[test]
428    fn claiming_removes_a_duplicate_queue_entry() {
429        let mut q = Queue::default();
430        let id = qid();
431        q.pending.push_back((1, id));
432        q.pending.push_back((2, qid()));
433
434        assert_eq!(claim_priority(&mut q, (1, id)), Dispatch::Spawn);
435        assert_eq!(
436            q.pending.len(),
437            1,
438            "the pool must not also pick up the claimed track"
439        );
440    }
441
442    #[test]
443    fn bump_to_front_preserves_relative_order() {
444        let (a, b, c, d) = (qid(), qid(), qid(), qid());
445        let mut pending: VecDeque<(i64, QueueItemId)> =
446            [(1, a), (2, b), (3, c), (4, d)].into_iter().collect();
447        let mates: HashSet<QueueItemId> = [b, d].into_iter().collect();
448
449        bump_to_front(&mut pending, &mates);
450
451        let order: Vec<QueueItemId> = pending.iter().map(|(_, id)| *id).collect();
452        assert_eq!(order, vec![b, d, a, c]);
453    }
454}