debug du upnp mediaserver
This commit is contained in:
121
BROWSEMETADATA_FIX.md
Normal file
121
BROWSEMETADATA_FIX.md
Normal 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/
|
||||
BIN
PMOMusic/.pmomusic/cache_audio/cache.db
Normal file
BIN
PMOMusic/.pmomusic/cache_audio/cache.db
Normal file
Binary file not shown.
BIN
PMOMusic/.pmomusic/cache_covers/cache.db
Normal file
BIN
PMOMusic/.pmomusic/cache_covers/cache.db
Normal file
Binary file not shown.
21
PMOMusic/.pmomusic/config.yaml
Normal file
21
PMOMusic/.pmomusic/config.yaml
Normal 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
|
||||
BIN
PMOMusic/.pmomusic/playlists/playlists.db
Normal file
BIN
PMOMusic/.pmomusic/playlists/playlists.db
Normal file
Binary file not shown.
@@ -1,6 +1,6 @@
|
||||
use pmoapp::{WebAppExt, Webapp};
|
||||
use pmomediarenderer::MEDIA_RENDERER;
|
||||
use pmomediaserver::{MEDIA_SERVER, ParadiseStreamingExt, sources::SourcesExt};
|
||||
use pmomediaserver::{MEDIA_SERVER, MediaServerDeviceExt, ParadiseStreamingExt, sources::SourcesExt};
|
||||
use pmoserver::Server;
|
||||
use pmosource::MusicSourceExt;
|
||||
use pmoupnp::UpnpServerExt;
|
||||
@@ -81,6 +81,9 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
.await
|
||||
.expect("Failed to register MediaServer");
|
||||
|
||||
// Initialiser les ProtocolInfo du MediaServer
|
||||
server_instance.init_protocol_info();
|
||||
|
||||
info!(
|
||||
"✅ MediaServer ready at {}{}",
|
||||
server_instance.base_url(),
|
||||
|
||||
243
UPNP_ANALYSIS_REPORT.md
Normal file
243
UPNP_ANALYSIS_REPORT.md
Normal 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
128
UPNP_FIX_SUMMARY.md
Normal 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/
|
||||
@@ -117,7 +117,24 @@ impl ContentHandler {
|
||||
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;
|
||||
for source in list_all_sources().await {
|
||||
match source.browse(object_id).await {
|
||||
@@ -192,7 +209,7 @@ impl ContentHandler {
|
||||
match source.browse(object_id).await {
|
||||
Ok(result) => {
|
||||
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;
|
||||
}
|
||||
Err(MusicSourceError::ObjectNotFound(_)) => continue,
|
||||
@@ -260,29 +277,39 @@ impl ContentHandler {
|
||||
starting_index: u32,
|
||||
requested_count: u32,
|
||||
) -> Result<(String, u32, u32, u32), String> {
|
||||
let source_id = source.id().to_string();
|
||||
let result = source
|
||||
.browse(source.id())
|
||||
.browse(&source_id)
|
||||
.await
|
||||
.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
|
||||
}
|
||||
|
||||
/// Convertit un BrowseResult en DIDL-Lite XML avec pagination
|
||||
async fn browse_result_to_didl(
|
||||
&self,
|
||||
object_id: &str,
|
||||
result: BrowseResult,
|
||||
source: Arc<dyn MusicSource>,
|
||||
starting_index: u32,
|
||||
requested_count: u32,
|
||||
) -> Result<(String, u32, u32, u32), String> {
|
||||
let (mut containers, mut items) = match result {
|
||||
let (containers, items) = match result {
|
||||
BrowseResult::Containers(c) => (c, vec![]),
|
||||
BrowseResult::Items(i) => (vec![], i),
|
||||
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
|
||||
let total = (containers.len() + items.len()) as u32;
|
||||
|
||||
|
||||
86
pmomediaserver/src/device_ext.rs
Normal file
86
pmomediaserver/src/device_ext.rs
Normal 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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -67,6 +67,7 @@ pub mod connectionmanager;
|
||||
pub mod content_handler;
|
||||
pub mod contentdirectory;
|
||||
pub mod device;
|
||||
pub mod device_ext;
|
||||
pub mod server_ext;
|
||||
pub mod source_registry;
|
||||
pub mod sources;
|
||||
@@ -81,6 +82,7 @@ pub mod paradise_streaming;
|
||||
|
||||
pub use content_handler::ContentHandler;
|
||||
pub use device::MEDIA_SERVER;
|
||||
pub use device_ext::MediaServerDeviceExt;
|
||||
pub use server_ext::{MediaServerExt, MusicSourceExt, get_source_registry};
|
||||
pub use source_registry::SourceRegistry;
|
||||
pub use sources::{SourceInitError, SourcesExt};
|
||||
|
||||
0
pmoparadise/.pmomusic/playlists/playlists.db
Normal file
0
pmoparadise/.pmomusic/playlists/playlists.db
Normal file
@@ -77,7 +77,7 @@ pmometadata = { path = "../pmometadata", optional = true }
|
||||
futures-util = { version = "0.3", optional = true }
|
||||
|
||||
[features]
|
||||
default = ["metadata-only", "pmoconfig"]
|
||||
default = ["metadata-only", "pmoconfig", "playlist"]
|
||||
# Mode métadonnées seules (pas de décodage FLAC)
|
||||
metadata-only = []
|
||||
# Active l'API REST pmoserver
|
||||
@@ -86,12 +86,14 @@ pmoserver = ["dep:pmoserver", "dep:utoipa", "dep:axum", "server"]
|
||||
server = ["pmosource/server", "pmoconfig"]
|
||||
# Feature pour activer le support de pmoconfig
|
||||
pmoconfig = ["dep:pmoconfig"]
|
||||
# Feature pour activer le support des playlists d'historique
|
||||
playlist = []
|
||||
# Feature cache (deprecated - toujours actif maintenant)
|
||||
cache = []
|
||||
# Active le support pmoaudio node (RadioParadiseStreamSource)
|
||||
pmoaudio = ["dep:pmoaudio", "dep:pmoflac", "dep:pmometadata", "dep:futures-util", "dep:pmoaudio-ext"]
|
||||
# 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]
|
||||
# Tests
|
||||
|
||||
@@ -41,8 +41,11 @@ struct AppState {
|
||||
|
||||
#[tokio::main]
|
||||
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()
|
||||
.with_env_filter(tracing_subscriber::EnvFilter::from_default_env())
|
||||
.with_env_filter(env_filter)
|
||||
.init();
|
||||
|
||||
let descriptor = pick_descriptor(std::env::args().nth(1))?;
|
||||
|
||||
@@ -13,8 +13,6 @@ use std::sync::Arc;
|
||||
use std::time::SystemTime;
|
||||
use tokio::sync::RwLock;
|
||||
|
||||
#[cfg(feature = "playlist")]
|
||||
use pmoplaylist::PlaylistManager;
|
||||
|
||||
/// Default Radio Paradise image (embedded in binary)
|
||||
const DEFAULT_IMAGE: &[u8] = include_bytes!("../assets/default.webp");
|
||||
@@ -257,7 +255,7 @@ impl RadioParadiseSource {
|
||||
async fn get_history_items(
|
||||
&self,
|
||||
slug: &str,
|
||||
offset: usize,
|
||||
_offset: usize,
|
||||
count: usize,
|
||||
) -> Result<Vec<Item>> {
|
||||
let playlist_id = Self::history_playlist_id(slug);
|
||||
@@ -268,65 +266,14 @@ impl RadioParadiseSource {
|
||||
MusicSourceError::BrowseError(format!("Failed to get playlist {}: {}", playlist_id, e))
|
||||
})?;
|
||||
|
||||
// Get entries from playlist
|
||||
let entries = reader.get_entries(offset, count).await.map_err(|e| {
|
||||
// Get items from playlist (to_items starts from cursor position)
|
||||
let items = reader.to_items(count).await.map_err(|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)
|
||||
}
|
||||
|
||||
/// 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
|
||||
@@ -396,26 +343,41 @@ impl MusicSource for RadioParadiseSource {
|
||||
}
|
||||
|
||||
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")]
|
||||
{
|
||||
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"))]
|
||||
{
|
||||
let _ = slug;
|
||||
Ok(BrowseResult::Items(vec![]))
|
||||
Ok(BrowseResult::Containers(vec![history_container]))
|
||||
}
|
||||
}
|
||||
|
||||
ObjectIdType::LiveStream { .. } | ObjectIdType::HistoryTrack { .. } => {
|
||||
// These are leaf nodes, cannot be browsed
|
||||
Err(MusicSourceError::ObjectNotFound(format!(
|
||||
"Object {} is not a container",
|
||||
object_id
|
||||
)))
|
||||
ObjectIdType::LiveStream { slug } => {
|
||||
// Return metadata for the live stream item
|
||||
let descriptor = Self::get_channel_by_slug(&slug).ok_or_else(|| {
|
||||
MusicSourceError::ObjectNotFound(format!("Unknown channel: {}", slug))
|
||||
})?;
|
||||
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!(
|
||||
@@ -522,17 +484,19 @@ impl MusicSource for RadioParadiseSource {
|
||||
))
|
||||
})?;
|
||||
|
||||
// Try to find the entry with this pk
|
||||
let entries = reader.get_entries(0, 1000).await.map_err(|e| {
|
||||
// Try to find the item with this pk
|
||||
let items = reader.to_items(1000).await.map_err(|e| {
|
||||
MusicSourceError::BrowseError(format!(
|
||||
"Failed to read playlist entries: {}",
|
||||
e
|
||||
))
|
||||
})?;
|
||||
|
||||
for entry in entries {
|
||||
if entry.pk == pk {
|
||||
return self.playlist_entry_to_item(&slug, &entry).await;
|
||||
// Find the item matching this pk in the item ID
|
||||
let expected_id = format!("radio-paradise:channel:{}:history:track:{}", slug, pk);
|
||||
for item in items {
|
||||
if item.id == expected_id {
|
||||
return Ok(item);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
166
tools/compare_upnp.py
Executable file
166
tools/compare_upnp.py
Executable 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
175
tools/discover_upnp.py
Executable 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
149
tools/test_soap.py
Normal 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()
|
||||
Reference in New Issue
Block a user