diff --git a/pmoaudio-ext/src/sources/playlist_source.rs b/pmoaudio-ext/src/sources/playlist_source.rs index 9ad533fc..656c645b 100644 --- a/pmoaudio-ext/src/sources/playlist_source.rs +++ b/pmoaudio-ext/src/sources/playlist_source.rs @@ -314,7 +314,9 @@ impl NodeLogic for PlaylistSourceLogic { Ok(()) => { tracing::info!("PlaylistSource: finished track {} - {}", artist, title); // Piste décodée avec succès, transférer vers l'historique si configuré + tracing::warn!("🔍 HISTORY DEBUG: history_playlist is {:?}", if self.history_playlist.is_some() { "Some" } else { "None" }); if let Some(ref history) = self.history_playlist { + tracing::warn!("🔍 HISTORY DEBUG: Attempting to push cache_pk={} to history", cache_pk); if let Err(e) = history.push(cache_pk.to_string()).await { tracing::warn!( "PlaylistSourceLogic: failed to add track to history: {}", diff --git a/pmomediaserver/src/paradise_streaming.rs b/pmomediaserver/src/paradise_streaming.rs index 6d188da3..32a464b4 100644 --- a/pmomediaserver/src/paradise_streaming.rs +++ b/pmomediaserver/src/paradise_streaming.rs @@ -102,7 +102,7 @@ impl ParadiseStreamingExt for pmoserver::Server { // Créer le builder d'historique let mut history_builder = ParadiseHistoryBuilder::default(); - history_builder.playlist_prefix = "radioparadise-history".into(); + history_builder.playlist_prefix = "radio-paradise-history".into(); history_builder.playlist_title_prefix = Some("Radio Paradise History".into()); history_builder.max_history_tracks = Some(500); history_builder.collection_prefix = Some("radioparadise".into()); diff --git a/pmoparadise/examples/serve_channels.rs b/pmoparadise/examples/serve_channels.rs index 21f90f2a..deaf04b8 100644 --- a/pmoparadise/examples/serve_channels.rs +++ b/pmoparadise/examples/serve_channels.rs @@ -54,7 +54,7 @@ async fn main() -> anyhow::Result<()> { let history_builder = ParadiseHistoryBuilder { audio_cache: audio_cache.clone(), cover_cache: cover_cache.clone(), - playlist_prefix: "radioparadise-history".into(), + playlist_prefix: "radio-paradise-history".into(), playlist_title_prefix: Some("Radio Paradise History".into()), max_history_tracks: Some(500), collection_prefix: Some("radioparadise".into()), diff --git a/pmoparadise/src/source.rs b/pmoparadise/src/source.rs index e5eea9e5..2935c1a7 100644 --- a/pmoparadise/src/source.rs +++ b/pmoparadise/src/source.rs @@ -148,7 +148,8 @@ impl RadioParadiseSource { /// Get the playlist ID for a channel's history #[cfg(feature = "playlist")] fn history_playlist_id(slug: &str) -> String { - format!("radioparadise-history-{}", slug) + // Must match the prefix used in ParadiseHistoryBuilder + format!("radio-paradise-history-{}", slug) } /// Get channel descriptor by slug @@ -241,15 +242,33 @@ impl RadioParadiseSource { id: format!("radio-paradise:channel:{}:history", descriptor.slug), parent_id: format!("radio-paradise:channel:{}", descriptor.slug), restricted: Some("1".to_string()), - child_count: None, // Will be determined by playlist + child_count: Some("0".to_string()), // Default to 0, updated below if playlist exists searchable: Some("1".to_string()), title: format!("{} - History", descriptor.display_name), - class: "object.container.playlistContainer".to_string(), + class: "object.container".to_string(), containers: vec![], items: vec![], } } + /// Build a history container with accurate child count from playlist + #[cfg(feature = "playlist")] + async fn build_history_container_with_count(&self, descriptor: &ChannelDescriptor) -> Container { + let mut container = self.build_history_container(descriptor); + + // Try to get actual count from playlist + let playlist_id = Self::history_playlist_id(descriptor.slug); + let manager = pmoplaylist::PlaylistManager(); + + if let Ok(reader) = manager.get_read_handle(&playlist_id).await { + if let Ok(count) = reader.remaining().await { + container.child_count = Some(count.to_string()); + } + } + + container + } + /// Get items from history playlist #[cfg(feature = "playlist")] async fn get_history_items( @@ -267,10 +286,31 @@ impl RadioParadiseSource { })?; // Get items from playlist (to_items starts from cursor position) - let items = reader.to_items(count).await.map_err(|e| { + let mut items = reader.to_items(count).await.map_err(|e| { MusicSourceError::BrowseError(format!("Failed to read playlist entries: {}", e)) })?; + // Transform item IDs, parent_ids, and resource URLs to match Radio Paradise schema + // Expected: radio-paradise:channel:{slug}:history:track:{pk} + // Parent: radio-paradise:channel:{slug}:history + for item in items.iter_mut() { + // Extract cache_pk from the resource URL (last segment) + if let Some(resource) = item.resources.first_mut() { + if let Some(pk) = resource.url.split('/').last() { + // Update item ID and parent ID + item.id = format!("radio-paradise:channel:{}:history:track:{}", slug, pk); + item.parent_id = format!("radio-paradise:channel:{}:history", slug); + + // Convert relative URL to absolute URL + // From: /audio/flac/pk + // To: http://base_url/audio/flac/pk + if resource.url.starts_with('/') { + resource.url = format!("{}{}", self.base_url, resource.url); + } + } + } + } + Ok(items) } @@ -334,6 +374,10 @@ impl MusicSource for RadioParadiseSource { })?; let live_item = self.build_live_stream_item(descriptor); + + #[cfg(feature = "playlist")] + let history_container = self.build_history_container_with_count(descriptor).await; + #[cfg(not(feature = "playlist"))] let history_container = self.build_history_container(descriptor); Ok(BrowseResult::Mixed { @@ -343,15 +387,15 @@ impl MusicSource for RadioParadiseSource { } ObjectIdType::History { slug } => { - // Return history container as first element (for BrowseMetadata) - // followed by history items (for BrowseDirectChildren) + // Return history container (for BrowseMetadata) and items (for BrowseDirectChildren) + // The content_handler will filter out the container when browsing direct children let descriptor = Self::get_channel_by_slug(&slug).ok_or_else(|| { MusicSourceError::ObjectNotFound(format!("Unknown channel: {}", slug)) })?; - let history_container = self.build_history_container(descriptor); #[cfg(feature = "playlist")] { + let history_container = self.build_history_container_with_count(descriptor).await; let items = self.get_history_items(&slug, 0, 100).await?; Ok(BrowseResult::Mixed { containers: vec![history_container], @@ -361,6 +405,8 @@ impl MusicSource for RadioParadiseSource { #[cfg(not(feature = "playlist"))] { + // If playlist feature is disabled, return just the container + let history_container = self.build_history_container(descriptor); Ok(BrowseResult::Containers(vec![history_container])) } } diff --git a/pmoplaylist/src/manager.rs b/pmoplaylist/src/manager.rs index ebe50ca0..d28678c6 100644 --- a/pmoplaylist/src/manager.rs +++ b/pmoplaylist/src/manager.rs @@ -161,7 +161,7 @@ impl PlaylistManager { let playlists = self.inner.playlists.read().await; if let Some(playlist) = playlists.get(&id) { - // Playlist existe + // Playlist existe en mémoire if !playlist.persistent { return Err(crate::Error::PlaylistNotPersistent(id)); } @@ -176,7 +176,34 @@ impl PlaylistManager { drop(playlists); - // N'existe pas, cr�er persistent + // Pas en mémoire, essayer de charger depuis la DB + if let Some(persistence) = &self.inner.persistence { + if let Some((title, config, tracks)) = persistence.load_playlist(&id).await? { + // Reconstruire la playlist + let mut playlists = self.inner.playlists.write().await; + + let playlist = Arc::new(Playlist::new(id.clone(), title.clone(), config, true)); + + // Restaurer les tracks + { + let mut core = playlist.core.write().await; + core.tracks = tracks; + } + + // Acquérir le write lock + let write_token = playlist + .acquire_write_lock() + .await + .map_err(|_| crate::Error::WriteLockHeld(id.clone()))?; + + playlists.insert(id.clone(), playlist.clone()); + drop(playlists); + + return Ok(WriteHandle::new(playlist, write_token)); + } + } + + // N'existe pas en DB, créer une nouvelle playlist persistante self.create_persistent_playlist(id).await } diff --git a/tools/discover_upnp_devices.sh b/tools/discover_upnp_devices.sh new file mode 100755 index 00000000..6dacfcdb --- /dev/null +++ b/tools/discover_upnp_devices.sh @@ -0,0 +1,142 @@ +#!/bin/bash +# +# Script de découverte des devices UPnP via SSDP +# +# Usage: ./discover_upnp_devices.sh [timeout_seconds] + +set -e + +TIMEOUT="${1:-5}" + +echo "=== Découverte des devices UPnP ===" >&2 +echo "Timeout: ${TIMEOUT}s" >&2 +echo >&2 + +# Créer un socket UDP pour envoyer la requête SSDP +DISCOVERY_MESSAGE="M-SEARCH * HTTP/1.1\r +Host: 239.255.255.250:1900\r +Man: \"ssdp:discover\"\r +MX: ${TIMEOUT}\r +ST: upnp:rootdevice\r +\r +" + +# Envoyer la requête SSDP et collecter les réponses +echo "Envoi de la requête SSDP..." >&2 +echo >&2 + +# Utiliser Python pour écouter les réponses SSDP +python3 - <<'PYTHON_SCRIPT' "$TIMEOUT" +import socket +import sys +import time + +timeout = int(sys.argv[1]) + +# Créer un socket UDP +sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) +sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) +sock.settimeout(timeout) + +# Message SSDP M-SEARCH +msg = ( + 'M-SEARCH * HTTP/1.1\r\n' + 'Host: 239.255.255.250:1900\r\n' + 'Man: "ssdp:discover"\r\n' + 'MX: {}\r\n' + 'ST: upnp:rootdevice\r\n' + '\r\n' +).format(timeout) + +# Envoyer la requête au multicast SSDP +sock.sendto(msg.encode(), ('239.255.255.250', 1900)) + +print("Écoute des réponses SSDP...", file=sys.stderr) +print(file=sys.stderr) + +devices = {} +start_time = time.time() + +try: + while time.time() - start_time < timeout: + try: + data, addr = sock.recvfrom(8192) + response = data.decode('utf-8', errors='ignore') + + # Extraire l'URL de la description + location = None + server = None + usn = None + for line in response.split('\r\n'): + if line.lower().startswith('location:'): + location = line.split(':', 1)[1].strip() + elif line.lower().startswith('server:'): + server = line.split(':', 1)[1].strip() + elif line.lower().startswith('usn:'): + usn = line.split(':', 1)[1].strip() + + if location and location not in devices: + devices[location] = { + 'addr': addr[0], + 'server': server, + 'usn': usn + } + print(f"Trouvé: {location}", file=sys.stderr) + except socket.timeout: + break +except KeyboardInterrupt: + pass +finally: + sock.close() + +print(file=sys.stderr) +print(f"=== {len(devices)} device(s) trouvé(s) ===", file=sys.stderr) +print(file=sys.stderr) + +# Afficher les détails de chaque device +for location, info in devices.items(): + print(f"Device: {location}", file=sys.stderr) + print(f" IP: {info['addr']}", file=sys.stderr) + if info['server']: + print(f" Server: {info['server']}", file=sys.stderr) + if info['usn']: + print(f" USN: {info['usn']}", file=sys.stderr) + + # Récupérer la description XML + import urllib.request + try: + with urllib.request.urlopen(location, timeout=2) as response: + xml = response.read().decode('utf-8') + + # Parser le XML pour trouver les services + import xml.etree.ElementTree as ET + root = ET.fromstring(xml) + + # Namespaces UPnP + ns = { + 'device': 'urn:schemas-upnp-org:device-1-0', + 'service': 'urn:schemas-upnp-org:service-1-0' + } + + # Trouver le nom du device + device_name = root.find('.//device:friendlyName', ns) + if device_name is not None: + print(f" Name: {device_name.text}", file=sys.stderr) + + # Trouver le ContentDirectory service + for service in root.findall('.//device:service', ns): + service_type = service.find('device:serviceType', ns) + if service_type is not None and 'ContentDirectory' in service_type.text: + control_url = service.find('device:controlURL', ns) + if control_url is not None: + # Construire l'URL complète + from urllib.parse import urljoin + full_control_url = urljoin(location, control_url.text) + print(f" ContentDirectory Control URL: {full_control_url}", file=sys.stderr) + print(full_control_url) # Output pour utilisation dans scripts + except Exception as e: + print(f" Erreur lors de la récupération de la description: {e}", file=sys.stderr) + + print(file=sys.stderr) + +PYTHON_SCRIPT diff --git a/tools/test_upnp_browse.sh b/tools/test_upnp_browse.sh new file mode 100755 index 00000000..ee5ae30b --- /dev/null +++ b/tools/test_upnp_browse.sh @@ -0,0 +1,132 @@ +#!/bin/bash +# +# Script de test pour les requêtes Browse UPnP ContentDirectory +# +# Usage: ./test_upnp_browse.sh [options] +# +# Options: +# -u URL URL de contrôle du service ContentDirectory +# -o ID Object ID à parcourir (défaut: "0") +# -f FLAG BrowseFlag: BrowseMetadata ou BrowseDirectChildren (défaut: BrowseDirectChildren) +# -s INDEX StartingIndex (défaut: 0) +# -c COUNT RequestedCount (défaut: 0 = tous) +# -h Afficher cette aide + +set -e + +# Valeurs par défaut +CONTROL_URL="http://localhost:8080/device/88b84e76-4de0-4ee6-b794-99cc4a278cc9/service/ContentDirectory/control" +OBJECT_ID="0" +BROWSE_FLAG="BrowseDirectChildren" +STARTING_INDEX=0 +REQUESTED_COUNT=0 + +# Fonction d'aide +show_help() { + cat << EOF +Script de test pour les requêtes Browse UPnP ContentDirectory + +Usage: $0 [options] + +Options: + -u URL URL de contrôle du service ContentDirectory + (défaut: http://localhost:8080/device/.../ContentDirectory/control) + -o ID Object ID à parcourir (défaut: "0") + -f FLAG BrowseFlag: BrowseMetadata ou BrowseDirectChildren + (défaut: BrowseDirectChildren) + -s INDEX StartingIndex (défaut: 0) + -c COUNT RequestedCount (défaut: 0 = tous) + -h Afficher cette aide + +Exemples: + # Parcourir la racine + $0 + + # Parcourir un canal Radio Paradise + $0 -o "radio-paradise:channel:main" + + # Obtenir les métadonnées d'un container + $0 -o "radio-paradise:channel:main:history" -f BrowseMetadata + + # Parcourir l'historique + $0 -o "radio-paradise:channel:main:history" + +EOF +} + +# Parser les options +while getopts "u:o:f:s:c:h" opt; do + case $opt in + u) CONTROL_URL="$OPTARG" ;; + o) OBJECT_ID="$OPTARG" ;; + f) BROWSE_FLAG="$OPTARG" ;; + s) STARTING_INDEX="$OPTARG" ;; + c) REQUESTED_COUNT="$OPTARG" ;; + h) show_help; exit 0 ;; + \?) echo "Option invalide: -$OPTARG" >&2; show_help; exit 1 ;; + esac +done + +# Afficher les paramètres +echo "=== Test Browse UPnP ===" >&2 +echo "Control URL: $CONTROL_URL" >&2 +echo "Object ID: $OBJECT_ID" >&2 +echo "Browse Flag: $BROWSE_FLAG" >&2 +echo "Starting Index: $STARTING_INDEX" >&2 +echo "Requested Count: $REQUESTED_COUNT" >&2 +echo >&2 + +# Échapper l'Object ID pour XML +OBJECT_ID_ESCAPED=$(echo "$OBJECT_ID" | sed 's/&/\&/g; s//\>/g; s/"/\"/g; s/'"'"'/\'/g') + +# Construire et envoyer la requête SOAP +RESPONSE=$(curl -s -w "\nHTTP_STATUS:%{http_code}" -X POST "$CONTROL_URL" \ + -H "Content-Type: text/xml; charset=\"utf-8\"" \ + -H "SOAPACTION: \"urn:schemas-upnp-org:service:ContentDirectory:1#Browse\"" \ + -d " + + + + $OBJECT_ID_ESCAPED + $BROWSE_FLAG + * + $STARTING_INDEX + $REQUESTED_COUNT + + + +") + +# Extraire le code de statut HTTP +HTTP_STATUS=$(echo "$RESPONSE" | grep "HTTP_STATUS:" | cut -d: -f2) +BODY=$(echo "$RESPONSE" | sed '/HTTP_STATUS:/d') + +echo "=== HTTP Status: $HTTP_STATUS ===" >&2 +echo >&2 + +# Afficher la réponse formatée +if [ -n "$BODY" ]; then + echo "=== SOAP Response ===" >&2 + echo "$BODY" | xmllint --format - 2>&1 + + # Extraire et décoder le DIDL-Lite + DIDL=$(echo "$BODY" | xmllint --xpath "string(//Result)" - 2>/dev/null || true) + if [ -n "$DIDL" ]; then + echo >&2 + echo "=== DIDL-Lite Content ===" >&2 + echo "$DIDL" | xmllint --format - 2>&1 || echo "$DIDL" + fi + + # Afficher NumberReturned et TotalMatches + echo >&2 + echo "=== Statistics ===" >&2 + NUMBER_RETURNED=$(echo "$BODY" | xmllint --xpath "string(//NumberReturned)" - 2>/dev/null || echo "N/A") + TOTAL_MATCHES=$(echo "$BODY" | xmllint --xpath "string(//TotalMatches)" - 2>/dev/null || echo "N/A") + UPDATE_ID=$(echo "$BODY" | xmllint --xpath "string(//UpdateID)" - 2>/dev/null || echo "N/A") + echo "NumberReturned: $NUMBER_RETURNED" >&2 + echo "TotalMatches: $TOTAL_MATCHES" >&2 + echo "UpdateID: $UPDATE_ID" >&2 +else + echo "Aucune réponse du serveur" >&2 + exit 1 +fi