From 5320545d563d8c1607805070122d51d8977ba536 Mon Sep 17 00:00:00 2001 From: Eric Coissac Date: Sat, 3 Jan 2026 08:19:23 +0100 Subject: [PATCH] =?UTF-8?q?Debugging=20de=20la=20nouvelle=20impl=C3=A9ment?= =?UTF-8?q?ation.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../webapp/src/services/openhomePlaylist.ts | 55 - .../webapp/src/services/pmocontrol/types.ts | 290 +- pmoapp/webapp/src/views/RendererView.vue | 616 ++-- pmocontrol/examples/queue_pmomusic_demo.rs | 539 ---- pmocontrol/src/arylic_client/mod.rs | 76 +- pmocontrol/src/control_point.rs | 2548 +++-------------- pmocontrol/src/control_point_corrections.md | 130 + .../src/discovery/chromecast_discovery.rs | 8 +- pmocontrol/src/discovery/manager.rs | 11 +- pmocontrol/src/discovery/mod.rs | 30 +- pmocontrol/src/discovery/upnp_discovery.rs | 42 +- pmocontrol/src/errors.rs | 19 +- pmocontrol/src/identity.rs | 31 +- pmocontrol/src/lib.rs | 29 +- pmocontrol/src/linkplay_client/mod.rs | 62 +- pmocontrol/src/media_server.rs | 18 +- pmocontrol/src/media_server_events.rs | 286 +- pmocontrol/src/model.rs | 45 +- pmocontrol/src/music_renderer/arylic_tcp.rs | 90 +- pmocontrol/src/music_renderer/mod.rs | 2 +- .../src/music_renderer/musicrenderer.rs | 250 +- .../src/music_renderer/openhome_renderer.rs | 31 +- pmocontrol/src/online.rs | 12 +- pmocontrol/src/openapi.rs | 90 +- pmocontrol/src/pmoserver_ext.rs | 471 +-- pmocontrol/src/queue/mod.rs | 16 +- pmocontrol/src/queue/openhome.rs | 13 +- pmocontrol/src/registry.rs | 26 +- pmocontrol/src/sse.rs | 109 +- .../src/upnp_clients/avtransport_client.rs | 33 +- .../upnp_clients/connection_manager_client.rs | 88 +- .../upnp_clients/rendering_control_client.rs | 22 +- pmodidl/src/lib.rs | 4 +- tools/check_a_file.sh | 30 + 34 files changed, 1989 insertions(+), 4133 deletions(-) delete mode 100644 pmoapp/webapp/src/services/openhomePlaylist.ts delete mode 100644 pmocontrol/examples/queue_pmomusic_demo.rs create mode 100644 pmocontrol/src/control_point_corrections.md create mode 100755 tools/check_a_file.sh diff --git a/pmoapp/webapp/src/services/openhomePlaylist.ts b/pmoapp/webapp/src/services/openhomePlaylist.ts deleted file mode 100644 index 892fccd4..00000000 --- a/pmoapp/webapp/src/services/openhomePlaylist.ts +++ /dev/null @@ -1,55 +0,0 @@ -import type { - OpenHomePlaylistAddRequest, - OpenHomePlaylistSnapshot, -} from '@/services/pmocontrol/types' - -const API_BASE = '/api/control' - -export async function getOpenHomePlaylist(rendererId: string): Promise { - const resp = await fetch( - `${API_BASE}/renderers/${encodeURIComponent(rendererId)}/oh/playlist`, - ) - if (!resp.ok) { - throw new Error(`Failed to fetch OpenHome playlist: ${resp.status} ${resp.statusText}`) - } - return resp.json() -} - -export async function clearOpenHomePlaylist(rendererId: string): Promise { - const resp = await fetch( - `${API_BASE}/renderers/${encodeURIComponent(rendererId)}/oh/playlist/clear`, - { method: 'POST' }, - ) - if (!resp.ok) { - throw new Error(`Failed to clear OpenHome playlist: ${resp.status} ${resp.statusText}`) - } -} - -export async function addOpenHomeTrack( - rendererId: string, - payload: OpenHomePlaylistAddRequest, -): Promise { - const resp = await fetch( - `${API_BASE}/renderers/${encodeURIComponent(rendererId)}/oh/playlist/add`, - { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(payload), - }, - ) - if (!resp.ok) { - throw new Error(`Failed to add track to OpenHome playlist: ${resp.status} ${resp.statusText}`) - } -} - -export async function playOpenHomeTrack(rendererId: string, trackId: number): Promise { - const resp = await fetch( - `${API_BASE}/renderers/${encodeURIComponent( - rendererId, - )}/oh/playlist/play/${encodeURIComponent(trackId.toString())}`, - { method: 'POST' }, - ) - if (!resp.ok) { - throw new Error(`Failed to play OpenHome track ${trackId}: ${resp.status} ${resp.statusText}`) - } -} diff --git a/pmoapp/webapp/src/services/pmocontrol/types.ts b/pmoapp/webapp/src/services/pmocontrol/types.ts index 519ab02c..046108e5 100644 --- a/pmoapp/webapp/src/services/pmocontrol/types.ts +++ b/pmoapp/webapp/src/services/pmocontrol/types.ts @@ -5,55 +5,65 @@ // RENDERERS // ============================================================================ -export type RendererProtocolSummary = 'upnp' | 'openhome' | 'hybrid' +export type RendererProtocolSummary = + | "upnp" + | "openhome" + | "hybrid" + | "chromecast"; export interface RendererCapabilitiesSummary { - has_avtransport: boolean - has_avtransport_set_next: boolean - has_rendering_control: boolean - has_connection_manager: boolean - has_linkplay_http: boolean - has_arylic_tcp: boolean - has_oh_playlist: boolean - has_oh_volume: boolean - has_oh_info: boolean - has_oh_time: boolean - has_oh_radio: boolean + has_avtransport: boolean; + has_avtransport_set_next: boolean; + has_rendering_control: boolean; + has_connection_manager: boolean; + has_linkplay_http: boolean; + has_arylic_tcp: boolean; + has_oh_playlist: boolean; + has_oh_volume: boolean; + has_oh_info: boolean; + has_oh_time: boolean; + has_oh_radio: boolean; } export interface RendererSummary { - id: string - friendly_name: string - model_name: string - protocol: RendererProtocolSummary - capabilities: RendererCapabilitiesSummary - online: boolean + id: string; + friendly_name: string; + model_name: string; + protocol: RendererProtocolSummary; + capabilities: RendererCapabilitiesSummary; + online: boolean; } export interface RendererState { - id: string - friendly_name: string - transport_state: 'PLAYING' | 'PAUSED' | 'STOPPED' | 'TRANSITIONING' | 'NO_MEDIA' | 'UNKNOWN' - position_ms: number | null - duration_ms: number | null - volume: number | null // 0-100 - mute: boolean | null - queue_len: number - attached_playlist: AttachedPlaylistInfo | null - current_track: CurrentTrackMetadata | null + id: string; + friendly_name: string; + transport_state: + | "PLAYING" + | "PAUSED" + | "STOPPED" + | "TRANSITIONING" + | "NO_MEDIA" + | "UNKNOWN"; + position_ms: number | null; + duration_ms: number | null; + volume: number | null; // 0-100 + mute: boolean | null; + queue_len: number; + attached_playlist: AttachedPlaylistInfo | null; + current_track: CurrentTrackMetadata | null; } export interface CurrentTrackMetadata { - title: string | null - artist: string | null - album: string | null - album_art_uri: string | null + title: string | null; + artist: string | null; + album: string | null; + album_art_uri: string | null; } export interface AttachedPlaylistInfo { - server_id: string - container_id: string - has_seen_update: boolean + server_id: string; + container_id: string; + has_seen_update: boolean; } // ============================================================================ @@ -61,53 +71,26 @@ export interface AttachedPlaylistInfo { // ============================================================================ export interface QueueItem { - index: number // 0-based - uri: string - title: string | null - artist: string | null - album: string | null - album_art_uri: string | null - server_id: string | null - object_id: string | null + index: number; // 0-based + uri: string; + title: string | null; + artist: string | null; + album: string | null; + album_art_uri: string | null; + server_id: string | null; + object_id: string | null; } export interface QueueSnapshot { - renderer_id: string - items: QueueItem[] - current_index: number | null // Index de la piste en cours (null si rien en lecture) + renderer_id: string; + items: QueueItem[]; + current_index: number | null; // Index de la piste en cours (null si rien en lecture) } export interface FullRendererSnapshot { - state: RendererState - queue: QueueSnapshot - binding: AttachedPlaylistInfo | null -} - -// ============================================================================ -// OPENHOME PLAYLIST -// ============================================================================ - -export interface OpenHomePlaylistTrack { - id: number - uri: string - title: string | null - artist: string | null - album: string | null - album_art_uri: string | null -} - -export interface OpenHomePlaylistSnapshot { - renderer_id: string - current_id: number | null - current_index: number | null - tracks: OpenHomePlaylistTrack[] -} - -export interface OpenHomePlaylistAddRequest { - uri: string - metadata: string - after_id?: number | null - play?: boolean + state: RendererState; + queue: QueueSnapshot; + binding: AttachedPlaylistInfo | null; } // ============================================================================ @@ -115,26 +98,26 @@ export interface OpenHomePlaylistAddRequest { // ============================================================================ export interface MediaServerSummary { - id: string - friendly_name: string - model_name: string - online: boolean + id: string; + friendly_name: string; + model_name: string; + online: boolean; } export interface ContainerEntry { - id: string - title: string - class: string // UPnP class - is_container: boolean - child_count: number | null - artist: string | null - album: string | null - album_art_uri: string | null // ⚠️ Nom exact: album_art_uri + id: string; + title: string; + class: string; // UPnP class + is_container: boolean; + child_count: number | null; + artist: string | null; + album: string | null; + album_art_uri: string | null; // ⚠️ Nom exact: album_art_uri } export interface BrowseResponse { - container_id: string - entries: ContainerEntry[] + container_id: string; + entries: ContainerEntry[]; } // ============================================================================ @@ -142,26 +125,26 @@ export interface BrowseResponse { // ============================================================================ export interface VolumeSetRequest { - volume: number // 0-100 + volume: number; // 0-100 } export interface AttachPlaylistRequest { - server_id: string - container_id: string - auto_play?: boolean + server_id: string; + container_id: string; + auto_play?: boolean; } export interface PlayContentRequest { - server_id: string - object_id: string + server_id: string; + object_id: string; } export interface SuccessResponse { - message: string + message: string; } export interface ErrorResponse { - error: string + error: string; } // ============================================================================ @@ -169,40 +152,105 @@ export interface ErrorResponse { // ============================================================================ export type RendererEventPayload = - | { type: 'state_changed'; renderer_id: string; state: string; timestamp: string } - | { type: 'position_changed'; renderer_id: string; track: number | null; rel_time: string | null; track_duration: string | null; timestamp: string } - | { type: 'volume_changed'; renderer_id: string; volume: number; timestamp: string } - | { type: 'mute_changed'; renderer_id: string; mute: boolean; timestamp: string } - | { type: 'metadata_changed'; renderer_id: string; title: string | null; artist: string | null; album: string | null; album_art_uri: string | null; timestamp: string } - | { type: 'queue_updated'; renderer_id: string; queue_length: number; timestamp: string } - | { type: 'binding_changed'; renderer_id: string; server_id: string | null; container_id: string | null; timestamp: string } - | { type: 'online'; renderer_id: string; friendly_name: string; model_name: string; manufacturer: string; timestamp: string } - | { type: 'offline'; renderer_id: string; timestamp: string } + | { + type: "state_changed"; + renderer_id: string; + state: string; + timestamp: string; + } + | { + type: "position_changed"; + renderer_id: string; + track: number | null; + rel_time: string | null; + track_duration: string | null; + timestamp: string; + } + | { + type: "volume_changed"; + renderer_id: string; + volume: number; + timestamp: string; + } + | { + type: "mute_changed"; + renderer_id: string; + mute: boolean; + timestamp: string; + } + | { + type: "metadata_changed"; + renderer_id: string; + title: string | null; + artist: string | null; + album: string | null; + album_art_uri: string | null; + timestamp: string; + } + | { + type: "queue_updated"; + renderer_id: string; + queue_length: number; + timestamp: string; + } + | { + type: "binding_changed"; + renderer_id: string; + server_id: string | null; + container_id: string | null; + timestamp: string; + } + | { + type: "online"; + renderer_id: string; + friendly_name: string; + model_name: string; + manufacturer: string; + timestamp: string; + } + | { type: "offline"; renderer_id: string; timestamp: string }; export type MediaServerEventPayload = - | { type: 'global_updated'; server_id: string; system_update_id: number | null; timestamp: string } - | { type: 'containers_updated'; server_id: string; container_ids: string[]; timestamp: string } - | { type: 'online'; server_id: string; friendly_name: string; model_name: string; manufacturer: string; timestamp: string } - | { type: 'offline'; server_id: string; timestamp: string } + | { + type: "global_updated"; + server_id: string; + system_update_id: number | null; + timestamp: string; + } + | { + type: "containers_updated"; + server_id: string; + container_ids: string[]; + timestamp: string; + } + | { + type: "online"; + server_id: string; + friendly_name: string; + model_name: string; + manufacturer: string; + timestamp: string; + } + | { type: "offline"; server_id: string; timestamp: string }; export type UnifiedEventPayload = - | { category: 'renderer' } & RendererEventPayload - | { category: 'media_server' } & MediaServerEventPayload + | ({ category: "renderer" } & RendererEventPayload) + | ({ category: "media_server" } & MediaServerEventPayload); // ============================================================================ // MÉTADONNÉES PISTE // ============================================================================ export interface TrackMetadata { - title: string | null - artist: string | null - album: string | null - album_art_uri: string | null - duration_ms: number | null + title: string | null; + artist: string | null; + album: string | null; + album_art_uri: string | null; + duration_ms: number | null; } export interface PositionInfo { - track: number | null - rel_time: string | null // Format HH:MM:SS - track_duration: string | null // Format HH:MM:SS + track: number | null; + rel_time: string | null; // Format HH:MM:SS + track_duration: string | null; // Format HH:MM:SS } diff --git a/pmoapp/webapp/src/views/RendererView.vue b/pmoapp/webapp/src/views/RendererView.vue index 03048da5..c9ddab45 100644 --- a/pmoapp/webapp/src/views/RendererView.vue +++ b/pmoapp/webapp/src/views/RendererView.vue @@ -1,516 +1,302 @@ diff --git a/pmocontrol/examples/queue_pmomusic_demo.rs b/pmocontrol/examples/queue_pmomusic_demo.rs deleted file mode 100644 index 81b9bc69..00000000 --- a/pmocontrol/examples/queue_pmomusic_demo.rs +++ /dev/null @@ -1,539 +0,0 @@ -//! End-to-end queue demo that prefers the PMOMusic media server and exercises -//! the ControlPoint playback queue API. - -use std::collections::VecDeque; -use std::env; -use std::process; -use std::thread; -use std::time::Duration; - -use anyhow::{Context, Result}; -use pmocontrol::model::TrackMetadata; -use pmocontrol::{ - ControlPoint, DeviceRegistryRead, MediaBrowser, MediaEntry, MediaServerEvent, UpnpMediaServer, - MusicRendererBackend, UpnpMediaServer, PlaybackItem, PlaybackPosition, PlaybackPositionInfo, RendererInfo, -}; - -const DEFAULT_TIMEOUT_SECS: u64 = 5; -const DEFAULT_DISCOVERY_SECS: u64 = 5; -const DEFAULT_MAX_TRACKS: usize = 3; -const MONITOR_DURATION_SECS: u64 = 600; -const MONITOR_POLL_SECS: u64 = 5; -const MAX_BROWSE_DEPTH: usize = 2; - -fn main() -> Result<()> { - let _ = tracing_subscriber::fmt::try_init(); - let config = CliConfig::parse_from_env().unwrap_or_else(|err| { - eprintln!("Error parsing arguments: {err}"); - print_usage_and_exit(); - }); - - if config.max_tracks == 0 { - eprintln!("--max-tracks must be >= 1"); - process::exit(1); - } - - println!( - "Starting queue_pmomusic_demo with timeout={}s discovery={}s max_tracks={}", - config.timeout_secs, config.discovery_secs, config.max_tracks - ); - - // ControlPoint::spawn starts the HttpXmlDescriptionProvider + DiscoveryManager combo. - let control_point = - ControlPoint::spawn(config.timeout_secs).context("Failed to start control point")?; - - println!( - "Discovery running for {} seconds before selecting devices...", - config.discovery_secs - ); - thread::sleep(Duration::from_secs(config.discovery_secs)); - - let registry = control_point.registry(); - let (renderer, server_info) = { - let reg = registry.read().expect("registry poisoned"); - let renderer_candidates: Vec = reg - .list_renderers() - .into_iter() - .filter(|info| !is_pmomusic_renderer(info)) - .collect(); - let renderer = pick_renderer(renderer_candidates) - .unwrap_or_else(|| no_renderer_and_exit("No suitable renderer found after discovery.")); - let server = pick_media_server(reg.list_servers()) - .unwrap_or_else(|| no_server_and_exit("No media server with ContentDirectory.")); - (renderer, server) - }; - - println!( - "Selected renderer \"{}\" (protocol={:?}, id={})", - renderer.friendly_name, renderer.protocol, renderer.id.0 - ); - println!( - "Selected media server \"{}\" at {} (id={})", - server_info.friendly_name, server_info.location, server_info.id.0 - ); - - let renderer_instance = MusicRendererBackend::from_renderer_info(renderer.clone(), ®istry) - .expect("Selected renderer is not usable by MusicRenderer façade"); - let supports_set_next = renderer_instance - .as_upnp() - .map(|upnp| upnp.supports_set_next()) - .unwrap_or(false); - println!( - "Renderer \"{}\": AVTransport present = {}, SetNextAVTransportURI supported = {}", - renderer.friendly_name, renderer.capabilities.has_avtransport, supports_set_next - ); - - let timeout = Duration::from_secs(config.timeout_secs); - let server = - UpnpMediaServer::from_info(&server_info, timeout).context("Failed to init MusicServer")?; - - let root_entries = server - .browse_root() - .context("Failed to browse ContentDirectory root")?; - println!("Root returned {} entries", root_entries.len()); - - // Try to find a playlist container first - let (playback_items, bound_container_id) = - collect_playable_items_with_binding(&server, &root_entries, config.max_tracks) - .context("Failed to derive playable items from ContentDirectory root/children")?; - - if playback_items.is_empty() { - println!("No playable tracks were found on the selected server."); - process::exit(1); - } - - println!( - "Discovered {} playable items; enqueuing…", - playback_items.len() - ); - - if let Some(ref container_id) = bound_container_id { - println!( - "Found playlist container '{}' to bind queue to", - container_id - ); - } else { - println!("No playlist container found; queue will not be bound to server"); - } - - let mut planned_queue: VecDeque; - let renderer_id = renderer.id.clone(); - control_point - .clear_queue(&renderer_id) - .context("Failed to clear playback queue")?; - control_point - .enqueue_items(&renderer_id, playback_items) - .context("Failed to enqueue playback items")?; - - // Attach queue to playlist container if we found one - if let Some(container_id) = bound_container_id { - control_point - .attach_queue_to_playlist(&renderer_id, server_info.id.clone(), container_id.clone()) - .context("Failed to attach queue to playlist container")?; - println!( - "✓ Queue attached to playlist container {} on server {}", - container_id, server_info.friendly_name - ); - } - - let snapshot = control_point - .get_queue_snapshot(&renderer_id) - .context("Failed to snapshot queue after enqueue")?; - print_queue_snapshot(&snapshot); - planned_queue = snapshot.clone().into(); - - control_point - .play_next_from_queue(&renderer_id) - .context("Failed to start playback from queue")?; - let mut current_track = planned_queue.pop_front(); - let remaining = control_point - .get_queue_snapshot(&renderer_id) - .context("Failed to snapshot queue after play_next_from_queue")?; - planned_queue = remaining.clone().into(); - println!( - "Playback started on \"{}\"; {} tracks remaining in queue.", - renderer.friendly_name, - remaining.len() - ); - - println!( - "Monitoring queue auto-advance for {} seconds (poll every {}s)…", - MONITOR_DURATION_SECS, MONITOR_POLL_SECS - ); - - // Subscribe to media server events to observe playlist updates - let media_event_rx = control_point.subscribe_media_server_events(); - - let poll_count = MONITOR_DURATION_SECS / MONITOR_POLL_SECS; - for tick in 0..poll_count { - thread::sleep(Duration::from_secs(MONITOR_POLL_SECS)); - - // Drain any MediaServerEvent that arrived since last poll - loop { - match media_event_rx.try_recv() { - Ok(MediaServerEvent::GlobalUpdated { - server_id, - system_update_id, - }) => { - println!( - " 📢 MediaServer {} global update (SystemUpdateID={:?})", - server_id.0, system_update_id - ); - } - Ok(MediaServerEvent::ContainersUpdated { - server_id, - container_ids, - }) => { - println!( - " 📢 MediaServer {} containers updated: {:?}", - server_id.0, container_ids - ); - - // Check if our bound container was updated - if let Some((bound_server, bound_container, _)) = - control_point.current_queue_playlist_binding(&renderer_id) - { - if bound_server == server_id && container_ids.contains(&bound_container) { - println!( - " 🔄 Bound playlist container '{}' was updated, queue will refresh automatically", - bound_container - ); - } - } - } - Err(_) => break, // No more events, continue with normal monitoring - } - } - - let snapshot = control_point - .get_queue_snapshot(&renderer_id) - .context("Queue snapshot failed during monitoring loop")?; - let new_plan: VecDeque = snapshot.clone().into(); - if planned_queue.len() > new_plan.len() { - let removed = planned_queue.len() - new_plan.len(); - for _ in 0..removed { - current_track = planned_queue.pop_front(); - } - } - planned_queue = new_plan; - - let playback_info = control_point - .music_renderer_by_id(&renderer_id) - .and_then(|renderer| renderer.playback_position().ok()); - - let title = current_track_title(current_track.as_ref()); - if let Some(info) = playback_info { - println!( - "[tick {tick}] Queue length = {} | now playing: {} [{}]", - snapshot.len(), - title, - format_playback_position(&info) - ); - } else { - println!( - "[tick {tick}] Queue length = {} | now playing: {} [position unavailable]", - snapshot.len(), - title - ); - } - } - - println!("Monitoring finished, exiting."); - Ok(()) -} - -#[derive(Debug)] -struct CliConfig { - timeout_secs: u64, - discovery_secs: u64, - max_tracks: usize, -} - -impl CliConfig { - fn parse_from_env() -> Result { - let mut timeout_secs = DEFAULT_TIMEOUT_SECS; - let mut discovery_secs = DEFAULT_DISCOVERY_SECS; - let mut max_tracks = DEFAULT_MAX_TRACKS; - - let mut args = env::args().skip(1); - while let Some(arg) = args.next() { - match arg.as_str() { - "--timeout-secs" => { - let value = args - .next() - .ok_or_else(|| "--timeout-secs requires a value".to_string())?; - timeout_secs = value.parse().map_err(|err| { - format!("Invalid value for --timeout-secs ({value}): {err}") - })?; - } - "--discovery-secs" => { - let value = args - .next() - .ok_or_else(|| "--discovery-secs requires a value".to_string())?; - discovery_secs = value.parse().map_err(|err| { - format!("Invalid value for --discovery-secs ({value}): {err}") - })?; - } - "--max-tracks" => { - let value = args - .next() - .ok_or_else(|| "--max-tracks requires a value".to_string())?; - max_tracks = value.parse().map_err(|err| { - format!("Invalid value for --max-tracks ({value}): {err}") - })?; - } - "--help" | "-h" => { - print_usage_and_exit(); - } - unknown => { - return Err(format!("Unknown argument: {unknown}")); - } - } - } - - Ok(Self { - timeout_secs, - discovery_secs, - max_tracks, - }) - } -} - -fn pick_renderer(renderers: Vec) -> Option { - let mut candidates: Vec = renderers; - - if candidates.is_empty() { - return None; - } - - println!("Renderer candidates:"); - for (idx, info) in candidates.iter().enumerate() { - println!( - " [{}] {} | model={} | location={} | online={}", - idx, info.friendly_name, info.model_name, info.location, info.online - ); - } - - let selected = candidates.remove(0); - println!( - "Automatically selecting renderer index 0: {}", - selected.friendly_name - ); - Some(selected) -} - -fn pick_media_server(servers: Vec) -> Option { - let mut candidates: Vec = servers - .into_iter() - .filter(|info| info.has_content_directory) - .filter(|info| info.content_directory_control_url.is_some()) - .collect(); - - if candidates.is_empty() { - return None; - } - - if let Some(idx) = candidates.iter().position(is_pmomusic_server) { - let server = candidates.remove(idx); - println!( - "Preferring PMOMusic server \"{}\" (server header: {}).", - server.friendly_name, server.server_header - ); - Some(server) - } else { - println!("No PMOMusic server discovered; falling back to first ContentDirectory server."); - Some(candidates.remove(0)) - } -} - -fn is_pmomusic_server(info: &UpnpMediaServer) -> bool { - let name = info.friendly_name.to_ascii_lowercase(); - let header = info.server_header.to_ascii_lowercase(); - name.contains("pmomusic") || header.contains("pmomusic") -} - -fn is_pmomusic_renderer(info: &RendererInfo) -> bool { - info.friendly_name - .to_ascii_lowercase() - .contains("pmomusic audio renderer") -} - -fn collect_playable_items_with_binding( - server: &UpnpMediaServer, - entries: &[MediaEntry], - max_tracks: usize, -) -> Result<(Vec, Option)> { - // First, try to find a playlist container - let playlist_container = entries.iter().find(|entry| { - entry.is_container - && entry - .class - .to_ascii_lowercase() - .contains("object.container.playlistcontainer") - }); - - if let Some(playlist) = playlist_container { - println!( - "Found playlist container: '{}' (id: {}, class: {})", - playlist.title, playlist.id, playlist.class - ); - - // Browse the playlist container - match server.browse_children(&playlist.id, 0, max_tracks as u32) { - Ok(children) => { - let mut items = Vec::new(); - for entry in &children { - if let Some(item) = playback_item_from_entry(server, entry) { - items.push(item); - if items.len() >= max_tracks { - break; - } - } - } - - if !items.is_empty() { - return Ok((items, Some(playlist.id.clone()))); - } - - println!( - "Playlist container '{}' is empty, falling back to general browse", - playlist.title - ); - } - Err(err) => { - println!( - "Failed to browse playlist container '{}': {}, falling back", - playlist.title, err - ); - } - } - } else { - println!("No playlist container found in root entries, using fallback"); - } - - // Fallback: collect from any container/item - let mut items = Vec::new(); - for entry in entries { - gather_items_from_entry(server, entry, max_tracks, 0, &mut items)?; - if items.len() >= max_tracks { - break; - } - } - Ok((items, None)) -} - -fn gather_items_from_entry( - server: &UpnpMediaServer, - entry: &MediaEntry, - max_tracks: usize, - depth: usize, - out: &mut Vec, -) -> Result<()> { - if out.len() >= max_tracks { - return Ok(()); - } - - if entry.is_container { - if depth >= MAX_BROWSE_DEPTH { - return Ok(()); - } - - match server.browse_children(&entry.id, 0, 50) { - Ok(children) => { - for child in children { - gather_items_from_entry(server, &child, max_tracks, depth + 1, out)?; - if out.len() >= max_tracks { - break; - } - } - } - Err(err) => { - tracing::warn!( - container_id = entry.id.as_str(), - error = %err, - "Failed to browse child container" - ); - } - } - return Ok(()); - } - - if let Some(item) = playback_item_from_entry(server, entry) { - out.push(item); - } - Ok(()) -} - -fn playback_item_from_entry(server: &UpnpMediaServer, entry: &MediaEntry) -> Option { - if entry.title.to_ascii_lowercase().contains("live stream") { - return None; - } - 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), - }) -} - -fn print_queue_snapshot(items: &[PlaybackItem]) { - println!("Current queue snapshot ({} items):", items.len()); - for (idx, item) in items.iter().enumerate() { - let label = item - .metadata - .as_ref() - .and_then(|meta| meta.title.as_deref()) - .unwrap_or_else(|| item.uri.as_str()); - println!(" [{}] {} -> {}", idx, label, item.uri); - } - if items.is_empty() { - println!(" "); - } -} - -fn current_track_title(item: Option<&PlaybackItem>) -> String { - match item { - Some(track) => track - .metadata - .as_ref() - .and_then(|meta| meta.title.as_deref()) - .unwrap_or_else(|| track.uri.as_str()) - .to_string(), - None => "".to_string(), - } -} - -fn format_playback_position(info: &PlaybackPositionInfo) -> String { - let rel = info.rel_time.as_deref().unwrap_or("-"); - let dur = info.track_duration.as_deref().unwrap_or("-"); - format!("{rel} / {dur}") -} - -fn no_renderer_and_exit(message: &str) -> ! { - println!("{message}"); - process::exit(1); -} - -fn no_server_and_exit(message: &str) -> ! { - println!("{message}"); - process::exit(1); -} - -fn print_usage_and_exit() -> ! { - println!( - "Usage: cargo run -p pmocontrol --example queue_pmomusic_demo -- [--timeout-secs N] [--discovery-secs N] [--max-tracks N]" - ); - process::exit(1); -} diff --git a/pmocontrol/src/arylic_client/mod.rs b/pmocontrol/src/arylic_client/mod.rs index b93d4c0a..38aafd51 100644 --- a/pmocontrol/src/arylic_client/mod.rs +++ b/pmocontrol/src/arylic_client/mod.rs @@ -1,4 +1,10 @@ -use std::{io::{Read, Write}, net::{Shutdown, TcpStream, ToSocketAddrs}, sync::{Mutex, OnceLock}, thread, time::{Duration, Instant}}; +use std::{ + io::{Read, Write}, + net::{Shutdown, TcpStream, ToSocketAddrs}, + sync::{Mutex, OnceLock}, + thread, + time::{Duration, Instant}, +}; use tracing::{debug, warn}; @@ -18,7 +24,6 @@ fn last_command_time() -> &'static Mutex { LAST_COMMAND_TIME.get_or_init(|| Mutex::new(Instant::now())) } - /// Mode d’attente de réponse pour une commande TCP Arylic. enum ResponseMode<'a> { /// On n’attend aucune réponse (fire-and-forget). @@ -40,16 +45,14 @@ fn send_command_with_mode( let packet = encode_packet(payload); stream.write_all(&packet).map_err(|_| { - ControlPointError::ArilycTcpError( - format!( + ControlPointError::ArilycTcpError(format!( "Failed to write Arylic TCP packet for {}: {}", host, payload )) })?; stream.flush().map_err(|_| { - ControlPointError::ArilycTcpError( - format!( + ControlPointError::ArilycTcpError(format!( "Failed to flush Arylic TCP stream for {} (command {})", host, payload )) @@ -91,8 +94,7 @@ fn send_command_with_mode( Err(ControlPointError::ArilycTcpError(format!( "No expected response for optional command {} on {}", - payload, - host + payload, host ))) } } @@ -110,9 +112,7 @@ fn read_expected_response( Err(err) => { return Err(ControlPointError::ArilycTcpError(format!( "Failed to read Arylic TCP response for {} (command {}): {}", - host, - payload, - err + host, payload, err ))); } }; @@ -128,8 +128,7 @@ fn read_expected_response( Err(ControlPointError::ArilycTcpError(format!( "No expected response for command {} on {}", - payload, - host + payload, host ))) } @@ -171,11 +170,15 @@ pub fn send_command_optional( ) } -pub fn send_command_no_response(host: &str, port: u16, timeout: Duration, payload: &str) -> Result<(), ControlPointError> { +pub fn send_command_no_response( + host: &str, + port: u16, + timeout: Duration, + payload: &str, +) -> Result<(), ControlPointError> { send_command_with_mode(host, port, timeout, payload, ResponseMode::None).map(|_| ()) } - fn connect(host: &str, port: u16, timeout: Duration) -> Result { if let Ok(mut last_time) = last_command_time().lock() { let elapsed = last_time.elapsed(); @@ -197,18 +200,15 @@ fn connect(host: &str, port: u16, timeout: Duration) -> Result { stream .set_read_timeout(Some(timeout)) .and_then(|_| stream.set_write_timeout(Some(timeout))) - .map_err(|err| { + .map_err(|_| { ControlPointError::ArilycTcpError(format!( "Failed to set socket timeouts for {}", address @@ -251,29 +251,37 @@ fn encode_packet(payload: &str) -> Vec { fn read_packet(stream: &mut TcpStream) -> Result { let mut header = [0u8; 4]; - stream.read_exact(&mut header) - .map_err(|e| ControlPointError::ArilycTcpError(format!("{}",e)))?; + stream + .read_exact(&mut header) + .map_err(|e| ControlPointError::ArilycTcpError(format!("{}", e)))?; if header != PACKET_HEADER { - return Err(ControlPointError::ArilycTcpError(format!("Invalid Arylic packet header: {:x?}", header))); + return Err(ControlPointError::ArilycTcpError(format!( + "Invalid Arylic packet header: {:x?}", + header + ))); } let mut len_buf = [0u8; 4]; - stream.read_exact(&mut len_buf) - .map_err(|e| ControlPointError::ArilycTcpError(format!("{}",e)))?; + stream + .read_exact(&mut len_buf) + .map_err(|e| ControlPointError::ArilycTcpError(format!("{}", e)))?; let len = u32::from_le_bytes(len_buf) as usize; let mut checksum_buf = [0u8; 4]; - stream.read_exact(&mut checksum_buf) - .map_err(|e| ControlPointError::ArilycTcpError(format!("{}",e)))?; + stream + .read_exact(&mut checksum_buf) + .map_err(|e| ControlPointError::ArilycTcpError(format!("{}", e)))?; let expected_checksum = u32::from_le_bytes(checksum_buf); let mut reserved = [0u8; 8]; - stream.read_exact(&mut reserved) - .map_err(|e| ControlPointError::ArilycTcpError(format!("{}",e)))?; + stream + .read_exact(&mut reserved) + .map_err(|e| ControlPointError::ArilycTcpError(format!("{}", e)))?; let mut payload = vec![0u8; len]; - stream.read_exact(&mut payload) - .map_err(|e| ControlPointError::ArilycTcpError(format!("{}",e)))?; + stream + .read_exact(&mut payload) + .map_err(|e| ControlPointError::ArilycTcpError(format!("{}", e)))?; let actual_checksum = payload.iter().fold(0u32, |acc, b| acc + (*b as u32)); if actual_checksum != expected_checksum { @@ -284,5 +292,5 @@ fn read_packet(stream: &mut TcpStream) -> Result { } Ok(String::from_utf8(payload) - .map_err(|e| ControlPointError::ArilycTcpError(format!("{}",e)))?) + .map_err(|e| ControlPointError::ArilycTcpError(format!("{}", e)))?) } diff --git a/pmocontrol/src/control_point.rs b/pmocontrol/src/control_point.rs index 5a20d50c..cecafee5 100644 --- a/pmocontrol/src/control_point.rs +++ b/pmocontrol/src/control_point.rs @@ -1,91 +1,33 @@ -use std::collections::{HashMap, HashSet}; -use std::hash::{DefaultHasher, Hash, Hasher}; -use std::io::{self, BufRead, BufReader, Read, Write}; -use std::marker::PhantomData; -use std::net::{IpAddr, TcpListener, TcpStream, UdpSocket}; -use std::ops::{Deref, DerefMut}; -use std::sync::{Arc, Mutex, MutexGuard, RwLock}; +use std::io; +use std::sync::{Arc, Mutex, RwLock}; use std::thread; -use std::time::{Duration, Instant}; +use std::time::Duration; -use anyhow::{anyhow, Context}; -use crossbeam_channel::{unbounded, Receiver, Sender}; +use anyhow::anyhow; +use crossbeam_channel::Receiver; use pmodidl::{DIDLLite, Item as DidlItem, Resource as DidlResource}; use pmoupnp::ssdp::SsdpClient; use quick_xml::se::to_string as to_didl_string; -use thiserror::Error; use tracing::{debug, error, info, warn}; -use ureq::{http, Agent}; -use xmltree::{Element, XMLNode}; - -pub const OPENHOME_SNAPSHOT_CACHE_TTL: Duration = Duration::from_secs(2); - -use crate::capabilities::{ - PlaybackPosition, PlaybackPositionInfo, PlaybackState, PlaybackStatus, TransportControl, - VolumeControl, -}; -use crate::control_point::music_queue::MusicQueue; -use crate::control_point::openhome_queue::OpenHomeQueue; -use crate::discovery::DiscoveryManager; use crate::discovery::manager::UDNRegistry; +use crate::errors::ControlPointError; use crate::events::{MediaServerEventBus, RendererEventBus}; -use crate::media_server::{ - playback_item_from_entry, MediaBrowser, UpnpMediaServer, ServerId, -}; +use crate::media_server::{MediaBrowser, MusicServer, playback_item_from_entry}; use crate::media_server_events::spawn_media_server_event_runtime; -use crate::model::TrackMetadata; -use crate::model::{MediaServerEvent, RendererEvent, ServiceId, RendererInfo}; -use crate::music_renderer::MusicRenderer; +use crate::model::{MediaServerEvent, RendererEvent}; +use crate::model::{PlaybackState, TrackMetadata}; +use crate::music_renderer::{MusicRenderer, PlaybackPositionInfo, PlaylistBinding}; + +use crate::{DeviceId, DeviceIdentity, DeviceOnline, PlaybackSource}; + #[cfg(feature = "pmoserver")] use crate::openapi::{ CurrentTrackMetadata, FullRendererSnapshot, QueueItem, QueueSnapshotView, RendererBindingView, RendererStateView, }; -use crate::openhome::{ - build_info_client, build_playlist_client, build_product_client, OhServiceKind, -}; -use crate::openhome_client::parse_track_metadata_from_didl; -use crate::openhome_playlist::{OpenHomePlaylistSnapshot, OpenHomePlaylistTrack}; -use crate::music_renderer::openhome_renderer::map_openhome_state; -use crate::music_renderer::time_utils::format_hhmmss_u32; -use crate::provider::HttpXmlDescriptionProvider; -use crate::queue::backend::{EnqueueMode, PlaybackItem, QueueBackend, QueueSnapshot}; -use crate::queue_interne::InternalQueue; -use crate::registry::{DeviceRegistry, DeviceRegistryRead, DeviceUpdate}; -use crate::upnp_renderer::UpnpRenderer; -use crate::MusicRendererBackend; -use crate::MusicRendererInfo; - - -/// 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)] -pub struct PlaylistBinding { - /// MediaServer that owns the playlist container. - pub server_id: ServerId, - /// DIDL-Lite object id of the playlist container. - pub container_id: String, - /// True once at least one ContainerUpdateIDs notification has been seen. - pub(crate) has_seen_update: bool, - /// Flag used internally to signal that the queue should be refreshed - /// from the server container. - pub(crate) pending_refresh: bool, - /// Whether the next refresh should auto-start playback if the renderer is idle. - pub(crate) auto_play_on_refresh: bool, -} - -#[derive(Debug, Error)] -pub enum OpenHomeAccessError { - #[error("Renderer {0} not found")] - RendererNotFound(String), - #[error("Renderer {0} has no OpenHome playlist service")] - PlaylistNotSupported(String), -} +use crate::queue::{EnqueueMode, PlaybackItem, QueueBackend, QueueSnapshot}; +use crate::registry::DeviceRegistry; /// Control point minimal : /// - lance un SsdpClient dans un thread, @@ -97,10 +39,9 @@ pub enum OpenHomeAccessError { /// et n'utiliser les événements SSE que comme signaux de rafraîchissement. pub struct ControlPoint { registry: Arc>, - udn_cache: Arc>, + // udn_cache: Arc>, event_bus: RendererEventBus, media_event_bus: MediaServerEventBus, - runtime: Arc, } impl ControlPoint { @@ -108,10 +49,13 @@ impl ControlPoint { /// /// `timeout_secs` : timeout HTTP pour la récupération des descriptions UPnP. pub fn spawn(timeout_secs: u64) -> io::Result { - let registry = Arc::new(RwLock::new(DeviceRegistry::new())); let event_bus = RendererEventBus::new(); + let udn_cache = Arc::new(Mutex::new(UDNRegistry::new())); let media_event_bus = MediaServerEventBus::new(); - let runtime = Arc::new(RuntimeState::new()); + let registry = Arc::new(RwLock::new(DeviceRegistry::new( + &event_bus, + &media_event_bus, + ))); // SsdpClient let client = SsdpClient::new()?; // pmoupnp::ssdp::SsdpClient @@ -121,14 +65,15 @@ impl ControlPoint { // Arc utilisé dans le thread let registry_for_thread = Arc::clone(®istry); - let event_bus_for_discovery = event_bus.clone(); - let media_event_bus_for_discovery = media_event_bus.clone(); + let udn_cache_for_thread = Arc::clone(&udn_cache); // Thread de découverte thread::spawn(move || { - // Provider HTTP+XML et DiscoveryManager VIVENT dans le thread - let provider = HttpXmlDescriptionProvider::new(timeout_secs); - let mut discovery = DiscoveryManager::new(provider); + use crate::discovery::UpnpDiscoveryManager; + + // Créer le gestionnaire de découverte UPNP + let mut discovery = + UpnpDiscoveryManager::new(registry_for_thread, udn_cache_for_thread); // ACTIVE DISCOVERY : envoyer quelques M-SEARCH au démarrage // pour forcer les devices à répondre rapidement. @@ -147,62 +92,10 @@ impl ControlPoint { std::thread::sleep(Duration::from_millis(200)); } - // La closure passée à run_event_loop capture discovery par mutable borrow - // => FnMut, ce que SsdpClient::run_event_loop accepte. + // La closure passe les événements SSDP au gestionnaire de découverte + // Le registry émet automatiquement les événements Online/Offline client.run_event_loop(move |event| { - let updates: Vec = discovery.handle_ssdp_event(event); - - if updates.is_empty() { - return; - } - - if let Ok(mut reg) = registry_for_thread.write() { - for update in updates { - // Émettre les événements Online/Offline avant d'appliquer l'update - match &update { - DeviceUpdate::RendererOnline(info) => { - event_bus_for_discovery.broadcast(RendererEvent::Online { - id: info.id.clone(), - info: info.clone(), - }); - } - DeviceUpdate::RendererOfflineById(id) => { - event_bus_for_discovery.broadcast(RendererEvent::Offline { - id: id.clone(), - }); - } - DeviceUpdate::RendererOfflineByUdn(udn) => { - // Trouver l'ID avant de marquer offline - if let Some(renderer) = reg.get_renderer_by_udn(udn) { - event_bus_for_discovery.broadcast(RendererEvent::Offline { - id: renderer.id.clone(), - }); - } - } - DeviceUpdate::ServerOnline(info) => { - media_event_bus_for_discovery.broadcast(MediaServerEvent::Online { - server_id: info.id.clone(), - info: info.clone(), - }); - } - DeviceUpdate::ServerOfflineById(id) => { - media_event_bus_for_discovery.broadcast(MediaServerEvent::Offline { - server_id: id.clone(), - }); - } - DeviceUpdate::ServerOfflineByUdn(udn) => { - // Trouver l'ID avant de marquer offline - if let Some(server) = reg.get_server_by_udn(udn) { - media_event_bus_for_discovery.broadcast(MediaServerEvent::Offline { - server_id: server.id.clone(), - }); - } - } - } - - reg.apply_update(update); - } - } + discovery.handle_ssdp_event(event); }); }); @@ -235,106 +128,31 @@ impl ControlPoint { }); // Thread de vérification de présence périodique - // Vérifie toutes les 60 secondes que les devices connus sont toujours accessibles - let registry_for_presence = Arc::clone(®istry); - let event_bus_for_presence = event_bus.clone(); - let media_event_bus_for_presence = media_event_bus.clone(); + // Vérifie toutes les 60 secondes les timeouts des devices + let registry_for_timeout = Arc::clone(®istry); + thread::spawn(move || { - use ureq::Agent; - - // HTTP client avec timeout court pour les vérifications de présence - let config = Agent::config_builder() - .timeout_global(Some(Duration::from_secs(5))) - .build(); - let agent: Agent = config.into(); - loop { - // Attendre 60 secondes avant le prochain cycle thread::sleep(Duration::from_secs(60)); - debug!("Starting periodic presence check for devices"); - - let mut updates = Vec::new(); - - // Lire la liste des devices - if let Ok(reg) = registry_for_presence.read() { - // Vérifier les renderers - for renderer in reg.list_renderers() { - if !renderer.online { - continue; // Skip déjà offline - } - - // Faire un HTTP HEAD pour vérifier la présence - match agent.head(&renderer.location).call() { - Ok(_) => { - // Device répond toujours - debug!("Renderer {} ({:?}) is still online", - renderer.friendly_name, renderer.id); - } - Err(e) => { - // Device ne répond plus - warn!("Renderer {} ({:?}) is no longer responding: {} - marking offline", - renderer.friendly_name, renderer.id, e); - updates.push(DeviceUpdate::RendererOfflineById(renderer.id)); - } - } - } - - // Vérifier les servers - for server in reg.list_servers() { - if !server.online { - continue; // Skip déjà offline - } - - // Faire un HTTP HEAD pour vérifier la présence - match agent.head(&server.location).call() { - Ok(_) => { - // Device répond toujours - debug!("Server {} ({:?}) is still online", - server.friendly_name, server.id); - } - Err(e) => { - // Device ne répond plus - warn!("Server {} ({:?}) is no longer responding: {} - marking offline", - server.friendly_name, server.id, e); - updates.push(DeviceUpdate::ServerOfflineById(server.id)); - } - } - } - } - - // Appliquer les updates et émettre les événements - if !updates.is_empty() { - if let Ok(mut reg) = registry_for_presence.write() { - for update in updates { - // Émettre les événements Offline - match &update { - DeviceUpdate::RendererOfflineById(id) => { - event_bus_for_presence.broadcast(RendererEvent::Offline { - id: id.clone(), - }); - } - DeviceUpdate::ServerOfflineById(id) => { - media_event_bus_for_presence.broadcast(MediaServerEvent::Offline { - server_id: id.clone(), - }); - } - _ => {} - } - - reg.apply_update(update); - } - } + // Le registry vérifie les timeouts et émet automatiquement les événements Offline + if let Ok(mut reg) = registry_for_timeout.write() { + reg.check_timeouts(); } } }); // Thread de découverte mDNS pour Chromecast let registry_for_mdns = Arc::clone(®istry); + let udn_cache_for_mdns = Arc::clone(&udn_cache); thread::spawn(move || { - use crate::chromecast_discovery; + use crate::discovery::ChromecastDiscoveryManager; use futures_util::StreamExt; + // Créer le gestionnaire de découverte UPNP + let mut discovery_manager = + ChromecastDiscoveryManager::new(registry_for_mdns, udn_cache_for_mdns); + debug!("Starting mDNS discovery thread for Chromecast devices"); const SERVICE_NAME: &str = "_googlecast._tcp.local"; @@ -354,17 +172,12 @@ impl ControlPoint { while let Some(result) = stream.next().await { match result { Ok(response) => { - debug!("Received mDNS response with {} records", - response.records().count()); + debug!( + "Received mDNS response with {} records", + response.records().count() + ); - // Process the mDNS response - if let Some(update) = chromecast_discovery::process_mdns_response(response) { - debug!("Processed Chromecast device update: {:?}", update); - - // Update the registry - let mut registry = registry_for_mdns.write().unwrap(); - registry.apply_update(update); - } + discovery_manager.handle_mdns_response(response); } Err(e) => { warn!("mDNS discovery error: {}", e); @@ -381,46 +194,37 @@ impl ControlPoint { }); }); - let runtime_cp = ControlPoint { + let polling_cp = ControlPoint { registry: Arc::clone(®istry), + // udn_cache: udn_cache.clone(), event_bus: event_bus.clone(), media_event_bus: media_event_bus.clone(), - runtime: Arc::clone(&runtime), }; thread::spawn(move || { + use std::collections::HashMap; + + // Local cache for change detection (not a source of truth) + let mut polling_cache: HashMap = HashMap::new(); let mut tick: u32 = 0; loop { - let infos = { - let reg = runtime_cp.registry.read().unwrap(); - reg.list_renderers() + // Get renderers directly from registry - they already contain backends + let renderers = { + let reg = polling_cp.registry.read().unwrap(); + reg.list_renderers().unwrap_or_else(|_| vec![]) }; - // Create MusicRendererBackend for each renderer from registry - // No caching needed - backends manage their own connections - let renderers: Vec = infos - .into_iter() - .filter_map(|info| { - MusicRendererBackend::from_renderer_info(info, &runtime_cp.registry) - }) - .collect(); - for renderer in renderers { - let info = renderer.info(); - - if !info.online { + if !renderer.is_online() { continue; } - let renderer_id = info.id.clone(); + let renderer_id = renderer.id(); - // Initialize runtime entry if it doesn't exist - // Queue is managed by MusicRenderer, not RuntimeState - if !runtime_cp.runtime.has_entry(&renderer_id) { - runtime_cp.runtime.create_entry(&renderer_id); - } - let prev_snapshot = runtime_cp.runtime.snapshot_for(&renderer_id); + // Get previous snapshot from local cache + let prev_snapshot = + polling_cache.get(&renderer_id).cloned().unwrap_or_default(); let mut new_snapshot = prev_snapshot.clone(); let prev_position = prev_snapshot.position.clone(); @@ -432,7 +236,7 @@ impl ControlPoint { }; if has_changed { - runtime_cp.emit_renderer_event(RendererEvent::PositionChanged { + polling_cp.emit_renderer_event(RendererEvent::PositionChanged { id: renderer_id.clone(), position: position.clone(), }); @@ -453,7 +257,7 @@ impl ControlPoint { artist = metadata.artist.as_deref(), "Emitting metadata changed event" ); - runtime_cp.emit_renderer_event( + polling_cp.emit_renderer_event( RendererEvent::MetadataChanged { id: renderer_id.clone(), metadata: metadata.clone(), @@ -490,7 +294,7 @@ impl ControlPoint { // 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 { + polling_cp.emit_renderer_event(RendererEvent::StateChanged { id: renderer_id.clone(), state: logical_state.clone(), }); @@ -504,7 +308,7 @@ impl ControlPoint { if tick % 3 == 0 { if let Ok(volume) = renderer.volume() { if prev_snapshot.last_volume != Some(volume) { - runtime_cp.emit_renderer_event(RendererEvent::VolumeChanged { + polling_cp.emit_renderer_event(RendererEvent::VolumeChanged { id: renderer_id.clone(), volume, }); @@ -515,7 +319,7 @@ impl ControlPoint { if let Ok(mute) = renderer.mute() { if prev_snapshot.last_mute != Some(mute) { - runtime_cp.emit_renderer_event(RendererEvent::MuteChanged { + polling_cp.emit_renderer_event(RendererEvent::MuteChanged { id: renderer_id.clone(), mute, }); @@ -525,9 +329,8 @@ impl ControlPoint { } } - runtime_cp - .runtime - .update_snapshot(&renderer_id, new_snapshot); + // Update local cache + polling_cache.insert(renderer_id, new_snapshot); } tick = tick.wrapping_add(1); @@ -542,123 +345,94 @@ impl ControlPoint { timeout_secs, )?; - let (oh_event_tx, oh_event_rx) = unbounded::(); - let event_forwarder_cp = ControlPoint { - registry: Arc::clone(®istry), - event_bus: event_bus.clone(), - media_event_bus: media_event_bus.clone(), - runtime: Arc::clone(&runtime), - }; - - thread::Builder::new() - .name("cp-openhome-event-forwarder".into()) - .spawn(move || { - while let Ok(event) = oh_event_rx.recv() { - event_forwarder_cp.emit_renderer_event(event); - } - })?; - - spawn_openhome_event_runtime( - Arc::clone(®istry), - Arc::clone(&runtime), - event_bus.clone(), - oh_event_tx, - )?; - // 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 event_bus_for_media_worker = event_bus.clone(); 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; - } - }; + .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, - } => { - // Get all renderers from registry and check their bindings - let renderers_to_refresh: Vec = { - let reg = registry_for_media_worker.read().unwrap(); - let mut to_refresh = Vec::new(); - - if let Ok(renderers) = reg.list_renderers() { - for renderer in renderers { - let renderer_id = renderer.info().id.clone(); - if let Some(binding) = renderer.get_playlist_binding() { - if binding.server_id == server_id - && container_ids.contains(&binding.container_id) - { - // Update binding to mark refresh needed - let mut updated_binding = binding.clone(); - updated_binding.pending_refresh = true; - updated_binding.has_seen_update = true; - renderer.set_playlist_binding(Some(updated_binding)); - to_refresh.push(renderer_id); - } + 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, + } => { + // Find all renderers bound to the updated containers + let renderers_to_refresh: Vec<(DeviceId, Arc)> = { + let reg = registry_for_media_worker.read().unwrap(); + match reg.list_renderers() { + Ok(renderers) => renderers + .into_iter() + .filter_map(|renderer| { + // Mark binding for refresh if it matches + if renderer.mark_binding_for_refresh(&server_id, &container_ids) { + Some((renderer.id(), renderer)) + } else { + None + } + }) + .collect(), + Err(e) => { + warn!(error = %e, "Failed to list renderers for container update"); + Vec::new() } } - } + }; - 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, - &renderer_id, - &event_bus_for_media_worker, - None, - ) { - warn!( + // Trigger refresh for each affected renderer (outside of registry lock) + for (renderer_id, _renderer) in renderers_to_refresh { + debug!( renderer = renderer_id.0.as_str(), server = server_id.0.as_str(), - error = %err, - "Failed to refresh queue from playlist container" + "Triggering queue refresh for bound playlist" ); + + if let Err(err) = refresh_attached_queue_for( + ®istry_for_media_worker, + &renderer_id, + &event_bus_for_media_worker, + None, + ) { + warn!( + renderer = renderer_id.0.as_str(), + server = server_id.0.as_str(), + error = %err, + "Failed to refresh queue from playlist container" + ); + } } } - } - MediaServerEvent::Online { server_id, info } => { - debug!( - server = server_id.0.as_str(), - friendly_name = info.friendly_name.as_str(), - "MediaServer came online" - ); - } - MediaServerEvent::Offline { server_id } => { - debug!( - server = server_id.0.as_str(), - "MediaServer went offline" - ); + MediaServerEvent::Online { server_id, info } => { + debug!( + server = server_id.0.as_str(), + friendly_name = info.friendly_name.as_str(), + "MediaServer came online" + ); + } + MediaServerEvent::Offline { server_id } => { + debug!(server = server_id.0.as_str(), "MediaServer went offline"); + } } } })?; @@ -666,7 +440,6 @@ impl ControlPoint { // 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 event_bus_for_periodic = event_bus.clone(); thread::Builder::new() @@ -677,24 +450,25 @@ impl ControlPoint { thread::sleep(Duration::from_secs(60)); // Collect all renderers with active bindings and mark them for refresh - let renderers_to_refresh: Vec = { + let renderers_to_refresh: Vec = { let reg = registry_for_periodic.read().unwrap(); - let mut to_refresh = Vec::new(); - - if let Ok(renderers) = reg.list_renderers() { - for renderer in renderers { - let renderer_id = renderer.info().id.clone(); - if let Some(binding) = renderer.get_playlist_binding() { - // Mark binding for refresh - let mut updated_binding = binding.clone(); - updated_binding.pending_refresh = true; - renderer.set_playlist_binding(Some(updated_binding)); - to_refresh.push(renderer_id); - } + match reg.list_renderers() { + Ok(renderers) => renderers + .into_iter() + .filter_map(|renderer| { + // Mark binding for refresh if it exists + if renderer.mark_pending_refresh() { + Some(renderer.id()) + } else { + None + } + }) + .collect(), + Err(e) => { + warn!(error = %e, "Failed to list renderers for periodic refresh"); + Vec::new() } } - - to_refresh }; // Trigger refresh for each bound renderer (outside of lock) @@ -706,7 +480,6 @@ impl ControlPoint { if let Err(err) = refresh_attached_queue_for( ®istry_for_periodic, - &runtime_for_periodic, &renderer_id, &event_bus_for_periodic, None, @@ -723,10 +496,9 @@ impl ControlPoint { Ok(Self { registry, - udn_cache: Arc::new(Mutex::new(UDNRegistry::new())), + // udn_cache, event_bus, media_event_bus, - runtime, }) } @@ -735,38 +507,6 @@ impl ControlPoint { Arc::clone(&self.registry) } - /// Snapshot list of renderers currently known by the registry. - pub fn list_upnp_renderers(&self) -> Vec { - let infos = { - let reg = self.registry.read().unwrap(); - reg.list_renderers() - }; - - infos - .into_iter() - .map(|info| UpnpRenderer::from_info(&info)) - .collect() - } - - /// Return the first renderer in the registry, if any. - pub fn default_upnp_renderer(&self) -> Option> { - let info = { - let reg = self.registry.read().unwrap(); - reg.list_renderers().into_iter().next() - }?; - - Some(UpnpRenderer::from_info(&info)) - } - - /// Lookup a renderer by id. - pub fn renderer_by_id(&self, id: &ServiceId) -> Option> { - - let reg = self.registry.read().unwrap(); - reg.get_renderer(id) - - } - - /// Snapshot list of music renderers (protocol-agnostic view). pub fn list_music_renderers(&self) -> Vec> { let reg = self.registry.read().unwrap(); @@ -780,19 +520,19 @@ impl ControlPoint { } /// Lookup a music renderer by id. - pub fn music_renderer_by_id(&self, id: &ServiceId) -> Option> { + pub fn music_renderer_by_id(&self, id: &DeviceId) -> Option> { let reg = self.registry.read().unwrap(); reg.get_renderer(id) } /// Snapshot list of media servers currently known by the registry. - pub fn list_media_servers(&self) -> Vec { + pub fn list_media_servers(&self) -> Result>, ControlPointError> { let reg = self.registry.read().unwrap(); reg.list_servers() } /// Lookup a media server by id. - pub fn media_server(&self, id: &ServerId) -> Option { + pub fn media_server(&self, id: &DeviceId) -> Option> { let reg = self.registry.read().unwrap(); reg.get_server(id) } @@ -804,29 +544,21 @@ impl ControlPoint { /// attachment stays consistent with the local `QueueBackend` snapshot. /// The actual structural change then goes through the backend helpers /// (`QueueBackend::clear_queue` via `MusicRenderer::get_queue_mut()`). - pub fn clear_queue(&self, renderer_id: &ServiceId) -> anyhow::Result<()> { - if !self.runtime.has_entry(renderer_id) { - let err = Self::runtime_entry_missing(renderer_id); - warn!( - renderer = renderer_id.0.as_str(), - "Cannot clear queue: renderer not registered in runtime" - ); - return Err(err); - } - + pub fn clear_queue(&self, renderer_id: &DeviceId) -> Result<(), ControlPointError> { // User-driven mutation: detach any playlist binding self.detach_playlist_binding(renderer_id, "clear_queue"); // 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))?; + let renderer = self.music_renderer_by_id(renderer_id).ok_or_else(|| { + ControlPointError::SnapshotError(format!("Renderer {} not found", renderer_id.0)) + })?; // Get queue length before clearing let removed = renderer.get_queue().upcoming_len().unwrap_or(0); renderer.clear_queue()?; - // Sync backend state to local cache (for OpenHome, this updates the cache) + // Sync backend state to local cache renderer.sync_queue_state()?; debug!( @@ -854,26 +586,17 @@ impl ControlPoint { /// accessed via `MusicRenderer::get_queue_mut()`). pub fn enqueue_items( &self, - renderer_id: &ServiceId, + renderer_id: &DeviceId, items: Vec, - ) -> anyhow::Result<()> { - if !self.runtime.has_entry(renderer_id) { - let err = Self::runtime_entry_missing(renderer_id); - warn!( - renderer = renderer_id.0.as_str(), - "Cannot enqueue items: renderer not registered in runtime" - ); - return Err(err); - } - + ) -> Result<(), ControlPointError> { // User-driven mutation: detach any playlist binding self.detach_playlist_binding(renderer_id, "enqueue_items"); // Enqueue items using QueueBackend abstraction (works for both backends) let item_count = items.len(); - let renderer = self - .music_renderer_by_id(renderer_id) - .ok_or_else(|| anyhow!("Renderer {} not found", renderer_id.0))?; + let renderer = self.music_renderer_by_id(renderer_id).ok_or_else(|| { + ControlPointError::SnapshotError(format!("Renderer {} not found", renderer_id.0)) + })?; let new_len = { let mut queue = renderer.get_queue_mut(); @@ -897,54 +620,19 @@ impl ControlPoint { Ok(()) } - /// Read-only snapshot of the upcoming queue items for a renderer. + /// Read-only snapshot of the queue items and current index for a renderer. /// - /// This helper never mutates the runtime. It simply exposes the pending - /// items as seen by the local `QueueBackend`. For a full `(items, index)` - /// view, prefer [`get_full_queue_snapshot`]. - pub fn get_queue_snapshot( - &self, - renderer_id: &ServiceId, - ) -> anyhow::Result> { - if !self.runtime.has_entry(renderer_id) { - let err = Self::runtime_entry_missing(renderer_id); - warn!( - renderer = renderer_id.0.as_str(), - "Cannot snapshot queue: renderer not registered in runtime" - ); - return Err(err); - } - - self.runtime - .queue_snapshot(renderer_id) - .ok_or_else(|| Self::runtime_entry_missing(renderer_id)) - } - - /// Read-only helper returning both queue items and the current index. - /// - /// This is the most detailed queue view exposed publicly and is meant - /// for UI/REST layers that need an authoritative snapshot without - /// mutating the runtime. + /// Returns both the queue items and the current playing index. + /// This is the authoritative queue view for UI/REST layers. pub fn get_full_queue_snapshot( &self, - renderer_id: &ServiceId, - ) -> anyhow::Result<(Vec, Option)> { - if !self.runtime.has_entry(renderer_id) { - // Renderer not yet initialized in runtime (just discovered via SSDP) - // This is normal and will be fixed on first polling cycle - debug!( - renderer = renderer_id.0.as_str(), - "Renderer not yet initialized in runtime, returning empty queue" - ); - return Err(Self::runtime_entry_missing(renderer_id)); - } + renderer_id: &DeviceId, + ) -> Result<(Vec, Option), ControlPointError> { + let renderer = self.music_renderer_by_id(renderer_id).ok_or_else(|| { + ControlPointError::SnapshotError(format!("Renderer {} not found", renderer_id.0)) + })?; - // Get queue from renderer - let renderer = self.music_renderer_by_id(renderer_id) - .map_err(|_| Self::runtime_entry_missing(renderer_id))?; - let queue = renderer.get_queue(); - let snapshot = queue.queue_snapshot() - .map_err(|e| ControlPointError::QueueError(e.to_string()))?; + let snapshot = renderer.queue_snapshot()?; Ok((snapshot.items, snapshot.current_index)) } @@ -952,58 +640,68 @@ impl ControlPoint { /// /// Useful for UI layers that want to display the currently playing /// track even when the renderer is not returning metadata via UPnP. - pub fn get_current_track_metadata(&self, renderer_id: &ServiceId) -> Option { - self.runtime.current_track_metadata(renderer_id) + pub fn get_current_track_metadata(&self, renderer_id: &DeviceId) -> Option { + self.music_renderer_by_id(renderer_id) + .and_then(|r| r.last_metadata()) } /// 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). + /// Returns the queue snapshot if the renderer has a backend queue, + /// or None if it doesn't. pub fn get_renderer_queue_snapshot( &self, - renderer_id: &ServiceId, - ) -> anyhow::Result> { - let renderer = self.music_renderer_by_id(renderer_id) - .ok_or_else(|| anyhow!("Renderer {} not found", renderer_id.0))?; + renderer_id: &DeviceId, + ) -> Result { + let renderer = self.music_renderer_by_id(renderer_id).ok_or_else(|| { + ControlPointError::SnapshotError(format!("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: &ServiceId) -> anyhow::Result { - let snapshot = self.get_renderer_queue_snapshot(renderer_id)?; - Ok(snapshot.map(|s| s.len()).unwrap_or(0)) + /// Returns the queue length. + pub fn get_renderer_queue_length( + &self, + renderer_id: &DeviceId, + ) -> Result { + Ok(self.get_renderer_queue_snapshot(renderer_id)?.len()) } /// Build a fully consistent snapshot for UI consumers (state + queue + binding). #[cfg(feature = "pmoserver")] pub fn renderer_full_snapshot( &self, - renderer_id: &ServiceId, + renderer_id: &DeviceId, ) -> anyhow::Result { let renderer = self .music_renderer_by_id(renderer_id) .ok_or_else(|| anyhow!("Renderer {} not found", renderer_id.0))?; let info = renderer.info(); - // Get runtime snapshot (state, position, volume, metadata) - let runtime_snapshot = self.runtime.snapshot_for(renderer_id); + // Query current state directly from renderer + let current_state = renderer.playback_state().ok(); + let current_position = renderer.playback_position().ok(); + let current_volume = renderer.volume().ok(); + let current_mute = renderer.mute().ok(); + let last_metadata = renderer.last_metadata(); // Get queue from renderer (works for all backends) - let queue_snapshot = renderer.get_queue().queue_snapshot() + let queue_snapshot = renderer + .get_queue() + .queue_snapshot() .map_err(|e| anyhow!("Failed to get queue snapshot: {}", e))?; let queue_items = queue_snapshot.items; let mut queue_current_index = queue_snapshot.current_index; - let playback_source = self.runtime.playback_source(renderer_id); + let playback_source = renderer.playback_source(); let queue_len = queue_items.len(); // Try heuristics to determine current_index if not set if queue_current_index.is_none() { - if let Some(position) = runtime_snapshot.position.as_ref() { + if let Some(position) = current_position.as_ref() { if let Some(uri) = position.track_uri.as_ref() { if let Some(idx) = queue_items.iter().position(|item| item.uri == *uri) { queue_current_index = Some(idx); @@ -1019,8 +717,7 @@ impl ControlPoint { // 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 + && current_state .as_ref() .map(|state| matches!(state, PlaybackState::Playing | PlaybackState::Paused)) .unwrap_or(false) @@ -1059,39 +756,32 @@ impl ControlPoint { }, ); - let (position_ms, duration_ms) = - convert_runtime_position(runtime_snapshot.position.as_ref()); + let (position_ms, duration_ms) = convert_runtime_position(current_position.as_ref()); let queue_current_metadata = queue_current_index .and_then(|idx| queue_items.get(idx)) .map(current_track_from_playback_item); - // Prefer queue metadata, fallback to runtime metadata + // Prefer queue metadata, fallback to cached metadata 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(), - }) + 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 + friendly_name: info.friendly_name().to_string(), + transport_state: current_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, + volume: current_volume.and_then(|value| u8::try_from(value).ok()), + mute: current_mute, queue_len, attached_playlist: binding.clone(), current_track, @@ -1104,32 +794,32 @@ impl ControlPoint { }) } - /// Clears the renderer's backend queue. + /// Clears the renderer's 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: &ServiceId) -> 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() + /// Works for both internal queues and persistent backend queues (OpenHome). + pub fn clear_renderer_queue(&self, renderer_id: &DeviceId) -> Result<(), ControlPointError> { + let renderer = self.music_renderer_by_id(renderer_id).ok_or_else(|| { + ControlPointError::SnapshotError(format!("Renderer {} not found", renderer_id.0)) + })?; + renderer.clear_queue() } /// Adds a track to the renderer's backend queue. /// - /// For renderers with persistent queues (OpenHome), this adds the track to the queue. + /// For renderers with persistent queues, 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: &ServiceId, + renderer_id: &DeviceId, uri: &str, metadata: &str, after_id: Option, play: bool, ) -> anyhow::Result> { - let renderer = self.music_renderer_by_id(renderer_id) + 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()?; @@ -1138,18 +828,18 @@ impl ControlPoint { /// Selects and plays a specific track from the renderer's backend queue. /// - /// For renderers with persistent queues (OpenHome), this uses the track ID. + /// For renderers with persistent queues, this uses the track ID. /// For other renderers, this returns an error. pub fn select_renderer_track( &self, - renderer_id: &ServiceId, + renderer_id: &DeviceId, track_id: u32, - ) -> anyhow::Result<()> { - let renderer = self.music_renderer_by_id(renderer_id) - .ok_or_else(|| anyhow!("Renderer {} not found", renderer_id.0))?; + ) -> Result<(), ControlPointError> { + let renderer = self.music_renderer_by_id(renderer_id).ok_or_else(|| { + ControlPointError::ControlPoint(format!("Renderer {} not found", renderer_id.0)) + })?; renderer.select_queue_track(track_id)?; - self.runtime - .set_playback_source(renderer_id, PlaybackSource::FromQueue); + renderer.set_playback_source(PlaybackSource::FromQueue); renderer.sync_queue_state() } @@ -1159,18 +849,9 @@ impl ControlPoint { /// The method only reads queue content via the runtime helpers and /// delegates potential structural mutations to `QueueBackend` (when an item /// needs to be restored after a playback error). - pub fn play_current_from_queue(&self, renderer_id: &ServiceId) -> anyhow::Result<()> { - if !self.runtime.has_entry(renderer_id) { - let err = Self::runtime_entry_missing(renderer_id); - warn!( - renderer = renderer_id.0.as_str(), - "Cannot play current: renderer not registered in runtime" - ); - return Err(err); - } - + pub fn play_current_from_queue(&self, renderer_id: &DeviceId) -> Result<(), ControlPointError> { let renderer = self.music_renderer_by_id(renderer_id).ok_or_else(|| { - anyhow!("Renderer {} not found", renderer_id.0) + ControlPointError::SnapshotError(format!("Renderer {} not found", renderer_id.0)) })?; // Use generic queue access (works for all backends) @@ -1179,8 +860,7 @@ impl ControlPoint { renderer = renderer_id.0.as_str(), "play_current_from_queue: queue is empty or no current item" ); - self.runtime - .set_playback_source(renderer_id, PlaybackSource::None); + renderer.set_playback_source(PlaybackSource::None); return Ok(()); }; @@ -1193,159 +873,91 @@ impl ControlPoint { // Temporarily disable auto-advance to prevent race condition // when renderer sends Stopped event during SetAVTransportURI - self.runtime.set_playback_source(renderer_id, PlaybackSource::None); + renderer.set_playback_source(PlaybackSource::None); - let playback = (|| -> anyhow::Result<()> { - let didl_metadata = playback_item_to_didl(&item); - renderer.play_uri(&item.uri, &didl_metadata)?; - Ok(()) - })(); - - match playback { - Ok(()) => { - info!( - renderer = renderer_id.0.as_str(), - uri = item.uri.as_str(), - "Queue playback started (current item)" - ); - - // Sauvegarder les métadonnées dans le snapshot pour que current_track soit disponible - // même si le renderer UPnP ne retourne pas de métadonnées dans GetPositionInfo - let metadata = playback_item_track_metadata(&item); - self.runtime.update_snapshot_with(renderer_id, |snapshot| { - snapshot.last_metadata = Some(metadata); - }); - - self.runtime - .set_playback_source(renderer_id, PlaybackSource::FromQueue); - Ok(()) - } - Err(e) => { - error!( - renderer = renderer_id.0.as_str(), - error = %e, - "Failed to play current item from queue" - ); - self.runtime - .set_playback_source(renderer_id, PlaybackSource::None); - Err(e) - } - } - } - - /// Advances the queue by one item, starts playback and updates the snapshot. - /// - /// The structural mutation uses the `QueueBackend::dequeue_next` helper - /// (through `RuntimeState`) so that all pointer updates are consistent. - pub fn play_next_from_queue(&self, renderer_id: &ServiceId) -> anyhow::Result<()> { - if !self.runtime.has_entry(renderer_id) { - let err = Self::runtime_entry_missing(renderer_id); - warn!( + // Start playback + let didl_metadata = playback_item_to_didl(&item); + if let Err(err) = renderer.play_uri(&item.uri, &didl_metadata) { + error!( renderer = renderer_id.0.as_str(), - "Cannot advance queue: renderer not registered in runtime" + error = %err, + "Failed to play current item from queue" ); + renderer.set_playback_source(PlaybackSource::None); return Err(err); } + info!( + renderer = renderer_id.0.as_str(), + uri = item.uri.as_str(), + "Queue playback started (current item)" + ); + + // Save metadata in renderer for current track availability + // even if the renderer doesn't return metadata in GetPositionInfo + let metadata = playback_item_track_metadata(&item); + renderer.set_last_metadata(Some(metadata)); + + renderer.set_playback_source(PlaybackSource::FromQueue); + Ok(()) + } + + /// Advances the queue by one item, starts playback and updates the snapshot. + pub fn play_next_from_queue(&self, renderer_id: &DeviceId) -> Result<(), ControlPointError> { let renderer = self.music_renderer_by_id(renderer_id).ok_or_else(|| { - anyhow!("Renderer {} not found", renderer_id.0) + ControlPointError::SnapshotError(format!("Renderer {} not found", renderer_id.0)) })?; - // Use generic queue access (works for all backends) + // Dequeue next item let Some((item, remaining_after)) = renderer.get_queue_mut().dequeue_next()? else { debug!( renderer = renderer_id.0.as_str(), "play_next_from_queue: queue is empty" ); - self.runtime - .set_playback_source(renderer_id, PlaybackSource::None); + renderer.set_playback_source(PlaybackSource::None); return Ok(()); }; - let queue_before = remaining_after + 1; debug!( renderer = renderer_id.0.as_str(), - queue_before, queue_after = remaining_after, uri = item.uri.as_str(), "Dequeued next playback item" ); - 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) - })?; - // Temporarily disable auto-advance to prevent race condition // when renderer sends Stopped event during SetAVTransportURI - self.runtime.set_playback_source(renderer_id, PlaybackSource::None); + renderer.set_playback_source(PlaybackSource::None); - let playback = (|| -> anyhow::Result<()> { - let didl_metadata = playback_item_to_didl(&item); - renderer.play_uri(&item.uri, &didl_metadata)?; - Ok(()) - })(); - - if let Err(err) = playback { + // Start playback + let didl_metadata = playback_item_to_didl(&item); + if let Err(err) = renderer.play_uri(&item.uri, &didl_metadata) { error!( renderer = renderer_id.0.as_str(), error = %err, "Failed to start playback for queued item" ); // Try to requeue the item - if renderer.get_queue_mut() - .enqueue_items(vec![item.clone()], EnqueueMode::InsertAfterCurrent) - .is_err() - { - warn!( - renderer = renderer_id.0.as_str(), - "Failed to requeue item after playback error" - ); - } - self.runtime - .set_playback_source(renderer_id, PlaybackSource::None); + let _ = renderer + .get_queue_mut() + .enqueue_items(vec![item.clone()], EnqueueMode::InsertAfterCurrent); + renderer.set_playback_source(PlaybackSource::None); return Err(err); } - // Sauvegarder les métadonnées dans le snapshot pour que current_track soit disponible - // même si le renderer UPnP ne retourne pas de métadonnées dans GetPositionInfo + // Save metadata for current track let metadata = playback_item_track_metadata(&item); - self.runtime.update_snapshot_with(renderer_id, |snapshot| { - snapshot.last_metadata = Some(metadata); - }); + renderer.set_last_metadata(Some(metadata)); - self.runtime - .set_playback_source(renderer_id, PlaybackSource::FromQueue); + renderer.set_playback_source(PlaybackSource::FromQueue); debug!( renderer = renderer_id.0.as_str(), queue_len = remaining_after, "Started playback from queue" ); - if let Some(snapshot) = self.runtime.queue_snapshot(renderer_id) { - if let Some(next_item) = snapshot.first() { - if let Some(upnp) = renderer.as_upnp() { - let known_supported = upnp.supports_set_next(); - if known_supported || upnp.has_avtransport() { - let next_didl_metadata = playback_item_to_didl(next_item); - match upnp.set_next_uri(&next_item.uri, &next_didl_metadata) { - Ok(_) => debug!( - renderer = renderer_id.0.as_str(), - "Prefetched next track via SetNextAVTransportURI" - ), - Err(err) => debug!( - renderer = renderer_id.0.as_str(), - error = %err, - "SetNextAVTransportURI failed for next queue item; continuing without prefetch" - ), - } - } - } - } - } + // Prefetch next track if supported + self.prefetch_next_track(&renderer, renderer_id); // Emit QueueUpdated event self.emit_renderer_event(RendererEvent::QueueUpdated { @@ -1356,39 +968,67 @@ impl ControlPoint { Ok(()) } - /// Jumps to a specific index in the queue and starts playback. - /// - /// For OpenHome renderers, this uses the playlist's native SeekId capability. - /// For internal queues, this updates the current index and starts playback. - pub fn play_queue_index(&self, renderer_id: &ServiceId, index: usize) -> anyhow::Result<()> { - if !self.runtime.has_entry(renderer_id) { - let err = Self::runtime_entry_missing(renderer_id); - warn!( - renderer = renderer_id.0.as_str(), - "Cannot jump to index: renderer not registered in runtime" - ); - return Err(err); + /// Prefetches the next track in the queue if the renderer supports it. + fn prefetch_next_track(&self, renderer: &Arc, renderer_id: &DeviceId) { + // Only attempt prefetch if the renderer supports it + if !renderer.supports_set_next() { + return; } + // Get the next item from the queue using peek_current + let Ok(Some((_, remaining))) = renderer.get_queue().peek_current() else { + return; + }; + + if remaining == 0 { + return; + } + + let queue_snapshot = match renderer.get_queue().queue_snapshot() { + Ok(snapshot) => snapshot, + Err(_) => return, + }; + + // Get next item (current + 1) + let next_index = queue_snapshot.current_index.map(|i| i + 1).unwrap_or(0); + let Some(next_item) = queue_snapshot.items.get(next_index) else { + return; + }; + + let next_didl_metadata = playback_item_to_didl(next_item); + match renderer.set_next_uri(&next_item.uri, &next_didl_metadata) { + Ok(_) => debug!( + renderer = renderer_id.0.as_str(), + "Prefetched next track via SetNextAVTransportURI" + ), + Err(err) => debug!( + renderer = renderer_id.0.as_str(), + error = %err, + "SetNextAVTransportURI failed; continuing without prefetch" + ), + } + } + + /// Jumps to a specific index in the queue and starts playback. + pub fn play_queue_index( + &self, + renderer_id: &DeviceId, + index: usize, + ) -> Result<(), ControlPointError> { let renderer = self.music_renderer_by_id(renderer_id).ok_or_else(|| { - anyhow!("Renderer {} not found", renderer_id.0) + ControlPointError::SnapshotError(format!("Renderer {} not found", renderer_id.0)) })?; - // Use generic queue access (works for all backends) + // Set queue index renderer.get_queue_mut().set_index(Some(index))?; - let (item, remaining) = match renderer.get_queue().peek_current()? { - Some(result) => result, - None => { - debug!( - renderer = renderer_id.0.as_str(), - index, - "play_queue_index: no item at index" - ); - self.runtime - .set_playback_source(renderer_id, PlaybackSource::None); - return Ok(()); - } + let Some((item, remaining)) = renderer.get_queue().peek_current()? else { + debug!( + renderer = renderer_id.0.as_str(), + index, "play_queue_index: no item at index" + ); + renderer.set_playback_source(PlaybackSource::None); + return Ok(()); }; debug!( @@ -1399,108 +1039,35 @@ impl ControlPoint { "Playing item at index 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) - })?; - // Temporarily disable auto-advance to prevent race condition - // when renderer sends Stopped event during SetAVTransportURI - self.runtime.set_playback_source(renderer_id, PlaybackSource::None); + renderer.set_playback_source(PlaybackSource::None); - let playback = (|| -> anyhow::Result<()> { - let didl_metadata = playback_item_to_didl(&item); - renderer.play_uri(&item.uri, &didl_metadata)?; - Ok(()) - })(); - - match playback { - Ok(()) => { - info!( - renderer = renderer_id.0.as_str(), - index, - uri = item.uri.as_str(), - "Queue playback started at index" - ); - - let metadata = playback_item_track_metadata(&item); - self.runtime.update_snapshot_with(renderer_id, |snapshot| { - snapshot.last_metadata = Some(metadata); - }); - - self.runtime - .set_playback_source(renderer_id, PlaybackSource::FromQueue); - Ok(()) - } - Err(err) => { - error!( - renderer = renderer_id.0.as_str(), - index, - error = %err, - "Failed to start playback for queued item at index" - ); - self.runtime - .set_playback_source(renderer_id, PlaybackSource::None); - Err(err) - } + // Start playback + let didl_metadata = playback_item_to_didl(&item); + if let Err(err) = renderer.play_uri(&item.uri, &didl_metadata) { + error!( + renderer = renderer_id.0.as_str(), + index, + error = %err, + "Failed to start playback for queued item at index" + ); + renderer.set_playback_source(PlaybackSource::None); + return Err(err); } - } - fn start_queue_playback_if_idle(&self, renderer_id: &ServiceId) -> anyhow::Result<()> { - let snapshot = self.runtime.snapshot_for(renderer_id); - let renderer_playing = matches!(snapshot.state, Some(PlaybackState::Playing)); - let from_queue = self.runtime.is_playing_from_queue(renderer_id); - - debug!( + info!( renderer = renderer_id.0.as_str(), - renderer_playing, - from_queue, - state = ?snapshot.state, - "start_queue_playback_if_idle: checking if should start playback" + index, + uri = item.uri.as_str(), + "Queue playback started at index" ); - // Only skip if the renderer is actually playing - // Don't skip just because playback_source is FromQueue - the renderer might have stopped - if renderer_playing { - debug!( - renderer = renderer_id.0.as_str(), - "start_queue_playback_if_idle: skipping because renderer is already playing" - ); - return Ok(()); - } + // Save metadata + let metadata = playback_item_track_metadata(&item); + renderer.set_last_metadata(Some(metadata)); - // Check if queue has ANY items (not just upcoming items after current) - // This is important for newly attached playlists with current_index set - let has_items = self - .music_renderer_by_id(renderer_id) - .ok() - .and_then(|r| r.get_queue().queue_snapshot().ok()) - .map(|s| !s.items.is_empty()) - .unwrap_or(false); - if !has_items { - debug!( - renderer = renderer_id.0.as_str(), - "start_queue_playback_if_idle: queue is empty" - ); - return Ok(()); - } - - // Play current item if index is set, otherwise play next (advances index to 0) - let has_current_index = self - .music_renderer_by_id(renderer_id) - .ok() - .and_then(|r| r.get_queue().queue_snapshot().ok()) - .and_then(|s| s.current_index) - .is_some(); - - if has_current_index { - self.play_current_from_queue(renderer_id) - } else { - self.play_next_from_queue(renderer_id) - } + renderer.set_playback_source(PlaybackSource::FromQueue); + Ok(()) } /// Stop playback in response to user action (e.g., Stop button in UI). @@ -1508,19 +1075,15 @@ impl ControlPoint { /// This method marks the stop as user-requested to prevent automatic /// advancement to the next track in the queue when the STOPPED event /// is received from the renderer. - pub fn user_stop(&self, renderer_id: &ServiceId) -> anyhow::Result<()> { - // Mark that user requested stop before actually stopping - self.runtime.mark_user_stop_requested(renderer_id); - + pub fn user_stop(&self, renderer_id: &DeviceId) -> Result<(), ControlPointError> { // Get renderer and call stop let renderer = self.music_renderer_by_id(renderer_id).ok_or_else(|| { - warn!( - renderer = renderer_id.0.as_str(), - "Cannot stop: renderer not found in registry" - ); - anyhow!("Renderer {} not found", renderer_id.0) + ControlPointError::SnapshotError(format!("Renderer {} not found", renderer_id.0)) })?; + // Mark that user requested stop before actually stopping + renderer.mark_user_stop_requested(); + debug!(renderer = renderer_id.0.as_str(), "User-requested stop"); renderer.stop() @@ -1555,10 +1118,10 @@ impl ControlPoint { /// The queue will be automatically refreshed when the playlist changes on the server. pub fn attach_queue_to_playlist( &self, - renderer_id: &ServiceId, - server_id: ServerId, + renderer_id: &DeviceId, + server_id: DeviceId, container_id: String, - ) -> anyhow::Result<()> { + ) -> Result<(), ControlPointError> { self.attach_queue_to_playlist_with_options(renderer_id, server_id, container_id, false) } @@ -1567,22 +1130,22 @@ impl ControlPoint { /// Same queue-mutation guarantees as [`attach_queue_to_playlist`]. pub fn attach_queue_to_playlist_with_options( &self, - renderer_id: &ServiceId, - server_id: ServerId, + renderer_id: &DeviceId, + server_id: DeviceId, container_id: String, auto_play: bool, - ) -> anyhow::Result<()> { + ) -> Result<(), ControlPointError> { self.attach_queue_to_playlist_internal(renderer_id, &server_id, &container_id, auto_play) } /// Internal implementation shared by every attach wrapper. fn attach_queue_to_playlist_internal( &self, - renderer_id: &ServiceId, - server_id: &ServerId, + renderer_id: &DeviceId, + server_id: &DeviceId, container_id: &str, auto_play: bool, - ) -> anyhow::Result<()> { + ) -> Result<(), ControlPointError> { // CRITICAL: When attaching a new playlist to a renderer, we must UNCONDITIONALLY // clear the RENDERER queue first (but NOT the local queue cache, which will be // replaced by refresh_attached_queue_for() using replace_entire_playlist()). @@ -1598,7 +1161,7 @@ impl ControlPoint { // 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) + ControlPointError::ControlPoint(format!("Renderer {} not found", renderer_id.0)) })?; renderer.clear_for_playlist_attach()?; @@ -1622,9 +1185,9 @@ impl ControlPoint { auto_play_on_refresh: auto_play, }; - let renderer = self - .music_renderer_by_id(renderer_id) - .ok_or_else(|| anyhow!("Renderer {} not found", renderer_id.0))?; + let renderer = self.music_renderer_by_id(renderer_id).ok_or_else(|| { + ControlPointError::ControlPoint(format!("Renderer {} not found", renderer_id.0)) + })?; renderer.set_playlist_binding(Some(binding.clone())); info!( @@ -1641,26 +1204,21 @@ impl ControlPoint { }); // For initial attach with auto_play, force playback start (don't check if idle) - let mut auto_start_cb = |rid: &ServiceId| { + let mut auto_start_cb = |rid: &DeviceId| { debug!( renderer = rid.0.as_str(), "Attach callback: forcing playback start (not checking if idle)" ); self.play_current_from_queue(rid) }; - let callback: Option<&mut dyn FnMut(&ServiceId) -> anyhow::Result<()>> = if auto_play { - Some(&mut auto_start_cb) - } else { - None - }; + let callback: Option<&mut dyn FnMut(&DeviceId) -> Result<(), ControlPointError>> = + if auto_play { + Some(&mut auto_start_cb) + } else { + None + }; - refresh_attached_queue_for( - &self.registry, - &self.runtime, - renderer_id, - &self.event_bus, - callback, - ) + refresh_attached_queue_for(&self.registry, renderer_id, &self.event_bus, callback) } /// Detach a renderer's queue from its associated playlist container. @@ -1668,7 +1226,7 @@ impl ControlPoint { /// Public mutation API paired with `attach_queue_to_playlist*`. 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: &ServiceId) { + pub fn detach_queue_playlist(&self, renderer_id: &DeviceId) { self.detach_playlist_binding(renderer_id, "api_detach"); } @@ -1678,8 +1236,8 @@ impl ControlPoint { /// bound to a server playlist container, or `None` otherwise. pub fn current_queue_playlist_binding( &self, - renderer_id: &ServiceId, - ) -> Option<(ServerId, String, bool)> { + renderer_id: &DeviceId, + ) -> Option<(DeviceId, String, bool)> { let renderer = self.music_renderer_by_id(renderer_id)?; renderer.get_playlist_binding().map(|binding| { ( @@ -1694,7 +1252,7 @@ impl ControlPoint { /// /// Invariant: every user-driven queue mutation **must** call this method so /// that bindings never become out of sync with the local queue snapshot. - fn detach_playlist_binding(&self, renderer_id: &ServiceId, reason: &str) { + fn detach_playlist_binding(&self, renderer_id: &DeviceId, reason: &str) { let renderer = match self.music_renderer_by_id(renderer_id) { Some(r) => r, None => return, @@ -1731,16 +1289,20 @@ impl ControlPoint { fn handle_renderer_event(&self, event: &RendererEvent) { if let RendererEvent::StateChanged { id, state } = event { + let Some(renderer) = self.music_renderer_by_id(id) else { + return; + }; + match state { PlaybackState::Stopped => { // Check if user requested stop (via Stop button in UI) - if self.runtime.check_and_clear_user_stop_requested(id) { + if renderer.check_and_clear_user_stop_requested() { debug!( renderer = id.0.as_str(), "Renderer stopped by user request; not auto-advancing" ); - self.runtime.set_playback_source(id, PlaybackSource::None); - } else if self.runtime.is_playing_from_queue(id) { + renderer.set_playback_source(PlaybackSource::None); + } else if renderer.is_playing_from_queue() { debug!( renderer = id.0.as_str(), "Renderer stopped after queue-driven playback; advancing" @@ -1751,27 +1313,19 @@ impl ControlPoint { error = %err, "Auto-advance failed; clearing queue playback state" ); - self.runtime.set_playback_source(id, PlaybackSource::None); + renderer.set_playback_source(PlaybackSource::None); } } else { - self.runtime.set_playback_source(id, PlaybackSource::None); + renderer.set_playback_source(PlaybackSource::None); } } PlaybackState::Playing => { - self.runtime.mark_external_if_idle(id); + renderer.mark_external_if_idle(); } _ => {} } } } - - fn runtime_entry_missing(renderer_id: &ServiceId) -> anyhow::Error { - anyhow!( - "Renderer {} not registered in control point runtime", - renderer_id.0 - ) - } - } #[cfg(feature = "pmoserver")] @@ -1800,6 +1354,8 @@ fn parse_hms_to_ms(hms: Option<&str>) -> Option { Some((hours * 3600 + minutes * 60 + seconds) * 1000) } +/// Snapshot of renderer state used for change detection in the polling thread. +/// This is a local cache, not a source of truth. #[derive(Clone, Default)] struct RendererRuntimeSnapshot { state: Option, @@ -1809,169 +1365,6 @@ struct RendererRuntimeSnapshot { last_metadata: Option, } -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] -enum PlaybackSource { - #[default] - None, - FromQueue, - External, -} - -struct RendererRuntimeEntry { - snapshot: RendererRuntimeSnapshot, - playback_source: PlaybackSource, - user_stop_requested: bool, -} - -impl Default for RendererRuntimeEntry { - fn default() -> Self { - Self { - snapshot: RendererRuntimeSnapshot::default(), - playback_source: PlaybackSource::None, - user_stop_requested: false, - } - } -} - -struct RuntimeState { - entries: Mutex>, -} - -struct RuntimeOpenHomeQueueProvider { - runtime: Arc, -} - -// impl OpenHomeQueueProvider for RuntimeOpenHomeQueueProvider { -// fn renderer_state(&self, renderer_id: &RendererId) -> anyhow::Result { -// self.runtime.renderer_state(renderer_id) -// } - -// fn renderer_state_mut<'a>( -// &'a self, -// renderer_id: &RendererId, -// ) -> anyhow::Result> { -// self.runtime.renderer_state_mut(renderer_id) -// } - -// fn invalidate_openhome_cache(&self, renderer_id: &RendererId) -> anyhow::Result<()> { -// self.runtime.invalidate_openhome_cache(renderer_id); -// Ok(()) -// } -// } - -impl RuntimeState { - fn new() -> Self { - Self { - entries: Mutex::new(HashMap::new()), - } - } - - fn snapshot_for(&self, id: &ServiceId) -> RendererRuntimeSnapshot { - let entries = self.entries.lock().unwrap(); - entries - .get(id) - .map(|entry| entry.snapshot.clone()) - .unwrap_or_default() - } - - fn update_snapshot(&self, id: &ServiceId, snapshot: RendererRuntimeSnapshot) { - self.with_entry(id, |entry| { - entry.snapshot = snapshot; - }); - } - - fn update_snapshot_with(&self, id: &ServiceId, f: F) - where - F: FnOnce(&mut RendererRuntimeSnapshot), - { - self.with_entry(id, |entry| { - f(&mut entry.snapshot); - }); - } - - fn has_entry(&self, id: &ServiceId) -> bool { - let entries = self.entries.lock().unwrap(); - entries.contains_key(id) - } - - - // fn renderer_state(&self, id: &RendererId) -> anyhow::Result { - // let entries = self.entries.lock().unwrap(); - // let entry = entries - // .get(id) - // .ok_or_else(|| anyhow!("Renderer {} not registered in runtime", id.0))?; - // Ok(RendererRuntimeState { - // queue: entry.queue.clone(), - // }) - // } - - fn current_track_metadata(&self, id: &ServiceId) -> Option { - let entries = self.entries.lock().unwrap(); - entries - .get(id) - .and_then(|entry| entry.snapshot.last_metadata.clone()) - } - - - fn set_playback_source(&self, id: &ServiceId, source: PlaybackSource) { - let mut entries = self.entries.lock().unwrap(); - if let Some(entry) = entries.get_mut(id) { - entry.playback_source = source; - } - } - - fn playback_source(&self, id: &ServiceId) -> PlaybackSource { - let entries = self.entries.lock().unwrap(); - entries - .get(id) - .map(|entry| entry.playback_source) - .unwrap_or(PlaybackSource::None) - } - - fn is_playing_from_queue(&self, id: &ServiceId) -> bool { - matches!(self.playback_source(id), PlaybackSource::FromQueue) - } - - fn mark_external_if_idle(&self, id: &ServiceId) { - let mut entries = self.entries.lock().unwrap(); - if let Some(entry) = entries.get_mut(id) { - if matches!(entry.playback_source, PlaybackSource::None) { - entry.playback_source = PlaybackSource::External; - } - } - } - - fn with_entry(&self, id: &ServiceId, f: F) -> R - where - F: FnOnce(&mut RendererRuntimeEntry) -> R, - { - let mut entries = self.entries.lock().unwrap(); - let entry = entries - .entry(id.clone()) - .or_insert_with(RendererRuntimeEntry::default); - f(entry) - } - - - fn mark_user_stop_requested(&self, id: &ServiceId) { - let mut entries = self.entries.lock().unwrap(); - if let Some(entry) = entries.get_mut(id) { - entry.user_stop_requested = true; - } - } - - fn check_and_clear_user_stop_requested(&self, id: &ServiceId) -> bool { - let mut entries = self.entries.lock().unwrap(); - if let Some(entry) = entries.get_mut(id) { - let was_requested = entry.user_stop_requested; - entry.user_stop_requested = false; - was_requested - } else { - false - } - } -} - /// Internal helper to refresh a renderer's playback queue from its bound /// playlist container. /// @@ -1980,12 +1373,11 @@ impl RuntimeState { /// currently playing item when possible. fn refresh_attached_queue_for( registry: &Arc>, - runtime: &Arc, - renderer_id: &ServiceId, + renderer_id: &DeviceId, event_bus: &RendererEventBus, - mut after_refresh: Option<&mut dyn FnMut(&ServiceId) -> anyhow::Result<()>>, -) -> anyhow::Result<()> { - // Step 1: Get renderer and check binding + mut after_refresh: Option<&mut dyn FnMut(&DeviceId) -> Result<(), ControlPointError>>, +) -> Result<(), ControlPointError> { + // Step 1: Get renderer from registry let renderer = { let reg = registry.read().unwrap(); reg.get_renderer(renderer_id) @@ -2002,7 +1394,16 @@ fn refresh_attached_queue_for( } }; - let (server_id, container_id, auto_play) = { + // Check if there's a binding and if it needs refresh + if !renderer.has_pending_refresh() { + debug!( + renderer = renderer_id.0.as_str(), + "refresh_attached_queue_for: no pending refresh needed" + ); + return Ok(()); + } + + let (server_id, container_id) = { let binding = match renderer.get_playlist_binding() { Some(b) => b, None => { @@ -2014,36 +1415,21 @@ fn refresh_attached_queue_for( } }; - 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 - clone, modify, and set back - let mut updated_binding = binding.clone(); - updated_binding.pending_refresh = false; - let auto_play = updated_binding.auto_play_on_refresh; - updated_binding.auto_play_on_refresh = false; - renderer.set_playlist_binding(Some(updated_binding)); - - ( - binding.server_id.clone(), - binding.container_id.clone(), - auto_play, - ) + (binding.server_id.clone(), binding.container_id.clone()) }; - // Step 2: Fetch MediaServerInfo from registry - let server_info = { + // Reset the pending_refresh flag and consume auto_play + renderer.reset_pending_refresh(); + let auto_play = renderer.consume_auto_play(); + + // Step 2: Get server from registry + let music_server = { let reg = registry.read().unwrap(); reg.get_server(&server_id) }; - let server_info = match server_info { - Some(info) => info, + let music_server = match music_server { + Some(s) => s, None => { warn!( renderer = renderer_id.0.as_str(), @@ -2054,7 +1440,7 @@ fn refresh_attached_queue_for( } }; - if !server_info.online { + if !music_server.is_online() { debug!( renderer = renderer_id.0.as_str(), server = server_id.0.as_str(), @@ -2063,17 +1449,7 @@ fn refresh_attached_queue_for( 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 = UpnpMediaServer::from_info(&server_info, Duration::from_secs(5))?; + // Step 3: Browse container const MAX_BROWSE_ATTEMPTS: usize = 3; const BROWSE_RETRY_DELAY_MS: u64 = 200; @@ -2123,7 +1499,7 @@ fn refresh_attached_queue_for( // Step 4: Convert MediaEntry to PlaybackItem let new_items: Vec = entries .iter() - .filter_map(|entry| playback_item_from_entry(&music_server, entry)) + .filter_map(|entry| playback_item_from_entry(music_server.clone(), entry)) .collect(); if new_items.is_empty() { @@ -2132,12 +1508,9 @@ fn refresh_attached_queue_for( server = server_id.0.as_str(), container = container_id.as_str(), total_entries = entries.len(), - "Refreshed playlist is empty, clearing queue - all entries were filtered out" + "Refreshed playlist is empty, clearing queue" ); - { - let mut queue = renderer.queue.lock().unwrap(); - queue.clear_queue()?; - } + renderer.clear_queue()?; // Emit QueueUpdated event event_bus.broadcast(RendererEvent::QueueUpdated { @@ -2148,154 +1521,22 @@ fn refresh_attached_queue_for( return Ok(()); } - // Step 5: GENTLE SYNCHRONIZATION - // Use incremental replace_queue() instead of replace_with_attached_playlist() + // Step 5: GENTLE SYNCHRONIZATION using sync_queue() // This uses LCS algorithm to minimize playlist operations and avoid interrupting playback - // Get the full queue snapshot to access the item currently being played - let (full_queue, current_idx) = { - let reg = registry.read().unwrap(); - reg.renderer_by_id(renderer_id) - .and_then(|info| MusicRenderer::from_renderer_info(info).ok()) - .and_then(|r| { - let queue = r.get_queue(); - queue.queue_snapshot().ok().map(|s| (s.items, s.current_index)) - }) - .unwrap_or((vec![], None)) - }; + info!( + renderer = renderer_id.0.as_str(), + server = server_id.0.as_str(), + container = container_id.as_str(), + total_items = new_items.len(), + "Refreshing playlist with sync_queue" + ); - // Get the item currently being played (at current_index), not the next one in queue - let current_item = current_idx.and_then(|idx| full_queue.get(idx).cloned()); - - // Check if renderer is playing - if so, we MUST preserve the current track - // We use multiple signals to determine playback state: - // 1. Direct playback_state() query (may fail on some renderers like upmpdcli) - // 2. Presence of current_idx (if we have a current track, likely playing) - let is_playing = { - let renderer_info = { - let reg = registry.read().unwrap(); - reg.get_renderer(renderer_id) - }; - - if let Some(info) = renderer_info { - if let Some(renderer) = MusicRendererBackend::from_renderer_info(info, registry) { - // Try direct query first - if matches!(renderer.playback_state(), Ok(PlaybackState::Playing)) { - true - } else if current_idx.is_some() && !full_queue.is_empty() { - // Fallback: if we have a current index and non-empty queue, - // assume playback is happening (handles renderers where playback_state() fails) - debug!( - renderer = renderer_id.0.as_str(), - "playback_state() failed or not Playing, but current_idx is set - assuming playback" - ); - true - } else { - false - } - } else { - false - } - } else { - false - } - }; - - let item_found_at = current_item.as_ref().and_then(|current| { - let current_uid = current.unique_id(); - new_items - .iter() - .position(|new_item| new_item.unique_id() == current_uid) - .or_else(|| { - new_items - .iter() - .position(|new_item| new_item.uri == current.uri) - }) - }); + renderer.sync_queue(new_items)?; let final_queue_len = { - let music_renderer = { - let reg = registry.read().unwrap(); - reg.renderer_by_id(renderer_id) - .and_then(|info| MusicRenderer::from_renderer_info(info).ok()) - }; - - let music_renderer = match music_renderer { - Some(r) => r, - None => { - warn!( - renderer = renderer_id.0.as_str(), - "refresh_attached_queue_for: renderer not found in registry" - ); - return Ok(()); - } - }; - - let mut queue = music_renderer.get_queue_mut(); - if let Some(idx) = item_found_at { - // Current item is in new playlist - use gentle incremental update - queue.replace_queue(new_items.clone(), Some(idx))?; - info!( - renderer = renderer_id.0.as_str(), - server = server_id.0.as_str(), - container = container_id.as_str(), - total_items = new_items.len(), - current_index = idx, - upcoming = new_items.len().saturating_sub(idx + 1), - current_preserved = true, - is_playing, - "Gentle refresh: current item found in new playlist" - ); - new_items.len() - } else if let Some(ref current) = current_item { - // Current item NOT in new playlist - if is_playing { - // CRITICAL: Preserve current playing track by inserting it at the beginning - let mut combined = Vec::with_capacity(new_items.len() + 1); - combined.push(current.clone()); - combined.extend(new_items.clone()); - queue.replace_queue(combined, Some(0))?; - info!( - renderer = renderer_id.0.as_str(), - server = server_id.0.as_str(), - container = container_id.as_str(), - total_items = new_items.len() + 1, - current_index = 0, - upcoming = new_items.len(), - current_preserved = true, - current_reinserted = true, - is_playing = true, - "Gentle refresh: preserved playing track not in new playlist" - ); - new_items.len() + 1 - } else { - // Not playing, use new playlist as-is - queue.replace_queue(new_items.clone(), None)?; - info!( - renderer = renderer_id.0.as_str(), - server = server_id.0.as_str(), - container = container_id.as_str(), - total_items = new_items.len(), - current_preserved = false, - is_playing = false, - "Gentle refresh: replaced queue (not playing)" - ); - new_items.len() - } - } else { - // No current item, use new playlist as-is - queue.replace_queue(new_items.clone(), None)?; - info!( - renderer = renderer_id.0.as_str(), - server = server_id.0.as_str(), - container = container_id.as_str(), - total_items = new_items.len(), - current_preserved = false, - is_playing, - "Gentle refresh: no current item" - ); - new_items.len() - } + let snapshot = renderer.queue_snapshot()?; + snapshot.items.len() }; // Emit QueueUpdated event @@ -2344,38 +1585,6 @@ fn refresh_attached_queue_for( Ok(()) } -const OPENHOME_TRACK_PREFIX: &str = "openhome:"; - -fn playback_item_from_openhome_track( - renderer_id: &ServiceId, - 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_PREFIX, 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), - } -} - -fn openhome_track_id_from_item(item: &PlaybackItem) -> Option { - let raw = item.didl_id.strip_prefix(OPENHOME_TRACK_PREFIX)?; - raw.parse::().ok() -} - fn didl_item_from_playback_item(item: &PlaybackItem) -> DidlItem { let metadata = item.metadata.as_ref(); let title = metadata @@ -2447,851 +1656,6 @@ fn playback_item_track_metadata(item: &PlaybackItem) -> TrackMetadata { }) } -const OPENHOME_SUBSCRIPTION_TIMEOUT_SECS: u64 = 300; -const OPENHOME_RENEWAL_MARGIN_SECS: u64 = 60; - -fn spawn_openhome_event_runtime( - registry: Arc>, - runtime: Arc, - event_bus: RendererEventBus, - event_tx: Sender, -) -> io::Result<()> { - let listener = TcpListener::bind("0.0.0.0:0")?; - let listener_addr = listener - .local_addr() - .map_err(|err| io::Error::new(io::ErrorKind::Other, err))?; - - info!("OpenHome event listener bound on {}", listener_addr); - - let (notify_tx, notify_rx) = unbounded::(); - thread::Builder::new() - .name("openhome-event-http".into()) - .spawn(move || run_openhome_http_listener(listener, notify_tx))?; - - let worker = OpenHomeEventRuntime::new( - registry, - runtime, - event_bus, - notify_rx, - event_tx, - listener_addr.port(), - ); - - thread::Builder::new() - .name("openhome-event-worker".into()) - .spawn(move || worker.run()) - .map(|_| ()) -} - -struct OpenHomeEventRuntime { - registry: Arc>, - runtime: Arc, - event_bus: RendererEventBus, - notify_rx: Receiver, - event_tx: Sender, - listener_port: u16, - http_timeout: Duration, - subscriptions: HashMap, - path_index: HashMap, -} - -impl OpenHomeEventRuntime { - fn new( - registry: Arc>, - runtime: Arc, - event_bus: RendererEventBus, - notify_rx: Receiver, - event_tx: Sender, - listener_port: u16, - ) -> Self { - Self { - registry, - runtime, - event_bus, - notify_rx, - event_tx, - listener_port, - http_timeout: Duration::from_secs(5), - subscriptions: HashMap::new(), - path_index: HashMap::new(), - } - } - - fn run(mut self) { - loop { - self.drain_notifications(); - self.refresh_renderers(); - self.renew_expiring(); - thread::sleep(Duration::from_millis(250)); - } - } - - fn drain_notifications(&mut self) { - while let Ok(notify) = self.notify_rx.try_recv() { - self.handle_notification(notify); - } - } - - fn refresh_renderers(&mut self) { - let renderer_infos = { - let reg = self.registry.read().unwrap(); - reg.list_renderers() - }; - - let mut active: HashSet = HashSet::new(); - - for info in renderer_infos { - if !info.online { - continue; - } - - if let Some(url) = info.oh_playlist_event_sub_url.clone() { - let key = OpenHomeSubscriptionKey::new(&info.id, OhServiceKind::Playlist); - active.insert(key.clone()); - self.ensure_subscription(key, info.clone(), url); - } - - if let Some(url) = info.oh_info_event_sub_url.clone() { - let key = OpenHomeSubscriptionKey::new(&info.id, OhServiceKind::Info); - active.insert(key.clone()); - self.ensure_subscription(key, info.clone(), url); - } - - if let Some(url) = info.oh_time_event_sub_url.clone() { - let key = OpenHomeSubscriptionKey::new(&info.id, OhServiceKind::Time); - active.insert(key.clone()); - self.ensure_subscription(key, info.clone(), url); - } - } - - let stale: Vec = self - .subscriptions - .keys() - .filter(|key| !active.contains(*key)) - .cloned() - .collect(); - - for key in stale { - if let Some(mut entry) = self.subscriptions.remove(&key) { - self.path_index.remove(&entry.callback_path); - if let Err(err) = Self::unsubscribe_entry(self.http_timeout, &mut entry) { - warn!( - renderer = entry.renderer.friendly_name.as_str(), - service = entry.service.as_str(), - error = %err, - "Failed to unsubscribe from OpenHome events" - ); - } - } - } - } - - fn ensure_subscription( - &mut self, - key: OpenHomeSubscriptionKey, - info: RendererInfo, - event_url: String, - ) { - let entry = self.subscriptions.entry(key.clone()).or_insert_with(|| { - OpenHomeSubscriptionState::new(info.clone(), key.service, event_url.clone()) - }); - - entry.update(info, event_url); - self.path_index - .insert(entry.callback_path.clone(), key.clone()); - - if entry.sid.is_none() && entry.should_retry() { - if let Err(err) = Self::subscribe_entry(self.listener_port, self.http_timeout, entry) { - warn!( - renderer = entry.renderer.friendly_name.as_str(), - service = entry.service.as_str(), - error = %err, - "OpenHome SUBSCRIBE failed" - ); - entry.defer_retry(); - } - } - } - - fn renew_expiring(&mut self) { - let now = Instant::now(); - let mut to_renew = Vec::new(); - for (key, entry) in self.subscriptions.iter() { - if let Some(exp) = entry.expires_at { - if exp <= now + Duration::from_secs(OPENHOME_RENEWAL_MARGIN_SECS) { - to_renew.push(key.clone()); - } - } - } - - for key in to_renew { - if let Some(entry) = self.subscriptions.get_mut(&key) { - if let Err(err) = Self::renew_entry(self.http_timeout, entry) { - warn!( - renderer = entry.renderer.friendly_name.as_str(), - service = entry.service.as_str(), - error = %err, - "Failed to renew OpenHome subscription" - ); - entry.reset_subscription(); - } - } - } - } - - fn handle_notification(&mut self, notify: OpenHomeIncomingNotify) { - let Some(key) = self.path_index.get(¬ify.path).cloned() else { - debug!("Dropping OpenHome notify for unknown path {}", notify.path); - return; - }; - - let Some(entry) = self.subscriptions.get(&key) else { - return; - }; - - if let (Some(expected), Some(received)) = (&entry.sid, ¬ify.sid) { - if !expected.eq_ignore_ascii_case(received) { - debug!( - renderer = entry.renderer.friendly_name.as_str(), - service = entry.service.as_str(), - expected_sid = expected.as_str(), - received_sid = received.as_str(), - "Ignoring OpenHome notify with mismatched SID" - ); - return; - } - } - - let properties = - parse_openhome_propertyset(&entry.renderer.id, &entry.service, ¬ify.body); - if properties.is_empty() { - return; - } - - match entry.service { - OhServiceKind::Playlist => { - if properties - .iter() - .any(|(name, _)| is_id_array_property(name)) - { - // OpenHome playlist changed event - // Queue synchronization is now handled by the generic queue backend - // and doesn't need special treatment here - debug!( - renderer = entry.renderer.friendly_name.as_str(), - "Received OpenHome IdArray change event" - ); - } - } - OhServiceKind::Info => { - self.handle_info_properties(&entry.renderer.id, properties); - } - OhServiceKind::Time => { - self.handle_time_properties(&entry.renderer.id, properties); - } - OhServiceKind::Volume | OhServiceKind::Product => {} - } - } - - fn handle_info_properties(&self, renderer_id: &ServiceId, properties: Vec<(String, String)>) { - let mut metadata_xml: Option = None; - let mut transport_state: Option = None; - let mut track_id: Option = None; - let mut track_uri: Option = None; - - for (name, value) in properties { - match name.as_str() { - "Metadata" | "TrackMetadata" => { - if !value.trim().is_empty() { - metadata_xml = Some(value); - } - } - "TransportState" => { - transport_state = Some(value); - } - "Id" | "TrackId" => { - if let Ok(id) = value.trim().parse::() { - track_id = Some(id); - } - } - "Uri" | "TrackUri" => { - if !value.trim().is_empty() { - track_uri = Some(value); - } - } - _ => {} - } - } - - if let Some(xml) = metadata_xml { - if let Some(metadata) = parse_track_metadata_from_didl(&xml) { - self.runtime.update_snapshot_with(renderer_id, |snapshot| { - snapshot.last_metadata = Some(metadata.clone()); - let mut position = snapshot - .position - .clone() - .unwrap_or_else(|| empty_playback_position()); - position.track_metadata = Some(xml.clone()); - snapshot.position = Some(position); - }); - let _ = self.event_tx.send(RendererEvent::MetadataChanged { - id: renderer_id.clone(), - metadata, - }); - } - } - - if track_id.is_some() || track_uri.is_some() { - self.runtime.update_snapshot_with(renderer_id, |snapshot| { - let mut position = snapshot - .position - .clone() - .unwrap_or_else(|| empty_playback_position()); - if let Some(id) = track_id { - position.track = Some(id); - } - if let Some(uri) = track_uri.clone() { - position.track_uri = Some(uri); - } - snapshot.position = Some(position); - }); - } - - if let Some(state_str) = transport_state { - let playback_state = map_openhome_state(&state_str); - let _ = self.event_tx.send(RendererEvent::StateChanged { - id: renderer_id.clone(), - state: playback_state, - }); - } - } - - fn handle_time_properties(&self, renderer_id: &ServiceId, properties: Vec<(String, String)>) { - let mut duration: Option = None; - let mut seconds: Option = None; - - for (name, value) in properties { - match name.as_str() { - "Duration" => { - duration = value.trim().parse::().ok(); - } - "Seconds" => { - seconds = value.trim().parse::().ok(); - } - _ => {} - } - } - - if duration.is_none() && seconds.is_none() { - return; - } - - let position = self.runtime.snapshot_for(renderer_id).position; - let mut new_position = position.unwrap_or_else(|| empty_playback_position()); - if let Some(d) = duration { - new_position.track_duration = Some(format_hhmmss_u32(d)); - } - if let Some(s) = seconds { - new_position.rel_time = Some(format_hhmmss_u32(s)); - } - - self.runtime.update_snapshot_with(renderer_id, |snapshot| { - snapshot.position = Some(new_position.clone()); - }); - - let _ = self.event_tx.send(RendererEvent::PositionChanged { - id: renderer_id.clone(), - position: new_position, - }); - } - - fn subscribe_entry( - listener_port: u16, - http_timeout: Duration, - entry: &mut OpenHomeSubscriptionState, - ) -> anyhow::Result<()> { - let event_url = entry.event_sub_url.clone(); - let (remote_host, remote_port) = - parse_host_port(&event_url).context("Cannot extract host for SUBSCRIBE")?; - let local_ip = determine_local_ip(&remote_host, remote_port) - .context("Cannot determine local IP for callback")?; - - let callback_url = format!( - "http://{}:{}{}", - format_ip(&local_ip), - listener_port, - entry.callback_path - ); - - debug!( - renderer = entry.renderer.friendly_name.as_str(), - service = entry.service.as_str(), - callback = callback_url.as_str(), - "Subscribing to OpenHome events" - ); - - let host_header = format!("{}:{}", remote_host, remote_port); - let timeout_header = format!("Second-{}", OPENHOME_SUBSCRIPTION_TIMEOUT_SECS); - let callback_header = format!("<{}>", callback_url); - - let request = http::Request::builder() - .method("SUBSCRIBE") - .uri(&event_url) - .header("HOST", host_header) - .header("CALLBACK", callback_header) - .header("NT", "upnp:event") - .header("TIMEOUT", timeout_header) - .body(()) - .map_err(anyhow::Error::new)?; - - let response = build_agent(http_timeout).run(request)?; - if !response.status().is_success() { - anyhow::bail!("SUBSCRIBE returned HTTP {}", response.status()); - } - - let sid = response - .headers() - .get("SID") - .and_then(|value| value.to_str().ok()) - .map(|s| s.to_string()) - .ok_or_else(|| anyhow::anyhow!("SUBSCRIBE response missing SID"))?; - let timeout = parse_timeout( - response - .headers() - .get("TIMEOUT") - .and_then(|value| value.to_str().ok()), - ) - .unwrap_or(Duration::from_secs(OPENHOME_SUBSCRIPTION_TIMEOUT_SECS)); - - entry.sid = Some(sid); - entry.expires_at = Some(Instant::now() + timeout); - entry.retry_after = Instant::now() + Duration::from_secs(5); - - info!( - renderer = entry.renderer.friendly_name.as_str(), - service = entry.service.as_str(), - "Subscribed to OpenHome events (timeout {}s)", - timeout.as_secs() - ); - - Ok(()) - } - - fn renew_entry( - http_timeout: Duration, - entry: &mut OpenHomeSubscriptionState, - ) -> anyhow::Result<()> { - let sid = entry.sid.clone().context("Cannot renew without SID")?; - let request = http::Request::builder() - .method("SUBSCRIBE") - .uri(&entry.event_sub_url) - .header("SID", sid) - .header( - "TIMEOUT", - format!("Second-{}", OPENHOME_SUBSCRIPTION_TIMEOUT_SECS), - ) - .body(()) - .map_err(anyhow::Error::new)?; - let response = build_agent(http_timeout).run(request)?; - if !response.status().is_success() { - anyhow::bail!("SUBSCRIBE renewal failed with {}", response.status()); - } - let timeout = parse_timeout( - response - .headers() - .get("TIMEOUT") - .and_then(|value| value.to_str().ok()), - ) - .unwrap_or(Duration::from_secs(OPENHOME_SUBSCRIPTION_TIMEOUT_SECS)); - entry.expires_at = Some(Instant::now() + timeout); - info!( - renderer = entry.renderer.friendly_name.as_str(), - service = entry.service.as_str(), - "Renewed OpenHome subscription (timeout {}s)", - timeout.as_secs() - ); - Ok(()) - } - - fn unsubscribe_entry( - http_timeout: Duration, - entry: &mut OpenHomeSubscriptionState, - ) -> anyhow::Result<()> { - let sid = match entry.sid.take() { - Some(sid) => sid, - None => return Ok(()), - }; - - let request = http::Request::builder() - .method("UNSUBSCRIBE") - .uri(&entry.event_sub_url) - .header("SID", sid) - .body(()) - .map_err(anyhow::Error::new)?; - let response = build_agent(http_timeout).run(request)?; - if !response.status().is_success() { - warn!( - renderer = entry.renderer.friendly_name.as_str(), - service = entry.service.as_str(), - status = response.status().as_u16(), - "UNSUBSCRIBE returned non-success status" - ); - } - Ok(()) - } -} - -#[derive(Clone, Debug, PartialEq, Eq, Hash)] -struct OpenHomeSubscriptionKey { - renderer_id: ServiceId, - service: OhServiceKind, -} - -impl OpenHomeSubscriptionKey { - fn new(renderer_id: &ServiceId, service: OhServiceKind) -> Self { - Self { - renderer_id: renderer_id.clone(), - service, - } - } -} - -struct OpenHomeSubscriptionState { - renderer: RendererInfo, - service: OhServiceKind, - event_sub_url: String, - callback_path: String, - sid: Option, - expires_at: Option, - retry_after: Instant, -} - -impl OpenHomeSubscriptionState { - fn new(renderer: RendererInfo, service: OhServiceKind, event_sub_url: String) -> Self { - Self { - callback_path: build_openhome_callback_path(&renderer.id, service), - renderer, - service, - event_sub_url, - sid: None, - expires_at: None, - retry_after: Instant::now(), - } - } - - fn update(&mut self, renderer: RendererInfo, event_url: String) { - if self.renderer.location != renderer.location || self.event_sub_url != event_url { - self.event_sub_url = event_url; - self.sid = None; - self.expires_at = None; - self.retry_after = Instant::now(); - } - self.renderer = renderer; - } - - fn should_retry(&self) -> bool { - Instant::now() >= self.retry_after - } - - fn defer_retry(&mut self) { - self.retry_after = Instant::now() + Duration::from_secs(15); - } - - fn reset_subscription(&mut self) { - self.sid = None; - self.expires_at = None; - self.retry_after = Instant::now() + Duration::from_secs(5); - } -} - -struct OpenHomeIncomingNotify { - path: String, - sid: Option, - body: Vec, -} - -fn run_openhome_http_listener(listener: TcpListener, notify_tx: Sender) { - for stream in listener.incoming() { - match stream { - Ok(mut stream) => { - if let Err(err) = stream.set_read_timeout(Some(Duration::from_secs(5))) { - warn!( - "Failed to set read timeout on OpenHome notify connection: {}", - err - ); - } - - match read_openhome_http_request(&mut stream) { - Ok(request) => { - if request.method != "NOTIFY" { - let _ = write_openhome_http_response( - &mut stream, - 405, - "Method Not Allowed", - ); - continue; - } - - let notify = OpenHomeIncomingNotify { - path: request.path, - sid: request.headers.get("sid").cloned(), - body: request.body, - }; - - if notify_tx.send(notify).is_err() { - warn!("Dropping OpenHome notify because worker channel is closed"); - } - let _ = write_openhome_http_response(&mut stream, 200, "OK"); - } - Err(err) => { - warn!("Failed to parse OpenHome notify request: {}", err); - let _ = write_openhome_http_response(&mut stream, 400, "Bad Request"); - } - } - } - Err(err) => { - warn!("Incoming OpenHome notify connection failed: {}", err); - } - } - } -} - -struct OpenHomeHttpRequest { - method: String, - path: String, - headers: HashMap, - body: Vec, -} - -fn read_openhome_http_request(stream: &mut TcpStream) -> io::Result { - let mut reader = BufReader::new(stream.try_clone()?); - let mut request_line = String::new(); - if reader.read_line(&mut request_line)? == 0 { - return Err(io::Error::new( - io::ErrorKind::UnexpectedEof, - "missing request line", - )); - } - - let request_line = request_line.trim_end_matches(&['\r', '\n'][..]); - let mut parts = request_line.split_whitespace(); - let method = parts - .next() - .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "missing method"))? - .to_ascii_uppercase(); - let path = parts - .next() - .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "missing path"))? - .to_string(); - - let mut headers = HashMap::new(); - loop { - let mut line = String::new(); - let len = reader.read_line(&mut line)?; - if len == 0 { - break; - } - let trimmed = line.trim_end_matches(&['\r', '\n'][..]); - if trimmed.is_empty() { - break; - } - if let Some((name, value)) = trimmed.split_once(':') { - headers.insert(name.trim().to_ascii_lowercase(), value.trim().to_string()); - } - } - - let content_length: usize = headers - .get("content-length") - .and_then(|v| v.parse().ok()) - .unwrap_or(0); - - let mut body = vec![0u8; content_length]; - reader.read_exact(&mut body)?; - - Ok(OpenHomeHttpRequest { - method, - path, - headers, - body, - }) -} - -fn write_openhome_http_response( - stream: &mut TcpStream, - status: u16, - message: &str, -) -> io::Result<()> { - let response = format!( - "HTTP/1.1 {} {}\r\nContent-Length: 0\r\nConnection: close\r\n\r\n", - status, message - ); - stream.write_all(response.as_bytes()) -} - -fn build_openhome_callback_path(id: &ServiceId, service: OhServiceKind) -> String { - let mut sanitized = String::new(); - for ch in id.0.chars() { - if ch.is_ascii_alphanumeric() { - sanitized.push(ch); - } else { - sanitized.push('_'); - } - } - let mut hasher = DefaultHasher::new(); - id.hash(&mut hasher); - service.hash(&mut hasher); - let suffix = hasher.finish(); - format!("/openhome-events/{}/{:x}", service.as_str(), suffix) -} - -fn parse_openhome_propertyset( - renderer_id: &ServiceId, - service: &OhServiceKind, - body: &[u8], -) -> Vec<(String, String)> { - let mut properties = Vec::new(); - let reader = std::io::Cursor::new(body); - let Ok(root) = Element::parse(reader) else { - warn!( - renderer = renderer_id.0.as_str(), - service = service.as_str(), - "Failed to parse OpenHome notify payload" - ); - return properties; - }; - - for property in root.children.iter().filter_map(|node| match node { - XMLNode::Element(elem) => Some(elem), - _ => None, - }) { - for child in property.children.iter().filter_map(|node| match node { - XMLNode::Element(elem) => Some(elem), - _ => None, - }) { - if let Some(text) = child.get_text() { - properties.push((child.name.clone(), text.into_owned())); - } - } - } - - properties -} - -fn is_id_array_property(name: &str) -> bool { - name.trim().to_ascii_lowercase().ends_with("idarray") -} - -fn empty_playback_position() -> PlaybackPositionInfo { - PlaybackPositionInfo { - track: None, - rel_time: None, - abs_time: None, - track_duration: None, - track_metadata: None, - track_uri: None, - } -} - -fn parse_timeout(raw: Option<&str>) -> Option { - let value = raw?; - let lower = value.trim().to_ascii_lowercase(); - if lower == "second-infinite" { - return Some(Duration::from_secs(OPENHOME_SUBSCRIPTION_TIMEOUT_SECS)); - } - if let Some(idx) = lower.find("second-") { - let number = &lower[idx + 7..]; - if let Ok(seconds) = number.parse::() { - return Some(Duration::from_secs(seconds)); - } - } - None -} - -fn parse_host_port(url: &str) -> Option<(String, u16)> { - let default_port = if url.to_ascii_lowercase().starts_with("https://") { - 443 - } else { - 80 - }; - let (_, rest) = url.split_once("://")?; - let mut parts = rest.splitn(2, '/'); - let authority = parts.next()?.trim(); - if authority.starts_with('[') { - let end = authority.find(']')?; - let host = &authority[1..end]; - let remainder = authority.get(end + 1..).unwrap_or(""); - let port = if let Some(stripped) = remainder.strip_prefix(':') { - stripped.parse().unwrap_or(default_port) - } else { - default_port - }; - Some((host.to_string(), port)) - } else if let Some((host, port)) = authority.split_once(':') { - Some((host.to_string(), port.parse().ok()?)) - } else { - Some((authority.to_string(), default_port)) - } -} - -fn determine_local_ip(remote_host: &str, remote_port: u16) -> io::Result { - let is_ipv6 = remote_host.contains(':') && !remote_host.contains('.'); - let target = if is_ipv6 { - format!( - "[{}]:{}", - remote_host.trim_matches(|c| c == '[' || c == ']'), - remote_port - ) - } else { - format!("{}:{}", remote_host, remote_port) - }; - let bind_addr = if is_ipv6 { "[::]:0" } else { "0.0.0.0:0" }; - let socket = UdpSocket::bind(bind_addr)?; - socket.connect(&target)?; - Ok(socket.local_addr()?.ip()) -} - -fn format_ip(ip: &IpAddr) -> String { - match ip { - IpAddr::V4(v4) => v4.to_string(), - IpAddr::V6(v6) => format!("[{}]", v6), - } -} - -fn build_agent(timeout: Duration) -> Agent { - Agent::config_builder() - .timeout_global(Some(timeout)) - .http_status_as_error(false) - .allow_non_standard_methods(true) - .build() - .into() -} - -/// Parse "HH:MM:SS" style time strings to seconds. -/// -/// Returns None for empty or sentinel values such as "NOT_IMPLEMENTED" or "-:--:--". -fn parse_hms_to_secs(s: &str) -> Option { - let s = s.trim(); - if s.is_empty() { - return None; - } - - // Common sentinel values for "no information" in UPnP implementations. - if s == "NOT_IMPLEMENTED" || s == "-:--:--" { - return None; - } - - let parts: Vec<_> = s.split(':').collect(); - if parts.len() != 3 { - return None; - } - - let hours: u64 = parts[0].parse().ok()?; - let minutes: u64 = parts[1].parse().ok()?; - let seconds: u64 = parts[2].parse().ok()?; - - Some(hours * 3600 + minutes * 60 + seconds) -} - fn parse_optional_hms_to_secs(value: &Option) -> Option { value.as_ref().and_then(|s| parse_hms_to_secs(s)) } @@ -3308,12 +1672,10 @@ fn compute_logical_playback_state( prev_position: Option<&PlaybackPositionInfo>, current_position: Option<&PlaybackPositionInfo>, ) -> PlaybackState { - use PlaybackState::*; - // Rule 1: Arylic / LinkPlay sometimes report STOPPED while the stream is // actually playing. If we detect that the relative time advances between // two polls, we treat this as Playing. - if let Stopped = raw { + if let PlaybackState::Stopped = raw { if let (Some(prev), Some(curr)) = (prev_position, current_position) { if let (Some(prev_rel), Some(curr_rel)) = ( parse_optional_hms_to_secs(&prev.rel_time), @@ -3323,7 +1685,7 @@ fn compute_logical_playback_state( let delta = curr_rel - prev_rel; // Our poll loop runs every 1s; accept small jitter in the delta. if delta <= 5 { - return Playing; + return PlaybackState::Playing; } } } @@ -3333,13 +1695,13 @@ fn compute_logical_playback_state( // Rule 2: Some devices report NO_MEDIA_PRESENT while exposing a non-zero // track duration. In practice this behaves like a stopped transport with // a loaded track. - if let NoMedia = raw { + if let PlaybackState::NoMedia = raw { let duration_secs = current_position .and_then(|p| parse_optional_hms_to_secs(&p.track_duration)) .or_else(|| prev_position.and_then(|p| parse_optional_hms_to_secs(&p.track_duration))); if matches!(duration_secs, Some(d) if d > 0) { - return Stopped; + return PlaybackState::Stopped; } } @@ -3420,3 +1782,29 @@ fn extract_track_metadata(position: &PlaybackPositionInfo) -> Option Option { + let s = s.trim(); + if s.is_empty() { + return None; + } + + // Common sentinel values for "no information" in UPnP implementations. + if s == "NOT_IMPLEMENTED" || s == "-:--:--" { + return None; + } + + let parts: Vec<_> = s.split(':').collect(); + if parts.len() != 3 { + return None; + } + + let hours: u64 = parts[0].parse().ok()?; + let minutes: u64 = parts[1].parse().ok()?; + let seconds: u64 = parts[2].parse().ok()?; + + Some(hours * 3600 + minutes * 60 + seconds) +} diff --git a/pmocontrol/src/control_point_corrections.md b/pmocontrol/src/control_point_corrections.md new file mode 100644 index 00000000..6df5801d --- /dev/null +++ b/pmocontrol/src/control_point_corrections.md @@ -0,0 +1,130 @@ +# Corrections à appliquer à control_point.rs + +## Changements effectués avec succès : + +1. ✅ Thread de découverte UPNP : utilise maintenant `UpnpDiscoveryManager` +2. ✅ Boucle de polling : utilise `Arc` directement avec traits +3. ✅ Suppression du thread OpenHome event forwarder + +## Corrections restantes à faire manuellement : + +### 1. Thread de vérification des timeouts (ligne ~173) + +**Remplacer :** +```rust +let registry_for_presence = Arc::clone(®istry); +let event_bus_for_presence = event_bus.clone(); +let media_event_bus_for_presence = media_event_bus.clone(); +thread::spawn(move || { + use ureq::Agent; + // ... tout le code de vérification HTTP manuelle ... +}); +``` + +**Par :** +```rust +let registry_for_timeout = Arc::clone(®istry); + +thread::spawn(move || { + loop { + thread::sleep(Duration::from_secs(60)); + + // Le registry vérifie les timeouts et émet automatiquement les événements Offline + if let Ok(mut reg) = registry_for_timeout.write() { + reg.check_timeouts(); + } + } +}); +``` + +### 2. Thread Chromecast mDNS (à ajouter après le thread de timeout) + +**Ajouter :** +```rust +// Thread de découverte mDNS pour Chromecast +let registry_for_mdns = Arc::clone(®istry); +let udn_cache_for_mdns = Arc::clone(&udn_cache); +thread::spawn(move || { + use crate::discovery::ChromecastDiscoveryManager; + use futures_util::StreamExt; + + let mut discovery_manager = + ChromecastDiscoveryManager::new(registry_for_mdns, udn_cache_for_mdns); + + debug!("Starting mDNS discovery thread for Chromecast devices"); + + const SERVICE_NAME: &str = "_googlecast._tcp.local"; + + async_std::task::block_on(async { + match mdns::discover::all(SERVICE_NAME, Duration::from_secs(15)) { + Ok(discovery) => { + let stream = discovery.listen(); + futures_util::pin_mut!(stream); + + debug!("mDNS discovery stream started for Chromecast devices"); + + while let Some(result) = stream.next().await { + match result { + Ok(response) => { + debug!( + "Received mDNS response with {} records", + response.records().count() + ); + discovery_manager.handle_mdns_response(response); + } + Err(e) => { + warn!("mDNS discovery error: {}", e); + } + } + } + + warn!("mDNS discovery stream ended unexpectedly"); + } + Err(e) => { + error!("Failed to start mDNS discovery: {}", e); + } + } + }); +}); +``` + +### 3. Correction de la boucle de polling (ligne ~200) + +**Le type doit être :** +```rust +let renderers: Vec> = infos // Pas Vec +``` + +### 4. Retour du constructeur (fin de fonction) + +**Remplacer :** +```rust +Ok(Self { + registry, + event_bus, + media_event_bus, + runtime, +}) +``` + +**Par :** +```rust +Ok(Self { + registry, + udn_cache, + event_bus, + media_event_bus, + runtime, +}) +``` + +## Suppressions à faire : + +- [ ] Supprimer toutes les fonctions OpenHome (à partir de `spawn_openhome_event_runtime`) +- [ ] Supprimer `OpenHomeAccessError` enum +- [ ] Supprimer `OPENHOME_SNAPSHOT_CACHE_TTL` constante +- [ ] Nettoyer les imports obsolètes en haut du fichier + +## Note importante : + +Le code ne compilera pas tant que tous les imports obsolètes ne seront pas nettoyés, mais la structure sera correcte. diff --git a/pmocontrol/src/discovery/chromecast_discovery.rs b/pmocontrol/src/discovery/chromecast_discovery.rs index 2cbac7ba..7088163b 100644 --- a/pmocontrol/src/discovery/chromecast_discovery.rs +++ b/pmocontrol/src/discovery/chromecast_discovery.rs @@ -7,7 +7,7 @@ use std::collections::HashMap; use std::net::IpAddr; -use std::sync::{Arc, Mutex}; +use std::sync::{Arc, Mutex, RwLock}; use crate::DeviceId; use crate::DeviceRegistry; @@ -17,13 +17,13 @@ use tracing::{debug, warn}; /// Gestionnaire des événements mDNS pour Chromecast. pub struct ChromecastDiscoveryManager { - device_registry: Arc>, + device_registry: Arc>, udn_cache: Arc>, } impl ChromecastDiscoveryManager { pub fn new( - device_registry: Arc>, + device_registry: Arc>, udn_cache: Arc>, ) -> Self { Self { @@ -173,7 +173,7 @@ impl ChromecastDiscoveryManager { // Register the renderer self.device_registry - .lock() + .write() .expect("DeviceRegistry mutex lock failed") .push_renderer(&renderer_info, default_max_age as u32); } diff --git a/pmocontrol/src/discovery/manager.rs b/pmocontrol/src/discovery/manager.rs index 4ad726c2..3d3aad95 100644 --- a/pmocontrol/src/discovery/manager.rs +++ b/pmocontrol/src/discovery/manager.rs @@ -1,7 +1,7 @@ use std::{ collections::HashMap, sync::{Arc, Mutex}, - time::{Instant, SystemTime}, + time::Instant, }; struct UDNSeen { @@ -20,18 +20,21 @@ impl UDNRegistry { } } - /// Returns `true` if the UDN has been seen for at least half of its lifetime + /// Returns `true` if we should fetch/process this UDN (either first time or more than half max_age elapsed) pub fn should_fetch(registry: Arc>, udn: &str, max_age: u64) -> bool { let now = Instant::now(); let mut r = registry.lock().expect("UDNRegistry mutex lock failed"); if let Some(seen) = r.seen.get_mut(udn) { + // If more than half the max_age has elapsed, we should fetch/process again if now.duration_since(seen.last_seen).as_secs() > max_age / 2 { - false - } else { seen.last_seen = now; true + } else { + // Too soon, skip this SSDP event + false } } else { + // First time seeing this UDN, insert and fetch r.seen.insert( udn.to_string(), UDNSeen { diff --git a/pmocontrol/src/discovery/mod.rs b/pmocontrol/src/discovery/mod.rs index 8f95c14b..d5d64e7a 100644 --- a/pmocontrol/src/discovery/mod.rs +++ b/pmocontrol/src/discovery/mod.rs @@ -1,30 +1,8 @@ -use crate::{RendererInfo, UpnpMediaServer}; - +pub mod arylic; +pub mod chromecast_discovery; pub mod manager; pub mod upnp_discovery; pub mod upnp_provider; -pub mod chromecast_discovery; -pub mod arylic; -/// Fournit les descriptions haut niveau à partir d’un endpoint découvert. -/// L’implémentation pourra, plus tard, faire un HTTP GET sur `location` -/// et parser la description pour remplir RendererInfo / MediaServerInfo. -pub trait DeviceDescriptionProvider: Send + Sync { - /// Construit un RendererInfo pour cet endpoint, ou None s’il - /// ne correspond pas à un renderer audio intéressant. - fn build_renderer_info( - &self, - udn: &str, - location: &str, - server_header: &str, - ) -> Option; - - /// Construit un MediaServerInfo pour cet endpoint, ou None s’il - /// ne correspond pas à un media server (ou pas intéressant). - fn build_server_info( - &self, - udn: &str, - location: &str, - server_header: &str, - ) -> Option; -} \ No newline at end of file +pub use chromecast_discovery::ChromecastDiscoveryManager; +pub use upnp_discovery::UpnpDiscoveryManager; diff --git a/pmocontrol/src/discovery/upnp_discovery.rs b/pmocontrol/src/discovery/upnp_discovery.rs index d6aa9d9a..8cfbc46e 100644 --- a/pmocontrol/src/discovery/upnp_discovery.rs +++ b/pmocontrol/src/discovery/upnp_discovery.rs @@ -1,19 +1,27 @@ use crate::{DeviceRegistry, discovery::upnp_provider::ParsedDeviceDescription}; use pmoupnp::ssdp::SsdpEvent; -use std::sync::{Arc, Mutex}; +use std::sync::{Arc, Mutex, RwLock}; use crate::discovery::manager::UDNRegistry; /// Gestionnaire des événements SSDP -> DeviceUpdate. pub struct UpnpDiscoveryManager { - device_registry: Arc>, + device_registry: Arc>, udn_cache: Arc>, } impl UpnpDiscoveryManager { - // Dans handle_ssdp_event (upnp_discovery.rs) - fn handle_ssdp_event(&mut self, event: SsdpEvent) { + pub fn new(device_registry: Arc>, + udn_cache: Arc>, + ) -> Self { + Self { + device_registry, + udn_cache, + } + } + + pub fn handle_ssdp_event(&mut self, event: SsdpEvent) { let (alive, usn, location, max_age, server_header) = match event { SsdpEvent::Alive { usn, @@ -39,24 +47,20 @@ impl UpnpDiscoveryManager { // ✅ Fetch + parse if let Ok(info) = ParsedDeviceDescription::new(&udn, &location, &server_header,5) { if let Some(renderer_info) = info.build_renderer() { - self.device_registry - .lock() - .expect("UDNRegistry mutex lock failed") - .push_renderer(&renderer_info,max_age); - } else { - if let Some(server_info) = info.build_server() { - self.device_registry - .lock() - .expect("UDNRegistry mutex lock failed") - .push_server(&server_info,max_age); + if let Ok(mut reg) = self.device_registry.write() { + reg.push_renderer(&renderer_info, max_age); + } + } else if let Some(server_info) = info.build_server() { + if let Ok(mut reg) = self.device_registry.write() { + reg.push_server(&server_info, max_age); + } } } - }} + } } else { - self.device_registry - .lock() - .expect("UDNRegistry mutex lock failed") - .device_says_byebye(&udn); + if let Ok(mut reg) = self.device_registry.write() { + reg.device_says_byebye(&udn); + } } } } diff --git a/pmocontrol/src/errors.rs b/pmocontrol/src/errors.rs index 6492e51c..29cc36a4 100644 --- a/pmocontrol/src/errors.rs +++ b/pmocontrol/src/errors.rs @@ -24,11 +24,11 @@ pub enum ControlPointError { #[error("Missing {0} element in SOAP body")] UpnpMissingReturnValue(String), #[error("Invalid {0} value: {1}")] - UpnpBadReturnValue(String,String), + UpnpBadReturnValue(String, String), #[error("Soap Error: Upnp action call {0}")] SoapAction(String), #[error("{0} returned UPnP error {1}: {2} (HTTP status {3})")] - SoapUpnpParseError(String,u32, String, u32), + SoapUpnpParseError(String, u32, String, u32), #[error("{0} failed with HTTP status {1} and body: {2}")] SoapActionWrongBody(String, u32, String), #[error("Soap Error: No envelop for action {0}")] @@ -45,23 +45,26 @@ pub enum ControlPointError { QueueError(String), #[error("Invalid time format: {0}")] InvalidTimeFormat(String), + #[error("Error on snapshot: {0}")] + SnapshotError(String), + #[error("Error on ControlPoint: {0}")] + ControlPoint(String), } impl ControlPointError { pub fn upnp_operation_not_supported(operation: &str, service: &str) -> Self { - ControlPointError::UpnpOperationNotSupported(operation.to_string(),service.to_string()) + ControlPointError::UpnpOperationNotSupported(operation.to_string(), service.to_string()) } pub fn upnp_missing_return_value(value: &str) -> Self { ControlPointError::UpnpMissingReturnValue(value.to_string()) } - pub fn upnp_bad_return_value(name : &str, value : &str ) -> Self { - ControlPointError::UpnpBadReturnValue(name.to_string(),value.to_string()) - + pub fn upnp_bad_return_value(name: &str, value: &str) -> Self { + ControlPointError::UpnpBadReturnValue(name.to_string(), value.to_string()) } - pub fn arilyc_tcp_error(message : &str ) -> Self { + pub fn arilyc_tcp_error(message: &str) -> Self { ControlPointError::ArilycTcpError(message.to_string()) } -} \ No newline at end of file +} diff --git a/pmocontrol/src/identity.rs b/pmocontrol/src/identity.rs index 64c47d8b..00bc23d2 100644 --- a/pmocontrol/src/identity.rs +++ b/pmocontrol/src/identity.rs @@ -1,14 +1,29 @@ use crate::DeviceId; +use crate::model::DeviceBasicInfo; pub trait DeviceIdentity { - fn id(&self) -> DeviceId ; + fn id(&self) -> DeviceId; fn udn(&self) -> &str; - fn friendly_name(&self) -> &str ; - fn model_name(&self) -> &str ; - fn manufacturer(&self) -> &str ; - fn location(&self) -> &str ; + fn friendly_name(&self) -> &str; + fn model_name(&self) -> &str; + fn manufacturer(&self) -> &str; + fn location(&self) -> &str; fn server_header(&self) -> &str; - fn is_a_media_server(&self) -> bool { false } - fn is_a_music_renderer(&self) -> bool { false } -} \ No newline at end of file + fn is_a_media_server(&self) -> bool { + false + } + fn is_a_music_renderer(&self) -> bool { + false + } + + /// Returns basic device information suitable for event notifications + fn basic_info(&self) -> DeviceBasicInfo { + DeviceBasicInfo { + id: self.id(), + friendly_name: self.friendly_name().to_string(), + model_name: self.model_name().to_string(), + manufacturer: self.manufacturer().to_string(), + } + } +} diff --git a/pmocontrol/src/lib.rs b/pmocontrol/src/lib.rs index 3a9ab60d..df323eba 100644 --- a/pmocontrol/src/lib.rs +++ b/pmocontrol/src/lib.rs @@ -1,23 +1,21 @@ mod events; mod media_server_events; -pub mod queue; -pub mod discovery; -pub mod upnp_clients; pub mod arylic_client; -pub mod linkplay_client; pub mod control_point; +pub mod discovery; pub mod errors; +pub mod identity; +pub mod linkplay_client; +pub mod linkplay_utils; pub mod media_server; pub mod model; pub mod music_renderer; +pub mod online; +pub mod queue; pub mod registry; pub mod soap_client; -pub mod online; -pub mod identity; -pub mod linkplay_utils; - - +pub mod upnp_clients; // pmoserver extension (optional) #[cfg(feature = "pmoserver")] @@ -32,20 +30,18 @@ use std::time::Duration; #[cfg(feature = "pmoserver")] pub use pmoserver_ext::ControlPointExt; - -pub use control_point::{ControlPoint, PlaylistBinding}; -pub use media_server::{ - MediaBrowser, MediaEntry, MediaResource, UpnpMediaServer, -}; +pub use control_point::ControlPoint; +pub use media_server::{MediaBrowser, MediaEntry, MediaResource, UpnpMediaServer}; pub use queue::{EnqueueMode, PlaybackItem, QueueSnapshot}; pub use model::{ - MediaServerEvent, RendererCapabilities, RendererEvent, RendererInfo, RendererProtocol, + MediaServerEvent, PlaybackSource, RendererCapabilities, RendererEvent, RendererInfo, + RendererProtocol, }; pub use registry::{DeviceRegistry, DeviceUpdate}; -pub use soap_client::invoke_upnp_action; pub use online::DeviceOnline; +pub use soap_client::invoke_upnp_action; pub use identity::DeviceIdentity; @@ -53,4 +49,3 @@ pub use identity::DeviceIdentity; pub struct DeviceId(pub String); const DEFAULT_HTTP_TIMEOUT: Duration = Duration::from_secs(30); - diff --git a/pmocontrol/src/linkplay_client/mod.rs b/pmocontrol/src/linkplay_client/mod.rs index 27bf9913..d072aac6 100644 --- a/pmocontrol/src/linkplay_client/mod.rs +++ b/pmocontrol/src/linkplay_client/mod.rs @@ -1,10 +1,16 @@ use std::time::Duration; use serde::Deserialize; -use tracing::debug; use ureq::Agent; -use crate::{errors::ControlPointError, model::PlaybackState, music_renderer::{PlaybackPositionInfo, time_utils::{format_hhmmss, ms_to_seconds}}}; +use crate::{ + errors::ControlPointError, + model::PlaybackState, + music_renderer::{ + PlaybackPositionInfo, + time_utils::{format_hhmmss, ms_to_seconds}, + }, +}; const STATUS_COMMAND: &str = "getPlayerStatus"; @@ -101,32 +107,41 @@ pub fn percent_encode(input: &str) -> String { out } -pub fn fetch_status_for_host(host: &str, timeout: Duration) -> Result { +pub fn fetch_status_for_host( + host: &str, + timeout: Duration, +) -> Result { let url = format!("http://{}/httpapi.asp?command={}", host, STATUS_COMMAND); - let mut response = build_agent(timeout) - .get(&url) - .call() - .map_err(|_| ControlPointError::ArilycTcpError(format!("HTTP request failed for LinkPlay status on {}", host)))?; + let mut response = build_agent(timeout).get(&url).call().map_err(|_| { + ControlPointError::ArilycTcpError(format!( + "HTTP request failed for LinkPlay status on {}", + host + )) + })?; - let body = response - .body_mut() - .read_to_string() - .map_err(|e| ControlPointError::ArilycTcpError(format!("Failed to read LinkPlay status body : {}",e)))?; + let body = response.body_mut().read_to_string().map_err(|e| { + ControlPointError::ArilycTcpError(format!("Failed to read LinkPlay status body : {}", e)) + })?; parse_linkplay_status(&body) } fn parse_linkplay_status(body: &str) -> Result { - let raw: LinkPlayStatusRaw = serde_json::from_str(body) - .map_err(|e| ControlPointError::LinkPlayError(format!("Failed to parse LinkPlay status JSON: {}", e)))?; + let raw: LinkPlayStatusRaw = serde_json::from_str(body).map_err(|e| { + ControlPointError::LinkPlayError(format!("Failed to parse LinkPlay status JSON: {}", e)) + })?; - let curpos_ms = raw.curpos.parse::() - .map_err(|_| ControlPointError::LinkPlayError(format!("Invalid curpos value: {}", raw.curpos)))?; + let curpos_ms = raw.curpos.parse::().map_err(|_| { + ControlPointError::LinkPlayError(format!("Invalid curpos value: {}", raw.curpos)) + })?; - let totlen_ms = raw.totlen.parse::() - .map_err(|_| ControlPointError::LinkPlayError(format!("Invalid totlen value: {}", raw.totlen)))?; + let totlen_ms = raw.totlen.parse::().map_err(|_| { + ControlPointError::LinkPlayError(format!("Invalid totlen value: {}", raw.totlen)) + })?; - let volume = raw.vol.parse::() + let volume = raw + .vol + .parse::() .map_err(|_| ControlPointError::LinkPlayError(format!("Invalid vol value: {}", raw.vol)))? .min(100); @@ -134,11 +149,15 @@ fn parse_linkplay_status(body: &str) -> Result true, "0" => false, other => { - return Err(ControlPointError::LinkPlayError(format!("Invalid mute value: {}", other))); + return Err(ControlPointError::LinkPlayError(format!( + "Invalid mute value: {}", + other + ))); } }; - let track_index = raw.plicurr + let track_index = raw + .plicurr .and_then(|s| s.parse::().ok()) .filter(|idx| *idx > 0); @@ -151,6 +170,3 @@ fn parse_linkplay_status(body: &str) -> Result, content_directory_control_url: Option, ) -> Arc { - Arc::new(Self::new( + Arc::new(UpnpMediaServer::new( id, udn, friendly_name, @@ -110,6 +110,10 @@ impl UpnpMediaServer { map_didl_entries(&didl_xml) } + fn has_content_directory(&self) -> bool { + self.has_content_directory + } + fn search_impl( &self, container_id: &str, @@ -384,9 +388,15 @@ pub enum MusicServer { } impl MusicServer { - pub fn from_server_info(info: &UpnpMediaServer) -> Result { + pub fn from_server_info(info: &UpnpMediaServer) -> Result { Ok(MusicServer::Upnp(info.clone())) } + + pub fn has_content_directory(&self) -> bool { + match self { + MusicServer::Upnp(u) => u.has_content_directory(), + } + } } impl DeviceOnline for MusicServer { @@ -502,7 +512,7 @@ impl MediaBrowser for MusicServer { /// 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: &UpnpMediaServer, + server: Arc, entry: &MediaEntry, ) -> Option { // Ignore containers diff --git a/pmocontrol/src/media_server_events.rs b/pmocontrol/src/media_server_events.rs index aac2ef41..1dbffcec 100644 --- a/pmocontrol/src/media_server_events.rs +++ b/pmocontrol/src/media_server_events.rs @@ -8,20 +8,23 @@ use std::time::{Duration, Instant}; use anyhow::{Context, Result}; use crossbeam_channel::{Receiver, Sender, unbounded}; -use tracing::{debug, info, warn}; +use tracing::{debug, error, info, warn}; use ureq::{Agent, http}; use xmltree::{Element, XMLNode}; -use crate::DeviceId; use crate::events::MediaServerEventBus; -use crate::media_server::{UpnpMediaServer}; +use crate::media_server::MusicServer; use crate::model::MediaServerEvent; -use crate::upnp_clients::resolve_control_url; use crate::registry::DeviceRegistry; -use crate::{DeviceOnline,DeviceIdentity}; +use crate::upnp_clients::resolve_control_url; +use crate::{DeviceId, DeviceIdentity, DeviceOnline}; const SUBSCRIPTION_TIMEOUT_SECS: u64 = 300; const RENEWAL_SAFETY_MARGIN_SECS: u64 = 60; +const HTTP_READ_TIMEOUT_SECS: u64 = 5; +const WORKER_LOOP_INTERVAL_MILLIS: u64 = 250; +const RETRY_DELAY_SECS: u64 = 15; +const SUBSCRIPTION_RESET_DELAY_SECS: u64 = 5; /// Launch the media server event runtime responsible for subscribing /// to ContentDirectory updates and forwarding notifications on the bus. @@ -67,11 +70,47 @@ struct IncomingNotify { body: Vec, } +impl IncomingNotify { + fn validate_sid(&self, expected: &Option) -> bool { + match (&self.sid, expected) { + (Some(received), Some(expected)) => expected.eq_ignore_ascii_case(received), + _ => false, + } + } +} + +/// Manages retry timing for subscription operations +struct RetryPolicy { + retry_after: Instant, +} + +impl RetryPolicy { + fn new() -> Self { + Self { + retry_after: Instant::now(), + } + } + + fn should_retry(&self) -> bool { + Instant::now() >= self.retry_after + } + + fn defer_retry(&mut self) { + self.retry_after = Instant::now() + Duration::from_secs(RETRY_DELAY_SECS); + } + + fn schedule_soon(&mut self) { + self.retry_after = Instant::now() + Duration::from_secs(SUBSCRIPTION_RESET_DELAY_SECS); + } +} + fn run_http_listener(listener: TcpListener, notify_tx: Sender) { for stream in listener.incoming() { match stream { Ok(mut stream) => { - if let Err(err) = stream.set_read_timeout(Some(Duration::from_secs(5))) { + if let Err(err) = + stream.set_read_timeout(Some(Duration::from_secs(HTTP_READ_TIMEOUT_SECS))) + { warn!("Failed to set read timeout on notify connection: {}", err); } @@ -209,7 +248,7 @@ impl MediaServerEventWorker { self.drain_notifications(); self.refresh_servers(); self.renew_expiring(); - thread::sleep(Duration::from_millis(250)); + thread::sleep(Duration::from_millis(WORKER_LOOP_INTERVAL_MILLIS)); } } @@ -220,54 +259,60 @@ impl MediaServerEventWorker { } fn refresh_servers(&mut self) { - let server_infos = { + let servers = { let reg = self.registry.read().unwrap(); - reg.list_servers() + match reg.list_servers() { + Ok(servers) => servers, + Err(e) => { + error!("Failed to list servers: {}", e); + return; + } + } }; let mut active: HashSet = HashSet::new(); - for info in server_infos { - if !info.is_online() || !info.has_content_directory { + for server in servers { + if !server.is_online() || !server.has_content_directory() { continue; } - active.insert(info.id.clone()); + active.insert(server.id()); let entry = self .subscriptions - .entry(info.id.clone()) - .or_insert_with(|| SubscriptionState::new(info.clone())); - entry.update(info); + .entry(server.id()) + .or_insert_with(|| SubscriptionState::from_music_server(&server)); + entry.update_from_server(&server); self.path_index - .insert(entry.callback_path.clone(), entry.info.id.clone()); + .insert(entry.callback_path.clone(), entry.device_id.clone()); if entry.event_sub_url.is_none() { - if entry.should_retry() { - match fetch_event_sub_url(&entry.info.location, self.http_timeout) { + if entry.retry_policy.should_retry() { + match fetch_event_sub_url(&entry.location, self.http_timeout) { Ok(Some(url)) => { debug!( - server = entry.info.friendly_name.as_str(), + server = entry.friendly_name.as_str(), callback = url.as_str(), "ContentDirectory eventSub URL resolved" ); entry.event_sub_url = Some(url); - entry.retry_after = Instant::now(); + entry.retry_policy = RetryPolicy::new(); } Ok(None) => { debug!( - server = entry.info.friendly_name.as_str(), + server = entry.friendly_name.as_str(), "No ContentDirectory eventSub URL found" ); - entry.defer_retry(); + entry.retry_policy.defer_retry(); continue; } Err(err) => { warn!( - server = entry.info.friendly_name.as_str(), + server = entry.friendly_name.as_str(), error = %err, "Failed to fetch ContentDirectory eventSub URL" ); - entry.defer_retry(); + entry.retry_policy.defer_retry(); continue; } } @@ -276,21 +321,21 @@ impl MediaServerEventWorker { } } - if entry.sid.is_none() && entry.should_retry() { + if entry.sid.is_none() && entry.retry_policy.should_retry() { if let Err(err) = Self::subscribe_entry(self.listener_port, self.http_timeout, entry) { warn!( - server = entry.info.friendly_name.as_str(), + server = entry.friendly_name.as_str(), error = %err, "ContentDirectory SUBSCRIBE failed" ); - entry.defer_retry(); + entry.retry_policy.defer_retry(); } } } - let stale_ids: Vec = self + let stale_ids: Vec = self .subscriptions .keys() .filter(|id| !active.contains(*id)) @@ -320,7 +365,7 @@ impl MediaServerEventWorker { if let Some(entry) = self.subscriptions.get_mut(&id) { if let Err(err) = Self::renew_entry(self.http_timeout, entry) { warn!( - server = entry.info.friendly_name.as_str(), + server = entry.friendly_name.as_str(), error = %err, "Failed to renew ContentDirectory subscription" ); @@ -340,8 +385,10 @@ impl MediaServerEventWorker { .as_ref() .context("EventSub URL missing for server")?; + let (host_header, timeout_header) = build_subscribe_headers(event_url)?; + let (remote_host, remote_port) = - parse_host_port(event_url).context("Cannot extract host for SUBSCRIBE")?; + parse_host_port(event_url).context("Cannot extract host for callback")?; let local_ip = determine_local_ip(&remote_host, remote_port) .context("Cannot determine local IP for callback")?; @@ -353,13 +400,11 @@ impl MediaServerEventWorker { ); debug!( - server = entry.info.friendly_name.as_str(), + server = entry.friendly_name.as_str(), callback = callback_url.as_str(), "Subscribing to ContentDirectory events" ); - let host_header = format!("{}:{}", remote_host, remote_port); - let timeout_header = format!("Second-{}", SUBSCRIPTION_TIMEOUT_SECS); let callback_header = format!("<{}>", callback_url); let request = http::Request::builder() @@ -393,10 +438,10 @@ impl MediaServerEventWorker { entry.sid = Some(sid); entry.expires_at = Some(Instant::now() + timeout); - entry.retry_after = Instant::now() + Duration::from_secs(5); + entry.retry_policy.schedule_soon(); info!( - server = entry.info.friendly_name.as_str(), + server = entry.friendly_name.as_str(), "Subscribed to ContentDirectory events (timeout {}s)", timeout.as_secs() ); @@ -414,10 +459,9 @@ impl MediaServerEventWorker { .as_ref() .cloned() .context("SID missing for renew")?; - let (remote_host, remote_port) = - parse_host_port(event_url).context("Cannot extract host for renew")?; - let host_header = format!("{}:{}", remote_host, remote_port); - let timeout_header = format!("Second-{}", SUBSCRIPTION_TIMEOUT_SECS); + + let (host_header, timeout_header) = build_subscribe_headers(event_url)?; + let request = http::Request::builder() .method("SUBSCRIBE") .uri(event_url) @@ -441,7 +485,7 @@ impl MediaServerEventWorker { .unwrap_or(Duration::from_secs(SUBSCRIPTION_TIMEOUT_SECS)); entry.expires_at = Some(Instant::now() + timeout); debug!( - server = entry.info.friendly_name.as_str(), + server = entry.friendly_name.as_str(), "Renewed ContentDirectory subscription" ); Ok(()) @@ -470,7 +514,7 @@ impl MediaServerEventWorker { Ok(req) => req, Err(err) => { warn!( - server = entry.info.friendly_name.as_str(), + server = entry.friendly_name.as_str(), error = %err, "Failed to build UNSUBSCRIBE request" ); @@ -482,12 +526,12 @@ impl MediaServerEventWorker { Ok(response) => { if response.status().is_success() { debug!( - server = entry.info.friendly_name.as_str(), + server = entry.friendly_name.as_str(), "Unsubscribed from ContentDirectory events" ); } else { warn!( - server = entry.info.friendly_name.as_str(), + server = entry.friendly_name.as_str(), status = %response.status(), "UNSUBSCRIBE returned non-success status" ); @@ -495,7 +539,7 @@ impl MediaServerEventWorker { } Err(err) => { warn!( - server = entry.info.friendly_name.as_str(), + server = entry.friendly_name.as_str(), error = %err, "UNSUBSCRIBE request failed" ); @@ -513,32 +557,30 @@ impl MediaServerEventWorker { return; }; - if let (Some(expected), Some(received)) = (&entry.sid, ¬ify.sid) { - if !expected.eq_ignore_ascii_case(received) { - debug!( - server = entry.info.friendly_name.as_str(), - expected_sid = expected.as_str(), - received_sid = received.as_str(), - "Ignoring notify with mismatched SID" - ); - return; - } + if !notify.validate_sid(&entry.sid) { + debug!( + server = entry.friendly_name.as_str(), + expected_sid = entry.sid.as_deref().unwrap_or("none"), + received_sid = notify.sid.as_deref().unwrap_or("none"), + "Ignoring notify with mismatched SID" + ); + return; } - for event in parse_notify_payload(&entry.info.id, ¬ify.body) { + for event in parse_notify_payload(&entry.device_id, ¬ify.body) { match &event { MediaServerEvent::GlobalUpdated { system_update_id, .. } => { debug!( - server = entry.info.friendly_name.as_str(), + server = entry.friendly_name.as_str(), update_id = system_update_id.unwrap_or_default(), "Broadcasting MediaServerEvent::GlobalUpdated" ); } MediaServerEvent::ContainersUpdated { container_ids, .. } => { debug!( - server = entry.info.friendly_name.as_str(), + server = entry.friendly_name.as_str(), changed_containers = container_ids.join(",").as_str(), "Broadcasting MediaServerEvent::ContainersUpdated" ); @@ -553,52 +595,63 @@ impl MediaServerEventWorker { } struct SubscriptionState { - info: UpnpMediaServer, + device_id: DeviceId, + location: String, + friendly_name: String, event_sub_url: Option, sid: Option, expires_at: Option, callback_path: String, - retry_after: Instant, + retry_policy: RetryPolicy, } impl SubscriptionState { - fn new(info: UpnpMediaServer) -> Self { + /// Creates a new subscription state from a MusicServer + fn from_music_server(server: &MusicServer) -> Self { Self { - callback_path: build_callback_path(&info.id), - info, + callback_path: build_callback_path(&server.id()), + device_id: server.id(), + location: server.location().to_string(), + friendly_name: server.friendly_name().to_string(), event_sub_url: None, sid: None, expires_at: None, - retry_after: Instant::now(), + retry_policy: RetryPolicy::new(), } } - fn update(&mut self, info: UpnpMediaServer) { - if self.info.location != info.location { + /// Updates the subscription state from a MusicServer + fn update_from_server(&mut self, server: &MusicServer) { + let new_location = server.location(); + if self.location != new_location { + // Location changed - invalidate subscription self.event_sub_url = None; self.sid = None; self.expires_at = None; - self.retry_after = Instant::now(); + self.retry_policy = RetryPolicy::new(); } - self.info = info; - } - - fn should_retry(&self) -> bool { - Instant::now() >= self.retry_after - } - - fn defer_retry(&mut self) { - self.retry_after = Instant::now() + Duration::from_secs(15); + self.device_id = server.id(); + self.location = new_location.to_string(); + self.friendly_name = server.friendly_name().to_string(); } fn reset_subscription(&mut self) { self.sid = None; self.expires_at = None; - self.retry_after = Instant::now() + Duration::from_secs(5); + self.retry_policy.schedule_soon(); } } -fn build_callback_path(id: &ServerId) -> String { +/// Builds HTTP headers common to SUBSCRIBE requests +fn build_subscribe_headers(event_url: &str) -> Result<(String, String)> { + let (remote_host, remote_port) = + parse_host_port(event_url).context("Cannot extract host for SUBSCRIBE")?; + let host_header = format!("{}:{}", remote_host, remote_port); + let timeout_header = format!("Second-{}", SUBSCRIPTION_TIMEOUT_SECS); + Ok((host_header, timeout_header)) +} + +fn build_callback_path(id: &DeviceId) -> String { let mut sanitized = String::new(); for ch in id.0.chars() { if ch.is_ascii_alphanumeric() { @@ -615,7 +668,7 @@ fn build_callback_path(id: &ServerId) -> String { format!("/media-server-events/{}-{:x}", sanitized, suffix) } -fn parse_notify_payload(server_id: &ServerId, body: &[u8]) -> Vec { +fn parse_notify_payload(server_id: &DeviceId, body: &[u8]) -> Vec { let mut events = Vec::new(); let reader = std::io::Cursor::new(body); let Ok(root) = Element::parse(reader) else { @@ -629,27 +682,21 @@ fn parse_notify_payload(server_id: &ServerId, body: &[u8]) -> Vec = None; let mut container_ids: Vec = Vec::new(); - for property in root.children.iter().filter_map(|node| match node { - XMLNode::Element(elem) => Some(elem), - _ => None, - }) { - for child in property.children.iter().filter_map(|node| match node { - XMLNode::Element(elem) => Some(elem), - _ => None, - }) { - if child.name == "SystemUpdateID" { - if let Some(text) = child.get_text() { - let trimmed = text.trim(); - if let Ok(value) = trimmed.parse::() { - system_update_id = Some(value); - } else { - system_update_id = None; + // Navigate through property elements + for property in xml_children(&root) { + for child in xml_children(property) { + match child.name.as_str() { + "SystemUpdateID" => { + system_update_id = child + .get_text() + .and_then(|text| text.trim().parse::().ok()); + } + "ContainerUpdateIDs" => { + if let Some(text) = child.get_text() { + container_ids = parse_container_update_ids(text.as_ref()); } } - } else if child.name == "ContainerUpdateIDs" { - if let Some(text) = child.get_text() { - container_ids = parse_container_update_ids(text.as_ref()); - } + _ => {} } } } @@ -671,6 +718,14 @@ fn parse_notify_payload(server_id: &ServerId, body: &[u8]) -> Vec impl Iterator { + element.children.iter().filter_map(|node| match node { + XMLNode::Element(elem) => Some(elem), + _ => None, + }) +} + fn parse_container_update_ids(raw: &str) -> Vec { let trimmed = raw.trim(); if trimmed.is_empty() { @@ -702,14 +757,9 @@ fn parse_container_update_ids(raw: &str) -> Vec { } fn child_text(element: &Element, name: &str) -> Option { - for node in &element.children { - if let XMLNode::Element(child) = node { - if child.name == name { - return child.get_text().map(|cow| cow.into_owned()); - } - } - } - None + xml_children(element) + .find(|child| child.name == name) + .and_then(|child| child.get_text().map(|cow| cow.into_owned())) } fn fetch_event_sub_url(location: &str, timeout: Duration) -> Result> { @@ -734,20 +784,18 @@ fn fetch_event_sub_url(location: &str, timeout: Duration) -> Result return Ok(None), }; - for node in &service_list.children { - if let XMLNode::Element(service) = node { - let Some(service_type) = child_text(service, "serviceType") else { - continue; - }; - if !service_type - .to_ascii_lowercase() - .contains("urn:schemas-upnp-org:service:contentdirectory:") - { - continue; - } - if let Some(event_sub) = child_text(service, "eventSubURL") { - return Ok(Some(resolve_control_url(location, &event_sub))); - } + for service in xml_children(service_list) { + let Some(service_type) = child_text(service, "serviceType") else { + continue; + }; + if !service_type + .to_ascii_lowercase() + .contains("urn:schemas-upnp-org:service:contentdirectory:") + { + continue; + } + if let Some(event_sub) = child_text(service, "eventSubURL") { + return Ok(Some(resolve_control_url(location, &event_sub))); } } diff --git a/pmocontrol/src/model.rs b/pmocontrol/src/model.rs index 051b5e83..63c82759 100644 --- a/pmocontrol/src/model.rs +++ b/pmocontrol/src/model.rs @@ -1,8 +1,28 @@ -use crate::control_point::PlaylistBinding; -use crate::media_server::UpnpMediaServer; -use crate::music_renderer::PlaybackPositionInfo; +use crate::music_renderer::{PlaybackPositionInfo, PlaylistBinding}; use crate::{DeviceId, DeviceIdentity}; +/// Basic device information for event notifications +/// Contains only the essential identification fields +#[derive(Clone, Debug)] +pub struct DeviceBasicInfo { + pub id: DeviceId, + pub friendly_name: String, + pub model_name: String, + pub manufacturer: String, +} + +impl DeviceBasicInfo { + /// Create from any type implementing DeviceIdentity + pub fn from_device(device: &D) -> Self { + Self { + id: device.id(), + friendly_name: device.friendly_name().to_string(), + model_name: device.model_name().to_string(), + manufacturer: device.manufacturer().to_string(), + } + } +} + /// High-level playback state across backends. #[derive(Clone, Debug)] pub enum PlaybackState { @@ -48,6 +68,21 @@ impl PlaybackState { } } +/// Indicates the source of the current playback. +/// +/// This helps the control point distinguish between playback initiated +/// from the queue vs external sources (e.g., user playing from another app). +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum PlaybackSource { + /// No playback active or source unknown. + #[default] + None, + /// Playback was started from the control point's queue. + FromQueue, + /// Playback was started externally (e.g., from another app). + External, +} + #[derive(Clone, Debug, PartialEq)] pub struct TrackMetadata { pub title: Option, @@ -401,7 +436,7 @@ pub enum RendererEvent { }, Online { id: DeviceId, - info: RendererInfo, + info: DeviceBasicInfo, }, Offline { id: DeviceId, @@ -420,7 +455,7 @@ pub enum MediaServerEvent { }, Online { server_id: DeviceId, - info: UpnpMediaServer, + info: DeviceBasicInfo, }, Offline { server_id: DeviceId, diff --git a/pmocontrol/src/music_renderer/arylic_tcp.rs b/pmocontrol/src/music_renderer/arylic_tcp.rs index 85c139fb..7cebd074 100644 --- a/pmocontrol/src/music_renderer/arylic_tcp.rs +++ b/pmocontrol/src/music_renderer/arylic_tcp.rs @@ -4,17 +4,19 @@ use serde::Deserialize; use tracing::debug; use crate::DeviceIdentity; -use crate::arylic_client::{ARYLIC_TCP_PORT, DEFAULT_TIMEOUT_SECS, send_command_no_response, send_command_optional, send_command_required}; +use crate::arylic_client::{ + ARYLIC_TCP_PORT, DEFAULT_TIMEOUT_SECS, send_command_no_response, send_command_optional, + send_command_required, +}; use crate::errors::ControlPointError; use crate::linkplay_client::extract_linkplay_host; use crate::model::{PlaybackState, RendererInfo}; use crate::music_renderer::RendererFromMediaRendererInfo; use crate::music_renderer::capabilities::{ - PlaybackPosition, PlaybackPositionInfo, PlaybackStatus, TransportControl, - VolumeControl, + PlaybackPosition, PlaybackPositionInfo, PlaybackStatus, TransportControl, VolumeControl, }; -use crate::music_renderer::time_utils::{ms_to_seconds, format_hhmmss, parse_hhmmss_strict}; use crate::music_renderer::musicrenderer::MusicRendererBackend; +use crate::music_renderer::time_utils::{format_hhmmss, ms_to_seconds, parse_hhmmss_strict}; /// Raw response from Arylic MCU+PINFGET command #[derive(Debug, Deserialize)] @@ -145,7 +147,7 @@ impl TransportControl for ArylicTcpRenderer { fn seek_rel_time(&self, hhmmss: &str) -> Result<(), ControlPointError> { let _ = parse_hhmmss_strict(hhmmss)?; Err(ControlPointError::ArilycTcpError( - "Arylic TCP seek_rel_time is not implemented yet for this device.".to_string() + "Arylic TCP seek_rel_time is not implemented yet for this device.".to_string(), )) } } @@ -255,59 +257,77 @@ fn parse_playback_info(payload: &str) -> Result() - .map_err(|_| ControlPointError::ArilycTcpError(format!("Invalid curpos value: {}", raw.curpos)))?; + let json_blob = &json_blob[json_start..=json_end]; - let totlen_ms = raw.totlen.parse::() - .map_err(|_| ControlPointError::ArilycTcpError(format!("Invalid totlen value: {}", raw.totlen)))?; + let raw: ArylicPlaybackInfoRaw = serde_json::from_str(json_blob).map_err(|e| { + ControlPointError::ArilycTcpError(format!( + "Failed to parse Arylic playback info JSON: {}", + e + )) + })?; - let volume = raw.vol.and_then(|raw_vol| { - match raw_vol.parse::() { - Ok(value) => Some(value.min(100)), - Err(err) => { - debug!("Invalid Arylic `vol` value {}: {}", raw_vol, err); - None - } + let curpos_ms = raw.curpos.parse::().map_err(|_| { + ControlPointError::ArilycTcpError(format!("Invalid curpos value: {}", raw.curpos)) + })?; + + let totlen_ms = raw.totlen.parse::().map_err(|_| { + ControlPointError::ArilycTcpError(format!("Invalid totlen value: {}", raw.totlen)) + })?; + + let volume = raw.vol.and_then(|raw_vol| match raw_vol.parse::() { + Ok(value) => Some(value.min(100)), + Err(err) => { + debug!("Invalid Arylic `vol` value {}: {}", raw_vol, err); + None } }); - let mute = raw.mute.and_then(|raw_mute| { - match raw_mute.as_str() { - "1" => Some(true), - "0" => Some(false), - other => { - debug!("Invalid Arylic `mute` value {}", other); - None - } + let mute = raw.mute.and_then(|raw_mute| match raw_mute.as_str() { + "1" => Some(true), + "0" => Some(false), + other => { + debug!("Invalid Arylic `mute` value {}", other); + None } }); - let playlist_size = raw.plicount.and_then(|raw_count| { - match raw_count.parse::() { + let playlist_size = raw + .plicount + .and_then(|raw_count| match raw_count.parse::() { Ok(count) if count > 0 => Some(count), Ok(_) => None, Err(err) => { debug!("Invalid Arylic `plicount` value {}: {}", raw_count, err); None } - } - }); + }); - let track_index = raw.plicurr.and_then(|raw_idx| { - match raw_idx.parse::() { + let track_index = raw + .plicurr + .and_then(|raw_idx| match raw_idx.parse::() { Ok(idx) if idx > 0 => Some(idx), Ok(_) => None, Err(err) => { debug!("Invalid Arylic `plicurr` value {}: {}", raw_idx, err); None } - } - }); + }); Ok(ArylicPlaybackInfo { status_raw: raw.status, diff --git a/pmocontrol/src/music_renderer/mod.rs b/pmocontrol/src/music_renderer/mod.rs index b9c5049e..56cffbc9 100644 --- a/pmocontrol/src/music_renderer/mod.rs +++ b/pmocontrol/src/music_renderer/mod.rs @@ -17,7 +17,7 @@ use std::sync::{Arc, Mutex}; pub use crate::music_renderer::capabilities::{ PlaybackPosition, PlaybackPositionInfo, PlaybackStatus, }; -pub use crate::music_renderer::musicrenderer::MusicRenderer; +pub use crate::music_renderer::musicrenderer::{MusicRenderer, PlaylistBinding}; use crate::{ RendererInfo, errors::ControlPointError, music_renderer::musicrenderer::MusicRendererBackend, }; diff --git a/pmocontrol/src/music_renderer/musicrenderer.rs b/pmocontrol/src/music_renderer/musicrenderer.rs index 3011a912..90a2600f 100644 --- a/pmocontrol/src/music_renderer/musicrenderer.rs +++ b/pmocontrol/src/music_renderer/musicrenderer.rs @@ -9,25 +9,44 @@ use std::sync::{Arc, Mutex}; use std::time::SystemTime; -use crate::control_point::PlaylistBinding; use crate::errors::ControlPointError; -use crate::model::{RendererInfo, RendererProtocol, TrackMetadata, PlaybackState}; +use crate::model::{PlaybackSource, PlaybackState, RendererInfo, RendererProtocol, TrackMetadata}; use crate::music_renderer::RendererFromMediaRendererInfo; use crate::music_renderer::arylic_tcp::ArylicTcpRenderer; use crate::music_renderer::capabilities::{ - PlaybackPosition, PlaybackPositionInfo, PlaybackStatus, TransportControl, - VolumeControl, + PlaybackPosition, PlaybackPositionInfo, PlaybackStatus, TransportControl, VolumeControl, }; use crate::music_renderer::chromecast_renderer::ChromecastRenderer; use crate::music_renderer::linkplay_renderer::LinkPlayRenderer; use crate::music_renderer::openhome_renderer::OpenHomeRenderer; use crate::music_renderer::upnp_renderer::UpnpRenderer; use crate::online::DeviceConnectionState; -use crate::queue::{EnqueueMode, MusicQueue, PlaybackItem, QueueBackend, QueueFromRendererInfo, QueueSnapshot}; +use crate::queue::{ + EnqueueMode, MusicQueue, PlaybackItem, QueueBackend, QueueFromRendererInfo, QueueSnapshot, +}; use crate::{DeviceId, DeviceIdentity, DeviceOnline}; use tracing::warn; +/// Describes a renderer's attachment to a media server playlist container. +/// +/// When a renderer is attached to a playlist, the ControlPoint monitors the container +/// for updates and automatically refreshes the queue when changes are detected. +#[derive(Clone, Debug)] +pub struct PlaylistBinding { + /// MediaServer that owns the playlist container. + pub server_id: DeviceId, + /// DIDL-Lite object id of the playlist container. + pub container_id: String, + /// True once at least one ContainerUpdateIDs notification has been seen. + pub(crate) has_seen_update: bool, + /// Flag used internally to signal that the queue should be refreshed + /// from the server container. + pub(crate) pending_refresh: bool, + /// Whether the next refresh should auto-start playback if the renderer is idle. + pub(crate) auto_play_on_refresh: bool, +} + /// Backend-agnostic façade exposing transport, volume, and status contracts. #[derive(Clone, Debug)] pub enum MusicRendererBackend { @@ -49,6 +68,17 @@ pub enum MusicRendererBackend { }, } +/// Internal state for tracking playback and control flow. +#[derive(Debug, Clone, Default)] +struct MusicRendererState { + /// Last known track metadata (cached to avoid repeated queries). + last_metadata: Option, + /// Source of the current playback (queue vs external). + playback_source: PlaybackSource, + /// Flag to distinguish user-requested stop from automatic events. + user_stop_requested: bool, +} + #[derive(Debug, Clone)] pub struct MusicRenderer { info: RendererInfo, @@ -56,6 +86,7 @@ pub struct MusicRenderer { backend: Arc>, queue: Arc>, playlist_binding: Arc>>, + state: Arc>, } impl MusicRenderer { @@ -72,6 +103,7 @@ impl MusicRenderer { backend, queue, playlist_binding: Arc::new(Mutex::new(None)), + state: Arc::new(Mutex::new(MusicRendererState::default())), }; Arc::new(renderer) @@ -88,6 +120,7 @@ impl MusicRenderer { backend, queue, playlist_binding: Arc::new(Mutex::new(None)), + state: Arc::new(Mutex::new(MusicRendererState::default())), }; Ok(renderer) } @@ -179,6 +212,23 @@ impl MusicRenderer { .queue_snapshot() } + /// Get the current queue item without advancing. + /// Returns the item and count of remaining items after current. + pub fn peek_current(&self) -> Result, ControlPointError> { + self.queue + .lock() + .expect("Queue mutex poisoned") + .peek_current() + } + + /// Get the count of items remaining after the current index. + pub fn upcoming_len(&self) -> Result { + self.queue + .lock() + .expect("Queue mutex poisoned") + .upcoming_len() + } + /// Play the current item from the queue. pub fn play_current_from_queue(&self) -> Result<(), ControlPointError> { let queue = self.queue.lock().expect("Queue mutex poisoned"); @@ -248,6 +298,22 @@ impl MusicRenderer { self.backend.lock().expect("Backend mutex poisoned").stop() } + /// Set the next URI for gapless playback (UPnP AVTransport only). + /// + /// Returns Ok if the backend supports this feature and it succeeded. + /// Returns Err if not supported or if it failed. + pub fn set_next_uri(&self, uri: &str, metadata: &str) -> Result<(), ControlPointError> { + let backend = self.backend.lock().expect("Backend mutex poisoned"); + + match &*backend { + MusicRendererBackend::Upnp(upnp) => upnp.set_next_uri(uri, metadata), + MusicRendererBackend::HybridUpnpArylic { upnp, .. } => upnp.set_next_uri(uri, metadata), + _ => Err(ControlPointError::ControlPoint( + "SetNextURI not supported by this backend".to_string(), + )), + } + } + /// Transport control: seek to relative time pub fn seek_rel_time(&self, hhmmss: &str) -> Result<(), ControlPointError> { self.backend @@ -303,17 +369,126 @@ impl MusicRenderer { /// Sets the playlist binding for this renderer. pub fn set_playlist_binding(&self, binding: Option) { - *self.playlist_binding.lock().expect("Playlist binding mutex poisoned") = binding; + *self + .playlist_binding + .lock() + .expect("Playlist binding mutex poisoned") = binding; } /// Gets the current playlist binding, if any. pub fn get_playlist_binding(&self) -> Option { - self.playlist_binding.lock().expect("Playlist binding mutex poisoned").clone() + self.playlist_binding + .lock() + .expect("Playlist binding mutex poisoned") + .clone() } /// Clears the playlist binding. pub fn clear_playlist_binding(&self) { - *self.playlist_binding.lock().expect("Playlist binding mutex poisoned") = None; + *self + .playlist_binding + .lock() + .expect("Playlist binding mutex poisoned") = None; + } + + /// Marks the current playlist binding for refresh if it matches the given server and container. + /// Returns true if a matching binding was found and marked. + pub fn mark_binding_for_refresh(&self, server_id: &DeviceId, container_ids: &[String]) -> bool { + let mut binding_guard = self + .playlist_binding + .lock() + .expect("Playlist binding mutex poisoned"); + + if let Some(binding) = binding_guard.as_mut() { + if &binding.server_id == server_id && container_ids.contains(&binding.container_id) { + binding.pending_refresh = true; + binding.has_seen_update = true; + return true; + } + } + + false + } + + /// Checks if the binding has pending_refresh flag set. + /// Returns false if no binding exists. + pub fn has_pending_refresh(&self) -> bool { + self.playlist_binding + .lock() + .expect("Playlist binding mutex poisoned") + .as_ref() + .map(|b| b.pending_refresh) + .unwrap_or(false) + } + + /// Resets the pending_refresh flag to false. + /// Does nothing if no binding exists. + pub fn reset_pending_refresh(&self) { + if let Some(binding) = self + .playlist_binding + .lock() + .expect("Playlist binding mutex poisoned") + .as_mut() + { + binding.pending_refresh = false; + } + } + + /// Marks the binding as pending refresh. + /// Returns true if a binding exists and was marked, false otherwise. + pub fn mark_pending_refresh(&self) -> bool { + self.playlist_binding + .lock() + .expect("Playlist binding mutex poisoned") + .as_mut() + .map(|binding| { + binding.pending_refresh = true; + true + }) + .unwrap_or(false) + } + + /// Consumes and returns the auto_play_on_refresh flag, resetting it to false. + /// Returns false if no binding exists. + pub fn consume_auto_play(&self) -> bool { + self.playlist_binding + .lock() + .expect("Playlist binding mutex poisoned") + .as_mut() + .map(|binding| { + let auto_play = binding.auto_play_on_refresh; + binding.auto_play_on_refresh = false; + auto_play + }) + .unwrap_or(false) + } + + /// Add items to the queue using the specified enqueue mode. + pub fn enqueue_items( + &self, + items: Vec, + mode: EnqueueMode, + ) -> Result<(), ControlPointError> { + let mut queue = self.queue.lock().expect("Queue mutex poisoned"); + queue.enqueue_items(items, mode) + } + + /// Synchronize the queue with new items while preserving the current track. + /// + /// This method intelligently updates the queue: + /// - If the current track is in the new items, it keeps playing at the new position + /// - If the current track is NOT in the new items, it's preserved as the first item + /// - If there's no current track, the queue is simply replaced + pub fn sync_queue(&self, items: Vec) -> Result<(), ControlPointError> { + let mut queue = self.queue.lock().expect("Queue mutex poisoned"); + queue.sync_queue(items) + } + + /// Set the current queue index (for advanced use). + /// Note: This does NOT start playback. Use select_queue_track() to play. + pub fn set_queue_index(&self, index: Option) -> Result<(), ControlPointError> { + let mut queue = self.queue.lock().expect("Queue mutex poisoned"); + queue.set_index(index) } /// Clears the renderer's queue using the generic QueueBackend trait. @@ -340,7 +515,7 @@ impl MusicRenderer { // The generic implementation cannot support this without more context // (media_server_id, didl_id, protocol_info, etc.). Err(ControlPointError::QueueError( - "add_track_to_queue requires backend-specific implementation".to_string() + "add_track_to_queue requires backend-specific implementation".to_string(), )) } @@ -360,7 +535,8 @@ impl MusicRenderer { queue.set_index(Some(position))?; // Get the item to play - let item = queue.get_item(position)? + let item = queue + .get_item(position)? .ok_or_else(|| ControlPointError::QueueError("Track not found".to_string()))?; drop(queue); @@ -396,7 +572,8 @@ impl MusicRenderer { let queue = self.queue.lock().expect("Queue mutex poisoned"); // Get current track ID using generic QueueBackend trait - let track_id = queue.current_track()? + let track_id = queue + .current_track()? .ok_or_else(|| ControlPointError::QueueError("No current track".to_string()))?; drop(queue); @@ -416,6 +593,57 @@ impl MusicRenderer { pub fn get_queue_mut(&self) -> std::sync::MutexGuard<'_, MusicQueue> { self.queue.lock().unwrap() } + + // --- Playback State Management --- + + /// Gets the last known track metadata. + pub fn last_metadata(&self) -> Option { + self.state.lock().unwrap().last_metadata.clone() + } + + /// Sets the last known track metadata. + pub fn set_last_metadata(&self, metadata: Option) { + self.state.lock().unwrap().last_metadata = metadata; + } + + /// Gets the current playback source. + pub fn playback_source(&self) -> PlaybackSource { + self.state.lock().unwrap().playback_source + } + + /// Sets the playback source. + pub fn set_playback_source(&self, source: PlaybackSource) { + self.state.lock().unwrap().playback_source = source; + } + + /// Checks if currently playing from queue. + pub fn is_playing_from_queue(&self) -> bool { + matches!( + self.state.lock().unwrap().playback_source, + PlaybackSource::FromQueue + ) + } + + /// Marks playback as external if currently idle (source is None). + pub fn mark_external_if_idle(&self) { + let mut state = self.state.lock().unwrap(); + if matches!(state.playback_source, PlaybackSource::None) { + state.playback_source = PlaybackSource::External; + } + } + + /// Marks that the user requested a stop (to prevent auto-advance). + pub fn mark_user_stop_requested(&self) { + self.state.lock().unwrap().user_stop_requested = true; + } + + /// Checks and clears the user stop requested flag. + pub fn check_and_clear_user_stop_requested(&self) -> bool { + let mut state = self.state.lock().unwrap(); + let was_requested = state.user_stop_requested; + state.user_stop_requested = false; + was_requested + } } /// Helper function to build DIDL-Lite metadata XML from TrackMetadata diff --git a/pmocontrol/src/music_renderer/openhome_renderer.rs b/pmocontrol/src/music_renderer/openhome_renderer.rs index d9f0a76c..895ca3e6 100644 --- a/pmocontrol/src/music_renderer/openhome_renderer.rs +++ b/pmocontrol/src/music_renderer/openhome_renderer.rs @@ -1,12 +1,11 @@ use crate::DeviceIdentity; use crate::music_renderer::capabilities::{ - PlaybackPosition, PlaybackPositionInfo, PlaybackStatus, TransportControl, - VolumeControl, + PlaybackPosition, PlaybackPositionInfo, PlaybackStatus, TransportControl, VolumeControl, }; -use crate::music_renderer::time_utils::{parse_time_flexible, format_hhmmss_u32}; +use crate::music_renderer::time_utils::{format_hhmmss_u32, parse_time_flexible}; use crate::errors::ControlPointError; -use crate::model::{RendererInfo, PlaybackState}; +use crate::model::{PlaybackState, RendererInfo}; use crate::music_renderer::RendererFromMediaRendererInfo; use crate::music_renderer::musicrenderer::MusicRendererBackend; use crate::music_renderer::openhome::{ @@ -49,7 +48,6 @@ impl OpenHomeRenderer { } } - pub fn has_playlist(&self) -> bool { self.playlist.is_some() } @@ -71,10 +69,9 @@ impl OpenHomeRenderer { } fn playlist_client_for(&self, op: &str) -> Result<&OhPlaylistClient, ControlPointError> { - let playlist = self - .playlist - .as_ref() - .ok_or_else(|| ControlPointError::upnp_operation_not_supported(op, "OpenHome Playlist"))?; + let playlist = self.playlist.as_ref().ok_or_else(|| { + ControlPointError::upnp_operation_not_supported(op, "OpenHome Playlist") + })?; self.ensure_playlist_source_selected()?; Ok(playlist) } @@ -97,10 +94,9 @@ impl OpenHomeRenderer { .ok_or_else(|| ControlPointError::upnp_operation_not_supported(op, "OpenHome Volume")) } - fn ensure_playlist_source_selected(&self) -> Result<(),ControlPointError> { + fn ensure_playlist_source_selected(&self) -> Result<(), ControlPointError> { if let Some(product) = &self.product_client { - product - .ensure_playlist_source_selected() + product.ensure_playlist_source_selected() } else { Ok(()) } @@ -132,7 +128,6 @@ impl OpenHomeRenderer { // }) // } - /// Retourne la longueur de la playlist OpenHome sans récupérer toutes les métadonnées. /// Plus rapide que snapshot_openhome_playlist() pour juste connaître le nombre de pistes. pub(crate) fn openhome_playlist_len(&self) -> Result { @@ -183,11 +178,8 @@ impl OpenHomeRenderer { } } - impl RendererFromMediaRendererInfo for OpenHomeRenderer { - fn from_renderer_info( - info: &RendererInfo, - ) -> Result { + fn from_renderer_info(info: &RendererInfo) -> Result { let renderer = OpenHomeRenderer::new( build_playlist_client(&info), build_info_client(&info), @@ -210,7 +202,6 @@ impl RendererFromMediaRendererInfo for OpenHomeRenderer { fn to_backend(self) -> MusicRendererBackend { MusicRendererBackend::OpenHome(self) } - } impl TransportControl for OpenHomeRenderer { @@ -228,6 +219,9 @@ impl TransportControl for OpenHomeRenderer { // Reuse the same insertion logic as the queue path so that we honor // renderer expectations (IdArray sequencing, etc.). self.add_track_openhome(uri, meta, None, true)?; + + // Start playback (like UPnP renderer does with avt.play()) + playlist.play()?; Ok(()) } @@ -336,4 +330,3 @@ pub(crate) fn map_openhome_state(raw: &str) -> PlaybackState { other => PlaybackState::Unknown(other.to_string()), } } - diff --git a/pmocontrol/src/online.rs b/pmocontrol/src/online.rs index f22d3ec0..ed8dbb3a 100644 --- a/pmocontrol/src/online.rs +++ b/pmocontrol/src/online.rs @@ -1,9 +1,9 @@ use std::{ sync::{Arc, Mutex}, - time::{SystemTime}, + time::SystemTime, }; -#[derive(Clone,Debug)] +#[derive(Clone, Debug)] pub struct DeviceConnectionState { online: bool, last_seen: SystemTime, @@ -20,8 +20,11 @@ pub trait DeviceOnline { impl DeviceConnectionState { pub fn new() -> Self { - DeviceConnectionState { - online: false, last_seen: std::time::UNIX_EPOCH, max_age: 1800 } + DeviceConnectionState { + online: true, + last_seen: SystemTime::now(), + max_age: 1800, + } } pub fn make() -> Arc> { @@ -43,6 +46,7 @@ impl DeviceConnectionState { pub fn has_been_seen_now(&mut self, max_age: u32) { self.last_seen = SystemTime::now(); self.max_age = max_age; + self.online = true; } pub fn mark_as_offline(&mut self) { diff --git a/pmocontrol/src/openapi.rs b/pmocontrol/src/openapi.rs index 82e7a0b1..4b37bd97 100644 --- a/pmocontrol/src/openapi.rs +++ b/pmocontrol/src/openapi.rs @@ -149,28 +149,6 @@ pub struct QueueSnapshot { pub current_index: Option, } -// ============================================================================ -// OPENHOME PLAYLIST -// ============================================================================ - -#[cfg(feature = "pmoserver")] -pub use crate::openhome_playlist::{OpenHomePlaylistSnapshot, OpenHomePlaylistTrack}; - -/// Requête pour ajouter un track à la playlist OpenHome -#[cfg(feature = "pmoserver")] -#[derive(Debug, Clone, Deserialize, ToSchema)] -pub struct OpenHomePlaylistAddRequest { - /// URI du flux à insérer - pub uri: String, - /// Métadonnées DIDL-Lite complètes - pub metadata: String, - /// ID devant lequel insérer (None => fin de playlist) - pub after_id: Option, - /// Si true, démarre immédiatement la lecture du track inséré - #[serde(default)] - pub play: bool, -} - // ============================================================================ // MEDIA SERVERS // ============================================================================ @@ -318,20 +296,22 @@ pub struct ErrorResponse { description = r#" # API REST pour le Control Point PMOMusic -Cette API permet de contrôler les renderers UPnP et de naviguer dans les serveurs de médias. +Cette API permet de contrôler les renderers audio et de naviguer dans les serveurs de médias de manière agnostique du backend. ## Fonctionnalités ### Renderers -- **Découverte** : Liste des renderers disponibles +- **Découverte** : Liste des renderers disponibles (tous types: UPnP AV, OpenHome, LinkPlay, Chromecast) - **État** : Récupération de l'état détaillé d'un renderer -- **Contrôle transport** : Play, pause, stop, next +- **Contrôle transport** : Play, pause, stop, resume, next - **Contrôle volume** : Lecture et modification du volume / mute -- **Queue** : Gestion de la queue de lecture +- **Queue unifiée** : Gestion de la queue de lecture (indépendante du backend) +- **Navigation dans la queue** : Saut à un index spécifique ### Playlists - **Binding** : Attachement de la queue à un container playlist d'un serveur - **Synchronisation automatique** : Mise à jour de la queue lors des changements côté serveur +- **Auto-play** : Option pour démarrer la lecture automatiquement ### Serveurs de médias - **Découverte** : Liste des serveurs disponibles @@ -339,11 +319,12 @@ Cette API permet de contrôler les renderers UPnP et de naviguer dans les serveu ## Architecture -Le Control Point PMOMusic est un point de contrôle UPnP qui : -1. Découvre automatiquement les renderers et serveurs via SSDP -2. Maintient un registre des devices actifs -3. Permet le contrôle unifié des renderers (UPnP AV, LinkPlay, Arylic TCP) -4. Gère une queue de lecture locale avec synchronisation optionnelle +Le Control Point PMOMusic est un point de contrôle multi-backend qui : +1. Découvre automatiquement les renderers et serveurs via SSDP et mDNS +2. Maintient un registre unifié des devices actifs +3. Abstrait les différences entre backends (UPnP AV, OpenHome, LinkPlay, Arylic TCP, Chromecast) +4. Gère une queue de lecture unifiée avec synchronisation optionnelle aux playlists serveur +5. Expose une API REST cohérente indépendante du type de renderer ## Exemples d'utilisation @@ -352,12 +333,33 @@ Le Control Point PMOMusic est un point de contrôle UPnP qui : GET /control/renderers ``` -### Contrôler un renderer +### Obtenir l'état complet d'un renderer +``` +GET /control/renderers/{renderer_id}/full +``` + +### Contrôler la lecture ``` POST /control/renderers/{renderer_id}/play POST /control/renderers/{renderer_id}/pause +POST /control/renderers/{renderer_id}/stop +POST /control/renderers/{renderer_id}/resume +POST /control/renderers/{renderer_id}/next +``` + +### Naviguer dans la queue +``` +POST /control/renderers/{renderer_id}/queue/seek + Body: {"index": 5} +``` + +### Contrôler le volume +``` POST /control/renderers/{renderer_id}/volume/set Body: {"volume": 50} +POST /control/renderers/{renderer_id}/volume/up +POST /control/renderers/{renderer_id}/volume/down +POST /control/renderers/{renderer_id}/mute/toggle ``` ### Attacher une playlist @@ -365,7 +367,17 @@ POST /control/renderers/{renderer_id}/volume/set POST /control/renderers/{renderer_id}/binding/attach Body: { "server_id": "uuid:...", - "container_id": "0$/Music/MyPlaylist" + "container_id": "0$/Music/MyPlaylist", + "auto_play": true + } +``` + +### Jouer du contenu +``` +POST /control/renderers/{renderer_id}/queue/play + Body: { + "server_id": "uuid:...", + "object_id": "0$/Music/Track.flac" } ``` @@ -384,16 +396,15 @@ GET /control/servers/{server_id}/containers/{container_id} paths( crate::pmoserver_ext::list_renderers, crate::pmoserver_ext::get_renderer_state, + crate::pmoserver_ext::get_renderer_full_snapshot, crate::pmoserver_ext::get_renderer_queue, crate::pmoserver_ext::get_renderer_binding, - crate::pmoserver_ext::get_openhome_playlist, - crate::pmoserver_ext::clear_openhome_playlist, - crate::pmoserver_ext::add_openhome_playlist_item, - crate::pmoserver_ext::play_openhome_track, crate::pmoserver_ext::play_renderer, crate::pmoserver_ext::pause_renderer, crate::pmoserver_ext::stop_renderer, + crate::pmoserver_ext::resume_renderer, crate::pmoserver_ext::next_renderer, + crate::pmoserver_ext::seek_queue_index, crate::pmoserver_ext::set_renderer_volume, crate::pmoserver_ext::volume_up_renderer, crate::pmoserver_ext::volume_down_renderer, @@ -415,17 +426,16 @@ GET /control/servers/{server_id}/containers/{container_id} RendererState, CurrentTrackMetadata, AttachedPlaylistInfo, + FullRendererSnapshot, QueueItem, QueueSnapshot, - OpenHomePlaylistSnapshot, - OpenHomePlaylistTrack, - OpenHomePlaylistAddRequest, MediaServerSummary, ContainerEntry, BrowseResponse, VolumeSetRequest, AttachPlaylistRequest, PlayContentRequest, + SeekQueueRequest, SuccessResponse, ErrorResponse, )), diff --git a/pmocontrol/src/pmoserver_ext.rs b/pmocontrol/src/pmoserver_ext.rs index 09771f04..d909d2a2 100644 --- a/pmocontrol/src/pmoserver_ext.rs +++ b/pmocontrol/src/pmoserver_ext.rs @@ -4,27 +4,22 @@ //! et naviguer dans les serveurs de médias. #[cfg(feature = "pmoserver")] -use crate::control_point::{ - ControlPoint, OpenHomeAccessError, OPENHOME_SNAPSHOT_CACHE_TTL, -}; +use crate::control_point::ControlPoint; #[cfg(feature = "pmoserver")] -use crate::media_server::{ - playback_item_from_entry, MediaBrowser, MediaEntry, UpnpMediaServer, ServerId, -}; +use crate::media_server::{MediaBrowser, playback_item_from_entry}; #[cfg(feature = "pmoserver")] -use crate::model::{RendererCapabilities, ServiceId, RendererProtocol, TrackMetadata}; +use crate::model::{RendererCapabilities, RendererProtocol}; #[cfg(feature = "pmoserver")] use crate::openapi::{ AttachPlaylistRequest, AttachedPlaylistInfo, BrowseResponse, ContainerEntry, ErrorResponse, - FullRendererSnapshot, MediaServerSummary, OpenHomePlaylistAddRequest, OpenHomePlaylistSnapshot, - PlayContentRequest, QueueItem, QueueSnapshot, RendererCapabilitiesSummary, - RendererProtocolSummary, RendererState, RendererSummary, SeekQueueRequest, SuccessResponse, - VolumeSetRequest, + FullRendererSnapshot, MediaServerSummary, PlayContentRequest, QueueSnapshot, + RendererCapabilitiesSummary, RendererProtocolSummary, RendererState, RendererSummary, + SeekQueueRequest, SuccessResponse, VolumeSetRequest, }; #[cfg(feature = "pmoserver")] -use crate::queue_backend::PlaybackItem; +use crate::queue::PlaybackItem; #[cfg(feature = "pmoserver")] -use crate::{PlaybackPosition, PlaybackStatus, TransportControl, VolumeControl}; +use crate::{DeviceId, DeviceIdentity, DeviceOnline}; #[cfg(feature = "pmoserver")] use async_trait::async_trait; @@ -49,8 +44,6 @@ use utoipa::OpenApi; #[cfg(feature = "pmoserver")] const BROWSE_PAGE_SIZE: u32 = 100; #[cfg(feature = "pmoserver")] -const MEDIA_SERVER_SOAP_TIMEOUT: Duration = Duration::from_secs(15); -#[cfg(feature = "pmoserver")] const BROWSE_REQUEST_TIMEOUT: Duration = Duration::from_secs(20); // Timeouts for simple commands (play/pause/stop) @@ -105,12 +98,12 @@ async fn list_renderers(State(state): State) -> Json, Path(renderer_id): Path, ) -> Result, (StatusCode, Json)> { - let rid = ServiceId(renderer_id.clone()); + let rid = DeviceId(renderer_id.clone()); let snapshot = state .control_point .renderer_full_snapshot(&rid) @@ -162,7 +155,7 @@ async fn get_renderer_full_snapshot( State(state): State, Path(renderer_id): Path, ) -> Result, (StatusCode, Json)> { - let rid = ServiceId(renderer_id.clone()); + let rid = DeviceId(renderer_id.clone()); let snapshot = state .control_point .renderer_full_snapshot(&rid) @@ -193,7 +186,7 @@ async fn get_renderer_queue( State(state): State, Path(renderer_id): Path, ) -> Result, (StatusCode, Json)> { - let rid = ServiceId(renderer_id.clone()); + let rid = DeviceId(renderer_id.clone()); let snapshot = state .control_point .renderer_full_snapshot(&rid) @@ -220,7 +213,7 @@ async fn get_renderer_binding( State(state): State, Path(renderer_id): Path, ) -> Result>, (StatusCode, Json)> { - let rid = ServiceId(renderer_id.clone()); + let rid = DeviceId(renderer_id.clone()); let snapshot = state .control_point .renderer_full_snapshot(&rid) @@ -252,7 +245,7 @@ async fn play_renderer( State(state): State, Path(renderer_id): Path, ) -> Result, (StatusCode, Json)> { - let rid = ServiceId(renderer_id.clone()); + let rid = DeviceId(renderer_id.clone()); let renderer = state .control_point .music_renderer_by_id(&rid) @@ -328,7 +321,7 @@ async fn pause_renderer( State(state): State, Path(renderer_id): Path, ) -> Result, (StatusCode, Json)> { - let rid = ServiceId(renderer_id.clone()); + let rid = DeviceId(renderer_id.clone()); let renderer = state .control_point .music_renderer_by_id(&rid) @@ -404,7 +397,7 @@ async fn stop_renderer( State(state): State, Path(renderer_id): Path, ) -> Result, (StatusCode, Json)> { - let rid = ServiceId(renderer_id.clone()); + let rid = DeviceId(renderer_id.clone()); state .control_point .music_renderer_by_id(&rid) @@ -481,7 +474,7 @@ async fn resume_renderer( State(state): State, Path(renderer_id): Path, ) -> Result, (StatusCode, Json)> { - let rid = ServiceId(renderer_id.clone()); + let rid = DeviceId(renderer_id.clone()); state .control_point .music_renderer_by_id(&rid) @@ -562,7 +555,7 @@ async fn next_renderer( State(state): State, Path(renderer_id): Path, ) -> Result, (StatusCode, Json)> { - let rid = ServiceId(renderer_id.clone()); + let rid = DeviceId(renderer_id.clone()); state .control_point .music_renderer_by_id(&rid) @@ -643,7 +636,7 @@ async fn seek_queue_index( Path(renderer_id): Path, Json(payload): Json, ) -> Result, (StatusCode, Json)> { - let rid = ServiceId(renderer_id.clone()); + let rid = DeviceId(renderer_id.clone()); state .control_point .music_renderer_by_id(&rid) @@ -659,9 +652,8 @@ async fn seek_queue_index( let control_point = Arc::clone(&state.control_point); let rid_for_task = rid.clone(); let index = payload.index; - let seek_task = tokio::task::spawn_blocking(move || { - control_point.play_queue_index(&rid_for_task, index) - }); + let seek_task = + tokio::task::spawn_blocking(move || control_point.play_queue_index(&rid_for_task, index)); time::timeout(TRANSPORT_COMMAND_TIMEOUT, seek_task) .await @@ -729,7 +721,7 @@ async fn set_renderer_volume( Path(renderer_id): Path, Json(req): Json, ) -> Result, (StatusCode, Json)> { - let rid = ServiceId(renderer_id.clone()); + let rid = DeviceId(renderer_id.clone()); let renderer = state .control_point @@ -807,7 +799,7 @@ async fn volume_up_renderer( State(state): State, Path(renderer_id): Path, ) -> Result, (StatusCode, Json)> { - let rid = ServiceId(renderer_id.clone()); + let rid = DeviceId(renderer_id.clone()); let renderer = state .control_point @@ -892,7 +884,7 @@ async fn volume_down_renderer( State(state): State, Path(renderer_id): Path, ) -> Result, (StatusCode, Json)> { - let rid = ServiceId(renderer_id.clone()); + let rid = DeviceId(renderer_id.clone()); let renderer = state .control_point @@ -977,7 +969,7 @@ async fn toggle_mute_renderer( State(state): State, Path(renderer_id): Path, ) -> Result, (StatusCode, Json)> { - let rid = ServiceId(renderer_id.clone()); + let rid = DeviceId(renderer_id.clone()); let renderer = state .control_point @@ -1064,8 +1056,8 @@ async fn attach_playlist_binding( Path(renderer_id): Path, Json(req): Json, ) -> Result, (StatusCode, Json)> { - let rid = ServiceId(renderer_id.clone()); - let sid = ServerId(req.server_id.clone()); + let rid = DeviceId(renderer_id.clone()); + let sid = DeviceId(req.server_id.clone()); let container_id = req.container_id.clone(); let control_point = Arc::clone(&state.control_point); @@ -1146,7 +1138,7 @@ async fn detach_playlist_binding( State(state): State, Path(renderer_id): Path, ) -> Json { - let rid = ServiceId(renderer_id.clone()); + let rid = DeviceId(renderer_id.clone()); state.control_point.detach_queue_playlist(&rid); @@ -1160,298 +1152,6 @@ async fn detach_playlist_binding( }) } -// ============================================================================ -// HANDLERS - OPENHOME PLAYLIST -// ============================================================================ - -/// GET /control/renderers/{renderer_id}/oh/playlist - Snapshot de la playlist OH -#[cfg(feature = "pmoserver")] -#[utoipa::path( - get, - path = "/renderers/{renderer_id}/oh/playlist", - params( - ("renderer_id" = String, Path, description = "ID unique du renderer") - ), - responses( - (status = 200, description = "Playlist OpenHome", body = OpenHomePlaylistSnapshot), - (status = 404, description = "Renderer non trouvé ou sans service OH", body = ErrorResponse) - ), - tag = "control" -)] -// async fn get_openhome_playlist( -// State(state): State, -// Path(renderer_id): Path, -// ) -> Result, (StatusCode, Json)> { -// let rid = RendererId(renderer_id.clone()); -// let control_point = Arc::clone(&state.control_point); -// let rid_for_task = rid.clone(); - -// let fetch_task = tokio::task::spawn_blocking(move || { -// control_point.get_cached_openhome_playlist_snapshot( -// &rid_for_task, -// OPENHOME_SNAPSHOT_CACHE_TTL, -// ) -// }); - -// let snapshot = fetch_task -// .await -// .map_err(|e| { -// warn!( -// renderer = renderer_id.as_str(), -// error = %e, -// "Join error while fetching OpenHome playlist" -// ); -// ( -// StatusCode::INTERNAL_SERVER_ERROR, -// Json(ErrorResponse { -// error: format!("Internal task error: {}", e), -// }), -// ) -// })? -// .map_err(|e| { -// warn!( -// renderer = renderer_id.as_str(), -// error = %e, -// "Failed to read OpenHome playlist" -// ); -// map_openhome_error(&rid, e, "read OpenHome playlist") -// })?; - -// Ok(Json(snapshot)) -// } - -/// POST /control/renderers/{renderer_id}/oh/playlist/clear - Vide la playlist OH -#[cfg(feature = "pmoserver")] -#[utoipa::path( - post, - path = "/renderers/{renderer_id}/oh/playlist/clear", - params( - ("renderer_id" = String, Path, description = "ID unique du renderer") - ), - responses( - (status = 200, description = "Playlist vidée", body = SuccessResponse), - (status = 404, description = "Renderer non trouvé ou sans service OH", body = ErrorResponse) - ), - tag = "control" -)] -async fn clear_openhome_playlist( - State(state): State, - Path(renderer_id): Path, -) -> Result, (StatusCode, Json)> { - let rid = ServiceId(renderer_id.clone()); - let control_point = Arc::clone(&state.control_point); - let rid_for_task = rid.clone(); - - let clear_task = - tokio::task::spawn_blocking(move || control_point.clear_renderer_queue(&rid_for_task)); - - time::timeout(QUEUE_COMMAND_TIMEOUT, clear_task) - .await - .map_err(|_| { - warn!( - renderer = renderer_id.as_str(), - timeout = QUEUE_COMMAND_TIMEOUT.as_secs(), - "Clearing OpenHome playlist timed out" - ); - ( - StatusCode::GATEWAY_TIMEOUT, - Json(ErrorResponse { - error: format!( - "Clear playlist timed out after {}s", - QUEUE_COMMAND_TIMEOUT.as_secs() - ), - }), - ) - })? - .map_err(|e| { - warn!( - renderer = renderer_id.as_str(), - error = %e, - "Join error while clearing OpenHome playlist" - ); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ErrorResponse { - error: format!("Internal task error: {}", e), - }), - ) - })? - .map_err(|e| { - warn!( - renderer = renderer_id.as_str(), - error = %e, - "Failed to clear OpenHome playlist" - ); - map_openhome_error(&rid, e, "clear OpenHome playlist") - })?; - - Ok(Json(SuccessResponse { - message: "OpenHome playlist cleared".to_string(), - })) -} - -/// POST /control/renderers/{renderer_id}/oh/playlist/add - Ajoute un track OH -#[cfg(feature = "pmoserver")] -#[utoipa::path( - post, - path = "/renderers/{renderer_id}/oh/playlist/add", - params( - ("renderer_id" = String, Path, description = "ID unique du renderer") - ), - request_body = OpenHomePlaylistAddRequest, - responses( - (status = 200, description = "Track ajouté", body = SuccessResponse), - (status = 404, description = "Renderer non trouvé ou sans service OH", body = ErrorResponse) - ), - tag = "control" -)] -async fn add_openhome_playlist_item( - State(state): State, - Path(renderer_id): Path, - Json(req): Json, -) -> Result, (StatusCode, Json)> { - let rid = ServiceId(renderer_id.clone()); - let control_point = Arc::clone(&state.control_point); - let rid_for_task = rid.clone(); - - let add_task = tokio::task::spawn_blocking(move || { - control_point.add_track_to_renderer( - &rid_for_task, - &req.uri, - &req.metadata, - req.after_id, - req.play, - ) - .map(|_| ()) // Ignore the track_id result for backward compatibility - }); - - time::timeout(QUEUE_COMMAND_TIMEOUT, add_task) - .await - .map_err(|_| { - warn!( - renderer = renderer_id.as_str(), - timeout = QUEUE_COMMAND_TIMEOUT.as_secs(), - "Adding OpenHome track timed out" - ); - ( - StatusCode::GATEWAY_TIMEOUT, - Json(ErrorResponse { - error: format!( - "Add track timed out after {}s", - QUEUE_COMMAND_TIMEOUT.as_secs() - ), - }), - ) - })? - .map_err(|e| { - warn!( - renderer = renderer_id.as_str(), - error = %e, - "Join error while adding OpenHome track" - ); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ErrorResponse { - error: format!("Internal task error: {}", e), - }), - ) - })? - .map_err(|e| { - warn!( - renderer = renderer_id.as_str(), - error = %e, - "Failed to add OpenHome track" - ); - map_openhome_error(&rid, e, "add OpenHome track") - })?; - - Ok(Json(SuccessResponse { - message: "Track added to OpenHome playlist".to_string(), - })) -} - -/// POST /control/renderers/{renderer_id}/oh/playlist/play/{track_id} - PlayId OH -#[cfg(feature = "pmoserver")] -#[utoipa::path( - post, - path = "/renderers/{renderer_id}/oh/playlist/play/{track_id}", - params( - ("renderer_id" = String, Path, description = "ID unique du renderer"), - ("track_id" = String, Path, description = "ID OpenHome du morceau") - ), - responses( - (status = 200, description = "Lecture démarrée", body = SuccessResponse), - (status = 404, description = "Renderer non trouvé ou sans service OH", body = ErrorResponse) - ), - tag = "control" -)] -async fn play_openhome_track( - State(state): State, - Path((renderer_id, track_id)): Path<(String, String)>, -) -> Result, (StatusCode, Json)> { - let rid = ServiceId(renderer_id.clone()); - let parsed_id = track_id.parse::().map_err(|e| { - ( - StatusCode::BAD_REQUEST, - Json(ErrorResponse { - error: format!("Invalid track id '{}': {}", track_id, e), - }), - ) - })?; - - let control_point = Arc::clone(&state.control_point); - let rid_for_task = rid.clone(); - - let play_task = tokio::task::spawn_blocking(move || { - control_point.select_renderer_track(&rid_for_task, parsed_id) - }); - - time::timeout(QUEUE_COMMAND_TIMEOUT, play_task) - .await - .map_err(|_| { - warn!( - renderer = renderer_id.as_str(), - timeout = QUEUE_COMMAND_TIMEOUT.as_secs(), - "PlayId command timed out" - ); - ( - StatusCode::GATEWAY_TIMEOUT, - Json(ErrorResponse { - error: format!( - "Play track timed out after {}s", - QUEUE_COMMAND_TIMEOUT.as_secs() - ), - }), - ) - })? - .map_err(|e| { - warn!( - renderer = renderer_id.as_str(), - error = %e, - "Join error while playing OpenHome track" - ); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ErrorResponse { - error: format!("Internal task error: {}", e), - }), - ) - })? - .map_err(|e| { - warn!( - renderer = renderer_id.as_str(), - error = %e, - track_id = parsed_id, - "Failed to start OpenHome track" - ); - map_openhome_error(&rid, e, "play OpenHome track") - })?; - - Ok(Json(SuccessResponse { - message: format!("Playing OpenHome track {}", parsed_id), - })) -} - // ============================================================================ // HANDLERS - QUEUE CONTENT // ============================================================================ @@ -1477,8 +1177,8 @@ async fn play_content( Path(renderer_id): Path, Json(req): Json, ) -> Result, (StatusCode, Json)> { - let rid = ServiceId(renderer_id.clone()); - let sid = ServerId(req.server_id.clone()); + let rid = DeviceId(renderer_id.clone()); + let sid = DeviceId(req.server_id.clone()); let object_id = req.object_id.clone(); let object_id_for_log = object_id.clone(); @@ -1606,8 +1306,8 @@ async fn add_to_queue( Path(renderer_id): Path, Json(req): Json, ) -> Result, (StatusCode, Json)> { - let rid = ServiceId(renderer_id.clone()); - let sid = ServerId(req.server_id.clone()); + let rid = DeviceId(renderer_id.clone()); + let sid = DeviceId(req.server_id.clone()); let object_id = req.object_id.clone(); let object_id_for_log = object_id.clone(); @@ -1707,15 +1407,15 @@ async fn add_to_queue( tag = "control" )] async fn list_servers(State(state): State) -> Json> { - let servers = state.control_point.list_media_servers(); + let servers = state.control_point.list_media_servers().unwrap_or_default(); let summaries: Vec = servers .into_iter() .map(|s| MediaServerSummary { - id: s.id.0, - friendly_name: s.friendly_name, - model_name: s.model_name, - online: s.online, + id: s.id().0.clone(), + friendly_name: s.friendly_name().to_string(), + model_name: s.model_name().to_string(), + online: s.is_online(), }) .collect(); @@ -1742,9 +1442,9 @@ async fn browse_container( State(state): State, Path((server_id, container_id)): Path<(String, String)>, ) -> Result, (StatusCode, Json)> { - let sid = ServerId(server_id.clone()); + let sid = DeviceId(server_id.clone()); - let server_info = state.control_point.media_server(&sid).ok_or_else(|| { + let server = state.control_point.media_server(&sid).ok_or_else(|| { ( StatusCode::NOT_FOUND, Json(ErrorResponse { @@ -1753,7 +1453,7 @@ async fn browse_container( ) })?; - if !server_info.online { + if !server.is_online() { return Err(( StatusCode::SERVICE_UNAVAILABLE, Json(ErrorResponse { @@ -1762,7 +1462,7 @@ async fn browse_container( )); } - if !server_info.has_content_directory { + if !server.has_content_directory() { return Err(( StatusCode::NOT_IMPLEMENTED, Json(ErrorResponse { @@ -1771,21 +1471,11 @@ async fn browse_container( )); } - let music_server = - UpnpMediaServer::from_info(&server_info, MEDIA_SERVER_SOAP_TIMEOUT).map_err(|e| { - warn!("Failed to create MusicServer for {}: {}", server_id, e); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ErrorResponse { - error: format!("Failed to initialize server: {}", e), - }), - ) - })?; - // Use spawn_blocking to avoid blocking the async runtime with synchronous SOAP calls let container_id_clone = container_id.clone(); + let server_clone = server.clone(); let browse_task = tokio::task::spawn_blocking(move || { - music_server.browse_children(&container_id_clone, 0, BROWSE_PAGE_SIZE) + server_clone.browse_children(&container_id_clone, 0, BROWSE_PAGE_SIZE) }); let entries = time::timeout(BROWSE_REQUEST_TIMEOUT, browse_task) @@ -1869,32 +1559,6 @@ fn map_snapshot_error( ) } -#[cfg(feature = "pmoserver")] -fn map_openhome_error( - renderer_id: &ServiceId, - err: anyhow::Error, - context: &str, -) -> (StatusCode, Json) { - if err.downcast_ref::().is_some() { - ( - StatusCode::NOT_FOUND, - Json(ErrorResponse { - error: err.to_string(), - }), - ) - } else { - ( - StatusCode::BAD_GATEWAY, - Json(ErrorResponse { - error: format!( - "Failed to {context} for renderer {}: {}", - renderer_id.0, err - ), - }), - ) - } -} - /// Helper to fetch playback items from a media server object (container or item). /// /// This function browses the server to get the entries and converts them to PlaybackItem. @@ -1902,30 +1566,27 @@ fn map_openhome_error( #[cfg(feature = "pmoserver")] fn fetch_playback_items( control_point: &ControlPoint, - server_id: &ServerId, + server_id: &DeviceId, object_id: &str, ) -> anyhow::Result> { - // Get server info from registry - let server_info = control_point + // Get server from registry + let server = control_point .media_server(server_id) .ok_or_else(|| anyhow::anyhow!("Server {} not found", server_id.0))?; - if !server_info.online { + if !server.is_online() { return Err(anyhow::anyhow!("Server {} is offline", server_id.0)); } - if !server_info.has_content_directory { + if !server.has_content_directory() { return Err(anyhow::anyhow!( "Server {} does not support ContentDirectory", server_id.0 )); } - // Create MusicServer - let music_server = UpnpMediaServer::from_info(&server_info, MEDIA_SERVER_SOAP_TIMEOUT)?; - // Browse the object to get entries - let entries = music_server.browse_children(object_id, 0, BROWSE_PAGE_SIZE)?; + let entries = server.browse_children(object_id, 0, BROWSE_PAGE_SIZE)?; debug!( server_id = server_id.0.as_str(), @@ -1939,7 +1600,7 @@ fn fetch_playback_items( // Convert to PlaybackItem let items: Vec = entries .iter() - .filter_map(|entry| playback_item_from_entry(&music_server, entry)) + .filter_map(|entry| playback_item_from_entry(server.clone(), entry)) .collect(); if items.is_empty() && !entries.is_empty() { @@ -2009,7 +1670,10 @@ pub fn create_api_router(state: ControlPointState, control_point: Arc MusicQueue; - fn build_from_renderer_info( - renderer: &RendererInfo, - ) -> Result + fn build_from_renderer_info(renderer: &RendererInfo) -> Result where Self: Sized, { @@ -42,4 +40,4 @@ pub trait QueueFromRendererInfo { let backend = Self::build_from_renderer_info(renderer)?; Ok(Arc::new(Mutex::new(backend))) } -} \ No newline at end of file +} diff --git a/pmocontrol/src/queue/openhome.rs b/pmocontrol/src/queue/openhome.rs index 11abe994..1bf3d102 100644 --- a/pmocontrol/src/queue/openhome.rs +++ b/pmocontrol/src/queue/openhome.rs @@ -5,7 +5,8 @@ use tracing::debug; use crate::errors::ControlPointError; use crate::upnp_clients::{ - OPENHOME_PLAYLIST_HEAD_ID, OhInfoClient, OhPlaylistClient, OhProductClient, OhTrack, OhTrackEntry + OPENHOME_PLAYLIST_HEAD_ID, OhInfoClient, OhPlaylistClient, OhProductClient, OhTrack, + OhTrackEntry, }; // use crate::openhome_playlist::{OpenHomePlaylistSnapshot, OpenHomePlaylistTrack}; use crate::queue::{ @@ -253,8 +254,8 @@ impl OpenHomeQueue { self.delete_marked_items(&old_ids_before, &keep_old_before, "BEFORE pivot")?; // Rebuild the playlist: [BEFORE, PIVOT, AFTER] - // Rebuild BEFORE part - let previous_id = self.rebuild_playlist_section( + // Rebuild BEFORE part (we don't need the returned previous_id) + self.rebuild_playlist_section( new_before, &keep_new_before, &old_ids_before, @@ -263,7 +264,7 @@ impl OpenHomeQueue { "BEFORE pivot", )?; - // Add PIVOT (keeps its ID!) + // PIVOT keeps its ID and position - it's the anchor point let previous_id = pivot_id as u32; debug!( renderer = self.renderer_id.0.as_str(), @@ -513,7 +514,9 @@ impl QueueBackend for OpenHomeQueue { } fn current_track(&self) -> Result, ControlPointError> { - Ok(Some(self.playlist_client.id()?)) + let id = self.playlist_client.id()?; + // OpenHome returns 0 when no track is selected/playing + if id == 0 { Ok(None) } else { Ok(Some(id)) } } fn current_index(&self) -> Result, ControlPointError> { diff --git a/pmocontrol/src/registry.rs b/pmocontrol/src/registry.rs index cb91e5bb..c36e8ce1 100644 --- a/pmocontrol/src/registry.rs +++ b/pmocontrol/src/registry.rs @@ -106,12 +106,12 @@ impl DeviceOnline for DeviceItem { } impl DeviceRegistry { - pub fn new(renderer_bus: RendererEventBus, server_bus: MediaServerEventBus) -> Self { + pub fn new(renderer_bus: &RendererEventBus, server_bus: &MediaServerEventBus) -> Self { Self { devices: HashMap::new(), udn_index: HashMap::new(), - renderer_bus, - server_bus, + renderer_bus: renderer_bus.clone(), + server_bus: server_bus.clone(), } } @@ -168,8 +168,8 @@ impl DeviceRegistry { if !was_online { self.renderer_bus.broadcast(RendererEvent::Online { - id: device_id, - info: info.clone(), + id: device_id.clone(), + info: info.basic_info(), }); } return; @@ -182,8 +182,8 @@ impl DeviceRegistry { // Broadcast sur le bon bus self.renderer_bus.broadcast(RendererEvent::Online { - id: device_id, - info: info.clone(), + id: device_id.clone(), + info: info.basic_info(), }); } } else { @@ -201,7 +201,7 @@ impl DeviceRegistry { // Broadcast sur le bon bus self.renderer_bus.broadcast(RendererEvent::Online { id: device_id, - info: info.clone(), + info: info.basic_info(), }); } } @@ -217,8 +217,8 @@ impl DeviceRegistry { if !was_online { self.server_bus.broadcast(MediaServerEvent::Online { - server_id: device_id, - info: info.clone(), + server_id: device_id.clone(), + info: info.basic_info(), }); } @@ -232,8 +232,8 @@ impl DeviceRegistry { // Broadcast sur le bon bus self.server_bus.broadcast(MediaServerEvent::Online { - server_id: device_id, - info: info.clone(), + server_id: device_id.clone(), + info: info.basic_info(), }); } } else { @@ -251,7 +251,7 @@ impl DeviceRegistry { // Broadcast sur le bon bus self.server_bus.broadcast(MediaServerEvent::Online { server_id: device_id, - info: info.clone(), + info: info.basic_info(), }); } } diff --git a/pmocontrol/src/sse.rs b/pmocontrol/src/sse.rs index 57f84278..c90e2af4 100644 --- a/pmocontrol/src/sse.rs +++ b/pmocontrol/src/sse.rs @@ -19,8 +19,6 @@ use crate::control_point::ControlPoint; #[cfg(feature = "pmoserver")] use crate::model::{MediaServerEvent, RendererEvent}; #[cfg(feature = "pmoserver")] -use crate::registry::DeviceRegistryRead; -#[cfg(feature = "pmoserver")] use async_stream::stream; #[cfg(feature = "pmoserver")] use axum::{ @@ -34,6 +32,9 @@ use serde::Serialize; #[cfg(feature = "pmoserver")] use std::sync::Arc; +use crate::{DeviceIdentity, DeviceOnline}; +use tracing::error; + // ============================================================================ // PAYLOADS SSE // ============================================================================ @@ -175,17 +176,23 @@ pub async fn renderer_events_sse( let initial_renderers = { let registry = control_point.registry(); let reg = registry.read().unwrap(); - reg.list_renderers() + match reg.list_renderers() { + Ok(renderers) => renderers, + Err(e) => { + error!("Failed to list renderers: {}", e); + Vec::new() + } + } }; - for info in initial_renderers { - if info.is_online() { + for renderer in initial_renderers { + if renderer.is_online() { let timestamp = chrono::Utc::now(); let payload = RendererEventPayload::Online { - renderer_id: info.id().0, - friendly_name: info.friendly_name().to_string(), - model_name: info. ().to_string(), - manufacturer: info.manufacturer().to_string(), + renderer_id: renderer.id().0, + friendly_name: renderer.friendly_name().to_string(), + model_name: renderer.model_name().to_string(), + manufacturer: renderer.manufacturer().to_string(), timestamp, }; @@ -258,9 +265,9 @@ pub async fn renderer_events_sse( RendererEvent::Online { id, info } => { RendererEventPayload::Online { renderer_id: id.0, - friendly_name: info.friendly_name, - model_name: info.model_name, - manufacturer: info.manufacturer, + friendly_name: info.friendly_name.clone(), + model_name: info.model_name.clone(), + manufacturer: info.manufacturer.clone(), timestamp, } } @@ -318,17 +325,23 @@ pub async fn media_server_events_sse( let initial_servers = { let registry = control_point.registry(); let reg = registry.read().unwrap(); - reg.list_servers() + match reg.list_servers() { + Ok(servers) => servers, + Err(e) => { + error!("Failed to list servers: {}", e); + Vec::new() + } + } }; - for info in initial_servers { - if info.online { + for server in initial_servers { + if server.is_online() { let timestamp = chrono::Utc::now(); let payload = MediaServerEventPayload::Online { - server_id: info.id.0.clone(), - friendly_name: info.friendly_name.clone(), - model_name: info.model_name.clone(), - manufacturer: info.manufacturer.clone(), + server_id: server.id().0, + friendly_name: server.friendly_name().to_string(), + model_name: server.model_name().to_string(), + manufacturer: server.manufacturer().to_string(), timestamp, }; @@ -360,9 +373,9 @@ pub async fn media_server_events_sse( MediaServerEvent::Online { server_id, info } => { MediaServerEventPayload::Online { server_id: server_id.0, - friendly_name: info.friendly_name, - model_name: info.model_name, - manufacturer: info.manufacturer, + friendly_name: info.friendly_name.clone(), + model_name: info.model_name.clone(), + manufacturer: info.manufacturer.clone(), timestamp, } } @@ -429,18 +442,32 @@ pub async fn all_events_sse(State(control_point): State>) -> i let (initial_renderers, initial_servers) = { let registry = control_point.registry(); let reg = registry.read().unwrap(); - (reg.list_renderers(), reg.list_servers()) + let renderers = match reg.list_renderers() { + Ok(r) => r, + Err(e) => { + error!("Failed to list renderers: {}", e); + Vec::new() + } + }; + let servers = match reg.list_servers() { + Ok(s) => s, + Err(e) => { + error!("Failed to list servers: {}", e); + Vec::new() + } + }; + (renderers, servers) }; // Send renderer Online events - for info in initial_renderers { - if info.online { + for renderer in initial_renderers { + if renderer.is_online() { let timestamp = chrono::Utc::now(); let renderer_payload = RendererEventPayload::Online { - renderer_id: info.id.0.clone(), - friendly_name: info.friendly_name.clone(), - model_name: info.model_name.clone(), - manufacturer: info.manufacturer.clone(), + renderer_id: renderer.id().0, + friendly_name: renderer.friendly_name().to_string(), + model_name: renderer.model_name().to_string(), + manufacturer: renderer.manufacturer().to_string(), timestamp, }; let payload = UnifiedEventPayload::Renderer(renderer_payload); @@ -452,14 +479,14 @@ pub async fn all_events_sse(State(control_point): State>) -> i } // Send server Online events - for info in initial_servers { - if info.online { + for server in initial_servers { + if server.is_online() { let timestamp = chrono::Utc::now(); let server_payload = MediaServerEventPayload::Online { - server_id: info.id.0.clone(), - friendly_name: info.friendly_name.clone(), - model_name: info.model_name.clone(), - manufacturer: info.manufacturer.clone(), + server_id: server.id().0, + friendly_name: server.friendly_name().to_string(), + model_name: server.model_name().to_string(), + manufacturer: server.manufacturer().to_string(), timestamp, }; let payload = UnifiedEventPayload::MediaServer(server_payload); @@ -535,9 +562,9 @@ pub async fn all_events_sse(State(control_point): State>) -> i RendererEvent::Online { id, info } => { RendererEventPayload::Online { renderer_id: id.0, - friendly_name: info.friendly_name, - model_name: info.model_name, - manufacturer: info.manufacturer, + friendly_name: info.friendly_name.clone(), + model_name: info.model_name.clone(), + manufacturer: info.manufacturer.clone(), timestamp, } } @@ -576,9 +603,9 @@ pub async fn all_events_sse(State(control_point): State>) -> i MediaServerEvent::Online { server_id, info } => { MediaServerEventPayload::Online { server_id: server_id.0, - friendly_name: info.friendly_name, - model_name: info.model_name, - manufacturer: info.manufacturer, + friendly_name: info.friendly_name.clone(), + model_name: info.model_name.clone(), + manufacturer: info.manufacturer.clone(), timestamp, } } diff --git a/pmocontrol/src/upnp_clients/avtransport_client.rs b/pmocontrol/src/upnp_clients/avtransport_client.rs index 088b5547..ce1dc839 100644 --- a/pmocontrol/src/upnp_clients/avtransport_client.rs +++ b/pmocontrol/src/upnp_clients/avtransport_client.rs @@ -5,10 +5,10 @@ use std::time::Duration; use crate::{ errors::ControlPointError, soap_client::{ - extract_child_text, handle_action_response, invoke_upnp_action, invoke_upnp_action_with_timeout + extract_child_text, handle_action_response, invoke_upnp_action, + invoke_upnp_action_with_timeout, }, }; -use anyhow::{Result, anyhow}; use pmoupnp::soap::SoapEnvelope; use xmltree::{Element, XMLNode}; @@ -54,10 +54,11 @@ impl AvTransportClient { ))); } - let envelope = call_result - .envelope - .as_ref() - .ok_or_else(|| ControlPointError::UpnpError(format!("Missing SOAP envelope in GetTransportInfo response")))?; + let envelope = call_result.envelope.as_ref().ok_or_else(|| { + ControlPointError::UpnpError(format!( + "Missing SOAP envelope in GetTransportInfo response" + )) + })?; parse_transport_info(envelope) } @@ -181,7 +182,11 @@ impl AvTransportClient { fn parse_transport_info(envelope: &SoapEnvelope) -> Result { let response = find_child_with_suffix(&envelope.body.content, "GetTransportInfoResponse") - .ok_or_else(|| ControlPointError::UpnpError(format!("Missing GetTransportInfoResponse element in SOAP body")))?; + .ok_or_else(|| { + ControlPointError::UpnpError(format!( + "Missing GetTransportInfoResponse element in SOAP body" + )) + })?; let current_transport_state = extract_child_text(response, "CurrentTransportState")?; let current_transport_status = extract_child_text(response, "CurrentTransportStatus")?; @@ -268,7 +273,6 @@ fn find_child_with_suffix<'a>(parent: &'a Element, suffix: &str) -> Option<&'a E }) } - #[cfg(test)] mod tests { use super::*; @@ -382,10 +386,11 @@ impl AvTransportClient { ))); } - let envelope = call_result - .envelope - .as_ref() - .ok_or_else(|| ControlPointError::UpnpError(format!("Missing SOAP envelope in GetPositionInfo response")))?; + let envelope = call_result.envelope.as_ref().ok_or_else(|| { + ControlPointError::UpnpError(format!( + "Missing SOAP envelope in GetPositionInfo response" + )) + })?; parse_position_info(envelope) } @@ -393,7 +398,9 @@ impl AvTransportClient { fn parse_position_info(envelope: &SoapEnvelope) -> Result { let response = find_child_with_suffix(&envelope.body.content, "GetPositionInfoResponse") - .ok_or_else(|| ControlPointError::UpnpError(format!("Missing GetPositionInfoResponse element")))?; + .ok_or_else(|| { + ControlPointError::UpnpError(format!("Missing GetPositionInfoResponse element")) + })?; // Helpers allow missing text (AVTransport allows empty durations) fn opt(parent: &Element, name: &str) -> Option { diff --git a/pmocontrol/src/upnp_clients/connection_manager_client.rs b/pmocontrol/src/upnp_clients/connection_manager_client.rs index a2ab2f21..26f60118 100644 --- a/pmocontrol/src/upnp_clients/connection_manager_client.rs +++ b/pmocontrol/src/upnp_clients/connection_manager_client.rs @@ -1,8 +1,10 @@ -use anyhow::{Result, anyhow}; - -use crate::{errors::ControlPointError, soap_client::{ - SoapCallResult, ensure_success, extract_child_text, extract_child_text_allow_empty, find_child_with_suffix, invoke_upnp_action, parse_upnp_error -}}; +use crate::{ + errors::ControlPointError, + soap_client::{ + ensure_success, extract_child_text, extract_child_text_allow_empty, find_child_with_suffix, + invoke_upnp_action, parse_upnp_error, + }, +}; #[derive(Debug, Clone)] pub struct ConnectionManagerClient { @@ -48,22 +50,25 @@ impl ConnectionManagerClient { ensure_success("GetProtocolInfo", &call_result)?; - let envelope = call_result - .envelope - .as_ref() - .ok_or_else(|| ControlPointError::UpnpError(format!("Missing SOAP envelope in GetProtocolInfo response")))?; + let envelope = call_result.envelope.as_ref().ok_or_else(|| { + ControlPointError::UpnpError(format!( + "Missing SOAP envelope in GetProtocolInfo response" + )) + })?; if let Some(err) = parse_upnp_error(envelope) { return Err(ControlPointError::UpnpError(format!( "GetProtocolInfo returned UPnP error {}: {} (HTTP status {})", - err.error_code, - err.error_description, - call_result.status + err.error_code, err.error_description, call_result.status ))); } let response = find_child_with_suffix(&envelope.body.content, "GetProtocolInfoResponse") - .ok_or_else(|| ControlPointError::UpnpError(format!("Missing GetProtocolInfoResponse element in SOAP body")))?; + .ok_or_else(|| { + ControlPointError::UpnpError(format!( + "Missing GetProtocolInfoResponse element in SOAP body" + )) + })?; let source_text = extract_child_text_allow_empty(response, "Source")?; let sink_text = extract_child_text_allow_empty(response, "Sink")?; @@ -85,24 +90,25 @@ impl ConnectionManagerClient { ensure_success("GetCurrentConnectionIDs", &call_result)?; - let envelope = call_result - .envelope - .as_ref() - .ok_or_else(|| ControlPointError::UpnpError(format!("Missing SOAP envelope in GetCurrentConnectionIDs response")))?; + let envelope = call_result.envelope.as_ref().ok_or_else(|| { + ControlPointError::UpnpError(format!( + "Missing SOAP envelope in GetCurrentConnectionIDs response" + )) + })?; if let Some(err) = parse_upnp_error(envelope) { return Err(ControlPointError::UpnpError(format!( "GetCurrentConnectionIDs returned UPnP error {}: {} (HTTP status {})", - err.error_code, - err.error_description, - call_result.status + err.error_code, err.error_description, call_result.status ))); } let response = find_child_with_suffix(&envelope.body.content, "GetCurrentConnectionIDsResponse") .ok_or_else(|| { - ControlPointError::UpnpError(format!("Missing GetCurrentConnectionIDsResponse element in SOAP body")) + ControlPointError::UpnpError(format!( + "Missing GetCurrentConnectionIDsResponse element in SOAP body" + )) })?; let ids_text = extract_child_text_allow_empty(response, "ConnectionIDs")?; @@ -118,9 +124,9 @@ impl ConnectionManagerClient { if value.is_empty() { continue; } - let parsed = value - .parse::() - .map_err(|_| ControlPointError::UpnpError(format!("Invalid ConnectionID value: {}", value)))?; + let parsed = value.parse::().map_err(|_| { + ControlPointError::UpnpError(format!("Invalid ConnectionID value: {}", value)) + })?; ids.push(parsed); } @@ -128,7 +134,10 @@ impl ConnectionManagerClient { } /// GetCurrentConnectionInfo - pub fn get_current_connection_info(&self, connection_id: i32) -> Result { + pub fn get_current_connection_info( + &self, + connection_id: i32, + ) -> Result { let connection_id_str = connection_id.to_string(); let args = [("ConnectionID", connection_id_str.as_str())]; @@ -141,33 +150,38 @@ impl ConnectionManagerClient { ensure_success("GetCurrentConnectionInfo", &call_result)?; - let envelope = call_result - .envelope - .as_ref() - .ok_or_else(|| ControlPointError::UpnpError(format!("Missing SOAP envelope in GetCurrentConnectionInfo response")))?; + let envelope = call_result.envelope.as_ref().ok_or_else(|| { + ControlPointError::UpnpError(format!( + "Missing SOAP envelope in GetCurrentConnectionInfo response" + )) + })?; if let Some(err) = parse_upnp_error(envelope) { return Err(ControlPointError::UpnpError(format!( "GetCurrentConnectionInfo returned UPnP error {}: {} (HTTP status {})", - err.error_code, - err.error_description, - call_result.status + err.error_code, err.error_description, call_result.status ))); } let response = find_child_with_suffix(&envelope.body.content, "GetCurrentConnectionInfoResponse") .ok_or_else(|| { - ControlPointError::UpnpError(format!("Missing GetCurrentConnectionInfoResponse element in SOAP body")) + ControlPointError::UpnpError(format!( + "Missing GetCurrentConnectionInfoResponse element in SOAP body" + )) })?; let rcs_id = extract_child_text(response, "RcsID")? .parse::() - .map_err(|_| ControlPointError::UpnpError(format!("Invalid RcsID value in response")))?; + .map_err(|_| { + ControlPointError::UpnpError(format!("Invalid RcsID value in response")) + })?; let av_transport_id = extract_child_text(response, "AVTransportID")? .parse::() - .map_err(|_| ControlPointError::UpnpError(format!("Invalid AVTransportID value in response")))?; + .map_err(|_| { + ControlPointError::UpnpError(format!("Invalid AVTransportID value in response")) + })?; let protocol_info = extract_child_text_allow_empty(response, "ProtocolInfo")?; let peer_connection_manager = @@ -175,7 +189,9 @@ impl ConnectionManagerClient { let peer_connection_id = extract_child_text(response, "PeerConnectionID")? .parse::() - .map_err(|_| ControlPointError::UpnpError(format!("Invalid PeerConnectionID value in response")))?; + .map_err(|_| { + ControlPointError::UpnpError(format!("Invalid PeerConnectionID value in response")) + })?; let direction = extract_child_text(response, "Direction")?; let status = extract_child_text(response, "Status")?; diff --git a/pmocontrol/src/upnp_clients/rendering_control_client.rs b/pmocontrol/src/upnp_clients/rendering_control_client.rs index 48225bba..3e7108d1 100644 --- a/pmocontrol/src/upnp_clients/rendering_control_client.rs +++ b/pmocontrol/src/upnp_clients/rendering_control_client.rs @@ -1,11 +1,10 @@ use crate::{ errors::ControlPointError, soap_client::{ - SoapCallResult, ensure_success, extract_child_text, find_child_with_suffix, - handle_action_response, invoke_upnp_action, parse_upnp_error, + ensure_success, extract_child_text, find_child_with_suffix, handle_action_response, + invoke_upnp_action, parse_upnp_error, }, }; -use anyhow::{Result, anyhow}; use tracing::debug; #[derive(Debug, Clone)] @@ -96,22 +95,23 @@ impl RenderingControlClient { ensure_success("GetMute", &call_result)?; - let envelope = call_result - .envelope - .as_ref() - .ok_or_else(|| ControlPointError::UpnpError(format!("Missing SOAP envelope in GetMute response")))?; + let envelope = call_result.envelope.as_ref().ok_or_else(|| { + ControlPointError::UpnpError(format!("Missing SOAP envelope in GetMute response")) + })?; if let Some(err) = parse_upnp_error(envelope) { return Err(ControlPointError::UpnpError(format!( "GetMute returned UPnP error {}: {} (HTTP status {})", - err.error_code, - err.error_description, - call_result.status + err.error_code, err.error_description, call_result.status ))); } let response = find_child_with_suffix(&envelope.body.content, "GetMuteResponse") - .ok_or_else(|| ControlPointError::UpnpError(format!("Missing GetMuteResponse element in SOAP body")))?; + .ok_or_else(|| { + ControlPointError::UpnpError(format!( + "Missing GetMuteResponse element in SOAP body" + )) + })?; let text = extract_child_text(response, "CurrentMute")?; let mute = match text.as_str() { diff --git a/pmodidl/src/lib.rs b/pmodidl/src/lib.rs index 1f1afda2..70fc3882 100644 --- a/pmodidl/src/lib.rs +++ b/pmodidl/src/lib.rs @@ -5,11 +5,11 @@ use bevy_reflect::Reflect; use pmoutils::ToXmlElement; use serde::{Deserialize, Serialize}; -use std::time::SystemTime; -use std::{borrow::Cow, time::Instant}; +use std::borrow::Cow; use std::collections::HashSet; use std::fmt::Write; use std::io::Cursor; +use std::time::SystemTime; use xmltree::{Element, XMLNode}; // ============= Couche d'abstraction générique ============= diff --git a/tools/check_a_file.sh b/tools/check_a_file.sh new file mode 100755 index 00000000..5726c3ec --- /dev/null +++ b/tools/check_a_file.sh @@ -0,0 +1,30 @@ +#!/bin/bash + +crate=$1 +pattern=$2 + +cargo check -p "${crate}" 2>&1 \ +| awk -v pattern="${pattern}" ' + /^warning/ || /^error/ { + if (inmsg && file ~ pattern) { + print message"\n===============\n" + } + inmsg = 1 + start = 1 + message = $0 + next + } + start { + file = $NF + start = 0 + message = message"\n"$0 + next + } + inmsg { + message = message"\n"$0 + } + END { + if (inmsg && file ~ pattern) { + print message"\n===============\n" + } + }'