1use std::sync::{
2 Mutex, PoisonError,
3 atomic::{AtomicU64, Ordering},
4};
5
6use kithara_audio::{AudioObserver, AudioObserverRelay, AudioObserverSlot};
7use kithara_bufpool::HasPool;
8use kithara_events::{EventBus, TrackId};
9use kithara_platform::CancelToken;
10use kithara_play::{ResourceConfig, ResourceSrc};
11
12use crate::{
13 attempts::{AttemptGuard, Ticket},
14 event::{QueueEvent, TrackStatus},
15};
16
17#[derive(Debug, Clone)]
19#[non_exhaustive]
20pub struct TrackEntry {
21 pub url: Option<String>,
24 pub name: String,
26 pub id: TrackId,
28 pub status: TrackStatus,
30}
31
32#[derive(derive_more::From)]
47#[non_exhaustive]
48#[derive_where::derive_where(Clone; S: HasPool<u8> + HasPool<f32> + Send + Sync + 'static)]
49pub enum TrackSource<S>
50where
51 S: HasPool<u8> + HasPool<f32> + Send + Sync + 'static,
52{
53 #[from]
55 Uri(String),
56 #[from]
59 Config(Box<ResourceConfig<S>>),
60}
61
62impl<S> TrackSource<S>
63where
64 S: HasPool<u8> + HasPool<f32> + Send + Sync + 'static,
65{
66 #[must_use]
70 pub fn uri(&self) -> Option<&str> {
71 match self {
72 Self::Uri(s) => Some(s),
73 Self::Config(cfg) => match cfg.source() {
74 ResourceSrc::Url(url) => Some(url.as_str()),
75 ResourceSrc::Path(path) => path.to_str(),
76 },
77 }
78 }
79}
80
81impl<S> From<&str> for TrackSource<S>
82where
83 S: HasPool<u8> + HasPool<f32> + Send + Sync + 'static,
84{
85 fn from(s: &str) -> Self {
86 Self::Uri(s.to_string())
87 }
88}
89
90impl<S> From<ResourceConfig<S>> for TrackSource<S>
91where
92 S: HasPool<u8> + HasPool<f32> + Send + Sync + 'static,
93{
94 fn from(c: ResourceConfig<S>) -> Self {
95 Self::Config(Box::new(c))
96 }
97}
98
99pub(crate) struct TrackRecord<S>
102where
103 S: HasPool<u8> + HasPool<f32> + Send + Sync + 'static,
104{
105 pub(crate) load: Option<AttemptGuard>,
106 pub(crate) url: Option<String>,
107 pub(crate) name: String,
108 pub(crate) id: TrackId,
109 pub(crate) source: TrackSource<S>,
110 pub(crate) status: TrackStatus,
111 observer: AudioObserverSlot,
112}
113
114impl<S> TrackRecord<S>
115where
116 S: HasPool<u8> + HasPool<f32> + Send + Sync + 'static,
117{
118 pub(crate) fn new(id: TrackId, name: String, source: TrackSource<S>) -> Self {
119 Self {
120 id,
121 name,
122 url: source.uri().map(str::to_string),
123 status: TrackStatus::Pending,
124 source,
125 load: None,
126 observer: AudioObserverSlot::default(),
127 }
128 }
129
130 pub(crate) fn entry(&self) -> TrackEntry {
131 TrackEntry {
132 id: self.id,
133 name: self.name.clone(),
134 url: self.url.clone(),
135 status: self.status.clone(),
136 }
137 }
138}
139
140pub(crate) struct Tracks<S>
148where
149 S: HasPool<u8> + HasPool<f32> + Send + Sync + 'static,
150{
151 next_generation: AtomicU64,
152 bus: EventBus,
153 inner: Mutex<Vec<TrackRecord<S>>>,
154}
155
156impl<S> Tracks<S>
157where
158 S: HasPool<u8> + HasPool<f32> + Send + Sync + 'static,
159{
160 pub(crate) const fn new(bus: EventBus) -> Self {
161 Self {
162 bus,
163 inner: Mutex::new(Vec::new()),
164 next_generation: AtomicU64::new(0),
165 }
166 }
167
168 pub(crate) fn attach_observer(&self, id: TrackId, observer: Box<dyn AudioObserver>) {
171 let slot = self
172 .lock()
173 .iter()
174 .find(|record| record.id == id)
175 .map(|record| record.observer.clone());
176 let Some(slot) = slot else {
177 return;
178 };
179 slot.attach(observer);
180 }
181
182 pub(crate) fn attempt_selected(&self, id: TrackId) -> bool {
187 let guard = self.lock();
188 let selected = guard
189 .iter()
190 .find(|r| r.id == id)
191 .and_then(|r| r.load.as_ref())
192 .is_some_and(|a| a.selected);
193 drop(guard);
194 selected
195 }
196
197 pub(crate) fn begin_attempt(
200 &self,
201 id: TrackId,
202 cancel: CancelToken,
203 selected: bool,
204 ) -> Option<Ticket> {
205 let mut guard = self.lock();
206 let ticket = match guard.iter_mut().find(|r| r.id == id) {
207 Some(record) if record.load.as_ref().is_none_or(AttemptGuard::is_cancelled) => {
208 Some(install(record, &self.next_generation, cancel, selected))
209 }
210 _ => None,
211 };
212 drop(guard);
213 ticket
214 }
215
216 pub(crate) fn finish_attempt(&self, ticket: &Ticket, failure: Option<String>) {
221 let mut guard = self.lock();
222 let Some(record) = guard.iter_mut().find(|r| r.id == ticket.id) else {
223 return;
224 };
225 if record
226 .load
227 .as_ref()
228 .is_none_or(|a| a.generation != ticket.generation)
229 {
230 return;
231 }
232 if let Some(mut attempt) = record.load.take() {
233 attempt.disarm();
234 }
235 let Some(reason) = failure else {
236 return;
237 };
238 record.status = TrackStatus::Failed(reason.clone());
239 drop(guard);
240 self.bus.publish(QueueEvent::TrackStatusChanged {
241 id: ticket.id,
242 status: TrackStatus::Failed(reason),
243 });
244 }
245
246 pub(crate) fn lock(&self) -> std::sync::MutexGuard<'_, Vec<TrackRecord<S>>> {
250 self.inner.lock().unwrap_or_else(PoisonError::into_inner)
251 }
252
253 pub(crate) fn mark_loading(&self, ticket: &Ticket) -> bool {
257 let mut guard = self.lock();
258 let claimed = guard
259 .iter_mut()
260 .find(|r| r.id == ticket.id)
261 .is_some_and(|r| {
262 let Some(attempt) = r.load.as_mut() else {
263 return false;
264 };
265 if attempt.generation != ticket.generation || attempt.is_cancelled() {
266 return false;
267 }
268 attempt.waiting = false;
269 r.status = TrackStatus::Loading;
270 true
271 });
272 drop(guard);
273 if claimed {
274 self.bus.publish(QueueEvent::TrackStatusChanged {
275 id: ticket.id,
276 status: TrackStatus::Loading,
277 });
278 }
279 claimed
280 }
281
282 pub(crate) fn observer_relay(&self, id: TrackId) -> AudioObserverRelay {
286 let slot = self
287 .lock()
288 .iter()
289 .find(|record| record.id == id)
290 .map(|record| record.observer.clone())
291 .unwrap_or_default();
292 slot.relay()
293 }
294
295 pub(crate) fn promote_attempt(&self, id: TrackId, cancel: CancelToken) -> Option<Ticket> {
301 let mut guard = self.lock();
302 let ticket = match guard.iter_mut().find(|r| r.id == id) {
303 Some(record)
304 if record
305 .load
306 .as_ref()
307 .is_some_and(|a| a.waiting || a.is_cancelled()) =>
308 {
309 Some(install(record, &self.next_generation, cancel, true))
310 }
311 Some(record) => {
312 if let Some(attempt) = record.load.as_mut() {
313 attempt.selected = true;
314 }
315 None
316 }
317 None => None,
318 };
319 drop(guard);
320 ticket
321 }
322
323 pub(crate) fn set_status(&self, id: TrackId, status: TrackStatus) {
332 let mut guard = self.lock();
333 let Some(record) = guard.iter_mut().find(|r| r.id == id) else {
334 return;
335 };
336 record.status = status.clone();
337 let aborted = matches!(status, TrackStatus::Cancelled | TrackStatus::Loaded)
338 .then(|| record.load.take())
339 .flatten();
340 drop(guard);
341 drop(aborted);
342 self.bus
343 .publish(QueueEvent::TrackStatusChanged { id, status });
344 }
345
346 pub(crate) fn source(&self, id: TrackId) -> Option<TrackSource<S>> {
348 self.lock()
349 .iter()
350 .find(|r| r.id == id)
351 .map(|r| r.source.clone())
352 }
353}
354
355fn install<S>(
356 record: &mut TrackRecord<S>,
357 generations: &AtomicU64,
358 cancel: CancelToken,
359 selected: bool,
360) -> Ticket
361where
362 S: HasPool<u8> + HasPool<f32> + Send + Sync + 'static,
363{
364 let generation = generations.fetch_add(1, Ordering::Relaxed);
365 let mut attempt = AttemptGuard::new(generation, cancel);
366 attempt.selected = selected;
367 record.load = Some(attempt);
368 Ticket {
369 generation,
370 id: record.id,
371 }
372}
373
374#[cfg(test)]
375mod tests {
376 use std::sync::atomic::AtomicUsize;
377
378 use kithara_assets::AssetStore;
379 use kithara_audio::{AudioObserveError, AudioObserver};
380 use kithara_platform::sync::Arc;
381 use kithara_signal::{AudioChunk, AudioChunkInfo};
382 use kithara_test_utils::kithara;
383
384 use super::*;
385 use crate::test_pools::{TestPools, pools, sample_buffer};
386
387 #[kithara::test]
388 #[case::from_str("https://example.com/song.mp3")]
389 #[case::from_string("https://example.com/track.m3u8")]
390 fn track_source_from_string_kind(#[case] url: &str) {
391 let owned = url.to_string();
392 let from_owned: TrackSource<TestPools> = owned.into();
393 assert_eq!(from_owned.uri(), Some(url));
394 let from_ref: TrackSource<TestPools> = url.into();
395 assert_eq!(from_ref.uri(), Some(url));
396 }
397
398 #[kithara::test]
399 fn track_source_from_resource_config() {
400 let src =
401 ResourceSrc::parse("https://example.com/a.mp3").expect("BUG: hard-coded URL is valid");
402 let cfg = ResourceConfig::for_src(src)
403 .store(AssetStore::builder(pools()).build())
404 .build();
405 let src: TrackSource<TestPools> = cfg.into();
406 assert!(matches!(src, TrackSource::Config(_)));
407 assert_eq!(src.uri(), Some("https://example.com/a.mp3"));
408 }
409
410 fn tracks_with(id: TrackId) -> Tracks<TestPools> {
411 let tracks = Tracks::new(EventBus::default());
412 tracks.lock().push(TrackRecord::new(
413 id,
414 String::new(),
415 "https://x/a.mp3".into(),
416 ));
417 tracks
418 }
419
420 fn two_tracks() -> Tracks<TestPools> {
423 let tracks = Tracks::new(EventBus::default());
424 let mut guard = tracks.lock();
425 guard.push(TrackRecord::new(
426 TrackId(1),
427 String::new(),
428 "https://x/first.mp3".into(),
429 ));
430 guard.push(TrackRecord::new(
431 TrackId(2),
432 String::new(),
433 "https://x/second.mp3".into(),
434 ));
435 drop(guard);
436 tracks
437 }
438
439 struct CountingObserver(Arc<AtomicUsize>);
440
441 impl AudioObserver for CountingObserver {
442 fn try_observe(&mut self, _chunk: &AudioChunk) -> Result<(), AudioObserveError> {
443 self.0.fetch_add(1, Ordering::Relaxed);
444 Ok(())
445 }
446 }
447
448 #[kithara::test]
449 fn an_attached_observer_reaches_its_own_tracks_decoder() {
450 let pools = pools();
451 let tracks = two_tracks();
452 let seen = Arc::new(AtomicUsize::new(0));
453 tracks.attach_observer(TrackId(2), Box::new(CountingObserver(Arc::clone(&seen))));
454 let mut relay = tracks.observer_relay(TrackId(2));
455
456 let chunk = AudioChunk::new(AudioChunkInfo::default(), sample_buffer(&pools, &[]));
457 relay.try_observe(&chunk).expect("the observer accepts it");
458
459 assert_eq!(seen.load(Ordering::Relaxed), 1);
460 }
461
462 #[kithara::test]
463 fn an_attached_observer_does_not_reach_another_track() {
464 let pools = pools();
465 let tracks = two_tracks();
466 let seen = Arc::new(AtomicUsize::new(0));
467 tracks.attach_observer(TrackId(2), Box::new(CountingObserver(Arc::clone(&seen))));
468 let mut relay = tracks.observer_relay(TrackId(1));
469
470 let chunk = AudioChunk::new(AudioChunkInfo::default(), sample_buffer(&pools, &[]));
471 relay
472 .try_observe(&chunk)
473 .expect("an empty relay is a no-op");
474
475 assert_eq!(seen.load(Ordering::Relaxed), 0);
476 }
477
478 #[kithara::test]
479 fn a_track_reports_its_own_source() {
480 let tracks = two_tracks();
481
482 assert_eq!(
483 tracks
484 .source(TrackId(2))
485 .as_ref()
486 .and_then(TrackSource::uri),
487 Some("https://x/second.mp3")
488 );
489 }
490
491 #[kithara::test]
492 fn an_unqueued_track_has_no_source() {
493 let tracks = two_tracks();
494
495 assert!(tracks.source(TrackId(3)).is_none());
496 }
497
498 fn token() -> CancelToken {
499 CancelToken::never().child()
500 }
501
502 #[kithara::test]
503 fn begin_dedupes_live_attempt() {
504 let tracks = tracks_with(TrackId(1));
505 assert!(tracks.begin_attempt(TrackId(1), token(), false).is_some());
506 assert!(tracks.begin_attempt(TrackId(1), token(), false).is_none());
507 }
508
509 #[kithara::test]
510 fn selection_reflects_only_the_requested_live_attempt() {
511 let tracks = two_tracks();
512 assert!(!tracks.attempt_selected(TrackId(1)));
513 assert!(tracks.begin_attempt(TrackId(1), token(), false).is_some());
514 assert!(tracks.begin_attempt(TrackId(2), token(), true).is_some());
515 assert!(!tracks.attempt_selected(TrackId(1)));
516 assert!(tracks.attempt_selected(TrackId(2)));
517 }
518
519 #[kithara::test]
520 fn begin_replaces_cancelled_unwinding_attempt() {
521 let tracks = tracks_with(TrackId(1));
522 let first_cancel = token();
523 let first = tracks
524 .begin_attempt(TrackId(1), first_cancel.clone(), false)
525 .expect("BUG: vacant record must accept an attempt");
526 tracks.set_status(TrackId(1), TrackStatus::Cancelled);
527 assert!(first_cancel.is_cancelled(), "Cancelled must abort the load");
528 let second = tracks
529 .begin_attempt(TrackId(1), token(), false)
530 .expect("cancelled attempt must be replaceable");
531 assert!(!tracks.mark_loading(&first), "replaced ticket loses claim");
532 assert!(tracks.mark_loading(&second));
533 }
534
535 #[kithara::test]
540 fn a_loaded_track_is_not_failed_by_the_attempt_it_outlived() {
541 let tracks = tracks_with(TrackId(1));
542 let attempt = tracks
543 .begin_attempt(TrackId(1), token(), false)
544 .expect("BUG: vacant record must accept an attempt");
545
546 tracks.set_status(TrackId(1), TrackStatus::Loaded);
547 tracks.finish_attempt(&attempt, Some("HTTP 404".to_owned()));
548
549 assert!(matches!(tracks.lock()[0].status, TrackStatus::Loaded));
550 }
551
552 #[kithara::test]
553 fn promote_replaces_waiting_and_cancels_it() {
554 let tracks = tracks_with(TrackId(1));
555 let parked_cancel = token();
556 let parked = tracks
557 .begin_attempt(TrackId(1), parked_cancel.clone(), false)
558 .expect("BUG: vacant record must accept an attempt");
559 let promoted = tracks
560 .promote_attempt(TrackId(1), token())
561 .expect("waiting attempt must be promotable");
562 assert!(parked_cancel.is_cancelled(), "parked attempt must abort");
563 assert!(!tracks.mark_loading(&parked));
564 assert!(tracks.mark_loading(&promoted));
565 }
566
567 #[kithara::test]
568 fn promote_keeps_attempt_holding_permit() {
569 let tracks = tracks_with(TrackId(1));
570 let loading = tracks
571 .begin_attempt(TrackId(1), token(), false)
572 .expect("BUG: vacant record must accept an attempt");
573 assert!(tracks.mark_loading(&loading));
574 assert!(
575 tracks.promote_attempt(TrackId(1), token()).is_none(),
576 "an attempt past the permit gate keeps its download"
577 );
578 }
579
580 #[kithara::test]
581 fn promote_vacant_is_noop() {
582 let tracks = tracks_with(TrackId(1));
583 assert!(tracks.promote_attempt(TrackId(1), token()).is_none());
584 }
585
586 #[kithara::test]
587 fn finish_disarms_and_ignores_stale_ticket() {
588 let tracks = tracks_with(TrackId(1));
589 let first_cancel = token();
590 let old = tracks
591 .begin_attempt(TrackId(1), first_cancel, false)
592 .expect("BUG: vacant record must accept an attempt");
593 let new = tracks
594 .promote_attempt(TrackId(1), token())
595 .expect("waiting attempt must be promotable");
596 tracks.finish_attempt(&old, None);
597 assert!(tracks.mark_loading(&new), "stale finish must not evict");
598 tracks.finish_attempt(&new, None);
599 assert!(
600 tracks.begin_attempt(TrackId(1), token(), false).is_some(),
601 "finished attempt must leave the record vacant"
602 );
603 }
604
605 #[kithara::test]
606 fn removing_record_cancels_attempt() {
607 let tracks = tracks_with(TrackId(1));
608 let cancel = token();
609 let _ticket = tracks
610 .begin_attempt(TrackId(1), cancel.clone(), false)
611 .expect("BUG: vacant record must accept an attempt");
612 tracks.lock().clear();
613 assert!(cancel.is_cancelled(), "dropping the record aborts the load");
614 }
615
616 #[kithara::test]
617 fn finish_with_failure_sets_failed_once() {
618 let tracks = tracks_with(TrackId(1));
619 let ticket = tracks
620 .begin_attempt(TrackId(1), token(), false)
621 .expect("BUG: vacant record must accept an attempt");
622 tracks.finish_attempt(&ticket, Some("boom".into()));
623 let status = tracks.lock()[0].status.clone();
624 assert!(matches!(status, TrackStatus::Failed(_)));
625 }
626}