⬆️ 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

@@ -28,7 +28,8 @@
//! - This identity is used by the sync helpers to preserve the current
//! track across queue rebuilds when the MediaServer content changes.
use crate::{PlaybackItem, QueueSnapshot, errors::ControlPointError};
use crate::{errors::ControlPointError, PlaybackItem, QueueSnapshot};
use std::sync::{atomic::AtomicBool, Arc};
/// High-level enqueue mode.
///
@@ -107,7 +108,12 @@ pub trait QueueBackend {
/// corresponds to the old current index track.
/// If the old current index track is absent from the new queue,
/// it is kept as the first item and the new items are appended after it.
fn sync_queue(&mut self, items: Vec<PlaybackItem>) -> Result<(), ControlPointError>;
fn sync_queue(
&mut self,
items: Vec<PlaybackItem>,
cancel_token: &Arc<AtomicBool>,
on_ready: Option<Box<dyn FnOnce() + Send>>,
) -> Result<(), ControlPointError>;
/// Returns the item at `index`, if it exists.
fn get_item(&self, index: usize) -> Result<Option<PlaybackItem>, ControlPointError>;

View File

@@ -20,6 +20,7 @@ use crate::{
queue::{MusicQueue, PlaybackItem, QueueBackend, QueueFromRendererInfo, QueueSnapshot},
DeviceId, DeviceIdentity, RendererInfo,
};
use std::sync::{atomic::AtomicBool, Arc};
/// Internal/local queue implementation.
///
@@ -269,25 +270,43 @@ impl QueueBackend for InternalQueue {
Ok(())
}
fn sync_queue(&mut self, items: Vec<PlaybackItem>) -> Result<(), ControlPointError> {
use tracing::debug;
fn sync_queue(
&mut self,
items: Vec<PlaybackItem>,
cancel_token: &Arc<AtomicBool>,
mut on_ready: Option<Box<dyn FnOnce() + Send>>,
) -> Result<(), ControlPointError> {
use std::sync::atomic::Ordering::SeqCst;
if items.is_empty() {
return self.replace_queue(Vec::new(), None);
if cancel_token.load(SeqCst) {
return Err(ControlPointError::SyncCancelled);
}
let has_current = self.current_index().ok().flatten().is_some();
if has_current {
if let Some(f) = on_ready.take() {
f();
}
}
if items.is_empty() {
let _ = self.replace_queue(Vec::new(), None);
return Ok(());
}
// Protéger les durées des streams contre la diminution
let updated_items = self.protect_stream_durations(items);
// Récupérer l'item actuel
let current = self.current_index.and_then(|idx| {
self.items
.get(idx)
.map(|item| (idx, item.uri.clone(), item.didl_id.clone()))
});
if cancel_token.load(SeqCst) {
return Err(ControlPointError::SyncCancelled);
}
if let Some((_current_idx, current_uri, current_didl_id)) = current {
// Chercher l'item actuel dans la nouvelle liste (par URI d'abord, puis par didl_id)
let new_idx = updated_items
.iter()
.position(|item| item.uri == current_uri)
@@ -298,34 +317,25 @@ impl QueueBackend for InternalQueue {
});
if let Some(new_idx) = new_idx {
// Item trouvé dans la nouvelle liste
debug!(
renderer = self.renderer_id.0.as_str(),
current_uri = current_uri.as_str(),
new_idx,
"sync_queue: current item found in new playlist"
);
self.replace_queue(updated_items, Some(new_idx))
self.replace_queue(updated_items, Some(new_idx))?;
} else {
// Item pas trouvé - cela ne devrait pas arriver si la playlist n'a pas changé
// Loguer pour diagnostic
debug!(
renderer = self.renderer_id.0.as_str(),
current_uri = current_uri.as_str(),
current_didl_id = current_didl_id.as_str(),
new_items_count = updated_items.len(),
"sync_queue: current item NOT found in new playlist, preserving as first item"
);
let current_item = self.items[self.current_index.unwrap()].clone();
let mut new_items = Vec::with_capacity(updated_items.len() + 1);
new_items.push(current_item);
new_items.extend(updated_items);
self.replace_queue(new_items, Some(0))
self.replace_queue(new_items, Some(0))?;
}
} else {
// Pas d'item actuel
self.replace_queue(updated_items, None)
self.replace_queue(updated_items, None)?;
}
if !has_current {
if let Some(f) = on_ready.take() {
f();
}
}
Ok(())
}
fn enqueue_items(
@@ -502,6 +512,6 @@ impl QueueFromRendererInfo for InternalQueue {
}
fn to_backend(self) -> MusicQueue {
MusicQueue::Internal(self)
MusicQueue::from_internal(self)
}
}

View File

@@ -7,20 +7,23 @@ mod snapshot;
use std::sync::{Arc, Mutex};
pub use backend::{EnqueueMode, QueueBackend};
pub use music_queue::MusicQueue;
pub use music_queue::{MusicQueue, SyncScheduleOutcome};
pub use snapshot::{PlaybackItem, QueueSnapshot};
// Internal queue implementations - not part of the public API
pub(crate) use interne::InternalQueue;
pub(crate) use openhome::OpenHomeQueue;
use crate::{RendererInfo, errors::ControlPointError};
use crate::music_renderer::time_utils::parse_time_flexible;
use crate::{errors::ControlPointError, RendererInfo};
/// Returns true if `new_dur` < `old_dur` (both parseable as HH:MM:SS/MM:SS/SS).
/// Used to protect stream durations from decreasing for the same track.
pub(super) fn stream_duration_decreased(old_dur: &str, new_dur: &str) -> bool {
match (parse_time_flexible(old_dur).ok(), parse_time_flexible(new_dur).ok()) {
match (
parse_time_flexible(old_dur).ok(),
parse_time_flexible(new_dur).ok(),
) {
(Some(old_secs), Some(new_secs)) => new_secs < old_secs,
_ => false,
}
@@ -28,7 +31,10 @@ pub(super) fn stream_duration_decreased(old_dur: &str, new_dur: &str) -> bool {
/// Returns true if `new_dur` > `old_dur` (both parseable as HH:MM:SS/MM:SS/SS).
pub(super) fn stream_duration_increased(old_dur: &str, new_dur: &str) -> bool {
match (parse_time_flexible(old_dur).ok(), parse_time_flexible(new_dur).ok()) {
match (
parse_time_flexible(old_dur).ok(),
parse_time_flexible(new_dur).ok(),
) {
(Some(old_secs), Some(new_secs)) => new_secs > old_secs,
_ => false,
}

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),
}
}
}

View File

@@ -1,5 +1,5 @@
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use std::sync::{atomic::AtomicBool, Arc, Mutex};
use std::time::SystemTime;
use std::usize;
@@ -544,32 +544,44 @@ impl OpenHomeQueue {
&mut self,
new_items: Vec<PlaybackItem>,
playing_id: usize,
cancel_token: &Arc<AtomicBool>,
on_ready: &mut Option<Box<dyn FnOnce() + Send>>,
) -> Result<(), ControlPointError> {
// Get current track IDs from OpenHome
use std::sync::atomic::Ordering::SeqCst;
let current_track_ids = self.track_ids()?;
// Delete everything except the currently playing item
// Using delete_id_if_exists() to handle cases where another control point
// may have already modified the playlist
for &track_id in current_track_ids.iter().rev() {
if cancel_token.load(SeqCst) {
return Err(ControlPointError::SyncCancelled);
}
if track_id as usize != playing_id {
self.playlist_client.delete_id_if_exists(track_id)?;
self.metadata_cache.lock().unwrap().remove(&track_id);
}
}
// Insert new items after the currently playing track
let mut previous_id = playing_id as u32;
let mut first_insert_done = false;
for item in new_items {
if cancel_token.load(SeqCst) {
return Err(ControlPointError::SyncCancelled);
}
let metadata = build_metadata_xml(&item);
let new_id = self
.playlist_client
.insert(previous_id, &item.uri, &metadata)?;
// Enregistrer les métadonnées dans le cache
self.cache_metadata(new_id, item.metadata, &item.uri);
previous_id = new_id;
if !first_insert_done && on_ready.is_some() {
first_insert_done = true;
if let Some(f) = on_ready.take() {
f();
}
}
}
debug!(
@@ -577,7 +589,6 @@ impl OpenHomeQueue {
"Gentle sync completed: preserved playing track as first item (not in new playlist)"
);
// Invalidate cache after playlist modifications
self.invalidate_track_caches();
Ok(())
@@ -589,8 +600,13 @@ impl OpenHomeQueue {
old_ids: &[u32],
keep_flags: &[bool],
position_label: &str,
cancel_token: &Arc<AtomicBool>,
) -> Result<(), ControlPointError> {
use std::sync::atomic::Ordering::SeqCst;
for (idx, &track_id) in old_ids.iter().enumerate().rev() {
if cancel_token.load(SeqCst) {
return Err(ControlPointError::SyncCancelled);
}
if !keep_flags[idx] {
debug!(
renderer = self.renderer_id.0.as_str(),
@@ -615,8 +631,11 @@ impl OpenHomeQueue {
keep_old_flags: &[bool],
mut previous_id: u32,
position_label: &str,
cancel_token: &Arc<AtomicBool>,
on_ready: &mut Option<Box<dyn FnOnce() + Send>>,
) -> Result<u32, ControlPointError> {
// Collect IDs of kept items (in order)
use std::sync::atomic::Ordering::SeqCst;
let remaining_ids: Vec<u32> = old_ids
.iter()
.enumerate()
@@ -624,15 +643,17 @@ impl OpenHomeQueue {
.collect();
let mut remaining_idx = 0;
let mut first_insert_done = false;
// Rebuild section
for (idx, item) in new_items.iter().enumerate() {
if cancel_token.load(SeqCst) {
return Err(ControlPointError::SyncCancelled);
}
if keep_new_flags[idx] {
let existing_id = remaining_ids[remaining_idx];
remaining_idx += 1;
previous_id = existing_id;
// Mettre à jour les métadonnées de l'item existant conservé
self.cache_metadata(existing_id, item.metadata.clone(), &item.uri);
debug!(
@@ -648,7 +669,6 @@ impl OpenHomeQueue {
.playlist_client
.insert(previous_id, &item.uri, &metadata)?;
// Enregistrer les métadonnées du nouvel item
self.cache_metadata(new_id, item.metadata.clone(), &item.uri);
debug!(
@@ -661,6 +681,13 @@ impl OpenHomeQueue {
new_id
);
previous_id = new_id;
if !first_insert_done && on_ready.is_some() {
first_insert_done = true;
if let Some(f) = on_ready.take() {
f();
}
}
}
}
@@ -677,6 +704,8 @@ impl OpenHomeQueue {
pivot_id: usize,
snapshot: &QueueSnapshot,
current_track_ids: &[u32],
cancel_token: &Arc<AtomicBool>,
on_ready: &mut Option<Box<dyn FnOnce() + Send>>,
) -> Result<(), ControlPointError> {
// Find the pivot index in our current state
let pivot_idx = current_track_ids
@@ -705,10 +734,15 @@ impl OpenHomeQueue {
let (keep_old_before, keep_new_before) = lcs_flags_optimized(&old_before, new_before);
// Delete items marked for deletion in AFTER part (reverse order)
self.delete_marked_items(&old_ids_after, &keep_old_after, "AFTER pivot")?;
self.delete_marked_items(&old_ids_after, &keep_old_after, "AFTER pivot", cancel_token)?;
// Delete items marked for deletion in BEFORE part (reverse order)
self.delete_marked_items(&old_ids_before, &keep_old_before, "BEFORE pivot")?;
self.delete_marked_items(
&old_ids_before,
&keep_old_before,
"BEFORE pivot",
cancel_token,
)?;
// Rebuild the playlist: [BEFORE, PIVOT, AFTER]
// Rebuild BEFORE part (we don't need the returned previous_id)
@@ -719,6 +753,8 @@ impl OpenHomeQueue {
&keep_old_before,
OPENHOME_PLAYLIST_HEAD_ID,
"BEFORE pivot",
cancel_token,
on_ready,
)?;
// PIVOT keeps its ID and position - it's the anchor point
@@ -748,6 +784,8 @@ impl OpenHomeQueue {
&keep_old_after,
previous_id,
"AFTER pivot",
cancel_token,
on_ready,
)?;
debug!(
@@ -769,7 +807,11 @@ impl OpenHomeQueue {
items: Vec<PlaybackItem>,
snapshot: &QueueSnapshot,
current_track_ids: &[u32],
cancel_token: &Arc<AtomicBool>,
on_ready: &mut Option<Box<dyn FnOnce() + Send>>,
) -> Result<(), ControlPointError> {
use std::sync::atomic::Ordering::SeqCst;
debug!(
renderer = self.renderer_id.0.as_str(),
current_count = snapshot.items.len(),
@@ -795,32 +837,22 @@ impl OpenHomeQueue {
"LCS computed: minimizing OpenHome playlist operations"
);
// Get current playing track ID BEFORE any modifications
let current_track_id = self.playlist_client.id().ok().filter(|&id| id != 0);
// Check if the currently playing track is in the new playlist
// If so, we should NOT use delete_all() - we must preserve it
let current_track_in_new_playlist = current_track_id.and_then(|current_id| {
items
.iter()
.position(|item| item.backend_id as u32 == current_id)
});
// If we're replacing everything (keep=0), use delete_all() BUT only if
// there's no currently playing track, OR if the current track is not in the new playlist.
// If current track IS in new playlist, we must preserve it using insert/delete operations.
if items_to_keep == 0 && items_to_delete > 0 {
if current_track_in_new_playlist.is_some() {
// Current track is in new playlist - use insert/delete instead of delete_all
// to preserve playback
debug!(
renderer = self.renderer_id.0.as_str(),
current_track_in_playlist = true,
"Preserving currently playing track - using insert/delete instead of delete_all"
);
// Fall through to selective deletion below
} else {
// No current track or not in new playlist - safe to use delete_all
debug!(
renderer = self.renderer_id.0.as_str(),
"Using delete_all() for complete replacement (safe - no current track or not in new playlist)"
@@ -829,19 +861,18 @@ impl OpenHomeQueue {
self.metadata_cache.lock().unwrap().clear();
}
} else {
// Selective deletion when keeping some items
for idx in (0..current_track_ids.len()).rev() {
if cancel_token.load(SeqCst) {
return Err(ControlPointError::SyncCancelled);
}
if !keep_current[idx] {
let track_id = current_track_ids[idx];
// Use delete_id_if_exists() to handle cases where another control point
// may have already modified the playlist
self.playlist_client.delete_id_if_exists(track_id)?;
self.metadata_cache.lock().unwrap().remove(&track_id);
}
}
}
// Rebuild by inserting new items
let remaining_ids: Vec<u32> = current_track_ids
.iter()
.enumerate()
@@ -856,8 +887,12 @@ impl OpenHomeQueue {
let mut remaining_idx = 0usize;
let mut previous_id = OPENHOME_PLAYLIST_HEAD_ID;
let mut first_insert_done = false;
for (idx, item) in items.into_iter().enumerate() {
if cancel_token.load(SeqCst) {
return Err(ControlPointError::SyncCancelled);
}
if keep_desired[idx] {
if remaining_idx >= remaining_ids.len() {
return Err(ControlPointError::OpenHomeError(format!(
@@ -868,8 +903,6 @@ impl OpenHomeQueue {
remaining_idx += 1;
previous_id = existing_id;
// Mettre à jour les métadonnées de l'item existant conservé
// La fonction cache_metadata gère la protection contre la diminution de durée
self.cache_metadata(existing_id, item.metadata, &item.uri);
} else {
let metadata = build_metadata_xml(&item);
@@ -877,10 +910,16 @@ impl OpenHomeQueue {
.playlist_client
.insert(previous_id, &item.uri, &metadata)?;
// Enregistrer les métadonnées du nouvel item
self.cache_metadata(new_id, item.metadata, &item.uri);
previous_id = new_id;
if !first_insert_done && on_ready.is_some() {
first_insert_done = true;
if let Some(f) = on_ready.take() {
f();
}
}
}
}
@@ -890,7 +929,6 @@ impl OpenHomeQueue {
)));
}
// Invalidate cache after playlist modifications
self.invalidate_track_caches();
Ok(())
@@ -1274,10 +1312,20 @@ impl QueueBackend for OpenHomeQueue {
Ok(())
}
fn sync_queue(&mut self, items: Vec<PlaybackItem>) -> Result<(), ControlPointError> {
fn sync_queue(
&mut self,
items: Vec<PlaybackItem>,
cancel_token: &Arc<AtomicBool>,
mut on_ready: Option<Box<dyn FnOnce() + Send>>,
) -> Result<(), ControlPointError> {
use std::sync::atomic::Ordering::SeqCst;
self.ensure_playlist_source_selected()?;
// DIAGNOSTIC: Log current track state before any modifications
if cancel_token.load(SeqCst) {
return Err(ControlPointError::SyncCancelled);
}
let pre_current_track = self.playlist_client.id().ok();
tracing::warn!(
renderer = self.renderer_id.0.as_str(),
@@ -1293,10 +1341,8 @@ impl QueueBackend for OpenHomeQueue {
);
self.playlist_client.delete_all()?;
self.metadata_cache.lock().unwrap().clear();
// Invalidate caches after delete_all (clears queue and current track)
self.invalidate_all_caches();
// DIAGNOSTIC: Log state after delete_all
let post_current_track = self.playlist_client.id().ok();
tracing::warn!(
renderer = self.renderer_id.0.as_str(),
@@ -1306,8 +1352,6 @@ impl QueueBackend for OpenHomeQueue {
return Ok(());
}
// Fast path: try to detect simple append-only or delete-from-end patterns
// without the expensive ReadList call that queue_snapshot() would trigger
match self.try_fast_path(&items) {
FastPathResult::AppendOnly { new_items } => {
debug!(
@@ -1315,23 +1359,25 @@ impl QueueBackend for OpenHomeQueue {
new_items_count = new_items.len(),
"sync_queue: fast path - append-only detected"
);
// Insert new items at the end, using the returned new_id as after_id for next insert
// Start after the last existing track (cached, no SOAP call needed)
let current_ids = self.track_ids()?;
let mut after_id = current_ids.last().copied().unwrap_or(OPENHOME_PLAYLIST_HEAD_ID);
let mut after_id = current_ids
.last()
.copied()
.unwrap_or(OPENHOME_PLAYLIST_HEAD_ID);
let mut uri_cache = self.uri_by_id.lock().unwrap();
for item in &new_items {
if cancel_token.load(SeqCst) {
return Err(ControlPointError::SyncCancelled);
}
let metadata = build_metadata_xml(item);
let uri = item.uri.as_str();
let metadata_xml = metadata.as_str();
after_id = self.playlist_client.insert(after_id, uri, metadata_xml)?;
// Update URI cache
if !item.uri.is_empty() {
uri_cache.insert(after_id, item.uri.clone());
}
}
drop(uri_cache);
// Invalidate caches after inserts
self.invalidate_track_caches();
return Ok(());
}
@@ -1341,11 +1387,12 @@ impl QueueBackend for OpenHomeQueue {
delete_count = delete_ids.len(),
"sync_queue: fast path - delete-from-end detected"
);
// Delete items from the end
for id in delete_ids.iter().rev() {
if cancel_token.load(SeqCst) {
return Err(ControlPointError::SyncCancelled);
}
self.playlist_client.delete_id(*id)?;
}
// Invalidate caches after deletes
self.invalidate_all_caches();
return Ok(());
}
@@ -1357,9 +1404,6 @@ impl QueueBackend for OpenHomeQueue {
}
}
// Synchronize local state with the actual OpenHome playlist before computing
// differences. Without this, any drift between our cache and the renderer
// (e.g., manual edits from another control point) would keep the stale items.
let snapshot = self.queue_snapshot()?;
debug!(
@@ -1370,9 +1414,6 @@ impl QueueBackend for OpenHomeQueue {
"sync_queue: snapshot vs new items comparison"
);
// Note: current_index may point to an index that doesn't exist in items
// if the OpenHome renderer is in an inconsistent state (e.g., IdArray returns
// IDs but ReadList returns empty TrackList). We must bounds-check here.
let playing_info = snapshot.current_index.and_then(|idx| {
if idx < snapshot.items.len() {
Some((
@@ -1399,8 +1440,14 @@ impl QueueBackend for OpenHomeQueue {
"OpenHome playlist state"
);
let has_pivot = playing_info.is_some();
if has_pivot {
if let Some(f) = on_ready.take() {
f();
}
}
if let Some((playing_idx, playing_id, playing_uri, playing_didl_id)) = playing_info {
// Find if the currently playing item is in the new playlist (by URI first, then by didl_id)
let new_playing_idx = items
.iter()
.position(|item| item.uri == playing_uri)
@@ -1420,8 +1467,6 @@ impl QueueBackend for OpenHomeQueue {
);
if let Some(pivot_idx) = new_playing_idx {
// CASE 2: Currently playing item IS in the new playlist
// Use gentle double-LCS strategy: preserve the pivot and sync before/after separately
debug!(
renderer = self.renderer_id.0.as_str(),
playing_idx,
@@ -1438,22 +1483,24 @@ impl QueueBackend for OpenHomeQueue {
playing_id,
&snapshot,
&current_ids_for_pivot,
cancel_token,
&mut on_ready,
)?;
} else {
// CASE 1: Currently playing item NOT in the new playlist
// Keep it as first item and append the new playlist after it
debug!(
renderer = self.renderer_id.0.as_str(),
playing_idx,
"Gentle sync: currently playing item not in new playlist, preserving as first item"
);
self.replace_queue_preserve_current(items, playing_id)?;
self.replace_queue_preserve_current(
items,
playing_id,
cancel_token,
&mut on_ready,
)?;
}
} else {
// No currently playing item or can't determine it - use standard LCS
// BUT first check if this is because the OpenHome device returned empty playlist
// This could cause the queue to be cleared incorrectly
if snapshot.items.is_empty() && !items.is_empty() {
tracing::warn!(
renderer = self.renderer_id.0.as_str(),
@@ -1461,8 +1508,6 @@ impl QueueBackend for OpenHomeQueue {
new_items = items.len(),
"OpenHome playlist appears empty - possible stale cache or device issue, NOT clearing queue"
);
// Don't call replace_queue_standard_lcs with empty snapshot - it would clear our queue
// Instead, just add the new items without deleting existing ones
return self.enqueue_items(items, crate::queue::EnqueueMode::AppendToEnd);
}
@@ -1472,10 +1517,15 @@ impl QueueBackend for OpenHomeQueue {
);
let current_ids_for_lcs: Vec<u32> =
snapshot.items.iter().map(|i| i.backend_id as u32).collect();
self.replace_queue_standard_lcs(items, &snapshot, &current_ids_for_lcs)?;
self.replace_queue_standard_lcs(
items,
&snapshot,
&current_ids_for_lcs,
cancel_token,
&mut on_ready,
)?;
}
// DIAGNOSTIC: Log state after sync completes
let post_current_track = self.playlist_client.id().ok();
let post_ids = self.track_ids();
tracing::warn!(
@@ -1694,6 +1744,6 @@ impl QueueFromRendererInfo for OpenHomeQueue {
}
fn to_backend(self) -> MusicQueue {
MusicQueue::OpenHome(self)
MusicQueue::from_openhome(self)
}
}

View File

@@ -1,4 +1,4 @@
use crate::{DeviceId, model::TrackMetadata};
use crate::{model::TrackMetadata, DeviceId};
/// Canonical representation of a track in a renderer queue.
///