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
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: HashSet<QueueItemId>,
51 priority_active: usize,
52}
53
54#[derive(Debug, PartialEq, Eq)]
56enum Dispatch {
57 Spawn,
59 Requeued,
61 AlreadyRunning,
63}
64
65fn 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
88struct 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 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 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 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
174fn 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
182fn 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
214fn run_download(inner: &Arc<Inner>, (db_id, queue_id): (i64, QueueItemId)) {
216 let Some(client) = inner.client.as_ref() else {
217 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
251fn 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 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
277fn 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 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
331pub 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 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}