From 0cbd0c9b30efcd4dc13b0de772fbc2000774769b Mon Sep 17 00:00:00 2001 From: Eric Coissac Date: Sat, 27 Dec 2025 22:15:10 +0100 Subject: [PATCH] Remise au propre des abstractions de PMOcontrol. --- pmocontrol/src/control_point.rs | 491 +++++++----------- .../src/control_point/openhome_queue.rs | 41 +- pmocontrol/src/music_renderer.rs | 240 ++++++++- 3 files changed, 458 insertions(+), 314 deletions(-) diff --git a/pmocontrol/src/control_point.rs b/pmocontrol/src/control_point.rs index 53dcd1a9..e0f5ee78 100644 --- a/pmocontrol/src/control_point.rs +++ b/pmocontrol/src/control_point.rs @@ -52,7 +52,7 @@ use crate::openhome_client::parse_track_metadata_from_didl; use crate::openhome_playlist::{OpenHomePlaylistSnapshot, OpenHomePlaylistTrack}; use crate::openhome_renderer::{format_seconds, map_openhome_state}; use crate::provider::HttpXmlDescriptionProvider; -use crate::queue_backend::{EnqueueMode, PlaybackItem, QueueBackend}; +use crate::queue_backend::{EnqueueMode, PlaybackItem, QueueBackend, QueueSnapshot}; use crate::queue_interne::InternalQueue; use crate::registry::{DeviceRegistry, DeviceRegistryRead, DeviceUpdate}; use crate::upnp_renderer::UpnpRenderer; @@ -941,17 +941,15 @@ impl ControlPoint { // User-driven mutation: detach any playlist binding self.detach_playlist_binding(renderer_id, "clear_queue"); - if self.runtime.uses_openhome_playlist(renderer_id) { - let renderer = self.openhome_renderer(renderer_id)?; - renderer.openhome_playlist_clear()?; - self.sync_openhome_playlist_for(renderer_id)?; - debug!( - renderer = renderer_id.0.as_str(), - "Cleared OpenHome playlist" - ); - return Ok(()); - } + // Clear the queue on the backend (backend-agnostic) + let renderer = self.music_renderer_by_id(renderer_id) + .ok_or_else(|| anyhow!("Renderer {} not found", renderer_id.0))?; + renderer.clear_queue()?; + // Sync backend state to local cache (for OpenHome, this updates the cache) + renderer.sync_queue_state()?; + + // Clear the local queue state let removed = self.runtime.with_music_queue_mut(renderer_id, |queue| { let removed = queue.upcoming_len()?; queue.clear_queue()?; @@ -998,11 +996,7 @@ impl ControlPoint { // User-driven mutation: detach any playlist binding self.detach_playlist_binding(renderer_id, "enqueue_items"); - if self.runtime.uses_openhome_playlist(renderer_id) { - self.enqueue_items_openhome(renderer_id, items)?; - return Ok(()); - } - + // Enqueue items using QueueBackend abstraction (works for both backends) let item_count = items.len(); let new_len = self.runtime.with_music_queue_mut(renderer_id, |queue| { queue.enqueue_items(items, EnqueueMode::AppendToEnd)?; @@ -1088,6 +1082,29 @@ impl ControlPoint { self.sync_openhome_playlist_for(renderer_id) } + /// Gets the backend queue snapshot for renderers with persistent queues. + /// + /// Returns the queue snapshot if the renderer has a backend queue (e.g., OpenHome), + /// or None if it doesn't (e.g., AVTransport). + pub fn get_renderer_queue_snapshot( + &self, + renderer_id: &RendererId, + ) -> anyhow::Result> { + let renderer = self.music_renderer_by_id(renderer_id) + .ok_or_else(|| anyhow!("Renderer {} not found", renderer_id.0))?; + renderer.queue_snapshot() + } + + /// Gets the length of the backend queue for renderers with persistent queues. + /// + /// Returns the queue length if the renderer has a backend queue, or 0 if it doesn't. + pub fn get_renderer_queue_length(&self, renderer_id: &RendererId) -> anyhow::Result { + let snapshot = self.get_renderer_queue_snapshot(renderer_id)?; + Ok(snapshot.map(|s| s.len()).unwrap_or(0)) + } + + // Deprecated: Use get_renderer_queue_snapshot instead + #[deprecated(since = "0.1.0", note = "Use get_renderer_queue_snapshot instead")] pub fn get_openhome_playlist_snapshot( &self, renderer_id: &RendererId, @@ -1105,6 +1122,8 @@ impl ControlPoint { self.runtime.openhome_snapshot_cached(&renderer, ttl) } + // Deprecated: Use get_renderer_queue_length instead + #[deprecated(since = "0.1.0", note = "Use get_renderer_queue_length instead")] pub fn get_openhome_playlist_len(&self, renderer_id: &RendererId) -> anyhow::Result { let renderer = self.openhome_renderer(renderer_id)?; renderer.openhome_playlist_len() @@ -1121,123 +1140,31 @@ impl ControlPoint { .ok_or_else(|| anyhow!("Renderer {} not found", renderer_id.0))?; let info = renderer.info(); - // MICRO-PATCH 5: Chemin OpenHome complètement découplé du miroir local - if self.runtime.uses_openhome_playlist(renderer_id) { - // Pour OpenHome: récupérer UNIQUEMENT runtime_snapshot pour volume/state/position - // Ne PAS utiliser queue_items/current_index du runtime (miroir local) - let (runtime_snapshot, _, _) = self.runtime.renderer_snapshot_bundle(renderer_id); + // Get runtime snapshot (needed for all backends: volume, state, position) + let (runtime_snapshot, runtime_queue_items, runtime_current_index) = + self.runtime.renderer_snapshot_bundle(renderer_id); - // OpenHome est la source de vérité pour la queue - pas de fallback au runtime - let snapshot = self.get_cached_openhome_playlist_snapshot( - renderer_id, - OPENHOME_SNAPSHOT_CACHE_TTL, - )?; - - let queue_items: Vec = snapshot - .tracks - .iter() - .map(|track| playback_item_from_openhome_track(renderer_id, track)) - .collect(); - - let queue_len = snapshot.tracks.len(); - - // Pour OpenHome: current_index vient UNIQUEMENT d'OpenHome, pas d'heuristiques runtime - let queue_current_index = snapshot.current_index.or_else(|| { - snapshot.current_id.and_then(|id| { - snapshot.tracks.iter().position(|track| track.id == id) - }) - }); + // Try to get queue from backend (returns Some for OpenHome, None for others) + let backend_queue = renderer.queue_snapshot()?; + // Use backend queue if available (OpenHome), otherwise use runtime queue (AVTransport) + let (queue_items, mut queue_current_index, from_backend) = if let Some(snapshot) = backend_queue { debug!( renderer = renderer_id.0.as_str(), - current_id = ?snapshot.current_id, - current_index = ?queue_current_index, - track_count = snapshot.tracks.len(), - "renderer_full_snapshot: OpenHome snapshot retrieved" + current_index = ?snapshot.current_index, + track_count = snapshot.items.len(), + "renderer_full_snapshot: using backend queue (OpenHome)" ); + (snapshot.items, snapshot.current_index, true) + } else { + (runtime_queue_items, runtime_current_index, false) + }; - let queue_view_items: Vec = queue_items - .iter() - .enumerate() - .map(|(index, item)| QueueItem { - index, - 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()), - server_id: Some(item.media_server_id.0.clone()), - object_id: Some(item.didl_id.clone()), - }) - .collect(); - - let queue_view = QueueSnapshotView { - renderer_id: renderer_id.0.clone(), - items: queue_view_items, - current_index: queue_current_index, - }; - - let binding = self.current_queue_playlist_binding(renderer_id).map( - |(server_id, container_id, has_seen_update)| RendererBindingView { - server_id: server_id.0, - container_id, - has_seen_update, - }, - ); - - let (position_ms, duration_ms) = - convert_runtime_position(runtime_snapshot.position.as_ref()); - let queue_current_metadata = queue_current_index - .and_then(|idx| queue_items.get(idx)) - .map(current_track_from_playback_item); - - // MICRO-PATCH 5: Pour OpenHome, préférer les métadonnées depuis le snapshot OpenHome - // car runtime_snapshot.last_metadata n'est jamais mis à jour pour OpenHome - let current_track = queue_current_metadata.or_else(|| { - runtime_snapshot - .last_metadata - .as_ref() - .map(|meta| CurrentTrackMetadata { - title: meta.title.clone(), - artist: meta.artist.clone(), - album: meta.album.clone(), - album_art_uri: meta.album_art_uri.clone(), - }) - }); - - let state_view = RendererStateView { - id: renderer_id.0.clone(), - friendly_name: info.friendly_name.clone(), - transport_state: runtime_snapshot - .state - .as_ref() - .map(|state| state.as_str().to_string()) - .unwrap_or_else(|| "UNKNOWN".to_string()), - position_ms, - duration_ms, - volume: runtime_snapshot - .last_volume - .and_then(|value| u8::try_from(value).ok()), - mute: runtime_snapshot.last_mute, - queue_len, - attached_playlist: binding.clone(), - current_track, - }; - - return Ok(FullRendererSnapshot { - state: state_view, - queue: queue_view, - binding, - }); - } - - // Chemin non-OpenHome (UPnP AV): continue d'utiliser le runtime - let (runtime_snapshot, queue_items, mut queue_current_index) = - self.runtime.renderer_snapshot_bundle(renderer_id); let playback_source = self.runtime.playback_source(renderer_id); let queue_len = queue_items.len(); - if queue_current_index.is_none() { + // For non-backend queues (AVTransport), try heuristics to determine current_index + if !from_backend && queue_current_index.is_none() { if let Some(position) = runtime_snapshot.position.as_ref() { if let Some(uri) = position.track_uri.as_ref() { if let Some(idx) = queue_items.iter().position(|item| item.uri == *uri) { @@ -1250,18 +1177,19 @@ impl ControlPoint { } } } - } - if queue_current_index.is_none() - && matches!(playback_source, PlaybackSource::FromQueue) - && runtime_snapshot - .state - .as_ref() - .map(|state| matches!(state, PlaybackState::Playing | PlaybackState::Paused)) - .unwrap_or(false) - && !queue_items.is_empty() - { - queue_current_index = Some(0); + // Final fallback: if playing from queue and no index, assume first track + if queue_current_index.is_none() + && matches!(playback_source, PlaybackSource::FromQueue) + && runtime_snapshot + .state + .as_ref() + .map(|state| matches!(state, PlaybackState::Playing | PlaybackState::Paused)) + .unwrap_or(false) + && !queue_items.is_empty() + { + queue_current_index = Some(0); + } } let queue_view_items: Vec = queue_items @@ -1299,16 +1227,32 @@ impl ControlPoint { .and_then(|idx| queue_items.get(idx)) .map(current_track_from_playback_item); - let current_track = runtime_snapshot - .last_metadata - .as_ref() - .map(|meta| CurrentTrackMetadata { - title: meta.title.clone(), - artist: meta.artist.clone(), - album: meta.album.clone(), - album_art_uri: meta.album_art_uri.clone(), + // For backend queues (OpenHome), prefer queue metadata since runtime isn't updated + // For runtime queues (AVTransport), prefer runtime metadata which is fresher + let current_track = if from_backend { + queue_current_metadata.or_else(|| { + runtime_snapshot + .last_metadata + .as_ref() + .map(|meta| CurrentTrackMetadata { + title: meta.title.clone(), + artist: meta.artist.clone(), + album: meta.album.clone(), + album_art_uri: meta.album_art_uri.clone(), + }) }) - .or(queue_current_metadata); + } else { + runtime_snapshot + .last_metadata + .as_ref() + .map(|meta| CurrentTrackMetadata { + title: meta.title.clone(), + artist: meta.artist.clone(), + album: meta.album.clone(), + album_art_uri: meta.album_art_uri.clone(), + }) + .or(queue_current_metadata) + }; let state_view = RendererStateView { id: renderer_id.0.clone(), @@ -1336,12 +1280,63 @@ impl ControlPoint { }) } - pub fn clear_openhome_playlist(&self, renderer_id: &RendererId) -> anyhow::Result<()> { - let renderer = self.openhome_renderer(renderer_id)?; - renderer.openhome_playlist_clear()?; - self.sync_openhome_playlist_for(renderer_id) + /// Clears the renderer's backend queue. + /// + /// For renderers with persistent queues (OpenHome), this clears the queue on the renderer. + /// For other renderers, this returns an error. + pub fn clear_renderer_queue(&self, renderer_id: &RendererId) -> anyhow::Result<()> { + let renderer = self.music_renderer_by_id(renderer_id) + .ok_or_else(|| anyhow!("Renderer {} not found", renderer_id.0))?; + renderer.clear_queue()?; + renderer.sync_queue_state() } + /// Adds a track to the renderer's backend queue. + /// + /// For renderers with persistent queues (OpenHome), this adds the track to the queue. + /// For other renderers, this returns an error. + /// + /// Returns the backend-specific track ID if applicable. + pub fn add_track_to_renderer( + &self, + renderer_id: &RendererId, + uri: &str, + metadata: &str, + after_id: Option, + play: bool, + ) -> anyhow::Result> { + let renderer = self.music_renderer_by_id(renderer_id) + .ok_or_else(|| anyhow!("Renderer {} not found", renderer_id.0))?; + let track_id = renderer.add_track_to_queue(uri, metadata, after_id, play)?; + renderer.sync_queue_state()?; + Ok(track_id) + } + + /// Selects and plays a specific track from the renderer's backend queue. + /// + /// For renderers with persistent queues (OpenHome), this uses the track ID. + /// For other renderers, this returns an error. + pub fn select_renderer_track( + &self, + renderer_id: &RendererId, + track_id: u32, + ) -> anyhow::Result<()> { + let renderer = self.music_renderer_by_id(renderer_id) + .ok_or_else(|| anyhow!("Renderer {} not found", renderer_id.0))?; + renderer.select_queue_track(track_id)?; + self.runtime + .set_playback_source(renderer_id, PlaybackSource::FromQueue); + renderer.sync_queue_state() + } + + // Deprecated: Use clear_renderer_queue instead + #[deprecated(since = "0.1.0", note = "Use clear_renderer_queue instead")] + pub fn clear_openhome_playlist(&self, renderer_id: &RendererId) -> anyhow::Result<()> { + self.clear_renderer_queue(renderer_id) + } + + // Deprecated: Use add_track_to_renderer instead + #[deprecated(since = "0.1.0", note = "Use add_track_to_renderer instead")] pub fn add_openhome_track( &self, renderer_id: &RendererId, @@ -1350,21 +1345,18 @@ impl ControlPoint { after_id: Option, play: bool, ) -> anyhow::Result<()> { - let renderer = self.openhome_renderer(renderer_id)?; - renderer.openhome_playlist_add_track(uri, metadata, after_id, play)?; - self.sync_openhome_playlist_for(renderer_id) + self.add_track_to_renderer(renderer_id, uri, metadata, after_id, play)?; + Ok(()) } + // Deprecated: Use select_renderer_track instead + #[deprecated(since = "0.1.0", note = "Use select_renderer_track instead")] pub fn play_openhome_track_id( &self, renderer_id: &RendererId, track_id: u32, ) -> anyhow::Result<()> { - let renderer = self.openhome_renderer(renderer_id)?; - renderer.openhome_playlist_play_id(track_id)?; - self.runtime - .set_playback_source(renderer_id, PlaybackSource::FromQueue); - self.sync_openhome_playlist_for(renderer_id) + self.select_renderer_track(renderer_id, track_id) } /// Plays the current queue item without advancing the index. @@ -1383,10 +1375,18 @@ impl ControlPoint { return Err(err); } - if self.runtime.uses_openhome_playlist(renderer_id) { - return self.play_current_openhome(renderer_id); + let renderer = self.music_renderer_by_id(renderer_id).ok_or_else(|| { + anyhow!("Renderer {} not found", renderer_id.0) + })?; + + // If renderer has a backend queue (OpenHome), use backend playback + if renderer.queue_snapshot()?.is_some() { + renderer.play_current_from_backend_queue()?; + self.runtime.set_playback_source(renderer_id, PlaybackSource::FromQueue); + return Ok(()); } + // Otherwise use local queue playback (AVTransport) let Some((item, remaining)) = self.runtime.peek_current(renderer_id) else { debug!( renderer = renderer_id.0.as_str(), @@ -1404,14 +1404,6 @@ impl ControlPoint { "Playing current playback item from queue" ); - let renderer = self.music_renderer_by_id(renderer_id).ok_or_else(|| { - warn!( - renderer = renderer_id.0.as_str(), - "Renderer disappeared before queue playback could start" - ); - anyhow!("Renderer {} not found", renderer_id.0) - })?; - let playback = (|| -> anyhow::Result<()> { let didl_metadata = playback_item_to_didl(&item); renderer.play_uri(&item.uri, &didl_metadata)?; @@ -1464,8 +1456,14 @@ impl ControlPoint { return Err(err); } - if self.runtime.uses_openhome_playlist(renderer_id) { - self.play_next_openhome(renderer_id)?; + let renderer = self.music_renderer_by_id(renderer_id).ok_or_else(|| { + anyhow!("Renderer {} not found", renderer_id.0) + })?; + + // If renderer has a backend queue (OpenHome), use backend playback + if renderer.queue_snapshot()?.is_some() { + renderer.play_next_from_backend_queue()?; + self.runtime.set_playback_source(renderer_id, PlaybackSource::FromQueue); return Ok(()); } @@ -1586,8 +1584,12 @@ impl ControlPoint { return Err(err); } - // For OpenHome, use select_track_index which handles the track_id lookup - if self.runtime.uses_openhome_playlist(renderer_id) { + let renderer = self.music_renderer_by_id(renderer_id).ok_or_else(|| { + anyhow!("Renderer {} not found", renderer_id.0) + })?; + + // For backend queues (OpenHome), use select_track_index which handles the track_id lookup + if renderer.queue_snapshot()?.is_some() { info!( renderer = renderer_id.0.as_str(), index, @@ -1722,7 +1724,13 @@ impl ControlPoint { return Ok(()); } - if self.runtime.uses_openhome_playlist(renderer_id) { + let renderer = self.music_renderer_by_id(renderer_id).ok_or_else(|| { + anyhow!("Renderer {} not found", renderer_id.0) + })?; + + // For backend queues (OpenHome), play current (renderer tracks its own position) + // For local queues (AVTransport), dequeue next (we manage position locally) + if renderer.queue_snapshot()?.is_some() { self.play_current_from_queue(renderer_id) } else { self.play_next_from_queue(renderer_id) @@ -1828,10 +1836,8 @@ impl ControlPoint { })?; renderer.clear_for_playlist_attach()?; - // For OpenHome, also sync the local cache to reflect the empty state - if self.runtime.uses_openhome_playlist(renderer_id) { - self.sync_openhome_playlist_for(renderer_id)?; - } + // Sync backend state to local cache (backend-agnostic) + renderer.sync_queue_state()?; // Clear the local queue (detach binding + clear runtime queue structure) self.detach_playlist_binding(renderer_id, "attach_new_playlist"); @@ -2042,141 +2048,6 @@ impl ControlPoint { Ok(()) } - fn play_current_openhome(&self, renderer_id: &RendererId) -> anyhow::Result<()> { - let renderer = self.openhome_renderer(renderer_id)?; - - // MICRO-PATCH 5: Pour OpenHome, récupérer les données directement depuis la playlist native - // au lieu du miroir local (qui peut être vide ou obsolète) - match self.get_cached_openhome_playlist_snapshot( - renderer_id, - OPENHOME_SNAPSHOT_CACHE_TTL, - ) { - Ok(snapshot) => { - if snapshot.tracks.is_empty() { - debug!( - renderer = renderer_id.0.as_str(), - "OpenHome playlist is empty" - ); - self.runtime - .set_playback_source(renderer_id, PlaybackSource::None); - return Ok(()); - } - - // Trouver le track_id courant depuis le snapshot OpenHome - let target_track_id = if let Some(current_id) = snapshot.current_id { - Some(current_id) - } else if let Some(current_idx) = snapshot.current_index { - snapshot.tracks.get(current_idx).map(|track| track.id) - } else { - snapshot.tracks.first().map(|track| track.id) - }; - - if let Some(track_id) = target_track_id { - match renderer.openhome_playlist_play_id(track_id) { - Ok(()) => { - self.runtime - .set_playback_source(renderer_id, PlaybackSource::FromQueue); - self.sync_openhome_playlist_for(renderer_id)?; - info!( - renderer = renderer_id.0.as_str(), - track_id, - playlist_len = snapshot.tracks.len(), - "Started OpenHome playlist playback (current item)" - ); - return Ok(()); - } - Err(err) => { - warn!( - renderer = renderer_id.0.as_str(), - track_id, - error = %err, - "PlayId failed, falling back to Play()" - ); - } - } - } - - // Fallback: appeler Play() sans spécifier de track_id - renderer.play()?; - self.runtime - .set_playback_source(renderer_id, PlaybackSource::FromQueue); - self.sync_openhome_playlist_for(renderer_id)?; - info!( - renderer = renderer_id.0.as_str(), - playlist_len = snapshot.tracks.len(), - "Started OpenHome native playlist playback" - ); - return Ok(()); - } - Err(err) => { - warn!( - renderer = renderer_id.0.as_str(), - error = %err, - "Failed to fetch OpenHome playlist snapshot, playlist might be empty" - ); - debug!( - renderer = renderer_id.0.as_str(), - "OpenHome playlist is empty or unavailable" - ); - self.runtime - .set_playback_source(renderer_id, PlaybackSource::None); - return Ok(()); - } - } - } - - fn play_next_openhome(&self, renderer_id: &RendererId) -> anyhow::Result<()> { - let renderer = self.openhome_renderer(renderer_id)?; - - // MICRO-PATCH 5: Récupérer les données directement depuis OpenHome au lieu du miroir local - let snapshot = self.get_cached_openhome_playlist_snapshot( - renderer_id, - OPENHOME_SNAPSHOT_CACHE_TTL, - )?; - - if snapshot.tracks.is_empty() { - debug!( - renderer = renderer_id.0.as_str(), - "OpenHome playlist is empty, cannot play next" - ); - self.runtime - .set_playback_source(renderer_id, PlaybackSource::None); - return Ok(()); - } - - // Déterminer le prochain track_id - let next_track_id = match snapshot.current_index { - Some(idx) => { - // Prendre la piste suivante si elle existe - snapshot - .tracks - .get(idx + 1) - .map(|track| track.id) - .or_else(|| snapshot.tracks.first().map(|track| track.id)) - } - None => snapshot.tracks.first().map(|track| track.id), - }; - - let Some(track_id) = next_track_id else { - debug!( - renderer = renderer_id.0.as_str(), - "No OpenHome track available to advance to" - ); - self.runtime - .set_playback_source(renderer_id, PlaybackSource::None); - return Ok(()); - }; - - renderer.openhome_playlist_play_id(track_id)?; - self.runtime - .set_playback_source(renderer_id, PlaybackSource::FromQueue); - self.sync_openhome_playlist_for(renderer_id)?; - info!( - renderer = renderer_id.0.as_str(), - track_id, "Advanced OpenHome playlist to next track" - ); - Ok(()) - } } #[cfg(feature = "pmoserver")] diff --git a/pmocontrol/src/control_point/openhome_queue.rs b/pmocontrol/src/control_point/openhome_queue.rs index 0c9dcb33..5151fdd3 100644 --- a/pmocontrol/src/control_point/openhome_queue.rs +++ b/pmocontrol/src/control_point/openhome_queue.rs @@ -10,7 +10,7 @@ use crate::openhome_client::{ OPENHOME_PLAYLIST_HEAD_ID, }; use crate::openhome_playlist::{OpenHomePlaylistSnapshot, OpenHomePlaylistTrack}; -use crate::queue_backend::{PlaybackItem, QueueBackend, QueueSnapshot}; +use crate::queue_backend::{EnqueueMode, PlaybackItem, QueueBackend, QueueSnapshot}; /// Local mirror of an OpenHome playlist for a single renderer. #[derive(Clone, Debug)] @@ -837,4 +837,43 @@ impl QueueBackend for OpenHomeQueue { 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, mode: EnqueueMode) -> Result<()> { + 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(()) + } } diff --git a/pmocontrol/src/music_renderer.rs b/pmocontrol/src/music_renderer.rs index 4080a89d..315dada0 100644 --- a/pmocontrol/src/music_renderer.rs +++ b/pmocontrol/src/music_renderer.rs @@ -13,10 +13,10 @@ use crate::control_point::RendererRuntimeStateMut; use crate::control_point::music_queue::MusicQueue; use crate::control_point::openhome_queue::didl_id_from_metadata; use crate::media_server::ServerId; -use crate::model::{RendererId, RendererInfo, RendererProtocol}; +use crate::model::{RendererId, RendererInfo, RendererProtocol, TrackMetadata}; use crate::openhome_client::parse_track_metadata_from_didl; -use crate::openhome_playlist::OpenHomePlaylistSnapshot; -use crate::queue_backend::PlaybackItem; +use crate::openhome_playlist::{OpenHomePlaylistSnapshot, OpenHomePlaylistTrack}; +use crate::queue_backend::{PlaybackItem, QueueSnapshot}; use crate::{ ArylicTcpRenderer, ChromecastRenderer, DeviceRegistry, LinkPlayRenderer, OpenHomeRenderer, PlaybackPosition, PlaybackState, TransportControl, UpnpRenderer, VolumeControl, @@ -304,6 +304,240 @@ impl MusicRenderer { } } + /// Synchronize the local queue state with the backend's actual state. + /// + /// - For OpenHome: fetches the playlist from the renderer and updates local cache + /// - For Internal queue/AVTransport: no-op (queue is already local) + pub fn sync_queue_state(&self) -> Result<()> { + match self { + MusicRenderer::OpenHome(_) => { + if let Some(provider) = OPENHOME_QUEUE_PROVIDER.get() { + // Fetch fresh playlist snapshot and update cache + provider.invalidate_openhome_cache(self.id())?; + } + Ok(()) + } + MusicRenderer::Upnp(_) + | MusicRenderer::Chromecast(_) + | MusicRenderer::LinkPlay(_) + | MusicRenderer::ArylicTcp(_) + | MusicRenderer::HybridUpnpArylic { .. } => { + // No sync needed - queue is local only + Ok(()) + } + } + } + + /// Clear the queue on both the backend and in local state. + /// + /// This ensures the backend renderer and local cache are consistent. + /// Should be called before queue mutations to ensure clean state. + pub fn clear_queue(&self) -> Result<()> { + match self { + MusicRenderer::OpenHome(_) => { + // For OpenHome: clear the playlist on the renderer itself + self.openhome_playlist_clear() + } + MusicRenderer::Upnp(_) + | MusicRenderer::Chromecast(_) + | MusicRenderer::LinkPlay(_) + | MusicRenderer::ArylicTcp(_) + | MusicRenderer::HybridUpnpArylic { .. } => { + // For other renderers: no persistent queue to clear + Ok(()) + } + } + } + + /// Add a track to the backend's queue, returning backend-specific track ID if applicable. + /// + /// - For OpenHome: adds to OpenHome playlist and returns track ID + /// - For others: returns error (not supported for single-track renderers) + pub fn add_track_to_queue( + &self, + uri: &str, + metadata: &str, + after_id: Option, + play: bool, + ) -> Result> { + match self { + MusicRenderer::OpenHome(_) => { + let track_id = self.openhome_playlist_add_track(uri, metadata, after_id, play)?; + Ok(Some(track_id)) + } + MusicRenderer::Upnp(_) + | MusicRenderer::Chromecast(_) + | MusicRenderer::LinkPlay(_) + | MusicRenderer::ArylicTcp(_) + | MusicRenderer::HybridUpnpArylic { .. } => Err(anyhow!( + "add_track_to_queue is not supported for {} backend", + self.unsupported_backend_name() + )), + } + } + + /// Select and play a specific track from the backend's queue. + /// + /// - For OpenHome: uses track ID to select from OpenHome playlist + /// - For others: returns error (use play_uri instead) + pub fn select_queue_track(&self, track_id: u32) -> Result<()> { + match self { + MusicRenderer::OpenHome(_) => self.openhome_playlist_play_id(track_id), + MusicRenderer::Upnp(_) + | MusicRenderer::Chromecast(_) + | MusicRenderer::LinkPlay(_) + | MusicRenderer::ArylicTcp(_) + | MusicRenderer::HybridUpnpArylic { .. } => Err(anyhow!( + "select_queue_track is not supported for {} backend", + self.unsupported_backend_name() + )), + } + } + + /// Get the current queue state from the backend. + /// + /// - For OpenHome: fetches current playlist snapshot + /// - For others: returns None (no persistent queue on backend) + pub fn queue_snapshot(&self) -> Result> { + match self { + MusicRenderer::OpenHome(_) => { + let oh_snapshot = self.fetch_openhome_playlist_snapshot()?; + + // Convert OpenHome tracks to PlaybackItems + let items: Vec = oh_snapshot.tracks.iter().map(|track| { + Self::playback_item_from_openhome_track(self.id(), track) + }).collect(); + + let snapshot = QueueSnapshot { + items, + current_index: oh_snapshot.current_index, + }; + + Ok(Some(snapshot)) + } + MusicRenderer::Upnp(_) + | MusicRenderer::Chromecast(_) + | MusicRenderer::LinkPlay(_) + | MusicRenderer::ArylicTcp(_) + | MusicRenderer::HybridUpnpArylic { .. } => { + // No backend queue for these renderers + Ok(None) + } + } + } + + /// Play the current item from the backend queue. + /// + /// - For OpenHome: Uses the native playlist to play the current track + /// - For others: Returns error (no backend queue) + pub fn play_current_from_backend_queue(&self) -> Result<()> { + match self { + MusicRenderer::OpenHome(_) => { + // Get the current OpenHome playlist snapshot + let snapshot = self.fetch_openhome_playlist_snapshot()?; + + if snapshot.tracks.is_empty() { + return Err(anyhow!("OpenHome playlist is empty")); + } + + // Find the track_id to play (prefer current_id, then current_index, then first) + let target_track_id = if let Some(current_id) = snapshot.current_id { + Some(current_id) + } else if let Some(current_idx) = snapshot.current_index { + snapshot.tracks.get(current_idx).map(|track| track.id) + } else { + snapshot.tracks.first().map(|track| track.id) + }; + + if let Some(track_id) = target_track_id { + self.openhome_playlist_play_id(track_id)?; + Ok(()) + } else { + Err(anyhow!("No track to play in OpenHome playlist")) + } + } + MusicRenderer::Upnp(_) + | MusicRenderer::Chromecast(_) + | MusicRenderer::LinkPlay(_) + | MusicRenderer::ArylicTcp(_) + | MusicRenderer::HybridUpnpArylic { .. } => Err(anyhow!( + "play_current_from_backend_queue is not supported for {} backend (no persistent queue)", + self.unsupported_backend_name() + )), + } + } + + /// Play the next item from the backend queue. + /// + /// - For OpenHome: Advances to the next track in the playlist + /// - For others: Returns error (no backend queue) + pub fn play_next_from_backend_queue(&self) -> Result<()> { + match self { + MusicRenderer::OpenHome(_) => { + // Get the current OpenHome playlist snapshot + let snapshot = self.fetch_openhome_playlist_snapshot()?; + + if snapshot.tracks.is_empty() { + return Err(anyhow!("OpenHome playlist is empty, cannot play next")); + } + + // Determine the next track_id + let next_track_id = match snapshot.current_index { + Some(idx) => { + // Take the next track if it exists, otherwise loop to first + snapshot + .tracks + .get(idx + 1) + .map(|track| track.id) + .or_else(|| snapshot.tracks.first().map(|track| track.id)) + } + None => snapshot.tracks.first().map(|track| track.id), + }; + + if let Some(track_id) = next_track_id { + self.openhome_playlist_play_id(track_id)?; + Ok(()) + } else { + Err(anyhow!("No track available to advance to in OpenHome playlist")) + } + } + MusicRenderer::Upnp(_) + | MusicRenderer::Chromecast(_) + | MusicRenderer::LinkPlay(_) + | MusicRenderer::ArylicTcp(_) + | MusicRenderer::HybridUpnpArylic { .. } => Err(anyhow!( + "play_next_from_backend_queue is not supported for {} backend (no persistent queue)", + self.unsupported_backend_name() + )), + } + } + + /// Convert an OpenHome playlist track to a PlaybackItem. + fn playback_item_from_openhome_track( + renderer_id: &RendererId, + track: &OpenHomePlaylistTrack, + ) -> PlaybackItem { + let metadata = TrackMetadata { + title: track.title.clone(), + artist: track.artist.clone(), + album: track.album.clone(), + genre: None, + album_art_uri: track.album_art_uri.clone(), + date: None, + track_number: None, + creator: None, + }; + + PlaybackItem { + media_server_id: ServerId(format!("openhome:{}", renderer_id.0)), + didl_id: format!("openhome:{}", track.id), + uri: track.uri.clone(), + // OpenHome tracks don't provide protocolInfo, use generic default + protocol_info: "http-get:*:audio/*:*".to_string(), + metadata: Some(metadata), + } + } + pub fn openhome_playlist_add_track( &self, uri: &str,