énorme refactoring de PMOcontrol step 1
This commit is contained in:
453
pmocontrol/src/queue/backend.rs
Normal file
453
pmocontrol/src/queue/backend.rs
Normal file
@@ -0,0 +1,453 @@
|
||||
//! Generic queue abstraction for PMOControl.
|
||||
//!
|
||||
//! This module defines:
|
||||
//! - the canonical `PlaybackItem` structure used by the ControlPoint queues,
|
||||
//! - a generic `QueueSnapshot` view,
|
||||
//! - the `EnqueueMode` enum,
|
||||
//! - the `QueueBackend` trait, which abstracts queue manipulation for
|
||||
//! different backends (internal/local queue, OpenHome playlist, …).
|
||||
//!
|
||||
//! Design goals:
|
||||
//! - All queue manipulation logic (length, current index, enqueue, replace,
|
||||
//! navigation, sync with MediaServer, …) is centralized here.
|
||||
//! - Backends only implement a extremely small set of primitives; all
|
||||
//! higher–level operations are provided as default methods.
|
||||
//! - This trait NEVER starts playback. It only manipulates the queue
|
||||
//! structure. Transport/renderer logic (play/pause/seek/…) is handled
|
||||
//! elsewhere (e.g. `TransportControl` / `MusicRenderer`).
|
||||
//!
|
||||
//! Identity model:
|
||||
//! - We are in a UPnP Control Point context.
|
||||
//! - Every `PlaybackItem` comes from a UPnP MediaServer (ContentDirectory)
|
||||
//! and is a projection of a DIDL-Lite `item`.
|
||||
//! - The logical identity of a track is the pair
|
||||
//! (media_server_id, didl_id)
|
||||
//! where:
|
||||
//! * `media_server_id` identifies the UPnP MediaServer,
|
||||
//! * `didl_id` is the DIDL-Lite `id` attribute for the item.
|
||||
//! - This identity is used by the sync helpers to preserve the current
|
||||
//! track across queue rebuilds when the MediaServer content changes.
|
||||
|
||||
use crate::DeviceId;
|
||||
use crate::errors::ControlPointError;
|
||||
// ADAPTE ces imports aux modules existants dans pmocontrol.
|
||||
// Exemple probable :
|
||||
// use crate::model::MediaServerId;
|
||||
// use crate::model::TrackMetadata;
|
||||
use crate::model::TrackMetadata;
|
||||
|
||||
/// Canonical representation of a track in a renderer queue.
|
||||
///
|
||||
/// This type is the bridge between:
|
||||
/// - the UPnP MediaServer (DIDL-Lite items),
|
||||
/// - the ControlPoint runtime,
|
||||
/// - and the different queue backends (internal / OpenHome).
|
||||
///
|
||||
/// It is intentionally DIDL-centric: every item in a queue comes from
|
||||
/// a UPnP ContentDirectory and carries its MediaServer identity.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct PlaybackItem {
|
||||
/// Identifier of the UPnP MediaServer that owns this content.
|
||||
///
|
||||
/// Typically this is the UDN of the MediaServer device, or an
|
||||
/// equivalent logical identifier.
|
||||
pub media_server_id: DeviceId,
|
||||
|
||||
/// L'ID interne de l'item dans la queue.
|
||||
/// Cet ID n'a de sens que lors du retour d'un snapshot.
|
||||
/// Dans une queue interne, il peut avoir n'importe quelle valeur,
|
||||
/// l'ID qui compte et la position dans le vecteur.
|
||||
/// Par principe, on la peut la mettre égale à usize::MAX.
|
||||
pub backend_id: usize,
|
||||
|
||||
/// DIDL-Lite `id` attribute of the `item` in the ContentDirectory.
|
||||
///
|
||||
/// This, combined with `media_server_id`, is the logical identity
|
||||
/// of the track across refreshes of the MediaServer state.
|
||||
pub didl_id: String,
|
||||
|
||||
/// Main resource URI to be used for playback.
|
||||
///
|
||||
/// This is usually the first `<res>` element (or a selected one)
|
||||
/// from the DIDL-Lite item.
|
||||
pub uri: String,
|
||||
|
||||
/// UPnP protocolInfo string for the resource (e.g., "http-get:*:audio/flac:*").
|
||||
///
|
||||
/// This string describes the protocol, network, MIME type, and additional
|
||||
/// info about the media resource. It's required for proper UPnP/OpenHome
|
||||
/// renderer compatibility.
|
||||
pub protocol_info: String,
|
||||
|
||||
/// Optional rich metadata for the track (title, artist, album, cover,
|
||||
/// duration, …).
|
||||
///
|
||||
/// The exact structure is defined in `TrackMetadata` and may
|
||||
/// aggregate information from DIDL, tags, or additional sources.
|
||||
pub metadata: Option<TrackMetadata>,
|
||||
}
|
||||
|
||||
impl PlaybackItem {
|
||||
/// Returns a stable, backend-agnostic logical identifier for this item.
|
||||
///
|
||||
/// By default this is the concatenation of the MediaServer identifier
|
||||
/// and the DIDL `id`. Backends and higher-level logic should use this
|
||||
/// when they need to match items across queue rebuilds.
|
||||
pub fn unique_id(&self) -> String {
|
||||
// ADAPTE si MediaServerId n'implémente pas Display : utilise
|
||||
// un champ string interne ou une méthode as_str().
|
||||
format!("{}::{}", self.media_server_id.0, self.didl_id)
|
||||
}
|
||||
}
|
||||
|
||||
/// Logical snapshot of a renderer queue.
|
||||
///
|
||||
/// This is the canonical view used by the ControlPoint and the REST/API
|
||||
/// layer. It is independent of how the queue is actually stored (local
|
||||
/// in-memory queue, OpenHome playlist, …).
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct QueueSnapshot {
|
||||
/// All items currently in the queue, in play order.
|
||||
pub items: Vec<PlaybackItem>,
|
||||
/// Index (0-based) of the current item in `items`, or `None` if
|
||||
/// no item is currently selected.
|
||||
pub current_index: Option<usize>,
|
||||
}
|
||||
|
||||
impl QueueSnapshot {
|
||||
/// Returns the number of items in the snapshot.
|
||||
pub fn len(&self) -> usize {
|
||||
self.items.len()
|
||||
}
|
||||
|
||||
/// Returns `true` if the snapshot contains no items.
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.items.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
/// High-level enqueue mode.
|
||||
///
|
||||
/// This enum specifies how new items should be inserted relative to the
|
||||
/// existing queue when using `QueueBackend::enqueue_items`.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub enum EnqueueMode {
|
||||
/// Append new items at the end of the queue.
|
||||
AppendToEnd,
|
||||
/// Insert new items immediately after the current index
|
||||
/// (or at the beginning if there is no current index).
|
||||
InsertAfterCurrent,
|
||||
/// Replace the whole queue with the new items.
|
||||
ReplaceAll,
|
||||
}
|
||||
|
||||
/// Backend abstraction for a renderer queue.
|
||||
///
|
||||
/// A `QueueBackend` exposes and manipulates the structural state of a queue
|
||||
/// for a given renderer instance:
|
||||
///
|
||||
/// - list of items,
|
||||
/// - current index,
|
||||
/// - replacement and mutation of items.
|
||||
///
|
||||
/// It does **not** control playback. Transport actions (“play current item”,
|
||||
/// “seek”, …) are handled by other components (e.g. `TransportControl`).
|
||||
///
|
||||
/// Each queue instance is bound to a single renderer by construction. The
|
||||
/// trait therefore does not take a `RendererId` parameter; all methods
|
||||
/// operate directly on `self`.
|
||||
///
|
||||
/// Implementors must provide a small set of primitives. All other methods
|
||||
/// are default helpers that can usually be reused as-is.
|
||||
pub trait QueueBackend {
|
||||
// =====================================================================
|
||||
// BACKEND PRIMITIVES (must be implemented)
|
||||
// =====================================================================
|
||||
|
||||
/// Returns the full snapshot (items + current index) of this queue.
|
||||
fn queue_snapshot(&self) -> Result<QueueSnapshot, ControlPointError>;
|
||||
|
||||
/// Sets the current index for this queue.
|
||||
///
|
||||
/// This method only updates the queue structure (pointer to the current
|
||||
/// item). It MUST NOT start playback.
|
||||
fn set_index(&mut self, index: Option<usize>) -> Result<(), ControlPointError>;
|
||||
|
||||
/// Replaces the entire queue with a new list of items and a new
|
||||
/// current index.
|
||||
fn replace_queue(
|
||||
&mut self,
|
||||
items: Vec<PlaybackItem>,
|
||||
current_index: Option<usize>,
|
||||
) -> Result<(), ControlPointError>;
|
||||
|
||||
/// Returns the item at `index`, if it exists.
|
||||
fn get_item(&self, index: usize) -> Result<Option<PlaybackItem>, ControlPointError>;
|
||||
|
||||
/// Replaces the item at `index` with `item`.
|
||||
fn replace_item(&mut self, index: usize, item: PlaybackItem) -> Result<(), ControlPointError>;
|
||||
|
||||
// =====================================================================
|
||||
// DEFAULT HELPERS (backend-agnostic logic)
|
||||
// =====================================================================
|
||||
|
||||
/// Clears the queue.
|
||||
fn clear_queue(&mut self) -> Result<(), ControlPointError> {
|
||||
self.replace_queue(Vec::new(), None)
|
||||
}
|
||||
|
||||
/// Alias for `clear_queue`, semantic name for “empty before rebuild”.
|
||||
fn empty_queue(&mut self) -> Result<(), ControlPointError> {
|
||||
self.clear_queue()
|
||||
}
|
||||
|
||||
/// Returns the current index, if any.
|
||||
fn current_index(&self) -> Result<Option<usize>, ControlPointError> {
|
||||
Ok(self.queue_snapshot()?.current_index)
|
||||
}
|
||||
|
||||
/// Returns the number of items in the queue.
|
||||
fn len(&self) -> Result<usize, ControlPointError> {
|
||||
Ok(self.queue_snapshot()?.len())
|
||||
}
|
||||
|
||||
/// Returns `true` if the queue is empty.
|
||||
fn is_empty(&self) -> Result<bool, ControlPointError> {
|
||||
Ok(self.queue_snapshot()?.is_empty())
|
||||
}
|
||||
|
||||
/// Returns a full snapshot of the queue.
|
||||
fn full_snapshot(&self) -> Result<QueueSnapshot, ControlPointError> {
|
||||
self.queue_snapshot()
|
||||
}
|
||||
|
||||
/// Returns an iterator over all items in the queue.
|
||||
///
|
||||
/// The default implementation:
|
||||
/// - takes a snapshot,
|
||||
/// - returns a boxed iterator owning the underlying `Vec`.
|
||||
fn iter_items(&self) -> Result<Box<dyn Iterator<Item = PlaybackItem>>, ControlPointError> {
|
||||
let snapshot = self.queue_snapshot()?;
|
||||
Ok(Box::new(snapshot.items.into_iter()))
|
||||
}
|
||||
|
||||
/// Returns the list of items that come strictly after the current index.
|
||||
fn upcoming_items(&self) -> Result<Vec<PlaybackItem>, ControlPointError> {
|
||||
let snapshot = self.queue_snapshot()?;
|
||||
let items = match snapshot.current_index {
|
||||
None => snapshot.items,
|
||||
Some(idx) => snapshot.items.into_iter().skip(idx + 1).collect(),
|
||||
};
|
||||
Ok(items)
|
||||
}
|
||||
|
||||
/// Returns how many items remain in the queue after the current index.
|
||||
fn upcoming_len(&self) -> Result<usize, ControlPointError> {
|
||||
Ok(self.upcoming_items()?.len())
|
||||
}
|
||||
|
||||
/// Returns the current item (or the first pending item if no index is set)
|
||||
/// along with the count of remaining items.
|
||||
fn peek_current(&self) -> Result<Option<(PlaybackItem, usize)>, ControlPointError> {
|
||||
let snapshot = self.queue_snapshot()?;
|
||||
let QueueSnapshot {
|
||||
items,
|
||||
current_index,
|
||||
} = snapshot;
|
||||
|
||||
if items.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let len = items.len();
|
||||
let (item, resolved_index) = match current_index {
|
||||
Some(idx) if idx < len => (items.get(idx).cloned(), Some(idx)),
|
||||
_ => (items.first().cloned(), None),
|
||||
};
|
||||
|
||||
let item = match item {
|
||||
Some(item) => item,
|
||||
None => return Ok(None),
|
||||
};
|
||||
|
||||
let remaining = match resolved_index {
|
||||
Some(idx) => len.saturating_sub(idx + 1),
|
||||
None => len,
|
||||
};
|
||||
|
||||
Ok(Some((item, remaining)))
|
||||
}
|
||||
|
||||
/// Advances the queue to the next item (respecting the current index) and
|
||||
/// returns it with the number of remaining items.
|
||||
fn dequeue_next(&mut self) -> Result<Option<(PlaybackItem, usize)>, ControlPointError> {
|
||||
let snapshot = self.queue_snapshot()?;
|
||||
let QueueSnapshot {
|
||||
items,
|
||||
current_index,
|
||||
} = snapshot;
|
||||
|
||||
if items.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let len = items.len();
|
||||
let next_index = match current_index {
|
||||
None => 0,
|
||||
Some(idx) => {
|
||||
let candidate = idx + 1;
|
||||
if candidate >= len {
|
||||
return Ok(None);
|
||||
}
|
||||
candidate
|
||||
}
|
||||
};
|
||||
|
||||
let Some(item) = items.get(next_index).cloned() else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
let remaining = len.saturating_sub(next_index + 1);
|
||||
self.set_index(Some(next_index))?;
|
||||
Ok(Some((item, remaining)))
|
||||
}
|
||||
|
||||
/// Enqueues items according to the selected `EnqueueMode`.
|
||||
///
|
||||
/// This method only manipulates the queue structure; it does not
|
||||
/// start playback.
|
||||
fn enqueue_items(&mut self, items: Vec<PlaybackItem>, mode: EnqueueMode) -> Result<(), ControlPointError> {
|
||||
let mut snapshot = self.queue_snapshot()?;
|
||||
|
||||
match mode {
|
||||
EnqueueMode::AppendToEnd => {
|
||||
snapshot.items.extend(items);
|
||||
}
|
||||
EnqueueMode::InsertAfterCurrent => {
|
||||
let insert_pos = snapshot
|
||||
.current_index
|
||||
.map(|i| (i + 1).min(snapshot.items.len()))
|
||||
.unwrap_or(0);
|
||||
|
||||
for (offset, it) in items.into_iter().enumerate() {
|
||||
snapshot.items.insert(insert_pos + offset, it);
|
||||
}
|
||||
}
|
||||
EnqueueMode::ReplaceAll => {
|
||||
snapshot.items = items;
|
||||
snapshot.current_index = None;
|
||||
}
|
||||
}
|
||||
|
||||
self.replace_queue(snapshot.items, snapshot.current_index)
|
||||
}
|
||||
|
||||
/// Replaces the queue with `items` and sets a default index.
|
||||
fn replace_all(&mut self, items: Vec<PlaybackItem>) -> Result<(), ControlPointError> {
|
||||
if items.is_empty() {
|
||||
self.replace_queue(Vec::new(), None)
|
||||
} else {
|
||||
self.replace_queue(items, Some(0))
|
||||
}
|
||||
}
|
||||
|
||||
/// Appends items and, if the queue was previously empty, initializes
|
||||
/// the current index to `0`.
|
||||
fn append_or_init_index(&mut self, items: Vec<PlaybackItem>) -> Result<(), ControlPointError> {
|
||||
let was_empty = self.is_empty()?;
|
||||
let mut snapshot = self.queue_snapshot()?;
|
||||
snapshot.items.extend(items);
|
||||
|
||||
let new_index = if was_empty && !snapshot.items.is_empty() {
|
||||
Some(0)
|
||||
} else {
|
||||
snapshot.current_index
|
||||
};
|
||||
|
||||
self.replace_queue(snapshot.items, new_index)
|
||||
}
|
||||
|
||||
/// Computes the “next” index.
|
||||
fn next_index(&self) -> Result<Option<usize>, ControlPointError> {
|
||||
let len = self.len()?;
|
||||
if len == 0 {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
match self.current_index()? {
|
||||
None => Ok(Some(0)),
|
||||
Some(i) if i + 1 < len => Ok(Some(i + 1)),
|
||||
_ => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
/// Computes the “previous” index.
|
||||
fn previous_index(&self) -> Result<Option<usize>, ControlPointError> {
|
||||
match self.current_index()? {
|
||||
None => Ok(None),
|
||||
Some(0) => Ok(None),
|
||||
Some(i) => Ok(Some(i - 1)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Advances the current index to the next item, if any.
|
||||
fn advance(&mut self) -> Result<bool, ControlPointError> {
|
||||
if let Some(next) = self.next_index()? {
|
||||
self.set_index(Some(next))?;
|
||||
Ok(true)
|
||||
} else {
|
||||
Ok(false)
|
||||
}
|
||||
}
|
||||
|
||||
/// Rewinds the current index to the previous item, if any.
|
||||
fn rewind(&mut self) -> Result<bool, ControlPointError> {
|
||||
if let Some(prev) = self.previous_index()? {
|
||||
self.set_index(Some(prev))?;
|
||||
Ok(true)
|
||||
} else {
|
||||
Ok(false)
|
||||
}
|
||||
}
|
||||
|
||||
/// Convenience helper to update an item “in place” at the given index.
|
||||
fn update_item(
|
||||
&mut self,
|
||||
index: usize,
|
||||
update: impl FnOnce(PlaybackItem) -> PlaybackItem,
|
||||
) -> Result<(), ControlPointError> {
|
||||
if let Some(item) = self.get_item(index)? {
|
||||
let new_item = update(item);
|
||||
self.replace_item(index, new_item)
|
||||
} else {
|
||||
Err(ControlPointError::QueueError(format!("Queue index {} out of range", index)))
|
||||
}
|
||||
}
|
||||
|
||||
/// Synchronizes the queue with a new list of items coming from an
|
||||
/// external MediaServer, trying to preserve the current track.
|
||||
fn sync_from_external_preserve_current(&mut self, new_items: Vec<PlaybackItem>) -> Result<(), ControlPointError> {
|
||||
let snapshot = self.queue_snapshot()?;
|
||||
let current = snapshot
|
||||
.current_index
|
||||
.and_then(|i| snapshot.items.get(i).cloned());
|
||||
|
||||
let Some(current) = current else {
|
||||
return self.replace_all(new_items);
|
||||
};
|
||||
|
||||
let current_uid = current.unique_id();
|
||||
|
||||
if let Some(new_idx) = new_items
|
||||
.iter()
|
||||
.position(|it| it.unique_id() == current_uid)
|
||||
{
|
||||
self.replace_queue(new_items, Some(new_idx))
|
||||
} else {
|
||||
let mut items = Vec::with_capacity(new_items.len() + 1);
|
||||
items.push(current);
|
||||
items.extend(new_items);
|
||||
self.replace_queue(items, Some(0))
|
||||
}
|
||||
}
|
||||
}
|
||||
126
pmocontrol/src/queue/interne.rs
Normal file
126
pmocontrol/src/queue/interne.rs
Normal file
@@ -0,0 +1,126 @@
|
||||
//! Internal (local) queue implementation for PMOControl.
|
||||
//!
|
||||
//! This module provides a concrete implementation of the generic
|
||||
//! `QueueBackend` trait for queues that are fully managed inside the
|
||||
//! ControlPoint, without delegating playlist management to a remote
|
||||
//! backend (like OpenHome).
|
||||
//!
|
||||
//! In this design, each queue instance is associated to exactly one
|
||||
//! renderer. The queue does not need to know the renderer identifier:
|
||||
//! it is "bound" to the renderer by construction, and will be stored
|
||||
//! directly in the runtime (inside a higher-level `MusicQueue` enum).
|
||||
//!
|
||||
//! This internal queue:
|
||||
//! - owns its list of `PlaybackItem`s,
|
||||
//! - maintains a `current_index`,
|
||||
//! - never starts playback (transport control is handled elsewhere).
|
||||
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use anyhow::Result;
|
||||
|
||||
use crate::{
|
||||
DeviceId, RendererInfo,
|
||||
DeviceIdentity,
|
||||
errors::ControlPointError,
|
||||
queue::{
|
||||
MusicQueue,
|
||||
backend::{PlaybackItem, QueueBackend, QueueSnapshot},
|
||||
},
|
||||
};
|
||||
|
||||
/// Internal/local queue implementation.
|
||||
///
|
||||
/// This is the simplest possible queue backend:
|
||||
/// - a `Vec<PlaybackItem>`
|
||||
/// - plus an optional `current_index`.
|
||||
///
|
||||
/// It does not talk to any remote service. All operations are pure
|
||||
/// structural mutations on in-memory data.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct InternalQueue {
|
||||
renderer_id: DeviceId,
|
||||
items: Vec<PlaybackItem>,
|
||||
current_index: Option<usize>,
|
||||
}
|
||||
|
||||
impl InternalQueue {
|
||||
/// Creates an empty internal queue.
|
||||
pub fn new(renderer_id: DeviceId) -> Self {
|
||||
Self {
|
||||
renderer_id,
|
||||
items: Vec::new(),
|
||||
current_index: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_renderer_info(
|
||||
info: &RendererInfo,
|
||||
) -> Result<InternalQueue, ControlPointError> {
|
||||
Ok(InternalQueue::new(
|
||||
info.id(),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
/// Exposes a read-only view of the underlying items.
|
||||
pub fn items(&self) -> &[PlaybackItem] {
|
||||
&self.items
|
||||
}
|
||||
|
||||
/// Exposes the current index (read-only).
|
||||
pub fn current_index(&self) -> Option<usize> {
|
||||
self.current_index
|
||||
}
|
||||
}
|
||||
|
||||
impl QueueBackend for InternalQueue {
|
||||
fn queue_snapshot(&self) -> Result<QueueSnapshot, ControlPointError> {
|
||||
let mut items = self.items.clone();
|
||||
for (i, item) in items.iter_mut().enumerate() {
|
||||
item.backend_id = i;
|
||||
}
|
||||
|
||||
Ok(QueueSnapshot {
|
||||
items,
|
||||
current_index: self.current_index,
|
||||
})
|
||||
}
|
||||
|
||||
fn set_index(&mut self, index: Option<usize>) -> Result<(), ControlPointError> {
|
||||
match index {
|
||||
None => {
|
||||
self.current_index = None;
|
||||
}
|
||||
Some(i) => {
|
||||
if i < self.items.len() {
|
||||
self.current_index = Some(i);
|
||||
} else {
|
||||
self.current_index = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn replace_queue(
|
||||
&mut self,
|
||||
items: Vec<PlaybackItem>,
|
||||
current_index: Option<usize>,
|
||||
) -> Result<(), ControlPointError> {
|
||||
self.items = items;
|
||||
self.current_index = current_index.filter(|&i| i < self.items.len());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn get_item(&self, index: usize) -> Result<Option<PlaybackItem>, ControlPointError> {
|
||||
Ok(self.items.get(index).cloned())
|
||||
}
|
||||
|
||||
fn replace_item(&mut self, index: usize, item: PlaybackItem) -> Result<(), ControlPointError> {
|
||||
if index < self.items.len() {
|
||||
self.items[index] = item;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
7
pmocontrol/src/queue/mod.rs
Normal file
7
pmocontrol/src/queue/mod.rs
Normal file
@@ -0,0 +1,7 @@
|
||||
mod music_queue;
|
||||
mod openhome;
|
||||
mod backend;
|
||||
mod interne;
|
||||
|
||||
pub use music_queue::MusicQueue;
|
||||
pub use backend::{PlaybackItem, QueueBackend, QueueSnapshot, EnqueueMode};
|
||||
86
pmocontrol/src/queue/music_queue.rs
Normal file
86
pmocontrol/src/queue/music_queue.rs
Normal file
@@ -0,0 +1,86 @@
|
||||
use crate::RendererInfo;
|
||||
use crate::control_point::openhome_queue::OpenHomeQueue;
|
||||
use crate::errors::ControlPointError;
|
||||
use crate::openhome_playlist::OpenHomePlaylistSnapshot;
|
||||
use crate::queue::backend::{PlaybackItem, QueueBackend, QueueSnapshot};
|
||||
use crate::queue::interne::InternalQueue;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum MusicQueue {
|
||||
Internal(InternalQueue),
|
||||
OpenHome(OpenHomeQueue),
|
||||
}
|
||||
|
||||
impl MusicQueue {
|
||||
|
||||
pub fn from_renderer_info(info: &RendererInfo) -> Result<MusicQueue, ControlPointError> {
|
||||
if info.capabilities().has_oh_playlist() {
|
||||
|
||||
Ok(MusicQueue::OpenHome(OpenHomeQueue::from_renderer_info(info)?))
|
||||
} else {
|
||||
Ok(MusicQueue::Internal(InternalQueue::from_renderer_info(info)?))
|
||||
}
|
||||
}
|
||||
|
||||
pub fn openhome_playlist_snapshot(&self) -> Result<OpenHomePlaylistSnapshot, ControlPointError> {
|
||||
match self {
|
||||
MusicQueue::OpenHome(queue) => queue.openhome_playlist_snapshot(),
|
||||
_ => Err(ControlPointError::QueueError(format!(
|
||||
"OpenHome playlist snapshot is only available for OpenHome queues"
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn replace_with_attached_playlist(
|
||||
&mut self,
|
||||
items: Vec<PlaybackItem>,
|
||||
current_index: Option<usize>,
|
||||
) -> Result<(), ControlPointError> {
|
||||
match self {
|
||||
MusicQueue::OpenHome(queue) => queue.replace_entire_playlist(items, current_index),
|
||||
MusicQueue::Internal(queue) => queue.replace_queue(items, current_index),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
impl QueueBackend for MusicQueue {
|
||||
fn queue_snapshot(&self) -> Result<QueueSnapshot, ControlPointError> {
|
||||
match self {
|
||||
MusicQueue::Internal(q) => q.queue_snapshot(),
|
||||
MusicQueue::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),
|
||||
}
|
||||
}
|
||||
|
||||
fn replace_queue(
|
||||
&mut self,
|
||||
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),
|
||||
}
|
||||
}
|
||||
|
||||
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),
|
||||
}
|
||||
}
|
||||
|
||||
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),
|
||||
}
|
||||
}
|
||||
}
|
||||
931
pmocontrol/src/queue/openhome.rs
Normal file
931
pmocontrol/src/queue/openhome.rs
Normal file
@@ -0,0 +1,931 @@
|
||||
use pmodidl::DIDLLite;
|
||||
use quick_xml::escape::escape;
|
||||
use tracing::debug;
|
||||
|
||||
use crate::errors::ControlPointError;
|
||||
use crate::openhome_client::{
|
||||
OPENHOME_PLAYLIST_HEAD_ID, OhInfoClient, OhPlaylistClient, OhProductClient, OhTrackEntry,
|
||||
parse_track_metadata_from_didl,
|
||||
};
|
||||
use crate::openhome_playlist::{OpenHomePlaylistSnapshot, OpenHomePlaylistTrack};
|
||||
use crate::queue::backend::{EnqueueMode, PlaybackItem, QueueBackend, QueueSnapshot};
|
||||
use crate::{DeviceId, DeviceIdentity, RendererInfo};
|
||||
|
||||
/// Local mirror of an OpenHome playlist for a single renderer.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct OpenHomeQueue {
|
||||
renderer_id: DeviceId,
|
||||
playlist_client: OhPlaylistClient,
|
||||
info_client: Option<OhInfoClient>,
|
||||
product_client: Option<OhProductClient>,
|
||||
items: Vec<PlaybackItem>,
|
||||
current_index: Option<usize>,
|
||||
track_ids: Vec<u32>,
|
||||
}
|
||||
|
||||
impl OpenHomeQueue {
|
||||
pub fn new(
|
||||
renderer_id: DeviceId,
|
||||
playlist: OhPlaylistClient,
|
||||
info_client: Option<OhInfoClient>,
|
||||
product_client: Option<OhProductClient>,
|
||||
) -> Self {
|
||||
Self {
|
||||
renderer_id,
|
||||
playlist_client: playlist,
|
||||
info_client,
|
||||
product_client,
|
||||
items: Vec::new(),
|
||||
current_index: None,
|
||||
track_ids: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_renderer_info(
|
||||
info: &RendererInfo,
|
||||
) -> Result<OpenHomeQueue, ControlPointError> {
|
||||
let playlist_client = OhPlaylistClient::from_renderer_info(info)?;
|
||||
let info_client = OhInfoClient::from_renderer_info(&info).ok();
|
||||
let product_client = OhProductClient::from_renderer_info(&info).ok();
|
||||
|
||||
Ok(OpenHomeQueue::new(
|
||||
info.id(),
|
||||
playlist_client,
|
||||
info_client,
|
||||
product_client,
|
||||
))
|
||||
}
|
||||
|
||||
/// Reload the full OpenHome playlist snapshot into local playback items.
|
||||
///
|
||||
/// This mirrors the logic previously implemented by
|
||||
/// `OpenHomeRenderer::snapshot_openhome_playlist` but converts entries
|
||||
/// directly into `PlaybackItem`s.
|
||||
pub fn refresh_from_openhome(&mut self) -> Result<(), ControlPointError> {
|
||||
self.ensure_playlist_source_selected()?;
|
||||
let entries = self.playlist_client.read_all_tracks()?;
|
||||
let mut items = Vec::with_capacity(entries.len());
|
||||
let mut track_ids = Vec::with_capacity(entries.len());
|
||||
|
||||
for entry in &entries {
|
||||
items.push(self.playback_item_from_entry(entry));
|
||||
track_ids.push(entry.id);
|
||||
}
|
||||
|
||||
// Get the currently playing track ID from the renderer (may be None if no track is playing)
|
||||
let current_id = self.playlist_client.id().ok();
|
||||
|
||||
// Find the index of the current track in the playlist
|
||||
let current_index =
|
||||
current_id.and_then(|id| track_ids.iter().position(|entry_id| *entry_id == id));
|
||||
|
||||
self.items = items;
|
||||
self.track_ids = track_ids;
|
||||
self.current_index = current_index;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn openhome_playlist_snapshot(&self) -> Result<OpenHomePlaylistSnapshot, ControlPointError> {
|
||||
let tracks = self
|
||||
.items
|
||||
.iter()
|
||||
.zip(self.track_ids.iter())
|
||||
.map(|(item, track_id)| OpenHomePlaylistTrack {
|
||||
id: *track_id,
|
||||
uri: item.uri.clone(),
|
||||
title: item.metadata.as_ref().and_then(|m| m.title.clone()),
|
||||
artist: item.metadata.as_ref().and_then(|m| m.artist.clone()),
|
||||
album: item.metadata.as_ref().and_then(|m| m.album.clone()),
|
||||
album_art_uri: item.metadata.as_ref().and_then(|m| m.album_art_uri.clone()),
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(OpenHomePlaylistSnapshot {
|
||||
renderer_id: self.renderer_id.0.clone(),
|
||||
current_id: self
|
||||
.current_index
|
||||
.and_then(|idx| self.track_ids.get(idx).copied()),
|
||||
current_index: self.current_index,
|
||||
tracks,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn len(&self) -> usize {
|
||||
self.items.len()
|
||||
}
|
||||
|
||||
/// Return the list of OpenHome track IDs in order.
|
||||
pub fn openhome_track_ids(&self) -> Vec<u32> {
|
||||
self.track_ids.clone()
|
||||
}
|
||||
|
||||
pub fn select_track_id(&mut self, id: u32) -> Result<(), ControlPointError> {
|
||||
let index = match self.track_ids.iter().position(|&tid| tid == id) {
|
||||
Some(pos) => pos,
|
||||
None => {
|
||||
self.refresh_from_openhome()?;
|
||||
self.track_ids
|
||||
.iter()
|
||||
.position(|&tid| tid == id)
|
||||
.ok_or_else(|| ControlPointError::QueueError(format!("Unknown OpenHome track id {}", id)))?
|
||||
}
|
||||
};
|
||||
|
||||
self.ensure_playlist_source_selected()?;
|
||||
self.playlist_client.play_id(id)?;
|
||||
self.current_index = Some(index);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Selects and plays a track by its queue index (0-based).
|
||||
pub fn select_track_index(&mut self, index: usize) -> Result<(), ControlPointError> {
|
||||
let track_id = self.track_ids.get(index).copied().ok_or_else(|| {
|
||||
ControlPointError::QueueError(format!(
|
||||
"Index {} out of bounds (queue length: {})",
|
||||
index,
|
||||
self.track_ids.len()
|
||||
))
|
||||
})?;
|
||||
|
||||
self.ensure_playlist_source_selected()?;
|
||||
self.playlist_client.play_id(track_id)?;
|
||||
self.current_index = Some(index);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn clear(&mut self) -> Result<(), ControlPointError> {
|
||||
self.ensure_playlist_source_selected()?;
|
||||
self.playlist_client.delete_all()?;
|
||||
self.items.clear();
|
||||
self.track_ids.clear();
|
||||
self.current_index = None;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Replace the remote OpenHome playlist entirely with `items`.
|
||||
///
|
||||
/// This is used when attaching a media server playlist: we want to drop any
|
||||
/// stale entries (even if they were inserted by another control point) and
|
||||
/// rebuild the renderer playlist from scratch.
|
||||
pub fn replace_entire_playlist(
|
||||
&mut self,
|
||||
items: Vec<PlaybackItem>,
|
||||
current_index: Option<usize>,
|
||||
) -> Result<(), ControlPointError> {
|
||||
self.ensure_playlist_source_selected()?;
|
||||
self.playlist_client.delete_all()?;
|
||||
self.items.clear();
|
||||
self.track_ids.clear();
|
||||
self.current_index = None;
|
||||
|
||||
if items.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let mut rebuilt_items = Vec::with_capacity(items.len());
|
||||
let mut rebuilt_ids = Vec::with_capacity(items.len());
|
||||
let mut previous_id = OPENHOME_PLAYLIST_HEAD_ID;
|
||||
|
||||
for item in items {
|
||||
let metadata = build_metadata_xml(&item);
|
||||
let new_id = self
|
||||
.playlist_client
|
||||
.insert(previous_id, &item.uri, &metadata)?;
|
||||
previous_id = new_id;
|
||||
rebuilt_ids.push(new_id);
|
||||
rebuilt_items.push(self.item_with_openhome_id(item, new_id));
|
||||
}
|
||||
|
||||
let normalized = current_index
|
||||
.filter(|&idx| idx < rebuilt_ids.len())
|
||||
.or_else(|| Some(0));
|
||||
|
||||
self.items = rebuilt_items;
|
||||
self.track_ids = rebuilt_ids;
|
||||
self.current_index = normalized;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn add_playback_item(
|
||||
&mut self,
|
||||
item: PlaybackItem,
|
||||
after_id: Option<u32>,
|
||||
play: bool,
|
||||
) -> Result<u32, ControlPointError> {
|
||||
self.ensure_playlist_source_selected()?;
|
||||
let metadata_xml = build_metadata_xml(&item);
|
||||
let insert_after = match after_id {
|
||||
Some(id) => id,
|
||||
None => self.track_ids.last().copied().unwrap_or(0),
|
||||
};
|
||||
|
||||
let new_id = self
|
||||
.playlist_client
|
||||
.insert(insert_after, &item.uri, &metadata_xml)?;
|
||||
|
||||
if play {
|
||||
self.playlist_client.play_id(new_id)?;
|
||||
}
|
||||
|
||||
let mut insert_index = after_id
|
||||
.and_then(|id| {
|
||||
if id == 0 {
|
||||
Some(0)
|
||||
} else {
|
||||
self.track_ids
|
||||
.iter()
|
||||
.position(|tid| *tid == id)
|
||||
.map(|pos| pos + 1)
|
||||
}
|
||||
})
|
||||
.unwrap_or_else(|| self.track_ids.len());
|
||||
|
||||
if insert_index > self.track_ids.len() {
|
||||
insert_index = self.track_ids.len();
|
||||
}
|
||||
|
||||
self.track_ids.insert(insert_index, new_id);
|
||||
let stored_item = self.item_with_openhome_id(item, new_id);
|
||||
self.items.insert(insert_index, stored_item);
|
||||
|
||||
self.current_index = if play {
|
||||
Some(insert_index)
|
||||
} else {
|
||||
self.current_index
|
||||
.map(|idx| if insert_index <= idx { idx + 1 } else { idx })
|
||||
};
|
||||
|
||||
Ok(new_id)
|
||||
}
|
||||
|
||||
fn ensure_playlist_source_selected(&self) -> Result<(), ControlPointError> {
|
||||
if let Some(product) = &self.product_client {
|
||||
product.ensure_playlist_source_selected()
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn playback_item_from_entry(&self, entry: &OhTrackEntry) -> PlaybackItem {
|
||||
let metadata = parse_track_metadata_from_didl(&entry.metadata_xml);
|
||||
let didl_id = didl_id_from_metadata(&entry.metadata_xml)
|
||||
.unwrap_or_else(|| format!("openhome:{}", entry.id));
|
||||
PlaybackItem {
|
||||
media_server_id: DeviceId(format!("openhome:{}", self.renderer_id.0)),
|
||||
backend_id: entry.id as usize,
|
||||
didl_id,
|
||||
uri: entry.uri.clone(),
|
||||
// OpenHome tracks don't provide protocolInfo, use generic default
|
||||
protocol_info: "http-get:*:audio/*:*".to_string(),
|
||||
metadata,
|
||||
}
|
||||
}
|
||||
|
||||
fn item_with_openhome_id(&self, mut item: PlaybackItem, track_id: u32) -> PlaybackItem {
|
||||
item.didl_id = format!("openhome:{}", track_id);
|
||||
item.media_server_id = DeviceId(format!("openhome:{}", self.renderer_id.0));
|
||||
item
|
||||
}
|
||||
|
||||
fn ensure_track_id(&mut self, index: usize) -> Result<u32, ControlPointError> {
|
||||
if index >= self.items.len() {
|
||||
return Err(ControlPointError::OpenHomeError(format!("Index out of bounds in OpenHomeQueue: {}", index)));
|
||||
}
|
||||
|
||||
if let Some(id) = self.track_ids.get(index).copied() {
|
||||
return Ok(id);
|
||||
}
|
||||
|
||||
self.refresh_from_openhome()?;
|
||||
self.track_ids
|
||||
.get(index)
|
||||
.copied()
|
||||
.ok_or_else(|| ControlPointError::OpenHomeError(format!("Failed to resolve OpenHome track id at index {}", index)))
|
||||
}
|
||||
|
||||
/// CASE 1: Replace queue while preserving the currently playing item as first.
|
||||
/// The currently playing item is NOT in the new playlist, so we keep it as the first
|
||||
/// item and append the entire new playlist after it.
|
||||
fn replace_queue_preserve_current(
|
||||
&mut self,
|
||||
new_items: Vec<PlaybackItem>,
|
||||
playing_idx: usize,
|
||||
playing_id: u32,
|
||||
) -> Result<(), ControlPointError> {
|
||||
// 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 self.track_ids.iter().rev() {
|
||||
if track_id != playing_id {
|
||||
self.playlist_client.delete_id_if_exists(track_id)?;
|
||||
}
|
||||
}
|
||||
|
||||
// Rebuild: [currently_playing, new_items...]
|
||||
let mut rebuilt_items = Vec::with_capacity(1 + new_items.len());
|
||||
let mut rebuilt_ids = Vec::with_capacity(1 + new_items.len());
|
||||
|
||||
rebuilt_items.push(self.items[playing_idx].clone());
|
||||
rebuilt_ids.push(playing_id);
|
||||
|
||||
let mut previous_id = playing_id;
|
||||
for item in new_items {
|
||||
let metadata = build_metadata_xml(&item);
|
||||
let new_id = self
|
||||
.playlist_client
|
||||
.insert(previous_id, &item.uri, &metadata)?;
|
||||
previous_id = new_id;
|
||||
rebuilt_ids.push(new_id);
|
||||
rebuilt_items.push(self.item_with_openhome_id(item, new_id));
|
||||
}
|
||||
|
||||
self.items = rebuilt_items;
|
||||
self.track_ids = rebuilt_ids;
|
||||
self.current_index = Some(0); // Currently playing is now at index 0
|
||||
|
||||
debug!(
|
||||
renderer = self.renderer_id.0.as_str(),
|
||||
"Gentle sync completed: preserved playing track as first item (not in new playlist)"
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// CASE 2: Replace queue with double-LCS (before and after the pivot).
|
||||
/// The currently playing item IS in the new playlist, so we use it as a pivot
|
||||
/// and apply LCS separately to the portions before and after it.
|
||||
fn replace_queue_with_pivot(
|
||||
&mut self,
|
||||
new_items: Vec<PlaybackItem>,
|
||||
pivot_idx_new: usize,
|
||||
pivot_id: u32,
|
||||
) -> Result<(), ControlPointError> {
|
||||
// Find the pivot index in our current state
|
||||
let pivot_idx = self
|
||||
.track_ids
|
||||
.iter()
|
||||
.position(|&id| id == pivot_id)
|
||||
.ok_or_else(|| ControlPointError::OpenHomeError(format!("Pivot track ID {} not found in playlist", pivot_id)))?;
|
||||
|
||||
// Split current data at the pivot
|
||||
let old_before: Vec<PlaybackItem> = self.items[..pivot_idx].to_vec();
|
||||
let old_after: Vec<PlaybackItem> = self.items[pivot_idx + 1..].to_vec();
|
||||
let old_ids_before: Vec<u32> = self.track_ids[..pivot_idx].to_vec();
|
||||
let old_ids_after: Vec<u32> = self.track_ids[pivot_idx + 1..].to_vec();
|
||||
|
||||
let new_before = &new_items[..pivot_idx_new];
|
||||
let new_after = &new_items[pivot_idx_new + 1..];
|
||||
|
||||
// LCS on the AFTER part (using fresh data from OpenHome)
|
||||
let (keep_old_after, keep_new_after) = lcs_flags(&old_after, new_after);
|
||||
|
||||
// LCS on the BEFORE part (using fresh data from OpenHome)
|
||||
let (keep_old_before, keep_new_before) = lcs_flags(&old_before, new_before);
|
||||
|
||||
// Delete items marked for deletion in AFTER part (reverse order)
|
||||
for (idx, &track_id) in old_ids_after.iter().enumerate().rev() {
|
||||
if !keep_old_after[idx] {
|
||||
debug!(
|
||||
renderer = self.renderer_id.0.as_str(),
|
||||
track_id,
|
||||
position = "AFTER pivot",
|
||||
"RENDERER OP: DeleteId({})",
|
||||
track_id
|
||||
);
|
||||
self.playlist_client.delete_id_if_exists(track_id)?;
|
||||
}
|
||||
}
|
||||
|
||||
// Delete items marked for deletion in BEFORE part (reverse order)
|
||||
for (idx, &track_id) in old_ids_before.iter().enumerate().rev() {
|
||||
if !keep_old_before[idx] {
|
||||
debug!(
|
||||
renderer = self.renderer_id.0.as_str(),
|
||||
track_id,
|
||||
position = "BEFORE pivot",
|
||||
"RENDERER OP: DeleteId({})",
|
||||
track_id
|
||||
);
|
||||
self.playlist_client.delete_id_if_exists(track_id)?;
|
||||
}
|
||||
}
|
||||
|
||||
// Rebuild the playlist: [BEFORE, PIVOT, AFTER]
|
||||
let mut rebuilt_items = Vec::with_capacity(new_items.len());
|
||||
let mut rebuilt_ids = Vec::with_capacity(new_items.len());
|
||||
|
||||
// Collect IDs of kept items in BEFORE part (in order)
|
||||
let remaining_before: Vec<u32> = old_ids_before
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter_map(|(idx, &id)| if keep_old_before[idx] { Some(id) } else { None })
|
||||
.collect();
|
||||
|
||||
let mut remaining_before_idx = 0;
|
||||
let mut previous_id = OPENHOME_PLAYLIST_HEAD_ID;
|
||||
|
||||
// Rebuild BEFORE part
|
||||
for (idx, item) in new_before.iter().enumerate() {
|
||||
if keep_new_before[idx] {
|
||||
let existing_id = remaining_before[remaining_before_idx];
|
||||
remaining_before_idx += 1;
|
||||
previous_id = existing_id;
|
||||
rebuilt_ids.push(existing_id);
|
||||
rebuilt_items.push(self.item_with_openhome_id(item.clone(), existing_id));
|
||||
debug!(
|
||||
renderer = self.renderer_id.0.as_str(),
|
||||
track_id = existing_id,
|
||||
position = "BEFORE pivot",
|
||||
"KEPT existing track ID {}",
|
||||
existing_id
|
||||
);
|
||||
} else {
|
||||
let metadata = build_metadata_xml(item);
|
||||
let new_id = self
|
||||
.playlist_client
|
||||
.insert(previous_id, &item.uri, &metadata)?;
|
||||
debug!(
|
||||
renderer = self.renderer_id.0.as_str(),
|
||||
after_id = previous_id,
|
||||
new_id,
|
||||
position = "BEFORE pivot",
|
||||
"RENDERER OP: Insert(after={}) -> new_id={}",
|
||||
previous_id,
|
||||
new_id
|
||||
);
|
||||
previous_id = new_id;
|
||||
rebuilt_ids.push(new_id);
|
||||
rebuilt_items.push(self.item_with_openhome_id(item.clone(), new_id));
|
||||
}
|
||||
}
|
||||
|
||||
// Add PIVOT (keeps its ID!)
|
||||
rebuilt_ids.push(pivot_id);
|
||||
rebuilt_items.push(self.item_with_openhome_id(new_items[pivot_idx_new].clone(), pivot_id));
|
||||
previous_id = pivot_id;
|
||||
debug!(
|
||||
renderer = self.renderer_id.0.as_str(),
|
||||
pivot_id,
|
||||
pivot_idx_new,
|
||||
"PIVOT preserved with ID {} at index {}",
|
||||
pivot_id,
|
||||
pivot_idx_new
|
||||
);
|
||||
|
||||
// Collect IDs of kept items in AFTER part (in order)
|
||||
let remaining_after: Vec<u32> = old_ids_after
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter_map(|(idx, &id)| if keep_old_after[idx] { Some(id) } else { None })
|
||||
.collect();
|
||||
|
||||
let mut remaining_after_idx = 0;
|
||||
|
||||
// Rebuild AFTER part
|
||||
for (idx, item) in new_after.iter().enumerate() {
|
||||
if keep_new_after[idx] {
|
||||
let existing_id = remaining_after[remaining_after_idx];
|
||||
remaining_after_idx += 1;
|
||||
previous_id = existing_id;
|
||||
rebuilt_ids.push(existing_id);
|
||||
rebuilt_items.push(self.item_with_openhome_id(item.clone(), existing_id));
|
||||
debug!(
|
||||
renderer = self.renderer_id.0.as_str(),
|
||||
track_id = existing_id,
|
||||
position = "AFTER pivot",
|
||||
"KEPT existing track ID {}",
|
||||
existing_id
|
||||
);
|
||||
} else {
|
||||
let metadata = build_metadata_xml(item);
|
||||
let new_id = self
|
||||
.playlist_client
|
||||
.insert(previous_id, &item.uri, &metadata)?;
|
||||
debug!(
|
||||
renderer = self.renderer_id.0.as_str(),
|
||||
after_id = previous_id,
|
||||
new_id,
|
||||
position = "AFTER pivot",
|
||||
"RENDERER OP: Insert(after={}) -> new_id={}",
|
||||
previous_id,
|
||||
new_id
|
||||
);
|
||||
previous_id = new_id;
|
||||
rebuilt_ids.push(new_id);
|
||||
rebuilt_items.push(self.item_with_openhome_id(item.clone(), new_id));
|
||||
}
|
||||
}
|
||||
|
||||
self.items = rebuilt_items;
|
||||
self.track_ids = rebuilt_ids;
|
||||
self.current_index = Some(pivot_idx_new); // Pivot is at its new position
|
||||
|
||||
// VERIFICATION: Check that pivot ID is preserved
|
||||
let final_pivot_id = self.track_ids.get(pivot_idx_new).copied();
|
||||
if final_pivot_id != Some(pivot_id) {
|
||||
return Err(ControlPointError::OpenHomeError(format!(
|
||||
"CRITICAL BUG: Pivot ID changed from {} to {:?} during replace_queue_with_pivot!",
|
||||
pivot_id,
|
||||
final_pivot_id
|
||||
)));
|
||||
}
|
||||
|
||||
debug!(
|
||||
renderer = self.renderer_id.0.as_str(),
|
||||
pivot_idx = pivot_idx_new,
|
||||
pivot_id,
|
||||
final_playlist_len = self.track_ids.len(),
|
||||
pivot_verified = true,
|
||||
"Gentle sync completed: double-LCS with pivot (playing track preserved)"
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Standard LCS-based replacement (used when no currently playing item).
|
||||
fn replace_queue_standard_lcs(
|
||||
&mut self,
|
||||
items: Vec<PlaybackItem>,
|
||||
current_index: Option<usize>,
|
||||
) -> Result<(), ControlPointError> {
|
||||
let (keep_current, keep_desired) = lcs_flags(&self.items, &items);
|
||||
|
||||
let items_to_keep = keep_current.iter().filter(|&&k| k).count();
|
||||
let items_to_delete = keep_current.iter().filter(|&&k| !k).count();
|
||||
let items_to_add = keep_desired.iter().filter(|&&k| !k).count();
|
||||
|
||||
debug!(
|
||||
renderer = self.renderer_id.0.as_str(),
|
||||
keep = items_to_keep,
|
||||
delete = items_to_delete,
|
||||
add = items_to_add,
|
||||
"LCS computed: minimizing OpenHome playlist operations"
|
||||
);
|
||||
|
||||
// If we're replacing everything (keep=0), use delete_all() instead of
|
||||
// individual delete_id() calls. This is much more robust for live playlists
|
||||
// where track IDs can become invalid between refresh and deletion.
|
||||
if items_to_keep == 0 && items_to_delete > 0 {
|
||||
debug!(
|
||||
renderer = self.renderer_id.0.as_str(),
|
||||
"Using delete_all() for complete replacement (more robust for live playlists)"
|
||||
);
|
||||
self.playlist_client.delete_all()?;
|
||||
self.track_ids.clear();
|
||||
self.items.clear();
|
||||
} else {
|
||||
// Selective deletion when keeping some items
|
||||
for idx in (0..self.track_ids.len()).rev() {
|
||||
if !keep_current[idx] {
|
||||
let track_id = self.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.track_ids.remove(idx);
|
||||
self.items.remove(idx);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let remaining_ids = self.track_ids.clone();
|
||||
let mut remaining_idx = 0usize;
|
||||
let mut previous_id = OPENHOME_PLAYLIST_HEAD_ID;
|
||||
let mut rebuilt_items = Vec::with_capacity(items.len());
|
||||
let mut rebuilt_ids = Vec::with_capacity(items.len());
|
||||
|
||||
for (idx, item) in items.into_iter().enumerate() {
|
||||
if keep_desired[idx] {
|
||||
if remaining_idx >= remaining_ids.len() {
|
||||
return Err(ControlPointError::OpenHomeError(format!(
|
||||
"OpenHome playlist refresh bookkeeping mismatch (kept entries underflow)"
|
||||
)));
|
||||
}
|
||||
let existing_id = remaining_ids[remaining_idx];
|
||||
remaining_idx += 1;
|
||||
previous_id = existing_id;
|
||||
rebuilt_ids.push(existing_id);
|
||||
rebuilt_items.push(self.item_with_openhome_id(item, existing_id));
|
||||
} else {
|
||||
let metadata = build_metadata_xml(&item);
|
||||
let new_id = self
|
||||
.playlist_client
|
||||
.insert(previous_id, &item.uri, &metadata)?;
|
||||
previous_id = new_id;
|
||||
rebuilt_ids.push(new_id);
|
||||
rebuilt_items.push(self.item_with_openhome_id(item, new_id));
|
||||
}
|
||||
}
|
||||
|
||||
if remaining_idx != remaining_ids.len() {
|
||||
return Err(ControlPointError::OpenHomeError(format!(
|
||||
"OpenHome playlist refresh bookkeeping mismatch (kept entries overflow)"
|
||||
)));
|
||||
}
|
||||
|
||||
let previous_index = self.current_index.and_then(|idx| {
|
||||
if idx < rebuilt_ids.len() {
|
||||
Some(idx)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
});
|
||||
let normalized = current_index
|
||||
.filter(|&i| i < rebuilt_ids.len())
|
||||
.or(previous_index)
|
||||
.or_else(|| {
|
||||
if rebuilt_ids.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(0)
|
||||
}
|
||||
});
|
||||
self.items = rebuilt_items;
|
||||
self.track_ids = rebuilt_ids;
|
||||
self.current_index = normalized;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
pub fn didl_id_from_metadata(xml: &str) -> Option<String> {
|
||||
if xml.trim().is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let parsed = pmodidl::parse_metadata::<DIDLLite>(xml).ok()?;
|
||||
parsed.data.items.first().map(|item| item.id.clone())
|
||||
}
|
||||
|
||||
fn build_metadata_xml(item: &PlaybackItem) -> String {
|
||||
let title = item
|
||||
.metadata
|
||||
.as_ref()
|
||||
.and_then(|m| m.title.as_deref())
|
||||
.unwrap_or("Unknown");
|
||||
let escaped_title = escape(title);
|
||||
let escaped_uri = escape(item.uri.as_str());
|
||||
let escaped_id = escape(item.didl_id.as_str());
|
||||
|
||||
let mut xml = String::from(
|
||||
r#"<DIDL-Lite xmlns="urn:schemas-upnp-org:metadata-1-0/DIDL-Lite/" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:upnp="urn:schemas-upnp-org:metadata-1-0/upnp/">"#,
|
||||
);
|
||||
xml.push_str(&format!(
|
||||
r#"<item id="{}" parentID="-1" restricted="1">"#,
|
||||
escaped_id
|
||||
));
|
||||
xml.push_str(&format!("<dc:title>{}</dc:title>", escaped_title));
|
||||
|
||||
if let Some(meta) = &item.metadata {
|
||||
if let Some(artist) = meta.artist.as_deref() {
|
||||
let escaped = escape(artist);
|
||||
xml.push_str(&format!("<upnp:artist>{}</upnp:artist>", escaped));
|
||||
xml.push_str(&format!("<dc:creator>{}</dc:creator>", escaped));
|
||||
}
|
||||
if let Some(album) = meta.album.as_deref() {
|
||||
let escaped = escape(album);
|
||||
xml.push_str(&format!("<upnp:album>{}</upnp:album>", escaped));
|
||||
}
|
||||
if let Some(genre) = meta.genre.as_deref() {
|
||||
let escaped = escape(genre);
|
||||
xml.push_str(&format!("<upnp:genre>{}</upnp:genre>", escaped));
|
||||
}
|
||||
if let Some(uri) = meta.album_art_uri.as_deref() {
|
||||
let escaped = escape(uri);
|
||||
xml.push_str(&format!("<upnp:albumArtURI>{}</upnp:albumArtURI>", escaped));
|
||||
}
|
||||
if let Some(date) = meta.date.as_deref() {
|
||||
let escaped = escape(date);
|
||||
xml.push_str(&format!("<dc:date>{}</dc:date>", escaped));
|
||||
}
|
||||
if let Some(track_no) = meta.track_number.as_deref() {
|
||||
let escaped = escape(track_no);
|
||||
xml.push_str(&format!(
|
||||
"<upnp:originalTrackNumber>{}</upnp:originalTrackNumber>",
|
||||
escaped
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
let escaped_protocol_info = escape(item.protocol_info.as_str());
|
||||
xml.push_str(&format!(
|
||||
r#"<res protocolInfo="{}">{}</res>"#,
|
||||
escaped_protocol_info, escaped_uri
|
||||
));
|
||||
xml.push_str(r#"<upnp:class>object.item.audioItem.musicTrack</upnp:class></item></DIDL-Lite>"#);
|
||||
xml
|
||||
}
|
||||
|
||||
fn lcs_flags(current: &[PlaybackItem], desired: &[PlaybackItem]) -> (Vec<bool>, Vec<bool>) {
|
||||
let m = current.len();
|
||||
let n = desired.len();
|
||||
let mut dp = vec![vec![0u32; n + 1]; m + 1];
|
||||
|
||||
for i in 0..m {
|
||||
for j in 0..n {
|
||||
if current[i].uri == desired[j].uri {
|
||||
dp[i + 1][j + 1] = dp[i][j] + 1;
|
||||
} else {
|
||||
dp[i + 1][j + 1] = dp[i + 1][j].max(dp[i][j + 1]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut keep_current = vec![false; m];
|
||||
let mut keep_desired = vec![false; n];
|
||||
let (mut i, mut j) = (m, n);
|
||||
|
||||
while i > 0 && j > 0 {
|
||||
if current[i - 1].uri == desired[j - 1].uri {
|
||||
keep_current[i - 1] = true;
|
||||
keep_desired[j - 1] = true;
|
||||
i -= 1;
|
||||
j -= 1;
|
||||
} else if dp[i - 1][j] >= dp[i][j - 1] {
|
||||
i -= 1;
|
||||
} else {
|
||||
j -= 1;
|
||||
}
|
||||
}
|
||||
|
||||
(keep_current, keep_desired)
|
||||
}
|
||||
|
||||
impl QueueBackend for OpenHomeQueue {
|
||||
fn queue_snapshot(&self) -> Result<QueueSnapshot, ControlPointError> {
|
||||
self.ensure_playlist_source_selected()?;
|
||||
let entries = self.playlist_client.read_all_tracks()?;
|
||||
let mut items = Vec::with_capacity(entries.len());
|
||||
|
||||
for entry in &entries {
|
||||
items.push(self.playback_item_from_entry(entry));
|
||||
}
|
||||
|
||||
// Get the currently playing track ID from the renderer (may be None if no track is playing)
|
||||
let current_id = self.playlist_client.id().ok();
|
||||
|
||||
// Find the index of the current track in the playlist
|
||||
let current_index =
|
||||
current_id.and_then(|id| items.iter().position(|entry_id| entry_id.backend_id == id as usize));
|
||||
|
||||
Ok(QueueSnapshot {
|
||||
items: self.items.clone(),
|
||||
current_index: self.current_index,
|
||||
})
|
||||
}
|
||||
|
||||
fn set_index(&mut self, index: Option<usize>) -> Result<(), ControlPointError> {
|
||||
let normalized = index.filter(|&i| i < self.items.len());
|
||||
if let Some(idx) = normalized {
|
||||
let track_id = self.ensure_track_id(idx)?;
|
||||
self.ensure_playlist_source_selected()?;
|
||||
self.playlist_client.play_id(track_id)?;
|
||||
}
|
||||
self.current_index = normalized;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn replace_queue(
|
||||
&mut self,
|
||||
items: Vec<PlaybackItem>,
|
||||
current_index: Option<usize>,
|
||||
) -> Result<(), ControlPointError> {
|
||||
self.ensure_playlist_source_selected()?;
|
||||
if items.is_empty() {
|
||||
self.playlist_client.delete_all()?;
|
||||
self.items.clear();
|
||||
self.track_ids.clear();
|
||||
self.current_index = None;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// 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.
|
||||
self.refresh_from_openhome()?;
|
||||
|
||||
// Get the currently playing track ID from the renderer
|
||||
let playing_info = self.playlist_client.id().ok().and_then(|id| {
|
||||
self.track_ids
|
||||
.iter()
|
||||
.position(|&tid| tid == id)
|
||||
.map(|idx| (idx, id, self.items[idx].uri.clone()))
|
||||
});
|
||||
|
||||
debug!(
|
||||
renderer = self.renderer_id.0.as_str(),
|
||||
actual_items = self.items.len(),
|
||||
playing_info_detected = playing_info.is_some(),
|
||||
"OpenHome playlist state refreshed before replace_queue"
|
||||
);
|
||||
|
||||
if let Some((playing_idx, playing_id, playing_uri)) = playing_info {
|
||||
// Find if the currently playing item is in the new playlist (by URI)
|
||||
let new_playing_idx = items.iter().position(|item| item.uri == playing_uri);
|
||||
|
||||
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,
|
||||
pivot_idx,
|
||||
"Gentle sync: currently playing item found in new playlist at index {}",
|
||||
pivot_idx
|
||||
);
|
||||
|
||||
self.replace_queue_with_pivot(items, pivot_idx, playing_id)?;
|
||||
} 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_idx, playing_id)?;
|
||||
}
|
||||
} else {
|
||||
// No currently playing item or can't determine it - use standard LCS
|
||||
debug!(
|
||||
renderer = self.renderer_id.0.as_str(),
|
||||
"No currently playing item, using standard LCS sync"
|
||||
);
|
||||
self.replace_queue_standard_lcs(items, current_index)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn get_item(&self, index: usize) -> Result<Option<PlaybackItem>, ControlPointError> {
|
||||
Ok(self.items.get(index).cloned())
|
||||
}
|
||||
|
||||
fn replace_item(&mut self, index: usize, item: PlaybackItem) -> Result<(), ControlPointError> {
|
||||
if index >= self.items.len() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
self.ensure_playlist_source_selected()?;
|
||||
let track_id = self.ensure_track_id(index)?;
|
||||
let before_id = if index == 0 {
|
||||
OPENHOME_PLAYLIST_HEAD_ID
|
||||
} else {
|
||||
self.ensure_track_id(index - 1)?
|
||||
};
|
||||
|
||||
// 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)?;
|
||||
let metadata = build_metadata_xml(&item);
|
||||
let new_id = self
|
||||
.playlist_client
|
||||
.insert(before_id, &item.uri, &metadata)?;
|
||||
|
||||
if self.current_index == Some(index) {
|
||||
self.playlist_client.play_id(new_id)?;
|
||||
}
|
||||
|
||||
self.items[index] = self.item_with_openhome_id(item, new_id);
|
||||
self.track_ids[index] = new_id;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Override enqueue_items to add items directly to the OpenHome playlist.
|
||||
fn enqueue_items(&mut self, items: Vec<PlaybackItem>, mode: EnqueueMode) -> Result<(), ControlPointError> {
|
||||
if items.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
match mode {
|
||||
EnqueueMode::AppendToEnd => {
|
||||
// Append to the end of the OpenHome playlist
|
||||
let mut after_id = self.track_ids.last().copied();
|
||||
|
||||
for item in items {
|
||||
after_id = Some(self.add_playback_item(item, after_id, false)?);
|
||||
}
|
||||
}
|
||||
EnqueueMode::InsertAfterCurrent => {
|
||||
// Insert after the current playing track
|
||||
let after_id = if let Some(idx) = self.current_index {
|
||||
self.track_ids.get(idx).copied()
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let mut next_after_id = after_id;
|
||||
for item in items {
|
||||
next_after_id = Some(self.add_playback_item(item, next_after_id, false)?);
|
||||
}
|
||||
}
|
||||
EnqueueMode::ReplaceAll => {
|
||||
// Replace the entire playlist
|
||||
self.replace_queue(items, None)?;
|
||||
}
|
||||
}
|
||||
|
||||
// Refresh local cache from OpenHome after modification
|
||||
self.refresh_from_openhome()?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user