Mise en place d'un bus d'événements pour les serveurs de médias.

This commit is contained in:
2025-12-01 23:10:16 +01:00
parent ea1774f216
commit aeb9332387
12 changed files with 9436 additions and 9 deletions

431
media_ Normal file
View File

@@ -0,0 +1,431 @@
warning: use of `async fn` in public traits is discouraged as auto trait bounds cannot be specified
--> pmoserver/src/config_ext.rs:35:5
|
35 | async fn init_config_api(&mut self) -> Result<()>;
| ^^^^^
|
= note: you can suppress this lint if you plan to use the trait only in your own code, or do not care about auto traits like `Send` on the `Future`
= note: `#[warn(async_fn_in_trait)]` on by default
help: you can alternatively desugar to a normal `fn` that returns `impl Future` and add any desired bounds such as `Send`, but these cannot be relaxed without a breaking API change
|
35 - async fn init_config_api(&mut self) -> Result<()>;
35 + fn init_config_api(&mut self) -> impl std::future::Future<Output = Result<()>> + Send;
|
warning: `pmoserver` (lib) generated 1 warning
warning: variable does not need to be mutable
--> pmocache/src/cache.rs:604:9
|
604 | mut reader: R,
| ----^^^^^^
| |
| help: remove this `mut`
|
= note: `#[warn(unused_mut)]` (part of `#[warn(unused)]`) on by default
warning: use of `async fn` in public traits is discouraged as auto trait bounds cannot be specified
--> pmocache/src/cache_trait.rs:97:5
|
97 | async fn add_from_url(&self, url: &str, collection: Option<&str>) -> Result<String>;
| ^^^^^
|
= note: you can suppress this lint if you plan to use the trait only in your own code, or do not care about auto traits like `Send` on the `Future`
= note: `#[warn(async_fn_in_trait)]` on by default
help: you can alternatively desugar to a normal `fn` that returns `impl Future` and add any desired bounds such as `Send`, but these cannot be relaxed without a breaking API change
|
97 - async fn add_from_url(&self, url: &str, collection: Option<&str>) -> Result<String>;
97 + fn add_from_url(&self, url: &str, collection: Option<&str>) -> impl std::future::Future<Output = Result<String>> + Send;
|
warning: use of `async fn` in public traits is discouraged as auto trait bounds cannot be specified
--> pmocache/src/cache_trait.rs:111:5
|
111 | async fn add_from_file(&self, path: &str, collection: Option<&str>) -> Result<String>;
| ^^^^^
|
= note: you can suppress this lint if you plan to use the trait only in your own code, or do not care about auto traits like `Send` on the `Future`
help: you can alternatively desugar to a normal `fn` that returns `impl Future` and add any desired bounds such as `Send`, but these cannot be relaxed without a breaking API change
|
111 - async fn add_from_file(&self, path: &str, collection: Option<&str>) -> Result<String>;
111 + fn add_from_file(&self, path: &str, collection: Option<&str>) -> impl std::future::Future<Output = Result<String>> + Send;
|
warning: use of `async fn` in public traits is discouraged as auto trait bounds cannot be specified
--> pmocache/src/cache_trait.rs:118:5
|
118 | async fn get(&self, pk: &str) -> Result<PathBuf>;
| ^^^^^
|
= note: you can suppress this lint if you plan to use the trait only in your own code, or do not care about auto traits like `Send` on the `Future`
help: you can alternatively desugar to a normal `fn` that returns `impl Future` and add any desired bounds such as `Send`, but these cannot be relaxed without a breaking API change
|
118 - async fn get(&self, pk: &str) -> Result<PathBuf>;
118 + fn get(&self, pk: &str) -> impl std::future::Future<Output = Result<PathBuf>> + Send;
|
warning: use of `async fn` in public traits is discouraged as auto trait bounds cannot be specified
--> pmocache/src/cache_trait.rs:125:5
|
125 | async fn get_collection(&self, collection: &str) -> Result<Vec<PathBuf>>;
| ^^^^^
|
= note: you can suppress this lint if you plan to use the trait only in your own code, or do not care about auto traits like `Send` on the `Future`
help: you can alternatively desugar to a normal `fn` that returns `impl Future` and add any desired bounds such as `Send`, but these cannot be relaxed without a breaking API change
|
125 - async fn get_collection(&self, collection: &str) -> Result<Vec<PathBuf>>;
125 + fn get_collection(&self, collection: &str) -> impl std::future::Future<Output = Result<Vec<PathBuf>>> + Send;
|
warning: use of `async fn` in public traits is discouraged as auto trait bounds cannot be specified
--> pmocache/src/cache_trait.rs:128:5
|
128 | async fn purge(&self) -> Result<()>;
| ^^^^^
|
= note: you can suppress this lint if you plan to use the trait only in your own code, or do not care about auto traits like `Send` on the `Future`
help: you can alternatively desugar to a normal `fn` that returns `impl Future` and add any desired bounds such as `Send`, but these cannot be relaxed without a breaking API change
|
128 - async fn purge(&self) -> Result<()>;
128 + fn purge(&self) -> impl std::future::Future<Output = Result<()>> + Send;
|
warning: use of `async fn` in public traits is discouraged as auto trait bounds cannot be specified
--> pmocache/src/cache_trait.rs:131:5
|
131 | async fn consolidate(&self) -> Result<()>;
| ^^^^^
|
= note: you can suppress this lint if you plan to use the trait only in your own code, or do not care about auto traits like `Send` on the `Future`
help: you can alternatively desugar to a normal `fn` that returns `impl Future` and add any desired bounds such as `Send`, but these cannot be relaxed without a breaking API change
|
131 - async fn consolidate(&self) -> Result<()>;
131 + fn consolidate(&self) -> impl std::future::Future<Output = Result<()>> + Send;
|
warning: use of `async fn` in public traits is discouraged as auto trait bounds cannot be specified
--> pmocache/src/cache_trait.rs:147:5
|
147 | async fn is_valid_pk(&self, pk: &str) -> bool {
| ^^^^^
|
= note: you can suppress this lint if you plan to use the trait only in your own code, or do not care about auto traits like `Send` on the `Future`
help: you can alternatively desugar to a normal `fn` that returns `impl Future` and add any desired bounds such as `Send`, but these cannot be relaxed without a breaking API change
|
147 ~ fn is_valid_pk(&self, pk: &str) -> impl std::future::Future<Output = bool> + Send {async {
148 | if self.get_database().get(pk, false).is_err() {
...
218 | false
219 ~ } }
|
warning: use of `async fn` in public traits is discouraged as auto trait bounds cannot be specified
--> pmocache/src/pmoserver_ext.rs:391:5
|
391 | async fn init_generic_cache<C: CacheConfig + 'static>(
| ^^^^^
|
= note: you can suppress this lint if you plan to use the trait only in your own code, or do not care about auto traits like `Send` on the `Future`
help: you can alternatively desugar to a normal `fn` that returns `impl Future` and add any desired bounds such as `Send`, but these cannot be relaxed without a breaking API change
|
391 ~ fn init_generic_cache<C: CacheConfig + 'static>(
392 | &mut self,
...
395 | content_type: &'static str,
396 ~ ) -> impl std::future::Future<Output = anyhow::Result<Arc<Cache<C>>>> + Send;
|
warning: `pmocache` (lib) generated 9 warnings (run `cargo fix --lib -p pmocache` to apply 1 suggestion)
warning: unused import: `serde_json::Value`
--> pmoaudiocache/src/cache.rs:11:5
|
11 | use serde_json::Value;
| ^^^^^^^^^^^^^^^^^
|
= note: `#[warn(unused_imports)]` (part of `#[warn(unused)]`) on by default
warning: unused import: `pmometadata::TrackMetadata`
--> pmoaudiocache/src/api.rs:11:5
|
11 | use pmometadata::TrackMetadata;
| ^^^^^^^^^^^^^^^^^^^^^^^^^^
warning: use of `async fn` in public traits is discouraged as auto trait bounds cannot be specified
--> pmoaudiocache/src/lib.rs:188:5
|
188 | async fn init_audio_cache(
| ^^^^^
|
= note: you can suppress this lint if you plan to use the trait only in your own code, or do not care about auto traits like `Send` on the `Future`
= note: `#[warn(async_fn_in_trait)]` on by default
help: you can alternatively desugar to a normal `fn` that returns `impl Future` and add any desired bounds such as `Send`, but these cannot be relaxed without a breaking API change
|
188 ~ fn init_audio_cache(
189 | &mut self,
190 | cache_dir: &str,
191 | limit: usize,
192 ~ ) -> impl std::future::Future<Output = anyhow::Result<std::sync::Arc<Cache>>> + Send;
|
warning: use of `async fn` in public traits is discouraged as auto trait bounds cannot be specified
--> pmoaudiocache/src/lib.rs:197:5
|
197 | async fn init_audio_cache_configured(&mut self) -> anyhow::Result<std::sync::Arc<Cache>>;
| ^^^^^
|
= note: you can suppress this lint if you plan to use the trait only in your own code, or do not care about auto traits like `Send` on the `Future`
help: you can alternatively desugar to a normal `fn` that returns `impl Future` and add any desired bounds such as `Send`, but these cannot be relaxed without a breaking API change
|
197 - async fn init_audio_cache_configured(&mut self) -> anyhow::Result<std::sync::Arc<Cache>>;
197 + fn init_audio_cache_configured(&mut self) -> impl std::future::Future<Output = anyhow::Result<std::sync::Arc<Cache>>> + Send;
|
warning: use of `async fn` in public traits is discouraged as auto trait bounds cannot be specified
--> pmoaudiocache/src/metadata_ext.rs:36:5
|
36 | async fn get_title(&self, pk: &str) -> anyhow::Result<Option<String>>;
| ^^^^^
|
= note: you can suppress this lint if you plan to use the trait only in your own code, or do not care about auto traits like `Send` on the `Future`
help: you can alternatively desugar to a normal `fn` that returns `impl Future` and add any desired bounds such as `Send`, but these cannot be relaxed without a breaking API change
|
36 - async fn get_title(&self, pk: &str) -> anyhow::Result<Option<String>>;
36 + fn get_title(&self, pk: &str) -> impl std::future::Future<Output = anyhow::Result<Option<String>>> + Send;
|
warning: use of `async fn` in public traits is discouraged as auto trait bounds cannot be specified
--> pmoaudiocache/src/metadata_ext.rs:37:5
|
37 | async fn get_artist(&self, pk: &str) -> anyhow::Result<Option<String>>;
| ^^^^^
|
= note: you can suppress this lint if you plan to use the trait only in your own code, or do not care about auto traits like `Send` on the `Future`
help: you can alternatively desugar to a normal `fn` that returns `impl Future` and add any desired bounds such as `Send`, but these cannot be relaxed without a breaking API change
|
37 - async fn get_artist(&self, pk: &str) -> anyhow::Result<Option<String>>;
37 + fn get_artist(&self, pk: &str) -> impl std::future::Future<Output = anyhow::Result<Option<String>>> + Send;
|
warning: use of `async fn` in public traits is discouraged as auto trait bounds cannot be specified
--> pmoaudiocache/src/metadata_ext.rs:38:5
|
38 | async fn get_album(&self, pk: &str) -> anyhow::Result<Option<String>>;
| ^^^^^
|
= note: you can suppress this lint if you plan to use the trait only in your own code, or do not care about auto traits like `Send` on the `Future`
help: you can alternatively desugar to a normal `fn` that returns `impl Future` and add any desired bounds such as `Send`, but these cannot be relaxed without a breaking API change
|
38 - async fn get_album(&self, pk: &str) -> anyhow::Result<Option<String>>;
38 + fn get_album(&self, pk: &str) -> impl std::future::Future<Output = anyhow::Result<Option<String>>> + Send;
|
warning: use of `async fn` in public traits is discouraged as auto trait bounds cannot be specified
--> pmoaudiocache/src/metadata_ext.rs:39:5
|
39 | async fn get_duration_secs(&self, pk: &str) -> anyhow::Result<Option<i64>>;
| ^^^^^
|
= note: you can suppress this lint if you plan to use the trait only in your own code, or do not care about auto traits like `Send` on the `Future`
help: you can alternatively desugar to a normal `fn` that returns `impl Future` and add any desired bounds such as `Send`, but these cannot be relaxed without a breaking API change
|
39 - async fn get_duration_secs(&self, pk: &str) -> anyhow::Result<Option<i64>>;
39 + fn get_duration_secs(&self, pk: &str) -> impl std::future::Future<Output = anyhow::Result<Option<i64>>> + Send;
|
warning: `pmoaudiocache` (lib) generated 8 warnings (run `cargo fix --lib -p pmoaudiocache` to apply 1 suggestion)
warning: unused import: `tokio_stream::StreamExt`
--> pmoplaylist/src/sse.rs:16:5
|
16 | use tokio_stream::StreamExt;
| ^^^^^^^^^^^^^^^^^^^^^^^
|
= note: `#[warn(unused_imports)]` (part of `#[warn(unused)]`) on by default
warning: methods `list_playlist_ids` and `remove_by_cache_pk` are never used
--> pmoplaylist/src/persistence/mod.rs:219:18
|
17 | impl PersistenceManager {
| ----------------------- methods in this implementation
...
219 | pub async fn list_playlist_ids(&self) -> Result<Vec<String>> {
| ^^^^^^^^^^^^^^^^^
...
240 | pub async fn remove_by_cache_pk(&self, cache_pk: &str) -> Result<()> {
| ^^^^^^^^^^^^^^^^^^
|
= note: `#[warn(dead_code)]` (part of `#[warn(unused)]`) on by default
warning: function `PlaylistManager` should have a snake case name
--> pmoplaylist/src/manager.rs:620:8
|
620 | pub fn PlaylistManager() -> &'static PlaylistManager {
| ^^^^^^^^^^^^^^^ help: convert the identifier to snake case: `playlist_manager`
|
= note: `#[warn(non_snake_case)]` (part of `#[warn(nonstandard_style)]`) on by default
warning: `pmoplaylist` (lib) generated 3 warnings
warning: unused variable: `base_url`
--> pmoupnp/src/upnp_server.rs:306:13
|
306 | let base_url = self.info().base_url.clone();
| ^^^^^^^^ help: if this is intentional, prefix it with an underscore: `_base_url`
|
= note: `#[warn(unused_variables)]` (part of `#[warn(unused)]`) on by default
warning: use of `async fn` in public traits is discouraged as auto trait bounds cannot be specified
--> pmoupnp/src/upnp_api.rs:229:5
|
229 | async fn register_upnp_api(&mut self);
| ^^^^^
|
= note: you can suppress this lint if you plan to use the trait only in your own code, or do not care about auto traits like `Send` on the `Future`
= note: `#[warn(async_fn_in_trait)]` on by default
help: you can alternatively desugar to a normal `fn` that returns `impl Future` and add any desired bounds such as `Send`, but these cannot be relaxed without a breaking API change
|
229 - async fn register_upnp_api(&mut self);
229 + fn register_upnp_api(&mut self) -> impl std::future::Future<Output = ()> + Send;
|
warning: use of `async fn` in public traits is discouraged as auto trait bounds cannot be specified
--> pmoupnp/src/upnp_server.rs:96:5
|
96 | async fn register_device(
| ^^^^^
|
= note: you can suppress this lint if you plan to use the trait only in your own code, or do not care about auto traits like `Send` on the `Future`
help: you can alternatively desugar to a normal `fn` that returns `impl Future` and add any desired bounds such as `Send`, but these cannot be relaxed without a breaking API change
|
96 ~ fn register_device(
97 | &mut self,
98 | device: Arc<Device>,
99 ~ ) -> impl std::future::Future<Output = Result<Arc<DeviceInstance>, DeviceError>> + Send;
|
warning: use of `async fn` in public traits is discouraged as auto trait bounds cannot be specified
--> pmoupnp/src/upnp_server.rs:125:5
|
125 | async fn init_cover_cache(
| ^^^^^
|
= note: you can suppress this lint if you plan to use the trait only in your own code, or do not care about auto traits like `Send` on the `Future`
help: you can alternatively desugar to a normal `fn` that returns `impl Future` and add any desired bounds such as `Send`, but these cannot be relaxed without a breaking API change
|
125 ~ fn init_cover_cache(
126 | &mut self,
127 | cache_dir: &str,
128 | limit: usize,
129 ~ ) -> impl std::future::Future<Output = Result<Arc<CoverCache>, anyhow::Error>> + Send;
|
warning: use of `async fn` in public traits is discouraged as auto trait bounds cannot be specified
--> pmoupnp/src/upnp_server.rs:144:5
|
144 | async fn init_audio_cache(
| ^^^^^
|
= note: you can suppress this lint if you plan to use the trait only in your own code, or do not care about auto traits like `Send` on the `Future`
help: you can alternatively desugar to a normal `fn` that returns `impl Future` and add any desired bounds such as `Send`, but these cannot be relaxed without a breaking API change
|
144 ~ fn init_audio_cache(
145 | &mut self,
146 | cache_dir: &str,
147 | limit: usize,
148 ~ ) -> impl std::future::Future<Output = Result<Arc<AudioCache>, anyhow::Error>> + Send;
|
warning: use of `async fn` in public traits is discouraged as auto trait bounds cannot be specified
--> pmoupnp/src/upnp_server.rs:158:5
|
158 | async fn init_caches(&mut self) -> Result<(Arc<CoverCache>, Arc<AudioCache>), anyhow::Error>;
| ^^^^^
|
= note: you can suppress this lint if you plan to use the trait only in your own code, or do not care about auto traits like `Send` on the `Future`
help: you can alternatively desugar to a normal `fn` that returns `impl Future` and add any desired bounds such as `Send`, but these cannot be relaxed without a breaking API change
|
158 - async fn init_caches(&mut self) -> Result<(Arc<CoverCache>, Arc<AudioCache>), anyhow::Error>;
158 + fn init_caches(&mut self) -> impl std::future::Future<Output = Result<(Arc<CoverCache>, Arc<AudioCache>), anyhow::Error>> + Send;
|
warning: use of `async fn` in public traits is discouraged as auto trait bounds cannot be specified
--> pmoupnp/src/upnp_server.rs:225:5
|
225 | async fn create_upnp_server() -> Result<Arc<tokio::sync::RwLock<Server>>, anyhow::Error>;
| ^^^^^
|
= note: you can suppress this lint if you plan to use the trait only in your own code, or do not care about auto traits like `Send` on the `Future`
help: you can alternatively desugar to a normal `fn` that returns `impl Future` and add any desired bounds such as `Send`, but these cannot be relaxed without a breaking API change
|
225 - async fn create_upnp_server() -> Result<Arc<tokio::sync::RwLock<Server>>, anyhow::Error>;
225 + fn create_upnp_server() -> impl std::future::Future<Output = Result<Arc<tokio::sync::RwLock<Server>>, anyhow::Error>> + Send;
|
warning: `pmoupnp` (lib) generated 7 warnings
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.16s
Running `target/debug/examples/media_server_events_demo`
ControlPoint started; waiting 5s for discovery...
Discovered media servers:
- BubbleUPnP Media Server (SM-A536B) | model=BubbleUPnP Media Server | udn=uuid:d38a2dc7-13c1-4a39-ab36-513490cf6766 | location=http://192.168.0.98:58645/dev/d38a2dc7-13c1-4a39-ab36-513490cf6766/desc.xml
- fenice | model=Jellyfin Server | udn=uuid:526dedec-fde2-4224-bac6-06f7b11711cf | location=http://192.168.0.34:8096/dlna/526dedec-fde2-4224-bac6-06f7b11711cf/description.xml
- PMOMusic Media Server | model=PMOMusic Media Server | udn=uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4 | location=http://192.168.0.138:8080/device/17fe2ea6-8908-4e30-bc52-b28ea4cab3e4/desc.xml
- Freebox Server | model=Freebox Media Server | udn=uuid:e929a46e-d218-377d-2dde-32bd8080dfbf | location=http://192.168.0.254:52424/device.xml
Listening for ContentDirectory events for 90 seconds...
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Global content update (SystemUpdateID=27)
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Containers updated: radio-paradise:channel:main:history
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Global content update (SystemUpdateID=28)
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Containers updated: radio-paradise:channel:main:liveplaylist
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Global content update (SystemUpdateID=30)
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Containers updated: radio-paradise:channel:main:liveplaylist
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Global content update (SystemUpdateID=31)
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Containers updated: radio-paradise:channel:main:liveplaylist
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Global content update (SystemUpdateID=32)
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Containers updated: radio-paradise:channel:main:liveplaylist
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Global content update (SystemUpdateID=33)
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Containers updated: radio-paradise:channel:main:liveplaylist
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Global content update (SystemUpdateID=37)
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Containers updated: radio-paradise:channel:main:liveplaylist
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Global content update (SystemUpdateID=40)
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Containers updated: radio-paradise:channel:main:liveplaylist
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Global content update (SystemUpdateID=41)
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Containers updated: radio-paradise:channel:main:liveplaylist
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Global content update (SystemUpdateID=42)
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Containers updated: radio-paradise:channel:main:liveplaylist
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Global content update (SystemUpdateID=44)
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Containers updated: radio-paradise:channel:main:liveplaylist
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Global content update (SystemUpdateID=45)
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Containers updated: radio-paradise:channel:main:liveplaylist
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Global content update (SystemUpdateID=46)
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Containers updated: radio-paradise:channel:main:liveplaylist
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Global content update (SystemUpdateID=49)
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Containers updated: radio-paradise:channel:rock:liveplaylist
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Global content update (SystemUpdateID=50)
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Containers updated: radio-paradise:channel:rock:liveplaylist
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Global content update (SystemUpdateID=51)
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Containers updated: radio-paradise:channel:rock:liveplaylist
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Global content update (SystemUpdateID=52)
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Containers updated: radio-paradise:channel:rock:liveplaylist
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Global content update (SystemUpdateID=53)
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Containers updated: radio-paradise:channel:rock:liveplaylist
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Global content update (SystemUpdateID=54)
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Containers updated: radio-paradise:channel:rock:liveplaylist
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Global content update (SystemUpdateID=55)
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Containers updated: radio-paradise:channel:rock:liveplaylist
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Global content update (SystemUpdateID=56)
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Containers updated: radio-paradise:channel:rock:liveplaylist
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Global content update (SystemUpdateID=57)
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Containers updated: radio-paradise:channel:rock:liveplaylist
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Global content update (SystemUpdateID=59)
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Containers updated: radio-paradise:channel:rock:liveplaylist
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Global content update (SystemUpdateID=63)
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Containers updated: radio-paradise:channel:rock:liveplaylist
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Global content update (SystemUpdateID=64)
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Containers updated: radio-paradise:channel:rock:liveplaylist
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Global content update (SystemUpdateID=65)
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Containers updated: radio-paradise:channel:rock:liveplaylist
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Global content update (SystemUpdateID=66)
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Containers updated: radio-paradise:channel:rock:liveplaylist
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Global content update (SystemUpdateID=67)
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Containers updated: radio-paradise:channel:rock:liveplaylist
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Global content update (SystemUpdateID=68)
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Containers updated: radio-paradise:channel:rock:liveplaylist
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Global content update (SystemUpdateID=69)
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Containers updated: radio-paradise:channel:rock:liveplaylist
Monitoring finished.

View File

@@ -0,0 +1,111 @@
use std::collections::HashMap;
use std::thread;
use std::time::{Duration, Instant};
use anyhow::Result;
use crossbeam_channel::RecvTimeoutError;
use pmocontrol::{ControlPoint, MediaServerEvent, MediaServerInfo, ServerId};
const DISCOVERY_WAIT_SECS: u64 = 5;
const MONITOR_DURATION_SECS: u64 = 90;
fn main() -> Result<()> {
tracing_subscriber::fmt::init();
let cp = ControlPoint::spawn(5)?;
println!(
"ControlPoint started; waiting {}s for discovery...",
DISCOVERY_WAIT_SECS
);
thread::sleep(Duration::from_secs(DISCOVERY_WAIT_SECS));
let servers = cp.list_media_servers();
if servers.is_empty() {
println!("No media servers discovered.");
return Ok(());
}
println!("Discovered media servers:");
for info in &servers {
println!(
" - {} | model={} | udn={} | location={}",
info.friendly_name, info.model_name, info.udn, info.location
);
}
let mut cache: HashMap<ServerId, MediaServerInfo> = servers
.into_iter()
.map(|info| (info.id.clone(), info))
.collect();
let receiver = cp.media_server_events().subscribe();
let deadline = Instant::now() + Duration::from_secs(MONITOR_DURATION_SECS);
println!(
"Listening for ContentDirectory events for {} seconds...",
MONITOR_DURATION_SECS
);
while Instant::now() < deadline {
match receiver.recv_timeout(Duration::from_millis(500)) {
Ok(event) => {
print_event(&cp, &mut cache, &event);
}
Err(RecvTimeoutError::Timeout) => {}
Err(RecvTimeoutError::Disconnected) => {
println!("Event channel disconnected.");
break;
}
}
}
println!("Monitoring finished.");
Ok(())
}
fn print_event(cp: &ControlPoint, cache: &mut HashMap<ServerId, MediaServerInfo>, event: &MediaServerEvent) {
match event {
MediaServerEvent::GlobalUpdated {
server_id,
system_update_id,
} => {
let label = describe_server(cp, cache, server_id);
println!(
"[{}] Global content update (SystemUpdateID={})",
label,
system_update_id
.map(|id| id.to_string())
.unwrap_or_else(|| "<unknown>".into())
);
}
MediaServerEvent::ContainersUpdated {
server_id,
container_ids,
} => {
let label = describe_server(cp, cache, server_id);
println!(
"[{}] Containers updated: {}",
label,
if container_ids.is_empty() {
"<none>".into()
} else {
container_ids.join(", ")
}
);
}
}
}
fn describe_server(
cp: &ControlPoint,
cache: &mut HashMap<ServerId, MediaServerInfo>,
id: &ServerId,
) -> String {
if let Some(info) = cache.get(id) {
return format!("{} ({})", info.friendly_name, id.0);
}
if let Some(info) = cp.media_server(id) {
cache.insert(id.clone(), info.clone());
return format!("{} ({})", info.friendly_name, id.0);
}
format!("{} (unknown)", id.0)
}

View File

@@ -15,9 +15,10 @@ use crate::capabilities::{
VolumeControl,
};
use crate::discovery::DiscoveryManager;
use crate::events::RendererEventBus;
use crate::events::{MediaServerEventBus, RendererEventBus};
use crate::media_server::{MediaServerInfo, ServerId};
use crate::model::{RendererEvent, RendererId, RendererProtocol};
use crate::media_server_events::spawn_media_server_event_runtime;
use crate::model::{MediaServerEvent, RendererEvent, RendererId, RendererProtocol};
use crate::music_renderer::op_not_supported;
use crate::playback_queue::{PlaybackItem, PlaybackQueue};
use crate::provider::HttpXmlDescriptionProvider;
@@ -31,6 +32,7 @@ use crate::upnp_renderer::UpnpRenderer;
pub struct ControlPoint {
registry: Arc<RwLock<DeviceRegistry>>,
event_bus: RendererEventBus,
media_event_bus: MediaServerEventBus,
runtime: Arc<RuntimeState>,
}
@@ -41,6 +43,7 @@ impl ControlPoint {
pub fn spawn(timeout_secs: u64) -> io::Result<Self> {
let registry = Arc::new(RwLock::new(DeviceRegistry::new()));
let event_bus = RendererEventBus::new();
let media_event_bus = MediaServerEventBus::new();
let runtime = Arc::new(RuntimeState::new());
// SsdpClient
@@ -91,6 +94,7 @@ impl ControlPoint {
let runtime_cp = ControlPoint {
registry: Arc::clone(&registry),
event_bus: event_bus.clone(),
media_event_bus: media_event_bus.clone(),
runtime: Arc::clone(&runtime),
};
@@ -191,9 +195,16 @@ impl ControlPoint {
}
});
spawn_media_server_event_runtime(
Arc::clone(&registry),
media_event_bus.clone(),
timeout_secs,
)?;
Ok(Self {
registry,
event_bus,
media_event_bus,
runtime,
})
}
@@ -480,6 +491,16 @@ impl ControlPoint {
self.event_bus.subscribe()
}
/// Access the media server event bus for ContentDirectory notifications.
pub fn media_server_events(&self) -> MediaServerEventBus {
self.media_event_bus.clone()
}
/// Subscribe directly to media server events emitted by the control point.
pub fn subscribe_media_server_events(&self) -> Receiver<MediaServerEvent> {
self.media_event_bus.subscribe()
}
pub(crate) fn emit_renderer_event(&self, event: RendererEvent) {
self.handle_renderer_event(&event);
self.event_bus.broadcast(event);

View File

@@ -2,7 +2,7 @@ use std::sync::{Arc, Mutex};
use crossbeam_channel::{Receiver, Sender, unbounded};
use crate::model::RendererEvent;
use crate::model::{MediaServerEvent, RendererEvent};
#[derive(Clone, Default)]
pub(crate) struct RendererEventBus {
@@ -31,3 +31,30 @@ impl RendererEventBus {
subscribers.retain(|tx| tx.send(event.clone()).is_ok());
}
}
#[derive(Clone, Default)]
pub struct MediaServerEventBus {
subscribers: Arc<Mutex<Vec<Sender<MediaServerEvent>>>>,
}
impl MediaServerEventBus {
pub fn new() -> Self {
Self {
subscribers: Arc::new(Mutex::new(Vec::new())),
}
}
pub fn subscribe(&self) -> Receiver<MediaServerEvent> {
let (tx, rx) = unbounded::<MediaServerEvent>();
{
let mut subscribers = self.subscribers.lock().unwrap();
subscribers.push(tx);
}
rx
}
pub(crate) fn broadcast(&self, event: MediaServerEvent) {
let mut subscribers = self.subscribers.lock().unwrap();
subscribers.retain(|tx| tx.send(event.clone()).is_ok());
}
}

View File

@@ -1,4 +1,5 @@
mod events;
mod media_server_events;
pub mod arylic_tcp;
pub mod avtransport_client;
@@ -36,7 +37,9 @@ pub use rendering_control_client::RenderingControlClient;
pub use upnp_renderer::UpnpRenderer;
pub use discovery::{DeviceDescriptionProvider, DiscoveredEndpoint, DiscoveryManager};
pub use model::{RendererCapabilities, RendererEvent, RendererId, RendererInfo, RendererProtocol};
pub use model::{
MediaServerEvent, RendererCapabilities, RendererEvent, RendererId, RendererInfo, RendererProtocol,
};
pub use provider::HttpXmlDescriptionProvider;
pub use registry::{DeviceRegistry, DeviceRegistryRead, DeviceUpdate};

View File

@@ -0,0 +1,828 @@
use std::collections::{HashMap, HashSet};
use std::hash::{DefaultHasher, Hash, Hasher};
use std::io::{self, BufRead, BufReader, Read, Write};
use std::net::{IpAddr, TcpListener, TcpStream, UdpSocket};
use std::sync::{Arc, RwLock};
use std::thread;
use std::time::{Duration, Instant};
use anyhow::{Context, Result};
use crossbeam_channel::{Receiver, Sender, unbounded};
use tracing::{debug, info, warn};
use ureq::{Agent, http};
use xmltree::{Element, XMLNode};
use crate::events::MediaServerEventBus;
use crate::media_server::{MediaServerInfo, ServerId};
use crate::model::MediaServerEvent;
use crate::provider::resolve_control_url;
use crate::registry::{DeviceRegistry, DeviceRegistryRead};
const SUBSCRIPTION_TIMEOUT_SECS: u64 = 300;
const RENEWAL_SAFETY_MARGIN_SECS: u64 = 60;
/// Launch the media server event runtime responsible for subscribing
/// to ContentDirectory updates and forwarding notifications on the bus.
pub(crate) fn spawn_media_server_event_runtime(
registry: Arc<RwLock<DeviceRegistry>>,
bus: MediaServerEventBus,
timeout_secs: u64,
) -> io::Result<()> {
let listener = TcpListener::bind("0.0.0.0:0")?;
let listener_addr = listener
.local_addr()
.context("Failed to read listener address")
.map_err(io_from_anyhow)?;
info!(
"MediaServer event listener bound on {}",
listener_addr
);
let (notify_tx, notify_rx) = unbounded::<IncomingNotify>();
thread::Builder::new()
.name("media-server-event-http".into())
.spawn(move || run_http_listener(listener, notify_tx))?;
let worker = MediaServerEventWorker::new(
registry,
bus,
Duration::from_secs(timeout_secs.max(1)),
notify_rx,
listener_addr.port(),
);
thread::Builder::new()
.name("media-server-event-worker".into())
.spawn(move || worker.run())
.map(|_| ())
}
fn io_from_anyhow(err: anyhow::Error) -> io::Error {
io::Error::new(io::ErrorKind::Other, err)
}
struct IncomingNotify {
path: String,
sid: Option<String>,
body: Vec<u8>,
}
fn run_http_listener(listener: TcpListener, notify_tx: Sender<IncomingNotify>) {
for stream in listener.incoming() {
match stream {
Ok(mut stream) => {
if let Err(err) = stream.set_read_timeout(Some(Duration::from_secs(5))) {
warn!("Failed to set read timeout on notify connection: {}", err);
}
match read_http_request(&mut stream) {
Ok(request) => {
if request.method != "NOTIFY" {
let _ = write_http_response(&mut stream, 405, "Method Not Allowed");
continue;
}
let notify = IncomingNotify {
path: request.path,
sid: request
.headers
.get("sid")
.cloned(),
body: request.body,
};
if notify_tx.send(notify).is_err() {
warn!("Dropping notify event because worker channel is closed");
}
let _ = write_http_response(&mut stream, 200, "OK");
}
Err(err) => {
warn!("Failed to parse incoming notify request: {}", err);
let _ = write_http_response(&mut stream, 400, "Bad Request");
}
}
}
Err(err) => {
warn!("Incoming notify connection failed: {}", err);
}
}
}
}
struct HttpRequest {
method: String,
path: String,
headers: HashMap<String, String>,
body: Vec<u8>,
}
fn read_http_request(stream: &mut TcpStream) -> io::Result<HttpRequest> {
let mut reader = BufReader::new(stream.try_clone()?);
let mut request_line = String::new();
if reader.read_line(&mut request_line)? == 0 {
return Err(io::Error::new(
io::ErrorKind::UnexpectedEof,
"missing request line",
));
}
let request_line = request_line.trim_end_matches(&['\r', '\n'][..]);
let mut parts = request_line.split_whitespace();
let method = parts
.next()
.ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "missing method"))?
.to_ascii_uppercase();
let path = parts
.next()
.ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "missing path"))?
.to_string();
// Headers
let mut headers = HashMap::new();
loop {
let mut line = String::new();
let len = reader.read_line(&mut line)?;
if len == 0 {
break;
}
let trimmed = line.trim_end_matches(&['\r', '\n'][..]);
if trimmed.is_empty() {
break;
}
if let Some((name, value)) = trimmed.split_once(':') {
headers.insert(
name.trim().to_ascii_lowercase(),
value.trim().to_string(),
);
}
}
let content_length: usize = headers
.get("content-length")
.and_then(|v| v.parse().ok())
.unwrap_or(0);
let mut body = vec![0u8; content_length];
reader.read_exact(&mut body)?;
Ok(HttpRequest {
method,
path,
headers,
body,
})
}
fn write_http_response(stream: &mut TcpStream, status: u16, message: &str) -> io::Result<()> {
let response = format!(
"HTTP/1.1 {} {}\r\nContent-Length: 0\r\nConnection: close\r\n\r\n",
status, message
);
stream.write_all(response.as_bytes())
}
struct MediaServerEventWorker {
registry: Arc<RwLock<DeviceRegistry>>,
bus: MediaServerEventBus,
http_timeout: Duration,
notify_rx: Receiver<IncomingNotify>,
listener_port: u16,
subscriptions: HashMap<ServerId, SubscriptionState>,
path_index: HashMap<String, ServerId>,
}
impl MediaServerEventWorker {
fn new(
registry: Arc<RwLock<DeviceRegistry>>,
bus: MediaServerEventBus,
http_timeout: Duration,
notify_rx: Receiver<IncomingNotify>,
listener_port: u16,
) -> Self {
Self {
registry,
bus,
http_timeout,
notify_rx,
listener_port,
subscriptions: HashMap::new(),
path_index: HashMap::new(),
}
}
fn run(mut self) {
loop {
self.drain_notifications();
self.refresh_servers();
self.renew_expiring();
thread::sleep(Duration::from_millis(250));
}
}
fn drain_notifications(&mut self) {
while let Ok(notify) = self.notify_rx.try_recv() {
self.handle_notification(notify);
}
}
fn refresh_servers(&mut self) {
let server_infos = {
let reg = self.registry.read().unwrap();
reg.list_servers()
};
let mut active: HashSet<ServerId> = HashSet::new();
for info in server_infos {
if !info.online || !info.has_content_directory {
continue;
}
active.insert(info.id.clone());
let entry = self
.subscriptions
.entry(info.id.clone())
.or_insert_with(|| SubscriptionState::new(info.clone()));
entry.update(info);
self.path_index
.insert(entry.callback_path.clone(), entry.info.id.clone());
if entry.event_sub_url.is_none() {
if entry.should_retry() {
match fetch_event_sub_url(&entry.info.location, self.http_timeout) {
Ok(Some(url)) => {
debug!(
server = entry.info.friendly_name.as_str(),
callback = url.as_str(),
"ContentDirectory eventSub URL resolved"
);
entry.event_sub_url = Some(url);
entry.retry_after = Instant::now();
}
Ok(None) => {
debug!(
server = entry.info.friendly_name.as_str(),
"No ContentDirectory eventSub URL found"
);
entry.defer_retry();
continue;
}
Err(err) => {
warn!(
server = entry.info.friendly_name.as_str(),
error = %err,
"Failed to fetch ContentDirectory eventSub URL"
);
entry.defer_retry();
continue;
}
}
} else {
continue;
}
}
if entry.sid.is_none() && entry.should_retry() {
if let Err(err) =
Self::subscribe_entry(self.listener_port, self.http_timeout, entry)
{
warn!(
server = entry.info.friendly_name.as_str(),
error = %err,
"ContentDirectory SUBSCRIBE failed"
);
entry.defer_retry();
}
}
}
let stale_ids: Vec<ServerId> = self
.subscriptions
.keys()
.filter(|id| !active.contains(*id))
.cloned()
.collect();
for id in stale_ids {
if let Some(mut entry) = self.subscriptions.remove(&id) {
self.path_index.remove(&entry.callback_path);
Self::unsubscribe_entry(self.http_timeout, &mut entry);
}
}
}
fn renew_expiring(&mut self) {
let now = Instant::now();
let mut to_renew = Vec::new();
for (id, entry) in self.subscriptions.iter() {
if let Some(exp) = entry.expires_at {
if exp <= now + Duration::from_secs(RENEWAL_SAFETY_MARGIN_SECS) {
to_renew.push(id.clone());
}
}
}
for id in to_renew {
if let Some(entry) = self.subscriptions.get_mut(&id) {
if let Err(err) = Self::renew_entry(self.http_timeout, entry) {
warn!(
server = entry.info.friendly_name.as_str(),
error = %err,
"Failed to renew ContentDirectory subscription"
);
entry.reset_subscription();
}
}
}
}
fn subscribe_entry(
listener_port: u16,
http_timeout: Duration,
entry: &mut SubscriptionState,
) -> Result<()> {
let event_url = entry
.event_sub_url
.as_ref()
.context("EventSub URL missing for server")?;
let (remote_host, remote_port) =
parse_host_port(event_url).context("Cannot extract host for SUBSCRIBE")?;
let local_ip = determine_local_ip(&remote_host, remote_port)
.context("Cannot determine local IP for callback")?;
let callback_url =
format!("http://{}:{}{}", format_ip(&local_ip), listener_port, entry.callback_path);
debug!(
server = entry.info.friendly_name.as_str(),
callback = callback_url.as_str(),
"Subscribing to ContentDirectory events"
);
let host_header = format!("{}:{}", remote_host, remote_port);
let timeout_header = format!("Second-{}", SUBSCRIPTION_TIMEOUT_SECS);
let callback_header = format!("<{}>", callback_url);
let request = http::Request::builder()
.method("SUBSCRIBE")
.uri(event_url)
.header("HOST", host_header)
.header("CALLBACK", callback_header)
.header("NT", "upnp:event")
.header("TIMEOUT", timeout_header)
.body(())
.map_err(anyhow::Error::new)?;
let response = build_agent(http_timeout).run(request)?;
if !response.status().is_success() {
anyhow::bail!("SUBSCRIBE returned HTTP {}", response.status());
}
let sid = response
.headers()
.get("SID")
.and_then(|value| value.to_str().ok())
.map(|s| s.to_string())
.ok_or_else(|| anyhow::anyhow!("SUBSCRIBE response missing SID"))?;
let timeout = parse_timeout(
response
.headers()
.get("TIMEOUT")
.and_then(|value| value.to_str().ok()),
)
.unwrap_or(Duration::from_secs(SUBSCRIPTION_TIMEOUT_SECS));
entry.sid = Some(sid);
entry.expires_at = Some(Instant::now() + timeout);
entry.retry_after = Instant::now() + Duration::from_secs(5);
info!(
server = entry.info.friendly_name.as_str(),
"Subscribed to ContentDirectory events (timeout {}s)",
timeout.as_secs()
);
Ok(())
}
fn renew_entry(http_timeout: Duration, entry: &mut SubscriptionState) -> Result<()> {
let event_url = entry
.event_sub_url
.as_ref()
.context("EventSub URL missing for renew")?;
let sid = entry
.sid
.as_ref()
.cloned()
.context("SID missing for renew")?;
let (remote_host, remote_port) =
parse_host_port(event_url).context("Cannot extract host for renew")?;
let host_header = format!("{}:{}", remote_host, remote_port);
let timeout_header = format!("Second-{}", SUBSCRIPTION_TIMEOUT_SECS);
let request = http::Request::builder()
.method("SUBSCRIBE")
.uri(event_url)
.header("HOST", host_header)
.header("TIMEOUT", timeout_header)
.header("SID", sid.clone())
.body(())
.map_err(anyhow::Error::new)?;
let response = build_agent(http_timeout).run(request)?;
if !response.status().is_success() {
anyhow::bail!("SUBSCRIBE renewal failed with {}", response.status());
}
let timeout = parse_timeout(
response
.headers()
.get("TIMEOUT")
.and_then(|value| value.to_str().ok()),
)
.unwrap_or(Duration::from_secs(SUBSCRIPTION_TIMEOUT_SECS));
entry.expires_at = Some(Instant::now() + timeout);
debug!(
server = entry.info.friendly_name.as_str(),
"Renewed ContentDirectory subscription"
);
Ok(())
}
fn unsubscribe_entry(http_timeout: Duration, entry: &mut SubscriptionState) {
let Some(event_url) = entry.event_sub_url.as_ref() else {
return;
};
let Some(sid) = entry.sid.take() else {
return;
};
let Some((remote_host, remote_port)) = parse_host_port(event_url) else {
return;
};
let host_header = format!("{}:{}", remote_host, remote_port);
let request = match http::Request::builder()
.method("UNSUBSCRIBE")
.uri(event_url)
.header("HOST", host_header)
.header("SID", sid)
.body(())
.map_err(anyhow::Error::new)
{
Ok(req) => req,
Err(err) => {
warn!(
server = entry.info.friendly_name.as_str(),
error = %err,
"Failed to build UNSUBSCRIBE request"
);
return;
}
};
match build_agent(http_timeout).run(request) {
Ok(response) => {
if response.status().is_success() {
debug!(
server = entry.info.friendly_name.as_str(),
"Unsubscribed from ContentDirectory events"
);
} else {
warn!(
server = entry.info.friendly_name.as_str(),
status = %response.status(),
"UNSUBSCRIBE returned non-success status"
);
}
}
Err(err) => {
warn!(
server = entry.info.friendly_name.as_str(),
error = %err,
"UNSUBSCRIBE request failed"
);
}
}
}
fn handle_notification(&mut self, notify: IncomingNotify) {
let Some(server_id) = self.path_index.get(&notify.path).cloned() else {
debug!("Dropping notify for unknown path {}", notify.path);
return;
};
let Some(entry) = self.subscriptions.get(&server_id) else {
return;
};
if let (Some(expected), Some(received)) = (&entry.sid, &notify.sid) {
if !expected.eq_ignore_ascii_case(received) {
debug!(
server = entry.info.friendly_name.as_str(),
expected_sid = expected.as_str(),
received_sid = received.as_str(),
"Ignoring notify with mismatched SID"
);
return;
}
}
for event in parse_notify_payload(&entry.info.id, &notify.body) {
match &event {
MediaServerEvent::GlobalUpdated {
system_update_id, ..
} => {
debug!(
server = entry.info.friendly_name.as_str(),
update_id = system_update_id.unwrap_or_default(),
"Broadcasting MediaServerEvent::GlobalUpdated"
);
}
MediaServerEvent::ContainersUpdated { container_ids, .. } => {
debug!(
server = entry.info.friendly_name.as_str(),
changed_containers = container_ids.join(",").as_str(),
"Broadcasting MediaServerEvent::ContainersUpdated"
);
}
}
self.bus.broadcast(event);
}
}
}
struct SubscriptionState {
info: MediaServerInfo,
event_sub_url: Option<String>,
sid: Option<String>,
expires_at: Option<Instant>,
callback_path: String,
retry_after: Instant,
}
impl SubscriptionState {
fn new(info: MediaServerInfo) -> Self {
Self {
callback_path: build_callback_path(&info.id),
info,
event_sub_url: None,
sid: None,
expires_at: None,
retry_after: Instant::now(),
}
}
fn update(&mut self, info: MediaServerInfo) {
if self.info.location != info.location {
self.event_sub_url = None;
self.sid = None;
self.expires_at = None;
self.retry_after = Instant::now();
}
self.info = info;
}
fn should_retry(&self) -> bool {
Instant::now() >= self.retry_after
}
fn defer_retry(&mut self) {
self.retry_after = Instant::now() + Duration::from_secs(15);
}
fn reset_subscription(&mut self) {
self.sid = None;
self.expires_at = None;
self.retry_after = Instant::now() + Duration::from_secs(5);
}
}
fn build_callback_path(id: &ServerId) -> String {
let mut sanitized = String::new();
for ch in id.0.chars() {
if ch.is_ascii_alphanumeric() {
sanitized.push(ch);
} else {
sanitized.push('_');
}
}
let mut hasher = DefaultHasher::new();
id.hash(&mut hasher);
let suffix = hasher.finish();
format!("/media-server-events/{}-{:x}", sanitized, suffix)
}
fn parse_notify_payload(server_id: &ServerId, body: &[u8]) -> Vec<MediaServerEvent> {
let mut events = Vec::new();
let reader = std::io::Cursor::new(body);
let Ok(root) = Element::parse(reader) else {
warn!(
server = server_id.0.as_str(),
"Failed to parse ContentDirectory notify payload"
);
return events;
};
let mut system_update_id: Option<u32> = None;
let mut container_ids: Vec<String> = Vec::new();
for property in root
.children
.iter()
.filter_map(|node| match node {
XMLNode::Element(elem) => Some(elem),
_ => None,
})
{
for child in property
.children
.iter()
.filter_map(|node| match node {
XMLNode::Element(elem) => Some(elem),
_ => None,
})
{
if child.name == "SystemUpdateID" {
if let Some(text) = child.get_text() {
let trimmed = text.trim();
if let Ok(value) = trimmed.parse::<u32>() {
system_update_id = Some(value);
} else {
system_update_id = None;
}
}
} else if child.name == "ContainerUpdateIDs" {
if let Some(text) = child.get_text() {
container_ids = parse_container_update_ids(text.as_ref());
}
}
}
}
if system_update_id.is_some() {
events.push(MediaServerEvent::GlobalUpdated {
server_id: server_id.clone(),
system_update_id,
});
}
if !container_ids.is_empty() {
events.push(MediaServerEvent::ContainersUpdated {
server_id: server_id.clone(),
container_ids,
});
}
events
}
fn parse_container_update_ids(raw: &str) -> Vec<String> {
let trimmed = raw.trim();
if trimmed.is_empty() {
return Vec::new();
}
if trimmed.contains('$') {
trimmed
.split(',')
.filter_map(|part| part.split('$').next())
.map(|id| id.trim().to_string())
.filter(|id| !id.is_empty())
.collect()
} else {
let mut ids = Vec::new();
let mut tokens = trimmed.split(',').map(|t| t.trim()).filter(|t| !t.is_empty());
loop {
let Some(id) = tokens.next() else { break; };
ids.push(id.to_string());
tokens.next(); // Skip the accompanying UpdateID
}
ids
}
}
fn child_text(element: &Element, name: &str) -> Option<String> {
for node in &element.children {
if let XMLNode::Element(child) = node {
if child.name == name {
return child.get_text().map(|cow| cow.into_owned());
}
}
}
None
}
fn fetch_event_sub_url(location: &str, timeout: Duration) -> Result<Option<String>> {
let agent = Agent::config_builder()
.timeout_global(Some(timeout))
.build();
let agent: Agent = agent.into();
let response = agent
.get(location)
.call()
.with_context(|| format!("HTTP error when fetching description at {}", location))?;
let (_parts, body) = response.into_parts();
let mut reader = BufReader::new(body.into_reader());
let root = Element::parse(&mut reader)?;
let device = match root.get_child("device") {
Some(device) => device,
None => return Ok(None),
};
let service_list = match device.get_child("serviceList") {
Some(list) => list,
None => return Ok(None),
};
for node in &service_list.children {
if let XMLNode::Element(service) = node {
let Some(service_type) = child_text(service, "serviceType") else {
continue;
};
if !service_type
.to_ascii_lowercase()
.contains("urn:schemas-upnp-org:service:contentdirectory:")
{
continue;
}
if let Some(event_sub) = child_text(service, "eventSubURL") {
return Ok(Some(resolve_control_url(location, &event_sub)));
}
}
}
Ok(None)
}
fn parse_timeout(raw: Option<&str>) -> Option<Duration> {
let value = raw?;
let lower = value.trim().to_ascii_lowercase();
if lower == "second-infinite" {
return Some(Duration::from_secs(SUBSCRIPTION_TIMEOUT_SECS));
}
if let Some(idx) = lower.find("second-") {
let number = &lower[idx + 7..];
if let Ok(seconds) = number.parse::<u64>() {
return Some(Duration::from_secs(seconds));
}
}
None
}
fn parse_host_port(url: &str) -> Option<(String, u16)> {
let default_port = if url.to_ascii_lowercase().starts_with("https://") {
443
} else {
80
};
let (_, rest) = url.split_once("://")?;
let mut parts = rest.splitn(2, '/');
let authority = parts.next()?.trim();
if authority.starts_with('[') {
let end = authority.find(']')?;
let host = &authority[1..end];
let remainder = authority.get(end + 1..).unwrap_or("");
let port = if let Some(stripped) = remainder.strip_prefix(':') {
stripped.parse().unwrap_or(default_port)
} else {
default_port
};
Some((host.to_string(), port))
} else if let Some((host, port)) = authority.split_once(':') {
Some((host.to_string(), port.parse().ok()?))
} else {
Some((authority.to_string(), default_port))
}
}
fn determine_local_ip(remote_host: &str, remote_port: u16) -> io::Result<IpAddr> {
let is_ipv6 = remote_host.contains(':') && !remote_host.contains('.');
let target = if is_ipv6 {
format!("[{}]:{}", remote_host.trim_matches(|c| c == '[' || c == ']'), remote_port)
} else {
format!("{}:{}", remote_host, remote_port)
};
let bind_addr = if is_ipv6 { "[::]:0" } else { "0.0.0.0:0" };
let socket = UdpSocket::bind(bind_addr)?;
socket.connect(&target)?;
Ok(socket.local_addr()?.ip())
}
fn format_ip(ip: &IpAddr) -> String {
match ip {
IpAddr::V4(v4) => v4.to_string(),
IpAddr::V6(v6) => format!("[{}]", v6),
}
}
fn build_agent(timeout: Duration) -> Agent {
Agent::config_builder()
.timeout_global(Some(timeout))
.http_status_as_error(false)
.allow_non_standard_methods(true)
.build()
.into()
}

View File

@@ -1,4 +1,5 @@
use crate::capabilities::{PlaybackPositionInfo, PlaybackState};
use crate::media_server::ServerId;
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
pub struct RendererId(pub String);
@@ -79,3 +80,15 @@ pub enum RendererEvent {
mute: bool,
},
}
#[derive(Clone, Debug)]
pub enum MediaServerEvent {
GlobalUpdated {
server_id: ServerId,
system_update_id: Option<u32>,
},
ContainersUpdated {
server_id: ServerId,
container_ids: Vec<String>,
},
}

View File

@@ -473,7 +473,7 @@ fn detect_renderer_protocol(caps: &RendererCapabilities) -> RendererProtocol {
///
/// - If `control_url` is already absolute (starts with http:// or https://), it is returned as-is.
/// - Otherwise, it is resolved against the scheme://host:port of `description_url`.
fn resolve_control_url(description_url: &str, control_url: &str) -> String {
pub(crate) fn resolve_control_url(description_url: &str, control_url: &str) -> String {
if control_url.starts_with("http://") || control_url.starts_with("https://") {
return control_url.to_string();
}

View File

@@ -1,9 +1,10 @@
use once_cell::sync::OnceCell;
use pmoupnp::{services::ServiceInstance, variable_types::StateValue};
use pmoupnp::{services::ServiceInstance, state_variables::StateVarInstance, variable_types::StateValue};
use std::sync::{
Arc, Mutex, Weak,
atomic::{AtomicU32, Ordering},
};
use tokio::task;
static CONTENTDIR_INSTANCE: OnceCell<Weak<ServiceInstance>> = OnceCell::new();
static SYSTEM_UPDATE_ID: AtomicU32 = AtomicU32::new(1);
@@ -43,7 +44,7 @@ fn set_system_update_id(id: u32) {
tracing::info!("ContentDirectory: SystemUpdateID -> {}", id);
if let Some(service) = CONTENTDIR_INSTANCE.get().and_then(|w| w.upgrade()) {
if let Some(var) = service.get_variable("SystemUpdateID") {
let _ = var.set_value(StateValue::UI4(id));
spawn_set_value(var, StateValue::UI4(id), "SystemUpdateID");
}
}
}
@@ -57,7 +58,24 @@ fn set_container_update_ids(value: &str) {
if let Some(service) = CONTENTDIR_INSTANCE.get().and_then(|w| w.upgrade()) {
if let Some(var) = service.get_variable("ContainerUpdateIDs") {
let _ = var.set_value(StateValue::String(value.to_string()));
spawn_set_value(
var,
StateValue::String(value.to_string()),
"ContainerUpdateIDs",
);
}
}
}
fn spawn_set_value(var: Arc<StateVarInstance>, value: StateValue, name: &str) {
let name = name.to_string();
task::spawn(async move {
if let Err(err) = var.set_value(value).await {
tracing::warn!(
variable = name.as_str(),
error = %err,
"Failed to update ContentDirectory state variable"
);
}
});
}

7970
pmoparadise_012.txt Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -6,7 +6,7 @@ use axum::{
};
use std::{
collections::HashMap,
sync::{Arc, RwLock},
sync::{Arc, RwLock}, time::Duration,
};
use tracing::info;
use xmltree::{Element, EmitterConfig, XMLNode};
@@ -17,6 +17,8 @@ use crate::{
services::ServiceInstance,
};
const DEFAULT_NOTIFY_INTERVAL: Duration = Duration::from_secs(1);
/// Instance d'un device UPnP.
///
/// Représente une instance concrète d'un device UPnP, avec ses services instanciés
@@ -304,6 +306,8 @@ impl DeviceInstance {
.register_urls(server)
.await
.map_err(|e| DeviceError::UrlRegistrationError(e.to_string()))?;
// Start the periodic notifier so buffered state changes are flushed to subscribers.
let _ = service.start_notifier(DEFAULT_NOTIFY_INTERVAL);
}
// Enregistrer les sous-devices

View File

@@ -125,6 +125,7 @@ pub struct ServiceInstance {
seqid: Arc<Mutex<HashMap<String, u32>>>,
}
impl std::fmt::Debug for ServiceInstance {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("ServiceInstance")