Mise en place de la synchronisation de la playlist du point de contrôle avec celle du serveur de média.
This commit is contained in:
@@ -7,7 +7,7 @@ use std::time::Duration;
|
||||
use anyhow::anyhow;
|
||||
use crossbeam_channel::Receiver;
|
||||
use pmoupnp::ssdp::SsdpClient;
|
||||
use tracing::{debug, error, warn};
|
||||
use tracing::{debug, error, info, warn};
|
||||
|
||||
use crate::MusicRenderer;
|
||||
use crate::capabilities::{
|
||||
@@ -16,7 +16,9 @@ use crate::capabilities::{
|
||||
};
|
||||
use crate::discovery::DiscoveryManager;
|
||||
use crate::events::{MediaServerEventBus, RendererEventBus};
|
||||
use crate::media_server::{MediaServerInfo, ServerId};
|
||||
use crate::media_server::{
|
||||
MediaBrowser, MediaEntry, MediaResource, MediaServerInfo, MusicServer, ServerId,
|
||||
};
|
||||
use crate::media_server_events::spawn_media_server_event_runtime;
|
||||
use crate::model::{MediaServerEvent, RendererEvent, RendererId, RendererProtocol};
|
||||
use crate::music_renderer::op_not_supported;
|
||||
@@ -25,6 +27,25 @@ use crate::provider::HttpXmlDescriptionProvider;
|
||||
use crate::registry::{DeviceRegistry, DeviceRegistryRead, DeviceUpdate};
|
||||
use crate::upnp_renderer::UpnpRenderer;
|
||||
|
||||
/// Optional attachment between a renderer playback queue and a server-side
|
||||
/// DIDL-Lite playlist container.
|
||||
///
|
||||
/// When a queue is bound to a playlist, the control point will automatically
|
||||
/// refresh it whenever the server notifies us of changes to that container.
|
||||
/// User-driven mutations (clear, enqueue, etc.) break the binding automatically.
|
||||
#[derive(Clone, Debug)]
|
||||
struct PlaylistBinding {
|
||||
/// MediaServer that owns the playlist container.
|
||||
server_id: ServerId,
|
||||
/// DIDL-Lite object id of the playlist container.
|
||||
container_id: String,
|
||||
/// True once at least one ContainerUpdateIDs notification has been seen.
|
||||
has_seen_update: bool,
|
||||
/// Flag used internally to signal that the queue should be refreshed
|
||||
/// from the server container.
|
||||
pending_refresh: bool,
|
||||
}
|
||||
|
||||
/// Control point minimal :
|
||||
/// - lance un SsdpClient dans un thread,
|
||||
/// - passe les SsdpEvent au DiscoveryManager,
|
||||
@@ -34,6 +55,12 @@ pub struct ControlPoint {
|
||||
event_bus: RendererEventBus,
|
||||
media_event_bus: MediaServerEventBus,
|
||||
runtime: Arc<RuntimeState>,
|
||||
/// Optional attachment between a renderer playback queue and a
|
||||
/// server-side DIDL-Lite playlist container.
|
||||
///
|
||||
/// Key : RendererId
|
||||
/// Value : PlaylistBinding
|
||||
playlist_bindings: Arc<Mutex<HashMap<RendererId, PlaylistBinding>>>,
|
||||
}
|
||||
|
||||
impl ControlPoint {
|
||||
@@ -45,6 +72,7 @@ impl ControlPoint {
|
||||
let event_bus = RendererEventBus::new();
|
||||
let media_event_bus = MediaServerEventBus::new();
|
||||
let runtime = Arc::new(RuntimeState::new());
|
||||
let playlist_bindings = Arc::new(Mutex::new(HashMap::new()));
|
||||
|
||||
// SsdpClient
|
||||
let client = SsdpClient::new()?; // pmoupnp::ssdp::SsdpClient
|
||||
@@ -61,10 +89,11 @@ impl ControlPoint {
|
||||
// ACTIVE DISCOVERY : envoyer quelques M-SEARCH au démarrage
|
||||
// pour forcer les devices à répondre rapidement.
|
||||
let search_targets = [
|
||||
"ssdp:all",
|
||||
"urn:schemas-upnp-org:device:MediaRenderer:1",
|
||||
"urn:av-openhome-org:device:MediaRenderer:1",
|
||||
"urn:schemas-upnp-org:device:MediaServer:1",
|
||||
"ssdp:all",
|
||||
"urn:schemas-upnp-org:device:MediaRenderer:1",
|
||||
"urn:av-openhome-org:device:MediaRenderer:1",
|
||||
"urn:schemas-upnp-org:device:MediaServer:1",
|
||||
"urn:schemas-wiimu-com:service:PlayQueue:1", // <-- AJOUTER
|
||||
];
|
||||
|
||||
for st in &search_targets {
|
||||
@@ -96,9 +125,11 @@ impl ControlPoint {
|
||||
event_bus: event_bus.clone(),
|
||||
media_event_bus: media_event_bus.clone(),
|
||||
runtime: Arc::clone(&runtime),
|
||||
playlist_bindings: Arc::clone(&playlist_bindings),
|
||||
};
|
||||
|
||||
thread::spawn(move || {
|
||||
let mut tick: u32 = 0;
|
||||
loop {
|
||||
let infos = {
|
||||
let reg = runtime_cp.registry.read().unwrap();
|
||||
@@ -126,6 +157,7 @@ impl ControlPoint {
|
||||
let mut new_snapshot = prev_snapshot.clone();
|
||||
let prev_position = prev_snapshot.position.clone();
|
||||
|
||||
// Poll position every tick (1s) for smooth UI progress
|
||||
if let Ok(position) = renderer.playback_position() {
|
||||
let has_changed = match prev_snapshot.position.as_ref() {
|
||||
Some(prev) => !playback_position_equal(prev, &position),
|
||||
@@ -142,6 +174,7 @@ impl ControlPoint {
|
||||
new_snapshot.position = Some(position);
|
||||
}
|
||||
|
||||
// Poll state every tick to ensure responsive playback control
|
||||
if let Ok(raw_state) = renderer.playback_state() {
|
||||
let logical_state = compute_logical_playback_state(
|
||||
&raw_state,
|
||||
@@ -154,7 +187,9 @@ impl ControlPoint {
|
||||
None => true,
|
||||
};
|
||||
|
||||
if has_changed {
|
||||
// Emit event only for non-transient states to reduce noise
|
||||
// and avoid overwhelming the renderer during track changes
|
||||
if has_changed && !matches!(logical_state, PlaybackState::Transitioning) {
|
||||
runtime_cp.emit_renderer_event(RendererEvent::StateChanged {
|
||||
id: renderer_id.clone(),
|
||||
state: logical_state.clone(),
|
||||
@@ -164,26 +199,30 @@ impl ControlPoint {
|
||||
new_snapshot.state = Some(logical_state);
|
||||
}
|
||||
|
||||
if let Ok(volume) = renderer.volume() {
|
||||
if prev_snapshot.last_volume != Some(volume) {
|
||||
runtime_cp.emit_renderer_event(RendererEvent::VolumeChanged {
|
||||
id: renderer_id.clone(),
|
||||
volume,
|
||||
});
|
||||
// Poll volume and mute less frequently (every 3 seconds)
|
||||
// to reduce SOAP overhead without impacting UI responsiveness
|
||||
if tick % 3 == 0 {
|
||||
if let Ok(volume) = renderer.volume() {
|
||||
if prev_snapshot.last_volume != Some(volume) {
|
||||
runtime_cp.emit_renderer_event(RendererEvent::VolumeChanged {
|
||||
id: renderer_id.clone(),
|
||||
volume,
|
||||
});
|
||||
}
|
||||
|
||||
new_snapshot.last_volume = Some(volume);
|
||||
}
|
||||
|
||||
new_snapshot.last_volume = Some(volume);
|
||||
}
|
||||
if let Ok(mute) = renderer.mute() {
|
||||
if prev_snapshot.last_mute != Some(mute) {
|
||||
runtime_cp.emit_renderer_event(RendererEvent::MuteChanged {
|
||||
id: renderer_id.clone(),
|
||||
mute,
|
||||
});
|
||||
}
|
||||
|
||||
if let Ok(mute) = renderer.mute() {
|
||||
if prev_snapshot.last_mute != Some(mute) {
|
||||
runtime_cp.emit_renderer_event(RendererEvent::MuteChanged {
|
||||
id: renderer_id.clone(),
|
||||
mute,
|
||||
});
|
||||
new_snapshot.last_mute = Some(mute);
|
||||
}
|
||||
|
||||
new_snapshot.last_mute = Some(mute);
|
||||
}
|
||||
|
||||
runtime_cp
|
||||
@@ -191,6 +230,8 @@ impl ControlPoint {
|
||||
.update_snapshot(&renderer_id, new_snapshot);
|
||||
}
|
||||
|
||||
tick = tick.wrapping_add(1);
|
||||
// Keep 1 second polling for smooth position updates
|
||||
thread::sleep(Duration::from_secs(1));
|
||||
}
|
||||
});
|
||||
@@ -201,11 +242,138 @@ impl ControlPoint {
|
||||
timeout_secs,
|
||||
)?;
|
||||
|
||||
// Worker thread to process MediaServerEvent and trigger queue refreshes
|
||||
// for renderers bound to updated playlist containers
|
||||
let registry_for_media_worker = Arc::clone(®istry);
|
||||
let runtime_for_media_worker = Arc::clone(&runtime);
|
||||
let bindings_for_media_worker = Arc::clone(&playlist_bindings);
|
||||
let media_rx = media_event_bus.subscribe();
|
||||
|
||||
thread::Builder::new()
|
||||
.name("cp-media-server-event-worker".into())
|
||||
.spawn(move || {
|
||||
loop {
|
||||
let event = match media_rx.recv() {
|
||||
Ok(e) => e,
|
||||
Err(_) => {
|
||||
warn!("MediaServerEvent channel closed, worker exiting");
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
match event {
|
||||
MediaServerEvent::GlobalUpdated {
|
||||
server_id,
|
||||
system_update_id,
|
||||
} => {
|
||||
info!(
|
||||
server = server_id.0.as_str(),
|
||||
system_update_id = system_update_id,
|
||||
"MediaServer global update"
|
||||
);
|
||||
}
|
||||
MediaServerEvent::ContainersUpdated {
|
||||
server_id,
|
||||
container_ids,
|
||||
} => {
|
||||
let renderers_to_refresh: Vec<RendererId> = {
|
||||
let mut bindings = bindings_for_media_worker.lock().unwrap();
|
||||
let mut to_refresh = Vec::new();
|
||||
|
||||
for (renderer_id, binding) in bindings.iter_mut() {
|
||||
if binding.server_id == server_id
|
||||
&& container_ids.contains(&binding.container_id)
|
||||
{
|
||||
binding.pending_refresh = true;
|
||||
binding.has_seen_update = true;
|
||||
to_refresh.push(renderer_id.clone());
|
||||
}
|
||||
}
|
||||
|
||||
to_refresh
|
||||
};
|
||||
|
||||
for renderer_id in renderers_to_refresh {
|
||||
debug!(
|
||||
renderer = renderer_id.0.as_str(),
|
||||
server = server_id.0.as_str(),
|
||||
"Triggering queue refresh for bound playlist"
|
||||
);
|
||||
|
||||
if let Err(err) = refresh_attached_queue_for(
|
||||
®istry_for_media_worker,
|
||||
&runtime_for_media_worker,
|
||||
&bindings_for_media_worker,
|
||||
&renderer_id,
|
||||
) {
|
||||
warn!(
|
||||
renderer = renderer_id.0.as_str(),
|
||||
server = server_id.0.as_str(),
|
||||
error = %err,
|
||||
"Failed to refresh queue from playlist container"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})?;
|
||||
|
||||
// Periodic refresh worker for bound playlists
|
||||
// Every 60 seconds, trigger a refresh for all renderers with active bindings
|
||||
let registry_for_periodic = Arc::clone(®istry);
|
||||
let runtime_for_periodic = Arc::clone(&runtime);
|
||||
let bindings_for_periodic = Arc::clone(&playlist_bindings);
|
||||
|
||||
thread::Builder::new()
|
||||
.name("cp-playlist-periodic-refresh".into())
|
||||
.spawn(move || {
|
||||
loop {
|
||||
// Sleep for 60 seconds between refresh cycles
|
||||
thread::sleep(Duration::from_secs(60));
|
||||
|
||||
// Collect all renderers with active bindings and mark them for refresh
|
||||
let renderers_to_refresh: Vec<RendererId> = {
|
||||
let mut bindings = bindings_for_periodic.lock().unwrap();
|
||||
let mut to_refresh = Vec::new();
|
||||
|
||||
for (renderer_id, binding) in bindings.iter_mut() {
|
||||
binding.pending_refresh = true;
|
||||
to_refresh.push(renderer_id.clone());
|
||||
}
|
||||
|
||||
to_refresh
|
||||
};
|
||||
|
||||
// Trigger refresh for each bound renderer (outside of lock)
|
||||
for renderer_id in renderers_to_refresh {
|
||||
debug!(
|
||||
renderer = renderer_id.0.as_str(),
|
||||
"Periodic refresh triggered for bound playlist"
|
||||
);
|
||||
|
||||
if let Err(err) = refresh_attached_queue_for(
|
||||
®istry_for_periodic,
|
||||
&runtime_for_periodic,
|
||||
&bindings_for_periodic,
|
||||
&renderer_id,
|
||||
) {
|
||||
warn!(
|
||||
renderer = renderer_id.0.as_str(),
|
||||
error = %err,
|
||||
"Periodic refresh failed for bound playlist"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
})?;
|
||||
|
||||
Ok(Self {
|
||||
registry,
|
||||
event_bus,
|
||||
media_event_bus,
|
||||
runtime,
|
||||
playlist_bindings,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -308,6 +476,9 @@ impl ControlPoint {
|
||||
return Err(err);
|
||||
}
|
||||
|
||||
// User-driven mutation: detach any playlist binding
|
||||
self.detach_binding_on_user_mutation(renderer_id, "clear_queue");
|
||||
|
||||
let removed = self
|
||||
.runtime
|
||||
.with_queue_mut(renderer_id, |queue| {
|
||||
@@ -340,6 +511,9 @@ impl ControlPoint {
|
||||
return Err(err);
|
||||
}
|
||||
|
||||
// User-driven mutation: detach any playlist binding
|
||||
self.detach_binding_on_user_mutation(renderer_id, "enqueue_items");
|
||||
|
||||
let item_count = items.len();
|
||||
let new_len = self
|
||||
.runtime
|
||||
@@ -501,6 +675,92 @@ impl ControlPoint {
|
||||
self.media_event_bus.subscribe()
|
||||
}
|
||||
|
||||
/// Attach a renderer's playback queue to a server-side playlist container.
|
||||
///
|
||||
/// When attached, the queue will be automatically refreshed from the
|
||||
/// container whenever the server notifies us of changes via ContentDirectory
|
||||
/// events. The binding is broken if the user explicitly mutates the queue
|
||||
/// through methods like `clear_queue` or `enqueue_items`.
|
||||
pub fn attach_queue_to_playlist(
|
||||
&self,
|
||||
renderer_id: &RendererId,
|
||||
server_id: ServerId,
|
||||
container_id: String,
|
||||
) {
|
||||
let mut bindings = self.playlist_bindings.lock().unwrap();
|
||||
bindings.insert(
|
||||
renderer_id.clone(),
|
||||
PlaylistBinding {
|
||||
server_id: server_id.clone(),
|
||||
container_id: container_id.clone(),
|
||||
has_seen_update: false,
|
||||
pending_refresh: false,
|
||||
},
|
||||
);
|
||||
info!(
|
||||
renderer = renderer_id.0.as_str(),
|
||||
server = server_id.0.as_str(),
|
||||
container = container_id.as_str(),
|
||||
"Queue attached to playlist container"
|
||||
);
|
||||
}
|
||||
|
||||
/// Detach a renderer's queue from its associated playlist container.
|
||||
///
|
||||
/// After calling this, the queue will no longer be automatically refreshed
|
||||
/// from the server. If no binding existed, this is a no-op.
|
||||
pub fn detach_queue_playlist(&self, renderer_id: &RendererId) {
|
||||
let mut bindings = self.playlist_bindings.lock().unwrap();
|
||||
if let Some(binding) = bindings.remove(renderer_id) {
|
||||
info!(
|
||||
renderer = renderer_id.0.as_str(),
|
||||
server = binding.server_id.0.as_str(),
|
||||
container = binding.container_id.as_str(),
|
||||
"Queue detached from playlist container"
|
||||
);
|
||||
} else {
|
||||
debug!(
|
||||
renderer = renderer_id.0.as_str(),
|
||||
"detach_queue_playlist: no binding to remove"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Query the current playlist binding for a renderer's queue, if any.
|
||||
///
|
||||
/// Returns `(server_id, container_id, has_seen_update)` if the queue is
|
||||
/// bound to a server playlist container, or `None` otherwise.
|
||||
pub fn current_queue_playlist_binding(
|
||||
&self,
|
||||
renderer_id: &RendererId,
|
||||
) -> Option<(ServerId, String, bool)> {
|
||||
let bindings = self.playlist_bindings.lock().unwrap();
|
||||
bindings.get(renderer_id).map(|binding| {
|
||||
(
|
||||
binding.server_id.clone(),
|
||||
binding.container_id.clone(),
|
||||
binding.has_seen_update,
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
/// Internal helper to detach the playlist binding on user-driven mutations.
|
||||
///
|
||||
/// This is called by public queue mutation methods (clear, enqueue, etc.)
|
||||
/// to ensure that explicit user actions break the automatic refresh binding.
|
||||
fn detach_binding_on_user_mutation(&self, renderer_id: &RendererId, reason: &str) {
|
||||
let mut bindings = self.playlist_bindings.lock().unwrap();
|
||||
if let Some(binding) = bindings.remove(renderer_id) {
|
||||
info!(
|
||||
renderer = renderer_id.0.as_str(),
|
||||
server = binding.server_id.0.as_str(),
|
||||
container = binding.container_id.as_str(),
|
||||
reason = reason,
|
||||
"Playlist binding auto-detached due to user mutation"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn emit_renderer_event(&self, event: RendererEvent) {
|
||||
self.handle_renderer_event(&event);
|
||||
self.event_bus.broadcast(event);
|
||||
@@ -657,6 +917,202 @@ impl RuntimeState {
|
||||
}
|
||||
}
|
||||
|
||||
/// Internal helper to refresh a renderer's playback queue from its bound
|
||||
/// playlist container.
|
||||
///
|
||||
/// This function is called automatically when a ContentDirectory event indicates
|
||||
/// that the bound container has been updated. It attempts to preserve the
|
||||
/// currently playing item when possible.
|
||||
fn refresh_attached_queue_for(
|
||||
registry: &Arc<RwLock<DeviceRegistry>>,
|
||||
runtime: &Arc<RuntimeState>,
|
||||
bindings: &Arc<Mutex<HashMap<RendererId, PlaylistBinding>>>,
|
||||
renderer_id: &RendererId,
|
||||
) -> anyhow::Result<()> {
|
||||
// Step 1: Check binding and mark refresh as in-progress
|
||||
let (server_id, container_id) = {
|
||||
let mut bindings_lock = bindings.lock().unwrap();
|
||||
let binding = match bindings_lock.get_mut(renderer_id) {
|
||||
Some(b) => b,
|
||||
None => {
|
||||
debug!(
|
||||
renderer = renderer_id.0.as_str(),
|
||||
"refresh_attached_queue_for: no binding present"
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
};
|
||||
|
||||
if !binding.pending_refresh {
|
||||
debug!(
|
||||
renderer = renderer_id.0.as_str(),
|
||||
"refresh_attached_queue_for: pending_refresh is false, nothing to do"
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Mark as processed
|
||||
binding.pending_refresh = false;
|
||||
(binding.server_id.clone(), binding.container_id.clone())
|
||||
};
|
||||
|
||||
// Step 2: Fetch MediaServerInfo from registry
|
||||
let server_info = {
|
||||
let reg = registry.read().unwrap();
|
||||
reg.get_server(&server_id)
|
||||
};
|
||||
|
||||
let server_info = match server_info {
|
||||
Some(info) => info,
|
||||
None => {
|
||||
warn!(
|
||||
renderer = renderer_id.0.as_str(),
|
||||
server = server_id.0.as_str(),
|
||||
"refresh_attached_queue_for: server not found in registry"
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
};
|
||||
|
||||
if !server_info.online {
|
||||
debug!(
|
||||
renderer = renderer_id.0.as_str(),
|
||||
server = server_id.0.as_str(),
|
||||
"refresh_attached_queue_for: server offline, skipping refresh"
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if !server_info.has_content_directory {
|
||||
debug!(
|
||||
renderer = renderer_id.0.as_str(),
|
||||
server = server_id.0.as_str(),
|
||||
"refresh_attached_queue_for: server has no ContentDirectory"
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Step 3: Create MusicServer and browse container
|
||||
let music_server = MusicServer::from_info(&server_info, Duration::from_secs(5))?;
|
||||
|
||||
let entries = match music_server.browse_children(&container_id, 0, 64) {
|
||||
Ok(e) => e,
|
||||
Err(err) => {
|
||||
warn!(
|
||||
renderer = renderer_id.0.as_str(),
|
||||
server = server_id.0.as_str(),
|
||||
container = container_id.as_str(),
|
||||
error = %err,
|
||||
"Failed to browse playlist container for refresh"
|
||||
);
|
||||
return Err(err);
|
||||
}
|
||||
};
|
||||
|
||||
// Step 4: Convert MediaEntry to PlaybackItem
|
||||
let new_items: Vec<PlaybackItem> = entries
|
||||
.iter()
|
||||
.filter_map(|entry| playback_item_from_entry(&music_server, entry))
|
||||
.collect();
|
||||
|
||||
if new_items.is_empty() {
|
||||
debug!(
|
||||
renderer = renderer_id.0.as_str(),
|
||||
server = server_id.0.as_str(),
|
||||
container = container_id.as_str(),
|
||||
"Refreshed playlist is empty, clearing queue"
|
||||
);
|
||||
runtime.with_queue_mut(renderer_id, |queue| queue.clear());
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Step 5: Intelligent refresh: try to keep current item if it's still in the new list
|
||||
let current_item = runtime.queue_snapshot(renderer_id).and_then(|q| q.first().cloned());
|
||||
|
||||
let item_found_at = current_item.as_ref().and_then(|current| {
|
||||
new_items.iter().position(|new_item| {
|
||||
// Match by object_id if both have it
|
||||
if let (Some(current_obj), Some(new_obj)) = (¤t.object_id, &new_item.object_id)
|
||||
{
|
||||
return current_obj == new_obj;
|
||||
}
|
||||
// Fallback: match by URI
|
||||
current.uri == new_item.uri
|
||||
})
|
||||
});
|
||||
|
||||
runtime.with_queue_mut(renderer_id, |queue| {
|
||||
queue.clear();
|
||||
|
||||
if let Some(idx) = item_found_at {
|
||||
// Current item found: reconstruct queue starting from that item
|
||||
for i in idx..new_items.len() {
|
||||
queue.enqueue(new_items[i].clone());
|
||||
}
|
||||
info!(
|
||||
renderer = renderer_id.0.as_str(),
|
||||
server = server_id.0.as_str(),
|
||||
container = container_id.as_str(),
|
||||
queue_len = new_items.len() - idx,
|
||||
current_preserved = true,
|
||||
"Refreshed queue from playlist container"
|
||||
);
|
||||
} else {
|
||||
// Current item not found: replace with full new list
|
||||
for item in new_items.iter() {
|
||||
queue.enqueue(item.clone());
|
||||
}
|
||||
info!(
|
||||
renderer = renderer_id.0.as_str(),
|
||||
server = server_id.0.as_str(),
|
||||
container = container_id.as_str(),
|
||||
queue_len = new_items.len(),
|
||||
current_preserved = false,
|
||||
"Refreshed queue from playlist container (current item not found)"
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Helper to detect if a MediaResource is audio content.
|
||||
fn is_audio_resource(res: &MediaResource) -> bool {
|
||||
let lower = res.protocol_info.to_ascii_lowercase();
|
||||
if lower.contains("audio/") {
|
||||
return true;
|
||||
}
|
||||
// Check MIME type in protocolInfo (format: protocol:network:contentFormat:additionalInfo)
|
||||
lower
|
||||
.split(':')
|
||||
.nth(2)
|
||||
.map(|mime| mime.starts_with("audio/"))
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// 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| is_audio_resource(res))?;
|
||||
|
||||
let mut item = PlaybackItem::new(resource.uri.clone());
|
||||
item.title = Some(entry.title.clone());
|
||||
item.server_id = Some(server.id().clone());
|
||||
item.object_id = Some(entry.id.clone());
|
||||
|
||||
Some(item)
|
||||
}
|
||||
|
||||
/// Parse "HH:MM:SS" style time strings to seconds.
|
||||
///
|
||||
/// Returns None for empty or sentinel values such as "NOT_IMPLEMENTED" or "-:--:--".
|
||||
|
||||
Reference in New Issue
Block a user