Merge pull request 'push-wmwyloupzyyo' (#31) from push-wmwyloupzyyo into main
All checks were successful
Build and Push Docker Image / build (push) Successful in 29m40s
All checks were successful
Build and Push Docker Image / build (push) Successful in 29m40s
Reviewed-on: #31
This commit was merged in pull request #31.
This commit is contained in:
@@ -31,7 +31,9 @@ use crate::control_point::music_queue::MusicQueue;
|
||||
use crate::control_point::openhome_queue::OpenHomeQueue;
|
||||
use crate::discovery::DiscoveryManager;
|
||||
use crate::events::{MediaServerEventBus, RendererEventBus};
|
||||
use crate::media_server::{MediaBrowser, MediaEntry, MediaServerInfo, MusicServer, ServerId};
|
||||
use crate::media_server::{
|
||||
playback_item_from_entry, MediaBrowser, MediaEntry, MediaServerInfo, MusicServer, ServerId,
|
||||
};
|
||||
use crate::media_server_events::spawn_media_server_event_runtime;
|
||||
use crate::model::TrackMetadata;
|
||||
use crate::model::{MediaServerEvent, RendererEvent, RendererId, RendererInfo};
|
||||
@@ -50,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;
|
||||
@@ -939,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()?;
|
||||
@@ -996,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)?;
|
||||
@@ -1086,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<Option<QueueSnapshot>> {
|
||||
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<usize> {
|
||||
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,
|
||||
@@ -1103,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<usize> {
|
||||
let renderer = self.openhome_renderer(renderer_id)?;
|
||||
renderer.openhome_playlist_len()
|
||||
@@ -1119,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<PlaybackItem> = 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<QueueItem> = 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) {
|
||||
@@ -1248,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<QueueItem> = queue_items
|
||||
@@ -1297,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(),
|
||||
@@ -1334,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<u32>,
|
||||
play: bool,
|
||||
) -> anyhow::Result<Option<u32>> {
|
||||
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,
|
||||
@@ -1348,21 +1345,18 @@ impl ControlPoint {
|
||||
after_id: Option<u32>,
|
||||
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.
|
||||
@@ -1381,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(),
|
||||
@@ -1402,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)?;
|
||||
@@ -1462,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(());
|
||||
}
|
||||
|
||||
@@ -1584,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,
|
||||
@@ -1720,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)
|
||||
@@ -1820,22 +1830,26 @@ impl ControlPoint {
|
||||
"Attaching new playlist: clearing renderer queue"
|
||||
);
|
||||
|
||||
// Clear the renderer's queue for OpenHome renderers
|
||||
// We also sync the local cache to reflect the empty state, which will trigger
|
||||
// refresh_attached_queue_for() to use replace_entire_playlist() instead of gentle sync
|
||||
if self.runtime.uses_openhome_playlist(renderer_id) {
|
||||
let renderer = self.openhome_renderer(renderer_id)?;
|
||||
renderer.openhome_playlist_clear()?;
|
||||
// Sync local cache to reflect the empty renderer state
|
||||
self.sync_openhome_playlist_for(renderer_id)?;
|
||||
debug!(
|
||||
renderer = renderer_id.0.as_str(),
|
||||
"Cleared OpenHome renderer playlist and synced local cache"
|
||||
);
|
||||
} else {
|
||||
// For non-OpenHome renderers, use the standard clear_queue
|
||||
self.clear_queue(renderer_id)?;
|
||||
}
|
||||
// Prepare the renderer for the new playlist (backend-agnostic)
|
||||
let renderer = self.music_renderer_by_id(renderer_id).ok_or_else(|| {
|
||||
anyhow!("Renderer {} not found", renderer_id.0)
|
||||
})?;
|
||||
renderer.clear_for_playlist_attach()?;
|
||||
|
||||
// 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");
|
||||
self.runtime.with_music_queue_mut(renderer_id, |queue| {
|
||||
queue.clear_queue()?;
|
||||
Ok(())
|
||||
})?;
|
||||
|
||||
debug!(
|
||||
renderer = renderer_id.0.as_str(),
|
||||
"Cleared renderer and local queue for new playlist"
|
||||
);
|
||||
|
||||
let binding = PlaylistBinding {
|
||||
server_id: server_id.clone(),
|
||||
@@ -2034,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")]
|
||||
@@ -2712,6 +2591,16 @@ fn refresh_attached_queue_for(
|
||||
}
|
||||
};
|
||||
|
||||
debug!(
|
||||
renderer = renderer_id.0.as_str(),
|
||||
server = server_id.0.as_str(),
|
||||
container = container_id.as_str(),
|
||||
total_entries = entries.len(),
|
||||
containers = entries.iter().filter(|e| e.is_container).count(),
|
||||
items_count = entries.iter().filter(|e| !e.is_container).count(),
|
||||
"Browse returned entries for playlist refresh"
|
||||
);
|
||||
|
||||
// Step 4: Convert MediaEntry to PlaybackItem
|
||||
let new_items: Vec<PlaybackItem> = entries
|
||||
.iter()
|
||||
@@ -2719,11 +2608,12 @@ fn refresh_attached_queue_for(
|
||||
.collect();
|
||||
|
||||
if new_items.is_empty() {
|
||||
debug!(
|
||||
warn!(
|
||||
renderer = renderer_id.0.as_str(),
|
||||
server = server_id.0.as_str(),
|
||||
container = container_id.as_str(),
|
||||
"Refreshed playlist is empty, clearing queue"
|
||||
total_entries = entries.len(),
|
||||
"Refreshed playlist is empty, clearing queue - all entries were filtered out"
|
||||
);
|
||||
runtime.with_music_queue_mut(renderer_id, |queue| queue.clear_queue())?;
|
||||
runtime.invalidate_openhome_cache(renderer_id);
|
||||
@@ -2886,41 +2776,6 @@ fn refresh_attached_queue_for(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Helper to convert a MediaEntry to a PlaybackItem.
|
||||
fn playback_item_from_entry(server: &MusicServer, entry: &MediaEntry) -> Option<PlaybackItem> {
|
||||
// Ignore containers
|
||||
if entry.is_container {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Skip "live stream" entries (heuristic from example)
|
||||
if entry.title.to_ascii_lowercase().contains("live stream") {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Find an audio resource
|
||||
let resource = entry.resources.iter().find(|res| res.is_audio())?;
|
||||
|
||||
let metadata = TrackMetadata {
|
||||
title: Some(entry.title.clone()),
|
||||
artist: entry.artist.clone(),
|
||||
album: entry.album.clone(),
|
||||
genre: entry.genre.clone(),
|
||||
album_art_uri: entry.album_art_uri.clone(),
|
||||
date: entry.date.clone(),
|
||||
track_number: entry.track_number.clone(),
|
||||
creator: entry.creator.clone(),
|
||||
};
|
||||
|
||||
Some(PlaybackItem {
|
||||
media_server_id: server.id().clone(),
|
||||
didl_id: entry.id.clone(),
|
||||
uri: resource.uri.clone(),
|
||||
protocol_info: resource.protocol_info.clone(),
|
||||
metadata: Some(metadata),
|
||||
})
|
||||
}
|
||||
|
||||
const OPENHOME_TRACK_PREFIX: &str = "openhome:";
|
||||
|
||||
fn playback_item_from_openhome_track(
|
||||
|
||||
@@ -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<PlaybackItem>, 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(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,8 +4,11 @@ use anyhow::{Result, anyhow};
|
||||
use pmodidl::{self, DIDLLite};
|
||||
use pmoupnp::soap::SoapEnvelope;
|
||||
use pmoupnp::soap::error_codes;
|
||||
use tracing::{debug, warn};
|
||||
use xmltree::{Element, XMLNode};
|
||||
|
||||
use crate::model::TrackMetadata;
|
||||
use crate::queue_backend::PlaybackItem;
|
||||
use crate::soap_client::{SoapCallResult, invoke_upnp_action_with_timeout};
|
||||
|
||||
/// Unique identifier for a media server registered by the control point.
|
||||
@@ -42,15 +45,49 @@ impl MediaResource {
|
||||
/// Returns true if this resource represents audio content.
|
||||
pub fn is_audio(&self) -> bool {
|
||||
let lower = self.protocol_info.to_ascii_lowercase();
|
||||
|
||||
// Standard case: audio/* MIME types
|
||||
if lower.contains("audio/") {
|
||||
return true;
|
||||
}
|
||||
|
||||
// List of known audio format subtypes (the part after the /)
|
||||
// These are recognized regardless of the MIME type prefix
|
||||
const AUDIO_FORMATS: &[&str] = &[
|
||||
"flac", "ogg", "opus", "vorbis",
|
||||
"mp3", "mpeg", "mp4", "m4a", "aac",
|
||||
"wav", "wave", "pcm",
|
||||
"wma", "webm",
|
||||
"ape", "alac", "aiff",
|
||||
"dsd", "dsf", "dff",
|
||||
];
|
||||
|
||||
// Check if any known audio format appears in the protocol_info
|
||||
for format in AUDIO_FORMATS {
|
||||
if lower.contains(format) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// protocolInfo format: protocol:network:contentFormat:additionalInfo
|
||||
lower
|
||||
.split(':')
|
||||
.nth(2)
|
||||
.map(|mime| mime.starts_with("audio/"))
|
||||
.unwrap_or(false)
|
||||
// Extract the MIME type (3rd field) for more precise checking
|
||||
if let Some(mime) = lower.split(':').nth(2) {
|
||||
// Check if it's audio/* or contains a known audio format
|
||||
if mime.starts_with("audio/") {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check the subtype (part after /) for known audio formats
|
||||
if let Some(subtype) = mime.split('/').nth(1) {
|
||||
for format in AUDIO_FORMATS {
|
||||
if subtype.contains(format) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -145,6 +182,80 @@ impl MediaBrowser for MusicServer {
|
||||
}
|
||||
}
|
||||
|
||||
/// Helper to convert a MediaEntry to a PlaybackItem.
|
||||
///
|
||||
/// This function filters out containers and entries without audio resources,
|
||||
/// returning None for items that cannot be played.
|
||||
pub fn playback_item_from_entry(server: &MusicServer, entry: &MediaEntry) -> Option<PlaybackItem> {
|
||||
// Ignore containers
|
||||
if entry.is_container {
|
||||
debug!(
|
||||
server_id = server.id().0.as_str(),
|
||||
entry_id = entry.id.as_str(),
|
||||
title = entry.title.as_str(),
|
||||
class = entry.class.as_str(),
|
||||
"Skipping container entry"
|
||||
);
|
||||
return None;
|
||||
}
|
||||
|
||||
// Skip "live stream" entries (heuristic from example)
|
||||
if entry.title.to_ascii_lowercase().contains("live stream") {
|
||||
debug!(
|
||||
server_id = server.id().0.as_str(),
|
||||
entry_id = entry.id.as_str(),
|
||||
title = entry.title.as_str(),
|
||||
"Skipping 'live stream' entry"
|
||||
);
|
||||
return None;
|
||||
}
|
||||
|
||||
// Find an audio resource
|
||||
let resource = entry.resources.iter().find(|res| res.is_audio());
|
||||
|
||||
if resource.is_none() {
|
||||
warn!(
|
||||
server_id = server.id().0.as_str(),
|
||||
entry_id = entry.id.as_str(),
|
||||
title = entry.title.as_str(),
|
||||
class = entry.class.as_str(),
|
||||
resource_count = entry.resources.len(),
|
||||
resources = ?entry.resources.iter().map(|r| &r.protocol_info).collect::<Vec<_>>(),
|
||||
"No audio resource found for entry"
|
||||
);
|
||||
return None;
|
||||
}
|
||||
|
||||
let resource = resource.unwrap();
|
||||
|
||||
let metadata = TrackMetadata {
|
||||
title: Some(entry.title.clone()),
|
||||
artist: entry.artist.clone(),
|
||||
album: entry.album.clone(),
|
||||
genre: entry.genre.clone(),
|
||||
album_art_uri: entry.album_art_uri.clone(),
|
||||
date: entry.date.clone(),
|
||||
track_number: entry.track_number.clone(),
|
||||
creator: entry.creator.clone(),
|
||||
};
|
||||
|
||||
debug!(
|
||||
server_id = server.id().0.as_str(),
|
||||
entry_id = entry.id.as_str(),
|
||||
title = entry.title.as_str(),
|
||||
uri = resource.uri.as_str(),
|
||||
"Created playback item"
|
||||
);
|
||||
|
||||
Some(PlaybackItem {
|
||||
media_server_id: server.id().clone(),
|
||||
didl_id: entry.id.clone(),
|
||||
uri: resource.uri.clone(),
|
||||
protocol_info: resource.protocol_info.clone(),
|
||||
metadata: Some(metadata),
|
||||
})
|
||||
}
|
||||
|
||||
/// Single UPnP ContentDirectory backend implementation.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct UpnpMediaServer {
|
||||
|
||||
@@ -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,
|
||||
@@ -269,6 +269,275 @@ impl MusicRenderer {
|
||||
}
|
||||
}
|
||||
|
||||
/// High-level method to prepare the renderer for attaching a new playlist.
|
||||
///
|
||||
/// This method handles backend-specific clearing logic:
|
||||
/// - For OpenHome: clears the OpenHome playlist
|
||||
/// - For AVTransport/Chromecast/etc.: stops the renderer (since they don't have a persistent queue)
|
||||
///
|
||||
/// This should be called by ControlPoint when attaching a new playlist, ensuring that:
|
||||
/// - Any currently playing content is stopped
|
||||
/// - The renderer is in a clean state ready to receive new content
|
||||
pub fn clear_for_playlist_attach(&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 AVTransport and other single-track renderers: stop playback
|
||||
// This ensures we're not in the middle of playing when we start the new playlist
|
||||
self.stop().or_else(|err| {
|
||||
// If stop fails (e.g., already stopped), that's fine - we just want to ensure it's not playing
|
||||
warn!(
|
||||
renderer = self.id().0.as_str(),
|
||||
error = %err,
|
||||
"Stop failed when preparing for playlist attach (continuing anyway)"
|
||||
);
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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<u32>,
|
||||
play: bool,
|
||||
) -> Result<Option<u32>> {
|
||||
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<Option<QueueSnapshot>> {
|
||||
match self {
|
||||
MusicRenderer::OpenHome(_) => {
|
||||
let oh_snapshot = self.fetch_openhome_playlist_snapshot()?;
|
||||
|
||||
// Convert OpenHome tracks to PlaybackItems
|
||||
let items: Vec<PlaybackItem> = 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,
|
||||
|
||||
@@ -8,7 +8,9 @@ use crate::control_point::{
|
||||
ControlPoint, OpenHomeAccessError, OPENHOME_SNAPSHOT_CACHE_TTL,
|
||||
};
|
||||
#[cfg(feature = "pmoserver")]
|
||||
use crate::media_server::{MediaBrowser, MediaEntry, MusicServer, ServerId};
|
||||
use crate::media_server::{
|
||||
playback_item_from_entry, MediaBrowser, MediaEntry, MusicServer, ServerId,
|
||||
};
|
||||
#[cfg(feature = "pmoserver")]
|
||||
use crate::model::{RendererCapabilities, RendererId, RendererProtocol, TrackMetadata};
|
||||
#[cfg(feature = "pmoserver")]
|
||||
@@ -1924,51 +1926,33 @@ fn fetch_playback_items(
|
||||
// Browse the object to get entries
|
||||
let entries = music_server.browse_children(object_id, 0, BROWSE_PAGE_SIZE)?;
|
||||
|
||||
debug!(
|
||||
server_id = server_id.0.as_str(),
|
||||
object_id = object_id,
|
||||
total_entries = entries.len(),
|
||||
containers = entries.iter().filter(|e| e.is_container).count(),
|
||||
items_count = entries.iter().filter(|e| !e.is_container).count(),
|
||||
"Browse returned entries"
|
||||
);
|
||||
|
||||
// Convert to PlaybackItem
|
||||
let items: Vec<PlaybackItem> = entries
|
||||
.iter()
|
||||
.filter_map(|entry| playback_item_from_entry(&music_server, entry))
|
||||
.collect();
|
||||
|
||||
if items.is_empty() && !entries.is_empty() {
|
||||
warn!(
|
||||
server_id = server_id.0.as_str(),
|
||||
object_id = object_id,
|
||||
total_entries = entries.len(),
|
||||
"No playable items found - all entries were filtered out"
|
||||
);
|
||||
}
|
||||
|
||||
Ok(items)
|
||||
}
|
||||
|
||||
/// Helper to convert a MediaEntry to a PlaybackItem.
|
||||
#[cfg(feature = "pmoserver")]
|
||||
fn playback_item_from_entry(server: &MusicServer, entry: &MediaEntry) -> Option<PlaybackItem> {
|
||||
// Ignore containers
|
||||
if entry.is_container {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Skip "live stream" entries
|
||||
if entry.title.to_ascii_lowercase().contains("live stream") {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Find an audio resource
|
||||
let resource = entry.resources.iter().find(|res| res.is_audio())?;
|
||||
|
||||
let metadata = TrackMetadata {
|
||||
title: Some(entry.title.clone()),
|
||||
artist: entry.artist.clone(),
|
||||
album: entry.album.clone(),
|
||||
genre: entry.genre.clone(),
|
||||
album_art_uri: entry.album_art_uri.clone(),
|
||||
date: entry.date.clone(),
|
||||
track_number: entry.track_number.clone(),
|
||||
creator: entry.creator.clone(),
|
||||
};
|
||||
|
||||
Some(PlaybackItem {
|
||||
media_server_id: server.id().clone(),
|
||||
didl_id: entry.id.clone(),
|
||||
uri: resource.uri.clone(),
|
||||
protocol_info: resource.protocol_info.clone(),
|
||||
metadata: Some(metadata),
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(feature = "pmoserver")]
|
||||
fn protocol_summary(protocol: &RendererProtocol) -> RendererProtocolSummary {
|
||||
match protocol {
|
||||
|
||||
Reference in New Issue
Block a user