debug du upnp mediaserver

This commit is contained in:
2025-11-26 21:40:24 +01:00
parent e21fa5948e
commit ccd2112305
18 changed files with 1170 additions and 80 deletions

121
BROWSEMETADATA_FIX.md Normal file
View File

@@ -0,0 +1,121 @@
# BrowseMetadata Fix for Radio Paradise - PMO Music
**Date:** 2025-11-27
**Issue:** gupnp-av-cp failed to get metadata for live streams and history containers
## Problem
UPnP clients (like gupnp-av-cp) were unable to get metadata for:
- Live stream items (e.g., `radio-paradise:channel:mellow:live`)
- History containers (e.g., `radio-paradise:channel:mellow:history`)
Error:
```
Failed to get metadata for 'radio-paradise:channel:mellow:live'
Failed to get metadata for 'radio-paradise:channel:mellow:history'
```
## Root Cause
The UPnP ContentDirectory service has two browse modes:
- **BrowseMetadata**: Get metadata for a specific object (item or container)
- **BrowseDirectChildren**: Get the children of a container
The `ContentHandler::browse_metadata()` was calling `source.browse()` for all objects, but:
1. The `MusicSource::browse()` trait method is designed to return children, not object metadata
2. For leaf items (LiveStream, HistoryTrack), `RadioParadiseSource::browse()` was rejecting them as "cannot be browsed"
3. The trait doesn't provide a way to distinguish between BrowseMetadata and BrowseDirectChildren requests
## Solution
### 1. Modified ContentHandler ([pmomediaserver/src/content_handler.rs](pmomediaserver/src/content_handler.rs))
- `browse_metadata()` now tries `get_item()` first for leaf items before falling back to `browse()`
- This allows proper metadata retrieval for items (LiveStream, HistoryTrack)
### 2. Modified RadioParadiseSource ([pmoparadise/src/source.rs](pmoparadise/src/source.rs))
**For LiveStream items:**
- `browse()` now returns `BrowseResult::Items([live_item])` with the item's metadata
- This supports both BrowseMetadata (via ContentHandler) and direct browse calls
**For HistoryTrack items:**
- `browse()` now returns `BrowseResult::Items([track])` using `get_item()` internally
- Properly retrieves track metadata from the history playlist
**For History containers:**
- `browse()` now returns `BrowseResult::Mixed { containers: [history_container], items: [tracks] }`
- Provides both container metadata and its children in one result
### 3. Added Container Filtering ([pmomediaserver/src/content_handler.rs](pmomediaserver/src/content_handler.rs))
- `browse_result_to_didl()` now filters out containers that match the browsed `object_id`
- Prevents containers from appearing as children of themselves
- For History: BrowseDirectChildren returns only tracks, not the container
## Files Modified
1. ✅ [pmomediaserver/src/content_handler.rs](pmomediaserver/src/content_handler.rs)
- Lines 120-135: Try get_item() first in browse_metadata()
- Lines 290-310: Added object_id parameter and container filtering in browse_result_to_didl()
- Lines 212, 286: Updated callers to pass object_id
2. ✅ [pmoparadise/src/source.rs](pmoparadise/src/source.rs)
- Lines 345-369: Modified History browse to return Mixed (container + items)
- Lines 371-377: Modified LiveStream browse to return item metadata
- Lines 379-383: Modified HistoryTrack browse to return track metadata
## Validation
### Live Stream Metadata ✅
```bash
curl -X POST -H "SOAPAction: ..." BrowseMetadata radio-paradise:channel:mellow:live
```
Returns:
```xml
<item id="radio-paradise:channel:mellow:live" parentID="radio-paradise:channel:mellow">
<dc:title>Unknown Title</dc:title>
<upnp:class>object.item.audioItem.audioBroadcast</upnp:class>
<res protocolInfo="http-get:*:audio/flac:*">http://.../radioparadise/stream/mellow/flac</res>
</item>
```
### History Container Metadata ✅
```bash
curl -X POST -H "SOAPAction: ..." BrowseMetadata radio-paradise:channel:mellow:history
```
Returns:
```xml
<container id="radio-paradise:channel:mellow:history" parentID="radio-paradise:channel:mellow">
<dc:title>Mellow Mix - History</dc:title>
<upnp:class>object.container.playlistContainer</upnp:class>
</container>
```
### History Children ✅
```bash
curl -X POST -H "SOAPAction: ..." BrowseDirectChildren radio-paradise:channel:mellow:history
```
Returns only track items (not the container itself)
## Design Notes
This solution works around a fundamental limitation in the `MusicSource` trait:
- The `browse()` method doesn't receive the `browse_flag` parameter
- It can't distinguish between BrowseMetadata and BrowseDirectChildren
- We use `get_item()` for items and `browse()` for containers
- Container filtering ensures correct BrowseDirectChildren behavior
## Testing Checklist
- [x] BrowseMetadata works for LiveStream items
- [x] BrowseMetadata works for History containers
- [x] BrowseDirectChildren works for History (returns only tracks)
- [x] Container filtering prevents self-reference
- [ ] Test with BubbleUPnP (user to verify)
- [ ] Test with gupnp-av-cp (user to verify)
## References
- Original issue report: [UPNP_FIX_SUMMARY.md](UPNP_FIX_SUMMARY.md)
- UPnP AV Architecture: https://openconnectivity.org/developer/specifications/upnp-resources/upnp/

Binary file not shown.

Binary file not shown.

View File

@@ -0,0 +1,21 @@
host:
http_port: '8080'
cover_cache:
directory: cache_covers
size: 2000
audio_cache:
directory: cache_audio
size: 500
logger:
buffer_capacity: 200
enable_console: true
min_level: INFO
playlists:
directory: playlists
devices:
mediarenderer:
pmo_mediarenderer:
udn: f77de90b-3a4a-408c-8462-3308ad500744
mediaserver:
pmo_mediaserver:
udn: 88b84e76-4de0-4ee6-b794-99cc4a278cc9

Binary file not shown.

View File

@@ -1,6 +1,6 @@
use pmoapp::{WebAppExt, Webapp}; use pmoapp::{WebAppExt, Webapp};
use pmomediarenderer::MEDIA_RENDERER; use pmomediarenderer::MEDIA_RENDERER;
use pmomediaserver::{MEDIA_SERVER, ParadiseStreamingExt, sources::SourcesExt}; use pmomediaserver::{MEDIA_SERVER, MediaServerDeviceExt, ParadiseStreamingExt, sources::SourcesExt};
use pmoserver::Server; use pmoserver::Server;
use pmosource::MusicSourceExt; use pmosource::MusicSourceExt;
use pmoupnp::UpnpServerExt; use pmoupnp::UpnpServerExt;
@@ -81,6 +81,9 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
.await .await
.expect("Failed to register MediaServer"); .expect("Failed to register MediaServer");
// Initialiser les ProtocolInfo du MediaServer
server_instance.init_protocol_info();
info!( info!(
"✅ MediaServer ready at {}{}", "✅ MediaServer ready at {}{}",
server_instance.base_url(), server_instance.base_url(),

243
UPNP_ANALYSIS_REPORT.md Normal file
View File

@@ -0,0 +1,243 @@
# Rapport d'Analyse UPnP - PMO Music vs Serveurs Fonctionnels
**Date:** 2025-11-26
**Problème:** Le serveur UPnP de PMO Music n'est pas reconnu par BubbleUPnP
## Résumé Exécutif
Le serveur PMO Music MediaServer est correctement découvert via SSDP et répond aux requêtes SOAP, mais présente plusieurs différences avec les serveurs qui fonctionnent (comme Upmpdcli). Les problèmes identifiés sont principalement liés aux en-têtes HTTP et aux métadonnées du device.
## Découverte Réseau
### Devices UPnP Détectés
| Device | IP | USN | Status |
|--------|------|-----|---------|
| PMO Music MediaServer | 192.168.0.138:8080 | uuid:8b8e9b19-9c65-4d59-b127-b34717658085 | ✅ Découvert |
| Upmpdcli (pizzicato) | 192.168.0.200:49152 | uuid:c110358f-d885-b44a-d6d3-dca6329ead0d | ✅ Découvert |
| Freebox | 192.168.0.254:52424 | uuid:e929a46e-d218-377d-2dde-32bd8080dfbf | ✅ Découvert |
| Jellyfin | 192.168.0.34:8096 | uuid:526dedec-fde2-4224-bac6-06f7b11711cf | ✅ Découvert |
**Conclusion SSDP:** ✅ PMO Music est correctement annoncé et découvert via SSDP
## Comparaison des Descripteurs XML
### PMO Music MediaServer
```xml
<?xml version="1.0" encoding="UTF-8"?>
<root xmlns="urn:schemas-upnp-org:device-1-0">
<specVersion>
<major>1</major>
<minor>0</minor> <!-- ⚠️ Version 1.0 -->
</specVersion>
<device>
<deviceType>urn:schemas-upnp-org:device:MediaServer:1</deviceType>
<friendlyName>PMOMusic Media Server</friendlyName>
<manufacturer>PMOMusic</manufacturer>
<modelName>PMOMusic Media Server</modelName>
<UDN>uuid:8b8e9b19-9c65-4d59-b127-b34717658085</UDN> <!-- ✅ Format correct -->
<!-- ❌ Pas d'iconList -->
<serviceList>
<service>
<serviceType>urn:schemas-upnp-org:service:ContentDirectory:1</serviceType>
<serviceId>urn:upnp-org:serviceId:ContentDirectory</serviceId>
<SCPDURL>/device/.../service/ContentDirectory/desc.xml</SCPDURL>
<controlURL>/device/.../service/ContentDirectory/control</controlURL>
<eventSubURL>/device/.../service/ContentDirectory/event</eventSubURL>
</service>
<service>
<serviceType>urn:schemas-upnp-org:service:ConnectionManager:1</serviceType>
...
</service>
</serviceList>
</device>
</root>
```
### Upmpdcli (Fonctionnel)
```xml
<?xml version="1.0" encoding="utf-8"?>
<root xmlns="urn:schemas-upnp-org:device-1-0">
<specVersion>
<major>1</major>
<minor>1</minor> <!-- ✅ Version 1.1 -->
</specVersion>
<device>
<deviceType>urn:schemas-upnp-org:device:MediaServer:1</deviceType>
<manufacturer>lesbonscomptes.com/upmpdcli</manufacturer>
<modelName>Upmpdcli Media Server</modelName>
<friendlyName>pizzicato-Music-mediaserver</friendlyName>
<iconList> <!-- ✅ Présence d'icônes -->
<icon>
<mimetype>image/png</mimetype>
<width>64</width>
<height>64</height>
<depth>32</depth>
<url>/uuid-.../icon.png</url>
</icon>
</iconList>
<UDN>uuid:c110358f-d885-b44a-d6d3-dca6329ead0d</UDN>
<serviceList>
<!-- Mêmes services -->
</serviceList>
</device>
</root>
```
### Différences Clés dans le Descripteur
| Élément | PMO Music | Upmpdcli | Impact |
|---------|-----------|----------|---------|
| **specVersion minor** | 0 | 1 | ⚠️ Moyen - Certains clients peuvent filtrer par version |
| **Ordre des éléments** | deviceType, friendlyName, manufacturer, modelName, UDN | deviceType, manufacturer, modelName, friendlyName, iconList, UDN | ⚠️ Faible - Ordre différent mais valide XML |
| **iconList** | ❌ Absent | ✅ Présent | ⚠️ Moyen - Requis pour certains clients |
| **UDN prefix** | ✅ uuid: | ✅ uuid: | ✅ Correct |
## Comparaison des Réponses SOAP
### Test 1: ConnectionManager::GetProtocolInfo
#### PMO Music
```http
Status: 200 OK
Content-Type: (absent) PROBLÈME CRITIQUE
<?xml version="1.0" encoding="UTF-8"?>
<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/"
s:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/">
<s:Body>
<u:GetProtocolInfoResponse xmlns:u="urn:schemas-upnp-org:service:ConnectionManager:1">
<Source></Source> Vide
<Sink></Sink> Vide
</u:GetProtocolInfoResponse>
</s:Body>
</s:Envelope>
```
#### Upmpdcli
```http
Status: 200 OK
Content-Type: text/xml; charset="utf-8" Présent
<?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:GetProtocolInfoResponse xmlns:u="urn:schemas-upnp-org:service:ConnectionManager:1">
<Source></Source>
<Sink>http-get:*:audio/flac:*,http-get:*:audio/mp3:*,...</Sink> Formats listés
</u:GetProtocolInfoResponse>
</s:Body>
</s:Envelope>
```
### Test 2: ContentDirectory::Browse
Les deux serveurs répondent correctement, mais PMO Music manque toujours le header `Content-Type`.
## Problèmes Identifiés par Ordre de Criticité
### 🔴 CRITIQUE
1. **Absence du header Content-Type dans les réponses SOAP**
- **Impact:** Les clients UPnP stricts (comme BubbleUPnP) peuvent rejeter les réponses sans Content-Type
- **Spec UPnP:** La spécification UPnP Device Architecture 1.0 exige `Content-Type: text/xml; charset="utf-8"`
- **Localisation probable:** Dans le code de réponse SOAP du serveur UPnP
- **Fichiers à vérifier:**
- `pmoupnp/src/services/service_instance.rs` (handler SOAP)
- `pmoupnp/src/soap/builder.rs`
2. **ProtocolInfo vide pour Source et Sink**
- **Impact:** Les clients ne savent pas quels formats audio sont supportés
- **Spec UPnP:** ConnectionManager doit annoncer les formats supportés
- **Action:** Implémenter la liste des formats dans ConnectionManager
### 🟡 MOYEN
3. **specVersion 1.0 au lieu de 1.1**
- **Impact:** Certains clients modernes peuvent filtrer les devices UPnP 1.0
- **Solution:** Passer à specVersion 1.1
4. **Absence d'iconList**
- **Impact:** Pas d'icône visible dans les clients UPnP
- **Solution:** Ajouter au moins une icône PNG 64x64
### 🟢 FAIBLE
5. **Ordre des éléments XML différent**
- **Impact:** Minimal - XML valide dans tous les cas
- **Action:** Optionnel - standardiser l'ordre
## Recommandations d'Implémentation
### Priorité 1: Corriger le Content-Type
Localiser le code qui génère les réponses SOAP et ajouter le header:
```rust
// Dans pmoupnp/src/services/service_instance.rs ou similaire
(
StatusCode::OK,
[(header::CONTENT_TYPE, "text/xml; charset=\"utf-8\"")], // ← AJOUTER
xml
)
```
### Priorité 2: Implémenter GetProtocolInfo correctement
Dans ConnectionManager, retourner la liste des formats supportés:
```rust
// Exemple de formats à supporter
let sink_protocols = vec![
"http-get:*:audio/flac:*",
"http-get:*:audio/mpeg:*",
"http-get:*:audio/mp4:*",
"http-get:*:audio/ogg:*",
// ...
];
```
### Priorité 3: Passer à UPnP 1.1
Changer la specVersion de 1.0 à 1.1 dans le device descriptor.
### Priorité 4: Ajouter une icône
Créer une icône PNG 64x64 et l'ajouter au descripteur:
```xml
<iconList>
<icon>
<mimetype>image/png</mimetype>
<width>64</width>
<height>64</height>
<depth>32</depth>
<url>/icon.png</url>
</icon>
</iconList>
```
## Fichiers à Modifier
1. **pmoupnp/src/services/service_instance.rs** - Ajouter Content-Type aux réponses SOAP
2. **pmoupnp/src/devices/device_methods.rs** - Ajouter iconList au descripteur
3. **pmoupnp/src/devices/device.rs** - Passer specVersion à 1.1
4. **pmomediaserver/src/connectionmanager/actions/getprotocolinfo.rs** - Implémenter la liste des formats
## Tests de Validation
Après les corrections, vérifier:
1.`curl` sur le descripteur montre specVersion 1.1 et iconList
2. ✅ Requête SOAP GetProtocolInfo retourne `Content-Type: text/xml`
3. ✅ GetProtocolInfo retourne les formats supportés dans Sink
4. ✅ BubbleUPnP détecte et affiche le serveur PMO Music
## Conclusion
Le serveur PMO Music est **fonctionnellement correct** au niveau de SSDP et des services SOAP, mais présente des problèmes de conformité aux standards UPnP qui peuvent causer des rejets par certains clients stricts comme BubbleUPnP.
Les corrections sont simples et localisées. La priorité absolue est d'ajouter le header `Content-Type` aux réponses SOAP.

128
UPNP_FIX_SUMMARY.md Normal file
View File

@@ -0,0 +1,128 @@
# Résolution du Problème UPnP - PMO Music MediaServer
**Date:** 2025-11-26
**Problème:** Le serveur UPnP de PMO Music n'est pas reconnu par BubbleUPnP
## Diagnostic
Après une analyse approfondie avec des outils de découverte UPnP et de tests SOAP, le problème identifié était :
**🔴 PROBLÈME CRITIQUE : `SourceProtocolInfo` vide**
Le service `ConnectionManager` du MediaServer retournait des valeurs vides pour `SourceProtocolInfo`, ce qui empêchait les clients UPnP (comme BubbleUPnP) de savoir quels formats audio le serveur pouvait fournir.
### Réponse AVANT la correction :
```xml
<u:GetProtocolInfoResponse>
<Source></Source> <!-- ❌ VIDE -->
<Sink></Sink>
</u:GetProtocolInfoResponse>
```
## Solution Implémentée
### 1. Nouveau Module : `device_ext.rs`
Création d'un trait d'extension `MediaServerDeviceExt` pour `Arc<DeviceInstance>` qui initialise automatiquement les `ProtocolInfo`.
**Fichier:** [`pmomediaserver/src/device_ext.rs`](pmomediaserver/src/device_ext.rs)
```rust
pub trait MediaServerDeviceExt {
/// Initialise les ProtocolInfo du ConnectionManager pour PMO Music.
///
/// PMO Music convertit tous les flux audio en FLAC (et OGG-FLAC).
fn init_protocol_info(&self);
}
```
### 2. Formats Supportés
PMO Music convertit tout au vol en FLAC, donc `SourceProtocolInfo` annonce :
- `http-get:*:audio/flac:*` - FLAC standard
- `http-get:*:audio/x-flac:*` - FLAC (format alternatif)
- `http-get:*:application/flac:*` - FLAC (MIME type alternatif)
- `http-get:*:application/x-flac:*` - FLAC (MIME type alternatif)
- `http-get:*:application/ogg:*` - OGG-FLAC
- `http-get:*:audio/ogg:*` - OGG-FLAC
- `http-get:*:audio/x-ogg:*` - OGG-FLAC (format alternatif)
### 3. Intégration dans `main.rs`
**Fichier:** [`PMOMusic/src/main.rs`](PMOMusic/src/main.rs)
```rust
use pmomediaserver::MediaServerDeviceExt;
let server_instance = server
.write()
.await
.register_device(MEDIA_SERVER.clone())
.await
.expect("Failed to register MediaServer");
// ✅ Initialiser les ProtocolInfo du MediaServer
server_instance.init_protocol_info();
```
### 4. Export dans `lib.rs`
**Fichier:** [`pmomediaserver/src/lib.rs`](pmomediaserver/src/lib.rs)
```rust
pub mod device_ext;
pub use device_ext::MediaServerDeviceExt;
```
## Réponse APRÈS la correction
```xml
<u:GetProtocolInfoResponse>
<Source>http-get:*:audio/flac:*,http-get:*:audio/x-flac:*,http-get:*:application/flac:*,http-get:*:application/x-flac:*,http-get:*:application/ogg:*,http-get:*:audio/ogg:*,http-get:*:audio/x-ogg:*</Source> <!-- ✅ INITIALISÉ -->
<Sink></Sink> <!-- ✅ Vide pour un MediaServer (normal) -->
</u:GetProtocolInfoResponse>
```
## Fichiers Modifiés
1.**Nouveau:** `pmomediaserver/src/device_ext.rs` - Trait d'extension pour initialiser ProtocolInfo
2.**Modifié:** `pmomediaserver/src/lib.rs` - Export du trait
3.**Modifié:** `PMOMusic/src/main.rs` - Appel à `init_protocol_info()`
## Test de Validation
Après redémarrage du serveur PMO Music, vérifier avec :
```bash
python3 tools/test_soap.py
```
Ou directement :
```bash
curl -X POST \
-H "Content-Type: text/xml" \
-H "SOAPAction: \"urn:schemas-upnp-org:service:ConnectionManager:1#GetProtocolInfo\"" \
-d '<?xml version="1.0"?>
<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/">
<s:Body>
<u:GetProtocolInfo xmlns:u="urn:schemas-upnp-org:service:ConnectionManager:1"/>
</s:Body>
</s:Envelope>' \
http://localhost:8080/device/.../service/ConnectionManager/control
```
## Prochaines Étapes
1. ✅ Redémarrer le serveur PMO Music
2. ⏳ Tester avec BubbleUPnP pour confirmer que le serveur est maintenant reconnu
3. ⏳ (Optionnel) Ajouter une icône pour le MediaServer (amélioration UX)
4. ⏳ (Optionnel) Passer à specVersion 1.1 (amélioration de compatibilité)
## Références
- Rapport d'analyse complet : [`UPNP_ANALYSIS_REPORT.md`](UPNP_ANALYSIS_REPORT.md)
- UPnP AV Architecture Specification :
https://openconnectivity.org/developer/specifications/upnp-resources/upnp/

View File

@@ -117,7 +117,24 @@ impl ContentHandler {
return Ok((didl, 1, 1, update_id)); return Ok((didl, 1, 1, update_id));
} }
// Sinon, chercher dans les sources // Try to get item metadata first (for leaf items)
for source in list_all_sources().await {
match source.get_item(object_id).await {
Ok(item) => {
let didl = to_didl_lite(&[], &[item])?;
let update_id = source.update_id().await;
return Ok((didl, 1, 1, update_id));
}
Err(MusicSourceError::ObjectNotFound(_))
| Err(MusicSourceError::NotSupported(_)) => continue,
Err(e) => {
tracing::debug!("get_item failed for {}: {}", object_id, e);
continue;
}
}
}
// Fallback to browse for containers
let mut non_not_found_error: Option<String> = None; let mut non_not_found_error: Option<String> = None;
for source in list_all_sources().await { for source in list_all_sources().await {
match source.browse(object_id).await { match source.browse(object_id).await {
@@ -192,7 +209,7 @@ impl ContentHandler {
match source.browse(object_id).await { match source.browse(object_id).await {
Ok(result) => { Ok(result) => {
return self return self
.browse_result_to_didl(result, source, starting_index, requested_count) .browse_result_to_didl(object_id, result, source, starting_index, requested_count)
.await; .await;
} }
Err(MusicSourceError::ObjectNotFound(_)) => continue, Err(MusicSourceError::ObjectNotFound(_)) => continue,
@@ -260,29 +277,39 @@ impl ContentHandler {
starting_index: u32, starting_index: u32,
requested_count: u32, requested_count: u32,
) -> Result<(String, u32, u32, u32), String> { ) -> Result<(String, u32, u32, u32), String> {
let source_id = source.id().to_string();
let result = source let result = source
.browse(source.id()) .browse(&source_id)
.await .await
.map_err(|e| format!("Browse failed: {}", e))?; .map_err(|e| format!("Browse failed: {}", e))?;
self.browse_result_to_didl(result, source, starting_index, requested_count) self.browse_result_to_didl(&source_id, result, source, starting_index, requested_count)
.await .await
} }
/// Convertit un BrowseResult en DIDL-Lite XML avec pagination /// Convertit un BrowseResult en DIDL-Lite XML avec pagination
async fn browse_result_to_didl( async fn browse_result_to_didl(
&self, &self,
object_id: &str,
result: BrowseResult, result: BrowseResult,
source: Arc<dyn MusicSource>, source: Arc<dyn MusicSource>,
starting_index: u32, starting_index: u32,
requested_count: u32, requested_count: u32,
) -> Result<(String, u32, u32, u32), String> { ) -> Result<(String, u32, u32, u32), String> {
let (mut containers, mut items) = match result { let (containers, items) = match result {
BrowseResult::Containers(c) => (c, vec![]), BrowseResult::Containers(c) => (c, vec![]),
BrowseResult::Items(i) => (vec![], i), BrowseResult::Items(i) => (vec![], i),
BrowseResult::Mixed { containers, items } => (containers, items), BrowseResult::Mixed { containers, items } => (containers, items),
}; };
// Filter out any container that matches the object_id being browsed
// (to avoid containers appearing as children of themselves)
let mut containers: Vec<Container> = containers
.into_iter()
.filter(|c| c.id != object_id)
.collect();
let mut items = items;
// Calculer le total avant pagination // Calculer le total avant pagination
let total = (containers.len() + items.len()) as u32; let total = (containers.len() + items.len()) as u32;

View File

@@ -0,0 +1,86 @@
///! Extension trait pour initialiser le PMO Music MediaServer UPnP
use pmoupnp::devices::DeviceInstance;
use pmoupnp::variable_types::StateValue;
use std::sync::Arc;
use tracing::{info, warn};
/// Extension trait pour initialiser les variables UPnP du MediaServer
pub trait MediaServerDeviceExt {
/// Initialise les ProtocolInfo du ConnectionManager pour PMO Music.
///
/// PMO Music convertit tous les flux audio en FLAC (et OGG-FLAC).
/// Cette méthode configure le `SourceProtocolInfo` avec les formats supportés:
/// - `http-get:*:audio/flac:*` - FLAC standard
/// - `http-get:*:application/ogg:*` - OGG-FLAC
/// - `http-get:*:audio/ogg:*` - OGG-FLAC (format alternatif)
///
/// # Arguments
///
/// * `device_instance` - L'instance du MediaServer device
///
/// # Returns
///
/// `Ok(())` si l'initialisation réussit, `Err` sinon.
///
/// # Example
///
/// ```ignore
/// use pmomediaserver::MediaServerDeviceExt;
/// use pmomediaserver::MEDIA_SERVER;
///
/// let server_instance = server
/// .write().await
/// .register_device(MEDIA_SERVER.clone())
/// .await?;
///
/// server_instance.init_protocol_info();
/// ```
fn init_protocol_info(&self);
}
impl MediaServerDeviceExt for Arc<DeviceInstance> {
fn init_protocol_info(&self) {
// Liste des formats que PMO Music peut servir
// PMO Music convertit tout au vol en FLAC
let protocol_info = vec![
// FLAC standard (format principal)
"http-get:*:audio/flac:*",
"http-get:*:audio/x-flac:*",
"http-get:*:application/flac:*",
"http-get:*:application/x-flac:*",
// OGG-FLAC
"http-get:*:application/ogg:*",
"http-get:*:audio/ogg:*",
"http-get:*:audio/x-ogg:*",
];
let source_protocol_info = protocol_info.join(",");
info!("🔧 Initializing MediaServer ProtocolInfo:");
info!(" Source: {}", source_protocol_info);
// Accéder au service ConnectionManager
if let Some(conn_mgr) = self.get_service("ConnectionManager") {
// Initialiser SourceProtocolInfo (formats que le serveur peut fournir)
if let Some(source_var) = conn_mgr.get_variable("SourceProtocolInfo") {
tokio::spawn(async move {
if let Err(e) = source_var
.set_value(StateValue::String(source_protocol_info.clone()))
.await
{
warn!("⚠️ Failed to set SourceProtocolInfo: {}", e);
} else {
info!("✅ SourceProtocolInfo initialized");
}
});
} else {
warn!("⚠️ SourceProtocolInfo variable not found in ConnectionManager");
}
// SinkProtocolInfo reste vide pour un MediaServer (il ne consomme pas de contenu)
} else {
warn!("⚠️ ConnectionManager service not found in MediaServer");
}
}
}

View File

@@ -67,6 +67,7 @@ pub mod connectionmanager;
pub mod content_handler; pub mod content_handler;
pub mod contentdirectory; pub mod contentdirectory;
pub mod device; pub mod device;
pub mod device_ext;
pub mod server_ext; pub mod server_ext;
pub mod source_registry; pub mod source_registry;
pub mod sources; pub mod sources;
@@ -81,6 +82,7 @@ pub mod paradise_streaming;
pub use content_handler::ContentHandler; pub use content_handler::ContentHandler;
pub use device::MEDIA_SERVER; pub use device::MEDIA_SERVER;
pub use device_ext::MediaServerDeviceExt;
pub use server_ext::{MediaServerExt, MusicSourceExt, get_source_registry}; pub use server_ext::{MediaServerExt, MusicSourceExt, get_source_registry};
pub use source_registry::SourceRegistry; pub use source_registry::SourceRegistry;
pub use sources::{SourceInitError, SourcesExt}; pub use sources::{SourceInitError, SourcesExt};

View File

@@ -77,7 +77,7 @@ pmometadata = { path = "../pmometadata", optional = true }
futures-util = { version = "0.3", optional = true } futures-util = { version = "0.3", optional = true }
[features] [features]
default = ["metadata-only", "pmoconfig"] default = ["metadata-only", "pmoconfig", "playlist"]
# Mode métadonnées seules (pas de décodage FLAC) # Mode métadonnées seules (pas de décodage FLAC)
metadata-only = [] metadata-only = []
# Active l'API REST pmoserver # Active l'API REST pmoserver
@@ -86,12 +86,14 @@ pmoserver = ["dep:pmoserver", "dep:utoipa", "dep:axum", "server"]
server = ["pmosource/server", "pmoconfig"] server = ["pmosource/server", "pmoconfig"]
# Feature pour activer le support de pmoconfig # Feature pour activer le support de pmoconfig
pmoconfig = ["dep:pmoconfig"] pmoconfig = ["dep:pmoconfig"]
# Feature pour activer le support des playlists d'historique
playlist = []
# Feature cache (deprecated - toujours actif maintenant) # Feature cache (deprecated - toujours actif maintenant)
cache = [] cache = []
# Active le support pmoaudio node (RadioParadiseStreamSource) # Active le support pmoaudio node (RadioParadiseStreamSource)
pmoaudio = ["dep:pmoaudio", "dep:pmoflac", "dep:pmometadata", "dep:futures-util", "dep:pmoaudio-ext"] pmoaudio = ["dep:pmoaudio", "dep:pmoflac", "dep:pmometadata", "dep:futures-util", "dep:pmoaudio-ext"]
# Active le support complet avec playlist (pour les exemples avancés) # Active le support complet avec playlist (pour les exemples avancés)
full = ["pmoaudio", "dep:pmoaudio-ext", "pmoconfig", "pmoserver"] full = ["pmoaudio", "dep:pmoaudio-ext", "pmoconfig", "pmoserver", "playlist"]
[dev-dependencies] [dev-dependencies]
# Tests # Tests

View File

@@ -41,8 +41,11 @@ struct AppState {
#[tokio::main] #[tokio::main]
async fn main() -> anyhow::Result<()> { async fn main() -> anyhow::Result<()> {
let env_filter = tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info"));
tracing_subscriber::fmt() tracing_subscriber::fmt()
.with_env_filter(tracing_subscriber::EnvFilter::from_default_env()) .with_env_filter(env_filter)
.init(); .init();
let descriptor = pick_descriptor(std::env::args().nth(1))?; let descriptor = pick_descriptor(std::env::args().nth(1))?;

View File

@@ -13,8 +13,6 @@ use std::sync::Arc;
use std::time::SystemTime; use std::time::SystemTime;
use tokio::sync::RwLock; use tokio::sync::RwLock;
#[cfg(feature = "playlist")]
use pmoplaylist::PlaylistManager;
/// Default Radio Paradise image (embedded in binary) /// Default Radio Paradise image (embedded in binary)
const DEFAULT_IMAGE: &[u8] = include_bytes!("../assets/default.webp"); const DEFAULT_IMAGE: &[u8] = include_bytes!("../assets/default.webp");
@@ -257,7 +255,7 @@ impl RadioParadiseSource {
async fn get_history_items( async fn get_history_items(
&self, &self,
slug: &str, slug: &str,
offset: usize, _offset: usize,
count: usize, count: usize,
) -> Result<Vec<Item>> { ) -> Result<Vec<Item>> {
let playlist_id = Self::history_playlist_id(slug); let playlist_id = Self::history_playlist_id(slug);
@@ -268,65 +266,14 @@ impl RadioParadiseSource {
MusicSourceError::BrowseError(format!("Failed to get playlist {}: {}", playlist_id, e)) MusicSourceError::BrowseError(format!("Failed to get playlist {}: {}", playlist_id, e))
})?; })?;
// Get entries from playlist // Get items from playlist (to_items starts from cursor position)
let entries = reader.get_entries(offset, count).await.map_err(|e| { let items = reader.to_items(count).await.map_err(|e| {
MusicSourceError::BrowseError(format!("Failed to read playlist entries: {}", e)) MusicSourceError::BrowseError(format!("Failed to read playlist entries: {}", e))
})?; })?;
// Convert entries to Items
let mut items = Vec::new();
for entry in entries {
if let Ok(item) = self.playlist_entry_to_item(slug, &entry).await {
items.push(item);
}
}
Ok(items) Ok(items)
} }
/// Convert a playlist entry to a DIDL Item
#[cfg(feature = "playlist")]
async fn playlist_entry_to_item(
&self,
slug: &str,
entry: &pmoplaylist::PlaylistEntry,
) -> Result<Item> {
let metadata = &entry.metadata;
// Build audio URL from cache
let audio_url = format!("{}/cache/audio/{}", self.base_url, entry.pk);
// Build item
Ok(Item {
id: format!("radio-paradise:channel:{}:history:track:{}", slug, entry.pk),
parent_id: format!("radio-paradise:channel:{}:history", slug),
restricted: Some("1".to_string()),
title: metadata
.title
.clone()
.unwrap_or_else(|| "Unknown Title".to_string()),
creator: metadata.artist.clone(),
class: "object.item.audioItem.musicTrack".to_string(),
artist: metadata.artist.clone(),
album: metadata.album.clone(),
genre: metadata.genre.clone(),
album_art: None,
album_art_pk: metadata.cover_pk.clone(),
date: metadata.year.map(|y| y.to_string()),
original_track_number: metadata.track_number.map(|n| n.to_string()),
resources: vec![Resource {
protocol_info: "http-get:*:audio/flac:*".to_string(),
bits_per_sample: metadata.bits_per_sample.map(|b| b.to_string()),
sample_frequency: metadata.sample_rate.map(|s| s.to_string()),
nr_audio_channels: Some("2".to_string()),
duration: metadata
.duration
.map(|d| format!("{}:{:02}:{:02}", d / 3600, (d % 3600) / 60, d % 60)),
url: audio_url,
}],
descriptions: vec![],
})
}
} }
/// Types of object IDs in the Radio Paradise source /// Types of object IDs in the Radio Paradise source
@@ -396,26 +343,41 @@ impl MusicSource for RadioParadiseSource {
} }
ObjectIdType::History { slug } => { ObjectIdType::History { slug } => {
// Return items from history playlist // Return history container as first element (for BrowseMetadata)
// followed by history items (for BrowseDirectChildren)
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")] #[cfg(feature = "playlist")]
{ {
let items = self.get_history_items(&slug, 0, 100).await?; let items = self.get_history_items(&slug, 0, 100).await?;
Ok(BrowseResult::Items(items)) Ok(BrowseResult::Mixed {
containers: vec![history_container],
items,
})
} }
#[cfg(not(feature = "playlist"))] #[cfg(not(feature = "playlist"))]
{ {
let _ = slug; Ok(BrowseResult::Containers(vec![history_container]))
Ok(BrowseResult::Items(vec![]))
} }
} }
ObjectIdType::LiveStream { .. } | ObjectIdType::HistoryTrack { .. } => { ObjectIdType::LiveStream { slug } => {
// These are leaf nodes, cannot be browsed // Return metadata for the live stream item
Err(MusicSourceError::ObjectNotFound(format!( let descriptor = Self::get_channel_by_slug(&slug).ok_or_else(|| {
"Object {} is not a container", MusicSourceError::ObjectNotFound(format!("Unknown channel: {}", slug))
object_id })?;
))) let item = self.build_live_stream_item(descriptor);
Ok(BrowseResult::Items(vec![item]))
}
ObjectIdType::HistoryTrack { slug, pk } => {
// Return metadata for the history track item
let item = self.get_item(object_id).await?;
Ok(BrowseResult::Items(vec![item]))
} }
ObjectIdType::Unknown => Err(MusicSourceError::ObjectNotFound(format!( ObjectIdType::Unknown => Err(MusicSourceError::ObjectNotFound(format!(
@@ -522,17 +484,19 @@ impl MusicSource for RadioParadiseSource {
)) ))
})?; })?;
// Try to find the entry with this pk // Try to find the item with this pk
let entries = reader.get_entries(0, 1000).await.map_err(|e| { let items = reader.to_items(1000).await.map_err(|e| {
MusicSourceError::BrowseError(format!( MusicSourceError::BrowseError(format!(
"Failed to read playlist entries: {}", "Failed to read playlist entries: {}",
e e
)) ))
})?; })?;
for entry in entries { // Find the item matching this pk in the item ID
if entry.pk == pk { let expected_id = format!("radio-paradise:channel:{}:history:track:{}", slug, pk);
return self.playlist_entry_to_item(&slug, &entry).await; for item in items {
if item.id == expected_id {
return Ok(item);
} }
} }

166
tools/compare_upnp.py Executable file
View File

@@ -0,0 +1,166 @@
#!/usr/bin/env python3
"""
Compare UPnP MediaServers
"""
from urllib.request import urlopen, Request
import re
# Devices à comparer
DEVICES = {
"PMO Music 1": "http://192.168.0.138:8080/device/659878e3-9790-4ba0-a710-946e9470bd01/desc.xml",
"PMO Music 2": "http://192.168.0.138:8080/device/8b8e9b19-9c65-4d59-b127-b34717658085/desc.xml",
"Upmpdcli": "http://192.168.0.200:49152/uuid-c110358f-d885-b44a-d6d3-dca6329ead0d/description.xml",
"Freebox": "http://192.168.0.254:52424/device.xml",
}
def fetch_description(url):
"""Récupère la description XML"""
try:
req = Request(url, headers={'User-Agent': 'PMOMusic/1.0'})
response = urlopen(req, timeout=3)
return response.read().decode('utf-8')
except Exception as e:
return f"Error: {e}"
def extract_info(xml):
"""Extrait les infos clés"""
info = {}
patterns = {
'deviceType': r'<deviceType>([^<]+)</deviceType>',
'friendlyName': r'<friendlyName>([^<]+)</friendlyName>',
'manufacturer': r'<manufacturer>([^<]+)</manufacturer>',
'modelName': r'<modelName>([^<]+)</modelName>',
'UDN': r'<UDN>([^<]+)</UDN>',
'specVersion': r'<specVersion>.*?<major>(\d+)</major>.*?<minor>(\d+)</minor>',
}
for key, pattern in patterns.items():
match = re.search(pattern, xml, re.DOTALL)
if match:
if key == 'specVersion':
info[key] = f"{match.group(1)}.{match.group(2)}"
else:
info[key] = match.group(1)
# Extraire les services
services = re.findall(r'<serviceType>([^<]+)</serviceType>', xml)
info['services'] = services
# Vérifier les icônes
has_icons = bool(re.search(r'<iconList>', xml))
info['hasIcons'] = has_icons
return info
def main():
print("=" * 100)
print(" 🔍 UPnP MediaServer Comparison")
print("=" * 100)
print()
results = {}
for name, url in DEVICES.items():
print(f"📡 Fetching {name}...")
xml = fetch_description(url)
if not xml.startswith("Error"):
results[name] = {
'xml': xml,
'info': extract_info(xml)
}
print(f" ✅ Fetched ({len(xml)} bytes)")
else:
print(f"{xml}")
print()
# Comparer les résultats
print("=" * 100)
print(" 📊 COMPARISON")
print("=" * 100)
print()
# Tableau comparatif
print(f"{'Property':<20} | {'PMO Music 1':<30} | {'PMO Music 2':<30} | {'Upmpdcli':<30} | {'Freebox':<30}")
print("-" * 150)
properties = ['deviceType', 'specVersion', 'UDN', 'friendlyName', 'manufacturer', 'modelName', 'hasIcons']
for prop in properties:
row = f"{prop:<20} |"
for device in ["PMO Music 1", "PMO Music 2", "Upmpdcli", "Freebox"]:
if device in results:
value = str(results[device]['info'].get(prop, 'N/A'))[:28]
row += f" {value:<30} |"
else:
row += f" {'N/A':<30} |"
print(row)
print()
print("=" * 100)
print(" 🔌 SERVICES")
print("=" * 100)
print()
for name, data in results.items():
print(f"\n{name}:")
for service in data['info'].get('services', []):
print(f" - {service}")
# Afficher les XMLs complets pour PMO Music et un qui fonctionne
print("\n" + "=" * 100)
print(" 📄 FULL XML COMPARISON")
print("=" * 100)
if "PMO Music 1" in results:
print("\n" + "=" * 50)
print(" PMO Music MediaServer XML:")
print("=" * 50)
print(results["PMO Music 1"]['xml'])
if "Upmpdcli" in results:
print("\n" + "=" * 50)
print(" Upmpdcli (WORKING) XML:")
print("=" * 50)
print(results["Upmpdcli"]['xml'])
# Analyse des différences critiques
print("\n" + "=" * 100)
print(" ⚠️ CRITICAL DIFFERENCES")
print("=" * 100)
print()
if "PMO Music 1" in results and "Upmpdcli" in results:
pmo_udn = results["PMO Music 1"]['info'].get('UDN', '')
upmp_udn = results["Upmpdcli"]['info'].get('UDN', '')
print(f"UDN Format:")
print(f" PMO Music: {pmo_udn}")
print(f" Upmpdcli: {upmp_udn}")
if not pmo_udn.startswith('uuid:'):
print(f" ❌ PROBLÈME: PMO Music UDN ne commence pas par 'uuid:'")
else:
print(f" ✅ PMO Music UDN format correct")
if not upmp_udn.startswith('uuid:'):
print(f" ❌ PROBLÈME: Upmpdcli UDN ne commence pas par 'uuid:'")
else:
print(f" ✅ Upmpdcli UDN format correct")
print()
pmo_icons = results["PMO Music 1"]['info'].get('hasIcons', False)
upmp_icons = results["Upmpdcli"]['info'].get('hasIcons', False)
print(f"Icons:")
print(f" PMO Music: {pmo_icons}")
print(f" Upmpdcli: {upmp_icons}")
if not pmo_icons and upmp_icons:
print(f" ⚠️ PMO Music n'a pas d'iconList (mais peut ne pas être critique)")
if __name__ == "__main__":
main()

175
tools/discover_upnp.py Executable file
View File

@@ -0,0 +1,175 @@
#!/usr/bin/env python3
"""
UPnP Device Discovery Tool
Envoie une requête M-SEARCH SSDP et collecte les réponses des devices
"""
import socket
import struct
import time
import sys
from urllib.parse import urlparse
from urllib.request import urlopen
SSDP_ADDR = "239.255.255.250"
SSDP_PORT = 1900
SSDP_MX = 3
SSDP_ST = "ssdp:all"
M_SEARCH = f"""M-SEARCH * HTTP/1.1
HOST: {SSDP_ADDR}:{SSDP_PORT}
MAN: "ssdp:discover"
MX: {SSDP_MX}
ST: {SSDP_ST}
USER-AGENT: PMOMusic UPnP Discovery Tool
"""
def discover_upnp_devices(timeout=5):
"""Découvre les devices UPnP sur le réseau local"""
devices = {}
# Créer le socket UDP
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM, socket.IPPROTO_UDP)
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
sock.settimeout(timeout)
# Envoyer la requête M-SEARCH
print(f"🔍 Envoi de la requête M-SEARCH sur {SSDP_ADDR}:{SSDP_PORT}...")
print(f"⏱️ Timeout: {timeout}s\n")
message = M_SEARCH.replace('\n', '\r\n').encode('utf-8')
sock.sendto(message, (SSDP_ADDR, SSDP_PORT))
# Collecter les réponses
start_time = time.time()
while time.time() - start_time < timeout:
try:
data, addr = sock.recvfrom(65507)
response = data.decode('utf-8', errors='ignore')
# Parser la réponse
location = None
server = None
st = 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('st:'):
st = 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] = {
'location': location,
'server': server,
'st': st,
'usn': usn,
'from': addr[0]
}
except socket.timeout:
break
except Exception as e:
print(f"⚠️ Erreur lors de la réception: {e}")
sock.close()
return devices
def fetch_device_description(location):
"""Récupère la description XML du device"""
try:
response = urlopen(location, timeout=3)
return response.read().decode('utf-8')
except Exception as e:
return f"Error: {e}"
def main():
print("=" * 70)
print(" 🔍 UPnP Device Discovery Tool")
print("=" * 70)
print()
devices = discover_upnp_devices(timeout=5)
# Filtrer pour ne garder que les MediaServers
media_servers = {}
for loc, info in devices.items():
if 'MediaServer' in str(info.get('st', '')):
media_servers[loc] = info
print(f"\n📊 Résultats:")
print(f" Total devices trouvés: {len(devices)}")
print(f" MediaServers trouvés: {len(media_servers)}\n")
if not media_servers:
print("❌ Aucun MediaServer trouvé!\n")
print("📋 Tous les devices trouvés:")
for loc, info in devices.items():
print(f"\n - Location: {loc}")
print(f" ST: {info.get('st', 'N/A')}")
print(f" Server: {info.get('server', 'N/A')}")
return
# Analyser chaque MediaServer
for idx, (location, info) in enumerate(media_servers.items(), 1):
print("=" * 70)
print(f"📡 MediaServer #{idx}")
print("=" * 70)
print(f"Location: {location}")
print(f"From IP: {info['from']}")
print(f"Server: {info.get('server', 'N/A')}")
print(f"USN: {info.get('usn', 'N/A')}")
print()
# Récupérer la description
print("📄 Fetching device description...")
desc = fetch_device_description(location)
# Analyser la description
if desc and not desc.startswith("Error"):
print("\n📝 Device Description XML:")
print("-" * 70)
# Afficher les premières lignes
lines = desc.split('\n')
for line in lines[:50]: # Limiter à 50 lignes
print(line)
if len(lines) > 50:
print(f"... ({len(lines) - 50} more lines)")
print("-" * 70)
# Extraire les infos importantes
import re
friendly_name = re.search(r'<friendlyName>([^<]+)</friendlyName>', desc)
manufacturer = re.search(r'<manufacturer>([^<]+)</manufacturer>', desc)
model_name = re.search(r'<modelName>([^<]+)</modelName>', desc)
udn = re.search(r'<UDN>([^<]+)</UDN>', desc)
print("\n📋 Device Info:")
if friendly_name:
print(f" Friendly Name: {friendly_name.group(1)}")
if manufacturer:
print(f" Manufacturer: {manufacturer.group(1)}")
if model_name:
print(f" Model Name: {model_name.group(1)}")
if udn:
print(f" UDN: {udn.group(1)}")
# Vérifier le format de l'UDN
udn_value = udn.group(1)
if not udn_value.startswith('uuid:'):
print(f" ⚠️ WARNING: UDN ne commence pas par 'uuid:' !")
else:
print(f"❌ Erreur lors de la récupération: {desc}")
print("\n")
if __name__ == "__main__":
main()

149
tools/test_soap.py Normal file
View File

@@ -0,0 +1,149 @@
#!/usr/bin/env python3
"""
Test SOAP Services for UPnP MediaServers
"""
from urllib.request import Request, urlopen
# SOAP request pour GetProtocolInfo
GET_PROTOCOL_INFO = """<?xml version="1.0" encoding="utf-8"?>
<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/" s:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/">
<s:Body>
<u:GetProtocolInfo xmlns:u="urn:schemas-upnp-org:service:ConnectionManager:1"/>
</s:Body>
</s:Envelope>"""
# SOAP request pour Browse
BROWSE_REQUEST = """<?xml version="1.0" encoding="utf-8"?>
<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>0</ObjectID>
<BrowseFlag>BrowseDirectChildren</BrowseFlag>
<Filter>*</Filter>
<StartingIndex>0</StartingIndex>
<RequestedCount>10</RequestedCount>
<SortCriteria></SortCriteria>
</u:Browse>
</s:Body>
</s:Envelope>"""
SERVERS = {
"PMO Music": {
"base": "http://192.168.0.138:8080",
"content_control": "/device/8b8e9b19-9c65-4d59-b127-b34717658085/service/ContentDirectory/control",
"conn_control": "/device/8b8e9b19-9c65-4d59-b127-b34717658085/service/ConnectionManager/control",
"scpd_content": "/device/8b8e9b19-9c65-4d59-b127-b34717658085/service/ContentDirectory/desc.xml",
},
"Upmpdcli": {
"base": "http://192.168.0.200:49152",
"content_control": "/uuid-c110358f-d885-b44a-d6d3-dca6329ead0d/ctl-urn-schemas-upnp-org-service-ContentDirectory-1",
"conn_control": "/uuid-c110358f-d885-b44a-d6d3-dca6329ead0d/ctl-urn-schemas-upnp-org-service-ConnectionManager-1",
"scpd_content": "/uuid-c110358f-d885-b44a-d6d3-dca6329ead0d/urn-schemas-upnp-org-service-ContentDirectory-1.xml",
},
}
def send_soap_request(url, soap_action, soap_body):
"""Envoie une requête SOAP"""
try:
req = Request(
url,
data=soap_body.encode('utf-8'),
headers={
'Content-Type': 'text/xml; charset="utf-8"',
'SOAPAction': f'"{soap_action}"',
'User-Agent': 'PMOMusic/1.0',
}
)
response = urlopen(req, timeout=5)
return response.read().decode('utf-8'), response.status, dict(response.headers)
except Exception as e:
return f"Error: {e}", None, None
def main():
print("=" * 100)
print(" 🧪 SOAP Services Testing")
print("=" * 100)
print()
for server_name, server_info in SERVERS.items():
print("\n" + "=" * 100)
print(f" 📡 Testing {server_name}")
print("=" * 100)
# Test 1: GetProtocolInfo
print("\n🔌 Test 1: ConnectionManager::GetProtocolInfo")
print("-" * 100)
url = server_info["base"] + server_info["conn_control"]
soap_action = "urn:schemas-upnp-org:service:ConnectionManager:1#GetProtocolInfo"
print(f"URL: {url}")
print(f"SOAPAction: {soap_action}")
response, status, headers = send_soap_request(url, soap_action, GET_PROTOCOL_INFO)
if status:
print(f"\n✅ Status: {status}")
if headers:
print(f"Content-Type: {headers.get('Content-Type', 'N/A')}")
print(f"\n📄 Response ({len(response)} bytes):")
print(response[:1000])
if len(response) > 1000:
print(f"... ({len(response) - 1000} more bytes)")
else:
print(f"\n❌ Error: {response}")
# Test 2: Browse
print("\n\n📁 Test 2: ContentDirectory::Browse")
print("-" * 100)
url = server_info["base"] + server_info["content_control"]
soap_action = "urn:schemas-upnp-org:service:ContentDirectory:1#Browse"
print(f"URL: {url}")
print(f"SOAPAction: {soap_action}")
response, status, headers = send_soap_request(url, soap_action, BROWSE_REQUEST)
if status:
print(f"\n✅ Status: {status}")
if headers:
print(f"Content-Type: {headers.get('Content-Type', 'N/A')}")
print(f"\n📄 Response ({len(response)} bytes):")
print(response[:2000])
if len(response) > 2000:
print(f"... ({len(response) - 2000} more bytes)")
else:
print(f"\n❌ Error: {response}")
print("\n")
# Test 3: Vérifier les SCPD
print("\n" + "=" * 100)
print(" 📋 SCPD (Service Control Protocol Description) Verification")
print("=" * 100)
for server_name, server_info in SERVERS.items():
print(f"\n{server_name}:")
# ContentDirectory SCPD
scpd_url = server_info["base"] + server_info["scpd_content"]
print(f" ContentDirectory SCPD: {scpd_url}")
try:
req = Request(scpd_url, headers={'User-Agent': 'PMOMusic/1.0'})
response = urlopen(req, timeout=3)
scpd_xml = response.read().decode('utf-8')
print(f" ✅ Fetched ({len(scpd_xml)} bytes)")
# Vérifier les actions
import re
actions = re.findall(r'<action>.*?<name>([^<]+)</name>', scpd_xml, re.DOTALL)
print(f" Actions: {', '.join(actions)}")
except Exception as e:
print(f" ❌ Error: {e}")
if __name__ == "__main__":
main()