⬆️ version to v0.3.41 — async queue sync refactoring

- Add `SyncCancelled` error variant for non-fatal cancellations
- Refactor queue sync to async via `MusicQueue::schedule_sync()`
  - Extract browse+conversion into internal helper
- Update all `QueueBackend::sync_queue()` signatures to accept cancel token and on_ready callback  
- Implement early-start logic (on pivot preservation or first insert)
 - Add `QueueReadyToPlay` and  ‘ QueueSyncCancelled➔ SSE events
- Replace blocking `refresh_attached_queue_for()` with non-blocking async dispatch in control_point.rs  
- Bump version to v0.3.41
This commit is contained in:
2026-04-09 11:19:54 +02:00
parent c128120697
commit 9dee193947
21 changed files with 1455 additions and 241 deletions

View File

@@ -3,84 +3,297 @@ use crate::queue::{
EnqueueMode, InternalQueue, OpenHomeQueue, QueueBackend, QueueFromRendererInfo,
};
use crate::{PlaybackItem, QueueSnapshot, RendererInfo};
use std::sync::{
atomic::{AtomicBool, Ordering::SeqCst},
Arc, Mutex,
};
use std::thread;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SyncScheduleOutcome {
Scheduled,
AlreadyRunning,
}
#[derive(Debug)]
pub enum MusicQueue {
enum MusicQueueBackend {
Internal(InternalQueue),
OpenHome(OpenHomeQueue),
}
#[derive(Debug)]
pub struct MusicQueue {
backend: MusicQueueBackend,
sync_in_progress: Arc<AtomicBool>,
sync_pending: Arc<AtomicBool>,
sync_cancel_token: Arc<AtomicBool>,
}
impl MusicQueue {
/// Creates a queue appropriate for the given renderer.
/// This is the factory method used by QueueFromRendererInfo trait.
pub fn from_renderer_info(info: &RendererInfo) -> Result<MusicQueue, ControlPointError> {
if info.capabilities().has_oh_playlist() {
Ok(MusicQueue::OpenHome(OpenHomeQueue::from_renderer_info(
info,
)?))
let backend = if info.capabilities().has_oh_playlist() {
MusicQueueBackend::OpenHome(OpenHomeQueue::from_renderer_info(info)?)
} else {
Ok(MusicQueue::Internal(InternalQueue::from_renderer_info(
info,
)?))
MusicQueueBackend::Internal(InternalQueue::from_renderer_info(info)?)
};
Ok(MusicQueue {
backend,
sync_in_progress: Arc::new(AtomicBool::new(false)),
sync_pending: Arc::new(AtomicBool::new(false)),
sync_cancel_token: Arc::new(AtomicBool::new(false)),
})
}
/// Creates a MusicQueue from an InternalQueue backend.
pub fn from_internal(queue: InternalQueue) -> MusicQueue {
MusicQueue {
backend: MusicQueueBackend::Internal(queue),
sync_in_progress: Arc::new(AtomicBool::new(false)),
sync_pending: Arc::new(AtomicBool::new(false)),
sync_cancel_token: Arc::new(AtomicBool::new(false)),
}
}
/// Creates a MusicQueue from an OpenHomeQueue backend.
pub fn from_openhome(queue: OpenHomeQueue) -> MusicQueue {
MusicQueue {
backend: MusicQueueBackend::OpenHome(queue),
sync_in_progress: Arc::new(AtomicBool::new(false)),
sync_pending: Arc::new(AtomicBool::new(false)),
sync_cancel_token: Arc::new(AtomicBool::new(false)),
}
}
/// Returns true if this is an OpenHome backend.
pub fn is_openhome(&self) -> bool {
matches!(self.backend, MusicQueueBackend::OpenHome(_))
}
/// Returns a reference to the OpenHome queue if this is an OpenHome backend.
pub fn as_openhome(&self) -> Option<&OpenHomeQueue> {
match &self.backend {
MusicQueueBackend::OpenHome(q) => Some(q),
MusicQueueBackend::Internal(_) => None,
}
}
/// Returns a mutable reference to the OpenHome queue if this is an OpenHome backend.
pub fn as_openhome_mut(&mut self) -> Option<&mut OpenHomeQueue> {
match &mut self.backend {
MusicQueueBackend::OpenHome(q) => Some(q),
MusicQueueBackend::Internal(_) => None,
}
}
pub fn len(&self) -> Result<usize, ControlPointError> {
match &self.backend {
MusicQueueBackend::Internal(q) => q.len(),
MusicQueueBackend::OpenHome(q) => q.len(),
}
}
pub fn schedule_sync(
queue_arc: &Arc<Mutex<MusicQueue>>,
renderer_id: &str,
items: Vec<PlaybackItem>,
pending_items_fn: Box<
dyn Fn() -> Result<Vec<PlaybackItem>, ControlPointError> + Send + 'static,
>,
on_ready: Option<Box<dyn FnOnce() + Send + 'static>>,
on_complete: Box<dyn Fn(usize) + Send + 'static>,
) -> SyncScheduleOutcome {
let (sync_in_progress, sync_pending, sync_cancel_token) = {
let q = queue_arc.lock().unwrap();
(
Arc::clone(&q.sync_in_progress),
Arc::clone(&q.sync_pending),
Arc::clone(&q.sync_cancel_token),
)
};
if sync_in_progress.swap(true, SeqCst) {
sync_cancel_token.store(true, SeqCst);
sync_pending.store(true, SeqCst);
return SyncScheduleOutcome::AlreadyRunning;
}
sync_cancel_token.store(false, SeqCst);
sync_pending.store(false, SeqCst);
let queue_arc = Arc::clone(queue_arc);
let thread_name = format!("queue-sync-{}", renderer_id);
thread::Builder::new()
.name(thread_name)
.spawn(move || {
struct Guard(Arc<AtomicBool>);
impl Drop for Guard {
fn drop(&mut self) {
self.0.store(false, SeqCst);
}
}
let _guard = Guard(Arc::clone(&sync_in_progress));
let mut current_items = items;
let mut current_on_ready = Some(on_ready);
let mut on_complete = Some(on_complete);
tracing::debug!(thread = %std::thread::current().name().unwrap_or("?"), "queue-sync thread started");
loop {
sync_pending.store(false, SeqCst);
sync_cancel_token.store(false, SeqCst);
// Extract the real on_ready BEFORE locking the queue.
// on_ready may call play_from_queue() which re-locks the queue,
// so we must NOT call it while holding queue_arc.
let real_on_ready = current_on_ready.take().flatten();
let on_ready_triggered = Arc::new(AtomicBool::new(false));
let proxy_on_ready: Option<Box<dyn FnOnce() + Send + 'static>> =
real_on_ready.as_ref().map(|_| {
let flag = Arc::clone(&on_ready_triggered);
Box::new(move || {
flag.store(true, SeqCst);
}) as Box<dyn FnOnce() + Send + 'static>
});
tracing::debug!(
thread = %std::thread::current().name().unwrap_or("?"),
items = current_items.len(),
has_on_ready = real_on_ready.is_some(),
"queue-sync: calling sync_queue"
);
let result = {
let mut q = queue_arc.lock().unwrap();
<MusicQueue as QueueBackend>::sync_queue(
&mut q,
current_items,
&sync_cancel_token,
proxy_on_ready,
)
};
// Queue lock is released here.
// Now safe to call on_ready (which may re-lock the queue).
if on_ready_triggered.load(SeqCst) {
tracing::debug!(
thread = %std::thread::current().name().unwrap_or("?"),
"queue-sync: on_ready triggered, calling callback"
);
if let Some(f) = real_on_ready {
f();
}
} else if real_on_ready.is_some() {
tracing::debug!(
thread = %std::thread::current().name().unwrap_or("?"),
"queue-sync: on_ready not triggered (cancelled or skipped)"
);
}
match result {
Err(ControlPointError::SyncCancelled) => {
tracing::debug!(
thread = %std::thread::current().name().unwrap_or("?"),
"queue-sync: cancelled"
);
}
Err(e) => {
tracing::warn!("queue-sync error: {}", e);
}
Ok(()) => {
tracing::debug!(
thread = %std::thread::current().name().unwrap_or("?"),
"queue-sync: completed successfully"
);
if let Some(cb) = on_complete.take() {
let queue_len = queue_arc.lock().unwrap().len().unwrap_or(0);
cb(queue_len);
}
}
}
if !sync_pending.load(SeqCst) {
break;
}
match pending_items_fn() {
Ok(new_items) => {
current_items = new_items;
current_on_ready = Some(None);
}
Err(e) => {
tracing::warn!("queue-sync pending re-fetch error: {}", e);
break;
}
}
}
tracing::debug!(thread = %std::thread::current().name().unwrap_or("?"), "queue-sync thread done");
})
.expect("Failed to spawn queue-sync thread");
SyncScheduleOutcome::Scheduled
}
}
impl QueueBackend for MusicQueue {
// Primitives
fn len(&self) -> Result<usize, ControlPointError> {
match self {
MusicQueue::Internal(q) => q.len(),
MusicQueue::OpenHome(q) => q.len(),
match &self.backend {
MusicQueueBackend::Internal(q) => q.len(),
MusicQueueBackend::OpenHome(q) => q.len(),
}
}
fn track_ids(&self) -> Result<Vec<u32>, ControlPointError> {
match self {
MusicQueue::Internal(q) => q.track_ids(),
MusicQueue::OpenHome(q) => q.track_ids(),
match &self.backend {
MusicQueueBackend::Internal(q) => q.track_ids(),
MusicQueueBackend::OpenHome(q) => q.track_ids(),
}
}
fn id_to_position(&self, id: u32) -> Result<usize, ControlPointError> {
match self {
MusicQueue::Internal(q) => q.id_to_position(id),
MusicQueue::OpenHome(q) => q.id_to_position(id),
match &self.backend {
MusicQueueBackend::Internal(q) => q.id_to_position(id),
MusicQueueBackend::OpenHome(q) => q.id_to_position(id),
}
}
fn position_to_id(&self, id: usize) -> Result<u32, ControlPointError> {
match self {
MusicQueue::Internal(q) => q.position_to_id(id),
MusicQueue::OpenHome(q) => q.position_to_id(id),
match &self.backend {
MusicQueueBackend::Internal(q) => q.position_to_id(id),
MusicQueueBackend::OpenHome(q) => q.position_to_id(id),
}
}
fn current_track(&self) -> Result<Option<u32>, ControlPointError> {
match self {
MusicQueue::Internal(q) => q.current_track(),
MusicQueue::OpenHome(q) => q.current_track(),
match &self.backend {
MusicQueueBackend::Internal(q) => q.current_track(),
MusicQueueBackend::OpenHome(q) => q.current_track(),
}
}
fn current_index(&self) -> Result<Option<usize>, ControlPointError> {
match self {
MusicQueue::Internal(q) => q.current_index(),
MusicQueue::OpenHome(q) => q.current_index(),
match &self.backend {
MusicQueueBackend::Internal(q) => q.current_index(),
MusicQueueBackend::OpenHome(q) => q.current_index(),
}
}
fn queue_snapshot(&self) -> Result<QueueSnapshot, ControlPointError> {
match self {
MusicQueue::Internal(q) => q.queue_snapshot(),
MusicQueue::OpenHome(q) => q.queue_snapshot(),
match &self.backend {
MusicQueueBackend::Internal(q) => q.queue_snapshot(),
MusicQueueBackend::OpenHome(q) => q.queue_snapshot(),
}
}
fn set_index(&mut self, index: Option<usize>) -> Result<(), ControlPointError> {
match self {
MusicQueue::Internal(q) => q.set_index(index),
MusicQueue::OpenHome(q) => q.set_index(index),
match &mut self.backend {
MusicQueueBackend::Internal(q) => q.set_index(index),
MusicQueueBackend::OpenHome(q) => q.set_index(index),
}
}
@@ -89,30 +302,35 @@ impl QueueBackend for MusicQueue {
items: Vec<PlaybackItem>,
current_index: Option<usize>,
) -> Result<(), ControlPointError> {
match self {
MusicQueue::Internal(q) => q.replace_queue(items, current_index),
MusicQueue::OpenHome(q) => q.replace_queue(items, current_index),
match &mut self.backend {
MusicQueueBackend::Internal(q) => q.replace_queue(items, current_index),
MusicQueueBackend::OpenHome(q) => q.replace_queue(items, current_index),
}
}
fn sync_queue(&mut self, items: Vec<PlaybackItem>) -> Result<(), ControlPointError> {
match self {
MusicQueue::Internal(q) => q.sync_queue(items),
MusicQueue::OpenHome(q) => q.sync_queue(items),
fn sync_queue(
&mut self,
items: Vec<PlaybackItem>,
cancel_token: &Arc<AtomicBool>,
on_ready: Option<Box<dyn FnOnce() + Send>>,
) -> Result<(), ControlPointError> {
match &mut self.backend {
MusicQueueBackend::Internal(q) => q.sync_queue(items, cancel_token, on_ready),
MusicQueueBackend::OpenHome(q) => q.sync_queue(items, cancel_token, on_ready),
}
}
fn get_item(&self, index: usize) -> Result<Option<PlaybackItem>, ControlPointError> {
match self {
MusicQueue::Internal(q) => q.get_item(index),
MusicQueue::OpenHome(q) => q.get_item(index),
match &self.backend {
MusicQueueBackend::Internal(q) => q.get_item(index),
MusicQueueBackend::OpenHome(q) => q.get_item(index),
}
}
fn replace_item(&mut self, index: usize, item: PlaybackItem) -> Result<(), ControlPointError> {
match self {
MusicQueue::Internal(q) => q.replace_item(index, item),
MusicQueue::OpenHome(q) => q.replace_item(index, item),
match &mut self.backend {
MusicQueueBackend::Internal(q) => q.replace_item(index, item),
MusicQueueBackend::OpenHome(q) => q.replace_item(index, item),
}
}
@@ -121,59 +339,59 @@ impl QueueBackend for MusicQueue {
items: Vec<PlaybackItem>,
mode: EnqueueMode,
) -> Result<(), ControlPointError> {
match self {
MusicQueue::Internal(q) => q.enqueue_items(items, mode),
MusicQueue::OpenHome(q) => q.enqueue_items(items, mode),
match &mut self.backend {
MusicQueueBackend::Internal(q) => q.enqueue_items(items, mode),
MusicQueueBackend::OpenHome(q) => q.enqueue_items(items, mode),
}
}
// Optimized helpers
fn clear_queue(&mut self) -> Result<(), ControlPointError> {
match self {
MusicQueue::Internal(q) => q.clear_queue(),
MusicQueue::OpenHome(q) => q.clear_queue(),
match &mut self.backend {
MusicQueueBackend::Internal(q) => q.clear_queue(),
MusicQueueBackend::OpenHome(q) => q.clear_queue(),
}
}
fn is_empty(&self) -> Result<bool, ControlPointError> {
match self {
MusicQueue::Internal(q) => q.is_empty(),
MusicQueue::OpenHome(q) => q.is_empty(),
match &self.backend {
MusicQueueBackend::Internal(q) => q.is_empty(),
MusicQueueBackend::OpenHome(q) => q.is_empty(),
}
}
fn upcoming_len(&self) -> Result<usize, ControlPointError> {
match self {
MusicQueue::Internal(q) => q.upcoming_len(),
MusicQueue::OpenHome(q) => q.upcoming_len(),
match &self.backend {
MusicQueueBackend::Internal(q) => q.upcoming_len(),
MusicQueueBackend::OpenHome(q) => q.upcoming_len(),
}
}
fn upcoming_items(&self) -> Result<Vec<PlaybackItem>, ControlPointError> {
match self {
MusicQueue::Internal(q) => q.upcoming_items(),
MusicQueue::OpenHome(q) => q.upcoming_items(),
match &self.backend {
MusicQueueBackend::Internal(q) => q.upcoming_items(),
MusicQueueBackend::OpenHome(q) => q.upcoming_items(),
}
}
fn peek_current(&mut self) -> Result<Option<(PlaybackItem, usize)>, ControlPointError> {
match self {
MusicQueue::Internal(q) => q.peek_current(),
MusicQueue::OpenHome(q) => q.peek_current(),
match &mut self.backend {
MusicQueueBackend::Internal(q) => q.peek_current(),
MusicQueueBackend::OpenHome(q) => q.peek_current(),
}
}
fn dequeue_next(&mut self) -> Result<Option<(PlaybackItem, usize)>, ControlPointError> {
match self {
MusicQueue::Internal(q) => q.dequeue_next(),
MusicQueue::OpenHome(q) => q.dequeue_next(),
match &mut self.backend {
MusicQueueBackend::Internal(q) => q.dequeue_next(),
MusicQueueBackend::OpenHome(q) => q.dequeue_next(),
}
}
fn append_or_init_index(&mut self, items: Vec<PlaybackItem>) -> Result<(), ControlPointError> {
match self {
MusicQueue::Internal(q) => q.append_or_init_index(items),
MusicQueue::OpenHome(q) => q.append_or_init_index(items),
match &mut self.backend {
MusicQueueBackend::Internal(q) => q.append_or_init_index(items),
MusicQueueBackend::OpenHome(q) => q.append_or_init_index(items),
}
}
}