debug media server suite
This commit is contained in:
@@ -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: {}",
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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()),
|
||||
|
||||
@@ -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]))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<63>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
|
||||
}
|
||||
|
||||
|
||||
142
tools/discover_upnp_devices.sh
Executable file
142
tools/discover_upnp_devices.sh
Executable file
@@ -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
|
||||
132
tools/test_upnp_browse.sh
Executable file
132
tools/test_upnp_browse.sh
Executable file
@@ -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; 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 "<?xml version=\"1.0\"?>
|
||||
<s:Envelope xmlns:s=\"http://schemas.xmlsoap.org/soap/envelope/\" s:encodingStyle=\"http://schemas.xmlsoap.org/soap/encoding/\">
|
||||
<s:Body>
|
||||
<u:Browse xmlns:u=\"urn:schemas-upnp-org:service:ContentDirectory:1\">
|
||||
<ObjectID>$OBJECT_ID_ESCAPED</ObjectID>
|
||||
<BrowseFlag>$BROWSE_FLAG</BrowseFlag>
|
||||
<Filter>*</Filter>
|
||||
<StartingIndex>$STARTING_INDEX</StartingIndex>
|
||||
<RequestedCount>$REQUESTED_COUNT</RequestedCount>
|
||||
<SortCriteria></SortCriteria>
|
||||
</u:Browse>
|
||||
</s:Body>
|
||||
</s:Envelope>")
|
||||
|
||||
# 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
|
||||
Reference in New Issue
Block a user