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
14const PRIORITY_PERMITS: usize = 2;
18
19const CURSOR_POLL: std::time::Duration = std::time::Duration::from_millis(30);
21
22#[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 client: Option<Arc<SubsonicClient>>,
43}
44
45#[derive(Default)]
48struct Queue {
49 pending: VecDeque<(i64, QueueItemId)>,
50 in_flight: HashMap<i64, HashSet<QueueItemId>>,
59 priority_active: usize,
60}
61
62#[derive(Debug, PartialEq, Eq)]
64enum Dispatch {
65 Spawn,
67 Requeued,
69 AlreadyRunning,
71}
72
73fn 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 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
100struct 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 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 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 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
186fn 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
194fn 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
226fn 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 if inner.state.is_cursor(id) {
251 inner.cmd_tx.send(PlayerCommand::TrackReady(id)).ok();
252 }
253 }
254}
255
256fn run_download(inner: &Arc<Inner>, (db_id, queue_id): (i64, QueueItemId)) {
258 let Some(client) = inner.client.as_ref() else {
259 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 settle_waiters(inner, db_id, queue_id);
295}
296
297fn 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 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
330fn 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 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
384pub 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 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 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 let mut q = Queue::default();
491 let (running, queued) = (qid(), qid());
492 assert_eq!(claim_priority(&mut q, (7, running)), Dispatch::Spawn);
493
494 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 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}