Skip to main content

kithara_queue/
track.rs

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/// Snapshot of a track entry in the queue.
18#[derive(Debug, Clone)]
19#[non_exhaustive]
20pub struct TrackEntry {
21    /// Canonical source location: a normalized URL or a file path.
22    /// `None` only for a non-UTF-8 file path.
23    pub url: Option<String>,
24    /// Display name derived from the URL or caller-supplied. May be empty.
25    pub name: String,
26    /// Stable identifier.
27    pub id: TrackId,
28    /// Current loading status.
29    pub status: TrackStatus,
30}
31
32/// Input to [`Queue::append`](crate::Queue::append) /
33/// [`Queue::insert`](crate::Queue::insert) describing how to load a track.
34///
35/// Two shapes:
36/// - [`TrackSource::Uri`] — the queue builds a default
37///   [`ResourceConfig`] from the [`QueueConfig`](crate::QueueConfig) templates
38///   (`net`, `store`). Convenient for simple use.
39/// - [`TrackSource::Config`] — the caller pre-builds a [`ResourceConfig`]
40///   (useful for DRM keys, custom headers, format hints). The queue leaves
41///   caller-set fields intact.
42///
43/// `TrackSource` is `Clone` so the queue can respawn a load when a
44/// previously-consumed track is re-selected — re-tapping a track in
45/// the playlist must work without the caller reconstructing anything.
46#[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    /// Load from URL / path. Queue fills in defaults from `QueueConfig`.
54    #[from]
55    Uri(String),
56    /// Caller-assembled resource config (DRM, headers, etc.). Boxed because
57    /// [`ResourceConfig`] is ~100 bytes larger than the `Uri` variant.
58    #[from]
59    Config(Box<ResourceConfig<S>>),
60}
61
62impl<S> TrackSource<S>
63where
64    S: HasPool<u8> + HasPool<f32> + Send + Sync + 'static,
65{
66    /// Canonical source location: the string for [`TrackSource::Uri`], the
67    /// config's URL or file path for [`TrackSource::Config`]. `None` only
68    /// for a non-UTF-8 file path.
69    #[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
99/// Single owner of everything the queue knows about one track. Dropping the record aborts its
100/// attempt via [`AttemptGuard`].
101pub(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
140/// Authoritative store for the queue's track list.
141///
142/// Single owner of `Vec<TrackRecord>`; shared between [`Queue`](crate::Queue)
143/// and [`Loader`](crate::loader::Loader) via `Arc<Tracks>`. Every status
144/// transition MUST go through [`Tracks::set_status`] (or the attempt ops
145/// below) so the polled view and the reactive
146/// [`QueueEvent::TrackStatusChanged`] stream never drift.
147pub(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    /// Attach decoded-audio observation to this track's current resource, or
169    /// retain it for resource admission when loading has not started yet.
170    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    /// Whether the user's selection wants this track's live attempt.
183    ///
184    /// Read by the attempt itself, so it reflects a selection that arrived
185    /// after the attempt started.
186    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    /// Register a fresh attempt. Dedupes against a live attempt; replaces
198    /// one that is already cancelled but still unwinding.
199    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    /// Attempt finished. Disarms and removes the guard this ticket owns
217    /// (the token now belongs to the built `Resource`, or died with the
218    /// dropped load future); `failure` flips the track to `Failed`.
219    /// A stale ticket changes nothing.
220    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    /// Lock the underlying `Vec<TrackRecord>` for direct read/write.
247    /// Callers that only need to flip status should prefer
248    /// [`Self::set_status`].
249    pub(crate) fn lock(&self) -> std::sync::MutexGuard<'_, Vec<TrackRecord<S>>> {
250        self.inner.lock().unwrap_or_else(PoisonError::into_inner)
251    }
252
253    /// Attempt won its lane permit: flip the track to `Loading`. `false`
254    /// means the ticket was replaced or cancelled while waiting - the
255    /// caller must release the permit and bail out without loading.
256    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    /// Create the decoder half before resource opening and install its
283    /// control half in canonical per-track state. Any observer attached before
284    /// admission is transferred into the same bounded relay.
285    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    /// Move a track's pending load into the interactive lane: replace a
296    /// still-waiting (or cancelled-but-unwinding) attempt. An attempt
297    /// already holding a permit is kept - its download is progressing.
298    /// Vacant means the attempt just finished; the completion path owns
299    /// what happens next, so no new attempt starts.
300    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    /// Atomically mutate `record.status` and publish
324    /// [`QueueEvent::TrackStatusChanged`]. `Cancelled` and `Loaded` also
325    /// abort the track's live attempt: a cancelled track never keeps
326    /// loading, and a track whose resource is already in the player has
327    /// nothing left to load. Without the latter an attempt that outlives
328    /// the resource it was meant to fetch reports its own outcome
329    /// afterwards and overwrites a track that is already playable.
330    /// No-op when `id` is not present (caller raced `Queue::remove`).
331    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    /// Original source for `id`, if still queued.
347    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    /// Two queued tracks, each carrying its own source, so a lookup that
421    /// reaches the wrong record is visible rather than indistinguishable.
422    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    /// A track whose resource is already in the player has nothing left
536    /// to load. The attempt still in flight for it is fetching something
537    /// nobody waits for, and which side of that race the machine picks
538    /// must not decide whether the track is playable.
539    #[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}